feat(form): show response totals and queue numbers

This commit is contained in:
2026-07-26 15:54:20 +07:00 Unverified
parent 1b58d2c363
commit 73bd6ef5d2
6 changed files with 148 additions and 10 deletions
+36 -3
View File
@@ -2,7 +2,7 @@
import { db } from "@/db"; import { db } from "@/db";
import { submissions, answers } from "@/db/schema"; 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 { revalidatePath } from "next/cache";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth-options"; import { authOptions } from "@/lib/auth-options";
@@ -44,11 +44,13 @@ export async function submitForm(
}); });
let submissionId = ""; let submissionId = "";
let submittedAt = new Date();
const isUpdate = Boolean(existingSubmission); const isUpdate = Boolean(existingSubmission);
let oldAnswersDict: Record<string, string> = {}; let oldAnswersDict: Record<string, string> = {};
if (existingSubmission) { if (existingSubmission) {
submissionId = existingSubmission.id; submissionId = existingSubmission.id;
submittedAt = existingSubmission.submittedAt;
const oldAnswersList = await tx.query.answers.findMany({ const oldAnswersList = await tx.query.answers.findMany({
where: (answer, { eq }) => eq(answer.submissionId, submissionId), where: (answer, { eq }) => eq(answer.submissionId, submissionId),
@@ -83,6 +85,7 @@ export async function submitForm(
}) })
.returning(); .returning();
submissionId = inserted.id; submissionId = inserted.id;
submittedAt = inserted.submittedAt;
} }
if (normalizedAnswers.length > 0) { 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 // Send Discord Webhook
@@ -146,7 +175,11 @@ export async function submitForm(
action: result.isUpdate ? "updated" : "created", action: result.isUpdate ? "updated" : "created",
}); });
revalidatePath(`/admin/form/${formId}/result`); revalidatePath(`/admin/form/${formId}/result`);
return { ok: true }; return {
ok: true,
totalAnswers: result.totalAnswers,
queueNumber: result.queueNumber,
};
} }
export async function deleteSubmission(id: string) { export async function deleteSubmission(id: string) {
+12 -1
View File
@@ -15,6 +15,7 @@ import { DeleteFormButton } from "@/components/delete-buttons";
import { AdminShellServer } from "@/components/admin-shell-server"; import { AdminShellServer } from "@/components/admin-shell-server";
import { createDefaultForm } from "./actions"; import { createDefaultForm } from "./actions";
import { RealtimeRefresh } from "@/components/realtime-refresh"; import { RealtimeRefresh } from "@/components/realtime-refresh";
import { getFormSubmissionStats } from "@/lib/form-submission-stats";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -22,6 +23,10 @@ export default async function AdminFormsPage() {
const formsList = await db.query.forms.findMany({ const formsList = await db.query.forms.findMany({
orderBy: (f, { desc }) => [desc(f.createdAt)], orderBy: (f, { desc }) => [desc(f.createdAt)],
}); });
const submissionStats = await getFormSubmissionStats(
formsList.map((form) => form.id),
null
);
return ( return (
<AdminShellServer> <AdminShellServer>
@@ -48,6 +53,7 @@ export default async function AdminFormsPage() {
<TableRow> <TableRow>
<TableHead>Title</TableHead> <TableHead>Title</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead>Responses</TableHead>
<TableHead>Created</TableHead> <TableHead>Created</TableHead>
<TableHead className="text-right">Actions</TableHead> <TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
@@ -55,7 +61,7 @@ export default async function AdminFormsPage() {
<TableBody> <TableBody>
{formsList.length === 0 ? ( {formsList.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={4} className="h-24 text-center text-muted-foreground"> <TableCell colSpan={5} className="h-24 text-center text-muted-foreground">
No forms found. Create one to get started. No forms found. Create one to get started.
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -76,6 +82,11 @@ export default async function AdminFormsPage() {
)} )}
</Link> </Link>
</TableCell> </TableCell>
<TableCell className="p-0 align-middle">
<Link href={`/admin/form/${form.id}/result`} className="flex items-center p-3 h-full w-full">
{submissionStats.get(form.id)?.totalAnswers ?? 0}
</Link>
</TableCell>
<TableCell className="p-0 align-middle"> <TableCell className="p-0 align-middle">
<Link href={`/admin/form/${form.id}`} className="flex items-center p-3 h-full w-full"> <Link href={`/admin/form/${form.id}`} className="flex items-center p-3 h-full w-full">
{form.createdAt ? new Date(form.createdAt).toLocaleDateString() : 'N/A'} {form.createdAt ? new Date(form.createdAt).toLocaleDateString() : 'N/A'}
+14 -4
View File
@@ -10,6 +10,7 @@ import {
decodeCheckboxAnswer, decodeCheckboxAnswer,
type FormAnswerValue, type FormAnswerValue,
} from "@/lib/form-answer-values"; } from "@/lib/form-answer-values";
import { getFormSubmissionStats } from "@/lib/form-submission-stats";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -43,10 +44,17 @@ export default async function FormsSubmitPage(
orderBy: (q, { asc }) => [asc(q.displayOrder)], orderBy: (q, { asc }) => [asc(q.displayOrder)],
}); });
const existingSubmission = await db.query.submissions.findFirst({ const [existingSubmission, submissionStats] = await Promise.all([
where: (s, { eq, and }) => db.query.submissions.findFirst({
and(eq(s.formId, form.id), eq(s.userDiscordId, discordId)), 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<string, FormAnswerValue> = {}; const initialAnswers: Record<string, FormAnswerValue> = {};
if (existingSubmission) { if (existingSubmission) {
@@ -71,6 +79,8 @@ export default async function FormsSubmitPage(
questions={questionsList} questions={questionsList}
initialAnswers={initialAnswers} initialAnswers={initialAnswers}
hasSubmission={Boolean(existingSubmission)} hasSubmission={Boolean(existingSubmission)}
totalAnswers={stats.totalAnswers}
queueNumber={stats.queueNumber}
/> />
</div> </div>
</> </>
+14 -1
View File
@@ -104,6 +104,8 @@ interface FormFillerClientProps {
questions: Question[]; questions: Question[];
initialAnswers?: Record<string, FormAnswerValue>; initialAnswers?: Record<string, FormAnswerValue>;
hasSubmission?: boolean; hasSubmission?: boolean;
totalAnswers: number;
queueNumber: number | null;
} }
const LS_KEY = (formId: string) => `form-draft-${formId}`; const LS_KEY = (formId: string) => `form-draft-${formId}`;
@@ -212,6 +214,8 @@ export function FormFillerClient({
questions, questions,
initialAnswers = {}, initialAnswers = {},
hasSubmission = false, hasSubmission = false,
totalAnswers: initialTotalAnswers,
queueNumber: initialQueueNumber,
}: FormFillerClientProps) { }: FormFillerClientProps) {
const router = useRouter(); const router = useRouter();
const isUpdating = hasSubmission; const isUpdating = hasSubmission;
@@ -230,6 +234,8 @@ export function FormFillerClient({
const [showSuccessDialog, setShowSuccessDialog] = useState(false); const [showSuccessDialog, setShowSuccessDialog] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [totalAnswers, setTotalAnswers] = useState(initialTotalAnswers);
const [queueNumber, setQueueNumber] = useState(initialQueueNumber);
const methods = useForm<z.infer<typeof formSchema>>({ const methods = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
@@ -337,6 +343,8 @@ export function FormFillerClient({
const res = await submitForm(form.id, formattedAnswers); const res = await submitForm(form.id, formattedAnswers);
if (res.ok) { if (res.ok) {
setTotalAnswers(res.totalAnswers);
setQueueNumber(res.queueNumber);
if (isUpdating) { if (isUpdating) {
toast.success("บันทึกการแก้ไขแล้ว"); toast.success("บันทึกการแก้ไขแล้ว");
} else { } else {
@@ -398,6 +406,10 @@ export function FormFillerClient({
: "ปิดรับคำตอบแล้ว"} : "ปิดรับคำตอบแล้ว"}
</Badge> </Badge>
<span>{questions.length} คำถาม</span> <span>{questions.length} คำถาม</span>
<span>{totalAnswers} คำตอบทั้งหมด</span>
{queueNumber !== null && (
<Badge variant="outline">คิวของคุณ #{queueNumber}</Badge>
)}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
{form.description && ( {form.description && (
@@ -833,7 +845,8 @@ export function FormFillerClient({
ส่งคำตอบแล้ว ส่งคำตอบแล้ว
</AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
บันทึกคำตอบของคุณเรียบร้อย คุณกลับมาแก้ไขได้ตราบใดที่แบบฟอร์มยังเปิดอยู่ บันทึกคำตอบของคุณเรียบร้อย คุณอยู่ในคิวที่ #{queueNumber} จากทั้งหมด{" "}
{totalAnswers} คำตอบ และกลับมาแก้ไขได้ตราบใดที่แบบฟอร์มยังเปิดอยู่
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
+19 -1
View File
@@ -10,6 +10,8 @@ import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth-options"; import { authOptions } from "@/lib/auth-options";
import { canAccessForm, getSessionDiscordId } from "@/lib/auth"; import { canAccessForm, getSessionDiscordId } from "@/lib/auth";
import { RealtimeRefresh } from "@/components/realtime-refresh"; import { RealtimeRefresh } from "@/components/realtime-refresh";
import { Badge } from "@/components/ui/badge";
import { getFormSubmissionStats } from "@/lib/form-submission-stats";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -27,6 +29,10 @@ export default async function FormsListingPage() {
visibleForms.push(form); visibleForms.push(form);
} }
} }
const submissionStats = await getFormSubmissionStats(
visibleForms.map((form) => form.id),
discordId
);
return ( return (
<> <>
@@ -41,7 +47,19 @@ export default async function FormsListingPage() {
<Link key={form.id} href={`/form/${form.id}`}> <Link key={form.id} href={`/form/${form.id}`}>
<Card className="hover:bg-muted/50 transition-colors h-full flex flex-col"> <Card className="hover:bg-muted/50 transition-colors h-full flex flex-col">
<CardHeader> <CardHeader>
<CardTitle>{form.title}</CardTitle> <div className="flex flex-wrap items-start justify-between gap-3">
<CardTitle>{form.title}</CardTitle>
<div className="flex flex-wrap gap-2">
<Badge variant="secondary">
{submissionStats.get(form.id)?.totalAnswers ?? 0} คำตอบ
</Badge>
{submissionStats.get(form.id)?.queueNumber != null && (
<Badge variant="outline">
คิวของคุณ #{submissionStats.get(form.id)?.queueNumber}
</Badge>
)}
</div>
</div>
{form.description && ( {form.description && (
<div className="line-clamp-3 text-sm text-muted-foreground mt-1.5"> <div className="line-clamp-3 text-sm text-muted-foreground mt-1.5">
<HtmlDisplay html={form.description} /> <HtmlDisplay html={form.description} />
+53
View File
@@ -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<Map<string, FormSubmissionStats>> {
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,
},
])
);
}