summaryrefslogtreecommitdiff
path: root/components/form-data
diff options
context:
space:
mode:
Diffstat (limited to 'components/form-data')
-rw-r--r--components/form-data/add-formTag-dialog.tsx957
-rw-r--r--components/form-data/export-excel-form.tsx197
-rw-r--r--components/form-data/form-data-report-batch-dialog.tsx8
-rw-r--r--components/form-data/form-data-report-dialog.tsx153
-rw-r--r--components/form-data/form-data-report-temp-upload-dialog.tsx45
-rw-r--r--components/form-data/form-data-report-temp-upload-tab.tsx2
-rw-r--r--components/form-data/form-data-report-temp-uploaded-list-tab.tsx2
-rw-r--r--components/form-data/form-data-table copy.tsx539
-rw-r--r--components/form-data/form-data-table-columns.tsx19
-rw-r--r--components/form-data/form-data-table.tsx948
-rw-r--r--components/form-data/import-excel-form.tsx323
-rw-r--r--components/form-data/publish-dialog.tsx470
-rw-r--r--components/form-data/sedp-compare-dialog.tsx372
-rw-r--r--components/form-data/sedp-components.tsx173
-rw-r--r--components/form-data/sedp-excel-download.tsx163
-rw-r--r--components/form-data/temp-download-btn.tsx11
-rw-r--r--components/form-data/update-form-sheet.tsx140
-rw-r--r--components/form-data/var-list-download-btn.tsx18
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