UNPKG

worker-calendar

Version:

A customizable, responsive Angular calendar component for scheduling worker appointments or shifts

1,238 lines (1,030 loc) 42.3 kB
import { Component, OnInit, Input, Output, EventEmitter, ElementRef, Renderer2, NgZone } from '@angular/core'; import { Worker } from '../../models/worker.model'; import { CalendarEvent } from '../../models/event.model'; import { CdkDragStart, CdkDragEnd, CdkDragDrop, CdkDragMove, CdkDragEnter } from '@angular/cdk/drag-drop'; import { initAngular14Compatibility } from '../../compat-angular14'; // Initialize compatibility fixes if (typeof window !== 'undefined') { initAngular14Compatibility(); } export type CalendarViewMode = 'day' | 'week' | 'month'; interface EventPosition { startHour: number; duration: number; // in hours spanningCells: number; top?: string; height?: string; left?: string; width?: string; } interface DragPreview { visible: boolean; top: string; left: string; width: string; height: string; workerId?: number; hour?: number; date?: Date; } interface DropPreview { visible: boolean; width: string; height: string; workerId?: number; hour?: number; date?: Date; title: string; color: string; } @Component({ selector: 'worker-calendar', templateUrl: './worker-calendar.component.html', styleUrls: ['./worker-calendar.component.scss'] }) export class WorkerCalendarComponent implements OnInit { @Input() workers: Worker[] = []; @Input() events: CalendarEvent[] = []; @Input() viewMode: CalendarViewMode = 'day'; @Input() currentDate: Date = new Date(); @Input() language: string = 'fr'; // Default to French @Output() eventClick = new EventEmitter<CalendarEvent>(); @Output() eventAdd = new EventEmitter<{ startTime: Date; endTime: Date; workerIds: number[]; date: Date; }>(); @Output() eventUpdate = new EventEmitter<CalendarEvent>(); @Output() eventRemove = new EventEmitter<CalendarEvent>(); // For all views displayDate: string = ''; // For day view hours: number[] = []; // For week view weekDays: {date: Date, dayName: string, dayNumber: string, isToday: boolean}[] = []; // For month view monthDays: {date: Date, dayNumber: number, isCurrentMonth: boolean, isToday: boolean}[] = []; weekDayNames: string[] = ['Dimanche', 'Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi']; // Event resizing isResizing: boolean = false; resizeDirection: 'left' | 'right' | null = null; resizingEvent: CalendarEvent | null = null; resizeStartX: number = 0; resizeOriginalStartTime: Date | null = null; resizeOriginalEndTime: Date | null = null; cellWidth: number = 100; // Default value, will be calculated on initialization // Event dragging isDragging: boolean = false; draggingEvent: CalendarEvent | null = null; dragStartX: number = 0; dragStartY: number = 0; dragOriginalStartTime: Date | null = null; dragOriginalEndTime: Date | null = null; dragOriginalWorkerId: number | null = null; // Drag preview dragPreview: DragPreview = { visible: false, top: '0px', left: '0px', width: '0px', height: '0px' }; // Drop preview - shows where the event will be placed dropPreview: DropPreview = { visible: false, width: '100%', height: '100%', title: '', color: 'rgba(66, 133, 244, 0.5)' }; // Currently active dragged event activeDragEvent: CalendarEvent | null = null; constructor( private elementRef: ElementRef, private renderer: Renderer2, private ngZone: NgZone ) { } ngOnInit(): void { this.setupHours(); this.updateView(); // Add event listeners for resize and drag operations this.ngZone.runOutsideAngular(() => { this.renderer.listen('document', 'mousemove', (event) => { if (this.isResizing && this.resizingEvent && this.resizeDirection) { this.handleResizeMove(event); } else if (this.isDragging && this.draggingEvent) { this.handleDragMove(event); } }); this.renderer.listen('document', 'mouseup', () => { if (this.isResizing && this.resizingEvent) { this.handleResizeEnd(); } else if (this.isDragging && this.draggingEvent) { this.handleDragEnd(); } }); }); } ngAfterViewInit(): void { // Calculate cell width after view is initialized setTimeout(() => { this.calculateCellWidth(); }, 0); } ngOnChanges(): void { this.updateView(); } setupHours(): void { // Create an array of hours for time-based views (e.g., 8AM to 6PM) this.hours = []; for (let i = 8; i <= 18; i++) { this.hours.push(i); } } updateView(): void { this.formatDisplayDate(); if (this.viewMode === 'week') { this.generateWeekDays(); } else if (this.viewMode === 'month') { this.generateMonthDays(); } // Recalculate cell width on view change setTimeout(() => { this.calculateCellWidth(); }, 0); } calculateCellWidth(): void { let cellElement: HTMLElement | null = null; if (this.viewMode === 'day') { cellElement = this.elementRef.nativeElement.querySelector('.time-cell'); } else if (this.viewMode === 'week') { cellElement = this.elementRef.nativeElement.querySelector('.day-column .hour-cell'); } if (cellElement) { this.cellWidth = cellElement.offsetWidth; } } formatDisplayDate(): void { const locale = this.language === 'fr' ? 'fr-FR' : 'en-US'; // Format the current date display based on view mode if (this.viewMode === 'day') { this.displayDate = this.currentDate.toLocaleDateString(locale, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); // Capitalize first letter for French if (locale === 'fr-FR') { this.displayDate = this.capitalizeFirstLetter(this.displayDate); } } else if (this.viewMode === 'week') { const startOfWeek = new Date(this.currentDate); startOfWeek.setDate(this.currentDate.getDate() - this.currentDate.getDay()); const endOfWeek = new Date(startOfWeek); endOfWeek.setDate(startOfWeek.getDate() + 6); if (locale === 'fr-FR') { const startMonth = startOfWeek.toLocaleDateString(locale, { month: 'long' }); const endMonth = endOfWeek.toLocaleDateString(locale, { month: 'long' }); const startYear = startOfWeek.getFullYear(); const endYear = endOfWeek.getFullYear(); if (startMonth === endMonth && startYear === endYear) { this.displayDate = `${this.capitalizeFirstLetter(startMonth)} ${startOfWeek.getDate()} - ${endOfWeek.getDate()}, ${startYear}`; } else if (startYear === endYear) { this.displayDate = `${this.capitalizeFirstLetter(startMonth)} ${startOfWeek.getDate()} - ${this.capitalizeFirstLetter(endMonth)} ${endOfWeek.getDate()}, ${startYear}`; } else { this.displayDate = `${this.capitalizeFirstLetter(startMonth)} ${startOfWeek.getDate()}, ${startYear} - ${this.capitalizeFirstLetter(endMonth)} ${endOfWeek.getDate()}, ${endYear}`; } } else { const startMonth = startOfWeek.toLocaleDateString(locale, { month: 'short' }); const endMonth = endOfWeek.toLocaleDateString(locale, { month: 'short' }); const startYear = startOfWeek.getFullYear(); const endYear = endOfWeek.getFullYear(); if (startMonth === endMonth && startYear === endYear) { this.displayDate = `${startMonth} ${startOfWeek.getDate()} - ${endOfWeek.getDate()}, ${startYear}`; } else if (startYear === endYear) { this.displayDate = `${startMonth} ${startOfWeek.getDate()} - ${endMonth} ${endOfWeek.getDate()}, ${startYear}`; } else { this.displayDate = `${startMonth} ${startOfWeek.getDate()}, ${startYear} - ${endMonth} ${endOfWeek.getDate()}, ${endYear}`; } } } else if (this.viewMode === 'month') { this.displayDate = this.currentDate.toLocaleDateString(locale, { month: 'long', year: 'numeric' }); // Capitalize first letter for French if (locale === 'fr-FR') { this.displayDate = this.capitalizeFirstLetter(this.displayDate); } } } // Helper to capitalize first letter for French date formatting capitalizeFirstLetter(text: string): string { return text.charAt(0).toUpperCase() + text.slice(1); } generateWeekDays(): void { this.weekDays = []; const startOfWeek = new Date(this.currentDate); startOfWeek.setDate(this.currentDate.getDate() - this.currentDate.getDay()); const today = new Date(); const locale = this.language === 'fr' ? 'fr-FR' : 'en-US'; for (let i = 0; i < 7; i++) { const date = new Date(startOfWeek); date.setDate(startOfWeek.getDate() + i); const isToday = this.isSameDay(date, today); let dayName = date.toLocaleDateString(locale, { weekday: 'short' }); // Capitalize first letter for French if (locale === 'fr-FR') { dayName = this.capitalizeFirstLetter(dayName); } this.weekDays.push({ date: date, dayName: dayName, dayNumber: date.getDate().toString(), isToday: isToday }); } } generateMonthDays(): void { this.monthDays = []; const firstDayOfMonth = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), 1); const lastDayOfMonth = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() + 1, 0); // Start from the first day of the week containing the first day of the month const startDate = new Date(firstDayOfMonth); startDate.setDate(startDate.getDate() - startDate.getDay()); const today = new Date(); const endDate = new Date(lastDayOfMonth); // Ensure we display 6 weeks (42 days) to maintain a consistent calendar grid endDate.setDate(endDate.getDate() + (6 - endDate.getDay())); if (this.getDayDifference(startDate, endDate) < 41) { endDate.setDate(endDate.getDate() + 7); } const currentMonth = this.currentDate.getMonth(); let currentDate = new Date(startDate); while (currentDate <= endDate) { this.monthDays.push({ date: new Date(currentDate), dayNumber: currentDate.getDate(), isCurrentMonth: currentDate.getMonth() === currentMonth, isToday: this.isSameDay(currentDate, today) }); currentDate.setDate(currentDate.getDate() + 1); } } getDayDifference(date1: Date, date2: Date): number { const diffTime = Math.abs(date2.getTime() - date1.getTime()); return Math.ceil(diffTime / (1000 * 60 * 60 * 24)); } isSameDay(date1: Date, date2: Date): boolean { return date1.getDate() === date2.getDate() && date1.getMonth() === date2.getMonth() && date1.getFullYear() === date2.getFullYear(); } onPrevious(): void { switch (this.viewMode) { case 'day': this.currentDate = new Date(this.currentDate.setDate(this.currentDate.getDate() - 1)); break; case 'week': this.currentDate = new Date(this.currentDate.setDate(this.currentDate.getDate() - 7)); break; case 'month': this.currentDate = new Date(this.currentDate.setMonth(this.currentDate.getMonth() - 1)); break; } this.updateView(); } onNext(): void { switch (this.viewMode) { case 'day': this.currentDate = new Date(this.currentDate.setDate(this.currentDate.getDate() + 1)); break; case 'week': this.currentDate = new Date(this.currentDate.setDate(this.currentDate.getDate() + 7)); break; case 'month': this.currentDate = new Date(this.currentDate.setMonth(this.currentDate.getMonth() + 1)); break; } this.updateView(); } onToday(): void { this.currentDate = new Date(); this.updateView(); } changeViewMode(mode: CalendarViewMode): void { this.viewMode = mode; this.updateView(); } handleEventClick(event: CalendarEvent): void { this.eventClick.emit(event); } /** * Handles the start of resize operation */ handleResizeStart(event: MouseEvent, calendarEvent: any, handle: 'left' | 'right'): void { // Prevent resizing from the left (start time) side if (handle === 'left') { event.preventDefault(); event.stopPropagation(); return; } event.preventDefault(); event.stopPropagation(); this.isResizing = true; this.resizingEvent = {...calendarEvent}; this.resizeDirection = handle; this.resizeStartX = event.clientX; this.resizeOriginalStartTime = new Date(calendarEvent.startTime); this.resizeOriginalEndTime = new Date(calendarEvent.endTime); // Add a class to the body to indicate resizing is in progress this.renderer.addClass(document.body, 'resizing'); } // Add event drag start handler handleEventDragStart(event: MouseEvent, calendarEvent: CalendarEvent): void { event.stopPropagation(); // Prevent drag if resize handles were clicked if (event.target && ((event.target as HTMLElement).classList.contains('resize-handle') || (event.target as HTMLElement).parentElement?.classList.contains('resize-handle'))) { return; } this.isDragging = true; this.draggingEvent = {...calendarEvent}; this.dragStartX = event.clientX; this.dragStartY = event.clientY; this.dragOriginalStartTime = new Date(calendarEvent.startTime); this.dragOriginalEndTime = new Date(calendarEvent.endTime); this.dragOriginalWorkerId = calendarEvent.workerIds[0]; // Initialize the drag preview const eventElement = event.currentTarget as HTMLElement; const eventRect = eventElement.getBoundingClientRect(); this.dragPreview = { visible: true, top: `${event.clientY - eventRect.top}px`, left: `${event.clientX - eventRect.left}px`, width: `${eventRect.width}px`, height: `${eventRect.height}px`, workerId: calendarEvent.workerIds[0] }; // Add a class to the body to indicate dragging is in progress this.renderer.addClass(document.body, 'dragging'); } // Add drag move handler handleDragMove(event: MouseEvent): void { if (!this.isDragging || !this.draggingEvent || !this.dragOriginalStartTime || !this.dragOriginalEndTime) { return; } const deltaX = event.clientX - this.dragStartX; const deltaY = event.clientY - this.dragStartY; // Update the drag preview position const element = this.elementRef.nativeElement; const calendarRect = element.getBoundingClientRect(); // Find the cell under the cursor if (this.viewMode === 'day') { const workerRows = element.querySelectorAll('.worker-row'); const timeCells = element.querySelectorAll('.time-cell'); if (workerRows.length > 0 && timeCells.length > 0) { let targetWorkerId = this.dragOriginalWorkerId || 0; let targetHour = this.hours[0]; // Calculate the target worker and hour based on cursor position for (let i = 0; i < workerRows.length; i++) { const rowRect = workerRows[i].getBoundingClientRect(); if (event.clientY >= rowRect.top && event.clientY <= rowRect.bottom) { targetWorkerId = this.workers[i].id; break; } } const hourWidth = this.cellWidth; const workerInfoWidth = element.querySelector('.worker-info')?.getBoundingClientRect().width || 150; const relativeX = event.clientX - calendarRect.left - workerInfoWidth; const hourIndex = Math.floor(relativeX / hourWidth); if (hourIndex >= 0 && hourIndex < this.hours.length) { targetHour = this.hours[hourIndex]; } // Calculate time difference const hourDiff = targetHour - this.getEventHour(this.dragOriginalStartTime); const workerChanged = targetWorkerId !== this.dragOriginalWorkerId; // Update preview position const targetCell = this.findCellElement(targetWorkerId, targetHour); if (targetCell) { const cellRect = targetCell.getBoundingClientRect(); // Calculate preview dimensions const durationInHours = this.getEventDurationInHours(this.draggingEvent); const previewWidth = durationInHours * hourWidth; this.dragPreview = { visible: true, top: `${cellRect.top - calendarRect.top}px`, left: `${cellRect.left - calendarRect.left}px`, width: `${previewWidth}px`, height: `${cellRect.height}px`, workerId: targetWorkerId, hour: targetHour }; // Update the event times for the preview but don't commit yet const newStartTime = new Date(this.dragOriginalStartTime); newStartTime.setHours(newStartTime.getHours() + hourDiff); const newEndTime = new Date(this.dragOriginalEndTime); newEndTime.setHours(newEndTime.getHours() + hourDiff); // Prepare updated worker IDs, preserving other assignments let updatedWorkerIds: number[]; if (workerChanged) { if (this.dragOriginalWorkerId !== null && this.draggingEvent.workerIds.includes(this.dragOriginalWorkerId)) { // Replace the original worker with the new one updatedWorkerIds = this.draggingEvent.workerIds.map(id => id === this.dragOriginalWorkerId ? targetWorkerId : id ); } else { // Add the new worker if not already included updatedWorkerIds = this.draggingEvent.workerIds.includes(targetWorkerId) ? [...this.draggingEvent.workerIds] : [...this.draggingEvent.workerIds, targetWorkerId]; } } else { // No worker change, keep original worker IDs updatedWorkerIds = [...this.draggingEvent.workerIds]; } // Store the potential new position this.draggingEvent = { ...this.draggingEvent, startTime: newStartTime, endTime: newEndTime, workerIds: updatedWorkerIds }; } } } else if (this.viewMode === 'week') { // Similar logic for week view... // For brevity, I'm focusing on the day view implementation } } // Add drag end handler handleDragEnd(): void { if (this.isDragging && this.draggingEvent) { // Emit the updated event this.eventUpdate.emit(this.draggingEvent); } // Reset drag state this.isDragging = false; this.draggingEvent = null; this.dragOriginalStartTime = null; this.dragOriginalEndTime = null; this.dragOriginalWorkerId = null; // Hide preview this.dragPreview.visible = false; // Remove dragging class from body this.renderer.removeClass(document.body, 'dragging'); } // Helper function to find a cell element by worker ID and hour findCellElement(workerId: number, hour: number): HTMLElement | null { const workerIndex = this.workers.findIndex(w => w.id === workerId); const hourIndex = this.hours.indexOf(hour); if (workerIndex === -1 || hourIndex === -1) { return null; } const rows = this.elementRef.nativeElement.querySelectorAll('.worker-row'); if (workerIndex < rows.length) { const cells = rows[workerIndex].querySelectorAll('.time-cell'); if (hourIndex < cells.length) { return cells[hourIndex] as HTMLElement; } } return null; } // Helper to get hour from date getEventHour(date: Date): number { return date.getHours(); } // Helper to get event duration in hours getEventDurationInHours(event: CalendarEvent): number { const startTime = new Date(event.startTime); const endTime = new Date(event.endTime); return (endTime.getTime() - startTime.getTime()) / (1000 * 60 * 60); } // Update the resize methods for both directions handleResizeMove(event: MouseEvent): void { if (!this.isResizing || !this.resizingEvent || !this.resizeDirection || !this.resizeOriginalStartTime || !this.resizeOriginalEndTime) { return; } // We only support right-side resizing (end time) now if (this.resizeDirection !== 'right') { return; } const deltaX = event.clientX - this.resizeStartX; const hourChange = Math.round(deltaX / this.cellWidth); // Resize end time const newEndTime = new Date(this.resizeOriginalEndTime); newEndTime.setHours(newEndTime.getHours() + hourChange); // Ensure end time is not before start time plus 15 minutes const minEndTime = new Date(this.resizingEvent.startTime); minEndTime.setMinutes(minEndTime.getMinutes() + 15); if (newEndTime > minEndTime) { this.resizingEvent.endTime = newEndTime; } else { this.resizingEvent.endTime = minEndTime; } // Force update to show visual changes during resize this.ngZone.run(() => {}); } handleResizeEnd(): void { if (this.isResizing && this.resizingEvent) { // Update the actual events array to reflect the change this.updateEventInArray(this.resizingEvent.id, this.resizingEvent); // Emit the updated event this.eventUpdate.emit(this.resizingEvent); } // Reset resize state this.isResizing = false; this.resizingEvent = null; this.resizeDirection = null; this.resizeOriginalStartTime = null; this.resizeOriginalEndTime = null; // Remove resizing class from body this.renderer.removeClass(document.body, 'resizing'); } // Day View Methods getEventsForWorkerAndHour(workerId: number, hour: number): CalendarEvent[] { if (this.viewMode !== 'day') return []; const startHour = new Date(this.currentDate); startHour.setHours(hour, 0, 0, 0); const endHour = new Date(this.currentDate); endHour.setHours(hour + 1, 0, 0, 0); return this.events.filter(event => { const eventDate = new Date(event.date); const eventStart = new Date(event.startTime); const eventEnd = new Date(event.endTime); // Check if event date matches current date const sameDate = this.isSameDay(eventDate, this.currentDate); // Check if event involves this worker const isWorkerInvolved = event.workerIds.includes(workerId); // Check if event overlaps with this hour const overlapWithHour = (eventStart < endHour && eventEnd > startHour); return sameDate && isWorkerInvolved && overlapWithHour; }); } getEventPosition(event: CalendarEvent, workerId: number): EventPosition | null { if (!event.startTime || !event.endTime) return null; const eventStart = new Date(event.startTime); const eventEnd = new Date(event.endTime); // If the event doesn't involve this worker, return null if (!event.workerIds.includes(workerId)) return null; // Calculate the starting hour and duration in hours const startHour = eventStart.getHours(); const endHour = eventEnd.getHours() + (eventEnd.getMinutes() > 0 ? 1 : 0); const duration = endHour - startHour; // Calculate how many cells the event spans const displayHours = this.hours; const firstHourIndex = displayHours.indexOf(startHour); const lastHourIndex = displayHours.indexOf(endHour - 1); // If the event doesn't fall within our display hours, return null if (firstHourIndex === -1) return null; const spanningCells = (lastHourIndex !== -1 ? lastHourIndex - firstHourIndex + 1 : duration > 0 ? duration : 1); return { startHour, duration, spanningCells }; } shouldRenderEvent(event: CalendarEvent, workerId: number, hour: number): boolean { const position = this.getEventPosition(event, workerId); if (!position) return false; return position.startHour === hour; } getEventStyle(event: CalendarEvent, workerId: number): { [key: string]: string } { const position = this.getEventPosition(event, workerId); if (!position) return {}; // If this is the resizing event, use its current values if (this.isResizing && this.resizingEvent && this.resizingEvent.id === event.id) { const resizingPosition = this.getEventPosition(this.resizingEvent, workerId); if (resizingPosition) { return { width: `calc(${resizingPosition.spanningCells * 100}% - 8px)`, 'background-color': event.color || '#4285f4', 'z-index': '5' // Higher z-index during resize }; } } return { width: `calc(${position.spanningCells * 100}% - 8px)`, 'background-color': event.color || '#4285f4' }; } handleDayCellClick(hour: number, workerId: number): void { const startTime = new Date(this.currentDate); startTime.setHours(hour, 0, 0, 0); const endTime = new Date(this.currentDate); endTime.setHours(hour + 1, 0, 0, 0); this.eventAdd.emit({ startTime, endTime, workerIds: [workerId], date: new Date(this.currentDate) }); } // Week View Methods getEventsForHourAndDay(hour: number, date: Date): CalendarEvent[] { const startHour = new Date(date); startHour.setHours(hour, 0, 0, 0); const endHour = new Date(date); endHour.setHours(hour + 1, 0, 0, 0); return this.events.filter(event => { const eventDate = new Date(event.date); const eventStart = new Date(event.startTime); const eventEnd = new Date(event.endTime); // Check if event date matches this day const sameDate = this.isSameDay(eventDate, date); // Check if event overlaps with this hour const overlapWithHour = (eventStart < endHour && eventEnd > startHour); return sameDate && overlapWithHour; }); } getWeekEventPosition(event: CalendarEvent, date: Date): EventPosition | null { if (!event.startTime || !event.endTime) return null; const eventDate = new Date(event.date); const eventStart = new Date(event.startTime); const eventEnd = new Date(event.endTime); // If the event isn't on this day, return null if (!this.isSameDay(eventDate, date)) return null; // Calculate the starting hour and duration in hours const startHour = eventStart.getHours(); const endHour = eventEnd.getHours() + (eventEnd.getMinutes() > 0 ? 1 : 0); const duration = endHour - startHour; // Calculate how many cells the event spans const displayHours = this.hours; const firstHourIndex = displayHours.indexOf(startHour); const lastHourIndex = displayHours.indexOf(endHour - 1); // If the event doesn't fall within our display hours, return null if (firstHourIndex === -1) return null; const spanningCells = (lastHourIndex !== -1 ? lastHourIndex - firstHourIndex + 1 : duration > 0 ? duration : 1); return { startHour, duration, spanningCells }; } shouldRenderWeekEvent(event: CalendarEvent, date: Date, hour: number): boolean { const position = this.getWeekEventPosition(event, date); if (!position) return false; return position.startHour === hour; } getWeekEventStyle(event: CalendarEvent, date: Date): { [key: string]: string } { const position = this.getWeekEventPosition(event, date); if (!position) return {}; // If this is the resizing event, use its current values if (this.isResizing && this.resizingEvent && this.resizingEvent.id === event.id) { const resizingPosition = this.getWeekEventPosition(this.resizingEvent, date); if (resizingPosition) { return { width: 'calc(100% - 8px)', height: `${resizingPosition.duration * 50}px`, // Use the height of hour cells 'background-color': event.color || '#4285f4', 'z-index': '5' }; } } return { width: 'calc(100% - 8px)', height: `${position.duration * 50}px`, // Use the height of hour cells 'background-color': event.color || '#4285f4', 'z-index': '2' }; } handleWeekCellClick(hour: number, date: Date): void { const startTime = new Date(date); startTime.setHours(hour, 0, 0, 0); const endTime = new Date(date); endTime.setHours(hour + 1, 0, 0, 0); this.eventAdd.emit({ startTime, endTime, workerIds: [], date: new Date(date) }); } // Month View Methods getEventsForDay(date: Date): CalendarEvent[] { return this.events.filter(event => { const eventDate = new Date(event.date); return this.isSameDay(eventDate, date); }); } getDisplayedEvents(date: Date, limit: number = 3): {events: CalendarEvent[], overflow: number} { const dayEvents = this.getEventsForDay(date); const displayEvents = dayEvents.slice(0, limit); const overflow = Math.max(0, dayEvents.length - limit); return { events: displayEvents, overflow: overflow }; } handleMonthDayClick(date: Date): void { // Set current date to the clicked date and switch to day view this.currentDate = new Date(date); this.viewMode = 'day'; this.updateView(); } formatMonthEventTime(event: CalendarEvent): string { const startHour = new Date(event.startTime).getHours(); const startMinute = new Date(event.startTime).getMinutes(); return `${startHour}:${startMinute.toString().padStart(2, '0')}`; } // Add Angular Material CDK Drag Drop handlers handleDragStarted(event: CdkDragStart, calendarEvent: CalendarEvent): void { // Set active drag event this.activeDragEvent = {...calendarEvent}; // Add a class to the body to indicate dragging is in progress this.renderer.addClass(document.body, 'dragging'); } handleDragEnded(event: CdkDragEnd): void { // Hide drop preview this.dropPreview.visible = false; // Reset active drag event this.activeDragEvent = null; // Remove dragging class from body this.renderer.removeClass(document.body, 'dragging'); } handleDrop(event: CdkDragDrop<{workerId: number, hour: number}>): void { if (!event.container.data || !event.item.data) return; const droppedEvent = event.item.data as CalendarEvent; const targetWorkerId = event.container.data.workerId; const targetHour = event.container.data.hour; // Calculate time difference const originalStartHour = new Date(droppedEvent.startTime).getHours(); const hourDiff = targetHour - originalStartHour; // Create updated event with preserved worker IDs let updatedWorkerIds: number[]; // If the event is already assigned to this worker, keep the original worker IDs if (droppedEvent.workerIds.includes(targetWorkerId)) { updatedWorkerIds = [...droppedEvent.workerIds]; } else { // Check the source container to see if it was a drag between workers const sourceWorkerId = this.findSourceWorkerId(event); if (sourceWorkerId !== null && droppedEvent.workerIds.includes(sourceWorkerId)) { // If it's a drag between workers, replace the source worker ID with the target worker ID updatedWorkerIds = droppedEvent.workerIds.map(id => id === sourceWorkerId ? targetWorkerId : id ); } else { // If it's a new assignment, add the target worker ID to the list updatedWorkerIds = droppedEvent.workerIds.includes(targetWorkerId) ? [...droppedEvent.workerIds] : [...droppedEvent.workerIds, targetWorkerId]; } } const updatedEvent: CalendarEvent = { ...droppedEvent, workerIds: updatedWorkerIds }; // Update times if (hourDiff !== 0) { const newStartTime = new Date(droppedEvent.startTime); newStartTime.setHours(newStartTime.getHours() + hourDiff); updatedEvent.startTime = newStartTime; const newEndTime = new Date(droppedEvent.endTime); newEndTime.setHours(newEndTime.getHours() + hourDiff); updatedEvent.endTime = newEndTime; } // Update the actual events array to reflect the change this.updateEventInArray(droppedEvent.id, updatedEvent); // Emit event update this.eventUpdate.emit(updatedEvent); // Hide drop preview this.dropPreview.visible = false; } // Helper method to find the source worker ID in a drag operation findSourceWorkerId(event: CdkDragDrop<any>): number | null { if (!event.previousContainer.data || typeof event.previousContainer.data.workerId !== 'number') { return null; } return event.previousContainer.data.workerId; } // Similar update for week view handleWeekDrop(event: CdkDragDrop<{date: Date, hour: number}>): void { if (!event.container.data || !event.item.data) return; const droppedEvent = event.item.data as CalendarEvent; const targetDate = event.container.data.date; const targetHour = event.container.data.hour; // Calculate time difference const originalStartHour = new Date(droppedEvent.startTime).getHours(); const hourDiff = targetHour - originalStartHour; // Check if date is different const originalDate = new Date(droppedEvent.date); const dateDiff = targetDate.getTime() - originalDate.getTime(); // Create updated event - preserve worker IDs const updatedEvent: CalendarEvent = { ...droppedEvent, date: new Date(targetDate) }; // Update times based on date and hour difference if (dateDiff !== 0 || hourDiff !== 0) { const newStartTime = new Date(droppedEvent.startTime); // If date changed, adjust the day if (dateDiff !== 0) { newStartTime.setFullYear( targetDate.getFullYear(), targetDate.getMonth(), targetDate.getDate() ); } // If hour changed, adjust the hour if (hourDiff !== 0) { newStartTime.setHours(newStartTime.getHours() + hourDiff); } updatedEvent.startTime = newStartTime; const newEndTime = new Date(droppedEvent.endTime); // If date changed, adjust the day if (dateDiff !== 0) { newEndTime.setFullYear( targetDate.getFullYear(), targetDate.getMonth(), targetDate.getDate() ); } // If hour changed, adjust the hour if (hourDiff !== 0) { newEndTime.setHours(newEndTime.getHours() + hourDiff); } updatedEvent.endTime = newEndTime; } // Update the actual events array to reflect the change this.updateEventInArray(droppedEvent.id, updatedEvent); // Emit event update this.eventUpdate.emit(updatedEvent); // Hide drop preview this.dropPreview.visible = false; } // Update the actual events array with the new event data updateEventInArray(eventId: number, updatedEvent: CalendarEvent): void { const index = this.events.findIndex(e => e.id === eventId); if (index !== -1) { this.events[index] = updatedEvent; } } // Show drop preview when dragging over a cell handleDragEnter(event: CdkDragEnter<{workerId: number, hour: number}>): void { if (!this.activeDragEvent || !event.container.data) return; const targetData = event.container.data; const eventDuration = this.getEventDurationInHours(this.activeDragEvent); this.dropPreview = { visible: true, width: `calc(${eventDuration * 100}% - 8px)`, height: '100%', workerId: targetData.workerId, hour: targetData.hour, title: this.activeDragEvent.title, color: this.activeDragEvent.color ? `${this.activeDragEvent.color}80` : 'rgba(66, 133, 244, 0.5)' // Add 50% transparency }; } handleWeekDragEnter(event: CdkDragEnter<{date: Date, hour: number}>): void { if (!this.activeDragEvent || !event.container.data) return; const targetData = event.container.data; const eventDuration = this.getEventDurationInHours(this.activeDragEvent); this.dropPreview = { visible: true, width: '100%', height: `calc(${eventDuration * 70}px - 8px)`, date: targetData.date, hour: targetData.hour, title: this.activeDragEvent.title, color: this.activeDragEvent.color ? `${this.activeDragEvent.color}80` : 'rgba(66, 133, 244, 0.5)' // Add 50% transparency }; } // Add event remove handler with worker-specific removal handleEventRemove(event: CalendarEvent, mouseEvent: MouseEvent): void { // Stop propagation to prevent other click handlers mouseEvent.stopPropagation(); // Get the clicked element to determine which worker's view this was clicked from const clickedElement = mouseEvent.currentTarget as HTMLElement; const workerRow = clickedElement.closest('.worker-row'); const dayColumn = clickedElement.closest('.day-column'); let workerId: number | null = null; // Determine which worker's view was clicked based on context if (workerRow) { // Day view const workerIndex = Array.from(this.elementRef.nativeElement.querySelectorAll('.worker-row')).indexOf(workerRow); if (workerIndex !== -1 && this.workers[workerIndex]) { workerId = this.workers[workerIndex].id; } } else if (dayColumn && this.viewMode === 'week') { // Week view - since we don't have worker columns in week view, this might show a dialog // to select which worker to remove from the event if (event.workerIds.length > 1) { const confirmMessage = this.language === 'fr' ? `Cet événement est assigné à plusieurs employés. Entrez l'ID de l'employé à retirer de cet événement:\n${ event.workerIds.map(id => { const worker = this.workers.find(w => w.id === id); return `${id}: ${worker ? worker.name : 'Inconnu'}`; }).join('\n') }` : `This event is assigned to multiple workers. Enter the ID of the worker to remove from this event:\n${ event.workerIds.map(id => { const worker = this.workers.find(w => w.id === id); return `${id}: ${worker ? worker.name : 'Unknown'}`; }).join('\n') }`; const workerToRemove = prompt(confirmMessage); if (workerToRemove !== null) { workerId = parseInt(workerToRemove, 10); if (isNaN(workerId) || !event.workerIds.includes(workerId)) { const errorMessage = this.language === 'fr' ? 'ID d\'employé invalide. Aucune modification effectuée.' : 'Invalid worker ID. No changes made.'; alert(errorMessage); return; } } else { return; // Cancelled } } else if (event.workerIds.length === 1) { workerId = event.workerIds[0]; } } else if (this.viewMode === 'month') { // Month view - show worker selection dialog if (event.workerIds.length > 1) { const confirmMessage = this.language === 'fr' ? `Cet événement est assigné à plusieurs employés. Entrez l'ID de l'employé à retirer de cet événement:\n${ event.workerIds.map(id => { const worker = this.workers.find(w => w.id === id); return `${id}: ${worker ? worker.name : 'Inconnu'}`; }).join('\n') }` : `This event is assigned to multiple workers. Enter the ID of the worker to remove from this event:\n${ event.workerIds.map(id => { const worker = this.workers.find(w => w.id === id); return `${id}: ${worker ? worker.name : 'Unknown'}`; }).join('\n') }`; const workerToRemove = prompt(confirmMessage); if (workerToRemove !== null) { workerId = parseInt(workerToRemove, 10); if (isNaN(workerId) || !event.workerIds.includes(workerId)) { const errorMessage = this.language === 'fr' ? 'ID d\'employé invalide. Aucune modification effectuée.' : 'Invalid worker ID. No changes made.'; alert(errorMessage); return; } } else { return; // Cancelled } } else if (event.workerIds.length === 1) { workerId = event.workerIds[0]; } } if (workerId !== null) { // If we have a worker ID, ask for confirmation const worker = this.workers.find(w => w.id === workerId); const workerName = worker ? worker.name : `ID: ${workerId}`; const removeConfirmMessage = this.language === 'fr' ? `Êtes-vous sûr de vouloir retirer ${workerName} de cet événement: "${event.title}"?` : `Are you sure you want to remove ${workerName} from this event: "${event.title}"?`; if (confirm(removeConfirmMessage)) { // If this is the only worker, ask if they want to delete the event entirely if (event.workerIds.length === 1) { const deleteConfirmMessage = this.language === 'fr' ? `C'est le seul employé assigné à cet événement. Supprimer l'événement entier?` : `This is the only worker assigned to this event. Delete the entire event?`; if (confirm(deleteConfirmMessage)) { // Remove the event entirely const index = this.events.findIndex(e => e.id === event.id); if (index !== -1) { this.events.splice(index, 1); } // Emit the full event removal this.eventRemove.emit(event); } } else { // Remove just this worker from the event const updatedEvent = { ...event, workerIds: event.workerIds.filter(id => id !== workerId) }; // Update the event in the array this.updateEventInArray(event.id, updatedEvent); // Emit the updated event this.eventUpdate.emit(updatedEvent); } } } else { // If we couldn't determine a worker ID, fall back to removing the entire event const confirmMessage = this.language === 'fr' ? `Êtes-vous sûr de vouloir supprimer cet événement: "${event.title}"?` : `Are you sure you want to remove this event: "${event.title}"?`; if (confirm(confirmMessage)) { // Remove from local events array const index = this.events.findIndex(e => e.id === event.id); if (index !== -1) { this.events.splice(index, 1); } // Emit event to notify parent component this.eventRemove.emit(event); } } } }