summaryrefslogtreecommitdiff
path: root/components/form-data
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-06-01 13:52:21 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-06-01 13:52:21 +0000
commitbac0228d21b7195065e9cddcc327ae33659c7bcc (patch)
tree8f3016ae4533c8706d0c00a605d9b1d41968c2bc /components/form-data
parent2fdce8d7a57c792bba0ac36fa554dca9c9cc31e3 (diff)
(대표님) 20250601까지 작업사항
Diffstat (limited to 'components/form-data')
-rw-r--r--components/form-data/delete-form-data-dialog.tsx217
-rw-r--r--components/form-data/export-excel-form.tsx97
-rw-r--r--components/form-data/form-data-table-columns.tsx50
-rw-r--r--components/form-data/form-data-table.tsx265
-rw-r--r--components/form-data/import-excel-form.tsx49
-rw-r--r--components/form-data/update-form-sheet.tsx102
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 />