add calendarview event creation

This commit is contained in:
Schneider Roland 2025-11-23 22:26:16 +01:00
parent 008b644bb1
commit 6b975dadac
24 changed files with 583 additions and 31 deletions

View File

@ -22,6 +22,7 @@
"@fullcalendar/timegrid": "^6.1.19",
"@tailwindcss/postcss": "^4.1.17",
"daisyui": "^5.4.5",
"date-fns": "^4.1.0",
"jwt-decode": "^4.0.0",
"postcss": "^8.5.6",
"rxjs": "~7.8.0",
@ -4908,6 +4909,16 @@
"url": "https://github.com/saadeghi/daisyui?sponsor=1"
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/date-format": {
"version": "4.0.14",
"resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz",

View File

@ -36,6 +36,7 @@
"@fullcalendar/timegrid": "^6.1.19",
"@tailwindcss/postcss": "^4.1.17",
"daisyui": "^5.4.5",
"date-fns": "^4.1.0",
"jwt-decode": "^4.0.0",
"postcss": "^8.5.6",
"rxjs": "~7.8.0",

View File

@ -0,0 +1,26 @@
<div class="card bg-base-100 w-96 shadow-sm" [class]="cardClass()">
@if (imageSrc()) {
<figure class="px-10 pt-10">
<img
[src]="imageSrc()"
[alt]="imageAlt()"
class="rounded-xl"
[class]="imageClass()"
/>
</figure>
}
<div class="card-body items-center text-center">
@if (cardTitle()) {
<h2 class="card-title">{{ cardTitle() }}</h2>
}
@if (cardText()) {
<p>A card component has a figure, a body part, and inside body there are title and actions parts</p>
}
<ng-content></ng-content>
@if (cardActionText()) {
<div class="card-actions">
<button class="btn btn-primary">{{ cardActionText() }}</button>
</div>
}
</div>
</div>

View File

@ -0,0 +1,19 @@
import { Component, input, output, signal } from '@angular/core';
@Component({
selector: 'rs-daisy-card-with-centered-content-and-paddings',
imports: [],
templateUrl: './card-with-centered-content-and-paddings.html',
styleUrl: './card-with-centered-content-and-paddings.css',
})
export class CardWithCenteredContentAndPaddings {
imageSrc = input<string | null>(null);
imageAlt = input<string | null>(null);
imageClass = input<string | null>(null);
cardTitle = input<string | null>(null);
cardText = input<string | null>(null);
cardActionText = input<string | null>(null);
cardActionClick = output<void>();
cardClass = signal<string | null>(null);
}

View File

@ -0,0 +1,15 @@
{{ isOpen() }}
<dialog #modal class="modal" [class]="dialogStyleClass()">
<div class="modal-box" [class]="modalBoxStyleClass()">
<button (click)="closeClicked()" class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
@if (headerText()) {
<h3 class="text-lg font-bold">{{ headerText() }}</h3>
}
<ng-content></ng-content>
</div>
@if (backdrop()) {
<form (click)="closeClicked()" class="modal-backdrop">
<button>close</button>
</form>
}
</dialog>

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Modal } from './modal';
describe('Modal', () => {
let component: Modal;
let fixture: ComponentFixture<Modal>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Modal]
})
.compileComponents();
fixture = TestBed.createComponent(Modal);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,95 @@
import {
AfterViewInit,
Component,
effect,
ElementRef,
input,
model, OnDestroy,
OnInit,
output,
signal,
ViewChild,
} from '@angular/core';
@Component({
selector: 'rs-daisy-modal',
imports: [],
templateUrl: './modal.html',
styleUrl: './modal.css',
})
export class Modal implements OnInit , AfterViewInit, OnDestroy{
dialogStyleClass = input<string>();
backdrop = input<boolean>(false);
modalBoxStyleClass = input<string>();
closeButton = input<boolean>(true);
headerText = input<string>();
isOpen = model<boolean>(false);
onClose = output<boolean>();
closeClick = output<boolean>();
@ViewChild('modal') modalRef!: ElementRef<HTMLDialogElement>;
private initialized = signal<boolean>(false);
constructor() {
effect(() => {
if ( this.isOpen()){
this.doOpen();
}else{
this.doClose();
}
});
}
ngOnInit() {
/* if (open()){
}*/
}
ngAfterViewInit() {
// console.info("dialog",this.dialog)
const modal = this.modalRef.nativeElement;
modal.addEventListener('close', () => this.onClose.emit(true) );
this.initialized.set(true);
}
emitClose(){
console.info("emit close", this.onClose);
this.onClose?.emit(true)
}
doOpen() {
if ( !this.initialized()){
return;
}
const modal = this.modalRef.nativeElement;
if ( !modal.open ){
modal.showModal();
}
}
doClose() {
if ( !this.initialized()){
return;
}
const modal = this.modalRef.nativeElement;
if ( modal.open ){
modal.close();
}
}
ngOnDestroy() {
console.info("destroy");
// const modal = this.modalRef?.nativeElement;
// if ( modal ){
// modal.removeEventListener('close', this.emitClose);
// }
}
closeClicked() {
console.info("close clicked");
this.closeClick.emit(true);
}
}

View File

@ -6,5 +6,7 @@ export * from './lib/ng-daisyui';
export * from './lib/components/button/button';
export * from './lib/components/footer/footer';
export * from './lib/components/breadcrumbs/breadcrumbs';
export * from './lib/components/modal/modal';
export * from './lib/components/card/card-with-centered-content-and-paddings/card-with-centered-content-and-paddings';
export * from './lib/daisy.types';
export * from './lib/layout/';

View File

@ -6,3 +6,8 @@
</div>
<full-calendar #calendar [options]="calendarOptions"></full-calendar>
</div>
<rs-daisy-modal [isOpen]="isOpen()" (closeClick)="closeDialog()" >
<app-create-event-form (ready)="closeDialog()"></app-create-event-form>
</rs-daisy-modal>

View File

@ -1,4 +1,4 @@
import { Component, ElementRef, ViewChild } from '@angular/core';
import { AfterViewInit, Component, effect, ElementRef, inject, OnInit, signal, ViewChild } from '@angular/core';
import { FullCalendarComponent, FullCalendarModule } from '@fullcalendar/angular';
import { CalendarOptions } from '@fullcalendar/core';
@ -6,19 +6,30 @@ import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import listPlugin from '@fullcalendar/list';
import interactionPlugin from '@fullcalendar/interaction';
import { appConfig } from '../../../../app.config';
import { Modal } from '@rschneider/ng-daisyui';
import { CreateEventForm } from '../create-event-form/create-event-form';
import { CalendarService } from '../../services/calendar.service';
import { addDays, subDays } from 'date-fns';
import { EventsInRangeDTO } from '../../models/events-in-range-dto.model';
@Component({
selector: 'app-calendar-view',
imports: [FullCalendarModule],
imports: [FullCalendarModule, Modal, CreateEventForm],
templateUrl: './calendar-view.html',
styleUrl: './calendar-view.css',
})
export class CalendarView {
export class CalendarView implements OnInit, AfterViewInit {
@ViewChild('startHour') startHour!: ElementRef;
@ViewChild('calendar') calendarComponent: FullCalendarComponent | undefined;
calendarService = inject(CalendarService);
workflow = signal<string>("")
isOpen = signal<boolean>(false);
events = signal<EventsInRangeDTO[]>([])
calendarOptions: CalendarOptions;
constructor() {
@ -27,6 +38,7 @@ export class CalendarView {
const end = new Date();
end.setHours(11,0,0)
this.calendarOptions = {
plugins: [dayGridPlugin, timeGridPlugin, listPlugin,interactionPlugin],
initialView: 'dayGridMonth',
@ -41,7 +53,6 @@ export class CalendarView {
events: [
{ title: 'Meeting1 until'+end.toString(), start, end },
],
eventClick: function(info) {
@ -51,21 +62,54 @@ export class CalendarView {
info.el.style.borderColor = 'red';
},
dateClick: function(info) {
console.info('Date click on: ' , info);
const calendarApi = info.view.calendar;
const start = new Date(info.date.getTime())
start.setHours(2,0,0)
const end = new Date(info.date.getTime())
end.setHours(3,0,0)
calendarApi.addEvent({
title: 'New Event',
start,
end,
});
dateClick: (info) => {
console.info("setting day workflow");
this.workflow.set("day");
this.isOpen.set(true);
// console.info('Date click on: ' , info);
// const calendarApi = info.view.calendar;
// const start = new Date(info.date.getTime())
// start.setHours(2,0,0)
// const end = new Date(info.date.getTime())
// end.setHours(3,0,0)
// calendarApi.addEvent({
// title: 'New Event',
// start,
// end,
// });
}
};
effect(() => {
// this.calendarOptions.events = this.events
});
}
ngAfterViewInit(): void {
// this.calendarComponent?.getApi().
}
ngOnInit(): void {
const start = subDays(new Date(),14)
const end = addDays(new Date(),14);
this.calendarService.getEventsInRange({
startTime: start,
endTime: end
}).subscribe(
{
'next': (events) => {
console.info('events',events)
}
}
)
}
protected addEvent($event: PointerEvent) {
@ -82,4 +126,8 @@ export class CalendarView {
start
})
}
closeDialog() {
this.isOpen.set(false)
}
}

View File

@ -0,0 +1,51 @@
<!-- Generated by the CLI -->
<div class="p-4 md:p-8">
<div class="card bg-base-100 shadow-xl max-w-2xl mx-auto">
<div class="card-body">
<h2 class="card-title text-3xl">
Esemény {{ isEditMode ? 'szerkesztése' : 'létrehozása' }}
</h2>
<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" />
</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"
>
<option disabled selected>Pick a color</option>
@for (eventType of eventTypes(); track eventType.id) {
<option [value]="eventType.id">{{ eventType.name }}</option>
}
</select>
</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>
<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>
<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>
<div class="form-control"><label class="label cursor-pointer justify-start gap-4">
<span class="label-text">Ismétlődő</span>
<input type="checkbox" formControlName="is_recurring" class="checkbox" />
</label></div>
<div class="card-actions justify-end mt-6">
<a routerLink="/events" class="btn btn-ghost">Mégse</a>
<button type="submit" class="btn btn-primary" [disabled]="form.invalid">
{{ isEditMode ? 'Mentés' : 'Létrezhozás' }}
</button>
</div>
</form>
</div>
</div>
</div>

View File

@ -0,0 +1,146 @@
import { Component, input, OnInit, output, signal } from '@angular/core';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { EventService } from '../../../events/services/event.service';
import { mergeMap, switchMap, tap } from 'rxjs/operators';
import { Observable, of } from 'rxjs';
import { Event } from '../../../events/models/event.model';
import { EventType } from '../../../event-type/models/event-type.model';
import { EventTypeService } from '../../../event-type/services/event-type.service';
import { CalendarService } from '../../services/calendar.service';
import { EventFormDTO } from '../../models/event-form-dto.model';
@Component({
selector: 'app-create-event-form',
imports: [
ReactiveFormsModule
],
templateUrl: './create-event-form.html',
styleUrl: './create-event-form.css',
})
export class CreateEventForm implements OnInit {
form: FormGroup;
isEditMode = false;
ready = output<void>();
id= input<number | null>() ;
eventTypes = signal<EventType[]>([])
private numericFields = ["event_type_id"];
constructor(
private fb: FormBuilder,
private route: ActivatedRoute,
private router: Router,
private eventService: EventService,
private calendarService: CalendarService,
private eventTypeService: EventTypeService
) {
this.form = this.fb.group({
eventTypeId: [null,Validators.required],
title: [null,Validators.required],
description: [null],
startTime: [null,Validators.required],
endTime: [null,Validators.required],
is_recurring: [null],
});
}
ngOnInit(): void {
of(this.id()).pipe(
tap(id => {
if (id) {
this.isEditMode = true;
}else{
const start = new Date();
const end = new Date();
start.setMinutes(0,0);
end.setHours(start.getHours()+1,0,0);
start.setMinutes(start.getMinutes() - start.getTimezoneOffset());
const startString = start.toISOString().slice(0,16);
end.setMinutes(end.getMinutes() - end.getTimezoneOffset());
const endTime = end.toISOString().slice(0,16);
console.info("Date start",start.toLocaleString("hu-HU", {timeStyle: 'short', dateStyle: 'short'}));
this.form.patchValue({
// start_time: new Date().getTime().toString(),
startTime: startString,
endTime: endTime
})
}
}),
mergeMap(() => {
return this.eventTypeService.find({});
}
),
tap(eventTypes => {
this.eventTypes.set(eventTypes.data);
}),
switchMap(() => {
if (this.isEditMode && this.id()) {
return this.eventService.findOne(this.id()!);
}
return of(null);
}),
).subscribe(event => {
if (event) {
this.form.patchValue(event);
}
});
}
onSubmit(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
const payload: EventFormDTO|any = { ...this.form.value };
for (const field of this.numericFields) {
if (payload[field] != null && payload[field] !== '') {
payload[field] = parseFloat(payload[field]);
}
}
let action$: Observable<Event>;
if (this.isEditMode && this.id()) {
console.info("rong branch")
// action$ = this.calendarService.update(this.id()!, payload);
action$ = of(payload);
} else {
action$ = this.calendarService.create(payload);
}
action$.subscribe({
next: () => this.router.navigate(['/events']),
error: (err) => console.error('Failed to save event', err)
});
}
get eventType(){
return this.form.get('eventTypeId');
}
get title(){
return this.form.get('title');
}
get description(){
return this.form.get('description');
}
get startTime(){
return this.form.get('startTime');
}
get endTime(){
return this.form.get('endTime');
}
doReady(){
this.ready.emit();
}
}

View File

@ -0,0 +1,12 @@
// dvbooking-cli/src/templates/angular/model.ts.tpl
// Generated by the CLI
export interface EventFormDTO {
id?: number;
event_type_id: number;
title: string;
description?: string;
start_time?: Date;
end_time?: Date;
is_recurring: boolean;
}

View File

@ -0,0 +1,22 @@
import { Event } from '../../events/models/event.model';
export interface EventsInRangeDTO {
startTime?: Date;
endTime?: Date;
}
export type BookingUserDto = {
id: number;
name: string; // Assuming user has a 'name' property
email: string; // Assuming user has a 'name' property
};
// This represents a booking with nested user info
export type BookingWithUserDto = {
user: BookingUserDto | null;
id: number;
};
// The final shape of a calendar event occurrence
export type CalendarEventDto = Omit<Event, 'bookings'> & {
isModified?: boolean;
eventBookings: BookingWithUserDto[];
};

View File

@ -0,0 +1,39 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ConfigurationService } from '../../../services/configuration.service';
import { EventFormDTO } from '../models/event-form-dto.model';
import { Event } from '../../events/models/event.model';
import { EventsInRangeDTO } from '../models/events-in-range-dto.model';
@Injectable({
providedIn: 'root'
})
export class CalendarService {
private readonly apiUrl: string;
constructor(
private http: HttpClient,
private configService: ConfigurationService
) {
this.apiUrl = `${this.configService.getApiUrl()}/calendar`;
}
/**
* get events in range
*/
public getEventsInRange(eventsInRangeDto: EventsInRangeDTO): Observable<EventsInRangeDTO[]> {
const params = new HttpParams()
.set('startDate', eventsInRangeDto.startTime!.toISOString())
.set('endDate', eventsInRangeDto.endTime!.toISOString());
return this.http.get<EventsInRangeDTO[]>(this.apiUrl+'', { params });
}
/**
* Create a new record.
*/
public create(data: EventFormDTO): Observable<Event> {
return this.http.post<Event>(this.apiUrl+'/events', data);
}
}

View File

@ -1,5 +1,3 @@
<!-- dvbooking-cli/src/templates/angular/form.component.html.tpl -->
<!-- Generated by the CLI -->
<div class="p-4 md:p-8">
<div class="card bg-base-100 shadow-xl max-w-2xl mx-auto">

View File

@ -6,10 +6,17 @@ import { RecurrenceRule } from '../entity/recurrence-rule.entity';
import { EventException } from '../entity/event-exception.entity';
import { Event } from '../entity/event.entity';
import { Booking } from '../entity/booking.entity';
import { EventType } from '../entity/event-type.entity';
@Module({
imports: [
TypeOrmModule.forFeature([RecurrenceRule, EventException, Event, Booking]),
TypeOrmModule.forFeature([
RecurrenceRule,
EventException,
Event,
Booking,
EventType,
]),
],
controllers: [CalendarController],
providers: [CalendarService],

View File

@ -15,6 +15,7 @@ import { CreateExceptionDto } from './dto/create-exception.dto';
import { Booking } from '../entity/booking.entity';
import { CancelBookingDto } from './dto/cancel-booking.dto';
import { CreateBookingDto } from './dto/create-booking.dto';
import { EventType } from '../entity/event-type.entity';
// --- Type-Safe Maps ---
const frequencyMap: Record<string, RRule.Frequency> = {
@ -60,6 +61,8 @@ export class CalendarService {
private readonly dataSource: DataSource,
@InjectRepository(Event)
private readonly eventRepository: Repository<Event>,
@InjectRepository(EventType)
private readonly eventTypeRepository: Repository<EventType>,
@InjectRepository(RecurrenceRule)
private readonly recurrenceRuleRepository: Repository<RecurrenceRule>,
@InjectRepository(EventException)
@ -196,9 +199,37 @@ export class CalendarService {
// --- Other service methods (createEvent, etc.) remain unchanged ---
async createEvent(createEventDto: CreateEventDto): Promise<Event> {
console.info('createEvent', createEventDto);
const { recurrenceRule, ...eventData } = createEventDto;
const event = this.eventRepository.create(eventData);
const newEvent: Omit<
Event,
'id' | 'bookings' | 'exceptions' | 'recurrenceRule'
> = {
...eventData,
createdAt: new Date(),
updatedAt: new Date(),
timezone: 'Europe/Budapest',
eventType: undefined,
isRecurring: recurrenceRule ? true : false,
};
// check if event type exists
if (eventData.eventTypeId) {
console.info('eventTypeId found');
const eventType = await this.eventTypeRepository.findOneBy({
id: eventData.eventTypeId,
});
if (!eventType) {
throw new BadRequestException(
{},
'Event type not found ' + eventData.eventTypeId,
);
}
newEvent.eventType = eventType;
}
const event = this.eventRepository.create(newEvent);
const savedEvent = await this.eventRepository.save(event);
if (event.isRecurring && recurrenceRule) {

View File

@ -55,9 +55,9 @@ export class CreateEventDto {
@Type(() => Date)
endTime: Date;
@IsNotEmpty()
@IsString()
timezone: string; // e.g., 'Europe/Berlin'
// @IsNotEmpty()
// @IsString()
// timezone: string; // e.g., 'Europe/Berlin'
@IsOptional()
@IsInt()

View File

@ -23,7 +23,7 @@ export class Event {
title: string;
@Column({ name: 'description', type: 'text', nullable: true })
description: string;
description?: string;
@Column({ name: 'start_time', type: 'timestamptz' })
startTime: Date;
@ -45,15 +45,15 @@ export class Event {
// --- Relationships ---
@Column({ name: 'event_type_id', type: 'bigint', nullable: true })
eventTypeId: number;
// @Column({ name: 'event_type_id', type: 'bigint', nullable: true })
// eventTypeId: number;
@ManyToOne(() => EventType, (eventType) => eventType.events, {
nullable: true,
onDelete: 'SET NULL', // As requested for optional relationship
})
@JoinColumn({ name: 'event_type_id' })
eventType: EventType;
eventType?: EventType;
@OneToOne(() => RecurrenceRule, (rule) => rule.event, {
cascade: true, // Automatically save/update recurrence rule when event is saved

View File

@ -26,6 +26,7 @@ async function bootstrap() {
transformOptions: {
enableImplicitConversion: true, // Allows class-transformer to convert string primitives to their target types
},
enableDebugMessages: true,
}),
);