diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-10-29 07:43:44 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-10-29 07:43:44 +0000 |
| commit | 2eb717eb2bbfd97a5f149d13049aa336c26c393b (patch) | |
| tree | 274283b7759bfba619e6d143edccf3845ba45ed6 | |
| parent | bfc26491991997b5b109af6ea6bc75a8be138e9a (diff) | |
(최겸) 구매 실사 개발(진행중)
| -rw-r--r-- | components/investigation/supplement-request-dialog.tsx | 336 | ||||
| -rw-r--r-- | components/investigation/supplement-response-dialog.tsx | 483 | ||||
| -rw-r--r-- | components/pq-input/pq-input-tabs.tsx | 26 | ||||
| -rw-r--r-- | components/pq-input/pq-review-wrapper.tsx | 260 | ||||
| -rw-r--r-- | config/vendorInvestigationsColumnsConfig.ts | 4 | ||||
| -rw-r--r-- | db/schema/pq.ts | 32 | ||||
| -rw-r--r-- | i18n/locales/en/menu.json | 5 | ||||
| -rw-r--r-- | i18n/locales/ko/menu.json | 8 | ||||
| -rw-r--r-- | lib/pq/pq-review-table-new/vendors-table-columns.tsx | 12 | ||||
| -rw-r--r-- | lib/pq/service.ts | 310 | ||||
| -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 |
17 files changed, 3313 insertions, 169 deletions
diff --git a/components/investigation/supplement-request-dialog.tsx b/components/investigation/supplement-request-dialog.tsx new file mode 100644 index 00000000..c0af36c7 --- /dev/null +++ b/components/investigation/supplement-request-dialog.tsx @@ -0,0 +1,336 @@ +"use client" + +import * as React from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Textarea } from "@/components/ui/textarea" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Badge } from "@/components/ui/badge" +import { useToast } from "@/hooks/use-toast" +import { + requestSupplementReinspectionAction, + requestSupplementDocumentAction +} from "@/lib/vendor-investigation/service" + +interface SupplementRequestDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + investigationId: number + investigationMethod: string + vendorName: string +} + +export function SupplementRequestDialog({ + open, + onOpenChange, + investigationId, + investigationMethod, + vendorName +}: SupplementRequestDialogProps) { + const { toast } = useToast() + const [isSubmitting, setIsSubmitting] = React.useState(false) + const [requestType, setRequestType] = React.useState<"REINSPECT" | "DOCUMENT">("REINSPECT") + + // 재실사 요청 데이터 + const [reinspectData, setReinspectData] = React.useState({ + inspectionDuration: 1.0, + requestedStartDate: "", + requestedEndDate: "", + additionalRequests: "" + }) + + // 서류제출 요청 데이터 + const [documentData, setDocumentData] = React.useState({ + requiredDocuments: [""], + additionalRequests: "" + }) + + // 보완 요청이 가능한 실사 방법인지 확인 + const canRequestSupplement = investigationMethod === "PRODUCT_INSPECTION" || + investigationMethod === "SITE_VISIT_EVAL" + + const handleSubmit = async () => { + if (!canRequestSupplement) { + toast({ + title: "보완 요청 불가", + description: "현재 실사 방법에서는 보완 요청을 할 수 없습니다.", + variant: "destructive" + }) + return + } + + try { + setIsSubmitting(true) + + if (requestType === "REINSPECT") { + const result = await requestSupplementReinspectionAction({ + investigationId, + siteVisitData: { + inspectionDuration: reinspectData.inspectionDuration, + requestedStartDate: reinspectData.requestedStartDate ? new Date(reinspectData.requestedStartDate) : undefined, + requestedEndDate: reinspectData.requestedEndDate ? new Date(reinspectData.requestedEndDate) : undefined, + additionalRequests: reinspectData.additionalRequests + } + }) + + if (result.success) { + toast({ + title: "보완-재실사 요청 완료", + description: "재실사 요청이 성공적으로 생성되었습니다.", + }) + onOpenChange(false) + } else { + toast({ + title: "요청 실패", + description: result.error || "재실사 요청 중 오류가 발생했습니다.", + variant: "destructive" + }) + } + } else { + const result = await requestSupplementDocumentAction({ + investigationId, + documentRequests: { + requiredDocuments: documentData.requiredDocuments.filter(doc => doc.trim() !== ""), + additionalRequests: documentData.additionalRequests + } + }) + + if (result.success) { + toast({ + title: "보완-서류제출 요청 완료", + description: "서류제출 요청이 성공적으로 생성되었습니다.", + }) + onOpenChange(false) + } else { + toast({ + title: "요청 실패", + description: result.error || "서류제출 요청 중 오류가 발생했습니다.", + variant: "destructive" + }) + } + } + } catch (error) { + console.error("보완 요청 오류:", error) + toast({ + title: "요청 실패", + description: "보완 요청 중 오류가 발생했습니다.", + variant: "destructive" + }) + } finally { + setIsSubmitting(false) + } + } + + const addDocument = () => { + setDocumentData(prev => ({ + ...prev, + requiredDocuments: [...prev.requiredDocuments, ""] + })) + } + + const removeDocument = (index: number) => { + setDocumentData(prev => ({ + ...prev, + requiredDocuments: prev.requiredDocuments.filter((_, i) => i !== index) + })) + } + + const updateDocument = (index: number, value: string) => { + setDocumentData(prev => ({ + ...prev, + requiredDocuments: prev.requiredDocuments.map((doc, i) => i === index ? value : doc) + })) + } + + if (!canRequestSupplement) { + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent> + <DialogHeader> + <DialogTitle>보완 요청 불가</DialogTitle> + <DialogDescription> + 현재 실사 방법({investigationMethod})에서는 보완 요청을 할 수 없습니다. + 보완 요청은 제품검사평가(PRODUCT_INSPECTION) 또는 방문실사평가(SITE_VISIT_EVAL)에서만 가능합니다. + </DialogDescription> + </DialogHeader> + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 확인 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-2xl"> + <DialogHeader> + <DialogTitle>보완 요청</DialogTitle> + <DialogDescription> + {vendorName}에 대한 보완 요청을 생성합니다. + </DialogDescription> + </DialogHeader> + + <div className="space-y-6"> + {/* 요청 유형 선택 */} + <div className="space-y-2"> + <Label>보완 요청 유형</Label> + <div className="flex gap-4"> + <Button + type="button" + variant={requestType === "REINSPECT" ? "default" : "outline"} + onClick={() => setRequestType("REINSPECT")} + > + 보완-재실사 + </Button> + <Button + type="button" + variant={requestType === "DOCUMENT" ? "default" : "outline"} + onClick={() => setRequestType("DOCUMENT")} + > + 보완-서류제출 + </Button> + </div> + </div> + + {/* 재실사 요청 폼 */} + {requestType === "REINSPECT" && ( + <div className="space-y-4"> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="duration">실사 기간 (일)</Label> + <Input + id="duration" + type="number" + step="0.1" + min="0.1" + value={reinspectData.inspectionDuration} + onChange={(e) => setReinspectData(prev => ({ + ...prev, + inspectionDuration: parseFloat(e.target.value) || 0 + }))} + /> + </div> + <div className="space-y-2"> + <Label>실사 방법</Label> + <Badge variant="outline"> + {investigationMethod === "PRODUCT_INSPECTION" ? "제품검사평가" : "방문실사평가"} + </Badge> + </div> + </div> + + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="startDate">요청 시작일</Label> + <Input + id="startDate" + type="date" + value={reinspectData.requestedStartDate} + onChange={(e) => setReinspectData(prev => ({ + ...prev, + requestedStartDate: e.target.value + }))} + /> + </div> + <div className="space-y-2"> + <Label htmlFor="endDate">요청 종료일</Label> + <Input + id="endDate" + type="date" + value={reinspectData.requestedEndDate} + onChange={(e) => setReinspectData(prev => ({ + ...prev, + requestedEndDate: e.target.value + }))} + /> + </div> + </div> + + <div className="space-y-2"> + <Label htmlFor="reinspectRequests">추가 요청사항</Label> + <Textarea + id="reinspectRequests" + placeholder="재실사에 대한 추가 요청사항을 입력하세요" + value={reinspectData.additionalRequests} + onChange={(e) => setReinspectData(prev => ({ + ...prev, + additionalRequests: e.target.value + }))} + className="min-h-20" + /> + </div> + </div> + )} + + {/* 서류제출 요청 폼 */} + {requestType === "DOCUMENT" && ( + <div className="space-y-4"> + <div className="space-y-2"> + <Label>필요 서류 목록</Label> + {documentData.requiredDocuments.map((doc, index) => ( + <div key={index} className="flex gap-2"> + <Input + placeholder="필요한 서류명을 입력하세요" + value={doc} + onChange={(e) => updateDocument(index, e.target.value)} + /> + <Button + type="button" + variant="outline" + size="sm" + onClick={() => removeDocument(index)} + disabled={documentData.requiredDocuments.length === 1} + > + 삭제 + </Button> + </div> + ))} + <Button + type="button" + variant="outline" + size="sm" + onClick={addDocument} + > + 서류 추가 + </Button> + </div> + + <div className="space-y-2"> + <Label htmlFor="documentRequests">추가 요청사항</Label> + <Textarea + id="documentRequests" + placeholder="서류제출에 대한 추가 요청사항을 입력하세요" + value={documentData.additionalRequests} + onChange={(e) => setDocumentData(prev => ({ + ...prev, + additionalRequests: e.target.value + }))} + className="min-h-20" + /> + </div> + </div> + )} + </div> + + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 취소 + </Button> + <Button onClick={handleSubmit} disabled={isSubmitting}> + {isSubmitting ? "요청 중..." : "보완 요청"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} diff --git a/components/investigation/supplement-response-dialog.tsx b/components/investigation/supplement-response-dialog.tsx new file mode 100644 index 00000000..8490c15c --- /dev/null +++ b/components/investigation/supplement-response-dialog.tsx @@ -0,0 +1,483 @@ +"use client" + +import * as React from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Textarea } from "@/components/ui/textarea" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Badge } from "@/components/ui/badge" +import { useToast } from "@/hooks/use-toast" +import { + Dropzone, + DropzoneDescription, + DropzoneInput, + DropzoneTitle, + DropzoneUploadIcon, + DropzoneZone, +} from "@/components/ui/dropzone" +import { + FileList, + FileListAction, + FileListDescription, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, +} from "@/components/ui/file-list" +import { X, Download } from "lucide-react" +import prettyBytes from "pretty-bytes" +import { + submitSupplementDocumentResponseAction +} from "@/lib/vendor-investigation/service" +import { uploadVendorFileAction } from "@/lib/pq/service" + +interface SupplementResponseDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + investigationId: number + supplementType: "REINSPECT" | "DOCUMENT" + vendorName: string + requiredDocuments?: string[] + additionalRequests?: string +} + +interface LocalFileState { + fileObj: File + uploaded: boolean +} + +export function SupplementResponseDialog({ + open, + onOpenChange, + investigationId, + supplementType, + vendorName, + requiredDocuments = [], + additionalRequests = "" +}: SupplementResponseDialogProps) { + const { toast } = useToast() + const [isSubmitting, setIsSubmitting] = React.useState(false) + const [isUploading, setIsUploading] = React.useState(false) + + // 서류제출 응답 데이터 + const [responseData, setResponseData] = React.useState({ + responseText: "", + uploadedFiles: [] as Array<{ + fileName: string + url: string + size?: number + }>, + newUploads: [] as LocalFileState[] + }) + + // 재실사 응답 데이터 + const [reinspectData, setReinspectData] = React.useState({ + inspectionDate: "", + inspectionDuration: 1.0, + inspectionResults: "", + additionalNotes: "" + }) + + const handleFileDrop = (files: File[]) => { + const newFiles: LocalFileState[] = files.map(file => ({ + fileObj: file, + uploaded: false + })) + + setResponseData(prev => ({ + ...prev, + newUploads: [...prev.newUploads, ...newFiles] + })) + } + + const removeNewUpload = (index: number) => { + setResponseData(prev => ({ + ...prev, + newUploads: prev.newUploads.filter((_, i) => i !== index) + })) + } + + const removeUploadedFile = (index: number) => { + setResponseData(prev => ({ + ...prev, + uploadedFiles: prev.uploadedFiles.filter((_, i) => i !== index) + })) + } + + const uploadFiles = async () => { + if (responseData.newUploads.length === 0) return + + setIsUploading(true) + try { + for (const localFile of responseData.newUploads) { + const uploadResult = await uploadVendorFileAction(localFile.fileObj) + setResponseData(prev => ({ + ...prev, + uploadedFiles: [...prev.uploadedFiles, { + fileName: uploadResult.fileName, + url: uploadResult.url, + size: uploadResult.size + }] + })) + } + + setResponseData(prev => ({ + ...prev, + newUploads: [] + })) + + toast({ + title: "파일 업로드 완료", + description: "파일이 성공적으로 업로드되었습니다.", + }) + } catch (error) { + console.error("파일 업로드 오류:", error) + toast({ + title: "업로드 실패", + description: "파일 업로드 중 오류가 발생했습니다.", + variant: "destructive" + }) + } finally { + setIsUploading(false) + } + } + + const handleSubmit = async () => { + if (supplementType === "DOCUMENT") { + // 서류제출 응답 검증 + if (!responseData.responseText.trim()) { + toast({ + title: "응답 필요", + description: "응답 내용을 입력해주세요.", + variant: "destructive" + }) + return + } + + if (responseData.uploadedFiles.length === 0 && responseData.newUploads.length > 0) { + toast({ + title: "파일 업로드 필요", + description: "새로 추가한 파일을 먼저 업로드해주세요.", + variant: "destructive" + }) + return + } + } else { + // 재실사 응답 검증 + if (!reinspectData.inspectionDate || !reinspectData.inspectionResults.trim()) { + toast({ + title: "필수 정보 필요", + description: "실사 일정과 결과를 입력해주세요.", + variant: "destructive" + }) + return + } + } + + try { + setIsSubmitting(true) + + if (supplementType === "DOCUMENT") { + const result = await submitSupplementDocumentResponseAction({ + investigationId, + responseData: { + responseText: responseData.responseText, + attachments: responseData.uploadedFiles.map(file => ({ + fileName: file.fileName, + url: file.url, + size: file.size + })) + } + }) + + if (result.success) { + toast({ + title: "서류제출 응답 완료", + description: "서류제출 응답이 성공적으로 제출되었습니다.", + }) + onOpenChange(false) + } else { + toast({ + title: "제출 실패", + description: result.error || "서류제출 응답 중 오류가 발생했습니다.", + variant: "destructive" + }) + } + } else { + // 재실사 응답은 별도 액션 필요 (구현 예정) + toast({ + title: "재실사 응답", + description: "재실사 응답 기능은 구현 예정입니다.", + }) + } + } catch (error) { + console.error("보완 응답 오류:", error) + toast({ + title: "제출 실패", + description: "보완 응답 중 오류가 발생했습니다.", + variant: "destructive" + }) + } finally { + setIsSubmitting(false) + } + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-3xl"> + <DialogHeader> + <DialogTitle> + {supplementType === "REINSPECT" ? "보완-재실사 응답" : "보완-서류제출 응답"} + </DialogTitle> + <DialogDescription> + {vendorName}에 대한 보완 요청에 응답합니다. + </DialogDescription> + </DialogHeader> + + <div className="space-y-6"> + {/* 서류제출 응답 폼 */} + {supplementType === "DOCUMENT" && ( + <> + {/* 요청된 서류 목록 */} + {requiredDocuments.length > 0 && ( + <div className="space-y-2"> + <Label>요청된 서류 목록</Label> + <div className="space-y-1"> + {requiredDocuments.map((doc, index) => ( + <div key={index} className="flex items-center gap-2"> + <Badge variant="outline">{index + 1}</Badge> + <span className="text-sm">{doc}</span> + </div> + ))} + </div> + </div> + )} + + {/* 추가 요청사항 */} + {additionalRequests && ( + <div className="space-y-2"> + <Label>추가 요청사항</Label> + <div className="p-3 bg-muted rounded-md text-sm"> + {additionalRequests} + </div> + </div> + )} + + {/* 응답 내용 */} + <div className="space-y-2"> + <Label htmlFor="responseText">응답 내용 *</Label> + <Textarea + id="responseText" + placeholder="요청된 서류에 대한 응답 내용을 입력하세요" + value={responseData.responseText} + onChange={(e) => setResponseData(prev => ({ + ...prev, + responseText: e.target.value + }))} + className="min-h-32" + /> + </div> + + {/* 파일 업로드 */} + <div className="space-y-4"> + <Label>첨부 파일</Label> + + <Dropzone + maxSize={6e8} // 600MB + onDropAccepted={handleFileDrop} + disabled={isUploading} + > + {() => ( + <DropzoneZone className="flex justify-center h-32"> + <DropzoneInput /> + <div className="flex items-center gap-6"> + <DropzoneUploadIcon /> + <div className="grid gap-0.5"> + <DropzoneTitle>파일을 드래그하거나 클릭하여 업로드</DropzoneTitle> + <DropzoneDescription> + PDF, Word, Excel, 이미지 파일 (최대 600MB) + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + )} + </Dropzone> + + {/* 업로드된 파일 목록 */} + {(responseData.uploadedFiles.length > 0 || responseData.newUploads.length > 0) && ( + <div className="space-y-2"> + <Label>업로드된 파일</Label> + <FileList> + {responseData.uploadedFiles.map((file, index) => ( + <FileListItem key={`uploaded-${index}`}> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>{file.fileName}</FileListName> + {file.size && ( + <FileListDescription> + {prettyBytes(file.size)} + </FileListDescription> + )} + </FileListInfo> + <div className="flex gap-1"> + <FileListAction + onClick={async () => { + try { + const { downloadFile } = await import('@/lib/file-download') + await downloadFile(file.url, file.fileName, { + showToast: true, + onError: (error) => { + console.error('다운로드 오류:', error) + toast({ + title: "다운로드 실패", + description: error, + variant: "destructive" + }) + } + }) + } catch (error) { + console.error('다운로드 오류:', error) + toast({ + title: "다운로드 실패", + description: "파일 다운로드 중 오류가 발생했습니다.", + variant: "destructive" + }) + } + }} + > + <Download className="h-4 w-4" /> + </FileListAction> + <FileListAction + onClick={() => removeUploadedFile(index)} + > + <X className="h-4 w-4" /> + </FileListAction> + </div> + </FileListHeader> + </FileListItem> + ))} + + {responseData.newUploads.map((file, index) => ( + <FileListItem key={`new-${index}`}> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>{file.fileObj.name}</FileListName> + <FileListDescription> + {prettyBytes(file.fileObj.size)} + </FileListDescription> + </FileListInfo> + <div className="flex gap-1"> + <FileListAction + onClick={() => removeNewUpload(index)} + > + <X className="h-4 w-4" /> + </FileListAction> + </div> + </FileListHeader> + </FileListItem> + ))} + </FileList> + + {responseData.newUploads.length > 0 && ( + <Button + type="button" + variant="outline" + size="sm" + onClick={uploadFiles} + disabled={isUploading} + > + {isUploading ? "업로드 중..." : "파일 업로드"} + </Button> + )} + </div> + )} + </div> + </> + )} + + {/* 재실사 응답 폼 */} + {supplementType === "REINSPECT" && ( + <div className="space-y-4"> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="inspectionDate">실사 일정 *</Label> + <Input + id="inspectionDate" + type="date" + value={reinspectData.inspectionDate} + onChange={(e) => setReinspectData(prev => ({ + ...prev, + inspectionDate: e.target.value + }))} + /> + </div> + <div className="space-y-2"> + <Label htmlFor="duration">실사 기간 (일)</Label> + <Input + id="duration" + type="number" + step="0.1" + min="0.1" + value={reinspectData.inspectionDuration} + onChange={(e) => setReinspectData(prev => ({ + ...prev, + inspectionDuration: parseFloat(e.target.value) || 0 + }))} + /> + </div> + </div> + + <div className="space-y-2"> + <Label htmlFor="inspectionResults">실사 결과 *</Label> + <Textarea + id="inspectionResults" + placeholder="실사 결과를 상세히 입력하세요" + value={reinspectData.inspectionResults} + onChange={(e) => setReinspectData(prev => ({ + ...prev, + inspectionResults: e.target.value + }))} + className="min-h-32" + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="additionalNotes">추가 메모</Label> + <Textarea + id="additionalNotes" + placeholder="추가 메모나 특이사항을 입력하세요" + value={reinspectData.additionalNotes} + onChange={(e) => setReinspectData(prev => ({ + ...prev, + additionalNotes: e.target.value + }))} + className="min-h-20" + /> + </div> + </div> + )} + </div> + + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 취소 + </Button> + <Button onClick={handleSubmit} disabled={isSubmitting || isUploading}> + {isSubmitting ? "제출 중..." : "응답 제출"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} diff --git a/components/pq-input/pq-input-tabs.tsx b/components/pq-input/pq-input-tabs.tsx index 9cdb5e8d..8d5aa2ab 100644 --- a/components/pq-input/pq-input-tabs.tsx +++ b/components/pq-input/pq-input-tabs.tsx @@ -180,7 +180,12 @@ export function PQInputTabs({ const { toast } = useToast() - const shouldDisableInput = isReadOnly; + // QM 검토 중이거나 이미 승인된 상태에서는 수정 불가 + const shouldDisableInput = isReadOnly || + (currentPQ?.status === "QM_APPROVED") || + (currentPQ?.status === "QM_REJECTED") || + (currentPQ?.status === "APPROVED") || + (currentPQ?.status === "REJECTED"); // 코드 순서로 정렬하는 함수 (1-1-1, 1-1-2, 1-2-1 순서) const sortByCode = (items: any[]) => { @@ -599,9 +604,16 @@ export function PQInputTabs({ <div className="mb-6 bg-muted p-4 rounded-md"> <div className="flex items-center justify-between mb-2"> <h3 className="text-lg font-semibold">프로젝트 정보</h3> - <Badge variant={getStatusVariant(projectData.status)}> - {getStatusLabel(projectData.status)} - </Badge> + <div className="flex items-center gap-2"> + <Badge variant={getStatusVariant(projectData.status)}> + {getStatusLabel(projectData.status)} + </Badge> + {projectData.status === "QM_REVIEWING" && ( + <div className="text-sm text-amber-600 bg-amber-50 px-2 py-1 rounded"> + QM 검토 중 - 수정 가능 + </div> + )} + </div> </div> <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> @@ -630,6 +642,9 @@ export function PQInputTabs({ case "REQUESTED": return "요청됨"; case "IN_PROGRESS": return "진행중"; case "SUBMITTED": return "제출됨"; + case "QM_REVIEWING": return "QM 검토중"; + case "QM_APPROVED": return "QM 승인됨"; + case "QM_REJECTED": return "QM 거절됨"; case "APPROVED": return "승인됨"; case "REJECTED": return "반려됨"; default: return status; @@ -641,6 +656,9 @@ export function PQInputTabs({ case "REQUESTED": return "secondary"; case "IN_PROGRESS": return "default"; case "SUBMITTED": return "outline"; + case "QM_REVIEWING": return "default"; + case "QM_APPROVED": return "outline"; + case "QM_REJECTED": return "destructive"; case "APPROVED": return "outline"; case "REJECTED": return "destructive"; default: return "secondary"; diff --git a/components/pq-input/pq-review-wrapper.tsx b/components/pq-input/pq-review-wrapper.tsx index 1545314c..9b719644 100644 --- a/components/pq-input/pq-review-wrapper.tsx +++ b/components/pq-input/pq-review-wrapper.tsx @@ -23,7 +23,7 @@ import { import { useToast } from "@/hooks/use-toast" import { CheckCircle, AlertCircle, Paperclip, Square } from "lucide-react" import { PQGroupData } from "@/lib/pq/service" -import { approvePQAction, rejectPQAction, updateSHICommentAction } from "@/lib/pq/service" +import { approvePQAction, rejectPQAction, updateSHICommentAction, approveQMReviewAction, rejectQMReviewAction } from "@/lib/pq/service" // import * as ExcelJS from 'exceljs'; // import { saveAs } from "file-saver"; @@ -61,9 +61,14 @@ export function PQReviewWrapper({ const { toast } = useToast() const [isApproving, setIsApproving] = React.useState(false) const [isRejecting, setIsRejecting] = React.useState(false) + const [isQMApproving, setIsQMApproving] = React.useState(false) + const [isQMRejecting, setIsQMRejecting] = React.useState(false) const [showApproveDialog, setShowApproveDialog] = React.useState(false) const [showRejectDialog, setShowRejectDialog] = React.useState(false) + const [showQMApproveDialog, setShowQMApproveDialog] = React.useState(false) + const [showQMRejectDialog, setShowQMRejectDialog] = React.useState(false) const [rejectReason, setRejectReason] = React.useState("") + const [qmRejectReason, setQmRejectReason] = React.useState("") const [shiComments, setShiComments] = React.useState<Record<number, string>>({}) const [isUpdatingComment, setIsUpdatingComment] = React.useState<number | null>(null) @@ -103,7 +108,7 @@ export function PQReviewWrapper({ setShiComments(initialComments) }, [pqData]) - // PQ 승인 처리 + // PQ 승인 처리 (구매 담당자) const handleApprove = async () => { try { setIsApproving(true) @@ -116,7 +121,7 @@ export function PQReviewWrapper({ if (result.ok) { toast({ title: "PQ 승인 완료", - description: "PQ가 성공적으로 승인되었습니다.", + description: "PQ가 QM 검토 단계로 전환되었습니다.", }) // 페이지 새로고침 router.refresh() @@ -139,6 +144,90 @@ export function PQReviewWrapper({ setShowApproveDialog(false) } } + + // QM 승인 처리 + const handleQMApprove = async () => { + try { + setIsQMApproving(true) + + const result = await approveQMReviewAction({ + pqSubmissionId: pqSubmission.id, + vendorId: vendorId + }) + + if (result.ok) { + toast({ + title: "QM 승인 완료", + description: "PQ가 최종 승인되어 실사 프로세스가 시작됩니다.", + }) + // 페이지 새로고침 + router.refresh() + } else { + toast({ + title: "QM 승인 실패", + description: result.error || "QM 승인 중 오류가 발생했습니다.", + variant: "destructive" + }) + } + } catch (error) { + console.error("QM 승인 오류:", error) + toast({ + title: "QM 승인 실패", + description: "QM 승인 중 오류가 발생했습니다.", + variant: "destructive" + }) + } finally { + setIsQMApproving(false) + setShowQMApproveDialog(false) + } + } + + // QM 거절 처리 + const handleQMReject = async () => { + if (!qmRejectReason.trim()) { + toast({ + title: "거절 사유 필요", + description: "거절 사유를 입력해주세요.", + variant: "destructive" + }) + return + } + + try { + setIsQMRejecting(true) + + const result = await rejectQMReviewAction({ + pqSubmissionId: pqSubmission.id, + vendorId: vendorId, + rejectReason: qmRejectReason + }) + + if (result.ok) { + toast({ + title: "QM 거절 완료", + description: "PQ가 QM에 의해 거절되었습니다.", + }) + // 페이지 새로고침 + router.refresh() + } else { + toast({ + title: "QM 거절 실패", + description: result.error || "QM 거절 중 오류가 발생했습니다.", + variant: "destructive" + }) + } + } catch (error) { + console.error("QM 거절 오류:", error) + toast({ + title: "QM 거절 실패", + description: "QM 거절 중 오류가 발생했습니다.", + variant: "destructive" + }) + } finally { + setIsQMRejecting(false) + setShowQMRejectDialog(false) + } + } // SHI 코멘트 업데이트 처리 const handleSHICommentUpdate = async (answerId: number) => { @@ -643,48 +732,87 @@ export function PQReviewWrapper({ </div> ))} - {/* 검토 버튼 */} + {/* 검토 버튼 - 상태에 따라 다른 버튼 표시 */} <div className="fixed bottom-4 right-4 bg-background p-4 rounded-lg shadow-md border"> <div className="flex gap-2"> + {/* SUBMITTED 상태: 구매 담당자 승인/거절 */} + {pqSubmission.status === "SUBMITTED" && ( + <> + <Button + variant="outline" + onClick={() => setShowRejectDialog(true)} + disabled={isRejecting} + > + {isRejecting ? "거부 중..." : "거부"} + </Button> + <Button + variant="default" + onClick={() => setShowApproveDialog(true)} + disabled={isApproving} + > + {isApproving ? "승인 중..." : "구매 승인"} + </Button> + </> + )} + {/* QM_REVIEWING 상태: QM 승인/거절 */} + {pqSubmission.status === "QM_REVIEWING" && ( + <> + <Button + variant="outline" + onClick={() => setShowQMRejectDialog(true)} + disabled={isQMRejecting} + > + {isQMRejecting ? "QM 거절 중..." : "QM 거절"} + </Button> + <Button + variant="default" + onClick={() => setShowQMApproveDialog(true)} + disabled={isQMApproving} + > + {isQMApproving ? "QM 승인 중..." : "QM 승인"} + </Button> + </> + )} - {/* <Button - variant="outline" - onClick={handleExportToExcel} - disabled={isExporting} - > - <Download className="h-4 w-4 mr-2" /> - {isExporting ? "내보내기 중..." : "Excel 내보내기"} - </Button> */} - <Button - variant="outline" - onClick={() => setShowRejectDialog(true)} - disabled={isRejecting} - > - {isRejecting ? "거부 중..." : "거부"} - </Button> - <Button - variant="default" - onClick={() => setShowApproveDialog(true)} - disabled={isApproving} - > - {isApproving ? "승인 중..." : "승인"} - </Button> + {/* QM_APPROVED 상태: 완료 표시 */} + {pqSubmission.status === "QM_APPROVED" && ( + <div className="flex items-center gap-2 text-green-600"> + <CheckCircle className="h-4 w-4" /> + <span>QM 승인 완료</span> + </div> + )} + + {/* QM_REJECTED 상태: 거절 표시 */} + {pqSubmission.status === "QM_REJECTED" && ( + <div className="flex items-center gap-2 text-red-600"> + <AlertCircle className="h-4 w-4" /> + <span>QM 거절됨</span> + </div> + )} + + {/* REJECTED 상태: 거절 표시 */} + {pqSubmission.status === "REJECTED" && ( + <div className="flex items-center gap-2 text-red-600"> + <AlertCircle className="h-4 w-4" /> + <span>거절됨</span> + </div> + )} </div> </div> - {/* 승인 확인 다이얼로그 */} + {/* 구매 승인 확인 다이얼로그 */} <Dialog open={showApproveDialog} onOpenChange={setShowApproveDialog}> <DialogContent> <DialogHeader> - <DialogTitle>PQ 승인 확인</DialogTitle> + <DialogTitle>PQ 구매 승인 확인</DialogTitle> <DialogDescription> {pqSubmission.vendorName || "알 수 없는 업체"}의 { pqSubmission.type === "GENERAL" ? "일반" : pqSubmission.type === "PROJECT" ? "프로젝트" : pqSubmission.type === "NON_INSPECTION" ? "미실사" : "일반" - } PQ를 승인하시겠습니까? + } PQ를 구매 승인하여 QM 검토 단계로 전환하시겠습니까? {pqSubmission.projectId && ( <span> 프로젝트: {pqSubmission.projectName}</span> )} @@ -695,23 +823,50 @@ export function PQReviewWrapper({ 취소 </Button> <Button onClick={handleApprove} disabled={isApproving}> - {isApproving ? "승인 중..." : "승인"} + {isApproving ? "승인 중..." : "구매 승인"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + + {/* QM 승인 확인 다이얼로그 */} + <Dialog open={showQMApproveDialog} onOpenChange={setShowQMApproveDialog}> + <DialogContent> + <DialogHeader> + <DialogTitle>QM 승인 확인</DialogTitle> + <DialogDescription> + {pqSubmission.vendorName || "알 수 없는 업체"}의 { + pqSubmission.type === "GENERAL" ? "일반" : + pqSubmission.type === "PROJECT" ? "프로젝트" : + pqSubmission.type === "NON_INSPECTION" ? "미실사" : "일반" + } PQ를 QM에서 최종 승인하여 실사 프로세스를 시작하시겠습니까? + {pqSubmission.projectId && ( + <span> 프로젝트: {pqSubmission.projectName}</span> + )} + </DialogDescription> + </DialogHeader> + <DialogFooter> + <Button variant="outline" onClick={() => setShowQMApproveDialog(false)}> + 취소 + </Button> + <Button onClick={handleQMApprove} disabled={isQMApproving}> + {isQMApproving ? "QM 승인 중..." : "QM 승인"} </Button> </DialogFooter> </DialogContent> </Dialog> - {/* 거부 확인 다이얼로그 */} + {/* 구매 거부 확인 다이얼로그 */} <Dialog open={showRejectDialog} onOpenChange={setShowRejectDialog}> <DialogContent> <DialogHeader> - <DialogTitle>PQ 거부</DialogTitle> + <DialogTitle>PQ 구매 거부</DialogTitle> <DialogDescription> {pqSubmission.vendorName || "알 수 없는 업체"}의 { pqSubmission.type === "GENERAL" ? "일반" : pqSubmission.type === "PROJECT" ? "프로젝트" : pqSubmission.type === "NON_INSPECTION" ? "미실사" : "일반" - } PQ를 거부하는 이유를 입력해주세요. + } PQ를 구매에서 거부하는 이유를 입력해주세요. {pqSubmission.projectId && ( <span> 프로젝트: {pqSubmission.projectName}</span> )} @@ -732,7 +887,44 @@ export function PQReviewWrapper({ onClick={handleReject} disabled={isRejecting || !rejectReason.trim()} > - {isRejecting ? "거부 중..." : "거부"} + {isRejecting ? "거부 중..." : "구매 거부"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + + {/* QM 거절 확인 다이얼로그 */} + <Dialog open={showQMRejectDialog} onOpenChange={setShowQMRejectDialog}> + <DialogContent> + <DialogHeader> + <DialogTitle>QM 거절</DialogTitle> + <DialogDescription> + {pqSubmission.vendorName || "알 수 없는 업체"}의 { + pqSubmission.type === "GENERAL" ? "일반" : + pqSubmission.type === "PROJECT" ? "프로젝트" : + pqSubmission.type === "NON_INSPECTION" ? "미실사" : "일반" + } PQ를 QM에서 거절하는 이유를 입력해주세요. + {pqSubmission.projectId && ( + <span> 프로젝트: {pqSubmission.projectName}</span> + )} + </DialogDescription> + </DialogHeader> + <Textarea + value={qmRejectReason} + onChange={(e) => setQmRejectReason(e.target.value)} + placeholder="QM 거절 사유를 입력하세요" + className="min-h-24" + /> + <DialogFooter> + <Button variant="outline" onClick={() => setShowQMRejectDialog(false)}> + 취소 + </Button> + <Button + variant="destructive" + onClick={handleQMReject} + disabled={isQMRejecting || !qmRejectReason.trim()} + > + {isQMRejecting ? "QM 거절 중..." : "QM 거절"} </Button> </DialogFooter> </DialogContent> diff --git a/config/vendorInvestigationsColumnsConfig.ts b/config/vendorInvestigationsColumnsConfig.ts index 4e15bd34..44a0cf09 100644 --- a/config/vendorInvestigationsColumnsConfig.ts +++ b/config/vendorInvestigationsColumnsConfig.ts @@ -8,7 +8,7 @@ export type VendorInvestigationsViewRaw = { pqSubmissionId: number | null requesterId: number | null qmManagerId: number | null - investigationStatus: "PLANNED" | "IN_PROGRESS" | "COMPLETED" | "CANCELED" + investigationStatus: "PLANNED" | "IN_PROGRESS" | "COMPLETED" | "CANCELED" | "SUPPLEMENT_REQUIRED" | "RESULT_SENT" investigationAddress: string | null investigationMethod: "PURCHASE_SELF_EVAL" | "DOCUMENT_EVAL" | "PRODUCT_INSPECTION" | "SITE_VISIT_EVAL" | null scheduledStartAt: Date | null @@ -18,7 +18,7 @@ export type VendorInvestigationsViewRaw = { confirmedAt: Date | null completedAt: Date | null evaluationScore: number | null - evaluationResult: "APPROVED" | "SUPPLEMENT" | "REJECTED" | null + evaluationResult: "APPROVED" | "SUPPLEMENT" | "SUPPLEMENT_REINSPECT" | "SUPPLEMENT_DOCUMENT" | "REJECTED" | null investigationNotes: string | null createdAt: Date updatedAt: Date diff --git a/db/schema/pq.ts b/db/schema/pq.ts index 56f2cc40..b233119f 100644 --- a/db/schema/pq.ts +++ b/db/schema/pq.ts @@ -129,7 +129,18 @@ export const vendorPQSubmissions = pgTable("vendor_pq_submissions", { // PQ 유형 구분을 명시적으로 type: varchar("type", { length: 20 }).notNull(), // "GENERAL" or "PROJECT" or "NON_INSPECTION" - status: varchar("status", { length: 20 }).notNull().default("REQUESTED"), + status: varchar("status", { + length: 20, + enum: [ + "REQUESTED", + "SUBMITTED", + "APPROVED", + "REJECTED", + "QM_REVIEWING", // QM 검토중 + "QM_APPROVED", // QM 승인 + "QM_REJECTED" // QM 거절 + ] + }).notNull().default("REQUESTED"), dueDate: timestamp("due_date"), agreements: jsonb("agreements").notNull().default({}), // ✅ 체크 항목들을 JSON으로 저장 @@ -243,11 +254,12 @@ export const vendorInvestigations = pgTable("vendor_investigations", { investigationStatus: varchar("investigation_status", { length: 50, enum: [ - "PLANNED", // 계획됨 - "IN_PROGRESS", // 진행 중 - "COMPLETED", // 완료됨 - "CANCELED", // 취소됨 - "RESULT_SENT", // 실사결과발송 - 구매담당자가 Vendor측으로 실사결과를 발송한 상태 + "PLANNED", // 계획됨 + "IN_PROGRESS", // 진행 중 + "COMPLETED", // 완료됨 + "CANCELED", // 취소됨 + "SUPPLEMENT_REQUIRED", // 보완 요구됨 (재실사 또는 서류제출) + "RESULT_SENT", // 실사결과발송 - 구매담당자가 Vendor측으로 실사결과를 발송한 상태 ], }) .notNull() @@ -291,9 +303,11 @@ export const vendorInvestigations = pgTable("vendor_investigations", { evaluationResult: varchar("evaluation_result", { length: 50, enum: [ - "APPROVED", // 승인 - "SUPPLEMENT", // 보완 - "REJECTED", // 불가 + "APPROVED", // 승인 + "SUPPLEMENT", // 보완 + "SUPPLEMENT_REINSPECT", // 보완-재실사 (PRODUCT_INSPECTION, SITE_VISIT_EVAL만 해당) + "SUPPLEMENT_DOCUMENT", // 보완-서류제출 (PRODUCT_INSPECTION, SITE_VISIT_EVAL만 해당) + "REJECTED", // 불가 ], }), diff --git a/i18n/locales/en/menu.json b/i18n/locales/en/menu.json index 712b1d12..8a245e04 100644 --- a/i18n/locales/en/menu.json +++ b/i18n/locales/en/menu.json @@ -30,7 +30,6 @@ "basic_contract": "Basic Contract Management", "engineering_management": "Engineering Management", "engineering_in_procurement":"Procurement Engineering" - }, "menu": { "master_data": { @@ -244,7 +243,9 @@ "pq_new": "PQ Submission", "pq_new_desc": "Submit PQ", "rfq_response":"RFQ Response", - "rfq_response_desc":"Create response to quotation request" + "rfq_response_desc":"Create response to quotation request", + "site_visit_management": "Site Visit Management", + "site_visit_management_desc": "Manage vendor site visit schedules and results" }, "engineering": { "tbe": "TBE", diff --git a/i18n/locales/ko/menu.json b/i18n/locales/ko/menu.json index ce69f274..407c490b 100644 --- a/i18n/locales/ko/menu.json +++ b/i18n/locales/ko/menu.json @@ -245,9 +245,11 @@ "general_contract_desc": "발주 리스트 확인 및 전자서명", "pq_new": "PQ 제출", "pq_new_desc": "PQ 제출", - "rfq_response": "견적 응답", - "rfq_response_desc": "견적 요청에 대한 응답 작성" - }, + "rfq_response": "견적 응답", + "rfq_response_desc": "견적 요청에 대한 응답 작성", + "site_visit_management": "방문실사 관리", + "site_visit_management_desc": "협력업체 방문실사 일정 및 결과 관리" + }, "engineering": { "title": "Engineering", "tbe": "TBE", diff --git a/lib/pq/pq-review-table-new/vendors-table-columns.tsx b/lib/pq/pq-review-table-new/vendors-table-columns.tsx index 30b1c83f..b4d7d038 100644 --- a/lib/pq/pq-review-table-new/vendors-table-columns.tsx +++ b/lib/pq/pq-review-table-new/vendors-table-columns.tsx @@ -82,7 +82,7 @@ export interface PQSubmission { completedAt: Date | null
forecastedAt: Date | null
evaluationScore: number | null
- evaluationResult: "APPROVED" | "SUPPLEMENT" | "REJECTED" | "RESULT_SENT" | null
+ evaluationResult: "APPROVED" | "SUPPLEMENT" | "SUPPLEMENT_REINSPECT" | "SUPPLEMENT_DOCUMENT" | "REJECTED" | "RESULT_SENT" | null
investigationNotes: string | null
} | null
// 통합 상태를 위한 새 필드
@@ -327,6 +327,10 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ExtendedC return { status: "INVESTIGATION_APPROVED", label: "실사 승인", variant: "success" as const };
case "SUPPLEMENT":
return { status: "INVESTIGATION_SUPPLEMENT", label: "실사 보완필요", variant: "secondary" as const };
+ case "SUPPLEMENT_REINSPECT":
+ return { status: "INVESTIGATION_SUPPLEMENT_REINSPECT", label: "실사 보완-재실사", variant: "secondary" as const };
+ case "SUPPLEMENT_DOCUMENT":
+ return { status: "INVESTIGATION_SUPPLEMENT_DOCUMENT", label: "실사 보완-서류제출", variant: "secondary" as const };
case "REJECTED":
return { status: "INVESTIGATION_REJECTED", label: "실사 불가", variant: "destructive" as const };
default:
@@ -336,6 +340,8 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ExtendedC return { status: "INVESTIGATION_COMPLETED", label: "실사 완료", variant: "default" as const };
case "CANCELED":
return { status: "INVESTIGATION_CANCELED", label: "실사 취소됨", variant: "destructive" as const };
+ case "SUPPLEMENT_REQUIRED":
+ return { status: "INVESTIGATION_SUPPLEMENT_REQUIRED", label: "실사 보완 요구됨", variant: "secondary" as const };
case "RESULT_SENT":
return { status: "INVESTIGATION_RESULT_SENT", label: "실사 결과 발송", variant: "success" as const };
default:
@@ -398,6 +404,10 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ExtendedC return <Badge variant="success">승인</Badge>;
case "SUPPLEMENT":
return <Badge variant="secondary">보완</Badge>;
+ case "SUPPLEMENT_REINSPECT":
+ return <Badge variant="secondary">보완-재실사</Badge>;
+ case "SUPPLEMENT_DOCUMENT":
+ return <Badge variant="secondary">보완-서류제출</Badge>;
case "REJECTED":
return <Badge variant="destructive">불가</Badge>;
default:
diff --git a/lib/pq/service.ts b/lib/pq/service.ts index b6640453..8b1986ce 100644 --- a/lib/pq/service.ts +++ b/lib/pq/service.ts @@ -1238,7 +1238,7 @@ export async function requestPqChangesAction({ await db
.update(vendorPQSubmissions)
.set({
- status: "IN_PROGRESS", // 변경 요청 상태로 설정
+ status: "SUBMITTED", // 변경 요청 상태로 설정
updatedAt: new Date(),
})
.where(
@@ -2210,22 +2210,21 @@ export async function approvePQAction({ projectName = projectData?.name || 'Unknown Project';
}
- // 5. PQ 상태 업데이트
+ // 5. PQ 상태를 QM_REVIEWING으로 업데이트 (TO-BE: QM 검토 단계 추가)
await db
.update(vendorPQSubmissions)
.set({
- status: "APPROVED",
- approvedAt: currentDate,
+ status: "QM_REVIEWING",
updatedAt: currentDate,
})
.where(eq(vendorPQSubmissions.id, pqSubmissionId));
- // 6. 일반 PQ인 경우 벤더 상태 업데이트 (선택사항)
+ // 6. 일반 PQ인 경우 벤더 상태를 IN_PQ로 업데이트 (QM 검토 중)
if (pqSubmission.type === "GENERAL") {
await db
.update(vendors)
.set({
- status: "PQ_APPROVED",
+ status: "IN_PQ",
updatedAt: currentDate,
})
.where(eq(vendors.id, vendorId));
@@ -2235,21 +2234,21 @@ export async function approvePQAction({ if (vendor.email) {
try {
const emailSubject = pqSubmission.projectId
- ? `[eVCP] Project PQ Approved for ${projectName}`
- : "[eVCP] General PQ Approved";
+ ? `[eVCP] Project PQ Under QM Review for ${projectName}`
+ : "[eVCP] General PQ Under QM Review";
const portalUrl = `${host}/partners/pq`;
await sendEmail({
to: vendor.email,
subject: emailSubject,
- template: "pq-approved-vendor",
+ template: "pq-qm-review-vendor",
context: {
vendorName: vendor.vendorName,
projectId: pqSubmission.projectId,
projectName: projectName,
isProjectPQ: !!pqSubmission.projectId,
- approvedDate: currentDate.toLocaleString(),
+ reviewDate: currentDate.toLocaleString(),
portalUrl,
}
});
@@ -2277,6 +2276,297 @@ export async function approvePQAction({ }
}
+// QM 검토 승인 액션
+export async function approveQMReviewAction({
+ pqSubmissionId,
+ vendorId,
+}: {
+ pqSubmissionId: number;
+ vendorId: number;
+}) {
+ unstable_noStore();
+
+ try {
+ const headersList = await headers();
+ const host = headersList.get('host') || 'localhost:3000';
+ const currentDate = new Date();
+
+ // 1. PQ 제출 정보 조회
+ const pqSubmission = await db
+ .select({
+ id: vendorPQSubmissions.id,
+ vendorId: vendorPQSubmissions.vendorId,
+ projectId: vendorPQSubmissions.projectId,
+ type: vendorPQSubmissions.type,
+ status: vendorPQSubmissions.status,
+ })
+ .from(vendorPQSubmissions)
+ .where(
+ and(
+ eq(vendorPQSubmissions.id, pqSubmissionId),
+ eq(vendorPQSubmissions.vendorId, vendorId)
+ )
+ )
+ .then(rows => rows[0]);
+
+ if (!pqSubmission) {
+ return { ok: false, error: "PQ submission not found" };
+ }
+
+ // 2. 상태 확인 (QM_REVIEWING 상태만 승인 가능)
+ if (pqSubmission.status !== "QM_REVIEWING") {
+ return {
+ ok: false,
+ error: `Cannot approve QM review in current status: ${pqSubmission.status}`
+ };
+ }
+
+ // 3. 벤더 정보 조회
+ const vendor = await db
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ email: vendors.email,
+ status: vendors.status,
+ })
+ .from(vendors)
+ .where(eq(vendors.id, vendorId))
+ .then(rows => rows[0]);
+
+ if (!vendor) {
+ return { ok: false, error: "Vendor not found" };
+ }
+
+ // 4. 프로젝트 정보 (프로젝트 PQ인 경우)
+ let projectName = '';
+ if (pqSubmission.projectId) {
+ const projectData = await db
+ .select({
+ id: projects.id,
+ name: projects.name,
+ })
+ .from(projects)
+ .where(eq(projects.id, pqSubmission.projectId))
+ .then(rows => rows[0]);
+
+ projectName = projectData?.name || 'Unknown Project';
+ }
+
+ // 5. PQ 상태를 QM_APPROVED로 업데이트
+ await db
+ .update(vendorPQSubmissions)
+ .set({
+ status: "QM_APPROVED",
+ approvedAt: currentDate,
+ updatedAt: currentDate,
+ })
+ .where(eq(vendorPQSubmissions.id, pqSubmissionId));
+
+ // 6. 일반 PQ인 경우 벤더 상태를 PQ_APPROVED로 업데이트
+ if (pqSubmission.type === "GENERAL") {
+ await db
+ .update(vendors)
+ .set({
+ status: "PQ_APPROVED",
+ updatedAt: currentDate,
+ })
+ .where(eq(vendors.id, vendorId));
+ }
+
+ // 7. 실사 요청 생성 (QM 승인 후 실사 프로세스 시작)
+ await db
+ .insert(vendorInvestigations)
+ .values({
+ vendorId: vendorId,
+ pqSubmissionId: pqSubmissionId,
+ investigationStatus: "PLANNED",
+ investigationMethod: "DOCUMENT_EVAL", // 기본값, 나중에 변경 가능
+ });
+
+ // 8. 벤더에게 이메일 알림 발송
+ if (vendor.email) {
+ try {
+ const emailSubject = pqSubmission.projectId
+ ? `[eVCP] Project PQ Approved for ${projectName}`
+ : "[eVCP] General PQ Approved";
+
+ const portalUrl = `${host}/partners/pq`;
+
+ await sendEmail({
+ to: vendor.email,
+ subject: emailSubject,
+ template: "pq-approved-vendor",
+ context: {
+ vendorName: vendor.vendorName,
+ projectId: pqSubmission.projectId,
+ projectName: projectName,
+ isProjectPQ: !!pqSubmission.projectId,
+ approvedDate: currentDate.toLocaleString(),
+ portalUrl,
+ }
+ });
+ } catch (emailError) {
+ console.error("Failed to send vendor notification:", emailError);
+ }
+ }
+
+ // 9. 캐시 무효화
+ revalidateTag("vendors");
+ revalidateTag("vendor-status-counts");
+ revalidateTag("pq-submissions");
+ revalidateTag("vendor-pq-submissions");
+ revalidateTag("vendor-investigations");
+ revalidatePath("/evcp/pq_new");
+
+ return { ok: true };
+ } catch (error) {
+ console.error("QM review approve error:", error);
+ return { ok: false, error: getErrorMessage(error) };
+ }
+}
+
+// QM 검토 거절 액션
+export async function rejectQMReviewAction({
+ pqSubmissionId,
+ vendorId,
+ rejectReason
+}: {
+ pqSubmissionId: number;
+ vendorId: number;
+ rejectReason: string;
+}) {
+ unstable_noStore();
+
+ try {
+ const headersList = await headers();
+ const host = headersList.get('host') || 'localhost:3000';
+ const currentDate = new Date();
+
+ // 1. PQ 제출 정보 조회
+ const pqSubmission = await db
+ .select({
+ id: vendorPQSubmissions.id,
+ vendorId: vendorPQSubmissions.vendorId,
+ projectId: vendorPQSubmissions.projectId,
+ type: vendorPQSubmissions.type,
+ status: vendorPQSubmissions.status,
+ })
+ .from(vendorPQSubmissions)
+ .where(
+ and(
+ eq(vendorPQSubmissions.id, pqSubmissionId),
+ eq(vendorPQSubmissions.vendorId, vendorId)
+ )
+ )
+ .then(rows => rows[0]);
+
+ if (!pqSubmission) {
+ return { ok: false, error: "PQ submission not found" };
+ }
+
+ // 2. 상태 확인 (QM_REVIEWING 상태만 거절 가능)
+ if (pqSubmission.status !== "QM_REVIEWING") {
+ return {
+ ok: false,
+ error: `Cannot reject QM review in current status: ${pqSubmission.status}`
+ };
+ }
+
+ // 3. 벤더 정보 조회
+ const vendor = await db
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ email: vendors.email,
+ status: vendors.status,
+ })
+ .from(vendors)
+ .where(eq(vendors.id, vendorId))
+ .then(rows => rows[0]);
+
+ if (!vendor) {
+ return { ok: false, error: "Vendor not found" };
+ }
+
+ // 4. 프로젝트 정보 (프로젝트 PQ인 경우)
+ let projectName = '';
+ if (pqSubmission.projectId) {
+ const projectData = await db
+ .select({
+ id: projects.id,
+ name: projects.name,
+ })
+ .from(projects)
+ .where(eq(projects.id, pqSubmission.projectId))
+ .then(rows => rows[0]);
+
+ projectName = projectData?.name || 'Unknown Project';
+ }
+
+ // 5. PQ 상태를 QM_REJECTED로 업데이트
+ await db
+ .update(vendorPQSubmissions)
+ .set({
+ status: "QM_REJECTED",
+ rejectedAt: currentDate,
+ rejectReason: rejectReason,
+ updatedAt: currentDate,
+ })
+ .where(eq(vendorPQSubmissions.id, pqSubmissionId));
+
+ // 6. 일반 PQ인 경우 벤더 상태를 PQ_FAILED로 업데이트
+ if (pqSubmission.type === "GENERAL") {
+ await db
+ .update(vendors)
+ .set({
+ status: "PQ_FAILED",
+ updatedAt: currentDate,
+ })
+ .where(eq(vendors.id, vendorId));
+ }
+
+ // 7. 벤더에게 이메일 알림 발송
+ if (vendor.email) {
+ try {
+ const emailSubject = pqSubmission.projectId
+ ? `[eVCP] Project PQ Rejected for ${projectName}`
+ : "[eVCP] General PQ Rejected";
+
+ const portalUrl = `${host}/partners/pq`;
+
+ await sendEmail({
+ to: vendor.email,
+ subject: emailSubject,
+ template: "pq-rejected-vendor",
+ context: {
+ vendorName: vendor.vendorName,
+ projectId: pqSubmission.projectId,
+ projectName: projectName,
+ isProjectPQ: !!pqSubmission.projectId,
+ rejectedDate: currentDate.toLocaleString(),
+ rejectReason: rejectReason,
+ portalUrl,
+ }
+ });
+ } catch (emailError) {
+ console.error("Failed to send vendor notification:", emailError);
+ }
+ }
+
+ // 8. 캐시 무효화
+ revalidateTag("vendors");
+ revalidateTag("vendor-status-counts");
+ revalidateTag("pq-submissions");
+ revalidateTag("vendor-pq-submissions");
+ revalidatePath("/evcp/pq_new");
+
+ return { ok: true };
+ } catch (error) {
+ console.error("QM review reject error:", error);
+ return { ok: false, error: getErrorMessage(error) };
+ }
+}
+
// PQ 거부 액션
export async function rejectPQAction({
pqSubmissionId,
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 업로드를 위한 필드 }) |
