From d4249eecfb8004d5d25a1a89287c334234324144 Mon Sep 17 00:00:00 2001 From: "Janus C. H. Knudsen" Date: Mon, 15 Dec 2025 17:10:43 +0100 Subject: [PATCH] Adds hierarchical grouping and entity resolution support Enhances calendar rendering with dynamic parent-child relationships between entities Introduces EntityResolver for dot-notation references Supports belongsTo configuration in grouping Implements flexible filtering across hierarchical entities Improves rendering flexibility for complex organizational structures --- src/v2/V2CompositionRoot.ts | 13 +++- src/v2/core/CalendarOrchestrator.ts | 68 +++++++++++++---- src/v2/core/EntityResolver.ts | 48 ++++++++++++ src/v2/core/FilterTemplate.ts | 78 +++++++++++++++++++- src/v2/core/IEntityResolver.ts | 15 ++++ src/v2/core/IGroupingRenderer.ts | 2 + src/v2/core/ViewConfig.ts | 4 +- src/v2/demo/DemoApp.ts | 4 +- src/v2/features/date/DateRenderer.ts | 9 +++ src/v2/features/resource/ResourceRenderer.ts | 30 +++++++- src/v2/features/team/TeamRenderer.ts | 39 +++++----- src/v2/repositories/MockTeamRepository.ts | 55 ++++++++++++++ src/v2/storage/IndexedDBContext.ts | 2 +- src/v2/storage/teams/TeamService.ts | 44 +++++++++++ src/v2/storage/teams/TeamStore.ts | 13 ++++ src/v2/types/CalendarTypes.ts | 9 ++- wwwroot/data/mock-teams.json | 14 ++++ 17 files changed, 403 insertions(+), 44 deletions(-) create mode 100644 src/v2/core/EntityResolver.ts create mode 100644 src/v2/core/IEntityResolver.ts create mode 100644 src/v2/repositories/MockTeamRepository.ts create mode 100644 src/v2/storage/teams/TeamService.ts create mode 100644 src/v2/storage/teams/TeamStore.ts create mode 100644 wwwroot/data/mock-teams.json diff --git a/src/v2/V2CompositionRoot.ts b/src/v2/V2CompositionRoot.ts index 031b150..1031077 100644 --- a/src/v2/V2CompositionRoot.ts +++ b/src/v2/V2CompositionRoot.ts @@ -16,7 +16,7 @@ import { DemoApp } from './demo/DemoApp'; // Event system import { EventBus } from './core/EventBus'; -import { IEventBus, ICalendarEvent, ISync, IResource, IBooking, ICustomer } from './types/CalendarTypes'; +import { IEventBus, ICalendarEvent, ISync, IResource, IBooking, ICustomer, ITeam } from './types/CalendarTypes'; // Storage import { IndexedDBContext } from './storage/IndexedDBContext'; @@ -30,6 +30,8 @@ import { BookingStore } from './storage/bookings/BookingStore'; import { BookingService } from './storage/bookings/BookingService'; import { CustomerStore } from './storage/customers/CustomerStore'; import { CustomerService } from './storage/customers/CustomerService'; +import { TeamStore } from './storage/teams/TeamStore'; +import { TeamService } from './storage/teams/TeamService'; // Audit import { AuditStore } from './storage/audit/AuditStore'; @@ -43,6 +45,7 @@ import { MockResourceRepository } from './repositories/MockResourceRepository'; import { MockBookingRepository } from './repositories/MockBookingRepository'; import { MockCustomerRepository } from './repositories/MockCustomerRepository'; import { MockAuditRepository } from './repositories/MockAuditRepository'; +import { MockTeamRepository } from './repositories/MockTeamRepository'; // Workers import { DataSeeder } from './workers/DataSeeder'; @@ -102,6 +105,7 @@ export function createV2Container(): Container { builder.registerType(ResourceStore).as(); builder.registerType(BookingStore).as(); builder.registerType(CustomerStore).as(); + builder.registerType(TeamStore).as(); builder.registerType(ScheduleOverrideStore).as(); builder.registerType(AuditStore).as(); @@ -122,6 +126,10 @@ export function createV2Container(): Container { builder.registerType(CustomerService).as>(); builder.registerType(CustomerService).as(); + builder.registerType(TeamService).as>(); + builder.registerType(TeamService).as>(); + builder.registerType(TeamService).as(); + // Repositories (for DataSeeder polymorphic array) builder.registerType(MockEventRepository).as>(); builder.registerType(MockEventRepository).as>(); @@ -138,6 +146,9 @@ export function createV2Container(): Container { builder.registerType(MockAuditRepository).as>(); builder.registerType(MockAuditRepository).as>(); + builder.registerType(MockTeamRepository).as>(); + builder.registerType(MockTeamRepository).as>(); + // Audit service (listens to ENTITY_SAVED/DELETED events automatically) builder.registerType(AuditService).as(); diff --git a/src/v2/core/CalendarOrchestrator.ts b/src/v2/core/CalendarOrchestrator.ts index 3e7a756..a39aa14 100644 --- a/src/v2/core/CalendarOrchestrator.ts +++ b/src/v2/core/CalendarOrchestrator.ts @@ -3,9 +3,11 @@ import { buildPipeline } from './RenderBuilder'; import { EventRenderer } from '../features/event/EventRenderer'; import { ScheduleRenderer } from '../features/schedule/ScheduleRenderer'; import { HeaderDrawerRenderer } from '../features/headerdrawer/HeaderDrawerRenderer'; -import { ViewConfig } from './ViewConfig'; +import { ViewConfig, GroupingConfig } from './ViewConfig'; import { FilterTemplate } from './FilterTemplate'; import { DateService } from './DateService'; +import { IEntityService } from '../storage/IEntityService'; +import { ISync } from '../types/CalendarTypes'; export class CalendarOrchestrator { constructor( @@ -13,7 +15,8 @@ export class CalendarOrchestrator { private eventRenderer: EventRenderer, private scheduleRenderer: ScheduleRenderer, private headerDrawerRenderer: HeaderDrawerRenderer, - private dateService: DateService + private dateService: DateService, + private entityServices: IEntityService[] ) {} async render(viewConfig: ViewConfig, container: HTMLElement): Promise { @@ -29,13 +32,18 @@ export class CalendarOrchestrator { filter[grouping.type] = grouping.values; } - // Byg FilterTemplate fra viewConfig groupings + // Byg FilterTemplate fra viewConfig groupings (kun de med idProperty) const filterTemplate = new FilterTemplate(this.dateService); for (const grouping of viewConfig.groupings) { - filterTemplate.addField(grouping.idProperty, grouping.derivedFrom); + if (grouping.idProperty) { + filterTemplate.addField(grouping.idProperty, grouping.derivedFrom); + } } - const context: IRenderContext = { headerContainer, columnContainer, filter }; + // Resolve belongsTo relations (e.g., team.resourceIds) + const { parentChildMap, childType } = await this.resolveBelongsTo(viewConfig.groupings, filter); + + const context: IRenderContext = { headerContainer, columnContainer, filter, parentChildMap, childType }; // Clear headerContainer.innerHTML = ''; @@ -48,10 +56,6 @@ export class CalendarOrchestrator { // Vælg renderers baseret på groupings types const activeRenderers = this.selectRenderers(viewConfig); - // Beregn total kolonner dynamisk - const totalColumns = this.calculateTotalColumns(viewConfig); - container.style.setProperty('--grid-columns', String(totalColumns)); - // Byg og kør pipeline const pipeline = buildPipeline(activeRenderers); await pipeline.run(context); @@ -74,9 +78,47 @@ export class CalendarOrchestrator { .filter((r): r is IRenderer => r !== undefined); } - private calculateTotalColumns(viewConfig: ViewConfig): number { - const dateCount = viewConfig.groupings.find(g => g.type === 'date')?.values.length || 1; - const resourceCount = viewConfig.groupings.find(g => g.type === 'resource')?.values.length || 1; - return dateCount * resourceCount; + /** + * Resolve belongsTo relations to build parent-child map + * e.g., belongsTo: 'team.resourceIds' → { team1: ['EMP001', 'EMP002'], team2: [...] } + * Also returns the childType (the grouping type that has belongsTo) + */ + private async resolveBelongsTo( + groupings: GroupingConfig[], + filter: Record + ): Promise<{ parentChildMap?: Record; childType?: string }> { + // Find grouping with belongsTo + const childGrouping = groupings.find(g => g.belongsTo); + if (!childGrouping?.belongsTo) return {}; + + // Parse belongsTo: 'team.resourceIds' + const [entityType, property] = childGrouping.belongsTo.split('.'); + if (!entityType || !property) return {}; + + // Get parent IDs from filter + const parentIds = filter[entityType] || []; + if (parentIds.length === 0) return {}; + + // Find service dynamisk baseret på entityType (ingen hardcoded type check) + const service = this.entityServices.find(s => + s.entityType.toLowerCase() === entityType + ); + if (!service) return {}; + + // Hent alle entities og filtrer på parentIds + const allEntities = await service.getAll(); + const entities = allEntities.filter(e => + parentIds.includes((e as Record).id as string) + ); + + // Byg parent-child map + const map: Record = {}; + for (const entity of entities) { + const entityRecord = entity as Record; + const children = (entityRecord[property] as string[]) || []; + map[entityRecord.id as string] = children; + } + + return { parentChildMap: map, childType: childGrouping.type }; } } diff --git a/src/v2/core/EntityResolver.ts b/src/v2/core/EntityResolver.ts new file mode 100644 index 0000000..7161c30 --- /dev/null +++ b/src/v2/core/EntityResolver.ts @@ -0,0 +1,48 @@ +import { IEntityResolver } from './IEntityResolver'; + +/** + * EntityResolver - Resolves entities from pre-loaded cache + * + * Entities must be loaded before use (typically at render time). + * This allows synchronous lookups during filtering. + */ +export class EntityResolver implements IEntityResolver { + private cache: Map>> = new Map(); + + /** + * Load entities into cache for a given type + * @param entityType - The entity type (e.g., 'resource') + * @param entities - Array of entities with 'id' property + */ + load(entityType: string, entities: T[]): void { + const typeCache = new Map>(); + for (const entity of entities) { + // Cast to Record for storage while preserving original data + typeCache.set(entity.id, entity as unknown as Record); + } + this.cache.set(entityType, typeCache); + } + + /** + * Resolve an entity by type and ID + */ + resolve(entityType: string, id: string): Record | undefined { + const typeCache = this.cache.get(entityType); + if (!typeCache) return undefined; + return typeCache.get(id); + } + + /** + * Clear all cached entities + */ + clear(): void { + this.cache.clear(); + } + + /** + * Clear cache for a specific entity type + */ + clearType(entityType: string): void { + this.cache.delete(entityType); + } +} diff --git a/src/v2/core/FilterTemplate.ts b/src/v2/core/FilterTemplate.ts index b838337..00451b1 100644 --- a/src/v2/core/FilterTemplate.ts +++ b/src/v2/core/FilterTemplate.ts @@ -1,5 +1,6 @@ import { ICalendarEvent } from '../types/CalendarTypes'; import { DateService } from './DateService'; +import { IEntityResolver } from './IEntityResolver'; /** * Field definition for FilterTemplate @@ -9,12 +10,24 @@ interface IFilterField { derivedFrom?: string; } +/** + * Parsed dot-notation reference + */ +interface IDotNotation { + entityType: string; // e.g., 'resource' + property: string; // e.g., 'teamId' + foreignKey: string; // e.g., 'resourceId' +} + /** * FilterTemplate - Bygger nøgler til event-kolonne matching * * ViewConfig definerer hvilke felter (idProperties) der indgår i kolonnens nøgle. * Samme template bruges til at bygge nøgle for både kolonne og event. * + * Supports dot-notation for hierarchical relations: + * - 'resource.teamId' → looks up event.resourceId → resource entity → teamId + * * Princip: Kolonnens nøgle-template bestemmer hvad der matches på. * * @see docs/filter-template.md @@ -22,7 +35,10 @@ interface IFilterField { export class FilterTemplate { private fields: IFilterField[] = []; - constructor(private dateService: DateService) {} + constructor( + private dateService: DateService, + private entityResolver?: IEntityResolver + ) {} /** * Tilføj felt til template @@ -34,25 +50,62 @@ export class FilterTemplate { return this; } + /** + * Parse dot-notation string into components + * @example 'resource.teamId' → { entityType: 'resource', property: 'teamId', foreignKey: 'resourceId' } + */ + private parseDotNotation(idProperty: string): IDotNotation | null { + if (!idProperty.includes('.')) return null; + const [entityType, property] = idProperty.split('.'); + return { + entityType, + property, + foreignKey: entityType + 'Id' // Convention: resource → resourceId + }; + } + + /** + * Get dataset key for column lookup + * For dot-notation 'resource.teamId', we look for 'teamId' in dataset + */ + private getDatasetKey(idProperty: string): string { + const dotNotation = this.parseDotNotation(idProperty); + if (dotNotation) { + return dotNotation.property; // 'teamId' + } + return idProperty; + } + /** * Byg nøgle fra kolonne * Læser værdier fra column.dataset[idProperty] + * For dot-notation, uses the property part (resource.teamId → teamId) */ buildKeyFromColumn(column: HTMLElement): string { return this.fields - .map(f => column.dataset[f.idProperty] || '') + .map(f => { + const key = this.getDatasetKey(f.idProperty); + return column.dataset[key] || ''; + }) .join(':'); } /** * Byg nøgle fra event * Læser værdier fra event[idProperty] eller udleder fra derivedFrom + * For dot-notation, resolves via EntityResolver */ buildKeyFromEvent(event: ICalendarEvent): string { // eslint-disable-next-line @typescript-eslint/no-explicit-any const eventRecord = event as any; return this.fields .map(f => { + // Check for dot-notation (e.g., 'resource.teamId') + const dotNotation = this.parseDotNotation(f.idProperty); + if (dotNotation) { + return this.resolveDotNotation(eventRecord, dotNotation); + } + if (f.derivedFrom) { // Udled værdi (f.eks. date fra start) const sourceValue = eventRecord[f.derivedFrom]; @@ -66,6 +119,27 @@ export class FilterTemplate { .join(':'); } + /** + * Resolve dot-notation reference via EntityResolver + */ + private resolveDotNotation(eventRecord: Record, dotNotation: IDotNotation): string { + if (!this.entityResolver) { + console.warn(`FilterTemplate: EntityResolver required for dot-notation '${dotNotation.entityType}.${dotNotation.property}'`); + return ''; + } + + // Get foreign key value from event (e.g., resourceId) + const foreignId = eventRecord[dotNotation.foreignKey]; + if (!foreignId) return ''; + + // Resolve entity + const entity = this.entityResolver.resolve(dotNotation.entityType, String(foreignId)); + if (!entity) return ''; + + // Return property value from entity + return String(entity[dotNotation.property] || ''); + } + /** * Match event mod kolonne */ diff --git a/src/v2/core/IEntityResolver.ts b/src/v2/core/IEntityResolver.ts new file mode 100644 index 0000000..b825c0f --- /dev/null +++ b/src/v2/core/IEntityResolver.ts @@ -0,0 +1,15 @@ +/** + * IEntityResolver - Resolves entities by type and ID + * + * Used by FilterTemplate to resolve dot-notation references like 'resource.teamId' + * where the value needs to be looked up from a related entity. + */ +export interface IEntityResolver { + /** + * Resolve an entity by type and ID + * @param entityType - The entity type (e.g., 'resource', 'booking', 'customer') + * @param id - The entity ID + * @returns The entity record or undefined if not found + */ + resolve(entityType: string, id: string): Record | undefined; +} diff --git a/src/v2/core/IGroupingRenderer.ts b/src/v2/core/IGroupingRenderer.ts index 5c44c1b..0dd31fa 100644 --- a/src/v2/core/IGroupingRenderer.ts +++ b/src/v2/core/IGroupingRenderer.ts @@ -2,6 +2,8 @@ export interface IRenderContext { headerContainer: HTMLElement; columnContainer: HTMLElement; filter: Record; // { team: ['alpha'], resource: ['alice', 'bob'], date: [...] } + parentChildMap?: Record; // { team1: ['EMP001', 'EMP002'], team2: ['EMP003', 'EMP004'] } + childType?: string; // The type of the child grouping (e.g., 'resource' when team has belongsTo) } export interface IRenderer { diff --git a/src/v2/core/ViewConfig.ts b/src/v2/core/ViewConfig.ts index 074943c..9b4334c 100644 --- a/src/v2/core/ViewConfig.ts +++ b/src/v2/core/ViewConfig.ts @@ -12,7 +12,7 @@ export interface ViewConfig { export interface GroupingConfig { type: string; values: string[]; - idProperty: string; // Property-navn på event (f.eks. 'resourceId', 'teamId') + idProperty?: string; // Property-navn på event (f.eks. 'resourceId') - kun for event matching derivedFrom?: string; // Hvis feltet udledes fra anden property (f.eks. 'date' fra 'start') - parentKey?: string; + belongsTo?: string; // Parent-child relation (f.eks. 'team.resourceIds') } diff --git a/src/v2/demo/DemoApp.ts b/src/v2/demo/DemoApp.ts index 3f83c36..658c84b 100644 --- a/src/v2/demo/DemoApp.ts +++ b/src/v2/demo/DemoApp.ts @@ -124,8 +124,8 @@ export class DemoApp { return { templateId: 'team', groupings: [ - { type: 'team', values: ['team1', 'team2'], idProperty: 'teamId' }, - { type: 'resource', values: ['res1', 'res2', 'res3'], idProperty: 'resourceId' }, + { type: 'team', values: ['team1', 'team2'] }, + { type: 'resource', values: ['EMP001', 'EMP002', 'EMP003', 'EMP004'], idProperty: 'resourceId', belongsTo: 'team.resourceIds' }, { type: 'date', values: dates, idProperty: 'date', derivedFrom: 'start' } ] }; diff --git a/src/v2/features/date/DateRenderer.ts b/src/v2/features/date/DateRenderer.ts index 5e6cc36..57f75f7 100644 --- a/src/v2/features/date/DateRenderer.ts +++ b/src/v2/features/date/DateRenderer.ts @@ -12,6 +12,7 @@ export class DateRenderer implements IRenderer { // Render dates for HVER resource (eller 1 gang hvis ingen resources) const iterations = resourceIds.length || 1; + let columnCount = 0; for (let r = 0; r < iterations; r++) { const resourceId = resourceIds[r]; // undefined hvis ingen resources @@ -46,7 +47,15 @@ export class DateRenderer implements IRenderer { } column.innerHTML = ''; context.columnContainer.appendChild(column); + + columnCount++; } } + + // Set grid columns on container + const container = context.columnContainer.closest('swp-calendar-container'); + if (container) { + (container as HTMLElement).style.setProperty('--grid-columns', String(columnCount)); + } } } diff --git a/src/v2/features/resource/ResourceRenderer.ts b/src/v2/features/resource/ResourceRenderer.ts index 4262d4c..72d503d 100644 --- a/src/v2/features/resource/ResourceRenderer.ts +++ b/src/v2/features/resource/ResourceRenderer.ts @@ -8,10 +8,36 @@ export class ResourceRenderer implements IRenderer { async render(context: IRenderContext): Promise { const resourceIds = context.filter['resource'] || []; - const resources = await this.resourceService.getByIds(resourceIds); const dateCount = context.filter['date']?.length || 1; - for (const resource of resources) { + // Determine render order based on parentChildMap + // If parentChildMap exists, render resources grouped by parent (e.g., team) + // Otherwise, render in filter order + let orderedResourceIds: string[]; + + if (context.parentChildMap) { + // Render resources in parent-child order + orderedResourceIds = []; + for (const childIds of Object.values(context.parentChildMap)) { + for (const childId of childIds) { + if (resourceIds.includes(childId)) { + orderedResourceIds.push(childId); + } + } + } + } else { + orderedResourceIds = resourceIds; + } + + const resources = await this.resourceService.getByIds(orderedResourceIds); + + // Create a map for quick lookup to preserve order + const resourceMap = new Map(resources.map(r => [r.id, r])); + + for (const resourceId of orderedResourceIds) { + const resource = resourceMap.get(resourceId); + if (!resource) continue; + const header = document.createElement('swp-resource-header'); header.dataset.resourceId = resource.id; header.textContent = resource.displayName; diff --git a/src/v2/features/team/TeamRenderer.ts b/src/v2/features/team/TeamRenderer.ts index 0b2a5d1..26691f3 100644 --- a/src/v2/features/team/TeamRenderer.ts +++ b/src/v2/features/team/TeamRenderer.ts @@ -1,32 +1,31 @@ import { IRenderer, IRenderContext } from '../../core/IGroupingRenderer'; - -interface Team { - id: string; - name: string; - resourceIds: string[]; -} +import { TeamService } from '../../storage/teams/TeamService'; export class TeamRenderer implements IRenderer { readonly type = 'team'; - // Hardcoded data - private teams: Team[] = [ - { id: 'team1', name: 'Team Alpha', resourceIds: ['res1', 'res2'] }, - { id: 'team2', name: 'Team Beta', resourceIds: ['res3'] } - ]; + constructor(private teamService: TeamService) {} - render(context: IRenderContext): void { - const allowedIds = context.filter['team'] || []; - const filteredTeams = this.teams.filter(t => allowedIds.includes(t.id)); + async render(context: IRenderContext): Promise { + const allowedIds = context.filter[this.type] || []; + if (allowedIds.length === 0) return; + + // Fetch teams from IndexedDB (only for name display) + const teams = await this.teamService.getByIds(allowedIds); const dateCount = context.filter['date']?.length || 1; - const resourceIds = context.filter['resource'] || []; - // Render ALLE team headers først - for (const team of filteredTeams) { - // Tæl resources der tilhører dette team OG er i filter - const teamResourceCount = team.resourceIds.filter(id => resourceIds.includes(id)).length; - const colspan = teamResourceCount * dateCount; + // Get child filter values using childType from context (not hardcoded) + const childIds = context.childType ? context.filter[context.childType] || [] : []; + + // Render team headers + for (const team of teams) { + // Get children from parentChildMap (resolved from belongsTo config) + const teamChildIds = context.parentChildMap?.[team.id] || []; + + // Count children that belong to this team AND are in the filter + const childCount = teamChildIds.filter(id => childIds.includes(id)).length; + const colspan = childCount * dateCount; const header = document.createElement('swp-team-header'); header.dataset.teamId = team.id; diff --git a/src/v2/repositories/MockTeamRepository.ts b/src/v2/repositories/MockTeamRepository.ts new file mode 100644 index 0000000..052cf40 --- /dev/null +++ b/src/v2/repositories/MockTeamRepository.ts @@ -0,0 +1,55 @@ +import { ITeam, EntityType } from '../types/CalendarTypes'; +import { IApiRepository } from './IApiRepository'; + +interface RawTeamData { + id: string; + name: string; + resourceIds: string[]; + syncStatus?: string; + [key: string]: unknown; +} + +/** + * MockTeamRepository - Loads team data from local JSON file + */ +export class MockTeamRepository implements IApiRepository { + public readonly entityType: EntityType = 'Team'; + private readonly dataUrl = 'data/mock-teams.json'; + + public async fetchAll(): Promise { + try { + const response = await fetch(this.dataUrl); + + if (!response.ok) { + throw new Error(`Failed to load mock teams: ${response.status} ${response.statusText}`); + } + + const rawData: RawTeamData[] = await response.json(); + return this.processTeamData(rawData); + } catch (error) { + console.error('Failed to load team data:', error); + throw error; + } + } + + public async sendCreate(_team: ITeam): Promise { + throw new Error('MockTeamRepository does not support sendCreate. Mock data is read-only.'); + } + + public async sendUpdate(_id: string, _updates: Partial): Promise { + throw new Error('MockTeamRepository does not support sendUpdate. Mock data is read-only.'); + } + + public async sendDelete(_id: string): Promise { + throw new Error('MockTeamRepository does not support sendDelete. Mock data is read-only.'); + } + + private processTeamData(data: RawTeamData[]): ITeam[] { + return data.map((team): ITeam => ({ + id: team.id, + name: team.name, + resourceIds: team.resourceIds, + syncStatus: 'synced' as const + })); + } +} diff --git a/src/v2/storage/IndexedDBContext.ts b/src/v2/storage/IndexedDBContext.ts index ea709bf..c399c85 100644 --- a/src/v2/storage/IndexedDBContext.ts +++ b/src/v2/storage/IndexedDBContext.ts @@ -10,7 +10,7 @@ import { IStore } from './IStore'; */ export class IndexedDBContext { private static readonly DB_NAME = 'CalendarV2DB'; - private static readonly DB_VERSION = 2; + private static readonly DB_VERSION = 3; private db: IDBDatabase | null = null; private initialized: boolean = false; diff --git a/src/v2/storage/teams/TeamService.ts b/src/v2/storage/teams/TeamService.ts new file mode 100644 index 0000000..642ce66 --- /dev/null +++ b/src/v2/storage/teams/TeamService.ts @@ -0,0 +1,44 @@ +import { ITeam, EntityType, IEventBus } from '../../types/CalendarTypes'; +import { TeamStore } from './TeamStore'; +import { BaseEntityService } from '../BaseEntityService'; +import { IndexedDBContext } from '../IndexedDBContext'; + +/** + * TeamService - CRUD operations for teams in IndexedDB + * + * Teams define which resources belong together for hierarchical grouping. + * Extends BaseEntityService for standard entity operations. + */ +export class TeamService extends BaseEntityService { + readonly storeName = TeamStore.STORE_NAME; + readonly entityType: EntityType = 'Team'; + + constructor(context: IndexedDBContext, eventBus: IEventBus) { + super(context, eventBus); + } + + /** + * Get teams by IDs + */ + async getByIds(ids: string[]): Promise { + if (ids.length === 0) return []; + const results = await Promise.all(ids.map(id => this.get(id))); + return results.filter((t): t is ITeam => t !== null); + } + + /** + * Build reverse lookup: resourceId → teamId + */ + async buildResourceToTeamMap(): Promise> { + const teams = await this.getAll(); + const map: Record = {}; + + for (const team of teams) { + for (const resourceId of team.resourceIds) { + map[resourceId] = team.id; + } + } + + return map; + } +} diff --git a/src/v2/storage/teams/TeamStore.ts b/src/v2/storage/teams/TeamStore.ts new file mode 100644 index 0000000..f4fdb57 --- /dev/null +++ b/src/v2/storage/teams/TeamStore.ts @@ -0,0 +1,13 @@ +import { IStore } from '../IStore'; + +/** + * TeamStore - IndexedDB ObjectStore definition for teams + */ +export class TeamStore implements IStore { + static readonly STORE_NAME = 'teams'; + readonly storeName = TeamStore.STORE_NAME; + + create(db: IDBDatabase): void { + db.createObjectStore(TeamStore.STORE_NAME, { keyPath: 'id' }); + } +} diff --git a/src/v2/types/CalendarTypes.ts b/src/v2/types/CalendarTypes.ts index 5c2be2f..df38557 100644 --- a/src/v2/types/CalendarTypes.ts +++ b/src/v2/types/CalendarTypes.ts @@ -6,7 +6,7 @@ import { IWeekSchedule } from './ScheduleTypes'; export type SyncStatus = 'synced' | 'pending' | 'error'; -export type EntityType = 'Event' | 'Booking' | 'Customer' | 'Resource' | 'Audit'; +export type EntityType = 'Event' | 'Booking' | 'Customer' | 'Resource' | 'Team' | 'Audit'; /** * CalendarEventType - Used by ICalendarEvent.type @@ -118,6 +118,13 @@ export interface IResource extends ISync { metadata?: Record; } +// Team types +export interface ITeam extends ISync { + id: string; + name: string; + resourceIds: string[]; +} + // Booking types export type BookingStatus = | 'created' diff --git a/wwwroot/data/mock-teams.json b/wwwroot/data/mock-teams.json new file mode 100644 index 0000000..6257ddc --- /dev/null +++ b/wwwroot/data/mock-teams.json @@ -0,0 +1,14 @@ +[ + { + "id": "team1", + "name": "Team Alpha", + "resourceIds": ["EMP001", "EMP002"], + "syncStatus": "synced" + }, + { + "id": "team2", + "name": "Team Beta", + "resourceIds": ["EMP003", "EMP004"], + "syncStatus": "synced" + } +]