diff --git a/apps/client/src/components/organisms/AdminEventsTableHeader.tsx b/apps/client/src/components/organisms/AdminEventsTableHeader.tsx new file mode 100644 index 00000000..64a5d8b5 --- /dev/null +++ b/apps/client/src/components/organisms/AdminEventsTableHeader.tsx @@ -0,0 +1,217 @@ +import { Input } from '@/components/atoms'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + AppDateRangeFilter, + AppMultiSelectFilter, + type DateRangeValue, +} from './AppTableFilters'; +import { AppTableHeader, type AppTableColumn } from './AppTableHeader'; + +export type AdminEventSortColumn = + | 'name' + | 'date' + | 'organizer' + | 'discipline' + | 'authorName' + | 'published' + | 'ranking'; + +export type AdminEventTextFilterColumn = 'name' | 'organizer' | 'authorName'; + +const DISCIPLINE_VALUES = [ + 'SPRINT', + 'MIDDLE', + 'LONG', + 'ULTRALONG', + 'NIGHT', + 'KNOCKOUT_SPRINT', + 'RELAY', + 'SPRINT_RELAY', + 'TEAMS', + 'OTHER', +] as const; + +export type AdminEventsTableHeaderProps = { + sortConfig: { column: AdminEventSortColumn; direction: 'asc' | 'desc' }; + onSort: (column: AdminEventSortColumn) => void; + textFilters: Record; + onTextFilterChange: ( + column: AdminEventTextFilterColumn, + value: string + ) => void; + dateRange: DateRangeValue; + onDateRangeChange: (next: DateRangeValue) => void; + disciplineFilters: string[]; + onDisciplineFiltersChange: (next: string[]) => void; + publishedFilters: string[]; + onPublishedFiltersChange: (next: string[]) => void; + rankingFilters: string[]; + onRankingFiltersChange: (next: string[]) => void; + showOwner?: boolean; +}; + +export const AdminEventsTableHeader = ({ + sortConfig, + onSort, + textFilters, + onTextFilterChange, + dateRange, + onDateRangeChange, + disciplineFilters, + onDisciplineFiltersChange, + publishedFilters, + onPublishedFiltersChange, + rankingFilters, + onRankingFiltersChange, + showOwner = true, +}: AdminEventsTableHeaderProps) => { + const { t } = useTranslation(); + + const disciplineOptions = useMemo( + () => + DISCIPLINE_VALUES.map(value => ({ + value, + label: t(`Pages.Event.Form.DisciplineOptions.${value}`), + })), + [t] + ); + + const booleanOptions = useMemo( + () => [ + { value: 'true', label: t('Pages.Admin.Table.Yes') }, + { value: 'false', label: t('Pages.Admin.Table.No') }, + ], + [t] + ); + + const selectedCountLabel = (count: number) => + t('Pages.Event.Report.Filters.SelectedCount', { count }); + const clearLabel = t('Pages.Event.Report.Filters.ClearSelection'); + + const columns: AppTableColumn[] = [ + { + id: 'name', + label: t('Pages.Admin.Table.Event'), + sortable: true, + filter: ( + onTextFilterChange('name', event.target.value)} + className="mt-2 h-8 bg-background text-xs" + /> + ), + }, + { + id: 'date', + label: t('Pages.Admin.Table.Date'), + sortable: true, + filter: ( + + ), + }, + { + id: 'organizer', + label: t('Pages.Admin.Table.Organizer'), + sortable: true, + filter: ( + + onTextFilterChange('organizer', event.target.value) + } + className="mt-2 h-8 bg-background text-xs" + /> + ), + }, + { + id: 'discipline', + label: t('Pages.Admin.Table.Discipline'), + sortable: true, + filter: ( + + ), + }, + ]; + + if (showOwner) { + columns.push({ + id: 'authorName', + label: t('Pages.Admin.Table.Owner'), + sortable: true, + filter: ( + + onTextFilterChange('authorName', event.target.value) + } + className="mt-2 h-8 bg-background text-xs" + /> + ), + }); + } + + columns.push( + { + id: 'published', + label: t('Pages.Admin.Table.Published'), + sortable: true, + filter: ( + + ), + }, + { + id: 'ranking', + label: t('Pages.Admin.Table.Ranking'), + sortable: true, + filter: ( + + ), + } + ); + + const columnOrder = columns.map(column => column.id); + + return ( + + ); +}; diff --git a/apps/client/src/components/organisms/AdminUsersTableHeader.tsx b/apps/client/src/components/organisms/AdminUsersTableHeader.tsx new file mode 100644 index 00000000..24f008ca --- /dev/null +++ b/apps/client/src/components/organisms/AdminUsersTableHeader.tsx @@ -0,0 +1,212 @@ +import { Input } from '@/components/atoms'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + AppDateRangeFilter, + AppMultiSelectFilter, + type DateRangeValue, +} from './AppTableFilters'; +import { AppTableHeader, type AppTableColumn } from './AppTableHeader'; + +export type AdminUserSortColumn = + | 'name' + | 'email' + | 'emailVerified' + | 'role' + | 'active' + | 'organisation' + | 'createdAt'; + +export type AdminUserColumnId = AdminUserSortColumn | 'actions'; + +export type AdminUserTextFilterColumn = 'name' | 'email' | 'organisation'; + +export type AdminUsersTableHeaderProps = { + sortConfig: { column: AdminUserSortColumn; direction: 'asc' | 'desc' }; + onSort: (column: AdminUserSortColumn) => void; + textFilters: Record; + onTextFilterChange: ( + column: AdminUserTextFilterColumn, + value: string + ) => void; + emailVerifiedFilters: string[]; + onEmailVerifiedFiltersChange: (next: string[]) => void; + roleFilters: string[]; + onRoleFiltersChange: (next: string[]) => void; + activeFilters: string[]; + onActiveFiltersChange: (next: string[]) => void; + createdAtRange: DateRangeValue; + onCreatedAtRangeChange: (next: DateRangeValue) => void; +}; + +export const AdminUsersTableHeader = ({ + sortConfig, + onSort, + textFilters, + onTextFilterChange, + emailVerifiedFilters, + onEmailVerifiedFiltersChange, + roleFilters, + onRoleFiltersChange, + activeFilters, + onActiveFiltersChange, + createdAtRange, + onCreatedAtRangeChange, +}: AdminUsersTableHeaderProps) => { + const { t } = useTranslation(); + + const booleanOptions = useMemo( + () => [ + { value: 'true', label: t('Pages.Admin.Table.Yes') }, + { value: 'false', label: t('Pages.Admin.Table.No') }, + ], + [t] + ); + + const roleOptions = useMemo( + () => [ + { value: 'USER', label: 'USER' }, + { value: 'ADMIN', label: 'ADMIN' }, + ], + [] + ); + + const activeOptions = useMemo( + () => [ + { value: 'true', label: t('Pages.Admin.Table.Active') }, + { value: 'false', label: t('Pages.Admin.Table.Inactive') }, + ], + [t] + ); + + const selectedCountLabel = (count: number) => + t('Pages.Event.Report.Filters.SelectedCount', { count }); + const clearLabel = t('Pages.Event.Report.Filters.ClearSelection'); + + const columns: AppTableColumn[] = [ + { + id: 'name', + label: t('Pages.Admin.Table.Name'), + sortable: true, + filter: ( + onTextFilterChange('name', event.target.value)} + className="mt-2 h-8 bg-background text-xs" + /> + ), + }, + { + id: 'email', + label: t('Pages.Admin.Table.Email'), + sortable: true, + filter: ( + onTextFilterChange('email', event.target.value)} + className="mt-2 h-8 bg-background text-xs" + /> + ), + }, + { + id: 'emailVerified', + label: t('Pages.Admin.Table.EmailVerified'), + sortable: true, + filter: ( + + ), + }, + { + id: 'role', + label: t('Pages.Admin.Table.Role'), + sortable: true, + filter: ( + + ), + }, + { + id: 'active', + label: t('Pages.Admin.Table.Status'), + sortable: true, + filter: ( + + ), + }, + { + id: 'organisation', + label: t('Pages.Admin.Table.Organization'), + sortable: true, + filter: ( + + onTextFilterChange('organisation', event.target.value) + } + className="mt-2 h-8 bg-background text-xs" + /> + ), + }, + { + id: 'createdAt', + label: t('Pages.Admin.Table.CreatedAt'), + sortable: true, + filter: ( + + ), + }, + { + id: 'actions', + label: t('Pages.Admin.Table.Actions'), + }, + ]; + + const columnOrder = columns.map(column => column.id); + + return ( + { + if (column === 'actions') return; + onSort(column); + }} + /> + ); +}; diff --git a/apps/client/src/components/organisms/AppTableFilters.tsx b/apps/client/src/components/organisms/AppTableFilters.tsx new file mode 100644 index 00000000..e559f160 --- /dev/null +++ b/apps/client/src/components/organisms/AppTableFilters.tsx @@ -0,0 +1,218 @@ +import { Button, Checkbox, Input } from '@/components/atoms'; +import { Calendar } from '@/components/ui/calendar'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; +import { format } from 'date-fns'; +import { CalendarIcon, ChevronDown } from 'lucide-react'; +import type { DateRange } from 'react-day-picker'; + +export type SelectOption = { value: string; label: string }; +export type DateRangeValue = { + range: DateRange | undefined; + fromTime: string; + toTime: string; +}; + +export type AppMultiSelectFilterProps = { + placeholder: string; + options: readonly SelectOption[]; + selected: string[]; + onChange: (next: string[]) => void; + selectedCountLabel: (count: number) => string; + clearLabel: string; +}; + +export const AppMultiSelectFilter = ({ + placeholder, + options, + selected, + onChange, + selectedCountLabel, + clearLabel, +}: AppMultiSelectFilterProps) => { + const toggleValue = (value: string) => { + if (selected.includes(value)) { + onChange(selected.filter(item => item !== value)); + } else { + onChange([...selected, value]); + } + }; + + const summary = + selected.length === 0 + ? placeholder + : selected.length === 1 + ? selected[0] + : selectedCountLabel(selected.length); + + return ( +
+ + + + + +
+ {options.map(option => { + const isChecked = selected.includes(option.value); + return ( + + ); + })} +
+ {selected.length > 0 && ( + + )} +
+
+
+ ); +}; + +export type AppDateRangeFilterProps = { + value: DateRangeValue; + onChange: (next: DateRangeValue) => void; + label: string; + fromLabel: string; + toLabel: string; + clearLabel: string; + timeFromLabel: string; + timeToLabel: string; +}; + +export const AppDateRangeFilter = ({ + value, + onChange, + label, + fromLabel, + toLabel, + clearLabel, + timeFromLabel, + timeToLabel, +}: AppDateRangeFilterProps) => { + const from = value.range?.from; + const to = value.range?.to; + const summary = from + ? to + ? `${format(from, 'd. M. yyyy')} – ${format(to, 'd. M. yyyy')}` + : `${format(from, 'd. M. yyyy')} – …` + : label; + + return ( +
+ + + + + +
+ + onChange({ + ...value, + range, + }) + } + className="p-0" + {...(from ? { defaultMonth: from } : {})} + /> + +
+ + +
+ +
+ + {fromLabel} / {toLabel} + + {(value.range?.from || value.range?.to) && ( + + )} +
+
+
+
+
+ ); +}; diff --git a/apps/client/src/components/organisms/ReportTableHeader.tsx b/apps/client/src/components/organisms/ReportTableHeader.tsx index 2694bb3e..6f13ca13 100644 --- a/apps/client/src/components/organisms/ReportTableHeader.tsx +++ b/apps/client/src/components/organisms/ReportTableHeader.tsx @@ -1,24 +1,15 @@ -import { Button, Checkbox, Input } from '@/components/atoms'; +import { Input } from '@/components/atoms'; import { AppTableHeader, type AppTableColumn } from '@/components/organisms'; -import { Calendar } from '@/components/ui/calendar'; import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@/components/ui/popover'; -import { cn } from '@/lib/utils'; -import { format } from 'date-fns'; -import { CalendarIcon, ChevronDown } from 'lucide-react'; -import type { DateRange } from 'react-day-picker'; + AppDateRangeFilter, + AppMultiSelectFilter, + type DateRangeValue, + type SelectOption, +} from '@/components/organisms/AppTableFilters'; import { useTranslation } from 'react-i18next'; import { ColumnFilter, SortColumn } from '@/types/reportTable'; -export type SelectOption = { value: string; label: string }; -export type DateRangeValue = { - range: DateRange | undefined; - fromTime: string; - toTime: string; -}; +export type { DateRangeValue, SelectOption }; export type ReportTableHeaderProps = { sortConfig: { column: SortColumn; direction: 'asc' | 'desc' }; @@ -77,7 +68,7 @@ export const ReportTableHeader = ({ label: t('Pages.Event.Report.Table.DateTime'), sortable: true, filter: ( - + t('Pages.Event.Report.Filters.SelectedCount', { count }) + } + clearLabel={t('Pages.Event.Report.Filters.ClearSelection')} /> ), }, @@ -111,11 +106,15 @@ export const ReportTableHeader = ({ label: t('Pages.Event.Report.Table.Type'), sortable: true, filter: ( - + t('Pages.Event.Report.Filters.SelectedCount', { count }) + } + clearLabel={t('Pages.Event.Report.Filters.ClearSelection')} /> ), }, @@ -204,202 +203,3 @@ export const ReportTableHeader = ({ /> ); }; - -type MultiSelectDropdownProps = { - placeholder: string; - options: readonly SelectOption[]; - selected: string[]; - onChange: (next: string[]) => void; -}; - -const MultiSelectDropdown = ({ - placeholder, - options, - selected, - onChange, -}: MultiSelectDropdownProps) => { - const { t } = useTranslation(); - const toggleValue = (value: string) => { - if (selected.includes(value)) { - onChange(selected.filter(item => item !== value)); - } else { - onChange([...selected, value]); - } - }; - - const summary = - selected.length === 0 - ? placeholder - : selected.length === 1 - ? selected[0] - : t('Pages.Event.Report.Filters.SelectedCount', { - count: selected.length, - }); - - return ( -
- - - - - -
- {options.map(option => { - const isChecked = selected.includes(option.value); - return ( - - ); - })} -
- {selected.length > 0 && ( - - )} -
-
-
- ); -}; - -type DateRangeFilterProps = { - value: DateRangeValue; - onChange: (next: DateRangeValue) => void; - label: string; - fromLabel: string; - toLabel: string; - clearLabel: string; - timeFromLabel: string; - timeToLabel: string; -}; - -const DateRangeFilter = ({ - value, - onChange, - label, - fromLabel, - toLabel, - clearLabel, - timeFromLabel, - timeToLabel, -}: DateRangeFilterProps) => { - const from = value.range?.from; - const to = value.range?.to; - const summary = from - ? to - ? `${format(from, 'd. M. yyyy')} – ${format(to, 'd. M. yyyy')}` - : `${format(from, 'd. M. yyyy')} – …` - : label; - - return ( -
- - - - - -
- - onChange({ - ...value, - range, - }) - } - className="p-0" - {...(from ? { defaultMonth: from } : {})} - /> - -
- - -
- -
- - {fromLabel} / {toLabel} - - {(value.range?.from || value.range?.to) && ( - - )} -
-
-
-
-
- ); -}; diff --git a/apps/client/src/components/organisms/index.ts b/apps/client/src/components/organisms/index.ts index 06c435ab..57e854fa 100644 --- a/apps/client/src/components/organisms/index.ts +++ b/apps/client/src/components/organisms/index.ts @@ -3,8 +3,11 @@ export * from './AdminHeader'; export * from './AdminSidebar'; export * from './AutoCompleteField'; export * from './CountdownTimer'; +export * from './AdminEventsTableHeader'; +export * from './AdminUsersTableHeader'; export * from './AppDataTable'; export * from './AppFilters'; +export * from './AppTableFilters'; export * from './AppTableHeader'; export * from './AppTableRow'; export * from './AppTableLoadingData'; diff --git a/apps/client/src/i18n/locales/cs/translation.json b/apps/client/src/i18n/locales/cs/translation.json index 502fb05d..a25c46ad 100644 --- a/apps/client/src/i18n/locales/cs/translation.json +++ b/apps/client/src/i18n/locales/cs/translation.json @@ -67,6 +67,11 @@ "RequestVerificationSuccessTitle": "Ověřovací e-mail odeslán", "RequestVerificationSuccessDescription": "Ověřovací e-mail byl odeslán uživateli {{name}}.", "RequestVerificationErrorTitle": "Nepodařilo se odeslat ověřovací e-mail" + }, + "EventsDialog": { + "Title": "Akce uživatele {{name}}", + "Description": "Akce vytvořené tímto uživatelem.", + "Empty": "Tento uživatel ještě nevytvořil žádnou akci." } }, "Events": { @@ -268,7 +273,20 @@ "Actions": "Akce", "Yes": "Ano", "No": "Ne", - "Discipline": "Disciplína" + "Discipline": "Disciplína", + "Filters": { + "NamePlaceholder": "Filtrovat jméno", + "EmailPlaceholder": "Filtrovat email", + "OrganizerPlaceholder": "Filtrovat pořadatele", + "OwnerPlaceholder": "Filtrovat vlastníka", + "OrganizationPlaceholder": "Filtrovat organizaci", + "DisciplinePlaceholder": "Vybrat disciplínu", + "PublishedPlaceholder": "Vybrat publikováno", + "RankingPlaceholder": "Vybrat ranking", + "RolePlaceholder": "Vybrat roli", + "StatusPlaceholder": "Vybrat stav", + "EmailVerifiedPlaceholder": "Vybrat ověření" + } } }, "Auth": { diff --git a/apps/client/src/i18n/locales/de/translation.json b/apps/client/src/i18n/locales/de/translation.json index 07bf65c8..f92ec978 100644 --- a/apps/client/src/i18n/locales/de/translation.json +++ b/apps/client/src/i18n/locales/de/translation.json @@ -67,6 +67,11 @@ "RequestVerificationSuccessTitle": "Bestätigungs-E-Mail gesendet", "RequestVerificationSuccessDescription": "Eine Bestätigungs-E-Mail wurde an {{name}} gesendet.", "RequestVerificationErrorTitle": "Bestätigungs-E-Mail konnte nicht gesendet werden" + }, + "EventsDialog": { + "Title": "Veranstaltungen von {{name}}", + "Description": "Von diesem Benutzer erstellte Veranstaltungen.", + "Empty": "Dieser Benutzer hat noch keine Veranstaltung erstellt." } }, "Events": { @@ -268,7 +273,20 @@ "Actions": "Aktionen", "Yes": "Ja", "No": "Nein", - "Discipline": "Disziplin" + "Discipline": "Disziplin", + "Filters": { + "NamePlaceholder": "Name filtern", + "EmailPlaceholder": "E-Mail filtern", + "OrganizerPlaceholder": "Veranstalter filtern", + "OwnerPlaceholder": "Eigentümer filtern", + "OrganizationPlaceholder": "Organisation filtern", + "DisciplinePlaceholder": "Disziplin auswählen", + "PublishedPlaceholder": "Veröffentlicht auswählen", + "RankingPlaceholder": "Ranking auswählen", + "RolePlaceholder": "Rolle auswählen", + "StatusPlaceholder": "Status auswählen", + "EmailVerifiedPlaceholder": "Verifizierung auswählen" + } } }, "Auth": { diff --git a/apps/client/src/i18n/locales/en/translation.json b/apps/client/src/i18n/locales/en/translation.json index e3a83d85..e67253a3 100644 --- a/apps/client/src/i18n/locales/en/translation.json +++ b/apps/client/src/i18n/locales/en/translation.json @@ -67,6 +67,11 @@ "RequestVerificationSuccessTitle": "Verification email sent", "RequestVerificationSuccessDescription": "A verification email was sent to {{name}}.", "RequestVerificationErrorTitle": "Failed to send verification email" + }, + "EventsDialog": { + "Title": "Events by {{name}}", + "Description": "Events authored by this user.", + "Empty": "This user has not authored any events yet." } }, "Events": { @@ -268,7 +273,20 @@ "Actions": "Actions", "Yes": "Yes", "No": "No", - "Discipline": "Discipline" + "Discipline": "Discipline", + "Filters": { + "NamePlaceholder": "Filter name", + "EmailPlaceholder": "Filter email", + "OrganizerPlaceholder": "Filter organizer", + "OwnerPlaceholder": "Filter owner", + "OrganizationPlaceholder": "Filter organization", + "DisciplinePlaceholder": "Select discipline", + "PublishedPlaceholder": "Select published", + "RankingPlaceholder": "Select ranking", + "RolePlaceholder": "Select role", + "StatusPlaceholder": "Select status", + "EmailVerifiedPlaceholder": "Select verification" + } } }, "Auth": { diff --git a/apps/client/src/i18n/locales/es/translation.json b/apps/client/src/i18n/locales/es/translation.json index c58a2dff..7712b494 100644 --- a/apps/client/src/i18n/locales/es/translation.json +++ b/apps/client/src/i18n/locales/es/translation.json @@ -67,6 +67,11 @@ "RequestVerificationSuccessTitle": "Correo de verificación enviado", "RequestVerificationSuccessDescription": "Se envió un correo de verificación a {{name}}.", "RequestVerificationErrorTitle": "No se pudo enviar el correo de verificación" + }, + "EventsDialog": { + "Title": "Eventos de {{name}}", + "Description": "Eventos creados por este usuario.", + "Empty": "Este usuario aún no ha creado ningún evento." } }, "Events": { @@ -268,7 +273,20 @@ "Actions": "Acciones", "Yes": "Sí", "No": "No", - "Discipline": "Disciplina" + "Discipline": "Disciplina", + "Filters": { + "NamePlaceholder": "Filtrar nombre", + "EmailPlaceholder": "Filtrar correo electrónico", + "OrganizerPlaceholder": "Filtrar organizador", + "OwnerPlaceholder": "Filtrar propietario", + "OrganizationPlaceholder": "Filtrar organización", + "DisciplinePlaceholder": "Seleccionar disciplina", + "PublishedPlaceholder": "Seleccionar publicado", + "RankingPlaceholder": "Seleccionar ranking", + "RolePlaceholder": "Seleccionar rol", + "StatusPlaceholder": "Seleccionar estado", + "EmailVerifiedPlaceholder": "Seleccionar verificación" + } } }, "Auth": { diff --git a/apps/client/src/i18n/locales/sv/translation.json b/apps/client/src/i18n/locales/sv/translation.json index 3c350d0c..7ff7813f 100644 --- a/apps/client/src/i18n/locales/sv/translation.json +++ b/apps/client/src/i18n/locales/sv/translation.json @@ -67,6 +67,11 @@ "RequestVerificationSuccessTitle": "Verifieringsmejl skickat", "RequestVerificationSuccessDescription": "Ett verifieringsmejl skickades till {{name}}.", "RequestVerificationErrorTitle": "Det gick inte att skicka verifieringsmejlet" + }, + "EventsDialog": { + "Title": "Tävlingar av {{name}}", + "Description": "Tävlingar skapade av denna användare.", + "Empty": "Den här användaren har inte skapat några tävlingar än." } }, "Events": { @@ -268,7 +273,20 @@ "Actions": "Åtgärder", "Yes": "Ja", "No": "Nej", - "Discipline": "Disciplin" + "Discipline": "Disciplin", + "Filters": { + "NamePlaceholder": "Filtrera namn", + "EmailPlaceholder": "Filtrera e-post", + "OrganizerPlaceholder": "Filtrera arrangör", + "OwnerPlaceholder": "Filtrera ägare", + "OrganizationPlaceholder": "Filtrera organisation", + "DisciplinePlaceholder": "Välj disciplin", + "PublishedPlaceholder": "Välj publicerad", + "RankingPlaceholder": "Välj ranking", + "RolePlaceholder": "Välj roll", + "StatusPlaceholder": "Välj status", + "EmailVerifiedPlaceholder": "Välj verifiering" + } } }, "Auth": { diff --git a/apps/client/src/lib/date.ts b/apps/client/src/lib/date.ts index e085fb07..300a5c43 100644 --- a/apps/client/src/lib/date.ts +++ b/apps/client/src/lib/date.ts @@ -281,6 +281,22 @@ export function formatDateTimeForInput(date: Date | string | number): string { return format(d, "yyyy-MM-dd'T'HH:mm"); } +/** Apply an "HH:mm" or "HH:mm:ss" time string to a Date, returning a new Date */ +export function applyTimeToDate(date: Date, time: string): Date { + const [hoursRaw, minutesRaw, secondsRaw] = time.split(':'); + const hours = Number(hoursRaw); + const minutes = Number(minutesRaw); + const seconds = Number(secondsRaw); + const result = new Date(date); + result.setHours( + Number.isFinite(hours) ? hours : 0, + Number.isFinite(minutes) ? minutes : 0, + Number.isFinite(seconds) ? seconds : 0, + 0 + ); + return result; +} + function pad2(n: number): string { return n.toString().padStart(2, '0'); } diff --git a/apps/client/src/pages/Admin/AdminEventsPage.tsx b/apps/client/src/pages/Admin/AdminEventsPage.tsx index a2203c01..964603ef 100644 --- a/apps/client/src/pages/Admin/AdminEventsPage.tsx +++ b/apps/client/src/pages/Admin/AdminEventsPage.tsx @@ -1,24 +1,25 @@ import { Link } from '@tanstack/react-router'; import { format } from 'date-fns'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Badge } from '@/components/atoms'; import { + AdminEventsTableHeader, AppDataTable, AppPagination, AppRowsPerPage, + type AdminEventSortColumn, + type AdminEventTextFilterColumn, + type DateRangeValue, } from '@/components/organisms'; -import { - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; +import { TableCell, TableRow } from '@/components/ui/table'; +import { applyTimeToDate } from '@/lib/date'; import { PATHNAMES } from '@/lib/paths/pathnames'; import { AdminPageLayout } from '@/templates'; import { useAdminEventsQuery } from './admin.hooks'; +import { getEventSortValue } from './admin.tableHelpers'; function formatDate(value: string | Date) { return format(new Date(value), 'dd.MM.yyyy'); @@ -33,6 +34,124 @@ export function AdminEventsPage() { limit: pageSize, }); + const [sortConfig, setSortConfig] = useState<{ + column: AdminEventSortColumn; + direction: 'asc' | 'desc'; + }>({ column: 'date', direction: 'desc' }); + const [textFilters, setTextFilters] = useState< + Record + >({ name: '', organizer: '', authorName: '' }); + const [dateRange, setDateRange] = useState({ + range: undefined, + fromTime: '', + toTime: '', + }); + const [disciplineFilters, setDisciplineFilters] = useState([]); + const [publishedFilters, setPublishedFilters] = useState([]); + const [rankingFilters, setRankingFilters] = useState([]); + + const handleSort = (column: AdminEventSortColumn) => { + setSortConfig(prev => + prev.column === column + ? { column, direction: prev.direction === 'asc' ? 'desc' : 'asc' } + : { column, direction: 'asc' } + ); + }; + + const updateTextFilter = ( + column: AdminEventTextFilterColumn, + value: string + ) => { + setTextFilters(prev => ({ ...prev, [column]: value.toLowerCase() })); + }; + + const visibleEvents = useMemo(() => { + const items = data?.items ?? []; + + const filtered = items.filter(event => { + if ( + textFilters.name && + !event.name.toLowerCase().includes(textFilters.name) + ) { + return false; + } + if ( + textFilters.organizer && + !(event.organizer ?? '').toLowerCase().includes(textFilters.organizer) + ) { + return false; + } + if ( + textFilters.authorName && + !(event.authorName ?? '') + .toLowerCase() + .includes(textFilters.authorName) + ) { + return false; + } + if ( + disciplineFilters.length > 0 && + !disciplineFilters.includes(event.discipline) + ) { + return false; + } + if ( + publishedFilters.length > 0 && + !publishedFilters.includes(String(event.published)) + ) { + return false; + } + if ( + rankingFilters.length > 0 && + !rankingFilters.includes(String(event.ranking)) + ) { + return false; + } + if (dateRange.range?.from || dateRange.range?.to) { + const itemTime = new Date(event.date).getTime(); + const fromTime = dateRange.range?.from + ? applyTimeToDate( + dateRange.range.from, + dateRange.fromTime || '00:00:00' + ).getTime() + : null; + const toTime = dateRange.range?.to + ? applyTimeToDate( + dateRange.range.to, + dateRange.toTime || '23:59:59' + ).getTime() + : null; + if (fromTime && itemTime < fromTime) return false; + if (toTime && itemTime > toTime) return false; + } + + return true; + }); + + return [...filtered].sort((a, b) => { + const aValue = getEventSortValue(a, sortConfig.column); + const bValue = getEventSortValue(b, sortConfig.column); + + if (typeof aValue === 'string' && typeof bValue === 'string') { + return sortConfig.direction === 'asc' + ? aValue.localeCompare(bValue) + : bValue.localeCompare(aValue); + } + + if (aValue < bValue) return sortConfig.direction === 'asc' ? -1 : 1; + if (aValue > bValue) return sortConfig.direction === 'asc' ? 1 : -1; + return 0; + }); + }, [ + data, + textFilters, + disciplineFilters, + publishedFilters, + rankingFilters, + dateRange, + sortConfig, + ]); + useEffect(() => { if (!data) return; const totalPages = Math.max(1, Math.ceil(data.total / pageSize)); @@ -62,7 +181,7 @@ export function AdminEventsPage() {
} renderHeader={ - - - {t('Pages.Admin.Table.Event')} - {t('Pages.Admin.Table.Date')} - {t('Pages.Admin.Table.Organizer')} - {t('Pages.Admin.Table.Discipline')} - {t('Pages.Admin.Table.Owner')} - {t('Pages.Admin.Table.Published')} - {t('Pages.Admin.Table.Ranking')} - - + } renderRow={event => ( diff --git a/apps/client/src/pages/Admin/AdminUsersPage.tsx b/apps/client/src/pages/Admin/AdminUsersPage.tsx index fdf6ad47..a798af62 100644 --- a/apps/client/src/pages/Admin/AdminUsersPage.tsx +++ b/apps/client/src/pages/Admin/AdminUsersPage.tsx @@ -1,24 +1,34 @@ import type { AdminUserListItem } from '@repo/shared'; import { useQueryClient } from '@tanstack/react-query'; +import { Link } from '@tanstack/react-router'; import { format } from 'date-fns'; import { Mail, MoreHorizontal, Power, PowerOff, Trash2 } from 'lucide-react'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Badge, Button, EmailVerifiedBadge } from '@/components/atoms'; import { ConfirmDialog } from '@/components/molecules'; import { useAuth } from '@/hooks/useAuth'; import { + AdminEventsTableHeader, + AdminUsersTableHeader, AppDataTable, AppPagination, AppRowsPerPage, + type AdminEventSortColumn, + type AdminEventTextFilterColumn, + type AdminUserSortColumn, + type AdminUserTextFilterColumn, + type DateRangeValue, } from '@/components/organisms'; import { - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { TableCell, TableRow } from '@/components/ui/table'; import { DropdownMenu, DropdownMenuContent, @@ -26,16 +36,19 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { applyTimeToDate } from '@/lib/date'; import { PATHNAMES } from '@/lib/paths/pathnames'; import { AdminPageLayout } from '@/templates'; import { toast } from '@/utils'; import { + useAdminEventsQuery, useAdminUserActiveMutation, useAdminUserDeleteMutation, useAdminUserRequestVerificationMutation, useAdminUsersQuery, } from './admin.hooks'; +import { getEventSortValue, getUserSortValue } from './admin.tableHelpers'; function formatDate(value: string | Date) { return format(new Date(value), 'dd.MM.yyyy'); @@ -58,6 +71,121 @@ export function AdminUsersPage() { if (page > totalPages) setPage(totalPages); }, [data, page, pageSize]); + const [userSortConfig, setUserSortConfig] = useState<{ + column: AdminUserSortColumn; + direction: 'asc' | 'desc'; + }>({ column: 'createdAt', direction: 'desc' }); + const [userTextFilters, setUserTextFilters] = useState< + Record + >({ name: '', email: '', organisation: '' }); + const [emailVerifiedFilters, setEmailVerifiedFilters] = useState( + [] + ); + const [roleFilters, setRoleFilters] = useState([]); + const [activeFilters, setActiveFilters] = useState([]); + const [createdAtRange, setCreatedAtRange] = useState({ + range: undefined, + fromTime: '', + toTime: '', + }); + + const handleUserSort = (column: AdminUserSortColumn) => { + setUserSortConfig(prev => + prev.column === column + ? { column, direction: prev.direction === 'asc' ? 'desc' : 'asc' } + : { column, direction: 'asc' } + ); + }; + + const updateUserTextFilter = ( + column: AdminUserTextFilterColumn, + value: string + ) => { + setUserTextFilters(prev => ({ ...prev, [column]: value.toLowerCase() })); + }; + + const visibleUsers = useMemo(() => { + const items = data?.items ?? []; + + const filtered = items.filter(user => { + const fullName = `${user.firstname} ${user.lastname}`.toLowerCase(); + if (userTextFilters.name && !fullName.includes(userTextFilters.name)) { + return false; + } + if ( + userTextFilters.email && + !user.email.toLowerCase().includes(userTextFilters.email) + ) { + return false; + } + if ( + userTextFilters.organisation && + !(user.organisation ?? '') + .toLowerCase() + .includes(userTextFilters.organisation) + ) { + return false; + } + if ( + emailVerifiedFilters.length > 0 && + !emailVerifiedFilters.includes(String(Boolean(user.emailVerifiedAt))) + ) { + return false; + } + if (roleFilters.length > 0 && !roleFilters.includes(user.role)) { + return false; + } + if ( + activeFilters.length > 0 && + !activeFilters.includes(String(user.active)) + ) { + return false; + } + if (createdAtRange.range?.from || createdAtRange.range?.to) { + const itemTime = new Date(user.createdAt).getTime(); + const fromTime = createdAtRange.range?.from + ? applyTimeToDate( + createdAtRange.range.from, + createdAtRange.fromTime || '00:00:00' + ).getTime() + : null; + const toTime = createdAtRange.range?.to + ? applyTimeToDate( + createdAtRange.range.to, + createdAtRange.toTime || '23:59:59' + ).getTime() + : null; + if (fromTime && itemTime < fromTime) return false; + if (toTime && itemTime > toTime) return false; + } + + return true; + }); + + return [...filtered].sort((a, b) => { + const aValue = getUserSortValue(a, userSortConfig.column); + const bValue = getUserSortValue(b, userSortConfig.column); + + if (typeof aValue === 'string' && typeof bValue === 'string') { + return userSortConfig.direction === 'asc' + ? aValue.localeCompare(bValue) + : bValue.localeCompare(aValue); + } + + if (aValue < bValue) return userSortConfig.direction === 'asc' ? -1 : 1; + if (aValue > bValue) return userSortConfig.direction === 'asc' ? 1 : -1; + return 0; + }); + }, [ + data, + userTextFilters, + emailVerifiedFilters, + roleFilters, + activeFilters, + createdAtRange, + userSortConfig, + ]); + const updateUserActiveMutation = useAdminUserActiveMutation(); const deleteUserMutation = useAdminUserDeleteMutation(); const requestVerificationMutation = useAdminUserRequestVerificationMutation(); @@ -68,6 +196,145 @@ export function AdminUsersPage() { const [deleteTarget, setDeleteTarget] = useState( null ); + const [eventsTarget, setEventsTarget] = useState( + null + ); + const [eventsPage, setEventsPage] = useState(1); + const [eventsPageSize, setEventsPageSize] = useState(25); + const { + data: eventsData, + isLoading: eventsLoading, + error: eventsError, + } = useAdminEventsQuery({ + page: eventsPage, + limit: eventsPageSize, + authorId: eventsTarget?.id, + enabled: eventsTarget !== null, + }); + + useEffect(() => { + if (!eventsData) return; + const totalPages = Math.max(1, Math.ceil(eventsData.total / eventsPageSize)); + if (eventsPage > totalPages) setEventsPage(totalPages); + }, [eventsData, eventsPage, eventsPageSize]); + + const [eventSortConfig, setEventSortConfig] = useState<{ + column: AdminEventSortColumn; + direction: 'asc' | 'desc'; + }>({ column: 'date', direction: 'desc' }); + const [eventTextFilters, setEventTextFilters] = useState< + Record + >({ name: '', organizer: '', authorName: '' }); + const [eventDateRange, setEventDateRange] = useState({ + range: undefined, + fromTime: '', + toTime: '', + }); + const [eventDisciplineFilters, setEventDisciplineFilters] = useState< + string[] + >([]); + const [eventPublishedFilters, setEventPublishedFilters] = useState< + string[] + >([]); + const [eventRankingFilters, setEventRankingFilters] = useState( + [] + ); + + const handleEventSort = (column: AdminEventSortColumn) => { + setEventSortConfig(prev => + prev.column === column + ? { column, direction: prev.direction === 'asc' ? 'desc' : 'asc' } + : { column, direction: 'asc' } + ); + }; + + const updateEventTextFilter = ( + column: AdminEventTextFilterColumn, + value: string + ) => { + setEventTextFilters(prev => ({ ...prev, [column]: value.toLowerCase() })); + }; + + const visibleUserEvents = useMemo(() => { + const items = eventsData?.items ?? []; + + const filtered = items.filter(event => { + if ( + eventTextFilters.name && + !event.name.toLowerCase().includes(eventTextFilters.name) + ) { + return false; + } + if ( + eventTextFilters.organizer && + !(event.organizer ?? '') + .toLowerCase() + .includes(eventTextFilters.organizer) + ) { + return false; + } + if ( + eventDisciplineFilters.length > 0 && + !eventDisciplineFilters.includes(event.discipline) + ) { + return false; + } + if ( + eventPublishedFilters.length > 0 && + !eventPublishedFilters.includes(String(event.published)) + ) { + return false; + } + if ( + eventRankingFilters.length > 0 && + !eventRankingFilters.includes(String(event.ranking)) + ) { + return false; + } + if (eventDateRange.range?.from || eventDateRange.range?.to) { + const itemTime = new Date(event.date).getTime(); + const fromTime = eventDateRange.range?.from + ? applyTimeToDate( + eventDateRange.range.from, + eventDateRange.fromTime || '00:00:00' + ).getTime() + : null; + const toTime = eventDateRange.range?.to + ? applyTimeToDate( + eventDateRange.range.to, + eventDateRange.toTime || '23:59:59' + ).getTime() + : null; + if (fromTime && itemTime < fromTime) return false; + if (toTime && itemTime > toTime) return false; + } + + return true; + }); + + return [...filtered].sort((a, b) => { + const aValue = getEventSortValue(a, eventSortConfig.column); + const bValue = getEventSortValue(b, eventSortConfig.column); + + if (typeof aValue === 'string' && typeof bValue === 'string') { + return eventSortConfig.direction === 'asc' + ? aValue.localeCompare(bValue) + : bValue.localeCompare(aValue); + } + + if (aValue < bValue) return eventSortConfig.direction === 'asc' ? -1 : 1; + if (aValue > bValue) return eventSortConfig.direction === 'asc' ? 1 : -1; + return 0; + }); + }, [ + eventsData, + eventTextFilters, + eventDisciplineFilters, + eventPublishedFilters, + eventRankingFilters, + eventDateRange, + eventSortConfig, + ]); const invalidateAdminQueries = async () => { await queryClient.invalidateQueries({ @@ -193,7 +460,7 @@ export function AdminUsersPage() {
} renderHeader={ - - - {t('Pages.Admin.Table.Name')} - {t('Pages.Admin.Table.Email')} - {t('Pages.Admin.Table.EmailVerified')} - {t('Pages.Admin.Table.Role')} - {t('Pages.Admin.Table.Status')} - {t('Pages.Admin.Table.Organization')} - {t('Pages.Admin.Table.CreatedAt')} - {t('Pages.Admin.Table.Actions')} - - + } renderRow={user => { const isCurrentUser = currentUser?.id === user.id; @@ -235,7 +504,16 @@ export function AdminUsersPage() { return ( - {user.firstname} {user.lastname} + {user.email} @@ -365,6 +643,102 @@ export function AdminUsersPage() { variant="destructive" onConfirm={() => void handleDeleteUser()} /> + + { + if (!open) { + setEventsTarget(null); + } + }} + > + + + + {eventsTarget && + t('Pages.Admin.Users.EventsDialog.Title', { + name: `${eventsTarget.firstname} ${eventsTarget.lastname}`, + })} + + + {t('Pages.Admin.Users.EventsDialog.Description')} + + + + { + setEventsPageSize(size); + setEventsPage(1); + }} + /> + } + renderPagination={ + + } + renderHeader={ + + } + renderRow={event => ( + + + + {event.name} + + + {formatDate(event.date)} + {event.organizer || '—'} + + {t(`Pages.Event.Form.DisciplineOptions.${event.discipline}`)} + + + + {event.published + ? t('Pages.Admin.Table.Yes') + : t('Pages.Admin.Table.No')} + + + + + {event.ranking + ? t('Pages.Admin.Table.Yes') + : t('Pages.Admin.Table.No')} + + + + )} + /> + + ); } diff --git a/apps/client/src/pages/Admin/admin.hooks.ts b/apps/client/src/pages/Admin/admin.hooks.ts index 47c87b2a..a3293867 100644 --- a/apps/client/src/pages/Admin/admin.hooks.ts +++ b/apps/client/src/pages/Admin/admin.hooks.ts @@ -157,15 +157,23 @@ export function useAdminUserRequestVerificationMutation() { export function useAdminEventsQuery({ page = 1, limit = 25, -}: { page?: number; limit?: number } = {}) { + authorId, + enabled = true, +}: { + page?: number; + limit?: number; + authorId?: number | undefined; + enabled?: boolean; +} = {}) { const api = useApi(); return useQuery({ - queryKey: ['admin', 'events', page, limit], + queryKey: ['admin', 'events', page, limit, authorId], queryFn: async () => adminEventListSchema.parse( - await api.get(ENDPOINTS.adminEvents({ page, limit })) + await api.get(ENDPOINTS.adminEvents({ page, limit, authorId })) ), + enabled, }); } diff --git a/apps/client/src/pages/Admin/admin.tableHelpers.ts b/apps/client/src/pages/Admin/admin.tableHelpers.ts new file mode 100644 index 00000000..55e1ba94 --- /dev/null +++ b/apps/client/src/pages/Admin/admin.tableHelpers.ts @@ -0,0 +1,52 @@ +import type { AdminEventListItem, AdminUserListItem } from '@repo/shared'; + +import type { AdminEventSortColumn } from '@/components/organisms/AdminEventsTableHeader'; +import type { AdminUserSortColumn } from '@/components/organisms/AdminUsersTableHeader'; + +export function getEventSortValue( + item: AdminEventListItem, + column: AdminEventSortColumn +) { + switch (column) { + case 'name': + return item.name.toLowerCase(); + case 'date': + return new Date(item.date).getTime(); + case 'organizer': + return (item.organizer ?? '').toLowerCase(); + case 'discipline': + return item.discipline; + case 'authorName': + return (item.authorName ?? '').toLowerCase(); + case 'published': + return item.published ? 1 : 0; + case 'ranking': + return item.ranking ? 1 : 0; + default: + return item.name.toLowerCase(); + } +} + +export function getUserSortValue( + item: AdminUserListItem, + column: AdminUserSortColumn +) { + switch (column) { + case 'name': + return `${item.lastname} ${item.firstname}`.toLowerCase(); + case 'email': + return item.email.toLowerCase(); + case 'emailVerified': + return item.emailVerifiedAt ? 1 : 0; + case 'role': + return item.role; + case 'active': + return item.active ? 1 : 0; + case 'organisation': + return (item.organisation ?? '').toLowerCase(); + case 'createdAt': + return new Date(item.createdAt).getTime(); + default: + return item.email.toLowerCase(); + } +} diff --git a/apps/client/src/pages/Event/Report/EventReportPage.tsx b/apps/client/src/pages/Event/Report/EventReportPage.tsx index db0c4e6e..f5c1d904 100644 --- a/apps/client/src/pages/Event/Report/EventReportPage.tsx +++ b/apps/client/src/pages/Event/Report/EventReportPage.tsx @@ -22,6 +22,7 @@ import type { } from '@/types/reportTable'; import { useApi, useEvent } from '@/hooks'; import { ENDPOINTS } from '@/lib/api/endpoints'; +import { applyTimeToDate } from '@/lib/date'; import { MainPageLayout } from '@/templates/MainPageLayout'; import { useQuery } from '@tanstack/react-query'; import { Link, useParams } from '@tanstack/react-router'; @@ -1202,17 +1203,3 @@ const getSortValue = (item: ChangelogEntry, column: SortColumn) => { } }; -const applyTimeToDate = (date: Date, time: string) => { - const [hoursRaw, minutesRaw, secondsRaw] = time.split(':'); - const hours = Number(hoursRaw); - const minutes = Number(minutesRaw); - const seconds = Number(secondsRaw); - const result = new Date(date); - result.setHours( - Number.isFinite(hours) ? hours : 0, - Number.isFinite(minutes) ? minutes : 0, - Number.isFinite(seconds) ? seconds : 0, - 0 - ); - return result; -}; diff --git a/apps/server/postman/collection.json b/apps/server/postman/collection.json index 8d39f5e3..d12a1a05 100644 --- a/apps/server/postman/collection.json +++ b/apps/server/postman/collection.json @@ -10460,7 +10460,7 @@ "method": "GET", "header": [], "url": { - "raw": "{{baseUrl}}/rest/v1/admin/events?page=1&limit=25", + "raw": "{{baseUrl}}/rest/v1/admin/events?page=1&limit=25&authorId={{authorId}}", "host": ["{{baseUrl}}"], "path": ["rest", "v1", "admin", "events"], "query": [ @@ -10473,6 +10473,11 @@ "key": "limit", "value": "25", "description": "Items per page (max 200)" + }, + { + "key": "authorId", + "value": "{{authorId}}", + "description": "Filter events to those authored by this user id (optional)" } ] }, diff --git a/apps/server/src/modules/admin/__tests__/admin.handlers.test.ts b/apps/server/src/modules/admin/__tests__/admin.handlers.test.ts index 351197bb..dbb58f71 100644 --- a/apps/server/src/modules/admin/__tests__/admin.handlers.test.ts +++ b/apps/server/src/modules/admin/__tests__/admin.handlers.test.ts @@ -55,4 +55,14 @@ describe('admin handlers pagination validation', () => { code: HTTP_STATUS.UNPROCESSABLE_CONTENT, }); }); + + it('rejects a non-numeric events authorId query parameter', async () => { + const response = await getAdminEventsHandler(createContext({ authorId: 'abc' })); + + expect(response.status).toBe(HTTP_STATUS.UNPROCESSABLE_CONTENT); + expect(response.body).toMatchObject({ + error: true, + code: HTTP_STATUS.UNPROCESSABLE_CONTENT, + }); + }); }); diff --git a/apps/server/src/modules/admin/__tests__/admin.service.test.ts b/apps/server/src/modules/admin/__tests__/admin.service.test.ts index 0fc3e951..79b7f0ae 100644 --- a/apps/server/src/modules/admin/__tests__/admin.service.test.ts +++ b/apps/server/src/modules/admin/__tests__/admin.service.test.ts @@ -10,6 +10,45 @@ import { updateAdminUserActive, } from '../admin.service.js'; +const EVENTS = [ + { + id: 'evt-1', + name: 'Spring race', + organizer: 'Alpha Club', + date: new Date('2026-04-12T00:00:00.000Z'), + discipline: 'MIDDLE' as const, + published: true, + ranking: true, + createdAt: new Date('2026-03-06T08:00:00.000Z'), + authorId: 1, + author: { firstname: 'Ada', lastname: 'Admin' }, + }, + { + id: 'evt-2', + name: 'Night challenge', + organizer: 'Beta Club', + date: new Date('2026-05-18T00:00:00.000Z'), + discipline: 'NIGHT' as const, + published: false, + ranking: true, + createdAt: new Date('2026-02-03T08:00:00.000Z'), + authorId: 2, + author: { firstname: 'Uma', lastname: 'User' }, + }, + { + id: 'evt-3', + name: 'City sprint', + organizer: 'Gamma Club', + date: new Date('2025-12-20T00:00:00.000Z'), + discipline: 'SPRINT' as const, + published: false, + ranking: false, + createdAt: new Date('2025-12-15T08:00:00.000Z'), + authorId: null, + author: null, + }, +]; + function createPrismaMock() { return { user: { @@ -73,7 +112,17 @@ function createPrismaMock() { event: { count: async ({ where, - }: { where?: { published?: boolean; ranking?: boolean; date?: { gte: Date } } } = {}) => { + }: { + where?: { + published?: boolean; + ranking?: boolean; + date?: { gte: Date }; + authorId?: number; + }; + } = {}) => { + if (where?.authorId !== undefined) { + return EVENTS.filter((event) => event.authorId === where.authorId).length; + } if (where?.published) return 1; if (where?.ranking) return 2; if (where?.date?.gte) return 2; @@ -84,54 +133,23 @@ function createPrismaMock() { skip, take, }: { - where?: { createdAt?: { gte: Date } }; + where?: { createdAt?: { gte: Date }; authorId?: number }; skip?: number; take?: number; }) => { - const events = [ - { - id: 'evt-1', - name: 'Spring race', - organizer: 'Alpha Club', - date: new Date('2026-04-12T00:00:00.000Z'), - discipline: 'MIDDLE' as const, - published: true, - ranking: true, - createdAt: new Date('2026-03-06T08:00:00.000Z'), - author: { firstname: 'Ada', lastname: 'Admin' }, - }, - { - id: 'evt-2', - name: 'Night challenge', - organizer: 'Beta Club', - date: new Date('2026-05-18T00:00:00.000Z'), - discipline: 'NIGHT' as const, - published: false, - ranking: true, - createdAt: new Date('2026-02-03T08:00:00.000Z'), - author: { firstname: 'Uma', lastname: 'User' }, - }, - { - id: 'evt-3', - name: 'City sprint', - organizer: 'Gamma Club', - date: new Date('2025-12-20T00:00:00.000Z'), - discipline: 'SPRINT' as const, - published: false, - ranking: false, - createdAt: new Date('2025-12-15T08:00:00.000Z'), - author: null, - }, - ]; - if (where?.createdAt?.gte) { - return events - .filter((event) => event.createdAt >= where.createdAt!.gte) - .map((event) => ({ createdAt: event.createdAt })); + return EVENTS.filter((event) => event.createdAt >= where.createdAt!.gte).map( + (event) => ({ createdAt: event.createdAt }), + ); } + const filtered = + where?.authorId !== undefined + ? EVENTS.filter((event) => event.authorId === where.authorId) + : EVENTS; + const offset = skip ?? 0; - return events.slice(offset, take !== undefined ? offset + take : undefined); + return filtered.slice(offset, take !== undefined ? offset + take : undefined); }, }, }; @@ -384,6 +402,23 @@ describe('admin service', () => { expect(events.items).toHaveLength(3); }); + it('filters events by authorId', async () => { + const prisma = createPrismaMock(); + const result = await getAdminEvents(prisma, { authorId: 2 }); + + expect(result.total).toBe(1); + expect(result.items).toHaveLength(1); + expect(result.items[0]?.id).toBe('evt-2'); + }); + + it('returns no events for an authorId with no events', async () => { + const prisma = createPrismaMock(); + const result = await getAdminEvents(prisma, { authorId: 999 }); + + expect(result.total).toBe(0); + expect(result.items).toHaveLength(0); + }); + it('updates admin user active state', async () => { const { prisma, state } = createUserMutationPrismaMock([ { diff --git a/apps/server/src/modules/admin/admin.handlers.ts b/apps/server/src/modules/admin/admin.handlers.ts index aa3a60f2..41bd6808 100644 --- a/apps/server/src/modules/admin/admin.handlers.ts +++ b/apps/server/src/modules/admin/admin.handlers.ts @@ -140,8 +140,11 @@ export async function getAdminEventsHandler(c) { try { const rawPage = c.req.query('page'); const rawLimit = c.req.query('limit'); + const rawAuthorId = c.req.query('authorId'); const parsedPage = parsePositiveIntegerQueryParam(rawPage, 1); const parsedLimit = parsePositiveIntegerQueryParam(rawLimit, 25); + const parsedAuthorId = + rawAuthorId === undefined ? undefined : parsePositiveIntegerQueryParam(rawAuthorId, 1); if (parsedPage === null) { return c.json( @@ -155,17 +158,25 @@ export async function getAdminEventsHandler(c) { HTTP_STATUS.UNPROCESSABLE_CONTENT, ); } + if (parsedAuthorId === null) { + return c.json( + validationResponse('Invalid authorId parameter'), + HTTP_STATUS.UNPROCESSABLE_CONTENT, + ); + } const page = parsedPage; const limit = Math.min(200, parsedLimit); + const authorId = parsedAuthorId ?? undefined; - const events = await getAdminEvents(prisma, { page, limit }); + const events = await getAdminEvents(prisma, { page, limit, authorId }); logger.info('Admin events list loaded', { ...logContext, results: { total: events.total, returned: events.items.length, + authorId, }, }); diff --git a/apps/server/src/modules/admin/admin.openapi.ts b/apps/server/src/modules/admin/admin.openapi.ts index 44055ffe..efc9274b 100644 --- a/apps/server/src/modules/admin/admin.openapi.ts +++ b/apps/server/src/modules/admin/admin.openapi.ts @@ -331,6 +331,13 @@ export const ADMIN_OPENAPI_PATHS: Record = { schema: { type: 'integer', minimum: 1, maximum: 200, default: 25 }, description: 'Items per page', }, + { + name: 'authorId', + in: 'query', + required: false, + schema: { type: 'integer', minimum: 1 }, + description: 'Filter events to those authored by this user id', + }, ], responses: { 200: eventsOkResponse, diff --git a/apps/server/src/modules/admin/admin.service.ts b/apps/server/src/modules/admin/admin.service.ts index 9eabd096..021e2eec 100644 --- a/apps/server/src/modules/admin/admin.service.ts +++ b/apps/server/src/modules/admin/admin.service.ts @@ -526,13 +526,19 @@ export async function requestAdminUserEmailVerification( export async function getAdminEvents( prisma, - { page = 1, limit = 25 }: { page?: number; limit?: number } = {}, + { + page = 1, + limit = 25, + authorId, + }: { page?: number; limit?: number; authorId?: number } = {}, ) { const skip = (page - 1) * limit; + const where = authorId ? { authorId } : undefined; const [total, events] = await Promise.all([ - prisma.event.count(), + prisma.event.count({ where }), prisma.event.findMany({ + where, orderBy: [{ createdAt: 'desc' }, { date: 'desc' }], skip, take: limit,