Compare commits

...
Author SHA1 Message Date
chanii8 bc6ee47734 feat: 이미지 뷰잉 작업 2025-04-04 18:40:30 +09:00
chanii8 b0f8638df2 feat: 사이트 반영 api 날짜 payload 수정 2025-04-04 16:24:57 +09:00
chanii8 7f4e973c5f feat: 사이트 반영 저장 api 작업 2025-04-04 15:25:34 +09:00
chanii8 528cc39831 feat: 사이트 반영 팝업 작업 2025-04-04 14:49:00 +09:00
chanii8 d84bfe2a7c fix: 스토리북 "Failed to fetch dynamically imported module" 에러 해결 2025-04-04 11:05:57 +09:00
chanii8 badb682163 refactor: 사이트 반영 폼 리팩토링 2025-04-03 18:39:54 +09:00
chanii8 e659f47d1d feat: 사이트 반영 폼 작업 2025-04-03 17:37:35 +09:00
chanii8 e3454e11d5 feat: 사이트 게시 이력 api 작업 2025-04-03 11:40:00 +09:00
chanii8 6df605a184 feat: loading ui 적용 2025-04-03 09:37:25 +09:00
chanii8 483e0b319a chore: 이미지 업로드 안내 문구 추가 2025-04-02 17:47:14 +09:00
chanii8 623b1e985e refactor: 로그인 완료 시 하위 모든 페이지 리렌더링 작업 2025-04-02 17:34:47 +09:00
chanii8 84d4635a75 feat: 상황판 페이지 추가 2025-04-02 17:34:13 +09:00
chanii8 0a51ac3f79 refactor: QueryClientProvider 선언 위치 변경 2025-04-02 17:19:16 +09:00
chanii8 4e4bd82009 refactor: menu wrapper에서 useQuery 제거 2025-04-02 15:33:51 +09:00
chanii8 a9739bdba5 feat: 이미지 등록 작업 2025-04-02 14:49:35 +09:00
chanii8 1d132a86de feat: 이미지 업데이트 및 삭제 작업 2025-04-02 14:07:36 +09:00
chanii8 6297d528fb feat: 이미지 등록 폼 작업 2025-04-02 13:22:11 +09:00
chanii8 3832adf3b0 chore: label 컴포넌트 적용 2025-04-02 10:58:02 +09:00
chanii8 60464b92b0 feat: 이미지 등록 폼 작업 2025-04-01 17:43:46 +09:00
chanii8 a912deeefb feat: 이미지 목록 페이지 작업 2025-04-01 15:40:38 +09:00
chanii8 f2954e6ebd refactor: page layout 공통 적용 2025-04-01 14:25:17 +09:00
68 changed files with 2059 additions and 611 deletions
@@ -0,0 +1,2 @@
import { DashboardPage } from "@/pages/dashboard";
export default DashboardPage;
@@ -0,0 +1,10 @@
import { ModifyPage as VModifyPage } from "@/pages/image";
export default async function ModifyPage({
params,
}: {
params: Promise<{ serial: string }>;
}) {
const { serial } = await params;
return <VModifyPage serial={serial} />;
}
@@ -0,0 +1,2 @@
import { CreatePage } from "@/pages/image";
export default CreatePage;
+11 -3
View File
@@ -1,10 +1,12 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import type { MenuItem } from "@common/components/api";
import { BaseLayout } from "@common/components/ui";
import { MenuWrapper } from "@/entities/menu";
import { useMenuStore } from "@/shared/lib";
import { PageLayout } from "@/shared/ui/page-layout";
export default function WithBaseLayout({
children,
}: {
@@ -12,6 +14,8 @@ export default function WithBaseLayout({
}) {
const router = useRouter();
const pathname = usePathname();
const queryClient = useQueryClient();
const { setCurrentMenuInfo } = useMenuStore((state) => state);
const handleMenuClick = (item: MenuItem) => {
@@ -20,8 +24,12 @@ export default function WithBaseLayout({
};
return (
<BaseLayout currentPath={pathname ?? ""} handleMenuClick={handleMenuClick}>
<MenuWrapper>{children}</MenuWrapper>
<BaseLayout
queryClient={queryClient}
currentPath={pathname ?? ""}
handleMenuClick={handleMenuClick}
>
<PageLayout>{children}</PageLayout>
</BaseLayout>
);
}
@@ -0,0 +1,2 @@
import { SkeletonPage } from "@/shared/ui/skeleton-page";
export default SkeletonPage;
+11 -1
View File
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { CLAIM_NAME, AUTH_USER_ID } from "./src/shared/config";
const serverApiUrl =
process.env.NEXT_PUBLIC_SERVER_API_URL ?? "http://localhost:3000";
@@ -16,8 +17,17 @@ export function middleware(req: NextRequest) {
}
if (pathname.startsWith("/attach-service")) {
const requestHeaders = new Headers(req.headers);
const token = req.cookies.get(CLAIM_NAME)?.value;
const userId = req.cookies.get(AUTH_USER_ID)?.value;
const newUrl = `${serverApiUrl}${pathname}`;
return NextResponse.rewrite(new URL(newUrl));
if (token) requestHeaders.set(CLAIM_NAME, token);
if (userId) requestHeaders.set(AUTH_USER_ID, userId);
return NextResponse.rewrite(new URL(newUrl), {
headers: requestHeaders,
});
}
return NextResponse.next();
+15 -8
View File
@@ -1,20 +1,27 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ConfirmProvider } from "@frontend-opensource/use-react-hooks";
import { AuthWrapper } from "@/entities/auth";
import { MenuWrapper } from "@/entities/menu";
import { StoreProvider } from "@/shared/providers";
import { AlertDialog } from "@/shared/ui/alert-dialog";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => {
const queryClient = new QueryClient();
return (
<StoreProvider>
<ConfirmProvider>
<AuthWrapper>
{children}
<AlertDialog />
</AuthWrapper>
</ConfirmProvider>
</StoreProvider>
<QueryClientProvider client={queryClient}>
<StoreProvider>
<ConfirmProvider>
<AuthWrapper>
<MenuWrapper>{children}</MenuWrapper>
<AlertDialog />
<ConfirmDialog />
</AuthWrapper>
</ConfirmProvider>
</StoreProvider>
</QueryClientProvider>
);
};
@@ -0,0 +1,17 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
Attachment,
AttachmentService,
} from "../model/attachment-interface";
export class AttachmentApiService implements AttachmentService {
public async getAttachments(atflCd: string): Promise<Attachment[]> {
const response = await client.get<Attachment[]>(
`${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}/${atflCd}`,
);
return response.data;
}
}
export const AttachmentApi = new AttachmentApiService();
+1
View File
@@ -0,0 +1 @@
export { AttachmentApi } from "./api/attachment-service";
@@ -0,0 +1,18 @@
export interface Attachment {
atflCd: string;
atflSn: number;
atflId: string;
physFileNm: string;
orgnlFileNm: string;
atflFlieSz: number;
atflType: string;
dwnldNocs: number;
delYn: string;
lnkgDmnId: string;
lnkgDmnNm: string;
regDt: string;
}
export interface AttachmentService {
getAttachments: (atflCd: string) => Promise<Attachment[]>;
}
+10 -11
View File
@@ -2,27 +2,26 @@
import { useEffect } from "react";
import { usePathname } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { ENDPOINT, SITE_ID } from "@/shared/config";
import { SITE_ID } from "@/shared/config";
import { useMenuStore } from "@/shared/lib";
import { MenuApi } from "../api/menu-service";
import { getCurrentMenu } from "./utils";
const MenuWrapper = ({ children }: { children: React.ReactNode }) => {
const pathname = usePathname();
const { data: menuRoles } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.MENU_ROLES],
queryFn: () => MenuApi.getMenuRoles(SITE_ID),
});
const { setCurrentMenuInfo } = useMenuStore((state) => state);
useEffect(() => {
if (!menuRoles) return;
const currentMenu = getCurrentMenu(menuRoles, pathname ?? "");
(async () => {
const menuRoles = await MenuApi.getMenuRoles(SITE_ID);
if (!menuRoles) return;
if (!currentMenu) return;
setCurrentMenuInfo(currentMenu);
}, [menuRoles]);
const currentMenu = getCurrentMenu(menuRoles, pathname ?? "");
if (!currentMenu) return;
setCurrentMenuInfo(currentMenu);
})();
}, []);
return <>{children}</>;
};
@@ -0,0 +1,31 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { PageableList } from "@/shared/model";
import type {
PostService,
PostingParams,
Post,
WithModePost,
} from "../model/posting-interface";
export class PostingApiService implements PostService {
public async getPostingHistory(
params: PostingParams,
): Promise<PageableList<Post>> {
const response = await client.get<PageableList<Post>>(
`${ENDPOINT.CMS_SERVICE.SITE_PSTGS}`,
{ params },
);
return response.data;
}
public async requestPosting(data: WithModePost[]): Promise<Post[]> {
const response = await client.post<Post[]>(
`${ENDPOINT.CMS_SERVICE.SITE_PSTGS}`,
data,
);
return response.data;
}
}
export const PostingApi = new PostingApiService();
+7
View File
@@ -0,0 +1,7 @@
export { PostingApi } from "./api/posting-service";
export {
POSTING_TYPE,
type Post,
type WithModePost,
} from "./model/posting-interface";
export { PostingDialog } from "./ui/posting-dialog";
@@ -0,0 +1,50 @@
import type { PageableList, PageableParams } from "@/shared/model";
import { z } from "zod";
export const POSTING_TYPE = {
POPUP: "POPUP",
BANNER: "BANNER",
IMAGE: "IMAGE",
} as const;
export type PostingType = (typeof POSTING_TYPE)[keyof typeof POSTING_TYPE];
export interface PostingParams extends PageableParams {
pstgTrgtNo: number;
}
export const postSchema = z.object({
siteId: z.string(),
siteNm: z.string(),
pstgTypeCd: z.string(),
pstgTypeNm: z.string(),
pstgTrgtNo: z.number(),
pstgBgngDt: z.string().optional(),
pstgEndDt: z.string().optional(),
pstgPrdSeCd: z.string(),
pstgPrdSeNm: z.string(),
delYn: z.string(),
});
export const withModePostSchema = postSchema
.extend({
mode: z.string(),
})
.omit({
siteNm: true,
pstgTypeNm: true,
pstgPrdSeNm: true,
});
export const postingFormSchema = z.object({
pstg: z.array(withModePostSchema),
});
export type Post = z.infer<typeof postSchema>;
export type WithModePost = z.infer<typeof withModePostSchema>;
export type PostingFormData = z.infer<typeof postingFormSchema>;
export interface PostService {
getPostingHistory: (params: PostingParams) => Promise<PageableList<Post>>;
requestPosting: (data: WithModePost[]) => Promise<Post[]>;
}
@@ -0,0 +1,323 @@
import dayjs from "dayjs";
import { useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useFieldArray, useForm } from "react-hook-form";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import {
Dialog,
DialogTrigger,
Button,
DialogContent,
DialogHeader,
DialogTitle,
Badge,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Checkbox,
Select,
DateRangePicker,
DialogClose,
DialogFooter,
} from "@common/components/ui";
import { cn } from "@common/components/lib";
import { CodeSelect } from "@/entities/code";
import { SiteApi, type Site } from "@/entities/site";
import type { PageableList } from "@/shared/model";
import { ENDPOINT } from "@/shared/config";
import { PostingApi } from "../api/posting-service";
import {
postingFormSchema,
type PostingFormData,
type PostingType,
type WithModePost,
} from "../model/posting-interface";
import { useAlertStore } from "@/shared/lib/use-alert-store";
type DateRange = {
from: string;
to?: string;
};
type PostingTarget = {
pstgTrgtNm: string;
pstgTrgtNo: number;
};
type PostingDialogProps = {
postingType: PostingType;
postingTargets: PostingTarget[];
};
const POSTING_TYPE_LABEL = {
POPUP: "팝업",
BANNER: "배너",
IMAGE: "이미지",
} as const;
function PostingDialog({ postingType, postingTargets }: PostingDialogProps) {
const { confirm } = useConfirm();
const { setMessage: alert } = useAlertStore((state) => state);
const [selectedRows, setSelectedRows] = useState<number[]>([]);
const [rememberPeriodCode, setRememberPeriodCode] = useState<
Record<string, string>
>({});
const [rememberPeriodDate, setRememberPeriodDate] = useState<
Record<string, DateRange | undefined>
>({});
const { data: siteList } = useQuery<PageableList<Site>>({
queryKey: [ENDPOINT.CMS_SERVICE.SITES],
queryFn: () => SiteApi.getSiteList({}),
});
const { mutateAsync: requestPosting } = useMutation({
mutationFn: (data: WithModePost[]) => PostingApi.requestPosting(data),
onSuccess: () => {
alert("저장되었습니다.");
},
});
const form = useForm<PostingFormData>({
resolver: zodResolver(postingFormSchema),
});
const { fields, append, replace } = useFieldArray({
control: form.control,
name: "pstg",
});
const isFormValid =
fields.length && fields.every((field) => field.pstgPrdSeCd !== "");
const isAllSelected =
siteList && selectedRows.length === siteList.content.length;
const postingPeriod = (siteId: string) => {
const periodCode = Number(rememberPeriodCode[siteId]);
if (periodCode === 99) return "상시";
return `${dayjs().format("YYYY-MM-DD")} ~ ${dayjs()
.add(periodCode, "day")
.format("YYYY-MM-DD")}`;
};
const handleOpenChange = (open: boolean) => {
if (open) return true;
replace([]);
setSelectedRows([]);
setRememberPeriodCode({});
setRememberPeriodDate({});
};
const handleAppendFields = (i: number) => {
const siteId = siteList?.content[i]?.siteId ?? "";
const newFields = postingTargets.map((target) => ({
siteId,
pstgTypeCd: postingType,
pstgTrgtNo: target.pstgTrgtNo,
pstgBgngDt: rememberPeriodDate[siteId]?.from ?? "",
pstgEndDt: rememberPeriodDate[siteId]?.to ?? "",
pstgPrdSeCd: rememberPeriodCode[siteId] ?? "",
delYn: "N",
mode: "C",
}));
append(newFields);
};
const handleUpdateFields =
(siteId: string) => (obj: Record<string, unknown>) => {
const updatedFields = fields.map((field) =>
"siteId" in field && field.siteId === siteId
? { ...field, ...obj }
: field,
);
replace(updatedFields);
};
const handleRemoveFields = (siteId: string) => {
const filteredFields = fields.filter(
(field) => "siteId" in field && field.siteId !== siteId,
);
replace(filteredFields);
};
const handleSelectRow = (i: number) => {
const isSelected = selectedRows.includes(i);
setSelectedRows((prev) =>
isSelected ? prev.filter((j) => j !== i) : [...prev, i],
);
if (isSelected) {
const siteId = siteList?.content[i]?.siteId;
if (!siteId) return;
handleRemoveFields(siteId);
return;
}
handleAppendFields(i);
};
const handleSelectAll = () => {
if (selectedRows.length === (siteList?.content.length ?? 0)) {
replace([]);
setSelectedRows([]);
} else {
replace([]);
setSelectedRows(siteList?.content.map((_, i) => i) ?? []);
siteList?.content.forEach((_, i) => handleAppendFields(i));
}
};
const handlePeriodCodeChange = (siteId: string) => (value: string) => {
const from = dayjs().format("YYYY-MM-DD 00:00:00");
const to = dayjs().add(Number(value), "day").format("YYYY-MM-DD 23:59:59");
setRememberPeriodCode((prev) => ({
...prev,
[siteId]: value,
}));
setRememberPeriodDate((prev) => ({
...prev,
[siteId]: { from, to },
}));
handleUpdateFields(siteId)({
pstgPrdSeCd: value,
pstgBgngDt: from,
pstgEndDt: to,
});
};
const handlePeriodDateChange =
(siteId: string) => (value: DateRange | undefined) => {
const from = dayjs(value?.from).format("YYYY-MM-DD 00:00:00");
const to = dayjs(value?.to).format("YYYY-MM-DD 23:59:59");
setRememberPeriodDate((prev) => ({
...prev,
[siteId]: { from, to },
}));
handleUpdateFields(siteId)({
pstgBgngDt: from,
pstgEndDt: to,
});
};
const handleSave = async () => {
if (await confirm("저장하시겠습니까?")) {
const data = form.getValues("pstg");
await requestPosting(data);
}
};
return (
<Dialog onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button color="emerald" disabled={!postingTargets.length}>
<i className="diveicon di-layer" />
사이트반영
</Button>
</DialogTrigger>
<DialogContent className="max-w-[68rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>사이트 반영</DialogTitle>
</DialogHeader>
<div className="card bg-accent !p-4">
<h3 className="font-semibold">
선택된 {POSTING_TYPE_LABEL[postingType]}
</h3>
<div className="mt-4 flex flex-wrap gap-3">
{postingTargets.map((target) => (
<Badge key={target.pstgTrgtNo}>
<span className="text-xl">{target.pstgTrgtNm}</span>
</Badge>
))}
</div>
</div>
<div className="m-2 max-h-[30rem] overflow-auto">
<Table variant="secondary">
<colgroup>
<col width="10%" />
<col width="25%" />
<col width="65%" />
</colgroup>
<TableHeader className="sticky top-0 z-10 bg-accent">
<TableRow>
<TableHead className="!px-0">
<Checkbox
checked={isAllSelected}
onCheckedChange={handleSelectAll}
/>
</TableHead>
<TableHead>사이트명</TableHead>
<TableHead>게시 구분</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{siteList?.content.map((site, i) => (
<TableRow key={site.siteId}>
<TableCell align="center" className="!px-0">
<Checkbox
checked={selectedRows.includes(i)}
onCheckedChange={() => handleSelectRow(i)}
/>
</TableCell>
<TableCell align="center">{site.siteNm}</TableCell>
<TableCell align="left">
<div className="flex items-center gap-4">
<PostingPeriodSelect
onValueChange={handlePeriodCodeChange(site.siteId)}
disabled={!selectedRows.includes(i)}
/>
{!!Number(rememberPeriodCode[site.siteId]) && (
<span
className={cn(
"text-lg text-label",
!selectedRows.includes(i) && "text-muted",
)}
>
게시 기간: <u>{postingPeriod(site.siteId)}</u>
</span>
)}
{Number(rememberPeriodCode[site.siteId]) === 0 && (
<DateRangePicker
handleDateChange={(date) =>
handlePeriodDateChange(site.siteId)(
date as DateRange | undefined,
)
}
disabled={!selectedRows.includes(i)}
/>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<DialogFooter className="!justify-center">
<Button size="lg" onClick={handleSave} disabled={!isFormValid}>
저장
</Button>
<DialogClose asChild>
<Button size="lg" color="white">
취소
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function PostingPeriodSelect({
...props
}: React.ComponentPropsWithoutRef<typeof Select>) {
return <CodeSelect upCd="PSTGPRDCD" {...props} />;
}
export { PostingDialog };
@@ -10,7 +10,7 @@ import {
} from "../model/site-interface";
export class SiteApiService implements SiteService {
public async getSiteList(params: SiteParams): Promise<SiteList> {
public async getSiteList(params?: SiteParams): Promise<SiteList> {
const response = await client.get<SiteList>(
`${ENDPOINT.CMS_SERVICE.SITES}`,
{ params },
@@ -34,7 +34,7 @@ export class SiteApiService implements SiteService {
return response.data;
}
public async modifySite(siteId: string, data: SiteFormData): Promise<void> {
public async updateSite(siteId: string, data: SiteFormData): Promise<void> {
const validatedData = siteFormSchema.parse(data);
const response = await client.put<void>(
`${ENDPOINT.CMS_SERVICE.SITES}/${siteId}`,
@@ -29,7 +29,7 @@ export interface SiteDetail extends SiteFormData {
mdfcnDt: string;
mdFr: string;
rgTrNm: string;
mdFrNm: string;
mdfrNm: string;
}
export const siteFormSchema = z.object({
@@ -54,6 +54,6 @@ export type SiteList = PageableList<Site>;
export interface SiteService {
getSiteList(params: SiteParams): Promise<SiteList>;
siteDetail(siteId: string): Promise<SiteDetail>;
modifySite(siteId: string, data: SiteFormData): Promise<void>;
updateSite(siteId: string, data: SiteFormData): Promise<void>;
createSite(data: SiteFormData): Promise<void>;
}
@@ -4,10 +4,8 @@ import Image from "next/image";
import { images } from "@common/assets";
import { LoginApi } from "@/entities/auth";
import "./login.css";
import { useRouter } from "next/navigation";
function LoginPage() {
const router = useRouter();
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
@@ -16,7 +14,7 @@ function LoginPage() {
const password = formData.get("password") as string;
await LoginApi.login({ userId, password });
router.push("/site");
window.location.href = "/dashboard";
};
return (
+1
View File
@@ -0,0 +1 @@
export { DashboardPage } from "./ui/dashboard-page";
@@ -0,0 +1,5 @@
function DashboardPage() {
return <></>;
}
export { DashboardPage };
@@ -0,0 +1,47 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
Image,
ImageFormData,
ImageList,
ImageParams,
ImageService,
} from "../model/image-interface";
export class ImageApiService implements ImageService {
public async getImageList(params: ImageParams): Promise<ImageList> {
const response = await client.get<ImageList>(
`${ENDPOINT.CMS_SERVICE.IMAGES}`,
{
params,
},
);
return response.data;
}
public async getImageInfo(imgSn: string): Promise<Image> {
const response = await client.get<Image>(
`${ENDPOINT.CMS_SERVICE.IMAGES}/${imgSn}`,
);
return response.data;
}
public async createImage(data: ImageFormData): Promise<void> {
const response = await client.post<void>(ENDPOINT.CMS_SERVICE.IMAGES, data);
return response.data;
}
public async updateImage(imgSn: string, data: ImageFormData): Promise<void> {
const response = await client.put<void>(
`${ENDPOINT.CMS_SERVICE.IMAGES}/${imgSn}`,
data,
);
return response.data;
}
public async deleteImage(imgSn: string): Promise<void> {
await client.delete(`${ENDPOINT.CMS_SERVICE.IMAGES}/${imgSn}`);
}
}
export const ImageApi = new ImageApiService();
+2
View File
@@ -1 +1,3 @@
export { CreatePage } from "./ui/create-page";
export { ModifyPage } from "./ui/modify-page";
export { ImagePage } from "./ui/image-page";
+61
View File
@@ -0,0 +1,61 @@
import { Upload, type HttpResponse } from "tus-js-client";
import { getCookie, setCookie } from "@/shared/lib";
import { AUTH_USER_ID, CLAIM_NAME, ENDPOINT } from "@/shared/config";
type UploadOptions = {
handleUploadStart?: (upload: Upload) => void;
handleProgress?: (percentage: string) => void;
handleSuccess?: (upload: Upload) => void;
handleResponse?: (res: HttpResponse) => void;
};
export const handleUpload = (files: FileList, options?: UploadOptions) => {
let metadata = {};
const file = files.item(0);
if (!file) return;
metadata = {
filename: file.name,
filetype: file.type,
};
const atflCd = getCookie("atfl-cd-key");
if (atflCd) {
metadata = {
...metadata,
atflCd,
};
}
const upload = new Upload(file, {
endpoint: `${ENDPOINT.ATTACH_SERVICE.TUS_UPLOAD}`,
chunkSize: 10 * 1024 * 1024,
headers: {
[CLAIM_NAME]: getCookie(CLAIM_NAME) ?? "",
[AUTH_USER_ID]: getCookie(AUTH_USER_ID) ?? "",
},
metadata,
onError(error) {
console.error(`Failed because: ${error}`);
},
onProgress(bytesUploaded, bytesTotal) {
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2);
options?.handleProgress?.(percentage);
},
onAfterResponse(req, res) {
const fileInfo = res.getHeader("X-File-Info");
if (fileInfo === undefined || fileInfo === null) return;
setCookie("atfl-cd-key", JSON.parse(fileInfo)?.atflCd ?? "");
options?.handleResponse?.(res);
},
onSuccess() {
options?.handleSuccess?.(upload);
},
});
upload.start();
options?.handleUploadStart?.(upload);
console.log("upload start >>>>>", upload);
};
@@ -0,0 +1,52 @@
import { z } from "zod";
import type { PageableList, PageableParams } from "@/shared/model";
export const IMAGE_TYPE = {
IMGSUB: "IMGSUB",
IMGMAIN: "IMGMAIN",
IMGLOGIN: "IMGLOGIN",
} as const;
export type ImageType = (typeof IMAGE_TYPE)[keyof typeof IMAGE_TYPE];
export interface ImageParams extends PageableParams {
keyword: string;
imgTypeCd?: string;
useYn?: string;
}
export interface Image {
imgSn: number;
imgTypeCd: string;
imgTypeNm: string;
imgTtl: string;
lnkgUrlAddr: string;
atflYn: string;
sortSeq: number;
atflCd: string;
useYn: string;
mdfcnDt: string;
mdFr: string;
mdfrNm: string;
}
export const imageFormSchema = z.object({
imgTtl: z.string(),
sortSeq: z.number(),
imgTypeCd: z.string(),
atflYn: z.string(),
atflCd: z.string().nullish(),
lnkgUrlAddr: z.string().nullish(),
useYn: z.string(),
});
export type ImageFormData = z.infer<typeof imageFormSchema>;
export type ImageList = PageableList<Image>;
export interface ImageService {
getImageList: (params: ImageParams) => Promise<ImageList>;
getImageInfo: (imgSn: string) => Promise<Image>;
createImage: (data: ImageFormData) => Promise<void>;
updateImage: (imgSn: string, data: ImageFormData) => Promise<void>;
deleteImage: (imgSn: string) => Promise<void>;
}
@@ -0,0 +1,74 @@
"use client";
import dayjs from "dayjs";
import { type ColumnDef } from "@tanstack/react-table";
import { Checkbox } from "@common/components/ui";
import { type Image } from "./image-interface";
import { IMAGE_TYPE } from "./image-interface";
export const columns: ColumnDef<Image>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) =>
table.toggleAllPageRowsSelected(!!checked)
}
className="h-4 w-4 rounded border-gray-300"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) => row.toggleSelected(!!checked)}
className="h-4 w-4 rounded border-gray-300"
/>
),
},
{
accessorKey: "imgSn",
header: "NO",
cell: ({ row }) => row.index + 1,
},
{
accessorKey: "imgTypeCd",
header: "이미지구분",
cell: ({ row }) => {
const type = row.getValue("imgTypeCd") as string;
switch (type) {
case IMAGE_TYPE.IMGSUB:
return "서브이미지";
case IMAGE_TYPE.IMGMAIN:
return "메인이미지";
case IMAGE_TYPE.IMGLOGIN:
return "로그인이미지";
default:
return type;
}
},
},
{
accessorKey: "imgTtl",
header: "이미지명",
},
{
accessorKey: "useYn",
header: "사용여부",
cell: ({ row }) => (row.getValue("useYn") === "Y" ? "사용" : "미사용"),
},
{
accessorKey: "mdfrNm",
header: "최종수정자",
},
{
accessorKey: "mdfcnDt",
header: "최종수정일",
cell: ({ row }) => {
const date = row.getValue("mdfcnDt") as string;
return date ? dayjs(date).format("YYYY-MM-DD") : "";
},
},
];
@@ -0,0 +1,44 @@
"use client";
import dayjs from "dayjs";
import type { ColumnDef } from "@tanstack/react-table";
import { Checkbox } from "@common/components/ui";
import type { Post } from "@/entities/posting";
export const columns: ColumnDef<Post>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) =>
table.toggleAllPageRowsSelected(!!checked)
}
className="h-4 w-4 rounded border-gray-300"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) => row.toggleSelected(!!checked)}
className="h-4 w-4 rounded border-gray-300"
/>
),
},
{
accessorKey: "siteNm",
header: "사이트",
},
{
accessorKey: "pstgPrdSeNm",
header: "게시기간",
cell: ({ row }) => {
const pstgPrdSeNm = row.original.pstgPrdSeNm;
const pstgBgngDt = row.original.pstgBgngDt;
const pstgEndDt = row.original.pstgEndDt;
return `${pstgPrdSeNm} (${dayjs(pstgBgngDt).format("YYYY-MM-DD")} ~ ${dayjs(pstgEndDt).format("YYYY-MM-DD")})`;
},
},
];
@@ -0,0 +1,40 @@
"use client";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import { imageFormSchema, type ImageFormData } from "../model/image-interface";
import { ImageApi } from "../api/image-service";
import { ImageForm } from "./image-form";
function CreatePage() {
const router = useRouter();
const { setMessage: alert } = useAlertStore((state) => state);
const { mutateAsync: createImage } = useMutation({
mutationFn: (data: ImageFormData) => ImageApi.createImage(data),
onSuccess: () => {
alert("등록되었습니다.", router.back);
},
});
const form = useForm<ImageFormData>({
resolver: zodResolver(imageFormSchema),
defaultValues: {
atflYn: "Y",
},
});
return (
<div className="card card-border !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">이미지 등록</h3>
</div>
<ImageForm form={form} handleSave={createImage} />
</div>
);
}
export { CreatePage };
+341
View File
@@ -0,0 +1,341 @@
"use client";
import Image from "next/image";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import type { UseFormReturn } from "react-hook-form";
import type { HttpResponse, Upload } from "tus-js-client";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import {
Button,
FileUploader,
Input,
Label,
Progress,
RadioGroup,
RadioGroupItem,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from "@common/components";
import { CodeSelect } from "@/entities/code";
import { AttachmentApi } from "@/entities/attachment";
import { ENDPOINT } from "@/shared/config";
import { Form, FormControl, FormField, FormItem } from "@/shared/ui/form";
import type { ImageFormData } from "../model/image-interface";
import { handleUpload } from "../lib/utils";
type ImageFormProps = {
form: UseFormReturn<ImageFormData>;
imgSn?: string;
handleSave: (data: ImageFormData) => Promise<void>;
handleDelete?: () => Promise<void>;
};
function ImageForm({ form, imgSn, handleSave, handleDelete }: ImageFormProps) {
const isModifyMode = !!imgSn;
const atflYn = form.watch("atflYn");
const router = useRouter();
const { confirm } = useConfirm();
const [attachmentFileId, setAttachmentFileId] = useState("");
const [upload, setUpload] = useState<Upload | null>(null);
const [progress, setProgress] = useState(0);
const { data: atflId } = useQuery({
queryKey: [ENDPOINT.ATTACH_SERVICE.ATTACHMENTS],
queryFn: () => AttachmentApi.getAttachments(form.getValues("atflCd")!),
enabled: !!form.getValues("atflCd"),
select: (data) => data?.[data.length - 1]?.atflId,
});
const onSubmit = async (data: ImageFormData) => {
const confirmMessage = isModifyMode
? "수정하시겠습니까?"
: "저장하시겠습니까?";
if (await confirm(confirmMessage)) {
await handleSave(data);
}
};
const onDelete = async () => {
if (await confirm("삭제하시겠습니까?")) {
await handleDelete?.();
}
};
const handleProgress = (percentage: string) => {
setProgress(Number(percentage));
};
const handleUploadResponse = async (res: HttpResponse) => {
const fileInfo = res.getHeader("X-File-Info");
if (fileInfo === undefined || fileInfo === null) return;
const { atflCd, atflId } = JSON.parse(fileInfo) ?? {};
if (atflCd) form.setValue("atflCd", atflCd);
if (atflId) setAttachmentFileId(atflId);
};
useEffect(() => {
if (atflId) setAttachmentFileId(atflId);
}, [atflId]);
return (
<>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="card">
<Table variant="secondary">
<colgroup>
<col width="20%" />
<col width="80%" />
</colgroup>
<TableBody>
<TableRow>
<TableHead>이미지명</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="imgTtl"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="이미지명을 입력해주세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>정렬순서</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="sortSeq"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
type="number"
min={0}
value={field.value ?? ""}
onChange={(e) => {
const value = e.target.value;
if (isNaN(Number(value))) return;
field.onChange(Number(value));
}}
className="w-[12rem] !text-[1.3rem]"
placeholder="정렬순서를 입력해주세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>이미지유형</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="imgTypeCd"
render={({ field }) => (
<FormItem>
<FormControl>
<CodeSelect
{...field}
value={field.value ?? ""}
upCd="IMGTYPE"
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>첨부파일</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="atflYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
{...field}
name="atflYn"
onValueChange={field.onChange}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="atflY" />
<Label htmlFor="atflY">이미지파일</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="atflN" />
<Label htmlFor="atflN">외부링크</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
{atflYn === "Y" && (
<TableRow>
<TableHead>이미지파일</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="atflCd"
render={() => (
<FormItem className="flex flex-col">
<FormControl>
<FileUploader
id="atflCd"
className="!h-auto !max-w-none flex-col !items-start"
accept=".jpeg,.jpg,.png"
onUpload={(e) => {
const files = e.target.files;
if (!files) return;
handleUpload(files, {
handleUploadStart: setUpload,
handleProgress,
handleSuccess: setUpload,
handleResponse: handleUploadResponse,
});
}}
customPreview={
<>
<Label htmlFor="atflCd">
<div className="relative h-[27rem] w-[48rem] overflow-hidden rounded-sm border border-border bg-accent">
<p className="absolute bottom-3 left-0 p-2 text-base leading-none">
{(upload?.file as File)?.name}
</p>
<div className="relative h-full w-full">
{attachmentFileId && (
<Image
src={`${ENDPOINT.ATTACH_SERVICE.IMAGES}/${attachmentFileId}`}
alt="업로드된 이미지"
fill
objectFit="contain"
/>
)}
</div>
<Progress
value={progress}
className="absolute bottom-0 left-0"
/>
</div>
</Label>
<div className="mb-2 text-lg font-medium text-label">
<ul className="ml-10 list-disc">
<li>권장 사이즈: 1920x500</li>
<li>파일 최대 크기: 2MB</li>
<li>확장자: jpg, jpeg, png 가능</li>
</ul>
</div>
</>
}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
)}
{atflYn === "N" && (
<TableRow>
<TableHead>외부링크</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="lnkgUrlAddr"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="외부링크를 입력해주세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
)}
<TableRow>
<TableHead>사용여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="useYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
{...field}
name="useYn"
onValueChange={field.onChange}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="useY" />
<Label htmlFor="useY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="useN" />
<Label htmlFor="useN">미사용</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<div className="flex justify-center gap-4 pb-12 pt-10">
<Button
size="lg"
color="white"
type="button"
onClick={() => router.back()}
>
목록
</Button>
<Button size="lg" type="submit">
저장
</Button>
{isModifyMode && (
<Button size="lg" color="red" type="button" onClick={onDelete}>
삭제
</Button>
)}
</div>
</form>
</Form>
</>
);
}
export { ImageForm };
+101 -44
View File
@@ -1,60 +1,117 @@
"use client";
import { Upload } from "tus-js-client";
import { FileUploader } from "@common/components/ui";
import { PageLayout } from "@/shared/ui/page-layout";
import { AUTH_USER_ID, CLAIM_NAME, ENDPOINT } from "@/shared/config";
import { getCookie } from "@/shared/lib";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import {
getCoreRowModel,
useReactTable,
type RowSelectionState,
} from "@tanstack/react-table";
import { Button, DataTable, Input, Label } from "@common/components";
import { POSTING_TYPE, PostingDialog } from "@/entities/posting";
import { ENDPOINT } from "@/shared/config";
import { ImageApi } from "../api/image-service";
import { columns } from "../model/image-table-columns";
import { type Image, type ImageParams } from "../model/image-interface";
type PostingTarget = {
pstgTrgtNm: string;
pstgTrgtNo: number;
};
function ImagePage() {
const handleUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.item(0);
if (!file) return;
const router = useRouter();
const upload = new Upload(file, {
endpoint: `${ENDPOINT.ATTACH_SERVICE.TUS_UPLOAD}`,
headers: {
[CLAIM_NAME]: getCookie(CLAIM_NAME) ?? "",
[AUTH_USER_ID]: getCookie(AUTH_USER_ID) ?? "",
},
retryDelays: [0, 3000, 5000, 10000, 20000],
metadata: {
filename: file.name,
filetype: file.type,
},
onError: function (error) {
console.log("Failed because: " + error);
},
onProgress: function (bytesUploaded, bytesTotal) {
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2);
console.log(bytesUploaded, bytesTotal, percentage + "%");
},
onSuccess: function () {
console.log(
"Download %s from %s",
(upload.file as File).name,
upload.url,
);
},
});
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [postingTargets, setPostingTargets] = useState<PostingTarget[]>([]);
const [imageQuery, setImageQuery] = useState<ImageParams>({ keyword: "" });
upload.findPreviousUploads().then(function (previousUploads) {
if (previousUploads.length) {
upload.resumeFromPreviousUpload(previousUploads[0]!);
}
upload.start();
});
const { data: imageList, refetch } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.IMAGES],
queryFn: () => ImageApi.getImageList(imageQuery),
enabled: false,
});
const table = useReactTable({
data: imageList?.content ?? [],
columns,
state: {
rowSelection,
},
enableMultiRowSelection: true,
getCoreRowModel: getCoreRowModel(),
onRowSelectionChange: setRowSelection,
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
refetch();
};
const handleRowClick = (row: Image) => {
router.push(`/image/register/${row.imgSn}`);
};
useEffect(() => {
const postingTargets = table
.getFilteredSelectedRowModel()
.rows.map((row) => ({
pstgTrgtNm: row.original.imgTtl,
pstgTrgtNo: row.original.imgSn,
}));
if (!postingTargets.length) {
setPostingTargets([]);
return;
}
setPostingTargets(postingTargets);
}, [rowSelection]);
useEffect(() => {
refetch();
}, []);
return (
<PageLayout>
<div className="card card-border !mt-6">
<div className="card card-border">
<form onSubmit={handleSubmit} className="flex items-center gap-2 p-2">
<Label htmlFor="" className="mr-2 text-label">
이미지명
</Label>
<Input
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]"
value={imageQuery.keyword}
onChange={(e) =>
setImageQuery({ ...imageQuery, keyword: e.target.value })
}
/>
<Button className="h-[3.2rem]" type="submit">
조회
</Button>
</form>
</div>
<div className="card card-border">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">이미지 목록</h3>
<div className="flex w-full items-center justify-between">
<h3 className="text-2xl font-medium text-label">이미지 목록</h3>
<div className="flex gap-2">
<PostingDialog
postingType={POSTING_TYPE.IMAGE}
postingTargets={postingTargets}
/>
<Button onClick={() => router.push("/image/register")}>
등록
</Button>
</div>
</div>
</div>
<div className="card !m-[1.2rem] h-[49rem] overflow-auto !p-0">
<DataTable table={table} onRowClick={handleRowClick} />
</div>
<FileUploader id="image-uploader" onUpload={handleUpload} />
</div>
</PageLayout>
</div>
);
}
+137
View File
@@ -0,0 +1,137 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
getCoreRowModel,
useReactTable,
type RowSelectionState,
} from "@tanstack/react-table";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { Button, DataTable } from "@common/components/ui";
import { PostingApi, type WithModePost } from "@/entities/posting";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import { ENDPOINT } from "@/shared/config";
import { imageFormSchema, type ImageFormData } from "../model/image-interface";
import { columns } from "../model/posting-table-columns";
import { ImageApi } from "../api/image-service";
import { ImageForm } from "./image-form";
function ModifyPage({ serial }: { serial: string }) {
const router = useRouter();
const { confirm } = useConfirm();
const queryClient = useQueryClient();
const { setMessage: alert } = useAlertStore((state) => state);
const [onConfirm, setOnConfirm] = useState(false);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const { data: imageDetail } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.IMAGES, serial],
queryFn: () => ImageApi.getImageInfo(serial),
});
const { data: postHistory } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.SITE_PSTGS, serial],
queryFn: () => PostingApi.getPostingHistory({ pstgTrgtNo: Number(serial) }),
});
const { mutateAsync: updateImage } = useMutation({
mutationFn: (data: ImageFormData) => ImageApi.updateImage(serial, data),
onSuccess: () => {
alert("수정되었습니다.");
},
});
const { mutateAsync: deleteImage } = useMutation({
mutationFn: () => ImageApi.deleteImage(serial),
onSuccess: () => {
alert("삭제되었습니다.", router.back);
},
});
const { mutateAsync: requestPosting } = useMutation({
mutationFn: (data: WithModePost[]) => PostingApi.requestPosting(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.SITE_PSTGS, serial],
});
},
});
const form = useForm<ImageFormData>({
resolver: zodResolver(imageFormSchema),
values: imageDetail,
});
const table = useReactTable({
data: postHistory?.content ?? [],
columns,
state: {
rowSelection,
},
enableMultiRowSelection: true,
getCoreRowModel: getCoreRowModel(),
onRowSelectionChange: setRowSelection,
});
const handleDeletePostingHistory = async () => {
const data = table.getFilteredSelectedRowModel().rows.map((row) => ({
...row.original,
mode: "D",
}));
setOnConfirm(true);
if (await confirm("삭제하시겠습니까?")) {
setOnConfirm(false);
await requestPosting(data);
alert("삭제되었습니다.");
}
setOnConfirm(false);
};
return (
<>
<div className="mt-6 flex flex-col">
<div className="card card-border">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">이미지 수정</h3>
</div>
<ImageForm
form={form}
imgSn={serial}
handleSave={updateImage}
handleDelete={deleteImage}
/>
</div>
<div className="card card-border">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">
사이트 게시 이력
</h3>
</div>
<div className="card">
<div className="mb-4 flex justify-end">
<Button
size="sm"
color="red"
disabled={!table.getFilteredSelectedRowModel().rows.length}
onClick={handleDeletePostingHistory}
>
이력삭제
</Button>
</div>
<DataTable table={table} />
</div>
</div>
</div>
<ConfirmDialog open={onConfirm} />
</>
);
}
export { ModifyPage };
+3 -3
View File
@@ -24,7 +24,7 @@ export class MenuApiService implements MenuService {
return response.data;
}
public async modifyMenu(
public async updateMenu(
menuCd: number,
data: Omit<MenuFormData, "siteId">,
): Promise<void> {
@@ -49,7 +49,7 @@ export class MenuApiService implements MenuService {
return response.data;
}
public async modifyTreeMenu(siteId: string, data: TreeNode[]): Promise<void> {
public async updateTreeMenu(siteId: string, data: TreeNode[]): Promise<void> {
const response = await client.put<void>(
`${ENDPOINT.CMS_SERVICE.MENUS}/${siteId}/tree`,
data,
@@ -57,7 +57,7 @@ export class MenuApiService implements MenuService {
return response.data;
}
public async modifyMenuName(menuCd: number, name: string): Promise<void> {
public async updateMenuName(menuCd: number, name: string): Promise<void> {
const response = await client.put<void>(
`${ENDPOINT.CMS_SERVICE.MENUS}/${menuCd}/${name}`,
);
@@ -1,6 +1,6 @@
import { createColumnHelper } from "@tanstack/react-table";
import { Checkbox } from "@common/components/ui";
import type { Content } from "../model/content-interface";
import type { Content } from "./content-interface";
const columnHelper = createColumnHelper<Content>();
@@ -75,12 +75,12 @@ export type Menu = Omit<MenuFormData, "iconNm"> & {
export interface MenuService {
getMenuInfo: (menuCd: number) => Promise<Menu>;
createMenu: (data: MenuFormData) => Promise<void>;
modifyMenu: (
updateMenu: (
menuCd: number,
data: Omit<MenuFormData, "siteId">,
) => Promise<void>;
deleteMenu: (menuCd: number) => Promise<void>;
getTreeMenuList: (siteId: string) => Promise<TreeMenu[]>;
modifyTreeMenu: (siteId: string, data: TreeNode[]) => Promise<void>;
modifyMenuName: (menuCd: number, name: string) => Promise<void>;
updateTreeMenu: (siteId: string, data: TreeNode[]) => Promise<void>;
updateMenuName: (menuCd: number, name: string) => Promise<void>;
}
+1 -9
View File
@@ -10,12 +10,7 @@ import {
TableHead,
TableRow,
} from "@common/components";
import {
FormItem,
FormControl,
FormField,
FormMessage,
} from "@/shared/ui/form";
import { FormItem, FormControl, FormField } from "@/shared/ui/form";
import { MENU_TYPE, type MenuFormData } from "../model/menu-interface";
interface BaseMenuFormProps {
@@ -57,7 +52,6 @@ function BaseMenuForm({ form, menuCd }: BaseMenuFormProps) {
placeholder="메뉴명을 입력하십시오."
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
@@ -79,7 +73,6 @@ function BaseMenuForm({ form, menuCd }: BaseMenuFormProps) {
placeholder="메뉴 영문명을 입력하십시오."
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
@@ -118,7 +111,6 @@ function BaseMenuForm({ form, menuCd }: BaseMenuFormProps) {
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
@@ -18,7 +18,7 @@ import {
import { ENDPOINT } from "@/shared/config";
import { ContentApi } from "../api/content-service";
import type { Content } from "../model/content-interface";
import { columns } from "../model/table-colums";
import { columns } from "../model/content-table-colums";
type ContentSearchDialogProps = {
handleContentSelect: (content: Content) => void;
@@ -33,8 +33,7 @@ function ContentSearchDialog({
const { data: contentList, refetch } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.CONTENTS],
queryFn: () =>
ContentApi.getContentList({ contsTtl, useYn: "Y", page: 0, size: 100 }),
queryFn: () => ContentApi.getContentList({ contsTtl }),
enabled: false,
});
+10 -7
View File
@@ -13,7 +13,7 @@ interface MenuFormProps {
form: UseFormReturn<MenuFormData>;
menuCd?: number;
handleCreate: (data: MenuFormData) => void;
handleModify: ({
handleUpdate: ({
menuCd,
data,
}: {
@@ -27,7 +27,7 @@ function MenuForm({
form,
menuCd,
handleCreate,
handleModify,
handleUpdate,
handleDelete,
}: MenuFormProps) {
const isModifyMode = !!menuCd;
@@ -35,15 +35,15 @@ function MenuForm({
const { confirm } = useConfirm();
const [onConfirm, setOnConfirm] = useState(false);
const handleConfirm = async (fn: () => Promise<void>) => {
const handleConfirm = async (callback: () => Promise<void>) => {
setOnConfirm(true);
await fn();
await callback();
setOnConfirm(false);
};
const onSubmit = async (data: MenuFormData) => {
if (isModifyMode) {
await onModify(data);
await onUpdate(data);
} else {
await onCreate(data);
}
@@ -52,15 +52,17 @@ function MenuForm({
const onCreate = async (data: MenuFormData) => {
await handleConfirm(async () => {
if (await confirm("저장하시겠습니까?")) {
setOnConfirm(false);
handleCreate(data);
}
});
};
const onModify = async (data: MenuFormData) => {
const onUpdate = async (data: MenuFormData) => {
await handleConfirm(async () => {
if (await confirm("수정하시겠습니까?")) {
handleModify({ menuCd: menuCd!, data });
setOnConfirm(false);
handleUpdate({ menuCd: menuCd!, data });
}
});
};
@@ -68,6 +70,7 @@ function MenuForm({
const onDelete = () => {
handleConfirm(async () => {
if (await confirm("삭제하시겠습니까?")) {
setOnConfirm(false);
handleDelete?.(menuCd!);
}
});
+3 -3
View File
@@ -34,10 +34,10 @@ function MenuNode({
const queryClient = useQueryClient();
const { mutateAsync: modifyMenuName } = useMutation({
const { mutateAsync: updateMenuName } = useMutation({
mutationFn: (nodeName: string) => {
const encodedName = encodeURIComponent(nodeName);
return MenuApi.modifyMenuName(id as number, encodedName);
return MenuApi.updateMenuName(id as number, encodedName);
},
onSuccess: () => {
queryClient.invalidateQueries({
@@ -53,7 +53,7 @@ function MenuNode({
const handleSelect = () => onSelect(node);
const handleSave = async () => {
await modifyMenuName(nodeName);
await updateMenuName(nodeName);
setEditMode(false);
};
+62 -64
View File
@@ -16,7 +16,6 @@ import {
} from "@common/components/ui";
import { SiteApi } from "@/entities/site";
import { ENDPOINT } from "@/shared/config";
import { PageLayout } from "@/shared/ui/page-layout";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import type { TreeMenu } from "../model/menu-interface";
@@ -63,7 +62,7 @@ function MenuPage() {
const { data: options } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.SITES],
queryFn: () => SiteApi.getSiteList({}),
queryFn: () => SiteApi.getSiteList(),
select: (data) =>
data.content.map((site) => ({
label: site.siteNm,
@@ -81,14 +80,14 @@ function MenuPage() {
},
});
const { mutate: modifyMenu } = useMutation({
const { mutate: updateMenu } = useMutation({
mutationFn: ({
menuCd,
data,
}: {
menuCd: number;
data: Omit<MenuFormData, "siteId">;
}) => MenuApi.modifyMenu(menuCd, data),
}) => MenuApi.updateMenu(menuCd, data),
onSuccess: () => {
alert("수정되었습니다.");
queryClient.invalidateQueries({
@@ -180,6 +179,7 @@ function MenuPage() {
setOnConfirm(false);
deleteMenu(selectedMenu!.menuCd!);
}
setOnConfirm(false);
};
const handleAddMenu = () => {
@@ -197,67 +197,65 @@ function MenuPage() {
return (
<>
<PageLayout>
<div className="card !mb-0 flex !min-h-[calc(100vh-16.8rem)] gap-2">
<div className="card card-border w-[24rem]">
<Select value={siteId} onValueChange={handleSiteIdChange}>
<SelectTrigger>
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
{options?.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{siteId && (
<>
<div className="my-4 flex gap-2">
<Button
size="sm"
color="primary"
className="flex-1"
onClick={handleAddMenu}
>
메뉴 추가
</Button>
<Button
size="sm"
color="red"
className="flex-1"
onClick={handleNodeDelete}
disabled={!selectedMenu}
>
메뉴 삭제
</Button>
</div>
<div className="card card-border !mx-0">
<MenuTree
tree={tree}
siteId={siteId}
selectedMenu={selectedMenu}
additionalNodeForm={additionalNodeForm}
onSelectNode={handleNodeSelect}
onAdditionalNodeSave={handleAdditionalNodeSave}
onAdditionalNodeCancel={resetAdditionalNode}
/>
</div>
</>
)}
</div>
<div className="card card-border flex-1">
<MenuForm
form={form}
menuCd={selectedMenu?.menuCd}
handleCreate={createMenu}
handleModify={modifyMenu}
handleDelete={deleteMenu}
/>
</div>
<div className="card !mb-0 flex !min-h-[calc(100vh-16.8rem)] gap-2">
<div className="card card-border w-[24rem]">
<Select value={siteId} onValueChange={handleSiteIdChange}>
<SelectTrigger>
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
{options?.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{siteId && (
<>
<div className="my-4 flex gap-2">
<Button
size="sm"
color="primary"
className="flex-1"
onClick={handleAddMenu}
>
메뉴 추가
</Button>
<Button
size="sm"
color="red"
className="flex-1"
onClick={handleNodeDelete}
disabled={!selectedMenu}
>
메뉴 삭제
</Button>
</div>
<div className="card card-border !mx-0">
<MenuTree
tree={tree}
siteId={siteId}
selectedMenu={selectedMenu}
additionalNodeForm={additionalNodeForm}
onSelectNode={handleNodeSelect}
onAdditionalNodeSave={handleAdditionalNodeSave}
onAdditionalNodeCancel={resetAdditionalNode}
/>
</div>
</>
)}
</div>
</PageLayout>
<div className="card card-border flex-1">
<MenuForm
form={form}
menuCd={selectedMenu?.menuCd}
handleCreate={createMenu}
handleUpdate={updateMenu}
handleDelete={deleteMenu}
/>
</div>
</div>
<ConfirmDialog open={onConfirm} />
</>
);
+3 -3
View File
@@ -41,9 +41,9 @@ function MenuTree({
const queryClient = useQueryClient();
const treeMethods = useRef<TreeMethods>(null);
const { mutate: modifyTreeMenu } = useMutation({
const { mutate: updateTreeMenu } = useMutation({
mutationFn: (newTree: TreeNode) =>
MenuApi.modifyTreeMenu(siteId, [newTree]),
MenuApi.updateTreeMenu(siteId, [newTree]),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.MENUS],
@@ -96,7 +96,7 @@ function MenuTree({
/>
);
}}
onDrop={handleDropNode(modifyTreeMenu)}
onDrop={handleDropNode(updateTreeMenu)}
classes={{
root: "p-4",
container: "flex flex-col",
+1 -13
View File
@@ -13,12 +13,7 @@ import {
TableHead,
TableRow,
} from "@common/components";
import {
FormItem,
FormControl,
FormField,
FormMessage,
} from "@/shared/ui/form";
import { FormItem, FormControl, FormField } from "@/shared/ui/form";
import type { BoardPopup } from "@/entities/board";
import type { Content } from "../model/content-interface";
import type { User } from "../model/organization-interface";
@@ -117,7 +112,6 @@ function TypeMenuForm({ form, menuType }: TypeMenuFormProps) {
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
@@ -153,7 +147,6 @@ function TypeMenuForm({ form, menuType }: TypeMenuFormProps) {
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
@@ -186,7 +179,6 @@ function TypeMenuForm({ form, menuType }: TypeMenuFormProps) {
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
@@ -211,7 +203,6 @@ function TypeMenuForm({ form, menuType }: TypeMenuFormProps) {
placeholder="사이트맵에서 다른 이름으로 사용할 경우 입력하십시오."
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
@@ -255,7 +246,6 @@ function TypeMenuForm({ form, menuType }: TypeMenuFormProps) {
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
@@ -287,7 +277,6 @@ function TypeMenuForm({ form, menuType }: TypeMenuFormProps) {
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
@@ -322,7 +311,6 @@ function TypeMenuForm({ form, menuType }: TypeMenuFormProps) {
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
+5 -3
View File
@@ -6,7 +6,6 @@ import { useMutation } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod";
import { SiteApi, siteFormSchema, type SiteFormData } from "@/entities/site";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import { PageLayout } from "@/shared/ui/page-layout";
import { SiteForm } from "./site-form";
function CreatePage() {
@@ -38,9 +37,12 @@ function CreatePage() {
});
return (
<PageLayout>
<div className="card card-border !mb-0 !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">사이트 등록</h3>
</div>
<SiteForm form={form} handleSave={createSite} />
</PageLayout>
</div>
);
}
@@ -22,14 +22,9 @@ import {
DialogFooter,
DialogClose,
Checkbox,
Label,
} from "@common/components";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/shared/ui/form";
import { Form, FormControl, FormField, FormItem } from "@/shared/ui/form";
import { ENDPOINT } from "@/shared/config";
import { DomainApi } from "../api/domain-service";
import {
@@ -233,7 +228,6 @@ function DomainManagementDialog({
className="!text-[1.3rem]"
/>
</FormControl>
<FormMessage className="text-left" />
</FormItem>
)}
/>
@@ -253,7 +247,6 @@ function DomainManagementDialog({
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
@@ -275,20 +268,19 @@ function DomainManagementDialog({
value="Y"
id={`useY-${i}`}
/>
<label htmlFor={`useY-${i}`}>예</label>
<Label htmlFor={`useY-${i}`}>예</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="N"
id={`useN-${i}`}
/>
<label htmlFor={`useN-${i}`}>
<Label htmlFor={`useN-${i}`}>
아니오
</label>
</Label>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
@@ -22,14 +22,9 @@ import {
DialogFooter,
DialogClose,
Checkbox,
Label,
} from "@common/components";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/shared/ui/form";
import { Form, FormControl, FormField, FormItem } from "@/shared/ui/form";
import { ENDPOINT } from "@/shared/config";
import { IpApi } from "../api/ip-service";
import { ipFormSchema, type IP, type IPFormData } from "../model/ip-interface";
@@ -95,6 +90,7 @@ function IPManagementDialog({
await mutateAsync(data.ips);
alert("저장되었습니다.");
}
setOnConfirm(false);
};
const handleAddIP = () => {
@@ -217,7 +213,6 @@ function IPManagementDialog({
className="!text-[1.3rem]"
/>
</FormControl>
<FormMessage className="text-left" />
</FormItem>
)}
/>
@@ -239,20 +234,19 @@ function IPManagementDialog({
value="Y"
id={`prmY-${i}`}
/>
<label htmlFor={`prmY-${i}`}>예</label>
<Label htmlFor={`prmY-${i}`}>예</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="N"
id={`prmN-${i}`}
/>
<label htmlFor={`prmN-${i}`}>
<Label htmlFor={`prmN-${i}`}>
아니오
</label>
</Label>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
+10 -15
View File
@@ -6,13 +6,10 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { SiteApi, siteFormSchema, type SiteFormData } from "@/entities/site";
import { ENDPOINT } from "@/shared/config";
import { PageLayout } from "@/shared/ui/page-layout";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import { SiteForm } from "./site-form";
function ModifyPage({ siteId }: { siteId: string }) {
const isModifyMode = !!siteId;
const router = useRouter();
const queryClient = useQueryClient();
const { setMessage: alert } = useAlertStore((state) => state);
@@ -31,11 +28,11 @@ function ModifyPage({ siteId }: { siteId: string }) {
bscSiteYn: data.basicSiteYn,
};
},
enabled: isModifyMode,
enabled: !!siteId,
});
const { mutateAsync: modifySite } = useMutation({
mutationFn: (data: SiteFormData) => SiteApi.modifySite(siteId, data),
const { mutateAsync: updateSite } = useMutation({
mutationFn: (data: SiteFormData) => SiteApi.updateSite(siteId, data),
onSuccess: () => {
alert("수정되었습니다.", () => {
queryClient.invalidateQueries({
@@ -48,12 +45,7 @@ function ModifyPage({ siteId }: { siteId: string }) {
const { mutateAsync: deleteSite } = useMutation({
mutationFn: () => SiteApi.deleteSite(siteId),
onSuccess: () => {
alert("삭제되었습니다.", () => {
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.SITES],
});
router.back();
});
alert("삭제되었습니다.", router.back);
},
});
@@ -63,14 +55,17 @@ function ModifyPage({ siteId }: { siteId: string }) {
});
return (
<PageLayout>
<div className="card card-border !mb-0 !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">사이트 수정</h3>
</div>
<SiteForm
form={form}
siteId={siteId}
handleSave={modifySite}
handleSave={updateSite}
handleDelete={deleteSite}
/>
</PageLayout>
</div>
);
}
+305 -324
View File
@@ -16,13 +16,7 @@ import {
} from "@common/components";
import { CodeSelect } from "@/entities/code";
import type { SiteFormData } from "@/entities/site";
import {
FormItem,
FormControl,
FormField,
Form,
FormMessage,
} from "@/shared/ui/form";
import { FormItem, FormControl, FormField, Form } from "@/shared/ui/form";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import type { Domain } from "../model/domain-interface";
import { IPManagementDialog } from "./ip-management-dialog";
@@ -44,6 +38,12 @@ function SiteForm({ form, siteId, handleSave, handleDelete }: SiteFormProps) {
const [isIpManagementOpen, setIsIpManagementOpen] = useState(false);
const [isDomainManagementOpen, setIsDomainManagementOpen] = useState(false);
const handleConfirm = async (callback: () => Promise<void>) => {
setOnConfirm(true);
await callback();
setOnConfirm(false);
};
const handleSaveDomain = (rows: Domain[]) => {
form.setValue(
"dmnAddr",
@@ -56,350 +56,331 @@ function SiteForm({ form, siteId, handleSave, handleDelete }: SiteFormProps) {
? "수정하시겠습니까?"
: "저장하시겠습니까?";
setOnConfirm(true);
if (await confirm(confirmMessage)) {
setOnConfirm(false);
handleSave(data);
}
setOnConfirm(false);
await handleConfirm(async () => {
if (await confirm(confirmMessage)) {
setOnConfirm(false);
handleSave(data);
}
});
};
const onDelete = async () => {
setOnConfirm(true);
if (await confirm("삭제하시겠습니까?")) {
setOnConfirm(false);
handleDelete?.();
}
setOnConfirm(false);
await handleConfirm(async () => {
if (await confirm("삭제하시겠습니까?")) {
setOnConfirm(false);
handleDelete?.();
}
});
};
return (
<>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="card card-border !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">
{isModifyMode ? "사이트 수정" : "사이트 등록"}
</h3>
</div>
<div className="card">
<Table variant="secondary">
<colgroup>
<col width="20%" />
<col width="80%" />
</colgroup>
<TableBody>
<TableRow>
<TableHead>사이트 코드</TableHead>
<TableCell className="border">
<div className="card">
<Table variant="secondary">
<colgroup>
<col width="20%" />
<col width="80%" />
</colgroup>
<TableBody>
<TableRow>
<TableHead>사이트 코드</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="siteId"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="w-[12rem] !text-[1.3rem]"
disabled={isModifyMode}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사이트 구분</TableHead>
<TableCell className="border">
<div className="flex gap-2">
<FormField
control={form.control}
name="siteId"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="w-[12rem] !text-[1.3rem]"
disabled={isModifyMode}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사이트 구분</TableHead>
<TableCell className="border">
<div className="flex gap-2">
<FormField
control={form.control}
name="siteKndCd"
render={({ field }) => (
<FormItem>
<FormControl>
<CodeSelect
{...field}
upCd="SITEKNDCD"
onValueChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{isModifyMode && (
<IPManagementDialog
siteId={siteId}
triggerDisabled={form.watch("siteKndCd") === "O"}
open={isIpManagementOpen}
onOpenChange={setIsIpManagementOpen}
/>
)}
</div>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사이트 디자인</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="siteSkn"
name="siteKndCd"
render={({ field }) => (
<FormItem>
<FormControl>
<CodeSelect
{...field}
value={field.value ?? ""}
upCd="SITESKINCD"
upCd="SITEKNDCD"
onValueChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
{isModifyMode && (
<IPManagementDialog
siteId={siteId}
triggerDisabled={form.watch("siteKndCd") === "O"}
open={isIpManagementOpen}
onOpenChange={setIsIpManagementOpen}
/>
)}
</div>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사이트 디자인</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="siteSkn"
render={({ field }) => (
<FormItem>
<FormControl>
<CodeSelect
{...field}
value={field.value ?? ""}
upCd="SITESKINCD"
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사이트명</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="siteNm"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="사이트명을 입력하십시오."
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사이트 설명</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="siteExpln"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="사이트 설명을 입력하십시오."
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
{isModifyMode && (
<TableRow>
<TableHead>사이트명</TableHead>
<TableHead>사이트 도메인</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="siteNm"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="사이트명을 입력하십시오."
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사이트 설명</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="siteExpln"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="사이트 설명을 입력하십시오."
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
{isModifyMode && (
<TableRow>
<TableHead>사이트 도메인</TableHead>
<TableCell className="border">
<div className="flex gap-2">
<div className="h-[3.2rem] w-[21.5rem] cursor-not-allowed rounded-md border border-input bg-form px-3 py-2 text-[1.3rem] opacity-50">
{form.watch("dmnAddr")}
</div>
<DomainManagementDialog
siteId={siteId}
open={isDomainManagementOpen}
onOpenChange={setIsDomainManagementOpen}
handleSaveDomain={handleSaveDomain}
/>
<div className="flex gap-2">
<div className="h-[3.2rem] w-[21.5rem] cursor-not-allowed rounded-md border border-input bg-form px-3 py-2 text-[1.3rem] opacity-50">
{form.watch("dmnAddr")}
</div>
</TableCell>
</TableRow>
)}
<TableRow>
<TableHead>사이트 하단 표기 주소</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="siteAddr"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="사이트 하단 주소를 입력하십시오."
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DomainManagementDialog
siteId={siteId}
open={isDomainManagementOpen}
onOpenChange={setIsDomainManagementOpen}
handleSaveDomain={handleSaveDomain}
/>
</div>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사이트 하단 표기 전화번호</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="telNo"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="사이트 하단 전화번호를 입력하십시오."
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사이트 하단 표기 팩스번호</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="faxNo"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="사이트 하단 팩스번호를 입력하십시오."
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사이트 하단 표기 내용</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="lwndCn"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="사이트 표기 내용을 입력하십시오. (ex. COPYRIGHT c 2013 KECO. ALL RIGHTS RESERVED.)"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>북마크 아이콘</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="bkmkIcon"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="아이콘 파일 경로 및 파일명을 입력하여 주십시오."
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>기본 사이트 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="bscSiteYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
{...field}
name="bscSiteYn"
onValueChange={field.onChange}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="bscSiteY" />
<Label htmlFor="bscSiteY">예</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="bscSiteN" />
<Label htmlFor="bscSiteN">아니오</Label>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사용 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="useYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
{...field}
name="useYn"
onValueChange={field.onChange}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="useY" />
<Label htmlFor="useY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="useN" />
<Label htmlFor="useN">사용안함</Label>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
)}
<TableRow>
<TableHead>사이트 하단 표기 주소</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="siteAddr"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="사이트 하단 주소를 입력하십시오."
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사이트 하단 표기 전화번호</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="telNo"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="사이트 하단 전화번호를 입력하십시오."
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사이트 하단 표기 팩스번호</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="faxNo"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="사이트 하단 팩스번호를 입력하십시오."
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사이트 하단 표기 내용</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="lwndCn"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="사이트 표기 내용을 입력하십시오. (ex. COPYRIGHT c 2013 KECO. ALL RIGHTS RESERVED.)"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>북마크 아이콘</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="bkmkIcon"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="아이콘 파일 경로 및 파일명을 입력하여 주십시오."
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>기본 사이트 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="bscSiteYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
{...field}
name="bscSiteYn"
onValueChange={field.onChange}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="bscSiteY" />
<Label htmlFor="bscSiteY">예</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="bscSiteN" />
<Label htmlFor="bscSiteN">아니오</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사용 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="useYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
{...field}
name="useYn"
onValueChange={field.onChange}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="useY" />
<Label htmlFor="useY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="useN" />
<Label htmlFor="useN">사용안함</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<div className="flex justify-center gap-4 pb-12 pt-10">
<Button
+24 -27
View File
@@ -6,10 +6,9 @@ import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { Button, DataTable, Input } from "@common/components";
import { Button, DataTable, Input, Label } from "@common/components";
import { SiteApi, type Site, type SiteParams } from "@/entities/site";
import { ENDPOINT } from "@/shared/config";
import { PageLayout } from "@/shared/ui/page-layout";
import { columns } from "../model/table-columns";
function SitePage() {
@@ -44,34 +43,32 @@ function SitePage() {
}, []);
return (
<PageLayout>
<div className="card card-border !mt-6">
<div className="card card-border">
<form onSubmit={handleSubmit} className="flex items-center gap-2 p-2">
<label htmlFor="" className="mr-2 text-label">
사이트명
</label>
<Input
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]"
value={siteQuery.siteNm}
onChange={(e) => setSiteQuery({ siteNm: e.target.value })}
/>
<Button className="h-[3.2rem]" type="submit">
조회
</Button>
</form>
<div className="card card-border !mt-6">
<div className="card card-border">
<form onSubmit={handleSubmit} className="flex items-center gap-2 p-2">
<Label htmlFor="" className="mr-2 text-label">
사이트명
</Label>
<Input
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]"
value={siteQuery.siteNm}
onChange={(e) => setSiteQuery({ siteNm: e.target.value })}
/>
<Button className="h-[3.2rem]" type="submit">
조회
</Button>
</form>
</div>
<div className="card card-border">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">사이트 목록</h3>
<Button onClick={() => router.push("/site/register")}>등록</Button>
</div>
<div className="card card-border">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">사이트 목록</h3>
<Button onClick={() => router.push("/site/register")}>등록</Button>
</div>
<div className="card !m-[1.2rem] h-[49rem] overflow-auto !p-0">
<DataTable table={table} onRowClick={handleRowClick} />
</div>
<div className="card !m-[1.2rem] h-[49rem] overflow-auto !p-0">
<DataTable table={table} onRowClick={handleRowClick} />
</div>
</div>
</PageLayout>
</div>
);
}
+4
View File
@@ -5,6 +5,8 @@ export const BASE_URL = `${PROXY_HOST}/server`;
export const ENDPOINT = {
ATTACH_SERVICE: {
TUS_UPLOAD: "/attach-service/api/v1/tus/upload",
IMAGES: "/attach-service/api/v1/images",
ATTACHMENTS: "/attach-service/api/v1/atfl/attachments",
},
USER_SERVICE: {
LOGIN: "/user-service/login",
@@ -20,6 +22,8 @@ export const ENDPOINT = {
MENU_ROLES: "/cms-service/api/v1/menu-roles",
CONTENTS: "/cms-service/api/v1/contents",
USER_OGNZ: "/cms-service/api/v1/user-ognz",
IMAGES: "/cms-service/api/v1/images",
SITE_PSTGS: "/cms-service/api/v1/site-pstgs",
},
BOARD_SERVICE: {
BBS: "/board-service/api/v1/bbs",
+3 -3
View File
@@ -9,7 +9,7 @@ import {
} from "@common/components/ui";
type ConfirmDialogProps = {
open: boolean;
open?: boolean;
confirmButtonProps?: ButtonProps & { label?: string };
cancelButtonProps?: ButtonProps & { label?: string };
};
@@ -19,9 +19,9 @@ function ConfirmDialog({
confirmButtonProps,
cancelButtonProps,
}: ConfirmDialogProps) {
const { message, onConfirm, onCancel } = useConfirm();
const { message, isOpen, onConfirm, onCancel } = useConfirm();
return (
<Dialog open={open} onOpenChange={onCancel}>
<Dialog open={open ?? isOpen} onOpenChange={onCancel}>
<DialogContent aria-describedby={undefined}>
<div className="py-10">
<p className="text-center text-[1.4rem] text-foreground">{message}</p>
+12
View File
@@ -0,0 +1,12 @@
import { Skeleton } from "@common/components/ui/skeleton";
function SkeletonPage() {
return (
<div className="flex flex-col gap-4 p-6">
<Skeleton className="h-[6rem] rounded-xl" />
<Skeleton className="h-[60rem] rounded-xl" />
</div>
);
}
export { SkeletonPage };
@@ -34,12 +34,21 @@ const config: StorybookConfig = {
AUTH_USER_ID: JSON.stringify(process.env.AUTH_USER_ID || "token-id"),
},
};
config.optimizeDeps = {
...config.optimizeDeps,
include: ["react", "react-dom", "@storybook/react"],
exclude: ["@storybook/react-vite"],
};
config.plugins?.push(
tsconfigPaths({
projects: [path.resolve(path.dirname(__dirname), "tsconfig.json")],
}),
);
config.server = {
...config.server,
proxy: {
"/server": {
target: process.env.SERVER_API_URL,
+1
View File
@@ -35,6 +35,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-progress": "^1.1.2",
"@radix-ui/react-radio-group": "^1.2.2",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
+1 -1
View File
@@ -20,7 +20,7 @@ export interface SiteInfo {
mdfcnDt: string;
mdFr: string;
rgTrNm: string;
mdFrNm: string;
mdfrNm: string;
}
export const getSiteInfo = async (siteId: string) => {
@@ -2,13 +2,17 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
export function QueryProvider({
children,
client,
queryClient,
}: {
children: React.ReactNode;
client?: QueryClient;
queryClient?: QueryClient;
}) {
const queryClient = client ?? new QueryClient();
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
if (!queryClient) {
const queryClient = new QueryClient();
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
return <>{children}</>;
}
@@ -44,7 +44,7 @@ export function Calendar({
),
day_range_end: "day-range-end",
day_selected:
"!bg-primary !text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
@@ -3,6 +3,7 @@ import {
type RowData,
type Table as TableType,
} from "@tanstack/react-table";
import { cn } from "../../lib";
import { Table, TableCell, TableHead, TableHeader, TableRow } from "../table";
declare module "@tanstack/react-table" {
@@ -55,7 +56,7 @@ function DataTable<T>({
<TableRow
key={row.id}
onClick={() => onRowClick?.(row.original)}
className={onRowClick ? "cursor-pointer hover:bg-accent" : ""}
className={cn(onRowClick && "cursor-pointer hover:bg-accent")}
>
{row.getVisibleCells().map((cell) => {
const { meta } = cell.column.columnDef;
@@ -2,6 +2,6 @@
@layer components {
.date-picker-trigger {
@apply h-[3rem] w-[12rem] justify-between border-input !bg-background px-4 py-2 text-left text-[1.3rem] font-normal leading-none text-muted-foreground hover:!bg-accent;
@apply h-[3rem] w-[12rem] justify-start border-input !bg-background px-4 py-2 text-left text-[1.3rem] font-normal text-muted-foreground hover:!bg-accent;
}
}
@@ -1,6 +1,6 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react";
import { DatePicker } from ".";
import { useState } from "react";
const meta: Meta<typeof DatePicker> = {
title: "Elements/DatePicker",
@@ -19,12 +19,12 @@ export function DatePicker({ className, ...props }: DatePickerProps) {
variant="outline"
className={cn("date-picker-trigger", className)}
>
<CalendarIcon className="!size-[1.6rem]" />
{props.selected ? (
format(props.selected, "yyyy-MM-dd")
) : (
<span>날짜 선택</span>
)}
<CalendarIcon className="!size-[1.6rem]" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
@@ -0,0 +1,71 @@
"use client";
import { useState } from "react";
import { format } from "date-fns";
import { CalendarIcon } from "lucide-react";
import type { DateRange } from "react-day-picker";
import { cn } from "../../lib/utils";
import { Button } from "../button";
import { Calendar } from "../calendar";
import { Popover, PopoverContent, PopoverTrigger } from "../popover";
type DateRangePickerProps = React.HTMLAttributes<HTMLDivElement> & {
disabled?: boolean;
handleDateChange?: (date: DateRange | undefined) => void;
};
export function DateRangePicker({
disabled,
className,
handleDateChange,
}: DateRangePickerProps) {
const [date, setDate] = useState<DateRange>();
const handleSelect = (date: DateRange | undefined) => {
setDate(date);
handleDateChange?.(date);
};
return (
<div className={cn("grid gap-2", className)}>
<Popover>
<PopoverTrigger asChild>
<Button
id="date"
variant="outline"
className={cn(
"h-[3.2rem] min-w-[12rem] justify-start border-input !bg-background px-4 py-2 text-left text-[1.3rem] font-normal text-muted-foreground hover:!bg-accent",
!date && "text-muted-foreground",
)}
disabled={disabled}
>
<CalendarIcon className="!size-[1.6rem]" />
{date?.from ? (
date.to ? (
<>
{format(date.from, "yyyy-MM-dd")} ~{" "}
{format(date.to, "yyyy-MM-dd")}
</>
) : (
format(date.from, "yyyy-MM-dd")
)
) : (
<span>기간 선택</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
initialFocus
mode="range"
defaultMonth={date?.from}
selected={date}
onSelect={handleSelect}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
</div>
);
}
@@ -2,12 +2,12 @@
@layer components {
.file-uploader-container {
@apply flex w-full max-w-sm items-center gap-2;
@apply flex h-[3.2rem] w-full max-w-sm items-center gap-2;
}
.file-uploader-preview {
@apply h-12 w-full min-w-56 overflow-x-auto whitespace-nowrap rounded-md border border-input bg-background px-3 py-2 text-[1.2rem];
@apply h-full w-full min-w-56 overflow-x-auto whitespace-nowrap rounded-md border border-input bg-background px-3 py-2 text-[1.2rem];
}
.file-uploader-label {
@apply flex-shrink-0 cursor-pointer rounded-sm border border-input bg-muted px-4 py-2 text-[1.2rem] font-medium text-muted-foreground;
@apply inline-flex h-full flex-shrink-0 cursor-pointer items-center rounded-sm border border-input bg-muted px-4 py-2 text-[1.2rem] font-medium text-muted-foreground;
}
}
@@ -2,18 +2,21 @@ import "./file-uploader.css";
import { cn } from "../../lib/utils";
import { Input } from "../input";
import { Label } from "../label";
import { combineFileName, validFileType } from "./utils";
type FileUploaderProps = React.InputHTMLAttributes<HTMLInputElement> & {
id: string;
files?: ReadonlyArray<File>;
customPreview?: React.ReactNode;
onUpload: (e: React.ChangeEvent<HTMLInputElement>) => void;
};
export function FileUploader({
id,
className,
files,
customPreview,
className,
onUpload,
...props
}: FileUploaderProps) {
@@ -23,15 +26,17 @@ export function FileUploader({
id={id}
type="file"
className="sr-only"
{...props}
onChange={onUpload}
{...props}
/>
<div className="file-uploader-preview">
{files?.filter(validFileType).map(combineFileName).join(", ")}
</div>
<label htmlFor={id} className="file-uploader-label">
{customPreview || (
<div className="file-uploader-preview">
{files?.filter(validFileType).map(combineFileName).join(", ")}
</div>
)}
<Label htmlFor={id} className="file-uploader-label">
파일 선택
</label>
</Label>
</div>
);
}
@@ -5,6 +5,7 @@ export * from "./calendar";
export * from "./checkbox";
export * from "./data-table";
export * from "./date-picker";
export * from "./date-range-picker";
export * from "./dialog";
export * from "./dropdown-menu";
export * from "./file-uploader";
@@ -15,6 +16,7 @@ export * from "./layout";
export * from "./loading";
export * from "./page-title";
export * from "./popover";
export * from "./progress";
export * from "./radio-group";
export * from "./select";
export * from "./slider";
@@ -19,7 +19,7 @@ export function BaseLayout({
handleMenuClick?: (item: MenuItem) => void;
}) {
return (
<QueryProvider client={queryClient}>
<QueryProvider queryClient={queryClient}>
<SidebarProvider>
<div className="flex-1">
<Header />
@@ -15,7 +15,7 @@ export function VBaseLayout({
queryClient?: QueryClient;
}) {
return (
<QueryProvider client={queryClient}>
<QueryProvider queryClient={queryClient}>
<SidebarProvider>
<div className="flex-1">
<Header viewOnly />
@@ -0,0 +1,28 @@
"use client";
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "../../lib/utils";
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };
@@ -6,7 +6,7 @@ function Skeleton({
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
className={cn("animate-pulse rounded-md bg-[rgba(0,0,0,0.1)]", className)}
{...props}
/>
);
@@ -2,7 +2,7 @@
@layer components {
.table-ui tr {
@apply border-0 hover:bg-accent;
@apply border-0;
}
.table-ui th {
@apply px-4 py-[1.2rem] text-[1.3rem] font-medium leading-[1.2];
+29 -5
View File
@@ -390,6 +390,9 @@ importers:
'@radix-ui/react-popover':
specifier: ^1.1.6
version: 1.1.6(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-progress':
specifier: ^1.1.2
version: 1.1.2(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-radio-group':
specifier: ^1.2.2
version: 1.2.3(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1)
@@ -1623,7 +1626,7 @@ packages:
magic-string: 0.27.0
react-docgen-typescript: 2.2.2(typescript@5.6.3)
typescript: 5.6.3
vite: 6.2.0(@types/node@22.13.9)
vite: 6.2.0(@types/node@22.13.5)
dev: true
/@jridgewell/gen-mapping@0.3.8:
@@ -2295,6 +2298,27 @@ packages:
react-dom: 18.3.1(react@18.3.1)
dev: false
/@radix-ui/react-progress@1.1.2(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1):
resolution: {integrity: sha512-u1IgJFQ4zNAUTjGdDL5dcl/U8ntOR6jsnhxKb5RKp5Ozwl88xKR9EqRZOe/Mk8tnx0x5tNUe2F+MzsyjqMg0MA==}
peerDependencies:
'@types/react': ^18.3.18
'@types/react-dom': ^18.3.5
react: ^18.3.1
react-dom: ^18.3.1
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@radix-ui/react-context': 1.1.1(@types/react@18.3.18)(react@18.3.1)
'@radix-ui/react-primitive': 2.0.2(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1)
'@types/react': 18.3.18
'@types/react-dom': 18.3.5(@types/react@18.3.18)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
dev: false
/@radix-ui/react-radio-group@1.2.3(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1):
resolution: {integrity: sha512-xtCsqt8Rp09FK50ItqEqTJ7Sxanz8EM8dnkVIhJrc/wkMMomSmXHvYbhv3E7Zx4oXh98aaLt9W679SUYXg4IDA==}
peerDependencies:
@@ -3059,7 +3083,7 @@ packages:
browser-assert: 1.2.1
storybook: 8.6.0(prettier@3.5.2)
ts-dedent: 2.2.0
vite: 6.2.0(@types/node@22.13.9)
vite: 6.2.0(@types/node@22.13.5)
dev: true
/@storybook/components@8.6.0(storybook@8.6.0):
@@ -3191,7 +3215,7 @@ packages:
resolve: 1.22.10
storybook: 8.6.0(prettier@3.5.2)
tsconfig-paths: 4.2.0
vite: 6.2.0(@types/node@22.13.9)
vite: 6.2.0(@types/node@22.13.5)
transitivePeerDependencies:
- rollup
- supports-color
@@ -3637,7 +3661,7 @@ packages:
'@babel/plugin-transform-react-jsx-source': 7.25.9(@babel/core@7.26.9)
'@types/babel__core': 7.20.5
react-refresh: 0.14.2
vite: 6.2.0(@types/node@22.13.9)
vite: 6.2.0(@types/node@22.13.5)
transitivePeerDependencies:
- supports-color
dev: true
@@ -8414,7 +8438,7 @@ packages:
debug: 4.4.0
globrex: 0.1.2
tsconfck: 3.1.5(typescript@5.6.3)
vite: 6.2.0(@types/node@22.13.9)
vite: 6.2.0(@types/node@22.13.5)
transitivePeerDependencies:
- supports-color
- typescript