From 5c4200158fc9ab1fc5c42c7238cc902fc4b8f024 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 10 Feb 2026 20:13:47 +0100 Subject: [PATCH] Improve projects UX, settings layout, uppercase names, per-page selector, and fix round deletion - Fix round deletion FK constraint: add onDelete Cascade on Evaluation.form and SetNull on ProjectFile.round - Add configurable per-page selector (10/20/50/100) to Pagination component, wired in projects page with URL sync - Add display_project_names_uppercase setting in admin defaults, applied to project titles across desktop/mobile views - Redesign admin settings page: vertical sidebar nav on desktop with grouped sections, horizontal scrollable tabs on mobile - Polish projects page: responsive header with total count, search clear button with result count, status stats bar, submission date column, country display, mobile card file count Co-Authored-By: Claude Opus 4.6 --- prisma/schema.prisma | 4 +- src/app/(admin)/admin/projects/page.tsx | 129 ++++++++++--- .../settings/defaults-settings-form.tsx | 28 +++ src/components/settings/settings-content.tsx | 177 +++++++++++++----- src/components/shared/pagination.tsx | 82 +++++--- 5 files changed, 323 insertions(+), 97 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 325ecbe..ac6f847 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -628,7 +628,7 @@ model ProjectFile { // Relations project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - round Round? @relation(fields: [roundId], references: [id]) + round Round? @relation(fields: [roundId], references: [id], onDelete: SetNull) requirement FileRequirement? @relation(fields: [requirementId], references: [id], onDelete: SetNull) replacedBy ProjectFile? @relation("FileVersions", fields: [replacedById], references: [id], onDelete: SetNull) replacements ProjectFile[] @relation("FileVersions") @@ -706,7 +706,7 @@ model Evaluation { // Relations assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade) - form EvaluationForm @relation(fields: [formId], references: [id]) + form EvaluationForm @relation(fields: [formId], references: [id], onDelete: Cascade) @@index([status]) @@index([submittedAt]) diff --git a/src/app/(admin)/admin/projects/page.tsx b/src/app/(admin)/admin/projects/page.tsx index efb30e7..e5dae26 100644 --- a/src/app/(admin)/admin/projects/page.tsx +++ b/src/app/(admin)/admin/projects/page.tsx @@ -104,7 +104,7 @@ const statusColors: Record< function parseFiltersFromParams( searchParams: URLSearchParams -): ProjectFilters & { page: number } { +): ProjectFilters & { page: number; perPage: number } { return { search: searchParams.get('q') || '', statuses: searchParams.get('status') @@ -133,11 +133,12 @@ function parseFiltersFromParams( ? false : undefined, page: parseInt(searchParams.get('page') || '1', 10), + perPage: parseInt(searchParams.get('pp') || '20', 10), } } function filtersToParams( - filters: ProjectFilters & { page: number } + filters: ProjectFilters & { page: number; perPage: number } ): URLSearchParams { const params = new URLSearchParams() if (filters.search) params.set('q', filters.search) @@ -155,11 +156,10 @@ function filtersToParams( if (filters.hasAssignments !== undefined) params.set('hasAssign', String(filters.hasAssignments)) if (filters.page > 1) params.set('page', String(filters.page)) + if (filters.perPage !== 20) params.set('pp', String(filters.perPage)) return params } -const PER_PAGE = 20 - export default function ProjectsPage() { const pathname = usePathname() @@ -178,8 +178,17 @@ export default function ProjectsPage() { hasAssignments: parsed.hasAssignments, }) const [page, setPage] = useState(parsed.page) + const [perPage, setPerPage] = useState(parsed.perPage || 20) const [searchInput, setSearchInput] = useState(parsed.search) + // Fetch display settings + const { data: displaySettings } = trpc.settings.getMultiple.useQuery({ + keys: ['display_project_names_uppercase'], + }) + const uppercaseNames = displaySettings?.find( + (s: { key: string; value: string }) => s.key === 'display_project_names_uppercase' + )?.value !== 'false' + // Debounced search useEffect(() => { const timer = setTimeout(() => { @@ -193,8 +202,8 @@ export default function ProjectsPage() { // Sync URL const syncUrl = useCallback( - (f: ProjectFilters, p: number) => { - const params = filtersToParams({ ...f, page: p }) + (f: ProjectFilters, p: number, pp: number) => { + const params = filtersToParams({ ...f, page: p, perPage: pp }) const qs = params.toString() window.history.replaceState(null, '', qs ? `${pathname}?${qs}` : pathname) }, @@ -202,8 +211,13 @@ export default function ProjectsPage() { ) useEffect(() => { - syncUrl(filters, page) - }, [filters, page, syncUrl]) + syncUrl(filters, page, perPage) + }, [filters, page, perPage, syncUrl]) + + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage) + setPage(1) + } // Reset page when filters change const handleFiltersChange = (newFilters: ProjectFilters) => { @@ -248,7 +262,7 @@ export default function ProjectsPage() { hasFiles: filters.hasFiles, hasAssignments: filters.hasAssignments, page, - perPage: PER_PAGE, + perPage, } const utils = trpc.useUtils() @@ -459,14 +473,14 @@ export default function ProjectsPage() { return (
{/* Header */} -
+

Projects

- Manage submitted projects across all rounds + {data ? `${data.total} projects across all rounds` : 'Manage submitted projects across all rounds'}

-
+
{/* Search */} -
- - setSearchInput(e.target.value)} - placeholder="Search projects by title, team, or description..." - className="pl-10" - /> +
+
+ + setSearchInput(e.target.value)} + placeholder="Search projects by title, team, or description..." + className="pl-10 pr-10" + /> + {searchInput && ( + + )} +
+ {filters.search && data && ( +

+ {data.total} result{data.total !== 1 ? 's' : ''} for “{filters.search}” +

+ )}
{/* Filters */} @@ -510,6 +540,37 @@ export default function ProjectsPage() { onChange={handleFiltersChange} /> + {/* Stats Summary */} + {data && data.projects.length > 0 && ( +
+ {Object.entries( + data.projects.reduce>((acc, p) => { + const s = p.status ?? 'SUBMITTED' + acc[s] = (acc[s] || 0) + 1 + return acc + }, {}) + ) + .sort(([a], [b]) => { + const order = ['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'WINNER', 'REJECTED', 'WITHDRAWN'] + return order.indexOf(a) - order.indexOf(b) + }) + .map(([status, count]) => ( + + {count} {status.charAt(0) + status.slice(1).toLowerCase().replace('_', ' ')} + + ))} + {data.total > data.projects.length && ( + + (page {data.page} of {data.totalPages}) + + )} +
+ )} + {/* Content */} {isLoading ? ( @@ -576,10 +637,11 @@ export default function ProjectsPage() { /> )} - Project + Project Round Files Assignments + Submitted Status Actions @@ -613,11 +675,14 @@ export default function ProjectsPage() { fallback="initials" />
-

+

{truncate(project.title, 40)}

{project.teamName} + {project.country && ( + ยท {project.country} + )}

@@ -650,6 +715,11 @@ export default function ProjectsPage() { {project._count.assignments}
+ + {project.createdAt + ? new Date(project.createdAt).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) + : '-'} + @@ -733,7 +803,7 @@ export default function ProjectsPage() { />
- + {project.title} Assignments {project._count.assignments} jurors
+
+ Files + {project._count?.files ?? 0} +
+ {project.createdAt && ( +
+ Submitted + {new Date(project.createdAt).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })} +
+ )} @@ -773,8 +853,9 @@ export default function ProjectsPage() { page={data.page} totalPages={data.totalPages} total={data.total} - perPage={PER_PAGE} + perPage={perPage} onPageChange={setPage} + onPerPageChange={handlePerPageChange} /> ) : null} diff --git a/src/components/settings/defaults-settings-form.tsx b/src/components/settings/defaults-settings-form.tsx index 28b2578..9ffd972 100644 --- a/src/components/settings/defaults-settings-form.tsx +++ b/src/components/settings/defaults-settings-form.tsx @@ -8,6 +8,7 @@ import { Loader2, Settings } from 'lucide-react' import { trpc } from '@/lib/trpc/client' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' +import { Switch } from '@/components/ui/switch' import { Form, FormControl, @@ -41,6 +42,7 @@ const formSchema = z.object({ default_timezone: z.string().min(1, 'Timezone is required'), default_page_size: z.string().regex(/^\d+$/, 'Must be a number'), autosave_interval_seconds: z.string().regex(/^\d+$/, 'Must be a number'), + display_project_names_uppercase: z.string().optional(), }) type FormValues = z.infer @@ -50,6 +52,7 @@ interface DefaultsSettingsFormProps { default_timezone?: string default_page_size?: string autosave_interval_seconds?: string + display_project_names_uppercase?: string } } @@ -62,6 +65,7 @@ export function DefaultsSettingsForm({ settings }: DefaultsSettingsFormProps) { default_timezone: settings.default_timezone || 'Europe/Monaco', default_page_size: settings.default_page_size || '20', autosave_interval_seconds: settings.autosave_interval_seconds || '30', + display_project_names_uppercase: settings.display_project_names_uppercase || 'true', }, }) @@ -81,6 +85,7 @@ export function DefaultsSettingsForm({ settings }: DefaultsSettingsFormProps) { { key: 'default_timezone', value: data.default_timezone }, { key: 'default_page_size', value: data.default_page_size }, { key: 'autosave_interval_seconds', value: data.autosave_interval_seconds }, + { key: 'display_project_names_uppercase', value: data.display_project_names_uppercase || 'true' }, ], }) } @@ -162,6 +167,29 @@ export function DefaultsSettingsForm({ settings }: DefaultsSettingsFormProps) { )} /> + ( + +
+ + Display Project Names in Uppercase + + + Show all project names in uppercase across the platform for a cleaner presentation + +
+ + field.onChange(String(checked))} + /> + +
+ )} + /> + - - Page {page} of {totalPages} - - +
+

+ Showing {from} to {to} of {total} results +

+ {onPerPageChange && ( + + )}
+ {totalPages > 1 && ( +
+ + + Page {page} of {totalPages} + + +
+ )}
) }