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 -1
View File
@@ -20,6 +20,8 @@ import {
} from "@/app/actions/questions";
import { Plus, Trash, ArrowUp, ArrowDown, Loader2, Image as ImageIcon, X, Check, GripVertical, AlertCircle } from "lucide-react";
import Image from 'next/image';
import { format, parseISO } from "date-fns";
import { DateTimePicker } from "@/components/date-time-picker";
import { RichTextEditor } from "@/components/rich-text-editor";
import { getErrorMessage } from "@/lib/utils";
import {
@@ -66,6 +68,8 @@ import {
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@/components/ui/field";
interface Question {
@@ -87,16 +91,28 @@ interface FormEditorClientProps {
title: string;
description: string | null;
isOpen: boolean;
closesAt: string | null;
allowMultipleSubmissions: boolean;
};
initialQuestions: Question[];
}
function toDateTimeLocalValue(value: string | null) {
if (!value) return "";
const date = new Date(value);
const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
return localDate.toISOString().slice(0, 16);
}
export default function FormEditorClient({ form, initialQuestions }: FormEditorClientProps) {
const router = useRouter();
const [formTitle, setFormTitle] = useState(form.title);
const [formDescription, setFormDescription] = useState(form.description || "");
const [formIsOpen, setFormIsOpen] = useState(form.isOpen);
const [closesAt, setClosesAt] = useState(
toDateTimeLocalValue(form.closesAt)
);
const [allowMultipleSubmissions, setAllowMultipleSubmissions] = useState(
form.allowMultipleSubmissions
);
@@ -108,6 +124,7 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
const formTitleRef = useRef(formTitle);
const formDescriptionRef = useRef(formDescription);
const formIsOpenRef = useRef(formIsOpen);
const closesAtRef = useRef(closesAt);
const allowMultipleSubmissionsRef = useRef(allowMultipleSubmissions);
const questionsRef = useRef(questions);
@@ -115,9 +132,10 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
formTitleRef.current = formTitle;
formDescriptionRef.current = formDescription;
formIsOpenRef.current = formIsOpen;
closesAtRef.current = closesAt;
allowMultipleSubmissionsRef.current = allowMultipleSubmissions;
questionsRef.current = questions;
}, [allowMultipleSubmissions, formDescription, formIsOpen, formTitle, questions]);
}, [allowMultipleSubmissions, closesAt, formDescription, formIsOpen, formTitle, questions]);
const saveForm = useCallback(async () => {
const title = formTitleRef.current;
@@ -128,6 +146,9 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
title: title.trim(),
description: formDescriptionRef.current,
isOpen: formIsOpenRef.current,
closesAt: closesAtRef.current
? new Date(closesAtRef.current).toISOString()
: null,
allowMultipleSubmissions: allowMultipleSubmissionsRef.current,
});
router.refresh();
@@ -169,6 +190,14 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
formAutoSave.schedule();
};
const handleClosesAtChange = (val: string) => {
closesAtRef.current = val;
setClosesAt(val);
formAutoSave.schedule();
};
const selectedCloseDateTime = closesAt ? parseISO(closesAt) : undefined;
const handleAllowMultipleSubmissionsChange = (val: boolean) => {
allowMultipleSubmissionsRef.current = val;
setAllowMultipleSubmissions(val);
@@ -389,6 +418,27 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
Accepting Submissions
</FieldLabel>
</Field>
<FieldSet className="gap-3">
<FieldLegend variant="label">
Automatically close submissions
</FieldLegend>
<Field>
<FieldLabel htmlFor="form-close-date">Date and time</FieldLabel>
<DateTimePicker
id="form-close-date"
value={selectedCloseDateTime}
onChange={(date) =>
handleClosesAtChange(
date ? format(date, "yyyy-MM-dd'T'HH:mm") : ""
)
}
/>
</Field>
<FieldDescription>
Changes are saved automatically. Leave blank for no deadline.
The selected date and time use your device&apos;s time zone.
</FieldDescription>
</FieldSet>
<Field orientation="horizontal">
<Switch
id="allow-multiple-submissions"
+2 -1
View File
@@ -7,6 +7,7 @@ import { getSessionDiscordId } from "@/lib/auth";
import { getDiscordMemberProfile } from "@/lib/discord";
import { db } from "@/db";
import { notFound } from "next/navigation";
import { isFormAcceptingSubmissions } from "@/lib/form-availability";
export const metadata: Metadata = {
title: {
@@ -45,7 +46,7 @@ export default async function AdminFormLayout(
<FormAdminSidebar
formId={params.id}
formTitle={form.title}
isOpen={form.isOpen}
isOpen={isFormAcceptingSubmissions(form)}
userName={session?.user?.name || ""}
userImage={session?.user?.image || ""}
profile={profile}
+7 -1
View File
@@ -28,7 +28,13 @@ export default async function AdminDashboardPage(
<>
<RealtimeRefresh topic="forms" formId={form.id} />
<div className="mx-auto w-full max-w-6xl p-4 pb-12 md:p-6">
<FormEditorClient form={form} initialQuestions={questionsList} />
<FormEditorClient
form={{
...form,
closesAt: form.closesAt?.toISOString() ?? null,
}}
initialQuestions={questionsList}
/>
</div>
</>
);
+1 -1
View File
@@ -28,7 +28,7 @@ export default async function AdminFormPreviewPage({
<Badge variant="secondary">Admin preview</Badge>
</div>
<FormFillerClient
form={{ ...form, isOpen: true }}
form={{ ...form, isOpen: true, closesAt: null }}
questions={questions}
totalAnswers={0}
queueNumber={null}
+11 -1
View File
@@ -47,7 +47,7 @@ export async function createDefaultForm() {
redirect(`/admin/form/${form.id}`);
}
export async function updateForm(id: string, data: { title?: string; description?: string | null; isOpen?: boolean; allowMultipleSubmissions?: boolean; discordWebhookUrl?: string | null; discordWebhookTemplate?: string | null; discordWebhookUpdateTemplate?: string | null; allowedRoles?: string[]; deniedRoles?: string[] }) {
export async function updateForm(id: string, data: { title?: string; description?: string | null; isOpen?: boolean; closesAt?: string | null; allowMultipleSubmissions?: boolean; discordWebhookUrl?: string | null; discordWebhookTemplate?: string | null; discordWebhookUpdateTemplate?: string | null; allowedRoles?: string[]; deniedRoles?: string[] }) {
await requireAdmin();
if (data.title !== undefined && !data.title.trim()) {
throw new Error("Form title is required.");
@@ -59,8 +59,18 @@ export async function updateForm(id: string, data: { title?: string; description
throw new Error("Enter a valid Discord webhook URL.");
}
const closesAt = data.closesAt === undefined
? undefined
: data.closesAt === null
? null
: new Date(data.closesAt);
if (closesAt instanceof Date && Number.isNaN(closesAt.getTime())) {
throw new Error("Enter a valid closing date and time.");
}
const nextData = {
...data,
closesAt,
description: data.description === undefined
? undefined
: data.description
+14 -7
View File
@@ -17,6 +17,7 @@ import { createDefaultForm } from "./actions";
import { RealtimeRefresh } from "@/components/realtime-refresh";
import { getFormSubmissionStats } from "@/lib/form-submission-stats";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { isFormAcceptingSubmissions } from "@/lib/form-availability";
export const dynamic = "force-dynamic";
@@ -67,7 +68,9 @@ export default async function AdminFormsPage() {
</TableCell>
</TableRow>
) : (
formsList.map((form) => (
formsList.map((form) => {
const isAccepting = isFormAcceptingSubmissions(form);
return (
<TableRow key={form.id} className="group hover:bg-muted/50 transition-colors">
<TableCell className="p-0 align-middle">
<Link href={`/admin/form/${form.id}`} className="flex items-center p-3 font-medium h-full w-full">
@@ -76,7 +79,7 @@ export default async function AdminFormsPage() {
</TableCell>
<TableCell className="p-0 align-middle">
<Link href={`/admin/form/${form.id}`} className="flex items-center p-3 h-full w-full">
{form.isOpen ? (
{isAccepting ? (
<Badge variant="default">Open</Badge>
) : (
<Badge variant="secondary">Closed</Badge>
@@ -106,21 +109,24 @@ export default async function AdminFormsPage() {
</div>
</TableCell>
</TableRow>
))
);
})
)}
</TableBody>
</Table>
</div>
<div className="flex flex-col gap-3 md:hidden">
{formsList.map((form) => (
{formsList.map((form) => {
const isAccepting = isFormAcceptingSubmissions(form);
return (
<Card key={form.id}>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<CardTitle className="text-base">
<Link href={`/admin/form/${form.id}`}>{form.title}</Link>
</CardTitle>
<Badge variant={form.isOpen ? "default" : "secondary"}>
{form.isOpen ? "Open" : "Closed"}
<Badge variant={isAccepting ? "default" : "secondary"}>
{isAccepting ? "Open" : "Closed"}
</Badge>
</div>
</CardHeader>
@@ -134,7 +140,8 @@ export default async function AdminFormsPage() {
<DeleteFormButton formId={form.id} />
</CardContent>
</Card>
))}
);
})}
{formsList.length === 0 && (
<Card>
<CardHeader>