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:
1174
package-lock.json
generated
1174
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
180
src/app/api/pdf/generate/route.ts
Normal file
180
src/app/api/pdf/generate/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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, 이메일, 팩스, 카카오톡, 인쇄)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,61 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import { Package, Save, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Package } from 'lucide-react';
|
||||
|
||||
export interface FormHeaderProps {
|
||||
mode: 'create' | 'edit';
|
||||
selectedItemType: string;
|
||||
isSubmitting: boolean;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 헤더 컴포넌트 - 기존 FormHeader와 동일한 디자인
|
||||
* 헤더 컴포넌트 - 타이틀만 표시 (버튼은 하단 sticky로 이동)
|
||||
*/
|
||||
export function FormHeader({
|
||||
mode,
|
||||
selectedItemType,
|
||||
isSubmitting,
|
||||
onCancel,
|
||||
}: FormHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-primary/10 rounded-lg hidden md:block">
|
||||
<Package className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl md:text-2xl">
|
||||
{mode === 'create' ? '품목 등록' : '품목 수정'}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
품목 정보를 입력하세요
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-primary/10 rounded-lg hidden md:block">
|
||||
<Package className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 sm:gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onCancel}
|
||||
className="gap-1 sm:gap-2"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">취소</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={!selectedItemType || isSubmitting}
|
||||
className="gap-1 sm:gap-2"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{isSubmitting ? '저장 중...' : '저장'}</span>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl md:text-2xl">
|
||||
{mode === 'create' ? '품목 등록' : '품목 수정'}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
품목 정보를 입력하세요
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useMenuStore } from '@/store/menuStore';
|
||||
import { Save, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { FormSectionSkeleton } from '@/components/ui/skeleton';
|
||||
@@ -45,6 +48,7 @@ export default function DynamicItemForm({
|
||||
onSubmit,
|
||||
}: DynamicItemFormProps) {
|
||||
const router = useRouter();
|
||||
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
|
||||
|
||||
// 품목 유형 상태 (변경 가능)
|
||||
const [selectedItemType, setSelectedItemType] = useState<ItemType | ''>(initialItemType || '');
|
||||
@@ -658,17 +662,12 @@ export default function DynamicItemForm({
|
||||
: [];
|
||||
|
||||
return (
|
||||
<form onSubmit={handleFormSubmit} className="space-y-6">
|
||||
<form onSubmit={handleFormSubmit} className="space-y-6 pb-24">
|
||||
{/* Validation 에러 Alert */}
|
||||
<ValidationAlert errors={errors} />
|
||||
|
||||
{/* 헤더 */}
|
||||
<FormHeader
|
||||
mode={mode}
|
||||
selectedItemType={selectedItemType}
|
||||
isSubmitting={isSubmitting}
|
||||
onCancel={() => router.back()}
|
||||
/>
|
||||
<FormHeader mode={mode} />
|
||||
|
||||
{/* 기본 정보 - 목업과 동일한 레이아웃 (모든 필드 한 줄씩) */}
|
||||
<Card>
|
||||
@@ -1045,6 +1044,26 @@ export default function DynamicItemForm({
|
||||
onCancel={handleCancelDuplicate}
|
||||
onGoToEdit={handleGoToEditDuplicate}
|
||||
/>
|
||||
|
||||
{/* 하단 액션 버튼 (sticky) */}
|
||||
<div className={`fixed bottom-6 ${sidebarCollapsed ? 'left-[156px]' : 'left-[316px]'} right-[48px] px-6 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 flex items-center justify-between`}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.back()}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!selectedItemType || isSubmitting}
|
||||
>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{isSubmitting ? '저장 중...' : '저장'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { ArrowLeft, Edit, Package, FileImage, Download, FileText, Check, Calendar } from 'lucide-react';
|
||||
import { downloadFileById } from '@/lib/utils/fileDownload';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { useMenuStore } from '@/store/menuStore';
|
||||
|
||||
interface ItemDetailClientProps {
|
||||
item: ItemMaster;
|
||||
@@ -94,39 +95,20 @@ function getStorageUrl(path: string | undefined): string | null {
|
||||
|
||||
export default function ItemDetailClient({ item }: ItemDetailClientProps) {
|
||||
const router = useRouter();
|
||||
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6 pb-24">
|
||||
{/* 헤더 */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-primary/10 rounded-lg hidden md:block">
|
||||
<Package className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl md:text-2xl">품목 상세 정보</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
등록된 품목 정보를 확인합니다
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-primary/10 rounded-lg hidden md:block">
|
||||
<Package className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
목록으로
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => router.push(`/production/screen-production/${encodeURIComponent(item.itemCode)}?mode=edit&type=${item.itemType}&id=${item.id}`)}
|
||||
>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl md:text-2xl">품목 상세 정보</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
등록된 품목 정보를 확인합니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -632,6 +614,25 @@ export default function ItemDetailClient({ item }: ItemDetailClientProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 하단 액션 버튼 (sticky) */}
|
||||
<div className={`fixed bottom-6 ${sidebarCollapsed ? 'left-[156px]' : 'left-[316px]'} right-[48px] px-6 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 flex items-center justify-between`}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
목록으로
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => router.push(`/production/screen-production/${encodeURIComponent(item.itemCode)}?mode=edit&type=${item.itemType}&id=${item.id}`)}
|
||||
>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,8 +17,8 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { TableRow, TableCell } from '@/components/ui/table';
|
||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { Search, Plus, Edit, Trash2, Package, Download, FileDown, Upload } from 'lucide-react';
|
||||
import { downloadExcel, downloadSelectedExcel, downloadExcelTemplate, parseExcelFile, type ExcelColumn, type TemplateColumn } from '@/lib/utils/excel-download';
|
||||
import { Search, Plus, Edit, Trash2, Package, FileDown, Upload } from 'lucide-react';
|
||||
import { downloadExcelTemplate, parseExcelFile, type ExcelColumn, type TemplateColumn } from '@/lib/utils/excel-download';
|
||||
import { useItemList } from '@/hooks/useItemList';
|
||||
import { handleApiError } from '@/lib/api/error-handler';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
@@ -253,34 +253,29 @@ export default function ItemListClient() {
|
||||
{ header: '활성상태', key: 'isActive', width: 10, transform: (v) => v ? '활성' : '비활성' },
|
||||
];
|
||||
|
||||
// 전체 엑셀 다운로드
|
||||
const handleExcelDownload = () => {
|
||||
if (items.length === 0) {
|
||||
alert('다운로드할 데이터가 없습니다.');
|
||||
return;
|
||||
}
|
||||
downloadExcel({
|
||||
data: items,
|
||||
columns: excelColumns,
|
||||
filename: '품목목록',
|
||||
sheetName: '품목',
|
||||
});
|
||||
};
|
||||
|
||||
// 선택 항목 엑셀 다운로드
|
||||
const handleSelectedExcelDownload = (selectedIds: string[]) => {
|
||||
if (selectedIds.length === 0) {
|
||||
alert('선택된 항목이 없습니다.');
|
||||
return;
|
||||
}
|
||||
downloadSelectedExcel({
|
||||
data: items,
|
||||
columns: excelColumns,
|
||||
selectedIds,
|
||||
idField: 'id',
|
||||
filename: '품목목록_선택',
|
||||
sheetName: '품목',
|
||||
});
|
||||
// API 응답을 ItemMaster 타입으로 변환 (엑셀 다운로드용)
|
||||
const mapItemResponse = (result: unknown): ItemMaster[] => {
|
||||
const data = result as { data?: { data?: Record<string, unknown>[] }; };
|
||||
const rawItems = data.data?.data ?? [];
|
||||
return rawItems.map((item: Record<string, unknown>) => ({
|
||||
id: String(item.id ?? item.item_id ?? ''),
|
||||
itemCode: (item.code ?? item.item_code ?? '') as string,
|
||||
itemName: (item.name ?? item.item_name ?? '') as string,
|
||||
itemType: (item.type_code ?? item.item_type ?? '') as ItemMaster['itemType'],
|
||||
partType: item.part_type as ItemMaster['partType'],
|
||||
unit: (item.unit ?? '') as string,
|
||||
specification: (item.specification ?? '') as string,
|
||||
isActive: item.is_active != null ? Boolean(item.is_active) : !item.deleted_at,
|
||||
category1: (item.category1 ?? '') as string,
|
||||
category2: (item.category2 ?? '') as string,
|
||||
category3: (item.category3 ?? '') as string,
|
||||
salesPrice: (item.sales_price ?? 0) as number,
|
||||
purchasePrice: (item.purchase_price ?? 0) as number,
|
||||
currentRevision: (item.current_revision ?? 0) as number,
|
||||
isFinal: Boolean(item.is_final ?? false),
|
||||
createdAt: (item.created_at ?? '') as string,
|
||||
updatedAt: (item.updated_at ?? '') as string,
|
||||
}));
|
||||
};
|
||||
|
||||
// 업로드용 템플릿 컬럼 정의
|
||||
@@ -416,55 +411,31 @@ export default function ItemListClient() {
|
||||
icon: Plus,
|
||||
},
|
||||
|
||||
// 헤더 액션 (엑셀 다운로드)
|
||||
headerActions: ({ selectedItems }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 양식 다운로드 버튼 - 추후 활성화
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleTemplateDownload}
|
||||
className="gap-2"
|
||||
>
|
||||
<FileDown className="h-4 w-4" />
|
||||
양식 다운로드
|
||||
</Button>
|
||||
*/}
|
||||
{/* 양식 업로드 버튼 - 추후 활성화
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="gap-2"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
양식 업로드
|
||||
</Button>
|
||||
*/}
|
||||
{/* 엑셀 데이터 다운로드 버튼 */}
|
||||
{selectedItems.size > 0 ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleSelectedExcelDownload(Array.from(selectedItems))}
|
||||
className="gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
선택 다운로드 ({selectedItems.size})
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleExcelDownload}
|
||||
className="gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
엑셀 다운로드
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
// 엑셀 다운로드 설정 (공통 기능)
|
||||
excelDownload: {
|
||||
columns: excelColumns,
|
||||
filename: '품목목록',
|
||||
sheetName: '품목',
|
||||
fetchAllUrl: '/api/proxy/items',
|
||||
fetchAllParams: ({ activeTab }): Record<string, string> => {
|
||||
// 현재 선택된 타입 필터 적용
|
||||
if (activeTab && activeTab !== 'all') {
|
||||
return { type: activeTab };
|
||||
}
|
||||
return { group_id: '1' }; // 품목관리 그룹
|
||||
},
|
||||
mapResponse: mapItemResponse,
|
||||
},
|
||||
|
||||
// 헤더 액션 (양식 다운로드/업로드 - 추후 활성화)
|
||||
// headerActions: () => (
|
||||
// <div className="flex items-center gap-2">
|
||||
// <Button variant="outline" size="sm" onClick={handleTemplateDownload} className="gap-2">
|
||||
// <FileDown className="h-4 w-4" />
|
||||
// 양식 다운로드
|
||||
// </Button>
|
||||
// </div>
|
||||
// ),
|
||||
|
||||
// API 액션 (일괄 삭제 포함)
|
||||
actions: {
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
231
src/components/quotes/DiscountModal.tsx
Normal file
231
src/components/quotes/DiscountModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
221
src/components/quotes/QuoteTransactionModal.tsx
Normal file
221
src/components/quotes/QuoteTransactionModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user