summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-10-23 10:10:21 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-10-23 10:10:21 +0000
commitf7f5069a2209cfa39b65f492f32270a5f554bed0 (patch)
tree933c731ec2cb7d8bc62219a0aeed45a5e97d5f15 /lib
parentd49ad5dee1e5a504e1321f6db802b647497ee9ff (diff)
(대표님) EDP 해양 관련 개발 사항들
Diffstat (limited to 'lib')
-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
18 files changed, 7934 insertions, 1 deletions
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())
+}
+