diff options
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()) +} + |
