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