summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/forms-plant/sedp-actions.ts222
-rw-r--r--lib/forms-plant/services.ts2076
-rw-r--r--lib/forms-plant/stat.ts375
-rw-r--r--lib/tags-plant/form-mapping-service.ts101
-rw-r--r--lib/tags-plant/repository.ts71
-rw-r--r--lib/tags-plant/service.ts1650
-rw-r--r--lib/tags-plant/table/add-tag-dialog.tsx997
-rw-r--r--lib/tags-plant/table/delete-tags-dialog.tsx151
-rw-r--r--lib/tags-plant/table/feature-flags-provider.tsx108
-rw-r--r--lib/tags-plant/table/tag-table-column.tsx164
-rw-r--r--lib/tags-plant/table/tag-table.tsx155
-rw-r--r--lib/tags-plant/table/tags-export.tsx158
-rw-r--r--lib/tags-plant/table/tags-table-floating-bar.tsx220
-rw-r--r--lib/tags-plant/table/tags-table-toolbar-actions.tsx758
-rw-r--r--lib/tags-plant/table/update-tag-sheet.tsx547
-rw-r--r--lib/tags-plant/validations.ts68
-rw-r--r--lib/tags/table/add-tag-dialog.tsx2
-rw-r--r--lib/vendor-data-plant/services.ts112
18 files changed, 7934 insertions, 1 deletions
diff --git a/lib/forms-plant/sedp-actions.ts b/lib/forms-plant/sedp-actions.ts
new file mode 100644
index 00000000..4883a33f
--- /dev/null
+++ b/lib/forms-plant/sedp-actions.ts
@@ -0,0 +1,222 @@
+"use server";
+
+import { getSEDPToken } from "@/lib/sedp/sedp-token";
+
+interface SEDPTagData {
+ [tableName: string]: Array<{
+ TAG_NO: string;
+ TAG_DESC: string;
+ ATTRIBUTES: Array<{
+ ATT_ID: string;
+ VALUE: string;
+ }>;
+ }>;
+}
+
+interface SEDPTemplateData {
+ templateId: string;
+ content: string;
+ projectNo: string;
+ regTypeId: string;
+ [key: string]: any;
+}
+
+// ๐Ÿ” ์‹ค์ œ SEDP API ์‘๋‹ต ๊ตฌ์กฐ (๋Œ€๋ฌธ์ž)
+interface SEDPTemplateResponse {
+ TMPL_ID: string;
+ NAME: string;
+ TMPL_TYPE: string;
+ SPR_LST_SETUP?: {
+ ACT_SHEET: string;
+ HIDN_SHEETS: string[];
+ CONTENT?: string;
+ DATA_SHEETS: Array<{
+ SHEET_NAME: string;
+ REG_TYPE_ID: string;
+ MAP_CELL_ATT: Array<{
+ ATT_ID: string;
+ IN: string;
+ }>;
+ }>;
+ };
+ GRD_LST_SETUP?: {
+ REG_TYPE_ID: string;
+ SPR_ITM_IDS: string[];
+ ATTS: any[];
+ };
+ SPR_ITM_LST_SETUP?: {
+ ACT_SHEET: string;
+ HIDN_SHEETS: string[];
+ CONTENT?: string;
+ DATA_SHEETS: Array<{
+ SHEET_NAME: string;
+ REG_TYPE_ID: string;
+ MAP_CELL_ATT: Array<{
+ ATT_ID: string;
+ IN: string;
+ }>;
+ }>;
+ };
+ [key: string]: any;
+}
+
+/**
+ * SEDP์—์„œ ํƒœ๊ทธ ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€์ ธ์˜ค๋Š” ์„œ๋ฒ„ ์•ก์…˜
+ */
+export async function fetchTagDataFromSEDP(
+ projectCode: string,
+ formCode: string
+): Promise<SEDPTagData> {
+ try {
+ // Get the token
+ const apiKey = await getSEDPToken();
+
+ // Define the API base URL
+ const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api';
+
+ // Make the API call
+ const response = await fetch(
+ `${SEDP_API_BASE_URL}/Data/GetPubData`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'accept': '*/*',
+ 'ApiKey': apiKey,
+ 'ProjectNo': projectCode
+ },
+ body: JSON.stringify({
+ ProjectNo: projectCode,
+ REG_TYPE_ID: formCode,
+ ContainDeleted: false
+ })
+ }
+ );
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(`SEDP API request failed: ${response.status} ${response.statusText} - ${errorText}`);
+ }
+
+ const data = await response.json();
+ return data as SEDPTagData;
+ } catch (error: unknown) {
+ console.error('Error calling SEDP API:', error);
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ throw new Error(`Failed to fetch data from SEDP API: ${errorMessage}`);
+ }
+}
+
+/**
+ * SEDP์—์„œ ํ…œํ”Œ๋ฆฟ ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€์ ธ์˜ค๋Š” ์„œ๋ฒ„ ์•ก์…˜
+ */
+export async function fetchTemplateFromSEDP(
+ projectCode: string,
+ formCode: string
+): Promise<SEDPTemplateResponse[]> {
+ try {
+ // Get the token
+ const apiKey = await getSEDPToken();
+
+ // Define the API base URL
+ const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api';
+
+ const responseAdapter = await fetch(
+ `${SEDP_API_BASE_URL}/AdapterDataMapping/GetByToolID`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'accept': '*/*',
+ 'ApiKey': apiKey,
+ 'ProjectNo': projectCode
+ },
+ body: JSON.stringify({
+ ProjectNo: projectCode,
+ "TOOL_ID": "eVCP"
+ })
+ }
+ );
+
+ if (!responseAdapter.ok) {
+ throw new Error(`์ƒˆ ๋ ˆ์ง€์Šคํ„ฐ ์š”์ฒญ ์‹คํŒจ: ${responseAdapter.status} ${responseAdapter.statusText}`);
+ }
+
+ const dataAdapter = await responseAdapter.json();
+ const templateList = dataAdapter.find(v => v.REG_TYPE_ID === formCode)?.MAP_TMPLS || [];
+
+ // ๊ฐ TMPL_ID์— ๋Œ€ํ•ด API ํ˜ธ์ถœ
+ const templatePromises = templateList.map(async (tmplId: string) => {
+ const response = await fetch(
+ `${SEDP_API_BASE_URL}/Template/GetByID`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'accept': '*/*',
+ 'ApiKey': apiKey,
+ 'ProjectNo': projectCode
+ },
+ body: JSON.stringify({
+ WithContent: true,
+ ProjectNo: projectCode,
+ TMPL_ID: tmplId
+ })
+ }
+ );
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(`SEDP Template API request failed for TMPL_ID ${tmplId}: ${response.status} ${response.statusText} - ${errorText}`);
+ }
+
+ const data = await response.json();
+
+ // ๐Ÿ” API ์‘๋‹ต ๋ฐ์ดํ„ฐ ๊ตฌ์กฐ ํ™•์ธ ๋ฐ ๋กœ๊น…
+ console.log('๐Ÿ” SEDP Template API Response for', tmplId, ':', {
+ hasTMPL_ID: !!data.TMPL_ID,
+ hasTemplateId: !!(data as any).templateId,
+ keys: Object.keys(data),
+ sample: data
+ });
+
+ // ๐Ÿ” TMPL_ID ํ•„๋“œ ๊ฒ€์ฆ
+ if (!data.TMPL_ID) {
+ console.error('โŒ Missing TMPL_ID in API response:', data);
+ // templateId๊ฐ€ ์žˆ๋‹ค๋ฉด ๋ณ€ํ™˜ ์‹œ๋„
+ if ((data as any).templateId) {
+ console.warn('โš ๏ธ Found templateId instead of TMPL_ID, converting...');
+ data.TMPL_ID = (data as any).templateId;
+ }
+ }
+
+ return data as SEDPTemplateResponse;
+ });
+
+ // ๋ชจ๋“  API ํ˜ธ์ถœ์„ ๋ณ‘๋ ฌ๋กœ ์‹คํ–‰ํ•˜๊ณ  ๊ฒฐ๊ณผ๋ฅผ ์ˆ˜์ง‘
+ const templates = await Promise.all(templatePromises);
+
+ // ๐Ÿ” null์ด๋‚˜ undefined๊ฐ€ ์•„๋‹Œ ๊ฐ’๋“ค๋งŒ ํ•„ํ„ฐ๋งํ•˜๊ณ  TMPL_ID ๊ฒ€์ฆ
+ const validTemplates = templates.filter(template => {
+ if (!template) {
+ console.warn('โš ๏ธ Null or undefined template received');
+ return false;
+ }
+ if (!template.TMPL_ID) {
+ console.error('โŒ Template missing TMPL_ID:', template);
+ return false;
+ }
+ return true;
+ });
+
+ console.log(`โœ… fetchTemplateFromSEDP completed: ${validTemplates.length} valid templates`);
+ validTemplates.forEach(t => console.log(` - ${t.TMPL_ID}: ${t.NAME} (${t.TMPL_TYPE})`));
+
+ return validTemplates;
+
+ } catch (error: unknown) {
+ console.error('Error calling SEDP Template API:', error);
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ throw new Error(`Failed to fetch template from SEDP API: ${errorMessage}`);
+ }
+} \ No newline at end of file
diff --git a/lib/forms-plant/services.ts b/lib/forms-plant/services.ts
new file mode 100644
index 00000000..99e7c35b
--- /dev/null
+++ b/lib/forms-plant/services.ts
@@ -0,0 +1,2076 @@
+// lib/forms/services.ts
+"use server";
+
+import { headers } from "next/headers";
+import path from "path";
+import fs from "fs/promises";
+import { v4 as uuidv4 } from "uuid";
+import db from "@/db/db";
+import {
+ formEntries,
+ formMetas,
+ forms,
+ tagClassAttributes,
+ tagClasses,
+ tags,
+ tagSubfieldOptions,
+ tagSubfields,
+ tagTypeClassFormMappings,
+ tagTypes,
+ vendorDataReportTemps,
+ VendorDataReportTemps,
+} from "@/db/schema/vendorData";
+import { eq, and, desc, sql, DrizzleError, inArray, or, type SQL, type InferSelectModel } from "drizzle-orm";
+import { unstable_cache } from "next/cache";
+import { revalidateTag } from "next/cache";
+import { getErrorMessage } from "../handle-error";
+import { DataTableColumnJSON } from "@/components/form-data/form-data-table-columns";
+import { contractItems, contracts, items, projects } from "@/db/schema";
+import { getSEDPToken } from "../sedp/sedp-token";
+import { decryptWithServerAction } from "@/components/drm/drmUtils";
+import { deleteFile, saveFile } from "@/lib/file-stroage";
+
+
+export type FormInfo = InferSelectModel<typeof forms>;
+
+export async function getFormsByContractItemId(
+ contractItemId: number | null,
+ mode: "ENG" | "IM" | "ALL" = "ALL"
+): Promise<{ forms: FormInfo[] }> {
+ // ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ
+ if (!contractItemId || contractItemId <= 0) {
+ console.warn(`Invalid contractItemId: ${contractItemId}`);
+ return { forms: [] };
+ }
+
+ // ๊ณ ์œ  ์บ์‹œ ํ‚ค (๋ชจ๋“œ ํฌํ•จ)
+ const cacheKey = `forms-${contractItemId}-${mode}`;
+
+ try {
+ // return unstable_cache(
+ // async () => {
+ // console.log(
+ // `[Forms Service] Fetching forms for contractItemId: ${contractItemId}, mode: ${mode}`
+ // );
+
+ try {
+ // ์ฟผ๋ฆฌ ์ƒ์„ฑ
+ let query = db.select().from(forms).where(eq(forms.contractItemId, contractItemId));
+
+ // ๋ชจ๋“œ์— ๋”ฐ๋ฅธ ์ถ”๊ฐ€ ํ•„ํ„ฐ
+ if (mode === "ENG") {
+ query = db.select().from(forms).where(
+ and(
+ eq(forms.contractItemId, contractItemId),
+ eq(forms.eng, true)
+ )
+ );
+ } else if (mode === "IM") {
+ query = db.select().from(forms).where(
+ and(
+ eq(forms.contractItemId, contractItemId),
+ eq(forms.im, true)
+ )
+ );
+ }
+
+ // ์ฟผ๋ฆฌ ์‹คํ–‰
+ const formRecords = await query;
+
+ console.log(
+ `[Forms Service] Found ${formRecords.length} forms for contractItemId: ${contractItemId}, mode: ${mode}`
+ );
+
+ return { forms: formRecords };
+ } catch (error) {
+ getErrorMessage(
+ `Database error for contractItemId ${contractItemId}, mode: ${mode}: ${error}`
+ );
+ throw error; // ์บ์‹œ ํ•จ์ˆ˜์—์„œ ์—๋Ÿฌ๋ฅผ ๋˜์ ธ ์บ์‹ฑ์ด ๋ฐœ์ƒํ•˜์ง€ ์•Š๋„๋ก ํ•จ
+ }
+ // },
+ // [cacheKey],
+ // {
+ // // ์บ์‹œ ์‹œ๊ฐ„ ๋‹จ์ถ•
+ // revalidate: 60, // 1๋ถ„์œผ๋กœ ์ค„์ž„
+ // tags: [cacheKey],
+ // }
+ // )();
+ } catch (error) {
+ getErrorMessage(
+ `Cache operation failed for contractItemId ${contractItemId}, mode: ${mode}: ${error}`
+ );
+
+ // ์บ์‹œ ๋ฌธ์ œ ์‹œ ์ง์ ‘ ์ฟผ๋ฆฌ ์‹œ๋„
+ try {
+ console.log(
+ `[Forms Service] Fallback: Direct query for contractItemId: ${contractItemId}, mode: ${mode}`
+ );
+
+ // ์ฟผ๋ฆฌ ์ƒ์„ฑ
+ let query = db.select().from(forms).where(eq(forms.contractItemId, contractItemId));
+
+ // ๋ชจ๋“œ์— ๋”ฐ๋ฅธ ์ถ”๊ฐ€ ํ•„ํ„ฐ
+ if (mode === "ENG") {
+ query = db.select().from(forms).where(
+ and(
+ eq(forms.contractItemId, contractItemId),
+ eq(forms.eng, true)
+ )
+ );
+ } else if (mode === "IM") {
+ query = db.select().from(forms).where(
+ and(
+ eq(forms.contractItemId, contractItemId),
+ eq(forms.im, true)
+ )
+ );
+ }
+
+ // ์ฟผ๋ฆฌ ์‹คํ–‰
+ const formRecords = await query;
+
+ return { forms: formRecords };
+ } catch (dbError) {
+ getErrorMessage(
+ `Fallback query failed for contractItemId ${contractItemId}, mode: ${mode}: ${dbError}`
+ );
+ return { forms: [] };
+ }
+ }
+}
+
+/**
+ * ํผ ์บ์‹œ๋ฅผ ๊ฐฑ์‹ ํ•˜๋Š” ์„œ๋ฒ„ ์•ก์…˜
+ */
+export async function revalidateForms(contractItemId: number) {
+ if (!contractItemId) return;
+
+ const cacheKey = `forms-${contractItemId}`;
+ console.log(`[Forms Service] Invalidating cache for ${cacheKey}`);
+
+ try {
+ revalidateTag(cacheKey);
+ console.log(`[Forms Service] Cache invalidated for ${cacheKey}`);
+ } catch (error) {
+ getErrorMessage(`Failed to invalidate cache for ${cacheKey}: ${error}`);
+ }
+}
+
+export interface EditableFieldsInfo {
+ tagNo: string;
+ editableFields: string[]; // ํŽธ์ง‘ ๊ฐ€๋Šฅํ•œ ํ•„๋“œ ํ‚ค ๋ชฉ๋ก
+}
+
+// TAG๋ณ„ ํŽธ์ง‘ ๊ฐ€๋Šฅ ํ•„๋“œ ์กฐํšŒ ํ•จ์ˆ˜
+export async function getEditableFieldsByTag(
+ contractItemId: number,
+ projectId: number
+): Promise<Map<string, string[]>> {
+ try {
+ // 1. ํ•ด๋‹น contractItemId์˜ ๋ชจ๋“  ํƒœ๊ทธ ์กฐํšŒ
+ const tagList = await db
+ .select({
+ tagNo: tags.tagNo,
+ tagClass: tags.class
+ })
+ .from(tags)
+ .where(eq(tags.contractItemId, contractItemId));
+
+ const editableFieldsMap = new Map<string, string[]>();
+
+ // 2. ๊ฐ ํƒœ๊ทธ๋ณ„๋กœ ํŽธ์ง‘ ๊ฐ€๋Šฅ ํ•„๋“œ ๊ณ„์‚ฐ
+ for (const tag of tagList) {
+ try {
+ // 2-1. tagClasses์—์„œ ํ•ด๋‹น class(label)์™€ projectId๋กœ tagClass ์ฐพ๊ธฐ
+ const tagClassResult = await db
+ .select({ id: tagClasses.id })
+ .from(tagClasses)
+ .where(
+ and(
+ eq(tagClasses.label, tag.tagClass),
+ eq(tagClasses.projectId, projectId)
+ )
+ )
+ .limit(1);
+
+ if (tagClassResult.length === 0) {
+ console.warn(`No tagClass found for class: ${tag.tagClass}, projectId: ${projectId}`);
+ editableFieldsMap.set(tag.tagNo, []); // ํŽธ์ง‘ ๋ถˆ๊ฐ€๋Šฅ
+ continue;
+ }
+
+ // 2-2. tagClassAttributes์—์„œ ํŽธ์ง‘ ๊ฐ€๋Šฅํ•œ ํ•„๋“œ ๋ชฉ๋ก ์กฐํšŒ
+ const editableAttributes = await db
+ .select({ attId: tagClassAttributes.attId })
+ .from(tagClassAttributes)
+ .where(eq(tagClassAttributes.tagClassId, tagClassResult[0].id))
+ .orderBy(tagClassAttributes.seq);
+
+ // 2-3. attId ๋ชฉ๋ก ์ €์žฅ
+ const editableFields = editableAttributes.map(attr => attr.attId);
+ editableFieldsMap.set(tag.tagNo, editableFields);
+
+ } catch (error) {
+ console.error(`Error processing tag ${tag.tagNo}:`, error);
+ editableFieldsMap.set(tag.tagNo, []); // ์—๋Ÿฌ ์‹œ ํŽธ์ง‘ ๋ถˆ๊ฐ€๋Šฅ
+ }
+ }
+
+ return editableFieldsMap;
+ } catch (error) {
+ console.error('Error getting editable fields by tag:', error);
+ return new Map();
+ }
+}
+/**
+ * "๊ฐ€์žฅ ์ตœ์‹  1๊ฐœ row"๋ฅผ ๊ฐ€์ ธ์˜ค๊ณ ,
+ * data๊ฐ€ ๋ฐฐ์—ด์ด๋ฉด ๊ทธ ๋ฐฐ์—ด์„ ๋ฐ˜ํ™˜,
+ * ๊ทธ๋ฆฌ๊ณ  ์ด ๋กœ์ง ์ „์ฒด๋ฅผ unstable_cache๋กœ ๊ฐ์‹ธ ์บ์‹ฑ.
+ */
+export async function getFormData(formCode: string, contractItemId: number) {
+ try {
+
+ // ๊ธฐ์กด ๋กœ์ง์œผ๋กœ projectId, columns, data ๊ฐ€์ ธ์˜ค๊ธฐ
+ const contractItemResult = await db
+ .select({
+ projectId: projects.id
+ })
+ .from(contractItems)
+ .innerJoin(contracts, eq(contractItems.contractId, contracts.id))
+ .innerJoin(projects, eq(contracts.projectId, projects.id))
+ .where(eq(contractItems.id, contractItemId))
+ .limit(1);
+
+ if (contractItemResult.length === 0) {
+ console.warn(`[getFormData] No contract item found with ID: ${contractItemId}`);
+ return { columns: null, data: [], editableFieldsMap: new Map() };
+ }
+
+ const projectId = contractItemResult[0].projectId;
+
+ const metaRows = await db
+ .select()
+ .from(formMetas)
+ .where(
+ and(
+ eq(formMetas.formCode, formCode),
+ eq(formMetas.projectId, projectId)
+ )
+ )
+ .orderBy(desc(formMetas.updatedAt))
+ .limit(1);
+
+ const meta = metaRows[0] ?? null;
+ if (!meta) {
+ console.warn(`[getFormData] No form meta found for formCode: ${formCode} and projectId: ${projectId}`);
+ return { columns: null, data: [], editableFieldsMap: new Map() };
+ }
+
+ const entryRows = await db
+ .select()
+ .from(formEntries)
+ .where(
+ and(
+ eq(formEntries.formCode, formCode),
+ eq(formEntries.contractItemId, contractItemId)
+ )
+ )
+ .orderBy(desc(formEntries.updatedAt))
+ .limit(1);
+
+ const entry = entryRows[0] ?? null;
+
+ let columns = meta.columns as DataTableColumnJSON[];
+ const excludeKeys = ['BF_TAG_NO', 'TAG_TYPE_ID', 'PIC_NO'];
+ columns = columns.filter(col => !excludeKeys.includes(col.key));
+
+
+
+ columns.forEach((col) => {
+ if (!col.displayLabel) {
+ if (col.uom) {
+ col.displayLabel = `${col.label} (${col.uom})`;
+ } else {
+ col.displayLabel = col.label;
+ }
+ }
+ });
+
+ columns.push({
+ key: "status",
+ label: "status",
+ displayLabel: "Status",
+ type: "STRING"
+ })
+
+ let data: Array<Record<string, any>> = [];
+ if (entry) {
+ if (Array.isArray(entry.data)) {
+ data = entry.data;
+
+ data.sort((a, b) => {
+ const statusA = a.status || '';
+ const statusB = b.status || '';
+ return statusB.localeCompare(statusA)
+ })
+
+ } else {
+ console.warn("formEntries data was not an array. Using empty array.");
+ }
+ }
+
+ // *** ์ƒˆ๋กœ ์ถ”๊ฐ€: ํŽธ์ง‘ ๊ฐ€๋Šฅ ํ•„๋“œ ์ •๋ณด ๊ณ„์‚ฐ ***
+ const editableFieldsMap = await getEditableFieldsByTag(contractItemId, projectId);
+
+ return { columns, data, editableFieldsMap };
+
+
+ } catch (cacheError) {
+ console.error(`[getFormData] Cache operation failed:`, cacheError);
+
+ // Fallback logic (๊ธฐ์กด๊ณผ ๋™์ผํ•˜๊ฒŒ editableFieldsMap ์ถ”๊ฐ€)
+ try {
+ console.log(`[getFormData] Fallback DB query for (${formCode}, ${contractItemId})`);
+
+ const contractItemResult = await db
+ .select({
+ projectId: projects.id
+ })
+ .from(contractItems)
+ .innerJoin(contracts, eq(contractItems.contractId, contracts.id))
+ .innerJoin(projects, eq(contracts.projectId, projects.id))
+ .where(eq(contractItems.id, contractItemId))
+ .limit(1);
+
+ if (contractItemResult.length === 0) {
+ console.warn(`[getFormData] Fallback: No contract item found with ID: ${contractItemId}`);
+ return { columns: null, data: [], editableFieldsMap: new Map() };
+ }
+
+ const projectId = contractItemResult[0].projectId;
+
+ const metaRows = await db
+ .select()
+ .from(formMetas)
+ .where(
+ and(
+ eq(formMetas.formCode, formCode),
+ eq(formMetas.projectId, projectId)
+ )
+ )
+ .orderBy(desc(formMetas.updatedAt))
+ .limit(1);
+
+ const meta = metaRows[0] ?? null;
+ if (!meta) {
+ console.warn(`[getFormData] Fallback: No form meta found for formCode: ${formCode} and projectId: ${projectId}`);
+ return { columns: null, data: [], editableFieldsMap: new Map() };
+ }
+
+ const entryRows = await db
+ .select()
+ .from(formEntries)
+ .where(
+ and(
+ eq(formEntries.formCode, formCode),
+ eq(formEntries.contractItemId, contractItemId)
+ )
+ )
+ .orderBy(desc(formEntries.updatedAt))
+ .limit(1);
+
+ const entry = entryRows[0] ?? null;
+
+ let columns = meta.columns as DataTableColumnJSON[];
+ const excludeKeys = ['BF_TAG_NO', 'TAG_TYPE_ID', 'PIC_NO'];
+ columns = columns.filter(col => !excludeKeys.includes(col.key));
+
+ columns.forEach((col) => {
+ if (!col.displayLabel) {
+ if (col.uom) {
+ col.displayLabel = `${col.label} (${col.uom})`;
+ } else {
+ col.displayLabel = col.label;
+ }
+ }
+ });
+
+ let data: Array<Record<string, any>> = [];
+ if (entry) {
+ if (Array.isArray(entry.data)) {
+ data = entry.data;
+ } else {
+ console.warn("formEntries data was not an array. Using empty array (fallback).");
+ }
+ }
+
+ // Fallback์—์„œ๋„ ํŽธ์ง‘ ๊ฐ€๋Šฅ ํ•„๋“œ ์ •๋ณด ๊ณ„์‚ฐ
+ const editableFieldsMap = await getEditableFieldsByTag(contractItemId, projectId);
+
+ return { columns, data, projectId, editableFieldsMap };
+ } catch (dbError) {
+ console.error(`[getFormData] Fallback DB query failed:`, dbError);
+ return { columns: null, data: [], editableFieldsMap: new Map() };
+ }
+ }
+}
+/**1
+ * contractId์™€ formCode(itemCode)๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ contractItemId๋ฅผ ์ฐพ๋Š” ์„œ๋ฒ„ ์•ก์…˜
+ *
+ * @param contractId - ๊ณ„์•ฝ ID
+ * @param formCode - ํผ ์ฝ”๋“œ (itemCode์™€ ๋™์ผ)
+ * @returns ์ฐพ์€ contractItemId ๋˜๋Š” null
+ */
+export async function findContractItemId(contractId: number, formCode: string): Promise<number | null> {
+ try {
+ console.log(`[findContractItemId] ๊ณ„์•ฝ ID ${contractId}์™€ formCode ${formCode}์— ๋Œ€ํ•œ contractItem ์กฐํšŒ ์‹œ์ž‘`);
+
+ // 1. forms ํ…Œ์ด๋ธ”์—์„œ formCode์— ํ•ด๋‹นํ•˜๋Š” ๋ชจ๋“  ๋ ˆ์ฝ”๋“œ ์กฐํšŒ
+ const formsResult = await db
+ .select({
+ contractItemId: forms.contractItemId
+ })
+ .from(forms)
+ .where(eq(forms.formCode, formCode));
+
+ if (formsResult.length === 0) {
+ console.warn(`[findContractItemId] formCode ${formCode}์— ํ•ด๋‹นํ•˜๋Š” form์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.`);
+ return null;
+ }
+
+ // ๋ชจ๋“  contractItemId ์ถ”์ถœ
+ const contractItemIds = formsResult.map(form => form.contractItemId);
+ console.log(`[findContractItemId] formCode ${formCode}์— ํ•ด๋‹นํ•˜๋Š” ${contractItemIds.length}๊ฐœ์˜ contractItemId ๋ฐœ๊ฒฌ`);
+
+ // 2. contractItems ํ…Œ์ด๋ธ”์—์„œ ์ถ”์ถœํ•œ contractItemId ์ค‘์—์„œ
+ // contractId๊ฐ€ ์ผ์น˜ํ•˜๋Š” ํ•ญ๋ชฉ ์ฐพ๊ธฐ
+ const contractItemResult = await db
+ .select({
+ id: contractItems.id
+ })
+ .from(contractItems)
+ .where(
+ and(
+ inArray(contractItems.id, contractItemIds),
+ eq(contractItems.contractId, contractId)
+ )
+ )
+ .limit(1);
+
+ if (contractItemResult.length === 0) {
+ console.warn(`[findContractItemId] ๊ณ„์•ฝ ID ${contractId}์™€ ์ผ์น˜ํ•˜๋Š” contractItemId๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.`);
+ return null;
+ }
+
+ const contractItemId = contractItemResult[0].id;
+ console.log(`[findContractItemId] ๊ณ„์•ฝ ์•„์ดํ…œ ID ${contractItemId} ๋ฐœ๊ฒฌ`);
+
+ return contractItemId;
+ } catch (error) {
+ console.error(`[findContractItemId] contractItem ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ:`, error);
+ return null;
+ }
+}
+
+export async function getPackageCodeById(contractItemId: number): Promise<string | null> {
+ try {
+
+ // 1. forms ํ…Œ์ด๋ธ”์—์„œ formCode์— ํ•ด๋‹นํ•˜๋Š” ๋ชจ๋“  ๋ ˆ์ฝ”๋“œ ์กฐํšŒ
+ const contractItemsResult = await db
+ .select({
+ itemId: contractItems.itemId
+ })
+ .from(contractItems)
+ .where(eq(contractItems.id, contractItemId))
+ .limit(1)
+ ;
+
+ if (contractItemsResult.length === 0) {
+ console.warn(`[contractItemId]์— ํ•ด๋‹นํ•˜๋Š” item์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.`);
+ return null;
+ }
+
+ const itemId = contractItemsResult[0].itemId
+
+ const packageCodeResult = await db
+ .select({
+ packageCode: items.packageCode
+ })
+ .from(items)
+ .where(eq(items.id, itemId))
+ .limit(1);
+
+ if (packageCodeResult.length === 0) {
+ console.warn(`${itemId}์™€ ์ผ์น˜ํ•˜๋Š” ํŒจํ‚ค์ง€ ์ฝ”๋“œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.`);
+ return null;
+ }
+
+ const packageCode = packageCodeResult[0].packageCode;
+
+ return packageCode;
+ } catch (error) {
+ console.error(`ํŒจํ‚ค์ง€ ์ฝ”๋“œ ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ:`, error);
+ return null;
+ }
+}
+
+
+export async function syncMissingTags(
+ contractItemId: number,
+ formCode: string
+) {
+ // (1) Ensure there's a row in `forms` matching (contractItemId, formCode).
+ const [formRow] = await db
+ .select()
+ .from(forms)
+ .where(
+ and(
+ eq(forms.contractItemId, contractItemId),
+ eq(forms.formCode, formCode)
+ )
+ )
+ .limit(1);
+
+ if (!formRow) {
+ throw new Error(
+ `Form not found for contractItemId=${contractItemId}, formCode=${formCode}`
+ );
+ }
+
+ // (2) Get all mappings from `tagTypeClassFormMappings` for this formCode.
+ const formMappings = await db
+ .select()
+ .from(tagTypeClassFormMappings)
+ .where(eq(tagTypeClassFormMappings.formCode, formCode));
+
+ // If no mappings are found, there's nothing to sync.
+ if (formMappings.length === 0) {
+ console.log(`No mappings found for formCode=${formCode}`);
+ return { createdCount: 0, updatedCount: 0, deletedCount: 0 };
+ }
+
+ // Build a dynamic OR clause to match (tagType, class) pairs from the mappings.
+ const orConditions = formMappings.map((m) =>
+ and(eq(tags.tagType, m.tagTypeLabel), eq(tags.class, m.classLabel))
+ );
+
+ // (3) Fetch all matching `tags` for the contractItemId + any of the (tagType, class) pairs.
+ const tagRows = await db
+ .select()
+ .from(tags)
+ .where(and(eq(tags.contractItemId, contractItemId), or(...orConditions)));
+
+ // (4) Fetch (or create) a single `formEntries` row for (contractItemId, formCode).
+ let [entry] = await db
+ .select()
+ .from(formEntries)
+ .where(
+ and(
+ eq(formEntries.contractItemId, contractItemId),
+ eq(formEntries.formCode, formCode)
+ )
+ )
+ .limit(1);
+
+ if (!entry) {
+ const [inserted] = await db
+ .insert(formEntries)
+ .values({
+ contractItemId,
+ formCode,
+ data: [], // Initialize with empty array
+ })
+ .returning();
+ entry = inserted;
+ }
+
+ // entry.data๋Š” [{ TAG_NO: string, TAG_DESC?: string }, ...] ํ˜•ํƒœ๋ผ๊ณ  ๊ฐ€์ •
+ const existingData = entry.data as Array<{
+ TAG_NO: string;
+ TAG_DESC?: string;
+ }>;
+
+ // Create a Set of valid tagNumbers from tagRows for efficient lookup
+ const validTagNumbers = new Set(tagRows.map((tag) => tag.tagNo));
+
+ // Copy existing data to work with
+ let updatedData: Array<{
+ TAG_NO: string;
+ TAG_DESC?: string;
+ }> = [];
+
+ let createdCount = 0;
+ let updatedCount = 0;
+ let deletedCount = 0;
+
+ // First, filter out items that should be deleted (not in validTagNumbers)
+ for (const item of existingData) {
+ if (validTagNumbers.has(item.TAG_NO)) {
+ updatedData.push(item);
+ } else {
+ deletedCount++;
+ }
+ }
+
+ // (5) For each tagRow, if it's missing in updatedData, push it in.
+ // ์ด๋ฏธ ์žˆ๋Š” ๊ฒฝ์šฐ์—๋„ description์ด ๋‹ฌ๋ผ์ง€๋ฉด ์—…๋ฐ์ดํŠธํ•  ์ˆ˜ ์žˆ์Œ.
+ for (const tagRow of tagRows) {
+ const { tagNo, description } = tagRow;
+
+ // 5-1. ๊ธฐ์กด ๋ฐ์ดํ„ฐ์—์„œ TAG_NO ๋งค์นญ
+ const existingIndex = updatedData.findIndex(
+ (item) => item.TAG_NO === tagNo
+ );
+
+ // 5-2. ์—†๋‹ค๋ฉด ์ƒˆ๋กœ ์ถ”๊ฐ€
+ if (existingIndex === -1) {
+ updatedData.push({
+ TAG_NO: tagNo,
+ TAG_DESC: description ?? "",
+ });
+ createdCount++;
+ } else {
+ // 5-3. ์ด๋ฏธ ์žˆ์œผ๋ฉด, description์ด ๋‹ค๋ฅผ ๋•Œ๋งŒ ์—…๋ฐ์ดํŠธ(์„ ํƒ ์‚ฌํ•ญ)
+ const existingItem = updatedData[existingIndex];
+ if (existingItem.TAG_DESC !== description) {
+ updatedData[existingIndex] = {
+ ...existingItem,
+ TAG_DESC: description ?? "",
+ };
+ updatedCount++;
+ }
+ }
+ }
+
+ // (6) ์‹ค์ œ๋กœ ์ถ”๊ฐ€๋˜๊ฑฐ๋‚˜ ์ˆ˜์ •๋˜๊ฑฐ๋‚˜ ์‚ญ์ œ๋œ ๊ฒŒ ์žˆ๋‹ค๋ฉด DB์— ๋ฐ˜์˜
+ if (createdCount > 0 || updatedCount > 0 || deletedCount > 0) {
+ await db
+ .update(formEntries)
+ .set({ data: updatedData })
+ .where(eq(formEntries.id, entry.id));
+ }
+
+ // ์บ์‹œ ๋ฌดํšจํ™” ๋“ฑ ํ›„์ฒ˜๋ฆฌ
+ revalidateTag(`form-data-${formCode}-${contractItemId}`);
+
+ return { createdCount, updatedCount, deletedCount };
+}
+
+/**
+ * updateFormDataInDB:
+ * (formCode, contractItemId)์— ํ•ด๋‹นํ•˜๋Š” "๋‹จ ํ•˜๋‚˜์˜" formEntries row๋ฅผ ๊ฐ€์ ธ์™€,
+ * data: [{ TAG_NO, ...}, ...] ๋ฐฐ์—ด์—์„œ TAG_NO ๋งค์นญ๋˜๋Š” ํ•ญ๋ชฉ์„ ์—…๋ฐ์ดํŠธ
+ * ์—…๋ฐ์ดํŠธ ํ›„, revalidateTag()๋กœ ์บ์‹œ ๋ฌดํšจํ™”.
+ */
+export interface UpdateResponse {
+ success: boolean;
+ message: string;
+ data?: {
+ updatedCount?: number;
+ failedCount?: number;
+ updatedTags?: string[];
+ notFoundTags?: string[];
+ updateTimestamp?: string;
+ error?: any;
+ invalidRows?: any[];
+ TAG_NO?: string;
+ updatedFields?: string[];
+ };
+}
+
+export async function updateFormDataInDB(
+ formCode: string,
+ contractItemId: number,
+ newData: Record<string, any>
+): Promise<UpdateResponse> {
+ try {
+ // 1) tagNumber๋กœ ์‹๋ณ„
+ const TAG_NO = newData.TAG_NO;
+ if (!TAG_NO) {
+ return {
+ success: false,
+ message: "tagNumber๋Š” ํ•„์ˆ˜ ํ•ญ๋ชฉ์ž…๋‹ˆ๋‹ค.",
+ };
+ }
+
+ // 2) row ์ฐพ๊ธฐ (๋‹จ ํ•˜๋‚˜)
+ const entries = await db
+ .select()
+ .from(formEntries)
+ .where(
+ and(
+ eq(formEntries.formCode, formCode),
+ eq(formEntries.contractItemId, contractItemId)
+ )
+ )
+ .limit(1);
+
+ if (!entries || entries.length === 0) {
+ return {
+ success: false,
+ message: `ํผ ๋ฐ์ดํ„ฐ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. (formCode=${formCode}, contractItemId=${contractItemId})`,
+ };
+ }
+
+ const entry = entries[0];
+
+ // 3) data๊ฐ€ ๋ฐฐ์—ด์ธ์ง€ ํ™•์ธ
+ if (!entry.data) {
+ return {
+ success: false,
+ message: "ํผ ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.",
+ };
+ }
+
+ const dataArray = entry.data as Array<Record<string, any>>;
+ if (!Array.isArray(dataArray)) {
+ return {
+ success: false,
+ message: "ํผ ๋ฐ์ดํ„ฐ๊ฐ€ ์˜ฌ๋ฐ”๋ฅธ ํ˜•์‹์ด ์•„๋‹™๋‹ˆ๋‹ค. ๋ฐฐ์—ด ํ˜•์‹์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.",
+ };
+ }
+
+ // 4) TAG_NO = newData.TAG_NO ํ•ญ๋ชฉ ์ฐพ๊ธฐ
+ const idx = dataArray.findIndex((item) => item.TAG_NO === TAG_NO);
+ if (idx < 0) {
+ return {
+ success: false,
+ message: `ํƒœ๊ทธ ๋ฒˆํ˜ธ "${TAG_NO}"๋ฅผ ๊ฐ€์ง„ ํ•ญ๋ชฉ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.`,
+ };
+ }
+
+ // 5) ๋ณ‘ํ•ฉ (status ํ•„๋“œ ์ถ”๊ฐ€)
+ const oldItem = dataArray[idx];
+ const updatedItem = {
+ ...oldItem,
+ ...newData,
+ TAG_NO: oldItem.TAG_NO, // TAG_NO ๋ณ€๊ฒฝ ๋ถˆ๊ฐ€ ์‹œ ์œ ์ง€
+ status: "Updated" // Excel์—์„œ ๊ฐ€์ ธ์˜จ ๋ฐ์ดํ„ฐ์ž„์„ ํ‘œ์‹œ
+ };
+
+ const updatedArray = [...dataArray];
+ updatedArray[idx] = updatedItem;
+
+ // 6) DB UPDATE
+ try {
+ await db
+ .update(formEntries)
+ .set({
+ data: updatedArray,
+ updatedAt: new Date(), // ์—…๋ฐ์ดํŠธ ์‹œ๊ฐ„๋„ ๊ฐฑ์‹ 
+ })
+ .where(eq(formEntries.id, entry.id));
+ } catch (dbError) {
+ console.error("Database update error:", dbError);
+
+ if (dbError instanceof DrizzleError) {
+ return {
+ success: false,
+ message: `๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—…๋ฐ์ดํŠธ ์˜ค๋ฅ˜: ${dbError.message}`,
+ };
+ }
+
+ return {
+ success: false,
+ message: "๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—…๋ฐ์ดํŠธ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.",
+ };
+ }
+
+ // 7) Cache ๋ฌดํšจํ™”
+ try {
+ // ์บ์‹œ ํƒœ๊ทธ๋ฅผ form-data-${formCode}-${contractItemId} ํ˜•ํƒœ๋กœ ๊ฐ€์ •
+ const cacheTag = `form-data-${formCode}-${contractItemId}`;
+ console.log(cacheTag, "update")
+ revalidateTag(cacheTag);
+ } catch (cacheError) {
+ console.warn("Cache revalidation warning:", cacheError);
+ // ์บ์‹œ ๋ฌดํšจํ™”๋Š” ์‹คํŒจํ•ด๋„ ์—…๋ฐ์ดํŠธ ์ž์ฒด๋Š” ์„ฑ๊ณตํ–ˆ์œผ๋ฏ€๋กœ ๊ฒฝ๊ณ ๋งŒ ๋กœ๊ทธ๋กœ ๋‚จ๊น€
+ }
+
+ return {
+ success: true,
+ message: "๋ฐ์ดํ„ฐ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์—…๋ฐ์ดํŠธ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.",
+ data: {
+ TAG_NO,
+ updatedFields: Object.keys(newData).filter(
+ (key) => key !== "TAG_NO"
+ ),
+ },
+ };
+ } catch (error) {
+ // ์˜ˆ์ƒ์น˜ ๋ชปํ•œ ์˜ค๋ฅ˜ ์ฒ˜๋ฆฌ
+ console.error("Unexpected error in updateFormDataInDB:", error);
+ return {
+ success: false,
+ message:
+ error instanceof Error
+ ? `์˜ˆ์ƒ์น˜ ๋ชปํ•œ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: ${error.message}`
+ : "์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.",
+ };
+ }
+}
+
+export async function updateFormDataBatchInDB(
+ formCode: string,
+ contractItemId: number,
+ newDataArray: Record<string, any>[]
+): Promise<UpdateResponse> {
+ try {
+ // ์ž…๋ ฅ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ
+ if (!newDataArray || newDataArray.length === 0) {
+ return {
+ success: false,
+ message: "์—…๋ฐ์ดํŠธํ•  ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.",
+ };
+ }
+
+ // TAG_NO ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ
+ const invalidRows = newDataArray.filter(row => !row.TAG_NO);
+ if (invalidRows.length > 0) {
+ return {
+ success: false,
+ message: `${invalidRows.length}๊ฐœ ํ–‰์— TAG_NO๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.`,
+ data: { invalidRows }
+ };
+ }
+
+ // 1) DB์—์„œ ํ˜„์žฌ ๋ฐ์ดํ„ฐ ๊ฐ€์ ธ์˜ค๊ธฐ
+ const entries = await db
+ .select()
+ .from(formEntries)
+ .where(
+ and(
+ eq(formEntries.formCode, formCode),
+ eq(formEntries.contractItemId, contractItemId)
+ )
+ )
+ .limit(1);
+
+ if (!entries || entries.length === 0) {
+ return {
+ success: false,
+ message: `ํผ ๋ฐ์ดํ„ฐ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. (formCode=${formCode}, contractItemId=${contractItemId})`,
+ };
+ }
+
+ const entry = entries[0];
+
+ // ๋ฐ์ดํ„ฐ ํ˜•์‹ ๊ฒ€์ฆ
+ if (!entry.data) {
+ return {
+ success: false,
+ message: "ํผ ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.",
+ };
+ }
+
+ const dataArray = entry.data as Array<Record<string, any>>;
+ if (!Array.isArray(dataArray)) {
+ return {
+ success: false,
+ message: "ํผ ๋ฐ์ดํ„ฐ๊ฐ€ ์˜ฌ๋ฐ”๋ฅธ ํ˜•์‹์ด ์•„๋‹™๋‹ˆ๋‹ค. ๋ฐฐ์—ด ํ˜•์‹์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.",
+ };
+ }
+
+ // 2) ๋ชจ๋“  ๋ณ€๊ฒฝ์‚ฌํ•ญ์„ ํ•œ๋ฒˆ์— ์ ์šฉ
+ const updatedArray = [...dataArray];
+ const updatedTags: string[] = [];
+ const notFoundTags: string[] = [];
+ const updateTimestamp = new Date().toISOString();
+
+ // ๊ฐ import row์— ๋Œ€ํ•ด ์—…๋ฐ์ดํŠธ ์ˆ˜ํ–‰
+ for (const newData of newDataArray) {
+ const TAG_NO = newData.TAG_NO;
+ const idx = updatedArray.findIndex(item => item.TAG_NO === TAG_NO);
+
+ if (idx >= 0) {
+ // ๊ธฐ์กด ๋ฐ์ดํ„ฐ์™€ ๋ณ‘ํ•ฉ
+ const oldItem = updatedArray[idx];
+ updatedArray[idx] = {
+ ...oldItem,
+ ...newData,
+ TAG_NO: oldItem.TAG_NO, // TAG_NO๋Š” ๋ณ€๊ฒฝ ๋ถˆ๊ฐ€
+ TAG_DESC: oldItem.TAG_DESC, // TAG_DESC๋„ ๋ณด์กด
+ status: "Updated", // Excel import ํ‘œ์‹œ
+ lastUpdated: updateTimestamp // ์—…๋ฐ์ดํŠธ ์‹œ๊ฐ ์ถ”๊ฐ€
+ };
+ updatedTags.push(TAG_NO);
+ } else {
+ // TAG๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†๋Š” ๊ฒฝ์šฐ
+ notFoundTags.push(TAG_NO);
+ }
+ }
+
+ // ํ•˜๋‚˜๋„ ์—…๋ฐ์ดํŠธํ•  ํ•ญ๋ชฉ์ด ์—†๋Š” ๊ฒฝ์šฐ
+ if (updatedTags.length === 0) {
+ return {
+ success: false,
+ message: `์—…๋ฐ์ดํŠธํ•  ์ˆ˜ ์žˆ๋Š” TAG๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ๋ชจ๋“  ${notFoundTags.length}๊ฐœ TAG๊ฐ€ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์—†์Šต๋‹ˆ๋‹ค.`,
+ data: {
+ updatedCount: 0,
+ failedCount: notFoundTags.length,
+ notFoundTags
+ }
+ };
+ }
+
+ // 3) DB์— ํ•œ ๋ฒˆ๋งŒ ์ €์žฅ
+ try {
+ await db
+ .update(formEntries)
+ .set({
+ data: updatedArray,
+ updatedAt: new Date(),
+ })
+ .where(eq(formEntries.id, entry.id));
+
+ } catch (dbError) {
+ console.error("Database update error:", dbError);
+
+ if (dbError instanceof DrizzleError) {
+ return {
+ success: false,
+ message: `๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—…๋ฐ์ดํŠธ ์˜ค๋ฅ˜: ${dbError.message}`,
+ data: {
+ updatedCount: 0,
+ failedCount: newDataArray.length,
+ error: dbError
+ }
+ };
+ }
+
+ return {
+ success: false,
+ message: "๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—…๋ฐ์ดํŠธ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.",
+ data: {
+ updatedCount: 0,
+ failedCount: newDataArray.length
+ }
+ };
+ }
+
+ // 4) ์บ์‹œ ๋ฌดํšจํ™”
+ try {
+ const cacheTag = `form-data-${formCode}-${contractItemId}`;
+ console.log(`Cache invalidated: ${cacheTag}`);
+ revalidateTag(cacheTag);
+ } catch (cacheError) {
+ // ์บ์‹œ ๋ฌดํšจํ™” ์‹คํŒจ๋Š” ๊ฒฝ๊ณ ๋งŒ
+ console.warn("Cache revalidation warning:", cacheError);
+ }
+
+ // 5) ์„ฑ๊ณต ์‘๋‹ต
+ const message = notFoundTags.length > 0
+ ? `${updatedTags.length}๊ฐœ ํ•ญ๋ชฉ์ด ์—…๋ฐ์ดํŠธ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. (${notFoundTags.length}๊ฐœ TAG๋Š” ์ฐพ์„ ์ˆ˜ ์—†์Œ)`
+ : `${updatedTags.length}๊ฐœ ํ•ญ๋ชฉ์ด ์„ฑ๊ณต์ ์œผ๋กœ ์—…๋ฐ์ดํŠธ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.`;
+
+ return {
+ success: true,
+ message: message,
+ data: {
+ updatedCount: updatedTags.length,
+ updatedTags,
+ notFoundTags: notFoundTags.length > 0 ? notFoundTags : undefined,
+ failedCount: notFoundTags.length,
+ updateTimestamp
+ },
+ };
+
+ } catch (error) {
+ // ์˜ˆ์ƒ์น˜ ๋ชปํ•œ ์˜ค๋ฅ˜ ์ฒ˜๋ฆฌ
+ console.error("Unexpected error in updateFormDataBatchInDB:", error);
+
+ return {
+ success: false,
+ message: error instanceof Error
+ ? `์˜ˆ์ƒ์น˜ ๋ชปํ•œ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: ${error.message}`
+ : "์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.",
+ data: {
+ updatedCount: 0,
+ failedCount: newDataArray.length,
+ error: error
+ }
+ };
+ }
+}
+
+// FormColumn Type (๋™์ผ)
+export interface FormColumn {
+ key: string;
+ type: string;
+ label: string;
+ options?: string[];
+}
+
+interface MetadataResult {
+ formName: string;
+ formCode: string;
+ columns: FormColumn[];
+}
+
+/**
+ * ์„œ๋ฒ„ ์•ก์…˜:
+ * ์ฃผ์–ด์ง„ formCode์— ํ•ด๋‹นํ•˜๋Š” form_metas ๋ ˆ์ฝ”๋“œ 1๊ฐœ๋ฅผ ์ฐพ์•„์„œ
+ * { formName, formCode, columns } ํ˜•ํƒœ๋กœ ๋ฐ˜ํ™˜.
+ * ์—†์œผ๋ฉด null.
+ */
+export async function fetchFormMetadata(
+ formCode: string,
+ projectId: number
+): Promise<MetadataResult | null> {
+ try {
+ // ๊ธฐ์กด ๋ฐฉ์‹: select().from().where()
+ const rows = await db
+ .select()
+ .from(formMetas)
+ .where(and(eq(formMetas.formCode, formCode), eq(formMetas.projectId, projectId)))
+ .limit(1);
+
+ // rows๋Š” ๋ฐฐ์—ด
+ const metaData = rows[0];
+ if (!metaData) return null;
+
+ return {
+ formCode: metaData.formCode,
+ formName: metaData.formName,
+ columns: metaData.columns as FormColumn[],
+ };
+ } catch (err) {
+ console.error("Error in fetchFormMetadata:", err);
+ return null;
+ }
+}
+
+type GetReportFileList = (
+ packageId: string,
+ formCode: string
+) => Promise<{
+ formId: number;
+}>;
+
+export const getFormId: GetReportFileList = async (packageId, formCode) => {
+ const result: { formId: number } = {
+ formId: 0,
+ };
+ try {
+ const [targetForm] = await db
+ .select()
+ .from(forms)
+ .where(
+ and(
+ eq(forms.formCode, formCode),
+ eq(forms.contractItemId, Number(packageId))
+ )
+ );
+
+ if (!targetForm) {
+ throw new Error("Not Found Target Form");
+ }
+
+ const { id: formId } = targetForm;
+
+ result.formId = formId;
+ } catch (err) {
+ } finally {
+ return result;
+ }
+};
+
+type getReportTempList = (
+ packageId: number,
+ formId: number
+) => Promise<VendorDataReportTemps[]>;
+
+export const getReportTempList: getReportTempList = async (
+ packageId,
+ formId
+) => {
+ let result: VendorDataReportTemps[] = [];
+
+ try {
+ result = await db
+ .select()
+ .from(vendorDataReportTemps)
+ .where(
+ and(
+ eq(vendorDataReportTemps.contractItemId, packageId),
+ eq(vendorDataReportTemps.formId, formId)
+ )
+ );
+ } catch (err) {
+ } finally {
+ return result;
+ }
+};
+
+export async function uploadReportTemp(
+ packageId: number,
+ formId: number,
+ formData: FormData
+) {
+ const file = formData.get("file") as File | null;
+ const customFileName = formData.get("customFileName") as string;
+ const uploaderType = (formData.get("uploaderType") as string) || "vendor";
+
+ if (!["vendor", "client", "shi"].includes(uploaderType)) {
+ throw new Error(
+ `Invalid uploaderType: ${uploaderType}. Must be one of: vendor, client, shi`
+ );
+ }
+ if (file && file.size > 0) {
+
+ const saveResult = await saveFile({ file, directory: "vendorFormData", originalName: customFileName });
+ if (!saveResult.success) {
+ return { success: false, error: saveResult.error };
+ }
+
+ return db.transaction(async (tx) => {
+ // ํŒŒ์ผ ์ •๋ณด๋ฅผ ํ…Œ์ด๋ธ”์— ์ €์žฅ
+ await tx
+ .insert(vendorDataReportTemps)
+ .values({
+ contractItemId: packageId,
+ formId: formId,
+ fileName: customFileName,
+ filePath: saveResult.publicPath!,
+ })
+ .returning();
+ });
+ }
+}
+
+export const getOrigin = async (): Promise<string> => {
+ const headersList = await headers();
+ const host = headersList.get("host");
+ const proto = headersList.get("x-forwarded-proto") || "http"; // ๊ธฐ๋ณธ๊ฐ’์€ http
+ const origin = `${proto}://${host}`;
+
+ return origin;
+};
+
+
+type deleteReportTempFile = (id: number) => Promise<{
+ result: boolean;
+ error?: any;
+}>;
+
+export const deleteReportTempFile: deleteReportTempFile = async (id) => {
+ try {
+ return db.transaction(async (tx) => {
+ const [targetTempFile] = await tx
+ .select()
+ .from(vendorDataReportTemps)
+ .where(eq(vendorDataReportTemps.id, id));
+
+ if (!targetTempFile) {
+ throw new Error("ํ•ด๋‹น Template File์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.");
+ }
+
+ await tx
+ .delete(vendorDataReportTemps)
+ .where(eq(vendorDataReportTemps.id, id));
+
+ const { filePath } = targetTempFile;
+
+ await deleteFile(filePath);
+
+ return { result: true };
+ });
+ } catch (err) {
+ return { result: false, error: (err as Error).message };
+ }
+};
+
+
+/**
+ * Get tag type mappings specific to a form
+ * @param formCode The form code to filter mappings
+ * @param projectId The project ID
+ * @returns Array of tag type-class mappings for the form
+ */
+export async function getFormTagTypeMappings(formCode: string, projectId: number) {
+
+ try {
+ const mappings = await db.query.tagTypeClassFormMappings.findMany({
+ where: and(
+ eq(tagTypeClassFormMappings.formCode, formCode),
+ eq(tagTypeClassFormMappings.projectId, projectId)
+ )
+ });
+
+ return mappings;
+ } catch (error) {
+ console.error("Error fetching form tag type mappings:", error);
+ throw new Error("Failed to load form tag type mappings");
+ }
+}
+
+/**
+ * Get tag type by its description
+ * @param description The tag type description (used as tagTypeLabel in mappings)
+ * @param projectId The project ID
+ * @returns The tag type object
+ */
+export async function getTagTypeByDescription(description: string, projectId: number) {
+ try {
+ const tagType = await db.query.tagTypes.findFirst({
+ where: and(
+ eq(tagTypes.description, description),
+ eq(tagTypes.projectId, projectId)
+ )
+ });
+
+ return tagType;
+ } catch (error) {
+ console.error("Error fetching tag type by description:", error);
+ throw new Error("Failed to load tag type");
+ }
+}
+
+/**
+ * Get subfields for a specific tag type
+ * @param tagTypeCode The tag type code
+ * @param projectId The project ID
+ * @returns Object containing subfields with their options
+ */
+export async function getSubfieldsByTagTypeForForm(tagTypeCode: string, projectId: number) {
+ try {
+ const subfields = await db.query.tagSubfields.findMany({
+ where: and(
+ eq(tagSubfields.tagTypeCode, tagTypeCode),
+ eq(tagSubfields.projectId, projectId)
+ ),
+ orderBy: tagSubfields.sortOrder
+ });
+
+ const subfieldsWithOptions = await Promise.all(
+ subfields.map(async (subfield) => {
+ const options = await db.query.tagSubfieldOptions.findMany({
+ where: and(
+ eq(tagSubfieldOptions.attributesId, subfield.attributesId),
+ eq(tagSubfieldOptions.projectId, projectId)
+ )
+ });
+
+ return {
+ name: subfield.attributesId,
+ label: subfield.attributesDescription,
+ type: options.length > 0 ? "select" : "text",
+ options: options.map(opt => ({ value: opt.code, label: opt.label })),
+ expression: subfield.expression || undefined,
+ delimiter: subfield.delimiter || undefined
+ };
+ })
+ );
+
+ return { subFields: subfieldsWithOptions };
+ } catch (error) {
+ console.error("Error fetching subfields for form:", error);
+ throw new Error("Failed to load subfields");
+ }
+}
+
+interface GenericData {
+ [key: string]: any;
+}
+
+interface SEDPAttribute {
+ NAME: string;
+ VALUE: any;
+ UOM: string;
+ UOM_ID?: string;
+ CLS_ID?:string;
+}
+
+interface SEDPDataItem {
+ TAG_NO: string;
+ TAG_DESC: string;
+ CLS_ID: string;
+ ATTRIBUTES: SEDPAttribute[];
+ SCOPE: string;
+ TOOLID: string;
+ ITM_NO: string;
+ OP_DELETE: boolean;
+ MAIN_YN: boolean;
+ LAST_REV_YN: boolean;
+ CRTER_NO: string;
+ CHGER_NO: string;
+ TYPE: string;
+ PROJ_NO: string;
+ REV_NO: string;
+ CRTE_DTM?: string;
+ CHGE_DTM?: string;
+ _id?: string;
+}
+
+async function transformDataToSEDPFormat(
+ tableData: GenericData[],
+ columnsJSON: DataTableColumnJSON[],
+ formCode: string,
+ objectCode: string,
+ projectNo: string,
+ contractItemId: number, // Add contractItemId parameter
+ designerNo: string = "253213"
+): Promise<SEDPDataItem[]> {
+ // Create a map for quick column lookup
+ const columnsMap = new Map<string, DataTableColumnJSON>();
+ columnsJSON.forEach(col => {
+ columnsMap.set(col.key, col);
+ });
+
+ // Current timestamp for CRTE_DTM and CHGE_DTM
+ const currentTimestamp = new Date().toISOString();
+
+ // Define the API base URL
+ const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api';
+
+ // Get the token
+ const apiKey = await getSEDPToken();
+
+ // Cache for UOM factors to avoid duplicate API calls
+ const uomFactorCache = new Map<string, number>();
+
+ // Cache for packageCode to avoid duplicate DB queries for same tag
+ const packageCodeCache = new Map<string, string>();
+
+ // Cache for tagClass code to avoid duplicate DB queries for same tag
+ const tagClassCodeCache = new Map<string, string>();
+
+ // Transform each row
+ const transformedItems = [];
+
+ for (const row of tableData) {
+
+ const cotractItem = await db.query.contractItems.findFirst({
+ where:
+ eq(contractItems.id, contractItemId),
+ });
+
+ const item = await db.query.items.findFirst({
+ where:
+ eq(items.id, cotractItem.itemId),
+ });
+
+ // Get packageCode for this specific tag
+ let packageCode = item.packageCode; // fallback to formCode
+ let tagClassCode = ""; // for CLS_ID
+
+ if (row.TAG_NO && contractItemId) {
+ // Check cache first
+ const cacheKey = `${contractItemId}-${row.TAG_NO}`;
+
+ if (packageCodeCache.has(cacheKey)) {
+ packageCode = packageCodeCache.get(cacheKey)!;
+ } else {
+ try {
+ // Query to get packageCode for this specific tag
+ const tagResult = await db.query.tags.findFirst({
+ where: and(
+ eq(tags.contractItemId, contractItemId),
+ eq(tags.tagNo, row.TAG_NO)
+ )
+ });
+
+ if (tagResult) {
+ // Get tagClass code if tagClassId exists
+ if (tagResult.tagClassId) {
+ // Check tagClass cache first
+ if (tagClassCodeCache.has(cacheKey)) {
+ tagClassCode = tagClassCodeCache.get(cacheKey)!;
+ } else {
+ const tagClassResult = await db.query.tagClasses.findFirst({
+ where: eq(tagClasses.id, tagResult.tagClassId)
+ });
+
+ if (tagClassResult) {
+ tagClassCode = tagClassResult.code;
+ console.log(`Found tagClass code for tag ${row.TAG_NO}: ${tagClassCode}`);
+ } else {
+ console.warn(`No tagClass found for tagClassId: ${tagResult.tagClassId}`);
+ }
+
+ // Cache the tagClass code result
+ tagClassCodeCache.set(cacheKey, tagClassCode);
+ }
+ }
+
+ // Get the contract item
+ const contractItemResult = await db.query.contractItems.findFirst({
+ where: eq(contractItems.id, tagResult.contractItemId)
+ });
+
+ if (contractItemResult) {
+ // Get the first item with this itemId
+ const itemResult = await db.query.items.findFirst({
+ where: eq(items.id, contractItemResult.itemId)
+ });
+
+ if (itemResult && itemResult.packageCode) {
+ packageCode = itemResult.packageCode;
+ console.log(`Found packageCode for tag ${row.TAG_NO}: ${packageCode}`);
+ } else {
+ console.warn(`No item found for contractItem.itemId: ${contractItemResult.itemId}, using fallback`);
+ }
+ } else {
+ console.warn(`No contractItem found for tag ${row.TAG_NO}, using fallback`);
+ }
+ } else {
+ console.warn(`No tag found for contractItemId: ${contractItemId}, tagNo: ${row.TAG_NO}, using fallback`);
+ }
+
+ // Cache the result (even if it's the fallback value)
+ packageCodeCache.set(cacheKey, packageCode);
+ } catch (error) {
+ console.error(`Error fetching packageCode for tag ${row.TAG_NO}:`, error);
+ // Use fallback value and cache it
+ packageCodeCache.set(cacheKey, packageCode);
+ }
+ }
+
+ // Get tagClass code if not already retrieved above
+ if (!tagClassCode && tagClassCodeCache.has(cacheKey)) {
+ tagClassCode = tagClassCodeCache.get(cacheKey)!;
+ } else if (!tagClassCode) {
+ try {
+ const tagResult = await db.query.tags.findFirst({
+ where: and(
+ eq(tags.contractItemId, contractItemId),
+ eq(tags.tagNo, row.TAG_NO)
+ )
+ });
+
+ if (tagResult && tagResult.tagClassId) {
+ const tagClassResult = await db.query.tagClasses.findFirst({
+ where: eq(tagClasses.id, tagResult.tagClassId)
+ });
+
+ if (tagClassResult) {
+ tagClassCode = tagClassResult.code;
+ console.log(`Found tagClass code for tag ${row.TAG_NO}: ${tagClassCode}`);
+ }
+ }
+
+ // Cache the tagClass code result
+ tagClassCodeCache.set(cacheKey, tagClassCode);
+ } catch (error) {
+ console.error(`Error fetching tagClass code for tag ${row.TAG_NO}:`, error);
+ // Cache empty string as fallback
+ tagClassCodeCache.set(cacheKey, "");
+ }
+ }
+ }
+
+ // Create base SEDP item with required fields
+ const sedpItem: SEDPDataItem = {
+ TAG_NO: row.TAG_NO || "",
+ TAG_DESC: row.TAG_DESC || "",
+ ATTRIBUTES: [],
+ // SCOPE: objectCode,
+ SCOPE: packageCode,
+ TOOLID: "eVCP", // Changed from VDCS
+ ITM_NO: row.TAG_NO || "",
+ OP_DELETE: false,
+ MAIN_YN: true,
+ LAST_REV_YN: true,
+ CRTER_NO: designerNo,
+ CHGER_NO: designerNo,
+ TYPE: formCode, // Use packageCode instead of formCode
+ CLS_ID: tagClassCode, // Add CLS_ID with tagClass code
+ PROJ_NO: projectNo,
+ REV_NO: "00",
+ CRTE_DTM: currentTimestamp,
+ CHGE_DTM: currentTimestamp,
+ _id: ""
+ };
+
+ // Convert all other fields (except TAG_NO and TAG_DESC) to ATTRIBUTES
+ for (const key in row) {
+ if (key !== "TAG_NO" && key !== "TAG_DESC") {
+ const column = columnsMap.get(key);
+ let value = row[key];
+
+ // Only process non-empty values
+ if (value !== undefined && value !== null && value !== "") {
+ // Check if we need to apply UOM conversion
+ if (column?.uomId) {
+ // First check cache to avoid duplicate API calls
+ let factor = uomFactorCache.get(column.uomId);
+
+ // If not in cache, make API call to get the factor
+ if (factor === undefined) {
+ try {
+ const response = await fetch(
+ `${SEDP_API_BASE_URL}/UOM/GetByID`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'accept': '*/*',
+ 'ApiKey': apiKey,
+ 'ProjectNo': projectNo
+ },
+ body: JSON.stringify({
+ 'ProjectNo': projectNo,
+ 'UOMID': column.uomId,
+ 'ContainDeleted': false
+ })
+ }
+ );
+
+ if (response.ok) {
+ const uomData = await response.json();
+ if (uomData && uomData.FACTOR !== undefined && uomData.FACTOR !== null) {
+ factor = Number(uomData.FACTOR);
+ // Store in cache for future use (type assertion to ensure it's a number)
+ uomFactorCache.set(column.uomId, factor);
+ }
+ } else {
+ console.warn(`Failed to get UOM data for ${column.uomId}: ${response.statusText}`);
+ }
+ } catch (error) {
+ console.error(`Error fetching UOM data for ${column.uomId}:`, error);
+ }
+ }
+
+ // Apply the factor if we got one
+ // if (factor !== undefined && typeof value === 'number') {
+ // value = value * factor;
+ // }
+ }
+
+ const attribute: SEDPAttribute = {
+ NAME: key,
+ VALUE: String(value), // ๋ชจ๋“  ๊ฐ’์„ ๋ฌธ์ž์—ด๋กœ ๋ณ€ํ™˜
+ UOM: column?.uom || "",
+ CLS_ID: tagClassCode || "",
+ };
+
+ // Add UOM_ID if present in column definition
+ if (column?.uomId) {
+ attribute.UOM_ID = column.uomId;
+ }
+
+ sedpItem.ATTRIBUTES.push(attribute);
+ }
+ }
+ }
+
+ transformedItems.push(sedpItem);
+ }
+
+ return transformedItems;
+}
+
+// Server Action wrapper (async)
+export async function transformFormDataToSEDP(
+ tableData: GenericData[],
+ columnsJSON: DataTableColumnJSON[],
+ formCode: string,
+ objectCode: string,
+ projectNo: string,
+ contractItemId: number, // Add contractItemId parameter
+ designerNo: string = "253213"
+): Promise<SEDPDataItem[]> {
+ return transformDataToSEDPFormat(
+ tableData,
+ columnsJSON,
+ formCode,
+ objectCode,
+ projectNo,
+ contractItemId, // Pass contractItemId
+ designerNo
+ );
+}
+/**
+ * Get project code by project ID
+ */
+export async function getProjectCodeById(projectId: number): Promise<string> {
+ const projectRecord = await db
+ .select({ code: projects.code })
+ .from(projects)
+ .where(eq(projects.id, projectId))
+ .limit(1);
+
+ if (!projectRecord || projectRecord.length === 0) {
+ throw new Error(`Project not found with ID: ${projectId}`);
+ }
+
+ return projectRecord[0].code;
+}
+
+export async function getProjectById(projectId: number): Promise<{ code: string; type: string; }> {
+ const projectRecord = await db
+ .select({ code: projects.code , type:projects.type})
+ .from(projects)
+ .where(eq(projects.id, projectId))
+ .limit(1);
+
+ if (!projectRecord || projectRecord.length === 0) {
+ throw new Error(`Project not found with ID: ${projectId}`);
+ }
+
+ return projectRecord[0];
+}
+
+
+/**
+ * Send data to SEDP
+ */
+export async function sendDataToSEDP(
+ projectCode: string,
+ sedpData: SEDPDataItem[]
+): Promise<any> {
+ try {
+ // Get the token
+ const apiKey = await getSEDPToken();
+
+ // Define the API base URL
+ const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api';
+
+ console.log("Sending data to SEDP:", JSON.stringify(sedpData, null, 2));
+
+ // Make the API call
+ const response = await fetch(
+ `${SEDP_API_BASE_URL}/AdapterData/Overwrite`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'accept': '*/*',
+ 'ApiKey': apiKey,
+ 'ProjectNo': projectCode
+ },
+ body: JSON.stringify(sedpData)
+ }
+ );
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(`SEDP API request failed: ${response.status} ${response.statusText} - ${errorText}`);
+ }
+
+ const data = await response.json();
+ return data;
+ } catch (error: any) {
+ console.error('Error calling SEDP API:', error);
+ throw new Error(`Failed to send data to SEDP API: ${error.message || 'Unknown error'}`);
+ }
+}
+
+/**
+ * Server action to send form data to SEDP
+ */
+export async function sendFormDataToSEDP(
+ formCode: string,
+ projectId: number,
+ contractItemId: number, // contractItemId ํŒŒ๋ผ๋ฏธํ„ฐ ์ถ”๊ฐ€
+ formData: GenericData[],
+ columns: DataTableColumnJSON[]
+): Promise<{ success: boolean; message: string; data?: any }> {
+ try {
+ // 1. Get project code
+ const projectCode = await getProjectCodeById(projectId);
+
+ // 2. Get class mapping
+ const mappingsResult = await db.query.tagTypeClassFormMappings.findFirst({
+ where: and(
+ eq(tagTypeClassFormMappings.formCode, formCode),
+ eq(tagTypeClassFormMappings.projectId, projectId)
+ )
+ });
+
+ // Check if mappings is an array or a single object and handle accordingly
+ const mappings = Array.isArray(mappingsResult) ? mappingsResult[0] : mappingsResult;
+
+ // Default object code to fallback value if we can't find it
+ let objectCode = ""; // Default fallback
+
+ if (mappings && mappings.classLabel) {
+ const objectCodeResult = await db.query.tagClasses.findFirst({
+ where: and(
+ eq(tagClasses.label, mappings.classLabel),
+ eq(tagClasses.projectId, projectId)
+ )
+ });
+
+ // Check if result is an array or a single object
+ const objectCodeRecord = Array.isArray(objectCodeResult) ? objectCodeResult[0] : objectCodeResult;
+
+ if (objectCodeRecord && objectCodeRecord.code) {
+ objectCode = objectCodeRecord.code;
+ } else {
+ console.warn(`No tag class found for label ${mappings.classLabel} in project ${projectId}, using default`);
+ }
+ } else {
+ console.warn(`No mapping found for formCode ${formCode} in project ${projectId}, using default object code`);
+ }
+
+ // 3. Transform data to SEDP format
+ const sedpData = await transformFormDataToSEDP(
+ formData,
+ columns,
+ formCode,
+ objectCode,
+ projectCode,
+ contractItemId // Add contractItemId parameter
+ );
+
+ // 4. Send to SEDP API
+ const result = await sendDataToSEDP(projectCode, sedpData);
+
+ // 5. SEDP ์ „์†ก ์„ฑ๊ณต ํ›„ formEntries์— status ์—…๋ฐ์ดํŠธ
+ try {
+ // Get the current formEntries data
+ const entries = await db
+ .select()
+ .from(formEntries)
+ .where(
+ and(
+ eq(formEntries.formCode, formCode),
+ eq(formEntries.contractItemId, contractItemId)
+ )
+ )
+ .limit(1);
+
+ if (entries && entries.length > 0) {
+ const entry = entries[0];
+ const dataArray = entry.data as Array<Record<string, any>>;
+
+ if (Array.isArray(dataArray)) {
+ // Extract TAG_NO list from formData
+ const sentTagNumbers = new Set(
+ formData
+ .map(item => item.TAG_NO)
+ .filter(tagNo => tagNo) // Remove null/undefined values
+ );
+
+ // Update status for sent tags
+ const updatedDataArray = dataArray.map(item => {
+ if (item.TAG_NO && sentTagNumbers.has(item.TAG_NO)) {
+ return {
+ ...item,
+ status: "Sent to S-EDP" // SEDP๋กœ ์ „์†ก๋œ ๋ฐ์ดํ„ฐ์ž„์„ ํ‘œ์‹œ
+ };
+ }
+ return item;
+ });
+
+ // Update the database
+ await db
+ .update(formEntries)
+ .set({
+ data: updatedDataArray,
+ updatedAt: new Date()
+ })
+ .where(eq(formEntries.id, entry.id));
+
+ console.log(`Updated status for ${sentTagNumbers.size} tags to "Sent to S-EDP"`);
+ }
+ } else {
+ console.warn(`No formEntries found for formCode: ${formCode}, contractItemId: ${contractItemId}`);
+ }
+ } catch (statusUpdateError) {
+ // Status ์—…๋ฐ์ดํŠธ ์‹คํŒจ๋Š” ๊ฒฝ๊ณ ๋กœ๋งŒ ์ฒ˜๋ฆฌ (SEDP ์ „์†ก์€ ์„ฑ๊ณตํ–ˆ์œผ๋ฏ€๋กœ)
+ console.warn("Failed to update status after SEDP send:", statusUpdateError);
+ }
+
+ return {
+ success: true,
+ message: "Data successfully sent to SEDP",
+ data: result
+ };
+ } catch (error: any) {
+ console.error("Error sending data to SEDP:", error);
+ return {
+ success: false,
+ message: error.message || "Failed to send data to SEDP"
+ };
+ }
+}
+
+
+export async function deleteFormDataByTags({
+ formCode,
+ contractItemId,
+ tagIdxs,
+}: {
+ formCode: string
+ contractItemId: number
+ tagIdxs: string[]
+}): Promise<{
+ error?: string
+ success?: boolean
+ deletedCount?: number
+ deletedTagsCount?: number
+}> {
+ try {
+ // ์ž…๋ ฅ ๊ฒ€์ฆ
+ if (!formCode || !contractItemId || !Array.isArray(tagIdxs) || tagIdxs.length === 0) {
+ return {
+ error: "Missing required parameters: formCode, contractItemId, tagIdxs",
+ }
+ }
+
+ console.log(`[DELETE ACTION] Deleting tags for formCode: ${formCode}, contractItemId: ${contractItemId}, tagNos:`, tagIdxs)
+
+ // ํŠธ๋žœ์žญ์…˜์œผ๋กœ ์•ˆ์ „ํ•˜๊ฒŒ ์ฒ˜๋ฆฌ
+ const result = await db.transaction(async (tx) => {
+ // 1. ํ˜„์žฌ formEntry ๋ฐ์ดํ„ฐ ๊ฐ€์ ธ์˜ค๊ธฐ
+ const currentEntryResult = await tx
+ .select()
+ .from(formEntries)
+ .where(
+ and(
+ eq(formEntries.formCode, formCode),
+ eq(formEntries.contractItemId, contractItemId)
+ )
+ )
+ .orderBy(desc(formEntries.updatedAt))
+ .limit(1)
+
+ if (currentEntryResult.length === 0) {
+ throw new Error("Form entry not found")
+ }
+
+ const currentEntry = currentEntryResult[0]
+ let currentData = Array.isArray(currentEntry.data) ? currentEntry.data : []
+
+ console.log(`[DELETE ACTION] Current data count: ${currentData.length}`)
+
+ // 2. ์‚ญ์ œํ•  ํ•ญ๋ชฉ๋“ค ํ•„ํ„ฐ๋ง (formEntries์—์„œ)
+ const updatedData = currentData.filter((item: any) =>
+ !tagIdxs.includes(item.TAG_IDX)
+ )
+
+ const deletedFromFormEntries = currentData.length - updatedData.length
+
+ console.log(`[DELETE ACTION] Updated data count: ${updatedData.length}`)
+ console.log(`[DELETE ACTION] Deleted ${deletedFromFormEntries} items from formEntries`)
+
+ if (deletedFromFormEntries === 0) {
+ throw new Error("No items were found to delete in formEntries")
+ }
+
+ // 3. tags ํ…Œ์ด๋ธ”์—์„œ ํ•ด๋‹น ํƒœ๊ทธ๋“ค ์‚ญ์ œ
+ const deletedTagsResult = await tx
+ .delete(tags)
+ .where(
+ and(
+ eq(tags.contractItemId, contractItemId),
+ inArray(tags.tagIdx, tagIdxs)
+ )
+ )
+ .returning({ tagNo: tags.tagNo })
+
+ const deletedTagsCount = deletedTagsResult.length
+
+ console.log(`[DELETE ACTION] Deleted ${deletedTagsCount} items from tags table`)
+ console.log(`[DELETE ACTION] Deleted tag numbers:`, deletedTagsResult.map(t => t.tagNo))
+
+ // 4. formEntries ๋ฐ์ดํ„ฐ ์—…๋ฐ์ดํŠธ
+ await tx
+ .update(formEntries)
+ .set({
+ data: updatedData,
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(formEntries.formCode, formCode),
+ eq(formEntries.contractItemId, contractItemId)
+ )
+ )
+
+ return {
+ deletedFromFormEntries,
+ deletedTagsCount,
+ deletedTagNumbers: deletedTagsResult.map(t => t.tagNo)
+ }
+ })
+
+ // 5. ์บ์‹œ ๋ฌดํšจํ™”
+ const cacheKey = `form-data-${formCode}-${contractItemId}`
+ revalidateTag(cacheKey)
+ revalidateTag(`tags-${contractItemId}`)
+
+ // ํŽ˜์ด์ง€ ์žฌ๊ฒ€์ฆ (ํ•„์š”ํ•œ ๊ฒฝ์šฐ)
+
+ console.log(`[DELETE ACTION] Transaction completed successfully`)
+ console.log(`[DELETE ACTION] FormEntries deleted: ${result.deletedFromFormEntries}`)
+ console.log(`[DELETE ACTION] Tags deleted: ${result.deletedTagsCount}`)
+
+ return {
+ success: true,
+ deletedCount: result.deletedFromFormEntries,
+ deletedTagsCount: result.deletedTagsCount,
+ }
+
+ } catch (error) {
+ console.error("[DELETE ACTION] Error deleting form data:", error)
+ return {
+ error: error instanceof Error ? error.message : "An unexpected error occurred",
+ }
+ }
+}
+
+/**
+ * Server action to exclude selected tags by updating their status
+ */
+export async function excludeFormDataByTags({
+ formCode,
+ contractItemId,
+ tagNumbers,
+}: {
+ formCode: string
+ contractItemId: number
+ tagNumbers: string[]
+}): Promise<{
+ error?: string
+ success?: boolean
+ excludedCount?: number
+}> {
+ try {
+ // ์ž…๋ ฅ ๊ฒ€์ฆ
+ if (!formCode || !contractItemId || !Array.isArray(tagNumbers) || tagNumbers.length === 0) {
+ return {
+ error: "Missing required parameters: formCode, contractItemId, tagNumbers",
+ }
+ }
+
+ console.log(`[EXCLUDE ACTION] Excluding tags for formCode: ${formCode}, contractItemId: ${contractItemId}, tagNumbers:`, tagNumbers)
+
+ // ํŠธ๋žœ์žญ์…˜์œผ๋กœ ์•ˆ์ „ํ•˜๊ฒŒ ์ฒ˜๋ฆฌ
+ const result = await db.transaction(async (tx) => {
+ // 1. ํ˜„์žฌ formEntry ๋ฐ์ดํ„ฐ ๊ฐ€์ ธ์˜ค๊ธฐ
+ const currentEntryResult = await tx
+ .select()
+ .from(formEntries)
+ .where(
+ and(
+ eq(formEntries.formCode, formCode),
+ eq(formEntries.contractItemId, contractItemId)
+ )
+ )
+ .orderBy(desc(formEntries.updatedAt))
+ .limit(1)
+
+ if (currentEntryResult.length === 0) {
+ throw new Error("Form entry not found")
+ }
+
+ const currentEntry = currentEntryResult[0]
+ let currentData = Array.isArray(currentEntry.data) ? currentEntry.data : []
+
+ console.log(`[EXCLUDE ACTION] Current data count: ${currentData.length}`)
+
+ // 2. TAG_NO๊ฐ€ ์ผ์น˜ํ•˜๋Š” ํ•ญ๋ชฉ๋“ค์˜ status๋ฅผ 'excluded'๋กœ ์—…๋ฐ์ดํŠธ
+ let excludedCount = 0
+ const updatedData = currentData.map((item: any) => {
+ if (tagNumbers.includes(item.TAG_NO)) {
+ excludedCount++
+ return {
+ ...item,
+ status: 'excluded',
+ excludedAt: new Date().toISOString() // ์ œ์™ธ ์‹œ๊ฐ„ ์ถ”๊ฐ€ (์„ ํƒ์‚ฌํ•ญ)
+ }
+ }
+ return item
+ })
+
+ console.log(`[EXCLUDE ACTION] Excluded ${excludedCount} items`)
+
+ if (excludedCount === 0) {
+ throw new Error("No items were found to exclude")
+ }
+
+ // 3. formEntries ๋ฐ์ดํ„ฐ ์—…๋ฐ์ดํŠธ
+ await tx
+ .update(formEntries)
+ .set({
+ data: updatedData,
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(formEntries.formCode, formCode),
+ eq(formEntries.contractItemId, contractItemId)
+ )
+ )
+
+ return {
+ excludedCount,
+ excludedTagNumbers: tagNumbers
+ }
+ })
+
+ // 4. ์บ์‹œ ๋ฌดํšจํ™”
+ const cacheKey = `form-data-${formCode}-${contractItemId}`
+ revalidateTag(cacheKey)
+
+ console.log(`[EXCLUDE ACTION] Transaction completed successfully`)
+ console.log(`[EXCLUDE ACTION] Tags excluded: ${result.excludedCount}`)
+
+ return {
+ success: true,
+ excludedCount: result.excludedCount,
+ }
+
+ } catch (error) {
+ console.error("[EXCLUDE ACTION] Error excluding form data:", error)
+ return {
+ error: error instanceof Error ? error.message : "An unexpected error occurred",
+ }
+ }
+}
+
+
+
+export async function getRegisters(projectCode: string): Promise<Register[]> {
+ try {
+ // ํ† ํฐ(API ํ‚ค) ๊ฐ€์ ธ์˜ค๊ธฐ
+ const apiKey = await getSEDPToken();
+ const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api';
+
+ const response = await fetch(
+ `${SEDP_API_BASE_URL}/Register/Get`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'accept': '*/*',
+ 'ApiKey': apiKey,
+ 'ProjectNo': projectCode
+ },
+ body: JSON.stringify({
+ ProjectNo: projectCode,
+ ContainDeleted: false
+ })
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error(`๋ ˆ์ง€์Šคํ„ฐ ์š”์ฒญ ์‹คํŒจ: ${response.status} ${response.statusText}`);
+ }
+
+ // ์•ˆ์ „ํ•˜๊ฒŒ JSON ํŒŒ์‹ฑ
+ let data;
+ try {
+ data = await response.json();
+ } catch (parseError) {
+ console.error(`ํ”„๋กœ์ ํŠธ ${projectCode}์˜ ๋ ˆ์ง€์Šคํ„ฐ ์‘๋‹ต ํŒŒ์‹ฑ ์‹คํŒจ:`, parseError);
+ // ์‘๋‹ต ๋‚ด์šฉ ๋กœ๊น…
+ const text = await response.clone().text();
+ console.log(`์‘๋‹ต ๋‚ด์šฉ: ${text.substring(0, 200)}${text.length > 200 ? '...' : ''}`);
+ throw new Error(`๋ ˆ์ง€์Šคํ„ฐ ์‘๋‹ต ํŒŒ์‹ฑ ์‹คํŒจ: ${parseError instanceof Error ? parseError.message : String(parseError)}`);
+ }
+
+ // ๊ฒฐ๊ณผ๋ฅผ ๋ฐฐ์—ด๋กœ ๋ณ€ํ™˜ (๋‹จ์ผ ๊ฐ์ฒด์ธ ๊ฒฝ์šฐ ๋ฐฐ์—ด๋กœ ๋ž˜ํ•‘)
+ const registers: Register[] = Array.isArray(data) ? data : [data];
+
+ console.log(`ํ”„๋กœ์ ํŠธ ${projectCode}์—์„œ ${registers.length}๊ฐœ์˜ ์œ ํšจํ•œ ๋ ˆ์ง€์Šคํ„ฐ๋ฅผ ๊ฐ€์ ธ์™”์Šต๋‹ˆ๋‹ค.`);
+ return registers;
+ } catch (error) {
+ console.error(`ํ”„๋กœ์ ํŠธ ${projectCode}์˜ ๋ ˆ์ง€์Šคํ„ฐ ๊ฐ€์ ธ์˜ค๊ธฐ ์‹คํŒจ:`, error);
+ throw error;
+ }
+} \ No newline at end of file
diff --git a/lib/forms-plant/stat.ts b/lib/forms-plant/stat.ts
new file mode 100644
index 00000000..f13bab61
--- /dev/null
+++ b/lib/forms-plant/stat.ts
@@ -0,0 +1,375 @@
+"use server"
+
+import db from "@/db/db"
+import { vendors, contracts, contractItems, forms, formEntries, formMetas, tags, tagClasses, tagClassAttributes, projects } from "@/db/schema"
+import { eq, and, inArray } from "drizzle-orm"
+import { getEditableFieldsByTag } from "./services"
+import { getServerSession } from "next-auth/next"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+
+interface VendorFormStatus {
+ vendorId: number
+ vendorName: string
+ formCount: number // ๋ฒค๋”๊ฐ€ ๊ฐ€์ง„ form ๊ฐœ์ˆ˜
+ tagCount: number // ๋ฒค๋”๊ฐ€ ๊ฐ€์ง„ tag ๊ฐœ์ˆ˜
+ totalFields: number // ์ž…๋ ฅํ•ด์•ผ ํ•˜๋Š” ์ด ํ•„๋“œ ๊ฐœ์ˆ˜
+ completedFields: number // ์ž…๋ ฅ ์™„๋ฃŒ๋œ ํ•„๋“œ ๊ฐœ์ˆ˜
+ completionRate: number // ์™„๋ฃŒ์œจ (%)
+}
+
+export interface FormStatusByVendor {
+ tagCount: number;
+ totalFields: number;
+ completedFields: number;
+ completionRate: number;
+ upcomingCount: number; // 7์ผ ์ด๋‚ด ์ž„๋ฐ•ํ•œ ๊ฐœ์ˆ˜
+ overdueCount: number; // ์ง€์—ฐ๋œ ๊ฐœ์ˆ˜
+}
+
+export async function getProjectsWithContracts() {
+ try {
+ const projectList = await db
+ .selectDistinct({
+ id: projects.id,
+ projectCode: projects.code,
+ projectName: projects.name,
+ })
+ .from(projects)
+ .innerJoin(contracts, eq(contracts.projectId, projects.id))
+ .orderBy(projects.code)
+
+ return projectList
+ } catch (error) {
+ console.error('Error getting projects with contracts:', error)
+ throw new Error('๊ณ„์•ฝ์ด ์žˆ๋Š” ํ”„๋กœ์ ํŠธ ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.')
+ }
+}
+
+
+
+export async function getVendorFormStatus(projectId?: number): Promise<VendorFormStatus[]> {
+ try {
+ // 1. ๋ฒค๋” ์กฐํšŒ ์ฟผ๋ฆฌ ์ˆ˜์ •
+ const vendorList = projectId
+ ? await db
+ .selectDistinct({
+ vendorId: vendors.id,
+ vendorName: vendors.vendorName,
+ })
+ .from(vendors)
+ .innerJoin(contracts, eq(contracts.vendorId, vendors.id))
+ .where(eq(contracts.projectId, projectId))
+ : await db
+ .selectDistinct({
+ vendorId: vendors.id,
+ vendorName: vendors.vendorName,
+ })
+ .from(vendors)
+ .innerJoin(contracts, eq(contracts.vendorId, vendors.id))
+
+
+ const vendorStatusList: VendorFormStatus[] = []
+
+ for (const vendor of vendorList) {
+ let vendorFormCount = 0
+ let vendorTagCount = 0
+ let vendorTotalFields = 0
+ let vendorCompletedFields = 0
+ const uniqueTags = new Set<string>()
+
+ // 2. ๊ณ„์•ฝ ์กฐํšŒ ์‹œ projectId ํ•„ํ„ฐ ์ถ”๊ฐ€
+ const vendorContracts = projectId
+ ? await db
+ .select({
+ id: contracts.id,
+ projectId: contracts.projectId
+ })
+ .from(contracts)
+ .where(
+ and(
+ eq(contracts.vendorId, vendor.vendorId),
+ eq(contracts.projectId, projectId)
+ )
+ )
+ : await db
+ .select({
+ id: contracts.id,
+ projectId: contracts.projectId
+ })
+ .from(contracts)
+ .where(eq(contracts.vendorId, vendor.vendorId))
+
+
+ for (const contract of vendorContracts) {
+ // 3. ๊ณ„์•ฝ๋ณ„ contractItems ์กฐํšŒ
+ const contractItemsList = await db
+ .select({
+ id: contractItems.id
+ })
+ .from(contractItems)
+ .where(eq(contractItems.contractId, contract.id))
+
+ for (const contractItem of contractItemsList) {
+ // 4. contractItem๋ณ„ forms ์กฐํšŒ
+ const formsList = await db
+ .select({
+ id: forms.id,
+ formCode: forms.formCode,
+ contractItemId: forms.contractItemId
+ })
+ .from(forms)
+ .where(eq(forms.contractItemId, contractItem.id))
+
+ vendorFormCount += formsList.length
+
+ // 5. formEntries ์กฐํšŒ
+ const entriesList = await db
+ .select({
+ id: formEntries.id,
+ formCode: formEntries.formCode,
+ data: formEntries.data
+ })
+ .from(formEntries)
+ .where(eq(formEntries.contractItemId, contractItem.id))
+
+ // 6. TAG๋ณ„ ํŽธ์ง‘ ๊ฐ€๋Šฅ ํ•„๋“œ ์กฐํšŒ
+ const editableFieldsByTag = await getEditableFieldsByTag(contractItem.id, contract.projectId)
+
+ for (const entry of entriesList) {
+ // formMetas์—์„œ ํ•ด๋‹น formCode์˜ columns ์กฐํšŒ
+ const metaResult = await db
+ .select({
+ columns: formMetas.columns
+ })
+ .from(formMetas)
+ .where(
+ and(
+ eq(formMetas.formCode, entry.formCode),
+ eq(formMetas.projectId, contract.projectId)
+ )
+ )
+ .limit(1)
+
+ if (metaResult.length === 0) continue
+
+ const metaColumns = metaResult[0].columns as any[]
+
+ // shi๊ฐ€ 'IN' ๋˜๋Š” 'BOTH'์ธ ํ•„๋“œ ์ฐพ๊ธฐ
+ const inputRequiredFields = metaColumns
+ .filter(col => col.shi === 'IN' || col.shi === 'BOTH')
+ .map(col => col.key)
+
+ // entry.data ๋ถ„์„ (๋ฐฐ์—ด๋กœ ๊ฐ€์ •)
+ const dataArray = Array.isArray(entry.data) ? entry.data : []
+
+ for (const dataItem of dataArray) {
+ if (typeof dataItem !== 'object' || !dataItem) continue
+
+ const tagNo = dataItem.TAG_NO
+ if (tagNo) {
+ uniqueTags.add(tagNo)
+
+ // TAG๋ณ„ ํŽธ์ง‘ ๊ฐ€๋Šฅ ํ•„๋“œ ๊ฐ€์ ธ์˜ค๊ธฐ
+ const tagEditableFields = editableFieldsByTag.get(tagNo) || []
+
+ // ์ตœ์ข… ์ž…๋ ฅ ํ•„์š” ํ•„๋“œ = shi ๊ธฐ๋ฐ˜ ํ•„๋“œ + TAG ๊ธฐ๋ฐ˜ ํŽธ์ง‘ ๊ฐ€๋Šฅ ํ•„๋“œ
+ const allRequiredFields = inputRequiredFields.filter(field =>
+ tagEditableFields.includes(field)
+ )
+ // ๊ฐ ํ•„๋“œ๋ณ„ ์ž…๋ ฅ ์ƒํƒœ ์ฒดํฌ
+ for (const fieldKey of allRequiredFields) {
+ vendorTotalFields++
+
+ const fieldValue = dataItem[fieldKey]
+ // ๊ฐ’์ด ์žˆ๊ณ , ๋นˆ ๋ฌธ์ž์—ด์ด ์•„๋‹ˆ๊ณ , null์ด ์•„๋‹ˆ๋ฉด ์ž…๋ ฅ ์™„๋ฃŒ
+ if (fieldValue !== undefined && fieldValue !== null && fieldValue !== '') {
+ vendorCompletedFields++
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // ์™„๋ฃŒ์œจ ๊ณ„์‚ฐ
+ const completionRate = vendorTotalFields > 0
+ ? Math.round((vendorCompletedFields / vendorTotalFields) * 100 * 10) / 10
+ : 0
+
+ vendorStatusList.push({
+ vendorId: vendor.vendorId,
+ vendorName: vendor.vendorName || '์ด๋ฆ„ ์—†์Œ',
+ formCount: vendorFormCount,
+ tagCount: uniqueTags.size,
+ totalFields: vendorTotalFields,
+ completedFields: vendorCompletedFields,
+ completionRate
+ })
+ }
+
+ return vendorStatusList
+
+ } catch (error) {
+ console.error('Error getting vendor form status:', error)
+ throw new Error('๋ฒค๋”๋ณ„ Form ์ž…๋ ฅ ํ˜„ํ™ฉ ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.')
+ }
+}
+
+
+
+export async function getFormStatusByVendor(projectId: number, contractItemId: number, formCode: string): Promise<FormStatusByVendor[]> {
+ try {
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.id) {
+ throw new Error("์ธ์ฆ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.")
+ }
+
+
+ let vendorFormCount = 0
+ let vendorTagCount = 0
+ let vendorTotalFields = 0
+ let vendorCompletedFields = 0
+ let vendorUpcomingCount = 0 // 7์ผ ์ด๋‚ด ์ž„๋ฐ•ํ•œ ๊ฐœ์ˆ˜
+ let vendorOverdueCount = 0 // ์ง€์—ฐ๋œ ๊ฐœ์ˆ˜
+ const uniqueTags = new Set<string>()
+ const processedTags = new Set<string>() // ์ค‘๋ณต ์ฒ˜๋ฆฌ ๋ฐฉ์ง€์šฉ
+
+ // ํ˜„์žฌ ๋‚ ์งœ์™€ 7์ผ ํ›„ ๋‚ ์งœ ๊ณ„์‚ฐ
+ const today = new Date()
+ today.setHours(0, 0, 0, 0) // ์‹œ๊ฐ„ ๋ถ€๋ถ„ ์ œ๊ฑฐ
+ const sevenDaysLater = new Date(today)
+ sevenDaysLater.setDate(sevenDaysLater.getDate() + 7)
+
+ // 4. contractItem๋ณ„ forms ์กฐํšŒ
+ const formsList = await db
+ .select({
+ id: forms.id,
+ formCode: forms.formCode,
+ contractItemId: forms.contractItemId
+ })
+ .from(forms)
+ .where(
+ and(
+ eq(forms.contractItemId, contractItemId),
+ eq(forms.formCode, formCode)
+ )
+ )
+
+ vendorFormCount += formsList.length
+
+ // 5. formEntries ์กฐํšŒ
+ const entriesList = await db
+ .select({
+ id: formEntries.id,
+ formCode: formEntries.formCode,
+ data: formEntries.data
+ })
+ .from(formEntries)
+ .where(
+ and(
+ eq(formEntries.contractItemId, contractItemId),
+ eq(formEntries.formCode, formCode)
+ )
+ )
+
+ // 6. TAG๋ณ„ ํŽธ์ง‘ ๊ฐ€๋Šฅ ํ•„๋“œ ์กฐํšŒ
+ const editableFieldsByTag = await getEditableFieldsByTag(contractItemId, projectId)
+
+ const vendorStatusList: VendorFormStatus[] = []
+
+ for (const entry of entriesList) {
+ const metaResult = await db
+ .select({
+ columns: formMetas.columns
+ })
+ .from(formMetas)
+ .where(
+ and(
+ eq(formMetas.formCode, entry.formCode),
+ eq(formMetas.projectId, projectId)
+ )
+ )
+ .limit(1)
+
+ if (metaResult.length === 0) continue
+
+ const metaColumns = metaResult[0].columns as any[]
+
+ const inputRequiredFields = metaColumns
+ .filter(col => col.shi === 'IN' || col.shi === 'BOTH')
+ .map(col => col.key)
+
+ const dataArray = Array.isArray(entry.data) ? entry.data : []
+
+ for (const dataItem of dataArray) {
+ if (typeof dataItem !== 'object' || !dataItem) continue
+
+ const tagNo = dataItem.TAG_NO
+ if (tagNo) {
+ uniqueTags.add(tagNo)
+
+ // TAG๋ณ„ ํŽธ์ง‘ ๊ฐ€๋Šฅ ํ•„๋“œ ๊ฐ€์ ธ์˜ค๊ธฐ
+ const tagEditableFields = editableFieldsByTag.get(tagNo) || []
+
+ const allRequiredFields = inputRequiredFields.filter(field =>
+ tagEditableFields.includes(field)
+ )
+
+ // ํ•ด๋‹น TAG์˜ ํ•„๋“œ ์™„๋ฃŒ ์ƒํƒœ ์ฒดํฌ
+ let tagHasIncompleteFields = false
+
+ for (const fieldKey of allRequiredFields) {
+ vendorTotalFields++
+
+ const fieldValue = dataItem[fieldKey]
+ if (fieldValue !== undefined && fieldValue !== null && fieldValue !== '') {
+ vendorCompletedFields++
+ } else {
+ tagHasIncompleteFields = true
+ }
+ }
+
+ // ๋ฏธ์™„๋ฃŒ TAG์— ๋Œ€ํ•ด์„œ๋งŒ ๋‚ ์งœ ์ฒดํฌ (TAG๋‹น ํ•œ ๋ฒˆ๋งŒ ์ฒ˜๋ฆฌ)
+ if (!processedTags.has(tagNo) && tagHasIncompleteFields) {
+ processedTags.add(tagNo)
+
+ const targetDate = dataItem.DUE_DATE
+ if (targetDate) {
+ const target = new Date(targetDate)
+ target.setHours(0, 0, 0, 0) // ์‹œ๊ฐ„ ๋ถ€๋ถ„ ์ œ๊ฑฐ
+
+ if (target < today) {
+ // ๋ฏธ์™„๋ฃŒ์ด๋ฉด์„œ ์ง€์—ฐ๋œ ๊ฒฝ์šฐ (์˜ค๋Š˜๋ณด๋‹ค ์ด์ „)
+ vendorOverdueCount++
+ } else if (target >= today && target <= sevenDaysLater) {
+ // ๋ฏธ์™„๋ฃŒ์ด๋ฉด์„œ 7์ผ ์ด๋‚ด ์ž„๋ฐ•ํ•œ ๊ฒฝ์šฐ
+ vendorUpcomingCount++
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // ์™„๋ฃŒ์œจ ๊ณ„์‚ฐ
+ const completionRate = vendorTotalFields > 0
+ ? Math.round((vendorCompletedFields / vendorTotalFields) * 100 * 10) / 10
+ : 0
+
+ vendorStatusList.push({
+ tagCount: uniqueTags.size,
+ totalFields: vendorTotalFields,
+ completedFields: vendorCompletedFields,
+ completionRate,
+ upcomingCount: vendorUpcomingCount,
+ overdueCount: vendorOverdueCount
+ })
+
+ return vendorStatusList
+
+ } catch (error) {
+ console.error('Error getting vendor form status:', error)
+ throw new Error('๋ฒค๋”๋ณ„ Form ์ž…๋ ฅ ํ˜„ํ™ฉ ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.')
+ }
+} \ No newline at end of file
diff --git a/lib/tags-plant/form-mapping-service.ts b/lib/tags-plant/form-mapping-service.ts
new file mode 100644
index 00000000..6de0e244
--- /dev/null
+++ b/lib/tags-plant/form-mapping-service.ts
@@ -0,0 +1,101 @@
+"use server"
+
+import db from "@/db/db"
+import { tagTypeClassFormMappings } from "@/db/schema/vendorData";
+import { eq, and } from "drizzle-orm"
+
+// ํผ ์ •๋ณด ์ธํ„ฐํŽ˜์ด์Šค (๋™์ผ)
+export interface FormMapping {
+ formCode: string;
+ formName: string;
+ ep: string;
+ remark: string;
+}
+
+/**
+ * ์ฃผ์–ด์ง„ tagType, classCode๋กœ DB๋ฅผ ์กฐํšŒํ•˜์—ฌ
+ * 1) ํŠน์ • classCode ๋งคํ•‘ => ์กด์žฌํ•˜๋ฉด ๋ฐ˜ํ™˜
+ * 2) ์—†์œผ๋ฉด DEFAULT ๋งคํ•‘ => ์—†์œผ๋ฉด ๋นˆ ๋ฐฐ์—ด
+ */
+export async function getFormMappingsByTagType(
+ tagType: string,
+ projectId: number,
+ classCode?: string
+): Promise<FormMapping[]> {
+
+ console.log(`DB-based getFormMappingsByTagType => tagType="${tagType}", class="${classCode ?? "NONE"}"`);
+
+ // 1) classCode๊ฐ€ ์žˆ์œผ๋ฉด ์‹œ๋„
+ if (classCode) {
+ const specificRows = await db
+ .select({
+ formCode: tagTypeClassFormMappings.formCode,
+ formName: tagTypeClassFormMappings.formName,
+ ep: tagTypeClassFormMappings.ep,
+ remark: tagTypeClassFormMappings.remark
+ })
+ .from(tagTypeClassFormMappings)
+ .where(and(
+ eq(tagTypeClassFormMappings.tagTypeLabel, tagType),
+ eq(tagTypeClassFormMappings.projectId, projectId),
+ eq(tagTypeClassFormMappings.classLabel, classCode)
+ ))
+
+ if (specificRows.length > 0) {
+ console.log("Found specific mapping rows:", specificRows.length);
+ return specificRows;
+ }
+ }
+
+ // 2) fallback => DEFAULT
+ console.log(`Falling back to DEFAULT for tagType="${tagType}"`);
+ const defaultRows = await db
+ .select({
+ formCode: tagTypeClassFormMappings.formCode,
+ formName: tagTypeClassFormMappings.formName,
+ ep: tagTypeClassFormMappings.ep
+ })
+ .from(tagTypeClassFormMappings)
+ .where(and(
+ eq(tagTypeClassFormMappings.tagTypeLabel, tagType),
+ eq(tagTypeClassFormMappings.projectId, projectId),
+ eq(tagTypeClassFormMappings.classLabel, "DEFAULT")
+ ))
+
+ if (defaultRows.length > 0) {
+ console.log("Using DEFAULT mapping rows:", defaultRows.length);
+ return defaultRows;
+ }
+
+ // 3) ์•„๋ฌด๊ฒƒ๋„ ์—†์œผ๋ฉด ๋นˆ ๋ฐฐ์—ด
+ console.log(`No mappings found at all for tagType="${tagType}"`);
+ return [];
+}
+
+
+export async function getFormMappingsByTagTypebyProeject(
+
+ projectId: number,
+): Promise<FormMapping[]> {
+
+ const specificRows = await db
+ .select({
+ formCode: tagTypeClassFormMappings.formCode,
+ formName: tagTypeClassFormMappings.formName,
+ ep: tagTypeClassFormMappings.ep,
+ remark: tagTypeClassFormMappings.remark
+ })
+ .from(tagTypeClassFormMappings)
+ .where(and(
+ eq(tagTypeClassFormMappings.projectId, projectId),
+ ))
+
+ if (specificRows.length > 0) {
+ console.log("Found specific mapping rows:", specificRows.length);
+ return specificRows;
+ }
+
+
+
+ return [];
+} \ No newline at end of file
diff --git a/lib/tags-plant/repository.ts b/lib/tags-plant/repository.ts
new file mode 100644
index 00000000..b5d48335
--- /dev/null
+++ b/lib/tags-plant/repository.ts
@@ -0,0 +1,71 @@
+import db from "@/db/db";
+import { NewTag, tags } from "@/db/schema/vendorData";
+import {
+ eq,
+ inArray,
+ not,
+ asc,
+ desc,
+ and,
+ ilike,
+ gte,
+ lte,
+ count,
+ gt,
+} from "drizzle-orm";
+import { PgTransaction } from "drizzle-orm/pg-core";
+
+export async function selectTags(
+ tx: PgTransaction<any, any, any>,
+ params: {
+ where?: any; // drizzle-orm์˜ ์กฐ๊ฑด์‹ (and, eq...) ๋“ฑ
+ orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[];
+ offset?: number;
+ limit?: number;
+ }
+) {
+ const { where, orderBy, offset = 0, limit = 10 } = params;
+
+ return tx
+ .select()
+ .from(tags)
+ .where(where)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset)
+ .limit(limit);
+}
+/** ์ด ๊ฐœ์ˆ˜ count */
+export async function countTags(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+) {
+ const res = await tx.select({ count: count() }).from(tags).where(where);
+ return res[0]?.count ?? 0;
+}
+
+export async function insertTag(
+ tx: PgTransaction<any, any, any>,
+ data: NewTag // DB์™€ ๋™์ผํ•œ insert ๊ฐ€๋Šฅํ•œ ํƒ€์ž…
+) {
+ // returning() ์‚ฌ์šฉ ์‹œ ๋ฐฐ์—ด๋กœ ๋Œ์•„์˜ค๋ฏ€๋กœ [0]๋งŒ ๋ฆฌํ„ด
+ return tx
+ .insert(tags)
+ .values(data)
+ .returning({ id: tags.id, createdAt: tags.createdAt });
+}
+
+/** ๋‹จ๊ฑด ์‚ญ์ œ */
+export async function deleteTagById(
+ tx: PgTransaction<any, any, any>,
+ tagId: number
+) {
+ return tx.delete(tags).where(eq(tags.id, tagId));
+}
+
+/** ๋ณต์ˆ˜ ์‚ญ์ œ */
+export async function deleteTagsByIds(
+ tx: PgTransaction<any, any, any>,
+ ids: number[]
+) {
+ return tx.delete(tags).where(inArray(tags.id, ids));
+}
diff --git a/lib/tags-plant/service.ts b/lib/tags-plant/service.ts
new file mode 100644
index 00000000..028cde42
--- /dev/null
+++ b/lib/tags-plant/service.ts
@@ -0,0 +1,1650 @@
+"use server"
+
+import db from "@/db/db"
+import { formEntries, forms, tagClasses, tags, tagSubfieldOptions, tagSubfields, tagTypes } from "@/db/schema/vendorData"
+// import { eq } from "drizzle-orm"
+import { createTagSchema, GetTagsSchema, updateTagSchema, UpdateTagSchema, type CreateTagSchema } from "./validations"
+import { revalidateTag, unstable_noStore } from "next/cache";
+import { filterColumns } from "@/lib/filter-columns";
+import { unstable_cache } from "@/lib/unstable-cache";
+import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq, sql, ne, count, isNull } from "drizzle-orm";
+import { countTags, insertTag, selectTags } from "./repository";
+import { getErrorMessage } from "../handle-error";
+import { getFormMappingsByTagType } from './form-mapping-service';
+import { contractItems, contracts } from "@/db/schema/contract";
+import { getCodeListsByID } from "../sedp/sync-object-class";
+import { projects, vendors } from "@/db/schema";
+import { randomBytes } from 'crypto';
+
+// ํผ ๊ฒฐ๊ณผ๋ฅผ ์œ„ํ•œ ์ธํ„ฐํŽ˜์ด์Šค ์ •์˜
+interface CreatedOrExistingForm {
+ id: number;
+ formCode: string;
+ formName: string;
+ isNewlyCreated: boolean;
+}
+
+/**
+ * 16์ง„์ˆ˜ 24์ž๋ฆฌ ๊ณ ์œ  ์‹๋ณ„์ž ์ƒ์„ฑ
+ * @returns 24์ž๋ฆฌ 16์ง„์ˆ˜ ๋ฌธ์ž์—ด (์˜ˆ: "a1b2c3d4e5f6789012345678")
+ */
+function generateTagIdx(): string {
+ return randomBytes(12).toString('hex'); // 12๋ฐ”์ดํŠธ = 24์ž๋ฆฌ 16์ง„์ˆ˜
+}
+
+export async function getTags(input: GetTagsSchema, packagesId: number) {
+
+ // return unstable_cache(
+ // async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // (1) advancedWhere
+ const advancedWhere = filterColumns({
+ table: tags,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+ // (2) globalWhere
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(tags.tagNo, s),
+ ilike(tags.tagType, s),
+ ilike(tags.description, s)
+ );
+ }
+ // (4) ์ตœ์ข… where
+ const finalWhere = and(advancedWhere, globalWhere, eq(tags.contractItemId, packagesId));
+
+ // (5) ์ •๋ ฌ
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(tags[item.id]) : asc(tags[item.id])
+ )
+ : [asc(tags.createdAt)];
+
+ // ํŠธ๋žœ์žญ์…˜ ๋‚ด๋ถ€์—์„œ Repository ํ˜ธ์ถœ
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectTags(tx, {
+ where: finalWhere,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+ const total = await countTags(tx, finalWhere);
+
+
+ return { data, total };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+
+ return { data, pageCount };
+ } catch (err) {
+ // ์—๋Ÿฌ ๋ฐœ์ƒ ์‹œ ๋””ํดํŠธ
+ return { data: [], pageCount: 0 };
+ }
+ // },
+ // [JSON.stringify(input), String(packagesId)], // ์บ์‹ฑ ํ‚ค์— packagesId ์ถ”๊ฐ€
+ // {
+ // revalidate: 3600,
+ // tags: [`tags-${packagesId}`], // ํŒจํ‚ค์ง€๋ณ„ ํƒœ๊ทธ ์‚ฌ์šฉ
+ // }
+ // )();
+}
+
+
+export async function createTag(
+ formData: CreateTagSchema,
+ selectedPackageId: number | null
+) {
+ if (!selectedPackageId) {
+ return { error: "No selectedPackageId provided" }
+ }
+
+ // Validate formData
+ const validated = createTagSchema.safeParse(formData)
+ if (!validated.success) {
+ return { error: validated.error.flatten().formErrors.join(", ") }
+ }
+
+ // React ์„œ๋ฒ„ ์•ก์…˜์—์„œ ๋งค ์š”์ฒญ๋งˆ๋‹ค ์‹คํ–‰
+ unstable_noStore()
+
+ try {
+ // ํ•˜๋‚˜์˜ ํŠธ๋žœ์žญ์…˜์—์„œ ๋ชจ๋“  ์ž‘์—… ์ˆ˜ํ–‰
+ return await db.transaction(async (tx) => {
+ // 1) ์„ ํƒ๋œ contractItem์˜ contractId ๊ฐ€์ ธ์˜ค๊ธฐ
+ const contractItemResult = await tx
+ .select({
+ contractId: contractItems.contractId,
+ projectId: contracts.projectId // projectId ์ถ”๊ฐ€
+ })
+ .from(contractItems)
+ .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) // contracts ํ…Œ์ด๋ธ” ์กฐ์ธ
+ .where(eq(contractItems.id, selectedPackageId))
+ .limit(1)
+
+ if (contractItemResult.length === 0) {
+ return { error: "Contract item not found" }
+ }
+
+ const contractId = contractItemResult[0].contractId
+ const projectId = contractItemResult[0].projectId
+
+ // 2) ํ•ด๋‹น ๊ณ„์•ฝ ๋‚ด์—์„œ ๊ฐ™์€ tagNo๋ฅผ ๊ฐ€์ง„ ํƒœ๊ทธ๊ฐ€ ์žˆ๋Š”์ง€ ํ™•์ธ
+ const duplicateCheck = await tx
+ .select({ count: sql<number>`count(*)` })
+ .from(tags)
+ .innerJoin(contractItems, eq(tags.contractItemId, contractItems.id))
+ .where(
+ and(
+ eq(contractItems.contractId, contractId),
+ eq(tags.tagNo, validated.data.tagNo)
+ )
+ )
+
+ if (duplicateCheck[0].count > 0) {
+ return {
+ error: `ํƒœ๊ทธ ๋ฒˆํ˜ธ "${validated.data.tagNo}"๋Š” ์ด๋ฏธ ์ด ๊ณ„์•ฝ ๋‚ด์— ์กด์žฌํ•ฉ๋‹ˆ๋‹ค.`,
+ }
+ }
+
+ // 3) ํƒœ๊ทธ ํƒ€์ž…์— ๋”ฐ๋ฅธ ํผ ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ
+ const allFormMappings = await getFormMappingsByTagType(
+ validated.data.tagType,
+ projectId, // projectId ์ „๋‹ฌ
+ validated.data.class
+ )
+
+ // ep๊ฐ€ "IMEP"์ธ ๊ฒƒ๋งŒ ํ•„ํ„ฐ๋ง
+ const formMappings = allFormMappings?.filter(mapping => mapping.ep === "IMEP") || []
+
+
+ // ํผ ๋งคํ•‘์ด ์—†์œผ๋ฉด ๋กœ๊ทธ๋งŒ ๋‚จ๊ธฐ๊ณ  ์ง„ํ–‰
+ if (!formMappings || formMappings.length === 0) {
+ console.log(
+ "No form mappings found for tag type:",
+ validated.data.tagType,
+ "in project:",
+ projectId
+ )
+ }
+
+
+ // 4) ์ด ํƒœ๊ทธ ํƒ€์ž…์— ๋Œ€ํ•œ ์ฃผ์š” ํผ(์ฒซ ๋ฒˆ์งธ ํผ)์„ ์ฐพ๊ฑฐ๋‚˜ ์ƒ์„ฑ
+ let primaryFormId: number | null = null
+ const createdOrExistingForms: CreatedOrExistingForm[] = []
+
+ if (formMappings && formMappings.length > 0) {
+ console.log(selectedPackageId, formMappings)
+ for (const formMapping of formMappings) {
+ // 4-1) ์ด๋ฏธ ์กด์žฌํ•˜๋Š” ํผ์ธ์ง€ ํ™•์ธ
+ const existingForm = await tx
+ .select({ id: forms.id, im: forms.im, eng: forms.eng }) // eng ํ•„๋“œ๋„ ์ถ”๊ฐ€๋กœ ์กฐํšŒ
+ .from(forms)
+ .where(
+ and(
+ eq(forms.contractItemId, selectedPackageId),
+ eq(forms.formCode, formMapping.formCode)
+ )
+ )
+ .limit(1)
+
+ let formId: number
+ if (existingForm.length > 0) {
+ // ์ด๋ฏธ ์กด์žฌํ•˜๋ฉด ํ•ด๋‹น ID ์‚ฌ์šฉ
+ formId = existingForm[0].id
+
+ // ์—…๋ฐ์ดํŠธํ•  ํ•„๋“œ๋“ค ์ค€๋น„
+ const updateValues: any = {};
+ let shouldUpdate = false;
+
+ // im ํ•„๋“œ ์ฒดํฌ
+ if (existingForm[0].im !== true) {
+ updateValues.im = true;
+ shouldUpdate = true;
+ }
+
+ // eng ํ•„๋“œ ์ฒดํฌ - remark์— "VD_"๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ์„ ๋•Œ๋งŒ
+ if (formMapping.remark && formMapping.remark.includes("VD_") && existingForm[0].eng !== true) {
+ updateValues.eng = true;
+ shouldUpdate = true;
+ }
+
+ if (shouldUpdate) {
+ await tx
+ .update(forms)
+ .set(updateValues)
+ .where(eq(forms.id, formId))
+
+ console.log(`Form ${formId} updated with:`, updateValues)
+ }
+
+ createdOrExistingForms.push({
+ id: formId,
+ formCode: formMapping.formCode,
+ formName: formMapping.formName,
+ isNewlyCreated: false,
+ })
+ } else {
+ // ์กด์žฌํ•˜์ง€ ์•Š์œผ๋ฉด ์ƒˆ๋กœ ์ƒ์„ฑ
+ const insertValues: any = {
+ contractItemId: selectedPackageId,
+ formCode: formMapping.formCode,
+ formName: formMapping.formName,
+ im: true,
+ };
+
+ // remark์— "VD_"๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ์„ ๋•Œ๋งŒ eng: true ์„ค์ •
+ if (formMapping.remark && formMapping.remark.includes("VD_")) {
+ insertValues.eng = true;
+ }
+
+ const insertResult = await tx
+ .insert(forms)
+ .values(insertValues)
+ .returning({ id: forms.id, formCode: forms.formCode, formName: forms.formName })
+
+ console.log("insertResult:", insertResult)
+ formId = insertResult[0].id
+ createdOrExistingForms.push({
+ id: formId,
+ formCode: insertResult[0].formCode,
+ formName: insertResult[0].formName,
+ isNewlyCreated: true,
+ })
+ }
+
+ // ์ฒซ ๋ฒˆ์งธ ํผ์„ "์ฃผ์š” ํผ"์œผ๋กœ ์„ค์ •ํ•˜์—ฌ ํƒœ๊ทธ ์ƒ์„ฑ ์‹œ ์‚ฌ์šฉ
+ if (primaryFormId === null) {
+ primaryFormId = formId
+ }
+ }
+ }
+
+ // ๐Ÿ†• 16์ง„์ˆ˜ 24์ž๋ฆฌ ํƒœ๊ทธ ๊ณ ์œ  ์‹๋ณ„์ž ์ƒ์„ฑ
+ const generatedTagIdx = generateTagIdx();
+ console.log(`[CREATE TAG] Generated tagIdx: ${generatedTagIdx}`);
+
+ // 5) ์ƒˆ Tag ์ƒ์„ฑ (tagIdx ์ถ”๊ฐ€)
+ const [newTag] = await insertTag(tx, {
+ contractItemId: selectedPackageId,
+ formId: primaryFormId,
+ tagIdx: generatedTagIdx, // ๐Ÿ†• ์ƒ์„ฑ๋œ 16์ง„์ˆ˜ 24์ž๋ฆฌ ์ถ”๊ฐ€
+ tagNo: validated.data.tagNo,
+ class: validated.data.class,
+ tagType: validated.data.tagType,
+ description: validated.data.description ?? null,
+ })
+
+ console.log(`tags-${selectedPackageId}`, "create", newTag)
+
+ // 6) ์ƒ์„ฑ๋œ ๊ฐ form์— ๋Œ€ํ•ด formEntries์— ๋ฐ์ดํ„ฐ ์ถ”๊ฐ€ (TAG_IDX ํฌํ•จ)
+ for (const form of createdOrExistingForms) {
+ try {
+ // ๊ธฐ์กด formEntry ๊ฐ€์ ธ์˜ค๊ธฐ
+ const existingEntry = await tx.query.formEntries.findFirst({
+ where: and(
+ eq(formEntries.formCode, form.formCode),
+ eq(formEntries.contractItemId, selectedPackageId)
+ )
+ });
+
+ // ์ƒˆ๋กœ์šด ํƒœ๊ทธ ๋ฐ์ดํ„ฐ ๊ฐ์ฒด ์ƒ์„ฑ (TAG_IDX ํฌํ•จ)
+ const newTagData = {
+ TAG_IDX: generatedTagIdx, // ๐Ÿ†• ๊ฐ™์€ 16์ง„์ˆ˜ 24์ž๋ฆฌ ๊ฐ’ ์‚ฌ์šฉ
+ TAG_NO: validated.data.tagNo,
+ TAG_DESC: validated.data.description ?? null,
+ status: "New" // ์ˆ˜๋™์œผ๋กœ ์ƒ์„ฑ๋œ ํƒœ๊ทธ์ž„์„ ํ‘œ์‹œ
+ };
+
+ if (existingEntry && existingEntry.id) {
+ // ๊ธฐ์กด formEntry๊ฐ€ ์žˆ๋Š” ๊ฒฝ์šฐ - TAG_IDX ํƒ€์ž… ์ถ”๊ฐ€
+ let existingData: Array<{
+ TAG_IDX?: string; // ๐Ÿ†• TAG_IDX ํ•„๋“œ ์ถ”๊ฐ€
+ TAG_NO: string;
+ TAG_DESC?: string;
+ status?: string;
+ [key: string]: any;
+ }> = [];
+
+ if (Array.isArray(existingEntry.data)) {
+ existingData = existingEntry.data;
+ }
+
+ // TAG_IDX ๋˜๋Š” TAG_NO๊ฐ€ ์ด๋ฏธ ์กด์žฌํ•˜๋Š”์ง€ ํ™•์ธ (์šฐ์„ ์ˆœ์œ„: TAG_IDX)
+ const existingTagIndex = existingData.findIndex(
+ item => item.TAG_IDX === generatedTagIdx ||
+ (item.TAG_NO === validated.data.tagNo && !item.TAG_IDX)
+ );
+
+ if (existingTagIndex === -1) {
+ // ํƒœ๊ทธ๊ฐ€ ์—†์œผ๋ฉด ์ƒˆ๋กœ ์ถ”๊ฐ€
+ const updatedData = [...existingData, newTagData];
+
+ await tx
+ .update(formEntries)
+ .set({
+ data: updatedData,
+ updatedAt: new Date()
+ })
+ .where(eq(formEntries.id, existingEntry.id));
+
+ console.log(`[CREATE TAG] Added tag ${validated.data.tagNo} with tagIdx ${generatedTagIdx} to existing formEntry for form ${form.formCode}`);
+ } else {
+ console.log(`[CREATE TAG] Tag ${validated.data.tagNo} already exists in formEntry for form ${form.formCode}`);
+ }
+ } else {
+ // formEntry๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ ์ƒˆ๋กœ ์ƒ์„ฑ (TAG_IDX ํฌํ•จ)
+ await tx.insert(formEntries).values({
+ formCode: form.formCode,
+ contractItemId: selectedPackageId,
+ data: [newTagData],
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ });
+
+ console.log(`[CREATE TAG] Created new formEntry with tag ${validated.data.tagNo} and tagIdx ${generatedTagIdx} for form ${form.formCode}`);
+ }
+ } catch (formEntryError) {
+ console.error(`[CREATE TAG] Error updating formEntry for form ${form.formCode}:`, formEntryError);
+ // ๊ฐœ๋ณ„ formEntry ์—๋Ÿฌ๋Š” ๋กœ๊ทธ๋งŒ ๋‚จ๊ธฐ๊ณ  ์ „์ฒด ํŠธ๋žœ์žญ์…˜์€ ๊ณ„์† ์ง„ํ–‰
+ }
+ }
+
+ // 7) ์บ์‹œ ๋ฌดํšจํ™” (React ์„œ๋ฒ„ ์•ก์…˜์—์„œ ์บ์‹ฑ ์‚ฌ์šฉ ์‹œ)
+ revalidateTag(`tags-${selectedPackageId}`)
+ revalidateTag(`forms-${selectedPackageId}-ENG`)
+ revalidateTag("tags")
+
+ // ์ƒ์„ฑ๋œ ๊ฐ form์˜ ์บ์‹œ๋„ ๋ฌดํšจํ™”
+ createdOrExistingForms.forEach(form => {
+ revalidateTag(`form-data-${form.formCode}-${selectedPackageId}`)
+ })
+
+ // 8) ์„ฑ๊ณต ์‹œ ๋ฐ˜ํ™˜ (tagIdx ์ถ”๊ฐ€)
+ return {
+ success: true,
+ data: {
+ forms: createdOrExistingForms,
+ primaryFormId,
+ tagNo: validated.data.tagNo,
+ tagIdx: generatedTagIdx, // ๐Ÿ†• ์ƒ์„ฑ๋œ tagIdx๋„ ๋ฐ˜ํ™˜
+ },
+ }
+ })
+ } catch (err: any) {
+ console.log("createTag error:", err)
+
+ console.error("createTag error:", err)
+ return { error: getErrorMessage(err) }
+ }
+}
+
+export async function createTagInForm(
+ formData: CreateTagSchema,
+ selectedPackageId: number | null,
+ formCode: string,
+ packageCode: string
+) {
+ // 1. ์ดˆ๊ธฐ ๊ฒ€์ฆ
+ if (!selectedPackageId) {
+ console.error("[CREATE TAG] No selectedPackageId provided");
+ return {
+ success: false,
+ error: "No selectedPackageId provided"
+ };
+ }
+
+ // 2. FormData ๊ฒ€์ฆ
+ const validated = createTagSchema.safeParse(formData);
+ if (!validated.success) {
+ const errorMsg = validated.error.flatten().formErrors.join(", ");
+ console.error("[CREATE TAG] Validation failed:", errorMsg);
+ return {
+ success: false,
+ error: errorMsg
+ };
+ }
+
+ // 3. ์บ์‹œ ๋ฌดํšจํ™” ์„ค์ •
+ unstable_noStore();
+
+ try {
+ // 4. ํŠธ๋žœ์žญ์…˜ ์‹œ์ž‘
+ return await db.transaction(async (tx) => {
+ // 5. Contract Item ์ •๋ณด ์กฐํšŒ
+ const contractItemResult = await tx
+ .select({
+ contractId: contractItems.contractId,
+ projectId: contracts.projectId,
+ vendorId: contracts.vendorId
+ })
+ .from(contractItems)
+ .innerJoin(contracts, eq(contractItems.contractId, contracts.id))
+ .where(eq(contractItems.id, selectedPackageId))
+ .limit(1);
+
+ if (contractItemResult.length === 0) {
+ console.error("[CREATE TAG] Contract item not found");
+ return {
+ success: false,
+ error: "Contract item not found"
+ };
+ }
+
+ const { contractId, projectId, vendorId } = contractItemResult[0];
+
+ // 6. Vendor ์ •๋ณด ์กฐํšŒ
+ const vendor = await tx.query.vendors.findFirst({
+ where: eq(vendors.id, vendorId)
+ });
+
+ if (!vendor) {
+ console.error("[CREATE TAG] Vendor not found");
+ return {
+ success: false,
+ error: "์„ ํƒํ•œ ๋ฒค๋”๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."
+ };
+ }
+
+ // 7. ์ค‘๋ณต ํƒœ๊ทธ ํ™•์ธ
+ const duplicateCheck = await tx
+ .select({ count: sql<number>`count(*)` })
+ .from(tags)
+ .innerJoin(contractItems, eq(tags.contractItemId, contractItems.id))
+ .innerJoin(contracts, eq(contractItems.contractId, contracts.id))
+ .where(
+ and(
+ eq(contracts.projectId, projectId),
+ eq(tags.tagNo, validated.data.tagNo)
+ )
+ );
+
+ if (duplicateCheck[0].count > 0) {
+ console.error(`[CREATE TAG] Duplicate tag number: ${validated.data.tagNo}`);
+ return {
+ success: false,
+ error: `ํƒœ๊ทธ ๋ฒˆํ˜ธ "${validated.data.tagNo}"๋Š” ์ด๋ฏธ ์ด ๊ณ„์•ฝ ๋‚ด์— ์กด์žฌํ•ฉ๋‹ˆ๋‹ค.`,
+ };
+ }
+
+ // 8. Form ์กฐํšŒ
+ let form = await tx.query.forms.findFirst({
+ where: and(
+ eq(forms.formCode, formCode),
+ eq(forms.contractItemId, selectedPackageId)
+ )
+ });
+
+ // 9. Form์ด ์—†์œผ๋ฉด ์ƒ์„ฑ
+ if (!form) {
+ console.log(`[CREATE TAG] Form ${formCode} not found, attempting to create...`);
+
+ // Form Mappings ์กฐํšŒ
+ const allFormMappings = await getFormMappingsByTagType(
+ validated.data.tagType,
+ projectId,
+ validated.data.class
+ );
+
+ // IMEP ํผ๋งŒ ํ•„ํ„ฐ๋ง
+ const formMappings = allFormMappings?.filter(mapping => mapping.ep === "IMEP") || [];
+ const targetFormMapping = formMappings.find(mapping => mapping.formCode === formCode);
+
+ if (!targetFormMapping) {
+ console.error(`[CREATE TAG] No IMEP form mapping found for formCode: ${formCode}`);
+ return {
+ success: false,
+ error: `Form ${formCode} not found and no IMEP mapping available for tag type ${validated.data.tagType}`
+ };
+ }
+
+ // Form ์ƒ์„ฑ
+ const insertResult = await tx
+ .insert(forms)
+ .values({
+ contractItemId: selectedPackageId,
+ formCode: targetFormMapping.formCode,
+ formName: targetFormMapping.formName,
+ im: true,
+ })
+ .returning({ id: forms.id, formCode: forms.formCode, formName: forms.formName });
+
+ form = {
+ id: insertResult[0].id,
+ formCode: insertResult[0].formCode,
+ formName: insertResult[0].formName,
+ contractItemId: selectedPackageId,
+ im: true,
+ createdAt: new Date(),
+ updatedAt: new Date()
+ };
+
+ console.log(`[CREATE TAG] Successfully created form:`, insertResult[0]);
+ } else {
+ // ๊ธฐ์กด form์˜ im ์ƒํƒœ ์—…๋ฐ์ดํŠธ
+ if (form.im !== true) {
+ await tx
+ .update(forms)
+ .set({ im: true })
+ .where(eq(forms.id, form.id));
+
+ console.log(`[CREATE TAG] Form ${form.id} updated with im: true`);
+ }
+ }
+
+ // 10. Form์ด ์žˆ๋Š” ๊ฒฝ์šฐ์—๋งŒ ์ง„ํ–‰
+ if (!form?.id) {
+ console.error("[CREATE TAG] Failed to create or find form");
+ return {
+ success: false,
+ error: "Failed to create or find form"
+ };
+ }
+
+ // 11. Tag Index ์ƒ์„ฑ
+ const generatedTagIdx = generateTagIdx();
+ console.log(`[CREATE TAG] Generated tagIdx: ${generatedTagIdx}`);
+
+ // 12. ์ƒˆ Tag ์ƒ์„ฑ
+ const [newTag] = await insertTag(tx, {
+ contractItemId: selectedPackageId,
+ formId: form.id,
+ tagIdx: generatedTagIdx,
+ tagNo: validated.data.tagNo,
+ class: validated.data.class,
+ tagType: validated.data.tagType,
+ description: validated.data.description ?? null,
+ });
+
+ // 13. Tag Class ์กฐํšŒ
+ const tagClass = await tx.query.tagClasses.findFirst({
+ where: and(
+ eq(tagClasses.projectId, projectId),
+ eq(tagClasses.label, validated.data.class)
+ )
+ });
+
+ if (!tagClass) {
+ console.warn("[CREATE TAG] Tag class not found, using default");
+ }
+
+ // 14. FormEntry ์ฒ˜๋ฆฌ
+ const entry = await tx.query.formEntries.findFirst({
+ where: and(
+ eq(formEntries.formCode, formCode),
+ eq(formEntries.contractItemId, selectedPackageId),
+ )
+ });
+
+ // 15. ์ƒˆ๋กœ์šด ํƒœ๊ทธ ๋ฐ์ดํ„ฐ ์ค€๋น„
+ const newTagData = {
+ TAG_IDX: generatedTagIdx,
+ TAG_NO: validated.data.tagNo,
+ TAG_DESC: validated.data.description ?? null,
+ CLS_ID: tagClass?.code || validated.data.class, // tagClass๊ฐ€ ์—†์„ ๊ฒฝ์šฐ ๋Œ€๋น„
+ VNDRCD: vendor.vendorCode,
+ VNDRNM_1: vendor.vendorName,
+ CM3003: packageCode,
+ ME5074: packageCode,
+ status: "New" // ์ˆ˜๋™์œผ๋กœ ์ƒ์„ฑ๋œ ํƒœ๊ทธ์ž„์„ ํ‘œ์‹œ
+ };
+
+ if (entry?.id) {
+ // 16. ๊ธฐ์กด FormEntry ์—…๋ฐ์ดํŠธ
+ let existingData: Array<any> = [];
+ if (Array.isArray(entry.data)) {
+ existingData = entry.data;
+ }
+
+ console.log(`[CREATE TAG] Existing data count: ${existingData.length}`);
+
+ const updatedData = [...existingData, newTagData];
+
+ await tx
+ .update(formEntries)
+ .set({
+ data: updatedData,
+ updatedAt: new Date()
+ })
+ .where(eq(formEntries.id, entry.id));
+
+ console.log(`[CREATE TAG] Updated formEntry with new tag`);
+ } else {
+ // 17. ์ƒˆ FormEntry ์ƒ์„ฑ
+ console.log(`[CREATE TAG] Creating new formEntry`);
+
+ await tx.insert(formEntries).values({
+ formCode: formCode,
+ contractItemId: selectedPackageId,
+ data: [newTagData],
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ });
+
+ console.log(`[CREATE TAG] Created new formEntry`);
+ }
+
+ // 18. ์บ์‹œ ๋ฌดํšจํ™”
+ revalidateTag(`tags-${selectedPackageId}`);
+ revalidateTag(`forms-${selectedPackageId}`);
+ revalidateTag(`form-data-${formCode}-${selectedPackageId}`);
+ revalidateTag("tags");
+
+ console.log(`[CREATE TAG] Successfully created tag: ${validated.data.tagNo} with tagIdx: ${generatedTagIdx}`);
+
+ // 19. ์„ฑ๊ณต ์‘๋‹ต
+ return {
+ success: true,
+ data: {
+ formId: form.id,
+ tagNo: validated.data.tagNo,
+ tagIdx: generatedTagIdx,
+ formCreated: !form
+ }
+ };
+ });
+ } catch (err: any) {
+ // 20. ์—๋Ÿฌ ์ฒ˜๋ฆฌ
+ console.error("[CREATE TAG] Transaction error:", err);
+ const errorMessage = getErrorMessage(err);
+
+ return {
+ success: false,
+ error: errorMessage
+ };
+ }
+}
+
+export async function updateTag(
+ formData: UpdateTagSchema & { id: number },
+ selectedPackageId: number | null
+) {
+ if (!selectedPackageId) {
+ return { error: "No selectedPackageId provided" }
+ }
+
+ if (!formData.id) {
+ return { error: "No tag ID provided" }
+ }
+
+ // Validate formData
+ const validated = updateTagSchema.safeParse(formData)
+ if (!validated.success) {
+ return { error: validated.error.flatten().formErrors.join(", ") }
+ }
+
+ // React ์„œ๋ฒ„ ์•ก์…˜์—์„œ ๋งค ์š”์ฒญ๋งˆ๋‹ค ์‹คํ–‰
+ unstable_noStore()
+
+ try {
+ // ํ•˜๋‚˜์˜ ํŠธ๋žœ์žญ์…˜์—์„œ ๋ชจ๋“  ์ž‘์—… ์ˆ˜ํ–‰
+ return await db.transaction(async (tx) => {
+ // 1) ๊ธฐ์กด ํƒœ๊ทธ ์กด์žฌ ์—ฌ๋ถ€ ํ™•์ธ
+ const existingTag = await tx
+ .select()
+ .from(tags)
+ .where(eq(tags.id, formData.id))
+ .limit(1)
+
+ if (existingTag.length === 0) {
+ return { error: "ํƒœ๊ทธ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค." }
+ }
+
+ const originalTag = existingTag[0]
+
+ // 2) ์„ ํƒ๋œ contractItem์˜ contractId ๊ฐ€์ ธ์˜ค๊ธฐ
+ const contractItemResult = await tx
+ .select({
+ contractId: contractItems.contractId,
+ projectId: contracts.projectId // projectId ์ถ”๊ฐ€
+ })
+ .from(contractItems)
+ .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) // contracts ํ…Œ์ด๋ธ” ์กฐ์ธ
+ .where(eq(contractItems.id, selectedPackageId))
+ .limit(1)
+
+ if (contractItemResult.length === 0) {
+ return { error: "Contract item not found" }
+ }
+
+ const contractId = contractItemResult[0].contractId
+ const projectId = contractItemResult[0].projectId
+
+ // 3) ํƒœ๊ทธ ๋ฒˆํ˜ธ๊ฐ€ ๋ณ€๊ฒฝ๋˜์—ˆ๊ณ , ํ•ด๋‹น ๊ณ„์•ฝ ๋‚ด์—์„œ ๊ฐ™์€ tagNo๋ฅผ ๊ฐ€์ง„ ๋‹ค๋ฅธ ํƒœ๊ทธ๊ฐ€ ์žˆ๋Š”์ง€ ํ™•์ธ
+ if (originalTag.tagNo !== validated.data.tagNo) {
+ const duplicateCheck = await tx
+ .select({ count: sql<number>`count(*)` })
+ .from(tags)
+ .innerJoin(contractItems, eq(tags.contractItemId, contractItems.id))
+ .where(
+ and(
+ eq(contractItems.contractId, contractId),
+ eq(tags.tagNo, validated.data.tagNo),
+ ne(tags.id, formData.id) // ์ž๊ธฐ ์ž์‹ ์€ ์ œ์™ธ
+ )
+ )
+
+ if (duplicateCheck[0].count > 0) {
+ return {
+ error: `ํƒœ๊ทธ ๋ฒˆํ˜ธ "${validated.data.tagNo}"๋Š” ์ด๋ฏธ ์ด ๊ณ„์•ฝ ๋‚ด์— ์กด์žฌํ•ฉ๋‹ˆ๋‹ค.`,
+ }
+ }
+ }
+
+ // 4) ํƒœ๊ทธ ํƒ€์ž…์ด๋‚˜ ํด๋ž˜์Šค๊ฐ€ ๋ณ€๊ฒฝ๋˜์—ˆ๋Š”์ง€ ํ™•์ธ
+ const isTagTypeOrClassChanged =
+ originalTag.tagType !== validated.data.tagType ||
+ originalTag.class !== validated.data.class
+
+ let primaryFormId = originalTag.formId
+
+ // ํƒœ๊ทธ ํƒ€์ž…์ด๋‚˜ ํด๋ž˜์Šค๊ฐ€ ๋ณ€๊ฒฝ๋˜์—ˆ๋‹ค๋ฉด ์—ฐ๊ด€๋œ ํผ ์—…๋ฐ์ดํŠธ
+ if (isTagTypeOrClassChanged) {
+ // 4-1) ํƒœ๊ทธ ํƒ€์ž…์— ๋”ฐ๋ฅธ ํผ ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ
+ const formMappings = await getFormMappingsByTagType(
+ validated.data.tagType,
+ projectId, // projectId ์ „๋‹ฌ
+ validated.data.class
+ )
+
+ // ํผ ๋งคํ•‘์ด ์—†์œผ๋ฉด ๋กœ๊ทธ๋งŒ ๋‚จ๊ธฐ๊ณ  ์ง„ํ–‰
+ if (!formMappings || formMappings.length === 0) {
+ console.log(
+ "No form mappings found for tag type:",
+ validated.data.tagType,
+ "in project:",
+ projectId
+ )
+ }
+
+ // 4-2) ์ด ํƒœ๊ทธ ํƒ€์ž…์— ๋Œ€ํ•œ ์ฃผ์š” ํผ(์ฒซ ๋ฒˆ์งธ ํผ)์„ ์ฐพ๊ฑฐ๋‚˜ ์ƒ์„ฑ
+ const createdOrExistingForms: CreatedOrExistingForm[] = []
+
+ if (formMappings && formMappings.length > 0) {
+ for (const formMapping of formMappings) {
+ // ์ด๋ฏธ ์กด์žฌํ•˜๋Š” ํผ์ธ์ง€ ํ™•์ธ
+ const existingForm = await tx
+ .select({ id: forms.id })
+ .from(forms)
+ .where(
+ and(
+ eq(forms.contractItemId, selectedPackageId),
+ eq(forms.formCode, formMapping.formCode)
+ )
+ )
+ .limit(1)
+
+ let formId: number
+ if (existingForm.length > 0) {
+ // ์ด๋ฏธ ์กด์žฌํ•˜๋ฉด ํ•ด๋‹น ID ์‚ฌ์šฉ
+ formId = existingForm[0].id
+ createdOrExistingForms.push({
+ id: formId,
+ formCode: formMapping.formCode,
+ formName: formMapping.formName,
+ isNewlyCreated: false,
+ })
+ } else {
+ // ์กด์žฌํ•˜์ง€ ์•Š์œผ๋ฉด ์ƒˆ๋กœ ์ƒ์„ฑ
+ const insertResult = await tx
+ .insert(forms)
+ .values({
+ contractItemId: selectedPackageId,
+ formCode: formMapping.formCode,
+ formName: formMapping.formName,
+ })
+ .returning({ id: forms.id, formCode: forms.formCode, formName: forms.formName })
+
+ formId = insertResult[0].id
+ createdOrExistingForms.push({
+ id: formId,
+ formCode: insertResult[0].formCode,
+ formName: insertResult[0].formName,
+ isNewlyCreated: true,
+ })
+ }
+
+ // ์ฒซ ๋ฒˆ์งธ ํผ์„ "์ฃผ์š” ํผ"์œผ๋กœ ์„ค์ •ํ•˜์—ฌ ํƒœ๊ทธ ์—…๋ฐ์ดํŠธ ์‹œ ์‚ฌ์šฉ
+ if (createdOrExistingForms.length === 1) {
+ primaryFormId = formId
+ }
+ }
+ }
+ }
+
+ // 5) ํƒœ๊ทธ ์—…๋ฐ์ดํŠธ
+ const [updatedTag] = await tx
+ .update(tags)
+ .set({
+ contractItemId: selectedPackageId,
+ formId: primaryFormId,
+ tagNo: validated.data.tagNo,
+ class: validated.data.class,
+ tagType: validated.data.tagType,
+ description: validated.data.description ?? null,
+ updatedAt: new Date(),
+ })
+ .where(eq(tags.id, formData.id))
+ .returning()
+
+ // 6) ์บ์‹œ ๋ฌดํšจํ™” (React ์„œ๋ฒ„ ์•ก์…˜์—์„œ ์บ์‹ฑ ์‚ฌ์šฉ ์‹œ)
+ revalidateTag(`tags-${selectedPackageId}`)
+ revalidateTag(`forms-${selectedPackageId}`)
+ revalidateTag("tags")
+
+ // 7) ์„ฑ๊ณต ์‹œ ๋ฐ˜ํ™˜
+ return {
+ success: true,
+ data: {
+ tag: updatedTag,
+ formUpdated: isTagTypeOrClassChanged
+ },
+ }
+ })
+ } catch (err: any) {
+ console.error("updateTag error:", err)
+ return { error: getErrorMessage(err) }
+ }
+}
+
+export interface TagInputData {
+ tagNo: string;
+ class: string;
+ tagType: string;
+ description?: string | null;
+ formId?: number | null;
+ [key: string]: any;
+}
+// ์ƒˆ๋กœ์šด ์„œ๋ฒ„ ์•ก์…˜
+export async function bulkCreateTags(
+ tagsfromExcel: TagInputData[],
+ selectedPackageId: number
+) {
+ unstable_noStore();
+
+ if (!tagsfromExcel.length) {
+ return { error: "No tags provided" };
+ }
+
+ try {
+ // ๋‹จ์ผ ํŠธ๋žœ์žญ์…˜์œผ๋กœ ๋ชจ๋“  ์ž‘์—… ์ฒ˜๋ฆฌ
+ return await db.transaction(async (tx) => {
+ // 1. ์ปจํŠธ๋ž™ํŠธ ID ๋ฐ ํ”„๋กœ์ ํŠธ ID ์กฐํšŒ (ํ•œ ๋ฒˆ๋งŒ)
+ const contractItemResult = await tx
+ .select({
+ contractId: contractItems.contractId,
+ projectId: contracts.projectId // projectId ์ถ”๊ฐ€
+ })
+ .from(contractItems)
+ .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) // contracts ํ…Œ์ด๋ธ” ์กฐ์ธ
+ .where(eq(contractItems.id, selectedPackageId))
+ .limit(1);
+
+ if (contractItemResult.length === 0) {
+ return { error: "Contract item not found" };
+ }
+
+ const contractId = contractItemResult[0].contractId;
+ const projectId = contractItemResult[0].projectId; // projectId ์ถ”์ถœ
+
+ // 2. ๋ชจ๋“  ํƒœ๊ทธ ๋ฒˆํ˜ธ ์ค‘๋ณต ๊ฒ€์‚ฌ (ํ•œ ๋ฒˆ์—)
+ const tagNos = tagsfromExcel.map(tag => tag.tagNo);
+ const duplicateCheck = await tx
+ .select({ tagNo: tags.tagNo })
+ .from(tags)
+ .innerJoin(contractItems, eq(tags.contractItemId, contractItems.id))
+ .where(and(
+ eq(contractItems.contractId, contractId),
+ inArray(tags.tagNo, tagNos)
+ ));
+
+ if (duplicateCheck.length > 0) {
+ return {
+ error: `ํƒœ๊ทธ ๋ฒˆํ˜ธ "${duplicateCheck.map(d => d.tagNo).join(', ')}"๋Š” ์ด๋ฏธ ์กด์žฌํ•ฉ๋‹ˆ๋‹ค.`
+ };
+ }
+
+ // 3. ํƒœ๊ทธ๋ณ„ ํผ ์ •๋ณด ์ฒ˜๋ฆฌ ๋ฐ ํƒœ๊ทธ ์ƒ์„ฑ
+ const createdTags = [];
+ const allFormsInfo = []; // ๋ชจ๋“  ํƒœ๊ทธ์— ๋Œ€ํ•œ ํผ ์ •๋ณด ์ €์žฅ
+
+ // ํƒœ๊ทธ ์œ ํ˜•๋ณ„ ํผ ๋งคํ•‘ ์บ์‹ฑ (์„ฑ๋Šฅ ์ตœ์ ํ™”)
+ const formMappingsCache = new Map();
+
+ // formEntries ์—…๋ฐ์ดํŠธ๋ฅผ ์œ„ํ•œ ๋งต (formCode -> ํƒœ๊ทธ ๋ฐ์ดํ„ฐ ๋ฐฐ์—ด)
+ const tagsByFormCode = new Map<string, Array<{
+ TAG_NO: string;
+ TAG_DESC: string | null;
+ status: string;
+ }>>();
+
+ for (const tagData of tagsfromExcel) {
+ // ์บ์‹œ ํ‚ค ์ƒ์„ฑ (tagType + class)
+ const cacheKey = `${tagData.tagType}|${tagData.class || 'NONE'}`;
+
+ // ํผ ๋งคํ•‘ ๊ฐ€์ ธ์˜ค๊ธฐ (์บ์‹œ ์‚ฌ์šฉ)
+ let formMappings;
+ if (formMappingsCache.has(cacheKey)) {
+ formMappings = formMappingsCache.get(cacheKey);
+ } else {
+ const tagTypeLabel = await tx
+ .select({ description: tagTypes.description })
+ .from(tagTypes)
+ .where(
+ and(
+ eq(tagTypes.projectId, projectId),
+ eq(tagTypes.code, tagData.tagType),
+ )
+ )
+ .limit(1)
+
+ const tagTypeLabelText = tagTypeLabel[0].description
+
+ // ๊ฐ ํƒœ๊ทธ ์œ ํ˜•์— ๋Œ€ํ•œ ํผ ๋งคํ•‘ ์กฐํšŒ (projectId ์ „๋‹ฌ)
+ const allFormMappings = await getFormMappingsByTagType(
+ tagTypeLabelText,
+ projectId, // projectId ์ „๋‹ฌ
+ tagData.class
+ );
+
+ // ep๊ฐ€ "IMEP"์ธ ๊ฒƒ๋งŒ ํ•„ํ„ฐ๋ง
+ formMappings = allFormMappings?.filter(mapping => mapping.ep === "IMEP") || [];
+ formMappingsCache.set(cacheKey, formMappings);
+ }
+
+ // ํผ ์ฒ˜๋ฆฌ ๋กœ์ง
+ let primaryFormId: number | null = null;
+ const createdOrExistingForms: CreatedOrExistingForm[] = [];
+
+ if (formMappings && formMappings.length > 0) {
+ for (const formMapping of formMappings) {
+ // ํ•ด๋‹น ํผ์ด ์ด๋ฏธ ์กด์žฌํ•˜๋Š”์ง€ ํ™•์ธ
+ const existingForm = await tx
+ .select({ id: forms.id, im: forms.im })
+ .from(forms)
+ .where(
+ and(
+ eq(forms.contractItemId, selectedPackageId),
+ eq(forms.formCode, formMapping.formCode)
+ )
+ )
+ .limit(1);
+
+ let formId: number;
+ if (existingForm.length > 0) {
+ // ์ด๋ฏธ ์กด์žฌํ•˜๋ฉด ํ•ด๋‹น ID ์‚ฌ์šฉ
+ formId = existingForm[0].id;
+
+ // im ํ•„๋“œ ์—…๋ฐ์ดํŠธ (ํ•„์š”ํ•œ ๊ฒฝ์šฐ)
+ if (existingForm[0].im !== true) {
+ await tx
+ .update(forms)
+ .set({ im: true })
+ .where(eq(forms.id, formId));
+ }
+
+ createdOrExistingForms.push({
+ id: formId,
+ formCode: formMapping.formCode,
+ formName: formMapping.formName,
+ isNewlyCreated: false,
+ });
+ } else {
+ // ์กด์žฌํ•˜์ง€ ์•Š์œผ๋ฉด ์ƒˆ๋กœ ์ƒ์„ฑ
+ const insertResult = await tx
+ .insert(forms)
+ .values({
+ contractItemId: selectedPackageId,
+ formCode: formMapping.formCode,
+ formName: formMapping.formName,
+ im: true
+ })
+ .returning({ id: forms.id, formCode: forms.formCode, formName: forms.formName });
+
+ formId = insertResult[0].id;
+ createdOrExistingForms.push({
+ id: formId,
+ formCode: insertResult[0].formCode,
+ formName: insertResult[0].formName,
+ isNewlyCreated: true,
+ });
+ }
+
+ // ์ฒซ ๋ฒˆ์งธ ํผ์„ "์ฃผ์š” ํผ"์œผ๋กœ ์„ค์ •ํ•˜์—ฌ ํƒœ๊ทธ ์ƒ์„ฑ ์‹œ ์‚ฌ์šฉ
+ if (primaryFormId === null) {
+ primaryFormId = formId;
+ }
+
+ // formEntries ์—…๋ฐ์ดํŠธ๋ฅผ ์œ„ํ•œ ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘ (tagsfromExcel์˜ ์›๋ณธ ๋ฐ์ดํ„ฐ ์‚ฌ์šฉ)
+ const newTagEntry = {
+ TAG_NO: tagData.tagNo,
+ TAG_DESC: tagData.description || null,
+ status: "New" // ๋ฒŒํฌ ์ƒ์„ฑ๋„ ์ˆ˜๋™ ์ƒ์„ฑ์œผ๋กœ ๋ถ„๋ฅ˜
+ };
+
+ if (!tagsByFormCode.has(formMapping.formCode)) {
+ tagsByFormCode.set(formMapping.formCode, []);
+ }
+ tagsByFormCode.get(formMapping.formCode)!.push(newTagEntry);
+ }
+ } else {
+ console.log(
+ "No IMEP form mappings found for tag type:",
+ tagData.tagType,
+ "class:",
+ tagData.class || "NONE",
+ "in project:",
+ projectId
+ );
+ }
+
+ // ํƒœ๊ทธ ์ƒ์„ฑ
+ const [newTag] = await insertTag(tx, {
+ contractItemId: selectedPackageId,
+ formId: primaryFormId,
+ tagNo: tagData.tagNo,
+ class: tagData.class || "",
+ tagType: tagData.tagType,
+ description: tagData.description || null,
+ });
+
+ createdTags.push(newTag);
+
+ // ํ•ด๋‹น ํƒœ๊ทธ์˜ ํผ ์ •๋ณด ์ €์žฅ
+ allFormsInfo.push({
+ tagNo: tagData.tagNo,
+ forms: createdOrExistingForms,
+ primaryFormId,
+ });
+ }
+
+ // 4. formEntries ์—…๋ฐ์ดํŠธ ์ฒ˜๋ฆฌ
+ for (const [formCode, newTagsData] of tagsByFormCode.entries()) {
+ try {
+ // ๊ธฐ์กด formEntry ๊ฐ€์ ธ์˜ค๊ธฐ
+ const existingEntry = await tx.query.formEntries.findFirst({
+ where: and(
+ eq(formEntries.formCode, formCode),
+ eq(formEntries.contractItemId, selectedPackageId)
+ )
+ });
+
+ if (existingEntry && existingEntry.id) {
+ // ๊ธฐ์กด formEntry๊ฐ€ ์žˆ๋Š” ๊ฒฝ์šฐ
+ let existingData: Array<{
+ TAG_NO: string;
+ TAG_DESC?: string | null;
+ status?: string;
+ [key: string]: any;
+ }> = [];
+
+ if (Array.isArray(existingEntry.data)) {
+ existingData = existingEntry.data;
+ }
+
+ // ๊ธฐ์กด TAG_NO๋“ค ์ถ”์ถœ
+ const existingTagNos = new Set(existingData.map(item => item.TAG_NO));
+
+ // ์ค‘๋ณต๋˜์ง€ ์•Š์€ ์ƒˆ ํƒœ๊ทธ๋“ค๋งŒ ํ•„ํ„ฐ๋ง
+ const newUniqueTagsData = newTagsData.filter(
+ tagData => !existingTagNos.has(tagData.TAG_NO)
+ );
+
+ if (newUniqueTagsData.length > 0) {
+ const updatedData = [...existingData, ...newUniqueTagsData];
+
+ await tx
+ .update(formEntries)
+ .set({
+ data: updatedData,
+ updatedAt: new Date()
+ })
+ .where(eq(formEntries.id, existingEntry.id));
+
+ console.log(`[BULK CREATE] Added ${newUniqueTagsData.length} tags to existing formEntry for form ${formCode}`);
+ } else {
+ console.log(`[BULK CREATE] All tags already exist in formEntry for form ${formCode}`);
+ }
+ } else {
+ // formEntry๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ ์ƒˆ๋กœ ์ƒ์„ฑ
+ await tx.insert(formEntries).values({
+ formCode: formCode,
+ contractItemId: selectedPackageId,
+ data: newTagsData,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ });
+
+ console.log(`[BULK CREATE] Created new formEntry with ${newTagsData.length} tags for form ${formCode}`);
+ }
+ } catch (formEntryError) {
+ console.error(`[BULK CREATE] Error updating formEntry for form ${formCode}:`, formEntryError);
+ // ๊ฐœ๋ณ„ formEntry ์—๋Ÿฌ๋Š” ๋กœ๊ทธ๋งŒ ๋‚จ๊ธฐ๊ณ  ์ „์ฒด ํŠธ๋žœ์žญ์…˜์€ ๊ณ„์† ์ง„ํ–‰
+ }
+ }
+
+ // 5. ์บ์‹œ ๋ฌดํšจํ™” (ํ•œ ๋ฒˆ๋งŒ)
+ revalidateTag(`tags-${selectedPackageId}`);
+ revalidateTag(`forms-${selectedPackageId}`);
+ revalidateTag("tags");
+
+ // ์—…๋ฐ์ดํŠธ๋œ ๋ชจ๋“  form์˜ ์บ์‹œ๋„ ๋ฌดํšจํ™”
+ for (const formCode of tagsByFormCode.keys()) {
+ revalidateTag(`form-data-${formCode}-${selectedPackageId}`);
+ }
+
+ return {
+ success: true,
+ data: {
+ createdCount: createdTags.length,
+ tags: createdTags,
+ formsInfo: allFormsInfo,
+ formEntriesUpdated: tagsByFormCode.size // ์—…๋ฐ์ดํŠธ๋œ formEntry ์ˆ˜
+ }
+ };
+ });
+ } catch (err: any) {
+ console.error("bulkCreateTags error:", err);
+ return { error: getErrorMessage(err) || "Failed to create tags" };
+ }
+}
+/** ๋ณต์ˆ˜ ์‚ญ์ œ */
+interface RemoveTagsInput {
+ ids: number[];
+ selectedPackageId: number;
+}
+
+
+// formEntries์˜ data JSON์—์„œ tagNo๊ฐ€ ์ผ์น˜ํ•˜๋Š” ๊ฐ์ฒด๋ฅผ ์ œ๊ฑฐํ•ด์ฃผ๋Š” ์˜ˆ์‹œ ํ•จ์ˆ˜
+function removeTagFromDataJson(
+ dataJson: any,
+ tagNo: string
+): any {
+ // data ๊ตฌ์กฐ๊ฐ€ ์–ด๋–ป๊ฒŒ ์ƒ๊ฒผ๋Š”์ง€์— ๋”ฐ๋ผ ๋กœ์ง์ด ๋‹ฌ๋ผ์ง‘๋‹ˆ๋‹ค.
+ // ์˜ˆ: data ๋ฐฐ์—ด ์•ˆ์— { TAG_NO: string, ... } ํ˜•ํƒœ๋กœ ์—ฌ๋Ÿฌ ๊ฐ์ฒด๊ฐ€ ์žˆ๋‹ค๊ณ  ๊ฐ€์ •
+ if (!Array.isArray(dataJson)) return dataJson
+ return dataJson.filter((entry) => entry.TAG_NO !== tagNo)
+}
+
+export async function removeTags(input: RemoveTagsInput) {
+ unstable_noStore() // React ์„œ๋ฒ„ ์•ก์…˜ ๋ฌด์ƒํƒœ ํ•จ์ˆ˜
+
+ const { ids, selectedPackageId } = input
+
+ try {
+ await db.transaction(async (tx) => {
+
+ const packageInfo = await tx
+ .select({
+ projectId: contracts.projectId
+ })
+ .from(contractItems)
+ .innerJoin(contracts, eq(contractItems.contractId, contracts.id))
+ .where(eq(contractItems.id, selectedPackageId))
+ .limit(1);
+
+ if (packageInfo.length === 0) {
+ throw new Error(`Contract item with ID ${selectedPackageId} not found`);
+ }
+
+ const projectId = packageInfo[0].projectId;
+
+ // 1) ์‚ญ์ œ ๋Œ€์ƒ tag๋“ค์„ ๋ฏธ๋ฆฌ ์กฐํšŒ
+ const tagsToDelete = await tx
+ .select({
+ id: tags.id,
+ tagNo: tags.tagNo,
+ tagType: tags.tagType,
+ class: tags.class,
+ })
+ .from(tags)
+ .where(inArray(tags.id, ids))
+
+ // 2) ํƒœ๊ทธ ํƒ€์ž…๊ณผ ํด๋ž˜์Šค์˜ ๊ณ ์œ  ์กฐํ•ฉ ์ถ”์ถœ
+ const uniqueTypeClassCombinations = [...new Set(
+ tagsToDelete.map(tag => `${tag.tagType}|${tag.class || ''}`)
+ )].map(combo => {
+ const [tagType, classValue] = combo.split('|');
+ return { tagType, class: classValue || undefined };
+ });
+
+ // 3) ๊ฐ ํƒœ๊ทธ ํƒ€์ž…/ํด๋ž˜์Šค ์กฐํ•ฉ์— ๋Œ€ํ•ด ์ฒ˜๋ฆฌ
+ for (const { tagType, class: classValue } of uniqueTypeClassCombinations) {
+ // 3-1) ์‚ญ์ œ ์ค‘์ธ ํƒœ๊ทธ๋“ค ์™ธ์—, ๋™์ผํ•œ ํƒœ๊ทธ ํƒ€์ž…/ํด๋ž˜์Šค๋ฅผ ๊ฐ€์ง„ ๋‹ค๋ฅธ ํƒœ๊ทธ๊ฐ€ ์žˆ๋Š”์ง€ ํ™•์ธ
+ const otherTagsWithSameTypeClass = await tx
+ .select({ count: count() })
+ .from(tags)
+ .where(
+ and(
+ eq(tags.tagType, tagType),
+ classValue ? eq(tags.class, classValue) : isNull(tags.class),
+ not(inArray(tags.id, ids)), // ํ˜„์žฌ ์‚ญ์ œ ์ค‘์ธ ํƒœ๊ทธ๋“ค์€ ์ œ์™ธ
+ eq(tags.contractItemId, selectedPackageId) // ๊ฐ™์€ contractItemId ๋‚ด์—์„œ๋งŒ ํ™•์ธ
+ )
+ )
+
+ // 3-2) ์ด ํƒœ๊ทธ ํƒ€์ž…/ํด๋ž˜์Šค์— ์—ฐ๊ฒฐ๋œ ํผ ๋งคํ•‘ ๊ฐ€์ ธ์˜ค๊ธฐ
+ const formMappings = await getFormMappingsByTagType(tagType, projectId, classValue);
+
+ if (!formMappings.length) continue;
+
+ // 3-3) ์ด ํƒœ๊ทธ ํƒ€์ž…/ํด๋ž˜์Šค์™€ ๊ด€๋ จ๋œ ํƒœ๊ทธ ๋ฒˆํ˜ธ ์ถ”์ถœ
+ const relevantTagNos = tagsToDelete
+ .filter(tag => tag.tagType === tagType &&
+ (classValue ? tag.class === classValue : !tag.class))
+ .map(tag => tag.tagNo);
+
+ // 3-4) ๊ฐ ํผ ์ฝ”๋“œ์— ๋Œ€ํ•ด ์ฒ˜๋ฆฌ
+ for (const formMapping of formMappings) {
+ // ๋‹ค๋ฅธ ํƒœ๊ทธ๊ฐ€ ์—†๋‹ค๋ฉด ํผ ์‚ญ์ œ
+ if (otherTagsWithSameTypeClass[0].count === 0) {
+ // ํผ ์‚ญ์ œ
+ await tx
+ .delete(forms)
+ .where(
+ and(
+ eq(forms.contractItemId, selectedPackageId),
+ eq(forms.formCode, formMapping.formCode)
+ )
+ )
+
+ // formEntries ํ…Œ์ด๋ธ”์—์„œ๋„ ํ•ด๋‹น formCode ๊ด€๋ จ ๋ฐ์ดํ„ฐ ์‚ญ์ œ
+ await tx
+ .delete(formEntries)
+ .where(
+ and(
+ eq(formEntries.contractItemId, selectedPackageId),
+ eq(formEntries.formCode, formMapping.formCode)
+ )
+ )
+ }
+ // ๋‹ค๋ฅธ ํƒœ๊ทธ๊ฐ€ ์žˆ๋‹ค๋ฉด formEntries ๋ฐ์ดํ„ฐ์—์„œ ํ•ด๋‹น ํƒœ๊ทธ ์ •๋ณด๋งŒ ์ œ๊ฑฐ
+ else if (relevantTagNos.length > 0) {
+ const formEntryRecords = await tx
+ .select({
+ id: formEntries.id,
+ data: formEntries.data,
+ })
+ .from(formEntries)
+ .where(
+ and(
+ eq(formEntries.contractItemId, selectedPackageId),
+ eq(formEntries.formCode, formMapping.formCode)
+ )
+ )
+
+ // ๊ฐ formEntry์— ๋Œ€ํ•ด ์ฒ˜๋ฆฌ
+ for (const entry of formEntryRecords) {
+ let updatedJson = entry.data;
+
+ // ๊ฐ tagNo์— ๋Œ€ํ•ด JSON ๋ฐ์ดํ„ฐ์—์„œ ์ œ๊ฑฐ
+ for (const tagNo of relevantTagNos) {
+ updatedJson = removeTagFromDataJson(updatedJson, tagNo);
+ }
+
+ // ๋ณ€๊ฒฝ์ด ์žˆ๋‹ค๋ฉด ์—…๋ฐ์ดํŠธ
+ await tx
+ .update(formEntries)
+ .set({ data: updatedJson })
+ .where(eq(formEntries.id, entry.id))
+ }
+ }
+ }
+ }
+
+ // 4) ๋งˆ์ง€๋ง‰์œผ๋กœ tags ํ…Œ์ด๋ธ”์—์„œ ํƒœ๊ทธ๋“ค ์‚ญ์ œ
+ await tx.delete(tags).where(inArray(tags.id, ids))
+ })
+
+ // 5) ์บ์‹œ ๋ฌดํšจํ™”
+ revalidateTag(`tags-${selectedPackageId}`)
+ revalidateTag(`forms-${selectedPackageId}`)
+
+ return { data: null, error: null }
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) }
+ }
+}
+// Updated service functions to support the new schema
+
+// ์—…๋ฐ์ดํŠธ๋œ ClassOption ํƒ€์ž…
+export interface ClassOption {
+ code: string;
+ label: string;
+ tagTypeCode: string; // ํด๋ž˜์Šค์™€ ์—ฐ๊ฒฐ๋œ ํƒœ๊ทธ ํƒ€์ž… ์ฝ”๋“œ
+ tagTypeDescription?: string; // ํƒœ๊ทธ ํƒ€์ž…์˜ ์„ค๋ช… (์„ ํƒ์ )
+}
+
+/**
+ * Class ์˜ต์…˜ ๋ชฉ๋ก์„ ๊ฐ€์ ธ์˜ค๋Š” ํ•จ์ˆ˜
+ * ์ด์ œ ๊ฐ ํด๋ž˜์Šค๋Š” ์—ฐ๊ฒฐ๋œ tagTypeCode์™€ tagTypeDescription์„ ํฌํ•จ
+ */
+export async function getClassOptions(selectedPackageId: number): Promise<UpdatedClassOption[]> {
+ try {
+ // 1. ๋จผ์ € contractItems์—์„œ projectId ์กฐํšŒ
+ const packageInfo = await db
+ .select({
+ projectId: contracts.projectId
+ })
+ .from(contractItems)
+ .innerJoin(contracts, eq(contractItems.contractId, contracts.id))
+ .where(eq(contractItems.id, selectedPackageId))
+ .limit(1);
+
+ if (packageInfo.length === 0) {
+ throw new Error(`Contract item with ID ${selectedPackageId} not found`);
+ }
+
+ const projectId = packageInfo[0].projectId;
+
+ // 2. ํƒœ๊ทธ ํด๋ž˜์Šค๋“ค์„ ์„œ๋ธŒํด๋ž˜์Šค ์ •๋ณด์™€ ํ•จ๊ป˜ ์กฐํšŒ
+ const tagClassesWithSubclasses = await db
+ .select({
+ id: tagClasses.id,
+ code: tagClasses.code,
+ label: tagClasses.label,
+ tagTypeCode: tagClasses.tagTypeCode,
+ subclasses: tagClasses.subclasses,
+ subclassRemark: tagClasses.subclassRemark,
+ })
+ .from(tagClasses)
+ .where(eq(tagClasses.projectId, projectId))
+ .orderBy(tagClasses.code);
+
+ // 3. ํƒœ๊ทธ ํƒ€์ž… ์ •๋ณด๋„ ํ•จ๊ป˜ ์กฐํšŒ (description์„ ์œ„ํ•ด)
+ const tagTypesMap = new Map();
+ const tagTypesList = await db
+ .select({
+ code: tagTypes.code,
+ description: tagTypes.description,
+ })
+ .from(tagTypes)
+ .where(eq(tagTypes.projectId, projectId));
+
+ tagTypesList.forEach(tagType => {
+ tagTypesMap.set(tagType.code, tagType.description);
+ });
+
+ // 4. ํด๋ž˜์Šค ์˜ต์…˜์œผ๋กœ ๋ณ€ํ™˜
+ const classOptions: UpdatedClassOption[] = tagClassesWithSubclasses.map(cls => ({
+ value: cls.code,
+ label: cls.label,
+ code: cls.code,
+ description: cls.label,
+ tagTypeCode: cls.tagTypeCode,
+ tagTypeDescription: tagTypesMap.get(cls.tagTypeCode) || cls.tagTypeCode,
+ subclasses: cls.subclasses || [],
+ subclassRemark: cls.subclassRemark || {},
+ }));
+
+ return classOptions;
+ } catch (error) {
+ console.error("Error fetching class options with subclasses:", error);
+ throw new Error("Failed to fetch class options");
+ }
+}
+interface SubFieldDef {
+ name: string
+ label: string
+ type: "select" | "text"
+ options: { value: string; label: string }[]
+ expression: string | null
+ delimiter: string | null
+}
+
+export async function getSubfieldsByTagType(
+ tagTypeCode: string,
+ selectedPackageId: number,
+ subclassRemark: string = "",
+ subclass: string = "",
+) {
+ try {
+ // 1. ๋จผ์ € contractItems์—์„œ projectId ์กฐํšŒ
+ const packageInfo = await db
+ .select({
+ projectId: contracts.projectId
+ })
+ .from(contractItems)
+ .innerJoin(contracts, eq(contractItems.contractId, contracts.id))
+ .where(eq(contractItems.id, selectedPackageId))
+ .limit(1);
+
+ if (packageInfo.length === 0) {
+ throw new Error(`Contract item with ID ${selectedPackageId} not found`);
+ }
+
+ const projectId = packageInfo[0].projectId;
+
+ // 2. ์˜ฌ๋ฐ”๋ฅธ projectId๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ tagSubfields ์กฐํšŒ
+ const rows = await db
+ .select()
+ .from(tagSubfields)
+ .where(
+ and(
+ eq(tagSubfields.tagTypeCode, tagTypeCode),
+ eq(tagSubfields.projectId, projectId)
+ )
+ )
+ .orderBy(asc(tagSubfields.sortOrder));
+
+ // ๊ฐ row -> SubFieldDef
+ const formattedSubFields: SubFieldDef[] = [];
+ for (const sf of rows) {
+ // projectId๊ฐ€ ํ•„์š”ํ•œ ๊ฒฝ์šฐ getSubfieldType๊ณผ getSubfieldOptions ํ•จ์ˆ˜์—๋„ ์ „๋‹ฌ
+ const subfieldType = await getSubfieldType(sf.attributesId, projectId);
+ const subclassMatched =subclassRemark.includes(sf.attributesId ) ? subclass: null
+
+ const subfieldOptions = subfieldType === "select"
+ ? await getSubfieldOptions(sf.attributesId, projectId, subclassMatched) // subclassRemark ํŒŒ๋ผ๋ฏธํ„ฐ ์ถ”๊ฐ€
+ : [];
+
+ formattedSubFields.push({
+ name: sf.attributesId.toLowerCase(),
+ label: sf.attributesDescription,
+ type: subfieldType,
+ options: subfieldOptions,
+ expression: sf.expression,
+ delimiter: sf.delimiter,
+ });
+ }
+
+ return { subFields: formattedSubFields };
+ } catch (error) {
+ console.error("Error fetching subfields by tag type:", error);
+ throw new Error("Failed to fetch subfields");
+ }
+}
+
+
+
+async function getSubfieldType(attributesId: string, projectId: number): Promise<"select" | "text"> {
+ const optRows = await db
+ .select()
+ .from(tagSubfieldOptions)
+ .where(and(eq(tagSubfieldOptions.attributesId, attributesId), eq(tagSubfieldOptions.projectId, projectId)))
+
+ return optRows.length > 0 ? "select" : "text"
+}
+
+export interface SubfieldOption {
+ /**
+ * ์˜ต์…˜์˜ ์‹ค์ œ ๊ฐ’ (๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์ €์žฅ๋  ๊ฐ’)
+ * ์˜ˆ: "PM", "AA", "VB", "01" ๋“ฑ
+ */
+ value: string;
+
+ /**
+ * ์˜ต์…˜์˜ ํ‘œ์‹œ ๋ ˆ์ด๋ธ” (์‚ฌ์šฉ์ž์—๊ฒŒ ๋ณด์—ฌ์งˆ ํ…์ŠคํŠธ)
+ * ์˜ˆ: "Pump", "Pneumatic Motor", "Ball Valve" ๋“ฑ
+ */
+ label: string;
+}
+
+
+
+/**
+ * SubField์˜ ์˜ต์…˜ ๋ชฉ๋ก์„ ๊ฐ€์ ธ์˜ค๋Š” ๋ณด์กฐ ํ•จ์ˆ˜
+ */
+async function getSubfieldOptions(
+ attributesId: string,
+ projectId: number,
+ subclass: string = ""
+): Promise<SubfieldOption[]> {
+ try {
+ // 1. subclassRemark๊ฐ€ ์žˆ๋Š” ๊ฒฝ์šฐ API์—์„œ ์ฝ”๋“œ ๋ฆฌ์ŠคํŠธ ๊ฐ€์ ธ์™€์„œ ํ•„ํ„ฐ๋ง
+ if (subclass && subclass.trim() !== "") {
+ // ํ”„๋กœ์ ํŠธ ์ฝ”๋“œ๋ฅผ projectId๋กœ๋ถ€ํ„ฐ ์กฐํšŒ
+ const projectInfo = await db
+ .select({
+ code: projects.code
+ })
+ .from(projects)
+ .where(eq(projects.id, projectId))
+ .limit(1);
+
+ if (projectInfo.length === 0) {
+ throw new Error(`Project with ID ${projectId} not found`);
+ }
+
+ const projectCode = projectInfo[0].code;
+
+ // API์—์„œ ์ฝ”๋“œ ๋ฆฌ์ŠคํŠธ ๊ฐ€์ ธ์˜ค๊ธฐ
+ const codeListValues = await getCodeListsByID(projectCode);
+
+ // ์„œ๋ธŒํด๋ž˜์Šค ๋ฆฌ๋งˆํฌ ๊ฐ’๋“ค์„ ๋ถ„๋ฆฌ (์‰ผํ‘œ, ๊ณต๋ฐฑ ๋“ฑ์œผ๋กœ ๊ตฌ๋ถ„)
+ const remarkValues = subclass
+ .split(/[,\s]+/) // ์‰ผํ‘œ๋‚˜ ๊ณต๋ฐฑ์œผ๋กœ ๋ถ„๋ฆฌ
+ .map(val => val.trim())
+ .filter(val => val.length > 0);
+
+ if (remarkValues.length > 0) {
+ // REMARK ํ•„๋“œ๊ฐ€ remarkValues ์ค‘ ํ•˜๋‚˜๋ฅผ ํฌํ•จํ•˜๊ณ  ์žˆ๋Š” ํ•ญ๋ชฉ๋“ค ํ•„ํ„ฐ๋ง
+ const filteredCodeValues = codeListValues.filter(codeValue =>
+ remarkValues.some(remarkValue =>
+ // ๋Œ€์†Œ๋ฌธ์ž ๊ตฌ๋ถ„ ์—†์ด ํฌํ•จ ์—ฌ๋ถ€ ํ™•์ธ
+ codeValue.VALUE.toLowerCase().includes(remarkValue.toLowerCase()) ||
+ remarkValue.toLowerCase().includes(codeValue.VALUE.toLowerCase())
+ )
+ );
+
+ // ํ•„ํ„ฐ๋ง๋œ ๊ฒฐ๊ณผ๋ฅผ PRNT_VALUE -> value, DESC -> label๋กœ ๋ณ€ํ™˜
+ return filteredCodeValues.map((codeValue) => ({
+ value: codeValue.PRNT_VALUE,
+ label: codeValue.DESC
+ }));
+ }
+ }
+
+ // 2. subclassRemark๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ ๊ธฐ์กด ๋ฐฉ์‹์œผ๋กœ DB์—์„œ ์กฐํšŒ
+ const allOptions = await db
+ .select({
+ code: tagSubfieldOptions.code,
+ label: tagSubfieldOptions.label
+ })
+ .from(tagSubfieldOptions)
+ .where(
+ and(
+ eq(tagSubfieldOptions.attributesId, attributesId),
+ eq(tagSubfieldOptions.projectId, projectId),
+ )
+ );
+
+ return allOptions.map((row) => ({
+ value: row.code,
+ label: row.label
+ }));
+ } catch (error) {
+ console.error(`Error fetching filtered options for attribute ${attributesId}:`, error);
+ return [];
+ }
+}
+
+export interface UpdatedClassOption extends ClassOption {
+ tagTypeCode: string
+ tagTypeDescription?: string
+ subclasses: {id: string, desc: string}[]
+ subclassRemark: Record<string, string>
+}
+
+/**
+ * Tag Type ๋ชฉ๋ก์„ ๊ฐ€์ ธ์˜ค๋Š” ํ•จ์ˆ˜
+ * ์ด์ œ tagTypes ํ…Œ์ด๋ธ”์—์„œ ์ง์ ‘ ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€์ ธ์˜ด
+ */
+export async function getTagTypes(): Promise<{ options: TagTypeOption[] }> {
+ return unstable_cache(
+ async () => {
+ console.log(`[Server] Fetching tag types from tagTypes table`)
+
+ try {
+ // ์ด์ œ tagSubfields๊ฐ€ ์•„๋‹Œ tagTypes ํ…Œ์ด๋ธ”์—์„œ ์ง์ ‘ ์กฐํšŒ
+ const result = await db
+ .select({
+ code: tagTypes.code,
+ description: tagTypes.description,
+ })
+ .from(tagTypes)
+ .orderBy(tagTypes.description);
+
+ // TagTypeOption ํ˜•์‹์œผ๋กœ ๋ณ€ํ™˜
+ const tagTypeOptions: TagTypeOption[] = result.map(item => ({
+ id: item.code, // id ํ•„๋“œ์— code ๊ฐ’ ํ• ๋‹น
+ label: item.description, // label ํ•„๋“œ์— description ๊ฐ’ ํ• ๋‹น
+ }));
+
+ console.log(`[Server] Found ${tagTypeOptions.length} tag types`)
+ return { options: tagTypeOptions };
+ } catch (error) {
+ console.error('[Server] Error fetching tag types:', error)
+ return { options: [] }
+ }
+ },
+ ['tag-types-list'],
+ {
+ revalidate: 3600, // 1์‹œ๊ฐ„ ์บ์‹œ
+ tags: ['tag-types']
+ }
+ )()
+}
+
+/**
+ * TagTypeOption ์ธํ„ฐํŽ˜์ด์Šค ์ •์˜
+ */
+export interface TagTypeOption {
+ id: string; // tagTypes.code ๊ฐ’
+ label: string; // tagTypes.description ๊ฐ’
+}
+
+export async function getProjectIdFromContractItemId(contractItemId: number): Promise<number | null> {
+ try {
+ // First get the contractId from contractItems
+ const contractItem = await db.query.contractItems.findFirst({
+ where: eq(contractItems.id, contractItemId),
+ columns: {
+ contractId: true
+ }
+ });
+
+ if (!contractItem) return null;
+
+ // Then get the projectId from contracts
+ const contract = await db.query.contracts.findFirst({
+ where: eq(contracts.id, contractItem.contractId),
+ columns: {
+ projectId: true
+ }
+ });
+
+ return contract?.projectId || null;
+ } catch (error) {
+ console.error("Error fetching projectId:", error);
+ return null;
+ }
+} \ No newline at end of file
diff --git a/lib/tags-plant/table/add-tag-dialog.tsx b/lib/tags-plant/table/add-tag-dialog.tsx
new file mode 100644
index 00000000..9c82bf1a
--- /dev/null
+++ b/lib/tags-plant/table/add-tag-dialog.tsx
@@ -0,0 +1,997 @@
+"use client"
+
+import * as React from "react"
+import { useRouter, useParams } from "next/navigation"
+import { useForm, useWatch, useFieldArray } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { toast } from "sonner"
+import { Loader2, ChevronsUpDown, Check, Plus, Trash2, Copy } from "lucide-react"
+
+import {
+ Dialog,
+ DialogTrigger,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import {
+ Form,
+ FormField,
+ FormItem,
+ FormControl,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import {
+ Popover,
+ PopoverTrigger,
+ PopoverContent,
+} from "@/components/ui/popover"
+import {
+ Command,
+ CommandInput,
+ CommandList,
+ CommandGroup,
+ CommandItem,
+ CommandEmpty,
+} from "@/components/ui/command"
+import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
+import { cn } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { useTranslation } from "@/i18n/client"
+
+import type { CreateTagSchema } from "@/lib/tags/validations"
+import { createTagSchema } from "@/lib/tags/validations"
+import {
+ createTag,
+ getSubfieldsByTagType,
+ getClassOptions,
+ type ClassOption,
+ TagTypeOption,
+} from "@/lib/tags/service"
+import { ScrollArea } from "@/components/ui/scroll-area"
+
+// Updated to support multiple rows and subclass
+interface MultiTagFormValues {
+ class: string;
+ tagType: string;
+ subclass: string; // ์ƒˆ๋กœ ์ถ”๊ฐ€๋œ ์„œ๋ธŒํด๋ž˜์Šค ํ•„๋“œ
+ rows: Array<{
+ [key: string]: string;
+ tagNo: string;
+ description: string;
+ }>;
+}
+
+// SubFieldDef for clarity
+interface SubFieldDef {
+ name: string
+ label: string
+ type: "select" | "text"
+ options?: { value: string; label: string }[]
+ expression?: string
+ delimiter?: string
+}
+
+// ์—…๋ฐ์ดํŠธ๋œ ํด๋ž˜์Šค ์˜ต์…˜ ์ธํ„ฐํŽ˜์ด์Šค (์„œ๋ธŒํด๋ž˜์Šค ์ •๋ณด ํฌํ•จ)
+interface UpdatedClassOption extends ClassOption {
+ tagTypeCode: string
+ tagTypeDescription?: string
+ subclasses: {
+ id: string;
+ desc: string;
+ }[] // ์„œ๋ธŒํด๋ž˜์Šค ๋ฐฐ์—ด ์ถ”๊ฐ€
+ subclassRemark: Record<string, string> // ์„œ๋ธŒํด๋ž˜์Šค ๋ฆฌ๋งˆํฌ ์ถ”๊ฐ€
+}
+
+interface AddTagDialogProps {
+ selectedPackageId: number
+}
+
+export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) {
+ const router = useRouter()
+ const params = useParams()
+ const lng = (params?.lng as string) || "ko"
+ const { t } = useTranslation(lng, "engineering")
+
+ const [open, setOpen] = React.useState(false)
+ const [tagTypeList, setTagTypeList] = React.useState<TagTypeOption[]>([])
+ const [selectedTagTypeCode, setSelectedTagTypeCode] = React.useState<string | null>(null)
+ const [selectedClassOption, setSelectedClassOption] = React.useState<UpdatedClassOption | null>(null)
+ const [selectedSubclass, setSelectedSubclass] = React.useState<string>("")
+ const [subFields, setSubFields] = React.useState<SubFieldDef[]>([])
+ const [classOptions, setClassOptions] = React.useState<UpdatedClassOption[]>([])
+ const [classSearchTerm, setClassSearchTerm] = React.useState("")
+ const [isLoadingClasses, setIsLoadingClasses] = React.useState(false)
+ const [isLoadingSubFields, setIsLoadingSubFields] = React.useState(false)
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+
+ // ID management
+ const selectIdRef = React.useRef(0)
+ const getUniqueSelectId = React.useCallback(() => `select-${selectIdRef.current++}`, [])
+ const fieldIdsRef = React.useRef<Record<string, string>>({})
+ const classOptionIdsRef = React.useRef<Record<string, string>>({})
+
+ console.log(selectedPackageId, "tag")
+
+ // ---------------
+ // Load Class Options (์„œ๋ธŒํด๋ž˜์Šค ์ •๋ณด ํฌํ•จ)
+ // ---------------
+ React.useEffect(() => {
+ const loadClassOptions = async () => {
+ setIsLoadingClasses(true)
+ try {
+ // getClassOptions ํ•จ์ˆ˜๊ฐ€ ์„œ๋ธŒํด๋ž˜์Šค ์ •๋ณด๋„ ํฌํ•จํ•˜๋„๋ก ์ˆ˜์ •๋˜์—ˆ๋‹ค๊ณ  ๊ฐ€์ •
+ const result = await getClassOptions(selectedPackageId)
+ setClassOptions(result)
+ } catch (err) {
+ toast.error(t("toast.classOptionsLoadFailed"))
+ } finally {
+ setIsLoadingClasses(false)
+ }
+ }
+
+ if (open) {
+ loadClassOptions()
+ }
+ }, [open, selectedPackageId])
+
+ // ---------------
+ // react-hook-form with fieldArray support for multiple rows
+ // ---------------
+ const form = useForm<MultiTagFormValues>({
+ defaultValues: {
+ tagType: "",
+ class: "",
+ subclass: "", // ์„œ๋ธŒํด๋ž˜์Šค ํ•„๋“œ ์ถ”๊ฐ€
+ rows: [{
+ tagNo: "",
+ description: ""
+ }]
+ },
+ })
+
+ const { fields, append, remove } = useFieldArray({
+ control: form.control,
+ name: "rows"
+ })
+
+ // ---------------
+ // ์„œ๋ธŒํด๋ž˜์Šค๋ณ„๋กœ ํ•„ํ„ฐ๋ง๋œ ์„œ๋ธŒํ•„๋“œ ๋กœ๋“œ
+ // ---------------
+ async function loadFilteredSubFieldsByTagTypeCode(tagTypeCode: string, subclassRemark: string, subclass: string) {
+ setIsLoadingSubFields(true)
+ try {
+ // ์ˆ˜์ •๋œ getSubfieldsByTagType ํ•จ์ˆ˜ ํ˜ธ์ถœ (subclassRemark ํŒŒ๋ผ๋ฏธํ„ฐ ์ถ”๊ฐ€)
+ const { subFields: apiSubFields } = await getSubfieldsByTagType(tagTypeCode, selectedPackageId, subclassRemark, subclass)
+ const formattedSubFields: SubFieldDef[] = apiSubFields.map(field => ({
+ name: field.name,
+ label: field.label,
+ type: field.type,
+ options: field.options || [],
+ expression: field.expression ?? undefined,
+ delimiter: field.delimiter ?? undefined,
+ }))
+ setSubFields(formattedSubFields)
+
+ // Initialize the rows with these subfields
+ const currentRows = form.getValues("rows");
+ const updatedRows = currentRows.map(row => {
+ const newRow = { ...row };
+ formattedSubFields.forEach(field => {
+ if (!newRow[field.name]) {
+ newRow[field.name] = "";
+ }
+ });
+ return newRow;
+ });
+
+ form.setValue("rows", updatedRows);
+ return true
+ } catch (err) {
+ toast.error(t("toast.subfieldsLoadFailed"))
+ setSubFields([])
+ return false
+ } finally {
+ setIsLoadingSubFields(false)
+ }
+ }
+
+ // ---------------
+ // Handle class selection
+ // ---------------
+ async function handleSelectClass(classOption: UpdatedClassOption) {
+ form.setValue("class", classOption.label)
+ form.setValue("subclass", "") // ์„œ๋ธŒํด๋ž˜์Šค ์ดˆ๊ธฐํ™”
+ setSelectedClassOption(classOption)
+ setSelectedSubclass("")
+
+ if (classOption.tagTypeCode) {
+ setSelectedTagTypeCode(classOption.tagTypeCode)
+ // If you have tagTypeList, you can find the label
+ const tagType = tagTypeList.find(t => t.id === classOption.tagTypeCode)
+ if (tagType) {
+ form.setValue("tagType", tagType.label)
+ } else if (classOption.tagTypeDescription) {
+ form.setValue("tagType", classOption.tagTypeDescription)
+ }
+
+ // ์„œ๋ธŒํด๋ž˜์Šค๊ฐ€ ์žˆ์œผ๋ฉด ์„œ๋ธŒํ•„๋“œ ๋กœ๋”ฉ์„ ํ•˜์ง€ ์•Š๊ณ  ๋Œ€๊ธฐ
+ if (classOption.subclasses && classOption.subclasses.length > 0) {
+ setSubFields([]) // ์„œ๋ธŒํด๋ž˜์Šค ์„ ํƒ์„ ๊ธฐ๋‹ค๋ฆผ
+ } else {
+ // ์„œ๋ธŒํด๋ž˜์Šค๊ฐ€ ์—†์œผ๋ฉด ๋ฐ”๋กœ ์„œ๋ธŒํ•„๋“œ ๋กœ๋”ฉ
+ await loadFilteredSubFieldsByTagTypeCode(classOption.tagTypeCode, "", "")
+ }
+ }
+ }
+
+ // ---------------
+ // Handle subclass selection
+ // ---------------
+ async function handleSelectSubclass(subclassCode: string) {
+ if (!selectedClassOption || !selectedTagTypeCode) return
+
+ setSelectedSubclass(subclassCode)
+ form.setValue("subclass", subclassCode)
+
+ // ์„ ํƒ๋œ ์„œ๋ธŒํด๋ž˜์Šค์˜ ๋ฆฌ๋งˆํฌ ๊ฐ’ ๊ฐ€์ ธ์˜ค๊ธฐ
+ const subclassRemarkValue = selectedClassOption.subclassRemark[subclassCode] || ""
+
+ // ๋ฆฌ๋งˆํฌ ๊ฐ’์œผ๋กœ ํ•„ํ„ฐ๋ง๋œ ์„œ๋ธŒํ•„๋“œ ๋กœ๋“œ
+ await loadFilteredSubFieldsByTagTypeCode(selectedTagTypeCode, subclassRemarkValue, subclassCode)
+ }
+
+ // ---------------
+ // Build TagNo from subfields automatically for each row
+ // ---------------
+ React.useEffect(() => {
+ if (subFields.length === 0) {
+ return;
+ }
+
+ const subscription = form.watch((value) => {
+ if (!value.rows || subFields.length === 0) {
+ return;
+ }
+
+ const rows = [...value.rows];
+ rows.forEach((row, rowIndex) => {
+ if (!row) return;
+
+ let combined = "";
+ subFields.forEach((sf, idx) => {
+ const fieldValue = row[sf.name] || "";
+
+ // delimiter๋ฅผ ์•ž์— ๋ถ™์ด๊ธฐ (์ฒซ ๋ฒˆ์งธ ํ•„๋“œ๊ฐ€ ์•„๋‹ˆ๊ณ , ํ˜„์žฌ ํ•„๋“œ์— ๊ฐ’์ด ์žˆ๊ณ , delimiter๊ฐ€ ์žˆ๋Š” ๊ฒฝ์šฐ)
+ if (idx > 0 && fieldValue && sf.delimiter) {
+ combined += sf.delimiter;
+ }
+
+ combined += fieldValue;
+ });
+
+ const currentTagNo = form.getValues(`rows.${rowIndex}.tagNo`);
+ if (currentTagNo !== combined) {
+ form.setValue(`rows.${rowIndex}.tagNo`, combined, {
+ shouldDirty: true,
+ shouldTouch: true,
+ shouldValidate: true,
+ });
+ }
+ });
+ });
+
+ return () => subscription.unsubscribe();
+ }, [subFields, form]);
+
+ // ---------------
+ // Check if tag numbers are valid
+ // ---------------
+ const areAllTagNosValid = React.useMemo(() => {
+ const rows = form.getValues("rows");
+ return rows.every(row => {
+ const tagNo = row.tagNo;
+ return tagNo && tagNo.trim() !== "" && !tagNo.includes("??");
+ });
+ }, [form.watch()]);
+
+ // ---------------
+ // Submit handler for multiple tags (์„œ๋ธŒํด๋ž˜์Šค ์ •๋ณด ํฌํ•จ)
+ // ---------------
+ async function onSubmit(data: MultiTagFormValues) {
+ if (!selectedPackageId) {
+ toast.error(t("toast.noSelectedPackageId"));
+ return;
+ }
+
+ setIsSubmitting(true);
+ try {
+ const successfulTags = [];
+ const failedTags = [];
+
+ // Process each row
+ for (const row of data.rows) {
+ // Create tag data from the row and shared class/tagType/subclass
+ const tagData: CreateTagSchema = {
+ tagType: data.tagType,
+ class: data.class,
+ // subclass: data.subclass, // ์„œ๋ธŒํด๋ž˜์Šค ์ •๋ณด ์ถ”๊ฐ€
+ tagNo: row.tagNo,
+ description: row.description,
+ ...Object.fromEntries(
+ subFields.map(field => [field.name, row[field.name] || ""])
+ ),
+ // Add any required default fields from the original form
+ functionCode: row.functionCode || "",
+ seqNumber: row.seqNumber || "",
+ valveAcronym: row.valveAcronym || "",
+ processUnit: row.processUnit || "",
+ };
+
+ try {
+ const res = await createTag(tagData, selectedPackageId);
+ if ("error" in res) {
+ console.log(res.error)
+ failedTags.push({ tag: row.tagNo, error: res.error });
+ } else {
+ successfulTags.push(row.tagNo);
+ }
+ } catch (err) {
+ failedTags.push({ tag: row.tagNo, error: "Unknown error" });
+ }
+ }
+
+ // Show results to the user
+ if (successfulTags.length > 0) {
+ toast.success(`${successfulTags.length}${t("toast.tagsCreatedSuccess")}`);
+ }
+
+ if (failedTags.length > 0) {
+ console.log("Failed tags:", failedTags);
+ toast.error(`${failedTags.length}${t("toast.tagsCreateFailed")}`);
+ }
+
+ // Refresh the page
+ router.refresh();
+
+ // Reset the form and close dialog if all successful
+ if (failedTags.length === 0) {
+ form.reset();
+ setOpen(false);
+ }
+ } catch (err) {
+ toast.error(t("toast.tagProcessingFailed"));
+ } finally {
+ setIsSubmitting(false);
+ }
+ }
+
+ // ---------------
+ // Add a new row
+ // ---------------
+ function addRow() {
+ const newRow: {
+ tagNo: string;
+ description: string;
+ [key: string]: string;
+ } = {
+ tagNo: "",
+ description: ""
+ };
+
+ // Add all subfields with empty values
+ subFields.forEach(field => {
+ newRow[field.name] = "";
+ });
+
+ append(newRow);
+ setTimeout(() => form.trigger(), 0);
+ }
+
+ // ---------------
+ // Duplicate row
+ // ---------------
+ function duplicateRow(index: number) {
+ const rowToDuplicate = form.getValues(`rows.${index}`);
+ const newRow: {
+ tagNo: string;
+ description: string;
+ [key: string]: string;
+ } = { ...rowToDuplicate };
+
+ newRow.tagNo = "";
+ append(newRow);
+ setTimeout(() => form.trigger(), 0);
+ }
+
+ // ---------------
+ // Render Class field
+ // ---------------
+ function renderClassField(field: any) {
+ const [popoverOpen, setPopoverOpen] = React.useState(false)
+
+ const buttonId = React.useMemo(
+ () => `class-button-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
+ []
+ )
+ const popoverContentId = React.useMemo(
+ () => `class-popover-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
+ []
+ )
+ const commandId = React.useMemo(
+ () => `class-command-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
+ []
+ )
+
+ return (
+ <FormItem className="w-1/3">
+ <FormLabel>{t("labels.class")}</FormLabel>
+ <FormControl>
+ <Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ key={buttonId}
+ type="button"
+ variant="outline"
+ className="w-full justify-between relative h-9"
+ disabled={isLoadingClasses}
+ >
+ {isLoadingClasses ? (
+ <>
+ <span>{t("messages.loadingClasses")}</span>
+ <Loader2 className="ml-2 h-4 w-4 animate-spin" />
+ </>
+ ) : (
+ <>
+ <span className="truncate mr-1 flex-grow text-left">
+ {field.value || t("placeholders.selectClass")}
+ </span>
+ <ChevronsUpDown className="h-4 w-4 opacity-50 flex-shrink-0" />
+ </>
+ )}
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent key={popoverContentId} className="w-[300px] p-0" style={{width:480}}>
+ <Command key={commandId}>
+ <CommandInput
+ key={`${commandId}-input`}
+ placeholder={t("placeholders.searchClass")}
+ value={classSearchTerm}
+ onValueChange={setClassSearchTerm}
+ />
+
+ <CommandList key={`${commandId}-list`} className="max-h-[300px]" onWheel={(e) => {
+ e.stopPropagation(); // ์ด๋ฒคํŠธ ์ „ํŒŒ ์ฐจ๋‹จ
+ const target = e.currentTarget;
+ target.scrollTop += e.deltaY; // ์ง์ ‘ ์Šคํฌ๋กค ์ฒ˜๋ฆฌ
+ }}>
+ <CommandEmpty key={`${commandId}-empty`}>{t("messages.noSearchResults")}</CommandEmpty>
+ <CommandGroup key={`${commandId}-group`}>
+ {classOptions.map((opt, optIndex) => {
+ if (!classOptionIdsRef.current[opt.code]) {
+ classOptionIdsRef.current[opt.code] =
+ `class-${opt.code}-${Date.now()}-${Math.random()
+ .toString(36)
+ .slice(2, 9)}`
+ }
+ const optionId = classOptionIdsRef.current[opt.code]
+
+ return (
+ <CommandItem
+ key={`${optionId}-${optIndex}`}
+ onSelect={() => {
+ field.onChange(opt.label)
+ setPopoverOpen(false)
+ handleSelectClass(opt)
+ }}
+ value={opt.label}
+ className="truncate"
+ title={opt.label}
+ >
+ <span className="truncate">{opt.label}</span>
+ <Check
+ key={`${optionId}-check`}
+ className={cn(
+ "ml-auto h-4 w-4 flex-shrink-0",
+ field.value === opt.label ? "opacity-100" : "opacity-0"
+ )}
+ />
+ </CommandItem>
+ )
+ })}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )
+ }
+
+ // ---------------
+ // Render Subclass field (์ƒˆ๋กœ ์ถ”๊ฐ€)
+ // ---------------
+ function renderSubclassField(field: any) {
+ const hasSubclasses = selectedClassOption?.subclasses && selectedClassOption.subclasses.length > 0
+
+ if (!hasSubclasses) {
+ return null
+ }
+
+ return (
+ <FormItem className="w-1/3">
+ <FormLabel>Item Class</FormLabel>
+ <FormControl>
+ <Select
+ value={field.value || ""}
+ onValueChange={(value) => {
+ field.onChange(value)
+ handleSelectSubclass(value)
+ }}
+ disabled={!selectedClassOption}
+ >
+ <SelectTrigger className="h-9">
+ <SelectValue placeholder={t("placeholders.selectSubclass")} />
+ </SelectTrigger>
+ <SelectContent>
+ {selectedClassOption?.subclasses.map((subclass) => (
+ <SelectItem key={subclass.id} value={subclass.id}>
+ {subclass.desc}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )
+ }
+
+ // ---------------
+ // Render TagType field (readonly after class selection)
+ // ---------------
+ function renderTagTypeField(field: any) {
+ const isReadOnly = !!selectedTagTypeCode
+ const inputId = React.useMemo(
+ () =>
+ `tag-type-input-${isReadOnly ? "readonly" : "editable"}-${Date.now()}-${Math.random()
+ .toString(36)
+ .slice(2, 9)}`,
+ [isReadOnly]
+ )
+
+ const width = selectedClassOption?.subclasses && selectedClassOption.subclasses.length > 0 ? "w-1/3" : "w-2/3"
+
+ return (
+ <FormItem className={width}>
+ <FormLabel>{t("labels.tagType")}</FormLabel>
+ <FormControl>
+ {isReadOnly ? (
+ <div className="relative">
+ <Input
+ key={`tag-type-readonly-${inputId}`}
+ {...field}
+ readOnly
+ className="h-9 bg-muted"
+ />
+ </div>
+ ) : (
+ <Input
+ key={`tag-type-placeholder-${inputId}`}
+ {...field}
+ readOnly
+ placeholder={t("placeholders.autoSetByClass")}
+ className="h-9 bg-muted"
+ />
+ )}
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )
+ }
+
+ // ---------------
+ // Render the table of subfields
+ // ---------------
+ function renderTagTable() {
+ if (isLoadingSubFields) {
+ return (
+ <div className="flex justify-center items-center py-8">
+ <Loader2 className="h-8 w-8 animate-spin text-primary" />
+ <div className="ml-3 text-muted-foreground">{t("messages.loadingFields")}</div>
+ </div>
+ )
+ }
+
+ if (subFields.length === 0 && selectedTagTypeCode) {
+ const message = selectedClassOption?.subclasses && selectedClassOption.subclasses.length > 0
+ ? t("messages.selectSubclassFirst")
+ : t("messages.noFieldsForTagType")
+
+ return (
+ <div className="py-4 text-center text-muted-foreground">
+ {message}
+ </div>
+ )
+ }
+
+ if (subFields.length === 0) {
+ return (
+ <div className="py-4 text-center text-muted-foreground">
+ {t("messages.selectClassFirst")}
+ </div>
+ )
+ }
+
+ return (
+ <div className="space-y-4">
+ {/* ํ—ค๋” */}
+ <div className="flex justify-between items-center">
+ <h3 className="text-sm font-medium">{t("sections.tagItems")} ({fields.length}๊ฐœ)</h3>
+ {!areAllTagNosValid && (
+ <Badge variant="destructive" className="ml-2">
+ {t("messages.invalidTagsExist")}
+ </Badge>
+ )}
+ </div>
+
+ {/* ํ…Œ์ด๋ธ” ์ปจํ…Œ์ด๋„ˆ - ๊ฐ€๋กœ/์„ธ๋กœ ์Šคํฌ๋กค ๋ชจ๋‘ ์ ์šฉ */}
+ <div className="border rounded-md overflow-auto" style={{ maxHeight: '400px', maxWidth: '100%' }}>
+ <div className="min-w-full overflow-x-auto">
+ <Table className="w-full table-fixed">
+ <TableHeader className="sticky top-0 bg-muted z-10">
+ <TableRow>
+ <TableHead className="w-10 text-center">#</TableHead>
+ <TableHead className="w-[120px]">
+ <div className="font-medium">{t("labels.tagNo")}</div>
+ </TableHead>
+ <TableHead className="w-[180px]">
+ <div className="font-medium">{t("labels.description")}</div>
+ </TableHead>
+
+ {/* Subfields */}
+ {subFields.map((field, fieldIndex) => (
+ <TableHead
+ key={`header-${field.name}-${fieldIndex}`}
+ className="w-[120px]"
+ >
+ <div className="flex flex-col">
+ <div className="font-medium" title={field.label}>
+ {field.label}
+ </div>
+ {field.expression && (
+ <div className="text-[10px] text-muted-foreground truncate" title={field.expression}>
+ {field.expression}
+ </div>
+ )}
+ </div>
+ </TableHead>
+ ))}
+
+ <TableHead className="w-[100px] text-center sticky right-0 bg-muted">{t("labels.actions")}</TableHead>
+ </TableRow>
+ </TableHeader>
+
+ <TableBody>
+ {fields.map((item, rowIndex) => (
+ <TableRow
+ key={`row-${item.id}-${rowIndex}`}
+ className={rowIndex % 2 === 0 ? "bg-background" : "bg-muted/20"}
+ >
+ {/* Row number */}
+ <TableCell className="text-center text-muted-foreground font-mono">
+ {rowIndex + 1}
+ </TableCell>
+
+ {/* Tag No cell */}
+ <TableCell className="p-1">
+ <FormField
+ control={form.control}
+ name={`rows.${rowIndex}.tagNo`}
+ render={({ field }) => (
+ <FormItem className="m-0 space-y-0">
+ <FormControl>
+ <div className="relative">
+ <Input
+ {...field}
+ readOnly
+ className={cn(
+ "bg-muted h-8 w-full font-mono text-sm",
+ field.value?.includes("??") && "border-red-500 bg-red-50"
+ )}
+ title={field.value || ""}
+ />
+ {field.value?.includes("??") && (
+ <div className="absolute right-2 top-1/2 transform -translate-y-1/2">
+ <Badge variant="destructive" className="text-xs">
+ !
+ </Badge>
+ </div>
+ )}
+ </div>
+ </FormControl>
+ </FormItem>
+ )}
+ />
+ </TableCell>
+
+ {/* Description cell */}
+ <TableCell className="p-1">
+ <FormField
+ control={form.control}
+ name={`rows.${rowIndex}.description`}
+ render={({ field }) => (
+ <FormItem className="m-0 space-y-0">
+ <FormControl>
+ <Input
+ {...field}
+ className="h-8 w-full"
+ placeholder={t("placeholders.enterDescription")}
+ title={field.value || ""}
+ />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+ </TableCell>
+
+ {/* Subfield cells */}
+ {subFields.map((sf, sfIndex) => (
+ <TableCell
+ key={`cell-${item.id}-${rowIndex}-${sf.name}-${sfIndex}`}
+ className="p-1"
+ >
+ <FormField
+ control={form.control}
+ name={`rows.${rowIndex}.${sf.name}`}
+ render={({ field }) => (
+ <FormItem className="m-0 space-y-0">
+ <FormControl>
+ {sf.type === "select" ? (
+ <Select
+ value={field.value || ""}
+ onValueChange={field.onChange}
+ >
+ <SelectTrigger
+ className="w-full h-8 truncate"
+ title={field.value || ""}
+ >
+ <SelectValue placeholder={t("placeholders.selectOption")} className="truncate" />
+ </SelectTrigger>
+ <SelectContent
+ align="start"
+ side="bottom"
+ className="max-h-[200px]"
+ style={{ minWidth: "250px", maxWidth: "350px" }}
+ >
+ {sf.options?.map((opt, index) => (
+ <SelectItem
+ key={`${rowIndex}-${sf.name}-${opt.value}-${index}`}
+ value={opt.value}
+ title={opt.label}
+ className="whitespace-normal py-2 break-words"
+ >
+ {opt.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ ) : (
+ <Input
+ {...field}
+ className="h-8 w-full"
+ placeholder={t("placeholders.enterValue")}
+ title={field.value || ""}
+ />
+ )}
+ </FormControl>
+ </FormItem>
+ )}
+ />
+ </TableCell>
+ ))}
+
+ {/* Actions cell */}
+ <TableCell className="p-1 sticky right-0 bg-white shadow-[-4px_0_4px_rgba(0,0,0,0.05)]">
+ <div className="flex justify-center space-x-1">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="h-7 w-7"
+ onClick={() => duplicateRow(rowIndex)}
+ >
+ <Copy className="h-3.5 w-3.5 text-muted-foreground" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent side="left">
+ <p>{t("tooltips.duplicateRow")}</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className={cn(
+ "h-7 w-7",
+ fields.length <= 1 && "opacity-50"
+ )}
+ onClick={() => fields.length > 1 && remove(rowIndex)}
+ disabled={fields.length <= 1}
+ >
+ <Trash2 className="h-3.5 w-3.5 text-red-500" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent side="left">
+ <p>{t("tooltips.deleteRow")}</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </div>
+
+ {/* ํ–‰ ์ถ”๊ฐ€ ๋ฒ„ํŠผ */}
+ <Button
+ type="button"
+ variant="outline"
+ className="w-full border-dashed"
+ onClick={addRow}
+ disabled={!selectedTagTypeCode || isLoadingSubFields || subFields.length === 0}
+ >
+ <Plus className="h-4 w-4 mr-2" />
+ {t("buttons.addRow")}
+ </Button>
+ </div>
+ </div>
+ );
+ }
+
+ // ---------------
+ // Reset IDs/states when dialog closes
+ // ---------------
+ React.useEffect(() => {
+ if (!open) {
+ fieldIdsRef.current = {}
+ classOptionIdsRef.current = {}
+ selectIdRef.current = 0
+ }
+ }, [open])
+
+ return (
+ <Dialog
+ open={open}
+ onOpenChange={(o) => {
+ if (!o) {
+ form.reset({
+ tagType: "",
+ class: "",
+ subclass: "",
+ rows: [{ tagNo: "", description: "" }]
+ });
+ setSelectedTagTypeCode(null);
+ setSelectedClassOption(null);
+ setSelectedSubclass("");
+ setSubFields([]);
+ }
+ setOpen(o);
+ }}
+ >
+ <DialogTrigger asChild>
+ <Button variant="default" size="sm">
+ {t("buttons.addTags")}
+ </Button>
+ </DialogTrigger>
+
+ <DialogContent className="max-h-[90vh] max-w-[95vw]" style={{ width: 1500 }}>
+ <DialogHeader>
+ <DialogTitle>{t("dialogs.addTag")}</DialogTitle>
+ <DialogDescription>
+ {t("dialogs.selectClassToLoadFields")}
+ </DialogDescription>
+ </DialogHeader>
+
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="space-y-6"
+ >
+ {/* ํด๋ž˜์Šค, ์„œ๋ธŒํด๋ž˜์Šค, ํƒœ๊ทธ ์œ ํ˜• ์„ ํƒ */}
+ <div className="flex gap-4">
+ <FormField
+ key="class-field"
+ control={form.control}
+ name="class"
+ render={({ field }) => renderClassField(field)}
+ />
+
+ <FormField
+ key="subclass-field"
+ control={form.control}
+ name="subclass"
+ render={({ field }) => renderSubclassField(field)}
+ />
+
+ <FormField
+ key="tag-type-field"
+ control={form.control}
+ name="tagType"
+ render={({ field }) => renderTagTypeField(field)}
+ />
+ </div>
+
+ {/* ํƒœ๊ทธ ํ…Œ์ด๋ธ” */}
+ {renderTagTable()}
+
+ {/* ๋ฒ„ํŠผ */}
+ <DialogFooter>
+ <div className="flex items-center gap-2">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => {
+ form.reset({
+ tagType: "",
+ class: "",
+ subclass: "",
+ rows: [{ tagNo: "", description: "" }]
+ });
+ setOpen(false);
+ setSubFields([]);
+ setSelectedTagTypeCode(null);
+ setSelectedClassOption(null);
+ setSelectedSubclass("");
+ }}
+ disabled={isSubmitting}
+ >
+ {t("buttons.cancel")}
+ </Button>
+ <Button
+ type="submit"
+ disabled={isSubmitting || !areAllTagNosValid || fields.length < 1}
+ >
+ {isSubmitting ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ {t("messages.processing")}
+ </>
+ ) : (
+ `${fields.length}${t("buttons.createTags")}`
+ )}
+ </Button>
+ </div>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/tags-plant/table/delete-tags-dialog.tsx b/lib/tags-plant/table/delete-tags-dialog.tsx
new file mode 100644
index 00000000..6a024cda
--- /dev/null
+++ b/lib/tags-plant/table/delete-tags-dialog.tsx
@@ -0,0 +1,151 @@
+"use client"
+
+import * as React from "react"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Trash } from "lucide-react"
+import { toast } from "sonner"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+
+import { removeTags } from "@/lib//tags/service"
+import { Tag } from "@/db/schema/vendorData"
+
+interface DeleteTasksDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ tags: Row<Tag>["original"][]
+ showTrigger?: boolean
+ selectedPackageId: number
+ onSuccess?: () => void
+}
+
+export function DeleteTagsDialog({
+ tags,
+ showTrigger = true,
+ onSuccess,
+ selectedPackageId,
+ ...props
+}: DeleteTasksDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onDelete() {
+ startDeleteTransition(async () => {
+ const { error } = await removeTags({
+ ids: tags.map((tag) => tag.id),selectedPackageId
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("Tasks deleted")
+ onSuccess?.()
+ })
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="size-4" aria-hidden="true" />
+ Delete ({tags.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Are you absolutely sure?</DialogTitle>
+ <DialogDescription>
+ This action cannot be undone. This will permanently delete your{" "}
+ <span className="font-medium">{tags.length}</span>
+ {tags.length === 1 ? " tag" : " tags"} from our servers.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DialogClose>
+ <Button
+ aria-label="Delete selected rows"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Delete
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ Delete ({tags.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>Are you absolutely sure?</DrawerTitle>
+ <DrawerDescription>
+ This action cannot be undone. This will permanently delete your{" "}
+ <span className="font-medium">{tags.length}</span>
+ {tags.length === 1 ? " tag" : " tags"} from our servers.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DrawerClose>
+ <Button
+ aria-label="Delete selected rows"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ Delete
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+}
diff --git a/lib/tags-plant/table/feature-flags-provider.tsx b/lib/tags-plant/table/feature-flags-provider.tsx
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/tags-plant/table/feature-flags-provider.tsx
@@ -0,0 +1,108 @@
+"use client"
+
+import * as React from "react"
+import { useQueryState } from "nuqs"
+
+import { dataTableConfig, type DataTableConfig } from "@/config/data-table"
+import { cn } from "@/lib/utils"
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface FeatureFlagsContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useFeatureFlags() {
+ const context = React.useContext(FeatureFlagsContext)
+ if (!context) {
+ throw new Error(
+ "useFeatureFlags must be used within a FeatureFlagsProvider"
+ )
+ }
+ return context
+}
+
+interface FeatureFlagsProviderProps {
+ children: React.ReactNode
+}
+
+export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "flags",
+ {
+ defaultValue: [],
+ parse: (value) => value.split(",") as FeatureFlagValue[],
+ serialize: (value) => value.join(","),
+ eq: (a, b) =>
+ a.length === b.length && a.every((value, index) => value === b[index]),
+ clearOnDefault: true,
+ shallow: false,
+ }
+ )
+
+ return (
+ <FeatureFlagsContext.Provider
+ value={{
+ featureFlags,
+ setFeatureFlags: (value) => void setFeatureFlags(value),
+ }}
+ >
+ <div className="w-full overflow-x-auto">
+ <ToggleGroup
+ type="multiple"
+ variant="outline"
+ size="sm"
+ value={featureFlags}
+ onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
+ className="w-fit gap-0"
+ >
+ {dataTableConfig.featureFlags.map((flag, index) => (
+ <Tooltip key={flag.value}>
+ <ToggleGroupItem
+ value={flag.value}
+ className={cn(
+ "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90",
+ {
+ "rounded-l-sm border-r-0": index === 0,
+ "rounded-r-sm":
+ index === dataTableConfig.featureFlags.length - 1,
+ }
+ )}
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
+ {flag.label}
+ </TooltipTrigger>
+ </ToggleGroupItem>
+ <TooltipContent
+ align="start"
+ side="bottom"
+ sideOffset={6}
+ className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
+ >
+ <div>{flag.tooltipTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ {flag.tooltipDescription}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </ToggleGroup>
+ </div>
+ {children}
+ </FeatureFlagsContext.Provider>
+ )
+}
diff --git a/lib/tags-plant/table/tag-table-column.tsx b/lib/tags-plant/table/tag-table-column.tsx
new file mode 100644
index 00000000..80c25464
--- /dev/null
+++ b/lib/tags-plant/table/tag-table-column.tsx
@@ -0,0 +1,164 @@
+"use client"
+
+import * as React from "react"
+import { ColumnDef } from "@tanstack/react-table"
+import { formatDate } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { Ellipsis } from "lucide-react"
+// ๊ธฐ์กด ํ—ค๋” ์ปดํฌ๋„ŒํŠธ ์‚ฌ์šฉ (๋ฆฌ์‚ฌ์ด์ €๊ฐ€ ๋‚ด์žฅ๋œ ํ—ค๋”๋Š” ๋”ฐ๋กœ ๊ตฌํ˜„ํ•  ์˜ˆ์ •)
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { Tag } from "@/db/schema/vendorData"
+import { DataTableRowAction } from "@/types/table"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<Tag> | null>>
+}
+
+export function getColumns({
+ setRowAction,
+}: GetColumnsProps): ColumnDef<Tag>[] {
+ return [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ enableResizing: false, // ์ฒดํฌ๋ฐ•์Šค ์—ด์€ ๋ฆฌ์‚ฌ์ด์ง• ๋น„ํ™œ์„ฑํ™”
+ size: 40,
+ minSize: 40,
+ maxSize: 40,
+ },
+
+ {
+ accessorKey: "tagNo",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Tag No." />
+ ),
+ cell: ({ row }) => <div>{row.getValue("tagNo")}</div>,
+ meta: {
+ excelHeader: "Tag No"
+ },
+ enableResizing: true, // ๋ฆฌ์‚ฌ์ด์ง• ํ™œ์„ฑํ™”
+ minSize: 100, // ์ตœ์†Œ ๋„ˆ๋น„
+ size: 160, // ๊ธฐ๋ณธ ๋„ˆ๋น„
+ },
+ {
+ accessorKey: "description",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Tag Description" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("description")}</div>,
+ meta: {
+ excelHeader: "Tag Descripiton"
+ },
+ enableResizing: true,
+ minSize: 150,
+ size: 240,
+ },
+ {
+ accessorKey: "class",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Tag Class" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("class")}</div>,
+ meta: {
+ excelHeader: "Tag Class"
+ },
+ enableResizing: true,
+ minSize: 100,
+ size: 150,
+ },
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Created At" />
+ ),
+ cell: ({ cell }) => formatDate(cell.getValue() as Date, "KR"),
+ meta: {
+ excelHeader: "created At"
+ },
+ enableResizing: true,
+ minSize: 120,
+ size: 180,
+ },
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Updated At" />
+ ),
+ cell: ({ cell }) => formatDate(cell.getValue() as Date, "KR"),
+ meta: {
+ excelHeader: "updated At"
+ },
+ enableResizing: true,
+ minSize: 120,
+ size: 180,
+ },
+ {
+ id: "actions",
+ cell: function Cell({ row }) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-6" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "update" })}
+ >
+ Edit
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "delete" })}
+ >
+ Delete
+ <DropdownMenuShortcut>โŒ˜โŒซ</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ enableResizing: false, // ์•ก์…˜ ์—ด์€ ๋ฆฌ์‚ฌ์ด์ง• ๋น„ํ™œ์„ฑํ™”
+ size: 40,
+ minSize: 40,
+ maxSize: 40,
+ enableHiding: false,
+ },
+ ]
+} \ No newline at end of file
diff --git a/lib/tags-plant/table/tag-table.tsx b/lib/tags-plant/table/tag-table.tsx
new file mode 100644
index 00000000..1986d933
--- /dev/null
+++ b/lib/tags-plant/table/tag-table.tsx
@@ -0,0 +1,155 @@
+"use client"
+
+import * as React from "react"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+
+import { toSentenceCase } from "@/lib/utils"
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+
+import { getColumns } from "./tag-table-column"
+import { Tag } from "@/db/schema/vendorData"
+import { DeleteTagsDialog } from "./delete-tags-dialog"
+import { TagsTableToolbarActions } from "./tags-table-toolbar-actions"
+import { TagsTableFloatingBar } from "./tags-table-floating-bar"
+import { getTags } from "../service"
+import { UpdateTagSheet } from "./update-tag-sheet"
+import { useAtomValue } from 'jotai'
+import { selectedModeAtom } from '@/atoms'
+
+// ์—ฌ๊ธฐ์„œ ๋ฐ›์€ `promises`๋กœ๋ถ€ํ„ฐ ํƒœ๊ทธ ๋ชฉ๋ก์„ ๊ฐ€์ ธ์™€ ์ƒํƒœ๋ฅผ ์„ธํŒ…
+// ์˜ˆ: "selectedPackageId"๋Š” props๋กœ ์ „๋‹ฌ
+interface TagsTableProps {
+ promises: Promise< [ Awaited<ReturnType<typeof getTags>> ] >
+ selectedPackageId: number
+}
+
+export function TagsTable({ promises, selectedPackageId }: TagsTableProps) {
+ // 1) ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€์ ธ์˜ด (server component -> use(...) pattern)
+ const [{ data, pageCount }] = React.use(promises)
+ const selectedMode = useAtomValue(selectedModeAtom)
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<Tag> | null>(null)
+
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ // Filter fields
+ const filterFields: DataTableFilterField<Tag>[] = [
+ {
+ id: "tagNo",
+ label: "Tag Number",
+ placeholder: "Filter Tag Number...",
+ },
+ ]
+
+ const advancedFilterFields: DataTableAdvancedFilterField<Tag>[] = [
+ {
+ id: "tagNo",
+ label: "Tag No",
+ type: "text",
+ },
+ {
+ id: "tagType",
+ label: "Tag Type",
+ type: "text",
+ },
+ {
+ id: "description",
+ label: "Description",
+ type: "text",
+ },
+ {
+ id: "createdAt",
+ label: "Created at",
+ type: "date",
+ },
+ {
+ id: "updatedAt",
+ label: "Updated at",
+ type: "date",
+ },
+ ]
+
+ // 3) useDataTable ํ›…์œผ๋กœ react-table ๊ตฌ์„ฑ
+ const { table } = useDataTable({
+ data: data, // <-- ์—ฌ๊ธฐ์„œ tableData ์‚ฌ์šฉ
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ // sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ columnResizeMode: "onEnd",
+
+ })
+
+ const [isCompact, setIsCompact] = React.useState<boolean>(false)
+
+
+ const handleCompactChange = React.useCallback((compact: boolean) => {
+ setIsCompact(compact)
+ }, [])
+
+
+ return (
+ <>
+ <DataTable
+ table={table}
+ compact={isCompact}
+
+ floatingBar={<TagsTableFloatingBar table={table} selectedPackageId={selectedPackageId}/>}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ enableCompactToggle={true}
+ compactStorageKey="tagTableCompact"
+ onCompactChange={handleCompactChange}
+ >
+ {/*
+ 4) ToolbarActions์— tableData, setTableData ๋„˜๊ฒจ์„œ
+ import ์‹œ ์ƒํƒœ ๋ณ‘ํ•ฉ
+ */}
+ <TagsTableToolbarActions
+ table={table}
+ selectedPackageId={selectedPackageId}
+ tableData={data} // <-- pass current data
+ selectedMode={selectedMode}
+ />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ <UpdateTagSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ tag={rowAction?.row.original ?? null}
+ selectedPackageId={selectedPackageId}
+ />
+
+
+ <DeleteTagsDialog
+ open={rowAction?.type === "delete"}
+ onOpenChange={() => setRowAction(null)}
+ tags={rowAction?.row.original ? [rowAction?.row.original] : []}
+ showTrigger={false}
+ onSuccess={() => rowAction?.row.toggleSelected(false)}
+ selectedPackageId={selectedPackageId}
+ />
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/tags-plant/table/tags-export.tsx b/lib/tags-plant/table/tags-export.tsx
new file mode 100644
index 00000000..fa85148d
--- /dev/null
+++ b/lib/tags-plant/table/tags-export.tsx
@@ -0,0 +1,158 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { toast } from "sonner"
+import ExcelJS from "exceljs"
+import { saveAs } from "file-saver"
+import { Tag } from "@/db/schema/vendorData"
+import { getClassOptions } from "../service"
+
+/**
+ * ํƒœ๊ทธ ๋ฐ์ดํ„ฐ๋ฅผ ์—‘์…€๋กœ ๋‚ด๋ณด๋‚ด๋Š” ํ•จ์ˆ˜ (์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ํฌํ•จ)
+ * - ๋ณ„๋„์˜ ValidationData ์‹œํŠธ์— Tag Class ์˜ต์…˜ ๋ฐ์ดํ„ฐ๋ฅผ ํฌํ•จ
+ * - Tag Class ์—ด์— ๋ฐ์ดํ„ฐ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ(๋“œ๋กญ๋‹ค์šด)์„ ์ ์šฉ
+ */
+export async function exportTagsToExcel(
+ table: Table<Tag>,
+ selectedPackageId: number,
+ {
+ filename = "Tags",
+ excludeColumns = ["select", "actions", "createdAt", "updatedAt"],
+ maxRows = 5000, // ๋ฐ์ดํ„ฐ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ๋ฅผ ์ ์šฉํ•  ์ตœ๋Œ€ ํ–‰ ์ˆ˜
+ }: {
+ filename?: string
+ excludeColumns?: string[]
+ maxRows?: number
+ } = {}
+) {
+ try {
+
+
+ // 1. ํ…Œ์ด๋ธ”์—์„œ ์ปฌ๋Ÿผ ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ
+ const allTableColumns = table.getAllLeafColumns()
+
+ // ์ œ์™ธํ•  ์ปฌ๋Ÿผ ํ•„ํ„ฐ๋ง
+ const tableColumns = allTableColumns.filter(
+ (col) => !excludeColumns.includes(col.id)
+ )
+
+ // 2. ์›Œํฌ๋ถ ๋ฐ ์›Œํฌ์‹œํŠธ ์ƒ์„ฑ
+ const workbook = new ExcelJS.Workbook()
+ const worksheet = workbook.addWorksheet("Tags")
+
+ // 3. Tag Class ์˜ต์…˜ ๊ฐ€์ ธ์˜ค๊ธฐ
+ const classOptions = await getClassOptions(selectedPackageId)
+
+ // 4. ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ์‹œํŠธ ์ƒ์„ฑ
+ const validationSheet = workbook.addWorksheet("ValidationData")
+ validationSheet.state = 'hidden' // ์‹œํŠธ ์ˆจ๊น€ ์ฒ˜๋ฆฌ
+
+ // 4.1. Tag Class ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ๋ฐ์ดํ„ฐ ์ถ”๊ฐ€
+ validationSheet.getColumn(1).values = ["Tag Class", ...classOptions.map(opt => opt.label)]
+
+ // 5. ๋ฉ”์ธ ์‹œํŠธ์— ํ—ค๋” ์ถ”๊ฐ€
+ const headers = tableColumns.map((col) => {
+ const meta = col.columnDef.meta as any
+ // meta์— excelHeader๊ฐ€ ์žˆ์œผ๋ฉด ์‚ฌ์šฉ
+ if (meta?.excelHeader) {
+ return meta.excelHeader
+ }
+ // ์—†์œผ๋ฉด ์ปฌ๋Ÿผ ID ์‚ฌ์šฉ
+ return col.id
+ })
+
+ worksheet.addRow(headers)
+
+ // 6. ํ—ค๋” ์Šคํƒ€์ผ ์ ์šฉ
+ const headerRow = worksheet.getRow(1)
+ headerRow.font = { bold: true }
+ headerRow.alignment = { horizontal: 'center' }
+ headerRow.eachCell((cell) => {
+ cell.fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FFCCCCCC' }
+ }
+ })
+
+ // 7. ๋ฐ์ดํ„ฐ ํ–‰ ์ถ”๊ฐ€
+ const rowModel = table.getPrePaginationRowModel()
+
+ rowModel.rows.forEach((row) => {
+ const rowData = tableColumns.map((col) => {
+ const value = row.getValue(col.id)
+
+ // ๋‚ ์งœ ํ˜•์‹ ์ฒ˜๋ฆฌ
+ if (value instanceof Date) {
+ return new Date(value).toISOString().split('T')[0]
+ }
+
+ // value๊ฐ€ null/undefined๋ฉด ๋นˆ ๋ฌธ์ž์—ด, ๊ฐ์ฒด๋ฉด JSON ๋ฌธ์ž์—ด, ๊ทธ ์™ธ์—๋Š” ๊ทธ๋Œ€๋กœ ๋ฐ˜ํ™˜
+ if (value == null) return ""
+ return typeof value === "object" ? JSON.stringify(value) : value
+ })
+
+ worksheet.addRow(rowData)
+ })
+
+ // 8. Tag Class ์—ด์— ๋ฐ์ดํ„ฐ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ์ ์šฉ
+ const classColIndex = headers.findIndex(header => header === "Tag Class")
+
+ if (classColIndex !== -1) {
+ const colLetter = worksheet.getColumn(classColIndex + 1).letter
+
+ // ๋ฐ์ดํ„ฐ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ์„ค์ •
+ const validation = {
+ type: 'list' as const,
+ allowBlank: true,
+ formulae: [`ValidationData!$A$2:$A$${classOptions.length + 1}`],
+ showErrorMessage: true,
+ errorStyle: 'warning' as const,
+ errorTitle: '์œ ํšจํ•˜์ง€ ์•Š์€ ํด๋ž˜์Šค',
+ error: '๋ชฉ๋ก์—์„œ ํด๋ž˜์Šค๋ฅผ ์„ ํƒํ•ด์ฃผ์„ธ์š”.'
+ }
+
+ // ๋ชจ๋“  ๋ฐ์ดํ„ฐ ํ–‰ + ์ถ”๊ฐ€ ํ–‰(์ตœ๋Œ€ maxRows๊นŒ์ง€)์— ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ์ ์šฉ
+ for (let rowIdx = 2; rowIdx <= maxRows; rowIdx++) {
+ worksheet.getCell(`${colLetter}${rowIdx}`).dataValidation = validation
+ }
+ }
+
+ // 9. ์ปฌ๋Ÿผ ๋„ˆ๋น„ ์ž๋™ ์กฐ์ •
+ tableColumns.forEach((col, index) => {
+ const column = worksheet.getColumn(index + 1)
+ const headerLength = headers[index]?.length || 10
+
+ // ๋ฐ์ดํ„ฐ ๊ธฐ๋ฐ˜ ์ตœ๋Œ€ ๊ธธ์ด ๊ณ„์‚ฐ
+ let maxLength = headerLength
+ rowModel.rows.forEach((row) => {
+ const value = row.getValue(col.id)
+ if (value != null) {
+ const valueLength = String(value).length
+ if (valueLength > maxLength) {
+ maxLength = valueLength
+ }
+ }
+ })
+
+ // ๋„ˆ๋น„ ์„ค์ • (์ตœ์†Œ 10, ์ตœ๋Œ€ 50)
+ column.width = Math.min(Math.max(maxLength + 2, 10), 50)
+ })
+
+ // 10. ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ
+ const buffer = await workbook.xlsx.writeBuffer()
+ saveAs(
+ new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ }),
+ `${filename}_${new Date().toISOString().split('T')[0]}.xlsx`
+ )
+
+ return true
+ } catch (error) {
+ console.error("Excel export error:", error)
+ toast.error("Excel ๋‚ด๋ณด๋‚ด๊ธฐ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.")
+ return false
+ }
+} \ No newline at end of file
diff --git a/lib/tags-plant/table/tags-table-floating-bar.tsx b/lib/tags-plant/table/tags-table-floating-bar.tsx
new file mode 100644
index 00000000..8d55b7ac
--- /dev/null
+++ b/lib/tags-plant/table/tags-table-floating-bar.tsx
@@ -0,0 +1,220 @@
+"use client"
+
+import * as React from "react"
+import { SelectTrigger } from "@radix-ui/react-select"
+import { type Table } from "@tanstack/react-table"
+import {
+ ArrowUp,
+ CheckCircle2,
+ Download,
+ Loader,
+ Trash2,
+ X,
+} from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { Portal } from "@/components/ui/portal"
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+} from "@/components/ui/select"
+import { Separator } from "@/components/ui/separator"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import { Kbd } from "@/components/kbd"
+
+import { removeTags } from "@/lib//tags/service"
+import { ActionConfirmDialog } from "@/components/ui/action-dialog"
+import { Tag } from "@/db/schema/vendorData"
+
+interface TagsTableFloatingBarProps {
+ table: Table<Tag>
+ selectedPackageId: number
+
+}
+
+
+export function TagsTableFloatingBar({ table, selectedPackageId }: TagsTableFloatingBarProps) {
+ const rows = table.getFilteredSelectedRowModel().rows
+
+ const [isPending, startTransition] = React.useTransition()
+ const [action, setAction] = React.useState<
+ "update-status" | "update-priority" | "export" | "delete"
+ >()
+ const [popoverOpen, setPopoverOpen] = React.useState(false)
+
+ // Clear selection on Escape key press
+ React.useEffect(() => {
+ function handleKeyDown(event: KeyboardEvent) {
+ if (event.key === "Escape") {
+ table.toggleAllRowsSelected(false)
+ }
+ }
+
+ window.addEventListener("keydown", handleKeyDown)
+ return () => window.removeEventListener("keydown", handleKeyDown)
+ }, [table])
+
+
+
+ // ๊ณต์šฉ confirm dialog state
+ const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false)
+ const [confirmProps, setConfirmProps] = React.useState<{
+ title: string
+ description?: string
+ onConfirm: () => Promise<void> | void
+ }>({
+ title: "",
+ description: "",
+ onConfirm: () => { },
+ })
+
+ // 1) "์‚ญ์ œ" Confirm ์—ด๊ธฐ
+ function handleDeleteConfirm() {
+ setAction("delete")
+ setConfirmProps({
+ title: `Delete ${rows.length} tag${rows.length > 1 ? "s" : ""}?`,
+ description: "This action cannot be undone.",
+ onConfirm: async () => {
+ startTransition(async () => {
+ const { error } = await removeTags({
+ ids: rows.map((row) => row.original.id),
+ selectedPackageId
+ })
+ if (error) {
+ toast.error(error)
+ return
+ }
+ toast.success("Tags deleted")
+ table.toggleAllRowsSelected(false)
+ setConfirmDialogOpen(false)
+ })
+ },
+ })
+ setConfirmDialogOpen(true)
+ }
+
+
+
+ return (
+ <Portal >
+ <div className="fixed inset-x-0 bottom-10 z-50 mx-auto w-fit px-2.5" style={{ bottom: '1.5rem' }}>
+ <div className="w-full overflow-x-auto">
+ <div className="mx-auto flex w-fit items-center gap-2 rounded-md border bg-background p-2 text-foreground shadow">
+ <div className="flex h-7 items-center rounded-md border border-dashed pl-2.5 pr-1">
+ <span className="whitespace-nowrap text-xs">
+ {rows.length} selected
+ </span>
+ <Separator orientation="vertical" className="ml-2 mr-1" />
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="size-5 hover:border"
+ onClick={() => table.toggleAllRowsSelected(false)}
+ >
+ <X className="size-3.5 shrink-0" aria-hidden="true" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent className="flex items-center border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900">
+ <p className="mr-2">Clear selection</p>
+ <Kbd abbrTitle="Escape" variant="outline">
+ Esc
+ </Kbd>
+ </TooltipContent>
+ </Tooltip>
+ </div>
+ <Separator orientation="vertical" className="hidden h-5 sm:block" />
+ <div className="flex items-center gap-1.5">
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="secondary"
+ size="icon"
+ className="size-7 border"
+ onClick={() => {
+ setAction("export")
+
+ startTransition(() => {
+ exportTableToExcel(table, {
+ excludeColumns: ["select", "actions"],
+ onlySelected: true,
+ })
+ })
+ }}
+ disabled={isPending}
+ >
+ {isPending && action === "export" ? (
+ <Loader
+ className="size-3.5 animate-spin"
+ aria-hidden="true"
+ />
+ ) : (
+ <Download className="size-3.5" aria-hidden="true" />
+ )}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900">
+ <p>Export tasks</p>
+ </TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="secondary"
+ size="icon"
+ className="size-7 border"
+ onClick={handleDeleteConfirm}
+ disabled={isPending}
+ >
+ {isPending && action === "delete" ? (
+ <Loader
+ className="size-3.5 animate-spin"
+ aria-hidden="true"
+ />
+ ) : (
+ <Trash2 className="size-3.5" aria-hidden="true" />
+ )}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900">
+ <p>Delete tasks</p>
+ </TooltipContent>
+ </Tooltip>
+ </div>
+ </div>
+ </div>
+ </div>
+
+
+ {/* ๊ณต์šฉ Confirm Dialog */}
+ <ActionConfirmDialog
+ open={confirmDialogOpen}
+ onOpenChange={setConfirmDialogOpen}
+ title={confirmProps.title}
+ description={confirmProps.description}
+ onConfirm={confirmProps.onConfirm}
+ isLoading={isPending && (action === "delete" || action === "update-priority" || action === "update-status")}
+ confirmLabel={
+ action === "delete"
+ ? "Delete"
+ : action === "update-priority" || action === "update-status"
+ ? "Update"
+ : "Confirm"
+ }
+ confirmVariant={
+ action === "delete" ? "destructive" : "default"
+ }
+ />
+ </Portal>
+ )
+}
diff --git a/lib/tags-plant/table/tags-table-toolbar-actions.tsx b/lib/tags-plant/table/tags-table-toolbar-actions.tsx
new file mode 100644
index 00000000..cc2d82b4
--- /dev/null
+++ b/lib/tags-plant/table/tags-table-toolbar-actions.tsx
@@ -0,0 +1,758 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { toast } from "sonner"
+import ExcelJS from "exceljs"
+import { saveAs } from "file-saver"
+
+import { Button } from "@/components/ui/button"
+import { Download, Upload, Loader2, RefreshCcw } from "lucide-react"
+import { Tag, TagSubfields } from "@/db/schema/vendorData"
+import { exportTagsToExcel } from "./tags-export"
+import { AddTagDialog } from "./add-tag-dialog"
+import { fetchTagSubfieldOptions, getTagNumberingRules, } from "@/lib/tag-numbering/service"
+import { bulkCreateTags, getClassOptions, getProjectIdFromContractItemId, getSubfieldsByTagType } from "../service"
+import { DeleteTagsDialog } from "./delete-tags-dialog"
+import { useRouter } from "next/navigation" // Add this import
+import { decryptWithServerAction } from "@/components/drm/drmUtils"
+
+// ํƒœ๊ทธ ๋ฒˆํ˜ธ ๊ฒ€์ฆ์„ ์œ„ํ•œ ์ธํ„ฐํŽ˜์ด์Šค
+interface TagNumberingRule {
+ attributesId: string;
+ attributesDescription: string;
+ expression: string | null;
+ delimiter: string | null;
+ sortOrder: number;
+}
+
+interface TagOption {
+ code: string;
+ label: string;
+}
+
+interface ClassOption {
+ code: string;
+ label: string;
+ tagTypeCode: string;
+ tagTypeDescription: string;
+}
+
+// ์„œ๋ธŒํ•„๋“œ ์ •์˜
+interface SubFieldDef {
+ name: string;
+ label: string;
+ type: "select" | "text";
+ options?: { value: string; label: string }[];
+ expression?: string;
+ delimiter?: string;
+}
+
+interface TagsTableToolbarActionsProps {
+ /** react-table ๊ฐ์ฒด */
+ table: Table<Tag>
+ /** ํ˜„์žฌ ์„ ํƒ๋œ ํŒจํ‚ค์ง€ ID */
+ selectedPackageId: number
+ /** ํ˜„์žฌ ํƒœ๊ทธ ๋ชฉ๋ก(์ƒํƒœ) */
+ tableData: Tag[]
+ /** ํƒœ๊ทธ ๋ชฉ๋ก์„ ๊ฐฑ์‹ ํ•˜๋Š” setState */
+ selectedMode: string
+}
+
+/**
+ * TagsTableToolbarActions:
+ * - Import ๋ฒ„ํŠผ -> Excel ํŒŒ์ผ ํŒŒ์‹ฑ & ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ (Class ๊ธฐ๋ฐ˜ ๊ฒ€์ฆ ์ถ”๊ฐ€)
+ * - ์—๋Ÿฌ ๋ฐœ์ƒ ์‹œ: state๋Š” ๊ทธ๋Œ€๋กœ ๋‘๊ณ , ์˜ค๋ฅ˜๊ฐ€ ์ ํžŒ ์—‘์…€๋งŒ ์žฌ๋‹ค์šด๋กœ๋“œ
+ * - ์ •์ƒ์ธ ๊ฒฝ์šฐ: tableData์— ๋ณ‘ํ•ฉ
+ * - Export ๋ฒ„ํŠผ -> ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ๊ฐ€ ํฌํ•จ๋œ Excel ๋‚ด๋ณด๋‚ด๊ธฐ
+ */
+export function TagsTableToolbarActions({
+ table,
+ selectedPackageId,
+ tableData,
+ selectedMode
+}: TagsTableToolbarActionsProps) {
+ const router = useRouter() // Add this line
+
+ const [isPending, setIsPending] = React.useState(false)
+ const [isExporting, setIsExporting] = React.useState(false)
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [syncId, setSyncId] = React.useState<string | null>(null)
+ const pollingRef = React.useRef<NodeJS.Timeout | null>(null)
+
+ // ํƒœ๊ทธ ํƒ€์ž…๋ณ„ ๋„˜๋ฒ„๋ง ๋ฃฐ ์บ์‹œ
+ const [tagNumberingRules, setTagNumberingRules] = React.useState<Record<string, TagNumberingRule[]>>({})
+ const [tagOptionsCache, setTagOptionsCache] = React.useState<Record<string, TagOption[]>>({})
+
+ // ํด๋ž˜์Šค ์˜ต์…˜ ๋ฐ ์„œ๋ธŒํ•„๋“œ ์บ์‹œ
+ const [classOptions, setClassOptions] = React.useState<ClassOption[]>([])
+ const [subfieldCache, setSubfieldCache] = React.useState<Record<string, SubFieldDef[]>>({})
+
+ // ์ปดํฌ๋„ŒํŠธ ๋งˆ์šดํŠธ ์‹œ ํด๋ž˜์Šค ์˜ต์…˜ ๋กœ๋“œ
+ React.useEffect(() => {
+ const loadClassOptions = async () => {
+ try {
+ const options = await getClassOptions(selectedPackageId)
+ setClassOptions(options)
+ } catch (error) {
+ console.error("Failed to load class options:", error)
+ }
+ }
+
+ loadClassOptions()
+ }, [selectedPackageId])
+
+ // ์ˆจ๊ฒจ์ง„ <input>์„ ํด๋ฆญ
+ function handleImportClick() {
+ fileInputRef.current?.click()
+ }
+
+ // ํƒœ๊ทธ ๋„˜๋ฒ„๋ง ๋ฃฐ ๊ฐ€์ ธ์˜ค๊ธฐ
+ const fetchTagNumberingRules = React.useCallback(async (tagType: string): Promise<TagNumberingRule[]> => {
+ // ์ด๋ฏธ ์บ์‹œ์— ์žˆ์œผ๋ฉด ์บ์‹œ๋œ ๊ฐ’ ์‚ฌ์šฉ
+ if (tagNumberingRules[tagType]) {
+ return tagNumberingRules[tagType]
+ }
+
+ try {
+ // ์„œ๋ฒ„ ์•ก์…˜ ์ง์ ‘ ํ˜ธ์ถœ
+ const rules = await getTagNumberingRules(tagType)
+
+ // ์บ์‹œ์— ์ €์žฅ
+ setTagNumberingRules(prev => ({
+ ...prev,
+ [tagType]: rules
+ }))
+
+ return rules
+ } catch (error) {
+ console.error(`Error fetching rules for ${tagType}:`, error)
+ return []
+ }
+ }, [tagNumberingRules])
+
+ const [projectId, setProjectId] = React.useState<number | null>(null);
+
+ // Add useEffect to fetch projectId when selectedPackageId changes
+ React.useEffect(() => {
+ const fetchProjectId = async () => {
+ if (selectedPackageId) {
+ try {
+ const pid = await getProjectIdFromContractItemId(selectedPackageId);
+ setProjectId(pid);
+ } catch (error) {
+ console.error("Failed to fetch project ID:", error);
+ toast.error("Failed to load project data");
+ }
+ }
+ };
+
+ fetchProjectId();
+ }, [selectedPackageId]);
+
+ // ํŠน์ • attributesId์— ๋Œ€ํ•œ ์˜ต์…˜ ๊ฐ€์ ธ์˜ค๊ธฐ
+ const fetchOptions = React.useCallback(async (attributesId: string): Promise<TagOption[]> => {
+ // Cache check remains the same
+ if (tagOptionsCache[attributesId]) {
+ return tagOptionsCache[attributesId];
+ }
+
+ try {
+ // Only pass projectId if it's not null
+ let options: TagOption[];
+ if (projectId !== null) {
+ options = await fetchTagSubfieldOptions(attributesId, projectId);
+ } else {
+ options = []
+ }
+
+ // Update cache
+ setTagOptionsCache(prev => ({
+ ...prev,
+ [attributesId]: options
+ }));
+
+ return options;
+ } catch (error) {
+ console.error(`Error fetching options for ${attributesId}:`, error);
+ return [];
+ }
+ }, [tagOptionsCache, projectId]);
+
+ // ํด๋ž˜์Šค ๋ผ๋ฒจ๋กœ ํƒœ๊ทธ ํƒ€์ž… ์ฝ”๋“œ ์ฐพ๊ธฐ
+ const getTagTypeCodeByClassLabel = React.useCallback((classLabel: string): string | null => {
+ const classOption = classOptions.find(opt => opt.label === classLabel)
+ return classOption?.tagTypeCode || null
+ }, [classOptions])
+
+ // ํƒœ๊ทธ ํƒ€์ž…์— ๋”ฐ๋ฅธ ์„œ๋ธŒํ•„๋“œ ๊ฐ€์ ธ์˜ค๊ธฐ
+ const fetchSubfieldsByTagType = React.useCallback(async (tagTypeCode: string): Promise<SubFieldDef[]> => {
+ // ์ด๋ฏธ ์บ์‹œ์— ์žˆ์œผ๋ฉด ์บ์‹œ๋œ ๊ฐ’ ์‚ฌ์šฉ
+ if (subfieldCache[tagTypeCode]) {
+ return subfieldCache[tagTypeCode]
+ }
+
+ try {
+ const { subFields } = await getSubfieldsByTagType(tagTypeCode, selectedPackageId)
+
+ // API ์‘๋‹ต์„ SubFieldDef ํ˜•์‹์œผ๋กœ ๋ณ€ํ™˜
+ const formattedSubFields: SubFieldDef[] = subFields.map(field => ({
+ name: field.name,
+ label: field.label,
+ type: field.type,
+ options: field.options || [],
+ expression: field.expression ?? undefined,
+ delimiter: field.delimiter ?? undefined,
+ }))
+
+ // ์บ์‹œ์— ์ €์žฅ
+ setSubfieldCache(prev => ({
+ ...prev,
+ [tagTypeCode]: formattedSubFields
+ }))
+
+ return formattedSubFields
+ } catch (error) {
+ console.error(`Error fetching subfields for tagType ${tagTypeCode}:`, error)
+ return []
+ }
+ }, [subfieldCache])
+
+ // Class ๊ธฐ๋ฐ˜ ํƒœ๊ทธ ๋ฒˆํ˜ธ ํ˜•์‹ ๊ฒ€์ฆ
+ const validateTagNumberByClass = React.useCallback(async (
+ tagNo: string,
+ classLabel: string
+ ): Promise<string> => {
+ if (!tagNo) return "Tag number is empty."
+ if (!classLabel) return "Class is empty."
+
+ try {
+ // 1. ํด๋ž˜์Šค ๋ผ๋ฒจ๋กœ ํƒœ๊ทธ ํƒ€์ž… ์ฝ”๋“œ ์ฐพ๊ธฐ
+ const tagTypeCode = getTagTypeCodeByClassLabel(classLabel)
+ if (!tagTypeCode) {
+ return `No tag type found for class '${classLabel}'.`
+ }
+
+ // 2. ํƒœ๊ทธ ํƒ€์ž… ์ฝ”๋“œ๋กœ ์„œ๋ธŒํ•„๋“œ ๊ฐ€์ ธ์˜ค๊ธฐ
+ const subfields = await fetchSubfieldsByTagType(tagTypeCode)
+ if (!subfields || subfields.length === 0) {
+ return `No subfields found for tag type code '${tagTypeCode}'.`
+ }
+
+ // 3. ํƒœ๊ทธ ๋ฒˆํ˜ธ๋ฅผ ํŒŒํŠธ๋ณ„๋กœ ๋ถ„์„
+ let remainingTagNo = tagNo
+ let currentPosition = 0
+
+ for (const field of subfields) {
+ // ๊ตฌ๋ถ„์ž ํ™•์ธ
+ const delimiter = field.delimiter || ""
+
+ // ๋‹ค์Œ ๊ตฌ๋ถ„์ž ์œ„์น˜ ๋˜๋Š” ๋ฌธ์ž์—ด ๋
+ let nextDelimiterPos
+ if (delimiter && remainingTagNo.includes(delimiter)) {
+ nextDelimiterPos = remainingTagNo.indexOf(delimiter)
+ } else {
+ nextDelimiterPos = remainingTagNo.length
+ }
+
+ // ํ˜„์žฌ ํŒŒํŠธ ์ถ”์ถœ
+ const part = remainingTagNo.substring(0, nextDelimiterPos)
+
+ // ๋น„์–ด์žˆ์œผ๋ฉด ์˜ค๋ฅ˜
+ if (!part) {
+ return `Empty part for field '${field.label}'.`
+ }
+
+ // ์ •๊ทœ์‹ ๊ฒ€์ฆ
+ if (field.expression) {
+ try {
+ // ์ค‘๋ณต๋œ ^, $ ์ œ๊ฑฐ ํ›„ ๋‹ค์‹œ ์ถ”๊ฐ€
+ let cleanPattern = field.expression;
+
+ // ์‹œ์ž‘๊ณผ ๋์˜ ^, $ ์ œ๊ฑฐ
+ cleanPattern = cleanPattern.replace(/^\^/, '').replace(/\$$/, '');
+
+ // ์ •๊ทœ์‹ ์ƒ์„ฑ (ํ•ญ์ƒ ์ „์ฒด ๋งค์นญ)
+ const regex = new RegExp(`^${cleanPattern}$`);
+
+ if (!regex.test(part)) {
+ return `Part '${part}' for field '${field.label}' does not match the pattern '${field.expression}'.`;
+ }
+ } catch (error) {
+ console.error(`Invalid regex pattern: ${field.expression}`, error);
+ return `Invalid pattern for field '${field.label}': ${field.expression}`;
+ }
+ }
+ // ์„ ํƒ ์˜ต์…˜ ๊ฒ€์ฆ
+ if (field.type === "select" && field.options && field.options.length > 0) {
+ const validValues = field.options.map(opt => opt.value)
+ if (!validValues.includes(part)) {
+ return `'${part}' is not a valid value for field '${field.label}'. Valid options: ${validValues.join(", ")}.`
+ }
+ }
+
+ // ๋‚จ์€ ๋ฌธ์ž์—ด ์—…๋ฐ์ดํŠธ
+ if (delimiter && nextDelimiterPos < remainingTagNo.length) {
+ remainingTagNo = remainingTagNo.substring(nextDelimiterPos + delimiter.length)
+ } else {
+ remainingTagNo = ""
+ break
+ }
+ }
+
+ // ๋ฌธ์ž์—ด์ด ๋‚จ์•„์žˆ์œผ๋ฉด ์˜ค๋ฅ˜
+ if (remainingTagNo) {
+ return `Tag number has extra parts: '${remainingTagNo}'.`
+ }
+
+ return "" // ์˜ค๋ฅ˜ ์—†์Œ
+ } catch (error) {
+ console.error("Error validating tag number by class:", error)
+ return "Error validating tag number format."
+ }
+ }, [getTagTypeCodeByClassLabel, fetchSubfieldsByTagType])
+
+ // ๊ธฐ์กด ํƒœ๊ทธ ๋ฒˆํ˜ธ ๊ฒ€์ฆ ํ•จ์ˆ˜ (๊ธฐ์กด ์ฝ”๋“œ๋ฅผ ์œ ์ง€)
+ const validateTagNumber = React.useCallback(async (tagNo: string, tagType: string): Promise<string> => {
+ if (!tagNo) return "Tag number is empty."
+ if (!tagType) return "Tag type is empty."
+
+ try {
+ // 1. ํƒœ๊ทธ ํƒ€์ž…์— ๋Œ€ํ•œ ๋„˜๋ฒ„๋ง ๋ฃฐ ๊ฐ€์ ธ์˜ค๊ธฐ
+ const rules = await fetchTagNumberingRules(tagType)
+ if (!rules || rules.length === 0) {
+ return `No numbering rules found for tag type '${tagType}'.`
+ }
+
+ // 2. ์ •๋ ฌ๋œ ๋ฃฐ (sortOrder ๊ธฐ์ค€)
+ const sortedRules = [...rules].sort((a, b) => a.sortOrder - b.sortOrder)
+
+ // 3. ํƒœ๊ทธ ๋ฒˆํ˜ธ๋ฅผ ํŒŒํŠธ๋กœ ๋ถ„๋ฆฌ
+ let remainingTagNo = tagNo
+ let currentPosition = 0
+
+ for (const rule of sortedRules) {
+ // ๋งˆ์ง€๋ง‰ ๋ฃฐ์ด ์•„๋‹ˆ๊ณ  ๊ตฌ๋ถ„์ž๊ฐ€ ์žˆ์œผ๋ฉด
+ const delimiter = rule.delimiter || ""
+
+ // ๋‹ค์Œ ๊ตฌ๋ถ„์ž ์œ„์น˜ ์ฐพ๊ธฐ ๋˜๋Š” ๋ฌธ์ž์—ด ๋
+ let nextDelimiterPos
+ if (delimiter && remainingTagNo.includes(delimiter)) {
+ nextDelimiterPos = remainingTagNo.indexOf(delimiter)
+ } else {
+ nextDelimiterPos = remainingTagNo.length
+ }
+
+ // ํ˜„์žฌ ํŒŒํŠธ ์ถ”์ถœ
+ const part = remainingTagNo.substring(0, nextDelimiterPos)
+
+ // ํ‘œํ˜„์‹์ด ์žˆ์œผ๋ฉด ๊ฒ€์ฆ
+ if (rule.expression) {
+ const regex = new RegExp(`^${rule.expression}$`)
+ if (!regex.test(part)) {
+ return `Part '${part}' does not match the pattern '${rule.expression}' for ${rule.attributesDescription}.`
+ }
+ }
+
+ // ์˜ต์…˜์ด ์žˆ๋Š” ๊ฒฝ์šฐ ์œ ํšจํ•œ ์ฝ”๋“œ์ธ์ง€ ํ™•์ธ
+ const options = await fetchOptions(rule.attributesId)
+ if (options.length > 0) {
+ const isValidCode = options.some(opt => opt.code === part)
+ if (!isValidCode) {
+ return `'${part}' is not a valid code for ${rule.attributesDescription}. Valid options: ${options.map(o => o.code).join(', ')}.`
+ }
+ }
+
+ // ๋‚จ์€ ๋ฌธ์ž์—ด ์—…๋ฐ์ดํŠธ
+ if (delimiter && nextDelimiterPos < remainingTagNo.length) {
+ remainingTagNo = remainingTagNo.substring(nextDelimiterPos + delimiter.length)
+ } else {
+ remainingTagNo = ""
+ break
+ }
+
+ // ๋ชจ๋“  ๋ฃฐ์„ ์ฒ˜๋ฆฌํ–ˆ๋Š”๋ฐ ๋ฌธ์ž์—ด์ด ๋‚จ์•„์žˆ์œผ๋ฉด ์˜ค๋ฅ˜
+ if (remainingTagNo && rule === sortedRules[sortedRules.length - 1]) {
+ return `Tag number has extra parts: '${remainingTagNo}'.`
+ }
+ }
+
+ // ๋ฌธ์ž์—ด์ด ๋‚จ์•„์žˆ์œผ๋ฉด ์˜ค๋ฅ˜
+ if (remainingTagNo) {
+ return `Tag number has unprocessed parts: '${remainingTagNo}'.`
+ }
+
+ return "" // ์˜ค๋ฅ˜ ์—†์Œ
+ } catch (error) {
+ console.error("Error validating tag number:", error)
+ return "Error validating tag number."
+ }
+ }, [fetchTagNumberingRules, fetchOptions])
+
+ /**
+ * ๊ฐœ์„ ๋œ handleFileChange ํ•จ์ˆ˜
+ * 1) ExcelJS๋กœ ํŒŒ์ผ ํŒŒ์‹ฑ
+ * 2) ํ—ค๋” -> meta.excelHeader ๋งคํ•‘
+ * 3) ๊ฐ ํ–‰ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ (Class ๊ธฐ๋ฐ˜ ๊ฒ€์ฆ ์ถ”๊ฐ€)
+ * 4) ์—๋Ÿฌ ํ–‰ ์žˆ์œผ๋ฉด โ†’ ์˜ค๋ฅ˜ ๋ฉ”์‹œ์ง€ ๊ธฐ๋ก + ์žฌ๋‹ค์šด๋กœ๋“œ (์ƒํƒœ ๋ณ€๊ฒฝ ์•ˆ ํ•จ)
+ * 5) ์ •์ƒ ํ–‰๋งŒ importedRows ๋กœ โ†’ ๋ณ‘ํ•ฉ
+ */
+ async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
+ const file = e.target.files?.[0]
+ if (!file) return
+
+ // ํŒŒ์ผ input ์ดˆ๊ธฐํ™”
+ e.target.value = ""
+ setIsPending(true)
+
+ try {
+ // 1) Workbook ๋กœ๋“œ
+ const workbook = new ExcelJS.Workbook()
+ // const arrayBuffer = await file.arrayBuffer()
+ const arrayBuffer = await decryptWithServerAction(file);
+ await workbook.xlsx.load(arrayBuffer)
+
+ // ์ฒซ ๋ฒˆ์งธ ์‹œํŠธ ์‚ฌ์šฉ
+ const worksheet = workbook.worksheets[0]
+
+ // (A) ๋งˆ์ง€๋ง‰ ์—ด์— "Error" ํ—ค๋”
+ const lastColIndex = worksheet.columnCount + 1
+ worksheet.getRow(1).getCell(lastColIndex).value = "Error"
+
+ // (B) ์—‘์…€ ํ—ค๋” (Row1)
+ const headerRowValues = worksheet.getRow(1).values as ExcelJS.CellValue[]
+
+ // (C) excelHeader -> accessor ๋งคํ•‘
+ const excelHeaderToAccessor: Record<string, string> = {}
+ for (const col of table.getAllColumns()) {
+ const meta = col.columnDef.meta as { excelHeader?: string } | undefined
+ if (meta?.excelHeader) {
+ const accessor = col.id as string
+ excelHeaderToAccessor[meta.excelHeader] = accessor
+ }
+ }
+
+ // (D) accessor -> column index
+ const accessorIndexMap: Record<string, number> = {}
+ for (let i = 1; i < headerRowValues.length; i++) {
+ const cellVal = String(headerRowValues[i] ?? "").trim()
+ if (!cellVal) continue
+ const accessor = excelHeaderToAccessor[cellVal]
+ if (accessor) {
+ accessorIndexMap[accessor] = i
+ }
+ }
+
+ let errorCount = 0
+ const importedRows: Tag[] = []
+ const fileTagNos = new Set<string>() // ํŒŒ์ผ ๋‚ด ํƒœ๊ทธ๋ฒˆํ˜ธ ์ค‘๋ณต ์ฒดํฌ์šฉ
+ const lastRow = worksheet.lastRow?.number || 1
+
+ // 2) ๊ฐ ๋ฐ์ดํ„ฐ ํ–‰ ํŒŒ์‹ฑ
+ for (let rowNum = 2; rowNum <= lastRow; rowNum++) {
+ const row = worksheet.getRow(rowNum)
+ const rowVals = row.values as ExcelJS.CellValue[]
+ if (!rowVals || rowVals.length <= 1) continue // ๋นˆ ํ–‰ ์Šคํ‚ต
+
+ let errorMsg = ""
+
+ // ํ•„์š”ํ•œ accessorIndex
+ const tagNoIndex = accessorIndexMap["tagNo"]
+ const classIndex = accessorIndexMap["class"]
+
+ // ์—‘์…€์—์„œ ๊ฐ’ ์ฝ๊ธฐ
+ const tagNo = tagNoIndex ? String(rowVals[tagNoIndex] ?? "").trim() : ""
+ const classVal = classIndex ? String(rowVals[classIndex] ?? "").trim() : ""
+
+ // A. ํ•„์ˆ˜๊ฐ’ ๊ฒ€์‚ฌ
+ if (!tagNo) {
+ errorMsg += `Tag No is empty. `
+ }
+ if (!classVal) {
+ errorMsg += `Class is empty. `
+ }
+
+ // B. ์ค‘๋ณต ๊ฒ€์‚ฌ
+ if (tagNo) {
+ // ์ด๋ฏธ tableData ๋‚ด ์กด์žฌ ์—ฌ๋ถ€
+ const dup = tableData.find(
+ (t) => t.contractItemId === selectedPackageId && t.tagNo === tagNo
+ )
+ if (dup) {
+ errorMsg += `TagNo '${tagNo}' already exists. `
+ }
+
+ // ์ด๋ฒˆ ์—‘์…€ ํŒŒ์ผ ๋‚ด ์ค‘๋ณต
+ if (fileTagNos.has(tagNo)) {
+ errorMsg += `TagNo '${tagNo}' is duplicated within this file. `
+ } else {
+ fileTagNos.add(tagNo)
+ }
+ }
+
+ // C. Class ๊ธฐ๋ฐ˜ ํ˜•์‹ ๊ฒ€์ฆ
+ if (tagNo && classVal && !errorMsg) {
+ // classVal ๋กœ๋ถ€ํ„ฐ ํƒœ๊ทธํƒ€์ž… ์ฝ”๋“œ ํš๋“
+ const tagTypeCode = getTagTypeCodeByClassLabel(classVal)
+
+ if (!tagTypeCode) {
+ errorMsg += `No tag type code found for class '${classVal}'. `
+ } else {
+ // validateTagNumberByClass( ) ์•ˆ์—์„œ
+ // โ†’ tagTypeCode๋กœ ์„œ๋ธŒํ•„๋“œ ์กฐํšŒ, ์ •๊ทœ์‹ ๊ฒ€์ฆ ๋“ฑ ์ฒ˜๋ฆฌ
+ const classValidationError = await validateTagNumberByClass(tagNo, classVal)
+ if (classValidationError) {
+ errorMsg += classValidationError + " "
+ }
+ }
+ }
+
+ // D. ์—๋Ÿฌ ์ฒ˜๋ฆฌ
+ if (errorMsg) {
+ row.getCell(lastColIndex).value = errorMsg.trim()
+ errorCount++
+ } else {
+ // ์ตœ์ข… ํƒœ๊ทธ ํƒ€์ž… ๊ฒฐ์ • (DB์— ์ €์žฅํ•  ๋•Œ 'tagType' ์ปฌ๋Ÿผ์„ ๋ฌด์—‡์œผ๋กœ ์“ธ์ง€ ๊ฒฐ์ •)
+ // ์˜ˆ: DB์—์„œ tagType์„ "CV" ๊ฐ™์€ ์ฝ”๋“œ๋กœ ์ €์žฅํ•˜๋ ค๋ฉด
+ // const finalTagType = getTagTypeCodeByClassLabel(classVal) ?? ""
+ // ํ˜น์€ "Control Valve" ๊ฐ™์€ description์„ ์“ฐ๋ ค๋ฉด classOptions์—์„œ ์ฐพ์•„๋ณผ ์ˆ˜๋„ ์žˆ์Œ
+ const finalTagType = getTagTypeCodeByClassLabel(classVal) ?? ""
+
+ // ์ •์ƒ ํ–‰์„ importedRows์— ์ถ”๊ฐ€
+ importedRows.push({
+ id: 0, // ์ž„์‹œ
+ contractItemId: selectedPackageId,
+ formId: null,
+ tagNo,
+ tagType: finalTagType, // โ† ์ฝ”๋“œ๋กœ ์ €์žฅํ• ์ง€, Description์œผ๋กœ ์ €์žฅํ• ์ง€ ๊ฒฐ์ •
+ class: classVal,
+ description: String(rowVals[accessorIndexMap["description"] ?? 0] ?? "").trim(),
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ }
+ }
+
+ // (E) ์˜ค๋ฅ˜ ํ–‰์ด ์žˆ์œผ๋ฉด โ†’ ์ˆ˜์ •๋œ ์—‘์…€ ์žฌ๋‹ค์šด๋กœ๋“œ & ์ข…๋ฃŒ
+ if (errorCount > 0) {
+ const outBuf = await workbook.xlsx.writeBuffer()
+ const errorFile = new Blob([outBuf])
+ const url = URL.createObjectURL(errorFile)
+ const link = document.createElement("a")
+ link.href = url
+ link.download = "tag_import_errors.xlsx"
+ link.click()
+ URL.revokeObjectURL(url)
+
+ toast.error(`There are ${errorCount} error row(s). Please see downloaded file.`)
+ return
+ }
+
+ // ์ •์ƒ ํ–‰์ด ์žˆ์œผ๋ฉด ํƒœ๊ทธ ์ƒ์„ฑ ์š”์ฒญ
+ if (importedRows.length > 0) {
+ const result = await bulkCreateTags(importedRows, selectedPackageId);
+ if ("error" in result) {
+ toast.error(result.error);
+ } else {
+ toast.success(`${result.data.createdCount}๊ฐœ์˜ ํƒœ๊ทธ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.`);
+ }
+ }
+
+ toast.success(`Imported ${importedRows.length} tags successfully!`)
+
+ } catch (err) {
+ console.error(err)
+ toast.error("ํŒŒ์ผ ์—…๋กœ๋“œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.")
+ } finally {
+ setIsPending(false)
+ }
+ }
+ // ์ƒˆ Export ํ•จ์ˆ˜ - ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ์‹œํŠธ๋ฅผ ํฌํ•จํ•œ ์—‘์…€ ๋‚ด๋ณด๋‚ด๊ธฐ
+ async function handleExport() {
+ try {
+ setIsExporting(true)
+
+ // ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ๊ฐ€ ํฌํ•จ๋œ ์ƒˆ๋กœ์šด ์—‘์…€ ๋‚ด๋ณด๋‚ด๊ธฐ ํ•จ์ˆ˜ ํ˜ธ์ถœ
+ await exportTagsToExcel(table, selectedPackageId, {
+ filename: `Tags_${selectedPackageId}`,
+ excludeColumns: ["select", "actions", "createdAt", "updatedAt"],
+ })
+
+ toast.success("ํƒœ๊ทธ ๋ชฉ๋ก์ด ์„ฑ๊ณต์ ์œผ๋กœ ๋‚ด๋ณด๋‚ด์กŒ์Šต๋‹ˆ๋‹ค.")
+ } catch (error) {
+ console.error("Export error:", error)
+ toast.error("ํƒœ๊ทธ ๋ชฉ๋ก ๋‚ด๋ณด๋‚ด๊ธฐ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.")
+ } finally {
+ setIsExporting(false)
+ }
+ }
+
+ const startGetTags = async () => {
+ try {
+ setIsLoading(true)
+
+ // API ์—”๋“œํฌ์ธํŠธ ํ˜ธ์ถœ - ์ž‘์—… ์‹œ์ž‘๋งŒ ์š”์ฒญ
+ const response = await fetch('/api/cron/tags/start', {
+ method: 'POST',
+ body: JSON.stringify({
+ packageId: selectedPackageId,
+ mode: selectedMode // ๋ชจ๋“œ ์ •๋ณด ์ถ”๊ฐ€
+ })
+ })
+ if (!response.ok) {
+ const errorData = await response.json()
+ throw new Error(errorData.error || 'Failed to start tag import')
+ }
+
+ const data = await response.json()
+
+ // ์ž‘์—… ID ์ €์žฅ
+ if (data.syncId) {
+ setSyncId(data.syncId)
+ toast.info('Tag import started. This may take a while...')
+
+ // ์ƒํƒœ ํ™•์ธ์„ ์œ„ํ•œ ํด๋ง ์‹œ์ž‘
+ startPolling(data.syncId)
+ } else {
+ throw new Error('No import ID returned from server')
+ }
+ } catch (error) {
+ console.error('Error starting tag import:', error)
+ toast.error(
+ error instanceof Error
+ ? error.message
+ : 'An error occurred while starting tag import'
+ )
+ setIsLoading(false)
+ }
+ }
+
+ const startPolling = (id: string) => {
+ // ์ด์ „ ํด๋ง์ด ์žˆ๋‹ค๋ฉด ์ œ๊ฑฐ
+ if (pollingRef.current) {
+ clearInterval(pollingRef.current)
+ }
+
+ // 5์ดˆ๋งˆ๋‹ค ์ƒํƒœ ํ™•์ธ
+ pollingRef.current = setInterval(async () => {
+ try {
+ const response = await fetch(`/api/cron/tags/status?id=${id}`)
+
+ if (!response.ok) {
+ throw new Error('Failed to get tag import status')
+ }
+
+ const data = await response.json()
+
+ if (data.status === 'completed') {
+ // ํด๋ง ์ค‘์ง€
+ if (pollingRef.current) {
+ clearInterval(pollingRef.current)
+ pollingRef.current = null
+ }
+
+ router.refresh()
+
+ // ์ƒํƒœ ์ดˆ๊ธฐํ™”
+ setIsLoading(false)
+ setSyncId(null)
+
+ // ์„ฑ๊ณต ๋ฉ”์‹œ์ง€ ํ‘œ์‹œ
+ toast.success(
+ `Tags imported successfully! ${data.result?.processedCount || 0} items processed.`
+ )
+
+ // ํ…Œ์ด๋ธ” ๋ฐ์ดํ„ฐ ์—…๋ฐ์ดํŠธ
+ table.resetRowSelection()
+ } else if (data.status === 'failed') {
+ // ์—๋Ÿฌ ์ฒ˜๋ฆฌ
+ if (pollingRef.current) {
+ clearInterval(pollingRef.current)
+ pollingRef.current = null
+ }
+
+ setIsLoading(false)
+ setSyncId(null)
+ toast.error(data.error || 'Import failed')
+ } else if (data.status === 'processing') {
+ // ์ง„ํ–‰ ์ƒํƒœ ์—…๋ฐ์ดํŠธ (์„ ํƒ์ )
+ if (data.progress) {
+ toast.info(`Import in progress: ${data.progress}%`, {
+ id: `import-progress-${id}`,
+ })
+ }
+ }
+ } catch (error) {
+ console.error('Error checking importing status:', error)
+ }
+ }, 5000) // 5์ดˆ๋งˆ๋‹ค ์ฒดํฌ
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+
+ {table.getFilteredSelectedRowModel().rows.length > 0 ? (
+ <DeleteTagsDialog
+ tags={table
+ .getFilteredSelectedRowModel()
+ .rows.map((row) => row.original)}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ selectedPackageId={selectedPackageId}
+ />
+ ) : null}
+ <Button
+ variant="samsung"
+ size="sm"
+ className="gap-2"
+ onClick={startGetTags}
+ disabled={isLoading}
+ >
+ <RefreshCcw className={`size-4 ${isLoading ? 'animate-spin' : ''}`} aria-hidden="true" />
+ <span className="hidden sm:inline">
+ {isLoading ? 'Syncing...' : 'Get Tags'}
+ </span>
+ </Button>
+
+ <AddTagDialog selectedPackageId={selectedPackageId} />
+
+ {/* Import */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleImportClick}
+ disabled={isPending || isExporting}
+ >
+ {isPending ? (
+ <Loader2 className="size-4 mr-2 animate-spin" aria-hidden="true" />
+ ) : (
+ <Upload className="size-4 mr-2" aria-hidden="true" />
+ )}
+ <span className="hidden sm:inline">Import</span>
+ </Button>
+ <input
+ ref={fileInputRef}
+ type="file"
+ accept=".xlsx,.xls"
+ className="hidden"
+ onChange={handleFileChange}
+ />
+
+ {/* Export */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleExport}
+ disabled={isPending || isExporting}
+ >
+ {isExporting ? (
+ <Loader2 className="size-4 mr-2 animate-spin" aria-hidden="true" />
+ ) : (
+ <Download className="size-4 mr-2" aria-hidden="true" />
+ )}
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/tags-plant/table/update-tag-sheet.tsx b/lib/tags-plant/table/update-tag-sheet.tsx
new file mode 100644
index 00000000..613abaa9
--- /dev/null
+++ b/lib/tags-plant/table/update-tag-sheet.tsx
@@ -0,0 +1,547 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Loader2, Check, ChevronsUpDown } from "lucide-react"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+import { z } from "zod"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command"
+import { Badge } from "@/components/ui/badge"
+import { cn } from "@/lib/utils"
+
+import { Tag } from "@/db/schema/vendorData"
+import { updateTag, getSubfieldsByTagType, getClassOptions, TagTypeOption } from "@/lib/tags/service"
+
+// SubFieldDef ์ธํ„ฐํŽ˜์ด์Šค
+interface SubFieldDef {
+ name: string
+ label: string
+ type: "select" | "text"
+ options?: { value: string; label: string }[]
+ expression?: string
+ delimiter?: string
+}
+
+// ํด๋ž˜์Šค ์˜ต์…˜ ์ธํ„ฐํŽ˜์ด์Šค
+interface UpdatedClassOption {
+ code: string
+ label: string
+ tagTypeCode: string
+ tagTypeDescription?: string
+}
+
+// UpdateTagSchema ์ •์˜
+const updateTagSchema = z.object({
+ class: z.string().min(1, "Class is required"),
+ tagType: z.string().min(1, "Tag Type is required"),
+ tagNo: z.string().min(1, "Tag Number is required"),
+ description: z.string().optional(),
+ // ์ถ”๊ฐ€ ํ•„๋“œ๋“ค์€ ๋™์ ์œผ๋กœ ์ฒ˜๋ฆฌ๋จ
+})
+
+// TypeScript ํƒ€์ž… ์ •์˜
+type UpdateTagSchema = z.infer<typeof updateTagSchema> & Record<string, string>
+
+interface UpdateTagSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
+ tag: Tag | null
+ selectedPackageId: number
+}
+
+export function UpdateTagSheet({ tag, selectedPackageId, ...props }: UpdateTagSheetProps) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+ const [tagTypeList, setTagTypeList] = React.useState<TagTypeOption[]>([])
+ const [selectedTagTypeCode, setSelectedTagTypeCode] = React.useState<string | null>(null)
+ const [subFields, setSubFields] = React.useState<SubFieldDef[]>([])
+ const [classOptions, setClassOptions] = React.useState<UpdatedClassOption[]>([])
+ const [classSearchTerm, setClassSearchTerm] = React.useState("")
+ const [isLoadingClasses, setIsLoadingClasses] = React.useState(false)
+ const [isLoadingSubFields, setIsLoadingSubFields] = React.useState(false)
+
+ // ID management for popover elements
+ const selectIdRef = React.useRef(0)
+ const fieldIdsRef = React.useRef<Record<string, string>>({})
+ const classOptionIdsRef = React.useRef<Record<string, string>>({})
+
+
+ // Load class options when sheet opens
+ React.useEffect(() => {
+ const loadClassOptions = async () => {
+ if (!props.open || !tag) return
+
+ setIsLoadingClasses(true)
+ try {
+ const result = await getClassOptions(selectedPackageId)
+ setClassOptions(result)
+ } catch (err) {
+ toast.error("ํด๋ž˜์Šค ์˜ต์…˜์„ ๋ถˆ๋Ÿฌ์˜ค๋Š”๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.")
+ } finally {
+ setIsLoadingClasses(false)
+ }
+ }
+
+ loadClassOptions()
+ }, [props.open, tag])
+
+ // Form setup
+ const form = useForm<UpdateTagSchema>({
+ resolver: zodResolver(updateTagSchema),
+ defaultValues: {
+ class: "",
+ tagType: "",
+ tagNo: "",
+ description: "",
+ },
+ })
+
+ // Load tag data into form when tag changes
+ React.useEffect(() => {
+ if (!tag) return
+
+ // ํ•„์š”ํ•œ ํ•„๋“œ๋งŒ ์„ ํƒ์ ์œผ๋กœ ์ถ”์ถœ
+ const formValues = {
+ tagNo: tag.tagNo,
+ tagType: tag.tagType,
+ class: tag.class,
+ description: tag.description || ""
+ // ์ฐธ๊ณ : ์‹ค์ œ ํƒœ๊ทธ ๋ฐ์ดํ„ฐ์—๋Š” ์„œ๋ธŒํ•„๋“œ(functionCode, seqNumber ๋“ฑ)๊ฐ€ ์—†์Œ
+ };
+
+ // ํผ ์ดˆ๊ธฐํ™”
+ form.reset(formValues)
+
+ // ํƒœ๊ทธ ํƒ€์ž… ์ฝ”๋“œ ์„ค์ • (์ถ”๊ฐ€ ํ•„๋“œ ๋กœ๋”ฉ์„ ์œ„ํ•ด)
+ if (tag.tagType) {
+ // ํ•ด๋‹น ํƒœ๊ทธ ํƒ€์ž…์— ๋งž๋Š” ํด๋ž˜์Šค ์˜ต์…˜์„ ์ฐพ์•„์„œ ํƒœ๊ทธ ํƒ€์ž… ์ฝ”๋“œ ์„ค์ •
+ const foundClass = classOptions.find(opt => opt.label === tag.class)
+ if (foundClass?.tagTypeCode) {
+ setSelectedTagTypeCode(foundClass.tagTypeCode)
+ loadSubFieldsByTagTypeCode(foundClass.tagTypeCode)
+ }
+ }
+ }, [tag, classOptions, form])
+
+ // Load subfields by tag type code
+ async function loadSubFieldsByTagTypeCode(tagTypeCode: string) {
+ setIsLoadingSubFields(true)
+ try {
+ const { subFields: apiSubFields } = await getSubfieldsByTagType(tagTypeCode, selectedPackageId)
+ const formattedSubFields: SubFieldDef[] = apiSubFields.map(field => ({
+ name: field.name,
+ label: field.label,
+ type: field.type,
+ options: field.options || [],
+ expression: field.expression ?? undefined,
+ delimiter: field.delimiter ?? undefined,
+ }))
+ setSubFields(formattedSubFields)
+ return true
+ } catch (err) {
+ toast.error("์„œ๋ธŒํ•„๋“œ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š”๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.")
+ setSubFields([])
+ return false
+ } finally {
+ setIsLoadingSubFields(false)
+ }
+ }
+
+ // Handle class selection
+ async function handleSelectClass(classOption: UpdatedClassOption) {
+ form.setValue("class", classOption.label, { shouldValidate: true })
+
+ if (classOption.tagTypeCode) {
+ setSelectedTagTypeCode(classOption.tagTypeCode)
+
+ // Set tag type
+ const tagType = tagTypeList.find(t => t.id === classOption.tagTypeCode)
+ if (tagType) {
+ form.setValue("tagType", tagType.label, { shouldValidate: true })
+ } else if (classOption.tagTypeDescription) {
+ form.setValue("tagType", classOption.tagTypeDescription, { shouldValidate: true })
+ }
+
+ await loadSubFieldsByTagTypeCode(classOption.tagTypeCode)
+ }
+ }
+
+ // Form submission handler
+ function onSubmit(data: UpdateTagSchema) {
+ startUpdateTransition(async () => {
+ if (!tag) return
+
+ try {
+ // ๊ธฐ๋ณธ ํ•„๋“œ์™€ ์„œ๋ธŒํ•„๋“œ ๋ฐ์ดํ„ฐ ๊ฒฐํ•ฉ
+ const tagData = {
+ id: tag.id,
+ tagType: data.tagType,
+ class: data.class,
+ tagNo: data.tagNo,
+ description: data.description,
+ ...Object.fromEntries(
+ subFields.map(field => [field.name, data[field.name] || ""])
+ ),
+ }
+
+ const result = await updateTag(tagData, selectedPackageId)
+
+ if ("error" in result) {
+ toast.error(result.error)
+ return
+ }
+
+ form.reset()
+ props.onOpenChange?.(false)
+ toast.success("ํƒœ๊ทธ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์—…๋ฐ์ดํŠธ๋˜์—ˆ์Šต๋‹ˆ๋‹ค")
+ } catch (error) {
+ console.error("Error updating tag:", error)
+ toast.error("ํƒœ๊ทธ ์—…๋ฐ์ดํŠธ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค")
+ }
+ })
+ }
+
+ // Render class field
+ function renderClassField(field: any) {
+ const [popoverOpen, setPopoverOpen] = React.useState(false)
+
+ const buttonId = React.useMemo(
+ () => `class-button-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
+ []
+ )
+ const popoverContentId = React.useMemo(
+ () => `class-popover-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
+ []
+ )
+ const commandId = React.useMemo(
+ () => `class-command-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
+ []
+ )
+
+ return (
+ <FormItem>
+ <FormLabel>Class</FormLabel>
+ <FormControl>
+ <Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ key={buttonId}
+ type="button"
+ variant="outline"
+ className="w-full justify-between relative h-9"
+ disabled={isLoadingClasses}
+ >
+ {isLoadingClasses ? (
+ <>
+ <span>ํด๋ž˜์Šค ๋กœ๋”ฉ ์ค‘...</span>
+ <Loader2 className="ml-2 h-4 w-4 animate-spin" />
+ </>
+ ) : (
+ <>
+ <span className="truncate mr-1 flex-grow text-left">
+ {field.value || "ํด๋ž˜์Šค ์„ ํƒ..."}
+ </span>
+ <ChevronsUpDown className="h-4 w-4 opacity-50 flex-shrink-0" />
+ </>
+ )}
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent key={popoverContentId} className="w-[300px] p-0">
+ <Command key={commandId}>
+ <CommandInput
+ key={`${commandId}-input`}
+ placeholder="ํด๋ž˜์Šค ๊ฒ€์ƒ‰..."
+ value={classSearchTerm}
+ onValueChange={setClassSearchTerm}
+ />
+ <CommandList key={`${commandId}-list`} className="max-h-[300px]">
+ <CommandEmpty key={`${commandId}-empty`}>๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.</CommandEmpty>
+ <CommandGroup key={`${commandId}-group`}>
+ {classOptions.map((opt, optIndex) => {
+ if (!classOptionIdsRef.current[opt.code]) {
+ classOptionIdsRef.current[opt.code] =
+ `class-${opt.code}-${Date.now()}-${Math.random()
+ .toString(36)
+ .slice(2, 9)}`
+ }
+ const optionId = classOptionIdsRef.current[opt.code]
+
+ return (
+ <CommandItem
+ key={`${optionId}-${optIndex}`}
+ onSelect={() => {
+ field.onChange(opt.label)
+ setPopoverOpen(false)
+ handleSelectClass(opt)
+ }}
+ value={opt.label}
+ className="truncate"
+ title={opt.label}
+ >
+ <span className="truncate">{opt.label}</span>
+ <Check
+ key={`${optionId}-check`}
+ className={cn(
+ "ml-auto h-4 w-4 flex-shrink-0",
+ field.value === opt.label ? "opacity-100" : "opacity-0"
+ )}
+ />
+ </CommandItem>
+ )
+ })}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )
+ }
+
+ // Render TagType field (readonly)
+ function renderTagTypeField(field: any) {
+ return (
+ <FormItem>
+ <FormLabel>Tag Type</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ {...field}
+ readOnly
+ className="h-9 bg-muted"
+ />
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )
+ }
+
+ // Render Tag Number field (readonly)
+ function renderTagNoField(field: any) {
+ return (
+ <FormItem>
+ <FormLabel>Tag Number</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ {...field}
+ readOnly
+ className="h-9 bg-muted font-mono"
+ />
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )
+ }
+
+ // Render form fields for each subfield
+ function renderSubFields() {
+ if (isLoadingSubFields) {
+ return (
+ <div className="flex justify-center items-center py-4">
+ <Loader2 className="h-6 w-6 animate-spin text-primary" />
+ <div className="ml-3 text-muted-foreground">ํ•„๋“œ ๋กœ๋”ฉ ์ค‘...</div>
+ </div>
+ )
+ }
+
+ if (subFields.length === 0) {
+ return null
+ }
+
+ return (
+ <div className="space-y-4">
+ <div className="text-sm font-medium text-muted-foreground">์ถ”๊ฐ€ ํ•„๋“œ</div>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ {subFields.map((sf, index) => (
+ <FormField
+ key={`subfield-${sf.name}-${index}`}
+ control={form.control}
+ name={sf.name}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{sf.label}</FormLabel>
+ <FormControl>
+ {sf.type === "select" ? (
+ <Select
+ value={field.value || ""}
+ onValueChange={field.onChange}
+ >
+ <SelectTrigger className="w-full h-9">
+ <SelectValue placeholder={`${sf.label} ์„ ํƒ...`} />
+ </SelectTrigger>
+ <SelectContent
+ align="start"
+ side="bottom"
+ className="max-h-[250px]"
+ style={{ minWidth: "250px", maxWidth: "350px" }}
+ >
+ {sf.options?.map((opt, optIndex) => (
+ <SelectItem
+ key={`${sf.name}-${opt.value}-${optIndex}`}
+ value={opt.value}
+ title={opt.label}
+ className="whitespace-normal py-2 break-words"
+ >
+ {opt.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ ) : (
+ <Input
+ {...field}
+ className="h-9"
+ placeholder={`${sf.label} ์ž…๋ ฅ...`}
+ />
+ )}
+ </FormControl>
+ {sf.expression && (
+ <p className="text-xs text-muted-foreground mt-1" title={sf.expression}>
+ {sf.expression}
+ </p>
+ )}
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ ))}
+ </div>
+ </div>
+ )
+ }
+
+ // ์ปดํฌ๋„ŒํŠธ ๋ Œ๋”๋ง
+ return (
+ <Sheet {...props}>
+ {/* <SheetContent className="flex flex-col gap-0 sm:max-w-md overflow-y-auto"> */}
+ <SheetContent className="flex flex-col gap-6 sm:max-w-lg overflow-y-auto">
+ <SheetHeader className="text-left">
+ <SheetTitle>ํƒœ๊ทธ ์ˆ˜์ •</SheetTitle>
+ <SheetDescription>
+ ํƒœ๊ทธ ์ •๋ณด๋ฅผ ์—…๋ฐ์ดํŠธํ•˜๊ณ  ๋ณ€๊ฒฝ ์‚ฌํ•ญ์„ ์ €์žฅํ•˜์„ธ์š”
+ </SheetDescription>
+ </SheetHeader>
+
+ <div className="flex-1 overflow-y-auto py-4">
+ <Form {...form}>
+ <form
+ id="update-tag-form"
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="space-y-6"
+ >
+ {/* ๊ธฐ๋ณธ ํƒœ๊ทธ ์ •๋ณด */}
+ <div className="space-y-4">
+ {/* Class */}
+ <FormField
+ control={form.control}
+ name="class"
+ render={({ field }) => renderClassField(field)}
+ />
+
+ {/* Tag Type */}
+ <FormField
+ control={form.control}
+ name="tagType"
+ render={({ field }) => renderTagTypeField(field)}
+ />
+
+ {/* Tag Number */}
+ <FormField
+ control={form.control}
+ name="tagNo"
+ render={({ field }) => renderTagNoField(field)}
+ />
+
+ {/* Description */}
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Description</FormLabel>
+ <FormControl>
+ <Input
+ {...field}
+ placeholder="ํƒœ๊ทธ ์„ค๋ช… ์ž…๋ ฅ..."
+ className="h-9"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* ์„œ๋ธŒํ•„๋“œ */}
+ {renderSubFields()}
+ </form>
+ </Form>
+ </div>
+
+ <SheetFooter className="pt-2">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ ์ทจ์†Œ
+ </Button>
+ </SheetClose>
+ <Button
+ type="submit"
+ form="update-tag-form"
+ disabled={isUpdatePending || isLoadingSubFields}
+ >
+ {isUpdatePending ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
+ ์ €์žฅ ์ค‘...
+ </>
+ ) : (
+ "์ €์žฅ"
+ )}
+ </Button>
+ </SheetFooter>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/tags-plant/validations.ts b/lib/tags-plant/validations.ts
new file mode 100644
index 00000000..65e64f04
--- /dev/null
+++ b/lib/tags-plant/validations.ts
@@ -0,0 +1,68 @@
+// /lib/tags/validations.ts
+import { z } from "zod"
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { Tag } from "@/db/schema/vendorData"
+
+export const createTagSchema = z.object({
+ tagNo: z.string().min(1, "Tag No is required"),
+ tagType: z.string().min(1, "Tag Type is required"),
+ class: z.string().min(1, "Equipment Class is required"),
+ description: z.string().min(1, "Description is required"), // ํ•„์ˆ˜ ํ•„๋“œ๋กœ ๋ณ€๊ฒฝ
+
+ // optional sub-fields for dynamic numbering
+ functionCode: z.string().optional(),
+ seqNumber: z.string().optional(),
+ valveAcronym: z.string().optional(),
+ processUnit: z.string().optional(),
+
+ // If you also want contractItemId:
+ // contractItemId: z.number(),
+})
+
+export const updateTagSchema = z.object({
+ id: z.number().optional(), // ์—…๋ฐ์ดํŠธ ๊ณผ์ •์—์„œ ๋ณ„๋„ ๊ฒ€์ฆ
+ tagNo: z.string().min(1, "Tag Number is required"),
+ class: z.string().min(1, "Class is required"),
+ tagType: z.string().min(1, "Tag Type is required"),
+ description: z.string().optional(),
+ // ์ถ”๊ฐ€ ํ•„๋“œ๋“ค์€ ๋™์ ์œผ๋กœ ์ถ”๊ฐ€๋  ์ˆ˜ ์žˆ์Œ
+ functionCode: z.string().optional(),
+ seqNumber: z.string().optional(),
+ valveAcronym: z.string().optional(),
+ processUnit: z.string().optional(),
+ // ๊ธฐํƒ€ ํ•„๋“œ๋“ค์€ ํ•„์š”์— ๋”ฐ๋ผ ์ถ”๊ฐ€
+})
+
+export type UpdateTagSchema = z.infer<typeof updateTagSchema>
+
+
+export const searchParamsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<Tag>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+ tagNo: parseAsString.withDefault(""),
+ tagType: parseAsString.withDefault(""),
+ description: parseAsString.withDefault(""),
+
+ // advanced filter
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+
+})
+
+export type CreateTagSchema = z.infer<typeof createTagSchema>
+export type GetTagsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
+
diff --git a/lib/tags/table/add-tag-dialog.tsx b/lib/tags/table/add-tag-dialog.tsx
index f3eaed3f..0f701f1e 100644
--- a/lib/tags/table/add-tag-dialog.tsx
+++ b/lib/tags/table/add-tag-dialog.tsx
@@ -465,7 +465,7 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) {
)}
</Button>
</PopoverTrigger>
- <PopoverContent key={popoverContentId} className="w-[300px] p-0">
+ <PopoverContent key={popoverContentId} className="w-[300px] p-0"style={{width:480}}>
<Command key={commandId}>
<CommandInput
key={`${commandId}-input`}
diff --git a/lib/vendor-data-plant/services.ts b/lib/vendor-data-plant/services.ts
new file mode 100644
index 00000000..e8ecd01c
--- /dev/null
+++ b/lib/vendor-data-plant/services.ts
@@ -0,0 +1,112 @@
+"use server";
+
+import db from "@/db/db"
+import { items } from "@/db/schema/items"
+import { projects } from "@/db/schema/projects"
+import { eq } from "drizzle-orm"
+import { contractItems, contracts } from "@/db/schema/contract";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/app/api/auth/[...nextauth]/route";
+
+export interface ProjectWithContracts {
+ projectId: number
+ projectCode: string
+ projectName: string
+ projectType: string
+
+ contracts: {
+ contractId: number
+ contractNo: string
+ contractName: string
+ packages: {
+ itemId: number // contract_items.id
+ itemName: string
+ }[]
+ }[]
+}
+
+export async function getVendorProjectsAndContracts(
+ vendorId?: number
+): Promise<ProjectWithContracts[]> {
+ // ์„ธ์…˜์—์„œ ๋„๋ฉ”์ธ ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ
+ const session = await getServerSession(authOptions)
+
+ // EVCP ๋„๋ฉ”์ธ์ผ ๋•Œ๋งŒ ์ „์ฒด ์กฐํšŒ
+ const isEvcpDomain = session?.user?.domain === "evcp"
+
+ const query = db
+ .select({
+ projectId: projects.id,
+ projectCode: projects.code,
+ projectName: projects.name,
+ projectType: projects.type,
+
+ contractId: contracts.id,
+ contractNo: contracts.contractNo,
+ contractName: contracts.contractName,
+
+ itemId: contractItems.id,
+ itemName: items.itemName,
+ })
+ .from(contracts)
+ .innerJoin(projects, eq(contracts.projectId, projects.id))
+ .innerJoin(contractItems, eq(contractItems.contractId, contracts.id))
+ .innerJoin(items, eq(contractItems.itemId, items.id))
+
+ if (!isEvcpDomain && vendorId) {
+ query.where(eq(contracts.vendorId, vendorId))
+ }
+
+ const rows = await query
+
+ const projectMap = new Map<number, ProjectWithContracts>()
+
+ for (const row of rows) {
+ // 1) ํ”„๋กœ์ ํŠธ ๊ทธ๋ฃน ์ฐพ๊ธฐ
+ let projectEntry = projectMap.get(row.projectId)
+ if (!projectEntry) {
+ // ์ƒˆ ํ”„๋กœ์ ํŠธ ํ•ญ๋ชฉ ์ƒ์„ฑ
+ projectEntry = {
+ projectId: row.projectId,
+ projectCode: row.projectCode,
+ projectName: row.projectName,
+ projectType: row.projectType,
+ contracts: [],
+ }
+ projectMap.set(row.projectId, projectEntry)
+ }
+
+ // 2) ํ”„๋กœ์ ํŠธ ์•ˆ์—์„œ ๊ณ„์•ฝ(contractId) ์ฐพ๊ธฐ
+ let contractEntry = projectEntry.contracts.find(
+ (c) => c.contractId === row.contractId
+ )
+ if (!contractEntry) {
+
+ // ์ƒˆ ๊ณ„์•ฝ ํ•ญ๋ชฉ
+ contractEntry = {
+ contractId: row.contractId,
+ contractNo: row.contractNo,
+ contractName: row.contractName,
+ packages: [],
+ }
+ projectEntry.contracts.push(contractEntry)
+ }
+
+ // 3) ๊ณ„์•ฝ์˜ packages ๋ฐฐ์—ด์— ์•„์ดํ…œ ์ถ”๊ฐ€ (์ค‘๋ณต ์ฒดํฌ)
+ // itemName์ด ๊ฐ™์€ ํ•ญ๋ชฉ์ด ์ด๋ฏธ ์กด์žฌํ•˜๋Š”์ง€ ํ™•์ธ
+ const existingItem = contractEntry.packages.find(
+ (pkg) => pkg.itemName === row.itemName
+ )
+
+ // ๊ฐ™์€ itemName์ด ์—†๋Š” ๊ฒฝ์šฐ์—๋งŒ ์ถ”๊ฐ€
+ if (!existingItem) {
+ contractEntry.packages.push({
+ itemId: row.itemId,
+ itemName: row.itemName,
+ })
+ }
+ }
+
+ return Array.from(projectMap.values())
+}
+