summaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
Diffstat (limited to 'components')
-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
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>