fix: [quote] QA 견적 관련 버그 수정

- BOM 탭 순서 통일 (주자재→모터→제어기→절곡품→부자재→검사비→기타)
- 스크린+스틸 혼합 등록 차단 밸리데이션
- 저장/확정 분리 (저장=draft, 견적확정=finalized)
- 수동 품목 추가 시 기타 탭 병합 + 탭 스크롤
- 필터 셀렉트박스 라벨 접두어 추가
- 수식 모달 하단 여백, tabLabel 중복 제거
This commit is contained in:
2026-03-17 13:51:18 +09:00
parent 9b6f4c6684
commit 704ea3c02d
9 changed files with 127 additions and 44 deletions

View File

@@ -109,7 +109,7 @@ function LocationDetail({ location }: { location: LocationItem }) {
const debugSteps = bom.debug_steps || [];
return (
<div className="space-y-2">
<div className="space-y-2 pb-6">
{/* 10단계 계산 과정 */}
{debugSteps.length > 0 ? (
<div className="space-y-1">

View File

@@ -22,6 +22,8 @@ interface ItemSearchModalProps {
tabLabel?: string;
/** 품목 유형 필터 (예: 'RM', 'SF', 'FG') */
itemType?: string;
/** BOM 카테고리 필터 (material, motor, controller, steel, parts, inspection) */
bomCategory?: string;
}
// 검색어 유효성: 영문, 한글, 숫자 1자 이상
@@ -40,15 +42,17 @@ export function ItemSearchModal({
onSelectItem,
tabLabel,
itemType,
bomCategory,
}: ItemSearchModalProps) {
const handleFetchData = useCallback(async (query: string) => {
const data = await fetchItems({
search: query || undefined,
itemType: itemType as ItemType | undefined,
bom_category: bomCategory || undefined,
per_page: 50,
});
return data;
}, [itemType]);
}, [itemType, bomCategory]);
const handleSelect = useCallback((item: ItemMaster) => {
onSelectItem({

View File

@@ -38,6 +38,7 @@ import { ItemSearchModal } from "./ItemSearchModal";
import type { LocationItem } from "./QuoteRegistration";
import type { FinishedGoods } from "./actions";
import type { BomCalculationResultItem } from "./types";
import { BOM_CATEGORY_ORDER, BOM_CATEGORY_LABELS } from "./types";
// 납품길이 옵션
const DELIVERY_LENGTH_OPTIONS = [
@@ -161,16 +162,34 @@ export function LocationDetailPanel({
const subtotals = location.bomResult.subtotals;
const tabs: TabDefinition[] = [];
const remaining = new Set(Object.keys(subtotals));
Object.entries(subtotals).forEach(([key, value]) => {
// 고정 순서대로 탭 추가
for (const key of BOM_CATEGORY_ORDER) {
if (remaining.has(key)) {
const value = subtotals[key];
if (typeof value === "object" && value !== null) {
const obj = value as { name?: string };
tabs.push({
value: key,
label: BOM_CATEGORY_LABELS[key] || obj.name || key,
});
}
remaining.delete(key);
}
}
// 미정의 카테고리 뒤에 추가
for (const key of remaining) {
const value = subtotals[key];
if (typeof value === "object" && value !== null) {
const obj = value as { name?: string };
tabs.push({
value: key,
label: obj.name || key,
label: BOM_CATEGORY_LABELS[key] || obj.name || key,
});
}
});
}
// 기타 탭 추가
tabs.push({ value: "etc", label: "기타" });
@@ -751,6 +770,8 @@ export function LocationDetailPanel({
<ItemSearchModal
open={itemSearchOpen}
onOpenChange={setItemSearchOpen}
tabLabel={detailTabs.find((t) => t.value === activeTab)?.label}
bomCategory={activeTab !== "etc" ? activeTab : undefined}
onSelectItem={async (item) => {
if (!location) return;
@@ -834,7 +855,6 @@ export function LocationDetailPanel({
totalPrice: updatedGrandTotal * location.quantity,
});
}}
tabLabel={detailTabs.find((t) => t.value === activeTab)?.label}
/>
</div>
);

View File

@@ -155,9 +155,31 @@ export function LocationListPanel({
const product = finishedGoods.find((fg) => fg.item_code === formData.productCode);
// 층/부호 자동 채움: 비어있으면 마지막 개소 기준으로 복제 스타일 적용
let autoFloor = formData.floor || "-";
let autoCode = formData.code || "-";
if (locations.length > 0 && !formData.floor && !formData.code) {
const lastLoc = locations[locations.length - 1];
autoFloor = lastLoc.floor || "-";
// 부호 +1: 접두어 + 숫자 패턴 분석
const codeMatch = lastLoc.code.match(/^(.*?)(\d+)$/);
if (codeMatch) {
const prefix = codeMatch[1];
const numLength = codeMatch[2].length;
let maxNum = 0;
locations.forEach((loc) => {
const m = loc.code.match(new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\d+)$`));
if (m) maxNum = Math.max(maxNum, parseInt(m[1], 10));
});
autoCode = prefix + String(maxNum + 1).padStart(numLength, "0");
} else {
autoCode = lastLoc.code || "-";
}
}
const newLocation: Omit<LocationItem, "id"> = {
floor: formData.floor || "-",
code: formData.code || "-",
floor: autoFloor,
code: autoCode,
openWidth: parseFloat(formData.openWidth) || 0,
openHeight: parseFloat(formData.openHeight) || 0,
productCode: formData.productCode,
@@ -175,14 +197,11 @@ export function LocationListPanel({
const success = await onAddLocation(newLocation);
if (success) {
// 폼 초기화 (일부 필드 유지)
// 폼 초기화 (층/부호만 초기화, 나머지 설정값 유지)
setFormData((prev) => ({
...prev,
floor: "",
code: "",
openWidth: "",
openHeight: "",
quantity: "1",
}));
}
}, [formData, finishedGoods, onAddLocation]);
@@ -361,11 +380,19 @@ export function LocationListPanel({
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{finishedGoods.map((fg) => (
<SelectItem key={fg.item_code} value={fg.item_code}>
{fg.item_code}
</SelectItem>
))}
{(() => {
// 기존 개소가 있으면 동일 모델 제품만 표시 (예: KSS01 → KSS01 변형만)
const existingCode = locations.length > 0 ? locations[0].productCode : null;
const existingModel = existingCode?.split('-')[1] ?? null; // FG-KSS01-... → KSS01
const filtered = existingModel
? finishedGoods.filter((fg) => fg.item_code.split('-')[1] === existingModel)
: finishedGoods;
return filtered.map((fg) => (
<SelectItem key={fg.item_code} value={fg.item_code}>
{fg.item_code}
</SelectItem>
));
})()}
</SelectContent>
</Select>
</div>

View File

@@ -39,6 +39,10 @@ interface QuoteFooterBarProps {
onOrderView?: () => void;
/** 연결된 수주 ID (있으면 수주 보기, 없으면 수주등록) */
orderId?: number | null;
/** 수정 가능 여부 (생산지시 존재 시 false) */
isEditable?: boolean;
/** 연결된 수주에 생산지시 존재 여부 */
hasWorkOrders?: boolean;
/** 할인하기 */
onDiscount?: () => void;
/** 수식보기 */
@@ -68,6 +72,8 @@ export function QuoteFooterBar({
onOrderRegister,
onOrderView,
orderId,
isEditable = true,
hasWorkOrders = false,
onDiscount,
onFormulaView,
hasBomResult = false,
@@ -139,8 +145,8 @@ export function QuoteFooterBar({
</Button>
)}
{/* 수정 - view 모드에서만 표시, 수주 등록된 경우 숨김 */}
{isViewMode && onEdit && !orderId && (
{/* 수정 - view 모드에서만 표시, 생산지시 존재 시 숨김 */}
{isViewMode && onEdit && isEditable && (
<Button
onClick={onEdit}
variant="outline"
@@ -152,11 +158,11 @@ export function QuoteFooterBar({
</Button>
)}
{/* 할인하기 - view 모드 또는 수주 등록된 경우 비활성 */}
{/* 할인하기 - view 모드 또는 수정 불가 시 비활성 */}
{onDiscount && (
<Button
onClick={onDiscount}
disabled={isViewMode || !!orderId}
disabled={isViewMode || !isEditable}
variant="outline"
size="sm"
className="border-orange-300 text-orange-600 hover:bg-orange-50 md:size-default md:px-6"
@@ -166,34 +172,28 @@ export function QuoteFooterBar({
</Button>
)}
{/* 저장 버튼 - edit 모드에서만 표시 */}
{/* final/converted 상태면 "저장", 그 외는 "임시저장" */}
{/* 저장 버튼 - edit 모드에서만 표시 (draft 유지, 확정하지 않음) */}
{!isViewMode && (
<Button
onClick={onSave}
disabled={isSaving}
disabled={isSaving || !isEditable}
size="sm"
className={status === "final" || status === "converted"
? "bg-blue-600 hover:bg-blue-700 text-white md:size-default md:px-6"
: "bg-slate-500 hover:bg-slate-600 text-white md:size-default md:px-6"
}
className="bg-slate-500 hover:bg-slate-600 text-white md:size-default md:px-6"
>
{isSaving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4 md:mr-2" />
)}
<span className="hidden md:inline">
{status === "final" || status === "converted" ? "저장" : "임시저장"}
</span>
<span className="hidden md:inline"></span>
</Button>
)}
{/* 견적확정 - final 상태가 아닐 때 표시 (view/edit 모두) */}
{status !== "final" && (
{/* 견적확정 - draft 상태에서만 표시 (final/converted 제외) */}
{status !== "final" && status !== "converted" && (
<Button
onClick={onFinalize}
disabled={isSaving || totalAmount === 0}
disabled={isSaving || totalAmount === 0 || !isEditable}
size="sm"
className="bg-blue-600 hover:bg-blue-700 text-white md:size-default md:px-6"
>
@@ -202,7 +202,7 @@ export function QuoteFooterBar({
) : (
<Check className="h-4 w-4 md:mr-2" />
)}
<span className="hidden md:inline">{isViewMode ? "견적확정" : "견적완료"}</span>
<span className="hidden md:inline"></span>
</Button>
)}

View File

@@ -315,10 +315,9 @@ export function QuoteManagementClient({
<SelectValue placeholder="제품분류" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="STEEL"></SelectItem>
<SelectItem value="SCREEN"></SelectItem>
<SelectItem value="MIXED"></SelectItem>
<SelectItem value="all">제품분류: </SelectItem>
<SelectItem value="STEEL">제품분류: </SelectItem>
<SelectItem value="SCREEN">제품분류: 스크</SelectItem>
</SelectContent>
</Select>
@@ -328,10 +327,10 @@ export function QuoteManagementClient({
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="initial"></SelectItem>
<SelectItem value="revising">N차수정</SelectItem>
<SelectItem value="final"></SelectItem>
<SelectItem value="all">상태: </SelectItem>
<SelectItem value="initial">상태: 최초작</SelectItem>
<SelectItem value="revising">상태: N차수정</SelectItem>
<SelectItem value="final">상태: 견적완</SelectItem>
</SelectContent>
</Select>
</div>

View File

@@ -101,6 +101,8 @@ export interface QuoteFormDataV2 {
discountAmount: number; // 할인 금액
locations: LocationItem[];
orderId?: number | null; // 연결된 수주 ID (수주전환 시 설정)
isEditable?: boolean; // 수정 가능 여부 (생산지시 존재 시 false)
hasWorkOrders?: boolean; // 연결된 수주에 생산지시 존재 여부
}
// =============================================================================
@@ -380,7 +382,7 @@ export function QuoteRegistration({
// 거래처 로드
setIsLoadingClients(true);
try {
const result = await getClients();
const result = await getClients({ size: 200, only_active: true });
if (result.success) {
setClients(result.data);
}
@@ -453,6 +455,16 @@ export function QuoteRegistration({
return false;
}
// 동일 모델만 등록 가능 (예: KSS01 → KSS01 변형만)
if (formData.locations.length > 0) {
const existingModel = formData.locations[0].productCode?.split('-')[1];
const newModel = newLocation.productCode?.split('-')[1];
if (existingModel && newModel && existingModel !== newModel) {
toast.error(`동일 모델만 등록 가능합니다. 현재 모델: ${existingModel}`);
return false;
}
}
// 먼저 BOM 계산 API 호출
try {
const bomItem = {
@@ -984,6 +996,8 @@ export function QuoteRegistration({
onOrderRegister={onOrderRegister}
onOrderView={onOrderView}
orderId={formData.orderId}
isEditable={formData.isEditable}
hasWorkOrders={formData.hasWorkOrders}
onDiscount={() => setDiscountModalOpen(true)}
onFormulaView={() => setFormulaViewOpen(true)}
hasBomResult={hasBomResult}

View File

@@ -45,6 +45,19 @@ export const PRODUCT_CATEGORY_LABELS: Record<string, string> = {
steel: '철재',
};
// ===== BOM 카테고리 순서 (고정) =====
// 주자재 → 모터 → 제어기 → 절곡품 → 부자재 → 검사비 → 기타
export const BOM_CATEGORY_ORDER = ['material', 'motor', 'controller', 'steel', 'parts', 'inspection'];
export const BOM_CATEGORY_LABELS: Record<string, string> = {
material: '주자재',
motor: '모터',
controller: '제어기',
steel: '절곡품',
parts: '부자재',
inspection: '검사비',
};
/** item_category(한글) → product_category 변환 (DB는 대문자) */
export function itemCategoryToProductCategory(itemCategory?: string): ProductCategory {
if (itemCategory === '철재') return 'STEEL';
@@ -725,6 +738,8 @@ export interface QuoteFormDataV2 {
discountAmount: number; // 할인 금액
locations: LocationItem[];
orderId?: number | null; // 연결된 수주 ID (수주전환 시 설정)
isEditable?: boolean; // 수정 가능 여부 (생산지시 존재 시 false)
hasWorkOrders?: boolean; // 연결된 수주에 생산지시 존재 여부
}
// =============================================================================
@@ -1032,6 +1047,9 @@ export function transformApiToV2(apiData: QuoteApiData): QuoteFormDataV2 {
locations: locations,
// 연결된 수주 ID (raw API: order_id, transformed: orderId)
orderId: apiData.order_id ?? transformed.orderId ?? null,
// 수정 가능 여부 (생산지시 존재 시 false)
isEditable: (apiData as unknown as { is_editable?: boolean }).is_editable ?? true,
hasWorkOrders: (apiData as unknown as { has_work_orders?: boolean }).has_work_orders ?? false,
};
}

View File

@@ -253,6 +253,7 @@ export interface FetchItemsParams {
isActive?: boolean;
page?: number;
per_page?: number;
bom_category?: string;
}
/**