feat: 견적 V2 동적 카테고리 탭 시스템 구현
- LocationDetailPanel: 하드코딩된 탭을 API 기반 동적 탭으로 변경 - convertCategoryTreeToTabs(): 카테고리 트리 → 탭 변환 - useEffect로 카테고리 API 로드 - BENDING 하위 카테고리 개별 탭 처리 - 레거시 process_group_key 호환 유지 - actions.ts: getItemCategoryTree() 함수 추가 - /api/v1/categories/tree?code_group=item_category 호출 - types.ts: BomCalculationResultItem 타입 확장 - process_group_key, category_code, is_manual 필드 추가
This commit is contained in:
@@ -9,8 +9,9 @@
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { Package, Settings, Plus, Trash2 } from "lucide-react";
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import { Package, Settings, Plus, Trash2, Loader2 } from "lucide-react";
|
||||
import { getItemCategoryTree, type ItemCategoryNode } from "./actions";
|
||||
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Button } from "../ui/button";
|
||||
@@ -48,15 +49,14 @@ const DELIVERY_LENGTH_OPTIONS = [
|
||||
{ value: "6000", label: "6000" },
|
||||
];
|
||||
|
||||
// 빈 BOM 아이템 (bomResult 없을 때 사용)
|
||||
const EMPTY_BOM_ITEMS: Record<string, BomCalculationResultItem[]> = {
|
||||
body: [],
|
||||
"guide-rail": [],
|
||||
case: [],
|
||||
bottom: [],
|
||||
motor: [],
|
||||
accessory: [],
|
||||
};
|
||||
// 빈 BOM 아이템 생성 함수 (동적 탭에 맞게)
|
||||
function createEmptyBomItems(tabs: TabDefinition[]): Record<string, BomCalculationResultItem[]> {
|
||||
const result: Record<string, BomCalculationResultItem[]> = {};
|
||||
tabs.forEach((tab) => {
|
||||
result[tab.value] = [];
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 상수
|
||||
@@ -81,14 +81,54 @@ const CONTROLLERS = [
|
||||
{ value: "premium", label: "매립형-뒷박스포함" },
|
||||
];
|
||||
|
||||
// 탭 정의 (6개)
|
||||
const DETAIL_TABS = [
|
||||
{ value: "body", label: "본체 (스크린/슬랫)" },
|
||||
{ value: "guide-rail", label: "절곡품 - 가이드레일" },
|
||||
{ value: "case", label: "절곡품 - 케이스" },
|
||||
{ value: "bottom", label: "절곡품 - 하단마감재" },
|
||||
{ value: "motor", label: "모터 & 제어기" },
|
||||
{ value: "accessory", label: "부자재" },
|
||||
// 탭 인터페이스 정의
|
||||
interface TabDefinition {
|
||||
value: string; // 카테고리 code (예: BODY, BENDING_GUIDE)
|
||||
label: string; // 표시 이름
|
||||
categoryId: number; // 카테고리 ID
|
||||
parentCode?: string; // 상위 카테고리 코드
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 트리를 탭 배열로 변환
|
||||
* - 본체, 모터&제어기, 부자재 → 1depth 탭
|
||||
* - 절곡품 → 하위 카테고리를 각각 탭으로 (가이드레일, 케이스, 하단마감재)
|
||||
*/
|
||||
function convertCategoryTreeToTabs(categories: ItemCategoryNode[]): TabDefinition[] {
|
||||
const tabs: TabDefinition[] = [];
|
||||
|
||||
categories.forEach((category) => {
|
||||
if (category.code === "BENDING") {
|
||||
// 절곡품: 하위 카테고리를 탭으로 변환
|
||||
category.children.forEach((subCategory) => {
|
||||
tabs.push({
|
||||
value: subCategory.code,
|
||||
label: `절곡품 - ${subCategory.name}`,
|
||||
categoryId: subCategory.id,
|
||||
parentCode: category.code,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// 본체, 모터&제어기, 부자재: 1depth 탭
|
||||
tabs.push({
|
||||
value: category.code,
|
||||
label: category.name,
|
||||
categoryId: category.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return tabs;
|
||||
}
|
||||
|
||||
// 기본 탭 정의 (API 로딩 전 또는 실패 시 fallback)
|
||||
const DEFAULT_TABS: TabDefinition[] = [
|
||||
{ value: "BODY", label: "본체", categoryId: 0 },
|
||||
{ value: "BENDING_GUIDE", label: "절곡품 - 가이드레일", categoryId: 0, parentCode: "BENDING" },
|
||||
{ value: "BENDING_CASE", label: "절곡품 - 케이스", categoryId: 0, parentCode: "BENDING" },
|
||||
{ value: "BENDING_BOTTOM", label: "절곡품 - 하단마감재", categoryId: 0, parentCode: "BENDING" },
|
||||
{ value: "MOTOR_CTRL", label: "모터 & 제어기", categoryId: 0 },
|
||||
{ value: "ACCESSORY", label: "부자재", categoryId: 0 },
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
@@ -116,9 +156,45 @@ export function LocationDetailPanel({
|
||||
// 상태
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const [activeTab, setActiveTab] = useState("body");
|
||||
const [activeTab, setActiveTab] = useState("BODY");
|
||||
const [itemSearchOpen, setItemSearchOpen] = useState(false);
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [itemCategories, setItemCategories] = useState<ItemCategoryNode[]>([]);
|
||||
const [categoriesLoading, setCategoriesLoading] = useState(true);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 카테고리 로드
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
useEffect(() => {
|
||||
async function loadCategories() {
|
||||
try {
|
||||
setCategoriesLoading(true);
|
||||
const result = await getItemCategoryTree();
|
||||
if (result.success && result.data) {
|
||||
setItemCategories(result.data);
|
||||
} else {
|
||||
console.error("[카테고리 로드 실패]", result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[카테고리 로드 에러]", error);
|
||||
} finally {
|
||||
setCategoriesLoading(false);
|
||||
}
|
||||
}
|
||||
loadCategories();
|
||||
}, []);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 탭 정의 (카테고리 기반 동적 생성)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const detailTabs = useMemo(() => {
|
||||
if (itemCategories.length === 0) {
|
||||
return DEFAULT_TABS;
|
||||
}
|
||||
return convertCategoryTreeToTabs(itemCategories);
|
||||
}, [itemCategories]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 계산된 값
|
||||
@@ -130,56 +206,97 @@ export function LocationDetailPanel({
|
||||
return finishedGoods.find((fg) => fg.item_code === location.productCode);
|
||||
}, [location?.productCode, finishedGoods]);
|
||||
|
||||
// BOM 아이템을 탭별로 분류
|
||||
// BOM 아이템을 탭별로 분류 (카테고리 코드 기반)
|
||||
const bomItemsByTab = useMemo(() => {
|
||||
// bomResult가 없으면 빈 배열 반환
|
||||
if (!location?.bomResult?.items) {
|
||||
return EMPTY_BOM_ITEMS;
|
||||
return createEmptyBomItems(detailTabs);
|
||||
}
|
||||
|
||||
const items = location.bomResult.items;
|
||||
const result: Record<string, typeof items> = {
|
||||
body: [],
|
||||
"guide-rail": [],
|
||||
case: [],
|
||||
bottom: [],
|
||||
};
|
||||
const result: Record<string, typeof items> = {};
|
||||
|
||||
// 탭별 빈 배열 초기화
|
||||
detailTabs.forEach((tab) => {
|
||||
result[tab.value] = [];
|
||||
});
|
||||
|
||||
// 카테고리 코드 → 탭 value 매핑 생성
|
||||
// (절곡품 하위 카테고리 매핑 포함)
|
||||
const categoryCodeToTab: Record<string, string> = {};
|
||||
itemCategories.forEach((category) => {
|
||||
if (category.code === "BENDING") {
|
||||
// 절곡품: 하위 카테고리별로 탭 매핑
|
||||
category.children.forEach((subCategory) => {
|
||||
categoryCodeToTab[subCategory.code] = subCategory.code;
|
||||
// 하위의 세부 카테고리도 매핑
|
||||
subCategory.children?.forEach((detailCategory) => {
|
||||
categoryCodeToTab[detailCategory.code] = subCategory.code;
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// 일반 카테고리
|
||||
categoryCodeToTab[category.code] = category.code;
|
||||
// 하위 카테고리도 상위 탭에 매핑
|
||||
category.children?.forEach((child) => {
|
||||
categoryCodeToTab[child.code] = category.code;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
items.forEach((item) => {
|
||||
// process_group_key (API 그룹 키) 또는 process_group (한글명) 사용
|
||||
const processGroupKey = (item as { process_group_key?: string }).process_group_key?.toLowerCase() || "";
|
||||
// 1. category_code로 분류 (우선)
|
||||
const categoryCode = (item as { category_code?: string }).category_code?.toUpperCase() || "";
|
||||
const mappedTab = categoryCodeToTab[categoryCode];
|
||||
|
||||
if (mappedTab && result[mappedTab]) {
|
||||
result[mappedTab].push(item);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. process_group_key로 분류 (legacy fallback)
|
||||
const processGroupKey = (item as { process_group_key?: string }).process_group_key?.toUpperCase() || "";
|
||||
|
||||
// 기존 process_group_key → 새 카테고리 코드 매핑
|
||||
const legacyMapping: Record<string, string> = {
|
||||
"SCREEN": "BODY",
|
||||
"ASSEMBLY": "BODY",
|
||||
"BENDING": "BENDING_GUIDE", // 기본적으로 가이드레일에 배치
|
||||
"STEEL": "BENDING_CASE",
|
||||
"ELECTRIC": "BENDING_BOTTOM",
|
||||
"MOTOR": "MOTOR_CTRL",
|
||||
"ACCESSORY": "ACCESSORY",
|
||||
};
|
||||
|
||||
const legacyTab = legacyMapping[processGroupKey];
|
||||
if (legacyTab && result[legacyTab]) {
|
||||
result[legacyTab].push(item);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. process_group (한글명) 기반 분류 (최종 fallback)
|
||||
const processGroup = item.process_group?.toLowerCase() || "";
|
||||
|
||||
// API 그룹 키 기반 분류 (우선)
|
||||
if (processGroupKey === "screen" || processGroupKey === "assembly") {
|
||||
result.body.push(item);
|
||||
} else if (processGroupKey === "bending") {
|
||||
result["guide-rail"].push(item);
|
||||
} else if (processGroupKey === "steel") {
|
||||
result.case.push(item);
|
||||
} else if (processGroupKey === "electric") {
|
||||
result.bottom.push(item);
|
||||
} else if (processGroupKey) {
|
||||
// 기타 그룹키는 본체에 포함
|
||||
result.body.push(item);
|
||||
}
|
||||
// 한글명 기반 분류 (fallback)
|
||||
else if (processGroup.includes("본체") || processGroup.includes("스크린") || processGroup.includes("슬랫") || processGroup.includes("조립")) {
|
||||
result.body.push(item);
|
||||
} else if (processGroup.includes("가이드") || processGroup.includes("레일") || processGroup.includes("절곡")) {
|
||||
result["guide-rail"].push(item);
|
||||
if (processGroup.includes("본체") || processGroup.includes("스크린") || processGroup.includes("슬랫") || processGroup.includes("조립")) {
|
||||
result["BODY"]?.push(item);
|
||||
} else if (processGroup.includes("가이드") || processGroup.includes("레일")) {
|
||||
result["BENDING_GUIDE"]?.push(item);
|
||||
} else if (processGroup.includes("케이스") || processGroup.includes("철재")) {
|
||||
result.case.push(item);
|
||||
} else if (processGroup.includes("하단") || processGroup.includes("마감") || processGroup.includes("전기")) {
|
||||
result.bottom.push(item);
|
||||
result["BENDING_CASE"]?.push(item);
|
||||
} else if (processGroup.includes("하단") || processGroup.includes("마감")) {
|
||||
result["BENDING_BOTTOM"]?.push(item);
|
||||
} else if (processGroup.includes("모터") || processGroup.includes("제어기")) {
|
||||
result["MOTOR_CTRL"]?.push(item);
|
||||
} else if (processGroup.includes("부자재")) {
|
||||
result["ACCESSORY"]?.push(item);
|
||||
} else {
|
||||
// 기타 항목은 본체에 포함
|
||||
result.body.push(item);
|
||||
result["BODY"]?.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [location?.bomResult?.items]);
|
||||
}, [location?.bomResult?.items, detailTabs, itemCategories]);
|
||||
|
||||
// 탭별 소계
|
||||
const tabSubtotals = useMemo(() => {
|
||||
@@ -339,270 +456,161 @@ export function LocationDetailPanel({
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col h-full">
|
||||
{/* 탭 목록 - 스크롤 가능 */}
|
||||
<div className="border-b bg-white overflow-x-auto">
|
||||
<TabsList className="w-max min-w-full justify-start rounded-none bg-transparent h-auto p-0">
|
||||
{DETAIL_TABS.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-blue-500 data-[state=active]:bg-blue-50 data-[state=active]:text-blue-700 px-4 py-2 text-sm whitespace-nowrap"
|
||||
>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{categoriesLoading ? (
|
||||
<div className="flex items-center justify-center py-2 px-4 text-gray-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
<span className="text-sm">카테고리 로딩 중...</span>
|
||||
</div>
|
||||
) : (
|
||||
<TabsList className="w-max min-w-full justify-start rounded-none bg-transparent h-auto p-0">
|
||||
{detailTabs.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-blue-500 data-[state=active]:bg-blue-50 data-[state=active]:text-blue-700 px-4 py-2 text-sm whitespace-nowrap"
|
||||
>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 본체 (스크린/슬랫) 탭 */}
|
||||
<TabsContent value="body" className="flex-1 overflow-auto m-0 p-0">
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg m-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-amber-100/50">
|
||||
<TableHead className="font-semibold">품목명</TableHead>
|
||||
<TableHead className="text-center font-semibold">제작사이즈</TableHead>
|
||||
<TableHead className="text-center font-semibold w-24">수량</TableHead>
|
||||
<TableHead className="text-center font-semibold w-20">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{bomItemsByTab.body.map((item: any, index: number) => (
|
||||
<TableRow key={item.id || `body-${index}`} className="bg-white">
|
||||
<TableCell className="font-medium">{item.item_name}</TableCell>
|
||||
<TableCell className="text-center text-gray-600">{item.manufacture_size || "-"}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<QuantityInput
|
||||
value={item.quantity}
|
||||
onChange={() => {}}
|
||||
className="w-16 h-8 text-center"
|
||||
min={1}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button variant="ghost" size="sm" className="text-red-500 hover:text-red-700 hover:bg-red-50 p-1" disabled={disabled}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{/* 동적 탭 콘텐츠 렌더링 */}
|
||||
{detailTabs.map((tab) => {
|
||||
const items = bomItemsByTab[tab.value] || [];
|
||||
const isBendingTab = tab.parentCode === "BENDING";
|
||||
const isMotorTab = tab.value === "MOTOR_CTRL";
|
||||
const isAccessoryTab = tab.value === "ACCESSORY";
|
||||
|
||||
{/* 품목 추가 버튼 + 안내 */}
|
||||
<div className="p-3 flex items-center justify-between border-t border-amber-200">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="bg-blue-100 border-blue-300 text-blue-700 hover:bg-blue-200"
|
||||
onClick={() => setItemSearchOpen(true)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
품목 추가
|
||||
</Button>
|
||||
<span className="text-sm text-gray-500 flex items-center gap-1">
|
||||
💡 금액은 아래 견적금액요약에서 확인하세요
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 절곡품 - 가이드레일, 케이스, 하단마감재 탭 */}
|
||||
{["guide-rail", "case", "bottom"].map((tabValue) => (
|
||||
<TabsContent key={tabValue} value={tabValue} className="flex-1 overflow-auto m-0 p-0">
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg m-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-amber-100/50">
|
||||
<TableHead className="font-semibold">품목명</TableHead>
|
||||
<TableHead className="text-center font-semibold">재질</TableHead>
|
||||
<TableHead className="text-center font-semibold">규격</TableHead>
|
||||
<TableHead className="text-center font-semibold w-28">납품길이</TableHead>
|
||||
<TableHead className="text-center font-semibold w-24">수량</TableHead>
|
||||
<TableHead className="text-center font-semibold w-20">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{bomItemsByTab[tabValue]?.map((item: any, index: number) => (
|
||||
<TableRow key={item.id || `${tabValue}-${index}`} className="bg-white">
|
||||
<TableCell className="font-medium">{item.item_name}</TableCell>
|
||||
<TableCell className="text-center text-gray-600">{item.material || "-"}</TableCell>
|
||||
<TableCell className="text-center text-gray-600">{item.spec || "-"}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Select defaultValue={item.delivery_length} disabled={disabled}>
|
||||
<SelectTrigger className="w-24 h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DELIVERY_LENGTH_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<QuantityInput
|
||||
value={item.quantity}
|
||||
onChange={() => {}}
|
||||
className="w-16 h-8 text-center"
|
||||
min={1}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button variant="ghost" size="sm" className="text-red-500 hover:text-red-700 hover:bg-red-50 p-1" disabled={disabled}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
return (
|
||||
<TabsContent key={tab.value} value={tab.value} className="flex-1 overflow-auto m-0 p-0">
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg m-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-amber-100/50">
|
||||
<TableHead className="font-semibold">품목명</TableHead>
|
||||
{/* 본체: 제작사이즈 */}
|
||||
{!isBendingTab && !isMotorTab && !isAccessoryTab && (
|
||||
<TableHead className="text-center font-semibold">제작사이즈</TableHead>
|
||||
)}
|
||||
{/* 절곡품: 재질, 규격, 납품길이 */}
|
||||
{isBendingTab && (
|
||||
<>
|
||||
<TableHead className="text-center font-semibold">재질</TableHead>
|
||||
<TableHead className="text-center font-semibold">규격</TableHead>
|
||||
<TableHead className="text-center font-semibold w-28">납품길이</TableHead>
|
||||
</>
|
||||
)}
|
||||
{/* 모터: 유형, 사양 */}
|
||||
{isMotorTab && (
|
||||
<>
|
||||
<TableHead className="text-center font-semibold">유형</TableHead>
|
||||
<TableHead className="text-center font-semibold">사양</TableHead>
|
||||
</>
|
||||
)}
|
||||
{/* 부자재: 규격, 납품길이 */}
|
||||
{isAccessoryTab && (
|
||||
<>
|
||||
<TableHead className="text-center font-semibold">규격</TableHead>
|
||||
<TableHead className="text-center font-semibold w-28">납품길이</TableHead>
|
||||
</>
|
||||
)}
|
||||
<TableHead className="text-center font-semibold w-24">수량</TableHead>
|
||||
<TableHead className="text-center font-semibold w-20">작업</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item: any, index: number) => (
|
||||
<TableRow key={item.id || `${tab.value}-${index}`} className="bg-white">
|
||||
<TableCell className="font-medium">{item.item_name}</TableCell>
|
||||
{/* 본체: 제작사이즈 */}
|
||||
{!isBendingTab && !isMotorTab && !isAccessoryTab && (
|
||||
<TableCell className="text-center text-gray-600">{item.manufacture_size || "-"}</TableCell>
|
||||
)}
|
||||
{/* 절곡품: 재질, 규격, 납품길이 */}
|
||||
{isBendingTab && (
|
||||
<>
|
||||
<TableCell className="text-center text-gray-600">{item.material || "-"}</TableCell>
|
||||
<TableCell className="text-center text-gray-600">{item.spec || "-"}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Select defaultValue={item.delivery_length} disabled={disabled}>
|
||||
<SelectTrigger className="w-24 h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DELIVERY_LENGTH_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
</>
|
||||
)}
|
||||
{/* 모터: 유형, 사양 */}
|
||||
{isMotorTab && (
|
||||
<>
|
||||
<TableCell className="text-center text-gray-600">{item.type || "-"}</TableCell>
|
||||
<TableCell className="text-center text-gray-600">{item.spec || "-"}</TableCell>
|
||||
</>
|
||||
)}
|
||||
{/* 부자재: 규격, 납품길이 */}
|
||||
{isAccessoryTab && (
|
||||
<>
|
||||
<TableCell className="text-center text-gray-600">{item.spec || "-"}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Select defaultValue={item.delivery_length} disabled={disabled}>
|
||||
<SelectTrigger className="w-24 h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DELIVERY_LENGTH_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
</>
|
||||
)}
|
||||
<TableCell className="text-center">
|
||||
<QuantityInput
|
||||
value={item.quantity}
|
||||
onChange={() => {}}
|
||||
className="w-16 h-8 text-center"
|
||||
min={1}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button variant="ghost" size="sm" className="text-red-500 hover:text-red-700 hover:bg-red-50 p-1" disabled={disabled}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* 품목 추가 버튼 + 안내 */}
|
||||
<div className="p-3 flex items-center justify-between border-t border-amber-200">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="bg-blue-100 border-blue-300 text-blue-700 hover:bg-blue-200"
|
||||
onClick={() => setItemSearchOpen(true)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
품목 추가
|
||||
</Button>
|
||||
<span className="text-sm text-gray-500 flex items-center gap-1">
|
||||
💡 금액은 아래 견적금액요약에서 확인하세요
|
||||
</span>
|
||||
{/* 품목 추가 버튼 + 안내 */}
|
||||
<div className="p-3 flex items-center justify-between border-t border-amber-200">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="bg-blue-100 border-blue-300 text-blue-700 hover:bg-blue-200"
|
||||
onClick={() => setItemSearchOpen(true)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
품목 추가
|
||||
</Button>
|
||||
<span className="text-sm text-gray-500 flex items-center gap-1">
|
||||
💡 금액은 아래 견적금액요약에서 확인하세요
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
|
||||
{/* 모터 & 제어기 탭 */}
|
||||
<TabsContent value="motor" className="flex-1 overflow-auto m-0 p-0">
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg m-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-amber-100/50">
|
||||
<TableHead className="font-semibold">품목명</TableHead>
|
||||
<TableHead className="text-center font-semibold">유형</TableHead>
|
||||
<TableHead className="text-center font-semibold">사양</TableHead>
|
||||
<TableHead className="text-center font-semibold w-24">수량</TableHead>
|
||||
<TableHead className="text-center font-semibold w-20">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{bomItemsByTab.motor?.map((item: any, index: number) => (
|
||||
<TableRow key={item.id || `motor-${index}`} className="bg-white">
|
||||
<TableCell className="font-medium">{item.item_name}</TableCell>
|
||||
<TableCell className="text-center text-gray-600">{item.type || "-"}</TableCell>
|
||||
<TableCell className="text-center text-gray-600">{item.spec || "-"}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<QuantityInput
|
||||
value={item.quantity}
|
||||
onChange={() => {}}
|
||||
className="w-16 h-8 text-center"
|
||||
min={1}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button variant="ghost" size="sm" className="text-red-500 hover:text-red-700 hover:bg-red-50 p-1" disabled={disabled}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* 품목 추가 버튼 + 안내 */}
|
||||
<div className="p-3 flex items-center justify-between border-t border-amber-200">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="bg-blue-100 border-blue-300 text-blue-700 hover:bg-blue-200"
|
||||
onClick={() => setItemSearchOpen(true)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
품목 추가
|
||||
</Button>
|
||||
<span className="text-sm text-gray-500 flex items-center gap-1">
|
||||
💡 금액은 아래 견적금액요약에서 확인하세요
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 부자재 탭 */}
|
||||
<TabsContent value="accessory" className="flex-1 overflow-auto m-0 p-0">
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg m-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-amber-100/50">
|
||||
<TableHead className="font-semibold">품목명</TableHead>
|
||||
<TableHead className="text-center font-semibold">규격</TableHead>
|
||||
<TableHead className="text-center font-semibold w-28">납품길이</TableHead>
|
||||
<TableHead className="text-center font-semibold w-24">수량</TableHead>
|
||||
<TableHead className="text-center font-semibold w-20">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{bomItemsByTab.accessory?.map((item: any, index: number) => (
|
||||
<TableRow key={item.id || `accessory-${index}`} className="bg-white">
|
||||
<TableCell className="font-medium">{item.item_name}</TableCell>
|
||||
<TableCell className="text-center text-gray-600">{item.spec || "-"}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Select defaultValue={item.delivery_length} disabled={disabled}>
|
||||
<SelectTrigger className="w-24 h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DELIVERY_LENGTH_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<QuantityInput
|
||||
value={item.quantity}
|
||||
onChange={() => {}}
|
||||
className="w-16 h-8 text-center"
|
||||
min={1}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button variant="ghost" size="sm" className="text-red-500 hover:text-red-700 hover:bg-red-50 p-1" disabled={disabled}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* 품목 추가 버튼 + 안내 */}
|
||||
<div className="p-3 flex items-center justify-between border-t border-amber-200">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="bg-blue-100 border-blue-300 text-blue-700 hover:bg-blue-200"
|
||||
onClick={() => setItemSearchOpen(true)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
품목 추가
|
||||
</Button>
|
||||
<span className="text-sm text-gray-500 flex items-center gap-1">
|
||||
💡 금액은 아래 견적금액요약에서 확인하세요
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</TabsContent>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
@@ -620,21 +628,13 @@ export function LocationDetailPanel({
|
||||
onSelectItem={(item) => {
|
||||
if (!location) return;
|
||||
|
||||
// 탭 → process_group_key 매핑
|
||||
const tabToProcessGroup: Record<string, string> = {
|
||||
body: "screen",
|
||||
"guide-rail": "bending",
|
||||
case: "steel",
|
||||
bottom: "electric",
|
||||
motor: "motor",
|
||||
accessory: "accessory",
|
||||
};
|
||||
// 현재 탭 정보 가져오기
|
||||
const currentTab = detailTabs.find((t) => t.value === activeTab);
|
||||
const categoryCode = activeTab; // 카테고리 코드를 직접 사용
|
||||
const categoryLabel = currentTab?.label || activeTab;
|
||||
|
||||
const processGroupKey = tabToProcessGroup[activeTab] || "screen";
|
||||
const processGroupLabel = DETAIL_TABS.find((t) => t.value === activeTab)?.label || activeTab;
|
||||
|
||||
// 새 품목 생성 (수동 추가 플래그 포함)
|
||||
const newItem: BomCalculationResultItem & { process_group_key?: string; is_manual?: boolean } = {
|
||||
// 새 품목 생성 (카테고리 코드 포함)
|
||||
const newItem: BomCalculationResultItem & { category_code?: string; is_manual?: boolean } = {
|
||||
item_code: item.code,
|
||||
item_name: item.name,
|
||||
specification: item.specification || "",
|
||||
@@ -642,8 +642,8 @@ export function LocationDetailPanel({
|
||||
quantity: 1,
|
||||
unit_price: 0,
|
||||
total_price: 0,
|
||||
process_group: processGroupLabel,
|
||||
process_group_key: processGroupKey,
|
||||
process_group: categoryLabel,
|
||||
category_code: categoryCode, // 새 카테고리 코드 사용
|
||||
is_manual: true, // 수동 추가 품목 표시
|
||||
};
|
||||
|
||||
@@ -662,16 +662,14 @@ export function LocationDetailPanel({
|
||||
|
||||
// subtotals 업데이트 (해당 카테고리의 count, subtotal 증가)
|
||||
const existingSubtotals = existingBomResult.subtotals || {};
|
||||
const categorySubtotal = existingSubtotals[processGroupKey] || {
|
||||
name: processGroupLabel,
|
||||
count: 0,
|
||||
subtotal: 0,
|
||||
};
|
||||
const rawCategorySubtotal = existingSubtotals[categoryCode];
|
||||
const categorySubtotal = (typeof rawCategorySubtotal === 'object' && rawCategorySubtotal !== null)
|
||||
? rawCategorySubtotal
|
||||
: { name: categoryLabel, count: 0, subtotal: 0 };
|
||||
const updatedSubtotals = {
|
||||
...existingSubtotals,
|
||||
[processGroupKey]: {
|
||||
...categorySubtotal,
|
||||
name: processGroupLabel,
|
||||
[categoryCode]: {
|
||||
name: categoryLabel,
|
||||
count: (categorySubtotal.count || 0) + 1,
|
||||
subtotal: (categorySubtotal.subtotal || 0) + (newItem.total_price || 0),
|
||||
},
|
||||
@@ -679,10 +677,10 @@ export function LocationDetailPanel({
|
||||
|
||||
// grouped_items 업데이트 (해당 카테고리의 items 배열에 추가)
|
||||
const existingGroupedItems = existingBomResult.grouped_items || {};
|
||||
const categoryGroupedItems = existingGroupedItems[processGroupKey] || { items: [] };
|
||||
const categoryGroupedItems = existingGroupedItems[categoryCode] || { items: [] };
|
||||
const updatedGroupedItems = {
|
||||
...existingGroupedItems,
|
||||
[processGroupKey]: {
|
||||
[categoryCode]: {
|
||||
...categoryGroupedItems,
|
||||
items: [...(categoryGroupedItems.items || []), newItem],
|
||||
},
|
||||
@@ -702,9 +700,9 @@ export function LocationDetailPanel({
|
||||
// location 업데이트
|
||||
onUpdateLocation(location.id, { bomResult: updatedBomResult });
|
||||
|
||||
console.log(`[품목 추가] ${item.code} - ${item.name} → ${processGroupLabel} (${processGroupKey})`);
|
||||
console.log(`[품목 추가] ${item.code} - ${item.name} → ${categoryLabel} (${categoryCode})`);
|
||||
}}
|
||||
tabLabel={DETAIL_TABS.find((t) => t.value === activeTab)?.label}
|
||||
tabLabel={detailTabs.find((t) => t.value === activeTab)?.label}
|
||||
/>
|
||||
|
||||
{/* 개소 정보 수정 모달 */}
|
||||
|
||||
@@ -1129,4 +1129,46 @@ export async function getSiteNames(): Promise<{
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 품목 카테고리 트리 조회 =====
|
||||
export interface ItemCategoryNode {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
is_active: number;
|
||||
sort_order: number;
|
||||
children: ItemCategoryNode[];
|
||||
}
|
||||
|
||||
export async function getItemCategoryTree(): Promise<{
|
||||
success: boolean;
|
||||
data: ItemCategoryNode[];
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const response = await serverFetch('/api/v1/categories/tree?code_group=item_category&only_active=true');
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
return { success: false, data: [], error: '인증이 필요합니다.', __authError: true };
|
||||
}
|
||||
return { success: false, data: [], error: '카테고리 조회 실패' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return {
|
||||
success: true,
|
||||
data: result.data || [],
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[QuoteActions] getItemCategoryTree error:', error);
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -406,6 +406,9 @@ export interface BomCalculationResultItem {
|
||||
unit_price: number;
|
||||
total_price: number;
|
||||
process_group?: string;
|
||||
process_group_key?: string; // Legacy 공정 그룹 키
|
||||
category_code?: string; // 아이템 카테고리 코드 (동적 카테고리 시스템)
|
||||
is_manual?: boolean; // 수동 추가 품목 여부
|
||||
}
|
||||
|
||||
// BOM 계산 결과 타입
|
||||
|
||||
Reference in New Issue
Block a user