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 { DepartmentStore } from './storage/departments/DepartmentStore';
|
||||
import { DepartmentService } from './storage/departments/DepartmentService';
|
||||
import { SettingsStore } from './storage/settings/SettingsStore';
|
||||
import { SettingsService } from './storage/settings/SettingsService';
|
||||
import { ITenantSettings } from './types/SettingsTypes';
|
||||
|
||||
// Audit
|
||||
import { AuditStore } from './storage/audit/AuditStore';
|
||||
|
|
@ -50,6 +53,7 @@ import { MockCustomerRepository } from './repositories/MockCustomerRepository';
|
|||
import { MockAuditRepository } from './repositories/MockAuditRepository';
|
||||
import { MockTeamRepository } from './repositories/MockTeamRepository';
|
||||
import { MockDepartmentRepository } from './repositories/MockDepartmentRepository';
|
||||
import { MockSettingsRepository } from './repositories/MockSettingsRepository';
|
||||
|
||||
// Workers
|
||||
import { DataSeeder } from './workers/DataSeeder';
|
||||
|
|
@ -113,6 +117,7 @@ export function createV2Container(): Container {
|
|||
builder.registerType(DepartmentStore).as<IStore>();
|
||||
builder.registerType(ScheduleOverrideStore).as<IStore>();
|
||||
builder.registerType(AuditStore).as<IStore>();
|
||||
builder.registerType(SettingsStore).as<IStore>();
|
||||
|
||||
// Entity services (for DataSeeder polymorphic array)
|
||||
builder.registerType(EventService).as<IEntityService<ICalendarEvent>>();
|
||||
|
|
@ -139,6 +144,10 @@ export function createV2Container(): Container {
|
|||
builder.registerType(DepartmentService).as<IEntityService<ISync>>();
|
||||
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)
|
||||
builder.registerType(MockEventRepository).as<IApiRepository<ICalendarEvent>>();
|
||||
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<ISync>>();
|
||||
|
||||
builder.registerType(MockSettingsRepository).as<IApiRepository<ITenantSettings>>();
|
||||
builder.registerType(MockSettingsRepository).as<IApiRepository<ISync>>();
|
||||
|
||||
// Audit service (listens to ENTITY_SAVED/DELETED events automatically)
|
||||
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
|
||||
// ============================================
|
||||
|
|
|
|||
|
|
@ -13,12 +13,15 @@ import { ResizeManager } from '../managers/ResizeManager';
|
|||
import { EventPersistenceManager } from '../managers/EventPersistenceManager';
|
||||
import { HeaderDrawerRenderer } from '../features/headerdrawer/HeaderDrawerRenderer';
|
||||
import { AuditService } from '../storage/audit/AuditService';
|
||||
import { SettingsService } from '../storage/settings/SettingsService';
|
||||
import { IWorkweekPreset } from '../types/SettingsTypes';
|
||||
|
||||
export class DemoApp {
|
||||
private animator!: NavigationAnimator;
|
||||
private container!: HTMLElement;
|
||||
private weekOffset = 0;
|
||||
private currentView: 'day' | 'simple' | 'resource' | 'team' | 'department' = 'simple';
|
||||
private workweekPreset: IWorkweekPreset | null = null;
|
||||
|
||||
constructor(
|
||||
private orchestrator: CalendarOrchestrator,
|
||||
|
|
@ -33,7 +36,8 @@ export class DemoApp {
|
|||
private resizeManager: ResizeManager,
|
||||
private headerDrawerRenderer: HeaderDrawerRenderer,
|
||||
private eventPersistenceManager: EventPersistenceManager,
|
||||
private auditService: AuditService
|
||||
private auditService: AuditService,
|
||||
private settingsService: SettingsService
|
||||
) {}
|
||||
|
||||
async init(): Promise<void> {
|
||||
|
|
@ -48,6 +52,10 @@ export class DemoApp {
|
|||
await this.dataSeeder.seedIfEmpty();
|
||||
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;
|
||||
|
||||
// NavigationAnimator har DOM-dependencies - tilladt med new
|
||||
|
|
@ -90,7 +98,9 @@ export class DemoApp {
|
|||
}
|
||||
|
||||
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);
|
||||
|
||||
switch (this.currentView) {
|
||||
|
|
@ -155,29 +165,32 @@ export class DemoApp {
|
|||
}
|
||||
|
||||
private setupViewSwitching(): void {
|
||||
document.getElementById('btn-day')?.addEventListener('click', () => {
|
||||
this.currentView = 'day';
|
||||
// View chip buttons
|
||||
const chips = document.querySelectorAll('.view-chip');
|
||||
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', () => {
|
||||
this.currentView = 'simple';
|
||||
this.render();
|
||||
});
|
||||
|
||||
document.getElementById('btn-resource')?.addEventListener('click', () => {
|
||||
this.currentView = 'resource';
|
||||
this.render();
|
||||
});
|
||||
|
||||
document.getElementById('btn-team')?.addEventListener('click', () => {
|
||||
this.currentView = 'team';
|
||||
this.render();
|
||||
});
|
||||
|
||||
document.getElementById('btn-department')?.addEventListener('click', () => {
|
||||
this.currentView = 'department';
|
||||
// Workweek preset dropdown
|
||||
const workweekSelect = document.getElementById('workweek-select') as HTMLSelectElement;
|
||||
workweekSelect?.addEventListener('change', async () => {
|
||||
const presetId = workweekSelect.value;
|
||||
const preset = await this.settingsService.getWorkweekPreset(presetId);
|
||||
if (preset) {
|
||||
this.workweekPreset = preset;
|
||||
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 {
|
||||
private static readonly DB_NAME = 'CalendarV2DB';
|
||||
private static readonly DB_VERSION = 3;
|
||||
private static readonly DB_VERSION = 4;
|
||||
|
||||
private db: IDBDatabase | null = null;
|
||||
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 EntityType = 'Event' | 'Booking' | 'Customer' | 'Resource' | 'Team' | 'Department' | 'Audit';
|
||||
export type EntityType = 'Event' | 'Booking' | 'Customer' | 'Resource' | 'Team' | 'Department' | 'Audit' | 'Settings';
|
||||
|
||||
/**
|
||||
* 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 */
|
||||
swp-calendar-nav {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
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 {
|
||||
padding: 8px 16px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
background: var(--color-surface);
|
||||
font-size: 12px;
|
||||
|
||||
&:hover { background: var(--color-background-hover); }
|
||||
|
||||
&.btn-small {
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
swp-week-info {
|
||||
|
|
@ -38,11 +96,12 @@ swp-week-info {
|
|||
|
||||
swp-week-number {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
swp-date-range {
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
|
@ -70,6 +129,8 @@ swp-time-axis {
|
|||
|
||||
swp-header-spacer {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
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">
|
||||
<swp-calendar>
|
||||
<swp-calendar-nav>
|
||||
<swp-nav-button id="btn-day">Dag</swp-nav-button>
|
||||
<swp-nav-button id="btn-simple">Datoer</swp-nav-button>
|
||||
<swp-nav-button id="btn-resource">Resources</swp-nav-button>
|
||||
<swp-nav-button id="btn-team">Teams</swp-nav-button>
|
||||
<swp-nav-button id="btn-department">Departments</swp-nav-button>
|
||||
<swp-week-info>
|
||||
<swp-week-number>V2</swp-week-number>
|
||||
<swp-date-range id="view-info"></swp-date-range>
|
||||
</swp-week-info>
|
||||
<!-- View switcher (small chips) -->
|
||||
<swp-view-switcher>
|
||||
<button class="view-chip active" data-view="simple">Datoer</button>
|
||||
<button class="view-chip" data-view="day">Dag</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-button id="btn-drawer">Toggle</swp-nav-button>
|
||||
</swp-nav-group>
|
||||
|
||||
<swp-week-info>
|
||||
<swp-week-number>V2 Demo</swp-week-number>
|
||||
<swp-date-range id="view-info"></swp-date-range>
|
||||
</swp-week-info>
|
||||
|
||||
<swp-nav-button id="btn-drawer" class="btn-small">Drawer</swp-nav-button>
|
||||
</swp-calendar-nav>
|
||||
|
||||
<swp-calendar-container>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue