WebApi + ClientApp, GraphQL, Reflection
This commit is contained in:
6
Events-WebApi/Events.ClientApp/.env.example
Normal file
6
Events-WebApi/Events.ClientApp/.env.example
Normal 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
|
||||
113
Events-WebApi/Events.ClientApp/README.md
Normal file
113
Events-WebApi/Events.ClientApp/README.md
Normal 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
|
||||
12
Events-WebApi/Events.ClientApp/index.html
Normal file
12
Events-WebApi/Events.ClientApp/index.html
Normal 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>
|
||||
1753
Events-WebApi/Events.ClientApp/package-lock.json
generated
Normal file
1753
Events-WebApi/Events.ClientApp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
Events-WebApi/Events.ClientApp/package.json
Normal file
25
Events-WebApi/Events.ClientApp/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
143
Events-WebApi/Events.ClientApp/src/App.vue
Normal file
143
Events-WebApi/Events.ClientApp/src/App.vue
Normal 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>
|
||||
61
Events-WebApi/Events.ClientApp/src/api/eventsApi.ts
Normal file
61
Events-WebApi/Events.ClientApp/src/api/eventsApi.ts
Normal 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 })
|
||||
};
|
||||
144
Events-WebApi/Events.ClientApp/src/api/http.ts
Normal file
144
Events-WebApi/Events.ClientApp/src/api/http.ts
Normal 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'
|
||||
};
|
||||
}
|
||||
79
Events-WebApi/Events.ClientApp/src/api/types.ts
Normal file
79
Events-WebApi/Events.ClientApp/src/api/types.ts
Normal 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;
|
||||
}
|
||||
19
Events-WebApi/Events.ClientApp/src/auth.ts
Normal file
19
Events-WebApi/Events.ClientApp/src/auth.ts
Normal 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
|
||||
});
|
||||
319
Events-WebApi/Events.ClientApp/src/components/EventsPanel.vue
Normal file
319
Events-WebApi/Events.ClientApp/src/components/EventsPanel.vue
Normal 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>
|
||||
431
Events-WebApi/Events.ClientApp/src/components/PeoplePanel.vue
Normal file
431
Events-WebApi/Events.ClientApp/src/components/PeoplePanel.vue
Normal 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>
|
||||
@@ -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>
|
||||
227
Events-WebApi/Events.ClientApp/src/components/SportsPanel.vue
Normal file
227
Events-WebApi/Events.ClientApp/src/components/SportsPanel.vue
Normal 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>
|
||||
1
Events-WebApi/Events.ClientApp/src/env.d.ts
vendored
Normal file
1
Events-WebApi/Events.ClientApp/src/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
22
Events-WebApi/Events.ClientApp/src/main.ts
Normal file
22
Events-WebApi/Events.ClientApp/src/main.ts
Normal 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');
|
||||
17
Events-WebApi/Events.ClientApp/src/state/catalogState.ts
Normal file
17
Events-WebApi/Events.ClientApp/src/state/catalogState.ts
Normal 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;
|
||||
}
|
||||
15
Events-WebApi/Events.ClientApp/src/state/uiState.ts
Normal file
15
Events-WebApi/Events.ClientApp/src/state/uiState.ts
Normal 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;
|
||||
}
|
||||
224
Events-WebApi/Events.ClientApp/src/style.css
Normal file
224
Events-WebApi/Events.ClientApp/src/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
38
Events-WebApi/Events.ClientApp/src/utils/dates.ts
Normal file
38
Events-WebApi/Events.ClientApp/src/utils/dates.ts
Normal 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));
|
||||
}
|
||||
18
Events-WebApi/Events.ClientApp/tsconfig.app.json
Normal file
18
Events-WebApi/Events.ClientApp/tsconfig.app.json
Normal 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"]
|
||||
}
|
||||
6
Events-WebApi/Events.ClientApp/tsconfig.json
Normal file
6
Events-WebApi/Events.ClientApp/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" }
|
||||
]
|
||||
}
|
||||
9
Events-WebApi/Events.ClientApp/vite.config.ts
Normal file
9
Events-WebApi/Events.ClientApp/vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user