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 { useDaumPostcode } from "@/hooks/useDaumPostcode";
|
||||||
import { useClientList } from "@/hooks/useClientList";
|
import { useClientList } from "@/hooks/useClientList";
|
||||||
import { Input } from "@/components/ui/input";
|
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) => {
|
const handleQuotationSelect = (quotation: QuotationForSelect) => {
|
||||||
// 견적 정보로 폼 자동 채우기 (견적에서 가져온 품목은 삭제 불가)
|
// 견적 정보로 폼 자동 채우기 (견적에서 가져온 품목은 삭제 불가)
|
||||||
@@ -769,6 +899,71 @@ export function OrderRegistration({
|
|||||||
<p className="text-sm text-red-500">{fieldErrors.items}</p>
|
<p className="text-sm text-red-500">{fieldErrors.items}</p>
|
||||||
)}
|
)}
|
||||||
{/* 품목 테이블 */}
|
{/* 품목 테이블 */}
|
||||||
|
{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")}>
|
<div className={cn("border rounded-lg overflow-hidden", fieldErrors.items && "border-red-500")}>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@@ -837,6 +1032,7 @@ export function OrderRegistration({
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 품목 추가 버튼 */}
|
{/* 품목 추가 버튼 */}
|
||||||
<Button
|
<Button
|
||||||
@@ -892,6 +1088,7 @@ export function OrderRegistration({
|
|||||||
handleClearQuotation,
|
handleClearQuotation,
|
||||||
handleQuantityChange,
|
handleQuantityChange,
|
||||||
handleRemoveItem,
|
handleRemoveItem,
|
||||||
|
itemGroups,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,18 @@ interface ApiQuoteForSelect {
|
|||||||
grade?: string; // 등급 (A, B, C)
|
grade?: string; // 등급 (A, B, C)
|
||||||
} | null;
|
} | null;
|
||||||
items?: ApiQuoteItem[];
|
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 {
|
interface ApiQuoteItem {
|
||||||
@@ -128,6 +140,7 @@ interface ApiQuoteItem {
|
|||||||
item_name: string;
|
item_name: string;
|
||||||
type_code?: string;
|
type_code?: string;
|
||||||
symbol?: string;
|
symbol?: string;
|
||||||
|
note?: string; // "5F FSS-01" 형태 (floor + code)
|
||||||
specification?: string;
|
specification?: string;
|
||||||
// QuoteItem 모델 필드명 (calculated_quantity, total_price)
|
// QuoteItem 모델 필드명 (calculated_quantity, total_price)
|
||||||
calculated_quantity?: number;
|
calculated_quantity?: number;
|
||||||
@@ -402,6 +415,18 @@ export interface QuotationForSelect {
|
|||||||
manager?: string; // 담당자
|
manager?: string; // 담당자
|
||||||
contact?: string; // 연락처
|
contact?: string; // 연락처
|
||||||
items?: QuotationItem[]; // 품목 내역
|
items?: QuotationItem[]; // 품목 내역
|
||||||
|
calculationInputs?: {
|
||||||
|
items?: Array<{
|
||||||
|
productCategory?: string;
|
||||||
|
productCode?: string;
|
||||||
|
productName?: string;
|
||||||
|
openWidth?: string;
|
||||||
|
openHeight?: string;
|
||||||
|
quantity?: number;
|
||||||
|
floor?: string;
|
||||||
|
code?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QuotationItem {
|
export interface QuotationItem {
|
||||||
@@ -643,6 +668,18 @@ function transformQuoteForSelect(apiData: ApiQuoteForSelect): QuotationForSelect
|
|||||||
manager: apiData.manager ?? undefined,
|
manager: apiData.manager ?? undefined,
|
||||||
contact: apiData.contact ?? apiData.client?.phone ?? undefined,
|
contact: apiData.contact ?? apiData.client?.phone ?? undefined,
|
||||||
items: apiData.items?.map(transformQuoteItemForSelect),
|
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 → 수량 * 단가 계산
|
// amount fallback: total_price → total_amount → 수량 * 단가 계산
|
||||||
const amount = Number(apiItem.total_price ?? apiItem.total_amount ?? 0) || (quantity * unitPrice);
|
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 {
|
return {
|
||||||
id: String(apiItem.id),
|
id: String(apiItem.id),
|
||||||
itemCode: apiItem.item_code || '',
|
itemCode: apiItem.item_code || '',
|
||||||
itemName: apiItem.item_name,
|
itemName: apiItem.item_name,
|
||||||
type: apiItem.type_code || '',
|
type: typeFromNote,
|
||||||
symbol: apiItem.symbol || '',
|
symbol: symbolFromNote,
|
||||||
spec: apiItem.specification || '',
|
spec: apiItem.specification || '',
|
||||||
quantity,
|
quantity,
|
||||||
unit: apiItem.unit || 'EA',
|
unit: apiItem.unit || 'EA',
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export function QuoteSummaryPanel({
|
|||||||
Object.entries(subtotals).forEach(([key, value]) => {
|
Object.entries(subtotals).forEach(([key, value]) => {
|
||||||
if (typeof value === "object" && value !== null) {
|
if (typeof value === "object" && value !== null) {
|
||||||
// grouped_items에서 items 가져오기 (subtotals에는 items가 없을 수 있음)
|
// 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 형식으로 변환
|
// DetailItem 형식으로 변환
|
||||||
const groupItems: DetailItem[] = (groupItemsRaw as Array<{
|
const groupItems: DetailItem[] = (groupItemsRaw as Array<{
|
||||||
item_name?: string;
|
item_name?: string;
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import type {
|
|||||||
QuoteListParams,
|
QuoteListParams,
|
||||||
QuoteStatus,
|
QuoteStatus,
|
||||||
ProductCategory,
|
ProductCategory,
|
||||||
|
BomCalculationResultItem,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { transformApiToFrontend, transformFrontendToApi } from './types';
|
import { transformApiToFrontend, transformFrontendToApi } from './types';
|
||||||
|
|
||||||
@@ -925,12 +926,8 @@ export interface BomCalculationResult {
|
|||||||
process_group?: string;
|
process_group?: string;
|
||||||
process_group_key?: string;
|
process_group_key?: string;
|
||||||
}>;
|
}>;
|
||||||
grouped_items?: Record<string, {
|
grouped_items?: Record<string, { items: BomCalculationResultItem[]; [key: string]: unknown }>;
|
||||||
name: string;
|
subtotals: Record<string, { name?: string; count?: number; subtotal?: number } | number>;
|
||||||
items: Array<unknown>;
|
|
||||||
subtotal: number;
|
|
||||||
}>;
|
|
||||||
subtotals: Record<string, { name?: string; count?: number; subtotal?: number; items?: unknown[] } | number>;
|
|
||||||
grand_total: number;
|
grand_total: number;
|
||||||
debug_steps?: Array<{ step: number; name: string; data: Record<string, unknown> }>; // 10단계 계산 과정
|
debug_steps?: Array<{ step: number; name: string; data: Record<string, unknown> }>; // 10단계 계산 과정
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user