diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-03-26 00:37:41 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-03-26 00:37:41 +0000 |
| commit | e0dfb55c5457aec489fc084c4567e791b4c65eb1 (patch) | |
| tree | 68543a65d88f5afb3a0202925804103daa91bc6f /lib/tags | |
3/25 까지의 대표님 작업사항
Diffstat (limited to 'lib/tags')
| -rw-r--r-- | lib/tags/form-mapping-service.ts | 65 | ||||
| -rw-r--r-- | lib/tags/repository.ts | 71 | ||||
| -rw-r--r-- | lib/tags/service.ts | 796 | ||||
| -rw-r--r-- | lib/tags/table/add-tag-dialog copy.tsx | 637 | ||||
| -rw-r--r-- | lib/tags/table/add-tag-dialog.tsx | 893 | ||||
| -rw-r--r-- | lib/tags/table/delete-tags-dialog.tsx | 151 | ||||
| -rw-r--r-- | lib/tags/table/feature-flags-provider.tsx | 108 | ||||
| -rw-r--r-- | lib/tags/table/tag-table-column.tsx | 164 | ||||
| -rw-r--r-- | lib/tags/table/tag-table.tsx | 141 | ||||
| -rw-r--r-- | lib/tags/table/tags-export.tsx | 155 | ||||
| -rw-r--r-- | lib/tags/table/tags-table-floating-bar.tsx | 220 | ||||
| -rw-r--r-- | lib/tags/table/tags-table-toolbar-actions.tsx | 598 | ||||
| -rw-r--r-- | lib/tags/table/update-tag-sheet.tsx | 548 | ||||
| -rw-r--r-- | lib/tags/validations.ts | 68 |
14 files changed, 4615 insertions, 0 deletions
diff --git a/lib/tags/form-mapping-service.ts b/lib/tags/form-mapping-service.ts new file mode 100644 index 00000000..4b772ab6 --- /dev/null +++ b/lib/tags/form-mapping-service.ts @@ -0,0 +1,65 @@ +"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; +} + +/** + * 주어진 tagType, classCode로 DB를 조회하여 + * 1) 특정 classCode 매핑 => 존재하면 반환 + * 2) 없으면 DEFAULT 매핑 => 없으면 빈 배열 + */ +export async function getFormMappingsByTagType( + tagType: string, + 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, + }) + .from(tagTypeClassFormMappings) + .where(and( + eq(tagTypeClassFormMappings.tagTypeLabel, tagType), + 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, + }) + .from(tagTypeClassFormMappings) + .where(and( + eq(tagTypeClassFormMappings.tagTypeLabel, tagType), + 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 []; +}
\ No newline at end of file diff --git a/lib/tags/repository.ts b/lib/tags/repository.ts new file mode 100644 index 00000000..b5d48335 --- /dev/null +++ b/lib/tags/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/service.ts b/lib/tags/service.ts new file mode 100644 index 00000000..efba2fd5 --- /dev/null +++ b/lib/tags/service.ts @@ -0,0 +1,796 @@ +"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 } from "drizzle-orm"; +import { countTags, deleteTagById, deleteTagsByIds, insertTag, selectTags } from "./repository"; +import { getErrorMessage } from "../handle-error"; +import { getFormMappingsByTagType } from './form-mapping-service'; +import { contractItems } from "@/db/schema/contract"; + + +// 폼 결과를 위한 인터페이스 정의 +interface CreatedOrExistingForm { + id: number; + formCode: string; + formName: string; + isNewlyCreated: boolean; +} + +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 }) + .from(contractItems) + .where(eq(contractItems.id, selectedPackageId)) + .limit(1) + + if (contractItemResult.length === 0) { + return { error: "Contract item not found" } + } + + const contractId = contractItemResult[0].contractId + + // 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 formMappings = await getFormMappingsByTagType( + validated.data.tagType, + validated.data.class + ) + + // 폼 매핑이 없으면 로그만 남기고 진행 + if (!formMappings || formMappings.length === 0) { + console.log( + "No form mappings found for tag type:", + validated.data.tagType + ) + } + + // 4) 이 태그 타입에 대한 주요 폼(첫 번째 폼)을 찾거나 생성 + let primaryFormId: number | null = null + const createdOrExistingForms: CreatedOrExistingForm[] = [] + + if (formMappings && formMappings.length > 0) { + for (const formMapping of formMappings) { + // 4-1) 이미 존재하는 폼인지 확인 + 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 }) + + 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 + } + } + } + + // 5) 새 Tag 생성 (같은 트랜잭션 `tx` 사용) + const [newTag] = await insertTag(tx, { + contractItemId: selectedPackageId, + formId: primaryFormId, + tagNo: validated.data.tagNo, + class: validated.data.class, + tagType: validated.data.tagType, + description: validated.data.description ?? null, + }) + + console.log(`tags-${selectedPackageId}`, "create", newTag) + + // 6) 캐시 무효화 (React 서버 액션에서 캐싱 사용 시) + revalidateTag(`tags-${selectedPackageId}`) + revalidateTag(`forms-${selectedPackageId}`) + revalidateTag("tags") + + // 7) 성공 시 반환 + return { + success: true, + data: { + forms: createdOrExistingForms, + primaryFormId, + }, + } + }) + } catch (err: any) { + console.error("createTag error:", err) + return { error: getErrorMessage(err) } + } +} + +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 }) + .from(contractItems) + .where(eq(contractItems.id, selectedPackageId)) + .limit(1) + + if (contractItemResult.length === 0) { + return { error: "Contract item not found" } + } + + const contractId = contractItemResult[0].contractId + + // 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, + validated.data.class + ) + + // 폼 매핑이 없으면 로그만 남기고 진행 + if (!formMappings || formMappings.length === 0) { + console.log( + "No form mappings found for tag type:", + validated.data.tagType + ) + } + + // 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 조회 (한 번만) + const contractItemResult = await tx + .select({ contractId: contractItems.contractId }) + .from(contractItems) + .where(eq(contractItems.id, selectedPackageId)) + .limit(1); + + if (contractItemResult.length === 0) { + return { error: "Contract item not found" }; + } + + const contractId = contractItemResult[0].contractId; + + // 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 = []; + + for (const tagData of tagsfromExcel) { + // 각 태그 유형에 대한 폼 처리 (createTag 함수와 유사한 로직) + const formMappings = await getFormMappingsByTagType(tagData.tagType, tagData.class); + let primaryFormId = null; + + // 폼 처리 로직 (생략...) + + // 태그 생성 + 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); + } + + // 4. 캐시 무효화 (한 번만) + revalidateTag(`tags-${selectedPackageId}`); + revalidateTag(`forms-${selectedPackageId}`); + revalidateTag("tags"); + + return { + success: true, + data: { + createdCount: createdTags.length, + tags: createdTags + } + }; + }); + } catch (err: any) { + console.error("bulkCreateTags error:", err); + return { error: err.message || "Failed to create tags" }; + } +} + + +/** 복수 삭제 */ +interface RemoveTagsInput { + ids: number[]; + selectedPackageId: number; +} + + +// formEntries의 data JSON에서 tagNo가 일치하는 객체를 제거해주는 예시 함수 +function removeTagFromDataJson( + dataJson: any, + tagNo: string +): any { + // data 구조가 어떻게 생겼는지에 따라 로직이 달라집니다. + // 예: data 배열 안에 { tagNumber: string, ... } 형태로 여러 객체가 있다고 가정 + if (!Array.isArray(dataJson)) return dataJson + return dataJson.filter((entry) => entry.tagNumber !== tagNo) +} + +export async function removeTags(input: RemoveTagsInput) { + unstable_noStore() // React 서버 액션 무상태 함수 + + const { ids, selectedPackageId } = input + + try { + await db.transaction(async (tx) => { + // 1) 삭제 대상 tag들을 미리 조회 (tagNo, tagType, class 등을 얻기 위함) + 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) 각 tag마다 관련된 formCode를 찾고, forms & formEntries 처리를 수행 + for (const tagInfo of tagsToDelete) { + const { tagNo, tagType, class: tagClass } = tagInfo + + // 2-1) tagTypeClassFormMappings(혹은 대응되는 로직)에서 formCode 목록 가져오기 + const formMappings = await getFormMappingsByTagType(tagType, tagClass) + if (!formMappings) continue + + // 2-2) 얻어온 formCode 리스트를 순회하면서, forms 테이블과 formEntries 테이블 처리 + for (const fm of formMappings) { + // (A) forms 테이블 삭제 + // - 조건: contractItemId=selectedPackageId, formCode=fm.formCode + await tx + .delete(forms) + .where( + and( + eq(forms.contractItemId, selectedPackageId), + eq(forms.formCode, fm.formCode) + ) + ) + + // (B) formEntries 테이블 JSON에서 tagNo 제거 → 업데이트 + // - 예: formEntries 안에 (id, contractItemId, formCode, data(=json)) 칼럼 존재 가정 + const formEntryRecords = await tx + .select({ + id: formEntries.id, + data: formEntries.data, + }) + .from(formEntries) + .where( + and( + eq(formEntries.contractItemId, selectedPackageId), + eq(formEntries.formCode, fm.formCode) + ) + ) + + // 여러 formEntries 레코드가 있을 수도 있어서 모두 처리 + for (const entry of formEntryRecords) { + const updatedJson = removeTagFromDataJson(entry.data, tagNo) + + // 변경이 있다면 업데이트 + await tx + .update(formEntries) + .set({ data: updatedJson }) + .where(eq(formEntries.id, entry.id)) + } + } + } + + // 3) 마지막으로 실제로 tags 테이블에서 Tag들을 삭제 + // (Tag → forms → formEntries 순서대로 처리) + await tx.delete(tags).where(inArray(tags.id, ids)) + }) + + // 4) 캐시 무효화 + // revalidateTag("tags") + 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(){ + const rows = await db + .select({ + id: tagClasses.id, + code: tagClasses.code, + label: tagClasses.label, + tagTypeCode: tagClasses.tagTypeCode, + tagTypeDescription: tagTypes.description, + }) + .from(tagClasses) + .leftJoin(tagTypes, eq(tagTypes.code, tagClasses.tagTypeCode)) + + return rows.map((row) => ({ + code: row.code, + label: row.label, + tagTypeCode: row.tagTypeCode, + tagTypeDescription: row.tagTypeDescription ?? "", + })) +} + +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) { + try { + const rows = await db + .select() + .from(tagSubfields) + .where(eq(tagSubfields.tagTypeCode, tagTypeCode)) + .orderBy(asc(tagSubfields.sortOrder)) + + // 각 row -> SubFieldDef + const formattedSubFields: SubFieldDef[] = [] + for (const sf of rows) { + const subfieldType = await getSubfieldType(sf.attributesId) + const subfieldOptions = subfieldType === "select" + ? await getSubfieldOptions(sf.attributesId) + : [] + + 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): Promise<"select" | "text"> { + const optRows = await db + .select() + .from(tagSubfieldOptions) + .where(eq(tagSubfieldOptions.attributesId, attributesId)) + + 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): Promise<SubfieldOption[]> { + try { + const rows = await db + .select({ + code: tagSubfieldOptions.code, + label: tagSubfieldOptions.label + }) + .from(tagSubfieldOptions) + .where(eq(tagSubfieldOptions.attributesId, attributesId)) + + return rows.map((row) => ({ + value: row.code, + label: row.label + })) + } catch (error) { + console.error(`Error fetching options for attribute ${attributesId}:`, error) + return [] + } +} + + +/** + * 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 값 +}
\ No newline at end of file diff --git a/lib/tags/table/add-tag-dialog copy.tsx b/lib/tags/table/add-tag-dialog copy.tsx new file mode 100644 index 00000000..e9f84933 --- /dev/null +++ b/lib/tags/table/add-tag-dialog copy.tsx @@ -0,0 +1,637 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" // <-- 1) Import router from App Router +import { useForm, useWatch } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { toast } from "sonner" +import { Loader2, ChevronsUpDown, Check } 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 { cn } from "@/lib/utils" + +import type { CreateTagSchema } from "@/lib/tags/validations" +import { createTagSchema } from "@/lib/tags/validations" +import { + createTag, + getSubfieldsByTagType, + getClassOptions, + type ClassOption, + TagTypeOption, +} from "@/lib/tags/service" + +// 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 +} + +interface AddTagDialogProps { + selectedPackageId: number | null +} + +export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) { + const router = useRouter() // <-- 2) Use the router hook + + const [open, setOpen] = React.useState(false) + 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) + 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>>({}) + + // --------------- + // Load Class Options + // --------------- + React.useEffect(() => { + const loadClassOptions = async () => { + setIsLoadingClasses(true) + try { + const result = await getClassOptions() + setClassOptions(result) + } catch (err) { + toast.error("Failed to load class options") + } finally { + setIsLoadingClasses(false) + } + } + + if (open) { + loadClassOptions() + } + }, [open]) + + // --------------- + // react-hook-form + // --------------- + const form = useForm<CreateTagSchema>({ + resolver: zodResolver(createTagSchema), + defaultValues: { + tagType: "", + tagNo: "", + description: "", + functionCode: "", + seqNumber: "", + valveAcronym: "", + processUnit: "", + class: "", + }, + }) + + // watch + const { tagNo, ...fieldsToWatch } = useWatch({ + control: form.control, + }) + + // --------------- + // Load subfields by TagType code + // --------------- + async function loadSubFieldsByTagTypeCode(tagTypeCode: string) { + setIsLoadingSubFields(true) + try { + const { subFields: apiSubFields } = await getSubfieldsByTagType(tagTypeCode) + 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) + selectIdRef.current = 0 + return true + } catch (err) { + toast.error("Failed to load subfields") + setSubFields([]) + return false + } finally { + setIsLoadingSubFields(false) + } + } + + // --------------- + // Handle class selection + // --------------- + async function handleSelectClass(classOption: UpdatedClassOption) { + form.setValue("class", classOption.label) + 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) + } + await loadSubFieldsByTagTypeCode(classOption.tagTypeCode) + } + } + + // --------------- + // Render subfields + // --------------- + function renderSubFields() { + if (isLoadingSubFields) { + return ( + <div className="flex justify-center items-center py-8"> + <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> + <span className="ml-3 text-muted-foreground">Loading fields...</span> + </div> + ) + } + if (subFields.length === 0 && selectedTagTypeCode) { + return ( + <div className="py-4 text-center text-muted-foreground"> + No fields available for this tag type. + </div> + ) + } + if (subFields.length === 0) { + return null + } + + return subFields.map((sf, index) => { + if (!fieldIdsRef.current[`${sf.name}-${index}`]) { + fieldIdsRef.current[`${sf.name}-${index}`] = + `field-${sf.name}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}` + } + const fieldId = fieldIdsRef.current[`${sf.name}-${index}`] + const selectId = getUniqueSelectId() + + return ( + <FormField + key={fieldId} + control={form.control} + name={sf.name as keyof CreateTagSchema} + render={({ field }) => ( + <FormItem> + <FormLabel>{sf.label}</FormLabel> + <FormControl> + {sf.type === "select" ? ( + <Select + value={field.value || ""} + onValueChange={field.onChange} + > + <SelectTrigger className="w-full"> + <SelectValue + placeholder={`Select ${sf.label}`} + className={ + !field.value ? "text-muted-foreground text-opacity-60" : "" + } + /> + </SelectTrigger> + <SelectContent + align="start" + side="bottom" + style={{ width: 400, maxWidth: 400 }} + sideOffset={4} + id={selectId} + > + {sf.options?.map((opt, optIndex) => { + const optionKey = `${fieldId}-option-${opt.value}-${optIndex}` + return ( + <SelectItem + key={optionKey} + value={opt.value} + className="multi-line-select-item pr-6" + title={opt.label} + > + {opt.label} + </SelectItem> + ) + })} + </SelectContent> + </Select> + ) : ( + <Input + placeholder={`Enter ${sf.label}`} + {...field} + className={ + !field.value + ? "placeholder:text-muted-foreground placeholder:text-opacity-60" + : "" + } + /> + )} + </FormControl> + <FormMessage> + {sf.expression && ( + <span + className="text-xs text-muted-foreground truncate block" + title={sf.expression} + > + 형식: {sf.expression} + </span> + )} + </FormMessage> + </FormItem> + )} + /> + ) + }) + } + + // --------------- + // Build TagNo from subfields automatically + // --------------- + React.useEffect(() => { + if (subFields.length === 0) { + form.setValue("tagNo", "", { shouldDirty: false }) + } + + const subscription = form.watch((value, { name }) => { + if (!name || name === "tagNo" || subFields.length === 0) { + return + } + let combined = "" + subFields.forEach((sf, idx) => { + const fieldValue = form.getValues(sf.name as keyof CreateTagSchema) || "" + combined += fieldValue + if (fieldValue && idx < subFields.length - 1 && sf.delimiter) { + combined += sf.delimiter + } + }) + const currentTagNo = form.getValues("tagNo") + if (currentTagNo !== combined) { + form.setValue("tagNo", combined, { + shouldDirty: false, + shouldTouch: false, + shouldValidate: false, + }) + } + }) + + return () => subscription.unsubscribe() + }, [subFields, form]) + + // --------------- + // Basic validation for TagNo + // --------------- + const isTagNoValid = React.useMemo(() => { + const val = form.getValues("tagNo") + return val && val.trim() !== "" && !val.includes("??") + }, [fieldsToWatch]) + + // --------------- + // Submit handler + // --------------- + async function onSubmit(data: CreateTagSchema) { + if (!selectedPackageId) { + toast.error("No selectedPackageId.") + return + } + setIsSubmitting(true) + try { + const res = await createTag(data, selectedPackageId) + if ("error" in res) { + toast.error(`Error: ${res.error}`) + return + } + + toast.success("Tag created successfully!") + + // 3) Refresh or navigate after creation: + // Option A: If you just want to refresh the same route: + router.refresh() + + // Option B: If you want to go to /partners/vendor-data/tag/{selectedPackageId} + // router.push(`/partners/vendor-data/tag/${selectedPackageId}?r=${Date.now()}`) + + // (If you want to reset the form dialog or close it, do that too) + form.reset() + setOpen(false) + } catch (err) { + toast.error("Failed to create tag.") + } finally { + setIsSubmitting(false) + } + } + + // --------------- + // 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" + disabled={isLoadingClasses} + > + {isLoadingClasses ? ( + <> + <span>Loading classes...</span> + <Loader2 className="ml-2 h-4 w-4 animate-spin" /> + </> + ) : ( + <> + <span className="truncate"> + {field.value || "Select Class..."} + </span> + <ChevronsUpDown className="ml-2 h-4 w-4 opacity-50" /> + </> + )} + </Button> + </PopoverTrigger> + <PopoverContent key={popoverContentId} className="w-full p-0"> + <Command key={commandId}> + <CommandInput + key={`${commandId}-input`} + placeholder="Search Class..." + value={classSearchTerm} + onValueChange={setClassSearchTerm} + /> + <CommandList key={`${commandId}-list`}> + <CommandEmpty key={`${commandId}-empty`}>No class found.</CommandEmpty> + <CommandGroup key={`${commandId}-group`}> + {classOptions.map((opt) => { + 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} + 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 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] + ) + + return ( + <FormItem> + <FormLabel>Tag Type</FormLabel> + <FormControl> + {isReadOnly ? ( + <Input + key={`tag-type-readonly-${inputId}`} + {...field} + readOnly + className="bg-muted" + /> + ) : ( + <Input + key={`tag-type-placeholder-${inputId}`} + {...field} + readOnly + placeholder="Tag Type is determined by selected Class" + className="bg-muted" + /> + )} + </FormControl> + <FormMessage /> + </FormItem> + ) + } + + // --------------- + // 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() + setSelectedTagTypeCode(null) + setSubFields([]) + } + setOpen(o) + }} + > + <DialogTrigger asChild> + <Button variant="default" size="sm"> + Add Tag + </Button> + </DialogTrigger> + + <DialogContent className="max-h-[80vh] flex flex-col"> + <DialogHeader> + <DialogTitle>Add New Tag</DialogTitle> + <DialogDescription> + Choose a Class, and the Tag Type and subfields will be automatically loaded. + </DialogDescription> + </DialogHeader> + + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="max-h-[70vh] flex flex-col" + > + <div className="flex-1 overflow-auto px-4 space-y-4"> + {/* Class */} + <FormField + key="class-field" + control={form.control} + name="class" + render={({ field }) => renderClassField(field)} + /> + + {/* TagType (read-only) */} + <FormField + key="tag-type-field" + control={form.control} + name="tagType" + render={({ field }) => renderTagTypeField(field)} + /> + + {/* SubFields */} + <div className="flex-1 overflow-auto px-2 py-2 space-y-4 max-h-[300px]"> + {renderSubFields()} + </div> + + {/* TagNo (read-only) */} + <FormField + key="tag-no-field" + control={form.control} + name="tagNo" + render={({ field }) => ( + <FormItem> + <FormLabel>Tag No</FormLabel> + <FormControl> + <Input + {...field} + readOnly + className="bg-muted truncate" + title={field.value || ""} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Description */} + <FormField + key="description-field" + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>Description</FormLabel> + <FormControl> + <Input + {...field} + placeholder="Enter description..." + className="truncate" + title={field.value || ""} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + {/* Footer */} + <DialogFooter className="bg-background z-10 pt-4 px-4 py-4"> + <Button + type="button" + variant="outline" + onClick={() => { + form.reset() + setOpen(false) + setSubFields([]) + setSelectedTagTypeCode(null) + }} + disabled={isSubmitting || isLoadingSubFields} + > + Cancel + </Button> + <Button + type="submit" + disabled={isSubmitting || isLoadingSubFields || !isTagNoValid} + > + {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + Create + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/tags/table/add-tag-dialog.tsx b/lib/tags/table/add-tag-dialog.tsx new file mode 100644 index 00000000..3814761d --- /dev/null +++ b/lib/tags/table/add-tag-dialog.tsx @@ -0,0 +1,893 @@ +"use client" + +import * as React from "react" +import { useRouter } 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 type { CreateTagSchema } from "@/lib/tags/validations" +import { createTagSchema } from "@/lib/tags/validations" +import { + createTag, + getSubfieldsByTagType, + getClassOptions, + type ClassOption, + TagTypeOption, +} from "@/lib/tags/service" + +// Updated to support multiple rows +interface MultiTagFormValues { + class: string; + tagType: 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 +} + +interface AddTagDialogProps { + selectedPackageId: number | null +} + +export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) { + const router = useRouter() + + const [open, setOpen] = React.useState(false) + 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) + 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(subFields) + + // --------------- + // Load Class Options + // --------------- + React.useEffect(() => { + const loadClassOptions = async () => { + setIsLoadingClasses(true) + try { + const result = await getClassOptions() + setClassOptions(result) + } catch (err) { + toast.error("클래스 옵션을 불러오는데 실패했습니다.") + } finally { + setIsLoadingClasses(false) + } + } + + if (open) { + loadClassOptions() + } + }, [open]) + + // --------------- + // react-hook-form with fieldArray support for multiple rows + // --------------- + const form = useForm<MultiTagFormValues>({ + defaultValues: { + tagType: "", + class: "", + rows: [{ + tagNo: "", + description: "" + }] + }, + }) + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "rows" + }) + + // --------------- + // Load subfields by TagType code + // --------------- + async function loadSubFieldsByTagTypeCode(tagTypeCode: string) { + setIsLoadingSubFields(true) + try { + const { subFields: apiSubFields } = await getSubfieldsByTagType(tagTypeCode) + 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("서브필드를 불러오는데 실패했습니다.") + setSubFields([]) + return false + } finally { + setIsLoadingSubFields(false) + } + } + + // --------------- + // Handle class selection + // --------------- + async function handleSelectClass(classOption: UpdatedClassOption) { + form.setValue("class", classOption.label) + 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) + } + await loadSubFieldsByTagTypeCode(classOption.tagTypeCode) + } + } + + // --------------- + // 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] || ""; + combined += fieldValue; + if (fieldValue && idx < subFields.length - 1 && sf.delimiter) { + combined += sf.delimiter; + } + }); + + const currentTagNo = form.getValues(`rows.${rowIndex}.tagNo`); + if (currentTagNo !== combined) { + form.setValue(`rows.${rowIndex}.tagNo`, combined, { + shouldDirty: true, // Changed from false to true + shouldTouch: true, // Changed from false to true + shouldValidate: true, // Changed from false to 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()]); // Watch the entire form to catch all changes + // --------------- + // Submit handler for multiple tags + // --------------- + async function onSubmit(data: MultiTagFormValues) { + if (!selectedPackageId) { + toast.error("No selectedPackageId."); + 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 + const tagData: CreateTagSchema = { + tagType: data.tagType, + class: data.class, + 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) { + 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}개의 태그가 성공적으로 생성되었습니다!`); + } + + if (failedTags.length > 0) { + toast.error(`${failedTags.length}개의 태그 생성에 실패했습니다.`); + console.error("Failed tags:", failedTags); + } + + // 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("태그 생성 처리에 실패했습니다."); + } finally { + setIsSubmitting(false); + } + } + + // --------------- + // Add a new row + // --------------- + function addRow() { + // Create a properly typed row with index signature to allow dynamic properties + const newRow: { + tagNo: string; + description: string; + [key: string]: string; // This allows any string key with string values + } = { + tagNo: "", + description: "" + }; + + // Add all subfields with empty values + subFields.forEach(field => { + newRow[field.name] = ""; + }); + + append(newRow); + + // Force form validation after row is added + setTimeout(() => form.trigger(), 0); + } + + // --------------- + // Duplicate row + // --------------- + function duplicateRow(index: number) { + const rowToDuplicate = form.getValues(`rows.${index}`); + // Use proper typing with index signature + const newRow: { + tagNo: string; + description: string; + [key: string]: string; + } = { ...rowToDuplicate }; + + // Clear the tagNo field as it will be auto-generated + newRow.tagNo = ""; + append(newRow); + + // Force form validation after row is duplicated + 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/2"> + <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 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] + ) + + return ( + <FormItem className="w-1/2"> + <FormLabel>Tag Type</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="클래스 선택시 자동으로 결정됩니다" + 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">필드 로딩 중...</div> + </div> + ) + } + + if (subFields.length === 0 && selectedTagTypeCode) { + return ( + <div className="py-4 text-center text-muted-foreground"> + 이 태그 유형에 대한 필드가 없습니다. + </div> + ) + } + + if (subFields.length === 0) { + return ( + <div className="py-4 text-center text-muted-foreground"> + 태그 데이터를 입력하려면 먼저 상단에서 클래스를 선택하세요. + </div> + ) + } + + return ( + <div className="space-y-4"> + {/* 헤더 */} + <div className="flex justify-between items-center"> + <h3 className="text-sm font-medium">태그 항목 ({fields.length}개)</h3> + {!areAllTagNosValid && ( + <Badge variant="destructive" className="ml-2"> + 유효하지 않은 태그 존재 + </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">Tag No</div> + </TableHead> + <TableHead className="w-[180px]"> + <div className="font-medium">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">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="항목 이름 입력" + 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={`선택...`} 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={`입력...`} + title={field.value || ""} + /> + )} + </FormControl> + {/* <FormMessage>{sf.expression}</FormMessage> */} + </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>행 복제</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>행 삭제</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} + > + <Plus className="h-4 w-4 mr-2" /> + 새 행 추가 + </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: "", + rows: [{ tagNo: "", description: "" }] + }); + setSelectedTagTypeCode(null); + setSubFields([]); + } + setOpen(o); + }} + > + <DialogTrigger asChild> + <Button variant="default" size="sm"> + 태그 추가 + </Button> + </DialogTrigger> + + <DialogContent className="max-h-[90vh] max-w-[95vw]" style={{ width: 1500 }}> + <DialogHeader> + <DialogTitle>새 태그 추가</DialogTitle> + <DialogDescription> + 클래스를 선택하여 태그 유형과 하위 필드를 로드한 다음, 여러 행을 추가하여 여러 태그를 생성하세요. + </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="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: "", + rows: [{ tagNo: "", description: "" }] + }); + setOpen(false); + setSubFields([]); + setSelectedTagTypeCode(null); + }} + disabled={isSubmitting} + > + 취소 + </Button> + <Button + type="submit" + disabled={isSubmitting || !areAllTagNosValid || fields.length < 1} + > + {isSubmitting ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 처리 중... + </> + ) : ( + `${fields.length}개 태그 생성` + )} + </Button> + </div> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/tags/table/delete-tags-dialog.tsx b/lib/tags/table/delete-tags-dialog.tsx new file mode 100644 index 00000000..6a024cda --- /dev/null +++ b/lib/tags/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/table/feature-flags-provider.tsx b/lib/tags/table/feature-flags-provider.tsx new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/tags/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/table/tag-table-column.tsx b/lib/tags/table/tag-table-column.tsx new file mode 100644 index 00000000..47746000 --- /dev/null +++ b/lib/tags/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), + 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), + 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/table/tag-table.tsx b/lib/tags/table/tag-table.tsx new file mode 100644 index 00000000..5c8c048f --- /dev/null +++ b/lib/tags/table/tag-table.tsx @@ -0,0 +1,141 @@ +"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" + +// 여기서 받은 `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 [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", + + }) + + return ( + <> + <DataTable + table={table} + floatingBar={<TagsTableFloatingBar table={table} selectedPackageId={selectedPackageId}/>} + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + {/* + 4) ToolbarActions에 tableData, setTableData 넘겨서 + import 시 상태 병합 + */} + <TagsTableToolbarActions + table={table} + selectedPackageId={selectedPackageId} + tableData={data} // <-- pass current data + /> + </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/table/tags-export.tsx b/lib/tags/table/tags-export.tsx new file mode 100644 index 00000000..4afbac6c --- /dev/null +++ b/lib/tags/table/tags-export.tsx @@ -0,0 +1,155 @@ +"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>, + { + 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() + + // 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/table/tags-table-floating-bar.tsx b/lib/tags/table/tags-table-floating-bar.tsx new file mode 100644 index 00000000..8d55b7ac --- /dev/null +++ b/lib/tags/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/table/tags-table-toolbar-actions.tsx b/lib/tags/table/tags-table-toolbar-actions.tsx new file mode 100644 index 00000000..8d53d3f3 --- /dev/null +++ b/lib/tags/table/tags-table-toolbar-actions.tsx @@ -0,0 +1,598 @@ +"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 } 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, getSubfieldsByTagType } from "../service" +import { DeleteTagsDialog } from "./delete-tags-dialog" + +// 태그 번호 검증을 위한 인터페이스 +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 */ +} + +/** + * TagsTableToolbarActions: + * - Import 버튼 -> Excel 파일 파싱 & 유효성 검사 (Class 기반 검증 추가) + * - 에러 발생 시: state는 그대로 두고, 오류가 적힌 엑셀만 재다운로드 + * - 정상인 경우: tableData에 병합 + * - Export 버튼 -> 유효성 검사가 포함된 Excel 내보내기 + */ +export function TagsTableToolbarActions({ + table, + selectedPackageId, + tableData, +}: TagsTableToolbarActionsProps) { + const [isPending, setIsPending] = React.useState(false) + const [isExporting, setIsExporting] = React.useState(false) + const fileInputRef = React.useRef<HTMLInputElement>(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() + setClassOptions(options) + } catch (error) { + console.error("Failed to load class options:", error) + } + } + + loadClassOptions() + }, []) + + // 숨겨진 <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]) + + // 특정 attributesId에 대한 옵션 가져오기 + const fetchOptions = React.useCallback(async (attributesId: string): Promise<TagOption[]> => { + // 이미 캐시에 있으면 캐시된 값 사용 + if (tagOptionsCache[attributesId]) { + return tagOptionsCache[attributesId] + } + + try { + const options = await fetchTagSubfieldOptions(attributesId) + + // 캐시에 저장 + setTagOptionsCache(prev => ({ + ...prev, + [attributesId]: options + })) + + return options + } catch (error) { + console.error(`Error fetching options for ${attributesId}:`, error) + return [] + } + }, [tagOptionsCache]) + + // 클래스 라벨로 태그 타입 코드 찾기 + 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) + + // 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) { + const regex = new RegExp(`^${field.expression}$`) + if (!regex.test(part)) { + return `Part '${part}' for field '${field.label}' does not match the pattern '${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() + 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, { + filename: `Tags_${selectedPackageId}`, + excludeColumns: ["select", "actions", "createdAt", "updatedAt"], + }) + + toast.success("태그 목록이 성공적으로 내보내졌습니다.") + } catch (error) { + console.error("Export error:", error) + toast.error("태그 목록 내보내기 중 오류가 발생했습니다.") + } finally { + setIsExporting(false) + } + } + + 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} + + + <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/table/update-tag-sheet.tsx b/lib/tags/table/update-tag-sheet.tsx new file mode 100644 index 00000000..27a1bdcb --- /dev/null +++ b/lib/tags/table/update-tag-sheet.tsx @@ -0,0 +1,548 @@ +"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>>({}) + + console.log(tag) + + // Load class options when sheet opens + React.useEffect(() => { + const loadClassOptions = async () => { + if (!props.open || !tag) return + + setIsLoadingClasses(true) + try { + const result = await getClassOptions() + 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) + 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/validations.ts b/lib/tags/validations.ts new file mode 100644 index 00000000..65e64f04 --- /dev/null +++ b/lib/tags/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>> + |
