From 570c91527a4a6396eee7028c9f020b9c7bba1e7a Mon Sep 17 00:00:00 2001 From: "Janus C. H. Knudsen" Date: Mon, 15 Dec 2025 18:23:08 +0100 Subject: [PATCH] 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 --- src/v2/V2CompositionRoot.ts | 15 ++++- src/v2/demo/DemoApp.ts | 17 +++++- .../features/department/DepartmentRenderer.ts | 37 +++++++++++++ .../repositories/MockDepartmentRepository.ts | 55 +++++++++++++++++++ .../storage/departments/DepartmentService.ts | 25 +++++++++ src/v2/storage/departments/DepartmentStore.ts | 13 +++++ src/v2/types/CalendarTypes.ts | 9 ++- wwwroot/css/calendar-layout-css.css | 2 +- wwwroot/css/v2/calendar-v2-layout.css | 16 +++++- wwwroot/data/mock-departments.json | 14 +++++ wwwroot/v2.html | 1 + 11 files changed, 199 insertions(+), 5 deletions(-) create mode 100644 src/v2/features/department/DepartmentRenderer.ts create mode 100644 src/v2/repositories/MockDepartmentRepository.ts create mode 100644 src/v2/storage/departments/DepartmentService.ts create mode 100644 src/v2/storage/departments/DepartmentStore.ts create mode 100644 wwwroot/data/mock-departments.json diff --git a/src/v2/V2CompositionRoot.ts b/src/v2/V2CompositionRoot.ts index 1031077..db696f0 100644 --- a/src/v2/V2CompositionRoot.ts +++ b/src/v2/V2CompositionRoot.ts @@ -7,6 +7,7 @@ import { ITimeFormatConfig } from './core/ITimeFormatConfig'; import { IGridConfig } from './core/IGridConfig'; import { ResourceRenderer } from './features/resource/ResourceRenderer'; import { TeamRenderer } from './features/team/TeamRenderer'; +import { DepartmentRenderer } from './features/department/DepartmentRenderer'; import { CalendarOrchestrator } from './core/CalendarOrchestrator'; import { TimeAxisRenderer } from './features/timeaxis/TimeAxisRenderer'; import { ScrollManager } from './core/ScrollManager'; @@ -16,7 +17,7 @@ import { DemoApp } from './demo/DemoApp'; // Event system 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 import { IndexedDBContext } from './storage/IndexedDBContext'; @@ -32,6 +33,8 @@ import { CustomerStore } from './storage/customers/CustomerStore'; import { CustomerService } from './storage/customers/CustomerService'; import { TeamStore } from './storage/teams/TeamStore'; import { TeamService } from './storage/teams/TeamService'; +import { DepartmentStore } from './storage/departments/DepartmentStore'; +import { DepartmentService } from './storage/departments/DepartmentService'; // Audit import { AuditStore } from './storage/audit/AuditStore'; @@ -46,6 +49,7 @@ import { MockBookingRepository } from './repositories/MockBookingRepository'; import { MockCustomerRepository } from './repositories/MockCustomerRepository'; import { MockAuditRepository } from './repositories/MockAuditRepository'; import { MockTeamRepository } from './repositories/MockTeamRepository'; +import { MockDepartmentRepository } from './repositories/MockDepartmentRepository'; // Workers import { DataSeeder } from './workers/DataSeeder'; @@ -106,6 +110,7 @@ export function createV2Container(): Container { builder.registerType(BookingStore).as(); builder.registerType(CustomerStore).as(); builder.registerType(TeamStore).as(); + builder.registerType(DepartmentStore).as(); builder.registerType(ScheduleOverrideStore).as(); builder.registerType(AuditStore).as(); @@ -130,6 +135,10 @@ export function createV2Container(): Container { builder.registerType(TeamService).as>(); builder.registerType(TeamService).as(); + builder.registerType(DepartmentService).as>(); + builder.registerType(DepartmentService).as>(); + builder.registerType(DepartmentService).as(); + // Repositories (for DataSeeder polymorphic array) builder.registerType(MockEventRepository).as>(); builder.registerType(MockEventRepository).as>(); @@ -149,6 +158,9 @@ export function createV2Container(): Container { builder.registerType(MockTeamRepository).as>(); builder.registerType(MockTeamRepository).as>(); + builder.registerType(MockDepartmentRepository).as>(); + builder.registerType(MockDepartmentRepository).as>(); + // Audit service (listens to ENTITY_SAVED/DELETED events automatically) builder.registerType(AuditService).as(); @@ -168,6 +180,7 @@ export function createV2Container(): Container { builder.registerType(DateRenderer).as(); builder.registerType(ResourceRenderer).as(); builder.registerType(TeamRenderer).as(); + builder.registerType(DepartmentRenderer).as(); // Stores - registreres som IGroupingStore builder.registerType(MockTeamStore).as(); diff --git a/src/v2/demo/DemoApp.ts b/src/v2/demo/DemoApp.ts index 658c84b..9c3dc56 100644 --- a/src/v2/demo/DemoApp.ts +++ b/src/v2/demo/DemoApp.ts @@ -18,7 +18,7 @@ export class DemoApp { private animator!: NavigationAnimator; private container!: HTMLElement; private weekOffset = 0; - private currentView: 'day' | 'simple' | 'resource' | 'team' = 'simple'; + private currentView: 'day' | 'simple' | 'resource' | 'team' | 'department' = 'simple'; constructor( private orchestrator: CalendarOrchestrator, @@ -129,6 +129,16 @@ export class DemoApp { { 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.render(); }); + + document.getElementById('btn-department')?.addEventListener('click', () => { + this.currentView = 'department'; + this.render(); + }); } private setupDrawerToggle(): void { diff --git a/src/v2/features/department/DepartmentRenderer.ts b/src/v2/features/department/DepartmentRenderer.ts new file mode 100644 index 0000000..c05b2a8 --- /dev/null +++ b/src/v2/features/department/DepartmentRenderer.ts @@ -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 { + 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); + } + } +} diff --git a/src/v2/repositories/MockDepartmentRepository.ts b/src/v2/repositories/MockDepartmentRepository.ts new file mode 100644 index 0000000..ba59245 --- /dev/null +++ b/src/v2/repositories/MockDepartmentRepository.ts @@ -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 { + public readonly entityType: EntityType = 'Department'; + private readonly dataUrl = 'data/mock-departments.json'; + + public async fetchAll(): Promise { + 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 { + throw new Error('MockDepartmentRepository does not support sendCreate. Mock data is read-only.'); + } + + public async sendUpdate(_id: string, _updates: Partial): Promise { + throw new Error('MockDepartmentRepository does not support sendUpdate. Mock data is read-only.'); + } + + public async sendDelete(_id: string): Promise { + 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 + })); + } +} diff --git a/src/v2/storage/departments/DepartmentService.ts b/src/v2/storage/departments/DepartmentService.ts new file mode 100644 index 0000000..f67aabe --- /dev/null +++ b/src/v2/storage/departments/DepartmentService.ts @@ -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 { + 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 { + 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); + } +} diff --git a/src/v2/storage/departments/DepartmentStore.ts b/src/v2/storage/departments/DepartmentStore.ts new file mode 100644 index 0000000..20e2cff --- /dev/null +++ b/src/v2/storage/departments/DepartmentStore.ts @@ -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' }); + } +} diff --git a/src/v2/types/CalendarTypes.ts b/src/v2/types/CalendarTypes.ts index df38557..0f6ef6e 100644 --- a/src/v2/types/CalendarTypes.ts +++ b/src/v2/types/CalendarTypes.ts @@ -6,7 +6,7 @@ import { IWeekSchedule } from './ScheduleTypes'; 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 @@ -125,6 +125,13 @@ export interface ITeam extends ISync { resourceIds: string[]; } +// Department types +export interface IDepartment extends ISync { + id: string; + name: string; + resourceIds: string[]; +} + // Booking types export type BookingStatus = | 'created' diff --git a/wwwroot/css/calendar-layout-css.css b/wwwroot/css/calendar-layout-css.css index c244ae0..415b3d4 100644 --- a/wwwroot/css/calendar-layout-css.css +++ b/wwwroot/css/calendar-layout-css.css @@ -1 +1 @@ -.calendar-wrapper{box-sizing:border-box;display:flex;flex-direction:column;height:100vh;margin:0;overflow:hidden;padding:0;width:100vw}swp-calendar{background:var(--color-background);display:grid;grid-template-rows:auto 1fr;height:100vh;overflow:hidden;position:relative;width:100%}swp-calendar[data-fit-to-width=true] swp-scrollable-content{overflow-x:hidden}swp-calendar-nav{align-items:center;background:var(--color-background);border-bottom:1px solid var(--color-border);box-shadow:var(--shadow-sm);display:grid;gap:20px;grid-template-columns:auto 1fr auto auto;padding:12px 16px}swp-calendar-container{display:grid;grid-template-columns:60px 1fr;grid-template-rows:auto 1fr;height:100%;overflow:hidden;position:relative}swp-calendar-container.week-transition{transition:opacity .3s ease}swp-calendar-container.week-transition:is(-out){opacity:.5}swp-header-spacer{background:var(--color-surface);border-bottom:1px solid var(--color-border);border-right:1px solid var(--color-border);grid-column:1;grid-row:1;height:calc(var(--header-height) + var(--all-day-row-height));position:relative;z-index:5}.allday-chevron{background:none;border:none;border-radius:4px;bottom:2px;color:#666;cursor:pointer;left:50%;padding:4px 8px;position:absolute;transform:translateX(-50%);transition:transform .3s ease,color .2s ease}.allday-chevron:hover{background-color:rgba(0,0,0,.05);color:#000}.allday-chevron.collapsed{transform:translateX(-50%) rotate(0deg)}.allday-chevron.expanded{transform:translateX(-50%) rotate(180deg)}.allday-chevron svg{display:block;height:8px;width:12px}swp-grid-container{display:grid;grid-column:2;grid-row:1/3;grid-template-rows:auto 1fr;transition:transform .4s cubic-bezier(.4,0,.2,1);width:100%}swp-grid-container,swp-time-axis{overflow:hidden;position:relative}swp-time-axis{background:var(--color-surface);border-right:1px solid var(--color-border);grid-column:1;grid-row:2;height:100%;left:0;width:60px;z-index:3}swp-time-axis-content{display:flex;flex-direction:column;position:relative}swp-hour-marker{align-items:flex-start;color:var(--color-text-secondary);display:flex;font-size:.75rem;height:var(--hour-height);padding:0 8px 8px 15px;position:relative}swp-hour-marker:before{background:var(--color-hour-line);content:"";height:1px;left:50px;position:absolute;top:-1px;width:calc(100vw - 60px);z-index:2}swp-calendar-header{background:var(--color-surface);display:grid;grid-template-columns:repeat(var(--grid-columns,7),minmax(var(--day-column-min-width),1fr));grid-template-rows:var(--header-height) auto;height:calc(var(--header-height) + var(--all-day-row-height));min-width:calc(var(--grid-columns, 7)*var(--day-column-min-width));overflow-x:hidden;overflow-y:scroll;position:sticky;top:0;z-index:3}swp-calendar-header::-webkit-scrollbar{background:transparent;width:17px}swp-calendar-header::-webkit-scrollbar-thumb,swp-calendar-header::-webkit-scrollbar-track{background:transparent}swp-calendar-header swp-allday-container{align-items:center;display:grid;gap:2px 0;grid-auto-rows:var(--single-row-height);grid-column:1/-1;grid-row:2;grid-template-columns:repeat(var(--grid-columns,7),minmax(var(--day-column-min-width),1fr));overflow:hidden}:is(swp-calendar-header swp-allday-container):has(swp-allday-event){border-bottom:1px solid var(--color-grid-line)}swp-day-header{align-items:center;border-bottom:1px solid var(--color-grid-line);border-right:1px solid var(--color-grid-line);display:flex;flex-direction:column;grid-row:1;justify-content:center;padding-top:3px;text-align:center}swp-day-header:last-child{border-right:none}swp-day-header[data-today=true]{background:rgba(33,150,243,.1)}swp-day-header[data-today=true] swp-day-name{color:var(--color-primary);font-weight:600}swp-day-header[data-today=true] swp-day-date{color:var(--color-primary)}swp-day-name{color:var(--color-text-secondary);display:block;font-size:12px;font-weight:500;letter-spacing:.1em}swp-day-date{display:block;font-size:30px;margin-top:4px}swp-resource-header{align-items:center;background:var(--color-surface);border-bottom:1px solid var(--color-grid-line);border-right:1px solid var(--color-grid-line);display:flex;flex-direction:column;justify-content:center;padding:12px;text-align:center}swp-resource-header:last-child{border-right:none}swp-resource-avatar{background:var(--color-border);border-radius:50%;display:block;height:40px;margin-bottom:8px;overflow:hidden;width:40px}swp-resource-avatar img{height:100%;-o-object-fit:cover;object-fit:cover;width:100%}swp-resource-name{color:var(--color-text);display:block;font-size:.875rem;font-weight:500;text-align:center}swp-allday-column{background:transparent;height:100%;opacity:0;position:relative;z-index:1}swp-allday-container swp-allday-event{align-items:center;background:#08f;border-radius:3px;color:#fff;display:flex;font-size:.75rem;height:22px!important;justify-content:flex-start;left:auto!important;margin:1px;overflow:hidden;padding:2px 4px;position:relative!important;right:auto!important;text-overflow:ellipsis;top:auto!important;white-space:nowrap;width:auto!important;z-index:2}[data-type=meeting]:is(swp-allday-container swp-allday-event){background:var(--color-event-meeting);color:var(--color-text)}[data-type=meal]:is(swp-allday-container swp-allday-event){background:var(--color-event-meal);color:var(--color-text)}[data-type=work]:is(swp-allday-container swp-allday-event){background:var(--color-event-work);color:var(--color-text)}[data-type=milestone]:is(swp-allday-container swp-allday-event){background:var(--color-event-milestone);color:var(--color-text)}[data-type=personal]:is(swp-allday-container swp-allday-event){background:var(--color-event-personal);color:var(--color-text)}[data-type=deadline]:is(swp-allday-container swp-allday-event){background:var(--color-event-milestone);color:var(--color-text)}.dragging:is(swp-allday-container swp-allday-event){opacity:1}.highlight[data-type=meeting]:is(swp-allday-container swp-allday-event){background:var(--color-event-meeting-hl)!important}.highlight[data-type=meal]:is(swp-allday-container swp-allday-event){background:var(--color-event-meal-hl)!important}.highlight[data-type=work]:is(swp-allday-container swp-allday-event){background:var(--color-event-work-hl)!important}.highlight[data-type=milestone]:is(swp-allday-container swp-allday-event){background:var(--color-event-milestone-hl)!important}.highlight[data-type=personal]:is(swp-allday-container swp-allday-event){background:var(--color-event-personal-hl)!important}.highlight[data-type=deadline]:is(swp-allday-container swp-allday-event){background:var(--color-event-milestone-hl)!important}.max-event-indicator:is(swp-allday-container swp-allday-event){background:#e0e0e0!important;border:1px dashed #999!important;color:#666!important;cursor:pointer!important;font-style:italic;justify-content:center;opacity:.8;text-align:center!important}.max-event-indicator:is(swp-allday-container swp-allday-event):hover{background:#d0d0d0!important;color:#333!important;opacity:1}.max-event-indicator:is(swp-allday-container swp-allday-event) span{display:block;font-size:11px;font-weight:400;text-align:center;width:100%}.max-event-overflow-show:is(swp-allday-container swp-allday-event){opacity:1;transition:opacity .3s ease-in-out}.max-event-overflow-hide:is(swp-allday-container swp-allday-event){opacity:0;transition:opacity .3s ease-in-out}:is(swp-allday-container swp-allday-event) swp-event-time{display:none}:is(swp-allday-container swp-allday-event) swp-event-title{display:block;font-size:12px;line-height:18px}.transitioning:is(swp-allday-container swp-allday-event){transition:grid-area .2s ease-out,grid-row .2s ease-out,grid-column .2s ease-out}swp-scrollable-content{display:grid;overflow-x:auto;overflow-y:auto;position:relative;scroll-behavior:smooth;top:-1px}swp-scrollable-content::-webkit-scrollbar{height:var(--scrollbar-width,12px);width:var(--scrollbar-width,12px)}swp-scrollable-content::-webkit-scrollbar-track{background:var(--scrollbar-track-color,#f0f0f0)}swp-scrollable-content::-webkit-scrollbar-thumb{background:var(--scrollbar-color,#666);border-radius:var(--scrollbar-border-radius,6px)}:is(swp-scrollable-content::-webkit-scrollbar-thumb):hover{background:var(--scrollbar-hover-color,#333)}swp-scrollable-content{scrollbar-color:var(--scrollbar-color,#666) var(--scrollbar-track-color,#f0f0f0);scrollbar-width:auto}swp-time-grid{height:calc((var(--day-end-hour) - var(--day-start-hour))*var(--hour-height));position:relative}swp-time-grid:before{background:transparent;display:none;height:0}swp-time-grid:after,swp-time-grid:before{content:"";left:0;min-width:calc(var(--grid-columns, 7)*var(--day-column-min-width));position:absolute;right:0;top:0}swp-time-grid:after{background-image:repeating-linear-gradient(to bottom,transparent,transparent calc(var(--hour-height) - 1px),var(--color-hour-line) calc(var(--hour-height) - 1px),var(--color-hour-line) var(--hour-height));bottom:0;z-index:1}swp-grid-lines{background-image:repeating-linear-gradient(to bottom,transparent,transparent calc(var(--hour-height)/4 - 1px),var(--color-grid-line-light) calc(var(--hour-height)/4 - 1px),var(--color-grid-line-light) calc(var(--hour-height)/4));bottom:0;left:0;right:0;top:0;z-index:var(--z-grid)}swp-day-columns,swp-grid-lines{min-width:calc(var(--grid-columns, 7)*var(--day-column-min-width));position:absolute}swp-day-columns{display:grid;grid-template-columns:repeat(var(--grid-columns,7),minmax(var(--day-column-min-width),1fr));inset:0}swp-day-column{background:var(--color-event-grid);border-right:1px solid var(--color-grid-line);min-width:var(--day-column-min-width);position:relative}swp-day-column:last-child{border-right:none}swp-day-column:after,swp-day-column:before{background:var(--color-non-work-hours);content:"";left:0;opacity:.3;position:absolute;right:0;z-index:2}swp-day-column:before{height:var(--before-work-height,0);top:0}swp-day-column:after{bottom:0;top:var(--after-work-top,100%)}swp-day-column[data-work-hours=off]{background:var(--color-non-work-hours)}swp-day-column[data-work-hours=off]:after,swp-day-column[data-work-hours=off]:before{display:none}swp-resource-column{background:var(--color-event-grid);border-right:1px solid var(--color-grid-line);min-width:var(--day-column-min-width);position:relative}swp-resource-column:last-child{border-right:none}swp-events-layer{display:block;inset:0;position:absolute;z-index:var(--z-event)}swp-current-time-indicator{background:var(--color-current-time);height:2px;left:0;position:absolute;right:0;z-index:var(--z-current-time)}swp-current-time-indicator:before{background:var(--color-current-time);border-radius:3px;color:#fff;content:attr(data-time);font-size:.75rem;left:-55px;padding:2px 6px;position:absolute;top:-10px;white-space:nowrap}swp-current-time-indicator:after{background:var(--color-current-time);border-radius:50%;box-shadow:0 0 0 2px rgba(255,0,0,.3);content:"";height:10px;position:absolute;right:-4px;top:-4px;width:10px} \ No newline at end of file +.calendar-wrapper{box-sizing:border-box;display:flex;flex-direction:column;height:100vh;margin:0;overflow:hidden;padding:0;width:100vw}swp-calendar{background:var(--color-background);display:grid;grid-template-rows:auto 1fr;height:100vh;overflow:hidden;position:relative;width:100%}swp-calendar[data-fit-to-width=true] swp-scrollable-content{overflow-x:hidden}swp-calendar-nav{align-items:center;background:var(--color-background);border-bottom:1px solid var(--color-border);box-shadow:var(--shadow-sm);display:grid;gap:20px;grid-template-columns:auto 1fr auto auto;padding:12px 16px}swp-calendar-container{display:grid;grid-template-columns:60px 1fr;grid-template-rows:auto 1fr;height:100%;overflow:hidden;position:relative}swp-calendar-container.week-transition{transition:opacity .3s ease}swp-calendar-container.week-transition:is(-out){opacity:.5}swp-header-spacer{background:var(--color-surface);border-bottom:1px solid var(--color-border);border-right:1px solid var(--color-border);grid-column:1;grid-row:1;height:calc(var(--header-height) + var(--all-day-row-height));position:relative;z-index:5}.allday-chevron{background:none;border:none;border-radius:4px;bottom:2px;color:#666;cursor:pointer;left:50%;padding:4px 8px;position:absolute;transform:translateX(-50%);transition:transform .3s ease,color .2s ease}.allday-chevron:hover{background-color:rgba(0,0,0,.05);color:#000}.allday-chevron.collapsed{transform:translateX(-50%) rotate(0deg)}.allday-chevron.expanded{transform:translateX(-50%) rotate(180deg)}.allday-chevron svg{display:block;height:8px;width:12px}swp-grid-container{display:grid;grid-column:2;grid-row:1/3;grid-template-rows:auto 1fr;transition:transform .4s cubic-bezier(.4,0,.2,1);width:100%}swp-grid-container,swp-time-axis{overflow:hidden;position:relative}swp-time-axis{background:var(--color-surface);border-right:1px solid var(--color-border);grid-column:1;grid-row:2;height:100%;left:0;width:60px;z-index:3}swp-time-axis-content{display:flex;flex-direction:column;position:relative}swp-hour-marker{align-items:flex-start;color:var(--color-text-secondary);display:flex;font-size:.75rem;height:var(--hour-height);padding:0 8px 8px 15px;position:relative}swp-hour-marker:before{background:var(--color-hour-line);content:"";height:1px;left:50px;position:absolute;top:-1px;width:calc(100vw - 60px);z-index:2}swp-calendar-header{background:var(--color-surface);display:grid;grid-template-columns:repeat(var(--grid-columns,7),minmax(var(--day-column-min-width),1fr));grid-template-rows:var(--header-height) auto;height:calc(var(--header-height) + var(--all-day-row-height));min-width:calc(var(--grid-columns, 7)*var(--day-column-min-width));overflow-x:hidden;overflow-y:scroll;position:sticky;top:0;z-index:3}swp-calendar-header::-webkit-scrollbar{background:transparent;width:17px}swp-calendar-header::-webkit-scrollbar-thumb,swp-calendar-header::-webkit-scrollbar-track{background:transparent}swp-calendar-header swp-allday-container{align-items:center;display:grid;gap:2px 0;grid-auto-rows:var(--single-row-height);grid-column:1/-1;grid-row:2;grid-template-columns:repeat(var(--grid-columns,7),minmax(var(--day-column-min-width),1fr));overflow:hidden}:is(swp-calendar-header swp-allday-container):has(swp-allday-event){border-bottom:1px solid var(--color-grid-line)}swp-day-header{align-items:center;border-bottom:1px solid var(--color-grid-line);border-right:1px solid var(--color-grid-line);display:flex;flex-direction:column;grid-row:1;justify-content:center;padding-top:3px;text-align:center}swp-day-header:last-child{border-right:none}swp-day-header[data-today=true]{background:rgba(33,150,243,.1)}swp-day-header[data-today=true] swp-day-name{color:var(--color-primary);font-weight:600}swp-day-header[data-today=true] swp-day-date{color:var(--color-primary)}swp-day-name{color:var(--color-text-secondary);display:block;font-size:12px;font-weight:500;letter-spacing:.1em}swp-day-date{display:block;font-size:30px;margin-top:4px}swp-resource-header{align-items:center;background:var(--color-surface);border-bottom:1px solid var(--color-grid-line);border-right:1px solid var(--color-grid-line);display:flex;flex-direction:column;justify-content:center;padding:12px;text-align:center}swp-resource-header:last-child{border-right:none}swp-resource-avatar{background:var(--color-border);border-radius:50%;display:block;height:40px;margin-bottom:8px;overflow:hidden;width:40px}swp-resource-avatar img{height:100%;-o-object-fit:cover;object-fit:cover;width:100%}swp-resource-name{color:var(--color-text);display:block;font-size:.875rem;font-weight:500;text-align:center}swp-allday-column{background:transparent;height:100%;opacity:0;position:relative;z-index:1}swp-allday-container swp-allday-event{align-items:center;background:#08f;border-radius:3px;color:#fff;display:flex;font-size:.75rem;height:22px!important;justify-content:flex-start;left:auto!important;margin:1px;overflow:hidden;padding:2px 4px;position:relative!important;right:auto!important;text-overflow:ellipsis;top:auto!important;white-space:nowrap;width:auto!important;z-index:2;--b-text:var(--color-text);background-color:color-mix(in srgb,var(--b-primary) 10%,var(--b-mix));border-left:4px solid var(--b-primary);color:var(--b-text)}.dragging:is(swp-allday-container swp-allday-event){opacity:1}.highlight:is(swp-allday-container swp-allday-event){background-color:color-mix(in srgb,var(--b-primary) 15%,var(--b-mix))!important}.max-event-indicator:is(swp-allday-container swp-allday-event){background:#e0e0e0!important;border:1px dashed #999!important;color:#666!important;cursor:pointer!important;font-style:italic;justify-content:center;opacity:.8;text-align:center!important}.max-event-indicator:is(swp-allday-container swp-allday-event):hover{background:#d0d0d0!important;color:#333!important;opacity:1}.max-event-indicator:is(swp-allday-container swp-allday-event) span{display:block;font-size:11px;font-weight:400;text-align:center;width:100%}.max-event-overflow-show:is(swp-allday-container swp-allday-event){opacity:1;transition:opacity .3s ease-in-out}.max-event-overflow-hide:is(swp-allday-container swp-allday-event){opacity:0;transition:opacity .3s ease-in-out}:is(swp-allday-container swp-allday-event) swp-event-time{display:none}:is(swp-allday-container swp-allday-event) swp-event-title{display:block;font-size:12px;line-height:18px}.transitioning:is(swp-allday-container swp-allday-event){transition:grid-area .2s ease-out,grid-row .2s ease-out,grid-column .2s ease-out}swp-scrollable-content{display:grid;overflow-x:auto;overflow-y:auto;position:relative;scroll-behavior:smooth;top:-1px}swp-scrollable-content::-webkit-scrollbar{height:var(--scrollbar-width,12px);width:var(--scrollbar-width,12px)}swp-scrollable-content::-webkit-scrollbar-track{background:var(--scrollbar-track-color,#f0f0f0)}swp-scrollable-content::-webkit-scrollbar-thumb{background:var(--scrollbar-color,#666);border-radius:var(--scrollbar-border-radius,6px)}:is(swp-scrollable-content::-webkit-scrollbar-thumb):hover{background:var(--scrollbar-hover-color,#333)}swp-scrollable-content{scrollbar-color:var(--scrollbar-color,#666) var(--scrollbar-track-color,#f0f0f0);scrollbar-width:auto}swp-time-grid{height:calc((var(--day-end-hour) - var(--day-start-hour))*var(--hour-height));position:relative}swp-time-grid:before{background:transparent;display:none;height:0}swp-time-grid:after,swp-time-grid:before{content:"";left:0;min-width:calc(var(--grid-columns, 7)*var(--day-column-min-width));position:absolute;right:0;top:0}swp-time-grid:after{background-image:repeating-linear-gradient(to bottom,transparent,transparent calc(var(--hour-height) - 1px),var(--color-hour-line) calc(var(--hour-height) - 1px),var(--color-hour-line) var(--hour-height));bottom:0;z-index:1}swp-grid-lines{background-image:repeating-linear-gradient(to bottom,transparent,transparent calc(var(--hour-height)/4 - 1px),var(--color-grid-line-light) calc(var(--hour-height)/4 - 1px),var(--color-grid-line-light) calc(var(--hour-height)/4));bottom:0;left:0;right:0;top:0;z-index:var(--z-grid)}swp-day-columns,swp-grid-lines{min-width:calc(var(--grid-columns, 7)*var(--day-column-min-width));position:absolute}swp-day-columns{display:grid;grid-template-columns:repeat(var(--grid-columns,7),minmax(var(--day-column-min-width),1fr));inset:0}swp-day-column{background:var(--color-event-grid);border-right:1px solid var(--color-grid-line);min-width:var(--day-column-min-width);position:relative}swp-day-column:last-child{border-right:none}swp-day-column:after,swp-day-column:before{background:var(--color-non-work-hours);content:"";left:0;opacity:.3;position:absolute;right:0;z-index:2}swp-day-column:before{height:var(--before-work-height,0);top:0}swp-day-column:after{bottom:0;top:var(--after-work-top,100%)}swp-day-column[data-work-hours=off]{background:var(--color-non-work-hours)}swp-day-column[data-work-hours=off]:after,swp-day-column[data-work-hours=off]:before{display:none}swp-resource-column{background:var(--color-event-grid);border-right:1px solid var(--color-grid-line);min-width:var(--day-column-min-width);position:relative}swp-resource-column:last-child{border-right:none}swp-events-layer{display:block;inset:0;position:absolute;z-index:var(--z-event)}swp-current-time-indicator{background:var(--color-current-time);height:2px;left:0;position:absolute;right:0;z-index:var(--z-current-time)}swp-current-time-indicator:before{background:var(--color-current-time);border-radius:3px;color:#fff;content:attr(data-time);font-size:.75rem;left:-55px;padding:2px 6px;position:absolute;top:-10px;white-space:nowrap}swp-current-time-indicator:after{background:var(--color-current-time);border-radius:50%;box-shadow:0 0 0 2px rgba(255,0,0,.3);content:"";height:10px;position:absolute;right:-4px;top:-4px;width:10px} \ No newline at end of file diff --git a/wwwroot/css/v2/calendar-v2-layout.css b/wwwroot/css/v2/calendar-v2-layout.css index 45fe8af..cc01eb2 100644 --- a/wwwroot/css/v2/calendar-v2-layout.css +++ b/wwwroot/css/v2/calendar-v2-layout.css @@ -175,11 +175,18 @@ swp-calendar-header { > swp-resource-header { grid-row: 2; } > 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-resource-header, -swp-team-header { +swp-team-header, +swp-department-header { padding: 8px; text-align: center; border-right: 1px solid var(--color-border); @@ -194,6 +201,13 @@ swp-team-header { 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 { background: var(--color-background-alt); font-size: 13px; diff --git a/wwwroot/data/mock-departments.json b/wwwroot/data/mock-departments.json new file mode 100644 index 0000000..033caad --- /dev/null +++ b/wwwroot/data/mock-departments.json @@ -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" + } +] diff --git a/wwwroot/v2.html b/wwwroot/v2.html index bbc41e4..a3f57d2 100644 --- a/wwwroot/v2.html +++ b/wwwroot/v2.html @@ -14,6 +14,7 @@ Datoer Resources Teams + Departments V2