summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-10-29 07:43:44 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-10-29 07:43:44 +0000
commit2eb717eb2bbfd97a5f149d13049aa336c26c393b (patch)
tree274283b7759bfba619e6d143edccf3845ba45ed6
parentbfc26491991997b5b109af6ea6bc75a8be138e9a (diff)
(최겸) 구매 실사 개발(진행중)
-rw-r--r--components/investigation/supplement-request-dialog.tsx336
-rw-r--r--components/investigation/supplement-response-dialog.tsx483
-rw-r--r--components/pq-input/pq-input-tabs.tsx26
-rw-r--r--components/pq-input/pq-review-wrapper.tsx260
-rw-r--r--config/vendorInvestigationsColumnsConfig.ts4
-rw-r--r--db/schema/pq.ts32
-rw-r--r--i18n/locales/en/menu.json5
-rw-r--r--i18n/locales/ko/menu.json8
-rw-r--r--lib/pq/pq-review-table-new/vendors-table-columns.tsx12
-rw-r--r--lib/pq/service.ts310
-rw-r--r--lib/vendor-investigation/service.ts516
-rw-r--r--lib/vendor-investigation/table/investigation-progress-sheet.tsx324
-rw-r--r--lib/vendor-investigation/table/investigation-result-sheet.tsx808
-rw-r--r--lib/vendor-investigation/table/investigation-table-columns.tsx110
-rw-r--r--lib/vendor-investigation/table/investigation-table.tsx51
-rw-r--r--lib/vendor-investigation/table/update-investigation-sheet.tsx144
-rw-r--r--lib/vendor-investigation/validations.ts53
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 업로드를 위한 필드
})