Merge remote-tracking branch 'origin/main' into iipbs/common/main

This commit is contained in:
YangJinSoo94
2025-03-26 09:59:06 +09:00
79 changed files with 3203 additions and 787 deletions
@@ -0,0 +1,2 @@
import { MenuPage } from "@/pages/menu";
export default MenuPage;
+8 -3
View File
@@ -5,10 +5,15 @@ const serverApiUrl =
export function middleware(req: NextRequest) {
const pathname = req.nextUrl.pathname;
const searchParams = req.nextUrl.searchParams;
if (pathname.startsWith("/server")) {
return NextResponse.rewrite(
`${serverApiUrl}${pathname.replace("/server", "")}`,
);
const queryParams = searchParams.toString();
const newUrl = `${serverApiUrl}${pathname.replace("/server", "")}${
queryParams ? `?${queryParams}` : ""
}`;
console.log("Rewriting to: ", newUrl);
return NextResponse.rewrite(new URL(newUrl));
}
return NextResponse.next();
+2
View File
@@ -13,6 +13,7 @@
"@common/components": "workspace:*",
"@frontend-opensource/use-react-hooks": "^1.1.4",
"@hookform/resolvers": "^4.1.3",
"@minoru/react-dnd-treeview": "^3.5.0",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@tanstack/react-query": "^5.67.1",
@@ -23,6 +24,7 @@
"js-cookie": "^3.0.5",
"next": "15.1.7",
"react-chartjs-2": "^5.3.0",
"react-dnd": "^16.0.1",
"react-hook-form": "^7.54.2",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.24.2",
+5 -2
View File
@@ -1,10 +1,13 @@
import "./styles/base.css";
import "./styles/components.css";
import { AppProvider } from "./providers";
import { cookies } from "next/headers";
const App = async ({ children }: Readonly<{ children: React.ReactNode }>) => {
const theme = (await cookies()).get("site-theme-key");
const App = ({ children }: Readonly<{ children: React.ReactNode }>) => {
return (
<html lang="ko">
<html lang="ko" data-theme={theme?.value}>
<body>
<AppProvider>{children}</AppProvider>
</body>
+8 -6
View File
@@ -2,16 +2,18 @@
import { ConfirmProvider } from "@frontend-opensource/use-react-hooks";
import { AuthWrapper } from "@/entities/auth";
import { QueryProvider, StoreProvider } from "@/shared/providers";
import { StoreProvider } from "@/shared/providers";
import { AlertDialog } from "@/shared/ui/alert-dialog";
const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => {
return (
<StoreProvider>
<QueryProvider>
<ConfirmProvider>
<AuthWrapper>{children}</AuthWrapper>
</ConfirmProvider>
</QueryProvider>
<ConfirmProvider>
<AuthWrapper>
{children}
<AlertDialog />
</AuthWrapper>
</ConfirmProvider>
</StoreProvider>
);
};
+1 -1
View File
@@ -14,7 +14,7 @@
@apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4;
}
[data-mode="dark"] {
[data-theme="black"] {
.card-border {
@apply border-[#3f4042] bg-[rgba(255,255,255,0.065)];
}
+23 -20
View File
@@ -1,44 +1,47 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { client } from "@/shared/api";
import { ENDPOINT, AUTH_USER_ID } from "@/shared/config";
import { getCookie, useUserStore } from "@/shared/lib";
import type { IUserInfo } from "@/shared/store";
import { getCookie, useUserStore } from "@/shared/lib";
import { ENDPOINT, AUTH_USER_ID } from "@/shared/config";
import { LoginApi } from "../api/login-service";
const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
const router = useRouter();
const { setUserInfo } = useUserStore((state) => state);
const { userInfo, setUserInfo } = useUserStore((state) => state);
useQuery({
queryKey: [ENDPOINT.USER_SERVICE.USERS],
queryFn: async () => {
async function authCheck() {
const userId = getCookie(AUTH_USER_ID);
if (!userId) {
throw new Error("Missing required authentication tokens");
}
const response = await client.get<IUserInfo>(
`${ENDPOINT.USER_SERVICE.USERS}/${userId}`,
);
if (!userInfo) setUserInfo(response.data);
}
useEffect(() => {
(async () => {
try {
const userId = getCookie(AUTH_USER_ID);
if (!userId) {
throw new Error("Missing required authentication tokens");
}
const response = await client.get<IUserInfo>(
`${ENDPOINT.USER_SERVICE.USERS}/${userId}`,
);
setUserInfo(response.data);
return response;
await authCheck();
} catch (error) {
console.error(error);
try {
await LoginApi.silentRefresh();
await authCheck();
} catch (refreshError) {
console.error("Token refresh failed:", refreshError);
setUserInfo(null);
router.push("/auth/login");
}
}
},
});
})();
}, []);
return <>{children}</>;
};
@@ -2,16 +2,16 @@ import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import {
siteFormSchema,
type ISiteInfo,
type SiteService,
type SiteList,
type ISiteDetail,
type ISiteParams,
type SiteFormData,
} from "../model/site-interface";
export class SiteApiService implements SiteService {
public async siteInfo(params: ISiteParams): Promise<ISiteInfo> {
const response = await client.get<ISiteInfo>(
public async getSiteList(params: ISiteParams): Promise<SiteList> {
const response = await client.get<SiteList>(
`${ENDPOINT.CMS_SERVICE.SITES}`,
{ params },
);
+7
View File
@@ -0,0 +1,7 @@
export { SiteApi } from "./api/site-service";
export {
siteFormSchema,
type ISiteParams,
type ISite,
type SiteFormData,
} from "./model/site-interface";
@@ -0,0 +1,57 @@
import { z } from "zod";
import type { IPageableParams, PageableList } from "@/shared/model";
export interface ISiteParams extends IPageableParams {
siteNm?: string;
siteKndCd?: string;
useYn?: string;
}
export interface ISite {
siteId: string;
siteNm: string;
siteKindCode: string;
bscSiteYn: string;
siteAddr: string;
useYn: string;
regDt: string;
rgTrNm: string;
}
export interface ISiteDetail extends SiteFormData {
siteId: string;
siteKindNm: string;
basicSiteYn: string;
faxNumber: string;
regDt: string;
rgTr: string;
mdfcnDt: string;
mdFr: string;
rgTrNm: string;
mdFrNm: string;
}
export const siteFormSchema = z.object({
siteId: z.string().max(2).nonempty(),
siteNm: z.string().max(100).nonempty(),
siteExpln: z.string().max(1000).nullable(),
siteKndCd: z.string().max(10).nonempty(),
siteAddr: z.string().max(2000).nullable(),
faxNo: z.string().max(100).nullable(),
telNo: z.string().max(1000).nullable(),
lwndCn: z.string().max(2000).nullable(),
bscSiteYn: z.string(),
bkmkIcon: z.string().max(100).nullable(),
useYn: z.string(),
siteSkn: z.string().max(10).nullable(),
});
export type SiteFormData = z.infer<typeof siteFormSchema>;
export type SiteList = PageableList<ISite[]>;
export interface SiteService {
getSiteList(params: ISiteParams): Promise<SiteList>;
siteDetail(siteId: string): Promise<ISiteDetail>;
modifySite(siteId: string, data: SiteFormData): Promise<void>;
createSite(data: SiteFormData): Promise<void>;
}
@@ -4,8 +4,10 @@ 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();
@@ -13,7 +15,8 @@ function LoginPage() {
const userId = formData.get("id") as string;
const password = formData.get("password") as string;
LoginApi.login({ userId, password });
await LoginApi.login({ userId, password });
router.push("/site");
};
return (
@@ -0,0 +1,22 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { Menu, MenuService, TreeMenu } from "../model/menu-interface";
export class MenuApiService implements MenuService {
public async getMenuList(siteId: string): Promise<Menu[]> {
const response = await client.get<Menu[]>(
`${ENDPOINT.CMS_SERVICE.MENUS}/${siteId}/tree`,
);
return response.data;
}
public async modifyMenu(siteId: string, data: TreeMenu[]): Promise<void> {
const response = await client.put<void>(
`${ENDPOINT.CMS_SERVICE.MENUS}/${siteId}/tree`,
data,
);
return response.data;
}
}
export const MenuApi = new MenuApiService();
+1
View File
@@ -0,0 +1 @@
export { MenuPage } from "./ui/menu-page";
+61
View File
@@ -0,0 +1,61 @@
import type { DropOptions, NodeModel } from "@minoru/react-dnd-treeview";
import type { Menu, TreeMenu } from "../model/menu-interface";
export const generateTreeData = (data: Menu[]): NodeModel<Menu>[] => {
return data.reduce<NodeModel<Menu>[]>((acc, menu) => {
const node: NodeModel<Menu> = {
id: menu.menuCd ?? 0,
parent: menu.parentId ?? 0,
text: menu.menuKornNm,
droppable: true,
data: menu,
};
acc.push(node);
if (menu.children?.length) {
acc.push(...generateTreeData(menu.children));
}
return acc;
}, []);
};
export const generateNewTreeData = (
treeData: NodeModel<Menu>[],
options: DropOptions<Menu>,
): TreeMenu | null => {
const { dragSourceId, dragSource, dropTargetId, dropTarget } = options;
if (!dragSource?.data) return null;
const childNodes = treeData.filter(
(node) => node.parent === dragSourceId && node.data,
);
const baseNode = (node: NodeModel<Menu>): TreeMenu => {
if (!node.data) return {};
return {
menuCd: node.data?.menuCd,
name: node.data?.menuKornNm,
sortSeq: node.data?.sortSeq,
icon: node.data?.iconNm,
};
};
const treeMenu: TreeMenu = {
...baseNode(dragSource),
parentId: dropTargetId === 0 ? null : (dropTargetId as number),
levelNo: (dropTarget?.data?.levelNo ?? 0) + 1,
};
const result: TreeMenu = {
...treeMenu,
children: childNodes.map((child) => ({
...baseNode(child),
parentId: child.data?.parentId === 0 ? null : (dropTargetId as number),
levelNo: (treeMenu?.levelNo ?? 0) + 1,
})),
};
return result;
};
@@ -0,0 +1,69 @@
import { z } from "zod";
export const MENU_TYPE = {
EMPTY: "EMPTY",
CONTENTS: "CONTENTS",
BOARD: "BOARD",
INSIDE: "INSIDE",
OUTSIDE: "OUTSIDE",
BOTTOM: "BOTTOM",
} as const;
export type MenuType = (typeof MENU_TYPE)[keyof typeof MENU_TYPE];
export interface TreeMenu {
menuCd?: number;
name?: string;
sortSeq?: number;
parentId?: number | null;
levelNo?: number;
icon?: string;
children?: TreeMenu[];
}
export interface Menu extends Omit<TreeMenu, "name" | "icon" | "children"> {
menuKornNm: string;
menuEngNm: string;
iconNm: string;
children: Menu[];
urlPath: string;
menuLnkgTypeCd: string;
menuLnkgSn: number;
qrcd: string;
useYn: string;
otptYn: string;
requiredUrlPath: boolean;
}
export const menuFormSchema = z.object({
siteId: z.string(),
menuKornNm: z.string(),
menuSubKornNm: z.string().nullish(),
menuEngNm: z.string().nullish(),
menuSubEngNm: z.string().nullish(),
levelNo: z.number(),
urlPath: z.string().nullish(),
iconNm: z.string().nullish(),
menuLnkgTypeCd: z.enum([
MENU_TYPE.EMPTY,
MENU_TYPE.CONTENTS,
MENU_TYPE.BOARD,
MENU_TYPE.INSIDE,
MENU_TYPE.OUTSIDE,
MENU_TYPE.BOTTOM,
]),
menuLnkgSn: z.number().nullish(),
otptYn: z.enum(["Y", "N"]).default("Y"),
prntMenuCd: z.number().nullish(),
sortSeq: z.number(),
evlUseYn: z.enum(["Y", "N"]).nullish(),
qrcd: z.string().nullish(),
useYn: z.enum(["Y", "N"]).default("Y"),
});
export type MenuFormData = z.infer<typeof menuFormSchema>;
export interface MenuService {
getMenuList: (siteId: string) => Promise<Menu[]>;
modifyMenu: (siteId: string, data: TreeMenu[]) => Promise<void>;
}
@@ -0,0 +1,122 @@
import { type UseFormReturn } from "react-hook-form";
import {
IconFinder,
Input,
Label,
RadioGroup,
RadioGroupItem,
TableCell,
TableHead,
TableRow,
} from "@common/components";
import {
FormItem,
FormControl,
FormField,
FormMessage,
} from "@/shared/ui/form";
import type { MenuFormData } from "../model/menu-interface";
export function BaseBottomMenuForm({
form,
}: {
form: UseFormReturn<MenuFormData>;
}) {
const handleIconClick = (icon: string) => {
form.setValue("iconNm", icon);
};
return (
<>
<TableRow>
<TableHead>메뉴 서브명</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="menuSubKornNm"
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">
<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("iconNm")}
</div>
<IconFinder handleClick={handleIconClick} />
</div>
</TableCell>
</TableRow>
<TableRow>
<TableHead>출력 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="otptYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup {...field} onValueChange={field.onChange}>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="otptY" />
<Label htmlFor="otptY">출력</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="otptN" />
<Label htmlFor="otptN">출력안함</Label>
</div>
</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} onValueChange={field.onChange}>
<div className="flex items-center space-x-4">
<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>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
</>
);
}
@@ -0,0 +1,130 @@
import { type UseFormReturn } from "react-hook-form";
import {
Input,
Label,
RadioGroup,
RadioGroupItem,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from "@common/components";
import {
FormItem,
FormControl,
FormField,
FormMessage,
} from "@/shared/ui/form";
import { MENU_TYPE, type MenuFormData } from "../model/menu-interface";
interface BaseMenuFormProps {
form: UseFormReturn<MenuFormData>;
menuId?: number;
}
export function BaseMenuForm({ form, menuId }: BaseMenuFormProps) {
return (
<Table variant="secondary">
<colgroup>
<col width="20%" />
<col width="80%" />
</colgroup>
<TableBody>
<TableRow>
<TableHead>메뉴ID</TableHead>
<TableCell className="border">
<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">
{menuId}
</div>
</TableCell>
</TableRow>
<TableRow>
<TableHead>메뉴명</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="menuKornNm"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
className="!text-[1.3rem]"
placeholder="메뉴명을 입력하십시오."
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>메뉴 영문명</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="menuEngNm"
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="menuLnkgTypeCd"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
value={field.value}
onValueChange={field.onChange}
>
<div className="flex items-center space-x-4">
{Object.entries(MENU_TYPE).map(([key, value]) => (
<div
key={key}
className="flex items-center space-x-2"
>
<RadioGroupItem
value={value}
id={`menuType${key}`}
/>
<Label htmlFor={`menuType${key}`}>
{key === "EMPTY" && "빈메뉴"}
{key === "CONTENTS" && "콘텐츠"}
{key === "BOARD" && "게시판"}
{key === "INSIDE" && "내부링크"}
{key === "OUTSIDE" && "외부링크"}
{key === "BOTTOM" && "하단메뉴"}
</Label>
</div>
))}
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}
@@ -0,0 +1,144 @@
import { type UseFormReturn } from "react-hook-form";
import {
Label,
RadioGroup,
RadioGroupItem,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from "@common/components";
import {
FormItem,
FormControl,
FormField,
FormMessage,
} from "@/shared/ui/form";
import type { MenuFormData } from "../model/menu-interface";
import { BaseBottomMenuForm } from "./base-bottom-menu-form";
export function BoardMenuForm({ form }: { form: UseFormReturn<MenuFormData> }) {
return (
<Table variant="secondary">
<colgroup>
<col width="20%" />
<col width="80%" />
</colgroup>
<TableBody>
<TableRow>
<TableHead>게시판</TableHead>
<TableCell className="border">
<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("menuLnkgSn")}
</div>
</TableCell>
</TableRow>
<TableRow>
<TableHead>연결 형태</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="evlUseYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
value={field.value ?? ""}
onValueChange={field.onChange}
>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<RadioGroupItem value="self" id="self" />
<Label htmlFor="self">현재창</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="blank" id="blank" />
<Label htmlFor="blank">새창</Label>
</div>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>메뉴 평가 사용 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="evlUseYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
value={field.value ?? ""}
onValueChange={field.onChange}
>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="evlUseY" />
<Label htmlFor="evlUseY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="evlUseN" />
<Label htmlFor="evlUseN">사용안함</Label>
</div>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
<BaseBottomMenuForm form={form} />
<TableRow>
<TableHead>QR코드</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="qrcd"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
value={field.value ?? ""}
onValueChange={field.onChange}
>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="qrcdY" />
<Label htmlFor="qrcdY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="qrcdN" />
<Label htmlFor="qrcdN">사용안함</Label>
</div>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>담당자</TableHead>
<TableCell className="border">
<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("menuLnkgSn")}
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}
@@ -0,0 +1,22 @@
import { type UseFormReturn } from "react-hook-form";
import { Table, TableBody } from "@common/components";
import type { MenuFormData } from "../model/menu-interface";
import { BaseBottomMenuForm } from "./base-bottom-menu-form";
export function BottomMenuForm({
form,
}: {
form: UseFormReturn<MenuFormData>;
}) {
return (
<Table variant="secondary">
<colgroup>
<col width="20%" />
<col width="80%" />
</colgroup>
<TableBody>
<BaseBottomMenuForm form={form} />
</TableBody>
</Table>
);
}
@@ -0,0 +1,148 @@
import { type UseFormReturn } from "react-hook-form";
import {
Label,
RadioGroup,
RadioGroupItem,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from "@common/components";
import {
FormItem,
FormControl,
FormField,
FormMessage,
} from "@/shared/ui/form";
import type { MenuFormData } from "../model/menu-interface";
import { BaseBottomMenuForm } from "./base-bottom-menu-form";
export function ContentMenuForm({
form,
}: {
form: UseFormReturn<MenuFormData>;
}) {
return (
<Table variant="secondary">
<colgroup>
<col width="20%" />
<col width="80%" />
</colgroup>
<TableBody>
<TableRow>
<TableHead>콘텐츠</TableHead>
<TableCell className="border">
<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("menuLnkgSn")}
</div>
</TableCell>
</TableRow>
<TableRow>
<TableHead>연결 형태</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="evlUseYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
value={field.value ?? ""}
onValueChange={field.onChange}
>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<RadioGroupItem value="self" id="self" />
<Label htmlFor="self">현재창</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="blank" id="blank" />
<Label htmlFor="blank">새창</Label>
</div>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>메뉴 평가 사용 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="evlUseYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
value={field.value ?? ""}
onValueChange={field.onChange}
>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="evlUseY" />
<Label htmlFor="evlUseY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="evlUseN" />
<Label htmlFor="evlUseN">사용안함</Label>
</div>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
<BaseBottomMenuForm form={form} />
<TableRow>
<TableHead>QR코드</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="qrcd"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
value={field.value ?? ""}
onValueChange={field.onChange}
>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="qrcdY" />
<Label htmlFor="qrcdY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="qrcdN" />
<Label htmlFor="qrcdN">사용안함</Label>
</div>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>담당자</TableHead>
<TableCell className="border">
<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("menuLnkgSn")}
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}
@@ -0,0 +1,18 @@
import { type UseFormReturn } from "react-hook-form";
import { Table, TableBody } from "@common/components";
import type { MenuFormData } from "../model/menu-interface";
import { BaseBottomMenuForm } from "./base-bottom-menu-form";
export function EmptyMenuForm({ form }: { form: UseFormReturn<MenuFormData> }) {
return (
<Table variant="secondary">
<colgroup>
<col width="20%" />
<col width="80%" />
</colgroup>
<TableBody>
<BaseBottomMenuForm form={form} />
</TableBody>
</Table>
);
}
@@ -0,0 +1,22 @@
import { type UseFormReturn } from "react-hook-form";
import { Table, TableBody } from "@common/components";
import type { MenuFormData } from "../model/menu-interface";
import { BaseBottomMenuForm } from "./base-bottom-menu-form";
export function InsideMenuForm({
form,
}: {
form: UseFormReturn<MenuFormData>;
}) {
return (
<Table variant="secondary">
<colgroup>
<col width="20%" />
<col width="80%" />
</colgroup>
<TableBody>
<BaseBottomMenuForm form={form} />
</TableBody>
</Table>
);
}
+111
View File
@@ -0,0 +1,111 @@
import { useState } from "react";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { type UseFormReturn } from "react-hook-form";
import { Button } from "@common/components";
import { Form } from "@/shared/ui/form";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import { MENU_TYPE, type MenuFormData } from "../model/menu-interface";
import { BaseMenuForm } from "./base-menu-form";
import { EmptyMenuForm } from "./empty-menu-form";
import { ContentMenuForm } from "./content-menu-form";
import { BoardMenuForm } from "./board-menu-form";
import { InsideMenuForm } from "./inside-menu-form";
import { OutsideMenuForm } from "./outside-menu-form";
import { BottomMenuForm } from "./bottom-menu-form";
interface MenuFormProps {
form: UseFormReturn<MenuFormData>;
menuId?: number;
handleSave: (data: MenuFormData) => Promise<void>;
handleDelete?: () => Promise<void>;
}
export function MenuForm({
form,
menuId,
handleSave,
handleDelete,
}: MenuFormProps) {
const isModifyMode = !!menuId;
const { confirm } = useConfirm();
const [onConfirm, setOnConfirm] = useState(false);
const onSubmit = async (data: MenuFormData) => {
const confirmMessage = isModifyMode
? "수정하시겠습니까?"
: "저장하시겠습니까?";
setOnConfirm(true);
if (await confirm(confirmMessage)) {
setOnConfirm(false);
handleSave(data);
}
setOnConfirm(false);
};
const onDelete = async () => {
setOnConfirm(true);
if (await confirm("삭제하시겠습니까?")) {
setOnConfirm(false);
handleDelete?.();
}
setOnConfirm(false);
};
const menuType = form.watch("menuLnkgTypeCd");
return (
<>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="card card-border">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">
메뉴 등록/수정
</h3>
</div>
<div className="card">
<BaseMenuForm form={form} menuId={menuId} />
<div className="-mt-[0.1rem]">
{renderMenuTypeComponent(menuType, form)}
</div>
</div>
</div>
<div className="flex justify-center gap-4 pb-12 pt-10">
<Button size="lg" type="submit">
저장
</Button>
{isModifyMode && (
<Button size="lg" color="red" type="button" onClick={onDelete}>
삭제
</Button>
)}
</div>
</form>
</Form>
<ConfirmDialog open={onConfirm} />
</>
);
}
function renderMenuTypeComponent(
menuType: string,
form: UseFormReturn<MenuFormData>,
) {
switch (menuType) {
case MENU_TYPE.EMPTY:
return <EmptyMenuForm form={form} />;
case MENU_TYPE.CONTENTS:
return <ContentMenuForm form={form} />;
case MENU_TYPE.BOARD:
return <BoardMenuForm form={form} />;
case MENU_TYPE.INSIDE:
return <InsideMenuForm form={form} />;
case MENU_TYPE.OUTSIDE:
return <OutsideMenuForm form={form} />;
case MENU_TYPE.BOTTOM:
return <BottomMenuForm form={form} />;
default:
return null;
}
}
+105
View File
@@ -0,0 +1,105 @@
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useQuery } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Button,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components/ui";
import { SiteApi } from "@/entities/site";
import { ENDPOINT } from "@/shared/config";
import { PageLayout } from "@/shared/ui/page-layout";
import { MenuApi } from "../api/menu-service";
import {
MENU_TYPE,
menuFormSchema,
type MenuFormData,
} from "../model/menu-interface";
import { MenuTree } from "./menu-tree";
import { MenuForm } from "./menu-form";
import { generateTreeData } from "../lib/utils";
function MenuPage() {
const [siteId, setSiteId] = useState("");
const { data: treeData } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.MENUS, siteId],
queryFn: () => MenuApi.getMenuList(siteId),
select: (data) => generateTreeData(data),
enabled: !!siteId,
});
const { data: options } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.SITES],
queryFn: () => SiteApi.getSiteList({}),
select: (data) =>
data.content.map((site) => ({
label: site.siteNm,
value: site.siteId,
})),
});
const form = useForm<MenuFormData>({
resolver: zodResolver(menuFormSchema),
defaultValues: {
menuLnkgTypeCd: MENU_TYPE.EMPTY,
},
});
const handleSave = async (data: MenuFormData) => {
console.log(data);
};
const handleDelete = async () => {};
return (
<PageLayout pageTitle="메뉴 관리">
<div className="card flex gap-2">
<div className="card card-border">
<Select value={siteId} onValueChange={setSiteId}>
<SelectTrigger className="w-[18rem]">
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
{options?.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{treeData && (
<>
<div className="my-4 flex gap-2">
<Button size="sm" color="primary" className="flex-1">
메뉴 추가
</Button>
<Button size="sm" color="red" className="flex-1">
메뉴 삭제
</Button>
</div>
<div className="card card-border !mx-0">
<MenuTree treeData={treeData ?? []} siteId={siteId} />
</div>
</>
)}
</div>
<div className="card card-border flex-1">
<MenuForm
form={form}
handleSave={handleSave}
handleDelete={handleDelete}
/>
</div>
</div>
</PageLayout>
);
}
export { MenuPage };
+132
View File
@@ -0,0 +1,132 @@
"use client";
import { useState } from "react";
import { DndProvider } from "react-dnd";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
Tree,
MultiBackend,
getBackendOptions,
type NodeModel,
type DropOptions,
} from "@minoru/react-dnd-treeview";
import { cn } from "@common/components/lib";
import { MenuApi } from "../api/menu-service";
import type { Menu, TreeMenu } from "../model/menu-interface";
import { generateNewTreeData } from "../lib/utils";
import { ENDPOINT } from "@/shared/config";
type MenuTreeProps = {
treeData: NodeModel<Menu>[];
siteId: string;
};
function MenuTree({ treeData, siteId }: MenuTreeProps) {
const queryClient = useQueryClient();
const { mutate } = useMutation({
mutationFn: ({ data }: { data: TreeMenu[] }) =>
MenuApi.modifyMenu(siteId, data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.MENUS],
});
},
});
const [selectedNode, setSelectedNode] = useState<NodeModel<Menu> | null>(
null,
);
const handleSelect = (node: NodeModel<Menu>) => setSelectedNode(node);
const handleDrop = (
newTree: NodeModel<Menu>[],
options: DropOptions<Menu>,
) => {
const newTreeData = generateNewTreeData(newTree, options);
if (!newTreeData) return;
mutate({ data: [newTreeData] });
};
return (
<DndProvider backend={MultiBackend} options={getBackendOptions()}>
<Tree
tree={treeData}
rootId={0}
render={(node: NodeModel<Menu>, { depth, isOpen, onToggle }) => (
<MenuNode
node={node}
depth={depth}
isOpen={isOpen}
isSelected={selectedNode?.id === node.id}
onToggle={onToggle}
onSelect={handleSelect}
/>
)}
onDrop={handleDrop}
classes={{
root: "p-4",
container: "flex flex-col",
listItem: "",
dropTarget: "!bg-[rgba(32,148,250,0.5)] rounded-sm",
draggingSource: "",
placeholder: "",
}}
/>
</DndProvider>
);
}
type MenuNodeProps = {
node: NodeModel<Menu>;
depth: number;
isOpen: boolean;
isSelected: boolean;
onToggle: (id: NodeModel["id"]) => void;
onSelect: (node: NodeModel<Menu>) => void;
};
function MenuNode({
node,
depth,
isOpen,
isSelected,
onToggle,
onSelect,
}: MenuNodeProps) {
const { id, text, data } = node;
const indent = depth * 10 + 5;
const handleToggle = () => onToggle(id);
const handleSelect = () => onSelect(node);
return (
<div
style={{ paddingInlineStart: indent }}
className={cn(
"flex cursor-pointer items-center gap-2 rounded-sm p-2 hover:bg-accent",
isSelected && "!bg-[rgba(32,148,250,0.2)]",
)}
onClick={handleSelect}
>
<div className="flex items-center">
{data?.children?.length && (
<i
className={cn(
"diveicon di-arrow-forward mr-2 text-label",
isOpen && "rotate-90",
)}
onClick={(e) => {
e.stopPropagation();
handleToggle();
}}
/>
)}
<span className="text-[1.4rem] font-medium text-label">{text}</span>
</div>
</div>
);
}
export { MenuTree };
@@ -0,0 +1,22 @@
import { type UseFormReturn } from "react-hook-form";
import { Table, TableBody } from "@common/components";
import type { MenuFormData } from "../model/menu-interface";
import { BaseBottomMenuForm } from "./base-bottom-menu-form";
export function OutsideMenuForm({
form,
}: {
form: UseFormReturn<MenuFormData>;
}) {
return (
<Table variant="secondary">
<colgroup>
<col width="20%" />
<col width="80%" />
</colgroup>
<TableBody>
<BaseBottomMenuForm form={form} />
</TableBody>
</Table>
);
}
@@ -0,0 +1,28 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
Domain,
DomainService,
DomainList,
IDomainParams,
} from "../model/domain-interface";
export class DomainApiService implements DomainService {
public async getDomainList(params: IDomainParams): Promise<DomainList> {
const response = await client.get<DomainList>(
`${ENDPOINT.CMS_SERVICE.DOMAINS}`,
{ params },
);
return response.data;
}
public async saveDomainList(body: Domain[]): Promise<void> {
const response = await client.post<void>(
`${ENDPOINT.CMS_SERVICE.DOMAINS}`,
body,
);
return response.data;
}
}
export const DomainApi = new DomainApiService();
+22
View File
@@ -0,0 +1,22 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { IIpParams, IP, IpList, IpService } from "../model/ip-interface";
export class IpApiService implements IpService {
public async getIpList(params: IIpParams): Promise<IpList> {
const response = await client.get<IpList>(`${ENDPOINT.CMS_SERVICE.IPS}`, {
params,
});
return response.data;
}
public async saveIpList(body: IP[]): Promise<void> {
const response = await client.post<void>(
`${ENDPOINT.CMS_SERVICE.IPS}`,
body,
);
return response.data;
}
}
export const IpApi = new IpApiService();
@@ -0,0 +1,31 @@
import { z } from "zod";
import type { IPageableParams, PageableList } from "@/shared/model";
export interface IDomainParams extends IPageableParams {
keywordType?: string;
keyword?: string;
siteId?: string;
useYn?: string;
}
export const domainSchema = z.object({
siteDmnNo: z.number().nullish(),
siteId: z.string(),
dmnAddr: z.string().nonempty(),
useYn: z.string(),
rprsDmnYn: z.string(),
mode: z.string(),
});
export const domainFormSchema = z.object({
domains: z.array(domainSchema),
});
export type Domain = z.infer<typeof domainSchema>;
export type DomainFormData = z.infer<typeof domainFormSchema>;
export type DomainList = PageableList<Domain[]>;
export interface DomainService {
getDomainList(params: IDomainParams): Promise<DomainList>;
saveDomainList(body: Domain[]): Promise<void>;
}
@@ -0,0 +1,29 @@
import { z } from "zod";
import type { IPageableParams, PageableList } from "@/shared/model";
export interface IIpParams extends IPageableParams {
siteId: string;
ipAddr?: string;
prmYn?: string;
}
export const ipSchema = z.object({
mngNo: z.number().nullish(),
siteId: z.string(),
ipAddr: z.string().nonempty(),
prmYn: z.string(),
mode: z.string(),
});
export const ipFormSchema = z.object({
ips: z.array(ipSchema),
});
export type IP = z.infer<typeof ipSchema>;
export type IPFormData = z.infer<typeof ipFormSchema>;
export type IpList = PageableList<IP[]>;
export interface IpService {
getIpList(params: IIpParams): Promise<IpList>;
saveIpList(body: IP[]): Promise<void>;
}
@@ -1,87 +0,0 @@
import { z } from "zod";
export interface ISiteParams {
siteNm: string;
siteKndCd: string;
useYn: string;
page: number;
size: number;
sort: string[];
}
export interface ISite {
siteId: string;
siteNm: string;
siteKindCode: string;
bscSiteYn: string;
siteAddr: string;
useYn: string;
regDt: string;
rgTrNm: string;
}
export interface ISiteInfo {
totalPages: number;
totalElements: number;
number: number;
sort: {
empty: boolean;
sorted: boolean;
unsorted: boolean;
};
size: number;
content: ISite[];
pageable: {
sort: {
empty: boolean;
sorted: boolean;
unsorted: boolean;
};
offset: number;
pageNumber: number;
pageSize: number;
paged: boolean;
unpaged: boolean;
};
numberOfElements: number;
first: boolean;
last: boolean;
empty: boolean;
}
export interface ISiteDetail extends SiteFormData {
siteId: string;
siteKindNm: string;
basicSiteYn: string;
faxNumber: string;
regDt: string;
rgTr: string;
mdfcnDt: string;
mdFr: string;
rgTrNm: string;
mdFrNm: string;
}
export const siteFormSchema = z.object({
siteId: z.string().max(2).nonempty(),
siteNm: z.string().max(100).nonempty(),
siteExpln: z.string().max(1000).optional(),
siteKndCd: z.string().max(10).nonempty(),
siteAddr: z.string().max(2000).optional(),
faxNo: z.string().max(100).optional(),
telNo: z.string().max(1000).optional(),
lwndCn: z.string().max(2000).optional(),
bscSiteYn: z.string(),
bkmkIcon: z.string().max(100).optional(),
useYn: z.string(),
siteSkn: z.string().max(10).optional(),
});
export type SiteFormData = z.infer<typeof siteFormSchema>;
export interface SiteService {
siteInfo(query: ISiteParams): Promise<ISiteInfo>;
siteDetail(siteId: string): Promise<ISiteDetail>;
modifySite(siteId: string, data: SiteFormData): Promise<void>;
createSite(data: SiteFormData): Promise<void>;
}
@@ -1,6 +1,6 @@
import dayjs from "dayjs";
import { createColumnHelper } from "@tanstack/react-table";
import type { ISite } from "./site-interface";
import type { ISite } from "@/entities/site";
const columnHelper = createColumnHelper<ISite>();
+21 -28
View File
@@ -1,53 +1,46 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { useMutation } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod";
import { SiteApi } from "../api/site-service";
import { siteFormSchema, type SiteFormData } from "../model/site-interface";
import { SitePageLayout } from "./layout";
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";
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
} from "@common/components/ui";
function CreatePage() {
const router = useRouter();
const { setMessage: alert } = useAlertStore((state) => state);
const { mutateAsync: createSite } = useMutation({
mutationFn: (data: SiteFormData) => SiteApi.createSite(data),
onSuccess: () => {
setIsCreateSuccess(true);
alert("등록되었습니다.", router.back);
},
});
const [isCreateSuccess, setIsCreateSuccess] = useState(false);
const form = useForm<SiteFormData>({
resolver: zodResolver(siteFormSchema),
defaultValues: {
siteId: "",
siteNm: "",
siteExpln: "",
siteKndCd: "",
siteAddr: "",
faxNo: "",
telNo: "",
lwndCn: "",
bscSiteYn: "",
bkmkIcon: "",
useYn: "",
siteSkn: "",
},
});
return (
<SitePageLayout>
<PageLayout pageTitle="사이트 관리">
<SiteForm form={form} handleSave={createSite} />
<Dialog open={isCreateSuccess} onOpenChange={() => router.back()}>
<DialogContent aria-describedby={undefined}>
<div className="py-10">
<p className="text-center text-[1.4rem]">등록되었습니다.</p>
</div>
<DialogFooter className="!justify-center">
<DialogClose asChild>
<Button>확인</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</SitePageLayout>
</PageLayout>
);
}
@@ -1,4 +1,8 @@
import { useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useForm, useFieldArray } from "react-hook-form";
import {
Dialog,
DialogTrigger,
@@ -13,197 +17,304 @@ import {
TableHeader,
TableRow,
Input,
Checkbox,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
RadioGroup,
RadioGroupItem,
DialogFooter,
DialogClose,
Checkbox,
} from "@common/components";
export type Domain = {
id: number;
domainUrl: string;
isRepresentative: boolean;
isActive: "true" | "false";
};
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/shared/ui/form";
import { ENDPOINT } from "@/shared/config";
import { DomainApi } from "../api/domain-service";
import {
domainFormSchema,
type Domain,
type DomainFormData,
} from "../model/domain-interface";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import { useAlertStore } from "@/shared/lib/use-alert-store";
type DomainManagementDialogProps = {
triggerDisabled?: boolean;
handleSave: (rows: Domain[]) => void;
siteId: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
handleSaveDomain?: (rows: Domain[]) => void;
};
function DomainManagementDialog({
triggerDisabled,
handleSave,
siteId,
handleSaveDomain,
...props
}: DomainManagementDialogProps) {
const [rows, setRows] = useState<Domain[]>([]);
const [rowSelection, setRowSelection] = useState<Domain["id"][]>([]);
const { confirm } = useConfirm();
const { setMessage: alert } = useAlertStore((state) => state);
const onSave = () => {
handleSave(rows);
const { data: domainList } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.DOMAINS, props.open],
queryFn: () => DomainApi.getDomainList({ siteId }),
select: (data) => {
const domains = data.content.map((item) => ({
...item,
mode: "U",
}));
initialForm();
return domains;
},
});
const { mutateAsync } = useMutation({
mutationFn: (body: Domain[]) => DomainApi.saveDomainList(body),
});
const form = useForm<DomainFormData>({
resolver: zodResolver(domainFormSchema),
values: {
domains: domainList ?? [],
},
});
const { fields, append, remove, update } = useFieldArray({
control: form.control,
name: "domains",
});
const [onConfirm, setOnConfirm] = useState(false);
const [selectedRows, setSelectedRows] = useState<number[]>([]);
const initialForm = () => {
form.reset();
setSelectedRows([]);
};
const onSubmit = async (data: DomainFormData) => {
setOnConfirm(true);
if (await confirm("저장하시겠습니까?")) {
setOnConfirm(false);
await mutateAsync(data.domains);
handleSaveDomain?.(data.domains);
alert("저장되었습니다.");
}
setOnConfirm(false);
};
const handleAddDomain = () => {
const newDomain: Domain = {
id: Date.now(),
domainUrl: "",
isRepresentative: rows.length === 0,
isActive: "true",
};
setRows([...rows, newDomain]);
};
const handleDeleteDomain = () => {
const filteredRows = rows.filter((row) => !rowSelection.includes(row.id));
const hasRepresentative = filteredRows.some((row) => row.isRepresentative);
let updatedRows = filteredRows;
if (!hasRepresentative && filteredRows.length > 0) {
updatedRows = filteredRows.map((row, index) =>
index === 0 ? { ...row, isRepresentative: true } : row,
);
}
setRows(updatedRows);
setRowSelection([]);
};
const handleDomainUrlChange = (id: number, value: string) => {
setRows(
rows.map((row) => (row.id === id ? { ...row, domainUrl: value } : row)),
);
};
const handleRepresentativeChange = (id: number) => {
setRows(
rows.map((row) => ({
...row,
isRepresentative: row.id === id,
})),
);
};
const handleActiveChange = (id: number, value: "true" | "false") => {
setRows(
rows.map((row) => (row.id === id ? { ...row, isActive: value } : row)),
);
};
const toggleRowSelection = (id: number) => {
setRowSelection((prev) => {
if (prev.includes(id)) {
return prev.filter((item) => item !== id);
} else {
return [...prev, id];
}
append({
siteDmnNo: null,
siteId,
dmnAddr: "",
useYn: "Y",
rprsDmnYn: "N",
mode: "C",
});
};
const handleSelectAll = () => {
if (selectedRows.length === fields.length) {
setSelectedRows([]);
} else {
setSelectedRows(fields.map((_, index) => index));
}
};
const handleSelectRow = (i: number) => {
setSelectedRows((prev) =>
prev.includes(i) ? prev.filter((j) => j !== i) : [...prev, i],
);
};
const handleDeleteDomain = async () => {
if (selectedRows.length === 0) return;
const sortedIndices = [...selectedRows].sort((a, b) => b - a);
sortedIndices.forEach((i) => {
const field = fields[i];
if (field?.siteDmnNo) {
update(i, {
...field,
mode: "D",
});
} else {
remove(i);
}
});
setSelectedRows([]);
};
const handleChangeRprsDmnYn = (i: number) => {
fields.forEach((_, j) => {
if (j === i) return;
form.setValue(`domains.${j}.rprsDmnYn`, "N");
});
};
const isAllSelected =
fields.length > 0 && selectedRows.length === fields.length;
return (
<Dialog>
<DialogTrigger asChild>
<Button disabled={triggerDisabled}>도메인 관리</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[60rem]">
<DialogHeader>
<DialogTitle>도메인 관리</DialogTitle>
</DialogHeader>
<div className="mb-4 flex justify-end gap-2">
<Button size="sm" color="white" onClick={handleAddDomain}>
항목 추가
</Button>
<Button
size="sm"
color="red"
onClick={handleDeleteDomain}
disabled={rowSelection.length === 0}
>
항목 삭제
</Button>
</div>
<div className="card card-border">
<Table variant="secondary">
<colgroup>
<col width="8%" />
<col width="12%" />
<col width="40%" />
<col width="20%" />
<col width="20%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>NO</TableHead>
<TableHead>선택</TableHead>
<TableHead>도메인URL</TableHead>
<TableHead>대표URL</TableHead>
<TableHead>사용여부</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row, idx) => (
<TableRow key={row.id}>
<TableCell align="center">{idx + 1}</TableCell>
<TableCell align="center">
<Checkbox
checked={rowSelection.includes(row.id)}
onCheckedChange={() => toggleRowSelection(row.id)}
className="cursor-pointer"
/>
</TableCell>
<TableCell align="center">
<Input
value={row.domainUrl}
onChange={(e) =>
handleDomainUrlChange(row.id, e.target.value)
}
className="!text-[1.3rem]"
/>
</TableCell>
<TableCell align="center">
<Checkbox
checked={row.isRepresentative}
onCheckedChange={() => handleRepresentativeChange(row.id)}
className="cursor-pointer"
/>
</TableCell>
<TableCell align="center">
<Select
value={row.isActive}
onValueChange={(value: "true" | "false") =>
handleActiveChange(row.id, value)
}
>
<SelectTrigger className="mx-auto w-[10rem]">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">예</SelectItem>
<SelectItem value="false">아니오</SelectItem>
</SelectContent>
</Select>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<DialogFooter className="!justify-center">
<DialogClose asChild>
<Button size="lg" onClick={onSave}>
저장
</Button>
</DialogClose>
<DialogClose asChild>
<Button size="lg" color="white">
취소
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
<>
<Dialog {...props}>
<DialogTrigger asChild>
<Button>도메인 관리</Button>
</DialogTrigger>
<DialogContent className="max-w-[60rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>도메인 관리</DialogTitle>
</DialogHeader>
<Form {...form}>
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit(onSubmit)(e);
}}
>
<div className="mb-4 flex justify-end gap-2">
<Button type="button" size="sm" onClick={handleAddDomain}>
항목 추가
</Button>
<Button
type="button"
size="sm"
color="red"
onClick={handleDeleteDomain}
disabled={selectedRows.length === 0}
>
항목 삭제
</Button>
</div>
<div className="card card-border max-h-[28.5rem] overflow-auto">
<Table variant="secondary">
<colgroup>
<col width="10%" />
<col width="auto" />
<col width="15%" />
<col width="25%" />
</colgroup>
<TableHeader className="sticky -top-[0.6rem]">
<TableRow>
<TableHead className="!px-0">
<Checkbox
checked={isAllSelected}
onCheckedChange={handleSelectAll}
/>
</TableHead>
<TableHead>도메인 주소</TableHead>
<TableHead>대표 URL</TableHead>
<TableHead>사용여부</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{fields.map((field, i) => {
if (field.mode === "D") return null;
return (
<TableRow key={field.id}>
<TableCell align="center" className="!px-0">
<Checkbox
checked={selectedRows.includes(i)}
onCheckedChange={() => handleSelectRow(i)}
/>
</TableCell>
<TableCell align="center">
<FormField
control={form.control}
name={`domains.${i}.dmnAddr`}
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
className="!text-[1.3rem]"
/>
</FormControl>
<FormMessage className="text-left" />
</FormItem>
)}
/>
</TableCell>
<TableCell align="center">
<FormField
control={form.control}
name={`domains.${i}.rprsDmnYn`}
render={({ field }) => (
<FormItem>
<FormControl>
<Checkbox
checked={field.value === "Y"}
onCheckedChange={(checked) => {
field.onChange(checked ? "Y" : "N");
handleChangeRprsDmnYn(i);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
<TableCell align="center">
<FormField
control={form.control}
name={`domains.${i}.useYn`}
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
value={field.value}
onValueChange={field.onChange}
className="flex justify-center"
>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="Y"
id={`useY-${i}`}
/>
<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>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
<DialogFooter className="!justify-center">
<Button type="submit" size="lg">
저장
</Button>
<DialogClose asChild>
<Button type="button" size="lg" color="white">
취소
</Button>
</DialogClose>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
<ConfirmDialog open={onConfirm} />
</>
);
}
+249 -141
View File
@@ -1,4 +1,8 @@
import { useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useForm, useFieldArray } from "react-hook-form";
import {
Dialog,
DialogTrigger,
@@ -15,162 +19,266 @@ import {
Input,
RadioGroup,
RadioGroupItem,
Checkbox,
DialogFooter,
DialogClose,
Checkbox,
} from "@common/components";
type IP = {
id: number;
ipAddress: string;
allowed: boolean;
};
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} 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";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import { useAlertStore } from "@/shared/lib/use-alert-store";
type IPManagementDialogProps = {
siteId: string;
triggerDisabled?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
};
function IPManagementDialog({ triggerDisabled }: IPManagementDialogProps) {
const [rows, setRows] = useState<IP[]>([]);
const [rowSelection, setRowSelection] = useState<IP["id"][]>([]);
function IPManagementDialog({
siteId,
triggerDisabled,
...props
}: IPManagementDialogProps) {
const { confirm } = useConfirm();
const { setMessage: alert } = useAlertStore((state) => state);
const onSave = () => {};
const { data: ipList } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.IPS, props.open],
queryFn: () => IpApi.getIpList({ siteId, sort: [] }),
select: (data) => {
const ips = data.content.map((item) => ({
...item,
mode: "U",
}));
initialForm();
return ips;
},
});
const { mutateAsync } = useMutation({
mutationFn: (body: IP[]) => IpApi.saveIpList(body),
});
const form = useForm<IPFormData>({
resolver: zodResolver(ipFormSchema),
values: {
ips: ipList ?? [],
},
});
const { fields, append, remove, update } = useFieldArray({
control: form.control,
name: "ips",
});
const [onConfirm, setOnConfirm] = useState(false);
const [selectedRows, setSelectedRows] = useState<number[]>([]);
const initialForm = () => {
form.reset();
setSelectedRows([]);
};
const onSubmit = async (data: IPFormData) => {
setOnConfirm(true);
if (await confirm("저장하시겠습니까?")) {
setOnConfirm(false);
await mutateAsync(data.ips);
alert("저장되었습니다.");
}
};
const handleAddIP = () => {
const newIP: IP = {
id: Date.now(),
ipAddress: "",
allowed: true,
};
setRows([...rows, newIP]);
};
const handleDeleteIP = () => {
const filteredRows = rows.filter((row) => !rowSelection.includes(row.id));
setRows(filteredRows);
setRowSelection([]);
};
const handleIPAddressChange = (id: number, value: string) => {
setRows(
rows.map((row) => (row.id === id ? { ...row, ipAddress: value } : row)),
);
};
const handleAllowedChange = (id: number, value: boolean) => {
setRows(
rows.map((row) => (row.id === id ? { ...row, allowed: value } : row)),
);
};
const toggleRowSelection = (id: number) => {
setRowSelection((prev) => {
if (prev.includes(id)) {
return prev.filter((item) => item !== id);
} else {
return [...prev, id];
}
append({
mngNo: null,
siteId,
ipAddr: "",
prmYn: "Y",
mode: "C",
});
};
const handleSelectAll = () => {
if (selectedRows.length === fields.length) {
setSelectedRows([]);
} else {
setSelectedRows(fields.map((_, index) => index));
}
};
const handleSelectRow = (i: number) => {
setSelectedRows((prev) =>
prev.includes(i) ? prev.filter((j) => j !== i) : [...prev, i],
);
};
const handleDeleteIP = async () => {
if (selectedRows.length === 0) return;
const sortedIndices = [...selectedRows].sort((a, b) => b - a);
sortedIndices.forEach((i) => {
const field = fields[i];
if (field?.mngNo) {
update(i, {
...field,
mode: "D",
});
} else {
remove(i);
}
});
setSelectedRows([]);
};
const isAllSelected =
fields.length > 0 && selectedRows.length === fields.length;
return (
<Dialog>
<DialogTrigger asChild>
<Button disabled={triggerDisabled}>IP 관리</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[60rem]">
<DialogHeader>
<DialogTitle>IP 관리</DialogTitle>
</DialogHeader>
<div className="mb-4 flex justify-end gap-2">
<Button size="sm" color="green">
엑셀 업로드
</Button>
<Button size="sm" color="white" onClick={handleAddIP}>
항목 추가
</Button>
<Button
size="sm"
color="red"
onClick={handleDeleteIP}
disabled={rowSelection.length === 0}
>
항목 삭제
</Button>
</div>
<div className="card card-border">
<Table variant="secondary">
<colgroup>
<col width="10%" />
<col width="10%" />
<col width="55%" />
<col width="25%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>NO</TableHead>
<TableHead>선택</TableHead>
<TableHead>IP 주소</TableHead>
<TableHead>허용여부</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row, idx) => (
<TableRow key={row.id}>
<TableCell align="center">{idx + 1}</TableCell>
<TableCell align="center">
<Checkbox
checked={rowSelection.includes(row.id)}
onCheckedChange={() => toggleRowSelection(row.id)}
className="cursor-pointer"
/>
</TableCell>
<TableCell align="center">
<Input
value={row.ipAddress}
onChange={(e) =>
handleIPAddressChange(row.id, e.target.value)
}
className="!text-[1.3rem]"
/>
</TableCell>
<TableCell align="center">
<RadioGroup
value={row.allowed ? "true" : "false"}
onValueChange={(value) =>
handleAllowedChange(row.id, value === "true")
}
className="flex justify-center"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="true" id={`allow-${row.id}`} />
<label htmlFor={`allow-${row.id}`}>예</label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="false" id={`deny-${row.id}`} />
<label htmlFor={`deny-${row.id}`}>아니오</label>
</div>
</RadioGroup>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<DialogFooter className="!justify-center">
<DialogClose asChild>
<Button size="lg" onClick={onSave}>
저장
</Button>
</DialogClose>
<DialogClose asChild>
<Button size="lg" color="white">
취소
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
<>
<Dialog {...props}>
<DialogTrigger asChild>
<Button disabled={triggerDisabled}>IP 관리</Button>
</DialogTrigger>
<DialogContent className="max-w-[60rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>IP 관리</DialogTitle>
</DialogHeader>
<Form {...form}>
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit(onSubmit)(e);
}}
>
<div className="mb-4 flex justify-end gap-2">
<Button type="button" size="sm" onClick={handleAddIP}>
항목 추가
</Button>
<Button
type="button"
size="sm"
color="red"
onClick={handleDeleteIP}
disabled={selectedRows.length === 0}
>
항목 삭제
</Button>
</div>
<div className="card card-border max-h-[28.5rem] overflow-auto">
<Table variant="secondary">
<colgroup>
<col width="10%" />
<col width="65%" />
<col width="25%" />
</colgroup>
<TableHeader className="sticky -top-[0.6rem]">
<TableRow>
<TableHead className="!px-0">
<Checkbox
checked={isAllSelected}
onCheckedChange={handleSelectAll}
/>
</TableHead>
<TableHead>IP 주소</TableHead>
<TableHead>허용여부</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{fields.map((field, i) => {
if (field.mode === "D") return null;
return (
<TableRow key={field.id}>
<TableCell align="center" className="!px-0">
<Checkbox
checked={selectedRows.includes(i)}
onCheckedChange={() => handleSelectRow(i)}
/>
</TableCell>
<TableCell align="center">
<FormField
control={form.control}
name={`ips.${i}.ipAddr`}
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
className="!text-[1.3rem]"
/>
</FormControl>
<FormMessage className="text-left" />
</FormItem>
)}
/>
</TableCell>
<TableCell align="center">
<FormField
control={form.control}
name={`ips.${i}.prmYn`}
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
value={field.value}
onValueChange={field.onChange}
className="flex justify-center"
>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="Y"
id={`prmY-${i}`}
/>
<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>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
<DialogFooter className="!justify-center">
<Button type="submit" size="lg">
저장
</Button>
<DialogClose asChild>
<Button type="button" size="lg" color="white">
취소
</Button>
</DialogClose>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
<ConfirmDialog open={onConfirm} />
</>
);
}
+31 -50
View File
@@ -1,94 +1,75 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { useMutation, useQuery } from "@tanstack/react-query";
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 { SiteApi } from "../api/site-service";
import {
siteFormSchema,
type ISiteDetail,
type SiteFormData,
} from "../model/site-interface";
import { SitePageLayout } from "./layout";
import { PageLayout } from "@/shared/ui/page-layout";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import { SiteForm } from "./site-form";
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
} from "@common/components/ui";
function ModifyPage({ siteId }: { siteId: string }) {
const isModifyMode = !!siteId;
const router = useRouter();
const queryClient = useQueryClient();
const { setMessage: alert } = useAlertStore((state) => state);
const { data: siteDetail } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.SITES, siteId],
queryFn: () => SiteApi.siteDetail(siteId),
select: (data) => ({
...data,
faxNo: data.faxNumber,
bscSiteYn: data.basicSiteYn,
}),
select: (data) => {
const keys = Object.keys(siteFormSchema.shape);
const filteredData = Object.fromEntries(
Object.entries(data).filter(([key]) => keys.includes(key)),
);
return {
...filteredData,
faxNo: data.faxNumber,
bscSiteYn: data.basicSiteYn,
};
},
enabled: isModifyMode,
});
const { mutateAsync: modifySite } = useMutation({
mutationFn: (data: SiteFormData) => SiteApi.modifySite(siteId, data),
onSuccess: () => {
setMessage("수정되었습니다.");
alert("수정되었습니다.", () => {
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.SITES],
});
});
},
});
const { mutateAsync: deleteSite } = useMutation({
mutationFn: () => SiteApi.deleteSite(siteId),
onSuccess: () => {
setMessage("삭제되었습니다.");
alert("삭제되었습니다.", () => {
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.SITES],
});
router.back();
});
},
});
const [message, setMessage] = useState("");
const form = useForm<SiteFormData>({
resolver: zodResolver(siteFormSchema),
values: siteDetail as SiteFormData,
});
useEffect(() => {
if (!siteDetail) return;
const keys = Object.keys(siteFormSchema.shape);
for (const key of keys) {
form.setValue(
key as keyof SiteFormData,
siteDetail[key as keyof ISiteDetail] ?? "",
);
}
}, [siteDetail]);
return (
<SitePageLayout>
<PageLayout pageTitle="사이트 관리">
<SiteForm
form={form}
siteId={siteId}
handleSave={modifySite}
handleDelete={deleteSite}
/>
<Dialog open={!!message} onOpenChange={() => router.back()}>
<DialogContent aria-describedby={undefined}>
<div className="py-10">
<p className="text-center text-[1.4rem]">{message}</p>
</div>
<DialogFooter className="!justify-center">
<DialogClose asChild>
<Button>확인</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</SitePageLayout>
</PageLayout>
);
}
+47 -53
View File
@@ -4,10 +4,6 @@ import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { type UseFormReturn } from "react-hook-form";
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
Input,
Label,
RadioGroup,
@@ -19,6 +15,7 @@ import {
TableRow,
} from "@common/components";
import { CodeSelect } from "@/entities/code";
import type { SiteFormData } from "@/entities/site";
import {
FormItem,
FormControl,
@@ -26,47 +23,52 @@ import {
Form,
FormMessage,
} from "@/shared/ui/form";
import { type ISiteDetail, type SiteFormData } from "../model/site-interface";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import type { Domain } from "../model/domain-interface";
import { IPManagementDialog } from "./ip-management-dialog";
import {
DomainManagementDialog,
type Domain,
} from "./domain-management-dialog";
import { DomainManagementDialog } from "./domain-management-dialog";
type SiteFormProps = {
form: UseFormReturn<SiteFormData>;
siteId?: string;
handleSave: (data: SiteFormData) => void;
handleDelete?: () => void;
handleSave: (data: SiteFormData) => Promise<void>;
handleDelete?: () => Promise<void>;
};
function SiteForm({ form, siteId, handleSave, handleDelete }: SiteFormProps) {
const router = useRouter();
const { message, confirm, onConfirm, onCancel } = useConfirm();
const [viewState, setViewState] = useState<Partial<ISiteDetail>>({});
const [representativeDomain, setRepresentativeDomain] = useState<string>("");
const isModifyMode = !!siteId;
const handleViewStateChange = (key: keyof ISiteDetail) => (value: string) => {
setViewState({ ...viewState, [key]: value });
const router = useRouter();
const { confirm } = useConfirm();
const [onConfirm, setOnConfirm] = useState(false);
const [isIpManagementOpen, setIsIpManagementOpen] = useState(false);
const [isDomainManagementOpen, setIsDomainManagementOpen] = useState(false);
const [domainUrl, setDomainUrl] = useState("");
const handleSaveDomain = (rows: Domain[]) => {
setDomainUrl(rows.find((row) => row.rprsDmnYn === "Y")?.dmnAddr || "");
};
const handleDomainSave = (rows: Domain[]) => {
setRepresentativeDomain(
rows.find((row) => row.isRepresentative)?.domainUrl || "",
);
};
const onSubmit = async (data: SiteFormData) => {
const confirmMessage = isModifyMode
? "수정하시겠습니까?"
: "저장하시겠습니까?";
const onSubmit = (data: SiteFormData) => {
handleSave(data);
setOnConfirm(true);
if (await confirm(confirmMessage)) {
setOnConfirm(false);
handleSave(data);
}
setOnConfirm(false);
};
const onDelete = async () => {
if (await confirm("정말 삭제하시겠습니까?")) {
setOnConfirm(true);
if (await confirm("삭제하시겠습니까?")) {
setOnConfirm(false);
handleDelete?.();
}
setOnConfirm(false);
};
return (
@@ -120,10 +122,7 @@ function SiteForm({ form, siteId, handleSave, handleDelete }: SiteFormProps) {
<CodeSelect
{...field}
upCd="SITEKNDCD"
onValueChange={(value) => {
handleViewStateChange("siteKndCd")(value);
field.onChange(value);
}}
onValueChange={field.onChange}
/>
</FormControl>
<FormMessage />
@@ -132,7 +131,10 @@ function SiteForm({ form, siteId, handleSave, handleDelete }: SiteFormProps) {
/>
{isModifyMode && (
<IPManagementDialog
triggerDisabled={viewState.siteKndCd === "O"}
siteId={siteId}
triggerDisabled={form.watch("siteKndCd") === "O"}
open={isIpManagementOpen}
onOpenChange={setIsIpManagementOpen}
/>
)}
</div>
@@ -149,6 +151,7 @@ function SiteForm({ form, siteId, handleSave, handleDelete }: SiteFormProps) {
<FormControl>
<CodeSelect
{...field}
value={field.value ?? ""}
upCd="SITESKINCD"
onValueChange={field.onChange}
/>
@@ -191,6 +194,7 @@ function SiteForm({ form, siteId, handleSave, handleDelete }: SiteFormProps) {
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="사이트 설명을 입력하십시오."
/>
@@ -207,10 +211,13 @@ function SiteForm({ form, siteId, handleSave, handleDelete }: SiteFormProps) {
<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">
{representativeDomain}
{domainUrl}
</div>
<DomainManagementDialog
handleSave={handleDomainSave}
siteId={siteId}
open={isDomainManagementOpen}
onOpenChange={setIsDomainManagementOpen}
handleSaveDomain={handleSaveDomain}
/>
</div>
</TableCell>
@@ -227,6 +234,7 @@ function SiteForm({ form, siteId, handleSave, handleDelete }: SiteFormProps) {
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="사이트 하단 주소를 입력하십시오."
/>
@@ -248,6 +256,7 @@ function SiteForm({ form, siteId, handleSave, handleDelete }: SiteFormProps) {
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="사이트 하단 전화번호를 입력하십시오."
/>
@@ -269,6 +278,7 @@ function SiteForm({ form, siteId, handleSave, handleDelete }: SiteFormProps) {
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="사이트 하단 팩스번호를 입력하십시오."
/>
@@ -290,6 +300,7 @@ function SiteForm({ form, siteId, handleSave, handleDelete }: SiteFormProps) {
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="사이트 표기 내용을 입력하십시오. (ex. COPYRIGHT c 2013 KECO. ALL RIGHTS RESERVED.)"
/>
@@ -311,6 +322,7 @@ function SiteForm({ form, siteId, handleSave, handleDelete }: SiteFormProps) {
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="아이콘 파일 경로 및 파일명을 입력하여 주십시오."
/>
@@ -405,25 +417,7 @@ function SiteForm({ form, siteId, handleSave, handleDelete }: SiteFormProps) {
</div>
</form>
</Form>
<Dialog open={!!message} onOpenChange={onCancel}>
<DialogContent aria-describedby={undefined}>
<div className="py-10">
<p className="text-center text-[1.4rem]">{message}</p>
</div>
<DialogFooter className="!justify-center">
<DialogClose asChild>
<Button color="red" onClick={onConfirm}>
삭제
</Button>
</DialogClose>
<DialogClose asChild>
<Button color="white" onClick={onCancel}>
취소
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog open={onConfirm} />
</>
);
}
+12 -20
View File
@@ -7,31 +7,25 @@ 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 { ENDPOINT } from "@/shared/config/api";
import { SiteApi } from "../api/site-service";
import { SiteApi, type ISite, type ISiteParams } from "@/entities/site";
import { ENDPOINT } from "@/shared/config";
import { PageLayout } from "@/shared/ui/page-layout";
import { columns } from "../model/table-columns";
import { type ISite, type ISiteParams } from "../model/site-interface";
import { SitePageLayout } from "./layout";
function SitePage() {
const router = useRouter();
const { data: siteInfo, refetch } = useQuery({
const { data: siteList, refetch } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.SITES],
queryFn: () => SiteApi.siteInfo(siteQuery),
queryFn: () => SiteApi.getSiteList(siteQuery),
enabled: false,
});
const [siteQuery, setSiteQuery] = useState<ISiteParams>({
siteNm: "",
siteKndCd: "",
useYn: "",
page: 0,
size: 0,
sort: [],
});
const table = useReactTable({
data: siteInfo?.content ?? [],
data: siteList?.content ?? [],
columns,
getCoreRowModel: getCoreRowModel(),
});
@@ -51,19 +45,17 @@ function SitePage() {
}, []);
return (
<SitePageLayout>
<PageLayout pageTitle="사이트 관리">
<div className="card card-border !mt-6">
<div className="card card-border !border-[#dbe2e6] !bg-[#eef7ff]">
<div className="card card-border">
<form onSubmit={handleSubmit} className="flex items-center gap-2 p-2">
<label htmlFor="" className="mr-2 text-[#657481]">
<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({ ...siteQuery, siteNm: e.target.value })
}
onChange={(e) => setSiteQuery({ siteNm: e.target.value })}
/>
<Button className="h-[3.2rem]" type="submit">
조회
@@ -72,7 +64,7 @@ function SitePage() {
</div>
<div className="card card-border">
<div className="card-header">
<h3 className="text-2xl font-medium text-[#666]">사이트 목록</h3>
<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">
@@ -80,7 +72,7 @@ function SitePage() {
</div>
</div>
</div>
</SitePageLayout>
</PageLayout>
);
}
+3
View File
@@ -11,6 +11,9 @@ export const ENDPOINT = {
CMS_SERVICE: {
SITES: "/cms-service/api/v1/sites",
CODE: "/cms-service/api/v1/code/cache",
IPS: "/cms-service/api/v1/site-ips",
DOMAINS: "/cms-service/api/v1/site-dmns",
MENUS: "/cms-service/api/v1/menus",
},
};
+14
View File
@@ -0,0 +1,14 @@
import { useStore } from "zustand";
import { useContext } from "react";
import type { AlertStore } from "../store/alert-store";
import { StoreContext } from "../providers/store-provider";
export const useAlertStore = <T>(selector: (store: AlertStore) => T): T => {
const storeContext = useContext(StoreContext);
if (!storeContext) {
throw new Error("useAlertStore must be used within StoreProvider");
}
return useStore(storeContext.alertStore, selector);
};
+1 -1
View File
@@ -1,6 +1,6 @@
import { useStore } from "zustand";
import { useContext } from "react";
import { type UserStore } from "@/shared/store/user-store";
import type { UserStore } from "../store/user-store";
import { StoreContext } from "../providers/store-provider";
export const useUserStore = <T>(selector: (store: UserStore) => T): T => {
+34
View File
@@ -0,0 +1,34 @@
interface SortConfig {
empty: boolean;
sorted: boolean;
unsorted: boolean;
}
interface PageableConfig {
sort: SortConfig;
offset: number;
pageNumber: number;
pageSize: number;
paged: boolean;
unpaged: boolean;
}
export interface PageableList<T> {
content: T;
pageable: PageableConfig;
sort: SortConfig;
totalPages: number;
totalElements: number;
number: number;
numberOfElements: number;
size: number;
first: boolean;
last: boolean;
empty: boolean;
}
export interface IPageableParams {
page?: number;
size?: number;
sort?: string[];
}
+1
View File
@@ -0,0 +1 @@
export * from "./api-interface";
-1
View File
@@ -1,2 +1 @@
export { QueryProvider } from "./query-provider";
export { StoreProvider } from "./store-provider";
@@ -1,9 +1,11 @@
"use client";
import { createContext, useRef } from "react";
import { createUserStore } from "@/shared/store/user-store";
import { createAlertStore } from "../store/alert-store";
import { createUserStore } from "../store/user-store";
type StoreContextType = {
alertStore: ReturnType<typeof createAlertStore>;
userStore: ReturnType<typeof createUserStore>;
};
@@ -15,6 +17,7 @@ export const StoreProvider = ({ children }: { children: React.ReactNode }) => {
const storeRef = useRef<StoreContextType | null>(null);
if (storeRef.current === null) {
storeRef.current = {
alertStore: createAlertStore(),
userStore: createUserStore(),
};
}
+25
View File
@@ -0,0 +1,25 @@
import { createStore } from "zustand/vanilla";
export type AlertState = {
message: string;
callback?: () => void;
};
export type AlertActions = {
setMessage: (message: string, callback?: () => void) => void;
};
export type AlertStore = AlertState & AlertActions;
export const defaultInitState: AlertState = {
message: "",
callback: undefined,
};
export const createAlertStore = (initState: AlertState = defaultInitState) => {
return createStore<AlertStore>()((set) => ({
...initState,
setMessage: (message: string, callback?: () => void) =>
set(() => ({ message, callback })),
}));
};
+32
View File
@@ -0,0 +1,32 @@
import {
Dialog,
DialogContent,
DialogFooter,
DialogClose,
Button,
} from "@common/components/ui";
import { useAlertStore } from "../lib/use-alert-store";
function AlertDialog() {
const { message, callback, setMessage } = useAlertStore((state) => state);
const handleOpenChange = () => {
setMessage("");
callback?.();
};
return (
<Dialog open={!!message} onOpenChange={handleOpenChange}>
<DialogContent aria-describedby={undefined}>
<div className="py-10">
<p className="text-center text-[1.4rem] text-foreground">{message}</p>
</div>
<DialogFooter className="!justify-center">
<DialogClose asChild>
<Button>확인</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { AlertDialog };
+46
View File
@@ -0,0 +1,46 @@
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import {
Dialog,
DialogContent,
DialogFooter,
DialogClose,
Button,
type ButtonProps,
} from "@common/components/ui";
type ConfirmDialogProps = {
open: boolean;
confirmButtonProps?: ButtonProps & { label?: string };
cancelButtonProps?: ButtonProps & { label?: string };
};
function ConfirmDialog({
open,
confirmButtonProps,
cancelButtonProps,
}: ConfirmDialogProps) {
const { message, onConfirm, onCancel } = useConfirm();
return (
<Dialog open={open} onOpenChange={onCancel}>
<DialogContent aria-describedby={undefined}>
<div className="py-10">
<p className="text-center text-[1.4rem] text-foreground">{message}</p>
</div>
<DialogFooter className="!justify-center">
<DialogClose asChild>
<Button onClick={onConfirm} {...confirmButtonProps}>
{confirmButtonProps?.label ?? "확인"}
</Button>
</DialogClose>
<DialogClose asChild>
<Button onClick={onCancel} color="white" {...cancelButtonProps}>
{cancelButtonProps?.label ?? "취소"}
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { ConfirmDialog };
@@ -1,12 +1,18 @@
function SitePageLayout({ children }: { children: React.ReactNode }) {
function PageLayout({
children,
pageTitle,
}: {
children: React.ReactNode;
pageTitle: string;
}) {
return (
<div className="h-auto min-h-full bg-container">
<h2 className="px-[1.8rem] pt-[2rem] text-[2.4rem] font-semibold text-title">
사이트 관리
{pageTitle}
</h2>
{children}
</div>
);
}
export { SitePageLayout };
export { PageLayout };
-1
View File
@@ -4,7 +4,6 @@ import presetConfig from "../../tailwind.config.preset";
export default {
presets: [presetConfig],
darkMode: ["class", "[data-mode='dark']"],
content: [
"./src/**/*.{js,ts,jsx,tsx,mdx}",
"../../packages/common/components/src/ui/**/*.{js,ts,jsx,tsx,mdx}",
@@ -49,4 +49,4 @@ import { useWrStore } from '../../../store';
</div>
</>
)
}
}
+1 -1
View File
@@ -45,7 +45,7 @@
--container: #f1f6f9;
--title: #4c5e6f;
}
[data-mode="dark"] {
[data-theme="black"] {
--background: #0a0a0a;
--foreground: #fafafa;
--card: #0a0a0a;
@@ -25,10 +25,10 @@ const preview: Preview = {
withThemeByDataAttribute({
themes: {
light: "light",
dark: "dark",
black: "black",
},
defaultTheme: "light",
attributeName: "data-mode",
attributeName: "data-theme",
}),
(Story) => (
<div className="rounded-lg bg-background px-28 py-16">
+2
View File
@@ -26,9 +26,11 @@
},
"dependencies": {
"@common/assets": "workspace:*",
"@frontend-opensource/use-react-hooks": "^1.1.4",
"@minoru/react-dnd-treeview": "^3.5.0",
"@radix-ui/react-accordion": "^1.2.3",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
@@ -1,2 +1,4 @@
export * from "./client";
export * from "./menu";
export * from "./site";
export * from "./user";
@@ -0,0 +1,27 @@
import { client } from "./client";
import { ENDPOINT } from "../config";
export interface MenuItem {
menuCd: number;
menuKornNm: string;
menuEngNm: string;
parentId: number | null;
sortSeq: number;
iconNm?: string;
children: MenuItem[] | null;
levelNo: number;
urlPath: string;
menuLnkgTypeCd: string;
menuLnkgSn: number;
qrcd: string;
useYn: string;
otptYn: string;
requiredUrlPath: boolean;
}
export const getMenuList = async (siteId: string) => {
const response = await client.get<MenuItem[]>(
`${ENDPOINT.CMS_SERVICE.MENU_ROLES}/${siteId}`,
);
return response.data;
};
@@ -0,0 +1,31 @@
import { client } from "./client";
import { ENDPOINT } from "../config";
export interface SiteInfo {
siteId: string;
siteNm: string;
siteExpln: string;
siteKndCd: string;
siteKindNm: string;
siteAddr: string;
faxNumber: string;
telNo: string;
lwndCn: string;
basicSiteYn: string;
bkmkIcon: string;
useYn: string;
siteSkn: string;
regDt: string;
rgTr: string;
mdfcnDt: string;
mdFr: string;
rgTrNm: string;
mdFrNm: string;
}
export const getSiteInfo = async (siteId: string) => {
const response = await client.get<SiteInfo>(
`${ENDPOINT.CMS_SERVICE.SITES}/${siteId}`,
);
return response.data;
};
+1 -1
View File
@@ -1,5 +1,5 @@
import { ENDPOINT } from "../config";
import { client } from "./client";
import { ENDPOINT } from "../config";
export interface IUser {
regDt: string;
@@ -3,6 +3,10 @@ import { PROXY_HOST } from "./env";
export const BASE_URL = `${PROXY_HOST}/server`;
export const ENDPOINT = {
CMS_SERVICE: {
MENU_ROLES: "/cms-service/api/v1/menu-roles",
SITES: "/cms-service/api/v1/sites",
},
USER_SERVICE: {
USERS: "/user-service/api/v1/users",
},
+2 -21
View File
@@ -10,7 +10,7 @@ export function getCookie(name: string): string | undefined {
export function setCookie(
name: string,
value: string,
options: Cookies.CookieAttributes,
options?: Cookies.CookieAttributes,
): void {
if (typeof window === "undefined") {
return;
@@ -20,29 +20,10 @@ export function setCookie(
export function removeCookie(
name: string,
options: Cookies.CookieAttributes,
options?: Cookies.CookieAttributes,
): void {
if (typeof window === "undefined") {
return;
}
Cookies.remove(name, options);
}
export function getToken(key: string): string | undefined {
return getCookie(key);
}
export function setToken(
key: string,
token: string,
options: Cookies.CookieAttributes,
): void {
setCookie(key, token, {
...options,
path: "/",
});
}
export function removeToken(key: string): void {
removeCookie(key, { path: "/" });
}
@@ -1,9 +1,14 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
export const QueryProvider = ({ children }: { children: React.ReactNode }) => {
export function QueryProvider({
children,
client,
}: {
children: React.ReactNode;
client?: QueryClient;
}) {
const queryClient = client ?? new QueryClient();
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}
@@ -10,16 +10,16 @@
/* 버튼 색상 */
div[role="radiogroup"][data-variant="button"][data-color="primary"] {
@apply border border-primary dark:border-[#404040];
@apply border border-primary;
}
div[role="radiogroup"][data-variant="button"][data-color="primary"] button {
@apply text-[#6f98bd] hover:bg-[#ecf3f9] hover:text-[#498bc7] data-[state=checked]:bg-primary data-[state=checked]:text-white dark:bg-[#505050] dark:text-[#aeb7bf] dark:hover:bg-[#525e69] dark:data-[state=checked]:bg-primary dark:data-[state=checked]:text-white;
@apply text-[#6f98bd] hover:bg-[#ecf3f9] hover:text-[#498bc7] data-[state=checked]:bg-primary data-[state=checked]:text-white;
}
div[role="radiogroup"][data-variant="button"][data-color="secondary"] {
@apply border border-[#ababab] dark:border-[#808080];
@apply border border-[#ababab];
}
div[role="radiogroup"][data-variant="button"][data-color="secondary"] button {
@apply bg-[#f3f3f3] text-[#8a8a8a] hover:bg-[#ececec] hover:text-[#808080] data-[state=checked]:bg-[#848484] data-[state=checked]:text-white dark:bg-[#505050] dark:text-[#a5a5a5] dark:hover:bg-[#444444] dark:data-[state=checked]:bg-[#848484] dark:data-[state=checked]:text-white;
@apply bg-[#f3f3f3] text-[#8a8a8a] hover:bg-[#ececec] hover:text-[#808080] data-[state=checked]:bg-[#848484] data-[state=checked]:text-white;
}
/* 버튼 모양 */
@@ -28,13 +28,37 @@
}
div[role="radiogroup"][data-variant="button"][data-color="primary"][data-shape="rounded"]
button {
@apply bg-[#70bcff] text-white hover:bg-[#6bb0ec] data-[state=checked]:bg-primary dark:bg-[#505050] dark:hover:bg-[#525e69] dark:data-[state=checked]:bg-primary;
@apply bg-[#70bcff] text-white hover:bg-[#6bb0ec] data-[state=checked]:bg-primary;
}
div[role="radiogroup"][data-variant="button"][data-color="secondary"][data-shape="rounded"] {
@apply border-[#7b7b7b];
}
div[role="radiogroup"][data-variant="button"][data-color="secondary"][data-shape="rounded"]
button {
@apply bg-[#b7b7b7] text-white hover:bg-[#a9a9a9] data-[state=checked]:bg-[#797979] dark:bg-[#505050] dark:hover:bg-[#444444] dark:data-[state=checked]:bg-[#797979];
@apply bg-[#b7b7b7] text-white hover:bg-[#a9a9a9] data-[state=checked]:bg-[#797979];
}
[data-theme="black"] {
div[role="radiogroup"][data-variant="button"][data-color="primary"] {
@apply border-[#404040];
}
div[role="radiogroup"][data-variant="button"][data-color="primary"] button {
@apply bg-[#505050] text-[#aeb7bf] hover:bg-[#525e69] data-[state=checked]:bg-primary data-[state=checked]:text-white;
}
div[role="radiogroup"][data-variant="button"][data-color="secondary"] {
@apply border-[#808080];
}
div[role="radiogroup"][data-variant="button"][data-color="secondary"]
button {
@apply bg-[#505050] text-[#a5a5a5] hover:bg-[#444444] data-[state=checked]:bg-[#848484] data-[state=checked]:text-white;
}
div[role="radiogroup"][data-variant="button"][data-color="primary"][data-shape="rounded"]
button {
@apply bg-[#505050] hover:bg-[#525e69] data-[state=checked]:bg-primary;
}
div[role="radiogroup"][data-variant="button"][data-color="secondary"][data-shape="rounded"]
button {
@apply bg-[#505050] hover:bg-[#444444] data-[state=checked]:bg-[#797979];
}
}
}
@@ -0,0 +1,9 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
@@ -1,8 +1,8 @@
import { useQuery } from "@tanstack/react-query";
import { images } from "@common/assets";
import { getUserInfo } from "../../api";
import { AUTH_USER_ID, ENDPOINT } from "../../config";
import { getToken } from "../../lib";
import { getCookie, setCookie } from "../../lib";
import { getSiteInfo, getUserInfo } from "../../api";
import { AUTH_USER_ID, ENDPOINT, SITE_ID } from "../../config";
import {
DropdownMenu,
DropdownMenuTrigger,
@@ -12,15 +12,30 @@ import {
import { Button } from "../button";
export function Header({ viewOnly }: { viewOnly?: boolean }) {
const userUniqId = getToken(AUTH_USER_ID) ?? "";
const userUniqId = getCookie(AUTH_USER_ID) ?? "";
const { data: userInfo } = useQuery({
queryKey: [ENDPOINT.USER_SERVICE.USERS],
queryFn: () => getUserInfo(userUniqId),
enabled: !viewOnly,
});
const { data: siteInfo } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.SITES, SITE_ID],
queryFn: () => getSiteInfo(SITE_ID),
select: (data) => {
if (data.siteSkn) {
const siteSkin = data.siteSkn.toLowerCase();
setCookie("site-theme-key", siteSkin);
document.documentElement.setAttribute("data-theme", siteSkin);
}
return data;
},
enabled: !viewOnly,
});
return (
<header className="sticky top-0 box-border flex h-[5rem] w-full items-center justify-between border-b border-solid border-border bg-background">
<header className="sticky top-0 z-30 box-border flex h-[5rem] w-full items-center justify-between border-b border-solid border-border bg-background">
<div className="flex items-center">
<div className="MobMenuBtn">
<img src={images.menuBtn} />
@@ -28,7 +43,7 @@ export function Header({ viewOnly }: { viewOnly?: boolean }) {
<h1 className="flex items-center gap-4">
<img src={images.logo} className="h-[3rem]" alt="logo" />
<span className="text-[1.8rem] font-semibold text-foreground">
통합플랫폼관리
{siteInfo?.siteNm}
</span>
</h1>
</div>
@@ -0,0 +1,592 @@
export default [
"account-box",
"account-circle",
"add-image",
"add-Note",
"alarm-add",
"alarm-clock",
"alarm-off",
"alarm-on",
"alarm",
"alternate",
"analytics",
"android",
"announcement",
"app-indicator",
"app-list",
"app-square",
"apple",
"apps-square",
"apps",
"archive-fill",
"archive",
"arrow-90left-down",
"arrow-90left-up",
"arrow-90left",
"arrow-90right-down",
"arrow-90right-up",
"arrow-90right",
"arrow-back-square-fill",
"arrow-back",
"arrow-down-square-fill",
"arrow-down-square",
"arrow-down",
"arrow-forward-square-fill",
"arrow-forward",
"arrow-left-right",
"arrow-left-square-fill",
"arrow-left-square",
"arrow-left",
"arrow-lr-up-down",
"arrow-reflash",
"arrow-reset",
"arrow-right-square-fill",
"arrow-right-square",
"arrow-right",
"arrow-up-down",
"arrow-up-square-fill",
"arrow-up-square",
"arrow-up",
"arrows-angle-contract",
"arrows-angle-expand",
"arrows-fullscreen",
"aspect-ratio-fill",
"aspect-ratio",
"assignment-ind",
"assignment-turned-in",
"assignment",
"asterisk",
"atm",
"badge-4k-fill",
"badge-4k",
"badge-8k-fill",
"badge-8k",
"badge-ad-fill",
"badge-ad",
"badge-cc-fill",
"badge-cc",
"badge-hd-fill",
"badge-hd",
"badge-tm-fill",
"badge-tm",
"bag-check-fill",
"bag-check",
"bag-fill",
"bag",
"bags",
"bar-chart-fill",
"bar-chart",
"battery-charging",
"battery-emty",
"battery-full",
"battery-harf",
"bell-alert-box-fill",
"bell-alert-box",
"bell-alert-fill",
"bell-alert",
"bell-notification-fill",
"bell-notification-on-fill",
"bell-notification-on",
"bell-notification",
"bike",
"bluetooth",
"boat",
"book",
"bookmark-check-fill",
"bookmark-check",
"bookmark-fill",
"bookmark-new-fill",
"bookmark-new",
"bookmark-star-fill",
"bookmark-star",
"bookmark",
"boostzone",
"box-arrow-backup",
"box-arrow-download",
"box-arrow-link-blank",
"box-arrow-upload",
"box-seam",
"box",
"braces",
"briefcase-fill",
"briefcase",
"brightness-fill",
"brightness",
"broadcast-pin",
"broadcast",
"bug-fill",
"bug",
"building",
"bus",
"calculator-fill",
"calculator",
"calendar-check-fill",
"calendar-check",
"calendar-day-fill",
"calendar-day",
"calendar-event-fill",
"calendar-event",
"calendar-fill",
"calendar-week-fill",
"calendar-week",
"calendar",
"call-end",
"call",
"camera-fill",
"camera-video-fill",
"camera-video",
"camera",
"car",
"card-fill",
"card",
"caret-down-fill",
"caret-down-square-fill",
"caret-down-square",
"caret-left-fill",
"caret-left-square-fill",
"caret-left-square",
"caret-right-fill",
"caret-right-square-fill",
"caret-right-square",
"caret-up-fill",
"caret-up-square-fill",
"caret-up-square",
"cart-fill",
"cart",
"cash-stack",
"cash",
"cast",
"cctv",
"chat-box-left-dots-fill",
"chat-box-left-dots",
"chat-box-left-fill",
"chat-box-left-text-fill",
"chat-box-left-text",
"chat-box-left",
"chat-box-right-dots-fill",
"chat-box-right-dots",
"chat-box-right-fill",
"chat-box-right-text-fill",
"chat-box-right-text",
"chat-box-right",
"chat-circle-left-dot-fill",
"chat-circle-left-dot",
"chat-circle-left-fill",
"chat-circle-left-text-fill",
"chat-circle-left-text",
"chat-circle-left",
"chat-circle-right-dot-fill",
"chat-circle-right-dot",
"chat-circle-right-fill",
"chat-circle-right-text-fill",
"chat-circle-right-text",
"chat-circle-right",
"check-box",
"check-circle-fill",
"check-circle-line",
"check-circle",
"check-square-fill",
"check-square-line",
"check-square",
"check",
"chevron-bar-contract",
"chevron-bar-down",
"chevron-bar-expand",
"chevron-bar-left",
"chevron-bar-right",
"chevron-bar-up",
"chevron-contract",
"chevron-double-down",
"chevron-double-left",
"chevron-double-right",
"chevron-double-up",
"chevron-down",
"chevron-expand",
"chevron-left",
"chevron-right",
"chevron-up",
"clipboard-check",
"clipboard-data",
"clipboard-text",
"clipboard",
"clock-fill",
"clock-history",
"clock",
"cloud-arrow-down-fill",
"cloud-arrow-down",
"cloud-arrow-up-fill",
"cloud-arrow-up",
"cloud-check-fill",
"cloud-check",
"cloud-fill",
"cloud",
"code-square",
"code",
"collection-fill",
"collection",
"compass-fill",
"compass",
"cone",
"controller-fill",
"controller",
"copyright-sign",
"copyright-symbol",
"COVID-19-fill",
"COVID-19",
"cpu-fill",
"cpu",
"crop",
"cup-fill",
"cup-straw",
"cup",
"cursor-fill",
"cursor-text",
"cursor",
"dash-circle-fill",
"dash-circle",
"dash-square-fill",
"dash-square",
"dash",
"dashboard",
"diagram",
"diamond-fill",
"diamond",
"diamond2",
"display-fill",
"display",
"dns",
"door-closed-fill",
"door-closed",
"download_app",
"download",
"email-fill-notification",
"email-fill-open",
"email-fill",
"email-notification",
"email-open",
"email",
"emergenc_call",
"emergenc",
"exclamation-chat-fill",
"exclamation-chat",
"exclamation-circle-fill",
"exclamation-circle",
"exclamation-diamond-fill",
"exclamation-diamond",
"exclamation-square-circle",
"exclamation-square-fill",
"exclamation-square",
"exclamation-triangle-fill",
"exclamation-triangle",
"exclamation",
"exclude",
"face-id",
"facebook-circle",
"facebook-square",
"favorite-hart",
"favorite-star",
"file-copy",
"file-excel",
"file-image",
"file-keynote",
"file-numbers",
"file-powerpoint",
"file-text",
"file-word",
"file",
"filter-circle-fill",
"filter-circle",
"filter-left",
"filter-right",
"filter-square-fill",
"filter-square",
"filter",
"finder-folder",
"finder",
"fingerprint",
"flag-fill",
"flag",
"flashlight",
"folder-check",
"folder-delet",
"folder-fill",
"folder-open-fill",
"folder-open",
"folder-plus",
"folder-share",
"folder-x",
"folder",
"font",
"fullscreen-exit",
"fullscreen",
"gear-fill",
"gear-square-fill",
"gear-system-update",
"gear",
"gem",
"geo-alt",
"geo-location",
"geo-my-location",
"gift-fill",
"gift",
"graph-down",
"graph-up",
"grid-2-fill",
"grid-3-fill",
"grid",
"grip-horizontal",
"grip-vertical",
"group",
"group2",
"hand-index",
"harf",
"hart-fill",
"hart-half",
"hash",
"hdd-network",
"hdd",
"hexagon-fill",
"hexagon-harf",
"hexagon",
"history",
"home-fill",
"home",
"hotel_bed",
"hotel",
"image-fill",
"image",
"images",
"info-box",
"info-circle",
"info-fill",
"info",
"instagram-circle",
"instagram-fill",
"instagram",
"intersect",
"justify-left",
"justify-right",
"justify",
"kakao-alim-talk",
"kakao-circle",
"kakao-firend-talk",
"kakao-story-circle",
"kakao-story",
"kakao-talk",
"key",
"keyboard",
"label",
"language",
"laptop-fill",
"laptop",
"layer-back",
"layer-front",
"layer",
"lightning-fill",
"lightning",
"list-check",
"list",
"local-airport-land",
"local-airport",
"local-atm",
"local-bar",
"local-cafe",
"local-dining",
"local-ev-station",
"local-fastfood",
"local-gas-station",
"local-hospital",
"local-laundry",
"local-library",
"local-museum",
"local-restaurant",
"local-store",
"lock-open",
"lock",
"map-fill",
"map",
"menu-close-fill",
"menu-close",
"menu-open-fill",
"menu-open",
"mic-fill",
"mic-mute-fill",
"mic-mute",
"mic",
"minus",
"mobile-android",
"mobile-flip",
"mobile-fold",
"mobile-ios",
"mobile-pad",
"mobile-store",
"mobile",
"moon-crescent",
"moon-half",
"moon",
"more-horiz",
"more-vert",
"movie-fill",
"movie",
"music-backward-fill",
"music-backward",
"music-forward-fill",
"music-forward",
"music-list",
"music-pause-fill",
"music-pause",
"music-play-fill",
"music-play",
"music-stop-fill",
"music-stop",
"music",
"naver-blog",
"naver-circle",
"naver",
"news",
"option",
"paperclip",
"payment",
"pen-fill",
"pen",
"pencil-fill",
"pencil-square",
"pencil",
"person-fill",
"person",
"personal-information",
"pet",
"power",
"print",
"problem",
"profile-smile",
"profile",
"push-assemble",
"push-fill",
"push-notification",
"push",
"question-answer",
"question-circle-fill",
"question-circle",
"question-diamond-fill",
"question-diamond",
"question-square-fill",
"question-square",
"question",
"radio",
"receipt",
"refresh-fill",
"refresh",
"reset-fill",
"reset",
"rotation-lock",
"rotation",
"rwis",
"search",
"server",
"share",
"shield",
"shift-fill",
"shift",
"shuffle",
"silent",
"sim",
"slash",
"sliders",
"smart-watch",
"sort-alpha-down-alt",
"sort-alpha-down",
"sort-alpha-up-alt",
"sort-alpha-up",
"sort-down-alt",
"sort-down",
"sort-numeric-down-alt",
"sort-numeric-down",
"sort-numeric-up-alt",
"sort-numeric-up",
"sort-up-alt",
"sort-up",
"sound",
"soundwave",
"square-fill",
"square",
"star-circle",
"star-fill",
"star",
"statistics-circle",
"statistics",
"sticky-fill",
"sticky",
"stoplights-fill",
"stoplights",
"sunny",
"tab",
"table",
"terminal-fill",
"terminal",
"ternnel",
"textarea-t",
"textarea",
"thermometer-full",
"thermometer-half",
"thermometer",
"time-end",
"time-full",
"time-start",
"toggle-off",
"toggle-on",
"toggle",
"train",
"trash-fill",
"trash",
"truck-fill",
"truck",
"ui-checks-grid",
"ui-radios-grid",
"upload",
"vibration",
"vms",
"volume-down-fill",
"volume-down",
"volume-mute-fill",
"volume-mute",
"volume-off-fill",
"volume-off",
"volume-up-fill",
"volume-up",
"weather-cloud-fill",
"weather-cloud",
"weather-cloudy-gusts",
"weather-cloudy-windy",
"weather-cloudy",
"weather-fahrenheit",
"weather-fog",
"weather-hail",
"weather-humidity",
"weather-hurricane",
"weather-lightning",
"weather-rain",
"weather-rainny",
"weather-snow",
"weather-snowflake",
"weather-sunny-overcast",
"weather-sunny",
"weather-thunderstorm",
"weather-umbrella",
"weather-windy",
"whater",
"whather-celsius",
"wifi-1",
"wifi-2",
"wifi-off",
"wifi",
"window",
"x-circle-fill",
"x-circle",
"x-square-fill",
"x-square",
"x",
"zoom-in",
"zoom-out",
"server-line",
];
@@ -0,0 +1,16 @@
import type { Meta, StoryObj } from "@storybook/react";
import { IconFinder } from "./index";
const meta: Meta<typeof IconFinder> = {
title: "UI/IconFinder",
component: IconFinder,
tags: ["autodocs"],
argTypes: {},
};
export default meta;
type Story = StoryObj<typeof IconFinder>;
export const Default: Story = {
render: () => <IconFinder />,
};
@@ -0,0 +1,74 @@
import { useState } from "react";
import { Button } from "../button";
import { Dialog, DialogContent, DialogTrigger } from "../dialog";
import { cn } from "../../lib";
import iconNames from "./data";
type IconFinderProps = {
handleClick: (icon: string) => void;
};
const IconFinder = ({ handleClick }: IconFinderProps) => {
const [selectedIcon, setSelectedIcon] = useState("");
const handleIconClick = (icon: string) => {
setSelectedIcon(icon);
handleClick(icon);
};
return (
<Dialog>
<DialogTrigger asChild>
<Button className="leading-none">
<i className="diveicon di-search" />
아이콘 찾기
</Button>
</DialogTrigger>
<DialogContent className="max-h-[65rem] max-w-[65rem] overflow-y-auto p-12">
<div className="py-4">
<div className="grid grid-cols-5 gap-6 text-[2.4rem]">
{iconNames.map((icon) => (
<IconItem
key={icon}
icon={icon}
selectedIcon={selectedIcon}
handleClick={handleIconClick}
/>
))}
</div>
</div>
</DialogContent>
</Dialog>
);
};
const IconItem = ({
icon,
selectedIcon,
handleClick,
}: {
icon: string;
selectedIcon: string;
handleClick: (icon: string) => void;
}) => {
return (
<div
className={cn(
"relative flex cursor-pointer flex-col items-center gap-2 rounded-md p-4",
selectedIcon === icon && "bg-accent",
)}
onClick={() => handleClick(icon)}
>
<i
className={cn(
"diveicon di-check absolute left-4 top-4 text-[1rem] text-primary",
selectedIcon !== icon && "hidden",
)}
/>
<i className={`diveicon di-${icon}`} />
<span className="text-center text-[1.2rem]">{icon}</span>
</div>
);
};
export { IconFinder };
+1 -1
View File
@@ -7,8 +7,8 @@ export * from "./data-table";
export * from "./date-picker";
export * from "./dialog";
export * from "./dropdown-menu";
export * from "./input";
export * from "./file-search";
export * from "./icon-finder";
export * from "./input";
export * from "./label";
export * from "./layout";
@@ -1,28 +1,35 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { LNB } from "./lnb";
import { QueryProvider } from "../../provider/query-provider";
import { SidebarProvider } from "../sidebar";
import { LNB } from "../lnb";
import { Header } from "../header";
import { Footer } from "./footer";
import "./common.css";
import type { QueryClient } from "@tanstack/react-query";
const queryClient = new QueryClient();
export function BaseLayout({ children }: { children: React.ReactNode }) {
export function BaseLayout({
children,
queryClient,
}: {
children: React.ReactNode;
queryClient?: QueryClient;
}) {
return (
<QueryClientProvider client={queryClient}>
<div>
<Header />
<div className="flex h-[calc(100vh-50px)]">
<LNB />
<div className="flex flex-1 flex-col">
<div className="max-h-[calc(100%-50px)] flex-1 overflow-auto">
{children}
<QueryProvider client={queryClient}>
<SidebarProvider>
<div className="flex-1">
<Header />
<div className="flex h-[calc(100vh-50px)]">
<LNB />
<div className="flex flex-1 flex-col">
<div className="max-h-[calc(100%-50px)] flex-1 overflow-auto">
{children}
</div>
<Footer />
</div>
<Footer />
</div>
</div>
</div>
</QueryClientProvider>
</SidebarProvider>
</QueryProvider>
);
}
@@ -1,104 +0,0 @@
export function LNB() {
return (
<div
id="lnb"
className="!w-[22rem] overflow-auto border-r border-solid border-border bg-background"
>
<ul>
<li id="m_dashboard">
<a href="#none" className="!bg-background">
Dashboard
<i
className="diveicon di-diveicon di-chevron-down arrow"
aria-hidden="true"
/>
</a>
</li>
<div className="mline">PAGES</div>
<li id="m_page_board">
<a href="#none" className="!bg-background">
콘텐츠 관리
<i className="diveicon di-chevron-down arrow" aria-hidden="true" />
</a>
</li>
<li id="m_page_mem">
<a href="#none" className="!bg-background">
로그인 & 회원관리
<i className="diveicon di-chevron-down arrow" aria-hidden="true" />
</a>
</li>
<li id="m_page_manage">
<a href="#none" className="!bg-background">
페이지 관리
<i className="diveicon di-chevron-down arrow" aria-hidden="true" />
</a>
</li>
<li id="m_page_example">
<a href="#none" className="!bg-background">
샘플 페이지
<i className="diveicon di-chevron-down arrow" aria-hidden="true" />
</a>
</li>
<li id="m_layouts">
<a href="#none" className="!bg-background">
페이지 레이아웃
<i className="diveicon di-chevron-down arrow" aria-hidden="true" />
</a>
</li>
<div className="mline">COMPONENTS</div>
<li id="m_charts">
<a href="#none" className="!bg-background">
Charts
<i className="diveicon di-chevron-down arrow" aria-hidden="true" />
</a>
</li>
<li id="m_tables">
<a href="#none" className="!bg-background">
Tables
<i className="diveicon di-chevron-down arrow" aria-hidden="true" />
</a>
</li>
<li id="m_elements">
<a href="#none" className="!bg-background">
Elements
<i className="diveicon di-chevron-down arrow" aria-hidden="true" />
</a>
</li>
<li id="m_tabs">
<a href="#none" className="!bg-background">
Tabs
<i className="diveicon di-chevron-down arrow" aria-hidden="true" />
</a>
</li>
<li id="m_popup">
<a href="#none" className="!bg-background">
Modal
<i className="diveicon di-chevron-down arrow" aria-hidden="true" />
</a>
</li>
<li id="m_swipe">
<a href="#none" className="!bg-background">
Swipe
<i className="diveicon di-chevron-down arrow" aria-hidden="true" />
</a>
</li>
<li id="m_icon">
<a href="#none" className="!bg-background">
Font Icon
</a>
</li>
<div className="mline">OTHERS</div>
<li id="m_elements">
<a href="#none" className="!bg-background">
Menu Label <span className="ar_label_box red">NEW</span>
</a>
</li>
<li id="m_elements">
<a href="#none" className="!bg-background">
External Link
</a>
</li>
</ul>
</div>
);
}
@@ -1,28 +1,35 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { LNB } from "./lnb";
import { QueryProvider } from "../../provider/query-provider";
import { SidebarProvider } from "../sidebar";
import { LNB } from "../lnb";
import { Header } from "../header";
import { Footer } from "./footer";
import "./common.css";
import type { QueryClient } from "@tanstack/react-query";
const queryClient = new QueryClient();
export function VBaseLayout({ children }: { children: React.ReactNode }) {
export function VBaseLayout({
children,
queryClient,
}: {
children: React.ReactNode;
queryClient?: QueryClient;
}) {
return (
<QueryClientProvider client={queryClient}>
<div>
<Header viewOnly />
<div className="flex h-[calc(100vh-50px)]">
<LNB />
<div className="flex flex-1 flex-col">
<div className="max-h-[calc(100%-50px)] flex-1 overflow-auto">
{children}
<QueryProvider client={queryClient}>
<SidebarProvider>
<div className="flex-1">
<Header viewOnly />
<div className="flex h-[calc(100vh-50px)]">
<LNB viewOnly />
<div className="flex flex-1 flex-col">
<div className="max-h-[calc(100%-50px)] flex-1 overflow-auto">
{children}
</div>
<Footer />
</div>
<Footer />
</div>
</div>
</div>
</QueryClientProvider>
</SidebarProvider>
</QueryProvider>
);
}
@@ -0,0 +1,100 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { ChevronRight } from "lucide-react";
import { cn } from "../../lib/utils";
import { ENDPOINT, SITE_ID } from "../../config";
import { getMenuList, type MenuItem } from "../../api";
import {
Sidebar,
SidebarContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
} from "../sidebar";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "../collapsible";
import { getParentIds } from "./lib";
export function LNB({ viewOnly }: { viewOnly?: boolean }) {
const { data: menuList } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.MENU_ROLES],
queryFn: () => getMenuList(SITE_ID),
enabled: !viewOnly,
});
const [openMenuIds, setOpenMenuIds] = useState<number[]>([]);
function MenuItem({ item }: { item: MenuItem }) {
if (item.children) {
return <MenuGroup item={item} />;
}
return (
<SidebarMenuItem>
<SidebarMenuButton className="h-auto py-4 text-[1.4rem] text-label transition-colors hover:bg-accent">
<i className={cn("diveicon ml-2", `di-${item.iconNm}`)} />
{item.menuKornNm}
</SidebarMenuButton>
</SidebarMenuItem>
);
}
function MenuGroup({ item }: { item: MenuItem }) {
const isOpen = openMenuIds.includes(item.menuCd);
const handleOpenChange = (open: boolean) => {
if (open) {
if (!openMenuIds.includes(item.menuCd)) {
const parentIds = getParentIds(item.menuCd)(menuList ?? []);
setOpenMenuIds([...parentIds, item.menuCd]);
}
} else {
setOpenMenuIds((prev) => prev.filter((id) => id !== item.menuCd));
}
};
return (
<Collapsible open={isOpen} onOpenChange={handleOpenChange}>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<div className="group flex items-center justify-between rounded-sm py-4 pr-2 hover:bg-accent data-[state=open]:text-primary">
<SidebarMenuButton className="group/menu-button text-[1.4rem] group-data-[state=open]:bg-transparent">
<i
className={cn(
"diveicon ml-2 text-label group-data-[state=open]:text-primary",
`di-${item.iconNm}`,
)}
/>
<span className="text-label group-hover/menu-button:text-foreground group-data-[state=open]:text-primary">
{item.menuKornNm}
</span>
</SidebarMenuButton>
<ChevronRight className="size-6 text-label group-data-[state=open]:rotate-90" />
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub className="mr-0">
{item.children?.map((child) => (
<MenuItem key={child.menuCd} item={child} />
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
);
}
return (
<Sidebar className="border-border bg-background px-4 py-[6rem]">
<SidebarContent className="bg-background">
<SidebarMenu className="gap-0">
{menuList?.map((item) => <MenuItem key={item.menuCd} item={item} />)}
</SidebarMenu>
</SidebarContent>
</Sidebar>
);
}
@@ -0,0 +1,36 @@
import type { MenuItem } from "../../api";
function getParentIds(menuCd: number): (menuList: MenuItem[]) => number[] {
return (menuList: MenuItem[]) => {
const parentIds: number[] = [];
let currentId = menuCd;
while (true) {
const menu = findMenuById(currentId)(menuList);
if (!menu || !menu.parentId) break;
parentIds.push(menu.parentId);
currentId = menu.parentId;
}
return parentIds;
};
}
function findMenuById(
menuCd: number,
): (menuList: MenuItem[]) => MenuItem | undefined {
function findInMenu(items: MenuItem[]): MenuItem | undefined {
for (const item of items) {
if (item.menuCd === menuCd) return item;
if (item.children) {
const found = findInMenu(item.children);
if (found) return found;
}
}
return undefined;
}
return findInMenu;
}
export { getParentIds, findMenuById };
@@ -11,7 +11,7 @@ type Story = StoryObj<typeof LoadingWithAnimation>;
export const Primary: Story = {
render: () => (
<div className="rounded-lg bg-secondary p-10 dark:bg-transparent">
<div className="[data-theme='black']:bg-transparent rounded-lg bg-secondary p-10">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
@@ -21,7 +21,7 @@ export const Primary: Story = {
export const Secondary: Story = {
render: () => (
<div className="rounded-lg bg-secondary p-10 dark:bg-transparent">
<div className="[data-theme='black']:bg-transparent rounded-lg bg-secondary p-10">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="secondary" />
</div>
@@ -31,7 +31,7 @@ export const Secondary: Story = {
export const PrimaryWithImage: Story = {
render: () => (
<div className="rounded-lg bg-secondary p-10 dark:bg-transparent">
<div className="[data-theme='black']:bg-transparent rounded-lg bg-secondary p-10">
<div className="relative size-[5rem]">
<LoadingWithImage color="primary" />
</div>
@@ -41,7 +41,7 @@ export const PrimaryWithImage: Story = {
export const SecondaryWithImage: Story = {
render: () => (
<div className="rounded-lg bg-secondary p-10 dark:bg-transparent">
<div className="[data-theme='black']:bg-transparent rounded-lg bg-secondary p-10">
<div className="relative size-[5rem]">
<LoadingWithImage color="secondary" />
</div>
@@ -3,7 +3,7 @@ import { Slot } from "@radix-ui/react-slot";
import { type VariantProps, cva } from "class-variance-authority";
import { PanelLeft } from "lucide-react";
import { useIsMobile } from "../../hooks/use-mobile";
import { useIsMobile } from "../../lib/use-mobile";
import { cn } from "../../lib/utils";
import { Button } from "../button";
import { Input } from "../input";
@@ -25,7 +25,7 @@ import {
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH = "22rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
@@ -5,7 +5,7 @@
@apply bg-[#5987bf] px-4 py-[1.2rem] text-[1.3rem] font-medium leading-[1.2] text-[#fff];
}
.table-ui td {
@apply border-b border-b-border px-2 py-[1.2rem] text-[1.3rem] font-normal leading-[1.2] text-[#666];
@apply border-b border-b-border px-2 py-[1.2rem] text-[1.3rem] font-normal leading-[1.2] text-label;
}
.table-ui.secondary th {
@@ -24,7 +24,7 @@
@apply border-b border-b-border;
}
[data-mode="dark"] {
[data-theme="black"] {
.table-ui th {
@apply bg-[#5e7b9c] text-[#fff];
}
@@ -3,7 +3,6 @@ import presetConfig from "../../../tailwind.config.preset";
/** @type {import('tailwindcss').Config} */
export default {
presets: [presetConfig],
darkMode: ["class", "[data-mode='dark']"],
content: ["./src/**/*.{ts,tsx,js,jsx}", "./.storybook/**/*.{ts,tsx,js,jsx}"],
plugins: [require("tailwindcss-animate")],
};
+12
View File
@@ -51,6 +51,9 @@ importers:
'@hookform/resolvers':
specifier: ^4.1.3
version: 4.1.3(react-hook-form@7.54.2)
'@minoru/react-dnd-treeview':
specifier: ^3.5.0
version: 3.5.0(dnd-core@16.0.1)(react-dnd@16.0.1)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-label':
specifier: ^2.1.2
version: 2.1.2(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1)
@@ -81,6 +84,9 @@ importers:
react-chartjs-2:
specifier: ^5.3.0
version: 5.3.0(chart.js@4.4.8)(react@18.3.1)
react-dnd:
specifier: ^16.0.1
version: 16.0.1(@types/node@22.13.5)(@types/react@18.3.18)(react@18.3.1)
react-hook-form:
specifier: ^7.54.2
version: 7.54.2(react@18.3.1)
@@ -354,6 +360,9 @@ importers:
'@common/assets':
specifier: workspace:*
version: link:../assets
'@frontend-opensource/use-react-hooks':
specifier: ^1.1.4
version: 1.1.4
'@minoru/react-dnd-treeview':
specifier: ^3.5.0
version: 3.5.0(dnd-core@16.0.1)(react-dnd@16.0.1)(react-dom@18.3.1)(react@18.3.1)
@@ -363,6 +372,9 @@ importers:
'@radix-ui/react-checkbox':
specifier: ^1.1.4
version: 1.1.4(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-collapsible':
specifier: ^1.1.3
version: 1.1.3(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-dialog':
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)