feat: 수식보기 모달 추가 (개발환경 전용)

- FormulaViewModal 컴포넌트 신규 생성
- 10단계 계산 과정을 수식 형태로 표시
- 변수 계산: 수식 + 대입값 + 결과 테이블 형식
- 품목별 수량/금액 계산 과정 표시
- QuoteFooterBar에 수식보기 버튼 추가
- NEXT_PUBLIC_APP_ENV가 local/development일 때만 버튼 표시
- BomDebugStep, formulas 타입 추가
- calculateBomBulk에 debug=true 파라미터 추가
This commit is contained in:
2026-01-29 08:17:25 +09:00
parent 32a1ed2de7
commit db70147468
5 changed files with 452 additions and 4 deletions

View File

@@ -0,0 +1,316 @@
/**
* 수식 보기 모달 (개발용)
* - 10단계 계산 과정 (debug_steps)
* - 실제 계산 수식 표시
*/
"use client";
import { Calculator, ChevronDown, ChevronRight } from "lucide-react";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "../ui/dialog";
import { ScrollArea } from "../ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import type { LocationItem, BomDebugStep } from "./types";
interface FormulaViewModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
locations: LocationItem[];
}
// 수식 항목 타입
interface FormulaItem {
var?: string;
desc?: string;
formula?: string;
calculation?: string;
result?: number | string;
value?: number | string;
unit?: string;
item?: string;
qty_formula?: string;
qty_result?: number;
unit_price?: number;
price_formula?: string;
price_calc?: string;
total?: number;
category?: string;
}
export function FormulaViewModal({
open,
onOpenChange,
locations,
}: FormulaViewModalProps) {
const locationsWithBom = locations.filter((loc) => loc.bomResult);
if (locationsWithBom.length === 0) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Calculator className="h-5 w-5" />
</DialogTitle>
</DialogHeader>
<div className="py-8 text-center text-gray-500">
. .
</div>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[95vw] w-[1600px] max-h-[90vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Calculator className="h-5 w-5" />
</DialogTitle>
</DialogHeader>
<Tabs defaultValue={locationsWithBom[0]?.id} className="w-full">
<TabsList className="mb-3 flex-wrap h-auto gap-1">
{locationsWithBom.map((loc) => (
<TabsTrigger key={loc.id} value={loc.id} className="text-xs">
{loc.floor}/{loc.code}
</TabsTrigger>
))}
</TabsList>
{locationsWithBom.map((location) => (
<TabsContent key={location.id} value={location.id}>
<ScrollArea className="h-[78vh] pr-4">
<LocationDetail location={location} />
</ScrollArea>
</TabsContent>
))}
</Tabs>
</DialogContent>
</Dialog>
);
}
function LocationDetail({ location }: { location: LocationItem }) {
const bom = location.bomResult;
if (!bom) return null;
const debugSteps = bom.debug_steps || [];
return (
<div className="space-y-2">
{/* 10단계 계산 과정 */}
{debugSteps.length > 0 ? (
<div className="space-y-1">
{debugSteps.map((step, idx) => (
<DebugStepCard key={idx} step={step} />
))}
</div>
) : (
<div className="text-sm text-gray-500 p-3 bg-yellow-50 rounded border border-yellow-200">
. 10 .
</div>
)}
{/* 합계 */}
<div className="flex justify-between items-center bg-blue-50 p-3 rounded-lg border border-blue-200 mt-4">
<div className="text-sm">
<span className="text-gray-600">:</span>{" "}
<span className="font-mono">{bom.finished_goods?.code}</span>{" "}
<span className="text-gray-500">{bom.finished_goods?.name}</span>
</div>
<div className="text-right">
<span className="text-sm text-gray-600">1 </span>
<span className="font-bold text-blue-600">{bom.grand_total.toLocaleString()}</span>
<span className="mx-2">×</span>
<span className="text-sm">{location.quantity} =</span>
<span className="font-bold text-green-600 text-lg ml-2">
{(bom.grand_total * location.quantity).toLocaleString()}
</span>
</div>
</div>
</div>
);
}
function DebugStepCard({ step }: { step: BomDebugStep }) {
const [expanded, setExpanded] = useState(step.step >= 1 && step.step <= 3);
const stepColors: Record<number, string> = {
0: "bg-gray-400",
1: "bg-blue-500",
2: "bg-blue-600",
3: "bg-green-500",
4: "bg-green-600",
5: "bg-yellow-500",
6: "bg-yellow-600",
7: "bg-orange-500",
8: "bg-purple-500",
9: "bg-purple-600",
10: "bg-red-500",
};
// formulas 배열이 있는지 확인
const formulas = step.data.formulas as FormulaItem[] | undefined;
const hasFormulas = formulas && Array.isArray(formulas) && formulas.length > 0;
return (
<div className="border rounded overflow-hidden">
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center gap-2 px-3 py-2 bg-gray-50 hover:bg-gray-100 text-left text-sm"
>
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
<span className={`w-6 h-6 rounded text-white text-xs flex items-center justify-center flex-shrink-0 ${stepColors[step.step] || "bg-gray-500"}`}>
{step.step}
</span>
<span className="font-medium">{step.name}</span>
{hasFormulas && (
<span className="text-xs text-blue-500 ml-2">({formulas.length} )</span>
)}
</button>
{expanded && (
<div className="p-3 bg-white border-t">
{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>
)}
</div>
)}
</div>
);
}
function FormulaTable({ formulas, stepName }: { formulas: FormulaItem[]; stepName: string }) {
// 입력값 (Step 1)
if (formulas[0]?.var && formulas[0]?.value !== undefined) {
return (
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-gray-100">
<th className="p-2 text-left border w-28"></th>
<th className="p-2 text-left border"></th>
<th className="p-2 text-right border w-32"></th>
<th className="p-2 text-left border w-20"></th>
</tr>
</thead>
<tbody>
{formulas.map((f, i) => (
<tr key={i} className="border-b hover:bg-gray-50">
<td className="p-2 border font-mono font-bold text-blue-600">{f.var}</td>
<td className="p-2 border text-gray-600">{f.desc}</td>
<td className="p-2 border text-right font-mono">{typeof f.value === 'number' ? f.value.toLocaleString() : f.value}</td>
<td className="p-2 border text-gray-500">{f.unit}</td>
</tr>
))}
</tbody>
</table>
);
}
// 변수 계산 (Step 3) - formula, calculation, result
if (formulas[0]?.formula && formulas[0]?.calculation) {
return (
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-gray-100">
<th className="p-2 text-left border w-36"></th>
<th className="p-2 text-left border"></th>
<th className="p-2 text-left border"></th>
<th className="p-2 text-right border w-28"></th>
</tr>
</thead>
<tbody>
{formulas.map((f, i) => (
<tr key={i} className="border-b hover:bg-gray-50">
<td className="p-2 border">
<span className="font-mono font-bold text-blue-600">{f.var}</span>
<span className="text-gray-500 text-xs ml-2">{f.desc}</span>
</td>
<td className="p-2 border font-mono text-purple-600">{f.formula}</td>
<td className="p-2 border font-mono text-gray-600">{f.calculation}</td>
<td className="p-2 border text-right">
<span className="font-mono font-bold">{typeof f.result === 'number' ? f.result.toLocaleString() : f.result}</span>
<span className="text-gray-500 text-xs ml-1">{f.unit}</span>
</td>
</tr>
))}
</tbody>
</table>
);
}
// 품목 수량/금액 계산 (Step 6, 7)
if (formulas[0]?.item) {
return (
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-gray-100">
<th className="p-2 text-left border"></th>
<th className="p-2 text-left border w-40"> </th>
<th className="p-2 text-right border w-20"></th>
<th className="p-2 text-right border w-24"></th>
<th className="p-2 text-left border w-40"> </th>
<th className="p-2 text-right border w-28"></th>
</tr>
</thead>
<tbody>
{formulas.map((f, i) => (
<tr key={i} className="border-b hover:bg-gray-50">
<td className="p-2 border font-medium">{f.item}</td>
<td className="p-2 border font-mono text-purple-600 text-xs">{f.qty_formula}</td>
<td className="p-2 border text-right font-mono">{f.qty_result}</td>
<td className="p-2 border text-right font-mono">{f.unit_price?.toLocaleString()}</td>
<td className="p-2 border font-mono text-gray-600 text-xs">{f.price_calc}</td>
<td className="p-2 border text-right font-mono font-bold">{f.total?.toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
);
}
// 소계 (Step 9)
if (formulas[0]?.category) {
return (
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-gray-100">
<th className="p-2 text-left border w-32"></th>
<th className="p-2 text-left border"> </th>
<th className="p-2 text-right border w-28"></th>
</tr>
</thead>
<tbody>
{formulas.map((f, i) => (
<tr key={i} className="border-b hover:bg-gray-50">
<td className="p-2 border font-medium">{f.category}</td>
<td className="p-2 border text-gray-600 text-xs">{f.formula}</td>
<td className="p-2 border text-right font-mono font-bold">{f.result?.toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
);
}
// 기본 폴백
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>
);
}

View File

@@ -8,7 +8,7 @@
"use client";
import { Save, Check, ArrowLeft, Loader2, FileText, Pencil, ClipboardList, Percent } from "lucide-react";
import { Save, Check, ArrowLeft, Loader2, FileText, Pencil, ClipboardList, Percent, Calculator } from "lucide-react";
import { Button } from "../ui/button";
@@ -36,6 +36,10 @@ interface QuoteFooterBarProps {
onOrderRegister?: () => void;
/** 할인하기 */
onDiscount?: () => void;
/** 수식보기 */
onFormulaView?: () => void;
/** BOM 결과 유무 */
hasBomResult?: boolean;
isSaving?: boolean;
disabled?: boolean;
/** view 모드 여부 (view: 수정+최종확정, edit: 저장+최종확정) */
@@ -58,6 +62,8 @@ export function QuoteFooterBar({
onEdit,
onOrderRegister,
onDiscount,
onFormulaView,
hasBomResult = false,
isSaving = false,
disabled = false,
isViewMode = false,
@@ -109,6 +115,19 @@ export function QuoteFooterBar({
</Button>
{/* 수식보기 - 개발환경(local/development)에서만 표시 */}
{onFormulaView && ['local', 'development'].includes(process.env.NEXT_PUBLIC_APP_ENV || '') && (
<Button
onClick={onFormulaView}
disabled={!hasBomResult}
variant="outline"
className="gap-2 px-6 border-purple-300 text-purple-600 hover:bg-purple-50"
>
<Calculator className="h-4 w-4" />
</Button>
)}
{/* 수정 - view 모드에서만 표시 */}
{isViewMode && onEdit && (
<Button

View File

@@ -35,6 +35,7 @@ import { QuoteFooterBar } from "./QuoteFooterBar";
import { QuotePreviewModal } from "./QuotePreviewModal";
import { QuoteTransactionModal } from "./QuoteTransactionModal";
import { DiscountModal } from "./DiscountModal";
import { FormulaViewModal } from "./FormulaViewModal";
import {
getFinishedGoods,
@@ -179,6 +180,7 @@ export function QuoteRegistrationV2({
const [quotePreviewOpen, setQuotePreviewOpen] = useState(false);
const [transactionPreviewOpen, setTransactionPreviewOpen] = useState(false);
const [discountModalOpen, setDiscountModalOpen] = useState(false);
const [formulaViewOpen, setFormulaViewOpen] = useState(false);
const [discountRate, setDiscountRate] = useState(0);
const [discountAmount, setDiscountAmount] = useState(0);
const pendingAutoCalculateRef = useRef(false);
@@ -323,6 +325,11 @@ export function QuoteRegistrationV2({
}));
}, [formData.locations]);
// BOM 결과 유무
const hasBomResult = useMemo(() => {
return formData.locations.some((loc) => loc.bomResult);
}, [formData.locations]);
// ---------------------------------------------------------------------------
// 작성자 자동 설정 (create 모드에서 로그인 사용자 정보 로드)
// ---------------------------------------------------------------------------
@@ -909,6 +916,8 @@ export function QuoteRegistrationV2({
onEdit={onEdit}
onOrderRegister={onOrderRegister}
onDiscount={() => setDiscountModalOpen(true)}
onFormulaView={() => setFormulaViewOpen(true)}
hasBomResult={hasBomResult}
isSaving={isSaving}
isViewMode={isViewMode}
/>
@@ -940,6 +949,13 @@ export function QuoteRegistrationV2({
initialDiscountAmount={discountAmount}
onApply={handleApplyDiscount}
/>
{/* 수식보기 모달 */}
<FormulaViewModal
open={formulaViewOpen}
onOpenChange={setFormulaViewOpen}
locations={formData.locations}
/>
</div>
);
}

View File

@@ -932,6 +932,8 @@ export interface BomCalculationResult {
}>;
subtotals: Record<string, { name?: string; count?: number; subtotal?: number; items?: unknown[] } | number>;
grand_total: number;
variables?: Record<string, unknown>; // 계산된 변수들
debug_steps?: Array<{ step: number; name: string; data: Record<string, unknown> }>; // 10단계 계산 과정
}
// API 서버 응답 구조 (QuoteCalculationService::calculateBomBulk)
@@ -951,7 +953,7 @@ export interface BomBulkResponse {
}>;
}
export async function calculateBomBulk(items: BomCalculateItem[]): Promise<{
export async function calculateBomBulk(items: BomCalculateItem[], debug: boolean = true): Promise<{
success: boolean;
data: BomBulkResponse | null;
error?: string;
@@ -960,11 +962,11 @@ export async function calculateBomBulk(items: BomCalculateItem[]): Promise<{
try {
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/quotes/calculate/bom/bulk`;
console.log('[QuoteActions] POST calculate BOM bulk:', { items });
console.log('[QuoteActions] POST calculate BOM bulk:', { items, debug });
const { response, error } = await serverFetch(url, {
method: 'POST',
body: JSON.stringify({ items }),
body: JSON.stringify({ items, debug }),
});
if (error) {
@@ -1023,6 +1025,71 @@ export async function calculateBomBulk(items: BomCalculateItem[]): Promise<{
}
}
// ===== 품목 단가 조회 =====
export interface ItemPriceResult {
item_code: string;
unit_price: number;
}
export async function getItemPrices(itemCodes: string[]): Promise<{
success: boolean;
data: Record<string, ItemPriceResult> | null;
error?: string;
__authError?: boolean;
}> {
try {
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/quotes/items/prices`;
console.log('[QuoteActions] POST getItemPrices:', { itemCodes });
const { response, error } = await serverFetch(url, {
method: 'POST',
body: JSON.stringify({ item_codes: itemCodes }),
});
if (error) {
return {
success: false,
data: null,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response) {
return {
success: false,
data: null,
error: '단가 조회에 실패했습니다.',
};
}
const result = await response.json();
console.log('[QuoteActions] POST getItemPrices response:', result);
if (!response.ok || !result.success) {
return {
success: false,
data: null,
error: result.message || '단가 조회에 실패했습니다.',
};
}
return {
success: true,
data: result.data || null,
};
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[QuoteActions] getItemPrices error:', error);
return {
success: false,
data: null,
error: '서버 오류가 발생했습니다.',
};
}
}
// ===== 견적 요약 통계 =====
export async function getQuotesSummary(params?: {
dateFrom?: string;

View File

@@ -116,6 +116,7 @@ export interface CalculationInputItem {
export interface CalculationInputs {
items?: CalculationInputItem[];
bomResults?: BomCalculationResult[]; // BOM 산출 결과 (수동 추가 품목 포함)
}
// BOM 자재 항목 타입 (API 응답)
@@ -411,6 +412,13 @@ export interface BomCalculationResultItem {
is_manual?: boolean; // 수동 추가 품목 여부
}
// BOM 계산 디버그 스텝 타입
export interface BomDebugStep {
step: number;
name: string;
data: Record<string, unknown>;
}
// BOM 계산 결과 타입
export interface BomCalculationResult {
finished_goods: {
@@ -421,6 +429,8 @@ export interface BomCalculationResult {
items: BomCalculationResultItem[];
subtotals: Record<string, { name?: string; count?: number; subtotal?: number } | number>;
grand_total: number;
variables?: Record<string, unknown>; // 계산된 변수들
debug_steps?: BomDebugStep[]; // 디버그 스텝 (개발용)
}
// 견적 산출 결과 타입
@@ -704,6 +714,16 @@ export function transformV2ToApi(
.map(loc => loc.bomResult)
.filter((br): br is BomCalculationResult => br !== undefined);
// DEBUG: bomResults 수집 상태 확인
console.log('[transformV2ToApi] locations 수:', data.locations.length);
console.log('[transformV2ToApi] locations bomResult 현황:', data.locations.map(loc => ({
id: loc.id,
hasBomResult: !!loc.bomResult,
bomItemCount: loc.bomResult?.items?.length || 0,
hasManualItems: loc.bomResult?.items?.some(item => (item as BomCalculationResultItem & { is_manual?: boolean }).is_manual) || false,
})));
console.log('[transformV2ToApi] 수집된 bomResults 수:', collectedBomResults.length);
const calculationInputs: CalculationInputs & { bomResults?: BomCalculationResult[] } = {
items: data.locations.map(loc => ({
productCategory: 'screen', // TODO: 동적으로 결정
@@ -868,6 +888,16 @@ export function transformApiToV2(apiData: QuoteApiData): QuoteFormDataV2 {
// BOM 결과 복원 (저장 시 calculation_inputs.bomResults에 저장됨)
const savedBomResults = (calculationInputs as { bomResults?: BomCalculationResult[] } | undefined)?.bomResults || [];
// DEBUG: 로딩 시 bomResults 확인
console.log('[transformApiToV2] rawCalcInputs 존재:', !!rawCalcInputs);
console.log('[transformApiToV2] transformedCalcInputs 존재:', !!transformedCalcInputs);
console.log('[transformApiToV2] calculationInputs.bomResults 존재:', !!(calculationInputs as { bomResults?: BomCalculationResult[] })?.bomResults);
console.log('[transformApiToV2] savedBomResults 수:', savedBomResults.length);
if (savedBomResults.length > 0) {
console.log('[transformApiToV2] 첫번째 bomResult items 수:', savedBomResults[0]?.items?.length || 0);
console.log('[transformApiToV2] 수동 추가 품목 있음:', savedBomResults.some(br => br.items?.some(item => (item as BomCalculationResultItem & { is_manual?: boolean }).is_manual)));
}
// calculation_inputs에서 locations 복원
let locations: LocationItem[] = [];