Adds department view to calendar application
Introduces department-level grouping and rendering in the calendar view Extends the application to support: - Department-based resource filtering - Dynamic department header rendering - Mock department data infrastructure Enables more granular organizational views
This commit is contained in:
parent
d4249eecfb
commit
570c91527a
11 changed files with 199 additions and 5 deletions
|
|
@ -7,6 +7,7 @@ import { ITimeFormatConfig } from './core/ITimeFormatConfig';
|
||||||
import { IGridConfig } from './core/IGridConfig';
|
import { IGridConfig } from './core/IGridConfig';
|
||||||
import { ResourceRenderer } from './features/resource/ResourceRenderer';
|
import { ResourceRenderer } from './features/resource/ResourceRenderer';
|
||||||
import { TeamRenderer } from './features/team/TeamRenderer';
|
import { TeamRenderer } from './features/team/TeamRenderer';
|
||||||
|
import { DepartmentRenderer } from './features/department/DepartmentRenderer';
|
||||||
import { CalendarOrchestrator } from './core/CalendarOrchestrator';
|
import { CalendarOrchestrator } from './core/CalendarOrchestrator';
|
||||||
import { TimeAxisRenderer } from './features/timeaxis/TimeAxisRenderer';
|
import { TimeAxisRenderer } from './features/timeaxis/TimeAxisRenderer';
|
||||||
import { ScrollManager } from './core/ScrollManager';
|
import { ScrollManager } from './core/ScrollManager';
|
||||||
|
|
@ -16,7 +17,7 @@ import { DemoApp } from './demo/DemoApp';
|
||||||
|
|
||||||
// Event system
|
// Event system
|
||||||
import { EventBus } from './core/EventBus';
|
import { EventBus } from './core/EventBus';
|
||||||
import { IEventBus, ICalendarEvent, ISync, IResource, IBooking, ICustomer, ITeam } from './types/CalendarTypes';
|
import { IEventBus, ICalendarEvent, ISync, IResource, IBooking, ICustomer, ITeam, IDepartment } from './types/CalendarTypes';
|
||||||
|
|
||||||
// Storage
|
// Storage
|
||||||
import { IndexedDBContext } from './storage/IndexedDBContext';
|
import { IndexedDBContext } from './storage/IndexedDBContext';
|
||||||
|
|
@ -32,6 +33,8 @@ import { CustomerStore } from './storage/customers/CustomerStore';
|
||||||
import { CustomerService } from './storage/customers/CustomerService';
|
import { CustomerService } from './storage/customers/CustomerService';
|
||||||
import { TeamStore } from './storage/teams/TeamStore';
|
import { TeamStore } from './storage/teams/TeamStore';
|
||||||
import { TeamService } from './storage/teams/TeamService';
|
import { TeamService } from './storage/teams/TeamService';
|
||||||
|
import { DepartmentStore } from './storage/departments/DepartmentStore';
|
||||||
|
import { DepartmentService } from './storage/departments/DepartmentService';
|
||||||
|
|
||||||
// Audit
|
// Audit
|
||||||
import { AuditStore } from './storage/audit/AuditStore';
|
import { AuditStore } from './storage/audit/AuditStore';
|
||||||
|
|
@ -46,6 +49,7 @@ import { MockBookingRepository } from './repositories/MockBookingRepository';
|
||||||
import { MockCustomerRepository } from './repositories/MockCustomerRepository';
|
import { MockCustomerRepository } from './repositories/MockCustomerRepository';
|
||||||
import { MockAuditRepository } from './repositories/MockAuditRepository';
|
import { MockAuditRepository } from './repositories/MockAuditRepository';
|
||||||
import { MockTeamRepository } from './repositories/MockTeamRepository';
|
import { MockTeamRepository } from './repositories/MockTeamRepository';
|
||||||
|
import { MockDepartmentRepository } from './repositories/MockDepartmentRepository';
|
||||||
|
|
||||||
// Workers
|
// Workers
|
||||||
import { DataSeeder } from './workers/DataSeeder';
|
import { DataSeeder } from './workers/DataSeeder';
|
||||||
|
|
@ -106,6 +110,7 @@ export function createV2Container(): Container {
|
||||||
builder.registerType(BookingStore).as<IStore>();
|
builder.registerType(BookingStore).as<IStore>();
|
||||||
builder.registerType(CustomerStore).as<IStore>();
|
builder.registerType(CustomerStore).as<IStore>();
|
||||||
builder.registerType(TeamStore).as<IStore>();
|
builder.registerType(TeamStore).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>();
|
||||||
|
|
||||||
|
|
@ -130,6 +135,10 @@ export function createV2Container(): Container {
|
||||||
builder.registerType(TeamService).as<IEntityService<ISync>>();
|
builder.registerType(TeamService).as<IEntityService<ISync>>();
|
||||||
builder.registerType(TeamService).as<TeamService>();
|
builder.registerType(TeamService).as<TeamService>();
|
||||||
|
|
||||||
|
builder.registerType(DepartmentService).as<IEntityService<IDepartment>>();
|
||||||
|
builder.registerType(DepartmentService).as<IEntityService<ISync>>();
|
||||||
|
builder.registerType(DepartmentService).as<DepartmentService>();
|
||||||
|
|
||||||
// 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>>();
|
||||||
|
|
@ -149,6 +158,9 @@ export function createV2Container(): Container {
|
||||||
builder.registerType(MockTeamRepository).as<IApiRepository<ITeam>>();
|
builder.registerType(MockTeamRepository).as<IApiRepository<ITeam>>();
|
||||||
builder.registerType(MockTeamRepository).as<IApiRepository<ISync>>();
|
builder.registerType(MockTeamRepository).as<IApiRepository<ISync>>();
|
||||||
|
|
||||||
|
builder.registerType(MockDepartmentRepository).as<IApiRepository<IDepartment>>();
|
||||||
|
builder.registerType(MockDepartmentRepository).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>();
|
||||||
|
|
||||||
|
|
@ -168,6 +180,7 @@ export function createV2Container(): Container {
|
||||||
builder.registerType(DateRenderer).as<IRenderer>();
|
builder.registerType(DateRenderer).as<IRenderer>();
|
||||||
builder.registerType(ResourceRenderer).as<IRenderer>();
|
builder.registerType(ResourceRenderer).as<IRenderer>();
|
||||||
builder.registerType(TeamRenderer).as<IRenderer>();
|
builder.registerType(TeamRenderer).as<IRenderer>();
|
||||||
|
builder.registerType(DepartmentRenderer).as<IRenderer>();
|
||||||
|
|
||||||
// Stores - registreres som IGroupingStore
|
// Stores - registreres som IGroupingStore
|
||||||
builder.registerType(MockTeamStore).as<IGroupingStore>();
|
builder.registerType(MockTeamStore).as<IGroupingStore>();
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ 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' = 'simple';
|
private currentView: 'day' | 'simple' | 'resource' | 'team' | 'department' = 'simple';
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private orchestrator: CalendarOrchestrator,
|
private orchestrator: CalendarOrchestrator,
|
||||||
|
|
@ -129,6 +129,16 @@ export class DemoApp {
|
||||||
{ type: 'date', values: dates, idProperty: 'date', derivedFrom: 'start' }
|
{ type: 'date', values: dates, idProperty: 'date', derivedFrom: 'start' }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
case 'department':
|
||||||
|
return {
|
||||||
|
templateId: 'department',
|
||||||
|
groupings: [
|
||||||
|
{ type: 'department', values: ['dept-styling', 'dept-training'] },
|
||||||
|
{ type: 'resource', values: ['EMP001', 'EMP002', 'EMP003', 'EMP004', 'STUDENT001', 'STUDENT002'], idProperty: 'resourceId', belongsTo: 'department.resourceIds' },
|
||||||
|
{ type: 'date', values: dates, idProperty: 'date', derivedFrom: 'start' }
|
||||||
|
]
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -164,6 +174,11 @@ export class DemoApp {
|
||||||
this.currentView = 'team';
|
this.currentView = 'team';
|
||||||
this.render();
|
this.render();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-department')?.addEventListener('click', () => {
|
||||||
|
this.currentView = 'department';
|
||||||
|
this.render();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupDrawerToggle(): void {
|
private setupDrawerToggle(): void {
|
||||||
|
|
|
||||||
37
src/v2/features/department/DepartmentRenderer.ts
Normal file
37
src/v2/features/department/DepartmentRenderer.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { IRenderer, IRenderContext } from '../../core/IGroupingRenderer';
|
||||||
|
import { DepartmentService } from '../../storage/departments/DepartmentService';
|
||||||
|
|
||||||
|
export class DepartmentRenderer implements IRenderer {
|
||||||
|
readonly type = 'department';
|
||||||
|
|
||||||
|
constructor(private departmentService: DepartmentService) {}
|
||||||
|
|
||||||
|
async render(context: IRenderContext): Promise<void> {
|
||||||
|
const allowedIds = context.filter[this.type] || [];
|
||||||
|
if (allowedIds.length === 0) return;
|
||||||
|
|
||||||
|
// Fetch departments from IndexedDB (only for name display)
|
||||||
|
const departments = await this.departmentService.getByIds(allowedIds);
|
||||||
|
|
||||||
|
const dateCount = context.filter['date']?.length || 1;
|
||||||
|
|
||||||
|
// Get child filter values using childType from context (not hardcoded)
|
||||||
|
const childIds = context.childType ? context.filter[context.childType] || [] : [];
|
||||||
|
|
||||||
|
// Render department headers
|
||||||
|
for (const dept of departments) {
|
||||||
|
// Get children from parentChildMap (resolved from belongsTo config)
|
||||||
|
const deptChildIds = context.parentChildMap?.[dept.id] || [];
|
||||||
|
|
||||||
|
// Count children that belong to this department AND are in the filter
|
||||||
|
const childCount = deptChildIds.filter(id => childIds.includes(id)).length;
|
||||||
|
const colspan = childCount * dateCount;
|
||||||
|
|
||||||
|
const header = document.createElement('swp-department-header');
|
||||||
|
header.dataset.departmentId = dept.id;
|
||||||
|
header.textContent = dept.name;
|
||||||
|
header.style.setProperty('--department-cols', String(colspan));
|
||||||
|
context.headerContainer.appendChild(header);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/v2/repositories/MockDepartmentRepository.ts
Normal file
55
src/v2/repositories/MockDepartmentRepository.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { IDepartment, EntityType } from '../types/CalendarTypes';
|
||||||
|
import { IApiRepository } from './IApiRepository';
|
||||||
|
|
||||||
|
interface RawDepartmentData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
resourceIds: string[];
|
||||||
|
syncStatus?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MockDepartmentRepository - Loads department data from local JSON file
|
||||||
|
*/
|
||||||
|
export class MockDepartmentRepository implements IApiRepository<IDepartment> {
|
||||||
|
public readonly entityType: EntityType = 'Department';
|
||||||
|
private readonly dataUrl = 'data/mock-departments.json';
|
||||||
|
|
||||||
|
public async fetchAll(): Promise<IDepartment[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.dataUrl);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load mock departments: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawData: RawDepartmentData[] = await response.json();
|
||||||
|
return this.processDepartmentData(rawData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load department data:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendCreate(_department: IDepartment): Promise<IDepartment> {
|
||||||
|
throw new Error('MockDepartmentRepository does not support sendCreate. Mock data is read-only.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendUpdate(_id: string, _updates: Partial<IDepartment>): Promise<IDepartment> {
|
||||||
|
throw new Error('MockDepartmentRepository does not support sendUpdate. Mock data is read-only.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendDelete(_id: string): Promise<void> {
|
||||||
|
throw new Error('MockDepartmentRepository does not support sendDelete. Mock data is read-only.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private processDepartmentData(data: RawDepartmentData[]): IDepartment[] {
|
||||||
|
return data.map((dept): IDepartment => ({
|
||||||
|
id: dept.id,
|
||||||
|
name: dept.name,
|
||||||
|
resourceIds: dept.resourceIds,
|
||||||
|
syncStatus: 'synced' as const
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/v2/storage/departments/DepartmentService.ts
Normal file
25
src/v2/storage/departments/DepartmentService.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { IDepartment, EntityType, IEventBus } from '../../types/CalendarTypes';
|
||||||
|
import { DepartmentStore } from './DepartmentStore';
|
||||||
|
import { BaseEntityService } from '../BaseEntityService';
|
||||||
|
import { IndexedDBContext } from '../IndexedDBContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DepartmentService - CRUD operations for departments in IndexedDB
|
||||||
|
*/
|
||||||
|
export class DepartmentService extends BaseEntityService<IDepartment> {
|
||||||
|
readonly storeName = DepartmentStore.STORE_NAME;
|
||||||
|
readonly entityType: EntityType = 'Department';
|
||||||
|
|
||||||
|
constructor(context: IndexedDBContext, eventBus: IEventBus) {
|
||||||
|
super(context, eventBus);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get departments by IDs
|
||||||
|
*/
|
||||||
|
async getByIds(ids: string[]): Promise<IDepartment[]> {
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
const results = await Promise.all(ids.map(id => this.get(id)));
|
||||||
|
return results.filter((d): d is IDepartment => d !== null);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/v2/storage/departments/DepartmentStore.ts
Normal file
13
src/v2/storage/departments/DepartmentStore.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { IStore } from '../IStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DepartmentStore - IndexedDB ObjectStore definition for departments
|
||||||
|
*/
|
||||||
|
export class DepartmentStore implements IStore {
|
||||||
|
static readonly STORE_NAME = 'departments';
|
||||||
|
readonly storeName = DepartmentStore.STORE_NAME;
|
||||||
|
|
||||||
|
create(db: IDBDatabase): void {
|
||||||
|
db.createObjectStore(DepartmentStore.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' | 'Audit';
|
export type EntityType = 'Event' | 'Booking' | 'Customer' | 'Resource' | 'Team' | 'Department' | 'Audit';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CalendarEventType - Used by ICalendarEvent.type
|
* CalendarEventType - Used by ICalendarEvent.type
|
||||||
|
|
@ -125,6 +125,13 @@ export interface ITeam extends ISync {
|
||||||
resourceIds: string[];
|
resourceIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Department types
|
||||||
|
export interface IDepartment extends ISync {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
resourceIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
// Booking types
|
// Booking types
|
||||||
export type BookingStatus =
|
export type BookingStatus =
|
||||||
| 'created'
|
| 'created'
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -175,11 +175,18 @@ swp-calendar-header {
|
||||||
> swp-resource-header { grid-row: 2; }
|
> swp-resource-header { grid-row: 2; }
|
||||||
> swp-day-header { grid-row: 3; }
|
> swp-day-header { grid-row: 3; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&[data-levels="department resource date"] {
|
||||||
|
> swp-department-header { grid-row: 1; }
|
||||||
|
> swp-resource-header { grid-row: 2; }
|
||||||
|
> swp-day-header { grid-row: 3; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
swp-day-header,
|
swp-day-header,
|
||||||
swp-resource-header,
|
swp-resource-header,
|
||||||
swp-team-header {
|
swp-team-header,
|
||||||
|
swp-department-header {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border-right: 1px solid var(--color-border);
|
border-right: 1px solid var(--color-border);
|
||||||
|
|
@ -194,6 +201,13 @@ swp-team-header {
|
||||||
grid-column: span var(--team-cols, 1);
|
grid-column: span var(--team-cols, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
swp-department-header {
|
||||||
|
background: var(--color-team-bg);
|
||||||
|
color: var(--color-team-text);
|
||||||
|
font-weight: 500;
|
||||||
|
grid-column: span var(--department-cols, 1);
|
||||||
|
}
|
||||||
|
|
||||||
swp-resource-header {
|
swp-resource-header {
|
||||||
background: var(--color-background-alt);
|
background: var(--color-background-alt);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|
|
||||||
14
wwwroot/data/mock-departments.json
Normal file
14
wwwroot/data/mock-departments.json
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "dept-styling",
|
||||||
|
"name": "Styling",
|
||||||
|
"resourceIds": ["EMP001", "EMP002", "EMP003"],
|
||||||
|
"syncStatus": "synced"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dept-training",
|
||||||
|
"name": "Training",
|
||||||
|
"resourceIds": ["EMP004", "STUDENT001", "STUDENT002"],
|
||||||
|
"syncStatus": "synced"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
<swp-nav-button id="btn-simple">Datoer</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-resource">Resources</swp-nav-button>
|
||||||
<swp-nav-button id="btn-team">Teams</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-info>
|
||||||
<swp-week-number>V2</swp-week-number>
|
<swp-week-number>V2</swp-week-number>
|
||||||
<swp-date-range id="view-info"></swp-date-range>
|
<swp-date-range id="view-info"></swp-date-range>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue