diff options
51 files changed, 21102 insertions, 123 deletions
diff --git a/app/[lng]/partners/(partners)/vendor-data-plant/form/[packageId]/[formId]/[projectId]/[contractId]/page.tsx b/app/[lng]/partners/(partners)/vendor-data-plant/form/[packageId]/[formId]/[projectId]/[contractId]/page.tsx new file mode 100644 index 00000000..00fd23da --- /dev/null +++ b/app/[lng]/partners/(partners)/vendor-data-plant/form/[packageId]/[formId]/[projectId]/[contractId]/page.tsx @@ -0,0 +1,82 @@ +import DynamicTable from "@/components/form-data-plant/form-data-table"; +import { findContractItemId, getFormData, getFormId } from "@/lib/forms-plant/services"; +import { useTranslation } from "@/i18n"; + +interface IndexPageProps { + params: { + lng: string; + packageId: string; + formId: string; + projectId: string; + contractId: string; + }; + searchParams?: { + mode?: string; + }; +} + +export default async function FormPage({ params, searchParams }: IndexPageProps) { + // 1) 구조 분해 할당 + const resolvedParams = await params; + + // 2) searchParams도 await 필요 + const resolvedSearchParams = await searchParams; + + // 3) 구조 분해 할당 + const { lng, packageId, formId: formCode, projectId, contractId } = resolvedParams; + + // i18n 설정 + const { t } = await useTranslation(lng, 'engineering'); + + // URL 쿼리 파라미터에서 mode 가져오기 (await 해서 사용) + const mode = resolvedSearchParams?.mode === "ENG" ? "ENG" : "IM"; // 기본값은 IM + + // 4) 변환 + let packageIdAsNumber = Number(packageId); + const contractIdAsNumber = Number(contractId); + + // packageId가 0이면 contractId와 formCode로 실제 contractItemId 찾기 + if (packageIdAsNumber === 0 && contractIdAsNumber > 0) { + console.log(`packageId가 0이므로 contractId ${contractIdAsNumber}와 formCode ${formCode}로 contractItemId 조회`); + + const foundContractItemId = await findContractItemId(contractIdAsNumber, formCode); + + if (foundContractItemId) { + console.log(`contractItemId ${foundContractItemId}를 찾았습니다. 이 값을 사용합니다.`); + packageIdAsNumber = foundContractItemId; + } else { + console.warn(`contractItemId를 찾을 수 없습니다. packageId는 계속 0으로 유지됩니다.`); + } + } + + // 5) DB 조회 + const { columns, data, editableFieldsMap } = await getFormData(formCode, packageIdAsNumber); + + // 6) formId 및 report temp file 조회 + const { formId } = await getFormId(String(packageIdAsNumber), formCode); + + // 7) 예외 처리 + if (!columns) { + return ( + <p className="text-red-500"> + {t('errors.form_meta_not_found')} + </p> + ); + } + + // 8) 렌더링 + return ( + <div className="space-y-6"> + <DynamicTable + contractItemId={packageIdAsNumber} + formCode={formCode} + formId={formId} + columnsJSON={columns} + dataJSON={data} + projectId={Number(projectId)} + editableFieldsMap={editableFieldsMap} // 새로 추가 + mode={mode} // 모드 전달 + /> + </div> + ); +}
\ No newline at end of file diff --git a/app/[lng]/partners/(partners)/vendor-data-plant/layout.tsx b/app/[lng]/partners/(partners)/vendor-data-plant/layout.tsx new file mode 100644 index 00000000..d2d63c28 --- /dev/null +++ b/app/[lng]/partners/(partners)/vendor-data-plant/layout.tsx @@ -0,0 +1,85 @@ +// app/vendor-data-plant/layout.tsx +import * as React from "react" +import { cookies } from "next/headers" +import { Shell } from "@/components/shell" +import { getVendorProjectsAndContracts } from "@/lib/vendor-data-plant/services" +import { VendorDataContainer } from "@/components/vendor-data-plant/vendor-data-container" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { getServerSession } from "next-auth" +import { InformationButton } from "@/components/information/information-button" +import { useTranslation } from "@/i18n" + +interface VendorDataLayoutProps { + children: React.ReactNode + params: { lng?: string } +} + +// Layout 컴포넌트는 서버 컴포넌트입니다 +export default async function VendorDataLayout({ + children, + params, +}: VendorDataLayoutProps) { + // 기본 언어는 'ko'로 설정, params.locale이 있으면 사용 + const { lng } = await params; + const language = lng || 'en' + const { t } = await useTranslation(language, 'engineering') + + const session = await getServerSession(authOptions) + const vendorId = session?.user.companyId + // const vendorId = "17" + const idAsNumber = Number(vendorId) + + // 프로젝트 데이터 가져오기 + const projects = await getVendorProjectsAndContracts(idAsNumber) + + // 레이아웃 설정 쿠키 가져오기 + // Next.js 15에서는 cookies()가 Promise를 반환하므로 await 사용 + const cookieStore = await cookies() + + // 이제 cookieStore.get() 메서드 사용 가능 + const layout = cookieStore.get("react-resizable-panels:layout:mail") + const collapsed = cookieStore.get("react-resizable-panels:collapsed") + + const defaultLayout = layout ? JSON.parse(layout.value) : undefined + const defaultCollapsed = collapsed ? JSON.parse(collapsed.value) : undefined + + return ( + <Shell className="gap-2"> + <div className="flex items-center justify-between space-y-2"> + <div className="flex items-center justify-between space-y-2"> + <div> + <div className="flex items-center gap-2"> + <h2 className="text-2xl font-bold tracking-tight"> + {t('layout.page_title')} + </h2> + <InformationButton pagePath="partners/vendor-data-plant" /> + </div> + {/* <p className="text-muted-foreground"> + 각종 Data 입력할 수 있습니다 + </p> */} + </div> + </div> + </div> + + <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow"> + <div className="hidden flex-col md:flex"> + {projects.length === 0 ? ( + <div className="p-4 text-center text-sm text-muted-foreground"> + {t('layout.no_projects')} + </div> + ) : ( + <VendorDataContainer + projects={projects} + defaultLayout={defaultLayout} + defaultCollapsed={defaultCollapsed} + navCollapsedSize={4} + > + {/* 페이지별 콘텐츠가 여기에 들어갑니다 */} + {children} + </VendorDataContainer> + )} + </div> + </section> + </Shell> + ) +}
\ No newline at end of file diff --git a/app/[lng]/partners/(partners)/vendor-data-plant/page.tsx b/app/[lng]/partners/(partners)/vendor-data-plant/page.tsx new file mode 100644 index 00000000..0fbb6f0a --- /dev/null +++ b/app/[lng]/partners/(partners)/vendor-data-plant/page.tsx @@ -0,0 +1,38 @@ +import * as React from "react" +import { Separator } from "@/components/ui/separator" +import { useTranslation } from "@/i18n" + +interface Props { + params: { lng?: string } +} + +export default async function VendorDataPage({ params }: Props) { + // 기본 언어는 'ko'로 설정, params.lng이 있으면 사용 + const { lng } = await params + const language = lng || 'en' + const { t } = await useTranslation(language, 'engineering') + + return ( + <div className="space-y-6"> + <div> + <h3 className="text-lg font-medium">{t('layout.title')}</h3> + <p className="text-sm text-muted-foreground"> + {t('layout.description')} + </p> + </div> + <Separator /> + <div className="grid gap-4"> + <div className="rounded-lg border p-4"> + <h4 className="text-sm font-medium">{t('layout.getting_started.title')}</h4> + <p className="text-sm text-muted-foreground mt-1"> + 1. {t('layout.getting_started.step1')}<br /> + 2. {t('layout.getting_started.step2')}<br /> + 3. {t('layout.getting_started.step3')}<br /> + 4. {t('layout.getting_started.step4')}<br /> + 5. {t('layout.getting_started.step5')} + </p> + </div> + </div> + </div> + ) +}
\ No newline at end of file diff --git a/app/[lng]/partners/(partners)/vendor-data-plant/tag/[id]/page.tsx b/app/[lng]/partners/(partners)/vendor-data-plant/tag/[id]/page.tsx new file mode 100644 index 00000000..c5d93525 --- /dev/null +++ b/app/[lng]/partners/(partners)/vendor-data-plant/tag/[id]/page.tsx @@ -0,0 +1,43 @@ +import { Separator } from "@/components/ui/separator" +import { type SearchParams } from "@/types/table" +import { getValidFilters } from "@/lib/data-table" +import { TagsTable } from "@/lib/tags-plant/table/tag-table" +import { searchParamsCache } from "@/lib/tags-plant/validations" +import { getTags } from "@/lib/tags-plant/service" + +interface IndexPageProps { + params: { + id: string + } + searchParams: Promise<SearchParams> +} + +export default async function TagPage(props: IndexPageProps) { + const resolvedParams = await props.params + const id = resolvedParams.id + + const idAsNumber = Number(id) + + // 2) SearchParams 파싱 (Zod) + // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 + const searchParams = await props.searchParams + const search = searchParamsCache.parse(searchParams) + const validFilters = getValidFilters(search.filters) + + const promises = Promise.all([ + getTags({ + ...search, + filters: validFilters, + }, + idAsNumber) + ]) + + // 4) 렌더링 + return ( + <div className="space-y-6"> + <div> + <TagsTable promises={promises} selectedPackageId={idAsNumber}/> + </div> + </div> + ) +}
\ No newline at end of file diff --git a/components/form-data-plant/add-formTag-dialog.tsx b/components/form-data-plant/add-formTag-dialog.tsx new file mode 100644 index 00000000..05043ca8 --- /dev/null +++ b/components/form-data-plant/add-formTag-dialog.tsx @@ -0,0 +1,985 @@ +"use client" + +import * as React from "react" +import { useParams, useRouter } from "next/navigation"; +import { useForm, useFieldArray } from "react-hook-form" +import { toast } from "sonner" +import { Loader2, ChevronsUpDown, Check, Plus, Trash2, Copy } from "lucide-react" + +import { + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Form, + FormField, + FormItem, + FormControl, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ui/popover" +import { + Command, + CommandInput, + CommandList, + CommandGroup, + CommandItem, + CommandEmpty, +} from "@/components/ui/command" +import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" +import { cn } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" + +import { createTagInForm } from "@/lib/tags/service" +import { + getFormTagTypeMappings, + getTagTypeByDescription, + getSubfieldsByTagTypeForForm +} from "@/lib/forms-plant/services" +import { useTranslation } from "@/i18n/client"; + +// Form-specific tag mapping interface +interface FormTagMapping { + id: number; + tagTypeLabel: string; + classLabel: string; + formCode: string; + formName: string; + remark?: string | null; +} + +// Updated to support multiple rows +interface MultiTagFormValues { + class: string; + tagType: string; + rows: Array<{ + [key: string]: string; + tagNo: string; + description: string; + }>; +} + +// SubFieldDef for clarity +interface SubFieldDef { + name: string; + label: string; + type: string; + options?: { value: string; label: string }[]; + expression?: string; + delimiter?: string; +} + +interface AddFormTagDialogProps { + projectId: number; + formCode: string; + formName?: string; + contractItemId: number; + packageCode: string; + open?: boolean; + onOpenChange?: (open: boolean) => void; +} + +export function AddFormTagDialog({ + projectId, + formCode, + formName, + contractItemId, + packageCode, + open: externalOpen, + onOpenChange: externalOnOpenChange +}: AddFormTagDialogProps) { + const router = useRouter() + const params = useParams(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "engineering"); + + // Use external control if provided, otherwise use internal state + const [internalOpen, setInternalOpen] = React.useState(false); + const isOpen = externalOpen !== undefined ? externalOpen : internalOpen; + const setIsOpen = externalOnOpenChange || setInternalOpen; + + const [mappings, setMappings] = React.useState<FormTagMapping[]>([]) + const [selectedTagTypeLabel, setSelectedTagTypeLabel] = React.useState<string | null>(null) + const [selectedTagTypeCode, setSelectedTagTypeCode] = React.useState<string | null>(null) + const [subFields, setSubFields] = React.useState<SubFieldDef[]>([]) + const [isLoadingClasses, setIsLoadingClasses] = React.useState(false) + const [isLoadingSubFields, setIsLoadingSubFields] = React.useState(false) + const [isSubmitting, setIsSubmitting] = React.useState(false) + + // ID management for React keys + const selectIdRef = React.useRef(0) + const fieldIdsRef = React.useRef<Record<string, string>>({}) + const classOptionIdsRef = React.useRef<Record<string, string>>({}) + + // --------------- + // Load Form Tag Mappings + // --------------- + React.useEffect(() => { + const loadMappings = async () => { + if (!formCode || !projectId) return; + + setIsLoadingClasses(true); + try { + const result = await getFormTagTypeMappings(formCode, projectId); + // Type safety casting + const typedMappings: FormTagMapping[] = result.map(item => ({ + id: item.id, + tagTypeLabel: item.tagTypeLabel, + classLabel: item.classLabel, + formCode: item.formCode, + formName: item.formName, + remark: item.remark + })); + setMappings(typedMappings); + } catch (err) { + toast.error("폼 태그 매핑 로드에 실패했습니다."); + } finally { + setIsLoadingClasses(false); + } + }; + + loadMappings(); + }, [formCode, projectId]); + + // Load mappings when dialog opens + React.useEffect(() => { + if (isOpen) { + const loadMappings = async () => { + if (!formCode || !projectId) return; + + setIsLoadingClasses(true); + try { + const result = await getFormTagTypeMappings(formCode, projectId); + // Type safety casting + const typedMappings: FormTagMapping[] = result.map(item => ({ + id: item.id, + tagTypeLabel: item.tagTypeLabel, + classLabel: item.classLabel, + formCode: item.formCode, + formName: item.formName, + remark: item.remark + })); + setMappings(typedMappings); + } catch (err) { + toast.error("폼 태그 매핑 로드에 실패했습니다."); + } finally { + setIsLoadingClasses(false); + } + }; + + loadMappings(); + } + }, [isOpen, formCode, projectId]); + + // --------------- + // react-hook-form with fieldArray support for multiple rows + // --------------- + const form = useForm<MultiTagFormValues>({ + defaultValues: { + tagType: "", + class: "", + rows: [{ + tagNo: "", + description: "" + }] + }, + }); + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "rows" + }); + + // --------------- + // Load subfields by TagType code + // --------------- + async function loadSubFieldsByTagTypeCode(tagTypeCode: string) { + setIsLoadingSubFields(true); + try { + const { subFields: apiSubFields } = await getSubfieldsByTagTypeForForm(tagTypeCode, projectId); + const formattedSubFields: SubFieldDef[] = apiSubFields.map(field => ({ + name: field.name, + label: field.label, + type: field.type, + options: field.options || [], + expression: field.expression ?? undefined, + delimiter: field.delimiter ?? undefined, + })); + setSubFields(formattedSubFields); + + // Initialize the rows with these subfields + const currentRows = form.getValues("rows"); + const updatedRows = currentRows.map(row => { + const newRow = { ...row }; + formattedSubFields.forEach(field => { + if (!newRow[field.name]) { + newRow[field.name] = ""; + } + }); + return newRow; + }); + + form.setValue("rows", updatedRows); + return true; + } catch (err) { + toast.error("서브필드를 불러오는데 실패했습니다."); + setSubFields([]); + return false; + } finally { + setIsLoadingSubFields(false); + } + } + + // --------------- + // Handle class selection + // --------------- + async function handleSelectClass(classLabel: string) { + form.setValue("class", classLabel); + + // Find the mapping for this class + const mapping = mappings.find(m => m.classLabel === classLabel); + if (mapping) { + setSelectedTagTypeLabel(mapping.tagTypeLabel); + form.setValue("tagType", mapping.tagTypeLabel); + + // Get the tagTypeCode for this tagTypeLabel + try { + const tagType = await getTagTypeByDescription(mapping.tagTypeLabel, projectId); + if (tagType) { + setSelectedTagTypeCode(tagType.code); + await loadSubFieldsByTagTypeCode(tagType.code); + } else { + toast.error("선택한 태그 유형을 찾을 수 없습니다."); + } + } catch (error) { + toast.error("태그 유형 정보를 불러오는데 실패했습니다."); + } + } + } + + // --------------- + // Build TagNo from subfields automatically for each row + // --------------- + React.useEffect(() => { + if (subFields.length === 0) { + return; + } + + const subscription = form.watch((value) => { + if (!value.rows || subFields.length === 0) { + return; + } + + const rows = [...value.rows]; + rows.forEach((row, rowIndex) => { + if (!row) return; + + let combined = ""; + subFields.forEach((sf, idx) => { + const fieldValue = row[sf.name] || ""; + + // delimiter를 앞에 붙이기 (첫 번째 필드가 아니고, 현재 필드에 값이 있고, delimiter가 있는 경우) + if (idx > 0 && fieldValue && sf.delimiter) { + combined += sf.delimiter; + } + + combined += fieldValue; + }); + + const currentTagNo = form.getValues(`rows.${rowIndex}.tagNo`); + if (currentTagNo !== combined) { + form.setValue(`rows.${rowIndex}.tagNo`, combined, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true, + }); + } + }); + }); + + return () => subscription.unsubscribe(); + }, [subFields, form]); + // --------------- + // Check if tag numbers are valid + // --------------- + const areAllTagNosValid = React.useMemo(() => { + const rows = form.getValues("rows"); + return rows.every(row => { + const tagNo = row.tagNo; + return tagNo && tagNo.trim() !== "" && !tagNo.includes("??"); + }); + }, [form.watch()]); + + // --------------- + // Submit handler for multiple tags + // --------------- + async function onSubmit(data: MultiTagFormValues) { + if (!contractItemId || !projectId) { + toast.error("필요한 정보가 없습니다."); + return; + } + + setIsSubmitting(true); + try { + const successfulTags = []; + const failedTags = []; + + // Process each row + for (const row of data.rows) { + // Create tag data + const tagData = { + tagType: data.tagType, + class: data.class, + tagNo: row.tagNo, + description: row.description, + ...Object.fromEntries( + subFields.map(field => [field.name, row[field.name] || ""]) + ), + functionCode: row.functionCode || "", + seqNumber: row.seqNumber || "", + valveAcronym: row.valveAcronym || "", + processUnit: row.processUnit || "", + }; + + try { + const res = await createTagInForm(tagData, contractItemId, formCode, packageCode); + if (res && "error" in res) { + failedTags.push({ tag: row.tagNo, error: res.error }); + } else if (res && res.success) { + successfulTags.push(row.tagNo); + } else { + // 예상치 못한 응답 처리 + console.error("Unexpected response:", res); + failedTags.push({ tag: row.tagNo, error: "Unexpected response format" }); + } + + } catch (err) { + failedTags.push({ tag: row.tagNo, error: "Unknown error" }); + } + } + + // Show results to the user + if (successfulTags.length > 0) { + toast.success(`${successfulTags.length}개의 태그가 성공적으로 생성되었습니다!`); + } + + if (failedTags.length > 0) { + console.log("Failed tags:", failedTags); + + // 전체 에러 메시지 표시 + const errorMessage = failedTags + .map(f => `${f.tag}: ${f.error}`) + .join('\n'); + + toast.error( + <div> + <p>{failedTags.length}개의 태그 생성 실패:</p> + <ul className="text-sm mt-1"> + {failedTags.map((f, idx) => ( + <li key={idx}>• {f.tag}: {f.error}</li> + ))} + </ul> + </div> + ); + } + + // Refresh the page + router.refresh(); + + // Reset the form and close dialog if all successful + if (failedTags.length === 0) { + form.reset(); + setIsOpen(false); + } + } catch (err) { + toast.error("태그 생성 처리에 실패했습니다."); + } finally { + setIsSubmitting(false); + } + } + + // --------------- + // Add a new row + // --------------- + function addRow() { + const newRow: { + tagNo: string; + description: string; + [key: string]: string; + } = { + tagNo: "", + description: "" + }; + + // Add all subfields with empty values + subFields.forEach(field => { + newRow[field.name] = ""; + }); + + append(newRow); + + // Force form validation after row is added + setTimeout(() => form.trigger(), 0); + } + + // --------------- + // Duplicate row + // --------------- + function duplicateRow(index: number) { + const rowToDuplicate = form.getValues(`rows.${index}`); + const newRow: { + tagNo: string; + description: string; + [key: string]: string; + } = { ...rowToDuplicate }; + + // Clear the tagNo field as it will be auto-generated + newRow.tagNo = ""; + append(newRow); + + // Force form validation after row is duplicated + setTimeout(() => form.trigger(), 0); + } + + // --------------- + // Render Class field + // --------------- + function renderClassField(field: any) { + const [popoverOpen, setPopoverOpen] = React.useState(false) + + const buttonId = React.useMemo( + () => `class-button-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + [] + ) + const popoverContentId = React.useMemo( + () => `class-popover-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + [] + ) + const commandId = React.useMemo( + () => `class-command-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + [] + ) + + // Get unique class labels from mappings + const classOptions = Array.from(new Set(mappings.map(m => m.classLabel))); + + return ( + <FormItem className="w-1/2"> + <FormLabel>{t("labels.class")}</FormLabel> + <FormControl> + <Popover open={popoverOpen} onOpenChange={setPopoverOpen}> + <PopoverTrigger asChild> + <Button + key={buttonId} + type="button" + variant="outline" + className="w-full justify-between relative h-9" + disabled={isLoadingClasses} + > + {isLoadingClasses ? ( + <> + <span>{t("messages.loadingClasses")}</span> + <Loader2 className="ml-2 h-4 w-4 animate-spin" /> + </> + ) : ( + <> + <span className="truncate mr-1 flex-grow text-left"> + {field.value || t("placeholders.selectClass")} + </span> + <ChevronsUpDown className="h-4 w-4 opacity-50 flex-shrink-0" /> + </> + )} + </Button> + </PopoverTrigger> + <PopoverContent key={popoverContentId} className="w-[300px] p-0"> + <Command key={commandId}> + <CommandInput + key={`${commandId}-input`} + placeholder={t("placeholders.searchClass")} + /> + <CommandList key={`${commandId}-list`} className="max-h-[300px]"> + <CommandEmpty key={`${commandId}-empty`}>{t("messages.noSearchResults")}</CommandEmpty> + <CommandGroup key={`${commandId}-group`}> + {classOptions.map((className, optIndex) => { + if (!classOptionIdsRef.current[className]) { + classOptionIdsRef.current[className] = + `class-${className}-${Date.now()}-${Math.random() + .toString(36) + .slice(2, 9)}` + } + const optionId = classOptionIdsRef.current[className] + + return ( + <CommandItem + key={`${optionId}-${optIndex}`} + onSelect={() => { + field.onChange(className) + setPopoverOpen(false) + handleSelectClass(className) + }} + value={className} + className="truncate" + title={className} + > + <span className="truncate">{className}</span> + <Check + key={`${optionId}-check`} + className={cn( + "ml-auto h-4 w-4 flex-shrink-0", + field.value === className ? "opacity-100" : "opacity-0" + )} + /> + </CommandItem> + ) + })} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + </FormControl> + <FormMessage /> + </FormItem> + ) + } + + // --------------- + // Render TagType field (readonly after class selection) + // --------------- + function renderTagTypeField(field: any) { + const isReadOnly = !!selectedTagTypeLabel + const inputId = React.useMemo( + () => + `tag-type-input-${isReadOnly ? "readonly" : "editable"}-${Date.now()}-${Math.random() + .toString(36) + .slice(2, 9)}`, + [isReadOnly] + ) + + return ( + <FormItem className="w-1/2"> + <FormLabel>{t("labels.tagType")}</FormLabel> + <FormControl> + {isReadOnly ? ( + <div className="relative"> + <Input + key={`tag-type-readonly-${inputId}`} + {...field} + readOnly + className="h-9 bg-muted" + /> + </div> + ) : ( + <Input + key={`tag-type-placeholder-${inputId}`} + {...field} + readOnly + placeholder={t("placeholders.autoSetByClass")} + className="h-9 bg-muted" + /> + )} + </FormControl> + <FormMessage /> + </FormItem> + ) + } + + // --------------- + // Render the table of subfields + // --------------- + function renderTagTable() { + if (isLoadingSubFields) { + return ( + <div className="flex justify-center items-center py-8"> + <Loader2 className="h-8 w-8 animate-spin text-primary" /> + <div className="ml-3 text-muted-foreground">{t("messages.loadingFields")}</div> + </div> + ) + } + + if (subFields.length === 0 && selectedTagTypeCode) { + return ( + <div className="py-4 text-center text-muted-foreground"> + {t("messages.noFieldsForTagType")} + </div> + ) + } + + if (subFields.length === 0) { + return ( + <div className="py-4 text-center text-muted-foreground"> + {t("messages.selectClassFirst")} + </div> + ) + } + + return ( + <div className="space-y-4"> + {/* 헤더 */} + <div className="flex justify-between items-center"> + <h3 className="text-sm font-medium">{t("sections.tagItems")} ({fields.length}개)</h3> + {!areAllTagNosValid && ( + <Badge variant="destructive" className="ml-2"> + {t("messages.invalidTagsExist")} + </Badge> + )} + </div> + + {/* 테이블 컨테이너 - 가로/세로 스크롤 모두 적용 */} + <div className="border rounded-md overflow-auto" style={{ maxHeight: '400px', maxWidth: '100%' }}> + <div className="min-w-full overflow-x-auto"> + <Table className="w-full table-fixed"> + <TableHeader className="sticky top-0 bg-muted z-10"> + <TableRow> + <TableHead className="w-10 text-center">#</TableHead> + <TableHead className="w-[120px]"> + <div className="font-medium">{t("labels.tagNo")}</div> + </TableHead> + <TableHead className="w-[180px]"> + <div className="font-medium">{t("labels.description")}</div> + </TableHead> + + {/* Subfields */} + {subFields.map((field, fieldIndex) => ( + <TableHead + key={`header-${field.name}-${fieldIndex}`} + className="w-[120px]" + > + <div className="flex flex-col"> + <div className="font-medium" title={field.label}> + {field.label} + </div> + {field.expression && ( + <div className="text-[10px] text-muted-foreground truncate" title={field.expression}> + {field.expression} + </div> + )} + </div> + </TableHead> + ))} + + <TableHead className="w-[100px] text-center sticky right-0 bg-muted">{t("labels.actions")}</TableHead> + </TableRow> + </TableHeader> + + <TableBody> + {fields.map((item, rowIndex) => ( + <TableRow + key={`row-${item.id}-${rowIndex}`} + className={rowIndex % 2 === 0 ? "bg-background" : "bg-muted/20"} + > + {/* Row number */} + <TableCell className="text-center text-muted-foreground font-mono"> + {rowIndex + 1} + </TableCell> + + {/* Tag No cell */} + <TableCell className="p-1"> + <FormField + control={form.control} + name={`rows.${rowIndex}.tagNo`} + render={({ field }) => ( + <FormItem className="m-0 space-y-0"> + <FormControl> + <div className="relative"> + <Input + {...field} + readOnly + className={cn( + "bg-muted h-8 w-full font-mono text-sm", + field.value?.includes("??") && "border-red-500 bg-red-50" + )} + title={field.value || ""} + /> + {field.value?.includes("??") && ( + <div className="absolute right-2 top-1/2 transform -translate-y-1/2"> + <Badge variant="destructive" className="text-xs"> + ! + </Badge> + </div> + )} + </div> + </FormControl> + </FormItem> + )} + /> + </TableCell> + + {/* Description cell */} + <TableCell className="p-1"> + <FormField + control={form.control} + name={`rows.${rowIndex}.description`} + render={({ field }) => ( + <FormItem className="m-0 space-y-0"> + <FormControl> + <Input + {...field} + className="h-8 w-full" + placeholder={t("placeholders.enterDescription")} + title={field.value || ""} + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + + {/* Subfield cells */} + {subFields.map((sf, sfIndex) => ( + <TableCell + key={`cell-${item.id}-${rowIndex}-${sf.name}-${sfIndex}`} + className="p-1" + > + <FormField + control={form.control} + name={`rows.${rowIndex}.${sf.name}`} + render={({ field }) => ( + <FormItem className="m-0 space-y-0"> + <FormControl> + {sf.type === "select" ? ( + <Select + value={field.value || ""} + onValueChange={field.onChange} + > + <SelectTrigger + className="w-full h-8 truncate" + title={field.value || ""} + > + <SelectValue placeholder={`선택...`} className="truncate" /> + </SelectTrigger> + <SelectContent + align="start" + side="bottom" + className="max-h-[200px]" + style={{ minWidth: "250px", maxWidth: "350px" }} + > + {sf.options?.map((opt, index) => ( + <SelectItem + key={`${rowIndex}-${sf.name}-${opt.value}-${index}`} + value={opt.value} + title={opt.label} + className="whitespace-normal py-2 break-words" + > + {opt.value} - {opt.label} + </SelectItem> + ))} + </SelectContent> + </Select> + ) : ( + <Input + {...field} + className="h-8 w-full" + placeholder={`입력...`} + title={field.value || ""} + /> + )} + </FormControl> + </FormItem> + )} + /> + </TableCell> + ))} + + {/* Actions cell */} + <TableCell className="p-1 sticky right-0 bg-white shadow-[-4px_0_4px_rgba(0,0,0,0.05)]"> + <div className="flex justify-center space-x-1"> + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + type="button" + variant="ghost" + size="icon" + className="h-7 w-7" + onClick={() => duplicateRow(rowIndex)} + > + <Copy className="h-3.5 w-3.5 text-muted-foreground" /> + </Button> + </TooltipTrigger> + <TooltipContent side="left"> + <p>{t("tooltips.duplicateRow")}</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + type="button" + variant="ghost" + size="icon" + className={cn( + "h-7 w-7", + fields.length <= 1 && "opacity-50" + )} + onClick={() => fields.length > 1 && remove(rowIndex)} + disabled={fields.length <= 1} + > + <Trash2 className="h-3.5 w-3.5 text-red-500" /> + </Button> + </TooltipTrigger> + <TooltipContent side="left"> + <p>{t("tooltips.deleteRow")}</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + </div> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </div> + + {/* 행 추가 버튼 */} + <Button + type="button" + variant="outline" + className="w-full border-dashed" + onClick={addRow} + disabled={!selectedTagTypeCode || isLoadingSubFields} + > + <Plus className="h-4 w-4 mr-2" /> + {t("buttons.addRow")} + </Button> + </div> + </div> + ); + } + + // --------------- + // Reset IDs/states when dialog closes + // --------------- + React.useEffect(() => { + if (!isOpen) { + fieldIdsRef.current = {} + classOptionIdsRef.current = {} + selectIdRef.current = 0 + } + }, [isOpen]) + + return ( + <Dialog + open={isOpen} + onOpenChange={(o) => { + if (!o) { + form.reset({ + tagType: "", + class: "", + rows: [{ tagNo: "", description: "" }] + }); + setSelectedTagTypeLabel(null); + setSelectedTagTypeCode(null); + setSubFields([]); + } + setIsOpen(o); + }} + > + {/* Only show the trigger if external control is not being used */} + {externalOnOpenChange === undefined && ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Plus className="mr-2 size-4" /> + {t("buttons.addTags")} + </Button> + </DialogTrigger> + )} + + <DialogContent className="max-h-[90vh] max-w-[95vw]" style={{ width: 1500 }}> + <DialogHeader> + <DialogTitle>{t("dialogs.addFormTag")} - {formName || formCode}</DialogTitle> + <DialogDescription> + {t("dialogs.selectClassToLoadFields")} + </DialogDescription> + </DialogHeader> + + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="space-y-6" + > + {/* 클래스 및 태그 유형 선택 */} + <div className="flex gap-4"> + <FormField + key="class-field" + control={form.control} + name="class" + render={({ field }) => renderClassField(field)} + /> + + <FormField + key="tag-type-field" + control={form.control} + name="tagType" + render={({ field }) => renderTagTypeField(field)} + /> + </div> + + {/* 태그 테이블 */} + {renderTagTable()} + + {/* 버튼 */} + <DialogFooter> + <div className="flex items-center gap-2"> + <Button + type="button" + variant="outline" + onClick={() => { + form.reset({ + tagType: "", + class: "", + rows: [{ tagNo: "", description: "" }] + }); + setIsOpen(false); + setSubFields([]); + setSelectedTagTypeLabel(null); + setSelectedTagTypeCode(null); + }} + disabled={isSubmitting} + > + {t("buttons.cancel")} + </Button> + <Button + type="submit" + disabled={isSubmitting || !areAllTagNosValid || fields.length < 1} + > + {isSubmitting ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + {t("messages.processing")} + </> + ) : ( + `${fields.length} ${t("buttons.create")}` + )} + </Button> + </div> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/components/form-data-plant/delete-form-data-dialog.tsx b/components/form-data-plant/delete-form-data-dialog.tsx new file mode 100644 index 00000000..6166b739 --- /dev/null +++ b/components/form-data-plant/delete-form-data-dialog.tsx @@ -0,0 +1,228 @@ +"use client" + +import * as React from "react" +import { Loader, Trash } from "lucide-react" +import { toast } from "sonner" +import { useParams } from "next/navigation" +import { useTranslation } from "@/i18n/client" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" + +import { deleteFormDataByTags } from "@/lib/forms-plant/services" + +interface GenericData { + [key: string]: any + TAG_NO?: string +} + +interface DeleteFormDataDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + formData: GenericData[] + formCode: string + contractItemId: number + showTrigger?: boolean + onSuccess?: () => void + triggerVariant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" +} + +export function DeleteFormDataDialog({ + formData, + formCode, + contractItemId, + showTrigger = true, + onSuccess, + triggerVariant = "outline", + ...props +}: DeleteFormDataDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + const params = useParams(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "engineering"); + + // TAG_NO가 있는 항목들만 필터링 + const validItems = formData.filter(item => item.TAG_IDX?.trim()) + const tagIdxs = validItems.map(item => item.TAG_IDX).filter(Boolean) as string[] + + function onDelete() { + startDeleteTransition(async () => { + if (tagIdxs.length === 0) { + toast.error(t("delete.noValidItems")) + return + } + + const result = await deleteFormDataByTags({ + formCode, + contractItemId, + tagIdxs, + }) + + if (result.error) { + toast.error(result.error) + return + } + + props.onOpenChange?.(false) + + // 성공 메시지 (개수는 같을 것으로 예상) + const deletedCount = result.deletedCount || 0 + const deletedTagsCount = result.deletedTagsCount || 0 + + if (deletedCount !== deletedTagsCount) { + // 데이터 불일치 경고 + console.warn(`Data inconsistency: FormEntries deleted: ${deletedCount}, Tags deleted: ${deletedTagsCount}`) + toast.error( + t("delete.dataInconsistency", { deletedCount, deletedTagsCount }) + ) + } else { + // 정상적인 삭제 완료 + toast.success( + t("delete.successMessage", { + count: deletedCount, + items: deletedCount === 1 ? t("delete.item") : t("delete.items") + }) + ) + } + + onSuccess?.() + }) + } + + const itemCount = tagIdxs.length + const hasValidItems = itemCount > 0 + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button + variant={triggerVariant} + size="sm" + disabled={!hasValidItems} + > + <Trash className="mr-2 size-4" aria-hidden="true" /> + {t("buttons.delete")} ({itemCount}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>{t("delete.confirmTitle")}</DialogTitle> + <DialogDescription> + {t("delete.confirmDescription", { + count: itemCount, + items: itemCount === 1 ? t("delete.item") : t("delete.items") + })} + {itemCount > 0 && ( + <> + <br /> + <br /> + <span className="text-sm text-muted-foreground"> + {t("delete.tagNumbers")}: {tagIdxs.slice(0, 3).join(", ")} + {tagIdxs.length > 3 && t("delete.andMore", { count: tagIdxs.length - 3 })} + </span> + </> + )} + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">{t("buttons.cancel")}</Button> + </DialogClose> + <Button + aria-label={t("delete.deleteButtonLabel")} + variant="destructive" + onClick={onDelete} + disabled={isDeletePending || !hasValidItems} + > + {isDeletePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + {t("buttons.delete")} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button + variant={triggerVariant} + size="sm" + disabled={!hasValidItems} + > + <Trash className="mr-2 size-4" aria-hidden="true" /> + {t("buttons.delete")} ({itemCount}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>{t("delete.confirmTitle")}</DrawerTitle> + <DrawerDescription> + {t("delete.confirmDescription", { + count: itemCount, + items: itemCount === 1 ? t("delete.item") : t("delete.items") + })} + {itemCount > 0 && ( + <> + <br /> + <br /> + <span className="text-sm text-muted-foreground"> + {t("delete.tagNumbers")}: {tagIdxs.slice(0, 3).join(", ")} + {tagIdxs.length > 3 && t("delete.andMore", { count: tagIdxs.length - 3 })} + </span> + </> + )} + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">{t("buttons.cancel")}</Button> + </DrawerClose> + <Button + aria-label={t("delete.deleteButtonLabel")} + variant="destructive" + onClick={onDelete} + disabled={isDeletePending || !hasValidItems} + > + {isDeletePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + {t("buttons.delete")} + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +}
\ No newline at end of file diff --git a/components/form-data-plant/export-excel-form.tsx b/components/form-data-plant/export-excel-form.tsx new file mode 100644 index 00000000..1efa5819 --- /dev/null +++ b/components/form-data-plant/export-excel-form.tsx @@ -0,0 +1,674 @@ +import ExcelJS from "exceljs"; +import { saveAs } from "file-saver"; +import { toast } from "sonner"; + +// Define the column type enum +export type ColumnType = "STRING" | "NUMBER" | "LIST" | string; + +// Define the column structure +export interface DataTableColumnJSON { + key: string; + label: string; + type: ColumnType; + options?: string[]; + shi?: string | null; // Updated to support both string and boolean for backward compatibility + required?: boolean; // Required field indicator + // Add any other properties that might be in columnsJSON +} + +// Define a generic data interface +export interface GenericData { + [key: string]: any; + TAG_NO?: string; // Since TAG_NO seems important in the code +} + +// Define error structure +export interface DataError { + tagNo: string; + rowIndex: number; + columnKey: string; + columnLabel: string; + errorType: string; + errorMessage: string; + currentValue?: any; + expectedFormat?: string; +} + +// Define the options interface for the export function +export interface ExportExcelOptions { + tableData: GenericData[]; + columnsJSON: DataTableColumnJSON[]; + formCode: string; + editableFieldsMap?: Map<string, string[]>; // 새로 추가 + onPendingChange?: (isPending: boolean) => void; + validateData?: boolean; // Option to enable/disable data validation +} + +// Define the return type +export interface ExportExcelResult { + success: boolean; + error?: any; + errorCount?: number; + hasErrors?: boolean; +} + +/** + * Check if a field is editable for a specific TAG_NO + */ +function isFieldEditable( + column: DataTableColumnJSON, + tagNo: string, + editableFieldsMap: Map<string, string[]> +): boolean { + // SHI-only fields (shi === "OUT" or shi === null) are never editable + if (column.shi === "OUT" || column.shi === null) return false; + + // System fields are never editable + if (column.key === "TAG_NO" || column.key === "TAG_DESC" || column.key === "status") return false; + + // If no editableFieldsMap provided, assume all non-SHI fields are editable + if (!editableFieldsMap || editableFieldsMap.size === 0) return true; + + // If TAG_NO not in map, no fields are editable + if (!editableFieldsMap.has(tagNo)) return false; + + // Check if this field is in the editable fields list for this TAG_NO + const editableFields = editableFieldsMap.get(tagNo) || []; + return editableFields.includes(column.key); +} + +/** + * Get the read-only reason for a field + */ +function getReadOnlyReason( + column: DataTableColumnJSON, + tagNo: string, + editableFieldsMap: Map<string, string[]> +): string { + if (column.shi === "OUT" || column.shi === null) { + return "SHI-only field"; + } + + if (column.key === "TAG_NO" || column.key === "TAG_DESC" || column.key === "status") { + return "System field"; + } + + if (!editableFieldsMap || editableFieldsMap.size === 0) { + return "No restrictions"; + } + + if (!editableFieldsMap.has(tagNo)) { + return "No editable fields for this TAG"; + } + + const editableFields = editableFieldsMap.get(tagNo) || []; + if (!editableFields.includes(column.key)) { + return "Not editable for this TAG"; + } + + return "Editable"; +} + +/** + * Validate data and collect errors + */ +function validateTableData( + tableData: GenericData[], + columnsJSON: DataTableColumnJSON[] +): DataError[] { + const errors: DataError[] = []; + const tagNoSet = new Set<string>(); + + tableData.forEach((rowData, index) => { + const rowIndex = index + 2; // Excel row number (header is row 1) + const tagNo = rowData.TAG_NO || `Row-${rowIndex}`; + + // Check for duplicate TAG_NO + if (rowData.TAG_NO) { + if (tagNoSet.has(rowData.TAG_NO)) { + errors.push({ + tagNo, + rowIndex, + columnKey: "TAG_NO", + columnLabel: "TAG NO", + errorType: "DUPLICATE", + errorMessage: "Duplicate TAG_NO found", + currentValue: rowData.TAG_NO, + }); + } else { + tagNoSet.add(rowData.TAG_NO); + } + } + + // Validate each column + columnsJSON.forEach((column) => { + const value = rowData[column.key]; + const isEmpty = value === undefined || value === null || value === ""; + + // Required field validation + if (column.required && isEmpty) { + errors.push({ + tagNo, + rowIndex, + columnKey: column.key, + columnLabel: column.label, + errorType: "REQUIRED", + errorMessage: "Required field is empty", + currentValue: value, + }); + } + + if (!isEmpty) { + // Type validation + switch (column.type) { + case "NUMBER": + if (isNaN(Number(value))) { + errors.push({ + tagNo, + rowIndex, + columnKey: column.key, + columnLabel: column.label, + errorType: "TYPE_MISMATCH", + errorMessage: "Value is not a valid number", + currentValue: value, + expectedFormat: "Number", + }); + } + break; + + case "LIST": + if (column.options && !column.options.includes(String(value))) { + errors.push({ + tagNo, + rowIndex, + columnKey: column.key, + columnLabel: column.label, + errorType: "INVALID_OPTION", + errorMessage: "Value is not in the allowed options list", + currentValue: value, + expectedFormat: column.options.join(", "), + }); + } + break; + + case "STRING": + // Additional string validations can be added here + if (typeof value !== "string" && typeof value !== "number") { + errors.push({ + tagNo, + rowIndex, + columnKey: column.key, + columnLabel: column.label, + errorType: "TYPE_MISMATCH", + errorMessage: "Value is not a valid string", + currentValue: value, + expectedFormat: "String", + }); + } + break; + } + } + }); + }); + + return errors; +} + +/** + * Create error sheet with validation results + */ +function createErrorSheet(workbook: ExcelJS.Workbook, errors: DataError[]) { + const errorSheet = workbook.addWorksheet("Errors"); + + // Error sheet headers + const errorHeaders = [ + "TAG NO", + "Row Number", + "Column", + "Error Type", + "Error Message", + "Current Value", + "Expected Format", + ]; + + errorSheet.addRow(errorHeaders); + + // Style error sheet header + const errorHeaderRow = errorSheet.getRow(1); + errorHeaderRow.font = { bold: true, color: { argb: "FFFFFFFF" } }; + errorHeaderRow.alignment = { horizontal: "center" }; + + errorHeaderRow.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFDC143C" }, // Crimson background + }; + }); + + // Add error data + errors.forEach((error) => { + const errorRow = errorSheet.addRow([ + error.tagNo, + error.rowIndex, + error.columnLabel, + error.errorType, + error.errorMessage, + error.currentValue || "", + error.expectedFormat || "", + ]); + + // Color code by error type + errorRow.eachCell((cell, colNumber) => { + let bgColor = "FFFFFFFF"; // Default white + + switch (error.errorType) { + case "REQUIRED": + bgColor = "FFFFCCCC"; // Light red + break; + case "TYPE_MISMATCH": + bgColor = "FFFFEECC"; // Light orange + break; + case "INVALID_OPTION": + bgColor = "FFFFFFE0"; // Light yellow + break; + case "DUPLICATE": + bgColor = "FFFFE0E0"; // Very light red + break; + } + + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: bgColor }, + }; + }); + }); + + // Auto-fit columns + errorSheet.columns.forEach((column) => { + let maxLength = 0; + column.eachCell({ includeEmpty: false }, (cell) => { + const columnLength = String(cell.value).length; + if (columnLength > maxLength) { + maxLength = columnLength; + } + }); + column.width = Math.min(Math.max(maxLength + 2, 10), 50); + }); + + // Add summary at the top + errorSheet.insertRow(1, [`Total Errors Found: ${errors.length}`]); + const summaryRow = errorSheet.getRow(1); + summaryRow.font = { bold: true, size: 14 }; + if (errors.length > 0) { + summaryRow.getCell(1).fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFFFC0C0" }, // Light red background + }; + } + + // Adjust header row number + const newHeaderRow = errorSheet.getRow(2); + newHeaderRow.font = { bold: true, color: { argb: "FFFFFFFF" } }; + newHeaderRow.alignment = { horizontal: "center" }; + + newHeaderRow.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFDC143C" }, + }; + }); + + return errorSheet; +} + +/** + * Export table data to Excel with data validation for select columns + * @param options Configuration options for Excel export + * @returns Promise with success/error information + */ +export async function exportExcelData({ + tableData, + columnsJSON, + formCode, + editableFieldsMap = new Map(), // 새로 추가 + onPendingChange, + validateData = true +}: ExportExcelOptions): Promise<ExportExcelResult> { + try { + if (onPendingChange) onPendingChange(true); + + // Validate data first if validation is enabled + const errors = validateData ? validateTableData(tableData, columnsJSON) : []; + + // Create a new workbook + const workbook = new ExcelJS.Workbook(); + + // 데이터 시트 생성 + const worksheet = workbook.addWorksheet("Data"); + + // 유효성 검사용 숨김 시트 생성 + const validationSheet = workbook.addWorksheet("ValidationData"); + validationSheet.state = "hidden"; // 시트 숨김 처리 + + // 1. 유효성 검사 시트에 select 옵션 추가 + const selectColumns = columnsJSON.filter( + (col) => col.type === "LIST" && col.options && col.options.length > 0 + ); + + // 유효성 검사 범위 저장 맵 (컬럼 키 -> 유효성 검사 범위) + const validationRanges = new Map<string, string>(); + + selectColumns.forEach((col, idx) => { + const colIndex = idx + 1; + const colLetter = validationSheet.getColumn(colIndex).letter; + + // 헤더 추가 (컬럼 레이블) + validationSheet.getCell(`${colLetter}1`).value = col.label; + + // 옵션 추가 + if (col.options) { + col.options.forEach((option, optIdx) => { + validationSheet.getCell(`${colLetter}${optIdx + 2}`).value = option; + }); + + // 유효성 검사 범위 저장 (ValidationData!$A$2:$A$4 형식) + validationRanges.set( + col.key, + `ValidationData!${colLetter}$2:${colLetter}${ + col.options.length + 1 + }` + ); + } + }); + + // 2. 데이터 시트에 헤더 추가 + const headers = columnsJSON.map((col) => { + let headerLabel = col.label; + if (col.required) { + headerLabel += " *"; // Required fields marked with asterisk + } + return headerLabel; + }); + worksheet.addRow(headers); + + // 헤더 스타일 적용 + const headerRow = worksheet.getRow(1); + headerRow.font = { bold: true }; + headerRow.alignment = { horizontal: "center" }; + + // 각 헤더 셀에 스타일 적용 + headerRow.eachCell((cell, colNumber) => { + const columnIndex = colNumber - 1; + const column = columnsJSON[columnIndex]; + + if (column?.shi === "OUT" || column?.shi === null ) { + // SHI-only 필드는 더 진한 음영으로 표시 + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFFF9999" }, // 연한 빨간색 배경 + }; + cell.font = { bold: true, color: { argb: "FF800000" } }; // 진한 빨간색 글자 + } else if (column?.required) { + // Required 필드는 파란색 배경 + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCE5FF" }, // 연한 파란색 배경 + }; + cell.font = { bold: true, color: { argb: "FF000080" } }; // 진한 파란색 글자 + } else { + // 일반 필드는 기존 스타일 + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, // 연한 회색 배경 + }; + } + }); + + // 3. 데이터 행 추가 + tableData.forEach((rowData, rowIndex) => { + const rowValues = columnsJSON.map((col) => { + const value = rowData[col.key]; + return value !== undefined && value !== null ? value : ""; + }); + const dataRow = worksheet.addRow(rowValues); + + // Get errors for this row + const rowErrors = errors.filter(err => err.rowIndex === rowIndex + 2); + const hasErrors = rowErrors.length > 0; + + // 각 데이터 셀에 적절한 스타일 적용 + dataRow.eachCell((cell, colNumber) => { + const columnIndex = colNumber - 1; + const column = columnsJSON[columnIndex]; + const tagNo = rowData.TAG_NO || ""; + + // Check if this cell has errors + const cellHasError = rowErrors.some(err => err.columnKey === column.key); + + // Check if this field is editable for this specific TAG_NO + const fieldEditable = isFieldEditable(column, tagNo, editableFieldsMap); + const readOnlyReason = getReadOnlyReason(column, tagNo, editableFieldsMap); + + if (!fieldEditable) { + // Read-only field styling + let bgColor = "FFFFCCCC"; // Default light red for read-only + let fontColor = "FF666666"; // Gray text + + if (column?.shi === "OUT" || column?.shi === null ) { + // SHI-only fields get a more distinct styling + bgColor = cellHasError ? "FFFF6666" : "FFFFCCCC"; // Darker red if error + fontColor = "FF800000"; // Dark red text + } else { + // Other read-only fields (editableFieldsMap restrictions) + bgColor = cellHasError ? "FFFFAA99" : "FFFFDDCC"; // Orange-ish tint + fontColor = "FF996633"; // Brown text + } + + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: bgColor }, + }; + cell.font = { italic: true, color: { argb: fontColor } }; + + // Add comment to explain why it's read-only + if (readOnlyReason !== "Editable") { + cell.note = { + texts: [{ text: `Read-only: ${readOnlyReason}` }], + margins: { + insetmode: "custom", + inset: [0.13, 0.13, 0.25, 0.25] + } + }; + } + } else if (cellHasError) { + // Editable field with validation error + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFFFDDDD" }, + }; + cell.font = { color: { argb: "FFCC0000" } }; + } + // If field is editable and has no errors, no special styling needed + }); + }); + + // 4. 데이터 유효성 검사 적용 + const maxRows = 5000; // 데이터 유효성 검사를 적용할 최대 행 수 + + columnsJSON.forEach((col, idx) => { + const colLetter = worksheet.getColumn(idx + 1).letter; + + // LIST 타입이고 유효성 검사 범위가 있는 경우에만 적용 + if (col.type === "LIST" && validationRanges.has(col.key)) { + const validationRange = validationRanges.get(col.key)!; + + // 유효성 검사 정의 + const validation = { + type: "list" as const, + allowBlank: !col.required, + formulae: [validationRange], + showErrorMessage: true, + errorStyle: "warning" as const, + errorTitle: "유효하지 않은 값", + error: "목록에서 값을 선택해주세요.", + }; + + // 모든 데이터 행에 유효성 검사 적용 (최대 maxRows까지) + for ( + let rowIdx = 2; + rowIdx <= Math.min(tableData.length + 1, maxRows); + rowIdx++ + ) { + const cell = worksheet.getCell(`${colLetter}${rowIdx}`); + + // Only apply validation to editable cells + const rowData = tableData[rowIdx - 2]; // rowIdx is 1-based, data array is 0-based + if (rowData) { + const tagNo = rowData.TAG_NO || ""; + const fieldEditable = isFieldEditable(col, tagNo, editableFieldsMap); + + if (fieldEditable) { + cell.dataValidation = validation; + } + } + } + + // 빈 행에도 적용 (최대 maxRows까지) - 기본적으로 편집 가능하다고 가정 + if (tableData.length + 1 < maxRows) { + for ( + let rowIdx = tableData.length + 2; + rowIdx <= maxRows; + rowIdx++ + ) { + worksheet.getCell(`${colLetter}${rowIdx}`).dataValidation = validation; + } + } + } + + // Read-only 필드의 빈 행들에도 음영 처리 적용 (기본적으로 SHI-only 필드에만) + if (col.shi === "OUT" || col.shi === null ) { + for (let rowIdx = tableData.length + 2; rowIdx <= maxRows; rowIdx++) { + const cell = worksheet.getCell(`${colLetter}${rowIdx}`); + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFFFCCCC" }, + }; + cell.font = { italic: true, color: { argb: "FF666666" } }; + } + } + }); + + // 5. 컬럼 너비 자동 조정 + columnsJSON.forEach((col, idx) => { + const column = worksheet.getColumn(idx + 1); + + // 최적 너비 계산 + let maxLength = col.label.length; + tableData.forEach((row) => { + const value = row[col.key]; + if (value !== undefined && value !== null) { + const valueLength = String(value).length; + if (valueLength > maxLength) { + maxLength = valueLength; + } + } + }); + + // 너비 설정 (최소 10, 최대 50) + column.width = Math.min(Math.max(maxLength + 2, 10), 50); + }); + + // 6. 에러 시트 생성 (에러가 있을 경우에만) + if (errors.length > 0) { + createErrorSheet(workbook, errors); + } + + // 7. 범례 추가 (별도 시트) + const legendSheet = workbook.addWorksheet("Legend"); + legendSheet.addRow(["Excel Template Legend"]); + legendSheet.addRow([]); + legendSheet.addRow(["Symbol", "Description"]); + legendSheet.addRow(["Red background header", "SHI-only fields that cannot be edited"]); + legendSheet.addRow(["Blue background header", "Required fields (marked with *)"]); + legendSheet.addRow(["Gray background header", "Regular optional fields"]); + legendSheet.addRow(["Light red background cells", "Cells with validation errors OR SHI-only fields"]); + legendSheet.addRow(["Light orange background cells", "Fields not editable for specific TAG (based on editableFieldsMap)"]); + legendSheet.addRow(["Cell comments", "Hover over read-only cells to see the reason why they cannot be edited"]); + + if (errors.length > 0) { + legendSheet.addRow([]); + legendSheet.addRow([`Note: ${errors.length} validation errors found in the 'Errors' sheet`]); + const errorNoteRow = legendSheet.getRow(legendSheet.rowCount); + errorNoteRow.font = { bold: true, color: { argb: "FFCC0000" } }; + } + + // Add editableFieldsMap summary if available + if (editableFieldsMap.size > 0) { + legendSheet.addRow([]); + legendSheet.addRow([`Editable Fields Map Summary (${editableFieldsMap.size} TAGs):`]); + const summaryHeaderRow = legendSheet.getRow(legendSheet.rowCount); + summaryHeaderRow.font = { bold: true, color: { argb: "FF000080" } }; + + // Show first few examples + let count = 0; + for (const [tagNo, editableFields] of editableFieldsMap) { + if (count >= 5) { // Show only first 5 examples + legendSheet.addRow([`... and ${editableFieldsMap.size - 5} more TAGs`]); + break; + } + legendSheet.addRow([`${tagNo}:`, editableFields.join(", ")]); + count++; + } + } + + // 범례 스타일 적용 + const legendHeaderRow = legendSheet.getRow(1); + legendHeaderRow.font = { bold: true, size: 14 }; + + const legendTableHeader = legendSheet.getRow(3); + legendTableHeader.font = { bold: true }; + legendTableHeader.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, + }; + }); + + // 8. 파일 다운로드 + const buffer = await workbook.xlsx.writeBuffer(); + const fileName = errors.length > 0 + ? `${formCode}_data_with_errors_${new Date().toISOString().slice(0, 10)}.xlsx` + : `${formCode}_data_${new Date().toISOString().slice(0, 10)}.xlsx`; + + saveAs(new Blob([buffer]), fileName); + + const message = errors.length > 0 + ? `Excel 내보내기 완료! (${errors.length}개의 검증 오류 발견)` + : "Excel 내보내기 완료!"; + + toast.success(message); + + return { + success: true, + errorCount: errors.length, + hasErrors: errors.length > 0 + }; + } catch (err) { + console.error("Excel export error:", err); + toast.error("Excel 내보내기 실패."); + return { success: false, error: err }; + } finally { + if (onPendingChange) onPendingChange(false); + } +}
\ No newline at end of file diff --git a/components/form-data-plant/form-data-report-batch-dialog.tsx b/components/form-data-plant/form-data-report-batch-dialog.tsx new file mode 100644 index 00000000..24b5827b --- /dev/null +++ b/components/form-data-plant/form-data-report-batch-dialog.tsx @@ -0,0 +1,444 @@ +"use client"; + +import React, { + FC, + Dispatch, + SetStateAction, + useState, + useEffect, +} from "react"; +import { useParams } from "next/navigation"; +import { useTranslation } from "@/i18n/client"; +import { useToast } from "@/hooks/use-toast"; +import { toast as toastMessage } from "sonner"; +import prettyBytes from "pretty-bytes"; +import { X, Loader2 } from "lucide-react"; +import { saveAs } from "file-saver"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dropzone, + DropzoneDescription, + DropzoneInput, + DropzoneTitle, + DropzoneUploadIcon, + DropzoneZone, +} from "@/components/ui/dropzone"; +import { + FileList, + FileListAction, + FileListDescription, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, +} from "@/components/ui/file-list"; +import { Button } from "@/components/ui/button"; +import { getReportTempList, getOrigin } from "@/lib/forms-plant/services"; +import { DataTableColumnJSON } from "./form-data-table-columns"; +import { PublishDialog } from "./publish-dialog"; + +const MAX_FILE_SIZE = 3000000; + +type ReportData = { + [key: string]: any; +}; + +interface tempFile { + fileName: string; + filePath: string; +} + +interface FormDataReportBatchDialogProps { + open: boolean; + setOpen: Dispatch<SetStateAction<boolean>>; + columnsJSON: DataTableColumnJSON[]; + reportData: ReportData[]; + packageId: number; + formId: number; + formCode: string; +} + +export const FormDataReportBatchDialog: FC<FormDataReportBatchDialogProps> = ({ + open, + setOpen, + columnsJSON, + reportData, + packageId, + formId, + formCode, +}) => { + const { toast } = useToast(); + const params = useParams(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "engineering"); + + const [tempList, setTempList] = useState<tempFile[]>([]); + const [selectTemp, setSelectTemp] = useState<string>(""); + const [selectedFiles, setSelectedFiles] = useState<File[]>([]); + const [isUploading, setIsUploading] = useState(false); + + // Add new state for publish dialog + const [publishDialogOpen, setPublishDialogOpen] = useState<boolean>(false); + const [generatedFileBlob, setGeneratedFileBlob] = useState<Blob | null>(null); + + useEffect(() => { + updateReportTempList(packageId, formId, setTempList); + }, [packageId, formId]); + + const onClose = () => { + if (isUploading) { + return; + } + setOpen(false); + }; + + // 드롭존 - 파일 드랍 처리 + const handleDropAccepted = (acceptedFiles: File[]) => { + const newFiles = [...acceptedFiles]; + setSelectedFiles(newFiles); + }; + + // 드롭존 - 파일 거부(에러) 처리 + const handleDropRejected = (fileRejections: any[]) => { + fileRejections.forEach((rejection) => { + toast({ + variant: "destructive", + title: t("batchReport.fileError"), + description: `${rejection.file.name}: ${ + rejection.errors[0]?.message || t("batchReport.uploadFailed") + }`, + }); + }); + }; + + // 파일 제거 핸들러 + const removeFile = (index: number) => { + const updatedFiles = [...selectedFiles]; + updatedFiles.splice(index, 1); + setSelectedFiles(updatedFiles); + }; + + // Create and download document + const submitData = async () => { + setIsUploading(true); + + try { + const origin = await getOrigin(); + const targetFiles = selectedFiles[0]; + + const reportDatas = reportData.map((c) => { + const reportValue = stringifyAllValues(c); + const reportValueMapping: { [key: string]: any } = {}; + + columnsJSON.forEach((c2) => { + const { key } = c2; + reportValueMapping[key] = reportValue?.[key] ?? ""; + }); + + return reportValueMapping; + }); + + const formData = new FormData(); + formData.append("file", targetFiles); + formData.append("customFileName", `${formCode}.pdf`); + formData.append("reportDatas", JSON.stringify(reportDatas)); + formData.append("reportTempPath", selectTemp); + + const requestCreateReport = await fetch( + `${origin}/api/pdftron/createVendorDataReports`, + { method: "POST", body: formData } + ); + + if (requestCreateReport.ok) { + const blob = await requestCreateReport.blob(); + saveAs(blob, `${formCode}.pdf`); + toastMessage.success(t("batchReport.downloadComplete")); + } else { + const err = await requestCreateReport.json(); + console.error("에러:", err); + throw new Error(err.message); + } + } catch (err) { + console.error(err); + toast({ + title: t("batchReport.error"), + description: t("batchReport.reportGenerationError"), + variant: "destructive", + }); + } finally { + setIsUploading(false); + setSelectedFiles([]); + setOpen(false); + } + }; + + // New function to prepare the file for publishing + const prepareFileForPublishing = async () => { + setIsUploading(true); + + try { + const origin = await getOrigin(); + const targetFiles = selectedFiles[0]; + + const reportDatas = reportData.map((c) => { + const reportValue = stringifyAllValues(c); + const reportValueMapping: { [key: string]: any } = {}; + + columnsJSON.forEach((c2) => { + const { key } = c2; + reportValueMapping[key] = reportValue?.[key] ?? ""; + }); + + return reportValueMapping; + }); + + const formData = new FormData(); + formData.append("file", targetFiles); + formData.append("customFileName", `${formCode}.pdf`); + formData.append("reportDatas", JSON.stringify(reportDatas)); + formData.append("reportTempPath", selectTemp); + + const requestCreateReport = await fetch( + `${origin}/api/pdftron/createVendorDataReports`, + { method: "POST", body: formData } + ); + + if (requestCreateReport.ok) { + const blob = await requestCreateReport.blob(); + setGeneratedFileBlob(blob); + setPublishDialogOpen(true); + toastMessage.success(t("batchReport.documentGenerated")); + } else { + const err = await requestCreateReport.json(); + console.error("에러:", err); + throw new Error(err.message); + } + } catch (err) { + console.error(err); + toast({ + title: t("batchReport.error"), + description: t("batchReport.documentGenerationError"), + variant: "destructive", + }); + } finally { + setIsUploading(false); + } + }; + + return ( + <> + <Dialog open={open} onOpenChange={onClose}> + <DialogContent className="w-[600px]" style={{ maxWidth: "none" }}> + <DialogHeader> + <DialogTitle>{t("batchReport.dialogTitle")}</DialogTitle> + <DialogDescription> + {t("batchReport.dialogDescription")} + </DialogDescription> + </DialogHeader> + <div className="h-[60px]"> + <Label>{t("batchReport.templateSelectLabel")}</Label> + <Select value={selectTemp} onValueChange={setSelectTemp}> + <SelectTrigger className="w-[100%]"> + <SelectValue placeholder={t("batchReport.templateSelectPlaceholder")} /> + </SelectTrigger> + <SelectContent> + {tempList.map((c) => { + const { fileName, filePath } = c; + + return ( + <SelectItem key={filePath} value={filePath}> + {fileName} + </SelectItem> + ); + })} + </SelectContent> + </Select> + </div> + <div> + <Label>{t("batchReport.coverPageUploadLabel")}</Label> + <Dropzone + maxSize={MAX_FILE_SIZE} + multiple={false} + accept={{ accept: [".docx"] }} + onDropAccepted={handleDropAccepted} + onDropRejected={handleDropRejected} + disabled={isUploading} + > + {({ maxSize }) => ( + <> + <DropzoneZone className="flex justify-center"> + <DropzoneInput /> + <div className="flex items-center gap-6"> + <DropzoneUploadIcon /> + <div className="grid gap-0.5"> + <DropzoneTitle>{t("batchReport.dropFileHere")}</DropzoneTitle> + <DropzoneDescription> + {t("batchReport.orClickToSelect", { + maxSize: maxSize ? prettyBytes(maxSize) : t("batchReport.unlimited") + })} + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + <Label className="text-xs text-muted-foreground"> + {t("batchReport.multipleFilesAllowed")} + </Label> + </> + )} + </Dropzone> + </div> + + {selectedFiles.length > 0 && ( + <div className="grid gap-2"> + <div className="flex items-center justify-between"> + <h6 className="text-sm font-semibold"> + {t("batchReport.selectedFiles", { count: selectedFiles.length })} + </h6> + <Badge variant="secondary"> + {t("batchReport.fileCount", { count: selectedFiles.length })} + </Badge> + </div> + <ScrollArea> + <UploadFileItem + selectedFiles={selectedFiles} + removeFile={removeFile} + isUploading={isUploading} + t={t} + /> + </ScrollArea> + </div> + )} + + <DialogFooter> + {/* Add the new Publish button */} + <Button + onClick={prepareFileForPublishing} + disabled={ + selectedFiles.length === 0 || + selectTemp.length === 0 || + isUploading + } + variant="outline" + className="mr-2" + > + {isUploading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + {t("batchReport.publish")} + </Button> + <Button + disabled={ + selectedFiles.length === 0 || + selectTemp.length === 0 || + isUploading + } + onClick={submitData} + > + {isUploading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + {t("batchReport.createDocument")} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + + {/* Add the PublishDialog component */} + <PublishDialog + open={publishDialogOpen} + onOpenChange={setPublishDialogOpen} + packageId={packageId} + formCode={formCode} + fileBlob={generatedFileBlob || undefined} + /> + </> + ); +}; + +interface UploadFileItemProps { + selectedFiles: File[]; + removeFile: (index: number) => void; + isUploading: boolean; + t: (key: string, options?: any) => string; +} + +const UploadFileItem: FC<UploadFileItemProps> = ({ + selectedFiles, + removeFile, + isUploading, + t, +}) => { + return ( + <FileList className="max-h-[200px] gap-3"> + {selectedFiles.map((file, index) => ( + <FileListItem key={index} className="p-3"> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>{file.name}</FileListName> + <FileListDescription> + {prettyBytes(file.size)} + </FileListDescription> + </FileListInfo> + <FileListAction + onClick={() => removeFile(index)} + disabled={isUploading} + > + <X className="h-4 w-4" /> + <span className="sr-only">{t("batchReport.remove")}</span> + </FileListAction> + </FileListHeader> + </FileListItem> + ))} + </FileList> + ); +}; + +type UpdateReportTempList = ( + packageId: number, + formId: number, + setPrevReportTemp: Dispatch<SetStateAction<tempFile[]>> +) => void; + +const updateReportTempList: UpdateReportTempList = async ( + packageId, + formId, + setTempList +) => { + const tempList = await getReportTempList(packageId, formId); + + setTempList( + tempList.map((c) => { + const { fileName, filePath } = c; + return { fileName, filePath }; + }) + ); +}; + +const stringifyAllValues = (obj: any): any => { + if (Array.isArray(obj)) { + return obj.map((item) => stringifyAllValues(item)); + } else if (typeof obj === "object" && obj !== null) { + const result: any = {}; + for (const key in obj) { + result[key] = stringifyAllValues(obj[key]); + } + return result; + } else { + return obj !== null && obj !== undefined ? String(obj) : ""; + } +};
\ No newline at end of file diff --git a/components/form-data-plant/form-data-report-dialog.tsx b/components/form-data-plant/form-data-report-dialog.tsx new file mode 100644 index 00000000..9177ab36 --- /dev/null +++ b/components/form-data-plant/form-data-report-dialog.tsx @@ -0,0 +1,415 @@ +"use client"; + +import React, { + FC, + Dispatch, + SetStateAction, + useState, + useEffect, + useRef, +} from "react"; +import { useParams } from "next/navigation"; +import { useTranslation } from "@/i18n/client"; +import { WebViewerInstance } from "@pdftron/webviewer"; +import { Loader2 } from "lucide-react"; +import { saveAs } from "file-saver"; +import { toast } from "sonner"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +import { Button } from "@/components/ui/button"; +import { getReportTempList } from "@/lib/forms-plant/services"; +import { DataTableColumnJSON } from "./form-data-table-columns"; +import { PublishDialog } from "./publish-dialog"; + +type ReportData = { + [key: string]: any; +}; + +interface tempFile { + fileName: string; + filePath: string; +} + +interface FormDataReportDialogProps { + columnsJSON: DataTableColumnJSON[]; + reportData: ReportData[]; + setReportData: Dispatch<SetStateAction<ReportData[]>>; + packageId: number; + formId: number; + formCode: string; +} + +export const FormDataReportDialog: FC<FormDataReportDialogProps> = ({ + columnsJSON, + reportData, + setReportData, + packageId, + formId, + formCode, +}) => { + const params = useParams(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "engineering"); + + const [tempList, setTempList] = useState<tempFile[]>([]); + const [selectTemp, setSelectTemp] = useState<string>(""); + const [instance, setInstance] = useState<null | WebViewerInstance>(null); + const [fileLoading, setFileLoading] = useState<boolean>(true); + + // Add new state for publish dialog + const [publishDialogOpen, setPublishDialogOpen] = useState<boolean>(false); + const [generatedFileBlob, setGeneratedFileBlob] = useState<Blob | null>(null); + + useEffect(() => { + updateReportTempList(packageId, formId, setTempList); + }, [packageId, formId]); + + const onClose = async (value: boolean) => { + if (fileLoading) { + return; + } + if (!value) { + setTimeout(() => cleanupHtmlStyle(), 1000); + setReportData([]); + } + }; + + const downloadFileData = async () => { + if (instance) { + const { UI, Core } = instance; + const { documentViewer } = Core; + + const doc = documentViewer.getDocument(); + const fileName = doc.getFilename(); + const fileData = await doc.getFileData({ + includeAnnotations: true, // 사용자가 추가한 폼 필드 및 입력 포함 + // officeOptions: { + // outputFormat: "docx", + // }, + }); + + saveAs(new Blob([fileData]), fileName); + + toast.success(t("singleReport.downloadComplete")); + } + }; + + // New function to prepare the file for publishing + const prepareFileForPublishing = async () => { + if (instance) { + try { + const { Core } = instance; + const { documentViewer } = Core; + + const doc = documentViewer.getDocument(); + const fileData = await doc.getFileData({ + includeAnnotations: true, + }); + + setGeneratedFileBlob(new Blob([fileData])); + setPublishDialogOpen(true); + } catch (error) { + console.error("Error preparing file for publishing:", error); + toast.error(t("singleReport.publishPreparationFailed")); + } + } + }; + + return ( + <> + <Dialog open={reportData.length > 0} onOpenChange={onClose}> + <DialogContent className="w-[70vw]" style={{ maxWidth: "none" }}> + <DialogHeader> + <DialogTitle>{t("singleReport.dialogTitle")}</DialogTitle> + <DialogDescription> + {t("singleReport.dialogDescription")} + </DialogDescription> + </DialogHeader> + <div className="h-[60px]"> + <Label>{t("singleReport.templateSelectLabel")}</Label> + <Select + value={selectTemp} + onValueChange={setSelectTemp} + disabled={instance === null} + > + <SelectTrigger className="w-[100%]"> + <SelectValue placeholder={t("singleReport.templateSelectPlaceholder")} /> + </SelectTrigger> + <SelectContent> + {tempList.map((c) => { + const { fileName, filePath } = c; + + return ( + <SelectItem key={filePath} value={filePath}> + {fileName} + </SelectItem> + ); + })} + </SelectContent> + </Select> + </div> + <div className="h-[calc(70vh-60px)]"> + <ReportWebViewer + columnsJSON={columnsJSON} + reportTempPath={selectTemp} + reportDatas={reportData} + instance={instance} + setInstance={setInstance} + setFileLoading={setFileLoading} + formCode={formCode} + t={t} + /> + </div> + + <DialogFooter> + {/* Add the new Publish button */} + <Button + onClick={prepareFileForPublishing} + disabled={selectTemp.length === 0} + variant="outline" + className="mr-2" + > + {t("singleReport.publish")} + </Button> + <Button onClick={downloadFileData} disabled={selectTemp.length === 0}> + {t("singleReport.createDocument")} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + + {/* Add the PublishDialog component */} + <PublishDialog + open={publishDialogOpen} + onOpenChange={setPublishDialogOpen} + packageId={packageId} + formCode={formCode} + fileBlob={generatedFileBlob || undefined} + /> + </> + ); +}; + +// Keep the rest of the component as is... +interface ReportWebViewerProps { + columnsJSON: DataTableColumnJSON[]; + reportTempPath: string; + reportDatas: ReportData[]; + instance: null | WebViewerInstance; + setInstance: Dispatch<SetStateAction<WebViewerInstance | null>>; + setFileLoading: Dispatch<SetStateAction<boolean>>; + formCode: string; + t: (key: string, options?: any) => string; +} + +const ReportWebViewer: FC<ReportWebViewerProps> = ({ + columnsJSON, + reportTempPath, + reportDatas, + instance, + setInstance, + setFileLoading, + formCode, + t, +}) => { + const [viwerLoading, setViewerLoading] = useState<boolean>(true); + const viewer = useRef<HTMLDivElement>(null); + const initialized = React.useRef(false); + const isCancelled = React.useRef(false); // 초기화 중단용 flag + + useEffect(() => { + if (!initialized.current) { + initialized.current = true; + isCancelled.current = false; // 다시 열릴 때는 false로 리셋 + + requestAnimationFrame(() => { + if (viewer.current) { + import("@pdftron/webviewer").then(({ default: WebViewer }) => { + console.log(isCancelled.current); + if (isCancelled.current) { + console.log("📛 WebViewer 초기화 취소됨 (Dialog 닫힘)"); + + return; + } + + WebViewer( + { + path: "/pdftronWeb", + licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, + fullAPI: true, + }, + viewer.current as HTMLDivElement + ).then(async (instance: WebViewerInstance) => { + setInstance(instance); + setViewerLoading(false); + }); + }); + } + }); + } + + return () => { + // cleanup 시에는 중단 flag 세움 + if (instance) { + instance.UI.dispose(); + } + setTimeout(() => cleanupHtmlStyle(), 500); + }; + }, []); + + useEffect(() => { + importReportData( + columnsJSON, + instance, + reportDatas, + reportTempPath, + setFileLoading, + formCode + ); + }, [reportTempPath, reportDatas, instance, columnsJSON, formCode]); + + return ( + <div ref={viewer} className="h-[100%]"> + {viwerLoading && ( + <div className="flex flex-col items-center justify-center py-12"> + <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> + <p className="text-sm text-muted-foreground">{t("singleReport.documentViewerLoading")}</p> + </div> + )} + </div> + ); +}; + +const cleanupHtmlStyle = () => { + const htmlElement = document.documentElement; + + // 기존 style 속성 가져오기 + const originalStyle = htmlElement.getAttribute("style") || ""; + + // "color-scheme: light" 또는 "color-scheme: dark" 찾기 + const colorSchemeStyle = originalStyle + .split(";") + .map((s) => s.trim()) + .find((s) => s.startsWith("color-scheme:")); + + // 새로운 스타일 적용 (color-scheme만 유지) + if (colorSchemeStyle) { + htmlElement.setAttribute("style", colorSchemeStyle + ";"); + } else { + htmlElement.removeAttribute("style"); // color-scheme도 없으면 style 속성 자체 삭제 + } + + console.log("html style 삭제"); +}; + +const stringifyAllValues = (obj: any): any => { + if (Array.isArray(obj)) { + return obj.map((item) => stringifyAllValues(item)); + } else if (typeof obj === "object" && obj !== null) { + const result: any = {}; + for (const key in obj) { + result[key] = stringifyAllValues(obj[key]); + } + return result; + } else { + return obj !== null && obj !== undefined ? String(obj) : ""; + } +}; + +type ImportReportData = ( + columnsJSON: DataTableColumnJSON[], + instance: null | WebViewerInstance, + reportDatas: ReportData[], + reportTempPath: string, + setFileLoading: Dispatch<SetStateAction<boolean>>, + formCode: string +) => void; + +const importReportData: ImportReportData = async ( + columnsJSON, + instance, + reportDatas, + reportTempPath, + setFileLoading, + formCode +) => { + setFileLoading(true); + try { + if (instance && reportDatas.length > 0 && reportTempPath.length > 0) { + const { UI, Core } = instance; + const { documentViewer, createDocument } = Core; + + const getFileData = await fetch(reportTempPath); + const reportFileBlob = await getFileData.blob(); + + const reportData = reportDatas[0]; + const reportValue = stringifyAllValues(reportData); + + const reportValueMapping: { [key: string]: any } = {}; + + columnsJSON.forEach((c) => { + const { key, label } = c; + + // const objKey = label.split(" ").join("_"); + + reportValueMapping[key] = reportValue?.[key] ?? ""; + }); + + const doc = await createDocument(reportFileBlob, { + filename: `${formCode}_report.docx`, + extension: "docx", + }); + + await doc.applyTemplateValues(reportValueMapping); + + documentViewer.loadDocument(doc, { + extension: "docx", + enableOfficeEditing: true, + officeOptions: { + formatOptions: { + applyPageBreaksToSheet: true, + }, + }, + }); + } + } catch (err) { + } finally { + setFileLoading(false); + } +}; + +type UpdateReportTempList = ( + packageId: number, + formId: number, + setPrevReportTemp: Dispatch<SetStateAction<tempFile[]>> +) => void; + +const updateReportTempList: UpdateReportTempList = async ( + packageId, + formId, + setTempList +) => { + const tempList = await getReportTempList(packageId, formId); + + setTempList( + tempList.map((c) => { + const { fileName, filePath } = c; + return { fileName, filePath }; + }) + ); +};
\ No newline at end of file diff --git a/components/form-data-plant/form-data-report-temp-upload-dialog.tsx b/components/form-data-plant/form-data-report-temp-upload-dialog.tsx new file mode 100644 index 00000000..59ea6ade --- /dev/null +++ b/components/form-data-plant/form-data-report-temp-upload-dialog.tsx @@ -0,0 +1,101 @@ +"use client"; + +import React, { FC, Dispatch, SetStateAction, useState } from "react"; +import { useParams } from "next/navigation"; +import { useTranslation } from "@/i18n/client"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { VarListDownloadBtn } from "./var-list-download-btn"; +import { FormDataReportTempUploadTab } from "./form-data-report-temp-upload-tab"; +import { FormDataReportTempUploadedListTab } from "./form-data-report-temp-uploaded-list-tab"; +import { DataTableColumnJSON } from "./form-data-table-columns"; +import { FileActionsDropdown } from "../ui/file-actions"; + +interface FormDataReportTempUploadDialogProps { + columnsJSON: DataTableColumnJSON[]; + open: boolean; + setOpen: Dispatch<SetStateAction<boolean>>; + packageId: number; + formCode: string; + formId: number; + uploaderType: string; +} + +export const FormDataReportTempUploadDialog: FC< + FormDataReportTempUploadDialogProps +> = ({ + columnsJSON, + open, + setOpen, + packageId, + formId, + formCode, + uploaderType, +}) => { + const params = useParams(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "engineering"); + + const [tabValue, setTabValue] = useState<"upload" | "uploaded">("upload"); + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogContent className="w-[600px]" style={{ maxWidth: "none" }}> + <DialogHeader className="gap-2"> + <DialogTitle>{t("templateUpload.dialogTitle")}</DialogTitle> + <DialogDescription className="flex justify-around gap-[16px] "> + <FileActionsDropdown + filePath={"/vendorFormReportSample/sample_template_file.docx"} + fileName={"sample_template_file.docx"} + variant="ghost" + size="icon" + description={t("templateUpload.sampleFile")} + /> + <VarListDownloadBtn columnsJSON={columnsJSON} formCode={formCode} /> + </DialogDescription> + </DialogHeader> + <Tabs value={tabValue}> + <div className="flex justify-between items-center"> + <TabsList className="w-full"> + <TabsTrigger + value="upload" + onClick={() => setTabValue("upload")} + className="flex-1" + > + {t("templateUpload.uploadTab")} + </TabsTrigger> + <TabsTrigger + value="uploaded" + onClick={() => setTabValue("uploaded")} + className="flex-1" + > + {t("templateUpload.uploadedListTab")} + </TabsTrigger> + </TabsList> + </div> + <TabsContent value="upload"> + <FormDataReportTempUploadTab + packageId={packageId} + formId={formId} + uploaderType={uploaderType} + /> + </TabsContent> + <TabsContent value="uploaded"> + <FormDataReportTempUploadedListTab + packageId={packageId} + formId={formId} + /> + </TabsContent> + </Tabs> + </DialogContent> + </Dialog> + ); +};
\ No newline at end of file diff --git a/components/form-data-plant/form-data-report-temp-upload-tab.tsx b/components/form-data-plant/form-data-report-temp-upload-tab.tsx new file mode 100644 index 00000000..81186ba4 --- /dev/null +++ b/components/form-data-plant/form-data-report-temp-upload-tab.tsx @@ -0,0 +1,243 @@ +"use client"; + +import React, { FC, useState } from "react"; +import { useParams } from "next/navigation"; +import { useTranslation } from "@/i18n/client"; +import { useToast } from "@/hooks/use-toast"; +import { toast as toastMessage } from "sonner"; +import prettyBytes from "pretty-bytes"; +import { X, Loader2 } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { DialogFooter } from "@/components/ui/dialog"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { + Dropzone, + DropzoneDescription, + DropzoneInput, + DropzoneTitle, + DropzoneUploadIcon, + DropzoneZone, +} from "@/components/ui/dropzone"; +import { + FileList, + FileListAction, + FileListDescription, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, +} from "@/components/ui/file-list"; +import { uploadReportTemp } from "@/lib/forms-plant/services"; + +// 최대 파일 크기 설정 (3000MB) +const MAX_FILE_SIZE = 3000000; + +interface FormDataReportTempUploadTabProps { + packageId: number; + formId: number; + uploaderType: string; +} + +export const FormDataReportTempUploadTab: FC< + FormDataReportTempUploadTabProps +> = ({ packageId, formId, uploaderType }) => { + const { toast } = useToast(); + const params = useParams(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "engineering"); + + const [selectedFiles, setSelectedFiles] = useState<File[]>([]); + const [isUploading, setIsUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + + // 드롭존 - 파일 드랍 처리 + const handleDropAccepted = (acceptedFiles: File[]) => { + const newFiles = [...selectedFiles, ...acceptedFiles]; + setSelectedFiles(newFiles); + }; + + // 드롭존 - 파일 거부(에러) 처리 + const handleDropRejected = (fileRejections: any[]) => { + fileRejections.forEach((rejection) => { + toast({ + variant: "destructive", + title: t("templateUploadTab.fileError"), + description: `${rejection.file.name}: ${ + rejection.errors[0]?.message || t("templateUploadTab.uploadFailed") + }`, + }); + }); + }; + + // 파일 제거 핸들러 + const removeFile = (index: number) => { + const updatedFiles = [...selectedFiles]; + updatedFiles.splice(index, 1); + setSelectedFiles(updatedFiles); + }; + + const submitData = async () => { + setIsUploading(true); + setUploadProgress(0); + try { + const totalFiles = selectedFiles.length; + let successCount = 0; + + for (let i = 0; i < totalFiles; i++) { + const file = selectedFiles[i]; + + const formData = new FormData(); + formData.append("file", file); + formData.append("customFileName", file.name); + formData.append("uploaderType", uploaderType); + + await uploadReportTemp(packageId, formId, formData); + + successCount++; + setUploadProgress(Math.round((successCount / totalFiles) * 100)); + } + toastMessage.success(t("templateUploadTab.uploadComplete")); + } catch (err) { + console.error(err); + toast({ + title: t("templateUploadTab.error"), + description: t("templateUploadTab.uploadError"), + variant: "destructive", + }); + } finally { + setIsUploading(false); + setUploadProgress(0); + setSelectedFiles([]) + } + }; + + return ( + <div className='flex flex-col gap-4'> + <div> + <Label>{t("templateUploadTab.uploadLabel")}</Label> + <Dropzone + maxSize={MAX_FILE_SIZE} + multiple={true} + accept={{ accept: [".docx"] }} + onDropAccepted={handleDropAccepted} + onDropRejected={handleDropRejected} + disabled={isUploading} + > + {({ maxSize }) => ( + <> + <DropzoneZone className="flex justify-center"> + <DropzoneInput /> + <div className="flex items-center gap-6"> + <DropzoneUploadIcon /> + <div className="grid gap-0.5"> + <DropzoneTitle>{t("templateUploadTab.dropFileHere")}</DropzoneTitle> + <DropzoneDescription> + {t("templateUploadTab.orClickToSelect", { + maxSize: maxSize ? prettyBytes(maxSize) : t("templateUploadTab.unlimited") + })} + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + <Label className="text-xs text-muted-foreground"> + {t("templateUploadTab.multipleFilesAllowed")} + </Label> + </> + )} + </Dropzone> + </div> + + {selectedFiles.length > 0 && ( + <div className="grid gap-2"> + <div className="flex items-center justify-between"> + <h6 className="text-sm font-semibold"> + {t("templateUploadTab.selectedFiles", { count: selectedFiles.length })} + </h6> + <Badge variant="secondary"> + {t("templateUploadTab.fileCount", { count: selectedFiles.length })} + </Badge> + </div> + <ScrollArea> + <UploadFileItem + selectedFiles={selectedFiles} + removeFile={removeFile} + isUploading={isUploading} + t={t} + /> + </ScrollArea> + </div> + )} + + {isUploading && <UploadProgressBox uploadProgress={uploadProgress} t={t} />} + <DialogFooter> + <Button disabled={selectedFiles.length === 0} onClick={submitData}> + {t("templateUploadTab.upload")} + </Button> + </DialogFooter> + </div> + ); +}; + +interface UploadFileItemProps { + selectedFiles: File[]; + removeFile: (index: number) => void; + isUploading: boolean; + t: (key: string, options?: any) => string; +} + +const UploadFileItem: FC<UploadFileItemProps> = ({ + selectedFiles, + removeFile, + isUploading, + t, +}) => { + return ( + <FileList className="max-h-[150px] gap-3"> + {selectedFiles.map((file, index) => ( + <FileListItem key={index} className="p-3"> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>{file.name}</FileListName> + <FileListDescription> + {prettyBytes(file.size)} + </FileListDescription> + </FileListInfo> + <FileListAction + onClick={() => removeFile(index)} + disabled={isUploading} + > + <X className="h-4 w-4" /> + <span className="sr-only">{t("templateUploadTab.remove")}</span> + </FileListAction> + </FileListHeader> + </FileListItem> + ))} + </FileList> + ); +}; + +const UploadProgressBox: FC<{ + uploadProgress: number; + t: (key: string, options?: any) => string; +}> = ({ uploadProgress, t }) => { + return ( + <div className="flex flex-col gap-1 mt-2"> + <div className="flex items-center gap-2"> + <Loader2 className="h-4 w-4 animate-spin" /> + <span className="text-sm"> + {t("templateUploadTab.uploadingProgress", { progress: uploadProgress })} + </span> + </div> + <div className="h-2 w-full bg-muted rounded-full overflow-hidden"> + <div + className="h-full bg-primary rounded-full transition-all" + style={{ width: `${uploadProgress}%` }} + /> + </div> + </div> + ); +};
\ No newline at end of file diff --git a/components/form-data-plant/form-data-report-temp-uploaded-list-tab.tsx b/components/form-data-plant/form-data-report-temp-uploaded-list-tab.tsx new file mode 100644 index 00000000..4cfbad69 --- /dev/null +++ b/components/form-data-plant/form-data-report-temp-uploaded-list-tab.tsx @@ -0,0 +1,218 @@ +"use client"; + +import React, { + FC, + Dispatch, + SetStateAction, + useState, + useEffect, +} from "react"; +import { useParams } from "next/navigation"; +import { useTranslation } from "@/i18n/client"; +import { useToast } from "@/hooks/use-toast"; +import { toast as toastMessage } from "sonner"; +import { Download, Trash2 } from "lucide-react"; +import { saveAs } from "file-saver"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Label } from "@/components/ui/label"; +import { + FileList, + FileListAction, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, +} from "@/components/ui/file-list"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { getReportTempList, deleteReportTempFile } from "@/lib/forms-plant/services"; +import { VendorDataReportTemps } from "@/db/schema/vendorData"; + +interface FormDataReportTempUploadedListTabProps { + packageId: number; + formId: number; +} + +export const FormDataReportTempUploadedListTab: FC< + FormDataReportTempUploadedListTabProps +> = ({ packageId, formId }) => { + const params = useParams(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "engineering"); + + const [prevReportTemp, setPrevReportTemp] = useState<VendorDataReportTemps[]>( + [] + ); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const getTempFiles = async () => { + await updateReportTempList(packageId, formId, setPrevReportTemp); + setIsLoading(false); + }; + + getTempFiles(); + }, [packageId, formId]); + + return ( + <div> + <Label>{t("templateUploadedList.listLabel")}</Label> + <UploadedTempFiles + prevReportTemp={prevReportTemp} + updateReportTempList={() => + updateReportTempList(packageId, formId, setPrevReportTemp) + } + isLoading={isLoading} + t={t} + /> + </div> + ); +}; + +type UpdateReportTempList = ( + packageId: number, + formId: number, + setPrevReportTemp: Dispatch<SetStateAction<VendorDataReportTemps[]>> +) => Promise<void>; + +const updateReportTempList: UpdateReportTempList = async ( + packageId, + formId, + setPrevReportTemp +) => { + const tempList = await getReportTempList(packageId, formId); + setPrevReportTemp(tempList); +}; + +interface UploadedTempFiles { + prevReportTemp: VendorDataReportTemps[]; + updateReportTempList: () => void; + isLoading: boolean; + t: (key: string, options?: any) => string; +} + +const UploadedTempFiles: FC<UploadedTempFiles> = ({ + prevReportTemp, + updateReportTempList, + isLoading, + t, +}) => { + const { toast } = useToast(); + + const downloadTempFile = async (fileName: string, filePath: string) => { + try { + const getTempFile = await fetch(filePath); + + if (getTempFile.ok) { + const blob = await getTempFile.blob(); + + saveAs(blob, fileName); + + toastMessage.success(t("templateUploadedList.downloadComplete")); + } else { + const err = await getTempFile.json(); + console.error("에러:", err); + throw new Error(err.message); + } + } catch (err) { + console.error(err); + toast({ + title: t("templateUploadedList.error"), + description: t("templateUploadedList.downloadError"), + variant: "destructive", + }); + } + }; + + const deleteTempFile = async (id: number) => { + try { + const { result, error } = await deleteReportTempFile(id); + + if (result) { + updateReportTempList(); + toastMessage.success(t("templateUploadedList.deleteComplete")); + } else { + throw new Error(error); + } + } catch (err) { + toast({ + title: t("templateUploadedList.error"), + description: t("templateUploadedList.deleteError"), + variant: "destructive", + }); + } + }; + + if (isLoading) { + return ( + <div className="min-h-[157px]"> + <Label>{t("templateUploadedList.loading")}</Label> + </div> + ); + } + + return ( + <ScrollArea className="min-h-[157px] max-h-[337px] overflow-auto"> + <FileList className="gap-3"> + {prevReportTemp.map((c) => { + const { fileName, filePath, id } = c; + + return ( + <AlertDialog key={id}> + <FileListItem className="p-3"> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>{fileName}</FileListName> + </FileListInfo> + <FileListAction + onClick={() => { + downloadTempFile(fileName, filePath); + }} + > + <Download className="h-4 w-4" /> + <span className="sr-only">{t("templateUploadedList.download")}</span> + </FileListAction> + <AlertDialogTrigger asChild> + <FileListAction> + <Trash2 className="h-4 w-4" /> + <span className="sr-only">{t("templateUploadedList.delete")}</span> + </FileListAction> + </AlertDialogTrigger> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle> + {t("templateUploadedList.deleteConfirmTitle", { fileName })} + </AlertDialogTitle> + <AlertDialogDescription /> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>{t("templateUploadedList.cancel")}</AlertDialogCancel> + <AlertDialogAction + onClick={() => { + deleteTempFile(id); + }} + > + {t("templateUploadedList.delete")} + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </FileListHeader> + </FileListItem> + </AlertDialog> + ); + })} + </FileList> + </ScrollArea> + ); +};
\ No newline at end of file diff --git a/components/form-data-plant/form-data-table-columns.tsx b/components/form-data-plant/form-data-table-columns.tsx new file mode 100644 index 00000000..d453f6c2 --- /dev/null +++ b/components/form-data-plant/form-data-table-columns.tsx @@ -0,0 +1,546 @@ +import type { ColumnDef, Row } from "@tanstack/react-table"; +import { ClientDataTableColumnHeaderSimple } from "../client-data-table/data-table-column-simple-header"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Badge } from "@/components/ui/badge"; +import { Ellipsis } from "lucide-react"; +import { formatDate } from "@/lib/utils"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { toast } from 'sonner'; +import { createFilterFn } from "@/components/client-data-table/table-filters"; + +/** row 액션 관련 타입 */ +export interface DataTableRowAction<TData> { + row: Row<TData>; + type: "open" | "edit" | "update" | "delete"; +} + +/** 컬럼 타입 (필요에 따라 확장) */ +export type ColumnType = "STRING" | "NUMBER" | "LIST"; + +export interface DataTableColumnJSON { + key: string; + /** 실제 Excel 등에서 구분용으로 쓰이는 label (고정) */ + label: string; + + /** UI 표시용 label (예: 단위를 함께 표시) */ + displayLabel?: string; + + type: ColumnType; + options?: string[]; + uom?: string; + uomId?: string; + shi?: string; + + /** 템플릿에서 가져온 추가 정보 */ + hidden?: boolean; // true이면 컬럼 숨김 + seq?: number; // 정렬 순서 + head?: string; // 헤더 텍스트 (우선순위 가장 높음) +} + +// Register 인터페이스 추가 +export interface Register { + PROJ_NO: string; + TYPE_ID: string; + EP_ID: string; + DESC: string; + REMARK: string | null; + NEW_TAG_YN: boolean; + ALL_TAG_YN: boolean; + VND_YN: boolean; + SEQ: number; + CMPLX_YN: boolean; + CMPL_SETT: any | null; + MAP_ATT: Array<{ ATT_ID: string; [key: string]: any }>; + MAP_CLS_ID: string[]; + MAP_OPER: any | null; + LNK_ATT: any[]; + JOIN_TABLS: any[]; + DELETED: boolean; + CRTER_NO: string; + CRTE_DTM: string; + CHGER_NO: string | null; + CHGE_DTM: string | null; + _id: string; +} + +/** + * getColumns 함수에 필요한 props + * - TData: 테이블에 표시할 행(Row)의 타입 + */ +interface GetColumnsProps<TData> { + columnsJSON: DataTableColumnJSON[]; + setRowAction: React.Dispatch< + React.SetStateAction<DataTableRowAction<TData> | null> + >; + setReportData: React.Dispatch<React.SetStateAction<{ [key: string]: any }[]>>; + tempCount: number; + // 체크박스 선택 관련 props + selectedRows?: Record<string, boolean>; + onRowSelectionChange?: (updater: Record<string, boolean> | ((prev: Record<string, boolean>) => Record<string, boolean>)) => void; + // 새로 추가: templateData + templateData?: any; + // 새로 추가: registers (필수 필드 체크용) + registers?: Register[]; +} + +/** + * 셀 주소(예: "A1", "B1", "AA1")에서 컬럼 순서를 추출하는 함수 + * A=0, B=1, C=2, ..., Z=25, AA=26, AB=27, ... + */ +function getColumnOrderFromCellAddress(cellAddress: string): number { + if (!cellAddress || typeof cellAddress !== 'string') { + return 999999; // 유효하지 않은 경우 맨 뒤로 + } + + // 셀 주소에서 알파벳 부분만 추출 (예: "A1" -> "A", "AA1" -> "AA") + const match = cellAddress.match(/^([A-Z]+)/); + if (!match) { + return 999999; + } + + const columnLetters = match[1]; + let result = 0; + + // 알파벳을 숫자로 변환 (26진법과 유사하지만 0이 없는 체계) + for (let i = 0; i < columnLetters.length; i++) { + const charCode = columnLetters.charCodeAt(i) - 65 + 1; // A=1, B=2, ..., Z=26 + result = result * 26 + charCode; + } + + return result - 1; // 0부터 시작하도록 조정 +} + +/** + * templateData에서 SPREAD_LIST의 컬럼 순서 정보를 추출하여 seq를 업데이트하는 함수 + */ +function updateSeqFromTemplate(columnsJSON: DataTableColumnJSON[], templateData: any): DataTableColumnJSON[] { + if (!templateData) { + return columnsJSON; // templateData가 없으면 원본 그대로 반환 + } + + // templateData가 배열인지 단일 객체인지 확인 + let templates: any[]; + if (Array.isArray(templateData)) { + templates = templateData; + } else { + templates = [templateData]; + } + + // SPREAD_LIST 타입의 템플릿 찾기 + const spreadListTemplate = templates.find(template => + template.TMPL_TYPE === 'SPREAD_LIST' && + template.SPR_LST_SETUP?.DATA_SHEETS + ); + + if (!spreadListTemplate) { + return columnsJSON; // SPREAD_LIST 템플릿이 없으면 원본 그대로 반환 + } + + // MAP_CELL_ATT에서 ATT_ID와 IN 매핑 정보 추출 + const cellMappings = new Map<string, string>(); // key: ATT_ID, value: IN (셀 주소) + + spreadListTemplate.SPR_LST_SETUP.DATA_SHEETS.forEach((dataSheet: any) => { + if (dataSheet.MAP_CELL_ATT) { + dataSheet.MAP_CELL_ATT.forEach((mapping: any) => { + if (mapping.ATT_ID && mapping.IN) { + cellMappings.set(mapping.ATT_ID, mapping.IN); + } + }); + } + }); + + // columnsJSON을 복사하여 seq 값 업데이트 + const updatedColumns = columnsJSON.map(column => { + const cellAddress = cellMappings.get(column.key); + if (cellAddress) { + // 셀 주소에서 컬럼 순서 추출 + const newSeq = getColumnOrderFromCellAddress(cellAddress); + console.log(`🔄 Updating seq for ${column.key}: ${column.seq} -> ${newSeq} (from ${cellAddress})`); + + return { + ...column, + seq: newSeq + }; + } + return column; // 매핑이 없으면 원본 그대로 + }); + + return updatedColumns; +} + +/** + * Register의 MAP_ATT에 해당 ATT_ID가 있는지 확인하는 함수 + * 필수 필드인지 체크 + */ +function isRequiredField(attId: string, registers?: Register[]): boolean { + if (!registers || registers.length === 0) { + return false; + } + + // 모든 레지스터의 MAP_ATT를 확인 + return registers.some(register => + register.MAP_ATT && + register.MAP_ATT.some(att => att.ATT_ID === attId) + ); +} + +/** + * status 값에 따라 Badge variant를 결정하는 헬퍼 함수 + */ +function getStatusBadgeVariant(status: string): "default" | "secondary" | "destructive" | "outline" { + const statusStr = String(status).toLowerCase(); + + switch (statusStr) { + case 'NEW': + case 'New': + return 'default'; // 초록색 계열 + case 'Updated or Modified': + return 'secondary'; // 노란색 계열 + case 'inactive': + case 'rejected': + case 'failed': + case 'cancelled': + return 'destructive'; // 빨간색 계열 + default: + return 'outline'; // 기본 회색 계열 + } +} + +/** + * 헤더 텍스트를 결정하는 헬퍼 함수 + * displayLabel이 있으면 사용, 없으면 label 사용 + * 필수 필드인 경우 빨간색 * 추가 + */ +function getHeaderText(col: DataTableColumnJSON, isRequired: boolean): React.ReactNode { + const baseText = col.displayLabel && col.displayLabel.trim() ? col.displayLabel : col.label; + + if (isRequired) { + return ( + <span> + {baseText} + <span style={{ color: 'red', marginLeft: '2px' }}>*</span> + </span> + ); + } + + return baseText; +} + +/** + * 컬럼들을 seq 순서대로 배치하면서 연속된 같은 head를 가진 컬럼들을 그룹으로 묶는 함수 + */ +function groupColumnsByHead(columns: DataTableColumnJSON[], registers?: Register[]): ColumnDef<any>[] { + const result: ColumnDef<any>[] = []; + let i = 0; + + while (i < columns.length) { + const currentCol = columns[i]; + + // head가 없거나 빈 문자열인 경우 일반 컬럼으로 처리 + if (!currentCol.head || !currentCol.head.trim()) { + result.push(createColumnDef(currentCol, false, registers)); + i++; + continue; + } + + // 같은 head를 가진 연속된 컬럼들을 찾기 + const groupHead = currentCol.head.trim(); + const groupColumns: DataTableColumnJSON[] = [currentCol]; + let j = i + 1; + + while (j < columns.length && columns[j].head && columns[j].head.trim() === groupHead) { + groupColumns.push(columns[j]); + j++; + } + + // 그룹에 컬럼이 하나만 있으면 일반 컬럼으로 처리 + if (groupColumns.length === 1) { + result.push(createColumnDef(currentCol, false, registers)); + } else { + // 그룹 컬럼 생성 (구분선 스타일 적용) + const groupColumn: ColumnDef<any> = { + id: `group-${groupHead.replace(/\s+/g, '-')}`, + header: groupHead, + columns: groupColumns.map(col => createColumnDef(col, true, registers)), + meta: { + isGroupColumn: true, + groupBorders: true, // 그룹 구분선 표시 플래그 + } + }; + result.push(groupColumn); + } + + i = j; // 다음 그룹으로 이동 + } + + return result; +} + +/** + * 개별 컬럼 정의를 생성하는 헬퍼 함수 + */ +function createColumnDef(col: DataTableColumnJSON, isInGroup: boolean = false, registers?: Register[]): ColumnDef<any> { + const isRequired = isRequiredField(col.key, registers); + + return { + accessorKey: col.key, + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple + column={column} + title={getHeaderText(col, isRequired)} + /> + ), + + filterFn: col.type === 'NUMBER' ? createFilterFn("number") : col.type === 'LIST' ? createFilterFn("multi-select"):createFilterFn("text"), + + meta: { + excelHeader: col.label, + minWidth: 80, + paddingFactor: 1.2, + maxWidth: col.key === "TAG_NO" ? 120 : 150, + isReadOnly: col.shi === true, + isInGroup, // 그룹 내 컬럼인지 표시 + groupBorders: isInGroup, // 그룹 구분선 표시 플래그 + isRequired, // 필수 필드 표시 + }, + + cell: ({ row }) => { + const cellValue = row.getValue(col.key); + + // SHI 필드만 읽기 전용으로 처리 + const isReadOnly = col.shi === true; + + // 그룹 구분선 스타일 클래스 추가 + const groupBorderClass = isInGroup ? "group-column-border" : ""; + const readOnlyClass = isReadOnly ? "read-only-cell" : ""; + const combinedClass = [groupBorderClass, readOnlyClass].filter(Boolean).join(" "); + + const cellStyle = { + ...(isReadOnly && { backgroundColor: '#f5f5f5', color: '#666', cursor: 'not-allowed' }), + ...(isInGroup && { + borderLeft: '2px solid #e2e8f0', + borderRight: '2px solid #e2e8f0', + position: 'relative' as const + }) + }; + + // 툴팁 메시지 설정 (SHI 필드만) + const tooltipMessage = isReadOnly ? "SHI 전용 필드입니다" : ""; + + // status 컬럼인 경우 Badge 적용 + if (col.key === "status") { + const statusValue = String(cellValue ?? ""); + const badgeVariant = getStatusBadgeVariant(statusValue); + + return ( + <div + className={combinedClass} + style={cellStyle} + title={tooltipMessage} + > + <Badge variant={badgeVariant}> + {statusValue} + </Badge> + </div> + ); + } + + // 데이터 타입별 처리 + switch (col.type) { + case "NUMBER": + return ( + <div + className={combinedClass} + style={cellStyle} + title={tooltipMessage} + > + {cellValue ? Number(cellValue).toLocaleString() : ""} + </div> + ); + + case "LIST": + return ( + <div + className={combinedClass} + style={cellStyle} + title={tooltipMessage} + > + {String(cellValue ?? "")} + </div> + ); + + case "STRING": + default: + return ( + <div + className={combinedClass} + style={cellStyle} + title={tooltipMessage} + > + {String(cellValue ?? "")} + </div> + ); + } + }, + }; +} + +/** + * getColumns 함수 + * 1) columnsJSON 배열을 필터링 (hidden이 true가 아닌 것들만) + * 2) seq에 따라 정렬 + * 3) seq 순서를 유지하면서 연속된 같은 head를 가진 컬럼들을 그룹으로 묶기 + * 4) 체크박스 컬럼 추가 + * 5) 마지막에 "Action" 칼럼 추가 + */ +export function getColumns<TData extends object>({ + columnsJSON, + setRowAction, + setReportData, + tempCount, + selectedRows = {}, + onRowSelectionChange, + templateData, // 새로 추가된 매개변수 + registers, // 필수 필드 체크를 위한 레지스터 데이터 +}: GetColumnsProps<TData>): ColumnDef<TData>[] { + const columns: ColumnDef<TData>[] = []; + + // (0) templateData에서 SPREAD_LIST인 경우 seq 값 업데이트 + const processedColumnsJSON = updateSeqFromTemplate(columnsJSON, templateData); + + // (1) 컬럼 필터링 및 정렬 + const visibleColumns = processedColumnsJSON + .filter(col => col.hidden !== true) // hidden이 true가 아닌 것들만 + .sort((a, b) => { + // seq가 없는 경우 999999로 처리하여 맨 뒤로 보냄 + const seqA = a.seq !== undefined ? a.seq : 999999; + const seqB = b.seq !== undefined ? b.seq : 999999; + return seqA - seqB; + }); + + console.log('📊 Final column order after template processing:', + visibleColumns.map(c => `${c.key}(seq:${c.seq})`)); + + // (2) 체크박스 컬럼 (항상 표시) + const selectColumn: ColumnDef<TData> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => { + table.toggleAllPageRowsSelected(!!value); + + // 모든 행 선택/해제 + if (onRowSelectionChange) { + const allRowsSelection: Record<string, boolean> = {}; + table.getRowModel().rows.forEach((row) => { + allRowsSelection[row.id] = !!value; + }); + onRowSelectionChange(allRowsSelection); + } + }} + aria-label="Select all" + className="translate-y-[2px]" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => { + row.toggleSelected(!!value); + + // 개별 행 선택 상태 업데이트 + if (onRowSelectionChange) { + onRowSelectionChange(prev => ({ + ...prev, + [row.id]: !!value + })); + } + }} + aria-label="Select row" + className="translate-y-[2px]" + /> + ), + enableSorting: false, + enableHiding: false, + enablePinning: true, + size: 40, + }; + columns.push(selectColumn); + + // (3) 기본 컬럼들 (seq 순서를 유지하면서 head에 따라 그룹핑 처리) + const groupedColumns = groupColumnsByHead(visibleColumns, registers); + columns.push(...groupedColumns); + + // (4) 액션 칼럼 - update 버튼 예시 + const actionColumn: ColumnDef<TData> = { + id: "update", + header: "", + cell: ({ row }) => ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItem + onSelect={() => { + setRowAction({ row, type: "update" }); + }} + > + Edit + </DropdownMenuItem> + <DropdownMenuItem + onSelect={() => { + if(tempCount > 0){ + const { original } = row; + setReportData([original]); + } else { + toast.error("업로드된 Template File이 없습니다."); + } + }} + > + Create Document + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem + onSelect={() => { + setRowAction({ row, type: "delete" }); + }} + className="text-red-600 focus:text-red-600" + > + Delete + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ), + size: 40, + enablePinning: true, + }; + + columns.push(actionColumn); + + // (5) 최종 반환 + return columns; +}
\ No newline at end of file diff --git a/components/form-data-plant/form-data-table.tsx b/components/form-data-plant/form-data-table.tsx new file mode 100644 index 00000000..9e7b3901 --- /dev/null +++ b/components/form-data-plant/form-data-table.tsx @@ -0,0 +1,1377 @@ +"use client"; + +import * as React from "react"; +import { useParams, useRouter, usePathname } from "next/navigation"; +import { useTranslation } from "@/i18n/client"; + +import { ClientDataTable } from "../client-data-table/data-table"; +import { + getColumns, + DataTableRowAction, + DataTableColumnJSON, + ColumnType, + Register, +} from "./form-data-table-columns"; +import type { DataTableAdvancedFilterField } from "@/types/table"; +import { Button } from "../ui/button"; +import { + Download, + Loader, + Upload, + Plus, + Tag, + TagsIcon, + FileOutput, + Clipboard, + Send, + GitCompareIcon, + RefreshCcw, + Trash2, + Eye, + FileText, + Target, + CheckCircle2, + AlertCircle, + Clock +} from "lucide-react"; +import { toast } from "sonner"; +import { + getPackageCodeById, + getProjectById, + getReportTempList, + sendFormDataToSEDP, + syncMissingTags, excludeFormDataByTags, getRegisters +} from "@/lib/forms-plant/services"; +import { UpdateTagSheet } from "./update-form-sheet"; +import { FormDataReportTempUploadDialog } from "./form-data-report-temp-upload-dialog"; +import { FormDataReportDialog } from "./form-data-report-dialog"; +import { FormDataReportBatchDialog } from "./form-data-report-batch-dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { AddFormTagDialog } from "./add-formTag-dialog"; +import { importExcelData } from "./import-excel-form"; +import { exportExcelData } from "./export-excel-form"; +import { SEDPConfirmationDialog, SEDPStatusDialog } from "./sedp-components"; +import { SEDPCompareDialog } from "./sedp-compare-dialog"; +import { DeleteFormDataDialog } from "./delete-form-data-dialog"; +import { TemplateViewDialog } from "./spreadJS-dialog"; +import { fetchTemplateFromSEDP } from "@/lib/forms-plant/sedp-actions"; +import { FormStatusByVendor, getFormStatusByVendor } from "@/lib/forms-plant/stat"; +import { + Card, + CardContent, + CardHeader, + CardTitle +} from "@/components/ui/card"; +import { XCircle } from "lucide-react"; // 기존 import 리스트에 추가 + +interface GenericData { + [key: string]: unknown; +} + +export interface DynamicTableProps { + dataJSON: GenericData[]; + columnsJSON: DataTableColumnJSON[]; + contractItemId: number; + formCode: string; + formId: number; + projectId: number; + formName?: string; + objectCode?: string; + mode: "IM" | "ENG"; // 모드 속성 + editableFieldsMap?: Map<string, string[]>; // 새로 추가 +} + +export default function DynamicTable({ + dataJSON, + columnsJSON, + contractItemId, + formCode, + formId, + projectId, + mode = "IM", // 기본값 설정 + formName = `${formCode}`, // Default form name based on formCode + editableFieldsMap = new Map(), // 새로 추가 +}: DynamicTableProps) { + const params = useParams(); + const router = useRouter(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "engineering"); + + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<GenericData> | null>(null); + const [tableData, setTableData] = React.useState<GenericData[]>(dataJSON); + + // 배치 선택 관련 상태 + const [selectedRowsData, setSelectedRowsData] = React.useState<GenericData[]>([]); + const [clearSelection, setClearSelection] = React.useState(false); + // 삭제 관련 상태 간소화 + const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false); + const [deleteTarget, setDeleteTarget] = React.useState<GenericData[]>([]); + + const [formStats, setFormStats] = React.useState<FormStatusByVendor | null>(null); + const [isLoadingStats, setIsLoadingStats] = React.useState(true); + + const [activeFilter, setActiveFilter] = React.useState<string | null>(null); + const [filteredTableData, setFilteredTableData] = React.useState<GenericData[]>(tableData); + const [rowSelection, setRowSelection] = React.useState<Record<string, boolean>>({}); + + const [isExcludingTags, setIsExcludingTags] = React.useState(false); + + const handleExcludeTags = async () => { + const selectedRows = getSelectedRowsData(); + + if (selectedRows.length === 0) { + toast.error(t("messages.noTagsSelected")); + return; + } + + // 확인 다이얼로그 + const confirmMessage = t("messages.confirmExclude", { + count: selectedRows.length + }) || `선택한 ${selectedRows.length}개의 태그를 제외 처리하시겠습니까?`; + + if (!confirm(confirmMessage)) { + return; + } + + setIsExcludingTags(true); + + try { + // TAG_NO 목록 추출 + const tagNumbers = selectedRows + .map(row => row.TAG_NO) + .filter(tagNo => tagNo !== null && tagNo !== undefined); + + if (tagNumbers.length === 0) { + toast.error(t("messages.noValidTags")); + return; + } + + // 서버 액션 호출 + const result = await excludeFormDataByTags({ + formCode, + contractItemId, + tagNumbers, + }); + + if (result.success) { + toast.success( + t("messages.tagsExcluded", { count: result.excludedCount }) || + `${result.excludedCount}개의 태그가 제외되었습니다.` + ); + + // 로컬 상태 업데이트 + setTableData(prev => + prev.map(item => { + if (tagNumbers.includes(item.TAG_NO)) { + return { + ...item, + status: 'excluded', + excludedAt: new Date().toISOString() + }; + } + return item; + }) + ); + + // 선택 상태 초기화 + setClearSelection(true); + setTimeout(() => setClearSelection(false), 100); + } else { + toast.error(result.error || t("messages.excludeFailed")); + } + } catch (error) { + console.error("Error excluding tags:", error); + toast.error(t("messages.excludeError") || "태그 제외 중 오류가 발생했습니다."); + } finally { + setIsExcludingTags(false); + } + }; + + // 필터링 로직 + React.useEffect(() => { + if (!activeFilter) { + setFilteredTableData(tableData); + return; + } + + const today = new Date(); + today.setHours(0, 0, 0, 0); + const sevenDaysLater = new Date(today); + sevenDaysLater.setDate(sevenDaysLater.getDate() + 7); + + let filtered = [...tableData]; + + switch (activeFilter) { + case 'completed': + // 모든 필수 필드가 완료된 태그만 표시 + filtered = tableData.filter(item => { + const tagEditableFields = editableFieldsMap.get(item.TAG_NO) || []; + return columnsJSON + .filter(col => (col.shi === 'IN' || col.shi === 'BOTH') && tagEditableFields.includes(col.key)) + .every(col => { + const value = item[col.key]; + return value !== undefined && value !== null && value !== ''; + }); + }); + break; + + case 'remaining': + // 미완료 필드가 있는 태그만 표시 + filtered = tableData.filter(item => { + const tagEditableFields = editableFieldsMap.get(item.TAG_NO) || []; + return columnsJSON + .filter(col => (col.shi === 'IN' || col.shi === 'BOTH') && tagEditableFields.includes(col.key)) + .some(col => { + const value = item[col.key]; + return value === undefined || value === null || value === ''; + }); + }); + break; + + case 'upcoming': + // 7일 이내 임박한 태그만 표시 + filtered = tableData.filter(item => { + const dueDate = item.DUE_DATE; + if (!dueDate) return false; + + const target = new Date(dueDate); + target.setHours(0, 0, 0, 0); + + // 미완료이면서 7일 이내인 경우 + const hasIncompleteFields = columnsJSON + .filter(col => col.shi === 'IN' || col.shi === 'BOTH') + .some(col => !item[col.key]); + + return hasIncompleteFields && target >= today && target <= sevenDaysLater; + }); + break; + + case 'overdue': + // 지연된 태그만 표시 + filtered = tableData.filter(item => { + const dueDate = item.DUE_DATE; + if (!dueDate) return false; + + const target = new Date(dueDate); + target.setHours(0, 0, 0, 0); + + // 미완료이면서 지연된 경우 + const hasIncompleteFields = columnsJSON + .filter(col => col.shi === 'IN' || col.shi === 'BOTH') + .some(col => !item[col.key]); + + return hasIncompleteFields && target < today; + }); + break; + + default: + filtered = tableData; + } + + setFilteredTableData(filtered); + }, [activeFilter, tableData, columnsJSON, editableFieldsMap]); + + // 카드 클릭 핸들러 + const handleCardClick = (filterType: string | null) => { + setActiveFilter(prev => prev === filterType ? null : filterType); + }; + + React.useEffect(() => { + const fetchFormStats = async () => { + try { + setIsLoadingStats(true); + // getFormStatusByVendor 서버 액션 직접 호출 + const data = await getFormStatusByVendor(projectId, contractItemId, formCode); + + if (data && data.length > 0) { + setFormStats(data[0]); + } + } catch (error) { + console.error("Failed to fetch form stats:", error); + toast.error("통계 데이터를 불러오는데 실패했습니다."); + } finally { + setIsLoadingStats(false); + } + }; + + if (projectId && formCode) { + fetchFormStats(); + } + }, [projectId, formCode]); + + // Update tableData when dataJSON changes + React.useEffect(() => { + setTableData(dataJSON); + }, [dataJSON]); + + // 폴링 상태 관리를 위한 ref + const pollingRef = React.useRef<NodeJS.Timeout | null>(null); + + // Separate loading states for different operations + const [isSyncingTags, setIsSyncingTags] = React.useState(false); + const [isImporting, setIsImporting] = React.useState(false); + const [isExporting, setIsExporting] = React.useState(false); + const [isSaving] = React.useState(false); + const [isSendingSEDP, setIsSendingSEDP] = React.useState(false); + const [isLoadingTags, setIsLoadingTags] = React.useState(false); + const [isLoadingTemplate, setIsLoadingTemplate] = React.useState(false); // 새로 추가 + + // Any operation in progress + const isAnyOperationPending = isSyncingTags || isImporting || isExporting || isSaving || isSendingSEDP || isLoadingTags || isLoadingTemplate || isExcludingTags; + + // SEDP dialogs state + const [sedpConfirmOpen, setSedpConfirmOpen] = React.useState(false); + const [sedpStatusOpen, setSedpStatusOpen] = React.useState(false); + const [sedpStatusData, setSedpStatusData] = React.useState({ + status: 'success' as 'success' | 'error' | 'partial', + message: '', + successCount: 0, + errorCount: 0, + totalCount: 0 + }); + + // SEDP compare dialog state + const [sedpCompareOpen, setSedpCompareOpen] = React.useState(false); + const [projectCode, setProjectCode] = React.useState<string>(''); + const [projectType, setProjectType] = React.useState<string>('plant'); + const [packageCode, setPackageCode] = React.useState<string>(''); + + // 새로 추가된 Template 다이얼로그 상태 + const [templateDialogOpen, setTemplateDialogOpen] = React.useState(false); + const [templateData, setTemplateData] = React.useState<unknown>(null); + + const [tempUpDialog, setTempUpDialog] = React.useState(false); + const [reportData, setReportData] = React.useState<GenericData[]>([]); + const [batchDownDialog, setBatchDownDialog] = React.useState(false); + const [tempCount, setTempCount] = React.useState(0); + const [addTagDialogOpen, setAddTagDialogOpen] = React.useState(false); + + const [registers, setRegisters] = React.useState<Register[]>([]); +const [isLoadingRegisters, setIsLoadingRegisters] = React.useState(false); + + + // TAG_NO가 있는 첫 번째 행의 shi 값 확인 + const isAddTagDisabled = React.useMemo(() => { + const firstRowWithTagNo = tableData.find(row => row.TAG_NO); + return firstRowWithTagNo?.shi === true; + }, [tableData]); + + // Clean up polling on unmount + React.useEffect(() => { + return () => { + if (pollingRef.current) { + clearInterval(pollingRef.current); + } + }; + }, []); + + React.useEffect(() => { + const getTempCount = async () => { + const tempList = await getReportTempList(contractItemId, formId); + setTempCount(tempList.length); + }; + + getTempCount(); + }, [contractItemId, formId, tempUpDialog]); + + React.useEffect(() => { + const getPackageCode = async () => { + try { + const packageCode = await getPackageCodeById(contractItemId); + setPackageCode(packageCode || ''); // 빈 문자열이나 다른 기본값 + } catch (error) { + console.error('패키지 조회 실패:', error); + setPackageCode(''); + } + }; + + getPackageCode(); + }, [contractItemId]) + // Get project code when component mounts + React.useEffect(() => { + const getProjectCode = async () => { + try { + const project = await getProjectById(projectId); + setProjectCode(project.code); + setProjectType(project.type); + } catch (error) { + console.error("Error fetching project code:", error); + toast.error("Failed to fetch project code"); + } + }; + + if (projectId) { + getProjectCode(); + } + }, [projectId]); + + // 선택된 행들의 실제 데이터 가져오기 + const getSelectedRowsData = React.useCallback(() => { + return selectedRowsData; + }, [selectedRowsData]); + + // 선택된 행 개수 계산 + const selectedRowCount = React.useMemo(() => { + return selectedRowsData.length; + }, [selectedRowsData]); + + // 프로젝트 코드를 가져오는 useEffect (기존 코드 참고) +React.useEffect(() => { + const fetchRegisters = async () => { + if (!projectCode) return; // projectCode가 있는지 확인 + + setIsLoadingRegisters(true); + try { + const registersData = await getRegisters(projectCode); + setRegisters(registersData); + console.log('✅ Registers loaded:', registersData.length); + } catch (error) { + console.error('❌ Failed to load registers:', error); + toast.error('레지스터 정보를 불러오는데 실패했습니다.'); + } finally { + setIsLoadingRegisters(false); + } + }; + + fetchRegisters(); +}, [projectCode]); + + + const columns = React.useMemo( + () => + getColumns({ + columnsJSON, + setRowAction, + setReportData, + tempCount, + onRowSelectionChange: setRowSelection, + templateData, // 기존 + registers, // 새로 추가 + }), + [columnsJSON, setRowAction, setReportData, tempCount, templateData, registers] + ); + + function mapColumnTypeToAdvancedFilterType( + columnType: ColumnType + ): DataTableAdvancedFilterField<GenericData>["type"] { + switch (columnType) { + case "STRING": + return "text"; + case "NUMBER": + return "number"; + case "LIST": + return "select"; + default: + return "text"; + } + } + + const advancedFilterFields = React.useMemo< + DataTableAdvancedFilterField<GenericData>[] + >(() => { + return columnsJSON.map((col) => ({ + id: col.key, + label: col.label, + type: mapColumnTypeToAdvancedFilterType(col.type), + options: + col.type === "LIST" + ? col.options?.map((v) => ({ label: v, value: v })) + : undefined, + })); + }, [columnsJSON]); + + // 새로 추가된 Template 가져오기 함수 + const handleGetTemplate = async () => { + + if (!projectCode) { + toast.error("Project code is not available"); + return; + } + + try { + setIsLoadingTemplate(true); + + const templateResult = await fetchTemplateFromSEDP(projectCode, formCode); + + // 🔍 전달되는 템플릿 데이터 로깅 + console.log('📊 Template data received from SEDP:', { + count: Array.isArray(templateResult) ? templateResult.length : 'not array', + isArray: Array.isArray(templateResult), + data: templateResult + }); + + if (Array.isArray(templateResult)) { + templateResult.forEach((tmpl, idx) => { + console.log(` [${idx}] TMPL_ID: ${tmpl?.TMPL_ID || 'MISSING'}, NAME: ${tmpl?.NAME || 'N/A'}, TYPE: ${tmpl?.TMPL_TYPE || 'N/A'}`); + }); + } + + setTemplateData(templateResult); + setTemplateDialogOpen(true); + + toast.success("Template data loaded successfully"); + } catch (error) { + console.error("Error fetching template:", error); + toast.error("Failed to fetch template from SEDP"); + } finally { + setIsLoadingTemplate(false); + } + }; + + // IM 모드: 태그 동기화 함수 + async function handleSyncTags() { + try { + setIsSyncingTags(true); + const result = await syncMissingTags(contractItemId, formCode); + + // Prepare the toast messages based on what changed + const changes = []; + if (result.createdCount > 0) + changes.push(`${result.createdCount}건 태그 생성`); + if (result.updatedCount > 0) + changes.push(`${result.updatedCount}건 태그 업데이트`); + if (result.deletedCount > 0) + changes.push(`${result.deletedCount}건 태그 삭제`); + + if (changes.length > 0) { + // If any changes were made, show success message and reload + toast.success(`동기화 완료: ${changes.join(", ")}`); + router.refresh(); // Use router.refresh instead of location.reload + } else { + // If no changes were made, show an info message + toast.info("변경사항이 없습니다. 모든 태그가 최신 상태입니다."); + } + } catch (err) { + console.error(err); + toast.error("태그 동기화 중 에러가 발생했습니다."); + } finally { + setIsSyncingTags(false); + } + } + + // ENG 모드: 태그 가져오기 함수 + const handleGetTags = async () => { + try { + setIsLoadingTags(true); + + // API 엔드포인트 호출 - 작업 시작만 요청 + const response = await fetch('/api/cron/form-tags/start', { + method: 'POST', + body: JSON.stringify({ projectCode, formCode, contractItemId }) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to start tag import'); + } + + const data = await response.json(); + + // 작업 ID 저장 + if (data.syncId) { + toast.info('Tag import started. This may take a while...'); + + // 상태 확인을 위한 폴링 시작 + startPolling(data.syncId); + } else { + throw new Error('No import ID returned from server'); + } + } catch (error) { + console.error('Error starting tag import:', error); + toast.error( + error instanceof Error + ? error.message + : 'An error occurred while starting tag import' + ); + setIsLoadingTags(false); + } + }; + + const startPolling = (id: string) => { + // 이전 폴링이 있다면 제거 + if (pollingRef.current) { + clearInterval(pollingRef.current); + } + + // 5초마다 상태 확인 + pollingRef.current = setInterval(async () => { + try { + const response = await fetch(`/api/cron/form-tags/status?id=${id}`); + + if (!response.ok) { + throw new Error('Failed to get tag import status'); + } + + const data = await response.json(); + + if (data.status === 'completed') { + // 폴링 중지 + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + + router.refresh(); + + // 상태 초기화 + setIsLoadingTags(false); + + // 성공 메시지 표시 + toast.success( + `Tags imported successfully! ${data.result?.processedCount || 0} items processed.` + ); + + } else if (data.status === 'failed') { + // 에러 처리 + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + + setIsLoadingTags(false); + toast.error(data.error || 'Import failed'); + } else if (data.status === 'processing') { + // 진행 상태 업데이트 (선택적) + if (data.progress) { + toast.info(`Import in progress: ${data.progress}%`, { + id: `import-progress-${id}`, + }); + } + } + } catch (error) { + console.error('Error checking importing status:', error); + } + }, 5000); // 5초마다 체크 + }; + + // Excel Import - Fixed version with proper loading state management + async function handleImportExcel(e: React.ChangeEvent<HTMLInputElement>) { + const file = e.target.files?.[0]; + if (!file) return; + + try { + // Don't set setIsImporting here - let importExcelData handle it completely + // setIsImporting(true); // Remove this line + + // Call the updated importExcelData function with editableFieldsMap + const result = await importExcelData({ + file, + tableData, + columnsJSON, + formCode, + contractItemId, + editableFieldsMap, // 추가: 편집 가능 필드 정보 전달 + onPendingChange: setIsImporting, // Let importExcelData handle loading state + onDataUpdate: (newData) => { + setTableData(Array.isArray(newData) ? newData : newData(tableData)); + } + }); + + // If import and save was successful, refresh the page + if (result.success) { + // Show additional info about skipped fields if any + if (result.skippedFields && result.skippedFields.length > 0) { + console.log("Import completed with some fields skipped:", result.skippedFields); + } + + // Ensure loading state is cleared before refresh + setIsImporting(false); + + // Add a small delay to ensure state update is processed + setTimeout(() => { + router.refresh(); + }, 100); + } + } catch (error) { + console.error("Import failed:", error); + toast.error("Failed to import Excel data"); + // Ensure loading state is cleared on error + setIsImporting(false); + } finally { + // Always clear the file input value + e.target.value = ""; + // Don't set setIsImporting(false) here since we handle it above + } + } + // SEDP Send handler (with confirmation) + function handleSEDPSendClick() { + if (tableData.length === 0) { + toast.error("No data to send to SEDP"); + return; + } + + // Open confirmation dialog + setSedpConfirmOpen(true); + } + + // Handle SEDP compare button click + function handleSEDPCompareClick() { + if (tableData.length === 0) { + toast.error("No data to compare with SEDP"); + return; + } + + if (!projectCode) { + toast.error("Project code is not available"); + return; + } + + // Open compare dialog + setSedpCompareOpen(true); + } + + // Actual SEDP send after confirmation + async function handleSEDPSendConfirmed() { + try { + setIsSendingSEDP(true); + + // Validate data + const invalidData = tableData.filter((item) => { + const tagNo = item.TAG_NO; + return !tagNo || (typeof tagNo === 'string' && !tagNo.trim()); + }); + if (invalidData.length > 0) { + toast.error(`태그 번호가 없는 항목이 ${invalidData.length}개 있습니다.`); + setSedpConfirmOpen(false); + return; + } + + // Then send to SEDP - pass formCode instead of formName + const sedpResult = await sendFormDataToSEDP( + formCode, // Send formCode instead of formName + projectId, // Project ID + contractItemId, + tableData.filter(v=>v.status !== 'excluded'), // Table data + columnsJSON // Column definitions + ); + + // Close confirmation dialog + setSedpConfirmOpen(false); + + // Set status data based on result + if (sedpResult.success) { + setSedpStatusData({ + status: 'success', + message: "Data successfully sent to SEDP", + successCount: tableData.length, + errorCount: 0, + totalCount: tableData.length + }); + } else { + setSedpStatusData({ + status: 'error', + message: sedpResult.message || "Failed to send data to SEDP", + successCount: 0, + errorCount: tableData.length, + totalCount: tableData.length + }); + } + + // Open status dialog to show result + setSedpStatusOpen(true); + + // Refresh the route to get fresh data + router.refresh(); + + } catch (err: unknown) { + console.error("SEDP error:", err); + + // Set error status + setSedpStatusData({ + status: 'error', + message: err instanceof Error ? err.message : "An unexpected error occurred", + successCount: 0, + errorCount: tableData.length, + totalCount: tableData.length + }); + + // Close confirmation and open status + setSedpConfirmOpen(false); + setSedpStatusOpen(true); + + } finally { + setIsSendingSEDP(false); + } + } + + // Template Export + async function handleExportExcel() { + try { + setIsExporting(true); + await exportExcelData({ + tableData, + columnsJSON, + formCode, + editableFieldsMap, + onPendingChange: setIsExporting + }); + } finally { + setIsExporting(false); + } + } + + // Handle batch document with smart selection logic + const handleBatchDocument = () => { + if (tempCount === 0) { + toast.error("업로드된 Template File이 없습니다."); + return; + } + + // 선택된 항목이 있으면 선택된 것만, 없으면 전체 사용 + const selectedData = getSelectedRowsData(); + if (selectedData.length > 0) { + toast.info(`선택된 ${selectedData.length}개 항목으로 배치 문서를 생성합니다.`); + } else { + toast.info(`전체 ${tableData.length}개 항목으로 배치 문서를 생성합니다.`); + } + + setBatchDownDialog(true); + }; + + // 개별 행 삭제 핸들러 + const handleDeleteRow = (rowData: GenericData) => { + setDeleteTarget([rowData]); + setDeleteDialogOpen(true); + }; + + // 배치 삭제 핸들러 + const handleBatchDelete = () => { + const selectedData = getSelectedRowsData(); + if (selectedData.length === 0) { + toast.error("삭제할 항목을 선택해주세요."); + return; + } + + setDeleteTarget(selectedData); + setDeleteDialogOpen(true); + }; + + // 삭제 성공 후 처리 + const handleDeleteSuccess = () => { + // 로컬 상태에서 삭제된 항목들 제거 + const tagNosToDelete = deleteTarget + .map(item => item.TAG_NO) + .filter(Boolean); + + setTableData(prev => + prev.filter(item => !tagNosToDelete.includes(item.TAG_NO)) + ); + + // 선택 상태 초기화 + setSelectedRowsData([]); + setClearSelection(prev => !prev); // ClientDataTable의 선택 상태 초기화 + + // 삭제 타겟 초기화 + setDeleteTarget([]); + }; + + // rowAction 처리 부분 수정 + React.useEffect(() => { + if (rowAction?.type === "delete") { + handleDeleteRow(rowAction.row.original); + setRowAction(null); // 액션 초기화 + } + }, [rowAction]); + + + return ( + <> + + <div className="mb-6"> + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5"> + {/* Total Tags Card - 클릭 시 전체 보기 */} + <Card + className={`cursor-pointer transition-all ${activeFilter === null ? 'ring-2 ring-primary' : 'hover:shadow-lg' + }`} + onClick={() => handleCardClick(null)} + > + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium"> + Total Tags + </CardTitle> + <FileText className="h-4 w-4 text-muted-foreground" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold"> + {isLoadingStats ? ( + <span className="animate-pulse">-</span> + ) : ( + formStats?.tagCount || 0 + )} + </div> + <p className="text-xs text-muted-foreground"> + {activeFilter === null ? 'Showing all' : 'Click to show all'} + </p> + </CardContent> + </Card> + + {/* Completed Fields Card */} + <Card + className={`cursor-pointer transition-all ${activeFilter === 'completed' ? 'ring-2 ring-green-600' : 'hover:shadow-lg' + }`} + onClick={() => handleCardClick('completed')} + > + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium"> + Completed + </CardTitle> + <CheckCircle2 className="h-4 w-4 text-green-600" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-green-600"> + {isLoadingStats ? ( + <span className="animate-pulse">-</span> + ) : ( + formStats?.completedFields || 0 + )} + </div> + <p className="text-xs text-muted-foreground"> + {activeFilter === 'completed' ? 'Filtering active' : 'Click to filter'} + </p> + </CardContent> + </Card> + + {/* Remaining Fields Card */} + <Card + className={`cursor-pointer transition-all ${activeFilter === 'remaining' ? 'ring-2 ring-blue-600' : 'hover:shadow-lg' + }`} + onClick={() => handleCardClick('remaining')} + > + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium"> + Remaining + </CardTitle> + <Clock className="h-4 w-4 text-muted-foreground" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold"> + {isLoadingStats ? ( + <span className="animate-pulse">-</span> + ) : ( + (formStats?.totalFields || 0) - (formStats?.completedFields || 0) + )} + </div> + <p className="text-xs text-muted-foreground"> + {activeFilter === 'remaining' ? 'Filtering active' : 'Click to filter'} + </p> + </CardContent> + </Card> + + {/* Upcoming Card */} + <Card + className={`cursor-pointer transition-all ${activeFilter === 'upcoming' ? 'ring-2 ring-yellow-600' : 'hover:shadow-lg' + }`} + onClick={() => handleCardClick('upcoming')} + > + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium"> + Upcoming + </CardTitle> + <AlertCircle className="h-4 w-4 text-yellow-600" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-yellow-600"> + {isLoadingStats ? ( + <span className="animate-pulse">-</span> + ) : ( + formStats?.upcomingCount || 0 + )} + </div> + <p className="text-xs text-muted-foreground"> + {activeFilter === 'upcoming' ? 'Filtering active' : 'Click to filter'} + </p> + </CardContent> + </Card> + + {/* Overdue Card */} + <Card + className={`cursor-pointer transition-all ${activeFilter === 'overdue' ? 'ring-2 ring-red-600' : 'hover:shadow-lg' + }`} + onClick={() => handleCardClick('overdue')} + > + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium"> + Overdue + </CardTitle> + <AlertCircle className="h-4 w-4 text-red-600" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-red-600"> + {isLoadingStats ? ( + <span className="animate-pulse">-</span> + ) : ( + formStats?.overdueCount || 0 + )} + </div> + <p className="text-xs text-muted-foreground"> + {activeFilter === 'overdue' ? 'Filtering active' : 'Click to filter'} + </p> + </CardContent> + </Card> + </div> + </div> + + + <ClientDataTable + data={filteredTableData} // tableData 대신 filteredTableData 사용 + columns={columns} + advancedFilterFields={advancedFilterFields} + autoSizeColumns + onSelectedRowsChange={setSelectedRowsData} + clearSelection={clearSelection} + > + {/* 필터 상태 표시 */} + {activeFilter && ( + <div className="flex items-center gap-2 mr-auto"> + <span className="text-sm text-muted-foreground"> + Filter: {activeFilter === 'completed' ? 'Completed' : + activeFilter === 'remaining' ? 'Remaining' : + activeFilter === 'upcoming' ? 'Upcoming (7 days)' : + activeFilter === 'overdue' ? 'Overdue' : 'All'} + </span> + <Button + variant="ghost" + size="sm" + onClick={() => setActiveFilter(null)} + > + Clear filter + </Button> + </div> + )} + {/* 선택된 항목 수 표시 (선택된 항목이 있을 때만) */} + {selectedRowCount > 0 && ( + <Button + variant="destructive" + size="sm" + onClick={handleBatchDelete} + > + <Trash2 className="mr-2 size-4" /> + {t("buttons.delete")} ({selectedRowCount}) + </Button> + )} + + {/* 버튼 그룹 */} + <div className="flex items-center gap-2"> + + {selectedRowCount > 0 && ( + <Button + variant="outline" + size="sm" + onClick={handleExcludeTags} + disabled={isAnyOperationPending} + className="border-orange-500 text-orange-600 hover:bg-orange-50" + > + {isExcludingTags ? ( + <Loader className="mr-2 size-4 animate-spin" /> + ) : ( + <XCircle className="mr-2 size-4" /> + )} + {t("buttons.excludeTags")} ({selectedRowCount}) + </Button> + )} + {/* 태그 관리 드롭다운 */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="outline" size="sm" disabled={isAnyOperationPending}> + {(isSyncingTags || isLoadingTags) ? ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + ) : + <TagsIcon className="size-4" />} + {t("buttons.tagOperations")} + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + {/* 모드에 따라 다른 태그 작업 표시 */} + {mode === "IM" ? ( + <DropdownMenuItem onClick={handleSyncTags} disabled={isAnyOperationPending}> + <Tag className="mr-2 h-4 w-4" /> + {t("buttons.syncTags")} + </DropdownMenuItem> + ) : ( + <DropdownMenuItem onClick={handleGetTags} disabled={isAnyOperationPending}> + <RefreshCcw className="mr-2 h-4 w-4" /> + {t("buttons.getTags")} + </DropdownMenuItem> + )} + <DropdownMenuItem + onClick={() => setAddTagDialogOpen(true)} + disabled={isAnyOperationPending || isAddTagDisabled} + > + <Plus className="mr-2 h-4 w-4" /> + {t("buttons.addTags")} + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + + {/* 리포트 관리 드롭다운 */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="outline" size="sm" disabled={isAnyOperationPending}> + <Clipboard className="size-4" /> + {t("buttons.reportOperations")} + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem onClick={() => setTempUpDialog(true)} disabled={isAnyOperationPending}> + <Upload className="mr-2 h-4 w-4" /> + {t("buttons.uploadTemplate")} + </DropdownMenuItem> + <DropdownMenuItem onClick={handleBatchDocument} disabled={isAnyOperationPending}> + <FileOutput className="mr-2 h-4 w-4" /> + {t("buttons.batchDocument")} + {selectedRowCount > 0 && ( + <span className="ml-2 text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded"> + {selectedRowCount} + </span> + )} + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + + {/* IMPORT 버튼 (파일 선택) */} + <Button asChild variant="outline" size="sm" disabled={isAnyOperationPending}> + <label> + {isImporting ? ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + ) : ( + <Upload className="size-4" /> + )} + {t("buttons.import")} + <input + type="file" + accept=".xlsx,.xls" + onChange={handleImportExcel} + style={{ display: "none" }} + disabled={isAnyOperationPending} + /> + </label> + </Button> + + {/* EXPORT 버튼 */} + <Button + variant="outline" + size="sm" + onClick={handleExportExcel} + disabled={isAnyOperationPending} + > + {isExporting ? ( + <Loader className="mr-2 size-4 animate-spin" /> + ) : ( + <Download className="mr-2 size-4" /> + )} + {t("buttons.export")} + </Button> + + {/* Template 보기 버튼 */} + <Button + variant="outline" + size="sm" + onClick={handleGetTemplate} + disabled={isAnyOperationPending} + > + {isLoadingTemplate ? ( + <Loader className="mr-2 size-4 animate-spin" /> + ) : ( + <Eye className="mr-2 size-4" /> + )} + {t("buttons.viewTemplate")} + </Button> + + {/* COMPARE WITH SEDP 버튼 */} + <Button + variant="outline" + size="sm" + onClick={handleSEDPCompareClick} + disabled={isAnyOperationPending} + > + <GitCompareIcon className="mr-2 size-4" /> + {t("buttons.compareWithSEDP")} + </Button> + + {/* SEDP 전송 버튼 */} + <Button + variant="samsung" + size="sm" + onClick={handleSEDPSendClick} + disabled={isAnyOperationPending} + > + {isSendingSEDP ? ( + <> + <Loader className="mr-2 size-4 animate-spin" /> + {t("messages.sendingSEDP")} + </> + ) : ( + <> + <Send className="size-4" /> + {t("buttons.sendToSHI")} + </> + )} + </Button> + </div> + </ClientDataTable> + + {/* Modal dialog for tag update */} + <UpdateTagSheet + open={rowAction?.type === "update"} + onOpenChange={(open) => { + if (!open) setRowAction(null); + }} + columns={columnsJSON} + rowData={rowAction?.row.original ?? null} + formCode={formCode} + contractItemId={contractItemId} + editableFieldsMap={editableFieldsMap} + onUpdateSuccess={(updatedValues) => { + // Update the specific row in tableData when a single row is updated + if (rowAction?.row.original?.TAG_NO) { + const tagNo = rowAction.row.original.TAG_NO; + setTableData(prev => + prev.map(item => + item.TAG_NO === tagNo ? updatedValues : item + ) + ); + } + }} + /> + + <DeleteFormDataDialog + formData={deleteTarget} + formCode={formCode} + contractItemId={contractItemId} + open={deleteDialogOpen} + onOpenChange={(open) => { + if (!open) { + setDeleteDialogOpen(false); + setDeleteTarget([]); + } + }} + onSuccess={handleDeleteSuccess} + showTrigger={false} + /> + + {/* Dialog for adding tags */} + {/* <AddFormTagDialog + projectId={projectId} + formCode={formCode} + formName={`Form ${formCode}`} + contractItemId={contractItemId} + packageCode={packageCode} + open={addTagDialogOpen} + onOpenChange={setAddTagDialogOpen} + /> */} + + {/* 새로 추가된 Template 다이얼로그 */} + <TemplateViewDialog + isOpen={templateDialogOpen} + onClose={() => setTemplateDialogOpen(false)} + templateData={templateData} + selectedRow={selectedRowsData[0]} // SPR_ITM_LST_SETUP용 + tableData={tableData} // SPR_LST_SETUP용 - 새로 추가 + formCode={formCode} + contractItemId={contractItemId} + editableFieldsMap={editableFieldsMap} + columnsJSON={columnsJSON} + onUpdateSuccess={(updatedValues) => { + // 업데이트 로직도 수정해야 함 - 단일 행 또는 복수 행 처리 + if (Array.isArray(updatedValues)) { + // SPR_LST_SETUP의 경우 - 복수 행 업데이트 + const updatedData = [...tableData]; + updatedValues.forEach(updatedItem => { + const index = updatedData.findIndex(item => item.TAG_NO === updatedItem.TAG_NO); + if (index !== -1) { + updatedData[index] = updatedItem; + } + }); + setTableData(updatedData); + } else { + // SPR_ITM_LST_SETUP의 경우 - 단일 행 업데이트 + const tagNo = updatedValues.TAG_NO; + if (tagNo) { + setTableData(prev => + prev.map(item => + item.TAG_NO === tagNo ? updatedValues : item + ) + ); + } + } + }} + /> + + {/* SEDP Confirmation Dialog */} + <SEDPConfirmationDialog + isOpen={sedpConfirmOpen} + onClose={() => setSedpConfirmOpen(false)} + onConfirm={handleSEDPSendConfirmed} + formName={formName} + tagCount={tableData.filter(v=>v.status !=='excluded').length} + isLoading={isSendingSEDP} + /> + + {/* SEDP Status Dialog */} + <SEDPStatusDialog + isOpen={sedpStatusOpen} + onClose={() => setSedpStatusOpen(false)} + status={sedpStatusData.status} + message={sedpStatusData.message} + successCount={sedpStatusData.successCount} + errorCount={sedpStatusData.errorCount} + totalCount={sedpStatusData.totalCount} + /> + + {/* SEDP Compare Dialog */} + <SEDPCompareDialog + isOpen={sedpCompareOpen} + onClose={() => setSedpCompareOpen(false)} + tableData={tableData} + columnsJSON={columnsJSON} + projectCode={projectCode} + formCode={formCode} + projectType={projectType} + packageCode={packageCode} + /> + + {/* Other dialogs */} + {tempUpDialog && ( + <FormDataReportTempUploadDialog + columnsJSON={columnsJSON} + open={tempUpDialog} + setOpen={setTempUpDialog} + packageId={contractItemId} + formCode={formCode} + formId={formId} + uploaderType="vendor" + /> + )} + + {reportData.length > 0 && ( + <FormDataReportDialog + columnsJSON={columnsJSON} + reportData={reportData} + setReportData={setReportData} + packageId={contractItemId} + formCode={formCode} + formId={formId} + /> + )} + + {batchDownDialog && ( + <FormDataReportBatchDialog + open={batchDownDialog} + setOpen={setBatchDownDialog} + columnsJSON={columnsJSON} + reportData={selectedRowCount > 0 ? getSelectedRowsData() : tableData} + packageId={contractItemId} + formCode={formCode} + formId={formId} + /> + )} + </> + ); +}
\ No newline at end of file diff --git a/components/form-data-plant/import-excel-form.tsx b/components/form-data-plant/import-excel-form.tsx new file mode 100644 index 00000000..ffc6f2f9 --- /dev/null +++ b/components/form-data-plant/import-excel-form.tsx @@ -0,0 +1,669 @@ +import ExcelJS from "exceljs"; +import { saveAs } from "file-saver"; +import { toast } from "sonner"; +import { DataTableColumnJSON } from "./form-data-table-columns"; +import { updateFormDataBatchInDB } from "@/lib/forms-plant/services"; +import { decryptWithServerAction } from "../drm/drmUtils"; + +// Define error structure for import +export interface ImportError { + tagNo: string; + rowIndex: number; + columnKey: string; + columnLabel: string; + errorType: string; + errorMessage: string; + currentValue?: any; + expectedFormat?: string; +} + +// Updated options interface with editableFieldsMap +export interface ImportExcelOptions { + file: File; + tableData: GenericData[]; + columnsJSON: DataTableColumnJSON[]; + formCode?: string; + contractItemId?: number; + editableFieldsMap?: Map<string, string[]>; // 새로 추가 + onPendingChange?: (isPending: boolean) => void; + onDataUpdate?: (updater: ((prev: GenericData[]) => GenericData[]) | GenericData[]) => void; +} + +export interface ImportExcelResult { + success: boolean; + importedCount?: number; + error?: any; + message?: string; + skippedFields?: { tagNo: string, fields: string[] }[]; // 건너뛴 필드 정보 + errorCount?: number; + hasErrors?: boolean; + notFoundTags?: string[]; +} + +export interface ExportExcelOptions { + tableData: GenericData[]; + columnsJSON: DataTableColumnJSON[]; + formCode: string; + editableFieldsMap?: Map<string, string[]>; // 새로 추가 + onPendingChange?: (isPending: boolean) => void; +} + +interface GenericData { + [key: string]: any; +} + +/** + * Check if a field is editable for a specific TAG_NO + */ +function isFieldEditable( + column: DataTableColumnJSON, + tagNo: string, + editableFieldsMap: Map<string, string[]> +): boolean { + // SHI-only fields (shi === "OUT" or shi === null) are never editable + if (column.shi === "OUT" || column.shi === null) return false; + + // System fields are never editable + if (column.key === "TAG_NO" || column.key === "TAG_DESC" || column.key === "status") return false; + + // If no editableFieldsMap provided, assume all non-SHI fields are editable + if (!editableFieldsMap || editableFieldsMap.size === 0) return true; + + // If TAG_NO not in map, no fields are editable + if (!editableFieldsMap.has(tagNo)) return false; + + // Check if this field is in the editable fields list for this TAG_NO + const editableFields = editableFieldsMap.get(tagNo) || []; + return editableFields.includes(column.key); +} + +/** + * Create error sheet with import validation results + */ +function createImportErrorSheet(workbook: ExcelJS.Workbook, errors: ImportError[], headerErrors?: string[]) { + + const existingErrorSheet = workbook.getWorksheet("Import_Errors"); + if (existingErrorSheet) { + workbook.removeWorksheet("Import_Errors"); + } + + const errorSheet = workbook.addWorksheet("Import_Errors"); + + // Add header error section if exists + if (headerErrors && headerErrors.length > 0) { + errorSheet.addRow(["HEADER VALIDATION ERRORS"]); + const headerErrorTitleRow = errorSheet.getRow(1); + headerErrorTitleRow.font = { bold: true, size: 14, color: { argb: "FFFFFFFF" } }; + headerErrorTitleRow.getCell(1).fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFDC143C" }, + }; + + headerErrors.forEach((error, index) => { + const errorRow = errorSheet.addRow([`${index + 1}. ${error}`]); + errorRow.getCell(1).fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFFFCCCC" }, + }; + }); + + errorSheet.addRow([]); // Empty row for separation + } + + // Data validation errors section + const startRow = errorSheet.rowCount + 1; + + // Summary row + errorSheet.addRow([`DATA VALIDATION ERRORS: ${errors.length} errors found`]); + const summaryRow = errorSheet.getRow(startRow); + summaryRow.font = { bold: true, size: 12 }; + if (errors.length > 0) { + summaryRow.getCell(1).fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFFFC0C0" }, + }; + } + + if (errors.length > 0) { + // Error data headers + const errorHeaders = [ + "TAG NO", + "Row Number", + "Column", + "Error Type", + "Error Message", + "Current Value", + "Expected Format", + ]; + + errorSheet.addRow(errorHeaders); + const headerRow = errorSheet.getRow(errorSheet.rowCount); + headerRow.font = { bold: true, color: { argb: "FFFFFFFF" } }; + headerRow.alignment = { horizontal: "center" }; + + headerRow.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFDC143C" }, + }; + }); + + // Add error data + errors.forEach((error) => { + const errorRow = errorSheet.addRow([ + error.tagNo, + error.rowIndex, + error.columnLabel, + error.errorType, + error.errorMessage, + error.currentValue || "", + error.expectedFormat || "", + ]); + + // Color code by error type + errorRow.eachCell((cell) => { + let bgColor = "FFFFFFFF"; // Default white + + switch (error.errorType) { + case "MISSING_TAG_NO": + bgColor = "FFFFCCCC"; // Light red + break; + case "TAG_NOT_FOUND": + bgColor = "FFFFDDDD"; // Very light red + break; + case "TYPE_MISMATCH": + bgColor = "FFFFEECC"; // Light orange + break; + case "INVALID_OPTION": + bgColor = "FFFFFFE0"; // Light yellow + break; + case "HEADER_MISMATCH": + bgColor = "FFFFE0E0"; // Very light red + break; + case "READ_ONLY_FIELD": + bgColor = "FFF0F0F0"; // Light gray + break; + } + + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: bgColor }, + }; + }); + }); + } + + // Auto-fit columns + errorSheet.columns.forEach((column) => { + let maxLength = 0; + column.eachCell({ includeEmpty: false }, (cell) => { + const columnLength = String(cell.value).length; + if (columnLength > maxLength) { + maxLength = columnLength; + } + }); + column.width = Math.min(Math.max(maxLength + 2, 10), 50); + }); + + return errorSheet; +} + +export async function importExcelData({ + file, + tableData, + columnsJSON, + formCode, + contractItemId, + editableFieldsMap = new Map(), // 새로 추가 + onPendingChange, + onDataUpdate +}: ImportExcelOptions): Promise<ImportExcelResult> { + if (!file) return { success: false, error: "No file provided" }; + + try { + if (onPendingChange) onPendingChange(true); + + // Get existing tag numbers and create a map for quick lookup + const existingTagNumbers = new Set(tableData.map((d) => d.TAG_NO)); + const existingDataMap = new Map<string, GenericData>(); + tableData.forEach(item => { + if (item.TAG_NO) { + existingDataMap.set(item.TAG_NO, item); + } + }); + + const workbook = new ExcelJS.Workbook(); + const arrayBuffer = await decryptWithServerAction(file); + await workbook.xlsx.load(arrayBuffer); + + const worksheet = workbook.worksheets[0]; + + // Parse headers + const headerRow = worksheet.getRow(1); + const headerRowValues = headerRow.values as ExcelJS.CellValue[]; + + console.log("Original headers:", headerRowValues); + + // Create mappings between Excel headers and column definitions + const headerToIndexMap = new Map<string, number>(); + for (let i = 1; i < headerRowValues.length; i++) { + const headerValue = String(headerRowValues[i] || "").trim(); + if (headerValue) { + headerToIndexMap.set(headerValue, i); + } + } + + // Validate headers + const headerErrors: string[] = []; + + // Check for missing required columns + columnsJSON.forEach((col) => { + const label = col.label; + if (!headerToIndexMap.has(label)) { + headerErrors.push(`Column "${label}" is missing from Excel file`); + } + }); + + // Check for unexpected columns + headerToIndexMap.forEach((index, headerLabel) => { + const found = columnsJSON.some((col) => col.label === headerLabel); + if (!found) { + headerErrors.push(`Unexpected column "${headerLabel}" found in Excel file`); + } + }); + + // If header validation fails, create error report and exit + if (headerErrors.length > 0) { + createImportErrorSheet(workbook, [], headerErrors); + + const outBuffer = await workbook.xlsx.writeBuffer(); + saveAs(new Blob([outBuffer]), `import-error-report_${Date.now()}.xlsx`); + + toast.error(`Header validation failed. ${headerErrors.length} errors found. Check downloaded error report.`); + return { + success: false, + error: "Header validation errors", + errorCount: headerErrors.length, + hasErrors: true + }; + } + + // Create column key to Excel index mapping + const keyToIndexMap = new Map<string, number>(); + columnsJSON.forEach((col) => { + const index = headerToIndexMap.get(col.label); + if (index !== undefined) { + keyToIndexMap.set(col.key, index); + } + }); + + // Parse and validate data rows + const importedData: GenericData[] = []; + const validationErrors: ImportError[] = []; + const lastRowNumber = worksheet.lastRow?.number || 1; + const skippedFieldsLog: { tagNo: string, fields: string[] }[] = []; // 건너뛴 필드 로그 + + // Process each data row + for (let rowNum = 2; rowNum <= lastRowNumber; rowNum++) { + const row = worksheet.getRow(rowNum); + const rowValues = row.values as ExcelJS.CellValue[]; + // 실제 값이 있는지 확인 (빈 문자열이 아닌 실제 내용) + const hasAnyValue = rowValues && rowValues.slice(1).some(val => + val !== undefined && + val !== null && + String(val).trim() !== "" + ); + + if (!hasAnyValue) { + console.log(`Row ${rowNum} is empty, skipping...`); + continue; // 완전히 빈 행은 건너뛰기 + } + + const rowObj: Record<string, any> = {}; + const skippedFields: string[] = []; // 현재 행에서 건너뛴 필드들 + let hasErrors = false; + + // Get the TAG_NO first to identify existing data + const tagNoColIndex = keyToIndexMap.get("TAG_NO"); + const tagNo = tagNoColIndex ? String(rowValues[tagNoColIndex] ?? "").trim() : ""; + const existingRowData = existingDataMap.get(tagNo); + + if (!existingTagNumbers.has(tagNo)) { + validationErrors.push({ + tagNo: tagNo, + rowIndex: rowNum, + columnKey: "TAG_NO", + columnLabel: "TAG NO", + errorType: "TAG_NOT_FOUND", + errorMessage: "TAG_NO not found in current data", + currentValue: tagNo, + }); + hasErrors = true; + } + + // Process each column + columnsJSON.forEach((col) => { + const colIndex = keyToIndexMap.get(col.key); + if (colIndex === undefined) return; + + // Check if this field is editable for this TAG_NO + const fieldEditable = isFieldEditable(col, tagNo, editableFieldsMap); + + if (!fieldEditable) { + // If field is not editable, preserve existing value + if (existingRowData && existingRowData[col.key] !== undefined) { + rowObj[col.key] = existingRowData[col.key]; + } else { + // If no existing data, use appropriate default + switch (col.type) { + case "NUMBER": + rowObj[col.key] = null; + break; + case "STRING": + case "LIST": + default: + rowObj[col.key] = ""; + break; + } + } + + // Determine skip reason + let skipReason = ""; + if (col.shi === "OUT" || col.shi === null) { + skipReason = "SHI-only field"; + } else if (col.key === "TAG_NO" || col.key === "TAG_DESC" || col.key === "status") { + skipReason = "System field"; + } else { + skipReason = "Not editable for this TAG"; + } + + // Log skipped field + skippedFields.push(`${col.label} (${skipReason})`); + + // Check if Excel contains a value for a read-only field and warn + const cellValue = rowValues[colIndex] ?? ""; + const stringVal = String(cellValue).trim(); + if (stringVal && existingRowData && String(existingRowData[col.key] || "").trim() !== stringVal) { + validationErrors.push({ + tagNo: tagNo || `Row-${rowNum}`, + rowIndex: rowNum, + columnKey: col.key, + columnLabel: col.label, + errorType: "READ_ONLY_FIELD", + errorMessage: `Attempting to modify read-only field. ${skipReason}.`, + currentValue: stringVal, + expectedFormat: `Field is read-only. Current value: ${existingRowData[col.key] || "empty"}`, + }); + hasErrors = true; + } + + return; // Skip processing Excel value for this column + } + + // Process Excel value for editable fields + const cellValue = rowValues[colIndex] ?? ""; + let stringVal = String(cellValue).trim(); + + // Type-specific validation + switch (col.type) { + case "STRING": + rowObj[col.key] = stringVal; + break; + + case "NUMBER": + if (stringVal) { + const num = parseFloat(stringVal); + if (isNaN(num)) { + validationErrors.push({ + tagNo: tagNo || `Row-${rowNum}`, + rowIndex: rowNum, + columnKey: col.key, + columnLabel: col.label, + errorType: "TYPE_MISMATCH", + errorMessage: "Value is not a valid number", + currentValue: stringVal, + expectedFormat: "Number", + }); + hasErrors = true; + } else { + rowObj[col.key] = num; + } + } else { + rowObj[col.key] = null; + } + break; + + case "LIST": + if (stringVal && col.options && !col.options.includes(stringVal)) { + validationErrors.push({ + tagNo: tagNo || `Row-${rowNum}`, + rowIndex: rowNum, + columnKey: col.key, + columnLabel: col.label, + errorType: "INVALID_OPTION", + errorMessage: "Value is not in the allowed options list", + currentValue: stringVal, + expectedFormat: col.options.join(", "), + }); + hasErrors = true; + } + rowObj[col.key] = stringVal; + break; + + default: + rowObj[col.key] = stringVal; + break; + } + }); + + // Log skipped fields for this TAG + if (skippedFields.length > 0) { + skippedFieldsLog.push({ + tagNo: tagNo, + fields: skippedFields + }); + } + + // Add to valid data only if no errors + if (!hasErrors) { + importedData.push(rowObj); + } + } + + // Show summary of skipped fields + if (skippedFieldsLog.length > 0) { + const totalSkippedFields = skippedFieldsLog.reduce((sum, log) => sum + log.fields.length, 0); + console.log("Skipped fields summary:", skippedFieldsLog); + toast.info( + `${totalSkippedFields} read-only fields were skipped across ${skippedFieldsLog.length} rows. Check console for details.` + ); + } + + // If there are validation errors, create error report and exit + if (validationErrors.length > 0) { + createImportErrorSheet(workbook, validationErrors); + + const outBuffer = await workbook.xlsx.writeBuffer(); + saveAs(new Blob([outBuffer]), `import-error-report_${Date.now()}.xlsx`); + + toast.error( + `Data validation failed. ${validationErrors.length} errors found across ${new Set(validationErrors.map(e => e.tagNo)).size} TAG(s). Check downloaded error report.` + ); + + return { + success: false, + error: "Data validation errors", + errorCount: validationErrors.length, + hasErrors: true, + skippedFields: skippedFieldsLog + }; + } + + // If we reached here, all data is valid + // Create locally merged data for UI update + const mergedData = [...tableData]; + const dataMap = new Map<string, GenericData>(); + + // Map existing data by TAG_NO + mergedData.forEach(item => { + if (item.TAG_NO) { + dataMap.set(item.TAG_NO, item); + } + }); + + // Update with imported data + importedData.forEach(item => { + if (item.TAG_NO) { + const existingItem = dataMap.get(item.TAG_NO); + if (existingItem) { + // Update existing item with imported values + Object.assign(existingItem, item); + } + } + }); + + // If formCode and contractItemId are provided, save directly to DB + // importExcelData 함수에서 DB 저장 부분 + if (formCode && contractItemId) { + try { + // 배치 업데이트 함수 호출 + const result = await updateFormDataBatchInDB( + formCode, + contractItemId, + importedData // 모든 imported rows를 한번에 전달 + ); + + if (result.success) { + // 로컬 상태 업데이트 + if (onDataUpdate) { + onDataUpdate(() => mergedData); + } + + // 성공 메시지 구성 + const { updatedCount, notFoundTags } = result.data || {}; + + let message = `Successfully updated ${updatedCount || importedData.length} rows`; + + // 건너뛴 필드가 있는 경우 + if (skippedFieldsLog.length > 0) { + const totalSkippedFields = skippedFieldsLog.reduce((sum, log) => sum + log.fields.length, 0); + message += ` (${totalSkippedFields} read-only fields preserved)`; + } + + // 찾을 수 없는 TAG가 있는 경우 + if (notFoundTags && notFoundTags.length > 0) { + console.warn("Tags not found in database:", notFoundTags); + message += `. Warning: ${notFoundTags.length} tags not found in database`; + } + + toast.success(message); + + return { + success: true, + importedCount: updatedCount || importedData.length, + message: message, + errorCount: 0, + hasErrors: false, + skippedFields: skippedFieldsLog, + notFoundTags: notFoundTags + }; + + } else { + // 배치 업데이트 실패 + console.error("Batch update failed:", result.message); + + // 부분 성공인 경우 + if (result.data?.updatedCount > 0) { + // 부분적으로라도 업데이트된 경우 로컬 상태 업데이트 + if (onDataUpdate) { + onDataUpdate(() => mergedData); + } + + toast.warning( + `Partially updated: ${result.data.updatedCount} of ${importedData.length} rows updated. ` + + `${result.data.failedCount || 0} failed.` + ); + + return { + success: true, // 부분 성공도 success로 처리 + importedCount: result.data.updatedCount, + message: result.message, + errorCount: result.data.failedCount || 0, + hasErrors: true, + skippedFields: skippedFieldsLog + }; + + } else { + // 완전 실패 + toast.error(result.message || "Failed to update data to database"); + + return { + success: false, + error: result.message, + errorCount: importedData.length, + hasErrors: true, + skippedFields: skippedFieldsLog + }; + } + } + + } catch (saveError) { + // 예외 발생 처리 + console.error("Failed to save imported data:", saveError); + + const errorMessage = saveError instanceof Error + ? saveError.message + : "Unknown error occurred"; + + toast.error(`Database update failed: ${errorMessage}`); + + return { + success: false, + error: saveError, + message: errorMessage, + errorCount: importedData.length, + hasErrors: true, + skippedFields: skippedFieldsLog + }; + } + + } else { + // formCode나 contractItemId가 없는 경우 - 로컬 업데이트만 + if (onDataUpdate) { + onDataUpdate(() => mergedData); + } + + const successMessage = skippedFieldsLog.length > 0 + ? `Imported ${importedData.length} rows successfully (read-only fields preserved)` + : `Imported ${importedData.length} rows successfully`; + + toast.success(`${successMessage} (local only - no database connection)`); + + return { + success: true, + importedCount: importedData.length, + message: "Data imported locally only", + errorCount: 0, + hasErrors: false, + skippedFields: skippedFieldsLog + }; + } + + } catch (err) { + console.error("Excel import error:", err); + toast.error("Excel import failed."); + return { + success: false, + error: err, + errorCount: 1, + hasErrors: true + }; + } finally { + if (onPendingChange) onPendingChange(false); + } +}
\ No newline at end of file diff --git a/components/form-data-plant/publish-dialog.tsx b/components/form-data-plant/publish-dialog.tsx new file mode 100644 index 00000000..a3a2ef0b --- /dev/null +++ b/components/form-data-plant/publish-dialog.tsx @@ -0,0 +1,470 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { useSession } from "next-auth/react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Button } from "@/components/ui/button"; +import { Loader2, Check, ChevronsUpDown } from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { + createRevisionAction, + fetchDocumentsByPackageId, + fetchStagesByDocumentId, + fetchRevisionsByStageParams, + Document, + IssueStage, + Revision +} from "@/lib/vendor-document/service"; + +interface PublishDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + packageId: number; + formCode: string; + fileBlob?: Blob; +} + +export const PublishDialog: React.FC<PublishDialogProps> = ({ + open, + onOpenChange, + packageId, + formCode, + fileBlob, +}) => { + // Get current user session from next-auth + const { data: session } = useSession(); + + // State for form data + const [documents, setDocuments] = useState<Document[]>([]); + const [stages, setStages] = useState<IssueStage[]>([]); + const [latestRevision, setLatestRevision] = useState<string>(""); + + // State for document search + const [openDocumentCombobox, setOpenDocumentCombobox] = useState(false); + const [documentSearchValue, setDocumentSearchValue] = useState(""); + + // Selected values + const [selectedDocId, setSelectedDocId] = useState<string>(""); + const [selectedDocumentDisplay, setSelectedDocumentDisplay] = useState<string>(""); + const [selectedStage, setSelectedStage] = useState<string>(""); + const [revisionInput, setRevisionInput] = useState<string>(""); + const [uploaderName, setUploaderName] = useState<string>(""); + const [comment, setComment] = useState<string>(""); + const [customFileName, setCustomFileName] = useState<string>(`${formCode}_document.docx`); + + // Loading states + const [isLoading, setIsLoading] = useState<boolean>(false); + const [isSubmitting, setIsSubmitting] = useState<boolean>(false); + + // Filter documents by search + const filteredDocuments = documentSearchValue + ? documents.filter(doc => + doc.docNumber.toLowerCase().includes(documentSearchValue.toLowerCase()) || + doc.title.toLowerCase().includes(documentSearchValue.toLowerCase()) + ) + : documents; + + // Set uploader name from session when dialog opens + useEffect(() => { + if (open && session?.user?.name) { + setUploaderName(session.user.name); + } + }, [open, session]); + + // Reset all fields when dialog opens/closes + useEffect(() => { + if (open) { + setSelectedDocId(""); + setSelectedDocumentDisplay(""); + setSelectedStage(""); + setRevisionInput(""); + // Only set uploaderName if not already set from session + if (!session?.user?.name) setUploaderName(""); + setComment(""); + setLatestRevision(""); + setCustomFileName(`${formCode}_document.docx`); + setDocumentSearchValue(""); + } + }, [open, formCode, session]); + + // Fetch documents based on packageId + useEffect(() => { + async function loadDocuments() { + if (packageId && open) { + setIsLoading(true); + + try { + const docs = await fetchDocumentsByPackageId(packageId); + setDocuments(docs); + } catch (error) { + console.error("Error fetching documents:", error); + toast.error("Failed to load documents"); + } finally { + setIsLoading(false); + } + } + } + + loadDocuments(); + }, [packageId, open]); + + // Fetch stages when document is selected + useEffect(() => { + async function loadStages() { + if (selectedDocId) { + setIsLoading(true); + + // Reset dependent fields + setSelectedStage(""); + setRevisionInput(""); + setLatestRevision(""); + + try { + const stagesList = await fetchStagesByDocumentId(parseInt(selectedDocId, 10)); + setStages(stagesList); + } catch (error) { + console.error("Error fetching stages:", error); + toast.error("Failed to load stages"); + } finally { + setIsLoading(false); + } + } else { + setStages([]); + } + } + + loadStages(); + }, [selectedDocId]); + + // Fetch latest revision when stage is selected (for reference) + useEffect(() => { + async function loadLatestRevision() { + if (selectedDocId && selectedStage) { + setIsLoading(true); + + try { + const revsList = await fetchRevisionsByStageParams( + parseInt(selectedDocId, 10), + selectedStage + ); + + // Find the latest revision (assuming revisions are sorted by revision number) + if (revsList.length > 0) { + // Sort revisions if needed + const sortedRevisions = [...revsList].sort((a, b) => { + return b.revision.localeCompare(a.revision, undefined, { numeric: true }); + }); + + setLatestRevision(sortedRevisions[0].revision); + + // Pre-fill the revision input with an incremented value if possible + if (sortedRevisions[0].revision.match(/^\d+$/)) { + // If it's a number, increment it + const nextRevision = String(parseInt(sortedRevisions[0].revision, 10) + 1); + setRevisionInput(nextRevision); + } else if (sortedRevisions[0].revision.match(/^[A-Za-z]$/)) { + // If it's a single letter, get the next letter + const currentChar = sortedRevisions[0].revision.charCodeAt(0); + const nextChar = String.fromCharCode(currentChar + 1); + setRevisionInput(nextChar); + } else { + // For other formats, just show the latest as reference + setRevisionInput(""); + } + } else { + // If no revisions exist, set default values + setLatestRevision(""); + setRevisionInput("0"); + } + } catch (error) { + console.error("Error fetching revisions:", error); + toast.error("Failed to load revision information"); + } finally { + setIsLoading(false); + } + } else { + setLatestRevision(""); + setRevisionInput(""); + } + } + + loadLatestRevision(); + }, [selectedDocId, selectedStage]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!selectedDocId || !selectedStage || !revisionInput || !fileBlob) { + toast.error("Please fill in all required fields"); + return; + } + + setIsSubmitting(true); + + try { + // Create FormData + const formData = new FormData(); + formData.append("documentId", selectedDocId); + formData.append("stage", selectedStage); + formData.append("revision", revisionInput); + formData.append("customFileName", customFileName); + formData.append("uploaderType", "vendor"); // Default value + + if (uploaderName) { + formData.append("uploaderName", uploaderName); + } + + if (comment) { + formData.append("comment", comment); + } + + // Append file as attachment + if (fileBlob) { + const file = new File([fileBlob], customFileName, { + type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + }); + formData.append("attachment", file); + } + + // Call server action directly + const result = await createRevisionAction(formData); + + if (result) { + toast.success("Document published successfully!"); + onOpenChange(false); + } + } catch (error) { + console.error("Error publishing document:", error); + toast.error("Failed to publish document"); + } finally { + setIsSubmitting(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[500px]"> + <DialogHeader> + <DialogTitle>Publish Document</DialogTitle> + <DialogDescription> + Select document, stage, and revision to publish the vendor document. + </DialogDescription> + </DialogHeader> + + <form onSubmit={handleSubmit}> + <div className="grid gap-4 py-4"> + {/* Document Selection with Search */} + <div className="grid grid-cols-4 items-center gap-4"> + <Label htmlFor="document" className="text-right"> + Document + </Label> + <div className="col-span-3"> + <Popover + open={openDocumentCombobox} + onOpenChange={setOpenDocumentCombobox} + > + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={openDocumentCombobox} + className="w-full justify-between" + disabled={isLoading || documents.length === 0} + > + {/* Add text-overflow handling for selected document display */} + <span className="truncate"> + {selectedDocumentDisplay + ? selectedDocumentDisplay + : "Select document..."} + </span> + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[400px] p-0"> + <Command> + <CommandInput + placeholder="Search document..." + value={documentSearchValue} + onValueChange={setDocumentSearchValue} + /> + <CommandEmpty>No document found.</CommandEmpty> + <CommandGroup className="max-h-[300px] overflow-auto"> + {filteredDocuments.map((doc) => ( + <CommandItem + key={doc.id} + value={`${doc.docNumber} - ${doc.title}`} + onSelect={() => { + setSelectedDocId(String(doc.id)); + setSelectedDocumentDisplay(`${doc.docNumber} - ${doc.title}`); + setOpenDocumentCombobox(false); + }} + className="flex items-center" + > + <Check + className={cn( + "mr-2 h-4 w-4 flex-shrink-0", + selectedDocId === String(doc.id) + ? "opacity-100" + : "opacity-0" + )} + /> + {/* Add text-overflow handling for document items */} + <span className="truncate">{doc.docNumber} - {doc.title}</span> + </CommandItem> + ))} + </CommandGroup> + </Command> + </PopoverContent> + </Popover> + </div> + </div> + + {/* Stage Selection */} + <div className="grid grid-cols-4 items-center gap-4"> + <Label htmlFor="stage" className="text-right"> + Stage + </Label> + <div className="col-span-3"> + <Select + value={selectedStage} + onValueChange={setSelectedStage} + disabled={isLoading || !selectedDocId || stages.length === 0} + > + <SelectTrigger> + <SelectValue placeholder="Select stage" /> + </SelectTrigger> + <SelectContent> + {stages.map((stage) => ( + <SelectItem key={stage.id} value={stage.stageName}> + {/* Add text-overflow handling for stage names */} + <span className="truncate">{stage.stageName}</span> + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + </div> + + {/* Revision Input */} + <div className="grid grid-cols-4 items-center gap-4"> + <Label htmlFor="revision" className="text-right"> + Revision + </Label> + <div className="col-span-3"> + <Input + id="revision" + value={revisionInput} + onChange={(e) => setRevisionInput(e.target.value)} + placeholder="Enter revision" + disabled={isLoading || !selectedStage} + /> + {latestRevision && ( + <p className="text-xs text-muted-foreground mt-1"> + Latest revision: {latestRevision} + </p> + )} + </div> + </div> + + <div className="grid grid-cols-4 items-center gap-4"> + <Label htmlFor="fileName" className="text-right"> + File Name + </Label> + <div className="col-span-3"> + <Input + id="fileName" + value={customFileName} + onChange={(e) => setCustomFileName(e.target.value)} + placeholder="Custom file name" + /> + </div> + </div> + + <div className="grid grid-cols-4 items-center gap-4"> + <Label htmlFor="uploaderName" className="text-right"> + Uploader + </Label> + <div className="col-span-3"> + <Input + id="uploaderName" + value={uploaderName} + onChange={(e) => setUploaderName(e.target.value)} + placeholder="Your name" + // Disable input but show a filled style + className={session?.user?.name ? "opacity-70" : ""} + readOnly={!!session?.user?.name} + /> + {session?.user?.name && ( + <p className="text-xs text-muted-foreground mt-1"> + Using your account name from login + </p> + )} + </div> + </div> + + <div className="grid grid-cols-4 items-center gap-4"> + <Label htmlFor="comment" className="text-right"> + Comment + </Label> + <div className="col-span-3"> + <Textarea + id="comment" + value={comment} + onChange={(e) => setComment(e.target.value)} + placeholder="Optional comment" + className="resize-none" + /> + </div> + </div> + </div> + + <DialogFooter> + <Button + type="submit" + disabled={isSubmitting || !selectedDocId || !selectedStage || !revisionInput} + > + {isSubmitting ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + Publishing... + </> + ) : ( + "Publish" + )} + </Button> + </DialogFooter> + </form> + </DialogContent> + </Dialog> + ); +};
\ No newline at end of file diff --git a/components/form-data-plant/sedp-compare-dialog.tsx b/components/form-data-plant/sedp-compare-dialog.tsx new file mode 100644 index 00000000..b481b4f8 --- /dev/null +++ b/components/form-data-plant/sedp-compare-dialog.tsx @@ -0,0 +1,618 @@ +import * as React from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Input } from "@/components/ui/input"; +import { Loader, RefreshCw, AlertCircle, CheckCircle, Info, EyeOff, ChevronDown, ChevronRight, Search } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { toast } from "sonner"; +import { DataTableColumnJSON } from "./form-data-table-columns"; +import { ExcelDownload } from "./sedp-excel-download"; +import { Switch } from "../ui/switch"; +import { Card, CardContent } from "@/components/ui/card"; +import { useTranslation } from "@/i18n/client" +import { useParams } from "next/navigation" +import { fetchTagDataFromSEDP } from "@/lib/forms-plant/sedp-actions"; + +interface SEDPCompareDialogProps { + isOpen: boolean; + onClose: () => void; + tableData: unknown[]; + columnsJSON: DataTableColumnJSON[]; + projectCode: string; + formCode: string; + projectType:string; + packageCode:string; +} + +interface ComparisonResult { + tagNo: string; + tagDesc: string; + isMatching: boolean; + attributes: { + key: string; + label: string; + localValue: unknown; + sedpValue: unknown; + isMatching: boolean; + uom?: string; + }[]; +} + +// Component for formatting display value with UOM +const DisplayValue = ({ value, uom, isSedp = false }: { value: unknown; uom?: string; isSedp?: boolean }) => { + if (value === "" || value === null || value === undefined) { + return <span className="text-muted-foreground italic">(empty)</span>; + } + + // SEDP 값은 UOM을 표시하지 않음 (이미 포함되어 있다고 가정) + if (isSedp) { + return <span>{value}</span>; + } + + // 로컬 값은 UOM과 함께 표시 + return ( + <span> + {value} + {uom && <span className="text-xs text-muted-foreground ml-1">{uom}</span>} + </span> + ); +}; + + +export function SEDPCompareDialog({ + isOpen, + onClose, + tableData, + columnsJSON, + projectCode, + formCode, + projectType, + packageCode +}: SEDPCompareDialogProps) { + + const params = useParams() || {} + const lng = params.lng ? String(params.lng) : "ko" + const { t } = useTranslation(lng, "engineering") + + // 범례 컴포넌트 + const ColorLegend = () => { + return ( + <div className="flex items-center gap-4 text-sm p-2 bg-muted/20 rounded"> + <div className="flex items-center gap-1.5"> + <Info className="h-4 w-4 text-muted-foreground" /> + <span className="font-medium">{t("labels.legend")}:</span> + </div> + <div className="flex items-center gap-3"> + <div className="flex items-center gap-1.5"> + <div className="h-3 w-3 rounded-full bg-red-500"></div> + <span className="line-through text-red-500">{t("labels.localValue")}</span> + </div> + <div className="flex items-center gap-1.5"> + <div className="h-3 w-3 rounded-full bg-green-500"></div> + <span className="text-green-500">{t("labels.sedpValue")}</span> + </div> + </div> + </div> + ); + }; + + // 확장 가능한 차이점 표시 컴포넌트 + const DifferencesCard = ({ + attributes, + columnLabelMap, + showOnlyDifferences + }: { + attributes: ComparisonResult['attributes']; + columnLabelMap: Record<string, string>; + showOnlyDifferences: boolean; + }) => { + const attributesToShow = showOnlyDifferences + ? attributes.filter(attr => !attr.isMatching) + : attributes; + + if (attributesToShow.length === 0) { + return ( + <div className="text-center text-muted-foreground py-4"> + {t("messages.allAttributesMatch")} + </div> + ); + } + + return ( + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 p-4"> + {attributesToShow.map((attr) => ( + <Card key={attr.key} className={`${!attr.isMatching ? 'border-red-200' : 'border-green-200'}`}> + <CardContent className="p-3"> + <div className="font-medium text-sm mb-2 truncate" title={attr.label}> + {attr.label} + {attr.uom && <span className="text-xs text-muted-foreground ml-1">({attr.uom})</span>} + </div> + {attr.isMatching ? ( + <div className="text-sm"> + <DisplayValue value={attr.localValue} uom={attr.uom} /> + </div> + ) : ( + <div className="space-y-2"> + <div className="flex items-center gap-2 text-sm"> + <span className="text-xs text-muted-foreground min-w-[35px]">로컬:</span> + <span className="line-through text-red-500 flex-1"> + <DisplayValue value={attr.localValue} uom={attr.uom} isSedp={false} /> + </span> + </div> + <div className="flex items-center gap-2 text-sm"> + <span className="text-xs text-muted-foreground min-w-[35px]">SEDP:</span> + <span className="text-green-500 flex-1"> + <DisplayValue value={attr.sedpValue} uom={attr.uom} isSedp={true} /> + </span> + </div> + </div> + )} + </CardContent> + </Card> + ))} + </div> + ); + }; + + + const [isLoading, setIsLoading] = React.useState(false); + const [comparisonResults, setComparisonResults] = React.useState<ComparisonResult[]>([]); + const [activeTab, setActiveTab] = React.useState("all"); + const [isExporting, setIsExporting] = React.useState(false); + const [missingTags, setMissingTags] = React.useState<{ + localOnly: { tagNo: string; tagDesc: string }[]; + sedpOnly: { tagNo: string; tagDesc: string }[]; + }>({ localOnly: [], sedpOnly: [] }); + const [showOnlyDifferences, setShowOnlyDifferences] = React.useState(true); + const [searchTerm, setSearchTerm] = React.useState(""); + const [expandedRows, setExpandedRows] = React.useState<Set<string>>(new Set()); + + // Stats for summary + const totalTags = comparisonResults.length; + const matchingTags = comparisonResults.filter(r => r.isMatching).length; + const nonMatchingTags = totalTags - matchingTags; + const totalMissingTags = missingTags.localOnly.length + missingTags.sedpOnly.length; + + // Get column label map and UOM map for better display + const { columnLabelMap, columnUomMap } = React.useMemo(() => { + const labelMap: Record<string, string> = {}; + const uomMap: Record<string, string> = {}; + + columnsJSON.forEach(col => { + labelMap[col.key] = col.displayLabel || col.label; + if (col.uom) { + uomMap[col.key] = col.uom; + } + }); + + return { columnLabelMap: labelMap, columnUomMap: uomMap }; + }, [columnsJSON]); + + // Filter and search results + const filteredResults = React.useMemo(() => { + let results = comparisonResults; + + // Filter by tab + switch (activeTab) { + case "matching": + results = results.filter(r => r.isMatching); + break; + case "differences": + results = results.filter(r => !r.isMatching); + break; + case "all": + default: + break; + } + + // Apply search filter + if (searchTerm.trim()) { + const search = searchTerm.toLowerCase(); + results = results.filter(r => + r.tagNo.toLowerCase().includes(search) || + r.tagDesc.toLowerCase().includes(search) + ); + } + + return results; + }, [comparisonResults, activeTab, searchTerm]); + + // Toggle row expansion + const toggleRowExpansion = (tagNo: string) => { + const newExpanded = new Set(expandedRows); + if (newExpanded.has(tagNo)) { + newExpanded.delete(tagNo); + } else { + newExpanded.add(tagNo); + } + setExpandedRows(newExpanded); + }; + + // Auto-expand rows with differences when switching to differences tab + React.useEffect(() => { + if (activeTab === "differences") { + const newExpanded = new Set<string>(); + filteredResults.filter(r => !r.isMatching).forEach(r => { + newExpanded.add(r.tagNo); + }); + setExpandedRows(newExpanded); + } + }, [activeTab, filteredResults]); + + const fetchAndCompareData = React.useCallback(async () => { + if (!projectCode || !formCode) { + toast.error("Project code or form code is missing"); + return; + } + + try { + setIsLoading(true); + + // Fetch data from SEDP API + const sedpData = await fetchTagDataFromSEDP(projectCode, formCode); + + // Get the table name from the response + const tableName = Object.keys(sedpData)[0]; + const sedpTagEntries = sedpData[tableName] || []; + + // Create a map of SEDP data by TAG_NO for quick lookup + const sedpTagMap = new Map(); + + const packageCodeAttId = projectType === "ship" ? "CM3003" : "ME5074"; + + + const tagEntries = sedpTagEntries.filter(entry => { + if (Array.isArray(entry.ATTRIBUTES)) { + const packageCodeAttr = entry.ATTRIBUTES.find(attr => attr.ATT_ID === packageCodeAttId); + if (packageCodeAttr && packageCodeAttr.VALUE === packageCode) { + return true; + } + } + return false; + }); + + + tagEntries.forEach((entry: Record<string, unknown>) => { + const tagNo = entry.TAG_NO; + const attributesMap = new Map(); + + // Convert attributes array to map for easier access + if (Array.isArray(entry.ATTRIBUTES)) { + entry.ATTRIBUTES.forEach((attr: Record<string, unknown>) => { + attributesMap.set(attr.ATT_ID, attr.VALUE); + }); + } + + sedpTagMap.set(tagNo, { + tagDesc: entry.TAG_DESC, + attributes: attributesMap + }); + }); + + // Create sets for finding missing tags + const localTagNos = new Set(tableData.map(item => item.TAG_NO)); + const sedpTagNos = new Set(sedpTagMap.keys()); + + // Find missing tags + const localOnlyTags = tableData + .filter(item => !sedpTagMap.has(item.TAG_NO)) + .map(item => ({ tagNo: item.TAG_NO, tagDesc: item.TAG_DESC || "" })); + + const sedpOnlyTags = Array.from(sedpTagMap.entries()) + .filter(([tagNo]) => !localTagNos.has(tagNo)) + .map(([tagNo, data]) => ({ tagNo, tagDesc: data.tagDesc || "" })); + + setMissingTags({ + localOnly: localOnlyTags, + sedpOnly: sedpOnlyTags + }); + + // Compare with local table data (only for tags that exist in both systems) + const results: ComparisonResult[] = tableData + .filter(localItem => sedpTagMap.has(localItem.TAG_NO)) + .map(localItem => { + const tagNo = localItem.TAG_NO; + const sedpItem = sedpTagMap.get(tagNo); + + // Compare attributes + const attributeComparisons = columnsJSON + .filter(col => col.key !== "TAG_NO" && col.key !== "TAG_DESC" && col.key !== "status"&& col.key !== "CLS_ID") + .map(col => { + const localValue = localItem[col.key]; + const sedpValue = sedpItem.attributes.get(col.key); + const uom = columnUomMap[col.key]; + + // Compare values (with type handling) + let isMatching = false; + + // Special case: Empty SEDP value and 0 local value + if ((sedpValue === "" || sedpValue === null || sedpValue === undefined) && + (localValue === 0 || localValue === "0")) { + isMatching = true; + } else { + // Standard string comparison for other cases + const normalizedLocal = localValue === undefined || localValue === null ? "" : String(localValue).trim(); + const normalizedSedp = sedpValue === undefined || sedpValue === null ? "" : String(sedpValue).trim(); + isMatching = normalizedLocal === normalizedSedp; + } + + return { + key: col.key, + label: columnLabelMap[col.key] || col.key, + localValue, + sedpValue, + isMatching, + uom + }; + }); + + // Item is matching if all attributes match + const isItemMatching = attributeComparisons.every(attr => attr.isMatching); + + return { + tagNo, + tagDesc: localItem.TAG_DESC || "", + isMatching: isItemMatching, + attributes: attributeComparisons + }; + }); + + setComparisonResults(results); + + // Show summary in toast + const matchCount = results.filter(r => r.isMatching).length; + const nonMatchCount = results.length - matchCount; + const missingCount = localOnlyTags.length + sedpOnlyTags.length; + + if (missingCount > 0) { + toast.error(`Found ${missingCount} missing tags between systems`); + } + + if (nonMatchCount > 0) { + toast.warning(`Found ${nonMatchCount} tags with differences`); + } else if (results.length > 0 && missingCount === 0) { + toast.success(`All ${results.length} tags match with SEDP data`); + } else if (results.length === 0 && missingCount === 0) { + toast.info("No tags to compare"); + } + + } catch (error) { + console.error("SEDP comparison error:", error); + toast.error(`Failed to compare with SEDP: ${error instanceof Error ? error.message : 'Unknown error'}`); + } finally { + setIsLoading(false); + } + }, [projectCode, formCode, tableData, columnsJSON, columnLabelMap, columnUomMap]); + + // Fetch data when dialog opens + React.useEffect(() => { + if (isOpen) { + fetchAndCompareData(); + } + }, [isOpen, fetchAndCompareData]); + + return ( + <Dialog open={isOpen} onOpenChange={onClose}> + <DialogContent className="max-w-6xl max-h-[90vh] overflow-hidden flex flex-col"> + <DialogHeader> + <DialogTitle className="mb-2">{t("dialogs.sedpDataComparison")}</DialogTitle> + <div className="flex items-center justify-between gap-2 pr-8"> + <div className="flex items-center gap-4"> + <div className="flex items-center gap-2"> + <Switch + checked={showOnlyDifferences} + onCheckedChange={setShowOnlyDifferences} + id="show-differences" + /> + <label htmlFor="show-differences" className="text-sm cursor-pointer"> + {t("switches.showOnlyDifferences")} + </label> + </div> + + {/* 검색 입력 */} + <div className="relative"> + <Search className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" /> + <Input + placeholder={t("placeholders.searchTagOrDesc")} + value={searchTerm} + onChange={(e) => setSearchTerm(e.target.value)} + className="pl-8 w-64" + /> + </div> + </div> + + <div className="flex items-center gap-2"> + <Badge variant={matchingTags === totalTags && totalMissingTags === 0 ? "default" : "destructive"}> + {matchingTags} / {totalTags} 일치 {totalMissingTags > 0 ? `(${totalMissingTags} 누락)` : ''} + </Badge> + <Button + variant="outline" + size="sm" + onClick={fetchAndCompareData} + disabled={isLoading} + > + {isLoading ? ( + <Loader className="h-4 w-4 animate-spin" /> + ) : ( + <RefreshCw className="h-4 w-4" /> + )} + <span className="ml-2">{t("buttons.refresh")}</span> + </Button> + </div> + </div> + </DialogHeader> + + {/* 범례 */} + <div className="mb-4"> + <ColorLegend /> + </div> + + <Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col overflow-hidden"> + <TabsList> + <TabsTrigger value="all">{t("tabs.allTags")} ({totalTags})</TabsTrigger> + <TabsTrigger value="differences">{t("tabs.differences")} ({nonMatchingTags})</TabsTrigger> + <TabsTrigger value="matching">{t("tabs.matching")} ({matchingTags})</TabsTrigger> + <TabsTrigger value="missing" className={totalMissingTags > 0 ? "text-red-500" : ""}> + {t("tabs.missingTags")} ({totalMissingTags}) + </TabsTrigger> + </TabsList> + + <TabsContent value={activeTab} className="flex-1 overflow-auto"> + {isLoading ? ( + <div className="flex items-center justify-center h-full"> + <Loader className="h-8 w-8 animate-spin mr-2" /> + <span>{t("messages.dataComparing")}</span> + </div> + ) : activeTab === "missing" ? ( + // Missing tags tab content + <div className="space-y-6"> + {missingTags.localOnly.length > 0 && ( + <div> + <h3 className="text-sm font-medium mb-2">{t("sections.localOnlyTags")} ({missingTags.localOnly.length})</h3> + <Table> + <TableHeader className="sticky top-0 bg-background z-10"> + <TableRow> + <TableHead className="w-[180px]">{t("labels.tagNo")}</TableHead> + <TableHead>{t("labels.description")}</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {missingTags.localOnly.map((tag) => ( + <TableRow key={tag.tagNo} className="bg-yellow-50 dark:bg-yellow-950/20"> + <TableCell className="font-medium">{tag.tagNo}</TableCell> + <TableCell>{tag.tagDesc}</TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </div> + )} + + {missingTags.sedpOnly.length > 0 && ( + <div> + <h3 className="text-sm font-medium mb-2">{t("sections.sedpOnlyTags")} ({missingTags.sedpOnly.length})</h3> + <Table> + <TableHeader className="sticky top-0 bg-background z-10"> + <TableRow> + <TableHead className="w-[180px]">{t("labels.tagNo")}</TableHead> + <TableHead>{t("labels.description")}</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {missingTags.sedpOnly.map((tag) => ( + <TableRow key={tag.tagNo} className="bg-blue-50 dark:bg-blue-950/20"> + <TableCell className="font-medium">{tag.tagNo}</TableCell> + <TableCell>{tag.tagDesc}</TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </div> + )} + + {totalMissingTags === 0 && ( + <div className="flex items-center justify-center h-full text-muted-foreground"> + {t("messages.allTagsExistInBothSystems")} + </div> + )} + </div> + ) : filteredResults.length > 0 ? ( + // 개선된 확장 가능한 테이블 구조 + <div className="border rounded-md"> + <Table> + <TableHeader className="sticky top-0 bg-muted/50 z-10"> + <TableRow> + <TableHead className="w-12"></TableHead> + <TableHead className="w-[180px]">{t("labels.tagNo")}</TableHead> + <TableHead className="w-[250px]">{t("labels.description")}</TableHead> + <TableHead className="w-[120px]">{t("labels.status")}</TableHead> + <TableHead>차이점 개수</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {filteredResults.map((result) => ( + <React.Fragment key={result.tagNo}> + {/* 메인 행 */} + <TableRow + className={`hover:bg-muted/20 cursor-pointer ${!result.isMatching ? "bg-muted/10" : ""}`} + onClick={() => toggleRowExpansion(result.tagNo)} + > + <TableCell> + {result.attributes.some(attr => !attr.isMatching) ? ( + expandedRows.has(result.tagNo) ? ( + <ChevronDown className="h-4 w-4" /> + ) : ( + <ChevronRight className="h-4 w-4" /> + ) + ) : null} + </TableCell> + <TableCell className="font-medium"> + {result.tagNo} + </TableCell> + <TableCell title={result.tagDesc}> + <div className="truncate"> + {result.tagDesc} + </div> + </TableCell> + <TableCell> + {result.isMatching ? ( + <Badge variant="default" className="flex items-center gap-1"> + <CheckCircle className="h-3 w-3" /> + <span>{t("labels.matching")}</span> + </Badge> + ) : ( + <Badge variant="destructive" className="flex items-center gap-1"> + <AlertCircle className="h-3 w-3" /> + <span>{t("labels.different")}</span> + </Badge> + )} + </TableCell> + <TableCell> + {!result.isMatching && ( + <span className="text-sm text-muted-foreground"> + {result.attributes.filter(attr => !attr.isMatching).length}{t("sections.differenceCount")} + </span> + )} + </TableCell> + </TableRow> + + {/* 확장된 차이점 표시 행 */} + {expandedRows.has(result.tagNo) && ( + <TableRow> + <TableCell colSpan={5} className="p-0 bg-muted/5"> + <DifferencesCard + attributes={result.attributes} + columnLabelMap={columnLabelMap} + showOnlyDifferences={showOnlyDifferences} + /> + </TableCell> + </TableRow> + )} + </React.Fragment> + ))} + </TableBody> + </Table> + </div> + ) : ( + <div className="flex items-center justify-center h-full text-muted-foreground"> + {searchTerm ? t("messages.noSearchResults") : t("messages.noMatchingTags")} + </div> + )} + </TabsContent> + </Tabs> + + <DialogFooter className="flex justify-between items-center gap-4 pt-4 border-t"> + <ExcelDownload + comparisonResults={comparisonResults} + missingTags={missingTags} + formCode={formCode} + disabled={isLoading || (nonMatchingTags === 0 && totalMissingTags === 0)} + /> + <Button onClick={onClose}>{t("buttons.close")}</Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/components/form-data-plant/sedp-components.tsx b/components/form-data-plant/sedp-components.tsx new file mode 100644 index 00000000..869f730c --- /dev/null +++ b/components/form-data-plant/sedp-components.tsx @@ -0,0 +1,193 @@ +"use client"; + +import * as React from "react"; +import { useParams } from "next/navigation"; +import { useTranslation } from "@/i18n/client"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from "@/components/ui/dialog"; +import { Loader, Send, AlertTriangle, CheckCircle } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Progress } from "@/components/ui/progress"; + +// SEDP Send Confirmation Dialog +export function SEDPConfirmationDialog({ + isOpen, + onClose, + onConfirm, + formName, + tagCount, + isLoading +}: { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + formName: string; + tagCount: number; + isLoading: boolean; +}) { + const params = useParams(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "engineering"); + + return ( + <Dialog open={isOpen} onOpenChange={onClose}> + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle>{t("sedp.sendDataTitle")}</DialogTitle> + <DialogDescription> + {t("sedp.sendDataDescription")} + </DialogDescription> + </DialogHeader> + + <div className="py-4"> + <div className="grid grid-cols-2 gap-4 mb-4"> + <div className="text-muted-foreground">{t("sedp.formName")}:</div> + <div className="font-medium">{formName}</div> + + <div className="text-muted-foreground">{t("sedp.totalTags")}:</div> + <div className="font-medium">{tagCount}</div> + </div> + + <div className="bg-amber-50 p-3 rounded-md border border-amber-200 flex items-start gap-2"> + <AlertTriangle className="h-5 w-5 text-amber-500 mt-0.5 flex-shrink-0" /> + <div className="text-sm text-amber-800"> + {t("sedp.warningMessage")} + </div> + </div> + </div> + + <DialogFooter className="gap-2 sm:gap-0"> + <Button variant="outline" onClick={onClose} disabled={isLoading}> + {t("buttons.cancel")} + </Button> + <Button + variant="samsung" + onClick={onConfirm} + disabled={isLoading} + className="gap-2" + > + {isLoading ? ( + <> + <Loader className="h-4 w-4 animate-spin" /> + {t("sedp.sending")} + </> + ) : ( + <> + <Send className="h-4 w-4" /> + {t("sedp.sendToSEDP")} + </> + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} + +// SEDP Status Dialog - shows the result of the SEDP operation +export function SEDPStatusDialog({ + isOpen, + onClose, + status, + message, + successCount, + errorCount, + totalCount +}: { + isOpen: boolean; + onClose: () => void; + status: 'success' | 'error' | 'partial'; + message: string; + successCount: number; + errorCount: number; + totalCount: number; +}) { + const params = useParams(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "engineering"); + + // Calculate percentage for the progress bar + const percentage = Math.round((successCount / totalCount) * 100); + + const getStatusTitle = () => { + switch (status) { + case 'success': + return t("sedp.dataSentSuccessfully"); + case 'partial': + return t("sedp.partiallySuccessful"); + case 'error': + default: + return t("sedp.failedToSendData"); + } + }; + + return ( + <Dialog open={isOpen} onOpenChange={onClose}> + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle> + {getStatusTitle()} + </DialogTitle> + </DialogHeader> + + <div className="py-4"> + {/* Status Icon */} + <div className="flex justify-center mb-4"> + {status === 'success' ? ( + <div className="h-12 w-12 rounded-full bg-green-100 flex items-center justify-center"> + <CheckCircle className="h-8 w-8 text-green-600" /> + </div> + ) : status === 'partial' ? ( + <div className="h-12 w-12 rounded-full bg-amber-100 flex items-center justify-center"> + <AlertTriangle className="h-8 w-8 text-amber-600" /> + </div> + ) : ( + <div className="h-12 w-12 rounded-full bg-red-100 flex items-center justify-center"> + <AlertTriangle className="h-8 w-8 text-red-600" /> + </div> + )} + </div> + + {/* Message */} + <p className="text-center mb-4">{message}</p> + + {/* Progress Stats */} + <div className="space-y-2 mb-4"> + <div className="flex justify-between text-sm"> + <span>{t("sedp.progress")}</span> + <span>{percentage}%</span> + </div> + <Progress value={percentage} className="h-2" /> + <div className="flex justify-between text-sm pt-1"> + <div> + <Badge variant="outline" className="bg-green-50 text-green-700 hover:bg-green-50"> + {t("sedp.successfulCount", { count: successCount })} + </Badge> + </div> + {errorCount > 0 && ( + <div> + <Badge variant="outline" className="bg-red-50 text-red-700 hover:bg-red-50"> + {t("sedp.failedCount", { count: errorCount })} + </Badge> + </div> + )} + </div> + </div> + </div> + + <DialogFooter> + <Button onClick={onClose}> + {t("buttons.close")} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/components/form-data-plant/sedp-excel-download.tsx b/components/form-data-plant/sedp-excel-download.tsx new file mode 100644 index 00000000..36be4847 --- /dev/null +++ b/components/form-data-plant/sedp-excel-download.tsx @@ -0,0 +1,259 @@ +import * as React from "react"; +import { useParams } from "next/navigation"; +import { useTranslation } from "@/i18n/client"; +import { Button } from "@/components/ui/button"; +import { FileDown, Loader } from "lucide-react"; +import { toast } from "sonner"; +import * as ExcelJS from 'exceljs'; + +interface ExcelDownloadProps { + comparisonResults: Array<{ + tagNo: string; + tagDesc: string; + isMatching: boolean; + attributes: Array<{ + key: string; + label: string; + localValue: any; + sedpValue: any; + isMatching: boolean; + uom?: string; + }>; + }>; + missingTags: { + localOnly: Array<{ tagNo: string; tagDesc: string }>; + sedpOnly: Array<{ tagNo: string; tagDesc: string }>; + }; + formCode: string; + disabled: boolean; +} + +export function ExcelDownload({ comparisonResults, missingTags, formCode, disabled }: ExcelDownloadProps) { + const [isExporting, setIsExporting] = React.useState(false); + const params = useParams(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "engineering"); + + // Function to generate and download Excel file with differences + const handleExportDifferences = async () => { + try { + setIsExporting(true); + + // Get only items with differences + const itemsWithDifferences = comparisonResults.filter(item => !item.isMatching); + const hasMissingTags = missingTags.localOnly.length > 0 || missingTags.sedpOnly.length > 0; + + if (itemsWithDifferences.length === 0 && !hasMissingTags) { + toast.info(t("excelDownload.noDifferencesToDownload")); + return; + } + + // Create a new workbook + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'SEDP Compare Tool'; + workbook.created = new Date(); + + // Add a worksheet for attribute differences + if (itemsWithDifferences.length > 0) { + const worksheet = workbook.addWorksheet(t("excelDownload.attributeDifferencesSheet")); + + // Add headers + worksheet.columns = [ + { header: t("excelDownload.tagNumber"), key: 'tagNo', width: 20 }, + { header: t("excelDownload.tagDescription"), key: 'tagDesc', width: 30 }, + { header: t("excelDownload.attribute"), key: 'attribute', width: 25 }, + { header: t("excelDownload.localValue"), key: 'localValue', width: 20 }, + { header: t("excelDownload.sedpValue"), key: 'sedpValue', width: 20 } + ]; + + // Style the header row + const headerRow = worksheet.getRow(1); + headerRow.eachCell((cell) => { + cell.font = { bold: true }; + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE0E0E0' } + }; + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + }); + + // Add data rows + let rowIndex = 2; + itemsWithDifferences.forEach(item => { + const differences = item.attributes.filter(attr => !attr.isMatching); + + if (differences.length === 0) return; + + differences.forEach(diff => { + const row = worksheet.getRow(rowIndex++); + + // Format local value with UOM + const localDisplay = diff.localValue === null || diff.localValue === undefined || diff.localValue === '' + ? t("excelDownload.emptyValue") + : diff.uom ? `${diff.localValue} ${diff.uom}` : diff.localValue; + + // SEDP value is displayed as-is + const sedpDisplay = diff.sedpValue === null || diff.sedpValue === undefined || diff.sedpValue === '' + ? t("excelDownload.emptyValue") + : diff.sedpValue; + + // Set cell values + row.getCell('tagNo').value = item.tagNo; + row.getCell('tagDesc').value = item.tagDesc; + row.getCell('attribute').value = diff.label; + row.getCell('localValue').value = localDisplay; + row.getCell('sedpValue').value = sedpDisplay; + + // Style the row + row.getCell('localValue').font = { color: { argb: 'FFFF0000' } }; // Red for local value + row.getCell('sedpValue').font = { color: { argb: 'FF008000' } }; // Green for SEDP value + + // Add borders + row.eachCell((cell) => { + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + }); + }); + + // Add a blank row after each tag for better readability + rowIndex++; + }); + } + + // Add a worksheet for missing tags if there are any + if (hasMissingTags) { + const missingWorksheet = workbook.addWorksheet(t("excelDownload.missingTagsSheet")); + + // Add headers + missingWorksheet.columns = [ + { header: t("excelDownload.tagNumber"), key: 'tagNo', width: 20 }, + { header: t("excelDownload.tagDescription"), key: 'tagDesc', width: 30 }, + { header: t("excelDownload.status"), key: 'status', width: 20 } + ]; + + // Style the header row + const headerRow = missingWorksheet.getRow(1); + headerRow.eachCell((cell) => { + cell.font = { bold: true }; + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE0E0E0' } + }; + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + }); + + // Add local-only tags + let rowIndex = 2; + missingTags.localOnly.forEach(tag => { + const row = missingWorksheet.getRow(rowIndex++); + + row.getCell('tagNo').value = tag.tagNo; + row.getCell('tagDesc').value = tag.tagDesc; + row.getCell('status').value = t("excelDownload.localOnlyStatus"); + + // Style the status cell + row.getCell('status').font = { color: { argb: 'FFFF8C00' } }; // Orange for local-only + + // Add borders + row.eachCell((cell) => { + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + }); + }); + + // Add a blank row + if (missingTags.localOnly.length > 0 && missingTags.sedpOnly.length > 0) { + rowIndex++; + } + + // Add SEDP-only tags + missingTags.sedpOnly.forEach(tag => { + const row = missingWorksheet.getRow(rowIndex++); + + row.getCell('tagNo').value = tag.tagNo; + row.getCell('tagDesc').value = tag.tagDesc; + row.getCell('status').value = t("excelDownload.sedpOnlyStatus"); + + // Style the status cell + row.getCell('status').font = { color: { argb: 'FF0000FF' } }; // Blue for SEDP-only + + // Add borders + row.eachCell((cell) => { + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + }); + }); + } + + // Generate Excel file + const buffer = await workbook.xlsx.writeBuffer(); + + // Create a Blob from the buffer + const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); + + // Create a download link and trigger the download + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${t("excelDownload.fileNamePrefix")}_${formCode}_${new Date().toISOString().slice(0, 10)}.xlsx`; + document.body.appendChild(a); + a.click(); + + // Clean up + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + + toast.success(t("excelDownload.downloadComplete")); + } catch (error) { + console.error("Error exporting to Excel:", error); + toast.error(t("excelDownload.downloadFailed")); + } finally { + setIsExporting(false); + } + }; + + // Determine if there are any differences or missing tags + const hasDifferences = comparisonResults.some(item => !item.isMatching); + const hasMissingTags = missingTags && (missingTags.localOnly.length > 0 || missingTags.sedpOnly.length > 0); + const hasExportableContent = hasDifferences || hasMissingTags; + + return ( + <Button + variant="secondary" + onClick={handleExportDifferences} + disabled={disabled || isExporting || !hasExportableContent} + className="flex items-center gap-2" + > + {isExporting ? ( + <Loader className="h-4 w-4 animate-spin" /> + ) : ( + <FileDown className="h-4 w-4" /> + )} + {t("excelDownload.downloadButtonText")} + </Button> + ); +}
\ No newline at end of file diff --git a/components/form-data-plant/spreadJS-dialog.tsx b/components/form-data-plant/spreadJS-dialog.tsx new file mode 100644 index 00000000..2eb2c8ba --- /dev/null +++ b/components/form-data-plant/spreadJS-dialog.tsx @@ -0,0 +1,1733 @@ +"use client"; + +import * as React from "react"; +import dynamic from "next/dynamic"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { GenericData } from "./export-excel-form"; +import * as GC from "@mescius/spread-sheets"; +import { toast } from "sonner"; +import { updateFormDataInDB } from "@/lib/forms-plant/services"; +import { Loader, Save, AlertTriangle } from "lucide-react"; +import '@mescius/spread-sheets/styles/gc.spread.sheets.excel2016colorful.css'; +import { DataTableColumnJSON, ColumnType } from "./form-data-table-columns"; +import { setupSpreadJSLicense } from "@/lib/spread-js/license-utils"; + +const SpreadSheets = dynamic( + () => import("@mescius/spread-sheets-react").then(mod => mod.SpreadSheets), + { + ssr: false, + loading: () => ( + <div className="flex items-center justify-center h-full"> + <Loader className="mr-2 h-4 w-4 animate-spin" /> + Loading SpreadSheets... + </div> + ) + } +); + +// 도메인별 라이선스 설정 +if (typeof window !== 'undefined') { + setupSpreadJSLicense(GC); +} + +interface TemplateItem { + TMPL_ID: string; + NAME: string; + TMPL_TYPE: string; + SPR_LST_SETUP: { + ACT_SHEET: string; + HIDN_SHEETS: Array<string>; + CONTENT?: string; + DATA_SHEETS: Array<{ + SHEET_NAME: string; + REG_TYPE_ID: string; + MAP_CELL_ATT: Array<{ + ATT_ID: string; + IN: string; + }>; + }>; + }; + GRD_LST_SETUP: { + REG_TYPE_ID: string; + SPR_ITM_IDS: Array<string>; + ATTS: Array<{}>; + }; + SPR_ITM_LST_SETUP: { + ACT_SHEET: string; + HIDN_SHEETS: Array<string>; + CONTENT?: string; + DATA_SHEETS: Array<{ + SHEET_NAME: string; + REG_TYPE_ID: string; + MAP_CELL_ATT: Array<{ + ATT_ID: string; + IN: string; + }>; + }>; + }; +} + +interface ValidationError { + cellAddress: string; + attId: string; + value: any; + expectedType: ColumnType; + message: string; +} + +interface CellMapping { + attId: string; + cellAddress: string; + isEditable: boolean; + dataRowIndex?: number; +} + +interface TemplateViewDialogProps { + isOpen: boolean; + onClose: () => void; + templateData: TemplateItem[] | any; + selectedRow?: GenericData; + tableData?: GenericData[]; + formCode: string; + columnsJSON: DataTableColumnJSON[] + contractItemId: number; + editableFieldsMap?: Map<string, string[]>; + onUpdateSuccess?: (updatedValues: Record<string, any> | GenericData[]) => void; +} + +// 🚀 로딩 프로그레스 컴포넌트 +interface LoadingProgressProps { + phase: string; + progress: number; + total: number; + isVisible: boolean; +} + +const LoadingProgress: React.FC<LoadingProgressProps> = ({ phase, progress, total, isVisible }) => { + const percentage = total > 0 ? Math.round((progress / total) * 100) : 0; + + if (!isVisible) return null; + + return ( + <div className="absolute inset-0 bg-white/90 flex items-center justify-center z-50"> + <div className="bg-white rounded-lg shadow-lg border p-6 min-w-[300px]"> + <div className="flex items-center space-x-3 mb-4"> + <Loader className="h-5 w-5 animate-spin text-blue-600" /> + <span className="font-medium text-gray-900">Loading Template</span> + </div> + + <div className="space-y-2"> + <div className="text-sm text-gray-600">{phase}</div> + <div className="w-full bg-gray-200 rounded-full h-2"> + <div + className="bg-blue-600 h-2 rounded-full transition-all duration-300 ease-out" + style={{ width: `${percentage}%` }} + /> + </div> + <div className="text-xs text-gray-500 text-right"> + {progress.toLocaleString()} / {total.toLocaleString()} ({percentage}%) + </div> + </div> + </div> + </div> + ); +}; + +export function TemplateViewDialog({ + isOpen, + onClose, + templateData, + selectedRow, + tableData = [], + formCode, + contractItemId, + columnsJSON, + editableFieldsMap = new Map(), + onUpdateSuccess +}: TemplateViewDialogProps) { + const [hostStyle, setHostStyle] = React.useState({ + width: '100%', + height: '100%' + }); + + const [isPending, setIsPending] = React.useState(false); + const [hasChanges, setHasChanges] = React.useState(false); + const [currentSpread, setCurrentSpread] = React.useState<any>(null); + const [cellMappings, setCellMappings] = React.useState<CellMapping[]>([]); + const [isClient, setIsClient] = React.useState(false); + const [templateType, setTemplateType] = React.useState<'SPREAD_LIST' | 'SPREAD_ITEM' | 'GRD_LIST' | null>(null); + const [validationErrors, setValidationErrors] = React.useState<ValidationError[]>([]); + const [selectedTemplateId, setSelectedTemplateId] = React.useState<string>(""); + const [availableTemplates, setAvailableTemplates] = React.useState<TemplateItem[]>([]); + + // 🆕 로딩 상태 추가 + const [loadingProgress, setLoadingProgress] = React.useState<{ + phase: string; + progress: number; + total: number; + } | null>(null); + const [isInitializing, setIsInitializing] = React.useState(false); + + // 🔄 진행상황 업데이트 함수 + const updateProgress = React.useCallback((phase: string, progress: number, total: number) => { + setLoadingProgress({ phase, progress, total }); + }, []); + + const determineTemplateType = React.useCallback((template: TemplateItem): 'SPREAD_LIST' | 'SPREAD_ITEM' | 'GRD_LIST' | null => { + if (template?.TMPL_TYPE === "SPREAD_LIST" && (template?.SPR_LST_SETUP?.CONTENT || template?.SPR_ITM_LST_SETUP?.CONTENT)) { + return 'SPREAD_LIST'; + } + if (template?.TMPL_TYPE === "SPREAD_ITEM" && (template?.SPR_LST_SETUP?.CONTENT || template?.SPR_ITM_LST_SETUP?.CONTENT)) { + return 'SPREAD_ITEM'; + } + if (template?.GRD_LST_SETUP && columnsJSON.length > 0) { + return 'GRD_LIST'; + } + return null; + }, [columnsJSON]); + + const isValidTemplate = React.useCallback((template: TemplateItem): boolean => { + // 🔍 TMPL_ID 필수 검증 추가 + if (!template || !template.TMPL_ID || typeof template.TMPL_ID !== 'string') { + console.warn('⚠️ Invalid template: missing or invalid TMPL_ID', template); + return false; + } + return determineTemplateType(template) !== null; + }, [determineTemplateType]); + + React.useEffect(() => { + setIsClient(true); + }, []); + + React.useEffect(() => { + // 🔍 받은 templateData 로깅 (디버깅용) + console.log('🎨 TemplateViewDialog received templateData:', { + isNull: templateData === null, + isUndefined: templateData === undefined, + isArray: Array.isArray(templateData), + length: Array.isArray(templateData) ? templateData.length : 'N/A', + data: templateData + }); + + // 템플릿 데이터가 없거나 빈 배열인 경우 기본 GRD_LIST 템플릿 생성 + if (!templateData || (Array.isArray(templateData) && templateData.length === 0)) { + // columnsJSON이 있으면 기본 GRD_LIST 템플릿 생성 + if (columnsJSON && columnsJSON.length > 0) { + const defaultGrdTemplate: TemplateItem = { + TMPL_ID: 'DEFAULT_GRD_LIST', + NAME: 'Default Grid View', + TMPL_TYPE: 'GRD_LIST', + SPR_LST_SETUP: { + ACT_SHEET: '', + HIDN_SHEETS: [], + DATA_SHEETS: [] + }, + GRD_LST_SETUP: { + REG_TYPE_ID: 'DEFAULT', + SPR_ITM_IDS: [], + ATTS: [] + }, + SPR_ITM_LST_SETUP: { + ACT_SHEET: '', + HIDN_SHEETS: [], + DATA_SHEETS: [] + } + }; + + setAvailableTemplates([defaultGrdTemplate]); + // setSelectedTemplateId('DEFAULT_GRD_LIST'); + // setTemplateType('GRD_LIST'); + console.log('📋 Created default GRD_LIST template'); + } + return; + } + + let templates: TemplateItem[]; + if (Array.isArray(templateData)) { + templates = templateData as TemplateItem[]; + } else { + templates = [templateData as TemplateItem]; + } + + // 🔍 각 템플릿의 TMPL_ID 확인 + console.log('🔍 Processing templates:', templates.length); + templates.forEach((tmpl, idx) => { + console.log(` [${idx}] TMPL_ID: ${tmpl?.TMPL_ID || '❌ MISSING'}, NAME: ${tmpl?.NAME || 'N/A'}, TYPE: ${tmpl?.TMPL_TYPE || 'N/A'}`); + if (!tmpl?.TMPL_ID) { + console.error(`❌ Template at index ${idx} is missing TMPL_ID:`, tmpl); + } + }); + + const validTemplates = templates.filter(isValidTemplate); + console.log(`✅ Valid templates after filtering: ${validTemplates.length}`); + + // 유효한 템플릿이 없지만 columnsJSON이 있으면 기본 GRD_LIST 추가 + if (validTemplates.length === 0 && columnsJSON && columnsJSON.length > 0) { + const defaultGrdTemplate: TemplateItem = { + TMPL_ID: 'DEFAULT_GRD_LIST', + NAME: 'Default Grid View', + TMPL_TYPE: 'GRD_LIST', + SPR_LST_SETUP: { + ACT_SHEET: '', + HIDN_SHEETS: [], + DATA_SHEETS: [] + }, + GRD_LST_SETUP: { + REG_TYPE_ID: 'DEFAULT', + SPR_ITM_IDS: [], + ATTS: [] + }, + SPR_ITM_LST_SETUP: { + ACT_SHEET: '', + HIDN_SHEETS: [], + DATA_SHEETS: [] + } + }; + + validTemplates.push(defaultGrdTemplate); + console.log('📋 Added default GRD_LIST template to empty template list'); + } + + setAvailableTemplates(validTemplates); + + // 🔍 최종 availableTemplates 로깅 + console.log('📋 availableTemplates set:', validTemplates.map(t => ({ + TMPL_ID: t.TMPL_ID, + NAME: t.NAME, + TYPE: t.TMPL_TYPE + }))); + + if (validTemplates.length > 0) { + // 🔍 현재 선택된 템플릿이 availableTemplates에 있는지 확인 + const selectedExists = selectedTemplateId && validTemplates.some(t => t.TMPL_ID === selectedTemplateId); + + if (!selectedExists) { + // 선택된 템플릿이 없거나 유효하지 않으면 첫 번째 템플릿 선택 + const firstTemplate = validTemplates[0]; + if (firstTemplate?.TMPL_ID) { + const templateTypeToSet = determineTemplateType(firstTemplate); + console.log(`🎯 ${selectedTemplateId ? 'Re-selecting' : 'Auto-selecting'} first template: ${firstTemplate.TMPL_ID} (${templateTypeToSet})`); + if (selectedTemplateId) { + console.warn(`⚠️ Previously selected "${selectedTemplateId}" not found in availableTemplates, switching to "${firstTemplate.TMPL_ID}"`); + } + setSelectedTemplateId(firstTemplate.TMPL_ID); + setTemplateType(templateTypeToSet); + } else { + console.error('❌ First valid template has no TMPL_ID:', firstTemplate); + } + } else { + console.log(`✅ Template already selected and valid: ${selectedTemplateId}`); + } + } + }, [templateData, selectedTemplateId, isValidTemplate, determineTemplateType, columnsJSON]); + + const handleTemplateChange = (templateId: string) => { + const template = availableTemplates.find(t => t?.TMPL_ID === templateId); + + // 🔍 템플릿과 TMPL_ID 검증 + if (!template || !template.TMPL_ID) { + console.error('❌ Template not found or invalid TMPL_ID:', templateId); + return; + } + + const templateTypeToSet = determineTemplateType(template); + setSelectedTemplateId(templateId); + setTemplateType(templateTypeToSet); + setHasChanges(false); + setValidationErrors([]); + + if (currentSpread) { + initSpread(currentSpread, template); + } + }; + + const selectedTemplate = React.useMemo(() => { + console.log('🔍 Finding template:', { + selectedTemplateId, + availableCount: availableTemplates.length, + availableIds: availableTemplates.map(t => t?.TMPL_ID) + }); + + const found = availableTemplates.find(t => t?.TMPL_ID === selectedTemplateId); + + if (!found && selectedTemplateId) { + console.warn('⚠️ Selected template not found:', { + searching: selectedTemplateId, + available: availableTemplates.map(t => t?.TMPL_ID), + availableTemplates: availableTemplates + }); + } else if (found) { + console.log('✅ Template found:', { + TMPL_ID: found.TMPL_ID, + NAME: found.NAME, + TYPE: found.TMPL_TYPE + }); + } + + return found; + }, [availableTemplates, selectedTemplateId]); + + const editableFields = React.useMemo(() => { + // SPREAD_ITEM의 경우에만 전역 editableFields 사용 + if (templateType === 'SPREAD_ITEM' && selectedRow?.TAG_NO) { + if (!editableFieldsMap.has(selectedRow.TAG_NO)) { + return []; + } + return editableFieldsMap.get(selectedRow.TAG_NO) || []; + } + + // SPREAD_LIST나 GRD_LIST의 경우 전역 editableFields는 사용하지 않음 + return []; + }, [templateType, selectedRow?.TAG_NO, editableFieldsMap]); + + + const isFieldEditable = React.useCallback((attId: string, rowData?: GenericData) => { + const columnConfig = columnsJSON.find(col => col.key === attId); + if (columnConfig?.shi === "OUT" || columnConfig?.shi === null) { + return false; + } + + if (attId === "TAG_NO" || attId === "TAG_DESC" || attId === "status") { + return false; + } + + if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') { + // 각 행의 TAG_NO를 기준으로 편집 가능 여부 판단 + if (!rowData?.TAG_NO || !editableFieldsMap.has(rowData.TAG_NO)) { + return false; + } + + const rowEditableFields = editableFieldsMap.get(rowData.TAG_NO) || []; + if (!rowEditableFields.includes(attId)) { + return false; + } + + if (rowData && (rowData.shi === "OUT" || rowData.shi === null)) { + return false; + } + return true; + } + + // SPREAD_ITEM의 경우 기존 로직 유지 + if (templateType === 'SPREAD_ITEM') { + return editableFields.includes(attId); + } + + return true; + }, [templateType, columnsJSON, editableFieldsMap]); // editableFields 의존성 제거 + + const editableFieldsCount = React.useMemo(() => { + if (templateType === 'SPREAD_ITEM') { + // SPREAD_ITEM의 경우 기존 로직 유지 + return cellMappings.filter(m => m.isEditable).length; + } + + if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') { + // 각 행별로 편집 가능한 필드 수를 계산 + let totalEditableCount = 0; + + tableData.forEach((rowData, rowIndex) => { + cellMappings.forEach(mapping => { + if (mapping.dataRowIndex === rowIndex) { + if (isFieldEditable(mapping.attId, rowData)) { + totalEditableCount++; + } + } + }); + }); + + return totalEditableCount; + } + + return cellMappings.filter(m => m.isEditable).length; + }, [cellMappings, templateType, tableData, isFieldEditable]); + + // 🚀 배치 처리 함수들 + const setBatchValues = React.useCallback(( + activeSheet: any, + valuesToSet: Array<{ row: number, col: number, value: any }> + ) => { + console.log(`🚀 Setting ${valuesToSet.length} values in batch`); + + const columnGroups = new Map<number, Array<{ row: number, value: any }>>(); + + valuesToSet.forEach(({ row, col, value }) => { + if (!columnGroups.has(col)) { + columnGroups.set(col, []); + } + columnGroups.get(col)!.push({ row, value }); + }); + + columnGroups.forEach((values, col) => { + values.sort((a, b) => a.row - b.row); + + let start = 0; + while (start < values.length) { + let end = start; + while (end + 1 < values.length && values[end + 1].row === values[end].row + 1) { + end++; + } + + const rangeValues = values.slice(start, end + 1).map(v => v.value); + const startRow = values[start].row; + + try { + if (rangeValues.length === 1) { + activeSheet.setValue(startRow, col, rangeValues[0]); + } else { + const dataArray = rangeValues.map(v => [v]); + activeSheet.setArray(startRow, col, dataArray); + } + } catch (error) { + for (let i = start; i <= end; i++) { + try { + activeSheet.setValue(values[i].row, col, values[i].value); + } catch (cellError) { + console.warn(`⚠️ Individual value setting failed [${values[i].row}, ${col}]:`, cellError); + } + } + } + + start = end + 1; + } + }); + }, []); + + const createCellStyle = React.useCallback((activeSheet: any, row: number, col: number, isEditable: boolean) => { + // 기존 스타일 가져오기 (없으면 새로 생성) + const existingStyle = activeSheet.getStyle(row, col) || new GC.Spread.Sheets.Style(); + + // backColor만 수정 + if (isEditable) { + existingStyle.backColor = "#bbf7d0"; + } else { + existingStyle.backColor = "#e5e7eb"; + // 읽기 전용일 때만 텍스트 색상 변경 (선택사항) + existingStyle.foreColor = "#4b5563"; + } + + return existingStyle; + }, []); + + const setBatchStyles = React.useCallback(( + activeSheet: any, + stylesToSet: Array<{ row: number, col: number, isEditable: boolean }> + ) => { + console.log(`🎨 Setting ${stylesToSet.length} styles in batch`); + + // 🔧 개별 셀별로 스타일과 잠금 상태 설정 (편집 권한 보장) + stylesToSet.forEach(({ row, col, isEditable }) => { + try { + const cell = activeSheet.getCell(row, col); + const style = createCellStyle(activeSheet, row, col, isEditable); + + activeSheet.setStyle(row, col, style); + cell.locked(!isEditable); // 편집 가능하면 잠금 해제 + + // 🆕 편집 가능한 셀에 기본 텍스트 에디터 설정 + if (isEditable) { + const textCellType = new GC.Spread.Sheets.CellTypes.Text(); + activeSheet.setCellType(row, col, textCellType); + } + } catch (error) { + console.warn(`⚠️ Failed to set style for cell [${row}, ${col}]:`, error); + } + }); + }, [createCellStyle]); + + const parseCellAddress = (address: string): { row: number, col: number } | null => { + if (!address || address.trim() === "") return null; + + const match = address.match(/^([A-Z]+)(\d+)$/); + if (!match) return null; + + const [, colStr, rowStr] = match; + + let col = 0; + for (let i = 0; i < colStr.length; i++) { + col = col * 26 + (colStr.charCodeAt(i) - 65 + 1); + } + col -= 1; + + const row = parseInt(rowStr) - 1; + return { row, col }; + }; + + const getCellAddress = (row: number, col: number): string => { + let colStr = ''; + let colNum = col; + while (colNum >= 0) { + colStr = String.fromCharCode((colNum % 26) + 65) + colStr; + colNum = Math.floor(colNum / 26) - 1; + } + return colStr + (row + 1); + }; + + const validateCellValue = (value: any, columnType: ColumnType, options?: string[]): string | null => { + if (value === undefined || value === null || value === "") { + return null; + } + + switch (columnType) { + case "NUMBER": + if (isNaN(Number(value))) { + return "Value must be a valid number"; + } + break; + case "LIST": + if (options && !options.includes(String(value))) { + return `Value must be one of: ${options.join(", ")}`; + } + break; + case "STRING": + break; + default: + break; + } + + return null; + }; + + const validateAllData = React.useCallback(() => { + if (!currentSpread || !selectedTemplate) return []; + + const activeSheet = currentSpread.getActiveSheet(); + const errors: ValidationError[] = []; + + cellMappings.forEach(mapping => { + const columnConfig = columnsJSON.find(col => col.key === mapping.attId); + if (!columnConfig) return; + + const cellPos = parseCellAddress(mapping.cellAddress); + if (!cellPos) return; + + const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); + const errorMessage = validateCellValue(cellValue, columnConfig.type, columnConfig.options); + + if (errorMessage) { + errors.push({ + cellAddress: mapping.cellAddress, + attId: mapping.attId, + value: cellValue, + expectedType: columnConfig.type, + message: errorMessage + }); + } + }); + + setValidationErrors(errors); + return errors; + }, [currentSpread, selectedTemplate, cellMappings, columnsJSON]); + + + + const setupOptimizedListValidation = React.useCallback((activeSheet: any, cellPos: { row: number, col: number }, options: string[], rowCount: number) => { + try { + console.log(`🎯 Setting up dropdown for ${rowCount} rows with options:`, options); + + const safeOptions = options + .filter(opt => opt !== null && opt !== undefined && opt !== '') + .map(opt => String(opt).trim()) + .filter(opt => opt.length > 0) + .filter((opt, index, arr) => arr.indexOf(opt) === index) + .slice(0, 20); + + if (safeOptions.length === 0) { + console.warn(`⚠️ No valid options found, skipping`); + return; + } + + const optionsString = safeOptions.join(','); + + for (let i = 0; i < rowCount; i++) { + try { + const targetRow = cellPos.row + i; + + // 🔧 각 셀마다 새로운 ComboBox 인스턴스 생성 + const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox(); + comboBoxCellType.items(safeOptions); + comboBoxCellType.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); + + // 🔧 DataValidation 설정 + const cellValidator = GC.Spread.Sheets.DataValidation.createListValidator(optionsString); + cellValidator.showInputMessage(false); + cellValidator.showErrorMessage(false); + + // ComboBox와 Validator 적용 + activeSheet.setCellType(targetRow, cellPos.col, comboBoxCellType); + activeSheet.setDataValidator(targetRow, cellPos.col, cellValidator); + + // 🚀 중요: 셀 잠금 해제 및 편집 가능 설정 + const cell = activeSheet.getCell(targetRow, cellPos.col); + cell.locked(false); + + console.log(`✅ Dropdown applied to [${targetRow}, ${cellPos.col}] with ${safeOptions.length} options`); + + } catch (cellError) { + console.warn(`⚠️ Failed to apply dropdown to row ${cellPos.row + i}:`, cellError); + } + } + + console.log(`✅ Dropdown setup completed for ${rowCount} cells`); + + } catch (error) { + console.error('❌ Dropdown setup failed:', error); + } + }, []); + + const getSafeActiveSheet = React.useCallback((spread: any, functionName: string = 'unknown') => { + if (!spread) return null; + + try { + let activeSheet = spread.getActiveSheet(); + if (!activeSheet) { + const sheetCount = spread.getSheetCount(); + if (sheetCount > 0) { + activeSheet = spread.getSheet(0); + if (activeSheet) { + spread.setActiveSheetIndex(0); + } + } + } + return activeSheet; + } catch (error) { + console.error(`❌ Error getting activeSheet in ${functionName}:`, error); + return null; + } + }, []); + + const ensureRowCapacity = React.useCallback((activeSheet: any, requiredRowCount: number) => { + try { + if (!activeSheet) return false; + + const currentRowCount = activeSheet.getRowCount(); + if (requiredRowCount > currentRowCount) { + const newRowCount = requiredRowCount + 10; + activeSheet.setRowCount(newRowCount); + } + return true; + } catch (error) { + console.error('❌ Error in ensureRowCapacity:', error); + return false; + } + }, []); + + const ensureColumnCapacity = React.useCallback((activeSheet: any, requiredColumnCount: number) => { + try { + if (!activeSheet) return false; + + const currentColumnCount = activeSheet.getColumnCount(); + if (requiredColumnCount > currentColumnCount) { + const newColumnCount = requiredColumnCount + 10; + activeSheet.setColumnCount(newColumnCount); + } + return true; + } catch (error) { + console.error('❌ Error in ensureColumnCapacity:', error); + return false; + } + }, []); + + const setOptimalColumnWidths = React.useCallback((activeSheet: any, columns: any[], startCol: number, tableData: any[]) => { + columns.forEach((column, colIndex) => { + const targetCol = startCol + colIndex; + const optimalWidth = column.type === 'NUMBER' ? 100 : column.type === 'STRING' ? 150 : 120; + activeSheet.setColumnWidth(targetCol, optimalWidth); + }); + }, []); + + // 🚀 최적화된 GRD_LIST 생성 + // 🚀 최적화된 GRD_LIST 생성 (TAG_DESC 컬럼 틀고정 포함) + const createGrdListTableOptimized = React.useCallback((activeSheet: any, template: TemplateItem) => { + console.log('🚀 Creating optimized GRD_LIST table with TAG_DESC freeze'); + + const visibleColumns = columnsJSON + .filter(col => col.hidden !== true) + .sort((a, b) => (a.seq ?? 999999) - (b.seq ?? 999999)); + + if (visibleColumns.length === 0) return []; + + const startCol = 1; + const dataStartRow = 1; + const mappings: CellMapping[] = []; + + ensureColumnCapacity(activeSheet, startCol + visibleColumns.length); + ensureRowCapacity(activeSheet, dataStartRow + tableData.length); + + // 🧊 TAG_DESC 컬럼 위치 찾기 (틀고정용) + const tagDescColumnIndex = visibleColumns.findIndex(col => col.key === 'TAG_DESC'); + let freezeColumnCount = 0; + + if (tagDescColumnIndex !== -1) { + // TAG_DESC 컬럼까지 포함해서 고정 (startCol + tagDescColumnIndex + 1) + freezeColumnCount = startCol + tagDescColumnIndex + 1; + console.log(`🧊 TAG_DESC found at column index ${tagDescColumnIndex}, freezing ${freezeColumnCount} columns`); + } else { + // TAG_DESC가 없으면 TAG_NO까지만 고정 (일반적으로 첫 번째 컬럼) + const tagNoColumnIndex = visibleColumns.findIndex(col => col.key === 'TAG_NO'); + if (tagNoColumnIndex !== -1) { + freezeColumnCount = startCol + tagNoColumnIndex + 1; + console.log(`🧊 TAG_NO found at column index ${tagNoColumnIndex}, freezing ${freezeColumnCount} columns`); + } + } + + // 헤더 생성 + const headerStyle = new GC.Spread.Sheets.Style(); + headerStyle.backColor = "#3b82f6"; + headerStyle.foreColor = "#ffffff"; + headerStyle.font = "bold 12px Arial"; + headerStyle.hAlign = GC.Spread.Sheets.HorizontalAlign.center; + + visibleColumns.forEach((column, colIndex) => { + const targetCol = startCol + colIndex; + const cell = activeSheet.getCell(0, targetCol); + cell.value(column.label); + cell.locked(true); + activeSheet.setStyle(0, targetCol, headerStyle); + }); + + // 🚀 데이터 배치 처리 준비 + const allValues: Array<{ row: number, col: number, value: any }> = []; + const allStyles: Array<{ row: number, col: number, isEditable: boolean }> = []; + + // 🔧 편집 가능한 셀 정보 수집 (드롭다운용) + const dropdownConfigs: Array<{ + startRow: number; + col: number; + rowCount: number; + options: string[]; + editableRows: number[]; // 편집 가능한 행만 추적 + }> = []; + + visibleColumns.forEach((column, colIndex) => { + const targetCol = startCol + colIndex; + + // 드롭다운 설정을 위한 편집 가능한 행 찾기 + if (column.type === "LIST" && column.options) { + const editableRows: number[] = []; + tableData.forEach((rowData, rowIndex) => { + if (isFieldEditable(column.key, rowData)) { // rowData 전달 + editableRows.push(dataStartRow + rowIndex); + } + }); + + if (editableRows.length > 0) { + dropdownConfigs.push({ + startRow: dataStartRow, + col: targetCol, + rowCount: tableData.length, + options: column.options, + editableRows: editableRows + }); + } + } + + tableData.forEach((rowData, rowIndex) => { + const targetRow = dataStartRow + rowIndex; + const cellEditable = isFieldEditable(column.key, rowData); // rowData 전달 + const value = rowData[column.key]; + + mappings.push({ + attId: column.key, + cellAddress: getCellAddress(targetRow, targetCol), + isEditable: cellEditable, + dataRowIndex: rowIndex + }); + + allValues.push({ + row: targetRow, + col: targetCol, + value: value ?? null + }); + + allStyles.push({ + row: targetRow, + col: targetCol, + isEditable: cellEditable + }); + }); + }); + + // 🚀 배치로 값과 스타일 설정 + setBatchValues(activeSheet, allValues); + setBatchStyles(activeSheet, allStyles); + + // 🎯 개선된 드롭다운 설정 (편집 가능한 셀에만) + dropdownConfigs.forEach(({ col, options, editableRows }) => { + try { + console.log(`🎯 Setting dropdown for column ${col}, editable rows: ${editableRows.length}`); + + const safeOptions = options + .filter(opt => opt !== null && opt !== undefined && opt !== '') + .map(opt => String(opt).trim()) + .filter(opt => opt.length > 0) + .slice(0, 20); + + if (safeOptions.length === 0) return; + + // 편집 가능한 행에만 드롭다운 적용 + editableRows.forEach(targetRow => { + try { + const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox(); + comboBoxCellType.items(safeOptions); + comboBoxCellType.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); + + const cellValidator = GC.Spread.Sheets.DataValidation.createListValidator(safeOptions.join(',')); + cellValidator.showInputMessage(false); + cellValidator.showErrorMessage(false); + + activeSheet.setCellType(targetRow, col, comboBoxCellType); + activeSheet.setDataValidator(targetRow, col, cellValidator); + + // 🚀 편집 권한 명시적 설정 + const cell = activeSheet.getCell(targetRow, col); + cell.locked(false); + + console.log(`✅ Dropdown applied to editable cell [${targetRow}, ${col}]`); + } catch (cellError) { + console.warn(`⚠️ Failed to apply dropdown to [${targetRow}, ${col}]:`, cellError); + } + }); + } catch (error) { + console.error(`❌ Dropdown config failed for column ${col}:`, error); + } + }); + + // 🧊 틀고정 설정 + if (freezeColumnCount > 0) { + try { + activeSheet.frozenColumnCount(freezeColumnCount); + activeSheet.frozenRowCount(1); // 헤더 행도 고정 + + console.log(`🧊 Freeze applied: ${freezeColumnCount} columns, 1 row (header)`); + + // 🎨 고정된 컬럼에 특별한 스타일 추가 (선택사항) + for (let col = 0; col < freezeColumnCount; col++) { + for (let row = 0; row <= tableData.length; row++) { + try { + const currentStyle = activeSheet.getStyle(row, col) || new GC.Spread.Sheets.Style(); + + if (row === 0) { + // 헤더는 기존 스타일 유지 + continue; + } else { + // 데이터 셀에 고정 구분선 추가 + if (col === freezeColumnCount - 1) { + currentStyle.borderRight = new GC.Spread.Sheets.LineBorder("#2563eb", GC.Spread.Sheets.LineStyle.medium); + activeSheet.setStyle(row, col, currentStyle); + } + } + } catch (styleError) { + console.warn(`⚠️ Failed to apply freeze border style to [${row}, ${col}]:`, styleError); + } + } + } + } catch (freezeError) { + console.error('❌ Failed to apply freeze:', freezeError); + } + } + + setOptimalColumnWidths(activeSheet, visibleColumns, startCol, tableData); + + console.log(`✅ Optimized GRD_LIST created with freeze:`); + console.log(` - Total mappings: ${mappings.length}`); + console.log(` - Editable cells: ${mappings.filter(m => m.isEditable).length}`); + console.log(` - Dropdown configs: ${dropdownConfigs.length}`); + console.log(` - Frozen columns: ${freezeColumnCount}`); + + return mappings; + }, [tableData, columnsJSON, isFieldEditable, setBatchValues, setBatchStyles, ensureColumnCapacity, ensureRowCapacity, getCellAddress, setOptimalColumnWidths]); + + const setupSheetProtectionAndEvents = React.useCallback((activeSheet: any, mappings: CellMapping[]) => { + console.log(`🛡️ Setting up protection and events for ${mappings.length} mappings`); + + // 🔧 시트 보호 완전 해제 후 편집 권한 설정 + activeSheet.options.isProtected = false; + + // 🔧 편집 가능한 셀들을 위한 강화된 설정 + mappings.forEach((mapping) => { + const cellPos = parseCellAddress(mapping.cellAddress); + if (!cellPos) return; + + try { + const cell = activeSheet.getCell(cellPos.row, cellPos.col); + const columnConfig = columnsJSON.find(col => col.key === mapping.attId); + + if (mapping.isEditable) { + // 🚀 편집 가능한 셀 설정 강화 + cell.locked(false); + + if (columnConfig?.type === "LIST" && columnConfig.options) { + // LIST 타입: 새 ComboBox 인스턴스 생성 + const comboBox = new GC.Spread.Sheets.CellTypes.ComboBox(); + comboBox.items(columnConfig.options); + comboBox.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); + activeSheet.setCellType(cellPos.row, cellPos.col, comboBox); + + // DataValidation도 추가 + const validator = GC.Spread.Sheets.DataValidation.createListValidator(columnConfig.options.join(',')); + activeSheet.setDataValidator(cellPos.row, cellPos.col, validator); + } else if (columnConfig?.type === "NUMBER") { + // NUMBER 타입: 숫자 입력 허용 + const textCellType = new GC.Spread.Sheets.CellTypes.Text(); + activeSheet.setCellType(cellPos.row, cellPos.col, textCellType); + + // 숫자 validation 추가 (에러 메시지 없이) + const numberValidator = GC.Spread.Sheets.DataValidation.createNumberValidator( + GC.Spread.Sheets.ConditionalFormatting.ComparisonOperators.between, + -999999999, 999999999, true + ); + numberValidator.showInputMessage(false); + numberValidator.showErrorMessage(false); + activeSheet.setDataValidator(cellPos.row, cellPos.col, numberValidator); + } else { + // 기본 TEXT 타입: 자유 텍스트 입력 + const textCellType = new GC.Spread.Sheets.CellTypes.Text(); + activeSheet.setCellType(cellPos.row, cellPos.col, textCellType); + } + + // 편집 가능 스타일 재적용 + const editableStyle = createCellStyle(activeSheet, cellPos.row, cellPos.col, true); + activeSheet.setStyle(cellPos.row, cellPos.col, editableStyle); + + console.log(`🔓 Cell [${cellPos.row}, ${cellPos.col}] ${mapping.attId} set as EDITABLE`); + } else { + // 읽기 전용 셀 + cell.locked(true); + const readonlyStyle = createCellStyle(activeSheet, cellPos.row, cellPos.col, false); + activeSheet.setStyle(cellPos.row, cellPos.col, readonlyStyle); + } + } catch (error) { + console.error(`❌ Error processing cell ${mapping.cellAddress}:`, error); + } + }); + + // 🛡️ 시트 보호 재설정 (편집 허용 모드로) + activeSheet.options.isProtected = false; + activeSheet.options.protectionOptions = { + allowSelectLockedCells: true, + allowSelectUnlockedCells: true, + allowSort: false, + allowFilter: false, + allowEditObjects: true, // ✅ 편집 객체 허용 + allowResizeRows: false, + allowResizeColumns: false, + allowFormatCells: false, + allowInsertRows: false, + allowInsertColumns: false, + allowDeleteRows: false, + allowDeleteColumns: false + }; + + // 🎯 변경 감지 이벤트 + const changeEvents = [ + GC.Spread.Sheets.Events.CellChanged, + GC.Spread.Sheets.Events.ValueChanged, + GC.Spread.Sheets.Events.ClipboardPasted + ]; + + changeEvents.forEach(eventType => { + activeSheet.bind(eventType, () => { + console.log(`📝 ${eventType} detected`); + setHasChanges(true); + }); + }); + + // 🚫 편집 시작 권한 확인 + activeSheet.bind(GC.Spread.Sheets.Events.EditStarting, (event: any, info: any) => { + console.log(`🎯 EditStarting: Row ${info.row}, Col ${info.col}`); + + const exactMapping = mappings.find(m => { + const cellPos = parseCellAddress(m.cellAddress); + return cellPos && cellPos.row === info.row && cellPos.col === info.col; + }); + + if (!exactMapping) { + console.log(`ℹ️ No mapping found for [${info.row}, ${info.col}] - allowing edit`); + return; // 매핑이 없으면 허용 + } + + console.log(`📋 Found mapping: ${exactMapping.attId}, isEditable: ${exactMapping.isEditable}`); + + if (!exactMapping.isEditable) { + console.log(`🚫 Field ${exactMapping.attId} is not editable`); + toast.warning(`${exactMapping.attId} field is read-only`); + info.cancel = true; + return; + } + + // SPREAD_LIST/GRD_LIST 개별 행 SHI 확인 + if ((templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && exactMapping.dataRowIndex !== undefined) { + const dataRowIndex = exactMapping.dataRowIndex; + if (dataRowIndex >= 0 && dataRowIndex < tableData.length) { + const rowData = tableData[dataRowIndex]; + if (rowData?.shi === "OUT" || rowData?.shi === null) { + console.log(`🚫 Row ${dataRowIndex} is in SHI mode`); + toast.warning(`Row ${dataRowIndex + 1}: ${exactMapping.attId} field is read-only (SHI mode)`); + info.cancel = true; + return; + } + } + } + + console.log(`✅ Edit allowed for ${exactMapping.attId}`); + }); + + // ✅ 편집 완료 검증 + activeSheet.bind(GC.Spread.Sheets.Events.EditEnded, (event: any, info: any) => { + console.log(`🏁 EditEnded: Row ${info.row}, Col ${info.col}, New value: ${activeSheet.getValue(info.row, info.col)}`); + + const exactMapping = mappings.find(m => { + const cellPos = parseCellAddress(m.cellAddress); + return cellPos && cellPos.row === info.row && cellPos.col === info.col; + }); + + if (!exactMapping) return; + + const columnConfig = columnsJSON.find(col => col.key === exactMapping.attId); + if (columnConfig) { + const cellValue = activeSheet.getValue(info.row, info.col); + const errorMessage = validateCellValue(cellValue, columnConfig.type, columnConfig.options); + const cell = activeSheet.getCell(info.row, info.col); + + if (errorMessage) { + // 🚨 에러 스타일 적용 + const errorStyle = new GC.Spread.Sheets.Style(); + errorStyle.backColor = "#fef2f2"; + errorStyle.foreColor = "#dc2626"; + errorStyle.borderLeft = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); + errorStyle.borderRight = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); + errorStyle.borderTop = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); + errorStyle.borderBottom = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); + + activeSheet.setStyle(info.row, info.col, errorStyle); + cell.locked(!exactMapping.isEditable); // 편집 가능 상태 유지 + toast.warning(`Invalid value in ${exactMapping.attId}: ${errorMessage}`, { duration: 5000 }); + } else { + // ✅ 정상 스타일 복원 + const normalStyle = createCellStyle(activeSheet, info.row, info.col, exactMapping.isEditable); + activeSheet.setStyle(info.row, info.col, normalStyle); + cell.locked(!exactMapping.isEditable); + } + } + + setHasChanges(true); + }); + + console.log(`🛡️ Protection configured. Editable cells: ${mappings.filter(m => m.isEditable).length}`); + }, [templateType, tableData, createCellStyle, validateCellValue, columnsJSON]); + + // 🚀 최적화된 initSpread + const initSpread = React.useCallback(async (spread: any, template?: TemplateItem) => { + const workingTemplate = template || selectedTemplate; + if (!spread || !workingTemplate) { + console.error('❌ Invalid spread or template'); + return; + } + + try { + console.log('🚀 Starting optimized spread initialization...'); + setIsInitializing(true); + updateProgress('Initializing...', 0, 100); + + setCurrentSpread(spread); + setHasChanges(false); + setValidationErrors([]); + + // 🚀 핵심 최적화: 모든 렌더링과 이벤트 중단 + spread.suspendPaint(); + spread.suspendEvent(); + spread.suspendCalcService(); + + updateProgress('Setting up workspace...', 10, 100); + + try { + let activeSheet = getSafeActiveSheet(spread, 'initSpread'); + if (!activeSheet) { + throw new Error('Failed to get initial activeSheet'); + } + + activeSheet.options.isProtected = false; + let mappings: CellMapping[] = []; + + if (templateType === 'GRD_LIST') { + updateProgress('Creating dynamic table...', 20, 100); + + spread.clearSheets(); + spread.addSheet(0); + const sheet = spread.getSheet(0); + sheet.name('Data'); + spread.setActiveSheet('Data'); + + updateProgress('Processing table data...', 50, 100); + mappings = createGrdListTableOptimized(sheet, workingTemplate); + + } else { + updateProgress('Loading template structure...', 20, 100); + + let contentJson = workingTemplate.SPR_LST_SETUP?.CONTENT || workingTemplate.SPR_ITM_LST_SETUP?.CONTENT; + let dataSheets = workingTemplate.SPR_LST_SETUP?.DATA_SHEETS || workingTemplate.SPR_ITM_LST_SETUP?.DATA_SHEETS; + + if (!contentJson || !dataSheets) { + throw new Error(`No template content found for ${workingTemplate.NAME}`); + } + + const jsonData = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson; + + updateProgress('Loading template layout...', 40, 100); + spread.fromJSON(jsonData); + + activeSheet = getSafeActiveSheet(spread, 'after-fromJSON'); + if (!activeSheet) { + throw new Error('ActiveSheet became null after loading template'); + } + + activeSheet.options.isProtected = false; + + if (templateType === 'SPREAD_LIST' && tableData.length > 0) { + updateProgress('Processing data rows...', 60, 100); + + const activeSheetName = workingTemplate.SPR_LST_SETUP?.ACT_SHEET; + + + // 🔧 각 DATA_SHEET별로 처리 + dataSheets.forEach(dataSheet => { + const sheetName = dataSheet.SHEET_NAME; + + // 해당 시트가 존재하는지 확인 + const targetSheet = spread.getSheetFromName(sheetName); + if (!targetSheet) { + console.warn(`⚠️ Sheet '${sheetName}' not found in template`); + return; + } + + console.log(`📋 Processing sheet: ${sheetName}`); + + // 해당 시트로 전환 + spread.setActiveSheet(sheetName); + const currentSheet = spread.getActiveSheet(); + + if (dataSheet.MAP_CELL_ATT && dataSheet.MAP_CELL_ATT.length > 0) { + dataSheet.MAP_CELL_ATT.forEach((mapping: any) => { + const { ATT_ID, IN } = mapping; + if (!ATT_ID || !IN || IN.trim() === "") return; + + const cellPos = parseCellAddress(IN); + if (!cellPos) return; + + const requiredRows = cellPos.row + tableData.length; + if (!ensureRowCapacity(currentSheet, requiredRows)) return; + + // 🚀 배치 데이터 준비 + const valuesToSet: Array<{ row: number, col: number, value: any }> = []; + const stylesToSet: Array<{ row: number, col: number, isEditable: boolean }> = []; + + tableData.forEach((rowData, index) => { + const targetRow = cellPos.row + index; + const cellEditable = isFieldEditable(ATT_ID, rowData); + const value = rowData[ATT_ID]; + + mappings.push({ + attId: ATT_ID, + cellAddress: getCellAddress(targetRow, cellPos.col), + isEditable: cellEditable, + dataRowIndex: index + }); + + valuesToSet.push({ + row: targetRow, + col: cellPos.col, + value: value ?? null + }); + + stylesToSet.push({ + row: targetRow, + col: cellPos.col, + isEditable: cellEditable + }); + }); + + // 🚀 배치 처리 + setBatchValues(currentSheet, valuesToSet); + setBatchStyles(currentSheet, stylesToSet); + + // 드롭다운 설정 + const columnConfig = columnsJSON.find(col => col.key === ATT_ID); + if (columnConfig?.type === "LIST" && columnConfig.options) { + const hasEditableRows = tableData.some((rowData) => isFieldEditable(ATT_ID, rowData)); + if (hasEditableRows) { + setupOptimizedListValidation(currentSheet, cellPos, columnConfig.options, tableData.length); + } + } + }); + } + }); + + // 🔧 마지막에 activeSheetName으로 다시 전환 + if (activeSheetName && spread.getSheetFromName(activeSheetName)) { + spread.setActiveSheet(activeSheetName); + activeSheet = spread.getActiveSheet(); + } + + } else if (templateType === 'SPREAD_ITEM' && selectedRow) { + updateProgress('Setting up form fields...', 60, 100); + const activeSheetName = workingTemplate.SPR_ITM_LST_SETUP?.ACT_SHEET; + + if (activeSheetName && spread.getSheetFromName(activeSheetName)) { + spread.setActiveSheet(activeSheetName); + } + + dataSheets.forEach(dataSheet => { + + const sheetName = dataSheet.SHEET_NAME; + // 해당 시트가 존재하는지 확인 + const targetSheet = spread.getSheetFromName(sheetName); + if (!targetSheet) { + console.warn(`⚠️ Sheet '${sheetName}' not found in template`); + return; + } + + console.log(`📋 Processing sheet: ${sheetName}`); + + // 해당 시트로 전환 + spread.setActiveSheet(sheetName); + const currentSheet = spread.getActiveSheet(); + + + dataSheet.MAP_CELL_ATT?.forEach((mapping: any) => { + const { ATT_ID, IN } = mapping; + const cellPos = parseCellAddress(IN); + + + if (cellPos) { + const isEditable = isFieldEditable(ATT_ID); + const value = selectedRow[ATT_ID]; + + mappings.push({ + attId: ATT_ID, + cellAddress: IN, + isEditable: isEditable, + dataRowIndex: 0 + }); + + const cell = currentSheet.getCell(cellPos.row, cellPos.col); + cell.value(value ?? null); + + const style = createCellStyle(currentSheet, cellPos.row, cellPos.col, isEditable); + currentSheet.setStyle(cellPos.row, cellPos.col, style); + + const columnConfig = columnsJSON.find(col => col.key === ATT_ID); + if (columnConfig?.type === "LIST" && columnConfig.options && isEditable) { + setupOptimizedListValidation(currentSheet, cellPos, columnConfig.options, 1); + } + } + }); + + // 🔧 마지막에 activeSheetName으로 다시 전환 + if (activeSheetName && spread.getSheetFromName(activeSheetName)) { + spread.setActiveSheet(activeSheetName); + activeSheet = spread.getActiveSheet(); + } + + + }); + } + } + + updateProgress('Configuring interactions...', 90, 100); + setCellMappings(mappings); + + const finalActiveSheet = getSafeActiveSheet(spread, 'setupEvents'); + if (finalActiveSheet) { + setupSheetProtectionAndEvents(finalActiveSheet, mappings); + } + + updateProgress('Finalizing...', 100, 100); + console.log(`✅ Optimized initialization completed with ${mappings.length} mappings`); + + } finally { + // 🚀 올바른 순서로 재개 + spread.resumeCalcService(); + spread.resumeEvent(); + spread.resumePaint(); + } + + } catch (error) { + console.error('❌ Error in optimized spread initialization:', error); + if (spread?.resumeCalcService) spread.resumeCalcService(); + if (spread?.resumeEvent) spread.resumeEvent(); + if (spread?.resumePaint) spread.resumePaint(); + toast.error(`Template loading failed: ${error.message}`); + } finally { + setIsInitializing(false); + setLoadingProgress(null); + } + }, [selectedTemplate, templateType, selectedRow, tableData, updateProgress, getSafeActiveSheet, createGrdListTableOptimized, setBatchValues, setBatchStyles, setupSheetProtectionAndEvents, setCellMappings, createCellStyle, isFieldEditable, columnsJSON, setupOptimizedListValidation, parseCellAddress, ensureRowCapacity, getCellAddress]); + + React.useEffect(() => { + // 🔍 안전성 검증: availableTemplates가 있고, selectedTemplateId가 없을 때만 실행 + if (!selectedTemplateId && availableTemplates.length > 0) { + const only = availableTemplates[0]; + + // 🔍 TMPL_ID 검증 + if (!only || !only.TMPL_ID) { + console.error('❌ First template has no TMPL_ID:', only); + return; + } + + const type = determineTemplateType(only); + + // 🔍 type이 null이 아닐 때만 진행 + if (!type) { + console.warn('⚠️ Could not determine template type for:', only); + return; + } + + // 선택되어 있지 않다면 자동 선택 + setSelectedTemplateId(only.TMPL_ID); + setTemplateType(type); + + // 이미 스프레드가 마운트되어 있다면 즉시 초기화 + if (currentSpread) { + initSpread(currentSpread, only); + } + } + }, [ + availableTemplates, + selectedTemplateId, + currentSpread, + determineTemplateType, + initSpread + ]); + + const handleSaveChanges = React.useCallback(async () => { + if (!currentSpread || !hasChanges) { + toast.info("No changes to save"); + return; + } + + const errors = validateAllData(); + if (errors.length > 0) { + toast.error(`Cannot save: ${errors.length} validation errors found. Please fix them first.`); + return; + } + + try { + setIsPending(true); + const activeSheet = currentSpread.getActiveSheet(); + + if (templateType === 'SPREAD_ITEM' && selectedRow) { + const dataToSave = { ...selectedRow }; + + cellMappings.forEach(mapping => { + if (mapping.isEditable) { + const cellPos = parseCellAddress(mapping.cellAddress); + if (cellPos) { + const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); + dataToSave[mapping.attId] = cellValue; + } + } + }); + + dataToSave.TAG_NO = selectedRow.TAG_NO; + + const { success, message } = await updateFormDataInDB( + formCode, + contractItemId, + dataToSave + ); + + if (!success) { + toast.error(message); + return; + } + + toast.success("Changes saved successfully!"); + onUpdateSuccess?.(dataToSave); + + } else if ((templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && tableData.length > 0) { + console.log('🔍 Starting batch save process...'); + + const updatedRows: GenericData[] = []; + let saveCount = 0; + let checkedCount = 0; + + for (let i = 0; i < tableData.length; i++) { + const originalRow = tableData[i]; + const dataToSave = { ...originalRow }; + let hasRowChanges = false; + + console.log(`🔍 Processing row ${i} (TAG_NO: ${originalRow.TAG_NO})`); + + cellMappings.forEach(mapping => { + if (mapping.dataRowIndex === i && mapping.isEditable) { + checkedCount++; + + // 🔧 isFieldEditable과 동일한 로직 사용 + const rowData = tableData[i]; + const fieldEditable = isFieldEditable(mapping.attId, rowData); + + console.log(` 📝 Field ${mapping.attId}: fieldEditable=${fieldEditable}, mapping.isEditable=${mapping.isEditable}`); + + if (fieldEditable) { + const cellPos = parseCellAddress(mapping.cellAddress); + if (cellPos) { + const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); + const originalValue = originalRow[mapping.attId]; + + // 🔧 개선된 값 비교 (타입 변환 및 null/undefined 처리) + const normalizedCellValue = cellValue === null || cellValue === undefined ? "" : String(cellValue).trim(); + const normalizedOriginalValue = originalValue === null || originalValue === undefined ? "" : String(originalValue).trim(); + + console.log(` 🔍 ${mapping.attId}: "${normalizedOriginalValue}" -> "${normalizedCellValue}"`); + + if (normalizedCellValue !== normalizedOriginalValue) { + dataToSave[mapping.attId] = cellValue; + hasRowChanges = true; + console.log(` ✅ Change detected for ${mapping.attId}`); + } + } + } + } + }); + + if (hasRowChanges) { + console.log(`💾 Saving row ${i} with changes`); + dataToSave.TAG_NO = originalRow.TAG_NO; + + try { + const { success, message } = await updateFormDataInDB( + formCode, + contractItemId, + dataToSave + ); + + if (success) { + updatedRows.push(dataToSave); + saveCount++; + console.log(`✅ Row ${i} saved successfully`); + } else { + console.error(`❌ Failed to save row ${i}: ${message}`); + toast.error(`Failed to save row ${i + 1}: ${message}`); + updatedRows.push(originalRow); // 원본 데이터 유지 + } + } catch (error) { + console.error(`❌ Error saving row ${i}:`, error); + toast.error(`Error saving row ${i + 1}`); + updatedRows.push(originalRow); // 원본 데이터 유지 + } + } else { + updatedRows.push(originalRow); + console.log(`ℹ️ No changes in row ${i}`); + } + } + + console.log(`📊 Save summary: ${saveCount} saved, ${checkedCount} fields checked`); + + if (saveCount > 0) { + toast.success(`${saveCount} rows saved successfully!`); + onUpdateSuccess?.(updatedRows); + } else { + console.warn(`⚠️ No changes detected despite hasChanges=${hasChanges}`); + toast.warning("No actual changes were found to save. Please check if the values were properly edited."); + } + } + + setHasChanges(false); + setValidationErrors([]); + + } catch (error) { + console.error("Error saving changes:", error); + toast.error("An unexpected error occurred while saving"); + } finally { + setIsPending(false); + } + }, [ + currentSpread, + hasChanges, + templateType, + selectedRow, + tableData, + formCode, + contractItemId, + onUpdateSuccess, + cellMappings, + columnsJSON, + validateAllData, + isFieldEditable // 🔧 의존성 추가 + ]); + + if (!isOpen) return null; + + const isDataValid = templateType === 'SPREAD_ITEM' ? !!selectedRow : tableData.length > 0; + const dataCount = templateType === 'SPREAD_ITEM' ? 1 : tableData.length; + + + + return ( + <Dialog open={isOpen} onOpenChange={onClose}> + <DialogContent + className="w-[95vw] max-w-[95vw] h-[90vh] flex flex-col fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] z-50" + > + <DialogHeader className="flex-shrink-0"> + <DialogTitle>SEDP Template - {formCode}</DialogTitle> + <DialogDescription> + <div className="space-y-3"> + {availableTemplates.length > 1 ? ( + // 🔍 템플릿이 2개 이상일 때: Select 박스 표시 + <div className="flex items-center gap-4"> + <span className="text-sm font-medium">Template:</span> + <Select value={selectedTemplateId} onValueChange={handleTemplateChange}> + <SelectTrigger className="w-64"> + <SelectValue placeholder="Select a template" /> + </SelectTrigger> + <SelectContent> + {availableTemplates + .filter(template => template?.TMPL_ID) // 🔍 TMPL_ID가 있는 것만 표시 + .map(template => ( + <SelectItem key={template.TMPL_ID} value={template.TMPL_ID}> + {template.NAME || 'Unnamed'} ({template.TMPL_TYPE || 'Unknown'}) + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + ) : availableTemplates.length === 1 ? ( + // 🔍 템플릿이 정확히 1개일 때: 템플릿 이름을 텍스트로 표시 + <div className="flex items-center gap-4"> + <span className="text-sm font-medium">Template:</span> + <span className="text-sm text-blue-600 font-medium"> + {availableTemplates[0]?.NAME || 'Unnamed'} ({availableTemplates[0]?.TMPL_TYPE || 'Unknown'}) + </span> + </div> + ) : null} + + {selectedTemplate && ( + <div className="flex items-center gap-4 text-sm"> + <span className="font-medium text-blue-600"> + Template Type: { + templateType === 'SPREAD_LIST' ? 'List View (SPREAD_LIST)' : + templateType === 'SPREAD_ITEM' ? 'Item View (SPREAD_ITEM)' : + 'Grid List View (GRD_LIST)' + } + </span> + {templateType === 'SPREAD_ITEM' && selectedRow && ( + <span>• Selected TAG_NO: {selectedRow.TAG_NO || 'N/A'}</span> + )} + {(templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && ( + <span>• {dataCount} rows</span> + )} + {hasChanges && ( + <span className="text-orange-600 font-medium"> + • Unsaved changes + </span> + )} + {validationErrors.length > 0 && ( + <span className="text-red-600 font-medium flex items-center"> + <AlertTriangle className="w-4 h-4 mr-1" /> + {validationErrors.length} validation errors + </span> + )} + </div> + )} + + <div className="flex items-center gap-4 text-xs"> + <span className="text-muted-foreground"> + <span className="inline-block w-3 h-3 bg-green-100 border border-green-400 mr-1"></span> + Editable fields + </span> + <span className="text-muted-foreground"> + <span className="inline-block w-3 h-3 bg-gray-100 border border-gray-300 mr-1"></span> + Read-only fields + </span> + <span className="text-muted-foreground"> + <span className="inline-block w-3 h-3 bg-red-100 border border-red-300 mr-1"></span> + Validation errors + </span> + {cellMappings.length > 0 && ( + <span className="text-blue-600"> + {editableFieldsCount} of {cellMappings.length} fields editable + </span> + )} + </div> + </div> + </DialogDescription> + </DialogHeader> + + <div className="flex-1 overflow-hidden relative"> + {/* 🆕 로딩 프로그레스 오버레이 */} + <LoadingProgress + phase={loadingProgress?.phase || ''} + progress={loadingProgress?.progress || 0} + total={loadingProgress?.total || 100} + isVisible={isInitializing && !!loadingProgress} + /> + + {selectedTemplate && isClient && isDataValid ? ( + <SpreadSheets + key={`${templateType}-${selectedTemplate?.TMPL_ID || 'unknown'}-${selectedTemplateId}`} + workbookInitialized={initSpread} + hostStyle={hostStyle} + /> + ) : ( + <div className="flex items-center justify-center h-full text-muted-foreground"> + {!isClient ? ( + <> + <Loader className="mr-2 h-4 w-4 animate-spin" /> + Loading... + </> + ) : !selectedTemplate ? ( + "No template available" + ) : !isDataValid ? ( + `No ${templateType === 'SPREAD_ITEM' ? 'selected row' : 'data'} available` + ) : ( + "Template not ready" + )} + </div> + )} + </div> + + <DialogFooter className="flex-shrink-0"> + <div className="flex items-center gap-2"> + <Button variant="outline" onClick={onClose}> + Close + </Button> + + {hasChanges && ( + <Button + variant="default" + onClick={handleSaveChanges} + disabled={isPending || validationErrors.length > 0} + > + {isPending ? ( + <> + <Loader className="mr-2 h-4 w-4 animate-spin" /> + Saving... + </> + ) : ( + <> + <Save className="mr-2 h-4 w-4" /> + Save Changes + </> + )} + </Button> + )} + + {validationErrors.length > 0 && ( + <Button + variant="outline" + onClick={validateAllData} + className="text-red-600 border-red-300 hover:bg-red-50" + > + <AlertTriangle className="mr-2 h-4 w-4" /> + Check Errors ({validationErrors.length}) + </Button> + )} + </div> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/components/form-data-plant/spreadJS-dialog_designer.tsx b/components/form-data-plant/spreadJS-dialog_designer.tsx new file mode 100644 index 00000000..44152a62 --- /dev/null +++ b/components/form-data-plant/spreadJS-dialog_designer.tsx @@ -0,0 +1,1404 @@ +"use client"; + +import * as React from "react"; +import dynamic from "next/dynamic"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { GenericData } from "./export-excel-form"; +import * as GC from "@mescius/spread-sheets"; +import { toast } from "sonner"; +import { updateFormDataInDB } from "@/lib/forms-plant/services"; +import { Loader, Save, AlertTriangle } from "lucide-react"; +import '@mescius/spread-sheets/styles/gc.spread.sheets.excel2016colorful.css'; +import { DataTableColumnJSON, ColumnType } from "./form-data-table-columns"; + +const Designer = dynamic( + () => import("@mescius/spread-sheets-designer-react").then(mod => mod.Designer), + { + ssr: false, + loading: () => ( + <div className="flex items-center justify-center h-full"> + <Loader className="mr-2 h-4 w-4 animate-spin" /> + Loading Designer... + </div> + ) + } +); + +// 라이센스 키 설정 (두 개의 환경변수 사용) +if (typeof window !== 'undefined') { + if (process.env.NEXT_PUBLIC_SPREAD_LICENSE) { + GC.Spread.Sheets.LicenseKey = process.env.NEXT_PUBLIC_SPREAD_LICENSE; + // ExcelIO가 사용 가능한 경우에만 설정 + if (typeof (window as any).ExcelIO !== 'undefined') { + (window as any).ExcelIO.LicenseKey = process.env.NEXT_PUBLIC_SPREAD_LICENSE; + } + } + + if (process.env.NEXT_PUBLIC_DESIGNER_LICENSE) { + // Designer 라이센스 키 설정 + if (GC.Spread.Sheets.Designer) { + GC.Spread.Sheets.Designer.LicenseKey = process.env.NEXT_PUBLIC_DESIGNER_LICENSE; + } + } +} + +interface TemplateItem { + TMPL_ID: string; + NAME: string; + TMPL_TYPE: string; + SPR_LST_SETUP: { + ACT_SHEET: string; + HIDN_SHEETS: Array<string>; + CONTENT?: string; + DATA_SHEETS: Array<{ + SHEET_NAME: string; + REG_TYPE_ID: string; + MAP_CELL_ATT: Array<{ + ATT_ID: string; + IN: string; + }>; + }>; + }; + GRD_LST_SETUP: { + REG_TYPE_ID: string; + SPR_ITM_IDS: Array<string>; + ATTS: Array<{}>; + }; + SPR_ITM_LST_SETUP: { + ACT_SHEET: string; + HIDN_SHEETS: Array<string>; + CONTENT?: string; + DATA_SHEETS: Array<{ + SHEET_NAME: string; + REG_TYPE_ID: string; + MAP_CELL_ATT: Array<{ + ATT_ID: string; + IN: string; + }>; + }>; + }; +} + +interface ValidationError { + cellAddress: string; + attId: string; + value: any; + expectedType: ColumnType; + message: string; +} + +interface CellMapping { + attId: string; + cellAddress: string; + isEditable: boolean; + dataRowIndex?: number; +} + +interface TemplateViewDialogProps { + isOpen: boolean; + onClose: () => void; + templateData: TemplateItem[] | any; + selectedRow?: GenericData; + tableData?: GenericData[]; + formCode: string; + columnsJSON: DataTableColumnJSON[] + contractItemId: number; + editableFieldsMap?: Map<string, string[]>; + onUpdateSuccess?: (updatedValues: Record<string, any> | GenericData[]) => void; +} + +// 🚀 로딩 프로그레스 컴포넌트 +interface LoadingProgressProps { + phase: string; + progress: number; + total: number; + isVisible: boolean; +} + +const LoadingProgress: React.FC<LoadingProgressProps> = ({ phase, progress, total, isVisible }) => { + const percentage = total > 0 ? Math.round((progress / total) * 100) : 0; + + if (!isVisible) return null; + + return ( + <div className="absolute inset-0 bg-white/90 flex items-center justify-center z-50"> + <div className="bg-white rounded-lg shadow-lg border p-6 min-w-[300px]"> + <div className="flex items-center space-x-3 mb-4"> + <Loader className="h-5 w-5 animate-spin text-blue-600" /> + <span className="font-medium text-gray-900">Loading Template</span> + </div> + + <div className="space-y-2"> + <div className="text-sm text-gray-600">{phase}</div> + <div className="w-full bg-gray-200 rounded-full h-2"> + <div + className="bg-blue-600 h-2 rounded-full transition-all duration-300 ease-out" + style={{ width: `${percentage}%` }} + /> + </div> + <div className="text-xs text-gray-500 text-right"> + {progress.toLocaleString()} / {total.toLocaleString()} ({percentage}%) + </div> + </div> + </div> + </div> + ); +}; + +export function TemplateViewDialog({ + isOpen, + onClose, + templateData, + selectedRow, + tableData = [], + formCode, + contractItemId, + columnsJSON, + editableFieldsMap = new Map(), + onUpdateSuccess +}: TemplateViewDialogProps) { + const [hostStyle, setHostStyle] = React.useState({ + width: '100%', + height: '100%' + }); + + const [isPending, setIsPending] = React.useState(false); + const [hasChanges, setHasChanges] = React.useState(false); + const [currentSpread, setCurrentSpread] = React.useState<any>(null); + const [cellMappings, setCellMappings] = React.useState<CellMapping[]>([]); + const [isClient, setIsClient] = React.useState(false); + const [templateType, setTemplateType] = React.useState<'SPREAD_LIST' | 'SPREAD_ITEM' | 'GRD_LIST' | null>(null); + const [validationErrors, setValidationErrors] = React.useState<ValidationError[]>([]); + const [selectedTemplateId, setSelectedTemplateId] = React.useState<string>(""); + const [availableTemplates, setAvailableTemplates] = React.useState<TemplateItem[]>([]); + + // 🆕 로딩 상태 추가 + const [loadingProgress, setLoadingProgress] = React.useState<{ + phase: string; + progress: number; + total: number; + } | null>(null); + const [isInitializing, setIsInitializing] = React.useState(false); + + // 🔄 진행상황 업데이트 함수 + const updateProgress = React.useCallback((phase: string, progress: number, total: number) => { + setLoadingProgress({ phase, progress, total }); + }, []); + + const determineTemplateType = React.useCallback((template: TemplateItem): 'SPREAD_LIST' | 'SPREAD_ITEM' | 'GRD_LIST' | null => { + if (template.TMPL_TYPE === "SPREAD_LIST" && (template.SPR_LST_SETUP?.CONTENT || template.SPR_ITM_LST_SETUP?.CONTENT)) { + return 'SPREAD_LIST'; + } + if (template.TMPL_TYPE === "SPREAD_ITEM" && (template.SPR_LST_SETUP?.CONTENT || template.SPR_ITM_LST_SETUP?.CONTENT)) { + return 'SPREAD_ITEM'; + } + if (template.GRD_LST_SETUP && columnsJSON.length > 0) { + return 'GRD_LIST'; + } + return null; + }, [columnsJSON]); + + const isValidTemplate = React.useCallback((template: TemplateItem): boolean => { + return determineTemplateType(template) !== null; + }, [determineTemplateType]); + + React.useEffect(() => { + setIsClient(true); + }, []); + + React.useEffect(() => { + if (!templateData) return; + + let templates: TemplateItem[]; + if (Array.isArray(templateData)) { + templates = templateData as TemplateItem[]; + } else { + templates = [templateData as TemplateItem]; + } + + const validTemplates = templates.filter(isValidTemplate); + setAvailableTemplates(validTemplates); + + if (validTemplates.length > 0 && !selectedTemplateId) { + const firstTemplate = validTemplates[0]; + const templateTypeToSet = determineTemplateType(firstTemplate); + setSelectedTemplateId(firstTemplate.TMPL_ID); + setTemplateType(templateTypeToSet); + } + }, [templateData, selectedTemplateId, isValidTemplate, determineTemplateType]); + + const handleTemplateChange = (templateId: string) => { + const template = availableTemplates.find(t => t.TMPL_ID === templateId); + if (template) { + const templateTypeToSet = determineTemplateType(template); + setSelectedTemplateId(templateId); + setTemplateType(templateTypeToSet); + setHasChanges(false); + setValidationErrors([]); + + if (currentSpread && template) { + initSpread(currentSpread, template); + } + } + }; + + const selectedTemplate = React.useMemo(() => { + return availableTemplates.find(t => t.TMPL_ID === selectedTemplateId); + }, [availableTemplates, selectedTemplateId]); + + const editableFields = React.useMemo(() => { + // SPREAD_ITEM의 경우에만 전역 editableFields 사용 + if (templateType === 'SPREAD_ITEM' && selectedRow?.TAG_NO) { + if (!editableFieldsMap.has(selectedRow.TAG_NO)) { + return []; + } + return editableFieldsMap.get(selectedRow.TAG_NO) || []; + } + + // SPREAD_LIST나 GRD_LIST의 경우 전역 editableFields는 사용하지 않음 + return []; + }, [templateType, selectedRow?.TAG_NO, editableFieldsMap]); + + +const isFieldEditable = React.useCallback((attId: string, rowData?: GenericData) => { + const columnConfig = columnsJSON.find(col => col.key === attId); + if (columnConfig?.shi === "OUT" || columnConfig?.shi === null) { + return false; + } + + if (attId === "TAG_NO" || attId === "TAG_DESC" || attId === "status") { + return false; + } + + if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') { + // 각 행의 TAG_NO를 기준으로 편집 가능 여부 판단 + if (!rowData?.TAG_NO || !editableFieldsMap.has(rowData.TAG_NO)) { + return false; + } + + const rowEditableFields = editableFieldsMap.get(rowData.TAG_NO) || []; + if (!rowEditableFields.includes(attId)) { + return false; + } + + if (rowData && (rowData.shi === "OUT" || rowData.shi === null)) { + return false; + } + return true; + } + + // SPREAD_ITEM의 경우 기존 로직 유지 + if (templateType === 'SPREAD_ITEM') { + return editableFields.includes(attId); + } + + return true; +}, [templateType, columnsJSON, editableFieldsMap]); // editableFields 의존성 제거 + +const editableFieldsCount = React.useMemo(() => { + if (templateType === 'SPREAD_ITEM') { + // SPREAD_ITEM의 경우 기존 로직 유지 + return cellMappings.filter(m => m.isEditable).length; + } + + if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') { + // 각 행별로 편집 가능한 필드 수를 계산 + let totalEditableCount = 0; + + tableData.forEach((rowData, rowIndex) => { + cellMappings.forEach(mapping => { + if (mapping.dataRowIndex === rowIndex) { + if (isFieldEditable(mapping.attId, rowData)) { + totalEditableCount++; + } + } + }); + }); + + return totalEditableCount; + } + + return cellMappings.filter(m => m.isEditable).length; +}, [cellMappings, templateType, tableData, isFieldEditable]); + + // 🚀 배치 처리 함수들 + const setBatchValues = React.useCallback(( + activeSheet: any, + valuesToSet: Array<{row: number, col: number, value: any}> + ) => { + console.log(`🚀 Setting ${valuesToSet.length} values in batch`); + + const columnGroups = new Map<number, Array<{row: number, value: any}>>(); + + valuesToSet.forEach(({row, col, value}) => { + if (!columnGroups.has(col)) { + columnGroups.set(col, []); + } + columnGroups.get(col)!.push({row, value}); + }); + + columnGroups.forEach((values, col) => { + values.sort((a, b) => a.row - b.row); + + let start = 0; + while (start < values.length) { + let end = start; + while (end + 1 < values.length && values[end + 1].row === values[end].row + 1) { + end++; + } + + const rangeValues = values.slice(start, end + 1).map(v => v.value); + const startRow = values[start].row; + + try { + if (rangeValues.length === 1) { + activeSheet.setValue(startRow, col, rangeValues[0]); + } else { + const dataArray = rangeValues.map(v => [v]); + activeSheet.setArray(startRow, col, dataArray); + } + } catch (error) { + for (let i = start; i <= end; i++) { + try { + activeSheet.setValue(values[i].row, col, values[i].value); + } catch (cellError) { + console.warn(`⚠️ Individual value setting failed [${values[i].row}, ${col}]:`, cellError); + } + } + } + + start = end + 1; + } + }); + }, []); + + const createCellStyle = React.useCallback((isEditable: boolean) => { + const style = new GC.Spread.Sheets.Style(); + if (isEditable) { + style.backColor = "#bbf7d0"; + } else { + style.backColor = "#e5e7eb"; + style.foreColor = "#4b5563"; + } + return style; + }, []); + + const setBatchStyles = React.useCallback(( + activeSheet: any, + stylesToSet: Array<{row: number, col: number, isEditable: boolean}> + ) => { + console.log(`🎨 Setting ${stylesToSet.length} styles in batch`); + + const editableStyle = createCellStyle(true); + const readonlyStyle = createCellStyle(false); + + // 🔧 개별 셀별로 스타일과 잠금 상태 설정 (편집 권한 보장) + stylesToSet.forEach(({row, col, isEditable}) => { + try { + const cell = activeSheet.getCell(row, col); + const style = isEditable ? editableStyle : readonlyStyle; + + activeSheet.setStyle(row, col, style); + cell.locked(!isEditable); // 편집 가능하면 잠금 해제 + + // 🆕 편집 가능한 셀에 기본 텍스트 에디터 설정 + if (isEditable) { + const textCellType = new GC.Spread.Sheets.CellTypes.Text(); + activeSheet.setCellType(row, col, textCellType); + } + } catch (error) { + console.warn(`⚠️ Failed to set style for cell [${row}, ${col}]:`, error); + } + }); + }, [createCellStyle]); + + const parseCellAddress = (address: string): { row: number, col: number } | null => { + if (!address || address.trim() === "") return null; + + const match = address.match(/^([A-Z]+)(\d+)$/); + if (!match) return null; + + const [, colStr, rowStr] = match; + + let col = 0; + for (let i = 0; i < colStr.length; i++) { + col = col * 26 + (colStr.charCodeAt(i) - 65 + 1); + } + col -= 1; + + const row = parseInt(rowStr) - 1; + return { row, col }; + }; + + const getCellAddress = (row: number, col: number): string => { + let colStr = ''; + let colNum = col; + while (colNum >= 0) { + colStr = String.fromCharCode((colNum % 26) + 65) + colStr; + colNum = Math.floor(colNum / 26) - 1; + } + return colStr + (row + 1); + }; + + const validateCellValue = (value: any, columnType: ColumnType, options?: string[]): string | null => { + if (value === undefined || value === null || value === "") { + return null; + } + + switch (columnType) { + case "NUMBER": + if (isNaN(Number(value))) { + return "Value must be a valid number"; + } + break; + case "LIST": + if (options && !options.includes(String(value))) { + return `Value must be one of: ${options.join(", ")}`; + } + break; + case "STRING": + break; + default: + break; + } + + return null; + }; + + const validateAllData = React.useCallback(() => { + if (!currentSpread || !selectedTemplate) return []; + + const activeSheet = currentSpread.getActiveSheet(); + const errors: ValidationError[] = []; + + cellMappings.forEach(mapping => { + const columnConfig = columnsJSON.find(col => col.key === mapping.attId); + if (!columnConfig) return; + + const cellPos = parseCellAddress(mapping.cellAddress); + if (!cellPos) return; + + const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); + const errorMessage = validateCellValue(cellValue, columnConfig.type, columnConfig.options); + + if (errorMessage) { + errors.push({ + cellAddress: mapping.cellAddress, + attId: mapping.attId, + value: cellValue, + expectedType: columnConfig.type, + message: errorMessage + }); + } + }); + + setValidationErrors(errors); + return errors; + }, [currentSpread, selectedTemplate, cellMappings, columnsJSON]); + + + + const setupOptimizedListValidation = React.useCallback((activeSheet: any, cellPos: { row: number, col: number }, options: string[], rowCount: number) => { + try { + console.log(`🎯 Setting up dropdown for ${rowCount} rows with options:`, options); + + const safeOptions = options + .filter(opt => opt !== null && opt !== undefined && opt !== '') + .map(opt => String(opt).trim()) + .filter(opt => opt.length > 0) + .filter((opt, index, arr) => arr.indexOf(opt) === index) + .slice(0, 20); + + if (safeOptions.length === 0) { + console.warn(`⚠️ No valid options found, skipping`); + return; + } + + const optionsString = safeOptions.join(','); + + for (let i = 0; i < rowCount; i++) { + try { + const targetRow = cellPos.row + i; + + // 🔧 각 셀마다 새로운 ComboBox 인스턴스 생성 + const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox(); + comboBoxCellType.items(safeOptions); + comboBoxCellType.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); + + // 🔧 DataValidation 설정 + const cellValidator = GC.Spread.Sheets.DataValidation.createListValidator(optionsString); + cellValidator.showInputMessage(false); + cellValidator.showErrorMessage(false); + + // ComboBox와 Validator 적용 + activeSheet.setCellType(targetRow, cellPos.col, comboBoxCellType); + activeSheet.setDataValidator(targetRow, cellPos.col, cellValidator); + + // 🚀 중요: 셀 잠금 해제 및 편집 가능 설정 + const cell = activeSheet.getCell(targetRow, cellPos.col); + cell.locked(false); + + console.log(`✅ Dropdown applied to [${targetRow}, ${cellPos.col}] with ${safeOptions.length} options`); + + } catch (cellError) { + console.warn(`⚠️ Failed to apply dropdown to row ${cellPos.row + i}:`, cellError); + } + } + + console.log(`✅ Dropdown setup completed for ${rowCount} cells`); + + } catch (error) { + console.error('❌ Dropdown setup failed:', error); + } + }, []); + + const getSafeActiveSheet = React.useCallback((spread: any, functionName: string = 'unknown') => { + if (!spread) return null; + + try { + let activeSheet = spread.getActiveSheet(); + if (!activeSheet) { + const sheetCount = spread.getSheetCount(); + if (sheetCount > 0) { + activeSheet = spread.getSheet(0); + if (activeSheet) { + spread.setActiveSheetIndex(0); + } + } + } + return activeSheet; + } catch (error) { + console.error(`❌ Error getting activeSheet in ${functionName}:`, error); + return null; + } + }, []); + + const ensureRowCapacity = React.useCallback((activeSheet: any, requiredRowCount: number) => { + try { + if (!activeSheet) return false; + + const currentRowCount = activeSheet.getRowCount(); + if (requiredRowCount > currentRowCount) { + const newRowCount = requiredRowCount + 10; + activeSheet.setRowCount(newRowCount); + } + return true; + } catch (error) { + console.error('❌ Error in ensureRowCapacity:', error); + return false; + } + }, []); + + const ensureColumnCapacity = React.useCallback((activeSheet: any, requiredColumnCount: number) => { + try { + if (!activeSheet) return false; + + const currentColumnCount = activeSheet.getColumnCount(); + if (requiredColumnCount > currentColumnCount) { + const newColumnCount = requiredColumnCount + 10; + activeSheet.setColumnCount(newColumnCount); + } + return true; + } catch (error) { + console.error('❌ Error in ensureColumnCapacity:', error); + return false; + } + }, []); + + const setOptimalColumnWidths = React.useCallback((activeSheet: any, columns: any[], startCol: number, tableData: any[]) => { + columns.forEach((column, colIndex) => { + const targetCol = startCol + colIndex; + const optimalWidth = column.type === 'NUMBER' ? 100 : column.type === 'STRING' ? 150 : 120; + activeSheet.setColumnWidth(targetCol, optimalWidth); + }); + }, []); + + // 🚀 최적화된 GRD_LIST 생성 + const createGrdListTableOptimized = React.useCallback((activeSheet: any, template: TemplateItem) => { + console.log('🚀 Creating optimized GRD_LIST table'); + + const visibleColumns = columnsJSON + .filter(col => col.hidden !== true) + .sort((a, b) => (a.seq ?? 999999) - (b.seq ?? 999999)); + + if (visibleColumns.length === 0) return []; + + const startCol = 1; + const dataStartRow = 1; + const mappings: CellMapping[] = []; + + ensureColumnCapacity(activeSheet, startCol + visibleColumns.length); + ensureRowCapacity(activeSheet, dataStartRow + tableData.length); + + // 헤더 생성 + const headerStyle = new GC.Spread.Sheets.Style(); + headerStyle.backColor = "#3b82f6"; + headerStyle.foreColor = "#ffffff"; + headerStyle.font = "bold 12px Arial"; + headerStyle.hAlign = GC.Spread.Sheets.HorizontalAlign.center; + + visibleColumns.forEach((column, colIndex) => { + const targetCol = startCol + colIndex; + const cell = activeSheet.getCell(0, targetCol); + cell.value(column.label); + cell.locked(true); + activeSheet.setStyle(0, targetCol, headerStyle); + }); + + // 🚀 데이터 배치 처리 준비 + const allValues: Array<{row: number, col: number, value: any}> = []; + const allStyles: Array<{row: number, col: number, isEditable: boolean}> = []; + + // 🔧 편집 가능한 셀 정보 수집 (드롭다운용) + const dropdownConfigs: Array<{ + startRow: number; + col: number; + rowCount: number; + options: string[]; + editableRows: number[]; // 편집 가능한 행만 추적 + }> = []; + + visibleColumns.forEach((column, colIndex) => { + const targetCol = startCol + colIndex; + + // 드롭다운 설정을 위한 편집 가능한 행 찾기 + if (column.type === "LIST" && column.options) { + const editableRows: number[] = []; + tableData.forEach((rowData, rowIndex) => { + if (isFieldEditable(column.key, rowData)) { // rowData 전달 + editableRows.push(dataStartRow + rowIndex); + } + }); + + if (editableRows.length > 0) { + dropdownConfigs.push({ + startRow: dataStartRow, + col: targetCol, + rowCount: tableData.length, + options: column.options, + editableRows: editableRows + }); + } + } + + tableData.forEach((rowData, rowIndex) => { + const targetRow = dataStartRow + rowIndex; + const cellEditable = isFieldEditable(column.key, rowData); // rowData 전달 + const value = rowData[column.key]; + + mappings.push({ + attId: column.key, + cellAddress: getCellAddress(targetRow, targetCol), + isEditable: cellEditable, + dataRowIndex: rowIndex + }); + + allValues.push({ + row: targetRow, + col: targetCol, + value: value ?? null + }); + + allStyles.push({ + row: targetRow, + col: targetCol, + isEditable: cellEditable + }); + }); + }); + + // 🚀 배치로 값과 스타일 설정 + setBatchValues(activeSheet, allValues); + setBatchStyles(activeSheet, allStyles); + + // 🎯 개선된 드롭다운 설정 (편집 가능한 셀에만) + dropdownConfigs.forEach(({ col, options, editableRows }) => { + try { + console.log(`🎯 Setting dropdown for column ${col}, editable rows: ${editableRows.length}`); + + const safeOptions = options + .filter(opt => opt !== null && opt !== undefined && opt !== '') + .map(opt => String(opt).trim()) + .filter(opt => opt.length > 0) + .slice(0, 20); + + if (safeOptions.length === 0) return; + + // 편집 가능한 행에만 드롭다운 적용 + editableRows.forEach(targetRow => { + try { + const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox(); + comboBoxCellType.items(safeOptions); + comboBoxCellType.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); + + const cellValidator = GC.Spread.Sheets.DataValidation.createListValidator(safeOptions.join(',')); + cellValidator.showInputMessage(false); + cellValidator.showErrorMessage(false); + + activeSheet.setCellType(targetRow, col, comboBoxCellType); + activeSheet.setDataValidator(targetRow, col, cellValidator); + + // 🚀 편집 권한 명시적 설정 + const cell = activeSheet.getCell(targetRow, col); + cell.locked(false); + + console.log(`✅ Dropdown applied to editable cell [${targetRow}, ${col}]`); + } catch (cellError) { + console.warn(`⚠️ Failed to apply dropdown to [${targetRow}, ${col}]:`, cellError); + } + }); + } catch (error) { + console.error(`❌ Dropdown config failed for column ${col}:`, error); + } + }); + + setOptimalColumnWidths(activeSheet, visibleColumns, startCol, tableData); + + console.log(`✅ Optimized GRD_LIST created:`); + console.log(` - Total mappings: ${mappings.length}`); + console.log(` - Editable cells: ${mappings.filter(m => m.isEditable).length}`); + console.log(` - Dropdown configs: ${dropdownConfigs.length}`); + + return mappings; + }, [tableData, columnsJSON, isFieldEditable, setBatchValues, setBatchStyles, ensureColumnCapacity, ensureRowCapacity, getCellAddress, setOptimalColumnWidths]); + + const setupSheetProtectionAndEvents = React.useCallback((activeSheet: any, mappings: CellMapping[]) => { + console.log(`🛡️ Setting up protection and events for ${mappings.length} mappings`); + + // 🔧 시트 보호 완전 해제 후 편집 권한 설정 + activeSheet.options.isProtected = false; + + // 🔧 편집 가능한 셀들을 위한 강화된 설정 + mappings.forEach((mapping) => { + const cellPos = parseCellAddress(mapping.cellAddress); + if (!cellPos) return; + + try { + const cell = activeSheet.getCell(cellPos.row, cellPos.col); + const columnConfig = columnsJSON.find(col => col.key === mapping.attId); + + if (mapping.isEditable) { + // 🚀 편집 가능한 셀 설정 강화 + cell.locked(false); + + if (columnConfig?.type === "LIST" && columnConfig.options) { + // LIST 타입: 새 ComboBox 인스턴스 생성 + const comboBox = new GC.Spread.Sheets.CellTypes.ComboBox(); + comboBox.items(columnConfig.options); + comboBox.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); + activeSheet.setCellType(cellPos.row, cellPos.col, comboBox); + + // DataValidation도 추가 + const validator = GC.Spread.Sheets.DataValidation.createListValidator(columnConfig.options.join(',')); + activeSheet.setDataValidator(cellPos.row, cellPos.col, validator); + } else if (columnConfig?.type === "NUMBER") { + // NUMBER 타입: 숫자 입력 허용 + const textCellType = new GC.Spread.Sheets.CellTypes.Text(); + activeSheet.setCellType(cellPos.row, cellPos.col, textCellType); + + // 숫자 validation 추가 (에러 메시지 없이) + const numberValidator = GC.Spread.Sheets.DataValidation.createNumberValidator( + GC.Spread.Sheets.ConditionalFormatting.ComparisonOperators.between, + -999999999, 999999999, true + ); + numberValidator.showInputMessage(false); + numberValidator.showErrorMessage(false); + activeSheet.setDataValidator(cellPos.row, cellPos.col, numberValidator); + } else { + // 기본 TEXT 타입: 자유 텍스트 입력 + const textCellType = new GC.Spread.Sheets.CellTypes.Text(); + activeSheet.setCellType(cellPos.row, cellPos.col, textCellType); + } + + // 편집 가능 스타일 재적용 + const editableStyle = createCellStyle(true); + activeSheet.setStyle(cellPos.row, cellPos.col, editableStyle); + + console.log(`🔓 Cell [${cellPos.row}, ${cellPos.col}] ${mapping.attId} set as EDITABLE`); + } else { + // 읽기 전용 셀 + cell.locked(true); + const readonlyStyle = createCellStyle(false); + activeSheet.setStyle(cellPos.row, cellPos.col, readonlyStyle); + } + } catch (error) { + console.error(`❌ Error processing cell ${mapping.cellAddress}:`, error); + } + }); + + // 🛡️ 시트 보호 재설정 (편집 허용 모드로) + activeSheet.options.isProtected = false; + activeSheet.options.protectionOptions = { + allowSelectLockedCells: true, + allowSelectUnlockedCells: true, + allowSort: false, + allowFilter: false, + allowEditObjects: true, // ✅ 편집 객체 허용 + allowResizeRows: false, + allowResizeColumns: false, + allowFormatCells: false, + allowInsertRows: false, + allowInsertColumns: false, + allowDeleteRows: false, + allowDeleteColumns: false + }; + + // 🎯 변경 감지 이벤트 + const changeEvents = [ + GC.Spread.Sheets.Events.CellChanged, + GC.Spread.Sheets.Events.ValueChanged, + GC.Spread.Sheets.Events.ClipboardPasted + ]; + + changeEvents.forEach(eventType => { + activeSheet.bind(eventType, () => { + console.log(`📝 ${eventType} detected`); + setHasChanges(true); + }); + }); + + // 🚫 편집 시작 권한 확인 + activeSheet.bind(GC.Spread.Sheets.Events.EditStarting, (event: any, info: any) => { + console.log(`🎯 EditStarting: Row ${info.row}, Col ${info.col}`); + + const exactMapping = mappings.find(m => { + const cellPos = parseCellAddress(m.cellAddress); + return cellPos && cellPos.row === info.row && cellPos.col === info.col; + }); + + if (!exactMapping) { + console.log(`ℹ️ No mapping found for [${info.row}, ${info.col}] - allowing edit`); + return; // 매핑이 없으면 허용 + } + + console.log(`📋 Found mapping: ${exactMapping.attId}, isEditable: ${exactMapping.isEditable}`); + + if (!exactMapping.isEditable) { + console.log(`🚫 Field ${exactMapping.attId} is not editable`); + toast.warning(`${exactMapping.attId} field is read-only`); + info.cancel = true; + return; + } + + // SPREAD_LIST/GRD_LIST 개별 행 SHI 확인 + if ((templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && exactMapping.dataRowIndex !== undefined) { + const dataRowIndex = exactMapping.dataRowIndex; + if (dataRowIndex >= 0 && dataRowIndex < tableData.length) { + const rowData = tableData[dataRowIndex]; + if (rowData?.shi === "OUT" || rowData?.shi === null ) { + console.log(`🚫 Row ${dataRowIndex} is in SHI mode`); + toast.warning(`Row ${dataRowIndex + 1}: ${exactMapping.attId} field is read-only (SHI mode)`); + info.cancel = true; + return; + } + } + } + + console.log(`✅ Edit allowed for ${exactMapping.attId}`); + }); + + // ✅ 편집 완료 검증 + activeSheet.bind(GC.Spread.Sheets.Events.EditEnded, (event: any, info: any) => { + console.log(`🏁 EditEnded: Row ${info.row}, Col ${info.col}, New value: ${activeSheet.getValue(info.row, info.col)}`); + + const exactMapping = mappings.find(m => { + const cellPos = parseCellAddress(m.cellAddress); + return cellPos && cellPos.row === info.row && cellPos.col === info.col; + }); + + if (!exactMapping) return; + + const columnConfig = columnsJSON.find(col => col.key === exactMapping.attId); + if (columnConfig) { + const cellValue = activeSheet.getValue(info.row, info.col); + const errorMessage = validateCellValue(cellValue, columnConfig.type, columnConfig.options); + const cell = activeSheet.getCell(info.row, info.col); + + if (errorMessage) { + // 🚨 에러 스타일 적용 + const errorStyle = new GC.Spread.Sheets.Style(); + errorStyle.backColor = "#fef2f2"; + errorStyle.foreColor = "#dc2626"; + errorStyle.borderLeft = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); + errorStyle.borderRight = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); + errorStyle.borderTop = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); + errorStyle.borderBottom = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); + + activeSheet.setStyle(info.row, info.col, errorStyle); + cell.locked(!exactMapping.isEditable); // 편집 가능 상태 유지 + toast.warning(`Invalid value in ${exactMapping.attId}: ${errorMessage}`, { duration: 5000 }); + } else { + // ✅ 정상 스타일 복원 + const normalStyle = createCellStyle(exactMapping.isEditable); + activeSheet.setStyle(info.row, info.col, normalStyle); + cell.locked(!exactMapping.isEditable); + } + } + + setHasChanges(true); + }); + + console.log(`🛡️ Protection configured. Editable cells: ${mappings.filter(m => m.isEditable).length}`); + }, [templateType, tableData, createCellStyle, validateCellValue, columnsJSON]); + + // 🚀 최적화된 initSpread - Designer용으로 수정 + const initSpread = React.useCallback(async (spread: any, template?: TemplateItem) => { + const workingTemplate = template || selectedTemplate; + if (!spread || !workingTemplate) { + console.error('❌ Invalid spread or template'); + return; + } + + try { + console.log('🚀 Starting optimized Designer initialization...'); + setIsInitializing(true); + updateProgress('Initializing...', 0, 100); + + setCurrentSpread(spread); + setHasChanges(false); + setValidationErrors([]); + + // 🚀 핵심 최적화: 모든 렌더링과 이벤트 중단 + spread.suspendPaint(); + spread.suspendEvent(); + spread.suspendCalcService(); + + updateProgress('Setting up workspace...', 10, 100); + + try { + let activeSheet = getSafeActiveSheet(spread, 'initSpread'); + if (!activeSheet) { + throw new Error('Failed to get initial activeSheet'); + } + + activeSheet.options.isProtected = false; + let mappings: CellMapping[] = []; + + if (templateType === 'GRD_LIST') { + updateProgress('Creating dynamic table...', 20, 100); + + spread.clearSheets(); + spread.addSheet(0); + const sheet = spread.getSheet(0); + sheet.name('Data'); + spread.setActiveSheet('Data'); + + updateProgress('Processing table data...', 50, 100); + mappings = createGrdListTableOptimized(sheet, workingTemplate); + + } else { + updateProgress('Loading template structure...', 20, 100); + + let contentJson = workingTemplate.SPR_LST_SETUP?.CONTENT || workingTemplate.SPR_ITM_LST_SETUP?.CONTENT; + let dataSheets = workingTemplate.SPR_LST_SETUP?.DATA_SHEETS || workingTemplate.SPR_ITM_LST_SETUP?.DATA_SHEETS; + + if (!contentJson || !dataSheets) { + throw new Error(`No template content found for ${workingTemplate.NAME}`); + } + + const jsonData = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson; + + updateProgress('Loading template layout...', 40, 100); + spread.fromJSON(jsonData); + + activeSheet = getSafeActiveSheet(spread, 'after-fromJSON'); + if (!activeSheet) { + throw new Error('ActiveSheet became null after loading template'); + } + + activeSheet.options.isProtected = false; + + if (templateType === 'SPREAD_LIST' && tableData.length > 0) { + updateProgress('Processing data rows...', 60, 100); + + dataSheets.forEach(dataSheet => { + if (dataSheet.MAP_CELL_ATT && dataSheet.MAP_CELL_ATT.length > 0) { + dataSheet.MAP_CELL_ATT.forEach((mapping: any) => { + const { ATT_ID, IN } = mapping; + if (!ATT_ID || !IN || IN.trim() === "") return; + + const cellPos = parseCellAddress(IN); + if (!cellPos) return; + + const requiredRows = cellPos.row + tableData.length; + if (!ensureRowCapacity(activeSheet, requiredRows)) return; + + // 🚀 배치 데이터 준비 + const valuesToSet: Array<{row: number, col: number, value: any}> = []; + const stylesToSet: Array<{row: number, col: number, isEditable: boolean}> = []; + + tableData.forEach((rowData, index) => { + const targetRow = cellPos.row + index; + const cellEditable = isFieldEditable(ATT_ID, rowData); + const value = rowData[ATT_ID]; + + mappings.push({ + attId: ATT_ID, + cellAddress: getCellAddress(targetRow, cellPos.col), + isEditable: cellEditable, + dataRowIndex: index + }); + + valuesToSet.push({ + row: targetRow, + col: cellPos.col, + value: value ?? null + }); + + stylesToSet.push({ + row: targetRow, + col: cellPos.col, + isEditable: cellEditable + }); + }); + + // 🚀 배치 처리 + setBatchValues(activeSheet, valuesToSet); + setBatchStyles(activeSheet, stylesToSet); + + // 드롭다운 설정 + const columnConfig = columnsJSON.find(col => col.key === ATT_ID); + if (columnConfig?.type === "LIST" && columnConfig.options) { + const hasEditableRows = tableData.some((rowData) => isFieldEditable(ATT_ID, rowData)); + if (hasEditableRows) { + setupOptimizedListValidation(activeSheet, cellPos, columnConfig.options, tableData.length); + } + } + }); + } + }); + + } else if (templateType === 'SPREAD_ITEM' && selectedRow) { + updateProgress('Setting up form fields...', 60, 100); + + dataSheets.forEach(dataSheet => { + dataSheet.MAP_CELL_ATT?.forEach((mapping: any) => { + const { ATT_ID, IN } = mapping; + const cellPos = parseCellAddress(IN); + if (cellPos) { + const isEditable = isFieldEditable(ATT_ID); + const value = selectedRow[ATT_ID]; + + mappings.push({ + attId: ATT_ID, + cellAddress: IN, + isEditable: isEditable, + dataRowIndex: 0 + }); + + const cell = activeSheet.getCell(cellPos.row, cellPos.col); + cell.value(value ?? null); + + const style = createCellStyle(isEditable); + activeSheet.setStyle(cellPos.row, cellPos.col, style); + + const columnConfig = columnsJSON.find(col => col.key === ATT_ID); + if (columnConfig?.type === "LIST" && columnConfig.options && isEditable) { + setupOptimizedListValidation(activeSheet, cellPos, columnConfig.options, 1); + } + } + }); + }); + } + } + + updateProgress('Configuring interactions...', 90, 100); + setCellMappings(mappings); + + const finalActiveSheet = getSafeActiveSheet(spread, 'setupEvents'); + if (finalActiveSheet) { + setupSheetProtectionAndEvents(finalActiveSheet, mappings); + } + + updateProgress('Finalizing...', 100, 100); + console.log(`✅ Optimized Designer initialization completed with ${mappings.length} mappings`); + + } finally { + // 🚀 올바른 순서로 재개 + spread.resumeCalcService(); + spread.resumeEvent(); + spread.resumePaint(); + } + + } catch (error) { + console.error('❌ Error in optimized Designer initialization:', error); + if (spread?.resumeCalcService) spread.resumeCalcService(); + if (spread?.resumeEvent) spread.resumeEvent(); + if (spread?.resumePaint) spread.resumePaint(); + toast.error(`Template loading failed: ${error.message}`); + } finally { + setIsInitializing(false); + setLoadingProgress(null); + } + }, [selectedTemplate, templateType, selectedRow, tableData, updateProgress, getSafeActiveSheet, createGrdListTableOptimized, setBatchValues, setBatchStyles, setupSheetProtectionAndEvents, setCellMappings]); + + const handleSaveChanges = React.useCallback(async () => { + if (!currentSpread || !hasChanges) { + toast.info("No changes to save"); + return; + } + + const errors = validateAllData(); + if (errors.length > 0) { + toast.error(`Cannot save: ${errors.length} validation errors found. Please fix them first.`); + return; + } + + try { + setIsPending(true); + const activeSheet = currentSpread.getActiveSheet(); + + if (templateType === 'SPREAD_ITEM' && selectedRow) { + const dataToSave = { ...selectedRow }; + + cellMappings.forEach(mapping => { + if (mapping.isEditable) { + const cellPos = parseCellAddress(mapping.cellAddress); + if (cellPos) { + const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); + dataToSave[mapping.attId] = cellValue; + } + } + }); + + dataToSave.TAG_NO = selectedRow.TAG_NO; + + const { success, message } = await updateFormDataInDB( + formCode, + contractItemId, + dataToSave + ); + + if (!success) { + toast.error(message); + return; + } + + toast.success("Changes saved successfully!"); + onUpdateSuccess?.(dataToSave); + + } else if ((templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && tableData.length > 0) { + const updatedRows: GenericData[] = []; + let saveCount = 0; + + for (let i = 0; i < tableData.length; i++) { + const originalRow = tableData[i]; + const dataToSave = { ...originalRow }; + let hasRowChanges = false; + + cellMappings.forEach(mapping => { + if (mapping.dataRowIndex === i && mapping.isEditable) { + const columnConfig = columnsJSON.find(col => col.key === mapping.attId); + const isColumnEditable = columnConfig?.shi === "IN" ||columnConfig?.shi === "BOTH"; + const isRowEditable = originalRow.shi === "IN" ||originalRow.shi === "BOTH" ; + + if (isColumnEditable && isRowEditable) { + const cellPos = parseCellAddress(mapping.cellAddress); + if (cellPos) { + const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); + if (cellValue !== originalRow[mapping.attId]) { + dataToSave[mapping.attId] = cellValue; + hasRowChanges = true; + } + } + } + } + }); + + if (hasRowChanges) { + dataToSave.TAG_NO = originalRow.TAG_NO; + const { success } = await updateFormDataInDB( + formCode, + contractItemId, + dataToSave + ); + + if (success) { + updatedRows.push(dataToSave); + saveCount++; + } + } else { + updatedRows.push(originalRow); + } + } + + if (saveCount > 0) { + toast.success(`${saveCount} rows saved successfully!`); + onUpdateSuccess?.(updatedRows); + } else { + toast.info("No changes to save"); + } + } + + setHasChanges(false); + setValidationErrors([]); + + } catch (error) { + console.error("Error saving changes:", error); + toast.error("An unexpected error occurred while saving"); + } finally { + setIsPending(false); + } + }, [currentSpread, hasChanges, templateType, selectedRow, tableData, formCode, contractItemId, onUpdateSuccess, cellMappings, columnsJSON, validateAllData]); + + if (!isOpen) return null; + + const isDataValid = templateType === 'SPREAD_ITEM' ? !!selectedRow : tableData.length > 0; + const dataCount = templateType === 'SPREAD_ITEM' ? 1 : tableData.length; + + return ( + <Dialog open={isOpen} onOpenChange={onClose}> + <DialogContent + className="w-[90vw] max-w-[1400px] h-[85vh] flex flex-col fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] z-50" + > + <DialogHeader className="flex-shrink-0"> + <DialogTitle>SEDP Template Designer - {formCode}</DialogTitle> + <DialogDescription> + <div className="space-y-3"> + {availableTemplates.length > 1 && ( + <div className="flex items-center gap-4"> + <span className="text-sm font-medium">Template:</span> + <Select value={selectedTemplateId} onValueChange={handleTemplateChange}> + <SelectTrigger className="w-64"> + <SelectValue placeholder="Select a template" /> + </SelectTrigger> + <SelectContent> + {availableTemplates.map(template => ( + <SelectItem key={template.TMPL_ID} value={template.TMPL_ID}> + {template.NAME} ({template.TMPL_TYPE}) + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + )} + + {selectedTemplate && ( + <div className="flex items-center gap-4 text-sm"> + <span className="font-medium text-blue-600"> + Template Type: { + templateType === 'SPREAD_LIST' ? 'List View (SPREAD_LIST)' : + templateType === 'SPREAD_ITEM' ? 'Item View (SPREAD_ITEM)' : + 'Grid List View (GRD_LIST)' + } + </span> + {templateType === 'SPREAD_ITEM' && selectedRow && ( + <span>• Selected TAG_NO: {selectedRow.TAG_NO || 'N/A'}</span> + )} + {(templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && ( + <span>• {dataCount} rows</span> + )} + {hasChanges && ( + <span className="text-orange-600 font-medium"> + • Unsaved changes + </span> + )} + {validationErrors.length > 0 && ( + <span className="text-red-600 font-medium flex items-center"> + <AlertTriangle className="w-4 h-4 mr-1" /> + {validationErrors.length} validation errors + </span> + )} + </div> + )} + + <div className="flex items-center gap-4 text-xs"> + <span className="text-muted-foreground"> + <span className="inline-block w-3 h-3 bg-green-100 border border-green-400 mr-1"></span> + Editable fields + </span> + <span className="text-muted-foreground"> + <span className="inline-block w-3 h-3 bg-gray-100 border border-gray-300 mr-1"></span> + Read-only fields + </span> + <span className="text-muted-foreground"> + <span className="inline-block w-3 h-3 bg-red-100 border border-red-300 mr-1"></span> + Validation errors + </span> + {cellMappings.length > 0 && ( + <span className="text-blue-600"> + {editableFieldsCount} of {cellMappings.length} fields editable + </span> + )} + </div> + </div> + </DialogDescription> + </DialogHeader> + + <div className="flex-1 overflow-hidden relative"> + {/* 🆕 로딩 프로그레스 오버레이 */} + <LoadingProgress + phase={loadingProgress?.phase || ''} + progress={loadingProgress?.progress || 0} + total={loadingProgress?.total || 100} + isVisible={isInitializing && !!loadingProgress} + /> + + {selectedTemplate && isClient && isDataValid ? ( + <Designer + key={`${templateType}-${selectedTemplate.TMPL_ID}-${selectedTemplateId}`} + workbookInitialized={initSpread} + hostStyle={hostStyle} + /> + ) : ( + <div className="flex items-center justify-center h-full text-muted-foreground"> + {!isClient ? ( + <> + <Loader className="mr-2 h-4 w-4 animate-spin" /> + Loading... + </> + ) : !selectedTemplate ? ( + "No template available" + ) : !isDataValid ? ( + `No ${templateType === 'SPREAD_ITEM' ? 'selected row' : 'data'} available` + ) : ( + "Template not ready" + )} + </div> + )} + </div> + + <DialogFooter className="flex-shrink-0"> + <div className="flex items-center gap-2"> + <Button variant="outline" onClick={onClose}> + Close + </Button> + + {hasChanges && ( + <Button + variant="default" + onClick={handleSaveChanges} + disabled={isPending || validationErrors.length > 0} + > + {isPending ? ( + <> + <Loader className="mr-2 h-4 w-4 animate-spin" /> + Saving... + </> + ) : ( + <> + <Save className="mr-2 h-4 w-4" /> + Save Changes + </> + )} + </Button> + )} + + {validationErrors.length > 0 && ( + <Button + variant="outline" + onClick={validateAllData} + className="text-red-600 border-red-300 hover:bg-red-50" + > + <AlertTriangle className="mr-2 h-4 w-4" /> + Check Errors ({validationErrors.length}) + </Button> + )} + </div> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/components/form-data-plant/update-form-sheet.tsx b/components/form-data-plant/update-form-sheet.tsx new file mode 100644 index 00000000..bd75d8f3 --- /dev/null +++ b/components/form-data-plant/update-form-sheet.tsx @@ -0,0 +1,445 @@ +"use client"; + +import * as React from "react"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { Check, ChevronsUpDown, Loader, LockIcon } from "lucide-react"; +import { toast } from "sonner"; +import { useRouter } from "next/navigation"; + +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Form, + FormField, + FormItem, + FormLabel, + FormControl, + FormMessage, + FormDescription, +} from "@/components/ui/form"; +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ui/popover"; +import { + Command, + CommandInput, + CommandList, + CommandGroup, + CommandItem, + CommandEmpty, +} from "@/components/ui/command"; + +import { DataTableColumnJSON } from "./form-data-table-columns"; +import { updateFormDataInDB } from "@/lib/forms-plant/services"; +import { cn } from "@/lib/utils"; +import { useParams } from "next/navigation"; +import { useTranslation } from "@/i18n/client"; + +/** ============================================================= + * 🔄 UpdateTagSheet with grouped fields by `head` property + * ----------------------------------------------------------- + * - Consecutive columns that share the same `head` value will be + * rendered under a section title (the head itself). + * - Columns without a head still appear normally. + * + * NOTE: Only rendering logic is touched – all validation, + * read‑only checks, and mutation logic stay the same. + * ============================================================*/ + +interface UpdateTagSheetProps extends React.ComponentPropsWithoutRef<typeof Sheet> { + open: boolean; + onOpenChange: (open: boolean) => void; + columns: DataTableColumnJSON[]; + rowData: Record<string, any> | null; + formCode: string; + contractItemId: number; + editableFieldsMap?: Map<string, string[]>; // 새로 추가 + /** 업데이트 성공 시 호출될 콜백 */ + onUpdateSuccess?: (updatedValues: Record<string, any>) => void; +} + +export function UpdateTagSheet({ + open, + onOpenChange, + columns, + rowData, + formCode, + contractItemId, + editableFieldsMap = new Map(), + onUpdateSuccess, + ...props +}: UpdateTagSheetProps) { + // ─────────────────────────────────────────────────────────────── + // hooks & helpers + // ─────────────────────────────────────────────────────────────── + const [isPending, startTransition] = React.useTransition(); + const router = useRouter(); + + const params = useParams(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "engineering"); + + /* ---------------------------------------------------------------- + * 1️⃣ Editable‑field helpers (unchanged) + * --------------------------------------------------------------*/ + const editableFields = React.useMemo(() => { + if (!rowData?.TAG_NO || !editableFieldsMap.has(rowData.TAG_NO)) { + return [] as string[]; + } + return editableFieldsMap.get(rowData.TAG_NO) || []; + }, [rowData?.TAG_NO, editableFieldsMap]); + + const isFieldEditable = React.useCallback((column: DataTableColumnJSON) => { + if (column.shi === "OUT" || column.shi === null) return false; // SHI‑only + if (column.key === "TAG_NO" || column.key === "TAG_DESC") return false; + if (column.key === "status") return false; + return editableFields.includes(column.key); + // return true + }, [editableFields]); + + const isFieldReadOnly = React.useCallback((column: DataTableColumnJSON) => !isFieldEditable(column), [isFieldEditable]); + + const getReadOnlyReason = React.useCallback((column: DataTableColumnJSON) => { + if (column.shi === "OUT" || column.shi === null) return t("updateTagSheet.readOnlyReasons.shiOnly"); + if (column.key !== "TAG_NO" && column.key !== "TAG_DESC") { + if (!rowData?.TAG_NO || !editableFieldsMap.has(rowData.TAG_NO)) { + return t("updateTagSheet.readOnlyReasons.noEditableFields"); + } + if (!editableFields.includes(column.key)) { + return t("updateTagSheet.readOnlyReasons.notEditableForTag"); + } + } + return t("updateTagSheet.readOnlyReasons.readOnly"); + }, [rowData?.TAG_NO, editableFieldsMap, editableFields, t]); + + /* ---------------------------------------------------------------- + * 2️⃣ Zod dynamic schema & form state (unchanged) + * --------------------------------------------------------------*/ + const dynamicSchema = React.useMemo(() => { + const shape: Record<string, z.ZodTypeAny> = {}; + for (const col of columns) { + if (col.type === "NUMBER") { + shape[col.key] = z + .union([z.coerce.number(), z.nan()]) + .transform((val) => (isNaN(val as number) ? undefined : val)) + .optional(); + } else { + shape[col.key] = z.string().optional(); + } + } + return z.object(shape); + }, [columns]); + + const form = useForm({ + resolver: zodResolver(dynamicSchema), + defaultValues: React.useMemo(() => { + if (!rowData) return {}; + return columns.reduce<Record<string, any>>((acc, col) => { + acc[col.key] = rowData[col.key] ?? ""; + return acc; + }, {}); + }, [rowData, columns]), + }); + + React.useEffect(() => { + if (!rowData) { + form.reset({}); + return; + } + const defaults: Record<string, any> = {}; + columns.forEach((col) => { + defaults[col.key] = rowData[col.key] ?? ""; + }); + form.reset(defaults); + }, [rowData, columns, form]); + + /* ---------------------------------------------------------------- + * 3️⃣ Grouping logic – figure out consecutive columns that share + * the same `head` value. This mirrors `groupColumnsByHead` that + * you already use for the table view. + * --------------------------------------------------------------*/ + const groupedColumns = React.useMemo(() => { + // Ensure original ordering by `seq` where present + const sorted = [...columns].sort((a, b) => { + const seqA = a.seq ?? 999999; + const seqB = b.seq ?? 999999; + return seqA - seqB; + }); + + const groups: { head: string | null; cols: DataTableColumnJSON[] }[] = []; + let i = 0; + while (i < sorted.length) { + const curr = sorted[i]; + const head = curr.head?.trim() || null; + if (!head) { + groups.push({ head: null, cols: [curr] }); + i += 1; + continue; + } + + // Collect consecutive columns with the same head + const cols: DataTableColumnJSON[] = [curr]; + let j = i + 1; + while (j < sorted.length && sorted[j].head?.trim() === head) { + cols.push(sorted[j]); + j += 1; + } + groups.push({ head, cols }); + i = j; + } + return groups; + }, [columns]); + + /* ---------------------------------------------------------------- + * 4️⃣ Submission handler (unchanged) + * --------------------------------------------------------------*/ + async function onSubmit(values: Record<string, any>) { + startTransition(async () => { + try { + // Restore read‑only fields to their original value before saving + const finalValues: Record<string, any> = { ...values }; + columns.forEach((col) => { + if (isFieldReadOnly(col)) { + finalValues[col.key] = rowData?.[col.key] ?? ""; + } + }); + + const { success, message } = await updateFormDataInDB( + formCode, + contractItemId, + finalValues, + ); + + if (!success) { + toast.error(message); + return; + } + + toast.success(t("updateTagSheet.messages.updateSuccess")); + + const updatedData = { ...rowData, ...finalValues, TAG_NO: rowData?.TAG_NO }; + onUpdateSuccess?.(updatedData); + router.refresh(); + onOpenChange(false); + } catch (error) { + console.error("Error updating form data:", error); + toast.error(t("updateTagSheet.messages.updateError")); + } + }); + } + + /* ---------------------------------------------------------------- + * 5️⃣ UI + * --------------------------------------------------------------*/ + const editableFieldCount = React.useMemo(() => columns.filter(isFieldEditable).length, [columns, isFieldEditable]); + + return ( + <Sheet open={open} onOpenChange={onOpenChange} {...props}> + <SheetContent className="sm:max-w-xl md:max-w-3xl lg:max-w-4xl xl:max-w-5xl flex flex-col"> + <SheetHeader className="text-left"> + <SheetTitle> + {t("updateTagSheet.title")} – {rowData?.TAG_NO || t("updateTagSheet.unknownTag")} + </SheetTitle> + <SheetDescription> + {t("updateTagSheet.description")} + <LockIcon className="inline h-3 w-3 mx-1" /> + {t("updateTagSheet.readOnlyIndicator")} + <br /> + <span className="text-sm text-green-600"> + {t("updateTagSheet.editableFieldsCount", { + editableCount: editableFieldCount, + totalCount: columns.length + })} + </span> + </SheetDescription> + </SheetHeader> + + {/* ────────────────────────────────────────────── */} + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> + {/* Scroll wrapper */} + <div className="overflow-y-auto max-h-[80vh] flex-1 pr-4 -mr-4"> + {/* ------------------------------------------------------------------ + * Render groups + * ----------------------------------------------------------------*/} + {groupedColumns.map(({ head, cols }) => ( + <div key={head ?? cols[0].key} className="flex flex-col gap-4 pt-2"> + {head && ( + <h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide pl-1"> + {head} + </h3> + )} + + {/* Fields inside the group */} + {cols.map((col) => { + const isReadOnly = isFieldReadOnly(col); + const readOnlyReason = isReadOnly ? getReadOnlyReason(col) : ""; + return ( + <FormField + key={col.key} + control={form.control} + name={col.key} + render={({ field }) => { + // ——————————————— Number ———————————————— + if (col.type === "NUMBER") { + return ( + <FormItem> + <FormLabel className="flex items-center"> + {col.displayLabel || col.label} + {isReadOnly && ( + <LockIcon className="ml-1 h-3 w-3 text-gray-400" /> + )} + </FormLabel> + <FormControl> + <Input + type="number" + readOnly={isReadOnly} + onChange={(e) => { + const num = parseFloat(e.target.value); + field.onChange(isNaN(num) ? "" : num); + }} + value={field.value ?? ""} + className={cn( + isReadOnly && + "bg-gray-100 text-gray-600 cursor-not-allowed border-gray-300", + )} + /> + </FormControl> + {isReadOnly && ( + <FormDescription className="text-xs text-gray-500"> + {readOnlyReason} + </FormDescription> + )} + <FormMessage /> + </FormItem> + ); + } + + // ——————————————— List ———————————————— + if (col.type === "LIST") { + return ( + <FormItem> + <FormLabel className="flex items-center"> + {col.label} + {isReadOnly && ( + <LockIcon className="ml-1 h-3 w-3 text-gray-400" /> + )} + </FormLabel> + <Popover> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + disabled={isReadOnly} + className={cn( + "w-full justify-between", + !field.value && "text-muted-foreground", + isReadOnly && + "bg-gray-100 text-gray-600 cursor-not-allowed border-gray-300", + )} + > + {field.value ? + col.options?.find((o) => o === field.value) : + t("updateTagSheet.selectOption") + } + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-full p-0"> + <Command> + <CommandInput placeholder={t("updateTagSheet.searchOptions")} /> + <CommandEmpty>{t("updateTagSheet.noOptionFound")}</CommandEmpty> + <CommandList> + <CommandGroup> + {col.options?.map((opt) => ( + <CommandItem key={opt} value={opt} onSelect={() => field.onChange(opt)}> + <Check + className={cn( + "mr-2 h-4 w-4", + field.value === opt ? "opacity-100" : "opacity-0", + )} + /> + {opt} + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + {isReadOnly && ( + <FormDescription className="text-xs text-gray-500"> + {readOnlyReason} + </FormDescription> + )} + <FormMessage /> + </FormItem> + ); + } + + // ——————————————— String / default ———————————— + return ( + <FormItem> + <FormLabel className="flex items-center"> + {col.label} + {isReadOnly && ( + <LockIcon className="ml-1 h-3 w-3 text-gray-400" /> + )} + </FormLabel> + <FormControl> + <Input + readOnly={isReadOnly} + {...field} + className={cn( + isReadOnly && + "bg-gray-100 text-gray-600 cursor-not-allowed border-gray-300", + )} + /> + </FormControl> + {isReadOnly && ( + <FormDescription className="text-xs text-gray-500"> + {readOnlyReason} + </FormDescription> + )} + <FormMessage /> + </FormItem> + ); + }} + /> + ); + })} + </div> + ))} + </div> + + {/* Footer */} + <SheetFooter className="gap-2 pt-2"> + <SheetClose asChild> + <Button type="button" variant="outline"> + {t("buttons.cancel")} + </Button> + </SheetClose> + <Button type="submit" disabled={isPending}> + {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />} + {t("buttons.save")} + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ); +}
\ No newline at end of file diff --git a/components/form-data-plant/var-list-download-btn.tsx b/components/form-data-plant/var-list-download-btn.tsx new file mode 100644 index 00000000..9d09ab8c --- /dev/null +++ b/components/form-data-plant/var-list-download-btn.tsx @@ -0,0 +1,122 @@ +"use client"; + +import React, { FC } from "react"; +import Image from "next/image"; +import { useToast } from "@/hooks/use-toast"; +import { toast as toastMessage } from "sonner"; +import ExcelJS from "exceljs"; +import { saveAs } from "file-saver"; +import { Button } from "@/components/ui/button"; +import { DataTableColumnJSON } from "./form-data-table-columns"; +import { useParams } from "next/navigation"; +import { useTranslation } from "@/i18n/client"; + +interface VarListDownloadBtnProps { + columnsJSON: DataTableColumnJSON[]; + formCode: string; +} + +export const VarListDownloadBtn: FC<VarListDownloadBtnProps> = ({ + columnsJSON, + formCode, +}) => { + const { toast } = useToast(); + + const params = useParams(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "engineering"); + + const downloadReportVarList = async () => { + try { + // Create a new workbook + const workbook = new ExcelJS.Workbook(); + + // 데이터 시트 생성 + const worksheet = workbook.addWorksheet("Data"); + + // 유효성 검사용 숨김 시트 생성 + const validationSheet = workbook.addWorksheet("ValidationData"); + validationSheet.state = "hidden"; // 시트 숨김 처리 + + // 1. 데이터 시트에 헤더 추가 + const headers = [ + t("varListDownload.headers.tableColumnLabel"), + t("varListDownload.headers.reportVariable") + ]; + worksheet.addRow(headers); + + // 헤더 스타일 적용 + const headerRow = worksheet.getRow(1); + headerRow.font = { bold: true }; + headerRow.alignment = { horizontal: "center" }; + headerRow.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, + }; + }); + + // 2. 데이터 행 추가 + columnsJSON.forEach((row) => { + console.log(row); + const { displayLabel, key } = row; + + // const labelConvert = label.replaceAll(" ", "_"); + + worksheet.addRow([displayLabel, key]); + }); + + // 3. 컬럼 너비 자동 조정 + headers.forEach((col, idx) => { + const column = worksheet.getColumn(idx + 1); + + // 최적 너비 계산 + let maxLength = col.length; + columnsJSON.forEach((row) => { + const valueKey = idx === 0 ? "displayLabel" : "label"; + + const value = row[valueKey]; + if (value !== undefined && value !== null) { + const valueLength = String(value).length; + if (valueLength > maxLength) { + maxLength = valueLength; + } + } + }); + + // 너비 설정 (최소 10, 최대 50) + column.width = Math.min(Math.max(maxLength + 2, 10), 50); + }); + + const buffer = await workbook.xlsx.writeBuffer(); + const fileName = `${formCode}${t("varListDownload.fileNameSuffix")}`; + saveAs(new Blob([buffer]), fileName); + toastMessage.success(t("varListDownload.messages.downloadComplete")); + } catch (err) { + console.log(err); + toast({ + title: t("varListDownload.messages.errorTitle"), + description: t("varListDownload.messages.errorDescription"), + variant: "destructive", + }); + } + }; + + return ( + <Button + variant="outline" + className="relative px-[8px] py-[6px] flex-1" + aria-label={t("varListDownload.buttonAriaLabel")} + onClick={downloadReportVarList} + > + <Image + src="/icons/var_list_icon.svg" + alt={t("varListDownload.iconAltText")} + width={16} + height={16} + /> + <div className="text-[12px]">{t("varListDownload.buttonText")}</div> + </Button> + ); +};
\ No newline at end of file diff --git a/components/form-data/form-data-table.tsx b/components/form-data/form-data-table.tsx index 0f55c559..98cc7b46 100644 --- a/components/form-data/form-data-table.tsx +++ b/components/form-data/form-data-table.tsx @@ -117,6 +117,7 @@ export default function DynamicTable({ const [activeFilter, setActiveFilter] = React.useState<string | null>(null); const [filteredTableData, setFilteredTableData] = React.useState<GenericData[]>(tableData); + const [rowSelection, setRowSelection] = React.useState<Record<string, boolean>>({}); // 필터링 로직 React.useEffect(() => { @@ -343,15 +344,20 @@ export default function DynamicTable({ }, [selectedRowsData]); const columns = React.useMemo( - () => getColumns<GenericData>({ - columnsJSON, - setRowAction, - setReportData, - tempCount, - }), - [columnsJSON, setRowAction, setReportData, tempCount] + () => + getColumns({ + columnsJSON, + setRowAction, + setReportData, + tempCount, + onRowSelectionChange: setRowSelection, // ✅ 맞습니다 + templateData, + }), + [columnsJSON, tempCount, templateData] + // setRowSelection은 setState 함수라서 의존성 배열에서 제외 가능 + // (React가 안정적인 참조를 보장) ); - + function mapColumnTypeToAdvancedFilterType( columnType: ColumnType ): DataTableAdvancedFilterField<GenericData>["type"] { diff --git a/components/vendor-data-plant/project-swicher.tsx b/components/vendor-data-plant/project-swicher.tsx new file mode 100644 index 00000000..d3123709 --- /dev/null +++ b/components/vendor-data-plant/project-swicher.tsx @@ -0,0 +1,171 @@ +"use client" + +import * as React from "react" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { Check, ChevronsUpDown, Loader2 } from "lucide-react" + +interface ContractInfo { + contractId: number + contractName: string +} + +interface ProjectInfo { + projectId: number + projectCode: string + projectName: string + contracts: ContractInfo[] +} + +interface ProjectSwitcherProps { + isCollapsed: boolean + projects: ProjectInfo[] + + // 상위가 관리하는 "현재 선택된 contractId" + selectedContractId: number | null + + // 콜백: 사용자가 "어떤 contract"를 골랐는지 + // => 우리가 projectId도 찾아서 상위 state를 같이 갱신해야 함 + onSelectContract: (projectId: number, contractId: number) => void + + // 로딩 상태 (선택사항) + isLoading?: boolean +} + +export function ProjectSwitcher({ + isCollapsed, + projects, + selectedContractId, + onSelectContract, + isLoading = false, +}: ProjectSwitcherProps) { + const [popoverOpen, setPopoverOpen] = React.useState(false) + const [searchTerm, setSearchTerm] = React.useState("") + + // 현재 선택된 contract 객체 찾기 + const selectedContract = React.useMemo(() => { + if (!selectedContractId) return null + for (const proj of projects) { + const found = proj.contracts.find((c) => c.contractId === selectedContractId) + if (found) { + return { ...found, projectId: proj.projectId, projectName: proj.projectName } + } + } + return null + }, [projects, selectedContractId]) + + // Trigger label => 계약 이름 or placeholder + const triggerLabel = selectedContract?.contractName ?? "Select a contract" + + // 검색어에 따른 필터링된 프로젝트/계약 목록 + const filteredProjects = React.useMemo(() => { + if (!searchTerm) return projects + + return projects.map(project => ({ + ...project, + contracts: project.contracts.filter(contract => + contract.contractName.toLowerCase().includes(searchTerm.toLowerCase()) || + project.projectName.toLowerCase().includes(searchTerm.toLowerCase()) + ) + })).filter(project => project.contracts.length > 0) + }, [projects, searchTerm]) + + // 계약 선택 핸들러 + function handleSelectContract(projectId: number, contractId: number) { + onSelectContract(projectId, contractId) + setPopoverOpen(false) + setSearchTerm("") // 검색어 초기화 + } + + // 총 계약 수 계산 (빈 상태 표시용) + const totalContracts = filteredProjects.reduce((sum, project) => sum + project.contracts.length, 0) + + return ( + <Popover open={popoverOpen} onOpenChange={setPopoverOpen}> + <PopoverTrigger asChild> + <Button + type="button" + variant="outline" + className={cn( + "justify-between relative", + isCollapsed ? "h-9 w-9 shrink-0 items-center justify-center p-0" : "w-full h-9" + )} + disabled={isLoading} + aria-label="Select Contract" + > + {isLoading ? ( + <> + <span className={cn(isCollapsed && "hidden")}>Loading...</span> + <Loader2 className={cn("h-4 w-4 animate-spin", !isCollapsed && "ml-2")} /> + </> + ) : ( + <> + <span className={cn("truncate flex-grow text-left", isCollapsed && "hidden")}> + {triggerLabel} + </span> + <ChevronsUpDown className={cn("h-4 w-4 opacity-50 flex-shrink-0", isCollapsed && "hidden")} /> + </> + )} + </Button> + </PopoverTrigger> + + <PopoverContent className="w-[320px] p-0" align="start"> + <Command> + <CommandInput + placeholder="Search contracts..." + value={searchTerm} + onValueChange={setSearchTerm} + /> + + <CommandList + className="max-h-[320px]" + onWheel={(e) => { + e.stopPropagation() // 이벤트 전파 차단 + const target = e.currentTarget + target.scrollTop += e.deltaY // 직접 스크롤 처리 + }} + > + <CommandEmpty> + {totalContracts === 0 ? "No contracts found." : "No search results."} + </CommandEmpty> + + {filteredProjects.map((project) => ( + <CommandGroup key={project.projectCode} heading={project.projectName}> + {project.contracts.map((contract) => ( + <CommandItem + key={contract.contractId} + onSelect={() => handleSelectContract(project.projectId, contract.contractId)} + value={`${project.projectName} ${contract.contractName}`} + className="truncate" + title={contract.contractName} + > + <span className="truncate">{contract.contractName}</span> + <Check + className={cn( + "ml-auto h-4 w-4 flex-shrink-0", + selectedContractId === contract.contractId ? "opacity-100" : "opacity-0" + )} + /> + </CommandItem> + ))} + </CommandGroup> + ))} + </CommandList> + </Command> + </PopoverContent> + </Popover> + ) +}
\ No newline at end of file diff --git a/components/vendor-data-plant/sidebar.tsx b/components/vendor-data-plant/sidebar.tsx new file mode 100644 index 00000000..31ee6dc7 --- /dev/null +++ b/components/vendor-data-plant/sidebar.tsx @@ -0,0 +1,318 @@ +"use client" + +import * as React from "react" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Separator } from "@/components/ui/separator" +import { + Tooltip, + TooltipTrigger, + TooltipContent, +} from "@/components/ui/tooltip" +import { Package2, FormInput } from "lucide-react" +import { useRouter, usePathname } from "next/navigation" +import { Skeleton } from "@/components/ui/skeleton" +import { type FormInfo } from "@/lib/forms/services" + +interface PackageData { + itemId: number + itemName: string +} + +interface SidebarProps extends React.HTMLAttributes<HTMLDivElement> { + isCollapsed: boolean + packages: PackageData[] + selectedPackageId: number | null + selectedProjectId: number | null + selectedContractId: number | null + onSelectPackage: (itemId: number) => void + forms?: FormInfo[] + onSelectForm: (formName: string) => void + isLoadingForms?: boolean + mode: "IM" | "ENG" +} + +export function Sidebar({ + className, + isCollapsed, + packages, + selectedPackageId, + selectedProjectId, + selectedContractId, + onSelectPackage, + forms, + onSelectForm, + isLoadingForms = false, + mode = "IM", +}: SidebarProps) { + const router = useRouter() + const rawPathname = usePathname() + const pathname = rawPathname ?? "" + + /** + * --------------------------- + * 1) URL에서 현재 패키지 / 폼 코드 추출 + * --------------------------- + */ + const segments = pathname.split("/").filter(Boolean) + + let currentItemId: number | null = null + let currentFormCode: string | null = null + + const tagIndex = segments.indexOf("tag") + if (tagIndex !== -1 && segments[tagIndex + 1]) { + currentItemId = parseInt(segments[tagIndex + 1], 10) + } + + const formIndex = segments.indexOf("form") + if (formIndex !== -1) { + const itemSegment = segments[formIndex + 1] + const codeSegment = segments[formIndex + 2] + + if (itemSegment) { + currentItemId = parseInt(itemSegment, 10) + } + if (codeSegment) { + currentFormCode = codeSegment + } + } + + /** + * --------------------------- + * 2) 패키지 클릭 핸들러 (IM 모드) + * --------------------------- + */ + const handlePackageClick = (itemId: number) => { + // 상위 컴포넌트 상태 업데이트 + onSelectPackage(itemId) + + // 해당 태그 페이지로 라우팅 + // 예: /vendor-data-plant/tag/123 + const baseSegments = segments.slice(0, segments.indexOf("vendor-data-plant") + 1).join("/") + router.push(`/${baseSegments}/tag/${itemId}`) + } + + /** + * --------------------------- + * 3) 폼 클릭 핸들러 (IM 모드만 사용) + * --------------------------- + */ + const handleFormClick = (form: FormInfo) => { + // IM 모드에서만 사용 + if (selectedPackageId === null) return; + + onSelectForm(form.formName) + + const baseSegments = segments.slice(0, segments.indexOf("vendor-data-plant") + 1).join("/") + router.push(`/${baseSegments}/form/${selectedPackageId}/${form.formCode}/${selectedProjectId}/${selectedContractId}?mode=${mode}`) + } + + /** + * --------------------------- + * 4) 패키지 클릭 핸들러 (ENG 모드) + * --------------------------- + */ + const handlePackageUnderFormClick = (form: FormInfo, pkg: PackageData) => { + onSelectForm(form.formName) + onSelectPackage(pkg.itemId) + + const baseSegments = segments.slice(0, segments.indexOf("vendor-data-plant") + 1).join("/") + router.push(`/${baseSegments}/form/${pkg.itemId}/${form.formCode}/${selectedProjectId}/${selectedContractId}?mode=${mode}`) + } + + return ( + <div className={cn("pb-12", className)}> + <div className="space-y-4 py-4"> + {/* ---------- 패키지(Items) 목록 - IM 모드에서만 표시 ---------- */} + {mode === "IM" && ( + <> + <div className="py-1"> + <h2 className="relative px-7 text-lg font-semibold tracking-tight"> + {isCollapsed ? "P" : "Package Lists"} + </h2> + <ScrollArea className="h-[150px] px-1"> + <div className="space-y-1 p-2"> + {packages.map((pkg) => { + const isActive = pkg.itemId === currentItemId + + return ( + <div key={pkg.itemId}> + {isCollapsed ? ( + <Tooltip delayDuration={0}> + <TooltipTrigger asChild> + <Button + variant="ghost" + className={cn( + "w-full justify-start font-normal", + isActive && "bg-accent text-accent-foreground" + )} + onClick={() => handlePackageClick(pkg.itemId)} + > + <Package2 className="mr-2 h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent side="right"> + {pkg.itemName} + </TooltipContent> + </Tooltip> + ) : ( + <Button + variant="ghost" + className={cn( + "w-full justify-start font-normal", + isActive && "bg-accent text-accent-foreground" + )} + onClick={() => handlePackageClick(pkg.itemId)} + > + <Package2 className="mr-2 h-4 w-4" /> + {pkg.itemName} + </Button> + )} + </div> + ) + })} + </div> + </ScrollArea> + </div> + <Separator /> + </> + )} + + {/* ---------- 폼 목록 (IM 모드) / 패키지와 폼 목록 (ENG 모드) ---------- */} + <div className="py-1"> + <h2 className="relative px-7 text-lg font-semibold tracking-tight"> + {isCollapsed + ? (mode === "IM" ? "F" : "P") + : (mode === "IM" ? "Form Lists" : "Package Lists") + } + </h2> + <ScrollArea className={cn( + "px-1", + mode === "IM" ? "h-[300px]" : "h-[450px]" + )}> + <div className="space-y-1 p-2"> + {isLoadingForms ? ( + Array.from({ length: 3 }).map((_, index) => ( + <div key={`form-skeleton-${index}`} className="px-2 py-1.5"> + <Skeleton className="h-8 w-full" /> + </div> + )) + ) : mode === "IM" ? ( + // =========== IM 모드: 폼만 표시 =========== + !forms || forms.length === 0 ? ( + <p className="text-sm text-muted-foreground px-2"> + (No forms loaded) + </p> + ) : ( + forms.map((form) => { + const isFormActive = form.formCode === currentFormCode + const isDisabled = currentItemId === null + + return isCollapsed ? ( + <Tooltip key={form.formCode} delayDuration={0}> + <TooltipTrigger asChild> + <Button + variant="ghost" + className={cn( + "w-full justify-start font-normal", + isFormActive && "bg-accent text-accent-foreground" + )} + onClick={() => handleFormClick(form)} + disabled={isDisabled} + > + <FormInput className="mr-2 h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent side="right"> + {form.formName} + </TooltipContent> + </Tooltip> + ) : ( + <Button + key={form.formCode} + variant="ghost" + className={cn( + "w-full justify-start font-normal", + isFormActive && "bg-accent text-accent-foreground" + )} + onClick={() => handleFormClick(form)} + disabled={isDisabled} + > + <FormInput className="mr-2 h-4 w-4" /> + {form.formName} + </Button> + ) + }) + ) + ) : ( + // =========== ENG 모드: 패키지 > 폼 계층 구조 =========== + packages.length === 0 ? ( + <p className="text-sm text-muted-foreground px-2"> + (No packages loaded) + </p> + ) : ( + packages.map((pkg) => ( + <div key={pkg.itemId} className="space-y-1"> + {isCollapsed ? ( + <Tooltip delayDuration={0}> + <TooltipTrigger asChild> + <div className="px-2 py-1"> + <Package2 className="h-4 w-4" /> + </div> + </TooltipTrigger> + <TooltipContent side="right"> + {pkg.itemName} + </TooltipContent> + </Tooltip> + ) : ( + <> + {/* 패키지 이름 (클릭 불가능한 라벨) */} + <div className="flex items-center px-2 py-1 text-sm font-medium"> + <Package2 className="mr-2 h-4 w-4" /> + {pkg.itemName} + </div> + + {/* 폼 목록 바로 표시 */} + <div className="ml-6 space-y-1"> + {!forms || forms.length === 0 ? ( + <p className="text-xs text-muted-foreground px-2 py-1"> + No forms available + </p> + ) : ( + forms.map((form) => { + const isFormPackageActive = + pkg.itemId === currentItemId && + form.formCode === currentFormCode + + return ( + <Button + key={`${pkg.itemId}-${form.formCode}`} + variant="ghost" + size="sm" + className={cn( + "w-full justify-start font-normal text-sm", + isFormPackageActive && "bg-accent text-accent-foreground" + )} + onClick={() => handlePackageUnderFormClick(form, pkg)} + > + <FormInput className="mr-2 h-3 w-3" /> + {form.formName} + </Button> + ) + }) + )} + </div> + </> + )} + </div> + )) + ) + )} + </div> + </ScrollArea> + </div> + </div> + </div> + ) +}
\ No newline at end of file diff --git a/components/vendor-data-plant/tag-table/add-tag-dialog.tsx b/components/vendor-data-plant/tag-table/add-tag-dialog.tsx new file mode 100644 index 00000000..1321fc58 --- /dev/null +++ b/components/vendor-data-plant/tag-table/add-tag-dialog.tsx @@ -0,0 +1,357 @@ +"use client" + +import * as React from "react" +import { useForm, useWatch } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { createTagSchema, type CreateTagSchema } from "@/lib/tags/validations" +import { createTag } from "@/lib/tags/service" +import { toast } from "sonner" +import { Loader2 } from "lucide-react" +import { cn } from "@/lib/utils" +import { useRouter } from "next/navigation" + +// Popover + Command +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ui/popover" +import { + Command, + CommandInput, + CommandList, + CommandGroup, + CommandItem, + CommandEmpty, +} from "@/components/ui/command" +import { ChevronsUpDown, Check } from "lucide-react" + +// The dynamic Tag Type definitions +import { tagTypeDefinitions } from "./tag-type-definitions" + +// Add Select component for dropdown fields +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { ScrollArea } from "@/components/ui/scroll-area" + +interface AddTagDialogProps { + selectedPackageId: number | null +} + +export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) { + const [open, setOpen] = React.useState(false) + const [isPending, startTransition] = React.useTransition() + const router = useRouter() + + const form = useForm<CreateTagSchema>({ + resolver: zodResolver(createTagSchema), + defaultValues: { + tagType: "", // user picks + tagNo: "", // auto-generated + description: "", + functionCode: "", + seqNumber: "", + valveAcronym: "", + processUnit: "", + }, + }) + + const watchAll = useWatch({ control: form.control }) + + // 1) Find the selected tag type definition + const currentTagTypeDef = React.useMemo(() => { + return tagTypeDefinitions.find((def) => def.id === watchAll.tagType) || null + }, [watchAll.tagType]) + + // 2) Whenever the user changes sub-fields, re-generate `tagNo` + React.useEffect(() => { + if (!currentTagTypeDef) { + // if no type selected, no auto-generation + return + } + + // Prevent infinite loop by excluding tagNo from the watched dependencies + // This is crucial because setting tagNo would trigger another update + const { tagNo, ...fieldsToWatch } = watchAll + + const newTagNo = currentTagTypeDef.generateTagNo(fieldsToWatch as CreateTagSchema) + + // Only update if different to avoid unnecessary re-renders + if (form.getValues("tagNo") !== newTagNo) { + form.setValue("tagNo", newTagNo, { shouldValidate: false }) + } + }, [currentTagTypeDef, watchAll, form]) + + // Check if tag number is valid (doesn't contain '??' and is not empty) + const isTagNoValid = React.useMemo(() => { + const tagNo = form.getValues("tagNo"); + return tagNo && tagNo.trim() !== "" && !tagNo.includes("??"); + }, [form, watchAll.tagNo]); + + // onSubmit + async function onSubmit(data: CreateTagSchema) { + startTransition(async () => { + if (!selectedPackageId) { + toast.error("No selectedPackageId.") + return + } + + const result = await createTag(data, selectedPackageId) + if ("error" in result) { + toast.error(`Error: ${result.error}`) + return + } + + toast.success("Tag created successfully!") + form.reset() + setOpen(false) + router.refresh() + + }) + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset() + } + setOpen(nextOpen) + } + + // 3) TagType selection UI (like your Command menu) + function renderTagTypeSelector(field: any) { + const [popoverOpen, setPopoverOpen] = React.useState(false) + return ( + <FormItem> + <FormLabel>Tag Type</FormLabel> + <FormControl> + <Popover open={popoverOpen} onOpenChange={setPopoverOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={popoverOpen} + className="w-full justify-between" + > + {field.value + ? tagTypeDefinitions.find((d) => d.id === field.value)?.label + : "Select Tag Type..."} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-full p-0"> + <Command> + <CommandInput placeholder="Search Tag Type..." /> + <CommandList> + <CommandEmpty>No tag type found.</CommandEmpty> + <CommandGroup> + {tagTypeDefinitions.map((def,index) => ( + <CommandItem + key={index} + onSelect={() => { + field.onChange(def.id) // store the 'id' + setPopoverOpen(false) + }} + value={def.id} + > + {def.label} + <Check + className={cn( + "ml-auto h-4 w-4", + field.value === def.id + ? "opacity-100" + : "opacity-0" + )} + /> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + </FormControl> + <FormMessage /> + </FormItem> + ) + } + + // 4) Render sub-fields based on currentTagTypeDef + // Updated to handle different field types (text, select) + function renderSubFields() { + if (!currentTagTypeDef) return null + + return currentTagTypeDef.subFields.map((subField, index) => ( + + <FormField + key={`${subField.name}-${index}`} + control={form.control} + name={subField.name as keyof CreateTagSchema} + render={({ field }) => ( + <FormItem> + <FormLabel>{subField.label}</FormLabel> + <FormControl> + {subField.type === "select" && subField.options ? ( + <Select + value={field.value || ""} + onValueChange={field.onChange} + > + <SelectTrigger className="w-full"> + <SelectValue placeholder={subField.placeholder || "Select an option"} /> + </SelectTrigger> + <SelectContent> + {subField.options.map((option, index) => ( + <SelectItem key={index} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + ) : ( + <Input + placeholder={subField.placeholder || ""} + value={field.value || ""} + onChange={field.onChange} + onBlur={field.onBlur} + name={field.name} + ref={field.ref} + /> + )} + </FormControl> + {subField.formatHint && ( + <p className="text-sm text-muted-foreground mt-1"> + {subField.formatHint} + </p> + )} + <FormMessage /> + </FormItem> + )} + /> + )) + } + + return ( + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + <DialogTrigger asChild> + <Button variant="default" size="sm"> + Add Tag + </Button> + </DialogTrigger> + + <DialogContent className="max-w-md max-h-[90vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>Add New Tag</DialogTitle> + <DialogDescription> + Select a Tag Type and fill in sub-fields. The Tag No will be generated automatically. + </DialogDescription> + </DialogHeader> + <ScrollArea className="flex-1"> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + <div className="space-y-4"> + {/* Tag Type - Outside ScrollArea as it's always visible */} + <FormField + control={form.control} + name="tagType" + render={({ field }) => renderTagTypeSelector(field)} + /> + </div> + + {/* ScrollArea for dynamic fields */} + <ScrollArea className="h-[50vh] pr-4"> + <div className="space-y-4"> + {/* sub-fields from the selected tagType */} + {renderSubFields()} + + {/* Tag No (auto-generated) */} + <FormField + control={form.control} + name="tagNo" + render={({ field }) => ( + <FormItem> + <FormLabel>Tag No (auto-generated)</FormLabel> + <FormControl> + <Input + placeholder="Auto-generated..." + {...field} + readOnly + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Description (optional) */} + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>Description </FormLabel> + <FormControl> + <Input + placeholder="Optional desc..." + value={field.value ?? ""} + onChange={(e) => field.onChange(e.target.value)} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </ScrollArea> + </form> + </Form> + </ScrollArea> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => { + form.reset(); + setOpen(false); + }} + disabled={isPending} + > + Cancel + </Button> + <Button type="submit" disabled={isPending || !isTagNoValid}> + {isPending && ( + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + )} + Create + </Button> + </DialogFooter> + + + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/components/vendor-data-plant/tag-table/tag-table-column.tsx b/components/vendor-data-plant/tag-table/tag-table-column.tsx new file mode 100644 index 00000000..6f0d977f --- /dev/null +++ b/components/vendor-data-plant/tag-table/tag-table-column.tsx @@ -0,0 +1,198 @@ +"use client" + +import * as React from "react" +import { ColumnDef } from "@tanstack/react-table" +import type { Row } from "@tanstack/react-table" +import { numericFilter } from "@/lib/data-table" +import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header" +import { formatDate } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, + } from "@/components/ui/dropdown-menu" +import { Ellipsis } from "lucide-react" +import { Tag } from "@/types/vendorData" +import { createFilterFn } from "@/components/client-data-table/table-filters" + + +export interface DataTableRowAction<TData> { + row: Row<TData> + type: 'open' | "update" | "delete" +} + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<Tag> | null>> +} + +export function getColumns({ + setRowAction, + }: GetColumnsProps): ColumnDef<Tag>[] { + return [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + enableSorting: false, + enableHiding: false, + size: 40, + }, + + { + accessorKey: "tagNo", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Tag No." /> + ), + filterFn: createFilterFn("text"), + cell: ({ row }) => <div className="w-20">{row.getValue("tagNo")}</div>, + meta: { + excelHeader: "Tag No" + }, + }, + { + accessorKey: "description", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Tag Description" /> + ), + cell: ({ row }) => <div className="w-120">{row.getValue("description")}</div>, + meta: { + excelHeader: "Tag Descripiton" + }, + }, + { + accessorKey: "tagType", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Tag Type" /> + ), + cell: ({ row }) => <div className="w-40">{row.getValue("tagType")}</div>, + meta: { + excelHeader: "Tag Type" + }, + }, + { + id: "validation", + header: "Error", + cell: ({ row }) => <div className="w-100"></div>, + }, + + { + accessorKey: "createdAt", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Created At" /> + ), + cell: ({ cell }) => formatDate(cell.getValue() as Date), + meta: { + excelHeader: "created At" + }, + }, + { + accessorKey: "updatedAt", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Updated At" /> + ), + cell: ({ cell }) => formatDate(cell.getValue() as Date), + meta: { + excelHeader: "updated At" + }, + }, + + { + id: "actions", + cell: function Cell({ row }) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "update" })} + > + Edit + </DropdownMenuItem> + <DropdownMenuSub> + <DropdownMenuSubTrigger>Labels</DropdownMenuSubTrigger> + <DropdownMenuSubContent> + {/* <DropdownMenuRadioGroup + value={row.original.label} + onValueChange={(value) => { + startUpdateTransition(() => { + toast.promise( + updateTask({ + id: row.original.id, + label: value as Task["label"], + }), + { + loading: "Updating...", + success: "Label updated", + error: (err) => getErrorMessage(err), + } + ) + }) + }} + > + {tasks.label.enumValues.map((label) => ( + <DropdownMenuRadioItem + key={label} + value={label} + className="capitalize" + disabled={isUpdatePending} + > + {label} + </DropdownMenuRadioItem> + ))} + </DropdownMenuRadioGroup> */} + </DropdownMenuSubContent> + </DropdownMenuSub> + <DropdownMenuSeparator /> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "delete" })} + > + Delete + <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + }, + ] + } +
\ No newline at end of file diff --git a/components/vendor-data-plant/tag-table/tag-table.tsx b/components/vendor-data-plant/tag-table/tag-table.tsx new file mode 100644 index 00000000..a449529f --- /dev/null +++ b/components/vendor-data-plant/tag-table/tag-table.tsx @@ -0,0 +1,39 @@ +"use client" + +import * as React from "react" +import { ClientDataTable } from "@/components/client-data-table/data-table" +import { DataTableRowAction, getColumns } from "./tag-table-column" +import { Tag as TagData } from "@/types/vendorData" +import { DataTableAdvancedFilterField } from "@/types/table" +import { AddTagDialog } from "./add-tag-dialog" + +interface TagTableProps { + data: TagData[] +} + +/** + * TagTable: Tag 데이터를 표시하는 표 + */ +export function TagTable({ data }: TagTableProps) { + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<TagData> | null>(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + const advancedFilterFields: DataTableAdvancedFilterField<TagData>[] = [] + + return ( + <> + <ClientDataTable + data={data} + columns={columns} + advancedFilterFields={advancedFilterFields} + /> + + </> + ) +}
\ No newline at end of file diff --git a/components/vendor-data-plant/tag-table/tag-type-definitions.ts b/components/vendor-data-plant/tag-table/tag-type-definitions.ts new file mode 100644 index 00000000..e5d04eab --- /dev/null +++ b/components/vendor-data-plant/tag-table/tag-type-definitions.ts @@ -0,0 +1,87 @@ +import { CreateTagSchema } from "@/lib/tags/validations" + +/** + * Each "Tag Type" has: + * - id, label + * - subFields[]: + * -- name (form field name) + * -- label (UI label) + * -- placeholder? + * -- type: "select" | "text" + * -- options?: { value: string; label: string; }[] (for dropdown) + * -- optional "regex" or "formatHint" for display + * - generateTagNo: function + */ +export const tagTypeDefinitions = [ + { + id: "EquipmentNumbering", + label: "Equipment Numbering", + subFields: [ + { + name: "functionCode", + label: "Function", + placeholder: "", + type: "select", + // Example options: + options: [ + { value: "PM", label: "Pump" }, + { value: "AA", label: "Pneumatic Motor" }, + ], + // or if you want a regex or format hint: + formatHint: "2 letters, e.g. PM", + }, + { + name: "seqNumber", + label: "Seq. Number", + placeholder: "001", + type: "text", + formatHint: "3 digits", + }, + ], + generateTagNo: (values: CreateTagSchema) => { + const fc = values.functionCode || "??" + const seq = values.seqNumber || "000" + return `${fc}-${seq}` + }, + }, + { + id: "Valve", + label: "Valve", + subFields: [ + { + name: "valveAcronym", + label: "Valve Acronym", + placeholder: "", + type: "select", + options: [ + { value: "VB", label: "Ball Valve" }, + { value: "VAR", label: "Auto Recirculation Valve" }, + ], + }, + { + name: "processUnit", + label: "Process Unit (2 digits)", + placeholder: "01", + type: "select", + options: [ + { value: "01", label: "Firewater System" }, + { value: "02", label: "Liquefaction Unit" }, + ], + }, + { + name: "seqNumber", + label: "Seq. Number", + placeholder: "001", + type: "text", + formatHint: "3 digits", + }, + ], + generateTagNo: (values: CreateTagSchema) => { + const va = values.valveAcronym || "??" + const pu = values.processUnit || "??" + const seq= values.seqNumber || "000" + return `${va}-${pu}${seq}` + }, + }, + // ... more types from your API ... +]
\ No newline at end of file diff --git a/components/vendor-data-plant/vendor-data-container.tsx b/components/vendor-data-plant/vendor-data-container.tsx new file mode 100644 index 00000000..60ec2c94 --- /dev/null +++ b/components/vendor-data-plant/vendor-data-container.tsx @@ -0,0 +1,505 @@ +"use client" + +import * as React from "react" +import { TooltipProvider } from "@/components/ui/tooltip" +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable" +import { cn } from "@/lib/utils" +import { ProjectSwitcher } from "./project-swicher" +import { Sidebar } from "./sidebar" +import { usePathname, useRouter, useSearchParams } from "next/navigation" +import { getFormsByContractItemId, type FormInfo } from "@/lib/forms/services" +import { Separator } from "@/components/ui/separator" +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Button } from "@/components/ui/button" +import { FormInput } from "lucide-react" +import { Skeleton } from "@/components/ui/skeleton" +import { selectedModeAtom } from '@/atoms' +import { useAtom } from 'jotai' + +interface PackageData { + itemId: number + itemName: string +} + +interface ContractData { + contractId: number + contractName: string + packages: PackageData[] +} + +interface ProjectData { + projectId: number + projectCode: string + projectName: string + projectType: string + contracts: ContractData[] +} + +interface VendorDataContainerProps { + projects: ProjectData[] + defaultLayout?: number[] + defaultCollapsed?: boolean + navCollapsedSize: number + children: React.ReactNode +} + +function getTagIdFromPathname(path: string | null): number | null { + if (!path) return null; + + // 태그 패턴 검사 (/tag/123) + const tagMatch = path.match(/\/tag\/(\d+)/) + if (tagMatch) return parseInt(tagMatch[1], 10) + + // 폼 패턴 검사 (/form/123/...) + const formMatch = path.match(/\/form\/(\d+)/) + if (formMatch) return parseInt(formMatch[1], 10) + + return null +} + +export function VendorDataContainer({ + projects, + defaultLayout = [20, 80], + defaultCollapsed = false, + navCollapsedSize, + children +}: VendorDataContainerProps) { + const pathname = usePathname() + const router = useRouter() + const searchParams = useSearchParams() + + const tagIdNumber = getTagIdFromPathname(pathname) + + // 기본 상태 + const [selectedProjectId, setSelectedProjectId] = React.useState(projects[0]?.projectId || 0) + const [isCollapsed, setIsCollapsed] = React.useState(defaultCollapsed) + const [selectedContractId, setSelectedContractId] = React.useState( + projects[0]?.contracts[0]?.contractId || 0 + ) + const [selectedPackageId, setSelectedPackageId] = React.useState<number | null>(null) + const [formList, setFormList] = React.useState<FormInfo[]>([]) + const [selectedFormCode, setSelectedFormCode] = React.useState<string | null>(null) + const [isLoadingForms, setIsLoadingForms] = React.useState(false) + + console.log(selectedPackageId,"selectedPackageId") + + + // 현재 선택된 프로젝트/계약/패키지 + const currentProject = projects.find((p) => p.projectId === selectedProjectId) ?? projects[0] + const currentContract = currentProject?.contracts.find((c) => c.contractId === selectedContractId) + ?? currentProject?.contracts[0] + + // 프로젝트 타입 확인 - ship인 경우 항상 ENG 모드 + const isShipProject = currentProject?.projectType === "ship" + + const [selectedMode, setSelectedMode] = useAtom(selectedModeAtom) + + // URL에서 모드 추출 (ship 프로젝트면 무조건 ENG로, 아니면 URL 또는 기본값) + const modeFromUrl = searchParams?.get('mode') + const initialMode ="ENG" + + // 모드 초기화 (기존의 useState 초기값 대신) + React.useEffect(() => { + setSelectedMode(initialMode as "IM" | "ENG") + }, [initialMode, setSelectedMode]) + + const isTagOrFormRoute = pathname ? (pathname.includes("/tag/") || pathname.includes("/form/")) : false + const currentPackageName = isTagOrFormRoute + ? currentContract?.packages.find((pkg) => pkg.itemId === selectedPackageId)?.itemName || "None" + : "None" + + // 폼 목록에서 고유한 폼 이름만 추출 + const formNames = React.useMemo(() => { + return [...new Set(formList.map((form) => form.formName))] + }, [formList]) + + // URL에서 현재 폼 코드 추출 + const getCurrentFormCode = (path: string): string | null => { + const segments = path.split("/").filter(Boolean) + const formIndex = segments.indexOf("form") + if (formIndex !== -1 && segments[formIndex + 2]) { + return segments[formIndex + 2] + } + return null + } + + const currentFormCode = React.useMemo(() => { + return pathname ? getCurrentFormCode(pathname) : null + }, [pathname]) + + // URL에서 모드가 변경되면 상태도 업데이트 (ship 프로젝트가 아닐 때만) + React.useEffect(() => { + if (!isShipProject) { + const modeFromUrl = searchParams?.get('mode') + if (modeFromUrl === "ENG" || modeFromUrl === "IM") { + setSelectedMode(modeFromUrl) + } + } + }, [searchParams, isShipProject]) + + // 프로젝트 타입이 변경될 때 모드 업데이트 + React.useEffect(() => { + if (isShipProject) { + setSelectedMode("ENG") + + // URL 모드 파라미터도 업데이트 + const url = new URL(window.location.href); + url.searchParams.set('mode', 'ENG'); + router.replace(url.pathname + url.search); + } + }, [isShipProject, router]) + + // (1) 새로고침 시 URL 파라미터(tagIdNumber) → selectedPackageId 세팅 + React.useEffect(() => { + if (!currentContract) return + + if (tagIdNumber) { + setSelectedPackageId(tagIdNumber) + } else { + // tagIdNumber가 없으면, 현재 계약의 첫 번째 패키지로 + if (currentContract.packages?.length) { + setSelectedPackageId(currentContract.packages[0].itemId) + } else { + setSelectedPackageId(null) + } + } + }, [tagIdNumber, currentContract]) + + // (2) 프로젝트 변경 시 계약 초기화 + // React.useEffect(() => { + // if (currentProject?.contracts.length) { + // setSelectedContractId(currentProject.contracts[0].contractId) + // } else { + // setSelectedContractId(0) + // } + // }, [currentProject]) + + // (3) 패키지 ID와 모드가 변경될 때마다 폼 로딩 + React.useEffect(() => { + const packageId = getTagIdFromPathname(pathname) + + if (packageId) { + setSelectedPackageId(packageId) + + // URL에서 패키지 ID를 얻었을 때 즉시 폼 로드 + loadFormsList(packageId, selectedMode); + } else if (currentContract?.packages?.length) { + const firstPackageId = currentContract.packages[0].itemId; + setSelectedPackageId(firstPackageId); + loadFormsList(firstPackageId, selectedMode); + } + }, [pathname, currentContract, selectedMode]) + + // 모드에 따른 폼 로드 함수 + const loadFormsList = async (packageId: number, mode: "IM" | "ENG") => { + if (!packageId) return; + + setIsLoadingForms(true); + try { + const result = await getFormsByContractItemId(packageId, mode); + setFormList(result.forms || []); + } catch (error) { + console.error(`폼 로딩 오류 (${mode} 모드):`, error); + setFormList([]); + } finally { + setIsLoadingForms(false); + } + }; + + // 핸들러들 +// 수정된 handleSelectContract 함수 +async function handleSelectContract(projId: number, cId: number) { + setSelectedProjectId(projId) + setSelectedContractId(cId) + + // 선택된 계약의 첫 번째 패키지 찾기 + const selectedProject = projects.find(p => p.projectId === projId) + const selectedContract = selectedProject?.contracts.find(c => c.contractId === cId) + + if (selectedContract?.packages?.length) { + const firstPackageId = selectedContract.packages[0].itemId + setSelectedPackageId(firstPackageId) + + // ENG 모드로 폼 목록 로드 + setIsLoadingForms(true) + try { + const result = await getFormsByContractItemId(firstPackageId, "ENG") + setFormList(result.forms || []) + + // 첫 번째 폼이 있으면 자동 선택 및 네비게이션 + if (result.forms && result.forms.length > 0) { + const firstForm = result.forms[0] + setSelectedFormCode(firstForm.formCode) + + // ENG 모드로 설정 + setSelectedMode("ENG") + + // 첫 번째 폼으로 네비게이션 + const baseSegments = pathname?.split("/").filter(Boolean).slice(0, pathname.split("/").filter(Boolean).indexOf("vendor-data-plant") + 1).join("/") + router.push(`/${baseSegments}/form/0/${firstForm.formCode}/${projId}/${cId}?mode=ENG`) + } else { + // 폼이 없는 경우에도 ENG 모드로 설정 + setSelectedMode("ENG") + setSelectedFormCode(null) + + const baseSegments = pathname?.split("/").filter(Boolean).slice(0, pathname.split("/").filter(Boolean).indexOf("vendor-data-plant") + 1).join("/") + router.push(`/${baseSegments}/form/0/0/${projId}/${cId}?mode=ENG`) + } + } catch (error) { + console.error("폼 로딩 오류:", error) + setFormList([]) + setSelectedFormCode(null) + + // 오류 발생 시에도 ENG 모드로 설정 + setSelectedMode("ENG") + } finally { + setIsLoadingForms(false) + } + } else { + // 패키지가 없는 경우 + setSelectedPackageId(null) + setFormList([]) + setSelectedFormCode(null) + setSelectedMode("ENG") + } +} + + function handleSelectPackage(itemId: number) { + setSelectedPackageId(itemId) + } + + function handleSelectForm(formName: string) { + const form = formList.find((f) => f.formName === formName) + if (form) { + setSelectedFormCode(form.formCode) + } + } + + // 모드 변경 핸들러 +// 모드 변경 핸들러 +const handleModeChange = async (mode: "IM" | "ENG") => { + // ship 프로젝트인 경우 모드 변경 금지 + if (isShipProject && mode !== "ENG") return; + + setSelectedMode(mode); + + // 모드가 변경될 때 자동 네비게이션 + if (currentContract?.packages?.length) { + const firstPackageId = currentContract.packages[0].itemId; + + if (mode === "IM") { + // IM 모드: 첫 번째 패키지로 이동 + const baseSegments = pathname?.split("/").filter(Boolean).slice(0, pathname.split("/").filter(Boolean).indexOf("vendor-data-plant") + 1).join("/"); + router.push(`/${baseSegments}/tag/${firstPackageId}?mode=${mode}`); + } else { + // ENG 모드: 폼 목록을 먼저 로드 + setIsLoadingForms(true); + try { + const result = await getFormsByContractItemId(firstPackageId, mode); + setFormList(result.forms || []); + + // 폼이 있으면 첫 번째 폼으로 이동 + if (result.forms && result.forms.length > 0) { + const firstForm = result.forms[0]; + setSelectedFormCode(firstForm.formCode); + + const baseSegments = pathname?.split("/").filter(Boolean).slice(0, pathname.split("/").filter(Boolean).indexOf("vendor-data-plant") + 1).join("/"); + router.push(`/${baseSegments}/form/0/${firstForm.formCode}/${selectedProjectId}/${selectedContractId}?mode=${mode}`); + } else { + // 폼이 없으면 모드만 변경 + const baseSegments = pathname?.split("/").filter(Boolean).slice(0, pathname.split("/").filter(Boolean).indexOf("vendor-data-plant") + 1).join("/"); + router.push(`/${baseSegments}/form/0/0/${selectedProjectId}/${selectedContractId}?mode=${mode}`); + } + } catch (error) { + console.error(`폼 로딩 오류 (${mode} 모드):`, error); + // 오류 발생 시 모드만 변경 + const url = new URL(window.location.href); + url.searchParams.set('mode', mode); + router.replace(url.pathname + url.search); + } finally { + setIsLoadingForms(false); + } + } + } else { + // 패키지가 없는 경우, 모드만 변경 + const url = new URL(window.location.href); + url.searchParams.set('mode', mode); + router.replace(url.pathname + url.search); + } +}; + + return ( + <TooltipProvider delayDuration={0}> + <ResizablePanelGroup direction="horizontal" className="h-full"> + <ResizablePanel + defaultSize={defaultLayout[0]} + collapsedSize={navCollapsedSize} + collapsible + minSize={15} + maxSize={25} + onCollapse={() => setIsCollapsed(true)} + onResize={() => setIsCollapsed(false)} + className={cn(isCollapsed && "min-w-[50px] transition-all duration-300 ease-in-out")} + > + <div + className={cn( + "flex h-[52px] items-center justify-center gap-2", + isCollapsed ? "h-[52px]" : "px-2" + )} + > + <ProjectSwitcher + isCollapsed={isCollapsed} + projects={projects} + selectedContractId={selectedContractId} + onSelectContract={handleSelectContract} + /> + </div> + <Separator /> + + {!isCollapsed ? ( + isShipProject ? ( + // 프로젝트 타입이 ship인 경우: 탭 없이 ENG 모드 사이드바만 바로 표시 + <div className="mt-0"> + <Sidebar + isCollapsed={isCollapsed} + packages={currentContract?.packages || []} + selectedPackageId={selectedPackageId} + selectedProjectId={selectedProjectId} + selectedContractId={selectedContractId} + onSelectPackage={handleSelectPackage} + forms={formList} + selectedForm={ + selectedFormCode + ? formList.find((f) => f.formCode === selectedFormCode)?.formName || null + : null + } + onSelectForm={handleSelectForm} + isLoadingForms={isLoadingForms} + mode="ENG" + className="hidden lg:block" + /> + </div> + ) : ( + // 프로젝트 타입이 ship이 아닌 경우: 기존 탭 UI 표시 + <Tabs + defaultValue={initialMode} + value={selectedMode} + onValueChange={(value) => handleModeChange(value as "IM" | "ENG")} + className="w-full" + > + <TabsList className="w-full"> + <TabsTrigger value="ENG" className="flex-1">Engineering</TabsTrigger> + <TabsTrigger value="IM" className="flex-1">Handover</TabsTrigger> + + </TabsList> + + <TabsContent value="IM" className="mt-0"> + <Sidebar + isCollapsed={isCollapsed} + packages={currentContract?.packages || []} + selectedPackageId={selectedPackageId} + selectedContractId={selectedContractId} + selectedProjectId={selectedProjectId} + onSelectPackage={handleSelectPackage} + forms={formList} + selectedForm={ + selectedFormCode + ? formList.find((f) => f.formCode === selectedFormCode)?.formName || null + : null + } + onSelectForm={handleSelectForm} + isLoadingForms={isLoadingForms} + mode="IM" + className="hidden lg:block" + /> + </TabsContent> + + <TabsContent value="ENG" className="mt-0"> + <Sidebar + isCollapsed={isCollapsed} + packages={currentContract?.packages || []} + selectedPackageId={selectedPackageId} + selectedContractId={selectedContractId} + selectedProjectId={selectedProjectId} + onSelectPackage={handleSelectPackage} + forms={formList} + selectedForm={ + selectedFormCode + ? formList.find((f) => f.formCode === selectedFormCode)?.formName || null + : null + } + onSelectForm={handleSelectForm} + isLoadingForms={isLoadingForms} + mode="ENG" + className="hidden lg:block" + /> + </TabsContent> + </Tabs> + ) + ) : ( + // 접혀있을 때 UI + <> + {!isShipProject && ( + // ship 프로젝트가 아닐 때만 모드 선택 버튼 표시 + <div className="flex justify-center space-x-1 my-2"> + + <Button + variant={selectedMode === "ENG" ? "default" : "ghost"} + size="sm" + className="h-8 px-2" + onClick={() => handleModeChange("ENG")} + > + Engineering + </Button> + <Button + variant={selectedMode === "IM" ? "default" : "ghost"} + size="sm" + className="h-8 px-2" + onClick={() => handleModeChange("IM")} + > + Handover + </Button> + </div> + )} + + <Sidebar + isCollapsed={isCollapsed} + packages={currentContract?.packages || []} + selectedPackageId={selectedPackageId} + selectedProjectId={selectedProjectId} + selectedContractId={selectedContractId} + onSelectPackage={handleSelectPackage} + forms={formList} + selectedForm={ + selectedFormCode + ? formList.find((f) => f.formCode === selectedFormCode)?.formName || null + : null + } + onSelectForm={handleSelectForm} + isLoadingForms={isLoadingForms} + mode={isShipProject ? "ENG" : selectedMode} + className="hidden lg:block" + /> + </> + )} + </ResizablePanel> + + <ResizableHandle withHandle /> + + <ResizablePanel defaultSize={defaultLayout[1]} minSize={40}> + <div className="p-4 h-full overflow-auto flex flex-col"> + <div className="flex items-center justify-between mb-4"> + <h2 className="text-lg font-bold"> + {isShipProject || selectedMode === "ENG" + ? "Engineering Mode" + : `Package: ${currentPackageName}`} + </h2> + </div> + {children} + </div> + </ResizablePanel> + </ResizablePanelGroup> + </TooltipProvider> + ) +}
\ No newline at end of file diff --git a/components/vendor-data/sidebar.tsx b/components/vendor-data/sidebar.tsx index 2e633442..edaf2e25 100644 --- a/components/vendor-data/sidebar.tsx +++ b/components/vendor-data/sidebar.tsx @@ -10,7 +10,7 @@ import { TooltipTrigger, TooltipContent, } from "@/components/ui/tooltip" -import { Package2, FormInput, ChevronRight, ChevronDown } from "lucide-react" +import { Package2, FormInput } from "lucide-react" import { useRouter, usePathname } from "next/navigation" import { Skeleton } from "@/components/ui/skeleton" import { type FormInfo } from "@/lib/forms/services" @@ -49,9 +49,6 @@ export function Sidebar({ const router = useRouter() const rawPathname = usePathname() const pathname = rawPathname ?? "" - - // ENG 모드에서 각 폼의 확장/축소 상태 관리 - const [expandedForms, setExpandedForms] = React.useState<Set<string>>(new Set()) /** * --------------------------- @@ -87,33 +84,28 @@ export function Sidebar({ * --------------------------- */ const handlePackageClick = (itemId: number) => { + // 상위 컴포넌트 상태 업데이트 onSelectPackage(itemId) + + // 해당 태그 페이지로 라우팅 + // 예: /vendor-data/tag/123 + const baseSegments = segments.slice(0, segments.indexOf("vendor-data") + 1).join("/") + router.push(`/${baseSegments}/tag/${itemId}`) } /** * --------------------------- - * 3) 폼 클릭 핸들러 (IM 모드) + * 3) 폼 클릭 핸들러 (IM 모드만 사용) * --------------------------- */ const handleFormClick = (form: FormInfo) => { - if (mode === "IM") { - // IM 모드에서는 반드시 선택된 패키지 ID 필요 - if (selectedPackageId === null) return; - - onSelectForm(form.formName) - - const baseSegments = segments.slice(0, segments.indexOf("vendor-data") + 1).join("/") - router.push(`/${baseSegments}/form/${selectedPackageId}/${form.formCode}/${selectedProjectId}/${selectedContractId}?mode=${mode}`) - } else { - // ENG 모드에서는 폼을 클릭하면 확장/축소만 토글 - const newExpanded = new Set(expandedForms) - if (newExpanded.has(form.formCode)) { - newExpanded.delete(form.formCode) - } else { - newExpanded.add(form.formCode) - } - setExpandedForms(newExpanded) - } + // IM 모드에서만 사용 + if (selectedPackageId === null) return; + + onSelectForm(form.formName) + + const baseSegments = segments.slice(0, segments.indexOf("vendor-data") + 1).join("/") + router.push(`/${baseSegments}/form/${selectedPackageId}/${form.formCode}/${selectedProjectId}/${selectedContractId}?mode=${mode}`) } /** @@ -187,10 +179,13 @@ export function Sidebar({ </> )} - {/* ---------- 폼 목록 (IM 모드) / 폼과 패키지 목록 (ENG 모드) ---------- */} + {/* ---------- 폼 목록 (IM 모드) / 패키지와 폼 목록 (ENG 모드) ---------- */} <div className="py-1"> <h2 className="relative px-7 text-lg font-semibold tracking-tight"> - {isCollapsed ? "F" : "Form Lists"} + {isCollapsed + ? (mode === "IM" ? "F" : "P") + : (mode === "IM" ? "Form Lists" : "Package Lists") + } </h2> <ScrollArea className={cn( "px-1", @@ -203,17 +198,15 @@ export function Sidebar({ <Skeleton className="h-8 w-full" /> </div> )) - ) : !forms || forms.length === 0 ? ( - <p className="text-sm text-muted-foreground px-2"> - (No forms loaded) - </p> - ) : ( - forms.map((form) => { - const isFormActive = form.formCode === currentFormCode - const isExpanded = expandedForms.has(form.formCode) - - // IM 모드 - if (mode === "IM") { + ) : mode === "IM" ? ( + // =========== IM 모드: 폼만 표시 =========== + !forms || forms.length === 0 ? ( + <p className="text-sm text-muted-foreground px-2"> + (No forms loaded) + </p> + ) : ( + forms.map((form) => { + const isFormActive = form.formCode === currentFormCode const isDisabled = currentItemId === null return isCollapsed ? ( @@ -250,79 +243,71 @@ export function Sidebar({ {form.formName} </Button> ) - } - - // ENG 모드 - 폼과 그 아래 패키지들 표시 - return ( - <div key={form.formCode}> + }) + ) + ) : ( + // =========== ENG 모드: 패키지 > 폼 계층 구조 =========== + packages.length === 0 ? ( + <p className="text-sm text-muted-foreground px-2"> + (No packages loaded) + </p> + ) : ( + packages.map((pkg) => ( + <div key={pkg.itemId} className="space-y-1"> {isCollapsed ? ( <Tooltip delayDuration={0}> <TooltipTrigger asChild> - <Button - variant="ghost" - className="w-full justify-start font-normal" - // onClick={() => handleFormClick(form)} - > - <FormInput className="mr-2 h-4 w-4" /> - </Button> + <div className="px-2 py-1"> + <Package2 className="h-4 w-4" /> + </div> </TooltipTrigger> <TooltipContent side="right"> - {form.formName} + {pkg.itemName} </TooltipContent> </Tooltip> ) : ( <> - <Button - variant="ghost" - className="w-full justify-start font-normal" - // onClick={() => handleFormClick(form)} - > - {isExpanded ? ( - <ChevronDown className="mr-2 h-4 w-4" /> - ) : ( - <ChevronRight className="mr-2 h-4 w-4" /> - )} - <FormInput className="mr-2 h-4 w-4" /> - {form.formName} - </Button> + {/* 패키지 이름 (클릭 불가능한 라벨) */} + <div className="flex items-center px-2 py-1 text-sm font-medium"> + <Package2 className="mr-2 h-4 w-4" /> + {pkg.itemName} + </div> - {/* 확장된 경우 패키지 목록 표시 */} - {isExpanded && ( - <div className="ml-4 space-y-1"> - {packages.length === 0 ? ( - <p className="text-xs text-muted-foreground px-4 py-1"> - No packages available - </p> - ) : ( - packages.map((pkg) => { - const isPackageActive = - pkg.itemId === currentItemId && - form.formCode === currentFormCode + {/* 폼 목록 바로 표시 */} + <div className="ml-6 space-y-1"> + {!forms || forms.length === 0 ? ( + <p className="text-xs text-muted-foreground px-2 py-1"> + No forms available + </p> + ) : ( + forms.map((form) => { + const isFormPackageActive = + pkg.itemId === currentItemId && + form.formCode === currentFormCode - return ( - <Button - key={`${form.formCode}-${pkg.itemId}`} - variant="ghost" - size="sm" - className={cn( - "w-full justify-start font-normal text-sm", - isPackageActive && "bg-accent text-accent-foreground" - )} - onClick={() => handlePackageUnderFormClick(form, pkg)} - > - <Package2 className="mr-2 h-3 w-3" /> - {pkg.itemName} - </Button> - ) - }) - )} - </div> - )} + return ( + <Button + key={`${pkg.itemId}-${form.formCode}`} + variant="ghost" + size="sm" + className={cn( + "w-full justify-start font-normal text-sm", + isFormPackageActive && "bg-accent text-accent-foreground" + )} + onClick={() => handlePackageUnderFormClick(form, pkg)} + > + <FormInput className="mr-2 h-3 w-3" /> + {form.formName} + </Button> + ) + }) + )} + </div> </> )} </div> - ) - }) + )) + ) )} </div> </ScrollArea> diff --git a/config/menuConfig.ts b/config/menuConfig.ts index 7175ed0d..30ada08b 100644 --- a/config/menuConfig.ts +++ b/config/menuConfig.ts @@ -31,12 +31,6 @@ export const mainNav: MenuSection[] = [ useGrouping: true, items: [ { - titleKey: 'menu.master_data.bid_projects', - href: '/evcp/bid-projects', - descriptionKey: 'menu.master_data.bid_projects_desc', - groupKey: 'groups.basic_info', - }, - { titleKey: 'menu.master_data.projects', href: '/evcp/projects', descriptionKey: 'menu.master_data.projects_desc', @@ -269,6 +263,12 @@ export const mainNav: MenuSection[] = [ useGrouping: true, items: [ { + titleKey: 'menu.master_data.bid_projects', + href: '/evcp/bid-projects', + descriptionKey: 'menu.master_data.bid_projects_desc', + groupKey: 'groups.common', + }, + { titleKey: 'menu.tech_sales.items', href: '/evcp/items-tech', descriptionKey: 'menu.tech_sales.items_desc', @@ -521,12 +521,6 @@ export const procurementNav: MenuSection[] = [ useGrouping: true, items: [ { - titleKey: "menu.master_data.bid_projects", - href: "/evcp/bid-projects", - descriptionKey: "menu.master_data.bid_projects_desc", - groupKey: "groups.basic_info" - }, - { titleKey: "menu.master_data.projects", href: "/evcp/projects", descriptionKey: "menu.master_data.projects_desc", @@ -820,12 +814,6 @@ export const engineeringNav: MenuSection[] = [ useGrouping: true, items: [ { - titleKey: "menu.master_data.bid_projects", - href: "/engineering/bid-projects", - descriptionKey: "menu.master_data.bid_projects_desc", - groupKey: "groups.basic_info" - }, - { titleKey: "menu.master_data.projects", href: "/engineering/projects", descriptionKey: "menu.master_data.projects_desc", @@ -989,7 +977,7 @@ export const mainNavVendor: MenuSection[] = [ }, { titleKey: "menu.vendor.engineering.data_input_offshore", - href: `/partners/vendor-data`, + href: `/partners/vendor-data-plant`, descriptionKey: "menu.vendor.engineering.data_input_offshore_desc", groupKey: "groups.offshore", }, diff --git a/lib/forms-plant/sedp-actions.ts b/lib/forms-plant/sedp-actions.ts new file mode 100644 index 00000000..4883a33f --- /dev/null +++ b/lib/forms-plant/sedp-actions.ts @@ -0,0 +1,222 @@ +"use server"; + +import { getSEDPToken } from "@/lib/sedp/sedp-token"; + +interface SEDPTagData { + [tableName: string]: Array<{ + TAG_NO: string; + TAG_DESC: string; + ATTRIBUTES: Array<{ + ATT_ID: string; + VALUE: string; + }>; + }>; +} + +interface SEDPTemplateData { + templateId: string; + content: string; + projectNo: string; + regTypeId: string; + [key: string]: any; +} + +// 🔍 실제 SEDP API 응답 구조 (대문자) +interface SEDPTemplateResponse { + TMPL_ID: string; + NAME: string; + TMPL_TYPE: string; + SPR_LST_SETUP?: { + ACT_SHEET: string; + HIDN_SHEETS: string[]; + CONTENT?: string; + DATA_SHEETS: Array<{ + SHEET_NAME: string; + REG_TYPE_ID: string; + MAP_CELL_ATT: Array<{ + ATT_ID: string; + IN: string; + }>; + }>; + }; + GRD_LST_SETUP?: { + REG_TYPE_ID: string; + SPR_ITM_IDS: string[]; + ATTS: any[]; + }; + SPR_ITM_LST_SETUP?: { + ACT_SHEET: string; + HIDN_SHEETS: string[]; + CONTENT?: string; + DATA_SHEETS: Array<{ + SHEET_NAME: string; + REG_TYPE_ID: string; + MAP_CELL_ATT: Array<{ + ATT_ID: string; + IN: string; + }>; + }>; + }; + [key: string]: any; +} + +/** + * SEDP에서 태그 데이터를 가져오는 서버 액션 + */ +export async function fetchTagDataFromSEDP( + projectCode: string, + formCode: string +): Promise<SEDPTagData> { + 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 as SEDPTagData; + } catch (error: unknown) { + console.error('Error calling SEDP API:', error); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + throw new Error(`Failed to fetch data from SEDP API: ${errorMessage}`); + } +} + +/** + * SEDP에서 템플릿 데이터를 가져오는 서버 액션 + */ +export async function fetchTemplateFromSEDP( + projectCode: string, + formCode: string +): Promise<SEDPTemplateResponse[]> { + 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'; + + const responseAdapter = await fetch( + `${SEDP_API_BASE_URL}/AdapterDataMapping/GetByToolID`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'accept': '*/*', + 'ApiKey': apiKey, + 'ProjectNo': projectCode + }, + body: JSON.stringify({ + ProjectNo: projectCode, + "TOOL_ID": "eVCP" + }) + } + ); + + if (!responseAdapter.ok) { + throw new Error(`새 레지스터 요청 실패: ${responseAdapter.status} ${responseAdapter.statusText}`); + } + + const dataAdapter = await responseAdapter.json(); + const templateList = dataAdapter.find(v => v.REG_TYPE_ID === formCode)?.MAP_TMPLS || []; + + // 각 TMPL_ID에 대해 API 호출 + const templatePromises = templateList.map(async (tmplId: string) => { + const response = await fetch( + `${SEDP_API_BASE_URL}/Template/GetByID`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'accept': '*/*', + 'ApiKey': apiKey, + 'ProjectNo': projectCode + }, + body: JSON.stringify({ + WithContent: true, + ProjectNo: projectCode, + TMPL_ID: tmplId + }) + } + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`SEDP Template API request failed for TMPL_ID ${tmplId}: ${response.status} ${response.statusText} - ${errorText}`); + } + + const data = await response.json(); + + // 🔍 API 응답 데이터 구조 확인 및 로깅 + console.log('🔍 SEDP Template API Response for', tmplId, ':', { + hasTMPL_ID: !!data.TMPL_ID, + hasTemplateId: !!(data as any).templateId, + keys: Object.keys(data), + sample: data + }); + + // 🔍 TMPL_ID 필드 검증 + if (!data.TMPL_ID) { + console.error('❌ Missing TMPL_ID in API response:', data); + // templateId가 있다면 변환 시도 + if ((data as any).templateId) { + console.warn('⚠️ Found templateId instead of TMPL_ID, converting...'); + data.TMPL_ID = (data as any).templateId; + } + } + + return data as SEDPTemplateResponse; + }); + + // 모든 API 호출을 병렬로 실행하고 결과를 수집 + const templates = await Promise.all(templatePromises); + + // 🔍 null이나 undefined가 아닌 값들만 필터링하고 TMPL_ID 검증 + const validTemplates = templates.filter(template => { + if (!template) { + console.warn('⚠️ Null or undefined template received'); + return false; + } + if (!template.TMPL_ID) { + console.error('❌ Template missing TMPL_ID:', template); + return false; + } + return true; + }); + + console.log(`✅ fetchTemplateFromSEDP completed: ${validTemplates.length} valid templates`); + validTemplates.forEach(t => console.log(` - ${t.TMPL_ID}: ${t.NAME} (${t.TMPL_TYPE})`)); + + return validTemplates; + + } catch (error: unknown) { + console.error('Error calling SEDP Template API:', error); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + throw new Error(`Failed to fetch template from SEDP API: ${errorMessage}`); + } +}
\ No newline at end of file diff --git a/lib/forms-plant/services.ts b/lib/forms-plant/services.ts new file mode 100644 index 00000000..99e7c35b --- /dev/null +++ b/lib/forms-plant/services.ts @@ -0,0 +1,2076 @@ +// lib/forms/services.ts +"use server"; + +import { headers } from "next/headers"; +import path from "path"; +import fs from "fs/promises"; +import { v4 as uuidv4 } from "uuid"; +import db from "@/db/db"; +import { + formEntries, + formMetas, + forms, + tagClassAttributes, + tagClasses, + tags, + tagSubfieldOptions, + tagSubfields, + tagTypeClassFormMappings, + tagTypes, + vendorDataReportTemps, + VendorDataReportTemps, +} from "@/db/schema/vendorData"; +import { eq, and, desc, sql, DrizzleError, inArray, or, type SQL, type InferSelectModel } from "drizzle-orm"; +import { unstable_cache } from "next/cache"; +import { revalidateTag } from "next/cache"; +import { getErrorMessage } from "../handle-error"; +import { DataTableColumnJSON } from "@/components/form-data/form-data-table-columns"; +import { contractItems, contracts, items, projects } from "@/db/schema"; +import { getSEDPToken } from "../sedp/sedp-token"; +import { decryptWithServerAction } from "@/components/drm/drmUtils"; +import { deleteFile, saveFile } from "@/lib/file-stroage"; + + +export type FormInfo = InferSelectModel<typeof forms>; + +export async function getFormsByContractItemId( + contractItemId: number | null, + mode: "ENG" | "IM" | "ALL" = "ALL" +): Promise<{ forms: FormInfo[] }> { + // 유효성 검사 + if (!contractItemId || contractItemId <= 0) { + console.warn(`Invalid contractItemId: ${contractItemId}`); + return { forms: [] }; + } + + // 고유 캐시 키 (모드 포함) + const cacheKey = `forms-${contractItemId}-${mode}`; + + try { + // return unstable_cache( + // async () => { + // console.log( + // `[Forms Service] Fetching forms for contractItemId: ${contractItemId}, mode: ${mode}` + // ); + + try { + // 쿼리 생성 + let query = db.select().from(forms).where(eq(forms.contractItemId, contractItemId)); + + // 모드에 따른 추가 필터 + if (mode === "ENG") { + query = db.select().from(forms).where( + and( + eq(forms.contractItemId, contractItemId), + eq(forms.eng, true) + ) + ); + } else if (mode === "IM") { + query = db.select().from(forms).where( + and( + eq(forms.contractItemId, contractItemId), + eq(forms.im, true) + ) + ); + } + + // 쿼리 실행 + const formRecords = await query; + + console.log( + `[Forms Service] Found ${formRecords.length} forms for contractItemId: ${contractItemId}, mode: ${mode}` + ); + + return { forms: formRecords }; + } catch (error) { + getErrorMessage( + `Database error for contractItemId ${contractItemId}, mode: ${mode}: ${error}` + ); + throw error; // 캐시 함수에서 에러를 던져 캐싱이 발생하지 않도록 함 + } + // }, + // [cacheKey], + // { + // // 캐시 시간 단축 + // revalidate: 60, // 1분으로 줄임 + // tags: [cacheKey], + // } + // )(); + } catch (error) { + getErrorMessage( + `Cache operation failed for contractItemId ${contractItemId}, mode: ${mode}: ${error}` + ); + + // 캐시 문제 시 직접 쿼리 시도 + try { + console.log( + `[Forms Service] Fallback: Direct query for contractItemId: ${contractItemId}, mode: ${mode}` + ); + + // 쿼리 생성 + let query = db.select().from(forms).where(eq(forms.contractItemId, contractItemId)); + + // 모드에 따른 추가 필터 + if (mode === "ENG") { + query = db.select().from(forms).where( + and( + eq(forms.contractItemId, contractItemId), + eq(forms.eng, true) + ) + ); + } else if (mode === "IM") { + query = db.select().from(forms).where( + and( + eq(forms.contractItemId, contractItemId), + eq(forms.im, true) + ) + ); + } + + // 쿼리 실행 + const formRecords = await query; + + return { forms: formRecords }; + } catch (dbError) { + getErrorMessage( + `Fallback query failed for contractItemId ${contractItemId}, mode: ${mode}: ${dbError}` + ); + return { forms: [] }; + } + } +} + +/** + * 폼 캐시를 갱신하는 서버 액션 + */ +export async function revalidateForms(contractItemId: number) { + if (!contractItemId) return; + + const cacheKey = `forms-${contractItemId}`; + console.log(`[Forms Service] Invalidating cache for ${cacheKey}`); + + try { + revalidateTag(cacheKey); + console.log(`[Forms Service] Cache invalidated for ${cacheKey}`); + } catch (error) { + getErrorMessage(`Failed to invalidate cache for ${cacheKey}: ${error}`); + } +} + +export interface EditableFieldsInfo { + tagNo: string; + editableFields: string[]; // 편집 가능한 필드 키 목록 +} + +// TAG별 편집 가능 필드 조회 함수 +export async function getEditableFieldsByTag( + contractItemId: number, + projectId: number +): Promise<Map<string, string[]>> { + try { + // 1. 해당 contractItemId의 모든 태그 조회 + const tagList = await db + .select({ + tagNo: tags.tagNo, + tagClass: tags.class + }) + .from(tags) + .where(eq(tags.contractItemId, contractItemId)); + + const editableFieldsMap = new Map<string, string[]>(); + + // 2. 각 태그별로 편집 가능 필드 계산 + for (const tag of tagList) { + try { + // 2-1. tagClasses에서 해당 class(label)와 projectId로 tagClass 찾기 + const tagClassResult = await db + .select({ id: tagClasses.id }) + .from(tagClasses) + .where( + and( + eq(tagClasses.label, tag.tagClass), + eq(tagClasses.projectId, projectId) + ) + ) + .limit(1); + + if (tagClassResult.length === 0) { + console.warn(`No tagClass found for class: ${tag.tagClass}, projectId: ${projectId}`); + editableFieldsMap.set(tag.tagNo, []); // 편집 불가능 + continue; + } + + // 2-2. tagClassAttributes에서 편집 가능한 필드 목록 조회 + const editableAttributes = await db + .select({ attId: tagClassAttributes.attId }) + .from(tagClassAttributes) + .where(eq(tagClassAttributes.tagClassId, tagClassResult[0].id)) + .orderBy(tagClassAttributes.seq); + + // 2-3. attId 목록 저장 + const editableFields = editableAttributes.map(attr => attr.attId); + editableFieldsMap.set(tag.tagNo, editableFields); + + } catch (error) { + console.error(`Error processing tag ${tag.tagNo}:`, error); + editableFieldsMap.set(tag.tagNo, []); // 에러 시 편집 불가능 + } + } + + return editableFieldsMap; + } catch (error) { + console.error('Error getting editable fields by tag:', error); + return new Map(); + } +} +/** + * "가장 최신 1개 row"를 가져오고, + * data가 배열이면 그 배열을 반환, + * 그리고 이 로직 전체를 unstable_cache로 감싸 캐싱. + */ +export async function getFormData(formCode: string, contractItemId: number) { + try { + + // 기존 로직으로 projectId, columns, data 가져오기 + const contractItemResult = await db + .select({ + projectId: projects.id + }) + .from(contractItems) + .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) + .innerJoin(projects, eq(contracts.projectId, projects.id)) + .where(eq(contractItems.id, contractItemId)) + .limit(1); + + if (contractItemResult.length === 0) { + console.warn(`[getFormData] No contract item found with ID: ${contractItemId}`); + return { columns: null, data: [], editableFieldsMap: new Map() }; + } + + const projectId = contractItemResult[0].projectId; + + const metaRows = await db + .select() + .from(formMetas) + .where( + and( + eq(formMetas.formCode, formCode), + eq(formMetas.projectId, projectId) + ) + ) + .orderBy(desc(formMetas.updatedAt)) + .limit(1); + + const meta = metaRows[0] ?? null; + if (!meta) { + console.warn(`[getFormData] No form meta found for formCode: ${formCode} and projectId: ${projectId}`); + return { columns: null, data: [], editableFieldsMap: new Map() }; + } + + const entryRows = await db + .select() + .from(formEntries) + .where( + and( + eq(formEntries.formCode, formCode), + eq(formEntries.contractItemId, contractItemId) + ) + ) + .orderBy(desc(formEntries.updatedAt)) + .limit(1); + + const entry = entryRows[0] ?? null; + + let columns = meta.columns as DataTableColumnJSON[]; + const excludeKeys = ['BF_TAG_NO', 'TAG_TYPE_ID', 'PIC_NO']; + columns = columns.filter(col => !excludeKeys.includes(col.key)); + + + + columns.forEach((col) => { + if (!col.displayLabel) { + if (col.uom) { + col.displayLabel = `${col.label} (${col.uom})`; + } else { + col.displayLabel = col.label; + } + } + }); + + columns.push({ + key: "status", + label: "status", + displayLabel: "Status", + type: "STRING" + }) + + let data: Array<Record<string, any>> = []; + if (entry) { + if (Array.isArray(entry.data)) { + data = entry.data; + + data.sort((a, b) => { + const statusA = a.status || ''; + const statusB = b.status || ''; + return statusB.localeCompare(statusA) + }) + + } else { + console.warn("formEntries data was not an array. Using empty array."); + } + } + + // *** 새로 추가: 편집 가능 필드 정보 계산 *** + const editableFieldsMap = await getEditableFieldsByTag(contractItemId, projectId); + + return { columns, data, editableFieldsMap }; + + + } catch (cacheError) { + console.error(`[getFormData] Cache operation failed:`, cacheError); + + // Fallback logic (기존과 동일하게 editableFieldsMap 추가) + try { + console.log(`[getFormData] Fallback DB query for (${formCode}, ${contractItemId})`); + + const contractItemResult = await db + .select({ + projectId: projects.id + }) + .from(contractItems) + .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) + .innerJoin(projects, eq(contracts.projectId, projects.id)) + .where(eq(contractItems.id, contractItemId)) + .limit(1); + + if (contractItemResult.length === 0) { + console.warn(`[getFormData] Fallback: No contract item found with ID: ${contractItemId}`); + return { columns: null, data: [], editableFieldsMap: new Map() }; + } + + const projectId = contractItemResult[0].projectId; + + const metaRows = await db + .select() + .from(formMetas) + .where( + and( + eq(formMetas.formCode, formCode), + eq(formMetas.projectId, projectId) + ) + ) + .orderBy(desc(formMetas.updatedAt)) + .limit(1); + + const meta = metaRows[0] ?? null; + if (!meta) { + console.warn(`[getFormData] Fallback: No form meta found for formCode: ${formCode} and projectId: ${projectId}`); + return { columns: null, data: [], editableFieldsMap: new Map() }; + } + + const entryRows = await db + .select() + .from(formEntries) + .where( + and( + eq(formEntries.formCode, formCode), + eq(formEntries.contractItemId, contractItemId) + ) + ) + .orderBy(desc(formEntries.updatedAt)) + .limit(1); + + const entry = entryRows[0] ?? null; + + let columns = meta.columns as DataTableColumnJSON[]; + const excludeKeys = ['BF_TAG_NO', 'TAG_TYPE_ID', 'PIC_NO']; + columns = columns.filter(col => !excludeKeys.includes(col.key)); + + columns.forEach((col) => { + if (!col.displayLabel) { + if (col.uom) { + col.displayLabel = `${col.label} (${col.uom})`; + } else { + col.displayLabel = col.label; + } + } + }); + + let data: Array<Record<string, any>> = []; + if (entry) { + if (Array.isArray(entry.data)) { + data = entry.data; + } else { + console.warn("formEntries data was not an array. Using empty array (fallback)."); + } + } + + // Fallback에서도 편집 가능 필드 정보 계산 + const editableFieldsMap = await getEditableFieldsByTag(contractItemId, projectId); + + return { columns, data, projectId, editableFieldsMap }; + } catch (dbError) { + console.error(`[getFormData] Fallback DB query failed:`, dbError); + return { columns: null, data: [], editableFieldsMap: new Map() }; + } + } +} +/**1 + * contractId와 formCode(itemCode)를 사용하여 contractItemId를 찾는 서버 액션 + * + * @param contractId - 계약 ID + * @param formCode - 폼 코드 (itemCode와 동일) + * @returns 찾은 contractItemId 또는 null + */ +export async function findContractItemId(contractId: number, formCode: string): Promise<number | null> { + try { + console.log(`[findContractItemId] 계약 ID ${contractId}와 formCode ${formCode}에 대한 contractItem 조회 시작`); + + // 1. forms 테이블에서 formCode에 해당하는 모든 레코드 조회 + const formsResult = await db + .select({ + contractItemId: forms.contractItemId + }) + .from(forms) + .where(eq(forms.formCode, formCode)); + + if (formsResult.length === 0) { + console.warn(`[findContractItemId] formCode ${formCode}에 해당하는 form을 찾을 수 없습니다.`); + return null; + } + + // 모든 contractItemId 추출 + const contractItemIds = formsResult.map(form => form.contractItemId); + console.log(`[findContractItemId] formCode ${formCode}에 해당하는 ${contractItemIds.length}개의 contractItemId 발견`); + + // 2. contractItems 테이블에서 추출한 contractItemId 중에서 + // contractId가 일치하는 항목 찾기 + const contractItemResult = await db + .select({ + id: contractItems.id + }) + .from(contractItems) + .where( + and( + inArray(contractItems.id, contractItemIds), + eq(contractItems.contractId, contractId) + ) + ) + .limit(1); + + if (contractItemResult.length === 0) { + console.warn(`[findContractItemId] 계약 ID ${contractId}와 일치하는 contractItemId를 찾을 수 없습니다.`); + return null; + } + + const contractItemId = contractItemResult[0].id; + console.log(`[findContractItemId] 계약 아이템 ID ${contractItemId} 발견`); + + return contractItemId; + } catch (error) { + console.error(`[findContractItemId] contractItem 조회 중 오류 발생:`, error); + return null; + } +} + +export async function getPackageCodeById(contractItemId: number): Promise<string | null> { + try { + + // 1. forms 테이블에서 formCode에 해당하는 모든 레코드 조회 + const contractItemsResult = await db + .select({ + itemId: contractItems.itemId + }) + .from(contractItems) + .where(eq(contractItems.id, contractItemId)) + .limit(1) + ; + + if (contractItemsResult.length === 0) { + console.warn(`[contractItemId]에 해당하는 item을 찾을 수 없습니다.`); + return null; + } + + const itemId = contractItemsResult[0].itemId + + const packageCodeResult = await db + .select({ + packageCode: items.packageCode + }) + .from(items) + .where(eq(items.id, itemId)) + .limit(1); + + if (packageCodeResult.length === 0) { + console.warn(`${itemId}와 일치하는 패키지 코드를 찾을 수 없습니다.`); + return null; + } + + const packageCode = packageCodeResult[0].packageCode; + + return packageCode; + } catch (error) { + console.error(`패키지 코드 조회 중 오류 발생:`, error); + return null; + } +} + + +export async function syncMissingTags( + contractItemId: number, + formCode: string +) { + // (1) Ensure there's a row in `forms` matching (contractItemId, formCode). + const [formRow] = await db + .select() + .from(forms) + .where( + and( + eq(forms.contractItemId, contractItemId), + eq(forms.formCode, formCode) + ) + ) + .limit(1); + + if (!formRow) { + throw new Error( + `Form not found for contractItemId=${contractItemId}, formCode=${formCode}` + ); + } + + // (2) Get all mappings from `tagTypeClassFormMappings` for this formCode. + const formMappings = await db + .select() + .from(tagTypeClassFormMappings) + .where(eq(tagTypeClassFormMappings.formCode, formCode)); + + // If no mappings are found, there's nothing to sync. + if (formMappings.length === 0) { + console.log(`No mappings found for formCode=${formCode}`); + return { createdCount: 0, updatedCount: 0, deletedCount: 0 }; + } + + // Build a dynamic OR clause to match (tagType, class) pairs from the mappings. + const orConditions = formMappings.map((m) => + and(eq(tags.tagType, m.tagTypeLabel), eq(tags.class, m.classLabel)) + ); + + // (3) Fetch all matching `tags` for the contractItemId + any of the (tagType, class) pairs. + const tagRows = await db + .select() + .from(tags) + .where(and(eq(tags.contractItemId, contractItemId), or(...orConditions))); + + // (4) Fetch (or create) a single `formEntries` row for (contractItemId, formCode). + let [entry] = await db + .select() + .from(formEntries) + .where( + and( + eq(formEntries.contractItemId, contractItemId), + eq(formEntries.formCode, formCode) + ) + ) + .limit(1); + + if (!entry) { + const [inserted] = await db + .insert(formEntries) + .values({ + contractItemId, + formCode, + data: [], // Initialize with empty array + }) + .returning(); + entry = inserted; + } + + // entry.data는 [{ TAG_NO: string, TAG_DESC?: string }, ...] 형태라고 가정 + const existingData = entry.data as Array<{ + TAG_NO: string; + TAG_DESC?: string; + }>; + + // Create a Set of valid tagNumbers from tagRows for efficient lookup + const validTagNumbers = new Set(tagRows.map((tag) => tag.tagNo)); + + // Copy existing data to work with + let updatedData: Array<{ + TAG_NO: string; + TAG_DESC?: string; + }> = []; + + let createdCount = 0; + let updatedCount = 0; + let deletedCount = 0; + + // First, filter out items that should be deleted (not in validTagNumbers) + for (const item of existingData) { + if (validTagNumbers.has(item.TAG_NO)) { + updatedData.push(item); + } else { + deletedCount++; + } + } + + // (5) For each tagRow, if it's missing in updatedData, push it in. + // 이미 있는 경우에도 description이 달라지면 업데이트할 수 있음. + for (const tagRow of tagRows) { + const { tagNo, description } = tagRow; + + // 5-1. 기존 데이터에서 TAG_NO 매칭 + const existingIndex = updatedData.findIndex( + (item) => item.TAG_NO === tagNo + ); + + // 5-2. 없다면 새로 추가 + if (existingIndex === -1) { + updatedData.push({ + TAG_NO: tagNo, + TAG_DESC: description ?? "", + }); + createdCount++; + } else { + // 5-3. 이미 있으면, description이 다를 때만 업데이트(선택 사항) + const existingItem = updatedData[existingIndex]; + if (existingItem.TAG_DESC !== description) { + updatedData[existingIndex] = { + ...existingItem, + TAG_DESC: description ?? "", + }; + updatedCount++; + } + } + } + + // (6) 실제로 추가되거나 수정되거나 삭제된 게 있다면 DB에 반영 + if (createdCount > 0 || updatedCount > 0 || deletedCount > 0) { + await db + .update(formEntries) + .set({ data: updatedData }) + .where(eq(formEntries.id, entry.id)); + } + + // 캐시 무효화 등 후처리 + revalidateTag(`form-data-${formCode}-${contractItemId}`); + + return { createdCount, updatedCount, deletedCount }; +} + +/** + * updateFormDataInDB: + * (formCode, contractItemId)에 해당하는 "단 하나의" formEntries row를 가져와, + * data: [{ TAG_NO, ...}, ...] 배열에서 TAG_NO 매칭되는 항목을 업데이트 + * 업데이트 후, revalidateTag()로 캐시 무효화. + */ +export interface UpdateResponse { + success: boolean; + message: string; + data?: { + updatedCount?: number; + failedCount?: number; + updatedTags?: string[]; + notFoundTags?: string[]; + updateTimestamp?: string; + error?: any; + invalidRows?: any[]; + TAG_NO?: string; + updatedFields?: string[]; + }; +} + +export async function updateFormDataInDB( + formCode: string, + contractItemId: number, + newData: Record<string, any> +): Promise<UpdateResponse> { + try { + // 1) tagNumber로 식별 + const TAG_NO = newData.TAG_NO; + if (!TAG_NO) { + return { + success: false, + message: "tagNumber는 필수 항목입니다.", + }; + } + + // 2) row 찾기 (단 하나) + const entries = await db + .select() + .from(formEntries) + .where( + and( + eq(formEntries.formCode, formCode), + eq(formEntries.contractItemId, contractItemId) + ) + ) + .limit(1); + + if (!entries || entries.length === 0) { + return { + success: false, + message: `폼 데이터를 찾을 수 없습니다. (formCode=${formCode}, contractItemId=${contractItemId})`, + }; + } + + const entry = entries[0]; + + // 3) data가 배열인지 확인 + if (!entry.data) { + return { + success: false, + message: "폼 데이터가 없습니다.", + }; + } + + const dataArray = entry.data as Array<Record<string, any>>; + if (!Array.isArray(dataArray)) { + return { + success: false, + message: "폼 데이터가 올바른 형식이 아닙니다. 배열 형식이어야 합니다.", + }; + } + + // 4) TAG_NO = newData.TAG_NO 항목 찾기 + const idx = dataArray.findIndex((item) => item.TAG_NO === TAG_NO); + if (idx < 0) { + return { + success: false, + message: `태그 번호 "${TAG_NO}"를 가진 항목을 찾을 수 없습니다.`, + }; + } + + // 5) 병합 (status 필드 추가) + const oldItem = dataArray[idx]; + const updatedItem = { + ...oldItem, + ...newData, + TAG_NO: oldItem.TAG_NO, // TAG_NO 변경 불가 시 유지 + status: "Updated" // Excel에서 가져온 데이터임을 표시 + }; + + const updatedArray = [...dataArray]; + updatedArray[idx] = updatedItem; + + // 6) DB UPDATE + try { + await db + .update(formEntries) + .set({ + data: updatedArray, + updatedAt: new Date(), // 업데이트 시간도 갱신 + }) + .where(eq(formEntries.id, entry.id)); + } catch (dbError) { + console.error("Database update error:", dbError); + + if (dbError instanceof DrizzleError) { + return { + success: false, + message: `데이터베이스 업데이트 오류: ${dbError.message}`, + }; + } + + return { + success: false, + message: "데이터베이스 업데이트 중 오류가 발생했습니다.", + }; + } + + // 7) Cache 무효화 + try { + // 캐시 태그를 form-data-${formCode}-${contractItemId} 형태로 가정 + const cacheTag = `form-data-${formCode}-${contractItemId}`; + console.log(cacheTag, "update") + revalidateTag(cacheTag); + } catch (cacheError) { + console.warn("Cache revalidation warning:", cacheError); + // 캐시 무효화는 실패해도 업데이트 자체는 성공했으므로 경고만 로그로 남김 + } + + return { + success: true, + message: "데이터가 성공적으로 업데이트되었습니다.", + data: { + TAG_NO, + updatedFields: Object.keys(newData).filter( + (key) => key !== "TAG_NO" + ), + }, + }; + } catch (error) { + // 예상치 못한 오류 처리 + console.error("Unexpected error in updateFormDataInDB:", error); + return { + success: false, + message: + error instanceof Error + ? `예상치 못한 오류가 발생했습니다: ${error.message}` + : "알 수 없는 오류가 발생했습니다.", + }; + } +} + +export async function updateFormDataBatchInDB( + formCode: string, + contractItemId: number, + newDataArray: Record<string, any>[] +): Promise<UpdateResponse> { + try { + // 입력 유효성 검사 + if (!newDataArray || newDataArray.length === 0) { + return { + success: false, + message: "업데이트할 데이터가 없습니다.", + }; + } + + // TAG_NO 유효성 검사 + const invalidRows = newDataArray.filter(row => !row.TAG_NO); + if (invalidRows.length > 0) { + return { + success: false, + message: `${invalidRows.length}개 행에 TAG_NO가 없습니다.`, + data: { invalidRows } + }; + } + + // 1) DB에서 현재 데이터 가져오기 + const entries = await db + .select() + .from(formEntries) + .where( + and( + eq(formEntries.formCode, formCode), + eq(formEntries.contractItemId, contractItemId) + ) + ) + .limit(1); + + if (!entries || entries.length === 0) { + return { + success: false, + message: `폼 데이터를 찾을 수 없습니다. (formCode=${formCode}, contractItemId=${contractItemId})`, + }; + } + + const entry = entries[0]; + + // 데이터 형식 검증 + if (!entry.data) { + return { + success: false, + message: "폼 데이터가 없습니다.", + }; + } + + const dataArray = entry.data as Array<Record<string, any>>; + if (!Array.isArray(dataArray)) { + return { + success: false, + message: "폼 데이터가 올바른 형식이 아닙니다. 배열 형식이어야 합니다.", + }; + } + + // 2) 모든 변경사항을 한번에 적용 + const updatedArray = [...dataArray]; + const updatedTags: string[] = []; + const notFoundTags: string[] = []; + const updateTimestamp = new Date().toISOString(); + + // 각 import row에 대해 업데이트 수행 + for (const newData of newDataArray) { + const TAG_NO = newData.TAG_NO; + const idx = updatedArray.findIndex(item => item.TAG_NO === TAG_NO); + + if (idx >= 0) { + // 기존 데이터와 병합 + const oldItem = updatedArray[idx]; + updatedArray[idx] = { + ...oldItem, + ...newData, + TAG_NO: oldItem.TAG_NO, // TAG_NO는 변경 불가 + TAG_DESC: oldItem.TAG_DESC, // TAG_DESC도 보존 + status: "Updated", // Excel import 표시 + lastUpdated: updateTimestamp // 업데이트 시각 추가 + }; + updatedTags.push(TAG_NO); + } else { + // TAG를 찾을 수 없는 경우 + notFoundTags.push(TAG_NO); + } + } + + // 하나도 업데이트할 항목이 없는 경우 + if (updatedTags.length === 0) { + return { + success: false, + message: `업데이트할 수 있는 TAG를 찾을 수 없습니다. 모든 ${notFoundTags.length}개 TAG가 데이터베이스에 없습니다.`, + data: { + updatedCount: 0, + failedCount: notFoundTags.length, + notFoundTags + } + }; + } + + // 3) DB에 한 번만 저장 + try { + await db + .update(formEntries) + .set({ + data: updatedArray, + updatedAt: new Date(), + }) + .where(eq(formEntries.id, entry.id)); + + } catch (dbError) { + console.error("Database update error:", dbError); + + if (dbError instanceof DrizzleError) { + return { + success: false, + message: `데이터베이스 업데이트 오류: ${dbError.message}`, + data: { + updatedCount: 0, + failedCount: newDataArray.length, + error: dbError + } + }; + } + + return { + success: false, + message: "데이터베이스 업데이트 중 오류가 발생했습니다.", + data: { + updatedCount: 0, + failedCount: newDataArray.length + } + }; + } + + // 4) 캐시 무효화 + try { + const cacheTag = `form-data-${formCode}-${contractItemId}`; + console.log(`Cache invalidated: ${cacheTag}`); + revalidateTag(cacheTag); + } catch (cacheError) { + // 캐시 무효화 실패는 경고만 + console.warn("Cache revalidation warning:", cacheError); + } + + // 5) 성공 응답 + const message = notFoundTags.length > 0 + ? `${updatedTags.length}개 항목이 업데이트되었습니다. (${notFoundTags.length}개 TAG는 찾을 수 없음)` + : `${updatedTags.length}개 항목이 성공적으로 업데이트되었습니다.`; + + return { + success: true, + message: message, + data: { + updatedCount: updatedTags.length, + updatedTags, + notFoundTags: notFoundTags.length > 0 ? notFoundTags : undefined, + failedCount: notFoundTags.length, + updateTimestamp + }, + }; + + } catch (error) { + // 예상치 못한 오류 처리 + console.error("Unexpected error in updateFormDataBatchInDB:", error); + + return { + success: false, + message: error instanceof Error + ? `예상치 못한 오류가 발생했습니다: ${error.message}` + : "알 수 없는 오류가 발생했습니다.", + data: { + updatedCount: 0, + failedCount: newDataArray.length, + error: error + } + }; + } +} + +// FormColumn Type (동일) +export interface FormColumn { + key: string; + type: string; + label: string; + options?: string[]; +} + +interface MetadataResult { + formName: string; + formCode: string; + columns: FormColumn[]; +} + +/** + * 서버 액션: + * 주어진 formCode에 해당하는 form_metas 레코드 1개를 찾아서 + * { formName, formCode, columns } 형태로 반환. + * 없으면 null. + */ +export async function fetchFormMetadata( + formCode: string, + projectId: number +): Promise<MetadataResult | null> { + try { + // 기존 방식: select().from().where() + const rows = await db + .select() + .from(formMetas) + .where(and(eq(formMetas.formCode, formCode), eq(formMetas.projectId, projectId))) + .limit(1); + + // rows는 배열 + const metaData = rows[0]; + if (!metaData) return null; + + return { + formCode: metaData.formCode, + formName: metaData.formName, + columns: metaData.columns as FormColumn[], + }; + } catch (err) { + console.error("Error in fetchFormMetadata:", err); + return null; + } +} + +type GetReportFileList = ( + packageId: string, + formCode: string +) => Promise<{ + formId: number; +}>; + +export const getFormId: GetReportFileList = async (packageId, formCode) => { + const result: { formId: number } = { + formId: 0, + }; + try { + const [targetForm] = await db + .select() + .from(forms) + .where( + and( + eq(forms.formCode, formCode), + eq(forms.contractItemId, Number(packageId)) + ) + ); + + if (!targetForm) { + throw new Error("Not Found Target Form"); + } + + const { id: formId } = targetForm; + + result.formId = formId; + } catch (err) { + } finally { + return result; + } +}; + +type getReportTempList = ( + packageId: number, + formId: number +) => Promise<VendorDataReportTemps[]>; + +export const getReportTempList: getReportTempList = async ( + packageId, + formId +) => { + let result: VendorDataReportTemps[] = []; + + try { + result = await db + .select() + .from(vendorDataReportTemps) + .where( + and( + eq(vendorDataReportTemps.contractItemId, packageId), + eq(vendorDataReportTemps.formId, formId) + ) + ); + } catch (err) { + } finally { + return result; + } +}; + +export async function uploadReportTemp( + packageId: number, + formId: number, + formData: FormData +) { + const file = formData.get("file") as File | null; + const customFileName = formData.get("customFileName") as string; + const uploaderType = (formData.get("uploaderType") as string) || "vendor"; + + if (!["vendor", "client", "shi"].includes(uploaderType)) { + throw new Error( + `Invalid uploaderType: ${uploaderType}. Must be one of: vendor, client, shi` + ); + } + if (file && file.size > 0) { + + const saveResult = await saveFile({ file, directory: "vendorFormData", originalName: customFileName }); + if (!saveResult.success) { + return { success: false, error: saveResult.error }; + } + + return db.transaction(async (tx) => { + // 파일 정보를 테이블에 저장 + await tx + .insert(vendorDataReportTemps) + .values({ + contractItemId: packageId, + formId: formId, + fileName: customFileName, + filePath: saveResult.publicPath!, + }) + .returning(); + }); + } +} + +export const getOrigin = async (): Promise<string> => { + const headersList = await headers(); + const host = headersList.get("host"); + const proto = headersList.get("x-forwarded-proto") || "http"; // 기본값은 http + const origin = `${proto}://${host}`; + + return origin; +}; + + +type deleteReportTempFile = (id: number) => Promise<{ + result: boolean; + error?: any; +}>; + +export const deleteReportTempFile: deleteReportTempFile = async (id) => { + try { + return db.transaction(async (tx) => { + const [targetTempFile] = await tx + .select() + .from(vendorDataReportTemps) + .where(eq(vendorDataReportTemps.id, id)); + + if (!targetTempFile) { + throw new Error("해당 Template File을 찾을 수 없습니다."); + } + + await tx + .delete(vendorDataReportTemps) + .where(eq(vendorDataReportTemps.id, id)); + + const { filePath } = targetTempFile; + + await deleteFile(filePath); + + return { result: true }; + }); + } catch (err) { + return { result: false, error: (err as Error).message }; + } +}; + + +/** + * Get tag type mappings specific to a form + * @param formCode The form code to filter mappings + * @param projectId The project ID + * @returns Array of tag type-class mappings for the form + */ +export async function getFormTagTypeMappings(formCode: string, projectId: number) { + + try { + const mappings = await db.query.tagTypeClassFormMappings.findMany({ + where: and( + eq(tagTypeClassFormMappings.formCode, formCode), + eq(tagTypeClassFormMappings.projectId, projectId) + ) + }); + + return mappings; + } catch (error) { + console.error("Error fetching form tag type mappings:", error); + throw new Error("Failed to load form tag type mappings"); + } +} + +/** + * Get tag type by its description + * @param description The tag type description (used as tagTypeLabel in mappings) + * @param projectId The project ID + * @returns The tag type object + */ +export async function getTagTypeByDescription(description: string, projectId: number) { + try { + const tagType = await db.query.tagTypes.findFirst({ + where: and( + eq(tagTypes.description, description), + eq(tagTypes.projectId, projectId) + ) + }); + + return tagType; + } catch (error) { + console.error("Error fetching tag type by description:", error); + throw new Error("Failed to load tag type"); + } +} + +/** + * Get subfields for a specific tag type + * @param tagTypeCode The tag type code + * @param projectId The project ID + * @returns Object containing subfields with their options + */ +export async function getSubfieldsByTagTypeForForm(tagTypeCode: string, projectId: number) { + try { + const subfields = await db.query.tagSubfields.findMany({ + where: and( + eq(tagSubfields.tagTypeCode, tagTypeCode), + eq(tagSubfields.projectId, projectId) + ), + orderBy: tagSubfields.sortOrder + }); + + const subfieldsWithOptions = await Promise.all( + subfields.map(async (subfield) => { + const options = await db.query.tagSubfieldOptions.findMany({ + where: and( + eq(tagSubfieldOptions.attributesId, subfield.attributesId), + eq(tagSubfieldOptions.projectId, projectId) + ) + }); + + return { + name: subfield.attributesId, + label: subfield.attributesDescription, + type: options.length > 0 ? "select" : "text", + options: options.map(opt => ({ value: opt.code, label: opt.label })), + expression: subfield.expression || undefined, + delimiter: subfield.delimiter || undefined + }; + }) + ); + + return { subFields: subfieldsWithOptions }; + } catch (error) { + console.error("Error fetching subfields for form:", error); + throw new Error("Failed to load subfields"); + } +} + +interface GenericData { + [key: string]: any; +} + +interface SEDPAttribute { + NAME: string; + VALUE: any; + UOM: string; + UOM_ID?: string; + CLS_ID?:string; +} + +interface SEDPDataItem { + TAG_NO: string; + TAG_DESC: string; + CLS_ID: string; + ATTRIBUTES: SEDPAttribute[]; + SCOPE: string; + TOOLID: string; + ITM_NO: string; + OP_DELETE: boolean; + MAIN_YN: boolean; + LAST_REV_YN: boolean; + CRTER_NO: string; + CHGER_NO: string; + TYPE: string; + PROJ_NO: string; + REV_NO: string; + CRTE_DTM?: string; + CHGE_DTM?: string; + _id?: string; +} + +async function transformDataToSEDPFormat( + tableData: GenericData[], + columnsJSON: DataTableColumnJSON[], + formCode: string, + objectCode: string, + projectNo: string, + contractItemId: number, // Add contractItemId parameter + designerNo: string = "253213" +): Promise<SEDPDataItem[]> { + // Create a map for quick column lookup + const columnsMap = new Map<string, DataTableColumnJSON>(); + columnsJSON.forEach(col => { + columnsMap.set(col.key, col); + }); + + // Current timestamp for CRTE_DTM and CHGE_DTM + const currentTimestamp = new Date().toISOString(); + + // Define the API base URL + const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api'; + + // Get the token + const apiKey = await getSEDPToken(); + + // Cache for UOM factors to avoid duplicate API calls + const uomFactorCache = new Map<string, number>(); + + // Cache for packageCode to avoid duplicate DB queries for same tag + const packageCodeCache = new Map<string, string>(); + + // Cache for tagClass code to avoid duplicate DB queries for same tag + const tagClassCodeCache = new Map<string, string>(); + + // Transform each row + const transformedItems = []; + + for (const row of tableData) { + + const cotractItem = await db.query.contractItems.findFirst({ + where: + eq(contractItems.id, contractItemId), + }); + + const item = await db.query.items.findFirst({ + where: + eq(items.id, cotractItem.itemId), + }); + + // Get packageCode for this specific tag + let packageCode = item.packageCode; // fallback to formCode + let tagClassCode = ""; // for CLS_ID + + if (row.TAG_NO && contractItemId) { + // Check cache first + const cacheKey = `${contractItemId}-${row.TAG_NO}`; + + if (packageCodeCache.has(cacheKey)) { + packageCode = packageCodeCache.get(cacheKey)!; + } else { + try { + // Query to get packageCode for this specific tag + const tagResult = await db.query.tags.findFirst({ + where: and( + eq(tags.contractItemId, contractItemId), + eq(tags.tagNo, row.TAG_NO) + ) + }); + + if (tagResult) { + // Get tagClass code if tagClassId exists + if (tagResult.tagClassId) { + // Check tagClass cache first + if (tagClassCodeCache.has(cacheKey)) { + tagClassCode = tagClassCodeCache.get(cacheKey)!; + } else { + const tagClassResult = await db.query.tagClasses.findFirst({ + where: eq(tagClasses.id, tagResult.tagClassId) + }); + + if (tagClassResult) { + tagClassCode = tagClassResult.code; + console.log(`Found tagClass code for tag ${row.TAG_NO}: ${tagClassCode}`); + } else { + console.warn(`No tagClass found for tagClassId: ${tagResult.tagClassId}`); + } + + // Cache the tagClass code result + tagClassCodeCache.set(cacheKey, tagClassCode); + } + } + + // Get the contract item + const contractItemResult = await db.query.contractItems.findFirst({ + where: eq(contractItems.id, tagResult.contractItemId) + }); + + if (contractItemResult) { + // Get the first item with this itemId + const itemResult = await db.query.items.findFirst({ + where: eq(items.id, contractItemResult.itemId) + }); + + if (itemResult && itemResult.packageCode) { + packageCode = itemResult.packageCode; + console.log(`Found packageCode for tag ${row.TAG_NO}: ${packageCode}`); + } else { + console.warn(`No item found for contractItem.itemId: ${contractItemResult.itemId}, using fallback`); + } + } else { + console.warn(`No contractItem found for tag ${row.TAG_NO}, using fallback`); + } + } else { + console.warn(`No tag found for contractItemId: ${contractItemId}, tagNo: ${row.TAG_NO}, using fallback`); + } + + // Cache the result (even if it's the fallback value) + packageCodeCache.set(cacheKey, packageCode); + } catch (error) { + console.error(`Error fetching packageCode for tag ${row.TAG_NO}:`, error); + // Use fallback value and cache it + packageCodeCache.set(cacheKey, packageCode); + } + } + + // Get tagClass code if not already retrieved above + if (!tagClassCode && tagClassCodeCache.has(cacheKey)) { + tagClassCode = tagClassCodeCache.get(cacheKey)!; + } else if (!tagClassCode) { + try { + const tagResult = await db.query.tags.findFirst({ + where: and( + eq(tags.contractItemId, contractItemId), + eq(tags.tagNo, row.TAG_NO) + ) + }); + + if (tagResult && tagResult.tagClassId) { + const tagClassResult = await db.query.tagClasses.findFirst({ + where: eq(tagClasses.id, tagResult.tagClassId) + }); + + if (tagClassResult) { + tagClassCode = tagClassResult.code; + console.log(`Found tagClass code for tag ${row.TAG_NO}: ${tagClassCode}`); + } + } + + // Cache the tagClass code result + tagClassCodeCache.set(cacheKey, tagClassCode); + } catch (error) { + console.error(`Error fetching tagClass code for tag ${row.TAG_NO}:`, error); + // Cache empty string as fallback + tagClassCodeCache.set(cacheKey, ""); + } + } + } + + // Create base SEDP item with required fields + const sedpItem: SEDPDataItem = { + TAG_NO: row.TAG_NO || "", + TAG_DESC: row.TAG_DESC || "", + ATTRIBUTES: [], + // SCOPE: objectCode, + SCOPE: packageCode, + TOOLID: "eVCP", // Changed from VDCS + ITM_NO: row.TAG_NO || "", + OP_DELETE: false, + MAIN_YN: true, + LAST_REV_YN: true, + CRTER_NO: designerNo, + CHGER_NO: designerNo, + TYPE: formCode, // Use packageCode instead of formCode + CLS_ID: tagClassCode, // Add CLS_ID with tagClass code + PROJ_NO: projectNo, + REV_NO: "00", + CRTE_DTM: currentTimestamp, + CHGE_DTM: currentTimestamp, + _id: "" + }; + + // Convert all other fields (except TAG_NO and TAG_DESC) to ATTRIBUTES + for (const key in row) { + if (key !== "TAG_NO" && key !== "TAG_DESC") { + const column = columnsMap.get(key); + let value = row[key]; + + // Only process non-empty values + if (value !== undefined && value !== null && value !== "") { + // Check if we need to apply UOM conversion + if (column?.uomId) { + // First check cache to avoid duplicate API calls + let factor = uomFactorCache.get(column.uomId); + + // If not in cache, make API call to get the factor + if (factor === undefined) { + try { + const response = await fetch( + `${SEDP_API_BASE_URL}/UOM/GetByID`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'accept': '*/*', + 'ApiKey': apiKey, + 'ProjectNo': projectNo + }, + body: JSON.stringify({ + 'ProjectNo': projectNo, + 'UOMID': column.uomId, + 'ContainDeleted': false + }) + } + ); + + if (response.ok) { + const uomData = await response.json(); + if (uomData && uomData.FACTOR !== undefined && uomData.FACTOR !== null) { + factor = Number(uomData.FACTOR); + // Store in cache for future use (type assertion to ensure it's a number) + uomFactorCache.set(column.uomId, factor); + } + } else { + console.warn(`Failed to get UOM data for ${column.uomId}: ${response.statusText}`); + } + } catch (error) { + console.error(`Error fetching UOM data for ${column.uomId}:`, error); + } + } + + // Apply the factor if we got one + // if (factor !== undefined && typeof value === 'number') { + // value = value * factor; + // } + } + + const attribute: SEDPAttribute = { + NAME: key, + VALUE: String(value), // 모든 값을 문자열로 변환 + UOM: column?.uom || "", + CLS_ID: tagClassCode || "", + }; + + // Add UOM_ID if present in column definition + if (column?.uomId) { + attribute.UOM_ID = column.uomId; + } + + sedpItem.ATTRIBUTES.push(attribute); + } + } + } + + transformedItems.push(sedpItem); + } + + return transformedItems; +} + +// Server Action wrapper (async) +export async function transformFormDataToSEDP( + tableData: GenericData[], + columnsJSON: DataTableColumnJSON[], + formCode: string, + objectCode: string, + projectNo: string, + contractItemId: number, // Add contractItemId parameter + designerNo: string = "253213" +): Promise<SEDPDataItem[]> { + return transformDataToSEDPFormat( + tableData, + columnsJSON, + formCode, + objectCode, + projectNo, + contractItemId, // Pass contractItemId + designerNo + ); +} +/** + * Get project code by project ID + */ +export async function getProjectCodeById(projectId: number): Promise<string> { + const projectRecord = await db + .select({ code: projects.code }) + .from(projects) + .where(eq(projects.id, projectId)) + .limit(1); + + if (!projectRecord || projectRecord.length === 0) { + throw new Error(`Project not found with ID: ${projectId}`); + } + + return projectRecord[0].code; +} + +export async function getProjectById(projectId: number): Promise<{ code: string; type: string; }> { + const projectRecord = await db + .select({ code: projects.code , type:projects.type}) + .from(projects) + .where(eq(projects.id, projectId)) + .limit(1); + + if (!projectRecord || projectRecord.length === 0) { + throw new Error(`Project not found with ID: ${projectId}`); + } + + return projectRecord[0]; +} + + +/** + * Send data to SEDP + */ +export async function sendDataToSEDP( + projectCode: string, + sedpData: SEDPDataItem[] +): 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'; + + console.log("Sending data to SEDP:", JSON.stringify(sedpData, null, 2)); + + // Make the API call + const response = await fetch( + `${SEDP_API_BASE_URL}/AdapterData/Overwrite`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'accept': '*/*', + 'ApiKey': apiKey, + 'ProjectNo': projectCode + }, + body: JSON.stringify(sedpData) + } + ); + + 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 send data to SEDP API: ${error.message || 'Unknown error'}`); + } +} + +/** + * Server action to send form data to SEDP + */ +export async function sendFormDataToSEDP( + formCode: string, + projectId: number, + contractItemId: number, // contractItemId 파라미터 추가 + formData: GenericData[], + columns: DataTableColumnJSON[] +): Promise<{ success: boolean; message: string; data?: any }> { + try { + // 1. Get project code + const projectCode = await getProjectCodeById(projectId); + + // 2. Get class mapping + const mappingsResult = await db.query.tagTypeClassFormMappings.findFirst({ + where: and( + eq(tagTypeClassFormMappings.formCode, formCode), + eq(tagTypeClassFormMappings.projectId, projectId) + ) + }); + + // Check if mappings is an array or a single object and handle accordingly + const mappings = Array.isArray(mappingsResult) ? mappingsResult[0] : mappingsResult; + + // Default object code to fallback value if we can't find it + let objectCode = ""; // Default fallback + + if (mappings && mappings.classLabel) { + const objectCodeResult = await db.query.tagClasses.findFirst({ + where: and( + eq(tagClasses.label, mappings.classLabel), + eq(tagClasses.projectId, projectId) + ) + }); + + // Check if result is an array or a single object + const objectCodeRecord = Array.isArray(objectCodeResult) ? objectCodeResult[0] : objectCodeResult; + + if (objectCodeRecord && objectCodeRecord.code) { + objectCode = objectCodeRecord.code; + } else { + console.warn(`No tag class found for label ${mappings.classLabel} in project ${projectId}, using default`); + } + } else { + console.warn(`No mapping found for formCode ${formCode} in project ${projectId}, using default object code`); + } + + // 3. Transform data to SEDP format + const sedpData = await transformFormDataToSEDP( + formData, + columns, + formCode, + objectCode, + projectCode, + contractItemId // Add contractItemId parameter + ); + + // 4. Send to SEDP API + const result = await sendDataToSEDP(projectCode, sedpData); + + // 5. SEDP 전송 성공 후 formEntries에 status 업데이트 + try { + // Get the current formEntries data + const entries = await db + .select() + .from(formEntries) + .where( + and( + eq(formEntries.formCode, formCode), + eq(formEntries.contractItemId, contractItemId) + ) + ) + .limit(1); + + if (entries && entries.length > 0) { + const entry = entries[0]; + const dataArray = entry.data as Array<Record<string, any>>; + + if (Array.isArray(dataArray)) { + // Extract TAG_NO list from formData + const sentTagNumbers = new Set( + formData + .map(item => item.TAG_NO) + .filter(tagNo => tagNo) // Remove null/undefined values + ); + + // Update status for sent tags + const updatedDataArray = dataArray.map(item => { + if (item.TAG_NO && sentTagNumbers.has(item.TAG_NO)) { + return { + ...item, + status: "Sent to S-EDP" // SEDP로 전송된 데이터임을 표시 + }; + } + return item; + }); + + // Update the database + await db + .update(formEntries) + .set({ + data: updatedDataArray, + updatedAt: new Date() + }) + .where(eq(formEntries.id, entry.id)); + + console.log(`Updated status for ${sentTagNumbers.size} tags to "Sent to S-EDP"`); + } + } else { + console.warn(`No formEntries found for formCode: ${formCode}, contractItemId: ${contractItemId}`); + } + } catch (statusUpdateError) { + // Status 업데이트 실패는 경고로만 처리 (SEDP 전송은 성공했으므로) + console.warn("Failed to update status after SEDP send:", statusUpdateError); + } + + return { + success: true, + message: "Data successfully sent to SEDP", + data: result + }; + } catch (error: any) { + console.error("Error sending data to SEDP:", error); + return { + success: false, + message: error.message || "Failed to send data to SEDP" + }; + } +} + + +export async function deleteFormDataByTags({ + formCode, + contractItemId, + tagIdxs, +}: { + formCode: string + contractItemId: number + tagIdxs: string[] +}): Promise<{ + error?: string + success?: boolean + deletedCount?: number + deletedTagsCount?: number +}> { + try { + // 입력 검증 + if (!formCode || !contractItemId || !Array.isArray(tagIdxs) || tagIdxs.length === 0) { + return { + error: "Missing required parameters: formCode, contractItemId, tagIdxs", + } + } + + console.log(`[DELETE ACTION] Deleting tags for formCode: ${formCode}, contractItemId: ${contractItemId}, tagNos:`, tagIdxs) + + // 트랜잭션으로 안전하게 처리 + const result = await db.transaction(async (tx) => { + // 1. 현재 formEntry 데이터 가져오기 + const currentEntryResult = await tx + .select() + .from(formEntries) + .where( + and( + eq(formEntries.formCode, formCode), + eq(formEntries.contractItemId, contractItemId) + ) + ) + .orderBy(desc(formEntries.updatedAt)) + .limit(1) + + if (currentEntryResult.length === 0) { + throw new Error("Form entry not found") + } + + const currentEntry = currentEntryResult[0] + let currentData = Array.isArray(currentEntry.data) ? currentEntry.data : [] + + console.log(`[DELETE ACTION] Current data count: ${currentData.length}`) + + // 2. 삭제할 항목들 필터링 (formEntries에서) + const updatedData = currentData.filter((item: any) => + !tagIdxs.includes(item.TAG_IDX) + ) + + const deletedFromFormEntries = currentData.length - updatedData.length + + console.log(`[DELETE ACTION] Updated data count: ${updatedData.length}`) + console.log(`[DELETE ACTION] Deleted ${deletedFromFormEntries} items from formEntries`) + + if (deletedFromFormEntries === 0) { + throw new Error("No items were found to delete in formEntries") + } + + // 3. tags 테이블에서 해당 태그들 삭제 + const deletedTagsResult = await tx + .delete(tags) + .where( + and( + eq(tags.contractItemId, contractItemId), + inArray(tags.tagIdx, tagIdxs) + ) + ) + .returning({ tagNo: tags.tagNo }) + + const deletedTagsCount = deletedTagsResult.length + + console.log(`[DELETE ACTION] Deleted ${deletedTagsCount} items from tags table`) + console.log(`[DELETE ACTION] Deleted tag numbers:`, deletedTagsResult.map(t => t.tagNo)) + + // 4. formEntries 데이터 업데이트 + await tx + .update(formEntries) + .set({ + data: updatedData, + updatedAt: new Date(), + }) + .where( + and( + eq(formEntries.formCode, formCode), + eq(formEntries.contractItemId, contractItemId) + ) + ) + + return { + deletedFromFormEntries, + deletedTagsCount, + deletedTagNumbers: deletedTagsResult.map(t => t.tagNo) + } + }) + + // 5. 캐시 무효화 + const cacheKey = `form-data-${formCode}-${contractItemId}` + revalidateTag(cacheKey) + revalidateTag(`tags-${contractItemId}`) + + // 페이지 재검증 (필요한 경우) + + console.log(`[DELETE ACTION] Transaction completed successfully`) + console.log(`[DELETE ACTION] FormEntries deleted: ${result.deletedFromFormEntries}`) + console.log(`[DELETE ACTION] Tags deleted: ${result.deletedTagsCount}`) + + return { + success: true, + deletedCount: result.deletedFromFormEntries, + deletedTagsCount: result.deletedTagsCount, + } + + } catch (error) { + console.error("[DELETE ACTION] Error deleting form data:", error) + return { + error: error instanceof Error ? error.message : "An unexpected error occurred", + } + } +} + +/** + * Server action to exclude selected tags by updating their status + */ +export async function excludeFormDataByTags({ + formCode, + contractItemId, + tagNumbers, +}: { + formCode: string + contractItemId: number + tagNumbers: string[] +}): Promise<{ + error?: string + success?: boolean + excludedCount?: number +}> { + try { + // 입력 검증 + if (!formCode || !contractItemId || !Array.isArray(tagNumbers) || tagNumbers.length === 0) { + return { + error: "Missing required parameters: formCode, contractItemId, tagNumbers", + } + } + + console.log(`[EXCLUDE ACTION] Excluding tags for formCode: ${formCode}, contractItemId: ${contractItemId}, tagNumbers:`, tagNumbers) + + // 트랜잭션으로 안전하게 처리 + const result = await db.transaction(async (tx) => { + // 1. 현재 formEntry 데이터 가져오기 + const currentEntryResult = await tx + .select() + .from(formEntries) + .where( + and( + eq(formEntries.formCode, formCode), + eq(formEntries.contractItemId, contractItemId) + ) + ) + .orderBy(desc(formEntries.updatedAt)) + .limit(1) + + if (currentEntryResult.length === 0) { + throw new Error("Form entry not found") + } + + const currentEntry = currentEntryResult[0] + let currentData = Array.isArray(currentEntry.data) ? currentEntry.data : [] + + console.log(`[EXCLUDE ACTION] Current data count: ${currentData.length}`) + + // 2. TAG_NO가 일치하는 항목들의 status를 'excluded'로 업데이트 + let excludedCount = 0 + const updatedData = currentData.map((item: any) => { + if (tagNumbers.includes(item.TAG_NO)) { + excludedCount++ + return { + ...item, + status: 'excluded', + excludedAt: new Date().toISOString() // 제외 시간 추가 (선택사항) + } + } + return item + }) + + console.log(`[EXCLUDE ACTION] Excluded ${excludedCount} items`) + + if (excludedCount === 0) { + throw new Error("No items were found to exclude") + } + + // 3. formEntries 데이터 업데이트 + await tx + .update(formEntries) + .set({ + data: updatedData, + updatedAt: new Date(), + }) + .where( + and( + eq(formEntries.formCode, formCode), + eq(formEntries.contractItemId, contractItemId) + ) + ) + + return { + excludedCount, + excludedTagNumbers: tagNumbers + } + }) + + // 4. 캐시 무효화 + const cacheKey = `form-data-${formCode}-${contractItemId}` + revalidateTag(cacheKey) + + console.log(`[EXCLUDE ACTION] Transaction completed successfully`) + console.log(`[EXCLUDE ACTION] Tags excluded: ${result.excludedCount}`) + + return { + success: true, + excludedCount: result.excludedCount, + } + + } catch (error) { + console.error("[EXCLUDE ACTION] Error excluding form data:", error) + return { + error: error instanceof Error ? error.message : "An unexpected error occurred", + } + } +} + + + +export async function getRegisters(projectCode: string): Promise<Register[]> { + try { + // 토큰(API 키) 가져오기 + const apiKey = await getSEDPToken(); + const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api'; + + const response = await fetch( + `${SEDP_API_BASE_URL}/Register/Get`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'accept': '*/*', + 'ApiKey': apiKey, + 'ProjectNo': projectCode + }, + body: JSON.stringify({ + ProjectNo: projectCode, + ContainDeleted: false + }) + } + ); + + if (!response.ok) { + throw new Error(`레지스터 요청 실패: ${response.status} ${response.statusText}`); + } + + // 안전하게 JSON 파싱 + let data; + try { + data = await response.json(); + } catch (parseError) { + console.error(`프로젝트 ${projectCode}의 레지스터 응답 파싱 실패:`, parseError); + // 응답 내용 로깅 + const text = await response.clone().text(); + console.log(`응답 내용: ${text.substring(0, 200)}${text.length > 200 ? '...' : ''}`); + throw new Error(`레지스터 응답 파싱 실패: ${parseError instanceof Error ? parseError.message : String(parseError)}`); + } + + // 결과를 배열로 변환 (단일 객체인 경우 배열로 래핑) + const registers: Register[] = Array.isArray(data) ? data : [data]; + + console.log(`프로젝트 ${projectCode}에서 ${registers.length}개의 유효한 레지스터를 가져왔습니다.`); + return registers; + } catch (error) { + console.error(`프로젝트 ${projectCode}의 레지스터 가져오기 실패:`, error); + throw error; + } +}
\ No newline at end of file diff --git a/lib/forms-plant/stat.ts b/lib/forms-plant/stat.ts new file mode 100644 index 00000000..f13bab61 --- /dev/null +++ b/lib/forms-plant/stat.ts @@ -0,0 +1,375 @@ +"use server" + +import db from "@/db/db" +import { vendors, contracts, contractItems, forms, formEntries, formMetas, tags, tagClasses, tagClassAttributes, projects } from "@/db/schema" +import { eq, and, inArray } from "drizzle-orm" +import { getEditableFieldsByTag } from "./services" +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" + +interface VendorFormStatus { + vendorId: number + vendorName: string + formCount: number // 벤더가 가진 form 개수 + tagCount: number // 벤더가 가진 tag 개수 + totalFields: number // 입력해야 하는 총 필드 개수 + completedFields: number // 입력 완료된 필드 개수 + completionRate: number // 완료율 (%) +} + +export interface FormStatusByVendor { + tagCount: number; + totalFields: number; + completedFields: number; + completionRate: number; + upcomingCount: number; // 7일 이내 임박한 개수 + overdueCount: number; // 지연된 개수 +} + +export async function getProjectsWithContracts() { + try { + const projectList = await db + .selectDistinct({ + id: projects.id, + projectCode: projects.code, + projectName: projects.name, + }) + .from(projects) + .innerJoin(contracts, eq(contracts.projectId, projects.id)) + .orderBy(projects.code) + + return projectList + } catch (error) { + console.error('Error getting projects with contracts:', error) + throw new Error('계약이 있는 프로젝트 조회 중 오류가 발생했습니다.') + } +} + + + +export async function getVendorFormStatus(projectId?: number): Promise<VendorFormStatus[]> { + try { + // 1. 벤더 조회 쿼리 수정 + const vendorList = projectId + ? await db + .selectDistinct({ + vendorId: vendors.id, + vendorName: vendors.vendorName, + }) + .from(vendors) + .innerJoin(contracts, eq(contracts.vendorId, vendors.id)) + .where(eq(contracts.projectId, projectId)) + : await db + .selectDistinct({ + vendorId: vendors.id, + vendorName: vendors.vendorName, + }) + .from(vendors) + .innerJoin(contracts, eq(contracts.vendorId, vendors.id)) + + + const vendorStatusList: VendorFormStatus[] = [] + + for (const vendor of vendorList) { + let vendorFormCount = 0 + let vendorTagCount = 0 + let vendorTotalFields = 0 + let vendorCompletedFields = 0 + const uniqueTags = new Set<string>() + + // 2. 계약 조회 시 projectId 필터 추가 + const vendorContracts = projectId + ? await db + .select({ + id: contracts.id, + projectId: contracts.projectId + }) + .from(contracts) + .where( + and( + eq(contracts.vendorId, vendor.vendorId), + eq(contracts.projectId, projectId) + ) + ) + : await db + .select({ + id: contracts.id, + projectId: contracts.projectId + }) + .from(contracts) + .where(eq(contracts.vendorId, vendor.vendorId)) + + + for (const contract of vendorContracts) { + // 3. 계약별 contractItems 조회 + const contractItemsList = await db + .select({ + id: contractItems.id + }) + .from(contractItems) + .where(eq(contractItems.contractId, contract.id)) + + for (const contractItem of contractItemsList) { + // 4. contractItem별 forms 조회 + const formsList = await db + .select({ + id: forms.id, + formCode: forms.formCode, + contractItemId: forms.contractItemId + }) + .from(forms) + .where(eq(forms.contractItemId, contractItem.id)) + + vendorFormCount += formsList.length + + // 5. formEntries 조회 + const entriesList = await db + .select({ + id: formEntries.id, + formCode: formEntries.formCode, + data: formEntries.data + }) + .from(formEntries) + .where(eq(formEntries.contractItemId, contractItem.id)) + + // 6. TAG별 편집 가능 필드 조회 + const editableFieldsByTag = await getEditableFieldsByTag(contractItem.id, contract.projectId) + + for (const entry of entriesList) { + // formMetas에서 해당 formCode의 columns 조회 + const metaResult = await db + .select({ + columns: formMetas.columns + }) + .from(formMetas) + .where( + and( + eq(formMetas.formCode, entry.formCode), + eq(formMetas.projectId, contract.projectId) + ) + ) + .limit(1) + + if (metaResult.length === 0) continue + + const metaColumns = metaResult[0].columns as any[] + + // shi가 'IN' 또는 'BOTH'인 필드 찾기 + const inputRequiredFields = metaColumns + .filter(col => col.shi === 'IN' || col.shi === 'BOTH') + .map(col => col.key) + + // entry.data 분석 (배열로 가정) + const dataArray = Array.isArray(entry.data) ? entry.data : [] + + for (const dataItem of dataArray) { + if (typeof dataItem !== 'object' || !dataItem) continue + + const tagNo = dataItem.TAG_NO + if (tagNo) { + uniqueTags.add(tagNo) + + // TAG별 편집 가능 필드 가져오기 + const tagEditableFields = editableFieldsByTag.get(tagNo) || [] + + // 최종 입력 필요 필드 = shi 기반 필드 + TAG 기반 편집 가능 필드 + const allRequiredFields = inputRequiredFields.filter(field => + tagEditableFields.includes(field) + ) + // 각 필드별 입력 상태 체크 + for (const fieldKey of allRequiredFields) { + vendorTotalFields++ + + const fieldValue = dataItem[fieldKey] + // 값이 있고, 빈 문자열이 아니고, null이 아니면 입력 완료 + if (fieldValue !== undefined && fieldValue !== null && fieldValue !== '') { + vendorCompletedFields++ + } + } + } + } + } + } + } + + // 완료율 계산 + const completionRate = vendorTotalFields > 0 + ? Math.round((vendorCompletedFields / vendorTotalFields) * 100 * 10) / 10 + : 0 + + vendorStatusList.push({ + vendorId: vendor.vendorId, + vendorName: vendor.vendorName || '이름 없음', + formCount: vendorFormCount, + tagCount: uniqueTags.size, + totalFields: vendorTotalFields, + completedFields: vendorCompletedFields, + completionRate + }) + } + + return vendorStatusList + + } catch (error) { + console.error('Error getting vendor form status:', error) + throw new Error('벤더별 Form 입력 현황 조회 중 오류가 발생했습니다.') + } +} + + + +export async function getFormStatusByVendor(projectId: number, contractItemId: number, formCode: string): Promise<FormStatusByVendor[]> { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("인증이 필요합니다.") + } + + + let vendorFormCount = 0 + let vendorTagCount = 0 + let vendorTotalFields = 0 + let vendorCompletedFields = 0 + let vendorUpcomingCount = 0 // 7일 이내 임박한 개수 + let vendorOverdueCount = 0 // 지연된 개수 + const uniqueTags = new Set<string>() + const processedTags = new Set<string>() // 중복 처리 방지용 + + // 현재 날짜와 7일 후 날짜 계산 + const today = new Date() + today.setHours(0, 0, 0, 0) // 시간 부분 제거 + const sevenDaysLater = new Date(today) + sevenDaysLater.setDate(sevenDaysLater.getDate() + 7) + + // 4. contractItem별 forms 조회 + const formsList = await db + .select({ + id: forms.id, + formCode: forms.formCode, + contractItemId: forms.contractItemId + }) + .from(forms) + .where( + and( + eq(forms.contractItemId, contractItemId), + eq(forms.formCode, formCode) + ) + ) + + vendorFormCount += formsList.length + + // 5. formEntries 조회 + const entriesList = await db + .select({ + id: formEntries.id, + formCode: formEntries.formCode, + data: formEntries.data + }) + .from(formEntries) + .where( + and( + eq(formEntries.contractItemId, contractItemId), + eq(formEntries.formCode, formCode) + ) + ) + + // 6. TAG별 편집 가능 필드 조회 + const editableFieldsByTag = await getEditableFieldsByTag(contractItemId, projectId) + + const vendorStatusList: VendorFormStatus[] = [] + + for (const entry of entriesList) { + const metaResult = await db + .select({ + columns: formMetas.columns + }) + .from(formMetas) + .where( + and( + eq(formMetas.formCode, entry.formCode), + eq(formMetas.projectId, projectId) + ) + ) + .limit(1) + + if (metaResult.length === 0) continue + + const metaColumns = metaResult[0].columns as any[] + + const inputRequiredFields = metaColumns + .filter(col => col.shi === 'IN' || col.shi === 'BOTH') + .map(col => col.key) + + const dataArray = Array.isArray(entry.data) ? entry.data : [] + + for (const dataItem of dataArray) { + if (typeof dataItem !== 'object' || !dataItem) continue + + const tagNo = dataItem.TAG_NO + if (tagNo) { + uniqueTags.add(tagNo) + + // TAG별 편집 가능 필드 가져오기 + const tagEditableFields = editableFieldsByTag.get(tagNo) || [] + + const allRequiredFields = inputRequiredFields.filter(field => + tagEditableFields.includes(field) + ) + + // 해당 TAG의 필드 완료 상태 체크 + let tagHasIncompleteFields = false + + for (const fieldKey of allRequiredFields) { + vendorTotalFields++ + + const fieldValue = dataItem[fieldKey] + if (fieldValue !== undefined && fieldValue !== null && fieldValue !== '') { + vendorCompletedFields++ + } else { + tagHasIncompleteFields = true + } + } + + // 미완료 TAG에 대해서만 날짜 체크 (TAG당 한 번만 처리) + if (!processedTags.has(tagNo) && tagHasIncompleteFields) { + processedTags.add(tagNo) + + const targetDate = dataItem.DUE_DATE + if (targetDate) { + const target = new Date(targetDate) + target.setHours(0, 0, 0, 0) // 시간 부분 제거 + + if (target < today) { + // 미완료이면서 지연된 경우 (오늘보다 이전) + vendorOverdueCount++ + } else if (target >= today && target <= sevenDaysLater) { + // 미완료이면서 7일 이내 임박한 경우 + vendorUpcomingCount++ + } + } + } + } + } + } + + // 완료율 계산 + const completionRate = vendorTotalFields > 0 + ? Math.round((vendorCompletedFields / vendorTotalFields) * 100 * 10) / 10 + : 0 + + vendorStatusList.push({ + tagCount: uniqueTags.size, + totalFields: vendorTotalFields, + completedFields: vendorCompletedFields, + completionRate, + upcomingCount: vendorUpcomingCount, + overdueCount: vendorOverdueCount + }) + + return vendorStatusList + + } catch (error) { + console.error('Error getting vendor form status:', error) + throw new Error('벤더별 Form 입력 현황 조회 중 오류가 발생했습니다.') + } +}
\ No newline at end of file diff --git a/lib/tags-plant/form-mapping-service.ts b/lib/tags-plant/form-mapping-service.ts new file mode 100644 index 00000000..6de0e244 --- /dev/null +++ b/lib/tags-plant/form-mapping-service.ts @@ -0,0 +1,101 @@ +"use server" + +import db from "@/db/db" +import { tagTypeClassFormMappings } from "@/db/schema/vendorData"; +import { eq, and } from "drizzle-orm" + +// 폼 정보 인터페이스 (동일) +export interface FormMapping { + formCode: string; + formName: string; + ep: string; + remark: string; +} + +/** + * 주어진 tagType, classCode로 DB를 조회하여 + * 1) 특정 classCode 매핑 => 존재하면 반환 + * 2) 없으면 DEFAULT 매핑 => 없으면 빈 배열 + */ +export async function getFormMappingsByTagType( + tagType: string, + projectId: number, + classCode?: string +): Promise<FormMapping[]> { + + console.log(`DB-based getFormMappingsByTagType => tagType="${tagType}", class="${classCode ?? "NONE"}"`); + + // 1) classCode가 있으면 시도 + if (classCode) { + const specificRows = await db + .select({ + formCode: tagTypeClassFormMappings.formCode, + formName: tagTypeClassFormMappings.formName, + ep: tagTypeClassFormMappings.ep, + remark: tagTypeClassFormMappings.remark + }) + .from(tagTypeClassFormMappings) + .where(and( + eq(tagTypeClassFormMappings.tagTypeLabel, tagType), + eq(tagTypeClassFormMappings.projectId, projectId), + eq(tagTypeClassFormMappings.classLabel, classCode) + )) + + if (specificRows.length > 0) { + console.log("Found specific mapping rows:", specificRows.length); + return specificRows; + } + } + + // 2) fallback => DEFAULT + console.log(`Falling back to DEFAULT for tagType="${tagType}"`); + const defaultRows = await db + .select({ + formCode: tagTypeClassFormMappings.formCode, + formName: tagTypeClassFormMappings.formName, + ep: tagTypeClassFormMappings.ep + }) + .from(tagTypeClassFormMappings) + .where(and( + eq(tagTypeClassFormMappings.tagTypeLabel, tagType), + eq(tagTypeClassFormMappings.projectId, projectId), + eq(tagTypeClassFormMappings.classLabel, "DEFAULT") + )) + + if (defaultRows.length > 0) { + console.log("Using DEFAULT mapping rows:", defaultRows.length); + return defaultRows; + } + + // 3) 아무것도 없으면 빈 배열 + console.log(`No mappings found at all for tagType="${tagType}"`); + return []; +} + + +export async function getFormMappingsByTagTypebyProeject( + + projectId: number, +): Promise<FormMapping[]> { + + const specificRows = await db + .select({ + formCode: tagTypeClassFormMappings.formCode, + formName: tagTypeClassFormMappings.formName, + ep: tagTypeClassFormMappings.ep, + remark: tagTypeClassFormMappings.remark + }) + .from(tagTypeClassFormMappings) + .where(and( + eq(tagTypeClassFormMappings.projectId, projectId), + )) + + if (specificRows.length > 0) { + console.log("Found specific mapping rows:", specificRows.length); + return specificRows; + } + + + + return []; +}
\ No newline at end of file diff --git a/lib/tags-plant/repository.ts b/lib/tags-plant/repository.ts new file mode 100644 index 00000000..b5d48335 --- /dev/null +++ b/lib/tags-plant/repository.ts @@ -0,0 +1,71 @@ +import db from "@/db/db"; +import { NewTag, tags } from "@/db/schema/vendorData"; +import { + eq, + inArray, + not, + asc, + desc, + and, + ilike, + gte, + lte, + count, + gt, +} from "drizzle-orm"; +import { PgTransaction } from "drizzle-orm/pg-core"; + +export async function selectTags( + tx: PgTransaction<any, any, any>, + params: { + where?: any; // drizzle-orm의 조건식 (and, eq...) 등 + orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[]; + offset?: number; + limit?: number; + } +) { + const { where, orderBy, offset = 0, limit = 10 } = params; + + return tx + .select() + .from(tags) + .where(where) + .orderBy(...(orderBy ?? [])) + .offset(offset) + .limit(limit); +} +/** 총 개수 count */ +export async function countTags( + tx: PgTransaction<any, any, any>, + where?: any +) { + const res = await tx.select({ count: count() }).from(tags).where(where); + return res[0]?.count ?? 0; +} + +export async function insertTag( + tx: PgTransaction<any, any, any>, + data: NewTag // DB와 동일한 insert 가능한 타입 +) { + // returning() 사용 시 배열로 돌아오므로 [0]만 리턴 + return tx + .insert(tags) + .values(data) + .returning({ id: tags.id, createdAt: tags.createdAt }); +} + +/** 단건 삭제 */ +export async function deleteTagById( + tx: PgTransaction<any, any, any>, + tagId: number +) { + return tx.delete(tags).where(eq(tags.id, tagId)); +} + +/** 복수 삭제 */ +export async function deleteTagsByIds( + tx: PgTransaction<any, any, any>, + ids: number[] +) { + return tx.delete(tags).where(inArray(tags.id, ids)); +} diff --git a/lib/tags-plant/service.ts b/lib/tags-plant/service.ts new file mode 100644 index 00000000..028cde42 --- /dev/null +++ b/lib/tags-plant/service.ts @@ -0,0 +1,1650 @@ +"use server" + +import db from "@/db/db" +import { formEntries, forms, tagClasses, tags, tagSubfieldOptions, tagSubfields, tagTypes } from "@/db/schema/vendorData" +// import { eq } from "drizzle-orm" +import { createTagSchema, GetTagsSchema, updateTagSchema, UpdateTagSchema, type CreateTagSchema } from "./validations" +import { revalidateTag, unstable_noStore } from "next/cache"; +import { filterColumns } from "@/lib/filter-columns"; +import { unstable_cache } from "@/lib/unstable-cache"; +import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq, sql, ne, count, isNull } from "drizzle-orm"; +import { countTags, insertTag, selectTags } from "./repository"; +import { getErrorMessage } from "../handle-error"; +import { getFormMappingsByTagType } from './form-mapping-service'; +import { contractItems, contracts } from "@/db/schema/contract"; +import { getCodeListsByID } from "../sedp/sync-object-class"; +import { projects, vendors } from "@/db/schema"; +import { randomBytes } from 'crypto'; + +// 폼 결과를 위한 인터페이스 정의 +interface CreatedOrExistingForm { + id: number; + formCode: string; + formName: string; + isNewlyCreated: boolean; +} + +/** + * 16진수 24자리 고유 식별자 생성 + * @returns 24자리 16진수 문자열 (예: "a1b2c3d4e5f6789012345678") + */ +function generateTagIdx(): string { + return randomBytes(12).toString('hex'); // 12바이트 = 24자리 16진수 +} + +export async function getTags(input: GetTagsSchema, packagesId: number) { + + // return unstable_cache( + // async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // (1) advancedWhere + const advancedWhere = filterColumns({ + table: tags, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + // (2) globalWhere + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(tags.tagNo, s), + ilike(tags.tagType, s), + ilike(tags.description, s) + ); + } + // (4) 최종 where + const finalWhere = and(advancedWhere, globalWhere, eq(tags.contractItemId, packagesId)); + + // (5) 정렬 + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(tags[item.id]) : asc(tags[item.id]) + ) + : [asc(tags.createdAt)]; + + // 트랜잭션 내부에서 Repository 호출 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectTags(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }); + const total = await countTags(tx, finalWhere); + + + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + + + return { data, pageCount }; + } catch (err) { + // 에러 발생 시 디폴트 + return { data: [], pageCount: 0 }; + } + // }, + // [JSON.stringify(input), String(packagesId)], // 캐싱 키에 packagesId 추가 + // { + // revalidate: 3600, + // tags: [`tags-${packagesId}`], // 패키지별 태그 사용 + // } + // )(); +} + + +export async function createTag( + formData: CreateTagSchema, + selectedPackageId: number | null +) { + if (!selectedPackageId) { + return { error: "No selectedPackageId provided" } + } + + // Validate formData + const validated = createTagSchema.safeParse(formData) + if (!validated.success) { + return { error: validated.error.flatten().formErrors.join(", ") } + } + + // React 서버 액션에서 매 요청마다 실행 + unstable_noStore() + + try { + // 하나의 트랜잭션에서 모든 작업 수행 + return await db.transaction(async (tx) => { + // 1) 선택된 contractItem의 contractId 가져오기 + const contractItemResult = await tx + .select({ + contractId: contractItems.contractId, + projectId: contracts.projectId // projectId 추가 + }) + .from(contractItems) + .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) // contracts 테이블 조인 + .where(eq(contractItems.id, selectedPackageId)) + .limit(1) + + if (contractItemResult.length === 0) { + return { error: "Contract item not found" } + } + + const contractId = contractItemResult[0].contractId + const projectId = contractItemResult[0].projectId + + // 2) 해당 계약 내에서 같은 tagNo를 가진 태그가 있는지 확인 + const duplicateCheck = await tx + .select({ count: sql<number>`count(*)` }) + .from(tags) + .innerJoin(contractItems, eq(tags.contractItemId, contractItems.id)) + .where( + and( + eq(contractItems.contractId, contractId), + eq(tags.tagNo, validated.data.tagNo) + ) + ) + + if (duplicateCheck[0].count > 0) { + return { + error: `태그 번호 "${validated.data.tagNo}"는 이미 이 계약 내에 존재합니다.`, + } + } + + // 3) 태그 타입에 따른 폼 정보 가져오기 + const allFormMappings = await getFormMappingsByTagType( + validated.data.tagType, + projectId, // projectId 전달 + validated.data.class + ) + + // ep가 "IMEP"인 것만 필터링 + const formMappings = allFormMappings?.filter(mapping => mapping.ep === "IMEP") || [] + + + // 폼 매핑이 없으면 로그만 남기고 진행 + if (!formMappings || formMappings.length === 0) { + console.log( + "No form mappings found for tag type:", + validated.data.tagType, + "in project:", + projectId + ) + } + + + // 4) 이 태그 타입에 대한 주요 폼(첫 번째 폼)을 찾거나 생성 + let primaryFormId: number | null = null + const createdOrExistingForms: CreatedOrExistingForm[] = [] + + if (formMappings && formMappings.length > 0) { + console.log(selectedPackageId, formMappings) + for (const formMapping of formMappings) { + // 4-1) 이미 존재하는 폼인지 확인 + const existingForm = await tx + .select({ id: forms.id, im: forms.im, eng: forms.eng }) // eng 필드도 추가로 조회 + .from(forms) + .where( + and( + eq(forms.contractItemId, selectedPackageId), + eq(forms.formCode, formMapping.formCode) + ) + ) + .limit(1) + + let formId: number + if (existingForm.length > 0) { + // 이미 존재하면 해당 ID 사용 + formId = existingForm[0].id + + // 업데이트할 필드들 준비 + const updateValues: any = {}; + let shouldUpdate = false; + + // im 필드 체크 + if (existingForm[0].im !== true) { + updateValues.im = true; + shouldUpdate = true; + } + + // eng 필드 체크 - remark에 "VD_"가 포함되어 있을 때만 + if (formMapping.remark && formMapping.remark.includes("VD_") && existingForm[0].eng !== true) { + updateValues.eng = true; + shouldUpdate = true; + } + + if (shouldUpdate) { + await tx + .update(forms) + .set(updateValues) + .where(eq(forms.id, formId)) + + console.log(`Form ${formId} updated with:`, updateValues) + } + + createdOrExistingForms.push({ + id: formId, + formCode: formMapping.formCode, + formName: formMapping.formName, + isNewlyCreated: false, + }) + } else { + // 존재하지 않으면 새로 생성 + const insertValues: any = { + contractItemId: selectedPackageId, + formCode: formMapping.formCode, + formName: formMapping.formName, + im: true, + }; + + // remark에 "VD_"가 포함되어 있을 때만 eng: true 설정 + if (formMapping.remark && formMapping.remark.includes("VD_")) { + insertValues.eng = true; + } + + const insertResult = await tx + .insert(forms) + .values(insertValues) + .returning({ id: forms.id, formCode: forms.formCode, formName: forms.formName }) + + console.log("insertResult:", insertResult) + formId = insertResult[0].id + createdOrExistingForms.push({ + id: formId, + formCode: insertResult[0].formCode, + formName: insertResult[0].formName, + isNewlyCreated: true, + }) + } + + // 첫 번째 폼을 "주요 폼"으로 설정하여 태그 생성 시 사용 + if (primaryFormId === null) { + primaryFormId = formId + } + } + } + + // 🆕 16진수 24자리 태그 고유 식별자 생성 + const generatedTagIdx = generateTagIdx(); + console.log(`[CREATE TAG] Generated tagIdx: ${generatedTagIdx}`); + + // 5) 새 Tag 생성 (tagIdx 추가) + const [newTag] = await insertTag(tx, { + contractItemId: selectedPackageId, + formId: primaryFormId, + tagIdx: generatedTagIdx, // 🆕 생성된 16진수 24자리 추가 + tagNo: validated.data.tagNo, + class: validated.data.class, + tagType: validated.data.tagType, + description: validated.data.description ?? null, + }) + + console.log(`tags-${selectedPackageId}`, "create", newTag) + + // 6) 생성된 각 form에 대해 formEntries에 데이터 추가 (TAG_IDX 포함) + for (const form of createdOrExistingForms) { + try { + // 기존 formEntry 가져오기 + const existingEntry = await tx.query.formEntries.findFirst({ + where: and( + eq(formEntries.formCode, form.formCode), + eq(formEntries.contractItemId, selectedPackageId) + ) + }); + + // 새로운 태그 데이터 객체 생성 (TAG_IDX 포함) + const newTagData = { + TAG_IDX: generatedTagIdx, // 🆕 같은 16진수 24자리 값 사용 + TAG_NO: validated.data.tagNo, + TAG_DESC: validated.data.description ?? null, + status: "New" // 수동으로 생성된 태그임을 표시 + }; + + if (existingEntry && existingEntry.id) { + // 기존 formEntry가 있는 경우 - TAG_IDX 타입 추가 + let existingData: Array<{ + TAG_IDX?: string; // 🆕 TAG_IDX 필드 추가 + TAG_NO: string; + TAG_DESC?: string; + status?: string; + [key: string]: any; + }> = []; + + if (Array.isArray(existingEntry.data)) { + existingData = existingEntry.data; + } + + // TAG_IDX 또는 TAG_NO가 이미 존재하는지 확인 (우선순위: TAG_IDX) + const existingTagIndex = existingData.findIndex( + item => item.TAG_IDX === generatedTagIdx || + (item.TAG_NO === validated.data.tagNo && !item.TAG_IDX) + ); + + if (existingTagIndex === -1) { + // 태그가 없으면 새로 추가 + const updatedData = [...existingData, newTagData]; + + await tx + .update(formEntries) + .set({ + data: updatedData, + updatedAt: new Date() + }) + .where(eq(formEntries.id, existingEntry.id)); + + console.log(`[CREATE TAG] Added tag ${validated.data.tagNo} with tagIdx ${generatedTagIdx} to existing formEntry for form ${form.formCode}`); + } else { + console.log(`[CREATE TAG] Tag ${validated.data.tagNo} already exists in formEntry for form ${form.formCode}`); + } + } else { + // formEntry가 없는 경우 새로 생성 (TAG_IDX 포함) + await tx.insert(formEntries).values({ + formCode: form.formCode, + contractItemId: selectedPackageId, + data: [newTagData], + createdAt: new Date(), + updatedAt: new Date(), + }); + + console.log(`[CREATE TAG] Created new formEntry with tag ${validated.data.tagNo} and tagIdx ${generatedTagIdx} for form ${form.formCode}`); + } + } catch (formEntryError) { + console.error(`[CREATE TAG] Error updating formEntry for form ${form.formCode}:`, formEntryError); + // 개별 formEntry 에러는 로그만 남기고 전체 트랜잭션은 계속 진행 + } + } + + // 7) 캐시 무효화 (React 서버 액션에서 캐싱 사용 시) + revalidateTag(`tags-${selectedPackageId}`) + revalidateTag(`forms-${selectedPackageId}-ENG`) + revalidateTag("tags") + + // 생성된 각 form의 캐시도 무효화 + createdOrExistingForms.forEach(form => { + revalidateTag(`form-data-${form.formCode}-${selectedPackageId}`) + }) + + // 8) 성공 시 반환 (tagIdx 추가) + return { + success: true, + data: { + forms: createdOrExistingForms, + primaryFormId, + tagNo: validated.data.tagNo, + tagIdx: generatedTagIdx, // 🆕 생성된 tagIdx도 반환 + }, + } + }) + } catch (err: any) { + console.log("createTag error:", err) + + console.error("createTag error:", err) + return { error: getErrorMessage(err) } + } +} + +export async function createTagInForm( + formData: CreateTagSchema, + selectedPackageId: number | null, + formCode: string, + packageCode: string +) { + // 1. 초기 검증 + if (!selectedPackageId) { + console.error("[CREATE TAG] No selectedPackageId provided"); + return { + success: false, + error: "No selectedPackageId provided" + }; + } + + // 2. FormData 검증 + const validated = createTagSchema.safeParse(formData); + if (!validated.success) { + const errorMsg = validated.error.flatten().formErrors.join(", "); + console.error("[CREATE TAG] Validation failed:", errorMsg); + return { + success: false, + error: errorMsg + }; + } + + // 3. 캐시 무효화 설정 + unstable_noStore(); + + try { + // 4. 트랜잭션 시작 + return await db.transaction(async (tx) => { + // 5. Contract Item 정보 조회 + const contractItemResult = await tx + .select({ + contractId: contractItems.contractId, + projectId: contracts.projectId, + vendorId: contracts.vendorId + }) + .from(contractItems) + .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) + .where(eq(contractItems.id, selectedPackageId)) + .limit(1); + + if (contractItemResult.length === 0) { + console.error("[CREATE TAG] Contract item not found"); + return { + success: false, + error: "Contract item not found" + }; + } + + const { contractId, projectId, vendorId } = contractItemResult[0]; + + // 6. Vendor 정보 조회 + const vendor = await tx.query.vendors.findFirst({ + where: eq(vendors.id, vendorId) + }); + + if (!vendor) { + console.error("[CREATE TAG] Vendor not found"); + return { + success: false, + error: "선택한 벤더를 찾을 수 없습니다." + }; + } + + // 7. 중복 태그 확인 + const duplicateCheck = await tx + .select({ count: sql<number>`count(*)` }) + .from(tags) + .innerJoin(contractItems, eq(tags.contractItemId, contractItems.id)) + .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) + .where( + and( + eq(contracts.projectId, projectId), + eq(tags.tagNo, validated.data.tagNo) + ) + ); + + if (duplicateCheck[0].count > 0) { + console.error(`[CREATE TAG] Duplicate tag number: ${validated.data.tagNo}`); + return { + success: false, + error: `태그 번호 "${validated.data.tagNo}"는 이미 이 계약 내에 존재합니다.`, + }; + } + + // 8. Form 조회 + let form = await tx.query.forms.findFirst({ + where: and( + eq(forms.formCode, formCode), + eq(forms.contractItemId, selectedPackageId) + ) + }); + + // 9. Form이 없으면 생성 + if (!form) { + console.log(`[CREATE TAG] Form ${formCode} not found, attempting to create...`); + + // Form Mappings 조회 + const allFormMappings = await getFormMappingsByTagType( + validated.data.tagType, + projectId, + validated.data.class + ); + + // IMEP 폼만 필터링 + const formMappings = allFormMappings?.filter(mapping => mapping.ep === "IMEP") || []; + const targetFormMapping = formMappings.find(mapping => mapping.formCode === formCode); + + if (!targetFormMapping) { + console.error(`[CREATE TAG] No IMEP form mapping found for formCode: ${formCode}`); + return { + success: false, + error: `Form ${formCode} not found and no IMEP mapping available for tag type ${validated.data.tagType}` + }; + } + + // Form 생성 + const insertResult = await tx + .insert(forms) + .values({ + contractItemId: selectedPackageId, + formCode: targetFormMapping.formCode, + formName: targetFormMapping.formName, + im: true, + }) + .returning({ id: forms.id, formCode: forms.formCode, formName: forms.formName }); + + form = { + id: insertResult[0].id, + formCode: insertResult[0].formCode, + formName: insertResult[0].formName, + contractItemId: selectedPackageId, + im: true, + createdAt: new Date(), + updatedAt: new Date() + }; + + console.log(`[CREATE TAG] Successfully created form:`, insertResult[0]); + } else { + // 기존 form의 im 상태 업데이트 + if (form.im !== true) { + await tx + .update(forms) + .set({ im: true }) + .where(eq(forms.id, form.id)); + + console.log(`[CREATE TAG] Form ${form.id} updated with im: true`); + } + } + + // 10. Form이 있는 경우에만 진행 + if (!form?.id) { + console.error("[CREATE TAG] Failed to create or find form"); + return { + success: false, + error: "Failed to create or find form" + }; + } + + // 11. Tag Index 생성 + const generatedTagIdx = generateTagIdx(); + console.log(`[CREATE TAG] Generated tagIdx: ${generatedTagIdx}`); + + // 12. 새 Tag 생성 + const [newTag] = await insertTag(tx, { + contractItemId: selectedPackageId, + formId: form.id, + tagIdx: generatedTagIdx, + tagNo: validated.data.tagNo, + class: validated.data.class, + tagType: validated.data.tagType, + description: validated.data.description ?? null, + }); + + // 13. Tag Class 조회 + const tagClass = await tx.query.tagClasses.findFirst({ + where: and( + eq(tagClasses.projectId, projectId), + eq(tagClasses.label, validated.data.class) + ) + }); + + if (!tagClass) { + console.warn("[CREATE TAG] Tag class not found, using default"); + } + + // 14. FormEntry 처리 + const entry = await tx.query.formEntries.findFirst({ + where: and( + eq(formEntries.formCode, formCode), + eq(formEntries.contractItemId, selectedPackageId), + ) + }); + + // 15. 새로운 태그 데이터 준비 + const newTagData = { + TAG_IDX: generatedTagIdx, + TAG_NO: validated.data.tagNo, + TAG_DESC: validated.data.description ?? null, + CLS_ID: tagClass?.code || validated.data.class, // tagClass가 없을 경우 대비 + VNDRCD: vendor.vendorCode, + VNDRNM_1: vendor.vendorName, + CM3003: packageCode, + ME5074: packageCode, + status: "New" // 수동으로 생성된 태그임을 표시 + }; + + if (entry?.id) { + // 16. 기존 FormEntry 업데이트 + let existingData: Array<any> = []; + if (Array.isArray(entry.data)) { + existingData = entry.data; + } + + console.log(`[CREATE TAG] Existing data count: ${existingData.length}`); + + const updatedData = [...existingData, newTagData]; + + await tx + .update(formEntries) + .set({ + data: updatedData, + updatedAt: new Date() + }) + .where(eq(formEntries.id, entry.id)); + + console.log(`[CREATE TAG] Updated formEntry with new tag`); + } else { + // 17. 새 FormEntry 생성 + console.log(`[CREATE TAG] Creating new formEntry`); + + await tx.insert(formEntries).values({ + formCode: formCode, + contractItemId: selectedPackageId, + data: [newTagData], + createdAt: new Date(), + updatedAt: new Date(), + }); + + console.log(`[CREATE TAG] Created new formEntry`); + } + + // 18. 캐시 무효화 + revalidateTag(`tags-${selectedPackageId}`); + revalidateTag(`forms-${selectedPackageId}`); + revalidateTag(`form-data-${formCode}-${selectedPackageId}`); + revalidateTag("tags"); + + console.log(`[CREATE TAG] Successfully created tag: ${validated.data.tagNo} with tagIdx: ${generatedTagIdx}`); + + // 19. 성공 응답 + return { + success: true, + data: { + formId: form.id, + tagNo: validated.data.tagNo, + tagIdx: generatedTagIdx, + formCreated: !form + } + }; + }); + } catch (err: any) { + // 20. 에러 처리 + console.error("[CREATE TAG] Transaction error:", err); + const errorMessage = getErrorMessage(err); + + return { + success: false, + error: errorMessage + }; + } +} + +export async function updateTag( + formData: UpdateTagSchema & { id: number }, + selectedPackageId: number | null +) { + if (!selectedPackageId) { + return { error: "No selectedPackageId provided" } + } + + if (!formData.id) { + return { error: "No tag ID provided" } + } + + // Validate formData + const validated = updateTagSchema.safeParse(formData) + if (!validated.success) { + return { error: validated.error.flatten().formErrors.join(", ") } + } + + // React 서버 액션에서 매 요청마다 실행 + unstable_noStore() + + try { + // 하나의 트랜잭션에서 모든 작업 수행 + return await db.transaction(async (tx) => { + // 1) 기존 태그 존재 여부 확인 + const existingTag = await tx + .select() + .from(tags) + .where(eq(tags.id, formData.id)) + .limit(1) + + if (existingTag.length === 0) { + return { error: "태그를 찾을 수 없습니다." } + } + + const originalTag = existingTag[0] + + // 2) 선택된 contractItem의 contractId 가져오기 + const contractItemResult = await tx + .select({ + contractId: contractItems.contractId, + projectId: contracts.projectId // projectId 추가 + }) + .from(contractItems) + .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) // contracts 테이블 조인 + .where(eq(contractItems.id, selectedPackageId)) + .limit(1) + + if (contractItemResult.length === 0) { + return { error: "Contract item not found" } + } + + const contractId = contractItemResult[0].contractId + const projectId = contractItemResult[0].projectId + + // 3) 태그 번호가 변경되었고, 해당 계약 내에서 같은 tagNo를 가진 다른 태그가 있는지 확인 + if (originalTag.tagNo !== validated.data.tagNo) { + const duplicateCheck = await tx + .select({ count: sql<number>`count(*)` }) + .from(tags) + .innerJoin(contractItems, eq(tags.contractItemId, contractItems.id)) + .where( + and( + eq(contractItems.contractId, contractId), + eq(tags.tagNo, validated.data.tagNo), + ne(tags.id, formData.id) // 자기 자신은 제외 + ) + ) + + if (duplicateCheck[0].count > 0) { + return { + error: `태그 번호 "${validated.data.tagNo}"는 이미 이 계약 내에 존재합니다.`, + } + } + } + + // 4) 태그 타입이나 클래스가 변경되었는지 확인 + const isTagTypeOrClassChanged = + originalTag.tagType !== validated.data.tagType || + originalTag.class !== validated.data.class + + let primaryFormId = originalTag.formId + + // 태그 타입이나 클래스가 변경되었다면 연관된 폼 업데이트 + if (isTagTypeOrClassChanged) { + // 4-1) 태그 타입에 따른 폼 정보 가져오기 + const formMappings = await getFormMappingsByTagType( + validated.data.tagType, + projectId, // projectId 전달 + validated.data.class + ) + + // 폼 매핑이 없으면 로그만 남기고 진행 + if (!formMappings || formMappings.length === 0) { + console.log( + "No form mappings found for tag type:", + validated.data.tagType, + "in project:", + projectId + ) + } + + // 4-2) 이 태그 타입에 대한 주요 폼(첫 번째 폼)을 찾거나 생성 + const createdOrExistingForms: CreatedOrExistingForm[] = [] + + if (formMappings && formMappings.length > 0) { + for (const formMapping of formMappings) { + // 이미 존재하는 폼인지 확인 + const existingForm = await tx + .select({ id: forms.id }) + .from(forms) + .where( + and( + eq(forms.contractItemId, selectedPackageId), + eq(forms.formCode, formMapping.formCode) + ) + ) + .limit(1) + + let formId: number + if (existingForm.length > 0) { + // 이미 존재하면 해당 ID 사용 + formId = existingForm[0].id + createdOrExistingForms.push({ + id: formId, + formCode: formMapping.formCode, + formName: formMapping.formName, + isNewlyCreated: false, + }) + } else { + // 존재하지 않으면 새로 생성 + const insertResult = await tx + .insert(forms) + .values({ + contractItemId: selectedPackageId, + formCode: formMapping.formCode, + formName: formMapping.formName, + }) + .returning({ id: forms.id, formCode: forms.formCode, formName: forms.formName }) + + formId = insertResult[0].id + createdOrExistingForms.push({ + id: formId, + formCode: insertResult[0].formCode, + formName: insertResult[0].formName, + isNewlyCreated: true, + }) + } + + // 첫 번째 폼을 "주요 폼"으로 설정하여 태그 업데이트 시 사용 + if (createdOrExistingForms.length === 1) { + primaryFormId = formId + } + } + } + } + + // 5) 태그 업데이트 + const [updatedTag] = await tx + .update(tags) + .set({ + contractItemId: selectedPackageId, + formId: primaryFormId, + tagNo: validated.data.tagNo, + class: validated.data.class, + tagType: validated.data.tagType, + description: validated.data.description ?? null, + updatedAt: new Date(), + }) + .where(eq(tags.id, formData.id)) + .returning() + + // 6) 캐시 무효화 (React 서버 액션에서 캐싱 사용 시) + revalidateTag(`tags-${selectedPackageId}`) + revalidateTag(`forms-${selectedPackageId}`) + revalidateTag("tags") + + // 7) 성공 시 반환 + return { + success: true, + data: { + tag: updatedTag, + formUpdated: isTagTypeOrClassChanged + }, + } + }) + } catch (err: any) { + console.error("updateTag error:", err) + return { error: getErrorMessage(err) } + } +} + +export interface TagInputData { + tagNo: string; + class: string; + tagType: string; + description?: string | null; + formId?: number | null; + [key: string]: any; +} +// 새로운 서버 액션 +export async function bulkCreateTags( + tagsfromExcel: TagInputData[], + selectedPackageId: number +) { + unstable_noStore(); + + if (!tagsfromExcel.length) { + return { error: "No tags provided" }; + } + + try { + // 단일 트랜잭션으로 모든 작업 처리 + return await db.transaction(async (tx) => { + // 1. 컨트랙트 ID 및 프로젝트 ID 조회 (한 번만) + const contractItemResult = await tx + .select({ + contractId: contractItems.contractId, + projectId: contracts.projectId // projectId 추가 + }) + .from(contractItems) + .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) // contracts 테이블 조인 + .where(eq(contractItems.id, selectedPackageId)) + .limit(1); + + if (contractItemResult.length === 0) { + return { error: "Contract item not found" }; + } + + const contractId = contractItemResult[0].contractId; + const projectId = contractItemResult[0].projectId; // projectId 추출 + + // 2. 모든 태그 번호 중복 검사 (한 번에) + const tagNos = tagsfromExcel.map(tag => tag.tagNo); + const duplicateCheck = await tx + .select({ tagNo: tags.tagNo }) + .from(tags) + .innerJoin(contractItems, eq(tags.contractItemId, contractItems.id)) + .where(and( + eq(contractItems.contractId, contractId), + inArray(tags.tagNo, tagNos) + )); + + if (duplicateCheck.length > 0) { + return { + error: `태그 번호 "${duplicateCheck.map(d => d.tagNo).join(', ')}"는 이미 존재합니다.` + }; + } + + // 3. 태그별 폼 정보 처리 및 태그 생성 + const createdTags = []; + const allFormsInfo = []; // 모든 태그에 대한 폼 정보 저장 + + // 태그 유형별 폼 매핑 캐싱 (성능 최적화) + const formMappingsCache = new Map(); + + // formEntries 업데이트를 위한 맵 (formCode -> 태그 데이터 배열) + const tagsByFormCode = new Map<string, Array<{ + TAG_NO: string; + TAG_DESC: string | null; + status: string; + }>>(); + + for (const tagData of tagsfromExcel) { + // 캐시 키 생성 (tagType + class) + const cacheKey = `${tagData.tagType}|${tagData.class || 'NONE'}`; + + // 폼 매핑 가져오기 (캐시 사용) + let formMappings; + if (formMappingsCache.has(cacheKey)) { + formMappings = formMappingsCache.get(cacheKey); + } else { + const tagTypeLabel = await tx + .select({ description: tagTypes.description }) + .from(tagTypes) + .where( + and( + eq(tagTypes.projectId, projectId), + eq(tagTypes.code, tagData.tagType), + ) + ) + .limit(1) + + const tagTypeLabelText = tagTypeLabel[0].description + + // 각 태그 유형에 대한 폼 매핑 조회 (projectId 전달) + const allFormMappings = await getFormMappingsByTagType( + tagTypeLabelText, + projectId, // projectId 전달 + tagData.class + ); + + // ep가 "IMEP"인 것만 필터링 + formMappings = allFormMappings?.filter(mapping => mapping.ep === "IMEP") || []; + formMappingsCache.set(cacheKey, formMappings); + } + + // 폼 처리 로직 + let primaryFormId: number | null = null; + const createdOrExistingForms: CreatedOrExistingForm[] = []; + + if (formMappings && formMappings.length > 0) { + for (const formMapping of formMappings) { + // 해당 폼이 이미 존재하는지 확인 + const existingForm = await tx + .select({ id: forms.id, im: forms.im }) + .from(forms) + .where( + and( + eq(forms.contractItemId, selectedPackageId), + eq(forms.formCode, formMapping.formCode) + ) + ) + .limit(1); + + let formId: number; + if (existingForm.length > 0) { + // 이미 존재하면 해당 ID 사용 + formId = existingForm[0].id; + + // im 필드 업데이트 (필요한 경우) + if (existingForm[0].im !== true) { + await tx + .update(forms) + .set({ im: true }) + .where(eq(forms.id, formId)); + } + + createdOrExistingForms.push({ + id: formId, + formCode: formMapping.formCode, + formName: formMapping.formName, + isNewlyCreated: false, + }); + } else { + // 존재하지 않으면 새로 생성 + const insertResult = await tx + .insert(forms) + .values({ + contractItemId: selectedPackageId, + formCode: formMapping.formCode, + formName: formMapping.formName, + im: true + }) + .returning({ id: forms.id, formCode: forms.formCode, formName: forms.formName }); + + formId = insertResult[0].id; + createdOrExistingForms.push({ + id: formId, + formCode: insertResult[0].formCode, + formName: insertResult[0].formName, + isNewlyCreated: true, + }); + } + + // 첫 번째 폼을 "주요 폼"으로 설정하여 태그 생성 시 사용 + if (primaryFormId === null) { + primaryFormId = formId; + } + + // formEntries 업데이트를 위한 데이터 수집 (tagsfromExcel의 원본 데이터 사용) + const newTagEntry = { + TAG_NO: tagData.tagNo, + TAG_DESC: tagData.description || null, + status: "New" // 벌크 생성도 수동 생성으로 분류 + }; + + if (!tagsByFormCode.has(formMapping.formCode)) { + tagsByFormCode.set(formMapping.formCode, []); + } + tagsByFormCode.get(formMapping.formCode)!.push(newTagEntry); + } + } else { + console.log( + "No IMEP form mappings found for tag type:", + tagData.tagType, + "class:", + tagData.class || "NONE", + "in project:", + projectId + ); + } + + // 태그 생성 + const [newTag] = await insertTag(tx, { + contractItemId: selectedPackageId, + formId: primaryFormId, + tagNo: tagData.tagNo, + class: tagData.class || "", + tagType: tagData.tagType, + description: tagData.description || null, + }); + + createdTags.push(newTag); + + // 해당 태그의 폼 정보 저장 + allFormsInfo.push({ + tagNo: tagData.tagNo, + forms: createdOrExistingForms, + primaryFormId, + }); + } + + // 4. formEntries 업데이트 처리 + for (const [formCode, newTagsData] of tagsByFormCode.entries()) { + try { + // 기존 formEntry 가져오기 + const existingEntry = await tx.query.formEntries.findFirst({ + where: and( + eq(formEntries.formCode, formCode), + eq(formEntries.contractItemId, selectedPackageId) + ) + }); + + if (existingEntry && existingEntry.id) { + // 기존 formEntry가 있는 경우 + let existingData: Array<{ + TAG_NO: string; + TAG_DESC?: string | null; + status?: string; + [key: string]: any; + }> = []; + + if (Array.isArray(existingEntry.data)) { + existingData = existingEntry.data; + } + + // 기존 TAG_NO들 추출 + const existingTagNos = new Set(existingData.map(item => item.TAG_NO)); + + // 중복되지 않은 새 태그들만 필터링 + const newUniqueTagsData = newTagsData.filter( + tagData => !existingTagNos.has(tagData.TAG_NO) + ); + + if (newUniqueTagsData.length > 0) { + const updatedData = [...existingData, ...newUniqueTagsData]; + + await tx + .update(formEntries) + .set({ + data: updatedData, + updatedAt: new Date() + }) + .where(eq(formEntries.id, existingEntry.id)); + + console.log(`[BULK CREATE] Added ${newUniqueTagsData.length} tags to existing formEntry for form ${formCode}`); + } else { + console.log(`[BULK CREATE] All tags already exist in formEntry for form ${formCode}`); + } + } else { + // formEntry가 없는 경우 새로 생성 + await tx.insert(formEntries).values({ + formCode: formCode, + contractItemId: selectedPackageId, + data: newTagsData, + createdAt: new Date(), + updatedAt: new Date(), + }); + + console.log(`[BULK CREATE] Created new formEntry with ${newTagsData.length} tags for form ${formCode}`); + } + } catch (formEntryError) { + console.error(`[BULK CREATE] Error updating formEntry for form ${formCode}:`, formEntryError); + // 개별 formEntry 에러는 로그만 남기고 전체 트랜잭션은 계속 진행 + } + } + + // 5. 캐시 무효화 (한 번만) + revalidateTag(`tags-${selectedPackageId}`); + revalidateTag(`forms-${selectedPackageId}`); + revalidateTag("tags"); + + // 업데이트된 모든 form의 캐시도 무효화 + for (const formCode of tagsByFormCode.keys()) { + revalidateTag(`form-data-${formCode}-${selectedPackageId}`); + } + + return { + success: true, + data: { + createdCount: createdTags.length, + tags: createdTags, + formsInfo: allFormsInfo, + formEntriesUpdated: tagsByFormCode.size // 업데이트된 formEntry 수 + } + }; + }); + } catch (err: any) { + console.error("bulkCreateTags error:", err); + return { error: getErrorMessage(err) || "Failed to create tags" }; + } +} +/** 복수 삭제 */ +interface RemoveTagsInput { + ids: number[]; + selectedPackageId: number; +} + + +// formEntries의 data JSON에서 tagNo가 일치하는 객체를 제거해주는 예시 함수 +function removeTagFromDataJson( + dataJson: any, + tagNo: string +): any { + // data 구조가 어떻게 생겼는지에 따라 로직이 달라집니다. + // 예: data 배열 안에 { TAG_NO: string, ... } 형태로 여러 객체가 있다고 가정 + if (!Array.isArray(dataJson)) return dataJson + return dataJson.filter((entry) => entry.TAG_NO !== tagNo) +} + +export async function removeTags(input: RemoveTagsInput) { + unstable_noStore() // React 서버 액션 무상태 함수 + + const { ids, selectedPackageId } = input + + try { + await db.transaction(async (tx) => { + + const packageInfo = await tx + .select({ + projectId: contracts.projectId + }) + .from(contractItems) + .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) + .where(eq(contractItems.id, selectedPackageId)) + .limit(1); + + if (packageInfo.length === 0) { + throw new Error(`Contract item with ID ${selectedPackageId} not found`); + } + + const projectId = packageInfo[0].projectId; + + // 1) 삭제 대상 tag들을 미리 조회 + const tagsToDelete = await tx + .select({ + id: tags.id, + tagNo: tags.tagNo, + tagType: tags.tagType, + class: tags.class, + }) + .from(tags) + .where(inArray(tags.id, ids)) + + // 2) 태그 타입과 클래스의 고유 조합 추출 + const uniqueTypeClassCombinations = [...new Set( + tagsToDelete.map(tag => `${tag.tagType}|${tag.class || ''}`) + )].map(combo => { + const [tagType, classValue] = combo.split('|'); + return { tagType, class: classValue || undefined }; + }); + + // 3) 각 태그 타입/클래스 조합에 대해 처리 + for (const { tagType, class: classValue } of uniqueTypeClassCombinations) { + // 3-1) 삭제 중인 태그들 외에, 동일한 태그 타입/클래스를 가진 다른 태그가 있는지 확인 + const otherTagsWithSameTypeClass = await tx + .select({ count: count() }) + .from(tags) + .where( + and( + eq(tags.tagType, tagType), + classValue ? eq(tags.class, classValue) : isNull(tags.class), + not(inArray(tags.id, ids)), // 현재 삭제 중인 태그들은 제외 + eq(tags.contractItemId, selectedPackageId) // 같은 contractItemId 내에서만 확인 + ) + ) + + // 3-2) 이 태그 타입/클래스에 연결된 폼 매핑 가져오기 + const formMappings = await getFormMappingsByTagType(tagType, projectId, classValue); + + if (!formMappings.length) continue; + + // 3-3) 이 태그 타입/클래스와 관련된 태그 번호 추출 + const relevantTagNos = tagsToDelete + .filter(tag => tag.tagType === tagType && + (classValue ? tag.class === classValue : !tag.class)) + .map(tag => tag.tagNo); + + // 3-4) 각 폼 코드에 대해 처리 + for (const formMapping of formMappings) { + // 다른 태그가 없다면 폼 삭제 + if (otherTagsWithSameTypeClass[0].count === 0) { + // 폼 삭제 + await tx + .delete(forms) + .where( + and( + eq(forms.contractItemId, selectedPackageId), + eq(forms.formCode, formMapping.formCode) + ) + ) + + // formEntries 테이블에서도 해당 formCode 관련 데이터 삭제 + await tx + .delete(formEntries) + .where( + and( + eq(formEntries.contractItemId, selectedPackageId), + eq(formEntries.formCode, formMapping.formCode) + ) + ) + } + // 다른 태그가 있다면 formEntries 데이터에서 해당 태그 정보만 제거 + else if (relevantTagNos.length > 0) { + const formEntryRecords = await tx + .select({ + id: formEntries.id, + data: formEntries.data, + }) + .from(formEntries) + .where( + and( + eq(formEntries.contractItemId, selectedPackageId), + eq(formEntries.formCode, formMapping.formCode) + ) + ) + + // 각 formEntry에 대해 처리 + for (const entry of formEntryRecords) { + let updatedJson = entry.data; + + // 각 tagNo에 대해 JSON 데이터에서 제거 + for (const tagNo of relevantTagNos) { + updatedJson = removeTagFromDataJson(updatedJson, tagNo); + } + + // 변경이 있다면 업데이트 + await tx + .update(formEntries) + .set({ data: updatedJson }) + .where(eq(formEntries.id, entry.id)) + } + } + } + } + + // 4) 마지막으로 tags 테이블에서 태그들 삭제 + await tx.delete(tags).where(inArray(tags.id, ids)) + }) + + // 5) 캐시 무효화 + revalidateTag(`tags-${selectedPackageId}`) + revalidateTag(`forms-${selectedPackageId}`) + + return { data: null, error: null } + } catch (err) { + return { data: null, error: getErrorMessage(err) } + } +} +// Updated service functions to support the new schema + +// 업데이트된 ClassOption 타입 +export interface ClassOption { + code: string; + label: string; + tagTypeCode: string; // 클래스와 연결된 태그 타입 코드 + tagTypeDescription?: string; // 태그 타입의 설명 (선택적) +} + +/** + * Class 옵션 목록을 가져오는 함수 + * 이제 각 클래스는 연결된 tagTypeCode와 tagTypeDescription을 포함 + */ +export async function getClassOptions(selectedPackageId: number): Promise<UpdatedClassOption[]> { + try { + // 1. 먼저 contractItems에서 projectId 조회 + const packageInfo = await db + .select({ + projectId: contracts.projectId + }) + .from(contractItems) + .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) + .where(eq(contractItems.id, selectedPackageId)) + .limit(1); + + if (packageInfo.length === 0) { + throw new Error(`Contract item with ID ${selectedPackageId} not found`); + } + + const projectId = packageInfo[0].projectId; + + // 2. 태그 클래스들을 서브클래스 정보와 함께 조회 + const tagClassesWithSubclasses = await db + .select({ + id: tagClasses.id, + code: tagClasses.code, + label: tagClasses.label, + tagTypeCode: tagClasses.tagTypeCode, + subclasses: tagClasses.subclasses, + subclassRemark: tagClasses.subclassRemark, + }) + .from(tagClasses) + .where(eq(tagClasses.projectId, projectId)) + .orderBy(tagClasses.code); + + // 3. 태그 타입 정보도 함께 조회 (description을 위해) + const tagTypesMap = new Map(); + const tagTypesList = await db + .select({ + code: tagTypes.code, + description: tagTypes.description, + }) + .from(tagTypes) + .where(eq(tagTypes.projectId, projectId)); + + tagTypesList.forEach(tagType => { + tagTypesMap.set(tagType.code, tagType.description); + }); + + // 4. 클래스 옵션으로 변환 + const classOptions: UpdatedClassOption[] = tagClassesWithSubclasses.map(cls => ({ + value: cls.code, + label: cls.label, + code: cls.code, + description: cls.label, + tagTypeCode: cls.tagTypeCode, + tagTypeDescription: tagTypesMap.get(cls.tagTypeCode) || cls.tagTypeCode, + subclasses: cls.subclasses || [], + subclassRemark: cls.subclassRemark || {}, + })); + + return classOptions; + } catch (error) { + console.error("Error fetching class options with subclasses:", error); + throw new Error("Failed to fetch class options"); + } +} +interface SubFieldDef { + name: string + label: string + type: "select" | "text" + options: { value: string; label: string }[] + expression: string | null + delimiter: string | null +} + +export async function getSubfieldsByTagType( + tagTypeCode: string, + selectedPackageId: number, + subclassRemark: string = "", + subclass: string = "", +) { + try { + // 1. 먼저 contractItems에서 projectId 조회 + const packageInfo = await db + .select({ + projectId: contracts.projectId + }) + .from(contractItems) + .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) + .where(eq(contractItems.id, selectedPackageId)) + .limit(1); + + if (packageInfo.length === 0) { + throw new Error(`Contract item with ID ${selectedPackageId} not found`); + } + + const projectId = packageInfo[0].projectId; + + // 2. 올바른 projectId를 사용하여 tagSubfields 조회 + const rows = await db + .select() + .from(tagSubfields) + .where( + and( + eq(tagSubfields.tagTypeCode, tagTypeCode), + eq(tagSubfields.projectId, projectId) + ) + ) + .orderBy(asc(tagSubfields.sortOrder)); + + // 각 row -> SubFieldDef + const formattedSubFields: SubFieldDef[] = []; + for (const sf of rows) { + // projectId가 필요한 경우 getSubfieldType과 getSubfieldOptions 함수에도 전달 + const subfieldType = await getSubfieldType(sf.attributesId, projectId); + const subclassMatched =subclassRemark.includes(sf.attributesId ) ? subclass: null + + const subfieldOptions = subfieldType === "select" + ? await getSubfieldOptions(sf.attributesId, projectId, subclassMatched) // subclassRemark 파라미터 추가 + : []; + + formattedSubFields.push({ + name: sf.attributesId.toLowerCase(), + label: sf.attributesDescription, + type: subfieldType, + options: subfieldOptions, + expression: sf.expression, + delimiter: sf.delimiter, + }); + } + + return { subFields: formattedSubFields }; + } catch (error) { + console.error("Error fetching subfields by tag type:", error); + throw new Error("Failed to fetch subfields"); + } +} + + + +async function getSubfieldType(attributesId: string, projectId: number): Promise<"select" | "text"> { + const optRows = await db + .select() + .from(tagSubfieldOptions) + .where(and(eq(tagSubfieldOptions.attributesId, attributesId), eq(tagSubfieldOptions.projectId, projectId))) + + return optRows.length > 0 ? "select" : "text" +} + +export interface SubfieldOption { + /** + * 옵션의 실제 값 (데이터베이스에 저장될 값) + * 예: "PM", "AA", "VB", "01" 등 + */ + value: string; + + /** + * 옵션의 표시 레이블 (사용자에게 보여질 텍스트) + * 예: "Pump", "Pneumatic Motor", "Ball Valve" 등 + */ + label: string; +} + + + +/** + * SubField의 옵션 목록을 가져오는 보조 함수 + */ +async function getSubfieldOptions( + attributesId: string, + projectId: number, + subclass: string = "" +): Promise<SubfieldOption[]> { + try { + // 1. subclassRemark가 있는 경우 API에서 코드 리스트 가져와서 필터링 + if (subclass && subclass.trim() !== "") { + // 프로젝트 코드를 projectId로부터 조회 + const projectInfo = await db + .select({ + code: projects.code + }) + .from(projects) + .where(eq(projects.id, projectId)) + .limit(1); + + if (projectInfo.length === 0) { + throw new Error(`Project with ID ${projectId} not found`); + } + + const projectCode = projectInfo[0].code; + + // API에서 코드 리스트 가져오기 + const codeListValues = await getCodeListsByID(projectCode); + + // 서브클래스 리마크 값들을 분리 (쉼표, 공백 등으로 구분) + const remarkValues = subclass + .split(/[,\s]+/) // 쉼표나 공백으로 분리 + .map(val => val.trim()) + .filter(val => val.length > 0); + + if (remarkValues.length > 0) { + // REMARK 필드가 remarkValues 중 하나를 포함하고 있는 항목들 필터링 + const filteredCodeValues = codeListValues.filter(codeValue => + remarkValues.some(remarkValue => + // 대소문자 구분 없이 포함 여부 확인 + codeValue.VALUE.toLowerCase().includes(remarkValue.toLowerCase()) || + remarkValue.toLowerCase().includes(codeValue.VALUE.toLowerCase()) + ) + ); + + // 필터링된 결과를 PRNT_VALUE -> value, DESC -> label로 변환 + return filteredCodeValues.map((codeValue) => ({ + value: codeValue.PRNT_VALUE, + label: codeValue.DESC + })); + } + } + + // 2. subclassRemark가 없는 경우 기존 방식으로 DB에서 조회 + const allOptions = await db + .select({ + code: tagSubfieldOptions.code, + label: tagSubfieldOptions.label + }) + .from(tagSubfieldOptions) + .where( + and( + eq(tagSubfieldOptions.attributesId, attributesId), + eq(tagSubfieldOptions.projectId, projectId), + ) + ); + + return allOptions.map((row) => ({ + value: row.code, + label: row.label + })); + } catch (error) { + console.error(`Error fetching filtered options for attribute ${attributesId}:`, error); + return []; + } +} + +export interface UpdatedClassOption extends ClassOption { + tagTypeCode: string + tagTypeDescription?: string + subclasses: {id: string, desc: string}[] + subclassRemark: Record<string, string> +} + +/** + * Tag Type 목록을 가져오는 함수 + * 이제 tagTypes 테이블에서 직접 데이터를 가져옴 + */ +export async function getTagTypes(): Promise<{ options: TagTypeOption[] }> { + return unstable_cache( + async () => { + console.log(`[Server] Fetching tag types from tagTypes table`) + + try { + // 이제 tagSubfields가 아닌 tagTypes 테이블에서 직접 조회 + const result = await db + .select({ + code: tagTypes.code, + description: tagTypes.description, + }) + .from(tagTypes) + .orderBy(tagTypes.description); + + // TagTypeOption 형식으로 변환 + const tagTypeOptions: TagTypeOption[] = result.map(item => ({ + id: item.code, // id 필드에 code 값 할당 + label: item.description, // label 필드에 description 값 할당 + })); + + console.log(`[Server] Found ${tagTypeOptions.length} tag types`) + return { options: tagTypeOptions }; + } catch (error) { + console.error('[Server] Error fetching tag types:', error) + return { options: [] } + } + }, + ['tag-types-list'], + { + revalidate: 3600, // 1시간 캐시 + tags: ['tag-types'] + } + )() +} + +/** + * TagTypeOption 인터페이스 정의 + */ +export interface TagTypeOption { + id: string; // tagTypes.code 값 + label: string; // tagTypes.description 값 +} + +export async function getProjectIdFromContractItemId(contractItemId: number): Promise<number | null> { + try { + // First get the contractId from contractItems + const contractItem = await db.query.contractItems.findFirst({ + where: eq(contractItems.id, contractItemId), + columns: { + contractId: true + } + }); + + if (!contractItem) return null; + + // Then get the projectId from contracts + const contract = await db.query.contracts.findFirst({ + where: eq(contracts.id, contractItem.contractId), + columns: { + projectId: true + } + }); + + return contract?.projectId || null; + } catch (error) { + console.error("Error fetching projectId:", error); + return null; + } +}
\ No newline at end of file diff --git a/lib/tags-plant/table/add-tag-dialog.tsx b/lib/tags-plant/table/add-tag-dialog.tsx new file mode 100644 index 00000000..9c82bf1a --- /dev/null +++ b/lib/tags-plant/table/add-tag-dialog.tsx @@ -0,0 +1,997 @@ +"use client" + +import * as React from "react" +import { useRouter, useParams } from "next/navigation" +import { useForm, useWatch, useFieldArray } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +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 { useTranslation } from "@/i18n/client" + +import type { CreateTagSchema } from "@/lib/tags/validations" +import { createTagSchema } from "@/lib/tags/validations" +import { + createTag, + getSubfieldsByTagType, + getClassOptions, + type ClassOption, + TagTypeOption, +} from "@/lib/tags/service" +import { ScrollArea } from "@/components/ui/scroll-area" + +// Updated to support multiple rows and subclass +interface MultiTagFormValues { + class: string; + tagType: string; + subclass: string; // 새로 추가된 서브클래스 필드 + rows: Array<{ + [key: string]: string; + tagNo: string; + description: string; + }>; +} + +// SubFieldDef for clarity +interface SubFieldDef { + name: string + label: string + type: "select" | "text" + options?: { value: string; label: string }[] + expression?: string + delimiter?: string +} + +// 업데이트된 클래스 옵션 인터페이스 (서브클래스 정보 포함) +interface UpdatedClassOption extends ClassOption { + tagTypeCode: string + tagTypeDescription?: string + subclasses: { + id: string; + desc: string; + }[] // 서브클래스 배열 추가 + subclassRemark: Record<string, string> // 서브클래스 리마크 추가 +} + +interface AddTagDialogProps { + selectedPackageId: number +} + +export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) { + const router = useRouter() + const params = useParams() + const lng = (params?.lng as string) || "ko" + const { t } = useTranslation(lng, "engineering") + + const [open, setOpen] = React.useState(false) + const [tagTypeList, setTagTypeList] = React.useState<TagTypeOption[]>([]) + const [selectedTagTypeCode, setSelectedTagTypeCode] = React.useState<string | null>(null) + const [selectedClassOption, setSelectedClassOption] = React.useState<UpdatedClassOption | null>(null) + const [selectedSubclass, setSelectedSubclass] = React.useState<string>("") + const [subFields, setSubFields] = React.useState<SubFieldDef[]>([]) + const [classOptions, setClassOptions] = React.useState<UpdatedClassOption[]>([]) + const [classSearchTerm, setClassSearchTerm] = React.useState("") + const [isLoadingClasses, setIsLoadingClasses] = React.useState(false) + const [isLoadingSubFields, setIsLoadingSubFields] = React.useState(false) + const [isSubmitting, setIsSubmitting] = React.useState(false) + + // ID management + const selectIdRef = React.useRef(0) + const getUniqueSelectId = React.useCallback(() => `select-${selectIdRef.current++}`, []) + const fieldIdsRef = React.useRef<Record<string, string>>({}) + const classOptionIdsRef = React.useRef<Record<string, string>>({}) + + console.log(selectedPackageId, "tag") + + // --------------- + // Load Class Options (서브클래스 정보 포함) + // --------------- + React.useEffect(() => { + const loadClassOptions = async () => { + setIsLoadingClasses(true) + try { + // getClassOptions 함수가 서브클래스 정보도 포함하도록 수정되었다고 가정 + const result = await getClassOptions(selectedPackageId) + setClassOptions(result) + } catch (err) { + toast.error(t("toast.classOptionsLoadFailed")) + } finally { + setIsLoadingClasses(false) + } + } + + if (open) { + loadClassOptions() + } + }, [open, selectedPackageId]) + + // --------------- + // react-hook-form with fieldArray support for multiple rows + // --------------- + const form = useForm<MultiTagFormValues>({ + defaultValues: { + tagType: "", + class: "", + subclass: "", // 서브클래스 필드 추가 + rows: [{ + tagNo: "", + description: "" + }] + }, + }) + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "rows" + }) + + // --------------- + // 서브클래스별로 필터링된 서브필드 로드 + // --------------- + async function loadFilteredSubFieldsByTagTypeCode(tagTypeCode: string, subclassRemark: string, subclass: string) { + setIsLoadingSubFields(true) + try { + // 수정된 getSubfieldsByTagType 함수 호출 (subclassRemark 파라미터 추가) + const { subFields: apiSubFields } = await getSubfieldsByTagType(tagTypeCode, selectedPackageId, subclassRemark, subclass) + 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(t("toast.subfieldsLoadFailed")) + setSubFields([]) + return false + } finally { + setIsLoadingSubFields(false) + } + } + + // --------------- + // Handle class selection + // --------------- + async function handleSelectClass(classOption: UpdatedClassOption) { + form.setValue("class", classOption.label) + form.setValue("subclass", "") // 서브클래스 초기화 + setSelectedClassOption(classOption) + setSelectedSubclass("") + + if (classOption.tagTypeCode) { + setSelectedTagTypeCode(classOption.tagTypeCode) + // If you have tagTypeList, you can find the label + const tagType = tagTypeList.find(t => t.id === classOption.tagTypeCode) + if (tagType) { + form.setValue("tagType", tagType.label) + } else if (classOption.tagTypeDescription) { + form.setValue("tagType", classOption.tagTypeDescription) + } + + // 서브클래스가 있으면 서브필드 로딩을 하지 않고 대기 + if (classOption.subclasses && classOption.subclasses.length > 0) { + setSubFields([]) // 서브클래스 선택을 기다림 + } else { + // 서브클래스가 없으면 바로 서브필드 로딩 + await loadFilteredSubFieldsByTagTypeCode(classOption.tagTypeCode, "", "") + } + } + } + + // --------------- + // Handle subclass selection + // --------------- + async function handleSelectSubclass(subclassCode: string) { + if (!selectedClassOption || !selectedTagTypeCode) return + + setSelectedSubclass(subclassCode) + form.setValue("subclass", subclassCode) + + // 선택된 서브클래스의 리마크 값 가져오기 + const subclassRemarkValue = selectedClassOption.subclassRemark[subclassCode] || "" + + // 리마크 값으로 필터링된 서브필드 로드 + await loadFilteredSubFieldsByTagTypeCode(selectedTagTypeCode, subclassRemarkValue, subclassCode) + } + + // --------------- + // Build TagNo from subfields automatically for each row + // --------------- + React.useEffect(() => { + if (subFields.length === 0) { + return; + } + + const subscription = form.watch((value) => { + if (!value.rows || subFields.length === 0) { + return; + } + + const rows = [...value.rows]; + rows.forEach((row, rowIndex) => { + if (!row) return; + + let combined = ""; + subFields.forEach((sf, idx) => { + const fieldValue = row[sf.name] || ""; + + // delimiter를 앞에 붙이기 (첫 번째 필드가 아니고, 현재 필드에 값이 있고, delimiter가 있는 경우) + if (idx > 0 && fieldValue && sf.delimiter) { + combined += sf.delimiter; + } + + combined += fieldValue; + }); + + const currentTagNo = form.getValues(`rows.${rowIndex}.tagNo`); + if (currentTagNo !== combined) { + form.setValue(`rows.${rowIndex}.tagNo`, combined, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true, + }); + } + }); + }); + + return () => subscription.unsubscribe(); + }, [subFields, form]); + + // --------------- + // Check if tag numbers are valid + // --------------- + const areAllTagNosValid = React.useMemo(() => { + const rows = form.getValues("rows"); + return rows.every(row => { + const tagNo = row.tagNo; + return tagNo && tagNo.trim() !== "" && !tagNo.includes("??"); + }); + }, [form.watch()]); + + // --------------- + // Submit handler for multiple tags (서브클래스 정보 포함) + // --------------- + async function onSubmit(data: MultiTagFormValues) { + if (!selectedPackageId) { + toast.error(t("toast.noSelectedPackageId")); + return; + } + + setIsSubmitting(true); + try { + const successfulTags = []; + const failedTags = []; + + // Process each row + for (const row of data.rows) { + // Create tag data from the row and shared class/tagType/subclass + const tagData: CreateTagSchema = { + tagType: data.tagType, + class: data.class, + // subclass: data.subclass, // 서브클래스 정보 추가 + tagNo: row.tagNo, + description: row.description, + ...Object.fromEntries( + subFields.map(field => [field.name, row[field.name] || ""]) + ), + // Add any required default fields from the original form + functionCode: row.functionCode || "", + seqNumber: row.seqNumber || "", + valveAcronym: row.valveAcronym || "", + processUnit: row.processUnit || "", + }; + + try { + const res = await createTag(tagData, selectedPackageId); + if ("error" in res) { + console.log(res.error) + 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}${t("toast.tagsCreatedSuccess")}`); + } + + if (failedTags.length > 0) { + console.log("Failed tags:", failedTags); + toast.error(`${failedTags.length}${t("toast.tagsCreateFailed")}`); + } + + // Refresh the page + router.refresh(); + + // Reset the form and close dialog if all successful + if (failedTags.length === 0) { + form.reset(); + setOpen(false); + } + } catch (err) { + toast.error(t("toast.tagProcessingFailed")); + } 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); + 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 }; + + newRow.tagNo = ""; + append(newRow); + 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)}`, + [] + ) + + return ( + <FormItem className="w-1/3"> + <FormLabel>{t("labels.class")}</FormLabel> + <FormControl> + <Popover open={popoverOpen} onOpenChange={setPopoverOpen}> + <PopoverTrigger asChild> + <Button + key={buttonId} + type="button" + variant="outline" + className="w-full justify-between relative h-9" + disabled={isLoadingClasses} + > + {isLoadingClasses ? ( + <> + <span>{t("messages.loadingClasses")}</span> + <Loader2 className="ml-2 h-4 w-4 animate-spin" /> + </> + ) : ( + <> + <span className="truncate mr-1 flex-grow text-left"> + {field.value || t("placeholders.selectClass")} + </span> + <ChevronsUpDown className="h-4 w-4 opacity-50 flex-shrink-0" /> + </> + )} + </Button> + </PopoverTrigger> + <PopoverContent key={popoverContentId} className="w-[300px] p-0" style={{width:480}}> + <Command key={commandId}> + <CommandInput + key={`${commandId}-input`} + placeholder={t("placeholders.searchClass")} + value={classSearchTerm} + onValueChange={setClassSearchTerm} + /> + + <CommandList key={`${commandId}-list`} className="max-h-[300px]" onWheel={(e) => { + e.stopPropagation(); // 이벤트 전파 차단 + const target = e.currentTarget; + target.scrollTop += e.deltaY; // 직접 스크롤 처리 + }}> + <CommandEmpty key={`${commandId}-empty`}>{t("messages.noSearchResults")}</CommandEmpty> + <CommandGroup key={`${commandId}-group`}> + {classOptions.map((opt, optIndex) => { + if (!classOptionIdsRef.current[opt.code]) { + classOptionIdsRef.current[opt.code] = + `class-${opt.code}-${Date.now()}-${Math.random() + .toString(36) + .slice(2, 9)}` + } + const optionId = classOptionIdsRef.current[opt.code] + + return ( + <CommandItem + key={`${optionId}-${optIndex}`} + onSelect={() => { + field.onChange(opt.label) + setPopoverOpen(false) + handleSelectClass(opt) + }} + value={opt.label} + className="truncate" + title={opt.label} + > + <span className="truncate">{opt.label}</span> + <Check + key={`${optionId}-check`} + className={cn( + "ml-auto h-4 w-4 flex-shrink-0", + field.value === opt.label ? "opacity-100" : "opacity-0" + )} + /> + </CommandItem> + ) + })} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + </FormControl> + <FormMessage /> + </FormItem> + ) + } + + // --------------- + // Render Subclass field (새로 추가) + // --------------- + function renderSubclassField(field: any) { + const hasSubclasses = selectedClassOption?.subclasses && selectedClassOption.subclasses.length > 0 + + if (!hasSubclasses) { + return null + } + + return ( + <FormItem className="w-1/3"> + <FormLabel>Item Class</FormLabel> + <FormControl> + <Select + value={field.value || ""} + onValueChange={(value) => { + field.onChange(value) + handleSelectSubclass(value) + }} + disabled={!selectedClassOption} + > + <SelectTrigger className="h-9"> + <SelectValue placeholder={t("placeholders.selectSubclass")} /> + </SelectTrigger> + <SelectContent> + {selectedClassOption?.subclasses.map((subclass) => ( + <SelectItem key={subclass.id} value={subclass.id}> + {subclass.desc} + </SelectItem> + ))} + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + ) + } + + // --------------- + // Render TagType field (readonly after class selection) + // --------------- + function renderTagTypeField(field: any) { + const isReadOnly = !!selectedTagTypeCode + const inputId = React.useMemo( + () => + `tag-type-input-${isReadOnly ? "readonly" : "editable"}-${Date.now()}-${Math.random() + .toString(36) + .slice(2, 9)}`, + [isReadOnly] + ) + + const width = selectedClassOption?.subclasses && selectedClassOption.subclasses.length > 0 ? "w-1/3" : "w-2/3" + + return ( + <FormItem className={width}> + <FormLabel>{t("labels.tagType")}</FormLabel> + <FormControl> + {isReadOnly ? ( + <div className="relative"> + <Input + key={`tag-type-readonly-${inputId}`} + {...field} + readOnly + className="h-9 bg-muted" + /> + </div> + ) : ( + <Input + key={`tag-type-placeholder-${inputId}`} + {...field} + readOnly + placeholder={t("placeholders.autoSetByClass")} + className="h-9 bg-muted" + /> + )} + </FormControl> + <FormMessage /> + </FormItem> + ) + } + + // --------------- + // Render the table of subfields + // --------------- + function renderTagTable() { + if (isLoadingSubFields) { + return ( + <div className="flex justify-center items-center py-8"> + <Loader2 className="h-8 w-8 animate-spin text-primary" /> + <div className="ml-3 text-muted-foreground">{t("messages.loadingFields")}</div> + </div> + ) + } + + if (subFields.length === 0 && selectedTagTypeCode) { + const message = selectedClassOption?.subclasses && selectedClassOption.subclasses.length > 0 + ? t("messages.selectSubclassFirst") + : t("messages.noFieldsForTagType") + + return ( + <div className="py-4 text-center text-muted-foreground"> + {message} + </div> + ) + } + + if (subFields.length === 0) { + return ( + <div className="py-4 text-center text-muted-foreground"> + {t("messages.selectClassFirst")} + </div> + ) + } + + return ( + <div className="space-y-4"> + {/* 헤더 */} + <div className="flex justify-between items-center"> + <h3 className="text-sm font-medium">{t("sections.tagItems")} ({fields.length}개)</h3> + {!areAllTagNosValid && ( + <Badge variant="destructive" className="ml-2"> + {t("messages.invalidTagsExist")} + </Badge> + )} + </div> + + {/* 테이블 컨테이너 - 가로/세로 스크롤 모두 적용 */} + <div className="border rounded-md overflow-auto" style={{ maxHeight: '400px', maxWidth: '100%' }}> + <div className="min-w-full overflow-x-auto"> + <Table className="w-full table-fixed"> + <TableHeader className="sticky top-0 bg-muted z-10"> + <TableRow> + <TableHead className="w-10 text-center">#</TableHead> + <TableHead className="w-[120px]"> + <div className="font-medium">{t("labels.tagNo")}</div> + </TableHead> + <TableHead className="w-[180px]"> + <div className="font-medium">{t("labels.description")}</div> + </TableHead> + + {/* Subfields */} + {subFields.map((field, fieldIndex) => ( + <TableHead + key={`header-${field.name}-${fieldIndex}`} + className="w-[120px]" + > + <div className="flex flex-col"> + <div className="font-medium" title={field.label}> + {field.label} + </div> + {field.expression && ( + <div className="text-[10px] text-muted-foreground truncate" title={field.expression}> + {field.expression} + </div> + )} + </div> + </TableHead> + ))} + + <TableHead className="w-[100px] text-center sticky right-0 bg-muted">{t("labels.actions")}</TableHead> + </TableRow> + </TableHeader> + + <TableBody> + {fields.map((item, rowIndex) => ( + <TableRow + key={`row-${item.id}-${rowIndex}`} + className={rowIndex % 2 === 0 ? "bg-background" : "bg-muted/20"} + > + {/* Row number */} + <TableCell className="text-center text-muted-foreground font-mono"> + {rowIndex + 1} + </TableCell> + + {/* Tag No cell */} + <TableCell className="p-1"> + <FormField + control={form.control} + name={`rows.${rowIndex}.tagNo`} + render={({ field }) => ( + <FormItem className="m-0 space-y-0"> + <FormControl> + <div className="relative"> + <Input + {...field} + readOnly + className={cn( + "bg-muted h-8 w-full font-mono text-sm", + field.value?.includes("??") && "border-red-500 bg-red-50" + )} + title={field.value || ""} + /> + {field.value?.includes("??") && ( + <div className="absolute right-2 top-1/2 transform -translate-y-1/2"> + <Badge variant="destructive" className="text-xs"> + ! + </Badge> + </div> + )} + </div> + </FormControl> + </FormItem> + )} + /> + </TableCell> + + {/* Description cell */} + <TableCell className="p-1"> + <FormField + control={form.control} + name={`rows.${rowIndex}.description`} + render={({ field }) => ( + <FormItem className="m-0 space-y-0"> + <FormControl> + <Input + {...field} + className="h-8 w-full" + placeholder={t("placeholders.enterDescription")} + title={field.value || ""} + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + + {/* Subfield cells */} + {subFields.map((sf, sfIndex) => ( + <TableCell + key={`cell-${item.id}-${rowIndex}-${sf.name}-${sfIndex}`} + className="p-1" + > + <FormField + control={form.control} + name={`rows.${rowIndex}.${sf.name}`} + render={({ field }) => ( + <FormItem className="m-0 space-y-0"> + <FormControl> + {sf.type === "select" ? ( + <Select + value={field.value || ""} + onValueChange={field.onChange} + > + <SelectTrigger + className="w-full h-8 truncate" + title={field.value || ""} + > + <SelectValue placeholder={t("placeholders.selectOption")} 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={t("placeholders.enterValue")} + title={field.value || ""} + /> + )} + </FormControl> + </FormItem> + )} + /> + </TableCell> + ))} + + {/* Actions cell */} + <TableCell className="p-1 sticky right-0 bg-white shadow-[-4px_0_4px_rgba(0,0,0,0.05)]"> + <div className="flex justify-center space-x-1"> + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + type="button" + variant="ghost" + size="icon" + className="h-7 w-7" + onClick={() => duplicateRow(rowIndex)} + > + <Copy className="h-3.5 w-3.5 text-muted-foreground" /> + </Button> + </TooltipTrigger> + <TooltipContent side="left"> + <p>{t("tooltips.duplicateRow")}</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + type="button" + variant="ghost" + size="icon" + className={cn( + "h-7 w-7", + fields.length <= 1 && "opacity-50" + )} + onClick={() => fields.length > 1 && remove(rowIndex)} + disabled={fields.length <= 1} + > + <Trash2 className="h-3.5 w-3.5 text-red-500" /> + </Button> + </TooltipTrigger> + <TooltipContent side="left"> + <p>{t("tooltips.deleteRow")}</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + </div> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </div> + + {/* 행 추가 버튼 */} + <Button + type="button" + variant="outline" + className="w-full border-dashed" + onClick={addRow} + disabled={!selectedTagTypeCode || isLoadingSubFields || subFields.length === 0} + > + <Plus className="h-4 w-4 mr-2" /> + {t("buttons.addRow")} + </Button> + </div> + </div> + ); + } + + // --------------- + // Reset IDs/states when dialog closes + // --------------- + React.useEffect(() => { + if (!open) { + fieldIdsRef.current = {} + classOptionIdsRef.current = {} + selectIdRef.current = 0 + } + }, [open]) + + return ( + <Dialog + open={open} + onOpenChange={(o) => { + if (!o) { + form.reset({ + tagType: "", + class: "", + subclass: "", + rows: [{ tagNo: "", description: "" }] + }); + setSelectedTagTypeCode(null); + setSelectedClassOption(null); + setSelectedSubclass(""); + setSubFields([]); + } + setOpen(o); + }} + > + <DialogTrigger asChild> + <Button variant="default" size="sm"> + {t("buttons.addTags")} + </Button> + </DialogTrigger> + + <DialogContent className="max-h-[90vh] max-w-[95vw]" style={{ width: 1500 }}> + <DialogHeader> + <DialogTitle>{t("dialogs.addTag")}</DialogTitle> + <DialogDescription> + {t("dialogs.selectClassToLoadFields")} + </DialogDescription> + </DialogHeader> + + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="space-y-6" + > + {/* 클래스, 서브클래스, 태그 유형 선택 */} + <div className="flex gap-4"> + <FormField + key="class-field" + control={form.control} + name="class" + render={({ field }) => renderClassField(field)} + /> + + <FormField + key="subclass-field" + control={form.control} + name="subclass" + render={({ field }) => renderSubclassField(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: "", + subclass: "", + rows: [{ tagNo: "", description: "" }] + }); + setOpen(false); + setSubFields([]); + setSelectedTagTypeCode(null); + setSelectedClassOption(null); + setSelectedSubclass(""); + }} + disabled={isSubmitting} + > + {t("buttons.cancel")} + </Button> + <Button + type="submit" + disabled={isSubmitting || !areAllTagNosValid || fields.length < 1} + > + {isSubmitting ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + {t("messages.processing")} + </> + ) : ( + `${fields.length}${t("buttons.createTags")}` + )} + </Button> + </div> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/tags-plant/table/delete-tags-dialog.tsx b/lib/tags-plant/table/delete-tags-dialog.tsx new file mode 100644 index 00000000..6a024cda --- /dev/null +++ b/lib/tags-plant/table/delete-tags-dialog.tsx @@ -0,0 +1,151 @@ +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, Trash } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" + +import { removeTags } from "@/lib//tags/service" +import { Tag } from "@/db/schema/vendorData" + +interface DeleteTasksDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + tags: Row<Tag>["original"][] + showTrigger?: boolean + selectedPackageId: number + onSuccess?: () => void +} + +export function DeleteTagsDialog({ + tags, + showTrigger = true, + onSuccess, + selectedPackageId, + ...props +}: DeleteTasksDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onDelete() { + startDeleteTransition(async () => { + const { error } = await removeTags({ + ids: tags.map((tag) => tag.id),selectedPackageId + }) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + toast.success("Tasks deleted") + onSuccess?.() + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="size-4" aria-hidden="true" /> + Delete ({tags.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>Are you absolutely sure?</DialogTitle> + <DialogDescription> + This action cannot be undone. This will permanently delete your{" "} + <span className="font-medium">{tags.length}</span> + {tags.length === 1 ? " tag" : " tags"} from our servers. + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">Cancel</Button> + </DialogClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Delete + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({tags.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>Are you absolutely sure?</DrawerTitle> + <DrawerDescription> + This action cannot be undone. This will permanently delete your{" "} + <span className="font-medium">{tags.length}</span> + {tags.length === 1 ? " tag" : " tags"} from our servers. + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">Cancel</Button> + </DrawerClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + Delete + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +} diff --git a/lib/tags-plant/table/feature-flags-provider.tsx b/lib/tags-plant/table/feature-flags-provider.tsx new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/tags-plant/table/feature-flags-provider.tsx @@ -0,0 +1,108 @@ +"use client" + +import * as React from "react" +import { useQueryState } from "nuqs" + +import { dataTableConfig, type DataTableConfig } from "@/config/data-table" +import { cn } from "@/lib/utils" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" + +type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] + +interface FeatureFlagsContextProps { + featureFlags: FeatureFlagValue[] + setFeatureFlags: (value: FeatureFlagValue[]) => void +} + +const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({ + featureFlags: [], + setFeatureFlags: () => {}, +}) + +export function useFeatureFlags() { + const context = React.useContext(FeatureFlagsContext) + if (!context) { + throw new Error( + "useFeatureFlags must be used within a FeatureFlagsProvider" + ) + } + return context +} + +interface FeatureFlagsProviderProps { + children: React.ReactNode +} + +export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { + const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( + "flags", + { + defaultValue: [], + parse: (value) => value.split(",") as FeatureFlagValue[], + serialize: (value) => value.join(","), + eq: (a, b) => + a.length === b.length && a.every((value, index) => value === b[index]), + clearOnDefault: true, + shallow: false, + } + ) + + return ( + <FeatureFlagsContext.Provider + value={{ + featureFlags, + setFeatureFlags: (value) => void setFeatureFlags(value), + }} + > + <div className="w-full overflow-x-auto"> + <ToggleGroup + type="multiple" + variant="outline" + size="sm" + value={featureFlags} + onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} + className="w-fit gap-0" + > + {dataTableConfig.featureFlags.map((flag, index) => ( + <Tooltip key={flag.value}> + <ToggleGroupItem + value={flag.value} + className={cn( + "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90", + { + "rounded-l-sm border-r-0": index === 0, + "rounded-r-sm": + index === dataTableConfig.featureFlags.length - 1, + } + )} + asChild + > + <TooltipTrigger> + <flag.icon className="size-3.5 shrink-0" aria-hidden="true" /> + {flag.label} + </TooltipTrigger> + </ToggleGroupItem> + <TooltipContent + align="start" + side="bottom" + sideOffset={6} + className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" + > + <div>{flag.tooltipTitle}</div> + <div className="text-xs text-muted-foreground"> + {flag.tooltipDescription} + </div> + </TooltipContent> + </Tooltip> + ))} + </ToggleGroup> + </div> + {children} + </FeatureFlagsContext.Provider> + ) +} diff --git a/lib/tags-plant/table/tag-table-column.tsx b/lib/tags-plant/table/tag-table-column.tsx new file mode 100644 index 00000000..80c25464 --- /dev/null +++ b/lib/tags-plant/table/tag-table-column.tsx @@ -0,0 +1,164 @@ +"use client" + +import * as React from "react" +import { ColumnDef } from "@tanstack/react-table" +import { formatDate } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Ellipsis } from "lucide-react" +// 기존 헤더 컴포넌트 사용 (리사이저가 내장된 헤더는 따로 구현할 예정) +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { Tag } from "@/db/schema/vendorData" +import { DataTableRowAction } from "@/types/table" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<Tag> | null>> +} + +export function getColumns({ + setRowAction, +}: GetColumnsProps): ColumnDef<Tag>[] { + return [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + enableSorting: false, + enableHiding: false, + enableResizing: false, // 체크박스 열은 리사이징 비활성화 + size: 40, + minSize: 40, + maxSize: 40, + }, + + { + accessorKey: "tagNo", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Tag No." /> + ), + cell: ({ row }) => <div>{row.getValue("tagNo")}</div>, + meta: { + excelHeader: "Tag No" + }, + enableResizing: true, // 리사이징 활성화 + minSize: 100, // 최소 너비 + size: 160, // 기본 너비 + }, + { + accessorKey: "description", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Tag Description" /> + ), + cell: ({ row }) => <div>{row.getValue("description")}</div>, + meta: { + excelHeader: "Tag Descripiton" + }, + enableResizing: true, + minSize: 150, + size: 240, + }, + { + accessorKey: "class", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Tag Class" /> + ), + cell: ({ row }) => <div>{row.getValue("class")}</div>, + meta: { + excelHeader: "Tag Class" + }, + enableResizing: true, + minSize: 100, + size: 150, + }, + { + accessorKey: "createdAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Created At" /> + ), + cell: ({ cell }) => formatDate(cell.getValue() as Date, "KR"), + meta: { + excelHeader: "created At" + }, + enableResizing: true, + minSize: 120, + size: 180, + }, + { + accessorKey: "updatedAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Updated At" /> + ), + cell: ({ cell }) => formatDate(cell.getValue() as Date, "KR"), + meta: { + excelHeader: "updated At" + }, + enableResizing: true, + minSize: 120, + size: 180, + }, + { + id: "actions", + cell: function Cell({ row }) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-6" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "update" })} + > + Edit + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "delete" })} + > + Delete + <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + enableResizing: false, // 액션 열은 리사이징 비활성화 + size: 40, + minSize: 40, + maxSize: 40, + enableHiding: false, + }, + ] +}
\ No newline at end of file diff --git a/lib/tags-plant/table/tag-table.tsx b/lib/tags-plant/table/tag-table.tsx new file mode 100644 index 00000000..1986d933 --- /dev/null +++ b/lib/tags-plant/table/tag-table.tsx @@ -0,0 +1,155 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { toSentenceCase } from "@/lib/utils" +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" + +import { getColumns } from "./tag-table-column" +import { Tag } from "@/db/schema/vendorData" +import { DeleteTagsDialog } from "./delete-tags-dialog" +import { TagsTableToolbarActions } from "./tags-table-toolbar-actions" +import { TagsTableFloatingBar } from "./tags-table-floating-bar" +import { getTags } from "../service" +import { UpdateTagSheet } from "./update-tag-sheet" +import { useAtomValue } from 'jotai' +import { selectedModeAtom } from '@/atoms' + +// 여기서 받은 `promises`로부터 태그 목록을 가져와 상태를 세팅 +// 예: "selectedPackageId"는 props로 전달 +interface TagsTableProps { + promises: Promise< [ Awaited<ReturnType<typeof getTags>> ] > + selectedPackageId: number +} + +export function TagsTable({ promises, selectedPackageId }: TagsTableProps) { + // 1) 데이터를 가져옴 (server component -> use(...) pattern) + const [{ data, pageCount }] = React.use(promises) + const selectedMode = useAtomValue(selectedModeAtom) + + const [rowAction, setRowAction] = React.useState<DataTableRowAction<Tag> | null>(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + // Filter fields + const filterFields: DataTableFilterField<Tag>[] = [ + { + id: "tagNo", + label: "Tag Number", + placeholder: "Filter Tag Number...", + }, + ] + + const advancedFilterFields: DataTableAdvancedFilterField<Tag>[] = [ + { + id: "tagNo", + label: "Tag No", + type: "text", + }, + { + id: "tagType", + label: "Tag Type", + type: "text", + }, + { + id: "description", + label: "Description", + type: "text", + }, + { + id: "createdAt", + label: "Created at", + type: "date", + }, + { + id: "updatedAt", + label: "Updated at", + type: "date", + }, + ] + + // 3) useDataTable 훅으로 react-table 구성 + const { table } = useDataTable({ + data: data, // <-- 여기서 tableData 사용 + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + // sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + columnResizeMode: "onEnd", + + }) + + const [isCompact, setIsCompact] = React.useState<boolean>(false) + + + const handleCompactChange = React.useCallback((compact: boolean) => { + setIsCompact(compact) + }, []) + + + return ( + <> + <DataTable + table={table} + compact={isCompact} + + floatingBar={<TagsTableFloatingBar table={table} selectedPackageId={selectedPackageId}/>} + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + enableCompactToggle={true} + compactStorageKey="tagTableCompact" + onCompactChange={handleCompactChange} + > + {/* + 4) ToolbarActions에 tableData, setTableData 넘겨서 + import 시 상태 병합 + */} + <TagsTableToolbarActions + table={table} + selectedPackageId={selectedPackageId} + tableData={data} // <-- pass current data + selectedMode={selectedMode} + /> + </DataTableAdvancedToolbar> + </DataTable> + + <UpdateTagSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + tag={rowAction?.row.original ?? null} + selectedPackageId={selectedPackageId} + /> + + + <DeleteTagsDialog + open={rowAction?.type === "delete"} + onOpenChange={() => setRowAction(null)} + tags={rowAction?.row.original ? [rowAction?.row.original] : []} + showTrigger={false} + onSuccess={() => rowAction?.row.toggleSelected(false)} + selectedPackageId={selectedPackageId} + /> + </> + ) +}
\ No newline at end of file diff --git a/lib/tags-plant/table/tags-export.tsx b/lib/tags-plant/table/tags-export.tsx new file mode 100644 index 00000000..fa85148d --- /dev/null +++ b/lib/tags-plant/table/tags-export.tsx @@ -0,0 +1,158 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { toast } from "sonner" +import ExcelJS from "exceljs" +import { saveAs } from "file-saver" +import { Tag } from "@/db/schema/vendorData" +import { getClassOptions } from "../service" + +/** + * 태그 데이터를 엑셀로 내보내는 함수 (유효성 검사 포함) + * - 별도의 ValidationData 시트에 Tag Class 옵션 데이터를 포함 + * - Tag Class 열에 데이터 유효성 검사(드롭다운)을 적용 + */ +export async function exportTagsToExcel( + table: Table<Tag>, + selectedPackageId: number, + { + filename = "Tags", + excludeColumns = ["select", "actions", "createdAt", "updatedAt"], + maxRows = 5000, // 데이터 유효성 검사를 적용할 최대 행 수 + }: { + filename?: string + excludeColumns?: string[] + maxRows?: number + } = {} +) { + try { + + + // 1. 테이블에서 컬럼 정보 가져오기 + const allTableColumns = table.getAllLeafColumns() + + // 제외할 컬럼 필터링 + const tableColumns = allTableColumns.filter( + (col) => !excludeColumns.includes(col.id) + ) + + // 2. 워크북 및 워크시트 생성 + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet("Tags") + + // 3. Tag Class 옵션 가져오기 + const classOptions = await getClassOptions(selectedPackageId) + + // 4. 유효성 검사 시트 생성 + const validationSheet = workbook.addWorksheet("ValidationData") + validationSheet.state = 'hidden' // 시트 숨김 처리 + + // 4.1. Tag Class 유효성 검사 데이터 추가 + validationSheet.getColumn(1).values = ["Tag Class", ...classOptions.map(opt => opt.label)] + + // 5. 메인 시트에 헤더 추가 + const headers = tableColumns.map((col) => { + const meta = col.columnDef.meta as any + // meta에 excelHeader가 있으면 사용 + if (meta?.excelHeader) { + return meta.excelHeader + } + // 없으면 컬럼 ID 사용 + return col.id + }) + + worksheet.addRow(headers) + + // 6. 헤더 스타일 적용 + 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' } + } + }) + + // 7. 데이터 행 추가 + const rowModel = table.getPrePaginationRowModel() + + rowModel.rows.forEach((row) => { + const rowData = tableColumns.map((col) => { + const value = row.getValue(col.id) + + // 날짜 형식 처리 + if (value instanceof Date) { + return new Date(value).toISOString().split('T')[0] + } + + // value가 null/undefined면 빈 문자열, 객체면 JSON 문자열, 그 외에는 그대로 반환 + if (value == null) return "" + return typeof value === "object" ? JSON.stringify(value) : value + }) + + worksheet.addRow(rowData) + }) + + // 8. Tag Class 열에 데이터 유효성 검사 적용 + const classColIndex = headers.findIndex(header => header === "Tag Class") + + if (classColIndex !== -1) { + const colLetter = worksheet.getColumn(classColIndex + 1).letter + + // 데이터 유효성 검사 설정 + const validation = { + type: 'list' as const, + allowBlank: true, + formulae: [`ValidationData!$A$2:$A$${classOptions.length + 1}`], + showErrorMessage: true, + errorStyle: 'warning' as const, + errorTitle: '유효하지 않은 클래스', + error: '목록에서 클래스를 선택해주세요.' + } + + // 모든 데이터 행 + 추가 행(최대 maxRows까지)에 유효성 검사 적용 + for (let rowIdx = 2; rowIdx <= maxRows; rowIdx++) { + worksheet.getCell(`${colLetter}${rowIdx}`).dataValidation = validation + } + } + + // 9. 컬럼 너비 자동 조정 + tableColumns.forEach((col, index) => { + const column = worksheet.getColumn(index + 1) + const headerLength = headers[index]?.length || 10 + + // 데이터 기반 최대 길이 계산 + let maxLength = headerLength + rowModel.rows.forEach((row) => { + const value = row.getValue(col.id) + if (value != null) { + const valueLength = String(value).length + if (valueLength > maxLength) { + maxLength = valueLength + } + } + }) + + // 너비 설정 (최소 10, 최대 50) + column.width = Math.min(Math.max(maxLength + 2, 10), 50) + }) + + // 10. 파일 다운로드 + const buffer = await workbook.xlsx.writeBuffer() + saveAs( + new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }), + `${filename}_${new Date().toISOString().split('T')[0]}.xlsx` + ) + + return true + } catch (error) { + console.error("Excel export error:", error) + toast.error("Excel 내보내기 중 오류가 발생했습니다.") + return false + } +}
\ No newline at end of file diff --git a/lib/tags-plant/table/tags-table-floating-bar.tsx b/lib/tags-plant/table/tags-table-floating-bar.tsx new file mode 100644 index 00000000..8d55b7ac --- /dev/null +++ b/lib/tags-plant/table/tags-table-floating-bar.tsx @@ -0,0 +1,220 @@ +"use client" + +import * as React from "react" +import { SelectTrigger } from "@radix-ui/react-select" +import { type Table } from "@tanstack/react-table" +import { + ArrowUp, + CheckCircle2, + Download, + Loader, + Trash2, + X, +} from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { Portal } from "@/components/ui/portal" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, +} from "@/components/ui/select" +import { Separator } from "@/components/ui/separator" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { Kbd } from "@/components/kbd" + +import { removeTags } from "@/lib//tags/service" +import { ActionConfirmDialog } from "@/components/ui/action-dialog" +import { Tag } from "@/db/schema/vendorData" + +interface TagsTableFloatingBarProps { + table: Table<Tag> + selectedPackageId: number + +} + + +export function TagsTableFloatingBar({ table, selectedPackageId }: TagsTableFloatingBarProps) { + const rows = table.getFilteredSelectedRowModel().rows + + const [isPending, startTransition] = React.useTransition() + const [action, setAction] = React.useState< + "update-status" | "update-priority" | "export" | "delete" + >() + const [popoverOpen, setPopoverOpen] = React.useState(false) + + // Clear selection on Escape key press + React.useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + table.toggleAllRowsSelected(false) + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [table]) + + + + // 공용 confirm dialog state + const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false) + const [confirmProps, setConfirmProps] = React.useState<{ + title: string + description?: string + onConfirm: () => Promise<void> | void + }>({ + title: "", + description: "", + onConfirm: () => { }, + }) + + // 1) "삭제" Confirm 열기 + function handleDeleteConfirm() { + setAction("delete") + setConfirmProps({ + title: `Delete ${rows.length} tag${rows.length > 1 ? "s" : ""}?`, + description: "This action cannot be undone.", + onConfirm: async () => { + startTransition(async () => { + const { error } = await removeTags({ + ids: rows.map((row) => row.original.id), + selectedPackageId + }) + if (error) { + toast.error(error) + return + } + toast.success("Tags deleted") + table.toggleAllRowsSelected(false) + setConfirmDialogOpen(false) + }) + }, + }) + setConfirmDialogOpen(true) + } + + + + return ( + <Portal > + <div className="fixed inset-x-0 bottom-10 z-50 mx-auto w-fit px-2.5" style={{ bottom: '1.5rem' }}> + <div className="w-full overflow-x-auto"> + <div className="mx-auto flex w-fit items-center gap-2 rounded-md border bg-background p-2 text-foreground shadow"> + <div className="flex h-7 items-center rounded-md border border-dashed pl-2.5 pr-1"> + <span className="whitespace-nowrap text-xs"> + {rows.length} selected + </span> + <Separator orientation="vertical" className="ml-2 mr-1" /> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + className="size-5 hover:border" + onClick={() => table.toggleAllRowsSelected(false)} + > + <X className="size-3.5 shrink-0" aria-hidden="true" /> + </Button> + </TooltipTrigger> + <TooltipContent className="flex items-center border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900"> + <p className="mr-2">Clear selection</p> + <Kbd abbrTitle="Escape" variant="outline"> + Esc + </Kbd> + </TooltipContent> + </Tooltip> + </div> + <Separator orientation="vertical" className="hidden h-5 sm:block" /> + <div className="flex items-center gap-1.5"> + + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border" + onClick={() => { + setAction("export") + + startTransition(() => { + exportTableToExcel(table, { + excludeColumns: ["select", "actions"], + onlySelected: true, + }) + }) + }} + disabled={isPending} + > + {isPending && action === "export" ? ( + <Loader + className="size-3.5 animate-spin" + aria-hidden="true" + /> + ) : ( + <Download className="size-3.5" aria-hidden="true" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Export tasks</p> + </TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border" + onClick={handleDeleteConfirm} + disabled={isPending} + > + {isPending && action === "delete" ? ( + <Loader + className="size-3.5 animate-spin" + aria-hidden="true" + /> + ) : ( + <Trash2 className="size-3.5" aria-hidden="true" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Delete tasks</p> + </TooltipContent> + </Tooltip> + </div> + </div> + </div> + </div> + + + {/* 공용 Confirm Dialog */} + <ActionConfirmDialog + open={confirmDialogOpen} + onOpenChange={setConfirmDialogOpen} + title={confirmProps.title} + description={confirmProps.description} + onConfirm={confirmProps.onConfirm} + isLoading={isPending && (action === "delete" || action === "update-priority" || action === "update-status")} + confirmLabel={ + action === "delete" + ? "Delete" + : action === "update-priority" || action === "update-status" + ? "Update" + : "Confirm" + } + confirmVariant={ + action === "delete" ? "destructive" : "default" + } + /> + </Portal> + ) +} diff --git a/lib/tags-plant/table/tags-table-toolbar-actions.tsx b/lib/tags-plant/table/tags-table-toolbar-actions.tsx new file mode 100644 index 00000000..cc2d82b4 --- /dev/null +++ b/lib/tags-plant/table/tags-table-toolbar-actions.tsx @@ -0,0 +1,758 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { toast } from "sonner" +import ExcelJS from "exceljs" +import { saveAs } from "file-saver" + +import { Button } from "@/components/ui/button" +import { Download, Upload, Loader2, RefreshCcw } from "lucide-react" +import { Tag, TagSubfields } from "@/db/schema/vendorData" +import { exportTagsToExcel } from "./tags-export" +import { AddTagDialog } from "./add-tag-dialog" +import { fetchTagSubfieldOptions, getTagNumberingRules, } from "@/lib/tag-numbering/service" +import { bulkCreateTags, getClassOptions, getProjectIdFromContractItemId, getSubfieldsByTagType } from "../service" +import { DeleteTagsDialog } from "./delete-tags-dialog" +import { useRouter } from "next/navigation" // Add this import +import { decryptWithServerAction } from "@/components/drm/drmUtils" + +// 태그 번호 검증을 위한 인터페이스 +interface TagNumberingRule { + attributesId: string; + attributesDescription: string; + expression: string | null; + delimiter: string | null; + sortOrder: number; +} + +interface TagOption { + code: string; + label: string; +} + +interface ClassOption { + code: string; + label: string; + tagTypeCode: string; + tagTypeDescription: string; +} + +// 서브필드 정의 +interface SubFieldDef { + name: string; + label: string; + type: "select" | "text"; + options?: { value: string; label: string }[]; + expression?: string; + delimiter?: string; +} + +interface TagsTableToolbarActionsProps { + /** react-table 객체 */ + table: Table<Tag> + /** 현재 선택된 패키지 ID */ + selectedPackageId: number + /** 현재 태그 목록(상태) */ + tableData: Tag[] + /** 태그 목록을 갱신하는 setState */ + selectedMode: string +} + +/** + * TagsTableToolbarActions: + * - Import 버튼 -> Excel 파일 파싱 & 유효성 검사 (Class 기반 검증 추가) + * - 에러 발생 시: state는 그대로 두고, 오류가 적힌 엑셀만 재다운로드 + * - 정상인 경우: tableData에 병합 + * - Export 버튼 -> 유효성 검사가 포함된 Excel 내보내기 + */ +export function TagsTableToolbarActions({ + table, + selectedPackageId, + tableData, + selectedMode +}: TagsTableToolbarActionsProps) { + const router = useRouter() // Add this line + + const [isPending, setIsPending] = React.useState(false) + const [isExporting, setIsExporting] = React.useState(false) + const fileInputRef = React.useRef<HTMLInputElement>(null) + + const [isLoading, setIsLoading] = React.useState(false) + const [syncId, setSyncId] = React.useState<string | null>(null) + const pollingRef = React.useRef<NodeJS.Timeout | null>(null) + + // 태그 타입별 넘버링 룰 캐시 + const [tagNumberingRules, setTagNumberingRules] = React.useState<Record<string, TagNumberingRule[]>>({}) + const [tagOptionsCache, setTagOptionsCache] = React.useState<Record<string, TagOption[]>>({}) + + // 클래스 옵션 및 서브필드 캐시 + const [classOptions, setClassOptions] = React.useState<ClassOption[]>([]) + const [subfieldCache, setSubfieldCache] = React.useState<Record<string, SubFieldDef[]>>({}) + + // 컴포넌트 마운트 시 클래스 옵션 로드 + React.useEffect(() => { + const loadClassOptions = async () => { + try { + const options = await getClassOptions(selectedPackageId) + setClassOptions(options) + } catch (error) { + console.error("Failed to load class options:", error) + } + } + + loadClassOptions() + }, [selectedPackageId]) + + // 숨겨진 <input>을 클릭 + function handleImportClick() { + fileInputRef.current?.click() + } + + // 태그 넘버링 룰 가져오기 + const fetchTagNumberingRules = React.useCallback(async (tagType: string): Promise<TagNumberingRule[]> => { + // 이미 캐시에 있으면 캐시된 값 사용 + if (tagNumberingRules[tagType]) { + return tagNumberingRules[tagType] + } + + try { + // 서버 액션 직접 호출 + const rules = await getTagNumberingRules(tagType) + + // 캐시에 저장 + setTagNumberingRules(prev => ({ + ...prev, + [tagType]: rules + })) + + return rules + } catch (error) { + console.error(`Error fetching rules for ${tagType}:`, error) + return [] + } + }, [tagNumberingRules]) + + const [projectId, setProjectId] = React.useState<number | null>(null); + + // Add useEffect to fetch projectId when selectedPackageId changes + React.useEffect(() => { + const fetchProjectId = async () => { + if (selectedPackageId) { + try { + const pid = await getProjectIdFromContractItemId(selectedPackageId); + setProjectId(pid); + } catch (error) { + console.error("Failed to fetch project ID:", error); + toast.error("Failed to load project data"); + } + } + }; + + fetchProjectId(); + }, [selectedPackageId]); + + // 특정 attributesId에 대한 옵션 가져오기 + const fetchOptions = React.useCallback(async (attributesId: string): Promise<TagOption[]> => { + // Cache check remains the same + if (tagOptionsCache[attributesId]) { + return tagOptionsCache[attributesId]; + } + + try { + // Only pass projectId if it's not null + let options: TagOption[]; + if (projectId !== null) { + options = await fetchTagSubfieldOptions(attributesId, projectId); + } else { + options = [] + } + + // Update cache + setTagOptionsCache(prev => ({ + ...prev, + [attributesId]: options + })); + + return options; + } catch (error) { + console.error(`Error fetching options for ${attributesId}:`, error); + return []; + } + }, [tagOptionsCache, projectId]); + + // 클래스 라벨로 태그 타입 코드 찾기 + const getTagTypeCodeByClassLabel = React.useCallback((classLabel: string): string | null => { + const classOption = classOptions.find(opt => opt.label === classLabel) + return classOption?.tagTypeCode || null + }, [classOptions]) + + // 태그 타입에 따른 서브필드 가져오기 + const fetchSubfieldsByTagType = React.useCallback(async (tagTypeCode: string): Promise<SubFieldDef[]> => { + // 이미 캐시에 있으면 캐시된 값 사용 + if (subfieldCache[tagTypeCode]) { + return subfieldCache[tagTypeCode] + } + + try { + const { subFields } = await getSubfieldsByTagType(tagTypeCode, selectedPackageId) + + // API 응답을 SubFieldDef 형식으로 변환 + const formattedSubFields: SubFieldDef[] = subFields.map(field => ({ + name: field.name, + label: field.label, + type: field.type, + options: field.options || [], + expression: field.expression ?? undefined, + delimiter: field.delimiter ?? undefined, + })) + + // 캐시에 저장 + setSubfieldCache(prev => ({ + ...prev, + [tagTypeCode]: formattedSubFields + })) + + return formattedSubFields + } catch (error) { + console.error(`Error fetching subfields for tagType ${tagTypeCode}:`, error) + return [] + } + }, [subfieldCache]) + + // Class 기반 태그 번호 형식 검증 + const validateTagNumberByClass = React.useCallback(async ( + tagNo: string, + classLabel: string + ): Promise<string> => { + if (!tagNo) return "Tag number is empty." + if (!classLabel) return "Class is empty." + + try { + // 1. 클래스 라벨로 태그 타입 코드 찾기 + const tagTypeCode = getTagTypeCodeByClassLabel(classLabel) + if (!tagTypeCode) { + return `No tag type found for class '${classLabel}'.` + } + + // 2. 태그 타입 코드로 서브필드 가져오기 + const subfields = await fetchSubfieldsByTagType(tagTypeCode) + if (!subfields || subfields.length === 0) { + return `No subfields found for tag type code '${tagTypeCode}'.` + } + + // 3. 태그 번호를 파트별로 분석 + let remainingTagNo = tagNo + let currentPosition = 0 + + for (const field of subfields) { + // 구분자 확인 + const delimiter = field.delimiter || "" + + // 다음 구분자 위치 또는 문자열 끝 + let nextDelimiterPos + if (delimiter && remainingTagNo.includes(delimiter)) { + nextDelimiterPos = remainingTagNo.indexOf(delimiter) + } else { + nextDelimiterPos = remainingTagNo.length + } + + // 현재 파트 추출 + const part = remainingTagNo.substring(0, nextDelimiterPos) + + // 비어있으면 오류 + if (!part) { + return `Empty part for field '${field.label}'.` + } + + // 정규식 검증 + if (field.expression) { + try { + // 중복된 ^, $ 제거 후 다시 추가 + let cleanPattern = field.expression; + + // 시작과 끝의 ^, $ 제거 + cleanPattern = cleanPattern.replace(/^\^/, '').replace(/\$$/, ''); + + // 정규식 생성 (항상 전체 매칭) + const regex = new RegExp(`^${cleanPattern}$`); + + if (!regex.test(part)) { + return `Part '${part}' for field '${field.label}' does not match the pattern '${field.expression}'.`; + } + } catch (error) { + console.error(`Invalid regex pattern: ${field.expression}`, error); + return `Invalid pattern for field '${field.label}': ${field.expression}`; + } + } + // 선택 옵션 검증 + if (field.type === "select" && field.options && field.options.length > 0) { + const validValues = field.options.map(opt => opt.value) + if (!validValues.includes(part)) { + return `'${part}' is not a valid value for field '${field.label}'. Valid options: ${validValues.join(", ")}.` + } + } + + // 남은 문자열 업데이트 + if (delimiter && nextDelimiterPos < remainingTagNo.length) { + remainingTagNo = remainingTagNo.substring(nextDelimiterPos + delimiter.length) + } else { + remainingTagNo = "" + break + } + } + + // 문자열이 남아있으면 오류 + if (remainingTagNo) { + return `Tag number has extra parts: '${remainingTagNo}'.` + } + + return "" // 오류 없음 + } catch (error) { + console.error("Error validating tag number by class:", error) + return "Error validating tag number format." + } + }, [getTagTypeCodeByClassLabel, fetchSubfieldsByTagType]) + + // 기존 태그 번호 검증 함수 (기존 코드를 유지) + const validateTagNumber = React.useCallback(async (tagNo: string, tagType: string): Promise<string> => { + if (!tagNo) return "Tag number is empty." + if (!tagType) return "Tag type is empty." + + try { + // 1. 태그 타입에 대한 넘버링 룰 가져오기 + const rules = await fetchTagNumberingRules(tagType) + if (!rules || rules.length === 0) { + return `No numbering rules found for tag type '${tagType}'.` + } + + // 2. 정렬된 룰 (sortOrder 기준) + const sortedRules = [...rules].sort((a, b) => a.sortOrder - b.sortOrder) + + // 3. 태그 번호를 파트로 분리 + let remainingTagNo = tagNo + let currentPosition = 0 + + for (const rule of sortedRules) { + // 마지막 룰이 아니고 구분자가 있으면 + const delimiter = rule.delimiter || "" + + // 다음 구분자 위치 찾기 또는 문자열 끝 + let nextDelimiterPos + if (delimiter && remainingTagNo.includes(delimiter)) { + nextDelimiterPos = remainingTagNo.indexOf(delimiter) + } else { + nextDelimiterPos = remainingTagNo.length + } + + // 현재 파트 추출 + const part = remainingTagNo.substring(0, nextDelimiterPos) + + // 표현식이 있으면 검증 + if (rule.expression) { + const regex = new RegExp(`^${rule.expression}$`) + if (!regex.test(part)) { + return `Part '${part}' does not match the pattern '${rule.expression}' for ${rule.attributesDescription}.` + } + } + + // 옵션이 있는 경우 유효한 코드인지 확인 + const options = await fetchOptions(rule.attributesId) + if (options.length > 0) { + const isValidCode = options.some(opt => opt.code === part) + if (!isValidCode) { + return `'${part}' is not a valid code for ${rule.attributesDescription}. Valid options: ${options.map(o => o.code).join(', ')}.` + } + } + + // 남은 문자열 업데이트 + if (delimiter && nextDelimiterPos < remainingTagNo.length) { + remainingTagNo = remainingTagNo.substring(nextDelimiterPos + delimiter.length) + } else { + remainingTagNo = "" + break + } + + // 모든 룰을 처리했는데 문자열이 남아있으면 오류 + if (remainingTagNo && rule === sortedRules[sortedRules.length - 1]) { + return `Tag number has extra parts: '${remainingTagNo}'.` + } + } + + // 문자열이 남아있으면 오류 + if (remainingTagNo) { + return `Tag number has unprocessed parts: '${remainingTagNo}'.` + } + + return "" // 오류 없음 + } catch (error) { + console.error("Error validating tag number:", error) + return "Error validating tag number." + } + }, [fetchTagNumberingRules, fetchOptions]) + + /** + * 개선된 handleFileChange 함수 + * 1) ExcelJS로 파일 파싱 + * 2) 헤더 -> meta.excelHeader 매핑 + * 3) 각 행 유효성 검사 (Class 기반 검증 추가) + * 4) 에러 행 있으면 → 오류 메시지 기록 + 재다운로드 (상태 변경 안 함) + * 5) 정상 행만 importedRows 로 → 병합 + */ + async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) { + const file = e.target.files?.[0] + if (!file) return + + // 파일 input 초기화 + e.target.value = "" + setIsPending(true) + + try { + // 1) Workbook 로드 + const workbook = new ExcelJS.Workbook() + // const arrayBuffer = await file.arrayBuffer() + const arrayBuffer = await decryptWithServerAction(file); + await workbook.xlsx.load(arrayBuffer) + + // 첫 번째 시트 사용 + const worksheet = workbook.worksheets[0] + + // (A) 마지막 열에 "Error" 헤더 + const lastColIndex = worksheet.columnCount + 1 + worksheet.getRow(1).getCell(lastColIndex).value = "Error" + + // (B) 엑셀 헤더 (Row1) + const headerRowValues = worksheet.getRow(1).values as ExcelJS.CellValue[] + + // (C) excelHeader -> accessor 매핑 + const excelHeaderToAccessor: Record<string, string> = {} + for (const col of table.getAllColumns()) { + const meta = col.columnDef.meta as { excelHeader?: string } | undefined + if (meta?.excelHeader) { + const accessor = col.id as string + excelHeaderToAccessor[meta.excelHeader] = accessor + } + } + + // (D) accessor -> column index + const accessorIndexMap: Record<string, number> = {} + for (let i = 1; i < headerRowValues.length; i++) { + const cellVal = String(headerRowValues[i] ?? "").trim() + if (!cellVal) continue + const accessor = excelHeaderToAccessor[cellVal] + if (accessor) { + accessorIndexMap[accessor] = i + } + } + + let errorCount = 0 + const importedRows: Tag[] = [] + const fileTagNos = new Set<string>() // 파일 내 태그번호 중복 체크용 + const lastRow = worksheet.lastRow?.number || 1 + + // 2) 각 데이터 행 파싱 + for (let rowNum = 2; rowNum <= lastRow; rowNum++) { + const row = worksheet.getRow(rowNum) + const rowVals = row.values as ExcelJS.CellValue[] + if (!rowVals || rowVals.length <= 1) continue // 빈 행 스킵 + + let errorMsg = "" + + // 필요한 accessorIndex + const tagNoIndex = accessorIndexMap["tagNo"] + const classIndex = accessorIndexMap["class"] + + // 엑셀에서 값 읽기 + const tagNo = tagNoIndex ? String(rowVals[tagNoIndex] ?? "").trim() : "" + const classVal = classIndex ? String(rowVals[classIndex] ?? "").trim() : "" + + // A. 필수값 검사 + if (!tagNo) { + errorMsg += `Tag No is empty. ` + } + if (!classVal) { + errorMsg += `Class is empty. ` + } + + // B. 중복 검사 + if (tagNo) { + // 이미 tableData 내 존재 여부 + const dup = tableData.find( + (t) => t.contractItemId === selectedPackageId && t.tagNo === tagNo + ) + if (dup) { + errorMsg += `TagNo '${tagNo}' already exists. ` + } + + // 이번 엑셀 파일 내 중복 + if (fileTagNos.has(tagNo)) { + errorMsg += `TagNo '${tagNo}' is duplicated within this file. ` + } else { + fileTagNos.add(tagNo) + } + } + + // C. Class 기반 형식 검증 + if (tagNo && classVal && !errorMsg) { + // classVal 로부터 태그타입 코드 획득 + const tagTypeCode = getTagTypeCodeByClassLabel(classVal) + + if (!tagTypeCode) { + errorMsg += `No tag type code found for class '${classVal}'. ` + } else { + // validateTagNumberByClass( ) 안에서 + // → tagTypeCode로 서브필드 조회, 정규식 검증 등 처리 + const classValidationError = await validateTagNumberByClass(tagNo, classVal) + if (classValidationError) { + errorMsg += classValidationError + " " + } + } + } + + // D. 에러 처리 + if (errorMsg) { + row.getCell(lastColIndex).value = errorMsg.trim() + errorCount++ + } else { + // 최종 태그 타입 결정 (DB에 저장할 때 'tagType' 컬럼을 무엇으로 쓸지 결정) + // 예: DB에서 tagType을 "CV" 같은 코드로 저장하려면 + // const finalTagType = getTagTypeCodeByClassLabel(classVal) ?? "" + // 혹은 "Control Valve" 같은 description을 쓰려면 classOptions에서 찾아볼 수도 있음 + const finalTagType = getTagTypeCodeByClassLabel(classVal) ?? "" + + // 정상 행을 importedRows에 추가 + importedRows.push({ + id: 0, // 임시 + contractItemId: selectedPackageId, + formId: null, + tagNo, + tagType: finalTagType, // ← 코드로 저장할지, Description으로 저장할지 결정 + class: classVal, + description: String(rowVals[accessorIndexMap["description"] ?? 0] ?? "").trim(), + createdAt: new Date(), + updatedAt: new Date(), + }) + } + } + + // (E) 오류 행이 있으면 → 수정된 엑셀 재다운로드 & 종료 + if (errorCount > 0) { + const outBuf = await workbook.xlsx.writeBuffer() + const errorFile = new Blob([outBuf]) + const url = URL.createObjectURL(errorFile) + const link = document.createElement("a") + link.href = url + link.download = "tag_import_errors.xlsx" + link.click() + URL.revokeObjectURL(url) + + toast.error(`There are ${errorCount} error row(s). Please see downloaded file.`) + return + } + + // 정상 행이 있으면 태그 생성 요청 + if (importedRows.length > 0) { + const result = await bulkCreateTags(importedRows, selectedPackageId); + if ("error" in result) { + toast.error(result.error); + } else { + toast.success(`${result.data.createdCount}개의 태그가 성공적으로 생성되었습니다.`); + } + } + + toast.success(`Imported ${importedRows.length} tags successfully!`) + + } catch (err) { + console.error(err) + toast.error("파일 업로드 중 오류가 발생했습니다.") + } finally { + setIsPending(false) + } + } + // 새 Export 함수 - 유효성 검사 시트를 포함한 엑셀 내보내기 + async function handleExport() { + try { + setIsExporting(true) + + // 유효성 검사가 포함된 새로운 엑셀 내보내기 함수 호출 + await exportTagsToExcel(table, selectedPackageId, { + filename: `Tags_${selectedPackageId}`, + excludeColumns: ["select", "actions", "createdAt", "updatedAt"], + }) + + toast.success("태그 목록이 성공적으로 내보내졌습니다.") + } catch (error) { + console.error("Export error:", error) + toast.error("태그 목록 내보내기 중 오류가 발생했습니다.") + } finally { + setIsExporting(false) + } + } + + const startGetTags = async () => { + try { + setIsLoading(true) + + // API 엔드포인트 호출 - 작업 시작만 요청 + const response = await fetch('/api/cron/tags/start', { + method: 'POST', + body: JSON.stringify({ + packageId: selectedPackageId, + mode: selectedMode // 모드 정보 추가 + }) + }) + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || 'Failed to start tag import') + } + + const data = await response.json() + + // 작업 ID 저장 + if (data.syncId) { + 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' + ) + setIsLoading(false) + } + } + + const startPolling = (id: string) => { + // 이전 폴링이 있다면 제거 + if (pollingRef.current) { + clearInterval(pollingRef.current) + } + + // 5초마다 상태 확인 + pollingRef.current = setInterval(async () => { + try { + const response = await fetch(`/api/cron/tags/status?id=${id}`) + + if (!response.ok) { + throw new Error('Failed to get tag import status') + } + + const data = await response.json() + + if (data.status === 'completed') { + // 폴링 중지 + if (pollingRef.current) { + clearInterval(pollingRef.current) + pollingRef.current = null + } + + router.refresh() + + // 상태 초기화 + setIsLoading(false) + setSyncId(null) + + // 성공 메시지 표시 + toast.success( + `Tags imported successfully! ${data.result?.processedCount || 0} items processed.` + ) + + // 테이블 데이터 업데이트 + table.resetRowSelection() + } else if (data.status === 'failed') { + // 에러 처리 + if (pollingRef.current) { + clearInterval(pollingRef.current) + pollingRef.current = null + } + + setIsLoading(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}`, + }) + } + } + } catch (error) { + console.error('Error checking importing status:', error) + } + }, 5000) // 5초마다 체크 + } + + return ( + <div className="flex items-center gap-2"> + + {table.getFilteredSelectedRowModel().rows.length > 0 ? ( + <DeleteTagsDialog + tags={table + .getFilteredSelectedRowModel() + .rows.map((row) => row.original)} + onSuccess={() => table.toggleAllRowsSelected(false)} + selectedPackageId={selectedPackageId} + /> + ) : null} + <Button + variant="samsung" + size="sm" + className="gap-2" + onClick={startGetTags} + disabled={isLoading} + > + <RefreshCcw className={`size-4 ${isLoading ? 'animate-spin' : ''}`} aria-hidden="true" /> + <span className="hidden sm:inline"> + {isLoading ? 'Syncing...' : 'Get Tags'} + </span> + </Button> + + <AddTagDialog selectedPackageId={selectedPackageId} /> + + {/* Import */} + <Button + variant="outline" + size="sm" + onClick={handleImportClick} + disabled={isPending || isExporting} + > + {isPending ? ( + <Loader2 className="size-4 mr-2 animate-spin" aria-hidden="true" /> + ) : ( + <Upload className="size-4 mr-2" aria-hidden="true" /> + )} + <span className="hidden sm:inline">Import</span> + </Button> + <input + ref={fileInputRef} + type="file" + accept=".xlsx,.xls" + className="hidden" + onChange={handleFileChange} + /> + + {/* Export */} + <Button + variant="outline" + size="sm" + onClick={handleExport} + disabled={isPending || isExporting} + > + {isExporting ? ( + <Loader2 className="size-4 mr-2 animate-spin" aria-hidden="true" /> + ) : ( + <Download className="size-4 mr-2" aria-hidden="true" /> + )} + <span className="hidden sm:inline">Export</span> + </Button> + </div> + ) +}
\ No newline at end of file diff --git a/lib/tags-plant/table/update-tag-sheet.tsx b/lib/tags-plant/table/update-tag-sheet.tsx new file mode 100644 index 00000000..613abaa9 --- /dev/null +++ b/lib/tags-plant/table/update-tag-sheet.tsx @@ -0,0 +1,547 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { Loader2, Check, ChevronsUpDown } from "lucide-react" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import { z } from "zod" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { Badge } from "@/components/ui/badge" +import { cn } from "@/lib/utils" + +import { Tag } from "@/db/schema/vendorData" +import { updateTag, getSubfieldsByTagType, getClassOptions, TagTypeOption } from "@/lib/tags/service" + +// SubFieldDef 인터페이스 +interface SubFieldDef { + name: string + label: string + type: "select" | "text" + options?: { value: string; label: string }[] + expression?: string + delimiter?: string +} + +// 클래스 옵션 인터페이스 +interface UpdatedClassOption { + code: string + label: string + tagTypeCode: string + tagTypeDescription?: string +} + +// UpdateTagSchema 정의 +const updateTagSchema = z.object({ + class: z.string().min(1, "Class is required"), + tagType: z.string().min(1, "Tag Type is required"), + tagNo: z.string().min(1, "Tag Number is required"), + description: z.string().optional(), + // 추가 필드들은 동적으로 처리됨 +}) + +// TypeScript 타입 정의 +type UpdateTagSchema = z.infer<typeof updateTagSchema> & Record<string, string> + +interface UpdateTagSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { + tag: Tag | null + selectedPackageId: number +} + +export function UpdateTagSheet({ tag, selectedPackageId, ...props }: UpdateTagSheetProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + const [tagTypeList, setTagTypeList] = React.useState<TagTypeOption[]>([]) + const [selectedTagTypeCode, setSelectedTagTypeCode] = React.useState<string | null>(null) + const [subFields, setSubFields] = React.useState<SubFieldDef[]>([]) + const [classOptions, setClassOptions] = React.useState<UpdatedClassOption[]>([]) + const [classSearchTerm, setClassSearchTerm] = React.useState("") + const [isLoadingClasses, setIsLoadingClasses] = React.useState(false) + const [isLoadingSubFields, setIsLoadingSubFields] = React.useState(false) + + // ID management for popover elements + const selectIdRef = React.useRef(0) + const fieldIdsRef = React.useRef<Record<string, string>>({}) + const classOptionIdsRef = React.useRef<Record<string, string>>({}) + + + // Load class options when sheet opens + React.useEffect(() => { + const loadClassOptions = async () => { + if (!props.open || !tag) return + + setIsLoadingClasses(true) + try { + const result = await getClassOptions(selectedPackageId) + setClassOptions(result) + } catch (err) { + toast.error("클래스 옵션을 불러오는데 실패했습니다.") + } finally { + setIsLoadingClasses(false) + } + } + + loadClassOptions() + }, [props.open, tag]) + + // Form setup + const form = useForm<UpdateTagSchema>({ + resolver: zodResolver(updateTagSchema), + defaultValues: { + class: "", + tagType: "", + tagNo: "", + description: "", + }, + }) + + // Load tag data into form when tag changes + React.useEffect(() => { + if (!tag) return + + // 필요한 필드만 선택적으로 추출 + const formValues = { + tagNo: tag.tagNo, + tagType: tag.tagType, + class: tag.class, + description: tag.description || "" + // 참고: 실제 태그 데이터에는 서브필드(functionCode, seqNumber 등)가 없음 + }; + + // 폼 초기화 + form.reset(formValues) + + // 태그 타입 코드 설정 (추가 필드 로딩을 위해) + if (tag.tagType) { + // 해당 태그 타입에 맞는 클래스 옵션을 찾아서 태그 타입 코드 설정 + const foundClass = classOptions.find(opt => opt.label === tag.class) + if (foundClass?.tagTypeCode) { + setSelectedTagTypeCode(foundClass.tagTypeCode) + loadSubFieldsByTagTypeCode(foundClass.tagTypeCode) + } + } + }, [tag, classOptions, form]) + + // Load subfields by tag type code + async function loadSubFieldsByTagTypeCode(tagTypeCode: string) { + setIsLoadingSubFields(true) + try { + const { subFields: apiSubFields } = await getSubfieldsByTagType(tagTypeCode, selectedPackageId) + 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) + return true + } catch (err) { + toast.error("서브필드를 불러오는데 실패했습니다.") + setSubFields([]) + return false + } finally { + setIsLoadingSubFields(false) + } + } + + // Handle class selection + async function handleSelectClass(classOption: UpdatedClassOption) { + form.setValue("class", classOption.label, { shouldValidate: true }) + + if (classOption.tagTypeCode) { + setSelectedTagTypeCode(classOption.tagTypeCode) + + // Set tag type + const tagType = tagTypeList.find(t => t.id === classOption.tagTypeCode) + if (tagType) { + form.setValue("tagType", tagType.label, { shouldValidate: true }) + } else if (classOption.tagTypeDescription) { + form.setValue("tagType", classOption.tagTypeDescription, { shouldValidate: true }) + } + + await loadSubFieldsByTagTypeCode(classOption.tagTypeCode) + } + } + + // Form submission handler + function onSubmit(data: UpdateTagSchema) { + startUpdateTransition(async () => { + if (!tag) return + + try { + // 기본 필드와 서브필드 데이터 결합 + const tagData = { + id: tag.id, + tagType: data.tagType, + class: data.class, + tagNo: data.tagNo, + description: data.description, + ...Object.fromEntries( + subFields.map(field => [field.name, data[field.name] || ""]) + ), + } + + const result = await updateTag(tagData, selectedPackageId) + + if ("error" in result) { + toast.error(result.error) + return + } + + form.reset() + props.onOpenChange?.(false) + toast.success("태그가 성공적으로 업데이트되었습니다") + } catch (error) { + console.error("Error updating tag:", error) + toast.error("태그 업데이트 중 오류가 발생했습니다") + } + }) + } + + // 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)}`, + [] + ) + + return ( + <FormItem> + <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="클래스 검색..." + value={classSearchTerm} + onValueChange={setClassSearchTerm} + /> + <CommandList key={`${commandId}-list`} className="max-h-[300px]"> + <CommandEmpty key={`${commandId}-empty`}>검색 결과가 없습니다.</CommandEmpty> + <CommandGroup key={`${commandId}-group`}> + {classOptions.map((opt, optIndex) => { + if (!classOptionIdsRef.current[opt.code]) { + classOptionIdsRef.current[opt.code] = + `class-${opt.code}-${Date.now()}-${Math.random() + .toString(36) + .slice(2, 9)}` + } + const optionId = classOptionIdsRef.current[opt.code] + + return ( + <CommandItem + key={`${optionId}-${optIndex}`} + onSelect={() => { + field.onChange(opt.label) + setPopoverOpen(false) + handleSelectClass(opt) + }} + value={opt.label} + className="truncate" + title={opt.label} + > + <span className="truncate">{opt.label}</span> + <Check + key={`${optionId}-check`} + className={cn( + "ml-auto h-4 w-4 flex-shrink-0", + field.value === opt.label ? "opacity-100" : "opacity-0" + )} + /> + </CommandItem> + ) + })} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + </FormControl> + <FormMessage /> + </FormItem> + ) + } + + // Render TagType field (readonly) + function renderTagTypeField(field: any) { + return ( + <FormItem> + <FormLabel>Tag Type</FormLabel> + <FormControl> + <div className="relative"> + <Input + {...field} + readOnly + className="h-9 bg-muted" + /> + </div> + </FormControl> + <FormMessage /> + </FormItem> + ) + } + + // Render Tag Number field (readonly) + function renderTagNoField(field: any) { + return ( + <FormItem> + <FormLabel>Tag Number</FormLabel> + <FormControl> + <div className="relative"> + <Input + {...field} + readOnly + className="h-9 bg-muted font-mono" + /> + </div> + </FormControl> + <FormMessage /> + </FormItem> + ) + } + + // Render form fields for each subfield + function renderSubFields() { + if (isLoadingSubFields) { + return ( + <div className="flex justify-center items-center py-4"> + <Loader2 className="h-6 w-6 animate-spin text-primary" /> + <div className="ml-3 text-muted-foreground">필드 로딩 중...</div> + </div> + ) + } + + if (subFields.length === 0) { + return null + } + + return ( + <div className="space-y-4"> + <div className="text-sm font-medium text-muted-foreground">추가 필드</div> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + {subFields.map((sf, index) => ( + <FormField + key={`subfield-${sf.name}-${index}`} + control={form.control} + name={sf.name} + render={({ field }) => ( + <FormItem> + <FormLabel>{sf.label}</FormLabel> + <FormControl> + {sf.type === "select" ? ( + <Select + value={field.value || ""} + onValueChange={field.onChange} + > + <SelectTrigger className="w-full h-9"> + <SelectValue placeholder={`${sf.label} 선택...`} /> + </SelectTrigger> + <SelectContent + align="start" + side="bottom" + className="max-h-[250px]" + style={{ minWidth: "250px", maxWidth: "350px" }} + > + {sf.options?.map((opt, optIndex) => ( + <SelectItem + key={`${sf.name}-${opt.value}-${optIndex}`} + value={opt.value} + title={opt.label} + className="whitespace-normal py-2 break-words" + > + {opt.label} + </SelectItem> + ))} + </SelectContent> + </Select> + ) : ( + <Input + {...field} + className="h-9" + placeholder={`${sf.label} 입력...`} + /> + )} + </FormControl> + {sf.expression && ( + <p className="text-xs text-muted-foreground mt-1" title={sf.expression}> + {sf.expression} + </p> + )} + <FormMessage /> + </FormItem> + )} + /> + ))} + </div> + </div> + ) + } + + // 컴포넌트 렌더링 + return ( + <Sheet {...props}> + {/* <SheetContent className="flex flex-col gap-0 sm:max-w-md overflow-y-auto"> */} + <SheetContent className="flex flex-col gap-6 sm:max-w-lg overflow-y-auto"> + <SheetHeader className="text-left"> + <SheetTitle>태그 수정</SheetTitle> + <SheetDescription> + 태그 정보를 업데이트하고 변경 사항을 저장하세요 + </SheetDescription> + </SheetHeader> + + <div className="flex-1 overflow-y-auto py-4"> + <Form {...form}> + <form + id="update-tag-form" + onSubmit={form.handleSubmit(onSubmit)} + className="space-y-6" + > + {/* 기본 태그 정보 */} + <div className="space-y-4"> + {/* Class */} + <FormField + control={form.control} + name="class" + render={({ field }) => renderClassField(field)} + /> + + {/* Tag Type */} + <FormField + control={form.control} + name="tagType" + render={({ field }) => renderTagTypeField(field)} + /> + + {/* Tag Number */} + <FormField + control={form.control} + name="tagNo" + render={({ field }) => renderTagNoField(field)} + /> + + {/* Description */} + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>Description</FormLabel> + <FormControl> + <Input + {...field} + placeholder="태그 설명 입력..." + className="h-9" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + {/* 서브필드 */} + {renderSubFields()} + </form> + </Form> + </div> + + <SheetFooter className="pt-2"> + <SheetClose asChild> + <Button type="button" variant="outline"> + 취소 + </Button> + </SheetClose> + <Button + type="submit" + form="update-tag-form" + disabled={isUpdatePending || isLoadingSubFields} + > + {isUpdatePending ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" /> + 저장 중... + </> + ) : ( + "저장" + )} + </Button> + </SheetFooter> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/tags-plant/validations.ts b/lib/tags-plant/validations.ts new file mode 100644 index 00000000..65e64f04 --- /dev/null +++ b/lib/tags-plant/validations.ts @@ -0,0 +1,68 @@ +// /lib/tags/validations.ts +import { z } from "zod" +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { Tag } from "@/db/schema/vendorData" + +export const createTagSchema = z.object({ + tagNo: z.string().min(1, "Tag No is required"), + tagType: z.string().min(1, "Tag Type is required"), + class: z.string().min(1, "Equipment Class is required"), + description: z.string().min(1, "Description is required"), // 필수 필드로 변경 + + // optional sub-fields for dynamic numbering + functionCode: z.string().optional(), + seqNumber: z.string().optional(), + valveAcronym: z.string().optional(), + processUnit: z.string().optional(), + + // If you also want contractItemId: + // contractItemId: z.number(), +}) + +export const updateTagSchema = z.object({ + id: z.number().optional(), // 업데이트 과정에서 별도 검증 + tagNo: z.string().min(1, "Tag Number is required"), + class: z.string().min(1, "Class is required"), + tagType: z.string().min(1, "Tag Type is required"), + description: z.string().optional(), + // 추가 필드들은 동적으로 추가될 수 있음 + functionCode: z.string().optional(), + seqNumber: z.string().optional(), + valveAcronym: z.string().optional(), + processUnit: z.string().optional(), + // 기타 필드들은 필요에 따라 추가 +}) + +export type UpdateTagSchema = z.infer<typeof updateTagSchema> + + +export const searchParamsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<Tag>().withDefault([ + { id: "createdAt", desc: true }, + ]), + tagNo: parseAsString.withDefault(""), + tagType: parseAsString.withDefault(""), + description: parseAsString.withDefault(""), + + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), + +}) + +export type CreateTagSchema = z.infer<typeof createTagSchema> +export type GetTagsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>> + diff --git a/lib/tags/table/add-tag-dialog.tsx b/lib/tags/table/add-tag-dialog.tsx index f3eaed3f..0f701f1e 100644 --- a/lib/tags/table/add-tag-dialog.tsx +++ b/lib/tags/table/add-tag-dialog.tsx @@ -465,7 +465,7 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) { )} </Button> </PopoverTrigger> - <PopoverContent key={popoverContentId} className="w-[300px] p-0"> + <PopoverContent key={popoverContentId} className="w-[300px] p-0"style={{width:480}}> <Command key={commandId}> <CommandInput key={`${commandId}-input`} diff --git a/lib/vendor-data-plant/services.ts b/lib/vendor-data-plant/services.ts new file mode 100644 index 00000000..e8ecd01c --- /dev/null +++ b/lib/vendor-data-plant/services.ts @@ -0,0 +1,112 @@ +"use server"; + +import db from "@/db/db" +import { items } from "@/db/schema/items" +import { projects } from "@/db/schema/projects" +import { eq } from "drizzle-orm" +import { contractItems, contracts } from "@/db/schema/contract"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; + +export interface ProjectWithContracts { + projectId: number + projectCode: string + projectName: string + projectType: string + + contracts: { + contractId: number + contractNo: string + contractName: string + packages: { + itemId: number // contract_items.id + itemName: string + }[] + }[] +} + +export async function getVendorProjectsAndContracts( + vendorId?: number +): Promise<ProjectWithContracts[]> { + // 세션에서 도메인 정보 가져오기 + const session = await getServerSession(authOptions) + + // EVCP 도메인일 때만 전체 조회 + const isEvcpDomain = session?.user?.domain === "evcp" + + const query = db + .select({ + projectId: projects.id, + projectCode: projects.code, + projectName: projects.name, + projectType: projects.type, + + contractId: contracts.id, + contractNo: contracts.contractNo, + contractName: contracts.contractName, + + itemId: contractItems.id, + itemName: items.itemName, + }) + .from(contracts) + .innerJoin(projects, eq(contracts.projectId, projects.id)) + .innerJoin(contractItems, eq(contractItems.contractId, contracts.id)) + .innerJoin(items, eq(contractItems.itemId, items.id)) + + if (!isEvcpDomain && vendorId) { + query.where(eq(contracts.vendorId, vendorId)) + } + + const rows = await query + + const projectMap = new Map<number, ProjectWithContracts>() + + for (const row of rows) { + // 1) 프로젝트 그룹 찾기 + let projectEntry = projectMap.get(row.projectId) + if (!projectEntry) { + // 새 프로젝트 항목 생성 + projectEntry = { + projectId: row.projectId, + projectCode: row.projectCode, + projectName: row.projectName, + projectType: row.projectType, + contracts: [], + } + projectMap.set(row.projectId, projectEntry) + } + + // 2) 프로젝트 안에서 계약(contractId) 찾기 + let contractEntry = projectEntry.contracts.find( + (c) => c.contractId === row.contractId + ) + if (!contractEntry) { + + // 새 계약 항목 + contractEntry = { + contractId: row.contractId, + contractNo: row.contractNo, + contractName: row.contractName, + packages: [], + } + projectEntry.contracts.push(contractEntry) + } + + // 3) 계약의 packages 배열에 아이템 추가 (중복 체크) + // itemName이 같은 항목이 이미 존재하는지 확인 + const existingItem = contractEntry.packages.find( + (pkg) => pkg.itemName === row.itemName + ) + + // 같은 itemName이 없는 경우에만 추가 + if (!existingItem) { + contractEntry.packages.push({ + itemId: row.itemId, + itemName: row.itemName, + }) + } + } + + return Array.from(projectMap.values()) +} + |
