summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-06-01 13:52:21 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-06-01 13:52:21 +0000
commitbac0228d21b7195065e9cddcc327ae33659c7bcc (patch)
tree8f3016ae4533c8706d0c00a605d9b1d41968c2bc /lib
parent2fdce8d7a57c792bba0ac36fa554dca9c9cc31e3 (diff)
(대표님) 20250601까지 작업사항
Diffstat (limited to 'lib')
-rw-r--r--lib/data-table.ts71
-rw-r--r--lib/equip-class/service.ts18
-rw-r--r--lib/form-list.zipbin0 -> 12417 bytes
-rw-r--r--lib/form-list/service.ts19
-rw-r--r--lib/form-list/table/formLists-table.tsx86
-rw-r--r--lib/forms/services.ts235
-rw-r--r--lib/items-tech/service.ts225
-rw-r--r--lib/items/service.ts9
-rw-r--r--lib/items/table/items-table-toolbar-actions.tsx63
-rw-r--r--lib/items/table/items-table.tsx189
-rw-r--r--lib/procurement-rfqs/services.ts21
-rw-r--r--lib/procurement-rfqs/table/detail-table/rfq-detail-column.tsx28
-rw-r--r--lib/procurement-rfqs/table/detail-table/update-vendor-sheet.tsx2
-rw-r--r--lib/rfqs-tech/service.ts4
-rw-r--r--lib/sedp/get-form-tags.ts726
-rw-r--r--lib/sedp/get-tags.ts116
-rw-r--r--lib/sedp/sync-package.ts282
-rw-r--r--lib/tag-numbering/service.ts18
-rw-r--r--lib/tags/form-mapping-service.ts5
-rw-r--r--lib/tags/service.ts411
-rw-r--r--lib/vendor-document-list/enhanced-document-service.ts38
-rw-r--r--lib/vendor-document-list/service.ts18
-rw-r--r--lib/vendor-document-list/table/add-doc-dialog.tsx28
-rw-r--r--lib/vendor-document-list/table/enhanced-doc-table-columns.tsx114
-rw-r--r--lib/vendor-document-list/table/enhanced-doc-table-toolbar-actions.tsx25
-rw-r--r--lib/vendor-document-list/table/enhanced-documents-table.tsx112
-rw-r--r--lib/vendor-investigation/table/vendor-details-dialog.tsx2
-rw-r--r--lib/vendors/items-table/delete-vendor-items-dialog.tsx182
-rw-r--r--lib/vendors/items-table/item-action-dialog.tsx248
-rw-r--r--lib/vendors/items-table/item-table-columns.tsx4
-rw-r--r--lib/vendors/items-table/item-table-toolbar-actions.tsx36
-rw-r--r--lib/vendors/items-table/item-table.tsx23
-rw-r--r--lib/vendors/materials-table/add-item-dialog.tsx289
-rw-r--r--lib/vendors/materials-table/delete-vendor-items-dialog.tsx182
-rw-r--r--lib/vendors/materials-table/feature-flags-provider.tsx108
-rw-r--r--lib/vendors/materials-table/item-action-dialog.tsx207
-rw-r--r--lib/vendors/materials-table/item-table-columns.tsx195
-rw-r--r--lib/vendors/materials-table/item-table-toolbar-actions.tsx122
-rw-r--r--lib/vendors/materials-table/item-table.tsx92
-rw-r--r--lib/vendors/repository.ts51
-rw-r--r--lib/vendors/service.ts409
-rw-r--r--lib/vendors/validations.ts35
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
new file mode 100644
index 00000000..7312729e
--- /dev/null
+++ b/lib/form-list.zip
Binary files differ
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>>