summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/[lng]/partners/(partners)/vendor-data-plant/form/[packageId]/[formId]/[projectId]/[contractId]/page.tsx82
-rw-r--r--app/[lng]/partners/(partners)/vendor-data-plant/layout.tsx85
-rw-r--r--app/[lng]/partners/(partners)/vendor-data-plant/page.tsx38
-rw-r--r--app/[lng]/partners/(partners)/vendor-data-plant/tag/[id]/page.tsx43
-rw-r--r--components/form-data-plant/add-formTag-dialog.tsx985
-rw-r--r--components/form-data-plant/delete-form-data-dialog.tsx228
-rw-r--r--components/form-data-plant/export-excel-form.tsx674
-rw-r--r--components/form-data-plant/form-data-report-batch-dialog.tsx444
-rw-r--r--components/form-data-plant/form-data-report-dialog.tsx415
-rw-r--r--components/form-data-plant/form-data-report-temp-upload-dialog.tsx101
-rw-r--r--components/form-data-plant/form-data-report-temp-upload-tab.tsx243
-rw-r--r--components/form-data-plant/form-data-report-temp-uploaded-list-tab.tsx218
-rw-r--r--components/form-data-plant/form-data-table-columns.tsx546
-rw-r--r--components/form-data-plant/form-data-table.tsx1377
-rw-r--r--components/form-data-plant/import-excel-form.tsx669
-rw-r--r--components/form-data-plant/publish-dialog.tsx470
-rw-r--r--components/form-data-plant/sedp-compare-dialog.tsx618
-rw-r--r--components/form-data-plant/sedp-components.tsx193
-rw-r--r--components/form-data-plant/sedp-excel-download.tsx259
-rw-r--r--components/form-data-plant/spreadJS-dialog.tsx1733
-rw-r--r--components/form-data-plant/spreadJS-dialog_designer.tsx1404
-rw-r--r--components/form-data-plant/update-form-sheet.tsx445
-rw-r--r--components/form-data-plant/var-list-download-btn.tsx122
-rw-r--r--components/form-data/form-data-table.tsx22
-rw-r--r--components/vendor-data-plant/project-swicher.tsx171
-rw-r--r--components/vendor-data-plant/sidebar.tsx318
-rw-r--r--components/vendor-data-plant/tag-table/add-tag-dialog.tsx357
-rw-r--r--components/vendor-data-plant/tag-table/tag-table-column.tsx198
-rw-r--r--components/vendor-data-plant/tag-table/tag-table.tsx39
-rw-r--r--components/vendor-data-plant/tag-table/tag-type-definitions.ts87
-rw-r--r--components/vendor-data-plant/vendor-data-container.tsx505
-rw-r--r--components/vendor-data/sidebar.tsx175
-rw-r--r--config/menuConfig.ts26
-rw-r--r--lib/forms-plant/sedp-actions.ts222
-rw-r--r--lib/forms-plant/services.ts2076
-rw-r--r--lib/forms-plant/stat.ts375
-rw-r--r--lib/tags-plant/form-mapping-service.ts101
-rw-r--r--lib/tags-plant/repository.ts71
-rw-r--r--lib/tags-plant/service.ts1650
-rw-r--r--lib/tags-plant/table/add-tag-dialog.tsx997
-rw-r--r--lib/tags-plant/table/delete-tags-dialog.tsx151
-rw-r--r--lib/tags-plant/table/feature-flags-provider.tsx108
-rw-r--r--lib/tags-plant/table/tag-table-column.tsx164
-rw-r--r--lib/tags-plant/table/tag-table.tsx155
-rw-r--r--lib/tags-plant/table/tags-export.tsx158
-rw-r--r--lib/tags-plant/table/tags-table-floating-bar.tsx220
-rw-r--r--lib/tags-plant/table/tags-table-toolbar-actions.tsx758
-rw-r--r--lib/tags-plant/table/update-tag-sheet.tsx547
-rw-r--r--lib/tags-plant/validations.ts68
-rw-r--r--lib/tags/table/add-tag-dialog.tsx2
-rw-r--r--lib/vendor-data-plant/services.ts112
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())
+}
+