Adds resource-based calendar view mode
Introduces new ResourceColumnDataSource and ResourceHeaderRenderer to support column rendering by resources instead of dates Enables dynamic calendar mode switching between date and resource views Updates core managers and services to support async column retrieval Refactors data source interfaces to use Promise-based methods Improves calendar flexibility and resource management capabilities
This commit is contained in:
parent
a7d365b186
commit
eeaeddeef8
19 changed files with 765 additions and 991 deletions
|
|
@ -30,7 +30,7 @@ export class DateColumnDataSource implements IColumnDataSource {
|
|||
/**
|
||||
* Get columns (dates) to display
|
||||
*/
|
||||
public getColumns(): IColumnInfo[] {
|
||||
public async getColumns(): Promise<IColumnInfo[]> {
|
||||
let dates: Date[];
|
||||
|
||||
switch (this.currentView) {
|
||||
|
|
|
|||
61
src/datasources/ResourceColumnDataSource.ts
Normal file
61
src/datasources/ResourceColumnDataSource.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { IColumnDataSource, IColumnInfo } from '../types/ColumnDataSource';
|
||||
import { CalendarView } from '../types/CalendarTypes';
|
||||
import { ResourceService } from '../storage/resources/ResourceService';
|
||||
|
||||
/**
|
||||
* ResourceColumnDataSource - Provides resource-based columns
|
||||
*
|
||||
* In resource mode, columns represent resources (people, rooms, etc.)
|
||||
* instead of dates. Events are still filtered by current date,
|
||||
* but grouped by resourceId.
|
||||
*/
|
||||
export class ResourceColumnDataSource implements IColumnDataSource {
|
||||
private resourceService: ResourceService;
|
||||
private currentDate: Date;
|
||||
private currentView: CalendarView;
|
||||
|
||||
constructor(resourceService: ResourceService) {
|
||||
this.resourceService = resourceService;
|
||||
this.currentDate = new Date();
|
||||
this.currentView = 'day';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get columns (resources) to display
|
||||
*/
|
||||
public async getColumns(): Promise<IColumnInfo[]> {
|
||||
const resources = await this.resourceService.getActive();
|
||||
return resources.map(resource => ({
|
||||
identifier: resource.id,
|
||||
data: resource
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get type of datasource
|
||||
*/
|
||||
public getType(): 'date' | 'resource' {
|
||||
return 'resource';
|
||||
}
|
||||
|
||||
/**
|
||||
* Update current date (for event filtering)
|
||||
*/
|
||||
public setCurrentDate(date: Date): void {
|
||||
this.currentDate = date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update current view
|
||||
*/
|
||||
public setCurrentView(view: CalendarView): void {
|
||||
this.currentView = view;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current date (for event filtering)
|
||||
*/
|
||||
public getCurrentDate(): Date {
|
||||
return this.currentDate;
|
||||
}
|
||||
}
|
||||
25
src/index.ts
25
src/index.ts
|
|
@ -70,6 +70,9 @@ import { EventStackManager } from './managers/EventStackManager';
|
|||
import { EventLayoutCoordinator } from './managers/EventLayoutCoordinator';
|
||||
import { IColumnDataSource } from './types/ColumnDataSource';
|
||||
import { DateColumnDataSource } from './datasources/DateColumnDataSource';
|
||||
import { ResourceColumnDataSource } from './datasources/ResourceColumnDataSource';
|
||||
import { ResourceHeaderRenderer } from './renderers/ResourceHeaderRenderer';
|
||||
import { ResourceColumnRenderer } from './renderers/ResourceColumnRenderer';
|
||||
import { IBooking } from './types/BookingTypes';
|
||||
import { ICustomer } from './types/CustomerTypes';
|
||||
import { IResource } from './types/ResourceTypes';
|
||||
|
|
@ -137,13 +140,25 @@ async function initializeCalendar(): Promise<void> {
|
|||
builder.registerType(MockResourceRepository).as<IApiRepository<IResource>>();
|
||||
builder.registerType(MockAuditRepository).as<IApiRepository<IAuditEntry>>();
|
||||
|
||||
builder.registerType(DateColumnDataSource).as<IColumnDataSource>();
|
||||
// Calendar mode: 'date' or 'resource' (default to resource)
|
||||
const calendarMode: 'date' | 'resource' = 'resource';
|
||||
|
||||
// Register DataSource and HeaderRenderer based on mode
|
||||
if (calendarMode === 'resource') {
|
||||
builder.registerType(ResourceColumnDataSource).as<IColumnDataSource>();
|
||||
builder.registerType(ResourceHeaderRenderer).as<IHeaderRenderer>();
|
||||
} else {
|
||||
builder.registerType(DateColumnDataSource).as<IColumnDataSource>();
|
||||
builder.registerType(DateHeaderRenderer).as<IHeaderRenderer>();
|
||||
}
|
||||
|
||||
// Register entity services (sync status management)
|
||||
// Open/Closed Principle: Adding new entity only requires adding one line here
|
||||
builder.registerType(EventService).as<IEntityService<ICalendarEvent>>();
|
||||
builder.registerType(BookingService).as<IEntityService<IBooking>>();
|
||||
builder.registerType(CustomerService).as<IEntityService<ICustomer>>();
|
||||
builder.registerType(ResourceService).as<IEntityService<IResource>>();
|
||||
builder.registerType(ResourceService).as<ResourceService>();
|
||||
builder.registerType(AuditService).as<AuditService>();
|
||||
|
||||
// Register workers
|
||||
|
|
@ -151,8 +166,12 @@ async function initializeCalendar(): Promise<void> {
|
|||
builder.registerType(DataSeeder).as<DataSeeder>();
|
||||
|
||||
// Register renderers
|
||||
builder.registerType(DateHeaderRenderer).as<IHeaderRenderer>();
|
||||
builder.registerType(DateColumnRenderer).as<IColumnRenderer>();
|
||||
// Note: IHeaderRenderer and IColumnRenderer are registered above based on calendarMode
|
||||
if (calendarMode === 'resource') {
|
||||
builder.registerType(ResourceColumnRenderer).as<IColumnRenderer>();
|
||||
} else {
|
||||
builder.registerType(DateColumnRenderer).as<IColumnRenderer>();
|
||||
}
|
||||
builder.registerType(DateEventRenderer).as<IEventRenderer>();
|
||||
|
||||
// Register core services and utilities
|
||||
|
|
|
|||
|
|
@ -89,7 +89,10 @@ export class GridManager {
|
|||
}
|
||||
|
||||
// Get columns from datasource - single source of truth
|
||||
const columns = this.dataSource.getColumns();
|
||||
const columns = await this.dataSource.getColumns();
|
||||
|
||||
// Set grid columns CSS variable based on actual column count
|
||||
document.documentElement.style.setProperty('--grid-columns', columns.length.toString());
|
||||
|
||||
// Extract dates for EventManager query
|
||||
const dates = columns.map(col => col.data as Date);
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ export class HeaderManager {
|
|||
/**
|
||||
* Update header content for navigation
|
||||
*/
|
||||
private updateHeader(currentDate: Date): void {
|
||||
private async updateHeader(currentDate: Date): Promise<void> {
|
||||
console.log('🎯 HeaderManager.updateHeader called', {
|
||||
currentDate,
|
||||
rendererType: this.headerRenderer.constructor.name
|
||||
|
|
@ -116,7 +116,7 @@ export class HeaderManager {
|
|||
|
||||
// Update DataSource with current date and get columns
|
||||
this.dataSource.setCurrentDate(currentDate);
|
||||
const columns = this.dataSource.getColumns();
|
||||
const columns = await this.dataSource.getColumns();
|
||||
|
||||
// Render new header content using injected renderer
|
||||
const context: IHeaderRenderContext = {
|
||||
|
|
|
|||
|
|
@ -173,7 +173,7 @@ export class NavigationManager {
|
|||
/**
|
||||
* Animation transition using pre-rendered containers when available
|
||||
*/
|
||||
private animateTransition(direction: 'prev' | 'next', targetWeek: Date): void {
|
||||
private async animateTransition(direction: 'prev' | 'next', targetWeek: Date): Promise<void> {
|
||||
|
||||
const container = document.querySelector('swp-calendar-container') as HTMLElement;
|
||||
const currentGrid = document.querySelector('swp-calendar-container swp-grid-container:not([data-prerendered])') as HTMLElement;
|
||||
|
|
@ -194,7 +194,7 @@ export class NavigationManager {
|
|||
|
||||
// Update DataSource with target week and get columns
|
||||
this.dataSource.setCurrentDate(targetWeek);
|
||||
const columns = this.dataSource.getColumns();
|
||||
const columns = await this.dataSource.getColumns();
|
||||
|
||||
// Always create a fresh container for consistent behavior
|
||||
newGrid = this.gridRenderer.createNavigationGrid(container, columns);
|
||||
|
|
|
|||
46
src/renderers/ResourceColumnRenderer.ts
Normal file
46
src/renderers/ResourceColumnRenderer.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { WorkHoursManager } from '../managers/WorkHoursManager';
|
||||
import { IColumnRenderer, IColumnRenderContext } from './ColumnRenderer';
|
||||
|
||||
/**
|
||||
* Resource-based column renderer
|
||||
*
|
||||
* In resource mode, columns represent resources (people, rooms, etc.)
|
||||
* Work hours are hardcoded (09:00-18:00) for all columns.
|
||||
* TODO: Each resource should have its own work hours.
|
||||
*/
|
||||
export class ResourceColumnRenderer implements IColumnRenderer {
|
||||
private workHoursManager: WorkHoursManager;
|
||||
|
||||
constructor(workHoursManager: WorkHoursManager) {
|
||||
this.workHoursManager = workHoursManager;
|
||||
}
|
||||
|
||||
render(columnContainer: HTMLElement, context: IColumnRenderContext): void {
|
||||
const { columns } = context;
|
||||
|
||||
// Hardcoded work hours for all resources: 09:00 - 18:00
|
||||
const workHours = { start: 9, end: 18 };
|
||||
|
||||
columns.forEach((columnInfo) => {
|
||||
const column = document.createElement('swp-day-column');
|
||||
|
||||
column.dataset.columnId = columnInfo.identifier;
|
||||
|
||||
// Apply hardcoded work hours to all resource columns
|
||||
this.applyWorkHoursToColumn(column, workHours);
|
||||
|
||||
const eventsLayer = document.createElement('swp-events-layer');
|
||||
column.appendChild(eventsLayer);
|
||||
|
||||
columnContainer.appendChild(column);
|
||||
});
|
||||
}
|
||||
|
||||
private applyWorkHoursToColumn(column: HTMLElement, workHours: { start: number; end: number }): void {
|
||||
const nonWorkStyle = this.workHoursManager.calculateNonWorkHoursStyle(workHours);
|
||||
if (nonWorkStyle) {
|
||||
column.style.setProperty('--before-work-height', `${nonWorkStyle.beforeWorkHeight}px`);
|
||||
column.style.setProperty('--after-work-top', `${nonWorkStyle.afterWorkTop}px`);
|
||||
}
|
||||
}
|
||||
}
|
||||
58
src/renderers/ResourceHeaderRenderer.ts
Normal file
58
src/renderers/ResourceHeaderRenderer.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { IHeaderRenderer, IHeaderRenderContext } from './DateHeaderRenderer';
|
||||
import { IResource } from '../types/ResourceTypes';
|
||||
|
||||
/**
|
||||
* ResourceHeaderRenderer - Renders resource-based headers
|
||||
*
|
||||
* Displays resource information (avatar, name) instead of dates.
|
||||
* Used in resource mode where columns represent people/rooms/equipment.
|
||||
*/
|
||||
export class ResourceHeaderRenderer implements IHeaderRenderer {
|
||||
render(calendarHeader: HTMLElement, context: IHeaderRenderContext): void {
|
||||
const { columns } = context;
|
||||
|
||||
// Create all-day container (same structure as date mode)
|
||||
const allDayContainer = document.createElement('swp-allday-container');
|
||||
calendarHeader.appendChild(allDayContainer);
|
||||
|
||||
columns.forEach((columnInfo) => {
|
||||
const resource = columnInfo.data as IResource;
|
||||
const header = document.createElement('swp-day-header');
|
||||
|
||||
// Build header content
|
||||
let avatarHtml = '';
|
||||
if (resource.avatarUrl) {
|
||||
avatarHtml = `<img class="swp-resource-avatar" src="${resource.avatarUrl}" alt="${resource.displayName}" />`;
|
||||
} else {
|
||||
// Fallback: initials
|
||||
const initials = this.getInitials(resource.displayName);
|
||||
const bgColor = resource.color || '#6366f1';
|
||||
avatarHtml = `<span class="swp-resource-initials" style="background-color: ${bgColor}">${initials}</span>`;
|
||||
}
|
||||
|
||||
header.innerHTML = `
|
||||
<div class="swp-resource-header">
|
||||
${avatarHtml}
|
||||
<span class="swp-resource-name">${resource.displayName}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
header.dataset.columnId = columnInfo.identifier;
|
||||
header.dataset.resourceId = resource.id;
|
||||
|
||||
calendarHeader.appendChild(header);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get initials from display name
|
||||
*/
|
||||
private getInitials(name: string): string {
|
||||
return name
|
||||
.split(' ')
|
||||
.map(part => part.charAt(0))
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.substring(0, 2);
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ import { EntityType } from '../types/CalendarTypes';
|
|||
export class MockAuditRepository implements IApiRepository<IAuditEntry> {
|
||||
readonly entityType: EntityType = 'Audit';
|
||||
|
||||
async sendCreate(entity: IAuditEntry): Promise<void> {
|
||||
async sendCreate(entity: IAuditEntry): Promise<IAuditEntry> {
|
||||
// Simulate API call delay
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
|
|
@ -22,9 +22,11 @@ export class MockAuditRepository implements IApiRepository<IAuditEntry> {
|
|||
operation: entity.operation,
|
||||
timestamp: new Date(entity.timestamp).toISOString()
|
||||
});
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
async sendUpdate(_id: string, _entity: IAuditEntry): Promise<void> {
|
||||
async sendUpdate(_id: string, entity: IAuditEntry): Promise<IAuditEntry> {
|
||||
// Audit entries are immutable - updates should not happen
|
||||
throw new Error('Audit entries cannot be updated');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import { IStore } from './IStore';
|
|||
*/
|
||||
export class IndexedDBContext {
|
||||
private static readonly DB_NAME = 'CalendarDB';
|
||||
private static readonly DB_VERSION = 3; // Bumped for audit store
|
||||
private static readonly DB_VERSION = 5; // Bumped to add syncStatus index to resources
|
||||
static readonly QUEUE_STORE = 'operationQueue';
|
||||
static readonly SYNC_STATE_STORE = 'syncState';
|
||||
|
||||
|
|
|
|||
|
|
@ -31,68 +31,25 @@ export class ResourceService extends BaseEntityService<IResource> {
|
|||
|
||||
/**
|
||||
* Get resources by type
|
||||
*
|
||||
* @param type - Resource type (person, room, equipment, etc.)
|
||||
* @returns Array of resources of this type
|
||||
*/
|
||||
async getByType(type: string): Promise<IResource[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction([this.storeName], 'readonly');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const index = store.index('type');
|
||||
const request = index.getAll(type);
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result as IResource[]);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error(`Failed to get resources by type ${type}: ${request.error}`));
|
||||
};
|
||||
});
|
||||
const all = await this.getAll();
|
||||
return all.filter(r => r.type === type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active resources only
|
||||
*
|
||||
* @returns Array of active resources (isActive = true)
|
||||
*/
|
||||
async getActive(): Promise<IResource[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction([this.storeName], 'readonly');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const index = store.index('isActive');
|
||||
const request = index.getAll(IDBKeyRange.only(true));
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result as IResource[]);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error(`Failed to get active resources: ${request.error}`));
|
||||
};
|
||||
});
|
||||
const all = await this.getAll();
|
||||
return all.filter(r => r.isActive === true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get inactive resources
|
||||
*
|
||||
* @returns Array of inactive resources (isActive = false)
|
||||
*/
|
||||
async getInactive(): Promise<IResource[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction([this.storeName], 'readonly');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const index = store.index('isActive');
|
||||
const request = index.getAll(IDBKeyRange.only(false));
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result as IResource[]);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error(`Failed to get inactive resources: ${request.error}`));
|
||||
};
|
||||
});
|
||||
const all = await this.getAll();
|
||||
return all.filter(r => r.isActive === false);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,16 +20,7 @@ export class ResourceStore implements IStore {
|
|||
* @param db - IDBDatabase instance
|
||||
*/
|
||||
create(db: IDBDatabase): void {
|
||||
// Create ObjectStore with 'id' as keyPath
|
||||
const store = db.createObjectStore(ResourceStore.STORE_NAME, { keyPath: 'id' });
|
||||
|
||||
// Index: type (for filtering by resource category)
|
||||
store.createIndex('type', 'type', { unique: false });
|
||||
|
||||
// Index: isActive (for showing/hiding inactive resources)
|
||||
store.createIndex('isActive', 'isActive', { unique: false });
|
||||
|
||||
// Index: syncStatus (for querying by sync status - used by SyncPlugin)
|
||||
store.createIndex('syncStatus', 'syncStatus', { unique: false });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export interface IColumnDataSource {
|
|||
* Get the list of columns to render
|
||||
* @returns Array of column information
|
||||
*/
|
||||
getColumns(): IColumnInfo[];
|
||||
getColumns(): Promise<IColumnInfo[]>;
|
||||
|
||||
/**
|
||||
* Get the type of columns this datasource provides
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue