diff options
Diffstat (limited to 'lib/vendor-rfq-response')
14 files changed, 3138 insertions, 0 deletions
diff --git a/lib/vendor-rfq-response/service.ts b/lib/vendor-rfq-response/service.ts new file mode 100644 index 00000000..cba6c414 --- /dev/null +++ b/lib/vendor-rfq-response/service.ts @@ -0,0 +1,301 @@ +import { unstable_cache } from "next/cache"; +import db from "@/db/db"; +import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm"; +import { rfqAttachments, rfqComments, rfqItems } from "@/db/schema/rfq"; +import { vendorResponsesView, vendorTechnicalResponses, vendorCommercialResponses, vendorResponseAttachments } from "@/db/schema/rfq"; +import { items } from "@/db/schema/items"; +import { GetRfqsForVendorsSchema } from "../rfqs/validations"; + + + +export async function getRfqResponsesForVendor(input: GetRfqsForVendorsSchema, vendorId: number) { + return unstable_cache( + async () => { + const offset = (input.page - 1) * input.perPage; + const limit = input.perPage; + + // 1) 메인 쿼리: vendorResponsesView 사용 + const { rows, total } = await db.transaction(async (tx) => { + // 검색 조건 + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + sql`${vendorResponsesView.rfqCode} ILIKE ${s}`, + sql`${vendorResponsesView.projectName} ILIKE ${s}`, + sql`${vendorResponsesView.rfqDescription} ILIKE ${s}` + ); + } + + // 벤더 ID 필터링 + const mainWhere = and(eq(vendorResponsesView.vendorId, vendorId), globalWhere); + + // 정렬: 응답 시간순 + const orderBy = [desc(vendorResponsesView.respondedAt)]; + + // (A) 데이터 조회 + const data = await tx + .select() + .from(vendorResponsesView) + .where(mainWhere) + .orderBy(...orderBy) + .offset(offset) + .limit(limit); + + // (B) 전체 개수 카운트 + const [{ count }] = await tx + .select({ + count: sql<number>`count(*)`.as("count"), + }) + .from(vendorResponsesView) + .where(mainWhere); + + return { rows: data, total: Number(count) }; + }); + + // 2) rfqId 고유 목록 추출 + const distinctRfqs = [...new Set(rows.map((r) => r.rfqId))]; + if (distinctRfqs.length === 0) { + return { data: [], pageCount: 0 }; + } + + // 3) 추가 데이터 조회 + // 3-A) RFQ 아이템 + const itemsAll = await db + .select({ + id: rfqItems.id, + rfqId: rfqItems.rfqId, + itemCode: rfqItems.itemCode, + itemName: items.itemName, + quantity: rfqItems.quantity, + description: rfqItems.description, + uom: rfqItems.uom, + }) + .from(rfqItems) + .leftJoin(items, eq(rfqItems.itemCode, items.itemCode)) + .where(inArray(rfqItems.rfqId, distinctRfqs)); + + // 3-B) RFQ 첨부 파일 (벤더용) + const attachAll = await db + .select() + .from(rfqAttachments) + .where( + and( + inArray(rfqAttachments.rfqId, distinctRfqs), + isNull(rfqAttachments.vendorId) + ) + ); + + // 3-C) RFQ 코멘트 + const commAll = await db + .select() + .from(rfqComments) + .where( + and( + inArray(rfqComments.rfqId, distinctRfqs), + or( + isNull(rfqComments.vendorId), + eq(rfqComments.vendorId, vendorId) + ) + ) + ); + + + // 3-E) 벤더 응답 상세 - 기술 + const technicalResponsesAll = await db + .select() + .from(vendorTechnicalResponses) + .where( + inArray( + vendorTechnicalResponses.responseId, + rows.map((r) => r.responseId) + ) + ); + + // 3-F) 벤더 응답 상세 - 상업 + const commercialResponsesAll = await db + .select() + .from(vendorCommercialResponses) + .where( + inArray( + vendorCommercialResponses.responseId, + rows.map((r) => r.responseId) + ) + ); + + // 3-G) 벤더 응답 첨부 파일 + const responseAttachmentsAll = await db + .select() + .from(vendorResponseAttachments) + .where( + inArray( + vendorResponseAttachments.responseId, + rows.map((r) => r.responseId) + ) + ); + + // 4) 데이터 그룹화 + // RFQ 아이템 그룹화 + const itemsByRfqId = new Map<number, any[]>(); + for (const it of itemsAll) { + if (!itemsByRfqId.has(it.rfqId)) { + itemsByRfqId.set(it.rfqId, []); + } + itemsByRfqId.get(it.rfqId)!.push({ + id: it.id, + itemCode: it.itemCode, + itemName: it.itemName, + quantity: it.quantity, + description: it.description, + uom: it.uom, + }); + } + + // RFQ 첨부 파일 그룹화 + const attachByRfqId = new Map<number, any[]>(); + for (const att of attachAll) { + const rid = att.rfqId!; + if (!attachByRfqId.has(rid)) { + attachByRfqId.set(rid, []); + } + attachByRfqId.get(rid)!.push({ + id: att.id, + fileName: att.fileName, + filePath: att.filePath, + vendorId: att.vendorId, + evaluationId: att.evaluationId, + }); + } + + // RFQ 코멘트 그룹화 + const commByRfqId = new Map<number, any[]>(); + for (const c of commAll) { + const rid = c.rfqId!; + if (!commByRfqId.has(rid)) { + commByRfqId.set(rid, []); + } + commByRfqId.get(rid)!.push({ + id: c.id, + commentText: c.commentText, + vendorId: c.vendorId, + evaluationId: c.evaluationId, + createdAt: c.createdAt, + }); + } + + + // 기술 응답 그룹화 + const techResponseByResponseId = new Map<number, any>(); + for (const tr of technicalResponsesAll) { + techResponseByResponseId.set(tr.responseId, { + id: tr.id, + summary: tr.summary, + notes: tr.notes, + createdAt: tr.createdAt, + updatedAt: tr.updatedAt, + }); + } + + // 상업 응답 그룹화 + const commResponseByResponseId = new Map<number, any>(); + for (const cr of commercialResponsesAll) { + commResponseByResponseId.set(cr.responseId, { + id: cr.id, + totalPrice: cr.totalPrice, + currency: cr.currency, + paymentTerms: cr.paymentTerms, + incoterms: cr.incoterms, + deliveryPeriod: cr.deliveryPeriod, + warrantyPeriod: cr.warrantyPeriod, + validityPeriod: cr.validityPeriod, + priceBreakdown: cr.priceBreakdown, + commercialNotes: cr.commercialNotes, + createdAt: cr.createdAt, + updatedAt: cr.updatedAt, + }); + } + + // 응답 첨부 파일 그룹화 + const respAttachByResponseId = new Map<number, any[]>(); + for (const ra of responseAttachmentsAll) { + const rid = ra.responseId!; + if (!respAttachByResponseId.has(rid)) { + respAttachByResponseId.set(rid, []); + } + respAttachByResponseId.get(rid)!.push({ + id: ra.id, + fileName: ra.fileName, + filePath: ra.filePath, + attachmentType: ra.attachmentType, + description: ra.description, + uploadedAt: ra.uploadedAt, + uploadedBy: ra.uploadedBy, + }); + } + + // 5) 최종 데이터 결합 + const final = rows.map((row) => { + return { + // 응답 정보 + responseId: row.responseId, + responseStatus: row.responseStatus, + respondedAt: row.respondedAt, + + // RFQ 기본 정보 + rfqId: row.rfqId, + rfqCode: row.rfqCode, + rfqDescription: row.rfqDescription, + rfqDueDate: row.rfqDueDate, + rfqStatus: row.rfqStatus, + rfqType: row.rfqType, + rfqCreatedAt: row.rfqCreatedAt, + rfqUpdatedAt: row.rfqUpdatedAt, + rfqCreatedBy: row.rfqCreatedBy, + + // 프로젝트 정보 + projectId: row.projectId, + projectCode: row.projectCode, + projectName: row.projectName, + + // 벤더 정보 + vendorId: row.vendorId, + vendorName: row.vendorName, + vendorCode: row.vendorCode, + + // RFQ 관련 데이터 + items: itemsByRfqId.get(row.rfqId) || [], + attachments: attachByRfqId.get(row.rfqId) || [], + comments: commByRfqId.get(row.rfqId) || [], + + // 평가 정보 + tbeEvaluation: row.tbeId ? { + id: row.tbeId, + result: row.tbeResult, + } : null, + cbeEvaluation: row.cbeId ? { + id: row.cbeId, + result: row.cbeResult, + } : null, + + // 벤더 응답 상세 + technicalResponse: techResponseByResponseId.get(row.responseId) || null, + commercialResponse: commResponseByResponseId.get(row.responseId) || null, + responseAttachments: respAttachByResponseId.get(row.responseId) || [], + + // 응답 상태 표시 + hasTechnicalResponse: row.hasTechnicalResponse, + hasCommercialResponse: row.hasCommercialResponse, + attachmentCount: row.attachmentCount || 0, + }; + }); + + const pageCount = Math.ceil(total / input.perPage); + return { data: final, pageCount }; + }, + [JSON.stringify(input), `${vendorId}`], + { + revalidate: 600, + tags: ["rfqs-vendor", `vendor-${vendorId}`], + } + )(); +}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/types.ts b/lib/vendor-rfq-response/types.ts new file mode 100644 index 00000000..5dadc89b --- /dev/null +++ b/lib/vendor-rfq-response/types.ts @@ -0,0 +1,76 @@ +// RFQ 아이템 타입 +export interface RfqResponseItem { + id: number; + itemCode: string; + itemName: string; + quantity?: number; + uom?: string; + description?: string | null; +} + +// RFQ 첨부 파일 타입 +export interface RfqResponseAttachment { + id: number; + fileName: string; + filePath: string; + vendorId?: number | null; + evaluationId?: number | null; +} + +// RFQ 코멘트 타입 +export interface RfqResponseComment { + id: number; + commentText: string; + vendorId?: number | null; + evaluationId?: number | null; + createdAt: Date; + commentedBy?: number; +} + +// 최종 RfqResponse 타입 - RFQ 참여 응답만 포함하도록 간소화 +export interface RfqResponse { + // 응답 정보 + responseId: number; + responseStatus: "INVITED" | "ACCEPTED" | "DECLINED" | "REVIEWING" | "RESPONDED"; + respondedAt: Date; + + // RFQ 기본 정보 + rfqId: number; + rfqCode: string; + rfqDescription?: string | null; + rfqDueDate?: Date | null; + rfqStatus: string; + rfqType?: string | null; + rfqCreatedAt: Date; + rfqUpdatedAt: Date; + rfqCreatedBy?: number | null; + + // 프로젝트 정보 + projectId?: number | null; + projectCode?: string | null; + projectName?: string | null; + + // 벤더 정보 + vendorId: number; + vendorName: string; + vendorCode?: string | null; + + // RFQ 관련 데이터 + items: RfqResponseItem[]; + attachments: RfqResponseAttachment[]; + comments: RfqResponseComment[]; +} + +// DataTable 등에서 사용할 수 있도록 id 필드를 추가한 확장 타입 +export interface RfqResponseWithId extends RfqResponse { + id: number; // rfqId와 동일하게 사용 +} + +// 페이지네이션 결과 타입 +export interface RfqResponsesResult { + data: RfqResponseWithId[]; + pageCount: number; +} + +// 이전 버전과의 호환성을 위한 RfqWithAll 타입 (이름만 유지) +export type RfqWithAll = RfqResponseWithId;
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-rfq-table/ItemsDialog.tsx b/lib/vendor-rfq-response/vendor-rfq-table/ItemsDialog.tsx new file mode 100644 index 00000000..504fc177 --- /dev/null +++ b/lib/vendor-rfq-response/vendor-rfq-table/ItemsDialog.tsx @@ -0,0 +1,125 @@ +"use client" + +import * as React from "react" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { + Table, + TableBody, + TableCaption, + TableCell, + TableFooter, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { RfqWithAll } from "../types" +/** + * 아이템 구조 예시 + * - API 응답에서 quantity가 "string" 형태이므로, + * 숫자로 사용하실 거라면 parse 과정이 필요할 수 있습니다. + */ +export interface RfqItem { + id: number + itemCode: string + itemName: string + quantity: string + description: string + uom: string +} + +/** + * 첨부파일 구조 예시 + */ +export interface RfqAttachment { + id: number + fileName: string + filePath: string + vendorId: number | null + evaluationId: number | null +} + + +/** + * 다이얼로그 내에서만 사용할 단순 아이템 구조 (예: 임시/기본값 표출용) + */ +export interface DefaultItem { + id?: number + itemCode: string + description?: string | null + quantity?: number | null + uom?: string | null +} + +/** + * RfqsItemsDialog 컴포넌트 Prop 타입 + */ +export interface RfqsItemsDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + rfq: RfqWithAll + defaultItems?: DefaultItem[] +} + +export function RfqsItemsDialog({ + open, + onOpenChange, + rfq, +}: RfqsItemsDialogProps) { + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-none w-[1200px]"> + <DialogHeader> + <DialogTitle>Items for RFQ {rfq?.rfqCode}</DialogTitle> + <DialogDescription> + Below is the list of items for this RFQ. + </DialogDescription> + </DialogHeader> + + <div className="overflow-x-auto w-full space-y-4"> + {rfq && rfq.items.length === 0 && ( + <p className="text-sm text-muted-foreground">No items found.</p> + )} + {rfq && rfq.items.length > 0 && ( + <Table> + {/* 필요에 따라 TableCaption 등을 추가해도 좋습니다. */} + <TableHeader> + <TableRow> + <TableHead>Item Code</TableHead> + <TableHead>Item Code</TableHead> + <TableHead>Description</TableHead> + <TableHead>Qty</TableHead> + <TableHead>UoM</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {rfq.items.map((it, idx) => ( + <TableRow key={it.id ?? idx}> + <TableCell>{it.itemCode || "No Code"}</TableCell> + <TableCell>{it.itemName || "No Name"}</TableCell> + <TableCell>{it.description || "-"}</TableCell> + <TableCell>{it.quantity ?? 1}</TableCell> + <TableCell>{it.uom ?? "each"}</TableCell> + </TableRow> + ))} + </TableBody> + </Table> + )} + </div> + + <DialogFooter className="mt-4"> + <Button type="button" variant="outline" onClick={() => onOpenChange(false)}> + Close + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-rfq-table/attachment-rfq-sheet.tsx b/lib/vendor-rfq-response/vendor-rfq-table/attachment-rfq-sheet.tsx new file mode 100644 index 00000000..6c51c12c --- /dev/null +++ b/lib/vendor-rfq-response/vendor-rfq-table/attachment-rfq-sheet.tsx @@ -0,0 +1,106 @@ +"use client" + +import * as React from "react" +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, + SheetFooter, + SheetClose, +} from "@/components/ui/sheet" +import { Button } from "@/components/ui/button" +import { Download } from "lucide-react" +import { formatDate } from "@/lib/utils" + +// 첨부파일 구조 +interface RfqAttachment { + id: number + fileName: string + filePath: string + createdAt?: Date // or Date + vendorId?: number | null + size?: number +} + +// 컴포넌트 Prop +interface RfqAttachmentsSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { + rfqId: number + attachments?: RfqAttachment[] +} + +/** + * RfqAttachmentsSheet: + * - 단순히 첨부파일 리스트 + 다운로드 버튼만 + */ +export function RfqAttachmentsSheet({ + rfqId, + attachments = [], + ...props +}: RfqAttachmentsSheetProps) { + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-sm"> + <SheetHeader> + <SheetTitle>Attachments</SheetTitle> + <SheetDescription>RFQ #{rfqId}에 대한 첨부파일 목록</SheetDescription> + </SheetHeader> + + <div className="space-y-2"> + {/* 첨부파일이 없을 경우 */} + {attachments.length === 0 && ( + <p className="text-sm text-muted-foreground"> + No attachments + </p> + )} + + {/* 첨부파일 목록 */} + {attachments.map((att) => ( + <div + key={att.id} + className="flex items-center justify-between rounded border p-2" + > + <div className="flex flex-col text-sm"> + <span className="font-medium">{att.fileName}</span> + {att.size && ( + <span className="text-xs text-muted-foreground"> + {Math.round(att.size / 1024)} KB + </span> + )} + {att.createdAt && ( + <span className="text-xs text-muted-foreground"> + Created at {formatDate(att.createdAt)} + </span> + )} + </div> + {/* 파일 다운로드 버튼 */} + {att.filePath && ( + <a + href={att.filePath} + download + target="_blank" + rel="noreferrer" + className="text-sm" + > + <Button variant="ghost" size="icon" type="button"> + <Download className="h-4 w-4" /> + </Button> + </a> + )} + </div> + ))} + </div> + + <SheetFooter className="gap-2 pt-2"> + {/* 닫기 버튼 */} + <SheetClose asChild> + <Button type="button" variant="outline"> + Close + </Button> + </SheetClose> + </SheetFooter> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-rfq-table/comments-sheet.tsx b/lib/vendor-rfq-response/vendor-rfq-table/comments-sheet.tsx new file mode 100644 index 00000000..d401f1cd --- /dev/null +++ b/lib/vendor-rfq-response/vendor-rfq-table/comments-sheet.tsx @@ -0,0 +1,415 @@ +"use client" + +import * as React from "react" +import { useForm, useFieldArray } from "react-hook-form" +import { z } from "zod" +import { zodResolver } from "@hookform/resolvers/zod" +import { Loader, Download, X } from "lucide-react" +import prettyBytes from "pretty-bytes" +import { toast } from "sonner" + +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Textarea, +} from "@/components/ui/textarea" + +import { + Dropzone, + DropzoneZone, + DropzoneUploadIcon, + DropzoneTitle, + DropzoneDescription, + DropzoneInput +} from "@/components/ui/dropzone" + +import { + Table, + TableHeader, + TableRow, + TableHead, + TableBody, + TableCell +} from "@/components/ui/table" + +// DB 스키마에서 필요한 타입들을 가져온다고 가정 +import { RfqWithAll } from "../types" + +import { createRfqCommentWithAttachments, updateRfqComment } from "../../rfqs/service" +import { formatDate } from "@/lib/utils" + +// 코멘트 + 첨부파일 구조 (단순 예시) +// 실제 DB 스키마에 맞춰 조정 +export interface RfqComment { + id: number + commentText: string + commentedBy?: number + createdAt?: Date + attachments?: { + id: number + fileName: string + filePath?: string + }[] +} + +interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { + /** 코멘트를 작성할 RFQ 정보 */ + /** 이미 존재하는 모든 코멘트 목록 (서버에서 불러와 주입) */ + initialComments?: RfqComment[] + + /** 사용자(작성자) ID (로그인 세션 등에서 가져옴) */ + currentUserId: number + rfq:RfqWithAll + /** 댓글 저장 후 갱신용 콜백 (옵션) */ + onCommentsUpdated?: (comments: RfqComment[]) => void +} + +// 새 코멘트 작성 폼 스키마 +const commentFormSchema = z.object({ + commentText: z.string().min(1, "댓글을 입력하세요."), + newFiles: z.array(z.any()).optional() // File[] +}) +type CommentFormValues = z.infer<typeof commentFormSchema> + +const MAX_FILE_SIZE = 30e6 // 30MB + +export function CommentSheet({ + rfq, + initialComments = [], + currentUserId, + onCommentsUpdated, + ...props +}: CommentSheetProps) { + const [comments, setComments] = React.useState<RfqComment[]>(initialComments) + const [isPending, startTransition] = React.useTransition() + + React.useEffect(() => { + setComments(initialComments) + }, [initialComments]) + + // RHF 세팅 + const form = useForm<CommentFormValues>({ + resolver: zodResolver(commentFormSchema), + defaultValues: { + commentText: "", + newFiles: [] + } + }) + + // formFieldArray 예시 (파일 목록) + const { fields: newFileFields, append, remove } = useFieldArray({ + control: form.control, + name: "newFiles" + }) + + // 1) 기존 코멘트 + 첨부 보여주기 + // 간단히 테이블 하나로 표현 + // 실제로는 Bubble 형태의 UI, Accordion, Timeline 등 다양하게 구성할 수 있음 + function renderExistingComments() { + // 1) 편집 상태 관리 + const [editingId, setEditingId] = React.useState<number | null>(null) + const [editText, setEditText] = React.useState("") + + // 2) Edit 시작 핸들러 + function handleEditClick(c: RfqComment) { + setEditingId(c.id) + setEditText(c.commentText) + } + + // 3) Save 핸들러 + async function handleSave(commentId: number) { + try { + // (예시) 서버 액션 or API 요청 + await updateRfqComment({ commentId, commentText: editText }) + + // 만약 단순 로컬 수정만 할 거라면, + // parent state의 comments를 갱신하는 로직 필요 + setComments((prev) => + prev.map((comment) => + comment.id === commentId + ? { ...comment, commentText: editText } + : comment + ) + ) + + toast.success("Comment updated.") + } catch (err) { + toast.error("Error updating comment.") + } finally { + // 편집 모드 종료 + setEditingId(null) + setEditText("") + } + } + + // 4) Cancel 핸들러 + function handleCancel() { + setEditingId(null) + setEditText("") + } + + // 만약 comments가 비어 있다면 + if (comments.length === 0) { + return <p className="text-sm text-muted-foreground">No comments yet</p> + } + + // 5) 테이블 렌더링 + return ( + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-1/2">Comment</TableHead> + <TableHead>Attachments</TableHead> + <TableHead>Created At</TableHead> + <TableHead>Created By</TableHead> + + {/* 추가된 Actions 컬럼 */} + <TableHead>Actions</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {comments.map((c) => ( + <TableRow key={c.id}> + {/* 1) Comment 셀 */} + <TableCell> + {/* 현재 행이 editing 모드인지 체크 */} + {editingId === c.id ? ( + // 편집 모드 + <textarea + value={editText} + onChange={(e) => setEditText(e.target.value)} + className="w-full border p-1 rounded" + rows={3} + /> + ) : ( + // 일반 모드 + c.commentText + )} + </TableCell> + + {/* 2) Attachments 셀 (기존과 동일) */} + <TableCell> + {(!c.attachments || c.attachments.length === 0) && ( + <span className="text-sm text-muted-foreground">No files</span> + )} + {c.attachments && c.attachments.length > 0 && ( + <div className="flex flex-col gap-1"> + {c.attachments.map((att) => ( + <div key={att.id} className="flex items-center gap-2"> + <a + href={att.filePath} + download + target="_blank" + rel="noreferrer" + className="inline-flex items-center gap-1 text-blue-600 underline" + > + <Download className="h-4 w-4" /> + {att.fileName} + </a> + </div> + ))} + </div> + )} + </TableCell> + + {/* 3) Created At */} + <TableCell> { c.createdAt ? formatDate(c.createdAt): "-"}</TableCell> + + {/* 4) Created By */} + <TableCell>{c.commentedBy ?? "-"}</TableCell> + + {/* 5) 새로 추가된 Actions */} + <TableCell> + {editingId === c.id ? ( + // 편집 중일 때 + <div className="flex gap-2"> + <Button variant="outline" size="sm" onClick={() => handleSave(c.id)}> + Save + </Button> + <Button variant="ghost" size="sm" onClick={handleCancel}> + Cancel + </Button> + </div> + ) : ( + // 일반 상태 + <Button variant="outline" size="sm" onClick={() => handleEditClick(c)}> + Edit + </Button> + )} + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + ) + } + + // 2) 새 파일 Drop + function handleDropAccepted(files: File[]) { + // 드롭된 File[]을 RHF field array에 추가 + const toAppend = files.map((f) => f) + append(toAppend) + } + + + // 3) 저장(Submit) + async function onSubmit(data: CommentFormValues) { + + if (!rfq) return + startTransition(async () => { + try { + // 서버 액션 호출 + const res = await createRfqCommentWithAttachments({ + rfqId: rfq.id, + vendorId: rfq.vendorId, // 필요시 세팅 + commentText: data.commentText, + commentedBy: currentUserId, + evaluationId: null, // 필요시 세팅 + files: data.newFiles + }) + + if (!res.ok) { + throw new Error("Failed to create comment") + } + + toast.success("Comment created") + + // 새 코멘트를 다시 불러오거나, + // 여기서는 임시로 "새로운 코멘트가 추가됐다" 라고 가정하여 클라이언트에서 상태 업데이트 + const newComment: RfqComment = { + id: res.commentId, // 서버에서 반환된 commentId + commentText: data.commentText, + commentedBy: currentUserId, + createdAt: res.createdAt, + attachments: (data.newFiles?.map((f, idx) => ({ + id: Math.random() * 100000, + fileName: f.name, + })) || []) + } + setComments((prev) => [...prev, newComment]) + onCommentsUpdated?.([...comments, newComment]) + + // 폼 리셋 + form.reset() + } catch (err: any) { + console.error(err) + toast.error("Error: " + err.message) + } + }) + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-lg"> + <SheetHeader className="text-left"> + <SheetTitle>Comments</SheetTitle> + <SheetDescription> + 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다. + </SheetDescription> + </SheetHeader> + + {/* 기존 코멘트 목록 */} + <div className="max-h-[300px] overflow-y-auto"> + {renderExistingComments()} + </div> + + {/* 새 코멘트 작성 Form */} + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> + <FormField + control={form.control} + name="commentText" + render={({ field }) => ( + <FormItem> + <FormLabel>New Comment</FormLabel> + <FormControl> + <Textarea + placeholder="Enter your comment..." + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Dropzone (파일 첨부) */} + <Dropzone + maxSize={MAX_FILE_SIZE} + onDropAccepted={handleDropAccepted} + onDropRejected={(rej) => { + toast.error("File rejected: " + (rej[0]?.file?.name || "")) + }} + > + {({ maxSize }) => ( + <DropzoneZone className="flex justify-center"> + <DropzoneInput /> + <div className="flex items-center gap-6"> + <DropzoneUploadIcon /> + <div className="grid gap-0.5"> + <DropzoneTitle>Drop to attach files</DropzoneTitle> + <DropzoneDescription> + Max size: {prettyBytes(maxSize || 0)} + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + )} + </Dropzone> + + {/* 선택된 파일 목록 */} + {newFileFields.length > 0 && ( + <div className="flex flex-col gap-2"> + {newFileFields.map((field, idx) => { + const file = form.getValues(`newFiles.${idx}`) + if (!file) return null + return ( + <div key={field.id} className="flex items-center justify-between border rounded p-2"> + <span className="text-sm">{file.name} ({prettyBytes(file.size)})</span> + <Button + variant="ghost" + size="icon" + type="button" + onClick={() => remove(idx)} + > + <X className="h-4 w-4" /> + </Button> + </div> + ) + })} + </div> + )} + + <SheetFooter className="gap-2 pt-4"> + <SheetClose asChild> + <Button type="button" variant="outline"> + Cancel + </Button> + </SheetClose> + <Button disabled={isPending}> + {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />} + Save + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-rfq-table/feature-flags-provider.tsx b/lib/vendor-rfq-response/vendor-rfq-table/feature-flags-provider.tsx new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/vendor-rfq-response/vendor-rfq-table/feature-flags-provider.tsx @@ -0,0 +1,108 @@ +"use client" + +import * as React from "react" +import { useQueryState } from "nuqs" + +import { dataTableConfig, type DataTableConfig } from "@/config/data-table" +import { cn } from "@/lib/utils" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" + +type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] + +interface FeatureFlagsContextProps { + featureFlags: FeatureFlagValue[] + setFeatureFlags: (value: FeatureFlagValue[]) => void +} + +const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({ + featureFlags: [], + setFeatureFlags: () => {}, +}) + +export function useFeatureFlags() { + const context = React.useContext(FeatureFlagsContext) + if (!context) { + throw new Error( + "useFeatureFlags must be used within a FeatureFlagsProvider" + ) + } + return context +} + +interface FeatureFlagsProviderProps { + children: React.ReactNode +} + +export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { + const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( + "flags", + { + defaultValue: [], + parse: (value) => value.split(",") as FeatureFlagValue[], + serialize: (value) => value.join(","), + eq: (a, b) => + a.length === b.length && a.every((value, index) => value === b[index]), + clearOnDefault: true, + shallow: false, + } + ) + + return ( + <FeatureFlagsContext.Provider + value={{ + featureFlags, + setFeatureFlags: (value) => void setFeatureFlags(value), + }} + > + <div className="w-full overflow-x-auto"> + <ToggleGroup + type="multiple" + variant="outline" + size="sm" + value={featureFlags} + onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} + className="w-fit gap-0" + > + {dataTableConfig.featureFlags.map((flag, index) => ( + <Tooltip key={flag.value}> + <ToggleGroupItem + value={flag.value} + className={cn( + "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90", + { + "rounded-l-sm border-r-0": index === 0, + "rounded-r-sm": + index === dataTableConfig.featureFlags.length - 1, + } + )} + asChild + > + <TooltipTrigger> + <flag.icon className="size-3.5 shrink-0" aria-hidden="true" /> + {flag.label} + </TooltipTrigger> + </ToggleGroupItem> + <TooltipContent + align="start" + side="bottom" + sideOffset={6} + className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" + > + <div>{flag.tooltipTitle}</div> + <div className="text-xs text-muted-foreground"> + {flag.tooltipDescription} + </div> + </TooltipContent> + </Tooltip> + ))} + </ToggleGroup> + </div> + {children} + </FeatureFlagsContext.Provider> + ) +} diff --git a/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx b/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx new file mode 100644 index 00000000..ac8fa35e --- /dev/null +++ b/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx @@ -0,0 +1,421 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import { ColumnDef } from "@tanstack/react-table" +import { + Ellipsis, + MessageSquare, + Package, + Paperclip, +} from "lucide-react" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger +} from "@/components/ui/dropdown-menu" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { Badge } from "@/components/ui/badge" + +import { getErrorMessage } from "@/lib/handle-error" +import { formatDate, formatDateTime } from "@/lib/utils" +import { modifyRfqVendor } from "../../rfqs/service" +import type { RfqWithAll } from "../types" +import type { DataTableRowAction } from "@/types/table" + +type NextRouter = ReturnType<typeof useRouter> + +interface GetColumnsProps { + setRowAction: React.Dispatch< + React.SetStateAction<DataTableRowAction<RfqWithAll> | null> + > + router: NextRouter + openAttachmentsSheet: (rfqId: number) => void + openCommentSheet: (rfqId: number) => void +} + +/** + * tanstack table 컬럼 정의 (Nested Header) + */ +export function getColumns({ + setRowAction, + router, + openAttachmentsSheet, + openCommentSheet, +}: GetColumnsProps): ColumnDef<RfqWithAll>[] { + // 1) 체크박스(Select) 컬럼 + const selectColumn: ColumnDef<RfqWithAll> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + } + + // 2) Actions (Dropdown) + const actionsColumn: ColumnDef<RfqWithAll> = { + id: "actions", + enableHiding: false, + cell: ({ row }) => { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" size="icon"> + <Ellipsis className="h-4 w-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-56"> + <DropdownMenuSub> + <DropdownMenuSubTrigger>RFQ Response</DropdownMenuSubTrigger> + <DropdownMenuSubContent> + <DropdownMenuRadioGroup + value={row.original.responseStatus} + onValueChange={(value) => { + startUpdateTransition(async () => { + let newStatus: + | "ACCEPTED" + | "DECLINED" + | "REVIEWING" + + switch (value) { + case "ACCEPTED": + newStatus = "ACCEPTED" + break + case "DECLINED": + newStatus = "DECLINED" + break + default: + newStatus = "REVIEWING" + } + + await toast.promise( + modifyRfqVendor({ + id: row.original.responseId, + status: newStatus, + }), + { + loading: "Updating response status...", + success: "Response status updated", + error: (err) => getErrorMessage(err), + } + ) + }) + }} + > + {[ + { value: "ACCEPTED", label: "Accept RFQ" }, + { value: "DECLINED", label: "Decline RFQ" }, + ].map((rep) => ( + <DropdownMenuRadioItem + key={rep.value} + value={rep.value} + className="capitalize" + disabled={isUpdatePending} + > + {rep.label} + </DropdownMenuRadioItem> + ))} + </DropdownMenuRadioGroup> + </DropdownMenuSubContent> + </DropdownMenuSub> + {/* <DropdownMenuItem + onClick={() => { + router.push(`/vendor/rfqs/${row.original.rfqId}`) + }} + > + View Details + </DropdownMenuItem> */} + {/* <DropdownMenuItem onClick={() => openAttachmentsSheet(row.original.rfqId)}> + View Attachments + </DropdownMenuItem> + <DropdownMenuItem onClick={() => openCommentSheet(row.original.rfqId)}> + View Comments + </DropdownMenuItem> + <DropdownMenuItem onClick={() => setRowAction({ row, type: "items" })}> + View Items + </DropdownMenuItem> */} + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + } + + // 3) RFQ Code 컬럼 + const rfqCodeColumn: ColumnDef<RfqWithAll> = { + id: "rfqCode", + accessorKey: "rfqCode", + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="RFQ Code" /> + ), + cell: ({ row }) => { + return ( + <Button + variant="link" + className="p-0 h-auto font-medium" + onClick={() => router.push(`/vendor/rfqs/${row.original.rfqId}`)} + > + {row.original.rfqCode} + </Button> + ) + }, + size: 150, + } + + // 4) 응답 상태 컬럼 + const responseStatusColumn: ColumnDef<RfqWithAll> = { + id: "responseStatus", + accessorKey: "responseStatus", + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Response Status" /> + ), + cell: ({ row }) => { + const status = row.original.responseStatus; + let variant: "default" | "secondary" | "destructive" | "outline"; + + switch (status) { + case "REVIEWING": + variant = "default"; + break; + case "ACCEPTED": + variant = "secondary"; + break; + case "DECLINED": + variant = "destructive"; + break; + default: + variant = "outline"; + } + + return <Badge variant={variant}>{status}</Badge>; + }, + size: 150, + } + + // 5) 프로젝트 이름 컬럼 + const projectNameColumn: ColumnDef<RfqWithAll> = { + id: "projectName", + accessorKey: "projectName", + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Project" /> + ), + cell: ({ row }) => row.original.projectName || "-", + size: 150, + } + + // 6) RFQ Description 컬럼 + const descriptionColumn: ColumnDef<RfqWithAll> = { + id: "rfqDescription", + accessorKey: "rfqDescription", + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Description" /> + ), + cell: ({ row }) => row.original.rfqDescription || "-", + size: 200, + } + + // 7) Due Date 컬럼 + const dueDateColumn: ColumnDef<RfqWithAll> = { + id: "rfqDueDate", + accessorKey: "rfqDueDate", + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Due Date" /> + ), + cell: ({ row }) => { + const date = row.original.rfqDueDate; + return date ? formatDate(date) : "-"; + }, + size: 120, + } + + // 8) Last Updated 컬럼 + const updatedAtColumn: ColumnDef<RfqWithAll> = { + id: "respondedAt", + accessorKey: "respondedAt", + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Last Updated" /> + ), + cell: ({ row }) => { + const date = row.original.respondedAt; + return date ? formatDateTime(date) : "-"; + }, + size: 150, + } + + // 9) Items 컬럼 - 뱃지로 아이템 개수 표시 + const itemsColumn: ColumnDef<RfqWithAll> = { + id: "items", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Items" /> + ), + cell: ({ row }) => { + const rfq = row.original + const count = rfq.items?.length ?? 0 + + function handleClick() { + setRowAction({ row, type: "items" }) + } + + return ( + <Button + variant="ghost" + size="sm" + className="relative h-8 w-8 p-0 group" + onClick={handleClick} + aria-label={count > 0 ? `View ${count} items` : "No items"} + > + <Package className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + {count > 0 && ( + <Badge + variant="secondary" + className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" + > + {count} + </Badge> + )} + + <span className="sr-only"> + {count > 0 ? `${count} Items` : "No Items"} + </span> + </Button> + ) + }, + enableSorting: false, + maxSize: 80, + } + + // 10) Attachments 컬럼 - 뱃지로 파일 개수 표시 + const attachmentsColumn: ColumnDef<RfqWithAll> = { + id: "attachments", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Attachments" /> + ), + cell: ({ row }) => { + const attachCount = row.original.attachments?.length ?? 0 + + function handleClick(e: React.MouseEvent<HTMLButtonElement>) { + e.preventDefault() + openAttachmentsSheet(row.original.rfqId) + } + + return ( + <Button + variant="ghost" + size="sm" + className="relative h-8 w-8 p-0 group" + onClick={handleClick} + aria-label={ + attachCount > 0 ? `View ${attachCount} files` : "No files" + } + > + <Paperclip className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + {attachCount > 0 && ( + <Badge + variant="secondary" + className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" + > + {attachCount} + </Badge> + )} + <span className="sr-only"> + {attachCount > 0 ? `${attachCount} Files` : "No Files"} + </span> + </Button> + ) + }, + enableSorting: false, + maxSize: 80, + } + + // 11) Comments 컬럼 - 뱃지로 댓글 개수 표시 + const commentsColumn: ColumnDef<RfqWithAll> = { + id: "comments", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Comments" /> + ), + cell: ({ row }) => { + const commCount = row.original.comments?.length ?? 0 + + function handleClick() { + setRowAction({ row, type: "comments" }) + openCommentSheet(row.original.rfqId) + } + + return ( + <Button + variant="ghost" + size="sm" + className="relative h-8 w-8 p-0 group" + onClick={handleClick} + aria-label={ + commCount > 0 ? `View ${commCount} comments` : "No comments" + } + > + <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + {commCount > 0 && ( + <Badge + variant="secondary" + className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" + > + {commCount} + </Badge> + )} + <span className="sr-only"> + {commCount > 0 ? `${commCount} Comments` : "No Comments"} + </span> + </Button> + ) + }, + enableSorting: false, + maxSize: 80, + } + + // 최종 컬럼 구성 - TBE/CBE 관련 컬럼 제외 + return [ + selectColumn, + rfqCodeColumn, + responseStatusColumn, + projectNameColumn, + descriptionColumn, + dueDateColumn, + itemsColumn, + attachmentsColumn, + commentsColumn, + updatedAtColumn, + actionsColumn, + ] +}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-toolbar-actions.tsx b/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-toolbar-actions.tsx new file mode 100644 index 00000000..1bae99ef --- /dev/null +++ b/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-toolbar-actions.tsx @@ -0,0 +1,40 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, Upload } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { RfqWithAll } from "../types" + + +interface RfqsTableToolbarActionsProps { + table: Table<RfqWithAll> +} + +export function RfqsVendorTableToolbarActions({ table }: RfqsTableToolbarActionsProps) { + + + return ( + <div className="flex items-center gap-2"> + + {/** 4) Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "tasks", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + ) +}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table.tsx b/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table.tsx new file mode 100644 index 00000000..337c2875 --- /dev/null +++ b/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table.tsx @@ -0,0 +1,270 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" +import { useRouter } from "next/navigation" + +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" + +import { useFeatureFlags } from "./feature-flags-provider" +import { getColumns } from "./rfqs-table-columns" +import { RfqWithAll } from "../types" + +import { + fetchRfqAttachments, + fetchRfqAttachmentsbyCommentId, +} from "../../rfqs/service" + +import { RfqsVendorTableToolbarActions } from "./rfqs-table-toolbar-actions" +import { RfqsItemsDialog } from "./ItemsDialog" +import { RfqAttachmentsSheet } from "./attachment-rfq-sheet" +import { CommentSheet, RfqComment } from "./comments-sheet" +import { getRfqResponsesForVendor } from "../service" + +interface RfqsTableProps { + promises: Promise<[Awaited<ReturnType<typeof getRfqResponsesForVendor>>]> +} + +// 코멘트+첨부파일 구조 예시 +export interface RfqCommentWithAttachments extends RfqComment { + attachments?: { + id: number + fileName: string + filePath: string + createdAt?: Date + vendorId?: number | null + size?: number + }[] +} + +export interface ExistingAttachment { + id: number + fileName: string + filePath: string + createdAt?: Date + vendorId?: number | null + size?: number +} + +export interface ExistingItem { + id?: number + itemCode: string + description: string | null + quantity: number | null + uom: string | null +} + +export function RfqsVendorTable({ promises }: RfqsTableProps) { + const { featureFlags } = useFeatureFlags() + + // 1) 테이블 데이터( RFQs ) + const [{ data: responseData, pageCount }] = React.use(promises) + + // 데이터를 RfqWithAll 타입으로 변환 (id 필드 추가) + const data: RfqWithAll[] = React.useMemo(() => { + return responseData.map(item => ({ + ...item, + id: item.rfqId, // id 필드를 rfqId와 동일하게 설정 + })); + }, [responseData]); + + const router = useRouter() + + // 2) 첨부파일 시트 + 관련 상태 + const [attachmentsOpen, setAttachmentsOpen] = React.useState(false) + const [selectedRfqIdForAttachments, setSelectedRfqIdForAttachments] = React.useState<number | null>(null) + const [attachDefault, setAttachDefault] = React.useState<ExistingAttachment[]>([]) + + // 3) 코멘트 시트 + 관련 상태 + const [initialComments, setInitialComments] = React.useState<RfqCommentWithAttachments[]>([]) + const [commentSheetOpen, setCommentSheetOpen] = React.useState(false) + const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null) + + // 4) rowAction으로 다양한 모달/시트 열기 + const [rowAction, setRowAction] = React.useState<DataTableRowAction<RfqWithAll> | null>(null) + + // 열리고 닫힐 때마다, rowAction 등을 확인해서 시트 열기/닫기 처리 + React.useEffect(() => { + if (rowAction?.type === "comments" && rowAction?.row.original) { + openCommentSheet(rowAction.row.original.id) + } + }, [rowAction]) + + /** + * (A) 코멘트 시트를 열기 전에, + * DB에서 (rfqId에 해당하는) 코멘트들 + 각 코멘트별 첨부파일을 조회. + */ + const openCommentSheet = React.useCallback(async (rfqId: number) => { + setInitialComments([]) + + // 여기서 rowAction을 직접 참조하지 않고, 필요한 데이터만 파라미터로 받기 + const comments = data.find(rfq => rfq.rfqId === rfqId)?.comments || [] + + if (comments && comments.length > 0) { + const commentWithAttachments = await Promise.all( + comments.map(async (c) => { + const attachments = await fetchRfqAttachmentsbyCommentId(c.id) + return { + ...c, + commentedBy: c.commentedBy || 1, + attachments, + } + }) + ) + + setInitialComments(commentWithAttachments) + } + + setSelectedRfqIdForComments(rfqId) + setCommentSheetOpen(true) + }, [data]) // data만 의존성으로 추가 + + /** + * (B) 첨부파일 시트 열기 + */ + const openAttachmentsSheet = React.useCallback(async (rfqId: number) => { + const list = await fetchRfqAttachments(rfqId) + setAttachDefault(list) + setSelectedRfqIdForAttachments(rfqId) + setAttachmentsOpen(true) + }, []) + + // 5) DataTable 컬럼 세팅 + const columns = React.useMemo( + () => + getColumns({ + setRowAction, + router, + openAttachmentsSheet, + openCommentSheet + }), + [setRowAction, router, openAttachmentsSheet, openCommentSheet] + ) + + /** + * 간단한 filterFields 예시 + */ + const filterFields: DataTableFilterField<RfqWithAll>[] = [ + { + id: "rfqCode", + label: "RFQ Code", + placeholder: "Filter RFQ Code...", + }, + { + id: "projectName", + label: "Project", + placeholder: "Filter Project...", + }, + { + id: "rfqDescription", + label: "Description", + placeholder: "Filter Description...", + }, + ] + + /** + * Advanced filter fields 예시 + */ + const advancedFilterFields: DataTableAdvancedFilterField<RfqWithAll>[] = [ + { + id: "rfqCode", + label: "RFQ Code", + type: "text", + }, + { + id: "rfqDescription", + label: "Description", + type: "text", + }, + { + id: "projectCode", + label: "Project Code", + type: "text", + }, + { + id: "projectName", + label: "Project Name", + type: "text", + }, + { + id: "rfqDueDate", + label: "Due Date", + type: "date", + }, + { + id: "responseStatus", + label: "Response Status", + type: "select", + options: [ + { label: "Reviewing", value: "REVIEWING" }, + { label: "Accepted", value: "ACCEPTED" }, + { label: "Declined", value: "DECLINED" }, + ], + } + ] + + // useDataTable() 훅 -> pagination, sorting 등 관리 + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "respondedAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <RfqsVendorTableToolbarActions table={table} /> + </DataTableAdvancedToolbar> + </DataTable> + + {/* 1) 아이템 목록 Dialog */} + {rowAction?.type === "items" && rowAction?.row.original && ( + <RfqsItemsDialog + open={true} + onOpenChange={() => setRowAction(null)} + rfq={rowAction.row.original} + /> + )} + + {/* 2) 코멘트 시트 */} + {selectedRfqIdForComments && ( + <CommentSheet + currentUserId={1} + open={commentSheetOpen} + onOpenChange={setCommentSheetOpen} + initialComments={initialComments} + rfq={data.find(item => item.rfqId === selectedRfqIdForComments)!} + /> + )} + + {/* 3) 첨부파일 시트 */} + <RfqAttachmentsSheet + open={attachmentsOpen} + onOpenChange={setAttachmentsOpen} + rfqId={selectedRfqIdForAttachments ?? 0} + attachments={attachDefault} + /> + </> + ) +}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx b/lib/vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx new file mode 100644 index 00000000..1eee54f5 --- /dev/null +++ b/lib/vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx @@ -0,0 +1,334 @@ +"use client" + +import * as React from "react" +import { useForm, useFieldArray } from "react-hook-form" +import { z } from "zod" +import { zodResolver } from "@hookform/resolvers/zod" +import { Loader, Download, X } from "lucide-react" +import prettyBytes from "pretty-bytes" +import { toast } from "sonner" + +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Textarea, +} from "@/components/ui/textarea" + +import { + Dropzone, + DropzoneZone, + DropzoneUploadIcon, + DropzoneTitle, + DropzoneDescription, + DropzoneInput +} from "@/components/ui/dropzone" + +import { + Table, + TableHeader, + TableRow, + TableHead, + TableBody, + TableCell +} from "@/components/ui/table" + +// DB 스키마에서 필요한 타입들을 가져온다고 가정 +// (실제 프로젝트에 맞춰 import를 수정하세요.) +import { RfqWithAll } from "@/db/schema/rfq" +import { createRfqCommentWithAttachments } from "../../rfqs/service" +import { formatDate } from "@/lib/utils" + +// 코멘트 + 첨부파일 구조 (단순 예시) +// 실제 DB 스키마에 맞춰 조정 +export interface TbeComment { + id: number + commentText: string + commentedBy?: number + createdAt?: string | Date + attachments?: { + id: number + fileName: string + filePath: string + }[] +} + +interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { + /** 코멘트를 작성할 RFQ 정보 */ + /** 이미 존재하는 모든 코멘트 목록 (서버에서 불러와 주입) */ + initialComments?: TbeComment[] + + /** 사용자(작성자) ID (로그인 세션 등에서 가져옴) */ + currentUserId: number + rfqId:number + vendorId:number + /** 댓글 저장 후 갱신용 콜백 (옵션) */ + onCommentsUpdated?: (comments: TbeComment[]) => void +} + +// 새 코멘트 작성 폼 스키마 +const commentFormSchema = z.object({ + commentText: z.string().min(1, "댓글을 입력하세요."), + newFiles: z.array(z.any()).optional() // File[] +}) +type CommentFormValues = z.infer<typeof commentFormSchema> + +const MAX_FILE_SIZE = 30e6 // 30MB + +export function CommentSheet({ + rfqId, + vendorId, + initialComments = [], + currentUserId, + onCommentsUpdated, + ...props +}: CommentSheetProps) { + const [comments, setComments] = React.useState<TbeComment[]>(initialComments) + const [isPending, startTransition] = React.useTransition() + + React.useEffect(() => { + setComments(initialComments) + }, [initialComments]) + + + // RHF 세팅 + const form = useForm<CommentFormValues>({ + resolver: zodResolver(commentFormSchema), + defaultValues: { + commentText: "", + newFiles: [] + } + }) + + // formFieldArray 예시 (파일 목록) + const { fields: newFileFields, append, remove } = useFieldArray({ + control: form.control, + name: "newFiles" + }) + + // 1) 기존 코멘트 + 첨부 보여주기 + // 간단히 테이블 하나로 표현 + // 실제로는 Bubble 형태의 UI, Accordion, Timeline 등 다양하게 구성할 수 있음 + function renderExistingComments() { + if (comments.length === 0) { + return <p className="text-sm text-muted-foreground">No comments yet</p> + } + + return ( + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-1/2">Comment</TableHead> + <TableHead>Attachments</TableHead> + <TableHead>Created At</TableHead> + <TableHead>Created By</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {comments.map((c) => ( + <TableRow key={c.id}> + <TableCell>{c.commentText}</TableCell> + <TableCell> + {/* 첨부파일 표시 */} + {(!c.attachments || c.attachments.length === 0) && ( + <span className="text-sm text-muted-foreground">No files</span> + )} + {c.attachments && c.attachments.length > 0 && ( + <div className="flex flex-col gap-1"> + {c.attachments.map((att) => ( + <div key={att.id} className="flex items-center gap-2"> + <a + href={att.filePath} + download + target="_blank" + rel="noreferrer" + className="inline-flex items-center gap-1 text-blue-600 underline" + > + <Download className="h-4 w-4" /> + {att.fileName} + </a> + </div> + ))} + </div> + )} + </TableCell> + <TableCell> { c.createdAt ? formatDate(c.createdAt): "-"}</TableCell> + <TableCell> + {c.commentedBy ?? "-"} + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + ) + } + + // 2) 새 파일 Drop + function handleDropAccepted(files: File[]) { + // 드롭된 File[]을 RHF field array에 추가 + const toAppend = files.map((f) => f) + append(toAppend) + } + + + // 3) 저장(Submit) + async function onSubmit(data: CommentFormValues) { + + if (!rfqId) return + startTransition(async () => { + try { + // 서버 액션 호출 + const res = await createRfqCommentWithAttachments({ + rfqId: rfqId, + vendorId: vendorId, // 필요시 세팅 + commentText: data.commentText, + commentedBy: currentUserId, + evaluationId: null, // 필요시 세팅 + files: data.newFiles + }) + + if (!res.ok) { + throw new Error("Failed to create comment") + } + + toast.success("Comment created") + + // 새 코멘트를 다시 불러오거나, + // 여기서는 임시로 "새로운 코멘트가 추가됐다" 라고 가정하여 클라이언트에서 상태 업데이트 + const newComment: TbeComment = { + id: res.commentId, // 서버에서 반환된 commentId + commentText: data.commentText, + commentedBy: currentUserId, + createdAt: new Date().toISOString(), + attachments: (data.newFiles?.map((f, idx) => ({ + id: Math.random() * 100000, + fileName: f.name, + filePath: "/uploads/" + f.name, + })) || []) + } + setComments((prev) => [...prev, newComment]) + onCommentsUpdated?.([...comments, newComment]) + + // 폼 리셋 + form.reset() + } catch (err: any) { + console.error(err) + toast.error("Error: " + err.message) + } + }) + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-lg"> + <SheetHeader className="text-left"> + <SheetTitle>Comments</SheetTitle> + <SheetDescription> + 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다. + </SheetDescription> + </SheetHeader> + + {/* 기존 코멘트 목록 */} + <div className="max-h-[300px] overflow-y-auto"> + {renderExistingComments()} + </div> + + {/* 새 코멘트 작성 Form */} + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> + <FormField + control={form.control} + name="commentText" + render={({ field }) => ( + <FormItem> + <FormLabel>New Comment</FormLabel> + <FormControl> + <Textarea + placeholder="Enter your comment..." + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Dropzone (파일 첨부) */} + <Dropzone + maxSize={MAX_FILE_SIZE} + onDropAccepted={handleDropAccepted} + onDropRejected={(rej) => { + toast.error("File rejected: " + (rej[0]?.file?.name || "")) + }} + > + {({ maxSize }) => ( + <DropzoneZone className="flex justify-center"> + <DropzoneInput /> + <div className="flex items-center gap-6"> + <DropzoneUploadIcon /> + <div className="grid gap-0.5"> + <DropzoneTitle>Drop to attach files</DropzoneTitle> + <DropzoneDescription> + Max size: {prettyBytes(maxSize || 0)} + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + )} + </Dropzone> + + {/* 선택된 파일 목록 */} + {newFileFields.length > 0 && ( + <div className="flex flex-col gap-2"> + {newFileFields.map((field, idx) => { + const file = form.getValues(`newFiles.${idx}`) + if (!file) return null + return ( + <div key={field.id} className="flex items-center justify-between border rounded p-2"> + <span className="text-sm">{file.name} ({prettyBytes(file.size)})</span> + <Button + variant="ghost" + size="icon" + type="button" + onClick={() => remove(idx)} + > + <X className="h-4 w-4" /> + </Button> + </div> + ) + })} + </div> + )} + + <SheetFooter className="gap-2 pt-4"> + <SheetClose asChild> + <Button type="button" variant="outline"> + Cancel + </Button> + </SheetClose> + <Button disabled={isPending}> + {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />} + Save + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-tbe-table/feature-flags-provider.tsx b/lib/vendor-rfq-response/vendor-tbe-table/feature-flags-provider.tsx new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/vendor-rfq-response/vendor-tbe-table/feature-flags-provider.tsx @@ -0,0 +1,108 @@ +"use client" + +import * as React from "react" +import { useQueryState } from "nuqs" + +import { dataTableConfig, type DataTableConfig } from "@/config/data-table" +import { cn } from "@/lib/utils" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" + +type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] + +interface FeatureFlagsContextProps { + featureFlags: FeatureFlagValue[] + setFeatureFlags: (value: FeatureFlagValue[]) => void +} + +const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({ + featureFlags: [], + setFeatureFlags: () => {}, +}) + +export function useFeatureFlags() { + const context = React.useContext(FeatureFlagsContext) + if (!context) { + throw new Error( + "useFeatureFlags must be used within a FeatureFlagsProvider" + ) + } + return context +} + +interface FeatureFlagsProviderProps { + children: React.ReactNode +} + +export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { + const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( + "flags", + { + defaultValue: [], + parse: (value) => value.split(",") as FeatureFlagValue[], + serialize: (value) => value.join(","), + eq: (a, b) => + a.length === b.length && a.every((value, index) => value === b[index]), + clearOnDefault: true, + shallow: false, + } + ) + + return ( + <FeatureFlagsContext.Provider + value={{ + featureFlags, + setFeatureFlags: (value) => void setFeatureFlags(value), + }} + > + <div className="w-full overflow-x-auto"> + <ToggleGroup + type="multiple" + variant="outline" + size="sm" + value={featureFlags} + onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} + className="w-fit gap-0" + > + {dataTableConfig.featureFlags.map((flag, index) => ( + <Tooltip key={flag.value}> + <ToggleGroupItem + value={flag.value} + className={cn( + "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90", + { + "rounded-l-sm border-r-0": index === 0, + "rounded-r-sm": + index === dataTableConfig.featureFlags.length - 1, + } + )} + asChild + > + <TooltipTrigger> + <flag.icon className="size-3.5 shrink-0" aria-hidden="true" /> + {flag.label} + </TooltipTrigger> + </ToggleGroupItem> + <TooltipContent + align="start" + side="bottom" + sideOffset={6} + className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" + > + <div>{flag.tooltipTitle}</div> + <div className="text-xs text-muted-foreground"> + {flag.tooltipDescription} + </div> + </TooltipContent> + </Tooltip> + ))} + </ToggleGroup> + </div> + {children} + </FeatureFlagsContext.Provider> + ) +} diff --git a/lib/vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx b/lib/vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx new file mode 100644 index 00000000..34a53d17 --- /dev/null +++ b/lib/vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx @@ -0,0 +1,317 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Download, MessageSquare, Upload } from "lucide-react" +import { toast } from "sonner" + +import { getErrorMessage } from "@/lib/handle-error" +import { formatDate } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { useRouter } from "next/navigation" + +import { + tbeVendorColumnsConfig, + VendorTbeColumnConfig, + vendorTbeColumnsConfig, + TbeVendorFields, +} from "@/config/vendorTbeColumnsConfig" + +type NextRouter = ReturnType<typeof useRouter> + +interface GetColumnsProps { + setRowAction: React.Dispatch< + React.SetStateAction<DataTableRowAction<TbeVendorFields> | null> + > + router: NextRouter + openCommentSheet: (vendorId: number) => void + handleDownloadTbeTemplate: (tbeId: number, vendorId: number, rfqId: number) => void + handleUploadTbeResponse: (tbeId: number, vendorId: number, rfqId: number, vendorResponseId:number) => void +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ + setRowAction, + router, + openCommentSheet, + handleDownloadTbeTemplate, + handleUploadTbeResponse, +}: GetColumnsProps): ColumnDef<TbeVendorFields>[] { + // ---------------------------------------------------------------- + // 1) Select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<TbeVendorFields> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) 그룹화(Nested) 컬럼 구성 + // ---------------------------------------------------------------- + const groupMap: Record<string, ColumnDef<TbeVendorFields>[]> = {} + + tbeVendorColumnsConfig.forEach((cfg) => { + const groupName = cfg.group || "_noGroup" + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // childCol: ColumnDef<TbeVendorFields> + const childCol: ColumnDef<TbeVendorFields> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + maxSize: 120, + // 셀 렌더링 + cell: ({ row, getValue }) => { + // 1) 필드값 가져오기 + const val = getValue() + + if (cfg.id === "vendorStatus") { + const statusVal = row.original.vendorStatus + if (!statusVal) return null + // const Icon = getStatusIcon(statusVal) + return ( + <Badge variant="outline"> + {statusVal} + </Badge> + ) + } + + + if (cfg.id === "rfqVendorStatus") { + const statusVal = row.original.rfqVendorStatus + if (!statusVal) return null + // const Icon = getStatusIcon(statusVal) + const variant = statusVal === "INVITED" ? "default" : statusVal === "REJECTED" ? "destructive" : statusVal === "ACCEPTED" ? "secondary" : "outline" + return ( + <Badge variant={variant}> + {statusVal} + </Badge> + ) + } + + // 예) TBE Updated (날짜) + if (cfg.id === "tbeUpdated") { + const dateVal = val as Date | undefined + if (!dateVal) return null + return formatDate(dateVal) + } + + // 그 외 필드는 기본 값 표시 + return val ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // groupMap → nestedColumns + const nestedColumns: ColumnDef<TbeVendorFields>[] = [] + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + nestedColumns.push(...colDefs) + } else { + nestedColumns.push({ + id: groupName, + header: groupName, + columns: colDefs, + }) + } + }) + + // ---------------------------------------------------------------- + // 3) Comments 컬럼 + // ---------------------------------------------------------------- + const commentsColumn: ColumnDef<TbeVendorFields> = { + id: "comments", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Comments" /> + ), + cell: ({ row }) => { + const vendor = row.original + const commCount = vendor.comments?.length ?? 0 + + function handleClick() { + // rowAction + openCommentSheet + setRowAction({ row, type: "comments" }) + openCommentSheet(vendor.tbeId ?? 0) + } + + return ( + <div> + <Button + variant="ghost" + size="sm" + className="h-8 w-8 p-0 group relative" + onClick={handleClick} + aria-label={commCount > 0 ? `View ${commCount} comments` : "Add comment"} + > + <div className="flex items-center justify-center relative"> + <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + </div> + {commCount > 0 && <span className="absolute -top-1 -right-1 inline-flex h-2 w-2 rounded-full bg-red-500"></span>} + <span className="sr-only">{commCount > 0 ? `${commCount} Comments` : "Add Comment"}</span> + </Button> + </div> + ) + }, + enableSorting: false, + maxSize: 80 + } + + // ---------------------------------------------------------------- + // 4) TBE 다운로드 컬럼 - 템플릿 다운로드 기능 + // ---------------------------------------------------------------- + const tbeDownloadColumn: ColumnDef<TbeVendorFields> = { + id: "tbeDownload", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="TBE Sheets" /> + ), + cell: ({ row }) => { + const vendor = row.original + const tbeId = vendor.tbeId + const vendorId = vendor.vendorId + const rfqId = vendor.rfqId + const templateFileCount = vendor.templateFileCount || 0 + + if (!tbeId || !vendorId || !rfqId) { + return <div className="text-center text-muted-foreground">-</div> + } + + // 템플릿 파일이 없으면 다운로드 버튼 비활성화 + const isDisabled = templateFileCount <= 0 + + return ( + <Button + variant="ghost" + size="sm" + className="relative h-8 w-8 p-0 group" + onClick={ + isDisabled + ? undefined + : () => handleDownloadTbeTemplate(tbeId, vendorId, rfqId) + } + aria-label={ + templateFileCount > 0 + ? `TBE 템플릿 다운로드 (${templateFileCount}개)` + : "다운로드할 파일 없음" + } + disabled={isDisabled} + > + <Download className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + + {/* 파일이 1개 이상인 경우 뱃지로 개수 표시 */} + {templateFileCount > 0 && ( + <Badge + variant="secondary" + className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" + > + {templateFileCount} + </Badge> + )} + + <span className="sr-only"> + {templateFileCount > 0 + ? `TBE 템플릿 다운로드 (${templateFileCount}개)` + : "다운로드할 파일 없음"} + </span> + </Button> + ) + }, + enableSorting: false, + maxSize: 80, + } + // ---------------------------------------------------------------- + // 5) TBE 업로드 컬럼 - 응답 업로드 기능 + // ---------------------------------------------------------------- + const tbeUploadColumn: ColumnDef<TbeVendorFields> = { + id: "tbeUpload", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Upload Response" /> + ), + cell: ({ row }) => { + const vendor = row.original + const tbeId = vendor.tbeId + const vendorId = vendor.vendorId + const rfqId = vendor.rfqId + const vendorResponseId = vendor.vendorResponseId + const status = vendor.rfqVendorStatus + const hasResponse = vendor.hasResponse || false + + + if (!tbeId || !vendorId || !rfqId || status === "REJECTED") { + return <div className="text-center text-muted-foreground">-</div> + } + + return ( + <div > + <Button + variant="ghost" + size="sm" + className="h-8 w-8 p-0 group relative" + onClick={() => handleUploadTbeResponse(tbeId, vendorId, rfqId, vendorResponseId)} + aria-label={hasResponse ? "TBE 응답 확인" : "TBE 응답 업로드"} + > + <div className="flex items-center justify-center relative"> + <Upload className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + </div> + {hasResponse && ( + <span className="absolute -top-1 -right-1 inline-flex h-2 w-2 rounded-full" style={{ backgroundColor: '#10B981' }}></span> + )} + <span className="sr-only"> + {"TBE 응답 업로드"} + </span> + </Button> + </div> + ) + }, + enableSorting: false, + maxSize: 80 + } + + // ---------------------------------------------------------------- + // 6) 최종 컬럼 배열 + // ---------------------------------------------------------------- + return [ + selectColumn, + ...nestedColumns, + commentsColumn, + tbeDownloadColumn, + tbeUploadColumn, + ] +}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx b/lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx new file mode 100644 index 00000000..3450a643 --- /dev/null +++ b/lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx @@ -0,0 +1,162 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { toSentenceCase } from "@/lib/utils" +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { useFeatureFlags } from "./feature-flags-provider" +import { getColumns } from "./tbe-table-columns" +import { Vendor, vendors } from "@/db/schema/vendors" +import { fetchRfqAttachmentsbyCommentId, getTBEforVendor } from "../../rfqs/service" +import { CommentSheet, TbeComment } from "./comments-sheet" +import { TbeVendorFields } from "@/config/vendorTbeColumnsConfig" +import { useTbeFileHandlers } from "./tbeFileHandler" +import { useSession } from "next-auth/react" + +interface VendorsTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getTBEforVendor>>, + ] + > +} + +export function TbeVendorTable({ promises }: VendorsTableProps) { + const { featureFlags } = useFeatureFlags() + const { data: session } = useSession() + const userVendorId = session?.user?.companyId + const userId = Number(session?.user?.id) + // Suspense로 받아온 데이터 + const [{ data, pageCount }] = React.use(promises) + const [rowAction, setRowAction] = React.useState<DataTableRowAction<TbeVendorFields> | null>(null) + + + // router 획득 + const router = useRouter() + + const [initialComments, setInitialComments] = React.useState<TbeComment[]>([]) + const [commentSheetOpen, setCommentSheetOpen] = React.useState(false) + const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null) + + // TBE 파일 핸들러 훅 사용 + const { + handleDownloadTbeTemplate, + handleUploadTbeResponse, + UploadDialog, + } = useTbeFileHandlers() + + React.useEffect(() => { + if (rowAction?.type === "comments") { + // rowAction가 새로 세팅된 뒤 여기서 openCommentSheet 실행 + openCommentSheet(Number(rowAction.row.original.id)) + } + }, [rowAction]) + + async function openCommentSheet(vendorId: number) { + setInitialComments([]) + + const comments = rowAction?.row.original.comments + + if (comments && comments.length > 0) { + const commentWithAttachments: TbeComment[] = await Promise.all( + comments.map(async (c) => { + // 서버 액션을 사용하여 코멘트 첨부 파일 가져오기 + const attachments = await fetchRfqAttachmentsbyCommentId(c.id) + + return { + ...c, + commentedBy: 1, // DB나 API 응답에 있다고 가정 + attachments, + } + }) + ) + + setInitialComments(commentWithAttachments) + } + + setSelectedRfqIdForComments(vendorId) + setCommentSheetOpen(true) + } + + // getColumns() 호출 시, 필요한 모든 핸들러 함수 주입 + const columns = React.useMemo( + () => getColumns({ + setRowAction, + router, + openCommentSheet, + handleDownloadTbeTemplate, + handleUploadTbeResponse, + }), + [setRowAction, router, openCommentSheet, handleDownloadTbeTemplate, handleUploadTbeResponse] + ) + + const filterFields: DataTableFilterField<TbeVendorFields>[] = [] + + const advancedFilterFields: DataTableAdvancedFilterField<TbeVendorFields>[] = [ + { id: "vendorName", label: "Vendor Name", type: "text" }, + { id: "vendorCode", label: "Vendor Code", type: "text" }, + { id: "email", label: "Email", type: "text" }, + { id: "country", label: "Country", type: "text" }, + { + id: "vendorStatus", + label: "Vendor Status", + type: "multi-select", + options: vendors.status.enumValues.map((status) => ({ + label: toSentenceCase(status), + value: status, + })), + }, + { id: "rfqVendorUpdated", label: "Updated at", type: "date" }, + ] + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "rfqVendorUpdated", desc: true }], + columnPinning: { right: ["comments", "tbeDocuments"] }, // tbeDocuments 컬럼을 우측에 고정 + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + /> + </DataTable> + + {/* 코멘트 시트 */} + {commentSheetOpen && selectedRfqIdForComments && ( + <CommentSheet + open={commentSheetOpen} + onOpenChange={setCommentSheetOpen} + rfqId={selectedRfqIdForComments} + initialComments={initialComments} + vendorId={userVendorId||0} + currentUserId={userId||0} + /> + )} + + {/* TBE 파일 다이얼로그 */} + <UploadDialog /> + </> + ) +}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx b/lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx new file mode 100644 index 00000000..3994b8eb --- /dev/null +++ b/lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx @@ -0,0 +1,355 @@ +"use client"; + +import { useCallback, useState, useEffect } from "react"; +import { toast } from "sonner"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { + fetchTbeTemplateFiles, + getTbeTemplateFileInfo, + uploadTbeResponseFile, + getTbeSubmittedFiles, +} from "../../rfqs/service"; +import { + Dropzone, + DropzoneDescription, + DropzoneInput, + DropzoneTitle, + DropzoneUploadIcon, + DropzoneZone, +} from "@/components/ui/dropzone"; +import { + FileList, + FileListAction, + FileListDescription, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, + FileListSize, +} from "@/components/ui/file-list"; +import { Download, X } from "lucide-react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { formatDateTime } from "@/lib/utils"; + +export function useTbeFileHandlers() { + // 모달 열림 여부, 현재 선택된 IDs + const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false); + const [currentTbeId, setCurrentTbeId] = useState<number | null>(null); + const [currentVendorId, setCurrentVendorId] = useState<number | null>(null); + const [currentRfqId, setCurrentRfqId] = useState<number | null>(null); + const [currentvendorResponseId, setCurrentvendorResponseId] = useState<number | null>(null); + + + + // 로딩 상태들 + const [isLoading, setIsLoading] = useState(false); + const [isFetchingFiles, setIsFetchingFiles] = useState(false); + + // 업로드할 파일, 제출된 파일 목록 + const [selectedFile, setSelectedFile] = useState<File | null>(null); + const [submittedFiles, setSubmittedFiles] = useState< + Array<{ id: number; fileName: string; filePath: string; uploadedAt: Date }> + >([]); + + // =================================== + // 1) 제출된 파일 목록 가져오기 + // =================================== + const fetchSubmittedFiles = useCallback(async (vendorResponseId: number) => { + if (!vendorResponseId ) return; + + setIsFetchingFiles(true); + try { + const { files, error } = await getTbeSubmittedFiles(vendorResponseId); + if (error) { + console.error(error); + return; + } + setSubmittedFiles(files); + } catch (error) { + console.error("Failed to fetch submitted files:", error); + } finally { + setIsFetchingFiles(false); + } + }, []); + + // =================================== + // 2) TBE 템플릿 다운로드 + // =================================== + const handleDownloadTbeTemplate = useCallback( + async (tbeId: number, vendorId: number, rfqId: number) => { + setCurrentTbeId(tbeId); + setCurrentVendorId(vendorId); + setCurrentRfqId(rfqId); + setIsLoading(true); + + try { + const { files, error } = await fetchTbeTemplateFiles(tbeId); + if (error) { + toast.error(error); + return; + } + if (files.length === 0) { + toast.warning("다운로드할 템플릿 파일이 없습니다"); + return; + } + // 순차적으로 파일 다운로드 + for (const file of files) { + await downloadFile(file.id); + } + toast.success("모든 템플릿 파일이 다운로드되었습니다"); + } catch (error) { + toast.error("템플릿 파일을 다운로드하는 데 실패했습니다"); + console.error(error); + } finally { + setIsLoading(false); + } + }, + [] + ); + + // 실제 다운로드 로직 + const downloadFile = useCallback(async (fileId: number) => { + try { + const { file, error } = await getTbeTemplateFileInfo(fileId); + if (error || !file) { + throw new Error(error || "파일 정보를 가져오는 데 실패했습니다"); + } + + const link = document.createElement("a"); + link.href = `/api/rfq-download?path=${encodeURIComponent(file.filePath)}`; + link.download = file.fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + return true; + } catch (error) { + console.error(error); + return false; + } + }, []); + + // =================================== + // 3) 제출된 파일 다운로드 + // =================================== + const downloadSubmittedFile = useCallback((file: { id: number; fileName: string; filePath: string }) => { + try { + const link = document.createElement("a"); + link.href = `/api/files/${file.filePath}`; + link.download = file.fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + toast.success(`${file.fileName} 다운로드 시작`); + } catch (error) { + console.error("Failed to download file:", error); + toast.error("파일 다운로드에 실패했습니다"); + } + }, []); + + // =================================== + // 4) TBE 응답 업로드 모달 열기 + // (이 시점에서는 데이터 fetch하지 않음) + // =================================== + const handleUploadTbeResponse = useCallback((tbeId: number, vendorId: number, rfqId: number, vendorResponseId:number) => { + setCurrentTbeId(tbeId); + setCurrentVendorId(vendorId); + setCurrentRfqId(rfqId); + setCurrentvendorResponseId(vendorResponseId); + setIsUploadDialogOpen(true); + }, []); + + // =================================== + // 5) Dialog 열고 닫힐 때 상태 초기화 + // 열렸을 때 -> useEffect로 파일 목록 가져오기 + // =================================== + useEffect(() => { + if (!isUploadDialogOpen) { + // 닫힐 때는 파일 상태들 초기화 + setSelectedFile(null); + setSubmittedFiles([]); + } + }, [isUploadDialogOpen]); + + useEffect(() => { + // Dialog가 열렸고, ID들이 유효하면 + if (isUploadDialogOpen &¤tvendorResponseId) { + fetchSubmittedFiles(currentvendorResponseId); + } + }, [isUploadDialogOpen, currentvendorResponseId, fetchSubmittedFiles]); + + // =================================== + // 6) 드롭존 파일 선택 & 제거 + // =================================== + const handleFileDrop = useCallback((files: File[]) => { + if (files && files.length > 0) { + setSelectedFile(files[0]); + } + }, []); + + const handleRemoveFile = useCallback(() => { + setSelectedFile(null); + }, []); + + // =================================== + // 7) 응답 파일 업로드 + // =================================== + const handleSubmitResponse = useCallback(async () => { + if (!selectedFile || !currentTbeId || !currentVendorId || !currentRfqId ||!currentvendorResponseId) { + toast.error("업로드할 파일을 선택해주세요"); + return; + } + + setIsLoading(true); + try { + // FormData 생성 + const formData = new FormData(); + formData.append("file", selectedFile); + formData.append("rfqId", currentRfqId.toString()); + formData.append("vendorId", currentVendorId.toString()); + formData.append("evaluationId", currentTbeId.toString()); + formData.append("vendorResponseId", currentvendorResponseId.toString()); + + const result = await uploadTbeResponseFile(formData); + if (!result.success) { + throw new Error(result.error || "파일 업로드에 실패했습니다"); + } + + toast.success(result.message || "응답이 성공적으로 업로드되었습니다"); + + // 업로드 후 다시 제출된 파일 목록 가져오기 + await fetchSubmittedFiles(currentvendorResponseId); + + // 업로드 성공 시 선택 파일 초기화 + setSelectedFile(null); + } catch (error) { + toast.error(error instanceof Error ? error.message : "응답 업로드에 실패했습니다"); + console.error(error); + } finally { + setIsLoading(false); + } + }, [selectedFile, currentTbeId, currentVendorId, currentRfqId, currentvendorResponseId,fetchSubmittedFiles]); + + // =================================== + // 8) 실제 Dialog 컴포넌트 + // =================================== + const UploadDialog = () => ( + <Dialog open={isUploadDialogOpen} onOpenChange={setIsUploadDialogOpen}> + <DialogContent className="sm:max-w-lg"> + <DialogHeader> + <DialogTitle>TBE 응답 파일</DialogTitle> + <DialogDescription>제출된 파일을 확인하거나 새 파일을 업로드하세요.</DialogDescription> + </DialogHeader> + + <Tabs defaultValue="upload" className="w-full"> + <TabsList className="grid w-full grid-cols-2"> + <TabsTrigger value="upload">새 파일 업로드</TabsTrigger> + <TabsTrigger + value="submitted" + disabled={submittedFiles.length === 0} + className={submittedFiles.length > 0 ? "relative" : ""} + > + 제출된 파일{" "} + {submittedFiles.length > 0 && ( + <span className="ml-2 inline-flex items-center justify-center rounded-full bg-primary w-4 h-4 text-[10px] text-primary-foreground"> + {submittedFiles.length} + </span> + )} + </TabsTrigger> + </TabsList> + + {/* 업로드 탭 */} + <TabsContent value="upload" className="pt-4"> + <div className="grid gap-4"> + {selectedFile ? ( + <FileList> + <FileListItem> + <FileListIcon /> + <FileListInfo> + <FileListName>{selectedFile.name}</FileListName> + <FileListSize>{selectedFile.size}</FileListSize> + </FileListInfo> + <FileListAction> + <Button variant="ghost" size="icon" onClick={handleRemoveFile}> + <X className="h-4 w-4" /> + <span className="sr-only">파일 제거</span> + </Button> + </FileListAction> + </FileListItem> + </FileList> + ) : ( + <Dropzone onDrop={handleFileDrop}> + <DropzoneInput className="sr-only" /> + <DropzoneZone className="flex flex-col items-center justify-center gap-2 p-6"> + <DropzoneUploadIcon className="h-10 w-10 text-muted-foreground" /> + <DropzoneTitle>파일을 드래그하거나 클릭하여 업로드</DropzoneTitle> + <DropzoneDescription>TBE 응답 파일 (XLSX, XLS, DOCX, PDF 등)</DropzoneDescription> + </DropzoneZone> + </Dropzone> + )} + + <DialogFooter className="mt-4"> + <Button type="submit" onClick={handleSubmitResponse} disabled={!selectedFile || isLoading}> + {isLoading ? "업로드 중..." : "응답 업로드"} + </Button> + </DialogFooter> + </div> + </TabsContent> + + {/* 제출된 파일 탭 */} + <TabsContent value="submitted" className="pt-4"> + {isFetchingFiles ? ( + <div className="flex justify-center items-center py-8"> + <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div> + </div> + ) : submittedFiles.length > 0 ? ( + <div className="grid gap-2"> + <FileList> + {submittedFiles.map((file) => ( + <FileListItem key={file.id} className="flex items-center justify-between gap-3"> + <div className="flex items-center gap-3 flex-1"> + <FileListIcon className="flex-shrink-0" /> + <FileListInfo className="flex-1 min-w-0"> + <FileListName className="text-sm font-medium truncate">{file.fileName}</FileListName> + <FileListDescription className="text-xs text-muted-foreground"> + {file.uploadedAt ? formatDateTime(file.uploadedAt) : ""} + </FileListDescription> + </FileListInfo> + </div> + <FileListAction className="flex-shrink-0 ml-2"> + <Button variant="ghost" size="icon" onClick={() => downloadSubmittedFile(file)}> + <Download className="h-4 w-4" /> + <span className="sr-only">파일 다운로드</span> + </Button> + </FileListAction> + </FileListItem> + ))} + </FileList> + </div> + ) : ( + <div className="text-center py-8 text-muted-foreground">제출된 파일이 없습니다.</div> + )} + </TabsContent> + </Tabs> + </DialogContent> + </Dialog> + ); + + // =================================== + // 9) Hooks 내보내기 + // =================================== + return { + handleDownloadTbeTemplate, + handleUploadTbeResponse, + UploadDialog, + }; +}
\ No newline at end of file |
