feat(form): show response totals and queue numbers
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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} />
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user