Improve admin form workspace UX

This commit is contained in:
2026-07-27 19:11:27 +07:00 Unverified
parent f988f60504
commit bc856402e9
33 changed files with 2194 additions and 596 deletions
@@ -14,6 +14,14 @@ import {
} from "@/components/ui/context-menu";
import { Copy, Check, Users, Calendar, Clock } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import Image from 'next/image';
import { stripHtml } from "@/lib/utils";
import { HtmlDisplay } from "@/components/html-display";
@@ -77,14 +85,14 @@ export default function SubmissionDetailClient({
<div className="flex items-center gap-2 text-muted-foreground">
<Calendar className="w-4 h-4 shrink-0" />
<span className="font-semibold text-foreground">Initial Submission:</span>
<span>{new Date(submission.submittedAt).toLocaleString("en-US", { timeZone: "Asia/Bangkok", hour12: false })}</span>
<span>{new Date(submission.submittedAt).toLocaleString("en-GB", { timeZone: "Asia/Bangkok", hour12: false })}</span>
</div>
{editHistory.length > 0 && (
<div className="flex flex-col mt-2 pt-2 border-t">
<span className="font-semibold text-foreground mb-1">Edit History:</span>
<ul className="list-disc list-inside text-xs text-muted-foreground space-y-1">
{editHistory.map((edit, idx) => (
<li key={idx}>Edited at {new Date(edit.editedAt).toLocaleString("en-US", { timeZone: "Asia/Bangkok", hour12: false })}</li>
<li key={idx}>Edited at {new Date(edit.editedAt).toLocaleString("en-GB", { timeZone: "Asia/Bangkok", hour12: false })}</li>
))}
</ul>
</div>
@@ -119,9 +127,36 @@ export default function SubmissionDetailClient({
<HtmlDisplay html={q.label} />
</div>
{wasEdited && (
<p className="text-xs text-muted-foreground italic flex items-center gap-1">
edited
</p>
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm">View history</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Answer history</DialogTitle>
<DialogDescription>
Previous values for {stripHtml(q.label)}
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-72">
<div className="flex flex-col gap-3 pr-4">
{qAnswerHistory.map((entry, idx) => (
<div key={idx} className="flex flex-col gap-1 text-sm">
<span className="text-xs text-muted-foreground">
{new Date(entry.editedAt).toLocaleString("en-GB", {
timeZone: "Asia/Bangkok",
hour12: false,
})}
</span>
<span className="whitespace-pre-wrap">
{entry.answer || "No response"}
</span>
</div>
))}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
)}
</div>
{q.imageUrl && (
@@ -152,13 +187,14 @@ export default function SubmissionDetailClient({
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
className="shrink-0 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 sm:group-focus-within:opacity-100"
onClick={() => handleCopy(answerValue, q.id)}
aria-label={`Copy answer for ${stripHtml(q.label)}`}
>
{copiedId === q.id ? (
<Check className="w-4 h-4 text-green-500" />
<Check />
) : (
<Copy className="w-4 h-4 text-muted-foreground" />
<Copy />
)}
</Button>
</div>
@@ -198,7 +234,7 @@ export default function SubmissionDetailClient({
>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="w-3 h-3 shrink-0" />
<span>{new Date(entry.editedAt).toLocaleString("en-US", { timeZone: "Asia/Bangkok", hour12: false })}</span>
<span>{new Date(entry.editedAt).toLocaleString("en-GB", { timeZone: "Asia/Bangkok", hour12: false })}</span>
</div>
<span className="text-sm text-foreground/80 whitespace-pre-wrap leading-relaxed">
{entry.answer || <span className="italic text-muted-foreground">No response</span>}
@@ -12,13 +12,26 @@ interface SubmissionDetailPageProps {
id: string;
submissionId: string;
}>;
searchParams: Promise<{ return?: string | string[] }>;
}
export default async function SubmissionDetailPage({ params }: SubmissionDetailPageProps) {
const { id: formId, submissionId } = await params;
export default async function SubmissionDetailPage({
params,
searchParams,
}: SubmissionDetailPageProps) {
const [{ id: formId, submissionId }, query] = await Promise.all([
params,
searchParams,
]);
const returnValue = Array.isArray(query.return) ? query.return[0] : query.return;
const returnQuery = returnValue?.slice(0, 1000) || "";
const backHref = `/admin/form/${formId}/result${returnQuery ? `?${returnQuery}` : ""}`;
const detailHref = (id: string) =>
`/admin/form/${formId}/result/${id}${returnQuery ? `?return=${encodeURIComponent(returnQuery)}` : ""}`;
const submission = await db.query.submissions.findFirst({
where: (s, { eq }) => eq(s.id, submissionId),
where: (s, { eq, and }) =>
and(eq(s.id, submissionId), eq(s.formId, formId)),
});
if (!submission) {
@@ -50,28 +63,14 @@ export default async function SubmissionDetailPage({ params }: SubmissionDetailP
return (
<div className="space-y-6 w-full max-w-6xl mx-auto p-4 md:p-6 pb-10">
<RealtimeRefresh topic="submissions" formId={formId} />
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Link href={`/admin/form/${formId}/result`}>
<Button variant="ghost" size="icon">
<ArrowLeft className="w-4 h-4" />
</Button>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Button variant="ghost" asChild className="self-start">
<Link href={backHref}>
<ArrowLeft data-icon="inline-start" />
Back to responses
</Link>
<h1 className="text-3xl font-bold tracking-tight">Response Detail</h1>
</div>
<div className="flex items-center gap-2">
{prevId && (
<Link href={`/admin/form/${formId}/result/${prevId}`}>
<Button variant="outline" size="sm">Previous</Button>
</Link>
)}
{nextId && (
<Link href={`/admin/form/${formId}/result/${nextId}`}>
<Button variant="outline" size="sm">Next</Button>
</Link>
)}
<DeleteSubmissionButton id={submission.id} redirectUrl={`/admin/form/${formId}/result`} />
</div>
</Button>
<DeleteSubmissionButton id={submission.id} redirectUrl={backHref} />
</div>
<SubmissionDetailClient
@@ -82,18 +81,18 @@ export default async function SubmissionDetailPage({ params }: SubmissionDetailP
<div className="flex justify-between items-center mt-8 pt-6 border-t">
{prevId ? (
<Link href={`/admin/form/${formId}/result/${prevId}`}>
<Link href={detailHref(prevId)}>
<Button variant="outline">
<ArrowLeft className="w-4 h-4 mr-2" /> Previous
<ArrowLeft data-icon="inline-start" /> Previous
</Button>
</Link>
) : (
<div />
)}
{nextId && (
<Link href={`/admin/form/${formId}/result/${nextId}`}>
<Link href={detailHref(nextId)}>
<Button variant="outline">
Next <ArrowRight className="w-4 h-4 ml-2" />
Next <ArrowRight data-icon="inline-end" />
</Button>
</Link>
)}