summaryrefslogtreecommitdiff
path: root/components/pq/pq-input-tabs.tsx
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-03-25 15:55:45 +0900
committerjoonhoekim <26rote@gmail.com>2025-03-25 15:55:45 +0900
commit1a2241c40e10193c5ff7008a7b7b36cc1d855d96 (patch)
tree8a5587f10ca55b162d7e3254cb088b323a34c41b /components/pq/pq-input-tabs.tsx
initial commit
Diffstat (limited to 'components/pq/pq-input-tabs.tsx')
-rw-r--r--components/pq/pq-input-tabs.tsx780
1 files changed, 780 insertions, 0 deletions
diff --git a/components/pq/pq-input-tabs.tsx b/components/pq/pq-input-tabs.tsx
new file mode 100644
index 00000000..743e1729
--- /dev/null
+++ b/components/pq/pq-input-tabs.tsx
@@ -0,0 +1,780 @@
+"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 from shadcn/ui
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+} from "@/components/ui/dialog"
+
+// Additional UI
+import { Separator } from "../ui/separator"
+
+// Server actions (adjust to your actual code)
+import {
+ uploadFileAction,
+ savePQAnswersAction,
+ submitPQAction,
+} from "@/lib/pq/service"
+import { PQGroupData } from "@/lib/pq/service"
+
+// ----------------------------------------------------------------------
+// 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,
+}: {
+ data: PQGroupData[]
+ vendorId: number
+}) {
+ 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()
+
+ // ----------------------------------------------------------------------
+ // 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,
+ 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)
+ if (result.ok) {
+ toast({
+ title: "PQ Submitted",
+ description: "Your PQ information has been submitted successfully",
+ })
+ // Optionally redirect
+ } 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)
+ }
+ }
+
+ // ----------------------------------------------------------------------
+ // H) Render
+ // ----------------------------------------------------------------------
+ return (
+ <Form {...form}>
+ <form>
+ <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 } = 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>
+ {/* Answer Field */}
+ <CardHeader className="pt-0 pb-3">
+ <FormField
+ control={form.control}
+ name={`answers.${answerIndex}.answer`}
+ render={({ field }) => (
+ <FormItem className="mt-3">
+ <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>
+ )}
+ />
+ </CardHeader>
+
+ {/* Attachments / Dropzone */}
+ <CardContent>
+ <div className="grid gap-2">
+ <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>
+ Review your answers before final submission.
+ </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