Skip to content

Commit deb0b0c

Browse files
committed
Add form clone, live dashboard stats, debounced notes autosave
- cloneForm action + duplicate button on the forms list: copies questions, config (email templates, offer page) and type — never dates/submissions - Admin dashboard now shows real counts (forms, members, pending applications, submissions this month) plus latest recruitment form status - Reviewer notes textarea debounces server saves (800ms) instead of a server action per keystroke
1 parent ff909a3 commit deb0b0c

5 files changed

Lines changed: 165 additions & 58 deletions

File tree

‎src/app/portal/admin/actions.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,24 @@ export async function createForm(data: { title: string; description: string }) {
1919
return db.forms.create({ data: { ...data, config: {}, questions: [] } });
2020
}
2121

22+
export async function cloneForm(id: number) {
23+
await requireAdmin();
24+
const source = await db.forms.findUnique({ where: { id: BigInt(id) } });
25+
if (!source) {
26+
throw new Error("Form not found");
27+
}
28+
// Copy structure (questions, config incl. email templates + offer page)
29+
// but never dates or submissions — the new form starts unopened.
30+
return db.forms.create({
31+
data: {
32+
title: `${source.title} (Copy)`,
33+
questions: source.questions as any,
34+
config: source.config as any,
35+
type: source.type,
36+
},
37+
});
38+
}
39+
2240
export async function updateForm(
2341
id: number,
2442
data: {

‎src/app/portal/admin/forms/page.tsx‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import NewFormDialog from "@/components/portal/admin/newFormDialog";
2+
import CloneFormButton from "@/components/portal/admin/cloneFormButton";
23
import { getForms } from "@/app/portal/admin/actions";
34
import Link from "next/link";
45
import { Form } from "@/lib/types/application";
@@ -14,19 +15,20 @@ export default async function Page() {
1415
<ul className="flex flex-col gap-2 py-2 w-full">
1516
{forms.map((form) => (
1617
<li key={form.id} className="flex flex-col gap-2">
17-
<Link
18-
href={`/portal/admin/forms/${form.id}`}
19-
className="border flex items-center justify-between border-background-800 w-full bg-zinc-900 text-lg px-4 hover:bg-lp-500 p-2 rounded duration-300"
20-
>
21-
<div className="flex flex-row gap-2">
18+
<div className="border flex items-center justify-between border-background-800 w-full bg-zinc-900 text-lg px-4 hover:bg-lp-500 p-2 rounded duration-300">
19+
<Link
20+
href={`/portal/admin/forms/${form.id}`}
21+
className="flex flex-row gap-2 flex-1"
22+
>
2223
<span>{form.title}</span>
23-
</div>
24-
<div className="flex text-sm flex-row gap-2">
24+
</Link>
25+
<div className="flex text-sm flex-row gap-2 items-center">
2526
<span>{form.open_at?.toLocaleDateString()}</span>
2627
{form.close_at && <span> - </span>}
2728
<span>{form.close_at?.toLocaleDateString()}</span>
29+
<CloneFormButton formId={Number(form.id)} formTitle={form.title} />
2830
</div>
29-
</Link>
31+
</div>
3032
</li>
3133
))}
3234
{!forms ||

‎src/app/portal/admin/page.tsx‎

Lines changed: 72 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,36 @@
11
import React from "react";
22
import Link from "next/link";
3-
import { Users, FileText, Settings, BarChart3, UserCheck } from "lucide-react";
3+
import { Users, FileText, BarChart3, UserCheck } from "lucide-react";
4+
import { db } from "@/db";
5+
import { getSessionUser, isAdmin } from "@/lib/utils/auth";
6+
import { redirect } from "next/navigation";
7+
8+
export default async function AdminDashboard() {
9+
const user = await getSessionUser();
10+
if (!user || !(await isAdmin(user.id))) {
11+
redirect("/portal");
12+
}
13+
14+
const now = new Date();
15+
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
16+
17+
const [
18+
totalForms,
19+
activeMembers,
20+
pendingApplications,
21+
submissionsThisMonth,
22+
latestForm,
23+
] = await Promise.all([
24+
db.forms.count(),
25+
db.members.count(),
26+
db.applications.count({ where: { status: null } }),
27+
db.submissions.count({ where: { created_at: { gte: startOfMonth } } }),
28+
db.forms.findFirst({
29+
where: { type: "recruitment" },
30+
orderBy: { created_at: "desc" },
31+
}),
32+
]);
433

5-
export default function AdminDashboard() {
634
const adminRoutes = [
735
{
836
title: "Forms Management",
@@ -34,6 +62,13 @@ export default function AdminDashboard() {
3462
},
3563
];
3664

65+
const stats = [
66+
{ label: "Total Forms", value: totalForms, icon: FileText, color: "text-blue-500" },
67+
{ label: "Active Members", value: activeMembers, icon: Users, color: "text-green-500" },
68+
{ label: "Pending Applications", value: pendingApplications, icon: UserCheck, color: "text-yellow-500" },
69+
{ label: "Submissions This Month", value: submissionsThisMonth, icon: BarChart3, color: "text-orange-500" },
70+
];
71+
3772
return (
3873
<div className="min-h-screen bg-background-800 text-white p-6">
3974
<div className="max-w-6xl mx-auto">
@@ -47,47 +82,46 @@ export default function AdminDashboard() {
4782
</p>
4883
</div>
4984

50-
{/* Quick Stats */}
51-
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
52-
<div className="bg-background-700 border border-background-600 rounded-lg p-6">
53-
<div className="flex items-center justify-between">
54-
<div>
55-
<p className="text-gray-400 text-sm">Total Forms</p>
56-
<p className="text-2xl font-semibold text-white">-</p>
57-
</div>
58-
<FileText className="w-8 h-8 text-blue-500" />
85+
{latestForm?.title && (
86+
<div className="mb-8 border border-background-600 bg-background-700 rounded-lg p-4 flex items-center justify-between">
87+
<div>
88+
<p className="text-gray-400 text-sm">Latest recruitment form</p>
89+
<p className="text-lg font-semibold">{latestForm.title}</p>
5990
</div>
60-
</div>
61-
62-
<div className="bg-background-700 border border-background-600 rounded-lg p-6">
63-
<div className="flex items-center justify-between">
64-
<div>
65-
<p className="text-gray-400 text-sm">Active Members</p>
66-
<p className="text-2xl font-semibold text-white">-</p>
67-
</div>
68-
<Users className="w-8 h-8 text-green-500" />
91+
<div className="text-sm text-gray-400 text-right">
92+
{latestForm.open_at ? (
93+
<p>
94+
{latestForm.open_at.toLocaleDateString()} —{" "}
95+
{latestForm.close_at?.toLocaleDateString() || "open-ended"}
96+
</p>
97+
) : (
98+
<p className="text-yellow-500">Not scheduled yet</p>
99+
)}
69100
</div>
70101
</div>
102+
)}
71103

72-
<div className="bg-background-700 border border-background-600 rounded-lg p-6">
73-
<div className="flex items-center justify-between">
74-
<div>
75-
<p className="text-gray-400 text-sm">Pending Applications</p>
76-
<p className="text-2xl font-semibold text-white">-</p>
77-
</div>
78-
<UserCheck className="w-8 h-8 text-yellow-500" />
79-
</div>
80-
</div>
81-
82-
<div className="bg-background-700 border border-background-600 rounded-lg p-6">
83-
<div className="flex items-center justify-between">
84-
<div>
85-
<p className="text-gray-400 text-sm">This Month</p>
86-
<p className="text-2xl font-semibold text-white">-</p>
104+
{/* Quick Stats */}
105+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
106+
{stats.map((stat) => {
107+
const Icon = stat.icon;
108+
return (
109+
<div
110+
key={stat.label}
111+
className="bg-background-700 border border-background-600 rounded-lg p-6"
112+
>
113+
<div className="flex items-center justify-between">
114+
<div>
115+
<p className="text-gray-400 text-sm">{stat.label}</p>
116+
<p className="text-2xl font-semibold text-white">
117+
{stat.value}
118+
</p>
119+
</div>
120+
<Icon className={`w-8 h-8 ${stat.color}`} />
121+
</div>
87122
</div>
88-
<BarChart3 className="w-8 h-8 text-orange-500" />
89-
</div>
90-
</div>
123+
);
124+
})}
91125
</div>
92126

93127
{/* Admin Routes Grid */}

‎src/components/forms/applications/columns.tsx‎

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ColumnDef, Row } from "@tanstack/react-table";
2-
import { useContext, useEffect, useState } from "react";
2+
import { useContext, useEffect, useRef, useState } from "react";
33
import MultiSelect from "@/components/general/multiSelect";
44
import {
55
sendStatusEmailToUser,
@@ -480,6 +480,7 @@ function TextareaField({
480480
field: FormFields[keyof FormFields];
481481
}) {
482482
const [text, setText] = useState(value);
483+
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
483484
const { mergeNewData } = useContext(formContext);
484485
const canUpdate = field.config?.allowUpdate;
485486
if (!canUpdate) {
@@ -488,19 +489,22 @@ function TextareaField({
488489
}
489490
return <span>{value}</span>;
490491
}
491-
const updateField = async (val: string) => {
492-
const prev = text;
492+
const updateField = (val: string) => {
493493
const setVal = val === undefined || val === null || val === "" ? null : val;
494494
setText(setVal);
495-
updateSubmissionField(submissionId, id, field.config?.tableName, setVal)
496-
.then(() => {
497-
mergeNewData({ [id]: setVal }, "id", submissionId);
498-
toast.success("Field updated successfully");
499-
})
500-
.catch(() => {
501-
setText(prev);
502-
toast.error("Error updating field");
503-
});
495+
// Debounce: one server call per typing pause instead of per keystroke
496+
if (debounceRef.current) {
497+
clearTimeout(debounceRef.current);
498+
}
499+
debounceRef.current = setTimeout(() => {
500+
updateSubmissionField(submissionId, id, field.config?.tableName, setVal)
501+
.then(() => {
502+
mergeNewData({ [id]: setVal }, "id", submissionId);
503+
})
504+
.catch(() => {
505+
toast.error("Error saving changes — retry after editing again");
506+
});
507+
}, 800);
504508
};
505509
return (
506510
<FloatingTextArea
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import { useRouter } from "next/navigation";
5+
import { cloneForm } from "@/app/portal/admin/actions";
6+
import { Button } from "@/components/primitives/button";
7+
import { CopyIcon, LoaderCircleIcon } from "lucide-react";
8+
import { toast } from "sonner";
9+
10+
export default function CloneFormButton({
11+
formId,
12+
formTitle,
13+
}: {
14+
formId: number;
15+
formTitle: string;
16+
}) {
17+
const router = useRouter();
18+
const [loading, setLoading] = useState(false);
19+
20+
return (
21+
<Button
22+
disabled={loading}
23+
onClick={(e) => {
24+
e.preventDefault();
25+
e.stopPropagation();
26+
setLoading(true);
27+
cloneForm(formId)
28+
.then(() => {
29+
toast.success(`Duplicated "${formTitle}"`);
30+
router.refresh();
31+
})
32+
.catch(() => {
33+
toast.error("Error duplicating form");
34+
})
35+
.finally(() => setLoading(false));
36+
}}
37+
className={
38+
"bg-background-600 border border-background-500 rounded-md p-2 hover:border-lp-500 transition-colors"
39+
}
40+
aria-label={`Duplicate ${formTitle}`}
41+
>
42+
{loading ? (
43+
<LoaderCircleIcon className={"h-4 w-4 animate-spin"} />
44+
) : (
45+
<CopyIcon className={"h-4 w-4"} />
46+
)}
47+
</Button>
48+
);
49+
}

0 commit comments

Comments
 (0)