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:
2026-02-04 23:04:47 +09:00
parent abd243fce2
commit f1c4ab62bf
4 changed files with 316 additions and 74 deletions

View File

@@ -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,
]
);

View File

@@ -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',

View File

@@ -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;

View File

@@ -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단계 계산 과정
}