fix: [quote] QA 견적 관련 버그 수정
- BOM 탭 순서 통일 (주자재→모터→제어기→절곡품→부자재→검사비→기타) - 스크린+스틸 혼합 등록 차단 밸리데이션 - 저장/확정 분리 (저장=draft, 견적확정=finalized) - 수동 품목 추가 시 기타 탭 병합 + 탭 스크롤 - 필터 셀렉트박스 라벨 접두어 추가 - 수식 모달 하단 여백, tabLabel 중복 제거
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -253,6 +253,7 @@ export interface FetchItemsParams {
|
||||
isActive?: boolean;
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
bom_category?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user