2025-12-11 23:04:48 +01:00
|
|
|
import { IEventBus, ICalendarEvent } from '../../types/CalendarTypes';
|
2025-12-10 23:11:11 +01:00
|
|
|
import { IGridConfig } from '../../core/IGridConfig';
|
|
|
|
|
import { CoreEvents } from '../../constants/CoreEvents';
|
2025-12-10 23:31:07 +01:00
|
|
|
import { HeaderDrawerManager } from '../../core/HeaderDrawerManager';
|
2025-12-11 23:04:48 +01:00
|
|
|
import { EventService } from '../../storage/events/EventService';
|
|
|
|
|
import { DateService } from '../../core/DateService';
|
2025-12-10 23:11:11 +01:00
|
|
|
import {
|
|
|
|
|
IDragEnterHeaderPayload,
|
|
|
|
|
IDragMoveHeaderPayload,
|
|
|
|
|
IDragLeaveHeaderPayload
|
|
|
|
|
} from '../../types/DragTypes';
|
|
|
|
|
|
2025-12-11 23:43:51 +01:00
|
|
|
/**
|
|
|
|
|
* Layout information for a header item
|
|
|
|
|
*/
|
|
|
|
|
interface IHeaderItemLayout {
|
|
|
|
|
event: ICalendarEvent;
|
|
|
|
|
row: number; // 1-indexed
|
|
|
|
|
colStart: number; // 1-indexed
|
|
|
|
|
colEnd: number; // exclusive
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-10 23:11:11 +01:00
|
|
|
/**
|
|
|
|
|
* 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;
|
2025-12-10 23:31:07 +01:00
|
|
|
private wasExpandedBeforeDrag = false;
|
2025-12-10 23:11:11 +01:00
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
|
private eventBus: IEventBus,
|
2025-12-10 23:31:07 +01:00
|
|
|
private gridConfig: IGridConfig,
|
2025-12-11 23:04:48 +01:00
|
|
|
private headerDrawerManager: HeaderDrawerManager,
|
|
|
|
|
private eventService: EventService,
|
|
|
|
|
private dateService: DateService
|
2025-12-10 23:11:11 +01:00
|
|
|
) {
|
|
|
|
|
this.setupListeners();
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-11 23:04:48 +01:00
|
|
|
/**
|
2025-12-11 23:43:51 +01:00
|
|
|
* Render allDay events into the header drawer with row stacking
|
2025-12-11 23:04:48 +01:00
|
|
|
*/
|
|
|
|
|
async render(container: HTMLElement, filter: Record<string, string[]>): Promise<void> {
|
|
|
|
|
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 = '';
|
|
|
|
|
|
2025-12-11 23:43:51 +01:00
|
|
|
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);
|
2025-12-11 23:04:48 +01:00
|
|
|
});
|
|
|
|
|
|
2025-12-11 23:43:51 +01:00
|
|
|
// Expand drawer to fit all rows
|
|
|
|
|
this.headerDrawerManager.expandToRows(rowCount);
|
2025-12-11 23:04:48 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-12-11 23:43:51 +01:00
|
|
|
* Create a header item element from layout
|
2025-12-11 23:04:48 +01:00
|
|
|
*/
|
2025-12-11 23:43:51 +01:00
|
|
|
private createHeaderItem(layout: IHeaderItemLayout): HTMLElement {
|
|
|
|
|
const { event, row, colStart, colEnd } = layout;
|
2025-12-11 23:04:48 +01:00
|
|
|
|
|
|
|
|
const item = document.createElement('swp-header-item');
|
|
|
|
|
item.dataset.eventId = event.id;
|
|
|
|
|
item.dataset.itemType = 'event';
|
2025-12-11 23:29:53 +01:00
|
|
|
item.dataset.start = event.start.toISOString();
|
|
|
|
|
item.dataset.end = event.end.toISOString();
|
2025-12-11 23:04:48 +01:00
|
|
|
item.textContent = event.title;
|
|
|
|
|
|
|
|
|
|
// Color class
|
|
|
|
|
const colorClass = this.getColorClass(event);
|
|
|
|
|
if (colorClass) item.classList.add(colorClass);
|
|
|
|
|
|
2025-12-11 23:43:51 +01:00
|
|
|
// Grid position from layout
|
|
|
|
|
item.style.gridArea = `${row} / ${colStart} / ${row + 1} / ${colEnd}`;
|
2025-12-11 23:04:48 +01:00
|
|
|
|
|
|
|
|
return item;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-11 23:43:51 +01:00
|
|
|
/**
|
|
|
|
|
* 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);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-11 23:04:48 +01:00
|
|
|
/**
|
|
|
|
|
* 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<string, string> = {
|
|
|
|
|
'customer': 'is-blue',
|
|
|
|
|
'vacation': 'is-green',
|
|
|
|
|
'break': 'is-amber',
|
|
|
|
|
'meeting': 'is-purple',
|
|
|
|
|
'blocked': 'is-red'
|
|
|
|
|
};
|
|
|
|
|
return typeColors[event.type] || 'is-blue';
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-10 23:11:11 +01:00
|
|
|
/**
|
|
|
|
|
* Setup event listeners for drag events
|
|
|
|
|
*/
|
|
|
|
|
private setupListeners(): void {
|
|
|
|
|
this.eventBus.on(CoreEvents.EVENT_DRAG_ENTER_HEADER, (e) => {
|
|
|
|
|
const payload = (e as CustomEvent<IDragEnterHeaderPayload>).detail;
|
|
|
|
|
this.handleDragEnter(payload);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.eventBus.on(CoreEvents.EVENT_DRAG_MOVE_HEADER, (e) => {
|
|
|
|
|
const payload = (e as CustomEvent<IDragMoveHeaderPayload>).detail;
|
|
|
|
|
this.handleDragMove(payload);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.eventBus.on(CoreEvents.EVENT_DRAG_LEAVE_HEADER, (e) => {
|
|
|
|
|
const payload = (e as CustomEvent<IDragLeaveHeaderPayload>).detail;
|
|
|
|
|
this.handleDragLeave(payload);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.eventBus.on(CoreEvents.EVENT_DRAG_END, () => {
|
|
|
|
|
this.handleDragEnd();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
2025-12-10 23:31:07 +01:00
|
|
|
// Remember if drawer was already expanded
|
|
|
|
|
this.wasExpandedBeforeDrag = this.headerDrawerManager.isExpanded();
|
|
|
|
|
|
|
|
|
|
// Expand drawer with animation
|
|
|
|
|
this.headerDrawerManager.expand();
|
|
|
|
|
|
2025-12-10 23:11:11 +01:00
|
|
|
// Store reference to source element
|
|
|
|
|
this.sourceElement = payload.element;
|
|
|
|
|
|
|
|
|
|
// Create header item
|
|
|
|
|
const item = document.createElement('swp-header-item');
|
2025-12-11 18:11:11 +01:00
|
|
|
item.dataset.eventId = payload.eventId;
|
2025-12-10 23:11:11 +01:00
|
|
|
item.dataset.itemType = payload.itemType;
|
|
|
|
|
item.dataset.date = payload.sourceDate;
|
|
|
|
|
item.dataset.duration = String(payload.duration);
|
|
|
|
|
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 (duration=1 for now)
|
|
|
|
|
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}`;
|
|
|
|
|
this.currentItem.dataset.date = payload.dateKey;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Handle drag leaving header - remove preview and restore source
|
|
|
|
|
*/
|
|
|
|
|
private handleDragLeave(_payload: IDragLeaveHeaderPayload): void {
|
|
|
|
|
this.cleanup();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Handle drag end - finalize the item (it stays in header)
|
2025-12-11 21:16:40 +01:00
|
|
|
* Note: EventRenderer handles removing the original element from the grid
|
|
|
|
|
* via EVENT_DRAG_END with target === 'header'
|
2025-12-10 23:11:11 +01:00
|
|
|
*/
|
|
|
|
|
private handleDragEnd(): void {
|
|
|
|
|
if (!this.currentItem) return;
|
|
|
|
|
|
|
|
|
|
// Remove dragging state
|
|
|
|
|
this.currentItem.classList.remove('dragging');
|
|
|
|
|
|
|
|
|
|
// TODO: Emit event to persist allDay=true change
|
|
|
|
|
|
2025-12-11 21:16:40 +01:00
|
|
|
// Clear references
|
2025-12-10 23:11:11 +01:00
|
|
|
this.currentItem = null;
|
|
|
|
|
this.sourceElement = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
2025-12-10 23:31:07 +01:00
|
|
|
|
|
|
|
|
// Collapse drawer if it wasn't expanded before drag
|
|
|
|
|
if (!this.wasExpandedBeforeDrag) {
|
|
|
|
|
this.headerDrawerManager.collapse();
|
|
|
|
|
}
|
2025-12-10 23:11:11 +01:00
|
|
|
}
|
|
|
|
|
}
|