diff options
| author | 0-Zz-ang <s1998319@gmail.com> | 2025-07-29 09:08:52 +0900 |
|---|---|---|
| committer | 0-Zz-ang <s1998319@gmail.com> | 2025-07-29 09:11:22 +0900 |
| commit | 8d92c88ab341156d82156bae49c62a8101280e75 (patch) | |
| tree | 065ed1838de4164da23e3777b5367143e4f13982 /lib | |
| parent | 75249e6fa46864f49d4eb91bd755171b6b65eaae (diff) | |
(박서영) 설계 Document Numbering Rule 수정
Diffstat (limited to 'lib')
18 files changed, 1227 insertions, 866 deletions
diff --git a/lib/docu-list-rule/code-groups/service.ts b/lib/docu-list-rule/code-groups/service.ts index 34ec5610..2c30cedb 100644 --- a/lib/docu-list-rule/code-groups/service.ts +++ b/lib/docu-list-rule/code-groups/service.ts @@ -6,28 +6,18 @@ import { codeGroups, comboBoxSettings, documentClasses } from "@/db/schema/docu- 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) { +// Code Groups 목록 조회 +export async function getCodeGroups(input: any) { unstable_noStore() try { - const { page, perPage, sort, search } = input + const { page, perPage, search, filters, joinOperator } = 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 ( @@ -38,33 +28,50 @@ export async function getCodeGroups(input: { )` } - // 정렬 + // 고급 필터링 (단순화) + if (filters && filters.length > 0) { + const filterConditions = filters.map(filter => { + const { id, value } = filter + if (!value || Array.isArray(value)) return null + + switch (id) { + case "groupId": + return sql`${codeGroups.groupId} ILIKE ${`%${value}%`}` + case "description": + return sql`${codeGroups.description} ILIKE ${`%${value}%`}` + case "codeFormat": + return sql`${codeGroups.codeFormat} ILIKE ${`%${value}%`}` + case "controlType": + return sql`${codeGroups.controlType} = ${value}` + case "isActive": + return sql`${codeGroups.isActive} = ${value === "true"}` + case "createdAt": + return sql`${codeGroups.createdAt}::text ILIKE ${`%${value}%`}` + default: + return null + } + }).filter(Boolean) + + if (filterConditions.length > 0) { + const operator = joinOperator === "or" ? sql` OR ` : sql` AND ` + const combinedFilters = filterConditions.reduce((acc, condition, index) => { + if (index === 0) return condition + return sql`${acc}${operator}${condition}` + }) + + whereConditions = sql`${whereConditions} AND (${combinedFilters})` + } + } + + // 정렬 (안전한 필드 체크 적용) 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` + if (input.sort && input.sort.length > 0) { + const sortField = input.sort[0] + // 안전성 체크: 필드가 실제 테이블에 존재하는지 확인 + if (sortField && sortField.id && typeof sortField.id === "string" && sortField.id in codeGroups) { + const direction = sortField.desc ? sql`DESC` : sql`ASC` + const col = codeGroups[sortField.id as keyof typeof codeGroups] + orderBy = sql`${col} ${direction}` } } 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 index cb6cdf8b..c15dd676 100644 --- 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 @@ -1,3 +1,4 @@ + "use client" import * as React from "react" 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 index 6d8bb907..c10d3445 100644 --- a/lib/docu-list-rule/code-groups/table/code-groups-table.tsx +++ b/lib/docu-list-rule/code-groups/table/code-groups-table.tsx @@ -1,5 +1,6 @@ "use client"; import * as React from "react"; +import { useRouter } from "next/navigation"; 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"; @@ -8,7 +9,6 @@ import type { 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"; @@ -20,14 +20,15 @@ interface CodeGroupsTableProps { } export function CodeGroupsTable({ promises }: CodeGroupsTableProps) { + const router = useRouter(); 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(); - }, []); + // 전체 페이지 새로고침 대신 router.refresh() 사용 (성능 개선) + router.refresh(); + }, [router]); // 컬럼 설정 - 외부 파일에서 가져옴 const columns = React.useMemo( diff --git a/lib/docu-list-rule/combo-box-settings/service.ts b/lib/docu-list-rule/combo-box-settings/service.ts index b603ee71..70046828 100644 --- a/lib/docu-list-rule/combo-box-settings/service.ts +++ b/lib/docu-list-rule/combo-box-settings/service.ts @@ -22,7 +22,7 @@ export async function getComboBoxCodeGroups(input: { unstable_noStore() try { - const { page, perPage, sort, search } = input + const { page, perPage, sort, search, filters, joinOperator } = input const offset = (page - 1) * perPage // Control Type이 combobox인 조건 @@ -38,33 +38,50 @@ export async function getComboBoxCodeGroups(input: { )` } - // 정렬 + // 고급 필터링 + if (filters && filters.length > 0) { + const filterConditions = filters.map(filter => { + const { id, value } = filter + if (!value) return null + + switch (id) { + case "groupId": + return sql`${codeGroups.groupId} ILIKE ${`%${value}%`}` + case "description": + return sql`${codeGroups.description} ILIKE ${`%${value}%`}` + case "codeFormat": + return sql`${codeGroups.codeFormat} ILIKE ${`%${value}%`}` + case "controlType": + return sql`${codeGroups.controlType} = ${value}` + case "isActive": + return sql`${codeGroups.isActive} = ${value === "true"}` + case "createdAt": + return sql`${codeGroups.createdAt}::text ILIKE ${`%${value}%`}` + default: + return null + } + }).filter(Boolean) + + if (filterConditions.length > 0) { + const operator = joinOperator === "or" ? sql` OR ` : sql` AND ` + const combinedFilters = filterConditions.reduce((acc, condition, index) => { + if (index === 0) return condition + return sql`${acc}${operator}${condition}` + }) + + whereConditions = sql`${whereConditions} AND (${combinedFilters})` + } + } + + // 정렬 (안전한 필드 체크 적용) 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` + // 안전성 체크: 필드가 실제 테이블에 존재하는지 확인 + if (sortField && sortField.id && typeof sortField.id === "string" && sortField.id in codeGroups) { + const direction = sortField.desc ? sql`DESC` : sql`ASC` + const col = codeGroups[sortField.id as keyof typeof codeGroups] + orderBy = sql`${col} ${direction}` } } @@ -137,30 +154,15 @@ export async function getComboBoxOptions(codeGroupId: number, input?: { )` } - // 정렬 + // 정렬 (안전한 필드 체크 적용) 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` + // 안전성 체크: 필드가 실제 테이블에 존재하는지 확인 + if (sortField && sortField.id && typeof sortField.id === "string" && sortField.id in comboBoxSettings) { + const direction = sortField.desc ? sql`DESC` : sql`ASC` + const col = comboBoxSettings[sortField.id as keyof typeof comboBoxSettings] + orderBy = sql`${col} ${direction}` } } 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 index 1c145c55..0b2a76a4 100644 --- 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 @@ -88,13 +88,12 @@ export function ComboBoxOptionsDetailSheet({ const result = await getComboBoxOptions(codeGroup.id, { page: 1, perPage: 10, - search: "", - sort: [{ id: "createdAt", desc: true }], - filters: [], + search: table.getState().globalFilter || "", + sort: table.getState().sorting, + filters: table.getState().columnFilters, joinOperator: "and", }) if (result.success && result.data) { - // isActive 필드가 없는 경우 기본값 true로 설정 const optionsWithIsActive = result.data.map(option => ({ ...option, isActive: (option as any).isActive ?? true @@ -126,6 +125,7 @@ export function ComboBoxOptionsDetailSheet({ enableAdvancedFilter: true, manualSorting: true, manualFiltering: true, + manualPagination: true, // 수동 페이징 활성화 initialState: { sorting: [{ id: "createdAt", desc: true }], columnPinning: { right: ["actions"] }, 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 index 356b2706..f6216363 100644 --- 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 @@ -1,6 +1,7 @@ "use client" import * as React from "react" +import { useRouter } from "next/navigation" 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" @@ -16,13 +17,15 @@ interface ComboBoxSettingsTableProps { } export function ComboBoxSettingsTable({ promises }: ComboBoxSettingsTableProps) { + const router = useRouter() 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() - }, []) + // 전체 페이지 새로고침 대신 router.refresh() 사용 (성능 개선) + router.refresh() + }, [router]) // Detail 버튼 클릭 핸들러 const handleDetail = (codeGroup: typeof codeGroups.$inferSelect) => { diff --git a/lib/docu-list-rule/document-class/service.ts b/lib/docu-list-rule/document-class/service.ts index 04dfa50e..99d85ea5 100644 --- a/lib/docu-list-rule/document-class/service.ts +++ b/lib/docu-list-rule/document-class/service.ts @@ -19,7 +19,7 @@ export async function getDocumentClassCodeGroups(input: { isActive?: string }) { try { - const { page, perPage, sort, search } = input + const { page, perPage, sort, search, filters, joinOperator } = input const offset = (page - 1) * perPage // 기본 조건 @@ -35,27 +35,48 @@ export async function getDocumentClassCodeGroups(input: { )` } - // 정렬 + // 고급 필터링 + if (filters && filters.length > 0) { + const filterConditions = filters.map(filter => { + const { id, value } = filter + if (!value) return null + + switch (id) { + case "code": + return sql`${documentClasses.code} ILIKE ${`%${value}%`}` + case "value": + return sql`${documentClasses.value} ILIKE ${`%${value}%`}` + case "description": + return sql`${documentClasses.description} ILIKE ${`%${value}%`}` + case "isActive": + return sql`${documentClasses.isActive} = ${value === "true"}` + case "createdAt": + return sql`${documentClasses.createdAt}::text ILIKE ${`%${value}%`}` + default: + return null + } + }).filter(Boolean) + + if (filterConditions.length > 0) { + const operator = joinOperator === "or" ? sql` OR ` : sql` AND ` + const combinedFilters = filterConditions.reduce((acc, condition, index) => { + if (index === 0) return condition + return sql`${acc}${operator}${condition}` + }) + + whereConditions = sql`${whereConditions} AND (${combinedFilters})` + } + } + + // 정렬 (안전한 필드 체크 적용) 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` + // 안전성 체크: 필드가 실제 테이블에 존재하는지 확인 + if (sortField && sortField.id && typeof sortField.id === "string" && sortField.id in documentClasses) { + const direction = sortField.desc ? sql`DESC` : sql`ASC` + const col = documentClasses[sortField.id as keyof typeof documentClasses] + orderBy = sql`${col} ${direction}` } } 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 index bbe79800..e3daac8a 100644 --- a/lib/docu-list-rule/document-class/table/document-class-table.tsx +++ b/lib/docu-list-rule/document-class/table/document-class-table.tsx @@ -1,6 +1,7 @@ "use client" import * as React from "react" +import { useRouter } from "next/navigation" 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" @@ -18,13 +19,15 @@ interface DocumentClassTableProps { } export function DocumentClassTable({ promises }: DocumentClassTableProps) { + const router = useRouter() 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() - }, []) + // 전체 페이지 새로고침 대신 router.refresh() 사용 (성능 개선) + router.refresh() + }, [router]) const columns = React.useMemo(() => getColumns({ setRowAction }), [setRowAction]) diff --git a/lib/docu-list-rule/number-type-configs/repository.ts b/lib/docu-list-rule/number-type-configs/repository.ts new file mode 100644 index 00000000..56be1bcf --- /dev/null +++ b/lib/docu-list-rule/number-type-configs/repository.ts @@ -0,0 +1,269 @@ +import db from "@/db/db" +import { documentNumberTypeConfigs } from "@/db/schema/docu-list-rule" +import { codeGroups, documentClasses } from "@/db/schema/docu-list-rule" +import { eq, asc, sql, count, ilike, or, and } from "drizzle-orm" +import { PgTransaction } from "drizzle-orm/pg-core" + +// Number Type Configs 조회 (고급 필터링 지원) +export async function selectNumberTypeConfigs( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tx: PgTransaction<any, any, any> | typeof db, + params: { + numberTypeId: number + // eslint-disable-next-line @typescript-eslint/no-explicit-any + where?: any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + orderBy?: any[] + offset?: number + limit?: number + search?: string + } +) { + const { numberTypeId, where, orderBy, offset = 0, limit = 10, search } = params + + let query = tx + .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)) + + // 기본 조건: 특정 Number Type + let whereCondition = eq(documentNumberTypeConfigs.documentNumberTypeId, numberTypeId) + + // 고급 필터 조건 추가 + if (where) { + whereCondition = and(whereCondition, where) || whereCondition + } + + // 검색 조건 추가 + if (search) { + const searchTerm = `%${search}%` + const searchCondition = or( + ilike(codeGroups.description, searchTerm), + ilike(documentNumberTypeConfigs.description, searchTerm), + ilike(documentNumberTypeConfigs.remark, searchTerm), + ilike(documentClasses.value, searchTerm) + ) + if (searchCondition) { + whereCondition = and(whereCondition, searchCondition) || whereCondition + } + } + + query = query.where(whereCondition) + + // 정렬 적용 + if (orderBy && orderBy.length > 0) { + query = query.orderBy(...orderBy) + } else { + query = query.orderBy(asc(documentNumberTypeConfigs.sdq)) + } + + return query.offset(offset).limit(limit) +} + +// Number Type Configs 개수 조회 +export async function countNumberTypeConfigs( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tx: PgTransaction<any, any, any> | typeof db, + params: { + numberTypeId: number + // eslint-disable-next-line @typescript-eslint/no-explicit-any + where?: any + search?: string + } +) { + const { numberTypeId, where, search } = params + + let query = tx + .select({ count: count() }) + .from(documentNumberTypeConfigs) + .leftJoin(codeGroups, eq(documentNumberTypeConfigs.codeGroupId, codeGroups.id)) + .leftJoin(documentClasses, eq(documentNumberTypeConfigs.documentClassId, documentClasses.id)) + + // 기본 조건: 특정 Number Type + let whereCondition = eq(documentNumberTypeConfigs.documentNumberTypeId, numberTypeId) + + // 고급 필터 조건 추가 + if (where) { + whereCondition = and(whereCondition, where) || whereCondition + } + + // 검색 조건 추가 + if (search) { + const searchTerm = `%${search}%` + const searchCondition = or( + ilike(codeGroups.description, searchTerm), + ilike(documentNumberTypeConfigs.description, searchTerm), + ilike(documentNumberTypeConfigs.remark, searchTerm), + ilike(documentClasses.value, searchTerm) + ) + if (searchCondition) { + whereCondition = and(whereCondition, searchCondition) || whereCondition + } + } + + const result = await query.where(whereCondition) + return result[0]?.count ?? 0 +} + +// Number Type Config 생성 +export async function insertNumberTypeConfig( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tx: PgTransaction<any, any, any> | typeof db, + input: { + documentNumberTypeId: number + codeGroupId: number | null + documentClassId: number | null + sdq: number + description?: string + remark?: string + } +) { + return tx + .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 }) +} + +// Number Type Config 수정 +export async function updateNumberTypeConfigById( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tx: PgTransaction<any, any, any> | typeof db, + id: number, + input: { + codeGroupId: number | null + documentClassId: number | null + sdq: number + description?: string + remark?: string + } +) { + return tx + .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, id)) + .returning({ id: documentNumberTypeConfigs.id }) +} + +// Number Type Config 삭제 +export async function deleteNumberTypeConfigById( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tx: PgTransaction<any, any, any> | typeof db, + id: number +) { + return tx + .delete(documentNumberTypeConfigs) + .where(eq(documentNumberTypeConfigs.id, id)) +} + +// 삭제할 Config 정보 조회 +export async function selectConfigToDelete( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tx: PgTransaction<any, any, any> | typeof db, + id: number +) { + return tx + .select({ + documentNumberTypeId: documentNumberTypeConfigs.documentNumberTypeId, + sdq: documentNumberTypeConfigs.sdq, + }) + .from(documentNumberTypeConfigs) + .where(eq(documentNumberTypeConfigs.id, id)) +} + +// 순서 재정렬 (삭제 후) +export async function reorderConfigsAfterDelete( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tx: PgTransaction<any, any, any> | typeof db, + documentNumberTypeId: number, + deletedSdq: number +) { + return tx + .update(documentNumberTypeConfigs) + .set({ + sdq: sql`${documentNumberTypeConfigs.sdq} - 1`, + updatedAt: new Date(), + }) + .where( + sql`${documentNumberTypeConfigs.documentNumberTypeId} = ${documentNumberTypeId} AND ${documentNumberTypeConfigs.sdq} > ${deletedSdq}` + ) +} + +// 활성화된 Code Groups 조회 +export async function selectActiveCodeGroups( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tx: PgTransaction<any, any, any> | typeof db +) { + return tx + .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)) +} + +// Document Class Code Group ID 조회 +export async function selectDocumentClassCodeGroupId( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tx: PgTransaction<any, any, any> | typeof db +) { + return tx + .select({ id: codeGroups.id }) + .from(codeGroups) + .where(eq(codeGroups.groupId, 'DOC_CLASS')) + .limit(1) +} + +// 활성화된 Document Classes 조회 +export async function selectActiveDocumentClasses( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tx: PgTransaction<any, any, any> | typeof db +) { + return tx + .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)) +}
\ 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 index 3e2cfc8e..1ba9b8f0 100644 --- a/lib/docu-list-rule/number-type-configs/service.ts +++ b/lib/docu-list-rule/number-type-configs/service.ts @@ -2,60 +2,96 @@ 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" +import { unstable_cache } from "@/lib/unstable-cache" +import { filterColumns } from "@/lib/filter-columns" +import { documentNumberTypeConfigs, codeGroups, documentClasses } from "@/db/schema/docu-list-rule" +import { asc, desc, eq, sql } from "drizzle-orm" +import { + selectNumberTypeConfigs, + countNumberTypeConfigs, + insertNumberTypeConfig, + updateNumberTypeConfigById, + deleteNumberTypeConfigById, + selectConfigToDelete, + reorderConfigsAfterDelete, + selectActiveCodeGroups, + selectDocumentClassCodeGroupId, + selectActiveDocumentClasses, +} from "./repository" +import { GetNumberTypeConfigsSchema } from "./validation" +// 특정 Number Type의 Configs 조회 (고급 필터링 지원) +export async function getNumberTypeConfigs(input: GetNumberTypeConfigsSchema) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage + // 고급 필터링 지원 + const advancedWhere = filterColumns({ + table: documentNumberTypeConfigs, + filters: input.filters, + joinOperator: input.joinOperator, + }) -// 특정 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)) + // 정렬 처리 + const orderBy = input.sort.length > 0 + ? input.sort + .map((item) => { + // 안전성 체크: 필드가 실제 테이블에 존재하는지 확인 + if (!item || !item.id || typeof item.id !== "string" || !(item.id in documentNumberTypeConfigs)) { + return null; + } + const col = documentNumberTypeConfigs[item.id as keyof typeof documentNumberTypeConfigs]; + return item.desc ? desc(col) : asc(col); + }) + .filter((v): v is Exclude<typeof v, null> => v !== null) + : [asc(documentNumberTypeConfigs.sdq)] - + // 트랜잭션 내부에서 Repository 호출 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectNumberTypeConfigs(tx, { + numberTypeId: input.numberTypeId, + where: advancedWhere, + orderBy, + offset, + limit: input.perPage, + search: input.search || undefined, + }) - return { - success: true, - data: configs, - } - } catch (error) { - - return { - success: false, - error: "Failed to fetch number type configs", - data: [], + const total = await countNumberTypeConfigs(tx, { + numberTypeId: input.numberTypeId, + where: advancedWhere, + search: input.search || undefined, + }) + + return { data, total } + }) + + const pageCount = Math.ceil(total / input.perPage) + + return { + success: true, + data, + pageCount, + } + } catch (error) { + console.error("Error fetching number type configs:", error) + return { + success: false, + error: "Failed to fetch number type configs", + data: [], + pageCount: 0, + } + } + }, + [JSON.stringify(input)], // 캐싱 키 + { + revalidate: 300, // 5분 캐시 + tags: ["number-type-configs"], } - } + )() } // Number Type Config 생성 @@ -68,23 +104,16 @@ export async function createNumberTypeConfig(input: { 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 }) + const result = await db.transaction(async (tx) => { + const [newConfig] = await insertNumberTypeConfig(tx, input) + return newConfig + }) revalidatePath("/evcp/docu-list-rule/number-type-configs") return { success: true, - data: newConfig, + data: result, message: "Number Type Config created successfully" } } catch (error) { @@ -106,24 +135,22 @@ export async function updateNumberTypeConfig(input: { remark?: string }) { try { - const [updatedConfig] = await db - .update(documentNumberTypeConfigs) - .set({ + const result = await db.transaction(async (tx) => { + const [updatedConfig] = await updateNumberTypeConfigById(tx, input.id, { 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 }) + return updatedConfig + }) revalidatePath("/evcp/docu-list-rule/number-type-configs") return { success: true, - data: updatedConfig, + data: result, message: "Number Type Config updated successfully" } } catch (error) { @@ -138,37 +165,24 @@ export async function updateNumberTypeConfig(input: { // 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)) + await db.transaction(async (tx) => { + // 삭제할 항목의 정보를 먼저 가져옴 + const [configToDelete] = await selectConfigToDelete(tx, id) - if (!configToDelete) { - return { - success: false, - error: "Config not found" + if (!configToDelete) { + throw new Error("Config not found") } - } - // 항목 삭제 - await db - .delete(documentNumberTypeConfigs) - .where(eq(documentNumberTypeConfigs.id, id)) + // 항목 삭제 + await deleteNumberTypeConfigById(tx, 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}` + // 같은 Number Type의 남은 항목들 중에서 삭제된 항목보다 큰 순서를 가진 항목들의 순서를 1씩 감소 + await reorderConfigsAfterDelete( + tx, + configToDelete.documentNumberTypeId, + configToDelete.sdq ) + }) revalidatePath("/evcp/docu-list-rule/number-type-configs") @@ -178,6 +192,14 @@ export async function deleteNumberTypeConfig(id: number) { } } catch (error) { console.error("Error deleting number type config:", error) + + if (error instanceof Error && error.message === "Config not found") { + return { + success: false, + error: "Config not found" + } + } + return { success: false, error: "Failed to delete number type config" @@ -187,22 +209,10 @@ export async function deleteNumberTypeConfig(id: number) { // 활성화된 Code Groups 조회 (Config 생성/수정 시 사용) export async function getActiveCodeGroups() { + unstable_noStore() + 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) + const codeGroupsData = await selectActiveCodeGroups(db) return { success: true, @@ -221,12 +231,7 @@ export async function getActiveCodeGroups() { // 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) - + const [codeGroup] = await selectDocumentClassCodeGroupId(db) return codeGroup?.id || null } catch (error) { console.error("Error fetching document class code group id:", error) @@ -236,23 +241,20 @@ export async function getDocumentClassCodeGroupId() { // 활성화된 Document Classes 조회 (Config 생성/수정 시 사용) export async function getActiveDocumentClasses() { + unstable_noStore() + 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, diff --git a/lib/docu-list-rule/number-type-configs/table/drag-drop-table.tsx b/lib/docu-list-rule/number-type-configs/table/drag-drop-table.tsx new file mode 100644 index 00000000..3141e450 --- /dev/null +++ b/lib/docu-list-rule/number-type-configs/table/drag-drop-table.tsx @@ -0,0 +1,193 @@ +"use client" + +import * as React from "react" +import { flexRender } from "@tanstack/react-table" +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragEndEvent, +} from '@dnd-kit/core' +import { + 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" + +// 드래그 가능한 행 컴포넌트 +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 inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground h-6 w-6" + > + ⋮⋮ + </div> + </TableCell> + {children} + </TableRow> + ) +} + +// 커스텀 드래그 앤 드롭 테이블 컴포넌트 +interface DragDropTableProps<TData> { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + table: any + data: TData[] + onDragEnd: (event: DragEndEvent) => void + children?: React.ReactNode + className?: string + maxHeight?: string +} + +export function DragDropTable<TData>({ + table, + data, + onDragEnd, + children, + className, + maxHeight = '35rem' +}: DragDropTableProps<TData>) { + 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> + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + {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> + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + <SortableContext items={data.map((item: any) => item.id.toString())} strategy={verticalListSortingStrategy}> + {table.getRowModel().rows?.length ? ( + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + table.getRowModel().rows.map((row: any) => { + if (row.getIsGrouped()) { + return null + } + + return ( + <SortableRow key={row.id} id={row.id} isDragging={false}> + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + {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> + ) +}
\ 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 index d3215958..72ee2698 100644 --- 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 @@ -55,7 +55,7 @@ export function NumberTypeConfigsEditDialog({ React.useEffect(() => { if (data) { setFormData({ - codeGroupId: data.codeGroupId.toString(), + codeGroupId: data.codeGroupId?.toString() || "", // null 체크 추가 sdq: data.sdq.toString(), description: data.description || "", remark: data.remark || "" @@ -100,6 +100,7 @@ export function NumberTypeConfigsEditDialog({ const result = await updateNumberTypeConfig({ id: data.id, codeGroupId: parseInt(formData.codeGroupId), + documentClassId: data.documentClassId, // 누락된 필드 추가 sdq: newSdq, description: formData.description || undefined, remark: formData.remark || undefined, 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 index 89a92a88..3b3d0180 100644 --- 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 @@ -2,488 +2,91 @@ 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 { arrayMove } from '@dnd-kit/sortable' +import { DragEndEvent } from '@dnd-kit/core' +import { toast } from "sonner" -import { getNumberTypeConfigs, createNumberTypeConfig, getActiveCodeGroups, getActiveDocumentClasses, getDocumentClassCodeGroupId, updateNumberTypeConfig } from "../service" -import { getNumberTypes } from "@/lib/docu-list-rule/number-types/service" +import { getNumberTypeConfigs, updateNumberTypeConfig } from "../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 { NumberTypeSelector } from "./number-type-selector" +import { DragDropTable } from "./drag-drop-table" +import { NumberTypeConfigsToolbarActions } from "./number-type-configs-toolbar-actions" 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" +import { GetNumberTypeConfigsSchema } from "../validation" interface NumberTypeConfigsTableProps { promises?: Promise<[{ data: typeof documentNumberTypes.$inferSelect[]; pageCount: number }]> + searchParams: GetNumberTypeConfigsSchema } -// 드래그 가능한 행 컴포넌트 -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 }[]>([]) +export function NumberTypeConfigsTable({ promises, searchParams }: NumberTypeConfigsTableProps) { + const rawData = React.use(promises!) + const [selectedNumberType, setSelectedNumberType] = React.useState<number | null>(null) + const [configsData, setConfigsData] = React.useState<{ data: NumberTypeConfig[]; pageCount: number }>({ data: [], pageCount: 0 }) + const [rowAction, setRowAction] = React.useState<DataTableRowAction<NumberTypeConfig> | null>(null) - const loadCodeGroups = React.useCallback(async () => { + // configs 데이터 로드 함수 + const fetchConfigs = React.useCallback(async (numberTypeId: number, params?: Partial<GetNumberTypeConfigsSchema>) => { 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) - + const result = await getNumberTypeConfigs({ + ...searchParams, + ...params, + numberTypeId, + }) 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) + setConfigsData({ data: result.data, pageCount: result.pageCount }) } else { - console.error("NumberTypeConfigsTableToolbarActions: Code Groups 로딩 실패", result.error) + setConfigsData({ data: [], pageCount: 0 }) } } catch (error) { - console.error("Error loading code groups:", error) - console.error("Error details:", error) + console.error("Error fetching configs:", error) + setConfigsData({ data: [], pageCount: 0 }) } - }, [configsData]) - - + }, [searchParams]) + // Number Types 데이터 로드 및 첫 번째 타입의 configs 데이터 로드 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 || "추가에 실패했습니다.") + const loadData = async () => { + try { + const result = rawData[0] + + if (result.data && result.data.length > 0) { + const firstNumberTypeId = result.data[0].id + setSelectedNumberType(firstNumberTypeId) + + // 첫 번째 타입의 configs 데이터도 바로 로드 + await fetchConfigs(firstNumberTypeId) + } + } catch (error) { + console.error("Error in loadData:", 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]) + + loadData() + }, [rawData, fetchConfigs]) - React.useEffect(() => { - console.log("configsData changed:", configsData) - console.log("configsData length:", configsData.length) - }, [configsData]) + // Number Type 변경 핸들러 (서버 사이드 처리 지원) + const handleNumberTypeChange = React.useCallback((numberTypeId: number) => { + setSelectedNumberType(numberTypeId) + // searchParams를 업데이트하여 서버 사이드 필터링 적용 + fetchConfigs(numberTypeId, { page: 1 }) // 페이지 리셋 + }, [fetchConfigs]) // 드래그 종료 핸들러 - const handleDragEnd = async (event: DragEndEvent) => { + const handleDragEnd = React.useCallback(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) + const oldIndex = configsData.data.findIndex(config => config.id.toString() === active.id) + const newIndex = configsData.data.findIndex(config => config.id.toString() === over?.id) if (oldIndex !== -1 && newIndex !== -1) { - const newConfigsData = arrayMove(configsData, oldIndex, newIndex) + const newConfigsData = arrayMove(configsData.data, oldIndex, newIndex) // 순서 업데이트 const updatedConfigsData = newConfigsData.map((config, index) => ({ @@ -491,7 +94,7 @@ export function NumberTypeConfigsTable({ promises }: NumberTypeConfigsTableProps sdq: index + 1 })) - setConfigsData(updatedConfigsData) + setConfigsData(prev => ({ ...prev, data: updatedConfigsData })) // 서버에 순서 업데이트 try { @@ -511,92 +114,12 @@ export function NumberTypeConfigsTable({ promises }: NumberTypeConfigsTableProps toast.error("순서 변경 중 오류가 발생했습니다.") // 에러 시 원래 데이터로 복원 if (selectedNumberType) { - const refreshResult = await getNumberTypeConfigs(selectedNumberType) - if (refreshResult.success && refreshResult.data) setConfigsData(refreshResult.data) + await fetchConfigs(selectedNumberType) } } } } - } - - // 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]) + }, [configsData.data, selectedNumberType, fetchConfigs]) // advanced filter fields 정의 const advancedFilterFields: DataTableAdvancedFilterField<NumberTypeConfig>[] = [ @@ -607,31 +130,23 @@ export function NumberTypeConfigsTable({ promises }: NumberTypeConfigsTableProps { id: "isActive", label: "상태", type: "select", options: [ { label: "활성", value: "true"}, { label: "비활성", value: "false" }] }, ] - // useDataTable 적용 + // 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, + data: configsData.data, columns: columns, - pageCount: 1, + pageCount: configsData.pageCount, enablePinning: true, enableAdvancedFilter: true, - manualSorting: false, + manualSorting: true, + manualFiltering: true, + manualPagination: true, initialState: { sorting: [{ id: "sdq", desc: false }], - // columnPinning: { right: ["actions"] }, // 일시적으로 제거 }, getRowId: (row) => String(row.id), shallow: false, @@ -640,80 +155,75 @@ export function NumberTypeConfigsTable({ promises }: NumberTypeConfigsTableProps 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) - } + await fetchConfigs(selectedNumberType) } - }, [selectedNumberType]) + }, [selectedNumberType, fetchConfigs]) + + // 테이블 상태가 변경될 때 서버 데이터 다시 로드 + React.useEffect(() => { + if (selectedNumberType) { + const state = table.getState() + fetchConfigs(selectedNumberType, { + page: state.pagination.pageIndex + 1, + perPage: state.pagination.pageSize, + sort: state.sorting.map(s => ({ id: s.id as keyof NumberTypeConfig, desc: s.desc })), + search: state.globalFilter || "", + }) + } + }, [table, selectedNumberType, fetchConfigs]) 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> + <NumberTypeSelector + numberTypes={rawData[0]?.data || []} + selectedNumberType={selectedNumberType} + onNumberTypeChange={handleNumberTypeChange} + isLoading={!rawData[0]?.data} + /> {/* 선택된 Number Type 정보 및 테이블: 조건부 렌더링 */} {selectedNumberType && rawData[0]?.data && rawData[0].data.length > 0 && ( - <> - <DragDropTable - table={table} - data={configsData} - onDragEnd={handleDragEnd} + <DragDropTable + table={table} + data={configsData.data} + onDragEnd={handleDragEnd} + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} > - <DataTableAdvancedToolbar table={table} filterFields={advancedFilterFields}> - <NumberTypeConfigsTableToolbarActions - table={table} - onSuccess={refreshData} - selectedNumberType={selectedNumberType} - configsData={configsData} - /> - </DataTableAdvancedToolbar> - </DragDropTable> - </> + <NumberTypeConfigsToolbarActions + table={table} + onSuccess={refreshData} + selectedNumberType={selectedNumberType} + configsData={configsData.data} + /> + </DataTableAdvancedToolbar> + </DragDropTable> )} {/* 삭제/수정 다이얼로그 */} <DeleteNumberTypeConfigsDialog open={rowAction?.type === "delete"} onOpenChange={() => setRowAction(null)} - configs={rowAction?.row ? [rowAction.row.original] : []} + configs={rowAction?.row.original ? [rowAction.row.original] : []} showTrigger={false} - onSuccess={refreshData} + onSuccess={() => { + setRowAction(null) + refreshData() + }} /> + <NumberTypeConfigsEditDialog open={rowAction?.type === "update"} onOpenChange={() => setRowAction(null)} - data={rowAction?.row?.original ?? null} - existingConfigs={configsData} - onSuccess={refreshData} + data={rowAction?.row.original ?? null} + existingConfigs={configsData.data} + onSuccess={() => { + setRowAction(null) + refreshData() + }} /> </> ) diff --git a/lib/docu-list-rule/number-type-configs/table/number-type-configs-toolbar-actions.tsx b/lib/docu-list-rule/number-type-configs/table/number-type-configs-toolbar-actions.tsx new file mode 100644 index 00000000..4923bd1d --- /dev/null +++ b/lib/docu-list-rule/number-type-configs/table/number-type-configs-toolbar-actions.tsx @@ -0,0 +1,251 @@ +"use client" + +import * as React from "react" +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 { createNumberTypeConfig, getActiveCodeGroups } from "../service" +import { DeleteNumberTypeConfigsDialog } from "./delete-number-type-configs-dialog" +import { NumberTypeConfig } from "../../types" + +interface NumberTypeConfigsToolbarActionsProps { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + table: any + onSuccess?: () => void + selectedNumberType: number | null + configsData: NumberTypeConfig[] +} + +export function NumberTypeConfigsToolbarActions({ + table, + onSuccess, + selectedNumberType, + configsData +}: NumberTypeConfigsToolbarActionsProps) { + 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 { + const result = await getActiveCodeGroups() + if (result.success && result.data) { + + // 이미 추가된 Code Group들을 제외하고 필터링 + const usedCodeGroupIds = configsData.map(config => config.codeGroupId) + const availableCodeGroups = result.data.filter(codeGroup => + !usedCodeGroupIds.includes(codeGroup.id) + ) + setCodeGroups(availableCodeGroups) + } + } catch (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 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + configs={table + .getFilteredSelectedRowModel() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .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> + ) +}
\ No newline at end of file diff --git a/lib/docu-list-rule/number-type-configs/table/number-type-selector.tsx b/lib/docu-list-rule/number-type-configs/table/number-type-selector.tsx new file mode 100644 index 00000000..c311730e --- /dev/null +++ b/lib/docu-list-rule/number-type-configs/table/number-type-selector.tsx @@ -0,0 +1,61 @@ +"use client" + +import * as React from "react" +import { documentNumberTypes } from "@/db/schema/docu-list-rule" + +interface NumberTypeSelectorProps { + numberTypes: typeof documentNumberTypes.$inferSelect[] + selectedNumberType: number | null + onNumberTypeChange: (numberTypeId: number) => void + isLoading?: boolean +} + +export function NumberTypeSelector({ + numberTypes, + selectedNumberType, + onNumberTypeChange, + isLoading = false +}: NumberTypeSelectorProps) { + if (isLoading) { + return ( + <div className="mb-6"> + <label className="text-sm font-medium mb-2 block">Number Type 선택</label> + <div className="px-4 py-2 text-sm text-muted-foreground"> + Number Type을 불러오는 중... + </div> + </div> + ) + } + + if (!numberTypes || numberTypes.length === 0) { + return ( + <div className="mb-6"> + <label className="text-sm font-medium mb-2 block">Number Type 선택</label> + <div className="px-4 py-2 text-sm text-muted-foreground"> + 사용 가능한 Number Type이 없습니다. + </div> + </div> + ) + } + + return ( + <div className="mb-6"> + <label className="text-sm font-medium mb-2 block">Number Type 선택</label> + <div className="flex gap-2 flex-wrap"> + {numberTypes.map((numberType) => ( + <button + key={numberType.id} + onClick={() => onNumberTypeChange(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> + </div> + ) +}
\ 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 index 2b3aaa2d..02b99290 100644 --- a/lib/docu-list-rule/number-type-configs/validation.ts +++ b/lib/docu-list-rule/number-type-configs/validation.ts @@ -1,12 +1,30 @@ import { createSearchParamsCache } from "nuqs/server"; import { parseAsInteger, parseAsString, parseAsArrayOf, parseAsStringEnum } from "nuqs/server"; import { getSortingStateParser, getFiltersStateParser } from "@/lib/parsers"; +import * as z from "zod" +import { NumberTypeConfig } from "../types"; export const searchParamsNumberTypeConfigsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), page: parseAsInteger.withDefault(1), perPage: parseAsInteger.withDefault(10), - sort: getSortingStateParser<any>(), - filters: getFiltersStateParser(), - search: parseAsString.withDefault(""), + sort: getSortingStateParser<NumberTypeConfig>().withDefault([ + { id: "sdq", desc: false }, + ]), + + // Number Type ID (필수) + numberTypeId: parseAsInteger.withDefault(0), + + // 기본 필터들 + codeGroupName: parseAsString.withDefault(""), + description: parseAsString.withDefault(""), + remark: parseAsString.withDefault(""), + isActive: parseAsString.withDefault(""), + + // 고급 필터 + filters: getFiltersStateParser().withDefault([]), joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), -});
\ No newline at end of file + search: parseAsString.withDefault(""), +}); + +export type GetNumberTypeConfigsSchema = Awaited<ReturnType<typeof searchParamsNumberTypeConfigsCache.parse>>
\ 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 index 8eaf19c7..17373848 100644 --- a/lib/docu-list-rule/number-types/service.ts +++ b/lib/docu-list-rule/number-types/service.ts @@ -22,7 +22,7 @@ export async function getNumberTypes(input: { unstable_noStore() try { - const { page, perPage, sort, search } = input + const { page, perPage, sort, search, filters, joinOperator } = input const offset = (page - 1) * perPage // 기본 조건 @@ -37,30 +37,46 @@ export async function getNumberTypes(input: { )` } - // 정렬 + // 고급 필터링 + if (filters && filters.length > 0) { + const filterConditions = filters.map(filter => { + const { id, value } = filter + if (!value) return null + + switch (id) { + case "name": + return sql`${documentNumberTypes.name} ILIKE ${`%${value}%`}` + case "description": + return sql`${documentNumberTypes.description} ILIKE ${`%${value}%`}` + case "isActive": + return sql`${documentNumberTypes.isActive} = ${value === "true"}` + case "createdAt": + return sql`${documentNumberTypes.createdAt}::text ILIKE ${`%${value}%`}` + default: + return null + } + }).filter(Boolean) + + if (filterConditions.length > 0) { + const operator = joinOperator === "or" ? sql` OR ` : sql` AND ` + const combinedFilters = filterConditions.reduce((acc, condition, index) => { + if (index === 0) return condition + return sql`${acc}${operator}${condition}` + }) + + whereConditions = sql`${whereConditions} AND (${combinedFilters})` + } + } + + // 정렬 (안전한 필드 체크 적용) 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` + // 안전성 체크: 필드가 실제 테이블에 존재하는지 확인 + if (sortField && sortField.id && typeof sortField.id === "string" && sortField.id in documentNumberTypes) { + const direction = sortField.desc ? sql`DESC` : sql`ASC` + const col = documentNumberTypes[sortField.id as keyof typeof documentNumberTypes] + orderBy = sql`${col} ${direction}` } } 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 index 5d25c4b5..00f7e2ab 100644 --- a/lib/docu-list-rule/number-types/table/number-types-table.tsx +++ b/lib/docu-list-rule/number-types/table/number-types-table.tsx @@ -1,6 +1,7 @@ "use client" import * as React from "react" +import { useRouter } from "next/navigation" 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" @@ -17,12 +18,13 @@ interface NumberTypesTableProps { } export function NumberTypesTable({ promises }: NumberTypesTableProps) { + const router = useRouter() const rawData = React.use(promises!) const [rowAction, setRowAction] = React.useState<DataTableRowAction<typeof documentNumberTypes.$inferSelect> | null>(null) const refreshData = React.useCallback(() => { - window.location.reload() - }, []) + router.refresh() + }, [router]) const columns = React.useMemo(() => getColumns({ setRowAction }), [setRowAction]) |
