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 { FilterTemplate } from '../../core/FilterTemplate'; import { IDragEnterHeaderPayload, IDragMoveHeaderPayload, IDragLeaveHeaderPayload, IDragEndPayload } from '../../types/DragTypes'; /** * Layout information for a header item */ interface IHeaderItemLayout { event: ICalendarEvent; columnKey: string; // Opaque column identifier 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; private filterTemplate: FilterTemplate | null = null; 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 * @param filterTemplate - Template for matching events to columns */ async render(container: HTMLElement, filter: Record, filterTemplate: FilterTemplate): Promise { // Store filterTemplate for buildColumnKeyFromEvent this.filterTemplate = filterTemplate; const drawer = container.querySelector('swp-header-drawer'); if (!drawer) return; const visibleDates = filter['date'] || []; if (visibleDates.length === 0) return; // Get column keys from DOM for correct multi-resource positioning const visibleColumnKeys = this.getVisibleColumnKeysFromDOM(); if (visibleColumnKeys.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 using columnKeys const layouts = this.calculateLayout(allDayEvents, visibleColumnKeys); 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, columnKey, 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.dataset.columnKey = columnKey; 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[], visibleColumnKeys: string[]): IHeaderItemLayout[] { // tracks[row][col] = occupied const tracks: boolean[][] = [new Array(visibleColumnKeys.length).fill(false)]; const layouts: IHeaderItemLayout[] = []; for (const event of events) { // Build columnKey from event fields (only place we need to construct it) const columnKey = this.buildColumnKeyFromEvent(event); const startCol = visibleColumnKeys.indexOf(columnKey); const endColumnKey = this.buildColumnKeyFromEvent(event, event.end); const endCol = visibleColumnKeys.indexOf(endColumnKey); if (startCol === -1 && endCol === -1) continue; // Clamp til synlige kolonner const colStart = Math.max(0, startCol); const colEnd = (endCol !== -1 ? endCol : visibleColumnKeys.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, columnKey, row: row + 1, colStart: colStart + 1, colEnd: colEnd + 1 }); } return layouts; } /** * Build columnKey from event using FilterTemplate * Uses the same template that columns use for matching */ private buildColumnKeyFromEvent(event: ICalendarEvent, date?: Date): string { if (!this.filterTemplate) { // Fallback if no template - shouldn't happen in normal flow const dateStr = this.dateService.getDateKey(date || event.start); return dateStr; } // For multi-day events, we need to override the date in the event if (date && date.getTime() !== event.start.getTime()) { // Create temporary event with overridden start for key generation const tempEvent = { ...event, start: date }; return this.filterTemplate.buildKeyFromEvent(tempEvent); } return this.filterTemplate.buildKeyFromEvent(event); } /** * 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 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.dataset.columnKey = payload.sourceColumnKey; item.textContent = payload.title; // 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 columnKey to new position this.currentItem.dataset.columnKey = payload.columnKey; } /** * Handle drag leaving header - cleanup for grid→header drag only */ private handleDragLeave(payload: IDragLeaveHeaderPayload): void { // Only cleanup for grid→header drag (when grid event leaves header back to grid) // For header→grid drag, the header item stays as ghost until drop if (payload.source === 'grid') { this.cleanup(); } // For header source, do nothing - ghost stays until EVENT_DRAG_END } /** * 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 column keys for correct multi-resource positioning const visibleColumnKeys = this.getVisibleColumnKeysFromDOM(); if (visibleColumnKeys.length === 0) return; // Build layout data from DOM items - use columnKey directly (opaque matching) const itemData = items.map(item => ({ element: item, columnKey: item.dataset.columnKey || '', duration: parseInt(item.dataset.duration || '1', 10) })); // Calculate new layout using track algorithm const tracks: boolean[][] = [new Array(visibleColumnKeys.length).fill(false)]; for (const item of itemData) { // Direct columnKey matching - no parsing or construction needed const startCol = visibleColumnKeys.indexOf(item.columnKey); if (startCol === -1) continue; const colStart = startCol; const colEnd = Math.min(startCol + item.duration, visibleColumnKeys.length); 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 column keys from DOM (preserves order for multi-resource views) * Uses filterTemplate.buildKeyFromColumn() for consistent key format with events */ private getVisibleColumnKeysFromDOM(): string[] { if (!this.filterTemplate) return []; const columns = document.querySelectorAll('swp-day-column'); const columnKeys: string[] = []; columns.forEach(col => { const columnKey = this.filterTemplate!.buildKeyFromColumn(col as HTMLElement); if (columnKey) columnKeys.push(columnKey); }); return columnKeys; } /** * 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(); } } }