Moving away from Azure Devops #1
13 changed files with 751 additions and 38 deletions
342
docs/filter-template-spec.md
Normal file
342
docs/filter-template-spec.md
Normal file
|
|
@ -0,0 +1,342 @@
|
||||||
|
# FilterTemplate & Grouping System Specification
|
||||||
|
|
||||||
|
> **Version:** 1.0
|
||||||
|
> **Dato:** 2025-12-15
|
||||||
|
> **Status:** Godkendt
|
||||||
|
|
||||||
|
## Formål
|
||||||
|
|
||||||
|
Dette dokument specificerer hvordan kalenderen matcher events til kolonner i alle view-typer (Simple, Dag, Resource, Team, Department). Formålet er at sikre konsistent opførsel og undgå fremtidige hacks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kerneprincipper
|
||||||
|
|
||||||
|
### 1. Én sandhedskilde for key-format
|
||||||
|
|
||||||
|
**FilterTemplate** er den ENESTE kilde til key-format for event-kolonne matching.
|
||||||
|
|
||||||
|
```
|
||||||
|
KORREKT: filterTemplate.buildKeyFromColumn(column)
|
||||||
|
KORREKT: filterTemplate.buildKeyFromEvent(event)
|
||||||
|
FORKERT: column.dataset.columnKey (bruger DateService-format)
|
||||||
|
FORKERT: Manuel key-konstruktion med string concatenation
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Fields-rækkefølge bestemmer key-format
|
||||||
|
|
||||||
|
Keys bygges fra `fields` array i samme rækkefølge som defineret i ViewConfig groupings.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ViewConfig groupings: [resource, date]
|
||||||
|
// Resultat: "EMP001:2025-12-09"
|
||||||
|
|
||||||
|
// ViewConfig groupings: [date, resource]
|
||||||
|
// Resultat: "2025-12-09:EMP001"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Kolonner og events bruger SAMME template
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Opret template fra ViewConfig
|
||||||
|
const filterTemplate = new FilterTemplate(dateService);
|
||||||
|
for (const grouping of viewConfig.groupings) {
|
||||||
|
if (grouping.idProperty) {
|
||||||
|
filterTemplate.addField(grouping.idProperty, grouping.derivedFrom);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Brug til BÅDE kolonner og events
|
||||||
|
const columnKey = filterTemplate.buildKeyFromColumn(column);
|
||||||
|
const eventKey = filterTemplate.buildKeyFromEvent(event);
|
||||||
|
const matches = columnKey === eventKey;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Kontrakt
|
||||||
|
|
||||||
|
### FilterTemplate
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class FilterTemplate {
|
||||||
|
constructor(dateService: DateService, entityResolver?: IEntityResolver)
|
||||||
|
|
||||||
|
// Tilføj felt til template
|
||||||
|
addField(idProperty: string, derivedFrom?: string): this
|
||||||
|
|
||||||
|
// Byg key fra kolonne (læser fra dataset)
|
||||||
|
buildKeyFromColumn(column: HTMLElement): string
|
||||||
|
|
||||||
|
// Byg key fra event (læser fra event properties)
|
||||||
|
buildKeyFromEvent(event: ICalendarEvent): string
|
||||||
|
|
||||||
|
// Convenience: matcher event mod kolonne
|
||||||
|
matches(event: ICalendarEvent, column: HTMLElement): boolean
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Field Definition
|
||||||
|
|
||||||
|
| Parameter | Type | Beskrivelse |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| `idProperty` | `string` | Property-navn på event ELLER dot-notation |
|
||||||
|
| `derivedFrom` | `string?` | Kilde-property hvis værdi skal udledes |
|
||||||
|
|
||||||
|
### Dot-Notation
|
||||||
|
|
||||||
|
For hierarkiske relationer bruges dot-notation:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
idProperty: 'resource.teamId'
|
||||||
|
// Betyder: event.resourceId → opslag i resource → teamId
|
||||||
|
```
|
||||||
|
|
||||||
|
**Convention:** `{entityType}.{property}` → foreignKey er `{entityType}Id`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kolonne Dataset Krav
|
||||||
|
|
||||||
|
Kolonner (`swp-day-column`) SKAL have dataset-attributter for alle felter i template:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Simple view (kun date) -->
|
||||||
|
<swp-day-column data-date="2025-12-09"></swp-day-column>
|
||||||
|
|
||||||
|
<!-- Resource view (resource + date) -->
|
||||||
|
<swp-day-column
|
||||||
|
data-date="2025-12-09"
|
||||||
|
data-resource-id="EMP001">
|
||||||
|
</swp-day-column>
|
||||||
|
|
||||||
|
<!-- Team view (team + resource + date) -->
|
||||||
|
<swp-day-column
|
||||||
|
data-date="2025-12-09"
|
||||||
|
data-resource-id="EMP001"
|
||||||
|
data-team-id="team-1">
|
||||||
|
</swp-day-column>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dataset Key Mapping
|
||||||
|
|
||||||
|
| idProperty | Dataset Key | Eksempel |
|
||||||
|
|------------|-------------|----------|
|
||||||
|
| `date` | `data-date` | `"2025-12-09"` |
|
||||||
|
| `resourceId` | `data-resource-id` | `"EMP001"` |
|
||||||
|
| `resource.teamId` | `data-team-id` | `"team-1"` |
|
||||||
|
|
||||||
|
**Regel:** Dot-notation bruger sidste segment som dataset-key.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Event Property Krav
|
||||||
|
|
||||||
|
Events SKAL have properties der matcher template fields:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ICalendarEvent {
|
||||||
|
id: string;
|
||||||
|
start: Date; // derivedFrom: 'start' → date key
|
||||||
|
end: Date;
|
||||||
|
resourceId?: string; // Direkte match
|
||||||
|
// ... andre properties
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Derived Values
|
||||||
|
|
||||||
|
| idProperty | derivedFrom | Transformation |
|
||||||
|
|------------|-------------|----------------|
|
||||||
|
| `date` | `start` | `Date → "YYYY-MM-DD"` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ViewConfig Groupings
|
||||||
|
|
||||||
|
### Struktur
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface GroupingConfig {
|
||||||
|
type: string; // 'date', 'resource', 'team', 'department'
|
||||||
|
values: string[]; // Synlige værdier
|
||||||
|
idProperty?: string; // Felt til key-matching
|
||||||
|
derivedFrom?: string; // Kilde hvis udledt
|
||||||
|
belongsTo?: string; // Parent-child relation
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Eksempler
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Simple view
|
||||||
|
groupings: [
|
||||||
|
{ type: 'date', values: ['2025-12-09', ...], idProperty: 'date', derivedFrom: 'start' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Resource view
|
||||||
|
groupings: [
|
||||||
|
{ type: 'resource', values: ['EMP001', 'EMP002'], idProperty: 'resourceId' },
|
||||||
|
{ type: 'date', values: ['2025-12-09', ...], idProperty: 'date', derivedFrom: 'start' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Team view
|
||||||
|
groupings: [
|
||||||
|
{ type: 'team', values: ['team-1', 'team-2'], idProperty: 'resource.teamId' },
|
||||||
|
{ type: 'resource', values: ['EMP001', ...], idProperty: 'resourceId', belongsTo: 'team.resourceIds' },
|
||||||
|
{ type: 'date', values: ['2025-12-09', ...], idProperty: 'date', derivedFrom: 'start' }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BelongsTo Resolution
|
||||||
|
|
||||||
|
### Formål
|
||||||
|
|
||||||
|
`belongsTo` definerer parent-child relationer for nested groupings.
|
||||||
|
|
||||||
|
### Syntax
|
||||||
|
|
||||||
|
```
|
||||||
|
belongsTo: '{parentEntityType}.{childArrayProperty}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Eksempel
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Team har resourceIds array
|
||||||
|
{ type: 'resource', belongsTo: 'team.resourceIds' }
|
||||||
|
|
||||||
|
// Resolver:
|
||||||
|
// 1. Hent team entities fra filter['team']
|
||||||
|
// 2. For hver team, læs team.resourceIds
|
||||||
|
// 3. Byg map: { 'team-1': ['EMP001', 'EMP002'], 'team-2': ['EMP003'] }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Implementering
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// CalendarOrchestrator.resolveBelongsTo()
|
||||||
|
const [entityType, property] = belongsTo.split('.');
|
||||||
|
const service = entityServices.find(s => s.entityType === entityType);
|
||||||
|
const entities = await service.getAll();
|
||||||
|
// Byg parent-child map
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HeaderDrawerRenderer Regler
|
||||||
|
|
||||||
|
### Key Matching for AllDay Events
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// KORREKT: Brug FilterTemplate
|
||||||
|
private getVisibleColumnKeysFromDOM(): string[] {
|
||||||
|
const columns = document.querySelectorAll('swp-day-column');
|
||||||
|
return Array.from(columns).map(col =>
|
||||||
|
this.filterTemplate.buildKeyFromColumn(col as HTMLElement)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// FORKERT: Læs dataset.columnKey direkte
|
||||||
|
// (bruger DateService-format som ikke matcher FilterTemplate)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Layout Beregning
|
||||||
|
|
||||||
|
1. Hent synlige columnKeys via `getVisibleColumnKeysFromDOM()`
|
||||||
|
2. For hver event, byg key via `filterTemplate.buildKeyFromEvent(event)`
|
||||||
|
3. Find kolonne-index via `columnKeys.indexOf(eventKey)`
|
||||||
|
4. Beregn row via track-algoritme
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anti-Patterns (UNDGÅ)
|
||||||
|
|
||||||
|
### 1. Manuel Key Konstruktion
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// FORKERT
|
||||||
|
const key = `${resourceId}:${dateStr}`;
|
||||||
|
|
||||||
|
// KORREKT
|
||||||
|
const key = filterTemplate.buildKeyFromEvent(event);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Direkte Dataset Læsning for Matching
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// FORKERT
|
||||||
|
const columnKey = column.dataset.columnKey;
|
||||||
|
|
||||||
|
// KORREKT
|
||||||
|
const columnKey = filterTemplate.buildKeyFromColumn(column);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Hardcoded Field Order
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// FORKERT
|
||||||
|
const key = [event.resourceId, dateStr].join(':');
|
||||||
|
|
||||||
|
// KORREKT
|
||||||
|
// Lad FilterTemplate håndtere rækkefølge fra ViewConfig
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Separate Key-Formater
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// FORKERT: DateService til kolonner, FilterTemplate til events
|
||||||
|
DateService.buildColumnKey(segments) // "2025-12-09:EMP001"
|
||||||
|
FilterTemplate.buildKeyFromEvent(e) // "EMP001:2025-12-09"
|
||||||
|
|
||||||
|
// KORREKT: FilterTemplate til begge
|
||||||
|
FilterTemplate.buildKeyFromColumn(col)
|
||||||
|
FilterTemplate.buildKeyFromEvent(event)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testcases
|
||||||
|
|
||||||
|
### TC1: Simple View Matching
|
||||||
|
|
||||||
|
```
|
||||||
|
Given: ViewConfig med [date] grouping
|
||||||
|
When: Event har start=2025-12-09
|
||||||
|
Then: Event matcher kolonne med data-date="2025-12-09"
|
||||||
|
```
|
||||||
|
|
||||||
|
### TC2: Resource View Matching
|
||||||
|
|
||||||
|
```
|
||||||
|
Given: ViewConfig med [resource, date] groupings
|
||||||
|
When: Event har resourceId=EMP001, start=2025-12-09
|
||||||
|
Then: Event matcher kolonne med data-resource-id="EMP001" OG data-date="2025-12-09"
|
||||||
|
```
|
||||||
|
|
||||||
|
### TC3: Team View Matching
|
||||||
|
|
||||||
|
```
|
||||||
|
Given: ViewConfig med [team, resource, date] groupings
|
||||||
|
Resource EMP001 tilhører team-1
|
||||||
|
When: Event har resourceId=EMP001, start=2025-12-09
|
||||||
|
Then: Event matcher kolonne med data-team-id="team-1" OG data-resource-id="EMP001" OG data-date="2025-12-09"
|
||||||
|
```
|
||||||
|
|
||||||
|
### TC4: Multi-Day Event
|
||||||
|
|
||||||
|
```
|
||||||
|
Given: Event spænder 2025-12-09 til 2025-12-11
|
||||||
|
When: HeaderDrawerRenderer beregner layout
|
||||||
|
Then: Event vises fra kolonne 09 til kolonne 11 (inclusive)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ændringslog
|
||||||
|
|
||||||
|
| Version | Dato | Ændring |
|
||||||
|
|---------|------|---------|
|
||||||
|
| 1.0 | 2025-12-15 | Initial specifikation |
|
||||||
|
|
@ -35,6 +35,9 @@ import { TeamStore } from './storage/teams/TeamStore';
|
||||||
import { TeamService } from './storage/teams/TeamService';
|
import { TeamService } from './storage/teams/TeamService';
|
||||||
import { DepartmentStore } from './storage/departments/DepartmentStore';
|
import { DepartmentStore } from './storage/departments/DepartmentStore';
|
||||||
import { DepartmentService } from './storage/departments/DepartmentService';
|
import { DepartmentService } from './storage/departments/DepartmentService';
|
||||||
|
import { SettingsStore } from './storage/settings/SettingsStore';
|
||||||
|
import { SettingsService } from './storage/settings/SettingsService';
|
||||||
|
import { ITenantSettings } from './types/SettingsTypes';
|
||||||
|
|
||||||
// Audit
|
// Audit
|
||||||
import { AuditStore } from './storage/audit/AuditStore';
|
import { AuditStore } from './storage/audit/AuditStore';
|
||||||
|
|
@ -50,6 +53,7 @@ import { MockCustomerRepository } from './repositories/MockCustomerRepository';
|
||||||
import { MockAuditRepository } from './repositories/MockAuditRepository';
|
import { MockAuditRepository } from './repositories/MockAuditRepository';
|
||||||
import { MockTeamRepository } from './repositories/MockTeamRepository';
|
import { MockTeamRepository } from './repositories/MockTeamRepository';
|
||||||
import { MockDepartmentRepository } from './repositories/MockDepartmentRepository';
|
import { MockDepartmentRepository } from './repositories/MockDepartmentRepository';
|
||||||
|
import { MockSettingsRepository } from './repositories/MockSettingsRepository';
|
||||||
|
|
||||||
// Workers
|
// Workers
|
||||||
import { DataSeeder } from './workers/DataSeeder';
|
import { DataSeeder } from './workers/DataSeeder';
|
||||||
|
|
@ -113,6 +117,7 @@ export function createV2Container(): Container {
|
||||||
builder.registerType(DepartmentStore).as<IStore>();
|
builder.registerType(DepartmentStore).as<IStore>();
|
||||||
builder.registerType(ScheduleOverrideStore).as<IStore>();
|
builder.registerType(ScheduleOverrideStore).as<IStore>();
|
||||||
builder.registerType(AuditStore).as<IStore>();
|
builder.registerType(AuditStore).as<IStore>();
|
||||||
|
builder.registerType(SettingsStore).as<IStore>();
|
||||||
|
|
||||||
// Entity services (for DataSeeder polymorphic array)
|
// Entity services (for DataSeeder polymorphic array)
|
||||||
builder.registerType(EventService).as<IEntityService<ICalendarEvent>>();
|
builder.registerType(EventService).as<IEntityService<ICalendarEvent>>();
|
||||||
|
|
@ -139,6 +144,10 @@ export function createV2Container(): Container {
|
||||||
builder.registerType(DepartmentService).as<IEntityService<ISync>>();
|
builder.registerType(DepartmentService).as<IEntityService<ISync>>();
|
||||||
builder.registerType(DepartmentService).as<DepartmentService>();
|
builder.registerType(DepartmentService).as<DepartmentService>();
|
||||||
|
|
||||||
|
builder.registerType(SettingsService).as<IEntityService<ITenantSettings>>();
|
||||||
|
builder.registerType(SettingsService).as<IEntityService<ISync>>();
|
||||||
|
builder.registerType(SettingsService).as<SettingsService>();
|
||||||
|
|
||||||
// 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>>();
|
||||||
|
|
@ -161,6 +170,9 @@ export function createV2Container(): Container {
|
||||||
builder.registerType(MockDepartmentRepository).as<IApiRepository<IDepartment>>();
|
builder.registerType(MockDepartmentRepository).as<IApiRepository<IDepartment>>();
|
||||||
builder.registerType(MockDepartmentRepository).as<IApiRepository<ISync>>();
|
builder.registerType(MockDepartmentRepository).as<IApiRepository<ISync>>();
|
||||||
|
|
||||||
|
builder.registerType(MockSettingsRepository).as<IApiRepository<ITenantSettings>>();
|
||||||
|
builder.registerType(MockSettingsRepository).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>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,21 @@ export class DateService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get dates for specific weekdays within a week
|
||||||
|
* @param offset - Week offset from base date (0 = current week)
|
||||||
|
* @param workDays - Array of ISO weekday numbers (1=Monday, 7=Sunday)
|
||||||
|
* @returns Array of date strings in YYYY-MM-DD format
|
||||||
|
*/
|
||||||
|
getWorkWeekDates(offset: number, workDays: number[]): string[] {
|
||||||
|
const monday = this.baseDate.startOf('week').add(1, 'day').add(offset, 'week');
|
||||||
|
return workDays.map(isoDay => {
|
||||||
|
// ISO: 1=Monday, 7=Sunday → days from Monday: 0-6
|
||||||
|
const daysFromMonday = isoDay === 7 ? 6 : isoDay - 1;
|
||||||
|
return monday.add(daysFromMonday, 'day').format('YYYY-MM-DD');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// FORMATTING
|
// FORMATTING
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,15 @@ import { ResizeManager } from '../managers/ResizeManager';
|
||||||
import { EventPersistenceManager } from '../managers/EventPersistenceManager';
|
import { EventPersistenceManager } from '../managers/EventPersistenceManager';
|
||||||
import { HeaderDrawerRenderer } from '../features/headerdrawer/HeaderDrawerRenderer';
|
import { HeaderDrawerRenderer } from '../features/headerdrawer/HeaderDrawerRenderer';
|
||||||
import { AuditService } from '../storage/audit/AuditService';
|
import { AuditService } from '../storage/audit/AuditService';
|
||||||
|
import { SettingsService } from '../storage/settings/SettingsService';
|
||||||
|
import { IWorkweekPreset } from '../types/SettingsTypes';
|
||||||
|
|
||||||
export class DemoApp {
|
export class DemoApp {
|
||||||
private animator!: NavigationAnimator;
|
private animator!: NavigationAnimator;
|
||||||
private container!: HTMLElement;
|
private container!: HTMLElement;
|
||||||
private weekOffset = 0;
|
private weekOffset = 0;
|
||||||
private currentView: 'day' | 'simple' | 'resource' | 'team' | 'department' = 'simple';
|
private currentView: 'day' | 'simple' | 'resource' | 'team' | 'department' = 'simple';
|
||||||
|
private workweekPreset: IWorkweekPreset | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private orchestrator: CalendarOrchestrator,
|
private orchestrator: CalendarOrchestrator,
|
||||||
|
|
@ -33,7 +36,8 @@ export class DemoApp {
|
||||||
private resizeManager: ResizeManager,
|
private resizeManager: ResizeManager,
|
||||||
private headerDrawerRenderer: HeaderDrawerRenderer,
|
private headerDrawerRenderer: HeaderDrawerRenderer,
|
||||||
private eventPersistenceManager: EventPersistenceManager,
|
private eventPersistenceManager: EventPersistenceManager,
|
||||||
private auditService: AuditService
|
private auditService: AuditService,
|
||||||
|
private settingsService: SettingsService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async init(): Promise<void> {
|
async init(): Promise<void> {
|
||||||
|
|
@ -48,6 +52,10 @@ export class DemoApp {
|
||||||
await this.dataSeeder.seedIfEmpty();
|
await this.dataSeeder.seedIfEmpty();
|
||||||
console.log('[DemoApp] Data seeding complete');
|
console.log('[DemoApp] Data seeding complete');
|
||||||
|
|
||||||
|
// Load default workweek preset from settings
|
||||||
|
this.workweekPreset = await this.settingsService.getDefaultWorkweekPreset();
|
||||||
|
console.log('[DemoApp] Workweek preset loaded:', this.workweekPreset?.id);
|
||||||
|
|
||||||
this.container = document.querySelector('swp-calendar-container') as HTMLElement;
|
this.container = document.querySelector('swp-calendar-container') as HTMLElement;
|
||||||
|
|
||||||
// NavigationAnimator har DOM-dependencies - tilladt med new
|
// NavigationAnimator har DOM-dependencies - tilladt med new
|
||||||
|
|
@ -90,7 +98,9 @@ export class DemoApp {
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildViewConfig(): ViewConfig {
|
private buildViewConfig(): ViewConfig {
|
||||||
const dates = this.dateService.getWeekDates(this.weekOffset, 3);
|
// Use workweek preset to determine which days to show
|
||||||
|
const workDays = this.workweekPreset?.workDays || [1, 2, 3, 4, 5]; // Fallback to Mon-Fri
|
||||||
|
const dates = this.dateService.getWorkWeekDates(this.weekOffset, workDays);
|
||||||
const today = this.dateService.getWeekDates(this.weekOffset, 1);
|
const today = this.dateService.getWeekDates(this.weekOffset, 1);
|
||||||
|
|
||||||
switch (this.currentView) {
|
switch (this.currentView) {
|
||||||
|
|
@ -155,29 +165,32 @@ export class DemoApp {
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupViewSwitching(): void {
|
private setupViewSwitching(): void {
|
||||||
document.getElementById('btn-day')?.addEventListener('click', () => {
|
// View chip buttons
|
||||||
this.currentView = 'day';
|
const chips = document.querySelectorAll('.view-chip');
|
||||||
this.render();
|
chips.forEach(chip => {
|
||||||
|
chip.addEventListener('click', () => {
|
||||||
|
// Update active state
|
||||||
|
chips.forEach(c => c.classList.remove('active'));
|
||||||
|
chip.classList.add('active');
|
||||||
|
|
||||||
|
// Switch view
|
||||||
|
const view = (chip as HTMLElement).dataset.view as typeof this.currentView;
|
||||||
|
if (view) {
|
||||||
|
this.currentView = view;
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('btn-simple')?.addEventListener('click', () => {
|
// Workweek preset dropdown
|
||||||
this.currentView = 'simple';
|
const workweekSelect = document.getElementById('workweek-select') as HTMLSelectElement;
|
||||||
this.render();
|
workweekSelect?.addEventListener('change', async () => {
|
||||||
});
|
const presetId = workweekSelect.value;
|
||||||
|
const preset = await this.settingsService.getWorkweekPreset(presetId);
|
||||||
document.getElementById('btn-resource')?.addEventListener('click', () => {
|
if (preset) {
|
||||||
this.currentView = 'resource';
|
this.workweekPreset = preset;
|
||||||
this.render();
|
this.render();
|
||||||
});
|
}
|
||||||
|
|
||||||
document.getElementById('btn-team')?.addEventListener('click', () => {
|
|
||||||
this.currentView = 'team';
|
|
||||||
this.render();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('btn-department')?.addEventListener('click', () => {
|
|
||||||
this.currentView = 'department';
|
|
||||||
this.render();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
47
src/v2/repositories/MockSettingsRepository.ts
Normal file
47
src/v2/repositories/MockSettingsRepository.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { EntityType } from '../types/CalendarTypes';
|
||||||
|
import { ITenantSettings } from '../types/SettingsTypes';
|
||||||
|
import { IApiRepository } from './IApiRepository';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MockSettingsRepository - Loads tenant settings from local JSON file
|
||||||
|
*
|
||||||
|
* Settings is a single document, but we wrap it in an array to match
|
||||||
|
* the IApiRepository interface used by DataSeeder.
|
||||||
|
*/
|
||||||
|
export class MockSettingsRepository implements IApiRepository<ITenantSettings> {
|
||||||
|
public readonly entityType: EntityType = 'Settings';
|
||||||
|
private readonly dataUrl = 'data/tenant-settings.json';
|
||||||
|
|
||||||
|
public async fetchAll(): Promise<ITenantSettings[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.dataUrl);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load tenant settings: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawData = await response.json();
|
||||||
|
// Ensure syncStatus is set
|
||||||
|
const settings: ITenantSettings = {
|
||||||
|
...rawData,
|
||||||
|
syncStatus: rawData.syncStatus || 'synced'
|
||||||
|
};
|
||||||
|
return [settings];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load tenant settings:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendCreate(_settings: ITenantSettings): Promise<ITenantSettings> {
|
||||||
|
throw new Error('MockSettingsRepository does not support sendCreate. Mock data is read-only.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendUpdate(_id: string, _updates: Partial<ITenantSettings>): Promise<ITenantSettings> {
|
||||||
|
throw new Error('MockSettingsRepository does not support sendUpdate. Mock data is read-only.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendDelete(_id: string): Promise<void> {
|
||||||
|
throw new Error('MockSettingsRepository does not support sendDelete. Mock data is read-only.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 = 3;
|
private static readonly DB_VERSION = 4;
|
||||||
|
|
||||||
private db: IDBDatabase | null = null;
|
private db: IDBDatabase | null = null;
|
||||||
private initialized: boolean = false;
|
private initialized: boolean = false;
|
||||||
|
|
|
||||||
60
src/v2/storage/settings/SettingsService.ts
Normal file
60
src/v2/storage/settings/SettingsService.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { EntityType, IEventBus } from '../../types/CalendarTypes';
|
||||||
|
import { ITenantSettings, IWorkweekPreset } from '../../types/SettingsTypes';
|
||||||
|
import { SettingsStore } from './SettingsStore';
|
||||||
|
import { BaseEntityService } from '../BaseEntityService';
|
||||||
|
import { IndexedDBContext } from '../IndexedDBContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default settings ID - single document per tenant
|
||||||
|
*/
|
||||||
|
const TENANT_SETTINGS_ID = 'tenant-settings';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SettingsService - CRUD operations for tenant settings
|
||||||
|
*
|
||||||
|
* Settings are stored as a single document with sections.
|
||||||
|
* This service provides convenience methods for accessing specific sections.
|
||||||
|
*/
|
||||||
|
export class SettingsService extends BaseEntityService<ITenantSettings> {
|
||||||
|
readonly storeName = SettingsStore.STORE_NAME;
|
||||||
|
readonly entityType: EntityType = 'Settings';
|
||||||
|
|
||||||
|
constructor(context: IndexedDBContext, eventBus: IEventBus) {
|
||||||
|
super(context, eventBus);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the tenant settings document
|
||||||
|
* Returns null if not yet loaded from backend
|
||||||
|
*/
|
||||||
|
async getSettings(): Promise<ITenantSettings | null> {
|
||||||
|
return this.get(TENANT_SETTINGS_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get workweek preset by ID
|
||||||
|
*/
|
||||||
|
async getWorkweekPreset(presetId: string): Promise<IWorkweekPreset | null> {
|
||||||
|
const settings = await this.getSettings();
|
||||||
|
if (!settings) return null;
|
||||||
|
return settings.workweek.presets[presetId] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the default workweek preset
|
||||||
|
*/
|
||||||
|
async getDefaultWorkweekPreset(): Promise<IWorkweekPreset | null> {
|
||||||
|
const settings = await this.getSettings();
|
||||||
|
if (!settings) return null;
|
||||||
|
return settings.workweek.presets[settings.workweek.defaultPreset] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all available workweek presets
|
||||||
|
*/
|
||||||
|
async getWorkweekPresets(): Promise<IWorkweekPreset[]> {
|
||||||
|
const settings = await this.getSettings();
|
||||||
|
if (!settings) return [];
|
||||||
|
return Object.values(settings.workweek.presets);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/v2/storage/settings/SettingsStore.ts
Normal file
16
src/v2/storage/settings/SettingsStore.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { IStore } from '../IStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SettingsStore - IndexedDB ObjectStore definition for tenant settings
|
||||||
|
*
|
||||||
|
* Single store for all settings sections. Settings are stored as one document
|
||||||
|
* per tenant with id='tenant-settings'.
|
||||||
|
*/
|
||||||
|
export class SettingsStore implements IStore {
|
||||||
|
static readonly STORE_NAME = 'settings';
|
||||||
|
readonly storeName = SettingsStore.STORE_NAME;
|
||||||
|
|
||||||
|
create(db: IDBDatabase): void {
|
||||||
|
db.createObjectStore(SettingsStore.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' | 'Team' | 'Department' | 'Audit';
|
export type EntityType = 'Event' | 'Booking' | 'Customer' | 'Resource' | 'Team' | 'Department' | 'Audit' | 'Settings';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CalendarEventType - Used by ICalendarEvent.type
|
* CalendarEventType - Used by ICalendarEvent.type
|
||||||
|
|
|
||||||
72
src/v2/types/SettingsTypes.ts
Normal file
72
src/v2/types/SettingsTypes.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
/**
|
||||||
|
* Tenant Settings Type Definitions
|
||||||
|
*
|
||||||
|
* Settings are tenant-specific configuration that comes from the backend
|
||||||
|
* and is stored in IndexedDB for offline access.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ISync } from './CalendarTypes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Workweek preset - defines which ISO weekdays to display
|
||||||
|
* ISO: 1=Monday, 7=Sunday
|
||||||
|
*/
|
||||||
|
export interface IWorkweekPreset {
|
||||||
|
id: string;
|
||||||
|
workDays: number[];
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Workweek settings section
|
||||||
|
*/
|
||||||
|
export interface IWorkweekSettings {
|
||||||
|
presets: Record<string, IWorkweekPreset>;
|
||||||
|
defaultPreset: string;
|
||||||
|
firstDayOfWeek: number; // ISO: 1=Monday
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grid display settings section
|
||||||
|
*/
|
||||||
|
export interface IGridSettings {
|
||||||
|
dayStartHour: number;
|
||||||
|
dayEndHour: number;
|
||||||
|
workStartHour: number;
|
||||||
|
workEndHour: number;
|
||||||
|
hourHeight: number;
|
||||||
|
snapInterval: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time format settings section
|
||||||
|
*/
|
||||||
|
export interface ITimeFormatSettings {
|
||||||
|
timezone: string;
|
||||||
|
locale: string;
|
||||||
|
use24HourFormat: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* View settings section
|
||||||
|
*/
|
||||||
|
export interface IViewSettings {
|
||||||
|
availableViews: string[];
|
||||||
|
defaultView: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ITenantSettings - Complete tenant configuration
|
||||||
|
*
|
||||||
|
* Single document stored in IndexedDB 'settings' store.
|
||||||
|
* Sections can be extended as needed without schema changes.
|
||||||
|
*/
|
||||||
|
export interface ITenantSettings extends ISync {
|
||||||
|
id: string;
|
||||||
|
lastModified?: string;
|
||||||
|
|
||||||
|
workweek: IWorkweekSettings;
|
||||||
|
grid: IGridSettings;
|
||||||
|
timeFormat: ITimeFormatSettings;
|
||||||
|
views: IViewSettings;
|
||||||
|
}
|
||||||
|
|
@ -16,20 +16,78 @@ swp-calendar {
|
||||||
/* Nav */
|
/* Nav */
|
||||||
swp-calendar-nav {
|
swp-calendar-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 12px;
|
||||||
padding: 12px 16px;
|
padding: 8px 16px;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* View switcher - small chips */
|
||||||
|
swp-view-switcher {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
background: var(--color-background-alt);
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-chip {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Workweek dropdown */
|
||||||
|
.workweek-dropdown {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover { border-color: var(--color-text-secondary); }
|
||||||
|
&:focus { outline: 2px solid var(--color-primary); outline-offset: 1px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation group */
|
||||||
|
swp-nav-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
swp-nav-button {
|
swp-nav-button {
|
||||||
padding: 8px 16px;
|
padding: 6px 12px;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
&:hover { background: var(--color-background-hover); }
|
&:hover { background: var(--color-background-hover); }
|
||||||
|
|
||||||
|
&.btn-small {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
swp-week-info {
|
swp-week-info {
|
||||||
|
|
@ -38,11 +96,12 @@ swp-week-info {
|
||||||
|
|
||||||
swp-week-number {
|
swp-week-number {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
swp-date-range {
|
swp-date-range {
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -70,6 +129,8 @@ swp-time-axis {
|
||||||
|
|
||||||
swp-header-spacer {
|
swp-header-spacer {
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
background: var(--color-surface);
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
swp-header-drawer {
|
swp-header-drawer {
|
||||||
|
|
|
||||||
57
wwwroot/data/tenant-settings.json
Normal file
57
wwwroot/data/tenant-settings.json
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
{
|
||||||
|
"id": "tenant-settings",
|
||||||
|
"syncStatus": "synced",
|
||||||
|
"lastModified": "2025-12-15T10:00:00Z",
|
||||||
|
|
||||||
|
"workweek": {
|
||||||
|
"presets": {
|
||||||
|
"standard": {
|
||||||
|
"id": "standard",
|
||||||
|
"workDays": [1, 2, 3, 4, 5],
|
||||||
|
"label": "Man-Fre"
|
||||||
|
},
|
||||||
|
"compressed": {
|
||||||
|
"id": "compressed",
|
||||||
|
"workDays": [1, 2, 3, 4],
|
||||||
|
"label": "Man-Tor"
|
||||||
|
},
|
||||||
|
"midweek": {
|
||||||
|
"id": "midweek",
|
||||||
|
"workDays": [3, 4, 5],
|
||||||
|
"label": "Ons-Fre"
|
||||||
|
},
|
||||||
|
"weekend": {
|
||||||
|
"id": "weekend",
|
||||||
|
"workDays": [6, 7],
|
||||||
|
"label": "Weekend"
|
||||||
|
},
|
||||||
|
"fullweek": {
|
||||||
|
"id": "fullweek",
|
||||||
|
"workDays": [1, 2, 3, 4, 5, 6, 7],
|
||||||
|
"label": "Alle dage"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultPreset": "standard",
|
||||||
|
"firstDayOfWeek": 1
|
||||||
|
},
|
||||||
|
|
||||||
|
"grid": {
|
||||||
|
"dayStartHour": 6,
|
||||||
|
"dayEndHour": 22,
|
||||||
|
"workStartHour": 8,
|
||||||
|
"workEndHour": 17,
|
||||||
|
"hourHeight": 80,
|
||||||
|
"snapInterval": 15
|
||||||
|
},
|
||||||
|
|
||||||
|
"timeFormat": {
|
||||||
|
"timezone": "Europe/Copenhagen",
|
||||||
|
"locale": "da-DK",
|
||||||
|
"use24HourFormat": true
|
||||||
|
},
|
||||||
|
|
||||||
|
"views": {
|
||||||
|
"availableViews": ["simple", "resource", "team", "department"],
|
||||||
|
"defaultView": "simple"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,18 +10,36 @@
|
||||||
<div class="calendar-wrapper">
|
<div class="calendar-wrapper">
|
||||||
<swp-calendar>
|
<swp-calendar>
|
||||||
<swp-calendar-nav>
|
<swp-calendar-nav>
|
||||||
<swp-nav-button id="btn-day">Dag</swp-nav-button>
|
<!-- View switcher (small chips) -->
|
||||||
<swp-nav-button id="btn-simple">Datoer</swp-nav-button>
|
<swp-view-switcher>
|
||||||
<swp-nav-button id="btn-resource">Resources</swp-nav-button>
|
<button class="view-chip active" data-view="simple">Datoer</button>
|
||||||
<swp-nav-button id="btn-team">Teams</swp-nav-button>
|
<button class="view-chip" data-view="day">Dag</button>
|
||||||
<swp-nav-button id="btn-department">Departments</swp-nav-button>
|
<button class="view-chip" data-view="resource">Resource</button>
|
||||||
|
<button class="view-chip" data-view="team">Team</button>
|
||||||
|
<button class="view-chip" data-view="department">Dept</button>
|
||||||
|
</swp-view-switcher>
|
||||||
|
|
||||||
|
<!-- Workweek preset dropdown -->
|
||||||
|
<select id="workweek-select" class="workweek-dropdown">
|
||||||
|
<option value="standard">Man-Fre</option>
|
||||||
|
<option value="compressed">Man-Tor</option>
|
||||||
|
<option value="midweek">Ons-Fre</option>
|
||||||
|
<option value="weekend">Weekend</option>
|
||||||
|
<option value="fullweek">Alle dage</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<swp-nav-group>
|
||||||
|
<swp-nav-button id="btn-prev">←</swp-nav-button>
|
||||||
|
<swp-nav-button id="btn-next">→</swp-nav-button>
|
||||||
|
</swp-nav-group>
|
||||||
|
|
||||||
<swp-week-info>
|
<swp-week-info>
|
||||||
<swp-week-number>V2</swp-week-number>
|
<swp-week-number>V2 Demo</swp-week-number>
|
||||||
<swp-date-range id="view-info"></swp-date-range>
|
<swp-date-range id="view-info"></swp-date-range>
|
||||||
</swp-week-info>
|
</swp-week-info>
|
||||||
<swp-nav-button id="btn-prev">←</swp-nav-button>
|
|
||||||
<swp-nav-button id="btn-next">→</swp-nav-button>
|
<swp-nav-button id="btn-drawer" class="btn-small">Drawer</swp-nav-button>
|
||||||
<swp-nav-button id="btn-drawer">Toggle</swp-nav-button>
|
|
||||||
</swp-calendar-nav>
|
</swp-calendar-nav>
|
||||||
|
|
||||||
<swp-calendar-container>
|
<swp-calendar-container>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue