From 25749225689c3934bc10ad1e8285e13020b61282 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Thu, 4 Dec 2025 09:04:09 +0000 Subject: (최겸)구매 입찰, 계약 수정 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/schema/bidding.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'db') diff --git a/db/schema/bidding.ts b/db/schema/bidding.ts index c08ea921..fa3f1df5 100644 --- a/db/schema/bidding.ts +++ b/db/schema/bidding.ts @@ -297,7 +297,7 @@ export const prItemsForBidding = pgTable('pr_items_for_bidding', { // 수량 및 중량 quantity: decimal('quantity', { precision: 10, scale: 3 }), // 수량 - quantityUnit: varchar('quantity_unit', { length: 50 }), // 수량단위 (구매단위) + quantityUnit: varchar('quantity_unit', { length: 50 }), // 수량단위 totalWeight: decimal('total_weight', { precision: 10, scale: 3 }), // 총 중량 weightUnit: varchar('weight_unit', { length: 50 }), // 중량단위 (자재순중량) -- cgit v1.2.3 From 0e1a15c1be7bd9620fc61767b63b5b6f87563b4f Mon Sep 17 00:00:00 2001 From: dujinkim Date: Thu, 4 Dec 2025 09:08:44 +0000 Subject: (임수민) 준법문의,법무검토 관련 요청사항 작업 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../legal/cpvw-wab-qust-list-view-dialog.tsx | 364 +++++++++++++++++++++ db/schema/basicContractDocumnet.ts | 15 + lib/basic-contract/cpvw-service.ts | 236 +++++++++++++ lib/basic-contract/service.ts | 267 ++++++++++++++- lib/basic-contract/sslvw-service.ts | 126 ++++++- ...basic-contract-detail-table-toolbar-actions.tsx | 209 +++++++++--- .../basic-contracts-detail-columns.tsx | 39 ++- types/table.d.ts | 2 +- 8 files changed, 1184 insertions(+), 74 deletions(-) create mode 100644 components/common/legal/cpvw-wab-qust-list-view-dialog.tsx create mode 100644 lib/basic-contract/cpvw-service.ts (limited to 'db') diff --git a/components/common/legal/cpvw-wab-qust-list-view-dialog.tsx b/components/common/legal/cpvw-wab-qust-list-view-dialog.tsx new file mode 100644 index 00000000..aeefbb84 --- /dev/null +++ b/components/common/legal/cpvw-wab-qust-list-view-dialog.tsx @@ -0,0 +1,364 @@ +"use client" + +import * as React from "react" +import { Loader, Database, Check } from "lucide-react" +import { toast } from "sonner" +import { + useReactTable, + getCoreRowModel, + getPaginationRowModel, + getFilteredRowModel, + ColumnDef, + flexRender, +} from "@tanstack/react-table" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Checkbox } from "@/components/ui/checkbox" +import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area" + +import { getCPVWWabQustListViewData, CPVWWabQustListView } from "@/lib/basic-contract/cpvw-service" + +interface CPVWWabQustListViewDialogProps { + onConfirm?: (selectedRows: CPVWWabQustListView[]) => void + requireSingleSelection?: boolean + triggerDisabled?: boolean + triggerTitle?: string +} + +export function CPVWWabQustListViewDialog({ + onConfirm, + requireSingleSelection = false, + triggerDisabled = false, + triggerTitle, +}: CPVWWabQustListViewDialogProps) { + const [open, setOpen] = React.useState(false) + const [isLoading, setIsLoading] = React.useState(false) + const [data, setData] = React.useState([]) + const [error, setError] = React.useState(null) + const [rowSelection, setRowSelection] = React.useState>({}) + + const loadData = async () => { + setIsLoading(true) + setError(null) + try { + const result = await getCPVWWabQustListViewData() + if (result.success) { + setData(result.data) + if (result.isUsingFallback) { + toast.info("테스트 데이터를 표시합니다.") + } + } else { + setError(result.error || "데이터 로딩 실패") + toast.error(result.error || "데이터 로딩 실패") + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "알 수 없는 오류" + setError(errorMessage) + toast.error(errorMessage) + } finally { + setIsLoading(false) + } + } + + React.useEffect(() => { + if (open) { + loadData() + } else { + // 다이얼로그 닫힐 때 데이터 초기화 + setData([]) + setError(null) + setRowSelection({}) + } + }, [open]) + + // 테이블 컬럼 정의 (동적 생성) + const columns = React.useMemo[]>(() => { + if (data.length === 0) return [] + + const dataKeys = Object.keys(data[0]) + + return [ + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="모든 행 선택" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="행 선택" + /> + ), + enableSorting: false, + enableHiding: false, + }, + ...dataKeys.map((key) => ({ + accessorKey: key, + header: key, + cell: ({ getValue }: any) => { + const value = getValue() + return value !== null && value !== undefined ? String(value) : "" + }, + })), + ] + }, [data]) + + // 테이블 인스턴스 생성 + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onRowSelectionChange: setRowSelection, + state: { + rowSelection, + }, + }) + + // 선택된 행들 가져오기 + const selectedRows = table.getFilteredSelectedRowModel().rows.map(row => row.original) + + // 확인 버튼 핸들러 + const handleConfirm = () => { + if (selectedRows.length === 0) { + toast.error("행을 선택해주세요.") + return + } + + if (requireSingleSelection && selectedRows.length !== 1) { + toast.error("하나의 행만 선택해주세요.") + return + } + + if (onConfirm) { + onConfirm(selectedRows) + toast.success( + requireSingleSelection + ? "선택한 행으로 준법문의 상태를 동기화합니다." + : `${selectedRows.length}개의 행을 선택했습니다.` + ) + } else { + // 임시로 선택된 데이터 콘솔 출력 + console.log("선택된 행들:", selectedRows) + toast.success(`${selectedRows.length}개의 행이 선택되었습니다. (콘솔 확인)`) + } + + setOpen(false) + } + + return ( + + + + + + + 준법문의 요청 데이터 + + 준법문의 요청 데이터를 조회합니다. + {data.length > 0 && ` (${data.length}건, ${selectedRows.length}개 선택됨)`} + + + +
+ {isLoading ? ( +
+ + 데이터 로딩 중... +
+ ) : error ? ( +
+ 오류: {error} +
+ ) : data.length === 0 ? ( +
+ 데이터가 없습니다. +
+ ) : ( +
+ {/* 테이블 영역 - 스크롤 가능 */} + + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + 데이터가 없습니다. + + + )} + +
+ +
+ + {/* 페이지네이션 컨트롤 - 고정 영역 */} +
+
+ {table.getFilteredSelectedRowModel().rows.length}개 행 선택됨 +
+
+
+

페이지당 행 수

+ +
+
+ {table.getState().pagination.pageIndex + 1} /{" "} + {table.getPageCount()} +
+
+ + + + +
+
+
+
+ )} +
+ + + + + + +
+
+ ) +} + diff --git a/db/schema/basicContractDocumnet.ts b/db/schema/basicContractDocumnet.ts index 944c4b2c..e571c7e0 100644 --- a/db/schema/basicContractDocumnet.ts +++ b/db/schema/basicContractDocumnet.ts @@ -67,6 +67,12 @@ export const basicContract = pgTable('basic_contract', { legalReviewRegNo: varchar('legal_review_reg_no', { length: 100 }), // 법무 시스템 REG_NO legalReviewProgressStatus: varchar('legal_review_progress_status', { length: 255 }), // PRGS_STAT_DSC 값 + // 준법문의 관련 필드 + complianceReviewRequestedAt: timestamp('compliance_review_requested_at'), // 준법문의 요청일 + complianceReviewCompletedAt: timestamp('compliance_review_completed_at'), // 준법문의 완료일 + complianceReviewRegNo: varchar('compliance_review_reg_no', { length: 100 }), // 준법문의 시스템 REG_NO + complianceReviewProgressStatus: varchar('compliance_review_progress_status', { length: 255 }), // 준법문의 PRGS_STAT_DSC 값 + createdAt: timestamp('created_at').defaultNow(), updatedAt: timestamp('updated_at').defaultNow(), completedAt: timestamp('completed_at'), // 계약 체결 완료 날짜 @@ -99,6 +105,12 @@ export const basicContractView = pgView('basic_contract_view').as((qb) => { legalReviewRegNo: sql`${basicContract.legalReviewRegNo}`.as('legal_review_reg_no'), legalReviewProgressStatus: sql`${basicContract.legalReviewProgressStatus}`.as('legal_review_progress_status'), + // 준법문의 관련 필드 + complianceReviewRequestedAt: sql`${basicContract.complianceReviewRequestedAt}`.as('compliance_review_requested_at'), + complianceReviewCompletedAt: sql`${basicContract.complianceReviewCompletedAt}`.as('compliance_review_completed_at'), + complianceReviewRegNo: sql`${basicContract.complianceReviewRegNo}`.as('compliance_review_reg_no'), + complianceReviewProgressStatus: sql`${basicContract.complianceReviewProgressStatus}`.as('compliance_review_progress_status'), + createdAt: sql`${basicContract.createdAt}`.as('created_at'), updatedAt: sql`${basicContract.updatedAt}`.as('updated_at'), completedAt: sql`${basicContract.completedAt}`.as('completed_at'), @@ -121,6 +133,9 @@ export const basicContractView = pgView('basic_contract_view').as((qb) => { // 법무검토 상태 (PRGS_STAT_DSC 동기화 값) legalReviewStatus: sql`${basicContract.legalReviewProgressStatus}`.as('legal_review_status'), + + // 준법문의 상태 (PRGS_STAT_DSC 동기화 값) + complianceReviewStatus: sql`${basicContract.complianceReviewProgressStatus}`.as('compliance_review_status'), // 템플릿 파일 정보 templateFilePath: sql`${basicContractTemplates.filePath}`.as('template_file_path'), diff --git a/lib/basic-contract/cpvw-service.ts b/lib/basic-contract/cpvw-service.ts new file mode 100644 index 00000000..6d249002 --- /dev/null +++ b/lib/basic-contract/cpvw-service.ts @@ -0,0 +1,236 @@ +"use server" + +import { oracleKnex } from '@/lib/oracle-db/db' + +// CPVW_WAB_QUST_LIST_VIEW 테이블 데이터 타입 (실제 테이블 구조에 맞게 조정 필요) +export interface CPVWWabQustListView { + [key: string]: string | number | Date | null | undefined +} + +// 테스트 환경용 폴백 데이터 (실제 CPVW_WAB_QUST_LIST_VIEW 테이블 구조에 맞춤) +const FALLBACK_TEST_DATA: CPVWWabQustListView[] = [ + { + REG_NO: '1030', + INQ_TP: 'OC', + INQ_TP_DSC: '해외계약', + TIT: 'Contrack of Sale', + REQ_DGR: '2', + REQR_NM: '김원식', + REQ_DT: '20130829', + REVIEW_TERM_DT: '20130902', + RVWR_NM: '김미정', + CNFMR_NM: '안한진', + APPR_NM: '염정훈', + PRGS_STAT: 'E', + PRGS_STAT_DSC: '검토중', + REGR_DPTCD: 'D602058000', + REGR_DEPTNM: '구매1팀(사외계약)' + }, + { + REG_NO: '1076', + INQ_TP: 'IC', + INQ_TP_DSC: '국내계약', + TIT: 'CAISSON PIPE 복관 계약서 검토 요청件', + REQ_DGR: '1', + REQR_NM: '서권환', + REQ_DT: '20130821', + REVIEW_TERM_DT: '20130826', + RVWR_NM: '이택준', + CNFMR_NM: '이택준', + APPR_NM: '전상용', + PRGS_STAT: 'E', + PRGS_STAT_DSC: '완료', + REGR_DPTCD: 'D602058000', + REGR_DEPTNM: '구매1팀(사외계약)' + }, + { + REG_NO: '1100', + INQ_TP: 'IC', + INQ_TP_DSC: '국내계약', + TIT: '(7102) HVAC 작업계약', + REQ_DGR: '1', + REQR_NM: '신동동', + REQ_DT: '20130826', + REVIEW_TERM_DT: '20130829', + RVWR_NM: '이두리', + CNFMR_NM: '이두리', + APPR_NM: '전상용', + PRGS_STAT: 'E', + PRGS_STAT_DSC: '완료', + REGR_DPTCD: 'D602058000', + REGR_DEPTNM: '구매1팀(사외계약)' + }, + { + REG_NO: '1105', + INQ_TP: 'IC', + INQ_TP_DSC: '국내계약', + TIT: 'Plate 가공계약서 검토 요청건', + REQ_DGR: '1', + REQR_NM: '서권환', + REQ_DT: '20130826', + REVIEW_TERM_DT: '20130829', + RVWR_NM: '백영국', + CNFMR_NM: '백영국', + APPR_NM: '전상용', + PRGS_STAT: 'E', + PRGS_STAT_DSC: '완료', + REGR_DPTCD: 'D602058000', + REGR_DEPTNM: '구매1팀(사외계약)' + }, + { + REG_NO: '1106', + INQ_TP: 'IC', + INQ_TP_DSC: '국내계약', + TIT: 'SHELL FLNG, V-BRACE 제작 계약서 검토件', + REQ_DGR: '1', + REQR_NM: '성기승', + REQ_DT: '20130826', + REVIEW_TERM_DT: '20130830', + RVWR_NM: '이두리', + CNFMR_NM: '이두리', + APPR_NM: '전상용', + PRGS_STAT: 'E', + PRGS_STAT_DSC: '완료', + REGR_DPTCD: 'D602058000', + REGR_DEPTNM: '구매1팀(사외계약)' + } +] + +const normalizeOracleRows = (rows: Array>): CPVWWabQustListView[] => { + return rows.map((item) => { + const convertedItem: CPVWWabQustListView = {} + for (const [key, value] of Object.entries(item)) { + if (value instanceof Date) { + convertedItem[key] = value + } else if (value === null) { + convertedItem[key] = null + } else { + convertedItem[key] = String(value) + } + } + return convertedItem + }) +} + +/** + * CPVW_WAB_QUST_LIST_VIEW 테이블 전체 조회 + * @returns 테이블 데이터 배열 + */ +export async function getCPVWWabQustListViewData(): Promise<{ + success: boolean + data: CPVWWabQustListView[] + error?: string + isUsingFallback?: boolean +}> { + try { + console.log('📋 [getCPVWWabQustListViewData] CPVW_WAB_QUST_LIST_VIEW 테이블 조회 시작...') + + const result = await oracleKnex.raw(` + SELECT * + FROM CPVW_WAB_QUST_LIST_VIEW + WHERE ROWNUM < 100 + ORDER BY 1 + `) + + // Oracle raw query의 결과는 rows 배열에 들어있음 + const rows = (result.rows || result) as Array> + + console.log(`✅ [getCPVWWabQustListViewData] 조회 성공 - ${rows.length}건`) + + // 데이터 타입 변환 (필요에 따라 조정) + const cleanedResult = normalizeOracleRows(rows) + + return { + success: true, + data: cleanedResult, + isUsingFallback: false + } + } catch (error) { + console.error('❌ [getCPVWWabQustListViewData] 오류:', error) + console.log('🔄 [getCPVWWabQustListViewData] 폴백 테스트 데이터 사용') + return { + success: true, + data: FALLBACK_TEST_DATA, + isUsingFallback: true + } + } +} + +export async function getCPVWWabQustListViewByRegNo(regNo: string): Promise<{ + success: boolean + data?: CPVWWabQustListView + error?: string + isUsingFallback?: boolean +}> { + if (!regNo) { + return { + success: false, + error: 'REG_NO는 필수입니다.' + } + } + + try { + console.log(`[getCPVWWabQustListViewByRegNo] REG_NO=${regNo} 조회`) + const result = await oracleKnex.raw( + ` + SELECT * + FROM CPVW_WAB_QUST_LIST_VIEW + WHERE REG_NO = :regNo + `, + { regNo } + ) + + const rows = (result.rows || result) as Array> + const cleanedResult = normalizeOracleRows(rows) + + if (cleanedResult.length === 0) { + // 데이터가 없을 때 폴백 테스트 데이터에서 찾기 + console.log(`[getCPVWWabQustListViewByRegNo] 데이터 없음, 폴백 테스트 데이터에서 검색: REG_NO=${regNo}`) + const fallbackData = FALLBACK_TEST_DATA.find(item => + String(item.REG_NO) === String(regNo) + ) + + if (fallbackData) { + console.log(`[getCPVWWabQustListViewByRegNo] 폴백 테스트 데이터에서 찾음: REG_NO=${regNo}`) + return { + success: true, + data: fallbackData, + isUsingFallback: true + } + } + + return { + success: false, + error: '해당 REG_NO에 대한 데이터가 없습니다.' + } + } + + return { + success: true, + data: cleanedResult[0], + isUsingFallback: false + } + } catch (error) { + console.error('[getCPVWWabQustListViewByRegNo] 오류:', error) + console.log(`[getCPVWWabQustListViewByRegNo] 폴백 테스트 데이터에서 검색: REG_NO=${regNo}`) + + // 오류 발생 시 폴백 테스트 데이터에서 찾기 + const fallbackData = FALLBACK_TEST_DATA.find(item => + String(item.REG_NO) === String(regNo) + ) + + if (fallbackData) { + console.log(`[getCPVWWabQustListViewByRegNo] 폴백 테스트 데이터에서 찾음: REG_NO=${regNo}`) + return { + success: true, + data: fallbackData, + isUsingFallback: true + } + } + + return { + success: false, + error: error instanceof Error ? error.message : 'REG_NO 조회 중 오류가 발생했습니다.' + } + } +} diff --git a/lib/basic-contract/service.ts b/lib/basic-contract/service.ts index 6f4e5d53..12278c54 100644 --- a/lib/basic-contract/service.ts +++ b/lib/basic-contract/service.ts @@ -2862,6 +2862,10 @@ export async function requestLegalReviewAction( } } +// ⚠️ SSLVW(법무관리시스템) PRGS_STAT_DSC 문자열을 그대로 저장하는 함수입니다. +// - 상태 텍스트 및 완료 여부는 외부 시스템에 의존하므로 신뢰도가 100%는 아니고, +// - 여기에서 관리하는 값들은 UI 표시/참고용으로만 사용해야 합니다. +// - 최종 승인 차단 등 핵심 비즈니스 로직에서는 SSLVW 쪽 완료 시간을 직접 신뢰하지 않습니다. const persistLegalReviewStatus = async ({ contractId, regNo, @@ -2903,6 +2907,121 @@ const persistLegalReviewStatus = async ({ revalidateTag("basic-contracts") } +/** + * 준법문의 요청 서버 액션 + */ +export async function requestComplianceInquiryAction( + contractIds: number[] +): Promise<{ success: boolean; message: string }> { + const session = await getServerSession(authOptions) + + if (!session?.user) { + return { + success: false, + message: "로그인이 필요합니다." + } + } + + // 계약서 정보 조회 + const contracts = await db + .select({ + id: basicContractView.id, + complianceReviewRequestedAt: basicContractView.complianceReviewRequestedAt, + }) + .from(basicContractView) + .where(inArray(basicContractView.id, contractIds)) + + if (contracts.length === 0) { + return { + success: false, + message: "선택된 계약서를 찾을 수 없습니다." + } + } + + // 준법문의 요청 가능한 계약서 필터링 (이미 요청되지 않은 것만) + const eligibleContracts = contracts.filter(contract => + !contract.complianceReviewRequestedAt + ) + + if (eligibleContracts.length === 0) { + return { + success: false, + message: "준법문의 요청 가능한 계약서가 없습니다." + } + } + + const currentDate = new Date() + + // 트랜잭션으로 처리 + await db.transaction(async (tx) => { + for (const contract of eligibleContracts) { + await tx + .update(basicContract) + .set({ + complianceReviewRequestedAt: currentDate, + updatedAt: currentDate, + }) + .where(eq(basicContract.id, contract.id)) + } + }) + + revalidateTag("basic-contracts") + + return { + success: true, + message: `${eligibleContracts.length}건의 준법문의 요청이 완료되었습니다.` + } +} + +/** + * 준법문의 상태 저장 (준법문의 전용 필드 사용) + */ +const persistComplianceReviewStatus = async ({ + contractId, + regNo, + progressStatus, +}: { + contractId: number + regNo: string + progressStatus: string +}) => { + const now = new Date() + + // 완료 상태 확인 (법무검토와 동일한 패턴) + // ⚠️ CPVW PRGS_STAT_DSC 문자열을 기반으로 한 best-effort 휴리스틱입니다. + // - 외부 시스템의 상태 텍스트에 의존하므로 신뢰도가 100%는 아니고, + // - 여기에서 설정하는 완료 시간(complianceReviewCompletedAt)은 UI 표시용으로만 사용해야 합니다. + // - 버튼 활성화, 서버 액션 차단, 필터 조건 등 핵심 비즈니스 로직에서는 + // 이 값을 신뢰하지 않도록 합니다. + // 완료 상태 확인 (법무검토와 동일한 패턴) + const isCompleted = progressStatus && ( + progressStatus.includes('완료') || + progressStatus.includes('승인') || + progressStatus.includes('종료') + ) + + await db.transaction(async (tx) => { + // 준법문의 상태 업데이트 (준법문의 전용 필드 사용) + const updateData: any = { + complianceReviewRegNo: regNo, + complianceReviewProgressStatus: progressStatus, + updatedAt: now, + } + + // 완료 상태인 경우 완료일 설정 + if (isCompleted) { + updateData.complianceReviewCompletedAt = now + } + + await tx + .update(basicContract) + .set(updateData) + .where(eq(basicContract.id, contractId)) + }) + + revalidateTag("basic-contracts") +} + /** * SSLVW 데이터로부터 법무검토 상태 업데이트 * @param sslvwData 선택된 SSLVW 데이터 배열 @@ -3033,6 +3152,137 @@ export async function updateLegalReviewStatusFromSSLVW( } } +/** + * CPVW 데이터로부터 준법문의 상태 업데이트 + * @param cpvwData 선택된 CPVW 데이터 배열 + * @param selectedContractIds 선택된 계약서 ID 배열 + * @returns 성공 여부 및 메시지 + */ +export async function updateComplianceReviewStatusFromCPVW( + cpvwData: Array<{ REG_NO?: string; reg_no?: string; PRGS_STAT_DSC?: string; prgs_stat_dsc?: string; [key: string]: any }>, + selectedContractIds: number[] +): Promise<{ success: boolean; message: string; updatedCount: number; errors: string[] }> { + try { + console.log(`[updateComplianceReviewStatusFromCPVW] CPVW 데이터로부터 준법문의 상태 업데이트 시작`) + + if (!cpvwData || cpvwData.length === 0) { + return { + success: false, + message: 'CPVW 데이터가 없습니다.', + updatedCount: 0, + errors: [] + } + } + + if (!selectedContractIds || selectedContractIds.length === 0) { + return { + success: false, + message: '선택된 계약서가 없습니다.', + updatedCount: 0, + errors: [] + } + } + + if (selectedContractIds.length !== 1) { + return { + success: false, + message: '한 개의 계약서만 선택해 주세요.', + updatedCount: 0, + errors: [] + } + } + + if (cpvwData.length !== 1) { + return { + success: false, + message: '준법문의 시스템 데이터도 한 건만 선택해 주세요.', + updatedCount: 0, + errors: [] + } + } + + const contractId = selectedContractIds[0] + const cpvwItem = cpvwData[0] + const regNo = String( + cpvwItem.REG_NO ?? + cpvwItem.reg_no ?? + cpvwItem.RegNo ?? + '' + ).trim() + const progressStatus = String( + cpvwItem.PRGS_STAT_DSC ?? + cpvwItem.prgs_stat_dsc ?? + cpvwItem.PrgsStatDsc ?? + '' + ).trim() + + if (!regNo) { + return { + success: false, + message: 'REG_NO 값을 찾을 수 없습니다.', + updatedCount: 0, + errors: [] + } + } + + if (!progressStatus) { + return { + success: false, + message: 'PRGS_STAT_DSC 값을 찾을 수 없습니다.', + updatedCount: 0, + errors: [] + } + } + + const contract = await db + .select({ + id: basicContract.id, + complianceReviewRegNo: basicContract.complianceReviewRegNo, + }) + .from(basicContract) + .where(eq(basicContract.id, contractId)) + .limit(1) + + if (!contract[0]) { + return { + success: false, + message: `계약서(${contractId})를 찾을 수 없습니다.`, + updatedCount: 0, + errors: [] + } + } + + if (contract[0].complianceReviewRegNo && contract[0].complianceReviewRegNo !== regNo) { + console.warn(`[updateComplianceReviewStatusFromCPVW] REG_NO가 변경됩니다: ${contract[0].complianceReviewRegNo} -> ${regNo}`) + } + + // 준법문의 상태 업데이트 + await persistComplianceReviewStatus({ + contractId, + regNo, + progressStatus, + }) + + console.log(`[updateComplianceReviewStatusFromCPVW] 완료: 계약서 ${contractId}, REG_NO ${regNo}, 상태 ${progressStatus}`) + + return { + success: true, + message: '준법문의 상태가 업데이트되었습니다.', + updatedCount: 1, + errors: [] + } + + } catch (error) { + console.error('[updateComplianceReviewStatusFromCPVW] 오류:', error) + return { + success: false, + message: '준법문의 상태 업데이트 중 오류가 발생했습니다.', + updatedCount: 0, + errors: [error instanceof Error ? error.message : '알 수 없는 오류'] + } + } +} + export async function refreshLegalReviewStatusFromOracle(contractId: number): Promise<{ success: boolean message: string @@ -3274,12 +3524,9 @@ export async function processBuyerSignatureAction( } } - if (contractData.legalReviewRequestedAt && !contractData.legalReviewCompletedAt) { - return { - success: false, - message: "법무검토가 완료되지 않았습니다." - } - } + // ⚠️ 법무검토 완료 여부는 SSLVW 상태/시간에 의존하므로 + // 여기서는 legalReviewCompletedAt 기반으로 최종승인을 막지 않습니다. + // (법무 상태는 UI에서 참고 정보로만 사용) // 파일 저장 로직 (기존 파일 덮어쓰기) const saveResult = await saveBuffer({ @@ -3373,9 +3620,9 @@ export async function prepareFinalApprovalAction( if (contract.completedAt !== null || !contract.signedFilePath) { return false } - if (contract.legalReviewRequestedAt && !contract.legalReviewCompletedAt) { - return false - } + // ⚠️ 법무검토 완료 여부는 SSLVW 상태/시간에 의존하므로 + // 여기서는 legalReviewCompletedAt 기반으로 필터링하지 않습니다. + // (법무 상태는 UI에서 참고 정보로만 사용) return true }) @@ -3949,6 +4196,8 @@ export async function saveGtcDocumentAction({ buyerSignedAt: null, legalReviewRequestedAt: null, legalReviewCompletedAt: null, + complianceReviewRequestedAt: null, + complianceReviewCompletedAt: null, updatedAt: new Date() }) .where(eq(basicContract.id, documentId)) diff --git a/lib/basic-contract/sslvw-service.ts b/lib/basic-contract/sslvw-service.ts index 38ecb67d..08b43f82 100644 --- a/lib/basic-contract/sslvw-service.ts +++ b/lib/basic-contract/sslvw-service.ts @@ -10,18 +10,89 @@ export interface SSLVWPurInqReq { // 테스트 환경용 폴백 데이터 const FALLBACK_TEST_DATA: SSLVWPurInqReq[] = [ { - id: 1, - request_number: 'REQ001', - status: 'PENDING', - created_date: new Date('2025-01-01'), - description: '테스트 요청 1' + REG_NO: '1030', + INQ_TP: 'OC', + INQ_TP_DSC: '해외계약', + TIT: 'Contrack of Sale', + REQ_DGR: '2', + REQR_NM: '김원식', + REQ_DT: '20130829', + REVIEW_TERM_DT: '20130902', + RVWR_NM: '김미정', + CNFMR_NM: '안한진', + APPR_NM: '염정훈', + PRGS_STAT: 'E', + PRGS_STAT_DSC: '검토중이라고', + REGR_DPTCD: 'D602058000', + REGR_DEPTNM: '구매1팀(사외계약)' }, { - id: 2, - request_number: 'REQ002', - status: 'APPROVED', - created_date: new Date('2025-01-02'), - description: '테스트 요청 2' + REG_NO: '1076', + INQ_TP: 'IC', + INQ_TP_DSC: '국내계약', + TIT: 'CAISSON PIPE 복관 계약서 검토 요청件', + REQ_DGR: '1', + REQR_NM: '서권환', + REQ_DT: '20130821', + REVIEW_TERM_DT: '20130826', + RVWR_NM: '이택준', + CNFMR_NM: '이택준', + APPR_NM: '전상용', + PRGS_STAT: 'E', + PRGS_STAT_DSC: '완료', + REGR_DPTCD: 'D602058000', + REGR_DEPTNM: '구매1팀(사외계약)' + }, + { + REG_NO: '1100', + INQ_TP: 'IC', + INQ_TP_DSC: '국내계약', + TIT: '(7102) HVAC 작업계약', + REQ_DGR: '1', + REQR_NM: '신동동', + REQ_DT: '20130826', + REVIEW_TERM_DT: '20130829', + RVWR_NM: '이두리', + CNFMR_NM: '이두리', + APPR_NM: '전상용', + PRGS_STAT: 'E', + PRGS_STAT_DSC: '완료', + REGR_DPTCD: 'D602058000', + REGR_DEPTNM: '구매1팀(사외계약)' + }, + { + REG_NO: '1105', + INQ_TP: 'IC', + INQ_TP_DSC: '국내계약', + TIT: 'Plate 가공계약서 검토 요청건', + REQ_DGR: '1', + REQR_NM: '서권환', + REQ_DT: '20130826', + REVIEW_TERM_DT: '20130829', + RVWR_NM: '백영국', + CNFMR_NM: '백영국', + APPR_NM: '전상용', + PRGS_STAT: 'E', + PRGS_STAT_DSC: '완료', + REGR_DPTCD: 'D602058000', + REGR_DEPTNM: '구매1팀(사외계약)' + }, + { + REG_NO: '1106', + INQ_TP: 'IC', + INQ_TP_DSC: '국내계약', + TIT: 'SHELL FLNG, V-BRACE 제작 계약서 검토件', + REQ_DGR: '1', + REQR_NM: '성기승', + REQ_DT: '20130826', + REVIEW_TERM_DT: '20130830', + RVWR_NM: '이두리', + CNFMR_NM: '이두리', + APPR_NM: '전상용', + PRGS_STAT: 'E', + PRGS_STAT_DSC: '완료', + REGR_DPTCD: 'D602058000', + REGR_DEPTNM: '구매1팀(사외계약)' } ] @@ -89,6 +160,7 @@ export async function getSSLVWPurInqReqByRegNo(regNo: string): Promise<{ success: boolean data?: SSLVWPurInqReq error?: string + isUsingFallback?: boolean }> { if (!regNo) { return { @@ -112,6 +184,21 @@ export async function getSSLVWPurInqReqByRegNo(regNo: string): Promise<{ const cleanedResult = normalizeOracleRows(rows) if (cleanedResult.length === 0) { + // 데이터가 없을 때 폴백 테스트 데이터에서 찾기 + console.log(`[getSSLVWPurInqReqByRegNo] 데이터 없음, 폴백 테스트 데이터에서 검색: REG_NO=${regNo}`) + const fallbackData = FALLBACK_TEST_DATA.find(item => + String(item.REG_NO) === String(regNo) + ) + + if (fallbackData) { + console.log(`[getSSLVWPurInqReqByRegNo] 폴백 테스트 데이터에서 찾음: REG_NO=${regNo}`) + return { + success: true, + data: fallbackData, + isUsingFallback: true + } + } + return { success: false, error: '해당 REG_NO에 대한 데이터가 없습니다.' @@ -120,10 +207,27 @@ export async function getSSLVWPurInqReqByRegNo(regNo: string): Promise<{ return { success: true, - data: cleanedResult[0] + data: cleanedResult[0], + isUsingFallback: false } } catch (error) { console.error('[getSSLVWPurInqReqByRegNo] 오류:', error) + console.log(`[getSSLVWPurInqReqByRegNo] 폴백 테스트 데이터에서 검색: REG_NO=${regNo}`) + + // 오류 발생 시 폴백 테스트 데이터에서 찾기 + const fallbackData = FALLBACK_TEST_DATA.find(item => + String(item.REG_NO) === String(regNo) + ) + + if (fallbackData) { + console.log(`[getSSLVWPurInqReqByRegNo] 폴백 테스트 데이터에서 찾음: REG_NO=${regNo}`) + return { + success: true, + data: fallbackData, + isUsingFallback: true + } + } + return { success: false, error: error instanceof Error ? error.message : 'REG_NO 조회 중 오류가 발생했습니다.' diff --git a/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx b/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx index 575582cf..3e7caee1 100644 --- a/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx +++ b/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx @@ -18,9 +18,10 @@ import { DialogTitle, } from "@/components/ui/dialog" import { Badge } from "@/components/ui/badge" -import { prepareFinalApprovalAction, quickFinalApprovalAction, resendContractsAction, updateLegalReviewStatusFromSSLVW } from "../service" +import { prepareFinalApprovalAction, quickFinalApprovalAction, resendContractsAction, updateLegalReviewStatusFromSSLVW, updateComplianceReviewStatusFromCPVW, requestComplianceInquiryAction } from "../service" import { BasicContractSignDialog } from "../vendor-table/basic-contract-sign-dialog" import { SSLVWPurInqReqDialog } from "@/components/common/legal/sslvw-pur-inq-req-dialog" +import { CPVWWabQustListViewDialog } from "@/components/common/legal/cpvw-wab-qust-list-view-dialog" import { prepareRedFlagResolutionApproval, requestRedFlagResolution } from "@/lib/compliance/red-flag-resolution" import { useRouter } from "next/navigation" import { useSession } from "next-auth/react" @@ -81,24 +82,26 @@ export function BasicContractDetailTableToolbarActions({ if (contract.completedAt !== null || !contract.signedFilePath) { return false; } - if (contract.legalReviewRequestedAt && !contract.legalReviewCompletedAt) { - return false; - } + // ⚠️ 법무/준법문의 완료 여부는 SSLVW/CPVW 상태 및 완료 시간에 의존하므로, + // 여기서는 legalReviewCompletedAt / complianceReviewCompletedAt 기반으로 + // 최종 승인 버튼을 막지 않습니다. (상태/시간은 UI 참고용으로만 사용) return true; }); - // 법무검토 요청 가능 여부 - // 1. 협의 완료됨 (negotiationCompletedAt 있음) OR - // 2. 협의 없음 (코멘트 없음, hasComments: false) + // 법무검토 요청 가능 여부 (준법서약 템플릿이 아닐 때만) + // 1. 협력업체 서명 완료 (vendorSignedAt 있음) + // 2. 협의 완료됨 (negotiationCompletedAt 있음) OR + // 3. 협의 없음 (코멘트 없음, hasComments: false) // 협의 중 (negotiationCompletedAt 없고 코멘트 있음)은 불가 - const canRequestLegalReview = hasSelectedRows && selectedRows.some(row => { + const canRequestLegalReview = !isComplianceTemplate && hasSelectedRows && selectedRows.some(row => { const contract = row.original; - // 이미 법무검토 요청된 계약서는 제외 - if (contract.legalReviewRequestedAt) { - return false; - } - // 이미 최종승인 완료된 계약서는 제외 - if (contract.completedAt) { + + // 필수 조건 확인: 최종승인 미완료, 법무검토 미요청, 협력업체 서명 완료 + if ( + contract.legalReviewRequestedAt || + contract.completedAt || + !contract.vendorSignedAt + ) { return false; } @@ -123,6 +126,35 @@ export function BasicContractDetailTableToolbarActions({ return false; }); + // 준법문의 버튼 활성화 가능 여부 + // 1. 협력업체 서명 완료 (vendorSignedAt 있음) + // 2. 협의 완료 (negotiationCompletedAt 있음) + // 3. 레드플래그 해소됨 (redFlagResolutionData에서 resolved 상태) + // 4. 이미 준법문의 요청되지 않음 (complianceReviewRequestedAt 없음) + const canRequestComplianceInquiry = hasSelectedRows && selectedRows.some(row => { + const contract = row.original; + + // 필수 조건 확인: 준법서약 템플릿, 최종승인 미완료, 협력업체 서명 완료, 협의 완료, 준법문의 미요청 + if ( + !isComplianceTemplate || + contract.completedAt || + !contract.vendorSignedAt || + !contract.negotiationCompletedAt || + contract.complianceReviewRequestedAt + ) { + return false; + } + + // 레드플래그 해소 확인 + const resolution = redFlagResolutionData[contract.id]; + // 레드플래그가 있는 경우, 해소되어야 함 + if (redFlagData[contract.id] === true && !resolution?.resolved) { + return false; + } + + return true; + }); + // 필터링된 계약서들 계산 const resendContracts = selectedRows.map(row => row.original) @@ -394,6 +426,47 @@ export function BasicContractDetailTableToolbarActions({ } } + // CPVW 데이터 선택 확인 핸들러 + const handleCPVWConfirm = async (selectedCPVWData: any[]) => { + if (!selectedCPVWData || selectedCPVWData.length === 0) { + toast.error("선택된 데이터가 없습니다.") + return + } + + if (selectedRows.length !== 1) { + toast.error("계약서 한 건을 선택해주세요.") + return + } + + try { + setLoading(true) + + // 선택된 계약서 ID들 추출 + const selectedContractIds = selectedRows.map(row => row.original.id) + + // 서버 액션 호출 + const result = await updateComplianceReviewStatusFromCPVW(selectedCPVWData, selectedContractIds) + + if (result.success) { + toast.success(result.message) + router.refresh() + table.toggleAllPageRowsSelected(false) + } else { + toast.error(result.message) + } + + if (result.errors && result.errors.length > 0) { + toast.warning(`일부 처리 실패: ${result.errors.join(', ')}`) + } + + } catch (error) { + console.error('CPVW 확인 처리 실패:', error) + toast.error('준법문의 상태 업데이트 중 오류가 발생했습니다.') + } finally { + setLoading(false) + } + } + // 빠른 승인 (서명 없이) const confirmQuickApproval = async () => { setLoading(true) @@ -541,9 +614,26 @@ export function BasicContractDetailTableToolbarActions({ const complianceInquiryUrl = 'http://60.101.207.55/Inquiry/Write/InquiryWrite.aspx' // 법무검토 요청 / 준법문의 - const handleRequestLegalReview = () => { + const handleRequestLegalReview = async () => { if (isComplianceTemplate) { - window.open(complianceInquiryUrl, '_blank', 'noopener,noreferrer') + // 준법문의: 요청일 기록 후 외부 URL 열기 + const selectedContractIds = selectedRows.map(row => row.original.id) + try { + setLoading(true) + const result = await requestComplianceInquiryAction(selectedContractIds) + if (result.success) { + toast.success(result.message) + router.refresh() + window.open(complianceInquiryUrl, '_blank', 'noopener,noreferrer') + } else { + toast.error(result.message) + } + } catch (error) { + console.error('준법문의 요청 처리 실패:', error) + toast.error('준법문의 요청 중 오류가 발생했습니다.') + } finally { + setLoading(false) + } return } setLegalReviewDialog(true) @@ -617,31 +707,72 @@ export function BasicContractDetailTableToolbarActions({ - {/* 법무검토 버튼 (SSLVW 데이터 조회) */} - + {/* 법무검토 버튼 (SSLVW 데이터 조회) - 준법서약 템플릿이 아닐 때만 표시 */} + {!isComplianceTemplate && ( + + )} + + {/* 준법문의 요청 데이터 조회 버튼 (준법서약 템플릿만) */} + {isComplianceTemplate && ( + + )} {/* 법무검토 요청 / 준법문의 버튼 */} - + {isComplianceTemplate ? ( + + ) : ( + + )} {/* 최종승인 버튼 */} + + {/* 로고 영역 */} +
+ + EVCP Logo + + {t(brandNameKey)} + + +
+ + {/* 네비게이션 메뉴 */} +
+ {isLoading ? ( +
+ +
+ ) : ( + +
+ + {tree.map((node) => { + // 드롭다운 메뉴 (menu_group with children) + if (isDropdownMenu(node)) { + return ( + + { + e.preventDefault(); + e.stopPropagation(); + toggleMenu(String(node.id)); + }} + onPointerEnter={(e) => e.preventDefault()} + onPointerMove={(e) => e.preventDefault()} + onPointerLeave={(e) => e.preventDefault()} + > + {getTitle(node)} + + + e.preventDefault()} + onPointerLeave={(e) => e.preventDefault()} + forceMount={ + openMenuKey === String(node.id) + ? true + : undefined + } + > + setOpenMenuKey("")} + /> + + + ); + } + + // 단일 링크 메뉴 (최상위 menu) + if (node.nodeType === 'menu' && node.menuPath) { + return ( + + + e.preventDefault()} + onPointerLeave={(e) => e.preventDefault()} + > + {getTitle(node)} + + + + ); + } + + return null; + })} + +
+
+ )} +
+ + {/* 우측 영역 */} +
+ {/* 데스크탑에서는 CommandMenu, 모바일에서는 검색 아이콘만 */} +
+ +
+ + + {/* 알림 버튼 */} + + + {/* 사용자 메뉴 */} + + + + + {initials || "?"} + + + + {t("user.my_account")} + + + {t("user.settings")} + + + + customSignOut({ + callbackUrl: `${window.location.origin}${basePath}`, + }) + } + > + {t("user.logout")} + + + +
+ + + + {/* 모바일 메뉴 */} + {isMobileMenuOpen && ( + + )} + + + ); +} diff --git a/components/layout/MobileMenuV2.tsx b/components/layout/MobileMenuV2.tsx new file mode 100644 index 00000000..c83ba779 --- /dev/null +++ b/components/layout/MobileMenuV2.tsx @@ -0,0 +1,160 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { X, ChevronDown, ChevronRight } from "lucide-react"; +import type { MenuTreeNode } from "@/lib/menu-v2/types"; + +interface MobileMenuV2Props { + lng: string; + onClose: () => void; + tree: MenuTreeNode[]; + getTitle: (node: MenuTreeNode) => string; + getDescription: (node: MenuTreeNode) => string | null; +} + +export function MobileMenuV2({ + lng, + onClose, + tree, + getTitle, + getDescription, +}: MobileMenuV2Props) { + const [expandedGroups, setExpandedGroups] = React.useState>( + new Set() + ); + + const toggleGroup = (groupId: number) => { + setExpandedGroups((prev) => { + const next = new Set(prev); + if (next.has(groupId)) { + next.delete(groupId); + } else { + next.add(groupId); + } + return next; + }); + }; + + // 드롭다운 메뉴인지 판단 + const isDropdownMenu = (node: MenuTreeNode) => + node.nodeType === 'menu_group' && node.children && node.children.length > 0; + + return ( +
+ {/* 헤더 */} +
+ 메뉴 + +
+ + {/* 스크롤 영역 */} + +
+ {tree.map((node) => { + // 드롭다운 메뉴 (menu_group with children) + if (isDropdownMenu(node)) { + return ( +
+ {/* 메뉴그룹 헤더 */} + + + {/* 하위 메뉴 */} + {expandedGroups.has(node.id) && ( +
+ {node.children?.map((item) => { + if (item.nodeType === "group") { + // 그룹인 경우 + return ( +
+
+ {getTitle(item)} +
+
+ {item.children?.map((menu) => ( + + ))} +
+
+ ); + } else if (item.nodeType === "menu") { + // 직접 메뉴인 경우 + return ( + + ); + } + return null; + })} +
+ )} +
+ ); + } + + // 단일 링크 메뉴 (최상위 menu) + if (node.nodeType === 'menu' && node.menuPath) { + return ( + + ); + } + + return null; + })} +
+
+
+ ); +} + +interface MobileMenuLinkProps { + href: string; + title: string; + onClick: () => void; +} + +function MobileMenuLink({ href, title, onClick }: MobileMenuLinkProps) { + return ( + + {title} + + ); +} diff --git a/db/schema/index.ts b/db/schema/index.ts index 6463e0ec..022431cc 100644 --- a/db/schema/index.ts +++ b/db/schema/index.ts @@ -29,7 +29,11 @@ export * from './evaluation'; export * from './evaluationTarget'; export * from './evaluationCriteria'; export * from './projectGtc'; +// 기존 menu 스키마 (deprecated - menu-v2로 대체됨) export * from './menu'; + +// 새로운 메뉴 트리 스키마 (v2) +export * from './menu-v2'; export * from './information'; export * from './qna'; export * from './notice'; diff --git a/db/schema/menu-v2.ts b/db/schema/menu-v2.ts new file mode 100644 index 00000000..2d0282fa --- /dev/null +++ b/db/schema/menu-v2.ts @@ -0,0 +1,88 @@ +// db/schema/menu-v2.ts +import { pgTable, pgEnum, integer, varchar, text, timestamp, boolean, index, uniqueIndex } from "drizzle-orm/pg-core"; +import { relations } from "drizzle-orm"; +import { users } from "./users"; + +export const menuTreeNodeTypeEnum = pgEnum('menu_tree_node_type', [ + 'menu_group', // 메뉴그룹 (1단계) - 헤더에 표시되는 드롭다운 트리거 + 'group', // 그룹 (2단계) - 드롭다운 내 구분 영역 + 'menu', // 메뉴 (3단계) - 드롭다운 내 링크 + 'additional' // 추가 메뉴 - 최상위 단일 링크 (Dashboard, QNA, FAQ 등) +]); + +export const menuDomainEnum = pgEnum('menu_domain', [ + 'evcp', // 내부 사용자용 + 'partners' // 협력업체용 +]); + +export const menuTreeNodes = pgTable("menu_tree_nodes", { + id: integer("id").primaryKey().generatedAlwaysAsIdentity(), + + // 도메인 구분 + domain: menuDomainEnum("domain").notNull(), + + // 트리 구조 + parentId: integer("parent_id").references((): any => menuTreeNodes.id, { onDelete: "cascade" }), + nodeType: menuTreeNodeTypeEnum("node_type").notNull(), + sortOrder: integer("sort_order").notNull().default(0), + + // 다국어 텍스트 (DB 직접 관리) + titleKo: varchar("title_ko", { length: 255 }).notNull(), + titleEn: varchar("title_en", { length: 255 }), + descriptionKo: text("description_ko"), + descriptionEn: text("description_en"), + + // 메뉴 전용 필드 (nodeType === 'menu' 또는 'additional'일 때) + menuPath: varchar("menu_path", { length: 255 }), // href 값 (예: /evcp/projects) + icon: varchar("icon", { length: 100 }), + + // 권한 연동 + // evcp: Oracle DB SCR_ID 참조 + // partners: 자체 권한 시스템 (TODO) + scrId: varchar("scr_id", { length: 100 }), + + // 상태 + isActive: boolean("is_active").default(true).notNull(), + + // 담당자 (evcp 전용) + manager1Id: integer("manager1_id").references(() => users.id, { onDelete: "set null" }), + manager2Id: integer("manager2_id").references(() => users.id, { onDelete: "set null" }), + + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), +}, (table) => ({ + domainIdx: index("menu_tree_domain_idx").on(table.domain), + parentIdx: index("menu_tree_parent_idx").on(table.parentId), + sortOrderIdx: index("menu_tree_sort_order_idx").on(table.sortOrder), + menuPathUnique: uniqueIndex("menu_tree_path_unique_idx").on(table.menuPath), + scrIdIdx: index("menu_tree_scr_id_idx").on(table.scrId), +})); + +// Relations 정의 +export const menuTreeNodesRelations = relations(menuTreeNodes, ({ one, many }) => ({ + parent: one(menuTreeNodes, { + fields: [menuTreeNodes.parentId], + references: [menuTreeNodes.id], + relationName: "parentChild", + }), + children: many(menuTreeNodes, { + relationName: "parentChild", + }), + manager1: one(users, { + fields: [menuTreeNodes.manager1Id], + references: [users.id], + relationName: "menuManager1", + }), + manager2: one(users, { + fields: [menuTreeNodes.manager2Id], + references: [users.id], + relationName: "menuManager2", + }), +})); + +// Type exports +export type MenuTreeNode = typeof menuTreeNodes.$inferSelect; +export type NewMenuTreeNode = typeof menuTreeNodes.$inferInsert; +export type NodeType = (typeof menuTreeNodeTypeEnum.enumValues)[number]; +export type MenuDomain = (typeof menuDomainEnum.enumValues)[number]; + diff --git a/db/seeds/menu-v2-seed.js b/db/seeds/menu-v2-seed.js new file mode 100644 index 00000000..e332f044 --- /dev/null +++ b/db/seeds/menu-v2-seed.js @@ -0,0 +1,231 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __generator = (this && this.__generator) || function (thisArg, body) { + var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); + return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; + function verb(n) { return function (v) { return step([n, v]); }; } + function step(op) { + if (f) throw new TypeError("Generator is already executing."); + while (g && (g = 0, op[0] && (_ = 0)), _) try { + if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; + if (y = 0, t) op = [op[0] & 2, t.value]; + switch (op[0]) { + case 0: case 1: t = op; break; + case 4: _.label++; return { value: op[1], done: false }; + case 5: _.label++; y = op[1]; op = [0]; continue; + case 7: op = _.ops.pop(); _.trys.pop(); continue; + default: + if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } + if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } + if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } + if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } + if (t[2]) _.ops.pop(); + _.trys.pop(); continue; + } + op = body.call(thisArg, _); + } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } + if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; + } +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.seedMenuTree = seedMenuTree; +// db/seeds/menu-v2-seed.ts +var menuConfig_1 = require("@/config/menuConfig"); +var menu_json_1 = require("@/i18n/locales/ko/menu.json"); +var menu_json_2 = require("@/i18n/locales/en/menu.json"); +var db_1 = require("@/db/db"); +var menu_v2_1 = require("@/db/schema/menu-v2"); +// 중첩 키로 번역 값 가져오기 +function getTranslation(key, locale) { + var translations = locale === 'ko' ? menu_json_1.default : menu_json_2.default; + var keys = key.split('.'); + var value = translations; + for (var _i = 0, keys_1 = keys; _i < keys_1.length; _i++) { + var k = keys_1[_i]; + if (typeof value === 'object' && value !== null) { + value = value[k]; + } + else { + return key; + } + if (value === undefined) + return key; + } + return typeof value === 'string' ? value : key; +} +function seedMenuTree() { + return __awaiter(this, void 0, void 0, function () { + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + console.log('🌱 Starting menu tree seeding...'); + // 기존 데이터 삭제 + return [4 /*yield*/, db_1.default.delete(menu_v2_1.menuTreeNodes)]; + case 1: + // 기존 데이터 삭제 + _a.sent(); + console.log('✅ Cleared existing menu tree data'); + // evcp 도메인 seed + return [4 /*yield*/, seedDomainMenus('evcp', menuConfig_1.mainNav, menuConfig_1.additionalNav)]; + case 2: + // evcp 도메인 seed + _a.sent(); + console.log('✅ Seeded evcp menu tree'); + // partners 도메인 seed + return [4 /*yield*/, seedDomainMenus('partners', menuConfig_1.mainNavVendor, menuConfig_1.additionalNavVendor)]; + case 3: + // partners 도메인 seed + _a.sent(); + console.log('✅ Seeded partners menu tree'); + console.log('🎉 Menu tree seeding completed!'); + return [2 /*return*/]; + } + }); + }); +} +function seedDomainMenus(domain, navConfig, additionalConfig) { + return __awaiter(this, void 0, void 0, function () { + var globalSortOrder, _loop_1, _i, navConfig_1, section, additionalSortOrder, _a, additionalConfig_1, item; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: + globalSortOrder = 0; + _loop_1 = function (section) { + var menuGroup, groupedItems, groupSortOrder, _c, groupedItems_1, _d, groupKey, items, parentId, group, menuSortOrder, _e, items_1, item; + return __generator(this, function (_f) { + switch (_f.label) { + case 0: return [4 /*yield*/, db_1.default.insert(menu_v2_1.menuTreeNodes).values({ + domain: domain, + parentId: null, + nodeType: 'menu_group', + titleKo: getTranslation(section.titleKey, 'ko'), + titleEn: getTranslation(section.titleKey, 'en'), + sortOrder: globalSortOrder++, + isActive: true, + }).returning()]; + case 1: + menuGroup = (_f.sent())[0]; + groupedItems = new Map(); + section.items.forEach(function (item) { + var groupKey = item.groupKey || '__default__'; + if (!groupedItems.has(groupKey)) { + groupedItems.set(groupKey, []); + } + groupedItems.get(groupKey).push(item); + }); + groupSortOrder = 0; + _c = 0, groupedItems_1 = groupedItems; + _f.label = 2; + case 2: + if (!(_c < groupedItems_1.length)) return [3 /*break*/, 9]; + _d = groupedItems_1[_c], groupKey = _d[0], items = _d[1]; + parentId = menuGroup.id; + if (!(groupKey !== '__default__')) return [3 /*break*/, 4]; + return [4 /*yield*/, db_1.default.insert(menu_v2_1.menuTreeNodes).values({ + domain: domain, + parentId: menuGroup.id, + nodeType: 'group', + titleKo: getTranslation(groupKey, 'ko'), + titleEn: getTranslation(groupKey, 'en'), + sortOrder: groupSortOrder++, + isActive: true, + }).returning()]; + case 3: + group = (_f.sent())[0]; + parentId = group.id; + _f.label = 4; + case 4: + menuSortOrder = 0; + _e = 0, items_1 = items; + _f.label = 5; + case 5: + if (!(_e < items_1.length)) return [3 /*break*/, 8]; + item = items_1[_e]; + return [4 /*yield*/, db_1.default.insert(menu_v2_1.menuTreeNodes).values({ + domain: domain, + parentId: parentId, + nodeType: 'menu', + titleKo: getTranslation(item.titleKey, 'ko'), + titleEn: getTranslation(item.titleKey, 'en'), + descriptionKo: item.descriptionKey ? getTranslation(item.descriptionKey, 'ko') : null, + descriptionEn: item.descriptionKey ? getTranslation(item.descriptionKey, 'en') : null, + menuPath: item.href, + icon: item.icon || null, + sortOrder: menuSortOrder++, + isActive: true, + })]; + case 6: + _f.sent(); + _f.label = 7; + case 7: + _e++; + return [3 /*break*/, 5]; + case 8: + _c++; + return [3 /*break*/, 2]; + case 9: return [2 /*return*/]; + } + }); + }; + _i = 0, navConfig_1 = navConfig; + _b.label = 1; + case 1: + if (!(_i < navConfig_1.length)) return [3 /*break*/, 4]; + section = navConfig_1[_i]; + return [5 /*yield**/, _loop_1(section)]; + case 2: + _b.sent(); + _b.label = 3; + case 3: + _i++; + return [3 /*break*/, 1]; + case 4: + additionalSortOrder = 0; + _a = 0, additionalConfig_1 = additionalConfig; + _b.label = 5; + case 5: + if (!(_a < additionalConfig_1.length)) return [3 /*break*/, 8]; + item = additionalConfig_1[_a]; + return [4 /*yield*/, db_1.default.insert(menu_v2_1.menuTreeNodes).values({ + domain: domain, + parentId: null, + nodeType: 'additional', + titleKo: getTranslation(item.titleKey, 'ko'), + titleEn: getTranslation(item.titleKey, 'en'), + descriptionKo: item.descriptionKey ? getTranslation(item.descriptionKey, 'ko') : null, + descriptionEn: item.descriptionKey ? getTranslation(item.descriptionKey, 'en') : null, + menuPath: item.href, + sortOrder: additionalSortOrder++, + isActive: true, + })]; + case 6: + _b.sent(); + _b.label = 7; + case 7: + _a++; + return [3 /*break*/, 5]; + case 8: return [2 /*return*/]; + } + }); + }); +} +// CLI에서 직접 실행 가능하도록 +if (require.main === module) { + seedMenuTree() + .then(function () { + console.log('Seed completed successfully'); + process.exit(0); + }) + .catch(function (error) { + console.error('Seed failed:', error); + process.exit(1); + }); +} diff --git a/db/seeds/menu-v2-seed.ts b/db/seeds/menu-v2-seed.ts new file mode 100644 index 00000000..0c6b310d --- /dev/null +++ b/db/seeds/menu-v2-seed.ts @@ -0,0 +1,145 @@ +// db/seeds/menu-v2-seed.ts +import { mainNav, additionalNav, mainNavVendor, additionalNavVendor, MenuSection, MenuItem } from "@/config/menuConfig"; +import koMenu from '@/i18n/locales/ko/menu.json'; +import enMenu from '@/i18n/locales/en/menu.json'; +import db from "@/db/db"; +import { menuTreeNodes } from "@/db/schema/menu-v2"; +import type { MenuDomain } from "@/lib/menu-v2/types"; + +type TranslationObject = { [key: string]: string | TranslationObject }; + +// 중첩 키로 번역 값 가져오기 +function getTranslation(key: string, locale: 'ko' | 'en'): string { + const translations: TranslationObject = locale === 'ko' ? koMenu : enMenu; + const keys = key.split('.'); + let value: string | TranslationObject | undefined = translations; + + for (const k of keys) { + if (typeof value === 'object' && value !== null) { + value = value[k]; + } else { + return key; + } + if (value === undefined) return key; + } + + return typeof value === 'string' ? value : key; +} + +export async function seedMenuTree() { + console.log('🌱 Starting menu tree seeding...'); + + // 기존 데이터 삭제 + await db.delete(menuTreeNodes); + console.log('✅ Cleared existing menu tree data'); + + // evcp 도메인 seed + await seedDomainMenus('evcp', mainNav, additionalNav); + console.log('✅ Seeded evcp menu tree'); + + // partners 도메인 seed + await seedDomainMenus('partners', mainNavVendor, additionalNavVendor); + console.log('✅ Seeded partners menu tree'); + + console.log('🎉 Menu tree seeding completed!'); +} + +async function seedDomainMenus( + domain: MenuDomain, + navConfig: MenuSection[], + additionalConfig: MenuItem[] +) { + // 최상위 sortOrder (메뉴그룹과 최상위 메뉴 모두 같은 레벨에서 정렬) + let topLevelSortOrder = 0; + + // 메인 네비게이션 (메뉴그룹 → 그룹 → 메뉴) + for (const section of navConfig) { + // 1단계: 메뉴그룹 생성 + const [menuGroup] = await db.insert(menuTreeNodes).values({ + domain, + parentId: null, + nodeType: 'menu_group', + titleKo: getTranslation(section.titleKey, 'ko'), + titleEn: getTranslation(section.titleKey, 'en'), + sortOrder: topLevelSortOrder++, + isActive: true, + }).returning(); + + // groupKey별로 그룹화 + const groupedItems = new Map(); + section.items.forEach(item => { + const groupKey = item.groupKey || '__default__'; + if (!groupedItems.has(groupKey)) { + groupedItems.set(groupKey, []); + } + groupedItems.get(groupKey)!.push(item); + }); + + let groupSortOrder = 0; + for (const [groupKey, items] of groupedItems) { + let parentId = menuGroup.id; + + // groupKey가 있으면 2단계 그룹 생성 + if (groupKey !== '__default__') { + const [group] = await db.insert(menuTreeNodes).values({ + domain, + parentId: menuGroup.id, + nodeType: 'group', + titleKo: getTranslation(groupKey, 'ko'), + titleEn: getTranslation(groupKey, 'en'), + sortOrder: groupSortOrder++, + isActive: true, + }).returning(); + parentId = group.id; + } + + // 3단계: 메뉴 생성 + let menuSortOrder = 0; + for (const item of items) { + await db.insert(menuTreeNodes).values({ + domain, + parentId, + nodeType: 'menu', + titleKo: getTranslation(item.titleKey, 'ko'), + titleEn: getTranslation(item.titleKey, 'en'), + descriptionKo: item.descriptionKey ? getTranslation(item.descriptionKey, 'ko') : null, + descriptionEn: item.descriptionKey ? getTranslation(item.descriptionKey, 'en') : null, + menuPath: item.href, + icon: item.icon || null, + sortOrder: menuSortOrder++, + isActive: true, + }); + } + } + } + + // 최상위 단일 링크 메뉴 (기존 additional) + // nodeType을 'menu'로 설정하고 parentId를 null로 유지 + for (const item of additionalConfig) { + await db.insert(menuTreeNodes).values({ + domain, + parentId: null, + nodeType: 'menu', // 'additional' 대신 'menu' 사용 + titleKo: getTranslation(item.titleKey, 'ko'), + titleEn: getTranslation(item.titleKey, 'en'), + descriptionKo: item.descriptionKey ? getTranslation(item.descriptionKey, 'ko') : null, + descriptionEn: item.descriptionKey ? getTranslation(item.descriptionKey, 'en') : null, + menuPath: item.href, + sortOrder: topLevelSortOrder++, // 메뉴그룹 다음 순서 + isActive: true, + }); + } +} + +// CLI에서 직접 실행 가능하도록 +if (require.main === module) { + seedMenuTree() + .then(() => { + console.log('Seed completed successfully'); + process.exit(0); + }) + .catch((error) => { + console.error('Seed failed:', error); + process.exit(1); + }); +} diff --git a/hooks/use-visible-menu-tree.ts b/hooks/use-visible-menu-tree.ts new file mode 100644 index 00000000..bc7f1f73 --- /dev/null +++ b/hooks/use-visible-menu-tree.ts @@ -0,0 +1,49 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { getVisibleMenuTree } from "@/lib/menu-v2/permission-service"; +import type { MenuDomain, MenuTreeNode, MenuTreeActiveResult } from "@/lib/menu-v2/types"; + +interface UseVisibleMenuTreeResult extends MenuTreeActiveResult { + isLoading: boolean; + error: Error | null; + refetch: () => Promise; +} + +/** + * Hook to fetch user's visible menu tree (filtered by permissions) + * Tree contains both menu groups (dropdowns) and top-level menus (single links) + */ +export function useVisibleMenuTree(domain: MenuDomain): UseVisibleMenuTreeResult { + const [tree, setTree] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchMenuTree = useCallback(async () => { + setIsLoading(true); + setError(null); + + try { + // Call server action directly + const result = await getVisibleMenuTree(domain); + setTree(result.tree); + } catch (err) { + console.error("Error fetching visible menu tree:", err); + setError(err instanceof Error ? err : new Error("Unknown error")); + setTree([]); + } finally { + setIsLoading(false); + } + }, [domain]); + + useEffect(() => { + fetchMenuTree(); + }, [fetchMenuTree]); + + return { + tree, + isLoading, + error, + refetch: fetchMenuTree, + }; +} diff --git a/lib/information/service.ts b/lib/information/service.ts index 02efe616..39e810e4 100644 --- a/lib/information/service.ts +++ b/lib/information/service.ts @@ -3,7 +3,7 @@ import { getErrorMessage } from "@/lib/handle-error" import { desc, or, eq } from "drizzle-orm" import db from "@/db/db" -import { pageInformation, menuAssignments, users } from "@/db/schema" +import { pageInformation, menuTreeNodes, users } from "@/db/schema" import { saveDRMFile } from "@/lib/file-stroage" import { decryptWithServerAction } from "@/components/drm/drmUtils" @@ -144,27 +144,27 @@ export async function checkInformationEditPermission(pagePath: string, userId: s pagePath // 원본 경로 정확한 매칭 ] - // menu_assignments에서 해당 pagePath와 매칭되는 메뉴 찾기 - const menuAssignment = await db + // menu_tree_nodes에서 해당 pagePath와 매칭되는 메뉴 찾기 + const menuNode = await db .select() - .from(menuAssignments) + .from(menuTreeNodes) .where( or( - ...menuPathQueries.map(path => eq(menuAssignments.menuPath, path)) + ...menuPathQueries.map(path => eq(menuTreeNodes.menuPath, path)) ) ) .limit(1) - if (menuAssignment.length === 0) { + if (menuNode.length === 0) { // 매칭되는 메뉴가 없으면 권한 없음 return false } - const assignment = menuAssignment[0] + const node = menuNode[0] const userIdNumber = parseInt(userId) // 현재 사용자가 manager1 또는 manager2인지 확인 - return assignment.manager1Id === userIdNumber || assignment.manager2Id === userIdNumber + return node.manager1Id === userIdNumber || node.manager2Id === userIdNumber } catch (error) { console.error("Failed to check information edit permission:", error) return false @@ -176,17 +176,21 @@ export async function getEditPermissionDirect(pagePath: string, userId: string) return await checkInformationEditPermission(pagePath, userId) } -// menu_assignments 기반으로 page_information 동기화 +// menu_tree_nodes 기반으로 page_information 동기화 export async function syncInformationFromMenuAssignments() { try { - // menu_assignments에서 모든 메뉴 가져오기 - const menuItems = await db.select().from(menuAssignments); + // menu_tree_nodes에서 메뉴 타입 노드만 가져오기 (menuPath가 있는 것) + const menuItems = await db.select() + .from(menuTreeNodes) + .where(eq(menuTreeNodes.nodeType, 'menu')); let processedCount = 0; // upsert를 사용하여 각 메뉴 항목 처리 for (const menu of menuItems) { try { + if (!menu.menuPath) continue; + // 맨 앞의 / 제거하여 pagePath 정규화 const normalizedPagePath = menu.menuPath.startsWith('/') ? menu.menuPath.slice(1) @@ -195,14 +199,14 @@ export async function syncInformationFromMenuAssignments() { await db.insert(pageInformation) .values({ pagePath: normalizedPagePath, - pageName: menu.menuTitle, + pageName: menu.titleKo, informationContent: "", isActive: true // 기본값으로 활성화 }) .onConflictDoUpdate({ target: pageInformation.pagePath, set: { - pageName: menu.menuTitle, + pageName: menu.titleKo, updatedAt: new Date() } }); @@ -213,8 +217,6 @@ export async function syncInformationFromMenuAssignments() { } } - // 캐시 무효화 제거됨 - return { success: true, message: `페이지 정보 동기화 완료: ${processedCount}개 처리됨` diff --git a/lib/menu-v2/components/add-node-dialog.tsx b/lib/menu-v2/components/add-node-dialog.tsx new file mode 100644 index 00000000..b6762820 --- /dev/null +++ b/lib/menu-v2/components/add-node-dialog.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { useForm } from "react-hook-form"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import type { + MenuDomain, + CreateMenuGroupInput, + CreateGroupInput, + CreateTopLevelMenuInput +} from "../types"; + +type DialogType = "menu_group" | "group" | "top_level_menu"; + +interface AddNodeDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + type: DialogType; + domain: MenuDomain; + parentId?: number; // group 생성 시 필요 + onSave: (data: CreateMenuGroupInput | CreateGroupInput | CreateTopLevelMenuInput) => Promise; +} + +interface FormData { + titleKo: string; + titleEn: string; + menuPath: string; +} + +export function AddNodeDialog({ + open, + onOpenChange, + type, + domain, + parentId, + onSave, +}: AddNodeDialogProps) { + const { + register, + handleSubmit, + reset, + formState: { isSubmitting, errors }, + } = useForm({ + defaultValues: { + titleKo: "", + titleEn: "", + menuPath: "", + }, + }); + + const getTitle = () => { + switch (type) { + case "menu_group": + return "Add Menu Group"; + case "group": + return "Add Group"; + case "top_level_menu": + return "Add Top-Level Menu"; + default: + return "Add"; + } + }; + + const getDescription = () => { + switch (type) { + case "menu_group": + return "A dropdown trigger displayed in the header navigation."; + case "group": + return "Groups menus within a menu group."; + case "top_level_menu": + return "A single link displayed in the header navigation."; + default: + return ""; + } + }; + + const onSubmit = async (data: FormData) => { + let saveData: CreateMenuGroupInput | CreateGroupInput | CreateTopLevelMenuInput; + + if (type === "menu_group") { + saveData = { + titleKo: data.titleKo, + titleEn: data.titleEn || undefined, + }; + } else if (type === "group" && parentId) { + saveData = { + parentId, + titleKo: data.titleKo, + titleEn: data.titleEn || undefined, + }; + } else if (type === "top_level_menu") { + saveData = { + titleKo: data.titleKo, + titleEn: data.titleEn || undefined, + menuPath: data.menuPath, + }; + } else { + return; + } + + await onSave(saveData); + reset(); + onOpenChange(false); + }; + + const handleClose = () => { + reset(); + onOpenChange(false); + }; + + return ( + + + + {getTitle()} + {getDescription()} + + +
+
+ {/* Korean Name */} +
+ + + {errors.titleKo && ( +

{errors.titleKo.message}

+ )} +
+ + {/* English Name */} +
+ + +
+ + {/* Menu Path for Top-Level Menu */} + {type === "top_level_menu" && ( +
+ + + {errors.menuPath && ( +

{errors.menuPath.message}

+ )} +

+ e.g., /{domain}/report, /{domain}/faq +

+
+ )} +
+ + + + + +
+
+
+ ); +} diff --git a/lib/menu-v2/components/domain-tabs.tsx b/lib/menu-v2/components/domain-tabs.tsx new file mode 100644 index 00000000..e52fa80b --- /dev/null +++ b/lib/menu-v2/components/domain-tabs.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import type { MenuDomain } from "../types"; + +interface DomainTabsProps { + value: MenuDomain; + onChange: (domain: MenuDomain) => void; +} + +export function DomainTabs({ value, onChange }: DomainTabsProps) { + return ( + onChange(v as MenuDomain)}> + + + EVCP (Internal) + + + Partners (Vendors) + + + + ); +} + diff --git a/lib/menu-v2/components/edit-node-dialog.tsx b/lib/menu-v2/components/edit-node-dialog.tsx new file mode 100644 index 00000000..9631a611 --- /dev/null +++ b/lib/menu-v2/components/edit-node-dialog.tsx @@ -0,0 +1,215 @@ +"use client"; + +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Switch } from "@/components/ui/switch"; +import type { MenuTreeNode, UpdateNodeInput } from "../types"; + +interface EditNodeDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + node: MenuTreeNode | null; + onSave: (nodeId: number, data: UpdateNodeInput) => Promise; +} + +interface FormData { + titleKo: string; + titleEn: string; + descriptionKo: string; + descriptionEn: string; + scrId: string; + isActive: boolean; +} + +export function EditNodeDialog({ + open, + onOpenChange, + node, + onSave, +}: EditNodeDialogProps) { + const { + register, + handleSubmit, + reset, + setValue, + watch, + formState: { isSubmitting }, + } = useForm({ + defaultValues: { + titleKo: "", + titleEn: "", + descriptionKo: "", + descriptionEn: "", + scrId: "", + isActive: true, + }, + }); + + const isActive = watch("isActive"); + + useEffect(() => { + if (node) { + reset({ + titleKo: node.titleKo, + titleEn: node.titleEn || "", + descriptionKo: node.descriptionKo || "", + descriptionEn: node.descriptionEn || "", + scrId: node.scrId || "", + isActive: node.isActive, + }); + } + }, [node, reset]); + + const onSubmit = async (data: FormData) => { + if (!node) return; + + await onSave(node.id, { + titleKo: data.titleKo, + titleEn: data.titleEn || undefined, + descriptionKo: data.descriptionKo || undefined, + descriptionEn: data.descriptionEn || undefined, + scrId: data.scrId || undefined, + isActive: data.isActive, + }); + + onOpenChange(false); + }; + + const getTypeLabel = () => { + switch (node?.nodeType) { + case "menu_group": + return "Menu Group"; + case "group": + return "Group"; + case "menu": + return "Menu"; + case "additional": + return "Additional Menu"; + default: + return "Node"; + } + }; + + const showMenuFields = node?.nodeType === "menu" || node?.nodeType === "additional"; + + return ( + + + + Edit {getTypeLabel()} + + {node?.menuPath && ( + {node.menuPath} + )} + + + +
+
+ {/* Korean Name */} +
+ + +
+ + {/* English Name */} +
+ + +
+ + {/* Korean Description */} + {showMenuFields && ( +
+ +