feat(WEB): 견적 자동산출 UI 개선 - datalist, 탭 카테고리, 스크롤 제거
- 층/부호 필드에 datalist 자동완성 추가 (B3~10F, 기존 부호코드) - 부호 데이터 조회 API 신규 추가 (GET /quotes/reference-data) - 제품코드 Select에 코드만 표시 (중복 제거) - LocationDetailPanel 탭을 subtotals 기반으로 동적 생성 + 기타 탭 - grouped_items 기반 탭별 품목 매핑으로 카테고리 불일치 해소 - QuoteSummaryPanel 상세별 합계 스크롤 제거 - BomCalculationResult 타입에 grouped_items 필드 추가
This commit is contained in:
@@ -3,18 +3,16 @@
|
||||
*
|
||||
* - 제품 정보 (제품명, 오픈사이즈, 제작사이즈, 산출중량, 산출면적, 수량)
|
||||
* - 필수 설정 (가이드레일, 전원, 제어기)
|
||||
* - 탭: 본체(스크린/슬랫), 절곡품-가이드레일, 절곡품-케이스, 절곡품-하단마감재, 모터&제어기, 부자재
|
||||
* - 탭별 품목 테이블 (각 탭마다 다른 컬럼 구조)
|
||||
* - 탭: BOM subtotals 기반 동적 생성 (주자재, 모터, 제어기, 절곡품, 부자재, 검사비, 기타)
|
||||
* - 탭별 품목 테이블 (grouped_items 기반)
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import { Package, Settings, Plus, Trash2, Loader2, Calculator, Save } from "lucide-react";
|
||||
import { getItemCategoryTree, type ItemCategoryNode } from "./actions";
|
||||
import { Package, Plus, Trash2, Loader2, Calculator, Save } from "lucide-react";
|
||||
import { fetchItemPrices } from "@/lib/api/items";
|
||||
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import { NumberInput } from "../ui/number-input";
|
||||
@@ -62,6 +60,13 @@ function createEmptyBomItems(tabs: TabDefinition[]): Record<string, BomCalculati
|
||||
// 상수
|
||||
// =============================================================================
|
||||
|
||||
// 층 옵션 (지하3층 ~ 지상10층)
|
||||
const FLOOR_OPTIONS = [
|
||||
"B3", "B2", "B1",
|
||||
"1F", "2F", "3F", "4F", "5F", "6F", "7F", "8F", "9F", "10F",
|
||||
"R",
|
||||
];
|
||||
|
||||
// 가이드레일 설치 유형
|
||||
const GUIDE_RAIL_TYPES = [
|
||||
{ value: "wall", label: "벽면형" },
|
||||
@@ -83,52 +88,19 @@ const CONTROLLERS = [
|
||||
|
||||
// 탭 인터페이스 정의
|
||||
interface TabDefinition {
|
||||
value: string; // 카테고리 code (예: BODY, BENDING_GUIDE)
|
||||
value: string; // subtotals 키 (예: material, motor, steel)
|
||||
label: string; // 표시 이름
|
||||
categoryId: number; // 카테고리 ID
|
||||
parentCode?: string; // 상위 카테고리 코드
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 트리를 탭 배열로 변환
|
||||
* - 본체, 모터&제어기, 부자재 → 1depth 탭
|
||||
* - 절곡품 → 하위 카테고리를 각각 탭으로 (가이드레일, 케이스, 하단마감재)
|
||||
*/
|
||||
function convertCategoryTreeToTabs(categories: ItemCategoryNode[]): TabDefinition[] {
|
||||
const tabs: TabDefinition[] = [];
|
||||
|
||||
categories.forEach((category) => {
|
||||
if (category.code === "BENDING") {
|
||||
// 절곡품: 하위 카테고리를 탭으로 변환
|
||||
category.children.forEach((subCategory) => {
|
||||
tabs.push({
|
||||
value: subCategory.code,
|
||||
label: `절곡품 - ${subCategory.name}`,
|
||||
categoryId: subCategory.id,
|
||||
parentCode: category.code,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// 본체, 모터&제어기, 부자재: 1depth 탭
|
||||
tabs.push({
|
||||
value: category.code,
|
||||
label: category.name,
|
||||
categoryId: category.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return tabs;
|
||||
}
|
||||
|
||||
// 기본 탭 정의 (API 로딩 전 또는 실패 시 fallback)
|
||||
// 기본 탭 정의 (BOM 계산 전 fallback)
|
||||
const DEFAULT_TABS: TabDefinition[] = [
|
||||
{ value: "BODY", label: "본체", categoryId: 0 },
|
||||
{ value: "BENDING_GUIDE", label: "절곡품 - 가이드레일", categoryId: 0, parentCode: "BENDING" },
|
||||
{ value: "BENDING_CASE", label: "절곡품 - 케이스", categoryId: 0, parentCode: "BENDING" },
|
||||
{ value: "BENDING_BOTTOM", label: "절곡품 - 하단마감재", categoryId: 0, parentCode: "BENDING" },
|
||||
{ value: "MOTOR_CTRL", label: "모터 & 제어기", categoryId: 0 },
|
||||
{ value: "ACCESSORY", label: "부자재", categoryId: 0 },
|
||||
{ value: "material", label: "주자재" },
|
||||
{ value: "motor", label: "모터" },
|
||||
{ value: "controller", label: "제어기" },
|
||||
{ value: "steel", label: "절곡품" },
|
||||
{ value: "parts", label: "부자재" },
|
||||
{ value: "inspection", label: "검사비" },
|
||||
{ value: "etc", label: "기타" },
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
@@ -142,6 +114,7 @@ interface LocationDetailPanelProps {
|
||||
onCalculateLocation?: (locationId: string) => Promise<void>;
|
||||
onSaveItems?: () => void;
|
||||
finishedGoods: FinishedGoods[];
|
||||
locationCodes?: string[];
|
||||
disabled?: boolean;
|
||||
isCalculating?: boolean;
|
||||
}
|
||||
@@ -157,51 +130,49 @@ export function LocationDetailPanel({
|
||||
onCalculateLocation,
|
||||
onSaveItems,
|
||||
finishedGoods,
|
||||
locationCodes = [],
|
||||
disabled = false,
|
||||
isCalculating = false,
|
||||
}: LocationDetailPanelProps) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// 상태
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const [activeTab, setActiveTab] = useState("BODY");
|
||||
const [activeTab, setActiveTab] = useState("material");
|
||||
const [itemSearchOpen, setItemSearchOpen] = useState(false);
|
||||
const [itemCategories, setItemCategories] = useState<ItemCategoryNode[]>([]);
|
||||
const [categoriesLoading, setCategoriesLoading] = useState(true);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 카테고리 로드
|
||||
// 탭 정의 (bomResult.subtotals 기반 동적 생성 + 기타 탭)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
useEffect(() => {
|
||||
async function loadCategories() {
|
||||
try {
|
||||
setCategoriesLoading(true);
|
||||
const result = await getItemCategoryTree();
|
||||
if (result.success && result.data) {
|
||||
setItemCategories(result.data);
|
||||
} else {
|
||||
console.error("[카테고리 로드 실패]", result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[카테고리 로드 에러]", error);
|
||||
} finally {
|
||||
setCategoriesLoading(false);
|
||||
}
|
||||
}
|
||||
loadCategories();
|
||||
}, []);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 탭 정의 (카테고리 기반 동적 생성)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const detailTabs = useMemo(() => {
|
||||
if (itemCategories.length === 0) {
|
||||
const detailTabs = useMemo((): TabDefinition[] => {
|
||||
if (!location?.bomResult?.subtotals) {
|
||||
return DEFAULT_TABS;
|
||||
}
|
||||
return convertCategoryTreeToTabs(itemCategories);
|
||||
}, [itemCategories]);
|
||||
|
||||
const subtotals = location.bomResult.subtotals;
|
||||
const tabs: TabDefinition[] = [];
|
||||
|
||||
Object.entries(subtotals).forEach(([key, value]) => {
|
||||
if (typeof value === "object" && value !== null) {
|
||||
tabs.push({
|
||||
value: key,
|
||||
label: value.name || key,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 기타 탭 추가
|
||||
tabs.push({ value: "etc", label: "기타" });
|
||||
|
||||
return tabs;
|
||||
}, [location?.bomResult?.subtotals]);
|
||||
|
||||
// location 변경 시 activeTab이 유효한 탭인지 확인하고 리셋
|
||||
useEffect(() => {
|
||||
if (detailTabs.length > 0 && !detailTabs.some((t) => t.value === activeTab)) {
|
||||
setActiveTab(detailTabs[0].value);
|
||||
}
|
||||
}, [location?.id, detailTabs]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 계산된 값
|
||||
@@ -213,97 +184,56 @@ export function LocationDetailPanel({
|
||||
return finishedGoods.find((fg) => fg.item_code === location.productCode);
|
||||
}, [location?.productCode, finishedGoods]);
|
||||
|
||||
// BOM 아이템을 탭별로 분류 (카테고리 코드 기반)
|
||||
// BOM 아이템을 탭별로 분류 (grouped_items 기반)
|
||||
const bomItemsByTab = useMemo(() => {
|
||||
// bomResult가 없으면 빈 배열 반환
|
||||
if (!location?.bomResult?.items) {
|
||||
if (!location?.bomResult?.grouped_items) {
|
||||
return createEmptyBomItems(detailTabs);
|
||||
}
|
||||
|
||||
const items = location.bomResult.items;
|
||||
const result: Record<string, typeof items> = {};
|
||||
const groupedItems = location.bomResult.grouped_items;
|
||||
const result: Record<string, BomCalculationResultItem[]> = {};
|
||||
|
||||
// 탭별 빈 배열 초기화
|
||||
detailTabs.forEach((tab) => {
|
||||
result[tab.value] = [];
|
||||
});
|
||||
|
||||
// 카테고리 코드 → 탭 value 매핑 생성
|
||||
// (절곡품 하위 카테고리 매핑 포함)
|
||||
const categoryCodeToTab: Record<string, string> = {};
|
||||
itemCategories.forEach((category) => {
|
||||
if (category.code === "BENDING") {
|
||||
// 절곡품: 하위 카테고리별로 탭 매핑
|
||||
category.children.forEach((subCategory) => {
|
||||
categoryCodeToTab[subCategory.code] = subCategory.code;
|
||||
// 하위의 세부 카테고리도 매핑
|
||||
subCategory.children?.forEach((detailCategory) => {
|
||||
categoryCodeToTab[detailCategory.code] = subCategory.code;
|
||||
});
|
||||
});
|
||||
// grouped_items에서 각 카테고리의 items를 탭에 매핑
|
||||
Object.entries(groupedItems).forEach(([key, group]) => {
|
||||
const items = (group as { items?: unknown[] })?.items || [];
|
||||
if (result[key]) {
|
||||
result[key] = items as BomCalculationResultItem[];
|
||||
} else {
|
||||
// 일반 카테고리
|
||||
categoryCodeToTab[category.code] = category.code;
|
||||
// 하위 카테고리도 상위 탭에 매핑
|
||||
category.children?.forEach((child) => {
|
||||
categoryCodeToTab[child.code] = category.code;
|
||||
});
|
||||
// subtotals에 없는 카테고리 → 기타에 추가
|
||||
result["etc"] = [
|
||||
...(result["etc"] || []),
|
||||
...(items as BomCalculationResultItem[]),
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
items.forEach((item) => {
|
||||
// 1. category_code로 분류 (우선)
|
||||
const categoryCode = (item as { category_code?: string }).category_code?.toUpperCase() || "";
|
||||
const mappedTab = categoryCodeToTab[categoryCode];
|
||||
|
||||
if (mappedTab && result[mappedTab]) {
|
||||
result[mappedTab].push(item);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. process_group_key로 분류 (legacy fallback)
|
||||
const processGroupKey = (item as { process_group_key?: string }).process_group_key?.toUpperCase() || "";
|
||||
|
||||
// 기존 process_group_key → 새 카테고리 코드 매핑
|
||||
const legacyMapping: Record<string, string> = {
|
||||
"SCREEN": "BODY",
|
||||
"ASSEMBLY": "BODY",
|
||||
"BENDING": "BENDING_GUIDE", // 기본적으로 가이드레일에 배치
|
||||
"STEEL": "BENDING_CASE",
|
||||
"ELECTRIC": "BENDING_BOTTOM",
|
||||
"MOTOR": "MOTOR_CTRL",
|
||||
"ACCESSORY": "ACCESSORY",
|
||||
};
|
||||
|
||||
const legacyTab = legacyMapping[processGroupKey];
|
||||
if (legacyTab && result[legacyTab]) {
|
||||
result[legacyTab].push(item);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. process_group (한글명) 기반 분류 (최종 fallback)
|
||||
const processGroup = item.process_group?.toLowerCase() || "";
|
||||
|
||||
if (processGroup.includes("본체") || processGroup.includes("스크린") || processGroup.includes("슬랫") || processGroup.includes("조립")) {
|
||||
result["BODY"]?.push(item);
|
||||
} else if (processGroup.includes("가이드") || processGroup.includes("레일")) {
|
||||
result["BENDING_GUIDE"]?.push(item);
|
||||
} else if (processGroup.includes("케이스") || processGroup.includes("철재")) {
|
||||
result["BENDING_CASE"]?.push(item);
|
||||
} else if (processGroup.includes("하단") || processGroup.includes("마감")) {
|
||||
result["BENDING_BOTTOM"]?.push(item);
|
||||
} else if (processGroup.includes("모터") || processGroup.includes("제어기")) {
|
||||
result["MOTOR_CTRL"]?.push(item);
|
||||
} else if (processGroup.includes("부자재")) {
|
||||
result["ACCESSORY"]?.push(item);
|
||||
// 수동 추가 품목 (is_manual) 중 grouped_items에 없는 것 → 해당 탭 or 기타
|
||||
const allItems = location.bomResult.items || [];
|
||||
const manualItems = allItems.filter(
|
||||
(item: BomCalculationResultItem & { is_manual?: boolean }) => item.is_manual === true
|
||||
);
|
||||
manualItems.forEach((item: BomCalculationResultItem & { category_code?: string }) => {
|
||||
const tabKey = item.category_code || "etc";
|
||||
if (result[tabKey]) {
|
||||
// 이미 grouped_items에서 추가되었는지 확인
|
||||
const exists = result[tabKey].some(
|
||||
(existing) => existing.item_code === item.item_code && existing.item_name === item.item_name
|
||||
);
|
||||
if (!exists) {
|
||||
result[tabKey].push(item);
|
||||
}
|
||||
} else {
|
||||
// 기타 항목은 본체에 포함
|
||||
result["BODY"]?.push(item);
|
||||
result["etc"] = [...(result["etc"] || []), item];
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [location?.bomResult?.items, detailTabs, itemCategories]);
|
||||
}, [location?.bomResult?.grouped_items, location?.bomResult?.items, detailTabs]);
|
||||
|
||||
// 탭별 소계
|
||||
const tabSubtotals = useMemo(() => {
|
||||
@@ -351,20 +281,32 @@ export function LocationDetailPanel({
|
||||
<div>
|
||||
<label className="text-xs text-gray-600">층</label>
|
||||
<Input
|
||||
list="floorOptionListDetail"
|
||||
value={location.floor}
|
||||
onChange={(e) => handleFieldChange("floor", e.target.value)}
|
||||
disabled={disabled}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<datalist id="floorOptionListDetail">
|
||||
{FLOOR_OPTIONS.map((f) => (
|
||||
<option key={f} value={f} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600">부호</label>
|
||||
<Input
|
||||
list="locationCodeListDetail"
|
||||
value={location.code}
|
||||
onChange={(e) => handleFieldChange("code", e.target.value)}
|
||||
disabled={disabled}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<datalist id="locationCodeListDetail">
|
||||
{locationCodes.map((code) => (
|
||||
<option key={code} value={code} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600">가로</label>
|
||||
@@ -403,7 +345,7 @@ export function LocationDetailPanel({
|
||||
<SelectContent>
|
||||
{finishedGoods.map((fg) => (
|
||||
<SelectItem key={fg.item_code} value={fg.item_code}>
|
||||
{fg.item_code} {fg.item_name}
|
||||
{fg.item_code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -550,30 +492,23 @@ export function LocationDetailPanel({
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col h-full">
|
||||
{/* 탭 목록 */}
|
||||
<div className="border-b bg-white overflow-x-auto">
|
||||
{categoriesLoading ? (
|
||||
<div className="flex items-center justify-center py-2 px-4 text-gray-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
<span className="text-sm">카테고리 로딩 중...</span>
|
||||
</div>
|
||||
) : (
|
||||
<TabsList className="w-max min-w-full justify-start rounded-none bg-transparent h-auto p-0">
|
||||
{detailTabs.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-orange-500 data-[state=active]:bg-orange-50 data-[state=active]:text-orange-700 px-4 py-2 text-sm whitespace-nowrap"
|
||||
>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
)}
|
||||
<TabsList className="w-max min-w-full justify-start rounded-none bg-transparent h-auto p-0">
|
||||
{detailTabs.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-orange-500 data-[state=active]:bg-orange-50 data-[state=active]:text-orange-700 px-4 py-2 text-sm whitespace-nowrap"
|
||||
>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* 탭 콘텐츠 */}
|
||||
{detailTabs.map((tab) => {
|
||||
const items = bomItemsByTab[tab.value] || [];
|
||||
const isBendingTab = tab.parentCode === "BENDING";
|
||||
const isBendingTab = tab.value === "steel";
|
||||
|
||||
return (
|
||||
<TabsContent key={tab.value} value={tab.value} className="flex-1 overflow-auto m-0 p-0">
|
||||
|
||||
@@ -41,6 +41,13 @@ import * as XLSX from "xlsx";
|
||||
// 상수
|
||||
// =============================================================================
|
||||
|
||||
// 층 옵션 (지하3층 ~ 지상10층)
|
||||
const FLOOR_OPTIONS = [
|
||||
"B3", "B2", "B1",
|
||||
"1F", "2F", "3F", "4F", "5F", "6F", "7F", "8F", "9F", "10F",
|
||||
"R",
|
||||
];
|
||||
|
||||
// 가이드레일 설치 유형
|
||||
const GUIDE_RAIL_TYPES = [
|
||||
{ value: "wall", label: "벽면형" },
|
||||
@@ -73,6 +80,7 @@ interface LocationListPanelProps {
|
||||
onUpdateLocation: (locationId: string, updates: Partial<LocationItem>) => void;
|
||||
onExcelUpload: (locations: Omit<LocationItem, "id">[]) => void;
|
||||
finishedGoods: FinishedGoods[];
|
||||
locationCodes?: string[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -89,6 +97,7 @@ export function LocationListPanel({
|
||||
onUpdateLocation,
|
||||
onExcelUpload,
|
||||
finishedGoods,
|
||||
locationCodes = [],
|
||||
disabled = false,
|
||||
}: LocationListPanelProps) {
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -279,20 +288,32 @@ export function LocationListPanel({
|
||||
<div>
|
||||
<label className="text-xs text-gray-600">층</label>
|
||||
<Input
|
||||
placeholder="예: 1층"
|
||||
list="floorOptionList"
|
||||
placeholder="예: 1F"
|
||||
value={formData.floor}
|
||||
onChange={(e) => handleFormChange("floor", e.target.value)}
|
||||
className="h-8 text-sm placeholder:text-gray-300"
|
||||
/>
|
||||
<datalist id="floorOptionList">
|
||||
{FLOOR_OPTIONS.map((f) => (
|
||||
<option key={f} value={f} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600">부호</label>
|
||||
<Input
|
||||
list="locationCodeList"
|
||||
placeholder="예: FSS-01"
|
||||
value={formData.code}
|
||||
onChange={(e) => handleFormChange("code", e.target.value)}
|
||||
className="h-8 text-sm placeholder:text-gray-300"
|
||||
/>
|
||||
<datalist id="locationCodeList">
|
||||
{locationCodes.map((code) => (
|
||||
<option key={code} value={code} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600">가로</label>
|
||||
@@ -324,7 +345,7 @@ export function LocationListPanel({
|
||||
<SelectContent>
|
||||
{finishedGoods.map((fg) => (
|
||||
<SelectItem key={fg.item_code} value={fg.item_code}>
|
||||
{fg.item_code} {fg.item_name}
|
||||
{fg.item_code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -40,7 +40,7 @@ import { FormulaViewModal } from "./FormulaViewModal";
|
||||
import {
|
||||
getFinishedGoods,
|
||||
calculateBomBulk,
|
||||
getSiteNames,
|
||||
getQuoteReferenceData,
|
||||
type FinishedGoods,
|
||||
type BomCalculationResult,
|
||||
type BomBulkResponse,
|
||||
@@ -194,6 +194,7 @@ export function QuoteRegistrationV2({
|
||||
const [clients, setClients] = useState<Vendor[]>([]);
|
||||
const [finishedGoods, setFinishedGoods] = useState<FinishedGoods[]>([]);
|
||||
const [siteNames, setSiteNames] = useState<string[]>([]);
|
||||
const [locationCodes, setLocationCodes] = useState<string[]>([]);
|
||||
const [isLoadingClients, setIsLoadingClients] = useState(false);
|
||||
const [isLoadingProducts, setIsLoadingProducts] = useState(false);
|
||||
|
||||
@@ -391,11 +392,12 @@ export function QuoteRegistrationV2({
|
||||
setIsLoadingProducts(false);
|
||||
}
|
||||
|
||||
// 현장명 로드
|
||||
// 참조 데이터 로드 (현장명, 부호)
|
||||
try {
|
||||
const result = await getSiteNames();
|
||||
const result = await getQuoteReferenceData();
|
||||
if (result.success) {
|
||||
setSiteNames(result.data);
|
||||
setSiteNames(result.data.siteNames);
|
||||
setLocationCodes(result.data.locationCodes);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
@@ -841,6 +843,7 @@ export function QuoteRegistrationV2({
|
||||
onUpdateLocation={handleUpdateLocation}
|
||||
onExcelUpload={handleExcelUpload}
|
||||
finishedGoods={finishedGoods}
|
||||
locationCodes={locationCodes}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
|
||||
@@ -849,6 +852,7 @@ export function QuoteRegistrationV2({
|
||||
location={selectedLocation}
|
||||
onUpdateLocation={handleUpdateLocation}
|
||||
onDeleteLocation={handleDeleteLocation}
|
||||
locationCodes={locationCodes}
|
||||
onCalculateLocation={async (locationId) => {
|
||||
// 단일 개소 산출
|
||||
const location = formData.locations.find((loc) => loc.id === locationId);
|
||||
|
||||
@@ -211,7 +211,7 @@ export function QuoteSummaryPanel({
|
||||
<p className="text-sm">견적 산출 후 상세 금액이 표시됩니다</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-[300px] overflow-y-auto pr-2">
|
||||
<div className="space-y-3">
|
||||
{detailTotals.map((category, index) => (
|
||||
<div key={index} className="bg-blue-50 rounded-lg overflow-hidden border border-blue-200">
|
||||
{/* 카테고리 헤더 */}
|
||||
|
||||
@@ -1164,50 +1164,84 @@ export async function getQuotesSummary(params?: {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 현장명 목록 조회 (자동완성용) =====
|
||||
// ===== 견적 참조 데이터 조회 (현장명, 부호 목록) =====
|
||||
export interface QuoteReferenceData {
|
||||
siteNames: string[];
|
||||
locationCodes: string[];
|
||||
}
|
||||
|
||||
export async function getQuoteReferenceData(): Promise<{
|
||||
success: boolean;
|
||||
data: QuoteReferenceData;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/quotes/reference-data`;
|
||||
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
data: { siteNames: [], locationCodes: [] },
|
||||
error: error.message,
|
||||
__authError: error.code === 'UNAUTHORIZED',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response?.ok) {
|
||||
return {
|
||||
success: false,
|
||||
data: { siteNames: [], locationCodes: [] },
|
||||
error: `API 오류: ${response?.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
data: { siteNames: [], locationCodes: [] },
|
||||
error: result.message || '참조 데이터 조회 실패',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
siteNames: result.data?.site_names || [],
|
||||
locationCodes: result.data?.location_codes || [],
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[QuoteActions] getQuoteReferenceData error:', error);
|
||||
return {
|
||||
success: false,
|
||||
data: { siteNames: [], locationCodes: [] },
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated getQuoteReferenceData 사용 */
|
||||
export async function getSiteNames(): Promise<{
|
||||
success: boolean;
|
||||
data: string[];
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
// 기존 견적에서 현장명 수집 (중복 제거)
|
||||
const listResult = await getQuotes({
|
||||
perPage: 500,
|
||||
});
|
||||
|
||||
if (!listResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
error: listResult.error,
|
||||
__authError: listResult.__authError,
|
||||
};
|
||||
}
|
||||
|
||||
// 현장명 추출 및 중복 제거
|
||||
const siteNames = Array.from(
|
||||
new Set(
|
||||
listResult.data
|
||||
.map(q => q.siteName)
|
||||
.filter((name): name is string => !!name && name.trim() !== '')
|
||||
)
|
||||
).sort();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: siteNames,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[QuoteActions] getSiteNames error:', error);
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
const result = await getQuoteReferenceData();
|
||||
return {
|
||||
success: result.success,
|
||||
data: result.data.siteNames,
|
||||
error: result.error,
|
||||
__authError: result.__authError,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 품목 카테고리 트리 조회 =====
|
||||
|
||||
@@ -434,6 +434,7 @@ export interface BomCalculationResult {
|
||||
};
|
||||
items: BomCalculationResultItem[];
|
||||
subtotals: Record<string, { name?: string; count?: number; subtotal?: number } | number>;
|
||||
grouped_items?: Record<string, { items: BomCalculationResultItem[]; [key: string]: unknown }>;
|
||||
grand_total: number;
|
||||
variables?: Record<string, unknown>; // 계산된 변수들
|
||||
debug_steps?: BomDebugStep[]; // 디버그 스텝 (개발용)
|
||||
|
||||
Reference in New Issue
Block a user