diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-10-23 10:10:21 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-10-23 10:10:21 +0000 |
| commit | f7f5069a2209cfa39b65f492f32270a5f554bed0 (patch) | |
| tree | 933c731ec2cb7d8bc62219a0aeed45a5e97d5f15 /lib | |
| parent | d49ad5dee1e5a504e1321f6db802b647497ee9ff (diff) | |
(대표님) EDP 해양 관련 개발 사항들
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/forms-plant/sedp-actions.ts | 222 | ||||
| -rw-r--r-- | lib/forms-plant/services.ts | 2076 | ||||
| -rw-r--r-- | lib/forms-plant/stat.ts | 375 | ||||
| -rw-r--r-- | lib/tags-plant/form-mapping-service.ts | 101 | ||||
| -rw-r--r-- | lib/tags-plant/repository.ts | 71 | ||||
| -rw-r--r-- | lib/tags-plant/service.ts | 1650 | ||||
| -rw-r--r-- | lib/tags-plant/table/add-tag-dialog.tsx | 997 | ||||
| -rw-r--r-- | lib/tags-plant/table/delete-tags-dialog.tsx | 151 | ||||
| -rw-r--r-- | lib/tags-plant/table/feature-flags-provider.tsx | 108 | ||||
| -rw-r--r-- | lib/tags-plant/table/tag-table-column.tsx | 164 | ||||
| -rw-r--r-- | lib/tags-plant/table/tag-table.tsx | 155 | ||||
| -rw-r--r-- | lib/tags-plant/table/tags-export.tsx | 158 | ||||
| -rw-r--r-- | lib/tags-plant/table/tags-table-floating-bar.tsx | 220 | ||||
| -rw-r--r-- | lib/tags-plant/table/tags-table-toolbar-actions.tsx | 758 | ||||
| -rw-r--r-- | lib/tags-plant/table/update-tag-sheet.tsx | 547 | ||||
| -rw-r--r-- | lib/tags-plant/validations.ts | 68 | ||||
| -rw-r--r-- | lib/tags/table/add-tag-dialog.tsx | 2 | ||||
| -rw-r--r-- | lib/vendor-data-plant/services.ts | 112 |
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()) +} + |
