From 73bd6ef5d2502049c5be6118a7cf4705da2a893e Mon Sep 17 00:00:00 2001 From: gunshiz Date: Sun, 26 Jul 2026 15:54:20 +0700 Subject: [PATCH] feat(form): show response totals and queue numbers --- app/actions/submissions.ts | 39 ++++++++++++++++++++++++-- app/admin/form/page.tsx | 13 ++++++++- app/form/[id]/page.tsx | 18 +++++++++--- app/form/client.tsx | 15 +++++++++- app/form/page.tsx | 20 +++++++++++++- lib/form-submission-stats.ts | 53 ++++++++++++++++++++++++++++++++++++ 6 files changed, 148 insertions(+), 10 deletions(-) create mode 100644 lib/form-submission-stats.ts diff --git a/app/actions/submissions.ts b/app/actions/submissions.ts index 859e4c7..ccd1dd7 100644 --- a/app/actions/submissions.ts +++ b/app/actions/submissions.ts @@ -2,7 +2,7 @@ import { db } from "@/db"; import { submissions, answers } from "@/db/schema"; -import { eq } from "drizzle-orm"; +import { and, count, eq, lt, lte, or } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth-options"; @@ -44,11 +44,13 @@ export async function submitForm( }); let submissionId = ""; + let submittedAt = new Date(); const isUpdate = Boolean(existingSubmission); let oldAnswersDict: Record = {}; if (existingSubmission) { submissionId = existingSubmission.id; + submittedAt = existingSubmission.submittedAt; const oldAnswersList = await tx.query.answers.findMany({ where: (answer, { eq }) => eq(answer.submissionId, submissionId), @@ -83,6 +85,7 @@ export async function submitForm( }) .returning(); submissionId = inserted.id; + submittedAt = inserted.submittedAt; } if (normalizedAnswers.length > 0) { @@ -95,7 +98,33 @@ export async function submitForm( ); } - return { submissionId, isUpdate, oldAnswersDict }; + const [submissionStats] = await tx + .select({ totalAnswers: count() }) + .from(submissions) + .where(eq(submissions.formId, formId)); + const [queueStats] = await tx + .select({ queueNumber: count() }) + .from(submissions) + .where( + and( + eq(submissions.formId, formId), + or( + lt(submissions.submittedAt, submittedAt), + and( + eq(submissions.submittedAt, submittedAt), + lte(submissions.id, submissionId) + ) + ) + ) + ); + + return { + submissionId, + isUpdate, + oldAnswersDict, + totalAnswers: submissionStats.totalAnswers, + queueNumber: queueStats.queueNumber, + }; }); // Send Discord Webhook @@ -146,7 +175,11 @@ export async function submitForm( action: result.isUpdate ? "updated" : "created", }); revalidatePath(`/admin/form/${formId}/result`); - return { ok: true }; + return { + ok: true, + totalAnswers: result.totalAnswers, + queueNumber: result.queueNumber, + }; } export async function deleteSubmission(id: string) { diff --git a/app/admin/form/page.tsx b/app/admin/form/page.tsx index 7a2429a..07435ca 100644 --- a/app/admin/form/page.tsx +++ b/app/admin/form/page.tsx @@ -15,6 +15,7 @@ import { DeleteFormButton } from "@/components/delete-buttons"; import { AdminShellServer } from "@/components/admin-shell-server"; import { createDefaultForm } from "./actions"; import { RealtimeRefresh } from "@/components/realtime-refresh"; +import { getFormSubmissionStats } from "@/lib/form-submission-stats"; export const dynamic = "force-dynamic"; @@ -22,6 +23,10 @@ export default async function AdminFormsPage() { const formsList = await db.query.forms.findMany({ orderBy: (f, { desc }) => [desc(f.createdAt)], }); + const submissionStats = await getFormSubmissionStats( + formsList.map((form) => form.id), + null + ); return ( @@ -48,6 +53,7 @@ export default async function AdminFormsPage() { Title Status + Responses Created Actions @@ -55,7 +61,7 @@ export default async function AdminFormsPage() { {formsList.length === 0 ? ( - + No forms found. Create one to get started. @@ -76,6 +82,11 @@ export default async function AdminFormsPage() { )} + + + {submissionStats.get(form.id)?.totalAnswers ?? 0} + + {form.createdAt ? new Date(form.createdAt).toLocaleDateString() : 'N/A'} diff --git a/app/form/[id]/page.tsx b/app/form/[id]/page.tsx index 59aa3f0..0b71a01 100644 --- a/app/form/[id]/page.tsx +++ b/app/form/[id]/page.tsx @@ -10,6 +10,7 @@ import { decodeCheckboxAnswer, type FormAnswerValue, } from "@/lib/form-answer-values"; +import { getFormSubmissionStats } from "@/lib/form-submission-stats"; export const dynamic = "force-dynamic"; @@ -43,10 +44,17 @@ export default async function FormsSubmitPage( orderBy: (q, { asc }) => [asc(q.displayOrder)], }); - const existingSubmission = await db.query.submissions.findFirst({ - where: (s, { eq, and }) => - and(eq(s.formId, form.id), eq(s.userDiscordId, discordId)), - }); + const [existingSubmission, submissionStats] = await Promise.all([ + db.query.submissions.findFirst({ + where: (s, { eq, and }) => + and(eq(s.formId, form.id), eq(s.userDiscordId, discordId)), + }), + getFormSubmissionStats([form.id], discordId), + ]); + const stats = submissionStats.get(form.id) ?? { + totalAnswers: 0, + queueNumber: null, + }; const initialAnswers: Record = {}; if (existingSubmission) { @@ -71,6 +79,8 @@ export default async function FormsSubmitPage( questions={questionsList} initialAnswers={initialAnswers} hasSubmission={Boolean(existingSubmission)} + totalAnswers={stats.totalAnswers} + queueNumber={stats.queueNumber} /> diff --git a/app/form/client.tsx b/app/form/client.tsx index 86eccc6..fc148d8 100644 --- a/app/form/client.tsx +++ b/app/form/client.tsx @@ -104,6 +104,8 @@ interface FormFillerClientProps { questions: Question[]; initialAnswers?: Record; hasSubmission?: boolean; + totalAnswers: number; + queueNumber: number | null; } const LS_KEY = (formId: string) => `form-draft-${formId}`; @@ -212,6 +214,8 @@ export function FormFillerClient({ questions, initialAnswers = {}, hasSubmission = false, + totalAnswers: initialTotalAnswers, + queueNumber: initialQueueNumber, }: FormFillerClientProps) { const router = useRouter(); const isUpdating = hasSubmission; @@ -230,6 +234,8 @@ export function FormFillerClient({ const [showSuccessDialog, setShowSuccessDialog] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [isDeleting, setIsDeleting] = useState(false); + const [totalAnswers, setTotalAnswers] = useState(initialTotalAnswers); + const [queueNumber, setQueueNumber] = useState(initialQueueNumber); const methods = useForm>({ resolver: zodResolver(formSchema), @@ -337,6 +343,8 @@ export function FormFillerClient({ const res = await submitForm(form.id, formattedAnswers); if (res.ok) { + setTotalAnswers(res.totalAnswers); + setQueueNumber(res.queueNumber); if (isUpdating) { toast.success("บันทึกการแก้ไขแล้ว"); } else { @@ -398,6 +406,10 @@ export function FormFillerClient({ : "ปิดรับคำตอบแล้ว"} {questions.length} คำถาม + {totalAnswers} คำตอบทั้งหมด + {queueNumber !== null && ( + คิวของคุณ #{queueNumber} + )} {form.description && ( @@ -833,7 +845,8 @@ export function FormFillerClient({ ส่งคำตอบแล้ว - บันทึกคำตอบของคุณเรียบร้อย คุณกลับมาแก้ไขได้ตราบใดที่แบบฟอร์มยังเปิดอยู่ + บันทึกคำตอบของคุณเรียบร้อย คุณอยู่ในคิวที่ #{queueNumber} จากทั้งหมด{" "} + {totalAnswers} คำตอบ และกลับมาแก้ไขได้ตราบใดที่แบบฟอร์มยังเปิดอยู่ diff --git a/app/form/page.tsx b/app/form/page.tsx index 6800a2a..abcddac 100644 --- a/app/form/page.tsx +++ b/app/form/page.tsx @@ -10,6 +10,8 @@ import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth-options"; import { canAccessForm, getSessionDiscordId } from "@/lib/auth"; import { RealtimeRefresh } from "@/components/realtime-refresh"; +import { Badge } from "@/components/ui/badge"; +import { getFormSubmissionStats } from "@/lib/form-submission-stats"; export const dynamic = "force-dynamic"; @@ -27,6 +29,10 @@ export default async function FormsListingPage() { visibleForms.push(form); } } + const submissionStats = await getFormSubmissionStats( + visibleForms.map((form) => form.id), + discordId + ); return ( <> @@ -41,7 +47,19 @@ export default async function FormsListingPage() { - {form.title} +
+ {form.title} +
+ + {submissionStats.get(form.id)?.totalAnswers ?? 0} คำตอบ + + {submissionStats.get(form.id)?.queueNumber != null && ( + + คิวของคุณ #{submissionStats.get(form.id)?.queueNumber} + + )} +
+
{form.description && (
diff --git a/lib/form-submission-stats.ts b/lib/form-submission-stats.ts new file mode 100644 index 0000000..135ee08 --- /dev/null +++ b/lib/form-submission-stats.ts @@ -0,0 +1,53 @@ +import { db } from "@/db"; +import { sql } from "drizzle-orm"; + +export type FormSubmissionStats = { + totalAnswers: number; + queueNumber: number | null; +}; + +export async function getFormSubmissionStats( + formIds: string[], + discordId: string | null +): Promise> { + if (formIds.length === 0) { + return new Map(); + } + + const rows = await db.execute<{ + formId: string; + totalAnswers: number; + queueNumber: number | null; + }>(sql` + with ranked_submissions as ( + select + form_id, + user_discord_id, + count(*) over (partition by form_id)::integer as total_answers, + row_number() over ( + partition by form_id + order by submitted_at asc, id asc + )::integer as queue_number + from form.submission + where form_id in (${sql.join(formIds.map((formId) => sql`${formId}`), sql`, `)}) + ) + select + form_id as "formId", + max(total_answers)::integer as "totalAnswers", + max(queue_number) filter ( + where user_discord_id = ${discordId} + )::integer as "queueNumber" + from ranked_submissions + group by form_id + `); + + return new Map( + rows.map((row) => [ + row.formId, + { + totalAnswers: row.totalAnswers, + queueNumber: row.queueNumber, + }, + ]) + ); +}