feat: [견적] 제어기 타입 변경 + 가이드레일 제품연동 + 수식보기 개선
- 제어기: 노출형/매립형(뒷박스포함)/매립형(뒷박스제외) 3가지로 변경 - 가이드레일: 제품코드 specification에서 벽면형/측면형/혼합형 자동 연동, Select 비활성 - FormulaViewModal: JSON 데이터를 범용 렌더러(GenericDataView)로 표시 - DevFill: 새 제어기 타입 + 제품 기반 가이드레일 적용
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user