Enhance calendar view with resource-aware rendering
Adds support for filtering events and rendering across multiple views with resource-specific context Improves event and date rendering to handle resource-based filtering Introduces day view and extends existing calendar infrastructure to support more flexible view configurations
This commit is contained in:
parent
6fc9be9534
commit
7f6279a6f3
17 changed files with 570 additions and 38 deletions
|
|
@ -15,7 +15,7 @@ import { DemoApp } from './demo/DemoApp';
|
||||||
|
|
||||||
// Event system
|
// Event system
|
||||||
import { EventBus } from './core/EventBus';
|
import { EventBus } from './core/EventBus';
|
||||||
import { IEventBus, ICalendarEvent, ISync } from './types/CalendarTypes';
|
import { IEventBus, ICalendarEvent, ISync, IResource, IBooking, ICustomer } from './types/CalendarTypes';
|
||||||
|
|
||||||
// Storage
|
// Storage
|
||||||
import { IndexedDBContext } from './storage/IndexedDBContext';
|
import { IndexedDBContext } from './storage/IndexedDBContext';
|
||||||
|
|
@ -23,10 +23,19 @@ import { IStore } from './storage/IStore';
|
||||||
import { IEntityService } from './storage/IEntityService';
|
import { IEntityService } from './storage/IEntityService';
|
||||||
import { EventStore } from './storage/events/EventStore';
|
import { EventStore } from './storage/events/EventStore';
|
||||||
import { EventService } from './storage/events/EventService';
|
import { EventService } from './storage/events/EventService';
|
||||||
|
import { ResourceStore } from './storage/resources/ResourceStore';
|
||||||
|
import { ResourceService } from './storage/resources/ResourceService';
|
||||||
|
import { BookingStore } from './storage/bookings/BookingStore';
|
||||||
|
import { BookingService } from './storage/bookings/BookingService';
|
||||||
|
import { CustomerStore } from './storage/customers/CustomerStore';
|
||||||
|
import { CustomerService } from './storage/customers/CustomerService';
|
||||||
|
|
||||||
// Repositories
|
// Repositories
|
||||||
import { IApiRepository } from './repositories/IApiRepository';
|
import { IApiRepository } from './repositories/IApiRepository';
|
||||||
import { MockEventRepository } from './repositories/MockEventRepository';
|
import { MockEventRepository } from './repositories/MockEventRepository';
|
||||||
|
import { MockResourceRepository } from './repositories/MockResourceRepository';
|
||||||
|
import { MockBookingRepository } from './repositories/MockBookingRepository';
|
||||||
|
import { MockCustomerRepository } from './repositories/MockCustomerRepository';
|
||||||
|
|
||||||
// Workers
|
// Workers
|
||||||
import { DataSeeder } from './workers/DataSeeder';
|
import { DataSeeder } from './workers/DataSeeder';
|
||||||
|
|
@ -58,17 +67,43 @@ export function createV2Container(): Container {
|
||||||
|
|
||||||
// Storage infrastructure
|
// Storage infrastructure
|
||||||
builder.registerType(IndexedDBContext).as<IndexedDBContext>();
|
builder.registerType(IndexedDBContext).as<IndexedDBContext>();
|
||||||
builder.registerType(EventStore).as<IStore>();
|
|
||||||
|
|
||||||
// Entity services
|
// Stores (for IndexedDB schema creation)
|
||||||
|
builder.registerType(EventStore).as<IStore>();
|
||||||
|
builder.registerType(ResourceStore).as<IStore>();
|
||||||
|
builder.registerType(BookingStore).as<IStore>();
|
||||||
|
builder.registerType(CustomerStore).as<IStore>();
|
||||||
|
|
||||||
|
// Entity services (for DataSeeder polymorphic array)
|
||||||
builder.registerType(EventService).as<IEntityService<ICalendarEvent>>();
|
builder.registerType(EventService).as<IEntityService<ICalendarEvent>>();
|
||||||
builder.registerType(EventService).as<IEntityService<ISync>>();
|
builder.registerType(EventService).as<IEntityService<ISync>>();
|
||||||
builder.registerType(EventService).as<EventService>();
|
builder.registerType(EventService).as<EventService>();
|
||||||
|
|
||||||
// Repositories
|
builder.registerType(ResourceService).as<IEntityService<IResource>>();
|
||||||
|
builder.registerType(ResourceService).as<IEntityService<ISync>>();
|
||||||
|
builder.registerType(ResourceService).as<ResourceService>();
|
||||||
|
|
||||||
|
builder.registerType(BookingService).as<IEntityService<IBooking>>();
|
||||||
|
builder.registerType(BookingService).as<IEntityService<ISync>>();
|
||||||
|
builder.registerType(BookingService).as<BookingService>();
|
||||||
|
|
||||||
|
builder.registerType(CustomerService).as<IEntityService<ICustomer>>();
|
||||||
|
builder.registerType(CustomerService).as<IEntityService<ISync>>();
|
||||||
|
builder.registerType(CustomerService).as<CustomerService>();
|
||||||
|
|
||||||
|
// Repositories (for DataSeeder polymorphic array)
|
||||||
builder.registerType(MockEventRepository).as<IApiRepository<ICalendarEvent>>();
|
builder.registerType(MockEventRepository).as<IApiRepository<ICalendarEvent>>();
|
||||||
builder.registerType(MockEventRepository).as<IApiRepository<ISync>>();
|
builder.registerType(MockEventRepository).as<IApiRepository<ISync>>();
|
||||||
|
|
||||||
|
builder.registerType(MockResourceRepository).as<IApiRepository<IResource>>();
|
||||||
|
builder.registerType(MockResourceRepository).as<IApiRepository<ISync>>();
|
||||||
|
|
||||||
|
builder.registerType(MockBookingRepository).as<IApiRepository<IBooking>>();
|
||||||
|
builder.registerType(MockBookingRepository).as<IApiRepository<ISync>>();
|
||||||
|
|
||||||
|
builder.registerType(MockCustomerRepository).as<IApiRepository<ICustomer>>();
|
||||||
|
builder.registerType(MockCustomerRepository).as<IApiRepository<ISync>>();
|
||||||
|
|
||||||
// Workers
|
// Workers
|
||||||
builder.registerType(DataSeeder).as<DataSeeder>();
|
builder.registerType(DataSeeder).as<DataSeeder>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,9 +43,8 @@ export class CalendarOrchestrator {
|
||||||
const pipeline = buildPipeline(activeRenderers);
|
const pipeline = buildPipeline(activeRenderers);
|
||||||
pipeline.run(context);
|
pipeline.run(context);
|
||||||
|
|
||||||
// Events
|
// Render events med hele filter (date + resource)
|
||||||
const dates = filter['date'] || [];
|
await this.eventRenderer.render(container, filter);
|
||||||
await this.eventRenderer.render(container, dates);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private selectRenderers(viewConfig: ViewConfig): Renderer[] {
|
private selectRenderers(viewConfig: ViewConfig): Renderer[] {
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ export class DemoApp {
|
||||||
private animator!: NavigationAnimator;
|
private animator!: NavigationAnimator;
|
||||||
private container!: HTMLElement;
|
private container!: HTMLElement;
|
||||||
private weekOffset = 0;
|
private weekOffset = 0;
|
||||||
private currentView: 'simple' | 'resource' | 'team' = 'simple';
|
private currentView: 'day' | 'simple' | 'resource' | 'team' = 'simple';
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private orchestrator: CalendarOrchestrator,
|
private orchestrator: CalendarOrchestrator,
|
||||||
|
|
@ -66,8 +66,18 @@ export class DemoApp {
|
||||||
|
|
||||||
private buildViewConfig(): ViewConfig {
|
private buildViewConfig(): ViewConfig {
|
||||||
const dates = this.dateService.getWeekDates(this.weekOffset, 3);
|
const dates = this.dateService.getWeekDates(this.weekOffset, 3);
|
||||||
|
const today = this.dateService.getWeekDates(this.weekOffset, 1);
|
||||||
|
|
||||||
switch (this.currentView) {
|
switch (this.currentView) {
|
||||||
|
case 'day':
|
||||||
|
return {
|
||||||
|
templateId: 'day',
|
||||||
|
groupings: [
|
||||||
|
{ type: 'resource', values: ['res1', 'res2'] },
|
||||||
|
{ type: 'date', values: today }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
case 'simple':
|
case 'simple':
|
||||||
return {
|
return {
|
||||||
templateId: 'simple',
|
templateId: 'simple',
|
||||||
|
|
@ -110,6 +120,11 @@ export class DemoApp {
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupViewSwitching(): void {
|
private setupViewSwitching(): void {
|
||||||
|
document.getElementById('btn-day')?.addEventListener('click', () => {
|
||||||
|
this.currentView = 'day';
|
||||||
|
this.render();
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('btn-simple')?.addEventListener('click', () => {
|
document.getElementById('btn-simple')?.addEventListener('click', () => {
|
||||||
this.currentView = 'simple';
|
this.currentView = 'simple';
|
||||||
this.render();
|
this.render();
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,23 @@ export class DateRenderer implements Renderer {
|
||||||
|
|
||||||
render(context: RenderContext): void {
|
render(context: RenderContext): void {
|
||||||
const dates = context.filter['date'] || [];
|
const dates = context.filter['date'] || [];
|
||||||
const resourceCount = context.filter['resource']?.length || 1;
|
const resourceIds = context.filter['resource'] || [];
|
||||||
|
|
||||||
|
// Render dates for HVER resource (eller 1 gang hvis ingen resources)
|
||||||
|
const iterations = resourceIds.length || 1;
|
||||||
|
|
||||||
|
for (let r = 0; r < iterations; r++) {
|
||||||
|
const resourceId = resourceIds[r]; // undefined hvis ingen resources
|
||||||
|
|
||||||
// Render dates for HVER resource (resourceCount gange)
|
|
||||||
for (let r = 0; r < resourceCount; r++) {
|
|
||||||
for (const dateStr of dates) {
|
for (const dateStr of dates) {
|
||||||
const date = this.dateService.parseISO(dateStr);
|
const date = this.dateService.parseISO(dateStr);
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
const header = document.createElement('swp-day-header');
|
const header = document.createElement('swp-day-header');
|
||||||
header.dataset.date = dateStr;
|
header.dataset.date = dateStr;
|
||||||
|
if (resourceId) {
|
||||||
|
header.dataset.resourceId = resourceId;
|
||||||
|
}
|
||||||
header.innerHTML = `
|
header.innerHTML = `
|
||||||
<swp-day-name>${this.dateService.getDayName(date, 'short')}</swp-day-name>
|
<swp-day-name>${this.dateService.getDayName(date, 'short')}</swp-day-name>
|
||||||
<swp-day-date>${date.getDate()}</swp-day-date>
|
<swp-day-date>${date.getDate()}</swp-day-date>
|
||||||
|
|
@ -27,6 +34,9 @@ export class DateRenderer implements Renderer {
|
||||||
// Column
|
// Column
|
||||||
const column = document.createElement('swp-day-column');
|
const column = document.createElement('swp-day-column');
|
||||||
column.dataset.date = dateStr;
|
column.dataset.date = dateStr;
|
||||||
|
if (resourceId) {
|
||||||
|
column.dataset.resourceId = resourceId;
|
||||||
|
}
|
||||||
column.innerHTML = '<swp-events-layer></swp-events-layer>';
|
column.innerHTML = '<swp-events-layer></swp-events-layer>';
|
||||||
context.columnContainer.appendChild(column);
|
context.columnContainer.appendChild(column);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,14 @@ export class EventRenderer {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render events for visible dates into day columns
|
* Render events for visible dates into day columns
|
||||||
|
* @param container - Calendar container element
|
||||||
|
* @param filter - Filter with 'date' and optionally 'resource' arrays
|
||||||
*/
|
*/
|
||||||
async render(container: HTMLElement, visibleDates: string[]): Promise<void> {
|
async render(container: HTMLElement, filter: Record<string, string[]>): Promise<void> {
|
||||||
|
const visibleDates = filter['date'] || [];
|
||||||
|
|
||||||
|
if (visibleDates.length === 0) return;
|
||||||
|
|
||||||
// Get date range for query
|
// Get date range for query
|
||||||
const startDate = new Date(visibleDates[0]);
|
const startDate = new Date(visibleDates[0]);
|
||||||
const endDate = new Date(visibleDates[visibleDates.length - 1]);
|
const endDate = new Date(visibleDates[visibleDates.length - 1]);
|
||||||
|
|
@ -31,19 +37,32 @@ export class EventRenderer {
|
||||||
// Fetch events from IndexedDB
|
// Fetch events from IndexedDB
|
||||||
const events = await this.eventService.getByDateRange(startDate, endDate);
|
const events = await this.eventService.getByDateRange(startDate, endDate);
|
||||||
|
|
||||||
// Group events by date
|
|
||||||
const eventsByDate = this.groupEventsByDate(events);
|
|
||||||
|
|
||||||
// Find day columns
|
// Find day columns
|
||||||
const dayColumns = container.querySelector('swp-day-columns');
|
const dayColumns = container.querySelector('swp-day-columns');
|
||||||
if (!dayColumns) return;
|
if (!dayColumns) return;
|
||||||
|
|
||||||
const columns = dayColumns.querySelectorAll('swp-day-column');
|
const columns = dayColumns.querySelectorAll('swp-day-column');
|
||||||
|
|
||||||
// Render events into columns
|
// Render events into each column based on data attributes
|
||||||
columns.forEach((column, index) => {
|
columns.forEach(column => {
|
||||||
const dateKey = visibleDates[index];
|
const dateKey = (column as HTMLElement).dataset.date;
|
||||||
const dateEvents = eventsByDate.get(dateKey) || [];
|
const columnResourceId = (column as HTMLElement).dataset.resourceId;
|
||||||
|
|
||||||
|
if (!dateKey) return;
|
||||||
|
|
||||||
|
// Filter events for this column
|
||||||
|
const columnEvents = events.filter(event => {
|
||||||
|
// Must match date
|
||||||
|
if (getDateKey(event.start) !== dateKey) return false;
|
||||||
|
|
||||||
|
// If column has resourceId, event must match
|
||||||
|
if (columnResourceId && event.resourceId !== columnResourceId) return false;
|
||||||
|
|
||||||
|
// If no resourceId on column but resources in filter, show all
|
||||||
|
// (this handles 'simple' view without resources)
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
// Get or create events layer
|
// Get or create events layer
|
||||||
let eventsLayer = column.querySelector('swp-events-layer');
|
let eventsLayer = column.querySelector('swp-events-layer');
|
||||||
|
|
@ -55,8 +74,8 @@ export class EventRenderer {
|
||||||
// Clear existing events
|
// Clear existing events
|
||||||
eventsLayer.innerHTML = '';
|
eventsLayer.innerHTML = '';
|
||||||
|
|
||||||
// Render each event
|
// Render each timed event
|
||||||
dateEvents.forEach(event => {
|
columnEvents.forEach(event => {
|
||||||
if (!event.allDay) {
|
if (!event.allDay) {
|
||||||
const eventElement = this.createEventElement(event);
|
const eventElement = this.createEventElement(event);
|
||||||
eventsLayer!.appendChild(eventElement);
|
eventsLayer!.appendChild(eventElement);
|
||||||
|
|
@ -65,22 +84,6 @@ export class EventRenderer {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Group events by their date key
|
|
||||||
*/
|
|
||||||
private groupEventsByDate(events: ICalendarEvent[]): Map<string, ICalendarEvent[]> {
|
|
||||||
const map = new Map<string, ICalendarEvent[]>();
|
|
||||||
|
|
||||||
events.forEach(event => {
|
|
||||||
const dateKey = getDateKey(event.start);
|
|
||||||
const existing = map.get(dateKey) || [];
|
|
||||||
existing.push(event);
|
|
||||||
map.set(dateKey, existing);
|
|
||||||
});
|
|
||||||
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a single event element
|
* Create a single event element
|
||||||
*
|
*
|
||||||
|
|
|
||||||
73
src/v2/repositories/MockBookingRepository.ts
Normal file
73
src/v2/repositories/MockBookingRepository.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { IBooking, IBookingService, BookingStatus, EntityType } from '../types/CalendarTypes';
|
||||||
|
import { IApiRepository } from './IApiRepository';
|
||||||
|
|
||||||
|
interface RawBookingData {
|
||||||
|
id: string;
|
||||||
|
customerId: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: string | Date;
|
||||||
|
services: RawBookingService[];
|
||||||
|
totalPrice?: number;
|
||||||
|
tags?: string[];
|
||||||
|
notes?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawBookingService {
|
||||||
|
serviceId: string;
|
||||||
|
serviceName: string;
|
||||||
|
baseDuration: number;
|
||||||
|
basePrice: number;
|
||||||
|
customPrice?: number;
|
||||||
|
resourceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MockBookingRepository - Loads booking data from local JSON file
|
||||||
|
*/
|
||||||
|
export class MockBookingRepository implements IApiRepository<IBooking> {
|
||||||
|
public readonly entityType: EntityType = 'Booking';
|
||||||
|
private readonly dataUrl = 'data/mock-bookings.json';
|
||||||
|
|
||||||
|
public async fetchAll(): Promise<IBooking[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.dataUrl);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load mock bookings: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawData: RawBookingData[] = await response.json();
|
||||||
|
return this.processBookingData(rawData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load booking data:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendCreate(_booking: IBooking): Promise<IBooking> {
|
||||||
|
throw new Error('MockBookingRepository does not support sendCreate. Mock data is read-only.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendUpdate(_id: string, _updates: Partial<IBooking>): Promise<IBooking> {
|
||||||
|
throw new Error('MockBookingRepository does not support sendUpdate. Mock data is read-only.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendDelete(_id: string): Promise<void> {
|
||||||
|
throw new Error('MockBookingRepository does not support sendDelete. Mock data is read-only.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private processBookingData(data: RawBookingData[]): IBooking[] {
|
||||||
|
return data.map((booking): IBooking => ({
|
||||||
|
id: booking.id,
|
||||||
|
customerId: booking.customerId,
|
||||||
|
status: booking.status as BookingStatus,
|
||||||
|
createdAt: new Date(booking.createdAt),
|
||||||
|
services: booking.services as IBookingService[],
|
||||||
|
totalPrice: booking.totalPrice,
|
||||||
|
tags: booking.tags,
|
||||||
|
notes: booking.notes,
|
||||||
|
syncStatus: 'synced' as const
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/v2/repositories/MockCustomerRepository.ts
Normal file
58
src/v2/repositories/MockCustomerRepository.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { ICustomer, EntityType } from '../types/CalendarTypes';
|
||||||
|
import { IApiRepository } from './IApiRepository';
|
||||||
|
|
||||||
|
interface RawCustomerData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
email?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MockCustomerRepository - Loads customer data from local JSON file
|
||||||
|
*/
|
||||||
|
export class MockCustomerRepository implements IApiRepository<ICustomer> {
|
||||||
|
public readonly entityType: EntityType = 'Customer';
|
||||||
|
private readonly dataUrl = 'data/mock-customers.json';
|
||||||
|
|
||||||
|
public async fetchAll(): Promise<ICustomer[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.dataUrl);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load mock customers: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawData: RawCustomerData[] = await response.json();
|
||||||
|
return this.processCustomerData(rawData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load customer data:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendCreate(_customer: ICustomer): Promise<ICustomer> {
|
||||||
|
throw new Error('MockCustomerRepository does not support sendCreate. Mock data is read-only.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendUpdate(_id: string, _updates: Partial<ICustomer>): Promise<ICustomer> {
|
||||||
|
throw new Error('MockCustomerRepository does not support sendUpdate. Mock data is read-only.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendDelete(_id: string): Promise<void> {
|
||||||
|
throw new Error('MockCustomerRepository does not support sendDelete. Mock data is read-only.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private processCustomerData(data: RawCustomerData[]): ICustomer[] {
|
||||||
|
return data.map((customer): ICustomer => ({
|
||||||
|
id: customer.id,
|
||||||
|
name: customer.name,
|
||||||
|
phone: customer.phone,
|
||||||
|
email: customer.email,
|
||||||
|
metadata: customer.metadata,
|
||||||
|
syncStatus: 'synced' as const
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/v2/repositories/MockResourceRepository.ts
Normal file
64
src/v2/repositories/MockResourceRepository.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { IResource, ResourceType, EntityType } from '../types/CalendarTypes';
|
||||||
|
import { IApiRepository } from './IApiRepository';
|
||||||
|
|
||||||
|
interface RawResourceData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
type: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
color?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MockResourceRepository - Loads resource data from local JSON file
|
||||||
|
*/
|
||||||
|
export class MockResourceRepository implements IApiRepository<IResource> {
|
||||||
|
public readonly entityType: EntityType = 'Resource';
|
||||||
|
private readonly dataUrl = 'data/mock-resources.json';
|
||||||
|
|
||||||
|
public async fetchAll(): Promise<IResource[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.dataUrl);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load mock resources: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawData: RawResourceData[] = await response.json();
|
||||||
|
return this.processResourceData(rawData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load resource data:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendCreate(_resource: IResource): Promise<IResource> {
|
||||||
|
throw new Error('MockResourceRepository does not support sendCreate. Mock data is read-only.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendUpdate(_id: string, _updates: Partial<IResource>): Promise<IResource> {
|
||||||
|
throw new Error('MockResourceRepository does not support sendUpdate. Mock data is read-only.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendDelete(_id: string): Promise<void> {
|
||||||
|
throw new Error('MockResourceRepository does not support sendDelete. Mock data is read-only.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private processResourceData(data: RawResourceData[]): IResource[] {
|
||||||
|
return data.map((resource): IResource => ({
|
||||||
|
id: resource.id,
|
||||||
|
name: resource.name,
|
||||||
|
displayName: resource.displayName,
|
||||||
|
type: resource.type as ResourceType,
|
||||||
|
avatarUrl: resource.avatarUrl,
|
||||||
|
color: resource.color,
|
||||||
|
isActive: resource.isActive,
|
||||||
|
metadata: resource.metadata,
|
||||||
|
syncStatus: 'synced' as const
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,7 +10,7 @@ import { IStore } from './IStore';
|
||||||
*/
|
*/
|
||||||
export class IndexedDBContext {
|
export class IndexedDBContext {
|
||||||
private static readonly DB_NAME = 'CalendarV2DB';
|
private static readonly DB_NAME = 'CalendarV2DB';
|
||||||
private static readonly DB_VERSION = 1;
|
private static readonly DB_VERSION = 2;
|
||||||
|
|
||||||
private db: IDBDatabase | null = null;
|
private db: IDBDatabase | null = null;
|
||||||
private initialized: boolean = false;
|
private initialized: boolean = false;
|
||||||
|
|
|
||||||
75
src/v2/storage/bookings/BookingService.ts
Normal file
75
src/v2/storage/bookings/BookingService.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { IBooking, EntityType, IEventBus, BookingStatus } from '../../types/CalendarTypes';
|
||||||
|
import { BookingStore } from './BookingStore';
|
||||||
|
import { BaseEntityService } from '../BaseEntityService';
|
||||||
|
import { IndexedDBContext } from '../IndexedDBContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BookingService - CRUD operations for bookings in IndexedDB
|
||||||
|
*/
|
||||||
|
export class BookingService extends BaseEntityService<IBooking> {
|
||||||
|
readonly storeName = BookingStore.STORE_NAME;
|
||||||
|
readonly entityType: EntityType = 'Booking';
|
||||||
|
|
||||||
|
constructor(context: IndexedDBContext, eventBus: IEventBus) {
|
||||||
|
super(context, eventBus);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected serialize(booking: IBooking): unknown {
|
||||||
|
return {
|
||||||
|
...booking,
|
||||||
|
createdAt: booking.createdAt.toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected deserialize(data: unknown): IBooking {
|
||||||
|
const raw = data as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
...raw,
|
||||||
|
createdAt: new Date(raw.createdAt as string)
|
||||||
|
} as IBooking;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get bookings for a customer
|
||||||
|
*/
|
||||||
|
async getByCustomer(customerId: string): Promise<IBooking[]> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction([this.storeName], 'readonly');
|
||||||
|
const store = transaction.objectStore(this.storeName);
|
||||||
|
const index = store.index('customerId');
|
||||||
|
const request = index.getAll(customerId);
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const data = request.result as unknown[];
|
||||||
|
const bookings = data.map(item => this.deserialize(item));
|
||||||
|
resolve(bookings);
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onerror = () => {
|
||||||
|
reject(new Error(`Failed to get bookings for customer ${customerId}: ${request.error}`));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get bookings by status
|
||||||
|
*/
|
||||||
|
async getByStatus(status: BookingStatus): Promise<IBooking[]> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction([this.storeName], 'readonly');
|
||||||
|
const store = transaction.objectStore(this.storeName);
|
||||||
|
const index = store.index('status');
|
||||||
|
const request = index.getAll(status);
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const data = request.result as unknown[];
|
||||||
|
const bookings = data.map(item => this.deserialize(item));
|
||||||
|
resolve(bookings);
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onerror = () => {
|
||||||
|
reject(new Error(`Failed to get bookings with status ${status}: ${request.error}`));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/v2/storage/bookings/BookingStore.ts
Normal file
18
src/v2/storage/bookings/BookingStore.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { IStore } from '../IStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BookingStore - IndexedDB ObjectStore definition for bookings
|
||||||
|
*/
|
||||||
|
export class BookingStore implements IStore {
|
||||||
|
static readonly STORE_NAME = 'bookings';
|
||||||
|
readonly storeName = BookingStore.STORE_NAME;
|
||||||
|
|
||||||
|
create(db: IDBDatabase): void {
|
||||||
|
const store = db.createObjectStore(BookingStore.STORE_NAME, { keyPath: 'id' });
|
||||||
|
|
||||||
|
store.createIndex('customerId', 'customerId', { unique: false });
|
||||||
|
store.createIndex('status', 'status', { unique: false });
|
||||||
|
store.createIndex('syncStatus', 'syncStatus', { unique: false });
|
||||||
|
store.createIndex('createdAt', 'createdAt', { unique: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/v2/storage/customers/CustomerService.ts
Normal file
46
src/v2/storage/customers/CustomerService.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { ICustomer, EntityType, IEventBus } from '../../types/CalendarTypes';
|
||||||
|
import { CustomerStore } from './CustomerStore';
|
||||||
|
import { BaseEntityService } from '../BaseEntityService';
|
||||||
|
import { IndexedDBContext } from '../IndexedDBContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CustomerService - CRUD operations for customers in IndexedDB
|
||||||
|
*/
|
||||||
|
export class CustomerService extends BaseEntityService<ICustomer> {
|
||||||
|
readonly storeName = CustomerStore.STORE_NAME;
|
||||||
|
readonly entityType: EntityType = 'Customer';
|
||||||
|
|
||||||
|
constructor(context: IndexedDBContext, eventBus: IEventBus) {
|
||||||
|
super(context, eventBus);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search customers by name (case-insensitive contains)
|
||||||
|
*/
|
||||||
|
async searchByName(query: string): Promise<ICustomer[]> {
|
||||||
|
const all = await this.getAll();
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
return all.filter(c => c.name.toLowerCase().includes(lowerQuery));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find customer by phone
|
||||||
|
*/
|
||||||
|
async getByPhone(phone: string): Promise<ICustomer | null> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction([this.storeName], 'readonly');
|
||||||
|
const store = transaction.objectStore(this.storeName);
|
||||||
|
const index = store.index('phone');
|
||||||
|
const request = index.get(phone);
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const data = request.result;
|
||||||
|
resolve(data ? (data as ICustomer) : null);
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onerror = () => {
|
||||||
|
reject(new Error(`Failed to find customer by phone ${phone}: ${request.error}`));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/v2/storage/customers/CustomerStore.ts
Normal file
17
src/v2/storage/customers/CustomerStore.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { IStore } from '../IStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CustomerStore - IndexedDB ObjectStore definition for customers
|
||||||
|
*/
|
||||||
|
export class CustomerStore implements IStore {
|
||||||
|
static readonly STORE_NAME = 'customers';
|
||||||
|
readonly storeName = CustomerStore.STORE_NAME;
|
||||||
|
|
||||||
|
create(db: IDBDatabase): void {
|
||||||
|
const store = db.createObjectStore(CustomerStore.STORE_NAME, { keyPath: 'id' });
|
||||||
|
|
||||||
|
store.createIndex('name', 'name', { unique: false });
|
||||||
|
store.createIndex('phone', 'phone', { unique: false });
|
||||||
|
store.createIndex('syncStatus', 'syncStatus', { unique: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/v2/storage/resources/ResourceService.ts
Normal file
45
src/v2/storage/resources/ResourceService.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { IResource, EntityType, IEventBus } from '../../types/CalendarTypes';
|
||||||
|
import { ResourceStore } from './ResourceStore';
|
||||||
|
import { BaseEntityService } from '../BaseEntityService';
|
||||||
|
import { IndexedDBContext } from '../IndexedDBContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ResourceService - CRUD operations for resources in IndexedDB
|
||||||
|
*/
|
||||||
|
export class ResourceService extends BaseEntityService<IResource> {
|
||||||
|
readonly storeName = ResourceStore.STORE_NAME;
|
||||||
|
readonly entityType: EntityType = 'Resource';
|
||||||
|
|
||||||
|
constructor(context: IndexedDBContext, eventBus: IEventBus) {
|
||||||
|
super(context, eventBus);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active resources
|
||||||
|
*/
|
||||||
|
async getActive(): Promise<IResource[]> {
|
||||||
|
const all = await this.getAll();
|
||||||
|
return all.filter(r => r.isActive !== false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get resources by 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 = () => {
|
||||||
|
const data = request.result as IResource[];
|
||||||
|
resolve(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onerror = () => {
|
||||||
|
reject(new Error(`Failed to get resources by type ${type}: ${request.error}`));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/v2/storage/resources/ResourceStore.ts
Normal file
17
src/v2/storage/resources/ResourceStore.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { IStore } from '../IStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ResourceStore - IndexedDB ObjectStore definition for resources
|
||||||
|
*/
|
||||||
|
export class ResourceStore implements IStore {
|
||||||
|
static readonly STORE_NAME = 'resources';
|
||||||
|
readonly storeName = ResourceStore.STORE_NAME;
|
||||||
|
|
||||||
|
create(db: IDBDatabase): void {
|
||||||
|
const store = db.createObjectStore(ResourceStore.STORE_NAME, { keyPath: 'id' });
|
||||||
|
|
||||||
|
store.createIndex('type', 'type', { unique: false });
|
||||||
|
store.createIndex('syncStatus', 'syncStatus', { unique: false });
|
||||||
|
store.createIndex('isActive', 'isActive', { unique: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -84,3 +84,59 @@ export interface IEntityDeletedPayload {
|
||||||
entityType: EntityType;
|
entityType: EntityType;
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resource types
|
||||||
|
export type ResourceType =
|
||||||
|
| 'person'
|
||||||
|
| 'room'
|
||||||
|
| 'equipment'
|
||||||
|
| 'vehicle'
|
||||||
|
| 'custom';
|
||||||
|
|
||||||
|
export interface IResource extends ISync {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
type: ResourceType;
|
||||||
|
avatarUrl?: string;
|
||||||
|
color?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Booking types
|
||||||
|
export type BookingStatus =
|
||||||
|
| 'created'
|
||||||
|
| 'arrived'
|
||||||
|
| 'paid'
|
||||||
|
| 'noshow'
|
||||||
|
| 'cancelled';
|
||||||
|
|
||||||
|
export interface IBookingService {
|
||||||
|
serviceId: string;
|
||||||
|
serviceName: string;
|
||||||
|
baseDuration: number;
|
||||||
|
basePrice: number;
|
||||||
|
customPrice?: number;
|
||||||
|
resourceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBooking extends ISync {
|
||||||
|
id: string;
|
||||||
|
customerId: string;
|
||||||
|
status: BookingStatus;
|
||||||
|
createdAt: Date;
|
||||||
|
services: IBookingService[];
|
||||||
|
totalPrice?: number;
|
||||||
|
tags?: string[];
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Customer types
|
||||||
|
export interface ICustomer extends ISync {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
email?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
<div class="calendar-wrapper">
|
<div class="calendar-wrapper">
|
||||||
<swp-calendar>
|
<swp-calendar>
|
||||||
<swp-calendar-nav>
|
<swp-calendar-nav>
|
||||||
|
<swp-nav-button id="btn-day">Dag</swp-nav-button>
|
||||||
<swp-nav-button id="btn-simple">Datoer</swp-nav-button>
|
<swp-nav-button id="btn-simple">Datoer</swp-nav-button>
|
||||||
<swp-nav-button id="btn-resource">Resources</swp-nav-button>
|
<swp-nav-button id="btn-resource">Resources</swp-nav-button>
|
||||||
<swp-nav-button id="btn-team">Teams</swp-nav-button>
|
<swp-nav-button id="btn-team">Teams</swp-nav-button>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue