Files
tilbudgivern/frontend/src/components/CalendarGrid.js
T
alexpolo1 bbb75ccce2 feat: Add Advanced Dashboard and perform repo cleanup
This commit introduces a new Advanced Dashboard feature, which provides enhanced analytics and system health monitoring.

Additionally, this commit includes a general repository cleanup, which consists of:

- Removal of a duplicate  file from the root directory.
- Addition of temporary report and documentation files to .
- Relocation of several test files from the root to the  directory.
- Staging of various other modified files that were previously uncommitted.

The new Advanced Dashboard feature includes:
-  and  for the UI.
-  for the backend API.
-  for API documentation.

New tests for smart packages and autosave functionality have also been added.
2026-02-28 17:38:23 +00:00

501 lines
21 KiB
JavaScript

import React, { useState, useMemo } from 'react';
import './CalendarGrid.css';
/**
* CalendarGrid - 1:1 match af design fra screenshot
* Viser medarbejdere i rækker og dage i kolonner med farve-kodede opgaver
*/
const CalendarGrid = ({ calendarData, employees }) => {
const [currentWeek, setCurrentWeek] = useState(0); // 0 = current week
const [_employeeTypeFilter] = useState('all'); // eslint-disable-line no-unused-vars
// Simplified filter - just 3 options instead of 8 checkboxes
const [viewMode, setViewMode] = useState('work-only'); // 'work-only' | 'work-and-personal' | 'all'
// Status farve mapping baseret på screenshot
const STATUS_COLORS = {
// Planlagt tid statuser (fra case_statuses)
'Åben': { bg: '#22c55e', text: '#fff', label: 'Åben' },
'åben': { bg: '#22c55e', text: '#fff', label: 'Åben' },
99991: { bg: '#22c55e', text: '#fff', label: 'Åben' },
'Lukket': { bg: '#f97316', text: '#fff', label: 'Lukket' },
'lukket': { bg: '#f97316', text: '#fff', label: 'Lukket' },
999964: { bg: '#f97316', text: '#fff', label: 'Lukket' },
'Aflyst': { bg: '#fbbf24', text: '#000', label: 'Aflyst' },
'aflyst': { bg: '#fbbf24', text: '#000', label: 'Aflyst' },
999976: { bg: '#fbbf24', text: '#000', label: 'Aflyst' },
'Afsluttet': { bg: '#dc2626', text: '#fff', label: 'Afsluttet' },
'afsluttet': { bg: '#dc2626', text: '#fff', label: 'Afsluttet' },
'Bogført': { bg: '#6b7280', text: '#fff', label: 'Bogført' },
'bogført': { bg: '#6b7280', text: '#fff', label: 'Bogført' },
999969: { bg: '#6b7280', text: '#fff', label: 'Bogført' },
'Murer igangværende': { bg: '#3b82f6', text: '#fff', label: 'Murer igangværende' },
999972: { bg: '#3b82f6', text: '#fff', label: 'Murer igangværende' },
'Murer kommende': { bg: '#06b6d4', text: '#fff', label: 'Murer kommende' },
999971: { bg: '#06b6d4', text: '#fff', label: 'Murer kommende' },
'Tømrer igangværende': { bg: '#059669', text: '#fff', label: 'Tømrer igangværende' },
999970: { bg: '#059669', text: '#fff', label: 'Tømrer igangværende' },
'Tømrer kommende': { bg: '#10b981', text: '#fff', label: 'Tømrer kommende' },
999974: { bg: '#10b981', text: '#fff', label: 'Tømrer kommende' },
// Hour types (event_type eller title)
'Arbejde': { bg: '#059669', text: '#fff', label: 'Arbejde' },
'Projekt arbejde': { bg: '#10b981', text: '#fff', label: 'Projekt arbejde' },
'Møde': { bg: '#8b5cf6', text: '#fff', label: 'Møde' },
'Kørsel': { bg: '#ec4899', text: '#fff', label: 'Kørsel' },
'Administration': { bg: '#f59e0b', text: '#000', label: 'Administration' },
'Lager': { bg: '#14b8a6', text: '#fff', label: 'Lager' },
'Vedligeholdelse': { bg: '#64748b', text: '#fff', label: 'Vedligeholdelse' },
// Fravær
'Ferie': { bg: '#fbbf24', text: '#000', label: 'Ferie' },
'Syg': { bg: '#fb923c', text: '#000', label: 'Syg' },
'Fri': { bg: '#d1d5db', text: '#000', label: 'Fri' },
'Pause': { bg: '#e5e7eb', text: '#000', label: 'Pause' },
// Employee types (arbejdstid)
'Svend': { bg: '#c0c0c0', text: '#000', label: 'Svend' },
'Svend, 50% overarbejdstillæg': { bg: '#d4d4d8', text: '#000', label: 'Svend, 50% OT' },
'Svend, 100% overarbejdstillæg': { bg: '#e4e4e7', text: '#000', label: 'Svend, 100% OT' },
'Lærling, 1.periode': { bg: '#c0c0c0', text: '#000', label: 'Lærling, 1.periode' },
'Lærling, 2.periode': { bg: '#c0c0c0', text: '#000', label: 'Lærling, 2.periode' },
'Lærling, 3.periode': { bg: '#c0c0c0', text: '#000', label: 'Lærling 3.periode' },
'Lærling, 4.periode': { bg: '#c0c0c0', text: '#000', label: 'Lærling, 4.periode' },
'Voksenlærling': { bg: '#c0c0c0', text: '#000', label: 'Voksenlærling' },
// Default
'default': { bg: '#94a3b8', text: '#fff', label: 'Opgave' }
};
// Get week dates (Monday to Sunday)
const getWeekDates = (weekOffset = 0) => {
const today = new Date();
const currentDayOfWeek = (today.getDay() + 6) % 7; // Monday = 0
const monday = new Date(today);
monday.setDate(today.getDate() - currentDayOfWeek + (weekOffset * 7));
const weekDates = [];
for (let i = 0; i < 7; i++) {
const date = new Date(monday);
date.setDate(monday.getDate() + i);
weekDates.push(date);
}
return weekDates;
};
const weekDates = getWeekDates(currentWeek);
// Get week range as string
const getWeekRange = () => {
const start = weekDates[0];
const end = weekDates[6];
return `Uge ${getWeekNumber(start)} - ${start.getDate()}/${start.getMonth() + 1} til ${end.getDate()}/${end.getMonth() + 1}`;
};
// Get ISO week number
const getWeekNumber = (date) => {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(),0,1));
return Math.ceil((((d - yearStart) / 86400000) + 1)/7);
};
// Group entries by employee and date - using filtered entries
const getEmployeeSchedule = (employeeId, date) => {
if (!filteredEntries.length) return [];
const dayStart = new Date(date);
dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(date);
dayEnd.setHours(23, 59, 59, 999);
return filteredEntries.filter(entry => {
// Parse start and end times
const entryStart = entry.start_time * 1000 ? new Date(entry.start_time * 1000) : new Date(entry.start_time);
const entryEnd = entry.end_time * 1000 ? new Date(entry.end_time * 1000) : new Date(entry.end_time);
// Show entry if it overlaps with this day
// Entry overlaps if: (entry_start <= day_end) AND (entry_end >= day_start)
return entry.employee_id === employeeId &&
entryStart <= dayEnd &&
entryEnd >= dayStart;
}).sort((a, b) => {
const timeA = a.start_time * 1000 || a.start_time;
const timeB = b.start_time * 1000 || b.start_time;
return timeA - timeB;
});
};
// Format time
const formatTime = (timestamp) => {
const date = timestamp * 1000 ? new Date(timestamp * 1000) : new Date(timestamp);
return date.toLocaleTimeString('da-DK', { hour: '2-digit', minute: '2-digit' });
};
// Get CSS class based on employee type
const getTypeClass = (entry) => {
const employeeType = entry.employee_type || '';
// Map employee types to CSS classes
if (employeeType.toLowerCase().includes('svend')) return 'type-svend';
if (employeeType.toLowerCase().includes('lærling, 1')) return 'type-laerling-1';
if (employeeType.toLowerCase().includes('lærling, 4')) return 'type-laerling-4';
if (employeeType.toLowerCase().includes('ferie')) return 'type-ferie';
if (employeeType.toLowerCase().includes('møde')) return 'type-moede';
if (employeeType.toLowerCase().includes('formand')) return 'type-formandstimer';
if (employeeType.toLowerCase().includes('planlagt')) return 'type-planlagt';
if (employeeType.toLowerCase().includes('ikke fakturerbare')) return 'type-ikke-fakturerbare';
if (employeeType.toLowerCase().includes('feriefridag')) return 'type-feriefridag';
return 'type-ukendt';
};
// Get status style - use employee_type_color from API
const getStatusStyle = (entry) => {
// Use employee_type_color from API response
if (entry.employee_type_color) {
return {
backgroundColor: entry.employee_type_color,
color: entry.is_personal ? '#1e293b' : '#fff',
label: entry.employee_type || entry.title || 'Opgave'
};
}
// Fallback to predefined colors
const statusKey = entry.case_status_text || entry.title || entry.description || 'default';
const style = STATUS_COLORS[statusKey] || STATUS_COLORS.default;
return {
backgroundColor: style.bg,
color: style.text,
label: style.label
};
};
// Filter entries based on simplified view mode
const filteredEntries = useMemo(() => {
if (!calendarData.entries) return [];
return calendarData.entries.filter(entry => {
const isWorkType = ['Svend', 'Aktiv', 'Planlagt', 'Formandstimer', 'Lærling'].includes(entry.status || '');
const isPersonalType = ['Ferie', 'Feriefridag (SH-betaling)', 'Syg', 'Møde'].includes(entry.status || '');
if (viewMode === 'work-only') {
return isWorkType || entry.is_personal === false;
} else if (viewMode === 'work-and-personal') {
return isWorkType || isPersonalType;
}
// 'all' mode - show everything
return true;
});
}, [calendarData.entries, viewMode]);
// Filter employees - show ALL employees, not just those with events (Lars needs to see who's free!)
const filteredEmployees = useMemo(() => {
// Get employees with events in current week for sorting
const employeesWithEvents = new Set();
filteredEntries.forEach(entry => {
const entryDate = new Date(entry.start_time * 1000);
const isInCurrentWeek = weekDates.some(weekDate =>
entryDate.toDateString() === weekDate.toDateString()
);
if (isInCurrentWeek) {
employeesWithEvents.add(entry.employee_id);
}
});
// Show ALL employees (Lars feedback: no type filtering, too complex)
let filtered = employees;
// Sort: employees WITH tasks first, then free employees
filtered.sort((a, b) => {
const aHasTasks = employeesWithEvents.has(a.id);
const bHasTasks = employeesWithEvents.has(b.id);
if (aHasTasks && !bHasTasks) return -1;
if (!aHasTasks && bHasTasks) return 1;
const nameA = (a.name || a.first_name || 'Ukendt').toLowerCase();
const nameB = (b.name || b.first_name || 'Ukendt').toLowerCase();
return nameA.localeCompare(nameB);
});
return filtered.map(emp => ({
...emp,
hasEvents: employeesWithEvents.has(emp.id)
}));
}, [employees, filteredEntries, weekDates]);
// Check for missing data and show helpful message
if (!calendarData || !calendarData.entries) {
return (
<div className="calendar-grid-container">
<div className="calendar-empty-state" style={{
padding: '40px',
textAlign: 'center',
background: '#f8f9fa',
borderRadius: '8px',
margin: '20px'
}}>
<h3 style={{color: '#6b7280', marginBottom: '10px'}}>📅 Ingen kalenderdata tilgængelig</h3>
<p style={{color: '#9ca3af'}}>
{!calendarData ? 'Venter på data...' : 'Ingen kalenderposter fundet for den valgte periode.'}
</p>
<p style={{color: '#9ca3af', marginTop: '10px', fontSize: '14px'}}>
Prøv at opdatere siden eller kontakt support hvis problemet fortsætter.
</p>
<p style={{color: '#3b82f6', marginTop: '15px', fontSize: '13px'}}>
💡 Tip: Kalenderen henter data fra Ordrestyring. Sørg for at der er planlagt arbejde i systemet.
</p>
</div>
</div>
);
}
if (!employees || employees.length === 0) {
return (
<div className="calendar-grid-container">
<div className="calendar-empty-state" style={{
padding: '40px',
textAlign: 'center',
background: '#f8f9fa',
borderRadius: '8px',
margin: '20px'
}}>
<h3 style={{color: '#6b7280', marginBottom: '10px'}}>👥 Ingen medarbejdere fundet</h3>
<p style={{color: '#9ca3af'}}>
Kan ikke vise kalender uden medarbejderdata.
</p>
<p style={{color: '#3b82f6', marginTop: '15px', fontSize: '13px'}}>
💡 Tip: Medarbejdere skal oprettes i Ordrestyring systemet.
</p>
</div>
</div>
);
}
return (
<div className="calendar-grid-container">
{/* Header with navigation */}
<div className="calendar-grid-header">
<div className="calendar-grid-title">
<h2>📅 DAGSOVERSIGT</h2>
<p className="week-range">{getWeekRange()}</p>
</div>
<div className="calendar-grid-nav">
<button
className="nav-btn"
onClick={() => setCurrentWeek(currentWeek - 1)}
>
← Forrige uge
</button>
<button
className="nav-btn today-btn"
onClick={() => setCurrentWeek(0)}
>
I dag
</button>
<button
className="nav-btn"
onClick={() => setCurrentWeek(currentWeek + 1)}
>
Næste uge →
</button>
</div>
{/* Employee type filter removed - show all medarbejdere */}
</div>
{/* Settings panel (simplified filters) */}
<div className="calendar-settings-panel">
<div className="settings-section">
<button className="settings-btn">⚙️ Generelle indstillinger</button>
</div>
<div className="settings-section">
<button className="settings-btn">📋 Dagsvisning</button>
</div>
<div className="settings-section expanded">
<div className="settings-header">
<span>🔽 Vis opgaver</span>
</div>
<div className="settings-content">
<div className="view-mode-options">
<label className="view-mode-radio">
<input
type="radio"
id="viewMode-work-only"
name="viewMode"
value="work-only"
checked={viewMode === 'work-only'}
onChange={(e) => setViewMode(e.target.value)}
/>
<span className="radio-label">💼 Kun arbejde</span>
</label>
<label className="view-mode-radio">
<input
type="radio"
id="viewMode-work-and-personal"
name="viewMode"
value="work-and-personal"
checked={viewMode === 'work-and-personal'}
onChange={(e) => setViewMode(e.target.value)}
/>
<span className="radio-label">💼 Arbejde + Fravær</span>
</label>
<label className="view-mode-radio">
<input
type="radio"
id="viewMode-all"
name="viewMode"
value="all"
checked={viewMode === 'all'}
onChange={(e) => setViewMode(e.target.value)}
/>
<span className="radio-label">📋 Vis alt</span>
</label>
</div>
</div>
</div>
{/* Employee type filter removed - confusing for carpenters */}
</div>
{/* Main calendar grid */}
<div className="calendar-grid-main">
{/* Column headers - Days */}
<div className="calendar-grid-days-header">
<div className="employee-header-cell">Medarbejder</div>
{weekDates.map((date, index) => {
const isToday = date.toDateString() === new Date().toDateString();
const dayName = date.toLocaleDateString('da-DK', { weekday: 'short' });
const dayDate = `${date.getDate()}/${date.getMonth() + 1}`;
return (
<div key={index} className={`day-header-cell ${isToday ? 'today' : ''}`}>
<div className="day-name">{dayName}</div>
<div className="day-date">{dayDate}</div>
</div>
);
})}
</div>
{/* Employee rows */}
<div className="calendar-grid-rows">
{filteredEmployees.length === 0 ? (
<div style={{
padding: '40px',
textAlign: 'center',
background: '#f8f9fa',
borderRadius: '8px',
margin: '20px',
gridColumn: '1 / -1'
}}>
<h3 style={{color: '#6b7280', marginBottom: '10px'}}>📭 Ingen planlagte opgaver denne uge</h3>
<p style={{color: '#9ca3af', marginBottom: '10px'}}>
Der er ingen medarbejdere med planlagt arbejde i den valgte uge.
</p>
<p style={{color: '#3b82f6', fontSize: '14px'}}>
💡 Tip: Prøv en anden uge eller tilføj planlagte opgaver i Ordrestyring.
</p>
</div>
) : (
filteredEmployees.map((employee) => (
<div key={employee.id} className={`calendar-grid-row ${!employee.hasEvents ? 'employee-free' : ''}`}>
{/* Employee name column */}
<div className="employee-cell">
<div className="employee-header">
<div className="employee-name-full">
<strong>{employee.first_name} {employee.last_name}</strong>
{employee.phone && <span className="employee-phone" title="Click to call">{employee.phone}</span>}
<span className="employee-type-badge">{employee.employee_type_title || 'Medarbejder'}</span>
</div>
{!employee.hasEvents && <div className="free-badge-prominent">🟢 LEDIG</div>}
</div>
</div>
{/* Day columns */}
{weekDates.map((date, dayIndex) => {
const entries = getEmployeeSchedule(employee.id, date);
const isToday = date.toDateString() === new Date().toDateString();
const isPast = date < new Date() && !isToday;
return (
<div
key={`${employee.id}-${dayIndex}`}
className={`day-cell ${isToday ? 'today' : ''} ${entries.length === 0 ? 'empty' : 'has-entries'}`}
>
{entries.length === 0 ? (
<div className="empty-indicator">
<span className="empty-text">Ikke afsat</span>
</div>
) : (
<div className="entries-container">
{entries.map((entry, entryIndex) => {
const style = getStatusStyle(entry);
const duration = entry.end_time && entry.start_time
? ((entry.end_time - entry.start_time) / 3600).toFixed(1)
: '0';
const typeClass = getTypeClass(entry);
const personalClass = entry.is_personal ? 'personal' : '';
return (
<div
key={entryIndex}
className={`calendar-entry ${typeClass} ${personalClass} ${isPast ? 'past-entry' : ''}`}
style={{
backgroundColor: style.backgroundColor,
color: style.color,
opacity: isPast ? 0.5 : 1
}}
title={`${entry.title || style.label}\n${formatTime(entry.start_time)} - ${formatTime(entry.end_time)}\n${duration}h`}
>
<div className="entry-time">
{formatTime(entry.start_time)} - {formatTime(entry.end_time)}
</div>
<div className="entry-info">
{(entry.new_case_number || entry.case_number) && (
<span className="entry-case">
{entry.new_case_number || entry.case_number}
</span>
)}
<span className="entry-status">{style.label}</span>
</div>
<div className="entry-description">
{entry.title || entry.description || 'Opgave'}
</div>
<div className="entry-duration">{duration}h</div>
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
))
)}
</div>
</div>
{/* Footer info */}
<div className="calendar-grid-footer">
<div className="footer-info">
<span>👷 {filteredEmployees.length} medarbejdere</span>
<span>📋 {calendarData.entries?.length || 0} opgaver total</span>
<span>⏱️ Sidste sync: {calendarData.lastSync ? new Date(calendarData.lastSync).toLocaleString('da-DK') : 'Aldrig'}</span>
</div>
</div>
</div>
);
};
export default CalendarGrid;