summaryrefslogtreecommitdiff
path: root/components/form-data-plant
diff options
context:
space:
mode:
Diffstat (limited to 'components/form-data-plant')
-rw-r--r--components/form-data-plant/add-formTag-dialog.tsx985
-rw-r--r--components/form-data-plant/delete-form-data-dialog.tsx228
-rw-r--r--components/form-data-plant/export-excel-form.tsx674
-rw-r--r--components/form-data-plant/form-data-report-batch-dialog.tsx444
-rw-r--r--components/form-data-plant/form-data-report-dialog.tsx415
-rw-r--r--components/form-data-plant/form-data-report-temp-upload-dialog.tsx101
-rw-r--r--components/form-data-plant/form-data-report-temp-upload-tab.tsx243
-rw-r--r--components/form-data-plant/form-data-report-temp-uploaded-list-tab.tsx218
-rw-r--r--components/form-data-plant/form-data-table-columns.tsx546
-rw-r--r--components/form-data-plant/form-data-table.tsx1377
-rw-r--r--components/form-data-plant/import-excel-form.tsx669
-rw-r--r--components/form-data-plant/publish-dialog.tsx470
-rw-r--r--components/form-data-plant/sedp-compare-dialog.tsx618
-rw-r--r--components/form-data-plant/sedp-components.tsx193
-rw-r--r--components/form-data-plant/sedp-excel-download.tsx259
-rw-r--r--components/form-data-plant/spreadJS-dialog.tsx1733
-rw-r--r--components/form-data-plant/spreadJS-dialog_designer.tsx1404
-rw-r--r--components/form-data-plant/update-form-sheet.tsx445
-rw-r--r--components/form-data-plant/var-list-download-btn.tsx122
19 files changed, 11144 insertions, 0 deletions
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<FormTagMapping[]>([])
+ const [selectedTagTypeLabel, setSelectedTagTypeLabel] = React.useState<string | null>(null)
+ const [selectedTagTypeCode, setSelectedTagTypeCode] = React.useState<string | null>(null)
+ const [subFields, setSubFields] = React.useState<SubFieldDef[]>([])
+ 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<Record<string, string>>({})
+ const classOptionIdsRef = React.useRef<Record<string, string>>({})
+
+ // ---------------
+ // 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<MultiTagFormValues>({
+ 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(
+ <div>
+ <p>{failedTags.length}개의 태그 생성 실패:</p>
+ <ul className="text-sm mt-1">
+ {failedTags.map((f, idx) => (
+ <li key={idx}>• {f.tag}: {f.error}</li>
+ ))}
+ </ul>
+ </div>
+ );
+ }
+
+ // 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 (
+ <FormItem className="w-1/2">
+ <FormLabel>{t("labels.class")}</FormLabel>
+ <FormControl>
+ <Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ key={buttonId}
+ type="button"
+ variant="outline"
+ className="w-full justify-between relative h-9"
+ disabled={isLoadingClasses}
+ >
+ {isLoadingClasses ? (
+ <>
+ <span>{t("messages.loadingClasses")}</span>
+ <Loader2 className="ml-2 h-4 w-4 animate-spin" />
+ </>
+ ) : (
+ <>
+ <span className="truncate mr-1 flex-grow text-left">
+ {field.value || t("placeholders.selectClass")}
+ </span>
+ <ChevronsUpDown className="h-4 w-4 opacity-50 flex-shrink-0" />
+ </>
+ )}
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent key={popoverContentId} className="w-[300px] p-0">
+ <Command key={commandId}>
+ <CommandInput
+ key={`${commandId}-input`}
+ placeholder={t("placeholders.searchClass")}
+ />
+ <CommandList key={`${commandId}-list`} className="max-h-[300px]">
+ <CommandEmpty key={`${commandId}-empty`}>{t("messages.noSearchResults")}</CommandEmpty>
+ <CommandGroup key={`${commandId}-group`}>
+ {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 (
+ <CommandItem
+ key={`${optionId}-${optIndex}`}
+ onSelect={() => {
+ field.onChange(className)
+ setPopoverOpen(false)
+ handleSelectClass(className)
+ }}
+ value={className}
+ className="truncate"
+ title={className}
+ >
+ <span className="truncate">{className}</span>
+ <Check
+ key={`${optionId}-check`}
+ className={cn(
+ "ml-auto h-4 w-4 flex-shrink-0",
+ field.value === className ? "opacity-100" : "opacity-0"
+ )}
+ />
+ </CommandItem>
+ )
+ })}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )
+ }
+
+ // ---------------
+ // 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 (
+ <FormItem className="w-1/2">
+ <FormLabel>{t("labels.tagType")}</FormLabel>
+ <FormControl>
+ {isReadOnly ? (
+ <div className="relative">
+ <Input
+ key={`tag-type-readonly-${inputId}`}
+ {...field}
+ readOnly
+ className="h-9 bg-muted"
+ />
+ </div>
+ ) : (
+ <Input
+ key={`tag-type-placeholder-${inputId}`}
+ {...field}
+ readOnly
+ placeholder={t("placeholders.autoSetByClass")}
+ className="h-9 bg-muted"
+ />
+ )}
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )
+ }
+
+ // ---------------
+ // Render the table of subfields
+ // ---------------
+ function renderTagTable() {
+ if (isLoadingSubFields) {
+ return (
+ <div className="flex justify-center items-center py-8">
+ <Loader2 className="h-8 w-8 animate-spin text-primary" />
+ <div className="ml-3 text-muted-foreground">{t("messages.loadingFields")}</div>
+ </div>
+ )
+ }
+
+ if (subFields.length === 0 && selectedTagTypeCode) {
+ return (
+ <div className="py-4 text-center text-muted-foreground">
+ {t("messages.noFieldsForTagType")}
+ </div>
+ )
+ }
+
+ if (subFields.length === 0) {
+ return (
+ <div className="py-4 text-center text-muted-foreground">
+ {t("messages.selectClassFirst")}
+ </div>
+ )
+ }
+
+ return (
+ <div className="space-y-4">
+ {/* 헤더 */}
+ <div className="flex justify-between items-center">
+ <h3 className="text-sm font-medium">{t("sections.tagItems")} ({fields.length}개)</h3>
+ {!areAllTagNosValid && (
+ <Badge variant="destructive" className="ml-2">
+ {t("messages.invalidTagsExist")}
+ </Badge>
+ )}
+ </div>
+
+ {/* 테이블 컨테이너 - 가로/세로 스크롤 모두 적용 */}
+ <div className="border rounded-md overflow-auto" style={{ maxHeight: '400px', maxWidth: '100%' }}>
+ <div className="min-w-full overflow-x-auto">
+ <Table className="w-full table-fixed">
+ <TableHeader className="sticky top-0 bg-muted z-10">
+ <TableRow>
+ <TableHead className="w-10 text-center">#</TableHead>
+ <TableHead className="w-[120px]">
+ <div className="font-medium">{t("labels.tagNo")}</div>
+ </TableHead>
+ <TableHead className="w-[180px]">
+ <div className="font-medium">{t("labels.description")}</div>
+ </TableHead>
+
+ {/* Subfields */}
+ {subFields.map((field, fieldIndex) => (
+ <TableHead
+ key={`header-${field.name}-${fieldIndex}`}
+ className="w-[120px]"
+ >
+ <div className="flex flex-col">
+ <div className="font-medium" title={field.label}>
+ {field.label}
+ </div>
+ {field.expression && (
+ <div className="text-[10px] text-muted-foreground truncate" title={field.expression}>
+ {field.expression}
+ </div>
+ )}
+ </div>
+ </TableHead>
+ ))}
+
+ <TableHead className="w-[100px] text-center sticky right-0 bg-muted">{t("labels.actions")}</TableHead>
+ </TableRow>
+ </TableHeader>
+
+ <TableBody>
+ {fields.map((item, rowIndex) => (
+ <TableRow
+ key={`row-${item.id}-${rowIndex}`}
+ className={rowIndex % 2 === 0 ? "bg-background" : "bg-muted/20"}
+ >
+ {/* Row number */}
+ <TableCell className="text-center text-muted-foreground font-mono">
+ {rowIndex + 1}
+ </TableCell>
+
+ {/* Tag No cell */}
+ <TableCell className="p-1">
+ <FormField
+ control={form.control}
+ name={`rows.${rowIndex}.tagNo`}
+ render={({ field }) => (
+ <FormItem className="m-0 space-y-0">
+ <FormControl>
+ <div className="relative">
+ <Input
+ {...field}
+ readOnly
+ className={cn(
+ "bg-muted h-8 w-full font-mono text-sm",
+ field.value?.includes("??") && "border-red-500 bg-red-50"
+ )}
+ title={field.value || ""}
+ />
+ {field.value?.includes("??") && (
+ <div className="absolute right-2 top-1/2 transform -translate-y-1/2">
+ <Badge variant="destructive" className="text-xs">
+ !
+ </Badge>
+ </div>
+ )}
+ </div>
+ </FormControl>
+ </FormItem>
+ )}
+ />
+ </TableCell>
+
+ {/* Description cell */}
+ <TableCell className="p-1">
+ <FormField
+ control={form.control}
+ name={`rows.${rowIndex}.description`}
+ render={({ field }) => (
+ <FormItem className="m-0 space-y-0">
+ <FormControl>
+ <Input
+ {...field}
+ className="h-8 w-full"
+ placeholder={t("placeholders.enterDescription")}
+ title={field.value || ""}
+ />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+ </TableCell>
+
+ {/* Subfield cells */}
+ {subFields.map((sf, sfIndex) => (
+ <TableCell
+ key={`cell-${item.id}-${rowIndex}-${sf.name}-${sfIndex}`}
+ className="p-1"
+ >
+ <FormField
+ control={form.control}
+ name={`rows.${rowIndex}.${sf.name}`}
+ render={({ field }) => (
+ <FormItem className="m-0 space-y-0">
+ <FormControl>
+ {sf.type === "select" ? (
+ <Select
+ value={field.value || ""}
+ onValueChange={field.onChange}
+ >
+ <SelectTrigger
+ className="w-full h-8 truncate"
+ title={field.value || ""}
+ >
+ <SelectValue placeholder={`선택...`} className="truncate" />
+ </SelectTrigger>
+ <SelectContent
+ align="start"
+ side="bottom"
+ className="max-h-[200px]"
+ style={{ minWidth: "250px", maxWidth: "350px" }}
+ >
+ {sf.options?.map((opt, index) => (
+ <SelectItem
+ key={`${rowIndex}-${sf.name}-${opt.value}-${index}`}
+ value={opt.value}
+ title={opt.label}
+ className="whitespace-normal py-2 break-words"
+ >
+ {opt.value} - {opt.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ ) : (
+ <Input
+ {...field}
+ className="h-8 w-full"
+ placeholder={`입력...`}
+ title={field.value || ""}
+ />
+ )}
+ </FormControl>
+ </FormItem>
+ )}
+ />
+ </TableCell>
+ ))}
+
+ {/* Actions cell */}
+ <TableCell className="p-1 sticky right-0 bg-white shadow-[-4px_0_4px_rgba(0,0,0,0.05)]">
+ <div className="flex justify-center space-x-1">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="h-7 w-7"
+ onClick={() => duplicateRow(rowIndex)}
+ >
+ <Copy className="h-3.5 w-3.5 text-muted-foreground" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent side="left">
+ <p>{t("tooltips.duplicateRow")}</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className={cn(
+ "h-7 w-7",
+ fields.length <= 1 && "opacity-50"
+ )}
+ onClick={() => fields.length > 1 && remove(rowIndex)}
+ disabled={fields.length <= 1}
+ >
+ <Trash2 className="h-3.5 w-3.5 text-red-500" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent side="left">
+ <p>{t("tooltips.deleteRow")}</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </div>
+
+ {/* 행 추가 버튼 */}
+ <Button
+ type="button"
+ variant="outline"
+ className="w-full border-dashed"
+ onClick={addRow}
+ disabled={!selectedTagTypeCode || isLoadingSubFields}
+ >
+ <Plus className="h-4 w-4 mr-2" />
+ {t("buttons.addRow")}
+ </Button>
+ </div>
+ </div>
+ );
+ }
+
+ // ---------------
+ // Reset IDs/states when dialog closes
+ // ---------------
+ React.useEffect(() => {
+ if (!isOpen) {
+ fieldIdsRef.current = {}
+ classOptionIdsRef.current = {}
+ selectIdRef.current = 0
+ }
+ }, [isOpen])
+
+ return (
+ <Dialog
+ open={isOpen}
+ onOpenChange={(o) => {
+ 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 && (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Plus className="mr-2 size-4" />
+ {t("buttons.addTags")}
+ </Button>
+ </DialogTrigger>
+ )}
+
+ <DialogContent className="max-h-[90vh] max-w-[95vw]" style={{ width: 1500 }}>
+ <DialogHeader>
+ <DialogTitle>{t("dialogs.addFormTag")} - {formName || formCode}</DialogTitle>
+ <DialogDescription>
+ {t("dialogs.selectClassToLoadFields")}
+ </DialogDescription>
+ </DialogHeader>
+
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="space-y-6"
+ >
+ {/* 클래스 및 태그 유형 선택 */}
+ <div className="flex gap-4">
+ <FormField
+ key="class-field"
+ control={form.control}
+ name="class"
+ render={({ field }) => renderClassField(field)}
+ />
+
+ <FormField
+ key="tag-type-field"
+ control={form.control}
+ name="tagType"
+ render={({ field }) => renderTagTypeField(field)}
+ />
+ </div>
+
+ {/* 태그 테이블 */}
+ {renderTagTable()}
+
+ {/* 버튼 */}
+ <DialogFooter>
+ <div className="flex items-center gap-2">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => {
+ form.reset({
+ tagType: "",
+ class: "",
+ rows: [{ tagNo: "", description: "" }]
+ });
+ setIsOpen(false);
+ setSubFields([]);
+ setSelectedTagTypeLabel(null);
+ setSelectedTagTypeCode(null);
+ }}
+ disabled={isSubmitting}
+ >
+ {t("buttons.cancel")}
+ </Button>
+ <Button
+ type="submit"
+ disabled={isSubmitting || !areAllTagNosValid || fields.length < 1}
+ >
+ {isSubmitting ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ {t("messages.processing")}
+ </>
+ ) : (
+ `${fields.length} ${t("buttons.create")}`
+ )}
+ </Button>
+ </div>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ 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<typeof Dialog> {
+ formData: GenericData[]
+ formCode: string
+ contractItemId: number
+ showTrigger?: boolean
+ onSuccess?: () => void
+ triggerVariant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"
+}
+
+export function DeleteFormDataDialog({
+ formData,
+ formCode,
+ contractItemId,
+ showTrigger = true,
+ onSuccess,
+ triggerVariant = "outline",
+ ...props
+}: DeleteFormDataDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ 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 (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button
+ variant={triggerVariant}
+ size="sm"
+ disabled={!hasValidItems}
+ >
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ {t("buttons.delete")} ({itemCount})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>{t("delete.confirmTitle")}</DialogTitle>
+ <DialogDescription>
+ {t("delete.confirmDescription", {
+ count: itemCount,
+ items: itemCount === 1 ? t("delete.item") : t("delete.items")
+ })}
+ {itemCount > 0 && (
+ <>
+ <br />
+ <br />
+ <span className="text-sm text-muted-foreground">
+ {t("delete.tagNumbers")}: {tagIdxs.slice(0, 3).join(", ")}
+ {tagIdxs.length > 3 && t("delete.andMore", { count: tagIdxs.length - 3 })}
+ </span>
+ </>
+ )}
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">{t("buttons.cancel")}</Button>
+ </DialogClose>
+ <Button
+ aria-label={t("delete.deleteButtonLabel")}
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending || !hasValidItems}
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ {t("buttons.delete")}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button
+ variant={triggerVariant}
+ size="sm"
+ disabled={!hasValidItems}
+ >
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ {t("buttons.delete")} ({itemCount})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>{t("delete.confirmTitle")}</DrawerTitle>
+ <DrawerDescription>
+ {t("delete.confirmDescription", {
+ count: itemCount,
+ items: itemCount === 1 ? t("delete.item") : t("delete.items")
+ })}
+ {itemCount > 0 && (
+ <>
+ <br />
+ <br />
+ <span className="text-sm text-muted-foreground">
+ {t("delete.tagNumbers")}: {tagIdxs.slice(0, 3).join(", ")}
+ {tagIdxs.length > 3 && t("delete.andMore", { count: tagIdxs.length - 3 })}
+ </span>
+ </>
+ )}
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">{t("buttons.cancel")}</Button>
+ </DrawerClose>
+ <Button
+ aria-label={t("delete.deleteButtonLabel")}
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending || !hasValidItems}
+ >
+ {isDeletePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ {t("buttons.delete")}
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ 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<string, string[]>; // 새로 추가
+ 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<string, string[]>
+): 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, string[]>
+): 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<string>();
+
+ 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<ExportExcelResult> {
+ 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<string, string>();
+
+ 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<SetStateAction<boolean>>;
+ columnsJSON: DataTableColumnJSON[];
+ reportData: ReportData[];
+ packageId: number;
+ formId: number;
+ formCode: string;
+}
+
+export const FormDataReportBatchDialog: FC<FormDataReportBatchDialogProps> = ({
+ 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<tempFile[]>([]);
+ const [selectTemp, setSelectTemp] = useState<string>("");
+ const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
+ const [isUploading, setIsUploading] = useState(false);
+
+ // Add new state for publish dialog
+ const [publishDialogOpen, setPublishDialogOpen] = useState<boolean>(false);
+ const [generatedFileBlob, setGeneratedFileBlob] = useState<Blob | null>(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 (
+ <>
+ <Dialog open={open} onOpenChange={onClose}>
+ <DialogContent className="w-[600px]" style={{ maxWidth: "none" }}>
+ <DialogHeader>
+ <DialogTitle>{t("batchReport.dialogTitle")}</DialogTitle>
+ <DialogDescription>
+ {t("batchReport.dialogDescription")}
+ </DialogDescription>
+ </DialogHeader>
+ <div className="h-[60px]">
+ <Label>{t("batchReport.templateSelectLabel")}</Label>
+ <Select value={selectTemp} onValueChange={setSelectTemp}>
+ <SelectTrigger className="w-[100%]">
+ <SelectValue placeholder={t("batchReport.templateSelectPlaceholder")} />
+ </SelectTrigger>
+ <SelectContent>
+ {tempList.map((c) => {
+ const { fileName, filePath } = c;
+
+ return (
+ <SelectItem key={filePath} value={filePath}>
+ {fileName}
+ </SelectItem>
+ );
+ })}
+ </SelectContent>
+ </Select>
+ </div>
+ <div>
+ <Label>{t("batchReport.coverPageUploadLabel")}</Label>
+ <Dropzone
+ maxSize={MAX_FILE_SIZE}
+ multiple={false}
+ accept={{ accept: [".docx"] }}
+ onDropAccepted={handleDropAccepted}
+ onDropRejected={handleDropRejected}
+ disabled={isUploading}
+ >
+ {({ maxSize }) => (
+ <>
+ <DropzoneZone className="flex justify-center">
+ <DropzoneInput />
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>{t("batchReport.dropFileHere")}</DropzoneTitle>
+ <DropzoneDescription>
+ {t("batchReport.orClickToSelect", {
+ maxSize: maxSize ? prettyBytes(maxSize) : t("batchReport.unlimited")
+ })}
+ </DropzoneDescription>
+ </div>
+ </div>
+ </DropzoneZone>
+ <Label className="text-xs text-muted-foreground">
+ {t("batchReport.multipleFilesAllowed")}
+ </Label>
+ </>
+ )}
+ </Dropzone>
+ </div>
+
+ {selectedFiles.length > 0 && (
+ <div className="grid gap-2">
+ <div className="flex items-center justify-between">
+ <h6 className="text-sm font-semibold">
+ {t("batchReport.selectedFiles", { count: selectedFiles.length })}
+ </h6>
+ <Badge variant="secondary">
+ {t("batchReport.fileCount", { count: selectedFiles.length })}
+ </Badge>
+ </div>
+ <ScrollArea>
+ <UploadFileItem
+ selectedFiles={selectedFiles}
+ removeFile={removeFile}
+ isUploading={isUploading}
+ t={t}
+ />
+ </ScrollArea>
+ </div>
+ )}
+
+ <DialogFooter>
+ {/* Add the new Publish button */}
+ <Button
+ onClick={prepareFileForPublishing}
+ disabled={
+ selectedFiles.length === 0 ||
+ selectTemp.length === 0 ||
+ isUploading
+ }
+ variant="outline"
+ className="mr-2"
+ >
+ {isUploading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ {t("batchReport.publish")}
+ </Button>
+ <Button
+ disabled={
+ selectedFiles.length === 0 ||
+ selectTemp.length === 0 ||
+ isUploading
+ }
+ onClick={submitData}
+ >
+ {isUploading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ {t("batchReport.createDocument")}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ {/* Add the PublishDialog component */}
+ <PublishDialog
+ open={publishDialogOpen}
+ onOpenChange={setPublishDialogOpen}
+ packageId={packageId}
+ formCode={formCode}
+ fileBlob={generatedFileBlob || undefined}
+ />
+ </>
+ );
+};
+
+interface UploadFileItemProps {
+ selectedFiles: File[];
+ removeFile: (index: number) => void;
+ isUploading: boolean;
+ t: (key: string, options?: any) => string;
+}
+
+const UploadFileItem: FC<UploadFileItemProps> = ({
+ selectedFiles,
+ removeFile,
+ isUploading,
+ t,
+}) => {
+ return (
+ <FileList className="max-h-[200px] gap-3">
+ {selectedFiles.map((file, index) => (
+ <FileListItem key={index} className="p-3">
+ <FileListHeader>
+ <FileListIcon />
+ <FileListInfo>
+ <FileListName>{file.name}</FileListName>
+ <FileListDescription>
+ {prettyBytes(file.size)}
+ </FileListDescription>
+ </FileListInfo>
+ <FileListAction
+ onClick={() => removeFile(index)}
+ disabled={isUploading}
+ >
+ <X className="h-4 w-4" />
+ <span className="sr-only">{t("batchReport.remove")}</span>
+ </FileListAction>
+ </FileListHeader>
+ </FileListItem>
+ ))}
+ </FileList>
+ );
+};
+
+type UpdateReportTempList = (
+ packageId: number,
+ formId: number,
+ setPrevReportTemp: Dispatch<SetStateAction<tempFile[]>>
+) => 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<SetStateAction<ReportData[]>>;
+ packageId: number;
+ formId: number;
+ formCode: string;
+}
+
+export const FormDataReportDialog: FC<FormDataReportDialogProps> = ({
+ 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<tempFile[]>([]);
+ const [selectTemp, setSelectTemp] = useState<string>("");
+ const [instance, setInstance] = useState<null | WebViewerInstance>(null);
+ const [fileLoading, setFileLoading] = useState<boolean>(true);
+
+ // Add new state for publish dialog
+ const [publishDialogOpen, setPublishDialogOpen] = useState<boolean>(false);
+ const [generatedFileBlob, setGeneratedFileBlob] = useState<Blob | null>(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 (
+ <>
+ <Dialog open={reportData.length > 0} onOpenChange={onClose}>
+ <DialogContent className="w-[70vw]" style={{ maxWidth: "none" }}>
+ <DialogHeader>
+ <DialogTitle>{t("singleReport.dialogTitle")}</DialogTitle>
+ <DialogDescription>
+ {t("singleReport.dialogDescription")}
+ </DialogDescription>
+ </DialogHeader>
+ <div className="h-[60px]">
+ <Label>{t("singleReport.templateSelectLabel")}</Label>
+ <Select
+ value={selectTemp}
+ onValueChange={setSelectTemp}
+ disabled={instance === null}
+ >
+ <SelectTrigger className="w-[100%]">
+ <SelectValue placeholder={t("singleReport.templateSelectPlaceholder")} />
+ </SelectTrigger>
+ <SelectContent>
+ {tempList.map((c) => {
+ const { fileName, filePath } = c;
+
+ return (
+ <SelectItem key={filePath} value={filePath}>
+ {fileName}
+ </SelectItem>
+ );
+ })}
+ </SelectContent>
+ </Select>
+ </div>
+ <div className="h-[calc(70vh-60px)]">
+ <ReportWebViewer
+ columnsJSON={columnsJSON}
+ reportTempPath={selectTemp}
+ reportDatas={reportData}
+ instance={instance}
+ setInstance={setInstance}
+ setFileLoading={setFileLoading}
+ formCode={formCode}
+ t={t}
+ />
+ </div>
+
+ <DialogFooter>
+ {/* Add the new Publish button */}
+ <Button
+ onClick={prepareFileForPublishing}
+ disabled={selectTemp.length === 0}
+ variant="outline"
+ className="mr-2"
+ >
+ {t("singleReport.publish")}
+ </Button>
+ <Button onClick={downloadFileData} disabled={selectTemp.length === 0}>
+ {t("singleReport.createDocument")}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ {/* Add the PublishDialog component */}
+ <PublishDialog
+ open={publishDialogOpen}
+ onOpenChange={setPublishDialogOpen}
+ packageId={packageId}
+ formCode={formCode}
+ fileBlob={generatedFileBlob || undefined}
+ />
+ </>
+ );
+};
+
+// Keep the rest of the component as is...
+interface ReportWebViewerProps {
+ columnsJSON: DataTableColumnJSON[];
+ reportTempPath: string;
+ reportDatas: ReportData[];
+ instance: null | WebViewerInstance;
+ setInstance: Dispatch<SetStateAction<WebViewerInstance | null>>;
+ setFileLoading: Dispatch<SetStateAction<boolean>>;
+ formCode: string;
+ t: (key: string, options?: any) => string;
+}
+
+const ReportWebViewer: FC<ReportWebViewerProps> = ({
+ columnsJSON,
+ reportTempPath,
+ reportDatas,
+ instance,
+ setInstance,
+ setFileLoading,
+ formCode,
+ t,
+}) => {
+ const [viwerLoading, setViewerLoading] = useState<boolean>(true);
+ const viewer = useRef<HTMLDivElement>(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 (
+ <div ref={viewer} className="h-[100%]">
+ {viwerLoading && (
+ <div className="flex flex-col items-center justify-center py-12">
+ <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" />
+ <p className="text-sm text-muted-foreground">{t("singleReport.documentViewerLoading")}</p>
+ </div>
+ )}
+ </div>
+ );
+};
+
+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<SetStateAction<boolean>>,
+ 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<SetStateAction<tempFile[]>>
+) => 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<SetStateAction<boolean>>;
+ 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 (
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogContent className="w-[600px]" style={{ maxWidth: "none" }}>
+ <DialogHeader className="gap-2">
+ <DialogTitle>{t("templateUpload.dialogTitle")}</DialogTitle>
+ <DialogDescription className="flex justify-around gap-[16px] ">
+ <FileActionsDropdown
+ filePath={"/vendorFormReportSample/sample_template_file.docx"}
+ fileName={"sample_template_file.docx"}
+ variant="ghost"
+ size="icon"
+ description={t("templateUpload.sampleFile")}
+ />
+ <VarListDownloadBtn columnsJSON={columnsJSON} formCode={formCode} />
+ </DialogDescription>
+ </DialogHeader>
+ <Tabs value={tabValue}>
+ <div className="flex justify-between items-center">
+ <TabsList className="w-full">
+ <TabsTrigger
+ value="upload"
+ onClick={() => setTabValue("upload")}
+ className="flex-1"
+ >
+ {t("templateUpload.uploadTab")}
+ </TabsTrigger>
+ <TabsTrigger
+ value="uploaded"
+ onClick={() => setTabValue("uploaded")}
+ className="flex-1"
+ >
+ {t("templateUpload.uploadedListTab")}
+ </TabsTrigger>
+ </TabsList>
+ </div>
+ <TabsContent value="upload">
+ <FormDataReportTempUploadTab
+ packageId={packageId}
+ formId={formId}
+ uploaderType={uploaderType}
+ />
+ </TabsContent>
+ <TabsContent value="uploaded">
+ <FormDataReportTempUploadedListTab
+ packageId={packageId}
+ formId={formId}
+ />
+ </TabsContent>
+ </Tabs>
+ </DialogContent>
+ </Dialog>
+ );
+}; \ 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<File[]>([]);
+ 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 (
+ <div className='flex flex-col gap-4'>
+ <div>
+ <Label>{t("templateUploadTab.uploadLabel")}</Label>
+ <Dropzone
+ maxSize={MAX_FILE_SIZE}
+ multiple={true}
+ accept={{ accept: [".docx"] }}
+ onDropAccepted={handleDropAccepted}
+ onDropRejected={handleDropRejected}
+ disabled={isUploading}
+ >
+ {({ maxSize }) => (
+ <>
+ <DropzoneZone className="flex justify-center">
+ <DropzoneInput />
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>{t("templateUploadTab.dropFileHere")}</DropzoneTitle>
+ <DropzoneDescription>
+ {t("templateUploadTab.orClickToSelect", {
+ maxSize: maxSize ? prettyBytes(maxSize) : t("templateUploadTab.unlimited")
+ })}
+ </DropzoneDescription>
+ </div>
+ </div>
+ </DropzoneZone>
+ <Label className="text-xs text-muted-foreground">
+ {t("templateUploadTab.multipleFilesAllowed")}
+ </Label>
+ </>
+ )}
+ </Dropzone>
+ </div>
+
+ {selectedFiles.length > 0 && (
+ <div className="grid gap-2">
+ <div className="flex items-center justify-between">
+ <h6 className="text-sm font-semibold">
+ {t("templateUploadTab.selectedFiles", { count: selectedFiles.length })}
+ </h6>
+ <Badge variant="secondary">
+ {t("templateUploadTab.fileCount", { count: selectedFiles.length })}
+ </Badge>
+ </div>
+ <ScrollArea>
+ <UploadFileItem
+ selectedFiles={selectedFiles}
+ removeFile={removeFile}
+ isUploading={isUploading}
+ t={t}
+ />
+ </ScrollArea>
+ </div>
+ )}
+
+ {isUploading && <UploadProgressBox uploadProgress={uploadProgress} t={t} />}
+ <DialogFooter>
+ <Button disabled={selectedFiles.length === 0} onClick={submitData}>
+ {t("templateUploadTab.upload")}
+ </Button>
+ </DialogFooter>
+ </div>
+ );
+};
+
+interface UploadFileItemProps {
+ selectedFiles: File[];
+ removeFile: (index: number) => void;
+ isUploading: boolean;
+ t: (key: string, options?: any) => string;
+}
+
+const UploadFileItem: FC<UploadFileItemProps> = ({
+ selectedFiles,
+ removeFile,
+ isUploading,
+ t,
+}) => {
+ return (
+ <FileList className="max-h-[150px] gap-3">
+ {selectedFiles.map((file, index) => (
+ <FileListItem key={index} className="p-3">
+ <FileListHeader>
+ <FileListIcon />
+ <FileListInfo>
+ <FileListName>{file.name}</FileListName>
+ <FileListDescription>
+ {prettyBytes(file.size)}
+ </FileListDescription>
+ </FileListInfo>
+ <FileListAction
+ onClick={() => removeFile(index)}
+ disabled={isUploading}
+ >
+ <X className="h-4 w-4" />
+ <span className="sr-only">{t("templateUploadTab.remove")}</span>
+ </FileListAction>
+ </FileListHeader>
+ </FileListItem>
+ ))}
+ </FileList>
+ );
+};
+
+const UploadProgressBox: FC<{
+ uploadProgress: number;
+ t: (key: string, options?: any) => string;
+}> = ({ uploadProgress, t }) => {
+ return (
+ <div className="flex flex-col gap-1 mt-2">
+ <div className="flex items-center gap-2">
+ <Loader2 className="h-4 w-4 animate-spin" />
+ <span className="text-sm">
+ {t("templateUploadTab.uploadingProgress", { progress: uploadProgress })}
+ </span>
+ </div>
+ <div className="h-2 w-full bg-muted rounded-full overflow-hidden">
+ <div
+ className="h-full bg-primary rounded-full transition-all"
+ style={{ width: `${uploadProgress}%` }}
+ />
+ </div>
+ </div>
+ );
+}; \ 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<VendorDataReportTemps[]>(
+ []
+ );
+ const [isLoading, setIsLoading] = useState(true);
+
+ useEffect(() => {
+ const getTempFiles = async () => {
+ await updateReportTempList(packageId, formId, setPrevReportTemp);
+ setIsLoading(false);
+ };
+
+ getTempFiles();
+ }, [packageId, formId]);
+
+ return (
+ <div>
+ <Label>{t("templateUploadedList.listLabel")}</Label>
+ <UploadedTempFiles
+ prevReportTemp={prevReportTemp}
+ updateReportTempList={() =>
+ updateReportTempList(packageId, formId, setPrevReportTemp)
+ }
+ isLoading={isLoading}
+ t={t}
+ />
+ </div>
+ );
+};
+
+type UpdateReportTempList = (
+ packageId: number,
+ formId: number,
+ setPrevReportTemp: Dispatch<SetStateAction<VendorDataReportTemps[]>>
+) => Promise<void>;
+
+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<UploadedTempFiles> = ({
+ 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 (
+ <div className="min-h-[157px]">
+ <Label>{t("templateUploadedList.loading")}</Label>
+ </div>
+ );
+ }
+
+ return (
+ <ScrollArea className="min-h-[157px] max-h-[337px] overflow-auto">
+ <FileList className="gap-3">
+ {prevReportTemp.map((c) => {
+ const { fileName, filePath, id } = c;
+
+ return (
+ <AlertDialog key={id}>
+ <FileListItem className="p-3">
+ <FileListHeader>
+ <FileListIcon />
+ <FileListInfo>
+ <FileListName>{fileName}</FileListName>
+ </FileListInfo>
+ <FileListAction
+ onClick={() => {
+ downloadTempFile(fileName, filePath);
+ }}
+ >
+ <Download className="h-4 w-4" />
+ <span className="sr-only">{t("templateUploadedList.download")}</span>
+ </FileListAction>
+ <AlertDialogTrigger asChild>
+ <FileListAction>
+ <Trash2 className="h-4 w-4" />
+ <span className="sr-only">{t("templateUploadedList.delete")}</span>
+ </FileListAction>
+ </AlertDialogTrigger>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>
+ {t("templateUploadedList.deleteConfirmTitle", { fileName })}
+ </AlertDialogTitle>
+ <AlertDialogDescription />
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel>{t("templateUploadedList.cancel")}</AlertDialogCancel>
+ <AlertDialogAction
+ onClick={() => {
+ deleteTempFile(id);
+ }}
+ >
+ {t("templateUploadedList.delete")}
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </FileListHeader>
+ </FileListItem>
+ </AlertDialog>
+ );
+ })}
+ </FileList>
+ </ScrollArea>
+ );
+}; \ 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<TData> {
+ row: Row<TData>;
+ 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<TData> {
+ columnsJSON: DataTableColumnJSON[];
+ setRowAction: React.Dispatch<
+ React.SetStateAction<DataTableRowAction<TData> | null>
+ >;
+ setReportData: React.Dispatch<React.SetStateAction<{ [key: string]: any }[]>>;
+ tempCount: number;
+ // 체크박스 선택 관련 props
+ selectedRows?: Record<string, boolean>;
+ onRowSelectionChange?: (updater: Record<string, boolean> | ((prev: Record<string, boolean>) => Record<string, boolean>)) => 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<string, string>(); // 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 (
+ <span>
+ {baseText}
+ <span style={{ color: 'red', marginLeft: '2px' }}>*</span>
+ </span>
+ );
+ }
+
+ return baseText;
+}
+
+/**
+ * 컬럼들을 seq 순서대로 배치하면서 연속된 같은 head를 가진 컬럼들을 그룹으로 묶는 함수
+ */
+function groupColumnsByHead(columns: DataTableColumnJSON[], registers?: Register[]): ColumnDef<any>[] {
+ const result: ColumnDef<any>[] = [];
+ 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<any> = {
+ 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<any> {
+ const isRequired = isRequiredField(col.key, registers);
+
+ return {
+ accessorKey: col.key,
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple
+ column={column}
+ title={getHeaderText(col, isRequired)}
+ />
+ ),
+
+ 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 (
+ <div
+ className={combinedClass}
+ style={cellStyle}
+ title={tooltipMessage}
+ >
+ <Badge variant={badgeVariant}>
+ {statusValue}
+ </Badge>
+ </div>
+ );
+ }
+
+ // 데이터 타입별 처리
+ switch (col.type) {
+ case "NUMBER":
+ return (
+ <div
+ className={combinedClass}
+ style={cellStyle}
+ title={tooltipMessage}
+ >
+ {cellValue ? Number(cellValue).toLocaleString() : ""}
+ </div>
+ );
+
+ case "LIST":
+ return (
+ <div
+ className={combinedClass}
+ style={cellStyle}
+ title={tooltipMessage}
+ >
+ {String(cellValue ?? "")}
+ </div>
+ );
+
+ case "STRING":
+ default:
+ return (
+ <div
+ className={combinedClass}
+ style={cellStyle}
+ title={tooltipMessage}
+ >
+ {String(cellValue ?? "")}
+ </div>
+ );
+ }
+ },
+ };
+}
+
+/**
+ * getColumns 함수
+ * 1) columnsJSON 배열을 필터링 (hidden이 true가 아닌 것들만)
+ * 2) seq에 따라 정렬
+ * 3) seq 순서를 유지하면서 연속된 같은 head를 가진 컬럼들을 그룹으로 묶기
+ * 4) 체크박스 컬럼 추가
+ * 5) 마지막에 "Action" 칼럼 추가
+ */
+export function getColumns<TData extends object>({
+ columnsJSON,
+ setRowAction,
+ setReportData,
+ tempCount,
+ selectedRows = {},
+ onRowSelectionChange,
+ templateData, // 새로 추가된 매개변수
+ registers, // 필수 필드 체크를 위한 레지스터 데이터
+}: GetColumnsProps<TData>): ColumnDef<TData>[] {
+ const columns: ColumnDef<TData>[] = [];
+
+ // (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<TData> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => {
+ table.toggleAllPageRowsSelected(!!value);
+
+ // 모든 행 선택/해제
+ if (onRowSelectionChange) {
+ const allRowsSelection: Record<string, boolean> = {};
+ table.getRowModel().rows.forEach((row) => {
+ allRowsSelection[row.id] = !!value;
+ });
+ onRowSelectionChange(allRowsSelection);
+ }
+ }}
+ aria-label="Select all"
+ className="translate-y-[2px]"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => {
+ 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<TData> = {
+ id: "update",
+ header: "",
+ cell: ({ row }) => (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem
+ onSelect={() => {
+ setRowAction({ row, type: "update" });
+ }}
+ >
+ Edit
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onSelect={() => {
+ if(tempCount > 0){
+ const { original } = row;
+ setReportData([original]);
+ } else {
+ toast.error("업로드된 Template File이 없습니다.");
+ }
+ }}
+ >
+ Create Document
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onSelect={() => {
+ setRowAction({ row, type: "delete" });
+ }}
+ className="text-red-600 focus:text-red-600"
+ >
+ Delete
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ ),
+ 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<string, string[]>; // 새로 추가
+}
+
+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<DataTableRowAction<GenericData> | null>(null);
+ const [tableData, setTableData] = React.useState<GenericData[]>(dataJSON);
+
+ // 배치 선택 관련 상태
+ const [selectedRowsData, setSelectedRowsData] = React.useState<GenericData[]>([]);
+ const [clearSelection, setClearSelection] = React.useState(false);
+ // 삭제 관련 상태 간소화
+ const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
+ const [deleteTarget, setDeleteTarget] = React.useState<GenericData[]>([]);
+
+ const [formStats, setFormStats] = React.useState<FormStatusByVendor | null>(null);
+ const [isLoadingStats, setIsLoadingStats] = React.useState(true);
+
+ const [activeFilter, setActiveFilter] = React.useState<string | null>(null);
+ const [filteredTableData, setFilteredTableData] = React.useState<GenericData[]>(tableData);
+ const [rowSelection, setRowSelection] = React.useState<Record<string, boolean>>({});
+
+ 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<NodeJS.Timeout | null>(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<string>('');
+ const [projectType, setProjectType] = React.useState<string>('plant');
+ const [packageCode, setPackageCode] = React.useState<string>('');
+
+ // 새로 추가된 Template 다이얼로그 상태
+ const [templateDialogOpen, setTemplateDialogOpen] = React.useState(false);
+ const [templateData, setTemplateData] = React.useState<unknown>(null);
+
+ const [tempUpDialog, setTempUpDialog] = React.useState(false);
+ const [reportData, setReportData] = React.useState<GenericData[]>([]);
+ const [batchDownDialog, setBatchDownDialog] = React.useState(false);
+ const [tempCount, setTempCount] = React.useState(0);
+ const [addTagDialogOpen, setAddTagDialogOpen] = React.useState(false);
+
+ const [registers, setRegisters] = React.useState<Register[]>([]);
+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<GenericData>["type"] {
+ switch (columnType) {
+ case "STRING":
+ return "text";
+ case "NUMBER":
+ return "number";
+ case "LIST":
+ return "select";
+ default:
+ return "text";
+ }
+ }
+
+ const advancedFilterFields = React.useMemo<
+ DataTableAdvancedFilterField<GenericData>[]
+ >(() => {
+ 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<HTMLInputElement>) {
+ 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 (
+ <>
+
+ <div className="mb-6">
+ <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
+ {/* Total Tags Card - 클릭 시 전체 보기 */}
+ <Card
+ className={`cursor-pointer transition-all ${activeFilter === null ? 'ring-2 ring-primary' : 'hover:shadow-lg'
+ }`}
+ onClick={() => handleCardClick(null)}
+ >
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">
+ Total Tags
+ </CardTitle>
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">
+ {isLoadingStats ? (
+ <span className="animate-pulse">-</span>
+ ) : (
+ formStats?.tagCount || 0
+ )}
+ </div>
+ <p className="text-xs text-muted-foreground">
+ {activeFilter === null ? 'Showing all' : 'Click to show all'}
+ </p>
+ </CardContent>
+ </Card>
+
+ {/* Completed Fields Card */}
+ <Card
+ className={`cursor-pointer transition-all ${activeFilter === 'completed' ? 'ring-2 ring-green-600' : 'hover:shadow-lg'
+ }`}
+ onClick={() => handleCardClick('completed')}
+ >
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">
+ Completed
+ </CardTitle>
+ <CheckCircle2 className="h-4 w-4 text-green-600" />
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold text-green-600">
+ {isLoadingStats ? (
+ <span className="animate-pulse">-</span>
+ ) : (
+ formStats?.completedFields || 0
+ )}
+ </div>
+ <p className="text-xs text-muted-foreground">
+ {activeFilter === 'completed' ? 'Filtering active' : 'Click to filter'}
+ </p>
+ </CardContent>
+ </Card>
+
+ {/* Remaining Fields Card */}
+ <Card
+ className={`cursor-pointer transition-all ${activeFilter === 'remaining' ? 'ring-2 ring-blue-600' : 'hover:shadow-lg'
+ }`}
+ onClick={() => handleCardClick('remaining')}
+ >
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">
+ Remaining
+ </CardTitle>
+ <Clock className="h-4 w-4 text-muted-foreground" />
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">
+ {isLoadingStats ? (
+ <span className="animate-pulse">-</span>
+ ) : (
+ (formStats?.totalFields || 0) - (formStats?.completedFields || 0)
+ )}
+ </div>
+ <p className="text-xs text-muted-foreground">
+ {activeFilter === 'remaining' ? 'Filtering active' : 'Click to filter'}
+ </p>
+ </CardContent>
+ </Card>
+
+ {/* Upcoming Card */}
+ <Card
+ className={`cursor-pointer transition-all ${activeFilter === 'upcoming' ? 'ring-2 ring-yellow-600' : 'hover:shadow-lg'
+ }`}
+ onClick={() => handleCardClick('upcoming')}
+ >
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">
+ Upcoming
+ </CardTitle>
+ <AlertCircle className="h-4 w-4 text-yellow-600" />
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold text-yellow-600">
+ {isLoadingStats ? (
+ <span className="animate-pulse">-</span>
+ ) : (
+ formStats?.upcomingCount || 0
+ )}
+ </div>
+ <p className="text-xs text-muted-foreground">
+ {activeFilter === 'upcoming' ? 'Filtering active' : 'Click to filter'}
+ </p>
+ </CardContent>
+ </Card>
+
+ {/* Overdue Card */}
+ <Card
+ className={`cursor-pointer transition-all ${activeFilter === 'overdue' ? 'ring-2 ring-red-600' : 'hover:shadow-lg'
+ }`}
+ onClick={() => handleCardClick('overdue')}
+ >
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">
+ Overdue
+ </CardTitle>
+ <AlertCircle className="h-4 w-4 text-red-600" />
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold text-red-600">
+ {isLoadingStats ? (
+ <span className="animate-pulse">-</span>
+ ) : (
+ formStats?.overdueCount || 0
+ )}
+ </div>
+ <p className="text-xs text-muted-foreground">
+ {activeFilter === 'overdue' ? 'Filtering active' : 'Click to filter'}
+ </p>
+ </CardContent>
+ </Card>
+ </div>
+ </div>
+
+
+ <ClientDataTable
+ data={filteredTableData} // tableData 대신 filteredTableData 사용
+ columns={columns}
+ advancedFilterFields={advancedFilterFields}
+ autoSizeColumns
+ onSelectedRowsChange={setSelectedRowsData}
+ clearSelection={clearSelection}
+ >
+ {/* 필터 상태 표시 */}
+ {activeFilter && (
+ <div className="flex items-center gap-2 mr-auto">
+ <span className="text-sm text-muted-foreground">
+ Filter: {activeFilter === 'completed' ? 'Completed' :
+ activeFilter === 'remaining' ? 'Remaining' :
+ activeFilter === 'upcoming' ? 'Upcoming (7 days)' :
+ activeFilter === 'overdue' ? 'Overdue' : 'All'}
+ </span>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => setActiveFilter(null)}
+ >
+ Clear filter
+ </Button>
+ </div>
+ )}
+ {/* 선택된 항목 수 표시 (선택된 항목이 있을 때만) */}
+ {selectedRowCount > 0 && (
+ <Button
+ variant="destructive"
+ size="sm"
+ onClick={handleBatchDelete}
+ >
+ <Trash2 className="mr-2 size-4" />
+ {t("buttons.delete")} ({selectedRowCount})
+ </Button>
+ )}
+
+ {/* 버튼 그룹 */}
+ <div className="flex items-center gap-2">
+
+ {selectedRowCount > 0 && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleExcludeTags}
+ disabled={isAnyOperationPending}
+ className="border-orange-500 text-orange-600 hover:bg-orange-50"
+ >
+ {isExcludingTags ? (
+ <Loader className="mr-2 size-4 animate-spin" />
+ ) : (
+ <XCircle className="mr-2 size-4" />
+ )}
+ {t("buttons.excludeTags")} ({selectedRowCount})
+ </Button>
+ )}
+ {/* 태그 관리 드롭다운 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="outline" size="sm" disabled={isAnyOperationPending}>
+ {(isSyncingTags || isLoadingTags) ? (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ ) :
+ <TagsIcon className="size-4" />}
+ {t("buttons.tagOperations")}
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ {/* 모드에 따라 다른 태그 작업 표시 */}
+ {mode === "IM" ? (
+ <DropdownMenuItem onClick={handleSyncTags} disabled={isAnyOperationPending}>
+ <Tag className="mr-2 h-4 w-4" />
+ {t("buttons.syncTags")}
+ </DropdownMenuItem>
+ ) : (
+ <DropdownMenuItem onClick={handleGetTags} disabled={isAnyOperationPending}>
+ <RefreshCcw className="mr-2 h-4 w-4" />
+ {t("buttons.getTags")}
+ </DropdownMenuItem>
+ )}
+ <DropdownMenuItem
+ onClick={() => setAddTagDialogOpen(true)}
+ disabled={isAnyOperationPending || isAddTagDisabled}
+ >
+ <Plus className="mr-2 h-4 w-4" />
+ {t("buttons.addTags")}
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+
+ {/* 리포트 관리 드롭다운 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="outline" size="sm" disabled={isAnyOperationPending}>
+ <Clipboard className="size-4" />
+ {t("buttons.reportOperations")}
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem onClick={() => setTempUpDialog(true)} disabled={isAnyOperationPending}>
+ <Upload className="mr-2 h-4 w-4" />
+ {t("buttons.uploadTemplate")}
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={handleBatchDocument} disabled={isAnyOperationPending}>
+ <FileOutput className="mr-2 h-4 w-4" />
+ {t("buttons.batchDocument")}
+ {selectedRowCount > 0 && (
+ <span className="ml-2 text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded">
+ {selectedRowCount}
+ </span>
+ )}
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+
+ {/* IMPORT 버튼 (파일 선택) */}
+ <Button asChild variant="outline" size="sm" disabled={isAnyOperationPending}>
+ <label>
+ {isImporting ? (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ ) : (
+ <Upload className="size-4" />
+ )}
+ {t("buttons.import")}
+ <input
+ type="file"
+ accept=".xlsx,.xls"
+ onChange={handleImportExcel}
+ style={{ display: "none" }}
+ disabled={isAnyOperationPending}
+ />
+ </label>
+ </Button>
+
+ {/* EXPORT 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleExportExcel}
+ disabled={isAnyOperationPending}
+ >
+ {isExporting ? (
+ <Loader className="mr-2 size-4 animate-spin" />
+ ) : (
+ <Download className="mr-2 size-4" />
+ )}
+ {t("buttons.export")}
+ </Button>
+
+ {/* Template 보기 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleGetTemplate}
+ disabled={isAnyOperationPending}
+ >
+ {isLoadingTemplate ? (
+ <Loader className="mr-2 size-4 animate-spin" />
+ ) : (
+ <Eye className="mr-2 size-4" />
+ )}
+ {t("buttons.viewTemplate")}
+ </Button>
+
+ {/* COMPARE WITH SEDP 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleSEDPCompareClick}
+ disabled={isAnyOperationPending}
+ >
+ <GitCompareIcon className="mr-2 size-4" />
+ {t("buttons.compareWithSEDP")}
+ </Button>
+
+ {/* SEDP 전송 버튼 */}
+ <Button
+ variant="samsung"
+ size="sm"
+ onClick={handleSEDPSendClick}
+ disabled={isAnyOperationPending}
+ >
+ {isSendingSEDP ? (
+ <>
+ <Loader className="mr-2 size-4 animate-spin" />
+ {t("messages.sendingSEDP")}
+ </>
+ ) : (
+ <>
+ <Send className="size-4" />
+ {t("buttons.sendToSHI")}
+ </>
+ )}
+ </Button>
+ </div>
+ </ClientDataTable>
+
+ {/* Modal dialog for tag update */}
+ <UpdateTagSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={(open) => {
+ 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
+ )
+ );
+ }
+ }}
+ />
+
+ <DeleteFormDataDialog
+ formData={deleteTarget}
+ formCode={formCode}
+ contractItemId={contractItemId}
+ open={deleteDialogOpen}
+ onOpenChange={(open) => {
+ if (!open) {
+ setDeleteDialogOpen(false);
+ setDeleteTarget([]);
+ }
+ }}
+ onSuccess={handleDeleteSuccess}
+ showTrigger={false}
+ />
+
+ {/* Dialog for adding tags */}
+ {/* <AddFormTagDialog
+ projectId={projectId}
+ formCode={formCode}
+ formName={`Form ${formCode}`}
+ contractItemId={contractItemId}
+ packageCode={packageCode}
+ open={addTagDialogOpen}
+ onOpenChange={setAddTagDialogOpen}
+ /> */}
+
+ {/* 새로 추가된 Template 다이얼로그 */}
+ <TemplateViewDialog
+ isOpen={templateDialogOpen}
+ onClose={() => 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 */}
+ <SEDPConfirmationDialog
+ isOpen={sedpConfirmOpen}
+ onClose={() => setSedpConfirmOpen(false)}
+ onConfirm={handleSEDPSendConfirmed}
+ formName={formName}
+ tagCount={tableData.filter(v=>v.status !=='excluded').length}
+ isLoading={isSendingSEDP}
+ />
+
+ {/* SEDP Status Dialog */}
+ <SEDPStatusDialog
+ isOpen={sedpStatusOpen}
+ onClose={() => setSedpStatusOpen(false)}
+ status={sedpStatusData.status}
+ message={sedpStatusData.message}
+ successCount={sedpStatusData.successCount}
+ errorCount={sedpStatusData.errorCount}
+ totalCount={sedpStatusData.totalCount}
+ />
+
+ {/* SEDP Compare Dialog */}
+ <SEDPCompareDialog
+ isOpen={sedpCompareOpen}
+ onClose={() => setSedpCompareOpen(false)}
+ tableData={tableData}
+ columnsJSON={columnsJSON}
+ projectCode={projectCode}
+ formCode={formCode}
+ projectType={projectType}
+ packageCode={packageCode}
+ />
+
+ {/* Other dialogs */}
+ {tempUpDialog && (
+ <FormDataReportTempUploadDialog
+ columnsJSON={columnsJSON}
+ open={tempUpDialog}
+ setOpen={setTempUpDialog}
+ packageId={contractItemId}
+ formCode={formCode}
+ formId={formId}
+ uploaderType="vendor"
+ />
+ )}
+
+ {reportData.length > 0 && (
+ <FormDataReportDialog
+ columnsJSON={columnsJSON}
+ reportData={reportData}
+ setReportData={setReportData}
+ packageId={contractItemId}
+ formCode={formCode}
+ formId={formId}
+ />
+ )}
+
+ {batchDownDialog && (
+ <FormDataReportBatchDialog
+ open={batchDownDialog}
+ setOpen={setBatchDownDialog}
+ columnsJSON={columnsJSON}
+ reportData={selectedRowCount > 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<string, string[]>; // 새로 추가
+ 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<string, string[]>; // 새로 추가
+ 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<string, string[]>
+): 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<ImportExcelResult> {
+ 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<string, GenericData>();
+ 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<string, number>();
+ 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<string, number>();
+ 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<string, any> = {};
+ 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<string, GenericData>();
+
+ // 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<PublishDialogProps> = ({
+ open,
+ onOpenChange,
+ packageId,
+ formCode,
+ fileBlob,
+}) => {
+ // Get current user session from next-auth
+ const { data: session } = useSession();
+
+ // State for form data
+ const [documents, setDocuments] = useState<Document[]>([]);
+ const [stages, setStages] = useState<IssueStage[]>([]);
+ const [latestRevision, setLatestRevision] = useState<string>("");
+
+ // State for document search
+ const [openDocumentCombobox, setOpenDocumentCombobox] = useState(false);
+ const [documentSearchValue, setDocumentSearchValue] = useState("");
+
+ // Selected values
+ const [selectedDocId, setSelectedDocId] = useState<string>("");
+ const [selectedDocumentDisplay, setSelectedDocumentDisplay] = useState<string>("");
+ const [selectedStage, setSelectedStage] = useState<string>("");
+ const [revisionInput, setRevisionInput] = useState<string>("");
+ const [uploaderName, setUploaderName] = useState<string>("");
+ const [comment, setComment] = useState<string>("");
+ const [customFileName, setCustomFileName] = useState<string>(`${formCode}_document.docx`);
+
+ // Loading states
+ const [isLoading, setIsLoading] = useState<boolean>(false);
+ const [isSubmitting, setIsSubmitting] = useState<boolean>(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 (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[500px]">
+ <DialogHeader>
+ <DialogTitle>Publish Document</DialogTitle>
+ <DialogDescription>
+ Select document, stage, and revision to publish the vendor document.
+ </DialogDescription>
+ </DialogHeader>
+
+ <form onSubmit={handleSubmit}>
+ <div className="grid gap-4 py-4">
+ {/* Document Selection with Search */}
+ <div className="grid grid-cols-4 items-center gap-4">
+ <Label htmlFor="document" className="text-right">
+ Document
+ </Label>
+ <div className="col-span-3">
+ <Popover
+ open={openDocumentCombobox}
+ onOpenChange={setOpenDocumentCombobox}
+ >
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={openDocumentCombobox}
+ className="w-full justify-between"
+ disabled={isLoading || documents.length === 0}
+ >
+ {/* Add text-overflow handling for selected document display */}
+ <span className="truncate">
+ {selectedDocumentDisplay
+ ? selectedDocumentDisplay
+ : "Select document..."}
+ </span>
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput
+ placeholder="Search document..."
+ value={documentSearchValue}
+ onValueChange={setDocumentSearchValue}
+ />
+ <CommandEmpty>No document found.</CommandEmpty>
+ <CommandGroup className="max-h-[300px] overflow-auto">
+ {filteredDocuments.map((doc) => (
+ <CommandItem
+ key={doc.id}
+ value={`${doc.docNumber} - ${doc.title}`}
+ onSelect={() => {
+ setSelectedDocId(String(doc.id));
+ setSelectedDocumentDisplay(`${doc.docNumber} - ${doc.title}`);
+ setOpenDocumentCombobox(false);
+ }}
+ className="flex items-center"
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4 flex-shrink-0",
+ selectedDocId === String(doc.id)
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {/* Add text-overflow handling for document items */}
+ <span className="truncate">{doc.docNumber} - {doc.title}</span>
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ </div>
+ </div>
+
+ {/* Stage Selection */}
+ <div className="grid grid-cols-4 items-center gap-4">
+ <Label htmlFor="stage" className="text-right">
+ Stage
+ </Label>
+ <div className="col-span-3">
+ <Select
+ value={selectedStage}
+ onValueChange={setSelectedStage}
+ disabled={isLoading || !selectedDocId || stages.length === 0}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="Select stage" />
+ </SelectTrigger>
+ <SelectContent>
+ {stages.map((stage) => (
+ <SelectItem key={stage.id} value={stage.stageName}>
+ {/* Add text-overflow handling for stage names */}
+ <span className="truncate">{stage.stageName}</span>
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+
+ {/* Revision Input */}
+ <div className="grid grid-cols-4 items-center gap-4">
+ <Label htmlFor="revision" className="text-right">
+ Revision
+ </Label>
+ <div className="col-span-3">
+ <Input
+ id="revision"
+ value={revisionInput}
+ onChange={(e) => setRevisionInput(e.target.value)}
+ placeholder="Enter revision"
+ disabled={isLoading || !selectedStage}
+ />
+ {latestRevision && (
+ <p className="text-xs text-muted-foreground mt-1">
+ Latest revision: {latestRevision}
+ </p>
+ )}
+ </div>
+ </div>
+
+ <div className="grid grid-cols-4 items-center gap-4">
+ <Label htmlFor="fileName" className="text-right">
+ File Name
+ </Label>
+ <div className="col-span-3">
+ <Input
+ id="fileName"
+ value={customFileName}
+ onChange={(e) => setCustomFileName(e.target.value)}
+ placeholder="Custom file name"
+ />
+ </div>
+ </div>
+
+ <div className="grid grid-cols-4 items-center gap-4">
+ <Label htmlFor="uploaderName" className="text-right">
+ Uploader
+ </Label>
+ <div className="col-span-3">
+ <Input
+ id="uploaderName"
+ value={uploaderName}
+ onChange={(e) => 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 && (
+ <p className="text-xs text-muted-foreground mt-1">
+ Using your account name from login
+ </p>
+ )}
+ </div>
+ </div>
+
+ <div className="grid grid-cols-4 items-center gap-4">
+ <Label htmlFor="comment" className="text-right">
+ Comment
+ </Label>
+ <div className="col-span-3">
+ <Textarea
+ id="comment"
+ value={comment}
+ onChange={(e) => setComment(e.target.value)}
+ placeholder="Optional comment"
+ className="resize-none"
+ />
+ </div>
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="submit"
+ disabled={isSubmitting || !selectedDocId || !selectedStage || !revisionInput}
+ >
+ {isSubmitting ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ Publishing...
+ </>
+ ) : (
+ "Publish"
+ )}
+ </Button>
+ </DialogFooter>
+ </form>
+ </DialogContent>
+ </Dialog>
+ );
+}; \ No newline at end of file
diff --git a/components/form-data-plant/sedp-compare-dialog.tsx b/components/form-data-plant/sedp-compare-dialog.tsx
new file mode 100644
index 00000000..b481b4f8
--- /dev/null
+++ b/components/form-data-plant/sedp-compare-dialog.tsx
@@ -0,0 +1,618 @@
+import * as React from "react";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
+import { Button } from "@/components/ui/button";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Input } from "@/components/ui/input";
+import { Loader, RefreshCw, AlertCircle, CheckCircle, Info, EyeOff, ChevronDown, ChevronRight, Search } from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+import { toast } from "sonner";
+import { DataTableColumnJSON } from "./form-data-table-columns";
+import { ExcelDownload } from "./sedp-excel-download";
+import { Switch } from "../ui/switch";
+import { Card, CardContent } from "@/components/ui/card";
+import { useTranslation } from "@/i18n/client"
+import { useParams } from "next/navigation"
+import { fetchTagDataFromSEDP } from "@/lib/forms-plant/sedp-actions";
+
+interface SEDPCompareDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ tableData: unknown[];
+ columnsJSON: DataTableColumnJSON[];
+ projectCode: string;
+ formCode: string;
+ projectType:string;
+ packageCode:string;
+}
+
+interface ComparisonResult {
+ tagNo: string;
+ tagDesc: string;
+ isMatching: boolean;
+ attributes: {
+ key: string;
+ label: string;
+ localValue: unknown;
+ sedpValue: unknown;
+ isMatching: boolean;
+ uom?: string;
+ }[];
+}
+
+// Component for formatting display value with UOM
+const DisplayValue = ({ value, uom, isSedp = false }: { value: unknown; uom?: string; isSedp?: boolean }) => {
+ if (value === "" || value === null || value === undefined) {
+ return <span className="text-muted-foreground italic">(empty)</span>;
+ }
+
+ // SEDP 값은 UOM을 표시하지 않음 (이미 포함되어 있다고 가정)
+ if (isSedp) {
+ return <span>{value}</span>;
+ }
+
+ // 로컬 값은 UOM과 함께 표시
+ return (
+ <span>
+ {value}
+ {uom && <span className="text-xs text-muted-foreground ml-1">{uom}</span>}
+ </span>
+ );
+};
+
+
+export function SEDPCompareDialog({
+ isOpen,
+ onClose,
+ tableData,
+ columnsJSON,
+ projectCode,
+ formCode,
+ projectType,
+ packageCode
+}: SEDPCompareDialogProps) {
+
+ const params = useParams() || {}
+ const lng = params.lng ? String(params.lng) : "ko"
+ const { t } = useTranslation(lng, "engineering")
+
+ // 범례 컴포넌트
+ const ColorLegend = () => {
+ return (
+ <div className="flex items-center gap-4 text-sm p-2 bg-muted/20 rounded">
+ <div className="flex items-center gap-1.5">
+ <Info className="h-4 w-4 text-muted-foreground" />
+ <span className="font-medium">{t("labels.legend")}:</span>
+ </div>
+ <div className="flex items-center gap-3">
+ <div className="flex items-center gap-1.5">
+ <div className="h-3 w-3 rounded-full bg-red-500"></div>
+ <span className="line-through text-red-500">{t("labels.localValue")}</span>
+ </div>
+ <div className="flex items-center gap-1.5">
+ <div className="h-3 w-3 rounded-full bg-green-500"></div>
+ <span className="text-green-500">{t("labels.sedpValue")}</span>
+ </div>
+ </div>
+ </div>
+ );
+ };
+
+ // 확장 가능한 차이점 표시 컴포넌트
+ const DifferencesCard = ({
+ attributes,
+ columnLabelMap,
+ showOnlyDifferences
+ }: {
+ attributes: ComparisonResult['attributes'];
+ columnLabelMap: Record<string, string>;
+ showOnlyDifferences: boolean;
+ }) => {
+ const attributesToShow = showOnlyDifferences
+ ? attributes.filter(attr => !attr.isMatching)
+ : attributes;
+
+ if (attributesToShow.length === 0) {
+ return (
+ <div className="text-center text-muted-foreground py-4">
+ {t("messages.allAttributesMatch")}
+ </div>
+ );
+ }
+
+ return (
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 p-4">
+ {attributesToShow.map((attr) => (
+ <Card key={attr.key} className={`${!attr.isMatching ? 'border-red-200' : 'border-green-200'}`}>
+ <CardContent className="p-3">
+ <div className="font-medium text-sm mb-2 truncate" title={attr.label}>
+ {attr.label}
+ {attr.uom && <span className="text-xs text-muted-foreground ml-1">({attr.uom})</span>}
+ </div>
+ {attr.isMatching ? (
+ <div className="text-sm">
+ <DisplayValue value={attr.localValue} uom={attr.uom} />
+ </div>
+ ) : (
+ <div className="space-y-2">
+ <div className="flex items-center gap-2 text-sm">
+ <span className="text-xs text-muted-foreground min-w-[35px]">로컬:</span>
+ <span className="line-through text-red-500 flex-1">
+ <DisplayValue value={attr.localValue} uom={attr.uom} isSedp={false} />
+ </span>
+ </div>
+ <div className="flex items-center gap-2 text-sm">
+ <span className="text-xs text-muted-foreground min-w-[35px]">SEDP:</span>
+ <span className="text-green-500 flex-1">
+ <DisplayValue value={attr.sedpValue} uom={attr.uom} isSedp={true} />
+ </span>
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+ ))}
+ </div>
+ );
+ };
+
+
+ const [isLoading, setIsLoading] = React.useState(false);
+ const [comparisonResults, setComparisonResults] = React.useState<ComparisonResult[]>([]);
+ const [activeTab, setActiveTab] = React.useState("all");
+ const [isExporting, setIsExporting] = React.useState(false);
+ const [missingTags, setMissingTags] = React.useState<{
+ localOnly: { tagNo: string; tagDesc: string }[];
+ sedpOnly: { tagNo: string; tagDesc: string }[];
+ }>({ localOnly: [], sedpOnly: [] });
+ const [showOnlyDifferences, setShowOnlyDifferences] = React.useState(true);
+ const [searchTerm, setSearchTerm] = React.useState("");
+ const [expandedRows, setExpandedRows] = React.useState<Set<string>>(new Set());
+
+ // Stats for summary
+ const totalTags = comparisonResults.length;
+ const matchingTags = comparisonResults.filter(r => r.isMatching).length;
+ const nonMatchingTags = totalTags - matchingTags;
+ const totalMissingTags = missingTags.localOnly.length + missingTags.sedpOnly.length;
+
+ // Get column label map and UOM map for better display
+ const { columnLabelMap, columnUomMap } = React.useMemo(() => {
+ const labelMap: Record<string, string> = {};
+ const uomMap: Record<string, string> = {};
+
+ columnsJSON.forEach(col => {
+ labelMap[col.key] = col.displayLabel || col.label;
+ if (col.uom) {
+ uomMap[col.key] = col.uom;
+ }
+ });
+
+ return { columnLabelMap: labelMap, columnUomMap: uomMap };
+ }, [columnsJSON]);
+
+ // Filter and search results
+ const filteredResults = React.useMemo(() => {
+ let results = comparisonResults;
+
+ // Filter by tab
+ switch (activeTab) {
+ case "matching":
+ results = results.filter(r => r.isMatching);
+ break;
+ case "differences":
+ results = results.filter(r => !r.isMatching);
+ break;
+ case "all":
+ default:
+ break;
+ }
+
+ // Apply search filter
+ if (searchTerm.trim()) {
+ const search = searchTerm.toLowerCase();
+ results = results.filter(r =>
+ r.tagNo.toLowerCase().includes(search) ||
+ r.tagDesc.toLowerCase().includes(search)
+ );
+ }
+
+ return results;
+ }, [comparisonResults, activeTab, searchTerm]);
+
+ // Toggle row expansion
+ const toggleRowExpansion = (tagNo: string) => {
+ const newExpanded = new Set(expandedRows);
+ if (newExpanded.has(tagNo)) {
+ newExpanded.delete(tagNo);
+ } else {
+ newExpanded.add(tagNo);
+ }
+ setExpandedRows(newExpanded);
+ };
+
+ // Auto-expand rows with differences when switching to differences tab
+ React.useEffect(() => {
+ if (activeTab === "differences") {
+ const newExpanded = new Set<string>();
+ filteredResults.filter(r => !r.isMatching).forEach(r => {
+ newExpanded.add(r.tagNo);
+ });
+ setExpandedRows(newExpanded);
+ }
+ }, [activeTab, filteredResults]);
+
+ const fetchAndCompareData = React.useCallback(async () => {
+ if (!projectCode || !formCode) {
+ toast.error("Project code or form code is missing");
+ return;
+ }
+
+ try {
+ setIsLoading(true);
+
+ // Fetch data from SEDP API
+ const sedpData = await fetchTagDataFromSEDP(projectCode, formCode);
+
+ // Get the table name from the response
+ const tableName = Object.keys(sedpData)[0];
+ const sedpTagEntries = sedpData[tableName] || [];
+
+ // Create a map of SEDP data by TAG_NO for quick lookup
+ const sedpTagMap = new Map();
+
+ const packageCodeAttId = projectType === "ship" ? "CM3003" : "ME5074";
+
+
+ const tagEntries = sedpTagEntries.filter(entry => {
+ if (Array.isArray(entry.ATTRIBUTES)) {
+ const packageCodeAttr = entry.ATTRIBUTES.find(attr => attr.ATT_ID === packageCodeAttId);
+ if (packageCodeAttr && packageCodeAttr.VALUE === packageCode) {
+ return true;
+ }
+ }
+ return false;
+ });
+
+
+ tagEntries.forEach((entry: Record<string, unknown>) => {
+ const tagNo = entry.TAG_NO;
+ const attributesMap = new Map();
+
+ // Convert attributes array to map for easier access
+ if (Array.isArray(entry.ATTRIBUTES)) {
+ entry.ATTRIBUTES.forEach((attr: Record<string, unknown>) => {
+ attributesMap.set(attr.ATT_ID, attr.VALUE);
+ });
+ }
+
+ sedpTagMap.set(tagNo, {
+ tagDesc: entry.TAG_DESC,
+ attributes: attributesMap
+ });
+ });
+
+ // Create sets for finding missing tags
+ const localTagNos = new Set(tableData.map(item => item.TAG_NO));
+ const sedpTagNos = new Set(sedpTagMap.keys());
+
+ // Find missing tags
+ const localOnlyTags = tableData
+ .filter(item => !sedpTagMap.has(item.TAG_NO))
+ .map(item => ({ tagNo: item.TAG_NO, tagDesc: item.TAG_DESC || "" }));
+
+ const sedpOnlyTags = Array.from(sedpTagMap.entries())
+ .filter(([tagNo]) => !localTagNos.has(tagNo))
+ .map(([tagNo, data]) => ({ tagNo, tagDesc: data.tagDesc || "" }));
+
+ setMissingTags({
+ localOnly: localOnlyTags,
+ sedpOnly: sedpOnlyTags
+ });
+
+ // Compare with local table data (only for tags that exist in both systems)
+ const results: ComparisonResult[] = tableData
+ .filter(localItem => sedpTagMap.has(localItem.TAG_NO))
+ .map(localItem => {
+ const tagNo = localItem.TAG_NO;
+ const sedpItem = sedpTagMap.get(tagNo);
+
+ // Compare attributes
+ const attributeComparisons = columnsJSON
+ .filter(col => col.key !== "TAG_NO" && col.key !== "TAG_DESC" && col.key !== "status"&& col.key !== "CLS_ID")
+ .map(col => {
+ const localValue = localItem[col.key];
+ const sedpValue = sedpItem.attributes.get(col.key);
+ const uom = columnUomMap[col.key];
+
+ // Compare values (with type handling)
+ let isMatching = false;
+
+ // Special case: Empty SEDP value and 0 local value
+ if ((sedpValue === "" || sedpValue === null || sedpValue === undefined) &&
+ (localValue === 0 || localValue === "0")) {
+ isMatching = true;
+ } else {
+ // Standard string comparison for other cases
+ const normalizedLocal = localValue === undefined || localValue === null ? "" : String(localValue).trim();
+ const normalizedSedp = sedpValue === undefined || sedpValue === null ? "" : String(sedpValue).trim();
+ isMatching = normalizedLocal === normalizedSedp;
+ }
+
+ return {
+ key: col.key,
+ label: columnLabelMap[col.key] || col.key,
+ localValue,
+ sedpValue,
+ isMatching,
+ uom
+ };
+ });
+
+ // Item is matching if all attributes match
+ const isItemMatching = attributeComparisons.every(attr => attr.isMatching);
+
+ return {
+ tagNo,
+ tagDesc: localItem.TAG_DESC || "",
+ isMatching: isItemMatching,
+ attributes: attributeComparisons
+ };
+ });
+
+ setComparisonResults(results);
+
+ // Show summary in toast
+ const matchCount = results.filter(r => r.isMatching).length;
+ const nonMatchCount = results.length - matchCount;
+ const missingCount = localOnlyTags.length + sedpOnlyTags.length;
+
+ if (missingCount > 0) {
+ toast.error(`Found ${missingCount} missing tags between systems`);
+ }
+
+ if (nonMatchCount > 0) {
+ toast.warning(`Found ${nonMatchCount} tags with differences`);
+ } else if (results.length > 0 && missingCount === 0) {
+ toast.success(`All ${results.length} tags match with SEDP data`);
+ } else if (results.length === 0 && missingCount === 0) {
+ toast.info("No tags to compare");
+ }
+
+ } catch (error) {
+ console.error("SEDP comparison error:", error);
+ toast.error(`Failed to compare with SEDP: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [projectCode, formCode, tableData, columnsJSON, columnLabelMap, columnUomMap]);
+
+ // Fetch data when dialog opens
+ React.useEffect(() => {
+ if (isOpen) {
+ fetchAndCompareData();
+ }
+ }, [isOpen, fetchAndCompareData]);
+
+ return (
+ <Dialog open={isOpen} onOpenChange={onClose}>
+ <DialogContent className="max-w-6xl max-h-[90vh] overflow-hidden flex flex-col">
+ <DialogHeader>
+ <DialogTitle className="mb-2">{t("dialogs.sedpDataComparison")}</DialogTitle>
+ <div className="flex items-center justify-between gap-2 pr-8">
+ <div className="flex items-center gap-4">
+ <div className="flex items-center gap-2">
+ <Switch
+ checked={showOnlyDifferences}
+ onCheckedChange={setShowOnlyDifferences}
+ id="show-differences"
+ />
+ <label htmlFor="show-differences" className="text-sm cursor-pointer">
+ {t("switches.showOnlyDifferences")}
+ </label>
+ </div>
+
+ {/* 검색 입력 */}
+ <div className="relative">
+ <Search className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
+ <Input
+ placeholder={t("placeholders.searchTagOrDesc")}
+ value={searchTerm}
+ onChange={(e) => setSearchTerm(e.target.value)}
+ className="pl-8 w-64"
+ />
+ </div>
+ </div>
+
+ <div className="flex items-center gap-2">
+ <Badge variant={matchingTags === totalTags && totalMissingTags === 0 ? "default" : "destructive"}>
+ {matchingTags} / {totalTags} 일치 {totalMissingTags > 0 ? `(${totalMissingTags} 누락)` : ''}
+ </Badge>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={fetchAndCompareData}
+ disabled={isLoading}
+ >
+ {isLoading ? (
+ <Loader className="h-4 w-4 animate-spin" />
+ ) : (
+ <RefreshCw className="h-4 w-4" />
+ )}
+ <span className="ml-2">{t("buttons.refresh")}</span>
+ </Button>
+ </div>
+ </div>
+ </DialogHeader>
+
+ {/* 범례 */}
+ <div className="mb-4">
+ <ColorLegend />
+ </div>
+
+ <Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col overflow-hidden">
+ <TabsList>
+ <TabsTrigger value="all">{t("tabs.allTags")} ({totalTags})</TabsTrigger>
+ <TabsTrigger value="differences">{t("tabs.differences")} ({nonMatchingTags})</TabsTrigger>
+ <TabsTrigger value="matching">{t("tabs.matching")} ({matchingTags})</TabsTrigger>
+ <TabsTrigger value="missing" className={totalMissingTags > 0 ? "text-red-500" : ""}>
+ {t("tabs.missingTags")} ({totalMissingTags})
+ </TabsTrigger>
+ </TabsList>
+
+ <TabsContent value={activeTab} className="flex-1 overflow-auto">
+ {isLoading ? (
+ <div className="flex items-center justify-center h-full">
+ <Loader className="h-8 w-8 animate-spin mr-2" />
+ <span>{t("messages.dataComparing")}</span>
+ </div>
+ ) : activeTab === "missing" ? (
+ // Missing tags tab content
+ <div className="space-y-6">
+ {missingTags.localOnly.length > 0 && (
+ <div>
+ <h3 className="text-sm font-medium mb-2">{t("sections.localOnlyTags")} ({missingTags.localOnly.length})</h3>
+ <Table>
+ <TableHeader className="sticky top-0 bg-background z-10">
+ <TableRow>
+ <TableHead className="w-[180px]">{t("labels.tagNo")}</TableHead>
+ <TableHead>{t("labels.description")}</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {missingTags.localOnly.map((tag) => (
+ <TableRow key={tag.tagNo} className="bg-yellow-50 dark:bg-yellow-950/20">
+ <TableCell className="font-medium">{tag.tagNo}</TableCell>
+ <TableCell>{tag.tagDesc}</TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </div>
+ )}
+
+ {missingTags.sedpOnly.length > 0 && (
+ <div>
+ <h3 className="text-sm font-medium mb-2">{t("sections.sedpOnlyTags")} ({missingTags.sedpOnly.length})</h3>
+ <Table>
+ <TableHeader className="sticky top-0 bg-background z-10">
+ <TableRow>
+ <TableHead className="w-[180px]">{t("labels.tagNo")}</TableHead>
+ <TableHead>{t("labels.description")}</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {missingTags.sedpOnly.map((tag) => (
+ <TableRow key={tag.tagNo} className="bg-blue-50 dark:bg-blue-950/20">
+ <TableCell className="font-medium">{tag.tagNo}</TableCell>
+ <TableCell>{tag.tagDesc}</TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </div>
+ )}
+
+ {totalMissingTags === 0 && (
+ <div className="flex items-center justify-center h-full text-muted-foreground">
+ {t("messages.allTagsExistInBothSystems")}
+ </div>
+ )}
+ </div>
+ ) : filteredResults.length > 0 ? (
+ // 개선된 확장 가능한 테이블 구조
+ <div className="border rounded-md">
+ <Table>
+ <TableHeader className="sticky top-0 bg-muted/50 z-10">
+ <TableRow>
+ <TableHead className="w-12"></TableHead>
+ <TableHead className="w-[180px]">{t("labels.tagNo")}</TableHead>
+ <TableHead className="w-[250px]">{t("labels.description")}</TableHead>
+ <TableHead className="w-[120px]">{t("labels.status")}</TableHead>
+ <TableHead>차이점 개수</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {filteredResults.map((result) => (
+ <React.Fragment key={result.tagNo}>
+ {/* 메인 행 */}
+ <TableRow
+ className={`hover:bg-muted/20 cursor-pointer ${!result.isMatching ? "bg-muted/10" : ""}`}
+ onClick={() => toggleRowExpansion(result.tagNo)}
+ >
+ <TableCell>
+ {result.attributes.some(attr => !attr.isMatching) ? (
+ expandedRows.has(result.tagNo) ? (
+ <ChevronDown className="h-4 w-4" />
+ ) : (
+ <ChevronRight className="h-4 w-4" />
+ )
+ ) : null}
+ </TableCell>
+ <TableCell className="font-medium">
+ {result.tagNo}
+ </TableCell>
+ <TableCell title={result.tagDesc}>
+ <div className="truncate">
+ {result.tagDesc}
+ </div>
+ </TableCell>
+ <TableCell>
+ {result.isMatching ? (
+ <Badge variant="default" className="flex items-center gap-1">
+ <CheckCircle className="h-3 w-3" />
+ <span>{t("labels.matching")}</span>
+ </Badge>
+ ) : (
+ <Badge variant="destructive" className="flex items-center gap-1">
+ <AlertCircle className="h-3 w-3" />
+ <span>{t("labels.different")}</span>
+ </Badge>
+ )}
+ </TableCell>
+ <TableCell>
+ {!result.isMatching && (
+ <span className="text-sm text-muted-foreground">
+ {result.attributes.filter(attr => !attr.isMatching).length}{t("sections.differenceCount")}
+ </span>
+ )}
+ </TableCell>
+ </TableRow>
+
+ {/* 확장된 차이점 표시 행 */}
+ {expandedRows.has(result.tagNo) && (
+ <TableRow>
+ <TableCell colSpan={5} className="p-0 bg-muted/5">
+ <DifferencesCard
+ attributes={result.attributes}
+ columnLabelMap={columnLabelMap}
+ showOnlyDifferences={showOnlyDifferences}
+ />
+ </TableCell>
+ </TableRow>
+ )}
+ </React.Fragment>
+ ))}
+ </TableBody>
+ </Table>
+ </div>
+ ) : (
+ <div className="flex items-center justify-center h-full text-muted-foreground">
+ {searchTerm ? t("messages.noSearchResults") : t("messages.noMatchingTags")}
+ </div>
+ )}
+ </TabsContent>
+ </Tabs>
+
+ <DialogFooter className="flex justify-between items-center gap-4 pt-4 border-t">
+ <ExcelDownload
+ comparisonResults={comparisonResults}
+ missingTags={missingTags}
+ formCode={formCode}
+ disabled={isLoading || (nonMatchingTags === 0 && totalMissingTags === 0)}
+ />
+ <Button onClick={onClose}>{t("buttons.close")}</Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+} \ No newline at end of file
diff --git a/components/form-data-plant/sedp-components.tsx b/components/form-data-plant/sedp-components.tsx
new file mode 100644
index 00000000..869f730c
--- /dev/null
+++ b/components/form-data-plant/sedp-components.tsx
@@ -0,0 +1,193 @@
+"use client";
+
+import * as React from "react";
+import { useParams } from "next/navigation";
+import { useTranslation } from "@/i18n/client";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle
+} from "@/components/ui/dialog";
+import { Loader, Send, AlertTriangle, CheckCircle } from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+import { Progress } from "@/components/ui/progress";
+
+// SEDP Send Confirmation Dialog
+export function SEDPConfirmationDialog({
+ isOpen,
+ onClose,
+ onConfirm,
+ formName,
+ tagCount,
+ isLoading
+}: {
+ isOpen: boolean;
+ onClose: () => void;
+ onConfirm: () => void;
+ formName: string;
+ tagCount: number;
+ isLoading: boolean;
+}) {
+ const params = useParams();
+ const lng = (params?.lng as string) || "ko";
+ const { t } = useTranslation(lng, "engineering");
+
+ return (
+ <Dialog open={isOpen} onOpenChange={onClose}>
+ <DialogContent className="sm:max-w-md">
+ <DialogHeader>
+ <DialogTitle>{t("sedp.sendDataTitle")}</DialogTitle>
+ <DialogDescription>
+ {t("sedp.sendDataDescription")}
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="py-4">
+ <div className="grid grid-cols-2 gap-4 mb-4">
+ <div className="text-muted-foreground">{t("sedp.formName")}:</div>
+ <div className="font-medium">{formName}</div>
+
+ <div className="text-muted-foreground">{t("sedp.totalTags")}:</div>
+ <div className="font-medium">{tagCount}</div>
+ </div>
+
+ <div className="bg-amber-50 p-3 rounded-md border border-amber-200 flex items-start gap-2">
+ <AlertTriangle className="h-5 w-5 text-amber-500 mt-0.5 flex-shrink-0" />
+ <div className="text-sm text-amber-800">
+ {t("sedp.warningMessage")}
+ </div>
+ </div>
+ </div>
+
+ <DialogFooter className="gap-2 sm:gap-0">
+ <Button variant="outline" onClick={onClose} disabled={isLoading}>
+ {t("buttons.cancel")}
+ </Button>
+ <Button
+ variant="samsung"
+ onClick={onConfirm}
+ disabled={isLoading}
+ className="gap-2"
+ >
+ {isLoading ? (
+ <>
+ <Loader className="h-4 w-4 animate-spin" />
+ {t("sedp.sending")}
+ </>
+ ) : (
+ <>
+ <Send className="h-4 w-4" />
+ {t("sedp.sendToSEDP")}
+ </>
+ )}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
+
+// SEDP Status Dialog - shows the result of the SEDP operation
+export function SEDPStatusDialog({
+ isOpen,
+ onClose,
+ status,
+ message,
+ successCount,
+ errorCount,
+ totalCount
+}: {
+ isOpen: boolean;
+ onClose: () => void;
+ status: 'success' | 'error' | 'partial';
+ message: string;
+ successCount: number;
+ errorCount: number;
+ totalCount: number;
+}) {
+ const params = useParams();
+ const lng = (params?.lng as string) || "ko";
+ const { t } = useTranslation(lng, "engineering");
+
+ // Calculate percentage for the progress bar
+ const percentage = Math.round((successCount / totalCount) * 100);
+
+ const getStatusTitle = () => {
+ switch (status) {
+ case 'success':
+ return t("sedp.dataSentSuccessfully");
+ case 'partial':
+ return t("sedp.partiallySuccessful");
+ case 'error':
+ default:
+ return t("sedp.failedToSendData");
+ }
+ };
+
+ return (
+ <Dialog open={isOpen} onOpenChange={onClose}>
+ <DialogContent className="sm:max-w-md">
+ <DialogHeader>
+ <DialogTitle>
+ {getStatusTitle()}
+ </DialogTitle>
+ </DialogHeader>
+
+ <div className="py-4">
+ {/* Status Icon */}
+ <div className="flex justify-center mb-4">
+ {status === 'success' ? (
+ <div className="h-12 w-12 rounded-full bg-green-100 flex items-center justify-center">
+ <CheckCircle className="h-8 w-8 text-green-600" />
+ </div>
+ ) : status === 'partial' ? (
+ <div className="h-12 w-12 rounded-full bg-amber-100 flex items-center justify-center">
+ <AlertTriangle className="h-8 w-8 text-amber-600" />
+ </div>
+ ) : (
+ <div className="h-12 w-12 rounded-full bg-red-100 flex items-center justify-center">
+ <AlertTriangle className="h-8 w-8 text-red-600" />
+ </div>
+ )}
+ </div>
+
+ {/* Message */}
+ <p className="text-center mb-4">{message}</p>
+
+ {/* Progress Stats */}
+ <div className="space-y-2 mb-4">
+ <div className="flex justify-between text-sm">
+ <span>{t("sedp.progress")}</span>
+ <span>{percentage}%</span>
+ </div>
+ <Progress value={percentage} className="h-2" />
+ <div className="flex justify-between text-sm pt-1">
+ <div>
+ <Badge variant="outline" className="bg-green-50 text-green-700 hover:bg-green-50">
+ {t("sedp.successfulCount", { count: successCount })}
+ </Badge>
+ </div>
+ {errorCount > 0 && (
+ <div>
+ <Badge variant="outline" className="bg-red-50 text-red-700 hover:bg-red-50">
+ {t("sedp.failedCount", { count: errorCount })}
+ </Badge>
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button onClick={onClose}>
+ {t("buttons.close")}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+} \ No newline at end of file
diff --git a/components/form-data-plant/sedp-excel-download.tsx b/components/form-data-plant/sedp-excel-download.tsx
new file mode 100644
index 00000000..36be4847
--- /dev/null
+++ b/components/form-data-plant/sedp-excel-download.tsx
@@ -0,0 +1,259 @@
+import * as React from "react";
+import { useParams } from "next/navigation";
+import { useTranslation } from "@/i18n/client";
+import { Button } from "@/components/ui/button";
+import { FileDown, Loader } from "lucide-react";
+import { toast } from "sonner";
+import * as ExcelJS from 'exceljs';
+
+interface ExcelDownloadProps {
+ comparisonResults: Array<{
+ tagNo: string;
+ tagDesc: string;
+ isMatching: boolean;
+ attributes: Array<{
+ key: string;
+ label: string;
+ localValue: any;
+ sedpValue: any;
+ isMatching: boolean;
+ uom?: string;
+ }>;
+ }>;
+ missingTags: {
+ localOnly: Array<{ tagNo: string; tagDesc: string }>;
+ sedpOnly: Array<{ tagNo: string; tagDesc: string }>;
+ };
+ formCode: string;
+ disabled: boolean;
+}
+
+export function ExcelDownload({ comparisonResults, missingTags, formCode, disabled }: ExcelDownloadProps) {
+ const [isExporting, setIsExporting] = React.useState(false);
+ const params = useParams();
+ const lng = (params?.lng as string) || "ko";
+ const { t } = useTranslation(lng, "engineering");
+
+ // Function to generate and download Excel file with differences
+ const handleExportDifferences = async () => {
+ try {
+ setIsExporting(true);
+
+ // Get only items with differences
+ const itemsWithDifferences = comparisonResults.filter(item => !item.isMatching);
+ const hasMissingTags = missingTags.localOnly.length > 0 || missingTags.sedpOnly.length > 0;
+
+ if (itemsWithDifferences.length === 0 && !hasMissingTags) {
+ toast.info(t("excelDownload.noDifferencesToDownload"));
+ return;
+ }
+
+ // Create a new workbook
+ const workbook = new ExcelJS.Workbook();
+ workbook.creator = 'SEDP Compare Tool';
+ workbook.created = new Date();
+
+ // Add a worksheet for attribute differences
+ if (itemsWithDifferences.length > 0) {
+ const worksheet = workbook.addWorksheet(t("excelDownload.attributeDifferencesSheet"));
+
+ // Add headers
+ worksheet.columns = [
+ { header: t("excelDownload.tagNumber"), key: 'tagNo', width: 20 },
+ { header: t("excelDownload.tagDescription"), key: 'tagDesc', width: 30 },
+ { header: t("excelDownload.attribute"), key: 'attribute', width: 25 },
+ { header: t("excelDownload.localValue"), key: 'localValue', width: 20 },
+ { header: t("excelDownload.sedpValue"), key: 'sedpValue', width: 20 }
+ ];
+
+ // Style the header row
+ const headerRow = worksheet.getRow(1);
+ headerRow.eachCell((cell) => {
+ cell.font = { bold: true };
+ cell.fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FFE0E0E0' }
+ };
+ cell.border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+ });
+
+ // Add data rows
+ let rowIndex = 2;
+ itemsWithDifferences.forEach(item => {
+ const differences = item.attributes.filter(attr => !attr.isMatching);
+
+ if (differences.length === 0) return;
+
+ differences.forEach(diff => {
+ const row = worksheet.getRow(rowIndex++);
+
+ // Format local value with UOM
+ const localDisplay = diff.localValue === null || diff.localValue === undefined || diff.localValue === ''
+ ? t("excelDownload.emptyValue")
+ : diff.uom ? `${diff.localValue} ${diff.uom}` : diff.localValue;
+
+ // SEDP value is displayed as-is
+ const sedpDisplay = diff.sedpValue === null || diff.sedpValue === undefined || diff.sedpValue === ''
+ ? t("excelDownload.emptyValue")
+ : diff.sedpValue;
+
+ // Set cell values
+ row.getCell('tagNo').value = item.tagNo;
+ row.getCell('tagDesc').value = item.tagDesc;
+ row.getCell('attribute').value = diff.label;
+ row.getCell('localValue').value = localDisplay;
+ row.getCell('sedpValue').value = sedpDisplay;
+
+ // Style the row
+ row.getCell('localValue').font = { color: { argb: 'FFFF0000' } }; // Red for local value
+ row.getCell('sedpValue').font = { color: { argb: 'FF008000' } }; // Green for SEDP value
+
+ // Add borders
+ row.eachCell((cell) => {
+ cell.border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+ });
+ });
+
+ // Add a blank row after each tag for better readability
+ rowIndex++;
+ });
+ }
+
+ // Add a worksheet for missing tags if there are any
+ if (hasMissingTags) {
+ const missingWorksheet = workbook.addWorksheet(t("excelDownload.missingTagsSheet"));
+
+ // Add headers
+ missingWorksheet.columns = [
+ { header: t("excelDownload.tagNumber"), key: 'tagNo', width: 20 },
+ { header: t("excelDownload.tagDescription"), key: 'tagDesc', width: 30 },
+ { header: t("excelDownload.status"), key: 'status', width: 20 }
+ ];
+
+ // Style the header row
+ const headerRow = missingWorksheet.getRow(1);
+ headerRow.eachCell((cell) => {
+ cell.font = { bold: true };
+ cell.fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FFE0E0E0' }
+ };
+ cell.border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+ });
+
+ // Add local-only tags
+ let rowIndex = 2;
+ missingTags.localOnly.forEach(tag => {
+ const row = missingWorksheet.getRow(rowIndex++);
+
+ row.getCell('tagNo').value = tag.tagNo;
+ row.getCell('tagDesc').value = tag.tagDesc;
+ row.getCell('status').value = t("excelDownload.localOnlyStatus");
+
+ // Style the status cell
+ row.getCell('status').font = { color: { argb: 'FFFF8C00' } }; // Orange for local-only
+
+ // Add borders
+ row.eachCell((cell) => {
+ cell.border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+ });
+ });
+
+ // Add a blank row
+ if (missingTags.localOnly.length > 0 && missingTags.sedpOnly.length > 0) {
+ rowIndex++;
+ }
+
+ // Add SEDP-only tags
+ missingTags.sedpOnly.forEach(tag => {
+ const row = missingWorksheet.getRow(rowIndex++);
+
+ row.getCell('tagNo').value = tag.tagNo;
+ row.getCell('tagDesc').value = tag.tagDesc;
+ row.getCell('status').value = t("excelDownload.sedpOnlyStatus");
+
+ // Style the status cell
+ row.getCell('status').font = { color: { argb: 'FF0000FF' } }; // Blue for SEDP-only
+
+ // Add borders
+ row.eachCell((cell) => {
+ cell.border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+ });
+ });
+ }
+
+ // Generate Excel file
+ const buffer = await workbook.xlsx.writeBuffer();
+
+ // Create a Blob from the buffer
+ const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
+
+ // Create a download link and trigger the download
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `${t("excelDownload.fileNamePrefix")}_${formCode}_${new Date().toISOString().slice(0, 10)}.xlsx`;
+ document.body.appendChild(a);
+ a.click();
+
+ // Clean up
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+
+ toast.success(t("excelDownload.downloadComplete"));
+ } catch (error) {
+ console.error("Error exporting to Excel:", error);
+ toast.error(t("excelDownload.downloadFailed"));
+ } finally {
+ setIsExporting(false);
+ }
+ };
+
+ // Determine if there are any differences or missing tags
+ const hasDifferences = comparisonResults.some(item => !item.isMatching);
+ const hasMissingTags = missingTags && (missingTags.localOnly.length > 0 || missingTags.sedpOnly.length > 0);
+ const hasExportableContent = hasDifferences || hasMissingTags;
+
+ return (
+ <Button
+ variant="secondary"
+ onClick={handleExportDifferences}
+ disabled={disabled || isExporting || !hasExportableContent}
+ className="flex items-center gap-2"
+ >
+ {isExporting ? (
+ <Loader className="h-4 w-4 animate-spin" />
+ ) : (
+ <FileDown className="h-4 w-4" />
+ )}
+ {t("excelDownload.downloadButtonText")}
+ </Button>
+ );
+} \ No newline at end of file
diff --git a/components/form-data-plant/spreadJS-dialog.tsx b/components/form-data-plant/spreadJS-dialog.tsx
new file mode 100644
index 00000000..2eb2c8ba
--- /dev/null
+++ b/components/form-data-plant/spreadJS-dialog.tsx
@@ -0,0 +1,1733 @@
+"use client";
+
+import * as React from "react";
+import dynamic from "next/dynamic";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { GenericData } from "./export-excel-form";
+import * as GC from "@mescius/spread-sheets";
+import { toast } from "sonner";
+import { updateFormDataInDB } from "@/lib/forms-plant/services";
+import { Loader, Save, AlertTriangle } from "lucide-react";
+import '@mescius/spread-sheets/styles/gc.spread.sheets.excel2016colorful.css';
+import { DataTableColumnJSON, ColumnType } from "./form-data-table-columns";
+import { setupSpreadJSLicense } from "@/lib/spread-js/license-utils";
+
+const SpreadSheets = dynamic(
+ () => import("@mescius/spread-sheets-react").then(mod => mod.SpreadSheets),
+ {
+ ssr: false,
+ loading: () => (
+ <div className="flex items-center justify-center h-full">
+ <Loader className="mr-2 h-4 w-4 animate-spin" />
+ Loading SpreadSheets...
+ </div>
+ )
+ }
+);
+
+// 도메인별 라이선스 설정
+if (typeof window !== 'undefined') {
+ setupSpreadJSLicense(GC);
+}
+
+interface TemplateItem {
+ TMPL_ID: string;
+ NAME: string;
+ TMPL_TYPE: string;
+ SPR_LST_SETUP: {
+ ACT_SHEET: string;
+ HIDN_SHEETS: Array<string>;
+ CONTENT?: string;
+ DATA_SHEETS: Array<{
+ SHEET_NAME: string;
+ REG_TYPE_ID: string;
+ MAP_CELL_ATT: Array<{
+ ATT_ID: string;
+ IN: string;
+ }>;
+ }>;
+ };
+ GRD_LST_SETUP: {
+ REG_TYPE_ID: string;
+ SPR_ITM_IDS: Array<string>;
+ ATTS: Array<{}>;
+ };
+ SPR_ITM_LST_SETUP: {
+ ACT_SHEET: string;
+ HIDN_SHEETS: Array<string>;
+ CONTENT?: string;
+ DATA_SHEETS: Array<{
+ SHEET_NAME: string;
+ REG_TYPE_ID: string;
+ MAP_CELL_ATT: Array<{
+ ATT_ID: string;
+ IN: string;
+ }>;
+ }>;
+ };
+}
+
+interface ValidationError {
+ cellAddress: string;
+ attId: string;
+ value: any;
+ expectedType: ColumnType;
+ message: string;
+}
+
+interface CellMapping {
+ attId: string;
+ cellAddress: string;
+ isEditable: boolean;
+ dataRowIndex?: number;
+}
+
+interface TemplateViewDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ templateData: TemplateItem[] | any;
+ selectedRow?: GenericData;
+ tableData?: GenericData[];
+ formCode: string;
+ columnsJSON: DataTableColumnJSON[]
+ contractItemId: number;
+ editableFieldsMap?: Map<string, string[]>;
+ onUpdateSuccess?: (updatedValues: Record<string, any> | GenericData[]) => void;
+}
+
+// 🚀 로딩 프로그레스 컴포넌트
+interface LoadingProgressProps {
+ phase: string;
+ progress: number;
+ total: number;
+ isVisible: boolean;
+}
+
+const LoadingProgress: React.FC<LoadingProgressProps> = ({ phase, progress, total, isVisible }) => {
+ const percentage = total > 0 ? Math.round((progress / total) * 100) : 0;
+
+ if (!isVisible) return null;
+
+ return (
+ <div className="absolute inset-0 bg-white/90 flex items-center justify-center z-50">
+ <div className="bg-white rounded-lg shadow-lg border p-6 min-w-[300px]">
+ <div className="flex items-center space-x-3 mb-4">
+ <Loader className="h-5 w-5 animate-spin text-blue-600" />
+ <span className="font-medium text-gray-900">Loading Template</span>
+ </div>
+
+ <div className="space-y-2">
+ <div className="text-sm text-gray-600">{phase}</div>
+ <div className="w-full bg-gray-200 rounded-full h-2">
+ <div
+ className="bg-blue-600 h-2 rounded-full transition-all duration-300 ease-out"
+ style={{ width: `${percentage}%` }}
+ />
+ </div>
+ <div className="text-xs text-gray-500 text-right">
+ {progress.toLocaleString()} / {total.toLocaleString()} ({percentage}%)
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+};
+
+export function TemplateViewDialog({
+ isOpen,
+ onClose,
+ templateData,
+ selectedRow,
+ tableData = [],
+ formCode,
+ contractItemId,
+ columnsJSON,
+ editableFieldsMap = new Map(),
+ onUpdateSuccess
+}: TemplateViewDialogProps) {
+ const [hostStyle, setHostStyle] = React.useState({
+ width: '100%',
+ height: '100%'
+ });
+
+ const [isPending, setIsPending] = React.useState(false);
+ const [hasChanges, setHasChanges] = React.useState(false);
+ const [currentSpread, setCurrentSpread] = React.useState<any>(null);
+ const [cellMappings, setCellMappings] = React.useState<CellMapping[]>([]);
+ const [isClient, setIsClient] = React.useState(false);
+ const [templateType, setTemplateType] = React.useState<'SPREAD_LIST' | 'SPREAD_ITEM' | 'GRD_LIST' | null>(null);
+ const [validationErrors, setValidationErrors] = React.useState<ValidationError[]>([]);
+ const [selectedTemplateId, setSelectedTemplateId] = React.useState<string>("");
+ const [availableTemplates, setAvailableTemplates] = React.useState<TemplateItem[]>([]);
+
+ // 🆕 로딩 상태 추가
+ const [loadingProgress, setLoadingProgress] = React.useState<{
+ phase: string;
+ progress: number;
+ total: number;
+ } | null>(null);
+ const [isInitializing, setIsInitializing] = React.useState(false);
+
+ // 🔄 진행상황 업데이트 함수
+ const updateProgress = React.useCallback((phase: string, progress: number, total: number) => {
+ setLoadingProgress({ phase, progress, total });
+ }, []);
+
+ const determineTemplateType = React.useCallback((template: TemplateItem): 'SPREAD_LIST' | 'SPREAD_ITEM' | 'GRD_LIST' | null => {
+ if (template?.TMPL_TYPE === "SPREAD_LIST" && (template?.SPR_LST_SETUP?.CONTENT || template?.SPR_ITM_LST_SETUP?.CONTENT)) {
+ return 'SPREAD_LIST';
+ }
+ if (template?.TMPL_TYPE === "SPREAD_ITEM" && (template?.SPR_LST_SETUP?.CONTENT || template?.SPR_ITM_LST_SETUP?.CONTENT)) {
+ return 'SPREAD_ITEM';
+ }
+ if (template?.GRD_LST_SETUP && columnsJSON.length > 0) {
+ return 'GRD_LIST';
+ }
+ return null;
+ }, [columnsJSON]);
+
+ const isValidTemplate = React.useCallback((template: TemplateItem): boolean => {
+ // 🔍 TMPL_ID 필수 검증 추가
+ if (!template || !template.TMPL_ID || typeof template.TMPL_ID !== 'string') {
+ console.warn('⚠️ Invalid template: missing or invalid TMPL_ID', template);
+ return false;
+ }
+ return determineTemplateType(template) !== null;
+ }, [determineTemplateType]);
+
+ React.useEffect(() => {
+ setIsClient(true);
+ }, []);
+
+ React.useEffect(() => {
+ // 🔍 받은 templateData 로깅 (디버깅용)
+ console.log('🎨 TemplateViewDialog received templateData:', {
+ isNull: templateData === null,
+ isUndefined: templateData === undefined,
+ isArray: Array.isArray(templateData),
+ length: Array.isArray(templateData) ? templateData.length : 'N/A',
+ data: templateData
+ });
+
+ // 템플릿 데이터가 없거나 빈 배열인 경우 기본 GRD_LIST 템플릿 생성
+ if (!templateData || (Array.isArray(templateData) && templateData.length === 0)) {
+ // columnsJSON이 있으면 기본 GRD_LIST 템플릿 생성
+ if (columnsJSON && columnsJSON.length > 0) {
+ const defaultGrdTemplate: TemplateItem = {
+ TMPL_ID: 'DEFAULT_GRD_LIST',
+ NAME: 'Default Grid View',
+ TMPL_TYPE: 'GRD_LIST',
+ SPR_LST_SETUP: {
+ ACT_SHEET: '',
+ HIDN_SHEETS: [],
+ DATA_SHEETS: []
+ },
+ GRD_LST_SETUP: {
+ REG_TYPE_ID: 'DEFAULT',
+ SPR_ITM_IDS: [],
+ ATTS: []
+ },
+ SPR_ITM_LST_SETUP: {
+ ACT_SHEET: '',
+ HIDN_SHEETS: [],
+ DATA_SHEETS: []
+ }
+ };
+
+ setAvailableTemplates([defaultGrdTemplate]);
+ // setSelectedTemplateId('DEFAULT_GRD_LIST');
+ // setTemplateType('GRD_LIST');
+ console.log('📋 Created default GRD_LIST template');
+ }
+ return;
+ }
+
+ let templates: TemplateItem[];
+ if (Array.isArray(templateData)) {
+ templates = templateData as TemplateItem[];
+ } else {
+ templates = [templateData as TemplateItem];
+ }
+
+ // 🔍 각 템플릿의 TMPL_ID 확인
+ console.log('🔍 Processing templates:', templates.length);
+ templates.forEach((tmpl, idx) => {
+ console.log(` [${idx}] TMPL_ID: ${tmpl?.TMPL_ID || '❌ MISSING'}, NAME: ${tmpl?.NAME || 'N/A'}, TYPE: ${tmpl?.TMPL_TYPE || 'N/A'}`);
+ if (!tmpl?.TMPL_ID) {
+ console.error(`❌ Template at index ${idx} is missing TMPL_ID:`, tmpl);
+ }
+ });
+
+ const validTemplates = templates.filter(isValidTemplate);
+ console.log(`✅ Valid templates after filtering: ${validTemplates.length}`);
+
+ // 유효한 템플릿이 없지만 columnsJSON이 있으면 기본 GRD_LIST 추가
+ if (validTemplates.length === 0 && columnsJSON && columnsJSON.length > 0) {
+ const defaultGrdTemplate: TemplateItem = {
+ TMPL_ID: 'DEFAULT_GRD_LIST',
+ NAME: 'Default Grid View',
+ TMPL_TYPE: 'GRD_LIST',
+ SPR_LST_SETUP: {
+ ACT_SHEET: '',
+ HIDN_SHEETS: [],
+ DATA_SHEETS: []
+ },
+ GRD_LST_SETUP: {
+ REG_TYPE_ID: 'DEFAULT',
+ SPR_ITM_IDS: [],
+ ATTS: []
+ },
+ SPR_ITM_LST_SETUP: {
+ ACT_SHEET: '',
+ HIDN_SHEETS: [],
+ DATA_SHEETS: []
+ }
+ };
+
+ validTemplates.push(defaultGrdTemplate);
+ console.log('📋 Added default GRD_LIST template to empty template list');
+ }
+
+ setAvailableTemplates(validTemplates);
+
+ // 🔍 최종 availableTemplates 로깅
+ console.log('📋 availableTemplates set:', validTemplates.map(t => ({
+ TMPL_ID: t.TMPL_ID,
+ NAME: t.NAME,
+ TYPE: t.TMPL_TYPE
+ })));
+
+ if (validTemplates.length > 0) {
+ // 🔍 현재 선택된 템플릿이 availableTemplates에 있는지 확인
+ const selectedExists = selectedTemplateId && validTemplates.some(t => t.TMPL_ID === selectedTemplateId);
+
+ if (!selectedExists) {
+ // 선택된 템플릿이 없거나 유효하지 않으면 첫 번째 템플릿 선택
+ const firstTemplate = validTemplates[0];
+ if (firstTemplate?.TMPL_ID) {
+ const templateTypeToSet = determineTemplateType(firstTemplate);
+ console.log(`🎯 ${selectedTemplateId ? 'Re-selecting' : 'Auto-selecting'} first template: ${firstTemplate.TMPL_ID} (${templateTypeToSet})`);
+ if (selectedTemplateId) {
+ console.warn(`⚠️ Previously selected "${selectedTemplateId}" not found in availableTemplates, switching to "${firstTemplate.TMPL_ID}"`);
+ }
+ setSelectedTemplateId(firstTemplate.TMPL_ID);
+ setTemplateType(templateTypeToSet);
+ } else {
+ console.error('❌ First valid template has no TMPL_ID:', firstTemplate);
+ }
+ } else {
+ console.log(`✅ Template already selected and valid: ${selectedTemplateId}`);
+ }
+ }
+ }, [templateData, selectedTemplateId, isValidTemplate, determineTemplateType, columnsJSON]);
+
+ const handleTemplateChange = (templateId: string) => {
+ const template = availableTemplates.find(t => t?.TMPL_ID === templateId);
+
+ // 🔍 템플릿과 TMPL_ID 검증
+ if (!template || !template.TMPL_ID) {
+ console.error('❌ Template not found or invalid TMPL_ID:', templateId);
+ return;
+ }
+
+ const templateTypeToSet = determineTemplateType(template);
+ setSelectedTemplateId(templateId);
+ setTemplateType(templateTypeToSet);
+ setHasChanges(false);
+ setValidationErrors([]);
+
+ if (currentSpread) {
+ initSpread(currentSpread, template);
+ }
+ };
+
+ const selectedTemplate = React.useMemo(() => {
+ console.log('🔍 Finding template:', {
+ selectedTemplateId,
+ availableCount: availableTemplates.length,
+ availableIds: availableTemplates.map(t => t?.TMPL_ID)
+ });
+
+ const found = availableTemplates.find(t => t?.TMPL_ID === selectedTemplateId);
+
+ if (!found && selectedTemplateId) {
+ console.warn('⚠️ Selected template not found:', {
+ searching: selectedTemplateId,
+ available: availableTemplates.map(t => t?.TMPL_ID),
+ availableTemplates: availableTemplates
+ });
+ } else if (found) {
+ console.log('✅ Template found:', {
+ TMPL_ID: found.TMPL_ID,
+ NAME: found.NAME,
+ TYPE: found.TMPL_TYPE
+ });
+ }
+
+ return found;
+ }, [availableTemplates, selectedTemplateId]);
+
+ const editableFields = React.useMemo(() => {
+ // SPREAD_ITEM의 경우에만 전역 editableFields 사용
+ if (templateType === 'SPREAD_ITEM' && selectedRow?.TAG_NO) {
+ if (!editableFieldsMap.has(selectedRow.TAG_NO)) {
+ return [];
+ }
+ return editableFieldsMap.get(selectedRow.TAG_NO) || [];
+ }
+
+ // SPREAD_LIST나 GRD_LIST의 경우 전역 editableFields는 사용하지 않음
+ return [];
+ }, [templateType, selectedRow?.TAG_NO, editableFieldsMap]);
+
+
+ const isFieldEditable = React.useCallback((attId: string, rowData?: GenericData) => {
+ const columnConfig = columnsJSON.find(col => col.key === attId);
+ if (columnConfig?.shi === "OUT" || columnConfig?.shi === null) {
+ return false;
+ }
+
+ if (attId === "TAG_NO" || attId === "TAG_DESC" || attId === "status") {
+ return false;
+ }
+
+ if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') {
+ // 각 행의 TAG_NO를 기준으로 편집 가능 여부 판단
+ if (!rowData?.TAG_NO || !editableFieldsMap.has(rowData.TAG_NO)) {
+ return false;
+ }
+
+ const rowEditableFields = editableFieldsMap.get(rowData.TAG_NO) || [];
+ if (!rowEditableFields.includes(attId)) {
+ return false;
+ }
+
+ if (rowData && (rowData.shi === "OUT" || rowData.shi === null)) {
+ return false;
+ }
+ return true;
+ }
+
+ // SPREAD_ITEM의 경우 기존 로직 유지
+ if (templateType === 'SPREAD_ITEM') {
+ return editableFields.includes(attId);
+ }
+
+ return true;
+ }, [templateType, columnsJSON, editableFieldsMap]); // editableFields 의존성 제거
+
+ const editableFieldsCount = React.useMemo(() => {
+ if (templateType === 'SPREAD_ITEM') {
+ // SPREAD_ITEM의 경우 기존 로직 유지
+ return cellMappings.filter(m => m.isEditable).length;
+ }
+
+ if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') {
+ // 각 행별로 편집 가능한 필드 수를 계산
+ let totalEditableCount = 0;
+
+ tableData.forEach((rowData, rowIndex) => {
+ cellMappings.forEach(mapping => {
+ if (mapping.dataRowIndex === rowIndex) {
+ if (isFieldEditable(mapping.attId, rowData)) {
+ totalEditableCount++;
+ }
+ }
+ });
+ });
+
+ return totalEditableCount;
+ }
+
+ return cellMappings.filter(m => m.isEditable).length;
+ }, [cellMappings, templateType, tableData, isFieldEditable]);
+
+ // 🚀 배치 처리 함수들
+ const setBatchValues = React.useCallback((
+ activeSheet: any,
+ valuesToSet: Array<{ row: number, col: number, value: any }>
+ ) => {
+ console.log(`🚀 Setting ${valuesToSet.length} values in batch`);
+
+ const columnGroups = new Map<number, Array<{ row: number, value: any }>>();
+
+ valuesToSet.forEach(({ row, col, value }) => {
+ if (!columnGroups.has(col)) {
+ columnGroups.set(col, []);
+ }
+ columnGroups.get(col)!.push({ row, value });
+ });
+
+ columnGroups.forEach((values, col) => {
+ values.sort((a, b) => a.row - b.row);
+
+ let start = 0;
+ while (start < values.length) {
+ let end = start;
+ while (end + 1 < values.length && values[end + 1].row === values[end].row + 1) {
+ end++;
+ }
+
+ const rangeValues = values.slice(start, end + 1).map(v => v.value);
+ const startRow = values[start].row;
+
+ try {
+ if (rangeValues.length === 1) {
+ activeSheet.setValue(startRow, col, rangeValues[0]);
+ } else {
+ const dataArray = rangeValues.map(v => [v]);
+ activeSheet.setArray(startRow, col, dataArray);
+ }
+ } catch (error) {
+ for (let i = start; i <= end; i++) {
+ try {
+ activeSheet.setValue(values[i].row, col, values[i].value);
+ } catch (cellError) {
+ console.warn(`⚠️ Individual value setting failed [${values[i].row}, ${col}]:`, cellError);
+ }
+ }
+ }
+
+ start = end + 1;
+ }
+ });
+ }, []);
+
+ const createCellStyle = React.useCallback((activeSheet: any, row: number, col: number, isEditable: boolean) => {
+ // 기존 스타일 가져오기 (없으면 새로 생성)
+ const existingStyle = activeSheet.getStyle(row, col) || new GC.Spread.Sheets.Style();
+
+ // backColor만 수정
+ if (isEditable) {
+ existingStyle.backColor = "#bbf7d0";
+ } else {
+ existingStyle.backColor = "#e5e7eb";
+ // 읽기 전용일 때만 텍스트 색상 변경 (선택사항)
+ existingStyle.foreColor = "#4b5563";
+ }
+
+ return existingStyle;
+ }, []);
+
+ const setBatchStyles = React.useCallback((
+ activeSheet: any,
+ stylesToSet: Array<{ row: number, col: number, isEditable: boolean }>
+ ) => {
+ console.log(`🎨 Setting ${stylesToSet.length} styles in batch`);
+
+ // 🔧 개별 셀별로 스타일과 잠금 상태 설정 (편집 권한 보장)
+ stylesToSet.forEach(({ row, col, isEditable }) => {
+ try {
+ const cell = activeSheet.getCell(row, col);
+ const style = createCellStyle(activeSheet, row, col, isEditable);
+
+ activeSheet.setStyle(row, col, style);
+ cell.locked(!isEditable); // 편집 가능하면 잠금 해제
+
+ // 🆕 편집 가능한 셀에 기본 텍스트 에디터 설정
+ if (isEditable) {
+ const textCellType = new GC.Spread.Sheets.CellTypes.Text();
+ activeSheet.setCellType(row, col, textCellType);
+ }
+ } catch (error) {
+ console.warn(`⚠️ Failed to set style for cell [${row}, ${col}]:`, error);
+ }
+ });
+ }, [createCellStyle]);
+
+ const parseCellAddress = (address: string): { row: number, col: number } | null => {
+ if (!address || address.trim() === "") return null;
+
+ const match = address.match(/^([A-Z]+)(\d+)$/);
+ if (!match) return null;
+
+ const [, colStr, rowStr] = match;
+
+ let col = 0;
+ for (let i = 0; i < colStr.length; i++) {
+ col = col * 26 + (colStr.charCodeAt(i) - 65 + 1);
+ }
+ col -= 1;
+
+ const row = parseInt(rowStr) - 1;
+ return { row, col };
+ };
+
+ const getCellAddress = (row: number, col: number): string => {
+ let colStr = '';
+ let colNum = col;
+ while (colNum >= 0) {
+ colStr = String.fromCharCode((colNum % 26) + 65) + colStr;
+ colNum = Math.floor(colNum / 26) - 1;
+ }
+ return colStr + (row + 1);
+ };
+
+ const validateCellValue = (value: any, columnType: ColumnType, options?: string[]): string | null => {
+ if (value === undefined || value === null || value === "") {
+ return null;
+ }
+
+ switch (columnType) {
+ case "NUMBER":
+ if (isNaN(Number(value))) {
+ return "Value must be a valid number";
+ }
+ break;
+ case "LIST":
+ if (options && !options.includes(String(value))) {
+ return `Value must be one of: ${options.join(", ")}`;
+ }
+ break;
+ case "STRING":
+ break;
+ default:
+ break;
+ }
+
+ return null;
+ };
+
+ const validateAllData = React.useCallback(() => {
+ if (!currentSpread || !selectedTemplate) return [];
+
+ const activeSheet = currentSpread.getActiveSheet();
+ const errors: ValidationError[] = [];
+
+ cellMappings.forEach(mapping => {
+ const columnConfig = columnsJSON.find(col => col.key === mapping.attId);
+ if (!columnConfig) return;
+
+ const cellPos = parseCellAddress(mapping.cellAddress);
+ if (!cellPos) return;
+
+ const cellValue = activeSheet.getValue(cellPos.row, cellPos.col);
+ const errorMessage = validateCellValue(cellValue, columnConfig.type, columnConfig.options);
+
+ if (errorMessage) {
+ errors.push({
+ cellAddress: mapping.cellAddress,
+ attId: mapping.attId,
+ value: cellValue,
+ expectedType: columnConfig.type,
+ message: errorMessage
+ });
+ }
+ });
+
+ setValidationErrors(errors);
+ return errors;
+ }, [currentSpread, selectedTemplate, cellMappings, columnsJSON]);
+
+
+
+ const setupOptimizedListValidation = React.useCallback((activeSheet: any, cellPos: { row: number, col: number }, options: string[], rowCount: number) => {
+ try {
+ console.log(`🎯 Setting up dropdown for ${rowCount} rows with options:`, options);
+
+ const safeOptions = options
+ .filter(opt => opt !== null && opt !== undefined && opt !== '')
+ .map(opt => String(opt).trim())
+ .filter(opt => opt.length > 0)
+ .filter((opt, index, arr) => arr.indexOf(opt) === index)
+ .slice(0, 20);
+
+ if (safeOptions.length === 0) {
+ console.warn(`⚠️ No valid options found, skipping`);
+ return;
+ }
+
+ const optionsString = safeOptions.join(',');
+
+ for (let i = 0; i < rowCount; i++) {
+ try {
+ const targetRow = cellPos.row + i;
+
+ // 🔧 각 셀마다 새로운 ComboBox 인스턴스 생성
+ const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox();
+ comboBoxCellType.items(safeOptions);
+ comboBoxCellType.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text);
+
+ // 🔧 DataValidation 설정
+ const cellValidator = GC.Spread.Sheets.DataValidation.createListValidator(optionsString);
+ cellValidator.showInputMessage(false);
+ cellValidator.showErrorMessage(false);
+
+ // ComboBox와 Validator 적용
+ activeSheet.setCellType(targetRow, cellPos.col, comboBoxCellType);
+ activeSheet.setDataValidator(targetRow, cellPos.col, cellValidator);
+
+ // 🚀 중요: 셀 잠금 해제 및 편집 가능 설정
+ const cell = activeSheet.getCell(targetRow, cellPos.col);
+ cell.locked(false);
+
+ console.log(`✅ Dropdown applied to [${targetRow}, ${cellPos.col}] with ${safeOptions.length} options`);
+
+ } catch (cellError) {
+ console.warn(`⚠️ Failed to apply dropdown to row ${cellPos.row + i}:`, cellError);
+ }
+ }
+
+ console.log(`✅ Dropdown setup completed for ${rowCount} cells`);
+
+ } catch (error) {
+ console.error('❌ Dropdown setup failed:', error);
+ }
+ }, []);
+
+ const getSafeActiveSheet = React.useCallback((spread: any, functionName: string = 'unknown') => {
+ if (!spread) return null;
+
+ try {
+ let activeSheet = spread.getActiveSheet();
+ if (!activeSheet) {
+ const sheetCount = spread.getSheetCount();
+ if (sheetCount > 0) {
+ activeSheet = spread.getSheet(0);
+ if (activeSheet) {
+ spread.setActiveSheetIndex(0);
+ }
+ }
+ }
+ return activeSheet;
+ } catch (error) {
+ console.error(`❌ Error getting activeSheet in ${functionName}:`, error);
+ return null;
+ }
+ }, []);
+
+ const ensureRowCapacity = React.useCallback((activeSheet: any, requiredRowCount: number) => {
+ try {
+ if (!activeSheet) return false;
+
+ const currentRowCount = activeSheet.getRowCount();
+ if (requiredRowCount > currentRowCount) {
+ const newRowCount = requiredRowCount + 10;
+ activeSheet.setRowCount(newRowCount);
+ }
+ return true;
+ } catch (error) {
+ console.error('❌ Error in ensureRowCapacity:', error);
+ return false;
+ }
+ }, []);
+
+ const ensureColumnCapacity = React.useCallback((activeSheet: any, requiredColumnCount: number) => {
+ try {
+ if (!activeSheet) return false;
+
+ const currentColumnCount = activeSheet.getColumnCount();
+ if (requiredColumnCount > currentColumnCount) {
+ const newColumnCount = requiredColumnCount + 10;
+ activeSheet.setColumnCount(newColumnCount);
+ }
+ return true;
+ } catch (error) {
+ console.error('❌ Error in ensureColumnCapacity:', error);
+ return false;
+ }
+ }, []);
+
+ const setOptimalColumnWidths = React.useCallback((activeSheet: any, columns: any[], startCol: number, tableData: any[]) => {
+ columns.forEach((column, colIndex) => {
+ const targetCol = startCol + colIndex;
+ const optimalWidth = column.type === 'NUMBER' ? 100 : column.type === 'STRING' ? 150 : 120;
+ activeSheet.setColumnWidth(targetCol, optimalWidth);
+ });
+ }, []);
+
+ // 🚀 최적화된 GRD_LIST 생성
+ // 🚀 최적화된 GRD_LIST 생성 (TAG_DESC 컬럼 틀고정 포함)
+ const createGrdListTableOptimized = React.useCallback((activeSheet: any, template: TemplateItem) => {
+ console.log('🚀 Creating optimized GRD_LIST table with TAG_DESC freeze');
+
+ const visibleColumns = columnsJSON
+ .filter(col => col.hidden !== true)
+ .sort((a, b) => (a.seq ?? 999999) - (b.seq ?? 999999));
+
+ if (visibleColumns.length === 0) return [];
+
+ const startCol = 1;
+ const dataStartRow = 1;
+ const mappings: CellMapping[] = [];
+
+ ensureColumnCapacity(activeSheet, startCol + visibleColumns.length);
+ ensureRowCapacity(activeSheet, dataStartRow + tableData.length);
+
+ // 🧊 TAG_DESC 컬럼 위치 찾기 (틀고정용)
+ const tagDescColumnIndex = visibleColumns.findIndex(col => col.key === 'TAG_DESC');
+ let freezeColumnCount = 0;
+
+ if (tagDescColumnIndex !== -1) {
+ // TAG_DESC 컬럼까지 포함해서 고정 (startCol + tagDescColumnIndex + 1)
+ freezeColumnCount = startCol + tagDescColumnIndex + 1;
+ console.log(`🧊 TAG_DESC found at column index ${tagDescColumnIndex}, freezing ${freezeColumnCount} columns`);
+ } else {
+ // TAG_DESC가 없으면 TAG_NO까지만 고정 (일반적으로 첫 번째 컬럼)
+ const tagNoColumnIndex = visibleColumns.findIndex(col => col.key === 'TAG_NO');
+ if (tagNoColumnIndex !== -1) {
+ freezeColumnCount = startCol + tagNoColumnIndex + 1;
+ console.log(`🧊 TAG_NO found at column index ${tagNoColumnIndex}, freezing ${freezeColumnCount} columns`);
+ }
+ }
+
+ // 헤더 생성
+ const headerStyle = new GC.Spread.Sheets.Style();
+ headerStyle.backColor = "#3b82f6";
+ headerStyle.foreColor = "#ffffff";
+ headerStyle.font = "bold 12px Arial";
+ headerStyle.hAlign = GC.Spread.Sheets.HorizontalAlign.center;
+
+ visibleColumns.forEach((column, colIndex) => {
+ const targetCol = startCol + colIndex;
+ const cell = activeSheet.getCell(0, targetCol);
+ cell.value(column.label);
+ cell.locked(true);
+ activeSheet.setStyle(0, targetCol, headerStyle);
+ });
+
+ // 🚀 데이터 배치 처리 준비
+ const allValues: Array<{ row: number, col: number, value: any }> = [];
+ const allStyles: Array<{ row: number, col: number, isEditable: boolean }> = [];
+
+ // 🔧 편집 가능한 셀 정보 수집 (드롭다운용)
+ const dropdownConfigs: Array<{
+ startRow: number;
+ col: number;
+ rowCount: number;
+ options: string[];
+ editableRows: number[]; // 편집 가능한 행만 추적
+ }> = [];
+
+ visibleColumns.forEach((column, colIndex) => {
+ const targetCol = startCol + colIndex;
+
+ // 드롭다운 설정을 위한 편집 가능한 행 찾기
+ if (column.type === "LIST" && column.options) {
+ const editableRows: number[] = [];
+ tableData.forEach((rowData, rowIndex) => {
+ if (isFieldEditable(column.key, rowData)) { // rowData 전달
+ editableRows.push(dataStartRow + rowIndex);
+ }
+ });
+
+ if (editableRows.length > 0) {
+ dropdownConfigs.push({
+ startRow: dataStartRow,
+ col: targetCol,
+ rowCount: tableData.length,
+ options: column.options,
+ editableRows: editableRows
+ });
+ }
+ }
+
+ tableData.forEach((rowData, rowIndex) => {
+ const targetRow = dataStartRow + rowIndex;
+ const cellEditable = isFieldEditable(column.key, rowData); // rowData 전달
+ const value = rowData[column.key];
+
+ mappings.push({
+ attId: column.key,
+ cellAddress: getCellAddress(targetRow, targetCol),
+ isEditable: cellEditable,
+ dataRowIndex: rowIndex
+ });
+
+ allValues.push({
+ row: targetRow,
+ col: targetCol,
+ value: value ?? null
+ });
+
+ allStyles.push({
+ row: targetRow,
+ col: targetCol,
+ isEditable: cellEditable
+ });
+ });
+ });
+
+ // 🚀 배치로 값과 스타일 설정
+ setBatchValues(activeSheet, allValues);
+ setBatchStyles(activeSheet, allStyles);
+
+ // 🎯 개선된 드롭다운 설정 (편집 가능한 셀에만)
+ dropdownConfigs.forEach(({ col, options, editableRows }) => {
+ try {
+ console.log(`🎯 Setting dropdown for column ${col}, editable rows: ${editableRows.length}`);
+
+ const safeOptions = options
+ .filter(opt => opt !== null && opt !== undefined && opt !== '')
+ .map(opt => String(opt).trim())
+ .filter(opt => opt.length > 0)
+ .slice(0, 20);
+
+ if (safeOptions.length === 0) return;
+
+ // 편집 가능한 행에만 드롭다운 적용
+ editableRows.forEach(targetRow => {
+ try {
+ const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox();
+ comboBoxCellType.items(safeOptions);
+ comboBoxCellType.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text);
+
+ const cellValidator = GC.Spread.Sheets.DataValidation.createListValidator(safeOptions.join(','));
+ cellValidator.showInputMessage(false);
+ cellValidator.showErrorMessage(false);
+
+ activeSheet.setCellType(targetRow, col, comboBoxCellType);
+ activeSheet.setDataValidator(targetRow, col, cellValidator);
+
+ // 🚀 편집 권한 명시적 설정
+ const cell = activeSheet.getCell(targetRow, col);
+ cell.locked(false);
+
+ console.log(`✅ Dropdown applied to editable cell [${targetRow}, ${col}]`);
+ } catch (cellError) {
+ console.warn(`⚠️ Failed to apply dropdown to [${targetRow}, ${col}]:`, cellError);
+ }
+ });
+ } catch (error) {
+ console.error(`❌ Dropdown config failed for column ${col}:`, error);
+ }
+ });
+
+ // 🧊 틀고정 설정
+ if (freezeColumnCount > 0) {
+ try {
+ activeSheet.frozenColumnCount(freezeColumnCount);
+ activeSheet.frozenRowCount(1); // 헤더 행도 고정
+
+ console.log(`🧊 Freeze applied: ${freezeColumnCount} columns, 1 row (header)`);
+
+ // 🎨 고정된 컬럼에 특별한 스타일 추가 (선택사항)
+ for (let col = 0; col < freezeColumnCount; col++) {
+ for (let row = 0; row <= tableData.length; row++) {
+ try {
+ const currentStyle = activeSheet.getStyle(row, col) || new GC.Spread.Sheets.Style();
+
+ if (row === 0) {
+ // 헤더는 기존 스타일 유지
+ continue;
+ } else {
+ // 데이터 셀에 고정 구분선 추가
+ if (col === freezeColumnCount - 1) {
+ currentStyle.borderRight = new GC.Spread.Sheets.LineBorder("#2563eb", GC.Spread.Sheets.LineStyle.medium);
+ activeSheet.setStyle(row, col, currentStyle);
+ }
+ }
+ } catch (styleError) {
+ console.warn(`⚠️ Failed to apply freeze border style to [${row}, ${col}]:`, styleError);
+ }
+ }
+ }
+ } catch (freezeError) {
+ console.error('❌ Failed to apply freeze:', freezeError);
+ }
+ }
+
+ setOptimalColumnWidths(activeSheet, visibleColumns, startCol, tableData);
+
+ console.log(`✅ Optimized GRD_LIST created with freeze:`);
+ console.log(` - Total mappings: ${mappings.length}`);
+ console.log(` - Editable cells: ${mappings.filter(m => m.isEditable).length}`);
+ console.log(` - Dropdown configs: ${dropdownConfigs.length}`);
+ console.log(` - Frozen columns: ${freezeColumnCount}`);
+
+ return mappings;
+ }, [tableData, columnsJSON, isFieldEditable, setBatchValues, setBatchStyles, ensureColumnCapacity, ensureRowCapacity, getCellAddress, setOptimalColumnWidths]);
+
+ const setupSheetProtectionAndEvents = React.useCallback((activeSheet: any, mappings: CellMapping[]) => {
+ console.log(`🛡️ Setting up protection and events for ${mappings.length} mappings`);
+
+ // 🔧 시트 보호 완전 해제 후 편집 권한 설정
+ activeSheet.options.isProtected = false;
+
+ // 🔧 편집 가능한 셀들을 위한 강화된 설정
+ mappings.forEach((mapping) => {
+ const cellPos = parseCellAddress(mapping.cellAddress);
+ if (!cellPos) return;
+
+ try {
+ const cell = activeSheet.getCell(cellPos.row, cellPos.col);
+ const columnConfig = columnsJSON.find(col => col.key === mapping.attId);
+
+ if (mapping.isEditable) {
+ // 🚀 편집 가능한 셀 설정 강화
+ cell.locked(false);
+
+ if (columnConfig?.type === "LIST" && columnConfig.options) {
+ // LIST 타입: 새 ComboBox 인스턴스 생성
+ const comboBox = new GC.Spread.Sheets.CellTypes.ComboBox();
+ comboBox.items(columnConfig.options);
+ comboBox.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text);
+ activeSheet.setCellType(cellPos.row, cellPos.col, comboBox);
+
+ // DataValidation도 추가
+ const validator = GC.Spread.Sheets.DataValidation.createListValidator(columnConfig.options.join(','));
+ activeSheet.setDataValidator(cellPos.row, cellPos.col, validator);
+ } else if (columnConfig?.type === "NUMBER") {
+ // NUMBER 타입: 숫자 입력 허용
+ const textCellType = new GC.Spread.Sheets.CellTypes.Text();
+ activeSheet.setCellType(cellPos.row, cellPos.col, textCellType);
+
+ // 숫자 validation 추가 (에러 메시지 없이)
+ const numberValidator = GC.Spread.Sheets.DataValidation.createNumberValidator(
+ GC.Spread.Sheets.ConditionalFormatting.ComparisonOperators.between,
+ -999999999, 999999999, true
+ );
+ numberValidator.showInputMessage(false);
+ numberValidator.showErrorMessage(false);
+ activeSheet.setDataValidator(cellPos.row, cellPos.col, numberValidator);
+ } else {
+ // 기본 TEXT 타입: 자유 텍스트 입력
+ const textCellType = new GC.Spread.Sheets.CellTypes.Text();
+ activeSheet.setCellType(cellPos.row, cellPos.col, textCellType);
+ }
+
+ // 편집 가능 스타일 재적용
+ const editableStyle = createCellStyle(activeSheet, cellPos.row, cellPos.col, true);
+ activeSheet.setStyle(cellPos.row, cellPos.col, editableStyle);
+
+ console.log(`🔓 Cell [${cellPos.row}, ${cellPos.col}] ${mapping.attId} set as EDITABLE`);
+ } else {
+ // 읽기 전용 셀
+ cell.locked(true);
+ const readonlyStyle = createCellStyle(activeSheet, cellPos.row, cellPos.col, false);
+ activeSheet.setStyle(cellPos.row, cellPos.col, readonlyStyle);
+ }
+ } catch (error) {
+ console.error(`❌ Error processing cell ${mapping.cellAddress}:`, error);
+ }
+ });
+
+ // 🛡️ 시트 보호 재설정 (편집 허용 모드로)
+ activeSheet.options.isProtected = false;
+ activeSheet.options.protectionOptions = {
+ allowSelectLockedCells: true,
+ allowSelectUnlockedCells: true,
+ allowSort: false,
+ allowFilter: false,
+ allowEditObjects: true, // ✅ 편집 객체 허용
+ allowResizeRows: false,
+ allowResizeColumns: false,
+ allowFormatCells: false,
+ allowInsertRows: false,
+ allowInsertColumns: false,
+ allowDeleteRows: false,
+ allowDeleteColumns: false
+ };
+
+ // 🎯 변경 감지 이벤트
+ const changeEvents = [
+ GC.Spread.Sheets.Events.CellChanged,
+ GC.Spread.Sheets.Events.ValueChanged,
+ GC.Spread.Sheets.Events.ClipboardPasted
+ ];
+
+ changeEvents.forEach(eventType => {
+ activeSheet.bind(eventType, () => {
+ console.log(`📝 ${eventType} detected`);
+ setHasChanges(true);
+ });
+ });
+
+ // 🚫 편집 시작 권한 확인
+ activeSheet.bind(GC.Spread.Sheets.Events.EditStarting, (event: any, info: any) => {
+ console.log(`🎯 EditStarting: Row ${info.row}, Col ${info.col}`);
+
+ const exactMapping = mappings.find(m => {
+ const cellPos = parseCellAddress(m.cellAddress);
+ return cellPos && cellPos.row === info.row && cellPos.col === info.col;
+ });
+
+ if (!exactMapping) {
+ console.log(`ℹ️ No mapping found for [${info.row}, ${info.col}] - allowing edit`);
+ return; // 매핑이 없으면 허용
+ }
+
+ console.log(`📋 Found mapping: ${exactMapping.attId}, isEditable: ${exactMapping.isEditable}`);
+
+ if (!exactMapping.isEditable) {
+ console.log(`🚫 Field ${exactMapping.attId} is not editable`);
+ toast.warning(`${exactMapping.attId} field is read-only`);
+ info.cancel = true;
+ return;
+ }
+
+ // SPREAD_LIST/GRD_LIST 개별 행 SHI 확인
+ if ((templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && exactMapping.dataRowIndex !== undefined) {
+ const dataRowIndex = exactMapping.dataRowIndex;
+ if (dataRowIndex >= 0 && dataRowIndex < tableData.length) {
+ const rowData = tableData[dataRowIndex];
+ if (rowData?.shi === "OUT" || rowData?.shi === null) {
+ console.log(`🚫 Row ${dataRowIndex} is in SHI mode`);
+ toast.warning(`Row ${dataRowIndex + 1}: ${exactMapping.attId} field is read-only (SHI mode)`);
+ info.cancel = true;
+ return;
+ }
+ }
+ }
+
+ console.log(`✅ Edit allowed for ${exactMapping.attId}`);
+ });
+
+ // ✅ 편집 완료 검증
+ activeSheet.bind(GC.Spread.Sheets.Events.EditEnded, (event: any, info: any) => {
+ console.log(`🏁 EditEnded: Row ${info.row}, Col ${info.col}, New value: ${activeSheet.getValue(info.row, info.col)}`);
+
+ const exactMapping = mappings.find(m => {
+ const cellPos = parseCellAddress(m.cellAddress);
+ return cellPos && cellPos.row === info.row && cellPos.col === info.col;
+ });
+
+ if (!exactMapping) return;
+
+ const columnConfig = columnsJSON.find(col => col.key === exactMapping.attId);
+ if (columnConfig) {
+ const cellValue = activeSheet.getValue(info.row, info.col);
+ const errorMessage = validateCellValue(cellValue, columnConfig.type, columnConfig.options);
+ const cell = activeSheet.getCell(info.row, info.col);
+
+ if (errorMessage) {
+ // 🚨 에러 스타일 적용
+ const errorStyle = new GC.Spread.Sheets.Style();
+ errorStyle.backColor = "#fef2f2";
+ errorStyle.foreColor = "#dc2626";
+ errorStyle.borderLeft = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick);
+ errorStyle.borderRight = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick);
+ errorStyle.borderTop = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick);
+ errorStyle.borderBottom = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick);
+
+ activeSheet.setStyle(info.row, info.col, errorStyle);
+ cell.locked(!exactMapping.isEditable); // 편집 가능 상태 유지
+ toast.warning(`Invalid value in ${exactMapping.attId}: ${errorMessage}`, { duration: 5000 });
+ } else {
+ // ✅ 정상 스타일 복원
+ const normalStyle = createCellStyle(activeSheet, info.row, info.col, exactMapping.isEditable);
+ activeSheet.setStyle(info.row, info.col, normalStyle);
+ cell.locked(!exactMapping.isEditable);
+ }
+ }
+
+ setHasChanges(true);
+ });
+
+ console.log(`🛡️ Protection configured. Editable cells: ${mappings.filter(m => m.isEditable).length}`);
+ }, [templateType, tableData, createCellStyle, validateCellValue, columnsJSON]);
+
+ // 🚀 최적화된 initSpread
+ const initSpread = React.useCallback(async (spread: any, template?: TemplateItem) => {
+ const workingTemplate = template || selectedTemplate;
+ if (!spread || !workingTemplate) {
+ console.error('❌ Invalid spread or template');
+ return;
+ }
+
+ try {
+ console.log('🚀 Starting optimized spread initialization...');
+ setIsInitializing(true);
+ updateProgress('Initializing...', 0, 100);
+
+ setCurrentSpread(spread);
+ setHasChanges(false);
+ setValidationErrors([]);
+
+ // 🚀 핵심 최적화: 모든 렌더링과 이벤트 중단
+ spread.suspendPaint();
+ spread.suspendEvent();
+ spread.suspendCalcService();
+
+ updateProgress('Setting up workspace...', 10, 100);
+
+ try {
+ let activeSheet = getSafeActiveSheet(spread, 'initSpread');
+ if (!activeSheet) {
+ throw new Error('Failed to get initial activeSheet');
+ }
+
+ activeSheet.options.isProtected = false;
+ let mappings: CellMapping[] = [];
+
+ if (templateType === 'GRD_LIST') {
+ updateProgress('Creating dynamic table...', 20, 100);
+
+ spread.clearSheets();
+ spread.addSheet(0);
+ const sheet = spread.getSheet(0);
+ sheet.name('Data');
+ spread.setActiveSheet('Data');
+
+ updateProgress('Processing table data...', 50, 100);
+ mappings = createGrdListTableOptimized(sheet, workingTemplate);
+
+ } else {
+ updateProgress('Loading template structure...', 20, 100);
+
+ let contentJson = workingTemplate.SPR_LST_SETUP?.CONTENT || workingTemplate.SPR_ITM_LST_SETUP?.CONTENT;
+ let dataSheets = workingTemplate.SPR_LST_SETUP?.DATA_SHEETS || workingTemplate.SPR_ITM_LST_SETUP?.DATA_SHEETS;
+
+ if (!contentJson || !dataSheets) {
+ throw new Error(`No template content found for ${workingTemplate.NAME}`);
+ }
+
+ const jsonData = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
+
+ updateProgress('Loading template layout...', 40, 100);
+ spread.fromJSON(jsonData);
+
+ activeSheet = getSafeActiveSheet(spread, 'after-fromJSON');
+ if (!activeSheet) {
+ throw new Error('ActiveSheet became null after loading template');
+ }
+
+ activeSheet.options.isProtected = false;
+
+ if (templateType === 'SPREAD_LIST' && tableData.length > 0) {
+ updateProgress('Processing data rows...', 60, 100);
+
+ const activeSheetName = workingTemplate.SPR_LST_SETUP?.ACT_SHEET;
+
+
+ // 🔧 각 DATA_SHEET별로 처리
+ dataSheets.forEach(dataSheet => {
+ const sheetName = dataSheet.SHEET_NAME;
+
+ // 해당 시트가 존재하는지 확인
+ const targetSheet = spread.getSheetFromName(sheetName);
+ if (!targetSheet) {
+ console.warn(`⚠️ Sheet '${sheetName}' not found in template`);
+ return;
+ }
+
+ console.log(`📋 Processing sheet: ${sheetName}`);
+
+ // 해당 시트로 전환
+ spread.setActiveSheet(sheetName);
+ const currentSheet = spread.getActiveSheet();
+
+ if (dataSheet.MAP_CELL_ATT && dataSheet.MAP_CELL_ATT.length > 0) {
+ dataSheet.MAP_CELL_ATT.forEach((mapping: any) => {
+ const { ATT_ID, IN } = mapping;
+ if (!ATT_ID || !IN || IN.trim() === "") return;
+
+ const cellPos = parseCellAddress(IN);
+ if (!cellPos) return;
+
+ const requiredRows = cellPos.row + tableData.length;
+ if (!ensureRowCapacity(currentSheet, requiredRows)) return;
+
+ // 🚀 배치 데이터 준비
+ const valuesToSet: Array<{ row: number, col: number, value: any }> = [];
+ const stylesToSet: Array<{ row: number, col: number, isEditable: boolean }> = [];
+
+ tableData.forEach((rowData, index) => {
+ const targetRow = cellPos.row + index;
+ const cellEditable = isFieldEditable(ATT_ID, rowData);
+ const value = rowData[ATT_ID];
+
+ mappings.push({
+ attId: ATT_ID,
+ cellAddress: getCellAddress(targetRow, cellPos.col),
+ isEditable: cellEditable,
+ dataRowIndex: index
+ });
+
+ valuesToSet.push({
+ row: targetRow,
+ col: cellPos.col,
+ value: value ?? null
+ });
+
+ stylesToSet.push({
+ row: targetRow,
+ col: cellPos.col,
+ isEditable: cellEditable
+ });
+ });
+
+ // 🚀 배치 처리
+ setBatchValues(currentSheet, valuesToSet);
+ setBatchStyles(currentSheet, stylesToSet);
+
+ // 드롭다운 설정
+ const columnConfig = columnsJSON.find(col => col.key === ATT_ID);
+ if (columnConfig?.type === "LIST" && columnConfig.options) {
+ const hasEditableRows = tableData.some((rowData) => isFieldEditable(ATT_ID, rowData));
+ if (hasEditableRows) {
+ setupOptimizedListValidation(currentSheet, cellPos, columnConfig.options, tableData.length);
+ }
+ }
+ });
+ }
+ });
+
+ // 🔧 마지막에 activeSheetName으로 다시 전환
+ if (activeSheetName && spread.getSheetFromName(activeSheetName)) {
+ spread.setActiveSheet(activeSheetName);
+ activeSheet = spread.getActiveSheet();
+ }
+
+ } else if (templateType === 'SPREAD_ITEM' && selectedRow) {
+ updateProgress('Setting up form fields...', 60, 100);
+ const activeSheetName = workingTemplate.SPR_ITM_LST_SETUP?.ACT_SHEET;
+
+ if (activeSheetName && spread.getSheetFromName(activeSheetName)) {
+ spread.setActiveSheet(activeSheetName);
+ }
+
+ dataSheets.forEach(dataSheet => {
+
+ const sheetName = dataSheet.SHEET_NAME;
+ // 해당 시트가 존재하는지 확인
+ const targetSheet = spread.getSheetFromName(sheetName);
+ if (!targetSheet) {
+ console.warn(`⚠️ Sheet '${sheetName}' not found in template`);
+ return;
+ }
+
+ console.log(`📋 Processing sheet: ${sheetName}`);
+
+ // 해당 시트로 전환
+ spread.setActiveSheet(sheetName);
+ const currentSheet = spread.getActiveSheet();
+
+
+ dataSheet.MAP_CELL_ATT?.forEach((mapping: any) => {
+ const { ATT_ID, IN } = mapping;
+ const cellPos = parseCellAddress(IN);
+
+
+ if (cellPos) {
+ const isEditable = isFieldEditable(ATT_ID);
+ const value = selectedRow[ATT_ID];
+
+ mappings.push({
+ attId: ATT_ID,
+ cellAddress: IN,
+ isEditable: isEditable,
+ dataRowIndex: 0
+ });
+
+ const cell = currentSheet.getCell(cellPos.row, cellPos.col);
+ cell.value(value ?? null);
+
+ const style = createCellStyle(currentSheet, cellPos.row, cellPos.col, isEditable);
+ currentSheet.setStyle(cellPos.row, cellPos.col, style);
+
+ const columnConfig = columnsJSON.find(col => col.key === ATT_ID);
+ if (columnConfig?.type === "LIST" && columnConfig.options && isEditable) {
+ setupOptimizedListValidation(currentSheet, cellPos, columnConfig.options, 1);
+ }
+ }
+ });
+
+ // 🔧 마지막에 activeSheetName으로 다시 전환
+ if (activeSheetName && spread.getSheetFromName(activeSheetName)) {
+ spread.setActiveSheet(activeSheetName);
+ activeSheet = spread.getActiveSheet();
+ }
+
+
+ });
+ }
+ }
+
+ updateProgress('Configuring interactions...', 90, 100);
+ setCellMappings(mappings);
+
+ const finalActiveSheet = getSafeActiveSheet(spread, 'setupEvents');
+ if (finalActiveSheet) {
+ setupSheetProtectionAndEvents(finalActiveSheet, mappings);
+ }
+
+ updateProgress('Finalizing...', 100, 100);
+ console.log(`✅ Optimized initialization completed with ${mappings.length} mappings`);
+
+ } finally {
+ // 🚀 올바른 순서로 재개
+ spread.resumeCalcService();
+ spread.resumeEvent();
+ spread.resumePaint();
+ }
+
+ } catch (error) {
+ console.error('❌ Error in optimized spread initialization:', error);
+ if (spread?.resumeCalcService) spread.resumeCalcService();
+ if (spread?.resumeEvent) spread.resumeEvent();
+ if (spread?.resumePaint) spread.resumePaint();
+ toast.error(`Template loading failed: ${error.message}`);
+ } finally {
+ setIsInitializing(false);
+ setLoadingProgress(null);
+ }
+ }, [selectedTemplate, templateType, selectedRow, tableData, updateProgress, getSafeActiveSheet, createGrdListTableOptimized, setBatchValues, setBatchStyles, setupSheetProtectionAndEvents, setCellMappings, createCellStyle, isFieldEditable, columnsJSON, setupOptimizedListValidation, parseCellAddress, ensureRowCapacity, getCellAddress]);
+
+ React.useEffect(() => {
+ // 🔍 안전성 검증: availableTemplates가 있고, selectedTemplateId가 없을 때만 실행
+ if (!selectedTemplateId && availableTemplates.length > 0) {
+ const only = availableTemplates[0];
+
+ // 🔍 TMPL_ID 검증
+ if (!only || !only.TMPL_ID) {
+ console.error('❌ First template has no TMPL_ID:', only);
+ return;
+ }
+
+ const type = determineTemplateType(only);
+
+ // 🔍 type이 null이 아닐 때만 진행
+ if (!type) {
+ console.warn('⚠️ Could not determine template type for:', only);
+ return;
+ }
+
+ // 선택되어 있지 않다면 자동 선택
+ setSelectedTemplateId(only.TMPL_ID);
+ setTemplateType(type);
+
+ // 이미 스프레드가 마운트되어 있다면 즉시 초기화
+ if (currentSpread) {
+ initSpread(currentSpread, only);
+ }
+ }
+ }, [
+ availableTemplates,
+ selectedTemplateId,
+ currentSpread,
+ determineTemplateType,
+ initSpread
+ ]);
+
+ const handleSaveChanges = React.useCallback(async () => {
+ if (!currentSpread || !hasChanges) {
+ toast.info("No changes to save");
+ return;
+ }
+
+ const errors = validateAllData();
+ if (errors.length > 0) {
+ toast.error(`Cannot save: ${errors.length} validation errors found. Please fix them first.`);
+ return;
+ }
+
+ try {
+ setIsPending(true);
+ const activeSheet = currentSpread.getActiveSheet();
+
+ if (templateType === 'SPREAD_ITEM' && selectedRow) {
+ const dataToSave = { ...selectedRow };
+
+ cellMappings.forEach(mapping => {
+ if (mapping.isEditable) {
+ const cellPos = parseCellAddress(mapping.cellAddress);
+ if (cellPos) {
+ const cellValue = activeSheet.getValue(cellPos.row, cellPos.col);
+ dataToSave[mapping.attId] = cellValue;
+ }
+ }
+ });
+
+ dataToSave.TAG_NO = selectedRow.TAG_NO;
+
+ const { success, message } = await updateFormDataInDB(
+ formCode,
+ contractItemId,
+ dataToSave
+ );
+
+ if (!success) {
+ toast.error(message);
+ return;
+ }
+
+ toast.success("Changes saved successfully!");
+ onUpdateSuccess?.(dataToSave);
+
+ } else if ((templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && tableData.length > 0) {
+ console.log('🔍 Starting batch save process...');
+
+ const updatedRows: GenericData[] = [];
+ let saveCount = 0;
+ let checkedCount = 0;
+
+ for (let i = 0; i < tableData.length; i++) {
+ const originalRow = tableData[i];
+ const dataToSave = { ...originalRow };
+ let hasRowChanges = false;
+
+ console.log(`🔍 Processing row ${i} (TAG_NO: ${originalRow.TAG_NO})`);
+
+ cellMappings.forEach(mapping => {
+ if (mapping.dataRowIndex === i && mapping.isEditable) {
+ checkedCount++;
+
+ // 🔧 isFieldEditable과 동일한 로직 사용
+ const rowData = tableData[i];
+ const fieldEditable = isFieldEditable(mapping.attId, rowData);
+
+ console.log(` 📝 Field ${mapping.attId}: fieldEditable=${fieldEditable}, mapping.isEditable=${mapping.isEditable}`);
+
+ if (fieldEditable) {
+ const cellPos = parseCellAddress(mapping.cellAddress);
+ if (cellPos) {
+ const cellValue = activeSheet.getValue(cellPos.row, cellPos.col);
+ const originalValue = originalRow[mapping.attId];
+
+ // 🔧 개선된 값 비교 (타입 변환 및 null/undefined 처리)
+ const normalizedCellValue = cellValue === null || cellValue === undefined ? "" : String(cellValue).trim();
+ const normalizedOriginalValue = originalValue === null || originalValue === undefined ? "" : String(originalValue).trim();
+
+ console.log(` 🔍 ${mapping.attId}: "${normalizedOriginalValue}" -> "${normalizedCellValue}"`);
+
+ if (normalizedCellValue !== normalizedOriginalValue) {
+ dataToSave[mapping.attId] = cellValue;
+ hasRowChanges = true;
+ console.log(` ✅ Change detected for ${mapping.attId}`);
+ }
+ }
+ }
+ }
+ });
+
+ if (hasRowChanges) {
+ console.log(`💾 Saving row ${i} with changes`);
+ dataToSave.TAG_NO = originalRow.TAG_NO;
+
+ try {
+ const { success, message } = await updateFormDataInDB(
+ formCode,
+ contractItemId,
+ dataToSave
+ );
+
+ if (success) {
+ updatedRows.push(dataToSave);
+ saveCount++;
+ console.log(`✅ Row ${i} saved successfully`);
+ } else {
+ console.error(`❌ Failed to save row ${i}: ${message}`);
+ toast.error(`Failed to save row ${i + 1}: ${message}`);
+ updatedRows.push(originalRow); // 원본 데이터 유지
+ }
+ } catch (error) {
+ console.error(`❌ Error saving row ${i}:`, error);
+ toast.error(`Error saving row ${i + 1}`);
+ updatedRows.push(originalRow); // 원본 데이터 유지
+ }
+ } else {
+ updatedRows.push(originalRow);
+ console.log(`ℹ️ No changes in row ${i}`);
+ }
+ }
+
+ console.log(`📊 Save summary: ${saveCount} saved, ${checkedCount} fields checked`);
+
+ if (saveCount > 0) {
+ toast.success(`${saveCount} rows saved successfully!`);
+ onUpdateSuccess?.(updatedRows);
+ } else {
+ console.warn(`⚠️ No changes detected despite hasChanges=${hasChanges}`);
+ toast.warning("No actual changes were found to save. Please check if the values were properly edited.");
+ }
+ }
+
+ setHasChanges(false);
+ setValidationErrors([]);
+
+ } catch (error) {
+ console.error("Error saving changes:", error);
+ toast.error("An unexpected error occurred while saving");
+ } finally {
+ setIsPending(false);
+ }
+ }, [
+ currentSpread,
+ hasChanges,
+ templateType,
+ selectedRow,
+ tableData,
+ formCode,
+ contractItemId,
+ onUpdateSuccess,
+ cellMappings,
+ columnsJSON,
+ validateAllData,
+ isFieldEditable // 🔧 의존성 추가
+ ]);
+
+ if (!isOpen) return null;
+
+ const isDataValid = templateType === 'SPREAD_ITEM' ? !!selectedRow : tableData.length > 0;
+ const dataCount = templateType === 'SPREAD_ITEM' ? 1 : tableData.length;
+
+
+
+ return (
+ <Dialog open={isOpen} onOpenChange={onClose}>
+ <DialogContent
+ className="w-[95vw] max-w-[95vw] h-[90vh] flex flex-col fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] z-50"
+ >
+ <DialogHeader className="flex-shrink-0">
+ <DialogTitle>SEDP Template - {formCode}</DialogTitle>
+ <DialogDescription>
+ <div className="space-y-3">
+ {availableTemplates.length > 1 ? (
+ // 🔍 템플릿이 2개 이상일 때: Select 박스 표시
+ <div className="flex items-center gap-4">
+ <span className="text-sm font-medium">Template:</span>
+ <Select value={selectedTemplateId} onValueChange={handleTemplateChange}>
+ <SelectTrigger className="w-64">
+ <SelectValue placeholder="Select a template" />
+ </SelectTrigger>
+ <SelectContent>
+ {availableTemplates
+ .filter(template => template?.TMPL_ID) // 🔍 TMPL_ID가 있는 것만 표시
+ .map(template => (
+ <SelectItem key={template.TMPL_ID} value={template.TMPL_ID}>
+ {template.NAME || 'Unnamed'} ({template.TMPL_TYPE || 'Unknown'})
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ ) : availableTemplates.length === 1 ? (
+ // 🔍 템플릿이 정확히 1개일 때: 템플릿 이름을 텍스트로 표시
+ <div className="flex items-center gap-4">
+ <span className="text-sm font-medium">Template:</span>
+ <span className="text-sm text-blue-600 font-medium">
+ {availableTemplates[0]?.NAME || 'Unnamed'} ({availableTemplates[0]?.TMPL_TYPE || 'Unknown'})
+ </span>
+ </div>
+ ) : null}
+
+ {selectedTemplate && (
+ <div className="flex items-center gap-4 text-sm">
+ <span className="font-medium text-blue-600">
+ Template Type: {
+ templateType === 'SPREAD_LIST' ? 'List View (SPREAD_LIST)' :
+ templateType === 'SPREAD_ITEM' ? 'Item View (SPREAD_ITEM)' :
+ 'Grid List View (GRD_LIST)'
+ }
+ </span>
+ {templateType === 'SPREAD_ITEM' && selectedRow && (
+ <span>• Selected TAG_NO: {selectedRow.TAG_NO || 'N/A'}</span>
+ )}
+ {(templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && (
+ <span>• {dataCount} rows</span>
+ )}
+ {hasChanges && (
+ <span className="text-orange-600 font-medium">
+ • Unsaved changes
+ </span>
+ )}
+ {validationErrors.length > 0 && (
+ <span className="text-red-600 font-medium flex items-center">
+ <AlertTriangle className="w-4 h-4 mr-1" />
+ {validationErrors.length} validation errors
+ </span>
+ )}
+ </div>
+ )}
+
+ <div className="flex items-center gap-4 text-xs">
+ <span className="text-muted-foreground">
+ <span className="inline-block w-3 h-3 bg-green-100 border border-green-400 mr-1"></span>
+ Editable fields
+ </span>
+ <span className="text-muted-foreground">
+ <span className="inline-block w-3 h-3 bg-gray-100 border border-gray-300 mr-1"></span>
+ Read-only fields
+ </span>
+ <span className="text-muted-foreground">
+ <span className="inline-block w-3 h-3 bg-red-100 border border-red-300 mr-1"></span>
+ Validation errors
+ </span>
+ {cellMappings.length > 0 && (
+ <span className="text-blue-600">
+ {editableFieldsCount} of {cellMappings.length} fields editable
+ </span>
+ )}
+ </div>
+ </div>
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="flex-1 overflow-hidden relative">
+ {/* 🆕 로딩 프로그레스 오버레이 */}
+ <LoadingProgress
+ phase={loadingProgress?.phase || ''}
+ progress={loadingProgress?.progress || 0}
+ total={loadingProgress?.total || 100}
+ isVisible={isInitializing && !!loadingProgress}
+ />
+
+ {selectedTemplate && isClient && isDataValid ? (
+ <SpreadSheets
+ key={`${templateType}-${selectedTemplate?.TMPL_ID || 'unknown'}-${selectedTemplateId}`}
+ workbookInitialized={initSpread}
+ hostStyle={hostStyle}
+ />
+ ) : (
+ <div className="flex items-center justify-center h-full text-muted-foreground">
+ {!isClient ? (
+ <>
+ <Loader className="mr-2 h-4 w-4 animate-spin" />
+ Loading...
+ </>
+ ) : !selectedTemplate ? (
+ "No template available"
+ ) : !isDataValid ? (
+ `No ${templateType === 'SPREAD_ITEM' ? 'selected row' : 'data'} available`
+ ) : (
+ "Template not ready"
+ )}
+ </div>
+ )}
+ </div>
+
+ <DialogFooter className="flex-shrink-0">
+ <div className="flex items-center gap-2">
+ <Button variant="outline" onClick={onClose}>
+ Close
+ </Button>
+
+ {hasChanges && (
+ <Button
+ variant="default"
+ onClick={handleSaveChanges}
+ disabled={isPending || validationErrors.length > 0}
+ >
+ {isPending ? (
+ <>
+ <Loader className="mr-2 h-4 w-4 animate-spin" />
+ Saving...
+ </>
+ ) : (
+ <>
+ <Save className="mr-2 h-4 w-4" />
+ Save Changes
+ </>
+ )}
+ </Button>
+ )}
+
+ {validationErrors.length > 0 && (
+ <Button
+ variant="outline"
+ onClick={validateAllData}
+ className="text-red-600 border-red-300 hover:bg-red-50"
+ >
+ <AlertTriangle className="mr-2 h-4 w-4" />
+ Check Errors ({validationErrors.length})
+ </Button>
+ )}
+ </div>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+} \ No newline at end of file
diff --git a/components/form-data-plant/spreadJS-dialog_designer.tsx b/components/form-data-plant/spreadJS-dialog_designer.tsx
new file mode 100644
index 00000000..44152a62
--- /dev/null
+++ b/components/form-data-plant/spreadJS-dialog_designer.tsx
@@ -0,0 +1,1404 @@
+"use client";
+
+import * as React from "react";
+import dynamic from "next/dynamic";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { GenericData } from "./export-excel-form";
+import * as GC from "@mescius/spread-sheets";
+import { toast } from "sonner";
+import { updateFormDataInDB } from "@/lib/forms-plant/services";
+import { Loader, Save, AlertTriangle } from "lucide-react";
+import '@mescius/spread-sheets/styles/gc.spread.sheets.excel2016colorful.css';
+import { DataTableColumnJSON, ColumnType } from "./form-data-table-columns";
+
+const Designer = dynamic(
+ () => import("@mescius/spread-sheets-designer-react").then(mod => mod.Designer),
+ {
+ ssr: false,
+ loading: () => (
+ <div className="flex items-center justify-center h-full">
+ <Loader className="mr-2 h-4 w-4 animate-spin" />
+ Loading Designer...
+ </div>
+ )
+ }
+);
+
+// 라이센스 키 설정 (두 개의 환경변수 사용)
+if (typeof window !== 'undefined') {
+ if (process.env.NEXT_PUBLIC_SPREAD_LICENSE) {
+ GC.Spread.Sheets.LicenseKey = process.env.NEXT_PUBLIC_SPREAD_LICENSE;
+ // ExcelIO가 사용 가능한 경우에만 설정
+ if (typeof (window as any).ExcelIO !== 'undefined') {
+ (window as any).ExcelIO.LicenseKey = process.env.NEXT_PUBLIC_SPREAD_LICENSE;
+ }
+ }
+
+ if (process.env.NEXT_PUBLIC_DESIGNER_LICENSE) {
+ // Designer 라이센스 키 설정
+ if (GC.Spread.Sheets.Designer) {
+ GC.Spread.Sheets.Designer.LicenseKey = process.env.NEXT_PUBLIC_DESIGNER_LICENSE;
+ }
+ }
+}
+
+interface TemplateItem {
+ TMPL_ID: string;
+ NAME: string;
+ TMPL_TYPE: string;
+ SPR_LST_SETUP: {
+ ACT_SHEET: string;
+ HIDN_SHEETS: Array<string>;
+ CONTENT?: string;
+ DATA_SHEETS: Array<{
+ SHEET_NAME: string;
+ REG_TYPE_ID: string;
+ MAP_CELL_ATT: Array<{
+ ATT_ID: string;
+ IN: string;
+ }>;
+ }>;
+ };
+ GRD_LST_SETUP: {
+ REG_TYPE_ID: string;
+ SPR_ITM_IDS: Array<string>;
+ ATTS: Array<{}>;
+ };
+ SPR_ITM_LST_SETUP: {
+ ACT_SHEET: string;
+ HIDN_SHEETS: Array<string>;
+ CONTENT?: string;
+ DATA_SHEETS: Array<{
+ SHEET_NAME: string;
+ REG_TYPE_ID: string;
+ MAP_CELL_ATT: Array<{
+ ATT_ID: string;
+ IN: string;
+ }>;
+ }>;
+ };
+}
+
+interface ValidationError {
+ cellAddress: string;
+ attId: string;
+ value: any;
+ expectedType: ColumnType;
+ message: string;
+}
+
+interface CellMapping {
+ attId: string;
+ cellAddress: string;
+ isEditable: boolean;
+ dataRowIndex?: number;
+}
+
+interface TemplateViewDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ templateData: TemplateItem[] | any;
+ selectedRow?: GenericData;
+ tableData?: GenericData[];
+ formCode: string;
+ columnsJSON: DataTableColumnJSON[]
+ contractItemId: number;
+ editableFieldsMap?: Map<string, string[]>;
+ onUpdateSuccess?: (updatedValues: Record<string, any> | GenericData[]) => void;
+}
+
+// 🚀 로딩 프로그레스 컴포넌트
+interface LoadingProgressProps {
+ phase: string;
+ progress: number;
+ total: number;
+ isVisible: boolean;
+}
+
+const LoadingProgress: React.FC<LoadingProgressProps> = ({ phase, progress, total, isVisible }) => {
+ const percentage = total > 0 ? Math.round((progress / total) * 100) : 0;
+
+ if (!isVisible) return null;
+
+ return (
+ <div className="absolute inset-0 bg-white/90 flex items-center justify-center z-50">
+ <div className="bg-white rounded-lg shadow-lg border p-6 min-w-[300px]">
+ <div className="flex items-center space-x-3 mb-4">
+ <Loader className="h-5 w-5 animate-spin text-blue-600" />
+ <span className="font-medium text-gray-900">Loading Template</span>
+ </div>
+
+ <div className="space-y-2">
+ <div className="text-sm text-gray-600">{phase}</div>
+ <div className="w-full bg-gray-200 rounded-full h-2">
+ <div
+ className="bg-blue-600 h-2 rounded-full transition-all duration-300 ease-out"
+ style={{ width: `${percentage}%` }}
+ />
+ </div>
+ <div className="text-xs text-gray-500 text-right">
+ {progress.toLocaleString()} / {total.toLocaleString()} ({percentage}%)
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+};
+
+export function TemplateViewDialog({
+ isOpen,
+ onClose,
+ templateData,
+ selectedRow,
+ tableData = [],
+ formCode,
+ contractItemId,
+ columnsJSON,
+ editableFieldsMap = new Map(),
+ onUpdateSuccess
+}: TemplateViewDialogProps) {
+ const [hostStyle, setHostStyle] = React.useState({
+ width: '100%',
+ height: '100%'
+ });
+
+ const [isPending, setIsPending] = React.useState(false);
+ const [hasChanges, setHasChanges] = React.useState(false);
+ const [currentSpread, setCurrentSpread] = React.useState<any>(null);
+ const [cellMappings, setCellMappings] = React.useState<CellMapping[]>([]);
+ const [isClient, setIsClient] = React.useState(false);
+ const [templateType, setTemplateType] = React.useState<'SPREAD_LIST' | 'SPREAD_ITEM' | 'GRD_LIST' | null>(null);
+ const [validationErrors, setValidationErrors] = React.useState<ValidationError[]>([]);
+ const [selectedTemplateId, setSelectedTemplateId] = React.useState<string>("");
+ const [availableTemplates, setAvailableTemplates] = React.useState<TemplateItem[]>([]);
+
+ // 🆕 로딩 상태 추가
+ const [loadingProgress, setLoadingProgress] = React.useState<{
+ phase: string;
+ progress: number;
+ total: number;
+ } | null>(null);
+ const [isInitializing, setIsInitializing] = React.useState(false);
+
+ // 🔄 진행상황 업데이트 함수
+ const updateProgress = React.useCallback((phase: string, progress: number, total: number) => {
+ setLoadingProgress({ phase, progress, total });
+ }, []);
+
+ const determineTemplateType = React.useCallback((template: TemplateItem): 'SPREAD_LIST' | 'SPREAD_ITEM' | 'GRD_LIST' | null => {
+ if (template.TMPL_TYPE === "SPREAD_LIST" && (template.SPR_LST_SETUP?.CONTENT || template.SPR_ITM_LST_SETUP?.CONTENT)) {
+ return 'SPREAD_LIST';
+ }
+ if (template.TMPL_TYPE === "SPREAD_ITEM" && (template.SPR_LST_SETUP?.CONTENT || template.SPR_ITM_LST_SETUP?.CONTENT)) {
+ return 'SPREAD_ITEM';
+ }
+ if (template.GRD_LST_SETUP && columnsJSON.length > 0) {
+ return 'GRD_LIST';
+ }
+ return null;
+ }, [columnsJSON]);
+
+ const isValidTemplate = React.useCallback((template: TemplateItem): boolean => {
+ return determineTemplateType(template) !== null;
+ }, [determineTemplateType]);
+
+ React.useEffect(() => {
+ setIsClient(true);
+ }, []);
+
+ React.useEffect(() => {
+ if (!templateData) return;
+
+ let templates: TemplateItem[];
+ if (Array.isArray(templateData)) {
+ templates = templateData as TemplateItem[];
+ } else {
+ templates = [templateData as TemplateItem];
+ }
+
+ const validTemplates = templates.filter(isValidTemplate);
+ setAvailableTemplates(validTemplates);
+
+ if (validTemplates.length > 0 && !selectedTemplateId) {
+ const firstTemplate = validTemplates[0];
+ const templateTypeToSet = determineTemplateType(firstTemplate);
+ setSelectedTemplateId(firstTemplate.TMPL_ID);
+ setTemplateType(templateTypeToSet);
+ }
+ }, [templateData, selectedTemplateId, isValidTemplate, determineTemplateType]);
+
+ const handleTemplateChange = (templateId: string) => {
+ const template = availableTemplates.find(t => t.TMPL_ID === templateId);
+ if (template) {
+ const templateTypeToSet = determineTemplateType(template);
+ setSelectedTemplateId(templateId);
+ setTemplateType(templateTypeToSet);
+ setHasChanges(false);
+ setValidationErrors([]);
+
+ if (currentSpread && template) {
+ initSpread(currentSpread, template);
+ }
+ }
+ };
+
+ const selectedTemplate = React.useMemo(() => {
+ return availableTemplates.find(t => t.TMPL_ID === selectedTemplateId);
+ }, [availableTemplates, selectedTemplateId]);
+
+ const editableFields = React.useMemo(() => {
+ // SPREAD_ITEM의 경우에만 전역 editableFields 사용
+ if (templateType === 'SPREAD_ITEM' && selectedRow?.TAG_NO) {
+ if (!editableFieldsMap.has(selectedRow.TAG_NO)) {
+ return [];
+ }
+ return editableFieldsMap.get(selectedRow.TAG_NO) || [];
+ }
+
+ // SPREAD_LIST나 GRD_LIST의 경우 전역 editableFields는 사용하지 않음
+ return [];
+ }, [templateType, selectedRow?.TAG_NO, editableFieldsMap]);
+
+
+const isFieldEditable = React.useCallback((attId: string, rowData?: GenericData) => {
+ const columnConfig = columnsJSON.find(col => col.key === attId);
+ if (columnConfig?.shi === "OUT" || columnConfig?.shi === null) {
+ return false;
+ }
+
+ if (attId === "TAG_NO" || attId === "TAG_DESC" || attId === "status") {
+ return false;
+ }
+
+ if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') {
+ // 각 행의 TAG_NO를 기준으로 편집 가능 여부 판단
+ if (!rowData?.TAG_NO || !editableFieldsMap.has(rowData.TAG_NO)) {
+ return false;
+ }
+
+ const rowEditableFields = editableFieldsMap.get(rowData.TAG_NO) || [];
+ if (!rowEditableFields.includes(attId)) {
+ return false;
+ }
+
+ if (rowData && (rowData.shi === "OUT" || rowData.shi === null)) {
+ return false;
+ }
+ return true;
+ }
+
+ // SPREAD_ITEM의 경우 기존 로직 유지
+ if (templateType === 'SPREAD_ITEM') {
+ return editableFields.includes(attId);
+ }
+
+ return true;
+}, [templateType, columnsJSON, editableFieldsMap]); // editableFields 의존성 제거
+
+const editableFieldsCount = React.useMemo(() => {
+ if (templateType === 'SPREAD_ITEM') {
+ // SPREAD_ITEM의 경우 기존 로직 유지
+ return cellMappings.filter(m => m.isEditable).length;
+ }
+
+ if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') {
+ // 각 행별로 편집 가능한 필드 수를 계산
+ let totalEditableCount = 0;
+
+ tableData.forEach((rowData, rowIndex) => {
+ cellMappings.forEach(mapping => {
+ if (mapping.dataRowIndex === rowIndex) {
+ if (isFieldEditable(mapping.attId, rowData)) {
+ totalEditableCount++;
+ }
+ }
+ });
+ });
+
+ return totalEditableCount;
+ }
+
+ return cellMappings.filter(m => m.isEditable).length;
+}, [cellMappings, templateType, tableData, isFieldEditable]);
+
+ // 🚀 배치 처리 함수들
+ const setBatchValues = React.useCallback((
+ activeSheet: any,
+ valuesToSet: Array<{row: number, col: number, value: any}>
+ ) => {
+ console.log(`🚀 Setting ${valuesToSet.length} values in batch`);
+
+ const columnGroups = new Map<number, Array<{row: number, value: any}>>();
+
+ valuesToSet.forEach(({row, col, value}) => {
+ if (!columnGroups.has(col)) {
+ columnGroups.set(col, []);
+ }
+ columnGroups.get(col)!.push({row, value});
+ });
+
+ columnGroups.forEach((values, col) => {
+ values.sort((a, b) => a.row - b.row);
+
+ let start = 0;
+ while (start < values.length) {
+ let end = start;
+ while (end + 1 < values.length && values[end + 1].row === values[end].row + 1) {
+ end++;
+ }
+
+ const rangeValues = values.slice(start, end + 1).map(v => v.value);
+ const startRow = values[start].row;
+
+ try {
+ if (rangeValues.length === 1) {
+ activeSheet.setValue(startRow, col, rangeValues[0]);
+ } else {
+ const dataArray = rangeValues.map(v => [v]);
+ activeSheet.setArray(startRow, col, dataArray);
+ }
+ } catch (error) {
+ for (let i = start; i <= end; i++) {
+ try {
+ activeSheet.setValue(values[i].row, col, values[i].value);
+ } catch (cellError) {
+ console.warn(`⚠️ Individual value setting failed [${values[i].row}, ${col}]:`, cellError);
+ }
+ }
+ }
+
+ start = end + 1;
+ }
+ });
+ }, []);
+
+ const createCellStyle = React.useCallback((isEditable: boolean) => {
+ const style = new GC.Spread.Sheets.Style();
+ if (isEditable) {
+ style.backColor = "#bbf7d0";
+ } else {
+ style.backColor = "#e5e7eb";
+ style.foreColor = "#4b5563";
+ }
+ return style;
+ }, []);
+
+ const setBatchStyles = React.useCallback((
+ activeSheet: any,
+ stylesToSet: Array<{row: number, col: number, isEditable: boolean}>
+ ) => {
+ console.log(`🎨 Setting ${stylesToSet.length} styles in batch`);
+
+ const editableStyle = createCellStyle(true);
+ const readonlyStyle = createCellStyle(false);
+
+ // 🔧 개별 셀별로 스타일과 잠금 상태 설정 (편집 권한 보장)
+ stylesToSet.forEach(({row, col, isEditable}) => {
+ try {
+ const cell = activeSheet.getCell(row, col);
+ const style = isEditable ? editableStyle : readonlyStyle;
+
+ activeSheet.setStyle(row, col, style);
+ cell.locked(!isEditable); // 편집 가능하면 잠금 해제
+
+ // 🆕 편집 가능한 셀에 기본 텍스트 에디터 설정
+ if (isEditable) {
+ const textCellType = new GC.Spread.Sheets.CellTypes.Text();
+ activeSheet.setCellType(row, col, textCellType);
+ }
+ } catch (error) {
+ console.warn(`⚠️ Failed to set style for cell [${row}, ${col}]:`, error);
+ }
+ });
+ }, [createCellStyle]);
+
+ const parseCellAddress = (address: string): { row: number, col: number } | null => {
+ if (!address || address.trim() === "") return null;
+
+ const match = address.match(/^([A-Z]+)(\d+)$/);
+ if (!match) return null;
+
+ const [, colStr, rowStr] = match;
+
+ let col = 0;
+ for (let i = 0; i < colStr.length; i++) {
+ col = col * 26 + (colStr.charCodeAt(i) - 65 + 1);
+ }
+ col -= 1;
+
+ const row = parseInt(rowStr) - 1;
+ return { row, col };
+ };
+
+ const getCellAddress = (row: number, col: number): string => {
+ let colStr = '';
+ let colNum = col;
+ while (colNum >= 0) {
+ colStr = String.fromCharCode((colNum % 26) + 65) + colStr;
+ colNum = Math.floor(colNum / 26) - 1;
+ }
+ return colStr + (row + 1);
+ };
+
+ const validateCellValue = (value: any, columnType: ColumnType, options?: string[]): string | null => {
+ if (value === undefined || value === null || value === "") {
+ return null;
+ }
+
+ switch (columnType) {
+ case "NUMBER":
+ if (isNaN(Number(value))) {
+ return "Value must be a valid number";
+ }
+ break;
+ case "LIST":
+ if (options && !options.includes(String(value))) {
+ return `Value must be one of: ${options.join(", ")}`;
+ }
+ break;
+ case "STRING":
+ break;
+ default:
+ break;
+ }
+
+ return null;
+ };
+
+ const validateAllData = React.useCallback(() => {
+ if (!currentSpread || !selectedTemplate) return [];
+
+ const activeSheet = currentSpread.getActiveSheet();
+ const errors: ValidationError[] = [];
+
+ cellMappings.forEach(mapping => {
+ const columnConfig = columnsJSON.find(col => col.key === mapping.attId);
+ if (!columnConfig) return;
+
+ const cellPos = parseCellAddress(mapping.cellAddress);
+ if (!cellPos) return;
+
+ const cellValue = activeSheet.getValue(cellPos.row, cellPos.col);
+ const errorMessage = validateCellValue(cellValue, columnConfig.type, columnConfig.options);
+
+ if (errorMessage) {
+ errors.push({
+ cellAddress: mapping.cellAddress,
+ attId: mapping.attId,
+ value: cellValue,
+ expectedType: columnConfig.type,
+ message: errorMessage
+ });
+ }
+ });
+
+ setValidationErrors(errors);
+ return errors;
+ }, [currentSpread, selectedTemplate, cellMappings, columnsJSON]);
+
+
+
+ const setupOptimizedListValidation = React.useCallback((activeSheet: any, cellPos: { row: number, col: number }, options: string[], rowCount: number) => {
+ try {
+ console.log(`🎯 Setting up dropdown for ${rowCount} rows with options:`, options);
+
+ const safeOptions = options
+ .filter(opt => opt !== null && opt !== undefined && opt !== '')
+ .map(opt => String(opt).trim())
+ .filter(opt => opt.length > 0)
+ .filter((opt, index, arr) => arr.indexOf(opt) === index)
+ .slice(0, 20);
+
+ if (safeOptions.length === 0) {
+ console.warn(`⚠️ No valid options found, skipping`);
+ return;
+ }
+
+ const optionsString = safeOptions.join(',');
+
+ for (let i = 0; i < rowCount; i++) {
+ try {
+ const targetRow = cellPos.row + i;
+
+ // 🔧 각 셀마다 새로운 ComboBox 인스턴스 생성
+ const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox();
+ comboBoxCellType.items(safeOptions);
+ comboBoxCellType.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text);
+
+ // 🔧 DataValidation 설정
+ const cellValidator = GC.Spread.Sheets.DataValidation.createListValidator(optionsString);
+ cellValidator.showInputMessage(false);
+ cellValidator.showErrorMessage(false);
+
+ // ComboBox와 Validator 적용
+ activeSheet.setCellType(targetRow, cellPos.col, comboBoxCellType);
+ activeSheet.setDataValidator(targetRow, cellPos.col, cellValidator);
+
+ // 🚀 중요: 셀 잠금 해제 및 편집 가능 설정
+ const cell = activeSheet.getCell(targetRow, cellPos.col);
+ cell.locked(false);
+
+ console.log(`✅ Dropdown applied to [${targetRow}, ${cellPos.col}] with ${safeOptions.length} options`);
+
+ } catch (cellError) {
+ console.warn(`⚠️ Failed to apply dropdown to row ${cellPos.row + i}:`, cellError);
+ }
+ }
+
+ console.log(`✅ Dropdown setup completed for ${rowCount} cells`);
+
+ } catch (error) {
+ console.error('❌ Dropdown setup failed:', error);
+ }
+ }, []);
+
+ const getSafeActiveSheet = React.useCallback((spread: any, functionName: string = 'unknown') => {
+ if (!spread) return null;
+
+ try {
+ let activeSheet = spread.getActiveSheet();
+ if (!activeSheet) {
+ const sheetCount = spread.getSheetCount();
+ if (sheetCount > 0) {
+ activeSheet = spread.getSheet(0);
+ if (activeSheet) {
+ spread.setActiveSheetIndex(0);
+ }
+ }
+ }
+ return activeSheet;
+ } catch (error) {
+ console.error(`❌ Error getting activeSheet in ${functionName}:`, error);
+ return null;
+ }
+ }, []);
+
+ const ensureRowCapacity = React.useCallback((activeSheet: any, requiredRowCount: number) => {
+ try {
+ if (!activeSheet) return false;
+
+ const currentRowCount = activeSheet.getRowCount();
+ if (requiredRowCount > currentRowCount) {
+ const newRowCount = requiredRowCount + 10;
+ activeSheet.setRowCount(newRowCount);
+ }
+ return true;
+ } catch (error) {
+ console.error('❌ Error in ensureRowCapacity:', error);
+ return false;
+ }
+ }, []);
+
+ const ensureColumnCapacity = React.useCallback((activeSheet: any, requiredColumnCount: number) => {
+ try {
+ if (!activeSheet) return false;
+
+ const currentColumnCount = activeSheet.getColumnCount();
+ if (requiredColumnCount > currentColumnCount) {
+ const newColumnCount = requiredColumnCount + 10;
+ activeSheet.setColumnCount(newColumnCount);
+ }
+ return true;
+ } catch (error) {
+ console.error('❌ Error in ensureColumnCapacity:', error);
+ return false;
+ }
+ }, []);
+
+ const setOptimalColumnWidths = React.useCallback((activeSheet: any, columns: any[], startCol: number, tableData: any[]) => {
+ columns.forEach((column, colIndex) => {
+ const targetCol = startCol + colIndex;
+ const optimalWidth = column.type === 'NUMBER' ? 100 : column.type === 'STRING' ? 150 : 120;
+ activeSheet.setColumnWidth(targetCol, optimalWidth);
+ });
+ }, []);
+
+ // 🚀 최적화된 GRD_LIST 생성
+ const createGrdListTableOptimized = React.useCallback((activeSheet: any, template: TemplateItem) => {
+ console.log('🚀 Creating optimized GRD_LIST table');
+
+ const visibleColumns = columnsJSON
+ .filter(col => col.hidden !== true)
+ .sort((a, b) => (a.seq ?? 999999) - (b.seq ?? 999999));
+
+ if (visibleColumns.length === 0) return [];
+
+ const startCol = 1;
+ const dataStartRow = 1;
+ const mappings: CellMapping[] = [];
+
+ ensureColumnCapacity(activeSheet, startCol + visibleColumns.length);
+ ensureRowCapacity(activeSheet, dataStartRow + tableData.length);
+
+ // 헤더 생성
+ const headerStyle = new GC.Spread.Sheets.Style();
+ headerStyle.backColor = "#3b82f6";
+ headerStyle.foreColor = "#ffffff";
+ headerStyle.font = "bold 12px Arial";
+ headerStyle.hAlign = GC.Spread.Sheets.HorizontalAlign.center;
+
+ visibleColumns.forEach((column, colIndex) => {
+ const targetCol = startCol + colIndex;
+ const cell = activeSheet.getCell(0, targetCol);
+ cell.value(column.label);
+ cell.locked(true);
+ activeSheet.setStyle(0, targetCol, headerStyle);
+ });
+
+ // 🚀 데이터 배치 처리 준비
+ const allValues: Array<{row: number, col: number, value: any}> = [];
+ const allStyles: Array<{row: number, col: number, isEditable: boolean}> = [];
+
+ // 🔧 편집 가능한 셀 정보 수집 (드롭다운용)
+ const dropdownConfigs: Array<{
+ startRow: number;
+ col: number;
+ rowCount: number;
+ options: string[];
+ editableRows: number[]; // 편집 가능한 행만 추적
+ }> = [];
+
+ visibleColumns.forEach((column, colIndex) => {
+ const targetCol = startCol + colIndex;
+
+ // 드롭다운 설정을 위한 편집 가능한 행 찾기
+ if (column.type === "LIST" && column.options) {
+ const editableRows: number[] = [];
+ tableData.forEach((rowData, rowIndex) => {
+ if (isFieldEditable(column.key, rowData)) { // rowData 전달
+ editableRows.push(dataStartRow + rowIndex);
+ }
+ });
+
+ if (editableRows.length > 0) {
+ dropdownConfigs.push({
+ startRow: dataStartRow,
+ col: targetCol,
+ rowCount: tableData.length,
+ options: column.options,
+ editableRows: editableRows
+ });
+ }
+ }
+
+ tableData.forEach((rowData, rowIndex) => {
+ const targetRow = dataStartRow + rowIndex;
+ const cellEditable = isFieldEditable(column.key, rowData); // rowData 전달
+ const value = rowData[column.key];
+
+ mappings.push({
+ attId: column.key,
+ cellAddress: getCellAddress(targetRow, targetCol),
+ isEditable: cellEditable,
+ dataRowIndex: rowIndex
+ });
+
+ allValues.push({
+ row: targetRow,
+ col: targetCol,
+ value: value ?? null
+ });
+
+ allStyles.push({
+ row: targetRow,
+ col: targetCol,
+ isEditable: cellEditable
+ });
+ });
+ });
+
+ // 🚀 배치로 값과 스타일 설정
+ setBatchValues(activeSheet, allValues);
+ setBatchStyles(activeSheet, allStyles);
+
+ // 🎯 개선된 드롭다운 설정 (편집 가능한 셀에만)
+ dropdownConfigs.forEach(({ col, options, editableRows }) => {
+ try {
+ console.log(`🎯 Setting dropdown for column ${col}, editable rows: ${editableRows.length}`);
+
+ const safeOptions = options
+ .filter(opt => opt !== null && opt !== undefined && opt !== '')
+ .map(opt => String(opt).trim())
+ .filter(opt => opt.length > 0)
+ .slice(0, 20);
+
+ if (safeOptions.length === 0) return;
+
+ // 편집 가능한 행에만 드롭다운 적용
+ editableRows.forEach(targetRow => {
+ try {
+ const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox();
+ comboBoxCellType.items(safeOptions);
+ comboBoxCellType.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text);
+
+ const cellValidator = GC.Spread.Sheets.DataValidation.createListValidator(safeOptions.join(','));
+ cellValidator.showInputMessage(false);
+ cellValidator.showErrorMessage(false);
+
+ activeSheet.setCellType(targetRow, col, comboBoxCellType);
+ activeSheet.setDataValidator(targetRow, col, cellValidator);
+
+ // 🚀 편집 권한 명시적 설정
+ const cell = activeSheet.getCell(targetRow, col);
+ cell.locked(false);
+
+ console.log(`✅ Dropdown applied to editable cell [${targetRow}, ${col}]`);
+ } catch (cellError) {
+ console.warn(`⚠️ Failed to apply dropdown to [${targetRow}, ${col}]:`, cellError);
+ }
+ });
+ } catch (error) {
+ console.error(`❌ Dropdown config failed for column ${col}:`, error);
+ }
+ });
+
+ setOptimalColumnWidths(activeSheet, visibleColumns, startCol, tableData);
+
+ console.log(`✅ Optimized GRD_LIST created:`);
+ console.log(` - Total mappings: ${mappings.length}`);
+ console.log(` - Editable cells: ${mappings.filter(m => m.isEditable).length}`);
+ console.log(` - Dropdown configs: ${dropdownConfigs.length}`);
+
+ return mappings;
+ }, [tableData, columnsJSON, isFieldEditable, setBatchValues, setBatchStyles, ensureColumnCapacity, ensureRowCapacity, getCellAddress, setOptimalColumnWidths]);
+
+ const setupSheetProtectionAndEvents = React.useCallback((activeSheet: any, mappings: CellMapping[]) => {
+ console.log(`🛡️ Setting up protection and events for ${mappings.length} mappings`);
+
+ // 🔧 시트 보호 완전 해제 후 편집 권한 설정
+ activeSheet.options.isProtected = false;
+
+ // 🔧 편집 가능한 셀들을 위한 강화된 설정
+ mappings.forEach((mapping) => {
+ const cellPos = parseCellAddress(mapping.cellAddress);
+ if (!cellPos) return;
+
+ try {
+ const cell = activeSheet.getCell(cellPos.row, cellPos.col);
+ const columnConfig = columnsJSON.find(col => col.key === mapping.attId);
+
+ if (mapping.isEditable) {
+ // 🚀 편집 가능한 셀 설정 강화
+ cell.locked(false);
+
+ if (columnConfig?.type === "LIST" && columnConfig.options) {
+ // LIST 타입: 새 ComboBox 인스턴스 생성
+ const comboBox = new GC.Spread.Sheets.CellTypes.ComboBox();
+ comboBox.items(columnConfig.options);
+ comboBox.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text);
+ activeSheet.setCellType(cellPos.row, cellPos.col, comboBox);
+
+ // DataValidation도 추가
+ const validator = GC.Spread.Sheets.DataValidation.createListValidator(columnConfig.options.join(','));
+ activeSheet.setDataValidator(cellPos.row, cellPos.col, validator);
+ } else if (columnConfig?.type === "NUMBER") {
+ // NUMBER 타입: 숫자 입력 허용
+ const textCellType = new GC.Spread.Sheets.CellTypes.Text();
+ activeSheet.setCellType(cellPos.row, cellPos.col, textCellType);
+
+ // 숫자 validation 추가 (에러 메시지 없이)
+ const numberValidator = GC.Spread.Sheets.DataValidation.createNumberValidator(
+ GC.Spread.Sheets.ConditionalFormatting.ComparisonOperators.between,
+ -999999999, 999999999, true
+ );
+ numberValidator.showInputMessage(false);
+ numberValidator.showErrorMessage(false);
+ activeSheet.setDataValidator(cellPos.row, cellPos.col, numberValidator);
+ } else {
+ // 기본 TEXT 타입: 자유 텍스트 입력
+ const textCellType = new GC.Spread.Sheets.CellTypes.Text();
+ activeSheet.setCellType(cellPos.row, cellPos.col, textCellType);
+ }
+
+ // 편집 가능 스타일 재적용
+ const editableStyle = createCellStyle(true);
+ activeSheet.setStyle(cellPos.row, cellPos.col, editableStyle);
+
+ console.log(`🔓 Cell [${cellPos.row}, ${cellPos.col}] ${mapping.attId} set as EDITABLE`);
+ } else {
+ // 읽기 전용 셀
+ cell.locked(true);
+ const readonlyStyle = createCellStyle(false);
+ activeSheet.setStyle(cellPos.row, cellPos.col, readonlyStyle);
+ }
+ } catch (error) {
+ console.error(`❌ Error processing cell ${mapping.cellAddress}:`, error);
+ }
+ });
+
+ // 🛡️ 시트 보호 재설정 (편집 허용 모드로)
+ activeSheet.options.isProtected = false;
+ activeSheet.options.protectionOptions = {
+ allowSelectLockedCells: true,
+ allowSelectUnlockedCells: true,
+ allowSort: false,
+ allowFilter: false,
+ allowEditObjects: true, // ✅ 편집 객체 허용
+ allowResizeRows: false,
+ allowResizeColumns: false,
+ allowFormatCells: false,
+ allowInsertRows: false,
+ allowInsertColumns: false,
+ allowDeleteRows: false,
+ allowDeleteColumns: false
+ };
+
+ // 🎯 변경 감지 이벤트
+ const changeEvents = [
+ GC.Spread.Sheets.Events.CellChanged,
+ GC.Spread.Sheets.Events.ValueChanged,
+ GC.Spread.Sheets.Events.ClipboardPasted
+ ];
+
+ changeEvents.forEach(eventType => {
+ activeSheet.bind(eventType, () => {
+ console.log(`📝 ${eventType} detected`);
+ setHasChanges(true);
+ });
+ });
+
+ // 🚫 편집 시작 권한 확인
+ activeSheet.bind(GC.Spread.Sheets.Events.EditStarting, (event: any, info: any) => {
+ console.log(`🎯 EditStarting: Row ${info.row}, Col ${info.col}`);
+
+ const exactMapping = mappings.find(m => {
+ const cellPos = parseCellAddress(m.cellAddress);
+ return cellPos && cellPos.row === info.row && cellPos.col === info.col;
+ });
+
+ if (!exactMapping) {
+ console.log(`ℹ️ No mapping found for [${info.row}, ${info.col}] - allowing edit`);
+ return; // 매핑이 없으면 허용
+ }
+
+ console.log(`📋 Found mapping: ${exactMapping.attId}, isEditable: ${exactMapping.isEditable}`);
+
+ if (!exactMapping.isEditable) {
+ console.log(`🚫 Field ${exactMapping.attId} is not editable`);
+ toast.warning(`${exactMapping.attId} field is read-only`);
+ info.cancel = true;
+ return;
+ }
+
+ // SPREAD_LIST/GRD_LIST 개별 행 SHI 확인
+ if ((templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && exactMapping.dataRowIndex !== undefined) {
+ const dataRowIndex = exactMapping.dataRowIndex;
+ if (dataRowIndex >= 0 && dataRowIndex < tableData.length) {
+ const rowData = tableData[dataRowIndex];
+ if (rowData?.shi === "OUT" || rowData?.shi === null ) {
+ console.log(`🚫 Row ${dataRowIndex} is in SHI mode`);
+ toast.warning(`Row ${dataRowIndex + 1}: ${exactMapping.attId} field is read-only (SHI mode)`);
+ info.cancel = true;
+ return;
+ }
+ }
+ }
+
+ console.log(`✅ Edit allowed for ${exactMapping.attId}`);
+ });
+
+ // ✅ 편집 완료 검증
+ activeSheet.bind(GC.Spread.Sheets.Events.EditEnded, (event: any, info: any) => {
+ console.log(`🏁 EditEnded: Row ${info.row}, Col ${info.col}, New value: ${activeSheet.getValue(info.row, info.col)}`);
+
+ const exactMapping = mappings.find(m => {
+ const cellPos = parseCellAddress(m.cellAddress);
+ return cellPos && cellPos.row === info.row && cellPos.col === info.col;
+ });
+
+ if (!exactMapping) return;
+
+ const columnConfig = columnsJSON.find(col => col.key === exactMapping.attId);
+ if (columnConfig) {
+ const cellValue = activeSheet.getValue(info.row, info.col);
+ const errorMessage = validateCellValue(cellValue, columnConfig.type, columnConfig.options);
+ const cell = activeSheet.getCell(info.row, info.col);
+
+ if (errorMessage) {
+ // 🚨 에러 스타일 적용
+ const errorStyle = new GC.Spread.Sheets.Style();
+ errorStyle.backColor = "#fef2f2";
+ errorStyle.foreColor = "#dc2626";
+ errorStyle.borderLeft = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick);
+ errorStyle.borderRight = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick);
+ errorStyle.borderTop = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick);
+ errorStyle.borderBottom = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick);
+
+ activeSheet.setStyle(info.row, info.col, errorStyle);
+ cell.locked(!exactMapping.isEditable); // 편집 가능 상태 유지
+ toast.warning(`Invalid value in ${exactMapping.attId}: ${errorMessage}`, { duration: 5000 });
+ } else {
+ // ✅ 정상 스타일 복원
+ const normalStyle = createCellStyle(exactMapping.isEditable);
+ activeSheet.setStyle(info.row, info.col, normalStyle);
+ cell.locked(!exactMapping.isEditable);
+ }
+ }
+
+ setHasChanges(true);
+ });
+
+ console.log(`🛡️ Protection configured. Editable cells: ${mappings.filter(m => m.isEditable).length}`);
+ }, [templateType, tableData, createCellStyle, validateCellValue, columnsJSON]);
+
+ // 🚀 최적화된 initSpread - Designer용으로 수정
+ const initSpread = React.useCallback(async (spread: any, template?: TemplateItem) => {
+ const workingTemplate = template || selectedTemplate;
+ if (!spread || !workingTemplate) {
+ console.error('❌ Invalid spread or template');
+ return;
+ }
+
+ try {
+ console.log('🚀 Starting optimized Designer initialization...');
+ setIsInitializing(true);
+ updateProgress('Initializing...', 0, 100);
+
+ setCurrentSpread(spread);
+ setHasChanges(false);
+ setValidationErrors([]);
+
+ // 🚀 핵심 최적화: 모든 렌더링과 이벤트 중단
+ spread.suspendPaint();
+ spread.suspendEvent();
+ spread.suspendCalcService();
+
+ updateProgress('Setting up workspace...', 10, 100);
+
+ try {
+ let activeSheet = getSafeActiveSheet(spread, 'initSpread');
+ if (!activeSheet) {
+ throw new Error('Failed to get initial activeSheet');
+ }
+
+ activeSheet.options.isProtected = false;
+ let mappings: CellMapping[] = [];
+
+ if (templateType === 'GRD_LIST') {
+ updateProgress('Creating dynamic table...', 20, 100);
+
+ spread.clearSheets();
+ spread.addSheet(0);
+ const sheet = spread.getSheet(0);
+ sheet.name('Data');
+ spread.setActiveSheet('Data');
+
+ updateProgress('Processing table data...', 50, 100);
+ mappings = createGrdListTableOptimized(sheet, workingTemplate);
+
+ } else {
+ updateProgress('Loading template structure...', 20, 100);
+
+ let contentJson = workingTemplate.SPR_LST_SETUP?.CONTENT || workingTemplate.SPR_ITM_LST_SETUP?.CONTENT;
+ let dataSheets = workingTemplate.SPR_LST_SETUP?.DATA_SHEETS || workingTemplate.SPR_ITM_LST_SETUP?.DATA_SHEETS;
+
+ if (!contentJson || !dataSheets) {
+ throw new Error(`No template content found for ${workingTemplate.NAME}`);
+ }
+
+ const jsonData = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
+
+ updateProgress('Loading template layout...', 40, 100);
+ spread.fromJSON(jsonData);
+
+ activeSheet = getSafeActiveSheet(spread, 'after-fromJSON');
+ if (!activeSheet) {
+ throw new Error('ActiveSheet became null after loading template');
+ }
+
+ activeSheet.options.isProtected = false;
+
+ if (templateType === 'SPREAD_LIST' && tableData.length > 0) {
+ updateProgress('Processing data rows...', 60, 100);
+
+ dataSheets.forEach(dataSheet => {
+ if (dataSheet.MAP_CELL_ATT && dataSheet.MAP_CELL_ATT.length > 0) {
+ dataSheet.MAP_CELL_ATT.forEach((mapping: any) => {
+ const { ATT_ID, IN } = mapping;
+ if (!ATT_ID || !IN || IN.trim() === "") return;
+
+ const cellPos = parseCellAddress(IN);
+ if (!cellPos) return;
+
+ const requiredRows = cellPos.row + tableData.length;
+ if (!ensureRowCapacity(activeSheet, requiredRows)) return;
+
+ // 🚀 배치 데이터 준비
+ const valuesToSet: Array<{row: number, col: number, value: any}> = [];
+ const stylesToSet: Array<{row: number, col: number, isEditable: boolean}> = [];
+
+ tableData.forEach((rowData, index) => {
+ const targetRow = cellPos.row + index;
+ const cellEditable = isFieldEditable(ATT_ID, rowData);
+ const value = rowData[ATT_ID];
+
+ mappings.push({
+ attId: ATT_ID,
+ cellAddress: getCellAddress(targetRow, cellPos.col),
+ isEditable: cellEditable,
+ dataRowIndex: index
+ });
+
+ valuesToSet.push({
+ row: targetRow,
+ col: cellPos.col,
+ value: value ?? null
+ });
+
+ stylesToSet.push({
+ row: targetRow,
+ col: cellPos.col,
+ isEditable: cellEditable
+ });
+ });
+
+ // 🚀 배치 처리
+ setBatchValues(activeSheet, valuesToSet);
+ setBatchStyles(activeSheet, stylesToSet);
+
+ // 드롭다운 설정
+ const columnConfig = columnsJSON.find(col => col.key === ATT_ID);
+ if (columnConfig?.type === "LIST" && columnConfig.options) {
+ const hasEditableRows = tableData.some((rowData) => isFieldEditable(ATT_ID, rowData));
+ if (hasEditableRows) {
+ setupOptimizedListValidation(activeSheet, cellPos, columnConfig.options, tableData.length);
+ }
+ }
+ });
+ }
+ });
+
+ } else if (templateType === 'SPREAD_ITEM' && selectedRow) {
+ updateProgress('Setting up form fields...', 60, 100);
+
+ dataSheets.forEach(dataSheet => {
+ dataSheet.MAP_CELL_ATT?.forEach((mapping: any) => {
+ const { ATT_ID, IN } = mapping;
+ const cellPos = parseCellAddress(IN);
+ if (cellPos) {
+ const isEditable = isFieldEditable(ATT_ID);
+ const value = selectedRow[ATT_ID];
+
+ mappings.push({
+ attId: ATT_ID,
+ cellAddress: IN,
+ isEditable: isEditable,
+ dataRowIndex: 0
+ });
+
+ const cell = activeSheet.getCell(cellPos.row, cellPos.col);
+ cell.value(value ?? null);
+
+ const style = createCellStyle(isEditable);
+ activeSheet.setStyle(cellPos.row, cellPos.col, style);
+
+ const columnConfig = columnsJSON.find(col => col.key === ATT_ID);
+ if (columnConfig?.type === "LIST" && columnConfig.options && isEditable) {
+ setupOptimizedListValidation(activeSheet, cellPos, columnConfig.options, 1);
+ }
+ }
+ });
+ });
+ }
+ }
+
+ updateProgress('Configuring interactions...', 90, 100);
+ setCellMappings(mappings);
+
+ const finalActiveSheet = getSafeActiveSheet(spread, 'setupEvents');
+ if (finalActiveSheet) {
+ setupSheetProtectionAndEvents(finalActiveSheet, mappings);
+ }
+
+ updateProgress('Finalizing...', 100, 100);
+ console.log(`✅ Optimized Designer initialization completed with ${mappings.length} mappings`);
+
+ } finally {
+ // 🚀 올바른 순서로 재개
+ spread.resumeCalcService();
+ spread.resumeEvent();
+ spread.resumePaint();
+ }
+
+ } catch (error) {
+ console.error('❌ Error in optimized Designer initialization:', error);
+ if (spread?.resumeCalcService) spread.resumeCalcService();
+ if (spread?.resumeEvent) spread.resumeEvent();
+ if (spread?.resumePaint) spread.resumePaint();
+ toast.error(`Template loading failed: ${error.message}`);
+ } finally {
+ setIsInitializing(false);
+ setLoadingProgress(null);
+ }
+ }, [selectedTemplate, templateType, selectedRow, tableData, updateProgress, getSafeActiveSheet, createGrdListTableOptimized, setBatchValues, setBatchStyles, setupSheetProtectionAndEvents, setCellMappings]);
+
+ const handleSaveChanges = React.useCallback(async () => {
+ if (!currentSpread || !hasChanges) {
+ toast.info("No changes to save");
+ return;
+ }
+
+ const errors = validateAllData();
+ if (errors.length > 0) {
+ toast.error(`Cannot save: ${errors.length} validation errors found. Please fix them first.`);
+ return;
+ }
+
+ try {
+ setIsPending(true);
+ const activeSheet = currentSpread.getActiveSheet();
+
+ if (templateType === 'SPREAD_ITEM' && selectedRow) {
+ const dataToSave = { ...selectedRow };
+
+ cellMappings.forEach(mapping => {
+ if (mapping.isEditable) {
+ const cellPos = parseCellAddress(mapping.cellAddress);
+ if (cellPos) {
+ const cellValue = activeSheet.getValue(cellPos.row, cellPos.col);
+ dataToSave[mapping.attId] = cellValue;
+ }
+ }
+ });
+
+ dataToSave.TAG_NO = selectedRow.TAG_NO;
+
+ const { success, message } = await updateFormDataInDB(
+ formCode,
+ contractItemId,
+ dataToSave
+ );
+
+ if (!success) {
+ toast.error(message);
+ return;
+ }
+
+ toast.success("Changes saved successfully!");
+ onUpdateSuccess?.(dataToSave);
+
+ } else if ((templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && tableData.length > 0) {
+ const updatedRows: GenericData[] = [];
+ let saveCount = 0;
+
+ for (let i = 0; i < tableData.length; i++) {
+ const originalRow = tableData[i];
+ const dataToSave = { ...originalRow };
+ let hasRowChanges = false;
+
+ cellMappings.forEach(mapping => {
+ if (mapping.dataRowIndex === i && mapping.isEditable) {
+ const columnConfig = columnsJSON.find(col => col.key === mapping.attId);
+ const isColumnEditable = columnConfig?.shi === "IN" ||columnConfig?.shi === "BOTH";
+ const isRowEditable = originalRow.shi === "IN" ||originalRow.shi === "BOTH" ;
+
+ if (isColumnEditable && isRowEditable) {
+ const cellPos = parseCellAddress(mapping.cellAddress);
+ if (cellPos) {
+ const cellValue = activeSheet.getValue(cellPos.row, cellPos.col);
+ if (cellValue !== originalRow[mapping.attId]) {
+ dataToSave[mapping.attId] = cellValue;
+ hasRowChanges = true;
+ }
+ }
+ }
+ }
+ });
+
+ if (hasRowChanges) {
+ dataToSave.TAG_NO = originalRow.TAG_NO;
+ const { success } = await updateFormDataInDB(
+ formCode,
+ contractItemId,
+ dataToSave
+ );
+
+ if (success) {
+ updatedRows.push(dataToSave);
+ saveCount++;
+ }
+ } else {
+ updatedRows.push(originalRow);
+ }
+ }
+
+ if (saveCount > 0) {
+ toast.success(`${saveCount} rows saved successfully!`);
+ onUpdateSuccess?.(updatedRows);
+ } else {
+ toast.info("No changes to save");
+ }
+ }
+
+ setHasChanges(false);
+ setValidationErrors([]);
+
+ } catch (error) {
+ console.error("Error saving changes:", error);
+ toast.error("An unexpected error occurred while saving");
+ } finally {
+ setIsPending(false);
+ }
+ }, [currentSpread, hasChanges, templateType, selectedRow, tableData, formCode, contractItemId, onUpdateSuccess, cellMappings, columnsJSON, validateAllData]);
+
+ if (!isOpen) return null;
+
+ const isDataValid = templateType === 'SPREAD_ITEM' ? !!selectedRow : tableData.length > 0;
+ const dataCount = templateType === 'SPREAD_ITEM' ? 1 : tableData.length;
+
+ return (
+ <Dialog open={isOpen} onOpenChange={onClose}>
+ <DialogContent
+ className="w-[90vw] max-w-[1400px] h-[85vh] flex flex-col fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] z-50"
+ >
+ <DialogHeader className="flex-shrink-0">
+ <DialogTitle>SEDP Template Designer - {formCode}</DialogTitle>
+ <DialogDescription>
+ <div className="space-y-3">
+ {availableTemplates.length > 1 && (
+ <div className="flex items-center gap-4">
+ <span className="text-sm font-medium">Template:</span>
+ <Select value={selectedTemplateId} onValueChange={handleTemplateChange}>
+ <SelectTrigger className="w-64">
+ <SelectValue placeholder="Select a template" />
+ </SelectTrigger>
+ <SelectContent>
+ {availableTemplates.map(template => (
+ <SelectItem key={template.TMPL_ID} value={template.TMPL_ID}>
+ {template.NAME} ({template.TMPL_TYPE})
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ )}
+
+ {selectedTemplate && (
+ <div className="flex items-center gap-4 text-sm">
+ <span className="font-medium text-blue-600">
+ Template Type: {
+ templateType === 'SPREAD_LIST' ? 'List View (SPREAD_LIST)' :
+ templateType === 'SPREAD_ITEM' ? 'Item View (SPREAD_ITEM)' :
+ 'Grid List View (GRD_LIST)'
+ }
+ </span>
+ {templateType === 'SPREAD_ITEM' && selectedRow && (
+ <span>• Selected TAG_NO: {selectedRow.TAG_NO || 'N/A'}</span>
+ )}
+ {(templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && (
+ <span>• {dataCount} rows</span>
+ )}
+ {hasChanges && (
+ <span className="text-orange-600 font-medium">
+ • Unsaved changes
+ </span>
+ )}
+ {validationErrors.length > 0 && (
+ <span className="text-red-600 font-medium flex items-center">
+ <AlertTriangle className="w-4 h-4 mr-1" />
+ {validationErrors.length} validation errors
+ </span>
+ )}
+ </div>
+ )}
+
+ <div className="flex items-center gap-4 text-xs">
+ <span className="text-muted-foreground">
+ <span className="inline-block w-3 h-3 bg-green-100 border border-green-400 mr-1"></span>
+ Editable fields
+ </span>
+ <span className="text-muted-foreground">
+ <span className="inline-block w-3 h-3 bg-gray-100 border border-gray-300 mr-1"></span>
+ Read-only fields
+ </span>
+ <span className="text-muted-foreground">
+ <span className="inline-block w-3 h-3 bg-red-100 border border-red-300 mr-1"></span>
+ Validation errors
+ </span>
+ {cellMappings.length > 0 && (
+ <span className="text-blue-600">
+ {editableFieldsCount} of {cellMappings.length} fields editable
+ </span>
+ )}
+ </div>
+ </div>
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="flex-1 overflow-hidden relative">
+ {/* 🆕 로딩 프로그레스 오버레이 */}
+ <LoadingProgress
+ phase={loadingProgress?.phase || ''}
+ progress={loadingProgress?.progress || 0}
+ total={loadingProgress?.total || 100}
+ isVisible={isInitializing && !!loadingProgress}
+ />
+
+ {selectedTemplate && isClient && isDataValid ? (
+ <Designer
+ key={`${templateType}-${selectedTemplate.TMPL_ID}-${selectedTemplateId}`}
+ workbookInitialized={initSpread}
+ hostStyle={hostStyle}
+ />
+ ) : (
+ <div className="flex items-center justify-center h-full text-muted-foreground">
+ {!isClient ? (
+ <>
+ <Loader className="mr-2 h-4 w-4 animate-spin" />
+ Loading...
+ </>
+ ) : !selectedTemplate ? (
+ "No template available"
+ ) : !isDataValid ? (
+ `No ${templateType === 'SPREAD_ITEM' ? 'selected row' : 'data'} available`
+ ) : (
+ "Template not ready"
+ )}
+ </div>
+ )}
+ </div>
+
+ <DialogFooter className="flex-shrink-0">
+ <div className="flex items-center gap-2">
+ <Button variant="outline" onClick={onClose}>
+ Close
+ </Button>
+
+ {hasChanges && (
+ <Button
+ variant="default"
+ onClick={handleSaveChanges}
+ disabled={isPending || validationErrors.length > 0}
+ >
+ {isPending ? (
+ <>
+ <Loader className="mr-2 h-4 w-4 animate-spin" />
+ Saving...
+ </>
+ ) : (
+ <>
+ <Save className="mr-2 h-4 w-4" />
+ Save Changes
+ </>
+ )}
+ </Button>
+ )}
+
+ {validationErrors.length > 0 && (
+ <Button
+ variant="outline"
+ onClick={validateAllData}
+ className="text-red-600 border-red-300 hover:bg-red-50"
+ >
+ <AlertTriangle className="mr-2 h-4 w-4" />
+ Check Errors ({validationErrors.length})
+ </Button>
+ )}
+ </div>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+} \ No newline at end of file
diff --git a/components/form-data-plant/update-form-sheet.tsx b/components/form-data-plant/update-form-sheet.tsx
new file mode 100644
index 00000000..bd75d8f3
--- /dev/null
+++ b/components/form-data-plant/update-form-sheet.tsx
@@ -0,0 +1,445 @@
+"use client";
+
+import * as React from "react";
+import { z } from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import { Check, ChevronsUpDown, Loader, LockIcon } from "lucide-react";
+import { toast } from "sonner";
+import { useRouter } from "next/navigation";
+
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+ Form,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormControl,
+ FormMessage,
+ FormDescription,
+} from "@/components/ui/form";
+import {
+ Popover,
+ PopoverTrigger,
+ PopoverContent,
+} from "@/components/ui/popover";
+import {
+ Command,
+ CommandInput,
+ CommandList,
+ CommandGroup,
+ CommandItem,
+ CommandEmpty,
+} from "@/components/ui/command";
+
+import { DataTableColumnJSON } from "./form-data-table-columns";
+import { updateFormDataInDB } from "@/lib/forms-plant/services";
+import { cn } from "@/lib/utils";
+import { useParams } from "next/navigation";
+import { useTranslation } from "@/i18n/client";
+
+/** =============================================================
+ * 🔄 UpdateTagSheet with grouped fields by `head` property
+ * -----------------------------------------------------------
+ * - Consecutive columns that share the same `head` value will be
+ * rendered under a section title (the head itself).
+ * - Columns without a head still appear normally.
+ *
+ * NOTE: Only rendering logic is touched – all validation,
+ * read‑only checks, and mutation logic stay the same.
+ * ============================================================*/
+
+interface UpdateTagSheetProps extends React.ComponentPropsWithoutRef<typeof Sheet> {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ columns: DataTableColumnJSON[];
+ rowData: Record<string, any> | null;
+ formCode: string;
+ contractItemId: number;
+ editableFieldsMap?: Map<string, string[]>; // 새로 추가
+ /** 업데이트 성공 시 호출될 콜백 */
+ onUpdateSuccess?: (updatedValues: Record<string, any>) => void;
+}
+
+export function UpdateTagSheet({
+ open,
+ onOpenChange,
+ columns,
+ rowData,
+ formCode,
+ contractItemId,
+ editableFieldsMap = new Map(),
+ onUpdateSuccess,
+ ...props
+}: UpdateTagSheetProps) {
+ // ───────────────────────────────────────────────────────────────
+ // hooks & helpers
+ // ───────────────────────────────────────────────────────────────
+ const [isPending, startTransition] = React.useTransition();
+ const router = useRouter();
+
+ const params = useParams();
+ const lng = (params?.lng as string) || "ko";
+ const { t } = useTranslation(lng, "engineering");
+
+ /* ----------------------------------------------------------------
+ * 1️⃣ Editable‑field helpers (unchanged)
+ * --------------------------------------------------------------*/
+ const editableFields = React.useMemo(() => {
+ if (!rowData?.TAG_NO || !editableFieldsMap.has(rowData.TAG_NO)) {
+ return [] as string[];
+ }
+ return editableFieldsMap.get(rowData.TAG_NO) || [];
+ }, [rowData?.TAG_NO, editableFieldsMap]);
+
+ const isFieldEditable = React.useCallback((column: DataTableColumnJSON) => {
+ if (column.shi === "OUT" || column.shi === null) return false; // SHI‑only
+ if (column.key === "TAG_NO" || column.key === "TAG_DESC") return false;
+ if (column.key === "status") return false;
+ return editableFields.includes(column.key);
+ // return true
+ }, [editableFields]);
+
+ const isFieldReadOnly = React.useCallback((column: DataTableColumnJSON) => !isFieldEditable(column), [isFieldEditable]);
+
+ const getReadOnlyReason = React.useCallback((column: DataTableColumnJSON) => {
+ if (column.shi === "OUT" || column.shi === null) return t("updateTagSheet.readOnlyReasons.shiOnly");
+ if (column.key !== "TAG_NO" && column.key !== "TAG_DESC") {
+ if (!rowData?.TAG_NO || !editableFieldsMap.has(rowData.TAG_NO)) {
+ return t("updateTagSheet.readOnlyReasons.noEditableFields");
+ }
+ if (!editableFields.includes(column.key)) {
+ return t("updateTagSheet.readOnlyReasons.notEditableForTag");
+ }
+ }
+ return t("updateTagSheet.readOnlyReasons.readOnly");
+ }, [rowData?.TAG_NO, editableFieldsMap, editableFields, t]);
+
+ /* ----------------------------------------------------------------
+ * 2️⃣ Zod dynamic schema & form state (unchanged)
+ * --------------------------------------------------------------*/
+ const dynamicSchema = React.useMemo(() => {
+ const shape: Record<string, z.ZodTypeAny> = {};
+ for (const col of columns) {
+ if (col.type === "NUMBER") {
+ shape[col.key] = z
+ .union([z.coerce.number(), z.nan()])
+ .transform((val) => (isNaN(val as number) ? undefined : val))
+ .optional();
+ } else {
+ shape[col.key] = z.string().optional();
+ }
+ }
+ return z.object(shape);
+ }, [columns]);
+
+ const form = useForm({
+ resolver: zodResolver(dynamicSchema),
+ defaultValues: React.useMemo(() => {
+ if (!rowData) return {};
+ return columns.reduce<Record<string, any>>((acc, col) => {
+ acc[col.key] = rowData[col.key] ?? "";
+ return acc;
+ }, {});
+ }, [rowData, columns]),
+ });
+
+ React.useEffect(() => {
+ if (!rowData) {
+ form.reset({});
+ return;
+ }
+ const defaults: Record<string, any> = {};
+ columns.forEach((col) => {
+ defaults[col.key] = rowData[col.key] ?? "";
+ });
+ form.reset(defaults);
+ }, [rowData, columns, form]);
+
+ /* ----------------------------------------------------------------
+ * 3️⃣ Grouping logic – figure out consecutive columns that share
+ * the same `head` value. This mirrors `groupColumnsByHead` that
+ * you already use for the table view.
+ * --------------------------------------------------------------*/
+ const groupedColumns = React.useMemo(() => {
+ // Ensure original ordering by `seq` where present
+ const sorted = [...columns].sort((a, b) => {
+ const seqA = a.seq ?? 999999;
+ const seqB = b.seq ?? 999999;
+ return seqA - seqB;
+ });
+
+ const groups: { head: string | null; cols: DataTableColumnJSON[] }[] = [];
+ let i = 0;
+ while (i < sorted.length) {
+ const curr = sorted[i];
+ const head = curr.head?.trim() || null;
+ if (!head) {
+ groups.push({ head: null, cols: [curr] });
+ i += 1;
+ continue;
+ }
+
+ // Collect consecutive columns with the same head
+ const cols: DataTableColumnJSON[] = [curr];
+ let j = i + 1;
+ while (j < sorted.length && sorted[j].head?.trim() === head) {
+ cols.push(sorted[j]);
+ j += 1;
+ }
+ groups.push({ head, cols });
+ i = j;
+ }
+ return groups;
+ }, [columns]);
+
+ /* ----------------------------------------------------------------
+ * 4️⃣ Submission handler (unchanged)
+ * --------------------------------------------------------------*/
+ async function onSubmit(values: Record<string, any>) {
+ startTransition(async () => {
+ try {
+ // Restore read‑only fields to their original value before saving
+ const finalValues: Record<string, any> = { ...values };
+ columns.forEach((col) => {
+ if (isFieldReadOnly(col)) {
+ finalValues[col.key] = rowData?.[col.key] ?? "";
+ }
+ });
+
+ const { success, message } = await updateFormDataInDB(
+ formCode,
+ contractItemId,
+ finalValues,
+ );
+
+ if (!success) {
+ toast.error(message);
+ return;
+ }
+
+ toast.success(t("updateTagSheet.messages.updateSuccess"));
+
+ const updatedData = { ...rowData, ...finalValues, TAG_NO: rowData?.TAG_NO };
+ onUpdateSuccess?.(updatedData);
+ router.refresh();
+ onOpenChange(false);
+ } catch (error) {
+ console.error("Error updating form data:", error);
+ toast.error(t("updateTagSheet.messages.updateError"));
+ }
+ });
+ }
+
+ /* ----------------------------------------------------------------
+ * 5️⃣ UI
+ * --------------------------------------------------------------*/
+ const editableFieldCount = React.useMemo(() => columns.filter(isFieldEditable).length, [columns, isFieldEditable]);
+
+ return (
+ <Sheet open={open} onOpenChange={onOpenChange} {...props}>
+ <SheetContent className="sm:max-w-xl md:max-w-3xl lg:max-w-4xl xl:max-w-5xl flex flex-col">
+ <SheetHeader className="text-left">
+ <SheetTitle>
+ {t("updateTagSheet.title")} – {rowData?.TAG_NO || t("updateTagSheet.unknownTag")}
+ </SheetTitle>
+ <SheetDescription>
+ {t("updateTagSheet.description")}
+ <LockIcon className="inline h-3 w-3 mx-1" />
+ {t("updateTagSheet.readOnlyIndicator")}
+ <br />
+ <span className="text-sm text-green-600">
+ {t("updateTagSheet.editableFieldsCount", {
+ editableCount: editableFieldCount,
+ totalCount: columns.length
+ })}
+ </span>
+ </SheetDescription>
+ </SheetHeader>
+
+ {/* ────────────────────────────────────────────── */}
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
+ {/* Scroll wrapper */}
+ <div className="overflow-y-auto max-h-[80vh] flex-1 pr-4 -mr-4">
+ {/* ------------------------------------------------------------------
+ * Render groups
+ * ----------------------------------------------------------------*/}
+ {groupedColumns.map(({ head, cols }) => (
+ <div key={head ?? cols[0].key} className="flex flex-col gap-4 pt-2">
+ {head && (
+ <h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide pl-1">
+ {head}
+ </h3>
+ )}
+
+ {/* Fields inside the group */}
+ {cols.map((col) => {
+ const isReadOnly = isFieldReadOnly(col);
+ const readOnlyReason = isReadOnly ? getReadOnlyReason(col) : "";
+ return (
+ <FormField
+ key={col.key}
+ control={form.control}
+ name={col.key}
+ render={({ field }) => {
+ // ——————————————— Number ————————————————
+ if (col.type === "NUMBER") {
+ return (
+ <FormItem>
+ <FormLabel className="flex items-center">
+ {col.displayLabel || col.label}
+ {isReadOnly && (
+ <LockIcon className="ml-1 h-3 w-3 text-gray-400" />
+ )}
+ </FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ readOnly={isReadOnly}
+ onChange={(e) => {
+ const num = parseFloat(e.target.value);
+ field.onChange(isNaN(num) ? "" : num);
+ }}
+ value={field.value ?? ""}
+ className={cn(
+ isReadOnly &&
+ "bg-gray-100 text-gray-600 cursor-not-allowed border-gray-300",
+ )}
+ />
+ </FormControl>
+ {isReadOnly && (
+ <FormDescription className="text-xs text-gray-500">
+ {readOnlyReason}
+ </FormDescription>
+ )}
+ <FormMessage />
+ </FormItem>
+ );
+ }
+
+ // ——————————————— List ————————————————
+ if (col.type === "LIST") {
+ return (
+ <FormItem>
+ <FormLabel className="flex items-center">
+ {col.label}
+ {isReadOnly && (
+ <LockIcon className="ml-1 h-3 w-3 text-gray-400" />
+ )}
+ </FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ disabled={isReadOnly}
+ className={cn(
+ "w-full justify-between",
+ !field.value && "text-muted-foreground",
+ isReadOnly &&
+ "bg-gray-100 text-gray-600 cursor-not-allowed border-gray-300",
+ )}
+ >
+ {field.value ?
+ col.options?.find((o) => o === field.value) :
+ t("updateTagSheet.selectOption")
+ }
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-full p-0">
+ <Command>
+ <CommandInput placeholder={t("updateTagSheet.searchOptions")} />
+ <CommandEmpty>{t("updateTagSheet.noOptionFound")}</CommandEmpty>
+ <CommandList>
+ <CommandGroup>
+ {col.options?.map((opt) => (
+ <CommandItem key={opt} value={opt} onSelect={() => field.onChange(opt)}>
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ field.value === opt ? "opacity-100" : "opacity-0",
+ )}
+ />
+ {opt}
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ {isReadOnly && (
+ <FormDescription className="text-xs text-gray-500">
+ {readOnlyReason}
+ </FormDescription>
+ )}
+ <FormMessage />
+ </FormItem>
+ );
+ }
+
+ // ——————————————— String / default ————————————
+ return (
+ <FormItem>
+ <FormLabel className="flex items-center">
+ {col.label}
+ {isReadOnly && (
+ <LockIcon className="ml-1 h-3 w-3 text-gray-400" />
+ )}
+ </FormLabel>
+ <FormControl>
+ <Input
+ readOnly={isReadOnly}
+ {...field}
+ className={cn(
+ isReadOnly &&
+ "bg-gray-100 text-gray-600 cursor-not-allowed border-gray-300",
+ )}
+ />
+ </FormControl>
+ {isReadOnly && (
+ <FormDescription className="text-xs text-gray-500">
+ {readOnlyReason}
+ </FormDescription>
+ )}
+ <FormMessage />
+ </FormItem>
+ );
+ }}
+ />
+ );
+ })}
+ </div>
+ ))}
+ </div>
+
+ {/* Footer */}
+ <SheetFooter className="gap-2 pt-2">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ {t("buttons.cancel")}
+ </Button>
+ </SheetClose>
+ <Button type="submit" disabled={isPending}>
+ {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />}
+ {t("buttons.save")}
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ );
+} \ No newline at end of file
diff --git a/components/form-data-plant/var-list-download-btn.tsx b/components/form-data-plant/var-list-download-btn.tsx
new file mode 100644
index 00000000..9d09ab8c
--- /dev/null
+++ b/components/form-data-plant/var-list-download-btn.tsx
@@ -0,0 +1,122 @@
+"use client";
+
+import React, { FC } from "react";
+import Image from "next/image";
+import { useToast } from "@/hooks/use-toast";
+import { toast as toastMessage } from "sonner";
+import ExcelJS from "exceljs";
+import { saveAs } from "file-saver";
+import { Button } from "@/components/ui/button";
+import { DataTableColumnJSON } from "./form-data-table-columns";
+import { useParams } from "next/navigation";
+import { useTranslation } from "@/i18n/client";
+
+interface VarListDownloadBtnProps {
+ columnsJSON: DataTableColumnJSON[];
+ formCode: string;
+}
+
+export const VarListDownloadBtn: FC<VarListDownloadBtnProps> = ({
+ columnsJSON,
+ formCode,
+}) => {
+ const { toast } = useToast();
+
+ const params = useParams();
+ const lng = (params?.lng as string) || "ko";
+ const { t } = useTranslation(lng, "engineering");
+
+ const downloadReportVarList = async () => {
+ try {
+ // Create a new workbook
+ const workbook = new ExcelJS.Workbook();
+
+ // 데이터 시트 생성
+ const worksheet = workbook.addWorksheet("Data");
+
+ // 유효성 검사용 숨김 시트 생성
+ const validationSheet = workbook.addWorksheet("ValidationData");
+ validationSheet.state = "hidden"; // 시트 숨김 처리
+
+ // 1. 데이터 시트에 헤더 추가
+ const headers = [
+ t("varListDownload.headers.tableColumnLabel"),
+ t("varListDownload.headers.reportVariable")
+ ];
+ worksheet.addRow(headers);
+
+ // 헤더 스타일 적용
+ const headerRow = worksheet.getRow(1);
+ headerRow.font = { bold: true };
+ headerRow.alignment = { horizontal: "center" };
+ headerRow.eachCell((cell) => {
+ cell.fill = {
+ type: "pattern",
+ pattern: "solid",
+ fgColor: { argb: "FFCCCCCC" },
+ };
+ });
+
+ // 2. 데이터 행 추가
+ columnsJSON.forEach((row) => {
+ console.log(row);
+ const { displayLabel, key } = row;
+
+ // const labelConvert = label.replaceAll(" ", "_");
+
+ worksheet.addRow([displayLabel, key]);
+ });
+
+ // 3. 컬럼 너비 자동 조정
+ headers.forEach((col, idx) => {
+ const column = worksheet.getColumn(idx + 1);
+
+ // 최적 너비 계산
+ let maxLength = col.length;
+ columnsJSON.forEach((row) => {
+ const valueKey = idx === 0 ? "displayLabel" : "label";
+
+ const value = row[valueKey];
+ 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);
+ });
+
+ const buffer = await workbook.xlsx.writeBuffer();
+ const fileName = `${formCode}${t("varListDownload.fileNameSuffix")}`;
+ saveAs(new Blob([buffer]), fileName);
+ toastMessage.success(t("varListDownload.messages.downloadComplete"));
+ } catch (err) {
+ console.log(err);
+ toast({
+ title: t("varListDownload.messages.errorTitle"),
+ description: t("varListDownload.messages.errorDescription"),
+ variant: "destructive",
+ });
+ }
+ };
+
+ return (
+ <Button
+ variant="outline"
+ className="relative px-[8px] py-[6px] flex-1"
+ aria-label={t("varListDownload.buttonAriaLabel")}
+ onClick={downloadReportVarList}
+ >
+ <Image
+ src="/icons/var_list_icon.svg"
+ alt={t("varListDownload.iconAltText")}
+ width={16}
+ height={16}
+ />
+ <div className="text-[12px]">{t("varListDownload.buttonText")}</div>
+ </Button>
+ );
+}; \ No newline at end of file