diff options
Diffstat (limited to 'components/form-data')
| -rw-r--r-- | components/form-data/delete-form-data-dialog.tsx | 217 | ||||
| -rw-r--r-- | components/form-data/export-excel-form.tsx | 97 | ||||
| -rw-r--r-- | components/form-data/form-data-table-columns.tsx | 50 | ||||
| -rw-r--r-- | components/form-data/form-data-table.tsx | 265 | ||||
| -rw-r--r-- | components/form-data/import-excel-form.tsx | 49 | ||||
| -rw-r--r-- | components/form-data/update-form-sheet.tsx | 102 |
6 files changed, 577 insertions, 203 deletions
diff --git a/components/form-data/delete-form-data-dialog.tsx b/components/form-data/delete-form-data-dialog.tsx new file mode 100644 index 00000000..ca2f8729 --- /dev/null +++ b/components/form-data/delete-form-data-dialog.tsx @@ -0,0 +1,217 @@ +"use client" + +import * as React from "react" +import { Loader, Trash } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" + +import { deleteFormDataByTags } from "@/lib/forms/services" + +interface GenericData { + [key: string]: any + TAG_NO?: string +} + +interface DeleteFormDataDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + formData: GenericData[] + formCode: string + contractItemId: number + showTrigger?: boolean + onSuccess?: () => void + triggerVariant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" +} + +export function DeleteFormDataDialog({ + formData, + formCode, + contractItemId, + showTrigger = true, + onSuccess, + triggerVariant = "outline", + ...props +}: DeleteFormDataDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + // TAG_NO가 있는 항목들만 필터링 + const validItems = formData.filter(item => item.TAG_NO?.trim()) + const tagNos = validItems.map(item => item.TAG_NO).filter(Boolean) as string[] + + function onDelete() { + startDeleteTransition(async () => { + if (tagNos.length === 0) { + toast.error("No valid items to delete") + return + } + + const result = await deleteFormDataByTags({ + formCode, + contractItemId, + tagNos, + }) + + if (result.error) { + toast.error(result.error) + return + } + + props.onOpenChange?.(false) + + // 성공 메시지 (개수는 같을 것으로 예상) + const deletedCount = result.deletedCount || 0 + const deletedTagsCount = result.deletedTagsCount || 0 + + if (deletedCount !== deletedTagsCount) { + // 데이터 불일치 경고 + console.warn(`Data inconsistency: FormEntries deleted: ${deletedCount}, Tags deleted: ${deletedTagsCount}`) + toast.error( + `Deleted ${deletedCount} form entries and ${deletedTagsCount} tags (data inconsistency detected)` + ) + } else { + // 정상적인 삭제 완료 + toast.success( + `Successfully deleted ${deletedCount} item${deletedCount === 1 ? "" : "s"}` + ) + } + + onSuccess?.() + }) + } + + const itemCount = tagNos.length + const hasValidItems = itemCount > 0 + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button + variant={triggerVariant} + size="sm" + disabled={!hasValidItems} + > + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({itemCount}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>Are you absolutely sure?</DialogTitle> + <DialogDescription> + This action cannot be undone. This will permanently delete{" "} + <span className="font-medium">{itemCount}</span> + {itemCount === 1 ? " item" : " items"} and related tag records from the database. + {itemCount > 0 && ( + <> + <br /> + <br /> + <span className="text-sm text-muted-foreground"> + TAG_NO(s): {tagNos.slice(0, 3).join(", ")} + {tagNos.length > 3 && ` and ${tagNos.length - 3} more...`} + </span> + </> + )} + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">Cancel</Button> + </DialogClose> + <Button + aria-label="Delete selected entries" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending || !hasValidItems} + > + {isDeletePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Delete + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button + variant={triggerVariant} + size="sm" + disabled={!hasValidItems} + > + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({itemCount}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>Are you absolutely sure?</DrawerTitle> + <DrawerDescription> + This action cannot be undone. This will permanently delete{" "} + <span className="font-medium">{itemCount}</span> + {itemCount === 1 ? " item" : " items"} and related tag records from the database. + {itemCount > 0 && ( + <> + <br /> + <br /> + <span className="text-sm text-muted-foreground"> + TAG_NO(s): {tagNos.slice(0, 3).join(", ")} + {tagNos.length > 3 && ` and ${tagNos.length - 3} more...`} + </span> + </> + )} + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">Cancel</Button> + </DrawerClose> + <Button + aria-label="Delete selected entries" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending || !hasValidItems} + > + {isDeletePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + Delete + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +}
\ No newline at end of file diff --git a/components/form-data/export-excel-form.tsx b/components/form-data/export-excel-form.tsx index c4010df2..d0ccf980 100644 --- a/components/form-data/export-excel-form.tsx +++ b/components/form-data/export-excel-form.tsx @@ -12,6 +12,7 @@ export interface DataTableColumnJSON { label: string; type: ColumnType; options?: string[]; + shi?: boolean; // SHI-only field indicator // Add any other properties that might be in columnsJSON } @@ -98,29 +99,64 @@ export async function exportExcelData({ const headerRow = worksheet.getRow(1); headerRow.font = { bold: true }; headerRow.alignment = { horizontal: "center" }; - headerRow.eachCell((cell) => { - cell.fill = { - type: "pattern", - pattern: "solid", - fgColor: { argb: "FFCCCCCC" }, - }; + + // 각 헤더 셀에 스타일 적용 + headerRow.eachCell((cell, colNumber) => { + const columnIndex = colNumber - 1; + const column = columnsJSON[columnIndex]; + + if (column?.shi === true) { + // SHI-only 필드는 더 진한 음영으로 표시 (헤더 라벨은 원본 유지) + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFFF9999" }, // 연한 빨간색 배경 + }; + cell.font = { bold: true, color: { argb: "FF800000" } }; // 진한 빨간색 글자 + } else { + // 일반 필드는 기존 스타일 + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, // 연한 회색 배경 + }; + } }); // 3. 데이터 행 추가 - tableData.forEach((row) => { + tableData.forEach((rowData, rowIndex) => { const rowValues = columnsJSON.map((col) => { - const value = row[col.key]; + const value = rowData[col.key]; return value !== undefined && value !== null ? value : ""; }); - worksheet.addRow(rowValues); + const dataRow = worksheet.addRow(rowValues); + + // SHI-only 컬럼의 데이터 셀에도 음영 적용 + dataRow.eachCell((cell, colNumber) => { + const columnIndex = colNumber - 1; + const column = columnsJSON[columnIndex]; + + if (column?.shi === true) { + // SHI-only 필드의 데이터 셀에 연한 음영 적용 + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFFFCCCC" }, // 매우 연한 빨간색 배경 + }; + // 읽기 전용임을 나타내기 위해 이탤릭 적용 + cell.font = { italic: true, color: { argb: "FF666666" } }; + } + }); }); // 4. 데이터 유효성 검사 적용 const maxRows = 5000; // 데이터 유효성 검사를 적용할 최대 행 수 columnsJSON.forEach((col, idx) => { - if (col.type === "LIST" && validationRanges.has(col.key)) { - const colLetter = worksheet.getColumn(idx + 1).letter; + const colLetter = worksheet.getColumn(idx + 1).letter; + + // SHI-only 필드가 아닌 LIST 타입에만 유효성 검사 적용 + if (col.type === "LIST" && validationRanges.has(col.key) && col.shi !== true) { const validationRange = validationRanges.get(col.key)!; // 유효성 검사 정의 @@ -156,6 +192,19 @@ export async function exportExcelData({ } } } + + // SHI-only 필드의 빈 행들에도 음영 처리 적용 + if (col.shi === true) { + for (let rowIdx = tableData.length + 2; rowIdx <= maxRows; rowIdx++) { + const cell = worksheet.getCell(`${colLetter}${rowIdx}`); + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFFFCCCC" }, + }; + cell.font = { italic: true, color: { argb: "FF666666" } }; + } + } }); // 5. 컬럼 너비 자동 조정 @@ -178,7 +227,31 @@ export async function exportExcelData({ column.width = Math.min(Math.max(maxLength + 2, 10), 50); }); - // 6. 파일 다운로드 + // 6. 범례 추가 (별도 시트) + const legendSheet = workbook.addWorksheet("Legend"); + legendSheet.addRow(["Excel Template Legend"]); + legendSheet.addRow([]); + legendSheet.addRow(["Symbol", "Description"]); + legendSheet.addRow(["Red background header", "SHI-only fields that cannot be edited"]); + legendSheet.addRow(["Gray background header", "Regular editable fields"]); + legendSheet.addRow(["Light red background cells", "Data in SHI-only fields (read-only)"]); + legendSheet.addRow(["Red text color", "SHI-only field headers"]); + + // 범례 스타일 적용 + const legendHeaderRow = legendSheet.getRow(1); + legendHeaderRow.font = { bold: true, size: 14 }; + + const legendTableHeader = legendSheet.getRow(3); + legendTableHeader.font = { bold: true }; + legendTableHeader.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, + }; + }); + + // 7. 파일 다운로드 const buffer = await workbook.xlsx.writeBuffer(); saveAs( new Blob([buffer]), diff --git a/components/form-data/form-data-table-columns.tsx b/components/form-data/form-data-table-columns.tsx index b088276e..bba2a208 100644 --- a/components/form-data/form-data-table-columns.tsx +++ b/components/form-data/form-data-table-columns.tsx @@ -22,7 +22,7 @@ import { toast } from 'sonner'; /** row 액션 관련 타입 */ export interface DataTableRowAction<TData> { row: Row<TData>; - type: "open" | "edit" | "update"; + type: "open" | "edit" | "update" | "delete"; } /** 컬럼 타입 (필요에 따라 확장) */ @@ -57,7 +57,7 @@ interface GetColumnsProps<TData> { // 체크박스 선택 관련 props selectedRows?: Record<string, boolean>; onRowSelectionChange?: (updater: Record<string, boolean> | ((prev: Record<string, boolean>) => Record<string, boolean>)) => void; - editableFieldsMap?: Map<string, string[]>; // 새로 추가 + // editableFieldsMap 제거됨 } /** @@ -73,7 +73,7 @@ export function getColumns<TData extends object>({ tempCount, selectedRows = {}, onRowSelectionChange, - editableFieldsMap = new Map(), // 새로 추가 + // editableFieldsMap 매개변수 제거됨 }: GetColumnsProps<TData>): ColumnDef<TData>[] { const columns: ColumnDef<TData>[] = []; @@ -122,6 +122,8 @@ export function getColumns<TData extends object>({ ), enableSorting: false, enableHiding: false, + enablePinning: true, // ← 이 줄 추가 + size: 40, }; columns.push(selectColumn); @@ -147,38 +149,16 @@ export function getColumns<TData extends object>({ cell: ({ row }) => { const cellValue = row.getValue(col.key); - // 기본 읽기 전용 여부 (shi 속성 기반) - let isReadOnly = col.shi === true; - - // 동적 읽기 전용 여부 계산 - if (!isReadOnly && col.key !== 'TAG_NO' && col.key !== 'TAG_DESC') { - const tagNo = row.getValue('TAG_NO') as string; - if (tagNo && editableFieldsMap.has(tagNo)) { - const editableFields = editableFieldsMap.get(tagNo) || []; - // 해당 TAG의 편집 가능 필드 목록에 없으면 읽기 전용 - isReadOnly = !editableFields.includes(col.key); - } else { - // TAG_NO가 없거나 editableFieldsMap에 없으면 읽기 전용 - isReadOnly = true; - } - } + // SHI 필드만 읽기 전용으로 처리 + const isReadOnly = col.shi === true; const readOnlyClass = isReadOnly ? "read-only-cell" : ""; const cellStyle = isReadOnly ? { backgroundColor: '#f5f5f5', color: '#666', cursor: 'not-allowed' } : {}; - // 툴팁 메시지 설정 - let tooltipMessage = ""; - if (isReadOnly) { - if (col.shi === true) { - tooltipMessage = "SHI 전용 필드입니다"; - } else if (col.key === 'TAG_NO' || col.key === 'TAG_DESC') { - tooltipMessage = "기본 필드는 수정할 수 없습니다"; - } else { - tooltipMessage = "이 TAG 클래스에서는 편집할 수 없는 필드입니다"; - } - } + // 툴팁 메시지 설정 (SHI 필드만) + const tooltipMessage = isReadOnly ? "SHI 전용 필드입니다" : ""; // 데이터 타입별 처리 switch (col.type) { @@ -220,6 +200,7 @@ export function getColumns<TData extends object>({ })); columns.push(...baseColumns); + // (4) 액션 칼럼 - update 버튼 예시 const actionColumn: ColumnDef<TData> = { id: "update", @@ -255,10 +236,19 @@ export function getColumns<TData extends object>({ > Create Document </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem + onSelect={() => { + setRowAction({ row, type: "delete" }); + }} + className="text-red-600 focus:text-red-600" + > + Delete + </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> ), - minSize: 50, + size: 40, enablePinning: true, }; diff --git a/components/form-data/form-data-table.tsx b/components/form-data/form-data-table.tsx index 6de6dd0b..9a438957 100644 --- a/components/form-data/form-data-table.tsx +++ b/components/form-data/form-data-table.tsx @@ -13,21 +13,22 @@ import { } from "./form-data-table-columns"; import type { DataTableAdvancedFilterField } from "@/types/table"; import { Button } from "../ui/button"; -import { - Download, - Loader, - Save, - Upload, - Plus, - Tag, - TagsIcon, - FileText, +import { + Download, + Loader, + Save, + Upload, + Plus, + Tag, + TagsIcon, + FileText, FileSpreadsheet, FileOutput, - Clipboard, + Clipboard, Send, GitCompareIcon, - RefreshCcw + RefreshCcw, + Trash2 } from "lucide-react"; import { toast } from "sonner"; import { @@ -54,16 +55,17 @@ import { exportExcelData } from "./export-excel-form"; import { SEDPConfirmationDialog, SEDPStatusDialog } from "./sedp-components"; import { SEDPCompareDialog } from "./sedp-compare-dialog"; import { getSEDPToken } from "@/lib/sedp/sedp-token"; +import { DeleteFormDataDialog } from "./delete-form-data-dialog"; // 새로 추가 async function fetchTagDataFromSEDP(projectCode: string, formCode: string): Promise<any> { try { // Get the token const apiKey = await getSEDPToken(); - + // Define the API base URL const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api'; - + // Make the API call const response = await fetch( `${SEDP_API_BASE_URL}/Data/GetPubData`, @@ -82,12 +84,12 @@ async function fetchTagDataFromSEDP(projectCode: string, formCode: string): Prom }) } ); - + if (!response.ok) { const errorText = await response.text(); throw new Error(`SEDP API request failed: ${response.status} ${response.statusText} - ${errorText}`); } - + const data = await response.json(); return data; } catch (error: any) { @@ -119,7 +121,7 @@ export default function DynamicTable({ contractItemId, formCode, formId, - projectId, + projectId, mode = "IM", // 기본값 설정 formName = `${formCode}`, // Default form name based on formCode editableFieldsMap = new Map(), // 새로 추가 @@ -134,19 +136,21 @@ export default function DynamicTable({ const [tableData, setTableData] = React.useState<GenericData[]>(dataJSON); // 배치 선택 관련 상태 - const [selectedRows, setSelectedRows] = React.useState<Record<string, boolean>>({}); + const [selectedRowsData, setSelectedRowsData] = React.useState<GenericData[]>([]); + const [clearSelection, setClearSelection] = React.useState(false); + // 삭제 관련 상태 간소화 + const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false); + const [deleteTarget, setDeleteTarget] = React.useState<GenericData[]>([]); // Update tableData when dataJSON changes React.useEffect(() => { setTableData(dataJSON); - // 데이터가 변경되면 선택 상태 초기화 - setSelectedRows({}); }, [dataJSON]); - + // 폴링 상태 관리를 위한 ref const pollingRef = React.useRef<NodeJS.Timeout | null>(null); const [syncId, setSyncId] = React.useState<string | null>(null); - + // Separate loading states for different operations const [isSyncingTags, setIsSyncingTags] = React.useState(false); const [isImporting, setIsImporting] = React.useState(false); @@ -154,10 +158,10 @@ export default function DynamicTable({ const [isSaving, setIsSaving] = React.useState(false); const [isSendingSEDP, setIsSendingSEDP] = React.useState(false); const [isLoadingTags, setIsLoadingTags] = React.useState(false); - + // Any operation in progress const isAnyOperationPending = isSyncingTags || isImporting || isExporting || isSaving || isSendingSEDP || isLoadingTags; - + // SEDP dialogs state const [sedpConfirmOpen, setSedpConfirmOpen] = React.useState(false); const [sedpStatusOpen, setSedpStatusOpen] = React.useState(false); @@ -168,11 +172,11 @@ export default function DynamicTable({ errorCount: 0, totalCount: 0 }); - + // SEDP compare dialog state const [sedpCompareOpen, setSedpCompareOpen] = React.useState(false); const [projectCode, setProjectCode] = React.useState<string>(''); - + const [tempUpDialog, setTempUpDialog] = React.useState(false); const [reportData, setReportData] = React.useState<GenericData[]>([]); const [batchDownDialog, setBatchDownDialog] = React.useState(false); @@ -216,26 +220,22 @@ export default function DynamicTable({ // 선택된 행들의 실제 데이터 가져오기 const getSelectedRowsData = React.useCallback(() => { - const selectedIndices = Object.keys(selectedRows).filter(key => selectedRows[key]); - return selectedIndices.map(index => tableData[parseInt(index)]).filter(Boolean); - }, [selectedRows, tableData]); + return selectedRowsData; + }, [selectedRowsData]); // 선택된 행 개수 계산 const selectedRowCount = React.useMemo(() => { - return Object.values(selectedRows).filter(Boolean).length; - }, [selectedRows]); + return selectedRowsData.length; + }, [selectedRowsData]); const columns = React.useMemo( - () => getColumns<GenericData>({ - columnsJSON, - setRowAction, - setReportData, + () => getColumns<GenericData>({ + columnsJSON, + setRowAction, + setReportData, tempCount, - selectedRows, - onRowSelectionChange: setSelectedRows, - editableFieldsMap }), - [columnsJSON, setRowAction, setReportData, tempCount, selectedRows] + [columnsJSON, setRowAction, setReportData, tempCount] ); function mapColumnTypeToAdvancedFilterType( @@ -297,30 +297,30 @@ export default function DynamicTable({ setIsSyncingTags(false); } } - + // ENG 모드: 태그 가져오기 함수 const handleGetTags = async () => { try { setIsLoadingTags(true); - + // API 엔드포인트 호출 - 작업 시작만 요청 const response = await fetch('/api/cron/form-tags/start', { method: 'POST', - body: JSON.stringify({ projectCode ,formCode ,contractItemId }) + body: JSON.stringify({ projectCode, formCode, contractItemId }) }); - + if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || 'Failed to start tag import'); } - + const data = await response.json(); - + // 작업 ID 저장 if (data.syncId) { setSyncId(data.syncId); toast.info('Tag import started. This may take a while...'); - + // 상태 확인을 위한 폴링 시작 startPolling(data.syncId); } else { @@ -336,49 +336,49 @@ export default function DynamicTable({ setIsLoadingTags(false); } }; - + const startPolling = (id: string) => { // 이전 폴링이 있다면 제거 if (pollingRef.current) { clearInterval(pollingRef.current); } - + // 5초마다 상태 확인 pollingRef.current = setInterval(async () => { try { const response = await fetch(`/api/cron/form-tags/status?id=${id}`); - + if (!response.ok) { throw new Error('Failed to get tag import status'); } - + const data = await response.json(); - + if (data.status === 'completed') { // 폴링 중지 if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; } - + router.refresh(); - + // 상태 초기화 setIsLoadingTags(false); setSyncId(null); - + // 성공 메시지 표시 toast.success( `Tags imported successfully! ${data.result?.processedCount || 0} items processed.` ); - + } else if (data.status === 'failed') { // 에러 처리 if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; } - + setIsLoadingTags(false); setSyncId(null); toast.error(data.error || 'Import failed'); @@ -395,15 +395,16 @@ export default function DynamicTable({ } }, 5000); // 5초마다 체크 }; - - // Excel Import - Modified to directly save to DB + + // Excel Import - Fixed version with proper loading state management async function handleImportExcel(e: React.ChangeEvent<HTMLInputElement>) { const file = e.target.files?.[0]; if (!file) return; - + try { - setIsImporting(true); - + // Don't set setIsImporting here - let importExcelData handle it completely + // setIsImporting(true); // Remove this line + // Call the updated importExcelData function with editableFieldsMap const result = await importExcelData({ file, @@ -411,28 +412,37 @@ export default function DynamicTable({ columnsJSON, formCode, contractItemId, - editableFieldsMap, // 추가: 편집 가능 필드 정보 전달 - onPendingChange: setIsImporting, + // editableFieldsMap, // 추가: 편집 가능 필드 정보 전달 + onPendingChange: setIsImporting, // Let importExcelData handle loading state onDataUpdate: (newData) => { setTableData(Array.isArray(newData) ? newData : newData(tableData)); } }); - + // If import and save was successful, refresh the page if (result.success) { // Show additional info about skipped fields if any if (result.skippedFields && result.skippedFields.length > 0) { console.log("Import completed with some fields skipped:", result.skippedFields); } - router.refresh(); + + // Ensure loading state is cleared before refresh + setIsImporting(false); + + // Add a small delay to ensure state update is processed + setTimeout(() => { + router.refresh(); + }, 100); } } catch (error) { console.error("Import failed:", error); toast.error("Failed to import Excel data"); + // Ensure loading state is cleared on error + setIsImporting(false); } finally { // Always clear the file input value e.target.value = ""; - setIsImporting(false); + // Don't set setIsImporting(false) here since we handle it above } } // SEDP Send handler (with confirmation) @@ -441,23 +451,23 @@ export default function DynamicTable({ toast.error("No data to send to SEDP"); return; } - + // Open confirmation dialog setSedpConfirmOpen(true); } - + // Handle SEDP compare button click function handleSEDPCompareClick() { if (tableData.length === 0) { toast.error("No data to compare with SEDP"); return; } - + if (!projectCode) { toast.error("Project code is not available"); return; } - + // Open compare dialog setSedpCompareOpen(true); } @@ -466,7 +476,7 @@ export default function DynamicTable({ async function handleSEDPSendConfirmed() { try { setIsSendingSEDP(true); - + // Validate data const invalidData = tableData.filter((item) => !item.TAG_NO?.trim()); if (invalidData.length > 0) { @@ -479,13 +489,14 @@ export default function DynamicTable({ const sedpResult = await sendFormDataToSEDP( formCode, // Send formCode instead of formName projectId, // Project ID + contractItemId, tableData, // Table data columnsJSON // Column definitions ); // Close confirmation dialog setSedpConfirmOpen(false); - + // Set status data based on result if (sedpResult.success) { setSedpStatusData({ @@ -504,16 +515,16 @@ export default function DynamicTable({ totalCount: tableData.length }); } - + // Open status dialog to show result setSedpStatusOpen(true); - + // Refresh the route to get fresh data router.refresh(); - + } catch (err: any) { console.error("SEDP error:", err); - + // Set error status setSedpStatusData({ status: 'error', @@ -522,16 +533,16 @@ export default function DynamicTable({ errorCount: tableData.length, totalCount: tableData.length }); - + // Close confirmation and open status setSedpConfirmOpen(false); setSedpStatusOpen(true); - + } finally { setIsSendingSEDP(false); } } - + // Template Export async function handleExportExcel() { try { @@ -561,24 +572,76 @@ export default function DynamicTable({ } else { toast.info(`전체 ${tableData.length}개 항목으로 배치 문서를 생성합니다.`); } - + setBatchDownDialog(true); }; + // 개별 행 삭제 핸들러 + const handleDeleteRow = (rowData: GenericData) => { + setDeleteTarget([rowData]); + setDeleteDialogOpen(true); + }; + + // 배치 삭제 핸들러 + const handleBatchDelete = () => { + const selectedData = getSelectedRowsData(); + if (selectedData.length === 0) { + toast.error("삭제할 항목을 선택해주세요."); + return; + } + + setDeleteTarget(selectedData); + setDeleteDialogOpen(true); + }; + + // 삭제 성공 후 처리 + const handleDeleteSuccess = () => { + // 로컬 상태에서 삭제된 항목들 제거 + const tagNosToDelete = deleteTarget + .map(item => item.TAG_NO) + .filter(Boolean); + + setTableData(prev => + prev.filter(item => !tagNosToDelete.includes(item.TAG_NO)) + ); + + // 선택 상태 초기화 + setSelectedRowsData([]); + setClearSelection(prev => !prev); // ClientDataTable의 선택 상태 초기화 + + // 삭제 타겟 초기화 + setDeleteTarget([]); + }; + + // rowAction 처리 부분 수정 + React.useEffect(() => { + if (rowAction?.type === "delete") { + handleDeleteRow(rowAction.row.original); + setRowAction(null); // 액션 초기화 + } + }, [rowAction]); + + return ( <> <ClientDataTable data={tableData} columns={columns} advancedFilterFields={advancedFilterFields} + autoSizeColumns + onSelectedRowsChange={setSelectedRowsData} + clearSelection={clearSelection} > {/* 선택된 항목 수 표시 (선택된 항목이 있을 때만) */} {selectedRowCount > 0 && ( - <div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-md"> - <p className="text-sm text-blue-700"> - {selectedRowCount}개 항목이 선택되었습니다. 배치 문서는 선택된 항목만으로 생성됩니다. - </p> - </div> + <Button + variant="destructive" + size="sm" + onClick={handleBatchDelete} + > + <Trash2 className="mr-2 size-4" /> + Delete ({selectedRowCount}) + </Button> )} {/* 버튼 그룹 */} @@ -613,7 +676,7 @@ export default function DynamicTable({ </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> - + {/* 리포트 관리 드롭다운 */} <DropdownMenu> <DropdownMenuTrigger asChild> @@ -707,6 +770,7 @@ export default function DynamicTable({ </ClientDataTable> {/* Modal dialog for tag update */} + {/* Modal dialog for tag update */} <UpdateTagSheet open={rowAction?.type === "update"} onOpenChange={(open) => { @@ -716,21 +780,36 @@ export default function DynamicTable({ rowData={rowAction?.row.original ?? null} formCode={formCode} contractItemId={contractItemId} + editableFieldsMap={editableFieldsMap} // 새로 추가 onUpdateSuccess={(updatedValues) => { // Update the specific row in tableData when a single row is updated if (rowAction?.row.original?.TAG_NO) { const tagNo = rowAction.row.original.TAG_NO; - setTableData(prev => - prev.map(item => + setTableData(prev => + prev.map(item => item.TAG_NO === tagNo ? updatedValues : item ) ); } }} /> - + <DeleteFormDataDialog + formData={deleteTarget} + formCode={formCode} + contractItemId={contractItemId} + open={deleteDialogOpen} + onOpenChange={(open) => { + if (!open) { + setDeleteDialogOpen(false); + setDeleteTarget([]); + } + }} + onSuccess={handleDeleteSuccess} + showTrigger={false} + /> + {/* Dialog for adding tags */} - <AddFormTagDialog + <AddFormTagDialog projectId={projectId} formCode={formCode} formName={`Form ${formCode}`} @@ -738,7 +817,7 @@ export default function DynamicTable({ open={addTagDialogOpen} onOpenChange={setAddTagDialogOpen} /> - + {/* SEDP Confirmation Dialog */} <SEDPConfirmationDialog isOpen={sedpConfirmOpen} @@ -748,7 +827,7 @@ export default function DynamicTable({ tagCount={tableData.length} isLoading={isSendingSEDP} /> - + {/* SEDP Status Dialog */} <SEDPStatusDialog isOpen={sedpStatusOpen} @@ -759,7 +838,7 @@ export default function DynamicTable({ errorCount={sedpStatusData.errorCount} totalCount={sedpStatusData.totalCount} /> - + {/* SEDP Compare Dialog */} <SEDPCompareDialog isOpen={sedpCompareOpen} @@ -770,7 +849,7 @@ export default function DynamicTable({ formCode={formCode} fetchTagDataFromSEDP={fetchTagDataFromSEDP} /> - + {/* Other dialogs */} {tempUpDialog && ( <FormDataReportTempUploadDialog diff --git a/components/form-data/import-excel-form.tsx b/components/form-data/import-excel-form.tsx index f32e44d8..6f0828b0 100644 --- a/components/form-data/import-excel-form.tsx +++ b/components/form-data/import-excel-form.tsx @@ -5,14 +5,13 @@ import { DataTableColumnJSON } from "./form-data-table-columns"; import { updateFormDataInDB } from "@/lib/forms/services"; import { decryptWithServerAction } from "../drm/drmUtils"; -// Enhanced options interface with editableFieldsMap +// Simplified options interface without editableFieldsMap export interface ImportExcelOptions { file: File; tableData: GenericData[]; columnsJSON: DataTableColumnJSON[]; formCode?: string; contractItemId?: number; - editableFieldsMap?: Map<string, string[]>; // 새로 추가 onPendingChange?: (isPending: boolean) => void; onDataUpdate?: (updater: ((prev: GenericData[]) => GenericData[]) | GenericData[]) => void; } @@ -42,7 +41,6 @@ export async function importExcelData({ columnsJSON, formCode, contractItemId, - editableFieldsMap = new Map(), // 기본값으로 빈 Map onPendingChange, onDataUpdate }: ImportExcelOptions): Promise<ImportExcelResult> { @@ -141,47 +139,18 @@ export async function importExcelData({ const rowObj: Record<string, any> = {}; const skippedFields: string[] = []; // 현재 행에서 건너뛴 필드들 - // Get the TAG_NO first to identify existing data and editable fields + // Get the TAG_NO first to identify existing data const tagNoColIndex = keyToIndexMap.get("TAG_NO"); const tagNo = tagNoColIndex ? String(rowValues[tagNoColIndex] ?? "").trim() : ""; const existingRowData = existingDataMap.get(tagNo); - - // Get editable fields for this specific TAG - const editableFields = editableFieldsMap.has(tagNo) ? editableFieldsMap.get(tagNo)! : []; // Process each column columnsJSON.forEach((col) => { const colIndex = keyToIndexMap.get(col.key); if (colIndex === undefined) return; - // Determine if this field is editable - let isFieldEditable = true; - let skipReason = ""; - - // 1. Check if this is a SHI-only field + // Check if this is a SHI-only field (skip processing but preserve existing value) if (col.shi === true) { - isFieldEditable = false; - skipReason = "SHI-only field"; - } - // 2. Check if this field is editable based on TAG class attributes - else if (col.key !== "TAG_NO" && col.key !== "TAG_DESC") { - // For non-basic fields, check if they're in the editable list - if (tagNo && editableFieldsMap.has(tagNo)) { - if (!editableFields.includes(col.key)) { - isFieldEditable = false; - skipReason = "Not editable for this TAG class"; - } - } else if (tagNo) { - // If TAG exists but no editable fields info, treat as not editable - isFieldEditable = false; - skipReason = "No editable fields info for this TAG"; - } - } - // 3. TAG_NO and TAG_DESC are always considered basic fields - // (They should be editable, but you might want to add specific logic here) - - // If field is not editable, use existing value or default - if (!isFieldEditable) { if (existingRowData && existingRowData[col.key] !== undefined) { rowObj[col.key] = existingRowData[col.key]; } else { @@ -199,7 +168,7 @@ export async function importExcelData({ } // Log skipped field - skippedFields.push(`${col.label} (${skipReason})`); + skippedFields.push(`${col.label} (SHI-only field)`); return; // Skip processing Excel value for this column } @@ -254,7 +223,7 @@ export async function importExcelData({ tagNo: tagNo, fields: skippedFields }); - warningMessage += `Skipped ${skippedFields.length} non-editable fields. `; + warningMessage += `Skipped ${skippedFields.length} SHI-only fields. `; } // Validate TAG_NO @@ -283,7 +252,7 @@ export async function importExcelData({ const totalSkippedFields = skippedFieldsLog.reduce((sum, log) => sum + log.fields.length, 0); console.log("Skipped fields summary:", skippedFieldsLog); toast.info( - `${totalSkippedFields} non-editable fields were skipped across ${skippedFieldsLog.length} rows. Check console for details.` + `${totalSkippedFields} SHI-only fields were skipped across ${skippedFieldsLog.length} rows. Check console for details.` ); } @@ -391,7 +360,7 @@ export async function importExcelData({ } const successMessage = skippedFieldsLog.length > 0 - ? `Successfully updated ${successCount} rows (some non-editable fields were preserved)` + ? `Successfully updated ${successCount} rows (SHI-only fields were preserved)` : `Successfully updated ${successCount} rows`; toast.success(successMessage); @@ -417,7 +386,7 @@ export async function importExcelData({ } const successMessage = skippedFieldsLog.length > 0 - ? `Imported ${importedData.length} rows successfully (some fields preserved)` + ? `Imported ${importedData.length} rows successfully (SHI-only fields preserved)` : `Imported ${importedData.length} rows successfully`; toast.success(`${successMessage} (local only)`); @@ -435,4 +404,4 @@ export async function importExcelData({ } finally { if (onPendingChange) onPendingChange(false); } -} +}
\ No newline at end of file diff --git a/components/form-data/update-form-sheet.tsx b/components/form-data/update-form-sheet.tsx index 6f2a4722..c8772e2a 100644 --- a/components/form-data/update-form-sheet.tsx +++ b/components/form-data/update-form-sheet.tsx @@ -54,6 +54,7 @@ interface UpdateTagSheetProps rowData: Record<string, any> | null; formCode: string; contractItemId: number; + editableFieldsMap?: Map<string, string[]>; // 새로 추가 /** 업데이트 성공 시 호출될 콜백 */ onUpdateSuccess?: (updatedValues: Record<string, any>) => void; } @@ -65,12 +66,66 @@ export function UpdateTagSheet({ rowData, formCode, contractItemId, + editableFieldsMap = new Map(), // 기본값 설정 onUpdateSuccess, ...props }: UpdateTagSheetProps) { const [isPending, startTransition] = React.useTransition(); const router = useRouter(); + // 현재 TAG의 편집 가능한 필드 목록 가져오기 + const editableFields = React.useMemo(() => { + if (!rowData?.TAG_NO || !editableFieldsMap.has(rowData.TAG_NO)) { + return []; + } + return editableFieldsMap.get(rowData.TAG_NO) || []; + }, [rowData?.TAG_NO, editableFieldsMap]); + + // 필드가 편집 가능한지 판별하는 함수 + const isFieldEditable = React.useCallback((column: DataTableColumnJSON) => { + // 1. SHI-only 필드는 편집 불가 + if (column.shi === true) { + return false; + } + + // 2. TAG_NO와 TAG_DESC는 기본적으로 편집 가능 (필요에 따라 수정 가능) + if (column.key === "TAG_NO" || column.key === "TAG_DESC") { + return true; + } + + // 3. editableFieldsMap이 있으면 해당 리스트에 있는지 확인 + if (rowData?.TAG_NO && editableFieldsMap.has(rowData.TAG_NO)) { + return editableFields.includes(column.key); + } + + // 4. editableFieldsMap 정보가 없으면 기본적으로 편집 불가 (안전한 기본값) + return false; + }, [rowData?.TAG_NO, editableFieldsMap, editableFields]); + + // 읽기 전용 필드인지 판별하는 함수 (편집 가능의 반대) + const isFieldReadOnly = React.useCallback((column: DataTableColumnJSON) => { + return !isFieldEditable(column); + }, [isFieldEditable]); + + // 읽기 전용 사유를 반환하는 함수 + const getReadOnlyReason = React.useCallback((column: DataTableColumnJSON) => { + if (column.shi === true) { + return "SHI-only field (managed by SHI system)"; + } + + if (column.key !== "TAG_NO" && column.key !== "TAG_DESC") { + if (!rowData?.TAG_NO || !editableFieldsMap.has(rowData.TAG_NO)) { + return "No editable fields information for this TAG"; + } + + if (!editableFields.includes(column.key)) { + return "Not editable for this TAG class"; + } + } + + return "Read-only field"; + }, [rowData?.TAG_NO, editableFieldsMap, editableFields]); + // 1) zod 스키마 const dynamicSchema = React.useMemo(() => { const shape: Record<string, z.ZodType<any>> = {}; @@ -118,7 +173,7 @@ export function UpdateTagSheet({ // 제출 전에 읽기 전용 필드를 원본 값으로 복원 const finalValues = { ...values }; for (const col of columns) { - if (col.shi || col.key === "TAG_NO" || col.key === "TAG_DESC") { + if (isFieldReadOnly(col)) { // 읽기 전용 필드는 원본 값으로 복원 finalValues[col.key] = rowData?.[col.key] ?? ""; } @@ -161,13 +216,22 @@ export function UpdateTagSheet({ }); } + // 편집 가능한 필드 개수 계산 + const editableFieldCount = React.useMemo(() => { + return columns.filter(col => isFieldEditable(col)).length; + }, [columns, isFieldEditable]); + return ( <Sheet open={open} onOpenChange={onOpenChange} {...props}> <SheetContent className="sm:max-w-xl md:max-w-3xl lg:max-w-4xl xl:max-w-5xl flex flex-col"> <SheetHeader className="text-left"> - <SheetTitle>Update Row</SheetTitle> + <SheetTitle>Update Row - {rowData?.TAG_NO || 'Unknown TAG'}</SheetTitle> <SheetDescription> Modify the fields below and save changes. Fields with <LockIcon className="inline h-3 w-3" /> are read-only. + <br /> + <span className="text-sm text-green-600"> + {editableFieldCount} of {columns.length} fields are editable for this TAG. + </span> </SheetDescription> </SheetHeader> @@ -179,10 +243,8 @@ export function UpdateTagSheet({ <div className="overflow-y-auto max-h-[80vh] flex-1 pr-4 -mr-4"> <div className="flex flex-col gap-4 pt-2"> {columns.map((col) => { - // 읽기 전용 조건 업데이트: shi가 true이거나 TAG_NO/TAG_DESC인 경우 - const isReadOnly = col.shi === true || - col.key === "TAG_NO" || - col.key === "TAG_DESC"; + const isReadOnly = isFieldReadOnly(col); + const readOnlyReason = isReadOnly ? getReadOnlyReason(col) : ""; return ( <FormField @@ -214,9 +276,9 @@ export function UpdateTagSheet({ )} /> </FormControl> - {isReadOnly && col.shi && ( + {isReadOnly && ( <FormDescription className="text-xs text-gray-500"> - This field is read-only + {readOnlyReason} </FormDescription> )} <FormMessage /> @@ -278,31 +340,15 @@ export function UpdateTagSheet({ </Command> </PopoverContent> </Popover> - {isReadOnly && col.shi && ( + {isReadOnly && ( <FormDescription className="text-xs text-gray-500"> - This field is read-only + {readOnlyReason} </FormDescription> )} <FormMessage /> </FormItem> ); - // case "date": - // return ( - // <FormItem> - // <FormLabel>{col.label}</FormLabel> - // <FormControl> - // <Input - // type="date" - // readOnly={isReadOnly} - // onChange={field.onChange} - // value={field.value ?? ""} - // /> - // </FormControl> - // <FormMessage /> - // </FormItem> - // ) - case "STRING": default: return ( @@ -322,9 +368,9 @@ export function UpdateTagSheet({ )} /> </FormControl> - {isReadOnly && col.shi && ( + {isReadOnly && ( <FormDescription className="text-xs text-gray-500"> - This field is read-only + {readOnlyReason} </FormDescription> )} <FormMessage /> |
