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

1174
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -46,10 +46,14 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"dom-to-image-more": "^3.7.2",
"html2canvas": "^1.4.1",
"immer": "^11.0.1",
"jspdf": "^4.0.0",
"lucide-react": "^0.552.0",
"next": "^15.5.9",
"next-intl": "^4.4.0",
"puppeteer": "^24.36.0",
"react": "^19.2.3",
"react-day-picker": "^9.11.1",
"react-dom": "^19.2.3",

View File

@@ -0,0 +1,180 @@
import { NextRequest, NextResponse } from 'next/server';
import puppeteer from 'puppeteer';
/**
* PDF 생성 API
* POST /api/pdf/generate
*
* Body: {
* html: string,
* styles: string,
* title?: string,
* orientation?: 'portrait' | 'landscape',
* documentNumber?: string,
* createdDate?: string,
* showHeaderFooter?: boolean
* }
* Response: PDF blob
*/
export async function POST(request: NextRequest) {
try {
const {
html,
styles = '',
title = '문서',
orientation = 'portrait',
documentNumber = '',
createdDate = '',
showHeaderFooter = true,
} = await request.json();
if (!html) {
return NextResponse.json(
{ error: 'HTML content is required' },
{ status: 400 }
);
}
// Puppeteer 브라우저 실행
const browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
],
});
const page = await browser.newPage();
// 전체 HTML 문서 구성 (인라인 스타일 포함)
const fullHtml = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title}</title>
<style>
/* 기본 리셋 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 11px;
line-height: 1.4;
color: #000;
background: #fff;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
/* 문서 컨테이너 - A4에 맞게 조정 */
.document-container {
width: 100%;
max-width: 100%;
padding: 0;
}
/* 페이지 나눔 설정 */
table { page-break-inside: auto; }
tr { page-break-inside: avoid; page-break-after: auto; }
thead { display: table-header-group; }
tfoot { display: table-footer-group; }
/* 이미지/SVG 크기 제한 */
img, svg {
max-width: 100%;
height: auto;
}
/* 클라이언트에서 추출한 computed styles */
${styles}
</style>
</head>
<body>
<div class="document-container">
${html}
</div>
</body>
</html>
`;
// 뷰포트 설정 (문서 전체가 보이도록 넓게)
await page.setViewport({
width: 1200,
height: 1600,
deviceScaleFactor: 2,
});
// HTML 설정
await page.setContent(fullHtml, {
waitUntil: 'networkidle0',
});
// 헤더 템플릿 (문서번호, 생성일)
const headerTemplate = showHeaderFooter
? `
<div style="width: 100%; font-size: 9px; font-family: 'Pretendard', sans-serif; padding: 0 15mm;">
<div style="border-bottom: 1px solid #ddd; padding-bottom: 5px; display: flex; justify-content: space-between; color: #666;">
<span>${documentNumber ? `문서번호: ${documentNumber}` : ''}</span>
<span>${createdDate ? `생성일: ${createdDate}` : ''}</span>
</div>
</div>
`
: '';
// 푸터 템플릿 (라인 + 페이지번호)
const footerTemplate = showHeaderFooter
? `
<div style="width: 100%; font-size: 9px; font-family: 'Pretendard', sans-serif; padding: 0 15mm;">
<div style="border-top: 1px solid #ddd; padding-top: 5px; display: flex; justify-content: space-between; color: #666;">
<span>${title}</span>
<span>Page <span class="pageNumber"></span> / <span class="totalPages"></span></span>
</div>
</div>
`
: '';
// PDF 생성 (자동 스케일로 A4에 맞춤)
const pdfBuffer = await page.pdf({
format: 'A4',
landscape: orientation === 'landscape',
printBackground: true,
preferCSSPageSize: false,
scale: 0.75, // 문서를 75%로 축소하여 A4에 맞춤
displayHeaderFooter: showHeaderFooter,
headerTemplate: headerTemplate,
footerTemplate: footerTemplate,
margin: {
top: showHeaderFooter ? '20mm' : '10mm',
right: '10mm',
bottom: showHeaderFooter ? '20mm' : '10mm',
left: '10mm',
},
});
// 브라우저 종료
await browser.close();
// PDF 응답
return new NextResponse(pdfBuffer, {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${encodeURIComponent(title)}.pdf"`,
},
});
} catch (error) {
console.error('PDF 생성 오류:', error);
return NextResponse.json(
{ error: 'PDF 생성 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}

View File

@@ -16,7 +16,7 @@ export const DOCUMENT_PRESETS: Record<PresetType, PresetConfig> = {
print: true,
download: true,
},
actions: ['print', 'download'],
actions: ['pdf', 'print', 'download'],
},
// 건설 프로젝트용 (CRUD)
@@ -27,7 +27,7 @@ export const DOCUMENT_PRESETS: Record<PresetType, PresetConfig> = {
print: true,
download: false,
},
actions: ['edit', 'delete', 'print'],
actions: ['edit', 'delete', 'pdf', 'print'],
},
// 결재 문서용 (기본)
@@ -38,7 +38,7 @@ export const DOCUMENT_PRESETS: Record<PresetType, PresetConfig> = {
print: true,
download: false,
},
actions: ['edit', 'submit', 'print'],
actions: ['edit', 'submit', 'pdf', 'print'],
},
// 결재 문서용 - 기안함 모드 (임시저장 상태: 복제, 상신, 인쇄)
@@ -49,7 +49,7 @@ export const DOCUMENT_PRESETS: Record<PresetType, PresetConfig> = {
print: true,
download: false,
},
actions: ['copy', 'submit', 'print'],
actions: ['copy', 'submit', 'pdf', 'print'],
},
// 결재 문서용 - 결재함 모드 (수정, 반려, 승인, 인쇄)
@@ -60,7 +60,7 @@ export const DOCUMENT_PRESETS: Record<PresetType, PresetConfig> = {
print: true,
download: false,
},
actions: ['edit', 'reject', 'approve', 'print'],
actions: ['edit', 'reject', 'approve', 'pdf', 'print'],
},
// 조회 전용
@@ -71,7 +71,7 @@ export const DOCUMENT_PRESETS: Record<PresetType, PresetConfig> = {
print: true,
download: false,
},
actions: ['print'],
actions: ['pdf', 'print'],
},
// 견적서/문서 전송용 (PDF, 이메일, 팩스, 카카오톡, 인쇄)

View File

@@ -144,6 +144,20 @@ export interface CustomBlock {
// DocumentViewer Props
// ============================================================
// ============================================================
// PDF Meta Types
// ============================================================
export interface PdfMeta {
documentNumber?: string;
createdDate?: string;
showHeaderFooter?: boolean;
}
// ============================================================
// DocumentViewer Props
// ============================================================
export interface DocumentViewerProps {
// Config 기반 (권장)
config?: DocumentConfig;
@@ -158,6 +172,9 @@ export interface DocumentViewerProps {
// 데이터
data?: any;
// PDF 메타 정보 (헤더/푸터용)
pdfMeta?: PdfMeta;
// 액션 핸들러
onPrint?: () => void;
onDownload?: () => void;

View File

@@ -1,12 +1,13 @@
'use client';
import React, { useEffect, ReactNode } from 'react';
import React, { useEffect, ReactNode, useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { toast } from 'sonner';
import { printArea } from '@/lib/print-utils';
import { DocumentToolbar } from './DocumentToolbar';
import { DocumentContent } from './DocumentContent';
@@ -17,6 +18,7 @@ import {
DocumentViewerProps,
ActionType,
DocumentFeatures,
PdfMeta,
} from '../types';
/**
@@ -59,6 +61,9 @@ export function DocumentViewer({
// 데이터
data,
// PDF 메타 정보
pdfMeta,
// 액션 핸들러
onPrint: propOnPrint,
onDownload,
@@ -120,6 +125,118 @@ export function DocumentViewer({
}
};
// 인라인 스타일 적용된 HTML 복제 생성
const cloneWithInlineStyles = (element: HTMLElement): HTMLElement => {
const clone = element.cloneNode(true) as HTMLElement;
// 원본 요소들과 복제 요소들 매칭
const originalElements = element.querySelectorAll('*');
const clonedElements = clone.querySelectorAll('*');
// 루트 요소 스타일 적용
const rootStyle = window.getComputedStyle(element);
applyStyles(clone, rootStyle);
// 모든 하위 요소 스타일 적용
originalElements.forEach((orig, index) => {
const cloned = clonedElements[index] as HTMLElement;
if (cloned) {
const computedStyle = window.getComputedStyle(orig);
applyStyles(cloned, computedStyle);
}
});
return clone;
};
// 계산된 스타일을 인라인으로 적용
const applyStyles = (element: HTMLElement, computedStyle: CSSStyleDeclaration) => {
const importantStyles = [
'display', 'position', 'width', 'height', 'min-width', 'min-height', 'max-width', 'max-height',
'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
'border', 'border-width', 'border-style', 'border-color',
'border-top', 'border-right', 'border-bottom', 'border-left',
'border-collapse', 'border-spacing',
'background', 'background-color',
'color', 'font-family', 'font-size', 'font-weight', 'font-style',
'text-align', 'text-decoration', 'vertical-align', 'line-height', 'white-space',
'flex', 'flex-direction', 'flex-wrap', 'justify-content', 'align-items', 'gap',
'grid-template-columns', 'grid-template-rows', 'grid-gap',
'table-layout', 'overflow', 'visibility', 'opacity',
];
importantStyles.forEach((prop) => {
const value = computedStyle.getPropertyValue(prop);
if (value && value !== 'none' && value !== 'normal' && value !== 'auto') {
element.style.setProperty(prop, value);
}
});
};
// PDF 핸들러 (서버사이드 Puppeteer로 생성)
const handlePdf = useCallback(async () => {
if (onPdf) {
onPdf();
return;
}
try {
toast.loading('PDF 생성 중...', { id: 'pdf-generating' });
// print-area 영역 찾기
const printAreaEl = document.querySelector('.print-area') as HTMLElement;
if (!printAreaEl) {
toast.error('PDF 생성 실패: 문서 영역을 찾을 수 없습니다.', { id: 'pdf-generating' });
return;
}
// 인라인 스타일 적용된 HTML 복제
const clonedElement = cloneWithInlineStyles(printAreaEl);
const html = clonedElement.outerHTML;
// 서버 API 호출
const response = await fetch('/api/pdf/generate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
html,
title,
orientation: 'portrait',
documentNumber: pdfMeta?.documentNumber || '',
createdDate: pdfMeta?.createdDate || new Date().toISOString().slice(0, 10),
showHeaderFooter: pdfMeta?.showHeaderFooter !== false,
}),
});
if (!response.ok) {
throw new Error('PDF 생성 API 오류');
}
// PDF Blob 다운로드
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
// 파일명 생성 (날짜 포함)
const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
link.download = `${title}_${date}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
toast.success('PDF가 생성되었습니다.', { id: 'pdf-generating' });
} catch (error) {
console.error('PDF 생성 오류:', error);
toast.error('PDF 생성 중 오류가 발생했습니다.', { id: 'pdf-generating' });
}
}, [onPdf, title, pdfMeta]);
// 콘텐츠 렌더링
const renderContent = (): ReactNode => {
// 1. children이 있으면 children 사용 (정적 모드)
@@ -179,7 +296,7 @@ export function DocumentViewer({
onApprove={onApprove}
onReject={onReject}
onCopy={onCopy}
onPdf={onPdf}
onPdf={handlePdf}
onEmail={onEmail}
onFax={onFax}
onKakao={onKakao}

View File

@@ -7,12 +7,12 @@ import {
UserCheck,
AlertCircle,
Calendar,
Download,
Plus,
FileText,
Edit,
Search,
} from 'lucide-react';
import type { ExcelColumn } from '@/lib/utils/excel-download';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -351,10 +351,23 @@ export function AttendanceManagement() {
router.push(`/ko/hr/documents/new?type=${data.reasonType}`);
}, [router]);
const handleExcelDownload = useCallback(() => {
console.log('Excel download');
// TODO: 엑셀 다운로드 기능 구현
}, []);
// ===== 엑셀 컬럼 정의 =====
const excelColumns: ExcelColumn<AttendanceRecord>[] = useMemo(() => [
{ header: '부서', key: 'department' },
{ header: '직책', key: 'position' },
{ header: '이름', key: 'employeeName' },
{ header: '직급', key: 'rank' },
{ header: '기준일', key: 'baseDate' },
{ header: '출근', key: 'checkIn', transform: (value) => value ? String(value).substring(0, 5) : '-' },
{ header: '퇴근', key: 'checkOut', transform: (value) => value ? String(value).substring(0, 5) : '-' },
{ header: '휴게', key: 'breakTime', transform: (value) => value ? String(value) : '-' },
{ header: '연장근무', key: 'overtimeHours', transform: (value) => value ? String(value) : '-' },
{ header: '상태', key: 'status', transform: (value) => ATTENDANCE_STATUS_LABELS[value as AttendanceStatus] },
{ header: '사유', key: 'reason', transform: (value) => {
const reason = value as AttendanceRecord['reason'];
return reason?.label || '-';
}},
], []);
const handleReasonClick = useCallback((record: AttendanceRecord) => {
if (record.reason?.documentId) {
@@ -458,12 +471,15 @@ export function AttendanceManagement() {
searchPlaceholder: '이름, 부서 검색...',
// 엑셀 다운로드 설정 (클라이언트 사이드 필터링이므로 filteredData 사용)
excelDownload: {
columns: excelColumns,
filename: '근태현황',
sheetName: '근태',
},
extraFilters: (
<div className="flex items-center gap-2 flex-wrap">
<Button variant="outline" onClick={handleExcelDownload}>
<Download className="w-4 h-4 mr-2" />
</Button>
<Button variant="outline" onClick={handleAddReason}>
<FileText className="w-4 h-4 mr-2" />
@@ -667,7 +683,7 @@ export function AttendanceManagement() {
startDate,
endDate,
handleAddAttendance,
handleExcelDownload,
excelColumns,
handleAddReason,
handleReasonClick,
handleEditAttendance,

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: {

View File

@@ -17,9 +17,9 @@ import {
CheckCircle2,
Clock,
AlertCircle,
Download,
Eye,
} from 'lucide-react';
import type { ExcelColumn } from '@/lib/utils/excel-download';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { TableCell, TableRow } from '@/components/ui/table';
@@ -37,7 +37,7 @@ import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import { getStocks, getStockStats, getStockStatsByType } from './actions';
import { ITEM_TYPE_LABELS, ITEM_TYPE_STYLES, STOCK_STATUS_LABELS } from './types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import type { StockItem, StockStats, ItemType } from './types';
import type { StockItem, StockStats, ItemType, StockStatusType } from './types';
// 페이지당 항목 수
const ITEMS_PER_PAGE = 20;
@@ -80,10 +80,42 @@ export function StockStatusList() {
[router]
);
// ===== 엑셀 다운로드 =====
const handleExcelDownload = useCallback(() => {
console.log('엑셀 다운로드');
// TODO: 엑셀 다운로드 기능 구현
// ===== 엑셀 컬럼 정의 =====
const excelColumns: ExcelColumn<StockItem>[] = useMemo(() => [
{ header: '품목코드', key: 'itemCode' },
{ header: '품목명', key: 'itemName' },
{ header: '품목유형', key: 'itemType', transform: (value) => ITEM_TYPE_LABELS[value as ItemType] || String(value) },
{ header: '단위', key: 'unit' },
{ header: '재고량', key: 'stockQty' },
{ header: '안전재고', key: 'safetyStock' },
{ header: 'LOT수', key: 'lotCount' },
{ header: 'LOT경과일', key: 'lotDaysElapsed' },
{ header: '상태', key: 'status', transform: (value) => value ? STOCK_STATUS_LABELS[value as StockStatusType] : '-' },
{ header: '위치', key: 'location' },
], []);
// ===== API 응답 매핑 함수 =====
const mapStockResponse = useCallback((result: unknown): StockItem[] => {
const data = result as { data?: { data?: Record<string, unknown>[] } };
const rawItems = data.data?.data ?? [];
return rawItems.map((item: Record<string, unknown>) => {
const stock = item.stock as Record<string, unknown> | null;
const hasStock = !!stock;
return {
id: String(item.id ?? ''),
itemCode: (item.code ?? '') as string,
itemName: (item.name ?? '') as string,
itemType: (item.item_type ?? 'RM') as ItemType,
unit: (item.unit ?? 'EA') as string,
stockQty: hasStock ? (parseFloat(String(stock?.stock_qty)) || 0) : 0,
safetyStock: hasStock ? (parseFloat(String(stock?.safety_stock)) || 0) : 0,
lotCount: hasStock ? (Number(stock?.lot_count) || 0) : 0,
lotDaysElapsed: hasStock ? (Number(stock?.days_elapsed) || 0) : 0,
status: hasStock ? (stock?.status as StockStatusType | null) : null,
location: hasStock ? ((stock?.location as string) || '-') : '-',
hasStock,
};
});
}, []);
// ===== 통계 카드 =====
@@ -236,13 +268,24 @@ export function StockStatusList() {
// 테이블 푸터
tableFooter,
// 헤더 액션 (엑셀 다운로드)
headerActions: () => (
<Button variant="outline" onClick={handleExcelDownload}>
<Download className="w-4 h-4 mr-1.5" />
</Button>
),
// 엑셀 다운로드 설정
excelDownload: {
columns: excelColumns,
filename: '재고현황',
sheetName: '재고',
fetchAllUrl: '/api/proxy/stocks',
fetchAllParams: ({ activeTab, searchValue }) => {
const params: Record<string, string> = {};
if (activeTab && activeTab !== 'all') {
params.item_type = activeTab;
}
if (searchValue) {
params.search = searchValue;
}
return params;
},
mapResponse: mapStockResponse,
},
// 테이블 행 렌더링
renderTableRow: (
@@ -363,7 +406,7 @@ export function StockStatusList() {
);
},
}),
[tabs, stats, tableFooter, handleRowClick, handleExcelDownload]
[tabs, stats, tableFooter, handleRowClick, excelColumns, mapStockResponse]
);
return <UniversalListPage config={config} />;

View File

@@ -17,10 +17,9 @@ import {
CheckCircle2,
XCircle,
Percent,
Download,
CheckCircle,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { ExcelColumn } from '@/lib/utils/excel-download';
import { Badge } from '@/components/ui/badge';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
@@ -73,10 +72,58 @@ export function WorkResultList() {
// TODO: 상세 보기 기능 구현
}, []);
// ===== 엑셀 다운로드 =====
const handleExcelDownload = useCallback(() => {
console.log('엑셀 다운로드');
// TODO: 엑셀 다운로드 기능 구현
// ===== 엑셀 컬럼 정의 =====
const excelColumns: ExcelColumn<WorkResult>[] = useMemo(() => [
{ header: '로트번호', key: 'lotNo' },
{ header: '작업일', key: 'workDate' },
{ header: '작업지시번호', key: 'workOrderNo' },
{ header: '공정', key: 'processName' },
{ header: '품목명', key: 'productName' },
{ header: '규격', key: 'specification' },
{ header: '생산수량', key: 'productionQty' },
{ header: '양품수량', key: 'goodQty' },
{ header: '불량수량', key: 'defectQty' },
{ header: '불량률(%)', key: 'defectRate' },
{ header: '검사', key: 'inspection', transform: (value): string => value ? '완료' : '대기' },
{ header: '포장', key: 'packaging', transform: (value): string => value ? '완료' : '대기' },
{ header: '작업자', key: 'workerName', transform: (value): string => (value as string) || '-' },
], []);
// ===== API 응답 매핑 함수 =====
const mapWorkResultResponse = useCallback((result: unknown): WorkResult[] => {
const data = result as { data?: { data?: Record<string, unknown>[] } };
const rawItems = data.data?.data ?? [];
return rawItems.map((api: Record<string, unknown>) => {
const options = api.options as { result?: Record<string, unknown> } | null;
const apiResult = options?.result;
const goodQty = Number(apiResult?.good_qty ?? 0);
const defectQty = Number(apiResult?.defect_qty ?? 0);
const workOrder = api.work_order as Record<string, unknown> | undefined;
const process = workOrder?.process as Record<string, unknown> | undefined;
return {
id: String(api.id ?? ''),
workOrderId: Number(api.work_order_id ?? 0),
lotNo: (apiResult?.lot_no as string) || '-',
workDate: (apiResult?.completed_at as string) || (api.updated_at as string) || '',
workOrderNo: (workOrder?.work_order_no as string) || '-',
projectName: (workOrder?.project_name as string) || '-',
processName: (process?.process_name as string) || '-',
processCode: (process?.process_code as string) || '-',
productName: (api.item_name as string) || '',
specification: (api.specification as string) || '-',
quantity: parseFloat(String(api.quantity)) || 0,
productionQty: goodQty + defectQty,
goodQty,
defectQty,
defectRate: Number(apiResult?.defect_rate ?? 0),
inspection: Boolean(apiResult?.is_inspected),
packaging: Boolean(apiResult?.is_packaged),
workerId: (apiResult?.worker_id as number) ?? null,
workerName: (api.worker_name as string) ?? null,
memo: (apiResult?.memo as string) ?? null,
};
});
}, []);
// ===== 통계 카드 =====
@@ -182,13 +229,21 @@ export function WorkResultList() {
// 통계 카드
stats,
// 헤더 액션 (엑셀 다운로드)
headerActions: () => (
<Button variant="outline" onClick={handleExcelDownload}>
<Download className="w-4 h-4 mr-1.5" />
</Button>
),
// 엑셀 다운로드 설정
excelDownload: {
columns: excelColumns,
filename: '작업실적',
sheetName: '작업실적',
fetchAllUrl: '/api/proxy/work-results',
fetchAllParams: ({ searchValue }) => {
const params: Record<string, string> = {};
if (searchValue) {
params.q = searchValue;
}
return params;
},
mapResponse: mapWorkResultResponse,
},
// 테이블 행 렌더링
renderTableRow: (
@@ -305,7 +360,7 @@ export function WorkResultList() {
);
},
}),
[stats, handleView, handleExcelDownload]
[stats, handleView, excelColumns, mapWorkResultResponse]
);
return <UniversalListPage config={config} />;

View File

@@ -0,0 +1,231 @@
/**
* 할인하기 모달
*
* - 공급가액 표시 (읽기 전용)
* - 할인율 입력 (% 단위, 소수점 첫째자리까지)
* - 할인금액 입력 (원 단위)
* - 할인 후 공급가액 자동 계산
* - 할인율 ↔ 할인금액 상호 계산
*/
"use client";
import { useState, useEffect, useCallback } from "react";
import { Percent } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "../ui/dialog";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
// =============================================================================
// Props
// =============================================================================
interface DiscountModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** 공급가액 (할인 전 금액) */
supplyAmount: number;
/** 기존 할인율 (%) */
initialDiscountRate?: number;
/** 기존 할인금액 (원) */
initialDiscountAmount?: number;
/** 적용 콜백 */
onApply: (discountRate: number, discountAmount: number) => void;
}
// =============================================================================
// 컴포넌트
// =============================================================================
export function DiscountModal({
open,
onOpenChange,
supplyAmount,
initialDiscountRate = 0,
initialDiscountAmount = 0,
onApply,
}: DiscountModalProps) {
// ---------------------------------------------------------------------------
// 상태
// ---------------------------------------------------------------------------
const [discountRate, setDiscountRate] = useState<string>("");
const [discountAmount, setDiscountAmount] = useState<string>("");
// ---------------------------------------------------------------------------
// 초기화
// ---------------------------------------------------------------------------
useEffect(() => {
if (open) {
// 모달이 열릴 때 초기값 설정
if (initialDiscountRate > 0) {
setDiscountRate(initialDiscountRate.toString());
setDiscountAmount(initialDiscountAmount.toString());
} else if (initialDiscountAmount > 0) {
setDiscountAmount(initialDiscountAmount.toString());
const rate = supplyAmount > 0 ? (initialDiscountAmount / supplyAmount) * 100 : 0;
setDiscountRate(rate.toFixed(1));
} else {
setDiscountRate("");
setDiscountAmount("");
}
}
}, [open, initialDiscountRate, initialDiscountAmount, supplyAmount]);
// ---------------------------------------------------------------------------
// 핸들러
// ---------------------------------------------------------------------------
// 할인율 변경 → 할인금액 자동 계산
const handleDiscountRateChange = useCallback(
(value: string) => {
// 숫자와 소수점만 허용
const sanitized = value.replace(/[^0-9.]/g, "");
// 소수점이 여러 개인 경우 첫 번째만 유지
const parts = sanitized.split(".");
const formatted = parts.length > 2
? parts[0] + "." + parts.slice(1).join("")
: sanitized;
setDiscountRate(formatted);
const rate = parseFloat(formatted) || 0;
if (rate >= 0 && rate <= 100) {
const amount = Math.round(supplyAmount * (rate / 100));
setDiscountAmount(amount.toString());
}
},
[supplyAmount]
);
// 할인금액 변경 → 할인율 자동 계산
const handleDiscountAmountChange = useCallback(
(value: string) => {
// 숫자만 허용
const sanitized = value.replace(/[^0-9]/g, "");
setDiscountAmount(sanitized);
const amount = parseInt(sanitized) || 0;
if (supplyAmount > 0 && amount >= 0 && amount <= supplyAmount) {
const rate = (amount / supplyAmount) * 100;
setDiscountRate(rate.toFixed(1));
}
},
[supplyAmount]
);
// 적용
const handleApply = useCallback(() => {
const rate = parseFloat(discountRate) || 0;
const amount = parseInt(discountAmount) || 0;
onApply(rate, amount);
onOpenChange(false);
}, [discountRate, discountAmount, onApply, onOpenChange]);
// 취소
const handleCancel = useCallback(() => {
onOpenChange(false);
}, [onOpenChange]);
// ---------------------------------------------------------------------------
// 계산
// ---------------------------------------------------------------------------
const discountedAmount = supplyAmount - (parseInt(discountAmount) || 0);
// ---------------------------------------------------------------------------
// 렌더링
// ---------------------------------------------------------------------------
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle className="text-center"></DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
{/* 공급가액 (읽기 전용) */}
<div className="space-y-2">
<Label></Label>
<Input
value={supplyAmount.toLocaleString()}
disabled
className="bg-gray-50 text-right font-medium"
/>
</div>
{/* 할인율 */}
<div className="space-y-2">
<Label></Label>
<div className="relative">
<Input
type="text"
placeholder="0"
value={discountRate}
onChange={(e) => handleDiscountRateChange(e.target.value)}
className="pr-8 text-right"
/>
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500">
%
</span>
</div>
</div>
{/* 할인금액 */}
<div className="space-y-2">
<Label></Label>
<div className="relative">
<Input
type="text"
placeholder="0"
value={discountAmount ? parseInt(discountAmount).toLocaleString() : ""}
onChange={(e) => handleDiscountAmountChange(e.target.value.replace(/,/g, ""))}
className="pr-8 text-right"
/>
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500">
</span>
</div>
</div>
{/* 할인 후 공급가액 (읽기 전용) */}
<div className="space-y-2">
<Label> </Label>
<Input
value={discountedAmount.toLocaleString()}
disabled
className="bg-gray-50 text-right font-bold text-blue-600"
/>
</div>
</div>
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={handleCancel}
className="flex-1"
>
</Button>
<Button
onClick={handleApply}
className="flex-1 bg-gray-800 hover:bg-gray-900 text-white"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -10,7 +10,7 @@
"use client";
import { useState, useMemo, useEffect } from "react";
import { Package, Settings, Plus, Trash2, Loader2 } from "lucide-react";
import { Package, Settings, Plus, Trash2, Loader2, Calculator, Save } from "lucide-react";
import { getItemCategoryTree, type ItemCategoryNode } from "./actions";
import { Badge } from "../ui/badge";
@@ -35,7 +35,6 @@ import {
} from "../ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { ItemSearchModal } from "./ItemSearchModal";
import { LocationEditModal } from "./LocationEditModal";
import type { LocationItem } from "./QuoteRegistrationV2";
import type { FinishedGoods } from "./actions";
@@ -138,8 +137,12 @@ const DEFAULT_TABS: TabDefinition[] = [
interface LocationDetailPanelProps {
location: LocationItem | null;
onUpdateLocation: (locationId: string, updates: Partial<LocationItem>) => void;
onDeleteLocation?: (locationId: string) => void;
onCalculateLocation?: (locationId: string) => Promise<void>;
onSaveItems?: () => void;
finishedGoods: FinishedGoods[];
disabled?: boolean;
isCalculating?: boolean;
}
// =============================================================================
@@ -149,8 +152,12 @@ interface LocationDetailPanelProps {
export function LocationDetailPanel({
location,
onUpdateLocation,
onDeleteLocation,
onCalculateLocation,
onSaveItems,
finishedGoods,
disabled = false,
isCalculating = false,
}: LocationDetailPanelProps) {
// ---------------------------------------------------------------------------
// 상태
@@ -158,7 +165,6 @@ export function LocationDetailPanel({
const [activeTab, setActiveTab] = useState("BODY");
const [itemSearchOpen, setItemSearchOpen] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [itemCategories, setItemCategories] = useState<ItemCategoryNode[]>([]);
const [categoriesLoading, setCategoriesLoading] = useState(true);
@@ -336,125 +342,184 @@ export function LocationDetailPanel({
return (
<div className="flex flex-col h-full overflow-hidden">
{/* 헤더 */}
<div className="bg-white px-4 py-3 border-b">
<div className="flex items-center justify-between">
<h3 className="text-lg font-bold">
{location.floor} / {location.code}
</h3>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">:</span>
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">
{location.productCode}
</Badge>
{location.bomResult && (
<Badge variant="default" className="bg-green-600">
</Badge>
)}
</div>
</div>
</div>
{/* 제품 정보 */}
<div className="bg-gray-50 px-4 py-3 border-b space-y-3">
{/* 오픈사이즈 */}
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600 w-20"></span>
<div className="flex items-center gap-2">
<NumberInput
value={location.openWidth}
onChange={(value) => handleFieldChange("openWidth", value ?? 0)}
disabled={disabled}
className="w-24 h-8 text-center font-bold"
/>
<span className="text-gray-400">×</span>
<NumberInput
value={location.openHeight}
onChange={(value) => handleFieldChange("openHeight", value ?? 0)}
disabled={disabled}
className="w-24 h-8 text-center font-bold"
/>
{!disabled && (
<Button
variant="secondary"
size="sm"
className="text-xs h-7"
onClick={() => setEditModalOpen(true)}
{/* ②-1 개소 정보 영역 */}
<div className="bg-gray-50 border-b">
{/* 1행: 층, 부호, 가로, 세로, 제품코드 */}
<div className="px-4 py-3 space-y-3">
<div className="grid grid-cols-5 gap-3">
<div>
<label className="text-xs text-gray-600"></label>
<Input
value={location.floor}
onChange={(e) => handleFieldChange("floor", e.target.value)}
disabled={disabled}
className="h-8 text-sm"
/>
</div>
<div>
<label className="text-xs text-gray-600"></label>
<Input
value={location.code}
onChange={(e) => handleFieldChange("code", e.target.value)}
disabled={disabled}
className="h-8 text-sm"
/>
</div>
<div>
<label className="text-xs text-gray-600"></label>
<NumberInput
value={location.openWidth}
onChange={(value) => handleFieldChange("openWidth", value ?? 0)}
disabled={disabled}
className="h-8 text-sm"
/>
</div>
<div>
<label className="text-xs text-gray-600"></label>
<NumberInput
value={location.openHeight}
onChange={(value) => handleFieldChange("openHeight", value ?? 0)}
disabled={disabled}
className="h-8 text-sm"
/>
</div>
<div>
<label className="text-xs text-gray-600"></label>
<Select
value={location.productCode}
onValueChange={(value) => {
const product = finishedGoods.find((fg) => fg.item_code === value);
onUpdateLocation(location.id, {
productCode: value,
productName: product?.item_name || value,
});
}}
disabled={disabled}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{finishedGoods.map((fg) => (
<SelectItem key={fg.item_code} value={fg.item_code}>
{fg.item_code}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 2행: 가이드레일, 전원, 제어기 */}
<div className="grid grid-cols-3 gap-3">
<div>
<label className="text-xs text-gray-600 flex items-center gap-1">
🔧
</label>
<Select
value={location.guideRailType}
onValueChange={(value) => handleFieldChange("guideRailType", value)}
disabled={disabled}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{GUIDE_RAIL_TYPES.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-xs text-gray-600 flex items-center gap-1">
</label>
<Select
value={location.motorPower}
onValueChange={(value) => handleFieldChange("motorPower", value)}
disabled={disabled}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MOTOR_POWERS.map((power) => (
<SelectItem key={power.value} value={power.value}>
{power.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-xs text-gray-600 flex items-center gap-1">
📦
</label>
<Select
value={location.controller}
onValueChange={(value) => handleFieldChange("controller", value)}
disabled={disabled}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONTROLLERS.map((ctrl) => (
<SelectItem key={ctrl.value} value={ctrl.value}>
{ctrl.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 3행: 제작사이즈, 산출중량, 산출면적, 수량 */}
<div className="grid grid-cols-4 gap-3 text-sm pt-2 border-t border-gray-200">
<div>
<span className="text-xs text-gray-500"></span>
<p className="font-semibold">
{location.manufactureWidth || location.openWidth + 280}X{location.manufactureHeight || location.openHeight + 280}
</p>
</div>
<div>
<span className="text-xs text-gray-500">-</span>
<p className="font-semibold">kg</p>
</div>
<div>
<span className="text-xs text-gray-500">-</span>
<p className="font-semibold">m²</p>
</div>
<div className="flex items-end">
<Button
onClick={() => onCalculateLocation?.(location.id)}
disabled={disabled || isCalculating}
className="w-full h-8 bg-orange-500 hover:bg-orange-600 text-white"
>
{isCalculating ? (
<>
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
...
</>
) : (
<>
<Calculator className="h-4 w-4 mr-1" />
</>
)}
</Button>
)}
</div>
</div>
{/* 제작사이즈, 산출중량, 산출면적, 수량 */}
<div className="grid grid-cols-4 gap-4 text-sm">
<div>
<span className="text-gray-500"></span>
<p className="font-semibold">
{location.manufactureWidth || location.openWidth + 280} × {location.manufactureHeight || location.openHeight + 280}
</p>
</div>
<div>
<span className="text-gray-500"></span>
<p className="font-semibold">{location.weight?.toFixed(1) || "-"} <span className="text-xs text-gray-400">kg</span></p>
</div>
<div>
<span className="text-gray-500"></span>
<p className="font-semibold">{location.area?.toFixed(1) || "-"} <span className="text-xs text-gray-400">m²</span></p>
</div>
<div>
<span className="text-gray-500"></span>
<QuantityInput
value={location.quantity}
onChange={(value) => handleFieldChange("quantity", value ?? 1)}
disabled={disabled}
className="w-24 h-7 text-center font-semibold"
min={1}
/>
</div>
</div>
</div>
</div>
{/* 필수 설정 (읽기 전용) */}
<div className="bg-white px-4 py-3 border-b">
<h4 className="text-sm font-semibold text-gray-700 mb-2 flex items-center gap-1">
<Settings className="h-4 w-4" />
</h4>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="text-xs text-gray-500 flex items-center gap-1 mb-1">
🔧
</label>
<Badge variant="outline" className="bg-gray-50 text-gray-700 border-gray-300 px-3 py-1.5">
{GUIDE_RAIL_TYPES.find(t => t.value === location.guideRailType)?.label || location.guideRailType}
</Badge>
</div>
<div>
<label className="text-xs text-gray-500 flex items-center gap-1 mb-1">
</label>
<Badge variant="outline" className="bg-gray-50 text-gray-700 border-gray-300 px-3 py-1.5">
{MOTOR_POWERS.find(p => p.value === location.motorPower)?.label || location.motorPower}
</Badge>
</div>
<div>
<label className="text-xs text-gray-500 flex items-center gap-1 mb-1">
📦
</label>
<Badge variant="outline" className="bg-gray-50 text-gray-700 border-gray-300 px-3 py-1.5">
{CONTROLLERS.find(c => c.value === location.controller)?.label || location.controller}
</Badge>
</div>
</div>
</div>
{/* 탭 및 품목 테이블 */}
{/* ②-2 품목 상세 영역 */}
<div className="flex-1 overflow-hidden flex flex-col">
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col h-full">
{/* 탭 목록 - 스크롤 가능 */}
{/* 탭 목록 */}
<div className="border-b bg-white overflow-x-auto">
{categoriesLoading ? (
<div className="flex items-center justify-center py-2 px-4 text-gray-500">
@@ -467,7 +532,7 @@ export function LocationDetailPanel({
<TabsTrigger
key={tab.value}
value={tab.value}
className="rounded-none border-b-2 border-transparent data-[state=active]:border-blue-500 data-[state=active]:bg-blue-50 data-[state=active]:text-blue-700 px-4 py-2 text-sm whitespace-nowrap"
className="rounded-none border-b-2 border-transparent data-[state=active]:border-orange-500 data-[state=active]:bg-orange-50 data-[state=active]:text-orange-700 px-4 py-2 text-sm whitespace-nowrap"
>
{tab.label}
</TabsTrigger>
@@ -476,122 +541,95 @@ export function LocationDetailPanel({
)}
</div>
{/* 동적 탭 콘텐츠 렌더링 */}
{/* 탭 콘텐츠 */}
{detailTabs.map((tab) => {
const items = bomItemsByTab[tab.value] || [];
const isBendingTab = tab.parentCode === "BENDING";
const isMotorTab = tab.value === "MOTOR_CTRL";
const isAccessoryTab = tab.value === "ACCESSORY";
return (
<TabsContent key={tab.value} value={tab.value} className="flex-1 overflow-auto m-0 p-0">
<div className="bg-amber-50 border border-amber-200 rounded-lg m-4">
<div className="bg-amber-50 border border-amber-200 rounded-lg m-3">
<Table>
<TableHeader>
<TableRow className="bg-amber-100/50">
<TableHead className="font-semibold"></TableHead>
{/* 본체: 제작사이즈 */}
{!isBendingTab && !isMotorTab && !isAccessoryTab && (
<TableHead className="text-center font-semibold"></TableHead>
)}
{/* 절곡품: 재질, 규격, 납품길이 */}
<TableHead className="text-center font-semibold"></TableHead>
{isBendingTab && (
<>
<TableHead className="text-center font-semibold"></TableHead>
<TableHead className="text-center font-semibold"></TableHead>
<TableHead className="text-center font-semibold w-28"></TableHead>
</>
<TableHead className="text-center font-semibold w-24"></TableHead>
)}
{/* 모터: 유형, 사양 */}
{isMotorTab && (
<>
<TableHead className="text-center font-semibold"></TableHead>
<TableHead className="text-center font-semibold"></TableHead>
</>
)}
{/* 부자재: 규격, 납품길이 */}
{isAccessoryTab && (
<>
<TableHead className="text-center font-semibold"></TableHead>
<TableHead className="text-center font-semibold w-28"></TableHead>
</>
)}
<TableHead className="text-center font-semibold w-24"></TableHead>
<TableHead className="text-center font-semibold w-20"></TableHead>
<TableHead className="text-center font-semibold w-20"></TableHead>
<TableHead className="text-center font-semibold w-12"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item: any, index: number) => (
<TableRow key={item.id || `${tab.value}-${index}`} className="bg-white">
<TableCell className="font-medium">{item.item_name}</TableCell>
{/* 본체: 제작사이즈 */}
{!isBendingTab && !isMotorTab && !isAccessoryTab && (
<TableCell className="text-center text-gray-600">{item.manufacture_size || "-"}</TableCell>
)}
{/* 절곡품: 재질, 규격, 납품길이 */}
{isBendingTab && (
<>
<TableCell className="text-center text-gray-600">{item.material || "-"}</TableCell>
<TableCell className="text-center text-gray-600">{item.spec || "-"}</TableCell>
<TableCell className="text-center">
<Select defaultValue={item.delivery_length} disabled={disabled}>
<SelectTrigger className="w-24 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DELIVERY_LENGTH_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
</>
)}
{/* 모터: 유형, 사양 */}
{isMotorTab && (
<>
<TableCell className="text-center text-gray-600">{item.type || "-"}</TableCell>
<TableCell className="text-center text-gray-600">{item.spec || "-"}</TableCell>
</>
)}
{/* 부자재: 규격, 납품길이 */}
{isAccessoryTab && (
<>
<TableCell className="text-center text-gray-600">{item.spec || "-"}</TableCell>
<TableCell className="text-center">
<Select defaultValue={item.delivery_length} disabled={disabled}>
<SelectTrigger className="w-24 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DELIVERY_LENGTH_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
</>
)}
<TableCell className="text-center">
<QuantityInput
value={item.quantity}
onChange={() => {}}
className="w-16 h-8 text-center"
min={1}
disabled={disabled}
/>
</TableCell>
<TableCell className="text-center">
<Button variant="ghost" size="sm" className="text-red-500 hover:text-red-700 hover:bg-red-50 p-1" disabled={disabled}>
<Trash2 className="h-4 w-4" />
</Button>
{items.length === 0 ? (
<TableRow>
<TableCell colSpan={isBendingTab ? 5 : 4} className="text-center text-gray-400 py-6">
</TableCell>
</TableRow>
))}
) : (
items.map((item: any, index: number) => (
<TableRow key={item.id || `${tab.value}-${index}`} className="bg-white">
<TableCell className="font-medium">{item.item_name}</TableCell>
<TableCell className="text-center text-gray-600">{item.spec || item.specification || "-"}</TableCell>
{isBendingTab && (
<TableCell className="text-center">
<Select defaultValue={item.delivery_length || "4000"} disabled={disabled}>
<SelectTrigger className="w-20 h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DELIVERY_LENGTH_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
)}
<TableCell className="text-center">
<QuantityInput
value={item.quantity}
onChange={() => {}}
className="w-14 h-7 text-center text-xs"
min={1}
disabled={disabled}
/>
</TableCell>
<TableCell className="text-center">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-gray-400 hover:text-red-500 hover:bg-red-50"
disabled={disabled}
onClick={() => {
if (!location) return;
// 품목 삭제 로직
const existingBomResult = location.bomResult;
if (!existingBomResult) return;
const updatedItems = (existingBomResult.items || []).filter(
(_: any, i: number) => !(bomItemsByTab[tab.value]?.[index] === _)
);
onUpdateLocation(location.id, {
bomResult: {
...existingBomResult,
items: updatedItems,
},
});
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
{/* 품목 추가 버튼 + 안내 */}
{/* 품목 추가 + 저장 버튼 */}
<div className="p-3 flex items-center justify-between border-t border-amber-200">
<Button
variant="outline"
@@ -603,9 +641,15 @@ export function LocationDetailPanel({
<Plus className="h-4 w-4 mr-1" />
</Button>
<span className="text-sm text-gray-500 flex items-center gap-1">
💡
</span>
<Button
size="sm"
className="bg-blue-600 hover:bg-blue-700 text-white"
onClick={onSaveItems}
disabled={disabled}
>
<Save className="h-4 w-4 mr-1" />
</Button>
</div>
</div>
</TabsContent>
@@ -614,13 +658,6 @@ export function LocationDetailPanel({
</Tabs>
</div>
{/* 금액 안내 */}
{!location.bomResult && (
<div className="bg-blue-50 px-4 py-2 border-t border-blue-200 text-center text-sm text-blue-700">
💡
</div>
)}
{/* 품목 검색 모달 */}
<ItemSearchModal
open={itemSearchOpen}
@@ -628,12 +665,10 @@ export function LocationDetailPanel({
onSelectItem={(item) => {
if (!location) return;
// 현재 탭 정보 가져오기
const currentTab = detailTabs.find((t) => t.value === activeTab);
const categoryCode = activeTab; // 카테고리 코드를 직접 사용
const categoryCode = activeTab;
const categoryLabel = currentTab?.label || activeTab;
// 새 품목 생성 (카테고리 코드 포함)
const newItem: BomCalculationResultItem & { category_code?: string; is_manual?: boolean } = {
item_code: item.code,
item_name: item.name,
@@ -643,11 +678,10 @@ export function LocationDetailPanel({
unit_price: 0,
total_price: 0,
process_group: categoryLabel,
category_code: categoryCode, // 새 카테고리 코드 사용
is_manual: true, // 수동 추가 품목 표시
category_code: categoryCode,
is_manual: true,
};
// 기존 bomResult 가져오기
const existingBomResult = location.bomResult || {
finished_goods: { code: location.productCode || "", name: location.productName || "" },
subtotals: {},
@@ -656,11 +690,9 @@ export function LocationDetailPanel({
items: [],
};
// 기존 items에 새 아이템 추가
const existingItems = existingBomResult.items || [];
const updatedItems = [...existingItems, newItem];
// subtotals 업데이트 (해당 카테고리의 count, subtotal 증가)
const existingSubtotals = existingBomResult.subtotals || {};
const rawCategorySubtotal = existingSubtotals[categoryCode];
const categorySubtotal = (typeof rawCategorySubtotal === 'object' && rawCategorySubtotal !== null)
@@ -675,7 +707,6 @@ export function LocationDetailPanel({
},
};
// grouped_items 업데이트 (해당 카테고리의 items 배열에 추가)
const existingGroupedItems = existingBomResult.grouped_items || {};
const categoryGroupedItems = existingGroupedItems[categoryCode] || { items: [] };
const updatedGroupedItems = {
@@ -686,7 +717,6 @@ export function LocationDetailPanel({
},
};
// grand_total 업데이트
const updatedGrandTotal = (existingBomResult.grand_total || 0) + (newItem.total_price || 0);
const updatedBomResult = {
@@ -697,23 +727,11 @@ export function LocationDetailPanel({
grand_total: updatedGrandTotal,
};
// location 업데이트
onUpdateLocation(location.id, { bomResult: updatedBomResult });
console.log(`[품목 추가] ${item.code} - ${item.name}${categoryLabel} (${categoryCode})`);
}}
tabLabel={detailTabs.find((t) => t.value === activeTab)?.label}
/>
{/* 개소 정보 수정 모달 */}
<LocationEditModal
open={editModalOpen}
onOpenChange={setEditModalOpen}
location={location}
onSave={(locationId, updates) => {
onUpdateLocation(locationId, updates);
}}
/>
</div>
);
}

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

View File

@@ -2,13 +2,13 @@
* 견적 푸터 바
*
* - 예상 전체 견적금액 표시
* - 버튼: 견적서 산출, 저장, 견적 확정
* - 버튼: 견적서 보기, 거래명세서 보기, 할인하기, 최종확정
* - 뒤로가기 버튼
*/
"use client";
import { Download, Save, Check, ArrowLeft, Loader2, Calculator, Eye, Pencil, ClipboardList } from "lucide-react";
import { Save, Check, ArrowLeft, Loader2, FileText, Pencil, ClipboardList, Percent } from "lucide-react";
import { Button } from "../ui/button";
@@ -20,17 +20,25 @@ interface QuoteFooterBarProps {
totalLocations: number;
totalAmount: number;
status: "draft" | "temporary" | "final";
onCalculate: () => void;
onPreview: () => void;
onSaveTemporary: () => void;
onSaveFinal: () => void;
/** 견적서 보기 */
onQuoteView: () => void;
/** 거래명세서 보기 */
onTransactionView: () => void;
/** 저장 (임시저장) */
onSave: () => void;
/** 최종확정 */
onFinalize: () => void;
/** 뒤로가기 */
onBack: () => void;
/** 수정 */
onEdit?: () => void;
/** 수주등록 */
onOrderRegister?: () => void;
isCalculating?: boolean;
/** 할인하기 */
onDiscount?: () => void;
isSaving?: boolean;
disabled?: boolean;
/** view 모드 여부 (view: 수정+최종저장, edit: 임시저장+최종저장) */
/** view 모드 여부 (view: 수정+최종확정, edit: 저장+최종확정) */
isViewMode?: boolean;
}
@@ -42,14 +50,14 @@ export function QuoteFooterBar({
totalLocations,
totalAmount,
status,
onCalculate,
onPreview,
onSaveTemporary,
onSaveFinal,
onQuoteView,
onTransactionView,
onSave,
onFinalize,
onBack,
onEdit,
onOrderRegister,
isCalculating = false,
onDiscount,
isSaving = false,
disabled = false,
isViewMode = false,
@@ -79,36 +87,26 @@ export function QuoteFooterBar({
{/* 오른쪽: 버튼들 */}
<div className="flex items-center gap-3">
{/* 견적서 산출 - edit 모드에서만 활성화 */}
{!isViewMode && (
<Button
onClick={onCalculate}
disabled={isCalculating || totalLocations === 0}
className="bg-indigo-600 hover:bg-indigo-700 text-white gap-2 px-6"
>
{isCalculating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Calculator className="h-4 w-4" />
</>
)}
</Button>
)}
{/* 미리보기 */}
{/* 견적서 보기 */}
<Button
onClick={onPreview}
onClick={onQuoteView}
disabled={totalLocations === 0}
variant="outline"
className="gap-2 px-6"
>
<Eye className="h-4 w-4" />
<FileText className="h-4 w-4" />
</Button>
{/* 거래명세서 보기 */}
<Button
onClick={onTransactionView}
disabled={totalLocations === 0}
variant="outline"
className="gap-2 px-6"
>
<ClipboardList className="h-4 w-4" />
</Button>
{/* 수정 - view 모드에서만 표시 */}
@@ -123,10 +121,22 @@ export function QuoteFooterBar({
</Button>
)}
{/* 할인하기 */}
{onDiscount && (
<Button
onClick={onDiscount}
variant="outline"
className="gap-2 px-6 border-orange-300 text-orange-600 hover:bg-orange-50"
>
<Percent className="h-4 w-4" />
</Button>
)}
{/* 저장 - edit 모드에서만 표시 */}
{!isViewMode && (
<Button
onClick={onSaveTemporary}
onClick={onSave}
disabled={isSaving}
className="bg-slate-500 hover:bg-slate-600 text-white gap-2 px-6"
>
@@ -139,10 +149,10 @@ export function QuoteFooterBar({
</Button>
)}
{/* 견적 확정 - 양쪽 모드에서 표시 (final 상태가 아닐 때만) */}
{/* 최종확정 - 양쪽 모드에서 표시 (final 상태가 아닐 때만) */}
{status !== "final" && (
<Button
onClick={onSaveFinal}
onClick={onFinalize}
disabled={isSaving || totalAmount === 0}
className="bg-blue-600 hover:bg-blue-700 text-white gap-2 px-6"
>
@@ -151,7 +161,7 @@ export function QuoteFooterBar({
) : (
<Check className="h-4 w-4" />
)}
</Button>
)}

View File

@@ -12,6 +12,7 @@
import { useState, useMemo, useCallback, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { format, startOfMonth, endOfMonth } from 'date-fns';
import {
FileText,
Edit,
@@ -23,6 +24,13 @@ import {
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { getQuoteStatusBadge } from '@/components/atoms/BadgeSm';
import { TableRow, TableCell } from '@/components/ui/table';
import {
@@ -37,7 +45,6 @@ import {
type UniversalListConfig,
type SelectionHandlers,
type RowClickHandlers,
type TabOption,
type StatCard,
type ListParams,
} from '@/components/templates/UniversalListPage';
@@ -64,6 +71,15 @@ export function QuoteManagementClient({
const router = useRouter();
const [isPending, startTransition] = useTransition();
// ===== 날짜 필터 상태 =====
const today = new Date();
const [startDate, setStartDate] = useState(format(startOfMonth(today), 'yyyy-MM-dd'));
const [endDate, setEndDate] = useState(format(endOfMonth(today), 'yyyy-MM-dd'));
// ===== 필터 상태 =====
const [productCategoryFilter, setProductCategoryFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
// ===== 산출내역서 다이얼로그 상태 =====
const [isCalculationDialogOpen, setIsCalculationDialogOpen] = useState(false);
const [calculationQuote, setCalculationQuote] = useState<Quote | null>(null);
@@ -156,35 +172,6 @@ export function QuoteManagementClient({
return getQuoteStatusBadge(legacyQuote as any);
}, []);
// ===== 탭 옵션 =====
const tabs: TabOption[] = useMemo(() => [
{ value: 'all', label: '전체', count: allQuotes.length, color: 'blue' },
{
value: 'initial',
label: '최초작성',
count: allQuotes.filter((q) => q.currentRevision === 0 && !q.isFinal && q.status !== 'converted').length,
color: 'gray',
},
{
value: 'revising',
label: '수정중',
count: allQuotes.filter((q) => q.currentRevision > 0 && !q.isFinal && q.status !== 'converted').length,
color: 'orange',
},
{
value: 'final',
label: '최종확정',
count: allQuotes.filter((q) => q.isFinal && q.status !== 'converted').length,
color: 'green',
},
{
value: 'converted',
label: '수주전환',
count: allQuotes.filter((q) => q.status === 'converted').length,
color: 'purple',
},
], [allQuotes]);
// ===== 통계 카드 계산 =====
const computeStats = useCallback((data: Quote[]): StatCard[] => {
const now = new Date();
@@ -307,22 +294,41 @@ export function QuoteManagementClient({
clientSideFiltering: true,
itemsPerPage: 20,
// 필터 함수
tabFilter: (item: Quote, activeTab: string) => {
if (activeTab === 'all') return true;
if (activeTab === 'initial') {
return item.currentRevision === 0 && !item.isFinal && item.status !== 'converted';
}
if (activeTab === 'revising') {
return item.currentRevision > 0 && !item.isFinal && item.status !== 'converted';
}
if (activeTab === 'final') {
return item.isFinal && item.status !== 'converted';
}
if (activeTab === 'converted') {
return item.status === 'converted';
}
return true;
// 필터 함수 (날짜 + 제품분류 + 상태)
customFilterFn: (items: Quote[]) => {
return items.filter((item) => {
// 날짜 필터
const itemDate = item.registrationDate;
if (itemDate) {
if (startDate && itemDate < startDate) return false;
if (endDate && itemDate > endDate) return false;
}
// 제품분류 필터
if (productCategoryFilter !== 'all') {
if (productCategoryFilter === 'STEEL' && item.productCategory !== 'STEEL') return false;
if (productCategoryFilter === 'SCREEN' && item.productCategory !== 'SCREEN') return false;
if (productCategoryFilter === 'MIXED' && item.productCategory !== 'MIXED') return false;
}
// 상태 필터
if (statusFilter !== 'all') {
if (statusFilter === 'initial') {
// 최초작성: currentRevision === 0 && !isFinal
if (!(item.currentRevision === 0 && !item.isFinal)) return false;
}
if (statusFilter === 'revising') {
// N차수정: currentRevision > 0 && !isFinal
if (!(item.currentRevision > 0 && !item.isFinal)) return false;
}
if (statusFilter === 'final') {
// 최종확정: isFinal === true
if (!item.isFinal) return false;
}
}
return true;
});
},
// 검색 필터 함수
@@ -344,13 +350,53 @@ export function QuoteManagementClient({
);
},
// 탭 설정
tabs,
defaultTab: 'all',
// 탭 비활성화 (필터로 대체)
tabs: [],
// 통계 카드
computeStats,
// 테이블 우측 필터 (탭 영역에 표시)
tableHeaderActions: (
<div className="flex items-center gap-2">
{/* 제품분류 필터 */}
<Select value={productCategoryFilter} onValueChange={setProductCategoryFilter}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="제품분류" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="STEEL"></SelectItem>
<SelectItem value="SCREEN"></SelectItem>
<SelectItem value="MIXED"></SelectItem>
</SelectContent>
</Select>
{/* 상태 필터 */}
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="initial"></SelectItem>
<SelectItem value="revising">N차수정</SelectItem>
<SelectItem value="final"></SelectItem>
</SelectContent>
</Select>
</div>
),
// 날짜 필터
dateRangeSelector: {
enabled: true,
showPresets: true,
startDate,
endDate,
onStartDateChange: setStartDate,
onEndDateChange: setEndDate,
},
// 검색
searchPlaceholder: '견적번호, 발주처, 담당자, 현장코드, 현장명 검색...',
@@ -553,7 +599,7 @@ export function QuoteManagementClient({
);
},
}),
[tabs, computeStats, router, handleView, handleEdit, handleDeleteClick, handleViewHistory, handleBulkDelete, getRevisionBadge, isPending]
[computeStats, router, handleView, handleEdit, handleDeleteClick, handleViewHistory, handleBulkDelete, getRevisionBadge, isPending, startDate, endDate, productCategoryFilter, statusFilter]
);
return (

View File

@@ -3,215 +3,368 @@
/**
* 견적서 문서 콘텐츠
*
* 공통 컴포넌트 사용:
* - DocumentHeader: simple 레이아웃 (결재란 없음)
* - SectionHeader: 섹션 제목
* 양식 타입:
* - vendor: 업체발송용 (부가세 별도/포함)
* - calculation: 산출내역서
*/
import type { QuoteFormDataV2 } from './QuoteRegistrationV2';
import { DocumentHeader, SectionHeader } from '@/components/document-system';
// 양식 타입
type TemplateType = 'vendor' | 'calculation';
interface QuotePreviewContentProps {
data: QuoteFormDataV2;
/** 양식 타입 (기본: vendor) */
templateType?: TemplateType;
/** 부가세 포함 여부 (업체발송용에서만 사용) */
vatIncluded?: boolean;
/** 할인율 (%) */
discountRate?: number;
/** 할인금액 (원) */
discountAmount?: number;
}
export function QuotePreviewContent({ data: quoteData }: QuotePreviewContentProps) {
// 총 금액 계산
const totalAmount = quoteData.locations.reduce(
export function QuotePreviewContent({
data: quoteData,
templateType = 'vendor',
vatIncluded = false,
discountRate = 0,
discountAmount = 0,
}: QuotePreviewContentProps) {
// 소계 (할인 전 금액)
const subtotal = quoteData.locations.reduce(
(sum, loc) => sum + (loc.totalPrice || 0),
0
);
// 할인 적용 후 금액
const afterDiscount = subtotal - discountAmount;
// 부가세
const vat = Math.round(totalAmount * 0.1);
const grandTotal = totalAmount + vat;
const vat = Math.round(afterDiscount * 0.1);
// 총 견적금액 (부가세 포함 여부에 따라)
const grandTotal = vatIncluded ? afterDiscount + vat : afterDiscount;
// 할인 적용 여부
const hasDiscount = discountAmount > 0;
// 산출내역서 여부
const isCalculation = templateType === 'calculation';
return (
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg p-8">
{/* 제목 (공통 컴포넌트) */}
<DocumentHeader
title="견 적 서"
subtitle={`문서번호: ${quoteData.id || "-"} | 작성일자: ${quoteData.registrationDate || "-"}`}
layout="simple"
approval={null}
/>
{/* 수요자 정보 */}
<div className="border border-gray-300 mb-4">
<div className="bg-gray-100 px-3 py-2 font-semibold border-b border-gray-300">
<div className="max-w-[210mm] mx-auto bg-white p-6 text-sm">
{/* 헤더: 제목 + 결재란 */}
<div className="flex justify-between items-start mb-4">
{/* 왼쪽: 제목 */}
<div className="flex-1">
<h1 className="text-2xl font-bold text-center tracking-[0.5em] mb-1">
</h1>
<p className="text-xs text-gray-500 text-center">
: {quoteData.id || 'ABC123'} | : {quoteData.registrationDate || '-'}
</p>
</div>
<div className="p-3 grid grid-cols-2 gap-2 text-sm">
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span className="font-medium">{quoteData.clientName || "-"}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span className="font-medium">{quoteData.manager || "-"}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span className="font-medium">{quoteData.siteName || "-"}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span className="font-medium">{quoteData.contact || "-"}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span className="font-medium">{quoteData.registrationDate || "-"}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span className="font-medium">{quoteData.dueDate || "-"}</span>
</div>
{/* 오른쪽: 결재란 */}
<div className="border border-gray-400">
<table className="text-xs">
<thead>
<tr className="bg-gray-100">
<th className="border-r border-gray-400 px-3 py-1"></th>
<th className="border-r border-gray-400 px-3 py-1"></th>
<th className="border-r border-gray-400 px-3 py-1"></th>
<th className="px-3 py-1"></th>
</tr>
</thead>
<tbody>
<tr>
<td className="border-t border-r border-gray-400 px-3 py-3 text-center"></td>
<td className="border-t border-r border-gray-400 px-3 py-3 text-center"></td>
<td className="border-t border-r border-gray-400 px-3 py-3 text-center"></td>
<td className="border-t border-gray-400 px-3 py-3 text-center"></td>
</tr>
<tr>
<td className="border-t border-r border-gray-400 px-2 py-1 text-center text-[10px] text-gray-500"></td>
<td className="border-t border-r border-gray-400 px-2 py-1 text-center text-[10px] text-gray-500"></td>
<td className="border-t border-r border-gray-400 px-2 py-1 text-center text-[10px] text-gray-500"></td>
<td className="border-t border-gray-400 px-2 py-1 text-center text-[10px] text-gray-500"></td>
</tr>
</tbody>
</table>
</div>
</div>
{/* 공급자 정보 */}
<div className="border border-gray-300 mb-6">
<div className="bg-gray-100 px-3 py-2 font-semibold border-b border-gray-300">
{/* 수요자 / 공급자 정보 (좌우 배치) */}
<div className="grid grid-cols-2 gap-4 mb-4">
{/* 수요자 */}
<div className="border border-gray-400">
<div className="bg-gray-200 px-2 py-1 font-semibold text-center border-b border-gray-400">
</div>
<table className="w-full text-xs">
<tbody>
<tr>
<td className="border-b border-r border-gray-300 bg-gray-50 px-2 py-1 w-20"></td>
<td className="border-b border-gray-300 px-2 py-1">{quoteData.clientName || '-'}</td>
</tr>
<tr>
<td className="border-b border-r border-gray-300 bg-gray-50 px-2 py-1"></td>
<td className="border-b border-gray-300 px-2 py-1">{quoteData.locations[0]?.productCode || '-'}</td>
</tr>
<tr>
<td className="border-b border-r border-gray-300 bg-gray-50 px-2 py-1"></td>
<td className="border-b border-gray-300 px-2 py-1">{quoteData.siteName || '-'}</td>
</tr>
<tr>
<td className="border-b border-r border-gray-300 bg-gray-50 px-2 py-1"></td>
<td className="border-b border-gray-300 px-2 py-1">{quoteData.manager || '-'}</td>
</tr>
<tr>
<td className="border-r border-gray-300 bg-gray-50 px-2 py-1"></td>
<td className="px-2 py-1">{quoteData.contact || '-'}</td>
</tr>
</tbody>
</table>
</div>
<div className="p-3 grid grid-cols-2 gap-2 text-sm">
<div className="flex">
<span className="w-24 text-gray-600"></span>
<span className="font-medium">_테스트회사</span>
</div>
<div className="flex">
<span className="w-24 text-gray-600"></span>
<span className="font-medium">123-45-67890</span>
</div>
<div className="flex">
<span className="w-24 text-gray-600"></span>
<span className="font-medium"></span>
</div>
<div className="flex">
<span className="w-24 text-gray-600"></span>
<span className="font-medium"></span>
</div>
<div className="flex col-span-2">
<span className="w-24 text-gray-600"></span>
<span className="font-medium"></span>
</div>
<div className="flex col-span-2">
<span className="w-24 text-gray-600"></span>
<span className="font-medium">07547 583 B-1602</span>
</div>
<div className="flex">
<span className="w-24 text-gray-600"></span>
<span className="font-medium">01048209104</span>
</div>
<div className="flex">
<span className="w-24 text-gray-600"></span>
<span className="font-medium">codebridgex@codebridge-x.com</span>
{/* 공급자 */}
<div className="border border-gray-400">
<div className="bg-gray-200 px-2 py-1 font-semibold text-center border-b border-gray-400">
</div>
<table className="w-full text-xs">
<tbody>
<tr>
<td className="border-b border-r border-gray-300 bg-gray-50 px-2 py-1 w-20"></td>
<td className="border-b border-gray-300 px-2 py-1"></td>
</tr>
<tr>
<td className="border-b border-r border-gray-300 bg-gray-50 px-2 py-1"></td>
<td className="border-b border-gray-300 px-2 py-1">123-12-12345</td>
</tr>
<tr>
<td className="border-b border-r border-gray-300 bg-gray-50 px-2 py-1"></td>
<td className="border-b border-gray-300 px-2 py-1"></td>
</tr>
<tr>
<td className="border-b border-r border-gray-300 bg-gray-50 px-2 py-1"></td>
<td className="border-b border-gray-300 px-2 py-1"></td>
</tr>
<tr>
<td className="border-r border-gray-300 bg-gray-50 px-2 py-1">TEL</td>
<td className="px-2 py-1">031-123-1234</td>
</tr>
</tbody>
</table>
</div>
</div>
{/* 총 견적금액 */}
<div className="border-2 border-gray-800 p-4 mb-6 text-center">
<p className="text-sm text-gray-600 mb-1"> </p>
<p className="text-3xl font-bold">
{grandTotal.toLocaleString()}
</p>
<p className="text-xs text-gray-500 mt-1"> </p>
</div>
{/* 제품 구성정보 */}
<div className="border border-gray-300 mb-6">
<SectionHeader> </SectionHeader>
<div className="p-3 grid grid-cols-2 gap-2 text-sm">
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span className="font-medium">
{quoteData.locations[0]?.productCode || "-"}
</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"> </span>
<span className="font-medium">{quoteData.locations.length}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span className="font-medium">
{quoteData.locations[0]?.openWidth || "-"} × {quoteData.locations[0]?.openHeight || "-"}
</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span className="font-medium">-</span>
</div>
{/* 내역 테이블 */}
<div className="border border-gray-400 mb-4">
<div className="bg-gray-800 text-white px-2 py-1 font-semibold text-center">
</div>
</div>
{/* 품목 내역 */}
<div className="border border-gray-300 mb-6">
<SectionHeader> </SectionHeader>
<table className="w-full text-sm">
<table className="w-full text-xs">
<thead>
<tr className="bg-gray-100 border-b border-gray-300">
<th className="px-3 py-2 text-left">No.</th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-center"></th>
<th className="px-3 py-2 text-center"></th>
<th className="px-3 py-2 text-center"></th>
<th className="px-3 py-2 text-right"></th>
<th className="px-3 py-2 text-right"></th>
<tr className="bg-gray-100 border-b border-gray-400">
<th className="border-r border-gray-300 px-2 py-1">No.</th>
<th className="border-r border-gray-300 px-2 py-1"></th>
<th className="border-r border-gray-300 px-2 py-1"></th>
<th className="border-r border-gray-300 px-2 py-1"></th>
<th className="border-r border-gray-300 px-2 py-1" colSpan={2}></th>
<th className="border-r border-gray-300 px-2 py-1"></th>
<th className="border-r border-gray-300 px-2 py-1"></th>
<th className="border-r border-gray-300 px-2 py-1"></th>
<th className="px-2 py-1"></th>
</tr>
<tr className="bg-gray-50 border-b border-gray-300 text-[10px] text-gray-500">
<th className="border-r border-gray-300 px-2 py-0.5"></th>
<th className="border-r border-gray-300 px-2 py-0.5"></th>
<th className="border-r border-gray-300 px-2 py-0.5"></th>
<th className="border-r border-gray-300 px-2 py-0.5"></th>
<th className="border-r border-gray-300 px-1 py-0.5"></th>
<th className="border-r border-gray-300 px-1 py-0.5"></th>
<th className="border-r border-gray-300 px-2 py-0.5"></th>
<th className="border-r border-gray-300 px-2 py-0.5"></th>
<th className="border-r border-gray-300 px-2 py-0.5"></th>
<th className="px-2 py-0.5"></th>
</tr>
</thead>
<tbody>
{quoteData.locations.map((loc, index) => (
<tr key={loc.id} className="border-b border-gray-200">
<td className="px-3 py-2">{index + 1}</td>
<td className="px-3 py-2">{loc.productCode}</td>
<td className="px-3 py-2 text-center">
{loc.openWidth}×{loc.openHeight}
</td>
<td className="px-3 py-2 text-center">{loc.quantity}</td>
<td className="px-3 py-2 text-center">EA</td>
<td className="px-3 py-2 text-right">
<tr key={loc.id} className="border-b border-gray-300">
<td className="border-r border-gray-300 px-2 py-1 text-center">{index + 1}</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">{loc.floor || '-'}</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">{loc.symbol || '-'}</td>
<td className="border-r border-gray-300 px-2 py-1">{loc.productCode}</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">{loc.openWidth}</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">{loc.openHeight}</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">{loc.quantity}</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">SET</td>
<td className="border-r border-gray-300 px-2 py-1 text-right">
{(loc.unitPrice || 0).toLocaleString()}
</td>
<td className="px-3 py-2 text-right">
<td className="px-2 py-1 text-right">
{(loc.totalPrice || 0).toLocaleString()}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 border-gray-400">
<td colSpan={5}></td>
<td className="px-3 py-2 text-right font-medium"> </td>
<td className="px-3 py-2 text-right font-bold">
{totalAmount.toLocaleString()}
{/* 소계 */}
<tr className="border-b border-gray-300">
<td colSpan={6} className="border-r border-gray-300"></td>
<td colSpan={3} className="border-r border-gray-300 px-2 py-1 text-right bg-gray-50">
{vatIncluded ? '공급가액 합계' : '소계'}
</td>
<td className="px-2 py-1 text-right">{subtotal.toLocaleString()}</td>
</tr>
{/* 할인율 */}
<tr className="border-b border-gray-300">
<td colSpan={6} className="border-r border-gray-300"></td>
<td colSpan={3} className="border-r border-gray-300 px-2 py-1 text-right bg-gray-50">
</td>
<td className="px-2 py-1 text-right text-orange-600">
{hasDiscount ? `${discountRate.toFixed(1)}%` : '0.0%'}
</td>
</tr>
<tr>
<td colSpan={5}></td>
<td className="px-3 py-2 text-right font-medium"> (10%)</td>
<td className="px-3 py-2 text-right font-bold">
{vat.toLocaleString()}
</td>
</tr>
<tr className="bg-gray-100">
<td colSpan={5}></td>
<td className="px-3 py-2 text-right font-medium"> </td>
<td className="px-3 py-2 text-right font-bold text-lg">
{grandTotal.toLocaleString()}
{/* 할인금액 */}
<tr className="border-b border-gray-300">
<td colSpan={6} className="border-r border-gray-300"></td>
<td colSpan={3} className="border-r border-gray-300 px-2 py-1 text-right bg-gray-50">
</td>
<td className="px-2 py-1 text-right text-orange-600">
{hasDiscount ? `-${discountAmount.toLocaleString()}` : '0'}
</td>
</tr>
{/* 부가세 포함일 때 추가 행들 */}
{vatIncluded && (
<>
<tr className="border-b border-gray-300">
<td colSpan={6} className="border-r border-gray-300"></td>
<td colSpan={3} className="border-r border-gray-300 px-2 py-1 text-right bg-gray-50">
</td>
<td className="px-2 py-1 text-right">{afterDiscount.toLocaleString()}</td>
</tr>
<tr className="border-b border-gray-300">
<td colSpan={6} className="border-r border-gray-300"></td>
<td colSpan={3} className="border-r border-gray-300 px-2 py-1 text-right bg-gray-50">
</td>
<td className="px-2 py-1 text-right">{vat.toLocaleString()}</td>
</tr>
<tr>
<td colSpan={6} className="border-r border-gray-300"></td>
<td colSpan={3} className="border-r border-gray-300 px-2 py-1 text-right bg-gray-50 font-semibold">
</td>
<td className="px-2 py-1 text-right font-semibold">{grandTotal.toLocaleString()}</td>
</tr>
</>
)}
</tfoot>
</table>
</div>
{/* 비고사항 */}
<div className="border border-gray-300">
<SectionHeader> </SectionHeader>
<div className="p-3 min-h-[80px] text-sm text-gray-600">
{quoteData.remarks || "비고 테스트"}
{/* 합계금액 박스 */}
<div className="border-2 border-gray-800 p-3 mb-4 flex justify-between items-center">
<span className="font-semibold text-red-600">
({vatIncluded ? '부가세 포함' : '부가세 별도'})
</span>
<span className="text-xl font-bold">
{grandTotal.toLocaleString()}
</span>
</div>
{/* 산출내역서일 경우 세부 산출내역서 테이블 추가 */}
{isCalculation && (
<div className="border border-gray-400 mb-4">
<div className="bg-gray-800 text-white px-2 py-1 font-semibold text-center">
</div>
<table className="w-full text-xs">
<thead>
<tr className="bg-gray-100 border-b border-gray-400">
<th className="border-r border-gray-300 px-2 py-1"></th>
<th className="border-r border-gray-300 px-2 py-1"></th>
<th className="border-r border-gray-300 px-2 py-1"></th>
<th className="border-r border-gray-300 px-2 py-1"></th>
<th className="border-r border-gray-300 px-2 py-1"></th>
<th className="border-r border-gray-300 px-2 py-1"></th>
<th className="px-2 py-1"></th>
</tr>
</thead>
<tbody>
{quoteData.locations.map((loc) => (
<>
{/* 각 개소별 품목 상세 */}
<tr key={`${loc.id}-main`} className="border-b border-gray-300">
<td className="border-r border-gray-300 px-2 py-1 text-center">{loc.symbol || '-'}</td>
<td className="border-r border-gray-300 px-2 py-1"></td>
<td className="border-r border-gray-300 px-2 py-1 text-center"></td>
<td className="border-r border-gray-300 px-2 py-1 text-center">{loc.quantity}</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">SET</td>
<td className="border-r border-gray-300 px-2 py-1 text-right">
{(loc.unitPrice || 0).toLocaleString()}
</td>
<td className="px-2 py-1 text-right">
{(loc.totalPrice || 0).toLocaleString()}
</td>
</tr>
</>
))}
{/* 소계 */}
<tr className="bg-gray-50">
<td colSpan={3} className="border-r border-gray-300"></td>
<td colSpan={3} className="border-r border-gray-300 px-2 py-1 text-right font-semibold">
</td>
<td className="px-2 py-1 text-right font-semibold">{subtotal.toLocaleString()}</td>
</tr>
</tbody>
</table>
</div>
)}
{/* 비고 */}
<div className="border border-gray-400 mb-4">
<div className="grid grid-cols-[80px_1fr]">
<div className="bg-gray-200 px-2 py-2 font-semibold text-center border-r border-gray-400">
</div>
<div className="px-3 py-2 text-xs">
<p> <span className="text-red-600 font-semibold"> 1</span> .</p>
<p> 50% .</p>
</div>
</div>
</div>
{/* 결제방법 / 담당자 */}
<div className="border border-gray-400">
<table className="w-full text-xs">
<tbody>
<tr className="border-b border-gray-300">
<td className="bg-gray-200 px-2 py-1 w-20 text-center border-r border-gray-300 font-semibold"></td>
<td className="px-2 py-1 border-r border-gray-300"></td>
<td className="bg-gray-200 px-2 py-1 w-20 text-center border-r border-gray-300 font-semibold"></td>
<td className="px-2 py-1"> 12312132132</td>
</tr>
<tr>
<td className="bg-gray-200 px-2 py-1 text-center border-r border-gray-300 font-semibold"></td>
<td className="px-2 py-1 border-r border-gray-300"> </td>
<td className="bg-gray-200 px-2 py-1 text-center border-r border-gray-300 font-semibold"></td>
<td className="px-2 py-1">010-1234-1234</td>
</tr>
</tbody>
</table>
</div>
</div>
);

View File

@@ -3,68 +3,131 @@
/**
* 견적서 미리보기 모달
*
* document-system 통합 버전 (2026-01-22)
* 양식 선택:
* - 업체발송용 (부가세 포함/별도는 기본정보에서 선택한 값 사용)
* - 산출내역서
*/
import { Download, Mail } from 'lucide-react';
import { useState } from 'react';
import { Copy, Pencil, FileText } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { DocumentViewer } from '@/components/document-system';
import type { QuoteFormDataV2 } from './QuoteRegistrationV2';
import { QuotePreviewContent } from './QuotePreviewContent';
// 양식 타입: 업체발송용 / 산출내역서
type TemplateType = 'vendor' | 'calculation';
interface QuotePreviewModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
quoteData: QuoteFormDataV2 | null;
/** 할인율 (%) */
discountRate?: number;
/** 할인금액 (원) */
discountAmount?: number;
/** 복제 핸들러 */
onDuplicate?: () => void;
/** 수정 핸들러 */
onEdit?: () => void;
}
export function QuotePreviewModal({
open,
onOpenChange,
quoteData,
discountRate = 0,
discountAmount = 0,
onDuplicate,
onEdit,
}: QuotePreviewModalProps) {
// 양식 타입 상태 (기본: 업체발송용)
const [templateType, setTemplateType] = useState<TemplateType>('vendor');
if (!quoteData) return null;
const handlePdfDownload = () => {
console.log('[테스트] PDF 다운로드');
// 부가세 포함 여부는 기본정보에서 선택한 값 사용
const vatIncluded = quoteData.vatType === 'included';
const handleDuplicate = () => {
console.log('[테스트] 복제');
onDuplicate?.();
};
const handleEmailSend = () => {
console.log('[테스트] 이메일 전송');
const handleEdit = () => {
console.log('[테스트] 수정');
onEdit?.();
};
const toolbarExtra = (
<>
{/* 복제 버튼 */}
<Button
variant="outline"
size="sm"
className="bg-red-500 hover:bg-red-600 text-white border-red-500"
onClick={handlePdfDownload}
onClick={handleDuplicate}
>
<Download className="h-4 w-4 mr-1" />
PDF
<Copy className="h-4 w-4 mr-1" />
</Button>
{/* 수정 버튼 */}
<Button
variant="outline"
size="sm"
className="bg-yellow-500 hover:bg-yellow-600 text-white border-yellow-500"
onClick={handleEmailSend}
onClick={handleEdit}
>
<Mail className="h-4 w-4 mr-1" />
<Pencil className="h-4 w-4 mr-1" />
</Button>
{/* 양식 선택 드롭다운 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<FileText className="h-4 w-4 mr-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => setTemplateType('vendor')}
className={templateType === 'vendor' ? 'bg-blue-50' : ''}
>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setTemplateType('calculation')}
className={templateType === 'calculation' ? 'bg-blue-50' : ''}
>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
);
return (
<DocumentViewer
title="견적서"
title={templateType === 'calculation' ? '산출내역서' : '견적서'}
preset="inspection"
open={open}
onOpenChange={onOpenChange}
toolbarExtra={toolbarExtra}
>
<QuotePreviewContent data={quoteData} />
<QuotePreviewContent
data={quoteData}
templateType={templateType}
vatIncluded={vatIncluded}
discountRate={discountRate}
discountAmount={discountAmount}
/>
</DocumentViewer>
);
}

View File

@@ -33,6 +33,8 @@ import { LocationDetailPanel } from "./LocationDetailPanel";
import { QuoteSummaryPanel } from "./QuoteSummaryPanel";
import { QuoteFooterBar } from "./QuoteFooterBar";
import { QuotePreviewModal } from "./QuotePreviewModal";
import { QuoteTransactionModal } from "./QuoteTransactionModal";
import { DiscountModal } from "./DiscountModal";
import {
getFinishedGoods,
@@ -82,15 +84,16 @@ export interface LocationItem {
// 견적 폼 데이터 V2
export interface QuoteFormDataV2 {
id?: string;
registrationDate: string;
writer: string;
clientId: string;
clientName: string;
siteName: string;
manager: string;
contact: string;
dueDate: string;
remarks: string;
quoteNumber: string; // 견적번호
registrationDate: string; // 접수일
writer: string; // 작성자
clientId: string; // 수주처 ID
clientName: string; // 수주처명
siteName: string; // 현장명
manager: string; // 담당자
contact: string; // 연락처
vatType: "included" | "excluded"; // 부가세 (포함/별도)
remarks: string; // 비고
status: "draft" | "temporary" | "final"; // 작성중, 임시저장, 최종저장
locations: LocationItem[];
}
@@ -118,6 +121,7 @@ const createNewLocation = (): LocationItem => ({
// 초기 폼 데이터
const INITIAL_FORM_DATA: QuoteFormDataV2 = {
quoteNumber: "", // 자동생성 또는 서버에서 부여
registrationDate: getLocalDateString(new Date()),
writer: "", // useAuth()에서 currentUser.name으로 설정됨
clientId: "",
@@ -125,7 +129,7 @@ const INITIAL_FORM_DATA: QuoteFormDataV2 = {
siteName: "",
manager: "",
contact: "",
dueDate: "",
vatType: "included", // 기본값: 부가세 포함
remarks: "",
status: "draft",
locations: [],
@@ -172,7 +176,11 @@ export function QuoteRegistrationV2({
const [selectedLocationId, setSelectedLocationId] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [isCalculating, setIsCalculating] = useState(false);
const [previewModalOpen, setPreviewModalOpen] = useState(false);
const [quotePreviewOpen, setQuotePreviewOpen] = useState(false);
const [transactionPreviewOpen, setTransactionPreviewOpen] = useState(false);
const [discountModalOpen, setDiscountModalOpen] = useState(false);
const [discountRate, setDiscountRate] = useState(0);
const [discountAmount, setDiscountAmount] = useState(0);
const pendingAutoCalculateRef = useRef(false);
// API 데이터
@@ -286,11 +294,23 @@ export function QuoteRegistrationV2({
return formData.locations.find((loc) => loc.id === selectedLocationId) || null;
}, [formData.locations, selectedLocationId]);
// 총 금액
// 총 금액 (할인 전)
const totalAmount = useMemo(() => {
return formData.locations.reduce((sum, loc) => sum + (loc.totalPrice || 0), 0);
}, [formData.locations]);
// 할인 적용 후 총 금액
const discountedTotalAmount = useMemo(() => {
return totalAmount - discountAmount;
}, [totalAmount, discountAmount]);
// 할인 적용 핸들러
const handleApplyDiscount = useCallback((rate: number, amount: number) => {
setDiscountRate(rate);
setDiscountAmount(amount);
toast.success(`할인이 적용되었습니다. (${rate.toFixed(1)}%, ${amount.toLocaleString()}원)`);
}, []);
// 개소별 합계
const locationTotals = useMemo(() => {
return formData.locations.map((loc) => ({
@@ -661,33 +681,34 @@ export function QuoteRegistrationV2({
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* 1행: 견적번호 | 접수일 | 수주처 | 현장명 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label className="text-sm font-medium text-gray-700"></label>
<label className="text-sm font-medium text-gray-700"></label>
<Input
value={formData.quoteNumber || "-"}
disabled
className="bg-gray-50"
/>
</div>
<div>
<label className="text-sm font-medium text-gray-700"></label>
<Input
type="date"
value={formData.registrationDate}
disabled
className="bg-gray-50"
onChange={(e) => handleFieldChange("registrationDate", e.target.value)}
disabled={isViewMode}
/>
</div>
<div>
<label className="text-sm font-medium text-gray-700"></label>
<Input
value={formData.writer}
disabled
className="bg-gray-50"
/>
</div>
<div>
<label className="text-sm font-medium text-gray-700"> <span className="text-red-500">*</span></label>
<label className="text-sm font-medium text-gray-700"> <span className="text-red-500">*</span></label>
<Select
value={formData.clientId}
onValueChange={handleClientChange}
disabled={isViewMode || isLoadingClients}
>
<SelectTrigger>
<SelectValue placeholder={isLoadingClients ? "로딩 중..." : "주처를 선택하세요"} />
<SelectValue placeholder={isLoadingClients ? "로딩 중..." : "주처를 선택하세요"} />
</SelectTrigger>
<SelectContent>
{clients.map((client) => (
@@ -698,9 +719,6 @@ export function QuoteRegistrationV2({
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="text-sm font-medium text-gray-700"></label>
<Input
@@ -716,8 +734,12 @@ export function QuoteRegistrationV2({
))}
</datalist>
</div>
</div>
{/* 2행: 담당자 | 연락처 | 작성자 | 부가세 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label className="text-sm font-medium text-gray-700"> </label>
<label className="text-sm font-medium text-gray-700"></label>
<Input
placeholder="담당자명을 입력하세요"
value={formData.manager}
@@ -734,26 +756,49 @@ export function QuoteRegistrationV2({
disabled={isViewMode}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="text-sm font-medium text-gray-700"></label>
<label className="text-sm font-medium text-gray-700"></label>
<Input
type="date"
value={formData.dueDate}
onChange={(e) => handleFieldChange("dueDate", e.target.value)}
disabled={isViewMode}
value={formData.writer}
disabled
className="bg-gray-50"
/>
</div>
<div className="md:col-span-2">
<div>
<label className="text-sm font-medium text-gray-700"></label>
<Select
value={formData.vatType}
onValueChange={(value) => handleFieldChange("vatType", value)}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="부가세 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="included"> </SelectItem>
<SelectItem value="excluded"> </SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 3행: 상태 | 비고 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label className="text-sm font-medium text-gray-700"></label>
<Input
value={formData.status === "final" ? "최종확정" : formData.status === "temporary" ? "임시저장" : "최초작성"}
disabled
className="bg-gray-50"
/>
</div>
<div className="md:col-span-3">
<label className="text-sm font-medium text-gray-700"></label>
<Textarea
<Input
placeholder="특이사항을 입력하세요"
value={formData.remarks}
onChange={(e) => handleFieldChange("remarks", e.target.value)}
disabled={isViewMode}
rows={2}
/>
</div>
</div>
@@ -788,8 +833,56 @@ export function QuoteRegistrationV2({
<LocationDetailPanel
location={selectedLocation}
onUpdateLocation={handleUpdateLocation}
onDeleteLocation={handleDeleteLocation}
onCalculateLocation={async (locationId) => {
// 단일 개소 산출
const location = formData.locations.find((loc) => loc.id === locationId);
if (!location) return;
setIsCalculating(true);
try {
const bomItem = {
finished_goods_code: location.productCode,
openWidth: location.openWidth,
openHeight: location.openHeight,
quantity: location.quantity,
guideRailType: location.guideRailType,
motorPower: location.motorPower,
controller: location.controller,
wingSize: location.wingSize,
inspectionFee: location.inspectionFee,
};
const result = await calculateBomBulk([bomItem]);
if (result.success && result.data) {
const apiData = result.data as BomBulkResponse;
const bomResult = apiData.items?.[0]?.result;
if (bomResult) {
handleUpdateLocation(locationId, {
unitPrice: bomResult.grand_total,
totalPrice: bomResult.grand_total * location.quantity,
bomResult: bomResult,
});
toast.success("견적이 산출되었습니다.");
}
} else {
toast.error(result.error || "산출 실패");
}
} catch (error) {
console.error("산출 오류:", error);
toast.error("산출 중 오류가 발생했습니다.");
} finally {
setIsCalculating(false);
}
}}
onSaveItems={() => {
toast.success("품목이 저장되었습니다.");
}}
finishedGoods={finishedGoods}
disabled={isViewMode}
isCalculating={isCalculating}
/>
</div>
</CardContent>
@@ -806,25 +899,46 @@ export function QuoteRegistrationV2({
{/* 푸터 바 (고정) */}
<QuoteFooterBar
totalLocations={formData.locations.length}
totalAmount={totalAmount}
totalAmount={discountedTotalAmount}
status={formData.status}
onCalculate={handleCalculate}
onPreview={() => setPreviewModalOpen(true)}
onSaveTemporary={() => handleSave("temporary")}
onSaveFinal={() => handleSave("final")}
onQuoteView={() => setQuotePreviewOpen(true)}
onTransactionView={() => setTransactionPreviewOpen(true)}
onSave={() => handleSave("temporary")}
onFinalize={() => handleSave("final")}
onBack={onBack}
onEdit={onEdit}
onOrderRegister={onOrderRegister}
isCalculating={isCalculating}
onDiscount={() => setDiscountModalOpen(true)}
isSaving={isSaving}
isViewMode={isViewMode}
/>
{/* 견적서 미리보기 모달 */}
{/* 견적서 보기 모달 */}
<QuotePreviewModal
open={previewModalOpen}
onOpenChange={setPreviewModalOpen}
open={quotePreviewOpen}
onOpenChange={setQuotePreviewOpen}
quoteData={formData}
discountRate={discountRate}
discountAmount={discountAmount}
/>
{/* 거래명세서 보기 모달 */}
<QuoteTransactionModal
open={transactionPreviewOpen}
onOpenChange={setTransactionPreviewOpen}
quoteData={formData}
discountRate={discountRate}
discountAmount={discountAmount}
/>
{/* 할인하기 모달 */}
<DiscountModal
open={discountModalOpen}
onOpenChange={setDiscountModalOpen}
supplyAmount={totalAmount}
initialDiscountRate={discountRate}
initialDiscountAmount={discountAmount}
onApply={handleApplyDiscount}
/>
</div>
);

View File

@@ -0,0 +1,221 @@
'use client';
/**
* 거래명세서 보기 모달
*
* 견적 데이터를 거래명세서 양식으로 표시
* - 공급자/공급받는자 정보
* - 품목내역
* - 금액 계산 (공급가액, 할인, 부가세, 합계)
* - 증명 문구 + 인감
*/
import { DocumentViewer } from '@/components/document-system';
import type { QuoteFormDataV2 } from './QuoteRegistrationV2';
interface QuoteTransactionModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
quoteData: QuoteFormDataV2 | null;
/** 할인율 (%) */
discountRate?: number;
/** 할인금액 (원) */
discountAmount?: number;
}
export function QuoteTransactionModal({
open,
onOpenChange,
quoteData,
discountRate = 0,
discountAmount = 0,
}: QuoteTransactionModalProps) {
if (!quoteData) return null;
// locations 배열 (undefined 방어)
const locations = quoteData.locations || [];
// 금액 계산
const subtotal = locations.reduce((sum, loc) => {
const locationTotal = (loc.items || []).reduce((itemSum, item) => itemSum + (item.amount || 0), 0);
return sum + locationTotal * (loc.quantity || 1);
}, 0);
const afterDiscount = subtotal - discountAmount;
const vat = Math.round(afterDiscount * 0.1);
const finalTotal = afterDiscount + vat;
// 부가세 포함 여부
const vatIncluded = quoteData.vatType === 'included';
// 오늘 날짜
const today = new Date().toISOString().split('T')[0];
return (
<DocumentViewer
title="거래명세서"
preset="inspection"
open={open}
onOpenChange={onOpenChange}
>
<div className="bg-white p-8 min-h-full">
{/* 문서 헤더 */}
<div className="text-center mb-6">
<h1 className="text-2xl font-bold tracking-widest mb-2"> </h1>
<p className="text-sm text-gray-600">
: {quoteData.quoteNumber || '-'} | : {quoteData.receiptDate || today}
</p>
</div>
{/* 공급자/공급받는자 정보 */}
<div className="grid grid-cols-2 gap-4 mb-4">
{/* 공급자 */}
<div className="border border-gray-300">
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
</div>
<div className="p-3 space-y-1 text-sm">
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span></span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span></span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span>123-12-12345</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span></span>
</div>
</div>
</div>
{/* 공급받는자 */}
<div className="border border-gray-300">
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
</div>
<div className="p-3 space-y-1 text-sm">
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span>{quoteData.clientName || '-'}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span>{quoteData.managerName || '-'}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span>{quoteData.contact || '-'}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span>{quoteData.siteName || '-'}</span>
</div>
</div>
</div>
</div>
{/* 품목내역 */}
<div className="border border-gray-300 mb-4">
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
</div>
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-100 border-b border-gray-300">
<th className="p-2 text-center font-medium border-r border-gray-300 w-12"></th>
<th className="p-2 text-center font-medium border-r border-gray-300 w-28"></th>
<th className="p-2 text-left font-medium border-r border-gray-300"></th>
<th className="p-2 text-center font-medium border-r border-gray-300 w-24"></th>
<th className="p-2 text-center font-medium border-r border-gray-300 w-12"></th>
<th className="p-2 text-center font-medium border-r border-gray-300 w-12"></th>
<th className="p-2 text-right font-medium border-r border-gray-300 w-24"></th>
<th className="p-2 text-right font-medium w-24"></th>
</tr>
</thead>
<tbody>
{locations.length > 0 ? (
locations.map((location, index) => (
<tr key={location.id} className="border-b border-gray-300">
<td className="p-2 text-center border-r border-gray-300">{index + 1}</td>
<td className="p-2 text-center border-r border-gray-300">{location.productCode || '-'}</td>
<td className="p-2 border-r border-gray-300">{location.floor} / {location.symbol}</td>
<td className="p-2 text-center border-r border-gray-300">{location.width}x{location.height}</td>
<td className="p-2 text-center border-r border-gray-300">{location.quantity || 1}</td>
<td className="p-2 text-center border-r border-gray-300">SET</td>
<td className="p-2 text-right border-r border-gray-300">
{(location.items || []).reduce((sum, item) => sum + (item.amount || 0), 0).toLocaleString()}
</td>
<td className="p-2 text-right">
{((location.items || []).reduce((sum, item) => sum + (item.amount || 0), 0) * (location.quantity || 1)).toLocaleString()}
</td>
</tr>
))
) : (
<tr className="border-b border-gray-300">
<td colSpan={8} className="p-4 text-center text-gray-400">
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* 금액 계산 */}
<div className="border border-gray-300 mb-6">
<table className="w-full text-sm">
<tbody>
<tr className="border-b border-gray-300">
<td className="p-2 bg-gray-100 border-r border-gray-300 w-32"></td>
<td className="p-2 text-right">{subtotal.toLocaleString()}</td>
</tr>
{/* 할인 적용 시에만 표시 */}
{(discountRate > 0 || discountAmount > 0) && (
<>
<tr className="border-b border-gray-300">
<td className="p-2 bg-gray-100 border-r border-gray-300"></td>
<td className="p-2 text-right">{discountRate.toFixed(2)}%</td>
</tr>
<tr className="border-b border-gray-300">
<td className="p-2 bg-gray-100 border-r border-gray-300"></td>
<td className="p-2 text-right text-red-600">
-{discountAmount.toLocaleString()}
</td>
</tr>
<tr className="border-b border-gray-300">
<td className="p-2 bg-gray-100 border-r border-gray-300"> </td>
<td className="p-2 text-right">{afterDiscount.toLocaleString()}</td>
</tr>
</>
)}
<tr className="border-b border-gray-300">
<td className="p-2 bg-gray-100 border-r border-gray-300"> (10%)</td>
<td className="p-2 text-right">{vat.toLocaleString()}</td>
</tr>
<tr>
<td className="p-2 bg-gray-100 border-r border-gray-300 font-medium"> </td>
<td className="p-2 text-right font-bold text-lg"> {finalTotal.toLocaleString()}</td>
</tr>
</tbody>
</table>
</div>
{/* 증명 문구 */}
<div className="text-center py-6 border-t border-gray-300">
<p className="text-sm mb-4"> .</p>
<p className="text-sm text-gray-600 mb-4">{quoteData.receiptDate || today}</p>
<div className="flex justify-center">
<div className="w-12 h-12 border-2 border-red-400 rounded-full flex items-center justify-center text-red-400 text-xs">
</div>
</div>
</div>
</div>
</DocumentViewer>
);
}

View File

@@ -14,11 +14,14 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { toast } from 'sonner';
import { Download, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import {
IntegratedListTemplateV2,
type PaginationConfig,
} from '@/components/templates/IntegratedListTemplateV2';
import { downloadExcel, downloadSelectedExcel } from '@/lib/utils/excel-download';
import type {
UniversalListPageProps,
TabOption,
@@ -71,6 +74,9 @@ export function UniversalListPage<T>({
const [itemToDelete, setItemToDelete] = useState<T | null>(null);
const [isBulkDelete, setIsBulkDelete] = useState(false);
// 엑셀 다운로드 상태
const [isExcelDownloading, setIsExcelDownloading] = useState(false);
const itemsPerPage = config.itemsPerPage || 20;
// 모바일 인피니티 스크롤 로딩 상태 (서버 사이드 페이지네이션 시)
@@ -460,6 +466,154 @@ export function UniversalListPage<T>({
setSelectedItems(new Set());
}, [config.initialFilters]);
// ===== 엑셀 다운로드 핸들러 =====
const handleExcelDownload = useCallback(async () => {
if (!config.excelDownload) return;
const { columns, filename = 'export', sheetName = 'Sheet1', fetchAllUrl, fetchAllParams, mapResponse } = config.excelDownload;
setIsExcelDownloading(true);
try {
let dataToDownload: T[];
// 디버깅: 데이터 개수 확인
console.log('[Excel] clientSideFiltering:', config.clientSideFiltering);
console.log('[Excel] rawData.length:', rawData.length);
console.log('[Excel] filteredData.length:', filteredData.length);
console.log('[Excel] fetchAllUrl:', fetchAllUrl);
// 클라이언트 사이드 필터링: 현재 필터링된 전체 데이터 사용
if (config.clientSideFiltering) {
dataToDownload = filteredData;
}
// 서버 사이드: fetchAllUrl로 전체 데이터 조회
else if (fetchAllUrl) {
const params = new URLSearchParams();
// 전체 데이터 조회 - API마다 다른 파라미터명 사용 가능하므로 둘 다 설정
params.append('size', '10000');
params.append('per_page', '10000');
// 동적 파라미터 추가
if (fetchAllParams) {
const additionalParams = fetchAllParams({
activeTab,
filters,
searchValue,
});
Object.entries(additionalParams).forEach(([key, value]) => {
if (value) params.append(key, value);
});
}
const response = await fetch(`${fetchAllUrl}?${params.toString()}`);
const result = await response.json();
if (!result.success) {
throw new Error(result.message || '데이터 조회에 실패했습니다.');
}
// 응답 매핑
const rawData = mapResponse
? mapResponse(result)
: (result.data?.data ?? result.data ?? []);
dataToDownload = rawData as T[];
}
// fetchAllUrl 없으면 현재 로드된 데이터 사용
else {
dataToDownload = rawData;
}
if (dataToDownload.length === 0) {
toast.warning('다운로드할 데이터가 없습니다.');
return;
}
downloadExcel({
data: dataToDownload as Record<string, unknown>[],
columns,
filename,
sheetName,
});
toast.success(`${dataToDownload.length}건 다운로드 완료`);
} catch (error) {
console.error('[Excel] 다운로드 실패:', error);
toast.error(error instanceof Error ? error.message : '엑셀 다운로드에 실패했습니다.');
} finally {
setIsExcelDownloading(false);
}
}, [config.excelDownload, config.clientSideFiltering, filteredData, rawData, activeTab, filters, searchValue]);
// 선택 항목 엑셀 다운로드
const handleSelectedExcelDownload = useCallback(() => {
if (!config.excelDownload) return;
const { columns, filename = 'export', sheetName = 'Sheet1' } = config.excelDownload;
const selectedIds = Array.from(effectiveSelectedItems);
if (selectedIds.length === 0) {
toast.warning('선택된 항목이 없습니다.');
return;
}
// 현재 데이터에서 선택된 항목 필터링
const selectedData = rawData.filter((item) => selectedIds.includes(getItemId(item)));
downloadSelectedExcel({
data: selectedData as Record<string, unknown>[],
columns,
selectedIds,
idField: 'id',
filename: `${filename}_선택`,
sheetName,
});
toast.success(`${selectedData.length}건 다운로드 완료`);
}, [config.excelDownload, effectiveSelectedItems, rawData, getItemId]);
// 엑셀 다운로드 버튼 렌더링
const renderExcelDownloadButton = useMemo(() => {
if (!config.excelDownload || config.excelDownload.enabled === false) {
return null;
}
const { enableSelectedDownload = true } = config.excelDownload;
// 선택 항목이 있고 선택 다운로드가 활성화된 경우
if (enableSelectedDownload && effectiveSelectedItems.size > 0) {
return (
<Button
variant="outline"
size="sm"
onClick={handleSelectedExcelDownload}
className="gap-2"
>
<Download className="h-4 w-4" />
({effectiveSelectedItems.size})
</Button>
);
}
// 전체 다운로드
return (
<Button
variant="outline"
size="sm"
onClick={handleExcelDownload}
disabled={isExcelDownloading}
className="gap-2"
>
{isExcelDownloading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
{isExcelDownloading ? '다운로드 중...' : '엑셀 다운로드'}
</Button>
);
}, [config.excelDownload, effectiveSelectedItems.size, isExcelDownloading, handleExcelDownload, handleSelectedExcelDownload]);
// ===== 정렬 핸들러 =====
const handleSort = useCallback((key: string) => {
if (sortBy === key) {
@@ -590,12 +744,19 @@ export function UniversalListPage<T>({
title={config.title}
description={config.description}
icon={config.icon}
headerActions={config.headerActions?.({
onCreate: handleCreate,
selectedItems: effectiveSelectedItems,
onClearSelection: () => setSelectedItems(new Set()),
onRefresh: fetchData,
})}
headerActions={
<>
{/* 엑셀 다운로드 버튼 (config.excelDownload 설정 시 자동 추가) */}
{renderExcelDownloadButton}
{/* 커스텀 헤더 액션 */}
{config.headerActions?.({
onCreate: handleCreate,
selectedItems: effectiveSelectedItems,
onClearSelection: () => setSelectedItems(new Set()),
onRefresh: fetchData,
})}
</>
}
// 공통 헤더 옵션 (달력/등록버튼)
dateRangeSelector={config.dateRangeSelector}
createButton={config.createButton}

View File

@@ -8,6 +8,7 @@
import { ReactNode, RefObject } from 'react';
import { LucideIcon } from 'lucide-react';
import type { FilterFieldConfig, FilterValues } from '@/components/molecules/MobileFilter';
import type { ExcelColumn } from '@/lib/utils/excel-download';
// ===== 기본 타입 (IntegratedListTemplateV2에서 re-export) =====
export type { FilterFieldConfig, FilterValues };
@@ -127,6 +128,40 @@ export interface RowClickHandlers<T> {
onDelete?: (item: T) => void;
}
// ===== 엑셀 다운로드 설정 =====
export interface ExcelDownloadConfig<T> {
/** 엑셀 다운로드 활성화 여부 (기본: true) */
enabled?: boolean;
/** 엑셀 컬럼 정의 */
columns: ExcelColumn<T>[];
/** 파일명 (확장자 제외) */
filename?: string;
/** 시트명 */
sheetName?: string;
/**
* 전체 데이터 조회 URL (서버 사이드 페이지네이션 시 필수)
* - 예: '/api/proxy/items'
* - size=10000 파라미터가 자동 추가됨
*/
fetchAllUrl?: string;
/**
* 전체 데이터 조회 시 추가 파라미터 (동적)
* - activeTab, filters 등 현재 상태 기반으로 파라미터 생성
*/
fetchAllParams?: (params: {
activeTab: string;
filters: Record<string, string | string[]>;
searchValue: string;
}) => Record<string, string>;
/**
* API 응답을 데이터 배열로 변환
* - 기본값: result.data?.data ?? result.data ?? []
*/
mapResponse?: (response: unknown) => T[];
/** 선택 항목 다운로드 활성화 (기본: true) */
enableSelectedDownload?: boolean;
}
// ===== 메인 Config 타입 =====
export interface UniversalListConfig<T> {
// ===== 페이지 기본 정보 =====
@@ -271,6 +306,15 @@ export interface UniversalListConfig<T> {
/** 데이터 변경 콜백 (동적 컬럼 계산 등에 사용) */
onDataChange?: (data: T[]) => void;
// ===== 엑셀 다운로드 =====
/**
* 엑셀 다운로드 설정
* - 설정 시 헤더 액션에 자동으로 엑셀 다운로드 버튼 추가
* - 클라이언트 사이드: 현재 데이터 기반 다운로드
* - 서버 사이드: fetchAllUrl로 전체 데이터 조회 후 다운로드
*/
excelDownload?: ExcelDownloadConfig<T>;
// ===== 추가 옵션 =====
/** 검색 플레이스홀더 */
searchPlaceholder?: string;

View File

@@ -74,7 +74,7 @@ const AccountNumberInput = React.forwardRef<HTMLInputElement, AccountNumberInput
ref={ref}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base bg-input-background transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"file:text-foreground placeholder:text-muted-foreground/50 selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base bg-input-background transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
error && "border-red-500 focus-visible:border-red-500 focus-visible:ring-red-500/20",
className

View File

@@ -83,7 +83,7 @@ const BusinessNumberInput = React.forwardRef<HTMLInputElement, BusinessNumberInp
ref={ref}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base bg-input-background transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"file:text-foreground placeholder:text-muted-foreground/50 selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base bg-input-background transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
error && "border-red-500 focus-visible:border-red-500 focus-visible:ring-red-500/20",
showValidation && "pr-10",

View File

@@ -74,7 +74,7 @@ const CardNumberInput = React.forwardRef<HTMLInputElement, CardNumberInputProps>
ref={ref}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base bg-input-background transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"file:text-foreground placeholder:text-muted-foreground/50 selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base bg-input-background transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
error && "border-red-500 focus-visible:border-red-500 focus-visible:ring-red-500/20",
className

View File

@@ -66,7 +66,7 @@ function CommandInput({
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
"placeholder:text-muted-foreground/50 flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}

View File

@@ -196,7 +196,7 @@ const CurrencyInput = React.forwardRef<HTMLInputElement, CurrencyInputProps>(
ref={ref}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base bg-input-background transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"file:text-foreground placeholder:text-muted-foreground/50 selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base bg-input-background transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
error && "border-red-500 focus-visible:border-red-500 focus-visible:ring-red-500/20",
showCurrency && "pl-8",

View File

@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base bg-input-background transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"file:text-foreground placeholder:text-muted-foreground/50 selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base bg-input-background transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,

View File

@@ -252,7 +252,7 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
ref={ref}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base bg-input-background transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"file:text-foreground placeholder:text-muted-foreground/50 selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base bg-input-background transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
error && "border-red-500 focus-visible:border-red-500 focus-visible:ring-red-500/20",
suffix && "pr-10",

View File

@@ -80,7 +80,7 @@ const PersonalNumberInput = React.forwardRef<HTMLInputElement, PersonalNumberInp
ref={ref}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base bg-input-background transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"file:text-foreground placeholder:text-muted-foreground/50 selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base bg-input-background transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
error && "border-red-500 focus-visible:border-red-500 focus-visible:ring-red-500/20",
className

View File

@@ -75,7 +75,7 @@ const PhoneInput = React.forwardRef<HTMLInputElement, PhoneInputProps>(
ref={ref}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base bg-input-background transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"file:text-foreground placeholder:text-muted-foreground/50 selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base bg-input-background transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
error && "border-red-500 focus-visible:border-red-500 focus-visible:ring-red-500/20",
className

View File

@@ -222,7 +222,7 @@ const QuantityInput = React.forwardRef<HTMLInputElement, QuantityInputProps>(
data-slot="input"
disabled={disabled}
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base bg-input-background transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"file:text-foreground placeholder:text-muted-foreground/50 selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base bg-input-background transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
error && "border-red-500 focus-visible:border-red-500 focus-visible:ring-red-500/20",
showButtons && "text-center",

View File

@@ -10,7 +10,7 @@ const Textarea = React.forwardRef<
<textarea
data-slot="textarea"
className={cn(
"placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex min-h-[80px] w-full rounded-md border bg-input-background px-3 py-2 text-base transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"placeholder:text-muted-foreground/50 selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex min-h-[80px] w-full rounded-md border bg-input-background px-3 py-2 text-base transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className