diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-05-28 00:32:31 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-05-28 00:32:31 +0000 |
| commit | 20800b214145ee6056f94ca18fa1054f145eb977 (patch) | |
| tree | b5c8b27febe5b126e6d9ece115ea05eace33a020 /lib/vendor-investigation | |
| parent | e1344a5da1aeef8fbf0f33e1dfd553078c064ccc (diff) | |
(대표님) lib 파트 커밋
Diffstat (limited to 'lib/vendor-investigation')
| -rw-r--r-- | lib/vendor-investigation/service.ts | 523 | ||||
| -rw-r--r-- | lib/vendor-investigation/table/contract-dialog.tsx | 85 | ||||
| -rw-r--r-- | lib/vendor-investigation/table/investigation-table-columns.tsx | 313 | ||||
| -rw-r--r-- | lib/vendor-investigation/table/investigation-table.tsx | 177 | ||||
| -rw-r--r-- | lib/vendor-investigation/table/items-dialog.tsx | 73 | ||||
| -rw-r--r-- | lib/vendor-investigation/table/update-investigation-sheet.tsx | 713 | ||||
| -rw-r--r-- | lib/vendor-investigation/table/vendor-details-dialog.tsx | 341 | ||||
| -rw-r--r-- | lib/vendor-investigation/validations.ts | 56 |
8 files changed, 1673 insertions, 608 deletions
diff --git a/lib/vendor-investigation/service.ts b/lib/vendor-investigation/service.ts index e3d03cd4..bcf9efd4 100644 --- a/lib/vendor-investigation/service.ts +++ b/lib/vendor-investigation/service.ts @@ -1,6 +1,6 @@ "use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) -import { vendorInvestigationAttachments, vendorInvestigations, vendorInvestigationsView } from "@/db/schema/vendors" +import { items, vendorInvestigationAttachments, vendorInvestigations, vendorInvestigationsView, vendorPossibleItems, vendors } from "@/db/schema/" import { GetVendorsInvestigationSchema, updateVendorInvestigationSchema } from "./validations" import { asc, desc, ilike, inArray, and, or, gte, lte, eq, isNull, count } from "drizzle-orm"; import { revalidateTag, unstable_noStore } from "next/cache"; @@ -13,54 +13,62 @@ import fs from "fs" import path from "path" import { v4 as uuid } from "uuid" import { vendorsLogs } from "@/db/schema"; +import { cache } from "react" export async function getVendorsInvestigation(input: GetVendorsInvestigationSchema) { return unstable_cache( async () => { try { const offset = (input.page - 1) * input.perPage - + // 1) Advanced filters const advancedWhere = filterColumns({ table: vendorInvestigationsView, filters: input.filters, joinOperator: input.joinOperator, }) - + // 2) Global search let globalWhere if (input.search) { const s = `%${input.search}%` globalWhere = or( + // 협력업체 정보 ilike(vendorInvestigationsView.vendorName, s), ilike(vendorInvestigationsView.vendorCode, s), + + // 담당자 정보 (새로 추가) + ilike(vendorInvestigationsView.requesterName, s), + ilike(vendorInvestigationsView.qmManagerName, s), + + // 실사 정보 ilike(vendorInvestigationsView.investigationNotes, s), - ilike(vendorInvestigationsView.vendorEmail, s) - // etc. + ilike(vendorInvestigationsView.investigationStatus, s), + ilike(vendorInvestigationsView.evaluationType, s), + ilike(vendorInvestigationsView.investigationAddress, s), + ilike(vendorInvestigationsView.investigationMethod, s), + + // 평가 결과 + ilike(vendorInvestigationsView.evaluationResult, s) ) } - // 3) Combine finalWhere - // Example: Only show vendorStatus = "PQ_SUBMITTED" const finalWhere = and( advancedWhere, - globalWhere, - // eq(vendorInvestigationsView.vendorStatus, "PQ_APPROVED") + globalWhere ) - - - - // 5) Sorting + + // 4) Sorting const orderBy = input.sort && input.sort.length > 0 ? input.sort.map((item) => - item.desc - ? desc(vendorInvestigationsView[item.id]) - : asc(vendorInvestigationsView[item.id]) - ) - : [desc(vendorInvestigationsView.investigationCreatedAt)] - - // 6) Query & count + item.desc + ? desc(vendorInvestigationsView[item.id]) + : asc(vendorInvestigationsView[item.id]) + ) + : [desc(vendorInvestigationsView.createdAt)] + + // 5) Query & count const { data, total } = await db.transaction(async (tx) => { // a) Select from the view const investigationsData = await tx @@ -70,7 +78,7 @@ export async function getVendorsInvestigation(input: GetVendorsInvestigationSche .orderBy(...orderBy) .offset(offset) .limit(input.perPage) - + // b) Count total const resCount = await tx .select({ count: count() }) @@ -79,14 +87,11 @@ export async function getVendorsInvestigation(input: GetVendorsInvestigationSche return { data: investigationsData, total: resCount[0]?.count } }) - - // 7) Calculate pageCount + + // 6) Calculate pageCount const pageCount = Math.ceil(total / input.perPage) - - console.log(data,"data") - - // Now 'data' already contains JSON arrays of contacts & items - // thanks to the subqueries in the view definition! + + // Data is already in the correct format from the simplified view return { data, pageCount } } catch (err) { console.error(err) @@ -101,8 +106,6 @@ export async function getVendorsInvestigation(input: GetVendorsInvestigationSche } )() } - - /** * Get existing investigations for a list of vendor IDs * @@ -116,7 +119,7 @@ export async function getExistingInvestigationsForVendors(vendorIds: number[]) { // Query the vendorInvestigationsView using the vendorIds const investigations = await db.query.vendorInvestigations.findMany({ where: inArray(vendorInvestigationsView.vendorId, vendorIds), - orderBy: [desc(vendorInvestigationsView.investigationCreatedAt)], + orderBy: [desc(vendorInvestigationsView.createdAt)], }) return investigations @@ -188,9 +191,10 @@ export async function requestInvestigateVendors({ } +// 개선된 서버 액션 - 텍스트 데이터만 처리 export async function updateVendorInvestigationAction(formData: FormData) { try { - // 1) Separate text fields from file fields + // 1) 텍스트 필드만 추출 const textEntries: Record<string, string> = {} for (const [key, value] of formData.entries()) { if (typeof value === "string") { @@ -198,69 +202,432 @@ export async function updateVendorInvestigationAction(formData: FormData) { } } - // 2) Convert text-based "investigationId" to a number + // 2) 적절한 타입으로 변환 + const processedEntries: any = {} + + // 필수 필드 if (textEntries.investigationId) { - textEntries.investigationId = String(Number(textEntries.investigationId)) + processedEntries.investigationId = Number(textEntries.investigationId) + } + if (textEntries.investigationStatus) { + processedEntries.investigationStatus = textEntries.investigationStatus } - // 3) Parse/validate with Zod - const parsed = updateVendorInvestigationSchema.parse(textEntries) - // parsed is type UpdateVendorInvestigationSchema + // 선택적 enum 필드 + if (textEntries.evaluationType) { + processedEntries.evaluationType = textEntries.evaluationType + } - // 4) Update the vendor_investigations table - await db - .update(vendorInvestigations) - .set({ - investigationStatus: parsed.investigationStatus, - scheduledStartAt: parsed.scheduledStartAt - ? new Date(parsed.scheduledStartAt) - : null, - scheduledEndAt: parsed.scheduledEndAt ? new Date(parsed.scheduledEndAt) : null, - completedAt: parsed.completedAt ? new Date(parsed.completedAt) : null, - investigationNotes: parsed.investigationNotes ?? "", - updatedAt: new Date(), - }) - .where(eq(vendorInvestigations.id, parsed.investigationId)) + // 선택적 문자열 필드 + if (textEntries.investigationAddress) { + processedEntries.investigationAddress = textEntries.investigationAddress + } + if (textEntries.investigationMethod) { + processedEntries.investigationMethod = textEntries.investigationMethod + } + if (textEntries.investigationNotes) { + processedEntries.investigationNotes = textEntries.investigationNotes + } - // 5) Handle file attachments - // formData.getAll("attachments") can contain multiple files - const files = formData.getAll("attachments") as File[] + // 선택적 날짜 필드 + if (textEntries.forecastedAt) { + processedEntries.forecastedAt = new Date(textEntries.forecastedAt) + } + if (textEntries.requestedAt) { + processedEntries.requestedAt = new Date(textEntries.requestedAt) + } + if (textEntries.confirmedAt) { + processedEntries.confirmedAt = new Date(textEntries.confirmedAt) + } + if (textEntries.completedAt) { + processedEntries.completedAt = new Date(textEntries.completedAt) + } - // Make sure the folder exists - const uploadDir = path.join(process.cwd(), "public", "vendor-investigation") - if (!fs.existsSync(uploadDir)) { - fs.mkdirSync(uploadDir, { recursive: true }) + // 선택적 숫자 필드 + if (textEntries.evaluationScore) { + processedEntries.evaluationScore = Number(textEntries.evaluationScore) } - for (const file of files) { - if (file && file.size > 0) { - // Create a unique filename - const ext = path.extname(file.name) // e.g. ".pdf" - const newFileName = `${uuid()}${ext}` + // 선택적 평가 결과 + if (textEntries.evaluationResult) { + processedEntries.evaluationResult = textEntries.evaluationResult + } - const filePath = path.join(uploadDir, newFileName) + // 3) Zod로 파싱/검증 + const parsed = updateVendorInvestigationSchema.parse(processedEntries) - // 6) Write file to disk - const arrayBuffer = await file.arrayBuffer() - const buffer = Buffer.from(arrayBuffer) - fs.writeFileSync(filePath, buffer) + // 4) 업데이트 데이터 준비 - 실제로 제공된 필드만 포함 + const updateData: any = { + investigationStatus: parsed.investigationStatus, + updatedAt: new Date(), + } - // 7) Insert a record in vendor_investigation_attachments - await db.insert(vendorInvestigationAttachments).values({ - investigationId: parsed.investigationId, - fileName: file.name, // original name - filePath: `/vendor-investigation/${newFileName}`, // relative path in public/ - attachmentType: "REPORT", // or user-specified - }) - } + // 선택적 필드들은 존재할 때만 추가 + if (parsed.evaluationType !== undefined) { + updateData.evaluationType = parsed.evaluationType + } + if (parsed.investigationAddress !== undefined) { + updateData.investigationAddress = parsed.investigationAddress + } + if (parsed.investigationMethod !== undefined) { + updateData.investigationMethod = parsed.investigationMethod + } + if (parsed.forecastedAt !== undefined) { + updateData.forecastedAt = parsed.forecastedAt + } + if (parsed.requestedAt !== undefined) { + updateData.requestedAt = parsed.requestedAt + } + if (parsed.confirmedAt !== undefined) { + updateData.confirmedAt = parsed.confirmedAt + } + if (parsed.completedAt !== undefined) { + updateData.completedAt = parsed.completedAt + } + if (parsed.evaluationScore !== undefined) { + updateData.evaluationScore = parsed.evaluationScore + } + if (parsed.evaluationResult !== undefined) { + updateData.evaluationResult = parsed.evaluationResult + } + if (parsed.investigationNotes !== undefined) { + updateData.investigationNotes = parsed.investigationNotes } - // Revalidate anything if needed + // 5) vendor_investigations 테이블 업데이트 + await db + .update(vendorInvestigations) + .set(updateData) + .where(eq(vendorInvestigations.id, parsed.investigationId)) + + // 6) 캐시 무효화 revalidateTag("vendors-in-investigation") return { data: "OK", error: null } } catch (err: unknown) { + console.error("Investigation update error:", err) const message = err instanceof Error ? err.message : String(err) return { error: message } } -}
\ No newline at end of file +} +// 실사 첨부파일 조회 함수 +export async function getInvestigationAttachments(investigationId: number) { + try { + const attachments = await db + .select() + .from(vendorInvestigationAttachments) + .where(eq(vendorInvestigationAttachments.investigationId, investigationId)) + .orderBy(vendorInvestigationAttachments.createdAt) + + return { success: true, attachments } + } catch (error) { + console.error("첨부파일 조회 실패:", error) + return { success: false, error: "첨부파일 조회에 실패했습니다.", attachments: [] } + } +} + +// 첨부파일 삭제 함수 +export async function deleteInvestigationAttachment(attachmentId: number) { + try { + // 파일 정보 조회 + const [attachment] = await db + .select() + .from(vendorInvestigationAttachments) + .where(eq(vendorInvestigationAttachments.id, attachmentId)) + .limit(1) + + if (!attachment) { + return { success: false, error: "첨부파일을 찾을 수 없습니다." } + } + + // 실제 파일 삭제 + const fullFilePath = path.join(process.cwd(), "public", attachment.filePath) + if (fs.existsSync(fullFilePath)) { + fs.unlinkSync(fullFilePath) + } + + // 데이터베이스에서 레코드 삭제 + await db + .delete(vendorInvestigationAttachments) + .where(eq(vendorInvestigationAttachments.id, attachmentId)) + + // 캐시 무효화 + revalidateTag("vendors-in-investigation") + + return { success: true } + } catch (error) { + console.error("첨부파일 삭제 실패:", error) + return { success: false, error: "첨부파일 삭제에 실패했습니다." } + } +} + +// 첨부파일 다운로드 정보 조회 +export async function getAttachmentDownloadInfo(attachmentId: number) { + try { + const [attachment] = await db + .select({ + fileName: vendorInvestigationAttachments.fileName, + filePath: vendorInvestigationAttachments.filePath, + mimeType: vendorInvestigationAttachments.mimeType, + fileSize: vendorInvestigationAttachments.fileSize, + }) + .from(vendorInvestigationAttachments) + .where(eq(vendorInvestigationAttachments.id, attachmentId)) + .limit(1) + + if (!attachment) { + return { success: false, error: "첨부파일을 찾을 수 없습니다." } + } + + const fullFilePath = path.join(process.cwd(), "public", attachment.filePath) + if (!fs.existsSync(fullFilePath)) { + return { success: false, error: "파일이 존재하지 않습니다." } + } + + return { + success: true, + downloadInfo: { + fileName: attachment.fileName, + filePath: attachment.filePath, + mimeType: attachment.mimeType, + fileSize: attachment.fileSize, + } + } + } catch (error) { + console.error("첨부파일 정보 조회 실패:", error) + return { success: false, error: "첨부파일 정보 조회에 실패했습니다." } + } +} +/** + * Get vendor details by ID + */ +export const getVendorById = cache(async (vendorId: number) => { + try { + const [vendorData] = await db + .select({ + id: vendors.id, + name: vendors.vendorName, + code: vendors.vendorCode, + taxId: vendors.taxId, + email: vendors.email, + phone: vendors.phone, + website: vendors.website, + address: vendors.address, + country: vendors.country, + status: vendors.status, + description: vendors.items, // Using items field as description for now + vendorTypeId: vendors.vendorTypeId, + representativeName: vendors.representativeName, + representativeBirth: vendors.representativeBirth, + representativeEmail: vendors.representativeEmail, + representativePhone: vendors.representativePhone, + corporateRegistrationNumber: vendors.corporateRegistrationNumber, + creditAgency: vendors.creditAgency, + creditRating: vendors.creditRating, + cashFlowRating: vendors.cashFlowRating, + businessSize: vendors.businessSize, + createdAt: vendors.createdAt, + updatedAt: vendors.updatedAt, + }) + .from(vendors) + .where(eq(vendors.id, vendorId)) + .limit(1) + + if (!vendorData) { + throw new Error(`Vendor with ID ${vendorId} not found`) + } + + return vendorData + } catch (error) { + console.error("Error fetching vendor:", error) + throw new Error("Failed to fetch vendor details") + } +}) + +/** + * Get vendor items by vendor ID with caching + */ +export async function getVendorItemsByVendorId(vendorId: number) { + return unstable_cache( + async () => { + try { + // Join vendorPossibleItems with items table to get complete item information + const vendorItems = await db + .select({ + id: vendorPossibleItems.id, + vendorId: vendorPossibleItems.vendorId, + itemCode: vendorPossibleItems.itemCode, + itemName: items.itemName, + description: items.description, + createdAt: vendorPossibleItems.createdAt, + updatedAt: vendorPossibleItems.updatedAt, + }) + .from(vendorPossibleItems) + .leftJoin( + items, + eq(vendorPossibleItems.itemCode, items.itemCode) + ) + .where(eq(vendorPossibleItems.vendorId, vendorId)) + .orderBy(vendorPossibleItems.createdAt) + + return vendorItems + } catch (error) { + console.error("Error fetching vendor items:", error) + throw new Error("Failed to fetch vendor items") + } + }, + // Cache key + [`vendor-items-${vendorId}`], + { + revalidate: 3600, // Cache for 1 hour + tags: [`vendor-items-${vendorId}`, "vendor-items"], + } + )() +} + +/** + * Get all items for a vendor (alternative function name for clarity) + */ +export const getVendorPossibleItems = cache(async (vendorId: number) => { + return getVendorItemsByVendorId(vendorId) +}) + +/** + * Get vendor contacts by vendor ID + * This function assumes you have a vendorContacts table + */ +export const getVendorContacts = cache(async (vendorId: number) => { + try { + // Note: This assumes you have a vendorContacts table + // If you don't have this table yet, you can return an empty array + // or implement based on your actual contacts storage structure + + // For now, returning empty array since vendorContacts table wasn't provided + return [] + + /* + // Uncomment and modify when you have vendorContacts table: + const contacts = await db + .select({ + id: vendorContacts.id, + contactName: vendorContacts.name, + contactEmail: vendorContacts.email, + contactPhone: vendorContacts.phone, + contactPosition: vendorContacts.position, + isPrimary: vendorContacts.isPrimary, + isActive: vendorContacts.isActive, + createdAt: vendorContacts.createdAt, + updatedAt: vendorContacts.updatedAt, + }) + .from(vendorContacts) + .where( + and( + eq(vendorContacts.vendorId, vendorId), + eq(vendorContacts.isActive, true) + ) + ) + + return contacts + */ + } catch (error) { + console.error("Error fetching vendor contacts:", error) + return [] + } +}) + +/** + * Add an item to a vendor + */ +export async function addVendorItem(vendorId: number, itemCode: string) { + try { + // Check if the item exists + const [item] = await db + .select() + .from(items) + .where(eq(items.itemCode, itemCode)) + .limit(1) + + if (!item) { + throw new Error(`Item with code ${itemCode} not found`) + } + + // Check if the vendor-item relationship already exists + const [existingRelation] = await db + .select() + .from(vendorPossibleItems) + .where( + eq(vendorPossibleItems.vendorId, vendorId) && + eq(vendorPossibleItems.itemCode, itemCode) + ) + .limit(1) + + if (existingRelation) { + throw new Error("This item is already associated with the vendor") + } + + // Add the item to the vendor + const [newVendorItem] = await db + .insert(vendorPossibleItems) + .values({ + vendorId, + itemCode, + }) + .returning() + + // Revalidate cache + revalidateTag(`vendor-items-${vendorId}`) + revalidateTag("vendor-items") + + return newVendorItem + } catch (error) { + console.error("Error adding vendor item:", error) + throw new Error("Failed to add item to vendor") + } +} + +/** + * Remove an item from a vendor + */ +export async function removeVendorItem(vendorId: number, itemCode: string) { + try { + await db + .delete(vendorPossibleItems) + .where( + eq(vendorPossibleItems.vendorId, vendorId) && + eq(vendorPossibleItems.itemCode, itemCode) + ) + + // Revalidate cache + revalidateTag(`vendor-items-${vendorId}`) + revalidateTag("vendor-items") + + return { success: true } + } catch (error) { + console.error("Error removing vendor item:", error) + throw new Error("Failed to remove item from vendor") + } +} + +/** + * Get all available items (for adding to vendors) + */ +export const getAllItems = cache(async () => { + try { + const allItems = await db + .select({ + id: items.id, + itemCode: items.itemCode, + itemName: items.itemName, + description: items.description, + createdAt: items.createdAt, + updatedAt: items.updatedAt, + }) + .from(items) + .orderBy(items.itemName) + + return allItems + } catch (error) { + console.error("Error fetching all items:", error) + throw new Error("Failed to fetch items") + } +})
\ No newline at end of file diff --git a/lib/vendor-investigation/table/contract-dialog.tsx b/lib/vendor-investigation/table/contract-dialog.tsx deleted file mode 100644 index 28e6963b..00000000 --- a/lib/vendor-investigation/table/contract-dialog.tsx +++ /dev/null @@ -1,85 +0,0 @@ -"use client" - -import * as React from "react" -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogFooter, -} from "@/components/ui/dialog" -import { Button } from "@/components/ui/button" -import { Avatar } from "@/components/ui/avatar" -import { ScrollArea } from "@/components/ui/scroll-area" -import { ContactItem } from "@/config/vendorInvestigationsColumnsConfig" - -interface ContactsDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - investigationId: number | null - contacts: ContactItem[] -} - -export function ContactsDialog({ - open, - onOpenChange, - investigationId, - contacts, -}: ContactsDialogProps) { - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="sm:max-w-md"> - <DialogHeader> - <DialogTitle>Vendor Contacts</DialogTitle> - <DialogDescription> - {contacts.length > 0 - ? `Showing ${contacts.length} contacts for investigation #${investigationId}` - : `No contacts found for investigation #${investigationId}`} - </DialogDescription> - </DialogHeader> - <ScrollArea className="max-h-[60vh] pr-4"> - {contacts.length > 0 ? ( - <div className="space-y-4"> - {contacts.map((contact, index) => ( - <div - key={index} - className="flex items-start gap-4 p-3 rounded-lg border" - > - <Avatar className="w-10 h-10"> - <span>{contact.contactName?.charAt(0) || "C"}</span> - </Avatar> - <div className="flex-1 space-y-1"> - <p className="font-medium">{contact.contactName || "Unnamed"}</p> - {contact.contactEmail && ( - <p className="text-sm text-muted-foreground"> - {contact.contactEmail} - </p> - )} - {contact.contactPhone && ( - <p className="text-sm text-muted-foreground"> - {contact.contactPhone} - </p> - )} - {contact.contactPosition && ( - <p className="text-sm text-muted-foreground"> - Position: {contact.contactPosition} - </p> - )} - </div> - </div> - ))} - </div> - ) : ( - <div className="text-center py-6 text-muted-foreground"> - No contacts available - </div> - )} - </ScrollArea> - <DialogFooter> - <Button onClick={() => onOpenChange(false)}>Close</Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/vendor-investigation/table/investigation-table-columns.tsx b/lib/vendor-investigation/table/investigation-table-columns.tsx index fd76a9a5..6146d940 100644 --- a/lib/vendor-investigation/table/investigation-table-columns.tsx +++ b/lib/vendor-investigation/table/investigation-table-columns.tsx @@ -5,31 +5,30 @@ import { ColumnDef } from "@tanstack/react-table" import { Checkbox } from "@/components/ui/checkbox" import { Button } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" -import { Ellipsis, Users, Boxes } from "lucide-react" -// import { toast } from "sonner" // If needed +import { Edit, Ellipsis } from "lucide-react" import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" -import { formatDate } from "@/lib/utils" // or your date util +import { formatDate } from "@/lib/utils" -// Example: If you have a type for row actions +// Import types import { type DataTableRowAction } from "@/types/table" -import { ContactItem, PossibleItem, vendorInvestigationsColumnsConfig, VendorInvestigationsViewWithContacts } from "@/config/vendorInvestigationsColumnsConfig" +import { + vendorInvestigationsColumnsConfig, + VendorInvestigationsViewWithContacts +} from "@/config/vendorInvestigationsColumnsConfig" -// Props that define how we handle special columns (contacts, items, actions, etc.) +// Props for the column generator function interface GetVendorInvestigationsColumnsProps { setRowAction?: React.Dispatch< React.SetStateAction< DataTableRowAction<VendorInvestigationsViewWithContacts> | null > > - openContactsModal?: (investigationId: number, contacts: ContactItem[]) => void - openItemsDrawer?: (investigationId: number, items: PossibleItem[]) => void + openVendorDetailsModal?: (vendorId: number) => void } -// This function returns the array of columns for TanStack Table export function getColumns({ setRowAction, - openContactsModal, - openItemsDrawer, + openVendorDetailsModal, }: GetVendorInvestigationsColumnsProps): ColumnDef< VendorInvestigationsViewWithContacts >[] { @@ -63,25 +62,22 @@ export function getColumns({ } // -------------------------------------------- - // 2) Actions column (optional) + // 2) Actions column // -------------------------------------------- const actionsColumn: ColumnDef<VendorInvestigationsViewWithContacts> = { id: "actions", enableHiding: false, cell: ({ row }) => { - const inv = row.original - return ( <Button variant="ghost" className="flex size-8 p-0 data-[state=open]:bg-muted" - aria-label="Open menu" + aria-label="실사 정보 수정" onClick={() => { - // e.g. open a dropdown or set your row action setRowAction?.({ type: "update", row }) }} > - <Ellipsis className="size-4" aria-hidden="true" /> + <Edit className="size-4" aria-hidden="true" /> </Button> ) }, @@ -89,97 +85,44 @@ export function getColumns({ } // -------------------------------------------- - // 3) Contacts column (badge count -> open modal) + // 3) Vendor Name with click handler // -------------------------------------------- - const contactsColumn: ColumnDef<VendorInvestigationsViewWithContacts> = { - id: "contacts", - header: "Contacts", + const vendorNameColumn: ColumnDef<VendorInvestigationsViewWithContacts> = { + accessorKey: "vendorName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="협력사명" /> + ), cell: ({ row }) => { - const { contacts, investigationId } = row.original - const count = contacts?.length ?? 0 - - const handleClick = () => { - openContactsModal?.(investigationId, contacts) - } + const vendorId = row.original.vendorId + const vendorName = row.getValue("vendorName") as string return ( <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={handleClick} - aria-label={ - count > 0 ? `View ${count} contacts` : "Add contacts" - } + variant="link" + className="p-0 h-auto font-normal" + onClick={() => openVendorDetailsModal?.(vendorId)} > - <Users 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} Contacts` : "Add Contacts"} - </span> + {vendorName} </Button> ) }, - enableSorting: false, - size: 60, - } - - // -------------------------------------------- - // 4) Possible Items column (badge count -> open drawer) - // -------------------------------------------- - const possibleItemsColumn: ColumnDef<VendorInvestigationsViewWithContacts> = { - id: "possibleItems", - header: "Items", - cell: ({ row }) => { - const { possibleItems, investigationId } = row.original - const count = possibleItems?.length ?? 0 - - const handleClick = () => { - openItemsDrawer?.(investigationId, possibleItems) - } - - return ( - <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={handleClick} - aria-label={ - count > 0 ? `View ${count} items` : "Add items" - } - > - <Boxes 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` : "Add Items"} - </span> - </Button> - ) + meta: { + excelHeader: "협력사명", + group: "협력업체", }, - enableSorting: false, - size: 60, } // -------------------------------------------- - // 5) Build "grouped" columns from config + // 4) Build grouped columns from config // -------------------------------------------- const groupMap: Record<string, ColumnDef<VendorInvestigationsViewWithContacts>[]> = {} vendorInvestigationsColumnsConfig.forEach((cfg) => { + // Skip vendorName as we have a custom column for it + if (cfg.id === "vendorName") { + return + } + const groupName = cfg.group || "_noGroup" if (!groupMap[groupName]) { @@ -196,34 +139,120 @@ export function getColumns({ group: cfg.group, type: cfg.type, }, - cell: ({ row, cell }) => { - const val = cell.getValue() - - // Example: Format date fields + cell: ({ row, column }) => { + const value = row.getValue(column.id) + + // Handle date fields if ( - cfg.id === "investigationCreatedAt" || - cfg.id === "investigationUpdatedAt" || - cfg.id === "scheduledStartAt" || - cfg.id === "scheduledEndAt" || - cfg.id === "completedAt" + column.id === "scheduledStartAt" || + column.id === "scheduledEndAt" || + column.id === "forecastedAt" || + column.id === "requestedAt" || + column.id === "confirmedAt" || + column.id === "completedAt" || + column.id === "createdAt" || + column.id === "updatedAt" ) { - const dateVal = val ? new Date(val as string) : null - return dateVal ? formatDate(dateVal) : "" + if (!value) return "" + return formatDate(new Date(value as string), "KR") + } + + // Handle status fields with badges + if (column.id === "investigationStatus") { + if (!value) return "" + + return ( + <Badge variant={getStatusVariant(value as string)}> + {formatStatus(value as string)} + </Badge> + ) + } + + // Handle evaluation type + if (column.id === "evaluationType") { + if (!value) return "" + + return ( + <span> + {formatEnumValue(value as string)} + </span> + ) + } + + // Handle evaluation result + if (column.id === "evaluationResult") { + if (!value) return "" + + return ( + <Badge variant={getResultVariant(value as string)}> + {formatEnumValue(value as string)} + </Badge> + ) + } + + // Handle IDs for pqSubmissionId (keeping for reference) + if (column.id === "pqSubmissionId") { + return value ? `#${value}` : "" + } + + // Handle file attachment status + if (column.id === "hasAttachments") { + return ( + <div className="flex items-center justify-center"> + {value ? ( + <Badge variant="default" className="text-xs"> + 📎 첨부 + </Badge> + ) : ( + <span className="text-muted-foreground text-xs">-</span> + )} + </div> + ) } - // Example: You could show an icon for "investigationStatus" - if (cfg.id === "investigationStatus") { - return <span className="capitalize">{val as string}</span> + if (column.id === "requesterName") { + if (!value && !row.original.requesterEmail) { + return <span className="text-muted-foreground">미배정</span> + } + + return ( + <div className="flex flex-col"> + <span>{value || "미배정"}</span> + {row.original.requesterEmail && ( + <span className="text-xs text-muted-foreground">{row.original.requesterEmail}</span> + )} + </div> + ) + } + + if (column.id === "qmManagerName") { + if (!value && !row.original.qmManagerEmail) { + return <span className="text-muted-foreground">미배정</span> + } + + return ( + <div className="flex flex-col"> + <span>{value || "미배정"}</span> + {row.original.qmManagerEmail && ( + <span className="text-xs text-muted-foreground">{row.original.qmManagerEmail}</span> + )} + </div> + ) } - return val ?? "" + return value ?? "" }, } groupMap[groupName].push(childCol) }) - // Turn the groupMap into nested columns + // Insert custom vendorNameColumn in the 협력업체 group + if (groupMap["협력업체"]) { + groupMap["협력업체"].unshift(vendorNameColumn) + } + + // Convert the groupMap into nested columns const nestedColumns: ColumnDef<VendorInvestigationsViewWithContacts>[] = [] for (const [groupName, colDefs] of Object.entries(groupMap)) { if (groupName === "_noGroup") { @@ -238,14 +267,76 @@ export function getColumns({ } // -------------------------------------------- - // 6) Return final columns array - // (You can reorder these as you wish.) + // 5) Return final columns array (simplified) // -------------------------------------------- return [ selectColumn, ...nestedColumns, - contactsColumn, - possibleItemsColumn, actionsColumn, ] +} + +// Helper functions for formatting +function formatStatus(status: string): string { + switch (status) { + case "PLANNED": + return "계획됨" + case "IN_PROGRESS": + return "진행 중" + case "COMPLETED": + return "완료됨" + case "CANCELED": + return "취소됨" + default: + return status + } +} + +function formatEnumValue(value: string): string { + switch (value) { + // Evaluation types + case "SITE_AUDIT": + return "실사의뢰평가" + case "QM_SELF_AUDIT": + return "QM자체평가" + + // Evaluation results + case "APPROVED": + return "승인" + case "SUPPLEMENT": + return "보완" + case "REJECTED": + return "불가" + + default: + return value.replace(/_/g, " ").toLowerCase() + } +} + +function getStatusVariant(status: string): "default" | "secondary" | "outline" | "destructive" { + switch (status) { + case "PLANNED": + return "secondary" + case "IN_PROGRESS": + return "default" + case "COMPLETED": + return "outline" + case "CANCELED": + return "destructive" + default: + return "default" + } +} + +function getResultVariant(result: string): "default" | "secondary" | "outline" | "destructive" { + switch (result) { + case "APPROVED": + return "default" + case "SUPPLEMENT": + return "secondary" + case "REJECTED": + return "destructive" + default: + return "outline" + } }
\ No newline at end of file diff --git a/lib/vendor-investigation/table/investigation-table.tsx b/lib/vendor-investigation/table/investigation-table.tsx index 56aa7962..40b849fc 100644 --- a/lib/vendor-investigation/table/investigation-table.tsx +++ b/lib/vendor-investigation/table/investigation-table.tsx @@ -15,14 +15,9 @@ import { useFeatureFlags } from "./feature-flags-provider" import { getColumns } from "./investigation-table-columns" import { getVendorsInvestigation } from "../service" import { VendorsTableToolbarActions } from "./investigation-table-toolbar-actions" -import { - VendorInvestigationsViewWithContacts, - ContactItem, - PossibleItem -} from "@/config/vendorInvestigationsColumnsConfig" +import { VendorInvestigationsViewWithContacts } from "@/config/vendorInvestigationsColumnsConfig" import { UpdateVendorInvestigationSheet } from "./update-investigation-sheet" -import { ItemsDrawer } from "./items-dialog" -import { ContactsDialog } from "./contract-dialog" +import { VendorDetailsDialog } from "./vendor-details-dialog" interface VendorsTableProps { promises: Promise< @@ -38,38 +33,13 @@ export function VendorsInvestigationTable({ promises }: VendorsTableProps) { // Get data from Suspense const [rawResponse] = React.use(promises) - // Transform the data to match the expected types + // Transform the data to match the expected types (simplified) const transformedData: VendorInvestigationsViewWithContacts[] = React.useMemo(() => { return rawResponse.data.map(item => { - // Parse contacts field if it's a string - let contacts: ContactItem[] = [] - if (typeof item.contacts === 'string') { - try { - contacts = JSON.parse(item.contacts) as ContactItem[] - } catch (e) { - console.error('Failed to parse contacts:', e) - } - } else if (Array.isArray(item.contacts)) { - contacts = item.contacts - } - - // Parse possibleItems field if it's a string - let possibleItems: PossibleItem[] = [] - if (typeof item.possibleItems === 'string') { - try { - possibleItems = JSON.parse(item.possibleItems) as PossibleItem[] - } catch (e) { - console.error('Failed to parse possibleItems:', e) - } - } else if (Array.isArray(item.possibleItems)) { - possibleItems = item.possibleItems - } - - // Return a new object with the transformed fields + // Add id field for backward compatibility (maps to investigationId) return { ...item, - contacts, - possibleItems + id: item.investigationId, // Map investigationId to id for backward compatibility } as VendorInvestigationsViewWithContacts }) }, [rawResponse.data]) @@ -81,51 +51,102 @@ export function VendorsInvestigationTable({ promises }: VendorsTableProps) { // Add state for row actions const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorInvestigationsViewWithContacts> | null>(null) - // Add state for contacts dialog - const [contactsDialogOpen, setContactsDialogOpen] = React.useState(false) - const [selectedContacts, setSelectedContacts] = React.useState<ContactItem[]>([]) - const [selectedContactInvestigationId, setSelectedContactInvestigationId] = React.useState<number | null>(null) + // Add state for vendor details dialog + const [vendorDetailsOpen, setVendorDetailsOpen] = React.useState(false) + const [selectedVendorId, setSelectedVendorId] = React.useState<number | null>(null) - // Add state for items drawer - const [itemsDrawerOpen, setItemsDrawerOpen] = React.useState(false) - const [selectedItems, setSelectedItems] = React.useState<PossibleItem[]>([]) - const [selectedItemInvestigationId, setSelectedItemInvestigationId] = React.useState<number | null>(null) - - // Create handlers for opening the contacts dialog and items drawer - const openContactsModal = React.useCallback((investigationId: number, contacts: ContactItem[]) => { - setSelectedContactInvestigationId(investigationId) - setSelectedContacts(contacts || []) - setContactsDialogOpen(true) - }, []) - - const openItemsDrawer = React.useCallback((investigationId: number, items: PossibleItem[]) => { - setSelectedItemInvestigationId(investigationId) - setSelectedItems(items || []) - setItemsDrawerOpen(true) + // Create handler for opening vendor details modal + const openVendorDetailsModal = React.useCallback((vendorId: number) => { + setSelectedVendorId(vendorId) + setVendorDetailsOpen(true) }, []) // Get router const router = useRouter() - // Call getColumns() with all required functions + // Call getColumns() with required functions (simplified) const columns = React.useMemo( () => getColumns({ setRowAction, - openContactsModal, - openItemsDrawer + openVendorDetailsModal }), - [setRowAction, openContactsModal, openItemsDrawer] + [setRowAction, openVendorDetailsModal] ) + // 기본 필터 필드들 const filterFields: DataTableFilterField<VendorInvestigationsViewWithContacts>[] = [ - { id: "vendorCode", label: "Vendor Code" }, + { id: "vendorCode", label: "협력사 코드" }, + { id: "vendorName", label: "협력사명" }, + { id: "investigationStatus", label: "실사 상태" }, ] + // 고급 필터 필드들 const advancedFilterFields: DataTableAdvancedFilterField<VendorInvestigationsViewWithContacts>[] = [ - { id: "vendorName", label: "Vendor Name", type: "text" }, - { id: "vendorCode", label: "Vendor Code", type: "text" }, + // 협력업체 필터 + { id: "vendorName", label: "협력사명", type: "text" }, + { id: "vendorCode", label: "협력사 코드", type: "text" }, + + // 실사 상태 필터 + { + id: "investigationStatus", + label: "실사 상태", + type: "select", + options: [ + { label: "계획됨", value: "PLANNED" }, + { label: "진행 중", value: "IN_PROGRESS" }, + { label: "완료됨", value: "COMPLETED" }, + { label: "취소됨", value: "CANCELED" }, + ] + }, + { + id: "evaluationType", + label: "평가 유형", + type: "select", + options: [ + { label: "실사의뢰평가", value: "SITE_AUDIT" }, + { label: "QM자체평가", value: "QM_SELF_AUDIT" }, + ] + }, + { + id: "evaluationResult", + label: "평가 결과", + type: "select", + options: [ + { label: "승인", value: "APPROVED" }, + { label: "보완", value: "SUPPLEMENT" }, + { label: "불가", value: "REJECTED" }, + ] + }, + + // 점수 필터 + { id: "evaluationScore", label: "평가 점수", type: "number" }, + + // 담당자 필터 + { id: "requesterName", label: "의뢰자", type: "text" }, + { id: "qmManagerName", label: "QM 담당자", type: "text" }, + + // 첨부파일 필터 + { + id: "hasAttachments", + label: "첨부파일 유무", + type: "select", + options: [ + { label: "첨부파일 있음", value: "true" }, + { label: "첨부파일 없음", value: "false" }, + ] + }, + + // 주요 날짜 필터 + { id: "forecastedAt", label: "실사 예정일", type: "date" }, + { id: "requestedAt", label: "실사 의뢰일", type: "date" }, + { id: "confirmedAt", label: "실사 확정일", type: "date" }, + { id: "completedAt", label: "실제 실사일", type: "date" }, + + // 메모 필터 + { id: "investigationNotes", label: "QM 의견", type: "text" }, ] + // 데이터 테이블 초기화 const { table } = useDataTable({ data: transformedData, columns, @@ -134,10 +155,17 @@ export function VendorsInvestigationTable({ promises }: VendorsTableProps) { enablePinning: true, enableAdvancedFilter: true, initialState: { - sorting: [{ id: "investigationCreatedAt", desc: true }], + sorting: [{ id: "createdAt", desc: true }], columnPinning: { right: ["actions"] }, + columnVisibility: { + // 자주 사용하지 않는 컬럼들은 기본적으로 숨김 + // investigationAddress: false, + // investigationMethod: false, + // requestedAt: false, + // confirmedAt: false, + } }, - getRowId: (originalRow) => String(originalRow.investigationId), + getRowId: (originalRow) => String(originalRow.investigationId ?? originalRow.id), shallow: false, clearOnDefault: true, }) @@ -162,21 +190,12 @@ export function VendorsInvestigationTable({ promises }: VendorsTableProps) { onOpenChange={() => setRowAction(null)} investigation={rowAction?.row.original ?? null} /> - - {/* Contacts Dialog */} - <ContactsDialog - open={contactsDialogOpen} - onOpenChange={setContactsDialogOpen} - investigationId={selectedContactInvestigationId} - contacts={selectedContacts} - /> - - {/* Items Drawer */} - <ItemsDrawer - open={itemsDrawerOpen} - onOpenChange={setItemsDrawerOpen} - investigationId={selectedItemInvestigationId} - items={selectedItems} + + {/* Vendor Details Dialog */} + <VendorDetailsDialog + open={vendorDetailsOpen} + onOpenChange={setVendorDetailsOpen} + vendorId={selectedVendorId} /> </> ) diff --git a/lib/vendor-investigation/table/items-dialog.tsx b/lib/vendor-investigation/table/items-dialog.tsx deleted file mode 100644 index 5d010ff4..00000000 --- a/lib/vendor-investigation/table/items-dialog.tsx +++ /dev/null @@ -1,73 +0,0 @@ -"use client" - -import * as React from "react" -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, - SheetFooter, -} from "@/components/ui/sheet" -import { Button } from "@/components/ui/button" -import { ScrollArea } from "@/components/ui/scroll-area" -import { PossibleItem } from "@/config/vendorInvestigationsColumnsConfig" - -interface ItemsDrawerProps { - open: boolean - onOpenChange: (open: boolean) => void - investigationId: number | null - items: PossibleItem[] -} - -export function ItemsDrawer({ - open, - onOpenChange, - investigationId, - items, -}: ItemsDrawerProps) { - return ( - <Sheet open={open} onOpenChange={onOpenChange}> - <SheetContent className="sm:max-w-md"> - <SheetHeader> - <SheetTitle>Possible Items</SheetTitle> - <SheetDescription> - {items.length > 0 - ? `Showing ${items.length} items for investigation #${investigationId}` - : `No items found for investigation #${investigationId}`} - </SheetDescription> - </SheetHeader> - <ScrollArea className="max-h-[70vh] mt-6 pr-4"> - {items.length > 0 ? ( - <div className="space-y-4"> - {items.map((item, index) => ( - <div - key={index} - className="flex flex-col gap-2 p-3 rounded-lg border" - > - <div className="flex justify-between items-start"> - <h4 className="font-medium">{item.itemName || "Unknown Item"}</h4> - {item.itemName && ( - <span className="text-xs bg-muted px-2 py-1 rounded"> - {item.itemCode} - </span> - )} - </div> - - - </div> - ))} - </div> - ) : ( - <div className="text-center py-6 text-muted-foreground"> - No items available - </div> - )} - </ScrollArea> - <SheetFooter className="mt-4"> - <Button onClick={() => onOpenChange(false)}>Close</Button> - </SheetFooter> - </SheetContent> - </Sheet> - ) -}
\ No newline at end of file diff --git a/lib/vendor-investigation/table/update-investigation-sheet.tsx b/lib/vendor-investigation/table/update-investigation-sheet.tsx index fe30c892..69f0d9ae 100644 --- a/lib/vendor-investigation/table/update-investigation-sheet.tsx +++ b/lib/vendor-investigation/table/update-investigation-sheet.tsx @@ -3,7 +3,8 @@ import * as React from "react" import { zodResolver } from "@hookform/resolvers/zod" import { useForm } from "react-hook-form" -import { Loader } from "lucide-react" +import { CalendarIcon, Loader } from "lucide-react" +import { format } from "date-fns" import { toast } from "sonner" import { Button } from "@/components/ui/button" @@ -16,6 +17,7 @@ import { FormMessage, } from "@/components/ui/form" import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" import { Sheet, SheetClose, @@ -33,33 +35,76 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" +import { Calendar } from "@/components/ui/calendar" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { + Dropzone, + DropzoneZone, + DropzoneUploadIcon, + DropzoneTitle, + DropzoneDescription, + DropzoneInput +} from "@/components/ui/dropzone" import { updateVendorInvestigationSchema, type UpdateVendorInvestigationSchema, } from "../validations" -import { updateVendorInvestigationAction } from "../service" +import { updateVendorInvestigationAction, getInvestigationAttachments, deleteInvestigationAttachment } from "../service" import { VendorInvestigationsViewWithContacts } from "@/config/vendorInvestigationsColumnsConfig" -/** - * The shape of `vendorInvestigation` - * might come from your `vendorInvestigationsView` row - * or your existing type for a single investigation. - */ - interface UpdateVendorInvestigationSheetProps extends React.ComponentPropsWithoutRef<typeof Sheet> { investigation: VendorInvestigationsViewWithContacts | null } +// 첨부파일 정책 정의 +const getFileUploadConfig = (status: string) => { + // 취소된 상태에서만 파일 업로드 비활성화 + if (status === "CANCELED") { + return { + enabled: false, + label: "", + description: "", + accept: undefined, // undefined로 변경 + maxSize: 0, + maxSizeText: "" + } + } + + // 모든 활성 상태에서 동일한 정책 적용 + return { + enabled: true, + label: "실사 관련 첨부파일", + description: "실사와 관련된 모든 문서와 이미지를 첨부할 수 있습니다.", + accept: { + 'application/pdf': ['.pdf'], + 'application/msword': ['.doc'], + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], + 'application/vnd.ms-excel': ['.xls'], + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], + 'image/*': ['.png', '.jpg', '.jpeg', '.gif'], + }, + maxSize: 10 * 1024 * 1024, // 10MB + maxSizeText: "10MB" + } +} + /** - * A sheet for updating a vendor investigation (plus optional attachments). + * 실사 정보 수정 시트 */ export function UpdateVendorInvestigationSheet({ investigation, ...props }: UpdateVendorInvestigationSheetProps) { const [isPending, startTransition] = React.useTransition() + const [existingAttachments, setExistingAttachments] = React.useState<any[]>([]) + const [loadingAttachments, setLoadingAttachments] = React.useState(false) + const [uploadingFiles, setUploadingFiles] = React.useState(false) // RHF + Zod const form = useForm<UpdateVendorInvestigationSchema>({ @@ -67,138 +112,346 @@ export function UpdateVendorInvestigationSheet({ defaultValues: { investigationId: investigation?.investigationId ?? 0, investigationStatus: investigation?.investigationStatus ?? "PLANNED", - scheduledStartAt: investigation?.scheduledStartAt ?? undefined, - scheduledEndAt: investigation?.scheduledEndAt ?? undefined, + evaluationType: investigation?.evaluationType ?? undefined, + investigationAddress: investigation?.investigationAddress ?? "", + investigationMethod: investigation?.investigationMethod ?? "", + forecastedAt: investigation?.forecastedAt ?? undefined, + requestedAt: investigation?.requestedAt ?? undefined, + confirmedAt: investigation?.confirmedAt ?? undefined, completedAt: investigation?.completedAt ?? undefined, + evaluationScore: investigation?.evaluationScore ?? undefined, + evaluationResult: investigation?.evaluationResult ?? undefined, investigationNotes: investigation?.investigationNotes ?? "", + attachments: undefined, // 파일은 매번 새로 업로드 }, }) + // investigation이 변경될 때마다 폼 리셋 React.useEffect(() => { if (investigation) { form.reset({ investigationId: investigation.investigationId, investigationStatus: investigation.investigationStatus || "PLANNED", - scheduledStartAt: investigation.scheduledStartAt ?? undefined, - scheduledEndAt: investigation.scheduledEndAt ?? undefined, + evaluationType: investigation.evaluationType ?? undefined, + investigationAddress: investigation.investigationAddress ?? "", + investigationMethod: investigation.investigationMethod ?? "", + forecastedAt: investigation.forecastedAt ?? undefined, + requestedAt: investigation.requestedAt ?? undefined, + confirmedAt: investigation.confirmedAt ?? undefined, completedAt: investigation.completedAt ?? undefined, + evaluationScore: investigation.evaluationScore ?? undefined, + evaluationResult: investigation.evaluationResult ?? undefined, investigationNotes: investigation.investigationNotes ?? "", + attachments: undefined, // 파일은 매번 새로 업로드 }) + + // 기존 첨부파일 로드 + loadExistingAttachments(investigation.investigationId) } }, [investigation, form]) - // Format date for form data - const formatDateForFormData = (date: Date | undefined): string | null => { - if (!date) return null; - return date.toISOString(); + // 기존 첨부파일 로드 함수 + const loadExistingAttachments = async (investigationId: number) => { + setLoadingAttachments(true) + try { + const result = await getInvestigationAttachments(investigationId) + if (result.success) { + setExistingAttachments(result.attachments || []) + } else { + toast.error("첨부파일 목록을 불러오는데 실패했습니다.") + } + } catch (error) { + console.error("첨부파일 로드 실패:", error) + toast.error("첨부파일 목록을 불러오는 중 오류가 발생했습니다.") + } finally { + setLoadingAttachments(false) + } } - // Submit handler - async function onSubmit(values: UpdateVendorInvestigationSchema) { - if (!values.investigationId) return - - startTransition(async () => { - // 1) Build a FormData object for the server action - const formData = new FormData() - - // Add text fields - formData.append("investigationId", String(values.investigationId)) - formData.append("investigationStatus", values.investigationStatus) - - // Format dates properly before appending to FormData - if (values.scheduledStartAt) { - const formattedDate = formatDateForFormData(values.scheduledStartAt) - if (formattedDate) formData.append("scheduledStartAt", formattedDate) - } + // 첨부파일 삭제 함수 + const handleDeleteAttachment = async (attachmentId: number) => { + if (!investigation) return + + try { + const response = await fetch(`/api/vendor-investigations/${investigation.investigationId}/attachments?attachmentId=${attachmentId}`, { + method: "DELETE", + }) - if (values.scheduledEndAt) { - const formattedDate = formatDateForFormData(values.scheduledEndAt) - if (formattedDate) formData.append("scheduledEndAt", formattedDate) + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || "첨부파일 삭제 실패") } - if (values.completedAt) { - const formattedDate = formatDateForFormData(values.completedAt) - if (formattedDate) formData.append("completedAt", formattedDate) - } + toast.success("첨부파일이 삭제되었습니다.") + // 목록 새로고침 + loadExistingAttachments(investigation.investigationId) - if (values.investigationNotes) { - formData.append("investigationNotes", values.investigationNotes) - } + } catch (error) { + console.error("첨부파일 삭제 오류:", error) + toast.error(error instanceof Error ? error.message : "첨부파일 삭제 중 오류가 발생했습니다.") + } + } - // Add attachments (if any) - // Note: If you have multiple files in "attachments", we store them in the form under the same key. - const attachmentValue = form.getValues("attachments"); - if (attachmentValue instanceof FileList) { - for (let i = 0; i < attachmentValue.length; i++) { - formData.append("attachments", attachmentValue[i]); - } - } + // 파일 업로드 섹션 렌더링 + const renderFileUploadSection = () => { + const currentStatus = form.watch("investigationStatus") + const config = getFileUploadConfig(currentStatus) + + if (!config.enabled) return null - const { error } = await updateVendorInvestigationAction(formData) - if (error) { - toast.error(error) - return - } + return ( + <> + {/* 기존 첨부파일 목록 */} + {(existingAttachments.length > 0 || loadingAttachments) && ( + <div className="space-y-2"> + <FormLabel>기존 첨부파일</FormLabel> + <div className="border rounded-md p-3 space-y-2 max-h-32 overflow-y-auto"> + {loadingAttachments ? ( + <div className="flex items-center justify-center py-4"> + <Loader className="h-4 w-4 animate-spin" /> + <span className="ml-2 text-sm text-muted-foreground"> + 첨부파일 로딩 중... + </span> + </div> + ) : existingAttachments.length > 0 ? ( + existingAttachments.map((attachment) => ( + <div key={attachment.id} className="flex items-center justify-between text-sm"> + <div className="flex items-center space-x-2 flex-1 min-w-0"> + <span className="text-xs px-2 py-1 bg-muted rounded"> + {attachment.attachmentType} + </span> + <span className="truncate">{attachment.fileName}</span> + <span className="text-muted-foreground"> + ({Math.round(attachment.fileSize / 1024)}KB) + </span> + </div> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => handleDeleteAttachment(attachment.id)} + className="text-destructive hover:text-destructive" + disabled={isPending} + > + 삭제 + </Button> + </div> + )) + ) : ( + <div className="text-sm text-muted-foreground text-center py-2"> + 첨부된 파일이 없습니다. + </div> + )} + </div> + </div> + )} - toast.success("Investigation updated!") - form.reset() - props.onOpenChange?.(false) - }) + {/* 새 파일 업로드 */} + <FormField + control={form.control} + name="attachments" + render={({ field: { onChange, ...field } }) => ( + <FormItem> + <FormLabel>{config.label}</FormLabel> + <FormControl> + <Dropzone + onDrop={(acceptedFiles, rejectedFiles) => { + // 거부된 파일에 대한 상세 에러 메시지 + if (rejectedFiles.length > 0) { + rejectedFiles.forEach((file) => { + const error = file.errors[0] + if (error.code === 'file-too-large') { + toast.error(`${file.file.name}: 파일 크기가 ${config.maxSizeText}를 초과합니다.`) + } else if (error.code === 'file-invalid-type') { + toast.error(`${file.file.name}: 지원하지 않는 파일 형식입니다.`) + } else { + toast.error(`${file.file.name}: 파일 업로드에 실패했습니다.`) + } + }) + } + + if (acceptedFiles.length > 0) { + onChange(acceptedFiles) + toast.success(`${acceptedFiles.length}개 파일이 선택되었습니다.`) + } + }} + accept={config.accept} + multiple + maxSize={config.maxSize} + disabled={isPending || uploadingFiles} + > + <DropzoneZone> + <DropzoneUploadIcon /> + <DropzoneTitle> + {isPending || uploadingFiles + ? "파일 업로드 중..." + : "파일을 드래그하거나 클릭하여 업로드" + } + </DropzoneTitle> + <DropzoneDescription> + {config.description} (최대 {config.maxSizeText}) + </DropzoneDescription> + <DropzoneInput /> + </DropzoneZone> + </Dropzone> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </> + ) } - // Format date value for input field - const formatDateForInput = (date: Date | undefined): string => { - if (!date) return ""; - return date instanceof Date ? date.toISOString().slice(0, 10) : ""; + // 파일 업로드 함수 + const uploadFiles = async (files: File[], investigationId: number) => { + const uploadPromises = files.map(async (file) => { + const formData = new FormData() + formData.append("file", file) + + const response = await fetch(`/api/vendor-investigations/${investigationId}/attachments`, { + method: "POST", + body: formData, + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || "파일 업로드 실패") + } + + return await response.json() + }) + + return await Promise.all(uploadPromises) } - // Handle date input change - const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>, onChange: (...event: any[]) => void) => { - const val = e.target.value; - if (val) { - // Ensure proper date handling by setting to noon to avoid timezone issues - const newDate = new Date(`${val}T12:00:00`); - onChange(newDate); - } else { - onChange(undefined); - } + // Submit handler + async function onSubmit(values: UpdateVendorInvestigationSchema) { + if (!values.investigationId) return + + startTransition(async () => { + try { + // 1) 먼저 텍스트 데이터 업데이트 + const formData = new FormData() + + // 필수 필드 + formData.append("investigationId", String(values.investigationId)) + formData.append("investigationStatus", values.investigationStatus) + + // 선택적 필드들 + if (values.evaluationType) { + formData.append("evaluationType", values.evaluationType) + } + + if (values.investigationAddress) { + formData.append("investigationAddress", values.investigationAddress) + } + + if (values.investigationMethod) { + formData.append("investigationMethod", values.investigationMethod) + } + + if (values.forecastedAt) { + formData.append("forecastedAt", values.forecastedAt.toISOString()) + } + + if (values.requestedAt) { + formData.append("requestedAt", values.requestedAt.toISOString()) + } + + if (values.confirmedAt) { + formData.append("confirmedAt", values.confirmedAt.toISOString()) + } + + if (values.completedAt) { + formData.append("completedAt", values.completedAt.toISOString()) + } + + if (values.evaluationScore !== undefined) { + formData.append("evaluationScore", String(values.evaluationScore)) + } + + if (values.evaluationResult) { + formData.append("evaluationResult", values.evaluationResult) + } + + if (values.investigationNotes) { + formData.append("investigationNotes", values.investigationNotes) + } + + // 텍스트 데이터 업데이트 + const { error } = await updateVendorInvestigationAction(formData) + + if (error) { + toast.error(error) + return + } + + // 2) 파일이 있으면 업로드 + if (values.attachments && values.attachments.length > 0) { + setUploadingFiles(true) + + try { + await uploadFiles(values.attachments, values.investigationId) + toast.success(`실사 정보와 ${values.attachments.length}개 파일이 업데이트되었습니다!`) + + // 첨부파일 목록 새로고침 + loadExistingAttachments(values.investigationId) + } catch (fileError) { + toast.error(`데이터는 저장되었지만 파일 업로드 중 오류가 발생했습니다: ${fileError}`) + } finally { + setUploadingFiles(false) + } + } else { + toast.success("실사 정보가 업데이트되었습니다!") + } + + form.reset() + props.onOpenChange?.(false) + + } catch (error) { + console.error("실사 정보 업데이트 오류:", error) + toast.error("실사 정보 업데이트 중 오류가 발생했습니다.") + } + }) } return ( <Sheet {...props}> - <SheetContent className="flex flex-col gap-6 sm:max-w-md"> - <SheetHeader className="text-left"> - <SheetTitle>Update Investigation</SheetTitle> + <SheetContent className="flex flex-col h-full sm:max-w-md"> + <SheetHeader className="text-left flex-shrink-0"> + <SheetTitle>실사 업데이트</SheetTitle> <SheetDescription> - Change the investigation details & attachments + {investigation?.vendorName && ( + <span className="font-medium">{investigation.vendorName}</span> + )}의 실사 정보를 수정합니다. </SheetDescription> </SheetHeader> - <Form {...form}> - <form - onSubmit={form.handleSubmit(onSubmit)} - className="flex flex-col gap-4" - // Must use multipart to support file uploads - encType="multipart/form-data" - > - {/* investigationStatus */} + <div className="flex-1 overflow-y-auto py-4"> + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="flex flex-col gap-4" + > + {/* 실사 상태 */} <FormField control={form.control} name="investigationStatus" render={({ field }) => ( <FormItem> - <FormLabel>Status</FormLabel> + <FormLabel>실사 상태</FormLabel> <FormControl> <Select value={field.value} onValueChange={field.onChange}> - <SelectTrigger className="capitalize"> - <SelectValue placeholder="Select a status" /> + <SelectTrigger> + <SelectValue placeholder="상태를 선택하세요" /> </SelectTrigger> <SelectContent> <SelectGroup> - <SelectItem value="PLANNED">PLANNED</SelectItem> - <SelectItem value="IN_PROGRESS">IN_PROGRESS</SelectItem> - <SelectItem value="COMPLETED">COMPLETED</SelectItem> - <SelectItem value="CANCELED">CANCELED</SelectItem> + <SelectItem value="PLANNED">계획됨</SelectItem> + <SelectItem value="IN_PROGRESS">진행 중</SelectItem> + <SelectItem value="COMPLETED">완료됨</SelectItem> + <SelectItem value="CANCELED">취소됨</SelectItem> </SelectGroup> </SelectContent> </Select> @@ -208,37 +461,43 @@ export function UpdateVendorInvestigationSheet({ )} /> - {/* scheduledStartAt */} + {/* 평가 유형 */} <FormField control={form.control} - name="scheduledStartAt" + name="evaluationType" render={({ field }) => ( <FormItem> - <FormLabel>Scheduled Start</FormLabel> + <FormLabel>평가 유형</FormLabel> <FormControl> - <Input - type="date" - value={formatDateForInput(field.value)} - onChange={(e) => handleDateChange(e, field.onChange)} - /> + <Select value={field.value || ""} onValueChange={field.onChange}> + <SelectTrigger> + <SelectValue placeholder="평가 유형을 선택하세요" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + <SelectItem value="SITE_AUDIT">실사의뢰평가</SelectItem> + <SelectItem value="QM_SELF_AUDIT">QM자체평가</SelectItem> + </SelectGroup> + </SelectContent> + </Select> </FormControl> <FormMessage /> </FormItem> )} /> - {/* scheduledEndAt */} + {/* 실사 주소 */} <FormField control={form.control} - name="scheduledEndAt" + name="investigationAddress" render={({ field }) => ( <FormItem> - <FormLabel>Scheduled End</FormLabel> + <FormLabel>실사 주소</FormLabel> <FormControl> - <Input - type="date" - value={formatDateForInput(field.value)} - onChange={(e) => handleDateChange(e, field.onChange)} + <Textarea + placeholder="실사가 진행될 주소를 입력하세요..." + {...field} + className="min-h-[60px]" /> </FormControl> <FormMessage /> @@ -246,55 +505,200 @@ export function UpdateVendorInvestigationSheet({ )} /> - {/* completedAt */} + {/* 실사 방법 */} <FormField control={form.control} - name="completedAt" + name="investigationMethod" render={({ field }) => ( <FormItem> - <FormLabel>Completed At</FormLabel> + <FormLabel>실사 방법</FormLabel> <FormControl> - <Input - type="date" - value={formatDateForInput(field.value)} - onChange={(e) => handleDateChange(e, field.onChange)} - /> + <Input placeholder="실사 방법을 입력하세요..." {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> - {/* investigationNotes */} + {/* 실사 예정일 */} <FormField control={form.control} - name="investigationNotes" + name="forecastedAt" render={({ field }) => ( - <FormItem> - <FormLabel>Notes</FormLabel> - <FormControl> - <Input placeholder="Notes about the investigation..." {...field} /> - </FormControl> + <FormItem className="flex flex-col"> + <FormLabel>실사 예정일</FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + className={`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} + initialFocus + /> + </PopoverContent> + </Popover> <FormMessage /> </FormItem> )} /> - {/* attachments: multiple file upload */} + {/* 실사 확정일 */} <FormField control={form.control} - name="attachments" - render={({ field: { value, onChange, ...fieldProps } }) => ( + name="confirmedAt" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel>실사 확정일</FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + className={`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} + initialFocus + /> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + + {/* 실제 실사일 */} + <FormField + control={form.control} + name="completedAt" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel>실제 실사일</FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + className={`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} + initialFocus + /> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + + {/* 평가 점수 - 완료된 상태일 때만 표시 */} + {form.watch("investigationStatus") === "COMPLETED" && ( + <FormField + control={form.control} + name="evaluationScore" + render={({ field }) => ( + <FormItem> + <FormLabel>평가 점수</FormLabel> + <FormControl> + <Input + type="number" + min={0} + max={100} + placeholder="0-100점" + {...field} + value={field.value || ""} + onChange={(e) => { + const value = e.target.value === "" ? undefined : parseInt(e.target.value, 10) + field.onChange(value) + }} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + )} + + {/* 평가 결과 - 완료된 상태일 때만 표시 */} + {form.watch("investigationStatus") === "COMPLETED" && ( + <FormField + control={form.control} + name="evaluationResult" + render={({ field }) => ( + <FormItem> + <FormLabel>평가 결과</FormLabel> + <FormControl> + <Select value={field.value || ""} onValueChange={field.onChange}> + <SelectTrigger> + <SelectValue placeholder="평가 결과를 선택하세요" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + <SelectItem value="APPROVED">승인</SelectItem> + <SelectItem value="SUPPLEMENT">보완</SelectItem> + <SelectItem value="REJECTED">불가</SelectItem> + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + )} + + {/* QM 의견 */} + <FormField + control={form.control} + name="investigationNotes" + render={({ field }) => ( <FormItem> - <FormLabel>Attachments</FormLabel> + <FormLabel>QM 의견</FormLabel> <FormControl> - <Input - type="file" - multiple - onChange={(e) => { - onChange(e.target.files); // Store the FileList directly - }} - {...fieldProps} + <Textarea + placeholder="실사에 대한 QM 의견을 입력하세요..." + {...field} + className="min-h-[80px]" /> </FormControl> <FormMessage /> @@ -302,22 +706,29 @@ export function UpdateVendorInvestigationSheet({ )} /> - {/* Footer Buttons */} - <SheetFooter className="gap-2 pt-2 sm:space-x-0"> - <SheetClose asChild> - <Button type="button" variant="outline"> - Cancel - </Button> - </SheetClose> - <Button disabled={isPending}> - {isPending && ( - <Loader className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" /> - )} - Save - </Button> - </SheetFooter> - </form> - </Form> + {/* 파일 첨부 섹션 */} + {renderFileUploadSection()} + </form> + </Form> + </div> + + {/* Footer Buttons */} + <SheetFooter className="gap-2 pt-2 sm:space-x-0 flex-shrink-0"> + <SheetClose asChild> + <Button type="button" variant="outline" disabled={isPending || uploadingFiles}> + 취소 + </Button> + </SheetClose> + <Button + disabled={isPending || uploadingFiles} + onClick={form.handleSubmit(onSubmit)} + > + {(isPending || uploadingFiles) && ( + <Loader className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" /> + )} + {uploadingFiles ? "업로드 중..." : isPending ? "저장 중..." : "저장"} + </Button> + </SheetFooter> </SheetContent> </Sheet> ) diff --git a/lib/vendor-investigation/table/vendor-details-dialog.tsx b/lib/vendor-investigation/table/vendor-details-dialog.tsx new file mode 100644 index 00000000..27ed7826 --- /dev/null +++ b/lib/vendor-investigation/table/vendor-details-dialog.tsx @@ -0,0 +1,341 @@ +"use client" + +import * as React from "react" +import { Building, Globe, Mail, MapPin, Phone, RefreshCw, Search } from "lucide-react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Skeleton } from "@/components/ui/skeleton" +import { Badge } from "@/components/ui/badge" +import { Separator } from "@/components/ui/separator" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { useToast } from "@/hooks/use-toast" + +// Import vendor service +import { getVendorById, getVendorItemsByVendorId } from "@/lib/vendor-investigation/service" +import { useRouter } from "next/navigation" + +interface VendorDetailsDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + vendorId: number | null +} + +export function VendorDetailsDialog({ + open, + onOpenChange, + vendorId, +}: VendorDetailsDialogProps) { + const { toast } = useToast() + const router = useRouter() + const [loading, setLoading] = React.useState(false) + const [vendorData, setVendorData] = React.useState<any>(null) + const [vendorItems, setVendorItems] = React.useState<any[]>([]) + const [activeTab, setActiveTab] = React.useState("details") + + // Fetch vendor details when the dialog opens + React.useEffect(() => { + if (open && vendorId) { + setLoading(true) + + // Fetch vendor details + Promise.all([ + getVendorById(vendorId), + getVendorItemsByVendorId(vendorId) + ]) + .then(([vendorDetails, items]) => { + setVendorData(vendorDetails) + setVendorItems(items || []) + }) + .catch((error) => { + console.error("Error fetching vendor data:", error) + toast({ + title: "Error", + description: "Failed to load vendor details. Please try again.", + variant: "destructive", + }) + }) + .finally(() => { + setLoading(false) + }) + } else { + // Reset state when the dialog closes + setVendorData(null) + setVendorItems([]) + } + }, [open, vendorId, toast]) + + // Handle refresh button click + const handleRefresh = () => { + if (!vendorId) return + + setLoading(true) + Promise.all([ + getVendorById(vendorId), + getVendorItemsByVendorId(vendorId) + ]) + .then(([vendorDetails, items]) => { + setVendorData(vendorDetails) + setVendorItems(items || []) + toast({ + title: "Refreshed", + description: "Vendor information has been refreshed.", + }) + }) + .catch((error) => { + console.error("Error refreshing vendor data:", error) + toast({ + title: "Error", + description: "Failed to refresh vendor details.", + variant: "destructive", + }) + }) + .finally(() => { + setLoading(false) + }) + } + + // Get vendor status badge variant + const getStatusVariant = (status: string) => { + switch (status?.toUpperCase()) { + case "ACTIVE": + return "default" + case "PENDING": + return "secondary" + case "SUSPENDED": + return "destructive" + case "APPROVED": + return "outline" + default: + return "secondary" + } + } + + // Navigate to full vendor profile page + const navigateToVendorProfile = () => { + if (!vendorId) return + + // Close dialog + onOpenChange(false) + + // Navigate to vendor profile page with router + router.push(`/evcp/vendors/${vendorId}`) + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[700px]"> + <DialogHeader> + <div className="flex items-center justify-between"> + <DialogTitle>협력업체 상세정보</DialogTitle> + <Button + variant="outline" + size="icon" + onClick={handleRefresh} + disabled={loading} + > + <RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} /> + <span className="sr-only">새로고침</span> + </Button> + </div> + <DialogDescription> + 협력업체 정보 상세보기 + </DialogDescription> + </DialogHeader> + + {loading ? ( + <div className="space-y-4 py-4"> + <div className="flex items-center space-x-4"> + <Skeleton className="h-12 w-12 rounded-full" /> + <div className="space-y-2"> + <Skeleton className="h-4 w-[200px]" /> + <Skeleton className="h-4 w-[150px]" /> + </div> + </div> + <Skeleton className="h-[200px] w-full" /> + </div> + ) : vendorData ? ( + <div className="py-4"> + {/* Vendor header with main info */} + <div className="flex items-start justify-between mb-6"> + <div> + <h2 className="text-xl font-semibold">{vendorData.name}</h2> + <div className="flex items-center mt-1 space-x-2"> + <span className="text-sm text-muted-foreground">업체코드: {vendorData.code}</span> + {vendorData.taxId && ( + <> + <span className="text-muted-foreground">•</span> + <span className="text-sm text-muted-foreground">사업자등록번호: {vendorData.taxId}</span> + </> + )} + </div> + </div> + {vendorData.status && ( + <Badge variant={getStatusVariant(vendorData.status)}> + {vendorData.status} + </Badge> + )} + </div> + + <Tabs defaultValue="details" onValueChange={setActiveTab}> + <TabsList className="mb-4"> + <TabsTrigger value="details">상세</TabsTrigger> + <TabsTrigger value="items">공급품목({vendorItems.length})</TabsTrigger> + </TabsList> + + {/* Details Tab */} + <TabsContent value="details" className="space-y-4"> + {/* Contact Information Card */} + <Card> + <CardHeader className="pb-2"> + <CardTitle className="text-base">연락처 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-2"> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + {/* Email */} + <div className="flex items-center space-x-2"> + <Mail className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm">{vendorData.email || "No email provided"}</span> + </div> + + {/* Phone */} + <div className="flex items-center space-x-2"> + <Phone className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm">{vendorData.phone || "No phone provided"}</span> + </div> + + {/* Website */} + {vendorData.website && ( + <div className="flex items-center space-x-2"> + <Globe className="h-4 w-4 text-muted-foreground" /> + <a + href={vendorData.website.startsWith('http') ? vendorData.website : `https://${vendorData.website}`} + target="_blank" + rel="noopener noreferrer" + className="text-sm text-blue-600 hover:underline" + > + {vendorData.website} + </a> + </div> + )} + + {/* Address */} + {vendorData.address && ( + <div className="flex items-center space-x-2"> + <MapPin className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm">{vendorData.address}</span> + </div> + )} + + {/* Country */} + {vendorData.country && ( + <div className="flex items-center space-x-2"> + <Building className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm">{vendorData.country}</span> + </div> + )} + </div> + </CardContent> + </Card> + + {/* Additional Information */} + {vendorData.description && ( + <Card> + <CardHeader className="pb-2"> + <CardTitle className="text-base">협력업체 설명</CardTitle> + </CardHeader> + <CardContent> + <p className="text-sm">{vendorData.description}</p> + </CardContent> + </Card> + )} + + {/* Registration Information */} + <Card> + <CardHeader className="pb-2"> + <CardTitle className="text-base">등록 정보</CardTitle> + </CardHeader> + <CardContent> + <div className="grid grid-cols-2 gap-2"> + <div> + <p className="text-xs text-muted-foreground">협력업체 생성일</p> + <p className="text-sm"> + {vendorData.createdAt + ? new Date(vendorData.createdAt).toLocaleDateString() + : "Unknown" + } + </p> + </div> + <div> + <p className="text-xs text-muted-foreground">협력업체 정보 업데이트일</p> + <p className="text-sm"> + {vendorData.updatedAt + ? new Date(vendorData.updatedAt).toLocaleDateString() + : "Unknown" + } + </p> + </div> + </div> + </CardContent> + </Card> + </TabsContent> + + {/* Items Tab */} + <TabsContent value="items"> + <ScrollArea className="h-[300px] pr-4"> + {vendorItems.length > 0 ? ( + <div className="space-y-4"> + {vendorItems.map((item) => ( + <Card key={item.id}> + <CardHeader className="pb-2"> + <CardTitle className="text-base">{item.itemName}</CardTitle> + <CardDescription>Code: {item.itemCode}</CardDescription> + </CardHeader> + {item.description && ( + <CardContent> + <p className="text-sm">{item.description}</p> + </CardContent> + )} + </Card> + ))} + </div> + ) : ( + <div className="flex flex-col items-center justify-center h-full text-center p-8"> + <Search className="h-8 w-8 text-muted-foreground mb-2" /> + <h3 className="text-lg font-semibold">No items found</h3> + <p className="text-sm text-muted-foreground">해당 업체는 아직 공급품목이 등록되지 않았습니다.</p> + </div> + )} + </ScrollArea> + </TabsContent> + + </Tabs> + </div> + ) : ( + <div className="py-6 text-center"> + <p className="text-muted-foreground">No vendor information available</p> + </div> + )} + + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 닫기 + </Button> + {vendorData && ( + <Button onClick={navigateToVendorProfile}> + 전체 정보 보러가기 + </Button> + )} + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/vendor-investigation/validations.ts b/lib/vendor-investigation/validations.ts index 18a50022..bfe2e988 100644 --- a/lib/vendor-investigation/validations.ts +++ b/lib/vendor-investigation/validations.ts @@ -1,4 +1,3 @@ -import { vendorInvestigationsView } from "@/db/schema/vendors" import { createSearchParamsCache, parseAsArrayOf, @@ -8,6 +7,7 @@ import { } from "nuqs/server" import * as z from "zod" import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { vendorInvestigationsView } from "@/db/schema" export const searchParamsInvestigationCache = createSearchParamsCache({ // Common flags @@ -19,7 +19,7 @@ export const searchParamsInvestigationCache = createSearchParamsCache({ // Sorting - adjusting for vendorInvestigationsView sort: getSortingStateParser<typeof vendorInvestigationsView.$inferSelect>().withDefault([ - { id: "investigationCreatedAt", desc: true }, + { id: "createdAt", desc: true }, ]), // Advanced filter @@ -60,34 +60,28 @@ export const searchParamsInvestigationCache = createSearchParamsCache({ // Finally, export the type you can use in your server action: export type GetVendorsInvestigationSchema = Awaited<ReturnType<typeof searchParamsInvestigationCache.parse>> - export const updateVendorInvestigationSchema = z.object({ - investigationId: z.number(), - investigationStatus: z.enum(["PLANNED", "IN_PROGRESS", "COMPLETED", "CANCELED"]), - - // If the user might send empty strings, we'll allow it by unioning with z.literal('') - // Then transform empty string to undefined - scheduledStartAt: z.preprocess( - // null이나 빈 문자열을 undefined로 변환 - (val) => (val === null || val === '') ? undefined : val, - z.date().optional() - ), - - scheduledEndAt:z.preprocess( - // null이나 빈 문자열을 undefined로 변환 - (val) => (val === null || val === '') ? undefined : val, - z.date().optional() - ), - - completedAt: z.preprocess( - // null이나 빈 문자열을 undefined로 변환 - (val) => (val === null || val === '') ? undefined : val, - z.date().optional() - ), - investigationNotes: z.string().optional(), - attachments: z.any().optional(), - }) + investigationId: z.number({ + required_error: "Investigation ID is required", + }), + investigationStatus: z.enum(["PLANNED", "IN_PROGRESS", "COMPLETED", "CANCELED"], { + required_error: "실사 상태를 선택해주세요.", + }), + evaluationType: z.enum(["SITE_AUDIT", "QM_SELF_AUDIT"]).optional(), + investigationAddress: z.string().optional(), + investigationMethod: z.string().max(100, "실사 방법은 100자 이내로 입력해주세요.").optional(), + forecastedAt: z.date().optional(), + requestedAt: z.date().optional(), + confirmedAt: z.date().optional(), + completedAt: z.date().optional(), + evaluationScore: z.number() + .int("평가 점수는 정수여야 합니다.") + .min(0, "평가 점수는 0점 이상이어야 합니다.") + .max(100, "평가 점수는 100점 이하여야 합니다.") + .optional(), + evaluationResult: z.enum(["APPROVED", "SUPPLEMENT", "REJECTED"]).optional(), + investigationNotes: z.string().max(1000, "QM 의견은 1000자 이내로 입력해주세요.").optional(), + attachments: z.any().optional(), // File 업로드를 위한 필드 +}) -export type UpdateVendorInvestigationSchema = z.infer< - typeof updateVendorInvestigationSchema ->
\ No newline at end of file +export type UpdateVendorInvestigationSchema = z.infer<typeof updateVendorInvestigationSchema> |
