diff options
| -rw-r--r-- | config/techVendorColumnsConfig.ts | 9 | ||||
| -rw-r--r-- | db/schema/techVendors.ts | 13 | ||||
| -rw-r--r-- | lib/tech-vendors/items-table/add-item-dialog.tsx | 20 | ||||
| -rw-r--r-- | lib/tech-vendors/items-table/item-table-toolbar-actions.tsx | 2 | ||||
| -rw-r--r-- | lib/tech-vendors/service.ts | 88 | ||||
| -rw-r--r-- | lib/tech-vendors/table/tech-vendors-table-columns.tsx | 9 | ||||
| -rw-r--r-- | lib/tech-vendors/table/tech-vendors-table.tsx | 43 | ||||
| -rw-r--r-- | lib/tech-vendors/table/update-vendor-sheet.tsx | 21 | ||||
| -rw-r--r-- | lib/tech-vendors/utils.ts | 2 | ||||
| -rw-r--r-- | lib/tech-vendors/validations.ts | 18 |
10 files changed, 153 insertions, 72 deletions
diff --git a/config/techVendorColumnsConfig.ts b/config/techVendorColumnsConfig.ts index c4b85b7b..f7fd4478 100644 --- a/config/techVendorColumnsConfig.ts +++ b/config/techVendorColumnsConfig.ts @@ -32,20 +32,23 @@ export const techVendorColumnsConfig: VendorColumnConfig[] = [ label: "업체 코드",
excelHeader: "업체 코드",
},
-
{
id: "vendorName",
label: "업체명",
excelHeader: "업체명",
},
-
{
id: "techVendorType",
label: "벤더 타입",
excelHeader: "벤더 타입",
type: "string",
},
-
+ {
+ id: "workTypes",
+ label: "Work Type",
+ excelHeader: "Work Type",
+ type: "string",
+ },
{
id: "taxId",
label: "세금 ID",
diff --git a/db/schema/techVendors.ts b/db/schema/techVendors.ts index 9cb15ad8..113a5a1a 100644 --- a/db/schema/techVendors.ts +++ b/db/schema/techVendors.ts @@ -30,7 +30,8 @@ export const techVendors = pgTable("tech_vendors", { enum: [
"ACTIVE",
"INACTIVE",
- "BLACKLISTED"
+ "BLACKLISTED",
+ "PENDING_REVIEW"
]
}).default("ACTIVE").notNull(),
// 대표자 정보
@@ -57,10 +58,7 @@ export const techVendorPossibleItems = pgTable("tech_vendor_possible_items", { id: serial("id").primaryKey(),
vendorId: integer("vendor_id").notNull().references(() => techVendors.id),
// itemId: integer("item_id"), // 별도 item 테이블 연동시
- itemCode: varchar("item_code", { length: 100 })
- .notNull()
- .references(() => items.itemCode, { onDelete: "cascade" }),
- itemName: varchar("item_name", { length: 255 }).notNull(),
+ itemCode: varchar("item_code", { length: 100 }).notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
@@ -83,7 +81,6 @@ export const techVendorItemsView = pgView("tech_vendor_items_view").as((qb) => { vendorItemId: techVendorPossibleItems.id,
vendorId: techVendorPossibleItems.vendorId,
itemCode: items.itemCode,
- itemName: items.itemName,
createdAt: techVendorPossibleItems.createdAt,
updatedAt: techVendorPossibleItems.updatedAt,
})
@@ -173,8 +170,7 @@ export const techVendorDetailView = pgView("tech_vendor_detail_view").as((qb) => (SELECT COALESCE(
json_agg(
json_build_object(
- 'itemCode', i.item_code,
- 'itemName', it.item_name
+ 'itemCode', i.item_code
)
),
'[]'::json
@@ -266,4 +262,5 @@ export type TechVendorCandidate = typeof techVendorCandidates.$inferSelect export type TechVendorWithAttachments = TechVendor & {
hasAttachments?: boolean;
attachmentsList?: TechVendorAttach[];
+ workTypes?: string;
}
\ No newline at end of file diff --git a/lib/tech-vendors/items-table/add-item-dialog.tsx b/lib/tech-vendors/items-table/add-item-dialog.tsx index e4d74204..21875295 100644 --- a/lib/tech-vendors/items-table/add-item-dialog.tsx +++ b/lib/tech-vendors/items-table/add-item-dialog.tsx @@ -79,8 +79,8 @@ export function AddItemDialog({ vendorId }: AddItemDialogProps) { if (result.data) { console.log(`[AddItemDialog] 사용 가능한 아이템 목록:`, result.data) - setItems(result.data) - setFilteredItems(result.data) + setItems(result.data as ItemDropdownOption[]) + setFilteredItems(result.data as ItemDropdownOption[]) } else if (result.error) { console.error("[AddItemDialog] 아이템 조회 실패:", result.error) toast.error(result.error) @@ -113,8 +113,8 @@ export function AddItemDialog({ vendorId }: AddItemDialogProps) { 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)) + item.itemList.toLowerCase().includes(lowerSearch) || + (item.subItemList && item.subItemList.toLowerCase().includes(lowerSearch)) ) console.log(`[AddItemDialog] 필터링 결과: ${filtered.length}개 아이템`) @@ -125,13 +125,13 @@ export function AddItemDialog({ vendorId }: AddItemDialogProps) { console.log(`[AddItemDialog] 아이템 선택: ${item.itemCode}`) form.setValue("itemCode", item.itemCode, { shouldValidate: true }) setSelectedItem({ - itemName: item.itemName, - description: item.description || "", + itemName: item.itemList, + description: item.subItemList || "", }) console.log(`[AddItemDialog] 선택된 아이템 정보:`, { itemCode: item.itemCode, - itemName: item.itemName, - description: item.description || "" + itemName: item.itemList, + description: item.subItemList || "" }) setCommandOpen(false) } @@ -241,7 +241,7 @@ export function AddItemDialog({ vendorId }: AddItemDialogProps) { {filteredItems.map((item) => ( <CommandItem key={item.itemCode} - value={`${item.itemCode} ${item.itemName}`} + value={`${item.itemCode} ${item.itemList}`} onSelect={() => handleSelectItem(item)} > <Check @@ -253,7 +253,7 @@ export function AddItemDialog({ vendorId }: AddItemDialogProps) { )} /> <span className="font-medium">{item.itemCode}</span> - <span className="ml-2 text-gray-500 truncate">- {item.itemName}</span> + <span className="ml-2 text-gray-500 truncate">- {item.itemList}</span> </CommandItem> ))} </CommandGroup> diff --git a/lib/tech-vendors/items-table/item-table-toolbar-actions.tsx b/lib/tech-vendors/items-table/item-table-toolbar-actions.tsx index 68a20816..b327ff56 100644 --- a/lib/tech-vendors/items-table/item-table-toolbar-actions.tsx +++ b/lib/tech-vendors/items-table/item-table-toolbar-actions.tsx @@ -65,7 +65,7 @@ export function TechVendorItemsTableToolbarActions({ table, vendorId, vendorType return ( <div className="flex items-center gap-2"> - <AddItemDialog vendorId={vendorId} vendorType={vendorType} /> + {/* <AddItemDialog vendorId={vendorId} vendorType={vendorType} /> */} {/** 3) Import 버튼 (파일 업로드) */} <Button variant="outline" size="sm" className="gap-2" onClick={handleImportClick}> diff --git a/lib/tech-vendors/service.ts b/lib/tech-vendors/service.ts index 5fd5ef02..15e7331b 100644 --- a/lib/tech-vendors/service.ts +++ b/lib/tech-vendors/service.ts @@ -40,6 +40,7 @@ import fs from "fs/promises"; import { randomUUID } from "crypto"; import { sql } from "drizzle-orm"; import { users } from "@/db/schema/users"; +import { decryptWithServerAction } from "@/components/drm/drmUtils"; /* ----------------------------------------------------- 1) 조회 관련 @@ -56,10 +57,14 @@ export async function getTechVendors(input: GetTechVendorsSchema) { try { const offset = (input.page - 1) * input.perPage; - // 1) 고급 필터 + // 1) 고급 필터 (workTypes와 techVendorType 제외 - 별도 처리) + const filteredFilters = input.filters.filter( + filter => filter.id !== "workTypes" && filter.id !== "techVendorType" + ); + const advancedWhere = filterColumns({ table: techVendors, - filters: input.filters, + filters: filteredFilters, joinOperator: input.joinOperator, }); @@ -108,8 +113,47 @@ export async function getTechVendors(input: GetTechVendorsSchema) { : undefined ); - // 실제 사용될 where (vendorType 필터링 추가) - const where = and(finalWhere, vendorTypeWhere); + // TechVendorType 필터링 로직 추가 (고급 필터에서) + let techVendorTypeWhere; + const techVendorTypeFilters = input.filters.filter(filter => filter.id === "techVendorType"); + if (techVendorTypeFilters.length > 0) { + const typeFilter = techVendorTypeFilters[0]; + if (Array.isArray(typeFilter.value) && typeFilter.value.length > 0) { + // 각 타입에 대해 LIKE 조건으로 OR 연결 + const typeConditions = typeFilter.value.map(type => + ilike(techVendors.techVendorType, `%${type}%`) + ); + techVendorTypeWhere = or(...typeConditions); + } + } + + // WorkTypes 필터링 로직 추가 + let workTypesWhere; + const workTypesFilters = input.filters.filter(filter => filter.id === "workTypes"); + if (workTypesFilters.length > 0) { + const workTypeFilter = workTypesFilters[0]; + if (Array.isArray(workTypeFilter.value) && workTypeFilter.value.length > 0) { + // workTypes에 해당하는 벤더 ID들을 서브쿼리로 찾음 + const vendorIdsWithWorkTypes = db + .selectDistinct({ vendorId: techVendorPossibleItems.vendorId }) + .from(techVendorPossibleItems) + .leftJoin(itemShipbuilding, eq(techVendorPossibleItems.itemCode, itemShipbuilding.itemCode)) + .leftJoin(itemOffshoreTop, eq(techVendorPossibleItems.itemCode, itemOffshoreTop.itemCode)) + .leftJoin(itemOffshoreHull, eq(techVendorPossibleItems.itemCode, itemOffshoreHull.itemCode)) + .where( + or( + inArray(itemShipbuilding.workType, workTypeFilter.value), + inArray(itemOffshoreTop.workType, workTypeFilter.value), + inArray(itemOffshoreHull.workType, workTypeFilter.value) + ) + ); + + workTypesWhere = inArray(techVendors.id, vendorIdsWithWorkTypes); + } + } + + // 실제 사용될 where (vendorType, techVendorType, workTypes 필터링 추가) + const where = and(finalWhere, vendorTypeWhere, techVendorTypeWhere, workTypesWhere); // 정렬 const orderBy = @@ -160,6 +204,7 @@ export async function getTechVendorStatusCounts() { async () => { try { const initial: Record<TechVendor["status"], number> = { + "PENDING_REVIEW": 0, "ACTIVE": 0, "INACTIVE": 0, "BLACKLISTED": 0, @@ -231,8 +276,10 @@ async function storeTechVendorFiles( for (const file of files) { // Convert file to buffer - const ab = await file.arrayBuffer(); - const buffer = Buffer.from(ab); + // DRM 복호화 시도 및 버퍼 변환 + const decryptedData = await decryptWithServerAction(file); + const buffer = Buffer.from(decryptedData); + // Generate a unique filename const uniqueName = `${randomUUID()}-${file.name}`; @@ -518,8 +565,7 @@ export async function getTechVendorItems(input: GetTechVendorItemsSchema, id: nu if (input.search) { const s = `%${input.search}%`; globalWhere = or( - ilike(techVendorItemsView.itemCode, s), - ilike(techVendorItemsView.itemName, s) + ilike(techVendorItemsView.itemCode, s) ); } @@ -863,7 +909,7 @@ export async function getVendorItemsByType(vendorId: number, vendorType: string) } } -export async function createTechVendorItem(input: CreateTechVendorItemSchema & { itemName: string }) { +export async function createTechVendorItem(input: CreateTechVendorItemSchema) { unstable_noStore(); try { // DB에 이미 존재하는지 확인 @@ -889,7 +935,6 @@ export async function createTechVendorItem(input: CreateTechVendorItemSchema & { .values({ vendorId: input.vendorId, itemCode: input.itemCode, - itemName: input.itemName || "기술영업", }) .returning(); return newItem; @@ -1009,14 +1054,12 @@ export async function exportTechVendorItems(vendorId: number) { .select({ id: techVendorItemsView.vendorItemId, vendorId: techVendorItemsView.vendorId, - itemName: techVendorItemsView.itemName, itemCode: techVendorItemsView.itemCode, createdAt: techVendorItemsView.createdAt, updatedAt: techVendorItemsView.updatedAt, }) .from(techVendorItemsView) .where(eq(techVendorItemsView.vendorId, vendorId)) - .orderBy(techVendorItemsView.itemName); return items; } catch (err) { @@ -1308,27 +1351,6 @@ export async function importTechVendorsFromExcel( } } - // // 3. 아이템 등록 - // if (vendor.items) { - // console.log("아이템 등록 시도:", vendor.items); - // const itemCodes = vendor.items.split(',').map(code => code.trim()); - - // for (const itemCode of itemCodes) { - // // 아이템 정보 조회 - // const [item] = await tx.select().from(items).where(eq(items.itemCode, itemCode)); - // if (item && item.itemCode && item.itemName) { - // await tx.insert(techVendorPossibleItems).values({ - // vendorId: newVendor.id, - // itemCode: item.itemCode, - // itemName: item.itemName, - // }); - // console.log("아이템 등록 성공:", itemCode); - // } else { - // console.log("아이템을 찾을 수 없음:", itemCode); - // } - // } - // } - createdVendors.push(newVendor); console.log("벤더 처리 완료:", vendor.vendorName); } catch (error) { diff --git a/lib/tech-vendors/table/tech-vendors-table-columns.tsx b/lib/tech-vendors/table/tech-vendors-table-columns.tsx index 093b5547..22e89dd0 100644 --- a/lib/tech-vendors/table/tech-vendors-table-columns.tsx +++ b/lib/tech-vendors/table/tech-vendors-table-columns.tsx @@ -230,6 +230,12 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef className: "bg-slate-800 text-white border-slate-900", iconColor: "text-white" }; + case "PENDING_REVIEW": + return { + variant: "default", + className: "bg-gray-100 text-gray-800 border-gray-300", + iconColor: "text-gray-600" + }; default: return { variant: "default", @@ -244,7 +250,8 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef const statusMap: StatusDisplayMap = { "ACTIVE": "활성 상태", "INACTIVE": "비활성 상태", - "BLACKLISTED": "거래 금지" + "BLACKLISTED": "거래 금지", + "PENDING_REVIEW": "비교 견적", }; return statusMap[status] || status; diff --git a/lib/tech-vendors/table/tech-vendors-table.tsx b/lib/tech-vendors/table/tech-vendors-table.tsx index d6e6f99f..63ca8fcc 100644 --- a/lib/tech-vendors/table/tech-vendors-table.tsx +++ b/lib/tech-vendors/table/tech-vendors-table.tsx @@ -13,7 +13,7 @@ import { DataTable } from "@/components/data-table/data-table" import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" import { getColumns } from "./tech-vendors-table-columns" import { getTechVendors, getTechVendorStatusCounts } from "../service" -import { TechVendor, techVendors } from "@/db/schema/techVendors" +import { TechVendor, techVendors, TechVendorWithAttachments } from "@/db/schema/techVendors" import { TechVendorsTableToolbarActions } from "./tech-vendors-table-toolbar-actions" import { UpdateVendorSheet } from "./update-vendor-sheet" import { getVendorStatusIcon } from "../utils" @@ -49,13 +49,14 @@ export function TechVendorsTable({ promises }: TechVendorsTableProps) { const statusMap: Record<string, string> = { "ACTIVE": "활성 상태", "INACTIVE": "비활성 상태", - "BLACKLISTED": "거래 금지" + "BLACKLISTED": "거래 금지", + "PENDING_REVIEW": "비교 견적", }; return statusMap[status] || status; }; - const filterFields: DataTableFilterField<TechVendor>[] = [ + const filterFields: DataTableFilterField<TechVendorWithAttachments>[] = [ { id: "status", label: "상태", @@ -69,7 +70,7 @@ export function TechVendorsTable({ promises }: TechVendorsTableProps) { { id: "vendorCode", label: "업체 코드" }, ] - const advancedFilterFields: DataTableAdvancedFilterField<TechVendor>[] = [ + const advancedFilterFields: DataTableAdvancedFilterField<TechVendorWithAttachments>[] = [ { id: "vendorName", label: "업체명", type: "text" }, { id: "vendorCode", label: "업체코드", type: "text" }, { id: "email", label: "이메일", type: "text" }, @@ -85,6 +86,40 @@ export function TechVendorsTable({ promises }: TechVendorsTableProps) { icon: getVendorStatusIcon(status), })), }, + { + id: "techVendorType", + label: "벤더 타입", + type: "multi-select", + options: [ + { label: "조선", value: "조선" }, + { label: "해양TOP", value: "해양TOP" }, + { label: "해양HULL", value: "해양HULL" }, + ], + }, + { + id: "workTypes", + label: "Work Type", + type: "multi-select", + options: [ + // 조선 workTypes + { label: "기장", value: "기장" }, + { label: "전장", value: "전장" }, + { label: "선실", value: "선실" }, + { label: "배관", value: "배관" }, + { label: "철의", value: "철의" }, + // 해양TOP workTypes + { label: "TM", value: "TM" }, + { label: "TS", value: "TS" }, + { label: "TE", value: "TE" }, + { label: "TP", value: "TP" }, + // 해양HULL workTypes + { label: "HA", value: "HA" }, + { label: "HE", value: "HE" }, + { label: "HH", value: "HH" }, + { label: "HM", value: "HM" }, + { label: "NC", value: "NC" }, + ], + }, { id: "createdAt", label: "등록일", type: "date" }, { id: "updatedAt", label: "수정일", type: "date" }, ] diff --git a/lib/tech-vendors/table/update-vendor-sheet.tsx b/lib/tech-vendors/table/update-vendor-sheet.tsx index 774299f1..1d05b0c4 100644 --- a/lib/tech-vendors/table/update-vendor-sheet.tsx +++ b/lib/tech-vendors/table/update-vendor-sheet.tsx @@ -8,9 +8,6 @@ import { Activity, AlertCircle, AlertTriangle, - ClipboardList, - FilePenLine, - XCircle, Circle as CircleIcon, Building, } from "lucide-react" @@ -83,6 +80,12 @@ const getStatusConfig = (status: StatusType): StatusConfig => { className: "text-slate-800", label: "거래 금지" }; + case "PENDING_REVIEW": + return { + Icon: AlertTriangle, + className: "text-slate-800", + label: "비교 견적" + }; default: return { Icon: CircleIcon, @@ -109,7 +112,7 @@ export function UpdateVendorSheet({ vendor, ...props }: UpdateVendorSheetProps) phone: vendor?.phone ?? "", email: vendor?.email ?? "", website: vendor?.website ?? "", - techVendorType: vendor?.techVendorType ? vendor.techVendorType.split(',').filter(Boolean) : [], + techVendorType: vendor?.techVendorType ? vendor.techVendorType.split(',').map(s => s.trim()).filter(Boolean) as ("조선" | "해양TOP" | "해양HULL")[] : [], status: vendor?.status ?? "ACTIVE", }, }) @@ -124,7 +127,7 @@ export function UpdateVendorSheet({ vendor, ...props }: UpdateVendorSheetProps) phone: vendor?.phone ?? "", email: vendor?.email ?? "", website: vendor?.website ?? "", - techVendorType: vendor?.techVendorType ? vendor.techVendorType.split(',').filter(Boolean) : [], + techVendorType: vendor?.techVendorType ? vendor.techVendorType.split(',').map(s => s.trim()).filter(Boolean) as ("조선" | "해양TOP" | "해양HULL")[] : [], status: vendor?.status ?? "ACTIVE", }); @@ -157,7 +160,7 @@ export function UpdateVendorSheet({ vendor, ...props }: UpdateVendorSheetProps) userId: Number(session.user.id), // Add user ID from session comment: statusComment, // Add comment for status changes ...data, // 모든 데이터 전달 - 서비스 함수에서 필요한 필드만 처리 - techVendorType: data.techVendorType ? data.techVendorType.join(',') : undefined, + techVendorType: Array.isArray(data.techVendorType) ? data.techVendorType.join(',') : undefined, }) if (error) throw new Error(error) @@ -165,7 +168,7 @@ export function UpdateVendorSheet({ vendor, ...props }: UpdateVendorSheetProps) toast.success("업체 정보가 업데이트되었습니다!") form.reset() props.onOpenChange?.(false) - } catch (err: any) { + } catch (err: unknown) { toast.error(String(err)) } }) @@ -312,11 +315,11 @@ export function UpdateVendorSheet({ vendor, ...props }: UpdateVendorSheetProps) id={`update-${type}`} checked={field.value?.includes(type as "조선" | "해양TOP" | "해양HULL")} onChange={(e) => { - const currentValue = field.value || []; + const currentValue = Array.isArray(field.value) ? field.value : []; if (e.target.checked) { field.onChange([...currentValue, type]); } else { - field.onChange(currentValue.filter((v) => v !== type)); + field.onChange(currentValue.filter((v: string) => v !== type)); } }} className="w-4 h-4" diff --git a/lib/tech-vendors/utils.ts b/lib/tech-vendors/utils.ts index ac49c78a..693a6929 100644 --- a/lib/tech-vendors/utils.ts +++ b/lib/tech-vendors/utils.ts @@ -8,6 +8,8 @@ type StatusType = TechVendor["status"]; */ export function getVendorStatusIcon(status: StatusType): LucideIcon { switch (status) { + case "PENDING_REVIEW": + return Hourglass; case "ACTIVE": return CheckCircle2; case "INACTIVE": diff --git a/lib/tech-vendors/validations.ts b/lib/tech-vendors/validations.ts index ee076945..fa0d9ae3 100644 --- a/lib/tech-vendors/validations.ts +++ b/lib/tech-vendors/validations.ts @@ -36,7 +36,7 @@ export const searchParamsCache = createSearchParamsCache({ // 기술영업 협력업체에 특화된 검색 필드 // ----------------------------------------------------------------- // 상태 (ACTIVE, INACTIVE, BLACKLISTED 등) 중에서 선택 - status: parseAsStringEnum(["ACTIVE", "INACTIVE", "BLACKLISTED", "PENDING_REVIEW", "IN_REVIEW", "REJECTED"]), + status: parseAsStringEnum(["ACTIVE", "INACTIVE", "BLACKLISTED", "PENDING_REVIEW"]), // 협력업체명 검색 vendorName: parseAsString.withDefault(""), @@ -46,8 +46,20 @@ export const searchParamsCache = createSearchParamsCache({ // 예) 코드 검색 vendorCode: parseAsString.withDefault(""), + // 벤더 타입 필터링 (다중 선택 가능) vendorType: parseAsStringEnum(["ship", "top", "hull"]), + + // workTypes 필터링 (다중 선택 가능) + workTypes: parseAsArrayOf(parseAsStringEnum([ + // 조선 workTypes + "기장", "전장", "선실", "배관", "철의", + // 해양TOP workTypes + "TM", "TS", "TE", "TP", + // 해양HULL workTypes + "HA", "HE", "HH", "HM", "NC" + ])).withDefault([]), + // 필요하다면 이메일 검색 / 웹사이트 검색 등 추가 가능 email: parseAsString.withDefault(""), website: parseAsString.withDefault(""), @@ -239,12 +251,12 @@ export const updateTechVendorContactSchema = z.object({ export const createTechVendorItemSchema = z.object({ vendorId: z.number(), itemCode: z.string().max(100, "Max length 100"), - itemName: z.string().min(1, "Item name is required").max(255, "Max length 255"), + itemList: z.string().min(1, "Item list is required").max(255, "Max length 255"), }); // 아이템 업데이트 스키마 export const updateTechVendorItemSchema = z.object({ - itemName: z.string().optional(), + itemList: z.string().optional(), itemCode: z.string().max(100, "Max length 100"), }); |
