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.
501 lines
21 KiB
JavaScript
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;
|