WebApi + ClientApp, GraphQL, Reflection

This commit is contained in:
Boris Milašinović
2026-05-06 20:55:05 +02:00
parent 8f7c704a90
commit 4fb3de19f6
196 changed files with 10395 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
VITE_AUTH0_DOMAIN=fer-web2.eu.auth0.com
VITE_AUTH0_CLIENT_ID=whed5Hdb8l1b1fGyyAz7Qrdsb2oKcSh3
VITE_AUTH0_AUDIENCE=https://erasmus-sta-2026/events-api
VITE_AUTH0_SCOPE=openid profile email events:read events:write
VITE_API_BASE_URL=https://localhost:7295
VITE_FILES_API_BASE_URL=https://localhost:7296

View File

@@ -0,0 +1,113 @@
# Events.ClientApp
`Events.ClientApp` is the Vue 3 front-end for Topic 2.
It uses:
- Vite
- Vue 3
- PrimeVue
- Auth0 Vue SDK
It is intended as a companion UI for the `Events.WebAPI` backend and demonstrates how the secured API can be consumed from a browser application.
## Scripts
Install dependencies:
```powershell
npm install
```
Start the development server:
```powershell
npm run dev
```
Build for production:
```powershell
npm run build
```
Preview the production build:
```powershell
npm run preview
```
By default, the Vite dev server runs on:
- `http://localhost:5173`
## Environment Configuration
The app reads configuration from Vite environment files.
Typical options are:
- `.env`
- `.env.local`
The project already includes:
- `.env.example`
The simplest setup is to copy `.env.example` to `.env.local` and fill in the real values.
Example:
```powershell
Copy-Item Topic2\Events.ClientApp\.env.example Topic2\Events.ClientApp\.env.local
```
## Environment Variables
### Required for Auth0 login
- `VITE_AUTH0_DOMAIN`
Auth0 tenant domain, for example `fer-web2.eu.auth0.com`
- `VITE_AUTH0_CLIENT_ID`
Auth0 client ID for the SPA application
### Optional Auth0 settings
- `VITE_AUTH0_AUDIENCE`
API audience passed to Auth0 when requesting an access token
- `VITE_AUTH0_SCOPE`
Space-separated scopes requested during login
### API configuration
- `VITE_API_BASE_URL`
Base URL of the Web API
If `VITE_API_BASE_URL` is not set, the app falls back to:
- `https://localhost:7150`
## Example
```env
VITE_AUTH0_DOMAIN=fer-web2.eu.auth0.com
VITE_AUTH0_CLIENT_ID=whed5Hdb8l1b1fGyyAz7Qrdsb2oKcSh3
VITE_AUTH0_AUDIENCE=https://erasmus-sta-2026/events-api
VITE_AUTH0_SCOPE=openid profile email events:read events:write
VITE_API_BASE_URL=https://localhost:7150
```
## Notes
- `VITE_AUTH0_DOMAIN` and `VITE_AUTH0_CLIENT_ID` are required if you want the Auth0 login flow to work.
- `VITE_AUTH0_AUDIENCE` and `VITE_AUTH0_SCOPE` are optional in code, but usually needed if the API expects bearer tokens with a specific audience and scopes.
- `VITE_API_BASE_URL` should point to the running `Events.WebAPI` instance for local development.
- `.env.local` is for local development and should not be treated as a shared secrets file.
## What The Client Demonstrates
- login and token acquisition through Auth0
- calling the secured Topic 2 API
- local development against a separately running ASP.NET Core backend

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Events Client</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
{
"name": "events-clientapp",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@auth0/auth0-vue": "^2.5.0",
"@primeuix/themes": "^2.0.3",
"primeicons": "^7.0.0",
"primevue": "^4.3.2",
"vue": "^3.5.13"
},
"devDependencies": {
"@types/node": "^22.13.10",
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "^5.8.2",
"vite": "^6.2.0",
"vue-tsc": "^2.2.8"
}
}

View File

@@ -0,0 +1,143 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useAuth0 } from '@auth0/auth0-vue';
import Button from 'primevue/button';
import ConfirmDialog from 'primevue/confirmdialog';
import Menu from 'primevue/menu';
import Tab from 'primevue/tab';
import TabList from 'primevue/tablist';
import TabPanel from 'primevue/tabpanel';
import TabPanels from 'primevue/tabpanels';
import Tabs from 'primevue/tabs';
import Toast from 'primevue/toast';
import EventsPanel from './components/EventsPanel.vue';
import PeoplePanel from './components/PeoplePanel.vue';
import RegistrationsPanel from './components/RegistrationsPanel.vue';
import SportsPanel from './components/SportsPanel.vue';
import { activeTab } from './state/uiState';
const {
isLoading,
isAuthenticated,
error,
loginWithRedirect,
logout: auth0Logout,
user
} = useAuth0();
const sportsPanelRef = ref<{ openCreate: () => void } | null>(null);
const eventsPanelRef = ref<{ openCreate: () => void } | null>(null);
const peoplePanelRef = ref<{ openCreate: () => void } | null>(null);
const registrationsPanelRef = ref<{ openCreate: () => void } | null>(null);
const userMenuRef = ref<{ toggle: (event: Event) => void } | null>(null);
const actionLabel = computed(() => {
switch (activeTab.value) {
case 'events':
return 'New event';
case 'people':
return 'New person';
case 'registrations':
return 'New registration';
case 'sports':
default:
return 'New sport';
}
});
function openActiveCreate() {
switch (activeTab.value) {
case 'events':
eventsPanelRef.value?.openCreate();
break;
case 'people':
peoplePanelRef.value?.openCreate();
break;
case 'registrations':
registrationsPanelRef.value?.openCreate();
break;
case 'sports':
default:
sportsPanelRef.value?.openCreate();
break;
}
}
function login() {
return loginWithRedirect();
}
function logout() {
return auth0Logout({ logoutParams: { returnTo: window.location.origin } });
}
const userMenuItems = [
{
label: 'Logout',
icon: 'pi pi-sign-out',
command: logout
}
];
function toggleUserMenu(event: Event) {
userMenuRef.value?.toggle(event);
}
</script>
<template>
<div class="app-shell">
<Toast position="top-right" />
<ConfirmDialog />
<Menu ref="userMenuRef" :model="userMenuItems" popup />
<section v-if="isLoading" class="workspace auth-state">
<div class="panel-card auth-card">Loading...</div>
</section>
<section v-else-if="!isAuthenticated" class="workspace auth-state">
<div class="panel-card auth-card">
<h1>Events</h1>
<p v-if="error">Error: {{ error.message }}</p>
<div class="inline-actions">
<Button label="Login" @click="login" />
</div>
</div>
</section>
<section v-else class="workspace">
<Tabs v-model:value="activeTab">
<div class="tabs-header-bar">
<TabList>
<Tab value="sports">Sports</Tab>
<Tab value="events">Events</Tab>
<Tab value="people">People</Tab>
<Tab value="registrations">Registrations</Tab>
</TabList>
<div class="tabs-header-center">
<Button :label="actionLabel" icon="pi pi-plus" @click="openActiveCreate" />
</div>
<div class="tabs-header-actions">
<button class="user-chip user-chip-button" type="button" @click="toggleUserMenu($event)">
{{ user?.email || user?.name || 'Authenticated user' }}
<span class="pi pi-angle-down" />
</button>
</div>
</div>
<TabPanels>
<TabPanel value="sports">
<SportsPanel ref="sportsPanelRef" />
</TabPanel>
<TabPanel value="events">
<EventsPanel ref="eventsPanelRef" />
</TabPanel>
<TabPanel value="people">
<PeoplePanel ref="peoplePanelRef" />
</TabPanel>
<TabPanel value="registrations">
<RegistrationsPanel ref="registrationsPanelRef" />
</TabPanel>
</TabPanels>
</Tabs>
</section>
</div>
</template>

View File

@@ -0,0 +1,61 @@
import { deleteJson, getFile, getJson, postJson, putJson } from './http';
import type {
EventDto,
IdName,
ItemsResponse,
PageState,
PersonDto,
RegistrationDto,
RegistrationUpsertDto,
SportDto
} from './types';
function toQuery(pageState: PageState) {
return {
page: pageState.page,
pageSize: pageState.pageSize,
sort: pageState.sort,
sortOrder: pageState.sortOrder,
filters: pageState.filters
};
}
export const sportsApi = {
list: (pageState: PageState) => getJson<ItemsResponse<SportDto>>('/Sports', toQuery(pageState)),
get: (id: number) => getJson<SportDto>(`/Sports/${id}`),
create: (payload: SportDto) => postJson<SportDto, SportDto>('/Sports', payload),
update: (payload: SportDto) => putJson(`/Sports/${payload.id}`, payload),
remove: (id: number) => deleteJson(`/Sports/${id}`)
};
export const eventsApi = {
list: (pageState: PageState) => getJson<ItemsResponse<EventDto>>('/Events', toQuery(pageState)),
get: (id: number) => getJson<EventDto>(`/Events/${id}`),
create: (payload: EventDto) => postJson<EventDto, EventDto>('/Events', payload),
update: (payload: EventDto) => putJson(`/Events/${payload.id}`, payload),
remove: (id: number) => deleteJson(`/Events/${id}`),
downloadRegistrationsExcel: (id: number) => getFile(`/Events/${id}/RegistrationsExcel`)
};
export const peopleApi = {
list: (pageState: PageState) => getJson<ItemsResponse<PersonDto>>('/People', toQuery(pageState)),
get: (id: number) => getJson<PersonDto>(`/People/${id}`),
create: (payload: PersonDto) => postJson<PersonDto, PersonDto>('/People', payload),
update: (payload: PersonDto) => putJson(`/People/${payload.id}`, payload),
remove: (id: number) => deleteJson(`/People/${id}`)
};
export const registrationsApi = {
list: (pageState: PageState) => getJson<ItemsResponse<RegistrationDto>>('/Registrations', toQuery(pageState)),
get: (id: number) => getJson<RegistrationDto>(`/Registrations/${id}`),
create: (payload: RegistrationUpsertDto) => postJson<RegistrationDto, RegistrationUpsertDto>('/Registrations', payload),
update: (payload: RegistrationUpsertDto) => putJson(`/Registrations/${payload.id}`, payload),
remove: (id: number) => deleteJson(`/Registrations/${id}`),
downloadCertificate: (id: number) => getFile(`/Registrations/${id}/Certificate`)
};
export const lookupApi = {
countries: (text?: string) => getJson<Array<IdName<string>>>('/Lookup/Countries', { text }),
people: (text?: string, countryCode?: string) =>
getJson<Array<IdName<number>>>('/Lookup/People', { text, countryCode })
};

View File

@@ -0,0 +1,144 @@
import { auth0 } from '../auth';
import type { ProblemDetails } from './types';
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'https://localhost:7150';
const filesApiBaseUrl = import.meta.env.VITE_FILES_API_BASE_URL ?? 'https://localhost:7296';
function toCamelCase(value: string) {
return value.length > 0 ? value[0].toLowerCase() + value.slice(1) : value;
}
function normalizeJson<T>(value: T): T {
if (Array.isArray(value)) {
return value.map((item) => normalizeJson(item)) as T;
}
if (value && typeof value === 'object' && !(value instanceof Date)) {
const normalizedEntries = Object.entries(value as Record<string, unknown>).map(([key, entryValue]) => [
toCamelCase(key),
normalizeJson(entryValue)
]);
return Object.fromEntries(normalizedEntries) as T;
}
return value;
}
function buildUrl(
path: string,
query?: Record<string, string | number | undefined | null>,
baseUrl: string = apiBaseUrl
) {
const url = new URL(path, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`);
if (query) {
for (const [key, value] of Object.entries(query)) {
if (value !== undefined && value !== null && value !== '') {
url.searchParams.set(key, String(value));
}
}
}
return url.toString();
}
async function parseResponse<T>(response: Response): Promise<T> {
if (response.ok) {
if (response.status === 204) {
return undefined as T;
}
const payload = (await response.json()) as T;
return normalizeJson(payload);
}
let problem: ProblemDetails | undefined;
try {
problem = normalizeJson((await response.json()) as ProblemDetails);
} catch {
problem = undefined;
}
const validationMessage = problem?.errors
? Object.entries(problem.errors)
.flatMap(([field, messages]) => messages.map((message) => (field ? `${field}: ${message}` : message)))
.join('\n')
: undefined;
throw new Error(validationMessage || problem?.detail || problem?.title || `HTTP ${response.status}`);
}
async function buildAuthHeaders() {
if (!auth0.isAuthenticated.value) {
return {};
}
const accessToken = await auth0.getAccessTokenSilently();
return accessToken ? { Authorization: `Bearer ${accessToken}` } : {};
}
export async function getJson<T>(path: string, query?: Record<string, string | number | undefined | null>) {
const response = await fetch(buildUrl(path, query), {
headers: await buildAuthHeaders()
});
return parseResponse<T>(response);
}
export async function postJson<TResponse, TBody>(path: string, body: TBody) {
const response = await fetch(buildUrl(path), {
method: 'POST',
headers: {
...(await buildAuthHeaders()),
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
return parseResponse<TResponse>(response);
}
export async function putJson<TBody>(path: string, body: TBody) {
const response = await fetch(buildUrl(path), {
method: 'PUT',
headers: {
...(await buildAuthHeaders()),
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
return parseResponse<void>(response);
}
export async function deleteJson(path: string) {
const response = await fetch(buildUrl(path), {
method: 'DELETE',
headers: await buildAuthHeaders()
});
return parseResponse<void>(response);
}
export async function getFile(
path: string,
query?: Record<string, string | number | undefined | null>
) {
const response = await fetch(buildUrl(path, query, filesApiBaseUrl), {
headers: await buildAuthHeaders()
});
if (!response.ok) {
await parseResponse<void>(response);
}
const contentDisposition = response.headers.get('Content-Disposition') ?? '';
const fileNameMatch =
contentDisposition.match(/filename\*=UTF-8''([^;]+)/i) ??
contentDisposition.match(/filename="?([^";]+)"?/i);
return {
blob: await response.blob(),
fileName: fileNameMatch?.[1] ? decodeURIComponent(fileNameMatch[1]) : 'download.bin'
};
}

View File

@@ -0,0 +1,79 @@
export interface ItemsResponse<T> {
data: T[] | null;
count: number;
}
export interface IdName<T> {
id: T;
name: string;
description?: string | null;
}
export interface SportDto {
id: number;
name: string;
}
export interface EventDto {
id: number;
name: string;
eventDate: string;
registrationsCount: number;
}
export interface PersonDto {
id: number;
firstName: string;
lastName: string;
firstNameTranscription: string;
lastNameTranscription: string;
addressLine: string;
postalCode: string;
city: string;
addressCountry: string;
email: string;
contactPhone: string;
birthDate: string;
documentNumber: string;
countryCode: string;
countryName: string;
fullNameTranscription: string;
registrationsCount: number;
}
export interface RegistrationDto {
id: number;
eventId: number;
personId: number;
sportId: number;
registeredAt: string | null;
personName: string;
personTranscription: string;
personFirstNameTranscription: string;
personLastNameTranscription: string;
countryCode: string;
countryName: string;
sportName: string;
}
export interface RegistrationUpsertDto {
id: number;
eventId: number;
personId: number;
sportId: number;
}
export interface ProblemDetails {
title?: string;
detail?: string;
errors?: Record<string, string[]>;
errorCodes?: Record<string, string[]>;
}
export interface PageState {
page: number;
pageSize: number;
sort?: string;
sortOrder?: 1 | -1;
filters?: string;
}

View File

@@ -0,0 +1,19 @@
import { createAuth0 } from '@auth0/auth0-vue';
const authorizationParams: Record<string, string> = {
redirect_uri: window.location.origin
};
if (import.meta.env.VITE_AUTH0_AUDIENCE) {
authorizationParams.audience = import.meta.env.VITE_AUTH0_AUDIENCE;
}
if (import.meta.env.VITE_AUTH0_SCOPE) {
authorizationParams.scope = import.meta.env.VITE_AUTH0_SCOPE;
}
export const auth0 = createAuth0({
domain: import.meta.env.VITE_AUTH0_DOMAIN,
clientId: import.meta.env.VITE_AUTH0_CLIENT_ID,
authorizationParams
});

View File

@@ -0,0 +1,319 @@
<script setup lang="ts">
import { onMounted, reactive, ref, watch } from 'vue';
import Button from 'primevue/button';
import Column from 'primevue/column';
import DataTable, { type DataTablePageEvent, type DataTableSortEvent } from 'primevue/datatable';
import DatePicker from 'primevue/datepicker';
import Dialog from 'primevue/dialog';
import InputText from 'primevue/inputtext';
import { useConfirm } from 'primevue/useconfirm';
import { useToast } from 'primevue/usetoast';
import { eventsApi } from '../api/eventsApi';
import { eventsCatalogVersion, touchEventsCatalog } from '../state/catalogState';
import { openRegistrationCreateForEvent } from '../state/uiState';
import type { EventDto } from '../api/types';
import { formatDateOnly, toDate, toDateOnlyString } from '../utils/dates';
const rows = ref<EventDto[]>([]);
const totalRecords = ref(0);
const loading = ref(false);
const error = ref('');
const dialogVisible = ref(false);
const page = ref(1);
const pageSize = ref(10);
const sort = ref('EventDate');
const sortOrder = ref<1 | -1>(1);
const eventDate = ref<Date | null>(null);
const confirm = useConfirm();
const toast = useToast();
const form = reactive<EventDto>({
id: 0,
name: '',
eventDate: '',
registrationsCount: 0
});
const filters = ref({
Name: {
operator: 'and',
constraints: [{ value: null as string | null, matchMode: 'contains' }]
},
EventDate: {
operator: 'and',
constraints: [{ value: null as string | null, matchMode: 'equals' }]
}
});
function escapeFilterValue(value: string) {
return value
.replace(/\\/g, '\\\\')
.replace(/\|/g, '\\|')
.replace(/,/g, '\\,');
}
function toSieveOperator(matchMode?: string) {
switch (matchMode) {
case 'startsWith':
return '_=*';
case 'endsWith':
return '_-=*';
case 'equals':
return '==*';
case 'notEquals':
return '!=*';
case 'contains':
default:
return '@=*';
}
}
function toFilterDate(value: unknown) {
if (!value) {
return '';
}
if (value instanceof Date && !Number.isNaN(value.getTime())) {
return value.toISOString().slice(0, 10);
}
return typeof value === 'string' ? value.trim() : '';
}
function buildFilters() {
const result: string[] = [];
result.push(
...filters.value.Name.constraints
.filter((constraint) => (constraint.value ?? '').trim() !== '')
.map((constraint) => `Name${toSieveOperator(constraint.matchMode)}${escapeFilterValue((constraint.value ?? '').trim())}`)
);
result.push(
...filters.value.EventDate.constraints
.map((constraint) => toFilterDate(constraint.value))
.filter((value) => value !== '')
.map((value) => `EventDate==${value}`)
);
return result.length > 0 ? result.join(',') : undefined;
}
async function loadData() {
loading.value = true;
error.value = '';
try {
const response = await eventsApi.list({
page: page.value,
pageSize: pageSize.value,
sort: sort.value,
sortOrder: sortOrder.value,
filters: buildFilters()
});
rows.value = response.data ?? [];
totalRecords.value = response.count;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unable to load events.';
} finally {
loading.value = false;
}
}
function resetForm() {
form.id = 0;
form.name = '';
form.eventDate = '';
form.registrationsCount = 0;
eventDate.value = null;
}
function openCreate() {
error.value = '';
resetForm();
dialogVisible.value = true;
}
function openRegistrationCreate(id: number) {
openRegistrationCreateForEvent(id);
}
async function openEdit(id: number) {
error.value = '';
try {
const item = await eventsApi.get(id);
form.id = item.id;
form.name = item.name;
form.eventDate = item.eventDate;
eventDate.value = toDate(item.eventDate);
dialogVisible.value = true;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unable to load the event.';
}
}
async function downloadRegistrationsExcel(id: number) {
error.value = '';
try {
const file = await eventsApi.downloadRegistrationsExcel(id);
const url = URL.createObjectURL(file.blob);
const link = document.createElement('a');
link.href = url;
link.download = file.fileName;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unable to download the registrations Excel file.';
toast.add({ severity: 'error', summary: 'Events', detail: error.value, life: 5000 });
}
}
async function save() {
error.value = '';
form.eventDate = toDateOnlyString(eventDate.value);
try {
if (form.id > 0) {
await eventsApi.update({ ...form });
toast.add({ severity: 'success', summary: 'Events', detail: 'Event updated successfully.', life: 3000 });
} else {
await eventsApi.create({ ...form });
toast.add({ severity: 'success', summary: 'Events', detail: 'Event created successfully.', life: 3000 });
}
touchEventsCatalog();
dialogVisible.value = false;
await loadData();
} catch (err) {
error.value = err instanceof Error ? err.message : 'Save failed.';
toast.add({ severity: 'error', summary: 'Events', detail: error.value, life: 5000 });
}
}
async function remove(id: number) {
confirm.require({
message: 'Delete this event?',
header: 'Confirmation',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Delete',
rejectLabel: 'Cancel',
acceptClass: 'p-button-danger',
accept: async () => {
error.value = '';
try {
await eventsApi.remove(id);
touchEventsCatalog();
toast.add({ severity: 'success', summary: 'Events', detail: 'Event deleted successfully.', life: 3000 });
await loadData();
} catch (err) {
error.value = err instanceof Error ? err.message : 'Delete failed.';
toast.add({ severity: 'error', summary: 'Events', detail: error.value, life: 5000 });
}
}
});
}
function onPage(event: DataTablePageEvent) {
page.value = (event.page ?? 0) + 1;
pageSize.value = event.rows;
void loadData();
}
function onSort(event: DataTableSortEvent) {
sort.value = event.sortField as string;
sortOrder.value = (event.sortOrder ?? 1) as 1 | -1;
void loadData();
}
function onFilter() {
page.value = 1;
void loadData();
}
defineExpose({
openCreate
});
onMounted(() => {
void loadData();
});
watch(eventsCatalogVersion, () => {
void loadData();
});
</script>
<template>
<div class="panel-card">
<DataTable
v-model:filters="filters"
:value="rows"
:loading="loading"
dataKey="id"
filterDisplay="menu"
lazy
paginator
:rows="pageSize"
:first="(page - 1) * pageSize"
:total-records="totalRecords"
:sort-field="sort"
:sort-order="sortOrder"
@filter="onFilter"
@page="onPage"
@sort="onSort"
>
<Column field="id" header="ID" sortable />
<Column field="name" header="Name" sortable sortField="Name" filterField="Name" :filterMenuStyle="{ width: '14rem' }">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" type="text" placeholder="Filter by name" @keydown.enter.prevent="filterCallback()" />
</template>
</Column>
<Column field="eventDate" header="Date" sortable sortField="EventDate" filterField="EventDate" :showFilterMatchModes="false" :filterMenuStyle="{ width: '14rem' }">
<template #body="{ data }">
{{ formatDateOnly(data.eventDate) }}
</template>
<template #filter="{ filterModel, filterCallback }">
<DatePicker v-model="filterModel.value" date-format="yy-mm-dd" show-icon fluid @date-select="filterCallback()" />
</template>
</Column>
<Column field="registrationsCount" header="Registrations" sortable sortField="RegistrationsCount">
<template #body="{ data }">
<Button
v-if="(data.registrationsCount ?? 0) > 0"
:label="String(data.registrationsCount ?? 0)"
link
@click="downloadRegistrationsExcel(data.id)"
/>
<span v-else>{{ data.registrationsCount ?? 0 }}</span>
</template>
</Column>
<Column header="" style="width: 16rem">
<template #body="{ data }">
<div class="inline-actions">
<Button label="New registration" link @click="openRegistrationCreate(data.id)" />
<Button icon="pi pi-pencil" text rounded @click="openEdit(data.id)" />
<Button icon="pi pi-trash" text rounded severity="danger" @click="remove(data.id)" />
</div>
</template>
</Column>
</DataTable>
<Dialog v-model:visible="dialogVisible" modal :style="{ width: '48rem' }" :header="form.id ? 'Edit event' : 'New event'">
<div class="field-grid">
<div class="field">
<label for="event-name">Name</label>
<InputText id="event-name" v-model="form.name" />
</div>
<div class="field">
<label for="event-date">Date</label>
<DatePicker id="event-date" v-model="eventDate" date-format="dd.mm.yy" show-icon fluid />
</div>
</div>
<template #footer>
<Button label="Cancel" text @click="dialogVisible = false" />
<Button label="Save" @click="save" />
</template>
</Dialog>
</div>
</template>

View File

@@ -0,0 +1,431 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import Button from 'primevue/button';
import Column from 'primevue/column';
import DataTable, { type DataTablePageEvent, type DataTableSortEvent } from 'primevue/datatable';
import DatePicker from 'primevue/datepicker';
import Dialog from 'primevue/dialog';
import InputText from 'primevue/inputtext';
import Select from 'primevue/select';
import { useConfirm } from 'primevue/useconfirm';
import { useToast } from 'primevue/usetoast';
import { lookupApi, peopleApi } from '../api/eventsApi';
import { touchPeopleCatalog } from '../state/catalogState';
import type { IdName, PersonDto } from '../api/types';
import { formatDateOnly, toDate, toDateOnlyString } from '../utils/dates';
const rows = ref<PersonDto[]>([]);
const totalRecords = ref(0);
const loading = ref(false);
const error = ref('');
const dialogVisible = ref(false);
const page = ref(1);
const pageSize = ref(10);
const sort = ref('LastName');
const sortOrder = ref<1 | -1>(1);
const birthDate = ref<Date | null>(null);
const countries = ref<Array<IdName<string>>>([]);
const confirm = useConfirm();
const toast = useToast();
const form = reactive<PersonDto>({
id: 0,
firstName: '',
lastName: '',
firstNameTranscription: '',
lastNameTranscription: '',
addressLine: '',
postalCode: '',
city: '',
addressCountry: '',
email: '',
contactPhone: '',
birthDate: '',
documentNumber: '',
countryCode: '',
countryName: '',
fullNameTranscription: '',
registrationsCount: 0
});
const countryOptions = computed(() =>
countries.value.map((country) => ({
label: country.name,
value: country.id
}))
);
const filters = ref({
Id: {
operator: 'and',
constraints: [{ value: null as number | null, matchMode: 'equals' }]
},
FirstName: {
operator: 'and',
constraints: [{ value: null as string | null, matchMode: 'contains' }]
},
LastName: {
operator: 'and',
constraints: [{ value: null as string | null, matchMode: 'contains' }]
},
FirstNameTranscription: {
operator: 'and',
constraints: [{ value: null as string | null, matchMode: 'contains' }]
},
LastNameTranscription: {
operator: 'and',
constraints: [{ value: null as string | null, matchMode: 'contains' }]
},
Email: {
operator: 'and',
constraints: [{ value: null as string | null, matchMode: 'contains' }]
},
CountryCode: {
operator: 'and',
constraints: [{ value: null as string | null, matchMode: 'equals' }]
}
});
function escapeFilterValue(value: string) {
return value
.replace(/\\/g, '\\\\')
.replace(/\|/g, '\\|')
.replace(/,/g, '\\,');
}
function toSieveOperator(matchMode?: string) {
switch (matchMode) {
case 'startsWith':
return '_=*';
case 'endsWith':
return '_-=*';
case 'equals':
return '==*';
case 'notEquals':
return '!=*';
case 'contains':
default:
return '@=*';
}
}
function getConstraintValues(field: keyof typeof filters.value) {
return filters.value[field].constraints
.map((constraint) => (constraint.value ?? '').toString().trim())
.filter((value) => value !== '');
}
function buildSameFieldFilter(field: string, operator: string, values: string[], logicalOperator: string) {
if (values.length === 0) {
return [];
}
if (values.length === 1) {
return [`${field}${operator}${values[0]}`];
}
if (logicalOperator === 'or') {
return [`${field}${operator}${values.join('|')}`];
}
return values.map((value) => `${field}${operator}${value}`);
}
function buildFilters() {
const result: string[] = [];
const textFields = ['FirstName', 'LastName', 'FirstNameTranscription', 'LastNameTranscription', 'Email'] as const;
for (const field of textFields) {
const groups = new Map<string, string[]>();
for (const constraint of filters.value[field].constraints) {
const value = (constraint.value ?? '').toString().trim();
if (!value) {
continue;
}
const key = constraint.matchMode ?? 'contains';
const entries = groups.get(key) ?? [];
entries.push(escapeFilterValue(value));
groups.set(key, entries);
}
for (const [matchMode, values] of groups.entries()) {
result.push(
...buildSameFieldFilter(field, toSieveOperator(matchMode), values, filters.value[field].operator)
);
}
}
result.push(
...buildSameFieldFilter(
'Id',
'==',
getConstraintValues('Id').map((value) => escapeFilterValue(value)),
filters.value.Id.operator
)
);
result.push(
...buildSameFieldFilter(
'CountryCode',
'==',
getConstraintValues('CountryCode').map((value) => escapeFilterValue(value)),
filters.value.CountryCode.operator
)
);
return result.length > 0 ? result.join(',') : undefined;
}
async function loadCountries() {
countries.value = await lookupApi.countries();
}
async function loadData() {
loading.value = true;
error.value = '';
try {
const response = await peopleApi.list({
page: page.value,
pageSize: pageSize.value,
sort: sort.value,
sortOrder: sortOrder.value,
filters: buildFilters()
});
rows.value = response.data ?? [];
totalRecords.value = response.count;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unable to load people.';
} finally {
loading.value = false;
}
}
function resetForm() {
form.id = 0;
form.firstName = '';
form.lastName = '';
form.firstNameTranscription = '';
form.lastNameTranscription = '';
form.addressLine = '';
form.postalCode = '';
form.city = '';
form.addressCountry = '';
form.email = '';
form.contactPhone = '';
form.birthDate = '';
form.documentNumber = '';
form.countryCode = '';
form.countryName = '';
form.fullNameTranscription = '';
form.registrationsCount = 0;
birthDate.value = null;
}
function openCreate() {
error.value = '';
resetForm();
dialogVisible.value = true;
}
async function openEdit(id: number) {
error.value = '';
try {
const item = await peopleApi.get(id);
Object.assign(form, item);
birthDate.value = toDate(item.birthDate);
dialogVisible.value = true;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unable to load the person.';
}
}
async function save() {
error.value = '';
form.birthDate = toDateOnlyString(birthDate.value);
try {
if (form.id > 0) {
await peopleApi.update({ ...form });
toast.add({ severity: 'success', summary: 'People', detail: 'Person updated successfully.', life: 3000 });
} else {
await peopleApi.create({ ...form });
toast.add({ severity: 'success', summary: 'People', detail: 'Person created successfully.', life: 3000 });
}
touchPeopleCatalog();
dialogVisible.value = false;
await loadData();
} catch (err) {
error.value = err instanceof Error ? err.message : 'Save failed.';
toast.add({ severity: 'error', summary: 'People', detail: error.value, life: 5000 });
}
}
async function remove(id: number) {
confirm.require({
message: 'Delete this person?',
header: 'Confirmation',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Delete',
rejectLabel: 'Cancel',
acceptClass: 'p-button-danger',
accept: async () => {
error.value = '';
try {
await peopleApi.remove(id);
touchPeopleCatalog();
toast.add({ severity: 'success', summary: 'People', detail: 'Person deleted successfully.', life: 3000 });
await loadData();
} catch (err) {
error.value = err instanceof Error ? err.message : 'Delete failed.';
toast.add({ severity: 'error', summary: 'People', detail: error.value, life: 5000 });
}
}
});
}
function onPage(event: DataTablePageEvent) {
page.value = (event.page ?? 0) + 1;
pageSize.value = event.rows;
void loadData();
}
function onSort(event: DataTableSortEvent) {
sort.value = event.sortField as string;
sortOrder.value = (event.sortOrder ?? 1) as 1 | -1;
void loadData();
}
function onSearch() {
page.value = 1;
void loadData();
}
function onFilter() {
page.value = 1;
void loadData();
}
defineExpose({
openCreate
});
onMounted(async () => {
await loadCountries();
await loadData();
});
</script>
<template>
<div class="panel-card">
<DataTable
v-model:filters="filters"
:value="rows"
:loading="loading"
dataKey="id"
filterDisplay="menu"
lazy
paginator
:rows="pageSize"
:first="(page - 1) * pageSize"
:total-records="totalRecords"
:sort-field="sort"
:sort-order="sortOrder"
@filter="onFilter"
@page="onPage"
@sort="onSort"
>
<Column field="id" header="ID" sortable sortField="Id" filterField="Id" :showFilterMatchModes="false" :showAddButton="false" :filterMenuStyle="{ width: '12rem' }">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" type="number" inputmode="numeric" placeholder="ID" @keydown.enter.prevent="filterCallback()" />
</template>
</Column>
<Column field="firstName" header="First name" sortable sortField="FirstName" filterField="FirstName" :filterMenuStyle="{ width: '14rem' }">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" type="text" placeholder="Filter by first name" @keydown.enter.prevent="filterCallback()" />
</template>
</Column>
<Column field="lastName" header="Last name" sortable sortField="LastName" filterField="LastName" :filterMenuStyle="{ width: '14rem' }">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" type="text" placeholder="Filter by last name" @keydown.enter.prevent="filterCallback()" />
</template>
</Column>
<Column field="email" header="Email" sortable sortField="Email" filterField="Email" :filterMenuStyle="{ width: '14rem' }">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" type="text" placeholder="Filter by email" @keydown.enter.prevent="filterCallback()" />
</template>
</Column>
<Column field="lastNameTranscription" header="Last name transcription" sortable sortField="LastNameTranscription" filterField="LastNameTranscription" :filterMenuStyle="{ width: '14rem' }">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" type="text" placeholder="Filter by last name transcription" @keydown.enter.prevent="filterCallback()" />
</template>
</Column>
<Column field="firstNameTranscription" header="First name transcription" sortable sortField="FirstNameTranscription" filterField="FirstNameTranscription" :filterMenuStyle="{ width: '14rem' }">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" type="text" placeholder="Filter by first name transcription" @keydown.enter.prevent="filterCallback()" />
</template>
</Column>
<Column field="countryName" header="Country" sortable sortField="CountryName" filterField="CountryCode" :showFilterMatchModes="false" :filterMenuStyle="{ width: '14rem' }">
<template #filter="{ filterModel, filterCallback }">
<Select
v-model="filterModel.value"
:options="countryOptions"
option-label="label"
option-value="value"
placeholder="All countries"
show-clear
filter
@change="filterCallback()"
/>
</template>
</Column>
<Column field="birthDate" header="Birth date" sortable>
<template #body="{ data }">
{{ formatDateOnly(data.birthDate) }}
</template>
</Column>
<Column field="registrationsCount" header="Registrations" sortable sortField="RegistrationsCount" />
<Column header="" style="width: 10rem">
<template #body="{ data }">
<div class="inline-actions">
<Button icon="pi pi-pencil" text rounded @click="openEdit(data.id)" />
<Button icon="pi pi-trash" text rounded severity="danger" @click="remove(data.id)" />
</div>
</template>
</Column>
</DataTable>
<Dialog v-model:visible="dialogVisible" modal :style="{ width: '78rem' }" :header="form.id ? 'Edit person' : 'New person'">
<div class="field-grid field-grid-person">
<div class="field"><label>First name</label><InputText v-model="form.firstName" /></div>
<div class="field"><label>Last name</label><InputText v-model="form.lastName" /></div>
<div class="field"><label>First name transcription</label><InputText v-model="form.firstNameTranscription" /></div>
<div class="field"><label>Last name transcription</label><InputText v-model="form.lastNameTranscription" /></div>
<div class="field"><label>Address</label><InputText v-model="form.addressLine" /></div>
<div class="field"><label>Postal code</label><InputText v-model="form.postalCode" /></div>
<div class="field"><label>City</label><InputText v-model="form.city" /></div>
<div class="field"><label>Address country</label><InputText v-model="form.addressCountry" /></div>
<div class="field"><label>Email</label><InputText v-model="form.email" /></div>
<div class="field"><label>Phone</label><InputText v-model="form.contactPhone" /></div>
<div class="field"><label>Birth date</label><DatePicker v-model="birthDate" date-format="dd.mm.yy" show-icon fluid /></div>
<div class="field"><label>Document number</label><InputText v-model="form.documentNumber" /></div>
<div class="field">
<label>Person country</label>
<Select
v-model="form.countryCode"
:options="countryOptions"
option-label="label"
option-value="value"
placeholder="Select country"
filter
/>
</div>
</div>
<template #footer>
<Button label="Cancel" text @click="dialogVisible = false" />
<Button label="Save" @click="save" />
</template>
</Dialog>
</div>
</template>

View File

@@ -0,0 +1,523 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue';
import AutoComplete, { type AutoCompleteCompleteEvent } from 'primevue/autocomplete';
import Button from 'primevue/button';
import Column from 'primevue/column';
import DataTable, { type DataTablePageEvent, type DataTableSortEvent } from 'primevue/datatable';
import Dialog from 'primevue/dialog';
import InputText from 'primevue/inputtext';
import Select from 'primevue/select';
import { useConfirm } from 'primevue/useconfirm';
import { useToast } from 'primevue/usetoast';
import { eventsApi, lookupApi, registrationsApi, sportsApi } from '../api/eventsApi';
import { eventsCatalogVersion, peopleCatalogVersion, sportsCatalogVersion, touchEventsCatalog } from '../state/catalogState';
import { activeTab, consumePendingRegistrationEventId, pendingRegistrationEventId } from '../state/uiState';
import type { EventDto, IdName, RegistrationDto, RegistrationUpsertDto, SportDto } from '../api/types';
import { formatDateTime } from '../utils/dates';
const rows = ref<RegistrationDto[]>([]);
const totalRecords = ref(0);
const loading = ref(false);
const error = ref('');
const dialogVisible = ref(false);
const selectedPerson = ref<IdName<number> | null>(null);
const peopleSuggestions = ref<Array<IdName<number>>>([]);
const countries = ref<Array<IdName<string>>>([]);
const sports = ref<SportDto[]>([]);
const events = ref<EventDto[]>([]);
const page = ref(1);
const pageSize = ref(10);
const sort = ref('RegisteredAt');
const sortOrder = ref<1 | -1>(-1);
const confirm = useConfirm();
const toast = useToast();
const form = reactive<RegistrationDto>({
id: 0,
eventId: 0,
personId: 0,
sportId: 0,
registeredAt: null,
personName: '',
personTranscription: '',
personFirstNameTranscription: '',
personLastNameTranscription: '',
countryCode: '',
countryName: '',
sportName: ''
});
const eventOptions = computed(() =>
events.value.map((item) => ({
label: `${item.name} (${item.eventDate})`,
value: item.id
}))
);
const sportOptions = computed(() =>
sports.value.map((item) => ({
label: item.name,
value: item.id
}))
);
const countryOptions = computed(() =>
countries.value.map((item) => ({
label: item.name,
value: item.id
}))
);
const filters = ref({
EventId: {
operator: 'and',
constraints: [{ value: null as number | null, matchMode: 'equals' }]
},
PersonName: {
operator: 'and',
constraints: [{ value: null as string | null, matchMode: 'contains' }]
},
PersonLastNameTranscription: {
operator: 'and',
constraints: [{ value: null as string | null, matchMode: 'contains' }]
},
PersonFirstNameTranscription: {
operator: 'and',
constraints: [{ value: null as string | null, matchMode: 'contains' }]
},
CountryCode: {
operator: 'and',
constraints: [{ value: null as string | null, matchMode: 'equals' }]
},
SportName: {
operator: 'and',
constraints: [{ value: null as string | null, matchMode: 'contains' }]
}
});
function escapeFilterValue(value: string) {
return value
.replace(/\\/g, '\\\\')
.replace(/\|/g, '\\|')
.replace(/,/g, '\\,');
}
function toSieveOperator(matchMode?: string) {
switch (matchMode) {
case 'startsWith':
return '_=*';
case 'endsWith':
return '_-=*';
case 'equals':
return '==*';
case 'notEquals':
return '!=*';
case 'contains':
default:
return '@=*';
}
}
function getConstraintValues(field: keyof typeof filters.value) {
return filters.value[field].constraints
.map((constraint) => (constraint.value ?? '').toString().trim())
.filter((value) => value !== '');
}
function buildSameFieldFilter(field: string, operator: string, values: string[], logicalOperator: string) {
if (values.length === 0) {
return [];
}
if (values.length === 1) {
return [`${field}${operator}${values[0]}`];
}
if (logicalOperator === 'or') {
return [`${field}${operator}${values.join('|')}`];
}
return values.map((value) => `${field}${operator}${value}`);
}
function toRegistrationPayload(): RegistrationUpsertDto {
return {
id: form.id,
eventId: form.eventId,
personId: form.personId,
sportId: form.sportId
};
}
function buildFilters() {
const result: string[] = [];
const textFields = ['PersonName', 'PersonLastNameTranscription', 'PersonFirstNameTranscription', 'SportName'] as const;
for (const field of textFields) {
const groups = new Map<string, string[]>();
for (const constraint of filters.value[field].constraints) {
const value = (constraint.value ?? '').toString().trim();
if (!value) {
continue;
}
const key = constraint.matchMode ?? 'contains';
const entries = groups.get(key) ?? [];
entries.push(escapeFilterValue(value));
groups.set(key, entries);
}
for (const [matchMode, values] of groups.entries()) {
result.push(
...buildSameFieldFilter(field, toSieveOperator(matchMode), values, filters.value[field].operator)
);
}
}
result.push(
...buildSameFieldFilter(
'EventId',
'==',
getConstraintValues('EventId').map((value) => escapeFilterValue(value)),
filters.value.EventId.operator
)
);
result.push(
...buildSameFieldFilter(
'CountryCode',
'==',
getConstraintValues('CountryCode').map((value) => escapeFilterValue(value)),
filters.value.CountryCode.operator
)
);
return result.length > 0 ? result.join(',') : undefined;
}
async function loadAuxiliaryData() {
const [loadedEvents, loadedSports, loadedCountries] = await Promise.all([
eventsApi.list({ page: 1, pageSize: 500, sort: 'EventDate', sortOrder: 1 }),
sportsApi.list({ page: 1, pageSize: 500, sort: 'Name', sortOrder: 1 }),
lookupApi.countries()
]);
events.value = loadedEvents.data ?? [];
sports.value = loadedSports.data ?? [];
countries.value = loadedCountries;
}
async function loadData() {
loading.value = true;
error.value = '';
try {
const response = await registrationsApi.list({
page: page.value,
pageSize: pageSize.value,
sort: sort.value,
sortOrder: sortOrder.value,
filters: buildFilters()
});
rows.value = response.data ?? [];
totalRecords.value = response.count;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unable to load registrations.';
} finally {
loading.value = false;
}
}
function resetForm() {
form.id = 0;
form.eventId = 0;
form.personId = 0;
form.sportId = 0;
form.registeredAt = null;
form.personName = '';
form.personTranscription = '';
form.personFirstNameTranscription = '';
form.personLastNameTranscription = '';
form.countryCode = '';
form.countryName = '';
form.sportName = '';
selectedPerson.value = null;
}
function openCreate() {
error.value = '';
resetForm();
dialogVisible.value = true;
}
function openCreateForEvent(eventId: number) {
error.value = '';
resetForm();
form.eventId = eventId;
dialogVisible.value = true;
}
async function openEdit(id: number) {
error.value = '';
try {
const item = await registrationsApi.get(id);
Object.assign(form, item);
selectedPerson.value = { id: item.personId, name: item.personName, description: item.personTranscription };
dialogVisible.value = true;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unable to load the registration.';
}
}
async function completePeopleLookup(event: AutoCompleteCompleteEvent) {
const query = event.query?.trim() ?? '';
if (!query) {
peopleSuggestions.value = [];
return;
}
try {
const countryCode = filters.value.CountryCode.constraints[0].value || undefined;
peopleSuggestions.value = await lookupApi.people(query, countryCode);
} catch (err) {
peopleSuggestions.value = [];
error.value = err instanceof Error ? err.message : 'Unable to load people for lookup.';
toast.add({ severity: 'error', summary: 'Registrations', detail: error.value, life: 5000 });
}
}
async function save() {
error.value = '';
form.personId = selectedPerson.value?.id ?? 0;
try {
if (form.id > 0) {
await registrationsApi.update(toRegistrationPayload());
toast.add({ severity: 'success', summary: 'Registrations', detail: 'Registration updated successfully.', life: 3000 });
} else {
await registrationsApi.create(toRegistrationPayload());
toast.add({ severity: 'success', summary: 'Registrations', detail: 'Registration created successfully.', life: 3000 });
}
touchEventsCatalog();
dialogVisible.value = false;
await loadData();
} catch (err) {
error.value = err instanceof Error ? err.message : 'Save failed.';
toast.add({ severity: 'error', summary: 'Registrations', detail: error.value, life: 5000 });
}
}
async function remove(id: number) {
confirm.require({
message: 'Delete this registration?',
header: 'Confirmation',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Delete',
rejectLabel: 'Cancel',
acceptClass: 'p-button-danger',
accept: async () => {
error.value = '';
try {
await registrationsApi.remove(id);
touchEventsCatalog();
toast.add({ severity: 'success', summary: 'Registrations', detail: 'Registration deleted successfully.', life: 3000 });
await loadData();
} catch (err) {
error.value = err instanceof Error ? err.message : 'Delete failed.';
toast.add({ severity: 'error', summary: 'Registrations', detail: error.value, life: 5000 });
}
}
});
}
async function downloadCertificate(id: number) {
error.value = '';
try {
const file = await registrationsApi.downloadCertificate(id);
const url = URL.createObjectURL(file.blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = file.fileName;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
} catch (err) {
error.value = err instanceof Error ? err.message : 'Certificate download failed.';
toast.add({ severity: 'error', summary: 'Registrations', detail: error.value, life: 5000 });
}
}
function onPage(event: DataTablePageEvent) {
page.value = (event.page ?? 0) + 1;
pageSize.value = event.rows;
void loadData();
}
function onSort(event: DataTableSortEvent) {
sort.value = event.sortField as string;
sortOrder.value = (event.sortOrder ?? 1) as 1 | -1;
void loadData();
}
function onFilter() {
page.value = 1;
void loadData();
}
watch([eventsCatalogVersion, sportsCatalogVersion, peopleCatalogVersion], async () => {
await loadAuxiliaryData();
});
watch([activeTab, pendingRegistrationEventId], async () => {
if (activeTab.value !== 'registrations' || pendingRegistrationEventId.value === null) {
return;
}
if (events.value.length === 0) {
await loadAuxiliaryData();
}
const eventId = consumePendingRegistrationEventId();
if (eventId !== null) {
openCreateForEvent(eventId);
}
});
defineExpose({
openCreate
});
onMounted(async () => {
await loadAuxiliaryData();
await loadData();
});
</script>
<template>
<div class="panel-card">
<DataTable
v-model:filters="filters"
:value="rows"
:loading="loading"
dataKey="id"
filterDisplay="menu"
lazy
paginator
:rows="pageSize"
:first="(page - 1) * pageSize"
:total-records="totalRecords"
:sort-field="sort"
:sort-order="sortOrder"
@filter="onFilter"
@page="onPage"
@sort="onSort"
>
<Column field="id" header="ID" sortable />
<Column field="personName" header="Person" sortable sortField="PersonName" filterField="PersonName" :filterMenuStyle="{ width: '14rem' }">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" type="text" placeholder="Filter by person" @keydown.enter.prevent="filterCallback()" />
</template>
</Column>
<Column field="personLastNameTranscription" header="Last name transcription" sortable sortField="PersonLastNameTranscription" filterField="PersonLastNameTranscription" :filterMenuStyle="{ width: '14rem' }">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" type="text" placeholder="Filter by last name" @keydown.enter.prevent="filterCallback()" />
</template>
</Column>
<Column field="personFirstNameTranscription" header="First name transcription" sortable sortField="PersonFirstNameTranscription" filterField="PersonFirstNameTranscription" :filterMenuStyle="{ width: '14rem' }">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" type="text" placeholder="Filter by first name" @keydown.enter.prevent="filterCallback()" />
</template>
</Column>
<Column field="countryName" header="Country" sortable filterField="CountryCode" :showFilterMatchModes="false" :filterMenuStyle="{ width: '14rem' }">
<template #filter="{ filterModel, filterCallback }">
<Select
v-model="filterModel.value"
:options="countryOptions"
option-label="label"
option-value="value"
placeholder="All countries"
show-clear
filter
@change="filterCallback()"
/>
</template>
</Column>
<Column field="sportName" header="Sport" sortable sortField="SportName" filterField="SportName" :filterMenuStyle="{ width: '14rem' }">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" type="text" placeholder="Filter by sport" @keydown.enter.prevent="filterCallback()" />
</template>
</Column>
<Column field="eventId" header="Event" sortable sortField="EventId" filterField="EventId" :showFilterMatchModes="false" :filterMenuStyle="{ width: '16rem' }">
<template #body="{ data }">
{{ events.find((item) => item.id === data.eventId)?.name || data.eventId }}
</template>
<template #filter="{ filterModel, filterCallback }">
<Select
v-model="filterModel.value"
:options="eventOptions"
option-label="label"
option-value="value"
placeholder="All events"
show-clear
filter
@change="filterCallback()"
/>
</template>
</Column>
<Column field="registeredAt" header="Registered at" sortable>
<template #body="{ data }">
{{ formatDateTime(data.registeredAt) }}
</template>
</Column>
<Column header="" style="width: 10rem">
<template #body="{ data }">
<div class="inline-actions">
<Button icon="pi pi-download" text rounded @click="downloadCertificate(data.id)" />
<Button icon="pi pi-pencil" text rounded @click="openEdit(data.id)" />
<Button icon="pi pi-trash" text rounded severity="danger" @click="remove(data.id)" />
</div>
</template>
</Column>
</DataTable>
<Dialog v-model:visible="dialogVisible" modal :style="{ width: '68rem' }" :header="form.id ? 'Edit registration' : 'New registration'">
<div class="field-grid field-grid-registration">
<div class="field field-span-2">
<label>Event</label>
<Select v-model="form.eventId" :options="eventOptions" option-label="label" option-value="value" />
</div>
<div class="field">
<label>Sport</label>
<Select v-model="form.sportId" :options="sportOptions" option-label="label" option-value="value" />
</div>
<div class="field" style="grid-column: 1 / -1">
<label>Person</label>
<AutoComplete
v-model="selectedPerson"
:suggestions="peopleSuggestions"
optionLabel="name"
:minLength="1"
completeOnFocus
dropdown
dropdownMode="current"
force-selection
@complete="completePeopleLookup"
>
<template #option="{ option }">
<div class="lookup-item">
<span>{{ option.name }}</span>
<small>{{ option.description || `ID: ${option.id}` }}</small>
</div>
</template>
</AutoComplete>
</div>
</div>
<template #footer>
<Button label="Cancel" text @click="dialogVisible = false" />
<Button label="Save" @click="save" />
</template>
</Dialog>
</div>
</template>

View File

@@ -0,0 +1,227 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import Button from 'primevue/button';
import Column from 'primevue/column';
import { useConfirm } from 'primevue/useconfirm';
import { useToast } from 'primevue/usetoast';
import DataTable, { type DataTablePageEvent, type DataTableSortEvent } from 'primevue/datatable';
import Dialog from 'primevue/dialog';
import InputText from 'primevue/inputtext';
import { sportsApi } from '../api/eventsApi';
import { touchSportsCatalog } from '../state/catalogState';
import type { SportDto } from '../api/types';
const rows = ref<SportDto[]>([]);
const totalRecords = ref(0);
const loading = ref(false);
const error = ref('');
const dialogVisible = ref(false);
const page = ref(1);
const pageSize = ref(10);
const sort = ref('Id');
const sortOrder = ref<1 | -1>(1);
const confirm = useConfirm();
const toast = useToast();
const form = reactive<SportDto>({
id: 0,
name: ''
});
const filters = ref({
Name: {
operator: 'and',
constraints: [{ value: null as string | null, matchMode: 'contains' }]
}
});
function escapeFilterValue(value: string) {
return value
.replace(/\\/g, '\\\\')
.replace(/\|/g, '\\|')
.replace(/,/g, '\\,');
}
function toSieveOperator(matchMode?: string) {
switch (matchMode) {
case 'startsWith':
return '_=*';
case 'endsWith':
return '_-=*';
case 'equals':
return '==*';
case 'notEquals':
return '!=*';
case 'contains':
default:
return '@=*';
}
}
function buildFilters() {
const result = filters.value.Name.constraints
.filter((constraint) => (constraint.value ?? '').trim() !== '')
.map((constraint) => `Name${toSieveOperator(constraint.matchMode)}${escapeFilterValue((constraint.value ?? '').trim())}`);
return result.length > 0 ? result.join(',') : undefined;
}
async function loadData() {
loading.value = true;
error.value = '';
try {
const response = await sportsApi.list({
page: page.value,
pageSize: pageSize.value,
sort: sort.value,
sortOrder: sortOrder.value,
filters: buildFilters()
});
rows.value = response.data ?? [];
totalRecords.value = response.count;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unable to load sports.';
} finally {
loading.value = false;
}
}
function resetForm() {
form.id = 0;
form.name = '';
}
function openCreate() {
error.value = '';
resetForm();
dialogVisible.value = true;
}
async function openEdit(id: number) {
error.value = '';
try {
const item = await sportsApi.get(id);
form.id = item.id;
form.name = item.name;
dialogVisible.value = true;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unable to load the sport.';
}
}
async function save() {
error.value = '';
try {
if (form.id > 0) {
await sportsApi.update({ ...form });
toast.add({ severity: 'success', summary: 'Sports', detail: 'Sport updated successfully.', life: 3000 });
} else {
await sportsApi.create({ ...form });
toast.add({ severity: 'success', summary: 'Sports', detail: 'Sport created successfully.', life: 3000 });
}
touchSportsCatalog();
dialogVisible.value = false;
await loadData();
} catch (err) {
error.value = err instanceof Error ? err.message : 'Save failed.';
toast.add({ severity: 'error', summary: 'Sports', detail: error.value, life: 5000 });
}
}
async function remove(id: number) {
confirm.require({
message: 'Delete this sport?',
header: 'Confirmation',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Delete',
rejectLabel: 'Cancel',
acceptClass: 'p-button-danger',
accept: async () => {
error.value = '';
try {
await sportsApi.remove(id);
touchSportsCatalog();
toast.add({ severity: 'success', summary: 'Sports', detail: 'Sport deleted successfully.', life: 3000 });
await loadData();
} catch (err) {
error.value = err instanceof Error ? err.message : 'Delete failed.';
toast.add({ severity: 'error', summary: 'Sports', detail: error.value, life: 5000 });
}
}
});
}
function onPage(event: DataTablePageEvent) {
page.value = (event.page ?? 0) + 1;
pageSize.value = event.rows;
void loadData();
}
function onSort(event: DataTableSortEvent) {
sort.value = event.sortField as string;
sortOrder.value = (event.sortOrder ?? 1) as 1 | -1;
void loadData();
}
function onFilter() {
page.value = 1;
void loadData();
}
defineExpose({
openCreate
});
onMounted(() => {
void loadData();
});
</script>
<template>
<div class="panel-card">
<DataTable
v-model:filters="filters"
:value="rows"
:loading="loading"
dataKey="id"
filterDisplay="menu"
lazy
paginator
:rows="pageSize"
:first="(page - 1) * pageSize"
:total-records="totalRecords"
:sort-field="sort"
:sort-order="sortOrder"
@filter="onFilter"
@page="onPage"
@sort="onSort"
>
<Column field="id" header="ID" sortable />
<Column field="name" header="Name" sortable sortField="Name" filterField="Name" :filterMenuStyle="{ width: '14rem' }">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" type="text" placeholder="Filter by name" @keydown.enter.prevent="filterCallback()" />
</template>
</Column>
<Column header="" style="width: 10rem">
<template #body="{ data }">
<div class="inline-actions">
<Button icon="pi pi-pencil" text rounded @click="openEdit(data.id)" />
<Button icon="pi pi-trash" text rounded severity="danger" @click="remove(data.id)" />
</div>
</template>
</Column>
</DataTable>
<Dialog v-model:visible="dialogVisible" modal :style="{ width: '42rem' }" :header="form.id ? 'Edit sport' : 'New sport'">
<div class="field">
<label for="sport-name">Name</label>
<InputText id="sport-name" v-model="form.name" />
</div>
<template #footer>
<Button label="Cancel" text @click="dialogVisible = false" />
<Button label="Save" @click="save" />
</template>
</Dialog>
</div>
</template>

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,22 @@
import { createApp } from 'vue';
import ConfirmationService from 'primevue/confirmationservice';
import PrimeVue from 'primevue/config';
import ToastService from 'primevue/toastservice';
import Aura from '@primeuix/themes/aura';
import App from './App.vue';
import { auth0 } from './auth';
import 'primeicons/primeicons.css';
import './style.css';
const app = createApp(App);
app.use(PrimeVue, {
theme: {
preset: Aura
}
});
app.use(auth0);
app.use(ConfirmationService);
app.use(ToastService);
app.mount('#app');

View File

@@ -0,0 +1,17 @@
import { ref } from 'vue';
export const eventsCatalogVersion = ref(0);
export const sportsCatalogVersion = ref(0);
export const peopleCatalogVersion = ref(0);
export function touchEventsCatalog() {
eventsCatalogVersion.value += 1;
}
export function touchSportsCatalog() {
sportsCatalogVersion.value += 1;
}
export function touchPeopleCatalog() {
peopleCatalogVersion.value += 1;
}

View File

@@ -0,0 +1,15 @@
import { ref } from 'vue';
export const activeTab = ref('sports');
export const pendingRegistrationEventId = ref<number | null>(null);
export function openRegistrationCreateForEvent(eventId: number) {
pendingRegistrationEventId.value = eventId;
activeTab.value = 'registrations';
}
export function consumePendingRegistrationEventId() {
const eventId = pendingRegistrationEventId.value;
pendingRegistrationEventId.value = null;
return eventId;
}

View File

@@ -0,0 +1,224 @@
:root {
color-scheme: light;
font-family: "Segoe UI", "Trebuchet MS", sans-serif;
background:
radial-gradient(circle at top left, rgba(26, 115, 232, 0.15), transparent 30%),
radial-gradient(circle at bottom right, rgba(28, 180, 137, 0.14), transparent 28%),
#f4f7fb;
color: #16324f;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
}
#app {
min-height: 100vh;
}
.app-shell {
max-width: 1600px;
margin: 0 auto;
padding: 18px;
}
.workspace {
padding: 18px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.78);
backdrop-filter: blur(14px);
box-shadow: 0 20px 40px rgba(14, 42, 71, 0.08);
}
.panel-card {
border-radius: 20px;
padding: 18px;
background: #ffffff;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9), 0 8px 24px rgba(15, 35, 56, 0.06);
}
.tabs-header-bar {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 16px;
margin-bottom: 14px;
}
.tabs-header-bar > :first-child {
justify-self: start;
}
.tabs-header-center {
justify-self: center;
}
.tabs-header-actions {
display: flex;
align-items: center;
gap: 12px;
justify-self: end;
}
.user-chip {
padding: 0.6rem 0.9rem;
border-radius: 999px;
background: rgba(22, 50, 79, 0.08);
color: #35536d;
font-size: 0.92rem;
white-space: nowrap;
}
.user-chip-button {
border: 0;
display: inline-flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.panel-toolbar {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.panel-toolbar-left {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
.field-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 14px;
}
.field-grid-person {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.field-grid-registration {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field-span-2 {
grid-column: span 2;
}
.field label {
font-size: 0.88rem;
font-weight: 600;
color: #35536d;
}
.column-header-filter {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 10rem;
}
.column-header-filter > span {
font-weight: 600;
}
.inline-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.status-banner {
margin-top: 16px;
border-radius: 16px;
padding: 12px 14px;
font-size: 0.95rem;
}
.status-banner.error {
background: #fff0f0;
color: #a61b1b;
}
.status-banner.success {
background: #eefbf4;
color: #12663f;
}
.lookup-item {
display: flex;
flex-direction: column;
}
.lookup-item small {
color: #65819b;
}
.auth-state {
min-height: calc(100vh - 72px);
display: flex;
align-items: center;
justify-content: center;
}
.auth-card {
width: min(28rem, 100%);
text-align: center;
}
.auth-card h1 {
margin-top: 0;
margin-bottom: 0.75rem;
}
.auth-card p {
margin-bottom: 1.25rem;
color: #4d6780;
}
@media (max-width: 768px) {
.app-shell {
padding: 16px;
}
.tabs-header-bar {
grid-template-columns: 1fr;
}
.tabs-header-center,
.tabs-header-actions,
.tabs-header-bar > :first-child {
justify-self: stretch;
}
.tabs-header-actions {
flex-wrap: wrap;
justify-content: flex-end;
}
.field-grid-person,
.field-grid-registration {
grid-template-columns: 1fr;
}
.field-span-2 {
grid-column: auto;
}
}

View File

@@ -0,0 +1,38 @@
export function toDate(value?: string | null) {
if (!value) {
return null;
}
const [year, month, day] = value.split('-').map(Number);
return new Date(year, (month ?? 1) - 1, day ?? 1);
}
export function toDateOnlyString(value: Date | null | undefined) {
if (!value) {
return '';
}
const year = value.getFullYear();
const month = `${value.getMonth() + 1}`.padStart(2, '0');
const day = `${value.getDate()}`.padStart(2, '0');
return `${year}-${month}-${day}`;
}
export function formatDateOnly(value?: string | null) {
if (!value) {
return '';
}
return new Intl.DateTimeFormat('hr-HR').format(toDate(value) ?? new Date(value));
}
export function formatDateTime(value?: string | null) {
if (!value) {
return '';
}
return new Intl.DateTimeFormat('hr-HR', {
dateStyle: 'short',
timeStyle: 'short'
}).format(new Date(value));
}

View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Node",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

View File

@@ -0,0 +1,6 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" }
]
}

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
server: {
port: 5173
}
});