Moving away from Azure Devops #1
17 changed files with 403 additions and 44 deletions
|
|
@ -16,7 +16,7 @@ import { DemoApp } from './demo/DemoApp';
|
||||||
|
|
||||||
// Event system
|
// Event system
|
||||||
import { EventBus } from './core/EventBus';
|
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
|
// Storage
|
||||||
import { IndexedDBContext } from './storage/IndexedDBContext';
|
import { IndexedDBContext } from './storage/IndexedDBContext';
|
||||||
|
|
@ -30,6 +30,8 @@ import { BookingStore } from './storage/bookings/BookingStore';
|
||||||
import { BookingService } from './storage/bookings/BookingService';
|
import { BookingService } from './storage/bookings/BookingService';
|
||||||
import { CustomerStore } from './storage/customers/CustomerStore';
|
import { CustomerStore } from './storage/customers/CustomerStore';
|
||||||
import { CustomerService } from './storage/customers/CustomerService';
|
import { CustomerService } from './storage/customers/CustomerService';
|
||||||
|
import { TeamStore } from './storage/teams/TeamStore';
|
||||||
|
import { TeamService } from './storage/teams/TeamService';
|
||||||
|
|
||||||
// Audit
|
// Audit
|
||||||
import { AuditStore } from './storage/audit/AuditStore';
|
import { AuditStore } from './storage/audit/AuditStore';
|
||||||
|
|
@ -43,6 +45,7 @@ import { MockResourceRepository } from './repositories/MockResourceRepository';
|
||||||
import { MockBookingRepository } from './repositories/MockBookingRepository';
|
import { MockBookingRepository } from './repositories/MockBookingRepository';
|
||||||
import { MockCustomerRepository } from './repositories/MockCustomerRepository';
|
import { MockCustomerRepository } from './repositories/MockCustomerRepository';
|
||||||
import { MockAuditRepository } from './repositories/MockAuditRepository';
|
import { MockAuditRepository } from './repositories/MockAuditRepository';
|
||||||
|
import { MockTeamRepository } from './repositories/MockTeamRepository';
|
||||||
|
|
||||||
// Workers
|
// Workers
|
||||||
import { DataSeeder } from './workers/DataSeeder';
|
import { DataSeeder } from './workers/DataSeeder';
|
||||||
|
|
@ -102,6 +105,7 @@ export function createV2Container(): Container {
|
||||||
builder.registerType(ResourceStore).as<IStore>();
|
builder.registerType(ResourceStore).as<IStore>();
|
||||||
builder.registerType(BookingStore).as<IStore>();
|
builder.registerType(BookingStore).as<IStore>();
|
||||||
builder.registerType(CustomerStore).as<IStore>();
|
builder.registerType(CustomerStore).as<IStore>();
|
||||||
|
builder.registerType(TeamStore).as<IStore>();
|
||||||
builder.registerType(ScheduleOverrideStore).as<IStore>();
|
builder.registerType(ScheduleOverrideStore).as<IStore>();
|
||||||
builder.registerType(AuditStore).as<IStore>();
|
builder.registerType(AuditStore).as<IStore>();
|
||||||
|
|
||||||
|
|
@ -122,6 +126,10 @@ export function createV2Container(): Container {
|
||||||
builder.registerType(CustomerService).as<IEntityService<ISync>>();
|
builder.registerType(CustomerService).as<IEntityService<ISync>>();
|
||||||
builder.registerType(CustomerService).as<CustomerService>();
|
builder.registerType(CustomerService).as<CustomerService>();
|
||||||
|
|
||||||
|
builder.registerType(TeamService).as<IEntityService<ITeam>>();
|
||||||
|
builder.registerType(TeamService).as<IEntityService<ISync>>();
|
||||||
|
builder.registerType(TeamService).as<TeamService>();
|
||||||
|
|
||||||
// Repositories (for DataSeeder polymorphic array)
|
// 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>>();
|
||||||
|
|
@ -138,6 +146,9 @@ export function createV2Container(): Container {
|
||||||
builder.registerType(MockAuditRepository).as<IApiRepository<IAuditEntry>>();
|
builder.registerType(MockAuditRepository).as<IApiRepository<IAuditEntry>>();
|
||||||
builder.registerType(MockAuditRepository).as<IApiRepository<ISync>>();
|
builder.registerType(MockAuditRepository).as<IApiRepository<ISync>>();
|
||||||
|
|
||||||
|
builder.registerType(MockTeamRepository).as<IApiRepository<ITeam>>();
|
||||||
|
builder.registerType(MockTeamRepository).as<IApiRepository<ISync>>();
|
||||||
|
|
||||||
// Audit service (listens to ENTITY_SAVED/DELETED events automatically)
|
// Audit service (listens to ENTITY_SAVED/DELETED events automatically)
|
||||||
builder.registerType(AuditService).as<AuditService>();
|
builder.registerType(AuditService).as<AuditService>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@ import { buildPipeline } from './RenderBuilder';
|
||||||
import { EventRenderer } from '../features/event/EventRenderer';
|
import { EventRenderer } from '../features/event/EventRenderer';
|
||||||
import { ScheduleRenderer } from '../features/schedule/ScheduleRenderer';
|
import { ScheduleRenderer } from '../features/schedule/ScheduleRenderer';
|
||||||
import { HeaderDrawerRenderer } from '../features/headerdrawer/HeaderDrawerRenderer';
|
import { HeaderDrawerRenderer } from '../features/headerdrawer/HeaderDrawerRenderer';
|
||||||
import { ViewConfig } from './ViewConfig';
|
import { ViewConfig, GroupingConfig } from './ViewConfig';
|
||||||
import { FilterTemplate } from './FilterTemplate';
|
import { FilterTemplate } from './FilterTemplate';
|
||||||
import { DateService } from './DateService';
|
import { DateService } from './DateService';
|
||||||
|
import { IEntityService } from '../storage/IEntityService';
|
||||||
|
import { ISync } from '../types/CalendarTypes';
|
||||||
|
|
||||||
export class CalendarOrchestrator {
|
export class CalendarOrchestrator {
|
||||||
constructor(
|
constructor(
|
||||||
|
|
@ -13,7 +15,8 @@ export class CalendarOrchestrator {
|
||||||
private eventRenderer: EventRenderer,
|
private eventRenderer: EventRenderer,
|
||||||
private scheduleRenderer: ScheduleRenderer,
|
private scheduleRenderer: ScheduleRenderer,
|
||||||
private headerDrawerRenderer: HeaderDrawerRenderer,
|
private headerDrawerRenderer: HeaderDrawerRenderer,
|
||||||
private dateService: DateService
|
private dateService: DateService,
|
||||||
|
private entityServices: IEntityService<ISync>[]
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async render(viewConfig: ViewConfig, container: HTMLElement): Promise<void> {
|
async render(viewConfig: ViewConfig, container: HTMLElement): Promise<void> {
|
||||||
|
|
@ -29,13 +32,18 @@ export class CalendarOrchestrator {
|
||||||
filter[grouping.type] = grouping.values;
|
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);
|
const filterTemplate = new FilterTemplate(this.dateService);
|
||||||
for (const grouping of viewConfig.groupings) {
|
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
|
// Clear
|
||||||
headerContainer.innerHTML = '';
|
headerContainer.innerHTML = '';
|
||||||
|
|
@ -48,10 +56,6 @@ export class CalendarOrchestrator {
|
||||||
// Vælg renderers baseret på groupings types
|
// Vælg renderers baseret på groupings types
|
||||||
const activeRenderers = this.selectRenderers(viewConfig);
|
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
|
// Byg og kør pipeline
|
||||||
const pipeline = buildPipeline(activeRenderers);
|
const pipeline = buildPipeline(activeRenderers);
|
||||||
await pipeline.run(context);
|
await pipeline.run(context);
|
||||||
|
|
@ -74,9 +78,47 @@ export class CalendarOrchestrator {
|
||||||
.filter((r): r is IRenderer => r !== undefined);
|
.filter((r): r is IRenderer => r !== undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
private calculateTotalColumns(viewConfig: ViewConfig): number {
|
/**
|
||||||
const dateCount = viewConfig.groupings.find(g => g.type === 'date')?.values.length || 1;
|
* Resolve belongsTo relations to build parent-child map
|
||||||
const resourceCount = viewConfig.groupings.find(g => g.type === 'resource')?.values.length || 1;
|
* e.g., belongsTo: 'team.resourceIds' → { team1: ['EMP001', 'EMP002'], team2: [...] }
|
||||||
return dateCount * resourceCount;
|
* Also returns the childType (the grouping type that has belongsTo)
|
||||||
|
*/
|
||||||
|
private async resolveBelongsTo(
|
||||||
|
groupings: GroupingConfig[],
|
||||||
|
filter: Record<string, string[]>
|
||||||
|
): Promise<{ parentChildMap?: Record<string, string[]>; 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<string, unknown>).id as string)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Byg parent-child map
|
||||||
|
const map: Record<string, string[]> = {};
|
||||||
|
for (const entity of entities) {
|
||||||
|
const entityRecord = entity as Record<string, unknown>;
|
||||||
|
const children = (entityRecord[property] as string[]) || [];
|
||||||
|
map[entityRecord.id as string] = children;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { parentChildMap: map, childType: childGrouping.type };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
48
src/v2/core/EntityResolver.ts
Normal file
48
src/v2/core/EntityResolver.ts
Normal file
|
|
@ -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<string, Map<string, Record<string, unknown>>> = 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<T extends { id: string }>(entityType: string, entities: T[]): void {
|
||||||
|
const typeCache = new Map<string, Record<string, unknown>>();
|
||||||
|
for (const entity of entities) {
|
||||||
|
// Cast to Record for storage while preserving original data
|
||||||
|
typeCache.set(entity.id, entity as unknown as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
this.cache.set(entityType, typeCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an entity by type and ID
|
||||||
|
*/
|
||||||
|
resolve(entityType: string, id: string): Record<string, unknown> | 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { ICalendarEvent } from '../types/CalendarTypes';
|
import { ICalendarEvent } from '../types/CalendarTypes';
|
||||||
import { DateService } from './DateService';
|
import { DateService } from './DateService';
|
||||||
|
import { IEntityResolver } from './IEntityResolver';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Field definition for FilterTemplate
|
* Field definition for FilterTemplate
|
||||||
|
|
@ -9,12 +10,24 @@ interface IFilterField {
|
||||||
derivedFrom?: string;
|
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
|
* FilterTemplate - Bygger nøgler til event-kolonne matching
|
||||||
*
|
*
|
||||||
* ViewConfig definerer hvilke felter (idProperties) der indgår i kolonnens nøgle.
|
* 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.
|
* 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å.
|
* Princip: Kolonnens nøgle-template bestemmer hvad der matches på.
|
||||||
*
|
*
|
||||||
* @see docs/filter-template.md
|
* @see docs/filter-template.md
|
||||||
|
|
@ -22,7 +35,10 @@ interface IFilterField {
|
||||||
export class FilterTemplate {
|
export class FilterTemplate {
|
||||||
private fields: IFilterField[] = [];
|
private fields: IFilterField[] = [];
|
||||||
|
|
||||||
constructor(private dateService: DateService) {}
|
constructor(
|
||||||
|
private dateService: DateService,
|
||||||
|
private entityResolver?: IEntityResolver
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tilføj felt til template
|
* Tilføj felt til template
|
||||||
|
|
@ -34,25 +50,62 @@ export class FilterTemplate {
|
||||||
return this;
|
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
|
* Byg nøgle fra kolonne
|
||||||
* Læser værdier fra column.dataset[idProperty]
|
* Læser værdier fra column.dataset[idProperty]
|
||||||
|
* For dot-notation, uses the property part (resource.teamId → teamId)
|
||||||
*/
|
*/
|
||||||
buildKeyFromColumn(column: HTMLElement): string {
|
buildKeyFromColumn(column: HTMLElement): string {
|
||||||
return this.fields
|
return this.fields
|
||||||
.map(f => column.dataset[f.idProperty] || '')
|
.map(f => {
|
||||||
|
const key = this.getDatasetKey(f.idProperty);
|
||||||
|
return column.dataset[key] || '';
|
||||||
|
})
|
||||||
.join(':');
|
.join(':');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Byg nøgle fra event
|
* Byg nøgle fra event
|
||||||
* Læser værdier fra event[idProperty] eller udleder fra derivedFrom
|
* Læser værdier fra event[idProperty] eller udleder fra derivedFrom
|
||||||
|
* For dot-notation, resolves via EntityResolver
|
||||||
*/
|
*/
|
||||||
buildKeyFromEvent(event: ICalendarEvent): string {
|
buildKeyFromEvent(event: ICalendarEvent): string {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const eventRecord = event as any;
|
const eventRecord = event as any;
|
||||||
return this.fields
|
return this.fields
|
||||||
.map(f => {
|
.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) {
|
if (f.derivedFrom) {
|
||||||
// Udled værdi (f.eks. date fra start)
|
// Udled værdi (f.eks. date fra start)
|
||||||
const sourceValue = eventRecord[f.derivedFrom];
|
const sourceValue = eventRecord[f.derivedFrom];
|
||||||
|
|
@ -66,6 +119,27 @@ export class FilterTemplate {
|
||||||
.join(':');
|
.join(':');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve dot-notation reference via EntityResolver
|
||||||
|
*/
|
||||||
|
private resolveDotNotation(eventRecord: Record<string, unknown>, 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
|
* Match event mod kolonne
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
15
src/v2/core/IEntityResolver.ts
Normal file
15
src/v2/core/IEntityResolver.ts
Normal file
|
|
@ -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<string, unknown> | undefined;
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,8 @@ export interface IRenderContext {
|
||||||
headerContainer: HTMLElement;
|
headerContainer: HTMLElement;
|
||||||
columnContainer: HTMLElement;
|
columnContainer: HTMLElement;
|
||||||
filter: Record<string, string[]>; // { team: ['alpha'], resource: ['alice', 'bob'], date: [...] }
|
filter: Record<string, string[]>; // { team: ['alpha'], resource: ['alice', 'bob'], date: [...] }
|
||||||
|
parentChildMap?: Record<string, string[]>; // { team1: ['EMP001', 'EMP002'], team2: ['EMP003', 'EMP004'] }
|
||||||
|
childType?: string; // The type of the child grouping (e.g., 'resource' when team has belongsTo)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IRenderer {
|
export interface IRenderer {
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ export interface ViewConfig {
|
||||||
export interface GroupingConfig {
|
export interface GroupingConfig {
|
||||||
type: string;
|
type: string;
|
||||||
values: 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')
|
derivedFrom?: string; // Hvis feltet udledes fra anden property (f.eks. 'date' fra 'start')
|
||||||
parentKey?: string;
|
belongsTo?: string; // Parent-child relation (f.eks. 'team.resourceIds')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -124,8 +124,8 @@ export class DemoApp {
|
||||||
return {
|
return {
|
||||||
templateId: 'team',
|
templateId: 'team',
|
||||||
groupings: [
|
groupings: [
|
||||||
{ type: 'team', values: ['team1', 'team2'], idProperty: 'teamId' },
|
{ type: 'team', values: ['team1', 'team2'] },
|
||||||
{ type: 'resource', values: ['res1', 'res2', 'res3'], idProperty: 'resourceId' },
|
{ type: 'resource', values: ['EMP001', 'EMP002', 'EMP003', 'EMP004'], idProperty: 'resourceId', belongsTo: 'team.resourceIds' },
|
||||||
{ type: 'date', values: dates, idProperty: 'date', derivedFrom: 'start' }
|
{ type: 'date', values: dates, idProperty: 'date', derivedFrom: 'start' }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ export class DateRenderer implements IRenderer {
|
||||||
|
|
||||||
// Render dates for HVER resource (eller 1 gang hvis ingen resources)
|
// Render dates for HVER resource (eller 1 gang hvis ingen resources)
|
||||||
const iterations = resourceIds.length || 1;
|
const iterations = resourceIds.length || 1;
|
||||||
|
let columnCount = 0;
|
||||||
|
|
||||||
for (let r = 0; r < iterations; r++) {
|
for (let r = 0; r < iterations; r++) {
|
||||||
const resourceId = resourceIds[r]; // undefined hvis ingen resources
|
const resourceId = resourceIds[r]; // undefined hvis ingen resources
|
||||||
|
|
@ -46,7 +47,15 @@ export class DateRenderer implements IRenderer {
|
||||||
}
|
}
|
||||||
column.innerHTML = '<swp-events-layer></swp-events-layer>';
|
column.innerHTML = '<swp-events-layer></swp-events-layer>';
|
||||||
context.columnContainer.appendChild(column);
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,36 @@ export class ResourceRenderer implements IRenderer {
|
||||||
|
|
||||||
async render(context: IRenderContext): Promise<void> {
|
async render(context: IRenderContext): Promise<void> {
|
||||||
const resourceIds = context.filter['resource'] || [];
|
const resourceIds = context.filter['resource'] || [];
|
||||||
const resources = await this.resourceService.getByIds(resourceIds);
|
|
||||||
const dateCount = context.filter['date']?.length || 1;
|
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');
|
const header = document.createElement('swp-resource-header');
|
||||||
header.dataset.resourceId = resource.id;
|
header.dataset.resourceId = resource.id;
|
||||||
header.textContent = resource.displayName;
|
header.textContent = resource.displayName;
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,31 @@
|
||||||
import { IRenderer, IRenderContext } from '../../core/IGroupingRenderer';
|
import { IRenderer, IRenderContext } from '../../core/IGroupingRenderer';
|
||||||
|
import { TeamService } from '../../storage/teams/TeamService';
|
||||||
interface Team {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
resourceIds: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TeamRenderer implements IRenderer {
|
export class TeamRenderer implements IRenderer {
|
||||||
readonly type = 'team';
|
readonly type = 'team';
|
||||||
|
|
||||||
// Hardcoded data
|
constructor(private teamService: TeamService) {}
|
||||||
private teams: Team[] = [
|
|
||||||
{ id: 'team1', name: 'Team Alpha', resourceIds: ['res1', 'res2'] },
|
|
||||||
{ id: 'team2', name: 'Team Beta', resourceIds: ['res3'] }
|
|
||||||
];
|
|
||||||
|
|
||||||
render(context: IRenderContext): void {
|
async render(context: IRenderContext): Promise<void> {
|
||||||
const allowedIds = context.filter['team'] || [];
|
const allowedIds = context.filter[this.type] || [];
|
||||||
const filteredTeams = this.teams.filter(t => allowedIds.includes(t.id));
|
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 dateCount = context.filter['date']?.length || 1;
|
||||||
const resourceIds = context.filter['resource'] || [];
|
|
||||||
|
|
||||||
// Render ALLE team headers først
|
// Get child filter values using childType from context (not hardcoded)
|
||||||
for (const team of filteredTeams) {
|
const childIds = context.childType ? context.filter[context.childType] || [] : [];
|
||||||
// Tæl resources der tilhører dette team OG er i filter
|
|
||||||
const teamResourceCount = team.resourceIds.filter(id => resourceIds.includes(id)).length;
|
// Render team headers
|
||||||
const colspan = teamResourceCount * dateCount;
|
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');
|
const header = document.createElement('swp-team-header');
|
||||||
header.dataset.teamId = team.id;
|
header.dataset.teamId = team.id;
|
||||||
|
|
|
||||||
55
src/v2/repositories/MockTeamRepository.ts
Normal file
55
src/v2/repositories/MockTeamRepository.ts
Normal file
|
|
@ -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<ITeam> {
|
||||||
|
public readonly entityType: EntityType = 'Team';
|
||||||
|
private readonly dataUrl = 'data/mock-teams.json';
|
||||||
|
|
||||||
|
public async fetchAll(): Promise<ITeam[]> {
|
||||||
|
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<ITeam> {
|
||||||
|
throw new Error('MockTeamRepository does not support sendCreate. Mock data is read-only.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendUpdate(_id: string, _updates: Partial<ITeam>): Promise<ITeam> {
|
||||||
|
throw new Error('MockTeamRepository does not support sendUpdate. Mock data is read-only.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendDelete(_id: string): Promise<void> {
|
||||||
|
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
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 = 2;
|
private static readonly DB_VERSION = 3;
|
||||||
|
|
||||||
private db: IDBDatabase | null = null;
|
private db: IDBDatabase | null = null;
|
||||||
private initialized: boolean = false;
|
private initialized: boolean = false;
|
||||||
|
|
|
||||||
44
src/v2/storage/teams/TeamService.ts
Normal file
44
src/v2/storage/teams/TeamService.ts
Normal file
|
|
@ -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<ITeam> {
|
||||||
|
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<ITeam[]> {
|
||||||
|
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<Record<string, string>> {
|
||||||
|
const teams = await this.getAll();
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const team of teams) {
|
||||||
|
for (const resourceId of team.resourceIds) {
|
||||||
|
map[resourceId] = team.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/v2/storage/teams/TeamStore.ts
Normal file
13
src/v2/storage/teams/TeamStore.ts
Normal file
|
|
@ -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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,7 @@ import { IWeekSchedule } from './ScheduleTypes';
|
||||||
|
|
||||||
export type SyncStatus = 'synced' | 'pending' | 'error';
|
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
|
* CalendarEventType - Used by ICalendarEvent.type
|
||||||
|
|
@ -118,6 +118,13 @@ export interface IResource extends ISync {
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Team types
|
||||||
|
export interface ITeam extends ISync {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
resourceIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
// Booking types
|
// Booking types
|
||||||
export type BookingStatus =
|
export type BookingStatus =
|
||||||
| 'created'
|
| 'created'
|
||||||
|
|
|
||||||
14
wwwroot/data/mock-teams.json
Normal file
14
wwwroot/data/mock-teams.json
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "team1",
|
||||||
|
"name": "Team Alpha",
|
||||||
|
"resourceIds": ["EMP001", "EMP002"],
|
||||||
|
"syncStatus": "synced"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "team2",
|
||||||
|
"name": "Team Beta",
|
||||||
|
"resourceIds": ["EMP003", "EMP004"],
|
||||||
|
"syncStatus": "synced"
|
||||||
|
}
|
||||||
|
]
|
||||||
Loading…
Add table
Add a link
Reference in a new issue