diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-01 13:52:21 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-01 13:52:21 +0000 |
| commit | bac0228d21b7195065e9cddcc327ae33659c7bcc (patch) | |
| tree | 8f3016ae4533c8706d0c00a605d9b1d41968c2bc /lib | |
| parent | 2fdce8d7a57c792bba0ac36fa554dca9c9cc31e3 (diff) | |
(대표님) 20250601까지 작업사항
Diffstat (limited to 'lib')
42 files changed, 4402 insertions, 646 deletions
diff --git a/lib/data-table.ts b/lib/data-table.ts index 4fed7b9b..4ad57d76 100644 --- a/lib/data-table.ts +++ b/lib/data-table.ts @@ -17,41 +17,60 @@ import { FilterFn, Row } from "@tanstack/react-table" * @param options.withBorder - Whether to show a box shadow between pinned and scrollable columns. * @returns A React.CSSProperties object containing the calculated styles. */ -export function getCommonPinningStyles<TData>({ +export function getCommonPinningStylesWithBorder<TData>({ column, - withBorder = false, + withBorder = true, }: { column: Column<TData> - /** - * Show box shadow between pinned and scrollable columns. - * @default false - */ withBorder?: boolean }): React.CSSProperties { - const isPinned = column.getIsPinned() - const isLastLeftPinnedColumn = - isPinned === "left" && column.getIsLastColumn("left") - const isFirstRightPinnedColumn = - isPinned === "right" && column.getIsFirstColumn("right") - - return { - boxShadow: withBorder - ? isLastLeftPinnedColumn - ? "-4px 0 4px -4px hsl(var(--border)) inset" - : isFirstRightPinnedColumn - ? "4px 0 4px -4px hsl(var(--border)) inset" - : undefined - : undefined, - left: isPinned === "left" ? `${column.getStart("left")}px` : undefined, - right: isPinned === "right" ? `${column.getAfter("right")}px` : undefined, - opacity: isPinned ? 0.97 : 1, - position: isPinned ? "sticky" : "relative", - background: isPinned ? "hsl(var(--background))" : "hsl(var(--background))", + const pinnedSide = column.getIsPinned() as "left" | "right" | false + + // 안전한 방식으로 위치 확인 + const isLastLeft = pinnedSide === "left" && column.getIsLastColumn?.("left") + const isFirstRight = pinnedSide === "right" && column.getIsFirstColumn?.("right") + + const styles: React.CSSProperties = { + /* ▒▒ sticky 위치 ▒▒ */ + position: pinnedSide ? "sticky" : "relative", + left: pinnedSide === "left" ? `${column.getStart("left")}px` : undefined, + right: pinnedSide === "right" ? `${column.getAfter("right")}px` : undefined, + + /* ▒▒ 기타 스타일 ▒▒ */ width: column.getSize(), - zIndex: isPinned ? 1 : 0, + // 불투명한 배경색 설정 - 테이블의 배경색과 동일하게 + background: pinnedSide ? "hsl(var(--background))" : "transparent", + zIndex: pinnedSide ? 1 : 0, } + + // 구분선 및 그림자 추가 (선택사항) + // if (withBorder && pinnedSide) { + // if (pinnedSide === "left" && isLastLeft) { + // // 좌측 핀의 마지막 컬럼에만 우측 그림자 추가 + // styles.boxShadow = "2px 0 4px -2px rgba(0, 0, 0, 0.1)" + // styles.borderRight = "1px solid hsl(var(--border))" + // } else if (pinnedSide === "right" && isFirstRight) { + // // 우측 핀의 첫 컬럼에만 좌측 그림자 추가 + // styles.boxShadow = "-2px 0 4px -2px rgba(0, 0, 0, 0.1)" + // styles.borderLeft = "1px solid hsl(var(--border))" + // } + // } + + return styles } +// 디버깅을 위한 헬퍼 함수 +export function debugPinningInfo<TData>(column: Column<TData>) { + console.log("Column Debug Info:", { + id: column.id, + isPinned: column.getIsPinned(), + start: column.getStart("left"), + after: column.getAfter("right"), + size: column.getSize(), + isLastLeft: column.getIsLastColumn?.("left"), + isFirstRight: column.getIsFirstColumn?.("right"), + }) +} /** * Determine the default filter operator for a given column type. * diff --git a/lib/equip-class/service.ts b/lib/equip-class/service.ts index 91b165f4..1b373740 100644 --- a/lib/equip-class/service.ts +++ b/lib/equip-class/service.ts @@ -11,8 +11,8 @@ import { countTagClassLists, selectTagClassLists } from "./repository"; import { projects } from "@/db/schema"; export async function getTagClassists(input: GetTagClassesSchema) { - return unstable_cache( - async () => { + // return unstable_cache( + // async () => { try { const offset = (input.page - 1) * input.perPage; @@ -86,11 +86,11 @@ export async function getTagClassists(input: GetTagClassesSchema) { // 에러 발생 시 디폴트 return { data: [], pageCount: 0 }; } - }, - [JSON.stringify(input)], // 캐싱 키 - { - revalidate: 3600, - tags: ["equip-class"], // revalidateTag("items") 호출 시 무효화 - } - )(); + // }, + // [JSON.stringify(input)], // 캐싱 키 + // { + // revalidate: 3600, + // tags: ["equip-class"], // revalidateTag("items") 호출 시 무효화 + // } + // )(); }
\ No newline at end of file diff --git a/lib/form-list.zip b/lib/form-list.zip Binary files differnew file mode 100644 index 00000000..7312729e --- /dev/null +++ b/lib/form-list.zip diff --git a/lib/form-list/service.ts b/lib/form-list/service.ts index d49dc5fc..10dfd640 100644 --- a/lib/form-list/service.ts +++ b/lib/form-list/service.ts @@ -11,8 +11,8 @@ import { countFormLists, selectFormLists } from "./repository"; import { projects } from "@/db/schema"; export async function getFormLists(input: GetFormListsSchema) { - return unstable_cache( - async () => { + // return unstable_cache( + // async () => { try { const offset = (input.page - 1) * input.perPage; const advancedTable = true; @@ -33,6 +33,7 @@ export async function getFormLists(input: GetFormListsSchema) { ilike(formListsView.formName, s), ilike(formListsView.tagTypeLabel, s), ilike(formListsView.classLabel, s), + ilike(formListsView.remark, s), ilike(formListsView.projectName, s), // 뷰 테이블의 projectName 사용 ilike(formListsView.projectCode, s), // 뷰 테이블의 projectCode 사용 ); @@ -81,11 +82,11 @@ export async function getFormLists(input: GetFormListsSchema) { // 에러 발생 시 디폴트 return { data: [], pageCount: 0 }; } - }, - [JSON.stringify(input)], // 캐싱 키 - { - revalidate: 3600, - tags: ["form-lists"], - } - )(); + // }, + // [JSON.stringify(input)], // 캐싱 키 + // { + // revalidate: 3600, + // tags: ["form-lists"], + // } + // )(); }
\ No newline at end of file diff --git a/lib/form-list/table/formLists-table.tsx b/lib/form-list/table/formLists-table.tsx index a9a56338..ebe59063 100644 --- a/lib/form-list/table/formLists-table.tsx +++ b/lib/form-list/table/formLists-table.tsx @@ -28,9 +28,26 @@ interface ItemsTableProps { export function FormListsTable({ promises }: ItemsTableProps) { const { featureFlags } = useFeatureFlags() - - const [{ data, pageCount }] = - React.use(promises) + + // 1. 데이터 로딩 상태 관리 추가 + const [isLoading, setIsLoading] = React.useState(true) + const [tableData, setTableData] = React.useState<{ + data: FormListsView[] + pageCount: number + }>({ data: [], pageCount: 0 }) + + // 2. Promise 해결을 useEffect로 처리 + React.useEffect(() => { + promises + .then(([result]) => { + setTableData(result) + setIsLoading(false) + }) + // .catch((error) => { + // console.error('Failed to load table data:', error) + // setIsLoading(false) + // }) + }, [promises]) const [rowAction, setRowAction] = React.useState<DataTableRowAction<FormListsView> | null>(null) @@ -40,32 +57,8 @@ export function FormListsTable({ promises }: ItemsTableProps) { [setRowAction] ) - /** - * This component can render either a faceted filter or a search filter based on the `options` prop. - * - * @prop options - An array of objects, each representing a filter option. If provided, a faceted filter is rendered. If not, a search filter is rendered. - * - * Each `option` object has the following properties: - * @prop {string} label - The label for the filter option. - * @prop {string} value - The value for the filter option. - * @prop {React.ReactNode} [icon] - An optional icon to display next to the label. - * @prop {boolean} [withCount] - An optional boolean to display the count of the filter option. - */ - const filterFields: DataTableFilterField<FormListsView>[] = [ + const filterFields: DataTableFilterField<FormListsView>[] = [] - - ] - - /** - * Advanced filter fields for the data table. - * These fields provide more complex filtering options compared to the regular filterFields. - * - * Key differences from regular filterFields: - * 1. More field types: Includes 'text', 'multi-select', 'date', and 'boolean'. - * 2. Enhanced flexibility: Allows for more precise and varied filtering options. - * 3. Used with DataTableAdvancedToolbar: Enables a more sophisticated filtering UI. - * 4. Date and boolean types: Adds support for filtering by date ranges and boolean values. - */ const advancedFilterFields: DataTableAdvancedFilterField<FormListsView>[] = [ { id: "projectCode", @@ -91,40 +84,39 @@ export function FormListsTable({ promises }: ItemsTableProps) { id: "tagTypeLabel", label: "TAG TYPE ID", type: "text", - }, { id: "classLabel", label: "Class Description", type: "text", - + }, + { + id: "ep", + label: "EP", + type: "text", }, { id: "remark", label: "remark", type: "text", - }, - { id: "createdAt", label: "Created At", type: "date", - }, { id: "updatedAt", label: "Updated At", type: "date", - }, ] - + // 3. 로딩 중이거나 데이터가 없을 때 처리 const { table } = useDataTable({ - data, + data: tableData.data, columns, - pageCount, + pageCount: tableData.pageCount, filterFields, enablePinning: true, enableAdvancedFilter: true, @@ -137,13 +129,19 @@ export function FormListsTable({ promises }: ItemsTableProps) { clearOnDefault: true, }) + // 4. 로딩 상태 표시 + if (isLoading) { + return ( + <div className="flex items-center justify-center h-32"> + <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div> + <span className="ml-2">Loading...</span> + </div> + ) + } + return ( <> - <DataTable - table={table} - - > - + <DataTable table={table}> <DataTableAdvancedToolbar table={table} filterFields={advancedFilterFields} @@ -151,14 +149,12 @@ export function FormListsTable({ promises }: ItemsTableProps) { > <FormListsTableToolbarActions table={table} /> </DataTableAdvancedToolbar> - </DataTable> <ViewMetas open={rowAction?.type === "items"} onOpenChange={() => setRowAction(null)} form={rowAction?.row.original ?? null} /> - </> ) -} +}
\ No newline at end of file diff --git a/lib/forms/services.ts b/lib/forms/services.ts index 0fbe68a6..b6e479a2 100644 --- a/lib/forms/services.ts +++ b/lib/forms/services.ts @@ -45,8 +45,8 @@ export async function getFormsByContractItemId( const cacheKey = `forms-${contractItemId}-${mode}`; try { - return unstable_cache( - async () => { + // return unstable_cache( + // async () => { console.log( `[Forms Service] Fetching forms for contractItemId: ${contractItemId}, mode: ${mode}` ); @@ -86,14 +86,14 @@ export async function getFormsByContractItemId( ); throw error; // 캐시 함수에서 에러를 던져 캐싱이 발생하지 않도록 함 } - }, - [cacheKey], - { - // 캐시 시간 단축 - revalidate: 60, // 1분으로 줄임 - tags: [cacheKey], - } - )(); + // }, + // [cacheKey], + // { + // // 캐시 시간 단축 + // revalidate: 60, // 1분으로 줄임 + // tags: [cacheKey], + // } + // )(); } catch (error) { getErrorMessage( `Cache operation failed for contractItemId ${contractItemId}, mode: ${mode}: ${error}` @@ -231,8 +231,6 @@ export async function getFormData(formCode: string, contractItemId: number) { console.log(cacheKey, "getFormData") try { - const result = await unstable_cache( - async () => { // 기존 로직으로 projectId, columns, data 가져오기 const contractItemResult = await db .select({ @@ -297,6 +295,14 @@ export async function getFormData(formCode: string, contractItemId: number) { } }); + // status 컬럼 추가 + columns.push({ + key: "status", + label: "status", + displayLabel: "Status", + type: "STRING" + }); + let data: Array<Record<string, any>> = []; if (entry) { if (Array.isArray(entry.data)) { @@ -310,15 +316,8 @@ export async function getFormData(formCode: string, contractItemId: number) { const editableFieldsMap = await getEditableFieldsByTag(contractItemId, projectId); return { columns, data, editableFieldsMap }; - }, - [cacheKey], - { - revalidate: 60, - tags: [cacheKey], - } - )(); + - return result; } catch (cacheError) { console.error(`[getFormData] Cache operation failed:`, cacheError); @@ -681,12 +680,13 @@ export async function updateFormDataInDB( }; } - // 5) 병합 + // 5) 병합 (status 필드 추가) const oldItem = dataArray[idx]; const updatedItem = { ...oldItem, ...newData, TAG_NO: oldItem.TAG_NO, // TAG_NO 변경 불가 시 유지 + status: "Imported from EXCEL" // Excel에서 가져온 데이터임을 표시 }; const updatedArray = [...dataArray]; @@ -750,7 +750,6 @@ export async function updateFormDataInDB( }; } } - // FormColumn Type (동일) export interface FormColumn { key: string; @@ -1305,7 +1304,8 @@ export async function sendDataToSEDP( */ export async function sendFormDataToSEDP( formCode: string, - projectId: number, + projectId: number, + contractItemId: number, // contractItemId 파라미터 추가 formData: GenericData[], columns: DataTableColumnJSON[] ): Promise<{ success: boolean; message: string; data?: any }> { @@ -1347,18 +1347,74 @@ export async function sendFormDataToSEDP( console.warn(`No mapping found for formCode ${formCode} in project ${projectId}, using default object code`); } - // 4. Transform data to SEDP format + // 3. Transform data to SEDP format const sedpData = await transformFormDataToSEDP( - formData, - columns, + formData, + columns, formCode, objectCode, projectCode ); - // 5. Send to SEDP API + // 4. Send to SEDP API const result = await sendDataToSEDP(projectCode, sedpData); + // 5. SEDP 전송 성공 후 formEntries에 status 업데이트 + try { + // Get the current formEntries data + const entries = await db + .select() + .from(formEntries) + .where( + and( + eq(formEntries.formCode, formCode), + eq(formEntries.contractItemId, contractItemId) + ) + ) + .limit(1); + + if (entries && entries.length > 0) { + const entry = entries[0]; + const dataArray = entry.data as Array<Record<string, any>>; + + if (Array.isArray(dataArray)) { + // Extract TAG_NO list from formData + const sentTagNumbers = new Set( + formData + .map(item => item.TAG_NO) + .filter(tagNo => tagNo) // Remove null/undefined values + ); + + // Update status for sent tags + const updatedDataArray = dataArray.map(item => { + if (item.TAG_NO && sentTagNumbers.has(item.TAG_NO)) { + return { + ...item, + status: "Sent to S-EDP" // SEDP로 전송된 데이터임을 표시 + }; + } + return item; + }); + + // Update the database + await db + .update(formEntries) + .set({ + data: updatedDataArray, + updatedAt: new Date() + }) + .where(eq(formEntries.id, entry.id)); + + console.log(`Updated status for ${sentTagNumbers.size} tags to "Sent to S-EDP"`); + } + } else { + console.warn(`No formEntries found for formCode: ${formCode}, contractItemId: ${contractItemId}`); + } + } catch (statusUpdateError) { + // Status 업데이트 실패는 경고로만 처리 (SEDP 전송은 성공했으므로) + console.warn("Failed to update status after SEDP send:", statusUpdateError); + } + return { success: true, message: "Data successfully sent to SEDP", @@ -1371,4 +1427,129 @@ export async function sendFormDataToSEDP( message: error.message || "Failed to send data to SEDP" }; } +} + + +export async function deleteFormDataByTags({ + formCode, + contractItemId, + tagNos, +}: { + formCode: string + contractItemId: number + tagNos: string[] +}): Promise<{ + error?: string + success?: boolean + deletedCount?: number + deletedTagsCount?: number +}> { + try { + // 입력 검증 + if (!formCode || !contractItemId || !Array.isArray(tagNos) || tagNos.length === 0) { + return { + error: "Missing required parameters: formCode, contractItemId, tagNos", + } + } + + console.log(`[DELETE ACTION] Deleting tags for formCode: ${formCode}, contractItemId: ${contractItemId}, tagNos:`, tagNos) + + // 트랜잭션으로 안전하게 처리 + const result = await db.transaction(async (tx) => { + // 1. 현재 formEntry 데이터 가져오기 + const currentEntryResult = await tx + .select() + .from(formEntries) + .where( + and( + eq(formEntries.formCode, formCode), + eq(formEntries.contractItemId, contractItemId) + ) + ) + .orderBy(desc(formEntries.updatedAt)) + .limit(1) + + if (currentEntryResult.length === 0) { + throw new Error("Form entry not found") + } + + const currentEntry = currentEntryResult[0] + let currentData = Array.isArray(currentEntry.data) ? currentEntry.data : [] + + console.log(`[DELETE ACTION] Current data count: ${currentData.length}`) + + // 2. 삭제할 항목들 필터링 (formEntries에서) + const updatedData = currentData.filter((item: any) => + !tagNos.includes(item.TAG_NO) + ) + + const deletedFromFormEntries = currentData.length - updatedData.length + + console.log(`[DELETE ACTION] Updated data count: ${updatedData.length}`) + console.log(`[DELETE ACTION] Deleted ${deletedFromFormEntries} items from formEntries`) + + if (deletedFromFormEntries === 0) { + throw new Error("No items were found to delete in formEntries") + } + + // 3. tags 테이블에서 해당 태그들 삭제 + const deletedTagsResult = await tx + .delete(tags) + .where( + and( + eq(tags.contractItemId, contractItemId), + inArray(tags.tagNo, tagNos) + ) + ) + .returning({ tagNo: tags.tagNo }) + + const deletedTagsCount = deletedTagsResult.length + + console.log(`[DELETE ACTION] Deleted ${deletedTagsCount} items from tags table`) + console.log(`[DELETE ACTION] Deleted tag numbers:`, deletedTagsResult.map(t => t.tagNo)) + + // 4. formEntries 데이터 업데이트 + await tx + .update(formEntries) + .set({ + data: updatedData, + updatedAt: new Date(), + }) + .where( + and( + eq(formEntries.formCode, formCode), + eq(formEntries.contractItemId, contractItemId) + ) + ) + + return { + deletedFromFormEntries, + deletedTagsCount, + deletedTagNumbers: deletedTagsResult.map(t => t.tagNo) + } + }) + + // 5. 캐시 무효화 + const cacheKey = `form-data-${formCode}-${contractItemId}` + revalidateTag(cacheKey) + revalidateTag(`tags-${contractItemId}`) + + // 페이지 재검증 (필요한 경우) + + console.log(`[DELETE ACTION] Transaction completed successfully`) + console.log(`[DELETE ACTION] FormEntries deleted: ${result.deletedFromFormEntries}`) + console.log(`[DELETE ACTION] Tags deleted: ${result.deletedTagsCount}`) + + return { + success: true, + deletedCount: result.deletedFromFormEntries, + deletedTagsCount: result.deletedTagsCount, + } + + } catch (error) { + console.error("[DELETE ACTION] Error deleting form data:", error) + return { + error: error instanceof Error ? error.message : "An unexpected error occurred", + } + } }
\ No newline at end of file diff --git a/lib/items-tech/service.ts b/lib/items-tech/service.ts index be985adb..158fce13 100644 --- a/lib/items-tech/service.ts +++ b/lib/items-tech/service.ts @@ -1076,3 +1076,228 @@ export async function getAllOffshoreItems(): Promise<(ItemOffshoreHull | ItemOff throw new Error("Failed to get items");
}
}
+
+
+// -----------------------------------------------------------
+// 기술영업을 위한 로직
+// -----------------------------------------------------------
+
+// 조선 공종 타입
+export type WorkType = '기장' | '전장' | '선실' | '배관' | '철의'
+
+// 조선 아이템 with 공종 정보
+export interface ShipbuildingItem {
+ id: number
+ itemCode: string
+ itemName: string
+ description: string | null
+ workType: WorkType
+ itemList: string | null // 실제 아이템명
+ shipTypes: string
+ createdAt: Date
+ updatedAt: Date
+}
+
+// 공종별 아이템 조회
+export async function getShipbuildingItemsByWorkType(workType?: WorkType, shipType?: string) {
+ try {
+ const query = db
+ .select({
+ id: itemShipbuilding.id,
+ itemCode: itemShipbuilding.itemCode,
+ itemName: items.itemName,
+ description: items.description,
+ workType: itemShipbuilding.workType,
+ itemList: itemShipbuilding.itemList,
+ shipTypes: itemShipbuilding.shipTypes,
+ createdAt: itemShipbuilding.createdAt,
+ updatedAt: itemShipbuilding.updatedAt,
+ })
+ .from(itemShipbuilding)
+ .leftJoin(items, eq(itemShipbuilding.itemCode, items.itemCode))
+
+ const conditions = []
+ if (workType) {
+ conditions.push(eq(itemShipbuilding.workType, workType))
+ }
+ if (shipType) {
+ conditions.push(eq(itemShipbuilding.shipTypes, shipType))
+ }
+
+ if (conditions.length > 0) {
+ query.where(and(...conditions))
+ }
+
+ const result = await query
+
+ return {
+ data: result as ShipbuildingItem[],
+ error: null
+ }
+ } catch (error) {
+ console.error("조선 아이템 조회 오류:", error)
+ return {
+ data: null,
+ error: error instanceof Error ? error.message : "알 수 없는 오류"
+ }
+ }
+}
+
+// 아이템 검색
+export async function searchShipbuildingItems(searchQuery: string, workType?: WorkType, shipType?: string) {
+ try {
+ const searchConditions = [
+ ilike(itemShipbuilding.itemCode, `%${searchQuery}%`),
+ ilike(items.itemName, `%${searchQuery}%`),
+ ilike(items.description, `%${searchQuery}%`),
+ ilike(itemShipbuilding.itemList, `%${searchQuery}%`)
+ ]
+
+ let whereCondition = or(...searchConditions)
+
+ const filterConditions = []
+ if (workType) {
+ filterConditions.push(eq(itemShipbuilding.workType, workType))
+ }
+ if (shipType) {
+ filterConditions.push(eq(itemShipbuilding.shipTypes, shipType))
+ }
+
+ if (filterConditions.length > 0) {
+ whereCondition = and(
+ and(...filterConditions),
+ or(...searchConditions)
+ )
+ }
+
+ const result = await db
+ .select({
+ id: itemShipbuilding.id,
+ itemCode: itemShipbuilding.itemCode,
+ itemName: items.itemName,
+ description: items.description,
+ workType: itemShipbuilding.workType,
+ itemList: itemShipbuilding.itemList,
+ shipTypes: itemShipbuilding.shipTypes,
+ createdAt: itemShipbuilding.createdAt,
+ updatedAt: itemShipbuilding.updatedAt,
+ })
+ .from(itemShipbuilding)
+ .leftJoin(items, eq(itemShipbuilding.itemCode, items.itemCode))
+ .where(whereCondition)
+
+ return {
+ data: result as ShipbuildingItem[],
+ error: null
+ }
+ } catch (error) {
+ console.error("조선 아이템 검색 오류:", error)
+ return {
+ data: null,
+ error: error instanceof Error ? error.message : "알 수 없는 오류"
+ }
+ }
+}
+
+// 모든 공종 목록 조회
+export async function getWorkTypes() {
+ return [
+ { code: '기장' as WorkType, name: '기장', description: '기계 장치' },
+ { code: '전장' as WorkType, name: '전장', description: '전기 장치' },
+ { code: '선실' as WorkType, name: '선실', description: '선실' },
+ { code: '배관' as WorkType, name: '배관', description: '배관' },
+ { code: '철의' as WorkType, name: '철의', description: '선체 강재' },
+ ]
+}
+
+// 특정 아이템 코드들로 아이템 조회
+export async function getShipbuildingItemsByCodes(itemCodes: string[]) {
+ try {
+ const result = await db
+ .select({
+ id: itemShipbuilding.id,
+ itemCode: itemShipbuilding.itemCode,
+ itemName: items.itemName,
+ description: items.description,
+ workType: itemShipbuilding.workType,
+ itemList: itemShipbuilding.itemList,
+ shipTypes: itemShipbuilding.shipTypes,
+ createdAt: itemShipbuilding.createdAt,
+ updatedAt: itemShipbuilding.updatedAt,
+ })
+ .from(itemShipbuilding)
+ .leftJoin(items, eq(itemShipbuilding.itemCode, items.itemCode))
+ .where(
+ or(...itemCodes.map(code => eq(itemShipbuilding.itemCode, code)))
+ )
+
+ return {
+ data: result as ShipbuildingItem[],
+ error: null
+ }
+ } catch (error) {
+ console.error("조선 아이템 코드별 조회 오류:", error)
+ return {
+ data: null,
+ error: error instanceof Error ? error.message : "알 수 없는 오류"
+ }
+ }
+}
+
+// 전체 조선 아이템 조회 (캐싱용)
+export async function getAllShipbuildingItemsForCache() {
+ try {
+ const result = await db
+ .select({
+ id: itemShipbuilding.id,
+ itemCode: itemShipbuilding.itemCode,
+ itemName: items.itemName,
+ description: items.description,
+ workType: itemShipbuilding.workType,
+ itemList: itemShipbuilding.itemList,
+ shipTypes: itemShipbuilding.shipTypes,
+ createdAt: itemShipbuilding.createdAt,
+ updatedAt: itemShipbuilding.updatedAt,
+ })
+ .from(itemShipbuilding)
+ .leftJoin(items, eq(itemShipbuilding.itemCode, items.itemCode))
+
+ return {
+ data: result as ShipbuildingItem[],
+ error: null
+ }
+ } catch (error) {
+ console.error("전체 조선 아이템 조회 오류:", error)
+ return {
+ data: null,
+ error: error instanceof Error ? error.message : "알 수 없는 오류"
+ }
+ }
+}
+
+// 선종 목록 가져오기
+export async function getShipTypes() {
+ try {
+ const result = await db
+ .selectDistinct({
+ shipTypes: itemShipbuilding.shipTypes
+ })
+ .from(itemShipbuilding)
+ .orderBy(itemShipbuilding.shipTypes)
+
+ return {
+ data: result.map(item => item.shipTypes),
+ error: null
+ }
+ } catch (error) {
+ console.error("선종 목록 조회 오류:", error)
+ return {
+ data: null,
+ error: error instanceof Error ? error.message : "알 수 없는 오류"
+ }
+ }
+}
+
+// -----------------------------------------------------------
+// 기술영업을 위한 로직 끝
+// -----------------------------------------------------------
\ No newline at end of file diff --git a/lib/items/service.ts b/lib/items/service.ts index 35d2fa01..c841efad 100644 --- a/lib/items/service.ts +++ b/lib/items/service.ts @@ -45,12 +45,9 @@ export async function getItems(input: GetItemsSchema) { ilike(items.itemLevel, s), ilike(items.itemCode, s), ilike(items.itemName, s), + ilike(items.smCode, s), + ilike(items.packageCode, s), ilike(items.description, s), - ilike(items.parentItemCode, s), - ilike(items.unitOfMeasure, s), - ilike(items.steelType, s), - ilike(items.gradeMaterial, s), - ilike(items.baseUnitOfMeasure, s), ilike(items.changeDate, s) ); } @@ -75,6 +72,8 @@ export async function getItems(input: GetItemsSchema) { return { data, total }; }); + console.log(data) + const pageCount = Math.ceil(total / safePerPage); return { data, pageCount }; } catch (err) { diff --git a/lib/items/table/items-table-toolbar-actions.tsx b/lib/items/table/items-table-toolbar-actions.tsx index b3178ce1..7d8f7fb6 100644 --- a/lib/items/table/items-table-toolbar-actions.tsx +++ b/lib/items/table/items-table-toolbar-actions.tsx @@ -2,9 +2,10 @@ import * as React from "react" import { type Table } from "@tanstack/react-table" -import { Download, FileDown } from "lucide-react" +import { Download, FileDown, Database, Loader2 } from "lucide-react" import * as ExcelJS from 'exceljs' import { saveAs } from "file-saver" +import { toast } from "sonner" import { Button } from "@/components/ui/button" import { @@ -19,6 +20,9 @@ import { DeleteItemsDialog } from "./delete-items-dialog" import { AddItemDialog } from "./add-items-dialog" import { exportItemTemplate } from "./item-excel-template" import { ImportItemButton } from "./import-excel-button" +import { syncItemsFromCodeLists } from "@/lib/sedp/sync-package" + +// 동기화 함수 import (실제 경로로 수정 필요) interface ItemsTableToolbarActionsProps { table: Table<Item> @@ -26,12 +30,47 @@ interface ItemsTableToolbarActionsProps { export function ItemsTableToolbarActions({ table }: ItemsTableToolbarActionsProps) { const [refreshKey, setRefreshKey] = React.useState(0) + const [isSyncing, setIsSyncing] = React.useState(false) // 가져오기 성공 후 테이블 갱신 const handleImportSuccess = () => { setRefreshKey(prev => prev + 1) } + // 테이블 새로고침 함수 + const refreshTable = () => { + setRefreshKey(prev => prev + 1) + // 페이지 새로고침 또는 데이터 refetch 로직 + window.location.reload() + } + + // 전체 프로젝트 동기화 + const handleSyncAllProjects = async () => { + if (isSyncing) return + + setIsSyncing(true) + const loadingToast = toast.loading("모든 프로젝트의 아이템을 동기화하는 중...") + + try { + await syncItemsFromCodeLists() + toast.dismiss(loadingToast) + toast.success("모든 프로젝트의 아이템이 성공적으로 동기화되었습니다!") + + // 테이블 새로고침 + setTimeout(() => { + refreshTable() + }, 1000) + } catch (error) { + toast.dismiss(loadingToast) + toast.error("동기화 중 오류가 발생했습니다: " + (error as Error).message) + console.error("동기화 오류:", error) + } finally { + setIsSyncing(false) + } + } + + + // Excel 내보내기 함수 const exportTableToExcel = async ( table: Table<any>, @@ -125,10 +164,28 @@ export function ItemsTableToolbarActions({ table }: ItemsTableToolbarActionsProp ) : null} {/* 새 아이템 추가 다이얼로그 */} - <AddItemDialog /> + {/* <AddItemDialog /> */} {/* Import 버튼 */} - <ImportItemButton onSuccess={handleImportSuccess} /> + {/* <ImportItemButton onSuccess={handleImportSuccess} /> */} + + {/* 동기화 버튼 */} + <Button + variant="outline" + size="sm" + className="gap-2" + disabled={isSyncing} + onClick={handleSyncAllProjects} + > + {isSyncing ? ( + <Loader2 className="size-4 animate-spin" aria-hidden="true" /> + ) : ( + <Database className="size-4" aria-hidden="true" /> + )} + <span className="hidden sm:inline"> + {isSyncing ? "동기화 중..." : "동기화"} + </span> + </Button> {/* Export 드롭다운 메뉴 */} <DropdownMenu> diff --git a/lib/items/table/items-table.tsx b/lib/items/table/items-table.tsx index c05b4348..92f805eb 100644 --- a/lib/items/table/items-table.tsx +++ b/lib/items/table/items-table.tsx @@ -22,6 +22,7 @@ import { getColumns } from "./items-table-columns" import { ItemsTableToolbarActions } from "./items-table-toolbar-actions" import { UpdateItemSheet } from "./update-item-sheet" import { DeleteItemsDialog } from "./delete-items-dialog" +import { ViewModeToggle } from "@/components/data-table/view-mode-toggle" interface ItemsTableProps { promises?: Promise< @@ -71,7 +72,7 @@ export function ItemsTable({ promises }: ItemsTableProps) { id: "itemName", label: "자재그룹이름", type: "text", - }, + }, { id: "description", label: "상세", @@ -115,13 +116,13 @@ export function ItemsTable({ promises }: ItemsTableProps) { ] // 확장된 useDataTable 훅 사용 (pageSize 기반 자동 전환) - const { - table, - infiniteScroll, - isInfiniteMode, + const { + table, + infiniteScroll, + isInfiniteMode, effectivePageSize, handlePageSizeChange, - urlState + urlState } = useDataTable({ data, columns, @@ -154,98 +155,92 @@ export function ItemsTable({ promises }: ItemsTableProps) { } return ( - <div className="w-full space-y-2.5"> - - {/* <div className="flex items-center justify-between"> - <div className="flex items-center gap-2"> - <Button - variant="outline" - size="sm" - onClick={handleRefresh} - disabled={isInfiniteMode && infiniteScroll?.isLoading} - > - <RotateCcw className="h-4 w-4 mr-2" /> - 새로고침 - </Button> + <div className="w-full space-y-2.5 overflow-x-auto" style={{maxWidth:'100wv'}}> + + {/* 모드 토글 & 새로고침 */} + <div className="flex items-center justify-between"> + <ViewModeToggle + isInfiniteMode={isInfiniteMode} + onSwitch={handlePageSizeChange} // ← ViewModeToggle에 prop 추가 + /> </div> - </div> */} - - {/* 에러 상태 (무한 스크롤 모드) */} - {isInfiniteMode && infiniteScroll?.error && ( - <Alert variant="destructive"> - <AlertDescription> - 데이터를 불러오는 중 오류가 발생했습니다. - <Button - variant="link" - size="sm" - onClick={() => infiniteScroll.reset()} - className="ml-2 p-0 h-auto" - > - 다시 시도 - </Button> - </AlertDescription> - </Alert> - )} - - {/* 로딩 상태가 아닐 때만 테이블 렌더링 */} - {!(isInfiniteMode && infiniteScroll?.isLoading && infiniteScroll?.isEmpty) ? ( - <> - {/* 도구 모음 */} - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} - > - <ItemsTableToolbarActions table={table} /> - </DataTableAdvancedToolbar> - - {/* 테이블 렌더링 */} - {isInfiniteMode ? ( - // 무한 스크롤 모드: InfiniteDataTable 사용 (자체 페이지네이션 없음) - <InfiniteDataTable - table={table} - hasNextPage={infiniteScroll?.hasNextPage || false} - isLoadingMore={infiniteScroll?.isLoadingMore || false} - onLoadMore={infiniteScroll?.loadMore} - totalCount={infiniteScroll?.totalCount} - isEmpty={infiniteScroll?.isEmpty || false} - compact={false} - autoSizeColumns={true} - /> - ) : ( - // 페이지네이션 모드: DataTable 사용 (내장 페이지네이션 활용) - <DataTable + + {/* 에러 상태 (무한 스크롤 모드) */} + {isInfiniteMode && infiniteScroll?.error && ( + <Alert variant="destructive"> + <AlertDescription> + 데이터를 불러오는 중 오류가 발생했습니다. + <Button + variant="link" + size="sm" + onClick={() => infiniteScroll.reset()} + className="ml-2 p-0 h-auto" + > + 다시 시도 + </Button> + </AlertDescription> + </Alert> + )} + + {/* 로딩 상태가 아닐 때만 테이블 렌더링 */} + {!(isInfiniteMode && infiniteScroll?.isLoading && infiniteScroll?.isEmpty) ? ( + <> + {/* 도구 모음 */} + <DataTableAdvancedToolbar table={table} - compact={false} - autoSizeColumns={true} - /> - )} - </> - ) : ( - /* 로딩 스켈레톤 (무한 스크롤 초기 로딩) */ - <div className="space-y-3"> - <div className="text-sm text-muted-foreground mb-4"> - 무한 스크롤 모드로 데이터를 로드하고 있습니다... + filterFields={advancedFilterFields} + shallow={false} + > + <ItemsTableToolbarActions table={table} /> + </DataTableAdvancedToolbar> + + {/* 테이블 렌더링 */} + {isInfiniteMode ? ( + // 무한 스크롤 모드: InfiniteDataTable 사용 (자체 페이지네이션 없음) + <InfiniteDataTable + table={table} + hasNextPage={infiniteScroll?.hasNextPage || false} + isLoadingMore={infiniteScroll?.isLoadingMore || false} + onLoadMore={infiniteScroll?.onLoadMore} + totalCount={infiniteScroll?.totalCount} + isEmpty={infiniteScroll?.isEmpty || false} + compact={false} + autoSizeColumns={true} + /> + ) : ( + // 페이지네이션 모드: DataTable 사용 (내장 페이지네이션 활용) + <DataTable + table={table} + compact={false} + autoSizeColumns={true} + /> + )} + </> + ) : ( + /* 로딩 스켈레톤 (무한 스크롤 초기 로딩) */ + <div className="space-y-3"> + <div className="text-sm text-muted-foreground mb-4"> + 무한 스크롤 모드로 데이터를 로드하고 있습니다... + </div> + {Array.from({ length: 10 }).map((_, i) => ( + <div key={i} className="h-12 w-full bg-muted animate-pulse rounded" /> + ))} </div> - {Array.from({ length: 10 }).map((_, i) => ( - <div key={i} className="h-12 w-full bg-muted animate-pulse rounded" /> - ))} - </div> - )} - - {/* 기존 다이얼로그들 */} - <UpdateItemSheet - open={rowAction?.type === "update"} - onOpenChange={() => setRowAction(null)} - item={rowAction?.row.original ?? null} - /> - <DeleteItemsDialog - open={rowAction?.type === "delete"} - onOpenChange={() => setRowAction(null)} - items={rowAction?.row.original ? [rowAction?.row.original] : []} - showTrigger={false} - onSuccess={() => rowAction?.row.toggleSelected(false)} - /> - </div> - ) + )} + + {/* 기존 다이얼로그들 */} + <UpdateItemSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + item={rowAction?.row.original ?? null} + /> + <DeleteItemsDialog + open={rowAction?.type === "delete"} + onOpenChange={() => setRowAction(null)} + items={rowAction?.row.original ? [rowAction?.row.original] : []} + showTrigger={false} + onSuccess={() => rowAction?.row.toggleSelected(false)} + /> + </div> + ) }
\ No newline at end of file diff --git a/lib/procurement-rfqs/services.ts b/lib/procurement-rfqs/services.ts index 32048768..3816605c 100644 --- a/lib/procurement-rfqs/services.ts +++ b/lib/procurement-rfqs/services.ts @@ -721,6 +721,8 @@ export async function sendRfq(rfqId: number) { rfqSendDate: true, remark: true, rfqSealedYn: true, + itemCode: true, + itemName: true, }, with: { project: { @@ -730,13 +732,6 @@ export async function sendRfq(rfqId: number) { name: true, } }, - item: { - columns: { - id: true, - itemCode: true, - itemName: true, - } - }, createdByUser: { columns: { id: true, @@ -1654,11 +1649,7 @@ export async function getVendorQuotations(input: GetQuotationsSchema, vendorId: offset, limit: perPage, with: { - rfq: { - with: { - item: true, // 여기서 item 정보도 가져옴 - } - }, + rfq:true, vendor: true, } }); @@ -1669,7 +1660,6 @@ export async function getVendorQuotations(input: GetQuotationsSchema, vendorId: .where(finalWhere || undefined) .then(rows => rows[0]); - console.log(totalCount) // 페이지 수 계산 const pageCount = Math.ceil(Number(totalCount) / perPage); @@ -1894,7 +1884,8 @@ export async function fetchExternalRfqs() { rfqCode, projectId: randomProject.id, series:seriesValue, - itemId: randomItem.id, + itemCode: randomItem.itemCode || `ITEM-${Math.floor(Math.random() * 1000)}`, // itemId 대신 itemCode 사용 + itemName: randomItem.itemName || `임의 아이템 ${Math.floor(Math.random() * 100)}`, // itemName 추가 dueDate, rfqSendDate: null, // null로 설정/ status: "RFQ Created", @@ -1932,7 +1923,7 @@ export async function fetchExternalRfqs() { rfqItem: `RFQI-${Math.floor(Math.random() * 1000)}`, prItem: `PRI-${Math.floor(Math.random() * 1000)}`, prNo: `PRN-${Math.floor(Math.random() * 1000)}`, - itemId: randomItem.id, + // itemId: randomItem.id, materialCode, materialCategory: "Standard", acc: `ACC-${Math.floor(Math.random() * 100)}`, diff --git a/lib/procurement-rfqs/table/detail-table/rfq-detail-column.tsx b/lib/procurement-rfqs/table/detail-table/rfq-detail-column.tsx index ce5e7767..bc257202 100644 --- a/lib/procurement-rfqs/table/detail-table/rfq-detail-column.tsx +++ b/lib/procurement-rfqs/table/detail-table/rfq-detail-column.tsx @@ -17,7 +17,7 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Ellipsis, MessageCircle } from "lucide-react"; +import { Ellipsis, MessageCircle, ExternalLink } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -116,7 +116,31 @@ export function getRfqDetailColumns({ header: ({ column }) => ( <DataTableColumnHeaderSimple column={column} title="벤더명" /> ), - cell: ({ row }) => <div>{row.getValue("vendorName")}</div>, + cell: ({ row }) => { + const vendorName = row.getValue("vendorName") as string; + const vendorId = row.original.vendorId; + + if (!vendorName || !vendorId) { + return <div>{vendorName}</div>; + } + + const handleVendorClick = () => { + window.open(`/evcp/vendors/${vendorId}/info`, '_blank'); + }; + + return ( + <Button + variant="link" + className="h-auto p-0 text-left justify-start font-normal text-foreground underline-offset-4 hover:underline" + onClick={handleVendorClick} + > + <span className="flex items-center gap-1"> + {vendorName} + {/* <ExternalLink className="h-3 w-3 opacity-50" /> */} + </span> + </Button> + ); + }, meta: { excelHeader: "벤더명" }, diff --git a/lib/procurement-rfqs/table/detail-table/update-vendor-sheet.tsx b/lib/procurement-rfqs/table/detail-table/update-vendor-sheet.tsx index 45e4a602..edc04788 100644 --- a/lib/procurement-rfqs/table/detail-table/update-vendor-sheet.tsx +++ b/lib/procurement-rfqs/table/detail-table/update-vendor-sheet.tsx @@ -415,7 +415,7 @@ export function UpdateRfqDetailSheet({ /> </FormControl> <div className="space-y-1 leading-none"> - <FormLabel>자재 가격 관련 여부</FormLabel> + <FormLabel>하도급 대금 연동 여부</FormLabel> </div> </FormItem> )} diff --git a/lib/rfqs-tech/service.ts b/lib/rfqs-tech/service.ts index cd4aeaf7..26012491 100644 --- a/lib/rfqs-tech/service.ts +++ b/lib/rfqs-tech/service.ts @@ -2210,7 +2210,7 @@ export async function uploadTbeResponseFile(formData: FormData) { const fileName = `${originalName.split(".")[0]}-${timestamp}.${fileExtension}` // 업로드 디렉토리 및 경로 정의 - const uploadDir = join(process.cwd(), "rfq", "tbe-responses") + const uploadDir = join(process.cwd(), "public", "rfq", "tbe-responses") // 디렉토리가 없으면 생성 try { @@ -2242,7 +2242,7 @@ export async function uploadTbeResponseFile(formData: FormData) { const technicalResponseId = technicalResponse[0].id; // 파일 정보를 데이터베이스에 저장 - const dbFilePath = `/rfq/tbe-responses/${fileName}` + const dbFilePath = `rfq/tbe-responses/${fileName}` // vendorResponseAttachments 테이블 스키마에 맞게 데이터 삽입 await db.insert(vendorResponseAttachments) diff --git a/lib/sedp/get-form-tags.ts b/lib/sedp/get-form-tags.ts index 6911180b..4e819414 100644 --- a/lib/sedp/get-form-tags.ts +++ b/lib/sedp/get-form-tags.ts @@ -1,4 +1,3 @@ -// lib/sedp/get-tag.ts import db from "@/db/db"; import { contractItems, @@ -14,6 +13,8 @@ import { } from "@/db/schema"; import { eq, and, like, inArray } from "drizzle-orm"; import { getSEDPToken } from "./sedp-token"; +import { getFormMappingsByTagType } from "../tags/form-mapping-service"; + interface Attribute { ATT_ID: string; @@ -42,12 +43,7 @@ interface Column { /** * 태그 가져오기 서비스 함수 * contractItemId(packageId)를 기반으로 외부 시스템에서 태그 데이터를 가져와 DB에 저장 - * - * @param formCode 양식 코드 - * @param projectCode 프로젝트 코드 - * @param packageId 계약 아이템 ID (contractItemId) - * @param progressCallback 진행 상황을 보고하기 위한 콜백 함수 - * @returns 처리 결과 정보 (처리된 태그 수, 오류 목록 등) + * formEntries와 tags 테이블 모두에 데이터를 저장 */ export async function importTagsFromSEDP( formCode: string, @@ -58,6 +54,7 @@ export async function importTagsFromSEDP( processedCount: number; excludedCount: number; totalEntries: number; + formCreated?: boolean; // 새로 추가: form이 생성되었는지 여부 errors?: string[]; }> { try { @@ -89,243 +86,568 @@ export async function importTagsFromSEDP( // 진행 상황 보고 if (progressCallback) progressCallback(20); - // 프로젝트 ID 가져오기 - const projectRecord = await db.select({ id: projects.id }) - .from(projects) - .where(eq(projects.code, projectCode)) - .limit(1); - - if (!projectRecord || projectRecord.length === 0) { - throw new Error(`Project not found for code: ${projectCode}`); - } - - const projectId = projectRecord[0].id; - - // 양식 메타데이터 가져오기 - const formMetaRecord = await db.select({ columns: formMetas.columns }) - .from(formMetas) - .where(and( - eq(formMetas.projectId, projectId), - eq(formMetas.formCode, formCode) - )) - .limit(1); - - if (!formMetaRecord || formMetaRecord.length === 0) { - throw new Error(`Form metadata not found for formCode: ${formCode} and projectId: ${projectId}`); - } - - // 진행 상황 보고 - if (progressCallback) progressCallback(30); - - // 컬럼 정보 파싱 - const columnsJSON: Column[] = (formMetaRecord[0].columns); - - // 현재 formEntries 데이터 가져오기 - const existingEntries = await db.select({ id: formEntries.id, data: formEntries.data }) - .from(formEntries) - .where(and( - eq(formEntries.formCode, formCode), - eq(formEntries.contractItemId, packageId) - )); - - // 진행 상황 보고 - if (progressCallback) progressCallback(50); - - // 기존 데이터를 맵으로 변환하여 태그 번호로 빠르게 조회할 수 있게 함 - const existingTagMap = new Map(); - existingEntries.forEach(entry => { - const data = entry.data as any[]; - data.forEach(item => { - if (item.TAG_NO) { - existingTagMap.set(item.TAG_NO, { - entryId: entry.id, - data: item - }); - } - }); - }); - - // 진행 상황 보고 - if (progressCallback) progressCallback(60); - - // 처리 결과 카운터 - let processedCount = 0; - let excludedCount = 0; - - // 새로운 태그 데이터와 업데이트할 데이터 준비 - const newTagData: any[] = []; - const updateData: {entryId: number, tagNo: string, updates: any}[] = []; - - // SEDP 태그 데이터 처리 - for (const tagEntry of tagEntries) { - try { - if (!tagEntry.TAG_NO) { - excludedCount++; - errors.push(`Missing TAG_NO in tag entry`); - continue; + // 트랜잭션으로 모든 DB 작업 처리 + return await db.transaction(async (tx) => { + // 프로젝트 ID 가져오기 + const projectRecord = await tx.select({ id: projects.id }) + .from(projects) + .where(eq(projects.code, projectCode)) + .limit(1); + + if (!projectRecord || projectRecord.length === 0) { + throw new Error(`Project not found for code: ${projectCode}`); + } + + const projectId = projectRecord[0].id; + + // form ID 가져오기 - 없으면 생성 + let formRecord = await tx.select({ id: forms.id }) + .from(forms) + .where(and( + eq(forms.formCode, formCode), + eq(forms.contractItemId, packageId) + )) + .limit(1); + + let formCreated = false; + + // form이 없으면 생성 + if (!formRecord || formRecord.length === 0) { + console.log(`[IMPORT TAGS] Form ${formCode} not found, attempting to create...`); + + // 첫 번째 태그의 정보를 사용해서 form mapping을 찾습니다 + // 모든 태그가 같은 formCode를 사용한다고 가정 + if (tagEntries.length > 0) { + const firstTag = tagEntries[0]; + + // tagType 조회 (TAG_TYPE_ID -> description) + let tagTypeDescription = firstTag.TAG_TYPE_ID; // 기본값 + if (firstTag.TAG_TYPE_ID) { + const tagTypeRecord = await tx.select({ description: tagTypes.description }) + .from(tagTypes) + .where(and( + eq(tagTypes.code, firstTag.TAG_TYPE_ID), + eq(tagTypes.projectId, projectId) + )) + .limit(1); + + if (tagTypeRecord && tagTypeRecord.length > 0) { + tagTypeDescription = tagTypeRecord[0].description; + } + } + + // tagClass 조회 (CLS_ID -> label) + let tagClassLabel = firstTag.CLS_ID; // 기본값 + if (firstTag.CLS_ID) { + const tagClassRecord = await tx.select({ label: tagClasses.label }) + .from(tagClasses) + .where(and( + eq(tagClasses.code, firstTag.CLS_ID), + eq(tagClasses.projectId, projectId) + )) + .limit(1); + + if (tagClassRecord && tagClassRecord.length > 0) { + tagClassLabel = tagClassRecord[0].label; + } + } + + // 태그 타입에 따른 폼 정보 가져오기 + const allFormMappings = await getFormMappingsByTagType( + tagTypeDescription, + projectId, + tagClassLabel + ); + + // ep가 "IMEP"인 것만 필터링 + const formMappings = allFormMappings?.filter(mapping => mapping.ep === "IMEP") || []; + + // 현재 formCode와 일치하는 매핑 찾기 + const targetFormMapping = formMappings.find(mapping => mapping.formCode === formCode); + + if (targetFormMapping) { + console.log(`[IMPORT TAGS] Found IMEP form mapping for ${formCode}, creating form...`); + + // form 생성 + const insertResult = await tx + .insert(forms) + .values({ + contractItemId: packageId, + formCode: targetFormMapping.formCode, + formName: targetFormMapping.formName, + eng: true, // ENG 모드에서 가져오는 것이므로 eng: true + im: targetFormMapping.ep === "IMEP" ? true:false + }) + .returning({ id: forms.id }); + + formRecord = insertResult; + formCreated = true; + + console.log(`[IMPORT TAGS] Successfully created form:`, insertResult[0]); + } else { + console.log(`[IMPORT TAGS] No IMEP form mapping found for formCode: ${formCode}`); + console.log(`[IMPORT TAGS] Available IMEP mappings:`, formMappings.map(m => m.formCode)); + throw new Error(`Form ${formCode} not found and no IMEP mapping available for tag type ${tagTypeDescription}`); + } + } else { + throw new Error(`Form not found for formCode: ${formCode} and contractItemId: ${packageId}, and no tags to derive form mapping`); } + } else { + console.log(`[IMPORT TAGS] Found existing form:`, formRecord[0].id); - // 기본 태그 데이터 객체 생성 - const tagObject: any = { - TAG_NO: tagEntry.TAG_NO, - TAG_DESC: tagEntry.TAG_DESC || "" - }; + // 기존 form이 있는 경우 eng와 im 필드를 체크하고 업데이트 + const existingForm = await tx.select({ + eng: forms.eng, + im: forms.im + }) + .from(forms) + .where(eq(forms.id, formRecord[0].id)) + .limit(1); - // ATTRIBUTES 필드에서 shi=true인 컬럼의 값 추출 - if (Array.isArray(tagEntry.ATTRIBUTES)) { - for (const attr of tagEntry.ATTRIBUTES) { - // 해당 어트리뷰트가 양식 메타에 있는지 확인 - const columnInfo = columnsJSON.find(col => col.key === attr.ATT_ID); - if (columnInfo) { - // shi가 true인 컬럼이거나 필수 컬럼만 처리 - if (columnInfo.shi === true) { - // 값 타입에 따른 변환 + if (existingForm.length > 0) { + // form mapping 정보 가져오기 (im 필드 업데이트를 위해) + let shouldUpdateIm = false; + let targetImValue = false; + + // 첫 번째 태그의 정보를 사용해서 form mapping을 확인 + if (tagEntries.length > 0) { + const firstTag = tagEntries[0]; + + // tagType 조회 + let tagTypeDescription = firstTag.TAG_TYPE_ID; + if (firstTag.TAG_TYPE_ID) { + const tagTypeRecord = await tx.select({ description: tagTypes.description }) + .from(tagTypes) + .where(and( + eq(tagTypes.code, firstTag.TAG_TYPE_ID), + eq(tagTypes.projectId, projectId) + )) + .limit(1); + + if (tagTypeRecord && tagTypeRecord.length > 0) { + tagTypeDescription = tagTypeRecord[0].description; + } + } + + // tagClass 조회 + let tagClassLabel = firstTag.CLS_ID; + if (firstTag.CLS_ID) { + const tagClassRecord = await tx.select({ label: tagClasses.label }) + .from(tagClasses) + .where(and( + eq(tagClasses.code, firstTag.CLS_ID), + eq(tagClasses.projectId, projectId) + )) + .limit(1); + + if (tagClassRecord && tagClassRecord.length > 0) { + tagClassLabel = tagClassRecord[0].label; + } + } + + // form mapping 정보 가져오기 + const allFormMappings = await getFormMappingsByTagType( + tagTypeDescription, + projectId, + tagClassLabel + ); + + // ep가 "IMEP"인 것만 필터링 + const formMappings = allFormMappings?.filter(mapping => mapping.ep === "IMEP") || []; + + // 현재 formCode와 일치하는 매핑 찾기 + const targetFormMapping = formMappings.find(mapping => mapping.formCode === formCode); + + if (targetFormMapping) { + targetImValue = targetFormMapping.ep === "IMEP"; + shouldUpdateIm = existingForm[0].im !== targetImValue; + } + } + + // 업데이트할 필드들 준비 + const updates: any = {}; + let hasUpdates = false; + + // eng 필드 체크 + if (existingForm[0].eng !== true) { + updates.eng = true; + hasUpdates = true; + } + + // im 필드 체크 + if (shouldUpdateIm) { + updates.im = targetImValue; + hasUpdates = true; + } + + // 업데이트 실행 + if (hasUpdates) { + await tx + .update(forms) + .set(updates) + .where(eq(forms.id, formRecord[0].id)); + + console.log(`[IMPORT TAGS] Form ${formRecord[0].id} updated with:`, updates); + } + } + } + + const formId = formRecord[0].id; + + // 양식 메타데이터 가져오기 + const formMetaRecord = await tx.select({ columns: formMetas.columns }) + .from(formMetas) + .where(and( + eq(formMetas.projectId, projectId), + eq(formMetas.formCode, formCode) + )) + .limit(1); + + if (!formMetaRecord || formMetaRecord.length === 0) { + throw new Error(`Form metadata not found for formCode: ${formCode} and projectId: ${projectId}`); + } + + // 진행 상황 보고 + if (progressCallback) progressCallback(30); + + // 컬럼 정보 파싱 + const columnsJSON: Column[] = (formMetaRecord[0].columns); + + // 현재 formEntries 데이터 가져오기 + const existingEntries = await tx.select({ id: formEntries.id, data: formEntries.data }) + .from(formEntries) + .where(and( + eq(formEntries.formCode, formCode), + eq(formEntries.contractItemId, packageId) + )); + + // 기존 tags 데이터 가져오기 + const existingTags = await tx.select() + .from(tags) + .where(eq(tags.contractItemId, packageId)); + + // 진행 상황 보고 + if (progressCallback) progressCallback(50); + + // 기존 데이터를 맵으로 변환 + const existingTagMap = new Map(); + const existingTagsMap = new Map(); + + existingEntries.forEach(entry => { + const data = entry.data as any[]; + data.forEach(item => { + if (item.TAG_NO) { + existingTagMap.set(item.TAG_NO, { + entryId: entry.id, + data: item + }); + } + }); + }); + + existingTags.forEach(tag => { + existingTagsMap.set(tag.tagNo, tag); + }); + + // 진행 상황 보고 + if (progressCallback) progressCallback(60); + + // 처리 결과 카운터 + let processedCount = 0; + let excludedCount = 0; + + // 새로운 태그 데이터와 업데이트할 데이터 준비 + const newTagData: any[] = []; + const upsertTagRecords: any[] = []; // 새로 추가되거나 업데이트될 태그들 + const updateData: {entryId: number, tagNo: string, updates: any}[] = []; + + // SEDP 태그 데이터 처리 + for (const tagEntry of tagEntries) { + try { + if (!tagEntry.TAG_NO) { + excludedCount++; + errors.push(`Missing TAG_NO in tag entry`); + continue; + } + + // tagType 조회 (TAG_TYPE_ID -> description) + let tagTypeDescription = tagEntry.TAG_TYPE_ID; // 기본값 + if (tagEntry.TAG_TYPE_ID) { + const tagTypeRecord = await tx.select({ description: tagTypes.description }) + .from(tagTypes) + .where(and( + eq(tagTypes.code, tagEntry.TAG_TYPE_ID), + eq(tagTypes.projectId, projectId) + )) + .limit(1); + + if (tagTypeRecord && tagTypeRecord.length > 0) { + tagTypeDescription = tagTypeRecord[0].description; + } + } + + // tagClass 조회 (CLS_ID -> label) + let tagClassLabel = tagEntry.CLS_ID; // 기본값 + if (tagEntry.CLS_ID) { + const tagClassRecord = await tx.select({ label: tagClasses.label }) + .from(tagClasses) + .where(and( + eq(tagClasses.code, tagEntry.CLS_ID), + eq(tagClasses.projectId, projectId) + )) + .limit(1); + + if (tagClassRecord && tagClassRecord.length > 0) { + tagClassLabel = tagClassRecord[0].label; + } + } + + // 기본 태그 데이터 객체 생성 (formEntries용) + const tagObject: any = { + TAG_NO: tagEntry.TAG_NO, + TAG_DESC: tagEntry.TAG_DESC || "", + status: "From S-EDP" // SEDP에서 가져온 데이터임을 표시 + }; + + // tags 테이블용 데이터 (UPSERT용) + const tagRecord = { + contractItemId: packageId, + formId: formId, + tagNo: tagEntry.TAG_NO, + tagType: tagTypeDescription, + class: tagClassLabel, + description: tagEntry.TAG_DESC || null, + createdAt: new Date(), + updatedAt: new Date() + }; + + // ATTRIBUTES 필드에서 shi=true인 컬럼의 값 추출 + if (Array.isArray(tagEntry.ATTRIBUTES)) { + for (const attr of tagEntry.ATTRIBUTES) { + const columnInfo = columnsJSON.find(col => col.key === attr.ATT_ID); + if (columnInfo && columnInfo.shi === true) { if (columnInfo.type === "NUMBER") { - // // 먼저 VALUE_DBL이 있는지 확인 - // if (attr.VALUE_DBL !== undefined && attr.VALUE_DBL !== null) { - // tagObject[attr.ATT_ID] = attr.VALUE_DBL; - // } - // VALUE_DBL이 없으면 VALUE 사용 시도 if (attr.VALUE !== undefined && attr.VALUE !== null) { - // 문자열에서 숫자 추출 if (typeof attr.VALUE === 'string') { - // 문자열에서 첫 번째 숫자 부분 추출 const numberMatch = attr.VALUE.match(/(-?\d+(\.\d+)?)/); if (numberMatch) { tagObject[attr.ATT_ID] = parseFloat(numberMatch[0]); } else { - // 숫자로 직접 변환 시도 const parsed = parseFloat(attr.VALUE); if (!isNaN(parsed)) { tagObject[attr.ATT_ID] = parsed; } } } else if (typeof attr.VALUE === 'number') { - // 이미 숫자인 경우 tagObject[attr.ATT_ID] = attr.VALUE; } } } else if (attr.VALUE !== null && attr.VALUE !== undefined) { - // 숫자 타입이 아닌 경우 VALUE 그대로 사용 tagObject[attr.ATT_ID] = attr.VALUE; } } } } - } - // 기존 태그가 있는지 확인하고 처리 - const existingTag = existingTagMap.get(tagEntry.TAG_NO); - if (existingTag) { - // 기존 태그가 있으면 업데이트할 필드 찾기 - const updates: any = {}; - let hasUpdates = false; - // shi=true인 필드만 업데이트 - for (const key of Object.keys(tagObject)) { - if (key === "TAG_NO") continue; // TAG_NO는 업데이트 안 함 - - // TAG_DESC는 항상 업데이트 - if (key === "TAG_DESC" && tagObject[key] !== existingTag.data[key]) { - updates[key] = tagObject[key]; - hasUpdates = true; - continue; - } + // 기존 태그가 있는지 확인하고 처리 + const existingTag = existingTagMap.get(tagEntry.TAG_NO); + + if (existingTag) { + // 기존 태그가 있으면 formEntries 업데이트 데이터 준비 + const updates: any = {}; + let hasUpdates = false; - // 그 외 필드는 컬럼 정보에서 shi=true인 것만 업데이트 - const columnInfo = columnsJSON.find(col => col.key === key); - if (columnInfo && columnInfo.shi === true) { - if (existingTag.data[key] !== tagObject[key]) { + for (const key of Object.keys(tagObject)) { + if (key === "TAG_NO") continue; + + if (key === "TAG_DESC" && tagObject[key] !== existingTag.data[key]) { updates[key] = tagObject[key]; hasUpdates = true; + continue; + } + + if (key === "status" && tagObject[key] !== existingTag.data[key]) { + updates[key] = tagObject[key]; + hasUpdates = true; + continue; + } + + const columnInfo = columnsJSON.find(col => col.key === key); + if (columnInfo && columnInfo.shi === true) { + if (existingTag.data[key] !== tagObject[key]) { + updates[key] = tagObject[key]; + hasUpdates = true; + } } } + + if (hasUpdates) { + updateData.push({ + entryId: existingTag.entryId, + tagNo: tagEntry.TAG_NO, + updates + }); + } + } else { + // 기존 태그가 없으면 새로 추가 + newTagData.push(tagObject); } - // 업데이트할 내용이 있으면 추가 - if (hasUpdates) { - updateData.push({ - entryId: existingTag.entryId, - tagNo: tagEntry.TAG_NO, - updates - }); - } + // tags 테이블에는 항상 upsert (새로 추가되거나 업데이트) + upsertTagRecords.push(tagRecord); + + processedCount++; + } catch (error) { + excludedCount++; + errors.push(`Error processing tag ${tagEntry.TAG_NO || 'unknown'}: ${error}`); + } + } + + // 진행 상황 보고 + if (progressCallback) progressCallback(80); + + // formEntries 업데이트 실행 + for (const update of updateData) { + try { + const entry = existingEntries.find(e => e.id === update.entryId); + if (!entry) continue; + + const data = entry.data as any[]; + const updatedData = data.map(item => { + if (item.TAG_NO === update.tagNo) { + return { ...item, ...update.updates }; + } + return item; + }); + + await tx.update(formEntries) + .set({ + data: updatedData, + updatedAt: new Date() + }) + .where(eq(formEntries.id, update.entryId)); + } catch (error) { + errors.push(`Error updating formEntry for tag ${update.tagNo}: ${error}`); + } + } + + // 새 태그 추가 (formEntries) + if (newTagData.length > 0) { + if (existingEntries.length > 0) { + const firstEntry = existingEntries[0]; + const existingData = firstEntry.data as any[]; + const updatedData = [...existingData, ...newTagData]; + + await tx.update(formEntries) + .set({ + data: updatedData, + updatedAt: new Date() + }) + .where(eq(formEntries.id, firstEntry.id)); } else { - // 기존 태그가 없으면 새로 추가 - newTagData.push(tagObject); + await tx.insert(formEntries) + .values({ + formCode, + contractItemId: packageId, + data: newTagData, + createdAt: new Date(), + updatedAt: new Date() + }); } - - processedCount++; - } catch (error) { - excludedCount++; - errors.push(`Error processing tag ${tagEntry.TAG_NO || 'unknown'}: ${error}`); } - } - - // 진행 상황 보고 - if (progressCallback) progressCallback(80); - - // 업데이트 실행 - for (const update of updateData) { - try { - const entry = existingEntries.find(e => e.id === update.entryId); - if (!entry) continue; + + // tags 테이블 처리 (INSERT + UPDATE 분리) + if (upsertTagRecords.length > 0) { + const newTagRecords: any[] = []; + const updateTagRecords: {tagId: number, updates: any}[] = []; - const data = entry.data as any[]; - const updatedData = data.map(item => { - if (item.TAG_NO === update.tagNo) { - return { ...item, ...update.updates }; + // 각 태그를 확인하여 신규/업데이트 분류 + for (const tagRecord of upsertTagRecords) { + const existingTagRecord = existingTagsMap.get(tagRecord.tagNo); + + if (existingTagRecord) { + // 기존 태그가 있으면 업데이트 준비 + const tagUpdates: any = {}; + let hasTagUpdates = false; + + if (existingTagRecord.tagType !== tagRecord.tagType) { + tagUpdates.tagType = tagRecord.tagType; + hasTagUpdates = true; + } + if (existingTagRecord.class !== tagRecord.class) { + tagUpdates.class = tagRecord.class; + hasTagUpdates = true; + } + if (existingTagRecord.description !== tagRecord.description) { + tagUpdates.description = tagRecord.description; + hasTagUpdates = true; + } + if (existingTagRecord.formId !== tagRecord.formId) { + tagUpdates.formId = tagRecord.formId; + hasTagUpdates = true; + } + + if (hasTagUpdates) { + updateTagRecords.push({ + tagId: existingTagRecord.id, + updates: { ...tagUpdates, updatedAt: new Date() } + }); + } + } else { + // 새로운 태그 + newTagRecords.push(tagRecord); } - return item; - }); + } - await db.update(formEntries) - .set({ - data: updatedData, - updatedAt: new Date() - }) - .where(eq(formEntries.id, update.entryId)); - } catch (error) { - errors.push(`Error updating tag ${update.tagNo}: ${error}`); - } - } - - // 새 태그 추가 - if (newTagData.length > 0) { - // 기존 엔트리가 있으면 첫 번째 것에 추가 - if (existingEntries.length > 0) { - const firstEntry = existingEntries[0]; - const existingData = firstEntry.data as any[]; - const updatedData = [...existingData, ...newTagData]; + // 새 태그 삽입 + if (newTagRecords.length > 0) { + try { + await tx.insert(tags) + .values(newTagRecords) + .onConflictDoNothing({ + target: [tags.contractItemId, tags.tagNo] + }); + } catch (error) { + // 개별 삽입으로 재시도 + for (const tagRecord of newTagRecords) { + try { + await tx.insert(tags) + .values(tagRecord) + .onConflictDoNothing({ + target: [tags.contractItemId, tags.tagNo] + }); + } catch (individualError) { + errors.push(`Error inserting tag ${tagRecord.tagNo}: ${individualError}`); + } + } + } + } - await db.update(formEntries) - .set({ - data: updatedData, - updatedAt: new Date() - }) - .where(eq(formEntries.id, firstEntry.id)); - } else { - // 기존 엔트리가 없으면 새로 생성 - await db.insert(formEntries) - .values({ - formCode, - contractItemId: packageId, - data: newTagData, - createdAt: new Date(), - updatedAt: new Date() - }); + // 기존 태그 업데이트 + for (const update of updateTagRecords) { + try { + await tx.update(tags) + .set(update.updates) + .where(eq(tags.id, update.tagId)); + } catch (error) { + errors.push(`Error updating tag record ${update.tagId}: ${error}`); + } + } } - } - - // 진행 상황 보고 - if (progressCallback) progressCallback(100); + + // 진행 상황 보고 + if (progressCallback) progressCallback(100); + + // 최종 결과 반환 + return { + processedCount, + excludedCount, + totalEntries: tagEntries.length, + formCreated, // 새로 추가: form이 생성되었는지 여부 + errors: errors.length > 0 ? errors : undefined + }; + }); - // 최종 결과 반환 - return { - processedCount, - excludedCount, - totalEntries: tagEntries.length, - errors: errors.length > 0 ? errors : undefined - }; } catch (error: any) { console.error("Tag import error:", error); throw error; diff --git a/lib/sedp/get-tags.ts b/lib/sedp/get-tags.ts index 7021d7d2..00916eb2 100644 --- a/lib/sedp/get-tags.ts +++ b/lib/sedp/get-tags.ts @@ -3,6 +3,7 @@ import { contractItems, tags, forms, + formEntries, // 추가 items, tagTypeClassFormMappings, projects, @@ -11,6 +12,7 @@ import { contracts } from "@/db/schema"; import { eq, and, like, inArray } from "drizzle-orm"; +import { revalidateTag } from "next/cache"; // 추가 import { getSEDPToken } from "./sedp-token"; /** @@ -240,6 +242,9 @@ export async function importTagsFromSEDP( insertValues.eng = true; } else if (mode === "IM") { insertValues.im = true; + if (mapping.remark && mapping.remark.includes("VD_")) { + insertValues.eng = true; + } } } @@ -289,8 +294,6 @@ export async function importTagsFromSEDP( try { // Step 5: Call the external API to get tag data - - const tagData = await fetchTagDataFromSEDP(projectCode, baseMappings[0].formCode); // 진행 상황 보고 @@ -309,13 +312,21 @@ export async function importTagsFromSEDP( const tagEntries = tagData[tableName]; if (!Array.isArray(tagEntries) || tagEntries.length === 0) { - allErrors.push(`No tag data found in the API response for formCode ${baseFormCode}`); + allErrors.push(`No tag data found in the API response for formCode ${baseMappings[0].formCode}`); continue; // 다음 매핑으로 진행 } const entriesCount = tagEntries.length; totalEntriesCount += entriesCount; + // formEntries를 위한 데이터 수집 + const newTagsForFormEntry: Array<{ + TAG_NO: string; + TAG_DESC: string | null; + status: string; + [key: string]: any; + }> = []; + // Process each tag entry for (let i = 0; i < tagEntries.length; i++) { try { @@ -371,6 +382,24 @@ export async function importTagsFromSEDP( } }); + // formEntries용 데이터 수집 + const tagDataForFormEntry = { + TAG_NO: entry.TAG_NO, + TAG_DESC: entry.TAG_DESC || null, + status:"From S-EDP" // SEDP에서 가져온 데이터임을 표시 + }; + + // ATTRIBUTES가 있으면 추가 (SHI 필드들) + if (Array.isArray(entry.ATTRIBUTES)) { + for (const attr of entry.ATTRIBUTES) { + if (attr.ATT_ID && attr.VALUE !== null && attr.VALUE !== undefined) { + tagDataForFormEntry[attr.ATT_ID] = attr.VALUE; + } + } + } + + newTagsForFormEntry.push(tagDataForFormEntry); + processedCount++; totalProcessedCount++; @@ -386,6 +415,87 @@ export async function importTagsFromSEDP( } } + // Step 7: formEntries 업데이트 + if (newTagsForFormEntry.length > 0) { + try { + // 기존 formEntry 가져오기 + const existingEntry = await db.query.formEntries.findFirst({ + where: and( + eq(formEntries.formCode, formCode), + eq(formEntries.contractItemId, packageId) + ) + }); + + if (existingEntry && existingEntry.id) { + // 기존 formEntry가 있는 경우 + let existingData: Array<{ + TAG_NO: string; + TAG_DESC?: string; + status?: string; + [key: string]: any; + }> = []; + + if (Array.isArray(existingEntry.data)) { + existingData = existingEntry.data; + } + + // 기존 TAG_NO들 추출 + const existingTagNos = new Set(existingData.map(item => item.TAG_NO)); + + // 중복되지 않은 새 태그들만 필터링 + const newUniqueTagsData = newTagsForFormEntry.filter( + tagData => !existingTagNos.has(tagData.TAG_NO) + ); + + // 기존 태그들의 status와 ATTRIBUTES 업데이트 + const updatedExistingData = existingData.map(existingItem => { + const newTagData = newTagsForFormEntry.find( + newItem => newItem.TAG_NO === existingItem.TAG_NO + ); + + if (newTagData) { + // 기존 태그가 있으면 SEDP 데이터로 업데이트 + return { + ...existingItem, + ...newTagData, + TAG_NO: existingItem.TAG_NO // TAG_NO는 유지 + }; + } + + return existingItem; + }); + + const finalData = [...updatedExistingData, ...newUniqueTagsData]; + + await db + .update(formEntries) + .set({ + data: finalData, + updatedAt: new Date() + }) + .where(eq(formEntries.id, existingEntry.id)); + + console.log(`[IMPORT SEDP] Updated formEntry with ${newUniqueTagsData.length} new tags, updated ${updatedExistingData.length - newUniqueTagsData.length} existing tags for form ${formCode}`); + } else { + // formEntry가 없는 경우 새로 생성 + await db.insert(formEntries).values({ + formCode: formCode, + contractItemId: packageId, + data: newTagsForFormEntry, + createdAt: new Date(), + updatedAt: new Date(), + }); + + console.log(`[IMPORT SEDP] Created new formEntry with ${newTagsForFormEntry.length} tags for form ${formCode}`); + } + + // 캐시 무효화 + revalidateTag(`form-data-${formCode}-${packageId}`); + } catch (formEntryError) { + console.error(`[IMPORT SEDP] Error updating formEntry for form ${formCode}:`, formEntryError); + allErrors.push(`Error updating formEntry for form ${formCode}: ${formEntryError}`); + } + } } catch (error: any) { console.error(`Error processing mapping for formCode ${formCode}:`, error); diff --git a/lib/sedp/sync-package.ts b/lib/sedp/sync-package.ts new file mode 100644 index 00000000..c8f39ad8 --- /dev/null +++ b/lib/sedp/sync-package.ts @@ -0,0 +1,282 @@ +"use server" +// src/lib/cron/syncItemsFromCodeLists.ts +import db from "@/db/db"; +import { projects, items } from '@/db/schema'; +import { eq } from 'drizzle-orm'; +import { getSEDPToken } from "./sedp-token"; + +const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api'; + +async function getCodeLists(projectCode: string): Promise<Map<string, CodeList>> { + try { + // 토큰(API 키) 가져오기 + const apiKey = await getSEDPToken(); + + const response = await fetch( + `${SEDP_API_BASE_URL}/CodeList/Get`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'accept': '*/*', + 'ApiKey': apiKey, + 'ProjectNo': projectCode + }, + body: JSON.stringify({ + ProjectNo: projectCode, + ContainDeleted: false + }) + } + ); + + if (!response.ok) { + throw new Error(`코드 리스트 요청 실패: ${response.status} ${response.statusText}`); + } + + // 안전하게 JSON 파싱 + try { + const data = await response.json(); + + // 데이터가 배열인지 확인 + const codeLists: CodeList[] = Array.isArray(data) ? data : [data]; + + // CL_ID로 효율적인 조회를 위한 맵 생성 + const codeListMap = new Map<string, CodeList>(); + for (const codeList of codeLists) { + if (!codeList.DELETED) { + codeListMap.set(codeList.CL_ID, codeList); + } + } + + console.log(`프로젝트 ${projectCode}에서 ${codeListMap.size}개의 코드 리스트를 가져왔습니다`); + return codeListMap; + + } catch (parseError) { + console.error(`프로젝트 ${projectCode}의 코드 리스트 응답 파싱 실패:`, parseError); + // 응답 내용 로깅 + try { + const text = await response.clone().text(); + console.log(`응답 내용: ${text.substring(0, 200)}${text.length > 200 ? '...' : ''}`); + } catch (textError) { + console.error('응답 내용 로깅 실패:', textError); + } + return new Map(); + } + } catch (error) { + console.error(`프로젝트 ${projectCode}의 코드 리스트 가져오기 실패:`, error); + return new Map(); + } + } + + +interface CodeValue { + VALUE: string; + DESCC: string; + ATTRIBUTES: Array<{ + ATT_ID: string; + VALUE: string; + }>; +} + +interface CodeList { + PROJ_NO: string; + CL_ID: string; + DESC: string; + REMARK: string | null; + PRNT_CD_ID: string | null; + REG_TYPE_ID: string | null; + VAL_ATT_ID: string | null; + VALUES: CodeValue[]; + LNK_ATT: any[]; + DELETED: boolean; + CRTER_NO: string; + CRTE_DTM: string; + CHGER_NO: string | null; + CHGE_DTM: string | null; + _id: string; +} + +export async function syncItemsFromCodeLists(): Promise<void> { + try { + console.log('아이템 동기화 시작...'); + + // 모든 프로젝트 가져오기 + const allProjects = await db.select().from(projects); + console.log(`총 ${allProjects.length}개의 프로젝트를 처리합니다.`); + + let totalItemsProcessed = 0; + let totalItemsInserted = 0; + let totalItemsUpdated = 0; + + for (const project of allProjects) { + try { + console.log(`프로젝트 ${project.code} (${project.name}) 처리 중...`); + + // 프로젝트의 코드리스트 가져오기 + const codeListMap = await getCodeLists(project.code); + + // PKG_NO 코드리스트 찾기 + const pkgNoCodeList = codeListMap.get('PKG_NO'); + + if (!pkgNoCodeList) { + console.log(`프로젝트 ${project.code}에서 PKG_NO 코드리스트를 찾을 수 없습니다.`); + continue; + } + + console.log(`프로젝트 ${project.code}에서 ${pkgNoCodeList.VALUES.length}개의 아이템을 처리합니다.`); + + // VALUES 배열 순회하며 items 테이블에 삽입/업데이트 + for (const codeValue of pkgNoCodeList.VALUES) { + try { + // ATTRIBUTES에서 필요한 값들 추출 + const packageCodeAttr = codeValue.ATTRIBUTES?.find(attr => attr.ATT_ID === 'SHI_PACK_NO'); + const smCodeAttr = codeValue.ATTRIBUTES?.find(attr => attr.ATT_ID === 'SM_code'); + + const itemData = { + ProjectNo: project.code, + itemCode: codeValue.VALUE, + itemName: codeValue.DESCC || '', + packageCode: packageCodeAttr?.VALUE || '', + smCode: smCodeAttr?.VALUE || null, + description: null, // 필요시 추가 매핑 + parentItemCode: null, // 필요시 추가 매핑 + itemLevel: null, // 필요시 추가 매핑 + deleteFlag: 'N', // 기본값 + unitOfMeasure: null, // 필요시 추가 매핑 + steelType: null, // 필요시 추가 매핑 + gradeMaterial: null, // 필요시 추가 매핑 + changeDate: null, // 필요시 추가 매핑 + baseUnitOfMeasure: null, // 필요시 추가 매핑 + updatedAt: new Date() + }; + + // 기존 아이템 확인 (itemCode로 조회) + const existingItem = await db.select() + .from(items) + .where(eq(items.itemCode, codeValue.VALUE)) + .limit(1); + + if (existingItem.length > 0) { + // 기존 아이템 업데이트 + await db.update(items) + .set(itemData) + .where(eq(items.itemCode, codeValue.VALUE)); + totalItemsUpdated++; + } else { + // 새 아이템 삽입 + await db.insert(items).values(itemData); + totalItemsInserted++; + } + + totalItemsProcessed++; + } catch (itemError) { + console.error(`아이템 ${codeValue.VALUE} 처리 중 오류:`, itemError); + } + } + + console.log(`프로젝트 ${project.code} 완료`); + } catch (projectError) { + console.error(`프로젝트 ${project.code} 처리 중 오류:`, projectError); + } + } + + console.log(`아이템 동기화 완료:`); + console.log(`- 총 처리된 아이템: ${totalItemsProcessed}개`); + console.log(`- 새로 삽입된 아이템: ${totalItemsInserted}개`); + console.log(`- 업데이트된 아이템: ${totalItemsUpdated}개`); + + } catch (error) { + console.error('아이템 동기화 중 전체 오류:', error); + throw error; + } +} + +// 특정 프로젝트만 동기화하는 함수 +export async function syncItemsForProject(projectCode: string): Promise<void> { + try { + console.log(`프로젝트 ${projectCode}의 아이템 동기화 시작...`); + + // 프로젝트 존재 확인 + const project = await db.select() + .from(projects) + .where(eq(projects.code, projectCode)) + .limit(1); + + if (project.length === 0) { + throw new Error(`프로젝트 ${projectCode}를 찾을 수 없습니다.`); + } + + // 프로젝트의 코드리스트 가져오기 + const codeListMap = await getCodeLists(projectCode); + + // PKG_NO 코드리스트 찾기 + const pkgNoCodeList = codeListMap.get('PKG_NO'); + + if (!pkgNoCodeList) { + console.log(`프로젝트 ${projectCode}에서 PKG_NO 코드리스트를 찾을 수 없습니다.`); + return; + } + + console.log(`${pkgNoCodeList.VALUES.length}개의 아이템을 처리합니다.`); + + let itemsProcessed = 0; + let itemsInserted = 0; + let itemsUpdated = 0; + + // VALUES 배열 순회하며 items 테이블에 삽입/업데이트 + for (const codeValue of pkgNoCodeList.VALUES) { + try { + // ATTRIBUTES에서 필요한 값들 추출 + const packageCodeAttr = codeValue.ATTRIBUTES?.find(attr => attr.ATT_ID === 'SHI_PACK_NO'); + const smCodeAttr = codeValue.ATTRIBUTES?.find(attr => attr.ATT_ID === 'SM_code'); + + const itemData = { + ProjectNo: projectCode, + itemCode: codeValue.VALUE, + itemName: codeValue.DESCC || '', + packageCode: packageCodeAttr?.VALUE || '', + smCode: smCodeAttr?.VALUE || null, + description: null, + parentItemCode: null, + itemLevel: null, + deleteFlag: 'N', + unitOfMeasure: null, + steelType: null, + gradeMaterial: null, + changeDate: null, + baseUnitOfMeasure: null, + updatedAt: new Date() + }; + + // 기존 아이템 확인 + const existingItem = await db.select() + .from(items) + .where(eq(items.itemCode, codeValue.VALUE)) + .limit(1); + + if (existingItem.length > 0) { + await db.update(items) + .set(itemData) + .where(eq(items.itemCode, codeValue.VALUE)); + itemsUpdated++; + } else { + await db.insert(items).values(itemData); + itemsInserted++; + } + + itemsProcessed++; + } catch (itemError) { + console.error(`아이템 ${codeValue.VALUE} 처리 중 오류:`, itemError); + } + } + + console.log(`프로젝트 ${projectCode} 아이템 동기화 완료:`); + console.log(`- 처리된 아이템: ${itemsProcessed}개`); + console.log(`- 새로 삽입된 아이템: ${itemsInserted}개`); + console.log(`- 업데이트된 아이템: ${itemsUpdated}개`); + + } catch (error) { + console.error(`프로젝트 ${projectCode} 아이템 동기화 중 오류:`, error); + throw error; + } +}
\ No newline at end of file diff --git a/lib/tag-numbering/service.ts b/lib/tag-numbering/service.ts index 3256b185..cc8bc5ea 100644 --- a/lib/tag-numbering/service.ts +++ b/lib/tag-numbering/service.ts @@ -11,8 +11,8 @@ import { countTagNumbering, selectTagNumbering } from "./repository"; export async function getTagNumbering(input: GetTagNumberigSchema) { - return unstable_cache( - async () => { + // return unstable_cache( + // async () => { try { const offset = (input.page - 1) * input.perPage; @@ -75,13 +75,13 @@ export async function getTagNumbering(input: GetTagNumberigSchema) { // 에러 발생 시 디폴트 return { data: [], pageCount: 0 }; } - }, - [JSON.stringify(input)], // 캐싱 키 - { - revalidate: 3600, - tags: ["tag-numbering"], // revalidateTag("items") 호출 시 무효화 - } - )(); + // }, + // [JSON.stringify(input)], // 캐싱 키 + // { + // revalidate: 3600, + // tags: ["tag-numbering"], // revalidateTag("items") 호출 시 무효화 + // } + // )(); } diff --git a/lib/tags/form-mapping-service.ts b/lib/tags/form-mapping-service.ts index 19b3ab14..3e86e9d9 100644 --- a/lib/tags/form-mapping-service.ts +++ b/lib/tags/form-mapping-service.ts @@ -8,6 +8,8 @@ import { eq, and } from "drizzle-orm" export interface FormMapping { formCode: string; formName: string; + ep: string; + remark: string; } /** @@ -29,6 +31,8 @@ export async function getFormMappingsByTagType( .select({ formCode: tagTypeClassFormMappings.formCode, formName: tagTypeClassFormMappings.formName, + ep: tagTypeClassFormMappings.ep, + remark: tagTypeClassFormMappings.remark }) .from(tagTypeClassFormMappings) .where(and( @@ -49,6 +53,7 @@ export async function getFormMappingsByTagType( .select({ formCode: tagTypeClassFormMappings.formCode, formName: tagTypeClassFormMappings.formName, + ep: tagTypeClassFormMappings.ep }) .from(tagTypeClassFormMappings) .where(and( diff --git a/lib/tags/service.ts b/lib/tags/service.ts index 6a34d208..187aba39 100644 --- a/lib/tags/service.ts +++ b/lib/tags/service.ts @@ -145,11 +145,15 @@ export async function createTag( } // 3) 태그 타입에 따른 폼 정보 가져오기 - const formMappings = await getFormMappingsByTagType( + const allFormMappings = await getFormMappingsByTagType( validated.data.tagType, projectId, // projectId 전달 validated.data.class ) + + // ep가 "IMEP"인 것만 필터링 + const formMappings = allFormMappings?.filter(mapping => mapping.ep === "IMEP") || [] + // 폼 매핑이 없으면 로그만 남기고 진행 if (!formMappings || formMappings.length === 0) { @@ -171,7 +175,7 @@ export async function createTag( for (const formMapping of formMappings) { // 4-1) 이미 존재하는 폼인지 확인 const existingForm = await tx - .select({ id: forms.id, im: forms.im }) // im 필드 추가로 조회 + .select({ id: forms.id, im: forms.im, eng: forms.eng }) // eng 필드도 추가로 조회 .from(forms) .where( and( @@ -186,13 +190,29 @@ export async function createTag( // 이미 존재하면 해당 ID 사용 formId = existingForm[0].id + // 업데이트할 필드들 준비 + const updateValues: any = {}; + let shouldUpdate = false; + + // im 필드 체크 if (existingForm[0].im !== true) { + updateValues.im = true; + shouldUpdate = true; + } + + // eng 필드 체크 - remark에 "VD_"가 포함되어 있을 때만 + if (formMapping.remark && formMapping.remark.includes("VD_") && existingForm[0].eng !== true) { + updateValues.eng = true; + shouldUpdate = true; + } + + if (shouldUpdate) { await tx .update(forms) - .set({ im: true }) + .set(updateValues) .where(eq(forms.id, formId)) - console.log(`Form ${formId} updated with im: true`) + console.log(`Form ${formId} updated with:`, updateValues) } createdOrExistingForms.push({ @@ -203,14 +223,21 @@ export async function createTag( }) } else { // 존재하지 않으면 새로 생성 + const insertValues: any = { + contractItemId: selectedPackageId, + formCode: formMapping.formCode, + formName: formMapping.formName, + im: true, + }; + + // remark에 "VD_"가 포함되어 있을 때만 eng: true 설정 + if (formMapping.remark && formMapping.remark.includes("VD_")) { + insertValues.eng = true; + } + const insertResult = await tx .insert(forms) - .values({ - contractItemId: selectedPackageId, - formCode: formMapping.formCode, - formName: formMapping.formName, - im: true - }) + .values(insertValues) .returning({ id: forms.id, formCode: forms.formCode, formName: forms.formName }) console.log("insertResult:", insertResult) @@ -242,17 +269,93 @@ export async function createTag( console.log(`tags-${selectedPackageId}`, "create", newTag) - // 6) 캐시 무효화 (React 서버 액션에서 캐싱 사용 시) + // 6) 생성된 각 form에 대해 formEntries에 데이터 추가 + for (const form of createdOrExistingForms) { + try { + // 기존 formEntry 가져오기 + const existingEntry = await tx.query.formEntries.findFirst({ + where: and( + eq(formEntries.formCode, form.formCode), + eq(formEntries.contractItemId, selectedPackageId) + ) + }); + + // 새로운 태그 데이터 객체 생성 + const newTagData = { + TAG_NO: validated.data.tagNo, + TAG_DESC: validated.data.description ?? null, + status: "New" // 수동으로 생성된 태그임을 표시 + }; + + if (existingEntry && existingEntry.id) { + // 기존 formEntry가 있는 경우 + let existingData: Array<{ + TAG_NO: string; + TAG_DESC?: string; + status?: string; + [key: string]: any; + }> = []; + + if (Array.isArray(existingEntry.data)) { + existingData = existingEntry.data; + } + + // TAG_NO가 이미 존재하는지 확인 + const existingTagIndex = existingData.findIndex( + item => item.TAG_NO === validated.data.tagNo + ); + + if (existingTagIndex === -1) { + // TAG_NO가 없으면 새로 추가 + const updatedData = [...existingData, newTagData]; + + await tx + .update(formEntries) + .set({ + data: updatedData, + updatedAt: new Date() + }) + .where(eq(formEntries.id, existingEntry.id)); + + console.log(`[CREATE TAG] Added tag ${validated.data.tagNo} to existing formEntry for form ${form.formCode}`); + } else { + console.log(`[CREATE TAG] Tag ${validated.data.tagNo} already exists in formEntry for form ${form.formCode}`); + } + } else { + // formEntry가 없는 경우 새로 생성 + await tx.insert(formEntries).values({ + formCode: form.formCode, + contractItemId: selectedPackageId, + data: [newTagData], + createdAt: new Date(), + updatedAt: new Date(), + }); + + console.log(`[CREATE TAG] Created new formEntry with tag ${validated.data.tagNo} for form ${form.formCode}`); + } + } catch (formEntryError) { + console.error(`[CREATE TAG] Error updating formEntry for form ${form.formCode}:`, formEntryError); + // 개별 formEntry 에러는 로그만 남기고 전체 트랜잭션은 계속 진행 + } + } + + // 7) 캐시 무효화 (React 서버 액션에서 캐싱 사용 시) revalidateTag(`tags-${selectedPackageId}`) revalidateTag(`forms-${selectedPackageId}-ENG`) revalidateTag("tags") - // 7) 성공 시 반환 + // 생성된 각 form의 캐시도 무효화 + createdOrExistingForms.forEach(form => { + revalidateTag(`form-data-${form.formCode}-${selectedPackageId}`) + }) + + // 8) 성공 시 반환 return { success: true, data: { forms: createdOrExistingForms, primaryFormId, + tagNo: validated.data.tagNo }, } }) @@ -264,6 +367,7 @@ export async function createTag( } } + export async function createTagInForm( formData: CreateTagSchema, selectedPackageId: number | null, @@ -321,10 +425,77 @@ export async function createTagInForm( } } - const form = await db.query.forms.findFirst({ - where: eq(forms.formCode, formCode) + // 3) 먼저 기존 form 찾기 + let form = await tx.query.forms.findFirst({ + where: and( + eq(forms.formCode, formCode), + eq(forms.contractItemId, selectedPackageId) + ) }); + // 4) form이 없으면 formMappings를 통해 생성 + if (!form) { + console.log(`[CREATE TAG IN FORM] Form ${formCode} not found, attempting to create...`); + + // 태그 타입에 따른 폼 정보 가져오기 + const allFormMappings = await getFormMappingsByTagType( + validated.data.tagType, + projectId, + validated.data.class + ) + + // ep가 "IMEP"인 것만 필터링 + const formMappings = allFormMappings?.filter(mapping => mapping.ep === "IMEP") || [] + + // 현재 formCode와 일치하는 매핑 찾기 + const targetFormMapping = formMappings.find(mapping => mapping.formCode === formCode); + + if (targetFormMapping) { + console.log(`[CREATE TAG IN FORM] Found form mapping for ${formCode}, creating form...`); + + // form 생성 + const insertResult = await tx + .insert(forms) + .values({ + contractItemId: selectedPackageId, + formCode: targetFormMapping.formCode, + formName: targetFormMapping.formName, + im: true, + }) + .returning({ id: forms.id, formCode: forms.formCode, formName: forms.formName }) + + form = { + id: insertResult[0].id, + formCode: insertResult[0].formCode, + formName: insertResult[0].formName, + contractItemId: selectedPackageId, + im: true, + createdAt: new Date(), + updatedAt: new Date() + }; + + console.log(`[CREATE TAG IN FORM] Successfully created form:`, insertResult[0]); + } else { + console.log(`[CREATE TAG IN FORM] No IMEP form mapping found for formCode: ${formCode}`); + console.log(`[CREATE TAG IN FORM] Available IMEP mappings:`, formMappings.map(m => m.formCode)); + return { + error: `Form ${formCode} not found and no IMEP mapping available for tag type ${validated.data.tagType}` + }; + } + } else { + console.log(`[CREATE TAG IN FORM] Found existing form:`, form.id); + + // 기존 form이 있지만 im이 false인 경우 true로 업데이트 + if (form.im !== true) { + await tx + .update(forms) + .set({ im: true }) + .where(eq(forms.id, form.id)) + + console.log(`[CREATE TAG IN FORM] Form ${form.id} updated with im: true`) + } + } + if (form?.id) { // 5) 새 Tag 생성 (같은 트랜잭션 `tx` 사용) const [newTag] = await insertTag(tx, { @@ -336,48 +507,91 @@ export async function createTagInForm( description: validated.data.description ?? null, }) - let updatedData: Array<{ - TAG_NO: string; - TAG_DESC?: string; - }> = []; - - updatedData.push({ - TAG_NO: validated.data.tagNo, - TAG_DESC: validated.data.description ?? null, - }); - - const entry = await db.query.formEntries.findFirst({ + // 6) 기존 formEntry 가져오기 + const entry = await tx.query.formEntries.findFirst({ where: and( eq(formEntries.formCode, formCode), eq(formEntries.contractItemId, selectedPackageId), ) }); - if (entry && entry.id && updatedData.length > 0) { - await db + if (entry && entry.id) { + // 7) 기존 데이터 가져오기 (배열인지 확인) + let existingData: Array<{ + TAG_NO: string; + TAG_DESC?: string; + status?: string; + [key: string]: any; // 다른 필드들도 포함 + }> = []; + + if (Array.isArray(entry.data)) { + existingData = entry.data; + } + + console.log(`[CREATE TAG IN FORM] Existing data count: ${existingData.length}`); + + // 8) 새로운 태그를 기존 데이터에 추가 (status 필드 포함) + const newTagData = { + TAG_NO: validated.data.tagNo, + TAG_DESC: validated.data.description ?? null, + status: "New" // 수동으로 생성된 태그임을 표시 + }; + + const updatedData = [...existingData, newTagData]; + + console.log(`[CREATE TAG IN FORM] Updated data count: ${updatedData.length}`); + console.log(`[CREATE TAG IN FORM] Added tag: ${validated.data.tagNo} with status: 수동 생성`); + + // 9) formEntries 업데이트 + await tx .update(formEntries) - .set({ data: updatedData }) + .set({ + data: updatedData, + updatedAt: new Date() // 업데이트 시간도 갱신 + }) .where(eq(formEntries.id, entry.id)); + } else { + // 10) formEntry가 없는 경우 새로 생성 (status 필드 포함) + console.log(`[CREATE TAG IN FORM] No existing formEntry found, creating new one`); + + const newEntryData = [{ + TAG_NO: validated.data.tagNo, + TAG_DESC: validated.data.description ?? null, + status: "New" // 수동으로 생성된 태그임을 표시 + }]; + + await tx.insert(formEntries).values({ + formCode: formCode, + contractItemId: selectedPackageId, + data: newEntryData, + createdAt: new Date(), + updatedAt: new Date(), + }); } - console.log(`tags-${selectedPackageId}`, "create", newTag) - + console.log(`[CREATE TAG IN FORM] Successfully created tag: ${validated.data.tagNo}`) + } else { + return { error: "Failed to create or find form" }; } - // 6) 캐시 무효화 (React 서버 액션에서 캐싱 사용 시) + // 11) 캐시 무효화 (React 서버 액션에서 캐싱 사용 시) revalidateTag(`tags-${selectedPackageId}`) revalidateTag(`forms-${selectedPackageId}`) + revalidateTag(`form-data-${formCode}-${selectedPackageId}`) // 폼 데이터 캐시도 무효화 revalidateTag("tags") - // 7) 성공 시 반환 + // 12) 성공 시 반환 return { success: true, - data: null + data: { + formId: form.id, + tagNo: validated.data.tagNo, + formCreated: !form // form이 새로 생성되었는지 여부 + } } }) } catch (err: any) { console.log("createTag in Form error:", err) - console.error("createTag in Form error:", err) return { error: getErrorMessage(err) } } @@ -638,6 +852,13 @@ export async function bulkCreateTags( // 태그 유형별 폼 매핑 캐싱 (성능 최적화) const formMappingsCache = new Map(); + + // formEntries 업데이트를 위한 맵 (formCode -> 태그 데이터 배열) + const tagsByFormCode = new Map<string, Array<{ + TAG_NO: string; + TAG_DESC: string | null; + status: string; + }>>(); for (const tagData of tagsfromExcel) { // 캐시 키 생성 (tagType + class) @@ -648,12 +869,28 @@ export async function bulkCreateTags( if (formMappingsCache.has(cacheKey)) { formMappings = formMappingsCache.get(cacheKey); } else { + const tagTypeLabel = await tx + .select({ description: tagTypes.description }) + .from(tagTypes) + .where( + and( + eq(tagTypes.projectId, projectId), + eq(tagTypes.code, tagData.tagType), + ) + ) + .limit(1) + + const tagTypeLabelText = tagTypeLabel[0].description + // 각 태그 유형에 대한 폼 매핑 조회 (projectId 전달) - formMappings = await getFormMappingsByTagType( - tagData.tagType, + const allFormMappings = await getFormMappingsByTagType( + tagTypeLabelText, projectId, // projectId 전달 tagData.class ); + + // ep가 "IMEP"인 것만 필터링 + formMappings = allFormMappings?.filter(mapping => mapping.ep === "IMEP") || []; formMappingsCache.set(cacheKey, formMappings); } @@ -665,7 +902,7 @@ export async function bulkCreateTags( for (const formMapping of formMappings) { // 해당 폼이 이미 존재하는지 확인 const existingForm = await tx - .select({ id: forms.id }) + .select({ id: forms.id, im: forms.im }) .from(forms) .where( and( @@ -679,6 +916,15 @@ export async function bulkCreateTags( if (existingForm.length > 0) { // 이미 존재하면 해당 ID 사용 formId = existingForm[0].id; + + // im 필드 업데이트 (필요한 경우) + if (existingForm[0].im !== true) { + await tx + .update(forms) + .set({ im: true }) + .where(eq(forms.id, formId)); + } + createdOrExistingForms.push({ id: formId, formCode: formMapping.formCode, @@ -693,6 +939,7 @@ export async function bulkCreateTags( contractItemId: selectedPackageId, formCode: formMapping.formCode, formName: formMapping.formName, + im: true }) .returning({ id: forms.id, formCode: forms.formCode, formName: forms.formName }); @@ -709,10 +956,22 @@ export async function bulkCreateTags( if (primaryFormId === null) { primaryFormId = formId; } + + // formEntries 업데이트를 위한 데이터 수집 (tagsfromExcel의 원본 데이터 사용) + const newTagEntry = { + TAG_NO: tagData.tagNo, + TAG_DESC: tagData.description || null, + status: "New" // 벌크 생성도 수동 생성으로 분류 + }; + + if (!tagsByFormCode.has(formMapping.formCode)) { + tagsByFormCode.set(formMapping.formCode, []); + } + tagsByFormCode.get(formMapping.formCode)!.push(newTagEntry); } } else { console.log( - "No form mappings found for tag type:", + "No IMEP form mappings found for tag type:", tagData.tagType, "class:", tagData.class || "NONE", @@ -741,17 +1000,88 @@ export async function bulkCreateTags( }); } - // 4. 캐시 무효화 (한 번만) + // 4. formEntries 업데이트 처리 + for (const [formCode, newTagsData] of tagsByFormCode.entries()) { + try { + // 기존 formEntry 가져오기 + const existingEntry = await tx.query.formEntries.findFirst({ + where: and( + eq(formEntries.formCode, formCode), + eq(formEntries.contractItemId, selectedPackageId) + ) + }); + + if (existingEntry && existingEntry.id) { + // 기존 formEntry가 있는 경우 + let existingData: Array<{ + TAG_NO: string; + TAG_DESC?: string | null; + status?: string; + [key: string]: any; + }> = []; + + if (Array.isArray(existingEntry.data)) { + existingData = existingEntry.data; + } + + // 기존 TAG_NO들 추출 + const existingTagNos = new Set(existingData.map(item => item.TAG_NO)); + + // 중복되지 않은 새 태그들만 필터링 + const newUniqueTagsData = newTagsData.filter( + tagData => !existingTagNos.has(tagData.TAG_NO) + ); + + if (newUniqueTagsData.length > 0) { + const updatedData = [...existingData, ...newUniqueTagsData]; + + await tx + .update(formEntries) + .set({ + data: updatedData, + updatedAt: new Date() + }) + .where(eq(formEntries.id, existingEntry.id)); + + console.log(`[BULK CREATE] Added ${newUniqueTagsData.length} tags to existing formEntry for form ${formCode}`); + } else { + console.log(`[BULK CREATE] All tags already exist in formEntry for form ${formCode}`); + } + } else { + // formEntry가 없는 경우 새로 생성 + await tx.insert(formEntries).values({ + formCode: formCode, + contractItemId: selectedPackageId, + data: newTagsData, + createdAt: new Date(), + updatedAt: new Date(), + }); + + console.log(`[BULK CREATE] Created new formEntry with ${newTagsData.length} tags for form ${formCode}`); + } + } catch (formEntryError) { + console.error(`[BULK CREATE] Error updating formEntry for form ${formCode}:`, formEntryError); + // 개별 formEntry 에러는 로그만 남기고 전체 트랜잭션은 계속 진행 + } + } + + // 5. 캐시 무효화 (한 번만) revalidateTag(`tags-${selectedPackageId}`); revalidateTag(`forms-${selectedPackageId}`); revalidateTag("tags"); + // 업데이트된 모든 form의 캐시도 무효화 + for (const formCode of tagsByFormCode.keys()) { + revalidateTag(`form-data-${formCode}-${selectedPackageId}`); + } + return { success: true, data: { createdCount: createdTags.length, tags: createdTags, - formsInfo: allFormsInfo + formsInfo: allFormsInfo, + formEntriesUpdated: tagsByFormCode.size // 업데이트된 formEntry 수 } }; }); @@ -760,7 +1090,6 @@ export async function bulkCreateTags( return { error: getErrorMessage(err) || "Failed to create tags" }; } } - /** 복수 삭제 */ interface RemoveTagsInput { ids: number[]; diff --git a/lib/vendor-document-list/enhanced-document-service.ts b/lib/vendor-document-list/enhanced-document-service.ts index 00f40ea6..d39dfaa4 100644 --- a/lib/vendor-document-list/enhanced-document-service.ts +++ b/lib/vendor-document-list/enhanced-document-service.ts @@ -86,8 +86,8 @@ export async function getEnhancedDocuments( input: GetEnhancedDocumentsSchema, contractId: number ) { - return unstable_cache( - async () => { + // return unstable_cache( + // async () => { try { const offset = (input.page - 1) * input.perPage @@ -140,6 +140,8 @@ export async function getEnhancedDocuments( return { data, total } }) + console.log(data) + const pageCount = Math.ceil(total / input.perPage) return { data, pageCount, total } @@ -147,19 +149,19 @@ export async function getEnhancedDocuments( console.error("Error fetching enhanced documents:", err) return { data: [], pageCount: 0, total: 0 } } - }, - [JSON.stringify(input), String(contractId)], - { - revalidate: 3600, - tags: [`enhanced-documents-${contractId}`], - } - )() + // }, + // [JSON.stringify(input), String(contractId)], + // { + // revalidate: 3600, + // tags: [`enhanced-documents-${contractId}`], + // } + // )() } // 통계 데이터 가져오기 export async function getDocumentStatistics(contractId: number) { - return unstable_cache( - async () => { + // return unstable_cache( + // async () => { try { const result = await db .select({ @@ -229,13 +231,13 @@ export async function getDocumentStatistics(contractId: number) { avgProgress: 0, } } - }, - [`document-stats-${contractId}`], - { - revalidate: 1800, // 30분 캐시 - tags: [`document-stats-${contractId}`], - } - )() + // }, + // [`document-stats-${contractId}`], + // { + // revalidate: 1800, // 30분 캐시 + // tags: [`document-stats-${contractId}`], + // } + // )() } // 빠른 필터 데이터 diff --git a/lib/vendor-document-list/service.ts b/lib/vendor-document-list/service.ts index 75c9b6cd..356bc792 100644 --- a/lib/vendor-document-list/service.ts +++ b/lib/vendor-document-list/service.ts @@ -21,8 +21,8 @@ import { revalidateTag, unstable_noStore ,revalidatePath} from "next/cache"; */ export async function getVendorDocuments(input: GetVendorDcoumentsSchema, id: number) { - return unstable_cache( - async () => { + // return unstable_cache( + // async () => { try { const offset = (input.page - 1) * input.perPage; @@ -69,13 +69,13 @@ export async function getVendorDocuments(input: GetVendorDcoumentsSchema, id: nu // 에러 발생 시 디폴트 return { data: [], pageCount: 0 }; } - }, - [JSON.stringify(input), String(id)], // 캐싱 키 - { - revalidate: 3600, - tags: [`vendor-docuemnt-list-${id}`], - } - )(); + // }, + // [JSON.stringify(input), String(id)], // 캐싱 키 + // { + // revalidate: 3600, + // tags: [`vendor-docuemnt-list-${id}`], + // } + // )(); } diff --git a/lib/vendor-document-list/table/add-doc-dialog.tsx b/lib/vendor-document-list/table/add-doc-dialog.tsx index b108721c..9bedc810 100644 --- a/lib/vendor-document-list/table/add-doc-dialog.tsx +++ b/lib/vendor-document-list/table/add-doc-dialog.tsx @@ -37,9 +37,10 @@ type CreateDocumentSchema = z.infer<typeof createDocumentSchema>; interface AddDocumentListDialogProps { projectType: "ship" | "plant"; contractId: number; + onSuccess?: () => void; // ✅ onSuccess 콜백 추가 } -export function AddDocumentListDialog({ projectType, contractId }: AddDocumentListDialogProps) { +export function AddDocumentListDialog({ projectType, contractId, onSuccess }: AddDocumentListDialogProps) { const [open, setOpen] = React.useState(false); const [isSubmitting, setIsSubmitting] = React.useState(false); const router = useRouter(); @@ -98,8 +99,12 @@ export function AddDocumentListDialog({ projectType, contractId }: AddDocumentLi } as CreateDocumentInputType); if (result.success) { - // 성공 시 캐시 무효화 - await invalidateDocumentCache(contractId); + // ✅ 캐시 무효화 시도 (에러가 나더라도 계속 진행) + try { + await invalidateDocumentCache(contractId); + } catch (cacheError) { + console.warn('Cache invalidation failed:', cacheError); + } // 토스트 메시지 toast({ @@ -109,10 +114,23 @@ export function AddDocumentListDialog({ projectType, contractId }: AddDocumentLi }); // 모달 닫기 및 폼 리셋 - form.reset(); + form.reset({ + docNumber: "", + title: "", + stages: defaultStages + }); setOpen(false); - router.refresh(); + // ✅ 성공 콜백 호출 (부모 컴포넌트에서 추가 처리 가능) + if (onSuccess) { + onSuccess(); + } + + // ✅ 라우터 새로고침 (약간의 지연을 두고 실행) + setTimeout(() => { + router.refresh(); + }, 100); + } else { // 실패 시 에러 토스트 toast({ diff --git a/lib/vendor-document-list/table/enhanced-doc-table-columns.tsx b/lib/vendor-document-list/table/enhanced-doc-table-columns.tsx index 534a80a0..c8487d82 100644 --- a/lib/vendor-document-list/table/enhanced-doc-table-columns.tsx +++ b/lib/vendor-document-list/table/enhanced-doc-table-columns.tsx @@ -30,7 +30,9 @@ import { FileText, Eye, Edit, - Trash2 + Trash2, + Building, + Code } from "lucide-react" import { cn } from "@/lib/utils" @@ -140,7 +142,11 @@ export function getUpdatedEnhancedColumns({ setRowAction, projectType }: GetColumnsProps): ColumnDef<EnhancedDocumentsView>[] { - return [ + const isPlantProject = projectType === "plant" + + + // 기본 컬럼들 + const baseColumns: ColumnDef<EnhancedDocumentsView>[] = [ // 체크박스 선택 { id: "select", @@ -177,13 +183,8 @@ export function getUpdatedEnhancedColumns({ cell: ({ row }) => { const doc = row.original return ( - <div className="flex flex-col gap-1 items-start"> {/* ✅ items-start 추가 */} + <div className="flex flex-col gap-1 items-start"> <span className="font-mono text-sm font-medium">{doc.docNumber}</span> - {/* {doc.currentStagePriority && ( - <Badge variant={getPriorityColor(doc.currentStagePriority)} className="self-start inline-flex w-auto shrink-0 whitespace-nowrap text-xs" > - {getPriorityText(doc.currentStagePriority)} - </Badge> - )} */} </div> ) }, @@ -193,7 +194,93 @@ export function getUpdatedEnhancedColumns({ excelHeader: "문서번호" }, }, + ] + // ✅ Ship 프로젝트용 추가 컬럼들 + const plantColumns: ColumnDef<EnhancedDocumentsView>[] = isPlantProject ? [ + // 벤더 문서번호 + { + accessorKey: "vendorDocNumber", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="벤더 문서번호" /> + ), + cell: ({ row }) => { + const doc = row.original + return ( + <div className="flex flex-col gap-1 items-start"> + {doc.vendorDocNumber ? ( + <span className="font-mono text-sm text-blue-600">{doc.vendorDocNumber}</span> + ) : ( + <span className="text-gray-400 text-sm">-</span> + )} + </div> + ) + }, + size: 120, + enableResizing: true, + meta: { + excelHeader: "벤더 문서번호" + }, + }, + + // 프로젝트 코드 + { + accessorKey: "projectCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="프로젝트" /> + ), + cell: ({ row }) => { + const doc = row.original + return ( + <div className="flex items-center gap-2"> + {/* <Code className="w-4 h-4 text-gray-500" /> */} + <span className="font-mono text-sm font-medium text-gray-700"> + {doc.projectCode || '-'} + </span> + </div> + ) + }, + size: 100, + enableResizing: true, + meta: { + excelHeader: "프로젝트 코드" + }, + }, + + // 벤더 정보 + { + accessorKey: "vendorName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="벤더" /> + ), + cell: ({ row }) => { + const doc = row.original + return ( + <div className="flex flex-col gap-1 items-start"> + <div className="flex items-center gap-2"> + {/* <Building className="w-4 h-4 text-gray-500" /> */} + <span className="text-sm font-medium text-gray-900"> + {doc.vendorName || '-'} + </span> + </div> + {doc.vendorCode && ( + <span className="text-xs font-mono text-gray-500 bg-gray-100 px-2 py-0.5 rounded"> + {doc.vendorCode} + </span> + )} + </div> + ) + }, + size: 150, + enableResizing: true, + meta: { + excelHeader: "벤더명" + }, + }, + ] : [] + + // 나머지 공통 컬럼들 + const commonColumns: ColumnDef<EnhancedDocumentsView>[] = [ // 문서명 + 담당자 { accessorKey: "title", @@ -223,7 +310,7 @@ export function getUpdatedEnhancedColumns({ </div> ) }, - size: 250, + size: isPlantProject ? 200 : 250, // Ship 프로젝트일 때는 너비 조정 enableResizing: true, meta: { excelHeader: "문서명" @@ -341,7 +428,6 @@ export function getUpdatedEnhancedColumns({ return ( <div className="flex flex-col gap-1 items-start"> <span className="font-mono text-sm font-medium">{doc.latestRevision}</span> - {/* <div className="text-xs text-gray-500">{doc.latestRevisionUploaderName}</div> */} {doc.latestRevisionStatus && ( <Badge variant={getStatusColor(doc.latestRevisionStatus)} className="self-start inline-flex w-auto shrink-0 whitespace-nowrap text-xs" > {getStatusText(doc.latestRevisionStatus)} @@ -381,7 +467,6 @@ export function getUpdatedEnhancedColumns({ }, // 액션 메뉴 - // 액션 메뉴 { id: "actions", enableHiding: false, @@ -532,6 +617,13 @@ export function getUpdatedEnhancedColumns({ size: 40, } ] + + // ✅ 모든 컬럼을 순서대로 결합 + return [ + ...baseColumns, // 체크박스, 문서번호 + ...plantColumns, // Ship 전용 컬럼들 (조건부) + ...commonColumns // 나머지 공통 컬럼들 + ] } // 확장된 행 컨텐츠 컴포넌트 (업데이트된 버전) diff --git a/lib/vendor-document-list/table/enhanced-doc-table-toolbar-actions.tsx b/lib/vendor-document-list/table/enhanced-doc-table-toolbar-actions.tsx index 368b1e1c..fa1b957b 100644 --- a/lib/vendor-document-list/table/enhanced-doc-table-toolbar-actions.tsx +++ b/lib/vendor-document-list/table/enhanced-doc-table-toolbar-actions.tsx @@ -40,6 +40,16 @@ export function EnhancedDocTableToolbarActions({ // 필요시 추가 액션 수행 } + const handleDocumentAdded = () => { + // 테이블 새로고침 + table.resetRowSelection() + + // 추가적인 새로고침 시도 + setTimeout(() => { + window.location.reload() // 강제 새로고침 + }, 500) + } + return ( <div className="flex items-center gap-2"> {/* 기존 액션들 */} @@ -52,14 +62,13 @@ export function EnhancedDocTableToolbarActions({ /> ) : null} - {/* 메인 액션 버튼들 */} - {projectType === "plant" && ( - <Button onClick={onNewDocument} className="flex items-center gap-2"> - <Plus className="w-4 h-4" /> - 새 문서 - </Button> - )} - + {/* ✅ AddDocumentListDialog에 필요한 props 전달 */} + <AddDocumentListDialog + projectType={projectType} + contractId={selectedPackageId} + onSuccess={handleDocumentAdded} // ✅ 성공 콜백 추가 + /> + {/* 일괄 업로드 버튼 */} <Button variant="outline" diff --git a/lib/vendor-document-list/table/enhanced-documents-table.tsx b/lib/vendor-document-list/table/enhanced-documents-table.tsx index d0f2991a..14c52455 100644 --- a/lib/vendor-document-list/table/enhanced-documents-table.tsx +++ b/lib/vendor-document-list/table/enhanced-documents-table.tsx @@ -10,7 +10,7 @@ import type { import { useDataTable } from "@/hooks/use-data-table" import { StageRevisionExpandedContent } from "./stage-revision-expanded-content" import { RevisionUploadDialog } from "./revision-upload-dialog" -import { SimplifiedDocumentEditDialog } from "./simplified-document-edit-dialog" +// ✅ UpdateDocumentSheet import 추가 import { EnhancedDocTableToolbarActions } from "./enhanced-doc-table-toolbar-actions" import { getEnhancedDocuments } from "../enhanced-document-service" import type { EnhancedDocument } from "@/types/enhanced-documents" @@ -27,22 +27,28 @@ import { import { getUpdatedEnhancedColumns } from "./enhanced-doc-table-columns" import { ExpandableDataTable } from "@/components/data-table/expandable-data-table" import { toast } from "sonner" -// import { ViewDocumentDialog } from "@/components/documents/view-document-dialog" +import { UpdateDocumentSheet } from "./update-doc-sheet" interface FinalIntegratedDocumentsTableProps { promises: Promise<[Awaited<ReturnType<typeof getEnhancedDocuments>>]> selectedPackageId: number projectType: "ship" | "plant" + // ✅ contractId 추가 (AddDocumentListDialog에서 필요) + contractId: number } export function EnhancedDocumentsTable({ promises, selectedPackageId, projectType, + contractId, // ✅ contractId 추가 }: FinalIntegratedDocumentsTableProps) { // 데이터 로딩 const [{ data, pageCount, total }] = React.use(promises) + console.log(data) + + // 상태 관리 const [rowAction, setRowAction] = React.useState<DataTableRowAction<EnhancedDocument> | null>(null) const [expandedRows, setExpandedRows] = React.useState<Set<string>>(new Set()) @@ -51,14 +57,12 @@ export function EnhancedDocumentsTable({ // ✅ 스테이지 확장 상태 관리 (문서별로 관리) const [expandedStages, setExpandedStages] = React.useState<Record<string, Record<number, boolean>>>({}) - // 다이얼로그 상태들 + // ✅ 다이얼로그 상태들 - editDialogOpen -> editSheetOpen으로 변경 const [uploadDialogOpen, setUploadDialogOpen] = React.useState(false) - const [editDialogOpen, setEditDialogOpen] = React.useState(false) - // const [viewDialogOpen, setViewDialogOpen] = React.useState(false) + const [editSheetOpen, setEditSheetOpen] = React.useState(false) // Sheet로 변경 const [selectedDocument, setSelectedDocument] = React.useState<EnhancedDocument | null>(null) const [selectedStage, setSelectedStage] = React.useState<string>("") const [selectedRevision, setSelectedRevision] = React.useState<string>("") - // const [selectedRevisions, setSelectedRevisions] = React.useState<any[]>([]) const [uploadMode, setUploadMode] = React.useState<'new' | 'append'>('new') // 다음 리비전 계산 함수 @@ -94,7 +98,7 @@ export function EnhancedDocumentsTable({ // 액션 타입에 따른 다이얼로그 열기 switch (action.type) { case "update": - setEditDialogOpen(true) + setEditSheetOpen(true) // ✅ Sheet 열기로 변경 break case "upload": setSelectedStage(action.row.original.currentStageName || "") @@ -114,9 +118,7 @@ export function EnhancedDocumentsTable({ } } }, - projectType - }), [expandedRows, projectType] ) @@ -219,19 +221,10 @@ export function EnhancedDocumentsTable({ return sortedRevisions[sortedRevisions.length - 1]?.revision || null }, []) - // const handleEditDocument = (document: EnhancedDocument) => { - // setSelectedDocument(document) - // setEditDialogOpen(true) - // } - - // const handleViewRevisions = (revisions: any[]) => { - // setSelectedRevisions(revisions) - // setViewDialogOpen(true) - // } - + // ✅ 새 문서 추가 핸들러 - EnhancedDocTableToolbarActions에서 AddDocumentListDialog를 직접 렌더링하므로 별도 상태 관리 불필요 const handleNewDocument = () => { - setSelectedDocument(null) - setEditDialogOpen(true) + // AddDocumentListDialog는 자체적으로 Dialog trigger를 가지므로 별도 처리 불필요 + // EnhancedDocTableToolbarActions에서 처리됨 } // ✅ 스테이지 토글 핸들러 추가 @@ -266,19 +259,32 @@ export function EnhancedDocumentsTable({ } } - // 다이얼로그 닫기 + // ✅ 다이얼로그 닫기 함수 수정 const closeAllDialogs = () => { setUploadDialogOpen(false) - setEditDialogOpen(false) - // setViewDialogOpen(false) + setEditSheetOpen(false) // editDialogOpen -> editSheetOpen setSelectedDocument(null) setSelectedStage("") setSelectedRevision("") - // setSelectedRevisions([]) setUploadMode('new') // ✅ 모드 초기화 setRowAction(null) } + // ✅ EnhancedDocument를 UpdateDocumentSheet의 document 형식으로 변환하는 함수 + const convertToUpdateFormat = React.useCallback((doc: EnhancedDocument | null) => { + if (!doc) return null + + return { + id: doc.documentId, + contractId: contractId, // contractId 사용 + docNumber: doc.docNumber, + title: doc.title, + status: doc.status || "pending", // 기본값 설정 + description: doc.description || null, + remarks: doc.remarks || null, + } + }, [contractId]) + // 필터 필드 정의 const filterFields: DataTableFilterField<EnhancedDocument>[] = [ { @@ -473,7 +479,6 @@ export function EnhancedDocumentsTable({ <StageRevisionExpandedContent document={document} onUploadRevision={handleUploadRevision} - // onViewRevision={handleViewRevisions} projectType={projectType} expandedStages={expandedStages[String(document.documentId)] || {}} onStageToggle={(stageId) => handleStageToggle(String(document.documentId), stageId)} @@ -492,44 +497,18 @@ export function EnhancedDocumentsTable({ table={table} projectType={projectType} selectedPackageId={selectedPackageId} + contractId={contractId} // ✅ contractId 추가 onNewDocument={handleNewDocument} onBulkAction={handleBulkAction} /> </DataTableAdvancedToolbar> </ExpandableDataTable> </div> - - {/* 선택된 항목 정보 */} - {/* {table.getFilteredSelectedRowModel().rows.length > 0 && ( - <div className="flex items-center justify-between p-4 bg-blue-50 border border-blue-200 rounded-lg"> - <span className="text-sm text-blue-700"> - {table.getFilteredSelectedRowModel().rows.length}개 항목이 선택되었습니다 - </span> - <div className="flex gap-2"> - <Button - size="sm" - variant="outline" - onClick={() => table.toggleAllRowsSelected(false)} - > - 선택 해제 - </Button> - <Button - size="sm" - onClick={() => { - const selectedRows = table.getFilteredSelectedRowModel().rows - handleBulkAction('bulk_approve', selectedRows) - }} - > - 선택 항목 승인 - </Button> - </div> - </div> - )} */} </div> - {/* 분리된 다이얼로그들 */} + {/* ✅ 분리된 다이얼로그들 - UpdateDocumentSheet와 AddDocumentListDialog로 교체 */} - {/* ✅ 리비전 업로드 다이얼로그 - mode props 추가 */} + {/* 리비전 업로드 다이얼로그 - mode props 추가 */} <RevisionUploadDialog open={uploadDialogOpen} onOpenChange={(open) => { @@ -543,28 +522,15 @@ export function EnhancedDocumentsTable({ mode={uploadMode} /> - {/* 문서 편집 다이얼로그 */} - <SimplifiedDocumentEditDialog - open={editDialogOpen} - onOpenChange={(open) => { - if (!open) closeAllDialogs() - else setEditDialogOpen(open) - }} - document={selectedDocument} - projectType={projectType} - /> - - {/* PDF 뷰어 다이얼로그 (기존 ViewDocumentDialog 재사용) */} - - {/* <ViewDocumentDialog - open={viewDialogOpen} + {/* ✅ 문서 편집 Sheet로 교체 */} + <UpdateDocumentSheet + open={editSheetOpen} onOpenChange={(open) => { if (!open) closeAllDialogs() - else setViewDialogOpen(open) + else setEditSheetOpen(open) }} - revisions={selectedRevisions} + document={convertToUpdateFormat(selectedDocument)} /> - */} </div> ) }
\ No newline at end of file diff --git a/lib/vendor-investigation/table/vendor-details-dialog.tsx b/lib/vendor-investigation/table/vendor-details-dialog.tsx index 27ed7826..e5294d88 100644 --- a/lib/vendor-investigation/table/vendor-details-dialog.tsx +++ b/lib/vendor-investigation/table/vendor-details-dialog.tsx @@ -127,7 +127,7 @@ export function VendorDetailsDialog({ onOpenChange(false) // Navigate to vendor profile page with router - router.push(`/evcp/vendors/${vendorId}`) + router.push(`/evcp/vendors/${vendorId}/info`) } return ( diff --git a/lib/vendors/items-table/delete-vendor-items-dialog.tsx b/lib/vendors/items-table/delete-vendor-items-dialog.tsx new file mode 100644 index 00000000..bcc84cc8 --- /dev/null +++ b/lib/vendors/items-table/delete-vendor-items-dialog.tsx @@ -0,0 +1,182 @@ +"use client" + +import * as React from "react" +import { VendorItemsView } from "@/db/schema/vendors" +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 { removeVendorItems } from "../service" + +interface DeleteVendorItemsDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + vendorId: number + items: Row<VendorItemsView>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeleteVendorItemsDialog({ + vendorId, + items, + showTrigger = true, + onSuccess, + ...props +}: DeleteVendorItemsDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onDelete() { + startDeleteTransition(async () => { + const { error } = await removeVendorItems({ + itemCodes: items.map((item) => item.itemCode).filter(Boolean) as string[], + vendorId, + }) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + toast.success( + items.length === 1 + ? "Item deleted successfully" + : `${items.length} items deleted successfully` + ) + onSuccess?.() + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({items.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>Are you absolutely sure?</DialogTitle> + <DialogDescription> + This action cannot be undone. This will permanently delete{" "} + <span className="font-medium">{items.length}</span> + {items.length === 1 ? " item" : " items"} from this vendor. + </DialogDescription> + </DialogHeader> + + {/* 삭제될 아이템 목록 미리보기 */} + <div className="max-h-32 overflow-y-auto rounded-md border p-2"> + {items.map((item, index) => ( + <div key={item.itemCode || index} className="flex justify-between text-sm py-1"> + <span className="font-medium truncate">{item.itemName}</span> + <span className="text-muted-foreground ml-2 flex-shrink-0"> + {item.itemCode} + </span> + </div> + ))} + </div> + + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">Cancel</Button> + </DialogClose> + <Button + aria-label="Delete selected items" + 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 ({items.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>Are you absolutely sure?</DrawerTitle> + <DrawerDescription> + This action cannot be undone. This will permanently delete{" "} + <span className="font-medium">{items.length}</span> + {items.length === 1 ? " item" : " items"} from this vendor. + </DrawerDescription> + </DrawerHeader> + + {/* 삭제될 아이템 목록 미리보기 */} + <div className="max-h-32 overflow-y-auto rounded-md border p-2 mx-4"> + {items.map((item, index) => ( + <div key={item.itemCode || index} className="flex justify-between text-sm py-1"> + <span className="font-medium truncate">{item.itemName}</span> + <span className="text-muted-foreground ml-2 flex-shrink-0"> + {item.itemCode} + </span> + </div> + ))} + </div> + + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">Cancel</Button> + </DrawerClose> + <Button + aria-label="Delete selected items" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + Delete + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +}
\ No newline at end of file diff --git a/lib/vendors/items-table/item-action-dialog.tsx b/lib/vendors/items-table/item-action-dialog.tsx new file mode 100644 index 00000000..19df27f8 --- /dev/null +++ b/lib/vendors/items-table/item-action-dialog.tsx @@ -0,0 +1,248 @@ +// components/vendor-items/item-actions-dialogs.tsx +"use client" + +import * as React from "react" +import type { DataTableRowAction } from "@/types/table" +import { VendorItemsView } from "@/db/schema/vendors" +import { toast } from "sonner" + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { Button } from "@/components/ui/button" +import { Label } from "@/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" + +import { updateVendorItem, deleteVendorItem, getItemsForVendor } from "../service" + +interface ItemActionsDialogsProps { + vendorId: number + rowAction: DataTableRowAction<VendorItemsView> | null + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorItemsView> | null>> +} + +export function ItemActionsDialogs({ + vendorId, + rowAction, + setRowAction, +}: ItemActionsDialogsProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + const [isDeletePending, startDeleteTransition] = React.useTransition() + const [availableMaterials, setAvailableMaterials] = React.useState<any[]>([]) + const [selectedItemCode, setSelectedItemCode] = React.useState<string>("") + + // 사용 가능한 재료 목록 로드 + React.useEffect(() => { + if (rowAction?.type === "update") { + getItemsForVendor(vendorId).then((result) => { + if (result.data) { + setAvailableMaterials(result.data) + } + }) + } + }, [rowAction, vendorId]) + + // Edit Dialog + const EditDialog = () => { + if (!rowAction || rowAction.type !== "update") return null + + const item = rowAction.row.original + + const handleSubmit = () => { + if (!selectedItemCode) { + toast.error("Please select a new item") + return + } + + if (!item.itemCode) { + toast.error("Invalid item code") + return + } + + startUpdateTransition(async () => { + const result = await updateVendorItem(vendorId, item.itemCode, selectedItemCode) + + if (result.error) { + toast.error(result.error) + } else { + toast.success("Item updated successfully") + setRowAction(null) + } + }) + } + + return ( + <Dialog + open={true} + onOpenChange={(open) => !open && setRowAction(null)} + > + <DialogContent className="sm:max-w-[425px]"> + <DialogHeader> + <DialogTitle>Change Item</DialogTitle> + <DialogDescription> + Select a new item to replace "{item.itemName}" (Code: {item.itemCode || 'N/A'}). + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + <div className="space-y-2"> + <Label>Current Item</Label> + <div className="p-2 bg-muted rounded-md"> + <div className="font-medium">{item.itemName}</div> + <div className="text-sm text-muted-foreground">Code: {item.itemCode || 'N/A'}</div> + </div> + </div> + + <div className="space-y-2"> + <Label htmlFor="newItem">New Item</Label> + <Select value={selectedItemCode} onValueChange={setSelectedItemCode}> + <SelectTrigger> + <SelectValue placeholder="Select a new item" /> + </SelectTrigger> + <SelectContent> + {availableMaterials.map((material) => ( + <SelectItem key={material.itemCode} value={material.itemCode}> + <div> + <div className="font-medium">{material.itemName}</div> + <div className="text-sm text-muted-foreground">Code: {material.itemCode}</div> + </div> + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => setRowAction(null)} + disabled={isUpdatePending} + > + Cancel + </Button> + <Button + onClick={handleSubmit} + disabled={isUpdatePending || !selectedItemCode} + > + {isUpdatePending ? "Updating..." : "Update Item"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + // Delete Dialog + const DeleteDialog = () => { + if (!rowAction || rowAction.type !== "delete") return null + + const item = rowAction.row.original + + const handleDelete = () => { + if (!item.itemCode) { + toast.error("Invalid item code") + return + } + + startDeleteTransition(async () => { + const result = await deleteVendorItem(vendorId, item.itemCode) + + if (result.error) { + toast.error(result.error) + } else { + toast.success("Item deleted successfully") + setRowAction(null) + } + }) + } + + return ( + <AlertDialog + open={true} + onOpenChange={(open) => !open && setRowAction(null)} + > + <AlertDialogContent> + return ( + <AlertDialog + open={true} + onOpenChange={(open) => !open && setRowAction(null)} + > + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>Are you sure?</AlertDialogTitle> + <AlertDialogDescription> + This will permanently delete the item "{item.itemName}" (Code: {item.itemCode || 'N/A'}). + This action cannot be undone. + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel disabled={isDeletePending}> + Cancel + </AlertDialogCancel> + <AlertDialogAction + onClick={handleDelete} + disabled={isDeletePending} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {isDeletePending ? "Deleting..." : "Delete"} + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + ) + } + + return ( + <> + <EditDialog /> + <DeleteDialog /> + </> + ) +} + <AlertDialogFooter> + <AlertDialogCancel disabled={isDeletePending}> + Cancel + </AlertDialogCancel> + <AlertDialogAction + onClick={handleDelete} + disabled={isDeletePending} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {isDeletePending ? "Deleting..." : "Delete"} + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + ) + } + + return ( + <> + <EditDialog /> + <DeleteDialog /> + </> + ) +}
\ No newline at end of file diff --git a/lib/vendors/items-table/item-table-columns.tsx b/lib/vendors/items-table/item-table-columns.tsx index b5d26434..769722e4 100644 --- a/lib/vendors/items-table/item-table-columns.tsx +++ b/lib/vendors/items-table/item-table-columns.tsx @@ -24,10 +24,8 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" -import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header" -import { VendorItemsView, vendors } from "@/db/schema/vendors" -import { modifyVendor } from "../service" +import { VendorItemsView } from "@/db/schema/vendors" import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" import { vendorItemsColumnsConfig } from "@/config/vendorItemsColumnsConfig" diff --git a/lib/vendors/items-table/item-table-toolbar-actions.tsx b/lib/vendors/items-table/item-table-toolbar-actions.tsx index f7bd2bf6..8be67f71 100644 --- a/lib/vendors/items-table/item-table-toolbar-actions.tsx +++ b/lib/vendors/items-table/item-table-toolbar-actions.tsx @@ -1,3 +1,4 @@ +// components/vendor-items/item-table-toolbar-actions.tsx "use client" import * as React from "react" @@ -8,21 +9,26 @@ import { toast } from "sonner" import { exportTableToExcel } from "@/lib/export" import { Button } from "@/components/ui/button" - // 만약 서버 액션이나 API 라우트를 이용해 업로드 처리한다면 import import { importTasksExcel } from "@/lib/tasks/service" // 예시 -import { VendorItemsView } from "@/db/schema/vendors" +import { + VendorItemsView +} from "@/db/schema/vendors" import { AddItemDialog } from "./add-item-dialog" +import { DeleteVendorItemsDialog } from "./delete-vendor-items-dialog" interface VendorsTableToolbarActionsProps { table: Table<VendorItemsView> vendorId: number } -export function VendorsTableToolbarActions({ table,vendorId }: VendorsTableToolbarActionsProps) { +export function VendorsTableToolbarActions({ table, vendorId }: VendorsTableToolbarActionsProps) { // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 const fileInputRef = React.useRef<HTMLInputElement>(null) + // 선택된 행들 가져오기 + const selectedRows = table.getFilteredSelectedRowModel().rows + // 파일이 선택되었을 때 처리 async function onFileChange(event: React.ChangeEvent<HTMLInputElement>) { const file = event.target.files?.[0] @@ -55,7 +61,6 @@ export function VendorsTableToolbarActions({ table,vendorId }: VendorsTableToolb } catch (err) { toast.error("파일 업로드 중 오류가 발생했습니다.") - } } @@ -66,10 +71,21 @@ export function VendorsTableToolbarActions({ table,vendorId }: VendorsTableToolb return ( <div className="flex items-center gap-2"> - - <AddItemDialog vendorId={vendorId}/> - - {/** 3) Import 버튼 (파일 업로드) */} + <AddItemDialog vendorId={vendorId} /> + + {/* 삭제 버튼 - 선택된 행이 있을 때만 표시 */} + {selectedRows.length > 0 && ( + <DeleteVendorItemsDialog + vendorId={vendorId} + items={selectedRows.map((row) => row.original)} + onSuccess={() => { + // 삭제 성공 후 선택 해제 + table.toggleAllPageRowsSelected(false) + }} + /> + )} + + {/** Import 버튼 (파일 업로드) */} <Button variant="outline" size="sm" className="gap-2" onClick={handleImportClick}> <Upload className="size-4" aria-hidden="true" /> <span className="hidden sm:inline">Import</span> @@ -86,13 +102,13 @@ export function VendorsTableToolbarActions({ table,vendorId }: VendorsTableToolb onChange={onFileChange} /> - {/** 4) Export 버튼 */} + {/** Export 버튼 */} <Button variant="outline" size="sm" onClick={() => exportTableToExcel(table, { - filename: "tasks", + filename: "vendor-items", excludeColumns: ["select", "actions"], }) } diff --git a/lib/vendors/items-table/item-table.tsx b/lib/vendors/items-table/item-table.tsx index d8cd0ea2..a58a136c 100644 --- a/lib/vendors/items-table/item-table.tsx +++ b/lib/vendors/items-table/item-table.tsx @@ -13,8 +13,11 @@ import { DataTable } from "@/components/data-table/data-table" import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" import { useFeatureFlags } from "./feature-flags-provider" import { getColumns } from "./item-table-columns" -import { getVendorItems, } from "../service" -import { VendorItemsView, vendors } from "@/db/schema/vendors" +import { + getVendorItems, +} from "../service" +import { VendorItemsView } from "@/db/schema/vendors" +import { ItemActionsDialogs } from "./item-action-dialog" import { VendorsTableToolbarActions } from "./item-table-toolbar-actions" interface VendorsTableProps { @@ -22,25 +25,24 @@ interface VendorsTableProps { [ Awaited<ReturnType<typeof getVendorItems>>, ] - >, - vendorId:number + >, + vendorId: number } -export function VendorItemsTable({ promises , vendorId}: VendorsTableProps) { +export function VendorItemsTable({ promises, vendorId }: VendorsTableProps) { const { featureFlags } = useFeatureFlags() // Suspense로 받아온 데이터 const [{ data, pageCount }] = React.use(promises) const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorItemsView> | null>(null) - // getColumns() 호출 시, router를 주입 + // getColumns() 호출 시, setRowAction을 주입 const columns = React.useMemo( () => getColumns({ setRowAction }), [setRowAction] ) const filterFields: DataTableFilterField<VendorItemsView>[] = [ - ] const advancedFilterFields: DataTableAdvancedFilterField<VendorItemsView>[] = [ @@ -80,6 +82,13 @@ export function VendorItemsTable({ promises , vendorId}: VendorsTableProps) { <VendorsTableToolbarActions table={table} vendorId={vendorId} /> </DataTableAdvancedToolbar> </DataTable> + + {/* 수정/삭제 다이얼로그 추가 */} + <ItemActionsDialogs + vendorId={vendorId} + rowAction={rowAction} + setRowAction={setRowAction} + /> </> ) }
\ No newline at end of file diff --git a/lib/vendors/materials-table/add-item-dialog.tsx b/lib/vendors/materials-table/add-item-dialog.tsx new file mode 100644 index 00000000..f4a7fef7 --- /dev/null +++ b/lib/vendors/materials-table/add-item-dialog.tsx @@ -0,0 +1,289 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Check, ChevronsUpDown } 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, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { cn } from "@/lib/utils" + +import { + createVendorItemSchema, + type CreateVendorItemSchema, +} from "../validations" + +import { createVendorItem, getMaterialsForVendor, ItemDropdownOption } from "../service" + +interface AddItemDialogProps { + vendorId: number +} + +export function AddItemDialog({ vendorId }: AddItemDialogProps) { + const [open, setOpen] = React.useState(false) + const [commandOpen, setCommandOpen] = React.useState(false) + const [items, setItems] = React.useState<ItemDropdownOption[]>([]) + const [filteredItems, setFilteredItems] = React.useState<ItemDropdownOption[]>([]) + const [isLoading, setIsLoading] = React.useState(false) + const [searchTerm, setSearchTerm] = React.useState("") + + // 선택된 아이템의 정보를 보여주기 위한 상태 + const [selectedItem, setSelectedItem] = React.useState<{ + itemName: string; + description: string; + } | null>(null) + + // react-hook-form 세팅 - 서버로 보낼 값은 vendorId와 itemCode만 + const form = useForm<CreateVendorItemSchema>({ + resolver: zodResolver(createVendorItemSchema), + defaultValues: { + vendorId, + itemCode: "", + }, + }) + + console.log(vendorId) + + // 아이템 목록 가져오기 (한 번만 호출) + const fetchItems = React.useCallback(async () => { + if (items.length > 0) return // 이미 로드된 경우 스킵 + + setIsLoading(true) + try { + const result = await getMaterialsForVendor(vendorId) + if (result.data) { + setItems(result.data) + setFilteredItems(result.data) + } + } catch (error) { + console.error("Failed to fetch items:", error) + } finally { + setIsLoading(false) + } + }, [items.length]) + + // 팝오버 열릴 때 아이템 목록 로드 + React.useEffect(() => { + if (commandOpen) { + fetchItems() + } + }, [commandOpen, fetchItems]) + + // 클라이언트 사이드 필터링 + React.useEffect(() => { + if (!items.length) return + + if (!searchTerm.trim()) { + setFilteredItems(items) + return + } + + const lowerSearch = searchTerm.toLowerCase() + const filtered = items.filter(item => + item.itemCode.toLowerCase().includes(lowerSearch) || + item.itemName.toLowerCase().includes(lowerSearch) || + (item.description && item.description.toLowerCase().includes(lowerSearch)) + ) + + setFilteredItems(filtered) + }, [searchTerm, items]) + + // 선택된 아이템 데이터로 폼 업데이트 + const handleSelectItem = (item: ItemDropdownOption) => { + // 폼에는 itemCode만 설정 + form.setValue("itemCode", item.itemCode) + + // 나머지 정보는 표시용 상태에 저장 + setSelectedItem({ + itemName: item.itemName, + description: item.description || "", + }) + + setCommandOpen(false) + } + + // 폼 제출 - itemCode만 서버로 전송 + async function onSubmit(data: CreateVendorItemSchema) { + // 서버에는 vendorId와 itemCode만 전송됨 + const result = await createVendorItem(data) + console.log(result) + if (result.error) { + alert(`에러: ${result.error}`) + return + } + // 성공 시 모달 닫고 폼 리셋 + form.reset() + setSelectedItem(null) + setOpen(false) + } + + // 모달 열림/닫힘 핸들 + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + // 닫힐 때 폼 리셋 + form.reset() + setSelectedItem(null) + } + setOpen(nextOpen) + } + + // 현재 선택된 아이템 코드 + const selectedItemCode = form.watch("itemCode") + + // 선택된 아이템 코드가 있으면 상세 정보 표시를 위한 아이템 찾기 + const displayItemCode = selectedItemCode || "아이템 선택..." + const displayItemName = selectedItem?.itemName || "" + + return ( + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + {/* 모달 열기 버튼 */} + <DialogTrigger asChild> + <Button variant="default" size="sm"> + Add Item + </Button> + </DialogTrigger> + + <DialogContent className="max-h-[90vh] overflow-hidden flex flex-col"> + <DialogHeader> + <DialogTitle>Create New Item</DialogTitle> + <DialogDescription> + 아이템을 선택한 후 <b>Create</b> 버튼을 누르세요. + </DialogDescription> + </DialogHeader> + + {/* shadcn/ui Form + react-hook-form */} + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 overflow-hidden"> + <div className="space-y-4 py-4 flex-1 overflow-y-auto"> + + {/* 아이템 선택 */} + <div> + <FormLabel className="text-sm font-medium">아이템 선택</FormLabel> + <Popover open={commandOpen} onOpenChange={setCommandOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={commandOpen} + className="w-full justify-between mt-1" + > + {selectedItemCode + ? `${selectedItemCode} - ${displayItemName}` + : "아이템 선택..."} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[400px] p-0"> + <Command> + <CommandInput + placeholder="아이템 코드/이름 검색..." + onValueChange={setSearchTerm} + /> + <CommandList className="max-h-[200px]"> + <CommandEmpty>검색 결과가 없습니다</CommandEmpty> + {isLoading ? ( + <div className="py-6 text-center text-sm">로딩 중...</div> + ) : ( + <CommandGroup> + {filteredItems.map((item) => ( + <CommandItem + key={item.itemCode} + value={`${item.itemCode} ${item.itemName}`} + onSelect={() => handleSelectItem(item)} + > + <Check + className={cn( + "mr-2 h-4 w-4", + selectedItemCode === item.itemCode + ? "opacity-100" + : "opacity-0" + )} + /> + <span className="font-medium">{item.itemCode}</span> + <span className="ml-2 text-gray-500 truncate">- {item.itemName}</span> + </CommandItem> + ))} + </CommandGroup> + )} + </CommandList> + </Command> + </PopoverContent> + </Popover> + </div> + + {/* 아이템 정보 영역 - 선택된 경우에만 표시 */} + {selectedItem && ( + <div className="rounded-md border p-3 mt-4 overflow-hidden"> + <h3 className="font-medium text-sm mb-2">선택된 아이템 정보</h3> + + {/* Item Code - readonly (hidden field) */} + <FormField + control={form.control} + name="itemCode" + render={({ field }) => ( + <FormItem className="hidden"> + <FormControl> + <Input {...field} /> + </FormControl> + </FormItem> + )} + /> + + {/* Item Name (표시용) */} + <div className="mb-2"> + <p className="text-xs font-medium text-gray-500">Item Name</p> + <p className="text-sm mt-0.5 break-words">{selectedItem.itemName}</p> + </div> + + {/* Description (표시용) */} + {selectedItem.description && ( + <div> + <p className="text-xs font-medium text-gray-500">Description</p> + <p className="text-sm mt-0.5 break-words max-h-20 overflow-y-auto">{selectedItem.description}</p> + </div> + )} + </div> + )} + + </div> + + <DialogFooter className="flex-shrink-0 pt-2"> + <Button type="button" variant="outline" onClick={() => setOpen(false)}> + Cancel + </Button> + <Button + type="submit" + disabled={form.formState.isSubmitting || !selectedItemCode} + > + Create + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/vendors/materials-table/delete-vendor-items-dialog.tsx b/lib/vendors/materials-table/delete-vendor-items-dialog.tsx new file mode 100644 index 00000000..132a54fb --- /dev/null +++ b/lib/vendors/materials-table/delete-vendor-items-dialog.tsx @@ -0,0 +1,182 @@ +"use client" + +import * as React from "react" +import { VendorMaterialsView } from "@/db/schema/vendors" +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 { removeVendorMaterials } from "../service" + +interface DeleteVendorItemsDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + vendorId: number + items: Row<VendorMaterialsView>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeleteVendorItemsDialog({ + vendorId, + items, + showTrigger = true, + onSuccess, + ...props +}: DeleteVendorItemsDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onDelete() { + startDeleteTransition(async () => { + const { error } = await removeVendorMaterials({ + itemCodes: items.map((item) => item.itemCode).filter(Boolean) as string[], + vendorId, + }) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + toast.success( + items.length === 1 + ? "Item deleted successfully" + : `${items.length} items deleted successfully` + ) + onSuccess?.() + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({items.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>Are you absolutely sure?</DialogTitle> + <DialogDescription> + This action cannot be undone. This will permanently delete{" "} + <span className="font-medium">{items.length}</span> + {items.length === 1 ? " item" : " items"} from this vendor. + </DialogDescription> + </DialogHeader> + + {/* 삭제될 아이템 목록 미리보기 */} + <div className="max-h-32 overflow-y-auto rounded-md border p-2"> + {items.map((item, index) => ( + <div key={item.itemCode || index} className="flex justify-between text-sm py-1"> + <span className="font-medium truncate">{item.itemName}</span> + <span className="text-muted-foreground ml-2 flex-shrink-0"> + {item.itemCode} + </span> + </div> + ))} + </div> + + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">Cancel</Button> + </DialogClose> + <Button + aria-label="Delete selected items" + 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 ({items.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>Are you absolutely sure?</DrawerTitle> + <DrawerDescription> + This action cannot be undone. This will permanently delete{" "} + <span className="font-medium">{items.length}</span> + {items.length === 1 ? " item" : " items"} from this vendor. + </DrawerDescription> + </DrawerHeader> + + {/* 삭제될 아이템 목록 미리보기 */} + <div className="max-h-32 overflow-y-auto rounded-md border p-2 mx-4"> + {items.map((item, index) => ( + <div key={item.itemCode || index} className="flex justify-between text-sm py-1"> + <span className="font-medium truncate">{item.itemName}</span> + <span className="text-muted-foreground ml-2 flex-shrink-0"> + {item.itemCode} + </span> + </div> + ))} + </div> + + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">Cancel</Button> + </DrawerClose> + <Button + aria-label="Delete selected items" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + Delete + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +}
\ No newline at end of file diff --git a/lib/vendors/materials-table/feature-flags-provider.tsx b/lib/vendors/materials-table/feature-flags-provider.tsx new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/vendors/materials-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/vendors/materials-table/item-action-dialog.tsx b/lib/vendors/materials-table/item-action-dialog.tsx new file mode 100644 index 00000000..d06f609c --- /dev/null +++ b/lib/vendors/materials-table/item-action-dialog.tsx @@ -0,0 +1,207 @@ +"use client" + +import * as React from "react" +import type { DataTableRowAction } from "@/types/table" +import { VendorMaterialsView } from "@/db/schema/vendors" +import { toast } from "sonner" + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { Button } from "@/components/ui/button" +import { Label } from "@/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { deleteVendorMaterial, getMaterialsForVendor, updateVendorMaterial } from "../service" + + +interface ItemActionsDialogsProps { + vendorId: number + rowAction: DataTableRowAction<VendorMaterialsView> | null + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorMaterialsView> | null>> +} + +export function ItemActionsDialogs({ + vendorId, + rowAction, + setRowAction, +}: ItemActionsDialogsProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + const [isDeletePending, startDeleteTransition] = React.useTransition() + const [availableMaterials, setAvailableMaterials] = React.useState<any[]>([]) + const [selectedItemCode, setSelectedItemCode] = React.useState<string>("") + + // 사용 가능한 재료 목록 로드 + React.useEffect(() => { + if (rowAction?.type === "update") { + getMaterialsForVendor(vendorId).then((result) => { + if (result.data) { + setAvailableMaterials(result.data) + } + }) + } + }, [rowAction, vendorId]) + + // Edit Dialog + const EditDialog = () => { + if (!rowAction || rowAction.type !== "update") return null + + const item = rowAction.row.original + + const handleSubmit = () => { + if (!selectedItemCode) { + toast.error("Please select a new item") + return + } + + startUpdateTransition(async () => { + const result = await updateVendorMaterial(vendorId, item.itemCode, selectedItemCode) + + if (result.error) { + toast.error(result.error) + } else { + toast.success("Item updated successfully") + setRowAction(null) + } + }) + } + + return ( + <Dialog + open={true} + onOpenChange={(open) => !open && setRowAction(null)} + > + <DialogContent className="sm:max-w-[425px]"> + <DialogHeader> + <DialogTitle>Change Item</DialogTitle> + <DialogDescription> + Select a new item to replace "{item.itemName}" (Code: {item.itemCode}). + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + <div className="space-y-2"> + <Label>Current Item</Label> + <div className="p-2 bg-muted rounded-md"> + <div className="font-medium">{item.itemName}</div> + <div className="text-sm text-muted-foreground">Code: {item.itemCode}</div> + </div> + </div> + + <div className="space-y-2"> + <Label htmlFor="newItem">New Item</Label> + <Select value={selectedItemCode} onValueChange={setSelectedItemCode}> + <SelectTrigger> + <SelectValue placeholder="Select a new item" /> + </SelectTrigger> + <SelectContent> + {availableMaterials.map((material) => ( + <SelectItem key={material.itemCode} value={material.itemCode}> + <div> + <div className="font-medium">{material.itemName}</div> + <div className="text-sm text-muted-foreground">Code: {material.itemCode}</div> + </div> + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => setRowAction(null)} + disabled={isUpdatePending} + > + Cancel + </Button> + <Button + onClick={handleSubmit} + disabled={isUpdatePending || !selectedItemCode} + > + {isUpdatePending ? "Updating..." : "Update Item"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + // Delete Dialog + const DeleteDialog = () => { + if (!rowAction || rowAction.type !== "delete") return null + + const item = rowAction.row.original + + const handleDelete = () => { + startDeleteTransition(async () => { + const result = await deleteVendorMaterial(vendorId, item.itemCode) + + if (result.success) { + toast.success(result.message) + setRowAction(null) + } else { + toast.error(result.message) + } + }) + } + + return ( + <AlertDialog + open={true} + onOpenChange={(open) => !open && setRowAction(null)} + > + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>Are you sure?</AlertDialogTitle> + <AlertDialogDescription> + This will permanently delete the item "{item.itemName}" (Code: {item.itemCode}). + This action cannot be undone. + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel disabled={isDeletePending}> + Cancel + </AlertDialogCancel> + <AlertDialogAction + onClick={handleDelete} + disabled={isDeletePending} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {isDeletePending ? "Deleting..." : "Delete"} + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + ) + } + + return ( + <> + <EditDialog /> + <DeleteDialog /> + </> + ) +}
\ No newline at end of file diff --git a/lib/vendors/materials-table/item-table-columns.tsx b/lib/vendors/materials-table/item-table-columns.tsx new file mode 100644 index 00000000..d2aa0f8f --- /dev/null +++ b/lib/vendors/materials-table/item-table-columns.tsx @@ -0,0 +1,195 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Ellipsis } from "lucide-react" +import { toast } from "sonner" + +import { getErrorMessage } from "@/lib/handle-error" +import { formatDate } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +import { VendorMaterialsView } from "@/db/schema/vendors" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { vendorMaterialsColumnsConfig } from "@/config/vendorMaterialsColumnsConfig" + + + + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorMaterialsView> | null>>; +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<VendorMaterialsView>[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<VendorMaterialsView> = { + 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" + /> + ), + size:40, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) actions 컬럼 (Dropdown 메뉴) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef<VendorMaterialsView> = { + id: "actions", + enableHiding: false, + 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-4" 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> + ) + }, + size: 40, + } + + // ---------------------------------------------------------------- + // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + // 3-1) groupMap: { [groupName]: ColumnDef<VendorMaterialsView>[] } + const groupMap: Record<string, ColumnDef<VendorMaterialsView>[]> = {} + + vendorMaterialsColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef<VendorMaterialsView> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + minSize: cfg.minWidth, + size: cfg.defaultWidth, + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + + + if (cfg.id === "createdAt") { + const dateVal = cell.getValue() as Date + return formatDate(dateVal) + } + + if (cfg.id === "updatedAt") { + const dateVal = cell.getValue() as Date + return formatDate(dateVal) + } + + + // code etc... + return row.getValue(cfg.id) ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // ---------------------------------------------------------------- + // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef<VendorMaterialsView>[] = [] + + // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 + // 여기서는 그냥 Object.entries 순서 + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + // 그룹 없음 → 그냥 최상위 레벨 컬럼 + nestedColumns.push(...colDefs) + } else { + // 상위 컬럼 + nestedColumns.push({ + id: groupName, + header: groupName, // "Basic Info", "Metadata" 등 + columns: colDefs, + }) + } + }) + + // ---------------------------------------------------------------- + // 4) 최종 컬럼 배열: select, nestedColumns, actions + // ---------------------------------------------------------------- + return [ + selectColumn, + ...nestedColumns, + actionsColumn, + ] +}
\ No newline at end of file diff --git a/lib/vendors/materials-table/item-table-toolbar-actions.tsx b/lib/vendors/materials-table/item-table-toolbar-actions.tsx new file mode 100644 index 00000000..16df1a19 --- /dev/null +++ b/lib/vendors/materials-table/item-table-toolbar-actions.tsx @@ -0,0 +1,122 @@ +// components/vendor-items/item-table-toolbar-actions.tsx +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, Upload } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" + +// 만약 서버 액션이나 API 라우트를 이용해 업로드 처리한다면 import +import { importTasksExcel } from "@/lib/tasks/service" // 예시 +import { + VendorMaterialsView +} from "@/db/schema/vendors" +import { AddItemDialog } from "./add-item-dialog" +import { DeleteVendorItemsDialog } from "./delete-vendor-items-dialog" + +interface VendorsTableToolbarActionsProps { + table: Table<VendorMaterialsView> + vendorId: number +} + +export function VendorsTableToolbarActions({ table, vendorId }: VendorsTableToolbarActionsProps) { + // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 + const fileInputRef = React.useRef<HTMLInputElement>(null) + + // 선택된 행들 가져오기 + const selectedRows = table.getFilteredSelectedRowModel().rows + + // 파일이 선택되었을 때 처리 + async function onFileChange(event: React.ChangeEvent<HTMLInputElement>) { + const file = event.target.files?.[0] + if (!file) return + + // 파일 초기화 (동일 파일 재업로드 시에도 onChange가 트리거되도록) + event.target.value = "" + + // 서버 액션 or API 호출 + try { + // 예: 서버 액션 호출 + const { errorFile, errorMessage } = await importTasksExcel(file) + + if (errorMessage) { + toast.error(errorMessage) + } + if (errorFile) { + // 에러 엑셀을 다운로드 + const url = URL.createObjectURL(errorFile) + const link = document.createElement("a") + link.href = url + link.download = "errors.xlsx" + link.click() + URL.revokeObjectURL(url) + } else { + // 성공 + toast.success("Import success") + // 필요 시 revalidateTag("tasks") 등 + } + + } catch (err) { + toast.error("파일 업로드 중 오류가 발생했습니다.") + } + } + + function handleImportClick() { + // 숨겨진 <input type="file" /> 요소를 클릭 + fileInputRef.current?.click() + } + + return ( + <div className="flex items-center gap-2"> + <AddItemDialog vendorId={vendorId} /> + + {/* 삭제 버튼 - 선택된 행이 있을 때만 표시 */} + {selectedRows.length > 0 && ( + <DeleteVendorItemsDialog + vendorId={vendorId} + items={selectedRows.map((row) => row.original)} + onSuccess={() => { + // 삭제 성공 후 선택 해제 + table.toggleAllPageRowsSelected(false) + }} + /> + )} + + {/** Import 버튼 (파일 업로드) */} + <Button variant="outline" size="sm" className="gap-2" onClick={handleImportClick}> + <Upload className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Import</span> + </Button> + {/* + 실제로는 숨겨진 input과 연결: + - accept=".xlsx,.xls" 등으로 Excel 파일만 업로드 허용 + */} + <input + ref={fileInputRef} + type="file" + accept=".xlsx,.xls" + className="hidden" + onChange={onFileChange} + /> + + {/** Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "vendor-items", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + ) +}
\ No newline at end of file diff --git a/lib/vendors/materials-table/item-table.tsx b/lib/vendors/materials-table/item-table.tsx new file mode 100644 index 00000000..066d8de5 --- /dev/null +++ b/lib/vendors/materials-table/item-table.tsx @@ -0,0 +1,92 @@ +"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 { useFeatureFlags } from "./feature-flags-provider" +import { getVendorMaterials, } from "../service" +import { VendorMaterialsView, vendors } from "@/db/schema/vendors" +import { VendorsTableToolbarActions } from "./item-table-toolbar-actions" +import { getColumns } from "./item-table-columns" +import { ItemActionsDialogs } from "./item-action-dialog" + +interface VendorsTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getVendorMaterials>>, + ] + >, + vendorId:number +} + +export function VendorMaterialsTable({ promises , vendorId}: VendorsTableProps) { + const { featureFlags } = useFeatureFlags() + + // Suspense로 받아온 데이터 + const [{ data, pageCount }] = React.use(promises) + const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorMaterialsView> | null>(null) + + // getColumns() 호출 시, router를 주입 + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + const filterFields: DataTableFilterField<VendorMaterialsView>[] = [ + + ] + + const advancedFilterFields: DataTableAdvancedFilterField<VendorMaterialsView>[] = [ + { id: "itemName", label: "Item Name", type: "text" }, + { id: "itemCode", label: "Item Code", type: "text" }, + { id: "description", label: "Description", type: "text" }, + { id: "createdAt", label: "Created at", type: "date" }, + { id: "updatedAt", label: "Updated at", type: "date" }, + ] + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.itemCode), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable + table={table} + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <VendorsTableToolbarActions table={table} vendorId={vendorId} /> + </DataTableAdvancedToolbar> + </DataTable> + + <ItemActionsDialogs + vendorId={vendorId} + rowAction={rowAction} + setRowAction={setRowAction} + /> + </> + ) +}
\ No newline at end of file diff --git a/lib/vendors/repository.ts b/lib/vendors/repository.ts index 1f59aac0..04d6322a 100644 --- a/lib/vendors/repository.ts +++ b/lib/vendors/repository.ts @@ -2,7 +2,7 @@ import { and, eq, inArray, count, gt, AnyColumn, SQLWrapper, SQL} from "drizzle-orm"; import { PgTransaction } from "drizzle-orm/pg-core"; -import { VendorContact, vendorContacts, vendorItemsView, vendorPossibleItems, vendors, vendorsWithTypesView, type Vendor } from "@/db/schema/vendors"; +import { VendorContact, vendorContacts, vendorItemsView, vendorMaterialsView, vendorPossibleItems, vendors, vendorsWithTypesView, type Vendor } from "@/db/schema/vendors"; import db from '@/db/db'; import { items } from "@/db/schema/items"; import { rfqs,rfqItems, rfqEvaluations, vendorResponses } from "@/db/schema/rfq"; @@ -225,6 +225,44 @@ export async function countVendorItems( return res[0]?.count ?? 0; } +export async function selectVendorMaterials( + 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({ + // vendor_possible_items cols + vendorItemId: vendorMaterialsView.vendorItemId, + vendorId: vendorMaterialsView.vendorId, + itemCode: vendorMaterialsView.itemCode, + createdAt: vendorMaterialsView.createdAt, + updatedAt: vendorMaterialsView.updatedAt, + itemName: vendorMaterialsView.itemName, + description: vendorMaterialsView.description, + }) + .from(vendorMaterialsView) + .where(where ?? undefined) + .orderBy(...(orderBy ?? [])) + .offset(offset) + .limit(limit); +} + +export async function countVendorMaterials( + tx: PgTransaction<any, any, any>, + where?: any +) { + const res = await tx.select({ count: count() }).from(vendorMaterialsView).where(where); + return res[0]?.count ?? 0; +} + + export async function insertVendorItem( tx: PgTransaction<any, any, any>, data: NewVendorItem // DB와 동일한 insert 가능한 타입 @@ -236,6 +274,17 @@ export async function insertVendorItem( .returning({ id: vendorPossibleItems.id, createdAt: vendorPossibleItems.createdAt }); } +export async function insertVendorMaterial( + tx: PgTransaction<any, any, any>, + data: NewVendorItem // DB와 동일한 insert 가능한 타입 +) { + // returning() 사용 시 배열로 돌아오므로 [0]만 리턴 + return tx + .insert(vendorPossibleMateirals) + .values(data) + .returning({ id: vendorPossibleMateirals.id, createdAt: vendorPossibleMateirals.createdAt }); +} + export async function selectRfqHistory( tx: PgTransaction<any, any, any>, { where, orderBy, offset, limit }: SelectVendorsOptions diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts index 16f57b57..fb834814 100644 --- a/lib/vendors/service.ts +++ b/lib/vendors/service.ts @@ -2,8 +2,9 @@ import { revalidateTag, unstable_noStore } from "next/cache"; import db from "@/db/db"; -import { vendorAttachments, VendorContact, vendorContacts, vendorDetailView, vendorItemsView, vendorPossibleItems, vendors, vendorsWithTypesView, vendorTypes, type Vendor } from "@/db/schema"; +import { vendorAttachments, VendorContact, vendorContacts, vendorDetailView, vendorItemsView, vendorMaterialsView, vendorPossibleItems, vendorPossibleMateirals, vendors, vendorsWithTypesView, vendorTypes, type Vendor } from "@/db/schema"; import logger from '@/lib/logger'; +import * as z from "zod" import { filterColumns } from "@/lib/filter-columns"; import { unstable_cache } from "@/lib/unstable-cache"; @@ -28,6 +29,8 @@ import { selectRfqHistory, selectVendorsWithTypes, countVendorsWithTypes, + countVendorMaterials, + insertVendorMaterial, } from "./repository"; @@ -40,6 +43,7 @@ import type { GetVendorItemsSchema, CreateVendorItemSchema, GetRfqHistorySchema, + GetVendorMaterialsSchema, } from "./validations"; import { asc, desc, ilike, inArray, and, or, gte, lte, eq, isNull, count, sql } from "drizzle-orm"; @@ -51,7 +55,7 @@ import JSZip from 'jszip'; import { promises as fsPromises } from 'fs'; import { sendEmail } from "../mail/sendEmail"; import { PgTransaction } from "drizzle-orm/pg-core"; -import { items } from "@/db/schema/items"; +import { items, materials } from "@/db/schema/items"; import { users } from "@/db/schema/users"; import { getServerSession } from "next-auth"; import { authOptions } from "@/app/api/auth/[...nextauth]/route"; @@ -740,6 +744,86 @@ export async function getVendorItems(input: GetVendorItemsSchema, id: number) { return cachedFunction(); } +export async function getVendorMaterials(input: GetVendorMaterialsSchema, id: number) { + const cachedFunction = unstable_cache( + + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // const advancedTable = input.flags.includes("advancedTable"); + const advancedTable = true; + + // advancedTable 모드면 filterColumns()로 where 절 구성 + const advancedWhere = filterColumns({ + table: vendorMaterialsView, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or(ilike(vendorMaterialsView.itemCode, s) + , ilike(vendorMaterialsView.description, s) + ) + // 필요시 여러 칼럼 OR조건 (status, priority, etc) + } + + const vendorWhere = eq(vendorMaterialsView.vendorId, id) + + const finalWhere = and( + // advancedWhere or your existing conditions + advancedWhere, + globalWhere, + vendorWhere + ) + + + // 아니면 ilike, inArray, gte 등으로 where 절 구성 + const where = finalWhere + + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(vendorMaterialsView[item.id]) : asc(vendorMaterialsView[item.id]) + ) + : [asc(vendorMaterialsView.createdAt)]; + + // 트랜잭션 내부에서 Repository 호출 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectVendorMaterials(tx, { + where, + orderBy, + offset, + limit: input.perPage, + }); + const total = await countVendorMaterials(tx, where); + return { data, total }; + }); + + + const pageCount = Math.ceil(total / input.perPage); + + + console.log(data) + + return { data, pageCount }; + } catch (err) { + // 에러 발생 시 디폴트 + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input), String(id)], // 캐싱 키 + { + revalidate: 3600, + tags: [`vendor-materials-${id}`], // revalidateTag("tasks") 호출 시 무효화 + } + ); + return cachedFunction(); +} + export interface ItemDropdownOption { itemCode: string; itemName: string; @@ -820,6 +904,327 @@ export async function createVendorItem(input: CreateVendorItemSchema) { } } + + +const updateVendorItemSchema = z.object({ + oldItemCode: z.string().min(1, "Old item code is required"), + newItemCode: z.string().min(1, "New item code is required"), + vendorId: z.number().min(1, "Vendor ID is required"), +}) + + +export async function deleteVendorItem( + vendorId: number, + itemCode: string +) { + try { + const validatedData = deleteVendorItemSchema.parse({ + itemCode, + vendorId, + }) + + await db + .delete(vendorPossibleItems) + .where( + and( + eq(vendorPossibleItems.itemCode, validatedData.itemCode), + eq(vendorPossibleItems.vendorId, validatedData.vendorId) + ) + ) + + revalidateTag(`vendor-items-${vendorId}`); + + return { success: true, message: "Item deleted successfully" } + } catch (error) { + console.error("Error deleting vendor item:", error) + return { + success: false, + message: error instanceof z.ZodError + ? error.errors[0].message + : "Failed to delete item" + } + } +} + +export async function updateVendorItem( + vendorId: number, + oldItemCode: string, + newItemCode: string +) { + unstable_noStore(); // Next.js 서버 액션 캐싱 방지 + + try { + const validatedData = updateVendorItemSchema.parse({ + oldItemCode, + newItemCode, + vendorId, + }) + + await db.transaction(async (tx) => { + // 기존 아이템 삭제 + await tx + .delete(vendorPossibleItems) + .where( + and( + eq(vendorPossibleItems.itemCode, validatedData.oldItemCode), + eq(vendorPossibleItems.vendorId, validatedData.vendorId) + ) + ) + + // 새 아이템 추가 + await tx.insert(vendorPossibleItems).values({ + vendorId: validatedData.vendorId, + itemCode: validatedData.newItemCode, + }) + }) + + // 캐시 무효화 + revalidateTag(`vendor-items-${vendorId}`) + + return { data: null, error: null } + } catch (err) { + console.error("Error updating vendor item:", err) + return { + data: null, + error: getErrorMessage(err) + } + } +} + +export async function removeVendorItems(input: { + itemCodes: string[] + vendorId: number +}) { + unstable_noStore() + + try { + const validatedData = removeVendorItemsSchema.parse(input) + + await db + .delete(vendorPossibleItems) + .where( + and( + inArray(vendorPossibleItems.itemCode, validatedData.itemCodes), + eq(vendorPossibleItems.vendorId, validatedData.vendorId) + ) + ) + + revalidateTag(`vendor-items-${validatedData.vendorId}`) + + return { data: null, error: null } + } catch (err) { + console.error("Error deleting vendor items:", err) + return { + data: null, + error: getErrorMessage(err) + } + } +} + +// 스키마도 추가해야 합니다 +const removeVendorItemsSchema = z.object({ + itemCodes: z.array(z.string()).min(1, "At least one item code is required"), + vendorId: z.number().min(1, "Vendor ID is required"), +}) + +const deleteVendorItemSchema = z.object({ + itemCode: z.string().min(1, "Item code is required"), + vendorId: z.number().min(1, "Vendor ID is required"), +}) + +export async function getMaterialsForVendor(vendorId: number) { + return unstable_cache( + async () => { + try { + // 해당 vendorId가 이미 가지고 있는 itemCode 목록을 서브쿼리로 구함 + // 그 아이템코드를 제외(notIn)하여 모든 items 테이블에서 조회 + const itemsData = await db + .select({ + itemCode: materials.itemCode, + itemName: materials.itemName, + description: materials.description, + }) + .from(materials) + .leftJoin( + vendorPossibleMateirals, + eq(materials.itemCode, vendorPossibleMateirals.itemCode) + ) + // vendorPossibleItems.vendorId가 이 vendorId인 행이 없는(즉 아직 등록되지 않은) 아이템만 + .where( + isNull(vendorPossibleMateirals.id) // 또는 isNull(vendorPossibleItems.itemCode) + ) + .orderBy(asc(materials.itemName)) + + return { + data: itemsData.map((item) => ({ + itemCode: item.itemCode ?? "", // null이라면 ""로 치환 + itemName: item.itemName, + description: item.description ?? "" // null이라면 ""로 치환 + })), + error: null + } + } catch (err) { + console.error("Failed to fetch items for vendor dropdown:", err) + return { + data: [], + error: "아이템 목록을 불러오는데 실패했습니다.", + } + } + }, + // 캐시 키를 vendorId 별로 달리 해야 한다. + ["materials-for-vendor", String(vendorId)], + { + revalidate: 3600, // 1시간 캐싱 + tags: ["materials"], // revalidateTag("materials") 호출 시 무효화 + } + )() +} + +export async function createVendorMaterial(input: CreateVendorItemSchema) { + unstable_noStore(); // Next.js 서버 액션 캐싱 방지 + try { + await db.transaction(async (tx) => { + // DB Insert + const [newContact] = await insertVendorMaterial(tx, { + vendorId: input.vendorId, + itemCode: input.itemCode, + + }); + return newContact; + }); + + // 캐시 무효화 (협력업체 연락처 목록 등) + revalidateTag(`vendor-materials-${input.vendorId}`); + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +const updateVendorMaterialSchema = z.object({ + oldItemCode: z.string().min(1, "Old item code is required"), + newItemCode: z.string().min(1, "New item code is required"), + vendorId: z.number().min(1, "Vendor ID is required"), +}) + + +export async function deleteVendorMaterial( + vendorId: number, + itemCode: string +) { + try { + const validatedData = deleteVendorItemSchema.parse({ + itemCode, + vendorId, + }) + + await db + .delete(vendorPossibleMateirals) + .where( + and( + eq(vendorPossibleMateirals.itemCode, validatedData.itemCode), + eq(vendorPossibleMateirals.vendorId, validatedData.vendorId) + ) + ) + + revalidateTag(`vendor-materials-${vendorId}`); + + return { success: true, message: "Item deleted successfully" } + } catch (error) { + console.error("Error deleting vendor item:", error) + return { + success: false, + message: error instanceof z.ZodError + ? error.errors[0].message + : "Failed to delete item" + } + } +} + +export async function updateVendorMaterial( + vendorId: number, + oldItemCode: string, + newItemCode: string +) { + unstable_noStore(); // Next.js 서버 액션 캐싱 방지 + + try { + const validatedData = updateVendorMaterialSchema.parse({ + oldItemCode, + newItemCode, + vendorId, + }) + + await db.transaction(async (tx) => { + // 기존 아이템 삭제 + await tx + .delete(vendorPossibleMateirals) + .where( + and( + eq(vendorPossibleMateirals.itemCode, validatedData.oldItemCode), + eq(vendorPossibleMateirals.vendorId, validatedData.vendorId) + ) + ) + + // 새 아이템 추가 + await tx.insert(vendorPossibleMateirals).values({ + vendorId: validatedData.vendorId, + itemCode: validatedData.newItemCode, + }) + }) + + // 캐시 무효화 + revalidateTag(`vendor-items-${vendorId}`) + + return { data: null, error: null } + } catch (err) { + console.error("Error updating vendor item:", err) + return { + data: null, + error: getErrorMessage(err) + } + } +} + +export async function removeVendorMaterials(input: { + itemCodes: string[] + vendorId: number +}) { + unstable_noStore() + + try { + const validatedData = removeVendormaterialsSchema.parse(input) + + await db + .delete(vendorPossibleMateirals) + .where( + and( + inArray(vendorPossibleMateirals.itemCode, validatedData.itemCodes), + eq(vendorPossibleMateirals.vendorId, validatedData.vendorId) + ) + ) + + revalidateTag(`vendor-materials-${validatedData.vendorId}`) + + return { data: null, error: null } + } catch (err) { + console.error("Error deleting vendor items:", err) + return { + data: null, + error: getErrorMessage(err) + } + } +} + +// 스키마도 추가해야 합니다 +const removeVendormaterialsSchema = z.object({ + itemCodes: z.array(z.string()).min(1, "At least one item code is required"), + vendorId: z.number().min(1, "Vendor ID is required"), +}) + + + export async function getRfqHistory(input: GetRfqHistorySchema, vendorId: number) { return unstable_cache( async () => { diff --git a/lib/vendors/validations.ts b/lib/vendors/validations.ts index 10ea3e78..7ba54ccf 100644 --- a/lib/vendors/validations.ts +++ b/lib/vendors/validations.ts @@ -8,7 +8,7 @@ import { import * as z from "zod" import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" -import { Vendor, VendorContact, vendorInvestigationsView, VendorItemsView, vendors, VendorWithType } from "@/db/schema/vendors"; +import { Vendor, VendorContact, VendorItemsView, VendorMaterialsView, vendors, VendorWithType } from "@/db/schema/vendors"; import { rfqs } from "@/db/schema/rfq" @@ -424,4 +424,35 @@ export const updateVendorSchemaWithConditions = updateVendorInfoSchema.superRefi } ) -export type UpdateVendorInfoSchema = z.infer<typeof updateVendorInfoSchema>
\ No newline at end of file +export type UpdateVendorInfoSchema = z.infer<typeof updateVendorInfoSchema> + + +export const searchParamsMaterialCache = createSearchParamsCache({ + // 공통 플래그 + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + + // 페이징 + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 정렬 (vendors 테이블에 맞춰 Vendor 타입 지정) + sort: getSortingStateParser<VendorMaterialsView>().withDefault([ + { id: "createdAt", desc: true }, // createdAt 기준 내림차순 + ]), + + // 고급 필터 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 검색 키워드 + search: parseAsString.withDefault(""), + + + itemName: parseAsString.withDefault(""), + itemCode: parseAsString.withDefault(""), + description: parseAsString.withDefault(""), +}); + +export type GetVendorMaterialsSchema = Awaited<ReturnType<typeof searchParamsMaterialCache.parse>> |
