diff options
Diffstat (limited to 'components/form-data')
18 files changed, 3961 insertions, 579 deletions
diff --git a/components/form-data/add-formTag-dialog.tsx b/components/form-data/add-formTag-dialog.tsx new file mode 100644 index 00000000..a327523b --- /dev/null +++ b/components/form-data/add-formTag-dialog.tsx @@ -0,0 +1,957 @@ +"use client" + +import * as React from "react" +import { 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/services" + +// 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; + open?: boolean; + onOpenChange?: (open: boolean) => void; +} + +export function AddFormTagDialog({ + projectId, + formCode, + formName, + contractItemId, + open: externalOpen, + onOpenChange: externalOnOpenChange +}: AddFormTagDialogProps) { + const router = useRouter() + + // 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] || ""; + combined += fieldValue; + if (fieldValue && idx < subFields.length - 1 && sf.delimiter) { + combined += sf.delimiter; + } + }); + + 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); + if ("error" in res) { + failedTags.push({ tag: row.tagNo, error: res.error }); + } else { + successfulTags.push(row.tagNo); + } + } 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); + toast.error(`${failedTags.length}개의 태그 생성에 실패했습니다.`); + } + + // 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>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>클래스 로딩 중...</span> + <Loader2 className="ml-2 h-4 w-4 animate-spin" /> + </> + ) : ( + <> + <span className="truncate mr-1 flex-grow text-left"> + {field.value || "클래스 선택..."} + </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="클래스 검색..." + /> + <CommandList key={`${commandId}-list`} className="max-h-[300px]"> + <CommandEmpty key={`${commandId}-empty`}>검색 결과가 없습니다.</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>Tag Type</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="클래스 선택시 자동으로 결정됩니다" + 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">필드 로딩 중...</div> + </div> + ) + } + + if (subFields.length === 0 && selectedTagTypeCode) { + return ( + <div className="py-4 text-center text-muted-foreground"> + 이 태그 유형에 대한 필드가 없습니다. + </div> + ) + } + + if (subFields.length === 0) { + return ( + <div className="py-4 text-center text-muted-foreground"> + 태그 데이터를 입력하려면 먼저 상단에서 클래스를 선택하세요. + </div> + ) + } + + return ( + <div className="space-y-4"> + {/* 헤더 */} + <div className="flex justify-between items-center"> + <h3 className="text-sm font-medium">태그 항목 ({fields.length}개)</h3> + {!areAllTagNosValid && ( + <Badge variant="destructive" className="ml-2"> + 유효하지 않은 태그 존재 + </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">Tag No</div> + </TableHead> + <TableHead className="w-[180px]"> + <div className="font-medium">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">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="항목 이름 입력" + 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.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>행 복제</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>행 삭제</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" /> + 새 행 추가 + </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" /> + 태그 추가 + </Button> + </DialogTrigger> + )} + + <DialogContent className="max-h-[90vh] max-w-[95vw]" style={{ width: 1500 }}> + <DialogHeader> + <DialogTitle>폼 태그 추가 - {formName || formCode}</DialogTitle> + <DialogDescription> + 클래스를 선택하여 태그 유형과 하위 필드를 로드한 다음, 여러 행을 추가하여 여러 태그를 생성하세요. + </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} + > + 취소 + </Button> + <Button + type="submit" + disabled={isSubmitting || !areAllTagNosValid || fields.length < 1} + > + {isSubmitting ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 처리 중... + </> + ) : ( + `${fields.length}개 태그 생성` + )} + </Button> + </div> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/components/form-data/export-excel-form.tsx b/components/form-data/export-excel-form.tsx new file mode 100644 index 00000000..c4010df2 --- /dev/null +++ b/components/form-data/export-excel-form.tsx @@ -0,0 +1,197 @@ +// lib/excelUtils.ts +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[]; + // 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 the options interface for the export function +export interface ExportExcelOptions { + tableData: GenericData[]; + columnsJSON: DataTableColumnJSON[]; + formCode: string; + onPendingChange?: (isPending: boolean) => void; +} + +// Define the return type +export interface ExportExcelResult { + success: boolean; + error?: any; +} + +/** + * 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, + onPendingChange +}: ExportExcelOptions): Promise<ExportExcelResult> { + try { + if (onPendingChange) onPendingChange(true); + + // 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) => col.label); + 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" }, + }; + }); + + // 3. 데이터 행 추가 + tableData.forEach((row) => { + const rowValues = columnsJSON.map((col) => { + const value = row[col.key]; + return value !== undefined && value !== null ? value : ""; + }); + worksheet.addRow(rowValues); + }); + + // 4. 데이터 유효성 검사 적용 + const maxRows = 5000; // 데이터 유효성 검사를 적용할 최대 행 수 + + columnsJSON.forEach((col, idx) => { + if (col.type === "LIST" && validationRanges.has(col.key)) { + const colLetter = worksheet.getColumn(idx + 1).letter; + const validationRange = validationRanges.get(col.key)!; + + // 유효성 검사 정의 + const validation = { + type: "list" as const, + allowBlank: true, + formulae: [validationRange], + showErrorMessage: true, + errorStyle: "warning" as const, + errorTitle: "유효하지 않은 값", + error: "목록에서 값을 선택해주세요.", + }; + + // 모든 데이터 행에 유효성 검사 적용 (최대 maxRows까지) + for ( + let rowIdx = 2; + rowIdx <= Math.min(tableData.length + 1, maxRows); + rowIdx++ + ) { + worksheet.getCell(`${colLetter}${rowIdx}`).dataValidation = + validation; + } + + // 빈 행에도 적용 (최대 maxRows까지) + if (tableData.length + 1 < maxRows) { + for ( + let rowIdx = tableData.length + 2; + rowIdx <= maxRows; + rowIdx++ + ) { + worksheet.getCell(`${colLetter}${rowIdx}`).dataValidation = + validation; + } + } + } + }); + + // 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. 파일 다운로드 + const buffer = await workbook.xlsx.writeBuffer(); + saveAs( + new Blob([buffer]), + `${formCode}_data_${new Date().toISOString().slice(0, 10)}.xlsx` + ); + + toast.success("Excel 내보내기 완료!"); + return { success: true }; + } 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/form-data-report-batch-dialog.tsx b/components/form-data/form-data-report-batch-dialog.tsx index 6a76784c..ef921a91 100644 --- a/components/form-data/form-data-report-batch-dialog.tsx +++ b/components/form-data/form-data-report-batch-dialog.tsx @@ -139,11 +139,11 @@ export const FormDataReportBatchDialog: FC<FormDataReportBatchDialogProps> = ({ const reportValueMapping: { [key: string]: any } = {}; columnsJSON.forEach((c2) => { - const { key, label } = c2; + const { key } = c2; - const objKey = label.split(" ").join("_"); + // const objKey = label.split(" ").join("_"); - reportValueMapping[objKey] = reportValue?.[key] ?? ""; + reportValueMapping[key] = reportValue?.[key] ?? ""; }); return reportValueMapping; @@ -351,4 +351,4 @@ const stringifyAllValues = (obj: any): any => { } else { return obj !== null && obj !== undefined ? String(obj) : ""; } -}; +};
\ No newline at end of file diff --git a/components/form-data/form-data-report-dialog.tsx b/components/form-data/form-data-report-dialog.tsx index 52262bf5..3cfbbeb3 100644 --- a/components/form-data/form-data-report-dialog.tsx +++ b/components/form-data/form-data-report-dialog.tsx @@ -32,6 +32,7 @@ import { import { Button } from "@/components/ui/button"; import { getReportTempList } from "@/lib/forms/services"; import { DataTableColumnJSON } from "./form-data-table-columns"; +import { PublishDialog } from "./publish-dialog"; type ReportData = { [key: string]: any; @@ -64,6 +65,10 @@ export const FormDataReportDialog: FC<FormDataReportDialogProps> = ({ 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); @@ -98,61 +103,103 @@ export const FormDataReportDialog: FC<FormDataReportDialogProps> = ({ toast.success("Report 다운로드 완료!"); } }; + + // 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("Failed to prepare document for publishing"); + } + } + }; return ( - <Dialog open={reportData.length > 0} onOpenChange={onClose}> - <DialogContent className="w-[70vw]" style={{ maxWidth: "none" }}> - <DialogHeader> - <DialogTitle>Create Vendor Document</DialogTitle> - <DialogDescription> - 사용하시고자 하는 Vendor Document Template를 선택하여 주시기 바랍니다. - </DialogDescription> - </DialogHeader> - <div className="h-[60px]"> - <Label>Vendor Document Template Select</Label> - <Select - value={selectTemp} - onValueChange={setSelectTemp} - disabled={instance === null} - > - <SelectTrigger className="w-[100%]"> - <SelectValue placeholder="사용하시고자하는 Vendor Document Template을 선택하여 주시기 바랍니다." /> - </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} - /> - </div> - - <DialogFooter> - <Button onClick={downloadFileData} disabled={selectTemp.length === 0}> - Create Vendor Document - </Button> - </DialogFooter> - </DialogContent> - </Dialog> + <> + <Dialog open={reportData.length > 0} onOpenChange={onClose}> + <DialogContent className="w-[70vw]" style={{ maxWidth: "none" }}> + <DialogHeader> + <DialogTitle>Create Vendor Document</DialogTitle> + <DialogDescription> + 사용하시고자 하는 Vendor Document Template를 선택하여 주시기 바랍니다. + </DialogDescription> + </DialogHeader> + <div className="h-[60px]"> + <Label>Vendor Document Template Select</Label> + <Select + value={selectTemp} + onValueChange={setSelectTemp} + disabled={instance === null} + > + <SelectTrigger className="w-[100%]"> + <SelectValue placeholder="사용하시고자하는 Vendor Document Template을 선택하여 주시기 바랍니다." /> + </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} + /> + </div> + + <DialogFooter> + {/* Add the new Publish button */} + <Button + onClick={prepareFileForPublishing} + disabled={selectTemp.length === 0} + variant="outline" + className="mr-2" + > + Publish + </Button> + <Button onClick={downloadFileData} disabled={selectTemp.length === 0}> + Create Vendor Document + </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; @@ -310,9 +357,9 @@ const importReportData: ImportReportData = async ( columnsJSON.forEach((c) => { const { key, label } = c; - const objKey = label.split(" ").join("_"); + // const objKey = label.split(" ").join("_"); - reportValueMapping[objKey] = reportValue?.[key] ?? ""; + reportValueMapping[key] = reportValue?.[key] ?? ""; }); const doc = await createDocument(reportFileBlob, { @@ -357,4 +404,4 @@ const updateReportTempList: UpdateReportTempList = async ( return { fileName, filePath }; }) ); -}; +};
\ No newline at end of file diff --git a/components/form-data/form-data-report-temp-upload-dialog.tsx b/components/form-data/form-data-report-temp-upload-dialog.tsx index 74cfe7c3..e4d78248 100644 --- a/components/form-data/form-data-report-temp-upload-dialog.tsx +++ b/components/form-data/form-data-report-temp-upload-dialog.tsx @@ -8,6 +8,7 @@ import { 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 { @@ -48,53 +49,33 @@ export const FormDataReportTempUploadDialog: FC< return ( <Dialog open={open} onOpenChange={setOpen}> <DialogContent className="w-[600px]" style={{ maxWidth: "none" }}> - <DialogHeader> + <DialogHeader className="gap-2"> <DialogTitle>Vendor Document Template</DialogTitle> - <DialogDescription> + <DialogDescription className="flex justify-around gap-[16px] "> {/* 사용하시고자 하는 Vendor Document Template(.docx)를 업로드 하여주시기 바랍니다. */} + <TempDownloadBtn /> + <VarListDownloadBtn columnsJSON={columnsJSON} formCode={formCode} /> </DialogDescription> </DialogHeader> <Tabs value={tabValue}> <div className="flex justify-between items-center"> - <TabsList> - <TabsTrigger value="upload" onClick={() => setTabValue("upload")}> + <TabsList className="w-full"> + <TabsTrigger + value="upload" + onClick={() => setTabValue("upload")} + className="flex-1" + > Upload Template File </TabsTrigger> <TabsTrigger value="uploaded" onClick={() => setTabValue("uploaded")} + className="flex-1" > Uploaded Template File List </TabsTrigger> </TabsList> - <div className="flex flex-row gap-2"> - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <div> - <TempDownloadBtn /> - </div> - </TooltipTrigger> - <TooltipContent> - <Label>Template Sample File Download</Label> - </TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <div> - <VarListDownloadBtn - columnsJSON={columnsJSON} - formCode={formCode} - /> - </div> - </TooltipTrigger> - <TooltipContent> - <Label>Variable List File Download</Label> - </TooltipContent> - </Tooltip> - </TooltipProvider> - </div> </div> <TabsContent value="upload"> <FormDataReportTempUploadTab @@ -113,4 +94,4 @@ export const FormDataReportTempUploadDialog: FC< </DialogContent> </Dialog> ); -}; +};
\ No newline at end of file diff --git a/components/form-data/form-data-report-temp-upload-tab.tsx b/components/form-data/form-data-report-temp-upload-tab.tsx index 5e6179a8..32161e49 100644 --- a/components/form-data/form-data-report-temp-upload-tab.tsx +++ b/components/form-data/form-data-report-temp-upload-tab.tsx @@ -225,4 +225,4 @@ const UploadProgressBox: FC<{ uploadProgress: number }> = ({ </div> </div> ); -}; +};
\ No newline at end of file diff --git a/components/form-data/form-data-report-temp-uploaded-list-tab.tsx b/components/form-data/form-data-report-temp-uploaded-list-tab.tsx index 7379a312..a5c3c7a5 100644 --- a/components/form-data/form-data-report-temp-uploaded-list-tab.tsx +++ b/components/form-data/form-data-report-temp-uploaded-list-tab.tsx @@ -208,4 +208,4 @@ const UploadedTempFiles: FC<UploadedTempFiles> = ({ </FileList> </ScrollArea> ); -}; +};
\ No newline at end of file diff --git a/components/form-data/form-data-table copy.tsx b/components/form-data/form-data-table copy.tsx new file mode 100644 index 00000000..aa16513a --- /dev/null +++ b/components/form-data/form-data-table copy.tsx @@ -0,0 +1,539 @@ +"use client"; + +import * as React from "react"; +import { useParams, useRouter } from "next/navigation"; +import { useTranslation } from "@/i18n/client"; + +import { ClientDataTable } from "../client-data-table/data-table"; +import { + getColumns, + DataTableRowAction, + DataTableColumnJSON, + ColumnType, +} from "./form-data-table-columns"; +import type { DataTableAdvancedFilterField } from "@/types/table"; +import { Button } from "../ui/button"; +import { + Download, + Loader, + Save, + Upload, + Plus, + Tag, + TagsIcon, + FileText, + FileSpreadsheet, + FileOutput, + Clipboard, + Send +} from "lucide-react"; +import { toast } from "sonner"; +import { + getReportTempList, + sendFormDataToSEDP, + syncMissingTags, + updateFormDataInDB, +} from "@/lib/forms/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, + DropdownMenuSeparator, +} 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"; + +interface GenericData { + [key: string]: any; +} + +export interface DynamicTableProps { + dataJSON: GenericData[]; + columnsJSON: DataTableColumnJSON[]; + contractItemId: number; + formCode: string; + formId: number; + projectId: number; + formName?: string; + objectCode?: string; +} + +export default function DynamicTable({ + dataJSON, + columnsJSON, + contractItemId, + formCode, + formId, + projectId, + formName = `VD)${formCode}`, // Default form name based on formCode + objectCode = "LO_PT_CLAS", // Default object code +}: DynamicTableProps) { + const params = useParams(); + const router = useRouter(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "translation"); + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<GenericData> | null>(null); + const [tableData, setTableData] = React.useState<GenericData[]>(dataJSON); + + + console.log(tableData) + console.log(columnsJSON) + + // Update tableData when dataJSON changes + React.useEffect(() => { + setTableData(dataJSON); + }, [dataJSON]); + + // 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, setIsSaving] = React.useState(false); + const [isSendingSEDP, setIsSendingSEDP] = React.useState(false); + + // Any operation in progress + const isAnyOperationPending = isSyncingTags || isImporting || isExporting || isSaving || isSendingSEDP; + + // 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 + }); + + 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); + + React.useEffect(() => { + const getTempCount = async () => { + const tempList = await getReportTempList(contractItemId, formId); + setTempCount(tempList.length); + }; + + getTempCount(); + }, [contractItemId, formId, tempUpDialog]); + + const columns = React.useMemo( + () => getColumns<GenericData>({ columnsJSON, setRowAction, setReportData, tempCount }), + [columnsJSON, setRowAction, setReportData, tempCount] + ); + + 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]); + + // 태그 불러오기 + 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); + } + } + + // Excel Import - Modified to directly save to DB + async function handleImportExcel(e: React.ChangeEvent<HTMLInputElement>) { + const file = e.target.files?.[0]; + if (!file) return; + + try { + setIsImporting(true); + + // Call the updated importExcelData function with direct save capability + const result = await importExcelData({ + file, + tableData, + columnsJSON, + formCode, // Pass formCode for direct save + contractItemId, // Pass contractItemId for direct save + onPendingChange: setIsImporting, + onDataUpdate: (newData) => { + // This is called only after successful DB save + setTableData(Array.isArray(newData) ? newData : newData(tableData)); + } + }); + + // If import and save was successful, refresh the page + if (result.success) { + router.refresh(); + } + } catch (error) { + console.error("Import failed:", error); + toast.error("Failed to import Excel data"); + } finally { + // Always clear the file input value + e.target.value = ""; + setIsImporting(false); + } + } + + // 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); + } + + // Actual SEDP send after confirmation +// In your DynamicTable component, update the handler for SEDP sending + +async function handleSEDPSendConfirmed() { + try { + setIsSendingSEDP(true); + + // Validate data + const invalidData = tableData.filter((item) => !item.TAG_NO?.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 + tableData, // 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: any) { + console.error("SEDP error:", err); + + // Set error status + setSedpStatusData({ + status: 'error', + message: 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, + onPendingChange: setIsExporting + }); + } finally { + setIsExporting(false); + } + } + + // Handle batch document check + const handleBatchDocument = () => { + if (tempCount > 0) { + setBatchDownDialog(true); + } else { + toast.error("업로드된 Template File이 없습니다."); + } + }; + + return ( + <> + <ClientDataTable + data={tableData} + columns={columns} + advancedFilterFields={advancedFilterFields} + > + {/* 버튼 그룹 */} + <div className="flex items-center gap-2"> + {/* 태그 관리 드롭다운 */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="outline" size="sm" disabled={isAnyOperationPending}> + {isSyncingTags && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + <TagsIcon className="size-4" /> + Tag Operations + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem onClick={handleSyncTags} disabled={isAnyOperationPending}> + <Tag className="mr-2 h-4 w-4" /> + Sync Tags + </DropdownMenuItem> + <DropdownMenuItem onClick={() => setAddTagDialogOpen(true)} disabled={isAnyOperationPending}> + <Plus className="mr-2 h-4 w-4" /> + Add Tags + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + + {/* 리포트 관리 드롭다운 */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="outline" size="sm" disabled={isAnyOperationPending}> + <Clipboard className="size-4" /> + Report Operations + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem onClick={() => setTempUpDialog(true)} disabled={isAnyOperationPending}> + <Upload className="mr-2 h-4 w-4" /> + Upload Template + </DropdownMenuItem> + <DropdownMenuItem onClick={handleBatchDocument} disabled={isAnyOperationPending}> + <FileOutput className="mr-2 h-4 w-4" /> + Batch Document + </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" /> + )} + 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" /> + )} + Export + </Button> + + + {/* SEDP 전송 버튼 */} + <Button + variant="samsung" + size="sm" + onClick={handleSEDPSendClick} + disabled={isAnyOperationPending} + > + {isSendingSEDP ? ( + <> + <Loader className="mr-2 size-4 animate-spin" /> + SEDP 전송 중... + </> + ) : ( + <> + <Send className="size-4" /> + Send to SHI + </> + )} + </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} + 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 + ) + ); + } + }} + /> + + {/* Dialog for adding tags */} + <AddFormTagDialog + projectId={projectId} + formCode={formCode} + formName={`Form ${formCode}`} + contractItemId={contractItemId} + open={addTagDialogOpen} + onOpenChange={setAddTagDialogOpen} + /> + + {/* SEDP Confirmation Dialog */} + <SEDPConfirmationDialog + isOpen={sedpConfirmOpen} + onClose={() => setSedpConfirmOpen(false)} + onConfirm={handleSEDPSendConfirmed} + formName={formName} + tagCount={tableData.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} + /> + + {/* 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={tableData} + packageId={contractItemId} + formCode={formCode} + formId={formId} + /> + )} + </> + ); +}
\ No newline at end of file diff --git a/components/form-data/form-data-table-columns.tsx b/components/form-data/form-data-table-columns.tsx index a136b5d3..4db3a724 100644 --- a/components/form-data/form-data-table-columns.tsx +++ b/components/form-data/form-data-table-columns.tsx @@ -16,6 +16,7 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { toast } from 'sonner'; /** row 액션 관련 타입 */ export interface DataTableRowAction<TData> { row: Row<TData>; @@ -36,6 +37,7 @@ export interface DataTableColumnJSON { type: ColumnType; options?: string[]; uom?: string; + uomId?: string; } /** * getColumns 함수에 필요한 props @@ -47,6 +49,7 @@ interface GetColumnsProps<TData> { React.SetStateAction<DataTableRowAction<TData> | null> >; setReportData: React.Dispatch<React.SetStateAction<{ [key: string]: any }[]>>; + tempCount: number; } /** @@ -58,6 +61,7 @@ export function getColumns<TData extends object>({ columnsJSON, setRowAction, setReportData, + tempCount, }: GetColumnsProps<TData>): ColumnDef<TData>[] { // (1) 기본 컬럼들 const baseColumns: ColumnDef<TData>[] = columnsJSON.map((col) => ({ @@ -73,7 +77,7 @@ export function getColumns<TData extends object>({ excelHeader: col.label, minWidth: 80, paddingFactor: 1.2, - maxWidth: col.key === "tagNumber" ? 120 : 150, + maxWidth: col.key === "TAG_NO" ? 120 : 150, }, // (2) 실제 셀(cell) 렌더링: type에 따라 분기 가능 cell: ({ row }) => { @@ -129,22 +133,23 @@ export function getColumns<TData extends object>({ </DropdownMenuItem> <DropdownMenuItem onSelect={() => { + if(tempCount > 0){ const { original } = row; setReportData([original]); + } else { + toast.error("업로드된 Template File이 없습니다."); + } }} > - Create Vendor Document + Create Document </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> ), - size: 40, - meta: { - maxWidth: 40, - }, + minSize: 50, enablePinning: true, }; // (4) 최종 반환 return [...baseColumns, actionColumn]; -} +}
\ 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 4caee44f..05278375 100644 --- a/components/form-data/form-data-table.tsx +++ b/components/form-data/form-data-table.tsx @@ -1,7 +1,7 @@ "use client"; import * as React from "react"; -import { useParams } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import { useTranslation } from "@/i18n/client"; import { ClientDataTable } from "../client-data-table/data-table"; @@ -13,20 +13,88 @@ import { } from "./form-data-table-columns"; import type { DataTableAdvancedFilterField } from "@/types/table"; import { Button } from "../ui/button"; -import { Download, Loader, Save, Upload } from "lucide-react"; +import { + Download, + Loader, + Save, + Upload, + Plus, + Tag, + TagsIcon, + FileText, + FileSpreadsheet, + FileOutput, + Clipboard, + Send, + GitCompareIcon, + RefreshCcw +} from "lucide-react"; import { toast } from "sonner"; -import { syncMissingTags, updateFormDataInDB } from "@/lib/forms/services"; +import { + getProjectCodeById, + getReportTempList, + sendFormDataToSEDP, + syncMissingTags, + updateFormDataInDB, +} from "@/lib/forms/services"; import { UpdateTagSheet } from "./update-form-sheet"; -import ExcelJS from "exceljs"; -import { saveAs } from "file-saver"; 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 { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSeparator, +} 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 { getSEDPToken } from "@/lib/sedp/sedp-token"; + + +async function fetchTagDataFromSEDP(projectCode: string, formCode: string): Promise<any> { + try { + // Get the token + const apiKey = await getSEDPToken(); + + // Define the API base URL + const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api'; + + // Make the API call + const response = await fetch( + `${SEDP_API_BASE_URL}/Data/GetPubData`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'accept': '*/*', + 'ApiKey': apiKey, + 'ProjectNo': projectCode + }, + body: JSON.stringify({ + ProjectNo: projectCode, + REG_TYPE_ID: formCode, + ContainDeleted: false + }) + } + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`SEDP API request failed: ${response.status} ${response.statusText} - ${errorText}`); + } + + const data = await response.json(); + return data; + } catch (error: any) { + console.error('Error calling SEDP API:', error); + throw new Error(`Failed to fetch data from SEDP API: ${error.message || 'Unknown error'}`); + } +} interface GenericData { [key: string]: any; @@ -38,6 +106,10 @@ export interface DynamicTableProps { contractItemId: number; formCode: string; formId: number; + projectId: number; + formName?: string; + objectCode?: string; + mode: "IM" | "ENG"; // 모드 속성 } export default function DynamicTable({ @@ -46,28 +118,98 @@ export default function DynamicTable({ contractItemId, formCode, formId, + projectId, + mode = "IM", // 기본값 설정 + formName = `${formCode}`, // Default form name based on formCode }: DynamicTableProps) { const params = useParams(); + const router = useRouter(); const lng = (params?.lng as string) || "ko"; const { t } = useTranslation(lng, "translation"); const [rowAction, setRowAction] = React.useState<DataTableRowAction<GenericData> | null>(null); - const [tableData, setTableData] = React.useState<GenericData[]>( - () => dataJSON - ); - const [isPending, setIsPending] = React.useState(false); + const [tableData, setTableData] = React.useState<GenericData[]>(dataJSON); + + // Update tableData when dataJSON changes + React.useEffect(() => { + setTableData(dataJSON); + }, [dataJSON]); + + // 폴링 상태 관리를 위한 ref + const pollingRef = React.useRef<NodeJS.Timeout | null>(null); + const [syncId, setSyncId] = React.useState<string | null>(null); + + // Separate loading states for different operations + const [isSyncingTags, setIsSyncingTags] = React.useState(false); + const [isImporting, setIsImporting] = React.useState(false); + const [isExporting, setIsExporting] = React.useState(false); const [isSaving, setIsSaving] = React.useState(false); + const [isSendingSEDP, setIsSendingSEDP] = React.useState(false); + const [isLoadingTags, setIsLoadingTags] = React.useState(false); + + // Any operation in progress + const isAnyOperationPending = isSyncingTags || isImporting || isExporting || isSaving || isSendingSEDP || isLoadingTags; + + // SEDP dialogs state + const [sedpConfirmOpen, setSedpConfirmOpen] = React.useState(false); + const [sedpStatusOpen, setSedpStatusOpen] = React.useState(false); + 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 [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); + + // 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]); + + // Get project code when component mounts + React.useEffect(() => { + const getProjectCode = async () => { + try { + const code = await getProjectCodeById(projectId); + setProjectCode(code); + } catch (error) { + console.error("Error fetching project code:", error); + toast.error("Failed to fetch project code"); + } + }; - // Reference to the table instance - const tableRef = React.useRef(null); + if (projectId) { + getProjectCode(); + } + }, [projectId]); const columns = React.useMemo( - () => getColumns<GenericData>({ columnsJSON, setRowAction, setReportData }), - [columnsJSON, setRowAction, setReportData] + () => getColumns<GenericData>({ columnsJSON, setRowAction, setReportData, tempCount }), + [columnsJSON, setRowAction, setReportData, tempCount] ); function mapColumnTypeToAdvancedFilterType( @@ -79,11 +221,8 @@ export default function DynamicTable({ case "NUMBER": return "number"; case "LIST": - // "select"로 하셔도 되고 "multi-select"로 하셔도 됩니다. return "select"; - // 그 외 다른 타입들도 적절히 추가 매핑 default: - // 예: 못 매핑한 경우 기본적으로 "text" 적용 return "text"; } } @@ -102,10 +241,10 @@ export default function DynamicTable({ })); }, [columnsJSON]); - // 1) 태그 불러오기 (기존) + // IM 모드: 태그 동기화 함수 async function handleSyncTags() { try { - setIsPending(true); + setIsSyncingTags(true); const result = await syncMissingTags(contractItemId, formCode); // Prepare the toast messages based on what changed @@ -120,7 +259,7 @@ export default function DynamicTable({ if (changes.length > 0) { // If any changes were made, show success message and reload toast.success(`동기화 완료: ${changes.join(", ")}`); - location.reload(); + router.refresh(); // Use router.refresh instead of location.reload } else { // If no changes were made, show an info message toast.info("변경사항이 없습니다. 모든 태그가 최신 상태입니다."); @@ -129,487 +268,393 @@ export default function DynamicTable({ console.error(err); toast.error("태그 동기화 중 에러가 발생했습니다."); } finally { - setIsPending(false); + setIsSyncingTags(false); } } - // 2) Excel Import (새로운 기능) - async function handleImportExcel(e: React.ChangeEvent<HTMLInputElement>) { - const file = e.target.files?.[0]; - if (!file) return; - + + // ENG 모드: 태그 가져오기 함수 + const handleGetTags = async () => { try { - setIsPending(true); - - // 기존 테이블 데이터의 tagNumber 목록 (이미 있는 태그) - const existingTagNumbers = new Set(tableData.map((d) => d.tagNumber)); - - const workbook = new ExcelJS.Workbook(); - const arrayBuffer = await file.arrayBuffer(); - await workbook.xlsx.load(arrayBuffer); - - const worksheet = workbook.worksheets[0]; - - // (A) 헤더 파싱 (Error 열 추가 전에 먼저 체크) - const headerRow = worksheet.getRow(1); - const headerRowValues = headerRow.values as ExcelJS.CellValue[]; - - // 디버깅용 로그 - console.log("원본 헤더 값:", headerRowValues); - - // Excel의 헤더와 columnsJSON의 label 매핑 생성 - // Excel의 행은 1부터 시작하므로 headerRowValues[0]은 undefined - 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); - } - } - - // (B) 헤더 검사 - let headerErrorMessage = ""; - - // (1) "columnsJSON에 있는데 엑셀에 없는" 라벨 - columnsJSON.forEach((col) => { - const label = col.label; - if (!headerToIndexMap.has(label)) { - headerErrorMessage += `Column "${label}" is missing. `; - } - }); - - // (2) "엑셀에는 있는데 columnsJSON에 없는" 라벨 검사 - headerToIndexMap.forEach((index, headerLabel) => { - const found = columnsJSON.some((col) => col.label === headerLabel); - if (!found) { - headerErrorMessage += `Unexpected column "${headerLabel}" found in Excel. `; - } + setIsLoadingTags(true); + + // API 엔드포인트 호출 - 작업 시작만 요청 + const response = await fetch('/api/cron/form-tags/start', { + method: 'POST', + body: JSON.stringify({ projectCode ,formCode ,contractItemId }) }); - - // (C) 이제 Error 열 추가 - const lastColIndex = worksheet.columnCount + 1; - worksheet.getRow(1).getCell(lastColIndex).value = "Error"; - - // 헤더 에러가 있으면 기록 후 다운로드하고 중단 - if (headerErrorMessage) { - headerRow.getCell(lastColIndex).value = headerErrorMessage.trim(); - - const outBuffer = await workbook.xlsx.writeBuffer(); - saveAs(new Blob([outBuffer]), `import-check-result_${Date.now()}.xlsx`); - - toast.error(`Header mismatch found. Please check downloaded file.`); - return; + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to start tag import'); } - - // -- 여기까지 왔다면, 헤더는 문제 없음 -- - - // 컬럼 키-인덱스 매핑 생성 (실제 데이터 행 파싱용) - // columnsJSON의 key와 Excel 열 인덱스 간의 매핑 - const keyToIndexMap = new Map<string, number>(); - columnsJSON.forEach((col) => { - const index = headerToIndexMap.get(col.label); - if (index !== undefined) { - keyToIndexMap.set(col.key, index); + + const data = await response.json(); + + // 작업 ID 저장 + if (data.syncId) { + setSyncId(data.syncId); + toast.info('Tag import started. This may take a while...'); + + // 상태 확인을 위한 폴링 시작 + startPolling(data.syncId); + } else { + 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 importedData: GenericData[] = []; - const lastRowNumber = worksheet.lastRow?.number || 1; - let errorCount = 0; - - // 실제 데이터 행 파싱 - for (let rowNum = 2; rowNum <= lastRowNumber; rowNum++) { - const row = worksheet.getRow(rowNum); - const rowValues = row.values as ExcelJS.CellValue[]; - if (!rowValues || rowValues.length <= 1) continue; // 빈 행 스킵 - - let errorMessage = ""; - const rowObj: Record<string, any> = {}; - - // 각 열에 대해 처리 - columnsJSON.forEach((col) => { - const colIndex = keyToIndexMap.get(col.key); - if (colIndex === undefined) return; - - const cellValue = rowValues[colIndex] ?? ""; - let stringVal = String(cellValue).trim(); - - // 타입별 검사 - switch (col.type) { - case "STRING": - if (!stringVal && col.key === "tagNumber") { - errorMessage += `[${col.label}] is empty. `; - } - rowObj[col.key] = stringVal; - break; - - case "NUMBER": - if (stringVal) { - const num = parseFloat(stringVal); - if (isNaN(num)) { - errorMessage += `[${col.label}] '${stringVal}' is not a valid number. `; - } else { - rowObj[col.key] = num; - } - } else { - rowObj[col.key] = null; - } - break; - - case "LIST": - if ( - stringVal && - col.options && - !col.options.includes(stringVal) - ) { - errorMessage += `[${ - col.label - }] '${stringVal}' not in ${col.options.join(", ")}. `; - } - rowObj[col.key] = stringVal; - break; - - default: - rowObj[col.key] = stringVal; - break; + + const data = await response.json(); + + if (data.status === 'completed') { + // 폴링 중지 + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + + router.refresh(); + + // 상태 초기화 + setIsLoadingTags(false); + setSyncId(null); + + // 성공 메시지 표시 + toast.success( + `Tags imported successfully! ${data.result?.processedCount || 0} items processed.` + ); + + } else if (data.status === 'failed') { + // 에러 처리 + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + + setIsLoadingTags(false); + setSyncId(null); + toast.error(data.error || 'Import failed'); + } else if (data.status === 'processing') { + // 진행 상태 업데이트 (선택적) + if (data.progress) { + toast.info(`Import in progress: ${data.progress}%`, { + id: `import-progress-${id}`, + }); } - }); - - // tagNumber 검사 - const tagNum = rowObj["tagNumber"]; - if (!tagNum) { - errorMessage += `No tagNumber found. `; - } else if (!existingTagNumbers.has(tagNum)) { - errorMessage += `TagNumber '${tagNum}' is not in current data. `; - } - - if (errorMessage) { - row.getCell(lastColIndex).value = errorMessage.trim(); - errorCount++; - } else { - importedData.push(rowObj); } + } catch (error) { + console.error('Error checking importing status:', error); } + }, 5000); // 5초마다 체크 + }; + + // Excel Import - Modified to directly save to DB + async function handleImportExcel(e: React.ChangeEvent<HTMLInputElement>) { + const file = e.target.files?.[0]; + if (!file) return; - // 에러가 있으면 재다운로드 후 import 중단 - if (errorCount > 0) { - const outBuffer = await workbook.xlsx.writeBuffer(); - saveAs(new Blob([outBuffer]), `import-check-result_${Date.now()}.xlsx`); - toast.error( - `There are ${errorCount} error row(s). Please check downloaded file.` - ); - return; - } - - // 에러 없으니 tableData 병합 - setTableData((prev) => { - const newDataMap = new Map<string, GenericData>(); - - // 기존 데이터를 맵에 추가 - prev.forEach((item) => { - if (item.tagNumber) { - newDataMap.set(item.tagNumber, { ...item }); - } - }); - - // 임포트 데이터로 기존 데이터 업데이트 - importedData.forEach((item) => { - const tag = item.tagNumber; - if (!tag) return; - const oldItem = newDataMap.get(tag) || {}; - newDataMap.set(tag, { ...oldItem, ...item }); - }); - - return Array.from(newDataMap.values()); + try { + setIsImporting(true); + + // Call the updated importExcelData function with direct save capability + const result = await importExcelData({ + file, + tableData, + columnsJSON, + formCode, // Pass formCode for direct save + contractItemId, // Pass contractItemId for direct save + onPendingChange: setIsImporting, + onDataUpdate: (newData) => { + // This is called only after successful DB save + setTableData(Array.isArray(newData) ? newData : newData(tableData)); + } }); - - toast.success(`Imported ${importedData.length} rows successfully.`); - } catch (err) { - console.error("Excel import error:", err); - toast.error("Excel import failed."); + + // If import and save was successful, refresh the page + if (result.success) { + router.refresh(); + } + } catch (error) { + console.error("Import failed:", error); + toast.error("Failed to import Excel data"); } finally { - setIsPending(false); + // Always clear the file input value e.target.value = ""; + setIsImporting(false); + } + } + + // 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); } - // 3) Save -> 서버에 전체 tableData를 저장 - async function handleSave() { + // Actual SEDP send after confirmation + async function handleSEDPSendConfirmed() { try { - setIsSaving(true); - - // 유효성 검사 - const invalidData = tableData.filter((item) => !item.tagNumber?.trim()); + setIsSendingSEDP(true); + + // Validate data + const invalidData = tableData.filter((item) => !item.TAG_NO?.trim()); if (invalidData.length > 0) { - toast.error( - `태그 번호가 없는 항목이 ${invalidData.length}개 있습니다.` - ); + toast.error(`태그 번호가 없는 항목이 ${invalidData.length}개 있습니다.`); + setSedpConfirmOpen(false); return; } - // 서버 액션 호출 - const result = await updateFormDataInDB( - formCode, - contractItemId, - tableData + // Then send to SEDP - pass formCode instead of formName + const sedpResult = await sendFormDataToSEDP( + formCode, // Send formCode instead of formName + projectId, // Project ID + tableData, // Table data + columnsJSON // Column definitions ); - if (result.success) { - toast.success(result.message); + // 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 { - toast.error(result.message); + setSedpStatusData({ + status: 'error', + message: sedpResult.message || "Failed to send data to SEDP", + successCount: 0, + errorCount: tableData.length, + totalCount: tableData.length + }); } - } catch (err) { - console.error("Save error:", err); - toast.error("데이터 저장 중 오류가 발생했습니다."); + + // Open status dialog to show result + setSedpStatusOpen(true); + + // Refresh the route to get fresh data + router.refresh(); + + } catch (err: any) { + console.error("SEDP error:", err); + + // Set error status + setSedpStatusData({ + status: 'error', + message: err.message || "An unexpected error occurred", + successCount: 0, + errorCount: tableData.length, + totalCount: tableData.length + }); + + // Close confirmation and open status + setSedpConfirmOpen(false); + setSedpStatusOpen(true); + } finally { - setIsSaving(false); + setIsSendingSEDP(false); } } - - // 4) NEW: Excel Export with data validation for select columns using a hidden validation sheet + + // Template Export async function handleExportExcel() { try { - setIsPending(true); - - // 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) => col.label); - 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" }, - }; - }); - - // 3. 데이터 행 추가 - tableData.forEach((row) => { - const rowValues = columnsJSON.map((col) => { - const value = row[col.key]; - return value !== undefined && value !== null ? value : ""; - }); - worksheet.addRow(rowValues); - }); - - // 4. 데이터 유효성 검사 적용 - const maxRows = 5000; // 데이터 유효성 검사를 적용할 최대 행 수 - - columnsJSON.forEach((col, idx) => { - if (col.type === "LIST" && validationRanges.has(col.key)) { - const colLetter = worksheet.getColumn(idx + 1).letter; - const validationRange = validationRanges.get(col.key)!; - - // 유효성 검사 정의 - const validation = { - type: "list" as const, - allowBlank: true, - formulae: [validationRange], - showErrorMessage: true, - errorStyle: "warning" as const, - errorTitle: "유효하지 않은 값", - error: "목록에서 값을 선택해주세요.", - }; - - // 모든 데이터 행에 유효성 검사 적용 (최대 maxRows까지) - for ( - let rowIdx = 2; - rowIdx <= Math.min(tableData.length + 1, maxRows); - rowIdx++ - ) { - worksheet.getCell(`${colLetter}${rowIdx}`).dataValidation = - validation; - } - - // 빈 행에도 적용 (최대 maxRows까지) - if (tableData.length + 1 < maxRows) { - for ( - let rowIdx = tableData.length + 2; - rowIdx <= maxRows; - rowIdx++ - ) { - worksheet.getCell(`${colLetter}${rowIdx}`).dataValidation = - validation; - } - } - } - }); - - // 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); + setIsExporting(true); + await exportExcelData({ + tableData, + columnsJSON, + formCode, + onPendingChange: setIsExporting }); - - // 6. 파일 다운로드 - const buffer = await workbook.xlsx.writeBuffer(); - saveAs( - new Blob([buffer]), - `${formCode}_data_${new Date().toISOString().slice(0, 10)}.xlsx` - ); - - toast.success("Excel 내보내기 완료!"); - } catch (err) { - console.error("Excel export error:", err); - toast.error("Excel 내보내기 실패."); } finally { - setIsPending(false); + setIsExporting(false); } } + // Handle batch document check + const handleBatchDocument = () => { + if (tempCount > 0) { + setBatchDownDialog(true); + } else { + toast.error("업로드된 Template File이 없습니다."); + } + }; + return ( <> <ClientDataTable data={tableData} columns={columns} advancedFilterFields={advancedFilterFields} - // tableRef={tableRef} > {/* 버튼 그룹 */} <div className="flex items-center gap-2"> - {/* 태그 불러오기 버튼 */} - <Popover> - <PopoverTrigger asChild> - <Button variant="default" size="sm"> - Vendor Document + {/* 태그 관리 드롭다운 */} + <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" /> + Tag Operations </Button> - </PopoverTrigger> - <PopoverContent className="flex flex-row gap-2 w-auto"> - <Button - variant="outline" - size="sm" - onClick={() => setTempUpDialog(true)} - > - Template Upload + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + {/* 모드에 따라 다른 태그 작업 표시 */} + {mode === "IM" ? ( + <DropdownMenuItem onClick={handleSyncTags} disabled={isAnyOperationPending}> + <Tag className="mr-2 h-4 w-4" /> + Sync Tags + </DropdownMenuItem> + ) : ( + <DropdownMenuItem onClick={handleGetTags} disabled={isAnyOperationPending}> + <RefreshCcw className="mr-2 h-4 w-4" /> + Get Tags + </DropdownMenuItem> + )} + <DropdownMenuItem onClick={() => setAddTagDialogOpen(true)} disabled={isAnyOperationPending}> + <Plus className="mr-2 h-4 w-4" /> + Add Tags + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + + {/* 리포트 관리 드롭다운 */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="outline" size="sm" disabled={isAnyOperationPending}> + <Clipboard className="size-4" /> + Report Operations </Button> - <Button - variant="outline" - size="sm" - onClick={() => setBatchDownDialog(true)} - > - Vendor Document Create - </Button> - </PopoverContent> - </Popover> - <Button - variant="default" - size="sm" - onClick={handleSyncTags} - disabled={isPending} - > - {isPending && ( - <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> - )} - Sync Tags - </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem onClick={() => setTempUpDialog(true)} disabled={isAnyOperationPending}> + <Upload className="mr-2 h-4 w-4" /> + Upload Template + </DropdownMenuItem> + <DropdownMenuItem onClick={handleBatchDocument} disabled={isAnyOperationPending}> + <FileOutput className="mr-2 h-4 w-4" /> + Batch Document + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> {/* IMPORT 버튼 (파일 선택) */} - <Button asChild variant="outline" size="sm" disabled={isPending}> + <Button asChild variant="outline" size="sm" disabled={isAnyOperationPending}> <label> - <Upload className="size-4" /> + {isImporting ? ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + ) : ( + <Upload className="size-4" /> + )} Import <input type="file" accept=".xlsx,.xls" onChange={handleImportExcel} style={{ display: "none" }} + disabled={isAnyOperationPending} /> </label> </Button> - {/* EXPORT 버튼 (새로 추가) */} + {/* EXPORT 버튼 */} <Button variant="outline" size="sm" onClick={handleExportExcel} - disabled={isPending} + disabled={isAnyOperationPending} > - <Download className="mr-2 size-4" /> - Export Template + {isExporting ? ( + <Loader className="mr-2 size-4 animate-spin" /> + ) : ( + <Download className="mr-2 size-4" /> + )} + Export </Button> - {/* SAVE 버튼 */} + {/* COMPARE WITH SEDP 버튼 */} <Button variant="outline" size="sm" - onClick={handleSave} - disabled={isPending || isSaving} + onClick={handleSEDPCompareClick} + disabled={isAnyOperationPending} > - {isSaving ? ( + <GitCompareIcon className="mr-2 size-4" /> + Compare with SEDP + </Button> + + {/* SEDP 전송 버튼 */} + <Button + variant="samsung" + size="sm" + onClick={handleSEDPSendClick} + disabled={isAnyOperationPending} + > + {isSendingSEDP ? ( <> <Loader className="mr-2 size-4 animate-spin" /> - 저장 중... + SEDP 전송 중... </> ) : ( <> - <Save className="mr-2 size-4" /> - Save + <Send className="size-4" /> + Send to SHI </> )} </Button> </div> </ClientDataTable> + {/* Modal dialog for tag update */} <UpdateTagSheet open={rowAction?.type === "update"} onOpenChange={(open) => { @@ -619,7 +664,62 @@ export default function DynamicTable({ rowData={rowAction?.row.original ?? null} formCode={formCode} contractItemId={contractItemId} + 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 + ) + ); + } + }} /> + + {/* Dialog for adding tags */} + <AddFormTagDialog + projectId={projectId} + formCode={formCode} + formName={`Form ${formCode}`} + contractItemId={contractItemId} + open={addTagDialogOpen} + onOpenChange={setAddTagDialogOpen} + /> + + {/* SEDP Confirmation Dialog */} + <SEDPConfirmationDialog + isOpen={sedpConfirmOpen} + onClose={() => setSedpConfirmOpen(false)} + onConfirm={handleSEDPSendConfirmed} + formName={formName} + tagCount={tableData.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} + fetchTagDataFromSEDP={fetchTagDataFromSEDP} + /> + + {/* Other dialogs */} {tempUpDialog && ( <FormDataReportTempUploadDialog columnsJSON={columnsJSON} @@ -656,4 +756,4 @@ export default function DynamicTable({ )} </> ); -} +}
\ No newline at end of file diff --git a/components/form-data/import-excel-form.tsx b/components/form-data/import-excel-form.tsx new file mode 100644 index 00000000..45e48312 --- /dev/null +++ b/components/form-data/import-excel-form.tsx @@ -0,0 +1,323 @@ +// lib/excelUtils.ts (continued) +import ExcelJS from "exceljs"; +import { saveAs } from "file-saver"; +import { toast } from "sonner"; +import { DataTableColumnJSON } from "./form-data-table-columns"; +import { updateFormDataInDB } from "@/lib/forms/services"; +// Assuming the previous types are defined above +export interface ImportExcelOptions { + file: File; + tableData: GenericData[]; + columnsJSON: DataTableColumnJSON[]; + formCode?: string; // Optional - provide to enable direct DB save + contractItemId?: number; // Optional - provide to enable direct DB save + onPendingChange?: (isPending: boolean) => void; + onDataUpdate?: (updater: ((prev: GenericData[]) => GenericData[]) | GenericData[]) => void; +} + +export interface ImportExcelResult { + success: boolean; + importedCount?: number; + error?: any; + message?: string; +} + +export interface ExportExcelOptions { + tableData: GenericData[]; + columnsJSON: DataTableColumnJSON[]; + formCode: string; + onPendingChange?: (isPending: boolean) => void; +} + +// For typing consistency +interface GenericData { + [key: string]: any; +} + +export async function importExcelData({ + file, + tableData, + columnsJSON, + formCode, + contractItemId, + onPendingChange, + onDataUpdate +}: ImportExcelOptions): Promise<ImportExcelResult> { + if (!file) return { success: false, error: "No file provided" }; + + try { + if (onPendingChange) onPendingChange(true); + + // Get existing tag numbers + const existingTagNumbers = new Set(tableData.map((d) => d.TAG_NO)); + + const workbook = new ExcelJS.Workbook(); + const arrayBuffer = await file.arrayBuffer(); + 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 + let headerErrorMessage = ""; + + // Check for missing required columns + columnsJSON.forEach((col) => { + const label = col.label; + if (!headerToIndexMap.has(label)) { + headerErrorMessage += `Column "${label}" is missing. `; + } + }); + + // Check for unexpected columns + headerToIndexMap.forEach((index, headerLabel) => { + const found = columnsJSON.some((col) => col.label === headerLabel); + if (!found) { + headerErrorMessage += `Unexpected column "${headerLabel}" found in Excel. `; + } + }); + + // Add error column + const lastColIndex = worksheet.columnCount + 1; + worksheet.getRow(1).getCell(lastColIndex).value = "Error"; + + // If header validation fails, download error report and exit + if (headerErrorMessage) { + headerRow.getCell(lastColIndex).value = headerErrorMessage.trim(); + + const outBuffer = await workbook.xlsx.writeBuffer(); + saveAs(new Blob([outBuffer]), `import-check-result_${Date.now()}.xlsx`); + + toast.error(`Header mismatch found. Please check downloaded file.`); + return { success: false, error: "Header mismatch" }; + } + + // 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 lastRowNumber = worksheet.lastRow?.number || 1; + let errorCount = 0; + + // Process each data row + for (let rowNum = 2; rowNum <= lastRowNumber; rowNum++) { + const row = worksheet.getRow(rowNum); + const rowValues = row.values as ExcelJS.CellValue[]; + if (!rowValues || rowValues.length <= 1) continue; // Skip empty rows + + let errorMessage = ""; + const rowObj: Record<string, any> = {}; + + // Process each column + columnsJSON.forEach((col) => { + const colIndex = keyToIndexMap.get(col.key); + if (colIndex === undefined) return; + + const cellValue = rowValues[colIndex] ?? ""; + let stringVal = String(cellValue).trim(); + + // Type-specific validation + switch (col.type) { + case "STRING": + if (!stringVal && col.key === "TAG_NO") { + errorMessage += `[${col.label}] is empty. `; + } + rowObj[col.key] = stringVal; + break; + + case "NUMBER": + if (stringVal) { + const num = parseFloat(stringVal); + if (isNaN(num)) { + errorMessage += `[${col.label}] '${stringVal}' is not a valid number. `; + } else { + rowObj[col.key] = num; + } + } else { + rowObj[col.key] = null; + } + break; + + case "LIST": + if ( + stringVal && + col.options && + !col.options.includes(stringVal) + ) { + errorMessage += `[${ + col.label + }] '${stringVal}' not in ${col.options.join(", ")}. `; + } + rowObj[col.key] = stringVal; + break; + + default: + rowObj[col.key] = stringVal; + break; + } + }); + + // Validate TAG_NO + const tagNum = rowObj["TAG_NO"]; + if (!tagNum) { + errorMessage += `No TAG_NO found. `; + } else if (!existingTagNumbers.has(tagNum)) { + errorMessage += `TagNumber '${tagNum}' is not in current data. `; + } + + // Record errors or add to valid data + if (errorMessage) { + row.getCell(lastColIndex).value = errorMessage.trim(); + errorCount++; + } else { + importedData.push(rowObj); + } + } + + // If there are validation errors, download error report and exit + if (errorCount > 0) { + const outBuffer = await workbook.xlsx.writeBuffer(); + saveAs(new Blob([outBuffer]), `import-check-result_${Date.now()}.xlsx`); + toast.error( + `There are ${errorCount} error row(s). Please check downloaded file.` + ); + return { success: false, error: "Data validation errors" }; + } + + // 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 + if (formCode && contractItemId) { + try { + // Process each imported row individually + let successCount = 0; + let errorCount = 0; + const errors = []; + + // Since updateFormDataInDB expects a single row at a time, + // we need to process each imported row individually + for (const importedRow of importedData) { + try { + const result = await updateFormDataInDB( + formCode, + contractItemId, + importedRow + ); + + if (result.success) { + successCount++; + } else { + errorCount++; + errors.push(`Error updating tag ${importedRow.TAG_NO}: ${result.message}`); + } + } catch (rowError) { + errorCount++; + errors.push(`Exception updating tag ${importedRow.TAG_NO}: ${rowError instanceof Error ? rowError.message : 'Unknown error'}`); + } + } + + // If any errors occurred + if (errorCount > 0) { + console.error("Errors during import:", errors); + + if (successCount > 0) { + toast.warning(`Partially successful: ${successCount} rows updated, ${errorCount} errors`); + } else { + toast.error(`Failed to update all ${errorCount} rows`); + } + + // If some rows were updated successfully, update the local state + if (successCount > 0) { + if (onDataUpdate) { + onDataUpdate(() => mergedData); + } + return { + success: true, + importedCount: successCount, + message: `Partially successful: ${successCount} rows updated, ${errorCount} errors` + }; + } else { + return { + success: false, + error: "All updates failed", + message: errors.join("\n") + }; + } + } + + // All rows were updated successfully + if (onDataUpdate) { + onDataUpdate(() => mergedData); + } + + toast.success(`Successfully updated ${successCount} rows`); + return { + success: true, + importedCount: successCount, + message: "All data imported and saved to database" + }; + } catch (saveError) { + console.error("Failed to save imported data:", saveError); + toast.error("Failed to save imported data to database"); + return { success: false, error: saveError }; + } + } else { + // Fall back to just updating local state if DB parameters aren't provided + if (onDataUpdate) { + onDataUpdate(() => mergedData); + } + + toast.success(`Imported ${importedData.length} rows successfully (local only)`); + return { success: true, importedCount: importedData.length }; + } + + } catch (err) { + console.error("Excel import error:", err); + toast.error("Excel import failed."); + return { success: false, error: err }; + } finally { + if (onPendingChange) onPendingChange(false); + } +}
\ No newline at end of file diff --git a/components/form-data/publish-dialog.tsx b/components/form-data/publish-dialog.tsx new file mode 100644 index 00000000..a3a2ef0b --- /dev/null +++ b/components/form-data/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/sedp-compare-dialog.tsx b/components/form-data/sedp-compare-dialog.tsx new file mode 100644 index 00000000..461a3630 --- /dev/null +++ b/components/form-data/sedp-compare-dialog.tsx @@ -0,0 +1,372 @@ +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 { Loader, RefreshCw, AlertCircle, CheckCircle, Info } 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"; + +interface SEDPCompareDialogProps { + isOpen: boolean; + onClose: () => void; + tableData: any[]; + columnsJSON: DataTableColumnJSON[]; + projectCode: string; + formCode: string; + fetchTagDataFromSEDP: (projectCode: string, formCode: string) => Promise<any>; +} + +interface ComparisonResult { + tagNo: string; + tagDesc: string; + isMatching: boolean; + attributes: { + key: string; + label: string; + localValue: any; + sedpValue: any; + isMatching: boolean; + uom?: string; + }[]; +} + +// Component for formatting display value with UOM +const DisplayValue = ({ value, uom, isSedp = false }: { value: any; uom?: string; isSedp?: boolean }) => { + if (value === "" || value === null || value === undefined) { + return <span>(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> + ); +}; + +// 범례 컴포넌트 추가 +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">범례:</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">로컬 값</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">SEDP 값</span> + </div> + </div> + </div> + ); +}; + +export function SEDPCompareDialog({ + isOpen, + onClose, + tableData, + columnsJSON, + projectCode, + formCode, + fetchTagDataFromSEDP, +}: SEDPCompareDialogProps) { + const [isLoading, setIsLoading] = React.useState(false); + const [comparisonResults, setComparisonResults] = React.useState<ComparisonResult[]>([]); + const [activeTab, setActiveTab] = React.useState("all"); + const [isExporting, setIsExporting] = React.useState(false); + + // Stats for summary + const totalTags = comparisonResults.length; + const matchingTags = comparisonResults.filter(r => r.isMatching).length; + const nonMatchingTags = totalTags - matchingTags; + + // 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]); + + 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(); + sedpTagEntries.forEach((entry: any) => { + 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: any) => { + attributesMap.set(attr.ATT_ID, attr.VALUE); + }); + } + + sedpTagMap.set(tagNo, { + tagDesc: entry.TAG_DESC, + attributes: attributesMap + }); + }); + + // Compare with local table data + const results: ComparisonResult[] = tableData.map(localItem => { + const tagNo = localItem.TAG_NO; + const sedpItem = sedpTagMap.get(tagNo); + + // If tag not found in SEDP data + if (!sedpItem) { + return { + tagNo, + tagDesc: localItem.TAG_DESC || "", + isMatching: false, + attributes: columnsJSON + .filter(col => col.key !== "TAG_NO" && col.key !== "TAG_DESC") + .map(col => ({ + key: col.key, + label: columnLabelMap[col.key] || col.key, + localValue: localItem[col.key], + sedpValue: null, + isMatching: false, + uom: columnUomMap[col.key] + })) + }; + } + + // Compare attributes + const attributeComparisons = columnsJSON + .filter(col => col.key !== "TAG_NO" && col.key !== "TAG_DESC") + .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; + + // 문자열 비교 + // Normalize empty values + 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; + + if (nonMatchCount > 0) { + toast.warning(`Found ${nonMatchCount} tags with differences`); + } else if (results.length > 0) { + toast.success(`All ${results.length} tags match with SEDP data`); + } else { + 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, fetchTagDataFromSEDP, columnLabelMap, columnUomMap]); + + // Fetch data when dialog opens + React.useEffect(() => { + if (isOpen) { + fetchAndCompareData(); + } + }, [isOpen, fetchAndCompareData]); + + // Filter results based on active tab + const filteredResults = React.useMemo(() => { + switch (activeTab) { + case "matching": + return comparisonResults.filter(r => r.isMatching); + case "differences": + return comparisonResults.filter(r => !r.isMatching); + case "all": + default: + return comparisonResults; + } + }, [comparisonResults, activeTab]); + + return ( + <Dialog open={isOpen} onOpenChange={onClose}> + <DialogContent className="max-w-5xl max-h-[90vh] overflow-hidden flex flex-col"> + <DialogHeader> + <DialogTitle className="flex items-center justify-between"> + <span>SEDP 데이터 비교</span> + <div className="flex items-center gap-2"> + <Badge variant={matchingTags === totalTags ? "default" : "destructive"}> + {matchingTags} / {totalTags} 일치 + </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">새로고침</span> + </Button> + </div> + </DialogTitle> + </DialogHeader> + + {/* 범례 추가 */} + <div className="mb-4"> + <ColorLegend /> + </div> + + <Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col overflow-hidden"> + <TabsList> + <TabsTrigger value="all">전체 태그 ({totalTags})</TabsTrigger> + <TabsTrigger value="differences">차이 있음 ({nonMatchingTags})</TabsTrigger> + <TabsTrigger value="matching">일치함 ({matchingTags})</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>데이터 비교 중...</span> + </div> + ) : filteredResults.length > 0 ? ( + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[180px]">Tag Number</TableHead> + <TableHead className="w-[200px]">Tag Description</TableHead> + <TableHead className="w-[120px]">상태</TableHead> + <TableHead>차이점</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {filteredResults.map((result) => { + // Find differences to display + const differences = result.attributes.filter(attr => !attr.isMatching); + + return ( + <TableRow key={result.tagNo} className={!result.isMatching ? "bg-muted/30" : ""}> + <TableCell className="font-medium">{result.tagNo}</TableCell> + <TableCell>{result.tagDesc}</TableCell> + <TableCell> + {result.isMatching ? ( + <Badge variant="default" className="flex items-center gap-1"> + <CheckCircle className="h-3 w-3" /> + <span>일치</span> + </Badge> + ) : ( + <Badge variant="destructive" className="flex items-center gap-1"> + <AlertCircle className="h-3 w-3" /> + <span>차이 있음</span> + </Badge> + )} + </TableCell> + <TableCell> + {differences.length > 0 ? ( + <div className="space-y-1"> + {differences.map((diff) => ( + <div key={diff.key} className="text-sm"> + <span className="font-medium">{diff.label}: </span> + <span className="line-through text-red-500 mr-2"> + <DisplayValue value={diff.localValue} uom={diff.uom} isSedp={false} /> + </span> + <span className="text-green-500"> + <DisplayValue value={diff.sedpValue} uom={diff.uom} isSedp={true} /> + </span> + </div> + ))} + </div> + ) : ( + <span className="text-muted-foreground">모든 값이 일치합니다</span> + )} + </TableCell> + </TableRow> + ); + })} + </TableBody> + </Table> + ) : ( + <div className="flex items-center justify-center h-full text-muted-foreground"> + 현재 필터에 맞는 태그가 없습니다 + </div> + )} + </TabsContent> + </Tabs> + + <DialogFooter className="flex justify-between items-center gap-4 pt-4 border-t"> + <ExcelDownload + comparisonResults={comparisonResults} + formCode={formCode} + disabled={isLoading || nonMatchingTags === 0} + /> + <Button onClick={onClose}>닫기</Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/components/form-data/sedp-components.tsx b/components/form-data/sedp-components.tsx new file mode 100644 index 00000000..4865e23d --- /dev/null +++ b/components/form-data/sedp-components.tsx @@ -0,0 +1,173 @@ +"use client"; + +import * as React from "react"; +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; +}) { + return ( + <Dialog open={isOpen} onOpenChange={onClose}> + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle>Send Data to SEDP</DialogTitle> + <DialogDescription> + You are about to send form data to the Samsung Engineering Design Platform (SEDP). + </DialogDescription> + </DialogHeader> + + <div className="py-4"> + <div className="grid grid-cols-2 gap-4 mb-4"> + <div className="text-muted-foreground">Form Name:</div> + <div className="font-medium">{formName}</div> + + <div className="text-muted-foreground">Total Tags:</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"> + Data sent to SEDP cannot be easily reverted. Please ensure all information is correct before proceeding. + </div> + </div> + </div> + + <DialogFooter className="gap-2 sm:gap-0"> + <Button variant="outline" onClick={onClose} disabled={isLoading}> + Cancel + </Button> + <Button + variant="samsung" + onClick={onConfirm} + disabled={isLoading} + className="gap-2" + > + {isLoading ? ( + <> + <Loader className="h-4 w-4 animate-spin" /> + Sending... + </> + ) : ( + <> + <Send className="h-4 w-4" /> + Send to SEDP + </> + )} + </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; +}) { + // Calculate percentage for the progress bar + const percentage = Math.round((successCount / totalCount) * 100); + + return ( + <Dialog open={isOpen} onOpenChange={onClose}> + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle> + {status === 'success' ? 'Data Sent Successfully' : + status === 'partial' ? 'Partially Successful' : + 'Failed to Send Data'} + </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>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"> + {successCount} Successful + </Badge> + </div> + {errorCount > 0 && ( + <div> + <Badge variant="outline" className="bg-red-50 text-red-700 hover:bg-red-50"> + {errorCount} Failed + </Badge> + </div> + )} + </div> + </div> + </div> + + <DialogFooter> + <Button onClick={onClose}> + Close + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/components/form-data/sedp-excel-download.tsx b/components/form-data/sedp-excel-download.tsx new file mode 100644 index 00000000..70f5c46a --- /dev/null +++ b/components/form-data/sedp-excel-download.tsx @@ -0,0 +1,163 @@ +import * as React from "react"; +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; + }>; + }>; + formCode: string; + disabled: boolean; +} + +export function ExcelDownload({ comparisonResults, formCode, disabled }: ExcelDownloadProps) { + const [isExporting, setIsExporting] = React.useState(false); + + // 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); + + if (itemsWithDifferences.length === 0) { + toast.info("차이가 없어 다운로드할 내용이 없습니다"); + return; + } + + // Create a new workbook + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'SEDP Compare Tool'; + workbook.created = new Date(); + + // Add a worksheet + const worksheet = workbook.addWorksheet('SEDP Differences'); + + // Add headers + worksheet.columns = [ + { header: 'Tag Number', key: 'tagNo', width: 20 }, + { header: 'Tag Description', key: 'tagDesc', width: 30 }, + { header: 'Attribute', key: 'attribute', width: 25 }, + { header: 'Local Value', key: 'localValue', width: 20 }, + { header: 'SEDP Value', 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 === '' + ? "(empty)" + : diff.uom ? `${diff.localValue} ${diff.uom}` : diff.localValue; + + // SEDP value is displayed as-is + const sedpDisplay = diff.sedpValue === null || diff.sedpValue === undefined || diff.sedpValue === '' + ? "(empty)" + : 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++; + }); + + // 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 = `SEDP_Differences_${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("차이점 Excel 다운로드 완료"); + } catch (error) { + console.error("Error exporting to Excel:", error); + toast.error("Excel 다운로드 실패"); + } finally { + setIsExporting(false); + } + }; + + return ( + <Button + variant="secondary" + onClick={handleExportDifferences} + disabled={disabled || isExporting} + className="flex items-center gap-2" + > + {isExporting ? ( + <Loader className="h-4 w-4 animate-spin" /> + ) : ( + <FileDown className="h-4 w-4" /> + )} + 차이점 Excel로 다운로드 + </Button> + ); +}
\ No newline at end of file diff --git a/components/form-data/temp-download-btn.tsx b/components/form-data/temp-download-btn.tsx index a5f963e4..793022d6 100644 --- a/components/form-data/temp-download-btn.tsx +++ b/components/form-data/temp-download-btn.tsx @@ -29,17 +29,18 @@ export const TempDownloadBtn = () => { }; return ( <Button - variant="ghost" - className="relative p-2" + variant="outline" + className="relative px-[8px] py-[6px] flex-1" aria-label="Template Sample Download" onClick={downloadTempFile} > <Image src="/icons/temp_sample_icon.svg" alt="Template Sample Download Icon" - width={20} - height={20} + width={16} + height={16} /> + <div className='text-[12px]'>Sample Template Download</div> </Button> ); -}; +};
\ No newline at end of file diff --git a/components/form-data/update-form-sheet.tsx b/components/form-data/update-form-sheet.tsx index c52b6833..27f426c1 100644 --- a/components/form-data/update-form-sheet.tsx +++ b/components/form-data/update-form-sheet.tsx @@ -4,8 +4,9 @@ import * as React from "react"; import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; -import { Loader } from "lucide-react"; +import { Check, ChevronsUpDown, Loader } from "lucide-react"; import { toast } from "sonner"; +import { useRouter } from "next/navigation"; // Add this import import { Sheet, @@ -27,15 +28,22 @@ import { FormMessage, } from "@/components/ui/form"; import { - Select, - SelectTrigger, - SelectContent, - SelectItem, - SelectValue, -} from "@/components/ui/select"; + 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/services"; +import { cn } from "@/lib/utils"; interface UpdateTagSheetProps extends React.ComponentPropsWithoutRef<typeof Sheet> { @@ -60,6 +68,7 @@ export function UpdateTagSheet({ ...props }: UpdateTagSheetProps) { const [isPending, startTransition] = React.useTransition(); + const router = useRouter(); // Add router hook // 1) zod 스키마 const dynamicSchema = React.useMemo(() => { @@ -104,27 +113,41 @@ export function UpdateTagSheet({ async function onSubmit(values: Record<string, any>) { startTransition(async () => { - const { success, message } = await updateFormDataInDB( - formCode, - contractItemId, - values - ); - if (!success) { - toast.error(message); - return; - } - toast.success("Updated successfully!"); + try { + const { success, message } = await updateFormDataInDB( + formCode, + contractItemId, + values + ); + + if (!success) { + toast.error(message); + return; + } + + // Success handling + toast.success("Updated successfully!"); + + // Create a merged object of original rowData and new values + const updatedData = { + ...rowData, + ...values, + TAG_NO: rowData?.TAG_NO, + }; - // (A) 수정된 값(폼 데이터)을 부모 콜백에 전달 - onUpdateSuccess?.({ - // rowData(원본)와 values를 합쳐서 최종 "수정된 row"를 만든다. - // tagNumber는 기존 그대로 - ...rowData, - ...values, - tagNumber: rowData?.tagNumber, - }); + // Call the success callback + onUpdateSuccess?.(updatedData); - onOpenChange(false); + // Refresh the entire route to get fresh data + router.refresh(); + + // Close the sheet + onOpenChange(false); + + } catch (error) { + console.error("Error updating form data:", error); + toast.error("An unexpected error occurred while updating"); + } }); } @@ -147,7 +170,7 @@ export function UpdateTagSheet({ <div className="flex flex-col gap-4 pt-2"> {columns.map((col) => { const isTagNumberField = - col.key === "tagNumber" || col.key === "tagDescription"; + col.key === "TAG_NO" || col.key === "TAG_DESC"; return ( <FormField key={col.key} @@ -178,22 +201,51 @@ export function UpdateTagSheet({ return ( <FormItem> <FormLabel>{col.label}</FormLabel> - <Select - disabled={isTagNumberField} - value={field.value ?? ""} - onValueChange={(val) => field.onChange(val)} - > - <SelectTrigger> - <SelectValue placeholder="Select an option" /> - </SelectTrigger> - <SelectContent> - {col.options?.map((opt) => ( - <SelectItem key={opt} value={opt}> - {opt} - </SelectItem> - ))} - </SelectContent> - </Select> + <Popover> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + disabled={isTagNumberField} + className={cn( + "w-full justify-between", + !field.value && "text-muted-foreground" + )} + > + {field.value + ? col.options?.find((opt) => opt === field.value) + : "Select an option"} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-full p-0"> + <Command> + <CommandInput placeholder="Search options..." /> + <CommandEmpty>No option found.</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> <FormMessage /> </FormItem> ); @@ -253,4 +305,4 @@ export function UpdateTagSheet({ </SheetContent> </Sheet> ); -} +}
\ No newline at end of file diff --git a/components/form-data/var-list-download-btn.tsx b/components/form-data/var-list-download-btn.tsx index 19bb26f9..bbadf893 100644 --- a/components/form-data/var-list-download-btn.tsx +++ b/components/form-data/var-list-download-btn.tsx @@ -50,11 +50,12 @@ export const VarListDownloadBtn: FC<VarListDownloadBtnProps> = ({ // 2. 데이터 행 추가 columnsJSON.forEach((row) => { - const { displayLabel, label } = row; + console.log(row) + const { displayLabel, key } = row; - const labelConvert = label.replaceAll(" ", "_"); + // const labelConvert = label.replaceAll(" ", "_"); - worksheet.addRow([displayLabel, labelConvert]); + worksheet.addRow([displayLabel, key]); }); // 3. 컬럼 너비 자동 조정 @@ -94,17 +95,18 @@ export const VarListDownloadBtn: FC<VarListDownloadBtnProps> = ({ return ( <Button - variant="ghost" - className="relative p-2" + variant="outline" + className="relative px-[8px] py-[6px] flex-1" aria-label="Variable List Download" onClick={downloadReportVarList} > <Image src="/icons/var_list_icon.svg" alt="Template Sample Download Icon" - width={20} - height={20} + width={16} + height={16} /> + <div className='text-[12px]'>Variable List Download</div> </Button> ); -}; +};
\ No newline at end of file |
