diff options
Diffstat (limited to 'components/pq/pq-input-tabs.tsx')
| -rw-r--r-- | components/pq/pq-input-tabs.tsx | 884 |
1 files changed, 0 insertions, 884 deletions
diff --git a/components/pq/pq-input-tabs.tsx b/components/pq/pq-input-tabs.tsx deleted file mode 100644 index d72eff92..00000000 --- a/components/pq/pq-input-tabs.tsx +++ /dev/null @@ -1,884 +0,0 @@ -"use client" - -import * as React from "react" -import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import { z } from "zod" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { - Card, - CardHeader, - CardTitle, - CardDescription, - CardContent, -} from "@/components/ui/card" -import { Button } from "@/components/ui/button" -import { Textarea } from "@/components/ui/textarea" -import { X, Save, CheckCircle2, AlertTriangle, ChevronsUpDown } from "lucide-react" -import prettyBytes from "pretty-bytes" -import { useToast } from "@/hooks/use-toast" -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible" - -// Form components -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, - FormDescription, -} from "@/components/ui/form" - -// Custom Dropzone, FileList components -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" - -// Dialog components -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - DialogDescription, -} from "@/components/ui/dialog" - -// Additional UI -import { Separator } from "@/components/ui/separator" -import { Badge } from "@/components/ui/badge" - -// Server actions -import { - uploadFileAction, - savePQAnswersAction, - submitPQAction, - ProjectPQ, -} from "@/lib/pq/service" -import { PQGroupData } from "@/lib/pq/service" -import { useRouter } from "next/navigation" - -// ---------------------------------------------------------------------- -// 1) Define client-side file shapes -// ---------------------------------------------------------------------- -interface UploadedFileState { - fileName: string - url: string - size?: number -} - -interface LocalFileState { - fileObj: File - uploaded: boolean -} - -// ---------------------------------------------------------------------- -// 2) Zod schema for the entire form -// ---------------------------------------------------------------------- -const pqFormSchema = z.object({ - answers: z.array( - z.object({ - criteriaId: z.number(), - // Must have at least 1 char - answer: z.string().min(1, "Answer is required"), - - // Existing, uploaded files - uploadedFiles: z - .array( - z.object({ - fileName: z.string(), - url: z.string(), - size: z.number().optional(), - }) - ) - .min(1, "At least one file attachment is required"), - - // Local (not-yet-uploaded) files - newUploads: z.array( - z.object({ - fileObj: z.any(), - uploaded: z.boolean().default(false), - }) - ), - - // track saved state - saved: z.boolean().default(false), - }) - ), -}) - -type PQFormValues = z.infer<typeof pqFormSchema> - -// ---------------------------------------------------------------------- -// 3) Main Component: PQInputTabs -// ---------------------------------------------------------------------- -export function PQInputTabs({ - data, - vendorId, - projectId, - projectData, -}: { - data: PQGroupData[] - vendorId: number - projectId?: number - projectData?: ProjectPQ | null -}) { - const [isSaving, setIsSaving] = React.useState(false) - const [isSubmitting, setIsSubmitting] = React.useState(false) - const [allSaved, setAllSaved] = React.useState(false) - const [showConfirmDialog, setShowConfirmDialog] = React.useState(false) - - const { toast } = useToast() - const router = useRouter() - // ---------------------------------------------------------------------- - // A) Create initial form values - // Mark items as "saved" if they have existing answer or attachments - // ---------------------------------------------------------------------- - function createInitialFormValues(): PQFormValues { - const answers: PQFormValues["answers"] = [] - - data.forEach((group) => { - group.items.forEach((item) => { - // Check if the server item is already "complete" - const hasExistingAnswer = item.answer && item.answer.trim().length > 0 - const hasExistingAttachments = item.attachments && item.attachments.length > 0 - - // If either is present, we consider it "saved" initially - const isAlreadySaved = hasExistingAnswer || hasExistingAttachments - - answers.push({ - criteriaId: item.criteriaId, - answer: item.answer || "", - uploadedFiles: item.attachments.map((attach) => ({ - fileName: attach.fileName, - url: attach.filePath, - size: attach.fileSize, - })), - newUploads: [], - saved: isAlreadySaved, - }) - }) - }) - - return { answers } - } - - // ---------------------------------------------------------------------- - // B) Set up react-hook-form - // ---------------------------------------------------------------------- - const form = useForm<PQFormValues>({ - resolver: zodResolver(pqFormSchema), - defaultValues: createInitialFormValues(), - mode: "onChange", - }) - - // ---------------------------------------------------------------------- - // C) Track if all items are saved => controls Submit PQ button - // ---------------------------------------------------------------------- - React.useEffect(() => { - const values = form.getValues() - // We consider items "saved" if `saved===true` AND they have an answer or attachments - const allItemsSaved = values.answers.every( - (answer) => answer.saved && (answer.answer || answer.uploadedFiles.length > 0) - ) - setAllSaved(allItemsSaved) - }, [form.watch()]) - - // Helper to find the array index by criteriaId - const getAnswerIndex = (criteriaId: number): number => { - return form.getValues().answers.findIndex((a) => a.criteriaId === criteriaId) - } - - // ---------------------------------------------------------------------- - // D) Handling File Drops, Removal - // ---------------------------------------------------------------------- - const handleDropAccepted = (criteriaId: number, files: File[]) => { - const answerIndex = getAnswerIndex(criteriaId) - if (answerIndex === -1) return - - // Convert each dropped file into a LocalFileState - const newLocalFiles: LocalFileState[] = files.map((f) => ({ - fileObj: f, - uploaded: false, - })) - - const current = form.getValues(`answers.${answerIndex}.newUploads`) - form.setValue(`answers.${answerIndex}.newUploads`, [...current, ...newLocalFiles], { - shouldDirty: true, - }) - - // Mark unsaved - form.setValue(`answers.${answerIndex}.saved`, false, { shouldDirty: true }) - } - - const handleDropRejected = () => { - toast({ - title: "File upload rejected", - description: "Please check file size and type.", - variant: "destructive", - }) - } - - const removeNewUpload = (answerIndex: number, fileIndex: number) => { - const current = [...form.getValues(`answers.${answerIndex}.newUploads`)] - current.splice(fileIndex, 1) - form.setValue(`answers.${answerIndex}.newUploads`, current, { shouldDirty: true }) - - form.setValue(`answers.${answerIndex}.saved`, false, { shouldDirty: true }) - } - - const removeUploadedFile = (answerIndex: number, fileIndex: number) => { - const current = [...form.getValues(`answers.${answerIndex}.uploadedFiles`)] - current.splice(fileIndex, 1) - form.setValue(`answers.${answerIndex}.uploadedFiles`, current, { shouldDirty: true }) - - form.setValue(`answers.${answerIndex}.saved`, false, { shouldDirty: true }) - } - - // ---------------------------------------------------------------------- - // E) Saving a Single Item - // ---------------------------------------------------------------------- - const handleSaveItem = async (answerIndex: number) => { - try { - const answerData = form.getValues(`answers.${answerIndex}`) - - // Validation - if (!answerData.answer) { - toast({ - title: "Validation Error", - description: "Answer is required", - variant: "destructive", - }) - return - } - - // Upload new files (if any) - if (answerData.newUploads.length > 0) { - setIsSaving(true) - - for (const localFile of answerData.newUploads) { - try { - const uploadResult = await uploadFileAction(localFile.fileObj) - const currentUploaded = form.getValues(`answers.${answerIndex}.uploadedFiles`) - currentUploaded.push({ - fileName: uploadResult.fileName, - url: uploadResult.url, - size: uploadResult.size, - }) - form.setValue(`answers.${answerIndex}.uploadedFiles`, currentUploaded, { - shouldDirty: true, - }) - } catch (error) { - console.error("File upload error:", error) - toast({ - title: "Upload Error", - description: "Failed to upload file", - variant: "destructive", - }) - } - } - - // Clear newUploads - form.setValue(`answers.${answerIndex}.newUploads`, [], { shouldDirty: true }) - } - - // Save to DB - const updatedAnswer = form.getValues(`answers.${answerIndex}`) - const saveResult = await savePQAnswersAction({ - vendorId, - projectId, // 프로젝트 ID 전달 - answers: [ - { - criteriaId: updatedAnswer.criteriaId, - answer: updatedAnswer.answer, - attachments: updatedAnswer.uploadedFiles.map((f) => ({ - fileName: f.fileName, - url: f.url, - size: f.size, - })), - }, - ], - }) - - if (saveResult.ok) { - // Mark as saved - form.setValue(`answers.${answerIndex}.saved`, true, { shouldDirty: false }) - toast({ - title: "Saved", - description: "Item saved successfully", - }) - } - } catch (error) { - console.error("Save error:", error) - toast({ - title: "Save Error", - description: "Failed to save item", - variant: "destructive", - }) - } finally { - setIsSaving(false) - } - } - - // For convenience - const answers = form.getValues().answers - const dirtyFields = form.formState.dirtyFields.answers - - // Check if any item is dirty or has new uploads - const isAnyItemDirty = answers.some((answer, i) => { - const itemDirty = !!dirtyFields?.[i] - const hasNewUploads = answer.newUploads.length > 0 - return itemDirty || hasNewUploads - }) - - // ---------------------------------------------------------------------- - // F) Save All Items - // ---------------------------------------------------------------------- - const handleSaveAll = async () => { - try { - setIsSaving(true) - const answers = form.getValues().answers - - // Only save items that are dirty or have new uploads - for (let i = 0; i < answers.length; i++) { - const itemDirty = !!dirtyFields?.[i] - const hasNewUploads = answers[i].newUploads.length > 0 - if (!itemDirty && !hasNewUploads) continue - - await handleSaveItem(i) - } - - toast({ - title: "All Saved", - description: "All items saved successfully", - }) - } catch (error) { - console.error("Save all error:", error) - toast({ - title: "Save Error", - description: "Failed to save all items", - variant: "destructive", - }) - } finally { - setIsSaving(false) - } - } - - // ---------------------------------------------------------------------- - // G) Submission with Confirmation Dialog - // ---------------------------------------------------------------------- - const handleSubmitPQ = () => { - if (!allSaved) { - toast({ - title: "Cannot Submit", - description: "Please save all items before submitting", - variant: "destructive", - }) - return - } - setShowConfirmDialog(true) - } - - const handleConfirmSubmission = async () => { - try { - setIsSubmitting(true) - setShowConfirmDialog(false) - - const result = await submitPQAction({ - vendorId, - projectId, // 프로젝트 ID 전달 - }) - - if (result.ok) { - toast({ - title: "PQ Submitted", - description: "Your PQ information has been submitted successfully", - }) - // 제출 후 페이지 새로고침 또는 리디렉션 처리 - router.refresh() - // window.location.reload() - } else { - toast({ - title: "Submit Error", - description: result.error || "Failed to submit PQ", - variant: "destructive", - }) - } - } catch (error) { - console.error("Submit error:", error) - toast({ - title: "Submit Error", - description: "Failed to submit PQ information", - variant: "destructive", - }) - } finally { - setIsSubmitting(false) - } - } - - // 프로젝트 정보 표시 섹션 - const renderProjectInfo = () => { - if (!projectData) return null; - - return ( - <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> - - <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> - <div> - <p className="text-sm font-medium text-muted-foreground">프로젝트 코드</p> - <p>{projectData.projectCode}</p> - </div> - <div> - <p className="text-sm font-medium text-muted-foreground">프로젝트명</p> - <p>{projectData.projectName}</p> - </div> - {projectData.submittedAt && ( - <div className="col-span-1 md:col-span-2"> - <p className="text-sm font-medium text-muted-foreground">제출일</p> - <p>{formatDate(projectData.submittedAt)}</p> - </div> - )} - </div> - </div> - ); - }; - - // 상태 표시용 함수 - const getStatusLabel = (status: string) => { - switch (status) { - case "REQUESTED": return "요청됨"; - case "IN_PROGRESS": return "진행중"; - case "SUBMITTED": return "제출됨"; - case "APPROVED": return "승인됨"; - case "REJECTED": return "반려됨"; - default: return status; - } - }; - - const getStatusVariant = (status: string) => { - switch (status) { - case "REQUESTED": return "secondary"; - case "IN_PROGRESS": return "default"; - case "SUBMITTED": return "outline"; - case "APPROVED": return "outline"; - case "REJECTED": return "destructive"; - default: return "secondary"; - } - }; - - // 날짜 형식화 함수 - const formatDate = (date: Date) => { - if (!date) return "-"; - return new Date(date).toLocaleDateString("ko-KR", { - year: "numeric", - month: "long", - day: "numeric", - }); - }; - - // ---------------------------------------------------------------------- - // H) Render - // ---------------------------------------------------------------------- - return ( - <Form {...form}> - <form> - {/* 프로젝트 정보 섹션 */} - {renderProjectInfo()} - - <Tabs defaultValue={data[0]?.groupName || ""} className="w-full"> - {/* Top Controls */} - <div className="flex justify-between items-center mb-4"> - <TabsList className="grid grid-cols-4"> - {data.map((group) => ( - <TabsTrigger - key={group.groupName} - value={group.groupName} - className="truncate" - > - <div className="flex items-center gap-2"> - {/* Mobile: truncated version */} - <span className="block sm:hidden"> - {group.groupName.length > 5 - ? group.groupName.slice(0, 5) + "..." - : group.groupName} - </span> - {/* Desktop: full text */} - <span className="hidden sm:block">{group.groupName}</span> - <span className="inline-flex items-center justify-center h-5 min-w-5 px-1 rounded-full bg-muted text-xs font-medium"> - {group.items.length} - </span> - </div> - </TabsTrigger> - ))} - </TabsList> - - <div className="flex gap-2"> - {/* Save All button */} - <Button - type="button" - variant="outline" - disabled={isSaving || !isAnyItemDirty} - onClick={handleSaveAll} - > - {isSaving ? "Saving..." : "Save All"} - <Save className="ml-2 h-4 w-4" /> - </Button> - - {/* Submit PQ button */} - <Button - type="button" - disabled={!allSaved || isSubmitting} - onClick={handleSubmitPQ} - > - {isSubmitting ? "Submitting..." : "Submit PQ"} - <CheckCircle2 className="ml-2 h-4 w-4" /> - </Button> - </div> - </div> - - {/* Render each group */} - {data.map((group) => ( - <TabsContent key={group.groupName} value={group.groupName}> - {/* 2-column grid */} - <div className="grid grid-cols-1 md:grid-cols-2 gap-4 pb-4"> - {group.items.map((item) => { - const { criteriaId, code, checkPoint, description, contractInfo, additionalRequirement } = item - const answerIndex = getAnswerIndex(criteriaId) - if (answerIndex === -1) return null - - const isSaved = form.watch(`answers.${answerIndex}.saved`) - const hasAnswer = form.watch(`answers.${answerIndex}.answer`) - const newUploads = form.watch(`answers.${answerIndex}.newUploads`) - const dirtyFieldsItem = form.formState.dirtyFields.answers?.[answerIndex] - - const isItemDirty = !!dirtyFieldsItem - const hasNewUploads = newUploads.length > 0 - const canSave = isItemDirty || hasNewUploads - - // For "Not Saved" vs. "Saved" status label - const hasUploads = - form.watch(`answers.${answerIndex}.uploadedFiles`).length > 0 || - newUploads.length > 0 - const isValid = !!hasAnswer || hasUploads - - return ( - <Collapsible key={criteriaId} defaultOpen={!isSaved} className="w-full"> - <Card className={isSaved ? "border-green-200" : ""}> - <CardHeader className="pb-1"> - <div className="flex justify-between"> - <div className="flex-1"> - <div className="flex items-center gap-2"> - <CollapsibleTrigger asChild> - <Button variant="ghost" size="sm" className="p-0 h-7 w-7"> - <ChevronsUpDown className="h-4 w-4" /> - <span className="sr-only">Toggle</span> - </Button> - </CollapsibleTrigger> - <CardTitle className="text-md"> - {code} - {checkPoint} - </CardTitle> - </div> - {description && ( - <CardDescription className="mt-1 whitespace-pre-wrap"> - {description} - </CardDescription> - )} - </div> - - {/* Save Status & Button */} - <div className="flex items-center gap-2"> - {!isSaved && canSave && ( - <span className="text-amber-600 text-xs flex items-center"> - <AlertTriangle className="h-4 w-4 mr-1" /> - Not Saved - </span> - )} - {isSaved && ( - <span className="text-green-600 text-xs flex items-center"> - <CheckCircle2 className="h-4 w-4 mr-1" /> - Saved - </span> - )} - - <Button - size="sm" - variant="outline" - disabled={isSaving || !canSave} - onClick={() => handleSaveItem(answerIndex)} - > - Save - </Button> - </div> - </div> - </CardHeader> - - <CollapsibleContent> - <CardContent className="pt-3 space-y-3"> - {/* 프로젝트별 추가 필드 (contractInfo, additionalRequirement) */} - {projectId && contractInfo && ( - <div className="space-y-1"> - <FormLabel className="text-sm font-medium">계약 정보</FormLabel> - <div className="rounded-md bg-muted/30 p-3 text-sm whitespace-pre-wrap"> - {contractInfo} - </div> - </div> - )} - - {projectId && additionalRequirement && ( - <div className="space-y-1"> - <FormLabel className="text-sm font-medium">추가 요구사항</FormLabel> - <div className="rounded-md bg-muted/30 p-3 text-sm whitespace-pre-wrap"> - {additionalRequirement} - </div> - </div> - )} - - {/* Answer Field */} - <FormField - control={form.control} - name={`answers.${answerIndex}.answer`} - render={({ field }) => ( - <FormItem className="mt-2"> - <FormLabel>Answer</FormLabel> - <FormControl> - <Textarea - {...field} - className="min-h-24" - placeholder="Enter your answer here" - onChange={(e) => { - field.onChange(e) - form.setValue( - `answers.${answerIndex}.saved`, - false, - { shouldDirty: true } - ) - }} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - - {/* Attachments / Dropzone */} - <div className="grid gap-2 mt-3"> - <FormLabel>Attachments</FormLabel> - <Dropzone - maxSize={6e8} // 600MB - onDropAccepted={(files) => - handleDropAccepted(criteriaId, files) - } - onDropRejected={handleDropRejected} - > - {() => ( - <FormItem> - <DropzoneZone className="flex justify-center h-24"> - <FormControl> - <DropzoneInput /> - </FormControl> - <div className="flex items-center gap-6"> - <DropzoneUploadIcon /> - <div className="grid gap-0.5"> - <DropzoneTitle>Drop files here</DropzoneTitle> - <DropzoneDescription> - Max size: 600MB - </DropzoneDescription> - </div> - </div> - </DropzoneZone> - <FormDescription> - Or click to browse files - </FormDescription> - <FormMessage /> - </FormItem> - )} - </Dropzone> - </div> - - {/* Existing + Pending Files */} - <div className="mt-4 space-y-4"> - {/* 1) Not-yet-uploaded files */} - {newUploads.length > 0 && ( - <div className="grid gap-2"> - <h6 className="text-sm font-medium"> - Pending Files ({newUploads.length}) - </h6> - <FileList> - {newUploads.map((f, fileIndex) => { - const fileObj = f.fileObj - if (!fileObj) return null - - return ( - <FileListItem key={fileIndex}> - <FileListHeader> - <FileListIcon /> - <FileListInfo> - <FileListName>{fileObj.name}</FileListName> - <FileListDescription> - {prettyBytes(fileObj.size)} - </FileListDescription> - </FileListInfo> - <FileListAction - onClick={() => - removeNewUpload(answerIndex, fileIndex) - } - > - <X className="h-4 w-4" /> - <span className="sr-only">Remove</span> - </FileListAction> - </FileListHeader> - </FileListItem> - ) - })} - </FileList> - </div> - )} - - {/* 2) Already uploaded files */} - {form - .watch(`answers.${answerIndex}.uploadedFiles`) - .map((file, fileIndex) => ( - <FileListItem key={fileIndex}> - <FileListHeader> - <FileListIcon /> - <FileListInfo> - <FileListName>{file.fileName}</FileListName> - {/* If you want to display the path: - <FileListDescription>{file.url}</FileListDescription> - */} - </FileListInfo> - {file.size && ( - <span className="text-xs text-muted-foreground"> - {prettyBytes(file.size)} - </span> - )} - <FileListAction - onClick={() => - removeUploadedFile(answerIndex, fileIndex) - } - > - <X className="h-4 w-4" /> - <span className="sr-only">Remove</span> - </FileListAction> - </FileListHeader> - </FileListItem> - ))} - </div> - </CardContent> - </CollapsibleContent> - </Card> - </Collapsible> - ) - })} - </div> - </TabsContent> - ))} - </Tabs> - </form> - - {/* Confirmation Dialog */} - <Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}> - <DialogContent> - <DialogHeader> - <DialogTitle>Confirm Submission</DialogTitle> - <DialogDescription> - {projectId - ? `${projectData?.projectCode} 프로젝트의 PQ 응답을 제출하시겠습니까?` - : "일반 PQ 응답을 제출하시겠습니까?" - } 제출 후에는 수정이 불가능합니다. - </DialogDescription> - </DialogHeader> - - <div className="space-y-4 max-h-[600px] overflow-y-auto "> - {data.map((group) => ( - <Collapsible key={group.groupName} defaultOpen> - <CollapsibleTrigger asChild> - <div className="flex justify-between items-center p-2 mb-1 cursor-pointer "> - <p className="font-semibold">{group.groupName}</p> - <ChevronsUpDown className="h-4 w-4 ml-2" /> - </div> - </CollapsibleTrigger> - - <CollapsibleContent> - {group.items.map((item) => { - const answerObj = form - .getValues() - .answers.find((a) => a.criteriaId === item.criteriaId) - - if (!answerObj) return null - - return ( - <div key={item.criteriaId} className="mb-2 p-2 ml-2 border rounded-md text-sm"> - {/* code & checkPoint */} - <p className="font-semibold"> - {item.code} - {item.checkPoint} - </p> - - {/* user's typed answer */} - <p className="text-sm font-medium mt-2">Answer:</p> - <p className="whitespace-pre-wrap text-sm"> - {answerObj.answer || "(no answer)"} - </p> - {/* attachments */} - <p>Attachments:</p> - {answerObj.uploadedFiles.length > 0 ? ( - <ul className="list-disc list-inside ml-4 text-xs"> - {answerObj.uploadedFiles.map((file, idx) => ( - <li key={idx}>{file.fileName}</li> - ))} - </ul> - ) : ( - <p className="text-xs text-muted-foreground">(none)</p> - )} - </div> - ) - })} - </CollapsibleContent> - </Collapsible> - ))} - </div> - - <DialogFooter> - <Button - variant="outline" - onClick={() => setShowConfirmDialog(false)} - disabled={isSubmitting} - > - Cancel - </Button> - <Button onClick={handleConfirmSubmission} disabled={isSubmitting}> - {isSubmitting ? "Submitting..." : "Confirm Submit"} - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - </Form> - ) -}
\ No newline at end of file |
