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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user