- MobileCard 접기/펼치기(collapsible) 기능 추가 및 반응형 레이아웃 개선 - DatePicker 공휴일/세무일정 색상 코딩 통합, DateTimePicker 신규 추가 - useCalendarScheduleInit 훅으로 전역 공휴일/일정 데이터 캐싱 - 전 도메인 날짜 필드 DatePicker 표준화 - 생산대시보드/작업지시/견적서/주문관리 모바일 호환성 강화 - 회계 모듈 기능 개선 (매입상세 결재연동, 미수금현황 조회조건 등) - 달력 일정 관리 API 연동 및 대량 등록 다이얼로그 개선 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
829 lines
35 KiB
TypeScript
829 lines
35 KiB
TypeScript
/**
|
|
* 선택 개소 상세 정보 패널
|
|
*
|
|
* - 제품 정보 (제품명, 오픈사이즈, 제작사이즈, 산출중량, 산출면적, 수량)
|
|
* - 필수 설정 (가이드레일, 전원, 제어기)
|
|
* - 탭: BOM subtotals 기반 동적 생성 (주자재, 모터, 제어기, 절곡품, 부자재, 검사비, 기타)
|
|
* - 탭별 품목 테이블 (grouped_items 기반)
|
|
*/
|
|
|
|
"use client";
|
|
|
|
import { useState, useMemo, useEffect } from "react";
|
|
import { Package, Plus, Trash2, Loader2, Calculator, Save } from "lucide-react";
|
|
import { fetchItemPrices } from "@/lib/api/items";
|
|
|
|
import { Button } from "../ui/button";
|
|
import { Input } from "../ui/input";
|
|
import { NumberInput } from "../ui/number-input";
|
|
import { QuantityInput } from "../ui/quantity-input";
|
|
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";
|
|
|
|
import type { LocationItem } from "./QuoteRegistration";
|
|
import type { FinishedGoods } from "./actions";
|
|
import type { BomCalculationResultItem } from "./types";
|
|
|
|
// 납품길이 옵션
|
|
const DELIVERY_LENGTH_OPTIONS = [
|
|
{ value: "3000", label: "3000" },
|
|
{ value: "4000", label: "4000" },
|
|
{ value: "5000", label: "5000" },
|
|
{ value: "6000", label: "6000" },
|
|
];
|
|
|
|
// 빈 BOM 아이템 생성 함수 (동적 탭에 맞게)
|
|
function createEmptyBomItems(tabs: TabDefinition[]): Record<string, BomCalculationResultItem[]> {
|
|
const result: Record<string, BomCalculationResultItem[]> = {};
|
|
tabs.forEach((tab) => {
|
|
result[tab.value] = [];
|
|
});
|
|
return result;
|
|
}
|
|
|
|
// =============================================================================
|
|
// 상수
|
|
// =============================================================================
|
|
|
|
// 층 옵션 (지하3층 ~ 지상10층)
|
|
const FLOOR_OPTIONS = [
|
|
"B3", "B2", "B1",
|
|
"1F", "2F", "3F", "4F", "5F", "6F", "7F", "8F", "9F", "10F",
|
|
"R",
|
|
];
|
|
|
|
// 가이드레일 설치 유형
|
|
const GUIDE_RAIL_TYPES = [
|
|
{ value: "wall", label: "벽면형" },
|
|
{ value: "floor", label: "측면형" },
|
|
{ value: "mixed", label: "혼합형" },
|
|
];
|
|
|
|
// 모터 전원
|
|
const MOTOR_POWERS = [
|
|
{ value: "single", label: "단상(220V)" },
|
|
{ value: "three", label: "삼상(380V)" },
|
|
];
|
|
|
|
// 연동제어기
|
|
const CONTROLLERS = [
|
|
{ value: "basic", label: "단독" },
|
|
{ value: "smart", label: "연동" },
|
|
{ value: "premium", label: "매립형-뒷박스포함" },
|
|
];
|
|
|
|
// 탭 인터페이스 정의
|
|
interface TabDefinition {
|
|
value: string; // subtotals 키 (예: material, motor, steel)
|
|
label: string; // 표시 이름
|
|
}
|
|
|
|
// 기본 탭 정의 (BOM 계산 전 fallback)
|
|
const DEFAULT_TABS: TabDefinition[] = [
|
|
{ value: "material", label: "주자재" },
|
|
{ value: "motor", label: "모터" },
|
|
{ value: "controller", label: "제어기" },
|
|
{ value: "steel", label: "절곡품" },
|
|
{ value: "parts", label: "부자재" },
|
|
{ value: "inspection", label: "검사비" },
|
|
{ value: "etc", label: "기타" },
|
|
];
|
|
|
|
// =============================================================================
|
|
// Props
|
|
// =============================================================================
|
|
|
|
interface LocationDetailPanelProps {
|
|
location: LocationItem | null;
|
|
onUpdateLocation: (locationId: string, updates: Partial<LocationItem>) => void;
|
|
onDeleteLocation?: (locationId: string) => void;
|
|
onCalculateLocation?: (locationId: string) => Promise<void>;
|
|
onSaveItems?: () => void;
|
|
finishedGoods: FinishedGoods[];
|
|
locationCodes?: string[];
|
|
disabled?: boolean;
|
|
isCalculating?: boolean;
|
|
}
|
|
|
|
// =============================================================================
|
|
// 컴포넌트
|
|
// =============================================================================
|
|
|
|
export function LocationDetailPanel({
|
|
location,
|
|
onUpdateLocation,
|
|
onDeleteLocation,
|
|
onCalculateLocation,
|
|
onSaveItems,
|
|
finishedGoods,
|
|
locationCodes = [],
|
|
disabled = false,
|
|
isCalculating = false,
|
|
}: LocationDetailPanelProps) {
|
|
// ---------------------------------------------------------------------------
|
|
// 상태
|
|
// ---------------------------------------------------------------------------
|
|
const [activeTab, setActiveTab] = useState("material");
|
|
const [itemSearchOpen, setItemSearchOpen] = useState(false);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 탭 정의 (bomResult.subtotals 기반 동적 생성 + 기타 탭)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const detailTabs = useMemo((): TabDefinition[] => {
|
|
if (!location?.bomResult?.subtotals) {
|
|
return DEFAULT_TABS;
|
|
}
|
|
|
|
const subtotals = location.bomResult.subtotals;
|
|
const tabs: TabDefinition[] = [];
|
|
|
|
Object.entries(subtotals).forEach(([key, value]) => {
|
|
if (typeof value === "object" && value !== null) {
|
|
const obj = value as { name?: string };
|
|
tabs.push({
|
|
value: key,
|
|
label: obj.name || key,
|
|
});
|
|
}
|
|
});
|
|
|
|
// 기타 탭 추가
|
|
tabs.push({ value: "etc", label: "기타" });
|
|
|
|
return tabs;
|
|
}, [location?.bomResult?.subtotals]);
|
|
|
|
// location 변경 시 activeTab이 유효한 탭인지 확인하고 리셋
|
|
useEffect(() => {
|
|
if (detailTabs.length > 0 && !detailTabs.some((t) => t.value === activeTab)) {
|
|
setActiveTab(detailTabs[0].value);
|
|
}
|
|
}, [location?.id, detailTabs]);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 계산된 값
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// 제품 정보
|
|
const product = useMemo(() => {
|
|
if (!location?.productCode) return null;
|
|
return finishedGoods.find((fg) => fg.item_code === location.productCode);
|
|
}, [location?.productCode, finishedGoods]);
|
|
|
|
// BOM 아이템을 탭별로 분류 (grouped_items 기반)
|
|
const bomItemsByTab = useMemo(() => {
|
|
if (!location?.bomResult?.grouped_items) {
|
|
return createEmptyBomItems(detailTabs);
|
|
}
|
|
|
|
const groupedItems = location.bomResult.grouped_items;
|
|
const result: Record<string, BomCalculationResultItem[]> = {};
|
|
|
|
// 탭별 빈 배열 초기화
|
|
detailTabs.forEach((tab) => {
|
|
result[tab.value] = [];
|
|
});
|
|
|
|
// grouped_items에서 각 카테고리의 items를 탭에 매핑
|
|
Object.entries(groupedItems).forEach(([key, group]) => {
|
|
const items = (group as { items?: unknown[] })?.items || [];
|
|
if (result[key]) {
|
|
result[key] = items as BomCalculationResultItem[];
|
|
} else {
|
|
// subtotals에 없는 카테고리 → 기타에 추가
|
|
result["etc"] = [
|
|
...(result["etc"] || []),
|
|
...(items as BomCalculationResultItem[]),
|
|
];
|
|
}
|
|
});
|
|
|
|
// 수동 추가 품목 (is_manual) 중 grouped_items에 없는 것 → 해당 탭 or 기타
|
|
const allItems = location.bomResult.items || [];
|
|
const manualItems = allItems.filter(
|
|
(item: BomCalculationResultItem & { is_manual?: boolean }) => item.is_manual === true
|
|
);
|
|
manualItems.forEach((item: BomCalculationResultItem & { category_code?: string }) => {
|
|
const tabKey = item.category_code || "etc";
|
|
if (result[tabKey]) {
|
|
// 이미 grouped_items에서 추가되었는지 확인
|
|
const exists = result[tabKey].some(
|
|
(existing) => existing.item_code === item.item_code && existing.item_name === item.item_name
|
|
);
|
|
if (!exists) {
|
|
result[tabKey].push(item);
|
|
}
|
|
} else {
|
|
result["etc"] = [...(result["etc"] || []), item];
|
|
}
|
|
});
|
|
|
|
return result;
|
|
}, [location?.bomResult?.grouped_items, location?.bomResult?.items, detailTabs]);
|
|
|
|
// 탭별 소계
|
|
const tabSubtotals = useMemo(() => {
|
|
const result: Record<string, number> = {};
|
|
Object.entries(bomItemsByTab).forEach(([tab, items]) => {
|
|
result[tab] = items.reduce((sum: number, item: { total_price?: number }) => sum + (item.total_price || 0), 0);
|
|
});
|
|
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 px-6 text-center">
|
|
<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">
|
|
{/* ②-1 개소 정보 영역 */}
|
|
<div className="bg-gray-50 border-b">
|
|
{/* 1행: 층, 부호, 가로, 세로, 제품코드 */}
|
|
<div className="px-4 py-3 space-y-3">
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
|
<div>
|
|
<label className="text-xs text-gray-600">층</label>
|
|
<Input
|
|
list="floorOptionListDetail"
|
|
value={location.floor}
|
|
onChange={(e) => handleFieldChange("floor", e.target.value)}
|
|
disabled={disabled}
|
|
className="h-8 text-sm"
|
|
/>
|
|
<datalist id="floorOptionListDetail">
|
|
{FLOOR_OPTIONS.map((f) => (
|
|
<option key={f} value={f} />
|
|
))}
|
|
</datalist>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-gray-600">부호</label>
|
|
<Input
|
|
list="locationCodeListDetail"
|
|
value={location.code}
|
|
onChange={(e) => handleFieldChange("code", e.target.value)}
|
|
disabled={disabled}
|
|
className="h-8 text-sm"
|
|
/>
|
|
<datalist id="locationCodeListDetail">
|
|
{locationCodes.map((code) => (
|
|
<option key={code} value={code} />
|
|
))}
|
|
</datalist>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-gray-600">가로</label>
|
|
<NumberInput
|
|
value={location.openWidth}
|
|
onChange={(value) => handleFieldChange("openWidth", value ?? 0)}
|
|
disabled={disabled}
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-gray-600">세로</label>
|
|
<NumberInput
|
|
value={location.openHeight}
|
|
onChange={(value) => handleFieldChange("openHeight", value ?? 0)}
|
|
disabled={disabled}
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-gray-600">제품코드</label>
|
|
<Select
|
|
value={location.productCode}
|
|
onValueChange={(value) => {
|
|
const product = finishedGoods.find((fg) => fg.item_code === value);
|
|
onUpdateLocation(location.id, {
|
|
productCode: value,
|
|
productName: product?.item_name || value,
|
|
itemCategory: product?.item_category,
|
|
});
|
|
}}
|
|
disabled={disabled}
|
|
>
|
|
<SelectTrigger className="h-8 text-sm">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{finishedGoods.map((fg) => (
|
|
<SelectItem key={fg.item_code} value={fg.item_code}>
|
|
{fg.item_code}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 2행: 가이드레일, 전원, 제어기 */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
|
<div>
|
|
<label className="text-xs text-gray-600 flex items-center gap-1">
|
|
🔧 가이드레일
|
|
</label>
|
|
<Select
|
|
value={location.guideRailType}
|
|
onValueChange={(value) => handleFieldChange("guideRailType", value)}
|
|
disabled={disabled}
|
|
>
|
|
<SelectTrigger className="h-8 text-sm">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{GUIDE_RAIL_TYPES.map((type) => (
|
|
<SelectItem key={type.value} value={type.value}>
|
|
{type.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-gray-600 flex items-center gap-1">
|
|
⚡ 전원
|
|
</label>
|
|
<Select
|
|
value={location.motorPower}
|
|
onValueChange={(value) => handleFieldChange("motorPower", value)}
|
|
disabled={disabled}
|
|
>
|
|
<SelectTrigger className="h-8 text-sm">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{MOTOR_POWERS.map((power) => (
|
|
<SelectItem key={power.value} value={power.value}>
|
|
{power.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-gray-600 flex items-center gap-1">
|
|
📦 제어기
|
|
</label>
|
|
<Select
|
|
value={location.controller}
|
|
onValueChange={(value) => handleFieldChange("controller", value)}
|
|
disabled={disabled}
|
|
>
|
|
<SelectTrigger className="h-8 text-sm">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{CONTROLLERS.map((ctrl) => (
|
|
<SelectItem key={ctrl.value} value={ctrl.value}>
|
|
{ctrl.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 3행: 제작사이즈, 산출중량, 산출면적, 수량, 산출하기 */}
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 text-sm pt-2 border-t border-gray-200">
|
|
<div>
|
|
<span className="text-xs text-gray-500">제작사이즈</span>
|
|
<p className="font-semibold">
|
|
{String(location.bomResult?.variables?.W1 ?? location.manufactureWidth ?? "-")}
|
|
X
|
|
{String(location.bomResult?.variables?.H1 ?? location.manufactureHeight ?? "-")}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-xs text-gray-500">산출중량</span>
|
|
<p className="font-semibold">
|
|
{(location.bomResult?.variables?.K ?? location.bomResult?.variables?.WEIGHT)
|
|
? `${Number(location.bomResult?.variables?.K ?? location.bomResult?.variables?.WEIGHT).toFixed(2)} kg`
|
|
: "-"}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-xs text-gray-500">산출면적</span>
|
|
<p className="font-semibold">
|
|
{(location.bomResult?.variables?.M ?? location.bomResult?.variables?.AREA)
|
|
? `${Number(location.bomResult?.variables?.M ?? location.bomResult?.variables?.AREA).toFixed(2)} m²`
|
|
: "-"}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-xs text-gray-500">수량 (QTY)</span>
|
|
<QuantityInput
|
|
value={location.quantity}
|
|
onChange={(newQty) => {
|
|
if (!location || disabled || newQty === undefined) return;
|
|
// 수량 변경 시 totalPrice 재계산
|
|
const unitPrice = location.unitPrice || 0;
|
|
onUpdateLocation(location.id, {
|
|
quantity: newQty,
|
|
totalPrice: unitPrice * newQty,
|
|
});
|
|
}}
|
|
className="h-8 text-sm font-semibold"
|
|
min={1}
|
|
disabled={disabled}
|
|
/>
|
|
</div>
|
|
<div className="flex items-end">
|
|
<Button
|
|
onClick={() => onCalculateLocation?.(location.id)}
|
|
disabled={disabled || isCalculating}
|
|
className="w-full h-8 bg-orange-500 hover:bg-orange-600 text-white"
|
|
>
|
|
{isCalculating ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
|
산출중...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Calculator className="h-4 w-4 mr-1" />
|
|
산출하기
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ②-2 품목 상세 영역 */}
|
|
<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">
|
|
<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-orange-500 data-[state=active]:bg-orange-50 data-[state=active]:text-orange-700 px-4 py-2 text-sm whitespace-nowrap"
|
|
>
|
|
{tab.label}
|
|
</TabsTrigger>
|
|
))}
|
|
</TabsList>
|
|
</div>
|
|
|
|
{/* 탭 콘텐츠 */}
|
|
{detailTabs.map((tab) => {
|
|
const items = bomItemsByTab[tab.value] || [];
|
|
const isBendingTab = tab.value === "steel";
|
|
|
|
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-3">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="bg-amber-100/50">
|
|
<TableHead className="font-semibold">품목명</TableHead>
|
|
<TableHead className="text-center font-semibold">규격</TableHead>
|
|
{isBendingTab && (
|
|
<TableHead className="text-center font-semibold w-24">납품길이</TableHead>
|
|
)}
|
|
<TableHead className="text-center font-semibold w-20">수량</TableHead>
|
|
<TableHead className="text-center font-semibold w-12"></TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{items.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={isBendingTab ? 5 : 4} className="text-center text-gray-400 py-6">
|
|
산출된 품목이 없습니다
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
items.map((item: any, index: number) => (
|
|
<TableRow key={item.id || `${tab.value}-${index}`} className="bg-white">
|
|
<TableCell className="font-medium">{item.item_name}</TableCell>
|
|
<TableCell className="text-center text-gray-600">{item.spec || item.specification || "-"}</TableCell>
|
|
{isBendingTab && (
|
|
<TableCell className="text-center">
|
|
<Select defaultValue={item.delivery_length || "4000"} disabled={disabled}>
|
|
<SelectTrigger className="w-20 h-7 text-xs">
|
|
<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={(newQty) => {
|
|
if (!location || disabled || newQty === undefined) return;
|
|
const existingBomResult = location.bomResult;
|
|
if (!existingBomResult) return;
|
|
|
|
const targetItem = item;
|
|
const targetCode = targetItem.item_code;
|
|
const targetName = targetItem.item_name;
|
|
|
|
// grouped_items 내 해당 탭에서 index로 매칭하여 items에서 찾기
|
|
const groupItems = existingBomResult.grouped_items?.[tab.value]?.items || [];
|
|
const matchedItem = groupItems[index];
|
|
if (!matchedItem) return;
|
|
|
|
// items 배열에서 같은 item_code + item_name 매칭 (동일 코드 복수 가능하므로 순서 기반)
|
|
let matchCount = 0;
|
|
const updatedItems = (existingBomResult.items || []).map((bomItem: any) => {
|
|
if (bomItem.item_code === targetCode && bomItem.item_name === targetName) {
|
|
matchCount++;
|
|
// grouped_items 내 순서와 items 내 순서가 같은 아이템 찾기
|
|
if (bomItem.quantity === targetItem.quantity && bomItem.unit_price === targetItem.unit_price) {
|
|
const newTotalPrice = (bomItem.unit_price || 0) * newQty;
|
|
return { ...bomItem, quantity: newQty, total_price: newTotalPrice };
|
|
}
|
|
}
|
|
return bomItem;
|
|
});
|
|
|
|
// grouped_items도 업데이트
|
|
const updatedGroupedItems: Record<string, any> = { ...existingBomResult.grouped_items };
|
|
if (updatedGroupedItems[tab.value]) {
|
|
const updatedTabItems = [...(updatedGroupedItems[tab.value].items || [])];
|
|
if (updatedTabItems[index]) {
|
|
const newTotalPrice = (updatedTabItems[index].unit_price || 0) * newQty;
|
|
updatedTabItems[index] = { ...updatedTabItems[index], quantity: newQty, total_price: newTotalPrice };
|
|
}
|
|
updatedGroupedItems[tab.value] = { ...updatedGroupedItems[tab.value], items: updatedTabItems };
|
|
}
|
|
|
|
// grand_total 재계산
|
|
const newGrandTotal = updatedItems.reduce(
|
|
(sum: number, bi: any) => sum + (bi.total_price || 0), 0
|
|
);
|
|
|
|
// subtotals 재계산
|
|
const updatedSubtotals = { ...existingBomResult.subtotals };
|
|
Object.entries(updatedGroupedItems).forEach(([key, group]) => {
|
|
const groupItemsList = (group as any)?.items || [];
|
|
const subtotal = groupItemsList.reduce((s: number, gi: any) => s + (gi.total_price || 0), 0);
|
|
const existing = updatedSubtotals[key];
|
|
if (typeof existing === 'object' && existing !== null) {
|
|
updatedSubtotals[key] = { ...existing, count: groupItemsList.length, subtotal };
|
|
}
|
|
});
|
|
|
|
onUpdateLocation(location.id, {
|
|
unitPrice: newGrandTotal,
|
|
totalPrice: newGrandTotal * location.quantity,
|
|
bomResult: {
|
|
...existingBomResult,
|
|
items: updatedItems,
|
|
grouped_items: updatedGroupedItems,
|
|
subtotals: updatedSubtotals,
|
|
grand_total: newGrandTotal,
|
|
},
|
|
});
|
|
}}
|
|
className="w-14 h-7 text-center text-xs"
|
|
min={1}
|
|
disabled={disabled}
|
|
/>
|
|
</TableCell>
|
|
<TableCell className="text-center">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-gray-400 hover:text-red-500 hover:bg-red-50"
|
|
disabled={disabled}
|
|
onClick={() => {
|
|
if (!location) return;
|
|
const existingBomResult = location.bomResult;
|
|
if (!existingBomResult) return;
|
|
|
|
// 삭제 대상: 현재 탭의 grouped_items에서 index번째 아이템
|
|
const groupItems: any[] = existingBomResult.grouped_items?.[tab.value]?.items || [];
|
|
const targetItem = groupItems[index];
|
|
if (!targetItem) return;
|
|
|
|
// grouped_items에서 해당 아이템 제거
|
|
const updatedGroupedItems: Record<string, any> = { ...existingBomResult.grouped_items };
|
|
if (updatedGroupedItems[tab.value]) {
|
|
const updatedTabItems = [...(updatedGroupedItems[tab.value].items || [])];
|
|
updatedTabItems.splice(index, 1);
|
|
updatedGroupedItems[tab.value] = { ...updatedGroupedItems[tab.value], items: updatedTabItems };
|
|
}
|
|
|
|
// items 배열에서도 제거 (item_code + item_name + quantity + unit_price 매칭)
|
|
let removed = false;
|
|
const updatedItems = (existingBomResult.items || []).filter((bi: any) => {
|
|
if (!removed &&
|
|
bi.item_code === targetItem.item_code &&
|
|
bi.item_name === targetItem.item_name &&
|
|
bi.quantity === targetItem.quantity &&
|
|
bi.unit_price === targetItem.unit_price
|
|
) {
|
|
removed = true;
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
// grand_total 재계산
|
|
const newGrandTotal = updatedItems.reduce(
|
|
(sum: number, bi: any) => sum + (bi.total_price || 0), 0
|
|
);
|
|
|
|
// subtotals 재계산
|
|
const updatedSubtotals = { ...existingBomResult.subtotals };
|
|
Object.entries(updatedGroupedItems).forEach(([key, group]) => {
|
|
const groupItemsList = (group as any)?.items || [];
|
|
const subtotal = groupItemsList.reduce((s: number, gi: any) => s + (gi.total_price || 0), 0);
|
|
const existing = updatedSubtotals[key];
|
|
if (typeof existing === 'object' && existing !== null) {
|
|
updatedSubtotals[key] = { ...existing, count: groupItemsList.length, subtotal };
|
|
}
|
|
});
|
|
|
|
onUpdateLocation(location.id, {
|
|
unitPrice: newGrandTotal,
|
|
totalPrice: newGrandTotal * location.quantity,
|
|
bomResult: {
|
|
...existingBomResult,
|
|
items: updatedItems,
|
|
grouped_items: updatedGroupedItems,
|
|
subtotals: updatedSubtotals,
|
|
grand_total: newGrandTotal,
|
|
},
|
|
});
|
|
}}
|
|
>
|
|
<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>
|
|
<Button
|
|
size="sm"
|
|
className="bg-blue-600 hover:bg-blue-700 text-white"
|
|
onClick={onSaveItems}
|
|
disabled={disabled}
|
|
>
|
|
<Save className="h-4 w-4 mr-1" />
|
|
저장
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
);
|
|
})}
|
|
</Tabs>
|
|
</div>
|
|
|
|
{/* 품목 검색 모달 */}
|
|
<ItemSearchModal
|
|
open={itemSearchOpen}
|
|
onOpenChange={setItemSearchOpen}
|
|
onSelectItem={async (item) => {
|
|
if (!location) return;
|
|
|
|
const currentTab = detailTabs.find((t) => t.value === activeTab);
|
|
const categoryCode = activeTab;
|
|
const categoryLabel = currentTab?.label || activeTab;
|
|
|
|
// 단가 조회 (클라이언트 API 호출)
|
|
let unitPrice = 0;
|
|
try {
|
|
const priceResult = await fetchItemPrices([item.code]);
|
|
unitPrice = priceResult[item.code]?.unit_price || 0;
|
|
} catch (error) {
|
|
console.error('[품목 추가] 단가 조회 실패:', error);
|
|
}
|
|
|
|
const totalPrice = unitPrice * 1; // quantity = 1
|
|
|
|
const newItem: BomCalculationResultItem & { category_code?: string; is_manual?: boolean } = {
|
|
item_code: item.code,
|
|
item_name: item.name,
|
|
specification: item.specification || "",
|
|
unit: "EA",
|
|
quantity: 1,
|
|
unit_price: unitPrice,
|
|
total_price: totalPrice,
|
|
process_group: categoryLabel,
|
|
category_code: categoryCode,
|
|
is_manual: true,
|
|
};
|
|
|
|
const existingBomResult = location.bomResult || {
|
|
finished_goods: { code: location.productCode || "", name: location.productName || "" },
|
|
subtotals: {},
|
|
grouped_items: {},
|
|
grand_total: 0,
|
|
items: [],
|
|
};
|
|
|
|
const existingItems = existingBomResult.items || [];
|
|
const updatedItems = [...existingItems, newItem];
|
|
|
|
const existingSubtotals = existingBomResult.subtotals || {};
|
|
const rawCategorySubtotal = existingSubtotals[categoryCode];
|
|
const categorySubtotal = (typeof rawCategorySubtotal === 'object' && rawCategorySubtotal !== null)
|
|
? rawCategorySubtotal
|
|
: { name: categoryLabel, count: 0, subtotal: 0 };
|
|
const updatedSubtotals = {
|
|
...existingSubtotals,
|
|
[categoryCode]: {
|
|
name: categoryLabel,
|
|
count: (categorySubtotal.count || 0) + 1,
|
|
subtotal: (categorySubtotal.subtotal || 0) + (newItem.total_price || 0),
|
|
},
|
|
};
|
|
|
|
const existingGroupedItems = existingBomResult.grouped_items || {};
|
|
const categoryGroupedItems = existingGroupedItems[categoryCode] || { items: [] };
|
|
const updatedGroupedItems = {
|
|
...existingGroupedItems,
|
|
[categoryCode]: {
|
|
...categoryGroupedItems,
|
|
items: [...(categoryGroupedItems.items || []), newItem],
|
|
},
|
|
};
|
|
|
|
const updatedGrandTotal = (existingBomResult.grand_total || 0) + (newItem.total_price || 0);
|
|
|
|
const updatedBomResult = {
|
|
...existingBomResult,
|
|
items: updatedItems,
|
|
subtotals: updatedSubtotals,
|
|
grouped_items: updatedGroupedItems,
|
|
grand_total: updatedGrandTotal,
|
|
};
|
|
|
|
// unitPrice, totalPrice도 함께 업데이트
|
|
onUpdateLocation(location.id, {
|
|
bomResult: updatedBomResult,
|
|
unitPrice: updatedGrandTotal,
|
|
totalPrice: updatedGrandTotal * location.quantity,
|
|
});
|
|
}}
|
|
tabLabel={detailTabs.find((t) => t.value === activeTab)?.label}
|
|
/>
|
|
</div>
|
|
);
|
|
} |