Compare commits

...
Author SHA1 Message Date
changwng b2751232e1 api-role Dialog가 호출됨 2025-04-09 18:17:06 +09:00
changwng 48c1cced18 api 관리 프로그램 추가, 조회시 ApiQuery 값이 변경되면 바로 refetch함 2025-04-08 18:40:17 +09:00
changwng 92b5341cd1 그룹관리 상단에 No 붙이기 2025-04-07 17:30:41 +09:00
changwng 010ced20b5 group 관리추가 2025-04-07 17:14:12 +09:00
chanii8 c6cf493b51 Merge pull request 'iipbs_cs' (#14) from iipbs_cs into main
Reviewed-on: #14
2025-04-02 17:35:44 +09:00
chanii8 623b1e985e refactor: 로그인 완료 시 하위 모든 페이지 리렌더링 작업 2025-04-02 17:34:47 +09:00
chanii8 84d4635a75 feat: 상황판 페이지 추가 2025-04-02 17:34:13 +09:00
chanii8 0a51ac3f79 refactor: QueryClientProvider 선언 위치 변경 2025-04-02 17:19:16 +09:00
chanii8 4e4bd82009 refactor: menu wrapper에서 useQuery 제거 2025-04-02 15:33:51 +09:00
chanii8 a9739bdba5 feat: 이미지 등록 작업 2025-04-02 14:49:35 +09:00
chanii8 1d132a86de feat: 이미지 업데이트 및 삭제 작업 2025-04-02 14:07:36 +09:00
chanii8 6297d528fb feat: 이미지 등록 폼 작업 2025-04-02 13:22:11 +09:00
chanii8 3832adf3b0 chore: label 컴포넌트 적용 2025-04-02 10:58:02 +09:00
chanii8 60464b92b0 feat: 이미지 등록 폼 작업 2025-04-01 17:43:46 +09:00
chanii8 a912deeefb feat: 이미지 목록 페이지 작업 2025-04-01 15:40:38 +09:00
chanii8 f2954e6ebd refactor: page layout 공통 적용 2025-04-01 14:25:17 +09:00
jcchoi 16511de37d Update README.md 2025-03-31 10:46:13 +09:00
jcchoi e3fc4e4dbf Update README.md 2025-03-31 10:45:43 +09:00
chanii8 0cfe98f4b7 Merge pull request 'iipbs_cs' (#13) from iipbs_cs into main
Reviewed-on: #13
2025-03-31 10:41:29 +09:00
jcchoi 131ceec61e Update README.md
components.css 추가
2025-03-26 14:14:23 +09:00
jcchoi e92f36f739 Merge branch 'iipbs_cs' 2025-03-26 14:08:46 +09:00
chanii8 18e9708af1 Merge pull request 'iipbs_cs' (#12) from iipbs_cs into main
Reviewed-on: #12
2025-03-24 17:43:58 +09:00
chanii8 deeb670150 Merge pull request 'iipbs_cs' (#11) from iipbs_cs into main
Reviewed-on: #11
2025-03-21 11:32:11 +09:00
chanii8 4e0e05ea78 Merge pull request 'iipbs_cs' (#10) from iipbs_cs into main
Reviewed-on: #10
2025-03-19 13:00:01 +09:00
yschoi c31a6ea8bf 테스트 2025-03-19 09:24:36 +09:00
chanii8 e7f2b3e3ee Update README.md 2025-03-17 19:14:32 +09:00
chanii8 4933fc164a Merge pull request 'iipbs_cs' (#9) from iipbs_cs into main
Reviewed-on: #9
2025-03-17 18:51:47 +09:00
77 changed files with 3303 additions and 597 deletions
+1
View File
@@ -17,3 +17,4 @@ dist
# env
.env*
.qodo
+14 -1
View File
@@ -44,6 +44,12 @@ npx create-next-app@latest ./apps/as --typescript --eslint --tailwind --app
```json
{
// 기타 설정 ...
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@common/components": "workspace:*", // 추가
"react": "^19.0.0", // 제거
@@ -143,7 +149,9 @@ export default nextConfig;
### 6. globals.css(또는 전역 css) 수정
```css
@import "@common/assets/index.css";
@import "@common/assets/styles/base.css";
@import "@common/assets/styles/diveicon.css";
@import "@common/assets/styles/components.css";
@tailwind base;
@tailwind components;
@@ -184,3 +192,8 @@ http://dev.onasys.co.kr:8000
## Swagger
http://dev.onasys.co.kr:8000/webjars/swagger-ui/index.html?urls.primaryName=attach-service
## 개발 가이드
- 사이드바 메뉴 클릭 시 "menuCd" 데이터 쿠키 저장 처리
- 쿠키명 : menu-code-key
- 해당 메뉴 쿠키 데이터 가져와 페이지 권한 및 상당 Breadcrumb 데이터 전달 처리
@@ -0,0 +1,5 @@
import { ApimanPage } from "@/pages/apiman";
export default function Page() {
return <ApimanPage />;
}
@@ -0,0 +1,12 @@
import { ModifyPage as VModifyPage } from "@/pages/apiman/ui/modify-page";
export default async function ModifyPage({
params,
}: {
params: Promise<{ id: number}>;
}) {
const { id } = await params;
return <VModifyPage authorizationNo={id} />;
}
@@ -0,0 +1,4 @@
"use client";
import { CreatePage } from "@/pages/apiman/ui/create-page";
export default CreatePage;
@@ -0,0 +1,2 @@
import { DashboardPage } from "@/pages/dashboard";
export default DashboardPage;
@@ -0,0 +1,5 @@
import { GroupPage } from "@/pages/group";
export default function Page() {
return <GroupPage />;
}
@@ -0,0 +1,10 @@
import { ModifyPage as VModifyPage } from "@/pages/group/ui/modify-page";
export default async function ModifyPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return <VModifyPage groupId={id} />;
}
@@ -0,0 +1,4 @@
"use client";
import { CreatePage } from "@/pages/group/ui/create-page";
export default CreatePage;
@@ -0,0 +1,10 @@
import { ModifyPage as VModifyPage } from "@/pages/image";
export default async function ModifyPage({
params,
}: {
params: Promise<{ serial: string }>;
}) {
const { serial } = await params;
return <VModifyPage serial={serial} />;
}
@@ -0,0 +1,2 @@
import { CreatePage } from "@/pages/image";
export default CreatePage;
+11 -3
View File
@@ -1,10 +1,12 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import type { MenuItem } from "@common/components/api";
import { BaseLayout } from "@common/components/ui";
import { MenuWrapper } from "@/entities/menu";
import { useMenuStore } from "@/shared/lib";
import { PageLayout } from "@/shared/ui/page-layout";
export default function WithBaseLayout({
children,
}: {
@@ -12,6 +14,8 @@ export default function WithBaseLayout({
}) {
const router = useRouter();
const pathname = usePathname();
const queryClient = useQueryClient();
const { setCurrentMenuInfo } = useMenuStore((state) => state);
const handleMenuClick = (item: MenuItem) => {
@@ -20,8 +24,12 @@ export default function WithBaseLayout({
};
return (
<BaseLayout currentPath={pathname ?? ""} handleMenuClick={handleMenuClick}>
<MenuWrapper>{children}</MenuWrapper>
<BaseLayout
queryClient={queryClient}
currentPath={pathname ?? ""}
handleMenuClick={handleMenuClick}
>
<PageLayout>{children}</PageLayout>
</BaseLayout>
);
}
+13 -8
View File
@@ -1,20 +1,25 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ConfirmProvider } from "@frontend-opensource/use-react-hooks";
import { AuthWrapper } from "@/entities/auth";
import { MenuWrapper } from "@/entities/menu";
import { StoreProvider } from "@/shared/providers";
import { AlertDialog } from "@/shared/ui/alert-dialog";
const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => {
const queryClient = new QueryClient();
return (
<StoreProvider>
<ConfirmProvider>
<AuthWrapper>
{children}
<AlertDialog />
</AuthWrapper>
</ConfirmProvider>
</StoreProvider>
<QueryClientProvider client={queryClient}>
<StoreProvider>
<ConfirmProvider>
<AuthWrapper>
<MenuWrapper>{children}</MenuWrapper>
<AlertDialog />
</AuthWrapper>
</ConfirmProvider>
</StoreProvider>
</QueryClientProvider>
);
};
+6
View File
@@ -0,0 +1,6 @@
export { GroupApi } from "./api/group-api";
export {
type GroupParams,
type Group,
type GroupFormData,
} from "./model/types";
+29
View File
@@ -0,0 +1,29 @@
export interface Group {
groupId: string;
groupNm: string;
groupSeCd: string;
groupExpln: string;
delYn: string;
regDt: string;
rgtr: string;
mdfcnDt: string | null;
mdfr: string | null;
authrtId: string;
siteId: string | null;
}
export interface GroupParams {
groupNm?: string;
groupSeCd?: string;
siteId?: string;
}
export interface GroupFormData {
groupId: string;
groupNm: string;
groupSeCd: string;
groupExpln: string;
delYn: string;
authrtId: string;
siteId: string;
}
+10 -11
View File
@@ -2,27 +2,26 @@
import { useEffect } from "react";
import { usePathname } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { ENDPOINT, SITE_ID } from "@/shared/config";
import { SITE_ID } from "@/shared/config";
import { useMenuStore } from "@/shared/lib";
import { MenuApi } from "../api/menu-service";
import { getCurrentMenu } from "./utils";
const MenuWrapper = ({ children }: { children: React.ReactNode }) => {
const pathname = usePathname();
const { data: menuRoles } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.MENU_ROLES],
queryFn: () => MenuApi.getMenuRoles(SITE_ID),
});
const { setCurrentMenuInfo } = useMenuStore((state) => state);
useEffect(() => {
if (!menuRoles) return;
const currentMenu = getCurrentMenu(menuRoles, pathname ?? "");
(async () => {
const menuRoles = await MenuApi.getMenuRoles(SITE_ID);
if (!menuRoles) return;
if (!currentMenu) return;
setCurrentMenuInfo(currentMenu);
}, [menuRoles]);
const currentMenu = getCurrentMenu(menuRoles, pathname ?? "");
if (!currentMenu) return;
setCurrentMenuInfo(currentMenu);
})();
}, []);
return <>{children}</>;
};
@@ -34,7 +34,7 @@ export class SiteApiService implements SiteService {
return response.data;
}
public async modifySite(siteId: string, data: SiteFormData): Promise<void> {
public async updateSite(siteId: string, data: SiteFormData): Promise<void> {
const validatedData = siteFormSchema.parse(data);
const response = await client.put<void>(
`${ENDPOINT.CMS_SERVICE.SITES}/${siteId}`,
@@ -29,7 +29,7 @@ export interface SiteDetail extends SiteFormData {
mdfcnDt: string;
mdFr: string;
rgTrNm: string;
mdFrNm: string;
mdfrNm: string;
}
export const siteFormSchema = z.object({
@@ -54,6 +54,6 @@ export type SiteList = PageableList<Site>;
export interface SiteService {
getSiteList(params: SiteParams): Promise<SiteList>;
siteDetail(siteId: string): Promise<SiteDetail>;
modifySite(siteId: string, data: SiteFormData): Promise<void>;
updateSite(siteId: string, data: SiteFormData): Promise<void>;
createSite(data: SiteFormData): Promise<void>;
}
@@ -0,0 +1,63 @@
import type { Page } from "@/shared/types";
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
export interface Api {
authorizationNo: number; // API 일련번호
authorizationName: string; // API 명
urlPatternValue: string; // URL 패턴
httpMethodCode: string; // 하이퍼텍스트 전송 프로토콜 메쏘드 값 (예: GET, POST)
sortSeq: number; // 정렬 순서
useYn: string; // 사용 여부 (Y/N)
regDt: string; // 등록 일시
rgtr: string; // 등록자 UUID
mdfcnDt: string | null; // 갱신 일시
mdfr: string | null; // 갱신자 UUID
}
export interface ApiParams {
authorizationName?: string; // API 명 검색
httpMethodCode?: string; // HTTP 메소드 검색
useYn?: string; // 사용 여부 검색
page?: number; // 페이지 번호
size?: number; // 페이지 크기
}
export interface ApiFormData {
authorizationNo: number | null; // API 일련번호 (신규 등록 시 null)
authorizationName: string; // API 명
urlPatternValue: string; // URL 패턴
httpMethodCode: string; // HTTP 메소드
sortSeq: number; // 정렬 순서
useYn: string; // 사용 여부
}
export const ApimanApi = {
getApiList: async (params: ApiParams) => {
const { data } = await client.get<Page<Api>>(
`${ENDPOINT.USER_SERVICE.AUTHRTS}`,
{ params }
);
return data;
},
getApi: async (authorizationNo: number) => {
const { data } = await client.get<Api>(`${ENDPOINT.USER_SERVICE.AUTHRTS}/${authorizationNo}`);
return data;
},
createApi: async (apiData: ApiFormData) => {
const { data } = await client.post<Api>(ENDPOINT.USER_SERVICE.AUTHRTS, apiData);
return data;
},
updateApi: async (authorizationNo: number, apiData: ApiFormData) => {
const { data } = await client.put<Api>(`${ENDPOINT.USER_SERVICE.AUTHRTS}/${authorizationNo}`, apiData);
return data;
},
deleteApi: async (authorizationNo: number) => {
const { data } = await client.delete<void>(`${ENDPOINT.USER_SERVICE.AUTHRTS}/${authorizationNo}`);
return data;
},
};
+5
View File
@@ -0,0 +1,5 @@
export { ApimanPage } from "./ui/apiman-page";
export { ApimanForm } from "./ui/apiman-form";
export { CreatePage } from "./ui/create-page";
export { ModifyPage } from "./ui/modify-page";
export { ApimanApi } from "./api/apiman-api";
@@ -0,0 +1,111 @@
import { ColumnDef } from "@tanstack/react-table";
import { Badge, Button } from "@common/components";
import type { Api } from "../api/apiman-api";
import dayjs from "dayjs";
// API 권한 관리를 위한 타입 정의
type ApiTableMeta = {
openRoleDialog: (api: Api) => void;
};
export const columns: ColumnDef<Api>[] = [
{
accessorKey: "authorizationNo",
header: "번호",
cell: ({ row }) => <div>{row.original.authorizationNo}</div>,
},
{
accessorKey: "authorizationName",
header: "API 명",
cell: ({ row }) => <div>{row.original.authorizationName}</div>,
},
{
accessorKey: "urlPatternValue",
header: "URL 패턴",
cell: ({ row }) => (
<div className="max-w-md truncate text-left" title={row.original.urlPatternValue}>
{row.original.urlPatternValue}
</div>
),
},
{
accessorKey: "httpMethodCode",
header: "HTTP 메소드",
cell: ({ row }) => {
const method = row.original.httpMethodCode;
let color = "bg-gray-500";
switch (method) {
case "GET":
color = "bg-blue-500";
break;
case "POST":
color = "bg-green-500";
break;
case "PUT":
color = "bg-orange-500";
break;
case "DELETE":
color = "bg-red-500";
break;
}
return (
<Badge className={`${color} text-white`}>
{method}
</Badge>
);
},
},
{
accessorKey: "sortSeq",
header: "정렬 순서",
cell: ({ row }) => <div className="text-center">{row.original.sortSeq}</div>,
},
{
accessorKey: "useYn",
header: "사용 여부",
cell: ({ row }) => {
const useYn = row.original.useYn;
return (
<Badge className={useYn === "Y" ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"}>
{useYn === "Y" ? "사용" : "미사용"}
</Badge>
);
},
},
{
accessorKey: "mdfrNm",
header: "수정자",
},
{
accessorKey: "mdfcnDt",
header: "수정일시",
cell: ({ row }) => dayjs(row.original.mdfcnDt).format("YYYY-MM-DD"),
},
{
id: "actions",
header: "권한",
cell: ({ row, table }) => {
// 메타 데이터에서 권한 다이얼로그 열기 함수 가져오기
const meta = table.options.meta as ApiTableMeta | undefined;
return (
<Button
variant="outline"
size="sm"
className="h-8 w-16 bg-blue-500 text-white hover:bg-blue-600"
onClick={(e) => {
e.stopPropagation(); // 행 클릭 이벤트 전파 방지
if (meta?.openRoleDialog) {
// console.log(row.original);
meta.openRoleDialog(row.original);
}
}}
>
권한
</Button>
);
},
},
];
@@ -0,0 +1,201 @@
"use client";
import { useState, useEffect } from "react";
import {
Dialog,
DialogTrigger,
Button,
DialogContent,
DialogHeader,
DialogTitle,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Input,
RadioGroup,
RadioGroupItem,
DialogFooter,
DialogClose,
Checkbox,
Label,
} from "@common/components";
import { ApiRoleApi, type ApiRoleByApi } from "../../apirole/api/apirole-api";
// 페이지네이션 응답을 위한 인터페이스
interface Page<T> {
content: T[];
pageable: {
pageNumber: number;
pageSize: number;
};
totalElements: number;
totalPages: number;
}
// API 역할 인터페이스 정의
interface Role extends ApiRoleByApi {
selected?: boolean;
}
interface ApiRoleDialogProps {
api: ApiRoleByApi | null;
isOpen: boolean;
onClose: () => void;
onSave: (roleIds: string[]) => Promise<void>;
}
export function ApiRoleDialog({ api, isOpen, onClose, onSave }: ApiRoleDialogProps): JSX.Element {
const [roles, setRoles] = useState<Role[]>([]);
const [loading, setLoading] = useState(false);
// API에서 역할 데이터 가져오기
useEffect(() => {
if (isOpen && api && api.authorizationNo) {
// 실제 API 호출로 데이터 가져오기
ApiRoleApi.getApiRoleByApiList({ authorizationNo: api.authorizationNo })
.then((data: Page<ApiRoleByApi>) => {
// 데이터를 Role 인터페이스에 맞게 변환
const rolesWithSelected = data.content.map(role => ({
...role,
mode: role.useYn ==null?"C":"R",
selected: role.useYn === "Y"
}));
setRoles(rolesWithSelected);
})
.catch(error => {
console.error("역할 데이터 가져오기 오류:", error);
// 오류 발생 시 빈 배열 설정
setRoles([]);
});
}
}, [isOpen, api]);
const handleToggleRole = (roleId: string) => {
setRoles(
roles.map((role) =>
role.roleId === roleId ? { ...role, useYn: role.useYn === "Y" ? "N" : "Y", mode: role.mode === "R" ? "U" : "C" } : role
)
);
};
const handleSave = async () => {
try {
setLoading(true);
//const selectedRoleIds = roles.filter((role) => role.useYn === "Y").map((role) => role.roleId ?? "");
await onSave(roles);
onClose();
} catch (error) {
console.error("권한 저장 중 오류 발생:", error);
} finally {
setLoading(false);
}
};
// Dialog가 닫힐 때 호출되는 함수
const handleOpenChange = (open: boolean) => {
if (!open) {
onClose();
}
};
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
API 권한 설정 - {api?.authorizationName}
</DialogTitle>
</DialogHeader>
<div className="card mb-4">
<Table>
<colgroup>
<col style={{ width: "20%" }} />
<col style={{ width: "30%" }} />
<col style={{ width: "20%" }} />
<col style={{ width: "30%" }} />
</colgroup>
<TableBody>
<TableRow>
<TableCell as="th">API 명</TableCell>
<TableCell className="border">
{api?.authorizationName || ""}
</TableCell>
<TableCell as="th">HTTP 메소드</TableCell>
<TableCell className="border">
{api?.httpMethodCode || ""}
</TableCell>
</TableRow>
<TableRow>
<TableCell as="th">URL 패턴</TableCell>
<TableCell colSpan={3} className="border">
{api?.urlPatternValue || ""}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<div className="max-h-[400px] overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px]">허용</TableHead>
<TableHead className="w-[150px]">권한 ID</TableHead>
<TableHead>권한명</TableHead>
<TableHead>권한내용</TableHead>
<TableHead className="w-[80px]">사용여부</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{roles.map((role) => (
<TableRow key={role.roleId}>
<TableCell className="text-center">
<Checkbox
checked={role.useYn === "Y"}
onCheckedChange={() => handleToggleRole(role.roleId)}
/>
</TableCell>
<TableCell>{role.roleId}</TableCell>
<TableCell>{role.roleName}</TableCell>
<TableCell>{role.roleContent}</TableCell>
<TableCell>
<span className={role.useYn === "Y" ? "text-green-600" : "text-red-600"}>
{role.useYn === "Y" ? "사용" : "미사용"}
</span>
</TableCell>
</TableRow>
))}
{roles.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center py-4">
권한 정보가 없습니다.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<DialogFooter className="flex justify-end gap-2 mt-4">
<Button
variant="outline"
onClick={onClose}
disabled={loading}
>
취소
</Button>
<Button
onClick={handleSave}
disabled={loading}
>
{loading ? "저장 중..." : "저장"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+256
View File
@@ -0,0 +1,256 @@
// apps/cs/src/pages/apiman/ui/apiman-form.tsx
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { type UseFormReturn } from "react-hook-form";
import {
Button,
Input,
Label,
RadioGroup,
RadioGroupItem,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
import { FormItem, FormControl, FormField, Form } from "@/shared/ui/form";
import type { ApiFormData } from "../api/apiman-api";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
interface ApimanFormProps {
form: UseFormReturn<ApiFormData>;
authorizationNo?: number;
handleSave: (data: ApiFormData) => Promise<void>;
handleDelete?: () => Promise<void>;
}
function ApimanForm({ form, authorizationNo, handleSave, handleDelete }: ApimanFormProps) {
const isModifyMode = !!authorizationNo;
const router = useRouter();
const { confirm } = useConfirm();
const [onConfirm, setOnConfirm] = useState(false);
const handleConfirm = async (callback: () => Promise<void>) => {
setOnConfirm(true);
await callback();
setOnConfirm(false);
};
const onSubmit = async (data: ApiFormData) => {
const confirmMessage = isModifyMode
? "수정하시겠습니까?"
: "저장하시겠습니까?";
await handleConfirm(async () => {
if (await confirm(confirmMessage)) {
await handleSave(data);
}
});
};
const onDelete = async () => {
await handleConfirm(async () => {
if (await confirm("삭제하시겠습니까?")) {
handleDelete?.();
}
});
};
return (
<>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="card">
<Table variant="secondary">
<colgroup>
<col width="20%" />
<col width="80%" />
</colgroup>
<TableBody>
{isModifyMode && (
<TableRow>
<TableHead>API 일련번호</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="authorizationNo"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value?.toString() ?? ""}
className="w-[12rem] !text-[1.3rem]"
disabled
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
)}
<TableRow>
<TableHead>API 명</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="authorizationName"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="API 명을 입력하세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>URL 패턴</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="urlPatternValue"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="URL 패턴을 입력하세요 (예: /api/users/{id})"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>요청 메소드</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="httpMethodCode"
render={({ field }) => (
<FormItem>
<FormControl>
<Select
value={field.value || undefined}
onValueChange={field.onChange}
>
<SelectTrigger className="!text-[1.3rem]">
<SelectValue placeholder="요청 메소드를 선택하세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
<SelectItem value="PATCH">PATCH</SelectItem>
<SelectItem value="OPTIONS">OPTIONS</SelectItem>
<SelectItem value="HEAD">TRACE</SelectItem>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>정렬 순서</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="sortSeq"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
type="number"
{...field}
value={field.value?.toString() ?? ""}
className="w-[12rem] !text-[1.3rem]"
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사용 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="useYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
{...field}
name="useYn"
onValueChange={field.onChange}
value={field.value}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="useY" />
<Label htmlFor="useY">예</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="useN" />
<Label htmlFor="useN">아니오</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<div className="flex justify-center gap-4 pb-12 pt-10">
<Button
size="lg"
variant="outline"
type="button"
onClick={() => router.back()}
>
목록
</Button>
<Button size="lg" type="submit">
저장
</Button>
{isModifyMode && (
<Button size="lg" variant="destructive" type="button" onClick={onDelete}>
삭제
</Button>
)}
</div>
</form>
</Form>
<ConfirmDialog open={onConfirm} />
</>
);
}
export { ApimanForm };
+213
View File
@@ -0,0 +1,213 @@
"use client";
import "@common/assets/styles/grid.css";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
getCoreRowModel,
getPaginationRowModel,
useReactTable
} from "@tanstack/react-table";
import {
Button,
DataTable,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Pagination
} from "@common/components";
import { ApimanApi, type Api, type ApiParams } from "../api/apiman-api";
import { type ApiRoleByApi } from "../../apirole/api/apirole-api";
import { ENDPOINT } from "@/shared/config";
import { columns } from "../model/apiman-table-columns";
import { ApiRoleDialog } from "./api-role-dialog";
function ApimanPage() {
const router = useRouter();
const [apiQuery, setApiQuery] = useState<ApiParams>({
authorizationName: "",
httpMethodCode: "",
useYn: "",
page: 0,
size: 10,
});
// API 역할 다이얼로그 상태 관리
const [apiRoleDialog, setApiRoleDialog] = useState<{
isOpen: boolean;
api: ApiRoleByApi | null;
}>({
isOpen: false,
api: null,
});
const { data: apiList, refetch, isLoading } = useQuery({
queryKey: [ENDPOINT.USER_SERVICE.AUTHRTS, apiQuery],
queryFn: () => ApimanApi.getApiList(apiQuery),
enabled: false,
});
const table = useReactTable({
data: apiList?.content ?? [],
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
pageCount: apiList?.totalPages ?? -1,
state: {
pagination: {
pageIndex: apiQuery.page ?? 0,
pageSize: apiQuery.size ?? 10,
},
},
meta: {
openRoleDialog: (api: ApiRoleByApi) => {
setApiRoleDialog({
isOpen: true,
api,
});
},
},
onPaginationChange: (updater) => {
const newPagination =
typeof updater === "function"
? updater({ pageIndex: apiQuery.page ?? 0, pageSize: apiQuery.size ?? 10 })
: updater;
setApiQuery((prev) => ({
...prev,
page: newPagination.pageIndex,
size: newPagination.pageSize,
}));
},
manualPagination: true,
});
useEffect(() => {
refetch();
}, [apiQuery, refetch]);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setApiQuery(prev => ({ ...prev, page: 0 }));
refetch();
};
const handleRowClick = (api: Api) => {
router.push(`/apiman/register/${api.authorizationNo}`);
};
// API 역할 저장 핸들러
const handleSaveRoles = async (roles: ApiRoleByApi[]) => {
try {
// 실제 구현에서는 API 호출로 대체
console.log(`API ${apiRoleDialog.api?.authorizationNo}에 권한 저장:`, roles);
// 성공 시 다이얼로그 닫기
setApiRoleDialog({ isOpen: false, api: null });
// 목록 새로고침
refetch();
} catch (error) {
console.error("권한 저장 중 오류 발생:", error);
}
};
return (
<div className="card card-border !mt-6">
{/* API 역할 다이얼로그 */}
<ApiRoleDialog
api={apiRoleDialog.api}
isOpen={apiRoleDialog.isOpen}
onClose={() => setApiRoleDialog({ isOpen: false, api: null })}
onSave={handleSaveRoles}
/>
<div className="card card-border">
<form onSubmit={handleSubmit} className="flex items-center gap-2 p-2">
<Label htmlFor="httpMethodCode" className="mr-2 text-label">요청 메소드</Label>
<Select
value={apiQuery.httpMethodCode || undefined}
onValueChange={(value) => setApiQuery( prev => ({ ...prev, httpMethodCode: value === "ALL" ? "" : value }))}
>
<SelectTrigger id="httpMethodCode" className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]">
<SelectValue placeholder="요청 메소드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">전체</SelectItem>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
<SelectItem value="OPTIONS">OPTIONS</SelectItem>
<SelectItem value="PATCH">PATCH</SelectItem>
<SelectItem value="HEAD">HEAD</SelectItem>
</SelectContent>
</Select>
<Label htmlFor="authorizationName" className="mr-2 text-label">API 명</Label>
<Input
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]"
id="authorizationName"
value={apiQuery.authorizationName || ""}
onChange={(e) => setApiQuery( prev => ({ ...prev, authorizationName: e.target.value }))}
placeholder="API 명 입력"
/>
<Button className="h-[3.2rem]" type="submit">
조회
</Button>
</form>
</div>
<div className="bg-white rounded-lg shadow">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">모듈API 목록</h3>
<Button onClick={() => router.push("/apiman/register")}>+등록</Button>
</div>
<DataTable
table={table}
isLoading={isLoading}
onRowClick={handleRowClick}
/>
<div className="flex items-center justify-end space-x-2 py-4">
<div className="flex-1 text-lm text-muted-foreground">
총 {apiList?.totalElements ?? 0} 건
</div>
<Pagination
currentPage={apiQuery.page + 1}
totalPages={apiList?.totalPages ?? 0}
onPageChange={(page) =>
setApiQuery(prev => ({ ...prev, page: page - 1 }))
}
/>
<Select
value={String(apiQuery.size)}
onValueChange={(value) =>
setApiQuery(prev => ({ ...prev, page: 0, size: Number(value) }))
}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[2,10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={String(pageSize)}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
);
}
export { ApimanPage };
@@ -0,0 +1,76 @@
"use client";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ApimanApi, type ApiFormData } from "../api/apiman-api";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import { ApimanForm } from "./apiman-form";
import { z } from "zod";
import { AxiosError } from "axios";
// API 스키마 정의
const apiFormSchema = z.object({
authorizationNo: z.number().nullable(),
authorizationName: z.string().min(1, "API 명을 입력해주세요"),
urlPatternValue: z.string().min(1, "URL 패턴을 입력해주세요"),
httpMethodCode: z.string().min(1, "HTTP 메소드를 선택해주세요"),
sortSeq: z.number().min(0, "정렬 순서는 0 이상이어야 합니다"),
useYn: z.string(),
});
function CreatePage() {
const router = useRouter();
const queryClient = useQueryClient();
const { setMessage: alert } = useAlertStore((state) => state);
const { mutateAsync: createApi } = useMutation({
mutationFn: (data: ApiFormData) => ApimanApi.createApi(data),
onSuccess: () => {
alert("등록되었습니다.", () => {
queryClient.invalidateQueries({
queryKey: [ENDPOINT.USER_SERVICE.AUTHRTS],
});
router.push("/apiman");
});
},
onError: (error: unknown) => {
const axiosError = error as AxiosError<{ message: string }>;
if (axiosError.response?.data) {
alert(axiosError.response.data.message);
} else if (error instanceof Error) {
alert(error.message);
} else {
alert("알 수 없는 오류가 발생했습니다.");
}
},
});
const form = useForm<ApiFormData>({
resolver: zodResolver(apiFormSchema),
defaultValues: {
authorizationNo: null,
authorizationName: "",
urlPatternValue: "",
httpMethodCode: "",
sortSeq: 0,
useYn: "Y"
}
});
return (
<div className="card card-border !mb-0 !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">API 등록</h3>
</div>
<ApimanForm
form={form}
handleSave={createApi}
/>
</div>
);
}
export { CreatePage };
+104
View File
@@ -0,0 +1,104 @@
"use client";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ApimanApi, type ApiFormData } from "../api/apiman-api";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import { ApimanForm } from "./apiman-form";
import { z } from "zod";
import { AxiosError } from "axios";
// API 스키마 정의
const apiFormSchema = z.object({
authorizationNo: z.number().nullable(),
authorizationName: z.string().min(1, "API 명을 입력해주세요"),
urlPatternValue: z.string().min(1, "URL 패턴을 입력해주세요"),
httpMethodCode: z.string().min(1, "HTTP 메소드를 선택해주세요"),
sortSeq: z.number(),
useYn: z.string(),
});
function ModifyPage({ authorizationNo }: { authorizationNo: number }) {
const router = useRouter();
const queryClient = useQueryClient();
const { setMessage: alert } = useAlertStore((state) => state);
const { data: apiDetail } = useQuery({
queryKey: [ENDPOINT.USER_SERVICE.AUTHRTS, authorizationNo],
queryFn: () => ApimanApi.getApi(authorizationNo),
select: (data) => {
const keys = Object.keys(apiFormSchema.shape);
const filteredData = Object.fromEntries(
Object.entries(data).filter(([key]) => keys.includes(key)),
);
return filteredData;
},
enabled: !!authorizationNo,
});
const { mutateAsync: updateApi } = useMutation({
mutationFn: (data: ApiFormData) => ApimanApi.updateApi(authorizationNo, data),
onSuccess: () => {
alert("수정되었습니다.", () => {
queryClient.invalidateQueries({
queryKey: [ENDPOINT.USER_SERVICE.AUTHRTS],
});
router.back();
});
},
onError: (error: unknown) => {
const axiosError = error as AxiosError<{ message: string }>;
if (axiosError.response?.data) {
alert(axiosError.response.data.message);
} else if (error instanceof Error) {
alert(error.message);
} else {
alert("알 수 없는 오류가 발생했습니다.");
}
},
});
const { mutateAsync: deleteApi } = useMutation({
mutationFn: () => ApimanApi.deleteApi(authorizationNo),
onSuccess: () => {
alert("삭제되었습니다.", router.back);
},
onError: (error: unknown) => {
const axiosError = error as AxiosError<{ message: string }>;
if (axiosError.response?.data) {
alert(axiosError.response.data.message);
} else if (error instanceof Error) {
alert(error.message);
} else {
alert("알 수 없는 오류가 발생했습니다.");
}
},
});
const form = useForm<ApiFormData>({
resolver: zodResolver(apiFormSchema),
values: apiDetail as ApiFormData,
});
return (
<div className="card card-border !mb-0 !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">API 수정</h3>
</div>
<ApimanForm
form={form}
authorizationNo={authorizationNo}
handleSave={async (data) => {
await updateApi(data);
return;
}}
handleDelete={deleteApi}
/>
</div>
);
}
export { ModifyPage };
@@ -0,0 +1,77 @@
import type { Page } from "@/shared/types";
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
export interface ApiRole {
roleId?: string;
/** @format int64 */
authorizationNo?: number;
authorizationName?: string;
urlPatternValue?: string;
httpMethodCode?: string;
/** @format int32 */
sortSeq?: number;
createdAt?: boolean;
}
/** API 에서 역할 추가 (API에 대한 역할) */
export interface ApiRoleByApi {
roleId?: string;
roleName?: string;
roleContent?: string;
useYn?: string;
/** @format int64 */
authorizationNo?: number;
mode?:string;
}
export interface ApiRoleParams {
authorizationNo?: number; // API 일련번호 검색
roleId?: string; // API 역할 검색
page?: number; // 페이지 번호
size?: number; // 페이지 크기
}
export interface ApiRoleFormData {
authorizationNo: number | null; // API 일련번호 (신규 등록 시 null)
authorizationName: string; // API 명
urlPatternValue: string; // URL 패턴
httpMethodCode: string; // HTTP 메소드
sortSeq: number; // 정렬 순서
useYn: string; // 사용 여부
}
export const ApiRoleApi = {
getApiRoleList: async (params: ApiRoleParams) => {
const { data } = await client.get<Page<ApiRole>>(
`${ENDPOINT.USER_SERVICE.ROLES_AUTHRTS}`,
{ params }
);
return data;
},
getApiRoleByApiList: async (params: ApiRoleParams) => {
const { data } = await client.get<Page<ApiRoleByApi>>(
`${ENDPOINT.USER_SERVICE.ROLES_AUTHRTS}/api`,
{ params }
);
return data;
},
getApiRole: async (authorizationNo: number) => {
const { data } = await client.get<ApiRole>(`${ENDPOINT.USER_SERVICE.ROLES_AUTHRTS}/${authorizationNo}`);
return data;
},
createApiRole: async (apiData: ApiRoleFormData) => {
const { data } = await client.post<ApiRole>(ENDPOINT.USER_SERVICE.ROLES_AUTHRTS, apiData);
return data;
},
updateApiRole: async (authorizationNo: number, apiData: ApiRoleFormData) => {
const { data } = await client.put<ApiRole>(`${ENDPOINT.USER_SERVICE.ROLES_AUTHRTS}/${authorizationNo}`, apiData);
return data;
},
deleteApiRole: async (authorizationNo: number) => {
const { data } = await client.delete<void>(`${ENDPOINT.USER_SERVICE.ROLES_AUTHRTS}/${authorizationNo}`);
return data;
},
};
@@ -4,10 +4,8 @@ import Image from "next/image";
import { images } from "@common/assets";
import { LoginApi } from "@/entities/auth";
import "./login.css";
import { useRouter } from "next/navigation";
function LoginPage() {
const router = useRouter();
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
@@ -16,7 +14,7 @@ function LoginPage() {
const password = formData.get("password") as string;
await LoginApi.login({ userId, password });
router.push("/site");
window.location.href = "/dashboard";
};
return (
+1
View File
@@ -0,0 +1 @@
export { DashboardPage } from "./ui/dashboard-page";
@@ -0,0 +1,5 @@
function DashboardPage() {
return <></>;
}
export { DashboardPage };
+64
View File
@@ -0,0 +1,64 @@
//import { http } from "@/libs/axios";
import type { Page } from "@/shared/types";
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
export interface Group {
groupId: string;
groupNm: string;
groupSeCd: string;
groupExpln: string;
delYn: string;
regDt: string;
rgtr: string;
mdfcnDt: string | null;
mdfr: string | null;
authrtId: string;
siteId: string | null;
}
export interface GroupParams {
groupNm?: string;
groupSeCd?: string;
siteId?: string;
}
export interface GroupFormData {
groupId: string;
groupNm: string;
groupSeCd: string;
groupExpln: string;
delYn: string;
authrtId: string;
siteId: string;
}
export const GroupApi = {
getGroupList: async (params: GroupParams) => {
const { data } = await client.get<Page<Group>>(
`${ENDPOINT.CMS_SERVICE.GROUP}`
, { params });
return data;
},
getGroup: async (groupId: string) => {
const { data } = await client.get<Group>(`${ENDPOINT.CMS_SERVICE.GROUP}/${groupId}`);
return data;
},
createGroup: async (groupData: GroupFormData) => {
const { data } = await client.post<Group>(ENDPOINT.CMS_SERVICE.GROUP, groupData);
return data;
},
updateGroup: async (groupId: string, groupData: GroupFormData) => {
const { data } = await client.put<Group>(`${ENDPOINT.CMS_SERVICE.GROUP}/${groupId}`, groupData);
return data;
},
deleteGroup: async (groupId: string) => {
const { data } = await client.delete<void>(`${ENDPOINT.CMS_SERVICE.GROUP}/${groupId}`);
return data;
},
};
+1
View File
@@ -0,0 +1 @@
export { GroupPage } from "./ui/group-page";
@@ -0,0 +1,73 @@
import { type ColumnDef } from "@tanstack/react-table";
import { Checkbox } from "@common/components/ui";
import { type Group } from "../api/group-api";
import dayjs from "dayjs";
export const columns: ColumnDef<Group>[] = [
/* {
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="전체 선택"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="행 선택"
/>
),
enableSorting: false,
enableHiding: false,
}, */
{
id: "no",
header: "NO",
accessorFn: (_, index) => index + 1,
},
{
accessorKey: "groupId",
header: "그룹 ID",
enableSorting: true,
},
{
accessorKey: "groupNm",
header: "그룹명",
},
{
accessorKey: "groupSeNm",
header: "그룹구분",
},
{
accessorKey: "delYn",
header: "삭제여부",
cell: ({ row }) => (
<Checkbox
checked={row.original.delYn === "Y"}
disabled
aria-label="삭제여부"
/>
)
},
{
accessorKey: "siteId",
header: "사이트 ID",
enableSorting: true,
},
{
accessorKey: "roleNm",
header: "권한",
},
{
accessorKey: "mdfrNm",
header: "수정자",
},
{
accessorKey: "mdfcnDt",
header: "수정일시",
cell: ({ row }) => dayjs(row.original.mdfcnDt).format("YYYY-MM-DD"),
}
];
@@ -0,0 +1,71 @@
// apps/cs/src/pages/group/ui/create-page.tsx
"use client";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { useMutation } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod";
import { GroupApi, type GroupFormData } from "../api/group-api";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import { GroupForm } from "./group-form";
import { z } from "zod";
const groupFormSchema = z.object({
groupId: z.string(),
groupNm: z.string().min(1, "그룹명을 입력해주세요"),
groupSeCd: z.string().min(1, "그룹구분을 선택해주세요"),
groupExpln: z.string(),
delYn: z.string(),
authrtId: z.string(),
siteId: z.string(),
});
function CreatePage() {
const router = useRouter();
const { setMessage: alert } = useAlertStore((state) => state);
const { mutateAsync: createGroup } = useMutation({
mutationFn: (data: GroupFormData) => GroupApi.createGroup(data),
onSuccess: () => {
alert("등록되었습니다.", router.back);
},
onError: (error) => {
/*{
"timestamp": "2025-04-07T14:30:15.471581",
"message": "해당 권한이 존재하지 않습니다. ID = sd",
"status": 400,
"code": "B001",
"errors": []
}*/
if(error.response.data){
alert(error.response.data.message);
}else{
alert(error.message);
}
},
});
const form = useForm<GroupFormData>({
resolver: zodResolver(groupFormSchema),
defaultValues: {
groupId: "",
groupNm: "",
groupSeCd: "",
groupExpln: "",
delYn: "N",
authrtId: "",
siteId: "",
},
});
return (
<div className="card card-border !mb-0 !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">그룹 등록</h3>
</div>
<GroupForm form={form} handleSave={createGroup} />
</div>
);
}
export { CreatePage };
+258
View File
@@ -0,0 +1,258 @@
// apps/cs/src/pages/group/ui/group-form.tsx
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { type UseFormReturn } from "react-hook-form";
import {
Button,
Input,
Label,
RadioGroup,
RadioGroupItem,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from "@common/components";
import { CodeSelect } from "@/entities/code";
import { FormItem, FormControl, FormField, Form } from "@/shared/ui/form";
import type { GroupFormData } from "../api/group-api";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
interface GroupFormProps {
form: UseFormReturn<GroupFormData>;
groupId?: string;
handleSave: (data: GroupFormData) => Promise<void>;
handleDelete?: () => Promise<void>;
}
function GroupForm({ form, groupId, handleSave, handleDelete }: GroupFormProps) {
const isModifyMode = !!groupId;
const router = useRouter();
const { confirm } = useConfirm();
const [onConfirm, setOnConfirm] = useState(false);
const handleConfirm = async (callback: () => Promise<void>) => {
setOnConfirm(true);
await callback();
setOnConfirm(false);
};
const onSubmit = async (data: GroupFormData) => {
const confirmMessage = isModifyMode
? "수정하시겠습니까?"
: "저장하시겠습니까?";
await handleConfirm(async () => {
if (await confirm(confirmMessage)) {
handleSave(data);
}
});
};
const onDelete = async () => {
await handleConfirm(async () => {
if (await confirm("삭제하시겠습니까?")) {
handleDelete?.();
}
});
};
return (
<>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="card">
<Table variant="secondary">
<colgroup>
<col width="20%" />
<col width="80%" />
</colgroup>
<TableBody>
<TableRow>
<TableHead>그룹 ID</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="groupId"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="w-[12rem] !text-[1.3rem]"
disabled={isModifyMode}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>그룹명</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="groupNm"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="그룹명을 입력하세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>그룹구분</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="groupSeCd"
render={({ field }) => (
<FormItem>
<FormControl>
<CodeSelect
{...field}
value={field.value ?? ""}
upCd="GROUPSECD"
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>그룹설명</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="groupExpln"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="그룹설명을 입력하세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사이트 ID</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="siteId"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="사이트 ID를 입력하세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>권한 ID</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="authrtId"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="권한 ID를 입력하세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>삭제여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="delYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
{...field}
name="delYn"
onValueChange={field.onChange}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="delY" />
<Label htmlFor="delY">예</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="delN" />
<Label htmlFor="delN">아니오</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<div className="flex justify-center gap-4 pb-12 pt-10">
<Button
size="lg"
color="white"
type="button"
onClick={() => router.back()}
>
목록
</Button>
<Button size="lg" type="submit">
저장
</Button>
{isModifyMode && (
<Button size="lg" color="red" type="button" onClick={onDelete}>
삭제
</Button>
)}
</div>
</form>
</Form>
<ConfirmDialog open={onConfirm} />
</>
);
}
export { GroupForm };
+145
View File
@@ -0,0 +1,145 @@
"use client";
import "@common/assets/styles/grid.css";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
getCoreRowModel,
getPaginationRowModel,
useReactTable
} from "@tanstack/react-table";
import {
Button,
DataTable,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Pagination
} from "@common/components";
import { GroupApi, type Group, type GroupParams } from "../api/group-api";
import { ENDPOINT } from "@/shared/config";
import { columns } from "../model/group-table-columns";
function GroupPage() {
const router = useRouter();
const [groupQuery, setGroupQuery] = useState<GroupParams>({
groupNm: "",
groupSeCd: "",
page: 0,
size: 2,
});
const { data: groupList, refetch } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.GROUP, groupQuery],
queryFn: () => GroupApi.getGroupList(groupQuery),
enabled: false,
});
const table = useReactTable({
data: groupList?.content ?? [],
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
pageCount: groupList?.totalPages ?? -1,
state: {
pagination: {
pageIndex: groupQuery.page,
pageSize: groupQuery.size,
},
},
onPaginationChange: (updater) => {
if (typeof updater === 'function') {
const newPagination = updater({
pageIndex: groupQuery.page,
pageSize: groupQuery.size,
});
setGroupQuery(prev => ({
...prev,
page: newPagination.pageIndex,
size: newPagination.pageSize,
}));
}
},
manualPagination: true,
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setGroupQuery(prev => ({ ...prev, page: 0 })); // 검색 시 첫 페이지로 이동
refetch();
};
const handleRowClick = (row: Group) => {
router.push(`/group/register/${row.groupId}`);
};
useEffect(() => {
refetch();
}, [groupQuery, refetch]); //[groupQuery.page, groupQuery.size]); // 페이지나 사이즈 변경 시 자동 조회
return (
<div className="card card-border !mt-6">
<div className="card card-border">
<form onSubmit={handleSubmit} className="flex items-center gap-2 p-2">
<Label htmlFor="" className="mr-2 text-label">
그룹명
</Label>
<Input
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]"
value={groupQuery.groupNm}
onChange={(e) => setGroupQuery(prev => ({ ...prev, groupNm: e.target.value }))}
/>
<Button className="h-[3.2rem]" type="submit">
조회
</Button>
</form>
</div>
<div className="card card-border">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">그룹 목록</h3>
<Button onClick={() => router.push("/group/register")}>+등록</Button>
</div>
<div className="card !m-[1.2rem] h-[49rem] overflow-auto !p-0">
<DataTable table={table} onRowClick={handleRowClick} />
<div className="flex items-center justify-end space-x-2 py-4">
<div className="flex-1 text-lm text-muted-foreground">
총 {groupList?.totalElements ?? 0} 건
</div>
<Pagination
currentPage={groupQuery.page + 1}
totalPages={groupList?.totalPages ?? 0}
onPageChange={(page) =>
setGroupQuery(prev => ({ ...prev, page: page - 1 }))
}
/>
<Select
value={String(groupQuery.size)}
onValueChange={(value) =>
setGroupQuery(prev => ({ ...prev, page: 0, size: Number(value) }))
}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[2,10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={String(pageSize)}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
</div>
);
}
export { GroupPage };
@@ -0,0 +1,95 @@
// apps/cs/src/pages/group/ui/modify-page.tsx
"use client";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { GroupApi, type GroupFormData } from "../api/group-api";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import { GroupForm } from "./group-form";
import { z } from "zod";
// Group 스키마 정의
const groupFormSchema = z.object({
groupId: z.string(),
groupNm: z.string().min(1, "그룹명을 입력해주세요"),
groupSeCd: z.string().min(1, "그룹구분을 선택해주세요"),
groupExpln: z.string(),
delYn: z.string(),
authrtId: z.string(),
siteId: z.string(),
});
function ModifyPage({ groupId }: { groupId: string }) {
const router = useRouter();
const queryClient = useQueryClient();
const { setMessage: alert } = useAlertStore((state) => state);
const { data: groupDetail } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.GROUP, groupId],
queryFn: () => GroupApi.getGroup(groupId),
select: (data) => {
const keys = Object.keys(groupFormSchema.shape);
const filteredData = Object.fromEntries(
Object.entries(data).filter(([key]) => keys.includes(key)),
);
return filteredData;
},
enabled: !!groupId,
});
const { mutateAsync: updateGroup } = useMutation({
mutationFn: (data: GroupFormData) => GroupApi.updateGroup(groupId, data),
onSuccess: () => {
alert("수정되었습니다.", () => {
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.GROUP],
});
});
},
onError: (error) => {
if(error.response.data){
alert(error.response.data.message);
}else{
alert(error.message);
}
},
});
const { mutateAsync: deleteGroup } = useMutation({
mutationFn: () => GroupApi.deleteGroup(groupId),
onSuccess: () => {
alert("삭제되었습니다.", router.back);
},
onError: (error) => {
if(error.response.data){
alert(error.response.data.message);
}else{
alert(error.message);
}
},
});
const form = useForm<GroupFormData>({
resolver: zodResolver(groupFormSchema),
values: groupDetail as GroupFormData,
});
return (
<div className="card card-border !mb-0 !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">그룹 수정</h3>
</div>
<GroupForm
form={form}
groupId={groupId}
handleSave={updateGroup}
handleDelete={deleteGroup}
/>
</div>
);
}
export { ModifyPage };
@@ -0,0 +1,47 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
Image,
ImageFormData,
ImageList,
ImageParams,
ImageService,
} from "../model/image-interface";
export class ImageApiService implements ImageService {
public async getImageList(params: ImageParams): Promise<ImageList> {
const response = await client.get<ImageList>(
`${ENDPOINT.CMS_SERVICE.IMAGES}`,
{
params,
},
);
return response.data;
}
public async getImageInfo(imgSn: string): Promise<Image> {
const response = await client.get<Image>(
`${ENDPOINT.CMS_SERVICE.IMAGES}/${imgSn}`,
);
return response.data;
}
public async createImage(data: ImageFormData): Promise<void> {
const response = await client.post<void>(ENDPOINT.CMS_SERVICE.IMAGES, data);
return response.data;
}
public async updateImage(imgSn: string, data: ImageFormData): Promise<void> {
const response = await client.put<void>(
`${ENDPOINT.CMS_SERVICE.IMAGES}/${imgSn}`,
data,
);
return response.data;
}
public async deleteImage(imgSn: string): Promise<void> {
await client.delete(`${ENDPOINT.CMS_SERVICE.IMAGES}/${imgSn}`);
}
}
export const ImageApi = new ImageApiService();
+2
View File
@@ -1 +1,3 @@
export { CreatePage } from "./ui/create-page";
export { ModifyPage } from "./ui/modify-page";
export { ImagePage } from "./ui/image-page";
+61
View File
@@ -0,0 +1,61 @@
import { Upload, type HttpResponse } from "tus-js-client";
import { getCookie, setCookie } from "@/shared/lib";
import { AUTH_USER_ID, CLAIM_NAME, ENDPOINT } from "@/shared/config";
type UploadOptions = {
handleUploadStart?: (upload: Upload) => void;
handleProgress?: (percentage: string) => void;
handleSuccess?: (upload: Upload) => void;
handleResponse?: (res: HttpResponse) => void;
};
export const handleUpload = (files: FileList, options?: UploadOptions) => {
let metadata = {};
const file = files.item(0);
if (!file) return;
metadata = {
filename: file.name,
filetype: file.type,
};
const atflCd = getCookie("atfl-cd-key");
if (atflCd) {
metadata = {
...metadata,
atflCd,
};
}
const upload = new Upload(file, {
endpoint: `${ENDPOINT.ATTACH_SERVICE.TUS_UPLOAD}`,
chunkSize: 10 * 1024 * 1024,
headers: {
[CLAIM_NAME]: getCookie(CLAIM_NAME) ?? "",
[AUTH_USER_ID]: getCookie(AUTH_USER_ID) ?? "",
},
metadata,
onError(error) {
console.error(`Failed because: ${error}`);
},
onProgress(bytesUploaded, bytesTotal) {
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2);
options?.handleProgress?.(percentage);
},
onAfterResponse(req, res) {
const fileInfo = res.getHeader("X-File-Info");
if (fileInfo === undefined || fileInfo === null) return;
setCookie("atfl-cd-key", JSON.parse(fileInfo)?.atflCd ?? "");
options?.handleResponse?.(res);
},
onSuccess() {
options?.handleSuccess?.(upload);
},
});
upload.start();
options?.handleUploadStart?.(upload);
console.log("upload start >>>>>", upload);
};
@@ -0,0 +1,52 @@
import { z } from "zod";
import type { PageableList, PageableParams } from "@/shared/model";
export const IMAGE_TYPE = {
IMGSUB: "IMGSUB",
IMGMAIN: "IMGMAIN",
IMGLOGIN: "IMGLOGIN",
} as const;
export type ImageType = (typeof IMAGE_TYPE)[keyof typeof IMAGE_TYPE];
export interface ImageParams extends PageableParams {
keyword: string;
imgTypeCd: string;
useYn: string;
}
export interface Image {
imgSn: number;
imgTypeCd: string;
imgTypeNm: string;
imgTtl: string;
lnkgUrlAddr: string;
atflYn: string;
sortSeq: number;
atflCd: string;
useYn: string;
mdfcnDt: string;
mdFr: string;
mdfrNm: string;
}
export const imageFormSchema = z.object({
imgTtl: z.string(),
sortSeq: z.number(),
imgTypeCd: z.string(),
atflYn: z.string(),
atflCd: z.string().nullish(),
lnkgUrlAddr: z.string().nullish(),
useYn: z.string(),
});
export type ImageFormData = z.infer<typeof imageFormSchema>;
export type ImageList = PageableList<Image>;
export interface ImageService {
getImageList: (params: ImageParams) => Promise<ImageList>;
getImageInfo: (imgSn: string) => Promise<Image>;
createImage: (data: ImageFormData) => Promise<void>;
updateImage: (imgSn: string, data: ImageFormData) => Promise<void>;
deleteImage: (imgSn: string) => Promise<void>;
}
@@ -0,0 +1,74 @@
"use client";
import dayjs from "dayjs";
import { type ColumnDef } from "@tanstack/react-table";
import { Checkbox } from "@common/components/ui";
import { type Image } from "./image-interface";
import { IMAGE_TYPE } from "./image-interface";
export const columns: ColumnDef<Image>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) =>
table.toggleAllPageRowsSelected(!!checked)
}
className="h-4 w-4 rounded border-gray-300"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) => row.toggleSelected(!!checked)}
className="h-4 w-4 rounded border-gray-300"
/>
),
},
{
accessorKey: "imgSn",
header: "NO",
cell: ({ row }) => row.index + 1,
},
{
accessorKey: "imgTypeCd",
header: "이미지구분",
cell: ({ row }) => {
const type = row.getValue("imgTypeCd") as string;
switch (type) {
case IMAGE_TYPE.IMGSUB:
return "서브이미지";
case IMAGE_TYPE.IMGMAIN:
return "메인이미지";
case IMAGE_TYPE.IMGLOGIN:
return "로그인이미지";
default:
return type;
}
},
},
{
accessorKey: "imgTtl",
header: "이미지명",
},
{
accessorKey: "useYn",
header: "사용여부",
cell: ({ row }) => (row.getValue("useYn") === "Y" ? "사용" : "미사용"),
},
{
accessorKey: "mdfrNm",
header: "최종수정자",
},
{
accessorKey: "mdfcnDt",
header: "최종수정일",
cell: ({ row }) => {
const date = row.getValue("mdfcnDt") as string;
return date ? dayjs(date).format("YYYY-MM-DD") : "";
},
},
];
@@ -0,0 +1,40 @@
"use client";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import { imageFormSchema, type ImageFormData } from "../model/image-interface";
import { ImageApi } from "../api/image-service";
import { ImageForm } from "./image-form";
function CreatePage() {
const router = useRouter();
const { setMessage: alert } = useAlertStore((state) => state);
const { mutateAsync: createImage } = useMutation({
mutationFn: (data: ImageFormData) => ImageApi.createImage(data),
onSuccess: () => {
alert("등록되었습니다.", router.back);
},
});
const form = useForm<ImageFormData>({
resolver: zodResolver(imageFormSchema),
defaultValues: {
atflYn: "Y",
},
});
return (
<div className="card card-border !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">이미지 등록</h3>
</div>
<ImageForm form={form} handleSave={createImage} />
</div>
);
}
export { CreatePage };
+317
View File
@@ -0,0 +1,317 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import type { UseFormReturn } from "react-hook-form";
import type { HttpResponse, Upload } from "tus-js-client";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import {
Button,
FileUploader,
Input,
Label,
Progress,
RadioGroup,
RadioGroupItem,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from "@common/components";
import { CodeSelect } from "@/entities/code";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import { Form, FormControl, FormField, FormItem } from "@/shared/ui/form";
import { handleUpload } from "../lib/utils";
import type { ImageFormData } from "../model/image-interface";
type ImageFormProps = {
form: UseFormReturn<ImageFormData>;
imgSn?: string;
handleSave: (data: ImageFormData) => Promise<void>;
handleDelete?: () => Promise<void>;
};
function ImageForm({ form, imgSn, handleSave, handleDelete }: ImageFormProps) {
const isModifyMode = !!imgSn;
const atflYn = form.watch("atflYn");
const router = useRouter();
const { confirm } = useConfirm();
const [onConfirm, setOnConfirm] = useState(false);
const [upload, setUpload] = useState<Upload | null>(null);
const [progress, setProgress] = useState(0);
const handleConfirm = async (callback: () => Promise<void>) => {
setOnConfirm(true);
await callback();
setOnConfirm(false);
};
const onSubmit = async (data: ImageFormData) => {
const confirmMessage = isModifyMode
? "수정하시겠습니까?"
: "저장하시겠습니까?";
await handleConfirm(async () => {
if (await confirm(confirmMessage)) {
await handleSave(data);
}
});
};
const onDelete = async () => {
await handleConfirm(async () => {
if (await confirm("삭제하시겠습니까?")) {
await handleDelete?.();
}
});
};
const handleProgress = (percentage: string) => {
setProgress(Number(percentage));
};
const handleUploadResponse = (res: HttpResponse) => {
const fileInfo = res.getHeader("X-File-Info");
if (fileInfo === undefined || fileInfo === null) return;
form.setValue("atflCd", JSON.parse(fileInfo)?.atflCd);
};
return (
<>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="card">
<Table variant="secondary">
<colgroup>
<col width="20%" />
<col width="80%" />
</colgroup>
<TableBody>
<TableRow>
<TableHead>이미지명</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="imgTtl"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="이미지명을 입력해주세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>정렬순서</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="sortSeq"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
type="number"
min={0}
value={field.value ?? ""}
onChange={(e) => {
const value = e.target.value;
if (isNaN(Number(value))) return;
field.onChange(Number(value));
}}
className="w-[12rem] !text-[1.3rem]"
placeholder="정렬순서를 입력해주세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>이미지유형</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="imgTypeCd"
render={({ field }) => (
<FormItem>
<FormControl>
<CodeSelect
{...field}
value={field.value ?? ""}
upCd="IMGTYPE"
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>첨부파일</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="atflYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
{...field}
name="atflYn"
onValueChange={field.onChange}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="atflY" />
<Label htmlFor="atflY">이미지파일</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="atflN" />
<Label htmlFor="atflN">외부링크</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
{atflYn === "Y" && (
<TableRow>
<TableHead>이미지파일</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="atflCd"
render={() => (
<FormItem className="flex flex-col">
<FormControl>
<FileUploader
id="atflCd"
className="!h-auto !max-w-none flex-col !items-start"
onUpload={(e) => {
const files = e.target.files;
if (!files) return;
handleUpload(files, {
handleUploadStart: setUpload,
handleProgress,
handleSuccess: setUpload,
handleResponse: handleUploadResponse,
});
}}
customPreview={
<Label htmlFor="atflCd">
<div className="relative h-[27rem] w-[48rem] rounded-sm border border-border">
<p className="absolute left-0 top-0 p-2 text-base leading-none">
{(upload?.file as File)?.name}
</p>
<div></div>
<Progress
value={progress}
className="absolute bottom-0 left-0"
/>
</div>
</Label>
}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
)}
{atflYn === "N" && (
<TableRow>
<TableHead>외부링크</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="lnkgUrlAddr"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="외부링크를 입력해주세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
)}
<TableRow>
<TableHead>사용여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="useYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
{...field}
name="useYn"
onValueChange={field.onChange}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="useY" />
<Label htmlFor="useY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="useN" />
<Label htmlFor="useN">미사용</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<div className="flex justify-center gap-4 pb-12 pt-10">
<Button
size="lg"
color="white"
type="button"
onClick={() => router.back()}
>
목록
</Button>
<Button size="lg" type="submit">
저장
</Button>
{isModifyMode && (
<Button size="lg" color="red" type="button" onClick={onDelete}>
삭제
</Button>
)}
</div>
</form>
</Form>
<ConfirmDialog open={onConfirm} />
</>
);
}
export { ImageForm };
+73 -43
View File
@@ -1,60 +1,90 @@
"use client";
import { Upload } from "tus-js-client";
import { FileUploader } from "@common/components/ui";
import { PageLayout } from "@/shared/ui/page-layout";
import { AUTH_USER_ID, CLAIM_NAME, ENDPOINT } from "@/shared/config";
import { getCookie } from "@/shared/lib";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import {
getCoreRowModel,
useReactTable,
type RowSelectionState,
} from "@tanstack/react-table";
import { Button, DataTable, Input, Label } from "@common/components";
import { ENDPOINT } from "@/shared/config";
import { ImageApi } from "../api/image-service";
import { columns } from "../model/table-columns";
import { type Image, type ImageParams } from "../model/image-interface";
function ImagePage() {
const handleUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.item(0);
if (!file) return;
const router = useRouter();
const upload = new Upload(file, {
endpoint: `${ENDPOINT.ATTACH_SERVICE.TUS_UPLOAD}`,
headers: {
[CLAIM_NAME]: getCookie(CLAIM_NAME) ?? "",
[AUTH_USER_ID]: getCookie(AUTH_USER_ID) ?? "",
},
retryDelays: [0, 3000, 5000, 10000, 20000],
metadata: {
filename: file.name,
filetype: file.type,
},
onError: function (error) {
console.log("Failed because: " + error);
},
onProgress: function (bytesUploaded, bytesTotal) {
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2);
console.log(bytesUploaded, bytesTotal, percentage + "%");
},
onSuccess: function () {
console.log(
"Download %s from %s",
(upload.file as File).name,
upload.url,
);
},
});
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [imageQuery, setImageQuery] = useState<ImageParams>({
keyword: "",
imgTypeCd: "",
useYn: "",
page: 0,
size: 100,
});
upload.findPreviousUploads().then(function (previousUploads) {
if (previousUploads.length) {
upload.resumeFromPreviousUpload(previousUploads[0]!);
}
upload.start();
});
const { data: imageList, refetch } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.IMAGES],
queryFn: () => ImageApi.getImageList(imageQuery),
enabled: false,
});
const table = useReactTable({
data: imageList?.content ?? [],
columns,
state: {
rowSelection,
},
enableMultiRowSelection: true,
getCoreRowModel: getCoreRowModel(),
onRowSelectionChange: setRowSelection,
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
refetch();
};
const handleRowClick = (row: Image) => {
router.push(`/image/register/${row.imgSn}`);
};
useEffect(() => {
refetch();
}, []);
return (
<PageLayout>
<div className="card card-border !mt-6">
<div className="card card-border">
<form onSubmit={handleSubmit} className="flex items-center gap-2 p-2">
<Label htmlFor="" className="mr-2 text-label">
이미지명
</Label>
<Input
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]"
value={imageQuery.keyword}
onChange={(e) =>
setImageQuery({ ...imageQuery, keyword: e.target.value })
}
/>
<Button className="h-[3.2rem]" type="submit">
조회
</Button>
</form>
</div>
<div className="card card-border">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">이미지 목록</h3>
<Button onClick={() => router.push("/image/register")}>등록</Button>
</div>
<div className="card !m-[1.2rem] h-[49rem] overflow-auto !p-0">
<DataTable table={table} onRowClick={handleRowClick} />
</div>
<FileUploader id="image-uploader" onUpload={handleUpload} />
</div>
</PageLayout>
</div>
);
}
@@ -0,0 +1,56 @@
"use client";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import { imageFormSchema, type ImageFormData } from "../model/image-interface";
import { ImageApi } from "../api/image-service";
import { ImageForm } from "./image-form";
import { useAlertStore } from "@/shared/lib/use-alert-store";
function ModifyPage({ serial }: { serial: string }) {
const router = useRouter();
const { setMessage: alert } = useAlertStore((state) => state);
const { data: imageDetail } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.IMAGES, serial],
queryFn: () => ImageApi.getImageInfo(serial),
});
const { mutateAsync: updateImage } = useMutation({
mutationFn: (data: ImageFormData) => ImageApi.updateImage(serial, data),
onSuccess: () => {
alert("수정되었습니다.");
},
});
const { mutateAsync: deleteImage } = useMutation({
mutationFn: () => ImageApi.deleteImage(serial),
onSuccess: () => {
alert("삭제되었습니다.", router.back);
},
});
const form = useForm<ImageFormData>({
resolver: zodResolver(imageFormSchema),
values: imageDetail,
});
return (
<div className="card card-border !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">이미지 수정</h3>
</div>
<ImageForm
form={form}
imgSn={serial}
handleSave={updateImage}
handleDelete={deleteImage}
/>
</div>
);
}
export { ModifyPage };
+3 -3
View File
@@ -24,7 +24,7 @@ export class MenuApiService implements MenuService {
return response.data;
}
public async modifyMenu(
public async updateMenu(
menuCd: number,
data: Omit<MenuFormData, "siteId">,
): Promise<void> {
@@ -49,7 +49,7 @@ export class MenuApiService implements MenuService {
return response.data;
}
public async modifyTreeMenu(siteId: string, data: TreeNode[]): Promise<void> {
public async updateTreeMenu(siteId: string, data: TreeNode[]): Promise<void> {
const response = await client.put<void>(
`${ENDPOINT.CMS_SERVICE.MENUS}/${siteId}/tree`,
data,
@@ -57,7 +57,7 @@ export class MenuApiService implements MenuService {
return response.data;
}
public async modifyMenuName(menuCd: number, name: string): Promise<void> {
public async updateMenuName(menuCd: number, name: string): Promise<void> {
const response = await client.put<void>(
`${ENDPOINT.CMS_SERVICE.MENUS}/${menuCd}/${name}`,
);
@@ -1,6 +1,6 @@
import { createColumnHelper } from "@tanstack/react-table";
import { Checkbox } from "@common/components/ui";
import type { Content } from "../model/content-interface";
import type { Content } from "./content-interface";
const columnHelper = createColumnHelper<Content>();
@@ -75,12 +75,12 @@ export type Menu = Omit<MenuFormData, "iconNm"> & {
export interface MenuService {
getMenuInfo: (menuCd: number) => Promise<Menu>;
createMenu: (data: MenuFormData) => Promise<void>;
modifyMenu: (
updateMenu: (
menuCd: number,
data: Omit<MenuFormData, "siteId">,
) => Promise<void>;
deleteMenu: (menuCd: number) => Promise<void>;
getTreeMenuList: (siteId: string) => Promise<TreeMenu[]>;
modifyTreeMenu: (siteId: string, data: TreeNode[]) => Promise<void>;
modifyMenuName: (menuCd: number, name: string) => Promise<void>;
updateTreeMenu: (siteId: string, data: TreeNode[]) => Promise<void>;
updateMenuName: (menuCd: number, name: string) => Promise<void>;
}
+1 -9
View File
@@ -10,12 +10,7 @@ import {
TableHead,
TableRow,
} from "@common/components";
import {
FormItem,
FormControl,
FormField,
FormMessage,
} from "@/shared/ui/form";
import { FormItem, FormControl, FormField } from "@/shared/ui/form";
import { MENU_TYPE, type MenuFormData } from "../model/menu-interface";
interface BaseMenuFormProps {
@@ -57,7 +52,6 @@ function BaseMenuForm({ form, menuCd }: BaseMenuFormProps) {
placeholder="메뉴명을 입력하십시오."
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
@@ -79,7 +73,6 @@ function BaseMenuForm({ form, menuCd }: BaseMenuFormProps) {
placeholder="메뉴 영문명을 입력하십시오."
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
@@ -118,7 +111,6 @@ function BaseMenuForm({ form, menuCd }: BaseMenuFormProps) {
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
@@ -18,7 +18,7 @@ import {
import { ENDPOINT } from "@/shared/config";
import { ContentApi } from "../api/content-service";
import type { Content } from "../model/content-interface";
import { columns } from "../model/table-colums";
import { columns } from "../model/content-table-colums";
type ContentSearchDialogProps = {
handleContentSelect: (content: Content) => void;
+7 -7
View File
@@ -13,7 +13,7 @@ interface MenuFormProps {
form: UseFormReturn<MenuFormData>;
menuCd?: number;
handleCreate: (data: MenuFormData) => void;
handleModify: ({
handleUpdate: ({
menuCd,
data,
}: {
@@ -27,7 +27,7 @@ function MenuForm({
form,
menuCd,
handleCreate,
handleModify,
handleUpdate,
handleDelete,
}: MenuFormProps) {
const isModifyMode = !!menuCd;
@@ -35,15 +35,15 @@ function MenuForm({
const { confirm } = useConfirm();
const [onConfirm, setOnConfirm] = useState(false);
const handleConfirm = async (fn: () => Promise<void>) => {
const handleConfirm = async (callback: () => Promise<void>) => {
setOnConfirm(true);
await fn();
await callback();
setOnConfirm(false);
};
const onSubmit = async (data: MenuFormData) => {
if (isModifyMode) {
await onModify(data);
await onUpdate(data);
} else {
await onCreate(data);
}
@@ -57,10 +57,10 @@ function MenuForm({
});
};
const onModify = async (data: MenuFormData) => {
const onUpdate = async (data: MenuFormData) => {
await handleConfirm(async () => {
if (await confirm("수정하시겠습니까?")) {
handleModify({ menuCd: menuCd!, data });
handleUpdate({ menuCd: menuCd!, data });
}
});
};
+3 -3
View File
@@ -34,10 +34,10 @@ function MenuNode({
const queryClient = useQueryClient();
const { mutateAsync: modifyMenuName } = useMutation({
const { mutateAsync: updateMenuName } = useMutation({
mutationFn: (nodeName: string) => {
const encodedName = encodeURIComponent(nodeName);
return MenuApi.modifyMenuName(id as number, encodedName);
return MenuApi.updateMenuName(id as number, encodedName);
},
onSuccess: () => {
queryClient.invalidateQueries({
@@ -53,7 +53,7 @@ function MenuNode({
const handleSelect = () => onSelect(node);
const handleSave = async () => {
await modifyMenuName(nodeName);
await updateMenuName(nodeName);
setEditMode(false);
};
+60 -63
View File
@@ -16,7 +16,6 @@ import {
} from "@common/components/ui";
import { SiteApi } from "@/entities/site";
import { ENDPOINT } from "@/shared/config";
import { PageLayout } from "@/shared/ui/page-layout";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import type { TreeMenu } from "../model/menu-interface";
@@ -81,14 +80,14 @@ function MenuPage() {
},
});
const { mutate: modifyMenu } = useMutation({
const { mutate: updateMenu } = useMutation({
mutationFn: ({
menuCd,
data,
}: {
menuCd: number;
data: Omit<MenuFormData, "siteId">;
}) => MenuApi.modifyMenu(menuCd, data),
}) => MenuApi.updateMenu(menuCd, data),
onSuccess: () => {
alert("수정되었습니다.");
queryClient.invalidateQueries({
@@ -197,67 +196,65 @@ function MenuPage() {
return (
<>
<PageLayout>
<div className="card !mb-0 flex !min-h-[calc(100vh-16.8rem)] gap-2">
<div className="card card-border w-[24rem]">
<Select value={siteId} onValueChange={handleSiteIdChange}>
<SelectTrigger>
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
{options?.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{siteId && (
<>
<div className="my-4 flex gap-2">
<Button
size="sm"
color="primary"
className="flex-1"
onClick={handleAddMenu}
>
메뉴 추가
</Button>
<Button
size="sm"
color="red"
className="flex-1"
onClick={handleNodeDelete}
disabled={!selectedMenu}
>
메뉴 삭제
</Button>
</div>
<div className="card card-border !mx-0">
<MenuTree
tree={tree}
siteId={siteId}
selectedMenu={selectedMenu}
additionalNodeForm={additionalNodeForm}
onSelectNode={handleNodeSelect}
onAdditionalNodeSave={handleAdditionalNodeSave}
onAdditionalNodeCancel={resetAdditionalNode}
/>
</div>
</>
)}
</div>
<div className="card card-border flex-1">
<MenuForm
form={form}
menuCd={selectedMenu?.menuCd}
handleCreate={createMenu}
handleModify={modifyMenu}
handleDelete={deleteMenu}
/>
</div>
<div className="card !mb-0 flex !min-h-[calc(100vh-16.8rem)] gap-2">
<div className="card card-border w-[24rem]">
<Select value={siteId} onValueChange={handleSiteIdChange}>
<SelectTrigger>
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
{options?.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{siteId && (
<>
<div className="my-4 flex gap-2">
<Button
size="sm"
color="primary"
className="flex-1"
onClick={handleAddMenu}
>
메뉴 추가
</Button>
<Button
size="sm"
color="red"
className="flex-1"
onClick={handleNodeDelete}
disabled={!selectedMenu}
>
메뉴 삭제
</Button>
</div>
<div className="card card-border !mx-0">
<MenuTree
tree={tree}
siteId={siteId}
selectedMenu={selectedMenu}
additionalNodeForm={additionalNodeForm}
onSelectNode={handleNodeSelect}
onAdditionalNodeSave={handleAdditionalNodeSave}
onAdditionalNodeCancel={resetAdditionalNode}
/>
</div>
</>
)}
</div>
</PageLayout>
<div className="card card-border flex-1">
<MenuForm
form={form}
menuCd={selectedMenu?.menuCd}
handleCreate={createMenu}
handleUpdate={updateMenu}
handleDelete={deleteMenu}
/>
</div>
</div>
<ConfirmDialog open={onConfirm} />
</>
);
+3 -3
View File
@@ -41,9 +41,9 @@ function MenuTree({
const queryClient = useQueryClient();
const treeMethods = useRef<TreeMethods>(null);
const { mutate: modifyTreeMenu } = useMutation({
const { mutate: updateTreeMenu } = useMutation({
mutationFn: (newTree: TreeNode) =>
MenuApi.modifyTreeMenu(siteId, [newTree]),
MenuApi.updateTreeMenu(siteId, [newTree]),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.MENUS],
@@ -96,7 +96,7 @@ function MenuTree({
/>
);
}}
onDrop={handleDropNode(modifyTreeMenu)}
onDrop={handleDropNode(updateTreeMenu)}
classes={{
root: "p-4",
container: "flex flex-col",
+1 -13
View File
@@ -13,12 +13,7 @@ import {
TableHead,
TableRow,
} from "@common/components";
import {
FormItem,
FormControl,
FormField,
FormMessage,
} from "@/shared/ui/form";
import { FormItem, FormControl, FormField } from "@/shared/ui/form";
import type { BoardPopup } from "@/entities/board";
import type { Content } from "../model/content-interface";
import type { User } from "../model/organization-interface";
@@ -117,7 +112,6 @@ function TypeMenuForm({ form, menuType }: TypeMenuFormProps) {
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
@@ -153,7 +147,6 @@ function TypeMenuForm({ form, menuType }: TypeMenuFormProps) {
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
@@ -186,7 +179,6 @@ function TypeMenuForm({ form, menuType }: TypeMenuFormProps) {
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
@@ -211,7 +203,6 @@ function TypeMenuForm({ form, menuType }: TypeMenuFormProps) {
placeholder="사이트맵에서 다른 이름으로 사용할 경우 입력하십시오."
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
@@ -255,7 +246,6 @@ function TypeMenuForm({ form, menuType }: TypeMenuFormProps) {
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
@@ -287,7 +277,6 @@ function TypeMenuForm({ form, menuType }: TypeMenuFormProps) {
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
@@ -322,7 +311,6 @@ function TypeMenuForm({ form, menuType }: TypeMenuFormProps) {
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
+5 -3
View File
@@ -6,7 +6,6 @@ import { useMutation } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod";
import { SiteApi, siteFormSchema, type SiteFormData } from "@/entities/site";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import { PageLayout } from "@/shared/ui/page-layout";
import { SiteForm } from "./site-form";
function CreatePage() {
@@ -38,9 +37,12 @@ function CreatePage() {
});
return (
<PageLayout>
<div className="card card-border !mb-0 !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">사이트 등록</h3>
</div>
<SiteForm form={form} handleSave={createSite} />
</PageLayout>
</div>
);
}
@@ -22,14 +22,9 @@ import {
DialogFooter,
DialogClose,
Checkbox,
Label,
} from "@common/components";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/shared/ui/form";
import { Form, FormControl, FormField, FormItem } from "@/shared/ui/form";
import { ENDPOINT } from "@/shared/config";
import { DomainApi } from "../api/domain-service";
import {
@@ -233,7 +228,6 @@ function DomainManagementDialog({
className="!text-[1.3rem]"
/>
</FormControl>
<FormMessage className="text-left" />
</FormItem>
)}
/>
@@ -253,7 +247,6 @@ function DomainManagementDialog({
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
@@ -275,20 +268,19 @@ function DomainManagementDialog({
value="Y"
id={`useY-${i}`}
/>
<label htmlFor={`useY-${i}`}>예</label>
<Label htmlFor={`useY-${i}`}>예</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="N"
id={`useN-${i}`}
/>
<label htmlFor={`useN-${i}`}>
<Label htmlFor={`useN-${i}`}>
아니오
</label>
</Label>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
@@ -22,14 +22,9 @@ import {
DialogFooter,
DialogClose,
Checkbox,
Label,
} from "@common/components";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/shared/ui/form";
import { Form, FormControl, FormField, FormItem } from "@/shared/ui/form";
import { ENDPOINT } from "@/shared/config";
import { IpApi } from "../api/ip-service";
import { ipFormSchema, type IP, type IPFormData } from "../model/ip-interface";
@@ -217,7 +212,6 @@ function IPManagementDialog({
className="!text-[1.3rem]"
/>
</FormControl>
<FormMessage className="text-left" />
</FormItem>
)}
/>
@@ -239,20 +233,19 @@ function IPManagementDialog({
value="Y"
id={`prmY-${i}`}
/>
<label htmlFor={`prmY-${i}`}>예</label>
<Label htmlFor={`prmY-${i}`}>예</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="N"
id={`prmN-${i}`}
/>
<label htmlFor={`prmN-${i}`}>
<Label htmlFor={`prmN-${i}`}>
아니오
</label>
</Label>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
+10 -15
View File
@@ -6,13 +6,10 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { SiteApi, siteFormSchema, type SiteFormData } from "@/entities/site";
import { ENDPOINT } from "@/shared/config";
import { PageLayout } from "@/shared/ui/page-layout";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import { SiteForm } from "./site-form";
function ModifyPage({ siteId }: { siteId: string }) {
const isModifyMode = !!siteId;
const router = useRouter();
const queryClient = useQueryClient();
const { setMessage: alert } = useAlertStore((state) => state);
@@ -31,11 +28,11 @@ function ModifyPage({ siteId }: { siteId: string }) {
bscSiteYn: data.basicSiteYn,
};
},
enabled: isModifyMode,
enabled: !!siteId,
});
const { mutateAsync: modifySite } = useMutation({
mutationFn: (data: SiteFormData) => SiteApi.modifySite(siteId, data),
const { mutateAsync: updateSite } = useMutation({
mutationFn: (data: SiteFormData) => SiteApi.updateSite(siteId, data),
onSuccess: () => {
alert("수정되었습니다.", () => {
queryClient.invalidateQueries({
@@ -48,12 +45,7 @@ function ModifyPage({ siteId }: { siteId: string }) {
const { mutateAsync: deleteSite } = useMutation({
mutationFn: () => SiteApi.deleteSite(siteId),
onSuccess: () => {
alert("삭제되었습니다.", () => {
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.SITES],
});
router.back();
});
alert("삭제되었습니다.", router.back);
},
});
@@ -63,14 +55,17 @@ function ModifyPage({ siteId }: { siteId: string }) {
});
return (
<PageLayout>
<div className="card card-border !mb-0 !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">사이트 수정</h3>
</div>
<SiteForm
form={form}
siteId={siteId}
handleSave={modifySite}
handleSave={updateSite}
handleDelete={deleteSite}
/>
</PageLayout>
</div>
);
}
+303 -324
View File
@@ -16,13 +16,7 @@ import {
} from "@common/components";
import { CodeSelect } from "@/entities/code";
import type { SiteFormData } from "@/entities/site";
import {
FormItem,
FormControl,
FormField,
Form,
FormMessage,
} from "@/shared/ui/form";
import { FormItem, FormControl, FormField, Form } from "@/shared/ui/form";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import type { Domain } from "../model/domain-interface";
import { IPManagementDialog } from "./ip-management-dialog";
@@ -44,6 +38,12 @@ function SiteForm({ form, siteId, handleSave, handleDelete }: SiteFormProps) {
const [isIpManagementOpen, setIsIpManagementOpen] = useState(false);
const [isDomainManagementOpen, setIsDomainManagementOpen] = useState(false);
const handleConfirm = async (callback: () => Promise<void>) => {
setOnConfirm(true);
await callback();
setOnConfirm(false);
};
const handleSaveDomain = (rows: Domain[]) => {
form.setValue(
"dmnAddr",
@@ -56,350 +56,329 @@ function SiteForm({ form, siteId, handleSave, handleDelete }: SiteFormProps) {
? "수정하시겠습니까?"
: "저장하시겠습니까?";
setOnConfirm(true);
if (await confirm(confirmMessage)) {
setOnConfirm(false);
handleSave(data);
}
setOnConfirm(false);
await handleConfirm(async () => {
if (await confirm(confirmMessage)) {
handleSave(data);
}
});
};
const onDelete = async () => {
setOnConfirm(true);
if (await confirm("삭제하시겠습니까?")) {
setOnConfirm(false);
handleDelete?.();
}
setOnConfirm(false);
await handleConfirm(async () => {
if (await confirm("삭제하시겠습니까?")) {
handleDelete?.();
}
});
};
return (
<>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="card card-border !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">
{isModifyMode ? "사이트 수정" : "사이트 등록"}
</h3>
</div>
<div className="card">
<Table variant="secondary">
<colgroup>
<col width="20%" />
<col width="80%" />
</colgroup>
<TableBody>
<TableRow>
<TableHead>사이트 코드</TableHead>
<TableCell className="border">
<div className="card">
<Table variant="secondary">
<colgroup>
<col width="20%" />
<col width="80%" />
</colgroup>
<TableBody>
<TableRow>
<TableHead>사이트 코드</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="siteId"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="w-[12rem] !text-[1.3rem]"
disabled={isModifyMode}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사이트 구분</TableHead>
<TableCell className="border">
<div className="flex gap-2">
<FormField
control={form.control}
name="siteId"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="w-[12rem] !text-[1.3rem]"
disabled={isModifyMode}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사이트 구분</TableHead>
<TableCell className="border">
<div className="flex gap-2">
<FormField
control={form.control}
name="siteKndCd"
render={({ field }) => (
<FormItem>
<FormControl>
<CodeSelect
{...field}
upCd="SITEKNDCD"
onValueChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{isModifyMode && (
<IPManagementDialog
siteId={siteId}
triggerDisabled={form.watch("siteKndCd") === "O"}
open={isIpManagementOpen}
onOpenChange={setIsIpManagementOpen}
/>
)}
</div>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사이트 디자인</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="siteSkn"
name="siteKndCd"
render={({ field }) => (
<FormItem>
<FormControl>
<CodeSelect
{...field}
value={field.value ?? ""}
upCd="SITESKINCD"
upCd="SITEKNDCD"
onValueChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
{isModifyMode && (
<IPManagementDialog
siteId={siteId}
triggerDisabled={form.watch("siteKndCd") === "O"}
open={isIpManagementOpen}
onOpenChange={setIsIpManagementOpen}
/>
)}
</div>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사이트 디자인</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="siteSkn"
render={({ field }) => (
<FormItem>
<FormControl>
<CodeSelect
{...field}
value={field.value ?? ""}
upCd="SITESKINCD"
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사이트명</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="siteNm"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="사이트명을 입력하십시오."
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사이트 설명</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="siteExpln"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="사이트 설명을 입력하십시오."
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
{isModifyMode && (
<TableRow>
<TableHead>사이트명</TableHead>
<TableHead>사이트 도메인</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="siteNm"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="사이트명을 입력하십시오."
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사이트 설명</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="siteExpln"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="사이트 설명을 입력하십시오."
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
{isModifyMode && (
<TableRow>
<TableHead>사이트 도메인</TableHead>
<TableCell className="border">
<div className="flex gap-2">
<div className="h-[3.2rem] w-[21.5rem] cursor-not-allowed rounded-md border border-input bg-form px-3 py-2 text-[1.3rem] opacity-50">
{form.watch("dmnAddr")}
</div>
<DomainManagementDialog
siteId={siteId}
open={isDomainManagementOpen}
onOpenChange={setIsDomainManagementOpen}
handleSaveDomain={handleSaveDomain}
/>
<div className="flex gap-2">
<div className="h-[3.2rem] w-[21.5rem] cursor-not-allowed rounded-md border border-input bg-form px-3 py-2 text-[1.3rem] opacity-50">
{form.watch("dmnAddr")}
</div>
</TableCell>
</TableRow>
)}
<TableRow>
<TableHead>사이트 하단 표기 주소</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="siteAddr"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="사이트 하단 주소를 입력하십시오."
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DomainManagementDialog
siteId={siteId}
open={isDomainManagementOpen}
onOpenChange={setIsDomainManagementOpen}
handleSaveDomain={handleSaveDomain}
/>
</div>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사이트 하단 표기 전화번호</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="telNo"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="사이트 하단 전화번호를 입력하십시오."
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사이트 하단 표기 팩스번호</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="faxNo"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="사이트 하단 팩스번호를 입력하십시오."
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사이트 하단 표기 내용</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="lwndCn"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="사이트 표기 내용을 입력하십시오. (ex. COPYRIGHT c 2013 KECO. ALL RIGHTS RESERVED.)"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>북마크 아이콘</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="bkmkIcon"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="아이콘 파일 경로 및 파일명을 입력하여 주십시오."
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>기본 사이트 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="bscSiteYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
{...field}
name="bscSiteYn"
onValueChange={field.onChange}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="bscSiteY" />
<Label htmlFor="bscSiteY">예</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="bscSiteN" />
<Label htmlFor="bscSiteN">아니오</Label>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사용 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="useYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
{...field}
name="useYn"
onValueChange={field.onChange}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="useY" />
<Label htmlFor="useY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="useN" />
<Label htmlFor="useN">사용안함</Label>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
)}
<TableRow>
<TableHead>사이트 하단 표기 주소</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="siteAddr"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="사이트 하단 주소를 입력하십시오."
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사이트 하단 표기 전화번호</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="telNo"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="사이트 하단 전화번호를 입력하십시오."
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사이트 하단 표기 팩스번호</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="faxNo"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="사이트 하단 팩스번호를 입력하십시오."
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사이트 하단 표기 내용</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="lwndCn"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="사이트 표기 내용을 입력하십시오. (ex. COPYRIGHT c 2013 KECO. ALL RIGHTS RESERVED.)"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>북마크 아이콘</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="bkmkIcon"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
placeholder="아이콘 파일 경로 및 파일명을 입력하여 주십시오."
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>기본 사이트 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="bscSiteYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
{...field}
name="bscSiteYn"
onValueChange={field.onChange}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="bscSiteY" />
<Label htmlFor="bscSiteY">예</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="bscSiteN" />
<Label htmlFor="bscSiteN">아니오</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>사용 여부</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="useYn"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
{...field}
name="useYn"
onValueChange={field.onChange}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="useY" />
<Label htmlFor="useY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="useN" />
<Label htmlFor="useN">사용안함</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<div className="flex justify-center gap-4 pb-12 pt-10">
<Button
+24 -27
View File
@@ -6,10 +6,9 @@ import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { Button, DataTable, Input } from "@common/components";
import { Button, DataTable, Input, Label } from "@common/components";
import { SiteApi, type Site, type SiteParams } from "@/entities/site";
import { ENDPOINT } from "@/shared/config";
import { PageLayout } from "@/shared/ui/page-layout";
import { columns } from "../model/table-columns";
function SitePage() {
@@ -44,34 +43,32 @@ function SitePage() {
}, []);
return (
<PageLayout>
<div className="card card-border !mt-6">
<div className="card card-border">
<form onSubmit={handleSubmit} className="flex items-center gap-2 p-2">
<label htmlFor="" className="mr-2 text-label">
사이트명
</label>
<Input
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]"
value={siteQuery.siteNm}
onChange={(e) => setSiteQuery({ siteNm: e.target.value })}
/>
<Button className="h-[3.2rem]" type="submit">
조회
</Button>
</form>
<div className="card card-border !mt-6">
<div className="card card-border">
<form onSubmit={handleSubmit} className="flex items-center gap-2 p-2">
<Label htmlFor="" className="mr-2 text-label">
사이트명
</Label>
<Input
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]"
value={siteQuery.siteNm}
onChange={(e) => setSiteQuery({ siteNm: e.target.value })}
/>
<Button className="h-[3.2rem]" type="submit">
조회
</Button>
</form>
</div>
<div className="card card-border">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">사이트 목록</h3>
<Button onClick={() => router.push("/site/register")}>등록</Button>
</div>
<div className="card card-border">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">사이트 목록</h3>
<Button onClick={() => router.push("/site/register")}>등록</Button>
</div>
<div className="card !m-[1.2rem] h-[49rem] overflow-auto !p-0">
<DataTable table={table} onRowClick={handleRowClick} />
</div>
<div className="card !m-[1.2rem] h-[49rem] overflow-auto !p-0">
<DataTable table={table} onRowClick={handleRowClick} />
</div>
</div>
</PageLayout>
</div>
);
}
+4
View File
@@ -10,6 +10,8 @@ export const ENDPOINT = {
LOGIN: "/user-service/login",
USERS: "/user-service/api/v1/users",
SILENT_REFRESH: "/user-service/api/v1/users/token/refresh",
AUTHRTS: "/user-service/api/v1/authorizations",
ROLES_AUTHRTS: "/user-service/api/v1/role-authorizations",
},
CMS_SERVICE: {
SITES: "/cms-service/api/v1/sites",
@@ -20,6 +22,8 @@ export const ENDPOINT = {
MENU_ROLES: "/cms-service/api/v1/menu-roles",
CONTENTS: "/cms-service/api/v1/contents",
USER_OGNZ: "/cms-service/api/v1/user-ognz",
IMAGES: "/cms-service/api/v1/images",
GROUP: "/cms-service/api/v1/groups",
},
BOARD_SERVICE: {
BBS: "/board-service/api/v1/bbs",
@@ -49,4 +49,4 @@ import { useWrStore } from '../../../store';
</div>
</>
)
}
}
+1
View File
@@ -0,0 +1 @@
.qodo
+1
View File
@@ -35,6 +35,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-progress": "^1.1.2",
"@radix-ui/react-radio-group": "^1.2.2",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
+1 -1
View File
@@ -20,7 +20,7 @@ export interface SiteInfo {
mdfcnDt: string;
mdFr: string;
rgTrNm: string;
mdFrNm: string;
mdfrNm: string;
}
export const getSiteInfo = async (siteId: string) => {
@@ -2,13 +2,17 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
export function QueryProvider({
children,
client,
queryClient,
}: {
children: React.ReactNode;
client?: QueryClient;
queryClient?: QueryClient;
}) {
const queryClient = client ?? new QueryClient();
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
if (!queryClient) {
const queryClient = new QueryClient();
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
return <>{children}</>;
}
@@ -3,6 +3,7 @@ import {
type RowData,
type Table as TableType,
} from "@tanstack/react-table";
import { cn } from "../../lib";
import { Table, TableCell, TableHead, TableHeader, TableRow } from "../table";
declare module "@tanstack/react-table" {
@@ -55,7 +56,7 @@ function DataTable<T>({
<TableRow
key={row.id}
onClick={() => onRowClick?.(row.original)}
className={onRowClick ? "cursor-pointer hover:bg-accent" : ""}
className={cn(onRowClick && "cursor-pointer hover:bg-accent")}
>
{row.getVisibleCells().map((cell) => {
const { meta } = cell.column.columnDef;
@@ -2,12 +2,12 @@
@layer components {
.file-uploader-container {
@apply flex w-full max-w-sm items-center gap-2;
@apply flex h-[3.2rem] w-full max-w-sm items-center gap-2;
}
.file-uploader-preview {
@apply h-12 w-full min-w-56 overflow-x-auto whitespace-nowrap rounded-md border border-input bg-background px-3 py-2 text-[1.2rem];
@apply h-full w-full min-w-56 overflow-x-auto whitespace-nowrap rounded-md border border-input bg-background px-3 py-2 text-[1.2rem];
}
.file-uploader-label {
@apply flex-shrink-0 cursor-pointer rounded-sm border border-input bg-muted px-4 py-2 text-[1.2rem] font-medium text-muted-foreground;
@apply inline-flex h-full flex-shrink-0 cursor-pointer items-center rounded-sm border border-input bg-muted px-4 py-2 text-[1.2rem] font-medium text-muted-foreground;
}
}
@@ -2,18 +2,21 @@ import "./file-uploader.css";
import { cn } from "../../lib/utils";
import { Input } from "../input";
import { Label } from "../label";
import { combineFileName, validFileType } from "./utils";
type FileUploaderProps = React.InputHTMLAttributes<HTMLInputElement> & {
id: string;
files?: ReadonlyArray<File>;
customPreview?: React.ReactNode;
onUpload: (e: React.ChangeEvent<HTMLInputElement>) => void;
};
export function FileUploader({
id,
className,
files,
customPreview,
className,
onUpload,
...props
}: FileUploaderProps) {
@@ -26,12 +29,14 @@ export function FileUploader({
{...props}
onChange={onUpload}
/>
<div className="file-uploader-preview">
{files?.filter(validFileType).map(combineFileName).join(", ")}
</div>
<label htmlFor={id} className="file-uploader-label">
{customPreview || (
<div className="file-uploader-preview">
{files?.filter(validFileType).map(combineFileName).join(", ")}
</div>
)}
<Label htmlFor={id} className="file-uploader-label">
파일 선택
</label>
</Label>
</div>
);
}
@@ -15,9 +15,11 @@ export * from "./layout";
export * from "./loading";
export * from "./page-title";
export * from "./popover";
export * from "./progress";
export * from "./radio-group";
export * from "./select";
export * from "./slider";
export * from "./switch";
export * from "./table";
export * from "./tabs";
export * from "./pagination";
@@ -19,7 +19,7 @@ export function BaseLayout({
handleMenuClick?: (item: MenuItem) => void;
}) {
return (
<QueryProvider client={queryClient}>
<QueryProvider queryClient={queryClient}>
<SidebarProvider>
<div className="flex-1">
<Header />
@@ -0,0 +1,90 @@
// apps/cs/src/common/components/ui/pagination.tsx
import { Button } from "../button";
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
}
function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
// 표시할 페이지 번호 범위 계산
const getPageRange = () => {
const range: number[] = [];
const maxButtons = 5; // 한 번에 보여줄 최대 페이지 버튼 수
const halfMaxButtons = Math.floor(maxButtons / 2);
let startPage = Math.max(currentPage - halfMaxButtons, 1);
let endPage = Math.min(startPage + maxButtons - 1, totalPages);
if (endPage - startPage + 1 < maxButtons) {
startPage = Math.max(endPage - maxButtons + 1, 1);
}
for (let i = startPage; i <= endPage; i++) {
range.push(i);
}
return range;
};
if (totalPages <= 1) return null;
return (
<div className="flex items-center gap-1">
{/* 첫 페이지 버튼 */}
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(1)}
disabled={currentPage === 1}
>
{"<<"}
</Button>
{/* 이전 페이지 버튼 */}
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
>
{"<"}
</Button>
{/* 페이지 번호 버튼들 */}
{getPageRange().map((pageNum) => (
<Button
key={pageNum}
variant={currentPage === pageNum ? "default" : "outline"}
size="sm"
onClick={() => onPageChange(pageNum)}
>
{pageNum}
</Button>
))}
{/* 다음 페이지 버튼 */}
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
{">"}
</Button>
{/* 마지막 페이지 버튼 */}
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(totalPages)}
disabled={currentPage === totalPages}
>
{">>"}
</Button>
</div>
);
}
export { Pagination };
@@ -0,0 +1,28 @@
"use client";
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "../../lib/utils";
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };
@@ -2,7 +2,7 @@
@layer components {
.table-ui tr {
@apply border-0 hover:bg-accent;
@apply border-0;
}
.table-ui th {
@apply px-4 py-[1.2rem] text-[1.3rem] font-medium leading-[1.2];
+29 -5
View File
@@ -390,6 +390,9 @@ importers:
'@radix-ui/react-popover':
specifier: ^1.1.6
version: 1.1.6(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-progress':
specifier: ^1.1.2
version: 1.1.2(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-radio-group':
specifier: ^1.2.2
version: 1.2.3(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1)
@@ -1623,7 +1626,7 @@ packages:
magic-string: 0.27.0
react-docgen-typescript: 2.2.2(typescript@5.6.3)
typescript: 5.6.3
vite: 6.2.0(@types/node@22.13.9)
vite: 6.2.0(@types/node@22.13.5)
dev: true
/@jridgewell/gen-mapping@0.3.8:
@@ -2295,6 +2298,27 @@ packages:
react-dom: 18.3.1(react@18.3.1)
dev: false
/@radix-ui/react-progress@1.1.2(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1):
resolution: {integrity: sha512-u1IgJFQ4zNAUTjGdDL5dcl/U8ntOR6jsnhxKb5RKp5Ozwl88xKR9EqRZOe/Mk8tnx0x5tNUe2F+MzsyjqMg0MA==}
peerDependencies:
'@types/react': ^18.3.18
'@types/react-dom': ^18.3.5
react: ^18.3.1
react-dom: ^18.3.1
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@radix-ui/react-context': 1.1.1(@types/react@18.3.18)(react@18.3.1)
'@radix-ui/react-primitive': 2.0.2(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1)
'@types/react': 18.3.18
'@types/react-dom': 18.3.5(@types/react@18.3.18)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
dev: false
/@radix-ui/react-radio-group@1.2.3(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1):
resolution: {integrity: sha512-xtCsqt8Rp09FK50ItqEqTJ7Sxanz8EM8dnkVIhJrc/wkMMomSmXHvYbhv3E7Zx4oXh98aaLt9W679SUYXg4IDA==}
peerDependencies:
@@ -3059,7 +3083,7 @@ packages:
browser-assert: 1.2.1
storybook: 8.6.0(prettier@3.5.2)
ts-dedent: 2.2.0
vite: 6.2.0(@types/node@22.13.9)
vite: 6.2.0(@types/node@22.13.5)
dev: true
/@storybook/components@8.6.0(storybook@8.6.0):
@@ -3191,7 +3215,7 @@ packages:
resolve: 1.22.10
storybook: 8.6.0(prettier@3.5.2)
tsconfig-paths: 4.2.0
vite: 6.2.0(@types/node@22.13.9)
vite: 6.2.0(@types/node@22.13.5)
transitivePeerDependencies:
- rollup
- supports-color
@@ -3637,7 +3661,7 @@ packages:
'@babel/plugin-transform-react-jsx-source': 7.25.9(@babel/core@7.26.9)
'@types/babel__core': 7.20.5
react-refresh: 0.14.2
vite: 6.2.0(@types/node@22.13.9)
vite: 6.2.0(@types/node@22.13.5)
transitivePeerDependencies:
- supports-color
dev: true
@@ -8414,7 +8438,7 @@ packages:
debug: 4.4.0
globrex: 0.1.2
tsconfck: 3.1.5(typescript@5.6.3)
vite: 6.2.0(@types/node@22.13.9)
vite: 6.2.0(@types/node@22.13.5)
transitivePeerDependencies:
- supports-color
- typescript