2026-01-12 15:26:17 +09:00
|
|
|
|
/**
|
|
|
|
|
|
* 선택 개소 상세 정보 패널
|
|
|
|
|
|
*
|
|
|
|
|
|
* - 제품 정보 (제품명, 오픈사이즈, 제작사이즈, 산출중량, 산출면적, 수량)
|
|
|
|
|
|
* - 필수 설정 (가이드레일, 전원, 제어기)
|
|
|
|
|
|
* - 탭: 본체(스크린/슬랫), 절곡품-가이드레일, 절곡품-케이스, 절곡품-하단마감재, 모터&제어기, 부자재
|
|
|
|
|
|
* - 탭별 품목 테이블 (각 탭마다 다른 컬럼 구조)
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
"use client";
|
|
|
|
|
|
|
2026-01-27 15:17:39 +09:00
|
|
|
|
import { useState, useMemo, useEffect } from "react";
|
|
|
|
|
|
import { Package, Settings, Plus, Trash2, Loader2 } from "lucide-react";
|
|
|
|
|
|
import { getItemCategoryTree, type ItemCategoryNode } from "./actions";
|
2026-01-12 15:26:17 +09:00
|
|
|
|
|
|
|
|
|
|
import { Badge } from "../ui/badge";
|
|
|
|
|
|
import { Button } from "../ui/button";
|
|
|
|
|
|
import { Input } from "../ui/input";
|
2026-01-21 20:56:17 +09:00
|
|
|
|
import { NumberInput } from "../ui/number-input";
|
|
|
|
|
|
import { QuantityInput } from "../ui/quantity-input";
|
2026-01-12 15:26:17 +09:00
|
|
|
|
import {
|
|
|
|
|
|
Select,
|
|
|
|
|
|
SelectContent,
|
|
|
|
|
|
SelectItem,
|
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
|
SelectValue,
|
|
|
|
|
|
} from "../ui/select";
|
|
|
|
|
|
import {
|
|
|
|
|
|
Table,
|
|
|
|
|
|
TableBody,
|
|
|
|
|
|
TableCell,
|
|
|
|
|
|
TableHead,
|
|
|
|
|
|
TableHeader,
|
|
|
|
|
|
TableRow,
|
|
|
|
|
|
} from "../ui/table";
|
|
|
|
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
|
|
|
|
|
import { ItemSearchModal } from "./ItemSearchModal";
|
2026-01-27 11:28:12 +09:00
|
|
|
|
import { LocationEditModal } from "./LocationEditModal";
|
2026-01-12 15:26:17 +09:00
|
|
|
|
|
2026-01-26 21:34:13 +09:00
|
|
|
|
import type { LocationItem } from "./QuoteRegistrationV2";
|
|
|
|
|
|
import type { FinishedGoods } from "./actions";
|
|
|
|
|
|
import type { BomCalculationResultItem } from "./types";
|
|
|
|
|
|
|
2026-01-12 15:26:17 +09:00
|
|
|
|
// 납품길이 옵션
|
|
|
|
|
|
const DELIVERY_LENGTH_OPTIONS = [
|
|
|
|
|
|
{ value: "3000", label: "3000" },
|
|
|
|
|
|
{ value: "4000", label: "4000" },
|
|
|
|
|
|
{ value: "5000", label: "5000" },
|
|
|
|
|
|
{ value: "6000", label: "6000" },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-01-27 15:17:39 +09:00
|
|
|
|
// 빈 BOM 아이템 생성 함수 (동적 탭에 맞게)
|
|
|
|
|
|
function createEmptyBomItems(tabs: TabDefinition[]): Record<string, BomCalculationResultItem[]> {
|
|
|
|
|
|
const result: Record<string, BomCalculationResultItem[]> = {};
|
|
|
|
|
|
tabs.forEach((tab) => {
|
|
|
|
|
|
result[tab.value] = [];
|
|
|
|
|
|
});
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
2026-01-12 15:26:17 +09:00
|
|
|
|
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
// 상수
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
// 가이드레일 설치 유형
|
|
|
|
|
|
const GUIDE_RAIL_TYPES = [
|
|
|
|
|
|
{ value: "wall", label: "벽면형" },
|
|
|
|
|
|
{ value: "floor", label: "측면형" },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
// 모터 전원
|
|
|
|
|
|
const MOTOR_POWERS = [
|
|
|
|
|
|
{ value: "single", label: "단상(220V)" },
|
|
|
|
|
|
{ value: "three", label: "삼상(380V)" },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
// 연동제어기
|
|
|
|
|
|
const CONTROLLERS = [
|
|
|
|
|
|
{ value: "basic", label: "단독" },
|
|
|
|
|
|
{ value: "smart", label: "연동" },
|
|
|
|
|
|
{ value: "premium", label: "매립형-뒷박스포함" },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-01-27 15:17:39 +09:00
|
|
|
|
// 탭 인터페이스 정의
|
|
|
|
|
|
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 },
|
2026-01-12 15:26:17 +09:00
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
// Props
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
interface LocationDetailPanelProps {
|
|
|
|
|
|
location: LocationItem | null;
|
|
|
|
|
|
onUpdateLocation: (locationId: string, updates: Partial<LocationItem>) => void;
|
|
|
|
|
|
finishedGoods: FinishedGoods[];
|
|
|
|
|
|
disabled?: boolean;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
// 컴포넌트
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
export function LocationDetailPanel({
|
|
|
|
|
|
location,
|
|
|
|
|
|
onUpdateLocation,
|
|
|
|
|
|
finishedGoods,
|
|
|
|
|
|
disabled = false,
|
|
|
|
|
|
}: LocationDetailPanelProps) {
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// 상태
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
2026-01-27 15:17:39 +09:00
|
|
|
|
const [activeTab, setActiveTab] = useState("BODY");
|
2026-01-12 15:26:17 +09:00
|
|
|
|
const [itemSearchOpen, setItemSearchOpen] = useState(false);
|
2026-01-27 11:28:12 +09:00
|
|
|
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
2026-01-27 15:17:39 +09:00
|
|
|
|
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]);
|
2026-01-12 15:26:17 +09:00
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// 계산된 값
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
// 제품 정보
|
|
|
|
|
|
const product = useMemo(() => {
|
|
|
|
|
|
if (!location?.productCode) return null;
|
|
|
|
|
|
return finishedGoods.find((fg) => fg.item_code === location.productCode);
|
|
|
|
|
|
}, [location?.productCode, finishedGoods]);
|
|
|
|
|
|
|
2026-01-27 15:17:39 +09:00
|
|
|
|
// BOM 아이템을 탭별로 분류 (카테고리 코드 기반)
|
2026-01-12 15:26:17 +09:00
|
|
|
|
const bomItemsByTab = useMemo(() => {
|
2026-01-26 21:34:13 +09:00
|
|
|
|
// bomResult가 없으면 빈 배열 반환
|
2026-01-12 15:26:17 +09:00
|
|
|
|
if (!location?.bomResult?.items) {
|
2026-01-27 15:17:39 +09:00
|
|
|
|
return createEmptyBomItems(detailTabs);
|
2026-01-12 15:26:17 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const items = location.bomResult.items;
|
2026-01-27 15:17:39 +09:00
|
|
|
|
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;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-01-12 15:26:17 +09:00
|
|
|
|
|
|
|
|
|
|
items.forEach((item) => {
|
2026-01-27 15:17:39 +09:00
|
|
|
|
// 1. category_code로 분류 (우선)
|
|
|
|
|
|
const categoryCode = (item as { category_code?: string }).category_code?.toUpperCase() || "";
|
|
|
|
|
|
const mappedTab = categoryCodeToTab[categoryCode];
|
2026-01-12 15:26:17 +09:00
|
|
|
|
|
2026-01-27 15:17:39 +09:00
|
|
|
|
if (mappedTab && result[mappedTab]) {
|
|
|
|
|
|
result[mappedTab].push(item);
|
|
|
|
|
|
return;
|
2026-01-26 16:12:37 +09:00
|
|
|
|
}
|
2026-01-27 15:17:39 +09:00
|
|
|
|
|
|
|
|
|
|
// 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() || "";
|
|
|
|
|
|
|
|
|
|
|
|
if (processGroup.includes("본체") || processGroup.includes("스크린") || processGroup.includes("슬랫") || processGroup.includes("조립")) {
|
|
|
|
|
|
result["BODY"]?.push(item);
|
|
|
|
|
|
} else if (processGroup.includes("가이드") || processGroup.includes("레일")) {
|
|
|
|
|
|
result["BENDING_GUIDE"]?.push(item);
|
2026-01-26 16:12:37 +09:00
|
|
|
|
} else if (processGroup.includes("케이스") || processGroup.includes("철재")) {
|
2026-01-27 15:17:39 +09:00
|
|
|
|
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);
|
2026-01-12 15:26:17 +09:00
|
|
|
|
} else {
|
|
|
|
|
|
// 기타 항목은 본체에 포함
|
2026-01-27 15:17:39 +09:00
|
|
|
|
result["BODY"]?.push(item);
|
2026-01-12 15:26:17 +09:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return result;
|
2026-01-27 15:17:39 +09:00
|
|
|
|
}, [location?.bomResult?.items, detailTabs, itemCategories]);
|
2026-01-12 15:26:17 +09:00
|
|
|
|
|
|
|
|
|
|
// 탭별 소계
|
|
|
|
|
|
const tabSubtotals = useMemo(() => {
|
|
|
|
|
|
const result: Record<string, number> = {};
|
|
|
|
|
|
Object.entries(bomItemsByTab).forEach(([tab, items]) => {
|
2026-01-26 16:12:37 +09:00
|
|
|
|
result[tab] = items.reduce((sum: number, item: { total_price?: number }) => sum + (item.total_price || 0), 0);
|
2026-01-12 15:26:17 +09:00
|
|
|
|
});
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}, [bomItemsByTab]);
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// 핸들러
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
const handleFieldChange = (field: keyof LocationItem, value: string | number) => {
|
|
|
|
|
|
if (!location || disabled) return;
|
|
|
|
|
|
onUpdateLocation(location.id, { [field]: value });
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// 렌더링: 빈 상태
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
if (!location) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex flex-col items-center justify-center h-full bg-gray-50 text-gray-500">
|
|
|
|
|
|
<Package className="h-12 w-12 mb-4 text-gray-300" />
|
|
|
|
|
|
<p className="text-lg font-medium">개소를 선택해주세요</p>
|
|
|
|
|
|
<p className="text-sm">왼쪽 목록에서 개소를 선택하면 상세 정보가 표시됩니다</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// 렌더링: 상세 정보
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex flex-col h-full overflow-hidden">
|
|
|
|
|
|
{/* 헤더 */}
|
|
|
|
|
|
<div className="bg-white px-4 py-3 border-b">
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<h3 className="text-lg font-bold">
|
|
|
|
|
|
{location.floor} / {location.code}
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<span className="text-sm text-gray-500">제품명:</span>
|
|
|
|
|
|
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">
|
|
|
|
|
|
{location.productCode}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
{location.bomResult && (
|
|
|
|
|
|
<Badge variant="default" className="bg-green-600">
|
|
|
|
|
|
산출완료
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 제품 정보 */}
|
|
|
|
|
|
<div className="bg-gray-50 px-4 py-3 border-b space-y-3">
|
|
|
|
|
|
{/* 오픈사이즈 */}
|
|
|
|
|
|
<div className="flex items-center gap-4">
|
|
|
|
|
|
<span className="text-sm text-gray-600 w-20">오픈사이즈</span>
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
2026-01-21 20:56:17 +09:00
|
|
|
|
<NumberInput
|
2026-01-12 15:26:17 +09:00
|
|
|
|
value={location.openWidth}
|
2026-01-21 20:56:17 +09:00
|
|
|
|
onChange={(value) => handleFieldChange("openWidth", value ?? 0)}
|
2026-01-12 15:26:17 +09:00
|
|
|
|
disabled={disabled}
|
|
|
|
|
|
className="w-24 h-8 text-center font-bold"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className="text-gray-400">×</span>
|
2026-01-21 20:56:17 +09:00
|
|
|
|
<NumberInput
|
2026-01-12 15:26:17 +09:00
|
|
|
|
value={location.openHeight}
|
2026-01-21 20:56:17 +09:00
|
|
|
|
onChange={(value) => handleFieldChange("openHeight", value ?? 0)}
|
2026-01-12 15:26:17 +09:00
|
|
|
|
disabled={disabled}
|
|
|
|
|
|
className="w-24 h-8 text-center font-bold"
|
|
|
|
|
|
/>
|
|
|
|
|
|
{!disabled && (
|
2026-01-27 11:28:12 +09:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="secondary"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
className="text-xs h-7"
|
|
|
|
|
|
onClick={() => setEditModalOpen(true)}
|
|
|
|
|
|
>
|
|
|
|
|
|
수정
|
|
|
|
|
|
</Button>
|
2026-01-12 15:26:17 +09:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 제작사이즈, 산출중량, 산출면적, 수량 */}
|
|
|
|
|
|
<div className="grid grid-cols-4 gap-4 text-sm">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<span className="text-gray-500">제작사이즈</span>
|
|
|
|
|
|
<p className="font-semibold">
|
|
|
|
|
|
{location.manufactureWidth || location.openWidth + 280} × {location.manufactureHeight || location.openHeight + 280}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<span className="text-gray-500">산출중량</span>
|
|
|
|
|
|
<p className="font-semibold">{location.weight?.toFixed(1) || "-"} <span className="text-xs text-gray-400">kg</span></p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<span className="text-gray-500">산출면적</span>
|
|
|
|
|
|
<p className="font-semibold">{location.area?.toFixed(1) || "-"} <span className="text-xs text-gray-400">m²</span></p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<span className="text-gray-500">수량</span>
|
2026-01-21 20:56:17 +09:00
|
|
|
|
<QuantityInput
|
2026-01-12 15:26:17 +09:00
|
|
|
|
value={location.quantity}
|
2026-01-21 20:56:17 +09:00
|
|
|
|
onChange={(value) => handleFieldChange("quantity", value ?? 1)}
|
2026-01-12 15:26:17 +09:00
|
|
|
|
disabled={disabled}
|
|
|
|
|
|
className="w-24 h-7 text-center font-semibold"
|
2026-01-21 20:56:17 +09:00
|
|
|
|
min={1}
|
2026-01-12 15:26:17 +09:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 필수 설정 (읽기 전용) */}
|
|
|
|
|
|
<div className="bg-white px-4 py-3 border-b">
|
|
|
|
|
|
<h4 className="text-sm font-semibold text-gray-700 mb-2 flex items-center gap-1">
|
|
|
|
|
|
<Settings className="h-4 w-4" />
|
|
|
|
|
|
필수 설정
|
|
|
|
|
|
</h4>
|
|
|
|
|
|
<div className="grid grid-cols-3 gap-3">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="text-xs text-gray-500 flex items-center gap-1 mb-1">
|
|
|
|
|
|
🔧 가이드레일
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<Badge variant="outline" className="bg-gray-50 text-gray-700 border-gray-300 px-3 py-1.5">
|
|
|
|
|
|
{GUIDE_RAIL_TYPES.find(t => t.value === location.guideRailType)?.label || location.guideRailType}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="text-xs text-gray-500 flex items-center gap-1 mb-1">
|
|
|
|
|
|
⚡ 전원
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<Badge variant="outline" className="bg-gray-50 text-gray-700 border-gray-300 px-3 py-1.5">
|
|
|
|
|
|
{MOTOR_POWERS.find(p => p.value === location.motorPower)?.label || location.motorPower}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="text-xs text-gray-500 flex items-center gap-1 mb-1">
|
|
|
|
|
|
📦 제어기
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<Badge variant="outline" className="bg-gray-50 text-gray-700 border-gray-300 px-3 py-1.5">
|
|
|
|
|
|
{CONTROLLERS.find(c => c.value === location.controller)?.label || location.controller}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 탭 및 품목 테이블 */}
|
|
|
|
|
|
<div className="flex-1 overflow-hidden flex flex-col">
|
|
|
|
|
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col h-full">
|
|
|
|
|
|
{/* 탭 목록 - 스크롤 가능 */}
|
|
|
|
|
|
<div className="border-b bg-white overflow-x-auto">
|
2026-01-27 15:17:39 +09:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-01-12 15:26:17 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-27 15:17:39 +09:00
|
|
|
|
{/* 동적 탭 콘텐츠 렌더링 */}
|
|
|
|
|
|
{detailTabs.map((tab) => {
|
|
|
|
|
|
const items = bomItemsByTab[tab.value] || [];
|
|
|
|
|
|
const isBendingTab = tab.parentCode === "BENDING";
|
|
|
|
|
|
const isMotorTab = tab.value === "MOTOR_CTRL";
|
|
|
|
|
|
const isAccessoryTab = tab.value === "ACCESSORY";
|
|
|
|
|
|
|
|
|
|
|
|
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>
|
2026-01-12 15:26:17 +09:00
|
|
|
|
</TableRow>
|
2026-01-27 15:17:39 +09:00
|
|
|
|
</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>
|
2026-01-12 15:26:17 +09:00
|
|
|
|
</div>
|
2026-01-27 15:17:39 +09:00
|
|
|
|
</TabsContent>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
2026-01-12 15:26:17 +09:00
|
|
|
|
</Tabs>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 금액 안내 */}
|
|
|
|
|
|
{!location.bomResult && (
|
|
|
|
|
|
<div className="bg-blue-50 px-4 py-2 border-t border-blue-200 text-center text-sm text-blue-700">
|
|
|
|
|
|
💡 금액은 아래 견적금액요약에서 확인하세요
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 품목 검색 모달 */}
|
|
|
|
|
|
<ItemSearchModal
|
|
|
|
|
|
open={itemSearchOpen}
|
|
|
|
|
|
onOpenChange={setItemSearchOpen}
|
|
|
|
|
|
onSelectItem={(item) => {
|
2026-01-27 14:28:17 +09:00
|
|
|
|
if (!location) return;
|
|
|
|
|
|
|
2026-01-27 15:17:39 +09:00
|
|
|
|
// 현재 탭 정보 가져오기
|
|
|
|
|
|
const currentTab = detailTabs.find((t) => t.value === activeTab);
|
|
|
|
|
|
const categoryCode = activeTab; // 카테고리 코드를 직접 사용
|
|
|
|
|
|
const categoryLabel = currentTab?.label || activeTab;
|
2026-01-27 14:28:17 +09:00
|
|
|
|
|
2026-01-27 15:17:39 +09:00
|
|
|
|
// 새 품목 생성 (카테고리 코드 포함)
|
|
|
|
|
|
const newItem: BomCalculationResultItem & { category_code?: string; is_manual?: boolean } = {
|
2026-01-27 14:28:17 +09:00
|
|
|
|
item_code: item.code,
|
|
|
|
|
|
item_name: item.name,
|
|
|
|
|
|
specification: item.specification || "",
|
|
|
|
|
|
unit: "EA",
|
|
|
|
|
|
quantity: 1,
|
|
|
|
|
|
unit_price: 0,
|
|
|
|
|
|
total_price: 0,
|
2026-01-27 15:17:39 +09:00
|
|
|
|
process_group: categoryLabel,
|
|
|
|
|
|
category_code: categoryCode, // 새 카테고리 코드 사용
|
2026-01-27 14:28:17 +09:00
|
|
|
|
is_manual: true, // 수동 추가 품목 표시
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 기존 bomResult 가져오기
|
|
|
|
|
|
const existingBomResult = location.bomResult || {
|
|
|
|
|
|
finished_goods: { code: location.productCode || "", name: location.productName || "" },
|
|
|
|
|
|
subtotals: {},
|
|
|
|
|
|
grouped_items: {},
|
|
|
|
|
|
grand_total: 0,
|
|
|
|
|
|
items: [],
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 기존 items에 새 아이템 추가
|
|
|
|
|
|
const existingItems = existingBomResult.items || [];
|
|
|
|
|
|
const updatedItems = [...existingItems, newItem];
|
|
|
|
|
|
|
|
|
|
|
|
// subtotals 업데이트 (해당 카테고리의 count, subtotal 증가)
|
|
|
|
|
|
const existingSubtotals = existingBomResult.subtotals || {};
|
2026-01-27 15:17:39 +09:00
|
|
|
|
const rawCategorySubtotal = existingSubtotals[categoryCode];
|
|
|
|
|
|
const categorySubtotal = (typeof rawCategorySubtotal === 'object' && rawCategorySubtotal !== null)
|
|
|
|
|
|
? rawCategorySubtotal
|
|
|
|
|
|
: { name: categoryLabel, count: 0, subtotal: 0 };
|
2026-01-27 14:28:17 +09:00
|
|
|
|
const updatedSubtotals = {
|
|
|
|
|
|
...existingSubtotals,
|
2026-01-27 15:17:39 +09:00
|
|
|
|
[categoryCode]: {
|
|
|
|
|
|
name: categoryLabel,
|
2026-01-27 14:28:17 +09:00
|
|
|
|
count: (categorySubtotal.count || 0) + 1,
|
|
|
|
|
|
subtotal: (categorySubtotal.subtotal || 0) + (newItem.total_price || 0),
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// grouped_items 업데이트 (해당 카테고리의 items 배열에 추가)
|
|
|
|
|
|
const existingGroupedItems = existingBomResult.grouped_items || {};
|
2026-01-27 15:17:39 +09:00
|
|
|
|
const categoryGroupedItems = existingGroupedItems[categoryCode] || { items: [] };
|
2026-01-27 14:28:17 +09:00
|
|
|
|
const updatedGroupedItems = {
|
|
|
|
|
|
...existingGroupedItems,
|
2026-01-27 15:17:39 +09:00
|
|
|
|
[categoryCode]: {
|
2026-01-27 14:28:17 +09:00
|
|
|
|
...categoryGroupedItems,
|
|
|
|
|
|
items: [...(categoryGroupedItems.items || []), newItem],
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// grand_total 업데이트
|
|
|
|
|
|
const updatedGrandTotal = (existingBomResult.grand_total || 0) + (newItem.total_price || 0);
|
|
|
|
|
|
|
|
|
|
|
|
const updatedBomResult = {
|
|
|
|
|
|
...existingBomResult,
|
|
|
|
|
|
items: updatedItems,
|
|
|
|
|
|
subtotals: updatedSubtotals,
|
|
|
|
|
|
grouped_items: updatedGroupedItems,
|
|
|
|
|
|
grand_total: updatedGrandTotal,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// location 업데이트
|
|
|
|
|
|
onUpdateLocation(location.id, { bomResult: updatedBomResult });
|
|
|
|
|
|
|
2026-01-27 15:17:39 +09:00
|
|
|
|
console.log(`[품목 추가] ${item.code} - ${item.name} → ${categoryLabel} (${categoryCode})`);
|
2026-01-12 15:26:17 +09:00
|
|
|
|
}}
|
2026-01-27 15:17:39 +09:00
|
|
|
|
tabLabel={detailTabs.find((t) => t.value === activeTab)?.label}
|
2026-01-12 15:26:17 +09:00
|
|
|
|
/>
|
2026-01-27 11:28:12 +09:00
|
|
|
|
|
|
|
|
|
|
{/* 개소 정보 수정 모달 */}
|
|
|
|
|
|
<LocationEditModal
|
|
|
|
|
|
open={editModalOpen}
|
|
|
|
|
|
onOpenChange={setEditModalOpen}
|
|
|
|
|
|
location={location}
|
|
|
|
|
|
onSave={(locationId, updates) => {
|
|
|
|
|
|
onUpdateLocation(locationId, updates);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
2026-01-12 15:26:17 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|