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
|
||||
import { EventBus } from './core/EventBus';
|
||||
import { IEventBus, ICalendarEvent, ISync } from './types/CalendarTypes';
|
||||
import { IEventBus, ICalendarEvent, ISync, IResource, IBooking, ICustomer } from './types/CalendarTypes';
|
||||
|
||||
// Storage
|
||||
import { IndexedDBContext } from './storage/IndexedDBContext';
|
||||
|
|
@ -23,10 +23,19 @@ import { IStore } from './storage/IStore';
|
|||
import { IEntityService } from './storage/IEntityService';
|
||||
import { EventStore } from './storage/events/EventStore';
|
||||
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
|
||||
import { IApiRepository } from './repositories/IApiRepository';
|
||||
import { MockEventRepository } from './repositories/MockEventRepository';
|
||||
import { MockResourceRepository } from './repositories/MockResourceRepository';
|
||||
import { MockBookingRepository } from './repositories/MockBookingRepository';
|
||||
import { MockCustomerRepository } from './repositories/MockCustomerRepository';
|
||||
|
||||
// Workers
|
||||
import { DataSeeder } from './workers/DataSeeder';
|
||||
|
|
@ -58,17 +67,43 @@ export function createV2Container(): Container {
|
|||
|
||||
// Storage infrastructure
|
||||
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<ISync>>();
|
||||
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<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
|
||||
builder.registerType(DataSeeder).as<DataSeeder>();
|
||||
|
||||
|
|
|
|||
|
|
@ -43,9 +43,8 @@ export class CalendarOrchestrator {
|
|||
const pipeline = buildPipeline(activeRenderers);
|
||||
pipeline.run(context);
|
||||
|
||||
// Events
|
||||
const dates = filter['date'] || [];
|
||||
await this.eventRenderer.render(container, dates);
|
||||
// Render events med hele filter (date + resource)
|
||||
await this.eventRenderer.render(container, filter);
|
||||
}
|
||||
|
||||
private selectRenderers(viewConfig: ViewConfig): Renderer[] {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export class DemoApp {
|
|||
private animator!: NavigationAnimator;
|
||||
private container!: HTMLElement;
|
||||
private weekOffset = 0;
|
||||
private currentView: 'simple' | 'resource' | 'team' = 'simple';
|
||||
private currentView: 'day' | 'simple' | 'resource' | 'team' = 'simple';
|
||||
|
||||
constructor(
|
||||
private orchestrator: CalendarOrchestrator,
|
||||
|
|
@ -66,8 +66,18 @@ export class DemoApp {
|
|||
|
||||
private buildViewConfig(): ViewConfig {
|
||||
const dates = this.dateService.getWeekDates(this.weekOffset, 3);
|
||||
const today = this.dateService.getWeekDates(this.weekOffset, 1);
|
||||
|
||||
switch (this.currentView) {
|
||||
case 'day':
|
||||
return {
|
||||
templateId: 'day',
|
||||
groupings: [
|
||||
{ type: 'resource', values: ['res1', 'res2'] },
|
||||
{ type: 'date', values: today }
|
||||
]
|
||||
};
|
||||
|
||||
case 'simple':
|
||||
return {
|
||||
templateId: 'simple',
|
||||
|
|
@ -110,6 +120,11 @@ export class DemoApp {
|
|||
}
|
||||
|
||||
private setupViewSwitching(): void {
|
||||
document.getElementById('btn-day')?.addEventListener('click', () => {
|
||||
this.currentView = 'day';
|
||||
this.render();
|
||||
});
|
||||
|
||||
document.getElementById('btn-simple')?.addEventListener('click', () => {
|
||||
this.currentView = 'simple';
|
||||
this.render();
|
||||
|
|
|
|||
|
|
@ -8,16 +8,23 @@ export class DateRenderer implements Renderer {
|
|||
|
||||
render(context: RenderContext): void {
|
||||
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) {
|
||||
const date = this.dateService.parseISO(dateStr);
|
||||
|
||||
// Header
|
||||
const header = document.createElement('swp-day-header');
|
||||
header.dataset.date = dateStr;
|
||||
if (resourceId) {
|
||||
header.dataset.resourceId = resourceId;
|
||||
}
|
||||
header.innerHTML = `
|
||||
<swp-day-name>${this.dateService.getDayName(date, 'short')}</swp-day-name>
|
||||
<swp-day-date>${date.getDate()}</swp-day-date>
|
||||
|
|
@ -27,6 +34,9 @@ export class DateRenderer implements Renderer {
|
|||
// Column
|
||||
const column = document.createElement('swp-day-column');
|
||||
column.dataset.date = dateStr;
|
||||
if (resourceId) {
|
||||
column.dataset.resourceId = resourceId;
|
||||
}
|
||||
column.innerHTML = '<swp-events-layer></swp-events-layer>';
|
||||
context.columnContainer.appendChild(column);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,8 +21,14 @@ export class EventRenderer {
|
|||
|
||||
/**
|
||||
* 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
|
||||
const startDate = new Date(visibleDates[0]);
|
||||
const endDate = new Date(visibleDates[visibleDates.length - 1]);
|
||||
|
|
@ -31,19 +37,32 @@ export class EventRenderer {
|
|||
// Fetch events from IndexedDB
|
||||
const events = await this.eventService.getByDateRange(startDate, endDate);
|
||||
|
||||
// Group events by date
|
||||
const eventsByDate = this.groupEventsByDate(events);
|
||||
|
||||
// Find day columns
|
||||
const dayColumns = container.querySelector('swp-day-columns');
|
||||
if (!dayColumns) return;
|
||||
|
||||
const columns = dayColumns.querySelectorAll('swp-day-column');
|
||||
|
||||
// Render events into columns
|
||||
columns.forEach((column, index) => {
|
||||
const dateKey = visibleDates[index];
|
||||
const dateEvents = eventsByDate.get(dateKey) || [];
|
||||
// Render events into each column based on data attributes
|
||||
columns.forEach(column => {
|
||||
const dateKey = (column as HTMLElement).dataset.date;
|
||||
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
|
||||
let eventsLayer = column.querySelector('swp-events-layer');
|
||||
|
|
@ -55,8 +74,8 @@ export class EventRenderer {
|
|||
// Clear existing events
|
||||
eventsLayer.innerHTML = '';
|
||||
|
||||
// Render each event
|
||||
dateEvents.forEach(event => {
|
||||
// Render each timed event
|
||||
columnEvents.forEach(event => {
|
||||
if (!event.allDay) {
|
||||
const eventElement = this.createEventElement(event);
|
||||
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
|
||||
*
|
||||
|
|
|
|||
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 {
|
||||
private static readonly DB_NAME = 'CalendarV2DB';
|
||||
private static readonly DB_VERSION = 1;
|
||||
private static readonly DB_VERSION = 2;
|
||||
|
||||
private db: IDBDatabase | null = null;
|
||||
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;
|
||||
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">
|
||||
<swp-calendar>
|
||||
<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-resource">Resources</swp-nav-button>
|
||||
<swp-nav-button id="btn-team">Teams</swp-nav-button>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue