feat:update form

This commit is contained in:
2026-07-28 00:02:34 +07:00 Unverified
parent 0e8c816276
commit fa35f00f38
25 changed files with 919 additions and 149 deletions
+260 -14
View File
@@ -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>