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

@@ -9,7 +9,7 @@
"use client";
import { useState, useCallback } from "react";
import { Plus, Upload, Download, Trash2, Pencil } from "lucide-react";
import { Plus, Upload, Download } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../ui/button";
@@ -32,7 +32,6 @@ import {
TableRow,
} from "../ui/table";
import { DeleteConfirmDialog } from "../ui/confirm-dialog";
import { LocationEditModal } from "./LocationEditModal";
import type { LocationItem } from "./QuoteRegistrationV2";
import type { FinishedGoods } from "./actions";
@@ -112,9 +111,6 @@ export function LocationListPanel({
// 삭제 확인 다이얼로그
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
// 수정 모달
const [editTarget, setEditTarget] = useState<LocationItem | null>(null);
// ---------------------------------------------------------------------------
// 핸들러
// ---------------------------------------------------------------------------
@@ -275,162 +271,49 @@ export function LocationListPanel({
return (
<div className="border-r border-gray-200 flex flex-col">
{/* 헤더 */}
<div className="bg-blue-100 px-4 py-3 border-b border-blue-200">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-blue-800">
📋 ({locations.length})
</h3>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
onClick={handleDownloadTemplate}
disabled={disabled}
className="text-xs"
>
<Download className="h-3 w-3 mr-1" />
</Button>
<label>
<input
type="file"
accept=".xlsx,.xls"
onChange={handleFileUpload}
disabled={disabled}
className="hidden"
/>
<Button
variant="outline"
size="sm"
disabled={disabled}
className="text-xs"
asChild
>
<span>
<Upload className="h-3 w-3 mr-1" />
</span>
</Button>
</label>
</div>
</div>
</div>
{/* 개소 목록 테이블 */}
<div className="flex-1 overflow-auto">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="w-[60px] text-center"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[100px] text-center"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[50px] text-center"></TableHead>
<TableHead className="w-[40px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{locations.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-gray-500 py-8">
</TableCell>
</TableRow>
) : (
locations.map((loc) => (
<TableRow
key={loc.id}
className={`cursor-pointer hover:bg-blue-50 ${
selectedLocationId === loc.id ? "bg-blue-100" : ""
}`}
onClick={() => onSelectLocation(loc.id)}
>
<TableCell className="text-center font-medium">{loc.floor}</TableCell>
<TableCell className="text-center">{loc.code}</TableCell>
<TableCell className="text-center text-sm">
{loc.openWidth}×{loc.openHeight}
</TableCell>
<TableCell className="text-center text-sm">{loc.productCode}</TableCell>
<TableCell className="text-center">{loc.quantity}</TableCell>
<TableCell className="text-center">
{!disabled && (
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-gray-500 hover:text-blue-600"
onClick={(e) => {
e.stopPropagation();
setEditTarget(loc);
}}
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-red-500 hover:text-red-600"
onClick={(e) => {
e.stopPropagation();
setDeleteTarget(loc.id);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* 추가 폼 */}
{/* ① 입력 영역 (상단으로 이동) */}
{!disabled && (
<div className="border-t border-blue-200 bg-blue-50 p-4 space-y-3">
{/* 1행: 층, 부호, 가로, 세로, 제품, 수량 */}
<div className="bg-gray-50 p-4 space-y-3 border-b border-gray-200">
{/* 1행: 층, 부호, 가로, 세로, 제품코드, 수량 */}
<div className="grid grid-cols-6 gap-2">
<div>
<label className="text-xs text-gray-600"></label>
<Input
placeholder="1층"
placeholder="예: 1층"
value={formData.floor}
onChange={(e) => handleFormChange("floor", e.target.value)}
className="h-8 text-sm"
className="h-8 text-sm placeholder:text-gray-300"
/>
</div>
<div>
<label className="text-xs text-gray-600"></label>
<Input
placeholder="FSS-01"
placeholder="예: FSS-01"
value={formData.code}
onChange={(e) => handleFormChange("code", e.target.value)}
className="h-8 text-sm"
className="h-8 text-sm placeholder:text-gray-300"
/>
</div>
<div>
<label className="text-xs text-gray-600"></label>
<NumberInput
placeholder="5000"
placeholder="예: 5000"
value={formData.openWidth ? Number(formData.openWidth) : undefined}
onChange={(value) => handleFormChange("openWidth", value?.toString() ?? "")}
className="h-8 text-sm"
className="h-8 text-sm placeholder:text-gray-300"
/>
</div>
<div>
<label className="text-xs text-gray-600"></label>
<NumberInput
placeholder="3000"
placeholder="예: 3000"
value={formData.openHeight ? Number(formData.openHeight) : undefined}
onChange={(value) => handleFormChange("openHeight", value?.toString() ?? "")}
className="h-8 text-sm"
className="h-8 text-sm placeholder:text-gray-300"
/>
</div>
<div>
<label className="text-xs text-gray-600"></label>
<label className="text-xs text-gray-600"></label>
<Select
value={formData.productCode}
onValueChange={(value) => handleFormChange("productCode", value)}
@@ -441,7 +324,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>
@@ -458,7 +341,7 @@ export function LocationListPanel({
</div>
</div>
{/* 2행: 가이드레일, 전원, 제어기, 버튼 */}
{/* 2행: 가이드레일, 전원, 제어기, 추가 버튼 */}
<div className="flex items-end gap-2">
<div className="flex-1">
<label className="text-xs text-gray-600 flex items-center gap-1">
@@ -522,7 +405,7 @@ export function LocationListPanel({
</div>
<Button
onClick={handleAdd}
className="h-8 bg-green-500 hover:bg-green-600"
className="h-8 bg-green-500 hover:bg-green-600 px-4"
>
<Plus className="h-4 w-4" />
</Button>
@@ -530,6 +413,88 @@ export function LocationListPanel({
</div>
)}
{/* 발주 개소 목록 헤더 */}
<div className="bg-blue-100 px-4 py-3 border-b border-blue-200">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-blue-800">
📋 ({locations.length})
</h3>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
onClick={handleDownloadTemplate}
disabled={disabled}
className="text-xs"
>
<Download className="h-3 w-3 mr-1" />
</Button>
<label>
<input
type="file"
accept=".xlsx,.xls"
onChange={handleFileUpload}
disabled={disabled}
className="hidden"
/>
<Button
variant="outline"
size="sm"
disabled={disabled}
className="text-xs"
asChild
>
<span>
<Upload className="h-3 w-3 mr-1" />
</span>
</Button>
</label>
</div>
</div>
</div>
{/* 개소 목록 테이블 (간소화: 부호, 사이즈만) */}
<div className="flex-1 overflow-auto">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="w-[100px] text-center"></TableHead>
<TableHead className="text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{locations.length === 0 ? (
<TableRow>
<TableCell colSpan={2} className="text-center text-gray-500 py-8">
</TableCell>
</TableRow>
) : (
locations.map((loc) => (
<TableRow
key={loc.id}
className={`cursor-pointer hover:bg-blue-50 ${
selectedLocationId === loc.id ? "bg-blue-100 border-l-4 border-l-blue-500" : ""
}`}
onClick={() => onSelectLocation(loc.id)}
>
<TableCell className="text-center">
<div className="font-medium text-blue-700">{loc.code}</div>
<div className="text-xs text-gray-500">{loc.productCode}</div>
</TableCell>
<TableCell className="text-center">
<div className="font-medium">{loc.openWidth}X{loc.openHeight}</div>
<div className="text-xs text-gray-500">{loc.floor} · {loc.quantity}</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* 삭제 확인 다이얼로그 */}
<DeleteConfirmDialog
open={!!deleteTarget}
@@ -543,18 +508,6 @@ export function LocationListPanel({
title="개소 삭제"
description="선택한 개소를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
/>
{/* 개소 정보 수정 모달 */}
<LocationEditModal
open={!!editTarget}
onOpenChange={(open) => !open && setEditTarget(null)}
location={editTarget}
onSave={(locationId, updates) => {
onUpdateLocation(locationId, updates);
setEditTarget(null);
toast.success("개소 정보가 수정되었습니다.");
}}
/>
</div>
);
}