diff options
Diffstat (limited to 'lib/vendor-investigation')
| -rw-r--r-- | lib/vendor-investigation/service.ts | 516 | ||||
| -rw-r--r-- | lib/vendor-investigation/table/investigation-progress-sheet.tsx | 324 | ||||
| -rw-r--r-- | lib/vendor-investigation/table/investigation-result-sheet.tsx | 808 | ||||
| -rw-r--r-- | lib/vendor-investigation/table/investigation-table-columns.tsx | 110 | ||||
| -rw-r--r-- | lib/vendor-investigation/table/investigation-table.tsx | 51 | ||||
| -rw-r--r-- | lib/vendor-investigation/table/update-investigation-sheet.tsx | 144 | ||||
| -rw-r--r-- | lib/vendor-investigation/validations.ts | 53 |
7 files changed, 1902 insertions, 104 deletions
diff --git a/lib/vendor-investigation/service.ts b/lib/vendor-investigation/service.ts index 9395a5de..f81f78f6 100644 --- a/lib/vendor-investigation/service.ts +++ b/lib/vendor-investigation/service.ts @@ -1,7 +1,7 @@ "use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) -import { items, vendorInvestigationAttachments, vendorInvestigations, vendorInvestigationsView, vendorPossibleItems, vendors } from "@/db/schema/" -import { GetVendorsInvestigationSchema, updateVendorInvestigationSchema } from "./validations" +import { items, vendorInvestigationAttachments, vendorInvestigations, vendorInvestigationsView, vendorPossibleItems, vendors, siteVisitRequests } from "@/db/schema/" +import { GetVendorsInvestigationSchema, updateVendorInvestigationSchema, updateVendorInvestigationProgressSchema, updateVendorInvestigationResultSchema } from "./validations" import { asc, desc, ilike, inArray, and, or, gte, lte, eq, isNull, count } from "drizzle-orm"; import { revalidateTag, unstable_noStore, revalidatePath } from "next/cache"; import { filterColumns } from "@/lib/filter-columns"; @@ -193,7 +193,213 @@ export async function requestInvestigateVendors({ } -// 개선된 서버 액션 - 텍스트 데이터만 처리 +// 실사 진행 관리 업데이트 액션 (PLANNED -> IN_PROGRESS) +export async function updateVendorInvestigationProgressAction(formData: FormData) { + try { + // 1) 텍스트 필드만 추출 + const textEntries: Record<string, string> = {} + for (const [key, value] of formData.entries()) { + if (typeof value === "string") { + textEntries[key] = value + } + } + + // 2) 적절한 타입으로 변환 + const processedEntries: any = {} + + // 필수 필드 + if (textEntries.investigationId) { + processedEntries.investigationId = Number(textEntries.investigationId) + } + + // 선택적 필드들 + if (textEntries.investigationAddress) { + processedEntries.investigationAddress = textEntries.investigationAddress + } + if (textEntries.investigationMethod) { + processedEntries.investigationMethod = textEntries.investigationMethod + } + + // 선택적 날짜 필드 + if (textEntries.forecastedAt) { + processedEntries.forecastedAt = new Date(textEntries.forecastedAt) + } + if (textEntries.confirmedAt) { + processedEntries.confirmedAt = new Date(textEntries.confirmedAt) + } + + // 3) Zod로 파싱/검증 + const parsed = updateVendorInvestigationProgressSchema.parse(processedEntries) + + // 4) 업데이트 데이터 준비 + const updateData: any = { + updatedAt: new Date(), + } + + // 선택적 필드들은 존재할 때만 추가 + 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.confirmedAt !== undefined) { + updateData.confirmedAt = parsed.confirmedAt + } + + // 실사 방법이 설정되면 PLANNED -> IN_PROGRESS로 상태 변경 + if (parsed.investigationMethod) { + updateData.investigationStatus = "IN_PROGRESS" + } + + // 5) vendor_investigations 테이블 업데이트 + await db + .update(vendorInvestigations) + .set(updateData) + .where(eq(vendorInvestigations.id, parsed.investigationId)) + + // 6) 캐시 무효화 + revalidateTag("vendor-investigations") + revalidatePath("/evcp/vendor-investigation") + + return { success: true } + } catch (error) { + console.error("실사 진행 관리 업데이트 오류:", error) + return { + success: false, + error: error instanceof Error ? error.message : "알 수 없는 오류" + } + } +} + +// 실사 결과 입력 액션 (IN_PROGRESS -> COMPLETED/CANCELED/SUPPLEMENT_REQUIRED) +export async function updateVendorInvestigationResultAction(formData: FormData) { + try { + // 1) 텍스트 필드만 추출 + const textEntries: Record<string, string> = {} + for (const [key, value] of formData.entries()) { + if (typeof value === "string") { + textEntries[key] = value + } + } + + // 2) 적절한 타입으로 변환 + const processedEntries: any = {} + + // 필수 필드 + if (textEntries.investigationId) { + processedEntries.investigationId = Number(textEntries.investigationId) + } + + // 선택적 필드들 + if (textEntries.completedAt) { + processedEntries.completedAt = new Date(textEntries.completedAt) + } + if (textEntries.evaluationScore) { + processedEntries.evaluationScore = Number(textEntries.evaluationScore) + } + if (textEntries.evaluationResult) { + processedEntries.evaluationResult = textEntries.evaluationResult + } + if (textEntries.investigationNotes) { + processedEntries.investigationNotes = textEntries.investigationNotes + } + + // 3) Zod로 파싱/검증 + const parsed = updateVendorInvestigationResultSchema.parse(processedEntries) + + // 4) 업데이트 데이터 준비 + const updateData: any = { + updatedAt: new Date(), + } + + // 선택적 필드들은 존재할 때만 추가 + 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 + } + + // 평가 결과에 따라 상태 자동 변경 + if (parsed.evaluationResult) { + if (parsed.evaluationResult === "REJECTED") { + updateData.investigationStatus = "CANCELED" + } else if (parsed.evaluationResult === "SUPPLEMENT" || + parsed.evaluationResult === "SUPPLEMENT_REINSPECT" || + parsed.evaluationResult === "SUPPLEMENT_DOCUMENT") { + updateData.investigationStatus = "SUPPLEMENT_REQUIRED" + } else if (parsed.evaluationResult === "APPROVED") { + updateData.investigationStatus = "COMPLETED" + } + } + + // 5) vendor_investigations 테이블 업데이트 + await db + .update(vendorInvestigations) + .set(updateData) + .where(eq(vendorInvestigations.id, parsed.investigationId)) + /* + 현재 보완 프로세스는 자동으로 처리됨. 만약 dialog 필요하면 아래 서버액션 분기 필요.(1029/최겸) + */ + // 5-1) 보완 프로세스 자동 처리 (TO-BE) + if (parsed.evaluationResult === "SUPPLEMENT_REINSPECT" || parsed.evaluationResult === "SUPPLEMENT_DOCUMENT") { + // 실사 방법 확인 + const investigation = await db + .select({ + investigationMethod: vendorInvestigations.investigationMethod, + }) + .from(vendorInvestigations) + .where(eq(vendorInvestigations.id, parsed.investigationId)) + .then(rows => rows[0]); + + if (investigation?.investigationMethod === "PRODUCT_INSPECTION" || investigation?.investigationMethod === "SITE_VISIT_EVAL") { + if (parsed.evaluationResult === "SUPPLEMENT_REINSPECT") { + // 보완-재실사 요청 자동 생성 + await requestSupplementReinspectionAction({ + investigationId: parsed.investigationId, + siteVisitData: { + inspectionDuration: 1.0, // 기본 1일 + additionalRequests: "보완을 위한 재실사 요청입니다.", + } + }); + } else if (parsed.evaluationResult === "SUPPLEMENT_DOCUMENT") { + // 보완-서류제출 요청 자동 생성 + await requestSupplementDocumentAction({ + investigationId: parsed.investigationId, + documentRequests: { + requiredDocuments: ["보완 서류"], + additionalRequests: "보완을 위한 서류 제출 요청입니다.", + } + }); + } + } + } + + // 6) 캐시 무효화 + revalidateTag("vendor-investigations") + revalidatePath("/evcp/vendor-investigation") + + return { success: true } + } catch (error) { + console.error("실사 결과 업데이트 오류:", error) + return { + success: false, + error: error instanceof Error ? error.message : "알 수 없는 오류" + } + } +} + +// 기존 함수 (호환성을 위해 유지) export async function updateVendorInvestigationAction(formData: FormData) { try { // 1) 텍스트 필드만 추출 @@ -300,12 +506,51 @@ export async function updateVendorInvestigationAction(formData: FormData) { updateData.investigationStatus = "IN_PROGRESS"; } + // 보완 프로세스 분기 로직 (TO-BE) + if (parsed.evaluationResult === "SUPPLEMENT_REINSPECT" || parsed.evaluationResult === "SUPPLEMENT_DOCUMENT") { + updateData.investigationStatus = "SUPPLEMENT_REQUIRED"; + } + // 5) vendor_investigations 테이블 업데이트 await db .update(vendorInvestigations) .set(updateData) .where(eq(vendorInvestigations.id, parsed.investigationId)) + // 5-1) 보완 프로세스 자동 처리 (TO-BE) + if (parsed.evaluationResult === "SUPPLEMENT_REINSPECT" || parsed.evaluationResult === "SUPPLEMENT_DOCUMENT") { + // 실사 방법 확인 + const investigation = await db + .select({ + investigationMethod: vendorInvestigations.investigationMethod, + }) + .from(vendorInvestigations) + .where(eq(vendorInvestigations.id, parsed.investigationId)) + .then(rows => rows[0]); + + if (investigation?.investigationMethod === "PRODUCT_INSPECTION" || investigation?.investigationMethod === "SITE_VISIT_EVAL") { + if (parsed.evaluationResult === "SUPPLEMENT_REINSPECT") { + // 보완-재실사 요청 자동 생성 + await requestSupplementReinspectionAction({ + investigationId: parsed.investigationId, + siteVisitData: { + inspectionDuration: 1.0, // 기본 1일 + additionalRequests: "보완을 위한 재실사 요청입니다.", + } + }); + } else if (parsed.evaluationResult === "SUPPLEMENT_DOCUMENT") { + // 보완-서류제출 요청 자동 생성 + await requestSupplementDocumentAction({ + investigationId: parsed.investigationId, + documentRequests: { + requiredDocuments: ["보완 서류"], + additionalRequests: "보완을 위한 서류 제출 요청입니다.", + } + }); + } + } + } + // 6) 캐시 무효화 revalidateTag("vendor-investigations") revalidateTag("pq-submissions") @@ -693,4 +938,267 @@ export async function createVendorInvestigationAttachmentAction(input: { error: error instanceof Error ? error.message : "알 수 없는 오류", }; } -}
\ No newline at end of file +} + +// 보완-재실사 요청 액션 +export async function requestSupplementReinspectionAction({ + investigationId, + siteVisitData +}: { + investigationId: number; + siteVisitData: { + inspectionDuration?: number; + requestedStartDate?: Date; + requestedEndDate?: Date; + shiAttendees?: any; + vendorRequests?: any; + additionalRequests?: string; + }; +}) { + try { + // 1. 실사 상태를 SUPPLEMENT_REQUIRED로 변경 + await db + .update(vendorInvestigations) + .set({ + investigationStatus: "SUPPLEMENT_REQUIRED", + updatedAt: new Date(), + }) + .where(eq(vendorInvestigations.id, investigationId)); + + // 2. 새로운 방문실사 요청 생성 + const [newSiteVisitRequest] = await db + .insert(siteVisitRequests) + .values({ + investigationId: investigationId, + inspectionDuration: siteVisitData.inspectionDuration, + requestedStartDate: siteVisitData.requestedStartDate, + requestedEndDate: siteVisitData.requestedEndDate, + shiAttendees: siteVisitData.shiAttendees || {}, + vendorRequests: siteVisitData.vendorRequests || {}, + additionalRequests: siteVisitData.additionalRequests, + status: "REQUESTED", + }) + .returning(); + + // 3. 캐시 무효화 + revalidateTag("vendor-investigations"); + revalidateTag("site-visit-requests"); + + return { success: true, siteVisitRequestId: newSiteVisitRequest.id }; + } catch (error) { + console.error("보완-재실사 요청 실패:", error); + return { + success: false, + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } +} + +// 보완-서류제출 요청 액션 +export async function requestSupplementDocumentAction({ + investigationId, + documentRequests +}: { + investigationId: number; + documentRequests: { + requiredDocuments: string[]; + additionalRequests?: string; + }; +}) { + try { + // 1. 실사 상태를 SUPPLEMENT_REQUIRED로 변경 + await db + .update(vendorInvestigations) + .set({ + investigationStatus: "SUPPLEMENT_REQUIRED", + updatedAt: new Date(), + }) + .where(eq(vendorInvestigations.id, investigationId)); + + // 2. 서류제출 요청을 위한 방문실사 요청 생성 (서류제출용) + const [newSiteVisitRequest] = await db + .insert(siteVisitRequests) + .values({ + investigationId: investigationId, + inspectionDuration: 0, // 서류제출은 방문 시간 0 + shiAttendees: {}, // 서류제출은 참석자 없음 + vendorRequests: { + requiredDocuments: documentRequests.requiredDocuments, + documentSubmissionOnly: true, // 서류제출 전용 플래그 + }, + additionalRequests: documentRequests.additionalRequests, + status: "REQUESTED", + }) + .returning(); + + // 3. 캐시 무효화 + revalidateTag("vendor-investigations"); + revalidateTag("site-visit-requests"); + + return { success: true, siteVisitRequestId: newSiteVisitRequest.id }; + } catch (error) { + console.error("보완-서류제출 요청 실패:", error); + return { + success: false, + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } +} + +// 보완 서류 제출 완료 액션 (벤더가 서류 제출 완료) +export async function completeSupplementDocumentAction({ + investigationId, + siteVisitRequestId, + submittedBy +}: { + investigationId: number; + siteVisitRequestId: number; + submittedBy: number; +}) { + try { + // 1. 방문실사 요청 상태를 COMPLETED로 변경 + await db + .update(siteVisitRequests) + .set({ + status: "COMPLETED", + sentAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(siteVisitRequests.id, siteVisitRequestId)); + + // 2. 실사 상태를 IN_PROGRESS로 변경 (재검토 대기) + await db + .update(vendorInvestigations) + .set({ + investigationStatus: "IN_PROGRESS", + updatedAt: new Date(), + }) + .where(eq(vendorInvestigations.id, investigationId)); + + // 3. 캐시 무효화 + revalidateTag("vendor-investigations"); + revalidateTag("site-visit-requests"); + + return { success: true }; + } catch (error) { + console.error("보완 서류 제출 완료 처리 실패:", error); + return { + success: false, + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } +} + +// 보완 재실사 완료 액션 (재실사 완료 후) +export async function completeSupplementReinspectionAction({ + investigationId, + siteVisitRequestId, + evaluationResult, + evaluationScore, + investigationNotes +}: { + investigationId: number; + siteVisitRequestId: number; + evaluationResult: "APPROVED" | "SUPPLEMENT" | "REJECTED"; + evaluationScore?: number; + investigationNotes?: string; +}) { + try { + // 1. 방문실사 요청 상태를 COMPLETED로 변경 + await db + .update(siteVisitRequests) + .set({ + status: "COMPLETED", + sentAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(siteVisitRequests.id, siteVisitRequestId)); + + // 2. 실사 상태 및 평가 결과 업데이트 + const updateData: any = { + investigationStatus: evaluationResult === "APPROVED" ? "COMPLETED" : "SUPPLEMENT_REQUIRED", + evaluationResult: evaluationResult, + updatedAt: new Date(), + }; + + if (evaluationScore !== undefined) { + updateData.evaluationScore = evaluationScore; + } + if (investigationNotes) { + updateData.investigationNotes = investigationNotes; + } + if (evaluationResult === "COMPLETED") { + updateData.completedAt = new Date(); + } + + await db + .update(vendorInvestigations) + .set(updateData) + .where(eq(vendorInvestigations.id, investigationId)); + + // 3. 캐시 무효화 + revalidateTag("vendor-investigations"); + revalidateTag("site-visit-requests"); + + return { success: true }; + } catch (error) { + console.error("보완 재실사 완료 처리 실패:", error); + return { + success: false, + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } +} + +// 보완 서류제출 응답 제출 액션 +export async function submitSupplementDocumentResponseAction({ + investigationId, + responseData +}: { + investigationId: number + responseData: { + responseText: string + attachments: Array<{ + fileName: string + url: string + size?: number + }> + } +}) { + try { + // 1. 실사 상태를 SUPPLEMENT_REQUIRED로 변경 + await db + .update(vendorInvestigations) + .set({ + investigationStatus: "SUPPLEMENT_REQUIRED", + investigationNotes: responseData.responseText, + updatedAt: new Date(), + }) + .where(eq(vendorInvestigations.id, investigationId)); + + // 2. 첨부 파일 저장 + if (responseData.attachments.length > 0) { + const attachmentData = responseData.attachments.map(attachment => ({ + investigationId, + fileName: attachment.fileName, + filePath: attachment.url, + fileSize: attachment.size || 0, + uploadedAt: new Date(), + })); + + await db.insert(vendorInvestigationAttachments).values(attachmentData); + } + + // 3. 캐시 무효화 + revalidateTag("vendor-investigations"); + revalidateTag("vendor-investigation-attachments"); + + return { success: true }; + } catch (error) { + console.error("보완 서류제출 응답 처리 실패:", error); + return { + success: false, + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } +} diff --git a/lib/vendor-investigation/table/investigation-progress-sheet.tsx b/lib/vendor-investigation/table/investigation-progress-sheet.tsx new file mode 100644 index 00000000..c0357f5c --- /dev/null +++ b/lib/vendor-investigation/table/investigation-progress-sheet.tsx @@ -0,0 +1,324 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { CalendarIcon, Loader } from "lucide-react" +import { format } from "date-fns" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Textarea } from "@/components/ui/textarea" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Calendar } from "@/components/ui/calendar" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" + +import { + updateVendorInvestigationProgressSchema, + type UpdateVendorInvestigationProgressSchema, +} from "../validations" +import { updateVendorInvestigationProgressAction } from "../service" +import { VendorInvestigationsViewWithContacts } from "@/config/vendorInvestigationsColumnsConfig" + +interface InvestigationProgressSheetProps + extends React.ComponentPropsWithoutRef<typeof Sheet> { + investigation: VendorInvestigationsViewWithContacts | null +} + +/** + * 실사 진행 관리 시트 + */ +export function InvestigationProgressSheet({ + investigation, + ...props +}: InvestigationProgressSheetProps) { + const [isPending, startTransition] = React.useTransition() + + // RHF + Zod + const form = useForm<UpdateVendorInvestigationProgressSchema>({ + resolver: zodResolver(updateVendorInvestigationProgressSchema), + defaultValues: { + investigationId: investigation?.investigationId ?? 0, + investigationAddress: investigation?.investigationAddress ?? "", + investigationMethod: investigation?.investigationMethod ?? undefined, + forecastedAt: investigation?.forecastedAt ?? undefined, + confirmedAt: investigation?.confirmedAt ?? undefined, + }, + }) + + // investigation이 변경될 때마다 폼 리셋 + React.useEffect(() => { + if (investigation) { + form.reset({ + investigationId: investigation.investigationId, + investigationAddress: investigation.investigationAddress ?? "", + investigationMethod: investigation.investigationMethod ?? undefined, + forecastedAt: investigation.forecastedAt ?? undefined, + confirmedAt: investigation.confirmedAt ?? undefined, + }) + } + }, [investigation, form]) + + // Submit handler + async function onSubmit(values: UpdateVendorInvestigationProgressSchema) { + console.log("실사 진행 관리 onSubmit 호출됨:", values) + + if (!values.investigationId) { + console.log("investigationId가 없음:", values.investigationId) + return + } + + startTransition(async () => { + try { + console.log("실사 진행 관리 startTransition 시작") + + // FormData 생성 + const formData = new FormData() + + // 필수 필드 + formData.append("investigationId", String(values.investigationId)) + + // 선택적 필드들 + 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.confirmedAt) { + formData.append("confirmedAt", values.confirmedAt.toISOString()) + } + + // 실사 진행 관리 업데이트 (PLANNED -> IN_PROGRESS) + const { error } = await updateVendorInvestigationProgressAction(formData) + + if (error) { + toast.error(error) + return + } + + toast.success("실사 진행 정보가 업데이트되었습니다!") + form.reset() + props.onOpenChange?.(false) + + } catch (error) { + console.error("실사 진행 관리 업데이트 오류:", error) + toast.error("실사 진행 관리 업데이트 중 오류가 발생했습니다.") + } + }) + } + + // 디버깅을 위한 버튼 클릭 핸들러 + const handleSaveClick = async () => { + console.log("실사 진행 관리 저장 버튼 클릭됨") + console.log("현재 폼 값:", form.getValues()) + console.log("폼 에러:", form.formState.errors) + + // 폼 검증 실행 + const isValid = await form.trigger() + console.log("폼 검증 결과:", isValid) + + if (isValid) { + form.handleSubmit(onSubmit)() + } else { + console.log("폼 검증 실패, 에러:", form.formState.errors) + } + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col h-full sm:max-w-xl" > + <SheetHeader className="text-left flex-shrink-0"> + <SheetTitle>실사 진행 관리</SheetTitle> + <SheetDescription> + {investigation?.vendorName && ( + <span className="font-medium">{investigation.vendorName}</span> + )}의 실사 진행 정보를 관리합니다. + </SheetDescription> + </SheetHeader> + + <div className="flex-1 overflow-y-auto py-4"> + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="flex flex-col gap-4" + id="investigation-progress-form" + > + {/* 실사 주소 */} + <FormField + control={form.control} + name="investigationAddress" + render={({ field }) => ( + <FormItem> + <FormLabel>실사 주소</FormLabel> + <FormControl> + <Textarea + placeholder="실사가 진행될 주소를 입력하세요..." + {...field} + className="min-h-[60px]" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 실사 방법 */} + <FormField + control={form.control} + name="investigationMethod" + render={({ field }) => ( + <FormItem> + <FormLabel>실사 방법</FormLabel> + <FormControl> + <Select value={field.value || ""} onValueChange={field.onChange}> + <SelectTrigger> + <SelectValue placeholder="실사 방법을 선택하세요" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + <SelectItem value="PURCHASE_SELF_EVAL">구매자체평가</SelectItem> + <SelectItem value="DOCUMENT_EVAL">서류평가</SelectItem> + <SelectItem value="PRODUCT_INSPECTION">제품검사평가</SelectItem> + <SelectItem value="SITE_VISIT_EVAL">방문실사평가</SelectItem> + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 실사 수행 예정일 */} + <FormField + control={form.control} + name="forecastedAt" + 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="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> + )} + /> + </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}> + 취소 + </Button> + </SheetClose> + <Button + disabled={isPending} + onClick={handleSaveClick} + > + {isPending && ( + <Loader className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" /> + )} + {isPending ? "저장 중..." : "저장"} + </Button> + </SheetFooter> + </SheetContent> + </Sheet> + ) +} diff --git a/lib/vendor-investigation/table/investigation-result-sheet.tsx b/lib/vendor-investigation/table/investigation-result-sheet.tsx new file mode 100644 index 00000000..b7577daa --- /dev/null +++ b/lib/vendor-investigation/table/investigation-result-sheet.tsx @@ -0,0 +1,808 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { CalendarIcon, Loader, X, Download } from "lucide-react" +import { format } from "date-fns" +import { toast } from "sonner" +import { updateVendorInvestigationResultAction } from "../service" +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + 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 { + FileList, + FileListAction, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, + FileListSize, +} from "@/components/ui/file-list" + +import { + updateVendorInvestigationResultSchema, + type UpdateVendorInvestigationResultSchema, +} from "../validations" +import { updateVendorInvestigationAction, getInvestigationAttachments, deleteInvestigationAttachment, createVendorInvestigationAttachmentAction } from "../service" +import { VendorInvestigationsViewWithContacts } from "@/config/vendorInvestigationsColumnsConfig" +import prettyBytes from "pretty-bytes" +import { downloadFile } from "@/lib/file-download" + +interface InvestigationResultSheetProps extends React.ComponentPropsWithoutRef<typeof Sheet> { + investigation: VendorInvestigationsViewWithContacts | null +} + +// 첨부파일 정책 정의 +const getFileUploadConfig = (status: string) => { + // 취소된 상태에서만 파일 업로드 비활성화 + if (status === "CANCELED") { + return { + enabled: false, + label: "", + description: "", + accept: 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" + } +} + +/** + * 실사 결과 입력 시트 + */ +export function InvestigationResultSheet({ + investigation, + ...props +}: InvestigationResultSheetProps) { + 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<UpdateVendorInvestigationResultSchema>({ + resolver: zodResolver(updateVendorInvestigationResultSchema), + defaultValues: { + investigationId: investigation?.investigationId ?? 0, + 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, + completedAt: investigation.completedAt ?? undefined, + evaluationScore: investigation.evaluationScore ?? undefined, + evaluationResult: investigation.evaluationResult ?? undefined, + investigationNotes: investigation.investigationNotes ?? "", + attachments: undefined, + }) + + // 기존 첨부파일 로드 + loadExistingAttachments(investigation.investigationId) + } + }, [investigation, form]) + + // 기존 첨부파일 로드 함수 + 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) + } + } + + // 첨부파일 삭제 함수 + const handleDeleteAttachment = async (attachmentId: number) => { + if (!investigation) return + + try { + await deleteInvestigationAttachment(attachmentId) + toast.success("첨부파일이 삭제되었습니다.") + // 목록 새로고침 + loadExistingAttachments(investigation.investigationId) + + } catch (error) { + console.error("첨부파일 삭제 오류:", error) + toast.error(error instanceof Error ? error.message : "첨부파일 삭제 중 오류가 발생했습니다.") + } + } + + // 첨부파일 다운로드 함수 + const handleDownloadAttachment = async (attachment: any) => { + if (!attachment.filePath || !attachment.fileName) { + toast.error("첨부파일 정보가 올바르지 않습니다.") + return + } + + try { + await downloadFile(attachment.filePath, attachment.fileName, { + showToast: true, + action: 'download' + }) + } catch (error) { + console.error("첨부파일 다운로드 오류:", error) + toast.error("첨부파일 다운로드 중 오류가 발생했습니다.") + } + } + + // 선택된 파일에서 특정 파일 제거 + const handleRemoveSelectedFile = (indexToRemove: number) => { + const currentFiles = form.getValues("attachments") || [] + const updatedFiles = currentFiles.filter((_: File, index: number) => index !== indexToRemove) + form.setValue("attachments", updatedFiles.length > 0 ? updatedFiles : undefined) + + if (updatedFiles.length === 0) { + toast.success("모든 선택된 파일이 제거되었습니다.") + } else { + toast.success("파일이 제거되었습니다.") + } + } + + // 파일 업로드 섹션 렌더링 + const renderFileUploadSection = () => { + const currentStatus = form.watch("investigationStatus") + const selectedFiles = form.watch("attachments") as File[] | undefined + const config = getFileUploadConfig(currentStatus) + + if (!config.enabled) return null + + 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> + <div className="flex items-center gap-1"> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => handleDownloadAttachment(attachment)} + className="text-blue-600 hover:text-blue-700" + disabled={isPending} + title="파일 다운로드" + > + <Download className="h-4 w-4" /> + </Button> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => handleDeleteAttachment(attachment.id)} + className="text-destructive hover:text-destructive" + disabled={isPending} + title="파일 삭제" + > + <X className="h-4 w-4" /> + </Button> + </div> + </div> + )) + ) : ( + <div className="text-sm text-muted-foreground text-center py-2"> + 첨부된 파일이 없습니다. + </div> + )} + </div> + </div> + )} + + {/* 새 파일 업로드 */} + <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) { + // 기존 파일들과 새로 선택된 파일들을 합치기 + const currentFiles = form.getValues("attachments") || [] + const newFiles = [...currentFiles, ...acceptedFiles] + onChange(newFiles) + 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> + )} + /> + + {/* 선택된 파일 목록 */} + {selectedFiles && selectedFiles.length > 0 && ( + <div className="space-y-2"> + {/* <FormLabel>선택된 파일 ({selectedFiles.length}개)</FormLabel> */} + <FileList> + <FileListHeader> + <span className="text-sm font-medium">업로드 예정 파일 ({selectedFiles.length}개)</span> + </FileListHeader> + {selectedFiles.map((file, index) => ( + <FileListItem + key={`${file.name}-${index}`} + className="flex items-center justify-between gap-2 px-2 py-2" + > + {/* 왼쪽 아이콘 */} + <FileListIcon className="shrink-0 h-4 w-4 text-muted-foreground" /> + + {/* 가운데 이름 + 사이즈 */} + <FileListInfo className="flex-1 min-w-0"> + <FileListName className="truncate">{file.name}</FileListName> + <FileListSize className="text-xs text-muted-foreground shrink-0"> + {file.size} + </FileListSize> + </FileListInfo> + + {/* 오른쪽 삭제 버튼 */} + <FileListAction className="shrink-0"> + <Button + type="button" + variant="ghost" + size="icon" + onClick={() => handleRemoveSelectedFile(index)} + disabled={isPending || uploadingFiles} + className="h-5 w-5 text-destructive hover:text-destructive" + > + <X className="h-4 w-4" /> + </Button> + </FileListAction> + </FileListItem> + + ))} + </FileList> + </div> + )} + </> + ) + } + + // 파일 업로드 함수 + const uploadFiles = async (files: File[], investigationId: number) => { + const uploadPromises = files.map(async (file) => { + try { + // 서버 액션을 호출하여 파일 저장 및 DB 레코드 생성 + const result = await createVendorInvestigationAttachmentAction({ + investigationId, + file, + userId: undefined // 필요시 사용자 ID 추가 + }); + + if (!result.success) { + throw new Error(result.error || "파일 업로드 실패"); + } + + return result.attachment; + } catch (error) { + console.error(`파일 업로드 실패: ${file.name}`, error); + throw error; + } + }); + + return await Promise.all(uploadPromises); + } + + // Submit handler + async function onSubmit(values: UpdateVendorInvestigationResultSchema) { + console.log("실사 결과 입력 onSubmit 호출됨:", values) + + if (!values.investigationId) { + console.log("investigationId가 없음:", values.investigationId) + return + } + + startTransition(async () => { + try { + console.log("실사 결과 입력 startTransition 시작") + + // 1) 먼저 텍스트 데이터 업데이트 + const formData = new FormData() + + // 필수 필드 + formData.append("investigationId", String(values.investigationId)) + + // 선택적 필드들 + 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) + } + + // 텍스트 데이터 업데이트 (IN_PROGRESS -> COMPLETED/CANCELED/SUPPLEMENT_REQUIRED) + const { error } = await updateVendorInvestigationResultAction(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) { + console.error("파일 업로드 에러:", fileError) + toast.error(`데이터는 저장되었지만 파일 업로드 중 오류가 발생했습니다: ${fileError}`) + } finally { + setUploadingFiles(false) + } + } else { + toast.success("실사 결과가 업데이트되었습니다!") + } + + form.reset() + props.onOpenChange?.(false) + + } catch (error) { + console.error("실사 결과 업데이트 오류:", error) + toast.error("실사 결과 업데이트 중 오류가 발생했습니다.") + } + }) + } + + // 디버깅을 위한 버튼 클릭 핸들러 + const handleSaveClick = async () => { + console.log("저장 버튼 클릭됨") + console.log("현재 폼 값:", form.getValues()) + console.log("폼 에러:", form.formState.errors) + + // 폼 검증 실행 + const isValid = await form.trigger() + console.log("폼 검증 결과:", isValid) + + if (isValid) { + form.handleSubmit(onSubmit)() + } else { + console.log("폼 검증 실패, 에러:", form.formState.errors) + } + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col h-full sm:max-w-xl" > + <SheetHeader className="text-left flex-shrink-0"> + <SheetTitle>실사 결과 입력</SheetTitle> + <SheetDescription> + {investigation?.vendorName && ( + <span className="font-medium">{investigation.vendorName}</span> + )}의 실사 결과를 입력합니다. + </SheetDescription> + </SheetHeader> + + <div className="flex-1 overflow-y-auto py-4"> + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="flex flex-col gap-4" + id="update-investigation-form" + > + {/* 실사 상태 - 주석처리 (실사 결과 입력에서는 자동으로 완료됨/취소됨/보완요구됨으로 변경) */} + {/* <FormField + control={form.control} + name="investigationStatus" + render={({ field }) => ( + <FormItem> + <FormLabel>실사 상태</FormLabel> + <FormControl> + <Select value={field.value} onValueChange={field.onChange}> + <SelectTrigger> + <SelectValue placeholder="상태를 선택하세요" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + <SelectItem value="PLANNED">계획됨</SelectItem> + <SelectItem value="IN_PROGRESS">진행 중</SelectItem> + <SelectItem value="COMPLETED">완료됨</SelectItem> + <SelectItem value="CANCELED">취소됨</SelectItem> + <SelectItem value="SUPPLEMENT_REQUIRED">보완 요구됨</SelectItem> + <SelectItem value="RESULT_SENT">실사결과발송</SelectItem> + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> */} + + {/* 실사 주소 - 주석처리 (실사 진행 관리에서 처리) */} + {/* <FormField + control={form.control} + name="investigationAddress" + render={({ field }) => ( + <FormItem> + <FormLabel>실사 주소</FormLabel> + <FormControl> + <Textarea + placeholder="실사가 진행될 주소를 입력하세요..." + {...field} + className="min-h-[60px]" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> */} + + {/* 실사 방법 - 주석처리 (실사 진행 관리에서 처리) */} + {/* <FormField + control={form.control} + name="investigationMethod" + render={({ field }) => ( + <FormItem> + <FormLabel>실사 방법</FormLabel> + <FormControl> + <Select value={field.value || ""} onValueChange={field.onChange}> + <SelectTrigger> + <SelectValue placeholder="실사 방법을 선택하세요" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + <SelectItem value="PURCHASE_SELF_EVAL">구매자체평가</SelectItem> + <SelectItem value="DOCUMENT_EVAL">서류평가</SelectItem> + <SelectItem value="PRODUCT_INSPECTION">제품검사평가</SelectItem> + <SelectItem value="SITE_VISIT_EVAL">방문실사평가</SelectItem> + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> */} + + {/* 실사 수행 예정일 - 주석처리 (실사 진행 관리에서 처리) */} + {/* <FormField + control={form.control} + name="forecastedAt" + 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="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> + )} + /> + + {/* 평가 점수 */} + <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> + )} + /> + + {/* 평가 결과 */} + <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="SUPPLEMENT_REINSPECT">보완-재실사</SelectItem> + <SelectItem value="SUPPLEMENT_DOCUMENT">보완-서류제출</SelectItem> + <SelectItem value="REJECTED">불가</SelectItem> + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* QM 의견 */} + <FormField + control={form.control} + name="investigationNotes" + render={({ field }) => ( + <FormItem> + <FormLabel>QM 의견</FormLabel> + <FormControl> + <Textarea + placeholder="실사에 대한 QM 의견을 입력하세요..." + {...field} + className="min-h-[80px]" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 파일 첨부 섹션 */} + {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={handleSaveClick} + > + {(isPending || uploadingFiles) && ( + <Loader className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" /> + )} + {uploadingFiles ? "업로드 중..." : isPending ? "저장 중..." : "저장"} + </Button> + </SheetFooter> + </SheetContent> + </Sheet> + ) +}
\ 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 b5344a1e..28ecc2ec 100644 --- a/lib/vendor-investigation/table/investigation-table-columns.tsx +++ b/lib/vendor-investigation/table/investigation-table-columns.tsx @@ -5,7 +5,14 @@ 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 { Edit, Ellipsis } from "lucide-react" +import { Edit, Ellipsis, AlertTriangle } from "lucide-react" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" import { formatDate } from "@/lib/utils" @@ -24,6 +31,7 @@ interface GetVendorInvestigationsColumnsProps { > > openVendorDetailsModal?: (vendorId: number) => void + openSupplementRequestDialog?: (investigationId: number, investigationMethod: string, vendorName: string) => void } // Helper function for investigation method variants @@ -45,6 +53,7 @@ function getMethodVariant(method: string): "default" | "secondary" | "outline" | export function getColumns({ setRowAction, openVendorDetailsModal, + openSupplementRequestDialog, }: GetVendorInvestigationsColumnsProps): ColumnDef< VendorInvestigationsViewWithContacts >[] { @@ -86,20 +95,69 @@ export function getColumns({ cell: ({ row }) => { const isCanceled = row.original.investigationStatus === "CANCELED" const isCompleted = row.original.investigationStatus === "COMPLETED" + const canRequestSupplement = (row.original.investigationMethod === "PRODUCT_INSPECTION" || + row.original.investigationMethod === "SITE_VISIT_EVAL") && + row.original.investigationStatus === "COMPLETED" && + (row.original.evaluationResult === "SUPPLEMENT" || + row.original.evaluationResult === "SUPPLEMENT_REINSPECT" || + row.original.evaluationResult === "SUPPLEMENT_DOCUMENT") + return ( - <Button - variant="ghost" - className="flex size-8 p-0 data-[state=open]:bg-muted" - aria-label="실사 정보 수정" - disabled={isCanceled} - onClick={() => { - if (!isCanceled || !isCompleted) { - setRowAction?.({ type: "update", row }) - } - }} - > - <Edit className="size-4" aria-hidden="true" /> - </Button> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-48"> + <DropdownMenuItem + onSelect={() => { + if (!isCanceled && row.original.investigationStatus === "PLANNED") { + setRowAction?.({ type: "update-progress", row }) + } + }} + disabled={isCanceled || row.original.investigationStatus !== "PLANNED"} + > + <Edit className="mr-2 h-4 w-4" /> + 실사 진행 관리 + </DropdownMenuItem> + + <DropdownMenuItem + onSelect={() => { + if (!isCanceled && row.original.investigationStatus === "IN_PROGRESS") { + setRowAction?.({ type: "update-result", row }) + } + }} + disabled={isCanceled || row.original.investigationStatus !== "IN_PROGRESS"} + > + <Edit className="mr-2 h-4 w-4" /> + 실사 결과 입력 + </DropdownMenuItem> + + {canRequestSupplement && ( + <> + <DropdownMenuSeparator /> + <DropdownMenuItem + onSelect={() => { + openSupplementRequestDialog?.( + row.original.investigationId, + row.original.investigationMethod || "", + row.original.vendorName + ) + }} + className="text-amber-600 focus:text-amber-600" + > + <AlertTriangle className="mr-2 h-4 w-4" /> + 보완 요청 + </DropdownMenuItem> + </> + )} + </DropdownMenuContent> + </DropdownMenu> ) }, size: 40, @@ -256,9 +314,9 @@ export function getColumns({ return ( <div className="flex flex-col"> <span>{value || "미배정"}</span> - {row.original.requesterEmail && ( + {row.original.requesterEmail ? ( <span className="text-xs text-muted-foreground">{row.original.requesterEmail}</span> - )} + ) : null} </div> ) } @@ -271,9 +329,9 @@ export function getColumns({ return ( <div className="flex flex-col"> <span>{value || "미배정"}</span> - {row.original.qmManagerEmail && ( + {row.original.qmManagerEmail ? ( <span className="text-xs text-muted-foreground">{row.original.qmManagerEmail}</span> - )} + ) : null} </div> ) } @@ -298,7 +356,7 @@ export function getColumns({ } else { nestedColumns.push({ id: groupName, - header: groupName, + header: groupName as any, columns: colDefs, }) } @@ -325,6 +383,8 @@ function formatStatus(status: string): string { return "완료됨" case "CANCELED": return "취소됨" + case "SUPPLEMENT_REQUIRED": + return "보완 요구됨" case "RESULT_SENT": return "실사결과발송" default: @@ -349,6 +409,10 @@ function formatEnumValue(value: string): string { return "승인" case "SUPPLEMENT": return "보완" + case "SUPPLEMENT_REINSPECT": + return "보완-재실사" + case "SUPPLEMENT_DOCUMENT": + return "보완-서류제출" case "REJECTED": return "불가" @@ -367,6 +431,10 @@ function getStatusVariant(status: string): "default" | "secondary" | "outline" | return "outline" case "CANCELED": return "destructive" + case "SUPPLEMENT_REQUIRED": + return "secondary" + case "RESULT_SENT": + return "default" default: return "default" } @@ -380,6 +448,10 @@ function getResultVariant(result: string): "default" | "secondary" | "outline" | return "default" case "SUPPLEMENT": return "secondary" + case "SUPPLEMENT_REINSPECT": + return "secondary" + case "SUPPLEMENT_DOCUMENT": + return "secondary" case "REJECTED": return "destructive" default: diff --git a/lib/vendor-investigation/table/investigation-table.tsx b/lib/vendor-investigation/table/investigation-table.tsx index b7663629..ee122f04 100644 --- a/lib/vendor-investigation/table/investigation-table.tsx +++ b/lib/vendor-investigation/table/investigation-table.tsx @@ -16,8 +16,10 @@ import { getColumns } from "./investigation-table-columns" import { getVendorsInvestigation } from "../service" import { VendorsTableToolbarActions } from "./investigation-table-toolbar-actions" import { VendorInvestigationsViewWithContacts } from "@/config/vendorInvestigationsColumnsConfig" -import { UpdateVendorInvestigationSheet } from "./update-investigation-sheet" +import { InvestigationResultSheet } from "./investigation-result-sheet" +import { InvestigationProgressSheet } from "./investigation-progress-sheet" import { VendorDetailsDialog } from "./vendor-details-dialog" +import { SupplementRequestDialog } from "@/components/investigation/supplement-request-dialog" interface VendorsTableProps { promises: Promise< @@ -54,12 +56,34 @@ export function VendorsInvestigationTable({ promises }: VendorsTableProps) { const [vendorDetailsOpen, setVendorDetailsOpen] = React.useState(false) const [selectedVendorId, setSelectedVendorId] = React.useState<number | null>(null) + // Add state for supplement request dialog + const [supplementRequestOpen, setSupplementRequestOpen] = React.useState(false) + const [supplementRequestData, setSupplementRequestData] = React.useState<{ + investigationId: number + investigationMethod: string + vendorName: string + } | null>(null) + // Create handler for opening vendor details modal const openVendorDetailsModal = React.useCallback((vendorId: number) => { setSelectedVendorId(vendorId) setVendorDetailsOpen(true) }, []) + // Create handler for opening supplement request dialog + const openSupplementRequestDialog = React.useCallback(( + investigationId: number, + investigationMethod: string, + vendorName: string + ) => { + setSupplementRequestData({ + investigationId, + investigationMethod, + vendorName + }) + setSupplementRequestOpen(true) + }, []) + // Get router const router = useRouter() @@ -67,9 +91,10 @@ export function VendorsInvestigationTable({ promises }: VendorsTableProps) { const columns = React.useMemo( () => getColumns({ setRowAction, - openVendorDetailsModal + openVendorDetailsModal, + openSupplementRequestDialog }), - [setRowAction, openVendorDetailsModal] + [setRowAction, openVendorDetailsModal, openSupplementRequestDialog] ) // 기본 필터 필드들 @@ -174,9 +199,15 @@ export function VendorsInvestigationTable({ promises }: VendorsTableProps) { </DataTableAdvancedToolbar> </DataTable> - {/* Update Investigation Sheet */} - <UpdateVendorInvestigationSheet - open={rowAction?.type === "update"} + {/* Update Investigation Sheets */} + <InvestigationProgressSheet + open={rowAction?.type === "update-progress"} + onOpenChange={() => setRowAction(null)} + investigation={rowAction?.row.original ?? null} + /> + + <InvestigationResultSheet + open={rowAction?.type === "update-result"} onOpenChange={() => setRowAction(null)} investigation={rowAction?.row.original ?? null} /> @@ -187,6 +218,14 @@ export function VendorsInvestigationTable({ promises }: VendorsTableProps) { onOpenChange={setVendorDetailsOpen} vendorId={selectedVendorId} /> + + <SupplementRequestDialog + open={supplementRequestOpen} + onOpenChange={setSupplementRequestOpen} + investigationId={supplementRequestData?.investigationId || 0} + investigationMethod={supplementRequestData?.investigationMethod || ""} + vendorName={supplementRequestData?.vendorName || ""} + /> </> ) }
\ 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 9f7c8994..7daa9d44 100644 --- a/lib/vendor-investigation/table/update-investigation-sheet.tsx +++ b/lib/vendor-investigation/table/update-investigation-sheet.tsx @@ -107,7 +107,7 @@ const getFileUploadConfig = (status: string) => { } /** - * 실사 정보 수정 시트 + * 실사 결과 입력 시트 */ export function UpdateVendorInvestigationSheet({ investigation, @@ -539,11 +539,11 @@ export function UpdateVendorInvestigationSheet({ <Sheet {...props}> <SheetContent className="flex flex-col h-full sm:max-w-xl" > <SheetHeader className="text-left flex-shrink-0"> - <SheetTitle>실사 업데이트</SheetTitle> + <SheetTitle>실사 결과 입력</SheetTitle> <SheetDescription> {investigation?.vendorName && ( <span className="font-medium">{investigation.vendorName}</span> - )}의 실사 정보를 수정합니다. + )}의 실사 결과를 입력합니다. </SheetDescription> </SheetHeader> @@ -554,8 +554,8 @@ export function UpdateVendorInvestigationSheet({ className="flex flex-col gap-4" id="update-investigation-form" > - {/* 실사 상태 */} - <FormField + {/* 실사 상태 - 주석처리 (실사 결과 입력에서는 자동으로 완료됨/취소됨/보완요구됨으로 변경) */} + {/* <FormField control={form.control} name="investigationStatus" render={({ field }) => ( @@ -572,6 +572,8 @@ export function UpdateVendorInvestigationSheet({ <SelectItem value="IN_PROGRESS">진행 중</SelectItem> <SelectItem value="COMPLETED">완료됨</SelectItem> <SelectItem value="CANCELED">취소됨</SelectItem> + <SelectItem value="SUPPLEMENT_REQUIRED">보완 요구됨</SelectItem> + <SelectItem value="RESULT_SENT">실사결과발송</SelectItem> </SelectGroup> </SelectContent> </Select> @@ -579,10 +581,10 @@ export function UpdateVendorInvestigationSheet({ <FormMessage /> </FormItem> )} - /> + /> */} - {/* 실사 주소 */} - <FormField + {/* 실사 주소 - 주석처리 (실사 진행 관리에서 처리) */} + {/* <FormField control={form.control} name="investigationAddress" render={({ field }) => ( @@ -598,10 +600,10 @@ export function UpdateVendorInvestigationSheet({ <FormMessage /> </FormItem> )} - /> + /> */} - {/* 실사 방법 */} - <FormField + {/* 실사 방법 - 주석처리 (실사 진행 관리에서 처리) */} + {/* <FormField control={form.control} name="investigationMethod" render={({ field }) => ( @@ -625,10 +627,10 @@ export function UpdateVendorInvestigationSheet({ <FormMessage /> </FormItem> )} - /> + /> */} - {/* 실사 수행 예정일 */} - <FormField + {/* 실사 수행 예정일 - 주석처리 (실사 진행 관리에서 처리) */} + {/* <FormField control={form.control} name="forecastedAt" render={({ field }) => ( @@ -662,10 +664,10 @@ export function UpdateVendorInvestigationSheet({ <FormMessage /> </FormItem> )} - /> + /> */} - {/* 실사 확정일 */} - <FormField + {/* 실사 확정일 - 주석처리 (실사 진행 관리에서 처리) */} + {/* <FormField control={form.control} name="confirmedAt" render={({ field }) => ( @@ -699,7 +701,7 @@ export function UpdateVendorInvestigationSheet({ <FormMessage /> </FormItem> )} - /> + /> */} {/* 실제 실사일 */} <FormField @@ -738,61 +740,59 @@ export function UpdateVendorInvestigationSheet({ )} /> - {/* 평가 점수 - 완료된 상태일 때만 표시 */} - {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> - )} - /> - )} + {/* 평가 점수 */} + <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> - )} - /> - )} + {/* 평가 결과 */} + <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="SUPPLEMENT_REINSPECT">보완-재실사</SelectItem> + <SelectItem value="SUPPLEMENT_DOCUMENT">보완-서류제출</SelectItem> + <SelectItem value="REJECTED">불가</SelectItem> + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> {/* QM 의견 */} <FormField diff --git a/lib/vendor-investigation/validations.ts b/lib/vendor-investigation/validations.ts index 0e84f13a..19412539 100644 --- a/lib/vendor-investigation/validations.ts +++ b/lib/vendor-investigation/validations.ts @@ -60,17 +60,64 @@ 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 updateVendorInvestigationProgressSchema = z.object({ + investigationId: z.number({ + required_error: "Investigation ID is required", + }), + investigationAddress: z.string().optional(), + investigationMethod: z.enum(["PURCHASE_SELF_EVAL", "DOCUMENT_EVAL", "PRODUCT_INSPECTION", "SITE_VISIT_EVAL"]).optional(), + + // 날짜 필드들 + forecastedAt: z.union([ + z.date(), + z.string().transform((str) => str ? new Date(str) : undefined) + ]).optional(), + + confirmedAt: z.union([ + z.date(), + z.string().transform((str) => str ? new Date(str) : undefined) + ]).optional(), +}) + +export type UpdateVendorInvestigationProgressSchema = z.infer<typeof updateVendorInvestigationProgressSchema> + +// 실사 결과 입력용 스키마 +export const updateVendorInvestigationResultSchema = z.object({ + investigationId: z.number({ + required_error: "Investigation ID is required", + }), + + // 날짜 필드들 + completedAt: z.union([ + z.date(), + z.string().transform((str) => str ? new Date(str) : undefined) + ]).optional(), + + evaluationScore: z.number() + .int("평가 점수는 정수여야 합니다.") + .min(0, "평가 점수는 0점 이상이어야 합니다.") + .max(100, "평가 점수는 100점 이하여야 합니다.") + .optional(), + evaluationResult: z.enum(["APPROVED", "SUPPLEMENT", "SUPPLEMENT_REINSPECT", "SUPPLEMENT_DOCUMENT", "REJECTED", "RESULT_SENT"]).optional(), + investigationNotes: z.string().max(1000, "QM 의견은 1000자 이내로 입력해주세요.").optional(), + attachments: z.any().optional(), // File 업로드를 위한 필드 +}) + +export type UpdateVendorInvestigationResultSchema = z.infer<typeof updateVendorInvestigationResultSchema> + +// 기존 호환성을 위한 통합 스키마 export const updateVendorInvestigationSchema = z.object({ investigationId: z.number({ required_error: "Investigation ID is required", }), - investigationStatus: z.enum(["PLANNED", "IN_PROGRESS", "COMPLETED", "CANCELED", "RESULT_SENT"], { + investigationStatus: z.enum(["PLANNED", "IN_PROGRESS", "COMPLETED", "CANCELED", "SUPPLEMENT_REQUIRED", "RESULT_SENT"], { required_error: "실사 상태를 선택해주세요.", }), investigationAddress: z.string().optional(), investigationMethod: z.enum(["PURCHASE_SELF_EVAL", "DOCUMENT_EVAL", "PRODUCT_INSPECTION", "SITE_VISIT_EVAL"]).optional(), - // 날짜 필드들을 string에서 Date로 변환하도록 수정 + // 날짜 필드들 forecastedAt: z.union([ z.date(), z.string().transform((str) => str ? new Date(str) : undefined) @@ -96,7 +143,7 @@ export const updateVendorInvestigationSchema = z.object({ .min(0, "평가 점수는 0점 이상이어야 합니다.") .max(100, "평가 점수는 100점 이하여야 합니다.") .optional(), - evaluationResult: z.enum(["APPROVED", "SUPPLEMENT", "REJECTED", "RESULT_SENT"]).optional(), + evaluationResult: z.enum(["APPROVED", "SUPPLEMENT", "SUPPLEMENT_REINSPECT", "SUPPLEMENT_DOCUMENT", "REJECTED", "RESULT_SENT"]).optional(), investigationNotes: z.string().max(1000, "QM 의견은 1000자 이내로 입력해주세요.").optional(), attachments: z.any().optional(), // File 업로드를 위한 필드 }) |
