summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-07-28 09:19:42 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-07-28 09:19:42 +0000
commit50ae0b8f02c034e60d4cbb504620dfa1575a836f (patch)
tree24c661a0c7354e15ad56e2bded4d300bd7fd2b41
parent738f956aa61264ffa761e30398eca23393929f8c (diff)
(박서영) 설계 document Numbering Rule 개발-최겸 업로드
-rw-r--r--app/[lng]/engineering/(engineering)/docu-list-rule/code-groups/page.tsx54
-rw-r--r--app/[lng]/engineering/(engineering)/docu-list-rule/combo-box-settings/page.tsx53
-rw-r--r--app/[lng]/engineering/(engineering)/docu-list-rule/document-class/page.tsx52
-rw-r--r--app/[lng]/engineering/(engineering)/docu-list-rule/layout.tsx69
-rw-r--r--app/[lng]/engineering/(engineering)/docu-list-rule/number-type-configs/page.tsx60
-rw-r--r--app/[lng]/engineering/(engineering)/docu-list-rule/number-types/page.tsx52
-rw-r--r--app/[lng]/engineering/(engineering)/docu-list-rule/page.tsx11
-rw-r--r--app/[lng]/evcp/(evcp)/basic-contract-template/gtc/[id]/page.tsx142
-rw-r--r--app/[lng]/evcp/(evcp)/docu-list-rule/code-groups/page.tsx54
-rw-r--r--app/[lng]/evcp/(evcp)/docu-list-rule/combo-box-settings/page.tsx53
-rw-r--r--app/[lng]/evcp/(evcp)/docu-list-rule/document-class/page.tsx52
-rw-r--r--app/[lng]/evcp/(evcp)/docu-list-rule/layout.tsx69
-rw-r--r--app/[lng]/evcp/(evcp)/docu-list-rule/number-type-configs/page.tsx60
-rw-r--r--app/[lng]/evcp/(evcp)/docu-list-rule/number-types/page.tsx52
-rw-r--r--app/[lng]/evcp/(evcp)/docu-list-rule/page.tsx11
-rw-r--r--db/schema/docu-list-rule.ts174
-rw-r--r--db/schema/index.ts1
-rw-r--r--lib/docu-list-rule/code-groups/service.ts290
-rw-r--r--lib/docu-list-rule/code-groups/table/code-groups-add-dialog.tsx236
-rw-r--r--lib/docu-list-rule/code-groups/table/code-groups-edit-sheet.tsx259
-rw-r--r--lib/docu-list-rule/code-groups/table/code-groups-table-columns.tsx190
-rw-r--r--lib/docu-list-rule/code-groups/table/code-groups-table-toolbar.tsx38
-rw-r--r--lib/docu-list-rule/code-groups/table/code-groups-table.tsx110
-rw-r--r--lib/docu-list-rule/code-groups/table/delete-code-groups-dialog.tsx203
-rw-r--r--lib/docu-list-rule/code-groups/validation.ts34
-rw-r--r--lib/docu-list-rule/combo-box-settings/service.ts368
-rw-r--r--lib/docu-list-rule/combo-box-settings/table/combo-box-options-add-dialog.tsx142
-rw-r--r--lib/docu-list-rule/combo-box-settings/table/combo-box-options-detail-sheet.tsx185
-rw-r--r--lib/docu-list-rule/combo-box-settings/table/combo-box-options-dialog.tsx234
-rw-r--r--lib/docu-list-rule/combo-box-settings/table/combo-box-options-edit-sheet.tsx147
-rw-r--r--lib/docu-list-rule/combo-box-settings/table/combo-box-options-expandable-row.tsx263
-rw-r--r--lib/docu-list-rule/combo-box-settings/table/combo-box-options-table-columns.tsx162
-rw-r--r--lib/docu-list-rule/combo-box-settings/table/combo-box-options-table-toolbar.tsx53
-rw-r--r--lib/docu-list-rule/combo-box-settings/table/combo-box-settings-table-columns.tsx180
-rw-r--r--lib/docu-list-rule/combo-box-settings/table/combo-box-settings-table-toolbar.tsx33
-rw-r--r--lib/docu-list-rule/combo-box-settings/table/combo-box-settings-table.tsx105
-rw-r--r--lib/docu-list-rule/combo-box-settings/table/delete-combo-box-options-dialog.tsx162
-rw-r--r--lib/docu-list-rule/combo-box-settings/table/delete-combo-box-settings-dialog.tsx85
-rw-r--r--lib/docu-list-rule/combo-box-settings/table/document-class-options-sheet.tsx436
-rw-r--r--lib/docu-list-rule/combo-box-settings/validation.ts12
-rw-r--r--lib/docu-list-rule/document-class/service.ts462
-rw-r--r--lib/docu-list-rule/document-class/table/delete-document-class-dialog.tsx154
-rw-r--r--lib/docu-list-rule/document-class/table/delete-document-class-option-dialog.tsx152
-rw-r--r--lib/docu-list-rule/document-class/table/document-class-add-dialog.tsx145
-rw-r--r--lib/docu-list-rule/document-class/table/document-class-edit-sheet.tsx160
-rw-r--r--lib/docu-list-rule/document-class/table/document-class-option-add-dialog.tsx137
-rw-r--r--lib/docu-list-rule/document-class/table/document-class-option-edit-sheet.tsx143
-rw-r--r--lib/docu-list-rule/document-class/table/document-class-options-table-columns.tsx156
-rw-r--r--lib/docu-list-rule/document-class/table/document-class-options-table-toolbar.tsx43
-rw-r--r--lib/docu-list-rule/document-class/table/document-class-options-table.tsx176
-rw-r--r--lib/docu-list-rule/document-class/table/document-class-table-columns.tsx169
-rw-r--r--lib/docu-list-rule/document-class/table/document-class-table-toolbar.tsx34
-rw-r--r--lib/docu-list-rule/document-class/table/document-class-table.tsx107
-rw-r--r--lib/docu-list-rule/document-class/validation.ts12
-rw-r--r--lib/docu-list-rule/number-type-configs/service.ts268
-rw-r--r--lib/docu-list-rule/number-type-configs/table/delete-number-type-configs-dialog.tsx156
-rw-r--r--lib/docu-list-rule/number-type-configs/table/number-type-configs-edit-dialog.tsx222
-rw-r--r--lib/docu-list-rule/number-type-configs/table/number-type-configs-table-columns.tsx188
-rw-r--r--lib/docu-list-rule/number-type-configs/table/number-type-configs-table.tsx720
-rw-r--r--lib/docu-list-rule/number-type-configs/validation.ts12
-rw-r--r--lib/docu-list-rule/number-types/service.ts251
-rw-r--r--lib/docu-list-rule/number-types/table/delete-number-types-dialog.tsx152
-rw-r--r--lib/docu-list-rule/number-types/table/number-type-add-dialog.tsx125
-rw-r--r--lib/docu-list-rule/number-types/table/number-type-edit-sheet.tsx159
-rw-r--r--lib/docu-list-rule/number-types/table/number-types-table-columns.tsx156
-rw-r--r--lib/docu-list-rule/number-types/table/number-types-table-toolbar.tsx53
-rw-r--r--lib/docu-list-rule/number-types/table/number-types-table.tsx88
-rw-r--r--lib/docu-list-rule/number-types/validation.ts12
-rw-r--r--lib/docu-list-rule/types.ts50
-rw-r--r--lib/docu-list-rule/utils.ts60
70 files changed, 9668 insertions, 0 deletions
diff --git a/app/[lng]/engineering/(engineering)/docu-list-rule/code-groups/page.tsx b/app/[lng]/engineering/(engineering)/docu-list-rule/code-groups/page.tsx
new file mode 100644
index 00000000..5aebf15d
--- /dev/null
+++ b/app/[lng]/engineering/(engineering)/docu-list-rule/code-groups/page.tsx
@@ -0,0 +1,54 @@
+import * as React from "react";
+import { type SearchParams } from "@/types/table";
+import { Shell } from "@/components/shell";
+import { Skeleton } from "@/components/ui/skeleton";
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton";
+import { getCodeGroups } from "@/lib/docu-list-rule/code-groups/service";
+import { CodeGroupsTable } from "@/lib/docu-list-rule/code-groups/table/code-groups-table";
+import { searchParamsCodeGroupsCache } from "@/lib/docu-list-rule/code-groups/validation";
+import { InformationButton } from "@/components/information/information-button";
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>;
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams;
+ const search = searchParamsCodeGroupsCache.parse(searchParams);
+
+ const promises = Promise.all([
+ getCodeGroups({
+ ...search,
+ }),
+ ]);
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <div className="flex items-center gap-2">
+ <h2 className="text-2xl font-bold tracking-tight">Code Group 정의</h2>
+ <InformationButton pagePath="evcp/docu-list-rule/code-groups" />
+ </div>
+ {/* <p className="text-muted-foreground">
+ 문서 번호에 사용될 수 있는 다양한 코드 그룹의 정의를 관리하는 페이지입니다.
+ </p> */}
+ </div>
+ </div>
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}></React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={7}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["8rem", "12rem", "10rem", "10rem", "12rem", "8rem", "12rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <CodeGroupsTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ );
+} \ No newline at end of file
diff --git a/app/[lng]/engineering/(engineering)/docu-list-rule/combo-box-settings/page.tsx b/app/[lng]/engineering/(engineering)/docu-list-rule/combo-box-settings/page.tsx
new file mode 100644
index 00000000..cf0bf02e
--- /dev/null
+++ b/app/[lng]/engineering/(engineering)/docu-list-rule/combo-box-settings/page.tsx
@@ -0,0 +1,53 @@
+import * as React from "react";
+import { Shell } from "@/components/shell";
+import { Skeleton } from "@/components/ui/skeleton";
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton";
+import { getComboBoxCodeGroups } from "@/lib/docu-list-rule/combo-box-settings/service";
+import { ComboBoxSettingsTable } from "@/lib/docu-list-rule/combo-box-settings/table/combo-box-settings-table";
+import { InformationButton } from "@/components/information/information-button";
+import { searchParamsCodeGroupsCache } from "@/lib/docu-list-rule/code-groups/validation";
+
+interface IndexPageProps {
+ searchParams: Promise<any>;
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams;
+
+ const promises = Promise.all([
+ getComboBoxCodeGroups(
+ searchParamsCodeGroupsCache.parse(searchParams)
+ ),
+ ]);
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <div className="flex items-center gap-2">
+ <h2 className="text-2xl font-bold tracking-tight">Combo Box 설정</h2>
+ <InformationButton pagePath="evcp/docu-list-rule/combo-box-settings" />
+ </div>
+ {/* <p className="text-muted-foreground">
+ Combo Box 옵션을 관리하는 페이지입니다.
+ 각 Code Group별로 Combo Box에 표시될 옵션들을 설정할 수 있습니다.
+ </p> */}
+ </div>
+ </div>
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}></React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["8rem", "12rem", "10rem", "8rem", "12rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <ComboBoxSettingsTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ );
+} \ No newline at end of file
diff --git a/app/[lng]/engineering/(engineering)/docu-list-rule/document-class/page.tsx b/app/[lng]/engineering/(engineering)/docu-list-rule/document-class/page.tsx
new file mode 100644
index 00000000..5c2c600e
--- /dev/null
+++ b/app/[lng]/engineering/(engineering)/docu-list-rule/document-class/page.tsx
@@ -0,0 +1,52 @@
+import * as React from "react";
+import { Shell } from "@/components/shell";
+import { Skeleton } from "@/components/ui/skeleton";
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton";
+import { getDocumentClassCodeGroups } from "@/lib/docu-list-rule/document-class/service";
+import { DocumentClassTable } from "@/lib/docu-list-rule/document-class/table/document-class-table";
+import { InformationButton } from "@/components/information/information-button";
+import { searchParamsDocumentClassCache } from "@/lib/docu-list-rule/document-class/validation";
+
+interface IndexPageProps {
+ searchParams: Promise<any>;
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams;
+
+ const promises = Promise.all([
+ getDocumentClassCodeGroups(
+ searchParamsDocumentClassCache.parse(searchParams)
+ ),
+ ]);
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <div className="flex items-center gap-2">
+ <h2 className="text-2xl font-bold tracking-tight">Document Class 관리</h2>
+ <InformationButton pagePath="evcp/docu-list-rule/document-class" />
+ </div>
+ {/* <p className="text-muted-foreground">
+ Document Class를 관리합니다.
+ </p> */}
+ </div>
+ </div>
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}></React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={4}
+ searchableColumnCount={1}
+ filterableColumnCount={1}
+ cellWidths={["10rem", "20rem", "10rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <DocumentClassTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ );
+} \ No newline at end of file
diff --git a/app/[lng]/engineering/(engineering)/docu-list-rule/layout.tsx b/app/[lng]/engineering/(engineering)/docu-list-rule/layout.tsx
new file mode 100644
index 00000000..25023e4b
--- /dev/null
+++ b/app/[lng]/engineering/(engineering)/docu-list-rule/layout.tsx
@@ -0,0 +1,69 @@
+import { Metadata } from "next"
+
+import { Separator } from "@/components/ui/separator"
+import { SidebarNav } from "@/components/layout/sidebar-nav"
+
+export const metadata: Metadata = {
+ title: "Document Numbering Rule",
+}
+
+
+
+export default async function DocumentNumberingLayout({
+ children,
+ params,
+}: {
+ children: React.ReactNode
+ params: { lng: string }
+}) {
+ const resolvedParams = await params
+ const lng = resolvedParams.lng
+
+ const sidebarNavItems = [
+ {
+ title: "Document Class 관리",
+ href: `/${lng}/engineering/docu-list-rule/document-class`,
+ },
+ {
+ title: "Code Group 정의",
+ href: `/${lng}/engineering/docu-list-rule/code-groups`,
+ },
+ {
+ title: "Combo Box 설정",
+ href: `/${lng}/engineering/docu-list-rule/combo-box-settings`,
+ },
+ {
+ title: "Number Type 관리",
+ href: `/${lng}/engineering/docu-list-rule/number-types`,
+ },
+ {
+ title: "Number Type별 설정",
+ href: `/${lng}/engineering/docu-list-rule/number-type-configs`,
+ },
+ ]
+
+ return (
+ <>
+ <div className="container py-6">
+ <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
+ <div className="hidden space-y-6 p-10 pb-16 md:block">
+ <div className="space-y-0.5">
+ <h2 className="text-2xl font-bold tracking-tight">Document Numbering Rule (해양)</h2>
+ <p className="text-muted-foreground">
+ 벤더 제출 문서 리스트 작성 시에 사용되는 넘버링
+ </p>
+ </div>
+
+ <Separator className="my-6" />
+ <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
+ <aside className="-mx-4 lg:w-1/5">
+ <SidebarNav items={sidebarNavItems} />
+ </aside>
+ <div className="flex-1 ">{children}</div>
+ </div>
+ </div>
+ </section>
+ </div>
+ </>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/engineering/(engineering)/docu-list-rule/number-type-configs/page.tsx b/app/[lng]/engineering/(engineering)/docu-list-rule/number-type-configs/page.tsx
new file mode 100644
index 00000000..4195ba24
--- /dev/null
+++ b/app/[lng]/engineering/(engineering)/docu-list-rule/number-type-configs/page.tsx
@@ -0,0 +1,60 @@
+import * as React from "react";
+import { Shell } from "@/components/shell";
+import { Skeleton } from "@/components/ui/skeleton";
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton";
+import { NumberTypeConfigsTable } from "@/lib/docu-list-rule/number-type-configs/table/number-type-configs-table";
+import { getNumberTypes } from "@/lib/docu-list-rule/number-types/service";
+import { InformationButton } from "@/components/information/information-button";
+
+interface IndexPageProps {
+ searchParams: Promise<any>;
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams;
+
+ const promises = Promise.all([
+ getNumberTypes({
+ page: 1,
+ perPage: 1000, // 모든 Number Type을 가져오기 위해 큰 값 설정
+ search: "",
+ sort: [{ id: "id", desc: false }], // DB 등록 순서대로 정렬
+ filters: [],
+ joinOperator: "and",
+ flags: ["advancedTable"],
+ numberTypeId: "",
+ description: "",
+ isActive: ""
+ }),
+ ]);
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <div className="flex items-center gap-2">
+ <h2 className="text-2xl font-bold tracking-tight">Number Type별 설정</h2>
+ <InformationButton pagePath="evcp/docu-list-rule/number-type-configs" />
+ </div>
+ {/* <p className="text-muted-foreground">
+ 각 문서 번호 유형별로 어떤 코드 그룹들을 어떤 순서로 사용할지 설정하는 페이지입니다.
+ </p> */}
+ </div>
+ </div>
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}></React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "12rem", "12rem", "12rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <NumberTypeConfigsTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ );
+} \ No newline at end of file
diff --git a/app/[lng]/engineering/(engineering)/docu-list-rule/number-types/page.tsx b/app/[lng]/engineering/(engineering)/docu-list-rule/number-types/page.tsx
new file mode 100644
index 00000000..6fa010c7
--- /dev/null
+++ b/app/[lng]/engineering/(engineering)/docu-list-rule/number-types/page.tsx
@@ -0,0 +1,52 @@
+import * as React from "react";
+import { Shell } from "@/components/shell";
+import { Skeleton } from "@/components/ui/skeleton";
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton";
+import { NumberTypesTable } from "@/lib/docu-list-rule/number-types/table/number-types-table";
+import { getNumberTypes } from "@/lib/docu-list-rule/number-types/service";
+import { InformationButton } from "@/components/information/information-button";
+import { searchParamsNumberTypesCache } from "@/lib/docu-list-rule/number-types/validation";
+
+interface IndexPageProps {
+ searchParams: Promise<any>;
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams;
+
+ const promises = Promise.all([
+ getNumberTypes(
+ searchParamsNumberTypesCache.parse(searchParams)
+ ),
+ ]);
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <div className="flex items-center gap-2">
+ <h2 className="text-2xl font-bold tracking-tight">Number Type 관리</h2>
+ <InformationButton pagePath="evcp/docu-list-rule/number-types" />
+ </div>
+ {/* <p className="text-muted-foreground">
+ 문서 번호 유형을 추가, 수정, 삭제할 수 있는 페이지입니다.
+ </p> */}
+ </div>
+ </div>
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}></React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={4}
+ searchableColumnCount={1}
+ filterableColumnCount={0}
+ cellWidths={["10rem", "20rem", "10rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <NumberTypesTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ );
+} \ No newline at end of file
diff --git a/app/[lng]/engineering/(engineering)/docu-list-rule/page.tsx b/app/[lng]/engineering/(engineering)/docu-list-rule/page.tsx
new file mode 100644
index 00000000..fed49256
--- /dev/null
+++ b/app/[lng]/engineering/(engineering)/docu-list-rule/page.tsx
@@ -0,0 +1,11 @@
+import { redirect } from "next/navigation"
+
+
+export default async function DocumentNumberingPage({
+ params,
+}: {
+ params: { lng: string }
+}) {
+ // Code Group 페이지로 리다이렉트
+ redirect(`/${params.lng}/engineering/docu-list-rule/document-class`)
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/basic-contract-template/gtc/[id]/page.tsx b/app/[lng]/evcp/(evcp)/basic-contract-template/gtc/[id]/page.tsx
new file mode 100644
index 00000000..0f783375
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/basic-contract-template/gtc/[id]/page.tsx
@@ -0,0 +1,142 @@
+import * as React from "react"
+import { notFound } from "next/navigation"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+import { InformationButton } from "@/components/information/information-button"
+
+import {
+ getGtcClauses,
+ getUsersForFilter,
+} from "@/lib/gtc-contract/gtc-clauses/service"
+import { getGtcDocumentById } from "@/lib/gtc-contract/service"
+import { searchParamsCache } from "@/lib/gtc-contract/gtc-clauses/validations"
+import { GtcClausesPageHeader } from "@/lib/gtc-contract/gtc-clauses/gtc-clauses-page-header"
+import { GtcClausesTable } from "@/lib/gtc-contract/gtc-clauses/table/clause-table"
+
+interface GtcClausesPageProps {
+ params: Promise<{ id: string }>
+ searchParams: Promise<SearchParams>
+}
+
+export default async function GtcClausesPage(props: GtcClausesPageProps) {
+ const params = await props.params
+ const searchParams = await props.searchParams
+ const documentId = parseInt(params.id)
+
+ if (isNaN(documentId)) {
+ notFound()
+ }
+
+ // 문서 정보 조회
+ const document = await getGtcDocumentById(documentId)
+ if (!document) {
+ notFound()
+ }
+
+ const search = searchParamsCache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ // 병렬로 데이터 조회
+ const promises = Promise.all([
+ getGtcClauses({
+ ...search,
+ filters: validFilters,
+ documentId,
+ }),
+ getUsersForFilter()
+ ])
+
+ return (
+ <Shell className="gap-2">
+ {/* 헤더 컴포넌트 */}
+ <GtcClausesPageHeader document={document} />
+
+ {/* 문서 정보 카드 */}
+ <div className="rounded-lg border bg-card p-4">
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
+ <div>
+ <div className="font-medium text-muted-foreground">최초등록일</div>
+ <div>{document.createdAt ? new Date(document.createdAt).toLocaleDateString('ko-KR') : '-'}</div>
+ </div>
+ <div>
+ <div className="font-medium text-muted-foreground">최초등록자</div>
+ <div>{document.createdByName || '-'}</div>
+ </div>
+ <div>
+ <div className="font-medium text-muted-foreground">최종수정일</div>
+ <div>{document.updatedAt ? new Date(document.updatedAt).toLocaleDateString('ko-KR') : '-'}</div>
+ </div>
+ <div>
+ <div className="font-medium text-muted-foreground">최종수정자</div>
+ <div>{document.updatedByName || '-'}</div>
+ </div>
+ </div>
+
+ {document.editReason && (
+ <div className="mt-3 pt-3 border-t">
+ <div className="font-medium text-muted-foreground mb-1">최종 편집사유</div>
+ <div className="text-sm">{document.editReason}</div>
+ </div>
+ )}
+ </div>
+
+ {/* 조항 테이블 */}
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={8}
+ searchableColumnCount={2}
+ filterableColumnCount={3}
+ cellWidths={["10rem", "15rem", "20rem", "30rem", "12rem", "12rem", "12rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <GtcClausesTable
+ promises={promises}
+ documentId={documentId}
+ document={document}
+ />
+ </React.Suspense>
+ </Shell>
+ )
+}
+
+// 메타데이터 생성
+export async function generateMetadata(props: GtcClausesPageProps) {
+ const params = await props.params
+ const documentId = parseInt(params.id)
+
+ if (isNaN(documentId)) {
+ return {
+ title: "GTC 조항 관리",
+ }
+ }
+
+ try {
+ const document = await getGtcDocumentById(documentId)
+
+ if (!document) {
+ return {
+ title: "GTC 조항 관리",
+ }
+ }
+
+ const title = `GTC 조항 관리 - ${document.type === "standard" ? "표준" : "프로젝트"} v${document.revision}`
+ const description = document.project
+ ? `${document.project.name} (${document.project.code}) 프로젝트의 GTC 조항을 관리합니다.`
+ : "표준 GTC 조항을 관리합니다."
+
+ return {
+ title,
+ description,
+ }
+ } catch (error) {
+ return {
+ title: "GTC 조항 관리",
+ }
+ }
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/docu-list-rule/code-groups/page.tsx b/app/[lng]/evcp/(evcp)/docu-list-rule/code-groups/page.tsx
new file mode 100644
index 00000000..5aebf15d
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/docu-list-rule/code-groups/page.tsx
@@ -0,0 +1,54 @@
+import * as React from "react";
+import { type SearchParams } from "@/types/table";
+import { Shell } from "@/components/shell";
+import { Skeleton } from "@/components/ui/skeleton";
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton";
+import { getCodeGroups } from "@/lib/docu-list-rule/code-groups/service";
+import { CodeGroupsTable } from "@/lib/docu-list-rule/code-groups/table/code-groups-table";
+import { searchParamsCodeGroupsCache } from "@/lib/docu-list-rule/code-groups/validation";
+import { InformationButton } from "@/components/information/information-button";
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>;
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams;
+ const search = searchParamsCodeGroupsCache.parse(searchParams);
+
+ const promises = Promise.all([
+ getCodeGroups({
+ ...search,
+ }),
+ ]);
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <div className="flex items-center gap-2">
+ <h2 className="text-2xl font-bold tracking-tight">Code Group 정의</h2>
+ <InformationButton pagePath="evcp/docu-list-rule/code-groups" />
+ </div>
+ {/* <p className="text-muted-foreground">
+ 문서 번호에 사용될 수 있는 다양한 코드 그룹의 정의를 관리하는 페이지입니다.
+ </p> */}
+ </div>
+ </div>
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}></React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={7}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["8rem", "12rem", "10rem", "10rem", "12rem", "8rem", "12rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <CodeGroupsTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ );
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/docu-list-rule/combo-box-settings/page.tsx b/app/[lng]/evcp/(evcp)/docu-list-rule/combo-box-settings/page.tsx
new file mode 100644
index 00000000..cf0bf02e
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/docu-list-rule/combo-box-settings/page.tsx
@@ -0,0 +1,53 @@
+import * as React from "react";
+import { Shell } from "@/components/shell";
+import { Skeleton } from "@/components/ui/skeleton";
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton";
+import { getComboBoxCodeGroups } from "@/lib/docu-list-rule/combo-box-settings/service";
+import { ComboBoxSettingsTable } from "@/lib/docu-list-rule/combo-box-settings/table/combo-box-settings-table";
+import { InformationButton } from "@/components/information/information-button";
+import { searchParamsCodeGroupsCache } from "@/lib/docu-list-rule/code-groups/validation";
+
+interface IndexPageProps {
+ searchParams: Promise<any>;
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams;
+
+ const promises = Promise.all([
+ getComboBoxCodeGroups(
+ searchParamsCodeGroupsCache.parse(searchParams)
+ ),
+ ]);
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <div className="flex items-center gap-2">
+ <h2 className="text-2xl font-bold tracking-tight">Combo Box 설정</h2>
+ <InformationButton pagePath="evcp/docu-list-rule/combo-box-settings" />
+ </div>
+ {/* <p className="text-muted-foreground">
+ Combo Box 옵션을 관리하는 페이지입니다.
+ 각 Code Group별로 Combo Box에 표시될 옵션들을 설정할 수 있습니다.
+ </p> */}
+ </div>
+ </div>
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}></React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["8rem", "12rem", "10rem", "8rem", "12rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <ComboBoxSettingsTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ );
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/docu-list-rule/document-class/page.tsx b/app/[lng]/evcp/(evcp)/docu-list-rule/document-class/page.tsx
new file mode 100644
index 00000000..5c2c600e
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/docu-list-rule/document-class/page.tsx
@@ -0,0 +1,52 @@
+import * as React from "react";
+import { Shell } from "@/components/shell";
+import { Skeleton } from "@/components/ui/skeleton";
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton";
+import { getDocumentClassCodeGroups } from "@/lib/docu-list-rule/document-class/service";
+import { DocumentClassTable } from "@/lib/docu-list-rule/document-class/table/document-class-table";
+import { InformationButton } from "@/components/information/information-button";
+import { searchParamsDocumentClassCache } from "@/lib/docu-list-rule/document-class/validation";
+
+interface IndexPageProps {
+ searchParams: Promise<any>;
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams;
+
+ const promises = Promise.all([
+ getDocumentClassCodeGroups(
+ searchParamsDocumentClassCache.parse(searchParams)
+ ),
+ ]);
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <div className="flex items-center gap-2">
+ <h2 className="text-2xl font-bold tracking-tight">Document Class 관리</h2>
+ <InformationButton pagePath="evcp/docu-list-rule/document-class" />
+ </div>
+ {/* <p className="text-muted-foreground">
+ Document Class를 관리합니다.
+ </p> */}
+ </div>
+ </div>
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}></React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={4}
+ searchableColumnCount={1}
+ filterableColumnCount={1}
+ cellWidths={["10rem", "20rem", "10rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <DocumentClassTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ );
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/docu-list-rule/layout.tsx b/app/[lng]/evcp/(evcp)/docu-list-rule/layout.tsx
new file mode 100644
index 00000000..03473293
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/docu-list-rule/layout.tsx
@@ -0,0 +1,69 @@
+import { Metadata } from "next"
+
+import { Separator } from "@/components/ui/separator"
+import { SidebarNav } from "@/components/layout/sidebar-nav"
+
+export const metadata: Metadata = {
+ title: "Document Numbering Rule",
+}
+
+
+
+export default async function DocumentNumberingLayout({
+ children,
+ params,
+}: {
+ children: React.ReactNode
+ params: { lng: string }
+}) {
+ const resolvedParams = await params
+ const lng = resolvedParams.lng
+
+ const sidebarNavItems = [
+ {
+ title: "Document Class 관리",
+ href: `/${lng}/evcp/docu-list-rule/document-class`,
+ },
+ {
+ title: "Code Group 정의",
+ href: `/${lng}/evcp/docu-list-rule/code-groups`,
+ },
+ {
+ title: "Combo Box 설정",
+ href: `/${lng}/evcp/docu-list-rule/combo-box-settings`,
+ },
+ {
+ title: "Number Type 관리",
+ href: `/${lng}/evcp/docu-list-rule/number-types`,
+ },
+ {
+ title: "Number Type별 설정",
+ href: `/${lng}/evcp/docu-list-rule/number-type-configs`,
+ },
+ ]
+
+ return (
+ <>
+ <div className="container py-6">
+ <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
+ <div className="hidden space-y-6 p-10 pb-16 md:block">
+ <div className="space-y-0.5">
+ <h2 className="text-2xl font-bold tracking-tight">Document Numbering Rule (해양)</h2>
+ <p className="text-muted-foreground">
+ 벤더 제출 문서 리스트 작성 시에 사용되는 넘버링
+ </p>
+ </div>
+
+ <Separator className="my-6" />
+ <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
+ <aside className="-mx-4 lg:w-1/5">
+ <SidebarNav items={sidebarNavItems} />
+ </aside>
+ <div className="flex-1 ">{children}</div>
+ </div>
+ </div>
+ </section>
+ </div>
+ </>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/docu-list-rule/number-type-configs/page.tsx b/app/[lng]/evcp/(evcp)/docu-list-rule/number-type-configs/page.tsx
new file mode 100644
index 00000000..4195ba24
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/docu-list-rule/number-type-configs/page.tsx
@@ -0,0 +1,60 @@
+import * as React from "react";
+import { Shell } from "@/components/shell";
+import { Skeleton } from "@/components/ui/skeleton";
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton";
+import { NumberTypeConfigsTable } from "@/lib/docu-list-rule/number-type-configs/table/number-type-configs-table";
+import { getNumberTypes } from "@/lib/docu-list-rule/number-types/service";
+import { InformationButton } from "@/components/information/information-button";
+
+interface IndexPageProps {
+ searchParams: Promise<any>;
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams;
+
+ const promises = Promise.all([
+ getNumberTypes({
+ page: 1,
+ perPage: 1000, // 모든 Number Type을 가져오기 위해 큰 값 설정
+ search: "",
+ sort: [{ id: "id", desc: false }], // DB 등록 순서대로 정렬
+ filters: [],
+ joinOperator: "and",
+ flags: ["advancedTable"],
+ numberTypeId: "",
+ description: "",
+ isActive: ""
+ }),
+ ]);
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <div className="flex items-center gap-2">
+ <h2 className="text-2xl font-bold tracking-tight">Number Type별 설정</h2>
+ <InformationButton pagePath="evcp/docu-list-rule/number-type-configs" />
+ </div>
+ {/* <p className="text-muted-foreground">
+ 각 문서 번호 유형별로 어떤 코드 그룹들을 어떤 순서로 사용할지 설정하는 페이지입니다.
+ </p> */}
+ </div>
+ </div>
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}></React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "12rem", "12rem", "12rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <NumberTypeConfigsTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ );
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/docu-list-rule/number-types/page.tsx b/app/[lng]/evcp/(evcp)/docu-list-rule/number-types/page.tsx
new file mode 100644
index 00000000..6fa010c7
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/docu-list-rule/number-types/page.tsx
@@ -0,0 +1,52 @@
+import * as React from "react";
+import { Shell } from "@/components/shell";
+import { Skeleton } from "@/components/ui/skeleton";
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton";
+import { NumberTypesTable } from "@/lib/docu-list-rule/number-types/table/number-types-table";
+import { getNumberTypes } from "@/lib/docu-list-rule/number-types/service";
+import { InformationButton } from "@/components/information/information-button";
+import { searchParamsNumberTypesCache } from "@/lib/docu-list-rule/number-types/validation";
+
+interface IndexPageProps {
+ searchParams: Promise<any>;
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams;
+
+ const promises = Promise.all([
+ getNumberTypes(
+ searchParamsNumberTypesCache.parse(searchParams)
+ ),
+ ]);
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <div className="flex items-center gap-2">
+ <h2 className="text-2xl font-bold tracking-tight">Number Type 관리</h2>
+ <InformationButton pagePath="evcp/docu-list-rule/number-types" />
+ </div>
+ {/* <p className="text-muted-foreground">
+ 문서 번호 유형을 추가, 수정, 삭제할 수 있는 페이지입니다.
+ </p> */}
+ </div>
+ </div>
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}></React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={4}
+ searchableColumnCount={1}
+ filterableColumnCount={0}
+ cellWidths={["10rem", "20rem", "10rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <NumberTypesTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ );
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/docu-list-rule/page.tsx b/app/[lng]/evcp/(evcp)/docu-list-rule/page.tsx
new file mode 100644
index 00000000..3ebf93b3
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/docu-list-rule/page.tsx
@@ -0,0 +1,11 @@
+import { redirect } from "next/navigation"
+
+
+export default async function DocumentNumberingPage({
+ params,
+}: {
+ params: { lng: string }
+}) {
+ // Code Group 페이지로 리다이렉트
+ redirect(`/${params.lng}/evcp/docu-list-rule/document-class`)
+} \ No newline at end of file
diff --git a/db/schema/docu-list-rule.ts b/db/schema/docu-list-rule.ts
new file mode 100644
index 00000000..67d34d75
--- /dev/null
+++ b/db/schema/docu-list-rule.ts
@@ -0,0 +1,174 @@
+import { pgTable, serial, varchar, text, timestamp, boolean, integer } from "drizzle-orm/pg-core"
+import { relations } from "drizzle-orm"
+
+// ===== Code Groups 테이블 =====
+export const codeGroups = pgTable("code_groups", {
+ id: serial("id").primaryKey(),
+ groupId: varchar("group_id", { length: 50 }).notNull().unique(), // GROUP ID (Code_번호 형태)
+ description: varchar("description", { length: 100 }).notNull(), // Description (예: PROJECT NO, Phase)
+ codeFormat: varchar("code_format", { length: 50 }), // Code Format (예: AANNN)
+ expressions: text("expressions"), // Expressions (자동 생성된 정규식)
+ controlType: varchar("control_type", { length: 20 }).notNull(), // Control Type (Textbox, Combobox)
+ isActive: boolean("is_active").default(true),
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
+})
+
+// ===== Document Classes 테이블 =====
+export const documentClasses = pgTable("document_classes", {
+ id: serial("id").primaryKey(),
+ code: varchar("code", { length: 50 }).notNull().unique(), // CODE (자동 생성)
+ value: varchar("value", { length: 100 }), // 사용자가 선택할 수 있는 값 (선택사항)
+ description: varchar("description", { length: 200 }).notNull(), // 값의 의미 설명
+ codeGroupId: integer("code_group_id").references(() => codeGroups.id), // 참조하는 Code Group ID
+ isActive: boolean("is_active").default(true),
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
+})
+
+// ===== Document Class Options 테이블 =====
+export const documentClassOptions = pgTable("document_class_options_new", {
+ id: serial("id").primaryKey(),
+ documentClassId: integer("document_class_id").notNull().references(() => documentClasses.id),
+ optionValue: varchar("option_value", { length: 100 }).notNull(), // 하위 옵션 값 (예: "General", "Technical")
+ optionCode: varchar("option_code", { length: 50 }), // 하위 옵션 코드 (선택사항)
+ sortOrder: integer("sort_order").default(0), // 정렬 순서
+ isActive: boolean("is_active").default(true),
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
+})
+
+// ===== ComboBox Settings 테이블 =====
+export const comboBoxSettings = pgTable("combo_box_settings", {
+ id: serial("id").primaryKey(),
+ codeGroupId: integer("code_group_id").notNull().references(() => codeGroups.id), // Code Group과 연결
+ code: varchar("code", { length: 50 }).notNull(), // CODE (예: 100, 201, 202)
+ description: varchar("description", { length: 200 }).notNull(), // Description (예: General, Feed Gas Reveive)
+ remark: text("remark"), // Remark (비고)
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
+})
+
+// ===== ComboBox Options 테이블 =====
+export const comboBoxOptions = pgTable("combo_box_options", {
+ id: serial("id").primaryKey(),
+ comboBoxSettingId: integer("combo_box_setting_id").notNull().references(() => comboBoxSettings.id),
+ code: varchar("code", { length: 50 }).notNull(), // CODE (예: 100, 201, 202)
+ description: varchar("description", { length: 200 }).notNull(), // Description (예: General, Feed Gas Reveive)
+ remark: text("remark"), // Remark (비고)
+ sortOrder: integer("sort_order").default(0), // 정렬 순서
+ isActive: boolean("is_active").default(true),
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
+})
+
+// ===== Document Number Types 테이블 =====
+export const documentNumberTypes = pgTable("document_number_types", {
+ id: serial("id").primaryKey(),
+ name: varchar("name", { length: 100 }).notNull().unique(), // Number Type 이름 (예: Project No, SHI No, Vendor No)
+ description: varchar("description", { length: 200 }), // 설명
+ isActive: boolean("is_active").default(true),
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
+})
+
+// ===== Document Number Type Configs 테이블 =====
+export const documentNumberTypeConfigs = pgTable("document_number_type_configs", {
+ id: serial("id").primaryKey(),
+ documentNumberTypeId: integer("document_number_type_id").notNull().references(() => documentNumberTypes.id),
+ codeGroupId: integer("code_group_id").references(() => codeGroups.id), // Code Group 또는 Document Class 중 하나만 선택
+ documentClassId: integer("document_class_id").references(() => documentClasses.id), // Code Group 또는 Document Class 중 하나만 선택
+ sdq: integer("sdq").notNull(), // 순서 번호 (1, 2, 3, 4, 5, 6)
+ description: varchar("description", { length: 200 }), // Description (예: [001] PROJECT NO)
+ remark: text("remark"), // Remark (비고)
+ isActive: boolean("is_active").default(true),
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
+})
+
+// ===== 관계 정의 =====
+
+// Code Groups 관계
+export const codeGroupsRelations = relations(codeGroups, ({ many, one }) => ({
+ documentClasses: many(documentClasses), // Code Group에 속한 Document Classes
+ comboBoxSettings: many(comboBoxSettings), // Code Group에 속한 ComboBox Settings
+ documentNumberTypeConfigs: many(documentNumberTypeConfigs), // Code Group을 참조하는 Number Type Configs
+}))
+
+// Document Classes 관계
+export const documentClassesRelations = relations(documentClasses, ({ many, one }) => ({
+ documentClassOptions: many(documentClassOptions), // Document Class 하위 옵션들
+ codeGroup: one(codeGroups, {
+ fields: [documentClasses.codeGroupId],
+ references: [codeGroups.id],
+ }),
+ documentNumberTypeConfigs: many(documentNumberTypeConfigs), // Document Class를 참조하는 Number Type Configs
+}))
+
+// Document Class Options 관계
+export const documentClassOptionsRelations = relations(documentClassOptions, ({ one }) => ({
+ documentClass: one(documentClasses, {
+ fields: [documentClassOptions.documentClassId],
+ references: [documentClasses.id],
+ }),
+}))
+
+// ComboBox Settings 관계
+export const comboBoxSettingsRelations = relations(comboBoxSettings, ({ one, many }) => ({
+ codeGroup: one(codeGroups, {
+ fields: [comboBoxSettings.codeGroupId],
+ references: [codeGroups.id],
+ }),
+ comboBoxOptions: many(comboBoxOptions), // ComboBox Setting 하위 옵션들
+}))
+
+// ComboBox Options 관계
+export const comboBoxOptionsRelations = relations(comboBoxOptions, ({ one }) => ({
+ comboBoxSetting: one(comboBoxSettings, {
+ fields: [comboBoxOptions.comboBoxSettingId],
+ references: [comboBoxSettings.id],
+ }),
+}))
+
+// Document Number Types 관계
+export const documentNumberTypesRelations = relations(documentNumberTypes, ({ many }) => ({
+ configs: many(documentNumberTypeConfigs), // Number Type의 설정들
+}))
+
+// Document Number Type Configs 관계
+export const documentNumberTypeConfigsRelations = relations(documentNumberTypeConfigs, ({ one }) => ({
+ documentNumberType: one(documentNumberTypes, {
+ fields: [documentNumberTypeConfigs.documentNumberTypeId],
+ references: [documentNumberTypes.id],
+ }),
+ codeGroup: one(codeGroups, {
+ fields: [documentNumberTypeConfigs.codeGroupId],
+ references: [codeGroups.id],
+ }),
+ documentClass: one(documentClasses, {
+ fields: [documentNumberTypeConfigs.documentClassId],
+ references: [documentClasses.id],
+ }),
+}))
+
+// ===== 타입 정의 =====
+export type CodeGroup = typeof codeGroups.$inferSelect
+export type NewCodeGroup = typeof codeGroups.$inferInsert
+
+export type DocumentClass = typeof documentClasses.$inferSelect
+export type NewDocumentClass = typeof documentClasses.$inferInsert
+
+export type DocumentClassOption = typeof documentClassOptions.$inferSelect
+export type NewDocumentClassOption = typeof documentClassOptions.$inferInsert
+
+export type ComboBoxSetting = typeof comboBoxSettings.$inferSelect
+export type NewComboBoxSetting = typeof comboBoxSettings.$inferInsert
+
+export type ComboBoxOption = typeof comboBoxOptions.$inferSelect
+export type NewComboBoxOption = typeof comboBoxOptions.$inferInsert
+
+export type DocumentNumberType = typeof documentNumberTypes.$inferSelect
+export type NewDocumentNumberType = typeof documentNumberTypes.$inferInsert
+
+export type DocumentNumberTypeConfig = typeof documentNumberTypeConfigs.$inferSelect
+export type NewDocumentNumberTypeConfig = typeof documentNumberTypeConfigs.$inferInsert \ No newline at end of file
diff --git a/db/schema/index.ts b/db/schema/index.ts
index 5c6aad41..c48f0a8b 100644
--- a/db/schema/index.ts
+++ b/db/schema/index.ts
@@ -30,6 +30,7 @@ export * from './history';
export * from './notification';
export * from './templates';
export * from './gtc';
+export * from './docu-list-rule';
// 부서별 도메인 할당 관리
export * from './departmentDomainAssignments';
diff --git a/lib/docu-list-rule/code-groups/service.ts b/lib/docu-list-rule/code-groups/service.ts
new file mode 100644
index 00000000..34ec5610
--- /dev/null
+++ b/lib/docu-list-rule/code-groups/service.ts
@@ -0,0 +1,290 @@
+"use server"
+
+import { revalidatePath } from "next/cache"
+import db from "@/db/db"
+import { codeGroups, comboBoxSettings, documentClasses } from "@/db/schema/docu-list-rule"
+import { eq, sql, count } from "drizzle-orm"
+import { unstable_noStore } from "next/cache"
+
+// Code Groups 목록 조회
+export async function getCodeGroups(input: {
+ page: number
+ perPage: number
+ search?: string
+ sort?: Array<{ id: string; desc: boolean }>
+ filters?: Array<{ id: string; value: string }>
+ joinOperator?: "and" | "or"
+ flags?: string[]
+ groupId?: string
+ description?: string
+ controlType?: string
+ isActive?: string
+} | any) {
+ unstable_noStore()
+
+ try {
+ const { page, perPage, sort, search } = input
+ const offset = (page - 1) * perPage
+
+ // 검색 조건 (Document Class 제외)
+ let whereConditions = sql`${codeGroups.groupId} != 'DOC_CLASS'`
+ if (search) {
+ const searchTerm = `%${search}%`
+ whereConditions = sql`${codeGroups.groupId} != 'DOC_CLASS' AND (
+ ${codeGroups.groupId} ILIKE ${searchTerm} OR
+ ${codeGroups.description} ILIKE ${searchTerm} OR
+ ${codeGroups.codeFormat} ILIKE ${searchTerm} OR
+ ${codeGroups.controlType} ILIKE ${searchTerm}
+ )`
+ }
+
+ // 정렬
+ let orderBy = sql`${codeGroups.createdAt} DESC`
+ if (sort && sort.length > 0) {
+ const sortField = sort[0]
+ const direction = sortField.desc ? sql`DESC` : sql`ASC`
+
+ switch (sortField.id) {
+ case "groupId":
+ orderBy = sql`${codeGroups.groupId} ${direction}`
+ break
+ case "description":
+ orderBy = sql`${codeGroups.description} ${direction}`
+ break
+ case "codeFormat":
+ orderBy = sql`${codeGroups.codeFormat} ${direction}`
+ break
+ case "controlType":
+ orderBy = sql`${codeGroups.controlType} ${direction}`
+ break
+ case "isActive":
+ orderBy = sql`${codeGroups.isActive} ${direction}`
+ break
+ case "createdAt":
+ orderBy = sql`${codeGroups.createdAt} ${direction}`
+ break
+ default:
+ orderBy = sql`${codeGroups.createdAt} DESC`
+ }
+ }
+
+ // 데이터 조회
+ const data = await db
+ .select({
+ id: codeGroups.id,
+ groupId: codeGroups.groupId,
+ description: codeGroups.description,
+ codeFormat: codeGroups.codeFormat,
+ expressions: codeGroups.expressions,
+ controlType: codeGroups.controlType,
+ isActive: codeGroups.isActive,
+ createdAt: codeGroups.createdAt,
+ updatedAt: codeGroups.updatedAt,
+ })
+ .from(codeGroups)
+ .where(whereConditions)
+ .orderBy(orderBy)
+ .limit(perPage)
+ .offset(offset)
+
+ // 총 개수 조회 (Document Class 제외)
+ const [{ count: total }] = await db
+ .select({ count: count() })
+ .from(codeGroups)
+ .where(whereConditions)
+
+ const pageCount = Math.ceil(total / perPage)
+
+ return {
+ data,
+ pageCount,
+ total,
+ }
+ } catch (error) {
+ console.error("Error fetching code groups:", error)
+ return {
+ data: [],
+ pageCount: 0,
+ total: 0,
+ }
+ }
+}
+
+// Code Group 생성
+export async function createCodeGroup(input: {
+ description: string
+ codeFormat?: string
+ expressions?: string
+ controlType: string
+ isActive?: boolean
+}) {
+ try {
+ // 마지막 Code Group의 groupId를 찾아서 다음 번호 생성 (DOC_CLASS 제외)
+ const lastCodeGroup = await db
+ .select({ groupId: codeGroups.groupId })
+ .from(codeGroups)
+ .where(sql`${codeGroups.groupId} != 'DOC_CLASS'`)
+ .orderBy(sql`CAST(SUBSTRING(${codeGroups.groupId}, 6) AS INTEGER) DESC`)
+ .limit(1)
+
+ let nextNumber = 1
+ if (lastCodeGroup.length > 0 && lastCodeGroup[0].groupId) {
+ const lastNumber = parseInt(lastCodeGroup[0].groupId.replace('Code_', ''))
+ if (!isNaN(lastNumber)) {
+ nextNumber = lastNumber + 1
+ }
+ }
+
+ const newGroupId = `Code_${nextNumber}`
+
+ // 새 Code Group 생성
+ const [newCodeGroup] = await db
+ .insert(codeGroups)
+ .values({
+ groupId: newGroupId,
+ description: input.description,
+ codeFormat: input.codeFormat,
+ expressions: input.expressions,
+ controlType: input.controlType,
+ isActive: input.isActive ?? true,
+ })
+ .returning({ id: codeGroups.id, groupId: codeGroups.groupId })
+
+ revalidatePath("/evcp/docu-list-rule/code-groups")
+
+ return {
+ success: true,
+ data: newCodeGroup,
+ message: "Code Group created successfully"
+ }
+ } catch (error) {
+ console.error("Error creating code group:", error)
+ return {
+ success: false,
+ error: "Failed to create code group"
+ }
+ }
+}
+
+// Code Group 수정
+export async function updateCodeGroup(input: {
+ id: number
+ description: string
+ codeFormat?: string
+ expressions?: string
+ controlType: string
+ isActive?: boolean
+}) {
+ try {
+ const [updatedCodeGroup] = await db
+ .update(codeGroups)
+ .set({
+ description: input.description,
+ codeFormat: input.codeFormat,
+ expressions: input.expressions,
+ controlType: input.controlType,
+ isActive: input.isActive,
+ updatedAt: new Date(),
+ })
+ .where(eq(codeGroups.id, input.id))
+ .returning({ id: codeGroups.id })
+
+ revalidatePath("/evcp/docu-list-rule/code-groups")
+
+ return {
+ success: true,
+ data: updatedCodeGroup,
+ message: "Code Group updated successfully"
+ }
+ } catch (error) {
+ console.error("Error updating code group:", error)
+ return {
+ success: false,
+ error: "Failed to update code group"
+ }
+ }
+}
+
+// Code Group 삭제
+export async function deleteCodeGroup(id: number) {
+ try {
+ // Code Group 정보 조회
+ const codeGroup = await db
+ .select({
+ id: codeGroups.id,
+ controlType: codeGroups.controlType,
+ description: codeGroups.description
+ })
+ .from(codeGroups)
+ .where(eq(codeGroups.id, id))
+ .limit(1)
+
+ if (codeGroup.length === 0) {
+ return {
+ success: false,
+ error: "Code Group not found"
+ }
+ }
+
+ // Control Type이 combobox인 경우 관련 Combo Box 옵션들도 삭제
+ if (codeGroup[0].controlType === 'combobox') {
+ // Combo Box 옵션들 삭제
+ await db
+ .delete(comboBoxSettings)
+ .where(eq(comboBoxSettings.codeGroupId, id))
+ }
+
+ // Document Class가 연결된 경우 Document Class도 삭제
+ await db
+ .delete(documentClasses)
+ .where(eq(documentClasses.codeGroupId, id))
+
+ // Code Group 삭제
+ await db
+ .delete(codeGroups)
+ .where(eq(codeGroups.id, id))
+
+ revalidatePath("/evcp/docu-list-rule/code-groups")
+ revalidatePath("/evcp/docu-list-rule/combo-box-settings")
+ revalidatePath("/evcp/docu-list-rule/document-class")
+
+ return {
+ success: true,
+ message: codeGroup[0].controlType === 'combobox'
+ ? `Code Group과 관련 Combo Box 옵션들이 삭제되었습니다.`
+ : "Code Group이 삭제되었습니다."
+ }
+ } catch (error) {
+ console.error("Error deleting code group:", error)
+ return {
+ success: false,
+ error: "Failed to delete code group"
+ }
+ }
+}
+
+// Code Group 단일 조회
+export async function getCodeGroupById(id: number) {
+ try {
+ const [codeGroup] = await db
+ .select({
+ id: codeGroups.id,
+ groupId: codeGroups.groupId,
+ description: codeGroups.description,
+ codeFormat: codeGroups.codeFormat,
+ expressions: codeGroups.expressions,
+ controlType: codeGroups.controlType,
+ isActive: codeGroups.isActive,
+ createdAt: codeGroups.createdAt,
+ updatedAt: codeGroups.updatedAt,
+ })
+ .from(codeGroups)
+ .where(eq(codeGroups.id, id))
+ .limit(1)
+
+ return codeGroup || null
+ } catch (error) {
+ console.error("Error fetching code group by id:", error)
+ return null
+ }
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/code-groups/table/code-groups-add-dialog.tsx b/lib/docu-list-rule/code-groups/table/code-groups-add-dialog.tsx
new file mode 100644
index 00000000..660adfed
--- /dev/null
+++ b/lib/docu-list-rule/code-groups/table/code-groups-add-dialog.tsx
@@ -0,0 +1,236 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { toast } from "sonner"
+import { Plus, Loader2 } from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { createCodeGroup } from "../service"
+import { z } from "zod"
+
+const createCodeGroupSchema = z.object({
+ description: z.string().min(1, "Description은 필수입니다."),
+ codeFormat: z.string().optional().refine((val) => {
+ if (!val) return true; // 빈 값은 허용
+ return /^[AN]*$/.test(val);
+ }, "Code Format은 A(영어 대문자) 또는 N(숫자)만 입력 가능합니다."),
+ controlType: z.string().min(1, "Control Type은 필수입니다."),
+})
+
+type CreateCodeGroupFormValues = z.infer<typeof createCodeGroupSchema>
+
+interface CodeGroupsAddDialogProps {
+ onSuccess?: () => void
+}
+
+export function CodeGroupsAddDialog({ onSuccess }: CodeGroupsAddDialogProps) {
+ const [open, setOpen] = React.useState(false)
+ const [isLoading, setIsLoading] = React.useState(false)
+
+ const form = useForm<CreateCodeGroupFormValues>({
+ resolver: zodResolver(createCodeGroupSchema),
+ defaultValues: {
+ description: "",
+ codeFormat: "",
+ controlType: "",
+ },
+ })
+
+ // Code Format을 기반으로 정규식 자동 생성 함수
+ const generateExpression = (codeFormat: string): string => {
+ if (!codeFormat) return ''
+
+ let expression = '^'
+ let currentChar = codeFormat[0]
+ let count = 1
+
+ for (let i = 1; i < codeFormat.length; i++) {
+ if (codeFormat[i] === currentChar) {
+ count++
+ } else {
+ // 이전 문자에 대한 정규식 추가
+ if (currentChar === 'A') {
+ expression += `[A-Z]{${count}}`
+ } else if (currentChar === 'N') {
+ expression += `[0-9]{${count}}`
+ }
+ currentChar = codeFormat[i]
+ count = 1
+ }
+ }
+
+ // 마지막 문자 처리
+ if (currentChar === 'A') {
+ expression += `[A-Z]{${count}}`
+ } else if (currentChar === 'N') {
+ expression += `[0-9]{${count}}`
+ }
+
+ expression += '$'
+ return expression
+ }
+
+ const handleOpenChange = (newOpen: boolean) => {
+ setOpen(newOpen)
+ if (!newOpen) {
+ form.reset()
+ }
+ }
+
+ const handleCancel = () => {
+ form.reset()
+ setOpen(false)
+ }
+
+ const onSubmit = async (data: CreateCodeGroupFormValues) => {
+ setIsLoading(true)
+ try {
+ // Expression 자동 생성
+ const expressions = generateExpression(data.codeFormat || "")
+
+ const result = await createCodeGroup({
+ description: data.description,
+ codeFormat: data.codeFormat,
+ expressions: expressions,
+ controlType: data.controlType,
+ })
+
+ if (result.success) {
+ toast.success("Code Group이 성공적으로 생성되었습니다.")
+ form.reset()
+ setOpen(false)
+ onSuccess?.()
+ } else {
+ toast.error(result.error || "생성 중 오류가 발생했습니다.")
+ }
+ } catch (error) {
+ console.error("Code Group 생성 오류:", error)
+ toast.error("Code Group 생성에 실패했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={handleOpenChange}>
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Plus className="mr-2 h-4 w-4" />
+ Add
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="w-[400px] sm:w-[540px]">
+ <DialogHeader>
+ <DialogTitle>Code Group 생성</DialogTitle>
+ <DialogDescription>
+ 새로운 Code Group을 생성합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Description</FormLabel>
+ <FormControl>
+ <Input placeholder="예: PROJECT NO" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="codeFormat"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Code Format</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="예: AANNN (A:영어대문자, N:숫자)"
+ {...field}
+ onBlur={() => form.trigger('codeFormat')}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="controlType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Control Type</FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="Control Type을 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="textbox">Textbox</SelectItem>
+ <SelectItem value="combobox">Combobox</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+
+ </form>
+ </Form>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleCancel}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ onClick={form.handleSubmit(onSubmit)}
+ disabled={isLoading}
+ >
+ {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ {isLoading ? "생성 중..." : "생성"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/code-groups/table/code-groups-edit-sheet.tsx b/lib/docu-list-rule/code-groups/table/code-groups-edit-sheet.tsx
new file mode 100644
index 00000000..28aebd54
--- /dev/null
+++ b/lib/docu-list-rule/code-groups/table/code-groups-edit-sheet.tsx
@@ -0,0 +1,259 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { toast } from "sonner"
+import { Loader } from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { Switch } from "@/components/ui/switch"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { updateCodeGroup } from "../service"
+import { codeGroups } from "@/db/schema/codeGroups"
+import { z } from "zod"
+
+const updateCodeGroupSchema = z.object({
+ description: z.string().min(1, "Description은 필수입니다."),
+ codeFormat: z.string().optional().refine((val) => {
+ if (!val) return true; // 빈 값은 허용
+ return /^[AN]*$/.test(val);
+ }, "Code Format은 A(영어 대문자) 또는 N(숫자)만 입력 가능합니다."),
+ controlType: z.string().min(1, "Control Type은 필수입니다."),
+ isActive: z.boolean().default(true),
+})
+
+type UpdateCodeGroupFormValues = z.infer<typeof updateCodeGroupSchema>
+
+interface CodeGroupsEditSheetProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ data: typeof codeGroups.$inferSelect | null
+ onSuccess: () => void
+}
+
+export function CodeGroupsEditSheet({
+ open,
+ onOpenChange,
+ data,
+ onSuccess,
+}: CodeGroupsEditSheetProps) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+
+ const form = useForm<UpdateCodeGroupFormValues>({
+ resolver: zodResolver(updateCodeGroupSchema),
+ defaultValues: {
+ description: data?.description ?? "",
+ codeFormat: data?.codeFormat ?? "",
+ controlType: data?.controlType ?? "",
+ isActive: data?.isActive ?? true,
+ },
+ mode: "onChange"
+ })
+
+ // Code Format을 기반으로 정규식 자동 생성 함수
+ const generateExpression = (codeFormat: string): string => {
+ if (!codeFormat) return ''
+
+ let expression = '^'
+ let currentChar = codeFormat[0]
+ let count = 1
+
+ for (let i = 1; i < codeFormat.length; i++) {
+ if (codeFormat[i] === currentChar) {
+ count++
+ } else {
+ // 이전 문자에 대한 정규식 추가
+ if (currentChar === 'A') {
+ expression += `[A-Z]{${count}}`
+ } else if (currentChar === 'N') {
+ expression += `[0-9]{${count}}`
+ }
+ currentChar = codeFormat[i]
+ count = 1
+ }
+ }
+
+ // 마지막 문자 처리
+ if (currentChar === 'A') {
+ expression += `[A-Z]{${count}}`
+ } else if (currentChar === 'N') {
+ expression += `[0-9]{${count}}`
+ }
+
+ expression += '$'
+ return expression
+ }
+
+ React.useEffect(() => {
+ if (data) {
+ form.reset({
+ description: data.description,
+ codeFormat: data.codeFormat || "",
+ controlType: data.controlType,
+ isActive: data.isActive ?? true,
+ })
+ }
+ }, [data, form])
+
+ async function onSubmit(input: UpdateCodeGroupFormValues) {
+ if (!data) return
+
+ startUpdateTransition(async () => {
+ try {
+ // Code Format이 변경되면 Expression 자동 업데이트
+ const expressions = generateExpression(input.codeFormat || "")
+
+ const result = await updateCodeGroup({
+ id: data.id,
+ description: input.description,
+ codeFormat: input.codeFormat,
+ expressions: expressions,
+ controlType: input.controlType,
+ isActive: input.isActive,
+ })
+
+ if (result.success) {
+ toast.success("Code Group이 성공적으로 수정되었습니다.")
+ onSuccess()
+ onOpenChange(false)
+ } else {
+ toast.error(result.error || "Code Group 수정에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("Update error:", error)
+ toast.error("Code Group 수정 중 오류가 발생했습니다.")
+ }
+ })
+ }
+
+ return (
+ <Sheet open={open} onOpenChange={onOpenChange}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>Code Group 수정</SheetTitle>
+ <SheetDescription>
+ Code Group 정보를 수정하고 변경사항을 저장하세요
+ </SheetDescription>
+ </SheetHeader>
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Description</FormLabel>
+ <FormControl>
+ <Input placeholder="예: PROJECT NO" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="codeFormat"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Code Format</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="예: AANNN (A:영어대문자, N:숫자)"
+ {...field}
+ onBlur={() => form.trigger('codeFormat')}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="controlType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Control Type</FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="Control Type을 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="textbox">Textbox</SelectItem>
+ <SelectItem value="combobox">Combobox</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="isActive"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
+ <div className="space-y-0.5">
+ <FormLabel className="text-base">활성 상태</FormLabel>
+ </div>
+ <FormControl>
+ <Switch
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ 취소
+ </Button>
+ </SheetClose>
+ <Button
+ type="submit"
+ disabled={isUpdatePending || !form.formState.isValid}
+ >
+ {isUpdatePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 저장
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/code-groups/table/code-groups-table-columns.tsx b/lib/docu-list-rule/code-groups/table/code-groups-table-columns.tsx
new file mode 100644
index 00000000..cb6cdf8b
--- /dev/null
+++ b/lib/docu-list-rule/code-groups/table/code-groups-table-columns.tsx
@@ -0,0 +1,190 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Ellipsis } from "lucide-react"
+
+import { formatDateTime } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { codeGroups } from "@/db/schema/docu-list-rule"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<typeof codeGroups.$inferSelect> | null>>
+}
+
+/**
+ * tanstack table 컬럼 정의
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<typeof codeGroups.$inferSelect>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<typeof codeGroups.$inferSelect> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ maxSize: 20,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 2) actions 컬럼 (Dropdown 메뉴)
+ // ----------------------------------------------------------------
+ const actionsColumn: ColumnDef<typeof codeGroups.$inferSelect> = {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "update" })}
+ >
+ Edit
+ </DropdownMenuItem>
+
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "delete" })}
+ >
+ Delete
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ maxSize: 20,
+ }
+
+ // ----------------------------------------------------------------
+ // 3) 데이터 컬럼들
+ // ----------------------------------------------------------------
+ const dataColumns: ColumnDef<typeof codeGroups.$inferSelect>[] = [
+ {
+ accessorKey: "groupId",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Group ID" />
+ ),
+ meta: {
+ excelHeader: "Group ID",
+ type: "text",
+ },
+ cell: ({ row }) => row.getValue("groupId") ?? "",
+ minSize: 60
+ },
+ {
+ accessorKey: "description",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Description" />
+ ),
+ meta: {
+ excelHeader: "Description",
+ type: "text",
+ },
+ cell: ({ row }) => row.getValue("description") ?? "",
+ minSize: 80
+ },
+ {
+ accessorKey: "codeFormat",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Code Format" />
+ ),
+ meta: {
+ excelHeader: "Code Format",
+ type: "text",
+ },
+ cell: ({ row }) => row.getValue("codeFormat") ?? "",
+ minSize: 70
+ },
+
+ {
+ accessorKey: "controlType",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Control Type" />
+ ),
+ meta: {
+ excelHeader: "Control Type",
+ type: "text",
+ },
+ cell: ({ row }) => {
+ const controlType = row.getValue("controlType") as string
+ return (
+ <Badge variant="outline">
+ {controlType}
+ </Badge>
+ )
+ },
+ minSize: 70
+ },
+ {
+ accessorKey: "createdAt",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Created At" />
+ ),
+ meta: {
+ excelHeader: "Created At",
+ type: "date",
+ },
+ cell: ({ row }) => {
+ const dateVal = row.getValue("createdAt") as Date
+ return formatDateTime(dateVal, "KR")
+ },
+ minSize: 60
+ }
+ ]
+
+ // ----------------------------------------------------------------
+ // 4) 최종 컬럼 배열: select, dataColumns, actions
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ ...dataColumns,
+ actionsColumn,
+ ]
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/code-groups/table/code-groups-table-toolbar.tsx b/lib/docu-list-rule/code-groups/table/code-groups-table-toolbar.tsx
new file mode 100644
index 00000000..d2d9efb4
--- /dev/null
+++ b/lib/docu-list-rule/code-groups/table/code-groups-table-toolbar.tsx
@@ -0,0 +1,38 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+
+import { DeleteCodeGroupsDialog } from "./delete-code-groups-dialog"
+import { CodeGroupsAddDialog } from "./code-groups-add-dialog"
+import { codeGroups } from "@/db/schema/codeGroups"
+
+interface CodeGroupsTableToolbarActionsProps<TData> {
+ table: Table<TData>
+ onSuccess?: () => void
+}
+
+export function CodeGroupsTableToolbarActions<TData>({
+ table,
+ onSuccess,
+}: CodeGroupsTableToolbarActionsProps<TData>) {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ const selectedCodeGroups = selectedRows.map((row) => row.original as typeof codeGroups.$inferSelect)
+
+ return (
+ <div className="flex items-center gap-2">
+ {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */}
+ {selectedCodeGroups.length > 0 ? (
+ <DeleteCodeGroupsDialog
+ codeGroups={selectedCodeGroups}
+ onSuccess={() => {
+ table.toggleAllRowsSelected(false)
+ onSuccess?.()
+ }}
+ />
+ ) : null}
+
+ <CodeGroupsAddDialog onSuccess={onSuccess} />
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/code-groups/table/code-groups-table.tsx b/lib/docu-list-rule/code-groups/table/code-groups-table.tsx
new file mode 100644
index 00000000..6d8bb907
--- /dev/null
+++ b/lib/docu-list-rule/code-groups/table/code-groups-table.tsx
@@ -0,0 +1,110 @@
+"use client";
+import * as React from "react";
+import { useDataTable } from "@/hooks/use-data-table";
+import { DataTable } from "@/components/data-table/data-table";
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar";
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+import { getCodeGroups } from "../service";
+import { getColumns } from "./code-groups-table-columns";
+import { DeleteCodeGroupsDialog } from "./delete-code-groups-dialog";
+import { CodeGroupsEditSheet } from "./code-groups-edit-sheet";
+import { CodeGroupsTableToolbarActions } from "./code-groups-table-toolbar";
+import { codeGroups } from "@/db/schema/docu-list-rule";
+
+interface CodeGroupsTableProps {
+ promises?: Promise<[{ data: typeof codeGroups.$inferSelect[]; pageCount: number }] >;
+}
+
+export function CodeGroupsTable({ promises }: CodeGroupsTableProps) {
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<typeof codeGroups.$inferSelect> | null>(null);
+
+ const [{ data, pageCount }] = promises ? React.use(promises) : [{ data: [], pageCount: 0 }];
+
+ const refreshData = React.useCallback(async () => {
+ // 페이지 새로고침으로 처리
+ window.location.reload();
+ }, []);
+
+ // 컬럼 설정 - 외부 파일에서 가져옴
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ // 필터 필드 설정
+ const filterFields: DataTableFilterField<typeof codeGroups.$inferSelect>[] = [];
+
+ // 고급 필터 필드 설정
+ const advancedFilterFields: DataTableAdvancedFilterField<typeof codeGroups.$inferSelect>[] = [
+ { id: "groupId", label: "Group ID", type: "text" },
+ { id: "description", label: "Description", type: "text" },
+ { id: "codeFormat", label: "Code Format", type: "text" },
+ {
+ id: "controlType", label: "Control Type", type: "select", options: [
+ { label: "Textbox", value: "textbox" },
+ { label: "Combobox", value: "combobox" },
+ { label: "Date", value: "date" },
+ { label: "Number", value: "number" },
+ ]
+ },
+ {
+ id: "isActive", label: "Status", type: "select", options: [
+ { label: "Active", value: "true" },
+ { label: "Inactive", value: "false" },
+ ]
+ },
+ { id: "createdAt", label: "Created At", type: "date" },
+ ];
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.groupId),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <CodeGroupsTableToolbarActions table={table} onSuccess={refreshData} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ <DeleteCodeGroupsDialog
+ open={rowAction?.type === "delete"}
+ onOpenChange={() => setRowAction(null)}
+ codeGroups={rowAction?.row.original ? [rowAction?.row.original] : []}
+ showTrigger={false}
+ onSuccess={() => {
+ rowAction?.row.toggleSelected(false)
+ refreshData()
+ }}
+ />
+
+ <CodeGroupsEditSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ data={rowAction?.row.original ?? null}
+ onSuccess={refreshData}
+ />
+ </>
+ );
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/code-groups/table/delete-code-groups-dialog.tsx b/lib/docu-list-rule/code-groups/table/delete-code-groups-dialog.tsx
new file mode 100644
index 00000000..66a8d7c2
--- /dev/null
+++ b/lib/docu-list-rule/code-groups/table/delete-code-groups-dialog.tsx
@@ -0,0 +1,203 @@
+"use client"
+
+import * as React from "react"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Trash } from "lucide-react"
+import { toast } from "sonner"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+import { codeGroups } from "@/db/schema/codeGroups"
+import { deleteCodeGroup } from "../service"
+
+interface DeleteCodeGroupsDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ codeGroups: Row<typeof codeGroups.$inferSelect>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeleteCodeGroupsDialog({
+ codeGroups,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeleteCodeGroupsDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ // Combo Box Code Group이 있는지 확인
+ const hasComboBoxGroups = codeGroups.some(group => group.controlType === 'combobox')
+ const comboBoxGroups = codeGroups.filter(group => group.controlType === 'combobox')
+
+ function onDelete() {
+ startDeleteTransition(async () => {
+ try {
+ // 각 Code Group을 순차적으로 삭제
+ for (const codeGroup of codeGroups) {
+ const result = await deleteCodeGroup(codeGroup.id)
+ if (!result.success) {
+ toast.error(`Code Group ${codeGroup.description} 삭제 실패: ${result.error}`)
+ return
+ }
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("Code Group이 성공적으로 삭제되었습니다.")
+ onSuccess?.()
+ } catch (error) {
+ console.error("Delete error:", error)
+ toast.error("Code Group 삭제 중 오류가 발생했습니다.")
+ }
+ })
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ delete ({codeGroups.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle>
+ <DialogDescription className="space-y-2">
+ <p>
+ 이 작업은 되돌릴 수 없습니다. 선택된{" "}
+ <span className="font-medium">{codeGroups.length}</span>
+ 개의 Code Group을 서버에서 영구적으로 삭제합니다.
+ </p>
+
+ {hasComboBoxGroups && (
+ <div className="mt-4 p-3 bg-amber-50 border border-amber-200 rounded-md">
+ <p className="text-sm font-medium text-amber-800 mb-1">
+ ⚠️ Combo Box 옵션 삭제 경고
+ </p>
+ <p className="text-sm text-amber-700">
+ 다음 {comboBoxGroups.length}개의 Combo Box Code Group이 포함되어 있습니다:
+ </p>
+ <ul className="text-sm text-amber-700 mt-1 ml-4 list-disc">
+ {comboBoxGroups.map((group) => (
+ <li key={group.id}>
+ <strong>{group.description}</strong> ({group.groupId})
+ </li>
+ ))}
+ </ul>
+ <p className="text-sm text-amber-700 mt-2">
+ 이 Code Group들을 삭제하면 관련된 모든 Combo Box 옵션들도 함께 삭제됩니다.
+ </p>
+ </div>
+ )}
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">취소</Button>
+ </DialogClose>
+ <Button
+ aria-label="선택된 행 삭제"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 삭제
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ delete ({codeGroups.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle>
+ <DrawerDescription className="space-y-2">
+ <p>
+ 이 작업은 되돌릴 수 없습니다. 선택된{" "}
+ <span className="font-medium">{codeGroups.length}</span>
+ 개의 Code Group을 서버에서 영구적으로 삭제합니다.
+ </p>
+
+ {hasComboBoxGroups && (
+ <div className="mt-4 p-3 bg-amber-50 border border-amber-200 rounded-md">
+ <p className="text-sm font-medium text-amber-800 mb-1">
+ ⚠️ Combo Box 옵션 삭제 경고
+ </p>
+ <p className="text-sm text-amber-700">
+ 다음 {comboBoxGroups.length}개의 Combo Box Code Group이 포함되어 있습니다:
+ </p>
+ <ul className="text-sm text-amber-700 mt-1 ml-4 list-disc">
+ {comboBoxGroups.map((group) => (
+ <li key={group.id}>
+ <strong>{group.description}</strong> ({group.groupId})
+ </li>
+ ))}
+ </ul>
+ <p className="text-sm text-amber-700 mt-2">
+ 이 Code Group들을 삭제하면 관련된 모든 Combo Box 옵션들도 함께 삭제됩니다.
+ </p>
+ </div>
+ )}
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">취소</Button>
+ </DrawerClose>
+ <Button
+ aria-label="선택된 행 삭제"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ delete
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/code-groups/validation.ts b/lib/docu-list-rule/code-groups/validation.ts
new file mode 100644
index 00000000..90e06d0c
--- /dev/null
+++ b/lib/docu-list-rule/code-groups/validation.ts
@@ -0,0 +1,34 @@
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { codeGroups } from "@/db/schema/docu-list-rule";
+
+export const searchParamsCodeGroupsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<typeof codeGroups.$inferSelect>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+
+ groupId: parseAsString.withDefault(""),
+ description: parseAsString.withDefault(""),
+ controlType: parseAsString.withDefault(""),
+ isActive: parseAsString.withDefault(""),
+
+ // advanced filter
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+})
+
+export type GetCodeGroupsSchema = Awaited<ReturnType<typeof searchParamsCodeGroupsCache.parse>> \ No newline at end of file
diff --git a/lib/docu-list-rule/combo-box-settings/service.ts b/lib/docu-list-rule/combo-box-settings/service.ts
new file mode 100644
index 00000000..b603ee71
--- /dev/null
+++ b/lib/docu-list-rule/combo-box-settings/service.ts
@@ -0,0 +1,368 @@
+"use server"
+
+import { revalidatePath } from "next/cache"
+import db from "@/db/db"
+import { codeGroups, comboBoxSettings } from "@/db/schema/docu-list-rule"
+import { eq, sql, count } from "drizzle-orm"
+import { unstable_noStore } from "next/cache"
+
+// Control Type이 combobox인 Code Groups 목록 조회
+export async function getComboBoxCodeGroups(input: {
+ page: number
+ perPage: number
+ search?: string
+ sort?: Array<{ id: string; desc: boolean }>
+ filters?: Array<{ id: string; value: string }>
+ joinOperator?: "and" | "or"
+ flags?: string[]
+ groupId?: string
+ description?: string
+ isActive?: string
+}) {
+ unstable_noStore()
+
+ try {
+ const { page, perPage, sort, search } = input
+ const offset = (page - 1) * perPage
+
+ // Control Type이 combobox인 조건
+ let whereConditions = sql`${codeGroups.controlType} = 'combobox'`
+
+ // 검색 조건
+ if (search) {
+ const searchTerm = `%${search}%`
+ whereConditions = sql`${whereConditions} AND (
+ ${codeGroups.groupId} ILIKE ${searchTerm} OR
+ ${codeGroups.description} ILIKE ${searchTerm} OR
+ ${codeGroups.codeFormat} ILIKE ${searchTerm}
+ )`
+ }
+
+ // 정렬
+ let orderBy = sql`${codeGroups.createdAt} DESC`
+ if (sort && sort.length > 0) {
+ const sortField = sort[0]
+ const direction = sortField.desc ? sql`DESC` : sql`ASC`
+
+ switch (sortField.id) {
+ case "groupId":
+ orderBy = sql`${codeGroups.groupId} ${direction}`
+ break
+ case "description":
+ orderBy = sql`${codeGroups.description} ${direction}`
+ break
+ case "codeFormat":
+ orderBy = sql`${codeGroups.codeFormat} ${direction}`
+ break
+ case "controlType":
+ orderBy = sql`${codeGroups.controlType} ${direction}`
+ break
+ case "isActive":
+ orderBy = sql`${codeGroups.isActive} ${direction}`
+ break
+ case "createdAt":
+ orderBy = sql`${codeGroups.createdAt} ${direction}`
+ break
+ default:
+ orderBy = sql`${codeGroups.createdAt} DESC`
+ }
+ }
+
+ // 데이터 조회
+ const data = await db
+ .select({
+ id: codeGroups.id,
+ groupId: codeGroups.groupId,
+ description: codeGroups.description,
+ codeFormat: codeGroups.codeFormat,
+ expressions: codeGroups.expressions,
+ controlType: codeGroups.controlType,
+ isActive: codeGroups.isActive,
+ createdAt: codeGroups.createdAt,
+ updatedAt: codeGroups.updatedAt,
+ })
+ .from(codeGroups)
+ .where(whereConditions)
+ .orderBy(orderBy)
+ .limit(perPage)
+ .offset(offset)
+
+ // 총 개수 조회
+ const [{ count: total }] = await db
+ .select({ count: count() })
+ .from(codeGroups)
+ .where(whereConditions)
+
+ const pageCount = Math.ceil(total / perPage)
+
+ return {
+ success: true,
+ data,
+ pageCount,
+ }
+ } catch (error) {
+ console.error("Error fetching combo box code groups:", error)
+ return {
+ success: false,
+ error: "Failed to fetch combo box code groups",
+ data: [],
+ pageCount: 0,
+ }
+ }
+}
+
+// 특정 Code Group의 Combo Box 옵션 조회
+export async function getComboBoxOptions(codeGroupId: number, input?: {
+ page?: number
+ perPage?: number
+ search?: string
+ sort?: Array<{ id: string; desc: boolean }>
+ filters?: Array<{ id: string; value: string }>
+ joinOperator?: "and" | "or"
+}) {
+ try {
+ const { page = 1, perPage = 10, sort, search } = input || {}
+ const offset = (page - 1) * perPage
+
+ // 기본 조건: codeGroupId
+ let whereConditions = eq(comboBoxSettings.codeGroupId, codeGroupId)
+
+ // 검색 조건
+ if (search) {
+ const searchTerm = `%${search}%`
+ whereConditions = sql`${whereConditions} AND (
+ ${comboBoxSettings.code} ILIKE ${searchTerm} OR
+ ${comboBoxSettings.description} ILIKE ${searchTerm} OR
+ ${comboBoxSettings.remark} ILIKE ${searchTerm}
+ )`
+ }
+
+ // 정렬
+ let orderBy = sql`${comboBoxSettings.createdAt} DESC`
+ if (sort && sort.length > 0) {
+ const sortField = sort[0]
+ const direction = sortField.desc ? sql`DESC` : sql`ASC`
+
+ switch (sortField.id) {
+ case "code":
+ orderBy = sql`${comboBoxSettings.code} ${direction}`
+ break
+ case "description":
+ orderBy = sql`${comboBoxSettings.description} ${direction}`
+ break
+ case "remark":
+ orderBy = sql`${comboBoxSettings.remark} ${direction}`
+ break
+ case "createdAt":
+ orderBy = sql`${comboBoxSettings.createdAt} ${direction}`
+ break
+ case "updatedAt":
+ orderBy = sql`${comboBoxSettings.updatedAt} ${direction}`
+ break
+ default:
+ orderBy = sql`${comboBoxSettings.createdAt} DESC`
+ }
+ }
+
+ // 데이터 조회
+ const data = await db
+ .select({
+ id: comboBoxSettings.id,
+ codeGroupId: comboBoxSettings.codeGroupId,
+ code: comboBoxSettings.code,
+ description: comboBoxSettings.description,
+ remark: comboBoxSettings.remark,
+ createdAt: comboBoxSettings.createdAt,
+ updatedAt: comboBoxSettings.updatedAt,
+ })
+ .from(comboBoxSettings)
+ .where(whereConditions)
+ .orderBy(orderBy)
+ .limit(perPage)
+ .offset(offset)
+
+ // 총 개수 조회
+ const [{ count: total }] = await db
+ .select({ count: count() })
+ .from(comboBoxSettings)
+ .where(whereConditions)
+
+ const pageCount = Math.ceil(total / perPage)
+
+ return {
+ success: true,
+ data,
+ pageCount,
+ }
+ } catch (error) {
+ console.error("Error fetching combo box options:", error)
+ return {
+ success: false,
+ error: "Failed to fetch combo box options",
+ data: [],
+ pageCount: 0,
+ }
+ }
+}
+
+// Combo Box 옵션 생성
+export async function createComboBoxOption(input: {
+ codeGroupId: number
+ code: string
+ description: string
+ remark?: string
+}) {
+ try {
+ // 해당 Code Group의 정보 가져오기
+ const codeGroup = await db
+ .select({ description: codeGroups.description })
+ .from(codeGroups)
+ .where(eq(codeGroups.id, input.codeGroupId))
+ .limit(1)
+
+ if (codeGroup.length === 0) {
+ return {
+ success: false,
+ error: "Code Group not found"
+ }
+ }
+
+ const codeGroupDescription = codeGroup[0].description
+
+ // 해당 Code Group의 마지막 옵션 번호 찾기
+ const lastOption = await db
+ .select({ code: comboBoxSettings.code })
+ .from(comboBoxSettings)
+ .where(eq(comboBoxSettings.codeGroupId, input.codeGroupId))
+ .orderBy(sql`CAST(SUBSTRING(${comboBoxSettings.code} FROM ${codeGroupDescription.length + 2}) AS INTEGER) DESC`)
+ .limit(1)
+
+ let nextNumber = 1
+ if (lastOption.length > 0 && lastOption[0].code) {
+ const prefix = `${codeGroupDescription}_`
+ if (lastOption[0].code.startsWith(prefix)) {
+ const lastNumber = parseInt(lastOption[0].code.replace(prefix, ''))
+ if (!isNaN(lastNumber)) {
+ nextNumber = lastNumber + 1
+ }
+ }
+ }
+
+ const newCode = `${codeGroupDescription}_${nextNumber}`
+
+ const [newOption] = await db
+ .insert(comboBoxSettings)
+ .values({
+ codeGroupId: input.codeGroupId,
+ code: newCode,
+ description: input.description,
+ remark: input.remark,
+ })
+ .returning({ id: comboBoxSettings.id })
+
+ revalidatePath("/evcp/docu-list-rule/combo-box-settings")
+
+ return {
+ success: true,
+ data: newOption,
+ message: "Combo Box option created successfully"
+ }
+ } catch (error) {
+ console.error("Error creating combo box option:", error)
+ return {
+ success: false,
+ error: "Failed to create combo box option"
+ }
+ }
+}
+
+// Combo Box 옵션 수정
+export async function updateComboBoxOption(input: {
+ id: number
+ code: string
+ description: string
+ remark?: string
+}) {
+ try {
+ const [updatedOption] = await db
+ .update(comboBoxSettings)
+ .set({
+ code: input.code,
+ description: input.description,
+ remark: input.remark,
+ updatedAt: new Date(),
+ })
+ .where(eq(comboBoxSettings.id, input.id))
+ .returning({ id: comboBoxSettings.id })
+
+ revalidatePath("/evcp/docu-list-rule/combo-box-settings")
+
+ return {
+ success: true,
+ data: updatedOption,
+ message: "Combo Box option updated successfully"
+ }
+ } catch (error) {
+ console.error("Error updating combo box option:", error)
+ return {
+ success: false,
+ error: "Failed to update combo box option"
+ }
+ }
+}
+
+// Combo Box 옵션 삭제
+export async function deleteComboBoxOption(id: number) {
+ try {
+ const [deletedOption] = await db
+ .delete(comboBoxSettings)
+ .where(eq(comboBoxSettings.id, id))
+ .returning({ id: comboBoxSettings.id })
+
+ if (!deletedOption) {
+ return {
+ success: false,
+ error: "Option not found"
+ }
+ }
+
+ revalidatePath("/evcp/docu-list-rule/combo-box-settings")
+
+ return {
+ success: true,
+ message: "Combo Box option deleted successfully"
+ }
+ } catch (error) {
+ console.error("Error deleting combo box option:", error)
+ return {
+ success: false,
+ error: "Failed to delete combo box option"
+ }
+ }
+}
+
+// Code Group의 모든 Combo Box 옵션 삭제
+export async function clearComboBoxOptions(codeGroupId: number) {
+ try {
+ const deletedOptions = await db
+ .delete(comboBoxSettings)
+ .where(eq(comboBoxSettings.codeGroupId, codeGroupId))
+ .returning({ id: comboBoxSettings.id })
+
+ revalidatePath("/evcp/docu-list-rule/combo-box-settings")
+
+ return {
+ success: true,
+ data: deletedOptions,
+ message: `Cleared ${deletedOptions.length} Combo Box options successfully`
+ }
+ } catch (error) {
+ console.error("Error clearing combo box options:", error)
+ return {
+ success: false,
+ error: "Failed to clear combo box options"
+ }
+ }
+}
+
+ \ No newline at end of file
diff --git a/lib/docu-list-rule/combo-box-settings/table/combo-box-options-add-dialog.tsx b/lib/docu-list-rule/combo-box-settings/table/combo-box-options-add-dialog.tsx
new file mode 100644
index 00000000..1fb8950c
--- /dev/null
+++ b/lib/docu-list-rule/combo-box-settings/table/combo-box-options-add-dialog.tsx
@@ -0,0 +1,142 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+import * as z from "zod"
+import { Plus } from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+
+import { createComboBoxOption } from "../service"
+
+const createOptionSchema = z.object({
+ description: z.string().min(1, "값은 필수입니다."),
+ remark: z.string().optional(),
+})
+
+type CreateOptionSchema = z.infer<typeof createOptionSchema>
+
+interface ComboBoxOptionsAddDialogProps {
+ codeGroupId: number
+ onSuccess?: () => void
+}
+
+export function ComboBoxOptionsAddDialog({ codeGroupId, onSuccess }: ComboBoxOptionsAddDialogProps) {
+ const [open, setOpen] = React.useState(false)
+ const [isPending, startTransition] = React.useTransition()
+
+ const form = useForm<CreateOptionSchema>({
+ resolver: zodResolver(createOptionSchema),
+ defaultValues: {
+ description: "",
+ remark: "",
+ },
+ })
+
+ const handleSubmit = (data: CreateOptionSchema) => {
+ startTransition(async () => {
+ try {
+ const result = await createComboBoxOption({
+ codeGroupId,
+ code: "", // 서비스에서 자동 생성
+ description: data.description,
+ remark: data.remark,
+ })
+
+ if (result.success) {
+ toast.success("옵션이 성공적으로 추가되었습니다.")
+ setOpen(false)
+ form.reset()
+ onSuccess?.()
+ } else {
+ toast.error(`옵션 추가 실패: ${result.error}`)
+ }
+ } catch (error) {
+ console.error("Create error:", error)
+ toast.error("옵션 추가 중 오류가 발생했습니다.")
+ }
+ })
+ }
+
+ const handleCancel = () => {
+ setOpen(false)
+ form.reset()
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Plus className="mr-2 h-4 w-4" />
+ 옵션 추가
+ </Button>
+ </DialogTrigger>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>옵션 추가</DialogTitle>
+ <DialogDescription>
+ 새로운 Combo Box 옵션을 추가합니다.
+ </DialogDescription>
+ </DialogHeader>
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>값</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="옵션 값" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="remark"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>비고</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="비고 (선택사항)" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <DialogFooter>
+ <Button type="button" variant="outline" onClick={handleCancel}>
+ 취소
+ </Button>
+ <Button type="submit" disabled={isPending || !form.formState.isValid}>
+ 추가
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/combo-box-settings/table/combo-box-options-detail-sheet.tsx b/lib/docu-list-rule/combo-box-settings/table/combo-box-options-detail-sheet.tsx
new file mode 100644
index 00000000..1c145c55
--- /dev/null
+++ b/lib/docu-list-rule/combo-box-settings/table/combo-box-options-detail-sheet.tsx
@@ -0,0 +1,185 @@
+"use client"
+
+import * as React from "react"
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import type { DataTableAdvancedFilterField, DataTableRowAction } from "@/types/table"
+import {
+ Sheet,
+ SheetContent,
+} from "@/components/ui/sheet"
+
+import { getComboBoxOptions } from "../service"
+import { getColumns } from "./combo-box-options-table-columns"
+import { ComboBoxOptionsEditSheet } from "./combo-box-options-edit-sheet"
+import { DeleteComboBoxOptionsDialog } from "./delete-combo-box-options-dialog"
+import { ComboBoxOptionsTableToolbarActions } from "./combo-box-options-table-toolbar"
+import { codeGroups } from "@/db/schema/codeGroups"
+
+type ComboBoxOption = {
+ id: number
+ codeGroupId: number
+ code: string
+ description: string
+ remark: string | null
+ isActive: boolean
+ createdAt: Date
+ updatedAt: Date
+}
+
+interface ComboBoxOptionsDetailSheetProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ codeGroup: typeof codeGroups.$inferSelect | null
+ onSuccess?: () => void
+ promises?: Promise<[{ data: ComboBoxOption[]; pageCount: number }]>
+}
+
+export function ComboBoxOptionsDetailSheet({
+ open,
+ onOpenChange,
+ codeGroup,
+ onSuccess,
+ promises,
+}: ComboBoxOptionsDetailSheetProps) {
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<any> | null>(null)
+ const [rawData, setRawData] = React.useState<{ data: ComboBoxOption[]; pageCount: number }>({ data: [], pageCount: 0 })
+
+ React.useEffect(() => {
+ if (promises) {
+ promises.then(([result]) => {
+ setRawData(result)
+ })
+ } else if (open && codeGroup) {
+ // fallback: 클라이언트에서 직접 fetch (CSR)
+ (async () => {
+ try {
+ const result = await getComboBoxOptions(codeGroup.id, {
+ page: 1,
+ perPage: 10,
+ search: "",
+ sort: [{ id: "createdAt", desc: true }],
+ filters: [],
+ joinOperator: "and",
+ })
+ if (result.success && result.data) {
+ // isActive 필드가 없는 경우 기본값 true로 설정
+ const optionsWithIsActive = result.data.map(option => ({
+ ...option,
+ isActive: (option as any).isActive ?? true
+ }))
+ setRawData({
+ data: optionsWithIsActive,
+ pageCount: result.pageCount || 1
+ })
+ }
+ } catch (error) {
+ console.error("Error refreshing data:", error)
+ }
+ })()
+ }
+ }, [promises, open, codeGroup])
+
+ const refreshData = React.useCallback(async () => {
+ if (!codeGroup) return
+
+ try {
+ const result = await getComboBoxOptions(codeGroup.id, {
+ page: 1,
+ perPage: 10,
+ search: "",
+ sort: [{ id: "createdAt", desc: true }],
+ filters: [],
+ joinOperator: "and",
+ })
+ if (result.success && result.data) {
+ // isActive 필드가 없는 경우 기본값 true로 설정
+ const optionsWithIsActive = result.data.map(option => ({
+ ...option,
+ isActive: (option as any).isActive ?? true
+ }))
+ setRawData({
+ data: optionsWithIsActive,
+ pageCount: result.pageCount || 1
+ })
+ }
+ } catch (error) {
+ console.error("Error refreshing data:", error)
+ }
+ }, [codeGroup])
+
+ const columns = React.useMemo(() => getColumns({ setRowAction: setRowAction as any }), [setRowAction])
+
+ // 고급 필터 필드 설정
+ const advancedFilterFields: DataTableAdvancedFilterField<any>[] = [
+ { id: "code", label: "코드", type: "text" },
+ { id: "description", label: "값", type: "text" },
+ { id: "remark", label: "비고", type: "text" },
+ ]
+
+ const { table } = useDataTable({
+ data: rawData.data as any,
+ columns: columns as any,
+ pageCount: rawData.pageCount,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ manualSorting: true,
+ manualFiltering: true,
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String((originalRow as any).id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ if (!codeGroup) return null
+
+ return (
+ <Sheet open={open} onOpenChange={onOpenChange}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-4xl">
+ <div className="flex items-center justify-between">
+ <div>
+ <h3 className="text-lg font-medium">{codeGroup.description} 옵션 관리</h3>
+ <p className="text-sm text-muted-foreground">
+ {codeGroup.groupId}의 Combo Box 옵션들을 관리합니다.
+ </p>
+ </div>
+ </div>
+
+ <ComboBoxOptionsTableToolbarActions
+ table={table as any}
+ codeGroupId={codeGroup.id}
+ onSuccess={refreshData}
+ />
+
+ <DataTable table={table as any}>
+ <DataTableAdvancedToolbar
+ table={table as any}
+ filterFields={advancedFilterFields}
+ />
+ </DataTable>
+
+ <DeleteComboBoxOptionsDialog
+ open={rowAction?.type === "delete"}
+ onOpenChange={() => setRowAction(null)}
+ options={rowAction?.row.original ? [rowAction?.row.original] : []}
+ showTrigger={false}
+ onSuccess={() => {
+ rowAction?.row.toggleSelected(false)
+ refreshData()
+ }}
+ />
+
+ <ComboBoxOptionsEditSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ data={rowAction?.row.original ?? null}
+ onSuccess={refreshData}
+ />
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/combo-box-settings/table/combo-box-options-dialog.tsx b/lib/docu-list-rule/combo-box-settings/table/combo-box-options-dialog.tsx
new file mode 100644
index 00000000..6459ae14
--- /dev/null
+++ b/lib/docu-list-rule/combo-box-settings/table/combo-box-options-dialog.tsx
@@ -0,0 +1,234 @@
+"use client"
+
+import * as React from "react"
+import { useState } from "react"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Plus, Trash2 } from "lucide-react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { toast } from "sonner"
+import { codeGroups } from "@/db/schema/codeGroups"
+import { createComboBoxOption } from "../service"
+
+interface ComboBoxOptionsDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ codeGroup: typeof codeGroups.$inferSelect | null
+ onSuccess: () => void
+}
+
+interface OptionRow {
+ id: string
+ description: string
+ remark: string
+}
+
+export function ComboBoxOptionsDialog({
+ open,
+ onOpenChange,
+ codeGroup,
+ onSuccess
+}: ComboBoxOptionsDialogProps) {
+ const [optionRows, setOptionRows] = useState<OptionRow[]>([])
+ const [loading, setLoading] = useState(false)
+
+ // 다이얼로그가 열릴 때 초기 행 생성
+ React.useEffect(() => {
+ if (open && optionRows.length === 0) {
+ addRow()
+ }
+ }, [open])
+
+ // 새 행 추가
+ const addRow = () => {
+ const newRow: OptionRow = {
+ id: `row-${Date.now()}-${Math.random()}`,
+ description: "",
+ remark: "",
+ }
+ setOptionRows(prev => [...prev, newRow])
+ }
+
+ // 행 삭제
+ const removeRow = (id: string) => {
+ setOptionRows(prev => prev.filter(row => row.id !== id))
+ }
+
+ // 행 업데이트
+ const updateRow = (id: string, field: keyof OptionRow, value: string) => {
+ setOptionRows(prev =>
+ prev.map(row =>
+ row.id === id ? { ...row, [field]: value } : row
+ )
+ )
+ }
+
+ // 일괄 저장
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+
+ if (!codeGroup) return
+
+ // 유효한 행들만 필터링 (Description이 있는 것만)
+ const validRows = optionRows.filter(row => row.description.trim())
+
+ if (validRows.length === 0) {
+ toast.error("최소 하나의 Description을 입력해주세요.")
+ return
+ }
+
+ setLoading(true)
+ try {
+ let successCount = 0
+ let errorCount = 0
+
+ // 각 행을 순차적으로 저장
+ for (const row of validRows) {
+ try {
+ const result = await createComboBoxOption({
+ codeGroupId: codeGroup.id,
+ code: "", // 서비스에서 자동 생성
+ description: row.description.trim(),
+ remark: row.remark.trim() || undefined,
+ })
+
+ if (result.success) {
+ successCount++
+ } else {
+ errorCount++
+ }
+ } catch (error) {
+ console.error("옵션 추가 실패:", error)
+ errorCount++
+ }
+ }
+
+ if (successCount > 0) {
+ toast.success(`${successCount}개의 옵션이 추가되었습니다.${errorCount > 0 ? ` (${errorCount}개 실패)` : ''}`)
+ // 폼 초기화
+ setOptionRows([])
+ onSuccess()
+ } else {
+ toast.error("모든 옵션 추가에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("옵션 추가 실패:", error)
+ toast.error("옵션 추가에 실패했습니다.")
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleCancel = () => {
+ // 폼 초기화
+ setOptionRows([])
+ onOpenChange(false)
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[600px] max-h-[80vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>Combo Box 옵션 추가</DialogTitle>
+ <DialogDescription>
+ {codeGroup?.description}에 새로운 옵션들을 추가합니다. Code는 자동으로 생성됩니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ {/* 테이블 헤더와 추가 버튼 */}
+ <div className="flex items-center justify-between">
+ <h4 className="text-sm font-medium">옵션 목록</h4>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={addRow}
+ className="h-8"
+ >
+ <Plus className="h-4 w-4 mr-1" />
+ 행 추가
+ </Button>
+ </div>
+
+ {/* 옵션 테이블 - 항상 표시 */}
+ <div className="border rounded-lg overflow-hidden">
+ <Table>
+ <TableHeader>
+ <TableRow className="bg-muted/30">
+ <TableHead className="w-[50%]">Description *</TableHead>
+ <TableHead className="w-[40%]">Remark</TableHead>
+ <TableHead className="w-[10%]"></TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {optionRows.map((row) => (
+ <TableRow key={row.id} className="hover:bg-muted/30">
+ <TableCell>
+ <Input
+ value={row.description}
+ onChange={(e) => updateRow(row.id, "description", e.target.value)}
+ className="border-0 focus-visible:ring-1 bg-transparent h-8"
+ />
+ </TableCell>
+ <TableCell>
+ <Input
+ value={row.remark}
+ onChange={(e) => updateRow(row.id, "remark", e.target.value)}
+ className="border-0 focus-visible:ring-1 bg-transparent h-8"
+ />
+ </TableCell>
+ <TableCell>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removeRow(row.id)}
+ className="h-6 w-6 p-0"
+ >
+ <Trash2 className="h-3 w-3" />
+ </Button>
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleCancel}
+ disabled={loading}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ onClick={handleSubmit}
+ disabled={loading}
+ >
+ {loading ? "저장 중..." : "저장"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/combo-box-settings/table/combo-box-options-edit-sheet.tsx b/lib/docu-list-rule/combo-box-settings/table/combo-box-options-edit-sheet.tsx
new file mode 100644
index 00000000..5732674e
--- /dev/null
+++ b/lib/docu-list-rule/combo-box-settings/table/combo-box-options-edit-sheet.tsx
@@ -0,0 +1,147 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+import * as z from "zod"
+import { Loader } from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+
+import { updateComboBoxOption } from "../service"
+
+const updateOptionSchema = z.object({
+ value: z.string().min(1, "값은 필수입니다."),
+})
+
+type UpdateOptionSchema = z.infer<typeof updateOptionSchema>
+
+interface ComboBoxOption {
+ id: number
+ codeGroupId: number
+ code: string
+ description: string
+ remark: string | null
+ isActive: boolean
+ createdAt: Date
+ updatedAt: Date
+}
+
+interface ComboBoxOptionsEditSheetProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ data: ComboBoxOption | null
+ onSuccess?: () => void
+}
+
+export function ComboBoxOptionsEditSheet({
+ open,
+ onOpenChange,
+ data,
+ onSuccess,
+}: ComboBoxOptionsEditSheetProps) {
+ const [isPending, startTransition] = React.useTransition()
+
+ const form = useForm<UpdateOptionSchema>({
+ resolver: zodResolver(updateOptionSchema),
+ defaultValues: {
+ value: "",
+ },
+ })
+
+ React.useEffect(() => {
+ if (data) {
+ form.reset({
+ value: data.description,
+ })
+ }
+ }, [data, form])
+
+ const handleSubmit = (formData: UpdateOptionSchema) => {
+ if (!data) return
+
+ startTransition(async () => {
+ try {
+ const result = await updateComboBoxOption({
+ id: data.id,
+ value: formData.value,
+ })
+
+ if (result.success) {
+ toast.success("옵션이 성공적으로 수정되었습니다.")
+ onOpenChange(false)
+ onSuccess?.()
+ } else {
+ toast.error(`옵션 수정 실패: ${result.error}`)
+ }
+ } catch (error) {
+ console.error("Update error:", error)
+ toast.error("옵션 수정 중 오류가 발생했습니다.")
+ }
+ })
+ }
+
+ const handleCancel = () => {
+ onOpenChange(false)
+ form.reset()
+ }
+
+ return (
+ <Sheet open={open} onOpenChange={onOpenChange}>
+ <SheetContent>
+ <SheetHeader>
+ <SheetTitle>옵션 수정</SheetTitle>
+ <SheetDescription>
+ ComboBox 옵션을 수정합니다.
+ </SheetDescription>
+ </SheetHeader>
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
+ <FormField
+ control={form.control}
+ name="value"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>값</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="옵션 값" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <SheetFooter>
+ <Button type="button" variant="outline" onClick={handleCancel}>
+ 취소
+ </Button>
+ <Button type="submit" disabled={isPending || !form.formState.isValid}>
+ {isPending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ 수정
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/combo-box-settings/table/combo-box-options-expandable-row.tsx b/lib/docu-list-rule/combo-box-settings/table/combo-box-options-expandable-row.tsx
new file mode 100644
index 00000000..07b63de5
--- /dev/null
+++ b/lib/docu-list-rule/combo-box-settings/table/combo-box-options-expandable-row.tsx
@@ -0,0 +1,263 @@
+"use client"
+
+import * as React from "react"
+import { useState, useEffect } from "react"
+import { MoreHorizontal, Settings } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { toast } from "sonner"
+import { codeGroups } from "@/db/schema/codeGroups"
+import { getComboBoxOptions, updateComboBoxOption, deleteComboBoxOption } from "../service"
+import { DocumentClassOptionsSheet } from "./document-class-options-sheet"
+
+
+interface ComboBoxOptionsExpandableRowProps {
+ codeGroup: typeof codeGroups.$inferSelect
+}
+
+interface ComboBoxOption {
+ id: number
+ codeGroupId: number
+ code: string
+ description: string
+ remark: string | null
+ createdAt: Date
+ updatedAt: Date
+}
+
+export function ComboBoxOptionsExpandableRow({ codeGroup }: ComboBoxOptionsExpandableRowProps) {
+ const [options, setOptions] = useState<ComboBoxOption[]>([])
+ const [loading, setLoading] = useState(true)
+ const [editingOption, setEditingOption] = useState<ComboBoxOption | null>(null)
+ const [selectedOptionForSubOptions, setSelectedOptionForSubOptions] = useState<ComboBoxOption | null>(null)
+
+ // 옵션 목록 로드
+ const loadOptions = async () => {
+ try {
+ setLoading(true)
+ const result = await getComboBoxOptions(codeGroup.id)
+ if (result.success && result.data) {
+ setOptions(result.data as ComboBoxOption[])
+ } else {
+ toast.error("옵션 목록을 불러오는데 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("옵션 로드 실패:", error)
+ toast.error("옵션 목록을 불러오는데 실패했습니다.")
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ useEffect(() => {
+ loadOptions()
+ }, [codeGroup.id])
+
+ // 기존 옵션 수정
+ const handleUpdateOption = async (option: ComboBoxOption) => {
+ if (!option.code.trim() || !option.description.trim()) {
+ toast.error("Code와 Description은 필수 입력 항목입니다.")
+ return
+ }
+
+ try {
+ const result = await updateComboBoxOption({
+ id: option.id,
+ code: option.code.trim(),
+ description: option.description.trim(),
+ remark: option.remark || undefined,
+ })
+
+ if (result.success) {
+ await loadOptions() // 목록 새로고침
+ setEditingOption(null) // 편집 모드 종료
+ toast.success("옵션이 수정되었습니다.")
+ } else {
+ toast.error("옵션 수정에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("옵션 수정 실패:", error)
+ toast.error("옵션 수정에 실패했습니다.")
+ }
+ }
+
+ // 기존 옵션 삭제
+ const handleDeleteOption = async (optionId: number) => {
+ if (!confirm("정말로 이 옵션을 삭제하시겠습니까?")) {
+ return
+ }
+
+ try {
+ const result = await deleteComboBoxOption(optionId)
+ if (result.success) {
+ await loadOptions() // 목록 새로고침
+ toast.success("옵션이 삭제되었습니다.")
+ } else {
+ toast.error("옵션 삭제에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("옵션 삭제 실패:", error)
+ toast.error("옵션 삭제에 실패했습니다.")
+ }
+ }
+
+ // Document Class인지 확인 (Description이 "Document Class"인 경우)
+ const isDocumentClass = codeGroup.description === "Document Class"
+
+ return (
+ <div className="bg-muted/20 border-t">
+ <div className="space-y-0 ml-[60px]">
+ {/* 커스텀 테이블 */}
+ <div className="border overflow-hidden bg-white">
+ <Table className="w-full table-fixed">
+ <TableHeader>
+ <TableRow className="bg-muted/30">
+ <TableHead className="w-[20%] font-medium text-muted-foreground">Code</TableHead>
+ <TableHead className="w-[30%] font-medium text-muted-foreground">Description</TableHead>
+ <TableHead className="w-[25%] font-medium text-muted-foreground">Remark</TableHead>
+ {isDocumentClass && (
+ <TableHead className="w-[15%] font-medium text-muted-foreground">하위 옵션</TableHead>
+ )}
+ <TableHead className="w-[10%] font-medium text-muted-foreground">작업</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {/* 기존 옵션들 */}
+ {options.map((option) => (
+ <TableRow key={option.id} className="hover:bg-muted/30 transition-colors">
+ <TableCell className="font-medium text-sm">
+ {editingOption?.id === option.id ? (
+ <Input
+ value={editingOption.code}
+ onChange={(e) => setEditingOption(prev => prev ? { ...prev, code: e.target.value } : null)}
+ placeholder="Code (*)"
+ className="border-0 focus-visible:ring-1 bg-transparent h-8"
+ />
+ ) : (
+ option.code
+ )}
+ </TableCell>
+ <TableCell className="text-sm">
+ {editingOption?.id === option.id ? (
+ <Input
+ value={editingOption.description}
+ onChange={(e) => setEditingOption(prev => prev ? { ...prev, description: e.target.value } : null)}
+ placeholder="Description (*)"
+ className="border-0 focus-visible:ring-1 bg-transparent h-8"
+ />
+ ) : (
+ option.description
+ )}
+ </TableCell>
+ <TableCell className="text-sm text-muted-foreground">
+ {editingOption?.id === option.id ? (
+ <Input
+ value={editingOption.remark || ""}
+ onChange={(e) => setEditingOption(prev => prev ? { ...prev, remark: e.target.value } : null)}
+ placeholder="Remark"
+ className="border-0 focus-visible:ring-1 bg-transparent h-8"
+ />
+ ) : (
+ option.remark || "-"
+ )}
+ </TableCell>
+ {isDocumentClass && (
+ <TableCell className="text-sm">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setSelectedOptionForSubOptions(option)}
+ className="h-6 px-2 text-xs"
+ >
+ <Settings className="h-3 w-3 mr-1" />
+ 관리
+ </Button>
+ </TableCell>
+ )}
+ <TableCell className="text-sm">
+ {editingOption?.id === option.id ? (
+ <div className="flex gap-1">
+ <Button
+ onClick={() => handleUpdateOption(editingOption)}
+ size="sm"
+ variant="outline"
+ className="h-6 px-2 text-xs"
+ >
+ 저장
+ </Button>
+ <Button
+ onClick={() => setEditingOption(null)}
+ size="sm"
+ variant="ghost"
+ className="h-6 px-2 text-xs"
+ >
+ 취소
+ </Button>
+ </div>
+ ) : (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="ghost"
+ className="h-6 w-6 p-0"
+ >
+ <MoreHorizontal className="h-3 w-3" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem onClick={() => setEditingOption(option)}>
+ 수정
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem onClick={() => handleDeleteOption(option.id)}>
+ 삭제
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )}
+ </TableCell>
+ </TableRow>
+ ))}
+
+ {options.length === 0 && (
+ <TableRow>
+ <TableCell colSpan={isDocumentClass ? 5 : 4} className="text-center text-muted-foreground py-8">
+ 등록된 옵션이 없습니다.
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ </div>
+ </div>
+
+ {/* Document Class 하위 옵션 관리 시트 */}
+ {selectedOptionForSubOptions && (
+ <DocumentClassOptionsSheet
+ open={!!selectedOptionForSubOptions}
+ onOpenChange={(open) => !open && setSelectedOptionForSubOptions(null)}
+ comboBoxOption={selectedOptionForSubOptions}
+ onSuccess={() => {
+ setSelectedOptionForSubOptions(null)
+ // 필요시 하위 옵션 목록 새로고침
+ }}
+ />
+ )}
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/combo-box-settings/table/combo-box-options-table-columns.tsx b/lib/docu-list-rule/combo-box-settings/table/combo-box-options-table-columns.tsx
new file mode 100644
index 00000000..e5780e9e
--- /dev/null
+++ b/lib/docu-list-rule/combo-box-settings/table/combo-box-options-table-columns.tsx
@@ -0,0 +1,162 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Ellipsis } from "lucide-react"
+
+
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+
+interface ComboBoxOption {
+ id: number
+ codeGroupId: number
+ code: string
+ description: string
+ remark: string | null
+ isActive?: boolean
+ createdAt: Date
+ updatedAt: Date
+}
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<ComboBoxOption> | null>>
+}
+
+/**
+ * tanstack table 컬럼 정의
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<ComboBoxOption>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<ComboBoxOption> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ maxSize: 30,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 2) actions 컬럼 (Dropdown 메뉴)
+ // ----------------------------------------------------------------
+ const actionsColumn: ColumnDef<ComboBoxOption> = {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "update" })}
+ >
+ Edit
+ </DropdownMenuItem>
+
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "delete" })}
+ >
+ Delete
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ maxSize: 30,
+ }
+
+ // ----------------------------------------------------------------
+ // 3) 데이터 컬럼들
+ // ----------------------------------------------------------------
+ const dataColumns: ColumnDef<ComboBoxOption>[] = [
+ {
+ accessorKey: "code",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="코드" />
+ ),
+ meta: {
+ excelHeader: "코드",
+ type: "text",
+ },
+ cell: ({ row }) => row.getValue("code") ?? "",
+ minSize: 80
+ },
+ {
+ accessorKey: "description",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="값" />
+ ),
+ meta: {
+ excelHeader: "값",
+ type: "text",
+ },
+ cell: ({ row }) => row.getValue("description") ?? "",
+ minSize: 80
+ },
+ {
+ accessorKey: "remark",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="비고" />
+ ),
+ meta: {
+ excelHeader: "비고",
+ type: "text",
+ },
+ cell: ({ row }) => row.getValue("remark") ?? "",
+ minSize: 80
+ }
+ ]
+
+ // ----------------------------------------------------------------
+ // 4) 최종 컬럼 배열: select, dataColumns, actions
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ ...dataColumns,
+ actionsColumn,
+ ]
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/combo-box-settings/table/combo-box-options-table-toolbar.tsx b/lib/docu-list-rule/combo-box-settings/table/combo-box-options-table-toolbar.tsx
new file mode 100644
index 00000000..7318efb8
--- /dev/null
+++ b/lib/docu-list-rule/combo-box-settings/table/combo-box-options-table-toolbar.tsx
@@ -0,0 +1,53 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+
+import { ComboBoxOptionsAddDialog } from "./combo-box-options-add-dialog"
+import { DeleteComboBoxOptionsDialog } from "./delete-combo-box-options-dialog"
+
+interface ComboBoxOption {
+ id: number
+ codeGroupId: number
+ code: string
+ description: string
+ remark: string | null
+ isActive: boolean
+ createdAt: Date
+ updatedAt: Date
+}
+
+interface ComboBoxOptionsTableToolbarActionsProps {
+ table: Table<ComboBoxOption>
+ codeGroupId: number
+ onSuccess?: () => void
+}
+
+export function ComboBoxOptionsTableToolbarActions({
+ table,
+ codeGroupId,
+ onSuccess,
+}: ComboBoxOptionsTableToolbarActionsProps) {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ const selectedOptions = selectedRows.map((row) => row.original)
+
+ return (
+ <div className="flex items-center gap-2">
+ {/** 선택된 로우가 있으면 삭제 다이얼로그 */}
+ {selectedOptions.length > 0 ? (
+ <DeleteComboBoxOptionsDialog
+ options={selectedOptions}
+ onSuccess={() => {
+ table.toggleAllRowsSelected(false)
+ onSuccess?.()
+ }}
+ />
+ ) : null}
+
+ <ComboBoxOptionsAddDialog
+ codeGroupId={codeGroupId}
+ onSuccess={onSuccess}
+ />
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/combo-box-settings/table/combo-box-settings-table-columns.tsx b/lib/docu-list-rule/combo-box-settings/table/combo-box-settings-table-columns.tsx
new file mode 100644
index 00000000..efce54b4
--- /dev/null
+++ b/lib/docu-list-rule/combo-box-settings/table/combo-box-settings-table-columns.tsx
@@ -0,0 +1,180 @@
+"use client"
+
+import * as React from "react"
+
+import { type ColumnDef } from "@tanstack/react-table"
+import { Ellipsis } from "lucide-react"
+
+import { formatDateTime } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { codeGroups } from "@/db/schema/docu-list-rule"
+
+interface GetColumnsProps {
+ onDetail?: (codeGroup: typeof codeGroups.$inferSelect) => void
+}
+
+/**
+ * tanstack table 컬럼 정의
+ */
+export function getColumns({ onDetail }: GetColumnsProps): ColumnDef<typeof codeGroups.$inferSelect>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<typeof codeGroups.$inferSelect> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ maxSize: 30,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 2) actions 컬럼 (Dropdown 메뉴)
+ // ----------------------------------------------------------------
+ const actionsColumn: ColumnDef<typeof codeGroups.$inferSelect> = {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem
+ onSelect={() => onDetail?.(row.original)}
+ >
+ Detail
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ maxSize: 30,
+ }
+
+ // ----------------------------------------------------------------
+ // 3) 데이터 컬럼들
+ // ----------------------------------------------------------------
+ const dataColumns: ColumnDef<typeof codeGroups.$inferSelect>[] = [
+ {
+ accessorKey: "groupId",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Group ID" />
+ ),
+ meta: {
+ excelHeader: "Group ID",
+ type: "text",
+ },
+ cell: ({ row }) => row.getValue("groupId") ?? "",
+ minSize: 80
+ },
+ {
+ accessorKey: "description",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Description" />
+ ),
+ meta: {
+ excelHeader: "Description",
+ type: "text",
+ },
+ cell: ({ row }) => row.getValue("description") ?? "",
+ minSize: 80
+ },
+ {
+ accessorKey: "codeFormat",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Code Format" />
+ ),
+ meta: {
+ excelHeader: "Code Format",
+ type: "text",
+ },
+ cell: ({ row }) => row.getValue("codeFormat") ?? "",
+ minSize: 80
+ },
+ {
+ accessorKey: "controlType",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Control Type" />
+ ),
+ meta: {
+ excelHeader: "Control Type",
+ type: "text",
+ },
+ cell: ({ row }) => {
+ const controlType = row.getValue("controlType") as string
+ return (
+ <Badge variant="outline">
+ {controlType}
+ </Badge>
+ )
+ },
+ minSize: 80
+ },
+
+ {
+ accessorKey: "createdAt",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Created At" />
+ ),
+ meta: {
+ excelHeader: "Created At",
+ type: "date",
+ },
+ cell: ({ row }) => {
+ const dateVal = row.getValue("createdAt") as Date
+ return formatDateTime(dateVal, "KR")
+ },
+ minSize: 80
+ }
+ ]
+
+ // ----------------------------------------------------------------
+ // 4) 최종 컬럼 배열: select, dataColumns, actions
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ ...dataColumns,
+ actionsColumn,
+ ]
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/combo-box-settings/table/combo-box-settings-table-toolbar.tsx b/lib/docu-list-rule/combo-box-settings/table/combo-box-settings-table-toolbar.tsx
new file mode 100644
index 00000000..77cbea01
--- /dev/null
+++ b/lib/docu-list-rule/combo-box-settings/table/combo-box-settings-table-toolbar.tsx
@@ -0,0 +1,33 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+
+import { DeleteComboBoxSettingsDialog } from "./delete-combo-box-settings-dialog"
+import { codeGroups } from "@/db/schema/codeGroups"
+
+interface ComboBoxSettingsTableToolbarActionsProps {
+ table: Table<typeof codeGroups.$inferSelect>
+ onSuccess?: () => void
+}
+
+export function ComboBoxSettingsTableToolbarActions({ table, onSuccess }: ComboBoxSettingsTableToolbarActionsProps) {
+ return (
+ <div className="flex items-center gap-2">
+ {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */}
+ {table.getFilteredSelectedRowModel().rows.length > 0 ? (
+ <DeleteComboBoxSettingsDialog
+ codeGroups={table
+ .getFilteredSelectedRowModel()
+ .rows.map((row) => row.original)}
+ onSuccess={() => {
+ table.toggleAllRowsSelected(false)
+ onSuccess?.()
+ }}
+ />
+ ) : null}
+
+
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/combo-box-settings/table/combo-box-settings-table.tsx b/lib/docu-list-rule/combo-box-settings/table/combo-box-settings-table.tsx
new file mode 100644
index 00000000..356b2706
--- /dev/null
+++ b/lib/docu-list-rule/combo-box-settings/table/combo-box-settings-table.tsx
@@ -0,0 +1,105 @@
+"use client"
+
+import * as React from "react"
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import type { DataTableAdvancedFilterField } from "@/types/table"
+import { getComboBoxCodeGroups } from "../service"
+import { getColumns } from "./combo-box-settings-table-columns"
+import { ComboBoxOptionsDetailSheet } from "./combo-box-options-detail-sheet"
+import { ComboBoxSettingsTableToolbarActions } from "./combo-box-settings-table-toolbar"
+import { codeGroups } from "@/db/schema/docu-list-rule"
+
+interface ComboBoxSettingsTableProps {
+ promises?: Promise<[{ data: typeof codeGroups.$inferSelect[]; pageCount: number }]>
+}
+
+export function ComboBoxSettingsTable({ promises }: ComboBoxSettingsTableProps) {
+ const rawData = React.use(promises!)
+ const [isDetailSheetOpen, setIsDetailSheetOpen] = React.useState(false)
+ const [selectedCodeGroup, setSelectedCodeGroup] = React.useState<typeof codeGroups.$inferSelect | null>(null)
+
+ const refreshData = React.useCallback(() => {
+ window.location.reload()
+ }, [])
+
+ // Detail 버튼 클릭 핸들러
+ const handleDetail = (codeGroup: typeof codeGroups.$inferSelect) => {
+ setSelectedCodeGroup(codeGroup)
+ setIsDetailSheetOpen(true)
+ }
+
+ const columns = React.useMemo(() => getColumns({ onDetail: handleDetail }), [handleDetail])
+
+ // 고급 필터 필드 설정
+ const advancedFilterFields: DataTableAdvancedFilterField<typeof codeGroups.$inferSelect>[] = [
+ { id: "groupId", label: "Group ID", type: "text" },
+ { id: "description", label: "Description", type: "text" },
+ { id: "codeFormat", label: "Code Format", type: "text" },
+ {
+ id: "controlType", label: "Control Type", type: "select", options: [
+ { label: "Textbox", value: "textbox" },
+ { label: "Combobox", value: "combobox" },
+ { label: "Date", value: "date" },
+ { label: "Number", value: "number" },
+ ]
+ },
+ {
+ id: "isActive", label: "Status", type: "select", options: [
+ { label: "Active", value: "true" },
+ { label: "Inactive", value: "false" },
+ ]
+ },
+ { id: "createdAt", label: "Created At", type: "date" },
+ ]
+
+ const { table } = useDataTable({
+ data: rawData[0].data as any,
+ columns,
+ pageCount: rawData[0].pageCount,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ manualSorting: false,
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ columnFilters: [
+ {
+ id: "controlType",
+ value: "combobox",
+ },
+ ],
+ },
+ getRowId: (originalRow) => String(originalRow.groupId),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ >
+ <ComboBoxSettingsTableToolbarActions table={table} onSuccess={refreshData} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ {/* Detail 시트 */}
+ <ComboBoxOptionsDetailSheet
+ open={isDetailSheetOpen}
+ onOpenChange={setIsDetailSheetOpen}
+ codeGroup={selectedCodeGroup}
+ onSuccess={() => {
+ setIsDetailSheetOpen(false)
+ setSelectedCodeGroup(null)
+ }}
+
+ />
+
+
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/combo-box-settings/table/delete-combo-box-options-dialog.tsx b/lib/docu-list-rule/combo-box-settings/table/delete-combo-box-options-dialog.tsx
new file mode 100644
index 00000000..e3d8bd23
--- /dev/null
+++ b/lib/docu-list-rule/combo-box-settings/table/delete-combo-box-options-dialog.tsx
@@ -0,0 +1,162 @@
+"use client"
+
+import * as React from "react"
+import { Loader, Trash } from "lucide-react"
+import { toast } from "sonner"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+
+import { deleteComboBoxOption } from "../service"
+
+interface ComboBoxOption {
+ id: number
+ codeGroupId: number
+ code: string
+ description: string
+ remark: string | null
+ isActive: boolean
+ createdAt: Date
+ updatedAt: Date
+}
+
+interface DeleteComboBoxOptionsDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ options: ComboBoxOption[]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeleteComboBoxOptionsDialog({
+ options,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeleteComboBoxOptionsDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onDelete() {
+ startDeleteTransition(async () => {
+ try {
+ for (const option of options) {
+ const result = await deleteComboBoxOption(option.id)
+ if (!result.success) {
+ toast.error(`ComboBox 옵션 삭제 실패: ${result.error}`)
+ return
+ }
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("ComboBox 옵션이 성공적으로 삭제되었습니다.")
+ onSuccess?.()
+ } catch (error) {
+ console.error("Delete error:", error)
+ toast.error("ComboBox 옵션 삭제 중 오류가 발생했습니다.")
+ }
+ })
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제 ({options.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle>
+ <DialogDescription>
+ 이 작업은 되돌릴 수 없습니다. 선택된{" "}
+ <span className="font-medium">{options.length}</span>
+ 개의 ComboBox 옵션을 서버에서 영구적으로 삭제합니다.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">취소</Button>
+ </DialogClose>
+ <Button
+ aria-label="선택된 행 삭제"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 삭제
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제 ({options.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle>
+ <DrawerDescription>
+ 이 작업은 되돌릴 수 없습니다. 선택된{" "}
+ <span className="font-medium">{options.length}</span>
+ 개의 ComboBox 옵션을 서버에서 영구적으로 삭제합니다.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">취소</Button>
+ </DrawerClose>
+ <Button
+ aria-label="선택된 행 삭제"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ 삭제
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/combo-box-settings/table/delete-combo-box-settings-dialog.tsx b/lib/docu-list-rule/combo-box-settings/table/delete-combo-box-settings-dialog.tsx
new file mode 100644
index 00000000..28788bd7
--- /dev/null
+++ b/lib/docu-list-rule/combo-box-settings/table/delete-combo-box-settings-dialog.tsx
@@ -0,0 +1,85 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { AlertTriangle } from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { deleteCodeGroup } from "@/lib/docu-list-rule/code-groups/service"
+import { codeGroups } from "@/db/schema/codeGroups"
+
+interface DeleteComboBoxSettingsDialogProps {
+ codeGroups: typeof codeGroups.$inferSelect[]
+ onSuccess?: () => void
+}
+
+export function DeleteComboBoxSettingsDialog({
+ codeGroups,
+ onSuccess,
+}: DeleteComboBoxSettingsDialogProps) {
+ const router = useRouter()
+ const [isDeleting, setIsDeleting] = React.useState(false)
+
+ const handleDelete = React.useCallback(async () => {
+ if (codeGroups.length === 0) return
+
+ setIsDeleting(true)
+ try {
+ for (const codeGroup of codeGroups) {
+ await deleteCodeGroup(codeGroup.id)
+ }
+
+ router.refresh()
+ onSuccess?.()
+ } catch (error) {
+ console.error("Error deleting code groups:", error)
+ } finally {
+ setIsDeleting(false)
+ }
+ }, [codeGroups, router, onSuccess])
+
+ if (codeGroups.length === 0) {
+ return null
+ }
+
+ return (
+ <Dialog>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <AlertTriangle className="h-5 w-5 text-destructive" />
+ Code Group 삭제
+ </DialogTitle>
+ <DialogDescription>
+ 선택한 Code Group{codeGroups.length > 1 ? "들" : ""}을 삭제하시겠습니까?
+ <br />
+ 이 작업은 되돌릴 수 없습니다.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter>
+ <Button
+ variant="outline"
+ disabled={isDeleting}
+ >
+ 취소
+ </Button>
+ <Button
+ variant="destructive"
+ onClick={handleDelete}
+ disabled={isDeleting}
+ >
+ {isDeleting ? "삭제 중..." : "삭제"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/combo-box-settings/table/document-class-options-sheet.tsx b/lib/docu-list-rule/combo-box-settings/table/document-class-options-sheet.tsx
new file mode 100644
index 00000000..8585d9a3
--- /dev/null
+++ b/lib/docu-list-rule/combo-box-settings/table/document-class-options-sheet.tsx
@@ -0,0 +1,436 @@
+"use client"
+
+import * as React from "react"
+import { useState, useEffect } from "react"
+import { Plus, Trash2, Save, X } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { toast } from "sonner"
+import {
+ getDocumentClassOptions,
+ createDocumentClassOption,
+ updateDocumentClassOption,
+ deleteDocumentClassOption
+} from "../service"
+
+interface DocumentClassOptionsSheetProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ comboBoxOption: {
+ id: number
+ code: string
+ description: string
+ }
+ onSuccess: () => void
+}
+
+interface DocumentClassOption {
+ id: number
+ comboBoxSettingId: number
+ optionValue: string
+ optionCode: string | null
+ sortOrder: number
+ isActive: boolean
+ createdAt: Date
+ updatedAt: Date
+}
+
+interface NewOptionRow {
+ id: string
+ optionValue: string
+ optionCode: string
+ sortOrder: number
+}
+
+export function DocumentClassOptionsSheet({
+ open,
+ onOpenChange,
+ comboBoxOption,
+ onSuccess
+}: DocumentClassOptionsSheetProps) {
+ const [options, setOptions] = useState<DocumentClassOption[]>([])
+ const [loading, setLoading] = useState(true)
+ const [newOptionRows, setNewOptionRows] = useState<NewOptionRow[]>([])
+ const [editingOption, setEditingOption] = useState<DocumentClassOption | null>(null)
+
+ // 하위 옵션 목록 로드
+ const loadOptions = async () => {
+ try {
+ setLoading(true)
+ const result = await getDocumentClassOptions(comboBoxOption.id)
+ if (result.success && result.data) {
+ setOptions(result.data)
+ } else {
+ toast.error("하위 옵션 목록을 불러오는데 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("하위 옵션 로드 실패:", error)
+ toast.error("하위 옵션 목록을 불러오는데 실패했습니다.")
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ useEffect(() => {
+ if (open) {
+ loadOptions()
+ }
+ }, [open, comboBoxOption.id])
+
+ // 새 행 추가
+ const addNewRow = () => {
+ const newRow: NewOptionRow = {
+ id: `new-${Date.now()}-${Math.random()}`,
+ optionValue: "",
+ optionCode: "",
+ sortOrder: options.length + newOptionRows.length + 1,
+ }
+ setNewOptionRows(prev => [...prev, newRow])
+ }
+
+ // 새 행 삭제
+ const removeNewRow = (id: string) => {
+ setNewOptionRows(prev => prev.filter(row => row.id !== id))
+ }
+
+ // 새 행 업데이트
+ const updateNewRow = (id: string, field: keyof NewOptionRow, value: string | number) => {
+ setNewOptionRows(prev =>
+ prev.map(row =>
+ row.id === id ? { ...row, [field]: value } : row
+ )
+ )
+ }
+
+ // 새 하위 옵션 저장
+ const handleSaveNewOptions = async () => {
+ const validRows = newOptionRows.filter(row => row.optionValue.trim())
+
+ if (validRows.length === 0) {
+ toast.error("최소 하나의 옵션 값을 입력해주세요.")
+ return
+ }
+
+ try {
+ let successCount = 0
+ let errorCount = 0
+
+ for (const row of validRows) {
+ try {
+ const result = await createDocumentClassOption({
+ comboBoxSettingId: comboBoxOption.id,
+ optionValue: row.optionValue.trim(),
+ optionCode: row.optionCode.trim() || undefined,
+ sortOrder: row.sortOrder,
+ })
+
+ if (result.success) {
+ successCount++
+ } else {
+ errorCount++
+ }
+ } catch (error) {
+ console.error("하위 옵션 추가 실패:", error)
+ errorCount++
+ }
+ }
+
+ if (successCount > 0) {
+ toast.success(`${successCount}개의 하위 옵션이 추가되었습니다.${errorCount > 0 ? ` (${errorCount}개 실패)` : ''}`)
+ setNewOptionRows([])
+ await loadOptions()
+ onSuccess()
+ } else {
+ toast.error("모든 하위 옵션 추가에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("하위 옵션 추가 실패:", error)
+ toast.error("하위 옵션 추가에 실패했습니다.")
+ }
+ }
+
+ // 기존 하위 옵션 수정
+ const handleUpdateOption = async (option: DocumentClassOption) => {
+ if (!option.optionValue.trim()) {
+ toast.error("옵션 값은 필수 입력 항목입니다.")
+ return
+ }
+
+ try {
+ const result = await updateDocumentClassOption({
+ id: option.id,
+ optionValue: option.optionValue.trim(),
+ optionCode: option.optionCode || undefined,
+ sortOrder: option.sortOrder,
+ isActive: option.isActive,
+ })
+
+ if (result.success) {
+ await loadOptions()
+ setEditingOption(null)
+ toast.success("하위 옵션이 수정되었습니다.")
+ } else {
+ toast.error("하위 옵션 수정에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("하위 옵션 수정 실패:", error)
+ toast.error("하위 옵션 수정에 실패했습니다.")
+ }
+ }
+
+ // 하위 옵션 삭제
+ const handleDeleteOption = async (optionId: number) => {
+ if (!confirm("정말로 이 하위 옵션을 삭제하시겠습니까?")) {
+ return
+ }
+
+ try {
+ const result = await deleteDocumentClassOption(optionId)
+ if (result.success) {
+ await loadOptions()
+ toast.success("하위 옵션이 삭제되었습니다.")
+ } else {
+ toast.error("하위 옵션 삭제에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("하위 옵션 삭제 실패:", error)
+ toast.error("하위 옵션 삭제에 실패했습니다.")
+ }
+ }
+
+ return (
+ <Sheet open={open} onOpenChange={onOpenChange}>
+ <SheetContent className="w-[600px] sm:max-w-[600px] overflow-y-auto">
+ <SheetHeader>
+ <SheetTitle>하위 옵션 관리</SheetTitle>
+ <SheetDescription>
+ {comboBoxOption.description} ({comboBoxOption.code})의 하위 옵션을 관리합니다.
+ </SheetDescription>
+ </SheetHeader>
+
+ <div className="space-y-4 mt-6">
+ {/* 새 하위 옵션 추가 */}
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <h4 className="text-sm font-medium">새 하위 옵션 추가</h4>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={addNewRow}
+ className="h-8"
+ >
+ <Plus className="h-4 w-4 mr-1" />
+ 옵션 추가
+ </Button>
+ </div>
+
+ {newOptionRows.length > 0 && (
+ <div className="border rounded-lg overflow-hidden">
+ <Table>
+ <TableHeader>
+ <TableRow className="bg-muted/30">
+ <TableHead className="w-[40%]">옵션 값 *</TableHead>
+ <TableHead className="w-[30%]">옵션 코드</TableHead>
+ <TableHead className="w-[20%]">순서</TableHead>
+ <TableHead className="w-[10%]"></TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {newOptionRows.map((row) => (
+ <TableRow key={row.id} className="hover:bg-muted/30">
+ <TableCell>
+ <Input
+ value={row.optionValue}
+ onChange={(e) => updateNewRow(row.id, "optionValue", e.target.value)}
+ placeholder="옵션 값"
+ className="border-0 focus-visible:ring-1 bg-transparent h-8"
+ />
+ </TableCell>
+ <TableCell>
+ <Input
+ value={row.optionCode}
+ onChange={(e) => updateNewRow(row.id, "optionCode", e.target.value)}
+ placeholder="옵션 코드 (선택)"
+ className="border-0 focus-visible:ring-1 bg-transparent h-8"
+ />
+ </TableCell>
+ <TableCell>
+ <Input
+ type="number"
+ value={row.sortOrder}
+ onChange={(e) => updateNewRow(row.id, "sortOrder", parseInt(e.target.value) || 0)}
+ className="border-0 focus-visible:ring-1 bg-transparent h-8"
+ />
+ </TableCell>
+ <TableCell>
+ <Button
+ onClick={() => removeNewRow(row.id)}
+ size="sm"
+ variant="ghost"
+ className="h-6 w-6 p-0"
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ <div className="p-3 border-t">
+ <Button
+ onClick={handleSaveNewOptions}
+ size="sm"
+ className="h-8"
+ >
+ <Save className="h-4 w-4 mr-1" />
+ 저장
+ </Button>
+ </div>
+ </div>
+ )}
+ </div>
+
+ {/* 기존 하위 옵션 목록 */}
+ <div className="space-y-2">
+ <h4 className="text-sm font-medium">기존 하위 옵션</h4>
+ <div className="border rounded-lg overflow-hidden">
+ <Table>
+ <TableHeader>
+ <TableRow className="bg-muted/30">
+ <TableHead className="w-[35%]">옵션 값</TableHead>
+ <TableHead className="w-[25%]">옵션 코드</TableHead>
+ <TableHead className="w-[15%]">순서</TableHead>
+ <TableHead className="w-[15%]">상태</TableHead>
+ <TableHead className="w-[10%]">작업</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {loading ? (
+ <TableRow>
+ <TableCell colSpan={5} className="text-center py-8">
+ 로딩 중...
+ </TableCell>
+ </TableRow>
+ ) : options.length === 0 ? (
+ <TableRow>
+ <TableCell colSpan={5} className="text-center text-muted-foreground py-8">
+ 등록된 하위 옵션이 없습니다.
+ </TableCell>
+ </TableRow>
+ ) : (
+ options.map((option) => (
+ <TableRow key={option.id} className="hover:bg-muted/30">
+ <TableCell className="text-sm">
+ {editingOption?.id === option.id ? (
+ <Input
+ value={editingOption.optionValue}
+ onChange={(e) => setEditingOption(prev => prev ? { ...prev, optionValue: e.target.value } : null)}
+ placeholder="옵션 값"
+ className="border-0 focus-visible:ring-1 bg-transparent h-8"
+ />
+ ) : (
+ option.optionValue
+ )}
+ </TableCell>
+ <TableCell className="text-sm">
+ {editingOption?.id === option.id ? (
+ <Input
+ value={editingOption.optionCode || ""}
+ onChange={(e) => setEditingOption(prev => prev ? { ...prev, optionCode: e.target.value } : null)}
+ placeholder="옵션 코드"
+ className="border-0 focus-visible:ring-1 bg-transparent h-8"
+ />
+ ) : (
+ option.optionCode || "-"
+ )}
+ </TableCell>
+ <TableCell className="text-sm">
+ {editingOption?.id === option.id ? (
+ <Input
+ type="number"
+ value={editingOption.sortOrder}
+ onChange={(e) => setEditingOption(prev => prev ? { ...prev, sortOrder: parseInt(e.target.value) || 0 } : null)}
+ className="border-0 focus-visible:ring-1 bg-transparent h-8"
+ />
+ ) : (
+ option.sortOrder
+ )}
+ </TableCell>
+ <TableCell className="text-sm">
+ <span className={`px-2 py-1 rounded text-xs ${
+ option.isActive
+ ? "bg-green-100 text-green-800"
+ : "bg-red-100 text-red-800"
+ }`}>
+ {option.isActive ? "활성" : "비활성"}
+ </span>
+ </TableCell>
+ <TableCell className="text-sm">
+ {editingOption?.id === option.id ? (
+ <div className="flex gap-1">
+ <Button
+ onClick={() => handleUpdateOption(editingOption)}
+ size="sm"
+ variant="outline"
+ className="h-6 px-2 text-xs"
+ >
+ 저장
+ </Button>
+ <Button
+ onClick={() => setEditingOption(null)}
+ size="sm"
+ variant="ghost"
+ className="h-6 px-2 text-xs"
+ >
+ 취소
+ </Button>
+ </div>
+ ) : (
+ <div className="flex gap-1">
+ <Button
+ onClick={() => setEditingOption(option)}
+ size="sm"
+ variant="outline"
+ className="h-6 px-2 text-xs"
+ >
+ 수정
+ </Button>
+ <Button
+ onClick={() => handleDeleteOption(option.id)}
+ size="sm"
+ variant="ghost"
+ className="h-6 px-2 text-xs text-red-600 hover:text-red-700"
+ >
+ <Trash2 className="h-3 w-3" />
+ </Button>
+ </div>
+ )}
+ </TableCell>
+ </TableRow>
+ ))
+ )}
+ </TableBody>
+ </Table>
+ </div>
+ </div>
+ </div>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/combo-box-settings/validation.ts b/lib/docu-list-rule/combo-box-settings/validation.ts
new file mode 100644
index 00000000..a83651be
--- /dev/null
+++ b/lib/docu-list-rule/combo-box-settings/validation.ts
@@ -0,0 +1,12 @@
+import { createSearchParamsCache } from "nuqs/server";
+import { parseAsInteger, parseAsString, parseAsArrayOf, parseAsStringEnum } from "nuqs/server";
+import { getSortingStateParser, getFiltersStateParser } from "@/lib/parsers";
+
+export const searchParamsComboBoxOptionsCache = createSearchParamsCache({
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<any>(),
+ filters: getFiltersStateParser(),
+ search: parseAsString.withDefault(""),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+}); \ No newline at end of file
diff --git a/lib/docu-list-rule/document-class/service.ts b/lib/docu-list-rule/document-class/service.ts
new file mode 100644
index 00000000..04dfa50e
--- /dev/null
+++ b/lib/docu-list-rule/document-class/service.ts
@@ -0,0 +1,462 @@
+"use server"
+
+import { revalidatePath } from "next/cache"
+import db from "@/db/db"
+import { documentClasses, documentClassOptions, codeGroups } from "@/db/schema/docu-list-rule"
+import { eq, desc, asc, sql } from "drizzle-orm"
+
+// Document Class 목록 조회 (A Class, B Class 등)
+export async function getDocumentClassCodeGroups(input: {
+ page: number
+ perPage: number
+ search?: string
+ sort?: Array<{ id: string; desc: boolean }>
+ filters?: Array<{ id: string; value: string }>
+ joinOperator?: "and" | "or"
+ flags?: string[]
+ classId?: string
+ description?: string
+ isActive?: string
+}) {
+ try {
+ const { page, perPage, sort, search } = input
+ const offset = (page - 1) * perPage
+
+ // 기본 조건
+ let whereConditions = sql`${documentClasses.isActive} = true`
+
+ // 검색 조건
+ if (search) {
+ const searchTerm = `%${search}%`
+ whereConditions = sql`${whereConditions} AND (
+ ${documentClasses.code} ILIKE ${searchTerm} OR
+ ${documentClasses.value} ILIKE ${searchTerm} OR
+ ${documentClasses.description} ILIKE ${searchTerm}
+ )`
+ }
+
+ // 정렬
+ let orderBy = sql`${documentClasses.createdAt} DESC`
+ if (sort && sort.length > 0) {
+ const sortField = sort[0]
+ const direction = sortField.desc ? sql`DESC` : sql`ASC`
+
+ switch (sortField.id) {
+ case "code":
+ orderBy = sql`${documentClasses.code} ${direction}`
+ break
+ case "value":
+ orderBy = sql`${documentClasses.value} ${direction}`
+ break
+ case "description":
+ orderBy = sql`${documentClasses.description} ${direction}`
+ break
+ case "createdAt":
+ orderBy = sql`${documentClasses.createdAt} ${direction}`
+ break
+ default:
+ orderBy = sql`${documentClasses.createdAt} DESC`
+ }
+ }
+
+ // 데이터 조회
+ const data = await db
+ .select({
+ id: documentClasses.id,
+ code: documentClasses.code,
+ value: documentClasses.value,
+ description: documentClasses.description,
+ isActive: documentClasses.isActive,
+ createdAt: documentClasses.createdAt,
+ updatedAt: documentClasses.updatedAt,
+ })
+ .from(documentClasses)
+ .where(whereConditions)
+ .orderBy(orderBy)
+ .limit(perPage)
+ .offset(offset)
+
+ // 총 개수 조회
+ const [{ count: total }] = await db
+ .select({ count: sql`count(*)` })
+ .from(documentClasses)
+ .where(whereConditions)
+
+ const pageCount = Math.ceil(Number(total) / perPage)
+
+ return {
+ success: true,
+ data,
+ pageCount,
+ }
+ } catch (error) {
+ console.error("Error fetching document classes:", error)
+ return {
+ success: false,
+ error: "Failed to fetch document classes",
+ data: [],
+ pageCount: 0,
+ }
+ }
+}
+
+// Document Class 생성
+export async function createDocumentClassCodeGroup(input: {
+ value: string
+ description?: string
+}) {
+ try {
+ // Value 자동 변환: "A", "AB", "A Class", "A CLASS" 등을 "A Class", "AB Class" 형태로 변환
+ const formatValue = (value: string): string => {
+ // 공백 제거 및 대소문자 정규화
+ const cleaned = value.trim().toLowerCase()
+
+ // "class"가 포함되어 있으면 제거
+ const withoutClass = cleaned.replace(/\s*class\s*/g, '')
+
+ // 알파벳과 숫자만 추출
+ const letters = withoutClass.replace(/[^a-z0-9]/g, '')
+
+ if (letters.length === 0) {
+ return value.trim() // 변환할 수 없으면 원본 반환
+ }
+
+ // 첫 글자를 대문자로 변환하고 "Class" 추가
+ return letters.charAt(0).toUpperCase() + letters.slice(1) + " Class"
+ }
+
+ const formattedValue = formatValue(input.value)
+
+ // 자동으로 code 생성 (예: "DOC_CLASS_001", "DOC_CLASS_002" 등)
+ const existingClasses = await db
+ .select({ code: documentClasses.code })
+ .from(documentClasses)
+ .orderBy(desc(documentClasses.code))
+
+ let newCode = "DOC_CLASS_001"
+ if (existingClasses.length > 0) {
+ const lastClass = existingClasses[0]
+ if (lastClass.code) {
+ const lastNumber = parseInt(lastClass.code.replace("DOC_CLASS_", "")) || 0
+ newCode = `DOC_CLASS_${String(lastNumber + 1).padStart(3, '0')}`
+ }
+ }
+
+ // Code Group이 존재하는지 확인
+ const existingCodeGroup = await db
+ .select({ id: codeGroups.id })
+ .from(codeGroups)
+ .where(eq(codeGroups.groupId, 'DOC_CLASS'))
+ .limit(1)
+
+ let codeGroupId: number | null = null
+
+ if (existingCodeGroup.length === 0) {
+ // Code Group이 없으면 자동으로 생성
+ const [newCodeGroup] = await db
+ .insert(codeGroups)
+ .values({
+ groupId: 'DOC_CLASS',
+ description: 'Document Class',
+ codeFormat: 'DOC_CLASS_###',
+ expressions: '^DOC_CLASS_\\d{3}$',
+ controlType: 'Combobox',
+ isActive: true,
+ })
+ .returning({ id: codeGroups.id })
+
+ codeGroupId = newCodeGroup.id
+ } else {
+ codeGroupId = existingCodeGroup[0].id
+ }
+
+ const [newDocumentClass] = await db
+ .insert(documentClasses)
+ .values({
+ code: newCode,
+ value: formattedValue,
+ description: input.description || "",
+ codeGroupId: codeGroupId,
+ isActive: true,
+ })
+ .returning({ id: documentClasses.id })
+
+ revalidatePath("/evcp/docu-list-rule/document-class")
+ revalidatePath("/evcp/docu-list-rule/code-groups")
+
+ return {
+ success: true,
+ data: newDocumentClass,
+ message: "Document Class created successfully"
+ }
+ } catch (error) {
+ console.error("Error creating document class:", error)
+ return {
+ success: false,
+ error: "Failed to create document class"
+ }
+ }
+}
+
+// Document Class 수정
+export async function updateDocumentClassCodeGroup(input: {
+ id: number
+ value: string
+ description?: string
+}) {
+ try {
+ // Value 자동 변환: "A", "AB", "A Class", "A CLASS" 등을 "A Class", "AB Class" 형태로 변환
+ const formatValue = (value: string): string => {
+ // 공백 제거 및 대소문자 정규화
+ const cleaned = value.trim().toLowerCase()
+
+ // "class"가 포함되어 있으면 제거
+ const withoutClass = cleaned.replace(/\s*class\s*/g, '')
+
+ // 알파벳과 숫자만 추출
+ const letters = withoutClass.replace(/[^a-z0-9]/g, '')
+
+ if (letters.length === 0) {
+ return value.trim() // 변환할 수 없으면 원본 반환
+ }
+
+ // 첫 글자를 대문자로 변환하고 "Class" 추가
+ return letters.charAt(0).toUpperCase() + letters.slice(1) + " Class"
+ }
+
+ const formattedValue = formatValue(input.value)
+
+ const [updatedDocumentClass] = await db
+ .update(documentClasses)
+ .set({
+ value: formattedValue,
+ description: input.description || "",
+ updatedAt: new Date(),
+ })
+ .where(eq(documentClasses.id, input.id))
+ .returning({ id: documentClasses.id })
+
+ revalidatePath("/evcp/docu-list-rule/document-class")
+
+ return {
+ success: true,
+ data: updatedDocumentClass,
+ message: "Document Class updated successfully"
+ }
+ } catch (error) {
+ console.error("Error updating document class:", error)
+ return {
+ success: false,
+ error: "Failed to update document class"
+ }
+ }
+}
+
+// Document Class 삭제
+export async function deleteDocumentClassCodeGroup(id: number) {
+ try {
+ // 삭제할 Document Class의 codeGroupId 확인
+ const documentClassToDelete = await db
+ .select({ codeGroupId: documentClasses.codeGroupId })
+ .from(documentClasses)
+ .where(eq(documentClasses.id, id))
+ .limit(1)
+
+ const [deletedDocumentClass] = await db
+ .delete(documentClasses)
+ .where(eq(documentClasses.id, id))
+ .returning({ id: documentClasses.id })
+
+ // 같은 codeGroupId를 가진 다른 Document Class가 있는지 확인
+ if (documentClassToDelete.length > 0 && documentClassToDelete[0].codeGroupId) {
+ const remainingClasses = await db
+ .select({ id: documentClasses.id })
+ .from(documentClasses)
+ .where(eq(documentClasses.codeGroupId, documentClassToDelete[0].codeGroupId))
+ .limit(1)
+
+ // 더 이상 Document Class가 없으면 Code Group도 삭제
+ if (remainingClasses.length === 0) {
+ await db
+ .delete(codeGroups)
+ .where(eq(codeGroups.id, documentClassToDelete[0].codeGroupId))
+ }
+ }
+
+ revalidatePath("/evcp/docu-list-rule/document-class")
+ revalidatePath("/evcp/docu-list-rule/code-groups")
+
+ return {
+ success: true,
+ data: deletedDocumentClass,
+ message: "Document Class deleted successfully"
+ }
+ } catch (error) {
+ console.error("Error deleting document class:", error)
+ return {
+ success: false,
+ error: "Failed to delete document class"
+ }
+ }
+}
+
+// Document Class 옵션 목록 조회
+export async function getDocumentClassSubOptions(documentClassId: number) {
+ try {
+ const data = await db
+ .select({
+ id: documentClassOptions.id,
+ documentClassId: documentClassOptions.documentClassId,
+ optionValue: documentClassOptions.optionValue,
+ optionCode: documentClassOptions.optionCode,
+ sortOrder: documentClassOptions.sortOrder,
+ isActive: documentClassOptions.isActive,
+ createdAt: documentClassOptions.createdAt,
+ updatedAt: documentClassOptions.updatedAt,
+ })
+ .from(documentClassOptions)
+ .where(eq(documentClassOptions.documentClassId, documentClassId))
+ .orderBy(asc(documentClassOptions.sortOrder), asc(documentClassOptions.optionValue))
+
+ return {
+ success: true,
+ data,
+ }
+ } catch (error) {
+ console.error("Error fetching document class options:", error)
+ return {
+ success: false,
+ error: "Failed to fetch document class options",
+ data: [],
+ }
+ }
+}
+
+// Document Class 옵션 생성
+export async function createDocumentClassOptionItem(input: {
+ documentClassId: number
+ optionValue: string
+}) {
+ try {
+ // Document Class 정보 조회하여 Value 가져오기
+ const documentClass = await db
+ .select({ value: documentClasses.value })
+ .from(documentClasses)
+ .where(eq(documentClasses.id, input.documentClassId))
+ .limit(1)
+
+ if (!documentClass.length) {
+ return {
+ success: false,
+ error: "Document Class not found"
+ }
+ }
+
+ // Value에서 클래스명 추출 (예: "A Class" → "A")
+ const classValue = documentClass[0].value
+ const className = classValue.split(' ')[0] // "A Class"에서 "A" 추출
+
+ // 자동으로 optionCode 생성 (예: "A_OP_01", "A_OP_02" 등)
+ const existingOptions = await db
+ .select({ optionCode: documentClassOptions.optionCode })
+ .from(documentClassOptions)
+ .where(eq(documentClassOptions.documentClassId, input.documentClassId))
+ .orderBy(desc(documentClassOptions.optionCode))
+
+ let newOptionCode = `${className}_OP_01`
+ if (existingOptions.length > 0) {
+ const lastOption = existingOptions[0]
+ if (lastOption.optionCode) {
+ // "A_OP_01" 형태에서 숫자 추출
+ const match = lastOption.optionCode.match(/_OP_(\d+)$/)
+ if (match) {
+ const lastNumber = parseInt(match[1]) || 0
+ newOptionCode = `${className}_OP_${String(lastNumber + 1).padStart(2, '0')}`
+ } else {
+ // 기존 형식이 다른 경우 01부터 시작
+ newOptionCode = `${className}_OP_01`
+ }
+ }
+ }
+
+ const [newOption] = await db
+ .insert(documentClassOptions)
+ .values({
+ documentClassId: input.documentClassId,
+ optionValue: input.optionValue,
+ optionCode: newOptionCode,
+ sortOrder: 0,
+ isActive: true,
+ })
+ .returning({ id: documentClassOptions.id })
+
+ revalidatePath("/evcp/docu-list-rule/document-class")
+
+ return {
+ success: true,
+ data: newOption,
+ message: "Document Class option created successfully"
+ }
+ } catch (error) {
+ console.error("Error creating document class option:", error)
+ return {
+ success: false,
+ error: "Failed to create document class option"
+ }
+ }
+}
+
+// Document Class 옵션 수정
+export async function updateDocumentClassOption(input: {
+ id: number
+ optionValue: string
+}) {
+ try {
+ const [updatedOption] = await db
+ .update(documentClassOptions)
+ .set({
+ optionValue: input.optionValue,
+ updatedAt: new Date(),
+ })
+ .where(eq(documentClassOptions.id, input.id))
+ .returning({ id: documentClassOptions.id })
+
+ revalidatePath("/evcp/docu-list-rule/document-class")
+
+ return {
+ success: true,
+ data: updatedOption,
+ message: "Document Class option updated successfully"
+ }
+ } catch (error) {
+ console.error("Error updating document class option:", error)
+ return {
+ success: false,
+ error: "Failed to update document class option"
+ }
+ }
+}
+
+// Document Class 옵션 삭제
+export async function deleteDocumentClassOption(id: number) {
+ try {
+ const [deletedOption] = await db
+ .delete(documentClassOptions)
+ .where(eq(documentClassOptions.id, id))
+ .returning({ id: documentClassOptions.id })
+
+ revalidatePath("/evcp/docu-list-rule/document-class")
+
+ return {
+ success: true,
+ data: deletedOption,
+ message: "Document Class option deleted successfully"
+ }
+ } catch (error) {
+ console.error("Error deleting document class option:", error)
+ return {
+ success: false,
+ error: "Failed to delete document class option"
+ }
+ }
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/document-class/table/delete-document-class-dialog.tsx b/lib/docu-list-rule/document-class/table/delete-document-class-dialog.tsx
new file mode 100644
index 00000000..677fe8ef
--- /dev/null
+++ b/lib/docu-list-rule/document-class/table/delete-document-class-dialog.tsx
@@ -0,0 +1,154 @@
+"use client"
+
+import * as React from "react"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Trash } from "lucide-react"
+import { toast } from "sonner"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+
+import { deleteDocumentClassCodeGroup } from "@/lib/docu-list-rule/document-class/service"
+import { documentClasses } from "@/db/schema/documentClasses"
+
+interface DeleteDocumentClassDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ documentClasses: Row<typeof documentClasses.$inferSelect>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeleteDocumentClassDialog({
+ documentClasses,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeleteDocumentClassDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onDelete() {
+ startDeleteTransition(async () => {
+ try {
+ // 각 Document Class를 순차적으로 삭제
+ for (const documentClass of documentClasses) {
+ const result = await deleteDocumentClassCodeGroup(documentClass.id)
+ if (!result.success) {
+ toast.error(`Document Class ${documentClass.code} 삭제 실패: ${result.error}`)
+ return
+ }
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("Document Class가 성공적으로 삭제되었습니다.")
+ onSuccess?.()
+ } catch (error) {
+ console.error("Delete error:", error)
+ toast.error("Document Class 삭제 중 오류가 발생했습니다.")
+ }
+ })
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제 ({documentClasses.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle>
+ <DialogDescription>
+ 이 작업은 되돌릴 수 없습니다. 선택된{" "}
+ <span className="font-medium">{documentClasses.length}</span>
+ 개의 Document Class를 서버에서 영구적으로 삭제합니다.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">취소</Button>
+ </DialogClose>
+ <Button
+ aria-label="선택된 행 삭제"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 삭제
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제 ({documentClasses.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle>
+ <DrawerDescription>
+ 이 작업은 되돌릴 수 없습니다. 선택된{" "}
+ <span className="font-medium">{documentClasses.length}</span>
+ 개의 Document Class를 서버에서 영구적으로 삭제합니다.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">취소</Button>
+ </DrawerClose>
+ <Button
+ aria-label="선택된 행 삭제"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ 삭제
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/document-class/table/delete-document-class-option-dialog.tsx b/lib/docu-list-rule/document-class/table/delete-document-class-option-dialog.tsx
new file mode 100644
index 00000000..f0fcbc34
--- /dev/null
+++ b/lib/docu-list-rule/document-class/table/delete-document-class-option-dialog.tsx
@@ -0,0 +1,152 @@
+"use client"
+
+import * as React from "react"
+import { Loader, Trash } from "lucide-react"
+import { toast } from "sonner"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+
+import { deleteDocumentClassOption } from "@/lib/docu-list-rule/document-class/service"
+import { documentClassOptions } from "@/db/schema/documentClasses"
+
+interface DeleteDocumentClassOptionDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ options: typeof documentClassOptions.$inferSelect[]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeleteDocumentClassOptionDialog({
+ options,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeleteDocumentClassOptionDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onDelete() {
+ startDeleteTransition(async () => {
+ try {
+ for (const option of options) {
+ const result = await deleteDocumentClassOption(option.id)
+ if (!result.success) {
+ toast.error(`Document Class 옵션 삭제 실패: ${result.error}`)
+ return
+ }
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("Document Class 옵션이 성공적으로 삭제되었습니다.")
+ onSuccess?.()
+ } catch (error) {
+ console.error("Delete error:", error)
+ toast.error("Document Class 옵션 삭제 중 오류가 발생했습니다.")
+ }
+ })
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제 ({options.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle>
+ <DialogDescription>
+ 이 작업은 되돌릴 수 없습니다. 선택된{" "}
+ <span className="font-medium">{options.length}</span>
+ 개의 Document Class 옵션을 서버에서 영구적으로 삭제합니다.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">취소</Button>
+ </DialogClose>
+ <Button
+ aria-label="선택된 행 삭제"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 삭제
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제 ({options.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle>
+ <DrawerDescription>
+ 이 작업은 되돌릴 수 없습니다. 선택된{" "}
+ <span className="font-medium">{options.length}</span>
+ 개의 Document Class 옵션을 서버에서 영구적으로 삭제합니다.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">취소</Button>
+ </DrawerClose>
+ <Button
+ aria-label="선택된 행 삭제"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ 삭제
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/document-class/table/document-class-add-dialog.tsx b/lib/docu-list-rule/document-class/table/document-class-add-dialog.tsx
new file mode 100644
index 00000000..ef9c50a8
--- /dev/null
+++ b/lib/docu-list-rule/document-class/table/document-class-add-dialog.tsx
@@ -0,0 +1,145 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+import * as z from "zod"
+import { Plus } from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+
+
+import { createDocumentClassCodeGroup } from "@/lib/docu-list-rule/document-class/service"
+
+const createDocumentClassSchema = z.object({
+ value: z.string().min(1, "Value는 필수입니다."),
+ description: z.string().optional(),
+})
+
+type CreateDocumentClassSchema = z.infer<typeof createDocumentClassSchema>
+
+interface DocumentClassAddDialogProps {
+ onSuccess?: () => void
+}
+
+export function DocumentClassAddDialog({
+ onSuccess,
+}: DocumentClassAddDialogProps) {
+ const [open, setOpen] = React.useState(false)
+ const [isPending, startTransition] = React.useTransition()
+
+ const form = useForm<CreateDocumentClassSchema>({
+ resolver: zodResolver(createDocumentClassSchema),
+ defaultValues: {
+ value: "",
+ description: "",
+ },
+ mode: "onChange"
+ })
+
+ async function onSubmit(input: CreateDocumentClassSchema) {
+ startTransition(async () => {
+ try {
+ const result = await createDocumentClassCodeGroup({
+ value: input.value,
+ description: input.description,
+ })
+
+ if (result.success) {
+ toast.success("Document Class가 생성되었습니다.")
+ form.reset()
+ setOpen(false)
+ onSuccess?.()
+ } else {
+ toast.error(result.error || "생성에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("Create error:", error)
+ toast.error("Document Class 생성 중 오류가 발생했습니다.")
+ }
+ })
+ }
+
+ const handleCancel = () => {
+ form.reset()
+ setOpen(false)
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Plus className="mr-2 h-4 w-4" />
+ Add
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="sm:max-w-[425px]">
+ <DialogHeader>
+ <DialogTitle>Document Class 추가</DialogTitle>
+ <DialogDescription>
+ 새로운 Document Class를 추가합니다.
+ <span className="text-red-500 mt-1 block text-sm">* 표시된 항목은 필수 입력사항입니다.</span>
+ </DialogDescription>
+ </DialogHeader>
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+ <FormField
+ control={form.control}
+ name="value"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Value *</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="예: A Class" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Description</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="예: A Class Description (선택사항)" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <DialogFooter>
+ <Button type="button" variant="outline" onClick={handleCancel}>
+ 취소
+ </Button>
+ <Button type="submit" disabled={isPending || !form.formState.isValid}>
+ {isPending ? "추가 중..." : "추가"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/document-class/table/document-class-edit-sheet.tsx b/lib/docu-list-rule/document-class/table/document-class-edit-sheet.tsx
new file mode 100644
index 00000000..97729caa
--- /dev/null
+++ b/lib/docu-list-rule/document-class/table/document-class-edit-sheet.tsx
@@ -0,0 +1,160 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+import * as z from "zod"
+import { Loader } from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+
+import { updateDocumentClassCodeGroup } from "@/lib/docu-list-rule/document-class/service"
+import { documentClasses } from "@/db/schema/documentClasses"
+
+const updateDocumentClassSchema = z.object({
+ value: z.string().min(1, "Value는 필수입니다."),
+ description: z.string().optional(),
+})
+
+type UpdateDocumentClassSchema = z.infer<typeof updateDocumentClassSchema>
+
+interface DocumentClassEditSheetProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ data: typeof documentClasses.$inferSelect | null
+ onSuccess?: () => void
+}
+
+export function DocumentClassEditSheet({
+ open,
+ onOpenChange,
+ data,
+ onSuccess,
+}: DocumentClassEditSheetProps) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+
+ const form = useForm<UpdateDocumentClassSchema>({
+ resolver: zodResolver(updateDocumentClassSchema),
+ defaultValues: {
+ value: data?.value || "",
+ description: data?.description || "",
+ },
+ mode: "onChange"
+ })
+
+ React.useEffect(() => {
+ if (data) {
+ form.reset({
+ value: data.value || "",
+ description: data.description || "",
+ })
+ }
+ }, [data, form])
+
+ async function onSubmit(input: UpdateDocumentClassSchema) {
+ if (!data) return
+
+ startUpdateTransition(async () => {
+ try {
+ const result = await updateDocumentClassCodeGroup({
+ id: data.id,
+ value: input.value,
+ description: input.description,
+ })
+
+ if (result.success) {
+ toast.success("Document Class가 성공적으로 수정되었습니다.")
+ onSuccess?.()
+ onOpenChange(false)
+ } else {
+ toast.error(result.error || "수정에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("Update error:", error)
+ toast.error("Document Class 수정 중 오류가 발생했습니다.")
+ }
+ })
+ }
+
+ return (
+ <Sheet open={open} onOpenChange={onOpenChange}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>Document Class 수정</SheetTitle>
+ <SheetDescription>
+ Document Class 정보를 수정하고 변경사항을 저장하세요
+ </SheetDescription>
+ </SheetHeader>
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
+ <FormField
+ control={form.control}
+ name="value"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Value</FormLabel>
+ <FormControl>
+ <Input placeholder="예: A Class" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Description</FormLabel>
+ <FormControl>
+ <Input placeholder="예: Document Class_1 (선택사항)" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ 취소
+ </Button>
+ </SheetClose>
+ <Button
+ type="submit"
+ disabled={isUpdatePending || !form.formState.isValid}
+ >
+ {isUpdatePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 저장
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/document-class/table/document-class-option-add-dialog.tsx b/lib/docu-list-rule/document-class/table/document-class-option-add-dialog.tsx
new file mode 100644
index 00000000..5bfcbd33
--- /dev/null
+++ b/lib/docu-list-rule/document-class/table/document-class-option-add-dialog.tsx
@@ -0,0 +1,137 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+import * as z from "zod"
+import { Plus } from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+
+import { createDocumentClassOptionItem } from "@/lib/docu-list-rule/document-class/service"
+import { documentClasses } from "@/db/schema/documentClasses"
+
+const createDocumentClassOptionSchema = z.object({
+ optionValue: z.string().min(1, "옵션 값은 필수입니다."),
+})
+
+type CreateDocumentClassOptionSchema = z.infer<typeof createDocumentClassOptionSchema>
+
+interface DocumentClassOptionAddDialogProps {
+ selectedDocumentClass: typeof documentClasses.$inferSelect | null
+ onSuccess?: () => void
+}
+
+export function DocumentClassOptionAddDialog({
+ selectedDocumentClass,
+ onSuccess,
+}: DocumentClassOptionAddDialogProps) {
+ const [open, setOpen] = React.useState(false)
+ const [isPending, startTransition] = React.useTransition()
+
+ const form = useForm<CreateDocumentClassOptionSchema>({
+ resolver: zodResolver(createDocumentClassOptionSchema),
+ defaultValues: {
+ optionValue: "",
+ },
+ mode: "onChange"
+ })
+
+ async function onSubmit(input: CreateDocumentClassOptionSchema) {
+ if (!selectedDocumentClass) {
+ toast.error("Document Class가 선택되지 않았습니다.")
+ return
+ }
+
+ startTransition(async () => {
+ try {
+ const result = await createDocumentClassOptionItem({
+ documentClassId: selectedDocumentClass.id,
+ optionValue: input.optionValue,
+ })
+
+ if (result.success) {
+ toast.success("Document Class 옵션이 생성되었습니다.")
+ form.reset()
+ setOpen(false)
+ onSuccess?.()
+ } else {
+ toast.error(result.error || "생성에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("Create error:", error)
+ toast.error("Document Class 옵션 생성 중 오류가 발생했습니다.")
+ }
+ })
+ }
+
+ const handleCancel = () => {
+ form.reset()
+ setOpen(false)
+ }
+
+ return (
+ <Dialog open={open && !!selectedDocumentClass} onOpenChange={setOpen}>
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm" disabled={!selectedDocumentClass}>
+ <Plus className="mr-2 h-4 w-4" />
+ 옵션 추가
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="sm:max-w-[425px]">
+ <DialogHeader>
+ <DialogTitle>Document Class 옵션 추가</DialogTitle>
+ <DialogDescription>
+ {selectedDocumentClass?.description || "Document Class"}에 새로운 옵션을 추가합니다.
+ <span className="text-red-500 mt-1 block text-sm">* 표시된 항목은 필수 입력사항입니다.</span>
+ </DialogDescription>
+ </DialogHeader>
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+ <FormField
+ control={form.control}
+ name="optionValue"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>옵션 값 *</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="옵션 값을 입력하세요" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <DialogFooter>
+ <Button type="button" variant="outline" onClick={handleCancel}>
+ 취소
+ </Button>
+ <Button type="submit" disabled={isPending || !form.formState.isValid}>
+ {isPending ? "추가 중..." : "추가"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/document-class/table/document-class-option-edit-sheet.tsx b/lib/docu-list-rule/document-class/table/document-class-option-edit-sheet.tsx
new file mode 100644
index 00000000..6f6e7a87
--- /dev/null
+++ b/lib/docu-list-rule/document-class/table/document-class-option-edit-sheet.tsx
@@ -0,0 +1,143 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+import * as z from "zod"
+import { Loader } from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { updateDocumentClassOption } from "@/lib/docu-list-rule/document-class/service"
+import { documentClassOptions } from "@/db/schema/documentClasses"
+
+const updateDocumentClassOptionSchema = z.object({
+ optionValue: z.string().min(1, "옵션 값은 필수입니다."),
+})
+
+type UpdateDocumentClassOptionSchema = z.infer<typeof updateDocumentClassOptionSchema>
+
+interface DocumentClassOptionEditSheetProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ data: typeof documentClassOptions.$inferSelect | null
+ onSuccess?: () => void
+}
+
+export function DocumentClassOptionEditSheet({
+ open,
+ onOpenChange,
+ data,
+ onSuccess,
+}: DocumentClassOptionEditSheetProps) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+
+ const form = useForm<UpdateDocumentClassOptionSchema>({
+ resolver: zodResolver(updateDocumentClassOptionSchema),
+ defaultValues: {
+ optionValue: data?.optionValue || "",
+ },
+ mode: "onChange"
+ })
+
+ React.useEffect(() => {
+ if (data) {
+ form.reset({
+ optionValue: data.optionValue || "",
+ })
+ }
+ }, [data, form])
+
+ async function onSubmit(input: UpdateDocumentClassOptionSchema) {
+ if (!data) return
+
+ startUpdateTransition(async () => {
+ try {
+ const result = await updateDocumentClassOption({
+ id: data.id,
+ optionValue: input.optionValue,
+ })
+
+ if (result.success) {
+ toast.success("Document Class 옵션이 성공적으로 수정되었습니다.")
+ onSuccess?.()
+ onOpenChange(false)
+ } else {
+ toast.error(result.error || "수정에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("Update error:", error)
+ toast.error("Document Class 옵션 수정 중 오류가 발생했습니다.")
+ }
+ })
+ }
+
+ return (
+ <Sheet open={open} onOpenChange={onOpenChange}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>Document Class 옵션 수정</SheetTitle>
+ <SheetDescription>
+ Document Class 옵션 정보를 수정하고 변경사항을 저장하세요
+ </SheetDescription>
+ </SheetHeader>
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
+ <FormField
+ control={form.control}
+ name="optionValue"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>옵션 값</FormLabel>
+ <FormControl>
+ <Input placeholder="옵션 값을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ 취소
+ </Button>
+ </SheetClose>
+ <Button
+ type="submit"
+ disabled={isUpdatePending || !form.formState.isValid}
+ >
+ {isUpdatePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 저장
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/document-class/table/document-class-options-table-columns.tsx b/lib/docu-list-rule/document-class/table/document-class-options-table-columns.tsx
new file mode 100644
index 00000000..c04a7b37
--- /dev/null
+++ b/lib/docu-list-rule/document-class/table/document-class-options-table-columns.tsx
@@ -0,0 +1,156 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Ellipsis } from "lucide-react"
+
+import { formatDateTime } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { documentClassOptions } from "@/db/schema/documentClasses"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<typeof documentClassOptions.$inferSelect> | null>>
+}
+
+/**
+ * tanstack table 컬럼 정의
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<typeof documentClassOptions.$inferSelect>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<typeof documentClassOptions.$inferSelect> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ maxSize: 30,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 2) actions 컬럼 (Dropdown 메뉴)
+ // ----------------------------------------------------------------
+ const actionsColumn: ColumnDef<typeof documentClassOptions.$inferSelect> = {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "update" })}
+ >
+ Edit
+ </DropdownMenuItem>
+
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "delete" })}
+ >
+ Delete
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ maxSize: 30,
+ }
+
+ // ----------------------------------------------------------------
+ // 3) 데이터 컬럼들
+ // ----------------------------------------------------------------
+ const dataColumns: ColumnDef<typeof documentClassOptions.$inferSelect>[] = [
+ {
+ accessorKey: "optionCode",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="코드" />
+ ),
+ meta: {
+ excelHeader: "코드",
+ type: "text",
+ },
+ cell: ({ row }) => row.getValue("optionCode") ?? "",
+ minSize: 80
+ },
+ {
+ accessorKey: "optionValue",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="옵션 값" />
+ ),
+ meta: {
+ excelHeader: "옵션 값",
+ type: "text",
+ },
+ cell: ({ row }) => row.getValue("optionValue") ?? "",
+ minSize: 80
+ },
+
+ {
+ accessorKey: "createdAt",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="생성일" />
+ ),
+ meta: {
+ excelHeader: "생성일",
+ type: "date",
+ },
+ cell: ({ row }) => {
+ const dateVal = row.getValue("createdAt") as Date
+ return formatDateTime(dateVal, "KR")
+ },
+ minSize: 80
+ }
+ ]
+
+ // ----------------------------------------------------------------
+ // 4) 최종 컬럼 배열: select, dataColumns, actions
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ ...dataColumns,
+ actionsColumn,
+ ]
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/document-class/table/document-class-options-table-toolbar.tsx b/lib/docu-list-rule/document-class/table/document-class-options-table-toolbar.tsx
new file mode 100644
index 00000000..5044d90d
--- /dev/null
+++ b/lib/docu-list-rule/document-class/table/document-class-options-table-toolbar.tsx
@@ -0,0 +1,43 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+
+import { DocumentClassOptionAddDialog } from "./document-class-option-add-dialog"
+import { DeleteDocumentClassOptionDialog } from "./delete-document-class-option-dialog"
+import { documentClasses, documentClassOptions } from "@/db/schema/documentClasses"
+
+interface DocumentClassOptionsTableToolbarActionsProps<TData> {
+ table: Table<TData>
+ selectedDocumentClass: typeof documentClasses.$inferSelect | null
+ onSuccess?: () => void
+}
+
+export function DocumentClassOptionsTableToolbarActions<TData>({
+ table,
+ selectedDocumentClass,
+ onSuccess,
+}: DocumentClassOptionsTableToolbarActionsProps<TData>) {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ const selectedOptions = selectedRows.map((row) => row.original as typeof documentClassOptions.$inferSelect)
+
+ return (
+ <div className="flex items-center gap-2">
+ {/** 선택된 로우가 있으면 삭제 다이얼로그 */}
+ {selectedOptions.length > 0 ? (
+ <DeleteDocumentClassOptionDialog
+ options={selectedOptions}
+ onSuccess={() => {
+ table.toggleAllRowsSelected(false)
+ onSuccess?.()
+ }}
+ />
+ ) : null}
+
+ <DocumentClassOptionAddDialog
+ selectedDocumentClass={selectedDocumentClass}
+ onSuccess={onSuccess}
+ />
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/document-class/table/document-class-options-table.tsx b/lib/docu-list-rule/document-class/table/document-class-options-table.tsx
new file mode 100644
index 00000000..644e3599
--- /dev/null
+++ b/lib/docu-list-rule/document-class/table/document-class-options-table.tsx
@@ -0,0 +1,176 @@
+"use client"
+
+import * as React from "react"
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import type { DataTableAdvancedFilterField, DataTableRowAction } from "@/types/table"
+
+import { getDocumentClassSubOptions } from "@/lib/docu-list-rule/document-class/service"
+import { getColumns } from "./document-class-options-table-columns"
+import { DocumentClassOptionEditSheet } from "./document-class-option-edit-sheet"
+import { DeleteDocumentClassOptionDialog } from "./delete-document-class-option-dialog"
+import { DocumentClassOptionsTableToolbarActions } from "./document-class-options-table-toolbar"
+import { documentClasses, documentClassOptions } from "@/db/schema/docu-list-rule"
+
+type DocumentClass = typeof documentClasses.$inferSelect
+
+interface DocumentClassOptionsTableProps {
+ selectedDocumentClass: DocumentClass | null
+ documentClasses: DocumentClass[]
+ onSelectDocumentClass: (documentClass: DocumentClass) => void
+}
+
+export function DocumentClassOptionsTable({
+ selectedDocumentClass,
+ documentClasses,
+ onSelectDocumentClass
+}: DocumentClassOptionsTableProps) {
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<typeof documentClassOptions.$inferSelect> | null>(null)
+
+ // 선택된 Document Class의 옵션 데이터 로드
+ const [options, setOptions] = React.useState<typeof documentClassOptions.$inferSelect[]>([])
+
+ // DB 등록 순서대로 정렬된 Document Classes
+ const sortedDocumentClasses = React.useMemo(() => {
+ return [...documentClasses].sort((a, b) => a.id - b.id)
+ }, [documentClasses])
+
+ const handleSuccess = React.useCallback(async () => {
+ // 옵션 테이블 새로고침
+ if (selectedDocumentClass) {
+ try {
+ const result = await getDocumentClassSubOptions(selectedDocumentClass.id)
+ if (result.success && result.data) {
+ setOptions(result.data)
+ }
+ } catch (error) {
+ console.error("Error refreshing options:", error)
+ }
+ }
+ }, [selectedDocumentClass])
+
+ const columns = React.useMemo(() => getColumns({ setRowAction }), [setRowAction])
+
+ // 고급 필터 필드 설정
+ const advancedFilterFields: DataTableAdvancedFilterField<typeof documentClassOptions.$inferSelect>[] = [
+ { id: "optionCode", label: "코드", type: "text" },
+ { id: "optionValue", label: "옵션 값", type: "text" },
+ { id: "createdAt", label: "생성일", type: "date" },
+ ]
+
+ const { table } = useDataTable({
+ data: options,
+ columns,
+ pageCount: 1,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ manualSorting: false,
+ initialState: {
+ sorting: [{ id: "id", desc: false }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ React.useEffect(() => {
+ const loadOptions = async () => {
+ if (!selectedDocumentClass) {
+ setOptions([])
+ return
+ }
+
+ try {
+ const result = await getDocumentClassSubOptions(selectedDocumentClass.id)
+ if (result.success && result.data) {
+ setOptions(result.data)
+ }
+ } catch (error) {
+ console.error("Error loading options:", error)
+ setOptions([])
+ }
+ }
+
+ loadOptions()
+ }, [selectedDocumentClass])
+
+ if (!selectedDocumentClass) {
+ return (
+ <div className="space-y-4">
+ <div className="flex gap-2">
+ {sortedDocumentClasses.map((documentClass) => (
+ <button
+ key={documentClass.id}
+ onClick={() => onSelectDocumentClass(documentClass)}
+ className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
+ selectedDocumentClass?.id === documentClass.id
+ ? "bg-primary text-primary-foreground"
+ : "bg-muted text-muted-foreground hover:bg-muted/80"
+ }`}
+ >
+ {documentClass.value}
+ </button>
+ ))}
+ </div>
+ <div className="text-center text-muted-foreground py-4">
+ Document Class를 선택하면 옵션을 관리할 수 있습니다.
+ </div>
+ </div>
+ )
+ }
+
+ return (
+ <>
+ <div className="space-y-2">
+ <div className="flex gap-2">
+ {sortedDocumentClasses.map((documentClass) => (
+ <button
+ key={documentClass.id}
+ onClick={() => onSelectDocumentClass(documentClass)}
+ className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
+ selectedDocumentClass?.id === documentClass.id
+ ? "bg-primary text-primary-foreground"
+ : "bg-muted text-muted-foreground hover:bg-muted/80"
+ }`}
+ >
+ {documentClass.value}
+ </button>
+ ))}
+ </div>
+
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ >
+ <DocumentClassOptionsTableToolbarActions
+ table={table}
+ selectedDocumentClass={selectedDocumentClass}
+ onSuccess={handleSuccess}
+ />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </div>
+
+ <DeleteDocumentClassOptionDialog
+ open={rowAction?.type === "delete"}
+ onOpenChange={() => setRowAction(null)}
+ options={rowAction?.row.original ? [rowAction?.row.original] : []}
+ showTrigger={false}
+ onSuccess={() => {
+ rowAction?.row.toggleSelected(false)
+ handleSuccess()
+ }}
+ />
+
+ <DocumentClassOptionEditSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ data={rowAction?.row.original ?? null}
+ onSuccess={handleSuccess}
+ />
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/document-class/table/document-class-table-columns.tsx b/lib/docu-list-rule/document-class/table/document-class-table-columns.tsx
new file mode 100644
index 00000000..6684d13a
--- /dev/null
+++ b/lib/docu-list-rule/document-class/table/document-class-table-columns.tsx
@@ -0,0 +1,169 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Ellipsis } from "lucide-react"
+
+import { formatDateTime } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { documentClasses } from "@/db/schema/docu-list-rule"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<typeof documentClasses.$inferSelect> | null>>
+}
+
+/**
+ * tanstack table 컬럼 정의
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<typeof documentClasses.$inferSelect>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<typeof documentClasses.$inferSelect> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ maxSize: 30,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 2) actions 컬럼 (Dropdown 메뉴)
+ // ----------------------------------------------------------------
+ const actionsColumn: ColumnDef<typeof documentClasses.$inferSelect> = {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "update" })}
+ >
+ Edit
+ </DropdownMenuItem>
+
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "delete" })}
+ >
+ Delete
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ maxSize: 30,
+ }
+
+ // ----------------------------------------------------------------
+ // 3) 데이터 컬럼들
+ // ----------------------------------------------------------------
+ const dataColumns: ColumnDef<typeof documentClasses.$inferSelect>[] = [
+ {
+ accessorKey: "code",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="코드" />
+ ),
+ meta: {
+ excelHeader: "코드",
+ type: "text",
+ },
+ cell: ({ row }) => row.getValue("code") ?? "",
+ minSize: 80
+ },
+ {
+ accessorKey: "value",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="값" />
+ ),
+ meta: {
+ excelHeader: "값",
+ type: "text",
+ },
+ cell: ({ row }) => row.getValue("value") ?? "",
+ minSize: 80
+ },
+ {
+ accessorKey: "description",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="설명" />
+ ),
+ meta: {
+ excelHeader: "설명",
+ type: "text",
+ },
+ cell: ({ row }) => row.getValue("description") ?? "",
+ minSize: 80
+ },
+
+ {
+ accessorKey: "createdAt",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="생성일" />
+ ),
+ meta: {
+ excelHeader: "생성일",
+ type: "date",
+ },
+ cell: ({ row }) => {
+ const dateVal = row.getValue("createdAt") as Date
+ return formatDateTime(dateVal, "KR")
+ },
+ minSize: 80
+ }
+ ]
+
+ // ----------------------------------------------------------------
+ // 4) 최종 컬럼 배열: select, dataColumns, actions
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ ...dataColumns,
+ actionsColumn,
+ ]
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/document-class/table/document-class-table-toolbar.tsx b/lib/docu-list-rule/document-class/table/document-class-table-toolbar.tsx
new file mode 100644
index 00000000..7bc28a06
--- /dev/null
+++ b/lib/docu-list-rule/document-class/table/document-class-table-toolbar.tsx
@@ -0,0 +1,34 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+
+import { DeleteDocumentClassDialog } from "./delete-document-class-dialog"
+import { DocumentClassAddDialog } from "./document-class-add-dialog"
+import { documentClasses } from "@/db/schema/documentClasses"
+
+interface DocumentClassTableToolbarActionsProps {
+ table: Table<typeof documentClasses.$inferSelect>
+ onSuccess?: () => void
+}
+
+export function DocumentClassTableToolbarActions({ table, onSuccess }: DocumentClassTableToolbarActionsProps) {
+ return (
+ <div className="flex items-center gap-2">
+ {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */}
+ {table.getFilteredSelectedRowModel().rows.length > 0 ? (
+ <DeleteDocumentClassDialog
+ documentClasses={table
+ .getFilteredSelectedRowModel()
+ .rows.map((row) => row.original)}
+ onSuccess={() => {
+ table.toggleAllRowsSelected(false)
+ onSuccess?.()
+ }}
+ />
+ ) : null}
+
+ <DocumentClassAddDialog onSuccess={onSuccess} />
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/document-class/table/document-class-table.tsx b/lib/docu-list-rule/document-class/table/document-class-table.tsx
new file mode 100644
index 00000000..bbe79800
--- /dev/null
+++ b/lib/docu-list-rule/document-class/table/document-class-table.tsx
@@ -0,0 +1,107 @@
+"use client"
+
+import * as React from "react"
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import type { DataTableAdvancedFilterField, DataTableRowAction } from "@/types/table"
+import { getDocumentClassCodeGroups } from "@/lib/docu-list-rule/document-class/service"
+import { getColumns } from "./document-class-table-columns"
+import { DocumentClassEditSheet } from "./document-class-edit-sheet"
+import { DocumentClassOptionsTable } from "./document-class-options-table"
+import { DocumentClassTableToolbarActions } from "./document-class-table-toolbar"
+import { DeleteDocumentClassDialog } from "./delete-document-class-dialog"
+import { documentClasses } from "@/db/schema/docu-list-rule"
+
+interface DocumentClassTableProps {
+ promises?: Promise<[{ data: typeof documentClasses.$inferSelect[]; pageCount: number }]>
+}
+
+export function DocumentClassTable({ promises }: DocumentClassTableProps) {
+ const rawData = React.use(promises!)
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<typeof documentClasses.$inferSelect> | null>(null)
+ const [selectedDocumentClass, setSelectedDocumentClass] = React.useState<typeof documentClasses.$inferSelect | null>(null)
+
+ const refreshData = React.useCallback(() => {
+ window.location.reload()
+ }, [])
+
+ const columns = React.useMemo(() => getColumns({ setRowAction }), [setRowAction])
+
+ // 고급 필터 필드 설정
+ const advancedFilterFields: DataTableAdvancedFilterField<typeof documentClasses.$inferSelect>[] = [
+ { id: "code", label: "코드", type: "text" },
+ { id: "value", label: "값", type: "text" },
+ { id: "description", label: "설명", type: "text" },
+ { id: "createdAt", label: "생성일", type: "date" },
+ ]
+
+ const { table } = useDataTable({
+ data: rawData[0].data as any,
+ columns,
+ pageCount: rawData[0].pageCount,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ manualSorting: false,
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ >
+ <DocumentClassTableToolbarActions table={table} onSuccess={refreshData} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ {/* 구분선 */}
+ <div className="border-t border-border my-6"></div>
+
+ {/* Document Class 옵션 관리 제목 */}
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <div className="flex items-center gap-2">
+ <h2 className="text-2xl font-bold tracking-tight">Document Class 옵션 관리</h2>
+ </div>
+ <p className="text-muted-foreground">
+ Document Class 옵션들을 관리합니다.
+ </p>
+ </div>
+ </div>
+
+ {/* Document Class 옵션 테이블 */}
+ <DocumentClassOptionsTable
+ selectedDocumentClass={selectedDocumentClass}
+ documentClasses={rawData[0].data || []}
+ onSelectDocumentClass={setSelectedDocumentClass}
+ />
+
+ <DeleteDocumentClassDialog
+ open={rowAction?.type === "delete"}
+ onOpenChange={() => setRowAction(null)}
+ documentClasses={rowAction?.row.original ? [rowAction?.row.original] : []}
+ showTrigger={false}
+ onSuccess={() => {
+ rowAction?.row.toggleSelected(false)
+ refreshData()
+ }}
+ />
+
+ <DocumentClassEditSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ data={rowAction?.row.original ?? null}
+ onSuccess={refreshData}
+ />
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/document-class/validation.ts b/lib/docu-list-rule/document-class/validation.ts
new file mode 100644
index 00000000..0600e8fb
--- /dev/null
+++ b/lib/docu-list-rule/document-class/validation.ts
@@ -0,0 +1,12 @@
+import { createSearchParamsCache } from "nuqs/server";
+import { parseAsInteger, parseAsString, parseAsArrayOf, parseAsStringEnum } from "nuqs/server";
+import { getSortingStateParser, getFiltersStateParser } from "@/lib/parsers";
+
+export const searchParamsDocumentClassCache = createSearchParamsCache({
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<any>(),
+ filters: getFiltersStateParser(),
+ search: parseAsString.withDefault(""),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+}); \ No newline at end of file
diff --git a/lib/docu-list-rule/number-type-configs/service.ts b/lib/docu-list-rule/number-type-configs/service.ts
new file mode 100644
index 00000000..3e2cfc8e
--- /dev/null
+++ b/lib/docu-list-rule/number-type-configs/service.ts
@@ -0,0 +1,268 @@
+"use server"
+
+import { revalidatePath } from "next/cache"
+import db from "@/db/db"
+import { documentNumberTypes, documentNumberTypeConfigs } from "@/db/schema/docu-list-rule"
+import { codeGroups, documentClasses } from "@/db/schema/docu-list-rule"
+import { eq, asc, sql } from "drizzle-orm"
+import { unstable_noStore } from "next/cache"
+
+
+
+// 특정 Number Type의 Configs 조회
+export async function getNumberTypeConfigs(numberTypeId: number) {
+ console.log("=== getNumberTypeConfigs START ===")
+ console.log("getNumberTypeConfigs called with numberTypeId:", numberTypeId)
+
+ try {
+ console.log("About to execute database query...")
+
+ const configs = await db
+ .select({
+ id: documentNumberTypeConfigs.id,
+ documentNumberTypeId: documentNumberTypeConfigs.documentNumberTypeId,
+ codeGroupId: documentNumberTypeConfigs.codeGroupId,
+ documentClassId: documentNumberTypeConfigs.documentClassId,
+ sdq: documentNumberTypeConfigs.sdq,
+ description: documentNumberTypeConfigs.description,
+ remark: documentNumberTypeConfigs.remark,
+ isActive: documentNumberTypeConfigs.isActive,
+ createdAt: documentNumberTypeConfigs.createdAt,
+ updatedAt: documentNumberTypeConfigs.updatedAt,
+ // Code Group 정보도 함께 가져오기
+ codeGroupName: codeGroups.description,
+ codeGroupControlType: codeGroups.controlType,
+ // Document Class 정보도 함께 가져오기
+ documentClassName: documentClasses.value,
+ documentClassDescription: documentClasses.description,
+ })
+ .from(documentNumberTypeConfigs)
+ .leftJoin(codeGroups, eq(documentNumberTypeConfigs.codeGroupId, codeGroups.id))
+ .leftJoin(documentClasses, eq(documentNumberTypeConfigs.documentClassId, documentClasses.id))
+ .where(eq(documentNumberTypeConfigs.documentNumberTypeId, numberTypeId))
+ .orderBy(asc(documentNumberTypeConfigs.sdq))
+
+
+
+ return {
+ success: true,
+ data: configs,
+ }
+ } catch (error) {
+
+ return {
+ success: false,
+ error: "Failed to fetch number type configs",
+ data: [],
+ }
+ }
+}
+
+// Number Type Config 생성
+export async function createNumberTypeConfig(input: {
+ documentNumberTypeId: number
+ codeGroupId: number | null
+ documentClassId: number | null
+ sdq: number
+ description?: string
+ remark?: string
+}) {
+ try {
+ const [newConfig] = await db
+ .insert(documentNumberTypeConfigs)
+ .values({
+ documentNumberTypeId: input.documentNumberTypeId,
+ codeGroupId: input.codeGroupId,
+ documentClassId: input.documentClassId,
+ sdq: input.sdq,
+ description: input.description,
+ remark: input.remark,
+ })
+ .returning({ id: documentNumberTypeConfigs.id })
+
+ revalidatePath("/evcp/docu-list-rule/number-type-configs")
+
+ return {
+ success: true,
+ data: newConfig,
+ message: "Number Type Config created successfully"
+ }
+ } catch (error) {
+ console.error("Error creating number type config:", error)
+ return {
+ success: false,
+ error: "Failed to create number type config"
+ }
+ }
+}
+
+// Number Type Config 수정
+export async function updateNumberTypeConfig(input: {
+ id: number
+ codeGroupId: number | null
+ documentClassId: number | null
+ sdq: number
+ description?: string
+ remark?: string
+}) {
+ try {
+ const [updatedConfig] = await db
+ .update(documentNumberTypeConfigs)
+ .set({
+ codeGroupId: input.codeGroupId,
+ documentClassId: input.documentClassId,
+ sdq: input.sdq,
+ description: input.description,
+ remark: input.remark,
+ updatedAt: new Date(),
+ })
+ .where(eq(documentNumberTypeConfigs.id, input.id))
+ .returning({ id: documentNumberTypeConfigs.id })
+
+ revalidatePath("/evcp/docu-list-rule/number-type-configs")
+
+ return {
+ success: true,
+ data: updatedConfig,
+ message: "Number Type Config updated successfully"
+ }
+ } catch (error) {
+ console.error("Error updating number type config:", error)
+ return {
+ success: false,
+ error: "Failed to update number type config"
+ }
+ }
+}
+
+// Number Type Config 삭제
+export async function deleteNumberTypeConfig(id: number) {
+ try {
+ // 삭제할 항목의 정보를 먼저 가져옴
+ const [configToDelete] = await db
+ .select({
+ documentNumberTypeId: documentNumberTypeConfigs.documentNumberTypeId,
+ sdq: documentNumberTypeConfigs.sdq,
+ })
+ .from(documentNumberTypeConfigs)
+ .where(eq(documentNumberTypeConfigs.id, id))
+
+ if (!configToDelete) {
+ return {
+ success: false,
+ error: "Config not found"
+ }
+ }
+
+ // 항목 삭제
+ await db
+ .delete(documentNumberTypeConfigs)
+ .where(eq(documentNumberTypeConfigs.id, id))
+
+ // 같은 Number Type의 남은 항목들 중에서 삭제된 항목보다 큰 순서를 가진 항목들의 순서를 1씩 감소
+ await db
+ .update(documentNumberTypeConfigs)
+ .set({
+ sdq: sql`${documentNumberTypeConfigs.sdq} - 1`,
+ updatedAt: new Date(),
+ })
+ .where(
+ sql`${documentNumberTypeConfigs.documentNumberTypeId} = ${configToDelete.documentNumberTypeId} AND ${documentNumberTypeConfigs.sdq} > ${configToDelete.sdq}`
+ )
+
+ revalidatePath("/evcp/docu-list-rule/number-type-configs")
+
+ return {
+ success: true,
+ message: "Number Type Config deleted successfully"
+ }
+ } catch (error) {
+ console.error("Error deleting number type config:", error)
+ return {
+ success: false,
+ error: "Failed to delete number type config"
+ }
+ }
+}
+
+// 활성화된 Code Groups 조회 (Config 생성/수정 시 사용)
+export async function getActiveCodeGroups() {
+ try {
+ console.log("getActiveCodeGroups: 함수 시작")
+
+ const codeGroupsData = await db
+ .select({
+ id: codeGroups.id,
+ groupId: codeGroups.groupId,
+ description: codeGroups.description,
+ controlType: codeGroups.controlType,
+ isActive: codeGroups.isActive,
+ })
+ .from(codeGroups)
+ .where(eq(codeGroups.isActive, true))
+ .orderBy(asc(codeGroups.description))
+
+ console.log("getActiveCodeGroups: 쿼리 결과", codeGroupsData)
+
+ return {
+ success: true,
+ data: codeGroupsData,
+ }
+ } catch (error) {
+ console.error("Error fetching active code groups:", error)
+ return {
+ success: false,
+ error: "Failed to fetch active code groups",
+ data: [],
+ }
+ }
+}
+
+// Document Class Code Group ID 조회
+export async function getDocumentClassCodeGroupId() {
+ try {
+ const [codeGroup] = await db
+ .select({ id: codeGroups.id })
+ .from(codeGroups)
+ .where(eq(codeGroups.groupId, 'DOC_CLASS'))
+ .limit(1)
+
+ return codeGroup?.id || null
+ } catch (error) {
+ console.error("Error fetching document class code group id:", error)
+ return null
+ }
+}
+
+// 활성화된 Document Classes 조회 (Config 생성/수정 시 사용)
+export async function getActiveDocumentClasses() {
+ try {
+ console.log("getActiveDocumentClasses: 함수 시작")
+
+ const documentClassesData = await db
+ .select({
+ id: documentClasses.id,
+ code: documentClasses.code,
+ value: documentClasses.value,
+ description: documentClasses.description,
+ isActive: documentClasses.isActive,
+ })
+ .from(documentClasses)
+ .where(eq(documentClasses.isActive, true))
+ .orderBy(asc(documentClasses.value))
+
+ console.log("getActiveDocumentClasses: 쿼리 결과", documentClassesData)
+
+ return {
+ success: true,
+ data: documentClassesData,
+ }
+ } catch (error) {
+ console.error("Error fetching active document classes:", error)
+ return {
+ success: false,
+ error: "Failed to fetch active document classes",
+ data: [],
+ }
+ }
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/number-type-configs/table/delete-number-type-configs-dialog.tsx b/lib/docu-list-rule/number-type-configs/table/delete-number-type-configs-dialog.tsx
new file mode 100644
index 00000000..9ed8ca71
--- /dev/null
+++ b/lib/docu-list-rule/number-type-configs/table/delete-number-type-configs-dialog.tsx
@@ -0,0 +1,156 @@
+"use client"
+
+import * as React from "react"
+import { Loader, Trash } from "lucide-react"
+import { toast } from "sonner"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+
+import { deleteNumberTypeConfig } from "../service"
+import { NumberTypeConfig } from "../../types"
+
+interface DeleteNumberTypeConfigsDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ configs: NumberTypeConfig[]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeleteNumberTypeConfigsDialog({
+ configs,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeleteNumberTypeConfigsDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onDelete() {
+ startDeleteTransition(async () => {
+ try {
+ // 삭제할 항목들을 sdq 순서대로 정렬 (큰 순서부터 삭제하여 순서 재정렬 문제 방지)
+ const sortedConfigs = [...configs].sort((a, b) => b.sdq - a.sdq)
+
+ // 각 config를 순차적으로 삭제 (큰 순서부터 삭제)
+ for (const config of sortedConfigs) {
+ const result = await deleteNumberTypeConfig(config.id)
+ if (!result.success) {
+ toast.error(`Number Type Config 삭제 실패: ${result.error}`)
+ return
+ }
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("Number Type Config가 성공적으로 삭제되었습니다.")
+ onSuccess?.()
+ } catch (error) {
+ console.error("Delete error:", error)
+ toast.error("Number Type Config 삭제 중 오류가 발생했습니다.")
+ }
+ })
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제 ({configs.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle>
+ <DialogDescription>
+ 이 작업은 되돌릴 수 없습니다. 선택된{" "}
+ <span className="font-medium">{configs.length}</span>
+ 개의 Number Type Config를 서버에서 영구적으로 삭제합니다.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">취소</Button>
+ </DialogClose>
+ <Button
+ aria-label="선택된 행 삭제"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 삭제
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제 ({configs.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle>
+ <DrawerDescription>
+ 이 작업은 되돌릴 수 없습니다. 선택된{" "}
+ <span className="font-medium">{configs.length}</span>
+ 개의 Number Type Config를 서버에서 영구적으로 삭제합니다.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">취소</Button>
+ </DrawerClose>
+ <Button
+ aria-label="선택된 행 삭제"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ 삭제
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/number-type-configs/table/number-type-configs-edit-dialog.tsx b/lib/docu-list-rule/number-type-configs/table/number-type-configs-edit-dialog.tsx
new file mode 100644
index 00000000..d3215958
--- /dev/null
+++ b/lib/docu-list-rule/number-type-configs/table/number-type-configs-edit-dialog.tsx
@@ -0,0 +1,222 @@
+"use client"
+
+import * as React from "react"
+import { Loader2 } from "lucide-react"
+import { toast } from "sonner"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Textarea } from "@/components/ui/textarea"
+
+import { updateNumberTypeConfig, getActiveCodeGroups } from "../service"
+import { NumberTypeConfig } from "../../types"
+
+interface NumberTypeConfigsEditDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ data: NumberTypeConfig | null
+ onSuccess?: () => void
+ existingConfigs?: NumberTypeConfig[] // 기존 configs 목록 추가
+}
+
+export function NumberTypeConfigsEditDialog({
+ open,
+ onOpenChange,
+ data,
+ onSuccess,
+ existingConfigs = [], // 기본값 추가
+}: NumberTypeConfigsEditDialogProps) {
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [codeGroups, setCodeGroups] = React.useState<{ id: number; description: string }[]>([])
+ const [formData, setFormData] = React.useState({
+ codeGroupId: "",
+ sdq: "",
+ description: "",
+ remark: ""
+ })
+
+ // 데이터가 변경될 때 폼 초기화
+ React.useEffect(() => {
+ if (data) {
+ setFormData({
+ codeGroupId: data.codeGroupId.toString(),
+ sdq: data.sdq.toString(),
+ description: data.description || "",
+ remark: data.remark || ""
+ })
+ }
+ }, [data])
+
+ // Code Groups 로드
+ React.useEffect(() => {
+ (async () => {
+ try {
+ const result = await getActiveCodeGroups()
+ if (result.success && result.data) {
+ setCodeGroups(result.data)
+ }
+ } catch (error) {
+ console.error("Error loading code groups:", error)
+ }
+ })()
+ }, [])
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ if (!data || !formData.codeGroupId || !formData.sdq) {
+ toast.error("필수 필드를 모두 입력해주세요.")
+ return
+ }
+
+ const newSdq = parseInt(formData.sdq)
+
+ // 순서 중복 검증 (현재 수정 중인 항목 제외)
+ const existingSdq = existingConfigs.find(config =>
+ config.sdq === newSdq && config.id !== data.id
+ )
+ if (existingSdq) {
+ toast.error(`순서 ${newSdq}번은 이미 사용 중입니다. 다른 순서를 입력해주세요.`)
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ const result = await updateNumberTypeConfig({
+ id: data.id,
+ codeGroupId: parseInt(formData.codeGroupId),
+ sdq: newSdq,
+ description: formData.description || undefined,
+ remark: formData.remark || undefined,
+ })
+
+ if (result.success) {
+ toast.success("Number Type Config가 성공적으로 수정되었습니다.")
+ onOpenChange(false)
+ onSuccess?.()
+ } else {
+ toast.error(result.error || "수정에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("Error updating number type config:", error)
+ toast.error("수정 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ if (!data) return null
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-md">
+ <DialogHeader>
+ <DialogTitle>Number Type Config 수정</DialogTitle>
+ <DialogDescription>
+ Number Type Config 정보를 수정합니다.
+ <span className="text-red-500 mt-1 block text-sm">* 표시된 항목은 필수 입력사항입니다.</span>
+ </DialogDescription>
+ </DialogHeader>
+ <form onSubmit={handleSubmit} className="space-y-4">
+ <div className="grid gap-4 py-2">
+ <div className="grid grid-cols-4 items-center gap-4">
+ <Label htmlFor="codeGroup" className="text-right">
+ Code Group <span className="text-red-500">*</span>
+ </Label>
+ <div className="col-span-3">
+ <Select
+ value={formData.codeGroupId}
+ onValueChange={(value) => setFormData(prev => ({ ...prev, codeGroupId: value }))}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="Code Group 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {codeGroups.map((codeGroup) => (
+ <SelectItem key={codeGroup.id} value={codeGroup.id.toString()}>
+ {codeGroup.description}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+ <div className="grid grid-cols-4 items-center gap-4">
+ <Label htmlFor="sdq" className="text-right">
+ 순서 <span className="text-red-500">*</span>
+ </Label>
+ <div className="col-span-3">
+ <Input
+ id="sdq"
+ type="number"
+ value={formData.sdq}
+ onChange={(e) => setFormData(prev => ({ ...prev, sdq: e.target.value }))}
+ min="1"
+ />
+ </div>
+ </div>
+ <div className="grid grid-cols-4 items-center gap-4">
+ <Label htmlFor="description" className="text-right">
+ Description
+ </Label>
+ <div className="col-span-3">
+ <Input
+ id="description"
+ value={formData.description}
+ onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
+ placeholder="예: PROJECT NO"
+ />
+ </div>
+ </div>
+ <div className="grid grid-cols-4 items-center gap-4">
+ <Label htmlFor="remark" className="text-right">
+ Remark
+ </Label>
+ <div className="col-span-3">
+ <Textarea
+ id="remark"
+ value={formData.remark}
+ onChange={(e) => setFormData(prev => ({ ...prev, remark: e.target.value }))}
+ placeholder="비고 사항"
+ rows={3}
+ />
+ </div>
+ </div>
+ </div>
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ disabled={isLoading}
+ >
+ {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ {isLoading ? "수정 중..." : "수정"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/number-type-configs/table/number-type-configs-table-columns.tsx b/lib/docu-list-rule/number-type-configs/table/number-type-configs-table-columns.tsx
new file mode 100644
index 00000000..b03000e0
--- /dev/null
+++ b/lib/docu-list-rule/number-type-configs/table/number-type-configs-table-columns.tsx
@@ -0,0 +1,188 @@
+"use client"
+
+import * as React from "react"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { MoreHorizontal } from "lucide-react"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { Checkbox } from "@/components/ui/checkbox"
+import type { DataTableRowAction } from "@/types/table"
+import { NumberTypeConfig } from "../../types"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<NumberTypeConfig> | null>>
+}
+
+/**
+ * tanstack table 컬럼 정의
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<NumberTypeConfig>[] {
+ return [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="모든 행 선택"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="행 선택"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ size: 35,
+ },
+ {
+ accessorKey: "sdq",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="순서" />
+ ),
+ meta: {
+ excelHeader: "순서",
+ type: "number",
+ },
+ cell: ({ row }) => row.getValue("sdq") ?? "",
+ minSize: 50
+ },
+ {
+ accessorKey: "codeGroupName",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Code Group" />
+ ),
+ meta: {
+ excelHeader: "Code Group",
+ type: "text",
+ },
+ cell: ({ row }) => {
+ const codeGroupName = row.getValue("codeGroupName") as string | null
+ const documentClassName = row.original.documentClassName
+
+ if (codeGroupName) {
+ return codeGroupName
+ } else if (documentClassName) {
+ return "Document Class"
+ } else {
+ return "-"
+ }
+ },
+ minSize: 100
+ },
+ {
+ accessorKey: "codeGroupControlType",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Control Type" />
+ ),
+ meta: {
+ excelHeader: "Control Type",
+ type: "text",
+ },
+ cell: ({ row }) => {
+ const controlType = row.getValue("codeGroupControlType") as string | null
+ const documentClassName = row.original.documentClassName
+
+ if (controlType) {
+ return (
+ <Badge variant="outline">
+ {controlType}
+ </Badge>
+ )
+ } else if (documentClassName) {
+ return (
+ <Badge variant="outline">
+ ComboBox
+ </Badge>
+ )
+ } else {
+ return "-"
+ }
+ },
+ minSize: 90
+ },
+ {
+ accessorKey: "description",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Description" />
+ ),
+ meta: {
+ excelHeader: "Description",
+ type: "text",
+ },
+ cell: ({ row }) => {
+ const description = row.getValue("description") as string | null
+ const documentClassName = row.original.documentClassName
+
+ if (description) {
+ return description
+ } else if (documentClassName) {
+ return "class 및 하위옵션"
+ } else {
+ return "-"
+ }
+ },
+ minSize: 100
+ },
+ {
+ accessorKey: "remark",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Remark" />
+ ),
+ meta: {
+ excelHeader: "Remark",
+ type: "text",
+ },
+ cell: ({ row }) => row.getValue("remark") ?? "-",
+ minSize: 80
+ },
+
+ {
+ id: "actions",
+ header: () => <span className="sr-only">작업</span>,
+ cell: ({ row }) => {
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" className="h-8 w-8 p-0">
+ <span className="sr-only">열기 메뉴</span>
+ <MoreHorizontal className="h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem onClick={() => setRowAction({ row, type: "update" })}>
+ 수정
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem onClick={() => setRowAction({ row, type: "delete" })}>
+ 삭제
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ maxSize: 40,
+ enableSorting: false,
+ enableHiding: false,
+ },
+ ]
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/number-type-configs/table/number-type-configs-table.tsx b/lib/docu-list-rule/number-type-configs/table/number-type-configs-table.tsx
new file mode 100644
index 00000000..89a92a88
--- /dev/null
+++ b/lib/docu-list-rule/number-type-configs/table/number-type-configs-table.tsx
@@ -0,0 +1,720 @@
+"use client"
+
+import * as React from "react"
+import { useDataTable } from "@/hooks/use-data-table"
+import { flexRender } from "@tanstack/react-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import type { DataTableAdvancedFilterField, DataTableRowAction } from "@/types/table"
+
+import { getNumberTypeConfigs, createNumberTypeConfig, getActiveCodeGroups, getActiveDocumentClasses, getDocumentClassCodeGroupId, updateNumberTypeConfig } from "../service"
+import { getNumberTypes } from "@/lib/docu-list-rule/number-types/service"
+import { getColumns } from "./number-type-configs-table-columns"
+import { DeleteNumberTypeConfigsDialog } from "./delete-number-type-configs-dialog"
+import { NumberTypeConfigsEditDialog } from "./number-type-configs-edit-dialog"
+import { documentNumberTypes } from "@/db/schema/docu-list-rule"
+import { Plus, Loader2 } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Textarea } from "@/components/ui/textarea"
+import { toast } from "sonner"
+import {
+ DndContext,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ DragEndEvent,
+} from '@dnd-kit/core'
+import {
+ arrayMove,
+ SortableContext,
+ sortableKeyboardCoordinates,
+ verticalListSortingStrategy,
+} from '@dnd-kit/sortable'
+import {
+ useSortable,
+} from '@dnd-kit/sortable'
+import { CSS } from '@dnd-kit/utilities'
+
+import { cn } from "@/lib/utils"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { DataTablePagination } from "@/components/data-table/data-table-pagination"
+import { NumberTypeConfig } from "../../types"
+
+interface NumberTypeConfigsTableProps {
+ promises?: Promise<[{ data: typeof documentNumberTypes.$inferSelect[]; pageCount: number }]>
+}
+
+// 드래그 가능한 행 컴포넌트
+function SortableRow({
+ children,
+ id,
+ isDragging
+}: {
+ children: React.ReactNode
+ id: string
+ isDragging: boolean
+}) {
+ const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id })
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.5 : 1,
+ }
+
+ return (
+ <TableRow
+ ref={setNodeRef}
+ style={style}
+ className={cn(
+ "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
+ isDragging && "opacity-50"
+ )}
+ >
+ <TableCell className="w-8 p-1 text-center">
+ <div
+ {...attributes}
+ {...listeners}
+ className="cursor-grab active:cursor-grabbing flex items-center justify-center w-5 h-5 text-muted-foreground hover:text-foreground"
+ >
+ <span className="text-sm">≡</span>
+ </div>
+ </TableCell>
+ {children}
+ </TableRow>
+ )
+}
+
+// 커스텀 드래그 앤 드롭 테이블 컴포넌트
+function DragDropTable<TData>({
+ table,
+ data,
+ onDragEnd,
+ children,
+ className,
+ maxHeight = '35rem'
+}: {
+ table: any
+ data: TData[]
+ onDragEnd: (event: DragEndEvent) => void
+ children?: React.ReactNode
+ className?: string
+ maxHeight?: string
+}) {
+ const sensors = useSensors(
+ useSensor(PointerSensor),
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
+ )
+
+ return (
+ <div className={cn("w-full space-y-2.5 overflow-auto", className)}>
+ {children}
+ <div className="max-w-[100vw] overflow-auto" style={{ maxHeight }}>
+ <DndContext
+ sensors={sensors}
+ collisionDetection={closestCenter}
+ onDragEnd={onDragEnd}
+ >
+ <Table className="[&>thead]:sticky [&>thead]:top-0 [&>thead]:z-10 table-fixed">
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-8 p-1 text-center"></TableHead>
+ {table.getHeaderGroups()[0].headers.map((header: any) => {
+ if (header.column.getIsGrouped()) {
+ return null
+ }
+
+ return (
+ <TableHead
+ key={header.id}
+ colSpan={header.colSpan}
+ data-column-id={header.column.id}
+ className="h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]"
+ style={{
+ width: header.getSize(),
+ }}
+ >
+ {header.isPlaceholder
+ ? null
+ : flexRender(header.column.columnDef.header, header.getContext())}
+ </TableHead>
+ )
+ })}
+ </TableRow>
+ </TableHeader>
+
+ <TableBody>
+ <SortableContext items={data.map((item: any) => item.id.toString())} strategy={verticalListSortingStrategy}>
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row: any) => {
+ if (row.getIsGrouped()) {
+ return null
+ }
+
+ return (
+ <SortableRow key={row.id} id={row.id} isDragging={false}>
+ {row.getVisibleCells().map((cell: any) => {
+ if (cell.column.getIsGrouped()) {
+ return null
+ }
+
+ return (
+ <TableCell
+ key={cell.id}
+ data-column-id={cell.column.id}
+ className="p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]"
+ style={{
+ width: cell.column.getSize(),
+ }}
+ >
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+ </TableCell>
+ )
+ })}
+ </SortableRow>
+ )
+ })
+ ) : (
+ <TableRow>
+ <TableCell
+ colSpan={table.getAllColumns().length + 1}
+ className="h-24 text-center"
+ >
+ No results.
+ </TableCell>
+ </TableRow>
+ )}
+ </SortableContext>
+ </TableBody>
+ </Table>
+ </DndContext>
+ </div>
+
+ <div className="flex flex-col gap-2.5">
+ <DataTablePagination table={table} />
+ </div>
+ </div>
+ )
+}
+
+// 툴바 액션 컴포넌트 (incoterms와 동일한 스타일)
+function NumberTypeConfigsTableToolbarActions({
+ table,
+ onSuccess,
+ selectedNumberType,
+ configsData
+}: {
+ table: any
+ onSuccess?: () => void
+ selectedNumberType: number | null
+ configsData: NumberTypeConfig[]
+}) {
+ const [isAddDialogOpen, setIsAddDialogOpen] = React.useState(false)
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [formData, setFormData] = React.useState({ codeGroupId: "", description: "", remark: "" })
+ const [codeGroups, setCodeGroups] = React.useState<{ id: number; description: string }[]>([])
+ const [allOptions, setAllOptions] = React.useState<{ id: string; name: string }[]>([])
+
+ const loadCodeGroups = React.useCallback(async () => {
+ try {
+ console.log("NumberTypeConfigsTableToolbarActions: Code Groups 로딩 시작")
+ console.log("NumberTypeConfigsTableToolbarActions: getActiveCodeGroups 함수 호출")
+ const result = await getActiveCodeGroups()
+ console.log("NumberTypeConfigsTableToolbarActions: getActiveCodeGroups 결과", result)
+ console.log("NumberTypeConfigsTableToolbarActions: result.success", result.success)
+ console.log("NumberTypeConfigsTableToolbarActions: result.data", result.data)
+ console.log("NumberTypeConfigsTableToolbarActions: result.error", result.error)
+
+ if (result.success && result.data) {
+ console.log("NumberTypeConfigsTableToolbarActions: Code Groups 설정", result.data)
+
+ // 이미 추가된 Code Group들을 제외하고 필터링
+ const usedCodeGroupIds = configsData.map(config => config.codeGroupId)
+ const availableCodeGroups = result.data.filter(codeGroup =>
+ !usedCodeGroupIds.includes(codeGroup.id)
+ )
+
+ console.log("NumberTypeConfigsTableToolbarActions: 사용된 Code Group IDs", usedCodeGroupIds)
+ console.log("NumberTypeConfigsTableToolbarActions: 사용 가능한 Code Groups", availableCodeGroups)
+
+ setCodeGroups(availableCodeGroups)
+ } else {
+ console.error("NumberTypeConfigsTableToolbarActions: Code Groups 로딩 실패", result.error)
+ }
+ } catch (error) {
+ console.error("Error loading code groups:", error)
+ console.error("Error details:", error)
+ }
+ }, [configsData])
+
+
+
+ React.useEffect(() => {
+ loadCodeGroups()
+ }, [loadCodeGroups])
+
+ // Code Groups를 옵션 목록으로 만드는 함수
+ const combineOptions = React.useCallback(() => {
+ const codeGroupOptions = codeGroups.map(cg => ({
+ id: `cg_${cg.id}`,
+ name: cg.description
+ }))
+
+ setAllOptions(codeGroupOptions)
+ }, [codeGroups])
+
+ // Code Groups가 변경될 때마다 옵션 목록 업데이트
+ React.useEffect(() => {
+ combineOptions()
+ }, [combineOptions])
+
+ // 다이얼로그가 열릴 때마다 Code Groups 다시 로드
+ React.useEffect(() => {
+ if (isAddDialogOpen) {
+ loadCodeGroups()
+ }
+ }, [isAddDialogOpen, loadCodeGroups, configsData])
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ if (!selectedNumberType || !formData.codeGroupId) {
+ toast.error("필수 필드를 모두 입력해주세요.")
+ return
+ }
+
+ const sdq = getNextSdq()
+ setIsLoading(true)
+
+ try {
+ // Code Group ID 추출
+ const codeGroupId = parseInt(formData.codeGroupId.replace('cg_', ''))
+
+ const result = await createNumberTypeConfig({
+ documentNumberTypeId: selectedNumberType,
+ codeGroupId: codeGroupId,
+ documentClassId: null,
+ sdq: sdq,
+ description: formData.description || undefined,
+ remark: formData.remark || undefined,
+ })
+
+ if (result.success) {
+ toast.success("Number Type Config가 성공적으로 추가되었습니다.")
+ setIsAddDialogOpen(false)
+ setFormData({ codeGroupId: "", description: "", remark: "" })
+ onSuccess?.()
+ } else {
+ toast.error(result.error || "추가에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("Error creating number type config:", error)
+ toast.error("추가 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const getNextSdq = () => {
+ if (configsData.length === 0) return 1
+ const maxSdq = Math.max(...configsData.map(config => config.sdq))
+ return maxSdq + 1
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */}
+ {table.getFilteredSelectedRowModel().rows.length > 0 ? (
+ <DeleteNumberTypeConfigsDialog
+ configs={table
+ .getFilteredSelectedRowModel()
+ .rows.map((row: any) => row.original)}
+ onSuccess={() => {
+ table.toggleAllRowsSelected(false);
+ onSuccess?.();
+ }}
+ />
+ ) : null}
+
+ <Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm" disabled={!selectedNumberType || codeGroups.length === 0}>
+ <Plus className="mr-2 h-4 w-4" />
+ Add
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="max-w-md">
+ <DialogHeader>
+ <DialogTitle>Number Type Config 추가</DialogTitle>
+ <DialogDescription>
+ 새로운 구성 요소를 추가합니다. 필수 정보를 입력해주세요.
+ <span className="text-red-500 mt-1 block text-sm">* 표시된 항목은 필수 입력사항입니다.</span>
+ </DialogDescription>
+ </DialogHeader>
+ <form onSubmit={handleSubmit} className="space-y-4">
+ <div className="grid gap-4 py-2">
+ <div className="grid grid-cols-4 items-center gap-4">
+ <Label htmlFor="codeGroup" className="text-right">
+ Code Group <span className="text-red-500">*</span>
+ </Label>
+ <div className="col-span-3">
+ <Select
+ value={formData.codeGroupId}
+ onValueChange={(value) => setFormData(prev => ({ ...prev, codeGroupId: value }))}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="Code Group 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {allOptions.length > 0 ? (
+ allOptions.map((option) => (
+ <SelectItem key={option.id} value={option.id}>
+ {option.name}
+ </SelectItem>
+ ))
+ ) : (
+ <div className="px-2 py-1.5 text-sm text-muted-foreground">
+ 사용 가능한 옵션이 없습니다.
+ </div>
+ )}
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+ <div className="grid grid-cols-4 items-center gap-4">
+ <Label htmlFor="description" className="text-right">
+ Description
+ </Label>
+ <div className="col-span-3">
+ <Input
+ id="description"
+ value={formData.description}
+ onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
+ placeholder="예: PROJECT NO"
+ />
+ </div>
+ </div>
+ <div className="grid grid-cols-4 items-center gap-4">
+ <Label htmlFor="remark" className="text-right">
+ Remark
+ </Label>
+ <div className="col-span-3">
+ <Textarea
+ id="remark"
+ value={formData.remark}
+ onChange={(e) => setFormData(prev => ({ ...prev, remark: e.target.value }))}
+ placeholder="비고 사항"
+ rows={3}
+ />
+ </div>
+ </div>
+ </div>
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setIsAddDialogOpen(false)}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ disabled={isLoading}
+ >
+ {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ {isLoading ? "추가 중..." : "추가"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </DialogContent>
+ </Dialog>
+ </div>
+ )
+}
+
+export function NumberTypeConfigsTable({ promises }: NumberTypeConfigsTableProps) {
+ const rawData = React.use(promises!)
+ const [selectedNumberType, setSelectedNumberType] = React.useState<number | null>(null)
+ const [configsData, setConfigsData] = React.useState<NumberTypeConfig[]>([])
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<NumberTypeConfig> | null>(null)
+
+ // 상태 변화 추적
+ React.useEffect(() => {
+ console.log("selectedNumberType changed:", selectedNumberType)
+ }, [selectedNumberType])
+
+ React.useEffect(() => {
+ console.log("configsData changed:", configsData)
+ console.log("configsData length:", configsData.length)
+ }, [configsData])
+
+ // 드래그 종료 핸들러
+ const handleDragEnd = async (event: DragEndEvent) => {
+ const { active, over } = event
+
+ if (active.id !== over?.id) {
+ const oldIndex = configsData.findIndex(config => config.id.toString() === active.id)
+ const newIndex = configsData.findIndex(config => config.id.toString() === over?.id)
+
+ if (oldIndex !== -1 && newIndex !== -1) {
+ const newConfigsData = arrayMove(configsData, oldIndex, newIndex)
+
+ // 순서 업데이트
+ const updatedConfigsData = newConfigsData.map((config, index) => ({
+ ...config,
+ sdq: index + 1
+ }))
+
+ setConfigsData(updatedConfigsData)
+
+ // 서버에 순서 업데이트
+ try {
+ for (const config of updatedConfigsData) {
+ await updateNumberTypeConfig({
+ id: config.id,
+ codeGroupId: config.codeGroupId,
+ documentClassId: config.documentClassId,
+ sdq: config.sdq,
+ description: config.description || undefined,
+ remark: config.remark || undefined,
+ })
+ }
+ toast.success("순서가 성공적으로 변경되었습니다.")
+ } catch (error) {
+ console.error("Error updating order:", error)
+ toast.error("순서 변경 중 오류가 발생했습니다.")
+ // 에러 시 원래 데이터로 복원
+ if (selectedNumberType) {
+ const refreshResult = await getNumberTypeConfigs(selectedNumberType)
+ if (refreshResult.success && refreshResult.data) setConfigsData(refreshResult.data)
+ }
+ }
+ }
+ }
+ }
+
+ // Number Type Configs를 가져오는 함수
+ const fetchConfigs = React.useCallback(async (numberTypeId: number) => {
+ try {
+ const result = await getNumberTypeConfigs(numberTypeId)
+ if (result.success && result.data) {
+ console.log("Configs data loaded:", result.data)
+ setConfigsData(result.data)
+ }
+ } catch (error) {
+ console.error("Error loading configs:", error)
+ }
+ }, [])
+
+ // Number Types 데이터 로드 및 첫 번째 타입의 configs 데이터 로드
+ React.useEffect(() => {
+ const loadData = async () => {
+ console.log("useEffect triggered - rawData:", !!rawData)
+
+ try {
+ const result = rawData[0]
+ console.log("Raw data result:", result)
+
+ if (result.data && result.data.length > 0) {
+ const firstNumberTypeId = result.data[0].id
+ console.log("Setting first number type ID:", firstNumberTypeId)
+ setSelectedNumberType(firstNumberTypeId)
+
+ // 첫 번째 타입의 configs 데이터도 바로 로드
+ console.log("Loading configs for first number type:", firstNumberTypeId)
+ const configsResult = await getNumberTypeConfigs(firstNumberTypeId)
+ console.log("Configs result received:", configsResult)
+
+ if (configsResult && configsResult.success && configsResult.data) {
+ console.log("Setting configs data:", configsResult.data)
+ setConfigsData(configsResult.data)
+ } else {
+ console.log("Configs result not successful or no data:", configsResult)
+ setConfigsData([])
+ }
+ }
+ } catch (error) {
+ console.error("Error in loadData:", error)
+ }
+ }
+
+ loadData()
+ }, [rawData])
+
+ // selectedNumberType이 변경될 때 configs 데이터 로드 (첫 번째 로드 이후)
+ React.useEffect(() => {
+ console.log("Second useEffect triggered - selectedNumberType:", selectedNumberType)
+
+ const loadConfigs = async () => {
+ if (selectedNumberType) {
+ console.log("Loading configs for selectedNumberType:", selectedNumberType)
+
+ try {
+ const configsResult = await getNumberTypeConfigs(selectedNumberType)
+ console.log("Configs result received:", configsResult)
+
+ if (configsResult && configsResult.success && configsResult.data) {
+ console.log("Setting configs data:", configsResult.data)
+ setConfigsData(configsResult.data)
+ } else {
+ console.log("Configs result not successful or no data:", configsResult)
+ setConfigsData([])
+ }
+ } catch (error) {
+ console.error("Error loading configs:", error)
+ setConfigsData([])
+ }
+ } else {
+ console.log("selectedNumberType is null, skipping configs load")
+ }
+ }
+
+ loadConfigs()
+ }, [selectedNumberType])
+
+ // advanced filter fields 정의
+ const advancedFilterFields: DataTableAdvancedFilterField<NumberTypeConfig>[] = [
+ { id: "codeGroupName", label: "Code Group", type: "text" },
+ { id: "sdq", label: "순서", type: "number" },
+ { id: "description", label: "Description", type: "text" },
+ { id: "remark", label: "Remark", type: "text" },
+ { id: "isActive", label: "상태", type: "select", options: [ { label: "활성", value: "true"}, { label: "비활성", value: "false" }] },
+ ]
+
+ // useDataTable 적용
+ const columns = React.useMemo(() => {
+ const cols = getColumns({ setRowAction })
+ console.log("Generated columns:", cols.map(col => (col as any).id || (col as any).accessorKey))
+ return cols
+ }, [setRowAction])
+
+ // 클라이언트 사이드 정렬을 위한 정렬된 데이터
+ const sortedConfigsData = React.useMemo(() => {
+ return [...configsData].sort((a, b) => {
+ // 기본적으로 sdq 순으로 정렬
+ return a.sdq - b.sdq
+ })
+ }, [configsData])
+
+ const { table } = useDataTable({
+ data: sortedConfigsData,
+ columns: columns,
+ pageCount: 1,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ manualSorting: false,
+ initialState: {
+ sorting: [{ id: "sdq", desc: false }],
+ // columnPinning: { right: ["actions"] }, // 일시적으로 제거
+ },
+ getRowId: (row) => String(row.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ const refreshData = React.useCallback(async () => {
+ if (selectedNumberType) {
+ try {
+ const result = await getNumberTypeConfigs(selectedNumberType)
+ if (result.success && result.data) setConfigsData(result.data)
+ } catch (error) {
+ console.error("Error refreshing data:", error)
+ }
+ }
+ }, [selectedNumberType])
+
+ return (
+ <>
+ {/* Number Type 선택 */}
+ <div className="mb-6">
+ <label className="text-sm font-medium mb-2 block">Number Type 선택</label>
+ <div className="flex gap-2">
+ {rawData[0]?.data && rawData[0].data.length > 0 ? (
+ rawData[0].data.map((numberType) => (
+ <button
+ key={numberType.id}
+ onClick={() => {
+ setSelectedNumberType(numberType.id)
+ fetchConfigs(numberType.id)
+ }}
+ className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
+ selectedNumberType === numberType.id
+ ? "bg-primary text-primary-foreground"
+ : "bg-muted text-muted-foreground hover:bg-muted/80"
+ }`}
+ >
+ {numberType.name}
+ </button>
+ ))
+ ) : (
+ <div className="px-4 py-2 text-sm text-muted-foreground">
+ Number Type을 불러오는 중...
+ </div>
+ )}
+ </div>
+ </div>
+
+ {/* 선택된 Number Type 정보 및 테이블: 조건부 렌더링 */}
+ {selectedNumberType && rawData[0]?.data && rawData[0].data.length > 0 && (
+ <>
+ <DragDropTable
+ table={table}
+ data={configsData}
+ onDragEnd={handleDragEnd}
+ >
+ <DataTableAdvancedToolbar table={table} filterFields={advancedFilterFields}>
+ <NumberTypeConfigsTableToolbarActions
+ table={table}
+ onSuccess={refreshData}
+ selectedNumberType={selectedNumberType}
+ configsData={configsData}
+ />
+ </DataTableAdvancedToolbar>
+ </DragDropTable>
+ </>
+ )}
+
+ {/* 삭제/수정 다이얼로그 */}
+ <DeleteNumberTypeConfigsDialog
+ open={rowAction?.type === "delete"}
+ onOpenChange={() => setRowAction(null)}
+ configs={rowAction?.row ? [rowAction.row.original] : []}
+ showTrigger={false}
+ onSuccess={refreshData}
+ />
+ <NumberTypeConfigsEditDialog
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ data={rowAction?.row?.original ?? null}
+ existingConfigs={configsData}
+ onSuccess={refreshData}
+ />
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/number-type-configs/validation.ts b/lib/docu-list-rule/number-type-configs/validation.ts
new file mode 100644
index 00000000..2b3aaa2d
--- /dev/null
+++ b/lib/docu-list-rule/number-type-configs/validation.ts
@@ -0,0 +1,12 @@
+import { createSearchParamsCache } from "nuqs/server";
+import { parseAsInteger, parseAsString, parseAsArrayOf, parseAsStringEnum } from "nuqs/server";
+import { getSortingStateParser, getFiltersStateParser } from "@/lib/parsers";
+
+export const searchParamsNumberTypeConfigsCache = createSearchParamsCache({
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<any>(),
+ filters: getFiltersStateParser(),
+ search: parseAsString.withDefault(""),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+}); \ No newline at end of file
diff --git a/lib/docu-list-rule/number-types/service.ts b/lib/docu-list-rule/number-types/service.ts
new file mode 100644
index 00000000..8eaf19c7
--- /dev/null
+++ b/lib/docu-list-rule/number-types/service.ts
@@ -0,0 +1,251 @@
+"use server"
+
+import { revalidatePath } from "next/cache"
+import db from "@/db/db"
+import { documentNumberTypes, documentNumberTypeConfigs } from "@/db/schema/docu-list-rule"
+import { eq, sql } from "drizzle-orm"
+import { unstable_noStore } from "next/cache"
+
+// Number Types 목록 조회
+export async function getNumberTypes(input: {
+ page: number
+ perPage: number
+ search?: string
+ sort?: Array<{ id: string; desc: boolean }>
+ filters?: Array<{ id: string; value: string }>
+ joinOperator?: "and" | "or"
+ flags?: string[]
+ numberTypeId?: string
+ description?: string
+ isActive?: string
+}) {
+ unstable_noStore()
+
+ try {
+ const { page, perPage, sort, search } = input
+ const offset = (page - 1) * perPage
+
+ // 기본 조건
+ let whereConditions = sql`1=1`
+
+ // 검색 조건
+ if (search) {
+ const searchTerm = `%${search}%`
+ whereConditions = sql`${whereConditions} AND (
+ ${documentNumberTypes.name} ILIKE ${searchTerm} OR
+ ${documentNumberTypes.description} ILIKE ${searchTerm}
+ )`
+ }
+
+ // 정렬
+ let orderBy = sql`${documentNumberTypes.createdAt} DESC`
+ if (sort && sort.length > 0) {
+ const sortField = sort[0]
+ const direction = sortField.desc ? sql`DESC` : sql`ASC`
+
+ switch (sortField.id) {
+ case "id":
+ orderBy = sql`${documentNumberTypes.id} ${direction}`
+ break
+ case "name":
+ orderBy = sql`${documentNumberTypes.name} ${direction}`
+ break
+ case "description":
+ orderBy = sql`${documentNumberTypes.description} ${direction}`
+ break
+ case "isActive":
+ orderBy = sql`${documentNumberTypes.isActive} ${direction}`
+ break
+ case "createdAt":
+ orderBy = sql`${documentNumberTypes.createdAt} ${direction}`
+ break
+ default:
+ orderBy = sql`${documentNumberTypes.createdAt} DESC`
+ }
+ }
+
+ // 데이터 조회
+ const data = await db
+ .select({
+ id: documentNumberTypes.id,
+ name: documentNumberTypes.name,
+ description: documentNumberTypes.description,
+ isActive: documentNumberTypes.isActive,
+ createdAt: documentNumberTypes.createdAt,
+ updatedAt: documentNumberTypes.updatedAt,
+ })
+ .from(documentNumberTypes)
+ .where(whereConditions)
+ .orderBy(orderBy)
+ .limit(perPage)
+ .offset(offset)
+
+ // 총 개수 조회
+ const [{ count: total }] = await db
+ .select({ count: sql`count(*)` })
+ .from(documentNumberTypes)
+ .where(whereConditions)
+
+ const pageCount = Math.ceil(Number(total) / perPage)
+
+ return {
+ success: true,
+ data,
+ pageCount,
+ }
+ } catch (error) {
+ console.error("Error fetching number types:", error)
+ return {
+ success: false,
+ error: "Failed to fetch number types",
+ data: [],
+ pageCount: 0,
+ }
+ }
+}
+
+// Number Type 생성
+export async function createNumberType(input: {
+ name: string
+ description?: string
+ isActive?: boolean
+}) {
+ try {
+ // 중복 이름 체크
+ const existing = await db
+ .select({ id: documentNumberTypes.id })
+ .from(documentNumberTypes)
+ .where(eq(documentNumberTypes.name, input.name))
+ .limit(1)
+
+ if (existing.length > 0) {
+ return {
+ success: false,
+ error: "Number Type with this name already exists"
+ }
+ }
+
+ const [newNumberType] = await db
+ .insert(documentNumberTypes)
+ .values({
+ name: input.name,
+ description: input.description,
+ isActive: input.isActive ?? true,
+ })
+ .returning({ id: documentNumberTypes.id })
+
+ revalidatePath("/evcp/docu-list-rule/number-types")
+
+ return {
+ success: true,
+ data: newNumberType,
+ message: "Number Type created successfully"
+ }
+ } catch (error) {
+ console.error("Error creating number type:", error)
+ return {
+ success: false,
+ error: "Failed to create number type"
+ }
+ }
+}
+
+// Number Type 수정
+export async function updateNumberType(input: {
+ id: number
+ name: string
+ description?: string
+ isActive?: boolean
+}) {
+ try {
+ // 다른 Number Type에서 같은 이름 사용하는지 체크
+ const existing = await db
+ .select({ id: documentNumberTypes.id })
+ .from(documentNumberTypes)
+ .where(sql`${documentNumberTypes.name} = ${input.name} AND ${documentNumberTypes.id} != ${input.id}`)
+ .limit(1)
+
+ if (existing.length > 0) {
+ return {
+ success: false,
+ error: "Number Type with this name already exists"
+ }
+ }
+
+ const [updatedNumberType] = await db
+ .update(documentNumberTypes)
+ .set({
+ name: input.name,
+ description: input.description,
+ isActive: input.isActive,
+ updatedAt: new Date(),
+ })
+ .where(eq(documentNumberTypes.id, input.id))
+ .returning({ id: documentNumberTypes.id })
+
+ if (!updatedNumberType) {
+ return {
+ success: false,
+ error: "Number Type not found"
+ }
+ }
+
+ revalidatePath("/evcp/docu-list-rule/number-types")
+
+ return {
+ success: true,
+ data: updatedNumberType,
+ message: "Number Type updated successfully"
+ }
+ } catch (error) {
+ console.error("Error updating number type:", error)
+ return {
+ success: false,
+ error: "Failed to update number type"
+ }
+ }
+}
+
+// Number Type 삭제
+export async function deleteNumberType(id: number) {
+ try {
+ // 관련된 config가 있는지 확인
+ const relatedConfigs = await db
+ .select({ id: documentNumberTypeConfigs.id })
+ .from(documentNumberTypeConfigs)
+ .where(eq(documentNumberTypeConfigs.documentNumberTypeId, id))
+ .limit(1)
+
+ if (relatedConfigs.length > 0) {
+ return {
+ success: false,
+ error: "Cannot delete Number Type with existing configurations"
+ }
+ }
+
+ const [deletedNumberType] = await db
+ .delete(documentNumberTypes)
+ .where(eq(documentNumberTypes.id, id))
+ .returning({ id: documentNumberTypes.id })
+
+ if (!deletedNumberType) {
+ return {
+ success: false,
+ error: "Number Type not found"
+ }
+ }
+
+ revalidatePath("/evcp/docu-list-rule/number-types")
+
+ return {
+ success: true,
+ message: "Number Type deleted successfully"
+ }
+ } catch (error) {
+ console.error("Error deleting number type:", error)
+ return {
+ success: false,
+ error: "Failed to delete number type"
+ }
+ }
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/number-types/table/delete-number-types-dialog.tsx b/lib/docu-list-rule/number-types/table/delete-number-types-dialog.tsx
new file mode 100644
index 00000000..8d1bc679
--- /dev/null
+++ b/lib/docu-list-rule/number-types/table/delete-number-types-dialog.tsx
@@ -0,0 +1,152 @@
+"use client"
+
+import * as React from "react"
+import { Loader, Trash } from "lucide-react"
+import { toast } from "sonner"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+
+import { deleteNumberType } from "../service"
+import { documentNumberTypes } from "@/db/schema/docu-list-rule"
+
+interface DeleteNumberTypesDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ numberTypes: typeof documentNumberTypes.$inferSelect[]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeleteNumberTypesDialog({
+ numberTypes,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeleteNumberTypesDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onDelete() {
+ startDeleteTransition(async () => {
+ try {
+ for (const numberType of numberTypes) {
+ const result = await deleteNumberType(numberType.id)
+ if (!result.success) {
+ toast.error(`Number Type 삭제 실패: ${result.error}`)
+ return
+ }
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("Number Type이 성공적으로 삭제되었습니다.")
+ onSuccess?.()
+ } catch (error) {
+ console.error("Delete error:", error)
+ toast.error("Number Type 삭제 중 오류가 발생했습니다.")
+ }
+ })
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제 ({numberTypes.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle>
+ <DialogDescription>
+ 이 작업은 되돌릴 수 없습니다. 선택된{" "}
+ <span className="font-medium">{numberTypes.length}</span>
+ 개의 Number Type을 서버에서 영구적으로 삭제합니다.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">취소</Button>
+ </DialogClose>
+ <Button
+ aria-label="선택된 행 삭제"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 삭제
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제 ({numberTypes.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle>
+ <DrawerDescription>
+ 이 작업은 되돌릴 수 없습니다. 선택된{" "}
+ <span className="font-medium">{numberTypes.length}</span>
+ 개의 Number Type을 서버에서 영구적으로 삭제합니다.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">취소</Button>
+ </DrawerClose>
+ <Button
+ aria-label="선택된 행 삭제"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ 삭제
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/number-types/table/number-type-add-dialog.tsx b/lib/docu-list-rule/number-types/table/number-type-add-dialog.tsx
new file mode 100644
index 00000000..78c3ec37
--- /dev/null
+++ b/lib/docu-list-rule/number-types/table/number-type-add-dialog.tsx
@@ -0,0 +1,125 @@
+"use client"
+
+import * as React from "react"
+import { useState } from "react"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Plus } from "lucide-react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import { toast } from "sonner"
+import { createNumberType } from "../service"
+
+interface NumberTypeAddDialogProps {
+ onSuccess: () => void
+}
+
+export function NumberTypeAddDialog({ onSuccess }: NumberTypeAddDialogProps) {
+ const [open, setOpen] = useState(false)
+ const [loading, setLoading] = useState(false)
+ const [formData, setFormData] = useState({
+ name: "",
+ description: "",
+ })
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+
+ if (!formData.name.trim()) {
+ toast.error("Name은 필수 입력 항목입니다.")
+ return
+ }
+
+ setLoading(true)
+ try {
+ const result = await createNumberType({
+ name: formData.name.trim(),
+ description: formData.description.trim() || undefined,
+ })
+
+ if (result.success) {
+ toast.success("Number Type이 생성되었습니다.")
+ setFormData({ name: "", description: "" })
+ setOpen(false)
+ onSuccess()
+ } else {
+ toast.error(result.error || "Number Type 생성에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("Number Type 생성 실패:", error)
+ toast.error("Number Type 생성에 실패했습니다.")
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleCancel = () => {
+ setFormData({ name: "", description: "" })
+ setOpen(false)
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Plus className="mr-2 h-4 w-4" />
+ Add
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="sm:max-w-[425px]">
+ <DialogHeader>
+ <DialogTitle>Add Number Type</DialogTitle>
+ <DialogDescription>
+ 새로운 Number Type을 추가합니다.
+ </DialogDescription>
+ </DialogHeader>
+ <form onSubmit={handleSubmit} className="space-y-4">
+ <div className="space-y-2">
+ <Label htmlFor="name">Name *</Label>
+ <Input
+ id="name"
+ value={formData.name}
+ onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
+ placeholder="Enter name"
+ required
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="description">Description</Label>
+ <Input
+ id="description"
+ value={formData.description}
+ onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
+ placeholder="Enter description"
+ />
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleCancel}
+ disabled={loading}
+ >
+ Cancel
+ </Button>
+ <Button
+ type="submit"
+ disabled={loading}
+ >
+ {loading ? "Creating..." : "Create"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/number-types/table/number-type-edit-sheet.tsx b/lib/docu-list-rule/number-types/table/number-type-edit-sheet.tsx
new file mode 100644
index 00000000..f4066cea
--- /dev/null
+++ b/lib/docu-list-rule/number-types/table/number-type-edit-sheet.tsx
@@ -0,0 +1,159 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+import * as z from "zod"
+import { Loader } from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Input } from "@/components/ui/input"
+import { updateNumberType } from "../service"
+import { documentNumberTypes } from "@/db/schema/docu-list-rule"
+
+const updateNumberTypeSchema = z.object({
+ name: z.string().min(1, "이름은 필수입니다."),
+ description: z.string().optional(),
+})
+
+type UpdateNumberTypeSchema = z.infer<typeof updateNumberTypeSchema>
+
+interface NumberTypeEditSheetProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ data: typeof documentNumberTypes.$inferSelect | null
+ onSuccess: () => void
+}
+
+export function NumberTypeEditSheet({
+ open,
+ onOpenChange,
+ data,
+ onSuccess,
+}: NumberTypeEditSheetProps) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+
+ const form = useForm<UpdateNumberTypeSchema>({
+ resolver: zodResolver(updateNumberTypeSchema),
+ defaultValues: {
+ name: data?.name ?? "",
+ description: data?.description ?? "",
+ },
+ mode: "onChange"
+ })
+
+ React.useEffect(() => {
+ if (data) {
+ form.reset({
+ name: data.name,
+ description: data.description || "",
+ })
+ }
+ }, [data, form])
+
+ async function onSubmit(input: UpdateNumberTypeSchema) {
+ if (!data) return
+
+ startUpdateTransition(async () => {
+ try {
+ const result = await updateNumberType({
+ id: data.id,
+ name: input.name,
+ description: input.description,
+ })
+
+ if (result.success) {
+ toast.success("Number Type이 성공적으로 수정되었습니다.")
+ onSuccess()
+ onOpenChange(false)
+ } else {
+ toast.error(result.error || "수정에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("Update error:", error)
+ toast.error("Number Type 수정 중 오류가 발생했습니다.")
+ }
+ })
+ }
+
+ return (
+ <Sheet open={open} onOpenChange={onOpenChange}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>Number Type 수정</SheetTitle>
+ <SheetDescription>
+ Number Type 정보를 수정하고 변경사항을 저장하세요
+ </SheetDescription>
+ </SheetHeader>
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
+ <FormField
+ control={form.control}
+ name="name"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>이름</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="Number Type 이름" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>설명</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="설명" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ 취소
+ </Button>
+ </SheetClose>
+ <Button
+ type="submit"
+ disabled={isUpdatePending || !form.formState.isValid}
+ >
+ {isUpdatePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 저장
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/number-types/table/number-types-table-columns.tsx b/lib/docu-list-rule/number-types/table/number-types-table-columns.tsx
new file mode 100644
index 00000000..93361b93
--- /dev/null
+++ b/lib/docu-list-rule/number-types/table/number-types-table-columns.tsx
@@ -0,0 +1,156 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Ellipsis } from "lucide-react"
+
+import { formatDateTime } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { documentNumberTypes } from "@/db/schema/docu-list-rule"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<typeof documentNumberTypes.$inferSelect> | null>>
+}
+
+/**
+ * tanstack table 컬럼 정의
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<typeof documentNumberTypes.$inferSelect>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<typeof documentNumberTypes.$inferSelect> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ maxSize: 30,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 2) actions 컬럼 (Dropdown 메뉴)
+ // ----------------------------------------------------------------
+ const actionsColumn: ColumnDef<typeof documentNumberTypes.$inferSelect> = {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "update" })}
+ >
+ Edit
+ </DropdownMenuItem>
+
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "delete" })}
+ >
+ Delete
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ maxSize: 30,
+ }
+
+ // ----------------------------------------------------------------
+ // 3) 데이터 컬럼들
+ // ----------------------------------------------------------------
+ const dataColumns: ColumnDef<typeof documentNumberTypes.$inferSelect>[] = [
+ {
+ accessorKey: "name",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="이름" />
+ ),
+ meta: {
+ excelHeader: "이름",
+ type: "text",
+ },
+ cell: ({ row }) => row.getValue("name") ?? "",
+ minSize: 80
+ },
+ {
+ accessorKey: "description",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="설명" />
+ ),
+ meta: {
+ excelHeader: "설명",
+ type: "text",
+ },
+ cell: ({ row }) => row.getValue("description") ?? "",
+ minSize: 80
+ },
+
+ {
+ accessorKey: "createdAt",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="생성일" />
+ ),
+ meta: {
+ excelHeader: "생성일",
+ type: "date",
+ },
+ cell: ({ row }) => {
+ const dateVal = row.getValue("createdAt") as Date
+ return formatDateTime(dateVal, "KR")
+ },
+ minSize: 80
+ }
+ ]
+
+ // ----------------------------------------------------------------
+ // 4) 최종 컬럼 배열: select, dataColumns, actions
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ ...dataColumns,
+ actionsColumn,
+ ]
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/number-types/table/number-types-table-toolbar.tsx b/lib/docu-list-rule/number-types/table/number-types-table-toolbar.tsx
new file mode 100644
index 00000000..306a3d74
--- /dev/null
+++ b/lib/docu-list-rule/number-types/table/number-types-table-toolbar.tsx
@@ -0,0 +1,53 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download } from "lucide-react"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { DeleteNumberTypesDialog } from "./delete-number-types-dialog"
+import { NumberTypeAddDialog } from "./number-type-add-dialog"
+import { documentNumberTypes } from "@/db/schema/docu-list-rule"
+
+interface NumberTypesTableToolbarActionsProps {
+ table: Table<typeof documentNumberTypes.$inferSelect>
+ onSuccess?: () => void
+}
+
+export function NumberTypesTableToolbarActions({ table, onSuccess }: NumberTypesTableToolbarActionsProps) {
+ return (
+ <div className="flex items-center gap-2">
+ {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */}
+ {table.getFilteredSelectedRowModel().rows.length > 0 ? (
+ <DeleteNumberTypesDialog
+ numberTypes={table
+ .getFilteredSelectedRowModel()
+ .rows.map((row) => row.original)}
+ onSuccess={() => {
+ table.toggleAllRowsSelected(false)
+ onSuccess?.()
+ }}
+ />
+ ) : null}
+
+ <NumberTypeAddDialog onSuccess={onSuccess || (() => {})} />
+
+ {/** 3) Export 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "number-types-list",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ className="gap-2"
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/number-types/table/number-types-table.tsx b/lib/docu-list-rule/number-types/table/number-types-table.tsx
new file mode 100644
index 00000000..5d25c4b5
--- /dev/null
+++ b/lib/docu-list-rule/number-types/table/number-types-table.tsx
@@ -0,0 +1,88 @@
+"use client"
+
+import * as React from "react"
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import type { DataTableAdvancedFilterField, DataTableRowAction } from "@/types/table"
+import { getNumberTypes } from "../service"
+import { getColumns } from "./number-types-table-columns"
+import { NumberTypeEditSheet } from "./number-type-edit-sheet"
+import { DeleteNumberTypesDialog } from "./delete-number-types-dialog"
+import { NumberTypesTableToolbarActions } from "./number-types-table-toolbar"
+import { documentNumberTypes } from "@/db/schema/docu-list-rule"
+
+interface NumberTypesTableProps {
+ promises?: Promise<[{ data: typeof documentNumberTypes.$inferSelect[]; pageCount: number }]>
+}
+
+export function NumberTypesTable({ promises }: NumberTypesTableProps) {
+ const rawData = React.use(promises!)
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<typeof documentNumberTypes.$inferSelect> | null>(null)
+
+ const refreshData = React.useCallback(() => {
+ window.location.reload()
+ }, [])
+
+ const columns = React.useMemo(() => getColumns({ setRowAction }), [setRowAction])
+
+ // 고급 필터 필드 설정
+ const advancedFilterFields: DataTableAdvancedFilterField<typeof documentNumberTypes.$inferSelect>[] = [
+ { id: "name", label: "이름", type: "text" },
+ { id: "description", label: "설명", type: "text" },
+ {
+ id: "isActive", label: "상태", type: "select", options: [
+ { label: "활성", value: "true" },
+ { label: "비활성", value: "false" },
+ ]
+ },
+ { id: "createdAt", label: "생성일", type: "date" },
+ ]
+
+ const { table } = useDataTable({
+ data: rawData[0].data as any,
+ columns,
+ pageCount: rawData[0].pageCount,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ manualSorting: false,
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ >
+ <NumberTypesTableToolbarActions table={table} onSuccess={refreshData} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ <DeleteNumberTypesDialog
+ open={rowAction?.type === "delete"}
+ onOpenChange={() => setRowAction(null)}
+ numberTypes={rowAction?.row.original ? [rowAction?.row.original] : []}
+ showTrigger={false}
+ onSuccess={() => {
+ rowAction?.row.toggleSelected(false)
+ refreshData()
+ }}
+ />
+
+ <NumberTypeEditSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ data={rowAction?.row.original ?? null}
+ onSuccess={refreshData}
+ />
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/number-types/validation.ts b/lib/docu-list-rule/number-types/validation.ts
new file mode 100644
index 00000000..72e2921d
--- /dev/null
+++ b/lib/docu-list-rule/number-types/validation.ts
@@ -0,0 +1,12 @@
+import { createSearchParamsCache } from "nuqs/server";
+import { parseAsInteger, parseAsString, parseAsArrayOf, parseAsStringEnum } from "nuqs/server";
+import { getSortingStateParser, getFiltersStateParser } from "@/lib/parsers";
+
+export const searchParamsNumberTypesCache = createSearchParamsCache({
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<any>(),
+ filters: getFiltersStateParser(),
+ search: parseAsString.withDefault(""),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+}); \ No newline at end of file
diff --git a/lib/docu-list-rule/types.ts b/lib/docu-list-rule/types.ts
new file mode 100644
index 00000000..a5277132
--- /dev/null
+++ b/lib/docu-list-rule/types.ts
@@ -0,0 +1,50 @@
+// docu-list-rule 모듈 공통 타입 정의
+
+export interface NumberTypeConfig {
+ id: number
+ documentNumberTypeId: number
+ codeGroupId: number | null
+ documentClassId: number | null
+ sdq: number
+ description: string | null
+ remark: string | null
+ isActive: boolean | null
+ createdAt: Date
+ updatedAt: Date
+ codeGroupName: string | null
+ codeGroupControlType: string | null
+ documentClassName: string | null
+ documentClassDescription: string | null
+}
+
+export interface CodeGroup {
+ id: number
+ groupId: string
+ description: string
+ codeFormat: string | null
+ expressions: string | null
+ controlType: string
+ isActive: boolean
+ createdAt: Date
+ updatedAt: Date
+}
+
+export interface DocumentClass {
+ id: number
+ code: string
+ value: string
+ description: string | null
+ codeGroupId: number | null
+ isActive: boolean
+ createdAt: Date
+ updatedAt: Date
+}
+
+export interface NumberType {
+ id: number
+ name: string
+ description: string | null
+ isActive: boolean
+ createdAt: Date
+ updatedAt: Date
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/utils.ts b/lib/docu-list-rule/utils.ts
new file mode 100644
index 00000000..ddeb5e6d
--- /dev/null
+++ b/lib/docu-list-rule/utils.ts
@@ -0,0 +1,60 @@
+// docu-list-rule 모듈 공통 유틸리티 함수들
+
+/**
+ * Code Group ID에서 다음 번호를 생성하는 함수
+ * DOC_CLASS는 제외하고 계산
+ */
+export function generateNextCodeGroupId(lastGroupId: string): string {
+ if (!lastGroupId.startsWith('GROUP')) {
+ return 'GROUP001'
+ }
+
+ const numberPart = lastGroupId.substring(5)
+ const nextNumber = parseInt(numberPart) + 1
+ return `GROUP${nextNumber.toString().padStart(3, '0')}`
+}
+
+/**
+ * Document Class Code에서 다음 번호를 생성하는 함수
+ */
+export function generateNextDocumentClassCode(lastCode: string): string {
+ if (!lastCode.startsWith('DOC_CLASS_')) {
+ return 'DOC_CLASS_001'
+ }
+
+ const numberPart = lastCode.substring(10)
+ const nextNumber = parseInt(numberPart) + 1
+ return `DOC_CLASS_${nextNumber.toString().padStart(3, '0')}`
+}
+
+/**
+ * Number Type Config에서 다음 SDQ를 생성하는 함수
+ */
+export function generateNextSdq(configs: Array<{ sdq: number }>): number {
+ if (configs.length === 0) {
+ return 1
+ }
+
+ const maxSdq = Math.max(...configs.map(config => config.sdq))
+ return maxSdq + 1
+}
+
+/**
+ * 활성화된 항목만 필터링하는 함수
+ */
+export function filterActiveItems<T extends { isActive: boolean }>(items: T[]): T[] {
+ return items.filter(item => item.isActive)
+}
+
+/**
+ * 날짜를 포맷팅하는 함수
+ */
+export function formatDate(date: Date): string {
+ return new Intl.DateTimeFormat('ko-KR', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit'
+ }).format(date)
+} \ No newline at end of file