feat(WEB): 견적 시스템 개선, 엑셀 다운로드, PDF 생성 기능 추가

견적 시스템:
- QuoteRegistrationV2: 할인 모달, 거래명세서 모달, vatType 필드 추가
- DiscountModal: 할인율/할인금액 상호 계산 모달
- QuoteTransactionModal: 거래명세서 미리보기 모달
- LocationDetailPanel, LocationListPanel 개선

템플릿 기능:
- UniversalListPage: 엑셀 다운로드 기능 추가 (전체/선택 다운로드)
- DocumentViewer: PDF 생성 기능 개선

신규 API:
- /api/pdf/generate: Puppeteer 기반 PDF 생성 엔드포인트

UI 개선:
- 입력 컴포넌트 placeholder 스타일 개선 (opacity 50%)
- 각종 리스트 컴포넌트 정렬/필터링 개선

패키지 추가:
- html2canvas, jspdf, puppeteer, dom-to-image-more

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-01-27 19:49:03 +09:00
parent c4644489e7
commit afd7bda269
35 changed files with 3493 additions and 946 deletions

View File

@@ -10,7 +10,7 @@
"use client";
import { useState, useMemo, useEffect } from "react";
import { Package, Settings, Plus, Trash2, Loader2 } from "lucide-react";
import { Package, Settings, Plus, Trash2, Loader2, Calculator, Save } from "lucide-react";
import { getItemCategoryTree, type ItemCategoryNode } from "./actions";
import { Badge } from "../ui/badge";
@@ -35,7 +35,6 @@ import {
} from "../ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { ItemSearchModal } from "./ItemSearchModal";
import { LocationEditModal } from "./LocationEditModal";
import type { LocationItem } from "./QuoteRegistrationV2";
import type { FinishedGoods } from "./actions";
@@ -138,8 +137,12 @@ const DEFAULT_TABS: TabDefinition[] = [
interface LocationDetailPanelProps {
location: LocationItem | null;
onUpdateLocation: (locationId: string, updates: Partial<LocationItem>) => void;
onDeleteLocation?: (locationId: string) => void;
onCalculateLocation?: (locationId: string) => Promise<void>;
onSaveItems?: () => void;
finishedGoods: FinishedGoods[];
disabled?: boolean;
isCalculating?: boolean;
}
// =============================================================================
@@ -149,8 +152,12 @@ interface LocationDetailPanelProps {
export function LocationDetailPanel({
location,
onUpdateLocation,
onDeleteLocation,
onCalculateLocation,
onSaveItems,
finishedGoods,
disabled = false,
isCalculating = false,
}: LocationDetailPanelProps) {
// ---------------------------------------------------------------------------
// 상태
@@ -158,7 +165,6 @@ export function LocationDetailPanel({
const [activeTab, setActiveTab] = useState("BODY");
const [itemSearchOpen, setItemSearchOpen] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [itemCategories, setItemCategories] = useState<ItemCategoryNode[]>([]);
const [categoriesLoading, setCategoriesLoading] = useState(true);
@@ -336,125 +342,184 @@ export function LocationDetailPanel({
return (
<div className="flex flex-col h-full overflow-hidden">
{/* 헤더 */}
<div className="bg-white px-4 py-3 border-b">
<div className="flex items-center justify-between">
<h3 className="text-lg font-bold">
{location.floor} / {location.code}
</h3>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">:</span>
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">
{location.productCode}
</Badge>
{location.bomResult && (
<Badge variant="default" className="bg-green-600">
</Badge>
)}
</div>
</div>
</div>
{/* 제품 정보 */}
<div className="bg-gray-50 px-4 py-3 border-b space-y-3">
{/* 오픈사이즈 */}
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600 w-20"></span>
<div className="flex items-center gap-2">
<NumberInput
value={location.openWidth}
onChange={(value) => handleFieldChange("openWidth", value ?? 0)}
disabled={disabled}
className="w-24 h-8 text-center font-bold"
/>
<span className="text-gray-400">×</span>
<NumberInput
value={location.openHeight}
onChange={(value) => handleFieldChange("openHeight", value ?? 0)}
disabled={disabled}
className="w-24 h-8 text-center font-bold"
/>
{!disabled && (
<Button
variant="secondary"
size="sm"
className="text-xs h-7"
onClick={() => setEditModalOpen(true)}
{/* ②-1 개소 정보 영역 */}
<div className="bg-gray-50 border-b">
{/* 1행: 층, 부호, 가로, 세로, 제품코드 */}
<div className="px-4 py-3 space-y-3">
<div className="grid grid-cols-5 gap-3">
<div>
<label className="text-xs text-gray-600"></label>
<Input
value={location.floor}
onChange={(e) => handleFieldChange("floor", e.target.value)}
disabled={disabled}
className="h-8 text-sm"
/>
</div>
<div>
<label className="text-xs text-gray-600"></label>
<Input
value={location.code}
onChange={(e) => handleFieldChange("code", e.target.value)}
disabled={disabled}
className="h-8 text-sm"
/>
</div>
<div>
<label className="text-xs text-gray-600"></label>
<NumberInput
value={location.openWidth}
onChange={(value) => handleFieldChange("openWidth", value ?? 0)}
disabled={disabled}
className="h-8 text-sm"
/>
</div>
<div>
<label className="text-xs text-gray-600"></label>
<NumberInput
value={location.openHeight}
onChange={(value) => handleFieldChange("openHeight", value ?? 0)}
disabled={disabled}
className="h-8 text-sm"
/>
</div>
<div>
<label className="text-xs text-gray-600"></label>
<Select
value={location.productCode}
onValueChange={(value) => {
const product = finishedGoods.find((fg) => fg.item_code === value);
onUpdateLocation(location.id, {
productCode: value,
productName: product?.item_name || value,
});
}}
disabled={disabled}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{finishedGoods.map((fg) => (
<SelectItem key={fg.item_code} value={fg.item_code}>
{fg.item_code}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 2행: 가이드레일, 전원, 제어기 */}
<div className="grid grid-cols-3 gap-3">
<div>
<label className="text-xs text-gray-600 flex items-center gap-1">
🔧
</label>
<Select
value={location.guideRailType}
onValueChange={(value) => handleFieldChange("guideRailType", value)}
disabled={disabled}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{GUIDE_RAIL_TYPES.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-xs text-gray-600 flex items-center gap-1">
</label>
<Select
value={location.motorPower}
onValueChange={(value) => handleFieldChange("motorPower", value)}
disabled={disabled}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MOTOR_POWERS.map((power) => (
<SelectItem key={power.value} value={power.value}>
{power.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-xs text-gray-600 flex items-center gap-1">
📦
</label>
<Select
value={location.controller}
onValueChange={(value) => handleFieldChange("controller", value)}
disabled={disabled}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONTROLLERS.map((ctrl) => (
<SelectItem key={ctrl.value} value={ctrl.value}>
{ctrl.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 3행: 제작사이즈, 산출중량, 산출면적, 수량 */}
<div className="grid grid-cols-4 gap-3 text-sm pt-2 border-t border-gray-200">
<div>
<span className="text-xs text-gray-500"></span>
<p className="font-semibold">
{location.manufactureWidth || location.openWidth + 280}X{location.manufactureHeight || location.openHeight + 280}
</p>
</div>
<div>
<span className="text-xs text-gray-500">-</span>
<p className="font-semibold">kg</p>
</div>
<div>
<span className="text-xs text-gray-500">-</span>
<p className="font-semibold">m²</p>
</div>
<div className="flex items-end">
<Button
onClick={() => onCalculateLocation?.(location.id)}
disabled={disabled || isCalculating}
className="w-full h-8 bg-orange-500 hover:bg-orange-600 text-white"
>
{isCalculating ? (
<>
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
...
</>
) : (
<>
<Calculator className="h-4 w-4 mr-1" />
</>
)}
</Button>
)}
</div>
</div>
{/* 제작사이즈, 산출중량, 산출면적, 수량 */}
<div className="grid grid-cols-4 gap-4 text-sm">
<div>
<span className="text-gray-500"></span>
<p className="font-semibold">
{location.manufactureWidth || location.openWidth + 280} × {location.manufactureHeight || location.openHeight + 280}
</p>
</div>
<div>
<span className="text-gray-500"></span>
<p className="font-semibold">{location.weight?.toFixed(1) || "-"} <span className="text-xs text-gray-400">kg</span></p>
</div>
<div>
<span className="text-gray-500"></span>
<p className="font-semibold">{location.area?.toFixed(1) || "-"} <span className="text-xs text-gray-400">m²</span></p>
</div>
<div>
<span className="text-gray-500"></span>
<QuantityInput
value={location.quantity}
onChange={(value) => handleFieldChange("quantity", value ?? 1)}
disabled={disabled}
className="w-24 h-7 text-center font-semibold"
min={1}
/>
</div>
</div>
</div>
</div>
{/* 필수 설정 (읽기 전용) */}
<div className="bg-white px-4 py-3 border-b">
<h4 className="text-sm font-semibold text-gray-700 mb-2 flex items-center gap-1">
<Settings className="h-4 w-4" />
</h4>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="text-xs text-gray-500 flex items-center gap-1 mb-1">
🔧
</label>
<Badge variant="outline" className="bg-gray-50 text-gray-700 border-gray-300 px-3 py-1.5">
{GUIDE_RAIL_TYPES.find(t => t.value === location.guideRailType)?.label || location.guideRailType}
</Badge>
</div>
<div>
<label className="text-xs text-gray-500 flex items-center gap-1 mb-1">
</label>
<Badge variant="outline" className="bg-gray-50 text-gray-700 border-gray-300 px-3 py-1.5">
{MOTOR_POWERS.find(p => p.value === location.motorPower)?.label || location.motorPower}
</Badge>
</div>
<div>
<label className="text-xs text-gray-500 flex items-center gap-1 mb-1">
📦
</label>
<Badge variant="outline" className="bg-gray-50 text-gray-700 border-gray-300 px-3 py-1.5">
{CONTROLLERS.find(c => c.value === location.controller)?.label || location.controller}
</Badge>
</div>
</div>
</div>
{/* 탭 및 품목 테이블 */}
{/* ②-2 품목 상세 영역 */}
<div className="flex-1 overflow-hidden flex flex-col">
<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">
@@ -467,7 +532,7 @@ export function LocationDetailPanel({
<TabsTrigger
key={tab.value}
value={tab.value}
className="rounded-none border-b-2 border-transparent data-[state=active]:border-blue-500 data-[state=active]:bg-blue-50 data-[state=active]:text-blue-700 px-4 py-2 text-sm whitespace-nowrap"
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>
@@ -476,122 +541,95 @@ export function LocationDetailPanel({
)}
</div>
{/* 동적 탭 콘텐츠 렌더링 */}
{/* 탭 콘텐츠 */}
{detailTabs.map((tab) => {
const items = bomItemsByTab[tab.value] || [];
const isBendingTab = tab.parentCode === "BENDING";
const isMotorTab = tab.value === "MOTOR_CTRL";
const isAccessoryTab = tab.value === "ACCESSORY";
return (
<TabsContent key={tab.value} value={tab.value} className="flex-1 overflow-auto m-0 p-0">
<div className="bg-amber-50 border border-amber-200 rounded-lg m-4">
<div className="bg-amber-50 border border-amber-200 rounded-lg m-3">
<Table>
<TableHeader>
<TableRow className="bg-amber-100/50">
<TableHead className="font-semibold"></TableHead>
{/* 본체: 제작사이즈 */}
{!isBendingTab && !isMotorTab && !isAccessoryTab && (
<TableHead className="text-center font-semibold"></TableHead>
)}
{/* 절곡품: 재질, 규격, 납품길이 */}
<TableHead className="text-center font-semibold"></TableHead>
{isBendingTab && (
<>
<TableHead className="text-center font-semibold"></TableHead>
<TableHead className="text-center font-semibold"></TableHead>
<TableHead className="text-center font-semibold w-28"></TableHead>
</>
<TableHead className="text-center font-semibold w-24"></TableHead>
)}
{/* 모터: 유형, 사양 */}
{isMotorTab && (
<>
<TableHead className="text-center font-semibold"></TableHead>
<TableHead className="text-center font-semibold"></TableHead>
</>
)}
{/* 부자재: 규격, 납품길이 */}
{isAccessoryTab && (
<>
<TableHead className="text-center font-semibold"></TableHead>
<TableHead className="text-center font-semibold w-28"></TableHead>
</>
)}
<TableHead className="text-center font-semibold w-24"></TableHead>
<TableHead className="text-center font-semibold w-20"></TableHead>
<TableHead className="text-center font-semibold w-20"></TableHead>
<TableHead className="text-center font-semibold w-12"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item: any, index: number) => (
<TableRow key={item.id || `${tab.value}-${index}`} className="bg-white">
<TableCell className="font-medium">{item.item_name}</TableCell>
{/* 본체: 제작사이즈 */}
{!isBendingTab && !isMotorTab && !isAccessoryTab && (
<TableCell className="text-center text-gray-600">{item.manufacture_size || "-"}</TableCell>
)}
{/* 절곡품: 재질, 규격, 납품길이 */}
{isBendingTab && (
<>
<TableCell className="text-center text-gray-600">{item.material || "-"}</TableCell>
<TableCell className="text-center text-gray-600">{item.spec || "-"}</TableCell>
<TableCell className="text-center">
<Select defaultValue={item.delivery_length} disabled={disabled}>
<SelectTrigger className="w-24 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DELIVERY_LENGTH_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
</>
)}
{/* 모터: 유형, 사양 */}
{isMotorTab && (
<>
<TableCell className="text-center text-gray-600">{item.type || "-"}</TableCell>
<TableCell className="text-center text-gray-600">{item.spec || "-"}</TableCell>
</>
)}
{/* 부자재: 규격, 납품길이 */}
{isAccessoryTab && (
<>
<TableCell className="text-center text-gray-600">{item.spec || "-"}</TableCell>
<TableCell className="text-center">
<Select defaultValue={item.delivery_length} disabled={disabled}>
<SelectTrigger className="w-24 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DELIVERY_LENGTH_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
</>
)}
<TableCell className="text-center">
<QuantityInput
value={item.quantity}
onChange={() => {}}
className="w-16 h-8 text-center"
min={1}
disabled={disabled}
/>
</TableCell>
<TableCell className="text-center">
<Button variant="ghost" size="sm" className="text-red-500 hover:text-red-700 hover:bg-red-50 p-1" disabled={disabled}>
<Trash2 className="h-4 w-4" />
</Button>
{items.length === 0 ? (
<TableRow>
<TableCell colSpan={isBendingTab ? 5 : 4} className="text-center text-gray-400 py-6">
</TableCell>
</TableRow>
))}
) : (
items.map((item: any, index: number) => (
<TableRow key={item.id || `${tab.value}-${index}`} className="bg-white">
<TableCell className="font-medium">{item.item_name}</TableCell>
<TableCell className="text-center text-gray-600">{item.spec || item.specification || "-"}</TableCell>
{isBendingTab && (
<TableCell className="text-center">
<Select defaultValue={item.delivery_length || "4000"} disabled={disabled}>
<SelectTrigger className="w-20 h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DELIVERY_LENGTH_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
)}
<TableCell className="text-center">
<QuantityInput
value={item.quantity}
onChange={() => {}}
className="w-14 h-7 text-center text-xs"
min={1}
disabled={disabled}
/>
</TableCell>
<TableCell className="text-center">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-gray-400 hover:text-red-500 hover:bg-red-50"
disabled={disabled}
onClick={() => {
if (!location) return;
// 품목 삭제 로직
const existingBomResult = location.bomResult;
if (!existingBomResult) return;
const updatedItems = (existingBomResult.items || []).filter(
(_: any, i: number) => !(bomItemsByTab[tab.value]?.[index] === _)
);
onUpdateLocation(location.id, {
bomResult: {
...existingBomResult,
items: updatedItems,
},
});
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
{/* 품목 추가 버튼 + 안내 */}
{/* 품목 추가 + 저장 버튼 */}
<div className="p-3 flex items-center justify-between border-t border-amber-200">
<Button
variant="outline"
@@ -603,9 +641,15 @@ export function LocationDetailPanel({
<Plus className="h-4 w-4 mr-1" />
</Button>
<span className="text-sm text-gray-500 flex items-center gap-1">
💡
</span>
<Button
size="sm"
className="bg-blue-600 hover:bg-blue-700 text-white"
onClick={onSaveItems}
disabled={disabled}
>
<Save className="h-4 w-4 mr-1" />
</Button>
</div>
</div>
</TabsContent>
@@ -614,13 +658,6 @@ export function LocationDetailPanel({
</Tabs>
</div>
{/* 금액 안내 */}
{!location.bomResult && (
<div className="bg-blue-50 px-4 py-2 border-t border-blue-200 text-center text-sm text-blue-700">
💡
</div>
)}
{/* 품목 검색 모달 */}
<ItemSearchModal
open={itemSearchOpen}
@@ -628,12 +665,10 @@ export function LocationDetailPanel({
onSelectItem={(item) => {
if (!location) return;
// 현재 탭 정보 가져오기
const currentTab = detailTabs.find((t) => t.value === activeTab);
const categoryCode = activeTab; // 카테고리 코드를 직접 사용
const categoryCode = activeTab;
const categoryLabel = currentTab?.label || activeTab;
// 새 품목 생성 (카테고리 코드 포함)
const newItem: BomCalculationResultItem & { category_code?: string; is_manual?: boolean } = {
item_code: item.code,
item_name: item.name,
@@ -643,11 +678,10 @@ export function LocationDetailPanel({
unit_price: 0,
total_price: 0,
process_group: categoryLabel,
category_code: categoryCode, // 새 카테고리 코드 사용
is_manual: true, // 수동 추가 품목 표시
category_code: categoryCode,
is_manual: true,
};
// 기존 bomResult 가져오기
const existingBomResult = location.bomResult || {
finished_goods: { code: location.productCode || "", name: location.productName || "" },
subtotals: {},
@@ -656,11 +690,9 @@ export function LocationDetailPanel({
items: [],
};
// 기존 items에 새 아이템 추가
const existingItems = existingBomResult.items || [];
const updatedItems = [...existingItems, newItem];
// subtotals 업데이트 (해당 카테고리의 count, subtotal 증가)
const existingSubtotals = existingBomResult.subtotals || {};
const rawCategorySubtotal = existingSubtotals[categoryCode];
const categorySubtotal = (typeof rawCategorySubtotal === 'object' && rawCategorySubtotal !== null)
@@ -675,7 +707,6 @@ export function LocationDetailPanel({
},
};
// grouped_items 업데이트 (해당 카테고리의 items 배열에 추가)
const existingGroupedItems = existingBomResult.grouped_items || {};
const categoryGroupedItems = existingGroupedItems[categoryCode] || { items: [] };
const updatedGroupedItems = {
@@ -686,7 +717,6 @@ export function LocationDetailPanel({
},
};
// grand_total 업데이트
const updatedGrandTotal = (existingBomResult.grand_total || 0) + (newItem.total_price || 0);
const updatedBomResult = {
@@ -697,23 +727,11 @@ export function LocationDetailPanel({
grand_total: updatedGrandTotal,
};
// location 업데이트
onUpdateLocation(location.id, { bomResult: updatedBomResult });
console.log(`[품목 추가] ${item.code} - ${item.name}${categoryLabel} (${categoryCode})`);
}}
tabLabel={detailTabs.find((t) => t.value === activeTab)?.label}
/>
{/* 개소 정보 수정 모달 */}
<LocationEditModal
open={editModalOpen}
onOpenChange={setEditModalOpen}
location={location}
onSave={(locationId, updates) => {
onUpdateLocation(locationId, updates);
}}
/>
</div>
);
}