summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-07-03 02:48:24 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-07-03 02:48:24 +0000
commite4b2bef735e6aab6a5ecae9a017c5c618a6d3a4b (patch)
tree827f1524fd78ee9ea6adddf4cef3d6e44937d6da
parent6f22fc9ebc8d175041aa18cf0986592e57d03f63 (diff)
(최겸) 기술영업 벤더 아이템 조회 아이콘 기능 추가
-rw-r--r--lib/tech-vendors/repository.ts63
-rw-r--r--lib/tech-vendors/service.ts101
-rw-r--r--lib/tech-vendors/table/tech-vendor-possible-items-view-dialog.tsx201
-rw-r--r--lib/tech-vendors/table/tech-vendors-table-columns.tsx50
-rw-r--r--lib/tech-vendors/table/tech-vendors-table.tsx22
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