import { IEventBus, ICalendarEvent } from '../../types/CalendarTypes'; import { IGridConfig } from '../../core/IGridConfig'; import { CoreEvents } from '../../constants/CoreEvents'; import { HeaderDrawerManager } from '../../core/HeaderDrawerManager'; import { EventService } from '../../storage/events/EventService'; import { DateService } from '../../core/DateService'; import { IDragEnterHeaderPayload, IDragMoveHeaderPayload, IDragLeaveHeaderPayload, IDragEndPayload } from '../../types/DragTypes'; /** * Layout information for a header item */ interface IHeaderItemLayout { event: ICalendarEvent; row: number; // 1-indexed colStart: number; // 1-indexed colEnd: number; // exclusive } /** * HeaderDrawerRenderer - Handles rendering of items in the header drawer * * Listens to drag events from DragDropManager and creates/manages * swp-header-item elements in the header drawer. * * Uses subgrid for column alignment with parent swp-calendar-header. * Position items via gridArea for explicit row/column placement. */ export class HeaderDrawerRenderer { private currentItem: HTMLElement | null = null; private container: HTMLElement | null = null; private sourceElement: HTMLElement | null = null; private wasExpandedBeforeDrag = false; constructor( private eventBus: IEventBus, private gridConfig: IGridConfig, private headerDrawerManager: HeaderDrawerManager, private eventService: EventService, private dateService: DateService ) { this.setupListeners(); } /** * Render allDay events into the header drawer with row stacking */ async render(container: HTMLElement, filter: Record): Promise { const drawer = container.querySelector('swp-header-drawer'); if (!drawer) return; const visibleDates = filter['date'] || []; if (visibleDates.length === 0) return; // Fetch events for date range const startDate = new Date(visibleDates[0]); const endDate = new Date(visibleDates[visibleDates.length - 1]); endDate.setHours(23, 59, 59, 999); const events = await this.eventService.getByDateRange(startDate, endDate); // Filter to allDay events only (allDay !== false) const allDayEvents = events.filter(event => event.allDay !== false); // Clear existing items drawer.innerHTML = ''; if (allDayEvents.length === 0) return; // Calculate layout with row stacking const layouts = this.calculateLayout(allDayEvents, visibleDates); const rowCount = Math.max(1, ...layouts.map(l => l.row)); // Render each item with layout layouts.forEach(layout => { const item = this.createHeaderItem(layout); drawer.appendChild(item); }); // Expand drawer to fit all rows this.headerDrawerManager.expandToRows(rowCount); } /** * Create a header item element from layout */ private createHeaderItem(layout: IHeaderItemLayout): HTMLElement { const { event, row, colStart, colEnd } = layout; const item = document.createElement('swp-header-item'); item.dataset.eventId = event.id; item.dataset.itemType = 'event'; item.dataset.start = event.start.toISOString(); item.dataset.end = event.end.toISOString(); item.textContent = event.title; // Color class const colorClass = this.getColorClass(event); if (colorClass) item.classList.add(colorClass); // Grid position from layout item.style.gridArea = `${row} / ${colStart} / ${row + 1} / ${colEnd}`; return item; } /** * Calculate layout for all events with row stacking * Uses track-based algorithm to find available rows for overlapping events */ private calculateLayout(events: ICalendarEvent[], visibleDates: string[]): IHeaderItemLayout[] { // tracks[row][col] = occupied const tracks: boolean[][] = [new Array(visibleDates.length).fill(false)]; const layouts: IHeaderItemLayout[] = []; for (const event of events) { const startCol = this.getColIndex(event.start, visibleDates); const endCol = this.getColIndex(event.end, visibleDates); if (startCol === -1 && endCol === -1) continue; // Clamp til synlige kolonner const colStart = Math.max(0, startCol); const colEnd = (endCol !== -1 ? endCol : visibleDates.length - 1) + 1; // Find ledig række const row = this.findAvailableRow(tracks, colStart, colEnd); // Marker som optaget for (let c = colStart; c < colEnd; c++) { tracks[row][c] = true; } layouts.push({ event, row: row + 1, colStart: colStart + 1, colEnd: colEnd + 1 }); } return layouts; } /** * Find available row for event spanning columns [colStart, colEnd) */ private findAvailableRow(tracks: boolean[][], colStart: number, colEnd: number): number { for (let row = 0; row < tracks.length; row++) { let available = true; for (let c = colStart; c < colEnd; c++) { if (tracks[row][c]) { available = false; break; } } if (available) return row; } // Ny række tracks.push(new Array(tracks[0].length).fill(false)); return tracks.length - 1; } /** * Get column index for a date (0-indexed, -1 if not found) */ private getColIndex(date: Date, visibleDates: string[]): number { const dateKey = this.dateService.getDateKey(date); return visibleDates.indexOf(dateKey); } /** * Get color class based on event metadata or type */ private getColorClass(event: ICalendarEvent): string { if (event.metadata?.color) { return `is-${event.metadata.color}`; } const typeColors: Record = { 'customer': 'is-blue', 'vacation': 'is-green', 'break': 'is-amber', 'meeting': 'is-purple', 'blocked': 'is-red' }; return typeColors[event.type] || 'is-blue'; } /** * Setup event listeners for drag events */ private setupListeners(): void { this.eventBus.on(CoreEvents.EVENT_DRAG_ENTER_HEADER, (e) => { const payload = (e as CustomEvent).detail; this.handleDragEnter(payload); }); this.eventBus.on(CoreEvents.EVENT_DRAG_MOVE_HEADER, (e) => { const payload = (e as CustomEvent).detail; this.handleDragMove(payload); }); this.eventBus.on(CoreEvents.EVENT_DRAG_LEAVE_HEADER, (e) => { const payload = (e as CustomEvent).detail; this.handleDragLeave(payload); }); this.eventBus.on(CoreEvents.EVENT_DRAG_END, (e) => { const payload = (e as CustomEvent).detail; this.handleDragEnd(payload); }); this.eventBus.on(CoreEvents.EVENT_DRAG_CANCEL, () => { this.cleanup(); }); } /** * Handle drag entering header zone - create preview item */ private handleDragEnter(payload: IDragEnterHeaderPayload): void { this.container = document.querySelector('swp-header-drawer'); if (!this.container) return; // Remember if drawer was already expanded this.wasExpandedBeforeDrag = this.headerDrawerManager.isExpanded(); // Expand to at least 1 row if collapsed, otherwise keep current height if (!this.wasExpandedBeforeDrag) { this.headerDrawerManager.expandToRows(1); } // Store reference to source element this.sourceElement = payload.element; // Create header item const item = document.createElement('swp-header-item'); item.dataset.eventId = payload.eventId; item.dataset.itemType = payload.itemType; item.dataset.duration = String(payload.duration); item.textContent = payload.title; // Set start/end as ISO dates (for recalculateDrawerLayout) const startDate = new Date(payload.sourceDate); const endDate = new Date(payload.sourceDate); endDate.setDate(endDate.getDate() + payload.duration - 1); item.dataset.start = startDate.toISOString(); item.dataset.end = endDate.toISOString(); // Apply color class if present if (payload.colorClass) { item.classList.add(payload.colorClass); } // Add dragging state item.classList.add('dragging'); // Initial placement (duration determines column span) // gridArea format: "row / col-start / row+1 / col-end" const col = payload.sourceColumnIndex + 1; const endCol = col + payload.duration; item.style.gridArea = `1 / ${col} / 2 / ${endCol}`; this.container.appendChild(item); this.currentItem = item; // Hide original element while in header payload.element.style.visibility = 'hidden'; } /** * Handle drag moving within header - update column position */ private handleDragMove(payload: IDragMoveHeaderPayload): void { if (!this.currentItem) return; // Update column position const col = payload.columnIndex + 1; const duration = parseInt(this.currentItem.dataset.duration || '1', 10); const endCol = col + duration; this.currentItem.style.gridArea = `1 / ${col} / 2 / ${endCol}`; // Update start/end dates based on new position const startDate = new Date(payload.dateKey); const endDate = new Date(payload.dateKey); endDate.setDate(endDate.getDate() + duration - 1); this.currentItem.dataset.start = startDate.toISOString(); this.currentItem.dataset.end = endDate.toISOString(); } /** * Handle drag leaving header - remove preview and restore source */ private handleDragLeave(_payload: IDragLeaveHeaderPayload): void { this.cleanup(); } /** * Handle drag end - finalize based on drop target */ private handleDragEnd(payload: IDragEndPayload): void { if (payload.target === 'header') { // Grid→Header: Finalize the header item (it stays in header) if (this.currentItem) { this.currentItem.classList.remove('dragging'); this.recalculateDrawerLayout(); this.currentItem = null; this.sourceElement = null; } } else { // Header→Grid: Remove ghost header item and recalculate const ghost = document.querySelector(`swp-header-item.drag-ghost[data-event-id="${payload.swpEvent.eventId}"]`); ghost?.remove(); this.recalculateDrawerLayout(); } } /** * Recalculate layout for all items currently in the drawer * Called after drop to reposition items and adjust height */ private recalculateDrawerLayout(): void { const drawer = document.querySelector('swp-header-drawer'); if (!drawer) return; const items = Array.from(drawer.querySelectorAll('swp-header-item')) as HTMLElement[]; if (items.length === 0) return; // Get visible dates from existing items const visibleDates = this.getVisibleDatesFromDOM(); if (visibleDates.length === 0) return; // Build layout data from DOM items const itemData = items.map(item => ({ element: item, start: new Date(item.dataset.start || ''), end: new Date(item.dataset.end || '') })); // Calculate new layout using track algorithm const tracks: boolean[][] = [new Array(visibleDates.length).fill(false)]; for (const item of itemData) { const startCol = this.getColIndex(item.start, visibleDates); const endCol = this.getColIndex(item.end, visibleDates); const colStart = Math.max(0, startCol); const colEnd = (endCol !== -1 ? endCol : visibleDates.length - 1) + 1; const row = this.findAvailableRow(tracks, colStart, colEnd); for (let c = colStart; c < colEnd; c++) { tracks[row][c] = true; } // Update element position item.element.style.gridArea = `${row + 1} / ${colStart + 1} / ${row + 2} / ${colEnd + 1}`; } // Update drawer height const rowCount = tracks.length; this.headerDrawerManager.expandToRows(rowCount); } /** * Get visible dates from header columns in DOM */ private getVisibleDatesFromDOM(): string[] { const columns = document.querySelectorAll('swp-day-column'); const dates: string[] = []; columns.forEach(col => { const date = (col as HTMLElement).dataset.date; if (date && !dates.includes(date)) dates.push(date); }); return dates; } /** * Cleanup preview item and restore source visibility */ private cleanup(): void { // Remove preview item this.currentItem?.remove(); this.currentItem = null; // Restore source element visibility if (this.sourceElement) { this.sourceElement.style.visibility = ''; this.sourceElement = null; } // Collapse drawer if it wasn't expanded before drag if (!this.wasExpandedBeforeDrag) { this.headerDrawerManager.collapse(); } } }