summaryrefslogtreecommitdiff
path: root/lib/tech-vendors/table
diff options
context:
space:
mode:
Diffstat (limited to 'lib/tech-vendors/table')
-rw-r--r--lib/tech-vendors/table/add-vendor-dialog.tsx48
-rw-r--r--lib/tech-vendors/table/attachmentButton.tsx152
-rw-r--r--lib/tech-vendors/table/excel-template-download.tsx380
-rw-r--r--lib/tech-vendors/table/feature-flags-provider.tsx216
-rw-r--r--lib/tech-vendors/table/import-button.tsx692
-rw-r--r--lib/tech-vendors/table/tech-vendor-possible-items-view-dialog.tsx201
-rw-r--r--lib/tech-vendors/table/tech-vendors-filter-sheet.tsx617
-rw-r--r--lib/tech-vendors/table/tech-vendors-table-columns.tsx788
-rw-r--r--lib/tech-vendors/table/tech-vendors-table-floating-bar.tsx240
-rw-r--r--lib/tech-vendors/table/tech-vendors-table-toolbar-actions.tsx396
-rw-r--r--lib/tech-vendors/table/tech-vendors-table.tsx470
-rw-r--r--lib/tech-vendors/table/update-vendor-sheet.tsx1035
-rw-r--r--lib/tech-vendors/table/vendor-all-export.ts512
13 files changed, 3189 insertions, 2558 deletions
diff --git a/lib/tech-vendors/table/add-vendor-dialog.tsx b/lib/tech-vendors/table/add-vendor-dialog.tsx
index 22c03bcc..e89f5d6b 100644
--- a/lib/tech-vendors/table/add-vendor-dialog.tsx
+++ b/lib/tech-vendors/table/add-vendor-dialog.tsx
@@ -255,7 +255,7 @@ export function AddVendorDialog({ onSuccess }: AddVendorDialogProps) {
className="w-4 h-4 mt-1"
/>
</FormControl>
- <div className="space-y-1 leading-none">
+ <div className="space-y-1 leading-none ml-2">
<FormLabel className="cursor-pointer">
견적비교용 벤더
</FormLabel>
@@ -361,6 +361,52 @@ export function AddVendorDialog({ onSuccess }: AddVendorDialogProps) {
</div>
</div>
+ {/* 에이전트 정보 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-medium">에이전트 정보</h3>
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="agentName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>에이전트명</FormLabel>
+ <FormControl>
+ <Input placeholder="에이전트명을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="agentPhone"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>에이전트 전화번호</FormLabel>
+ <FormControl>
+ <Input placeholder="에이전트 전화번호를 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ <FormField
+ control={form.control}
+ name="agentEmail"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>에이전트 이메일</FormLabel>
+ <FormControl>
+ <Input type="email" placeholder="에이전트 이메일을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
{/* 대표자 정보 */}
<div className="space-y-4">
<h3 className="text-lg font-medium">대표자 정보</h3>
diff --git a/lib/tech-vendors/table/attachmentButton.tsx b/lib/tech-vendors/table/attachmentButton.tsx
index 12dc6f77..2754c9f0 100644
--- a/lib/tech-vendors/table/attachmentButton.tsx
+++ b/lib/tech-vendors/table/attachmentButton.tsx
@@ -1,76 +1,76 @@
-'use client';
-
-import React from 'react';
-import { Button } from '@/components/ui/button';
-import { PaperclipIcon } from 'lucide-react';
-import { Badge } from '@/components/ui/badge';
-import { toast } from 'sonner';
-import { type VendorAttach } from '@/db/schema/vendors';
-import { downloadTechVendorAttachments } from '../service';
-
-interface AttachmentsButtonProps {
- vendorId: number;
- hasAttachments: boolean;
- attachmentsList?: VendorAttach[];
-}
-
-export function AttachmentsButton({ vendorId, hasAttachments, attachmentsList = [] }: AttachmentsButtonProps) {
- if (!hasAttachments) return null;
-
- const handleDownload = async () => {
- try {
- toast.loading('첨부파일을 준비하는 중...');
-
- // 서버 액션 호출
- const result = await downloadTechVendorAttachments(vendorId);
-
- // 로딩 토스트 닫기
- toast.dismiss();
-
- if (!result || !result.url) {
- toast.error('다운로드 준비 중 오류가 발생했습니다.');
- return;
- }
-
- // 파일 다운로드 트리거
- toast.success('첨부파일 다운로드가 시작되었습니다.');
-
- // 다운로드 링크 열기
- const a = document.createElement('a');
- a.href = result.url;
- a.download = result.fileName || '첨부파일.zip';
- a.style.display = 'none';
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
-
- } catch (error) {
- toast.dismiss();
- toast.error('첨부파일 다운로드에 실패했습니다.');
- console.error('첨부파일 다운로드 오류:', error);
- }
- };
-
- return (
- <>
- {attachmentsList && attachmentsList.length > 0 &&
- <Button
- variant="ghost"
- size="icon"
- onClick={handleDownload}
- title={`${attachmentsList.length}개 파일 다운로드`}
- >
- <PaperclipIcon className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
- {/* {attachmentsList.length > 1 && (
- <Badge
- variant="secondary"
- className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.425rem] leading-none flex items-center justify-center"
- >
- {attachmentsList.length}
- </Badge>
- )} */}
- </Button>
- }
- </>
- );
-}
+'use client';
+
+import React from 'react';
+import { Button } from '@/components/ui/button';
+import { PaperclipIcon } from 'lucide-react';
+import { Badge } from '@/components/ui/badge';
+import { toast } from 'sonner';
+import { type VendorAttach } from '@/db/schema/vendors';
+import { downloadTechVendorAttachments } from '../service';
+
+interface AttachmentsButtonProps {
+ vendorId: number;
+ hasAttachments: boolean;
+ attachmentsList?: VendorAttach[];
+}
+
+export function AttachmentsButton({ vendorId, hasAttachments, attachmentsList = [] }: AttachmentsButtonProps) {
+ if (!hasAttachments) return null;
+
+ const handleDownload = async () => {
+ try {
+ toast.loading('첨부파일을 준비하는 중...');
+
+ // 서버 액션 호출
+ const result = await downloadTechVendorAttachments(vendorId);
+
+ // 로딩 토스트 닫기
+ toast.dismiss();
+
+ if (!result || !result.url) {
+ toast.error('다운로드 준비 중 오류가 발생했습니다.');
+ return;
+ }
+
+ // 파일 다운로드 트리거
+ toast.success('첨부파일 다운로드가 시작되었습니다.');
+
+ // 다운로드 링크 열기
+ const a = document.createElement('a');
+ a.href = result.url;
+ a.download = result.fileName || '첨부파일.zip';
+ a.style.display = 'none';
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+
+ } catch (error) {
+ toast.dismiss();
+ toast.error('첨부파일 다운로드에 실패했습니다.');
+ console.error('첨부파일 다운로드 오류:', error);
+ }
+ };
+
+ return (
+ <>
+ {attachmentsList && attachmentsList.length > 0 &&
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={handleDownload}
+ title={`${attachmentsList.length}개 파일 다운로드`}
+ >
+ <PaperclipIcon className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ {/* {attachmentsList.length > 1 && (
+ <Badge
+ variant="secondary"
+ className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.425rem] leading-none flex items-center justify-center"
+ >
+ {attachmentsList.length}
+ </Badge>
+ )} */}
+ </Button>
+ }
+ </>
+ );
+}
diff --git a/lib/tech-vendors/table/excel-template-download.tsx b/lib/tech-vendors/table/excel-template-download.tsx
index b6011e2c..3de9ab33 100644
--- a/lib/tech-vendors/table/excel-template-download.tsx
+++ b/lib/tech-vendors/table/excel-template-download.tsx
@@ -1,150 +1,232 @@
-import * as ExcelJS from 'exceljs';
-import { saveAs } from "file-saver";
-
-/**
- * 기술영업 벤더 데이터 가져오기를 위한 Excel 템플릿 파일 생성 및 다운로드
- */
-export async function exportTechVendorTemplate() {
- // 워크북 생성
- const workbook = new ExcelJS.Workbook();
- workbook.creator = 'Tech Vendor Management System';
- workbook.created = new Date();
-
- // 워크시트 생성
- const worksheet = workbook.addWorksheet('기술영업 벤더');
-
- // 컬럼 헤더 정의 및 스타일 적용
- worksheet.columns = [
- { header: '업체명', key: 'vendorName', width: 20 },
- { header: '업체코드', key: 'vendorCode', width: 15 },
- { header: '사업자등록번호', key: 'taxId', width: 15 },
- { header: '국가', key: 'country', width: 15 },
- { header: '영문국가명', key: 'countryEng', width: 15 },
- { header: '제조국', key: 'countryFab', width: 15 },
- { header: '대리점명', key: 'agentName', width: 20 },
- { header: '대리점연락처', key: 'agentPhone', width: 15 },
- { header: '대리점이메일', key: 'agentEmail', width: 25 },
- { header: '주소', key: 'address', width: 30 },
- { header: '전화번호', key: 'phone', width: 15 },
- { header: '이메일', key: 'email', width: 25 },
- { header: '웹사이트', key: 'website', width: 25 },
- { header: '벤더타입', key: 'techVendorType', width: 15 },
- { header: '대표자명', key: 'representativeName', width: 20 },
- { header: '대표자이메일', key: 'representativeEmail', width: 25 },
- { header: '대표자연락처', key: 'representativePhone', width: 15 },
- { header: '대표자생년월일', key: 'representativeBirth', width: 15 },
- { header: '아이템', key: 'items', width: 30 },
- ];
-
- // 헤더 스타일 적용
- const headerRow = worksheet.getRow(1);
- headerRow.font = { bold: true };
- headerRow.fill = {
- type: 'pattern',
- pattern: 'solid',
- fgColor: { argb: 'FFE0E0E0' }
- };
- headerRow.alignment = { vertical: 'middle', horizontal: 'center' };
-
- // 테두리 스타일 적용
- headerRow.eachCell((cell) => {
- cell.border = {
- top: { style: 'thin' },
- left: { style: 'thin' },
- bottom: { style: 'thin' },
- right: { style: 'thin' }
- };
- });
-
- // 샘플 데이터 추가
- const sampleData = [
- {
- vendorName: '샘플 업체 1',
- vendorCode: 'TV001',
- taxId: '123-45-67890',
- country: '대한민국',
- countryEng: 'Korea',
- countryFab: '대한민국',
- agentName: '대리점1',
- agentPhone: '02-1234-5678',
- agentEmail: 'agent1@example.com',
- address: '서울시 강남구',
- phone: '02-1234-5678',
- email: 'sample1@example.com',
- website: 'https://example1.com',
- techVendorType: '조선,해양TOP',
- representativeName: '홍길동',
- representativeEmail: 'ceo1@example.com',
- representativePhone: '010-1234-5678',
- representativeBirth: '1980-01-01',
- items: 'ITEM001,ITEM002'
- },
- {
- vendorName: '샘플 업체 2',
- vendorCode: 'TV002',
- taxId: '234-56-78901',
- country: '대한민국',
- countryEng: 'Korea',
- countryFab: '대한민국',
- agentName: '대리점2',
- agentPhone: '051-234-5678',
- agentEmail: 'agent2@example.com',
- address: '부산시 해운대구',
- phone: '051-234-5678',
- email: 'sample2@example.com',
- website: 'https://example2.com',
- techVendorType: '해양HULL',
- representativeName: '김철수',
- representativeEmail: 'ceo2@example.com',
- representativePhone: '010-2345-6789',
- representativeBirth: '1985-02-02',
- items: 'ITEM003,ITEM004'
- }
- ];
-
- // 데이터 행 추가
- sampleData.forEach(item => {
- worksheet.addRow(item);
- });
-
- // 데이터 행 스타일 적용
- worksheet.eachRow((row, rowNumber) => {
- if (rowNumber > 1) { // 헤더를 제외한 데이터 행
- row.eachCell((cell) => {
- cell.border = {
- top: { style: 'thin' },
- left: { style: 'thin' },
- bottom: { style: 'thin' },
- right: { style: 'thin' }
- };
- });
- }
- });
-
- // 워크시트 보호 (선택적)
- worksheet.protect('', {
- selectLockedCells: true,
- selectUnlockedCells: true,
- formatColumns: true,
- formatRows: true,
- insertColumns: false,
- insertRows: true,
- insertHyperlinks: false,
- deleteColumns: false,
- deleteRows: true,
- sort: true,
- autoFilter: true,
- pivotTables: false
- });
-
- try {
- // 워크북을 Blob으로 변환
- const buffer = await workbook.xlsx.writeBuffer();
- const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
- saveAs(blob, 'tech-vendor-template.xlsx');
- return true;
- } catch (error) {
- console.error('Excel 템플릿 생성 오류:', error);
- throw error;
- }
+import * as ExcelJS from 'exceljs';
+import { saveAs } from "file-saver";
+
+/**
+ * 기술영업 벤더 데이터 가져오기를 위한 Excel 템플릿 파일 생성 및 다운로드
+ */
+export async function exportTechVendorTemplate() {
+ // 워크북 생성
+ const workbook = new ExcelJS.Workbook();
+ workbook.creator = 'Tech Vendor Management System';
+ workbook.created = new Date();
+
+ // 워크시트 생성
+ const worksheet = workbook.addWorksheet('기술영업 벤더');
+
+ // 컬럼 헤더 정의 및 스타일 적용
+ worksheet.columns = [
+ { header: '업체명', key: 'vendorName', width: 20 },
+ { header: '업체코드', key: 'vendorCode', width: 15 },
+ { header: '사업자등록번호', key: 'taxId', width: 15 },
+ { header: '국가', key: 'country', width: 15 },
+ { header: '영문국가명', key: 'countryEng', width: 15 },
+ { header: '제조국', key: 'countryFab', width: 15 },
+ { header: '에이전트명', key: 'agentName', width: 20 },
+ { header: '에이전트연락처', key: 'agentPhone', width: 15 },
+ { header: '에이전트이메일', key: 'agentEmail', width: 25 },
+ { header: '주소', key: 'address', width: 30 },
+ { header: '전화번호', key: 'phone', width: 15 },
+ { header: '이메일', key: 'email', width: 25 },
+ { header: '웹사이트', key: 'website', width: 25 },
+ { header: '벤더타입', key: 'techVendorType', width: 15 },
+ { header: '대표자명', key: 'representativeName', width: 20 },
+ { header: '대표자이메일', key: 'representativeEmail', width: 25 },
+ { header: '대표자연락처', key: 'representativePhone', width: 15 },
+ { header: '대표자생년월일', key: 'representativeBirth', width: 15 },
+ { header: '담당자명', key: 'contactName', width: 20 },
+ { header: '담당자직책', key: 'contactPosition', width: 15 },
+ { header: '담당자이메일', key: 'contactEmail', width: 25 },
+ { header: '담당자연락처', key: 'contactPhone', width: 15 },
+ { header: '담당자국가', key: 'contactCountry', width: 15 },
+ { header: '아이템', key: 'items', width: 30 },
+ ];
+
+ // 헤더 스타일 적용
+ const headerRow = worksheet.getRow(1);
+ headerRow.font = { bold: true };
+ headerRow.fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FFE0E0E0' }
+ };
+
+ // 샘플 데이터 추가
+ worksheet.addRow([
+ 'ABC 조선소', // 업체명
+ 'ABC001', // 업체코드
+ '123-45-67890', // 사업자등록번호
+ '대한민국', // 국가
+ 'South Korea', // 영문국가명
+ '대한민국', // 제조국
+ '김대리', // 에이전트명
+ '02-123-4567', // 에이전트연락처
+ 'agent@abc.co.kr', // 에이전트이메일
+ '서울시 강남구 테헤란로 123', // 주소
+ '02-123-4567', // 전화번호
+ 'contact@abc.co.kr', // 이메일
+ 'https://www.abc.co.kr', // 웹사이트
+ '조선', // 벤더타입
+ '홍길동', // 대표자명
+ 'ceo@abc.co.kr', // 대표자이메일
+ '02-123-4567', // 대표자연락처
+ '1970-01-01', // 대표자생년월일
+ '박담당', // 담당자명
+ '과장', // 담당자직책
+ 'contact@abc.co.kr', // 담당자이메일
+ '010-1234-5678', // 담당자연락처
+ '대한민국', // 담당자국가
+ '선박부품, 엔진부품' // 아이템
+ ]);
+
+ // 설명을 위한 시트 추가
+ const instructionSheet = workbook.addWorksheet('입력 가이드');
+ instructionSheet.columns = [
+ { header: '컬럼명', key: 'column', width: 20 },
+ { header: '필수여부', key: 'required', width: 10 },
+ { header: '설명', key: 'description', width: 50 },
+ ];
+
+ // 가이드 헤더 스타일
+ const guideHeaderRow = instructionSheet.getRow(1);
+ guideHeaderRow.font = { bold: true };
+ guideHeaderRow.fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FFE0E0E0' }
+ };
+
+ // 입력 가이드 데이터
+ const guideData = [
+ ['업체명', '필수', '벤더 업체명을 입력하세요'],
+ ['업체코드', '선택', '벤더 고유 코드 (없으면 자동 생성)'],
+ ['사업자등록번호', '필수', '벤더의 사업자등록번호'],
+ ['국가', '선택', '벤더 소재 국가'],
+ ['영문국가명', '선택', '벤더 소재 국가의 영문명'],
+ ['제조국', '선택', '제품 제조 국가'],
+ ['에이전트명', '선택', '담당 에이전트 이름'],
+ ['에이전트연락처', '선택', '담당 에이전트 연락처'],
+ ['에이전트이메일', '선택', '담당 에이전트 이메일'],
+ ['주소', '선택', '벤더 주소'],
+ ['전화번호', '선택', '벤더 대표 전화번호'],
+ ['이메일', '필수', '벤더 대표 이메일 (대표 담당자가 없으면 이 이메일이 기본 담당자가 됩니다)'],
+ ['웹사이트', '선택', '벤더 웹사이트 URL'],
+ ['벤더타입', '필수', '벤더 유형 (조선, 해양TOP, 해양HULL 중 선택)'],
+ ['대표자명', '선택', '벤더 대표자 이름'],
+ ['대표자이메일', '선택', '벤더 대표자 이메일'],
+ ['대표자연락처', '선택', '벤더 대표자 연락처'],
+ ['대표자생년월일', '선택', '벤더 대표자 생년월일 (YYYY-MM-DD 형식)'],
+ ['담당자명', '선택', '주 담당자 이름 (없으면 대표자 또는 업체명으로 기본 담당자 생성)'],
+ ['담당자직책', '선택', '주 담당자 직책'],
+ ['담당자이메일', '선택', '주 담당자 이메일 (있으면 벤더 이메일보다 우선)'],
+ ['담당자연락처', '선택', '주 담당자 연락처'],
+ ['담당자국가', '선택', '주 담당자 소재 국가'],
+ ['아이템', '선택', '벤더가 제공하는 아이템 (쉼표로 구분)'],
+ ];
+
+ guideData.forEach(row => {
+ instructionSheet.addRow(row);
+ });
+ headerRow.alignment = { vertical: 'middle', horizontal: 'center' };
+
+ // 테두리 스타일 적용
+ headerRow.eachCell((cell) => {
+ cell.border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+ });
+
+ // 샘플 데이터 추가
+ const sampleData = [
+ {
+ vendorName: '샘플 업체 1',
+ vendorCode: 'TV001',
+ taxId: '123-45-67890',
+ country: '대한민국',
+ countryEng: 'Korea',
+ countryFab: '대한민국',
+ agentName: '에이전트1',
+ agentPhone: '02-1234-5678',
+ agentEmail: 'agent1@example.com',
+ address: '서울시 강남구',
+ phone: '02-1234-5678',
+ email: 'sample1@example.com',
+ website: 'https://example1.com',
+ techVendorType: '조선,해양TOP',
+ representativeName: '홍길동',
+ representativeEmail: 'ceo1@example.com',
+ representativePhone: '010-1234-5678',
+ representativeBirth: '1980-01-01',
+ items: 'ITEM001,ITEM002'
+ },
+ {
+ vendorName: '샘플 업체 2',
+ vendorCode: 'TV002',
+ taxId: '234-56-78901',
+ country: '대한민국',
+ countryEng: 'Korea',
+ countryFab: '대한민국',
+ agentName: '에이전트2',
+ agentPhone: '051-234-5678',
+ agentEmail: 'agent2@example.com',
+ address: '부산시 해운대구',
+ phone: '051-234-5678',
+ email: 'sample2@example.com',
+ website: 'https://example2.com',
+ techVendorType: '해양HULL',
+ representativeName: '김철수',
+ representativeEmail: 'ceo2@example.com',
+ representativePhone: '010-2345-6789',
+ representativeBirth: '1985-02-02',
+ items: 'ITEM003,ITEM004'
+ }
+ ];
+
+ // 데이터 행 추가
+ sampleData.forEach(item => {
+ worksheet.addRow(item);
+ });
+
+ // 데이터 행 스타일 적용
+ worksheet.eachRow((row, rowNumber) => {
+ if (rowNumber > 1) { // 헤더를 제외한 데이터 행
+ row.eachCell((cell) => {
+ cell.border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+ });
+ }
+ });
+
+ // 워크시트 보호 (선택적)
+ worksheet.protect('', {
+ selectLockedCells: true,
+ selectUnlockedCells: true,
+ formatColumns: true,
+ formatRows: true,
+ insertColumns: false,
+ insertRows: true,
+ insertHyperlinks: false,
+ deleteColumns: false,
+ deleteRows: true,
+ sort: true,
+ autoFilter: true,
+ pivotTables: false
+ });
+
+ try {
+ // 워크북을 Blob으로 변환
+ const buffer = await workbook.xlsx.writeBuffer();
+ const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
+ saveAs(blob, 'tech-vendor-template.xlsx');
+ return true;
+ } catch (error) {
+ console.error('Excel 템플릿 생성 오류:', error);
+ throw error;
+ }
} \ No newline at end of file
diff --git a/lib/tech-vendors/table/feature-flags-provider.tsx b/lib/tech-vendors/table/feature-flags-provider.tsx
index 81131894..615377d6 100644
--- a/lib/tech-vendors/table/feature-flags-provider.tsx
+++ b/lib/tech-vendors/table/feature-flags-provider.tsx
@@ -1,108 +1,108 @@
-"use client"
-
-import * as React from "react"
-import { useQueryState } from "nuqs"
-
-import { dataTableConfig, type DataTableConfig } from "@/config/data-table"
-import { cn } from "@/lib/utils"
-import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip"
-
-type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
-
-interface FeatureFlagsContextProps {
- featureFlags: FeatureFlagValue[]
- setFeatureFlags: (value: FeatureFlagValue[]) => void
-}
-
-const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
- featureFlags: [],
- setFeatureFlags: () => {},
-})
-
-export function useFeatureFlags() {
- const context = React.useContext(FeatureFlagsContext)
- if (!context) {
- throw new Error(
- "useFeatureFlags must be used within a FeatureFlagsProvider"
- )
- }
- return context
-}
-
-interface FeatureFlagsProviderProps {
- children: React.ReactNode
-}
-
-export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
- const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
- "flags",
- {
- defaultValue: [],
- parse: (value) => value.split(",") as FeatureFlagValue[],
- serialize: (value) => value.join(","),
- eq: (a, b) =>
- a.length === b.length && a.every((value, index) => value === b[index]),
- clearOnDefault: true,
- shallow: false,
- }
- )
-
- return (
- <FeatureFlagsContext.Provider
- value={{
- featureFlags,
- setFeatureFlags: (value) => void setFeatureFlags(value),
- }}
- >
- <div className="w-full overflow-x-auto">
- <ToggleGroup
- type="multiple"
- variant="outline"
- size="sm"
- value={featureFlags}
- onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
- className="w-fit gap-0"
- >
- {dataTableConfig.featureFlags.map((flag, index) => (
- <Tooltip key={flag.value}>
- <ToggleGroupItem
- value={flag.value}
- className={cn(
- "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90",
- {
- "rounded-l-sm border-r-0": index === 0,
- "rounded-r-sm":
- index === dataTableConfig.featureFlags.length - 1,
- }
- )}
- asChild
- >
- <TooltipTrigger>
- <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
- {flag.label}
- </TooltipTrigger>
- </ToggleGroupItem>
- <TooltipContent
- align="start"
- side="bottom"
- sideOffset={6}
- className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
- >
- <div>{flag.tooltipTitle}</div>
- <div className="text-xs text-muted-foreground">
- {flag.tooltipDescription}
- </div>
- </TooltipContent>
- </Tooltip>
- ))}
- </ToggleGroup>
- </div>
- {children}
- </FeatureFlagsContext.Provider>
- )
-}
+"use client"
+
+import * as React from "react"
+import { useQueryState } from "nuqs"
+
+import { dataTableConfig, type DataTableConfig } from "@/config/data-table"
+import { cn } from "@/lib/utils"
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface FeatureFlagsContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useFeatureFlags() {
+ const context = React.useContext(FeatureFlagsContext)
+ if (!context) {
+ throw new Error(
+ "useFeatureFlags must be used within a FeatureFlagsProvider"
+ )
+ }
+ return context
+}
+
+interface FeatureFlagsProviderProps {
+ children: React.ReactNode
+}
+
+export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "flags",
+ {
+ defaultValue: [],
+ parse: (value) => value.split(",") as FeatureFlagValue[],
+ serialize: (value) => value.join(","),
+ eq: (a, b) =>
+ a.length === b.length && a.every((value, index) => value === b[index]),
+ clearOnDefault: true,
+ shallow: false,
+ }
+ )
+
+ return (
+ <FeatureFlagsContext.Provider
+ value={{
+ featureFlags,
+ setFeatureFlags: (value) => void setFeatureFlags(value),
+ }}
+ >
+ <div className="w-full overflow-x-auto">
+ <ToggleGroup
+ type="multiple"
+ variant="outline"
+ size="sm"
+ value={featureFlags}
+ onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
+ className="w-fit gap-0"
+ >
+ {dataTableConfig.featureFlags.map((flag, index) => (
+ <Tooltip key={flag.value}>
+ <ToggleGroupItem
+ value={flag.value}
+ className={cn(
+ "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90",
+ {
+ "rounded-l-sm border-r-0": index === 0,
+ "rounded-r-sm":
+ index === dataTableConfig.featureFlags.length - 1,
+ }
+ )}
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
+ {flag.label}
+ </TooltipTrigger>
+ </ToggleGroupItem>
+ <TooltipContent
+ align="start"
+ side="bottom"
+ sideOffset={6}
+ className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
+ >
+ <div>{flag.tooltipTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ {flag.tooltipDescription}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </ToggleGroup>
+ </div>
+ {children}
+ </FeatureFlagsContext.Provider>
+ )
+}
diff --git a/lib/tech-vendors/table/import-button.tsx b/lib/tech-vendors/table/import-button.tsx
index ba01e150..1d3bf242 100644
--- a/lib/tech-vendors/table/import-button.tsx
+++ b/lib/tech-vendors/table/import-button.tsx
@@ -1,313 +1,381 @@
-"use client"
-
-import * as React from "react"
-import { Upload } from "lucide-react"
-import { toast } from "sonner"
-import * as ExcelJS from 'exceljs'
-
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import { Progress } from "@/components/ui/progress"
-import { importTechVendorsFromExcel } from "../service"
-import { decryptWithServerAction } from "@/components/drm/drmUtils"
-
-interface ImportTechVendorButtonProps {
- onSuccess?: () => void;
-}
-
-export function ImportTechVendorButton({ onSuccess }: ImportTechVendorButtonProps) {
- const [open, setOpen] = React.useState(false);
- const [file, setFile] = React.useState<File | null>(null);
- const [isUploading, setIsUploading] = React.useState(false);
- const [progress, setProgress] = React.useState(0);
- const [error, setError] = React.useState<string | null>(null);
-
- const fileInputRef = React.useRef<HTMLInputElement>(null);
-
- // 파일 선택 처리
- const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
- const selectedFile = e.target.files?.[0];
- if (!selectedFile) return;
-
- if (!selectedFile.name.endsWith('.xlsx') && !selectedFile.name.endsWith('.xls')) {
- setError("Excel 파일(.xlsx 또는 .xls)만 가능합니다.");
- return;
- }
-
- setFile(selectedFile);
- setError(null);
- };
-
- // 데이터 가져오기 처리
- const handleImport = async () => {
- if (!file) {
- setError("가져올 파일을 선택해주세요.");
- return;
- }
-
- try {
- setIsUploading(true);
- setProgress(0);
- setError(null);
-
- // DRM 복호화 처리
- let arrayBuffer: ArrayBuffer;
- try {
- setProgress(10);
- toast.info("파일 복호화 중...");
- arrayBuffer = await decryptWithServerAction(file);
- setProgress(30);
- } catch (decryptError) {
- console.error("파일 복호화 실패, 원본 파일 사용:", decryptError);
- toast.warning("파일 복호화에 실패하여 원본 파일을 사용합니다.");
- arrayBuffer = await file.arrayBuffer();
- }
-
- // ExcelJS 워크북 로드
- const workbook = new ExcelJS.Workbook();
- await workbook.xlsx.load(arrayBuffer);
-
- // 첫 번째 워크시트 가져오기
- const worksheet = workbook.worksheets[0];
- if (!worksheet) {
- throw new Error("Excel 파일에 워크시트가 없습니다.");
- }
-
- // 헤더 행 찾기
- let headerRowIndex = 1;
- let headerRow: ExcelJS.Row | undefined;
- let headerValues: (string | null)[] = [];
-
- worksheet.eachRow((row, rowNumber) => {
- const values = row.values as (string | null)[];
- if (!headerRow && values.some(v => v === "업체명" || v === "vendorName")) {
- headerRowIndex = rowNumber;
- headerRow = row;
- headerValues = [...values];
- }
- });
-
- if (!headerRow) {
- throw new Error("Excel 파일에서 헤더 행을 찾을 수 없습니다.");
- }
-
- // 헤더를 기반으로 인덱스 매핑 생성
- const headerMapping: Record<string, number> = {};
- headerValues.forEach((value, index) => {
- if (typeof value === 'string') {
- headerMapping[value] = index;
- }
- });
-
- // 필수 헤더 확인
- const requiredHeaders = ["업체명", "이메일", "사업자등록번호", "벤더타입"];
- const alternativeHeaders = {
- "업체명": ["vendorName"],
- "업체코드": ["vendorCode"],
- "이메일": ["email"],
- "사업자등록번호": ["taxId"],
- "국가": ["country"],
- "영문국가명": ["countryEng"],
- "제조국": ["countryFab"],
- "대리점명": ["agentName"],
- "대리점연락처": ["agentPhone"],
- "대리점이메일": ["agentEmail"],
- "주소": ["address"],
- "전화번호": ["phone"],
- "웹사이트": ["website"],
- "벤더타입": ["techVendorType"],
- "대표자명": ["representativeName"],
- "대표자이메일": ["representativeEmail"],
- "대표자연락처": ["representativePhone"],
- "대표자생년월일": ["representativeBirth"],
- "아이템": ["items"]
- };
-
- // 헤더 매핑 확인 (대체 이름 포함)
- const missingHeaders = requiredHeaders.filter(header => {
- const alternatives = alternativeHeaders[header as keyof typeof alternativeHeaders] || [];
- return !(header in headerMapping) &&
- !alternatives.some(alt => alt in headerMapping);
- });
-
- if (missingHeaders.length > 0) {
- throw new Error(`다음 필수 헤더가 누락되었습니다: ${missingHeaders.join(", ")}`);
- }
-
- // 데이터 행 추출
- const dataRows: Record<string, any>[] = [];
-
- worksheet.eachRow((row, rowNumber) => {
- if (rowNumber > headerRowIndex) {
- const rowData: Record<string, any> = {};
- const values = row.values as (string | null | undefined)[];
-
- // 헤더 매핑에 따라 데이터 추출
- Object.entries(headerMapping).forEach(([header, index]) => {
- rowData[header] = values[index] || "";
- });
-
- // 빈 행이 아닌 경우만 추가
- if (Object.values(rowData).some(value => value && value.toString().trim() !== "")) {
- dataRows.push(rowData);
- }
- }
- });
-
- if (dataRows.length === 0) {
- throw new Error("Excel 파일에 가져올 데이터가 없습니다.");
- }
-
- // 진행 상황 업데이트를 위한 콜백
- const updateProgress = (current: number, total: number) => {
- const percentage = Math.round((current / total) * 100);
- setProgress(percentage);
- };
-
- // 벤더 데이터 처리
- const vendors = dataRows.map(row => ({
- vendorName: row["업체명"] || row["vendorName"] || "",
- vendorCode: row["업체코드"] || row["vendorCode"] || null,
- email: row["이메일"] || row["email"] || "",
- taxId: row["사업자등록번호"] || row["taxId"] || "",
- country: row["국가"] || row["country"] || null,
- countryEng: row["영문국가명"] || row["countryEng"] || null,
- countryFab: row["제조국"] || row["countryFab"] || null,
- agentName: row["대리점명"] || row["agentName"] || null,
- agentPhone: row["대리점연락처"] || row["agentPhone"] || null,
- agentEmail: row["대리점이메일"] || row["agentEmail"] || null,
- address: row["주소"] || row["address"] || null,
- phone: row["전화번호"] || row["phone"] || null,
- website: row["웹사이트"] || row["website"] || null,
- techVendorType: row["벤더타입"] || row["techVendorType"] || "",
- representativeName: row["대표자명"] || row["representativeName"] || null,
- representativeEmail: row["대표자이메일"] || row["representativeEmail"] || null,
- representativePhone: row["대표자연락처"] || row["representativePhone"] || null,
- representativeBirth: row["대표자생년월일"] || row["representativeBirth"] || null,
- items: row["아이템"] || row["items"] || ""
- }));
-
- // 벤더 데이터 가져오기 실행
- const result = await importTechVendorsFromExcel(vendors);
-
- if (result.success) {
- toast.success(`${vendors.length}개의 기술영업 벤더가 성공적으로 가져와졌습니다.`);
- } else {
- toast.error(result.error || "벤더 가져오기에 실패했습니다.");
- }
-
- // 상태 초기화 및 다이얼로그 닫기
- setFile(null);
- setOpen(false);
-
- // 성공 콜백 호출
- if (onSuccess) {
- onSuccess();
- }
- } catch (error) {
- console.error("Excel 파일 처리 중 오류 발생:", error);
- setError(error instanceof Error ? error.message : "파일 처리 중 오류가 발생했습니다.");
- } finally {
- setIsUploading(false);
- }
- };
-
- // 다이얼로그 열기/닫기 핸들러
- const handleOpenChange = (newOpen: boolean) => {
- if (!newOpen) {
- // 닫을 때 상태 초기화
- setFile(null);
- setError(null);
- setProgress(0);
- if (fileInputRef.current) {
- fileInputRef.current.value = "";
- }
- }
- setOpen(newOpen);
- };
-
- return (
- <>
- <Button
- variant="outline"
- size="sm"
- className="gap-2"
- onClick={() => setOpen(true)}
- disabled={isUploading}
- >
- <Upload className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">Import</span>
- </Button>
-
- <Dialog open={open} onOpenChange={handleOpenChange}>
- <DialogContent className="sm:max-w-[500px]">
- <DialogHeader>
- <DialogTitle>기술영업 벤더 가져오기</DialogTitle>
- <DialogDescription>
- 기술영업 벤더를 Excel 파일에서 가져옵니다.
- <br />
- 올바른 형식의 Excel 파일(.xlsx)을 업로드하세요.
- </DialogDescription>
- </DialogHeader>
-
- <div className="space-y-4 py-4">
- <div className="flex items-center gap-4">
- <input
- type="file"
- ref={fileInputRef}
- className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-foreground file:font-medium"
- accept=".xlsx,.xls"
- onChange={handleFileChange}
- disabled={isUploading}
- />
- </div>
-
- {file && (
- <div className="text-sm text-muted-foreground">
- 선택된 파일: <span className="font-medium">{file.name}</span> ({(file.size / 1024).toFixed(1)} KB)
- </div>
- )}
-
- {isUploading && (
- <div className="space-y-2">
- <Progress value={progress} />
- <p className="text-sm text-muted-foreground text-center">
- {progress}% 완료
- </p>
- </div>
- )}
-
- {error && (
- <div className="text-sm font-medium text-destructive">
- {error}
- </div>
- )}
- </div>
-
- <DialogFooter>
- <Button
- variant="outline"
- onClick={() => setOpen(false)}
- disabled={isUploading}
- >
- 취소
- </Button>
- <Button
- onClick={handleImport}
- disabled={!file || isUploading}
- >
- {isUploading ? "처리 중..." : "가져오기"}
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- </>
- );
+"use client"
+
+import * as React from "react"
+import { Upload } from "lucide-react"
+import { toast } from "sonner"
+import * as ExcelJS from 'exceljs'
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Progress } from "@/components/ui/progress"
+import { importTechVendorsFromExcel } from "../service"
+import { decryptWithServerAction } from "@/components/drm/drmUtils"
+
+interface ImportTechVendorButtonProps {
+ onSuccess?: () => void;
+}
+
+export function ImportTechVendorButton({ onSuccess }: ImportTechVendorButtonProps) {
+ const [open, setOpen] = React.useState(false);
+ const [file, setFile] = React.useState<File | null>(null);
+ const [isUploading, setIsUploading] = React.useState(false);
+ const [progress, setProgress] = React.useState(0);
+ const [error, setError] = React.useState<string | null>(null);
+
+ const fileInputRef = React.useRef<HTMLInputElement>(null);
+
+ // 파일 선택 처리
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ const selectedFile = e.target.files?.[0];
+ if (!selectedFile) return;
+
+ if (!selectedFile.name.endsWith('.xlsx') && !selectedFile.name.endsWith('.xls')) {
+ setError("Excel 파일(.xlsx 또는 .xls)만 가능합니다.");
+ return;
+ }
+
+ setFile(selectedFile);
+ setError(null);
+ };
+
+ // 데이터 가져오기 처리
+ const handleImport = async () => {
+ if (!file) {
+ setError("가져올 파일을 선택해주세요.");
+ return;
+ }
+
+ try {
+ setIsUploading(true);
+ setProgress(0);
+ setError(null);
+
+ // DRM 복호화 처리
+ let arrayBuffer: ArrayBuffer;
+ try {
+ setProgress(10);
+ toast.info("파일 복호화 중...");
+ arrayBuffer = await decryptWithServerAction(file);
+ setProgress(30);
+ } catch (decryptError) {
+ console.error("파일 복호화 실패, 원본 파일 사용:", decryptError);
+ toast.warning("파일 복호화에 실패하여 원본 파일을 사용합니다.");
+ arrayBuffer = await file.arrayBuffer();
+ }
+
+ // ExcelJS 워크북 로드
+ const workbook = new ExcelJS.Workbook();
+ await workbook.xlsx.load(arrayBuffer);
+
+ // 첫 번째 워크시트 가져오기
+ const worksheet = workbook.worksheets[0];
+ if (!worksheet) {
+ throw new Error("Excel 파일에 워크시트가 없습니다.");
+ }
+
+ // 헤더 행 찾기
+ let headerRowIndex = 1;
+ let headerRow: ExcelJS.Row | undefined;
+ let headerValues: (string | null)[] = [];
+
+ worksheet.eachRow((row, rowNumber) => {
+ const values = row.values as (string | null)[];
+ if (!headerRow && values.some(v => v === "업체명" || v === "vendorName")) {
+ headerRowIndex = rowNumber;
+ headerRow = row;
+ headerValues = [...values];
+ }
+ });
+
+ if (!headerRow) {
+ throw new Error("Excel 파일에서 헤더 행을 찾을 수 없습니다.");
+ }
+
+ // 헤더를 기반으로 인덱스 매핑 생성
+ const headerMapping: Record<string, number> = {};
+ headerValues.forEach((value, index) => {
+ if (typeof value === 'string') {
+ headerMapping[value] = index;
+ }
+ });
+
+ // 필수 헤더 확인
+ const requiredHeaders = ["업체명", "이메일", "사업자등록번호", "벤더타입"];
+ const alternativeHeaders = {
+ "업체명": ["vendorName"],
+ "업체코드": ["vendorCode"],
+ "이메일": ["email"],
+ "사업자등록번호": ["taxId"],
+ "국가": ["country"],
+ "영문국가명": ["countryEng"],
+ "제조국": ["countryFab"],
+ "에이전트명": ["agentName"],
+ "에이전트연락처": ["agentPhone"],
+ "에이전트이메일": ["agentEmail"],
+ "주소": ["address"],
+ "전화번호": ["phone"],
+ "웹사이트": ["website"],
+ "벤더타입": ["techVendorType"],
+ "대표자명": ["representativeName"],
+ "대표자이메일": ["representativeEmail"],
+ "대표자연락처": ["representativePhone"],
+ "대표자생년월일": ["representativeBirth"],
+ "담당자명": ["contactName"],
+ "담당자직책": ["contactPosition"],
+ "담당자이메일": ["contactEmail"],
+ "담당자연락처": ["contactPhone"],
+ "담당자국가": ["contactCountry"],
+ "아이템": ["items"]
+ };
+
+ // 헤더 매핑 확인 (대체 이름 포함)
+ const missingHeaders = requiredHeaders.filter(header => {
+ const alternatives = alternativeHeaders[header as keyof typeof alternativeHeaders] || [];
+ return !(header in headerMapping) &&
+ !alternatives.some(alt => alt in headerMapping);
+ });
+
+ if (missingHeaders.length > 0) {
+ throw new Error(`다음 필수 헤더가 누락되었습니다: ${missingHeaders.join(", ")}`);
+ }
+
+ // 데이터 행 추출
+ const dataRows: Record<string, string | null | undefined>[] = [];
+
+ worksheet.eachRow((row, rowNumber) => {
+ if (rowNumber > headerRowIndex) {
+ const rowData: Record<string, string | null | undefined> = {};
+ const values = row.values as (string | null | undefined)[];
+
+ // 헤더 매핑에 따라 데이터 추출
+ Object.entries(headerMapping).forEach(([header, index]) => {
+ rowData[header] = values[index] || "";
+ });
+
+ // 빈 행이 아닌 경우만 추가
+ if (Object.values(rowData).some(value => value && value.toString().trim() !== "")) {
+ dataRows.push(rowData);
+ }
+ }
+ });
+
+ if (dataRows.length === 0) {
+ throw new Error("Excel 파일에 가져올 데이터가 없습니다.");
+ }
+
+ setProgress(70);
+
+ // 벤더 데이터 처리
+ const vendors = dataRows.map(row => {
+ const vendorEmail = row["이메일"] || row["email"] || "";
+ const contactName = row["담당자명"] || row["contactName"] || "";
+ const contactEmail = row["담당자이메일"] || row["contactEmail"] || "";
+
+ // 담당자 정보 처리: 담당자가 없으면 벤더 이메일을 기본 담당자로 사용
+ const contacts = [];
+
+ if (contactName && contactEmail) {
+ // 명시적인 담당자가 있는 경우
+ contacts.push({
+ contactName: contactName,
+ contactPosition: row["담당자직책"] || row["contactPosition"] || "",
+ contactEmail: contactEmail,
+ contactPhone: row["담당자연락처"] || row["contactPhone"] || "",
+ country: row["담당자국가"] || row["contactCountry"] || null,
+ isPrimary: true
+ });
+ } else if (vendorEmail) {
+ // 담당자 정보가 없으면 벤더 정보를 기본 담당자로 사용
+ const representativeName = row["대표자명"] || row["representativeName"];
+ contacts.push({
+ contactName: representativeName || row["업체명"] || row["vendorName"] || "기본 담당자",
+ contactPosition: "기본 담당자",
+ contactEmail: vendorEmail,
+ contactPhone: row["대표자연락처"] || row["representativePhone"] || row["전화번호"] || row["phone"] || "",
+ country: row["국가"] || row["country"] || null,
+ isPrimary: true
+ });
+ }
+
+ return {
+ vendorName: row["업체명"] || row["vendorName"] || "",
+ vendorCode: row["업체코드"] || row["vendorCode"] || null,
+ email: vendorEmail,
+ taxId: row["사업자등록번호"] || row["taxId"] || "",
+ country: row["국가"] || row["country"] || null,
+ countryEng: row["영문국가명"] || row["countryEng"] || null,
+ countryFab: row["제조국"] || row["countryFab"] || null,
+ agentName: row["에이전트명"] || row["agentName"] || null,
+ agentPhone: row["에이전트연락처"] || row["agentPhone"] || null,
+ agentEmail: row["에이전트이메일"] || row["agentEmail"] || null,
+ address: row["주소"] || row["address"] || null,
+ phone: row["전화번호"] || row["phone"] || null,
+ website: row["웹사이트"] || row["website"] || null,
+ techVendorType: row["벤더타입"] || row["techVendorType"] || "",
+ representativeName: row["대표자명"] || row["representativeName"] || null,
+ representativeEmail: row["대표자이메일"] || row["representativeEmail"] || null,
+ representativePhone: row["대표자연락처"] || row["representativePhone"] || null,
+ representativeBirth: row["대표자생년월일"] || row["representativeBirth"] || null,
+ items: row["아이템"] || row["items"] || "",
+ contacts: contacts
+ };
+ });
+
+ setProgress(90);
+ toast.info(`${vendors.length}개 벤더 데이터를 서버로 전송 중...`);
+
+ // 벤더 데이터 가져오기 실행
+ const result = await importTechVendorsFromExcel(vendors);
+
+ setProgress(100);
+
+ if (result.success) {
+ // 상세한 결과 메시지 표시
+ if (result.message) {
+ toast.success(`가져오기 완료: ${result.message}`);
+ } else {
+ toast.success(`${vendors.length}개의 기술영업 벤더가 성공적으로 가져와졌습니다.`);
+ }
+
+ // 스킵된 벤더가 있으면 경고 메시지 추가
+ if (result.details?.skipped && result.details.skipped.length > 0) {
+ setTimeout(() => {
+ const skippedList = result.details.skipped
+ .map(item => `${item.vendorName} (${item.email}): ${item.reason}`)
+ .slice(0, 3) // 최대 3개만 표시
+ .join('\n');
+ const moreText = result.details.skipped.length > 3 ? `\n... 외 ${result.details.skipped.length - 3}개` : '';
+ toast.warning(`중복으로 스킵된 벤더:\n${skippedList}${moreText}`);
+ }, 1000);
+ }
+
+ // 오류가 있으면 오류 메시지 추가
+ if (result.details?.errors && result.details.errors.length > 0) {
+ setTimeout(() => {
+ const errorList = result.details.errors
+ .map(item => `${item.vendorName} (${item.email}): ${item.error}`)
+ .slice(0, 3) // 최대 3개만 표시
+ .join('\n');
+ const moreText = result.details.errors.length > 3 ? `\n... 외 ${result.details.errors.length - 3}개` : '';
+ toast.error(`처리 중 오류 발생:\n${errorList}${moreText}`);
+ }, 2000);
+ }
+ } else {
+ toast.error(result.error || "벤더 가져오기에 실패했습니다.");
+ }
+
+ // 상태 초기화 및 다이얼로그 닫기
+ setFile(null);
+ setOpen(false);
+
+ // 성공 콜백 호출
+ if (onSuccess) {
+ onSuccess();
+ }
+ } catch (error) {
+ console.error("Excel 파일 처리 중 오류 발생:", error);
+ setError(error instanceof Error ? error.message : "파일 처리 중 오류가 발생했습니다.");
+ } finally {
+ setIsUploading(false);
+ }
+ };
+
+ // 다이얼로그 열기/닫기 핸들러
+ const handleOpenChange = (newOpen: boolean) => {
+ if (!newOpen) {
+ // 닫을 때 상태 초기화
+ setFile(null);
+ setError(null);
+ setProgress(0);
+ if (fileInputRef.current) {
+ fileInputRef.current.value = "";
+ }
+ }
+ setOpen(newOpen);
+ };
+
+ return (
+ <>
+ <Button
+ variant="outline"
+ size="sm"
+ className="gap-2"
+ onClick={() => setOpen(true)}
+ disabled={isUploading}
+ >
+ <Upload className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Import</span>
+ </Button>
+
+ <Dialog open={open} onOpenChange={handleOpenChange}>
+ <DialogContent className="sm:max-w-[500px]">
+ <DialogHeader>
+ <DialogTitle>기술영업 벤더 가져오기</DialogTitle>
+ <DialogDescription>
+ 기술영업 벤더를 Excel 파일에서 가져옵니다.
+ <br />
+ 올바른 형식의 Excel 파일(.xlsx)을 업로드하세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4 py-4">
+ <div className="flex items-center gap-4">
+ <input
+ type="file"
+ ref={fileInputRef}
+ className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-foreground file:font-medium"
+ accept=".xlsx,.xls"
+ onChange={handleFileChange}
+ disabled={isUploading}
+ />
+ </div>
+
+ {file && (
+ <div className="text-sm text-muted-foreground">
+ 선택된 파일: <span className="font-medium">{file.name}</span> ({(file.size / 1024).toFixed(1)} KB)
+ </div>
+ )}
+
+ {isUploading && (
+ <div className="space-y-2">
+ <Progress value={progress} />
+ <p className="text-sm text-muted-foreground text-center">
+ {progress}% 완료
+ </p>
+ </div>
+ )}
+
+ {error && (
+ <div className="text-sm font-medium text-destructive">
+ {error}
+ </div>
+ )}
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => setOpen(false)}
+ disabled={isUploading}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={handleImport}
+ disabled={!file || isUploading}
+ >
+ {isUploading ? "처리 중..." : "가져오기"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </>
+ );
} \ No newline at end of file
diff --git a/lib/tech-vendors/table/tech-vendor-possible-items-view-dialog.tsx b/lib/tech-vendors/table/tech-vendor-possible-items-view-dialog.tsx
deleted file mode 100644
index b2b9c990..00000000
--- a/lib/tech-vendors/table/tech-vendor-possible-items-view-dialog.tsx
+++ /dev/null
@@ -1,201 +0,0 @@
-"use client"
-
-import * as React from "react"
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogDescription,
- DialogFooter,
-} from "@/components/ui/dialog"
-import { Button } from "@/components/ui/button"
-import { Badge } from "@/components/ui/badge"
-import { Package, FileText, X } from "lucide-react"
-import { getVendorItemsByType } from "../service"
-
-interface VendorPossibleItem {
- id: number;
- itemCode: string;
- itemList: string;
- workType: string | null;
- shipTypes?: string | null; // 조선용
- subItemList?: string | null; // 해양용
- techVendorType: "조선" | "해양TOP" | "해양HULL";
-}
-
-interface TechVendorPossibleItemsViewDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- vendor: {
- id: number;
- vendorName?: string | null;
- vendorCode?: string | null;
- techVendorType?: string | null;
- } | null;
-}
-
-export function TechVendorPossibleItemsViewDialog({
- open,
- onOpenChange,
- vendor,
-}: TechVendorPossibleItemsViewDialogProps) {
- const [items, setItems] = React.useState<VendorPossibleItem[]>([]);
- const [loading, setLoading] = React.useState(false);
-
- console.log("TechVendorPossibleItemsViewDialog render:", { open, vendor });
-
- React.useEffect(() => {
- console.log("TechVendorPossibleItemsViewDialog useEffect:", { open, vendorId: vendor?.id });
- if (open && vendor?.id && vendor?.techVendorType) {
- loadItems();
- }
- }, [open, vendor?.id, vendor?.techVendorType]);
-
- const loadItems = async () => {
- if (!vendor?.id || !vendor?.techVendorType) return;
-
- console.log("Loading items for vendor:", vendor.id, vendor.techVendorType);
- setLoading(true);
- try {
- const result = await getVendorItemsByType(vendor.id, vendor.techVendorType);
- console.log("Items loaded:", result);
- if (result.data) {
- setItems(result.data);
- }
- } catch (error) {
- console.error("Failed to load items:", error);
- } finally {
- setLoading(false);
- }
- };
-
- const getTypeLabel = (type: string) => {
- switch (type) {
- case "조선":
- return "조선";
- case "해양TOP":
- return "해양TOP";
- case "해양HULL":
- return "해양HULL";
- default:
- return type;
- }
- };
-
- const getTypeColor = (type: string) => {
- switch (type) {
- case "조선":
- return "bg-blue-100 text-blue-800";
- case "해양TOP":
- return "bg-green-100 text-green-800";
- case "해양HULL":
- return "bg-purple-100 text-purple-800";
- default:
- return "bg-gray-100 text-gray-800";
- }
- };
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-none w-[1200px]">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- 벤더 Possible Items 조회
- <Badge variant="outline" className="ml-2">
- {vendor?.vendorName || `Vendor #${vendor?.id}`}
- </Badge>
- {vendor?.techVendorType && (
- <Badge variant="secondary" className={getTypeColor(vendor.techVendorType)}>
- {getTypeLabel(vendor.techVendorType)}
- </Badge>
- )}
- </DialogTitle>
- <DialogDescription>
- 해당 벤더가 공급 가능한 아이템 목록을 확인할 수 있습니다.
- </DialogDescription>
- </DialogHeader>
-
- <div className="overflow-x-auto w-full">
- <div className="space-y-4">
- {loading ? (
- <div className="flex items-center justify-center py-8">
- <div className="text-center space-y-2">
- <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
- <p className="text-sm text-muted-foreground">아이템을 불러오는 중...</p>
- </div>
- </div>
- ) : items.length === 0 ? (
- <div className="flex flex-col items-center justify-center py-12 text-center">
- <FileText className="h-12 w-12 text-muted-foreground mb-3" />
- <h3 className="text-lg font-medium mb-1">등록된 아이템이 없습니다</h3>
- <p className="text-sm text-muted-foreground">
- 이 벤더에 등록된 아이템이 없습니다.
- </p>
- </div>
- ) : (
- <>
- {/* 헤더 행 (라벨) */}
- <div className="flex items-center gap-2 border-b pb-2 font-medium text-sm">
- <div className="w-[50px] text-center">No.</div>
- <div className="w-[120px] pl-2">타입</div>
- <div className="w-[200px] ">자재 그룹</div>
- <div className="w-[150px] ">공종</div>
- <div className="w-[300px] ">자재명</div>
- <div className="w-[150px] ">선종/자재명(상세)</div>
- </div>
-
- {/* 아이템 행들 */}
- <div className="max-h-[50vh] overflow-y-auto pr-1 space-y-2">
- {items.map((item, index) => (
- <div
- key={item.id}
- className="flex items-center gap-2 group hover:bg-gray-50 p-2 rounded-md transition-colors border"
- >
- <div className="w-[50px] text-center text-sm font-medium text-muted-foreground">
- {index + 1}
- </div>
- <div className="w-[120px] pl-2">
- <Badge variant="secondary" className={`text-xs ${getTypeColor(item.techVendorType)}`}>
- {getTypeLabel(item.techVendorType)}
- </Badge>
- </div>
- <div className="w-[200px] pl-2 font-mono text-sm">
- {item.itemCode}
- </div>
- <div className="w-[150px] pl-2 text-sm">
- {item.workType || '-'}
- </div>
- <div className="w-[300px] pl-2 font-medium">
- {item.itemList}
- </div>
- <div className="w-[150px] pl-2 text-sm">
- {item.techVendorType === '조선' ? item.shipTypes : item.subItemList}
- </div>
- </div>
- ))}
- </div>
-
- <div className="flex justify-between items-center pt-2 border-t">
- <div className="flex items-center gap-2">
- <Package className="h-4 w-4 text-muted-foreground" />
- <span className="text-sm text-muted-foreground">
- 총 {items.length}개 아이템
- </span>
- </div>
- </div>
- </>
- )}
- </div>
- </div>
-
- <DialogFooter className="mt-6">
- <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
- <X className="mr-2 h-4 w-4" />
- 닫기
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
-} \ No newline at end of file
diff --git a/lib/tech-vendors/table/tech-vendors-filter-sheet.tsx b/lib/tech-vendors/table/tech-vendors-filter-sheet.tsx
new file mode 100644
index 00000000..c6beb7a9
--- /dev/null
+++ b/lib/tech-vendors/table/tech-vendors-filter-sheet.tsx
@@ -0,0 +1,617 @@
+"use client"
+
+import { useEffect, useTransition, useState, useRef } from "react"
+import { z } from "zod"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Search, X } from "lucide-react"
+import { customAlphabet } from "nanoid"
+import { parseAsStringEnum, useQueryState } from "nuqs"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { Badge } from "@/components/ui/badge"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { cn } from "@/lib/utils"
+import { getFiltersStateParser } from "@/lib/parsers"
+import { Checkbox } from "@/components/ui/checkbox"
+
+// nanoid 생성기
+const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6)
+
+// 필터 스키마 정의 (기술영업 벤더에 맞게 수정)
+const filterSchema = z.object({
+ vendorCode: z.string().optional(),
+ vendorName: z.string().optional(),
+ country: z.string().optional(),
+ status: z.string().optional(),
+ techVendorType: z.array(z.string()).optional(),
+ workTypes: z.array(z.string()).optional(),
+})
+
+// 상태 옵션 정의
+const statusOptions = [
+ { value: "ACTIVE", label: "활성 상태" },
+ { value: "INACTIVE", label: "비활성 상태" },
+ { value: "BLACKLISTED", label: "거래 금지" },
+ { value: "PENDING_INVITE", label: "초대 대기" },
+ { value: "INVITED", label: "초대 완료" },
+ { value: "QUOTE_COMPARISON", label: "견적 비교" },
+]
+
+// 벤더 타입 옵션
+const vendorTypeOptions = [
+ { value: "조선", label: "조선" },
+ { value: "해양TOP", label: "해양TOP" },
+ { value: "해양HULL", label: "해양HULL" },
+]
+
+// 공종 옵션
+const workTypeOptions = [
+ // 조선 workTypes
+ { value: "기장", label: "기장" },
+ { value: "전장", label: "전장" },
+ { value: "선실", label: "선실" },
+ { value: "배관", label: "배관" },
+ { value: "철의", label: "철의" },
+ { value: "선체", label: "선체" },
+ // 해양TOP workTypes
+ { value: "TM", label: "TM" },
+ { value: "TS", label: "TS" },
+ { value: "TE", label: "TE" },
+ { value: "TP", label: "TP" },
+ // 해양HULL workTypes
+ { value: "HA", label: "HA" },
+ { value: "HE", label: "HE" },
+ { value: "HH", label: "HH" },
+ { value: "HM", label: "HM" },
+ { value: "NC", label: "NC" },
+ { value: "HO", label: "HO" },
+ { value: "HP", label: "HP" },
+]
+
+type FilterFormValues = z.infer<typeof filterSchema>
+
+interface TechVendorsFilterSheetProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSearch?: () => void;
+ isLoading?: boolean;
+}
+
+export function TechVendorsFilterSheet({
+ isOpen,
+ onSearch,
+ isLoading = false
+}: TechVendorsFilterSheetProps) {
+
+
+ const [isPending, startTransition] = useTransition()
+
+ // 초기화 상태 추가 - 폼 초기화 중에는 상태 변경을 방지
+ const [isInitializing, setIsInitializing] = useState(false)
+ // 마지막으로 적용된 필터를 추적하기 위한 ref
+ const lastAppliedFilters = useRef<string>("")
+
+ // nuqs로 URL 상태 관리 - 파라미터명을 'filters'로 변경하여 searchParamsCache와 일치
+ const [filters] = useQueryState(
+ "filters",
+ getFiltersStateParser().withDefault([])
+ )
+
+ // joinOperator 설정
+ const [joinOperator, setJoinOperator] = useQueryState(
+ "joinOperator",
+ parseAsStringEnum(["and", "or"]).withDefault("and")
+ )
+
+ // 폼 상태 초기화
+ const form = useForm<FilterFormValues>({
+ resolver: zodResolver(filterSchema),
+ defaultValues: {
+ vendorCode: "",
+ vendorName: "",
+ country: "",
+ status: "",
+ techVendorType: [],
+ workTypes: [],
+ },
+ })
+
+ // URL 필터에서 초기 폼 상태 설정
+ useEffect(() => {
+ const currentFiltersString = JSON.stringify(filters);
+
+ if (isOpen && filters && filters.length > 0 && currentFiltersString !== lastAppliedFilters.current) {
+ setIsInitializing(true);
+
+ const formValues = { ...form.getValues() };
+ let formUpdated = false;
+
+ filters.forEach(filter => {
+ if (filter.id === "techVendorType" && Array.isArray(filter.value)) {
+ formValues.techVendorType = filter.value;
+ formUpdated = true;
+ } else if (filter.id === "workTypes" && Array.isArray(filter.value)) {
+ formValues.workTypes = filter.value;
+ formUpdated = true;
+ } else if (filter.id in formValues) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (formValues as any)[filter.id] = filter.value;
+ formUpdated = true;
+ }
+ });
+
+ if (formUpdated) {
+ form.reset(formValues);
+ lastAppliedFilters.current = currentFiltersString;
+ }
+
+ setIsInitializing(false);
+ }
+ }, [filters, isOpen, form])
+
+ // 현재 적용된 필터 카운트
+ const getActiveFilterCount = () => {
+ return filters?.length || 0
+ }
+
+ // 폼 제출 핸들러
+ async function onSubmit(data: FilterFormValues) {
+ if (isInitializing) return;
+
+ startTransition(async () => {
+ try {
+ const newFilters = []
+
+ if (data.vendorCode?.trim()) {
+ newFilters.push({
+ id: "vendorCode",
+ value: data.vendorCode.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ })
+ }
+
+ if (data.vendorName?.trim()) {
+ newFilters.push({
+ id: "vendorName",
+ value: data.vendorName.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ })
+ }
+
+ if (data.country?.trim()) {
+ newFilters.push({
+ id: "country",
+ value: data.country.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ })
+ }
+
+ if (data.status?.trim()) {
+ newFilters.push({
+ id: "status",
+ value: data.status.trim(),
+ type: "select",
+ operator: "eq",
+ rowId: generateId()
+ })
+ }
+
+ if (data.techVendorType && data.techVendorType.length > 0) {
+ newFilters.push({
+ id: "techVendorType",
+ value: data.techVendorType,
+ type: "multi-select",
+ operator: "eq",
+ rowId: generateId()
+ })
+ }
+
+ if (data.workTypes && data.workTypes.length > 0) {
+ newFilters.push({
+ id: "workTypes",
+ value: data.workTypes,
+ type: "multi-select",
+ operator: "eq",
+ rowId: generateId()
+ })
+ }
+
+ // URL 수동 업데이트
+ const currentUrl = new URL(window.location.href);
+ const params = new URLSearchParams(currentUrl.search);
+
+ // 기존 필터 관련 파라미터 제거
+ params.delete('filters');
+ params.delete('joinOperator');
+ params.delete('page');
+
+ // 새로운 필터 추가
+ if (newFilters.length > 0) {
+ params.set('filters', JSON.stringify(newFilters));
+ params.set('joinOperator', joinOperator);
+ }
+
+ // 페이지를 1로 설정
+ params.set('page', '1');
+
+ const newUrl = `${currentUrl.pathname}?${params.toString()}`;
+
+ // 페이지 완전 새로고침
+ window.location.href = newUrl;
+
+ // 마지막 적용된 필터 업데이트
+ lastAppliedFilters.current = JSON.stringify(newFilters);
+
+ if (onSearch) {
+ onSearch();
+ }
+ } catch (error) {
+ console.error("벤더 필터 적용 오류:", error);
+ }
+ })
+ }
+
+ // 필터 초기화 핸들러
+ async function handleReset() {
+ try {
+ setIsInitializing(true);
+
+ form.reset({
+ vendorCode: "",
+ vendorName: "",
+ country: "",
+ status: "",
+ techVendorType: [],
+ workTypes: [],
+ });
+
+ const currentUrl = new URL(window.location.href);
+ const params = new URLSearchParams(currentUrl.search);
+
+ params.delete('filters');
+ params.delete('joinOperator');
+ params.set('page', '1');
+
+ const newUrl = `${currentUrl.pathname}?${params.toString()}`;
+ window.location.href = newUrl;
+
+ lastAppliedFilters.current = "";
+ setIsInitializing(false);
+ } catch (error) {
+ console.error("벤더 필터 초기화 오류:", error);
+ setIsInitializing(false);
+ }
+ }
+
+ if (!isOpen) {
+ return null;
+ }
+
+ return (
+ <div className="flex flex-col h-full max-h-full bg-[#F5F7FB] px-6 sm:px-8" style={{backgroundColor:"#F5F7FB", paddingLeft:"2rem", paddingRight:"2rem"}}>
+ {/* Filter Panel Header */}
+ <div className="flex items-center justify-between px-6 min-h-[60px] shrink-0">
+ <h3 className="text-lg font-semibold whitespace-nowrap">벤더 검색 필터</h3>
+ <div className="flex items-center gap-2">
+ {getActiveFilterCount() > 0 && (
+ <Badge variant="secondary" className="px-2 py-1">
+ {getActiveFilterCount()}개 필터 적용됨
+ </Badge>
+ )}
+ </div>
+ </div>
+
+ {/* Join Operator Selection */}
+ <div className="px-6 shrink-0">
+ <label className="text-sm font-medium">조건 결합 방식</label>
+ <Select
+ value={joinOperator}
+ onValueChange={(value: "and" | "or") => setJoinOperator(value)}
+ disabled={isInitializing}
+ >
+ <SelectTrigger className="h-8 w-[180px] mt-2 bg-white">
+ <SelectValue placeholder="조건 결합 방식" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="and">모든 조건 충족 (AND)</SelectItem>
+ <SelectItem value="or">하나라도 충족 (OR)</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col h-full min-h-0">
+ {/* Scrollable content area */}
+ <div className="flex-1 min-h-0 overflow-y-auto px-6 pb-4">
+ <div className="space-y-4 pt-2">
+ {/* 벤더코드 */}
+ <FormField
+ control={form.control}
+ name="vendorCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>벤더코드</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder="벤더코드 입력"
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("vendorCode", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 벤더명 */}
+ <FormField
+ control={form.control}
+ name="vendorName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>벤더명</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder="벤더명 입력"
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("vendorName", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 국가 */}
+ <FormField
+ control={form.control}
+ name="country"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>국가</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder="국가 입력"
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("country", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 상태 */}
+ <FormField
+ control={form.control}
+ name="status"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>상태</FormLabel>
+ <Select
+ value={field.value}
+ onValueChange={field.onChange}
+ disabled={isInitializing}
+ >
+ <FormControl>
+ <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
+ <div className="flex justify-between w-full">
+ <SelectValue placeholder="상태 선택" />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="h-4 w-4 -mr-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("status", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {statusOptions.map(option => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 벤더 타입 */}
+ <FormField
+ control={form.control}
+ name="techVendorType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>벤더 타입</FormLabel>
+ <div className="space-y-2">
+ {vendorTypeOptions.map((option) => (
+ <div key={option.value} className="flex items-center space-x-2">
+ <Checkbox
+ id={`vendorType-${option.value}`}
+ checked={field.value?.includes(option.value) || false}
+ onCheckedChange={(checked) => {
+ const updatedValue = checked
+ ? [...(field.value || []), option.value]
+ : (field.value || []).filter((value) => value !== option.value);
+ field.onChange(updatedValue);
+ }}
+ disabled={isInitializing}
+ />
+ <label
+ htmlFor={`vendorType-${option.value}`}
+ className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+ >
+ {option.label}
+ </label>
+ </div>
+ ))}
+ </div>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 공종 */}
+ <FormField
+ control={form.control}
+ name="workTypes"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>공종</FormLabel>
+ <div className="grid grid-cols-2 gap-2">
+ {workTypeOptions.map((option) => (
+ <div key={option.value} className="flex items-center space-x-2">
+ <Checkbox
+ id={`workType-${option.value}`}
+ checked={field.value?.includes(option.value) || false}
+ onCheckedChange={(checked) => {
+ const updatedValue = checked
+ ? [...(field.value || []), option.value]
+ : (field.value || []).filter((value) => value !== option.value);
+ field.onChange(updatedValue);
+ }}
+ disabled={isInitializing}
+ />
+ <label
+ htmlFor={`workType-${option.value}`}
+ className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+ >
+ {option.label}
+ </label>
+ </div>
+ ))}
+ </div>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+
+ {/* Action buttons */}
+ <div className="shrink-0 border-t bg-white px-6 py-4">
+ <div className="flex gap-2">
+ <Button
+ type="submit"
+ className="flex-1"
+ disabled={isPending || isInitializing || isLoading}
+ >
+ {isPending ? (
+ <>
+ <Search className="mr-2 h-4 w-4 animate-spin" />
+ 검색 중...
+ </>
+ ) : (
+ <>
+ <Search className="mr-2 h-4 w-4" />
+ 검색
+ </>
+ )}
+ </Button>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleReset}
+ disabled={isPending || isInitializing || isLoading}
+ >
+ 초기화
+ </Button>
+ </div>
+ </div>
+ </form>
+ </Form>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/tech-vendors/table/tech-vendors-table-columns.tsx b/lib/tech-vendors/table/tech-vendors-table-columns.tsx
index 052794ce..5184e3f3 100644
--- a/lib/tech-vendors/table/tech-vendors-table-columns.tsx
+++ b/lib/tech-vendors/table/tech-vendors-table-columns.tsx
@@ -1,414 +1,376 @@
-"use client"
-
-import * as React from "react"
-import { type DataTableRowAction } from "@/types/table"
-import { type ColumnDef } from "@tanstack/react-table"
-import { Ellipsis, Package } from "lucide-react"
-import { toast } from "sonner"
-
-import { getErrorMessage } from "@/lib/handle-error"
-import { formatDate } from "@/lib/utils"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuRadioGroup,
- DropdownMenuRadioItem,
- DropdownMenuSub,
- DropdownMenuSubContent,
- DropdownMenuSubTrigger,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-import { useRouter } from "next/navigation"
-
-import { TechVendor, techVendors } from "@/db/schema/techVendors"
-import { modifyTechVendor } from "../service"
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import { techVendorColumnsConfig } from "@/config/techVendorColumnsConfig"
-import { Separator } from "@/components/ui/separator"
-import { getVendorStatusIcon } from "../utils"
-
-// 타입 정의 추가
-type StatusType = (typeof techVendors.status.enumValues)[number];
-type BadgeVariantType = "default" | "secondary" | "destructive" | "outline";
-type StatusConfig = {
- variant: BadgeVariantType;
- className: string;
-};
-type StatusDisplayMap = {
- [key in StatusType]: string;
-};
-
-type NextRouter = ReturnType<typeof useRouter>;
-
-interface GetColumnsProps {
- setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TechVendor> | null>>;
- router: NextRouter;
- openItemsDialog: (vendor: TechVendor) => void;
-}
-
-
-
-
-/**
- * tanstack table 컬럼 정의 (중첩 헤더 버전)
- */
-export function getColumns({ setRowAction, router, openItemsDialog }: GetColumnsProps): ColumnDef<TechVendor>[] {
- // ----------------------------------------------------------------
- // 1) select 컬럼 (체크박스)
- // ----------------------------------------------------------------
- const selectColumn: ColumnDef<TechVendor> = {
- id: "select",
- header: ({ table }) => (
- <Checkbox
- checked={
- table.getIsAllPageRowsSelected() ||
- (table.getIsSomePageRowsSelected() && "indeterminate")
- }
- onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
- aria-label="Select all"
- className="translate-y-0.5"
- />
- ),
- cell: ({ row }) => (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => row.toggleSelected(!!value)}
- aria-label="Select row"
- className="translate-y-0.5"
- />
- ),
- size: 40,
- enableSorting: false,
- enableHiding: false,
- }
-
- // ----------------------------------------------------------------
- // 2) actions 컬럼 (Dropdown 메뉴)
- // ----------------------------------------------------------------
- const actionsColumn: ColumnDef<TechVendor> = {
- id: "actions",
- enableHiding: false,
- cell: function Cell({ row }) {
- const [isUpdatePending, startUpdateTransition] = React.useTransition()
-
- return (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- aria-label="Open menu"
- variant="ghost"
- className="flex size-8 p-0 data-[state=open]:bg-muted"
- >
- <Ellipsis className="size-4" aria-hidden="true" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="w-56">
- <DropdownMenuItem
- onSelect={() => setRowAction({ row, type: "update" })}
- >
- 레코드 편집
- </DropdownMenuItem>
-
- <DropdownMenuItem
- onSelect={() => {
- // 1) 만약 rowAction을 열고 싶다면
- // setRowAction({ row, type: "update" })
-
- // 2) 자세히 보기 페이지로 클라이언트 라우팅
- router.push(`/evcp/tech-vendors/${row.original.id}/info`);
- }}
- >
- 상세보기
- </DropdownMenuItem>
- <DropdownMenuItem
- onSelect={() => {
- // 새창으로 열기 위해 window.open() 사용
- window.open(`/evcp/tech-vendors/${row.original.id}/info`, '_blank');
- }}
- >
- 상세보기(새창)
- </DropdownMenuItem>
-
- <Separator />
- <DropdownMenuSub>
- <DropdownMenuSubTrigger>Status</DropdownMenuSubTrigger>
- <DropdownMenuSubContent>
- <DropdownMenuRadioGroup
- value={row.original.status}
- onValueChange={(value) => {
- startUpdateTransition(() => {
- toast.promise(
- modifyTechVendor({
- id: String(row.original.id),
- status: value as TechVendor["status"],
- vendorName: row.original.vendorName, // Required field from UpdateVendorSchema
- }),
- {
- loading: "Updating...",
- success: "Label updated",
- error: (err) => getErrorMessage(err),
- }
- )
- })
- }}
- >
- {techVendors.status.enumValues.map((status) => (
- <DropdownMenuRadioItem
- key={status}
- value={status}
- className="capitalize"
- disabled={isUpdatePending}
- >
- {status}
- </DropdownMenuRadioItem>
- ))}
- </DropdownMenuRadioGroup>
- </DropdownMenuSubContent>
- </DropdownMenuSub>
-
-
- </DropdownMenuContent>
- </DropdownMenu>
- )
- },
- size: 40,
- }
-
- // ----------------------------------------------------------------
- // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
- // ----------------------------------------------------------------
- // 3-1) groupMap: { [groupName]: ColumnDef<TechVendor>[] }
- const groupMap: Record<string, ColumnDef<TechVendor>[]> = {}
-
- techVendorColumnsConfig.forEach((cfg) => {
- // 만약 group가 없으면 "_noGroup" 처리
- const groupName = cfg.group || "_noGroup"
-
- if (!groupMap[groupName]) {
- groupMap[groupName] = []
- }
-
- // child column 정의
- const childCol: ColumnDef<TechVendor> = {
- accessorKey: cfg.id,
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title={cfg.label} />
- ),
- meta: {
- excelHeader: cfg.excelHeader,
- group: cfg.group,
- type: cfg.type,
- },
- cell: ({ row, cell }) => {
- // Status 컬럼 렌더링 개선 - 아이콘과 더 선명한 배경색 사용
- if (cfg.id === "status") {
- const statusVal = row.original.status;
- if (!statusVal) return null;
-
- // Status badge variant mapping - 더 뚜렷한 색상으로 변경
- const getStatusConfig = (status: StatusType): StatusConfig & { iconColor: string } => {
- switch (status) {
- case "ACTIVE":
- return {
- variant: "default",
- className: "bg-emerald-100 text-emerald-800 border-emerald-300 font-semibold",
- iconColor: "text-emerald-600"
- };
- case "INACTIVE":
- return {
- variant: "default",
- className: "bg-gray-100 text-gray-800 border-gray-300",
- iconColor: "text-gray-600"
- };
-
- case "PENDING_INVITE":
- return {
- variant: "default",
- className: "bg-blue-100 text-blue-800 border-blue-300",
- iconColor: "text-blue-600"
- };
- case "INVITED":
- return {
- variant: "default",
- className: "bg-green-100 text-green-800 border-green-300",
- iconColor: "text-green-600"
- };
- case "QUOTE_COMPARISON":
- return {
- variant: "default",
- className: "bg-purple-100 text-purple-800 border-purple-300",
- iconColor: "text-purple-600"
- };
- case "BLACKLISTED":
- return {
- variant: "destructive",
- className: "bg-slate-800 text-white border-slate-900",
- iconColor: "text-white"
- };
- default:
- return {
- variant: "default",
- className: "bg-gray-100 text-gray-800 border-gray-300",
- iconColor: "text-gray-600"
- };
- }
- };
-
- // 상태 표시 텍스트
- const getStatusDisplay = (status: StatusType): string => {
- const statusMap: StatusDisplayMap = {
- "ACTIVE": "활성 상태",
- "INACTIVE": "비활성 상태",
- "BLACKLISTED": "거래 금지",
- "PENDING_INVITE": "초대 대기",
- "INVITED": "초대 완료",
- "QUOTE_COMPARISON": "견적 비교"
- };
-
- return statusMap[status] || status;
- };
-
- const statusConfig = getStatusConfig(statusVal);
- const displayText = getStatusDisplay(statusVal);
- const StatusIcon = getVendorStatusIcon(statusVal);
-
- return (
- <div className="flex items-center gap-2">
- <Badge
- variant={statusConfig.variant}
- className={statusConfig.className}
- >
- <StatusIcon className={`mr-1 h-3.5 w-3.5 ${statusConfig.iconColor}`} />
- {displayText}
- </Badge>
- </div>
- );
- }
- // TechVendorType 컬럼을 badge로 표시
- if (cfg.id === "techVendorType") {
- const techVendorType = row.original.techVendorType as string | null | undefined;
-
- // 벤더 타입 파싱 개선 - null/undefined 안전 처리
- let types: string[] = [];
- if (!techVendorType) {
- types = [];
- } else if (techVendorType.startsWith('[') && techVendorType.endsWith(']')) {
- // JSON 배열 형태
- try {
- const parsed = JSON.parse(techVendorType);
- types = Array.isArray(parsed) ? parsed.filter(Boolean) : [techVendorType];
- } catch {
- types = [techVendorType];
- }
- } else if (techVendorType.includes(',')) {
- // 콤마로 구분된 문자열
- types = techVendorType.split(',').map(t => t.trim()).filter(Boolean);
- } else {
- // 단일 문자열
- types = [techVendorType.trim()].filter(Boolean);
- }
-
- // 벤더 타입 정렬 - 조선 > 해양top > 해양hull 순
- const typeOrder = ["조선", "해양top", "해양hull"];
- types.sort((a, b) => {
- const indexA = typeOrder.indexOf(a);
- const indexB = typeOrder.indexOf(b);
-
- // 정의된 순서에 있는 경우 우선순위 적용
- if (indexA !== -1 && indexB !== -1) {
- return indexA - indexB;
- }
- return a.localeCompare(b);
- });
-
- return (
- <div className="flex flex-wrap gap-1">
- {types.length > 0 ? types.map((type, index) => (
- <Badge key={`${type}-${index}`} variant="secondary" className="text-xs">
- {type}
- </Badge>
- )) : (
- <span className="text-muted-foreground">-</span>
- )}
- </div>
- );
- }
-
- // 날짜 컬럼 포맷팅
- if (cfg.type === "date" && cell.getValue()) {
- return formatDate(cell.getValue() as Date);
- }
-
- return cell.getValue();
- },
- };
-
- groupMap[groupName].push(childCol);
- });
-
- // 3-2) groupMap -> columns (그룹별 -> 중첩 헤더 ColumnDef[] 배열 변환)
- const columns: ColumnDef<TechVendor>[] = [
- selectColumn, // 1) 체크박스
- ];
-
- // 3-3) 그룹이 있는 컬럼들은 중첩 헤더로, 없는 것들은 그냥 컬럼으로
- Object.entries(groupMap).forEach(([groupName, childColumns]) => {
- if (groupName === "_noGroup") {
- // 그룹이 없는 컬럼들은 그냥 추가
- columns.push(...childColumns);
- } else {
- // 그룹이 있는 컬럼들은 헤더 아래 자식으로 중첩
- columns.push({
- id: groupName,
- header: groupName, // 그룹명을 헤더로
- columns: childColumns, // 그룹에 속한 컬럼들을 자식으로
- });
- }
- });
-
- // Possible Items 컬럼 추가 (액션 컬럼 직전에)
- const possibleItemsColumn: ColumnDef<TechVendor> = {
- id: "possibleItems",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="자재 그룹" />
- ),
- cell: ({ row }) => {
- const vendor = row.original;
-
- const handleClick = () => {
- openItemsDialog(vendor);
- };
-
- return (
- <Button
- variant="ghost"
- size="sm"
- className="relative h-8 w-8 p-0 group"
- onClick={handleClick}
- aria-label="View possible items"
- >
- <Package className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
- <span className="sr-only">
- Possible Items 보기
- </span>
- </Button>
- );
- },
- enableSorting: false,
- enableResizing: false,
- size: 80,
- meta: {
- excelHeader: "Possible Items"
- },
- };
-
- columns.push(possibleItemsColumn);
- columns.push(actionsColumn); // 마지막에 액션 컬럼 추가
-
- return columns;
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Ellipsis, Package } from "lucide-react"
+import { toast } from "sonner"
+
+import { getErrorMessage } from "@/lib/handle-error"
+import { formatDate } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { useRouter } from "next/navigation"
+
+import { TechVendor, techVendors } from "@/db/schema/techVendors"
+import { modifyTechVendor } from "../service"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { techVendorColumnsConfig } from "@/config/techVendorColumnsConfig"
+import { Separator } from "@/components/ui/separator"
+import { getVendorStatusIcon } from "../utils"
+
+// 타입 정의 추가
+type StatusType = (typeof techVendors.status.enumValues)[number];
+type BadgeVariantType = "default" | "secondary" | "destructive" | "outline";
+type StatusConfig = {
+ variant: BadgeVariantType;
+ className: string;
+};
+type StatusDisplayMap = {
+ [key in StatusType]: string;
+};
+
+type NextRouter = ReturnType<typeof useRouter>;
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TechVendor> | null>>;
+ router: NextRouter;
+}
+
+
+
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef<TechVendor>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<TechVendor> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 2) actions 컬럼 (Dropdown 메뉴)
+ // ----------------------------------------------------------------
+ const actionsColumn: ColumnDef<TechVendor> = {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-56">
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "update" })}
+ >
+ 레코드 편집
+ </DropdownMenuItem>
+
+ <DropdownMenuItem
+ onSelect={() => {
+ // 1) 만약 rowAction을 열고 싶다면
+ // setRowAction({ row, type: "update" })
+
+ // 2) 자세히 보기 페이지로 클라이언트 라우팅
+ router.push(`/evcp/tech-vendors/${row.original.id}/info`);
+ }}
+ >
+ 상세보기
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onSelect={() => {
+ // 새창으로 열기 위해 window.open() 사용
+ window.open(`/evcp/tech-vendors/${row.original.id}/info`, '_blank');
+ }}
+ >
+ 상세보기(새창)
+ </DropdownMenuItem>
+
+ <Separator />
+ <DropdownMenuSub>
+ <DropdownMenuSubTrigger>Status</DropdownMenuSubTrigger>
+ <DropdownMenuSubContent>
+ <DropdownMenuRadioGroup
+ value={row.original.status}
+ onValueChange={(value) => {
+ startUpdateTransition(() => {
+ toast.promise(
+ modifyTechVendor({
+ id: String(row.original.id),
+ status: value as TechVendor["status"],
+ vendorName: row.original.vendorName, // Required field from UpdateVendorSchema
+ }),
+ {
+ loading: "Updating...",
+ success: "Label updated",
+ error: (err) => getErrorMessage(err),
+ }
+ )
+ })
+ }}
+ >
+ {techVendors.status.enumValues.map((status) => (
+ <DropdownMenuRadioItem
+ key={status}
+ value={status}
+ className="capitalize"
+ disabled={isUpdatePending}
+ >
+ {status}
+ </DropdownMenuRadioItem>
+ ))}
+ </DropdownMenuRadioGroup>
+ </DropdownMenuSubContent>
+ </DropdownMenuSub>
+
+
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ }
+
+ // ----------------------------------------------------------------
+ // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
+ // ----------------------------------------------------------------
+ // 3-1) groupMap: { [groupName]: ColumnDef<TechVendor>[] }
+ const groupMap: Record<string, ColumnDef<TechVendor>[]> = {}
+
+ techVendorColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<TechVendor> = {
+ accessorKey: cfg.id,
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={cfg.label} />
+ ),
+ meta: {
+ excelHeader: cfg.excelHeader,
+ group: cfg.group,
+ type: cfg.type,
+ },
+ cell: ({ row, cell }) => {
+ // Status 컬럼 렌더링 개선 - 아이콘과 더 선명한 배경색 사용
+ if (cfg.id === "status") {
+ const statusVal = row.original.status;
+ if (!statusVal) return null;
+
+ // Status badge variant mapping - 더 뚜렷한 색상으로 변경
+ const getStatusConfig = (status: StatusType): StatusConfig & { iconColor: string } => {
+ switch (status) {
+ case "ACTIVE":
+ return {
+ variant: "default",
+ className: "bg-emerald-100 text-emerald-800 border-emerald-300 font-semibold",
+ iconColor: "text-emerald-600"
+ };
+ case "INACTIVE":
+ return {
+ variant: "default",
+ className: "bg-gray-100 text-gray-800 border-gray-300",
+ iconColor: "text-gray-600"
+ };
+
+ case "PENDING_INVITE":
+ return {
+ variant: "default",
+ className: "bg-blue-100 text-blue-800 border-blue-300",
+ iconColor: "text-blue-600"
+ };
+ case "INVITED":
+ return {
+ variant: "default",
+ className: "bg-green-100 text-green-800 border-green-300",
+ iconColor: "text-green-600"
+ };
+ case "QUOTE_COMPARISON":
+ return {
+ variant: "default",
+ className: "bg-purple-100 text-purple-800 border-purple-300",
+ iconColor: "text-purple-600"
+ };
+ case "BLACKLISTED":
+ return {
+ variant: "destructive",
+ className: "bg-slate-800 text-white border-slate-900",
+ iconColor: "text-white"
+ };
+ default:
+ return {
+ variant: "default",
+ className: "bg-gray-100 text-gray-800 border-gray-300",
+ iconColor: "text-gray-600"
+ };
+ }
+ };
+
+ // 상태 표시 텍스트
+ const getStatusDisplay = (status: StatusType): string => {
+ const statusMap: StatusDisplayMap = {
+ "ACTIVE": "활성 상태",
+ "INACTIVE": "비활성 상태",
+ "BLACKLISTED": "거래 금지",
+ "PENDING_INVITE": "초대 대기",
+ "INVITED": "초대 완료",
+ "QUOTE_COMPARISON": "견적 비교"
+ };
+
+ return statusMap[status] || status;
+ };
+
+ const statusConfig = getStatusConfig(statusVal);
+ const displayText = getStatusDisplay(statusVal);
+ const StatusIcon = getVendorStatusIcon(statusVal);
+
+ return (
+ <div className="flex items-center gap-2">
+ <Badge
+ variant={statusConfig.variant}
+ className={statusConfig.className}
+ >
+ <StatusIcon className={`mr-1 h-3.5 w-3.5 ${statusConfig.iconColor}`} />
+ {displayText}
+ </Badge>
+ </div>
+ );
+ }
+ // TechVendorType 컬럼을 badge로 표시
+ if (cfg.id === "techVendorType") {
+ const techVendorType = row.original.techVendorType as string | null | undefined;
+
+ // 벤더 타입 파싱 개선 - null/undefined 안전 처리
+ let types: string[] = [];
+ if (!techVendorType) {
+ types = [];
+ } else if (techVendorType.startsWith('[') && techVendorType.endsWith(']')) {
+ // JSON 배열 형태
+ try {
+ const parsed = JSON.parse(techVendorType);
+ types = Array.isArray(parsed) ? parsed.filter(Boolean) : [techVendorType];
+ } catch {
+ types = [techVendorType];
+ }
+ } else if (techVendorType.includes(',')) {
+ // 콤마로 구분된 문자열
+ types = techVendorType.split(',').map(t => t.trim()).filter(Boolean);
+ } else {
+ // 단일 문자열
+ types = [techVendorType.trim()].filter(Boolean);
+ }
+
+ // 벤더 타입 정렬 - 조선 > 해양top > 해양hull 순
+ const typeOrder = ["조선", "해양top", "해양hull"];
+ types.sort((a, b) => {
+ const indexA = typeOrder.indexOf(a);
+ const indexB = typeOrder.indexOf(b);
+
+ // 정의된 순서에 있는 경우 우선순위 적용
+ if (indexA !== -1 && indexB !== -1) {
+ return indexA - indexB;
+ }
+ return a.localeCompare(b);
+ });
+
+ return (
+ <div className="flex flex-wrap gap-1">
+ {types.length > 0 ? types.map((type, index) => (
+ <Badge key={`${type}-${index}`} variant="secondary" className="text-xs">
+ {type}
+ </Badge>
+ )) : (
+ <span className="text-muted-foreground">-</span>
+ )}
+ </div>
+ );
+ }
+
+ // 날짜 컬럼 포맷팅
+ if (cfg.type === "date" && cell.getValue()) {
+ return formatDate(cell.getValue() as Date);
+ }
+
+ return cell.getValue();
+ },
+ };
+
+ groupMap[groupName].push(childCol);
+ });
+
+ // 3-2) groupMap -> columns (그룹별 -> 중첩 헤더 ColumnDef[] 배열 변환)
+ const columns: ColumnDef<TechVendor>[] = [
+ selectColumn, // 1) 체크박스
+ ];
+
+ // 3-3) 그룹이 있는 컬럼들은 중첩 헤더로, 없는 것들은 그냥 컬럼으로
+ Object.entries(groupMap).forEach(([groupName, childColumns]) => {
+ if (groupName === "_noGroup") {
+ // 그룹이 없는 컬럼들은 그냥 추가
+ columns.push(...childColumns);
+ } else {
+ // 그룹이 있는 컬럼들은 헤더 아래 자식으로 중첩
+ columns.push({
+ id: groupName,
+ header: groupName, // 그룹명을 헤더로
+ columns: childColumns, // 그룹에 속한 컬럼들을 자식으로
+ });
+ }
+ });
+
+ columns.push(actionsColumn); // 마지막에 액션 컬럼 추가
+
+ return columns;
} \ No newline at end of file
diff --git a/lib/tech-vendors/table/tech-vendors-table-floating-bar.tsx b/lib/tech-vendors/table/tech-vendors-table-floating-bar.tsx
deleted file mode 100644
index 2cc83105..00000000
--- a/lib/tech-vendors/table/tech-vendors-table-floating-bar.tsx
+++ /dev/null
@@ -1,240 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { SelectTrigger } from "@radix-ui/react-select"
-import { type Table } from "@tanstack/react-table"
-import {
- ArrowUp,
- CheckCircle2,
- Download,
- Loader,
- Trash2,
- X,
-} from "lucide-react"
-import { toast } from "sonner"
-
-import { exportTableToExcel } from "@/lib/export"
-import { Button } from "@/components/ui/button"
-import { Portal } from "@/components/ui/portal"
-import {
- Select,
- SelectContent,
- SelectGroup,
- SelectItem,
-} from "@/components/ui/select"
-import { Separator } from "@/components/ui/separator"
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip"
-import { Kbd } from "@/components/kbd"
-
-import { ActionConfirmDialog } from "@/components/ui/action-dialog"
-import { Vendor, vendors } from "@/db/schema/vendors"
-import { modifyTechVendors } from "../service"
-import { TechVendor } from "@/db/schema"
-
-interface VendorsTableFloatingBarProps {
- table: Table<Vendor>
-}
-
-
-export function VendorsTableFloatingBar({ table }: VendorsTableFloatingBarProps) {
- const rows = table.getFilteredSelectedRowModel().rows
-
- const [isPending, startTransition] = React.useTransition()
- const [action, setAction] = React.useState<
- "update-status" | "export" | "delete"
- >()
- // Clear selection on Escape key press
- React.useEffect(() => {
- function handleKeyDown(event: KeyboardEvent) {
- if (event.key === "Escape") {
- table.toggleAllRowsSelected(false)
- }
- }
-
- window.addEventListener("keydown", handleKeyDown)
- return () => window.removeEventListener("keydown", handleKeyDown)
- }, [table])
-
-
-
- // 공용 confirm dialog state
- const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false)
- const [confirmProps, setConfirmProps] = React.useState<{
- title: string
- description?: string
- onConfirm: () => Promise<void> | void
- }>({
- title: "",
- description: "",
- onConfirm: () => { },
- })
-
-
- // 2)
- function handleSelectStatus(newStatus: Vendor["status"]) {
- setAction("update-status")
-
- setConfirmProps({
- title: `Update ${rows.length} vendor${rows.length > 1 ? "s" : ""} with status: ${newStatus}?`,
- description: "This action will override their current status.",
- onConfirm: async () => {
- startTransition(async () => {
- const { error } = await modifyTechVendors({
- ids: rows.map((row) => String(row.original.id)),
- status: newStatus as TechVendor["status"],
- })
- if (error) {
- toast.error(error)
- return
- }
- toast.success("Vendors updated")
- setConfirmDialogOpen(false)
- })
- },
- })
- setConfirmDialogOpen(true)
- }
-
-
- return (
- <Portal >
- <div className="fixed inset-x-0 bottom-10 z-50 mx-auto w-fit px-2.5" style={{ bottom: '1.5rem' }}>
- <div className="w-full overflow-x-auto">
- <div className="mx-auto flex w-fit items-center gap-2 rounded-md border bg-background p-2 text-foreground shadow">
- <div className="flex h-7 items-center rounded-md border border-dashed pl-2.5 pr-1">
- <span className="whitespace-nowrap text-xs">
- {rows.length} selected
- </span>
- <Separator orientation="vertical" className="ml-2 mr-1" />
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="ghost"
- size="icon"
- className="size-5 hover:border"
- onClick={() => table.toggleAllRowsSelected(false)}
- >
- <X className="size-3.5 shrink-0" aria-hidden="true" />
- </Button>
- </TooltipTrigger>
- <TooltipContent className="flex items-center border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900">
- <p className="mr-2">Clear selection</p>
- <Kbd abbrTitle="Escape" variant="outline">
- Esc
- </Kbd>
- </TooltipContent>
- </Tooltip>
- </div>
- <Separator orientation="vertical" className="hidden h-5 sm:block" />
- <div className="flex items-center gap-1.5">
- <Select
- onValueChange={(value: Vendor["status"]) => {
- handleSelectStatus(value)
- }}
- >
- <Tooltip>
- <SelectTrigger asChild>
- <TooltipTrigger asChild>
- <Button
- variant="secondary"
- size="icon"
- className="size-7 border data-[state=open]:bg-accent data-[state=open]:text-accent-foreground"
- disabled={isPending}
- >
- {isPending && action === "update-status" ? (
- <Loader
- className="size-3.5 animate-spin"
- aria-hidden="true"
- />
- ) : (
- <CheckCircle2
- className="size-3.5"
- aria-hidden="true"
- />
- )}
- </Button>
- </TooltipTrigger>
- </SelectTrigger>
- <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900">
- <p>Update status</p>
- </TooltipContent>
- </Tooltip>
- <SelectContent align="center">
- <SelectGroup>
- {vendors.status.enumValues.map((status) => (
- <SelectItem
- key={status}
- value={status}
- className="capitalize"
- >
- {status}
- </SelectItem>
- ))}
- </SelectGroup>
- </SelectContent>
- </Select>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="secondary"
- size="icon"
- className="size-7 border"
- onClick={() => {
- setAction("export")
-
- startTransition(() => {
- exportTableToExcel(table, {
- excludeColumns: ["select", "actions"],
- onlySelected: true,
- })
- })
- }}
- disabled={isPending}
- >
- {isPending && action === "export" ? (
- <Loader
- className="size-3.5 animate-spin"
- aria-hidden="true"
- />
- ) : (
- <Download className="size-3.5" aria-hidden="true" />
- )}
- </Button>
- </TooltipTrigger>
- <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900">
- <p>Export vendors</p>
- </TooltipContent>
- </Tooltip>
-
- </div>
- </div>
- </div>
- </div>
-
-
- {/* 공용 Confirm Dialog */}
- <ActionConfirmDialog
- open={confirmDialogOpen}
- onOpenChange={setConfirmDialogOpen}
- title={confirmProps.title}
- description={confirmProps.description}
- onConfirm={confirmProps.onConfirm}
- isLoading={isPending && (action === "delete" || action === "update-status")}
- confirmLabel={
- action === "delete"
- ? "Delete"
- : action === "update-status"
- ? "Update"
- : "Confirm"
- }
- confirmVariant={
- action === "delete" ? "destructive" : "default"
- }
- />
- </Portal>
- )
-}
diff --git a/lib/tech-vendors/table/tech-vendors-table-toolbar-actions.tsx b/lib/tech-vendors/table/tech-vendors-table-toolbar-actions.tsx
index ac7ee184..c5380140 100644
--- a/lib/tech-vendors/table/tech-vendors-table-toolbar-actions.tsx
+++ b/lib/tech-vendors/table/tech-vendors-table-toolbar-actions.tsx
@@ -1,197 +1,201 @@
-"use client"
-
-import * as React from "react"
-import { type Table } from "@tanstack/react-table"
-import { Download, FileSpreadsheet, FileText } from "lucide-react"
-import { toast } from "sonner"
-
-import { exportTableToExcel } from "@/lib/export"
-import { Button } from "@/components/ui/button"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-
-import { exportVendorsWithRelatedData } from "./vendor-all-export"
-import { TechVendor } from "@/db/schema/techVendors"
-import { ImportTechVendorButton } from "./import-button"
-import { exportTechVendorTemplate } from "./excel-template-download"
-import { AddVendorDialog } from "./add-vendor-dialog"
-import { InviteTechVendorDialog } from "./invite-tech-vendor-dialog"
-
-interface TechVendorsTableToolbarActionsProps {
- table: Table<TechVendor>
- onRefresh?: () => void
-}
-
-export function TechVendorsTableToolbarActions({ table, onRefresh }: TechVendorsTableToolbarActionsProps) {
- const [isExporting, setIsExporting] = React.useState(false);
-
- // 선택된 모든 벤더 가져오기
- const selectedVendors = React.useMemo(() => {
- return table
- .getFilteredSelectedRowModel()
- .rows
- .map(row => row.original);
- }, [table.getFilteredSelectedRowModel().rows]);
-
- // 초대 가능한 벤더들 (PENDING_INVITE 상태 + 이메일 있음)
- const invitableVendors = React.useMemo(() => {
- return selectedVendors.filter(vendor =>
- vendor.status === "PENDING_INVITE" && vendor.email
- );
- }, [selectedVendors]);
-
- // 테이블의 모든 벤더 가져오기 (필터링된 결과)
- const allFilteredVendors = React.useMemo(() => {
- return table
- .getFilteredRowModel()
- .rows
- .map(row => row.original);
- }, [table.getFilteredRowModel().rows]);
-
- // 선택된 벤더 통합 내보내기 함수 실행
- const handleSelectedExport = async () => {
- if (selectedVendors.length === 0) {
- toast.warning("내보낼 협력업체를 선택해주세요.");
- return;
- }
-
- try {
- setIsExporting(true);
- toast.info(`선택된 ${selectedVendors.length}개 업체의 정보를 내보내는 중입니다...`);
- await exportVendorsWithRelatedData(selectedVendors, "selected-vendors-detailed");
- toast.success(`${selectedVendors.length}개 업체 정보 내보내기가 완료되었습니다.`);
- } catch (error) {
- console.error("상세 정보 내보내기 오류:", error);
- toast.error("상세 정보 내보내기 중 오류가 발생했습니다.");
- } finally {
- setIsExporting(false);
- }
- };
-
- // 모든 벤더 통합 내보내기 함수 실행
- const handleAllFilteredExport = async () => {
- if (allFilteredVendors.length === 0) {
- toast.warning("내보낼 협력업체가 없습니다.");
- return;
- }
-
- try {
- setIsExporting(true);
- toast.info(`총 ${allFilteredVendors.length}개 업체의 정보를 내보내는 중입니다...`);
- await exportVendorsWithRelatedData(allFilteredVendors, "all-vendors-detailed");
- toast.success(`${allFilteredVendors.length}개 업체 정보 내보내기가 완료되었습니다.`);
- } catch (error) {
- console.error("상세 정보 내보내기 오류:", error);
- toast.error("상세 정보 내보내기 중 오류가 발생했습니다.");
- } finally {
- setIsExporting(false);
- }
- };
-
- // 벤더 추가 성공 시 테이블 새로고침을 위한 핸들러
- const handleVendorAddSuccess = () => {
- // 테이블 데이터 리프레시
- if (onRefresh) {
- onRefresh();
- } else {
- window.location.reload(); // 간단한 새로고침 방법
- }
- };
-
- return (
- <div className="flex items-center gap-2">
- {/* 초대 버튼 - 선택된 PENDING_REVIEW 벤더들이 있을 때만 표시 */}
- {invitableVendors.length > 0 && (
- <InviteTechVendorDialog
- vendors={invitableVendors}
- onSuccess={handleVendorAddSuccess}
- />
- )}
-
- {/* 벤더 추가 다이얼로그 추가 */}
- <AddVendorDialog onSuccess={handleVendorAddSuccess} />
-
- {/* Import 버튼 추가 */}
- <ImportTechVendorButton
- onSuccess={() => {
- // 성공 시 테이블 새로고침
- toast.success("업체 정보 가져오기가 완료되었습니다.");
- }}
- />
-
- {/* Export 드롭다운 메뉴로 변경 */}
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- variant="outline"
- size="sm"
- className="gap-2"
- disabled={isExporting}
- >
- <Download className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">
- {isExporting ? "내보내는 중..." : "Export"}
- </span>
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end">
- {/* 템플릿 다운로드 추가 */}
- <DropdownMenuItem
- onClick={() => exportTechVendorTemplate()}
- disabled={isExporting}
- >
- <FileText className="mr-2 size-4" />
- <span>Excel 템플릿 다운로드</span>
- </DropdownMenuItem>
-
- <DropdownMenuSeparator />
-
- {/* 기본 내보내기 - 현재 테이블에 보이는 데이터만 */}
- <DropdownMenuItem
- onClick={() =>
- exportTableToExcel(table, {
- filename: "vendors",
- excludeColumns: ["select", "actions"],
- })
- }
- disabled={isExporting}
- >
- <FileText className="mr-2 size-4" />
- <span>현재 테이블 데이터 내보내기</span>
- </DropdownMenuItem>
-
- <DropdownMenuSeparator />
-
- {/* 선택된 벤더만 상세 내보내기 */}
- <DropdownMenuItem
- onClick={handleSelectedExport}
- disabled={selectedVendors.length === 0 || isExporting}
- >
- <FileSpreadsheet className="mr-2 size-4" />
- <span>선택한 업체 상세 정보 내보내기</span>
- {selectedVendors.length > 0 && (
- <span className="ml-1 text-xs text-muted-foreground">({selectedVendors.length}개)</span>
- )}
- </DropdownMenuItem>
-
- {/* 모든 필터링된 벤더 상세 내보내기 */}
- <DropdownMenuItem
- onClick={handleAllFilteredExport}
- disabled={allFilteredVendors.length === 0 || isExporting}
- >
- <Download className="mr-2 size-4" />
- <span>모든 업체 상세 정보 내보내기</span>
- {allFilteredVendors.length > 0 && (
- <span className="ml-1 text-xs text-muted-foreground">({allFilteredVendors.length}개)</span>
- )}
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- </div>
- )
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, FileSpreadsheet, FileText } from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
+
+// import { exportVendorsWithRelatedData } from "./vendor-all-export"
+import { TechVendor } from "@/db/schema/techVendors"
+import { ImportTechVendorButton } from "./import-button"
+import { exportTechVendorTemplate } from "./excel-template-download"
+import { AddVendorDialog } from "./add-vendor-dialog"
+import { InviteTechVendorDialog } from "./invite-tech-vendor-dialog"
+
+interface TechVendorsTableToolbarActionsProps {
+ table: Table<TechVendor>
+ onRefresh?: () => void
+}
+
+export function TechVendorsTableToolbarActions({
+ table,
+ onRefresh
+}: TechVendorsTableToolbarActionsProps) {
+ const [isExporting, setIsExporting] = React.useState(false);
+
+ // 선택된 모든 벤더 가져오기
+ const selectedVendors = React.useMemo(() => {
+ return table
+ .getFilteredSelectedRowModel()
+ .rows
+ .map(row => row.original);
+ }, [table.getFilteredSelectedRowModel().rows]);
+
+ // 초대 가능한 벤더들 (PENDING_INVITE 상태 + 이메일 있음)
+ const invitableVendors = React.useMemo(() => {
+ return selectedVendors.filter(vendor =>
+ vendor.status === "PENDING_INVITE" && vendor.email
+ );
+ }, [selectedVendors]);
+
+ // // 테이블의 모든 벤더 가져오기 (필터링된 결과)
+ // const allFilteredVendors = React.useMemo(() => {
+ // return table
+ // .getFilteredRowModel()
+ // .rows
+ // .map(row => row.original);
+ // }, [table.getFilteredRowModel().rows]);
+
+ // // 선택된 벤더 통합 내보내기 함수 실행
+ // const handleSelectedExport = async () => {
+ // if (selectedVendors.length === 0) {
+ // toast.warning("내보낼 협력업체를 선택해주세요.");
+ // return;
+ // }
+
+ // try {
+ // setIsExporting(true);
+ // toast.info(`선택된 ${selectedVendors.length}개 업체의 정보를 내보내는 중입니다...`);
+ // await exportVendorsWithRelatedData(selectedVendors, "selected-vendors-detailed");
+ // toast.success(`${selectedVendors.length}개 업체 정보 내보내기가 완료되었습니다.`);
+ // } catch (error) {
+ // console.error("상세 정보 내보내기 오류:", error);
+ // toast.error("상세 정보 내보내기 중 오류가 발생했습니다.");
+ // } finally {
+ // setIsExporting(false);
+ // }
+ // };
+
+ // // 모든 벤더 통합 내보내기 함수 실행
+ // const handleAllFilteredExport = async () => {
+ // if (allFilteredVendors.length === 0) {
+ // toast.warning("내보낼 협력업체가 없습니다.");
+ // return;
+ // }
+
+ // try {
+ // setIsExporting(true);
+ // toast.info(`총 ${allFilteredVendors.length}개 업체의 정보를 내보내는 중입니다...`);
+ // await exportVendorsWithRelatedData(allFilteredVendors, "all-vendors-detailed");
+ // toast.success(`${allFilteredVendors.length}개 업체 정보 내보내기가 완료되었습니다.`);
+ // } catch (error) {
+ // console.error("상세 정보 내보내기 오류:", error);
+ // toast.error("상세 정보 내보내기 중 오류가 발생했습니다.");
+ // } finally {
+ // setIsExporting(false);
+ // }
+ // };
+
+ // 벤더 추가 성공 시 테이블 새로고침을 위한 핸들러
+ const handleVendorAddSuccess = () => {
+ // 테이블 데이터 리프레시
+ if (onRefresh) {
+ onRefresh();
+ } else {
+ window.location.reload(); // 간단한 새로고침 방법
+ }
+ };
+
+ return (
+ <div className="flex items-center gap-2">
+ {/* 초대 버튼 - 선택된 PENDING_REVIEW 벤더들이 있을 때만 표시 */}
+ {invitableVendors.length > 0 && (
+ <InviteTechVendorDialog
+ vendors={invitableVendors}
+ onSuccess={handleVendorAddSuccess}
+ />
+ )}
+
+ {/* 벤더 추가 다이얼로그 추가 */}
+ <AddVendorDialog onSuccess={handleVendorAddSuccess} />
+
+ {/* Import 버튼 추가 */}
+ <ImportTechVendorButton
+ onSuccess={() => {
+ // 성공 시 테이블 새로고침
+ toast.success("업체 정보 가져오기가 완료되었습니다.");
+ }}
+ />
+
+ {/* Export 드롭다운 메뉴로 변경 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ className="gap-2"
+ disabled={isExporting}
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">
+ {isExporting ? "내보내는 중..." : "Export"}
+ </span>
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ {/* 템플릿 다운로드 추가 */}
+ <DropdownMenuItem
+ onClick={() => exportTechVendorTemplate()}
+ disabled={isExporting}
+ >
+ <FileText className="mr-2 size-4" />
+ <span>Excel 템플릿 다운로드</span>
+ </DropdownMenuItem>
+
+ <DropdownMenuSeparator />
+
+ {/* 기본 내보내기 - 현재 테이블에 보이는 데이터만 */}
+ <DropdownMenuItem
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "vendors",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ disabled={isExporting}
+ >
+ <FileText className="mr-2 size-4" />
+ <span>현재 테이블 데이터 내보내기</span>
+ </DropdownMenuItem>
+
+ <DropdownMenuSeparator />
+
+ {/* 선택된 벤더만 상세 내보내기 */}
+ {/* <DropdownMenuItem
+ onClick={handleSelectedExport}
+ disabled={selectedVendors.length === 0 || isExporting}
+ >
+ <FileSpreadsheet className="mr-2 size-4" />
+ <span>선택한 업체 상세 정보 내보내기</span>
+ {selectedVendors.length > 0 && (
+ <span className="ml-1 text-xs text-muted-foreground">({selectedVendors.length}개)</span>
+ )}
+ </DropdownMenuItem> */}
+
+ {/* 모든 필터링된 벤더 상세 내보내기 */}
+ {/* <DropdownMenuItem
+ onClick={handleAllFilteredExport}
+ disabled={allFilteredVendors.length === 0 || isExporting}
+ >
+ <Download className="mr-2 size-4" />
+ <span>모든 업체 상세 정보 내보내기</span>
+ {allFilteredVendors.length > 0 && (
+ <span className="ml-1 text-xs text-muted-foreground">({allFilteredVendors.length}개)</span>
+ )}
+ </DropdownMenuItem> */}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+ )
} \ No newline at end of file
diff --git a/lib/tech-vendors/table/tech-vendors-table.tsx b/lib/tech-vendors/table/tech-vendors-table.tsx
index a8e18501..7f9625cf 100644
--- a/lib/tech-vendors/table/tech-vendors-table.tsx
+++ b/lib/tech-vendors/table/tech-vendors-table.tsx
@@ -1,195 +1,277 @@
-"use client"
-
-import * as React from "react"
-import { useRouter } from "next/navigation"
-import type {
- DataTableAdvancedFilterField,
- DataTableFilterField,
- DataTableRowAction,
-} from "@/types/table"
-
-import { useDataTable } from "@/hooks/use-data-table"
-import { DataTable } from "@/components/data-table/data-table"
-import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
-import { getColumns } from "./tech-vendors-table-columns"
-import { getTechVendors, getTechVendorStatusCounts } from "../service"
-import { TechVendor, techVendors, TechVendorWithAttachments } from "@/db/schema/techVendors"
-import { TechVendorsTableToolbarActions } from "./tech-vendors-table-toolbar-actions"
-import { UpdateVendorSheet } from "./update-vendor-sheet"
-import { getVendorStatusIcon } from "../utils"
-import { TechVendorPossibleItemsViewDialog } from "./tech-vendor-possible-items-view-dialog"
-// import { ViewTechVendorLogsDialog } from "./view-tech-vendors-logs-dialog"
-
-interface TechVendorsTableProps {
- promises: Promise<
- [
- Awaited<ReturnType<typeof getTechVendors>>,
- Awaited<ReturnType<typeof getTechVendorStatusCounts>>
- ]
- >
-}
-
-export function TechVendorsTable({ promises }: TechVendorsTableProps) {
- // Suspense로 받아온 데이터
- const [{ data, pageCount }, statusCounts] = React.use(promises)
- const [isCompact, setIsCompact] = React.useState<boolean>(false)
-
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<TechVendor> | null>(null)
- const [itemsDialogOpen, setItemsDialogOpen] = React.useState(false)
- const [selectedVendorForItems, setSelectedVendorForItems] = React.useState<TechVendor | null>(null)
-
- // **router** 획득
- const router = useRouter()
-
- // openItemsDialog 함수 정의
- const openItemsDialog = React.useCallback((vendor: TechVendor) => {
- setSelectedVendorForItems(vendor)
- setItemsDialogOpen(true)
- }, [])
-
- // getColumns() 호출 시, router와 openItemsDialog를 주입
- const columns = React.useMemo(
- () => getColumns({ setRowAction, router, openItemsDialog }),
- [setRowAction, router, openItemsDialog]
- )
-
- // 상태 한글 변환 유틸리티 함수
- const getStatusDisplay = (status: string): string => {
- const statusMap: Record<string, string> = {
- "ACTIVE": "활성 상태",
- "INACTIVE": "비활성 상태",
- "BLACKLISTED": "거래 금지",
- "PENDING_INVITE": "초대 대기",
- "INVITED": "초대 완료",
- "QUOTE_COMPARISON": "견적 비교",
- };
-
- return statusMap[status] || status;
- };
-
- const filterFields: DataTableFilterField<TechVendorWithAttachments>[] = [
- {
- id: "status",
- label: "상태",
- options: techVendors.status.enumValues.map((status) => ({
- label: getStatusDisplay(status),
- value: status,
- count: statusCounts[status],
- })),
- },
-
- { id: "vendorCode", label: "업체 코드" },
- ]
-
- const advancedFilterFields: DataTableAdvancedFilterField<TechVendorWithAttachments>[] = [
- { id: "vendorName", label: "업체명", type: "text" },
- { id: "vendorCode", label: "업체코드", type: "text" },
- { id: "email", label: "이메일", type: "text" },
- { id: "country", label: "국가", type: "text" },
- {
- id: "status",
- label: "업체승인상태",
- type: "multi-select",
- options: techVendors.status.enumValues.map((status) => ({
- label: getStatusDisplay(status),
- value: status,
- count: statusCounts[status],
- icon: getVendorStatusIcon(status),
- })),
- },
- {
- id: "techVendorType",
- label: "벤더 타입",
- type: "multi-select",
- options: [
- { label: "조선", value: "조선" },
- { label: "해양TOP", value: "해양TOP" },
- { label: "해양HULL", value: "해양HULL" },
- ],
- },
- {
- id: "workTypes",
- label: "Work Type",
- type: "multi-select",
- options: [
- // 조선 workTypes
- { label: "기장", value: "기장" },
- { label: "전장", value: "전장" },
- { label: "선실", value: "선실" },
- { label: "배관", value: "배관" },
- { label: "철의", value: "철의" },
- // 해양TOP workTypes
- { label: "TM", value: "TM" },
- { label: "TS", value: "TS" },
- { label: "TE", value: "TE" },
- { label: "TP", value: "TP" },
- // 해양HULL workTypes
- { label: "HA", value: "HA" },
- { label: "HE", value: "HE" },
- { label: "HH", value: "HH" },
- { label: "HM", value: "HM" },
- { label: "NC", value: "NC" },
- ],
- },
- { id: "createdAt", label: "등록일", type: "date" },
- { id: "updatedAt", label: "수정일", type: "date" },
- ]
-
- const { table } = useDataTable({
- data,
- columns,
- pageCount,
- filterFields,
- enablePinning: true,
- enableAdvancedFilter: true,
- initialState: {
- sorting: [{ id: "createdAt", desc: true }],
- columnPinning: { right: ["actions", "possibleItems"] },
- },
- getRowId: (originalRow) => String(originalRow.id),
- shallow: false,
- clearOnDefault: true,
- })
-
- const handleCompactChange = React.useCallback((compact: boolean) => {
- setIsCompact(compact)
- }, [])
-
- // 테이블 새로고침 핸들러
- const handleRefresh = React.useCallback(() => {
- router.refresh()
- }, [router])
-
-
- return (
- <>
- <DataTable
- table={table}
- compact={isCompact}
- // floatingBar={<TechVendorsTableFloatingBar table={table} />}
- >
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
- enableCompactToggle={true}
- compactStorageKey="techVendorsTableCompact"
- onCompactChange={handleCompactChange}
- >
- <TechVendorsTableToolbarActions table={table} onRefresh={handleRefresh} />
- </DataTableAdvancedToolbar>
- </DataTable>
- <UpdateVendorSheet
- open={rowAction?.type === "update"}
- onOpenChange={() => setRowAction(null)}
- vendor={rowAction?.row.original ?? null}
- />
- <TechVendorPossibleItemsViewDialog
- open={itemsDialogOpen}
- onOpenChange={setItemsDialogOpen}
- vendor={selectedVendorForItems}
- />
-
- </>
- )
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+import { cn } from "@/lib/utils"
+import { PanelLeftClose, PanelLeftOpen } from "lucide-react"
+
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { Button } from "@/components/ui/button"
+import { getColumns } from "./tech-vendors-table-columns"
+import { getTechVendors, getTechVendorStatusCounts } from "../service"
+import { TechVendor, techVendors, TechVendorWithAttachments } from "@/db/schema/techVendors"
+import { TechVendorsTableToolbarActions } from "./tech-vendors-table-toolbar-actions"
+import { UpdateVendorSheet } from "./update-vendor-sheet"
+import { getVendorStatusIcon } from "../utils"
+import { TechVendorsFilterSheet } from "./tech-vendors-filter-sheet"
+// import { ViewTechVendorLogsDialog } from "./view-tech-vendors-logs-dialog"
+
+// 필터 패널 관련 상수
+const FILTER_PANEL_WIDTH = 400;
+const LAYOUT_HEADER_HEIGHT = 60;
+const LOCAL_HEADER_HEIGHT = 60;
+const FIXED_FILTER_HEIGHT = "calc(100vh - 120px)";
+
+interface TechVendorsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getTechVendors>>,
+ Awaited<ReturnType<typeof getTechVendorStatusCounts>>
+ ]
+ >
+ className?: string;
+ calculatedHeight?: string;
+}
+
+export function TechVendorsTable({
+ promises,
+ className,
+ calculatedHeight
+}: TechVendorsTableProps) {
+ // Suspense로 받아온 데이터
+ const [{ data, pageCount }, statusCounts] = React.use(promises)
+ const [isCompact, setIsCompact] = React.useState<boolean>(false)
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<TechVendor> | null>(null)
+
+ // 필터 패널 상태
+ const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false)
+
+ // **router** 획득
+ const router = useRouter()
+
+ // getColumns() 호출 시, router를 주입
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction, router }),
+ [setRowAction, router]
+ )
+
+ // 상태 한글 변환 유틸리티 함수
+ const getStatusDisplay = (status: string): string => {
+ const statusMap: Record<string, string> = {
+ "ACTIVE": "활성 상태",
+ "INACTIVE": "비활성 상태",
+ "BLACKLISTED": "거래 금지",
+ "PENDING_INVITE": "초대 대기",
+ "INVITED": "초대 완료",
+ "QUOTE_COMPARISON": "견적 비교",
+ };
+
+ return statusMap[status] || status;
+ };
+
+ const filterFields: DataTableFilterField<TechVendorWithAttachments>[] = [
+ {
+ id: "status",
+ label: "상태",
+ options: techVendors.status.enumValues.map((status) => ({
+ label: getStatusDisplay(status),
+ value: status,
+ count: statusCounts[status],
+ })),
+ },
+
+ { id: "vendorCode", label: "업체 코드" },
+ ]
+
+ const advancedFilterFields: DataTableAdvancedFilterField<TechVendorWithAttachments>[] = [
+ { id: "vendorName", label: "업체명", type: "text" },
+ { id: "vendorCode", label: "업체코드", type: "text" },
+ { id: "email", label: "이메일", type: "text" },
+ { id: "country", label: "국가", type: "text" },
+ {
+ id: "status",
+ label: "업체승인상태",
+ type: "multi-select",
+ options: techVendors.status.enumValues.map((status) => ({
+ label: getStatusDisplay(status),
+ value: status,
+ count: statusCounts[status],
+ icon: getVendorStatusIcon(status),
+ })),
+ },
+ {
+ id: "techVendorType",
+ label: "벤더 타입",
+ type: "multi-select",
+ options: [
+ { label: "조선", value: "조선" },
+ { label: "해양TOP", value: "해양TOP" },
+ { label: "해양HULL", value: "해양HULL" },
+ ],
+ },
+ {
+ id: "workTypes",
+ label: "Work Type",
+ type: "multi-select",
+ options: [
+ // 조선 workTypes
+ { label: "기장", value: "기장" },
+ { label: "전장", value: "전장" },
+ { label: "선실", value: "선실" },
+ { label: "배관", value: "배관" },
+ { label: "철의", value: "철의" },
+ { label: "선체", value: "선체" },
+ // 해양TOP workTypes
+ { label: "TM", value: "TM" },
+ { label: "TS", value: "TS" },
+ { label: "TE", value: "TE" },
+ { label: "TP", value: "TP" },
+ // 해양HULL workTypes
+ { label: "HA", value: "HA" },
+ { label: "HE", value: "HE" },
+ { label: "HH", value: "HH" },
+ { label: "HM", value: "HM" },
+ { label: "NC", value: "NC" },
+ { label: "HP", value: "HP" },
+ { label: "HO", value: "HO" },
+ ],
+ },
+ { id: "createdAt", label: "등록일", type: "date" },
+ { id: "updatedAt", label: "수정일", type: "date" },
+ ]
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions", "possibleItems"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ const handleCompactChange = React.useCallback((compact: boolean) => {
+ setIsCompact(compact)
+ }, [])
+
+ // 테이블 새로고침 핸들러
+ const handleRefresh = React.useCallback(() => {
+ router.refresh()
+ }, [router])
+
+ // 필터 패널 검색 핸들러
+ const handleSearch = React.useCallback(() => {
+ router.refresh()
+ }, [router])
+
+ return (
+ <div
+ className={cn("flex flex-col relative", className)}
+ style={{ height: calculatedHeight }}
+ >
+ {/* Filter Panel */}
+ <div
+ className={cn(
+ "fixed left-0 bg-background border-r z-30 flex flex-col transition-all duration-300 ease-in-out overflow-hidden",
+ isFilterPanelOpen ? "border-r shadow-lg" : "border-r-0"
+ )}
+ style={{
+ width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px',
+ top: `${LAYOUT_HEADER_HEIGHT*2}px`,
+ height: FIXED_FILTER_HEIGHT
+ }}
+ >
+ {/* Filter Content */}
+ <div className="h-full">
+ <TechVendorsFilterSheet
+ isOpen={isFilterPanelOpen}
+ onClose={() => setIsFilterPanelOpen(false)}
+ onSearch={handleSearch}
+ isLoading={false}
+ />
+ </div>
+ </div>
+
+ {/* Main Content */}
+ <div
+ className="flex flex-col transition-all duration-300 ease-in-out"
+ style={{
+ width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : '100%',
+ marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px',
+ height: '100%'
+ }}
+ >
+ {/* Header Bar - 고정 높이 */}
+ <div
+ className="flex items-center justify-between p-4 bg-background border-b"
+ style={{
+ height: `${LOCAL_HEADER_HEIGHT}px`,
+ flexShrink: 0
+ }}
+ >
+ <div className="flex items-center gap-3">
+ <Button
+ variant="outline"
+ size="sm"
+ type='button'
+ onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)}
+ className="flex items-center shadow-sm"
+ >
+ {isFilterPanelOpen ? <PanelLeftClose className="size-4"/> : <PanelLeftOpen className="size-4"/>}
+ </Button>
+ </div>
+
+ {/* Right side info
+ <div className="text-sm text-muted-foreground">
+ {data && (
+ <span>총 {data.length || 0}건</span>
+ )}
+ </div> */}
+ </div>
+
+ {/* DataTable */}
+ <div className="flex-1 overflow-hidden">
+ <DataTable
+ table={table}
+ compact={isCompact}
+ // floatingBar={<TechVendorsTableFloatingBar table={table} />}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ enableCompactToggle={true}
+ compactStorageKey="techVendorsTableCompact"
+ onCompactChange={handleCompactChange}
+ >
+ <TechVendorsTableToolbarActions
+ table={table}
+ onRefresh={handleRefresh}
+ />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </div>
+ </div>
+
+ <UpdateVendorSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ vendor={rowAction?.row.original ?? null}
+ />
+ </div>
+ )
} \ No newline at end of file
diff --git a/lib/tech-vendors/table/update-vendor-sheet.tsx b/lib/tech-vendors/table/update-vendor-sheet.tsx
index 1d05b0c4..8498df51 100644
--- a/lib/tech-vendors/table/update-vendor-sheet.tsx
+++ b/lib/tech-vendors/table/update-vendor-sheet.tsx
@@ -1,413 +1,624 @@
-"use client"
-
-import * as React from "react"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { useForm } from "react-hook-form"
-import {
- Loader,
- Activity,
- AlertCircle,
- AlertTriangle,
- Circle as CircleIcon,
- Building,
-} from "lucide-react"
-import { toast } from "sonner"
-
-import { Button } from "@/components/ui/button"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
- FormDescription
-} from "@/components/ui/form"
-import { Input } from "@/components/ui/input"
-import {
- Sheet,
- SheetClose,
- SheetContent,
- SheetDescription,
- SheetFooter,
- SheetHeader,
- SheetTitle,
-} from "@/components/ui/sheet"
-import {
- Select,
- SelectContent,
- SelectGroup,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import { useSession } from "next-auth/react" // Import useSession
-
-import { TechVendor, techVendors } from "@/db/schema/techVendors"
-import { updateTechVendorSchema, type UpdateTechVendorSchema } from "../validations"
-import { modifyTechVendor } from "../service"
-
-interface UpdateVendorSheetProps
- extends React.ComponentPropsWithRef<typeof Sheet> {
- vendor: TechVendor | null
-}
-type StatusType = (typeof techVendors.status.enumValues)[number];
-
-type StatusConfig = {
- Icon: React.ElementType;
- className: string;
- label: string;
-};
-
-// 상태 표시 유틸리티 함수
-const getStatusConfig = (status: StatusType): StatusConfig => {
- switch(status) {
- case "ACTIVE":
- return {
- Icon: Activity,
- className: "text-emerald-600",
- label: "활성 상태"
- };
- case "INACTIVE":
- return {
- Icon: AlertCircle,
- className: "text-gray-600",
- label: "비활성 상태"
- };
- case "BLACKLISTED":
- return {
- Icon: AlertTriangle,
- className: "text-slate-800",
- label: "거래 금지"
- };
- case "PENDING_REVIEW":
- return {
- Icon: AlertTriangle,
- className: "text-slate-800",
- label: "비교 견적"
- };
- default:
- return {
- Icon: CircleIcon,
- className: "text-gray-600",
- label: status
- };
- }
-};
-
-
-// 폼 컴포넌트
-export function UpdateVendorSheet({ vendor, ...props }: UpdateVendorSheetProps) {
- const [isPending, startTransition] = React.useTransition()
- const { data: session } = useSession()
- // 폼 정의 - UpdateVendorSchema 타입을 직접 사용
- const form = useForm<UpdateTechVendorSchema>({
- resolver: zodResolver(updateTechVendorSchema),
- defaultValues: {
- // 업체 기본 정보
- vendorName: vendor?.vendorName ?? "",
- vendorCode: vendor?.vendorCode ?? "",
- address: vendor?.address ?? "",
- country: vendor?.country ?? "",
- phone: vendor?.phone ?? "",
- email: vendor?.email ?? "",
- website: vendor?.website ?? "",
- techVendorType: vendor?.techVendorType ? vendor.techVendorType.split(',').map(s => s.trim()).filter(Boolean) as ("조선" | "해양TOP" | "해양HULL")[] : [],
- status: vendor?.status ?? "ACTIVE",
- },
- })
-
- React.useEffect(() => {
- if (vendor) {
- form.reset({
- vendorName: vendor?.vendorName ?? "",
- vendorCode: vendor?.vendorCode ?? "",
- address: vendor?.address ?? "",
- country: vendor?.country ?? "",
- phone: vendor?.phone ?? "",
- email: vendor?.email ?? "",
- website: vendor?.website ?? "",
- techVendorType: vendor?.techVendorType ? vendor.techVendorType.split(',').map(s => s.trim()).filter(Boolean) as ("조선" | "해양TOP" | "해양HULL")[] : [],
- status: vendor?.status ?? "ACTIVE",
-
- });
- }
- }, [vendor, form]);
-
-
- // 제출 핸들러
- async function onSubmit(data: UpdateTechVendorSchema) {
- if (!vendor) return
-
- if (!session?.user?.id) {
- toast.error("사용자 인증 정보를 찾을 수 없습니다.")
- return
- }
- startTransition(async () => {
- try {
- // Add status change comment if status has changed
- const oldStatus = vendor.status ?? "ACTIVE" // Default to ACTIVE if undefined
- const newStatus = data.status ?? "ACTIVE" // Default to ACTIVE if undefined
-
- const statusComment =
- oldStatus !== newStatus
- ? `상태 변경: ${getStatusConfig(oldStatus).label} → ${getStatusConfig(newStatus).label}`
- : "" // Empty string instead of undefined
-
- // 업체 정보 업데이트 - userId와 상태 변경 코멘트 추가
- const { error } = await modifyTechVendor({
- id: String(vendor.id),
- userId: Number(session.user.id), // Add user ID from session
- comment: statusComment, // Add comment for status changes
- ...data, // 모든 데이터 전달 - 서비스 함수에서 필요한 필드만 처리
- techVendorType: Array.isArray(data.techVendorType) ? data.techVendorType.join(',') : undefined,
- })
-
- if (error) throw new Error(error)
-
- toast.success("업체 정보가 업데이트되었습니다!")
- form.reset()
- props.onOpenChange?.(false)
- } catch (err: unknown) {
- toast.error(String(err))
- }
- })
-}
-
- return (
- <Sheet {...props}>
- <SheetContent className="flex flex-col gap-6 sm:max-w-lg overflow-y-auto">
- <SheetHeader className="text-left">
- <SheetTitle>업체 정보 수정</SheetTitle>
- <SheetDescription>
- 업체 세부 정보를 수정하고 변경 사항을 저장하세요
- </SheetDescription>
- </SheetHeader>
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
- {/* 업체 기본 정보 섹션 */}
- <div className="space-y-4">
- <div className="flex items-center">
- <Building className="mr-2 h-5 w-5 text-muted-foreground" />
- <h3 className="text-sm font-medium">업체 기본 정보</h3>
- </div>
- <FormDescription>
- 업체가 제공한 기본 정보입니다. 필요시 수정하세요.
- </FormDescription>
- <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
- {/* vendorName */}
- <FormField
- control={form.control}
- name="vendorName"
- render={({ field }) => (
- <FormItem>
- <FormLabel>업체명</FormLabel>
- <FormControl>
- <Input placeholder="업체명 입력" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* vendorCode */}
- <FormField
- control={form.control}
- name="vendorCode"
- render={({ field }) => (
- <FormItem>
- <FormLabel>업체 코드</FormLabel>
- <FormControl>
- <Input placeholder="예: ABC123" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* address */}
- <FormField
- control={form.control}
- name="address"
- render={({ field }) => (
- <FormItem className="md:col-span-2">
- <FormLabel>주소</FormLabel>
- <FormControl>
- <Input placeholder="주소 입력" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* country */}
- <FormField
- control={form.control}
- name="country"
- render={({ field }) => (
- <FormItem>
- <FormLabel>국가</FormLabel>
- <FormControl>
- <Input placeholder="예: 대한민국" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* phone */}
- <FormField
- control={form.control}
- name="phone"
- render={({ field }) => (
- <FormItem>
- <FormLabel>전화번호</FormLabel>
- <FormControl>
- <Input placeholder="예: 010-1234-5678" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* email */}
- <FormField
- control={form.control}
- name="email"
- render={({ field }) => (
- <FormItem>
- <FormLabel>이메일</FormLabel>
- <FormControl>
- <Input placeholder="예: info@company.com" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* website */}
- <FormField
- control={form.control}
- name="website"
- render={({ field }) => (
- <FormItem>
- <FormLabel>웹사이트</FormLabel>
- <FormControl>
- <Input placeholder="예: https://www.company.com" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* techVendorType */}
- <FormField
- control={form.control}
- name="techVendorType"
- render={({ field }) => (
- <FormItem className="md:col-span-2">
- <FormLabel>벤더 타입 *</FormLabel>
- <div className="space-y-2">
- {["조선", "해양TOP", "해양HULL"].map((type) => (
- <div key={type} className="flex items-center space-x-2">
- <input
- type="checkbox"
- id={`update-${type}`}
- checked={field.value?.includes(type as "조선" | "해양TOP" | "해양HULL")}
- onChange={(e) => {
- const currentValue = Array.isArray(field.value) ? field.value : [];
- if (e.target.checked) {
- field.onChange([...currentValue, type]);
- } else {
- field.onChange(currentValue.filter((v: string) => v !== type));
- }
- }}
- className="w-4 h-4"
- />
- <label htmlFor={`update-${type}`} className="text-sm font-medium cursor-pointer">
- {type}
- </label>
- </div>
- ))}
- </div>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* status with icons */}
- <FormField
- control={form.control}
- name="status"
- render={({ field }) => {
- // 현재 선택된 상태의 구성 정보 가져오기
- const selectedConfig = getStatusConfig(field.value ?? "ACTIVE");
- const SelectedIcon = selectedConfig?.Icon || CircleIcon;
-
- return (
- <FormItem>
- <FormLabel>업체승인상태</FormLabel>
- <FormControl>
- <Select
- value={field.value || ""}
- onValueChange={field.onChange}
- >
- <SelectTrigger className="w-full">
- <SelectValue>
- {field.value && (
- <div className="flex items-center">
- <SelectedIcon className={`mr-2 h-4 w-4 ${selectedConfig.className}`} />
- <span>{selectedConfig.label}</span>
- </div>
- )}
- </SelectValue>
- </SelectTrigger>
- <SelectContent>
- <SelectGroup>
- {techVendors.status.enumValues.map((status) => {
- const config = getStatusConfig(status);
- const StatusIcon = config.Icon;
- return (
- <SelectItem key={status} value={status}>
- <div className="flex items-center">
- <StatusIcon className={`mr-2 h-4 w-4 ${config.className}`} />
- <span>{config.label}</span>
- </div>
- </SelectItem>
- );
- })}
- </SelectGroup>
- </SelectContent>
- </Select>
- </FormControl>
- <FormMessage />
- </FormItem>
- );
- }}
- />
-
-
-
-
- </div>
- </div>
-
- <SheetFooter className="gap-2 pt-2 sm:space-x-0">
- <SheetClose asChild>
- <Button type="button" variant="outline">
- 취소
- </Button>
- </SheetClose>
- <Button disabled={isPending}>
- {isPending && (
- <Loader className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
- )}
- 저장
- </Button>
- </SheetFooter>
- </form>
- </Form>
- </SheetContent>
- </Sheet>
- )
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useForm } from "react-hook-form"
+import {
+ Loader,
+ Activity,
+ AlertCircle,
+ AlertTriangle,
+ Circle as CircleIcon
+} from "lucide-react"
+import { toast } from "sonner"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { useSession } from "next-auth/react"
+
+import { TechVendor, techVendors } from "@/db/schema/techVendors"
+import { updateTechVendorSchema, type UpdateTechVendorSchema } from "../validations"
+import { modifyTechVendor } from "../service"
+
+interface UpdateVendorSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ vendor: TechVendor | null
+}
+type StatusType = (typeof techVendors.status.enumValues)[number];
+
+type StatusConfig = {
+ Icon: React.ElementType;
+ className: string;
+ label: string;
+};
+
+// 상태 표시 유틸리티 함수
+const getStatusConfig = (status: StatusType): StatusConfig => {
+ switch(status) {
+ case "ACTIVE":
+ return {
+ Icon: Activity,
+ className: "text-emerald-600",
+ label: "활성 상태"
+ };
+ case "INACTIVE":
+ return {
+ Icon: AlertCircle,
+ className: "text-gray-600",
+ label: "비활성 상태"
+ };
+ case "BLACKLISTED":
+ return {
+ Icon: AlertTriangle,
+ className: "text-slate-800",
+ label: "거래 금지"
+ };
+ case "QUOTE_COMPARISON":
+ return {
+ Icon: AlertTriangle,
+ className: "text-slate-800",
+ label: "비교 견적"
+ };
+ case "PENDING_INVITE":
+ return {
+ Icon: AlertTriangle,
+ className: "text-slate-800",
+ label: "초대 대기"
+ };
+ case "INVITED":
+ return {
+ Icon: AlertTriangle,
+ className: "text-slate-800",
+ label: "초대 완료"
+ };
+ default:
+ return {
+ Icon: CircleIcon,
+ className: "text-gray-600",
+ label: status
+ };
+ }
+};
+
+// 폼 컴포넌트
+export function UpdateVendorSheet({ vendor, ...props }: UpdateVendorSheetProps) {
+ const [isPending, startTransition] = React.useTransition()
+ const { data: session } = useSession()
+
+ // 폼 정의 - UpdateVendorSchema 타입을 직접 사용
+ const form = useForm<UpdateTechVendorSchema>({
+ resolver: zodResolver(updateTechVendorSchema),
+ defaultValues: {
+ // 업체 기본 정보
+ vendorName: vendor?.vendorName ?? "",
+ vendorCode: vendor?.vendorCode ?? "",
+ address: vendor?.address ?? "",
+ country: vendor?.country ?? "",
+ countryEng: vendor?.countryEng ?? "",
+ countryFab: vendor?.countryFab ?? "",
+ phone: vendor?.phone ?? "",
+ email: vendor?.email ?? "",
+ website: vendor?.website ?? "",
+ techVendorType: vendor?.techVendorType ? vendor.techVendorType.split(',').map(s => s.trim()).filter(Boolean) as ("조선" | "해양TOP" | "해양HULL")[] : [],
+ status: vendor?.status ?? "ACTIVE",
+ // 에이전트 정보
+ agentName: vendor?.agentName ?? "",
+ agentEmail: vendor?.agentEmail ?? "",
+ agentPhone: vendor?.agentPhone ?? "",
+ // 대표자 정보
+ representativeName: vendor?.representativeName ?? "",
+ representativeEmail: vendor?.representativeEmail ?? "",
+ representativePhone: vendor?.representativePhone ?? "",
+ representativeBirth: vendor?.representativeBirth ?? "",
+ },
+ })
+
+ React.useEffect(() => {
+ if (vendor) {
+ form.reset({
+ vendorName: vendor?.vendorName ?? "",
+ vendorCode: vendor?.vendorCode ?? "",
+ address: vendor?.address ?? "",
+ country: vendor?.country ?? "",
+ countryEng: vendor?.countryEng ?? "",
+ countryFab: vendor?.countryFab ?? "",
+ phone: vendor?.phone ?? "",
+ email: vendor?.email ?? "",
+ website: vendor?.website ?? "",
+ techVendorType: vendor?.techVendorType ? vendor.techVendorType.split(',').map(s => s.trim()).filter(Boolean) as ("조선" | "해양TOP" | "해양HULL")[] : [],
+ status: vendor?.status ?? "ACTIVE",
+ // 에이전트 정보
+ agentName: vendor?.agentName ?? "",
+ agentEmail: vendor?.agentEmail ?? "",
+ agentPhone: vendor?.agentPhone ?? "",
+ // 대표자 정보
+ representativeName: vendor?.representativeName ?? "",
+ representativeEmail: vendor?.representativeEmail ?? "",
+ representativePhone: vendor?.representativePhone ?? "",
+ representativeBirth: vendor?.representativeBirth ?? "",
+ });
+ }
+ }, [vendor, form]);
+
+ // 제출 핸들러
+ async function onSubmit(data: UpdateTechVendorSchema) {
+ if (!vendor) return
+
+ if (!session?.user?.id) {
+ toast.error("사용자 인증 정보를 찾을 수 없습니다.")
+ return
+ }
+
+ startTransition(async () => {
+ try {
+ // Add status change comment if status has changed
+ const oldStatus = vendor.status ?? "ACTIVE" // Default to ACTIVE if undefined
+ const newStatus = data.status ?? "ACTIVE" // Default to ACTIVE if undefined
+
+ const statusComment =
+ oldStatus !== newStatus
+ ? `상태 변경: ${getStatusConfig(oldStatus).label} → ${getStatusConfig(newStatus).label}`
+ : "" // Empty string instead of undefined
+
+ // 업체 정보 업데이트 - userId와 상태 변경 코멘트 추가
+ const { error } = await modifyTechVendor({
+ id: String(vendor.id),
+ userId: Number(session.user.id), // Add user ID from session
+ comment: statusComment, // Add comment for status changes
+ ...data, // 모든 데이터 전달 - 서비스 함수에서 필요한 필드만 처리
+ techVendorType: Array.isArray(data.techVendorType) ? data.techVendorType.join(',') : undefined,
+ })
+
+ if (error) throw new Error(error)
+
+ toast.success("업체 정보가 업데이트되었습니다!")
+ form.reset()
+ props.onOpenChange?.(false)
+ } catch (err: unknown) {
+ toast.error(String(err))
+ }
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-xl overflow-y-auto">
+ <SheetHeader className="text-left">
+ <SheetTitle>업체 정보 수정</SheetTitle>
+ <SheetDescription>
+ 업체 세부 정보를 수정하고 변경 사항을 저장하세요
+ </SheetDescription>
+ </SheetHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
+
+ {/* 업체 기본 정보 섹션 */}
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-base">
+ 업체 기본 정보
+ </CardTitle>
+ <CardDescription>
+ 업체의 기본 정보를 관리합니다
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ {/* 업체명 */}
+ <FormField
+ control={form.control}
+ name="vendorName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>업체명</FormLabel>
+ <FormControl>
+ <Input placeholder="업체명 입력" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 업체 코드 */}
+ <FormField
+ control={form.control}
+ name="vendorCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>업체 코드</FormLabel>
+ <FormControl>
+ <Input placeholder="예: ABC123" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 이메일 */}
+ <FormField
+ control={form.control}
+ name="email"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>이메일</FormLabel>
+ <FormControl>
+ <Input placeholder="예: info@company.com" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 전화번호 */}
+ <FormField
+ control={form.control}
+ name="phone"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>전화번호</FormLabel>
+ <FormControl>
+ <Input placeholder="예: 010-1234-5678" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 웹사이트 */}
+ <FormField
+ control={form.control}
+ name="website"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>웹사이트</FormLabel>
+ <FormControl>
+ <Input placeholder="예: https://www.company.com" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 주소 */}
+ <FormField
+ control={form.control}
+ name="address"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>주소</FormLabel>
+ <FormControl>
+ <Input placeholder="주소 입력" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
+ {/* 국가 */}
+ <FormField
+ control={form.control}
+ name="country"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>국가</FormLabel>
+ <FormControl>
+ <Input placeholder="예: 대한민국" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 국가(영문) */}
+ <FormField
+ control={form.control}
+ name="countryEng"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>국가(영문)</FormLabel>
+ <FormControl>
+ <Input placeholder="예: South Korea" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 제조국가 */}
+ <FormField
+ control={form.control}
+ name="countryFab"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>제조국가</FormLabel>
+ <FormControl>
+ <Input placeholder="제조국가 입력" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 벤더 타입 */}
+ <FormField
+ control={form.control}
+ name="techVendorType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>벤더 타입 *</FormLabel>
+ <div className="flex gap-6">
+ {["조선", "해양TOP", "해양HULL"].map((type) => (
+ <div key={type} className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id={`update-${type}`}
+ checked={field.value?.includes(type as "조선" | "해양TOP" | "해양HULL")}
+ onChange={(e) => {
+ const currentValue = Array.isArray(field.value) ? field.value : [];
+ if (e.target.checked) {
+ field.onChange([...currentValue, type]);
+ } else {
+ field.onChange(currentValue.filter((v: string) => v !== type));
+ }
+ }}
+ className="w-4 h-4"
+ />
+ <label htmlFor={`update-${type}`} className="text-sm font-medium cursor-pointer">
+ {type}
+ </label>
+ </div>
+ ))}
+ </div>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </CardContent>
+ </Card>
+
+ {/* 승인 상태 섹션 */}
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-base">
+ 승인 상태
+ </CardTitle>
+ <CardDescription>
+ 업체의 승인 상태를 관리합니다
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <FormField
+ control={form.control}
+ name="status"
+ render={({ field }) => {
+ const selectedConfig = getStatusConfig(field.value ?? "ACTIVE");
+ const SelectedIcon = selectedConfig?.Icon || CircleIcon;
+
+ return (
+ <FormItem>
+ <FormLabel>업체 승인 상태</FormLabel>
+ <FormControl>
+ <Select
+ value={field.value || ""}
+ onValueChange={field.onChange}
+ >
+ <SelectTrigger className="w-full">
+ <SelectValue>
+ {field.value && (
+ <div className="flex items-center">
+ <SelectedIcon className={`mr-2 h-4 w-4 ${selectedConfig.className}`} />
+ <span>{selectedConfig.label}</span>
+ </div>
+ )}
+ </SelectValue>
+ </SelectTrigger>
+ <SelectContent>
+ <SelectGroup>
+ {techVendors.status.enumValues.map((status) => {
+ const config = getStatusConfig(status);
+ const StatusIcon = config.Icon;
+ return (
+ <SelectItem key={status} value={status}>
+ <div className="flex items-center">
+ <StatusIcon className={`mr-2 h-4 w-4 ${config.className}`} />
+ <span>{config.label}</span>
+ </div>
+ </SelectItem>
+ );
+ })}
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ );
+ }}
+ />
+ </CardContent>
+ </Card>
+
+ {/* 에이전트 정보 섹션 */}
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-base">
+ 에이전트 정보
+ </CardTitle>
+ <CardDescription>
+ 해당 업체의 에이전트 정보를 관리합니다
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ {/* 에이전트명 */}
+ <FormField
+ control={form.control}
+ name="agentName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>에이전트명</FormLabel>
+ <FormControl>
+ <Input placeholder="에이전트명 입력" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 에이전트 전화번호 */}
+ <FormField
+ control={form.control}
+ name="agentPhone"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>에이전트 전화번호</FormLabel>
+ <FormControl>
+ <Input placeholder="에이전트 전화번호 입력" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 에이전트 이메일 */}
+ <FormField
+ control={form.control}
+ name="agentEmail"
+ render={({ field }) => (
+ <FormItem className="md:col-span-2">
+ <FormLabel>에이전트 이메일</FormLabel>
+ <FormControl>
+ <Input type="email" placeholder="에이전트 이메일 입력" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 대표자 정보 섹션 */}
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-base">
+ 대표자 정보
+ </CardTitle>
+ <CardDescription>
+ 업체 대표자의 정보를 관리합니다
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ {/* 대표자명 */}
+ <FormField
+ control={form.control}
+ name="representativeName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>대표자명</FormLabel>
+ <FormControl>
+ <Input placeholder="대표자명 입력" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 대표자 생년월일 */}
+ <FormField
+ control={form.control}
+ name="representativeBirth"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>대표자 생년월일</FormLabel>
+ <FormControl>
+ <Input placeholder="YYYY-MM-DD" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 대표자 전화번호 */}
+ <FormField
+ control={form.control}
+ name="representativePhone"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>대표자 전화번호</FormLabel>
+ <FormControl>
+ <Input placeholder="대표자 전화번호 입력" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 대표자 이메일 */}
+ <FormField
+ control={form.control}
+ name="representativeEmail"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>대표자 이메일</FormLabel>
+ <FormControl>
+ <Input type="email" placeholder="대표자 이메일 입력" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </CardContent>
+ </Card>
+
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ 취소
+ </Button>
+ </SheetClose>
+ <Button disabled={isPending}>
+ {isPending && (
+ <Loader className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
+ )}
+ 저장
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
} \ No newline at end of file
diff --git a/lib/tech-vendors/table/vendor-all-export.ts b/lib/tech-vendors/table/vendor-all-export.ts
index f2650102..f1492324 100644
--- a/lib/tech-vendors/table/vendor-all-export.ts
+++ b/lib/tech-vendors/table/vendor-all-export.ts
@@ -1,257 +1,257 @@
-// /lib/vendor-export.ts
-import ExcelJS from "exceljs"
-import { TechVendor, TechVendorContact, TechVendorItem } from "@/db/schema/techVendors"
-import { exportTechVendorDetails } from "../service";
-
-/**
- * 선택된 벤더의 모든 관련 정보를 통합 시트 형식으로 엑셀로 내보내는 함수
- * - 기본정보 시트
- * - 연락처 시트
- * - 아이템 시트
- * 각 시트에는 식별을 위한 벤더 코드, 벤더명, 세금ID가 포함됨
- */
-export async function exportVendorsWithRelatedData(
- vendors: TechVendor[],
- filename = "tech-vendors-detailed"
-): Promise<void> {
- if (!vendors.length) return;
-
- // 선택된 벤더 ID 목록
- const vendorIds = vendors.map(vendor => vendor.id);
-
- try {
- // 서버로부터 모든 관련 데이터 가져오기
- const vendorsWithDetails = await exportTechVendorDetails(vendorIds);
-
- if (!vendorsWithDetails.length) {
- throw new Error("내보내기 데이터를 가져오는 중 오류가 발생했습니다.");
- }
-
- // 워크북 생성
- const workbook = new ExcelJS.Workbook();
-
- // 데이터 타입 확인 (서비스에서 반환하는 실제 데이터 형태)
- const vendorData = vendorsWithDetails as unknown as any[];
-
- // ===== 1. 기본 정보 시트 =====
- createBasicInfoSheet(workbook, vendorData);
-
- // ===== 2. 연락처 시트 =====
- createContactsSheet(workbook, vendorData);
-
- // ===== 3. 아이템 시트 =====
- createItemsSheet(workbook, vendorData);
-
-
- // 파일 다운로드
- const buffer = await workbook.xlsx.writeBuffer();
- const blob = new Blob([buffer], {
- type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
- });
- const url = URL.createObjectURL(blob);
- const link = document.createElement("a");
- link.href = url;
- link.download = `${filename}-${new Date().toISOString().split("T")[0]}.xlsx`;
- link.click();
- URL.revokeObjectURL(url);
-
- return;
- } catch (error) {
- console.error("Export error:", error);
- throw error;
- }
-}
-
-// 기본 정보 시트 생성 함수
-function createBasicInfoSheet(
- workbook: ExcelJS.Workbook,
- vendors: TechVendor[]
-): void {
- const basicInfoSheet = workbook.addWorksheet("기본정보");
-
- // 기본 정보 시트 헤더 설정
- basicInfoSheet.columns = [
- { header: "업체코드", key: "vendorCode", width: 15 },
- { header: "업체명", key: "vendorName", width: 20 },
- { header: "세금ID", key: "taxId", width: 15 },
- { header: "국가", key: "country", width: 10 },
- { header: "상태", key: "status", width: 15 },
- { header: "이메일", key: "email", width: 20 },
- { header: "전화번호", key: "phone", width: 15 },
- { header: "웹사이트", key: "website", width: 20 },
- { header: "주소", key: "address", width: 30 },
- { header: "대표자명", key: "representativeName", width: 15 },
- { header: "생성일", key: "createdAt", width: 15 },
- { header: "벤더타입", key: "techVendorType", width: 15 },
- { header: "대리점명", key: "agentName", width: 15 },
- { header: "대리점연락처", key: "agentPhone", width: 15 },
- { header: "대리점이메일", key: "agentEmail", width: 25 },
- { header: "대리점주소", key: "agentAddress", width: 30 },
- { header: "대리점국가", key: "agentCountry", width: 15 },
- { header: "대리점영문국가명", key: "agentCountryEng", width: 20 },
- ];
-
- // 헤더 스타일 설정
- applyHeaderStyle(basicInfoSheet);
-
- // 벤더 데이터 추가
- vendors.forEach((vendor: TechVendor) => {
- basicInfoSheet.addRow({
- vendorCode: vendor.vendorCode || "",
- vendorName: vendor.vendorName,
- taxId: vendor.taxId,
- country: vendor.country,
- status: getStatusText(vendor.status), // 상태 코드를 읽기 쉬운 텍스트로 변환
- email: vendor.email,
- phone: vendor.phone,
- website: vendor.website,
- address: vendor.address,
- representativeName: vendor.representativeName,
- createdAt: vendor.createdAt ? formatDate(vendor.createdAt) : "",
- techVendorType: vendor.techVendorType?.split(',').join(', ') || vendor.techVendorType,
- });
- });
-}
-
-// 연락처 시트 생성 함수
-function createContactsSheet(
- workbook: ExcelJS.Workbook,
- vendors: TechVendor[]
-): void {
- const contactsSheet = workbook.addWorksheet("연락처");
-
- contactsSheet.columns = [
- // 벤더 식별 정보
- { header: "업체코드", key: "vendorCode", width: 15 },
- { header: "업체명", key: "vendorName", width: 20 },
- { header: "세금ID", key: "taxId", width: 15 },
- // 연락처 정보
- { header: "이름", key: "contactName", width: 15 },
- { header: "직책", key: "contactPosition", width: 15 },
- { header: "이메일", key: "contactEmail", width: 25 },
- { header: "전화번호", key: "contactPhone", width: 15 },
- { header: "주요 연락처", key: "isPrimary", width: 10 },
- ];
-
- // 헤더 스타일 설정
- applyHeaderStyle(contactsSheet);
-
- // 벤더별 연락처 데이터 추가
- vendors.forEach((vendor: TechVendor) => {
- if (vendor.contacts && vendor.contacts.length > 0) {
- vendor.contacts.forEach((contact: TechVendorContact) => {
- contactsSheet.addRow({
- // 벤더 식별 정보
- vendorCode: vendor.vendorCode || "",
- vendorName: vendor.vendorName,
- taxId: vendor.taxId,
- // 연락처 정보
- contactName: contact.contactName,
- contactPosition: contact.contactPosition || "",
- contactEmail: contact.contactEmail,
- contactPhone: contact.contactPhone || "",
- isPrimary: contact.isPrimary ? "예" : "아니오",
- });
- });
- } else {
- // 연락처가 없는 경우에도 벤더 정보만 추가
- contactsSheet.addRow({
- vendorCode: vendor.vendorCode || "",
- vendorName: vendor.vendorName,
- taxId: vendor.taxId,
- contactName: "",
- contactPosition: "",
- contactEmail: "",
- contactPhone: "",
- isPrimary: "",
- });
- }
- });
-}
-
-// 아이템 시트 생성 함수
-function createItemsSheet(
- workbook: ExcelJS.Workbook,
- vendors: TechVendor[]
-): void {
- const itemsSheet = workbook.addWorksheet("아이템");
-
- itemsSheet.columns = [
- // 벤더 식별 정보
- { header: "업체코드", key: "vendorCode", width: 15 },
- { header: "업체명", key: "vendorName", width: 20 },
- { header: "세금ID", key: "taxId", width: 15 },
- // 아이템 정보
- { header: "아이템 코드", key: "itemCode", width: 15 },
- { header: "아이템명", key: "itemName", width: 25 },
- { header: "설명", key: "description", width: 30 },
- { header: "등록일", key: "createdAt", width: 15 },
- ];
-
- // 헤더 스타일 설정
- applyHeaderStyle(itemsSheet);
-
- // 벤더별 아이템 데이터 추가
- vendors.forEach((vendor: TechVendor) => {
- if (vendor.items && vendor.items.length > 0) {
- vendor.items.forEach((item: TechVendorItem) => {
- itemsSheet.addRow({
- // 벤더 식별 정보
- vendorCode: vendor.vendorCode || "",
- vendorName: vendor.vendorName,
- taxId: vendor.taxId,
- // 아이템 정보
- itemCode: item.itemCode,
- itemName: item.itemName,
- createdAt: item.createdAt ? formatDate(item.createdAt) : "",
- });
- });
- } else {
- // 아이템이 없는 경우에도 벤더 정보만 추가
- itemsSheet.addRow({
- vendorCode: vendor.vendorCode || "",
- vendorName: vendor.vendorName,
- taxId: vendor.taxId,
- itemCode: "",
- itemName: "",
- createdAt: "",
- });
- }
- });
-}
-
-
-// 헤더 스타일 적용 함수
-function applyHeaderStyle(sheet: ExcelJS.Worksheet): void {
- const headerRow = sheet.getRow(1);
- headerRow.font = { bold: true };
- headerRow.alignment = { horizontal: "center" };
- headerRow.eachCell((cell: ExcelJS.Cell) => {
- cell.fill = {
- type: "pattern",
- pattern: "solid",
- fgColor: { argb: "FFCCCCCC" },
- };
- });
-}
-
-// 날짜 포맷 함수
-function formatDate(date: Date | string): string {
- if (!date) return "";
- if (typeof date === 'string') {
- date = new Date(date);
- }
- return date.toISOString().split('T')[0];
-}
-
-
-// 상태 코드를 읽기 쉬운 텍스트로 변환하는 함수
-function getStatusText(status: string): string {
- const statusMap: Record<string, string> = {
- "ACTIVE": "활성",
- "INACTIVE": "비활성",
- "BLACKLISTED": "거래 금지"
- };
-
- return statusMap[status] || status;
+// /lib/vendor-export.ts
+import ExcelJS from "exceljs"
+import { TechVendor, TechVendorContact, TechVendorItem } from "@/db/schema/techVendors"
+import { exportTechVendorDetails } from "../service";
+
+/**
+ * 선택된 벤더의 모든 관련 정보를 통합 시트 형식으로 엑셀로 내보내는 함수
+ * - 기본정보 시트
+ * - 연락처 시트
+ * - 아이템 시트
+ * 각 시트에는 식별을 위한 벤더 코드, 벤더명, 세금ID가 포함됨
+ */
+export async function exportVendorsWithRelatedData(
+ vendors: TechVendor[],
+ filename = "tech-vendors-detailed"
+): Promise<void> {
+ if (!vendors.length) return;
+
+ // 선택된 벤더 ID 목록
+ const vendorIds = vendors.map(vendor => vendor.id);
+
+ try {
+ // 서버로부터 모든 관련 데이터 가져오기
+ const vendorsWithDetails = await exportTechVendorDetails(vendorIds);
+
+ if (!vendorsWithDetails.length) {
+ throw new Error("내보내기 데이터를 가져오는 중 오류가 발생했습니다.");
+ }
+
+ // 워크북 생성
+ const workbook = new ExcelJS.Workbook();
+
+ // 데이터 타입 확인 (서비스에서 반환하는 실제 데이터 형태)
+ const vendorData = vendorsWithDetails as unknown as any[];
+
+ // ===== 1. 기본 정보 시트 =====
+ createBasicInfoSheet(workbook, vendorData);
+
+ // ===== 2. 연락처 시트 =====
+ createContactsSheet(workbook, vendorData);
+
+ // ===== 3. 아이템 시트 =====
+ createItemsSheet(workbook, vendorData);
+
+
+ // 파일 다운로드
+ const buffer = await workbook.xlsx.writeBuffer();
+ const blob = new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = `${filename}-${new Date().toISOString().split("T")[0]}.xlsx`;
+ link.click();
+ URL.revokeObjectURL(url);
+
+ return;
+ } catch (error) {
+ console.error("Export error:", error);
+ throw error;
+ }
+}
+
+// 기본 정보 시트 생성 함수
+function createBasicInfoSheet(
+ workbook: ExcelJS.Workbook,
+ vendors: TechVendor[]
+): void {
+ const basicInfoSheet = workbook.addWorksheet("기본정보");
+
+ // 기본 정보 시트 헤더 설정
+ basicInfoSheet.columns = [
+ { header: "업체코드", key: "vendorCode", width: 15 },
+ { header: "업체명", key: "vendorName", width: 20 },
+ { header: "세금ID", key: "taxId", width: 15 },
+ { header: "국가", key: "country", width: 10 },
+ { header: "상태", key: "status", width: 15 },
+ { header: "이메일", key: "email", width: 20 },
+ { header: "전화번호", key: "phone", width: 15 },
+ { header: "웹사이트", key: "website", width: 20 },
+ { header: "주소", key: "address", width: 30 },
+ { header: "대표자명", key: "representativeName", width: 15 },
+ { header: "생성일", key: "createdAt", width: 15 },
+ { header: "벤더타입", key: "techVendorType", width: 15 },
+ { header: "에이전트명", key: "agentName", width: 15 },
+ { header: "에이전트연락처", key: "agentPhone", width: 15 },
+ { header: "에이전트이메일", key: "agentEmail", width: 25 },
+ { header: "에이전트주소", key: "agentAddress", width: 30 },
+ { header: "에이전트국가", key: "agentCountry", width: 15 },
+ { header: "에이전트영문국가명", key: "agentCountryEng", width: 20 },
+ ];
+
+ // 헤더 스타일 설정
+ applyHeaderStyle(basicInfoSheet);
+
+ // 벤더 데이터 추가
+ vendors.forEach((vendor: TechVendor) => {
+ basicInfoSheet.addRow({
+ vendorCode: vendor.vendorCode || "",
+ vendorName: vendor.vendorName,
+ taxId: vendor.taxId,
+ country: vendor.country,
+ status: getStatusText(vendor.status), // 상태 코드를 읽기 쉬운 텍스트로 변환
+ email: vendor.email,
+ phone: vendor.phone,
+ website: vendor.website,
+ address: vendor.address,
+ representativeName: vendor.representativeName,
+ createdAt: vendor.createdAt ? formatDate(vendor.createdAt) : "",
+ techVendorType: vendor.techVendorType?.split(',').join(', ') || vendor.techVendorType,
+ });
+ });
+}
+
+// 연락처 시트 생성 함수
+function createContactsSheet(
+ workbook: ExcelJS.Workbook,
+ vendors: TechVendor[]
+): void {
+ const contactsSheet = workbook.addWorksheet("연락처");
+
+ contactsSheet.columns = [
+ // 벤더 식별 정보
+ { header: "업체코드", key: "vendorCode", width: 15 },
+ { header: "업체명", key: "vendorName", width: 20 },
+ { header: "세금ID", key: "taxId", width: 15 },
+ // 연락처 정보
+ { header: "이름", key: "contactName", width: 15 },
+ { header: "직책", key: "contactPosition", width: 15 },
+ { header: "이메일", key: "contactEmail", width: 25 },
+ { header: "전화번호", key: "contactPhone", width: 15 },
+ { header: "주요 연락처", key: "isPrimary", width: 10 },
+ ];
+
+ // 헤더 스타일 설정
+ applyHeaderStyle(contactsSheet);
+
+ // 벤더별 연락처 데이터 추가
+ vendors.forEach((vendor: TechVendor) => {
+ if (vendor.contacts && vendor.contacts.length > 0) {
+ vendor.contacts.forEach((contact: TechVendorContact) => {
+ contactsSheet.addRow({
+ // 벤더 식별 정보
+ vendorCode: vendor.vendorCode || "",
+ vendorName: vendor.vendorName,
+ taxId: vendor.taxId,
+ // 연락처 정보
+ contactName: contact.contactName,
+ contactPosition: contact.contactPosition || "",
+ contactEmail: contact.contactEmail,
+ contactPhone: contact.contactPhone || "",
+ isPrimary: contact.isPrimary ? "예" : "아니오",
+ });
+ });
+ } else {
+ // 연락처가 없는 경우에도 벤더 정보만 추가
+ contactsSheet.addRow({
+ vendorCode: vendor.vendorCode || "",
+ vendorName: vendor.vendorName,
+ taxId: vendor.taxId,
+ contactName: "",
+ contactPosition: "",
+ contactEmail: "",
+ contactPhone: "",
+ isPrimary: "",
+ });
+ }
+ });
+}
+
+// 아이템 시트 생성 함수
+function createItemsSheet(
+ workbook: ExcelJS.Workbook,
+ vendors: TechVendor[]
+): void {
+ const itemsSheet = workbook.addWorksheet("아이템");
+
+ itemsSheet.columns = [
+ // 벤더 식별 정보
+ { header: "업체코드", key: "vendorCode", width: 15 },
+ { header: "업체명", key: "vendorName", width: 20 },
+ { header: "세금ID", key: "taxId", width: 15 },
+ // 아이템 정보
+ { header: "아이템 코드", key: "itemCode", width: 15 },
+ { header: "아이템명", key: "itemName", width: 25 },
+ { header: "설명", key: "description", width: 30 },
+ { header: "등록일", key: "createdAt", width: 15 },
+ ];
+
+ // 헤더 스타일 설정
+ applyHeaderStyle(itemsSheet);
+
+ // 벤더별 아이템 데이터 추가
+ vendors.forEach((vendor: TechVendor) => {
+ if (vendor.items && vendor.items.length > 0) {
+ vendor.items.forEach((item: TechVendorItem) => {
+ itemsSheet.addRow({
+ // 벤더 식별 정보
+ vendorCode: vendor.vendorCode || "",
+ vendorName: vendor.vendorName,
+ taxId: vendor.taxId,
+ // 아이템 정보
+ itemCode: item.itemCode,
+ itemName: item.itemName,
+ createdAt: item.createdAt ? formatDate(item.createdAt) : "",
+ });
+ });
+ } else {
+ // 아이템이 없는 경우에도 벤더 정보만 추가
+ itemsSheet.addRow({
+ vendorCode: vendor.vendorCode || "",
+ vendorName: vendor.vendorName,
+ taxId: vendor.taxId,
+ itemCode: "",
+ itemName: "",
+ createdAt: "",
+ });
+ }
+ });
+}
+
+
+// 헤더 스타일 적용 함수
+function applyHeaderStyle(sheet: ExcelJS.Worksheet): void {
+ const headerRow = sheet.getRow(1);
+ headerRow.font = { bold: true };
+ headerRow.alignment = { horizontal: "center" };
+ headerRow.eachCell((cell: ExcelJS.Cell) => {
+ cell.fill = {
+ type: "pattern",
+ pattern: "solid",
+ fgColor: { argb: "FFCCCCCC" },
+ };
+ });
+}
+
+// 날짜 포맷 함수
+function formatDate(date: Date | string): string {
+ if (!date) return "";
+ if (typeof date === 'string') {
+ date = new Date(date);
+ }
+ return date.toISOString().split('T')[0];
+}
+
+
+// 상태 코드를 읽기 쉬운 텍스트로 변환하는 함수
+function getStatusText(status: string): string {
+ const statusMap: Record<string, string> = {
+ "ACTIVE": "활성",
+ "INACTIVE": "비활성",
+ "BLACKLISTED": "거래 금지"
+ };
+
+ return statusMap[status] || status;
} \ No newline at end of file