feat: 견적확정 밸리데이션, 수주등록 개소그룹, 작업지시 개선

- 견적확정 시 업체명/현장명/담당자/연락처 프론트 밸리데이션 추가
- 견적확정 후 수주등록 버튼 동적 전환
- 수주등록 품목 개소별(floor+code) 그룹핑 수정
- 작업지시 상세 quantity 문자열→숫자 변환 (formatQuantity)
- 작업지시 탭 카운트 초기 로딩 시 전체 표시 (by_process 활용)
- 작업지시 상세 개소별/품목별 합산 테이블 추가
- 작업자 화면 API 연동 및 목업 데이터 분리
- 입고관리 완료건 수정, 재고현황 개선
This commit is contained in:
2026-02-07 03:27:23 +09:00
parent b2085a84ca
commit a8591c438e
29 changed files with 3238 additions and 700 deletions

View File

@@ -234,93 +234,58 @@ 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;
}, []);
// 아이템을 제품 모델+타입별로 그룹핑 (제품 단위 집약)
// 아이템을 개소별(floor+code)로 그룹핑
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>();
// floor+code → calculationInput 매핑 (개소 메타정보)
const locationMetaMap = new Map<string, {
productCode: string;
productName: string;
quantity: number;
floor: string;
code: string;
}>();
calcItems.forEach(ci => {
if (ci.floor && ci.code && ci.productCode) {
locationProductMap.set(`${ci.floor}|${ci.code}`, ci.productCode);
if (ci.floor && ci.code) {
const locKey = `${ci.floor}|${ci.code}`;
locationMetaMap.set(locKey, {
productCode: ci.productCode || '',
productName: ci.productName || '',
quantity: ci.quantity ?? 1,
floor: ci.floor,
code: ci.code,
});
}
});
// 그룹별 데이터 집계
// 개소별 그룹
const groups = new Map<string, {
items: OrderItem[];
productCode: string;
locations: Set<string>; // 개소 목록
quantity: number; // 개소별 수량 합계 (calculation_inputs 기준)
meta: { productCode: string; productName: string; quantity: number; floor: string; code: string };
}>();
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 meta = locationMetaMap.get(locKey);
if (meta) {
if (!groups.has(locKey)) {
groups.set(locKey, { items: [], meta });
}
const g = groups.get(groupKey)!;
g.items.push(item);
g.locations.add(locKey);
groups.get(locKey)!.items.push(item);
} 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) {
if (groups.size === 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;
@@ -329,20 +294,19 @@ export function OrderRegistration({
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,
label: `${orderNum}. ${value.meta.floor} / ${value.meta.code}`,
productCode: value.meta.productName || value.meta.productCode,
locationCount: 1,
quantity: value.meta.quantity,
amount,
items: value.items,
aggregatedItems: aggregateItems(value.items),
});
orderNum++;
});
@@ -357,12 +321,11 @@ export function OrderRegistration({
quantity: ungrouped.length,
amount,
items: ungrouped,
aggregatedItems: aggregateItems(ungrouped),
});
}
return result;
}, [form.items, form.selectedQuotation?.calculationInputs, extractGroupKey]);
}, [form.items, form.selectedQuotation?.calculationInputs]);
// 견적 선택 핸들러
const handleQuotationSelect = (quotation: QuotationForSelect) => {
@@ -903,17 +866,18 @@ export function OrderRegistration({
{itemGroups ? (
// 그룹핑 표시
<div className="space-y-4">
{itemGroups.map((group) => {
return (
{itemGroups.map((group) => (
<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={getPresetStyle('info')}>
{group.label}
</Badge>
<span className="text-sm text-muted-foreground">
({group.locationCount} / {group.quantity})
</span>
{group.productCode && (
<span className="text-sm text-muted-foreground">
{group.productCode} ({group.quantity})
</span>
)}
</div>
<span className="text-sm font-medium">
: {formatAmount(group.amount)}
@@ -934,8 +898,8 @@ export function OrderRegistration({
</TableRow>
</TableHeader>
<TableBody>
{group.aggregatedItems.map((item, index) => (
<TableRow key={`agg-${item.itemCode || item.id}`}>
{group.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">
@@ -960,8 +924,7 @@ export function OrderRegistration({
</TableBody>
</Table>
</div>
);
})}
))}
</div>
) : (
// 기본 플랫 리스트