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

@@ -1,61 +1,29 @@
'use client';
import { Package, Save, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Package } from 'lucide-react';
export interface FormHeaderProps {
mode: 'create' | 'edit';
selectedItemType: string;
isSubmitting: boolean;
onCancel: () => void;
}
/**
* 헤더 컴포넌트 - 기존 FormHeader와 동일한 디자인
* 헤더 컴포넌트 - 타이틀만 표시 (버튼은 하단 sticky로 이동)
*/
export function FormHeader({
mode,
selectedItemType,
isSubmitting,
onCancel,
}: FormHeaderProps) {
return (
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-start gap-3">
<div className="p-2 bg-primary/10 rounded-lg hidden md:block">
<Package className="w-6 h-6 text-primary" />
</div>
<div>
<h1 className="text-xl md:text-2xl">
{mode === 'create' ? '품목 등록' : '품목 수정'}
</h1>
<p className="text-sm text-muted-foreground mt-1">
</p>
</div>
<div className="flex items-start gap-3">
<div className="p-2 bg-primary/10 rounded-lg hidden md:block">
<Package className="w-6 h-6 text-primary" />
</div>
<div className="flex gap-1 sm:gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={onCancel}
className="gap-1 sm:gap-2"
disabled={isSubmitting}
>
<X className="h-4 w-4" />
<span className="hidden sm:inline"></span>
</Button>
<Button
type="submit"
size="sm"
disabled={!selectedItemType || isSubmitting}
className="gap-1 sm:gap-2"
>
<Save className="h-4 w-4" />
<span className="hidden sm:inline">{isSubmitting ? '저장 중...' : '저장'}</span>
</Button>
<div>
<h1 className="text-xl md:text-2xl">
{mode === 'create' ? '품목 등록' : '품목 수정'}
</h1>
<p className="text-sm text-muted-foreground mt-1">
</p>
</div>
</div>
);

View File

@@ -9,6 +9,9 @@
import { useState, useEffect, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { cn } from '@/lib/utils';
import { useMenuStore } from '@/store/menuStore';
import { Save, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { FormSectionSkeleton } from '@/components/ui/skeleton';
@@ -45,6 +48,7 @@ export default function DynamicItemForm({
onSubmit,
}: DynamicItemFormProps) {
const router = useRouter();
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
// 품목 유형 상태 (변경 가능)
const [selectedItemType, setSelectedItemType] = useState<ItemType | ''>(initialItemType || '');
@@ -658,17 +662,12 @@ export default function DynamicItemForm({
: [];
return (
<form onSubmit={handleFormSubmit} className="space-y-6">
<form onSubmit={handleFormSubmit} className="space-y-6 pb-24">
{/* Validation 에러 Alert */}
<ValidationAlert errors={errors} />
{/* 헤더 */}
<FormHeader
mode={mode}
selectedItemType={selectedItemType}
isSubmitting={isSubmitting}
onCancel={() => router.back()}
/>
<FormHeader mode={mode} />
{/* 기본 정보 - 목업과 동일한 레이아웃 (모든 필드 한 줄씩) */}
<Card>
@@ -1045,6 +1044,26 @@ export default function DynamicItemForm({
onCancel={handleCancelDuplicate}
onGoToEdit={handleGoToEditDuplicate}
/>
{/* 하단 액션 버튼 (sticky) */}
<div className={`fixed bottom-6 ${sidebarCollapsed ? 'left-[156px]' : 'left-[316px]'} right-[48px] px-6 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 flex items-center justify-between`}>
<Button
type="button"
variant="outline"
onClick={() => router.back()}
disabled={isSubmitting}
>
<X className="h-4 w-4 mr-2" />
</Button>
<Button
type="submit"
disabled={!selectedItemType || isSubmitting}
>
<Save className="h-4 w-4 mr-2" />
{isSubmitting ? '저장 중...' : '저장'}
</Button>
</div>
</form>
);
}

View File

@@ -29,6 +29,7 @@ import {
import { ArrowLeft, Edit, Package, FileImage, Download, FileText, Check, Calendar } from 'lucide-react';
import { downloadFileById } from '@/lib/utils/fileDownload';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { useMenuStore } from '@/store/menuStore';
interface ItemDetailClientProps {
item: ItemMaster;
@@ -94,39 +95,20 @@ function getStorageUrl(path: string | undefined): string | null {
export default function ItemDetailClient({ item }: ItemDetailClientProps) {
const router = useRouter();
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
return (
<div className="space-y-6">
<div className="space-y-6 pb-24">
{/* 헤더 */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-start gap-3">
<div className="p-2 bg-primary/10 rounded-lg hidden md:block">
<Package className="w-6 h-6 text-primary" />
</div>
<div>
<h1 className="text-xl md:text-2xl"> </h1>
<p className="text-sm text-muted-foreground mt-1">
</p>
</div>
<div className="flex items-start gap-3">
<div className="p-2 bg-primary/10 rounded-lg hidden md:block">
<Package className="w-6 h-6 text-primary" />
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={() => router.back()}
>
<ArrowLeft className="w-4 h-4 mr-2" />
</Button>
<Button
type="button"
onClick={() => router.push(`/production/screen-production/${encodeURIComponent(item.itemCode)}?mode=edit&type=${item.itemType}&id=${item.id}`)}
>
<Edit className="w-4 h-4 mr-2" />
</Button>
<div>
<h1 className="text-xl md:text-2xl"> </h1>
<p className="text-sm text-muted-foreground mt-1">
</p>
</div>
</div>
@@ -632,6 +614,25 @@ export default function ItemDetailClient({ item }: ItemDetailClientProps) {
</CardContent>
</Card>
)}
{/* 하단 액션 버튼 (sticky) */}
<div className={`fixed bottom-6 ${sidebarCollapsed ? 'left-[156px]' : 'left-[316px]'} right-[48px] px-6 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 flex items-center justify-between`}>
<Button
type="button"
variant="outline"
onClick={() => router.back()}
>
<ArrowLeft className="w-4 h-4 mr-2" />
</Button>
<Button
type="button"
onClick={() => router.push(`/production/screen-production/${encodeURIComponent(item.itemCode)}?mode=edit&type=${item.itemType}&id=${item.id}`)}
>
<Edit className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
);
}

View File

@@ -17,8 +17,8 @@ import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { TableRow, TableCell } from '@/components/ui/table';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { Search, Plus, Edit, Trash2, Package, Download, FileDown, Upload } from 'lucide-react';
import { downloadExcel, downloadSelectedExcel, downloadExcelTemplate, parseExcelFile, type ExcelColumn, type TemplateColumn } from '@/lib/utils/excel-download';
import { Search, Plus, Edit, Trash2, Package, FileDown, Upload } from 'lucide-react';
import { downloadExcelTemplate, parseExcelFile, type ExcelColumn, type TemplateColumn } from '@/lib/utils/excel-download';
import { useItemList } from '@/hooks/useItemList';
import { handleApiError } from '@/lib/api/error-handler';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
@@ -253,34 +253,29 @@ export default function ItemListClient() {
{ header: '활성상태', key: 'isActive', width: 10, transform: (v) => v ? '활성' : '비활성' },
];
// 전체 엑셀 다운로드
const handleExcelDownload = () => {
if (items.length === 0) {
alert('다운로드할 데이터가 없습니다.');
return;
}
downloadExcel({
data: items,
columns: excelColumns,
filename: '품목목록',
sheetName: '품목',
});
};
// 선택 항목 엑셀 다운로드
const handleSelectedExcelDownload = (selectedIds: string[]) => {
if (selectedIds.length === 0) {
alert('선택된 항목이 없습니다.');
return;
}
downloadSelectedExcel({
data: items,
columns: excelColumns,
selectedIds,
idField: 'id',
filename: '품목목록_선택',
sheetName: '품목',
});
// API 응답을 ItemMaster 타입으로 변환 (엑셀 다운로드용)
const mapItemResponse = (result: unknown): ItemMaster[] => {
const data = result as { data?: { data?: Record<string, unknown>[] }; };
const rawItems = data.data?.data ?? [];
return rawItems.map((item: Record<string, unknown>) => ({
id: String(item.id ?? item.item_id ?? ''),
itemCode: (item.code ?? item.item_code ?? '') as string,
itemName: (item.name ?? item.item_name ?? '') as string,
itemType: (item.type_code ?? item.item_type ?? '') as ItemMaster['itemType'],
partType: item.part_type as ItemMaster['partType'],
unit: (item.unit ?? '') as string,
specification: (item.specification ?? '') as string,
isActive: item.is_active != null ? Boolean(item.is_active) : !item.deleted_at,
category1: (item.category1 ?? '') as string,
category2: (item.category2 ?? '') as string,
category3: (item.category3 ?? '') as string,
salesPrice: (item.sales_price ?? 0) as number,
purchasePrice: (item.purchase_price ?? 0) as number,
currentRevision: (item.current_revision ?? 0) as number,
isFinal: Boolean(item.is_final ?? false),
createdAt: (item.created_at ?? '') as string,
updatedAt: (item.updated_at ?? '') as string,
}));
};
// 업로드용 템플릿 컬럼 정의
@@ -416,55 +411,31 @@ export default function ItemListClient() {
icon: Plus,
},
// 헤더 액션 (엑셀 다운로드)
headerActions: ({ selectedItems }) => (
<div className="flex items-center gap-2">
{/* 양식 다운로드 버튼 - 추후 활성화
<Button
variant="outline"
size="sm"
onClick={handleTemplateDownload}
className="gap-2"
>
<FileDown className="h-4 w-4" />
양식 다운로드
</Button>
*/}
{/* 양식 업로드 버튼 - 추후 활성화
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
className="gap-2"
>
<Upload className="h-4 w-4" />
양식 업로드
</Button>
*/}
{/* 엑셀 데이터 다운로드 버튼 */}
{selectedItems.size > 0 ? (
<Button
variant="outline"
size="sm"
onClick={() => handleSelectedExcelDownload(Array.from(selectedItems))}
className="gap-2"
>
<Download className="h-4 w-4" />
({selectedItems.size})
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={handleExcelDownload}
className="gap-2"
>
<Download className="h-4 w-4" />
</Button>
)}
</div>
),
// 엑셀 다운로드 설정 (공통 기능)
excelDownload: {
columns: excelColumns,
filename: '품목목록',
sheetName: '품목',
fetchAllUrl: '/api/proxy/items',
fetchAllParams: ({ activeTab }): Record<string, string> => {
// 현재 선택된 타입 필터 적용
if (activeTab && activeTab !== 'all') {
return { type: activeTab };
}
return { group_id: '1' }; // 품목관리 그룹
},
mapResponse: mapItemResponse,
},
// 헤더 액션 (양식 다운로드/업로드 - 추후 활성화)
// headerActions: () => (
// <div className="flex items-center gap-2">
// <Button variant="outline" size="sm" onClick={handleTemplateDownload} className="gap-2">
// <FileDown className="h-4 w-4" />
// 양식 다운로드
// </Button>
// </div>
// ),
// API 액션 (일괄 삭제 포함)
actions: {