From f7f5069a2209cfa39b65f492f32270a5f554bed0 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Thu, 23 Oct 2025 10:10:21 +0000 Subject: (대표님) EDP 해양 관련 개발 사항들 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/form-data-plant/add-formTag-dialog.tsx | 985 +++++++++++ .../form-data-plant/delete-form-data-dialog.tsx | 228 +++ components/form-data-plant/export-excel-form.tsx | 674 ++++++++ .../form-data-report-batch-dialog.tsx | 444 +++++ .../form-data-plant/form-data-report-dialog.tsx | 415 +++++ .../form-data-report-temp-upload-dialog.tsx | 101 ++ .../form-data-report-temp-upload-tab.tsx | 243 +++ .../form-data-report-temp-uploaded-list-tab.tsx | 218 +++ .../form-data-plant/form-data-table-columns.tsx | 546 ++++++ components/form-data-plant/form-data-table.tsx | 1377 ++++++++++++++++ components/form-data-plant/import-excel-form.tsx | 669 ++++++++ components/form-data-plant/publish-dialog.tsx | 470 ++++++ components/form-data-plant/sedp-compare-dialog.tsx | 618 +++++++ components/form-data-plant/sedp-components.tsx | 193 +++ components/form-data-plant/sedp-excel-download.tsx | 259 +++ components/form-data-plant/spreadJS-dialog.tsx | 1733 ++++++++++++++++++++ .../form-data-plant/spreadJS-dialog_designer.tsx | 1404 ++++++++++++++++ components/form-data-plant/update-form-sheet.tsx | 445 +++++ .../form-data-plant/var-list-download-btn.tsx | 122 ++ 19 files changed, 11144 insertions(+) create mode 100644 components/form-data-plant/add-formTag-dialog.tsx create mode 100644 components/form-data-plant/delete-form-data-dialog.tsx create mode 100644 components/form-data-plant/export-excel-form.tsx create mode 100644 components/form-data-plant/form-data-report-batch-dialog.tsx create mode 100644 components/form-data-plant/form-data-report-dialog.tsx create mode 100644 components/form-data-plant/form-data-report-temp-upload-dialog.tsx create mode 100644 components/form-data-plant/form-data-report-temp-upload-tab.tsx create mode 100644 components/form-data-plant/form-data-report-temp-uploaded-list-tab.tsx create mode 100644 components/form-data-plant/form-data-table-columns.tsx create mode 100644 components/form-data-plant/form-data-table.tsx create mode 100644 components/form-data-plant/import-excel-form.tsx create mode 100644 components/form-data-plant/publish-dialog.tsx create mode 100644 components/form-data-plant/sedp-compare-dialog.tsx create mode 100644 components/form-data-plant/sedp-components.tsx create mode 100644 components/form-data-plant/sedp-excel-download.tsx create mode 100644 components/form-data-plant/spreadJS-dialog.tsx create mode 100644 components/form-data-plant/spreadJS-dialog_designer.tsx create mode 100644 components/form-data-plant/update-form-sheet.tsx create mode 100644 components/form-data-plant/var-list-download-btn.tsx (limited to 'components/form-data-plant') diff --git a/components/form-data-plant/add-formTag-dialog.tsx b/components/form-data-plant/add-formTag-dialog.tsx new file mode 100644 index 00000000..05043ca8 --- /dev/null +++ b/components/form-data-plant/add-formTag-dialog.tsx @@ -0,0 +1,985 @@ +"use client" + +import * as React from "react" +import { useParams, useRouter } from "next/navigation"; +import { useForm, useFieldArray } from "react-hook-form" +import { toast } from "sonner" +import { Loader2, ChevronsUpDown, Check, Plus, Trash2, Copy } from "lucide-react" + +import { + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Form, + FormField, + FormItem, + FormControl, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ui/popover" +import { + Command, + CommandInput, + CommandList, + CommandGroup, + CommandItem, + CommandEmpty, +} from "@/components/ui/command" +import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" +import { cn } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" + +import { createTagInForm } from "@/lib/tags/service" +import { + getFormTagTypeMappings, + getTagTypeByDescription, + getSubfieldsByTagTypeForForm +} from "@/lib/forms-plant/services" +import { useTranslation } from "@/i18n/client"; + +// Form-specific tag mapping interface +interface FormTagMapping { + id: number; + tagTypeLabel: string; + classLabel: string; + formCode: string; + formName: string; + remark?: string | null; +} + +// Updated to support multiple rows +interface MultiTagFormValues { + class: string; + tagType: string; + rows: Array<{ + [key: string]: string; + tagNo: string; + description: string; + }>; +} + +// SubFieldDef for clarity +interface SubFieldDef { + name: string; + label: string; + type: string; + options?: { value: string; label: string }[]; + expression?: string; + delimiter?: string; +} + +interface AddFormTagDialogProps { + projectId: number; + formCode: string; + formName?: string; + contractItemId: number; + packageCode: string; + open?: boolean; + onOpenChange?: (open: boolean) => void; +} + +export function AddFormTagDialog({ + projectId, + formCode, + formName, + contractItemId, + packageCode, + open: externalOpen, + onOpenChange: externalOnOpenChange +}: AddFormTagDialogProps) { + const router = useRouter() + const params = useParams(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "engineering"); + + // Use external control if provided, otherwise use internal state + const [internalOpen, setInternalOpen] = React.useState(false); + const isOpen = externalOpen !== undefined ? externalOpen : internalOpen; + const setIsOpen = externalOnOpenChange || setInternalOpen; + + const [mappings, setMappings] = React.useState([]) + const [selectedTagTypeLabel, setSelectedTagTypeLabel] = React.useState(null) + const [selectedTagTypeCode, setSelectedTagTypeCode] = React.useState(null) + const [subFields, setSubFields] = React.useState([]) + const [isLoadingClasses, setIsLoadingClasses] = React.useState(false) + const [isLoadingSubFields, setIsLoadingSubFields] = React.useState(false) + const [isSubmitting, setIsSubmitting] = React.useState(false) + + // ID management for React keys + const selectIdRef = React.useRef(0) + const fieldIdsRef = React.useRef>({}) + const classOptionIdsRef = React.useRef>({}) + + // --------------- + // Load Form Tag Mappings + // --------------- + React.useEffect(() => { + const loadMappings = async () => { + if (!formCode || !projectId) return; + + setIsLoadingClasses(true); + try { + const result = await getFormTagTypeMappings(formCode, projectId); + // Type safety casting + const typedMappings: FormTagMapping[] = result.map(item => ({ + id: item.id, + tagTypeLabel: item.tagTypeLabel, + classLabel: item.classLabel, + formCode: item.formCode, + formName: item.formName, + remark: item.remark + })); + setMappings(typedMappings); + } catch (err) { + toast.error("폼 태그 매핑 로드에 실패했습니다."); + } finally { + setIsLoadingClasses(false); + } + }; + + loadMappings(); + }, [formCode, projectId]); + + // Load mappings when dialog opens + React.useEffect(() => { + if (isOpen) { + const loadMappings = async () => { + if (!formCode || !projectId) return; + + setIsLoadingClasses(true); + try { + const result = await getFormTagTypeMappings(formCode, projectId); + // Type safety casting + const typedMappings: FormTagMapping[] = result.map(item => ({ + id: item.id, + tagTypeLabel: item.tagTypeLabel, + classLabel: item.classLabel, + formCode: item.formCode, + formName: item.formName, + remark: item.remark + })); + setMappings(typedMappings); + } catch (err) { + toast.error("폼 태그 매핑 로드에 실패했습니다."); + } finally { + setIsLoadingClasses(false); + } + }; + + loadMappings(); + } + }, [isOpen, formCode, projectId]); + + // --------------- + // react-hook-form with fieldArray support for multiple rows + // --------------- + const form = useForm({ + defaultValues: { + tagType: "", + class: "", + rows: [{ + tagNo: "", + description: "" + }] + }, + }); + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "rows" + }); + + // --------------- + // Load subfields by TagType code + // --------------- + async function loadSubFieldsByTagTypeCode(tagTypeCode: string) { + setIsLoadingSubFields(true); + try { + const { subFields: apiSubFields } = await getSubfieldsByTagTypeForForm(tagTypeCode, projectId); + const formattedSubFields: SubFieldDef[] = apiSubFields.map(field => ({ + name: field.name, + label: field.label, + type: field.type, + options: field.options || [], + expression: field.expression ?? undefined, + delimiter: field.delimiter ?? undefined, + })); + setSubFields(formattedSubFields); + + // Initialize the rows with these subfields + const currentRows = form.getValues("rows"); + const updatedRows = currentRows.map(row => { + const newRow = { ...row }; + formattedSubFields.forEach(field => { + if (!newRow[field.name]) { + newRow[field.name] = ""; + } + }); + return newRow; + }); + + form.setValue("rows", updatedRows); + return true; + } catch (err) { + toast.error("서브필드를 불러오는데 실패했습니다."); + setSubFields([]); + return false; + } finally { + setIsLoadingSubFields(false); + } + } + + // --------------- + // Handle class selection + // --------------- + async function handleSelectClass(classLabel: string) { + form.setValue("class", classLabel); + + // Find the mapping for this class + const mapping = mappings.find(m => m.classLabel === classLabel); + if (mapping) { + setSelectedTagTypeLabel(mapping.tagTypeLabel); + form.setValue("tagType", mapping.tagTypeLabel); + + // Get the tagTypeCode for this tagTypeLabel + try { + const tagType = await getTagTypeByDescription(mapping.tagTypeLabel, projectId); + if (tagType) { + setSelectedTagTypeCode(tagType.code); + await loadSubFieldsByTagTypeCode(tagType.code); + } else { + toast.error("선택한 태그 유형을 찾을 수 없습니다."); + } + } catch (error) { + toast.error("태그 유형 정보를 불러오는데 실패했습니다."); + } + } + } + + // --------------- + // Build TagNo from subfields automatically for each row + // --------------- + React.useEffect(() => { + if (subFields.length === 0) { + return; + } + + const subscription = form.watch((value) => { + if (!value.rows || subFields.length === 0) { + return; + } + + const rows = [...value.rows]; + rows.forEach((row, rowIndex) => { + if (!row) return; + + let combined = ""; + subFields.forEach((sf, idx) => { + const fieldValue = row[sf.name] || ""; + + // delimiter를 앞에 붙이기 (첫 번째 필드가 아니고, 현재 필드에 값이 있고, delimiter가 있는 경우) + if (idx > 0 && fieldValue && sf.delimiter) { + combined += sf.delimiter; + } + + combined += fieldValue; + }); + + const currentTagNo = form.getValues(`rows.${rowIndex}.tagNo`); + if (currentTagNo !== combined) { + form.setValue(`rows.${rowIndex}.tagNo`, combined, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true, + }); + } + }); + }); + + return () => subscription.unsubscribe(); + }, [subFields, form]); + // --------------- + // Check if tag numbers are valid + // --------------- + const areAllTagNosValid = React.useMemo(() => { + const rows = form.getValues("rows"); + return rows.every(row => { + const tagNo = row.tagNo; + return tagNo && tagNo.trim() !== "" && !tagNo.includes("??"); + }); + }, [form.watch()]); + + // --------------- + // Submit handler for multiple tags + // --------------- + async function onSubmit(data: MultiTagFormValues) { + if (!contractItemId || !projectId) { + toast.error("필요한 정보가 없습니다."); + return; + } + + setIsSubmitting(true); + try { + const successfulTags = []; + const failedTags = []; + + // Process each row + for (const row of data.rows) { + // Create tag data + const tagData = { + tagType: data.tagType, + class: data.class, + tagNo: row.tagNo, + description: row.description, + ...Object.fromEntries( + subFields.map(field => [field.name, row[field.name] || ""]) + ), + functionCode: row.functionCode || "", + seqNumber: row.seqNumber || "", + valveAcronym: row.valveAcronym || "", + processUnit: row.processUnit || "", + }; + + try { + const res = await createTagInForm(tagData, contractItemId, formCode, packageCode); + if (res && "error" in res) { + failedTags.push({ tag: row.tagNo, error: res.error }); + } else if (res && res.success) { + successfulTags.push(row.tagNo); + } else { + // 예상치 못한 응답 처리 + console.error("Unexpected response:", res); + failedTags.push({ tag: row.tagNo, error: "Unexpected response format" }); + } + + } catch (err) { + failedTags.push({ tag: row.tagNo, error: "Unknown error" }); + } + } + + // Show results to the user + if (successfulTags.length > 0) { + toast.success(`${successfulTags.length}개의 태그가 성공적으로 생성되었습니다!`); + } + + if (failedTags.length > 0) { + console.log("Failed tags:", failedTags); + + // 전체 에러 메시지 표시 + const errorMessage = failedTags + .map(f => `${f.tag}: ${f.error}`) + .join('\n'); + + toast.error( +
+

{failedTags.length}개의 태그 생성 실패:

+
    + {failedTags.map((f, idx) => ( +
  • • {f.tag}: {f.error}
  • + ))} +
+
+ ); + } + + // Refresh the page + router.refresh(); + + // Reset the form and close dialog if all successful + if (failedTags.length === 0) { + form.reset(); + setIsOpen(false); + } + } catch (err) { + toast.error("태그 생성 처리에 실패했습니다."); + } finally { + setIsSubmitting(false); + } + } + + // --------------- + // Add a new row + // --------------- + function addRow() { + const newRow: { + tagNo: string; + description: string; + [key: string]: string; + } = { + tagNo: "", + description: "" + }; + + // Add all subfields with empty values + subFields.forEach(field => { + newRow[field.name] = ""; + }); + + append(newRow); + + // Force form validation after row is added + setTimeout(() => form.trigger(), 0); + } + + // --------------- + // Duplicate row + // --------------- + function duplicateRow(index: number) { + const rowToDuplicate = form.getValues(`rows.${index}`); + const newRow: { + tagNo: string; + description: string; + [key: string]: string; + } = { ...rowToDuplicate }; + + // Clear the tagNo field as it will be auto-generated + newRow.tagNo = ""; + append(newRow); + + // Force form validation after row is duplicated + setTimeout(() => form.trigger(), 0); + } + + // --------------- + // Render Class field + // --------------- + function renderClassField(field: any) { + const [popoverOpen, setPopoverOpen] = React.useState(false) + + const buttonId = React.useMemo( + () => `class-button-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + [] + ) + const popoverContentId = React.useMemo( + () => `class-popover-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + [] + ) + const commandId = React.useMemo( + () => `class-command-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + [] + ) + + // Get unique class labels from mappings + const classOptions = Array.from(new Set(mappings.map(m => m.classLabel))); + + return ( + + {t("labels.class")} + + + + + + + + + + {t("messages.noSearchResults")} + + {classOptions.map((className, optIndex) => { + if (!classOptionIdsRef.current[className]) { + classOptionIdsRef.current[className] = + `class-${className}-${Date.now()}-${Math.random() + .toString(36) + .slice(2, 9)}` + } + const optionId = classOptionIdsRef.current[className] + + return ( + { + field.onChange(className) + setPopoverOpen(false) + handleSelectClass(className) + }} + value={className} + className="truncate" + title={className} + > + {className} + + + ) + })} + + + + + + + + + ) + } + + // --------------- + // Render TagType field (readonly after class selection) + // --------------- + function renderTagTypeField(field: any) { + const isReadOnly = !!selectedTagTypeLabel + const inputId = React.useMemo( + () => + `tag-type-input-${isReadOnly ? "readonly" : "editable"}-${Date.now()}-${Math.random() + .toString(36) + .slice(2, 9)}`, + [isReadOnly] + ) + + return ( + + {t("labels.tagType")} + + {isReadOnly ? ( +
+ +
+ ) : ( + + )} +
+ +
+ ) + } + + // --------------- + // Render the table of subfields + // --------------- + function renderTagTable() { + if (isLoadingSubFields) { + return ( +
+ +
{t("messages.loadingFields")}
+
+ ) + } + + if (subFields.length === 0 && selectedTagTypeCode) { + return ( +
+ {t("messages.noFieldsForTagType")} +
+ ) + } + + if (subFields.length === 0) { + return ( +
+ {t("messages.selectClassFirst")} +
+ ) + } + + return ( +
+ {/* 헤더 */} +
+

{t("sections.tagItems")} ({fields.length}개)

+ {!areAllTagNosValid && ( + + {t("messages.invalidTagsExist")} + + )} +
+ + {/* 테이블 컨테이너 - 가로/세로 스크롤 모두 적용 */} +
+
+ + + + # + +
{t("labels.tagNo")}
+
+ +
{t("labels.description")}
+
+ + {/* Subfields */} + {subFields.map((field, fieldIndex) => ( + +
+
+ {field.label} +
+ {field.expression && ( +
+ {field.expression} +
+ )} +
+
+ ))} + + {t("labels.actions")} +
+
+ + + {fields.map((item, rowIndex) => ( + + {/* Row number */} + + {rowIndex + 1} + + + {/* Tag No cell */} + + ( + + +
+ + {field.value?.includes("??") && ( +
+ + ! + +
+ )} +
+
+
+ )} + /> +
+ + {/* Description cell */} + + ( + + + + + + )} + /> + + + {/* Subfield cells */} + {subFields.map((sf, sfIndex) => ( + + ( + + + {sf.type === "select" ? ( + + ) : ( + + )} + + + )} + /> + + ))} + + {/* Actions cell */} + +
+ + + + + + +

{t("tooltips.duplicateRow")}

+
+
+
+ + + + + + + +

{t("tooltips.deleteRow")}

+
+
+
+
+
+
+ ))} +
+
+
+ + {/* 행 추가 버튼 */} + +
+
+ ); + } + + // --------------- + // Reset IDs/states when dialog closes + // --------------- + React.useEffect(() => { + if (!isOpen) { + fieldIdsRef.current = {} + classOptionIdsRef.current = {} + selectIdRef.current = 0 + } + }, [isOpen]) + + return ( + { + if (!o) { + form.reset({ + tagType: "", + class: "", + rows: [{ tagNo: "", description: "" }] + }); + setSelectedTagTypeLabel(null); + setSelectedTagTypeCode(null); + setSubFields([]); + } + setIsOpen(o); + }} + > + {/* Only show the trigger if external control is not being used */} + {externalOnOpenChange === undefined && ( + + + + )} + + + + {t("dialogs.addFormTag")} - {formName || formCode} + + {t("dialogs.selectClassToLoadFields")} + + + +
+ + {/* 클래스 및 태그 유형 선택 */} +
+ renderClassField(field)} + /> + + renderTagTypeField(field)} + /> +
+ + {/* 태그 테이블 */} + {renderTagTable()} + + {/* 버튼 */} + +
+ + +
+
+
+ +
+
+ ) +} \ No newline at end of file diff --git a/components/form-data-plant/delete-form-data-dialog.tsx b/components/form-data-plant/delete-form-data-dialog.tsx new file mode 100644 index 00000000..6166b739 --- /dev/null +++ b/components/form-data-plant/delete-form-data-dialog.tsx @@ -0,0 +1,228 @@ +"use client" + +import * as React from "react" +import { Loader, Trash } from "lucide-react" +import { toast } from "sonner" +import { useParams } from "next/navigation" +import { useTranslation } from "@/i18n/client" + +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-plant/services" + +interface GenericData { + [key: string]: any + TAG_NO?: string +} + +interface DeleteFormDataDialogProps + extends React.ComponentPropsWithoutRef { + 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)") + + const params = useParams(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "engineering"); + + // TAG_NO가 있는 항목들만 필터링 + const validItems = formData.filter(item => item.TAG_IDX?.trim()) + const tagIdxs = validItems.map(item => item.TAG_IDX).filter(Boolean) as string[] + + function onDelete() { + startDeleteTransition(async () => { + if (tagIdxs.length === 0) { + toast.error(t("delete.noValidItems")) + return + } + + const result = await deleteFormDataByTags({ + formCode, + contractItemId, + tagIdxs, + }) + + 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( + t("delete.dataInconsistency", { deletedCount, deletedTagsCount }) + ) + } else { + // 정상적인 삭제 완료 + toast.success( + t("delete.successMessage", { + count: deletedCount, + items: deletedCount === 1 ? t("delete.item") : t("delete.items") + }) + ) + } + + onSuccess?.() + }) + } + + const itemCount = tagIdxs.length + const hasValidItems = itemCount > 0 + + if (isDesktop) { + return ( + + {showTrigger ? ( + + + + ) : null} + + + {t("delete.confirmTitle")} + + {t("delete.confirmDescription", { + count: itemCount, + items: itemCount === 1 ? t("delete.item") : t("delete.items") + })} + {itemCount > 0 && ( + <> +
+
+ + {t("delete.tagNumbers")}: {tagIdxs.slice(0, 3).join(", ")} + {tagIdxs.length > 3 && t("delete.andMore", { count: tagIdxs.length - 3 })} + + + )} +
+
+ + + + + + +
+
+ ) + } + + return ( + + {showTrigger ? ( + + + + ) : null} + + + {t("delete.confirmTitle")} + + {t("delete.confirmDescription", { + count: itemCount, + items: itemCount === 1 ? t("delete.item") : t("delete.items") + })} + {itemCount > 0 && ( + <> +
+
+ + {t("delete.tagNumbers")}: {tagIdxs.slice(0, 3).join(", ")} + {tagIdxs.length > 3 && t("delete.andMore", { count: tagIdxs.length - 3 })} + + + )} +
+
+ + + + + + +
+
+ ) +} \ No newline at end of file diff --git a/components/form-data-plant/export-excel-form.tsx b/components/form-data-plant/export-excel-form.tsx new file mode 100644 index 00000000..1efa5819 --- /dev/null +++ b/components/form-data-plant/export-excel-form.tsx @@ -0,0 +1,674 @@ +import ExcelJS from "exceljs"; +import { saveAs } from "file-saver"; +import { toast } from "sonner"; + +// Define the column type enum +export type ColumnType = "STRING" | "NUMBER" | "LIST" | string; + +// Define the column structure +export interface DataTableColumnJSON { + key: string; + label: string; + type: ColumnType; + options?: string[]; + shi?: string | null; // Updated to support both string and boolean for backward compatibility + required?: boolean; // Required field indicator + // Add any other properties that might be in columnsJSON +} + +// Define a generic data interface +export interface GenericData { + [key: string]: any; + TAG_NO?: string; // Since TAG_NO seems important in the code +} + +// Define error structure +export interface DataError { + tagNo: string; + rowIndex: number; + columnKey: string; + columnLabel: string; + errorType: string; + errorMessage: string; + currentValue?: any; + expectedFormat?: string; +} + +// Define the options interface for the export function +export interface ExportExcelOptions { + tableData: GenericData[]; + columnsJSON: DataTableColumnJSON[]; + formCode: string; + editableFieldsMap?: Map; // 새로 추가 + onPendingChange?: (isPending: boolean) => void; + validateData?: boolean; // Option to enable/disable data validation +} + +// Define the return type +export interface ExportExcelResult { + success: boolean; + error?: any; + errorCount?: number; + hasErrors?: boolean; +} + +/** + * Check if a field is editable for a specific TAG_NO + */ +function isFieldEditable( + column: DataTableColumnJSON, + tagNo: string, + editableFieldsMap: Map +): boolean { + // SHI-only fields (shi === "OUT" or shi === null) are never editable + if (column.shi === "OUT" || column.shi === null) return false; + + // System fields are never editable + if (column.key === "TAG_NO" || column.key === "TAG_DESC" || column.key === "status") return false; + + // If no editableFieldsMap provided, assume all non-SHI fields are editable + if (!editableFieldsMap || editableFieldsMap.size === 0) return true; + + // If TAG_NO not in map, no fields are editable + if (!editableFieldsMap.has(tagNo)) return false; + + // Check if this field is in the editable fields list for this TAG_NO + const editableFields = editableFieldsMap.get(tagNo) || []; + return editableFields.includes(column.key); +} + +/** + * Get the read-only reason for a field + */ +function getReadOnlyReason( + column: DataTableColumnJSON, + tagNo: string, + editableFieldsMap: Map +): string { + if (column.shi === "OUT" || column.shi === null) { + return "SHI-only field"; + } + + if (column.key === "TAG_NO" || column.key === "TAG_DESC" || column.key === "status") { + return "System field"; + } + + if (!editableFieldsMap || editableFieldsMap.size === 0) { + return "No restrictions"; + } + + if (!editableFieldsMap.has(tagNo)) { + return "No editable fields for this TAG"; + } + + const editableFields = editableFieldsMap.get(tagNo) || []; + if (!editableFields.includes(column.key)) { + return "Not editable for this TAG"; + } + + return "Editable"; +} + +/** + * Validate data and collect errors + */ +function validateTableData( + tableData: GenericData[], + columnsJSON: DataTableColumnJSON[] +): DataError[] { + const errors: DataError[] = []; + const tagNoSet = new Set(); + + tableData.forEach((rowData, index) => { + const rowIndex = index + 2; // Excel row number (header is row 1) + const tagNo = rowData.TAG_NO || `Row-${rowIndex}`; + + // Check for duplicate TAG_NO + if (rowData.TAG_NO) { + if (tagNoSet.has(rowData.TAG_NO)) { + errors.push({ + tagNo, + rowIndex, + columnKey: "TAG_NO", + columnLabel: "TAG NO", + errorType: "DUPLICATE", + errorMessage: "Duplicate TAG_NO found", + currentValue: rowData.TAG_NO, + }); + } else { + tagNoSet.add(rowData.TAG_NO); + } + } + + // Validate each column + columnsJSON.forEach((column) => { + const value = rowData[column.key]; + const isEmpty = value === undefined || value === null || value === ""; + + // Required field validation + if (column.required && isEmpty) { + errors.push({ + tagNo, + rowIndex, + columnKey: column.key, + columnLabel: column.label, + errorType: "REQUIRED", + errorMessage: "Required field is empty", + currentValue: value, + }); + } + + if (!isEmpty) { + // Type validation + switch (column.type) { + case "NUMBER": + if (isNaN(Number(value))) { + errors.push({ + tagNo, + rowIndex, + columnKey: column.key, + columnLabel: column.label, + errorType: "TYPE_MISMATCH", + errorMessage: "Value is not a valid number", + currentValue: value, + expectedFormat: "Number", + }); + } + break; + + case "LIST": + if (column.options && !column.options.includes(String(value))) { + errors.push({ + tagNo, + rowIndex, + columnKey: column.key, + columnLabel: column.label, + errorType: "INVALID_OPTION", + errorMessage: "Value is not in the allowed options list", + currentValue: value, + expectedFormat: column.options.join(", "), + }); + } + break; + + case "STRING": + // Additional string validations can be added here + if (typeof value !== "string" && typeof value !== "number") { + errors.push({ + tagNo, + rowIndex, + columnKey: column.key, + columnLabel: column.label, + errorType: "TYPE_MISMATCH", + errorMessage: "Value is not a valid string", + currentValue: value, + expectedFormat: "String", + }); + } + break; + } + } + }); + }); + + return errors; +} + +/** + * Create error sheet with validation results + */ +function createErrorSheet(workbook: ExcelJS.Workbook, errors: DataError[]) { + const errorSheet = workbook.addWorksheet("Errors"); + + // Error sheet headers + const errorHeaders = [ + "TAG NO", + "Row Number", + "Column", + "Error Type", + "Error Message", + "Current Value", + "Expected Format", + ]; + + errorSheet.addRow(errorHeaders); + + // Style error sheet header + const errorHeaderRow = errorSheet.getRow(1); + errorHeaderRow.font = { bold: true, color: { argb: "FFFFFFFF" } }; + errorHeaderRow.alignment = { horizontal: "center" }; + + errorHeaderRow.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFDC143C" }, // Crimson background + }; + }); + + // Add error data + errors.forEach((error) => { + const errorRow = errorSheet.addRow([ + error.tagNo, + error.rowIndex, + error.columnLabel, + error.errorType, + error.errorMessage, + error.currentValue || "", + error.expectedFormat || "", + ]); + + // Color code by error type + errorRow.eachCell((cell, colNumber) => { + let bgColor = "FFFFFFFF"; // Default white + + switch (error.errorType) { + case "REQUIRED": + bgColor = "FFFFCCCC"; // Light red + break; + case "TYPE_MISMATCH": + bgColor = "FFFFEECC"; // Light orange + break; + case "INVALID_OPTION": + bgColor = "FFFFFFE0"; // Light yellow + break; + case "DUPLICATE": + bgColor = "FFFFE0E0"; // Very light red + break; + } + + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: bgColor }, + }; + }); + }); + + // Auto-fit columns + errorSheet.columns.forEach((column) => { + let maxLength = 0; + column.eachCell({ includeEmpty: false }, (cell) => { + const columnLength = String(cell.value).length; + if (columnLength > maxLength) { + maxLength = columnLength; + } + }); + column.width = Math.min(Math.max(maxLength + 2, 10), 50); + }); + + // Add summary at the top + errorSheet.insertRow(1, [`Total Errors Found: ${errors.length}`]); + const summaryRow = errorSheet.getRow(1); + summaryRow.font = { bold: true, size: 14 }; + if (errors.length > 0) { + summaryRow.getCell(1).fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFFFC0C0" }, // Light red background + }; + } + + // Adjust header row number + const newHeaderRow = errorSheet.getRow(2); + newHeaderRow.font = { bold: true, color: { argb: "FFFFFFFF" } }; + newHeaderRow.alignment = { horizontal: "center" }; + + newHeaderRow.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFDC143C" }, + }; + }); + + return errorSheet; +} + +/** + * Export table data to Excel with data validation for select columns + * @param options Configuration options for Excel export + * @returns Promise with success/error information + */ +export async function exportExcelData({ + tableData, + columnsJSON, + formCode, + editableFieldsMap = new Map(), // 새로 추가 + onPendingChange, + validateData = true +}: ExportExcelOptions): Promise { + try { + if (onPendingChange) onPendingChange(true); + + // Validate data first if validation is enabled + const errors = validateData ? validateTableData(tableData, columnsJSON) : []; + + // Create a new workbook + const workbook = new ExcelJS.Workbook(); + + // 데이터 시트 생성 + const worksheet = workbook.addWorksheet("Data"); + + // 유효성 검사용 숨김 시트 생성 + const validationSheet = workbook.addWorksheet("ValidationData"); + validationSheet.state = "hidden"; // 시트 숨김 처리 + + // 1. 유효성 검사 시트에 select 옵션 추가 + const selectColumns = columnsJSON.filter( + (col) => col.type === "LIST" && col.options && col.options.length > 0 + ); + + // 유효성 검사 범위 저장 맵 (컬럼 키 -> 유효성 검사 범위) + const validationRanges = new Map(); + + selectColumns.forEach((col, idx) => { + const colIndex = idx + 1; + const colLetter = validationSheet.getColumn(colIndex).letter; + + // 헤더 추가 (컬럼 레이블) + validationSheet.getCell(`${colLetter}1`).value = col.label; + + // 옵션 추가 + if (col.options) { + col.options.forEach((option, optIdx) => { + validationSheet.getCell(`${colLetter}${optIdx + 2}`).value = option; + }); + + // 유효성 검사 범위 저장 (ValidationData!$A$2:$A$4 형식) + validationRanges.set( + col.key, + `ValidationData!${colLetter}$2:${colLetter}${ + col.options.length + 1 + }` + ); + } + }); + + // 2. 데이터 시트에 헤더 추가 + const headers = columnsJSON.map((col) => { + let headerLabel = col.label; + if (col.required) { + headerLabel += " *"; // Required fields marked with asterisk + } + return headerLabel; + }); + worksheet.addRow(headers); + + // 헤더 스타일 적용 + const headerRow = worksheet.getRow(1); + headerRow.font = { bold: true }; + headerRow.alignment = { horizontal: "center" }; + + // 각 헤더 셀에 스타일 적용 + headerRow.eachCell((cell, colNumber) => { + const columnIndex = colNumber - 1; + const column = columnsJSON[columnIndex]; + + if (column?.shi === "OUT" || column?.shi === null ) { + // SHI-only 필드는 더 진한 음영으로 표시 + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFFF9999" }, // 연한 빨간색 배경 + }; + cell.font = { bold: true, color: { argb: "FF800000" } }; // 진한 빨간색 글자 + } else if (column?.required) { + // Required 필드는 파란색 배경 + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCE5FF" }, // 연한 파란색 배경 + }; + cell.font = { bold: true, color: { argb: "FF000080" } }; // 진한 파란색 글자 + } else { + // 일반 필드는 기존 스타일 + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, // 연한 회색 배경 + }; + } + }); + + // 3. 데이터 행 추가 + tableData.forEach((rowData, rowIndex) => { + const rowValues = columnsJSON.map((col) => { + const value = rowData[col.key]; + return value !== undefined && value !== null ? value : ""; + }); + const dataRow = worksheet.addRow(rowValues); + + // Get errors for this row + const rowErrors = errors.filter(err => err.rowIndex === rowIndex + 2); + const hasErrors = rowErrors.length > 0; + + // 각 데이터 셀에 적절한 스타일 적용 + dataRow.eachCell((cell, colNumber) => { + const columnIndex = colNumber - 1; + const column = columnsJSON[columnIndex]; + const tagNo = rowData.TAG_NO || ""; + + // Check if this cell has errors + const cellHasError = rowErrors.some(err => err.columnKey === column.key); + + // Check if this field is editable for this specific TAG_NO + const fieldEditable = isFieldEditable(column, tagNo, editableFieldsMap); + const readOnlyReason = getReadOnlyReason(column, tagNo, editableFieldsMap); + + if (!fieldEditable) { + // Read-only field styling + let bgColor = "FFFFCCCC"; // Default light red for read-only + let fontColor = "FF666666"; // Gray text + + if (column?.shi === "OUT" || column?.shi === null ) { + // SHI-only fields get a more distinct styling + bgColor = cellHasError ? "FFFF6666" : "FFFFCCCC"; // Darker red if error + fontColor = "FF800000"; // Dark red text + } else { + // Other read-only fields (editableFieldsMap restrictions) + bgColor = cellHasError ? "FFFFAA99" : "FFFFDDCC"; // Orange-ish tint + fontColor = "FF996633"; // Brown text + } + + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: bgColor }, + }; + cell.font = { italic: true, color: { argb: fontColor } }; + + // Add comment to explain why it's read-only + if (readOnlyReason !== "Editable") { + cell.note = { + texts: [{ text: `Read-only: ${readOnlyReason}` }], + margins: { + insetmode: "custom", + inset: [0.13, 0.13, 0.25, 0.25] + } + }; + } + } else if (cellHasError) { + // Editable field with validation error + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFFFDDDD" }, + }; + cell.font = { color: { argb: "FFCC0000" } }; + } + // If field is editable and has no errors, no special styling needed + }); + }); + + // 4. 데이터 유효성 검사 적용 + const maxRows = 5000; // 데이터 유효성 검사를 적용할 최대 행 수 + + columnsJSON.forEach((col, idx) => { + const colLetter = worksheet.getColumn(idx + 1).letter; + + // LIST 타입이고 유효성 검사 범위가 있는 경우에만 적용 + if (col.type === "LIST" && validationRanges.has(col.key)) { + const validationRange = validationRanges.get(col.key)!; + + // 유효성 검사 정의 + const validation = { + type: "list" as const, + allowBlank: !col.required, + formulae: [validationRange], + showErrorMessage: true, + errorStyle: "warning" as const, + errorTitle: "유효하지 않은 값", + error: "목록에서 값을 선택해주세요.", + }; + + // 모든 데이터 행에 유효성 검사 적용 (최대 maxRows까지) + for ( + let rowIdx = 2; + rowIdx <= Math.min(tableData.length + 1, maxRows); + rowIdx++ + ) { + const cell = worksheet.getCell(`${colLetter}${rowIdx}`); + + // Only apply validation to editable cells + const rowData = tableData[rowIdx - 2]; // rowIdx is 1-based, data array is 0-based + if (rowData) { + const tagNo = rowData.TAG_NO || ""; + const fieldEditable = isFieldEditable(col, tagNo, editableFieldsMap); + + if (fieldEditable) { + cell.dataValidation = validation; + } + } + } + + // 빈 행에도 적용 (최대 maxRows까지) - 기본적으로 편집 가능하다고 가정 + if (tableData.length + 1 < maxRows) { + for ( + let rowIdx = tableData.length + 2; + rowIdx <= maxRows; + rowIdx++ + ) { + worksheet.getCell(`${colLetter}${rowIdx}`).dataValidation = validation; + } + } + } + + // Read-only 필드의 빈 행들에도 음영 처리 적용 (기본적으로 SHI-only 필드에만) + if (col.shi === "OUT" || col.shi === null ) { + 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. 컬럼 너비 자동 조정 + columnsJSON.forEach((col, idx) => { + const column = worksheet.getColumn(idx + 1); + + // 최적 너비 계산 + let maxLength = col.label.length; + tableData.forEach((row) => { + const value = row[col.key]; + if (value !== undefined && value !== null) { + const valueLength = String(value).length; + if (valueLength > maxLength) { + maxLength = valueLength; + } + } + }); + + // 너비 설정 (최소 10, 최대 50) + column.width = Math.min(Math.max(maxLength + 2, 10), 50); + }); + + // 6. 에러 시트 생성 (에러가 있을 경우에만) + if (errors.length > 0) { + createErrorSheet(workbook, errors); + } + + // 7. 범례 추가 (별도 시트) + 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(["Blue background header", "Required fields (marked with *)"]); + legendSheet.addRow(["Gray background header", "Regular optional fields"]); + legendSheet.addRow(["Light red background cells", "Cells with validation errors OR SHI-only fields"]); + legendSheet.addRow(["Light orange background cells", "Fields not editable for specific TAG (based on editableFieldsMap)"]); + legendSheet.addRow(["Cell comments", "Hover over read-only cells to see the reason why they cannot be edited"]); + + if (errors.length > 0) { + legendSheet.addRow([]); + legendSheet.addRow([`Note: ${errors.length} validation errors found in the 'Errors' sheet`]); + const errorNoteRow = legendSheet.getRow(legendSheet.rowCount); + errorNoteRow.font = { bold: true, color: { argb: "FFCC0000" } }; + } + + // Add editableFieldsMap summary if available + if (editableFieldsMap.size > 0) { + legendSheet.addRow([]); + legendSheet.addRow([`Editable Fields Map Summary (${editableFieldsMap.size} TAGs):`]); + const summaryHeaderRow = legendSheet.getRow(legendSheet.rowCount); + summaryHeaderRow.font = { bold: true, color: { argb: "FF000080" } }; + + // Show first few examples + let count = 0; + for (const [tagNo, editableFields] of editableFieldsMap) { + if (count >= 5) { // Show only first 5 examples + legendSheet.addRow([`... and ${editableFieldsMap.size - 5} more TAGs`]); + break; + } + legendSheet.addRow([`${tagNo}:`, editableFields.join(", ")]); + count++; + } + } + + // 범례 스타일 적용 + 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" }, + }; + }); + + // 8. 파일 다운로드 + const buffer = await workbook.xlsx.writeBuffer(); + const fileName = errors.length > 0 + ? `${formCode}_data_with_errors_${new Date().toISOString().slice(0, 10)}.xlsx` + : `${formCode}_data_${new Date().toISOString().slice(0, 10)}.xlsx`; + + saveAs(new Blob([buffer]), fileName); + + const message = errors.length > 0 + ? `Excel 내보내기 완료! (${errors.length}개의 검증 오류 발견)` + : "Excel 내보내기 완료!"; + + toast.success(message); + + return { + success: true, + errorCount: errors.length, + hasErrors: errors.length > 0 + }; + } catch (err) { + console.error("Excel export error:", err); + toast.error("Excel 내보내기 실패."); + return { success: false, error: err }; + } finally { + if (onPendingChange) onPendingChange(false); + } +} \ No newline at end of file diff --git a/components/form-data-plant/form-data-report-batch-dialog.tsx b/components/form-data-plant/form-data-report-batch-dialog.tsx new file mode 100644 index 00000000..24b5827b --- /dev/null +++ b/components/form-data-plant/form-data-report-batch-dialog.tsx @@ -0,0 +1,444 @@ +"use client"; + +import React, { + FC, + Dispatch, + SetStateAction, + useState, + useEffect, +} from "react"; +import { useParams } from "next/navigation"; +import { useTranslation } from "@/i18n/client"; +import { useToast } from "@/hooks/use-toast"; +import { toast as toastMessage } from "sonner"; +import prettyBytes from "pretty-bytes"; +import { X, Loader2 } from "lucide-react"; +import { saveAs } from "file-saver"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dropzone, + DropzoneDescription, + DropzoneInput, + DropzoneTitle, + DropzoneUploadIcon, + DropzoneZone, +} from "@/components/ui/dropzone"; +import { + FileList, + FileListAction, + FileListDescription, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, +} from "@/components/ui/file-list"; +import { Button } from "@/components/ui/button"; +import { getReportTempList, getOrigin } from "@/lib/forms-plant/services"; +import { DataTableColumnJSON } from "./form-data-table-columns"; +import { PublishDialog } from "./publish-dialog"; + +const MAX_FILE_SIZE = 3000000; + +type ReportData = { + [key: string]: any; +}; + +interface tempFile { + fileName: string; + filePath: string; +} + +interface FormDataReportBatchDialogProps { + open: boolean; + setOpen: Dispatch>; + columnsJSON: DataTableColumnJSON[]; + reportData: ReportData[]; + packageId: number; + formId: number; + formCode: string; +} + +export const FormDataReportBatchDialog: FC = ({ + open, + setOpen, + columnsJSON, + reportData, + packageId, + formId, + formCode, +}) => { + const { toast } = useToast(); + const params = useParams(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "engineering"); + + const [tempList, setTempList] = useState([]); + const [selectTemp, setSelectTemp] = useState(""); + const [selectedFiles, setSelectedFiles] = useState([]); + const [isUploading, setIsUploading] = useState(false); + + // Add new state for publish dialog + const [publishDialogOpen, setPublishDialogOpen] = useState(false); + const [generatedFileBlob, setGeneratedFileBlob] = useState(null); + + useEffect(() => { + updateReportTempList(packageId, formId, setTempList); + }, [packageId, formId]); + + const onClose = () => { + if (isUploading) { + return; + } + setOpen(false); + }; + + // 드롭존 - 파일 드랍 처리 + const handleDropAccepted = (acceptedFiles: File[]) => { + const newFiles = [...acceptedFiles]; + setSelectedFiles(newFiles); + }; + + // 드롭존 - 파일 거부(에러) 처리 + const handleDropRejected = (fileRejections: any[]) => { + fileRejections.forEach((rejection) => { + toast({ + variant: "destructive", + title: t("batchReport.fileError"), + description: `${rejection.file.name}: ${ + rejection.errors[0]?.message || t("batchReport.uploadFailed") + }`, + }); + }); + }; + + // 파일 제거 핸들러 + const removeFile = (index: number) => { + const updatedFiles = [...selectedFiles]; + updatedFiles.splice(index, 1); + setSelectedFiles(updatedFiles); + }; + + // Create and download document + const submitData = async () => { + setIsUploading(true); + + try { + const origin = await getOrigin(); + const targetFiles = selectedFiles[0]; + + const reportDatas = reportData.map((c) => { + const reportValue = stringifyAllValues(c); + const reportValueMapping: { [key: string]: any } = {}; + + columnsJSON.forEach((c2) => { + const { key } = c2; + reportValueMapping[key] = reportValue?.[key] ?? ""; + }); + + return reportValueMapping; + }); + + const formData = new FormData(); + formData.append("file", targetFiles); + formData.append("customFileName", `${formCode}.pdf`); + formData.append("reportDatas", JSON.stringify(reportDatas)); + formData.append("reportTempPath", selectTemp); + + const requestCreateReport = await fetch( + `${origin}/api/pdftron/createVendorDataReports`, + { method: "POST", body: formData } + ); + + if (requestCreateReport.ok) { + const blob = await requestCreateReport.blob(); + saveAs(blob, `${formCode}.pdf`); + toastMessage.success(t("batchReport.downloadComplete")); + } else { + const err = await requestCreateReport.json(); + console.error("에러:", err); + throw new Error(err.message); + } + } catch (err) { + console.error(err); + toast({ + title: t("batchReport.error"), + description: t("batchReport.reportGenerationError"), + variant: "destructive", + }); + } finally { + setIsUploading(false); + setSelectedFiles([]); + setOpen(false); + } + }; + + // New function to prepare the file for publishing + const prepareFileForPublishing = async () => { + setIsUploading(true); + + try { + const origin = await getOrigin(); + const targetFiles = selectedFiles[0]; + + const reportDatas = reportData.map((c) => { + const reportValue = stringifyAllValues(c); + const reportValueMapping: { [key: string]: any } = {}; + + columnsJSON.forEach((c2) => { + const { key } = c2; + reportValueMapping[key] = reportValue?.[key] ?? ""; + }); + + return reportValueMapping; + }); + + const formData = new FormData(); + formData.append("file", targetFiles); + formData.append("customFileName", `${formCode}.pdf`); + formData.append("reportDatas", JSON.stringify(reportDatas)); + formData.append("reportTempPath", selectTemp); + + const requestCreateReport = await fetch( + `${origin}/api/pdftron/createVendorDataReports`, + { method: "POST", body: formData } + ); + + if (requestCreateReport.ok) { + const blob = await requestCreateReport.blob(); + setGeneratedFileBlob(blob); + setPublishDialogOpen(true); + toastMessage.success(t("batchReport.documentGenerated")); + } else { + const err = await requestCreateReport.json(); + console.error("에러:", err); + throw new Error(err.message); + } + } catch (err) { + console.error(err); + toast({ + title: t("batchReport.error"), + description: t("batchReport.documentGenerationError"), + variant: "destructive", + }); + } finally { + setIsUploading(false); + } + }; + + return ( + <> + + + + {t("batchReport.dialogTitle")} + + {t("batchReport.dialogDescription")} + + +
+ + +
+
+ + + {({ maxSize }) => ( + <> + + +
+ +
+ {t("batchReport.dropFileHere")} + + {t("batchReport.orClickToSelect", { + maxSize: maxSize ? prettyBytes(maxSize) : t("batchReport.unlimited") + })} + +
+
+
+ + + )} +
+
+ + {selectedFiles.length > 0 && ( +
+
+
+ {t("batchReport.selectedFiles", { count: selectedFiles.length })} +
+ + {t("batchReport.fileCount", { count: selectedFiles.length })} + +
+ + + +
+ )} + + + {/* Add the new Publish button */} + + + +
+
+ + {/* Add the PublishDialog component */} + + + ); +}; + +interface UploadFileItemProps { + selectedFiles: File[]; + removeFile: (index: number) => void; + isUploading: boolean; + t: (key: string, options?: any) => string; +} + +const UploadFileItem: FC = ({ + selectedFiles, + removeFile, + isUploading, + t, +}) => { + return ( + + {selectedFiles.map((file, index) => ( + + + + + {file.name} + + {prettyBytes(file.size)} + + + removeFile(index)} + disabled={isUploading} + > + + {t("batchReport.remove")} + + + + ))} + + ); +}; + +type UpdateReportTempList = ( + packageId: number, + formId: number, + setPrevReportTemp: Dispatch> +) => void; + +const updateReportTempList: UpdateReportTempList = async ( + packageId, + formId, + setTempList +) => { + const tempList = await getReportTempList(packageId, formId); + + setTempList( + tempList.map((c) => { + const { fileName, filePath } = c; + return { fileName, filePath }; + }) + ); +}; + +const stringifyAllValues = (obj: any): any => { + if (Array.isArray(obj)) { + return obj.map((item) => stringifyAllValues(item)); + } else if (typeof obj === "object" && obj !== null) { + const result: any = {}; + for (const key in obj) { + result[key] = stringifyAllValues(obj[key]); + } + return result; + } else { + return obj !== null && obj !== undefined ? String(obj) : ""; + } +}; \ No newline at end of file diff --git a/components/form-data-plant/form-data-report-dialog.tsx b/components/form-data-plant/form-data-report-dialog.tsx new file mode 100644 index 00000000..9177ab36 --- /dev/null +++ b/components/form-data-plant/form-data-report-dialog.tsx @@ -0,0 +1,415 @@ +"use client"; + +import React, { + FC, + Dispatch, + SetStateAction, + useState, + useEffect, + useRef, +} from "react"; +import { useParams } from "next/navigation"; +import { useTranslation } from "@/i18n/client"; +import { WebViewerInstance } from "@pdftron/webviewer"; +import { Loader2 } from "lucide-react"; +import { saveAs } from "file-saver"; +import { toast } from "sonner"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +import { Button } from "@/components/ui/button"; +import { getReportTempList } from "@/lib/forms-plant/services"; +import { DataTableColumnJSON } from "./form-data-table-columns"; +import { PublishDialog } from "./publish-dialog"; + +type ReportData = { + [key: string]: any; +}; + +interface tempFile { + fileName: string; + filePath: string; +} + +interface FormDataReportDialogProps { + columnsJSON: DataTableColumnJSON[]; + reportData: ReportData[]; + setReportData: Dispatch>; + packageId: number; + formId: number; + formCode: string; +} + +export const FormDataReportDialog: FC = ({ + columnsJSON, + reportData, + setReportData, + packageId, + formId, + formCode, +}) => { + const params = useParams(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "engineering"); + + const [tempList, setTempList] = useState([]); + const [selectTemp, setSelectTemp] = useState(""); + const [instance, setInstance] = useState(null); + const [fileLoading, setFileLoading] = useState(true); + + // Add new state for publish dialog + const [publishDialogOpen, setPublishDialogOpen] = useState(false); + const [generatedFileBlob, setGeneratedFileBlob] = useState(null); + + useEffect(() => { + updateReportTempList(packageId, formId, setTempList); + }, [packageId, formId]); + + const onClose = async (value: boolean) => { + if (fileLoading) { + return; + } + if (!value) { + setTimeout(() => cleanupHtmlStyle(), 1000); + setReportData([]); + } + }; + + const downloadFileData = async () => { + if (instance) { + const { UI, Core } = instance; + const { documentViewer } = Core; + + const doc = documentViewer.getDocument(); + const fileName = doc.getFilename(); + const fileData = await doc.getFileData({ + includeAnnotations: true, // 사용자가 추가한 폼 필드 및 입력 포함 + // officeOptions: { + // outputFormat: "docx", + // }, + }); + + saveAs(new Blob([fileData]), fileName); + + toast.success(t("singleReport.downloadComplete")); + } + }; + + // New function to prepare the file for publishing + const prepareFileForPublishing = async () => { + if (instance) { + try { + const { Core } = instance; + const { documentViewer } = Core; + + const doc = documentViewer.getDocument(); + const fileData = await doc.getFileData({ + includeAnnotations: true, + }); + + setGeneratedFileBlob(new Blob([fileData])); + setPublishDialogOpen(true); + } catch (error) { + console.error("Error preparing file for publishing:", error); + toast.error(t("singleReport.publishPreparationFailed")); + } + } + }; + + return ( + <> + 0} onOpenChange={onClose}> + + + {t("singleReport.dialogTitle")} + + {t("singleReport.dialogDescription")} + + +
+ + +
+
+ +
+ + + {/* Add the new Publish button */} + + + +
+
+ + {/* Add the PublishDialog component */} + + + ); +}; + +// Keep the rest of the component as is... +interface ReportWebViewerProps { + columnsJSON: DataTableColumnJSON[]; + reportTempPath: string; + reportDatas: ReportData[]; + instance: null | WebViewerInstance; + setInstance: Dispatch>; + setFileLoading: Dispatch>; + formCode: string; + t: (key: string, options?: any) => string; +} + +const ReportWebViewer: FC = ({ + columnsJSON, + reportTempPath, + reportDatas, + instance, + setInstance, + setFileLoading, + formCode, + t, +}) => { + const [viwerLoading, setViewerLoading] = useState(true); + const viewer = useRef(null); + const initialized = React.useRef(false); + const isCancelled = React.useRef(false); // 초기화 중단용 flag + + useEffect(() => { + if (!initialized.current) { + initialized.current = true; + isCancelled.current = false; // 다시 열릴 때는 false로 리셋 + + requestAnimationFrame(() => { + if (viewer.current) { + import("@pdftron/webviewer").then(({ default: WebViewer }) => { + console.log(isCancelled.current); + if (isCancelled.current) { + console.log("📛 WebViewer 초기화 취소됨 (Dialog 닫힘)"); + + return; + } + + WebViewer( + { + path: "/pdftronWeb", + licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, + fullAPI: true, + }, + viewer.current as HTMLDivElement + ).then(async (instance: WebViewerInstance) => { + setInstance(instance); + setViewerLoading(false); + }); + }); + } + }); + } + + return () => { + // cleanup 시에는 중단 flag 세움 + if (instance) { + instance.UI.dispose(); + } + setTimeout(() => cleanupHtmlStyle(), 500); + }; + }, []); + + useEffect(() => { + importReportData( + columnsJSON, + instance, + reportDatas, + reportTempPath, + setFileLoading, + formCode + ); + }, [reportTempPath, reportDatas, instance, columnsJSON, formCode]); + + return ( +
+ {viwerLoading && ( +
+ +

{t("singleReport.documentViewerLoading")}

+
+ )} +
+ ); +}; + +const cleanupHtmlStyle = () => { + const htmlElement = document.documentElement; + + // 기존 style 속성 가져오기 + const originalStyle = htmlElement.getAttribute("style") || ""; + + // "color-scheme: light" 또는 "color-scheme: dark" 찾기 + const colorSchemeStyle = originalStyle + .split(";") + .map((s) => s.trim()) + .find((s) => s.startsWith("color-scheme:")); + + // 새로운 스타일 적용 (color-scheme만 유지) + if (colorSchemeStyle) { + htmlElement.setAttribute("style", colorSchemeStyle + ";"); + } else { + htmlElement.removeAttribute("style"); // color-scheme도 없으면 style 속성 자체 삭제 + } + + console.log("html style 삭제"); +}; + +const stringifyAllValues = (obj: any): any => { + if (Array.isArray(obj)) { + return obj.map((item) => stringifyAllValues(item)); + } else if (typeof obj === "object" && obj !== null) { + const result: any = {}; + for (const key in obj) { + result[key] = stringifyAllValues(obj[key]); + } + return result; + } else { + return obj !== null && obj !== undefined ? String(obj) : ""; + } +}; + +type ImportReportData = ( + columnsJSON: DataTableColumnJSON[], + instance: null | WebViewerInstance, + reportDatas: ReportData[], + reportTempPath: string, + setFileLoading: Dispatch>, + formCode: string +) => void; + +const importReportData: ImportReportData = async ( + columnsJSON, + instance, + reportDatas, + reportTempPath, + setFileLoading, + formCode +) => { + setFileLoading(true); + try { + if (instance && reportDatas.length > 0 && reportTempPath.length > 0) { + const { UI, Core } = instance; + const { documentViewer, createDocument } = Core; + + const getFileData = await fetch(reportTempPath); + const reportFileBlob = await getFileData.blob(); + + const reportData = reportDatas[0]; + const reportValue = stringifyAllValues(reportData); + + const reportValueMapping: { [key: string]: any } = {}; + + columnsJSON.forEach((c) => { + const { key, label } = c; + + // const objKey = label.split(" ").join("_"); + + reportValueMapping[key] = reportValue?.[key] ?? ""; + }); + + const doc = await createDocument(reportFileBlob, { + filename: `${formCode}_report.docx`, + extension: "docx", + }); + + await doc.applyTemplateValues(reportValueMapping); + + documentViewer.loadDocument(doc, { + extension: "docx", + enableOfficeEditing: true, + officeOptions: { + formatOptions: { + applyPageBreaksToSheet: true, + }, + }, + }); + } + } catch (err) { + } finally { + setFileLoading(false); + } +}; + +type UpdateReportTempList = ( + packageId: number, + formId: number, + setPrevReportTemp: Dispatch> +) => void; + +const updateReportTempList: UpdateReportTempList = async ( + packageId, + formId, + setTempList +) => { + const tempList = await getReportTempList(packageId, formId); + + setTempList( + tempList.map((c) => { + const { fileName, filePath } = c; + return { fileName, filePath }; + }) + ); +}; \ No newline at end of file diff --git a/components/form-data-plant/form-data-report-temp-upload-dialog.tsx b/components/form-data-plant/form-data-report-temp-upload-dialog.tsx new file mode 100644 index 00000000..59ea6ade --- /dev/null +++ b/components/form-data-plant/form-data-report-temp-upload-dialog.tsx @@ -0,0 +1,101 @@ +"use client"; + +import React, { FC, Dispatch, SetStateAction, useState } from "react"; +import { useParams } from "next/navigation"; +import { useTranslation } from "@/i18n/client"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { VarListDownloadBtn } from "./var-list-download-btn"; +import { FormDataReportTempUploadTab } from "./form-data-report-temp-upload-tab"; +import { FormDataReportTempUploadedListTab } from "./form-data-report-temp-uploaded-list-tab"; +import { DataTableColumnJSON } from "./form-data-table-columns"; +import { FileActionsDropdown } from "../ui/file-actions"; + +interface FormDataReportTempUploadDialogProps { + columnsJSON: DataTableColumnJSON[]; + open: boolean; + setOpen: Dispatch>; + packageId: number; + formCode: string; + formId: number; + uploaderType: string; +} + +export const FormDataReportTempUploadDialog: FC< + FormDataReportTempUploadDialogProps +> = ({ + columnsJSON, + open, + setOpen, + packageId, + formId, + formCode, + uploaderType, +}) => { + const params = useParams(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "engineering"); + + const [tabValue, setTabValue] = useState<"upload" | "uploaded">("upload"); + + return ( + + + + {t("templateUpload.dialogTitle")} + + + + + + +
+ + setTabValue("upload")} + className="flex-1" + > + {t("templateUpload.uploadTab")} + + setTabValue("uploaded")} + className="flex-1" + > + {t("templateUpload.uploadedListTab")} + + +
+ + + + + + +
+
+
+ ); +}; \ No newline at end of file diff --git a/components/form-data-plant/form-data-report-temp-upload-tab.tsx b/components/form-data-plant/form-data-report-temp-upload-tab.tsx new file mode 100644 index 00000000..81186ba4 --- /dev/null +++ b/components/form-data-plant/form-data-report-temp-upload-tab.tsx @@ -0,0 +1,243 @@ +"use client"; + +import React, { FC, useState } from "react"; +import { useParams } from "next/navigation"; +import { useTranslation } from "@/i18n/client"; +import { useToast } from "@/hooks/use-toast"; +import { toast as toastMessage } from "sonner"; +import prettyBytes from "pretty-bytes"; +import { X, Loader2 } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { DialogFooter } from "@/components/ui/dialog"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { + Dropzone, + DropzoneDescription, + DropzoneInput, + DropzoneTitle, + DropzoneUploadIcon, + DropzoneZone, +} from "@/components/ui/dropzone"; +import { + FileList, + FileListAction, + FileListDescription, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, +} from "@/components/ui/file-list"; +import { uploadReportTemp } from "@/lib/forms-plant/services"; + +// 최대 파일 크기 설정 (3000MB) +const MAX_FILE_SIZE = 3000000; + +interface FormDataReportTempUploadTabProps { + packageId: number; + formId: number; + uploaderType: string; +} + +export const FormDataReportTempUploadTab: FC< + FormDataReportTempUploadTabProps +> = ({ packageId, formId, uploaderType }) => { + const { toast } = useToast(); + const params = useParams(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "engineering"); + + const [selectedFiles, setSelectedFiles] = useState([]); + const [isUploading, setIsUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + + // 드롭존 - 파일 드랍 처리 + const handleDropAccepted = (acceptedFiles: File[]) => { + const newFiles = [...selectedFiles, ...acceptedFiles]; + setSelectedFiles(newFiles); + }; + + // 드롭존 - 파일 거부(에러) 처리 + const handleDropRejected = (fileRejections: any[]) => { + fileRejections.forEach((rejection) => { + toast({ + variant: "destructive", + title: t("templateUploadTab.fileError"), + description: `${rejection.file.name}: ${ + rejection.errors[0]?.message || t("templateUploadTab.uploadFailed") + }`, + }); + }); + }; + + // 파일 제거 핸들러 + const removeFile = (index: number) => { + const updatedFiles = [...selectedFiles]; + updatedFiles.splice(index, 1); + setSelectedFiles(updatedFiles); + }; + + const submitData = async () => { + setIsUploading(true); + setUploadProgress(0); + try { + const totalFiles = selectedFiles.length; + let successCount = 0; + + for (let i = 0; i < totalFiles; i++) { + const file = selectedFiles[i]; + + const formData = new FormData(); + formData.append("file", file); + formData.append("customFileName", file.name); + formData.append("uploaderType", uploaderType); + + await uploadReportTemp(packageId, formId, formData); + + successCount++; + setUploadProgress(Math.round((successCount / totalFiles) * 100)); + } + toastMessage.success(t("templateUploadTab.uploadComplete")); + } catch (err) { + console.error(err); + toast({ + title: t("templateUploadTab.error"), + description: t("templateUploadTab.uploadError"), + variant: "destructive", + }); + } finally { + setIsUploading(false); + setUploadProgress(0); + setSelectedFiles([]) + } + }; + + return ( +
+
+ + + {({ maxSize }) => ( + <> + + +
+ +
+ {t("templateUploadTab.dropFileHere")} + + {t("templateUploadTab.orClickToSelect", { + maxSize: maxSize ? prettyBytes(maxSize) : t("templateUploadTab.unlimited") + })} + +
+
+
+ + + )} +
+
+ + {selectedFiles.length > 0 && ( +
+
+
+ {t("templateUploadTab.selectedFiles", { count: selectedFiles.length })} +
+ + {t("templateUploadTab.fileCount", { count: selectedFiles.length })} + +
+ + + +
+ )} + + {isUploading && } + + + +
+ ); +}; + +interface UploadFileItemProps { + selectedFiles: File[]; + removeFile: (index: number) => void; + isUploading: boolean; + t: (key: string, options?: any) => string; +} + +const UploadFileItem: FC = ({ + selectedFiles, + removeFile, + isUploading, + t, +}) => { + return ( + + {selectedFiles.map((file, index) => ( + + + + + {file.name} + + {prettyBytes(file.size)} + + + removeFile(index)} + disabled={isUploading} + > + + {t("templateUploadTab.remove")} + + + + ))} + + ); +}; + +const UploadProgressBox: FC<{ + uploadProgress: number; + t: (key: string, options?: any) => string; +}> = ({ uploadProgress, t }) => { + return ( +
+
+ + + {t("templateUploadTab.uploadingProgress", { progress: uploadProgress })} + +
+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/components/form-data-plant/form-data-report-temp-uploaded-list-tab.tsx b/components/form-data-plant/form-data-report-temp-uploaded-list-tab.tsx new file mode 100644 index 00000000..4cfbad69 --- /dev/null +++ b/components/form-data-plant/form-data-report-temp-uploaded-list-tab.tsx @@ -0,0 +1,218 @@ +"use client"; + +import React, { + FC, + Dispatch, + SetStateAction, + useState, + useEffect, +} from "react"; +import { useParams } from "next/navigation"; +import { useTranslation } from "@/i18n/client"; +import { useToast } from "@/hooks/use-toast"; +import { toast as toastMessage } from "sonner"; +import { Download, Trash2 } from "lucide-react"; +import { saveAs } from "file-saver"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Label } from "@/components/ui/label"; +import { + FileList, + FileListAction, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, +} from "@/components/ui/file-list"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { getReportTempList, deleteReportTempFile } from "@/lib/forms-plant/services"; +import { VendorDataReportTemps } from "@/db/schema/vendorData"; + +interface FormDataReportTempUploadedListTabProps { + packageId: number; + formId: number; +} + +export const FormDataReportTempUploadedListTab: FC< + FormDataReportTempUploadedListTabProps +> = ({ packageId, formId }) => { + const params = useParams(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "engineering"); + + const [prevReportTemp, setPrevReportTemp] = useState( + [] + ); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const getTempFiles = async () => { + await updateReportTempList(packageId, formId, setPrevReportTemp); + setIsLoading(false); + }; + + getTempFiles(); + }, [packageId, formId]); + + return ( +
+ + + updateReportTempList(packageId, formId, setPrevReportTemp) + } + isLoading={isLoading} + t={t} + /> +
+ ); +}; + +type UpdateReportTempList = ( + packageId: number, + formId: number, + setPrevReportTemp: Dispatch> +) => Promise; + +const updateReportTempList: UpdateReportTempList = async ( + packageId, + formId, + setPrevReportTemp +) => { + const tempList = await getReportTempList(packageId, formId); + setPrevReportTemp(tempList); +}; + +interface UploadedTempFiles { + prevReportTemp: VendorDataReportTemps[]; + updateReportTempList: () => void; + isLoading: boolean; + t: (key: string, options?: any) => string; +} + +const UploadedTempFiles: FC = ({ + prevReportTemp, + updateReportTempList, + isLoading, + t, +}) => { + const { toast } = useToast(); + + const downloadTempFile = async (fileName: string, filePath: string) => { + try { + const getTempFile = await fetch(filePath); + + if (getTempFile.ok) { + const blob = await getTempFile.blob(); + + saveAs(blob, fileName); + + toastMessage.success(t("templateUploadedList.downloadComplete")); + } else { + const err = await getTempFile.json(); + console.error("에러:", err); + throw new Error(err.message); + } + } catch (err) { + console.error(err); + toast({ + title: t("templateUploadedList.error"), + description: t("templateUploadedList.downloadError"), + variant: "destructive", + }); + } + }; + + const deleteTempFile = async (id: number) => { + try { + const { result, error } = await deleteReportTempFile(id); + + if (result) { + updateReportTempList(); + toastMessage.success(t("templateUploadedList.deleteComplete")); + } else { + throw new Error(error); + } + } catch (err) { + toast({ + title: t("templateUploadedList.error"), + description: t("templateUploadedList.deleteError"), + variant: "destructive", + }); + } + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( + + + {prevReportTemp.map((c) => { + const { fileName, filePath, id } = c; + + return ( + + + + + + {fileName} + + { + downloadTempFile(fileName, filePath); + }} + > + + {t("templateUploadedList.download")} + + + + + {t("templateUploadedList.delete")} + + + + + + {t("templateUploadedList.deleteConfirmTitle", { fileName })} + + + + + {t("templateUploadedList.cancel")} + { + deleteTempFile(id); + }} + > + {t("templateUploadedList.delete")} + + + + + + + ); + })} + + + ); +}; \ No newline at end of file diff --git a/components/form-data-plant/form-data-table-columns.tsx b/components/form-data-plant/form-data-table-columns.tsx new file mode 100644 index 00000000..d453f6c2 --- /dev/null +++ b/components/form-data-plant/form-data-table-columns.tsx @@ -0,0 +1,546 @@ +import type { ColumnDef, Row } from "@tanstack/react-table"; +import { ClientDataTableColumnHeaderSimple } from "../client-data-table/data-table-column-simple-header"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Badge } from "@/components/ui/badge"; +import { Ellipsis } from "lucide-react"; +import { formatDate } from "@/lib/utils"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { toast } from 'sonner'; +import { createFilterFn } from "@/components/client-data-table/table-filters"; + +/** row 액션 관련 타입 */ +export interface DataTableRowAction { + row: Row; + type: "open" | "edit" | "update" | "delete"; +} + +/** 컬럼 타입 (필요에 따라 확장) */ +export type ColumnType = "STRING" | "NUMBER" | "LIST"; + +export interface DataTableColumnJSON { + key: string; + /** 실제 Excel 등에서 구분용으로 쓰이는 label (고정) */ + label: string; + + /** UI 표시용 label (예: 단위를 함께 표시) */ + displayLabel?: string; + + type: ColumnType; + options?: string[]; + uom?: string; + uomId?: string; + shi?: string; + + /** 템플릿에서 가져온 추가 정보 */ + hidden?: boolean; // true이면 컬럼 숨김 + seq?: number; // 정렬 순서 + head?: string; // 헤더 텍스트 (우선순위 가장 높음) +} + +// Register 인터페이스 추가 +export interface Register { + PROJ_NO: string; + TYPE_ID: string; + EP_ID: string; + DESC: string; + REMARK: string | null; + NEW_TAG_YN: boolean; + ALL_TAG_YN: boolean; + VND_YN: boolean; + SEQ: number; + CMPLX_YN: boolean; + CMPL_SETT: any | null; + MAP_ATT: Array<{ ATT_ID: string; [key: string]: any }>; + MAP_CLS_ID: string[]; + MAP_OPER: any | null; + LNK_ATT: any[]; + JOIN_TABLS: any[]; + DELETED: boolean; + CRTER_NO: string; + CRTE_DTM: string; + CHGER_NO: string | null; + CHGE_DTM: string | null; + _id: string; +} + +/** + * getColumns 함수에 필요한 props + * - TData: 테이블에 표시할 행(Row)의 타입 + */ +interface GetColumnsProps { + columnsJSON: DataTableColumnJSON[]; + setRowAction: React.Dispatch< + React.SetStateAction | null> + >; + setReportData: React.Dispatch>; + tempCount: number; + // 체크박스 선택 관련 props + selectedRows?: Record; + onRowSelectionChange?: (updater: Record | ((prev: Record) => Record)) => void; + // 새로 추가: templateData + templateData?: any; + // 새로 추가: registers (필수 필드 체크용) + registers?: Register[]; +} + +/** + * 셀 주소(예: "A1", "B1", "AA1")에서 컬럼 순서를 추출하는 함수 + * A=0, B=1, C=2, ..., Z=25, AA=26, AB=27, ... + */ +function getColumnOrderFromCellAddress(cellAddress: string): number { + if (!cellAddress || typeof cellAddress !== 'string') { + return 999999; // 유효하지 않은 경우 맨 뒤로 + } + + // 셀 주소에서 알파벳 부분만 추출 (예: "A1" -> "A", "AA1" -> "AA") + const match = cellAddress.match(/^([A-Z]+)/); + if (!match) { + return 999999; + } + + const columnLetters = match[1]; + let result = 0; + + // 알파벳을 숫자로 변환 (26진법과 유사하지만 0이 없는 체계) + for (let i = 0; i < columnLetters.length; i++) { + const charCode = columnLetters.charCodeAt(i) - 65 + 1; // A=1, B=2, ..., Z=26 + result = result * 26 + charCode; + } + + return result - 1; // 0부터 시작하도록 조정 +} + +/** + * templateData에서 SPREAD_LIST의 컬럼 순서 정보를 추출하여 seq를 업데이트하는 함수 + */ +function updateSeqFromTemplate(columnsJSON: DataTableColumnJSON[], templateData: any): DataTableColumnJSON[] { + if (!templateData) { + return columnsJSON; // templateData가 없으면 원본 그대로 반환 + } + + // templateData가 배열인지 단일 객체인지 확인 + let templates: any[]; + if (Array.isArray(templateData)) { + templates = templateData; + } else { + templates = [templateData]; + } + + // SPREAD_LIST 타입의 템플릿 찾기 + const spreadListTemplate = templates.find(template => + template.TMPL_TYPE === 'SPREAD_LIST' && + template.SPR_LST_SETUP?.DATA_SHEETS + ); + + if (!spreadListTemplate) { + return columnsJSON; // SPREAD_LIST 템플릿이 없으면 원본 그대로 반환 + } + + // MAP_CELL_ATT에서 ATT_ID와 IN 매핑 정보 추출 + const cellMappings = new Map(); // key: ATT_ID, value: IN (셀 주소) + + spreadListTemplate.SPR_LST_SETUP.DATA_SHEETS.forEach((dataSheet: any) => { + if (dataSheet.MAP_CELL_ATT) { + dataSheet.MAP_CELL_ATT.forEach((mapping: any) => { + if (mapping.ATT_ID && mapping.IN) { + cellMappings.set(mapping.ATT_ID, mapping.IN); + } + }); + } + }); + + // columnsJSON을 복사하여 seq 값 업데이트 + const updatedColumns = columnsJSON.map(column => { + const cellAddress = cellMappings.get(column.key); + if (cellAddress) { + // 셀 주소에서 컬럼 순서 추출 + const newSeq = getColumnOrderFromCellAddress(cellAddress); + console.log(`🔄 Updating seq for ${column.key}: ${column.seq} -> ${newSeq} (from ${cellAddress})`); + + return { + ...column, + seq: newSeq + }; + } + return column; // 매핑이 없으면 원본 그대로 + }); + + return updatedColumns; +} + +/** + * Register의 MAP_ATT에 해당 ATT_ID가 있는지 확인하는 함수 + * 필수 필드인지 체크 + */ +function isRequiredField(attId: string, registers?: Register[]): boolean { + if (!registers || registers.length === 0) { + return false; + } + + // 모든 레지스터의 MAP_ATT를 확인 + return registers.some(register => + register.MAP_ATT && + register.MAP_ATT.some(att => att.ATT_ID === attId) + ); +} + +/** + * status 값에 따라 Badge variant를 결정하는 헬퍼 함수 + */ +function getStatusBadgeVariant(status: string): "default" | "secondary" | "destructive" | "outline" { + const statusStr = String(status).toLowerCase(); + + switch (statusStr) { + case 'NEW': + case 'New': + return 'default'; // 초록색 계열 + case 'Updated or Modified': + return 'secondary'; // 노란색 계열 + case 'inactive': + case 'rejected': + case 'failed': + case 'cancelled': + return 'destructive'; // 빨간색 계열 + default: + return 'outline'; // 기본 회색 계열 + } +} + +/** + * 헤더 텍스트를 결정하는 헬퍼 함수 + * displayLabel이 있으면 사용, 없으면 label 사용 + * 필수 필드인 경우 빨간색 * 추가 + */ +function getHeaderText(col: DataTableColumnJSON, isRequired: boolean): React.ReactNode { + const baseText = col.displayLabel && col.displayLabel.trim() ? col.displayLabel : col.label; + + if (isRequired) { + return ( + + {baseText} + * + + ); + } + + return baseText; +} + +/** + * 컬럼들을 seq 순서대로 배치하면서 연속된 같은 head를 가진 컬럼들을 그룹으로 묶는 함수 + */ +function groupColumnsByHead(columns: DataTableColumnJSON[], registers?: Register[]): ColumnDef[] { + const result: ColumnDef[] = []; + let i = 0; + + while (i < columns.length) { + const currentCol = columns[i]; + + // head가 없거나 빈 문자열인 경우 일반 컬럼으로 처리 + if (!currentCol.head || !currentCol.head.trim()) { + result.push(createColumnDef(currentCol, false, registers)); + i++; + continue; + } + + // 같은 head를 가진 연속된 컬럼들을 찾기 + const groupHead = currentCol.head.trim(); + const groupColumns: DataTableColumnJSON[] = [currentCol]; + let j = i + 1; + + while (j < columns.length && columns[j].head && columns[j].head.trim() === groupHead) { + groupColumns.push(columns[j]); + j++; + } + + // 그룹에 컬럼이 하나만 있으면 일반 컬럼으로 처리 + if (groupColumns.length === 1) { + result.push(createColumnDef(currentCol, false, registers)); + } else { + // 그룹 컬럼 생성 (구분선 스타일 적용) + const groupColumn: ColumnDef = { + id: `group-${groupHead.replace(/\s+/g, '-')}`, + header: groupHead, + columns: groupColumns.map(col => createColumnDef(col, true, registers)), + meta: { + isGroupColumn: true, + groupBorders: true, // 그룹 구분선 표시 플래그 + } + }; + result.push(groupColumn); + } + + i = j; // 다음 그룹으로 이동 + } + + return result; +} + +/** + * 개별 컬럼 정의를 생성하는 헬퍼 함수 + */ +function createColumnDef(col: DataTableColumnJSON, isInGroup: boolean = false, registers?: Register[]): ColumnDef { + const isRequired = isRequiredField(col.key, registers); + + return { + accessorKey: col.key, + header: ({ column }) => ( + + ), + + filterFn: col.type === 'NUMBER' ? createFilterFn("number") : col.type === 'LIST' ? createFilterFn("multi-select"):createFilterFn("text"), + + meta: { + excelHeader: col.label, + minWidth: 80, + paddingFactor: 1.2, + maxWidth: col.key === "TAG_NO" ? 120 : 150, + isReadOnly: col.shi === true, + isInGroup, // 그룹 내 컬럼인지 표시 + groupBorders: isInGroup, // 그룹 구분선 표시 플래그 + isRequired, // 필수 필드 표시 + }, + + cell: ({ row }) => { + const cellValue = row.getValue(col.key); + + // SHI 필드만 읽기 전용으로 처리 + const isReadOnly = col.shi === true; + + // 그룹 구분선 스타일 클래스 추가 + const groupBorderClass = isInGroup ? "group-column-border" : ""; + const readOnlyClass = isReadOnly ? "read-only-cell" : ""; + const combinedClass = [groupBorderClass, readOnlyClass].filter(Boolean).join(" "); + + const cellStyle = { + ...(isReadOnly && { backgroundColor: '#f5f5f5', color: '#666', cursor: 'not-allowed' }), + ...(isInGroup && { + borderLeft: '2px solid #e2e8f0', + borderRight: '2px solid #e2e8f0', + position: 'relative' as const + }) + }; + + // 툴팁 메시지 설정 (SHI 필드만) + const tooltipMessage = isReadOnly ? "SHI 전용 필드입니다" : ""; + + // status 컬럼인 경우 Badge 적용 + if (col.key === "status") { + const statusValue = String(cellValue ?? ""); + const badgeVariant = getStatusBadgeVariant(statusValue); + + return ( +
+ + {statusValue} + +
+ ); + } + + // 데이터 타입별 처리 + switch (col.type) { + case "NUMBER": + return ( +
+ {cellValue ? Number(cellValue).toLocaleString() : ""} +
+ ); + + case "LIST": + return ( +
+ {String(cellValue ?? "")} +
+ ); + + case "STRING": + default: + return ( +
+ {String(cellValue ?? "")} +
+ ); + } + }, + }; +} + +/** + * getColumns 함수 + * 1) columnsJSON 배열을 필터링 (hidden이 true가 아닌 것들만) + * 2) seq에 따라 정렬 + * 3) seq 순서를 유지하면서 연속된 같은 head를 가진 컬럼들을 그룹으로 묶기 + * 4) 체크박스 컬럼 추가 + * 5) 마지막에 "Action" 칼럼 추가 + */ +export function getColumns({ + columnsJSON, + setRowAction, + setReportData, + tempCount, + selectedRows = {}, + onRowSelectionChange, + templateData, // 새로 추가된 매개변수 + registers, // 필수 필드 체크를 위한 레지스터 데이터 +}: GetColumnsProps): ColumnDef[] { + const columns: ColumnDef[] = []; + + // (0) templateData에서 SPREAD_LIST인 경우 seq 값 업데이트 + const processedColumnsJSON = updateSeqFromTemplate(columnsJSON, templateData); + + // (1) 컬럼 필터링 및 정렬 + const visibleColumns = processedColumnsJSON + .filter(col => col.hidden !== true) // hidden이 true가 아닌 것들만 + .sort((a, b) => { + // seq가 없는 경우 999999로 처리하여 맨 뒤로 보냄 + const seqA = a.seq !== undefined ? a.seq : 999999; + const seqB = b.seq !== undefined ? b.seq : 999999; + return seqA - seqB; + }); + + console.log('📊 Final column order after template processing:', + visibleColumns.map(c => `${c.key}(seq:${c.seq})`)); + + // (2) 체크박스 컬럼 (항상 표시) + const selectColumn: ColumnDef = { + id: "select", + header: ({ table }) => ( + { + table.toggleAllPageRowsSelected(!!value); + + // 모든 행 선택/해제 + if (onRowSelectionChange) { + const allRowsSelection: Record = {}; + table.getRowModel().rows.forEach((row) => { + allRowsSelection[row.id] = !!value; + }); + onRowSelectionChange(allRowsSelection); + } + }} + aria-label="Select all" + className="translate-y-[2px]" + /> + ), + cell: ({ row }) => ( + { + row.toggleSelected(!!value); + + // 개별 행 선택 상태 업데이트 + if (onRowSelectionChange) { + onRowSelectionChange(prev => ({ + ...prev, + [row.id]: !!value + })); + } + }} + aria-label="Select row" + className="translate-y-[2px]" + /> + ), + enableSorting: false, + enableHiding: false, + enablePinning: true, + size: 40, + }; + columns.push(selectColumn); + + // (3) 기본 컬럼들 (seq 순서를 유지하면서 head에 따라 그룹핑 처리) + const groupedColumns = groupColumnsByHead(visibleColumns, registers); + columns.push(...groupedColumns); + + // (4) 액션 칼럼 - update 버튼 예시 + const actionColumn: ColumnDef = { + id: "update", + header: "", + cell: ({ row }) => ( + + + + + + { + setRowAction({ row, type: "update" }); + }} + > + Edit + + { + if(tempCount > 0){ + const { original } = row; + setReportData([original]); + } else { + toast.error("업로드된 Template File이 없습니다."); + } + }} + > + Create Document + + + { + setRowAction({ row, type: "delete" }); + }} + className="text-red-600 focus:text-red-600" + > + Delete + + + + ), + size: 40, + enablePinning: true, + }; + + columns.push(actionColumn); + + // (5) 최종 반환 + return columns; +} \ No newline at end of file diff --git a/components/form-data-plant/form-data-table.tsx b/components/form-data-plant/form-data-table.tsx new file mode 100644 index 00000000..9e7b3901 --- /dev/null +++ b/components/form-data-plant/form-data-table.tsx @@ -0,0 +1,1377 @@ +"use client"; + +import * as React from "react"; +import { useParams, useRouter, usePathname } from "next/navigation"; +import { useTranslation } from "@/i18n/client"; + +import { ClientDataTable } from "../client-data-table/data-table"; +import { + getColumns, + DataTableRowAction, + DataTableColumnJSON, + ColumnType, + Register, +} from "./form-data-table-columns"; +import type { DataTableAdvancedFilterField } from "@/types/table"; +import { Button } from "../ui/button"; +import { + Download, + Loader, + Upload, + Plus, + Tag, + TagsIcon, + FileOutput, + Clipboard, + Send, + GitCompareIcon, + RefreshCcw, + Trash2, + Eye, + FileText, + Target, + CheckCircle2, + AlertCircle, + Clock +} from "lucide-react"; +import { toast } from "sonner"; +import { + getPackageCodeById, + getProjectById, + getReportTempList, + sendFormDataToSEDP, + syncMissingTags, excludeFormDataByTags, getRegisters +} from "@/lib/forms-plant/services"; +import { UpdateTagSheet } from "./update-form-sheet"; +import { FormDataReportTempUploadDialog } from "./form-data-report-temp-upload-dialog"; +import { FormDataReportDialog } from "./form-data-report-dialog"; +import { FormDataReportBatchDialog } from "./form-data-report-batch-dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { AddFormTagDialog } from "./add-formTag-dialog"; +import { importExcelData } from "./import-excel-form"; +import { exportExcelData } from "./export-excel-form"; +import { SEDPConfirmationDialog, SEDPStatusDialog } from "./sedp-components"; +import { SEDPCompareDialog } from "./sedp-compare-dialog"; +import { DeleteFormDataDialog } from "./delete-form-data-dialog"; +import { TemplateViewDialog } from "./spreadJS-dialog"; +import { fetchTemplateFromSEDP } from "@/lib/forms-plant/sedp-actions"; +import { FormStatusByVendor, getFormStatusByVendor } from "@/lib/forms-plant/stat"; +import { + Card, + CardContent, + CardHeader, + CardTitle +} from "@/components/ui/card"; +import { XCircle } from "lucide-react"; // 기존 import 리스트에 추가 + +interface GenericData { + [key: string]: unknown; +} + +export interface DynamicTableProps { + dataJSON: GenericData[]; + columnsJSON: DataTableColumnJSON[]; + contractItemId: number; + formCode: string; + formId: number; + projectId: number; + formName?: string; + objectCode?: string; + mode: "IM" | "ENG"; // 모드 속성 + editableFieldsMap?: Map; // 새로 추가 +} + +export default function DynamicTable({ + dataJSON, + columnsJSON, + contractItemId, + formCode, + formId, + projectId, + mode = "IM", // 기본값 설정 + formName = `${formCode}`, // Default form name based on formCode + editableFieldsMap = new Map(), // 새로 추가 +}: DynamicTableProps) { + const params = useParams(); + const router = useRouter(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "engineering"); + + + const [rowAction, setRowAction] = + React.useState | null>(null); + const [tableData, setTableData] = React.useState(dataJSON); + + // 배치 선택 관련 상태 + const [selectedRowsData, setSelectedRowsData] = React.useState([]); + const [clearSelection, setClearSelection] = React.useState(false); + // 삭제 관련 상태 간소화 + const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false); + const [deleteTarget, setDeleteTarget] = React.useState([]); + + const [formStats, setFormStats] = React.useState(null); + const [isLoadingStats, setIsLoadingStats] = React.useState(true); + + const [activeFilter, setActiveFilter] = React.useState(null); + const [filteredTableData, setFilteredTableData] = React.useState(tableData); + const [rowSelection, setRowSelection] = React.useState>({}); + + const [isExcludingTags, setIsExcludingTags] = React.useState(false); + + const handleExcludeTags = async () => { + const selectedRows = getSelectedRowsData(); + + if (selectedRows.length === 0) { + toast.error(t("messages.noTagsSelected")); + return; + } + + // 확인 다이얼로그 + const confirmMessage = t("messages.confirmExclude", { + count: selectedRows.length + }) || `선택한 ${selectedRows.length}개의 태그를 제외 처리하시겠습니까?`; + + if (!confirm(confirmMessage)) { + return; + } + + setIsExcludingTags(true); + + try { + // TAG_NO 목록 추출 + const tagNumbers = selectedRows + .map(row => row.TAG_NO) + .filter(tagNo => tagNo !== null && tagNo !== undefined); + + if (tagNumbers.length === 0) { + toast.error(t("messages.noValidTags")); + return; + } + + // 서버 액션 호출 + const result = await excludeFormDataByTags({ + formCode, + contractItemId, + tagNumbers, + }); + + if (result.success) { + toast.success( + t("messages.tagsExcluded", { count: result.excludedCount }) || + `${result.excludedCount}개의 태그가 제외되었습니다.` + ); + + // 로컬 상태 업데이트 + setTableData(prev => + prev.map(item => { + if (tagNumbers.includes(item.TAG_NO)) { + return { + ...item, + status: 'excluded', + excludedAt: new Date().toISOString() + }; + } + return item; + }) + ); + + // 선택 상태 초기화 + setClearSelection(true); + setTimeout(() => setClearSelection(false), 100); + } else { + toast.error(result.error || t("messages.excludeFailed")); + } + } catch (error) { + console.error("Error excluding tags:", error); + toast.error(t("messages.excludeError") || "태그 제외 중 오류가 발생했습니다."); + } finally { + setIsExcludingTags(false); + } + }; + + // 필터링 로직 + React.useEffect(() => { + if (!activeFilter) { + setFilteredTableData(tableData); + return; + } + + const today = new Date(); + today.setHours(0, 0, 0, 0); + const sevenDaysLater = new Date(today); + sevenDaysLater.setDate(sevenDaysLater.getDate() + 7); + + let filtered = [...tableData]; + + switch (activeFilter) { + case 'completed': + // 모든 필수 필드가 완료된 태그만 표시 + filtered = tableData.filter(item => { + const tagEditableFields = editableFieldsMap.get(item.TAG_NO) || []; + return columnsJSON + .filter(col => (col.shi === 'IN' || col.shi === 'BOTH') && tagEditableFields.includes(col.key)) + .every(col => { + const value = item[col.key]; + return value !== undefined && value !== null && value !== ''; + }); + }); + break; + + case 'remaining': + // 미완료 필드가 있는 태그만 표시 + filtered = tableData.filter(item => { + const tagEditableFields = editableFieldsMap.get(item.TAG_NO) || []; + return columnsJSON + .filter(col => (col.shi === 'IN' || col.shi === 'BOTH') && tagEditableFields.includes(col.key)) + .some(col => { + const value = item[col.key]; + return value === undefined || value === null || value === ''; + }); + }); + break; + + case 'upcoming': + // 7일 이내 임박한 태그만 표시 + filtered = tableData.filter(item => { + const dueDate = item.DUE_DATE; + if (!dueDate) return false; + + const target = new Date(dueDate); + target.setHours(0, 0, 0, 0); + + // 미완료이면서 7일 이내인 경우 + const hasIncompleteFields = columnsJSON + .filter(col => col.shi === 'IN' || col.shi === 'BOTH') + .some(col => !item[col.key]); + + return hasIncompleteFields && target >= today && target <= sevenDaysLater; + }); + break; + + case 'overdue': + // 지연된 태그만 표시 + filtered = tableData.filter(item => { + const dueDate = item.DUE_DATE; + if (!dueDate) return false; + + const target = new Date(dueDate); + target.setHours(0, 0, 0, 0); + + // 미완료이면서 지연된 경우 + const hasIncompleteFields = columnsJSON + .filter(col => col.shi === 'IN' || col.shi === 'BOTH') + .some(col => !item[col.key]); + + return hasIncompleteFields && target < today; + }); + break; + + default: + filtered = tableData; + } + + setFilteredTableData(filtered); + }, [activeFilter, tableData, columnsJSON, editableFieldsMap]); + + // 카드 클릭 핸들러 + const handleCardClick = (filterType: string | null) => { + setActiveFilter(prev => prev === filterType ? null : filterType); + }; + + React.useEffect(() => { + const fetchFormStats = async () => { + try { + setIsLoadingStats(true); + // getFormStatusByVendor 서버 액션 직접 호출 + const data = await getFormStatusByVendor(projectId, contractItemId, formCode); + + if (data && data.length > 0) { + setFormStats(data[0]); + } + } catch (error) { + console.error("Failed to fetch form stats:", error); + toast.error("통계 데이터를 불러오는데 실패했습니다."); + } finally { + setIsLoadingStats(false); + } + }; + + if (projectId && formCode) { + fetchFormStats(); + } + }, [projectId, formCode]); + + // Update tableData when dataJSON changes + React.useEffect(() => { + setTableData(dataJSON); + }, [dataJSON]); + + // 폴링 상태 관리를 위한 ref + const pollingRef = React.useRef(null); + + // Separate loading states for different operations + const [isSyncingTags, setIsSyncingTags] = React.useState(false); + const [isImporting, setIsImporting] = React.useState(false); + const [isExporting, setIsExporting] = React.useState(false); + const [isSaving] = React.useState(false); + const [isSendingSEDP, setIsSendingSEDP] = React.useState(false); + const [isLoadingTags, setIsLoadingTags] = React.useState(false); + const [isLoadingTemplate, setIsLoadingTemplate] = React.useState(false); // 새로 추가 + + // Any operation in progress + const isAnyOperationPending = isSyncingTags || isImporting || isExporting || isSaving || isSendingSEDP || isLoadingTags || isLoadingTemplate || isExcludingTags; + + // SEDP dialogs state + const [sedpConfirmOpen, setSedpConfirmOpen] = React.useState(false); + const [sedpStatusOpen, setSedpStatusOpen] = React.useState(false); + const [sedpStatusData, setSedpStatusData] = React.useState({ + status: 'success' as 'success' | 'error' | 'partial', + message: '', + successCount: 0, + errorCount: 0, + totalCount: 0 + }); + + // SEDP compare dialog state + const [sedpCompareOpen, setSedpCompareOpen] = React.useState(false); + const [projectCode, setProjectCode] = React.useState(''); + const [projectType, setProjectType] = React.useState('plant'); + const [packageCode, setPackageCode] = React.useState(''); + + // 새로 추가된 Template 다이얼로그 상태 + const [templateDialogOpen, setTemplateDialogOpen] = React.useState(false); + const [templateData, setTemplateData] = React.useState(null); + + const [tempUpDialog, setTempUpDialog] = React.useState(false); + const [reportData, setReportData] = React.useState([]); + const [batchDownDialog, setBatchDownDialog] = React.useState(false); + const [tempCount, setTempCount] = React.useState(0); + const [addTagDialogOpen, setAddTagDialogOpen] = React.useState(false); + + const [registers, setRegisters] = React.useState([]); +const [isLoadingRegisters, setIsLoadingRegisters] = React.useState(false); + + + // TAG_NO가 있는 첫 번째 행의 shi 값 확인 + const isAddTagDisabled = React.useMemo(() => { + const firstRowWithTagNo = tableData.find(row => row.TAG_NO); + return firstRowWithTagNo?.shi === true; + }, [tableData]); + + // Clean up polling on unmount + React.useEffect(() => { + return () => { + if (pollingRef.current) { + clearInterval(pollingRef.current); + } + }; + }, []); + + React.useEffect(() => { + const getTempCount = async () => { + const tempList = await getReportTempList(contractItemId, formId); + setTempCount(tempList.length); + }; + + getTempCount(); + }, [contractItemId, formId, tempUpDialog]); + + React.useEffect(() => { + const getPackageCode = async () => { + try { + const packageCode = await getPackageCodeById(contractItemId); + setPackageCode(packageCode || ''); // 빈 문자열이나 다른 기본값 + } catch (error) { + console.error('패키지 조회 실패:', error); + setPackageCode(''); + } + }; + + getPackageCode(); + }, [contractItemId]) + // Get project code when component mounts + React.useEffect(() => { + const getProjectCode = async () => { + try { + const project = await getProjectById(projectId); + setProjectCode(project.code); + setProjectType(project.type); + } catch (error) { + console.error("Error fetching project code:", error); + toast.error("Failed to fetch project code"); + } + }; + + if (projectId) { + getProjectCode(); + } + }, [projectId]); + + // 선택된 행들의 실제 데이터 가져오기 + const getSelectedRowsData = React.useCallback(() => { + return selectedRowsData; + }, [selectedRowsData]); + + // 선택된 행 개수 계산 + const selectedRowCount = React.useMemo(() => { + return selectedRowsData.length; + }, [selectedRowsData]); + + // 프로젝트 코드를 가져오는 useEffect (기존 코드 참고) +React.useEffect(() => { + const fetchRegisters = async () => { + if (!projectCode) return; // projectCode가 있는지 확인 + + setIsLoadingRegisters(true); + try { + const registersData = await getRegisters(projectCode); + setRegisters(registersData); + console.log('✅ Registers loaded:', registersData.length); + } catch (error) { + console.error('❌ Failed to load registers:', error); + toast.error('레지스터 정보를 불러오는데 실패했습니다.'); + } finally { + setIsLoadingRegisters(false); + } + }; + + fetchRegisters(); +}, [projectCode]); + + + const columns = React.useMemo( + () => + getColumns({ + columnsJSON, + setRowAction, + setReportData, + tempCount, + onRowSelectionChange: setRowSelection, + templateData, // 기존 + registers, // 새로 추가 + }), + [columnsJSON, setRowAction, setReportData, tempCount, templateData, registers] + ); + + function mapColumnTypeToAdvancedFilterType( + columnType: ColumnType + ): DataTableAdvancedFilterField["type"] { + switch (columnType) { + case "STRING": + return "text"; + case "NUMBER": + return "number"; + case "LIST": + return "select"; + default: + return "text"; + } + } + + const advancedFilterFields = React.useMemo< + DataTableAdvancedFilterField[] + >(() => { + return columnsJSON.map((col) => ({ + id: col.key, + label: col.label, + type: mapColumnTypeToAdvancedFilterType(col.type), + options: + col.type === "LIST" + ? col.options?.map((v) => ({ label: v, value: v })) + : undefined, + })); + }, [columnsJSON]); + + // 새로 추가된 Template 가져오기 함수 + const handleGetTemplate = async () => { + + if (!projectCode) { + toast.error("Project code is not available"); + return; + } + + try { + setIsLoadingTemplate(true); + + const templateResult = await fetchTemplateFromSEDP(projectCode, formCode); + + // 🔍 전달되는 템플릿 데이터 로깅 + console.log('📊 Template data received from SEDP:', { + count: Array.isArray(templateResult) ? templateResult.length : 'not array', + isArray: Array.isArray(templateResult), + data: templateResult + }); + + if (Array.isArray(templateResult)) { + templateResult.forEach((tmpl, idx) => { + console.log(` [${idx}] TMPL_ID: ${tmpl?.TMPL_ID || 'MISSING'}, NAME: ${tmpl?.NAME || 'N/A'}, TYPE: ${tmpl?.TMPL_TYPE || 'N/A'}`); + }); + } + + setTemplateData(templateResult); + setTemplateDialogOpen(true); + + toast.success("Template data loaded successfully"); + } catch (error) { + console.error("Error fetching template:", error); + toast.error("Failed to fetch template from SEDP"); + } finally { + setIsLoadingTemplate(false); + } + }; + + // IM 모드: 태그 동기화 함수 + async function handleSyncTags() { + try { + setIsSyncingTags(true); + const result = await syncMissingTags(contractItemId, formCode); + + // Prepare the toast messages based on what changed + const changes = []; + if (result.createdCount > 0) + changes.push(`${result.createdCount}건 태그 생성`); + if (result.updatedCount > 0) + changes.push(`${result.updatedCount}건 태그 업데이트`); + if (result.deletedCount > 0) + changes.push(`${result.deletedCount}건 태그 삭제`); + + if (changes.length > 0) { + // If any changes were made, show success message and reload + toast.success(`동기화 완료: ${changes.join(", ")}`); + router.refresh(); // Use router.refresh instead of location.reload + } else { + // If no changes were made, show an info message + toast.info("변경사항이 없습니다. 모든 태그가 최신 상태입니다."); + } + } catch (err) { + console.error(err); + toast.error("태그 동기화 중 에러가 발생했습니다."); + } finally { + 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 }) + }); + + 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) { + toast.info('Tag import started. This may take a while...'); + + // 상태 확인을 위한 폴링 시작 + startPolling(data.syncId); + } else { + throw new Error('No import ID returned from server'); + } + } catch (error) { + console.error('Error starting tag import:', error); + toast.error( + error instanceof Error + ? error.message + : 'An error occurred while starting tag import' + ); + 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); + + // 성공 메시지 표시 + 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); + toast.error(data.error || 'Import failed'); + } else if (data.status === 'processing') { + // 진행 상태 업데이트 (선택적) + if (data.progress) { + toast.info(`Import in progress: ${data.progress}%`, { + id: `import-progress-${id}`, + }); + } + } + } catch (error) { + console.error('Error checking importing status:', error); + } + }, 5000); // 5초마다 체크 + }; + + // Excel Import - Fixed version with proper loading state management + async function handleImportExcel(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + + try { + // 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, + tableData, + columnsJSON, + formCode, + contractItemId, + 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); + } + + // 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 = ""; + // Don't set setIsImporting(false) here since we handle it above + } + } + // SEDP Send handler (with confirmation) + function handleSEDPSendClick() { + if (tableData.length === 0) { + 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); + } + + // Actual SEDP send after confirmation + async function handleSEDPSendConfirmed() { + try { + setIsSendingSEDP(true); + + // Validate data + const invalidData = tableData.filter((item) => { + const tagNo = item.TAG_NO; + return !tagNo || (typeof tagNo === 'string' && !tagNo.trim()); + }); + if (invalidData.length > 0) { + toast.error(`태그 번호가 없는 항목이 ${invalidData.length}개 있습니다.`); + setSedpConfirmOpen(false); + return; + } + + // Then send to SEDP - pass formCode instead of formName + const sedpResult = await sendFormDataToSEDP( + formCode, // Send formCode instead of formName + projectId, // Project ID + contractItemId, + tableData.filter(v=>v.status !== 'excluded'), // Table data + columnsJSON // Column definitions + ); + + // Close confirmation dialog + setSedpConfirmOpen(false); + + // Set status data based on result + if (sedpResult.success) { + setSedpStatusData({ + status: 'success', + message: "Data successfully sent to SEDP", + successCount: tableData.length, + errorCount: 0, + totalCount: tableData.length + }); + } else { + setSedpStatusData({ + status: 'error', + message: sedpResult.message || "Failed to send data to SEDP", + successCount: 0, + errorCount: tableData.length, + totalCount: tableData.length + }); + } + + // Open status dialog to show result + setSedpStatusOpen(true); + + // Refresh the route to get fresh data + router.refresh(); + + } catch (err: unknown) { + console.error("SEDP error:", err); + + // Set error status + setSedpStatusData({ + status: 'error', + message: err instanceof Error ? err.message : "An unexpected error occurred", + successCount: 0, + errorCount: tableData.length, + totalCount: tableData.length + }); + + // Close confirmation and open status + setSedpConfirmOpen(false); + setSedpStatusOpen(true); + + } finally { + setIsSendingSEDP(false); + } + } + + // Template Export + async function handleExportExcel() { + try { + setIsExporting(true); + await exportExcelData({ + tableData, + columnsJSON, + formCode, + editableFieldsMap, + onPendingChange: setIsExporting + }); + } finally { + setIsExporting(false); + } + } + + // Handle batch document with smart selection logic + const handleBatchDocument = () => { + if (tempCount === 0) { + toast.error("업로드된 Template File이 없습니다."); + return; + } + + // 선택된 항목이 있으면 선택된 것만, 없으면 전체 사용 + const selectedData = getSelectedRowsData(); + if (selectedData.length > 0) { + toast.info(`선택된 ${selectedData.length}개 항목으로 배치 문서를 생성합니다.`); + } 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 ( + <> + +
+
+ {/* Total Tags Card - 클릭 시 전체 보기 */} + handleCardClick(null)} + > + + + Total Tags + + + + +
+ {isLoadingStats ? ( + - + ) : ( + formStats?.tagCount || 0 + )} +
+

+ {activeFilter === null ? 'Showing all' : 'Click to show all'} +

+
+
+ + {/* Completed Fields Card */} + handleCardClick('completed')} + > + + + Completed + + + + +
+ {isLoadingStats ? ( + - + ) : ( + formStats?.completedFields || 0 + )} +
+

+ {activeFilter === 'completed' ? 'Filtering active' : 'Click to filter'} +

+
+
+ + {/* Remaining Fields Card */} + handleCardClick('remaining')} + > + + + Remaining + + + + +
+ {isLoadingStats ? ( + - + ) : ( + (formStats?.totalFields || 0) - (formStats?.completedFields || 0) + )} +
+

+ {activeFilter === 'remaining' ? 'Filtering active' : 'Click to filter'} +

+
+
+ + {/* Upcoming Card */} + handleCardClick('upcoming')} + > + + + Upcoming + + + + +
+ {isLoadingStats ? ( + - + ) : ( + formStats?.upcomingCount || 0 + )} +
+

+ {activeFilter === 'upcoming' ? 'Filtering active' : 'Click to filter'} +

+
+
+ + {/* Overdue Card */} + handleCardClick('overdue')} + > + + + Overdue + + + + +
+ {isLoadingStats ? ( + - + ) : ( + formStats?.overdueCount || 0 + )} +
+

+ {activeFilter === 'overdue' ? 'Filtering active' : 'Click to filter'} +

+
+
+
+
+ + + + {/* 필터 상태 표시 */} + {activeFilter && ( +
+ + Filter: {activeFilter === 'completed' ? 'Completed' : + activeFilter === 'remaining' ? 'Remaining' : + activeFilter === 'upcoming' ? 'Upcoming (7 days)' : + activeFilter === 'overdue' ? 'Overdue' : 'All'} + + +
+ )} + {/* 선택된 항목 수 표시 (선택된 항목이 있을 때만) */} + {selectedRowCount > 0 && ( + + )} + + {/* 버튼 그룹 */} +
+ + {selectedRowCount > 0 && ( + + )} + {/* 태그 관리 드롭다운 */} + + + + + + {/* 모드에 따라 다른 태그 작업 표시 */} + {mode === "IM" ? ( + + + {t("buttons.syncTags")} + + ) : ( + + + {t("buttons.getTags")} + + )} + setAddTagDialogOpen(true)} + disabled={isAnyOperationPending || isAddTagDisabled} + > + + {t("buttons.addTags")} + + + + + {/* 리포트 관리 드롭다운 */} + + + + + + setTempUpDialog(true)} disabled={isAnyOperationPending}> + + {t("buttons.uploadTemplate")} + + + + {t("buttons.batchDocument")} + {selectedRowCount > 0 && ( + + {selectedRowCount} + + )} + + + + + {/* IMPORT 버튼 (파일 선택) */} + + + {/* EXPORT 버튼 */} + + + {/* Template 보기 버튼 */} + + + {/* COMPARE WITH SEDP 버튼 */} + + + {/* SEDP 전송 버튼 */} + +
+
+ + {/* Modal dialog for tag update */} + { + if (!open) setRowAction(null); + }} + columns={columnsJSON} + 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 => + item.TAG_NO === tagNo ? updatedValues : item + ) + ); + } + }} + /> + + { + if (!open) { + setDeleteDialogOpen(false); + setDeleteTarget([]); + } + }} + onSuccess={handleDeleteSuccess} + showTrigger={false} + /> + + {/* Dialog for adding tags */} + {/* */} + + {/* 새로 추가된 Template 다이얼로그 */} + setTemplateDialogOpen(false)} + templateData={templateData} + selectedRow={selectedRowsData[0]} // SPR_ITM_LST_SETUP용 + tableData={tableData} // SPR_LST_SETUP용 - 새로 추가 + formCode={formCode} + contractItemId={contractItemId} + editableFieldsMap={editableFieldsMap} + columnsJSON={columnsJSON} + onUpdateSuccess={(updatedValues) => { + // 업데이트 로직도 수정해야 함 - 단일 행 또는 복수 행 처리 + if (Array.isArray(updatedValues)) { + // SPR_LST_SETUP의 경우 - 복수 행 업데이트 + const updatedData = [...tableData]; + updatedValues.forEach(updatedItem => { + const index = updatedData.findIndex(item => item.TAG_NO === updatedItem.TAG_NO); + if (index !== -1) { + updatedData[index] = updatedItem; + } + }); + setTableData(updatedData); + } else { + // SPR_ITM_LST_SETUP의 경우 - 단일 행 업데이트 + const tagNo = updatedValues.TAG_NO; + if (tagNo) { + setTableData(prev => + prev.map(item => + item.TAG_NO === tagNo ? updatedValues : item + ) + ); + } + } + }} + /> + + {/* SEDP Confirmation Dialog */} + setSedpConfirmOpen(false)} + onConfirm={handleSEDPSendConfirmed} + formName={formName} + tagCount={tableData.filter(v=>v.status !=='excluded').length} + isLoading={isSendingSEDP} + /> + + {/* SEDP Status Dialog */} + setSedpStatusOpen(false)} + status={sedpStatusData.status} + message={sedpStatusData.message} + successCount={sedpStatusData.successCount} + errorCount={sedpStatusData.errorCount} + totalCount={sedpStatusData.totalCount} + /> + + {/* SEDP Compare Dialog */} + setSedpCompareOpen(false)} + tableData={tableData} + columnsJSON={columnsJSON} + projectCode={projectCode} + formCode={formCode} + projectType={projectType} + packageCode={packageCode} + /> + + {/* Other dialogs */} + {tempUpDialog && ( + + )} + + {reportData.length > 0 && ( + + )} + + {batchDownDialog && ( + 0 ? getSelectedRowsData() : tableData} + packageId={contractItemId} + formCode={formCode} + formId={formId} + /> + )} + + ); +} \ No newline at end of file diff --git a/components/form-data-plant/import-excel-form.tsx b/components/form-data-plant/import-excel-form.tsx new file mode 100644 index 00000000..ffc6f2f9 --- /dev/null +++ b/components/form-data-plant/import-excel-form.tsx @@ -0,0 +1,669 @@ +import ExcelJS from "exceljs"; +import { saveAs } from "file-saver"; +import { toast } from "sonner"; +import { DataTableColumnJSON } from "./form-data-table-columns"; +import { updateFormDataBatchInDB } from "@/lib/forms-plant/services"; +import { decryptWithServerAction } from "../drm/drmUtils"; + +// Define error structure for import +export interface ImportError { + tagNo: string; + rowIndex: number; + columnKey: string; + columnLabel: string; + errorType: string; + errorMessage: string; + currentValue?: any; + expectedFormat?: string; +} + +// Updated options interface with editableFieldsMap +export interface ImportExcelOptions { + file: File; + tableData: GenericData[]; + columnsJSON: DataTableColumnJSON[]; + formCode?: string; + contractItemId?: number; + editableFieldsMap?: Map; // 새로 추가 + onPendingChange?: (isPending: boolean) => void; + onDataUpdate?: (updater: ((prev: GenericData[]) => GenericData[]) | GenericData[]) => void; +} + +export interface ImportExcelResult { + success: boolean; + importedCount?: number; + error?: any; + message?: string; + skippedFields?: { tagNo: string, fields: string[] }[]; // 건너뛴 필드 정보 + errorCount?: number; + hasErrors?: boolean; + notFoundTags?: string[]; +} + +export interface ExportExcelOptions { + tableData: GenericData[]; + columnsJSON: DataTableColumnJSON[]; + formCode: string; + editableFieldsMap?: Map; // 새로 추가 + onPendingChange?: (isPending: boolean) => void; +} + +interface GenericData { + [key: string]: any; +} + +/** + * Check if a field is editable for a specific TAG_NO + */ +function isFieldEditable( + column: DataTableColumnJSON, + tagNo: string, + editableFieldsMap: Map +): boolean { + // SHI-only fields (shi === "OUT" or shi === null) are never editable + if (column.shi === "OUT" || column.shi === null) return false; + + // System fields are never editable + if (column.key === "TAG_NO" || column.key === "TAG_DESC" || column.key === "status") return false; + + // If no editableFieldsMap provided, assume all non-SHI fields are editable + if (!editableFieldsMap || editableFieldsMap.size === 0) return true; + + // If TAG_NO not in map, no fields are editable + if (!editableFieldsMap.has(tagNo)) return false; + + // Check if this field is in the editable fields list for this TAG_NO + const editableFields = editableFieldsMap.get(tagNo) || []; + return editableFields.includes(column.key); +} + +/** + * Create error sheet with import validation results + */ +function createImportErrorSheet(workbook: ExcelJS.Workbook, errors: ImportError[], headerErrors?: string[]) { + + const existingErrorSheet = workbook.getWorksheet("Import_Errors"); + if (existingErrorSheet) { + workbook.removeWorksheet("Import_Errors"); + } + + const errorSheet = workbook.addWorksheet("Import_Errors"); + + // Add header error section if exists + if (headerErrors && headerErrors.length > 0) { + errorSheet.addRow(["HEADER VALIDATION ERRORS"]); + const headerErrorTitleRow = errorSheet.getRow(1); + headerErrorTitleRow.font = { bold: true, size: 14, color: { argb: "FFFFFFFF" } }; + headerErrorTitleRow.getCell(1).fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFDC143C" }, + }; + + headerErrors.forEach((error, index) => { + const errorRow = errorSheet.addRow([`${index + 1}. ${error}`]); + errorRow.getCell(1).fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFFFCCCC" }, + }; + }); + + errorSheet.addRow([]); // Empty row for separation + } + + // Data validation errors section + const startRow = errorSheet.rowCount + 1; + + // Summary row + errorSheet.addRow([`DATA VALIDATION ERRORS: ${errors.length} errors found`]); + const summaryRow = errorSheet.getRow(startRow); + summaryRow.font = { bold: true, size: 12 }; + if (errors.length > 0) { + summaryRow.getCell(1).fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFFFC0C0" }, + }; + } + + if (errors.length > 0) { + // Error data headers + const errorHeaders = [ + "TAG NO", + "Row Number", + "Column", + "Error Type", + "Error Message", + "Current Value", + "Expected Format", + ]; + + errorSheet.addRow(errorHeaders); + const headerRow = errorSheet.getRow(errorSheet.rowCount); + headerRow.font = { bold: true, color: { argb: "FFFFFFFF" } }; + headerRow.alignment = { horizontal: "center" }; + + headerRow.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFDC143C" }, + }; + }); + + // Add error data + errors.forEach((error) => { + const errorRow = errorSheet.addRow([ + error.tagNo, + error.rowIndex, + error.columnLabel, + error.errorType, + error.errorMessage, + error.currentValue || "", + error.expectedFormat || "", + ]); + + // Color code by error type + errorRow.eachCell((cell) => { + let bgColor = "FFFFFFFF"; // Default white + + switch (error.errorType) { + case "MISSING_TAG_NO": + bgColor = "FFFFCCCC"; // Light red + break; + case "TAG_NOT_FOUND": + bgColor = "FFFFDDDD"; // Very light red + break; + case "TYPE_MISMATCH": + bgColor = "FFFFEECC"; // Light orange + break; + case "INVALID_OPTION": + bgColor = "FFFFFFE0"; // Light yellow + break; + case "HEADER_MISMATCH": + bgColor = "FFFFE0E0"; // Very light red + break; + case "READ_ONLY_FIELD": + bgColor = "FFF0F0F0"; // Light gray + break; + } + + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: bgColor }, + }; + }); + }); + } + + // Auto-fit columns + errorSheet.columns.forEach((column) => { + let maxLength = 0; + column.eachCell({ includeEmpty: false }, (cell) => { + const columnLength = String(cell.value).length; + if (columnLength > maxLength) { + maxLength = columnLength; + } + }); + column.width = Math.min(Math.max(maxLength + 2, 10), 50); + }); + + return errorSheet; +} + +export async function importExcelData({ + file, + tableData, + columnsJSON, + formCode, + contractItemId, + editableFieldsMap = new Map(), // 새로 추가 + onPendingChange, + onDataUpdate +}: ImportExcelOptions): Promise { + if (!file) return { success: false, error: "No file provided" }; + + try { + if (onPendingChange) onPendingChange(true); + + // Get existing tag numbers and create a map for quick lookup + const existingTagNumbers = new Set(tableData.map((d) => d.TAG_NO)); + const existingDataMap = new Map(); + tableData.forEach(item => { + if (item.TAG_NO) { + existingDataMap.set(item.TAG_NO, item); + } + }); + + const workbook = new ExcelJS.Workbook(); + const arrayBuffer = await decryptWithServerAction(file); + await workbook.xlsx.load(arrayBuffer); + + const worksheet = workbook.worksheets[0]; + + // Parse headers + const headerRow = worksheet.getRow(1); + const headerRowValues = headerRow.values as ExcelJS.CellValue[]; + + console.log("Original headers:", headerRowValues); + + // Create mappings between Excel headers and column definitions + const headerToIndexMap = new Map(); + for (let i = 1; i < headerRowValues.length; i++) { + const headerValue = String(headerRowValues[i] || "").trim(); + if (headerValue) { + headerToIndexMap.set(headerValue, i); + } + } + + // Validate headers + const headerErrors: string[] = []; + + // Check for missing required columns + columnsJSON.forEach((col) => { + const label = col.label; + if (!headerToIndexMap.has(label)) { + headerErrors.push(`Column "${label}" is missing from Excel file`); + } + }); + + // Check for unexpected columns + headerToIndexMap.forEach((index, headerLabel) => { + const found = columnsJSON.some((col) => col.label === headerLabel); + if (!found) { + headerErrors.push(`Unexpected column "${headerLabel}" found in Excel file`); + } + }); + + // If header validation fails, create error report and exit + if (headerErrors.length > 0) { + createImportErrorSheet(workbook, [], headerErrors); + + const outBuffer = await workbook.xlsx.writeBuffer(); + saveAs(new Blob([outBuffer]), `import-error-report_${Date.now()}.xlsx`); + + toast.error(`Header validation failed. ${headerErrors.length} errors found. Check downloaded error report.`); + return { + success: false, + error: "Header validation errors", + errorCount: headerErrors.length, + hasErrors: true + }; + } + + // Create column key to Excel index mapping + const keyToIndexMap = new Map(); + columnsJSON.forEach((col) => { + const index = headerToIndexMap.get(col.label); + if (index !== undefined) { + keyToIndexMap.set(col.key, index); + } + }); + + // Parse and validate data rows + const importedData: GenericData[] = []; + const validationErrors: ImportError[] = []; + const lastRowNumber = worksheet.lastRow?.number || 1; + const skippedFieldsLog: { tagNo: string, fields: string[] }[] = []; // 건너뛴 필드 로그 + + // Process each data row + for (let rowNum = 2; rowNum <= lastRowNumber; rowNum++) { + const row = worksheet.getRow(rowNum); + const rowValues = row.values as ExcelJS.CellValue[]; + // 실제 값이 있는지 확인 (빈 문자열이 아닌 실제 내용) + const hasAnyValue = rowValues && rowValues.slice(1).some(val => + val !== undefined && + val !== null && + String(val).trim() !== "" + ); + + if (!hasAnyValue) { + console.log(`Row ${rowNum} is empty, skipping...`); + continue; // 완전히 빈 행은 건너뛰기 + } + + const rowObj: Record = {}; + const skippedFields: string[] = []; // 현재 행에서 건너뛴 필드들 + let hasErrors = false; + + // 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); + + if (!existingTagNumbers.has(tagNo)) { + validationErrors.push({ + tagNo: tagNo, + rowIndex: rowNum, + columnKey: "TAG_NO", + columnLabel: "TAG NO", + errorType: "TAG_NOT_FOUND", + errorMessage: "TAG_NO not found in current data", + currentValue: tagNo, + }); + hasErrors = true; + } + + // Process each column + columnsJSON.forEach((col) => { + const colIndex = keyToIndexMap.get(col.key); + if (colIndex === undefined) return; + + // Check if this field is editable for this TAG_NO + const fieldEditable = isFieldEditable(col, tagNo, editableFieldsMap); + + if (!fieldEditable) { + // If field is not editable, preserve existing value + if (existingRowData && existingRowData[col.key] !== undefined) { + rowObj[col.key] = existingRowData[col.key]; + } else { + // If no existing data, use appropriate default + switch (col.type) { + case "NUMBER": + rowObj[col.key] = null; + break; + case "STRING": + case "LIST": + default: + rowObj[col.key] = ""; + break; + } + } + + // Determine skip reason + let skipReason = ""; + if (col.shi === "OUT" || col.shi === null) { + skipReason = "SHI-only field"; + } else if (col.key === "TAG_NO" || col.key === "TAG_DESC" || col.key === "status") { + skipReason = "System field"; + } else { + skipReason = "Not editable for this TAG"; + } + + // Log skipped field + skippedFields.push(`${col.label} (${skipReason})`); + + // Check if Excel contains a value for a read-only field and warn + const cellValue = rowValues[colIndex] ?? ""; + const stringVal = String(cellValue).trim(); + if (stringVal && existingRowData && String(existingRowData[col.key] || "").trim() !== stringVal) { + validationErrors.push({ + tagNo: tagNo || `Row-${rowNum}`, + rowIndex: rowNum, + columnKey: col.key, + columnLabel: col.label, + errorType: "READ_ONLY_FIELD", + errorMessage: `Attempting to modify read-only field. ${skipReason}.`, + currentValue: stringVal, + expectedFormat: `Field is read-only. Current value: ${existingRowData[col.key] || "empty"}`, + }); + hasErrors = true; + } + + return; // Skip processing Excel value for this column + } + + // Process Excel value for editable fields + const cellValue = rowValues[colIndex] ?? ""; + let stringVal = String(cellValue).trim(); + + // Type-specific validation + switch (col.type) { + case "STRING": + rowObj[col.key] = stringVal; + break; + + case "NUMBER": + if (stringVal) { + const num = parseFloat(stringVal); + if (isNaN(num)) { + validationErrors.push({ + tagNo: tagNo || `Row-${rowNum}`, + rowIndex: rowNum, + columnKey: col.key, + columnLabel: col.label, + errorType: "TYPE_MISMATCH", + errorMessage: "Value is not a valid number", + currentValue: stringVal, + expectedFormat: "Number", + }); + hasErrors = true; + } else { + rowObj[col.key] = num; + } + } else { + rowObj[col.key] = null; + } + break; + + case "LIST": + if (stringVal && col.options && !col.options.includes(stringVal)) { + validationErrors.push({ + tagNo: tagNo || `Row-${rowNum}`, + rowIndex: rowNum, + columnKey: col.key, + columnLabel: col.label, + errorType: "INVALID_OPTION", + errorMessage: "Value is not in the allowed options list", + currentValue: stringVal, + expectedFormat: col.options.join(", "), + }); + hasErrors = true; + } + rowObj[col.key] = stringVal; + break; + + default: + rowObj[col.key] = stringVal; + break; + } + }); + + // Log skipped fields for this TAG + if (skippedFields.length > 0) { + skippedFieldsLog.push({ + tagNo: tagNo, + fields: skippedFields + }); + } + + // Add to valid data only if no errors + if (!hasErrors) { + importedData.push(rowObj); + } + } + + // Show summary of skipped fields + if (skippedFieldsLog.length > 0) { + const totalSkippedFields = skippedFieldsLog.reduce((sum, log) => sum + log.fields.length, 0); + console.log("Skipped fields summary:", skippedFieldsLog); + toast.info( + `${totalSkippedFields} read-only fields were skipped across ${skippedFieldsLog.length} rows. Check console for details.` + ); + } + + // If there are validation errors, create error report and exit + if (validationErrors.length > 0) { + createImportErrorSheet(workbook, validationErrors); + + const outBuffer = await workbook.xlsx.writeBuffer(); + saveAs(new Blob([outBuffer]), `import-error-report_${Date.now()}.xlsx`); + + toast.error( + `Data validation failed. ${validationErrors.length} errors found across ${new Set(validationErrors.map(e => e.tagNo)).size} TAG(s). Check downloaded error report.` + ); + + return { + success: false, + error: "Data validation errors", + errorCount: validationErrors.length, + hasErrors: true, + skippedFields: skippedFieldsLog + }; + } + + // If we reached here, all data is valid + // Create locally merged data for UI update + const mergedData = [...tableData]; + const dataMap = new Map(); + + // Map existing data by TAG_NO + mergedData.forEach(item => { + if (item.TAG_NO) { + dataMap.set(item.TAG_NO, item); + } + }); + + // Update with imported data + importedData.forEach(item => { + if (item.TAG_NO) { + const existingItem = dataMap.get(item.TAG_NO); + if (existingItem) { + // Update existing item with imported values + Object.assign(existingItem, item); + } + } + }); + + // If formCode and contractItemId are provided, save directly to DB + // importExcelData 함수에서 DB 저장 부분 + if (formCode && contractItemId) { + try { + // 배치 업데이트 함수 호출 + const result = await updateFormDataBatchInDB( + formCode, + contractItemId, + importedData // 모든 imported rows를 한번에 전달 + ); + + if (result.success) { + // 로컬 상태 업데이트 + if (onDataUpdate) { + onDataUpdate(() => mergedData); + } + + // 성공 메시지 구성 + const { updatedCount, notFoundTags } = result.data || {}; + + let message = `Successfully updated ${updatedCount || importedData.length} rows`; + + // 건너뛴 필드가 있는 경우 + if (skippedFieldsLog.length > 0) { + const totalSkippedFields = skippedFieldsLog.reduce((sum, log) => sum + log.fields.length, 0); + message += ` (${totalSkippedFields} read-only fields preserved)`; + } + + // 찾을 수 없는 TAG가 있는 경우 + if (notFoundTags && notFoundTags.length > 0) { + console.warn("Tags not found in database:", notFoundTags); + message += `. Warning: ${notFoundTags.length} tags not found in database`; + } + + toast.success(message); + + return { + success: true, + importedCount: updatedCount || importedData.length, + message: message, + errorCount: 0, + hasErrors: false, + skippedFields: skippedFieldsLog, + notFoundTags: notFoundTags + }; + + } else { + // 배치 업데이트 실패 + console.error("Batch update failed:", result.message); + + // 부분 성공인 경우 + if (result.data?.updatedCount > 0) { + // 부분적으로라도 업데이트된 경우 로컬 상태 업데이트 + if (onDataUpdate) { + onDataUpdate(() => mergedData); + } + + toast.warning( + `Partially updated: ${result.data.updatedCount} of ${importedData.length} rows updated. ` + + `${result.data.failedCount || 0} failed.` + ); + + return { + success: true, // 부분 성공도 success로 처리 + importedCount: result.data.updatedCount, + message: result.message, + errorCount: result.data.failedCount || 0, + hasErrors: true, + skippedFields: skippedFieldsLog + }; + + } else { + // 완전 실패 + toast.error(result.message || "Failed to update data to database"); + + return { + success: false, + error: result.message, + errorCount: importedData.length, + hasErrors: true, + skippedFields: skippedFieldsLog + }; + } + } + + } catch (saveError) { + // 예외 발생 처리 + console.error("Failed to save imported data:", saveError); + + const errorMessage = saveError instanceof Error + ? saveError.message + : "Unknown error occurred"; + + toast.error(`Database update failed: ${errorMessage}`); + + return { + success: false, + error: saveError, + message: errorMessage, + errorCount: importedData.length, + hasErrors: true, + skippedFields: skippedFieldsLog + }; + } + + } else { + // formCode나 contractItemId가 없는 경우 - 로컬 업데이트만 + if (onDataUpdate) { + onDataUpdate(() => mergedData); + } + + const successMessage = skippedFieldsLog.length > 0 + ? `Imported ${importedData.length} rows successfully (read-only fields preserved)` + : `Imported ${importedData.length} rows successfully`; + + toast.success(`${successMessage} (local only - no database connection)`); + + return { + success: true, + importedCount: importedData.length, + message: "Data imported locally only", + errorCount: 0, + hasErrors: false, + skippedFields: skippedFieldsLog + }; + } + + } catch (err) { + console.error("Excel import error:", err); + toast.error("Excel import failed."); + return { + success: false, + error: err, + errorCount: 1, + hasErrors: true + }; + } finally { + if (onPendingChange) onPendingChange(false); + } +} \ No newline at end of file diff --git a/components/form-data-plant/publish-dialog.tsx b/components/form-data-plant/publish-dialog.tsx new file mode 100644 index 00000000..a3a2ef0b --- /dev/null +++ b/components/form-data-plant/publish-dialog.tsx @@ -0,0 +1,470 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { useSession } from "next-auth/react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Button } from "@/components/ui/button"; +import { Loader2, Check, ChevronsUpDown } from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { + createRevisionAction, + fetchDocumentsByPackageId, + fetchStagesByDocumentId, + fetchRevisionsByStageParams, + Document, + IssueStage, + Revision +} from "@/lib/vendor-document/service"; + +interface PublishDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + packageId: number; + formCode: string; + fileBlob?: Blob; +} + +export const PublishDialog: React.FC = ({ + open, + onOpenChange, + packageId, + formCode, + fileBlob, +}) => { + // Get current user session from next-auth + const { data: session } = useSession(); + + // State for form data + const [documents, setDocuments] = useState([]); + const [stages, setStages] = useState([]); + const [latestRevision, setLatestRevision] = useState(""); + + // State for document search + const [openDocumentCombobox, setOpenDocumentCombobox] = useState(false); + const [documentSearchValue, setDocumentSearchValue] = useState(""); + + // Selected values + const [selectedDocId, setSelectedDocId] = useState(""); + const [selectedDocumentDisplay, setSelectedDocumentDisplay] = useState(""); + const [selectedStage, setSelectedStage] = useState(""); + const [revisionInput, setRevisionInput] = useState(""); + const [uploaderName, setUploaderName] = useState(""); + const [comment, setComment] = useState(""); + const [customFileName, setCustomFileName] = useState(`${formCode}_document.docx`); + + // Loading states + const [isLoading, setIsLoading] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + // Filter documents by search + const filteredDocuments = documentSearchValue + ? documents.filter(doc => + doc.docNumber.toLowerCase().includes(documentSearchValue.toLowerCase()) || + doc.title.toLowerCase().includes(documentSearchValue.toLowerCase()) + ) + : documents; + + // Set uploader name from session when dialog opens + useEffect(() => { + if (open && session?.user?.name) { + setUploaderName(session.user.name); + } + }, [open, session]); + + // Reset all fields when dialog opens/closes + useEffect(() => { + if (open) { + setSelectedDocId(""); + setSelectedDocumentDisplay(""); + setSelectedStage(""); + setRevisionInput(""); + // Only set uploaderName if not already set from session + if (!session?.user?.name) setUploaderName(""); + setComment(""); + setLatestRevision(""); + setCustomFileName(`${formCode}_document.docx`); + setDocumentSearchValue(""); + } + }, [open, formCode, session]); + + // Fetch documents based on packageId + useEffect(() => { + async function loadDocuments() { + if (packageId && open) { + setIsLoading(true); + + try { + const docs = await fetchDocumentsByPackageId(packageId); + setDocuments(docs); + } catch (error) { + console.error("Error fetching documents:", error); + toast.error("Failed to load documents"); + } finally { + setIsLoading(false); + } + } + } + + loadDocuments(); + }, [packageId, open]); + + // Fetch stages when document is selected + useEffect(() => { + async function loadStages() { + if (selectedDocId) { + setIsLoading(true); + + // Reset dependent fields + setSelectedStage(""); + setRevisionInput(""); + setLatestRevision(""); + + try { + const stagesList = await fetchStagesByDocumentId(parseInt(selectedDocId, 10)); + setStages(stagesList); + } catch (error) { + console.error("Error fetching stages:", error); + toast.error("Failed to load stages"); + } finally { + setIsLoading(false); + } + } else { + setStages([]); + } + } + + loadStages(); + }, [selectedDocId]); + + // Fetch latest revision when stage is selected (for reference) + useEffect(() => { + async function loadLatestRevision() { + if (selectedDocId && selectedStage) { + setIsLoading(true); + + try { + const revsList = await fetchRevisionsByStageParams( + parseInt(selectedDocId, 10), + selectedStage + ); + + // Find the latest revision (assuming revisions are sorted by revision number) + if (revsList.length > 0) { + // Sort revisions if needed + const sortedRevisions = [...revsList].sort((a, b) => { + return b.revision.localeCompare(a.revision, undefined, { numeric: true }); + }); + + setLatestRevision(sortedRevisions[0].revision); + + // Pre-fill the revision input with an incremented value if possible + if (sortedRevisions[0].revision.match(/^\d+$/)) { + // If it's a number, increment it + const nextRevision = String(parseInt(sortedRevisions[0].revision, 10) + 1); + setRevisionInput(nextRevision); + } else if (sortedRevisions[0].revision.match(/^[A-Za-z]$/)) { + // If it's a single letter, get the next letter + const currentChar = sortedRevisions[0].revision.charCodeAt(0); + const nextChar = String.fromCharCode(currentChar + 1); + setRevisionInput(nextChar); + } else { + // For other formats, just show the latest as reference + setRevisionInput(""); + } + } else { + // If no revisions exist, set default values + setLatestRevision(""); + setRevisionInput("0"); + } + } catch (error) { + console.error("Error fetching revisions:", error); + toast.error("Failed to load revision information"); + } finally { + setIsLoading(false); + } + } else { + setLatestRevision(""); + setRevisionInput(""); + } + } + + loadLatestRevision(); + }, [selectedDocId, selectedStage]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!selectedDocId || !selectedStage || !revisionInput || !fileBlob) { + toast.error("Please fill in all required fields"); + return; + } + + setIsSubmitting(true); + + try { + // Create FormData + const formData = new FormData(); + formData.append("documentId", selectedDocId); + formData.append("stage", selectedStage); + formData.append("revision", revisionInput); + formData.append("customFileName", customFileName); + formData.append("uploaderType", "vendor"); // Default value + + if (uploaderName) { + formData.append("uploaderName", uploaderName); + } + + if (comment) { + formData.append("comment", comment); + } + + // Append file as attachment + if (fileBlob) { + const file = new File([fileBlob], customFileName, { + type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + }); + formData.append("attachment", file); + } + + // Call server action directly + const result = await createRevisionAction(formData); + + if (result) { + toast.success("Document published successfully!"); + onOpenChange(false); + } + } catch (error) { + console.error("Error publishing document:", error); + toast.error("Failed to publish document"); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + + Publish Document + + Select document, stage, and revision to publish the vendor document. + + + +
+
+ {/* Document Selection with Search */} +
+ +
+ + + + + + + + No document found. + + {filteredDocuments.map((doc) => ( + { + setSelectedDocId(String(doc.id)); + setSelectedDocumentDisplay(`${doc.docNumber} - ${doc.title}`); + setOpenDocumentCombobox(false); + }} + className="flex items-center" + > + + {/* Add text-overflow handling for document items */} + {doc.docNumber} - {doc.title} + + ))} + + + + +
+
+ + {/* Stage Selection */} +
+ +
+ +
+
+ + {/* Revision Input */} +
+ +
+ setRevisionInput(e.target.value)} + placeholder="Enter revision" + disabled={isLoading || !selectedStage} + /> + {latestRevision && ( +

+ Latest revision: {latestRevision} +

+ )} +
+
+ +
+ +
+ setCustomFileName(e.target.value)} + placeholder="Custom file name" + /> +
+
+ +
+ +
+ setUploaderName(e.target.value)} + placeholder="Your name" + // Disable input but show a filled style + className={session?.user?.name ? "opacity-70" : ""} + readOnly={!!session?.user?.name} + /> + {session?.user?.name && ( +

+ Using your account name from login +

+ )} +
+
+ +
+ +
+