feat: [견적] 제어기 타입 변경 + 가이드레일 제품연동 + 수식보기 개선

- 제어기: 노출형/매립형(뒷박스포함)/매립형(뒷박스제외) 3가지로 변경
- 가이드레일: 제품코드 specification에서 벽면형/측면형/혼합형 자동 연동, Select 비활성
- FormulaViewModal: JSON 데이터를 범용 렌더러(GenericDataView)로 표시
- DevFill: 새 제어기 타입 + 제품 기반 가이드레일 적용
This commit is contained in:
2026-03-11 16:55:39 +09:00
parent b768ac63c2
commit 2692865b55
6 changed files with 184 additions and 30 deletions

View File

@@ -185,9 +185,7 @@ function DebugStepCard({ step }: { step: BomDebugStep }) {
{hasFormulas ? (
<FormulaTable formulas={formulas} stepName={step.name} />
) : (
<pre className="text-xs bg-gray-50 p-2 rounded overflow-x-auto max-h-40 border font-mono">
{JSON.stringify(step.data, null, 2)}
</pre>
<GenericDataView data={step.data} />
)}
</div>
)}
@@ -308,10 +306,125 @@ function FormulaTable({ formulas, stepName: _stepName }: { formulas: FormulaItem
);
}
// 기본 폴백
// 기본 폴백 — 범용 렌더러
return <GenericDataView data={formulas} />;
}
// =============================================================================
// 범용 JSON 데이터 렌더러 (formulas 없는 step용)
// =============================================================================
// 금액성 키 감지
const AMOUNT_KEYS = ['subtotal', 'grand_total', 'total_price', 'unit_price', 'total'];
const LABEL_MAP: Record<string, string> = {
tenant_id: '테넌트 ID',
handler: '계산 핸들러',
handler_class: '핸들러 클래스',
finished_goods: '완제품 코드',
code: '코드',
name: '제품명',
item_category: '품목 카테고리',
total_items: '총 품목 수',
item_codes: '품목 코드',
items: '품목 목록',
groups: '카테고리 그룹',
count: '품목 수',
subtotal: '소계',
grand_total: '총합계',
formatted: '금액',
item_count: '품목 수',
formula: '수식',
calculation: '계산',
result: '결과',
category: '카테고리',
item_categories: '품목 카테고리',
};
function isAmountKey(key: string): boolean {
return AMOUNT_KEYS.some(k => key.toLowerCase().includes(k));
}
function formatValue(key: string, value: unknown): string {
if (value === null || value === undefined) return '-';
if (typeof value === 'number') {
if (isAmountKey(key)) return `${formatNumber(value)}`;
return formatNumber(value);
}
return String(value);
}
function GenericDataView({ data }: { data: unknown }) {
if (data === null || data === undefined) return null;
// 배열 처리
if (Array.isArray(data)) {
// 객체 배열 → 카드 리스트
if (data.length > 0 && typeof data[0] === 'object') {
return (
<div className="space-y-2">
{data.map((item, i) => (
<div key={i} className="bg-gray-50 rounded border p-2">
<GenericDataView data={item} />
</div>
))}
</div>
);
}
// 단순 배열 → 태그
return (
<div className="flex flex-wrap gap-1">
{data.map((item, i) => (
<span key={i} className="inline-block bg-blue-100 text-blue-700 text-xs px-2 py-0.5 rounded">
{String(item)}
</span>
))}
</div>
);
}
// 객체 처리
if (typeof data === 'object') {
const entries = Object.entries(data as Record<string, unknown>);
return (
<div className="space-y-1.5">
{entries.map(([key, value]) => (
<GenericField key={key} fieldKey={key} value={value} />
))}
</div>
);
}
// 원시 값
return <span className="text-sm font-mono">{String(data)}</span>;
}
function GenericField({ fieldKey, value }: { fieldKey: string; value: unknown }) {
const label = LABEL_MAP[fieldKey] || fieldKey;
// 중첩 객체/배열 → 접이식
if (typeof value === 'object' && value !== null) {
const isArray = Array.isArray(value);
const count = isArray ? value.length : Object.keys(value as object).length;
return (
<div className="border-l-2 border-gray-200 pl-3">
<div className="text-xs font-medium text-gray-500 mb-1">
{label}
{isArray && <span className="text-blue-500 ml-1">({count})</span>}
</div>
<GenericDataView data={value} />
</div>
);
}
// 단순 key-value
const isAmount = isAmountKey(fieldKey);
return (
<pre className="text-xs bg-gray-50 p-2 rounded overflow-x-auto max-h-40 border font-mono">
{JSON.stringify(formulas, null, 2)}
</pre>
<div className="flex items-baseline gap-2 text-sm">
<span className="text-gray-500 text-xs min-w-[80px] shrink-0">{label}</span>
<span className={`font-mono ${isAmount ? 'font-bold text-blue-600' : 'text-gray-800'}`}>
{formatValue(fieldKey, value)}
</span>
</div>
);
}

View File

@@ -74,17 +74,26 @@ const GUIDE_RAIL_TYPES = [
{ value: "mixed", label: "혼합형" },
];
// 제품 specification에서 가이드레일 타입 추출
function getGuideRailFromSpec(spec?: string, itemName?: string): string | null {
const text = spec || itemName || '';
if (text.includes('혼합')) return 'mixed';
if (text.includes('벽면')) return 'wall';
if (text.includes('측면')) return 'floor';
return null;
}
// 모터 전원
const MOTOR_POWERS = [
{ value: "single", label: "단상(220V)" },
{ value: "three", label: "삼상(380V)" },
];
// 연동제어기
// 제어기 설치 방식
const CONTROLLERS = [
{ value: "basic", label: "단독" },
{ value: "smart", label: "연동" },
{ value: "premium", label: "매립형-뒷박스포함" },
{ value: "exposed", label: "노출형" },
{ value: "embedded", label: "매립형(뒷박스 포함)" },
{ value: "embedded_no_box", label: "매립형(뒷박스 제외)" },
];
// 탭 인터페이스 정의
@@ -334,10 +343,12 @@ export function LocationDetailPanel({
value={location.productCode}
onValueChange={(value) => {
const product = finishedGoods.find((fg) => fg.item_code === value);
const guideRail = product ? getGuideRailFromSpec(product.specification, product.item_name) : null;
onUpdateLocation(location.id, {
productCode: value,
productName: product?.item_name || value,
itemCategory: product?.item_category,
...(guideRail ? { guideRailType: guideRail } : {}),
});
}}
disabled={disabled}
@@ -361,11 +372,12 @@ export function LocationDetailPanel({
<div>
<label className="text-xs text-gray-600 flex items-center gap-1">
🔧
<span className="text-[10px] text-gray-400">( )</span>
</label>
<Select
value={location.guideRailType}
onValueChange={(value) => handleFieldChange("guideRailType", value)}
disabled={disabled}
disabled
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />

View File

@@ -37,6 +37,7 @@ import type { LocationItem } from "./QuoteRegistration";
const GUIDE_RAIL_TYPES = [
{ value: "wall", label: "벽면형" },
{ value: "floor", label: "측면형" },
{ value: "mixed", label: "혼합형" },
];
// 모터 전원
@@ -45,11 +46,11 @@ const MOTOR_POWERS = [
{ value: "three", label: "삼상(380V)" },
];
// 연동제어기
// 제어기 설치 방식
const CONTROLLERS = [
{ value: "basic", label: "단독" },
{ value: "smart", label: "연동" },
{ value: "premium", label: "매립형-뒷박스포함" },
{ value: "exposed", label: "노출형" },
{ value: "embedded", label: "매립형(뒷박스 포함)" },
{ value: "embedded_no_box", label: "매립형(뒷박스 제외)" },
];
// =============================================================================
@@ -83,7 +84,7 @@ export function LocationEditModal({
openHeight: 0,
guideRailType: "wall",
motorPower: "single",
controller: "basic",
controller: "exposed",
});
// location 변경 시 폼 데이터 초기화
@@ -199,10 +200,12 @@ export function LocationEditModal({
<div>
<label className="text-xs text-gray-500 flex items-center gap-1 mb-1">
🔧
<span className="text-[10px] text-gray-400">( )</span>
</label>
<Select
value={formData.guideRailType}
onValueChange={(value) => handleFieldChange("guideRailType", value)}
disabled
>
<SelectTrigger>
<SelectValue />

View File

@@ -52,19 +52,29 @@ const FLOOR_OPTIONS = [
const GUIDE_RAIL_TYPES = [
{ value: "wall", label: "벽면형" },
{ value: "floor", label: "측면형" },
{ value: "mixed", label: "혼합형" },
];
// 제품 specification에서 가이드레일 타입 추출
function getGuideRailFromSpec(spec?: string, itemName?: string): string | null {
const text = spec || itemName || '';
if (text.includes('혼합')) return 'mixed';
if (text.includes('벽면')) return 'wall';
if (text.includes('측면')) return 'floor';
return null;
}
// 모터 전원
const MOTOR_POWERS = [
{ value: "single", label: "단상(220V)" },
{ value: "three", label: "삼상(380V)" },
];
// 연동제어기
// 제어기 설치 방식
const CONTROLLERS = [
{ value: "basic", label: "단독" },
{ value: "smart", label: "연동" },
{ value: "premium", label: "매립형-뒷박스포함" },
{ value: "exposed", label: "노출형" },
{ value: "embedded", label: "매립형(뒷박스 포함)" },
{ value: "embedded_no_box", label: "매립형(뒷박스 제외)" },
];
// =============================================================================
@@ -116,7 +126,7 @@ export function LocationListPanel({
quantity: "1",
guideRailType: "wall",
motorPower: "single",
controller: "basic",
controller: "exposed",
});
// 삭제 확인 다이얼로그
@@ -190,7 +200,7 @@ export function LocationListPanel({
수량: 1,
: "wall",
: "single",
: "basic",
: "exposed",
},
];
@@ -245,7 +255,7 @@ export function LocationListPanel({
quantity: parseInt(row["수량"]) || 1,
guideRailType: row["가이드레일"] || "wall",
motorPower: row["전원"] || "single",
controller: row["제어기"] || "basic",
controller: row["제어기"] || "exposed",
wingSize: 50,
inspectionFee: 50000,
};
@@ -338,7 +348,14 @@ export function LocationListPanel({
<label className="text-xs text-gray-600"></label>
<Select
value={formData.productCode}
onValueChange={(value) => handleFormChange("productCode", value)}
onValueChange={(value) => {
handleFormChange("productCode", value);
const product = finishedGoods.find((fg) => fg.item_code === value);
const guideRail = product ? getGuideRailFromSpec(product.specification, product.item_name) : null;
if (guideRail) {
handleFormChange("guideRailType", guideRail);
}
}}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="선택" />
@@ -368,10 +385,12 @@ export function LocationListPanel({
<div>
<label className="text-xs text-gray-600 flex items-center gap-1">
🔧
<span className="text-[10px] text-gray-400">( )</span>
</label>
<Select
value={formData.guideRailType}
onValueChange={(value) => handleFormChange("guideRailType", value)}
disabled
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />

View File

@@ -119,7 +119,7 @@ const _createNewLocation = (): LocationItem => ({
quantity: 1,
guideRailType: "wall",
motorPower: "single",
controller: "basic",
controller: "exposed",
wingSize: 50,
inspectionFee: 50000,
});
@@ -231,9 +231,16 @@ export function QuoteRegistration({
const codePrefixes = ["SD", "FSS", "FD", "SS", "DS"];
const fixedPrefix = codePrefixes[Math.floor(Math.random() * codePrefixes.length)];
const guideRailTypes = ["wall", "floor", "mixed"];
// 가이드레일: 제품 specification에서 자동 추출
const getGuideRailFromSpec = (spec?: string, name?: string): string => {
const text = spec || name || '';
if (text.includes('혼합')) return 'mixed';
if (text.includes('측면')) return 'floor';
if (text.includes('벽면')) return 'wall';
return 'wall';
};
const motorPowers = ["single", "three"];
const controllers = ["basic", "smart", "premium"];
const controllers = ["exposed", "embedded", "embedded_no_box"];
// 1~5개 랜덤 개소 생성
const locationCount = Math.floor(Math.random() * 5) + 1;
@@ -257,7 +264,7 @@ export function QuoteRegistration({
productCode: fixedProduct?.item_code || "FG-SCR-001",
productName: fixedProduct?.item_name || "방화 스크린 셔터 (소형)",
quantity: 1,
guideRailType: guideRailTypes[Math.floor(Math.random() * guideRailTypes.length)],
guideRailType: getGuideRailFromSpec(fixedProduct?.specification, fixedProduct?.item_name),
motorPower: motorPowers[Math.floor(Math.random() * motorPowers.length)],
controller: controllers[Math.floor(Math.random() * controllers.length)],
wingSize: [50, 60, 70][Math.floor(Math.random() * 3)],

View File

@@ -971,7 +971,7 @@ export function transformApiToV2(apiData: QuoteApiData): QuoteFormDataV2 {
quantity: qty,
guideRailType: ci.guideRailType || 'wall',
motorPower: ci.motorPower || 'single',
controller: ci.controller || 'basic',
controller: ci.controller || 'exposed',
wingSize: parseInt(ci.wingSize || '50', 10),
inspectionFee: ci.inspectionFee || 50000,
unitPrice,