summaryrefslogtreecommitdiff
path: root/lib/vendor-investigation
diff options
context:
space:
mode:
Diffstat (limited to 'lib/vendor-investigation')
-rw-r--r--lib/vendor-investigation/service.ts523
-rw-r--r--lib/vendor-investigation/table/contract-dialog.tsx85
-rw-r--r--lib/vendor-investigation/table/investigation-table-columns.tsx313
-rw-r--r--lib/vendor-investigation/table/investigation-table.tsx177
-rw-r--r--lib/vendor-investigation/table/items-dialog.tsx73
-rw-r--r--lib/vendor-investigation/table/update-investigation-sheet.tsx713
-rw-r--r--lib/vendor-investigation/table/vendor-details-dialog.tsx341
-rw-r--r--lib/vendor-investigation/validations.ts56
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 &amp; 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>