diff options
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/rfq-last/service.ts | 625 | ||||
| -rw-r--r-- | lib/rfq-last/table/create-general-rfq-dialog.tsx | 779 | ||||
| -rw-r--r-- | lib/rfq-last/table/rfq-attachments-dialog.tsx | 351 | ||||
| -rw-r--r-- | lib/rfq-last/table/rfq-items-dialog.tsx | 354 | ||||
| -rw-r--r-- | lib/rfq-last/table/rfq-table-columns.tsx | 1206 | ||||
| -rw-r--r-- | lib/rfq-last/table/rfq-table-toolbar-actions.tsx | 15 | ||||
| -rw-r--r-- | lib/rfq-last/table/rfq-table.tsx | 26 | ||||
| -rw-r--r-- | lib/rfq-last/validations.ts | 24 | ||||
| -rw-r--r-- | lib/vendor-document-list/enhanced-document-service.ts | 233 | ||||
| -rw-r--r-- | lib/vendor-document-list/import-service.ts | 15 | ||||
| -rw-r--r-- | lib/vendor-document-list/ship/bulk-b4-upload-dialog.tsx | 428 | ||||
| -rw-r--r-- | lib/vendor-document-list/ship/enhanced-doc-table-columns.tsx | 37 | ||||
| -rw-r--r-- | lib/vendor-document-list/ship/enhanced-doc-table-toolbar-actions.tsx | 77 | ||||
| -rw-r--r-- | lib/vendor-document-list/ship/enhanced-documents-table.tsx | 1 |
14 files changed, 3771 insertions, 400 deletions
diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts index f2710f02..0be8049b 100644 --- a/lib/rfq-last/service.ts +++ b/lib/rfq-last/service.ts @@ -1,12 +1,12 @@ // lib/rfq/service.ts 'use server' -import { unstable_noStore } from "next/cache"; +import { unstable_cache, unstable_noStore } from "next/cache"; import db from "@/db/db"; -import { rfqsLastView } from "@/db/schema"; -import { and, desc, asc, ilike, or, eq, SQL, count, gte, lte,isNotNull,ne } from "drizzle-orm"; +import { RfqsLastView, rfqLastAttachmentRevisions, rfqLastAttachments, rfqsLast, rfqsLastView, users, rfqPrItems, prItemsLastView } from "@/db/schema"; +import {sql, and, desc, asc, like, ilike, or, eq, SQL, count, gte, lte, isNotNull, ne, inArray } from "drizzle-orm"; import { filterColumns } from "@/lib/filter-columns"; -import { GetRfqsSchema } from "./validations"; +import { GetRfqLastAttachmentsSchema, GetRfqsSchema } from "./validations"; export async function getRfqs(input: GetRfqsSchema) { unstable_noStore(); @@ -44,17 +44,17 @@ export async function getRfqs(input: GetRfqsSchema) { // 2. 고급 필터 처리 let advancedWhere: SQL<unknown> | undefined = undefined; - + if (input.filters && Array.isArray(input.filters) && input.filters.length > 0) { console.log("필터 적용:", input.filters.map(f => `${f.id} ${f.operator} ${f.value}`)); - + try { advancedWhere = filterColumns({ table: rfqsLastView, filters: input.filters, joinOperator: input.joinOperator || 'and', }); - + console.log("필터 조건 생성 완료"); } catch (error) { console.error("필터 조건 생성 오류:", error); @@ -111,8 +111,8 @@ export async function getRfqs(input: GetRfqsSchema) { // 6. 정렬 및 페이징 처리 const orderByColumns = input.sort.map((sort) => { const column = sort.id as keyof typeof rfqsLastView.$inferSelect; - return sort.desc - ? desc(rfqsLastView[column]) + return sort.desc + ? desc(rfqsLastView[column]) : asc(rfqsLastView[column]); }); @@ -139,3 +139,610 @@ export async function getRfqs(input: GetRfqsSchema) { } } +const getRfqById = async (id: number): Promise<RfqsLastView | null> => { + // 1) RFQ 단건 조회 + const rfqsRes = await db + .select() + .from(rfqsLastView) + .where(eq(rfqsLastView.id, id)) + .limit(1); + + if (rfqsRes.length === 0) return null; + const rfqRow = rfqsRes[0]; + + // 3) RfqWithItems 형태로 반환 + const result: RfqsLastView = { + ...rfqRow, + + }; + + return result; +}; + + +export const findRfqLastById = async (id: number): Promise<RfqsLastView | null> => { + try { + + const rfq = await getRfqById(id); + + return rfq; + } catch (error) { + throw new Error('Failed to fetch user'); + } +}; + + +export async function getRfqLastAttachments( + input: GetRfqLastAttachmentsSchema, + rfqId: number +) { + try { + const offset = (input.page - 1) * input.perPage + + // Advanced Filter 처리 (메인 테이블 기준) + const advancedWhere = filterColumns({ + table: rfqLastAttachments, + filters: input.filters, + joinOperator: input.joinOperator, + }) + + // 전역 검색 (첨부파일 + 리비전 파일명 검색) + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or( + ilike(rfqLastAttachments.serialNo, s), + ilike(rfqLastAttachments.description, s), + ilike(rfqLastAttachments.currentRevision, s), + ilike(rfqLastAttachmentRevisions.fileName, s), + ilike(rfqLastAttachmentRevisions.originalFileName, s) + ) + } + + // 기본 필터 + let basicWhere + if (input.attachmentType.length > 0 || input.fileType.length > 0) { + basicWhere = and( + input.attachmentType.length > 0 + ? inArray(rfqLastAttachments.attachmentType, input.attachmentType) + : undefined, + input.fileType.length > 0 + ? inArray(rfqLastAttachmentRevisions.fileType, input.fileType) + : undefined + ) + } + + // 최종 WHERE 절 + const finalWhere = and( + eq(rfqLastAttachments.rfqId, rfqId), // RFQ ID 필수 조건 + advancedWhere, + globalWhere, + basicWhere + ) + + // 정렬 (메인 테이블 기준) + const orderBy = input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(rfqLastAttachments[item.id as keyof typeof rfqLastAttachments]) : asc(rfqLastAttachments[item.id as keyof typeof rfqLastAttachments]) + ) + : [desc(rfqLastAttachments.createdAt)] + + // 트랜잭션으로 데이터 조회 + const { data, total } = await db.transaction(async (tx) => { + // 메인 데이터 조회 (첨부파일 + 최신 리비전 조인) + const data = await tx + .select({ + // 첨부파일 메인 정보 + id: rfqLastAttachments.id, + attachmentType: rfqLastAttachments.attachmentType, + serialNo: rfqLastAttachments.serialNo, + rfqId: rfqLastAttachments.rfqId, + currentRevision: rfqLastAttachments.currentRevision, + latestRevisionId: rfqLastAttachments.latestRevisionId, + description: rfqLastAttachments.description, + createdBy: rfqLastAttachments.createdBy, + createdAt: rfqLastAttachments.createdAt, + updatedAt: rfqLastAttachments.updatedAt, + + // 최신 리비전 파일 정보 + fileName: rfqLastAttachmentRevisions.fileName, + originalFileName: rfqLastAttachmentRevisions.originalFileName, + filePath: rfqLastAttachmentRevisions.filePath, + fileSize: rfqLastAttachmentRevisions.fileSize, + fileType: rfqLastAttachmentRevisions.fileType, + revisionComment: rfqLastAttachmentRevisions.revisionComment, + + // 생성자 정보 + createdByName: users.name, + }) + .from(rfqLastAttachments) + .leftJoin( + rfqLastAttachmentRevisions, + and( + eq(rfqLastAttachments.latestRevisionId, rfqLastAttachmentRevisions.id), + eq(rfqLastAttachmentRevisions.isLatest, true) + ) + ) + .leftJoin(users, eq(rfqLastAttachments.createdBy, users.id)) + .where(finalWhere) + .orderBy(...orderBy) + .limit(input.perPage) + .offset(offset) + + // 전체 개수 조회 + const totalResult = await tx + .select({ count: count() }) + .from(rfqLastAttachments) + .leftJoin( + rfqLastAttachmentRevisions, + eq(rfqLastAttachments.latestRevisionId, rfqLastAttachmentRevisions.id) + ) + .where(finalWhere) + + const total = totalResult[0]?.count ?? 0 + + return { data, total } + }) + + const pageCount = Math.ceil(total / input.perPage) + + return { data, pageCount } + } catch (err) { + console.error("getRfqAttachments error:", err) + return { data: [], pageCount: 0 } + } + +} +// 사용자 목록 조회 (필터용) +export async function getPUsersForFilter() { + + try { + return await db + .select({ + id: users.id, + name: users.name, + userCode: users.userCode, + }) + .from(users) + .where(and(eq(users.isActive, true), isNotNull(users.userCode,))) + .orderBy(asc(users.name)) + } catch (err) { + console.error("Error fetching users for filter:", err) + return [] + } +} + + + +// 일반견적 RFQ 코드 생성 (F+userCode(3자리)+일련번호5자리 형식) +async function generateGeneralRfqCode(userCode: string): Promise<string> { + try { + // 동일한 userCode를 가진 마지막 일반견적 번호 조회 + const lastRfq = await db + .select({ rfqCode: rfqsLast.rfqCode }) + .from(rfqsLast) + .where( + and( + eq(rfqsLast.rfqType, "일반견적"), + like(rfqsLast.rfqCode, `F${userCode}%`) // 같은 userCode로 시작하는 RFQ만 조회 + ) + ) + .orderBy(desc(rfqsLast.createdAt)) + .limit(1); + + let nextNumber = 1; + + if (lastRfq.length > 0 && lastRfq[0].rfqCode) { + // F+userCode(3자리)+일련번호(5자리) 형식에서 마지막 5자리 숫자 추출 + const rfqCode = lastRfq[0].rfqCode; + const serialNumber = rfqCode.slice(-5); // 마지막 5자리 추출 + + // 숫자인지 확인하고 다음 번호 생성 + if (/^\d{5}$/.test(serialNumber)) { + nextNumber = parseInt(serialNumber) + 1; + } + } + + // 5자리 숫자로 패딩 + const paddedNumber = String(nextNumber).padStart(5, '0'); + return `F${userCode}${paddedNumber}`; + } catch (error) { + console.error("Error generating General RFQ code:", error); + // 에러 발생 시 타임스탬프 기반 코드 생성 + const timestamp = Date.now().toString().slice(-5); + return `F${userCode}${timestamp}`; + } +} + +// 일반견적 생성 액션 +interface CreateGeneralRfqInput { + rfqType: string; + rfqTitle: string; + dueDate: Date; + picUserId: number; + remark?: string; + items: Array<{ + itemCode: string; + itemName: string; + quantity: number; + uom: string; + remark?: string; + }>; + createdBy: number; + updatedBy: number; +} + +export async function createGeneralRfqAction(input: CreateGeneralRfqInput) { + try { + // 트랜잭션으로 처리 + const result = await db.transaction(async (tx) => { + // 1. 구매 담당자 정보 조회 + const picUser = await tx + .select({ + name: users.name, + email: users.email, + userCode: users.userCode + }) + .from(users) + .where(eq(users.id, input.picUserId)) + .limit(1); + + if (!picUser || picUser.length === 0) { + throw new Error("구매 담당자를 찾을 수 없습니다"); + } + + // 2. userCode 확인 (3자리) + const userCode = picUser[0].userCode; + if (!userCode || userCode.length !== 3) { + throw new Error("구매 담당자의 userCode가 올바르지 않습니다 (3자리 필요)"); + } + + // 3. RFQ 코드 생성 (userCode 사용) + const rfqCode = await generateGeneralRfqCode(userCode); + + // 4. 대표 아이템 정보 추출 (첫 번째 아이템) + const representativeItem = input.items[0]; + + // 5. rfqsLast 테이블에 기본 정보 삽입 + const [newRfq] = await tx + .insert(rfqsLast) + .values({ + rfqCode, + rfqType: input.rfqType, + rfqTitle: input.rfqTitle, + status: "RFQ 생성", + dueDate: input.dueDate, + + // 대표 아이템 정보 + itemCode: representativeItem.itemCode, + itemName: representativeItem.itemName, + + // 담당자 정보 + pic: input.picUserId, + picCode: userCode, // userCode를 picCode로 사용 + picName: picUser[0].name || '', + + // 기타 정보 + remark: input.remark || null, + createdBy: input.createdBy, + updatedBy: input.updatedBy, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); + + // 6. rfqPrItems 테이블에 아이템들 삽입 + const prItemsData = input.items.map((item, index) => ({ + rfqsLastId: newRfq.id, + rfqItem: `${index + 1}`.padStart(3, '0'), // 001, 002, ... + prItem: `${index + 1}`.padStart(3, '0'), + prNo: rfqCode, // RFQ 코드를 PR 번호로 사용 + + materialCode: item.itemCode, + materialDescription: item.itemName, + quantity: item.quantity, + uom: item.uom, + + majorYn: index === 0, // 첫 번째 아이템을 주요 아이템으로 설정 + remark: item.remark || null, + })); + + await tx.insert(rfqPrItems).values(prItemsData); + + return newRfq; + }); + + return { + success: true, + message: "일반견적이 성공적으로 생성되었습니다", + data: { + id: result.id, + rfqCode: result.rfqCode, + }, + }; + + } catch (error) { + console.error("일반견적 생성 오류:", error); + + if (error instanceof Error) { + return { + success: false, + error: error.message, + }; + } + + return { + success: false, + error: "일반견적 생성 중 오류가 발생했습니다", + }; + } +} + +// 일반견적 미리보기 (선택적 기능) +export async function previewGeneralRfqCode(picUserId: number): Promise<string> { + try { + // 구매 담당자 정보 조회 + const picUser = await db + .select({ + userCode: users.userCode + }) + .from(users) + .where(eq(users.id, picUserId)) + .limit(1); + + if (!picUser || picUser.length === 0 || !picUser[0].userCode) { + return `F???00001`; + } + + const userCode = picUser[0].userCode; + if (userCode.length !== 3) { + return `F???00001`; + } + + // 동일한 userCode를 가진 마지막 일반견적 번호 조회 + const lastRfq = await db + .select({ rfqCode: rfqsLast.rfqCode }) + .from(rfqsLast) + .where( + and( + eq(rfqsLast.rfqType, "일반견적"), + like(rfqsLast.rfqCode, `F${userCode}%`) + ) + ) + .orderBy(desc(rfqsLast.createdAt)) + .limit(1); + + let nextNumber = 1; + + if (lastRfq.length > 0 && lastRfq[0].rfqCode) { + const rfqCode = lastRfq[0].rfqCode; + const serialNumber = rfqCode.slice(-5); + + if (/^\d{5}$/.test(serialNumber)) { + nextNumber = parseInt(serialNumber) + 1; + } + } + + const paddedNumber = String(nextNumber).padStart(5, '0'); + return `F${userCode}${paddedNumber}`; + } catch (error) { + return `F???XXXXX`; + } +} + + +/** + * RFQ 첨부파일 목록 조회 + */ +export async function getRfqAttachmentsAction(rfqId: number) { + try { + if (!rfqId || rfqId <= 0) { + return { + success: false, + error: "유효하지 않은 RFQ ID입니다", + data: [] + } + } + + // rfpAttachmentsWithLatestRevisionView 뷰 조회 + const attachments = await db.execute(sql` + SELECT + attachment_id, + attachment_type, + serial_no, + rfq_id, + description, + current_revision, + revision_id, + file_name, + original_file_name, + file_path, + file_size, + file_type, + revision_comment, + created_by, + created_by_name, + created_at, + updated_at + FROM rfq_attachments_with_latest_revision + WHERE rfq_id = ${rfqId} + ORDER BY attachment_type, serial_no, created_at DESC + `) + + const formattedAttachments = attachments.rows.map((row: any) => ({ + attachmentId: row.attachment_id, + attachmentType: row.attachment_type, + serialNo: row.serial_no, + rfqId: row.rfq_id, + description: row.description, + currentRevision: row.current_revision, + revisionId: row.revision_id, + fileName: row.file_name, + originalFileName: row.original_file_name, + filePath: row.file_path, + fileSize: row.file_size, + fileType: row.file_type, + revisionComment: row.revision_comment, + createdBy: row.created_by, + createdByName: row.created_by_name, + createdAt: row.created_at ? new Date(row.created_at) : null, + updatedAt: row.updated_at ? new Date(row.updated_at) : null, + })) + + return { + success: true, + data: formattedAttachments, + count: formattedAttachments.length + } + + } catch (error) { + console.error("RFQ 첨부파일 조회 오류:", error) + return { + success: false, + error: "첨부파일 목록을 불러오는데 실패했습니다", + data: [] + } + } +} + +/** + * RFQ 품목 목록 조회 + */ +export async function getRfqItemsAction(rfqId: number) { + try { + if (!rfqId || rfqId <= 0) { + return { + success: false, + error: "유효하지 않은 RFQ ID입니다", + data: [] + } + } + + // prItemsLastView 조회 + const items = await db + .select() + .from(prItemsLastView) + .where(eq(prItemsLastView.rfqsLastId, rfqId)) + .orderBy(prItemsLastView.majorYn, prItemsLastView.rfqItem, prItemsLastView.materialCode) + + const formattedItems = items.map(item => ({ + id: item.id, + rfqsLastId: item.rfqsLastId, + rfqItem: item.rfqItem, + prItem: item.prItem, + prNo: item.prNo, + materialCode: item.materialCode, + materialCategory: item.materialCategory, + acc: item.acc, + materialDescription: item.materialDescription, + size: item.size, + deliveryDate: item.deliveryDate, + quantity: item.quantity, + uom: item.uom, + grossWeight: item.grossWeight, + gwUom: item.gwUom, + specNo: item.specNo, + specUrl: item.specUrl, + trackingNo: item.trackingNo, + majorYn: item.majorYn, + remark: item.remark, + projectDef: item.projectDef, + projectSc: item.projectSc, + projectKl: item.projectKl, + projectLc: item.projectLc, + projectDl: item.projectDl, + // RFQ 관련 정보 + rfqCode: item.rfqCode, + rfqType: item.rfqType, + rfqTitle: item.rfqTitle, + itemCode: item.itemCode, + itemName: item.itemName, + projectCode: item.projectCode, + projectName: item.projectName, + })) + + // 주요 품목과 일반 품목 분리 및 통계 + const majorItems = formattedItems.filter(item => item.majorYn) + const regularItems = formattedItems.filter(item => !item.majorYn) + + return { + success: true, + data: formattedItems, + statistics: { + total: formattedItems.length, + major: majorItems.length, + regular: regularItems.length, + totalQuantity: formattedItems.reduce((sum, item) => sum + (item.quantity || 0), 0), + totalWeight: formattedItems.reduce((sum, item) => sum + (item.grossWeight || 0), 0), + } + } + + } catch (error) { + console.error("RFQ 품목 조회 오류:", error) + return { + success: false, + error: "품목 목록을 불러오는데 실패했습니다", + data: [], + statistics: { + total: 0, + major: 0, + regular: 0, + totalQuantity: 0, + totalWeight: 0, + } + } + } +} + +/** + * RFQ 기본 정보 조회 (첨부파일/품목 다이얼로그용) + */ +export async function getRfqBasicInfoAction(rfqId: number) { + try { + if (!rfqId || rfqId <= 0) { + return { + success: false, + error: "유효하지 않은 RFQ ID입니다", + data: null + } + } + + const rfqInfo = await db + .select({ + id: rfqsLast.id, + rfqCode: rfqsLast.rfqCode, + rfqType: rfqsLast.rfqType, + rfqTitle: rfqsLast.rfqTitle, + status: rfqsLast.status, + itemCode: rfqsLast.itemCode, + itemName: rfqsLast.itemName, + dueDate: rfqsLast.dueDate, + createdAt: rfqsLast.createdAt, + }) + .from(rfqsLast) + .where(eq(rfqsLast.id, rfqId)) + .limit(1) + + if (!rfqInfo.length) { + return { + success: false, + error: "RFQ를 찾을 수 없습니다", + data: null + } + } + + return { + success: true, + data: rfqInfo[0] + } + + } catch (error) { + console.error("RFQ 기본정보 조회 오류:", error) + return { + success: false, + error: "RFQ 정보를 불러오는데 실패했습니다", + data: null + } + } +} + diff --git a/lib/rfq-last/table/create-general-rfq-dialog.tsx b/lib/rfq-last/table/create-general-rfq-dialog.tsx new file mode 100644 index 00000000..14564686 --- /dev/null +++ b/lib/rfq-last/table/create-general-rfq-dialog.tsx @@ -0,0 +1,779 @@ +"use client" + +import * as React from "react" +import { useForm, useFieldArray } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { format } from "date-fns" +import { CalendarIcon, Plus, Loader2, Trash2, PlusCircle, Check, ChevronsUpDown } from "lucide-react" +import { useRouter } from "next/navigation" +import { useSession } from "next-auth/react" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { + Command, + CommandInput, + CommandList, + CommandGroup, + CommandItem, + CommandEmpty, +} from "@/components/ui/command" +import { Calendar } from "@/components/ui/calendar" +import { Badge } from "@/components/ui/badge" +import { cn } from "@/lib/utils" +import { toast } from "sonner" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Separator } from "@/components/ui/separator" +import { createGeneralRfqAction, getPUsersForFilter, previewGeneralRfqCode } from "../service" + +// 아이템 스키마 +const itemSchema = z.object({ + itemCode: z.string().min(1, "자재코드를 입력해주세요"), + itemName: z.string().min(1, "자재명을 입력해주세요"), + quantity: z.number().min(1, "수량은 1 이상이어야 합니다"), + uom: z.string().min(1, "단위를 입력해주세요"), + remark: z.string().optional(), +}) + +// 일반견적 생성 폼 스키마 +const createGeneralRfqSchema = z.object({ + rfqType: z.string().min(1, "견적 종류를 선택해주세요"), + customRfqType: z.string().optional(), + rfqTitle: z.string().min(1, "견적명을 입력해주세요"), + dueDate: z.date({ + required_error: "마감일을 선택해주세요", + }), + picUserId: z.number().min(1, "구매 담당자를 선택해주세요"), + remark: z.string().optional(), + items: z.array(itemSchema).min(1, "최소 하나의 아이템을 추가해주세요"), +}).refine((data) => { + if (data.rfqType === "기타") { + return data.customRfqType && data.customRfqType.trim().length > 0 + } + return true +}, { + message: "견적 종류를 직접 입력해주세요", + path: ["customRfqType"], +}) + +type CreateGeneralRfqFormValues = z.infer<typeof createGeneralRfqSchema> + +interface CreateGeneralRfqDialogProps { + onSuccess?: () => void; +} + +export function CreateGeneralRfqDialog({ onSuccess }: CreateGeneralRfqDialogProps) { + const [open, setOpen] = React.useState(false) + const [isLoading, setIsLoading] = React.useState(false) + const [users, setUsers] = React.useState<Array<{ id: number; name: string }>>([]) + const [isLoadingUsers, setIsLoadingUsers] = React.useState(false) + const [userPopoverOpen, setUserPopoverOpen] = React.useState(false) + const [userSearchTerm, setUserSearchTerm] = React.useState("") + const [previewCode, setPreviewCode] = React.useState("") + const [isLoadingPreview, setIsLoadingPreview] = React.useState(false) + const userOptionIdsRef = React.useRef<Record<number, string>>({}) + const router = useRouter() + const { data: session } = useSession() + + // 고유 ID 생성 + const buttonId = React.useMemo(() => `user-button-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, []) + const popoverContentId = React.useMemo(() => `user-popover-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, []) + const commandId = React.useMemo(() => `user-command-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, []) + + const userId = React.useMemo(() => { + return session?.user?.id ? Number(session.user.id) : null; + }, [session]); + + const form = useForm<CreateGeneralRfqFormValues>({ + resolver: zodResolver(createGeneralRfqSchema), + defaultValues: { + rfqType: "", + customRfqType: "", + rfqTitle: "", + dueDate: undefined, + picUserId: userId || undefined, + remark: "", + items: [ + { + itemCode: "", + itemName: "", + quantity: 1, + uom: "", + remark: "", + }, + ], + }, + }) + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "items", + }) + + // 견적 종류 변경 시 customRfqType 필드 초기화 + const handleRfqTypeChange = (value: string) => { + form.setValue("rfqType", value) + if (value !== "기타") { + form.setValue("customRfqType", "") + } + } + + // RFQ 코드 미리보기 생성 + const generatePreviewCode = React.useCallback(async () => { + const picUserId = form.watch("picUserId") + + if (!picUserId) { + setPreviewCode("") + return + } + + setIsLoadingPreview(true) + try { + const code = await previewGeneralRfqCode(picUserId) + setPreviewCode(code) + } catch (error) { + console.error("코드 미리보기 오류:", error) + setPreviewCode("") + } finally { + setIsLoadingPreview(false) + } + }, [form]) + + // 필드 변경 감지해서 미리보기 업데이트 + React.useEffect(() => { + const subscription = form.watch((value, { name }) => { + if (name === "picUserId") { + generatePreviewCode() + } + }) + return () => subscription.unsubscribe() + }, [form, generatePreviewCode]) + + // 사용자 목록 로드 + React.useEffect(() => { + const loadUsers = async () => { + setIsLoadingUsers(true) + try { + const userList = await getPUsersForFilter() + setUsers(userList) + } catch (error) { + console.log("사용자 목록 로드 오류:", error) + toast.error("사용자 목록을 불러오는데 실패했습니다") + } finally { + setIsLoadingUsers(false) + } + } + loadUsers() + }, []) + + // 세션 사용자 ID로 기본값 설정 + React.useEffect(() => { + if (userId && !form.getValues("picUserId")) { + form.setValue("picUserId", userId) + } + }, [userId, form]) + + // 사용자 검색 필터링 + const userOptions = React.useMemo(() => { + return users.filter((user) => + user.name.toLowerCase().includes(userSearchTerm.toLowerCase()) + ) + }, [users, userSearchTerm]) + + // 선택된 사용자 찾기 + const selectedUser = React.useMemo(() => { + const picUserId = form.watch("picUserId") + return users.find(user => user.id === picUserId) + }, [users, form.watch("picUserId")]) + + // 사용자 선택 핸들러 + const handleSelectUser = (user: { id: number; name: string }) => { + form.setValue("picUserId", user.id) + setUserPopoverOpen(false) + setUserSearchTerm("") + } + + // 다이얼로그 열림/닫힘 처리 및 폼 리셋 + const handleOpenChange = (newOpen: boolean) => { + setOpen(newOpen) + + // 다이얼로그가 닫힐 때 폼과 상태 초기화 + if (!newOpen) { + form.reset({ + rfqType: "", + customRfqType: "", + rfqTitle: "", + dueDate: undefined, + picUserId: userId || undefined, + remark: "", + items: [ + { + itemCode: "", + itemName: "", + quantity: 1, + uom: "", + remark: "", + }, + ], + }) + setUserSearchTerm("") + setUserPopoverOpen(false) + setPreviewCode("") + setIsLoadingPreview(false) + } + } + + const handleCancel = () => { + form.reset() + setOpen(false) + } + + const onSubmit = async (data: CreateGeneralRfqFormValues) => { + if (!userId) { + toast.error("로그인이 필요합니다") + return + } + + setIsLoading(true) + + try { + // 견적 종류가 "기타"인 경우 customRfqType 사용 + const finalRfqType = data.rfqType === "기타" ? data.customRfqType || "기타" : data.rfqType + + // 서버 액션 호출 + const result = await createGeneralRfqAction({ + rfqType: finalRfqType, + rfqTitle: data.rfqTitle, + dueDate: data.dueDate, + picUserId: data.picUserId, + remark: data.remark || "", + items: data.items, + createdBy: userId, + updatedBy: userId, + }) + + if (result.success) { + toast.success(result.message, { + description: `RFQ 코드: ${result.data?.rfqCode}`, + }) + + // 다이얼로그 닫기 + setOpen(false) + + // 성공 콜백 실행 + if (onSuccess) { + onSuccess() + } + + // RFQ 상세 페이지로 이동 (선택사항) + // router.push(`/rfq/${result.data?.rfqCode}`) + + } else { + toast.error(result.error || "일반견적 생성에 실패했습니다") + } + + } catch (error) { + console.error('일반견적 생성 오류:', error) + toast.error("일반견적 생성에 실패했습니다", { + description: "알 수 없는 오류가 발생했습니다", + }) + } finally { + setIsLoading(false) + } + } + + // 아이템 추가 + const handleAddItem = () => { + append({ + itemCode: "", + itemName: "", + quantity: 1, + uom: "", + remark: "", + }) + } + + const isCustomRfqType = form.watch("rfqType") === "기타" + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogTrigger asChild> + <Button variant="samsung" size="sm" className="h-8 px-2 lg:px-3"> + <Plus className="mr-2 h-4 w-4" /> + 일반견적 생성 + </Button> + </DialogTrigger> + <DialogContent className="max-w-4xl h-[90vh] flex flex-col"> + {/* 고정된 헤더 */} + <DialogHeader className="flex-shrink-0"> + <DialogTitle>일반견적 생성</DialogTitle> + <DialogDescription> + 새로운 일반견적을 생성합니다. 필수 정보를 입력해주세요. + </DialogDescription> + </DialogHeader> + + {/* 스크롤 가능한 컨텐츠 영역 */} + <ScrollArea className="flex-1 px-1"> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 py-2"> + + {/* 기본 정보 섹션 */} + <div className="space-y-4"> + <h3 className="text-lg font-semibold">기본 정보</h3> + + <div className="grid grid-cols-2 gap-4"> + {/* 견적 종류 */} + <div className="space-y-2"> + <FormField + control={form.control} + name="rfqType" + render={({ field }) => ( + <FormItem> + <FormLabel> + 견적 종류 <span className="text-red-500">*</span> + </FormLabel> + <Select onValueChange={handleRfqTypeChange} value={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="견적 종류 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="정기견적">정기견적</SelectItem> + <SelectItem value="긴급견적">긴급견적</SelectItem> + <SelectItem value="단가계약">단가계약</SelectItem> + <SelectItem value="기술견적">기술견적</SelectItem> + <SelectItem value="예산견적">예산견적</SelectItem> + <SelectItem value="기타">기타</SelectItem> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 기타 견적 종류 입력 필드 */} + {isCustomRfqType && ( + <FormField + control={form.control} + name="customRfqType" + render={({ field }) => ( + <FormItem> + <FormLabel> + 견적 종류 직접 입력 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input + placeholder="견적 종류를 입력해주세요" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + )} + </div> + + {/* 마감일 */} + <FormField + control={form.control} + name="dueDate" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel> + 마감일 <span className="text-red-500">*</span> + </FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + className={cn( + "w-full pl-3 text-left font-normal", + !field.value && "text-muted-foreground" + )} + > + {field.value ? ( + format(field.value, "yyyy-MM-dd") + ) : ( + <span>마감일을 선택하세요</span> + )} + <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <Calendar + mode="single" + selected={field.value} + onSelect={field.onChange} + disabled={(date) => + date < new Date() || date < new Date("1900-01-01") + } + initialFocus + /> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + </div> + + {/* 견적명 */} + <FormField + control={form.control} + name="rfqTitle" + render={({ field }) => ( + <FormItem> + <FormLabel> + 견적명 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input + placeholder="예: 2025년 1분기 사무용품 구매 견적" + {...field} + /> + </FormControl> + <FormDescription> + 견적의 목적이나 내용을 간단명료하게 입력해주세요 + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + {/* 구매 담당자 - 검색 가능한 셀렉터로 변경 */} + <FormField + control={form.control} + name="picUserId" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel> + 구매 담당자 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Popover open={userPopoverOpen} onOpenChange={setUserPopoverOpen}> + <PopoverTrigger asChild> + <Button + key={buttonId} + type="button" + variant="outline" + className="w-full justify-between relative h-9" + disabled={isLoadingUsers} + > + {isLoadingUsers ? ( + <> + <span>담당자 로딩 중...</span> + <Loader2 className="ml-2 h-4 w-4 animate-spin" /> + </> + ) : ( + <> + <span className="truncate mr-1 flex-grow text-left"> + {selectedUser ? selectedUser.name : "구매 담당자를 선택하세요"} + </span> + <ChevronsUpDown className="h-4 w-4 opacity-50 flex-shrink-0" /> + </> + )} + </Button> + </PopoverTrigger> + <PopoverContent key={popoverContentId} className="w-[300px] p-0"> + <Command key={commandId}> + <CommandInput + key={`${commandId}-input`} + placeholder="담당자 검색..." + value={userSearchTerm} + onValueChange={setUserSearchTerm} + /> + <CommandList + key={`${commandId}-list`} + className="max-h-[300px]" + onWheel={(e) => { + e.stopPropagation(); // 이벤트 전파 차단 + const target = e.currentTarget; + target.scrollTop += e.deltaY; // 직접 스크롤 처리 + }} + > + <CommandEmpty key={`${commandId}-empty`}>담당자를 찾을 수 없습니다.</CommandEmpty> + <CommandGroup key={`${commandId}-group`}> + {userOptions.map((user, userIndex) => { + if (!userOptionIdsRef.current[user.id]) { + userOptionIdsRef.current[user.id] = + `user-${user.id}-${Date.now()}-${Math.random() + .toString(36) + .slice(2, 9)}` + } + const optionId = userOptionIdsRef.current[user.id] + + return ( + <CommandItem + key={`${optionId}-${userIndex}`} + onSelect={() => handleSelectUser(user)} + value={user.name} + className="truncate" + title={user.name} + > + <span className="truncate">{user.name}</span> + <Check + key={`${optionId}-check`} + className={cn( + "ml-auto h-4 w-4 flex-shrink-0", + selectedUser?.id === user.id ? "opacity-100" : "opacity-0" + )} + /> + </CommandItem> + ) + })} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + </FormControl> + {/* RFQ 코드 미리보기 */} + {previewCode && ( + <div className="flex items-center gap-2 mt-2"> + <Badge variant="secondary" className="font-mono text-sm"> + 예상 RFQ 코드: {previewCode} + </Badge> + {isLoadingPreview && ( + <Loader2 className="h-3 w-3 animate-spin" /> + )} + </div> + )} + <FormMessage /> + </FormItem> + )} + /> + + {/* 비고 */} + <FormField + control={form.control} + name="remark" + render={({ field }) => ( + <FormItem> + <FormLabel>비고</FormLabel> + <FormControl> + <Textarea + placeholder="추가 비고사항을 입력하세요" + className="resize-none" + rows={3} + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <Separator /> + + {/* 아이템 정보 섹션 - 컴팩트한 UI */} + <div className="space-y-4"> + <div className="flex items-center justify-between"> + <h3 className="text-lg font-semibold">아이템 정보</h3> + <Button + type="button" + variant="outline" + size="sm" + onClick={handleAddItem} + > + <PlusCircle className="mr-2 h-4 w-4" /> + 아이템 추가 + </Button> + </div> + + <div className="space-y-3"> + {fields.map((field, index) => ( + <div key={field.id} className="border rounded-lg p-3 bg-gray-50/50"> + <div className="flex items-center justify-between mb-3"> + <span className="text-sm font-medium text-gray-700"> + 아이템 #{index + 1} + </span> + {fields.length > 1 && ( + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => remove(index)} + className="h-6 w-6 p-0 text-destructive hover:text-destructive" + > + <Trash2 className="h-3 w-3" /> + </Button> + )} + </div> + + <div className="grid grid-cols-4 gap-3"> + {/* 자재코드 */} + <FormField + control={form.control} + name={`items.${index}.itemCode`} + render={({ field }) => ( + <FormItem> + <FormLabel className="text-xs"> + 자재코드 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input + placeholder="MAT-001" + className="h-8 text-sm" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 자재명 */} + <FormField + control={form.control} + name={`items.${index}.itemName`} + render={({ field }) => ( + <FormItem> + <FormLabel className="text-xs"> + 자재명 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input + placeholder="A4 용지" + className="h-8 text-sm" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 수량 */} + <FormField + control={form.control} + name={`items.${index}.quantity`} + render={({ field }) => ( + <FormItem> + <FormLabel className="text-xs"> + 수량 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input + type="number" + min="1" + placeholder="1" + className="h-8 text-sm" + {...field} + onChange={(e) => field.onChange(Number(e.target.value))} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 단위 */} + <FormField + control={form.control} + name={`items.${index}.uom`} + render={({ field }) => ( + <FormItem> + <FormLabel className="text-xs"> + 단위 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input + placeholder="EA" + className="h-8 text-sm" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + {/* 비고 - 별도 행에 배치 */} + <div className="mt-3"> + <FormField + control={form.control} + name={`items.${index}.remark`} + render={({ field }) => ( + <FormItem> + <FormLabel className="text-xs">비고</FormLabel> + <FormControl> + <Input + placeholder="아이템별 비고사항" + className="h-8 text-sm" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </div> + ))} + </div> + </div> + </form> + </Form> + </ScrollArea> + + {/* 고정된 푸터 */} + <DialogFooter className="flex-shrink-0"> + <Button + type="button" + variant="outline" + onClick={handleCancel} + disabled={isLoading} + > + 취소 + </Button> + <Button + type="submit" + onClick={form.handleSubmit(onSubmit)} + disabled={isLoading} + > + {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + {isLoading ? "생성 중..." : "일반견적 생성"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/rfq-last/table/rfq-attachments-dialog.tsx b/lib/rfq-last/table/rfq-attachments-dialog.tsx new file mode 100644 index 00000000..253daaa2 --- /dev/null +++ b/lib/rfq-last/table/rfq-attachments-dialog.tsx @@ -0,0 +1,351 @@ +"use client" + +import * as React from "react" +import { format } from "date-fns" +import { Download, FileText, Eye, ExternalLink, Loader2 } from "lucide-react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "sonner" +import { RfqsLastView } from "@/db/schema" +import { getRfqAttachmentsAction } from "../service" +import { downloadFile, quickPreview, smartFileAction, formatFileSize, getFileInfo } from "@/lib/file-download" + +// 첨부파일 타입 +interface RfqAttachment { + attachmentId: number + attachmentType: string + serialNo: string + description: string | null + currentRevision: string + fileName: string + originalFileName: string + filePath: string + fileSize: number | null + fileType: string | null + createdByName: string | null + createdAt: Date | null + updatedAt: Date | null + revisionComment?: string | null +} + +interface RfqAttachmentsDialogProps { + isOpen: boolean + onClose: () => void + rfqData: RfqsLastView +} + +export function RfqAttachmentsDialog({ isOpen, onClose, rfqData }: RfqAttachmentsDialogProps) { + const [attachments, setAttachments] = React.useState<RfqAttachment[]>([]) + const [isLoading, setIsLoading] = React.useState(false) + const [downloadingFiles, setDownloadingFiles] = React.useState<Set<number>>(new Set()) + + // 첨부파일 목록 로드 + React.useEffect(() => { + if (!isOpen || !rfqData.id) return + + const loadAttachments = async () => { + setIsLoading(true) + try { + const result = await getRfqAttachmentsAction(rfqData.id) + + if (result.success) { + setAttachments(result.data) + } else { + toast.error(result.error || "첨부파일을 불러오는데 실패했습니다") + setAttachments([]) + } + } catch (error) { + console.error("첨부파일 로드 오류:", error) + toast.error("첨부파일을 불러오는데 실패했습니다") + setAttachments([]) + } finally { + setIsLoading(false) + } + } + + loadAttachments() + }, [isOpen, rfqData.id]) + + // 파일 다운로드 핸들러 + const handleDownload = async (attachment: RfqAttachment) => { + const attachmentId = attachment.attachmentId + setDownloadingFiles(prev => new Set([...prev, attachmentId])) + + try { + const result = await downloadFile( + attachment.filePath, + attachment.originalFileName, + { + action: 'download', + showToast: true, + showSuccessToast: true, + onSuccess: (fileName, fileSize) => { + console.log(`다운로드 완료: ${fileName} (${formatFileSize(fileSize || 0)})`) + }, + onError: (error) => { + console.error(`다운로드 실패: ${error}`) + } + } + ) + + if (!result.success) { + console.error("다운로드 결과:", result) + } + } catch (error) { + console.error("파일 다운로드 오류:", error) + toast.error("파일 다운로드에 실패했습니다") + } finally { + setDownloadingFiles(prev => { + const newSet = new Set(prev) + newSet.delete(attachmentId) + return newSet + }) + } + } + + // 파일 미리보기 핸들러 + const handlePreview = async (attachment: RfqAttachment) => { + const fileInfo = getFileInfo(attachment.originalFileName) + + if (!fileInfo.canPreview) { + toast.info("이 파일 형식은 미리보기를 지원하지 않습니다. 다운로드를 진행합니다.") + return handleDownload(attachment) + } + + try { + const result = await quickPreview(attachment.filePath, attachment.originalFileName) + + if (!result.success) { + console.error("미리보기 결과:", result) + } + } catch (error) { + console.error("파일 미리보기 오류:", error) + toast.error("파일 미리보기에 실패했습니다") + } + } + + // 스마트 파일 액션 (미리보기 가능하면 미리보기, 아니면 다운로드) + const handleSmartAction = async (attachment: RfqAttachment) => { + const attachmentId = attachment.attachmentId + const fileInfo = getFileInfo(attachment.originalFileName) + + if (fileInfo.canPreview) { + return handlePreview(attachment) + } else { + return handleDownload(attachment) + } + } + + // 첨부파일 타입별 색상 + const getAttachmentTypeBadgeVariant = (type: string) => { + switch (type.toLowerCase()) { + case "견적요청서": return "default" + case "기술사양서": return "secondary" + case "도면": return "outline" + default: return "outline" + } + } + + return ( + <Dialog open={isOpen} onOpenChange={onClose}> + <DialogContent className="max-w-6xl h-[85vh] flex flex-col"> + <DialogHeader> + <DialogTitle>견적 첨부파일</DialogTitle> + <DialogDescription> + {rfqData.rfqCode} - {rfqData.rfqTitle || rfqData.itemName || "견적"} + {attachments.length > 0 && ` (${attachments.length}개 파일)`} + </DialogDescription> + </DialogHeader> + + <ScrollArea className="flex-1"> + {isLoading ? ( + <div className="space-y-3"> + {[...Array(3)].map((_, i) => ( + <div key={i} className="flex items-center space-x-4 p-3 border rounded-lg"> + <Skeleton className="h-8 w-8" /> + <div className="space-y-2 flex-1"> + <Skeleton className="h-4 w-[300px]" /> + <Skeleton className="h-3 w-[200px]" /> + </div> + <div className="flex gap-2"> + <Skeleton className="h-8 w-20" /> + <Skeleton className="h-8 w-20" /> + </div> + </div> + ))} + </div> + ) : ( + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[120px]">타입</TableHead> + <TableHead>파일명</TableHead> + <TableHead>설명</TableHead> + <TableHead className="w-[90px]">리비전</TableHead> + <TableHead className="w-[100px]">크기</TableHead> + <TableHead className="w-[120px]">생성자</TableHead> + <TableHead className="w-[120px]">생성일</TableHead> + <TableHead className="w-[140px]">액션</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {attachments.length === 0 ? ( + <TableRow> + <TableCell colSpan={8} className="text-center text-muted-foreground py-12"> + <div className="flex flex-col items-center gap-2"> + <FileText className="h-8 w-8 text-muted-foreground" /> + <span>첨부된 파일이 없습니다.</span> + </div> + </TableCell> + </TableRow> + ) : ( + attachments.map((attachment) => { + const fileInfo = getFileInfo(attachment.originalFileName) + const isDownloading = downloadingFiles.has(attachment.attachmentId) + + return ( + <TableRow key={attachment.attachmentId}> + <TableCell> + <Badge + variant={getAttachmentTypeBadgeVariant(attachment.attachmentType)} + className="text-xs" + > + {attachment.attachmentType} + </Badge> + </TableCell> + <TableCell> + <div className="flex items-center gap-2"> + <span className="text-lg">{fileInfo.icon}</span> + <div className="flex flex-col min-w-0"> + <span className="text-sm font-medium truncate" title={attachment.originalFileName}> + {attachment.originalFileName} + </span> + {attachment.fileName !== attachment.originalFileName && ( + <span className="text-xs text-muted-foreground truncate" title={attachment.fileName}> + {attachment.fileName} + </span> + )} + </div> + </div> + </TableCell> + <TableCell> + <span className="text-sm" title={attachment.description || ""}> + {attachment.description || "-"} + </span> + {attachment.revisionComment && ( + <div className="text-xs text-muted-foreground mt-1"> + {attachment.revisionComment} + </div> + )} + </TableCell> + <TableCell> + <Badge variant="secondary" className="font-mono text-xs"> + {attachment.currentRevision} + </Badge> + </TableCell> + <TableCell className="text-xs text-muted-foreground"> + {attachment.fileSize ? formatFileSize(attachment.fileSize) : "-"} + </TableCell> + <TableCell className="text-sm"> + {attachment.createdByName || "-"} + </TableCell> + <TableCell className="text-xs text-muted-foreground"> + {attachment.createdAt ? format(new Date(attachment.createdAt), "MM-dd HH:mm") : "-"} + </TableCell> + <TableCell> + <div className="flex items-center gap-1"> + {/* 미리보기 버튼 (미리보기 가능한 파일만) */} + {fileInfo.canPreview && ( + <Button + variant="ghost" + size="sm" + onClick={() => handlePreview(attachment)} + disabled={isDownloading} + title="미리보기" + > + <Eye className="h-4 w-4" /> + </Button> + )} + + {/* 다운로드 버튼 */} + <Button + variant="ghost" + size="sm" + onClick={() => handleDownload(attachment)} + disabled={isDownloading} + title="다운로드" + > + {isDownloading ? ( + <Loader2 className="h-4 w-4 animate-spin" /> + ) : ( + <Download className="h-4 w-4" /> + )} + </Button> + + {/* 스마트 액션 버튼 (메인 액션) */} + <Button + variant="outline" + size="sm" + onClick={() => handleSmartAction(attachment)} + disabled={isDownloading} + className="ml-1" + > + {isDownloading ? ( + <Loader2 className="h-4 w-4 animate-spin mr-1" /> + ) : fileInfo.canPreview ? ( + <Eye className="h-4 w-4 mr-1" /> + ) : ( + <Download className="h-4 w-4 mr-1" /> + )} + {fileInfo.canPreview ? "보기" : "다운로드"} + </Button> + </div> + </TableCell> + </TableRow> + ) + }) + )} + </TableBody> + </Table> + )} + </ScrollArea> + + {/* 하단 정보 */} + {attachments.length > 0 && !isLoading && ( + <div className="border-t pt-4 text-xs text-muted-foreground"> + <div className="flex justify-between items-center"> + <span> + 총 {attachments.length}개 파일 + {attachments.some(a => a.fileSize) && + ` · 전체 크기: ${formatFileSize( + attachments.reduce((sum, a) => sum + (a.fileSize || 0), 0) + )}` + } + </span> + <span> + {fileInfo.icon} 미리보기 가능 | 📥 다운로드 + </span> + </div> + </div> + )} + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/rfq-last/table/rfq-items-dialog.tsx b/lib/rfq-last/table/rfq-items-dialog.tsx new file mode 100644 index 00000000..5d7e0747 --- /dev/null +++ b/lib/rfq-last/table/rfq-items-dialog.tsx @@ -0,0 +1,354 @@ +"use client" + +import * as React from "react" +import { format } from "date-fns" +import { Package, ExternalLink } from "lucide-react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Skeleton } from "@/components/ui/skeleton" +import { Separator } from "@/components/ui/separator" +import { toast } from "sonner" +import { RfqsLastView } from "@/db/schema" +import { getRfqItemsAction } from "../service" + +// 품목 타입 +interface RfqItem { + id: number + rfqsLastId: number | null + rfqItem: string | null + prItem: string | null + prNo: string | null + materialCode: string | null + materialCategory: string | null + acc: string | null + materialDescription: string | null + size: string | null + deliveryDate: Date | null + quantity: number | null + uom: string | null + grossWeight: number | null + gwUom: string | null + specNo: string | null + specUrl: string | null + trackingNo: string | null + majorYn: boolean | null + remark: string | null + projectDef: string | null + projectSc: string | null + projectKl: string | null + projectLc: string | null + projectDl: string | null + // RFQ 관련 정보 + rfqCode: string | null + rfqType: string | null + rfqTitle: string | null + itemCode: string | null + itemName: string | null + projectCode: string | null + projectName: string | null +} + +interface ItemStatistics { + total: number + major: number + regular: number + totalQuantity: number + totalWeight: number +} + +interface RfqItemsDialogProps { + isOpen: boolean + onClose: () => void + rfqData: RfqsLastView +} + +export function RfqItemsDialog({ isOpen, onClose, rfqData }: RfqItemsDialogProps) { + const [items, setItems] = React.useState<RfqItem[]>([]) + const [statistics, setStatistics] = React.useState<ItemStatistics | null>(null) + const [isLoading, setIsLoading] = React.useState(false) + + // 품목 목록 로드 + React.useEffect(() => { + if (!isOpen || !rfqData.id) return + + const loadItems = async () => { + setIsLoading(true) + try { + const result = await getRfqItemsAction(rfqData.id) + + if (result.success) { + setItems(result.data) + setStatistics(result.statistics) + } else { + toast.error(result.error || "품목을 불러오는데 실패했습니다") + setItems([]) + setStatistics(null) + } + } catch (error) { + console.error("품목 로드 오류:", error) + toast.error("품목을 불러오는데 실패했습니다") + setItems([]) + setStatistics(null) + } finally { + setIsLoading(false) + } + } + + loadItems() + }, [isOpen, rfqData.id]) + + // 사양서 링크 열기 + const handleOpenSpec = (specUrl: string) => { + window.open(specUrl, '_blank', 'noopener,noreferrer') + } + + // 수량 포맷팅 + const formatQuantity = (quantity: number | null, uom: string | null) => { + if (!quantity) return "-" + return `${quantity.toLocaleString()}${uom ? ` ${uom}` : ""}` + } + + // 중량 포맷팅 + const formatWeight = (weight: number | null, uom: string | null) => { + if (!weight) return "-" + return `${weight.toLocaleString()} ${uom || "KG"}` + } + + return ( + <Dialog open={isOpen} onOpenChange={onClose}> + <DialogContent className="max-w-7xl h-[90vh] flex flex-col"> + <DialogHeader> + <DialogTitle>견적 품목 목록</DialogTitle> + <DialogDescription> + {rfqData.rfqCode} - {rfqData.rfqTitle || rfqData.itemName || "품목 정보"} + </DialogDescription> + </DialogHeader> + + {/* 통계 정보 */} + {statistics && !isLoading && ( + <> + <div className="grid grid-cols-5 gap-3 py-3"> + <div className="text-center"> + <div className="text-2xl font-bold text-primary">{statistics.total}</div> + <div className="text-xs text-muted-foreground">전체 품목</div> + </div> + <div className="text-center"> + <div className="text-2xl font-bold text-blue-600">{statistics.major}</div> + <div className="text-xs text-muted-foreground">주요 품목</div> + </div> + <div className="text-center"> + <div className="text-2xl font-bold text-gray-600">{statistics.regular}</div> + <div className="text-xs text-muted-foreground">일반 품목</div> + </div> + <div className="text-center"> + <div className="text-2xl font-bold text-green-600">{statistics.totalQuantity.toLocaleString()}</div> + <div className="text-xs text-muted-foreground">총 수량</div> + </div> + <div className="text-center"> + <div className="text-2xl font-bold text-orange-600">{statistics.totalWeight.toLocaleString()}</div> + <div className="text-xs text-muted-foreground">총 중량 (KG)</div> + </div> + </div> + <Separator /> + </> + )} + + <ScrollArea className="flex-1"> + {isLoading ? ( + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[60px]">구분</TableHead> + <TableHead className="w-[120px]">자재코드</TableHead> + <TableHead>자재명</TableHead> + <TableHead className="w-[100px]">수량</TableHead> + <TableHead className="w-[100px]">중량</TableHead> + <TableHead className="w-[100px]">납기일</TableHead> + <TableHead className="w-[100px]">PR번호</TableHead> + <TableHead className="w-[80px]">사양</TableHead> + <TableHead>비고</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {[...Array(3)].map((_, i) => ( + <TableRow key={i}> + <TableCell><Skeleton className="h-8 w-full" /></TableCell> + <TableCell><Skeleton className="h-8 w-full" /></TableCell> + <TableCell><Skeleton className="h-8 w-full" /></TableCell> + <TableCell><Skeleton className="h-8 w-full" /></TableCell> + <TableCell><Skeleton className="h-8 w-full" /></TableCell> + <TableCell><Skeleton className="h-8 w-full" /></TableCell> + <TableCell><Skeleton className="h-8 w-full" /></TableCell> + <TableCell><Skeleton className="h-8 w-full" /></TableCell> + <TableCell><Skeleton className="h-8 w-full" /></TableCell> + </TableRow> + ))} + </TableBody> + </Table> + ) : items.length === 0 ? ( + <div className="text-center text-muted-foreground py-12"> + <Package className="h-12 w-12 mx-auto mb-4 text-muted-foreground" /> + <p>품목이 없습니다.</p> + </div> + ) : ( + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[60px]">구분</TableHead> + <TableHead className="w-[120px]">자재코드</TableHead> + <TableHead>자재명</TableHead> + <TableHead className="w-[100px]">수량</TableHead> + <TableHead className="w-[100px]">중량</TableHead> + <TableHead className="w-[100px]">납기일</TableHead> + <TableHead className="w-[100px]">PR번호</TableHead> + <TableHead className="w-[100px]">사양</TableHead> + <TableHead className="w-[100px]">프로젝트</TableHead> + <TableHead>비고</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {items.map((item, index) => ( + <TableRow key={item.id} className={item.majorYn ? "bg-blue-50 border-l-4 border-l-blue-500" : ""}> + <TableCell> + <div className="flex flex-col items-center gap-1"> + <span className="text-xs font-mono">#{index + 1}</span> + {item.majorYn && ( + <Badge variant="default" className="text-xs px-1 py-0"> + 주요 + </Badge> + )} + </div> + </TableCell> + <TableCell> + <div className="flex flex-col"> + <span className="font-mono text-sm font-medium">{item.materialCode || "-"}</span> + {item.acc && ( + <span className="text-xs text-muted-foreground font-mono"> + ACC: {item.acc} + </span> + )} + </div> + </TableCell> + <TableCell> + <div className="flex flex-col"> + <span className="text-sm font-medium" title={item.materialDescription || ""}> + {item.materialDescription || "-"} + </span> + {item.materialCategory && ( + <span className="text-xs text-muted-foreground"> + {item.materialCategory} + </span> + )} + {item.size && ( + <span className="text-xs text-muted-foreground"> + 크기: {item.size} + </span> + )} + </div> + </TableCell> + <TableCell> + <span className="text-sm font-medium"> + {formatQuantity(item.quantity, item.uom)} + </span> + </TableCell> + <TableCell> + <span className="text-sm"> + {formatWeight(item.grossWeight, item.gwUom)} + </span> + </TableCell> + <TableCell> + <span className="text-sm"> + {item.deliveryDate ? format(new Date(item.deliveryDate), "yyyy-MM-dd") : "-"} + </span> + </TableCell> + <TableCell> + <div className="flex flex-col"> + <span className="text-xs font-mono">{item.prNo || "-"}</span> + {item.prItem && item.prItem !== item.prNo && ( + <span className="text-xs text-muted-foreground font-mono"> + {item.prItem} + </span> + )} + </div> + </TableCell> + <TableCell> + <div className="flex items-center gap-1"> + {item.specNo && ( + <span className="text-xs font-mono">{item.specNo}</span> + )} + {item.specUrl && ( + <Button + variant="ghost" + size="sm" + className="h-5 w-5 p-0" + onClick={() => handleOpenSpec(item.specUrl!)} + title="사양서 열기" + > + <ExternalLink className="h-3 w-3" /> + </Button> + )} + {item.trackingNo && ( + <div className="text-xs text-muted-foreground"> + TRK: {item.trackingNo} + </div> + )} + </div> + </TableCell> + <TableCell> + <div className="text-xs"> + {[ + item.projectDef && `DEF: ${item.projectDef}`, + item.projectSc && `SC: ${item.projectSc}`, + item.projectKl && `KL: ${item.projectKl}`, + item.projectLc && `LC: ${item.projectLc}`, + item.projectDl && `DL: ${item.projectDl}` + ].filter(Boolean).join(" | ") || "-"} + </div> + </TableCell> + <TableCell> + <span className="text-xs" title={item.remark || ""}> + {item.remark ? (item.remark.length > 30 ? `${item.remark.slice(0, 30)}...` : item.remark) : "-"} + </span> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + )} + </ScrollArea> + + {/* 하단 통계 정보 */} + {statistics && !isLoading && ( + <div className="border-t pt-3 text-xs text-muted-foreground"> + <div className="flex justify-between items-center"> + <span> + 총 {statistics.total}개 품목 + (주요: {statistics.major}개, 일반: {statistics.regular}개) + </span> + <span> + 전체 수량: {statistics.totalQuantity.toLocaleString()} | + 전체 중량: {statistics.totalWeight.toLocaleString()} KG + </span> + </div> + </div> + )} + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/rfq-last/table/rfq-table-columns.tsx b/lib/rfq-last/table/rfq-table-columns.tsx index 3fac8881..1b523adc 100644 --- a/lib/rfq-last/table/rfq-table-columns.tsx +++ b/lib/rfq-last/table/rfq-table-columns.tsx @@ -5,7 +5,7 @@ import { type ColumnDef } from "@tanstack/react-table"; import { Checkbox } from "@/components/ui/checkbox"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Eye, FileText, Send, Package, Users, ChevronRight } from "lucide-react"; +import { Eye, FileText, Send, Lock, LockOpen } from "lucide-react"; import { Tooltip, TooltipContent, @@ -17,10 +17,14 @@ import { RfqsLastView } from "@/db/schema"; import { DataTableRowAction } from "@/types/table"; import { format } from "date-fns"; import { ko } from "date-fns/locale"; +import { useRouter } from "next/navigation"; + +type NextRouter = ReturnType<typeof useRouter>; interface GetColumnsProps { setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<RfqsLastView> | null>>; - rfqCategory?: "all" | "general" | "itb" | "rfq"; + rfqCategory?: "general" | "itb" | "rfq"; + router: NextRouter; } // RFQ 상태별 색상 @@ -38,408 +42,914 @@ const getStatusBadgeVariant = (status: string) => { } }; -// 시리즈 배지 -const getSeriesBadge = (series: string | null) => { - if (!series) return null; - - const label = series === "SS" ? "시리즈 통합" : series === "II" ? "품목 통합" : series; - const variant = series === "SS" ? "default" : series === "II" ? "secondary" : "outline"; - - return <Badge variant={variant} className="text-xs">{label}</Badge>; -}; - export function getRfqColumns({ setRowAction, - rfqCategory = "all" + rfqCategory = "itb", + router }: GetColumnsProps): ColumnDef<RfqsLastView>[] { - - const baseColumns: ColumnDef<RfqsLastView>[] = [ - // ═══════════════════════════════════════════════════════════════ - // 선택 및 기본 정보 - // ═══════════════════════════════════════════════════════════════ - - // Checkbox - { - id: "select", - header: ({ table }) => ( - <Checkbox - checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")} - onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)} - aria-label="select all" - className="translate-y-0.5" - /> - ), - cell: ({ row }) => ( - <Checkbox - checked={row.getIsSelected()} - onCheckedChange={(v) => row.toggleSelected(!!v)} - aria-label="select row" - className="translate-y-0.5" - /> - ), - size: 40, - enableSorting: false, - enableHiding: false, - }, - - // RFQ 코드 - { - accessorKey: "rfqCode", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="RFQ 코드" />, - cell: ({ row }) => { - const rfqSealed = row.original.rfqSealedYn; - return ( - <div className="flex items-center gap-1"> - <span className="font-mono font-medium">{row.original.rfqCode}</span> - {rfqSealed && ( - <Badge variant="destructive" className="text-xs">봉인</Badge> - )} - </div> - ); - }, - size: 140, - }, - - // 상태 - { - accessorKey: "status", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="상태" />, - cell: ({ row }) => ( - <Badge variant={getStatusBadgeVariant(row.original.status)}> - {row.original.status} - </Badge> - ), - size: 120, - }, - - // ═══════════════════════════════════════════════════════════════ - // 일반견적 필드 (rfqCategory가 'general' 또는 'all'일 때만) - // ═══════════════════════════════════════════════════════════════ - ...(rfqCategory === "general" || rfqCategory === "all" ? [ - { - id: "rfqType", - accessorKey: "rfqType", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적 유형" />, - cell: ({ row }) => row.original.rfqType || "-", + + // ═══════════════════════════════════════════════════════════════ + // ITB 컬럼 정의 + // ═══════════════════════════════════════════════════════════════ + if (rfqCategory === "itb") { + return [ + // Checkbox + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")} + onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)} + aria-label="select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(v) => row.toggleSelected(!!v)} + aria-label="select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + }, + + // ITB No. + { + accessorKey: "rfqCode", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="ITB No." />, + cell: ({ row }) => ( + <span className="font-mono font-medium">{row.original.rfqCode}</span> + ), + size: 120, + }, + + // 상세 (액션 버튼) - 수정됨 + { + id: "detail", + header: "상세", + cell: ({ row }) => ( + <Button + variant="ghost" + size="icon" + className="h-8 w-8" + onClick={() => router.push(`/evcp/rfq-last/${row.original.id}`)} + > + <Eye className="h-4 w-4" /> + </Button> + ), + size: 60, + }, + + // 견적상태 + { + accessorKey: "status", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적상태" />, + cell: ({ row }) => ( + <Badge variant={getStatusBadgeVariant(row.original.status)}> + {row.original.status} + </Badge> + ), + size: 120, + }, + + // 견적 밀봉 + { + accessorKey: "rfqSealedYn", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적 밀봉" />, + cell: ({ row }) => { + const isSealed = row.original.rfqSealedYn; + return ( + <div className="flex justify-center"> + {isSealed ? ( + <Lock className="h-4 w-4 text-red-500" /> + ) : ( + <LockOpen className="h-4 w-4 text-gray-400" /> + )} + </div> + ); + }, + size: 80, + }, + + // 구매담당자 + { + accessorKey: "picUserName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="구매담당자" />, + cell: ({ row }) => row.original.picUserName || row.original.picName || "-", size: 100, }, + + // 프로젝트 (프로젝트명) { - id: "rfqTitle", - accessorKey: "rfqTitle", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적 제목" />, + accessorKey: "projectName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트 (프로젝트명)" />, cell: ({ row }) => ( - <div className="max-w-[200px] truncate" title={row.original.rfqTitle || ""}> - {row.original.rfqTitle || "-"} + <div className="flex flex-col"> + <span className="font-mono text-xs text-muted-foreground"> + {row.original.projectCode} + </span> + <span className="max-w-[200px] truncate" title={row.original.projectName || ""}> + {row.original.projectName || "-"} + </span> </div> ), - size: 200, + size: 220, + }, + + // 선급 + { + accessorKey: "classNo", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="선급" />, + cell: ({ row }) => row.original.classNo || "-", + size: 80, + }, + + // PKG No. (PKG명) + { + accessorKey: "packageName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="PKG No. (PKG명)" />, + cell: ({ row }) => ( + <div className="flex flex-col"> + <span className="font-mono text-xs text-muted-foreground"> + {row.original.packageNo} + </span> + <span className="max-w-[150px] truncate" title={row.original.packageName || ""}> + {row.original.packageName || "-"} + </span> + </div> + ), + size: 180, + }, + + // 자재그룹 (자재그룹명) + { + accessorKey: "itemName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재그룹 (자재그룹명)" />, + cell: ({ row }) => ( + <div className="flex flex-col"> + <span className="font-mono text-xs text-muted-foreground"> + {row.original.itemCode} + </span> + <span className="max-w-[150px] truncate" title={row.original.itemName || ""}> + {row.original.itemName || "-"} + </span> + </div> + ), + size: 180, + }, + + // SM Code + { + accessorKey: "smCode", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="SM Code" />, + cell: ({ row }) => row.original.smCode || "-", + size: 80, + }, + + // 견적문서 - 수정됨 + { + id: "rfqDocument", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적문서" />, + cell: ({ row }) => ( + <Button + variant="ghost" + size="sm" + onClick={() => setRowAction({ row, type: "attachment" })} + > + <FileText className="h-4 w-4" /> + </Button> + ), + size: 80, + }, + + // 견적생성일 + { + accessorKey: "createdAt", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적생성일" />, + cell: ({ row }) => { + const date = row.original.createdAt; + return date ? format(new Date(date), "yyyy-MM-dd") : "-"; + }, + size: 100, + }, + + // 견적발송일 + { + accessorKey: "rfqSendDate", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적발송일" />, + cell: ({ row }) => { + const date = row.original.rfqSendDate; + return date ? format(new Date(date), "yyyy-MM-dd") : "-"; + }, + size: 100, + }, + + // 견적마감일 + { + accessorKey: "dueDate", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적마감일" />, + cell: ({ row }) => { + const date = row.original.dueDate; + if (!date) return "-"; + + const now = new Date(); + const dueDate = new Date(date); + const isOverdue = now > dueDate; + + return ( + <span className={`text-sm ${isOverdue ? "text-red-600 font-medium" : ""}`}> + {format(dueDate, "yyyy-MM-dd")} + </span> + ); + }, + size: 100, + }, + + // 설계담당자 + { + accessorKey: "engPicName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="설계담당자" />, + cell: ({ row }) => row.original.engPicName || "-", + size: 100, }, - ] as ColumnDef<RfqsLastView>[] : []), - // ═══════════════════════════════════════════════════════════════ - // ITB 필드 (rfqCategory가 'itb' 또는 'all'일 때만) - // ═══════════════════════════════════════════════════════════════ - ...(rfqCategory === "itb" || rfqCategory === "all" ? [ + // 프로젝트 Company { - id: "projectCompany", accessorKey: "projectCompany", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트 회사" />, + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트 Company" />, cell: ({ row }) => row.original.projectCompany || "-", size: 120, }, + + // TBE 결과접수 + { + accessorKey: "tbeResultReceived", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="TBE 결과접수" />, + cell: ({ row }) => { + const received = row.original.quotationReceivedCount || 0; + const total = row.original.vendorCount || 0; + return `${received}/${total}`; + }, + size: 100, + }, + + // 프로젝트 Flag { - id: "projectFlag", accessorKey: "projectFlag", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트 플래그" />, + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트 Flag" />, cell: ({ row }) => row.original.projectFlag || "-", size: 100, }, + + // 프로젝트 Site { - id: "projectSite", accessorKey: "projectSite", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트 사이트" />, + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트 Site" />, cell: ({ row }) => row.original.projectSite || "-", + size: 100, + }, + + // 최종수정일 + { + accessorKey: "updatedAt", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종수정일" />, + cell: ({ row }) => { + const date = row.original.updatedAt; + return date ? format(new Date(date), "yyyy-MM-dd HH:mm") : "-"; + }, size: 120, }, + + // 최종수정자 { - id: "smCode", - accessorKey: "smCode", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="SM 코드" />, - cell: ({ row }) => row.original.smCode || "-", - size: 80, + accessorKey: "updatedByUserName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종수정자" />, + cell: ({ row }) => row.original.updatedByUserName || "-", + size: 100, }, - ] as ColumnDef<RfqsLastView>[] : []), - // ═══════════════════════════════════════════════════════════════ - // RFQ(PR) 필드 (rfqCategory가 'rfq' 또는 'all'일 때만) - // ═══════════════════════════════════════════════════════════════ - ...(rfqCategory === "rfq" || rfqCategory === "all" ? [ + // 비고 { - id: "prNumber", - accessorKey: "prNumber", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="PR 번호" />, + accessorKey: "remark", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="비고" />, + cell: ({ row }) => row.original.remark || "-", + size: 150, + }, + ]; + } + + // ═══════════════════════════════════════════════════════════════ + // RFQ 컬럼 정의 + // ═══════════════════════════════════════════════════════════════ + if (rfqCategory === "rfq") { + return [ + // Checkbox + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")} + onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)} + aria-label="select all" + className="translate-y-0.5" + /> + ), cell: ({ row }) => ( - <span className="font-mono text-sm">{row.original.prNumber || "-"}</span> + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(v) => row.toggleSelected(!!v)} + aria-label="select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + }, + + // RFQ No. + { + accessorKey: "rfqCode", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="RFQ No." />, + cell: ({ row }) => ( + <span className="font-mono font-medium">{row.original.rfqCode}</span> ), size: 120, }, + + // 상세 - 수정됨 { - id: "prIssueDate", - accessorKey: "prIssueDate", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="PR 발행일" />, + id: "detail", + header: "상세", + cell: ({ row }) => ( + <Button + variant="ghost" + size="icon" + className="h-8 w-8" + onClick={() => router.push(`/evcp/rfq-last/${row.original.id}`)} + > + <Eye className="h-4 w-4" /> + </Button> + ), + size: 60, + }, + + // 견적상태 + { + accessorKey: "status", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적상태" />, + cell: ({ row }) => ( + <Badge variant={getStatusBadgeVariant(row.original.status)}> + {row.original.status} + </Badge> + ), + size: 120, + }, + + // 견적 밀봉 + { + accessorKey: "rfqSealedYn", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적 밀봉" />, cell: ({ row }) => { - const date = row.original.prIssueDate; - return date ? format(new Date(date), "yyyy-MM-dd") : "-"; + const isSealed = row.original.rfqSealedYn; + return ( + <div className="flex justify-center"> + {isSealed ? ( + <Lock className="h-4 w-4 text-red-500" /> + ) : ( + <LockOpen className="h-4 w-4 text-gray-400" /> + )} + </div> + ); }, + size: 80, + }, + + // 구매담당자 + { + accessorKey: "picUserName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="구매담당자" />, + cell: ({ row }) => row.original.picUserName || row.original.picName || "-", size: 100, }, + + // 프로젝트 (프로젝트명) + { + accessorKey: "projectName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트 (프로젝트명)" />, + cell: ({ row }) => ( + <div className="flex flex-col"> + <span className="font-mono text-xs text-muted-foreground"> + {row.original.projectCode} + </span> + <span className="max-w-[200px] truncate" title={row.original.projectName || ""}> + {row.original.projectName || "-"} + </span> + </div> + ), + size: 220, + }, + + // 시리즈 { - id: "series", accessorKey: "series", header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="시리즈" />, - cell: ({ row }) => getSeriesBadge(row.original.series), + cell: ({ row }) => { + const series = row.original.series; + if (!series) return "-"; + const label = series === "SS" ? "시리즈 통합" : series === "II" ? "품목 통합" : series; + return <Badge variant="outline">{label}</Badge>; + }, size: 100, }, - ] as ColumnDef<RfqsLastView>[] : []), - - // ═══════════════════════════════════════════════════════════════ - // 공통 프로젝트 정보 - // ═══════════════════════════════════════════════════════════════ - { - header: "프로젝트 정보", - columns: [ - { - accessorKey: "projectCode", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트 코드" />, - cell: ({ row }) => ( - <span className="font-mono text-sm">{row.original.projectCode || "-"}</span> - ), - size: 120, - }, - { - accessorKey: "projectName", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트명" />, - cell: ({ row }) => ( - <div className="max-w-[200px] truncate" title={row.original.projectName || ""}> - {row.original.projectName || "-"} - </div> - ), - size: 200, - }, - ] - }, - - // ═══════════════════════════════════════════════════════════════ - // 품목 정보 - // ═══════════════════════════════════════════════════════════════ - { - header: "품목 정보", - columns: [ - { - accessorKey: "itemCode", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재코드" />, - cell: ({ row }) => ( - <span className="font-mono text-sm">{row.original.itemCode || "-"}</span> - ), - size: 100, - }, - { - accessorKey: "itemName", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재명" />, - cell: ({ row }) => ( - <div className="max-w-[200px] truncate" title={row.original.itemName || ""}> + + // 선급 + { + accessorKey: "classNo", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="선급" />, + cell: ({ row }) => row.original.classNo || "-", + size: 80, + }, + + // PKG No. (PKG명) + { + accessorKey: "packageName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="PKG No. (PKG명)" />, + cell: ({ row }) => ( + <div className="flex flex-col"> + <span className="font-mono text-xs text-muted-foreground"> + {row.original.packageNo} + </span> + <span className="max-w-[150px] truncate" title={row.original.packageName || ""}> + {row.original.packageName || "-"} + </span> + </div> + ), + size: 180, + }, + + // 자재그룹 (자재그룹명) + { + accessorKey: "itemName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재그룹 (자재그룹명)" />, + cell: ({ row }) => ( + <div className="flex flex-col"> + <span className="font-mono text-xs text-muted-foreground"> + {row.original.itemCode} + </span> + <span className="max-w-[150px] truncate" title={row.original.itemName || ""}> {row.original.itemName || "-"} - </div> - ), - size: 200, - }, - { - accessorKey: "packageNo", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="패키지 번호" />, - cell: ({ row }) => row.original.packageNo || "-", - size: 100, + </span> + </div> + ), + size: 180, + }, + + // 자재코드 + { + accessorKey: "itemCode", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재코드" />, + cell: ({ row }) => ( + <span className="font-mono text-sm">{row.original.itemCode || "-"}</span> + ), + size: 100, + }, + + // 견적문서 - 수정됨 + { + id: "rfqDocument", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적문서" />, + cell: ({ row }) => ( + <Button + variant="ghost" + size="sm" + onClick={() => setRowAction({ row, type: "attachment" })} + > + <FileText className="h-4 w-4" /> + </Button> + ), + size: 80, + }, + + // PR건수 - 수정됨 + { + accessorKey: "prItemsCount", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="PR건수" />, + cell: ({ row }) => ( + <Button + variant="ghost" + size="sm" + className="font-mono text-sm p-1 h-auto" + onClick={() => setRowAction({ row, type: "items" })} + > + {row.original.prItemsCount || 0} + </Button> + ), + size: 80, + }, + + // 견적생성일 + { + accessorKey: "createdAt", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적생성일" />, + cell: ({ row }) => { + const date = row.original.createdAt; + return date ? format(new Date(date), "yyyy-MM-dd") : "-"; }, - { - accessorKey: "packageName", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="패키지명" />, - cell: ({ row }) => ( - <div className="max-w-[200px] truncate" title={row.original.packageName || ""}> - {row.original.packageName || "-"} - </div> - ), - size: 200, + size: 100, + }, + + // 견적발송일 + { + accessorKey: "rfqSendDate", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적발송일" />, + cell: ({ row }) => { + const date = row.original.rfqSendDate; + return date ? format(new Date(date), "yyyy-MM-dd") : "-"; }, - ] - }, - - // ═══════════════════════════════════════════════════════════════ - // 담당자 정보 - // ═══════════════════════════════════════════════════════════════ - { - header: "담당자", - columns: [ - { - accessorKey: "picUserName", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="구매담당자" />, - cell: ({ row }) => row.original.picUserName || row.original.picName || "-", - size: 100, + size: 100, + }, + + // 견적마감일 + { + accessorKey: "dueDate", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적마감일" />, + cell: ({ row }) => { + const date = row.original.dueDate; + if (!date) return "-"; + + const now = new Date(); + const dueDate = new Date(date); + const isOverdue = now > dueDate; + + return ( + <span className={`text-sm ${isOverdue ? "text-red-600 font-medium" : ""}`}> + {format(dueDate, "yyyy-MM-dd")} + </span> + ); }, - { - accessorKey: "engPicName", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="엔지니어링 담당" />, - cell: ({ row }) => row.original.engPicName || "-", - size: 120, + size: 100, + }, + + // 설계담당자 + { + accessorKey: "engPicName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="설계담당자" />, + cell: ({ row }) => row.original.engPicName || "-", + size: 100, + }, + + // TBE 결과접수 + { + accessorKey: "tbeResultReceived", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="TBE 결과접수" />, + cell: ({ row }) => { + const received = row.original.quotationReceivedCount || 0; + const total = row.original.vendorCount || 0; + return `${received}/${total}`; }, - ] - }, - - // ═══════════════════════════════════════════════════════════════ - // 일정 정보 - // ═══════════════════════════════════════════════════════════════ - { - header: "일정", - columns: [ - { - accessorKey: "rfqSendDate", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="발송일" />, - cell: ({ row }) => { - const date = row.original.rfqSendDate; - return date ? ( - <div className="flex items-center gap-1"> - <Send className="h-3 w-3 text-muted-foreground" /> - <span className="text-sm"> - {format(new Date(date), "MM-dd", { locale: ko })} - </span> - </div> - ) : "-"; - }, - size: 90, + size: 100, + }, + + // 대표 PR No. + { + accessorKey: "prNumber", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="대표 PR No." />, + cell: ({ row }) => ( + <span className="font-mono text-sm">{row.original.prNumber || "-"}</span> + ), + size: 120, + }, + + // PR발행일 + { + accessorKey: "prIssueDate", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="PR발행일" />, + cell: ({ row }) => { + const date = row.original.prIssueDate; + return date ? format(new Date(date), "yyyy-MM-dd") : "-"; }, - { - accessorKey: "dueDate", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="마감일" />, - cell: ({ row }) => { - const date = row.original.dueDate; - if (!date) return "-"; - - const now = new Date(); - const dueDate = new Date(date); - const isOverdue = now > dueDate; - - return ( - <span className={`text-sm ${isOverdue ? "text-red-600 font-medium" : ""}`}> - {format(dueDate, "MM-dd", { locale: ko })} - </span> - ); - }, - size: 90, + size: 100, + }, + + // 최종수정일 + { + accessorKey: "updatedAt", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종수정일" />, + cell: ({ row }) => { + const date = row.original.updatedAt; + return date ? format(new Date(date), "yyyy-MM-dd HH:mm") : "-"; }, - ] - }, - - // ═══════════════════════════════════════════════════════════════ - // 벤더 및 견적 현황 - // ═══════════════════════════════════════════════════════════════ - { - header: "견적 현황", - columns: [ - { - accessorKey: "vendorCount", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="업체수" />, - cell: ({ row }) => ( - <div className="flex items-center gap-1"> - <Users className="h-3 w-3 text-muted-foreground" /> - <span className="font-medium">{row.original.vendorCount || 0}</span> + size: 120, + }, + + // 최종수정자 + { + accessorKey: "updatedByUserName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종수정자" />, + cell: ({ row }) => row.original.updatedByUserName || "-", + size: 100, + }, + + // 비고 + { + accessorKey: "remark", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="비고" />, + cell: ({ row }) => row.original.remark || "-", + size: 150, + }, + ]; + } + + // ═══════════════════════════════════════════════════════════════ + // 일반견적 컬럼 정의 + // ═══════════════════════════════════════════════════════════════ + if (rfqCategory === "general") { + return [ + // Checkbox + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")} + onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)} + aria-label="select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(v) => row.toggleSelected(!!v)} + aria-label="select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + }, + + // 견적 No. + { + accessorKey: "rfqCode", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적 No." />, + cell: ({ row }) => ( + <span className="font-mono font-medium">{row.original.rfqCode}</span> + ), + size: 120, + }, + + // 상세 - 수정됨 + { + id: "detail", + header: "상세", + cell: ({ row }) => ( + <Button + variant="ghost" + size="icon" + className="h-8 w-8" + onClick={() => router.push(`/evcp/rfq-last/${row.original.id}`)} + > + <Eye className="h-4 w-4" /> + </Button> + ), + size: 60, + }, + + // 견적상태 + { + accessorKey: "status", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적상태" />, + cell: ({ row }) => ( + <Badge variant={getStatusBadgeVariant(row.original.status)}> + {row.original.status} + </Badge> + ), + size: 120, + }, + + // 견적 밀봉 + { + accessorKey: "rfqSealedYn", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적 밀봉" />, + cell: ({ row }) => { + const isSealed = row.original.rfqSealedYn; + return ( + <div className="flex justify-center"> + {isSealed ? ( + <Lock className="h-4 w-4 text-red-500" /> + ) : ( + <LockOpen className="h-4 w-4 text-gray-400" /> + )} </div> - ), - size: 80, - }, - { - accessorKey: "shortListedVendorCount", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="Short List" />, - cell: ({ row }) => { - const count = row.original.shortListedVendorCount || 0; - return count > 0 ? ( - <Badge variant="default" className="font-mono"> - {count} - </Badge> - ) : "-"; - }, - size: 90, + ); }, - { - accessorKey: "quotationReceivedCount", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적접수" />, - cell: ({ row }) => { - const received = row.original.quotationReceivedCount || 0; - const total = row.original.vendorCount || 0; - - return ( - <div className="flex items-center gap-1"> - <FileText className="h-3 w-3 text-muted-foreground" /> - <span className={`text-sm ${received === total && total > 0 ? "text-green-600 font-medium" : ""}`}> - {received}/{total} - </span> - </div> - ); - }, - size: 90, + size: 80, + }, + + // 견적종류 + { + accessorKey: "rfqType", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적종류" />, + cell: ({ row }) => row.original.rfqType || "-", + size: 100, + }, + + // 프로젝트 (프로젝트명) + { + accessorKey: "projectName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트 (프로젝트명)" />, + cell: ({ row }) => ( + <div className="flex flex-col"> + <span className="font-mono text-xs text-muted-foreground"> + {row.original.projectCode} + </span> + <span className="max-w-[200px] truncate" title={row.original.projectName || ""}> + {row.original.projectName || "-"} + </span> + </div> + ), + size: 220, + }, + + // 시리즈 + { + accessorKey: "series", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="시리즈" />, + cell: ({ row }) => { + const series = row.original.series; + if (!series) return "-"; + const label = series === "SS" ? "시리즈 통합" : series === "II" ? "품목 통합" : series; + return <Badge variant="outline">{label}</Badge>; }, - ] - }, - - // PR Items 정보 - { - accessorKey: "prItemsCount", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="PR Items" />, - cell: ({ row }) => { - const prItems = row.original.prItemsCount || 0; - const majorItems = row.original.majorItemsCount || 0; - - return ( - <div className="flex flex-col gap-0.5"> - <span className="text-sm font-medium">{prItems}개</span> - {majorItems > 0 && ( - <Badge variant="secondary" className="text-xs"> - 주요 {majorItems} - </Badge> - )} + size: 100, + }, + + // 선급 + { + accessorKey: "classNo", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="선급" />, + cell: ({ row }) => row.original.classNo || "-", + size: 80, + }, + + // 견적명 + { + accessorKey: "rfqTitle", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적명" />, + cell: ({ row }) => ( + <div className="max-w-[200px] truncate" title={row.original.rfqTitle || ""}> + {row.original.rfqTitle || "-"} </div> - ); - }, - size: 90, - }, - - // 액션 - { - id: "actions", - header: "액션", - enableHiding: false, - size: 80, - minSize: 80, - cell: ({ row }) => { - return ( - <div className="flex items-center gap-1"> - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="ghost" - size="icon" - className="h-8 w-8" - onClick={() => setRowAction({ row, type: "view" })} - > - <Eye className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>상세보기</TooltipContent> - </Tooltip> - </TooltipProvider> + ), + size: 200, + }, + + // 자재그룹 (자재그룹명) + { + accessorKey: "itemName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재그룹 (자재그룹명)" />, + cell: ({ row }) => ( + <div className="flex flex-col"> + <span className="font-mono text-xs text-muted-foreground"> + {row.original.itemCode} + </span> + <span className="max-w-[150px] truncate" title={row.original.itemName || ""}> + {row.original.itemName || "-"} + </span> </div> - ); + ), + size: 180, + }, + + // 자재코드 + { + accessorKey: "itemCode", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재코드" />, + cell: ({ row }) => ( + <span className="font-mono text-sm">{row.original.itemCode || "-"}</span> + ), + size: 100, }, - }, - ]; - return baseColumns; + // 견적 자료 - 수정됨 + { + id: "rfqDocument", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적 자료" />, + cell: ({ row }) => ( + <Button + variant="ghost" + size="sm" + onClick={() => setRowAction({ row, type: "attachment" })} + > + <FileText className="h-4 w-4" /> + </Button> + ), + size: 80, + }, + + // 견적품목수 - 수정됨 + { + accessorKey: "prItemsCount", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적품목수" />, + cell: ({ row }) => ( + <Button + variant="ghost" + size="sm" + className="font-mono text-sm p-1 h-auto" + onClick={() => setRowAction({ row, type: "items" })} + > + {row.original.prItemsCount || 0} + </Button> + ), + size: 90, + }, + + // 견적생성일 + { + accessorKey: "createdAt", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적생성일" />, + cell: ({ row }) => { + const date = row.original.createdAt; + return date ? format(new Date(date), "yyyy-MM-dd") : "-"; + }, + size: 100, + }, + + // 견적발송일 + { + accessorKey: "rfqSendDate", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적발송일" />, + cell: ({ row }) => { + const date = row.original.rfqSendDate; + return date ? format(new Date(date), "yyyy-MM-dd") : "-"; + }, + size: 100, + }, + + // 견적마감일 + { + accessorKey: "dueDate", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적마감일" />, + cell: ({ row }) => { + const date = row.original.dueDate; + if (!date) return "-"; + + const now = new Date(); + const dueDate = new Date(date); + const isOverdue = now > dueDate; + + return ( + <span className={`text-sm ${isOverdue ? "text-red-600 font-medium" : ""}`}> + {format(dueDate, "yyyy-MM-dd")} + </span> + ); + }, + size: 100, + }, + + // 구매담당자 + { + accessorKey: "picUserName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="구매담당자" />, + cell: ({ row }) => row.original.picUserName || row.original.picName || "-", + size: 100, + }, + + // 최종수정일 + { + accessorKey: "updatedAt", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종수정일" />, + cell: ({ row }) => { + const date = row.original.updatedAt; + return date ? format(new Date(date), "yyyy-MM-dd HH:mm") : "-"; + }, + size: 120, + }, + + // 최종수정자 + { + accessorKey: "updatedByUserName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종수정자" />, + cell: ({ row }) => row.original.updatedByUserName || "-", + size: 100, + }, + + // 비고 + { + accessorKey: "remark", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="비고" />, + cell: ({ row }) => row.original.remark || "-", + size: 150, + }, + ]; + } + + // 기본값 (fallback) + return []; }
\ No newline at end of file diff --git a/lib/rfq-last/table/rfq-table-toolbar-actions.tsx b/lib/rfq-last/table/rfq-table-toolbar-actions.tsx index 1f60da36..9b696cbd 100644 --- a/lib/rfq-last/table/rfq-table-toolbar-actions.tsx +++ b/lib/rfq-last/table/rfq-table-toolbar-actions.tsx @@ -13,15 +13,18 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { RfqsLastView } from "@/db/schema"; +import { CreateGeneralRfqDialog } from "./create-general-rfq-dialog"; interface RfqTableToolbarActionsProps { table: Table<RfqsLastView>; onRefresh?: () => void; + rfqCategory?: "general" | "itb" | "rfq"; } export function RfqTableToolbarActions({ table, onRefresh, + rfqCategory = "itb", }: RfqTableToolbarActionsProps) { const [isExporting, setIsExporting] = React.useState(false); @@ -150,14 +153,10 @@ export function RfqTableToolbarActions({ </DropdownMenuContent> </DropdownMenu> - <Button - variant="samsung" - size="sm" - className="h-8 px-2 lg:px-3" - > - <Plus className="mr-2 h-4 w-4" /> - RFQ 생성 - </Button> + {/* rfqCategory가 'general'일 때만 일반견적 생성 다이얼로그 표시 */} + {rfqCategory === "general" && ( + <CreateGeneralRfqDialog onSuccess={onRefresh} /> + ) } </div> ); }
\ No newline at end of file diff --git a/lib/rfq-last/table/rfq-table.tsx b/lib/rfq-last/table/rfq-table.tsx index 199695a0..e8db116b 100644 --- a/lib/rfq-last/table/rfq-table.tsx +++ b/lib/rfq-last/table/rfq-table.tsx @@ -24,6 +24,8 @@ import { getRfqColumns } from "./rfq-table-columns"; import { RfqsLastView } from "@/db/schema"; import { getRfqs } from "../service"; import { RfqTableToolbarActions } from "./rfq-table-toolbar-actions"; +import { RfqAttachmentsDialog } from "./rfq-attachments-dialog"; +import { RfqItemsDialog } from "./rfq-items-dialog"; interface RfqTableProps { data: Awaited<ReturnType<typeof getRfqs>>; @@ -66,7 +68,6 @@ export function RfqTable({ ); // 초기 데이터 설정 -// const [initialPromiseData] = React.use(promises); const [tableData, setTableData] = React.useState(data); const [isDataLoading, setIsDataLoading] = React.useState(false); @@ -232,9 +233,10 @@ export function RfqTable({ const columns = React.useMemo(() => { return getRfqColumns({ setRowAction, - rfqCategory + rfqCategory , + router }); - }, [rfqCategory, setRowAction]); + }, [rfqCategory, setRowAction, router]); const filterFields: DataTableFilterField<RfqsLastView>[] = [ { id: "rfqCode", label: "RFQ 코드" }, @@ -442,6 +444,7 @@ export function RfqTable({ <RfqTableToolbarActions table={table} + rfqCategory={rfqCategory} onRefresh={refreshData} /> </div> @@ -452,6 +455,23 @@ export function RfqTable({ </div> </div> </div> + + {/* 다이얼로그들 */} + {rowAction?.type === "attachment" && ( + <RfqAttachmentsDialog + isOpen={true} + onClose={() => setRowAction(null)} + rfqData={rowAction.row.original} + /> + )} + + {rowAction?.type === "items" && ( + <RfqItemsDialog + isOpen={true} + onClose={() => setRowAction(null)} + rfqData={rowAction.row.original} + /> + )} </> ); }
\ No newline at end of file diff --git a/lib/rfq-last/validations.ts b/lib/rfq-last/validations.ts index 09fd2f6f..b133433f 100644 --- a/lib/rfq-last/validations.ts +++ b/lib/rfq-last/validations.ts @@ -10,6 +10,7 @@ import { import * as z from "zod"; import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"; +import { RfqLastAttachments } from "@/db/schema"; // RFQ 상태 옵션 export const RFQ_STATUS_OPTIONS = [ @@ -62,4 +63,25 @@ import { export type GetRfqsSchema = Awaited< ReturnType<typeof searchParamsRfqCache.parse> - >;
\ No newline at end of file + >; + + + export const searchParamsRfqAttachmentsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<RfqLastAttachments>().withDefault([ + { id: "createdAt", desc: true }, + ]), + // 기본 필터 + attachmentType: parseAsArrayOf(z.string()).withDefault([]), + fileType: parseAsArrayOf(z.string()).withDefault([]), + search: parseAsString.withDefault(""), + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + }) + + // 스키마 타입들 + export type GetRfqLastAttachmentsSchema = Awaited<ReturnType<typeof searchParamsRfqAttachmentsCache.parse>> + diff --git a/lib/vendor-document-list/enhanced-document-service.ts b/lib/vendor-document-list/enhanced-document-service.ts index 7464b13f..43eea6eb 100644 --- a/lib/vendor-document-list/enhanced-document-service.ts +++ b/lib/vendor-document-list/enhanced-document-service.ts @@ -24,6 +24,7 @@ import { contracts, users, vendors } from "@/db/schema" import { getServerSession } from "next-auth/next" import { authOptions } from "@/app/api/auth/[...nextauth]/route" import { countDocumentStagesOnly, selectDocumentStagesOnly } from "./repository" +import { saveFile } from "../file-stroage" // 스키마 타입 정의 export interface GetEnhancedDocumentsSchema { @@ -1468,4 +1469,234 @@ export async function getDocumentDetails(documentId: number) { error: error instanceof Error ? error.message : "Failed to delete revision" } } - }
\ No newline at end of file + } + + + interface UploadResult { + docNumber: string + revision: string + success: boolean + message?: string + error?: string + } + + interface BulkUploadResult { + success: boolean + successCount?: number + failCount?: number + results?: UploadResult[] + error?: string + } + + export async function bulkUploadB4Documents(formData: FormData): Promise<BulkUploadResult> { + try { + const session = await getServerSession(authOptions) + if (!session?.user) { + return { success: false, error: "인증이 필요합니다" } + } + + const projectId = formData.get("projectId") as string + const fileCount = parseInt(formData.get("fileCount") as string) + + if (!projectId) { + return { success: false, error: "프로젝트를 선택해주세요" } + } + + const results: UploadResult[] = [] + let successCount = 0 + let failCount = 0 + + // 문서번호별로 그룹화 + const fileGroups = new Map<string, Array<{ + file: File + revision: string + index: number + }>>() + + // 파일들을 문서번호별로 그룹화 + for (let i = 0; i < fileCount; i++) { + const file = formData.get(`file_${i}`) as File + const docNumber = formData.get(`docNumber_${i}`) as string + const revision = formData.get(`revision_${i}`) as string + + if (!file || !docNumber) continue + + if (!fileGroups.has(docNumber)) { + fileGroups.set(docNumber, []) + } + + fileGroups.get(docNumber)!.push({ + file, + revision: revision || "00", + index: i + }) + } + + // 각 문서번호 그룹 처리 + for (const [docNumber, files] of fileGroups.entries()) { + try { + // 문서가 존재하는지 확인 + const existingDoc = await db.query.documents.findFirst({ + where: and( + eq(documents.docNumber, docNumber), + eq(documents.projectId, parseInt(projectId)) + ) + }) + + if (!existingDoc) { + // 문서가 없으면 모든 파일 스킵 + for (const fileInfo of files) { + results.push({ + docNumber, + revision: fileInfo.revision, + success: false, + error: `문서번호 ${docNumber}가 존재하지 않습니다` + }) + failCount++ + } + continue + } + + // 기존 스테이지 조회 + const existingStages = await db.query.issueStages.findMany({ + where: eq(issueStages.documentId, existingDoc.id) + }) + + const preStage = existingStages.find(s => s.stageName === 'GTT → SHI (For Pre.DWG)') + const workStage = existingStages.find(s => s.stageName === 'GTT → SHI (For Work.DWG)') + + // 파일별 처리 (첫 번째 리비전은 Pre.DWG, 나머지는 Work.DWG) + for (let fileIndex = 0; fileIndex < files.length; fileIndex++) { + const fileInfo = files[fileIndex] + let targetStageId: number + + try { + // 스테이지 결정 및 생성 + if (fileIndex === 0) { + // 첫 번째 리비전 - Pre.DWG 스테이지 + if (preStage) { + targetStageId = preStage.id + } else { + // Pre.DWG 스테이지 생성 + const [newStage] = await db.insert(issueStages).values({ + documentId: existingDoc.id, + stageName: 'GTT → SHI (For Pre.DWG)', + stageOrder: 1, + stageStatus: 'PLANNED', + }).returning() + targetStageId = newStage.id + } + } else { + // 나머지 리비전 - Work.DWG 스테이지 + if (workStage) { + targetStageId = workStage.id + } else { + // Work.DWG 스테이지 생성 + const [newStage] = await db.insert(issueStages).values({ + documentId: existingDoc.id, + stageName: 'GTT → SHI (For Work.DWG)', + stageOrder: 2, + stageStatus: 'PLANNED', + }).returning() + targetStageId = newStage.id + } + } + + // 같은 리비전이 이미 있는지 확인 + const existingRevision = await db.query.revisions.findFirst({ + where: and( + eq(revisions.issueStageId, targetStageId), + eq(revisions.revision, fileInfo.revision) + ) + }) + + let revisionId: number + + if (existingRevision) { + // 기존 리비전 사용 + revisionId = existingRevision.id + } else { + // 새 리비전 생성 + const [newRevision] = await db.insert(revisions).values({ + issueStageId: targetStageId, + revision: fileInfo.revision, + uploaderType: "vendor", + uploaderName: session.user.name || "System", + uploadedAt: new Date().toISOString().split('T')[0], + submittedDate: new Date().toISOString().split('T')[0], + revisionStatus: 'SUBMITTED', + }).returning() + revisionId = newRevision.id + } + + // 파일 저장 + const saveResult = await saveFile({ + file: fileInfo.file, + directory: `documents/${existingDoc.id}/revisions/${revisionId}`, + originalName: fileInfo.file.name, + userId: session.user.id + }) + + if (!saveResult.success) { + throw new Error(saveResult.error || "파일 저장 실패") + } + + // 첨부파일 정보 저장 + await db.insert(documentAttachments).values({ + revisionId, + fileName: fileInfo.file.name, + filePath: saveResult.publicPath!, + fileType: fileInfo.file.type, + fileSize: fileInfo.file.size, + }) + + results.push({ + docNumber, + revision: fileInfo.revision, + success: true, + message: `${fileIndex === 0 ? 'Pre.DWG' : 'Work.DWG'} 스테이지에 업로드 완료` + }) + successCount++ + + } catch (fileError) { + results.push({ + docNumber, + revision: fileInfo.revision, + success: false, + error: fileError instanceof Error ? fileError.message : "파일 처리 실패" + }) + failCount++ + } + } + + } catch (docError) { + // 문서 그룹 전체 에러 + for (const fileInfo of files) { + results.push({ + docNumber, + revision: fileInfo.revision, + success: false, + error: docError instanceof Error ? docError.message : "문서 처리 실패" + }) + failCount++ + } + } + } + + revalidatePath('/documents') + + return { + success: true, + successCount, + failCount, + results + } + + } catch (error) { + console.error("Bulk upload error:", error) + return { + success: false, + error: error instanceof Error ? error.message : "업로드 중 오류가 발생했습니다" + } + } + } diff --git a/lib/vendor-document-list/import-service.ts b/lib/vendor-document-list/import-service.ts index 61670c79..135dfb3a 100644 --- a/lib/vendor-document-list/import-service.ts +++ b/lib/vendor-document-list/import-service.ts @@ -657,7 +657,8 @@ class ImportService { .from(documents) .where(and( eq(documents.projectId, projectId), - eq(documents.docNumber, dolceDoc.DrawingNo) + eq(documents.docNumber, dolceDoc.DrawingNo), + eq(documents.discipline, dolceDoc.Discipline) )) .limit(1) @@ -765,7 +766,8 @@ class ImportService { .from(documents) .where(and( eq(documents.projectId, projectId), - eq(documents.docNumber, dolceDoc.DrawingNo) + eq(documents.docNumber, dolceDoc.DrawingNo), + eq(documents.discipline, dolceDoc.Discipline), )) .limit(1) @@ -1275,7 +1277,8 @@ class ImportService { .from(documents) .where(and( eq(documents.projectId, projectId), - eq(documents.docNumber, drawingNo) + eq(documents.docNumber, drawingNo), + eq(documents.discipline, dolceDoc.Discipline) )) .limit(1) @@ -1340,7 +1343,8 @@ class ImportService { .from(documents) .where(and( eq(documents.projectId, projectId), - eq(documents.docNumber, drawingNo) + eq(documents.docNumber, drawingNo), + eq(documents.discipline, dolceDoc.Discipline) )) .limit(1) @@ -1403,7 +1407,8 @@ class ImportService { .from(documents) .where(and( eq(documents.projectId, projectId), - eq(documents.docNumber, drawingNo) + eq(documents.docNumber, drawingNo), + eq(documents.discipline, dolceDoc.Discipline) )) .limit(1) diff --git a/lib/vendor-document-list/ship/bulk-b4-upload-dialog.tsx b/lib/vendor-document-list/ship/bulk-b4-upload-dialog.tsx new file mode 100644 index 00000000..65166bd6 --- /dev/null +++ b/lib/vendor-document-list/ship/bulk-b4-upload-dialog.tsx @@ -0,0 +1,428 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { toast } from "sonner" +import { useRouter } from "next/navigation" + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Separator } from "@/components/ui/separator" +import { Badge } from "@/components/ui/badge" +import { + Upload, + X, + Loader2, + FileSpreadsheet, + Files, + CheckCircle2, + AlertCircle, + AlertTriangle, + FileText, +} from "lucide-react" + +import { SimplifiedDocumentsView } from "@/db/schema" +import { bulkUploadB4Documents } from "../enhanced-document-service" + +// 파일명 파싱 유틸리티 +function parseFileName(fileName: string): { docNumber: string | null; revision: string | null } { + // 파일 확장자 제거 + const nameWithoutExt = fileName.replace(/\.[^.]+$/, "") + + // revision 패턴 찾기 (R01, r01, REV01, rev01 등) + const revisionMatch = nameWithoutExt.match(/[Rr](?:EV)?(\d+)/g) + const revision = revisionMatch ? revisionMatch[revisionMatch.length - 1].toUpperCase() : null + + // revision 제거한 나머지에서 docNumber 찾기 + let cleanedName = nameWithoutExt + if (revision) { + // revision과 그 앞의 구분자를 제거 + const revPattern = new RegExp(`[-_\\s]*${revision.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1")}.*$`, 'i') + cleanedName = cleanedName.replace(revPattern, "") + } + + // docNumber 패턴 찾기 (XX-XX-XX 형태) + // 공백이나 언더스코어를 하이픈으로 정규화 + const normalizedName = cleanedName.replace(/[\s_]+/g, '-') + + // 2~3자리 코드가 2~3개 연결된 패턴 찾기 + const docNumberPatterns = [ + /\b([A-Za-z]{2,3})-([A-Za-z]{2,3})-([A-Za-z0-9]{2,4})\b/, + /\b([A-Za-z]{2,3})\s+([A-Za-z]{2,3})\s+([A-Za-z0-9]{2,4})\b/, + ] + + let docNumber = null + for (const pattern of docNumberPatterns) { + const match = normalizedName.match(pattern) || cleanedName.match(pattern) + if (match) { + docNumber = `${match[1]}-${match[2]}-${match[3]}`.toUpperCase() + break + } + } + + return { docNumber, revision } +} + +// Form schema +const formSchema = z.object({ + projectId: z.string().min(1, "Please select a project"), + files: z.array(z.instanceof(File)).min(1, "Please select files"), +}) + +interface BulkB4UploadDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + allDocuments: SimplifiedDocumentsView[] +} + +interface ParsedFile { + file: File + docNumber: string | null + revision: string | null + status: 'pending' | 'uploading' | 'success' | 'error' | 'ignored' + message?: string +} + +export function BulkB4UploadDialog({ + open, + onOpenChange, + allDocuments +}: BulkB4UploadDialogProps) { + const [isUploading, setIsUploading] = React.useState(false) + const [parsedFiles, setParsedFiles] = React.useState<ParsedFile[]>([]) + const router = useRouter() + + // 프로젝트 ID 추출 + const projectOptions = React.useMemo(() => { + const projectIds = [...new Set(allDocuments.map(doc => doc.projectId).filter(Boolean))] + return projectIds.map(id => ({ + id: String(id), + code: allDocuments.find(doc => doc.projectId === id)?.projectCode || `Project ${id}` + })) + }, [allDocuments]) + + const form = useForm<z.infer<typeof formSchema>>({ + resolver: zodResolver(formSchema), + defaultValues: { + projectId: "", + files: [], + }, + }) + + // 파일 선택 시 파싱 + const handleFilesChange = (files: File[]) => { + const parsed = files.map(file => { + const { docNumber, revision } = parseFileName(file.name) + return { + file, + docNumber, + revision, + status: docNumber ? 'pending' as const : 'ignored' as const, + message: !docNumber ? 'docNumber를 찾을 수 없음' : undefined + } + }) + + setParsedFiles(parsed) + form.setValue("files", files) + } + + // 파일 제거 + const removeFile = (index: number) => { + const newParsedFiles = parsedFiles.filter((_, i) => i !== index) + setParsedFiles(newParsedFiles) + form.setValue("files", newParsedFiles.map(pf => pf.file)) + } + + // 업로드 처리 + async function onSubmit(values: z.infer<typeof formSchema>) { + setIsUploading(true) + + try { + // 유효한 파일만 필터링 + const validFiles = parsedFiles.filter(pf => pf.docNumber && pf.status === 'pending') + + if (validFiles.length === 0) { + toast.error("업로드 가능한 파일이 없습니다") + return + } + + // 파일별로 상태 업데이트 + setParsedFiles(prev => prev.map(pf => + pf.docNumber && pf.status === 'pending' + ? { ...pf, status: 'uploading' as const } + : pf + )) + + // FormData 생성 + const formData = new FormData() + formData.append("projectId", values.projectId) + + validFiles.forEach((pf, index) => { + formData.append(`file_${index}`, pf.file) + formData.append(`docNumber_${index}`, pf.docNumber!) + formData.append(`revision_${index}`, pf.revision || "00") + }) + + formData.append("fileCount", String(validFiles.length)) + + // 서버 액션 호출 + const result = await bulkUploadB4Documents(formData) + + if (result.success) { + // 성공한 파일들 표시 + setParsedFiles(prev => prev.map(pf => { + const uploadResult = result.results?.find(r => + r.docNumber === pf.docNumber && r.revision === (pf.revision || "00") + ) + + if (uploadResult?.success) { + return { ...pf, status: 'success' as const, message: uploadResult.message } + } else if (uploadResult) { + return { ...pf, status: 'error' as const, message: uploadResult.error } + } + return pf + })) + + toast.success(`${result.successCount}/${validFiles.length} 파일 업로드 완료`) + + // 모두 성공하면 닫기 + if (result.successCount === validFiles.length) { + setTimeout(() => { + onOpenChange(false) + router.refresh() + }, 1500) + } + } else { + toast.error(result.error || "업로드 실패") + setParsedFiles(prev => prev.map(pf => + pf.status === 'uploading' + ? { ...pf, status: 'error' as const, message: result.error } + : pf + )) + } + } catch (error) { + toast.error("업로드 중 오류가 발생했습니다") + setParsedFiles(prev => prev.map(pf => + pf.status === 'uploading' + ? { ...pf, status: 'error' as const, message: '업로드 실패' } + : pf + )) + } finally { + setIsUploading(false) + } + } + + // 다이얼로그 닫을 때 초기화 + React.useEffect(() => { + if (!open) { + form.reset() + setParsedFiles([]) + } + }, [open, form]) + + const validFileCount = parsedFiles.filter(pf => pf.docNumber).length + const ignoredFileCount = parsedFiles.filter(pf => !pf.docNumber).length + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-3xl"> + <DialogHeader> + <DialogTitle>B4 Document Bulk Upload</DialogTitle> + <DialogDescription> + Document numbers and revisions will be automatically extracted from file names. + Example: "agadfg de na oc R01.pdf" → Document Number: DE-NA-OC, Revision: R01 + </DialogDescription> + </DialogHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + <FormField + control={form.control} + name="projectId" + render={({ field }) => ( + <FormItem> + <FormLabel>Select Project *</FormLabel> + <Select onValueChange={field.onChange} value={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="Please select a project" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {projectOptions.map(project => ( + <SelectItem key={project.id} value={project.id}> + {project.code} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + <div className="space-y-2"> + <FormLabel>Select Files</FormLabel> + <div className="border-2 border-dashed rounded-lg p-6"> + <input + type="file" + multiple + accept=".pdf,.doc,.docx,.xls,.xlsx,.dwg,.dxf" + onChange={(e) => handleFilesChange(Array.from(e.target.files || []))} + className="hidden" + id="file-upload" + /> + <label + htmlFor="file-upload" + className="flex flex-col items-center justify-center cursor-pointer" + > + <Upload className="h-10 w-10 text-muted-foreground mb-2" /> + <p className="text-sm text-muted-foreground"> + Click or drag files to upload + </p> + <p className="text-xs text-muted-foreground mt-1"> + PDF, DOC, DOCX, XLS, XLSX, DWG, DXF + </p> + </label> + </div> + </div> + + {parsedFiles.length > 0 && ( + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <FormLabel>Selected Files</FormLabel> + <div className="flex gap-2"> + <Badge variant="default"> + Valid: {validFileCount} + </Badge> + {ignoredFileCount > 0 && ( + <Badge variant="secondary"> + Ignored: {ignoredFileCount} + </Badge> + )} + </div> + </div> + + <ScrollArea className="h-[250px] border rounded-lg p-2"> + <div className="space-y-2"> + {parsedFiles.map((pf, index) => ( + <div + key={index} + className="flex items-center justify-between p-2 rounded-lg bg-muted/30" + > + <div className="flex items-center gap-3 flex-1"> + <FileText className="h-4 w-4 text-muted-foreground" /> + <div className="flex-1 min-w-0"> + <p className="text-sm font-medium truncate"> + {pf.file.name} + </p> + <div className="flex items-center gap-2 mt-1"> + {pf.docNumber ? ( + <> + <Badge variant="outline" className="text-xs"> + Doc: {pf.docNumber} + </Badge> + {pf.revision && ( + <Badge variant="outline" className="text-xs"> + Rev: {pf.revision} + </Badge> + )} + </> + ) : ( + <span className="text-xs text-muted-foreground"> + {pf.message} + </span> + )} + </div> + </div> + + <div className="flex items-center gap-2"> + {pf.status === 'uploading' && ( + <Loader2 className="h-4 w-4 animate-spin" /> + )} + {pf.status === 'success' && ( + <CheckCircle2 className="h-4 w-4 text-green-500" /> + )} + {pf.status === 'error' && ( + <AlertCircle className="h-4 w-4 text-red-500" /> + )} + {pf.status === 'ignored' && ( + <AlertTriangle className="h-4 w-4 text-yellow-500" /> + )} + + {!isUploading && ( + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => removeFile(index)} + > + <X className="h-4 w-4" /> + </Button> + )} + </div> + </div> + </div> + ))} + </div> + </ScrollArea> + </div> + )} + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isUploading} + > + Cancel + </Button> + <Button + type="submit" + disabled={isUploading || validFileCount === 0 || !form.watch("projectId")} + > + {isUploading ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + Uploading... + </> + ) : ( + <> + <Upload className="mr-2 h-4 w-4" /> + Upload ({validFileCount} files) + </> + )} + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/ship/enhanced-doc-table-columns.tsx b/lib/vendor-document-list/ship/enhanced-doc-table-columns.tsx index 51c104dc..8370cd34 100644 --- a/lib/vendor-document-list/ship/enhanced-doc-table-columns.tsx +++ b/lib/vendor-document-list/ship/enhanced-doc-table-columns.tsx @@ -114,6 +114,43 @@ export function getSimplifiedDocumentColumns({ }, }, + { + accessorKey: "discipline", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Discipline" /> + ), + cell: ({ row }) => { + return ( + <div className="text-center"> + {row.original.discipline} + </div> + ) + }, + enableResizing: true, + maxSize:80, + meta: { + excelHeader: "discipline" + }, + }, + + { + accessorKey: "managerENM", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Contact" /> + ), + cell: ({ row }) => { + return ( + <div className="text-center"> + {row.original.managerENM} + </div> + ) + }, + enableResizing: true, + maxSize:80, + meta: { + excelHeader: "Contact" + }, + }, // 프로젝트 코드 { accessorKey: "projectCode", diff --git a/lib/vendor-document-list/ship/enhanced-doc-table-toolbar-actions.tsx b/lib/vendor-document-list/ship/enhanced-doc-table-toolbar-actions.tsx index 4ec57369..94252db5 100644 --- a/lib/vendor-document-list/ship/enhanced-doc-table-toolbar-actions.tsx +++ b/lib/vendor-document-list/ship/enhanced-doc-table-toolbar-actions.tsx @@ -1,4 +1,4 @@ -// enhanced-doc-table-toolbar-actions.tsx - 최적화된 버전 +// enhanced-doc-table-toolbar-actions.tsx - B4 업로드 기능 추가 버전 "use client" import * as React from "react" @@ -11,15 +11,18 @@ import { Button } from "@/components/ui/button" import { SimplifiedDocumentsView } from "@/db/schema/vendorDocu" import { SendToSHIButton } from "./send-to-shi-button" import { ImportFromDOLCEButton } from "./import-from-dolce-button" +import { BulkB4UploadDialog } from "./bulk-b4-upload-dialog" interface EnhancedDocTableToolbarActionsProps { table: Table<SimplifiedDocumentsView> projectType: "ship" | "plant" + b4: boolean } export function EnhancedDocTableToolbarActions({ table, projectType, + b4 }: EnhancedDocTableToolbarActionsProps) { const [bulkUploadDialogOpen, setBulkUploadDialogOpen] = React.useState(false) @@ -68,31 +71,55 @@ export function EnhancedDocTableToolbarActions({ }, [table]) return ( - <div className="flex items-center gap-2"> - {/* SHIP: DOLCE에서 목록 가져오기 */} - <ImportFromDOLCEButton - allDocuments={allDocuments} - projectIds={projectIds} // 🔥 미리 계산된 projectIds 전달 - onImportComplete={handleImportComplete} - /> + <> + <div className="flex items-center gap-2"> + {/* SHIP: DOLCE에서 목록 가져오기 */} + <ImportFromDOLCEButton + allDocuments={allDocuments} + projectIds={projectIds} // 🔥 미리 계산된 projectIds 전달 + onImportComplete={handleImportComplete} + /> - {/* Export 버튼 (공통) */} - <Button - variant="outline" - size="sm" - onClick={handleExport} - className="gap-2" - > - <Download className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">Export</span> - </Button> + {/* B4 일괄 업로드 버튼 - b4가 true일 때만 표시 */} + {b4 && ( + <Button + variant="outline" + size="sm" + onClick={() => setBulkUploadDialogOpen(true)} + className="gap-2" + > + <Upload className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">B4 업로드</span> + </Button> + )} - {/* Send to SHI 버튼 (공통) */} - <SendToSHIButton - documents={allDocuments} - onSyncComplete={handleSyncComplete} - projectType={projectType} - /> - </div> + {/* Export 버튼 (공통) */} + <Button + variant="outline" + size="sm" + onClick={handleExport} + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + + {/* Send to SHI 버튼 (공통) */} + <SendToSHIButton + documents={allDocuments} + onSyncComplete={handleSyncComplete} + projectType={projectType} + /> + </div> + + {/* B4 일괄 업로드 다이얼로그 */} + {b4 && ( + <BulkB4UploadDialog + open={bulkUploadDialogOpen} + onOpenChange={setBulkUploadDialogOpen} + allDocuments={allDocuments} + /> + )} + </> ) }
\ No newline at end of file diff --git a/lib/vendor-document-list/ship/enhanced-documents-table.tsx b/lib/vendor-document-list/ship/enhanced-documents-table.tsx index 8051da7e..287df755 100644 --- a/lib/vendor-document-list/ship/enhanced-documents-table.tsx +++ b/lib/vendor-document-list/ship/enhanced-documents-table.tsx @@ -287,6 +287,7 @@ export function SimplifiedDocumentsTable({ <EnhancedDocTableToolbarActions table={table} projectType="ship" + b4={hasB4Documents} /> </DataTableAdvancedToolbar> </DataTable> |
