feat: Implement event dashboard with activation, cancellation, editing, and booking management functionalities.

This commit is contained in:
Schneider Roland
2025-12-31 12:00:05 +01:00
parent 90c192c881
commit 7ed3367ed8
21 changed files with 441 additions and 274 deletions

66
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,66 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"skipFiles": [
"<node_internals>/**"
],
"program": "${file}"
},
{
"name": "NPM Start Admin",
"type": "node",
"request": "launch",
"cwd": "${workspaceFolder}/admin",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"start"
],
"console": "integratedTerminal",
},
{
"name": "NPM Start LIB",
"type": "node",
"request": "launch",
"cwd": "${workspaceFolder}/admin",
"runtimeExecutable": "ng",
"runtimeArgs": [
"build",
"@rschneider/ng-daisyui",
"--watch"
],
"console": "integratedTerminal"
},
{
"name": "NPM Start REST",
"type": "node",
"request": "launch",
"cwd": "${workspaceFolder}/server",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"start:dev"
],
"preLaunchTask": "Docker Up (Environment)",
"console": "integratedTerminal"
}
],
"compounds": [
{
"name": "Full Stack: Docker + App + GUI",
"configurations": [
"NPM Start LIB",
"NPM Start Admin",
"NPM Start REST"
],
"stopAll": true
}
]
}

5
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,5 @@
{
"editor.fontFamily": "'FiraCode Nerd Font Mono',Menlo, Monaco, 'Courier New', monospace",
"terminal.integrated.fontFamily": "'FiraCode Nerd Font Mono'",
"markdown.preview.fontFamily": "'FiraCode Nerd Font Mono',-apple-system, BlinkMacSystemFont, 'Segoe WPC', 'Segoe UI', system-ui, 'Ubuntu', 'Droid Sans', sans-serif"
}

18
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,18 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Docker Up (Environment)",
"type": "shell",
"command": "docker compose up -d",
"options": {
"cwd": "${workspaceFolder}/environment/dev"
},
"presentation": {
"reveal": "always",
"panel": "new"
},
"problemMatcher": []
}
]
}

File diff suppressed because one or more lines are too long

View File

@@ -78,6 +78,7 @@ export interface CalendarCreateBookingDto {
}
export interface CancelBookingDto {
canceledReason: string;
}
export interface UserResponseDto {
@@ -94,6 +95,7 @@ export interface BookingResponseDto {
canceledAt: string | null;
createdAt: string | null;
user: UserResponseDto;
status?: string;
}
export interface PaginationResponseMetaDto {

View File

@@ -137,9 +137,9 @@ export class CalendarService {
return this.httpClient.post(url, calendarCreateBookingDto, requestOptions);
}
calendarControllerCancelBooking(bookingId: number, cancelBookingDto: CancelBookingDto, observe?: 'body', options?: RequestOptions<'json'>): Observable<any>;
calendarControllerCancelBooking(bookingId: number, cancelBookingDto: CancelBookingDto, observe?: 'response', options?: RequestOptions<'json'>): Observable<HttpResponse<any>>;
calendarControllerCancelBooking(bookingId: number, cancelBookingDto: CancelBookingDto, observe?: 'events', options?: RequestOptions<'json'>): Observable<HttpEvent<any>>;
calendarControllerCancelBooking(bookingId: number, cancelBookingDto: CancelBookingDto, observe?: 'body', options?: RequestOptions<'json'>): Observable<CalenderControllerGetBookingResponse>;
calendarControllerCancelBooking(bookingId: number, cancelBookingDto: CancelBookingDto, observe?: 'response', options?: RequestOptions<'json'>): Observable<HttpResponse<CalenderControllerGetBookingResponse>>;
calendarControllerCancelBooking(bookingId: number, cancelBookingDto: CancelBookingDto, observe?: 'events', options?: RequestOptions<'json'>): Observable<HttpEvent<CalenderControllerGetBookingResponse>>;
calendarControllerCancelBooking(bookingId: number, cancelBookingDto: CancelBookingDto, observe?: 'body' | 'events' | 'response', options?: RequestOptions<'arraybuffer' | 'blob' | 'json' | 'text'>): Observable<any> {
const url = `${this.basePath}/api/calendar/bookings/${bookingId}/cancel`;

View File

@@ -5,26 +5,22 @@
@if (workflow() == 'event_create') {
<rs-daisy-modal [isOpen]="true" (closeClick)="closeDialog()" [modalBoxStyleClass]="'max-w-none w-2xl'">
<app-create-event-form (ready)="closeDialog()" [date]="selectedDate()"
[id]="undefined"></app-create-event-form>
<app-create-event-form (ready)="closeDialog()" [date]="selectedDate()" [id]="undefined"></app-create-event-form>
</rs-daisy-modal>
}
@if (workflow() == 'event_dashboard' && selectedEvent()) {
<rs-daisy-modal [isOpen]="true" (closeClick)="closeDialog()" [modalBoxStyleClass]="'max-w-none w-2xl'">
<app-single-event-dashboard [event]="selectedEvent()"
(action)="setWorkFlow($event)"
></app-single-event-dashboard>
<app-single-event-dashboard [event]="selectedEvent()" (action)="setWorkFlow($event)"></app-single-event-dashboard>
</rs-daisy-modal>
}
@for (dialogDefinition of dialogs; track dialogDefinition) {
@if (dialogDefinition.isRendered()) {
<rs-daisy-modal [isOpen]="true" (closeClick)="closeDialog()" [modalBoxStyleClass]="'max-w-none w-2xl'">
<rs-daisy-modal [isOpen]="true" (closeClick)="openDashboard()" [modalBoxStyleClass]="'max-w-none w-2xl'">
<ng-container
*ngComponentOutlet="dialogDefinition.component; inputs: dialogDefinition.componentInputs ? dialogDefinition.componentInputs() : {}; "
></ng-container>
*ngComponentOutlet="dialogDefinition.component; inputs: dialogDefinition.componentInputs ? dialogDefinition.componentInputs() : {}; "></ng-container>
</rs-daisy-modal>
}
}

View File

@@ -79,14 +79,24 @@ export class CalendarView {
}
});
this.eventBus.on(EventType.CALENDAR_VIEW_DIALOG_CLOSED)
this.eventBus.on(EventType.CALENDAR_VIEW_CLOSE_DIALOG_AND_RELOAD)
.pipe(takeUntilDestroyed())
.subscribe({
next: (_) => {
this.closeDialog();
this.calendarComponent?.getApi().refetchEvents();
}
})
this.eventBus.on(EventType.CALENDAR_VIEW_EVENT_DASHBOARD)
.pipe(takeUntilDestroyed())
.subscribe({
next: (_) => {
this.openDashboard();
}
})
this.calendarOptions = {
plugins: [dayGridPlugin, timeGridPlugin, listPlugin, interactionPlugin],
@@ -254,7 +264,9 @@ export class CalendarView {
closeDialog() {
this.workflow.set('NO_DIALOG');
}
openDashboard() {
this.workflow.set('event_dashboard');
}
/**
* Set dashboard workflow
* @param workflowType

View File

@@ -12,6 +12,7 @@
<tr>
<th>Foglalás dátuma</th>
<th>Foglalt helyek száma</th>
<th>Státusz</th>
<th></th>
</tr>
</thead>
@@ -20,9 +21,23 @@
<tr>
<td>{{formatDateTime(booking.createdAt)}}</td>
<td>{{booking.reservedSeatsCount}}</td>
<td><rs-daisy-button [variant]="'error'">
<td>
@switch (booking.status){
@case ('active'){
<span [outerHTML]="SvgIcons.heroCheckCircle | safeHtml"></span>
}
@case ('customer_cancelled'){
<span [outerHTML]="SvgIcons.heroMinusCircle | safeHtml"></span>
}
}
</td>
<td>
@if (booking.status == 'active') {
<rs-daisy-button [variant]="'error'" (clickEvent)="cancelBooking(booking)">
Lemondás
</rs-daisy-button></td>
</rs-daisy-button>
}
</td>
</tr>
}
</tbody>

View File

@@ -7,6 +7,9 @@ import { delay, of, throwError } from 'rxjs';
import { Button, Pagination } from '@rschneider/ng-daisyui';
import { Heading } from '../../../../../components/heading/heading';
import { format } from 'date-fns';
import { EventType } from '../../../../../../types';
import { SvgIcons } from '../../../../../svg-icons';
import { SafeHtmlPipe } from "../../../../../pipes/safe-html-pipe";
@Component({
selector: 'app-single-event-booking-list',
@@ -14,6 +17,7 @@ import { format } from 'date-fns';
Pagination,
Heading,
Button,
SafeHtmlPipe
],
templateUrl: './single-event-booking-list.html',
styleUrl: './single-event-booking-list.css',
@@ -30,57 +34,64 @@ export class SingleEventBookingList {
pageCount = computed(() => {
// return this.bookings.value()?.pageCount || 1;
return 1;
})
});
bookings = rxResource(
{
params: () => ({
page: this.activePage()
page: this.activePage(),
}),
stream: ({ params }) => {
// console.info("loading resource", params);
//
// const allData = ["a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t"]
//
// let pageCount = Math.floor( allData.length / this.pageSize());
// if ( (allData.length % this.pageSize()) > 0){
// pageCount += 1;
// }
// pageCount = Math.max(pageCount ,1);
//
// const pageData = allData.slice( ((this.activePage()-1) * this.pageSize()),this.activePage()*this.pageSize());
// console.info("booking page data",pageData);
// return of({
// items: pageData,
// pageCount
// }).pipe( delay(1000))
if (!this.event()) {
return throwError( () => new Error("no event"))
return throwError(() => new Error('no event'));
}
const event = this.event()!;
return this.calendarService.calendarControllerGetBookings(
event.id,
new Date(event.startTime).toISOString()
)
new Date(event.startTime).toISOString(),
params.page,
this.pageSize(),
);
},
}
)
},
);
protected paginate($event: number) {
console.info("paginated to ", $event)
console.info('paginated to ', $event);
this.activePage.set($event);
}
public cancelBooking(booking: BookingResponseDto) {
this.calendarService.calendarControllerCancelBooking(
booking.id,
{
canceledReason: 'customer_cancelled',
},
).subscribe({
next: () => {
// this.eventBus.emit(EventType.CALENDAR_VIEW_BOOKING_CANCELLED, '');
this.bookings.reload();
},
error: () => {
// todo: handle error
}
});
}
formatDateTime(dateStr?: string | Date | null) {
if (!dateStr) {
return "";
return '';
}
return format(new Date(dateStr), 'yyyy-MM-dd HH:mm');
}
protected readonly format = format;
protected readonly SvgIcons = SvgIcons;
}

View File

@@ -77,7 +77,7 @@ export class SingleEventDashboardEventActivation {
}
protected closeDialog() {
this.eventBus.emit(EventType.CALENDAR_VIEW_DIALOG_CLOSED, 'Event saved')
this.eventBus.emit(EventType.CALENDAR_VIEW_EVENT_DASHBOARD, 'Event saved')
}
}

View File

@@ -27,7 +27,7 @@ export class SingleEventDashboardEventEdit {
} else if (action == 'save-event-failed') {
this.eventBus.emit(EventType.CALENDAR_VIEW_DIALOG_CLOSED, '');
} else {
this.eventBus.emit(EventType.CALENDAR_VIEW_DIALOG_CLOSED,'');
this.eventBus.emit(EventType.CALENDAR_VIEW_EVENT_DASHBOARD, '');
}
}
}

View File

@@ -7,14 +7,13 @@
<form [formGroup]="form" (ngSubmit)="onSubmit()" class="space-y-4 mt-4">
<div class="form-control"><label class="label"><span class="label-text">Megnevezés</span></label>
<input [class.input-error]="title?.invalid && title?.touched" type="text" formControlName="title" class="input input-bordered w-full" />
<input [class.input-error]="title?.invalid && title?.touched" type="text" formControlName="title"
class="input input-bordered w-full" />
</div>
<div class="form-control"><label class="label"><span class="label-text">Esemény típus</span></label>
<select class="select w-full"
formControlName="eventTypeId"
[class.input-error]="eventType?.invalid && eventType?.touched"
>
<select class="select w-full" formControlName="eventTypeId"
[class.input-error]="eventType?.invalid && eventType?.touched">
<option disabled selected>Pick a color</option>
@for (eventType of eventTypes(); track eventType.id) {
<option [value]="eventType.id">{{ eventType.name }}</option>
@@ -23,13 +22,19 @@
</div>
<div class="form-control"><label class="label"><span class="label-text">Leírás</span></label>
<input [class.input-error]="description?.invalid && description?.touched" type="text" formControlName="description" class="input input-bordered w-full" /></div>
<input [class.input-error]="description?.invalid && description?.touched" type="text"
formControlName="description" class="input input-bordered w-full" />
</div>
<div class="form-control"><label class="label"><span class="label-text">Kezdő időpont</span></label>
<input [class.input-error]="startTime?.invalid && startTime?.touched" type="datetime-local" formControlName="startTime" class="input input-bordered w-full" /></div>
<input [class.input-error]="startTime?.invalid && startTime?.touched" type="datetime-local"
formControlName="startTime" class="input input-bordered w-full" />
</div>
<div class="form-control"><label class="label"><span class="label-text">Befejezési időpont</span></label>
<input [class.input-error]="endTime?.invalid && endTime?.touched" type="datetime-local" formControlName="endTime" class="input input-bordered w-full" /></div>
<input [class.input-error]="endTime?.invalid && endTime?.touched" type="datetime-local" formControlName="endTime"
class="input input-bordered w-full" />
</div>
<div class="form-control"><label class="label cursor-pointer justify-start gap-4">
<span class="label-text">Ismétlődő</span>
@@ -42,8 +47,7 @@
<div class="form-control">
<label class="label"><span class="label-text">Gyakoriság</span></label>
<select class="select w-full"
formControlName="frequency"
<select class="select w-full" formControlName="frequency"
[class.select-error]="frequency?.invalid && frequency?.touched">
<option disabled selected>Válassz gyakoriságot</option>
@for (frequencyOption of frequencyOptions; track frequencyOption) {
@@ -54,9 +58,7 @@
<div class="form-control">
<label class="label"><span class="label-text">Intervallum</span></label>
<input type="number"
formControlName="interval"
class="input input-bordered w-full"
<input type="number" formControlName="interval" class="input input-bordered w-full"
[class.input-error]="interval?.invalid && interval?.touched" />
</div>
@@ -65,11 +67,8 @@
<div class="flex flex-wrap gap-4">
@for (day of weekDayOptions; track day.value) {
<label class="label cursor-pointer justify-start gap-2">
<input type="checkbox"
[value]="day.value"
[checked]="isDayChecked(day.value)"
(change)="onByDayChange($event)"
class="checkbox" />
<input type="checkbox" [value]="day.value" [checked]="isDayChecked(day.value)"
(change)="onByDayChange($event)" class="checkbox" />
<span class="label-text">{{ day.label }}</span>
</label>
}
@@ -78,9 +77,7 @@
<div class="form-control">
<label class="label"><span class="label-text">Hónap napja</span></label>
<input type="text"
formControlName="byMonthDay"
class="input input-bordered w-full" />
<input type="text" formControlName="byMonthDay" class="input input-bordered w-full" />
<div class="label">
<span class="label-text-alt">Vesszővel elválasztott lista (pl. 1,15,31)</span>
</div>
@@ -88,9 +85,7 @@
<div class="form-control">
<label class="label"><span class="label-text">Hónap</span></label>
<input type="text"
formControlName="byMonth"
class="input input-bordered w-full" />
<input type="text" formControlName="byMonth" class="input input-bordered w-full" />
<div class="label">
<span class="label-text-alt">Vesszővel elválasztott lista (pl. 1,2,3)</span>
</div>
@@ -98,18 +93,16 @@
<div class="form-control">
<label class="label"><span class="label-text">Ismétlődés vége</span></label>
<input type="date"
formControlName="endDate"
class="input input-bordered w-full" />
<input type="date" formControlName="endDate" class="input input-bordered w-full" />
</div>
</div>
}
<div class="card-actions justify-end mt-6">
<a class="btn btn-ghost" (click)="doReady()">Mégse</a>
<button type="submit" class="btn btn-primary" [disabled]="form.invalid">
{{ isEditMode ? 'Mentés' : 'Létrezhozás' }}
</button>
<a class="btn btn-primary" (click)="doReady()">Mégse</a>
</div>
</form>
</div>

View File

@@ -44,4 +44,19 @@ export class SvgIcons {
</svg>
`;
public static heroMinusCircle = `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12H9m12 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
`;
public static heroBadgeCircle = `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 0 1-1.043 3.296 3.745 3.745 0 0 1-3.296 1.043A3.745 3.745 0 0 1 12 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 0 1-3.296-1.043 3.745 3.745 0 0 1-1.043-3.296A3.745 3.745 0 0 1 3 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 0 1 1.043-3.296 3.746 3.746 0 0 1 3.296-1.043A3.746 3.746 0 0 1 12 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 0 1 3.296 1.043 3.746 3.746 0 0 1 1.043 3.296Z" />
</svg>
`;
public static heroCheckCircle = `
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
`;
}

View File

@@ -19,8 +19,11 @@ export enum EventType {
USER_LOGGED_IN = 'USER_LOGGED_IN',
ORDER_CREATED = 'ORDER_CREATED',
THEME_CHANGED = 'THEME_CHANGED',
CALENDAR_VIEW_EVENT_DASHBOARD = 'CALENDAR_VIEW_EVENT_DASHBOARD',
CALENDAR_VIEW_EVENT_SAVED = 'CALENDAR_VIEW_EVENT_SAVED',
CALENDAR_VIEW_DIALOG_CLOSED = 'CALENDAR_VIEW_DIALOG_CLOSED'
CALENDAR_VIEW_DIALOG_CLOSED = 'CALENDAR_VIEW_DIALOG_CLOSED',
CALENDAR_VIEW_BOOKING_CANCELLED = 'CALENDAR_VIEW_BOOKING_CANCELLED',
CALENDAR_VIEW_CLOSE_DIALOG_AND_RELOAD = 'CALENDAR_VIEW_CLOSE_DIALOG_AND_RELOAD'
}
@@ -30,6 +33,10 @@ export interface EventMap {
[EventType.ORDER_CREATED]: { orderId: number };
[EventType.CALENDAR_VIEW_EVENT_SAVED]: string;
[EventType.CALENDAR_VIEW_DIALOG_CLOSED]: string;
[EventType.CALENDAR_VIEW_DIALOG_CLOSED]: string;
[EventType.CALENDAR_VIEW_BOOKING_CANCELLED]: string;
[EventType.CALENDAR_VIEW_CLOSE_DIALOG_AND_RELOAD]: string;
[EventType.CALENDAR_VIEW_EVENT_DASHBOARD]: string;
}
export interface AppEvent<T = any> {

8
dius.txt Normal file
View File

@@ -0,0 +1,8 @@
NAiP
file:///Users/rschneider/Library/Fonts/FiraCodeNerdFontMono-Light.ttf#postscript-name=FiraCodeNFM-Light
file:///Users/rschneider/Library/Fonts/FiraCodeNerdFontMono-Regular.ttf#postscript-name=FiraCodeNFM-Reg
file:///Users/rschneider/Library/Fonts/FiraCodeNerdFontMono-Retina.ttf#postscript-name=FiraCodeNFM-Ret
file:///Users/rschneider/Library/Fonts/FiraCodeNerdFontMono-Medium.ttf#postscript-name=FiraCodeNFM-Med
file:///Users/rschneider/Library/Fonts/FiraCodeNerdFontMono-SemiBold.ttf#postscript-name=FiraCodeNFM-SemBd
file:///Users/rschneider/Library/Fonts/FiraCodeNerdFontMono-Bold.ttf#postscript-name=FiraCodeNFM-Bold

View File

@@ -110,6 +110,7 @@ export class CalendarController {
@ApiCreatedResponse({ type: CalenderControllerGetBookingResponse }) // Removed <Booking> generic for cleaner syntax unless defined elsewhere
// Ensure ValidationPipe is enabled with transform: true (Global or Method scoped)
// @UsePipes(new ValidationPipe({ transform: true }))
@Roles(Role.Admin, Role.User)
async getBookings(
@User() user: types.AppUser,
@Param('eventId', ParseIntPipe) eventId: number,
@@ -117,7 +118,7 @@ export class CalendarController {
@Query() calendarGetBookingDto: CalendarGetBookingDto,
) {
return this.calendarService.getBookings(
user.user!.userId,
user,
eventId,
calendarGetBookingDto,
);

View File

@@ -28,6 +28,8 @@ import {
BookingResponseDto,
CalenderControllerGetBookingResponse,
} from './dto/booking.response.dto';
import { AppUser } from 'src/types';
import { Role } from '../auth/role.enum';
// --- Type-Safe Maps ---
const frequencyMap: Record<string, RRule.Frequency> = {
@@ -606,7 +608,7 @@ export class CalendarService {
}
async getBookings(
userId: number,
user: AppUser,
eventId: number,
queryParams: CalendarGetBookingDto,
): Promise<CalenderControllerGetBookingResponse> {
@@ -621,6 +623,13 @@ export class CalendarService {
['createdAt']: order!,
},
};
if (!user.user?.roles.includes(Role.Admin)) {
findOptions.where = {
...findOptions.where,
userId: user.user!.userId,
};
}
const paginated = limit > 0;
const [data, totalItems] =
await this.bookingRepository.findAndCount(findOptions);

View File

@@ -32,6 +32,9 @@ export class BookingResponseDto {
})
user?: UserResponseDto;
@ApiProperty({ required: false })
status?: string;
constructor(booking: Booking) {
this.id = Number(booking.id); // Handle BigInt conversion
this.eventId = Number(booking.eventId);
@@ -46,6 +49,14 @@ export class BookingResponseDto {
// Assuming User entity has firstName/lastName
this.user = new UserResponseDto(booking.user);
}
this.status = 'active';
if (booking.canceledAt) {
if (booking.canceledReason == 'customer_cancelled') {
this.status = 'customer_cancelled';
} else {
this.status = 'cancelled';
}
}
}
}

View File

@@ -1,14 +1,12 @@
import {
IsInt,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CancelBookingDto {
@ApiProperty()
@IsOptional()
@IsString()
@MaxLength(50)