feat(WEB): 수주등록 품목을 제품 모델+타입별 그룹핑 표시
- 견적의 calculation_inputs에서 productCode 정보를 수주등록으로 전달 - quote_items.note 파싱으로 floor/code 추출 (type_code/symbol 컬럼 부재 대응) - 제품코드(FG-KWE01-벽면형-SUS)에서 그룹핑 키(KWE01-SUS) 추출 - 그룹 내 동일 품목(item_code 기준) 수량/금액 합산하여 1행으로 표시 - quotes/actions.ts BomCalculationResult 타입을 types.ts와 일치시켜 TS 에러 해결
This commit is contained in:
@@ -11,7 +11,7 @@
|
||||
* - 품목 내역 섹션
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useDaumPostcode } from "@/hooks/useDaumPostcode";
|
||||
import { useClientList } from "@/hooks/useClientList";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -232,6 +232,136 @@ export function OrderRegistration({
|
||||
}, [])
|
||||
);
|
||||
|
||||
// 제품코드에서 그룹핑 키 추출: FG-KWE01-벽면형-SUS → KWE01-SUS
|
||||
const extractGroupKey = useCallback((productName: string): string => {
|
||||
const parts = productName.split('-');
|
||||
if (parts.length >= 4) {
|
||||
// FG-{model}-{installationType}-{finishType}
|
||||
return `${parts[1]}-${parts[3]}`;
|
||||
}
|
||||
return productName;
|
||||
}, []);
|
||||
|
||||
// 아이템을 제품 모델+타입별로 그룹핑 (제품 단위 집약)
|
||||
const itemGroups = useMemo(() => {
|
||||
const calcItems = form.selectedQuotation?.calculationInputs?.items;
|
||||
if (!calcItems || calcItems.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// floor+code → productCode 매핑
|
||||
const locationProductMap = new Map<string, string>();
|
||||
calcItems.forEach(ci => {
|
||||
if (ci.floor && ci.code && ci.productCode) {
|
||||
locationProductMap.set(`${ci.floor}|${ci.code}`, ci.productCode);
|
||||
}
|
||||
});
|
||||
|
||||
// 그룹별 데이터 집계
|
||||
const groups = new Map<string, {
|
||||
items: OrderItem[];
|
||||
productCode: string;
|
||||
locations: Set<string>; // 개소 목록
|
||||
quantity: number; // 개소별 수량 합계 (calculation_inputs 기준)
|
||||
}>();
|
||||
const ungrouped: OrderItem[] = [];
|
||||
|
||||
form.items.forEach(item => {
|
||||
const locKey = `${item.type}|${item.symbol}`;
|
||||
const productCode = locationProductMap.get(locKey);
|
||||
if (productCode) {
|
||||
const groupKey = extractGroupKey(productCode);
|
||||
if (!groups.has(groupKey)) {
|
||||
groups.set(groupKey, { items: [], productCode, locations: new Set(), quantity: 0 });
|
||||
}
|
||||
const g = groups.get(groupKey)!;
|
||||
g.items.push(item);
|
||||
g.locations.add(locKey);
|
||||
} else {
|
||||
ungrouped.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
// calculation_inputs에서 개소별 수량 합산
|
||||
calcItems.forEach(ci => {
|
||||
if (ci.productCode) {
|
||||
const groupKey = extractGroupKey(ci.productCode);
|
||||
const g = groups.get(groupKey);
|
||||
if (g) {
|
||||
g.quantity += ci.quantity ?? 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (groups.size <= 1 && ungrouped.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 그룹 내 동일 품목(item_code) 합산
|
||||
const aggregateItems = (items: OrderItem[]) => {
|
||||
const map = new Map<string, OrderItem & { _sourceIds: string[] }>();
|
||||
items.forEach(item => {
|
||||
const code = item.itemCode || item.itemName;
|
||||
if (map.has(code)) {
|
||||
const existing = map.get(code)!;
|
||||
existing.quantity += item.quantity;
|
||||
existing.amount = (existing.amount ?? 0) + (item.amount ?? 0);
|
||||
existing._sourceIds.push(item.id);
|
||||
} else {
|
||||
map.set(code, {
|
||||
...item,
|
||||
quantity: item.quantity,
|
||||
amount: item.amount ?? 0,
|
||||
_sourceIds: [item.id],
|
||||
});
|
||||
}
|
||||
});
|
||||
return Array.from(map.values());
|
||||
};
|
||||
|
||||
const result: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
productCode: string;
|
||||
locationCount: number;
|
||||
quantity: number;
|
||||
amount: number;
|
||||
items: OrderItem[];
|
||||
aggregatedItems: (OrderItem & { _sourceIds: string[] })[];
|
||||
}> = [];
|
||||
let orderNum = 1;
|
||||
groups.forEach((value, key) => {
|
||||
const amount = value.items.reduce((sum, item) => sum + (item.amount ?? 0), 0);
|
||||
result.push({
|
||||
key,
|
||||
label: `수주 ${orderNum}: ${key}`,
|
||||
productCode: key,
|
||||
locationCount: value.locations.size,
|
||||
quantity: value.quantity,
|
||||
amount,
|
||||
items: value.items,
|
||||
aggregatedItems: aggregateItems(value.items),
|
||||
});
|
||||
orderNum++;
|
||||
});
|
||||
|
||||
if (ungrouped.length > 0) {
|
||||
const amount = ungrouped.reduce((sum, item) => sum + (item.amount ?? 0), 0);
|
||||
result.push({
|
||||
key: '_ungrouped',
|
||||
label: '기타',
|
||||
productCode: '',
|
||||
locationCount: 0,
|
||||
quantity: ungrouped.length,
|
||||
amount,
|
||||
items: ungrouped,
|
||||
aggregatedItems: aggregateItems(ungrouped),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [form.items, form.selectedQuotation?.calculationInputs, extractGroupKey]);
|
||||
|
||||
// 견적 선택 핸들러
|
||||
const handleQuotationSelect = (quotation: QuotationForSelect) => {
|
||||
// 견적 정보로 폼 자동 채우기 (견적에서 가져온 품목은 삭제 불가)
|
||||
@@ -769,74 +899,140 @@ export function OrderRegistration({
|
||||
<p className="text-sm text-red-500">{fieldErrors.items}</p>
|
||||
)}
|
||||
{/* 품목 테이블 */}
|
||||
<div className={cn("border rounded-lg overflow-hidden", fieldErrors.items && "border-red-500")}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[60px] text-center">순번</TableHead>
|
||||
<TableHead>품목코드</TableHead>
|
||||
<TableHead>품명</TableHead>
|
||||
<TableHead>규격</TableHead>
|
||||
<TableHead className="w-[80px] text-center">수량</TableHead>
|
||||
<TableHead className="w-[60px] text-center">단위</TableHead>
|
||||
<TableHead className="text-right">단가</TableHead>
|
||||
<TableHead className="text-right">금액</TableHead>
|
||||
<TableHead className="w-[60px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{form.items.length === 0 ? (
|
||||
{itemGroups ? (
|
||||
// 그룹핑 표시
|
||||
<div className="space-y-4">
|
||||
{itemGroups.map((group) => {
|
||||
return (
|
||||
<div key={group.key} className={cn("border rounded-lg overflow-hidden", fieldErrors.items && "border-red-500")}>
|
||||
<div className="bg-blue-50 px-4 py-2 border-b flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="bg-blue-100 text-blue-700 border-blue-300">
|
||||
{group.label}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({group.locationCount}개소 / {group.quantity}대)
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
소계: {formatAmount(group.amount)}
|
||||
</span>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[60px] text-center">순번</TableHead>
|
||||
<TableHead>품목코드</TableHead>
|
||||
<TableHead>품명</TableHead>
|
||||
<TableHead>규격</TableHead>
|
||||
<TableHead className="w-[80px] text-center">수량</TableHead>
|
||||
<TableHead className="w-[60px] text-center">단위</TableHead>
|
||||
<TableHead className="text-right">단가</TableHead>
|
||||
<TableHead className="text-right">금액</TableHead>
|
||||
<TableHead className="w-[60px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{group.aggregatedItems.map((item, index) => (
|
||||
<TableRow key={`agg-${item.itemCode || item.id}`}>
|
||||
<TableCell className="text-center">{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{item.itemCode}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>{item.itemName}</TableCell>
|
||||
<TableCell>{item.spec}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{item.quantity}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.unit}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatAmount(item.unitPrice)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{formatAmount(item.amount ?? 0)}
|
||||
</TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
// 기본 플랫 리스트
|
||||
<div className={cn("border rounded-lg overflow-hidden", fieldErrors.items && "border-red-500")}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="text-center py-8 text-muted-foreground">
|
||||
품목이 없습니다. 견적을 선택하거나 품목을 추가해주세요.
|
||||
</TableCell>
|
||||
<TableHead className="w-[60px] text-center">순번</TableHead>
|
||||
<TableHead>품목코드</TableHead>
|
||||
<TableHead>품명</TableHead>
|
||||
<TableHead>규격</TableHead>
|
||||
<TableHead className="w-[80px] text-center">수량</TableHead>
|
||||
<TableHead className="w-[60px] text-center">단위</TableHead>
|
||||
<TableHead className="text-right">단가</TableHead>
|
||||
<TableHead className="text-right">금액</TableHead>
|
||||
<TableHead className="w-[60px]"></TableHead>
|
||||
</TableRow>
|
||||
) : (
|
||||
form.items.map((item, index) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-center">{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{item.itemCode}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>{item.itemName}</TableCell>
|
||||
<TableCell>{item.spec}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<QuantityInput
|
||||
min={1}
|
||||
value={item.quantity}
|
||||
onChange={(value) =>
|
||||
handleQuantityChange(
|
||||
item.id,
|
||||
value ?? 1
|
||||
)
|
||||
}
|
||||
className="w-16 text-center"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.unit}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatAmount(item.unitPrice)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{formatAmount(item.amount ?? 0)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveItem(item.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{form.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="text-center py-8 text-muted-foreground">
|
||||
품목이 없습니다. 견적을 선택하거나 품목을 추가해주세요.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
form.items.map((item, index) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-center">{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{item.itemCode}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>{item.itemName}</TableCell>
|
||||
<TableCell>{item.spec}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<QuantityInput
|
||||
min={1}
|
||||
value={item.quantity}
|
||||
onChange={(value) =>
|
||||
handleQuantityChange(
|
||||
item.id,
|
||||
value ?? 1
|
||||
)
|
||||
}
|
||||
className="w-16 text-center"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.unit}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatAmount(item.unitPrice)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{formatAmount(item.amount ?? 0)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveItem(item.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 품목 추가 버튼 */}
|
||||
<Button
|
||||
@@ -892,6 +1088,7 @@ export function OrderRegistration({
|
||||
handleClearQuotation,
|
||||
handleQuantityChange,
|
||||
handleRemoveItem,
|
||||
itemGroups,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -120,6 +120,18 @@ interface ApiQuoteForSelect {
|
||||
grade?: string; // 등급 (A, B, C)
|
||||
} | null;
|
||||
items?: ApiQuoteItem[];
|
||||
calculation_inputs?: {
|
||||
items?: Array<{
|
||||
productCategory?: string;
|
||||
productCode?: string;
|
||||
productName?: string;
|
||||
openWidth?: string;
|
||||
openHeight?: string;
|
||||
quantity?: number;
|
||||
floor?: string;
|
||||
code?: string;
|
||||
}>;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface ApiQuoteItem {
|
||||
@@ -128,6 +140,7 @@ interface ApiQuoteItem {
|
||||
item_name: string;
|
||||
type_code?: string;
|
||||
symbol?: string;
|
||||
note?: string; // "5F FSS-01" 형태 (floor + code)
|
||||
specification?: string;
|
||||
// QuoteItem 모델 필드명 (calculated_quantity, total_price)
|
||||
calculated_quantity?: number;
|
||||
@@ -402,6 +415,18 @@ export interface QuotationForSelect {
|
||||
manager?: string; // 담당자
|
||||
contact?: string; // 연락처
|
||||
items?: QuotationItem[]; // 품목 내역
|
||||
calculationInputs?: {
|
||||
items?: Array<{
|
||||
productCategory?: string;
|
||||
productCode?: string;
|
||||
productName?: string;
|
||||
openWidth?: string;
|
||||
openHeight?: string;
|
||||
quantity?: number;
|
||||
floor?: string;
|
||||
code?: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface QuotationItem {
|
||||
@@ -643,6 +668,18 @@ function transformQuoteForSelect(apiData: ApiQuoteForSelect): QuotationForSelect
|
||||
manager: apiData.manager ?? undefined,
|
||||
contact: apiData.contact ?? apiData.client?.phone ?? undefined,
|
||||
items: apiData.items?.map(transformQuoteItemForSelect),
|
||||
calculationInputs: apiData.calculation_inputs ? {
|
||||
items: apiData.calculation_inputs.items?.map(item => ({
|
||||
productCategory: item.productCategory,
|
||||
productCode: item.productCode,
|
||||
productName: item.productName,
|
||||
openWidth: item.openWidth,
|
||||
openHeight: item.openHeight,
|
||||
quantity: item.quantity,
|
||||
floor: item.floor,
|
||||
code: item.code,
|
||||
})),
|
||||
} : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -655,12 +692,23 @@ function transformQuoteItemForSelect(apiItem: ApiQuoteItem): QuotationItem {
|
||||
// amount fallback: total_price → total_amount → 수량 * 단가 계산
|
||||
const amount = Number(apiItem.total_price ?? apiItem.total_amount ?? 0) || (quantity * unitPrice);
|
||||
|
||||
// note에서 floor+code 추출: "5F FSS-01" → type="5F", symbol="FSS-01"
|
||||
let typeFromNote = apiItem.type_code || '';
|
||||
let symbolFromNote = apiItem.symbol || '';
|
||||
if (!typeFromNote && !symbolFromNote && apiItem.note) {
|
||||
const noteParts = apiItem.note.trim().split(/\s+/);
|
||||
if (noteParts.length >= 2) {
|
||||
typeFromNote = noteParts[0];
|
||||
symbolFromNote = noteParts.slice(1).join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: String(apiItem.id),
|
||||
itemCode: apiItem.item_code || '',
|
||||
itemName: apiItem.item_name,
|
||||
type: apiItem.type_code || '',
|
||||
symbol: apiItem.symbol || '',
|
||||
type: typeFromNote,
|
||||
symbol: symbolFromNote,
|
||||
spec: apiItem.specification || '',
|
||||
quantity,
|
||||
unit: apiItem.unit || 'EA',
|
||||
|
||||
@@ -94,7 +94,7 @@ export function QuoteSummaryPanel({
|
||||
Object.entries(subtotals).forEach(([key, value]) => {
|
||||
if (typeof value === "object" && value !== null) {
|
||||
// grouped_items에서 items 가져오기 (subtotals에는 items가 없을 수 있음)
|
||||
const groupItemsRaw = groupedItems?.[key]?.items || value.items || [];
|
||||
const groupItemsRaw = groupedItems?.[key]?.items || (value as Record<string, unknown>).items as Array<unknown> || [];
|
||||
// DetailItem 형식으로 변환
|
||||
const groupItems: DetailItem[] = (groupItemsRaw as Array<{
|
||||
item_name?: string;
|
||||
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
QuoteListParams,
|
||||
QuoteStatus,
|
||||
ProductCategory,
|
||||
BomCalculationResultItem,
|
||||
} from './types';
|
||||
import { transformApiToFrontend, transformFrontendToApi } from './types';
|
||||
|
||||
@@ -925,12 +926,8 @@ export interface BomCalculationResult {
|
||||
process_group?: string;
|
||||
process_group_key?: string;
|
||||
}>;
|
||||
grouped_items?: Record<string, {
|
||||
name: string;
|
||||
items: Array<unknown>;
|
||||
subtotal: number;
|
||||
}>;
|
||||
subtotals: Record<string, { name?: string; count?: number; subtotal?: number; items?: unknown[] } | number>;
|
||||
grouped_items?: Record<string, { items: BomCalculationResultItem[]; [key: string]: unknown }>;
|
||||
subtotals: Record<string, { name?: string; count?: number; subtotal?: number } | number>;
|
||||
grand_total: number;
|
||||
debug_steps?: Array<{ step: number; name: string; data: Record<string, unknown> }>; // 10단계 계산 과정
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user