diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-10-23 10:10:21 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-10-23 10:10:21 +0000 |
| commit | f7f5069a2209cfa39b65f492f32270a5f554bed0 (patch) | |
| tree | 933c731ec2cb7d8bc62219a0aeed45a5e97d5f15 /components | |
| parent | d49ad5dee1e5a504e1321f6db802b647497ee9ff (diff) | |
(대표님) EDP 해양 관련 개발 사항들
Diffstat (limited to 'components')
28 files changed, 12913 insertions, 103 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 diff --git a/components/form-data/form-data-table.tsx b/components/form-data/form-data-table.tsx index 0f55c559..98cc7b46 100644 --- a/components/form-data/form-data-table.tsx +++ b/components/form-data/form-data-table.tsx @@ -117,6 +117,7 @@ export default function DynamicTable({ const [activeFilter, setActiveFilter] = React.useState<string | null>(null); const [filteredTableData, setFilteredTableData] = React.useState<GenericData[]>(tableData); + const [rowSelection, setRowSelection] = React.useState<Record<string, boolean>>({}); // 필터링 로직 React.useEffect(() => { @@ -343,15 +344,20 @@ export default function DynamicTable({ }, [selectedRowsData]); const columns = React.useMemo( - () => getColumns<GenericData>({ - columnsJSON, - setRowAction, - setReportData, - tempCount, - }), - [columnsJSON, setRowAction, setReportData, tempCount] + () => + getColumns({ + columnsJSON, + setRowAction, + setReportData, + tempCount, + onRowSelectionChange: setRowSelection, // ✅ 맞습니다 + templateData, + }), + [columnsJSON, tempCount, templateData] + // setRowSelection은 setState 함수라서 의존성 배열에서 제외 가능 + // (React가 안정적인 참조를 보장) ); - + function mapColumnTypeToAdvancedFilterType( columnType: ColumnType ): DataTableAdvancedFilterField<GenericData>["type"] { diff --git a/components/vendor-data-plant/project-swicher.tsx b/components/vendor-data-plant/project-swicher.tsx new file mode 100644 index 00000000..d3123709 --- /dev/null +++ b/components/vendor-data-plant/project-swicher.tsx @@ -0,0 +1,171 @@ +"use client" + +import * as React from "react" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { Check, ChevronsUpDown, Loader2 } from "lucide-react" + +interface ContractInfo { + contractId: number + contractName: string +} + +interface ProjectInfo { + projectId: number + projectCode: string + projectName: string + contracts: ContractInfo[] +} + +interface ProjectSwitcherProps { + isCollapsed: boolean + projects: ProjectInfo[] + + // 상위가 관리하는 "현재 선택된 contractId" + selectedContractId: number | null + + // 콜백: 사용자가 "어떤 contract"를 골랐는지 + // => 우리가 projectId도 찾아서 상위 state를 같이 갱신해야 함 + onSelectContract: (projectId: number, contractId: number) => void + + // 로딩 상태 (선택사항) + isLoading?: boolean +} + +export function ProjectSwitcher({ + isCollapsed, + projects, + selectedContractId, + onSelectContract, + isLoading = false, +}: ProjectSwitcherProps) { + const [popoverOpen, setPopoverOpen] = React.useState(false) + const [searchTerm, setSearchTerm] = React.useState("") + + // 현재 선택된 contract 객체 찾기 + const selectedContract = React.useMemo(() => { + if (!selectedContractId) return null + for (const proj of projects) { + const found = proj.contracts.find((c) => c.contractId === selectedContractId) + if (found) { + return { ...found, projectId: proj.projectId, projectName: proj.projectName } + } + } + return null + }, [projects, selectedContractId]) + + // Trigger label => 계약 이름 or placeholder + const triggerLabel = selectedContract?.contractName ?? "Select a contract" + + // 검색어에 따른 필터링된 프로젝트/계약 목록 + const filteredProjects = React.useMemo(() => { + if (!searchTerm) return projects + + return projects.map(project => ({ + ...project, + contracts: project.contracts.filter(contract => + contract.contractName.toLowerCase().includes(searchTerm.toLowerCase()) || + project.projectName.toLowerCase().includes(searchTerm.toLowerCase()) + ) + })).filter(project => project.contracts.length > 0) + }, [projects, searchTerm]) + + // 계약 선택 핸들러 + function handleSelectContract(projectId: number, contractId: number) { + onSelectContract(projectId, contractId) + setPopoverOpen(false) + setSearchTerm("") // 검색어 초기화 + } + + // 총 계약 수 계산 (빈 상태 표시용) + const totalContracts = filteredProjects.reduce((sum, project) => sum + project.contracts.length, 0) + + return ( + <Popover open={popoverOpen} onOpenChange={setPopoverOpen}> + <PopoverTrigger asChild> + <Button + type="button" + variant="outline" + className={cn( + "justify-between relative", + isCollapsed ? "h-9 w-9 shrink-0 items-center justify-center p-0" : "w-full h-9" + )} + disabled={isLoading} + aria-label="Select Contract" + > + {isLoading ? ( + <> + <span className={cn(isCollapsed && "hidden")}>Loading...</span> + <Loader2 className={cn("h-4 w-4 animate-spin", !isCollapsed && "ml-2")} /> + </> + ) : ( + <> + <span className={cn("truncate flex-grow text-left", isCollapsed && "hidden")}> + {triggerLabel} + </span> + <ChevronsUpDown className={cn("h-4 w-4 opacity-50 flex-shrink-0", isCollapsed && "hidden")} /> + </> + )} + </Button> + </PopoverTrigger> + + <PopoverContent className="w-[320px] p-0" align="start"> + <Command> + <CommandInput + placeholder="Search contracts..." + value={searchTerm} + onValueChange={setSearchTerm} + /> + + <CommandList + className="max-h-[320px]" + onWheel={(e) => { + e.stopPropagation() // 이벤트 전파 차단 + const target = e.currentTarget + target.scrollTop += e.deltaY // 직접 스크롤 처리 + }} + > + <CommandEmpty> + {totalContracts === 0 ? "No contracts found." : "No search results."} + </CommandEmpty> + + {filteredProjects.map((project) => ( + <CommandGroup key={project.projectCode} heading={project.projectName}> + {project.contracts.map((contract) => ( + <CommandItem + key={contract.contractId} + onSelect={() => handleSelectContract(project.projectId, contract.contractId)} + value={`${project.projectName} ${contract.contractName}`} + className="truncate" + title={contract.contractName} + > + <span className="truncate">{contract.contractName}</span> + <Check + className={cn( + "ml-auto h-4 w-4 flex-shrink-0", + selectedContractId === contract.contractId ? "opacity-100" : "opacity-0" + )} + /> + </CommandItem> + ))} + </CommandGroup> + ))} + </CommandList> + </Command> + </PopoverContent> + </Popover> + ) +}
\ No newline at end of file diff --git a/components/vendor-data-plant/sidebar.tsx b/components/vendor-data-plant/sidebar.tsx new file mode 100644 index 00000000..31ee6dc7 --- /dev/null +++ b/components/vendor-data-plant/sidebar.tsx @@ -0,0 +1,318 @@ +"use client" + +import * as React from "react" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Separator } from "@/components/ui/separator" +import { + Tooltip, + TooltipTrigger, + TooltipContent, +} from "@/components/ui/tooltip" +import { Package2, FormInput } from "lucide-react" +import { useRouter, usePathname } from "next/navigation" +import { Skeleton } from "@/components/ui/skeleton" +import { type FormInfo } from "@/lib/forms/services" + +interface PackageData { + itemId: number + itemName: string +} + +interface SidebarProps extends React.HTMLAttributes<HTMLDivElement> { + isCollapsed: boolean + packages: PackageData[] + selectedPackageId: number | null + selectedProjectId: number | null + selectedContractId: number | null + onSelectPackage: (itemId: number) => void + forms?: FormInfo[] + onSelectForm: (formName: string) => void + isLoadingForms?: boolean + mode: "IM" | "ENG" +} + +export function Sidebar({ + className, + isCollapsed, + packages, + selectedPackageId, + selectedProjectId, + selectedContractId, + onSelectPackage, + forms, + onSelectForm, + isLoadingForms = false, + mode = "IM", +}: SidebarProps) { + const router = useRouter() + const rawPathname = usePathname() + const pathname = rawPathname ?? "" + + /** + * --------------------------- + * 1) URL에서 현재 패키지 / 폼 코드 추출 + * --------------------------- + */ + const segments = pathname.split("/").filter(Boolean) + + let currentItemId: number | null = null + let currentFormCode: string | null = null + + const tagIndex = segments.indexOf("tag") + if (tagIndex !== -1 && segments[tagIndex + 1]) { + currentItemId = parseInt(segments[tagIndex + 1], 10) + } + + const formIndex = segments.indexOf("form") + if (formIndex !== -1) { + const itemSegment = segments[formIndex + 1] + const codeSegment = segments[formIndex + 2] + + if (itemSegment) { + currentItemId = parseInt(itemSegment, 10) + } + if (codeSegment) { + currentFormCode = codeSegment + } + } + + /** + * --------------------------- + * 2) 패키지 클릭 핸들러 (IM 모드) + * --------------------------- + */ + const handlePackageClick = (itemId: number) => { + // 상위 컴포넌트 상태 업데이트 + onSelectPackage(itemId) + + // 해당 태그 페이지로 라우팅 + // 예: /vendor-data-plant/tag/123 + const baseSegments = segments.slice(0, segments.indexOf("vendor-data-plant") + 1).join("/") + router.push(`/${baseSegments}/tag/${itemId}`) + } + + /** + * --------------------------- + * 3) 폼 클릭 핸들러 (IM 모드만 사용) + * --------------------------- + */ + const handleFormClick = (form: FormInfo) => { + // IM 모드에서만 사용 + if (selectedPackageId === null) return; + + onSelectForm(form.formName) + + const baseSegments = segments.slice(0, segments.indexOf("vendor-data-plant") + 1).join("/") + router.push(`/${baseSegments}/form/${selectedPackageId}/${form.formCode}/${selectedProjectId}/${selectedContractId}?mode=${mode}`) + } + + /** + * --------------------------- + * 4) 패키지 클릭 핸들러 (ENG 모드) + * --------------------------- + */ + const handlePackageUnderFormClick = (form: FormInfo, pkg: PackageData) => { + onSelectForm(form.formName) + onSelectPackage(pkg.itemId) + + const baseSegments = segments.slice(0, segments.indexOf("vendor-data-plant") + 1).join("/") + router.push(`/${baseSegments}/form/${pkg.itemId}/${form.formCode}/${selectedProjectId}/${selectedContractId}?mode=${mode}`) + } + + return ( + <div className={cn("pb-12", className)}> + <div className="space-y-4 py-4"> + {/* ---------- 패키지(Items) 목록 - IM 모드에서만 표시 ---------- */} + {mode === "IM" && ( + <> + <div className="py-1"> + <h2 className="relative px-7 text-lg font-semibold tracking-tight"> + {isCollapsed ? "P" : "Package Lists"} + </h2> + <ScrollArea className="h-[150px] px-1"> + <div className="space-y-1 p-2"> + {packages.map((pkg) => { + const isActive = pkg.itemId === currentItemId + + return ( + <div key={pkg.itemId}> + {isCollapsed ? ( + <Tooltip delayDuration={0}> + <TooltipTrigger asChild> + <Button + variant="ghost" + className={cn( + "w-full justify-start font-normal", + isActive && "bg-accent text-accent-foreground" + )} + onClick={() => handlePackageClick(pkg.itemId)} + > + <Package2 className="mr-2 h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent side="right"> + {pkg.itemName} + </TooltipContent> + </Tooltip> + ) : ( + <Button + variant="ghost" + className={cn( + "w-full justify-start font-normal", + isActive && "bg-accent text-accent-foreground" + )} + onClick={() => handlePackageClick(pkg.itemId)} + > + <Package2 className="mr-2 h-4 w-4" /> + {pkg.itemName} + </Button> + )} + </div> + ) + })} + </div> + </ScrollArea> + </div> + <Separator /> + </> + )} + + {/* ---------- 폼 목록 (IM 모드) / 패키지와 폼 목록 (ENG 모드) ---------- */} + <div className="py-1"> + <h2 className="relative px-7 text-lg font-semibold tracking-tight"> + {isCollapsed + ? (mode === "IM" ? "F" : "P") + : (mode === "IM" ? "Form Lists" : "Package Lists") + } + </h2> + <ScrollArea className={cn( + "px-1", + mode === "IM" ? "h-[300px]" : "h-[450px]" + )}> + <div className="space-y-1 p-2"> + {isLoadingForms ? ( + Array.from({ length: 3 }).map((_, index) => ( + <div key={`form-skeleton-${index}`} className="px-2 py-1.5"> + <Skeleton className="h-8 w-full" /> + </div> + )) + ) : mode === "IM" ? ( + // =========== IM 모드: 폼만 표시 =========== + !forms || forms.length === 0 ? ( + <p className="text-sm text-muted-foreground px-2"> + (No forms loaded) + </p> + ) : ( + forms.map((form) => { + const isFormActive = form.formCode === currentFormCode + const isDisabled = currentItemId === null + + return isCollapsed ? ( + <Tooltip key={form.formCode} delayDuration={0}> + <TooltipTrigger asChild> + <Button + variant="ghost" + className={cn( + "w-full justify-start font-normal", + isFormActive && "bg-accent text-accent-foreground" + )} + onClick={() => handleFormClick(form)} + disabled={isDisabled} + > + <FormInput className="mr-2 h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent side="right"> + {form.formName} + </TooltipContent> + </Tooltip> + ) : ( + <Button + key={form.formCode} + variant="ghost" + className={cn( + "w-full justify-start font-normal", + isFormActive && "bg-accent text-accent-foreground" + )} + onClick={() => handleFormClick(form)} + disabled={isDisabled} + > + <FormInput className="mr-2 h-4 w-4" /> + {form.formName} + </Button> + ) + }) + ) + ) : ( + // =========== ENG 모드: 패키지 > 폼 계층 구조 =========== + packages.length === 0 ? ( + <p className="text-sm text-muted-foreground px-2"> + (No packages loaded) + </p> + ) : ( + packages.map((pkg) => ( + <div key={pkg.itemId} className="space-y-1"> + {isCollapsed ? ( + <Tooltip delayDuration={0}> + <TooltipTrigger asChild> + <div className="px-2 py-1"> + <Package2 className="h-4 w-4" /> + </div> + </TooltipTrigger> + <TooltipContent side="right"> + {pkg.itemName} + </TooltipContent> + </Tooltip> + ) : ( + <> + {/* 패키지 이름 (클릭 불가능한 라벨) */} + <div className="flex items-center px-2 py-1 text-sm font-medium"> + <Package2 className="mr-2 h-4 w-4" /> + {pkg.itemName} + </div> + + {/* 폼 목록 바로 표시 */} + <div className="ml-6 space-y-1"> + {!forms || forms.length === 0 ? ( + <p className="text-xs text-muted-foreground px-2 py-1"> + No forms available + </p> + ) : ( + forms.map((form) => { + const isFormPackageActive = + pkg.itemId === currentItemId && + form.formCode === currentFormCode + + return ( + <Button + key={`${pkg.itemId}-${form.formCode}`} + variant="ghost" + size="sm" + className={cn( + "w-full justify-start font-normal text-sm", + isFormPackageActive && "bg-accent text-accent-foreground" + )} + onClick={() => handlePackageUnderFormClick(form, pkg)} + > + <FormInput className="mr-2 h-3 w-3" /> + {form.formName} + </Button> + ) + }) + )} + </div> + </> + )} + </div> + )) + ) + )} + </div> + </ScrollArea> + </div> + </div> + </div> + ) +}
\ No newline at end of file diff --git a/components/vendor-data-plant/tag-table/add-tag-dialog.tsx b/components/vendor-data-plant/tag-table/add-tag-dialog.tsx new file mode 100644 index 00000000..1321fc58 --- /dev/null +++ b/components/vendor-data-plant/tag-table/add-tag-dialog.tsx @@ -0,0 +1,357 @@ +"use client" + +import * as React from "react" +import { useForm, useWatch } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +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, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { createTagSchema, type CreateTagSchema } from "@/lib/tags/validations" +import { createTag } from "@/lib/tags/service" +import { toast } from "sonner" +import { Loader2 } from "lucide-react" +import { cn } from "@/lib/utils" +import { useRouter } from "next/navigation" + +// Popover + Command +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ui/popover" +import { + Command, + CommandInput, + CommandList, + CommandGroup, + CommandItem, + CommandEmpty, +} from "@/components/ui/command" +import { ChevronsUpDown, Check } from "lucide-react" + +// The dynamic Tag Type definitions +import { tagTypeDefinitions } from "./tag-type-definitions" + +// Add Select component for dropdown fields +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { ScrollArea } from "@/components/ui/scroll-area" + +interface AddTagDialogProps { + selectedPackageId: number | null +} + +export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) { + const [open, setOpen] = React.useState(false) + const [isPending, startTransition] = React.useTransition() + const router = useRouter() + + const form = useForm<CreateTagSchema>({ + resolver: zodResolver(createTagSchema), + defaultValues: { + tagType: "", // user picks + tagNo: "", // auto-generated + description: "", + functionCode: "", + seqNumber: "", + valveAcronym: "", + processUnit: "", + }, + }) + + const watchAll = useWatch({ control: form.control }) + + // 1) Find the selected tag type definition + const currentTagTypeDef = React.useMemo(() => { + return tagTypeDefinitions.find((def) => def.id === watchAll.tagType) || null + }, [watchAll.tagType]) + + // 2) Whenever the user changes sub-fields, re-generate `tagNo` + React.useEffect(() => { + if (!currentTagTypeDef) { + // if no type selected, no auto-generation + return + } + + // Prevent infinite loop by excluding tagNo from the watched dependencies + // This is crucial because setting tagNo would trigger another update + const { tagNo, ...fieldsToWatch } = watchAll + + const newTagNo = currentTagTypeDef.generateTagNo(fieldsToWatch as CreateTagSchema) + + // Only update if different to avoid unnecessary re-renders + if (form.getValues("tagNo") !== newTagNo) { + form.setValue("tagNo", newTagNo, { shouldValidate: false }) + } + }, [currentTagTypeDef, watchAll, form]) + + // Check if tag number is valid (doesn't contain '??' and is not empty) + const isTagNoValid = React.useMemo(() => { + const tagNo = form.getValues("tagNo"); + return tagNo && tagNo.trim() !== "" && !tagNo.includes("??"); + }, [form, watchAll.tagNo]); + + // onSubmit + async function onSubmit(data: CreateTagSchema) { + startTransition(async () => { + if (!selectedPackageId) { + toast.error("No selectedPackageId.") + return + } + + const result = await createTag(data, selectedPackageId) + if ("error" in result) { + toast.error(`Error: ${result.error}`) + return + } + + toast.success("Tag created successfully!") + form.reset() + setOpen(false) + router.refresh() + + }) + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset() + } + setOpen(nextOpen) + } + + // 3) TagType selection UI (like your Command menu) + function renderTagTypeSelector(field: any) { + const [popoverOpen, setPopoverOpen] = React.useState(false) + return ( + <FormItem> + <FormLabel>Tag Type</FormLabel> + <FormControl> + <Popover open={popoverOpen} onOpenChange={setPopoverOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={popoverOpen} + className="w-full justify-between" + > + {field.value + ? tagTypeDefinitions.find((d) => d.id === field.value)?.label + : "Select Tag Type..."} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-full p-0"> + <Command> + <CommandInput placeholder="Search Tag Type..." /> + <CommandList> + <CommandEmpty>No tag type found.</CommandEmpty> + <CommandGroup> + {tagTypeDefinitions.map((def,index) => ( + <CommandItem + key={index} + onSelect={() => { + field.onChange(def.id) // store the 'id' + setPopoverOpen(false) + }} + value={def.id} + > + {def.label} + <Check + className={cn( + "ml-auto h-4 w-4", + field.value === def.id + ? "opacity-100" + : "opacity-0" + )} + /> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + </FormControl> + <FormMessage /> + </FormItem> + ) + } + + // 4) Render sub-fields based on currentTagTypeDef + // Updated to handle different field types (text, select) + function renderSubFields() { + if (!currentTagTypeDef) return null + + return currentTagTypeDef.subFields.map((subField, index) => ( + + <FormField + key={`${subField.name}-${index}`} + control={form.control} + name={subField.name as keyof CreateTagSchema} + render={({ field }) => ( + <FormItem> + <FormLabel>{subField.label}</FormLabel> + <FormControl> + {subField.type === "select" && subField.options ? ( + <Select + value={field.value || ""} + onValueChange={field.onChange} + > + <SelectTrigger className="w-full"> + <SelectValue placeholder={subField.placeholder || "Select an option"} /> + </SelectTrigger> + <SelectContent> + {subField.options.map((option, index) => ( + <SelectItem key={index} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + ) : ( + <Input + placeholder={subField.placeholder || ""} + value={field.value || ""} + onChange={field.onChange} + onBlur={field.onBlur} + name={field.name} + ref={field.ref} + /> + )} + </FormControl> + {subField.formatHint && ( + <p className="text-sm text-muted-foreground mt-1"> + {subField.formatHint} + </p> + )} + <FormMessage /> + </FormItem> + )} + /> + )) + } + + return ( + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + <DialogTrigger asChild> + <Button variant="default" size="sm"> + Add Tag + </Button> + </DialogTrigger> + + <DialogContent className="max-w-md max-h-[90vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>Add New Tag</DialogTitle> + <DialogDescription> + Select a Tag Type and fill in sub-fields. The Tag No will be generated automatically. + </DialogDescription> + </DialogHeader> + <ScrollArea className="flex-1"> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + <div className="space-y-4"> + {/* Tag Type - Outside ScrollArea as it's always visible */} + <FormField + control={form.control} + name="tagType" + render={({ field }) => renderTagTypeSelector(field)} + /> + </div> + + {/* ScrollArea for dynamic fields */} + <ScrollArea className="h-[50vh] pr-4"> + <div className="space-y-4"> + {/* sub-fields from the selected tagType */} + {renderSubFields()} + + {/* Tag No (auto-generated) */} + <FormField + control={form.control} + name="tagNo" + render={({ field }) => ( + <FormItem> + <FormLabel>Tag No (auto-generated)</FormLabel> + <FormControl> + <Input + placeholder="Auto-generated..." + {...field} + readOnly + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Description (optional) */} + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>Description </FormLabel> + <FormControl> + <Input + placeholder="Optional desc..." + value={field.value ?? ""} + onChange={(e) => field.onChange(e.target.value)} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </ScrollArea> + </form> + </Form> + </ScrollArea> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => { + form.reset(); + setOpen(false); + }} + disabled={isPending} + > + Cancel + </Button> + <Button type="submit" disabled={isPending || !isTagNoValid}> + {isPending && ( + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + )} + Create + </Button> + </DialogFooter> + + + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/components/vendor-data-plant/tag-table/tag-table-column.tsx b/components/vendor-data-plant/tag-table/tag-table-column.tsx new file mode 100644 index 00000000..6f0d977f --- /dev/null +++ b/components/vendor-data-plant/tag-table/tag-table-column.tsx @@ -0,0 +1,198 @@ +"use client" + +import * as React from "react" +import { ColumnDef } from "@tanstack/react-table" +import type { Row } from "@tanstack/react-table" +import { numericFilter } from "@/lib/data-table" +import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header" +import { formatDate } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, + } from "@/components/ui/dropdown-menu" +import { Ellipsis } from "lucide-react" +import { Tag } from "@/types/vendorData" +import { createFilterFn } from "@/components/client-data-table/table-filters" + + +export interface DataTableRowAction<TData> { + row: Row<TData> + type: 'open' | "update" | "delete" +} + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<Tag> | null>> +} + +export function getColumns({ + setRowAction, + }: GetColumnsProps): ColumnDef<Tag>[] { + return [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + enableSorting: false, + enableHiding: false, + size: 40, + }, + + { + accessorKey: "tagNo", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Tag No." /> + ), + filterFn: createFilterFn("text"), + cell: ({ row }) => <div className="w-20">{row.getValue("tagNo")}</div>, + meta: { + excelHeader: "Tag No" + }, + }, + { + accessorKey: "description", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Tag Description" /> + ), + cell: ({ row }) => <div className="w-120">{row.getValue("description")}</div>, + meta: { + excelHeader: "Tag Descripiton" + }, + }, + { + accessorKey: "tagType", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Tag Type" /> + ), + cell: ({ row }) => <div className="w-40">{row.getValue("tagType")}</div>, + meta: { + excelHeader: "Tag Type" + }, + }, + { + id: "validation", + header: "Error", + cell: ({ row }) => <div className="w-100"></div>, + }, + + { + accessorKey: "createdAt", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Created At" /> + ), + cell: ({ cell }) => formatDate(cell.getValue() as Date), + meta: { + excelHeader: "created At" + }, + }, + { + accessorKey: "updatedAt", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Updated At" /> + ), + cell: ({ cell }) => formatDate(cell.getValue() as Date), + meta: { + excelHeader: "updated At" + }, + }, + + { + id: "actions", + cell: function Cell({ row }) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + return ( + <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> + <DropdownMenuSub> + <DropdownMenuSubTrigger>Labels</DropdownMenuSubTrigger> + <DropdownMenuSubContent> + {/* <DropdownMenuRadioGroup + value={row.original.label} + onValueChange={(value) => { + startUpdateTransition(() => { + toast.promise( + updateTask({ + id: row.original.id, + label: value as Task["label"], + }), + { + loading: "Updating...", + success: "Label updated", + error: (err) => getErrorMessage(err), + } + ) + }) + }} + > + {tasks.label.enumValues.map((label) => ( + <DropdownMenuRadioItem + key={label} + value={label} + className="capitalize" + disabled={isUpdatePending} + > + {label} + </DropdownMenuRadioItem> + ))} + </DropdownMenuRadioGroup> */} + </DropdownMenuSubContent> + </DropdownMenuSub> + <DropdownMenuSeparator /> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "delete" })} + > + Delete + <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + }, + ] + } +
\ No newline at end of file diff --git a/components/vendor-data-plant/tag-table/tag-table.tsx b/components/vendor-data-plant/tag-table/tag-table.tsx new file mode 100644 index 00000000..a449529f --- /dev/null +++ b/components/vendor-data-plant/tag-table/tag-table.tsx @@ -0,0 +1,39 @@ +"use client" + +import * as React from "react" +import { ClientDataTable } from "@/components/client-data-table/data-table" +import { DataTableRowAction, getColumns } from "./tag-table-column" +import { Tag as TagData } from "@/types/vendorData" +import { DataTableAdvancedFilterField } from "@/types/table" +import { AddTagDialog } from "./add-tag-dialog" + +interface TagTableProps { + data: TagData[] +} + +/** + * TagTable: Tag 데이터를 표시하는 표 + */ +export function TagTable({ data }: TagTableProps) { + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<TagData> | null>(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + const advancedFilterFields: DataTableAdvancedFilterField<TagData>[] = [] + + return ( + <> + <ClientDataTable + data={data} + columns={columns} + advancedFilterFields={advancedFilterFields} + /> + + </> + ) +}
\ No newline at end of file diff --git a/components/vendor-data-plant/tag-table/tag-type-definitions.ts b/components/vendor-data-plant/tag-table/tag-type-definitions.ts new file mode 100644 index 00000000..e5d04eab --- /dev/null +++ b/components/vendor-data-plant/tag-table/tag-type-definitions.ts @@ -0,0 +1,87 @@ +import { CreateTagSchema } from "@/lib/tags/validations" + +/** + * Each "Tag Type" has: + * - id, label + * - subFields[]: + * -- name (form field name) + * -- label (UI label) + * -- placeholder? + * -- type: "select" | "text" + * -- options?: { value: string; label: string; }[] (for dropdown) + * -- optional "regex" or "formatHint" for display + * - generateTagNo: function + */ +export const tagTypeDefinitions = [ + { + id: "EquipmentNumbering", + label: "Equipment Numbering", + subFields: [ + { + name: "functionCode", + label: "Function", + placeholder: "", + type: "select", + // Example options: + options: [ + { value: "PM", label: "Pump" }, + { value: "AA", label: "Pneumatic Motor" }, + ], + // or if you want a regex or format hint: + formatHint: "2 letters, e.g. PM", + }, + { + name: "seqNumber", + label: "Seq. Number", + placeholder: "001", + type: "text", + formatHint: "3 digits", + }, + ], + generateTagNo: (values: CreateTagSchema) => { + const fc = values.functionCode || "??" + const seq = values.seqNumber || "000" + return `${fc}-${seq}` + }, + }, + { + id: "Valve", + label: "Valve", + subFields: [ + { + name: "valveAcronym", + label: "Valve Acronym", + placeholder: "", + type: "select", + options: [ + { value: "VB", label: "Ball Valve" }, + { value: "VAR", label: "Auto Recirculation Valve" }, + ], + }, + { + name: "processUnit", + label: "Process Unit (2 digits)", + placeholder: "01", + type: "select", + options: [ + { value: "01", label: "Firewater System" }, + { value: "02", label: "Liquefaction Unit" }, + ], + }, + { + name: "seqNumber", + label: "Seq. Number", + placeholder: "001", + type: "text", + formatHint: "3 digits", + }, + ], + generateTagNo: (values: CreateTagSchema) => { + const va = values.valveAcronym || "??" + const pu = values.processUnit || "??" + const seq= values.seqNumber || "000" + return `${va}-${pu}${seq}` + }, + }, + // ... more types from your API ... +]
\ No newline at end of file diff --git a/components/vendor-data-plant/vendor-data-container.tsx b/components/vendor-data-plant/vendor-data-container.tsx new file mode 100644 index 00000000..60ec2c94 --- /dev/null +++ b/components/vendor-data-plant/vendor-data-container.tsx @@ -0,0 +1,505 @@ +"use client" + +import * as React from "react" +import { TooltipProvider } from "@/components/ui/tooltip" +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable" +import { cn } from "@/lib/utils" +import { ProjectSwitcher } from "./project-swicher" +import { Sidebar } from "./sidebar" +import { usePathname, useRouter, useSearchParams } from "next/navigation" +import { getFormsByContractItemId, type FormInfo } from "@/lib/forms/services" +import { Separator } from "@/components/ui/separator" +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Button } from "@/components/ui/button" +import { FormInput } from "lucide-react" +import { Skeleton } from "@/components/ui/skeleton" +import { selectedModeAtom } from '@/atoms' +import { useAtom } from 'jotai' + +interface PackageData { + itemId: number + itemName: string +} + +interface ContractData { + contractId: number + contractName: string + packages: PackageData[] +} + +interface ProjectData { + projectId: number + projectCode: string + projectName: string + projectType: string + contracts: ContractData[] +} + +interface VendorDataContainerProps { + projects: ProjectData[] + defaultLayout?: number[] + defaultCollapsed?: boolean + navCollapsedSize: number + children: React.ReactNode +} + +function getTagIdFromPathname(path: string | null): number | null { + if (!path) return null; + + // 태그 패턴 검사 (/tag/123) + const tagMatch = path.match(/\/tag\/(\d+)/) + if (tagMatch) return parseInt(tagMatch[1], 10) + + // 폼 패턴 검사 (/form/123/...) + const formMatch = path.match(/\/form\/(\d+)/) + if (formMatch) return parseInt(formMatch[1], 10) + + return null +} + +export function VendorDataContainer({ + projects, + defaultLayout = [20, 80], + defaultCollapsed = false, + navCollapsedSize, + children +}: VendorDataContainerProps) { + const pathname = usePathname() + const router = useRouter() + const searchParams = useSearchParams() + + const tagIdNumber = getTagIdFromPathname(pathname) + + // 기본 상태 + const [selectedProjectId, setSelectedProjectId] = React.useState(projects[0]?.projectId || 0) + const [isCollapsed, setIsCollapsed] = React.useState(defaultCollapsed) + const [selectedContractId, setSelectedContractId] = React.useState( + projects[0]?.contracts[0]?.contractId || 0 + ) + const [selectedPackageId, setSelectedPackageId] = React.useState<number | null>(null) + const [formList, setFormList] = React.useState<FormInfo[]>([]) + const [selectedFormCode, setSelectedFormCode] = React.useState<string | null>(null) + const [isLoadingForms, setIsLoadingForms] = React.useState(false) + + console.log(selectedPackageId,"selectedPackageId") + + + // 현재 선택된 프로젝트/계약/패키지 + const currentProject = projects.find((p) => p.projectId === selectedProjectId) ?? projects[0] + const currentContract = currentProject?.contracts.find((c) => c.contractId === selectedContractId) + ?? currentProject?.contracts[0] + + // 프로젝트 타입 확인 - ship인 경우 항상 ENG 모드 + const isShipProject = currentProject?.projectType === "ship" + + const [selectedMode, setSelectedMode] = useAtom(selectedModeAtom) + + // URL에서 모드 추출 (ship 프로젝트면 무조건 ENG로, 아니면 URL 또는 기본값) + const modeFromUrl = searchParams?.get('mode') + const initialMode ="ENG" + + // 모드 초기화 (기존의 useState 초기값 대신) + React.useEffect(() => { + setSelectedMode(initialMode as "IM" | "ENG") + }, [initialMode, setSelectedMode]) + + const isTagOrFormRoute = pathname ? (pathname.includes("/tag/") || pathname.includes("/form/")) : false + const currentPackageName = isTagOrFormRoute + ? currentContract?.packages.find((pkg) => pkg.itemId === selectedPackageId)?.itemName || "None" + : "None" + + // 폼 목록에서 고유한 폼 이름만 추출 + const formNames = React.useMemo(() => { + return [...new Set(formList.map((form) => form.formName))] + }, [formList]) + + // URL에서 현재 폼 코드 추출 + const getCurrentFormCode = (path: string): string | null => { + const segments = path.split("/").filter(Boolean) + const formIndex = segments.indexOf("form") + if (formIndex !== -1 && segments[formIndex + 2]) { + return segments[formIndex + 2] + } + return null + } + + const currentFormCode = React.useMemo(() => { + return pathname ? getCurrentFormCode(pathname) : null + }, [pathname]) + + // URL에서 모드가 변경되면 상태도 업데이트 (ship 프로젝트가 아닐 때만) + React.useEffect(() => { + if (!isShipProject) { + const modeFromUrl = searchParams?.get('mode') + if (modeFromUrl === "ENG" || modeFromUrl === "IM") { + setSelectedMode(modeFromUrl) + } + } + }, [searchParams, isShipProject]) + + // 프로젝트 타입이 변경될 때 모드 업데이트 + React.useEffect(() => { + if (isShipProject) { + setSelectedMode("ENG") + + // URL 모드 파라미터도 업데이트 + const url = new URL(window.location.href); + url.searchParams.set('mode', 'ENG'); + router.replace(url.pathname + url.search); + } + }, [isShipProject, router]) + + // (1) 새로고침 시 URL 파라미터(tagIdNumber) → selectedPackageId 세팅 + React.useEffect(() => { + if (!currentContract) return + + if (tagIdNumber) { + setSelectedPackageId(tagIdNumber) + } else { + // tagIdNumber가 없으면, 현재 계약의 첫 번째 패키지로 + if (currentContract.packages?.length) { + setSelectedPackageId(currentContract.packages[0].itemId) + } else { + setSelectedPackageId(null) + } + } + }, [tagIdNumber, currentContract]) + + // (2) 프로젝트 변경 시 계약 초기화 + // React.useEffect(() => { + // if (currentProject?.contracts.length) { + // setSelectedContractId(currentProject.contracts[0].contractId) + // } else { + // setSelectedContractId(0) + // } + // }, [currentProject]) + + // (3) 패키지 ID와 모드가 변경될 때마다 폼 로딩 + React.useEffect(() => { + const packageId = getTagIdFromPathname(pathname) + + if (packageId) { + setSelectedPackageId(packageId) + + // URL에서 패키지 ID를 얻었을 때 즉시 폼 로드 + loadFormsList(packageId, selectedMode); + } else if (currentContract?.packages?.length) { + const firstPackageId = currentContract.packages[0].itemId; + setSelectedPackageId(firstPackageId); + loadFormsList(firstPackageId, selectedMode); + } + }, [pathname, currentContract, selectedMode]) + + // 모드에 따른 폼 로드 함수 + const loadFormsList = async (packageId: number, mode: "IM" | "ENG") => { + if (!packageId) return; + + setIsLoadingForms(true); + try { + const result = await getFormsByContractItemId(packageId, mode); + setFormList(result.forms || []); + } catch (error) { + console.error(`폼 로딩 오류 (${mode} 모드):`, error); + setFormList([]); + } finally { + setIsLoadingForms(false); + } + }; + + // 핸들러들 +// 수정된 handleSelectContract 함수 +async function handleSelectContract(projId: number, cId: number) { + setSelectedProjectId(projId) + setSelectedContractId(cId) + + // 선택된 계약의 첫 번째 패키지 찾기 + const selectedProject = projects.find(p => p.projectId === projId) + const selectedContract = selectedProject?.contracts.find(c => c.contractId === cId) + + if (selectedContract?.packages?.length) { + const firstPackageId = selectedContract.packages[0].itemId + setSelectedPackageId(firstPackageId) + + // ENG 모드로 폼 목록 로드 + setIsLoadingForms(true) + try { + const result = await getFormsByContractItemId(firstPackageId, "ENG") + setFormList(result.forms || []) + + // 첫 번째 폼이 있으면 자동 선택 및 네비게이션 + if (result.forms && result.forms.length > 0) { + const firstForm = result.forms[0] + setSelectedFormCode(firstForm.formCode) + + // ENG 모드로 설정 + setSelectedMode("ENG") + + // 첫 번째 폼으로 네비게이션 + const baseSegments = pathname?.split("/").filter(Boolean).slice(0, pathname.split("/").filter(Boolean).indexOf("vendor-data-plant") + 1).join("/") + router.push(`/${baseSegments}/form/0/${firstForm.formCode}/${projId}/${cId}?mode=ENG`) + } else { + // 폼이 없는 경우에도 ENG 모드로 설정 + setSelectedMode("ENG") + setSelectedFormCode(null) + + const baseSegments = pathname?.split("/").filter(Boolean).slice(0, pathname.split("/").filter(Boolean).indexOf("vendor-data-plant") + 1).join("/") + router.push(`/${baseSegments}/form/0/0/${projId}/${cId}?mode=ENG`) + } + } catch (error) { + console.error("폼 로딩 오류:", error) + setFormList([]) + setSelectedFormCode(null) + + // 오류 발생 시에도 ENG 모드로 설정 + setSelectedMode("ENG") + } finally { + setIsLoadingForms(false) + } + } else { + // 패키지가 없는 경우 + setSelectedPackageId(null) + setFormList([]) + setSelectedFormCode(null) + setSelectedMode("ENG") + } +} + + function handleSelectPackage(itemId: number) { + setSelectedPackageId(itemId) + } + + function handleSelectForm(formName: string) { + const form = formList.find((f) => f.formName === formName) + if (form) { + setSelectedFormCode(form.formCode) + } + } + + // 모드 변경 핸들러 +// 모드 변경 핸들러 +const handleModeChange = async (mode: "IM" | "ENG") => { + // ship 프로젝트인 경우 모드 변경 금지 + if (isShipProject && mode !== "ENG") return; + + setSelectedMode(mode); + + // 모드가 변경될 때 자동 네비게이션 + if (currentContract?.packages?.length) { + const firstPackageId = currentContract.packages[0].itemId; + + if (mode === "IM") { + // IM 모드: 첫 번째 패키지로 이동 + const baseSegments = pathname?.split("/").filter(Boolean).slice(0, pathname.split("/").filter(Boolean).indexOf("vendor-data-plant") + 1).join("/"); + router.push(`/${baseSegments}/tag/${firstPackageId}?mode=${mode}`); + } else { + // ENG 모드: 폼 목록을 먼저 로드 + setIsLoadingForms(true); + try { + const result = await getFormsByContractItemId(firstPackageId, mode); + setFormList(result.forms || []); + + // 폼이 있으면 첫 번째 폼으로 이동 + if (result.forms && result.forms.length > 0) { + const firstForm = result.forms[0]; + setSelectedFormCode(firstForm.formCode); + + const baseSegments = pathname?.split("/").filter(Boolean).slice(0, pathname.split("/").filter(Boolean).indexOf("vendor-data-plant") + 1).join("/"); + router.push(`/${baseSegments}/form/0/${firstForm.formCode}/${selectedProjectId}/${selectedContractId}?mode=${mode}`); + } else { + // 폼이 없으면 모드만 변경 + const baseSegments = pathname?.split("/").filter(Boolean).slice(0, pathname.split("/").filter(Boolean).indexOf("vendor-data-plant") + 1).join("/"); + router.push(`/${baseSegments}/form/0/0/${selectedProjectId}/${selectedContractId}?mode=${mode}`); + } + } catch (error) { + console.error(`폼 로딩 오류 (${mode} 모드):`, error); + // 오류 발생 시 모드만 변경 + const url = new URL(window.location.href); + url.searchParams.set('mode', mode); + router.replace(url.pathname + url.search); + } finally { + setIsLoadingForms(false); + } + } + } else { + // 패키지가 없는 경우, 모드만 변경 + const url = new URL(window.location.href); + url.searchParams.set('mode', mode); + router.replace(url.pathname + url.search); + } +}; + + return ( + <TooltipProvider delayDuration={0}> + <ResizablePanelGroup direction="horizontal" className="h-full"> + <ResizablePanel + defaultSize={defaultLayout[0]} + collapsedSize={navCollapsedSize} + collapsible + minSize={15} + maxSize={25} + onCollapse={() => setIsCollapsed(true)} + onResize={() => setIsCollapsed(false)} + className={cn(isCollapsed && "min-w-[50px] transition-all duration-300 ease-in-out")} + > + <div + className={cn( + "flex h-[52px] items-center justify-center gap-2", + isCollapsed ? "h-[52px]" : "px-2" + )} + > + <ProjectSwitcher + isCollapsed={isCollapsed} + projects={projects} + selectedContractId={selectedContractId} + onSelectContract={handleSelectContract} + /> + </div> + <Separator /> + + {!isCollapsed ? ( + isShipProject ? ( + // 프로젝트 타입이 ship인 경우: 탭 없이 ENG 모드 사이드바만 바로 표시 + <div className="mt-0"> + <Sidebar + isCollapsed={isCollapsed} + packages={currentContract?.packages || []} + selectedPackageId={selectedPackageId} + selectedProjectId={selectedProjectId} + selectedContractId={selectedContractId} + onSelectPackage={handleSelectPackage} + forms={formList} + selectedForm={ + selectedFormCode + ? formList.find((f) => f.formCode === selectedFormCode)?.formName || null + : null + } + onSelectForm={handleSelectForm} + isLoadingForms={isLoadingForms} + mode="ENG" + className="hidden lg:block" + /> + </div> + ) : ( + // 프로젝트 타입이 ship이 아닌 경우: 기존 탭 UI 표시 + <Tabs + defaultValue={initialMode} + value={selectedMode} + onValueChange={(value) => handleModeChange(value as "IM" | "ENG")} + className="w-full" + > + <TabsList className="w-full"> + <TabsTrigger value="ENG" className="flex-1">Engineering</TabsTrigger> + <TabsTrigger value="IM" className="flex-1">Handover</TabsTrigger> + + </TabsList> + + <TabsContent value="IM" className="mt-0"> + <Sidebar + isCollapsed={isCollapsed} + packages={currentContract?.packages || []} + selectedPackageId={selectedPackageId} + selectedContractId={selectedContractId} + selectedProjectId={selectedProjectId} + onSelectPackage={handleSelectPackage} + forms={formList} + selectedForm={ + selectedFormCode + ? formList.find((f) => f.formCode === selectedFormCode)?.formName || null + : null + } + onSelectForm={handleSelectForm} + isLoadingForms={isLoadingForms} + mode="IM" + className="hidden lg:block" + /> + </TabsContent> + + <TabsContent value="ENG" className="mt-0"> + <Sidebar + isCollapsed={isCollapsed} + packages={currentContract?.packages || []} + selectedPackageId={selectedPackageId} + selectedContractId={selectedContractId} + selectedProjectId={selectedProjectId} + onSelectPackage={handleSelectPackage} + forms={formList} + selectedForm={ + selectedFormCode + ? formList.find((f) => f.formCode === selectedFormCode)?.formName || null + : null + } + onSelectForm={handleSelectForm} + isLoadingForms={isLoadingForms} + mode="ENG" + className="hidden lg:block" + /> + </TabsContent> + </Tabs> + ) + ) : ( + // 접혀있을 때 UI + <> + {!isShipProject && ( + // ship 프로젝트가 아닐 때만 모드 선택 버튼 표시 + <div className="flex justify-center space-x-1 my-2"> + + <Button + variant={selectedMode === "ENG" ? "default" : "ghost"} + size="sm" + className="h-8 px-2" + onClick={() => handleModeChange("ENG")} + > + Engineering + </Button> + <Button + variant={selectedMode === "IM" ? "default" : "ghost"} + size="sm" + className="h-8 px-2" + onClick={() => handleModeChange("IM")} + > + Handover + </Button> + </div> + )} + + <Sidebar + isCollapsed={isCollapsed} + packages={currentContract?.packages || []} + selectedPackageId={selectedPackageId} + selectedProjectId={selectedProjectId} + selectedContractId={selectedContractId} + onSelectPackage={handleSelectPackage} + forms={formList} + selectedForm={ + selectedFormCode + ? formList.find((f) => f.formCode === selectedFormCode)?.formName || null + : null + } + onSelectForm={handleSelectForm} + isLoadingForms={isLoadingForms} + mode={isShipProject ? "ENG" : selectedMode} + className="hidden lg:block" + /> + </> + )} + </ResizablePanel> + + <ResizableHandle withHandle /> + + <ResizablePanel defaultSize={defaultLayout[1]} minSize={40}> + <div className="p-4 h-full overflow-auto flex flex-col"> + <div className="flex items-center justify-between mb-4"> + <h2 className="text-lg font-bold"> + {isShipProject || selectedMode === "ENG" + ? "Engineering Mode" + : `Package: ${currentPackageName}`} + </h2> + </div> + {children} + </div> + </ResizablePanel> + </ResizablePanelGroup> + </TooltipProvider> + ) +}
\ No newline at end of file diff --git a/components/vendor-data/sidebar.tsx b/components/vendor-data/sidebar.tsx index 2e633442..edaf2e25 100644 --- a/components/vendor-data/sidebar.tsx +++ b/components/vendor-data/sidebar.tsx @@ -10,7 +10,7 @@ import { TooltipTrigger, TooltipContent, } from "@/components/ui/tooltip" -import { Package2, FormInput, ChevronRight, ChevronDown } from "lucide-react" +import { Package2, FormInput } from "lucide-react" import { useRouter, usePathname } from "next/navigation" import { Skeleton } from "@/components/ui/skeleton" import { type FormInfo } from "@/lib/forms/services" @@ -49,9 +49,6 @@ export function Sidebar({ const router = useRouter() const rawPathname = usePathname() const pathname = rawPathname ?? "" - - // ENG 모드에서 각 폼의 확장/축소 상태 관리 - const [expandedForms, setExpandedForms] = React.useState<Set<string>>(new Set()) /** * --------------------------- @@ -87,33 +84,28 @@ export function Sidebar({ * --------------------------- */ const handlePackageClick = (itemId: number) => { + // 상위 컴포넌트 상태 업데이트 onSelectPackage(itemId) + + // 해당 태그 페이지로 라우팅 + // 예: /vendor-data/tag/123 + const baseSegments = segments.slice(0, segments.indexOf("vendor-data") + 1).join("/") + router.push(`/${baseSegments}/tag/${itemId}`) } /** * --------------------------- - * 3) 폼 클릭 핸들러 (IM 모드) + * 3) 폼 클릭 핸들러 (IM 모드만 사용) * --------------------------- */ const handleFormClick = (form: FormInfo) => { - if (mode === "IM") { - // IM 모드에서는 반드시 선택된 패키지 ID 필요 - if (selectedPackageId === null) return; - - onSelectForm(form.formName) - - const baseSegments = segments.slice(0, segments.indexOf("vendor-data") + 1).join("/") - router.push(`/${baseSegments}/form/${selectedPackageId}/${form.formCode}/${selectedProjectId}/${selectedContractId}?mode=${mode}`) - } else { - // ENG 모드에서는 폼을 클릭하면 확장/축소만 토글 - const newExpanded = new Set(expandedForms) - if (newExpanded.has(form.formCode)) { - newExpanded.delete(form.formCode) - } else { - newExpanded.add(form.formCode) - } - setExpandedForms(newExpanded) - } + // IM 모드에서만 사용 + if (selectedPackageId === null) return; + + onSelectForm(form.formName) + + const baseSegments = segments.slice(0, segments.indexOf("vendor-data") + 1).join("/") + router.push(`/${baseSegments}/form/${selectedPackageId}/${form.formCode}/${selectedProjectId}/${selectedContractId}?mode=${mode}`) } /** @@ -187,10 +179,13 @@ export function Sidebar({ </> )} - {/* ---------- 폼 목록 (IM 모드) / 폼과 패키지 목록 (ENG 모드) ---------- */} + {/* ---------- 폼 목록 (IM 모드) / 패키지와 폼 목록 (ENG 모드) ---------- */} <div className="py-1"> <h2 className="relative px-7 text-lg font-semibold tracking-tight"> - {isCollapsed ? "F" : "Form Lists"} + {isCollapsed + ? (mode === "IM" ? "F" : "P") + : (mode === "IM" ? "Form Lists" : "Package Lists") + } </h2> <ScrollArea className={cn( "px-1", @@ -203,17 +198,15 @@ export function Sidebar({ <Skeleton className="h-8 w-full" /> </div> )) - ) : !forms || forms.length === 0 ? ( - <p className="text-sm text-muted-foreground px-2"> - (No forms loaded) - </p> - ) : ( - forms.map((form) => { - const isFormActive = form.formCode === currentFormCode - const isExpanded = expandedForms.has(form.formCode) - - // IM 모드 - if (mode === "IM") { + ) : mode === "IM" ? ( + // =========== IM 모드: 폼만 표시 =========== + !forms || forms.length === 0 ? ( + <p className="text-sm text-muted-foreground px-2"> + (No forms loaded) + </p> + ) : ( + forms.map((form) => { + const isFormActive = form.formCode === currentFormCode const isDisabled = currentItemId === null return isCollapsed ? ( @@ -250,79 +243,71 @@ export function Sidebar({ {form.formName} </Button> ) - } - - // ENG 모드 - 폼과 그 아래 패키지들 표시 - return ( - <div key={form.formCode}> + }) + ) + ) : ( + // =========== ENG 모드: 패키지 > 폼 계층 구조 =========== + packages.length === 0 ? ( + <p className="text-sm text-muted-foreground px-2"> + (No packages loaded) + </p> + ) : ( + packages.map((pkg) => ( + <div key={pkg.itemId} className="space-y-1"> {isCollapsed ? ( <Tooltip delayDuration={0}> <TooltipTrigger asChild> - <Button - variant="ghost" - className="w-full justify-start font-normal" - // onClick={() => handleFormClick(form)} - > - <FormInput className="mr-2 h-4 w-4" /> - </Button> + <div className="px-2 py-1"> + <Package2 className="h-4 w-4" /> + </div> </TooltipTrigger> <TooltipContent side="right"> - {form.formName} + {pkg.itemName} </TooltipContent> </Tooltip> ) : ( <> - <Button - variant="ghost" - className="w-full justify-start font-normal" - // onClick={() => handleFormClick(form)} - > - {isExpanded ? ( - <ChevronDown className="mr-2 h-4 w-4" /> - ) : ( - <ChevronRight className="mr-2 h-4 w-4" /> - )} - <FormInput className="mr-2 h-4 w-4" /> - {form.formName} - </Button> + {/* 패키지 이름 (클릭 불가능한 라벨) */} + <div className="flex items-center px-2 py-1 text-sm font-medium"> + <Package2 className="mr-2 h-4 w-4" /> + {pkg.itemName} + </div> - {/* 확장된 경우 패키지 목록 표시 */} - {isExpanded && ( - <div className="ml-4 space-y-1"> - {packages.length === 0 ? ( - <p className="text-xs text-muted-foreground px-4 py-1"> - No packages available - </p> - ) : ( - packages.map((pkg) => { - const isPackageActive = - pkg.itemId === currentItemId && - form.formCode === currentFormCode + {/* 폼 목록 바로 표시 */} + <div className="ml-6 space-y-1"> + {!forms || forms.length === 0 ? ( + <p className="text-xs text-muted-foreground px-2 py-1"> + No forms available + </p> + ) : ( + forms.map((form) => { + const isFormPackageActive = + pkg.itemId === currentItemId && + form.formCode === currentFormCode - return ( - <Button - key={`${form.formCode}-${pkg.itemId}`} - variant="ghost" - size="sm" - className={cn( - "w-full justify-start font-normal text-sm", - isPackageActive && "bg-accent text-accent-foreground" - )} - onClick={() => handlePackageUnderFormClick(form, pkg)} - > - <Package2 className="mr-2 h-3 w-3" /> - {pkg.itemName} - </Button> - ) - }) - )} - </div> - )} + return ( + <Button + key={`${pkg.itemId}-${form.formCode}`} + variant="ghost" + size="sm" + className={cn( + "w-full justify-start font-normal text-sm", + isFormPackageActive && "bg-accent text-accent-foreground" + )} + onClick={() => handlePackageUnderFormClick(form, pkg)} + > + <FormInput className="mr-2 h-3 w-3" /> + {form.formName} + </Button> + ) + }) + )} + </div> </> )} </div> - ) - }) + )) + ) )} </div> </ScrollArea> |
