Boulangerie_Website/frontend/src/pages/WeeklySummaryPage.jsx
Louis Mazin 32750a0cd7 nice
2026-09-19 14:38:51 +02:00

1025 lines
29 KiB
JavaScript

import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { timeApi } from '../api/time';
import { useAuth } from '../context/useAuth';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
Calendar,
ChevronLeft,
ChevronRight,
Clock,
Users,
User,
CalendarDays,
} from 'lucide-react';
const DEFAULT_START_HOUR = 8;
const DEFAULT_END_HOUR = 18;
const HOUR_HEIGHT = 64;
const USER_COLORS = [
'#c56a3d',
'#3d7c8c',
'#8a5a9e',
'#5d8b55',
'#c08a32',
'#b04f66',
'#4772a8',
];
const expandOvernightAttendances = (entries) => {
return entries.map((entry) => {
if (entry.start_time && !entry.end_time) {
return { ...entry, end_time: '24:00:00' };
}
if (!entry.start_time && entry.end_time) {
return { ...entry, start_time: '00:00:00' };
}
return entry;
});
};
const WeeklySummaryPage = ({ userId }) => {
const { isAdmin } = useAuth();
const [attendances, setAttendances] = useState([]);
const [currentWeek, setCurrentWeek] = useState(new Date());
const [selectedDayIndex, setSelectedDayIndex] = useState(() => {
const day = new Date().getDay();
return day === 0 ? 6 : day - 1;
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [viewportHeight, setViewportHeight] = useState(() => window.innerHeight);
const [availableScheduleHeight, setAvailableScheduleHeight] = useState(620);
const [isMobile, setIsMobile] = useState(false);
const scheduleTableRef = useRef(null);
useEffect(() => {
const updateViewportHeight = () => {
setViewportHeight(window.innerHeight);
};
const checkMobile = () => {
setIsMobile(window.innerWidth < 768);
};
updateViewportHeight();
checkMobile();
window.addEventListener('resize', updateViewportHeight);
window.addEventListener('resize', checkMobile);
return () => {
window.removeEventListener('resize', updateViewportHeight);
window.removeEventListener('resize', checkMobile);
};
}, []);
useLayoutEffect(() => {
const updateScheduleHeight = () => {
const scheduleTable = scheduleTableRef.current;
const main = document.querySelector('main');
const footer = document.querySelector('footer');
if (!scheduleTable || !main || !footer) {
return;
}
const mainStyles = window.getComputedStyle(main);
const bottomPadding = parseFloat(mainStyles.paddingBottom) || 0;
const footerHeight = footer.getBoundingClientRect().height;
const tableTop = scheduleTable.getBoundingClientRect().top;
const tableHeaderHeight = 58;
const remainingHeight =
window.innerHeight -
tableTop -
footerHeight -
bottomPadding -
tableHeaderHeight;
setAvailableScheduleHeight(Math.max(180, remainingHeight));
};
updateScheduleHeight();
window.addEventListener('resize', updateScheduleHeight);
return () => {
window.removeEventListener('resize', updateScheduleHeight);
};
}, [loading, error]);
useEffect(() => {
const loadAttendances = async () => {
try {
setLoading(true);
setError('');
const entries = await timeApi.getTimeEntries();
setAttendances(entries);
} catch (loadError) {
setError('Impossible de charger les pointages.');
console.error(loadError);
} finally {
setLoading(false);
}
};
loadAttendances();
}, [userId]);
const getWeekDays = (date) => {
const startOfWeek = new Date(date);
const day = startOfWeek.getDay() || 7;
startOfWeek.setDate(startOfWeek.getDate() - day + 1);
return Array.from({ length: 7 }, (_, index) => {
const weekDay = new Date(startOfWeek);
weekDay.setDate(startOfWeek.getDate() + index);
return weekDay;
});
};
const formatDateKey = (date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const getTimeValue = (attendance, key) => {
const legacyKey =
key === 'start_time' ? 'startTime' : 'endTime';
const value = attendance[key] || attendance[legacyKey] || '';
return value.slice(0, 5);
};
const getMinutes = (time) => {
if (!time) {
return null;
}
const [hours, minutes] = time.split(':').map(Number);
if (Number.isNaN(hours) || Number.isNaN(minutes)) {
return null;
}
return (hours * 60) + minutes;
};
const formatSegmentTime = (minutes) => {
if (minutes === 1440) {
return '23h59';
}
return `${String(Math.floor(minutes / 60)).padStart(2, '0')}h${String(
minutes % 60,
).padStart(2, '0')}`;
};
const getUserColor = (username) => {
const hash = [...username].reduce(
(total, character) => total + character.charCodeAt(0),
0,
);
return USER_COLORS[hash % USER_COLORS.length];
};
const getAttendancesForDay = (date) => {
const dateKey = formatDateKey(date);
return expandOvernightAttendances(attendances).filter(
(attendance) =>
String(attendance.date).slice(0, 10) === dateKey,
);
};
const getAttendanceSegments = (dayAttendances) => {
const validAttendances = dayAttendances
.map((attendance) => ({
attendance,
start: getMinutes(
getTimeValue(attendance, 'start_time'),
),
end: getMinutes(
getTimeValue(attendance, 'end_time'),
),
}))
.filter(
({ start, end }) =>
start !== null &&
end !== null &&
end > start,
);
const boundaries = [
...new Set(
validAttendances.flatMap(({ start, end }) => [
start,
end,
]),
),
].sort((a, b) => a - b);
return boundaries
.slice(0, -1)
.map((start, index) => {
const end = boundaries[index + 1];
const activeAttendances = validAttendances.filter(
({
start: entryStart,
end: entryEnd,
}) =>
entryStart <= start &&
entryEnd >= end,
);
if (!activeAttendances.length) {
return null;
}
return {
start,
end,
users: activeAttendances
.map(({ attendance }) => attendance.username)
.filter(Boolean)
.filter(
(username, usernameIndex, names) =>
names.indexOf(username) === usernameIndex,
)
.map((username) => ({
username,
color: getUserColor(username),
})),
};
})
.filter(Boolean);
};
const getAttendanceColumns = (dayAttendances) => {
const columns = new Map();
dayAttendances.forEach((attendance) => {
const start = getMinutes(
getTimeValue(attendance, 'start_time'),
);
const end = getMinutes(
getTimeValue(attendance, 'end_time'),
);
const username = attendance.username;
if (
start === null ||
end === null ||
end <= start ||
!username
) {
return;
}
const currentColumn = columns.get(username);
columns.set(username, {
username,
color: getUserColor(username),
start: currentColumn
? Math.min(currentColumn.start, start)
: start,
end: currentColumn
? Math.max(currentColumn.end, end)
: end,
});
});
return [...columns.values()];
};
const navigateWeek = (direction) => {
const newWeek = new Date(currentWeek);
newWeek.setDate(
currentWeek.getDate() + direction * 7,
);
setCurrentWeek(newWeek);
};
const navigateDay = (direction) => {
const nextDayIndex =
selectedDayIndex + direction;
if (
nextDayIndex < 0 ||
nextDayIndex > 6
) {
navigateWeek(direction);
setSelectedDayIndex(
direction < 0 ? 6 : 0,
);
return;
}
setSelectedDayIndex(nextDayIndex);
};
const getScheduleBounds = (weekDays) => {
const weekDateKeys = new Set(
weekDays.map(formatDateKey),
);
const weekAttendances =
expandOvernightAttendances(attendances).filter(
(attendance) =>
weekDateKeys.has(
String(attendance.date).slice(0, 10),
),
);
const times = weekAttendances
.flatMap((attendance) => [
getMinutes(
getTimeValue(
attendance,
'start_time',
),
),
getMinutes(
getTimeValue(
attendance,
'end_time',
),
),
])
.filter((time) => time !== null);
if (!times.length) {
return {
startHour: DEFAULT_START_HOUR,
endHour: DEFAULT_END_HOUR,
};
}
const firstHour = Math.floor(
Math.min(...times) / 60,
);
const lastHour = Math.ceil(
Math.max(...times) / 60,
);
return {
startHour: firstHour,
endHour: Math.max(
lastHour,
firstHour + 1,
),
};
};
const getAttendanceStyle = (
start,
end,
startHour,
endHour,
hourHeight,
) => {
const visibleStart = Math.max(
startHour * 60,
start,
);
const visibleEnd = Math.min(
endHour * 60,
end,
);
return {
top: `${
((visibleStart - startHour * 60) / 60) *
hourHeight
}px`,
height: `${Math.max(
((visibleEnd - visibleStart) / 60) *
hourHeight,
1,
)}px`,
};
};
if (loading) {
return (
<Card className="page-layout page-state weekly-schedule flex h-full w-full">
<CardContent className="p-6">
<div className="flex items-center justify-center h-32">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
<span className="ml-3">
Chargement...
</span>
</div>
</CardContent>
</Card>
);
}
if (error) {
return (
<Card className="page-layout page-state weekly-schedule schedule-error flex h-full w-full">
<CardContent className="p-6">
<div className="text-red-500 text-center">
<div className="flex items-center justify-center mb-2">
<div className="bg-red-100 p-2 rounded-full">
<div className="bg-red-500 text-white p-1 rounded-full">
<div className="w-4 h-4" />
</div>
</div>
</div>
<p>{error}</p>
</div>
</CardContent>
</Card>
);
}
const weekDays = getWeekDays(currentWeek);
const { startHour, endHour } =
getScheduleBounds(weekDays);
const hourCount = endHour - startHour;
const measuredHeight = Math.min(
availableScheduleHeight,
Math.max(180, viewportHeight - 180),
);
const scheduleHeight = Math.max(
180,
measuredHeight,
);
const hourHeight = Math.min(
HOUR_HEIGHT,
Math.max(
12,
scheduleHeight / hourCount,
),
);
const hours = Array.from(
{ length: hourCount },
(_, index) => startHour + index,
);
const formatHour = (hour) =>
`${String(hour).padStart(2, '0')}:00`;
const legendUsers = [
...new Set(
attendances
.map((attendance) => attendance.username)
.filter(Boolean),
),
]
.sort((first, second) =>
first.localeCompare(second, 'fr'),
)
.map((username) => ({
username,
color: getUserColor(username),
}));
const displayDay = isMobile
? weekDays[selectedDayIndex]
: null;
return (
<Card
className={`flex h-full w-full weekly-schedule page-layout${
isAdmin ? ' multi-user-schedule' : ''
}`}
>
<CardHeader className="border-b">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<CardTitle className="flex items-center gap-2">
{isAdmin ? (
<>
<Users className="h-5 w-5" />
Récapitulatif de tous les utilisateurs
</>
) : (
<>
<CalendarDays className="h-5 w-5" />
Emploi du temps hebdomadaire
</>
)}
</CardTitle>
</div>
{!isMobile && (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => navigateWeek(-1)}
className="flex items-center gap-1"
>
<ChevronLeft className="h-4 w-4" />
Précédente
</Button>
<Badge
variant="secondary"
className="text-sm"
>
Du{' '}
{weekDays[0].toLocaleDateString(
'fr-FR',
{
day: '2-digit',
month: 'short',
},
)}{' '}
au{' '}
{weekDays[6].toLocaleDateString(
'fr-FR',
{
day: '2-digit',
month: 'short',
},
)}
</Badge>
<Button
variant="outline"
size="sm"
onClick={() => navigateWeek(1)}
className="flex items-center gap-1"
>
Suivante
<ChevronRight className="h-4 w-4" />
</Button>
</div>
)}
{isMobile && (
<div className="flex items-center justify-between gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={() => navigateDay(-1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Badge
variant="secondary"
className="text-sm"
>
{displayDay.toLocaleDateString(
'fr-FR',
{
weekday: 'long',
day: '2-digit',
month: 'short',
},
)}
</Badge>
<Button
variant="outline"
size="sm"
onClick={() => navigateDay(1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
)}
</div>
</CardHeader>
<CardContent className="p-4">
{isAdmin && legendUsers.length > 0 && (
<div className="mb-4 p-3 bg-muted rounded-lg">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium mr-2">
Légende :
</span>
{legendUsers.map(
({ username, color }) => (
<div
key={username}
className="flex items-center gap-1"
>
<div
className="w-3 h-3 rounded-full"
style={{
backgroundColor: color,
}}
/>
<span className="text-sm">
{username}
</span>
</div>
),
)}
</div>
</div>
)}
<div
ref={scheduleTableRef}
className="relative overflow-auto border rounded-lg"
style={{
height: `${Math.max(
180,
Math.min(
availableScheduleHeight,
viewportHeight - 180,
),
)}px`,
}}
>
{isMobile ? (
<div className="grid grid-cols-1 gap-0 min-w-[300px]">
<div className="p-2 text-center font-medium border-b sticky top-0 bg-background z-10">
{displayDay.toLocaleDateString(
'fr-FR',
{
weekday: 'long',
day: '2-digit',
month: 'short',
},
)}
</div>
<div className="grid grid-cols-[64px_1fr] gap-0">
<div className="border-r">
{hours.map((hour) => (
<div
key={hour}
className="flex items-center justify-center text-xs border-b"
style={{
height: `${hourHeight}px`,
}}
>
{formatHour(hour)}
</div>
))}
</div>
<div
className="relative"
style={{
height: `${hourCount * hourHeight}px`,
}}
>
{hours.map((hour) => (
<div
key={hour}
className="border-b border-gray-200"
style={{
height: `${hourHeight}px`,
}}
/>
))}
{isAdmin
? getAttendanceColumns(
getAttendancesForDay(
displayDay,
),
).map(
({
start,
end,
username,
color,
}, columnIndex) => {
const style =
getAttendanceStyle(
start,
end,
startHour,
endHour,
hourHeight,
);
return (
<div
key={`${formatDateKey(
displayDay,
)}-${username}`}
className="absolute rounded-sm"
style={{
...style,
left: `${
5 +
columnIndex * 9
}px`,
width: '6px',
backgroundColor:
color,
}}
title={`${username} : ${formatSegmentTime(
start,
)} - ${formatSegmentTime(
end,
)}`}
/>
);
},
)
: getAttendanceSegments(
getAttendancesForDay(
displayDay,
),
).map(
({
start,
end,
users,
}) => {
const style =
getAttendanceStyle(
start,
end,
startHour,
endHour,
hourHeight,
);
const segmentLabel =
users
.map(
({
username,
}) => username,
)
.join(', ');
return (
<div
key={`${formatDateKey(
displayDay,
)}-${start}-${end}`}
className="absolute bg-blue-500/80 rounded-sm border border-blue-600/30 left-0 right-0"
style={style}
title={segmentLabel}
>
<div className="p-1 text-xs text-white">
{formatSegmentTime(
start,
)}{' '}
-{' '}
{formatSegmentTime(
end,
)}
</div>
</div>
);
},
)}
</div>
</div>
</div>
) : (
<div
className="grid min-w-[900px]"
style={{
gridTemplateColumns: `64px repeat(7, minmax(120px, 1fr))`,
}}
>
{/* En-tête */}
<div className="sticky top-0 z-20 bg-background border-b border-r p-2" />
{weekDays.map((day, dayIndex) => (
<div
key={formatDateKey(day)}
className={`sticky top-0 z-20 bg-background border-b border-r p-2 text-center font-medium ${
dayIndex === selectedDayIndex
? 'bg-muted'
: ''
}`}
>
<div className="text-sm capitalize">
{day.toLocaleDateString(
'fr-FR',
{
weekday: 'long',
},
)}
</div>
<div className="text-xs text-muted-foreground">
{day.toLocaleDateString(
'fr-FR',
{
day: '2-digit',
month: 'short',
},
)}
</div>
</div>
))}
{/* Heures */}
<div className="border-r">
{hours.map((hour) => (
<div
key={hour}
className="flex items-start justify-center pt-1 text-xs text-muted-foreground border-b"
style={{
height: `${hourHeight}px`,
}}
>
{formatHour(hour)}
</div>
))}
</div>
{/* Jours */}
{weekDays.map((day) => {
const dayAttendances =
getAttendancesForDay(day);
return (
<div
key={formatDateKey(day)}
className="relative border-r"
style={{
height: `${hourCount * hourHeight}px`,
}}
>
{hours.map((hour) => (
<div
key={hour}
className="border-b border-gray-200"
style={{
height: `${hourHeight}px`,
}}
/>
))}
{isAdmin
? getAttendanceColumns(
dayAttendances,
).map(
({
start,
end,
username,
color,
}) => {
const style =
getAttendanceStyle(
start,
end,
startHour,
endHour,
hourHeight,
);
return (
<div
key={`${formatDateKey(
day,
)}-${username}`}
className="absolute left-2 right-2 rounded-md border"
style={{
...style,
backgroundColor: `${color}22`,
borderColor: color,
}}
title={`${username} : ${formatSegmentTime(
start,
)} - ${formatSegmentTime(
end,
)}`}
>
<div
className="absolute left-0 top-0 bottom-0 w-1 rounded-l-md"
style={{
backgroundColor:
color,
}}
/>
<div className="px-3 py-1 text-xs">
<div
className="font-medium"
style={{
color,
}}
>
{username}
</div>
<div className="text-muted-foreground">
{formatSegmentTime(
start,
)}{' '}
-{' '}
{formatSegmentTime(
end,
)}
</div>
</div>
</div>
);
},
)
: getAttendanceSegments(
dayAttendances,
).map(
({
start,
end,
users,
}) => {
const style =
getAttendanceStyle(
start,
end,
startHour,
endHour,
hourHeight,
);
const segmentLabel =
users
.map(
({
username,
}) =>
username,
)
.join(', ');
return (
<div
key={`${formatDateKey(
day,
)}-${start}-${end}`}
className="absolute left-1 right-1 bg-blue-500/80 rounded-md border border-blue-600/30 overflow-hidden"
style={style}
title={segmentLabel}
>
<div className="p-2 text-xs text-white">
<div className="font-medium">
{formatSegmentTime(
start,
)}{' '}
-{' '}
{formatSegmentTime(
end,
)}
</div>
{isAdmin &&
users.length >
0 && (
<div className="mt-1">
{segmentLabel}
</div>
)}
</div>
</div>
);
},
)}
</div>
);
})}
</div>
)}
</div>
</CardContent>
</Card>
);
};
export default WeeklySummaryPage;