summaryrefslogtreecommitdiff
path: root/lib/tags-plant
diff options
context:
space:
mode:
Diffstat (limited to 'lib/tags-plant')
-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
13 files changed, 5148 insertions, 0 deletions
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>>
+