diff options
Diffstat (limited to 'lib/rfq-last')
| -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 |
8 files changed, 3011 insertions, 369 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>> + |
