feat:update form
This commit is contained in:
+260
-14
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import React, { useRef, useState, useEffect, useMemo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Card,
|
||||
@@ -92,18 +92,31 @@ interface Question {
|
||||
required: boolean;
|
||||
options: string[] | null;
|
||||
allowOther: boolean;
|
||||
imageAnswerMode: "none" | "optional" | "required";
|
||||
}
|
||||
|
||||
type AnswerDraft = {
|
||||
value: FormAnswerValue;
|
||||
imageCdnId: string | null;
|
||||
};
|
||||
|
||||
interface FormFillerClientProps {
|
||||
form: {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
isOpen: boolean;
|
||||
allowMultipleSubmissions: boolean;
|
||||
};
|
||||
questions: Question[];
|
||||
initialAnswers?: Record<string, FormAnswerValue>;
|
||||
initialAnswers?: Record<string, AnswerDraft>;
|
||||
hasSubmission?: boolean;
|
||||
selectedSubmissionId?: string | null;
|
||||
ownSubmissions?: {
|
||||
id: string;
|
||||
submittedAt: string;
|
||||
answers: Record<string, AnswerDraft>;
|
||||
}[];
|
||||
totalAnswers: number;
|
||||
queueNumber: number | null;
|
||||
preview?: boolean;
|
||||
@@ -115,6 +128,7 @@ type StoredDraft = {
|
||||
version: 1;
|
||||
answers: FormValues;
|
||||
otherValues: Record<string, string>;
|
||||
imageAnswers?: Record<string, string | null>;
|
||||
};
|
||||
|
||||
const buildSchema = (questions: Question[]) => {
|
||||
@@ -133,16 +147,26 @@ const buildSchema = (questions: Question[]) => {
|
||||
return z.object(shape);
|
||||
};
|
||||
|
||||
function isImageOnlyQuestion(question: Question) {
|
||||
return (
|
||||
question.imageAnswerMode === "required" &&
|
||||
!question.required &&
|
||||
(question.type === "text" || question.type === "textarea")
|
||||
);
|
||||
}
|
||||
|
||||
function prepareInitialState(
|
||||
questions: Question[],
|
||||
answers: Record<string, FormAnswerValue | undefined>,
|
||||
answers: Record<string, AnswerDraft | undefined>,
|
||||
savedOtherValues: Record<string, string> = {}
|
||||
) {
|
||||
const values: FormValues = {};
|
||||
const imageAnswers: Record<string, string | null> = {};
|
||||
const otherValues = { ...savedOtherValues };
|
||||
|
||||
questions.forEach((question) => {
|
||||
const answer = answers[question.id];
|
||||
const answer = answers[question.id]?.value;
|
||||
imageAnswers[question.id] = answers[question.id]?.imageCdnId ?? null;
|
||||
|
||||
if (question.type === "checkbox") {
|
||||
let selected = decodeCheckboxAnswer(answer);
|
||||
@@ -182,12 +206,13 @@ function prepareInitialState(
|
||||
values[question.id] = typeof answer === "string" ? answer : "";
|
||||
});
|
||||
|
||||
return { values, otherValues };
|
||||
return { values, otherValues, imageAnswers };
|
||||
}
|
||||
|
||||
function parseStoredDraft(raw: string): {
|
||||
answers: FormValues;
|
||||
otherValues: Record<string, string>;
|
||||
imageAnswers: Record<string, string | null>;
|
||||
} {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (
|
||||
@@ -201,12 +226,14 @@ function parseStoredDraft(raw: string): {
|
||||
return {
|
||||
answers: draft.answers ?? {},
|
||||
otherValues: draft.otherValues ?? {},
|
||||
imageAnswers: draft.imageAnswers ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
answers: (parsed as FormValues) ?? {},
|
||||
otherValues: {},
|
||||
imageAnswers: {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -215,12 +242,17 @@ export function FormFillerClient({
|
||||
questions,
|
||||
initialAnswers = {},
|
||||
hasSubmission = false,
|
||||
selectedSubmissionId: initialSelectedSubmissionId = null,
|
||||
ownSubmissions = [],
|
||||
totalAnswers: initialTotalAnswers,
|
||||
queueNumber: initialQueueNumber,
|
||||
preview = false,
|
||||
}: FormFillerClientProps) {
|
||||
const router = useRouter();
|
||||
const isUpdating = hasSubmission;
|
||||
const [selectedSubmissionId, setSelectedSubmissionId] = useState<string | null>(
|
||||
initialSelectedSubmissionId
|
||||
);
|
||||
const isUpdating = Boolean(selectedSubmissionId) || (!form.allowMultipleSubmissions && hasSubmission);
|
||||
|
||||
const formSchema = useMemo(() => buildSchema(questions), [questions]);
|
||||
const serverInitialState = useMemo(
|
||||
@@ -231,6 +263,12 @@ export function FormFillerClient({
|
||||
const [otherValues, setOtherValues] = useState<Record<string, string>>(
|
||||
serverInitialState.otherValues
|
||||
);
|
||||
const [imageAnswers, setImageAnswers] = useState<Record<string, string | null>>(
|
||||
serverInitialState.imageAnswers
|
||||
);
|
||||
const [imageErrors, setImageErrors] = useState<Record<string, string | null>>({});
|
||||
const imageInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||
const [uploadingAnswerId, setUploadingAnswerId] = useState<string | null>(null);
|
||||
const [draftReady, setDraftReady] = useState(preview);
|
||||
const [draftSaved, setDraftSaved] = useState(false);
|
||||
const [showSuccessDialog, setShowSuccessDialog] = useState(false);
|
||||
@@ -270,11 +308,17 @@ export function FormFillerClient({
|
||||
const draft = parseStoredDraft(raw);
|
||||
const restored = prepareInitialState(
|
||||
questions,
|
||||
draft.answers,
|
||||
Object.fromEntries(
|
||||
Object.entries(draft.answers).map(([questionId, value]) => [
|
||||
questionId,
|
||||
{ value: value as FormAnswerValue, imageCdnId: draft.imageAnswers[questionId] ?? null },
|
||||
])
|
||||
),
|
||||
draft.otherValues
|
||||
);
|
||||
reset(restored.values);
|
||||
setOtherValues(restored.otherValues);
|
||||
setImageAnswers(restored.imageAnswers);
|
||||
setDraftSaved(true);
|
||||
}
|
||||
} catch {
|
||||
@@ -297,13 +341,94 @@ export function FormFillerClient({
|
||||
version: 1,
|
||||
answers: getValues() as FormValues,
|
||||
otherValues,
|
||||
imageAnswers,
|
||||
};
|
||||
localStorage.setItem(LS_KEY(form.id), JSON.stringify(draft));
|
||||
queueMicrotask(() => setDraftSaved(true));
|
||||
} catch {
|
||||
queueMicrotask(() => setDraftSaved(false));
|
||||
}
|
||||
}, [draftReady, form.id, form.isOpen, getValues, otherValues, preview, watchedValues]);
|
||||
}, [draftReady, form.id, form.isOpen, getValues, imageAnswers, otherValues, preview, watchedValues]);
|
||||
|
||||
const switchToSubmission = (submissionId: string | null) => {
|
||||
const submission = submissionId
|
||||
? ownSubmissions.find((item) => item.id === submissionId)
|
||||
: null;
|
||||
const nextState = prepareInitialState(questions, submission?.answers ?? {});
|
||||
reset(nextState.values);
|
||||
setOtherValues(nextState.otherValues);
|
||||
setImageAnswers(nextState.imageAnswers);
|
||||
setImageErrors({});
|
||||
Object.values(imageInputRefs.current).forEach((input) => {
|
||||
if (input) input.value = "";
|
||||
});
|
||||
setSelectedSubmissionId(submissionId);
|
||||
setDraftSaved(false);
|
||||
};
|
||||
|
||||
const clearAnswerImage = (questionId: string) => {
|
||||
setImageAnswers((current) => ({
|
||||
...current,
|
||||
[questionId]: null,
|
||||
}));
|
||||
setImageErrors((current) => ({
|
||||
...current,
|
||||
[questionId]: null,
|
||||
}));
|
||||
const input = imageInputRefs.current[questionId];
|
||||
if (input) input.value = "";
|
||||
};
|
||||
|
||||
const validateRequiredImages = () => {
|
||||
const nextErrors: Record<string, string | null> = {};
|
||||
let firstInvalidId: string | null = null;
|
||||
|
||||
for (const question of questions) {
|
||||
if (question.imageAnswerMode === "required" && !imageAnswers[question.id]) {
|
||||
nextErrors[question.id] = "จำเป็นต้องอัปโหลดรูปสำหรับข้อนี้";
|
||||
firstInvalidId ??= question.id;
|
||||
} else {
|
||||
nextErrors[question.id] = null;
|
||||
}
|
||||
}
|
||||
|
||||
setImageErrors((current) => ({ ...current, ...nextErrors }));
|
||||
if (firstInvalidId) {
|
||||
document.getElementById(`answer-image-${firstInvalidId}`)?.focus();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleAnswerImageUpload = async (
|
||||
questionId: string,
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
setUploadingAnswerId(questionId);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/upload", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.id) {
|
||||
throw new Error(data.error || "ไม่สามารถอัปโหลดรูปได้");
|
||||
}
|
||||
setImageAnswers((current) => ({ ...current, [questionId]: data.id }));
|
||||
setImageErrors((current) => ({ ...current, [questionId]: null }));
|
||||
toast.success("อัปโหลดรูปแล้ว");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "ไม่สามารถอัปโหลดรูปได้"));
|
||||
} finally {
|
||||
setUploadingAnswerId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||
if (preview) return;
|
||||
@@ -328,6 +453,10 @@ export function FormFillerClient({
|
||||
}
|
||||
}
|
||||
|
||||
if (!validateRequiredImages()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formattedAnswers = questions.map((q) => {
|
||||
let val = values[q.id] as FormAnswerValue | undefined;
|
||||
@@ -342,10 +471,11 @@ export function FormFillerClient({
|
||||
return {
|
||||
questionId: q.id,
|
||||
value: val ?? (q.type === "checkbox" ? [] : ""),
|
||||
imageCdnId: q.imageAnswerMode === "none" ? null : imageAnswers[q.id] ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
const res = await submitForm(form.id, formattedAnswers);
|
||||
const res = await submitForm(form.id, formattedAnswers, selectedSubmissionId);
|
||||
if (res.ok) {
|
||||
setTotalAnswers(res.totalAnswers);
|
||||
setQueueNumber(res.queueNumber);
|
||||
@@ -362,15 +492,21 @@ export function FormFillerClient({
|
||||
}
|
||||
};
|
||||
|
||||
const onInvalidSubmit = () => {
|
||||
validateRequiredImages();
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const res = await deleteOwnSubmission(form.id);
|
||||
const res = await deleteOwnSubmission(form.id, selectedSubmissionId);
|
||||
if (res.ok) {
|
||||
toast.success("ลบคำตอบแล้ว");
|
||||
localStorage.removeItem(LS_KEY(form.id));
|
||||
reset({});
|
||||
setOtherValues({});
|
||||
setImageAnswers({});
|
||||
setSelectedSubmissionId(null);
|
||||
setDraftSaved(false);
|
||||
setDeleteDialogOpen(false);
|
||||
router.refresh();
|
||||
@@ -405,7 +541,7 @@ export function FormFillerClient({
|
||||
<span>{questions.length} คำถาม</span>
|
||||
<span>{totalAnswers} คำตอบทั้งหมด</span>
|
||||
{queueNumber !== null && (
|
||||
<Badge variant="outline">คิวของคุณ #{queueNumber}</Badge>
|
||||
<Badge variant="outline">ลำดับของคุณ #{queueNumber}</Badge>
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
@@ -439,8 +575,39 @@ export function FormFillerClient({
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{form.allowMultipleSubmissions && ownSubmissions.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">คำตอบของคุณ</CardTitle>
|
||||
<CardDescription>เลือกคำตอบเดิมเพื่อแก้ไข หรือเริ่มส่งคำตอบใหม่</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={selectedSubmissionId ? "outline" : "default"}
|
||||
onClick={() => switchToSubmission(null)}
|
||||
>
|
||||
คำตอบใหม่
|
||||
</Button>
|
||||
{ownSubmissions.map((submission, index) => (
|
||||
<Button
|
||||
key={submission.id}
|
||||
type="button"
|
||||
variant={selectedSubmissionId === submission.id ? "default" : "outline"}
|
||||
onClick={() => switchToSubmission(submission.id)}
|
||||
>
|
||||
#{ownSubmissions.length - index} · {new Date(submission.submittedAt).toLocaleString("th-TH", {
|
||||
timeZone: "Asia/Bangkok",
|
||||
hour12: false,
|
||||
})}
|
||||
</Button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Form {...methods}>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||
<form onSubmit={handleSubmit(onSubmit, onInvalidSubmit)} className="flex flex-col gap-6">
|
||||
{questions.length === 0 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -519,7 +686,7 @@ export function FormFillerClient({
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FormItem>
|
||||
{q.type === "text" && (
|
||||
{q.type === "text" && !isImageOnlyQuestion(q) && (
|
||||
<Field data-invalid={fieldState.invalid || undefined}>
|
||||
<FormControl
|
||||
aria-describedby={
|
||||
@@ -539,7 +706,7 @@ export function FormFillerClient({
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{q.type === "textarea" && (
|
||||
{q.type === "textarea" && !isImageOnlyQuestion(q) && (
|
||||
<Field data-invalid={fieldState.invalid || undefined}>
|
||||
<FormControl
|
||||
aria-describedby={
|
||||
@@ -753,6 +920,85 @@ export function FormFillerClient({
|
||||
<FormMessage id={questionErrorId} />
|
||||
</FieldSet>
|
||||
)}
|
||||
{q.imageAnswerMode !== "none" && (
|
||||
<Field data-invalid={Boolean(imageErrors[q.id]) || undefined}>
|
||||
<FieldLabel htmlFor={`answer-image-${q.id}`}>
|
||||
รูปภาพคำตอบ
|
||||
{q.imageAnswerMode === "required" && (
|
||||
<Badge variant="destructive">จำเป็น</Badge>
|
||||
)}
|
||||
{q.imageAnswerMode === "optional" && (
|
||||
<Badge variant="outline">ไม่บังคับ</Badge>
|
||||
)}
|
||||
</FieldLabel>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Input
|
||||
id={`answer-image-${q.id}`}
|
||||
ref={(input) => {
|
||||
imageInputRefs.current[q.id] = input;
|
||||
}}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
disabled={!form.isOpen || isSubmitting || uploadingAnswerId !== null}
|
||||
onChange={(event) => handleAnswerImageUpload(q.id, event)}
|
||||
className="max-w-sm"
|
||||
aria-invalid={Boolean(imageErrors[q.id])}
|
||||
aria-describedby={
|
||||
imageErrors[q.id]
|
||||
? `answer-image-error-${q.id}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{uploadingAnswerId === q.id && (
|
||||
<Badge variant="secondary">
|
||||
<Spinner data-icon="inline-start" />
|
||||
กำลังอัปโหลด
|
||||
</Badge>
|
||||
)}
|
||||
{imageAnswers[q.id] && (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="outline">
|
||||
ดูรูป
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogTitle>รูปภาพคำตอบ</DialogTitle>
|
||||
<Image
|
||||
src={`/cdn/${imageAnswers[q.id]}`}
|
||||
alt=""
|
||||
width={1200}
|
||||
height={800}
|
||||
unoptimized
|
||||
className="max-h-[70vh] w-full rounded-md object-contain"
|
||||
onError={(event) => {
|
||||
event.currentTarget.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
{imageAnswers[q.id] && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => clearAnswerImage(q.id)}
|
||||
disabled={!form.isOpen || isSubmitting}
|
||||
>
|
||||
ลบรูป
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{imageErrors[q.id] && (
|
||||
<p
|
||||
id={`answer-image-error-${q.id}`}
|
||||
className="text-sm text-destructive"
|
||||
>
|
||||
{imageErrors[q.id]}
|
||||
</p>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
</FormItem>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user