Adds employee work schedule component

Introduces a new work schedule feature for managing employee shifts and schedules

Implements interactive schedule view with:
- Week-based schedule grid
- Shift status tracking (work, vacation, sick, off)
- Editable time ranges
- Repeat shift functionality

Enhances employee management with dynamic scheduling capabilities
This commit is contained in:
Janus C. H. Knudsen 2026-01-14 22:47:40 +01:00
parent d5a803ba80
commit 3214cbdc16
11 changed files with 1669 additions and 0 deletions

View file

@ -879,6 +879,531 @@ swp-data-row.focus-highlight {
}
}
/* ===========================================
WORK SCHEDULE TABLE
=========================================== */
swp-schedule-table {
display: grid;
grid-template-columns: 180px repeat(7, minmax(100px, 1fr));
border-radius: var(--radius-md);
overflow: hidden;
border: 1px solid var(--color-border);
background: var(--color-surface);
}
swp-schedule-cell {
display: flex;
flex-direction: column;
justify-content: center;
padding: 12px 16px;
min-height: 60px;
background: var(--color-surface);
border-right: 1px solid var(--color-border);
border-bottom: 1px solid var(--color-border);
user-select: none;
}
/* Last column: no right border */
swp-schedule-cell:nth-child(8n) {
border-right: none;
}
/* Last row: no bottom border */
swp-schedule-cell:nth-last-child(-n+8) {
border-bottom: none;
}
swp-schedule-cell.header {
background: var(--color-background-alt);
font-weight: var(--font-weight-medium);
font-size: 13px;
color: var(--color-text-secondary);
min-height: 48px;
text-align: center;
align-items: center;
}
swp-schedule-cell.header.week-number {
font-size: 15px;
font-weight: var(--font-weight-semibold);
color: var(--color-text);
}
swp-schedule-cell.header.closed {
background: color-mix(in srgb, #f59e0b 10%, var(--color-background-alt));
border-top: 2px solid #f59e0b;
border-left: 2px solid #f59e0b;
border-right: 2px solid #f59e0b;
border-bottom: none;
swp-day-name {
color: #d97706;
}
}
swp-schedule-cell.employee {
align-items: flex-start;
gap: 2px;
}
swp-schedule-cell.day {
align-items: center;
text-align: center;
position: relative;
}
swp-schedule-cell.day.closed-day {
background: color-mix(in srgb, #f59e0b 6%, var(--color-surface));
border-left: 2px solid #f59e0b;
border-right: 2px solid #f59e0b;
swp-time-display {
opacity: 0.5;
}
}
/* Last cell in closed column gets bottom border */
swp-schedule-cell.day.closed-day:nth-last-child(-n+8) {
border-bottom: 2px solid #f59e0b;
}
/* Schedule employee info */
swp-schedule-cell swp-employee-name {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text);
}
swp-schedule-cell swp-employee-hours {
font-family: var(--font-mono);
font-size: 12px;
color: var(--color-text-muted);
}
/* Day header */
swp-day-name {
font-weight: var(--font-weight-medium);
color: var(--color-text);
}
swp-day-date {
font-size: 12px;
color: var(--color-text-muted);
font-weight: var(--font-weight-normal);
}
/* Time display variants */
swp-time-display {
font-family: var(--font-mono);
font-size: 12px;
font-weight: var(--font-weight-medium);
padding: 4px 8px;
border-radius: 4px;
background: var(--bg-teal-light);
color: var(--color-text);
white-space: nowrap;
min-width: 90px;
text-align: center;
display: inline-block;
}
swp-time-display.off {
background: transparent;
color: var(--color-text-muted);
}
swp-time-display.off.off-override {
background: color-mix(in srgb, #7c3aed 12%, white);
color: #6d28d9;
}
swp-time-display.vacation {
background: color-mix(in srgb, #f59e0b 15%, white);
color: #b45309;
}
swp-time-display.sick {
background: color-mix(in srgb, #ef4444 15%, white);
color: #dc2626;
}
/* Edit mode */
body.schedule-edit-mode swp-schedule-cell.day {
cursor: pointer;
}
body.schedule-edit-mode swp-schedule-cell.day:hover {
background: var(--color-background-alt);
}
body.schedule-edit-mode swp-schedule-cell.day.selected {
background: color-mix(in srgb, var(--color-teal) 12%, white);
border: 2px solid var(--color-teal);
margin: -1px;
padding: 11px 15px;
}
body.schedule-edit-mode swp-schedule-cell.header:not(.week-number) {
cursor: pointer;
}
body.schedule-edit-mode swp-schedule-cell.header:not(.week-number):hover {
background: var(--color-border);
}
/* Status options in drawer */
swp-status-options {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
swp-status-option {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all var(--transition-fast);
font-size: 13px;
font-weight: var(--font-weight-medium);
background: var(--color-background-alt);
color: var(--color-text-secondary);
&::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
&[data-status="work"] {
--status-color: var(--color-teal);
}
&[data-status="off"] {
--status-color: #7c3aed;
}
&[data-status="vacation"] {
--status-color: #f59e0b;
}
&[data-status="sick"] {
--status-color: #e53935;
}
&::before {
background: var(--status-color);
}
&:hover {
background: var(--color-border);
}
&.selected {
background: color-mix(in srgb, var(--status-color) 15%, white);
color: var(--status-color);
}
}
/* Time range slider */
swp-time-range {
display: flex;
align-items: center;
gap: 12px;
}
swp-time-range-slider {
position: relative;
flex: 1;
height: 20px;
display: flex;
align-items: center;
}
swp-time-range-track {
position: absolute;
width: 100%;
height: 4px;
background: var(--color-border);
border-radius: 2px;
}
swp-time-range-fill {
position: absolute;
height: 4px;
background: var(--color-teal);
border-radius: 2px;
cursor: grab;
&:active {
cursor: grabbing;
}
}
swp-time-range-slider input[type="range"] {
position: absolute;
width: 100%;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: transparent;
pointer-events: none;
margin: 0;
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
background: var(--color-teal);
border: 2px solid white;
border-radius: 50%;
cursor: pointer;
pointer-events: auto;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
&::-moz-range-thumb {
width: 14px;
height: 14px;
background: var(--color-teal);
border: 2px solid white;
border-radius: 50%;
cursor: pointer;
pointer-events: auto;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
}
swp-time-range-label {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
min-width: 100px;
text-align: center;
background: var(--color-background-alt);
padding: 6px 12px;
border-radius: 4px;
}
swp-time-range-times {
font-size: 13px;
font-family: var(--font-mono);
font-weight: var(--font-weight-medium);
color: var(--color-text);
white-space: nowrap;
}
swp-time-range-duration {
font-size: 11px;
font-family: var(--font-mono);
color: var(--color-text-secondary);
white-space: nowrap;
}
/* Toggle options (Enkelt/Gentagelse) */
swp-toggle-options {
display: flex;
gap: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
overflow: hidden;
}
swp-toggle-option {
flex: 1;
padding: 10px 16px;
text-align: center;
font-size: var(--font-size-sm);
cursor: pointer;
background: var(--color-surface);
border-right: 1px solid var(--color-border);
transition: all var(--transition-fast);
&:last-child {
border-right: none;
}
&:hover {
background: var(--color-background-alt);
}
&.selected {
background: var(--color-teal);
color: white;
}
}
/* Schedule drawer employee display */
swp-employee-display {
display: flex;
align-items: center;
gap: 10px;
swp-employee-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(135deg, var(--color-teal) 0%, #00695c 100%);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: var(--font-weight-semibold);
font-size: 12px;
flex-shrink: 0;
}
&.empty swp-employee-avatar {
background: var(--color-border);
color: var(--color-text-muted);
}
&.multi swp-employee-avatar {
background: var(--color-text-muted);
}
}
/* ===========================================
SCHEDULE DRAWER (matches POC exactly)
=========================================== */
/* Drawer header with background */
#schedule-drawer swp-drawer-header {
background: var(--color-background-alt);
padding: 20px 24px;
}
#schedule-drawer swp-drawer-title {
font-size: 18px;
}
/* Drawer body/content */
#schedule-drawer swp-drawer-body {
padding: 24px;
}
/* Form row layout */
#schedule-drawer swp-form-row {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 16px;
}
/* Form labels - uppercase style from POC */
#schedule-drawer swp-form-label {
font-size: 11px;
font-weight: 400;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
.optional,
.auto {
font-weight: 400;
text-transform: none;
color: var(--color-text-muted);
}
}
/* Form value (read-only display) */
#schedule-drawer swp-form-value {
font-size: 15px;
font-weight: 500;
color: var(--color-text);
}
/* Form divider */
#schedule-drawer swp-form-divider {
display: block;
height: 1px;
background: var(--color-border);
margin: 20px 0;
}
/* Form hint text */
#schedule-drawer swp-form-hint {
display: block;
font-size: 12px;
color: var(--color-text-muted);
margin: -8px 0 16px 0;
line-height: 1.4;
}
/* Form group - gray card background from POC */
#schedule-drawer swp-form-group {
display: block;
padding: 16px;
background: var(--color-background-alt);
border-radius: 8px;
margin-top: 16px;
swp-form-row:last-child {
margin-bottom: 0;
}
}
/* Form select wrapper */
#schedule-drawer swp-form-select {
display: block;
select {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--color-border);
border-radius: 6px;
font-size: 14px;
font-family: var(--font-family);
color: var(--color-text);
background: var(--color-surface);
cursor: pointer;
&:focus {
outline: none;
border-color: var(--color-teal);
}
}
}
/* Text inputs in drawer */
#schedule-drawer input[type="text"],
#schedule-drawer input[type="date"] {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--color-border);
border-radius: 6px;
font-size: 14px;
font-family: var(--font-family);
color: var(--color-text);
background: var(--color-surface);
&::placeholder {
color: var(--color-text-muted);
}
&:focus {
outline: none;
border-color: var(--color-teal);
}
}
/* Drawer footer with background */
#schedule-drawer swp-drawer-footer {
display: flex;
gap: 12px;
padding: 20px 24px;
border-top: 1px solid var(--color-border);
background: var(--color-background-alt);
swp-btn {
flex: 1;
}
}
/* ===========================================
RESPONSIVE
=========================================== */

View file

@ -8,6 +8,7 @@
export class EmployeesController {
private ratesSync: RatesSyncController | null = null;
private scheduleController: ScheduleController | null = null;
private listView: HTMLElement | null = null;
private detailView: HTMLElement | null = null;
@ -25,6 +26,7 @@ export class EmployeesController {
this.setupHistoryNavigation();
this.restoreStateFromUrl();
this.ratesSync = new RatesSyncController();
this.scheduleController = new ScheduleController();
}
/**
@ -397,3 +399,646 @@ class RatesSyncController {
});
}
}
/**
* Schedule Controller
*
* Handles work schedule (vagtplan) functionality:
* - Edit mode toggle
* - Cell selection (single, ctrl+click, shift+click)
* - Drawer interaction
* - Time range slider
* - Status options
*/
class ScheduleController {
private isEditMode = false;
private selectedCells: HTMLElement[] = [];
private anchorCell: HTMLElement | null = null;
private drawer: HTMLElement | null = null;
private editBtn: HTMLElement | null = null;
private scheduleTable: HTMLElement | null = null;
// Time range constants
private readonly TIME_RANGE_MAX = 60; // 15 hours (06:00-21:00) * 4 intervals
constructor() {
this.drawer = document.getElementById('schedule-drawer');
this.editBtn = document.getElementById('scheduleEditBtn');
this.scheduleTable = document.getElementById('scheduleTable');
if (!this.scheduleTable) return;
this.setupEditModeToggle();
this.setupCellSelection();
this.setupStatusOptions();
this.setupTypeToggle();
this.setupTimeRangeSlider();
this.setupDrawerSave();
}
/**
* Setup edit mode toggle button
*/
private setupEditModeToggle(): void {
this.editBtn?.addEventListener('click', () => {
this.isEditMode = !this.isEditMode;
document.body.classList.toggle('schedule-edit-mode', this.isEditMode);
if (this.editBtn) {
const icon = this.editBtn.querySelector('i');
const text = this.editBtn.childNodes[this.editBtn.childNodes.length - 1];
if (this.isEditMode) {
icon?.classList.replace('ph-pencil-simple', 'ph-check');
if (text && text.nodeType === Node.TEXT_NODE) {
text.textContent = ' Færdig';
}
this.openDrawer();
this.showEmptyState();
} else {
icon?.classList.replace('ph-check', 'ph-pencil-simple');
if (text && text.nodeType === Node.TEXT_NODE) {
text.textContent = ' Rediger';
}
this.closeDrawer();
this.clearSelection();
}
}
});
}
/**
* Setup cell click selection
*/
private setupCellSelection(): void {
if (!this.scheduleTable) return;
const dayCells = this.scheduleTable.querySelectorAll<HTMLElement>('swp-schedule-cell.day');
dayCells.forEach(cell => {
// Double-click to enter edit mode
cell.addEventListener('dblclick', () => {
if (!this.isEditMode) {
this.isEditMode = true;
document.body.classList.add('schedule-edit-mode');
if (this.editBtn) {
const icon = this.editBtn.querySelector('i');
const text = this.editBtn.childNodes[this.editBtn.childNodes.length - 1];
icon?.classList.replace('ph-pencil-simple', 'ph-check');
if (text && text.nodeType === Node.TEXT_NODE) {
text.textContent = ' Færdig';
}
}
this.openDrawer();
this.clearSelection();
cell.classList.add('selected');
this.selectedCells = [cell];
this.anchorCell = cell;
this.updateDrawerFields();
}
});
// Click selection in edit mode
cell.addEventListener('click', (e: MouseEvent) => {
if (!this.isEditMode) return;
if (e.shiftKey && this.anchorCell) {
// Shift+click: range selection
this.selectRange(this.anchorCell, cell);
} else if (e.ctrlKey || e.metaKey) {
// Ctrl/Cmd+click: toggle selection
if (cell.classList.contains('selected')) {
cell.classList.remove('selected');
this.selectedCells = this.selectedCells.filter(c => c !== cell);
} else {
cell.classList.add('selected');
this.selectedCells.push(cell);
this.anchorCell = cell;
}
} else {
// Single click: replace selection
this.clearSelection();
cell.classList.add('selected');
this.selectedCells = [cell];
this.anchorCell = cell;
}
this.updateDrawerFields();
});
});
}
/**
* Select a range of cells between anchor and target
*/
private selectRange(anchor: HTMLElement, target: HTMLElement): void {
if (!this.scheduleTable) return;
const allDayCells = Array.from(this.scheduleTable.querySelectorAll<HTMLElement>('swp-schedule-cell.day'));
const anchorIdx = allDayCells.indexOf(anchor);
const targetIdx = allDayCells.indexOf(target);
// Calculate grid positions (7 columns for days)
const anchorRow = Math.floor(anchorIdx / 7);
const anchorCol = anchorIdx % 7;
const targetRow = Math.floor(targetIdx / 7);
const targetCol = targetIdx % 7;
const minRow = Math.min(anchorRow, targetRow);
const maxRow = Math.max(anchorRow, targetRow);
const minCol = Math.min(anchorCol, targetCol);
const maxCol = Math.max(anchorCol, targetCol);
this.clearSelection();
allDayCells.forEach((c, idx) => {
const row = Math.floor(idx / 7);
const col = idx % 7;
if (row >= minRow && row <= maxRow && col >= minCol && col <= maxCol) {
c.classList.add('selected');
this.selectedCells.push(c);
}
});
}
/**
* Clear all selected cells
*/
private clearSelection(): void {
this.selectedCells.forEach(c => c.classList.remove('selected'));
this.selectedCells = [];
}
/**
* Get initials from name
*/
private getInitials(name: string): string {
return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
}
/**
* Show empty state in drawer
*/
private showEmptyState(): void {
const employeeDisplay = document.getElementById('scheduleFieldEmployee');
const avatar = document.getElementById('scheduleFieldAvatar');
const employeeName = document.getElementById('scheduleFieldEmployeeName');
const dateField = document.getElementById('scheduleFieldDate');
const weekdayField = document.getElementById('scheduleFieldWeekday');
const drawerBody = this.drawer?.querySelector('swp-drawer-body') as HTMLElement;
const drawerFooter = this.drawer?.querySelector('swp-drawer-footer') as HTMLElement;
if (employeeDisplay) employeeDisplay.classList.add('empty');
if (employeeDisplay) employeeDisplay.classList.remove('multi');
if (avatar) avatar.textContent = '?';
if (employeeName) employeeName.textContent = 'Vælg celle...';
if (dateField) dateField.textContent = '—';
if (weekdayField) weekdayField.textContent = '—';
if (drawerBody) {
drawerBody.style.opacity = '0.5';
drawerBody.style.pointerEvents = 'none';
}
if (drawerFooter) drawerFooter.style.display = 'none';
}
/**
* Show edit state in drawer
*/
private showEditState(): void {
const drawerBody = this.drawer?.querySelector('swp-drawer-body') as HTMLElement;
const drawerFooter = this.drawer?.querySelector('swp-drawer-footer') as HTMLElement;
if (drawerBody) {
drawerBody.style.opacity = '1';
drawerBody.style.pointerEvents = 'auto';
}
if (drawerFooter) drawerFooter.style.display = 'flex';
}
/**
* Update drawer fields based on selected cells
*/
private updateDrawerFields(): void {
if (this.selectedCells.length === 0) {
this.showEmptyState();
return;
}
this.showEditState();
const employeeDisplay = document.getElementById('scheduleFieldEmployee');
const avatar = document.getElementById('scheduleFieldAvatar');
const employeeName = document.getElementById('scheduleFieldEmployeeName');
const dateField = document.getElementById('scheduleFieldDate');
const weekdayField = document.getElementById('scheduleFieldWeekday');
employeeDisplay?.classList.remove('empty', 'multi');
if (this.selectedCells.length === 1) {
const cell = this.selectedCells[0];
const name = cell.dataset.employee || '';
const date = cell.dataset.date || '';
const dayName = this.getDayName(cell.dataset.day || '');
if (employeeName) employeeName.textContent = name;
if (avatar) avatar.textContent = this.getInitials(name);
if (dateField) dateField.textContent = this.formatDate(date);
if (weekdayField) weekdayField.textContent = dayName;
this.prefillFormFromCell(cell);
} else {
const employees = [...new Set(this.selectedCells.map(c => c.dataset.employee))];
const days = [...new Set(this.selectedCells.map(c => c.dataset.day))];
if (employees.length === 1) {
if (employeeName) employeeName.textContent = employees[0] || '';
if (avatar) avatar.textContent = this.getInitials(employees[0] || '');
if (dateField) dateField.textContent = `${this.selectedCells.length} dage valgt`;
} else {
if (employeeName) employeeName.textContent = `${this.selectedCells.length} valgt`;
if (avatar) avatar.textContent = String(this.selectedCells.length);
employeeDisplay?.classList.add('multi');
if (dateField) dateField.textContent = `${employees.length} medarbejdere, ${days.length} dage`;
}
if (days.length === 1) {
if (weekdayField) weekdayField.textContent = this.getDayName(days[0] || '');
} else {
if (weekdayField) weekdayField.textContent = 'Flere dage';
}
this.resetFormToDefault();
}
}
/**
* Format ISO date to display format
*/
private formatDate(isoDate: string): string {
const date = new Date(isoDate);
return `${date.getDate()}/${date.getMonth() + 1}/${date.getFullYear()}`;
}
/**
* Get full day name from short name
*/
private getDayName(shortName: string): string {
const dayMap: Record<string, string> = {
'Man': 'Mandag', 'Tir': 'Tirsdag', 'Ons': 'Onsdag',
'Tor': 'Torsdag', 'Fre': 'Fredag', 'Lør': 'Lørdag', 'Søn': 'Søndag'
};
return dayMap[shortName] || shortName;
}
/**
* Prefill form from cell data
*/
private prefillFormFromCell(cell: HTMLElement): void {
const timeDisplay = cell.querySelector('swp-time-display');
if (!timeDisplay) return;
let status = 'work';
if (timeDisplay.classList.contains('off')) status = 'off';
else if (timeDisplay.classList.contains('vacation')) status = 'vacation';
else if (timeDisplay.classList.contains('sick')) status = 'sick';
// Update status options
document.querySelectorAll('#scheduleStatusOptions swp-status-option').forEach(opt => {
opt.classList.toggle('selected', (opt as HTMLElement).dataset.status === status);
});
// Show/hide time row
const timeRow = document.getElementById('scheduleTimeRow');
if (timeRow) timeRow.style.display = status === 'work' ? 'flex' : 'none';
// Parse time if work status
if (status === 'work') {
const timeText = timeDisplay.textContent?.trim() || '';
const timeMatch = timeText.match(/(\d{2}:\d{2})\s*-\s*(\d{2}:\d{2})/);
if (timeMatch) {
this.setTimeRange(timeMatch[1], timeMatch[2]);
}
}
// Reset type to template
document.querySelectorAll('#scheduleTypeOptions swp-toggle-option').forEach(opt => {
opt.classList.toggle('selected', (opt as HTMLElement).dataset.value === 'template');
});
const repeatGroup = document.getElementById('scheduleRepeatGroup');
if (repeatGroup) repeatGroup.style.display = 'block';
}
/**
* Reset form to default values
*/
private resetFormToDefault(): void {
document.querySelectorAll('#scheduleStatusOptions swp-status-option').forEach(opt => {
opt.classList.toggle('selected', (opt as HTMLElement).dataset.status === 'work');
});
const timeRow = document.getElementById('scheduleTimeRow');
if (timeRow) timeRow.style.display = 'flex';
this.setTimeRange('09:00', '17:00');
document.querySelectorAll('#scheduleTypeOptions swp-toggle-option').forEach(opt => {
opt.classList.toggle('selected', (opt as HTMLElement).dataset.value === 'template');
});
const repeatGroup = document.getElementById('scheduleRepeatGroup');
if (repeatGroup) repeatGroup.style.display = 'block';
const noteField = document.getElementById('scheduleFieldNote') as HTMLInputElement;
if (noteField) noteField.value = '';
}
/**
* Setup status options click handlers
*/
private setupStatusOptions(): void {
document.querySelectorAll('#scheduleStatusOptions swp-status-option').forEach(option => {
option.addEventListener('click', () => {
document.querySelectorAll('#scheduleStatusOptions swp-status-option').forEach(o =>
o.classList.remove('selected')
);
option.classList.add('selected');
const status = (option as HTMLElement).dataset.status;
const timeRow = document.getElementById('scheduleTimeRow');
if (timeRow) timeRow.style.display = status === 'work' ? 'flex' : 'none';
this.updateSelectedCellsStatus();
});
});
}
/**
* Setup type toggle (single/repeat)
*/
private setupTypeToggle(): void {
document.querySelectorAll('#scheduleTypeOptions swp-toggle-option').forEach(option => {
option.addEventListener('click', () => {
document.querySelectorAll('#scheduleTypeOptions swp-toggle-option').forEach(o =>
o.classList.remove('selected')
);
option.classList.add('selected');
const isTemplate = (option as HTMLElement).dataset.value === 'template';
const repeatGroup = document.getElementById('scheduleRepeatGroup');
if (repeatGroup) repeatGroup.style.display = isTemplate ? 'block' : 'none';
});
});
}
/**
* Convert slider value to time string
*/
private valueToTime(value: number): string {
const totalMinutes = (value * 15) + (6 * 60);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
}
/**
* Convert time string to slider value
*/
private timeToValue(timeStr: string): number {
const [hours, minutes] = timeStr.split(':').map(Number);
const totalMinutes = hours * 60 + minutes;
return Math.round((totalMinutes - 6 * 60) / 15);
}
/**
* Set time range slider values
*/
private setTimeRange(startTime: string, endTime: string): void {
const timeRange = document.getElementById('scheduleTimeRange');
if (!timeRange) return;
const startInput = timeRange.querySelector<HTMLInputElement>('.range-start');
const endInput = timeRange.querySelector<HTMLInputElement>('.range-end');
if (startInput) startInput.value = String(this.timeToValue(startTime));
if (endInput) endInput.value = String(this.timeToValue(endTime));
this.updateTimeRangeDisplay(timeRange);
}
/**
* Update time range display
*/
private updateTimeRangeDisplay(container: HTMLElement): void {
const startInput = container.querySelector<HTMLInputElement>('.range-start');
const endInput = container.querySelector<HTMLInputElement>('.range-end');
const fill = container.querySelector<HTMLElement>('swp-time-range-fill');
const timesEl = container.querySelector('swp-time-range-times');
const durationEl = container.querySelector('swp-time-range-duration');
if (!startInput || !endInput) return;
let startVal = parseInt(startInput.value);
let endVal = parseInt(endInput.value);
// Ensure start doesn't exceed end
if (startVal > endVal) {
if (startInput === document.activeElement) {
startInput.value = String(endVal);
startVal = endVal;
} else {
endInput.value = String(startVal);
endVal = startVal;
}
}
// Update fill bar
if (fill) {
const startPercent = (startVal / this.TIME_RANGE_MAX) * 100;
const endPercent = (endVal / this.TIME_RANGE_MAX) * 100;
fill.style.left = startPercent + '%';
fill.style.width = (endPercent - startPercent) + '%';
}
// Calculate duration
const durationMinutes = (endVal - startVal) * 15;
const durationHours = durationMinutes / 60;
const durationText = durationHours % 1 === 0
? `${durationHours} timer`
: `${durationHours.toFixed(1).replace('.', ',')} timer`;
if (timesEl) timesEl.textContent = `${this.valueToTime(startVal)} ${this.valueToTime(endVal)}`;
if (durationEl) durationEl.textContent = durationText;
}
/**
* Setup time range slider
*/
private setupTimeRangeSlider(): void {
const timeRange = document.getElementById('scheduleTimeRange');
if (!timeRange) return;
const startInput = timeRange.querySelector<HTMLInputElement>('.range-start');
const endInput = timeRange.querySelector<HTMLInputElement>('.range-end');
const fill = timeRange.querySelector<HTMLElement>('swp-time-range-fill');
const track = timeRange.querySelector<HTMLElement>('swp-time-range-track');
this.updateTimeRangeDisplay(timeRange);
startInput?.addEventListener('input', () => {
this.updateTimeRangeDisplay(timeRange);
this.updateSelectedCellsTime();
});
endInput?.addEventListener('input', () => {
this.updateTimeRangeDisplay(timeRange);
this.updateSelectedCellsTime();
});
// Drag fill bar to move entire range
if (fill && track && startInput && endInput) {
let isDragging = false;
let dragStartX = 0;
let dragStartValues = { start: 0, end: 0 };
fill.addEventListener('mousedown', (e: MouseEvent) => {
isDragging = true;
dragStartX = e.clientX;
dragStartValues.start = parseInt(startInput.value);
dragStartValues.end = parseInt(endInput.value);
e.preventDefault();
});
document.addEventListener('mousemove', (e: MouseEvent) => {
if (!isDragging) return;
const sliderWidth = track.offsetWidth;
const deltaX = e.clientX - dragStartX;
const deltaValue = Math.round((deltaX / sliderWidth) * this.TIME_RANGE_MAX);
const duration = dragStartValues.end - dragStartValues.start;
let newStart = dragStartValues.start + deltaValue;
let newEnd = dragStartValues.end + deltaValue;
if (newStart < 0) {
newStart = 0;
newEnd = duration;
}
if (newEnd > this.TIME_RANGE_MAX) {
newEnd = this.TIME_RANGE_MAX;
newStart = this.TIME_RANGE_MAX - duration;
}
startInput.value = String(newStart);
endInput.value = String(newEnd);
this.updateTimeRangeDisplay(timeRange);
this.updateSelectedCellsTime();
});
document.addEventListener('mouseup', () => {
isDragging = false;
});
}
}
/**
* Update selected cells with current time
*/
private updateSelectedCellsTime(): void {
const selectedStatus = document.querySelector('#scheduleStatusOptions swp-status-option.selected') as HTMLElement;
const status = selectedStatus?.dataset.status || 'work';
if (status !== 'work') return;
const timeRange = document.getElementById('scheduleTimeRange');
if (!timeRange) return;
const startInput = timeRange.querySelector<HTMLInputElement>('.range-start');
const endInput = timeRange.querySelector<HTMLInputElement>('.range-end');
if (!startInput || !endInput) return;
const startTime = this.valueToTime(parseInt(startInput.value));
const endTime = this.valueToTime(parseInt(endInput.value));
const formattedTime = `${startTime} - ${endTime}`;
this.selectedCells.forEach(cell => {
const timeDisplay = cell.querySelector('swp-time-display');
if (timeDisplay && !timeDisplay.classList.contains('off') &&
!timeDisplay.classList.contains('vacation') &&
!timeDisplay.classList.contains('sick')) {
timeDisplay.textContent = formattedTime;
}
});
}
/**
* Update selected cells with current status
*/
private updateSelectedCellsStatus(): void {
const selectedStatus = document.querySelector('#scheduleStatusOptions swp-status-option.selected') as HTMLElement;
const status = selectedStatus?.dataset.status || 'work';
const timeRange = document.getElementById('scheduleTimeRange');
const startInput = timeRange?.querySelector<HTMLInputElement>('.range-start');
const endInput = timeRange?.querySelector<HTMLInputElement>('.range-end');
const startTime = startInput ? this.valueToTime(parseInt(startInput.value)) : '09:00';
const endTime = endInput ? this.valueToTime(parseInt(endInput.value)) : '17:00';
this.selectedCells.forEach(cell => {
const timeDisplay = cell.querySelector('swp-time-display');
if (!timeDisplay) return;
timeDisplay.classList.remove('off', 'off-override', 'vacation', 'sick');
switch (status) {
case 'work':
timeDisplay.textContent = `${startTime} - ${endTime}`;
break;
case 'off':
timeDisplay.classList.add('off');
timeDisplay.textContent = '—';
break;
case 'vacation':
timeDisplay.classList.add('vacation');
timeDisplay.textContent = 'Ferie';
break;
case 'sick':
timeDisplay.classList.add('sick');
timeDisplay.textContent = 'Syg';
break;
}
});
}
/**
* Setup drawer save button
*/
private setupDrawerSave(): void {
const saveBtn = document.getElementById('scheduleDrawerSave');
saveBtn?.addEventListener('click', () => {
// Changes are already applied in real-time
this.clearSelection();
this.showEmptyState();
});
}
/**
* Open the schedule drawer
*/
private openDrawer(): void {
this.drawer?.classList.add('open');
document.getElementById('drawerOverlay')?.classList.add('active');
document.body.style.overflow = 'hidden';
}
/**
* Close the schedule drawer
*/
private closeDrawer(): void {
this.drawer?.classList.remove('open');
document.getElementById('drawerOverlay')?.classList.remove('active');
document.body.style.overflow = '';
}
}

View file

@ -0,0 +1,30 @@
/**
* Work Schedule Types
*
* Types for employee work schedule (arbejdstidsplan)
*/
export type ShiftStatus = 'work' | 'off' | 'vacation' | 'sick';
export interface WorkScheduleShift {
status: ShiftStatus;
start?: string; // "09:00" - only when status=work
end?: string; // "17:00" - only when status=work
note?: string;
}
export interface EmployeeSchedule {
employeeId: string;
name: string;
weeklyHours: number;
schedule: Record<string, WorkScheduleShift>; // key = ISO date "2025-12-23"
}
export interface WeekSchedule {
weekNumber: number;
year: number;
startDate: string; // ISO date "2025-12-23"
endDate: string; // ISO date "2025-12-29"
closedDays: string[]; // ["2025-12-25"]
employees: EmployeeSchedule[];
}