feat: schedule automatic form closure

This commit is contained in:
2026-07-31 00:54:15 +07:00 Unverified
parent 8ee011cdfc
commit 6a73b60c48
20 changed files with 1226 additions and 35 deletions
+51 -16
View File
@@ -106,6 +106,7 @@ interface FormFillerClientProps {
title: string;
description: string | null;
isOpen: boolean;
closesAt: string | null;
allowMultipleSubmissions: boolean;
};
questions: Question[];
@@ -276,6 +277,40 @@ export function FormFillerClient({
const [isDeleting, setIsDeleting] = useState(false);
const [totalAnswers, setTotalAnswers] = useState(initialTotalAnswers);
const [queueNumber, setQueueNumber] = useState(initialQueueNumber);
const closesAtTime = form.closesAt
? new Date(form.closesAt).getTime()
: null;
const [reachedDeadline, setReachedDeadline] = useState<number | null>(null);
const deadlineReached =
closesAtTime !== null && reachedDeadline === closesAtTime;
const isAcceptingSubmissions =
preview || (form.isOpen && !deadlineReached);
useEffect(() => {
if (closesAtTime === null) return;
let timeoutId: number | undefined;
const checkDeadline = () => {
const remaining = closesAtTime - Date.now();
if (remaining <= 0) {
setReachedDeadline(closesAtTime);
return;
}
timeoutId = window.setTimeout(
checkDeadline,
Math.min(remaining, 2_147_483_647)
);
};
const remaining = closesAtTime - Date.now();
timeoutId = window.setTimeout(
checkDeadline,
Math.max(0, Math.min(remaining, 2_147_483_647))
);
return () => {
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
};
}, [closesAtTime]);
const methods = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
@@ -332,7 +367,7 @@ export function FormFillerClient({
}, [form.id, isUpdating, preview, questions, reset]);
useEffect(() => {
if (preview || !draftReady || !form.isOpen) {
if (preview || !draftReady || !isAcceptingSubmissions) {
return;
}
@@ -348,7 +383,7 @@ export function FormFillerClient({
} catch {
queueMicrotask(() => setDraftSaved(false));
}
}, [draftReady, form.id, form.isOpen, getValues, imageAnswers, otherValues, preview, watchedValues]);
}, [draftReady, form.id, getValues, imageAnswers, isAcceptingSubmissions, otherValues, preview, watchedValues]);
const switchToSubmission = (submissionId: string | null) => {
const submission = submissionId
@@ -432,7 +467,7 @@ export function FormFillerClient({
const onSubmit = async (values: z.infer<typeof formSchema>) => {
if (preview) return;
if (!form.isOpen) return;
if (!isAcceptingSubmissions) return;
for (const question of questions) {
const value = values[question.id] as FormAnswerValue | undefined;
@@ -519,7 +554,7 @@ export function FormFillerClient({
}
};
if (!form.isOpen && !isUpdating) {
if (!isAcceptingSubmissions && !isUpdating) {
return (
<Card className="mx-auto max-w-md">
<CardHeader>
@@ -567,7 +602,7 @@ export function FormFillerClient({
ช่องที่มีป้าย “จำเป็น” ต้องตอบให้ครบ
</span>
)}
{!form.isOpen && (
{!isAcceptingSubmissions && (
<span className="text-sm text-muted-foreground">
คุณยังดูหรือลบคำตอบเดิมได้ แต่บันทึกการแก้ไขไม่ได้
</span>
@@ -699,7 +734,7 @@ export function FormFillerClient({
value={(field.value as string) || ""}
aria-labelledby={questionLabelId}
aria-required={q.required}
disabled={!form.isOpen || isSubmitting}
disabled={!isAcceptingSubmissions || isSubmitting}
/>
</FormControl>
<FormMessage id={questionErrorId} />
@@ -720,7 +755,7 @@ export function FormFillerClient({
aria-labelledby={questionLabelId}
aria-required={q.required}
className="min-h-32 resize-y"
disabled={!form.isOpen || isSubmitting}
disabled={!isAcceptingSubmissions || isSubmitting}
/>
</FormControl>
<FormMessage id={questionErrorId} />
@@ -743,7 +778,7 @@ export function FormFillerClient({
aria-labelledby={questionLabelId}
aria-required={q.required}
className="flex flex-col gap-3"
disabled={!form.isOpen || isSubmitting}
disabled={!isAcceptingSubmissions || isSubmitting}
>
{q.options.map((option, optionIndex) => (
<Field
@@ -797,7 +832,7 @@ export function FormFillerClient({
}
className="w-full max-w-xs sm:ml-6"
placeholder="โปรดระบุ"
disabled={!form.isOpen || isSubmitting}
disabled={!isAcceptingSubmissions || isSubmitting}
/>
)}
</FieldGroup>
@@ -835,7 +870,7 @@ export function FormFillerClient({
<Checkbox
id={`${q.id}-${optionIndex}`}
checked={isChecked}
disabled={!form.isOpen || isSubmitting}
disabled={!isAcceptingSubmissions || isSubmitting}
onCheckedChange={(checked) => {
const current = Array.isArray(field.value)
? field.value
@@ -867,7 +902,7 @@ export function FormFillerClient({
Array.isArray(field.value) &&
field.value.includes("__OTHER__")
}
disabled={!form.isOpen || isSubmitting}
disabled={!isAcceptingSubmissions || isSubmitting}
onCheckedChange={(checked) => {
const current = Array.isArray(field.value)
? field.value
@@ -910,7 +945,7 @@ export function FormFillerClient({
}
className="w-full max-w-xs sm:ml-6"
placeholder="โปรดระบุ"
disabled={!form.isOpen || isSubmitting}
disabled={!isAcceptingSubmissions || isSubmitting}
/>
)}
</FieldGroup>
@@ -939,7 +974,7 @@ export function FormFillerClient({
}}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
disabled={!form.isOpen || isSubmitting || uploadingAnswerId !== null}
disabled={!isAcceptingSubmissions || isSubmitting || uploadingAnswerId !== null}
onChange={(event) => handleAnswerImageUpload(q.id, event)}
className="max-w-sm"
aria-invalid={Boolean(imageErrors[q.id])}
@@ -983,7 +1018,7 @@ export function FormFillerClient({
type="button"
variant="ghost"
onClick={() => clearAnswerImage(q.id)}
disabled={!form.isOpen || isSubmitting}
disabled={!isAcceptingSubmissions || isSubmitting}
>
ลบรูป
</Button>
@@ -1014,7 +1049,7 @@ export function FormFillerClient({
<p className="text-sm text-muted-foreground" aria-live="polite">
{preview
? "ตัวอย่างสำหรับผู้ดูแลระบบ — ไม่มีการส่งคำตอบ"
: !form.isOpen
: !isAcceptingSubmissions
? "ปิดรับคำตอบแล้ว"
: draftSaved
? "บันทึกฉบับร่างไว้ในอุปกรณ์นี้แล้ว"
@@ -1061,7 +1096,7 @@ export function FormFillerClient({
</AlertDialogContent>
</AlertDialog>
)}
{form.isOpen && (
{isAcceptingSubmissions && (
<Button
type="submit"
disabled={isSubmitting || questions.length === 0}