diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-10-29 07:43:44 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-10-29 07:43:44 +0000 |
| commit | 2eb717eb2bbfd97a5f149d13049aa336c26c393b (patch) | |
| tree | 274283b7759bfba619e6d143edccf3845ba45ed6 /components | |
| parent | bfc26491991997b5b109af6ea6bc75a8be138e9a (diff) | |
(최겸) 구매 실사 개발(진행중)
Diffstat (limited to 'components')
| -rw-r--r-- | components/investigation/supplement-request-dialog.tsx | 336 | ||||
| -rw-r--r-- | components/investigation/supplement-response-dialog.tsx | 483 | ||||
| -rw-r--r-- | components/pq-input/pq-input-tabs.tsx | 26 | ||||
| -rw-r--r-- | components/pq-input/pq-review-wrapper.tsx | 260 |
4 files changed, 1067 insertions, 38 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> |
