fix(WEB): 견적 제품분류 동적 결정 + 슬랫 작업일지 포맷 개선

- 견적: product_category 하드코딩 'screen' → FG 품목의 item_category에서 자동 결정
- LocationItem에 itemCategory 필드 추가, 제품 선택 시 자동 설정
- 슬랫 작업일지: 코일 사용량 소수점 포맷 (212.0 → 212, 212.5 유지)
- 슬랫 작업일지: 헤더 "설치홈/부호" → "층/부호"
This commit is contained in:
2026-02-21 02:03:33 +09:00
parent e15de71f52
commit 77cad7a83a
5 changed files with 35 additions and 9 deletions

View File

@@ -50,6 +50,9 @@ export function SlatWorkLogContent({ data: order, materialLots = [] }: SlatWorkL
// 숫자 천단위 콤마 포맷
const fmt = (v?: number) => v != null ? formatNumber(v) : '-';
// 소수점: 정수면 소수점 없이, 소수면 소수점 1자리까지
const fmtDec = (v: number) => Number.isInteger(v) ? String(v) : v.toFixed(1);
// floorCode에서 부호 추출: "1층/FSS-01" → "FSS-01"
const getSymbolCode = (floorCode?: string) => {
if (!floorCode || floorCode === '-') return '-';
@@ -176,7 +179,7 @@ export function SlatWorkLogContent({ data: order, materialLots = [] }: SlatWorkL
<th className="border border-gray-400 px-2 py-1" colSpan={3}>(mm) - </th>
<th className="border border-gray-400 px-2 py-1 whitespace-nowrap" rowSpan={2}><br/></th>
<th className="border border-gray-400 px-2 py-1 whitespace-nowrap" rowSpan={2}><br/></th>
<th className="border border-gray-400 px-2 py-1 whitespace-nowrap" rowSpan={2}>/<br/></th>
<th className="border border-gray-400 px-2 py-1 whitespace-nowrap" rowSpan={2}>/<br/></th>
</tr>
<tr className="bg-gray-100">
<th className="border border-gray-400 px-2 py-1"></th>
@@ -201,7 +204,7 @@ export function SlatWorkLogContent({ data: order, materialLots = [] }: SlatWorkL
<td className="border border-gray-400 px-2 py-1 text-center whitespace-nowrap">{fmt(item.height)}</td>
<td className="border border-gray-400 px-2 py-1 text-center">{slatCount > 0 ? fmt(slatCount) : '-'}</td>
<td className="border border-gray-400 px-2 py-1 text-center">{jointBar > 0 ? fmt(jointBar) : '-'}</td>
<td className="border border-gray-400 px-2 py-1 text-center">{coilUsage > 0 ? coilUsage.toFixed(1) : '-'}</td>
<td className="border border-gray-400 px-2 py-1 text-center">{coilUsage > 0 ? fmtDec(coilUsage) : '-'}</td>
<td className="border border-gray-400 px-2 py-1 text-center whitespace-nowrap">{getSymbolCode(item.floorCode)}</td>
</tr>
);
@@ -221,7 +224,7 @@ export function SlatWorkLogContent({ data: order, materialLots = [] }: SlatWorkL
<tbody>
<tr>
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium whitespace-nowrap"> [m²]</td>
<td className="border border-gray-400 px-3 py-2">{totalCoilUsage > 0 ? totalCoilUsage.toFixed(1) : ''}</td>
<td className="border border-gray-400 px-3 py-2">{totalCoilUsage > 0 ? fmtDec(totalCoilUsage) : ''}</td>
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium whitespace-nowrap"> </td>
<td className="border border-gray-400 px-3 py-2">{totalJointBar > 0 ? fmt(totalJointBar) : ''}</td>
</tr>

View File

@@ -337,6 +337,7 @@ export function LocationDetailPanel({
onUpdateLocation(location.id, {
productCode: value,
productName: product?.item_name || value,
itemCategory: product?.item_category,
});
}}
disabled={disabled}

View File

@@ -152,6 +152,7 @@ export function LocationListPanel({
openHeight: parseFloat(formData.openHeight) || 0,
productCode: formData.productCode,
productName: product?.item_name || formData.productCode,
itemCategory: product?.item_category,
quantity: parseInt(formData.quantity) || 1,
guideRailType: formData.guideRailType,
motorPower: formData.motorPower,

View File

@@ -68,6 +68,7 @@ export interface LocationItem {
openHeight: number; // 세로 (오픈사이즈 H)
productCode: string; // 제품코드
productName: string; // 제품명
itemCategory?: string; // 품목 카테고리 (스크린/철재)
quantity: number; // 수량
guideRailType: string; // 가이드레일 설치 유형
motorPower: string; // 모터 전원

View File

@@ -36,13 +36,28 @@ export const QUOTE_STATUS_COLORS: Record<QuoteStatus, string> = {
};
// ===== 제품 카테고리 =====
export type ProductCategory = 'screen' | 'steel';
export type ProductCategory = 'SCREEN' | 'STEEL';
export const PRODUCT_CATEGORY_LABELS: Record<ProductCategory, string> = {
export const PRODUCT_CATEGORY_LABELS: Record<string, string> = {
SCREEN: '스크린',
STEEL: '철재',
screen: '스크린',
steel: '철재',
};
/** item_category(한글) → product_category 변환 (DB는 대문자) */
export function itemCategoryToProductCategory(itemCategory?: string): ProductCategory {
if (itemCategory === '철재') return 'STEEL';
return 'SCREEN';
}
/** 개소 목록에서 견적 전체의 product_category 결정 */
function deriveProductCategory(locations: LocationItem[]): ProductCategory {
const categories = new Set(locations.map(loc => itemCategoryToProductCategory(loc.itemCategory)));
if (categories.size === 1) return categories.values().next().value!;
return itemCategoryToProductCategory(locations[0]?.itemCategory);
}
// ===== 견적 품목 =====
export interface QuoteItem {
id: string;
@@ -104,6 +119,8 @@ export interface Quote {
// 자동산출 입력값 타입
export interface CalculationInputItem {
productCategory?: string;
itemCategory?: string; // 품목 카테고리 원본 (스크린/철재) - round-trip용
productCode?: string; // 제품코드
productName?: string;
openWidth?: string;
openHeight?: string;
@@ -246,7 +263,7 @@ export function transformApiToFrontend(apiData: QuoteApiData): Quote {
// API 실제 필드명(manager, contact) 우선, 레거시 필드명(manager_name, manager_contact) 폴백
managerName: apiData.manager || apiData.manager_name || undefined,
managerContact: apiData.contact || apiData.manager_contact || undefined,
productCategory: apiData.product_category,
productCategory: (apiData.product_category?.toUpperCase() || 'SCREEN') as ProductCategory,
quantity: apiData.quantity || 0,
unitSymbol: apiData.unit_symbol || undefined,
supplyAmount: parseFloat(String(apiData.supply_amount)) || 0,
@@ -669,6 +686,7 @@ export interface LocationItem {
openHeight: number; // 세로 (오픈사이즈 H)
productCode: string; // 제품코드
productName: string; // 제품명
itemCategory?: string; // 품목 카테고리 (스크린/철재)
quantity: number; // 수량
guideRailType: string; // 가이드레일 설치 유형
motorPower: string; // 모터 전원
@@ -734,7 +752,8 @@ export function transformV2ToApi(
const calculationInputs: CalculationInputs & { bomResults?: BomCalculationResult[] } = {
items: data.locations.map(loc => ({
productCategory: 'screen', // TODO: 동적으로 결정
productCategory: itemCategoryToProductCategory(loc.itemCategory),
itemCategory: loc.itemCategory, // round-trip용
productCode: loc.productCode, // BOM 재계산용
productName: loc.productName,
openWidth: String(loc.openWidth),
@@ -876,7 +895,7 @@ export function transformV2ToApi(
contact: data.contact || null,
completion_date: data.dueDate || null,
remarks: data.remarks || null,
product_category: 'screen', // TODO: 동적으로 결정
product_category: deriveProductCategory(data.locations),
quantity: data.locations.reduce((sum, loc) => sum + loc.quantity, 0),
unit_symbol: '개소',
total_amount: grandTotal,
@@ -948,6 +967,7 @@ export function transformApiToV2(apiData: QuoteApiData): QuoteFormDataV2 {
openHeight: parseInt(ci.openHeight || '0', 10),
productCode: (ci as { productCode?: string }).productCode || bomResult?.finished_goods?.code || '',
productName: ci.productName || '',
itemCategory: ci.itemCategory,
quantity: qty,
guideRailType: ci.guideRailType || 'wall',
motorPower: ci.motorPower || 'single',
@@ -1129,7 +1149,7 @@ export function transformFormDataToApi(formData: QuoteFormData): Record<string,
contact: formData.contact || null,
completion_date: formData.dueDate || null,
remarks: formData.remarks || null,
product_category: formData.items[0]?.productCategory?.toLowerCase() || 'screen',
product_category: formData.items[0]?.productCategory?.toUpperCase() || 'SCREEN',
quantity: formData.items.reduce((sum, item) => sum + item.quantity, 0),
unit_symbol: formData.unitSymbol || '개소', // 선택한 제품의 단위 또는 기본값
total_amount: grandTotal,