Adds workweek settings and dynamic view configuration
Introduces settings service for managing tenant-specific calendar configurations Enables dynamic workweek presets with configurable work days Improves view switching with enhanced UI components Adds flexible calendar rendering based on tenant settings Extends DateService to support workweek date generation
This commit is contained in:
parent
58cedb9fad
commit
ad2df353b5
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');
|
||||||
|
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();
|
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();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('btn-team')?.addEventListener('click', () => {
|
|
||||||
this.currentView = 'team';
|
|
||||||
this.render();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('btn-department')?.addEventListener('click', () => {
|
|
||||||
this.currentView = 'department';
|
|
||||||
this.render();
|
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>
|
||||||
<swp-week-info>
|
<button class="view-chip" data-view="team">Team</button>
|
||||||
<swp-week-number>V2</swp-week-number>
|
<button class="view-chip" data-view="department">Dept</button>
|
||||||
<swp-date-range id="view-info"></swp-date-range>
|
</swp-view-switcher>
|
||||||
</swp-week-info>
|
|
||||||
|
<!-- 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-prev">←</swp-nav-button>
|
||||||
<swp-nav-button id="btn-next">→</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-nav>
|
||||||
|
|
||||||
<swp-calendar-container>
|
<swp-calendar-container>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue