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>
);
}