diff options
Diffstat (limited to 'lib/tech-vendors')
| -rw-r--r-- | lib/tech-vendors/repository.ts | 63 | ||||
| -rw-r--r-- | lib/tech-vendors/service.ts | 101 | ||||
| -rw-r--r-- | lib/tech-vendors/table/tech-vendor-possible-items-view-dialog.tsx | 201 | ||||
| -rw-r--r-- | lib/tech-vendors/table/tech-vendors-table-columns.tsx | 50 | ||||
| -rw-r--r-- | lib/tech-vendors/table/tech-vendors-table.tsx | 22 |
5 files changed, 389 insertions, 48 deletions
diff --git a/lib/tech-vendors/repository.ts b/lib/tech-vendors/repository.ts index 72c01a1c..d3c6671c 100644 --- a/lib/tech-vendors/repository.ts +++ b/lib/tech-vendors/repository.ts @@ -2,8 +2,9 @@ import { eq, inArray, count, desc } from "drizzle-orm"; import db from '@/db/db'; -import { sql, SQL } from "drizzle-orm"; +import { SQL } from "drizzle-orm"; import { techVendors, techVendorContacts, techVendorPossibleItems, techVendorItemsView, type TechVendor, type TechVendorContact, type TechVendorItem, type TechVendorWithAttachments, techVendorAttachments } from "@/db/schema/techVendors"; +import { itemShipbuilding, itemOffshoreTop, itemOffshoreHull } from "@/db/schema/items"; export type NewTechVendorContact = typeof techVendorContacts.$inferInsert export type NewTechVendorItem = typeof techVendorPossibleItems.$inferInsert @@ -79,10 +80,14 @@ export async function selectTechVendorsWithAttachments( .from(techVendorAttachments) .where(eq(techVendorAttachments.vendorId, vendor.id)); + // 벤더의 worktype 조회 + const workTypes = await getVendorWorkTypes(tx, vendor.id, vendor.techVendorType); + return { ...vendor, hasAttachments: attachments.length > 0, attachmentsList: attachments, + workTypes: workTypes.join(', '), // 콤마로 구분해서 저장 } as TechVendorWithAttachments; }) ); @@ -326,3 +331,59 @@ export async function insertTechVendorItem( .returning(); } +// 벤더의 worktype 조회 +export async function getVendorWorkTypes( + tx: any, + vendorId: number, + vendorType: string +): Promise<string[]> { + try { + // 벤더의 possible items 조회 + const possibleItems = await tx + .select({ itemCode: techVendorPossibleItems.itemCode }) + .from(techVendorPossibleItems) + .where(eq(techVendorPossibleItems.vendorId, vendorId)); + + if (!possibleItems.length) { + return []; + } + + const itemCodes = possibleItems.map((item: { itemCode: string }) => item.itemCode); + const workTypes: string[] = []; + + // 벤더 타입에 따라 해당하는 아이템 테이블에서 worktype 조회 + if (vendorType.includes('조선')) { + const shipWorkTypes = await tx + .select({ workType: itemShipbuilding.workType }) + .from(itemShipbuilding) + .where(inArray(itemShipbuilding.itemCode, itemCodes)); + + workTypes.push(...shipWorkTypes.map((item: { workType: string | null }) => item.workType).filter(Boolean)); + } + + if (vendorType.includes('해양TOP')) { + const topWorkTypes = await tx + .select({ workType: itemOffshoreTop.workType }) + .from(itemOffshoreTop) + .where(inArray(itemOffshoreTop.itemCode, itemCodes)); + + workTypes.push(...topWorkTypes.map((item: { workType: string | null }) => item.workType).filter(Boolean)); + } + + if (vendorType.includes('해양HULL')) { + const hullWorkTypes = await tx + .select({ workType: itemOffshoreHull.workType }) + .from(itemOffshoreHull) + .where(inArray(itemOffshoreHull.itemCode, itemCodes)); + + workTypes.push(...hullWorkTypes.map((item: { workType: string | null }) => item.workType).filter(Boolean)); + } + + // 중복 제거 후 반환 + const uniqueWorkTypes = [...new Set(workTypes)]; + + return uniqueWorkTypes; + } catch (error) { + return []; + } +} diff --git a/lib/tech-vendors/service.ts b/lib/tech-vendors/service.ts index 15e7331b..71d47e05 100644 --- a/lib/tech-vendors/service.ts +++ b/lib/tech-vendors/service.ts @@ -846,13 +846,15 @@ export async function getItemsByVendorType(vendorType: string, itemCode: string) })); return { data: result, error: null }; - } catch (error) { + } catch (err) { + console.error("Error fetching items by vendor type:", err); return { data: [], error: "Failed to fetch items" }; } } /** * 벤더의 possible_items를 조회하고 해당 아이템 코드로 각 타입별 테이블을 조회 + * 벤더 타입이 콤마로 구분된 경우 (예: "조선,해양TOP,해양HULL") 모든 타입의 아이템을 조회 */ export async function getVendorItemsByType(vendorId: number, vendorType: string) { try { @@ -865,47 +867,61 @@ export async function getVendorItemsByType(vendorId: number, vendorType: string) }) const itemCodes = possibleItems.map(item => item.itemCode) + + if (itemCodes.length === 0) { + return { data: [] } + } - // 벤더 타입에 따라 해당하는 테이블에서 아이템 조회 - switch (vendorType) { - case "조선": - const shipbuildingItems = await db.query.itemShipbuilding.findMany({ - where: inArray(itemShipbuilding.itemCode, itemCodes) - }) - return { - data: shipbuildingItems.map(item => ({ + // 벤더 타입을 콤마로 분리 + const vendorTypes = vendorType.split(',').map(type => type.trim()) + const allItems: Array<Record<string, any> & { techVendorType: "조선" | "해양TOP" | "해양HULL" }> = [] + + // 각 벤더 타입에 따라 해당하는 테이블에서 아이템 조회 + for (const singleType of vendorTypes) { + switch (singleType) { + case "조선": + const shipbuildingItems = await db.query.itemShipbuilding.findMany({ + where: inArray(itemShipbuilding.itemCode, itemCodes) + }) + allItems.push(...shipbuildingItems.map(item => ({ ...item, - techVendorType: "조선" - })) - } + techVendorType: "조선" as const + }))) + break - case "해양TOP": - const offshoreTopItems = await db.query.itemOffshoreTop.findMany({ - where: inArray(itemOffshoreTop.itemCode, itemCodes) - }) - return { - data: offshoreTopItems.map(item => ({ + case "해양TOP": + const offshoreTopItems = await db.query.itemOffshoreTop.findMany({ + where: inArray(itemOffshoreTop.itemCode, itemCodes) + }) + allItems.push(...offshoreTopItems.map(item => ({ ...item, - techVendorType: "해양TOP" - })) - } + techVendorType: "해양TOP" as const + }))) + break - case "해양HULL": - const offshoreHullItems = await db.query.itemOffshoreHull.findMany({ - where: inArray(itemOffshoreHull.itemCode, itemCodes) - }) - return { - data: offshoreHullItems.map(item => ({ + case "해양HULL": + const offshoreHullItems = await db.query.itemOffshoreHull.findMany({ + where: inArray(itemOffshoreHull.itemCode, itemCodes) + }) + allItems.push(...offshoreHullItems.map(item => ({ ...item, - techVendorType: "해양HULL" - })) - } + techVendorType: "해양HULL" as const + }))) + break - default: - throw new Error(`Unsupported vendor type: ${vendorType}`) + default: + console.warn(`Unknown vendor type: ${singleType}`) + break + } } - } catch (error) { - throw error + + // 중복 허용 - 모든 아이템을 그대로 반환 + return { + data: allItems.sort((a, b) => a.itemCode.localeCompare(b.itemCode)) + } + } catch (err) { + console.error("Error getting vendor items by type:", err) + return { data: [] } } } @@ -1645,3 +1661,20 @@ export async function addTechVendor(input: { } } +/** + * 벤더의 possible items 개수 조회 + */ +export async function getTechVendorPossibleItemsCount(vendorId: number): Promise<number> { + try { + const result = await db + .select({ count: sql<number>`count(*)`.as("count") }) + .from(techVendorPossibleItems) + .where(eq(techVendorPossibleItems.vendorId, vendorId)); + + return result[0]?.count || 0; + } catch (err) { + console.error("Error getting tech vendor possible items count:", err); + return 0; + } +} + diff --git a/lib/tech-vendors/table/tech-vendor-possible-items-view-dialog.tsx b/lib/tech-vendors/table/tech-vendor-possible-items-view-dialog.tsx new file mode 100644 index 00000000..b2b9c990 --- /dev/null +++ b/lib/tech-vendors/table/tech-vendor-possible-items-view-dialog.tsx @@ -0,0 +1,201 @@ +"use client"
+
+import * as React from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { Package, FileText, X } from "lucide-react"
+import { getVendorItemsByType } from "../service"
+
+interface VendorPossibleItem {
+ id: number;
+ itemCode: string;
+ itemList: string;
+ workType: string | null;
+ shipTypes?: string | null; // 조선용
+ subItemList?: string | null; // 해양용
+ techVendorType: "조선" | "해양TOP" | "해양HULL";
+}
+
+interface TechVendorPossibleItemsViewDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ vendor: {
+ id: number;
+ vendorName?: string | null;
+ vendorCode?: string | null;
+ techVendorType?: string | null;
+ } | null;
+}
+
+export function TechVendorPossibleItemsViewDialog({
+ open,
+ onOpenChange,
+ vendor,
+}: TechVendorPossibleItemsViewDialogProps) {
+ const [items, setItems] = React.useState<VendorPossibleItem[]>([]);
+ const [loading, setLoading] = React.useState(false);
+
+ console.log("TechVendorPossibleItemsViewDialog render:", { open, vendor });
+
+ React.useEffect(() => {
+ console.log("TechVendorPossibleItemsViewDialog useEffect:", { open, vendorId: vendor?.id });
+ if (open && vendor?.id && vendor?.techVendorType) {
+ loadItems();
+ }
+ }, [open, vendor?.id, vendor?.techVendorType]);
+
+ const loadItems = async () => {
+ if (!vendor?.id || !vendor?.techVendorType) return;
+
+ console.log("Loading items for vendor:", vendor.id, vendor.techVendorType);
+ setLoading(true);
+ try {
+ const result = await getVendorItemsByType(vendor.id, vendor.techVendorType);
+ console.log("Items loaded:", result);
+ if (result.data) {
+ setItems(result.data);
+ }
+ } catch (error) {
+ console.error("Failed to load items:", error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const getTypeLabel = (type: string) => {
+ switch (type) {
+ case "조선":
+ return "조선";
+ case "해양TOP":
+ return "해양TOP";
+ case "해양HULL":
+ return "해양HULL";
+ default:
+ return type;
+ }
+ };
+
+ const getTypeColor = (type: string) => {
+ switch (type) {
+ case "조선":
+ return "bg-blue-100 text-blue-800";
+ case "해양TOP":
+ return "bg-green-100 text-green-800";
+ case "해양HULL":
+ return "bg-purple-100 text-purple-800";
+ default:
+ return "bg-gray-100 text-gray-800";
+ }
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-none w-[1200px]">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ 벤더 Possible Items 조회
+ <Badge variant="outline" className="ml-2">
+ {vendor?.vendorName || `Vendor #${vendor?.id}`}
+ </Badge>
+ {vendor?.techVendorType && (
+ <Badge variant="secondary" className={getTypeColor(vendor.techVendorType)}>
+ {getTypeLabel(vendor.techVendorType)}
+ </Badge>
+ )}
+ </DialogTitle>
+ <DialogDescription>
+ 해당 벤더가 공급 가능한 아이템 목록을 확인할 수 있습니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="overflow-x-auto w-full">
+ <div className="space-y-4">
+ {loading ? (
+ <div className="flex items-center justify-center py-8">
+ <div className="text-center space-y-2">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
+ <p className="text-sm text-muted-foreground">아이템을 불러오는 중...</p>
+ </div>
+ </div>
+ ) : items.length === 0 ? (
+ <div className="flex flex-col items-center justify-center py-12 text-center">
+ <FileText className="h-12 w-12 text-muted-foreground mb-3" />
+ <h3 className="text-lg font-medium mb-1">등록된 아이템이 없습니다</h3>
+ <p className="text-sm text-muted-foreground">
+ 이 벤더에 등록된 아이템이 없습니다.
+ </p>
+ </div>
+ ) : (
+ <>
+ {/* 헤더 행 (라벨) */}
+ <div className="flex items-center gap-2 border-b pb-2 font-medium text-sm">
+ <div className="w-[50px] text-center">No.</div>
+ <div className="w-[120px] pl-2">타입</div>
+ <div className="w-[200px] ">자재 그룹</div>
+ <div className="w-[150px] ">공종</div>
+ <div className="w-[300px] ">자재명</div>
+ <div className="w-[150px] ">선종/자재명(상세)</div>
+ </div>
+
+ {/* 아이템 행들 */}
+ <div className="max-h-[50vh] overflow-y-auto pr-1 space-y-2">
+ {items.map((item, index) => (
+ <div
+ key={item.id}
+ className="flex items-center gap-2 group hover:bg-gray-50 p-2 rounded-md transition-colors border"
+ >
+ <div className="w-[50px] text-center text-sm font-medium text-muted-foreground">
+ {index + 1}
+ </div>
+ <div className="w-[120px] pl-2">
+ <Badge variant="secondary" className={`text-xs ${getTypeColor(item.techVendorType)}`}>
+ {getTypeLabel(item.techVendorType)}
+ </Badge>
+ </div>
+ <div className="w-[200px] pl-2 font-mono text-sm">
+ {item.itemCode}
+ </div>
+ <div className="w-[150px] pl-2 text-sm">
+ {item.workType || '-'}
+ </div>
+ <div className="w-[300px] pl-2 font-medium">
+ {item.itemList}
+ </div>
+ <div className="w-[150px] pl-2 text-sm">
+ {item.techVendorType === '조선' ? item.shipTypes : item.subItemList}
+ </div>
+ </div>
+ ))}
+ </div>
+
+ <div className="flex justify-between items-center pt-2 border-t">
+ <div className="flex items-center gap-2">
+ <Package className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm text-muted-foreground">
+ 총 {items.length}개 아이템
+ </span>
+ </div>
+ </div>
+ </>
+ )}
+ </div>
+ </div>
+
+ <DialogFooter className="mt-6">
+ <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
+ <X className="mr-2 h-4 w-4" />
+ 닫기
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
\ No newline at end of file diff --git a/lib/tech-vendors/table/tech-vendors-table-columns.tsx b/lib/tech-vendors/table/tech-vendors-table-columns.tsx index 22e89dd0..438fceac 100644 --- a/lib/tech-vendors/table/tech-vendors-table-columns.tsx +++ b/lib/tech-vendors/table/tech-vendors-table-columns.tsx @@ -3,7 +3,7 @@ import * as React from "react" import { type DataTableRowAction } from "@/types/table" import { type ColumnDef } from "@tanstack/react-table" -import { Ellipsis } from "lucide-react" +import { Ellipsis, Package } from "lucide-react" import { toast } from "sonner" import { getErrorMessage } from "@/lib/handle-error" @@ -47,6 +47,7 @@ type NextRouter = ReturnType<typeof useRouter>; interface GetColumnsProps { setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TechVendor> | null>>; router: NextRouter; + openItemsDialog: (vendor: TechVendor) => void; } @@ -55,7 +56,7 @@ interface GetColumnsProps { /** * tanstack table 컬럼 정의 (중첩 헤더 버전) */ -export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef<TechVendor>[] { +export function getColumns({ setRowAction, router, openItemsDialog }: GetColumnsProps): ColumnDef<TechVendor>[] { // ---------------------------------------------------------------- // 1) select 컬럼 (체크박스) // ---------------------------------------------------------------- @@ -230,12 +231,6 @@ 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", @@ -251,7 +246,7 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef "ACTIVE": "활성 상태", "INACTIVE": "비활성 상태", "BLACKLISTED": "거래 금지", - "PENDING_REVIEW": "비교 견적", + "PENDING_REVIEW": "비교 견적" }; return statusMap[status] || status; @@ -306,6 +301,43 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef } }); + // Possible Items 컬럼 추가 (액션 컬럼 직전에) + const possibleItemsColumn: ColumnDef<TechVendor> = { + id: "possibleItems", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="자재 그룹" /> + ), + cell: ({ row }) => { + const vendor = row.original; + + const handleClick = () => { + openItemsDialog(vendor); + }; + + return ( + <Button + variant="ghost" + size="sm" + className="relative h-8 w-8 p-0 group" + onClick={handleClick} + aria-label="View possible items" + > + <Package className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + <span className="sr-only"> + Possible Items 보기 + </span> + </Button> + ); + }, + enableSorting: false, + enableResizing: false, + size: 80, + meta: { + excelHeader: "Possible Items" + }, + }; + + columns.push(possibleItemsColumn); columns.push(actionsColumn); // 마지막에 액션 컬럼 추가 return columns; diff --git a/lib/tech-vendors/table/tech-vendors-table.tsx b/lib/tech-vendors/table/tech-vendors-table.tsx index 63ca8fcc..125e39dc 100644 --- a/lib/tech-vendors/table/tech-vendors-table.tsx +++ b/lib/tech-vendors/table/tech-vendors-table.tsx @@ -17,6 +17,7 @@ import { TechVendor, techVendors, TechVendorWithAttachments } from "@/db/schema/ import { TechVendorsTableToolbarActions } from "./tech-vendors-table-toolbar-actions" import { UpdateVendorSheet } from "./update-vendor-sheet" import { getVendorStatusIcon } from "../utils" +import { TechVendorPossibleItemsViewDialog } from "./tech-vendor-possible-items-view-dialog" // import { ViewTechVendorLogsDialog } from "./view-tech-vendors-logs-dialog" interface TechVendorsTableProps { @@ -34,14 +35,22 @@ export function TechVendorsTable({ promises }: TechVendorsTableProps) { const [isCompact, setIsCompact] = React.useState<boolean>(false) const [rowAction, setRowAction] = React.useState<DataTableRowAction<TechVendor> | null>(null) + const [itemsDialogOpen, setItemsDialogOpen] = React.useState(false) + const [selectedVendorForItems, setSelectedVendorForItems] = React.useState<TechVendor | null>(null) // **router** 획득 const router = useRouter() - // getColumns() 호출 시, router를 주입 + // openItemsDialog 함수 정의 + const openItemsDialog = React.useCallback((vendor: TechVendor) => { + setSelectedVendorForItems(vendor) + setItemsDialogOpen(true) + }, []) + + // getColumns() 호출 시, router와 openItemsDialog를 주입 const columns = React.useMemo( - () => getColumns({ setRowAction, router }), - [setRowAction, router] + () => getColumns({ setRowAction, router, openItemsDialog }), + [setRowAction, router, openItemsDialog] ) // 상태 한글 변환 유틸리티 함수 @@ -133,7 +142,7 @@ export function TechVendorsTable({ promises }: TechVendorsTableProps) { enableAdvancedFilter: true, initialState: { sorting: [{ id: "createdAt", desc: true }], - columnPinning: { right: ["actions"] }, + columnPinning: { right: ["actions", "possibleItems"] }, }, getRowId: (originalRow) => String(originalRow.id), shallow: false, @@ -173,6 +182,11 @@ export function TechVendorsTable({ promises }: TechVendorsTableProps) { onOpenChange={() => setRowAction(null)} vendor={rowAction?.row.original ?? null} /> + <TechVendorPossibleItemsViewDialog + open={itemsDialogOpen} + onOpenChange={setItemsDialogOpen} + vendor={selectedVendorForItems} + /> </> ) }
\ No newline at end of file |
