This commit is contained in:
Louis Mazin 2026-10-10 20:16:43 +02:00
parent aa3b5e6ae3
commit e360b0b684
4 changed files with 754 additions and 1 deletions

View File

@ -0,0 +1,168 @@
/** Prépare des semaines complètes (lundi -> dimanche) pour l'export PDF. */
const addDays = (value, count) => {
const date = new Date(`${value}T12:00:00`);
date.setDate(date.getDate() + count);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
};
export const mondayOf = (value) => {
const day = new Date(`${value}T12:00:00`).getDay();
return addDays(value, -((day + 6) % 7));
};
const parseMinutes = (value) => {
const match = String(value || '').match(/^(\d{1,2}):(\d{2})/);
return match ? Number(match[1]) * 60 + Number(match[2]) : null;
};
export const durationLabel = (minutes) => {
if (minutes === null || !Number.isFinite(minutes)) return '—';
const rounded = Math.max(0, Math.round(minutes));
return `${Math.floor(rounded / 60)}h${String(rounded % 60).padStart(2, '0')}`;
};
export const dateLabel = (value) => {
const [year, month, day] = String(value).slice(0, 10).split('-');
return `${day}/${month}/${year}`;
};
export const validateExportPeriod = (start, end) => {
const valid = (value) => {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
const date = new Date(`${value}T12:00:00`);
return !Number.isNaN(date.getTime()) &&
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` === value;
};
if (!valid(start) || !valid(end)) return 'Renseignez une date de début et de fin valides.';
if (start > end) return 'La date de fin doit être postérieure ou égale à la date de début.';
if (start < '2000-01-01' || end > '2099-12-31') return 'Choisissez une période entre 2000 et 2099.';
const length = (Date.parse(`${end}T12:00:00Z`) - Date.parse(`${start}T12:00:00Z`)) / 86400000;
if (length > 365) return 'La période ne peut pas dépasser 366 jours.';
return '';
};
const time = (value) => value ? String(value).slice(0, 5) : null;
/** Une entrée est rattachée à sa date de début, même pour les pointages de nuit. */
const summarizeEntry = (entry) => {
const start = parseMinutes(entry.start_time);
const finish = parseMinutes(entry.end_time);
if (start === null || finish === null) return { minutes: null, incomplete: true };
const startDate = String(entry.start_date || '').slice(0, 10);
const endDate = String(entry.end_date || startDate).slice(0, 10);
const dateSpan = (Date.parse(`${endDate}T12:00:00Z`) - Date.parse(`${startDate}T12:00:00Z`)) / 86400000;
const reported = entry.duration == null ? null : Number(entry.duration) * 60;
const minutes = Number.isFinite(reported)
? reported
: finish - start + (Number.isFinite(dateSpan) ? dateSpan * 1440 : 0);
return { minutes: minutes >= 0 ? minutes : null, incomplete: minutes < 0 };
};
export const buildWeeklyPdfPages = ({
start, end, name, withPlanning, withPointage, planning, attendances,
}) => {
const error = validateExportPeriod(start, end);
if (error) throw new Error(error);
if (!withPlanning && !withPointage) throw new Error('Sélectionnez au moins un élément à exporter.');
const daysByDate = new Map((planning?.days || []).map((item) => [item.date, item]));
const slotsByDate = new Map();
(planning?.segments || []).forEach((slot) => {
if (!slotsByDate.has(slot.date)) slotsByDate.set(slot.date, []);
slotsByDate.get(slot.date).push(slot);
});
const entriesByDate = new Map();
(attendances || []).forEach((entry) => {
const day = String(entry.start_date || '').slice(0, 10);
if (day < start || day > end) return;
if (!entriesByDate.has(day)) entriesByDate.set(day, []);
entriesByDate.get(day).push(entry);
});
const pages = [];
for (let monday = mondayOf(start); monday <= end; monday = addDays(monday, 7)) {
const sunday = addDays(monday, 6);
const page = {
weekStart: monday, weekEnd: sunday, start, end, name, withPlanning, withPointage,
days: [], plannedMinutes: 0, pointedMinutes: 0, incompleteCount: 0,
plannedSlots: 0, pointedEntries: 0, daysWithPointing: 0,
includedDays: 0, weeklyTarget: null, partial: start > monday || end < sunday,
};
for (let offset = 0; offset < 7; offset++) {
const date = addDays(monday, offset);
const included = date >= start && date <= end;
const original = included ? daysByDate.get(date) : null;
const slots = included ? [...(slotsByDate.get(date) || [])]
.sort((a, b) => String(a.start_time).localeCompare(String(b.start_time))) : [];
const entries = included ? [...(entriesByDate.get(date) || [])]
.sort((a, b) => String(a.start_time || '').localeCompare(String(b.start_time || ''))) : [];
const day = {
date, included, kind: original?.kind || 'undefined',
planningLabel: original?.label || '', planLines: [], clockLines: [],
plannedMinutes: null, pointedMinutes: null, incompleteCount: 0,
};
if (included) page.includedDays++;
if (included && withPlanning) {
if (slots.length) {
let minutes = 0;
day.planLines = slots.map((slot) => {
const begin = parseMinutes(slot.start_time);
const finish = parseMinutes(slot.end_time);
if (begin !== null && finish !== null && finish >= begin) minutes += finish - begin;
return `${time(slot.start_time) || '?'} - ${time(slot.end_time) || '?'}${slot.end_time?.startsWith('24:') ? ' (minuit)' : ''}`;
});
day.plannedMinutes = minutes;
page.plannedMinutes += minutes;
page.plannedSlots += slots.length;
} else if (original?.schedule_mode === 'daily_hours') {
const minutes = Number(original.target_minutes || 0);
day.plannedMinutes = minutes;
page.plannedMinutes += minutes;
day.planLines = [minutes > 0 ? 'Heures à effectuer (horaires libres)' : 'Repos'];
} else if (original?.schedule_mode === 'weekly_hours') {
if (page.weeklyTarget === null) page.weeklyTarget = Number(original.week_target_minutes || 0);
day.planLines = [Number(original.week_target_minutes || 0) > 0 ? 'Répartition libre sur la semaine' : 'Repos'];
} else if (original?.kind === 'absence') {
day.planLines = [original.label || 'Congé / absence'];
} else if (original?.kind === 'rest') {
day.planLines = [original.label || 'Repos'];
} else {
day.planLines = [original?.label || 'Planning non défini'];
}
}
if (included && withPointage) {
if (entries.length) {
let completeMinutes = 0;
entries.forEach((entry) => {
const result = summarizeEntry(entry);
const begin = time(entry.start_time);
const finish = time(entry.end_time);
const followingDay = entry.end_date && String(entry.end_date).slice(0, 10) > date;
day.clockLines.push(`${begin || '?'} - ${finish || '?'}${followingDay ? ' (+1j)' : ''}${result.incomplete ? ' (incomplet)' : ''}`);
if (result.minutes !== null) completeMinutes += result.minutes;
else {
day.incompleteCount++;
page.incompleteCount++;
}
});
day.pointedMinutes = completeMinutes;
page.pointedMinutes += completeMinutes;
page.pointedEntries += entries.length;
page.daysWithPointing++;
} else {
day.clockLines = ['Aucun pointage'];
}
}
page.days.push(day);
}
// Les objectifs « heures/semaine » s'appliquent à la semaine entière,
// également quand la sélection ne couvre que quelques-uns de ses jours.
if (page.weeklyTarget !== null) page.plannedMinutes += page.weeklyTarget;
pages.push(page);
}
return pages;
};

View File

@ -0,0 +1,360 @@
/** PDF vectoriel A4 paysage, sans dépendance et sans accès serveur supplémentaire. */
import { dateLabel, durationLabel } from './weeklyExport.js';
const W = 841.89;
const H = 595.28;
const M = 35;
const PAPER = '#F4F7FB';
const NAVY = '#12283D';
const TEXT = '#172B3F';
const MUTED = '#65768A';
const LINE = '#E2E8F0';
const GREEN = '#147C6D';
const BLUE = '#3567AE';
const GOLD = '#E7B967';
const WEEKDAYS = ['LUNDI', 'MARDI', 'MERCREDI', 'JEUDI', 'VENDREDI', 'SAMEDI', 'DIMANCHE'];
const MONTHS = ['JANV.', 'FÉVR.', 'MARS', 'AVR.', 'MAI', 'JUIN', 'JUIL.', 'AOÛT', 'SEPT.', 'OCT.', 'NOV.', 'DÉC.'];
// Helvetica et Helvetica Bold utilisent l'encodage WinAnsi (PDF standard).
// Les caractères non disponibles dans WinAnsi sont remplacés explicitement.
const WIN_ANSI = { '€': 128, '‚': 130, 'ƒ': 131, '„': 132, '…': 133, '†': 134,
'‡': 135, 'ˆ': 136, '‰': 137, 'Š': 138, '‹': 139, 'Œ': 140, 'Ž': 142,
'‘': 145, '’': 146, '“': 147, '”': 148, '•': 149, '–': 150, '—': 151,
'˜': 152, '™': 153, 'š': 154, '›': 155, 'œ': 156, 'ž': 158, 'Ÿ': 159 };
const ansi = (value) => {
let out = '';
for (const char of String(value ?? '')) {
let code = char.codePointAt(0);
if (code > 255) code = WIN_ANSI[char];
if (code === undefined || (code >= 127 && code < 160 && !Object.values(WIN_ANSI).includes(code))) {
out += char.normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^\x20-\xFF]/g, '') || '?';
} else {
out += String.fromCharCode(code);
}
}
return out;
};
const escaped = (value) => ansi(value).replace(/[\\()]/g, '\\$&').replace(/[\r\n]/g, ' ');
const num = (value) => Number(value.toFixed(2));
const rgb = (hex) => {
const value = hex.replace('#', '');
return [0, 2, 4].map((p) => num(parseInt(value.slice(p, p + 2), 16) / 255)).join(' ');
};
// Largeurs des polices standard en unités pour 1000 pt, pour un alignement fiable.
// Cette table est générée à partir des métriques Adobe des polices PDF standard.
const FONT_WIDTHS = {
regular: [
278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278,
556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556,
1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778,
667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556,
333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556,
556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584, 761,
556, 556, 222, 556, 333, 1000, 556, 556, 333, 1000, 667, 333, 1000, 556, 611, 556,
556, 222, 222, 333, 333, 350, 556, 1000, 333, 1000, 500, 333, 944, 556, 500, 667,
278, 333, 556, 556, 556, 556, 260, 556, 333, 737, 370, 556, 584, 333, 737, 333,
400, 584, 333, 333, 333, 556, 537, 278, 333, 333, 365, 556, 834, 834, 834, 611,
667, 667, 667, 667, 667, 667, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278,
722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611,
556, 556, 556, 556, 556, 556, 889, 500, 556, 556, 556, 556, 278, 278, 278, 278,
556, 556, 556, 556, 556, 556, 556, 584, 611, 556, 556, 556, 556, 500, 556, 500
],
bold: [
278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278,
556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611,
975, 722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778,
667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 333, 278, 333, 584, 556,
333, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556, 278, 889, 611, 611,
611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584, 761,
556, 611, 278, 556, 500, 1000, 556, 556, 333, 1000, 667, 333, 1000, 611, 611, 611,
611, 278, 278, 500, 500, 350, 556, 1000, 333, 1000, 556, 333, 944, 611, 500, 667,
278, 333, 556, 556, 556, 556, 280, 556, 333, 737, 370, 556, 584, 333, 737, 333,
400, 584, 333, 333, 333, 611, 556, 278, 333, 333, 365, 556, 834, 834, 834, 611,
722, 722, 722, 722, 722, 722, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278,
722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611,
556, 556, 556, 556, 556, 556, 889, 556, 556, 556, 556, 556, 278, 278, 278, 278,
611, 611, 611, 611, 611, 611, 611, 584, 611, 611, 611, 611, 611, 556, 611, 556
]
};
const fontWidth = (value, size, bold = false) => {
const table = bold ? FONT_WIDTHS.bold : FONT_WIDTHS.regular;
let total = 0;
for (const char of ansi(value)) {
const code = char.charCodeAt(0);
total += table[code - 32] ?? 556;
}
return total * size / 1000;
};
const fit = (value, maxWidth, size, bold = false, minimum = 7) => {
let current = size;
while (current > minimum && fontWidth(value, current, bold) > maxWidth) current -= 0.25;
return current;
};
const wrap = (value, maxWidth, size, bold = false) => {
const words = String(value).split(/\s+/).filter(Boolean);
const lines = [];
let line = '';
for (const word of words) {
const combined = line ? `${line} ${word}` : word;
if (fontWidth(combined, size, bold) <= maxWidth) { line = combined; continue; }
if (line) lines.push(line);
if (fontWidth(word, size, bold) <= maxWidth) { line = word; continue; }
// Évite tout débordement même pour une longue valeur sans espaces.
let fragment = '';
for (const char of word) {
if (fragment && fontWidth(fragment + char, size, bold) > maxWidth) {
lines.push(fragment);
fragment = '';
}
fragment += char;
}
line = fragment;
}
if (line) lines.push(line);
return lines;
};
class PdfCanvas {
constructor() { this.ops = []; }
rect(x, y, w, h, color, border = null) {
const operations = ['q'];
if (color) operations.push(`${rgb(color)} rg`);
if (border) operations.push(`${rgb(border)} RG 0.7 w`);
operations.push(`${num(x)} ${num(H - y - h)} ${num(w)} ${num(h)} re`);
operations.push(color && border ? 'B' : color ? 'f' : 'S', 'Q');
this.ops.push(operations.join('\n'));
}
rounded(x, y, w, h, radius, color, border = null) {
const r = Math.min(radius, w / 2, h / 2);
const k = r * 0.55228475;
const up = (top) => num(H - top);
const ops = ['q'];
if (color) ops.push(`${rgb(color)} rg`);
if (border) ops.push(`${rgb(border)} RG 0.7 w`);
ops.push(`${num(x + r)} ${up(y)} m`);
ops.push(`${num(x + w - r)} ${up(y)} l`);
ops.push(`${num(x + w - r + k)} ${up(y)} ${num(x + w)} ${up(y + r - k)} ${num(x + w)} ${up(y + r)} c`);
ops.push(`${num(x + w)} ${up(y + h - r)} l`);
ops.push(`${num(x + w)} ${up(y + h - r + k)} ${num(x + w - r + k)} ${up(y + h)} ${num(x + w - r)} ${up(y + h)} c`);
ops.push(`${num(x + r)} ${up(y + h)} l`);
ops.push(`${num(x + r - k)} ${up(y + h)} ${num(x)} ${up(y + h - r + k)} ${num(x)} ${up(y + h - r)} c`);
ops.push(`${num(x)} ${up(y + r)} l`);
ops.push(`${num(x)} ${up(y + r - k)} ${num(x + r - k)} ${up(y)} ${num(x + r)} ${up(y)} c`);
ops.push('h', color && border ? 'B' : color ? 'f' : 'S', 'Q');
this.ops.push(ops.join('\n'));
}
line(x1, y1, x2, y2, color = LINE, width = 0.7) {
this.ops.push(`q\n${rgb(color)} RG ${num(width)} w\n${num(x1)} ${num(H - y1)} m ${num(x2)} ${num(H - y2)} l S\nQ`);
}
text(value, x, y, size = 9, color = TEXT, bold = false, align = 'left') {
const string = String(value ?? '');
if (!string) return;
let dx = x;
if (align !== 'left') {
const width = fontWidth(string, size, bold);
dx -= align === 'center' ? width / 2 : width;
}
this.ops.push(`q\n${rgb(color)} rg\nBT /${bold ? 'F2' : 'F1'} ${num(size)} Tf 1 0 0 1 ${num(dx)} ${num(H - y)} Tm (${escaped(string)}) Tj ET\nQ`);
}
content() { return this.ops.join('\n') + '\n'; }
}
const dateShort = (value) => {
const [year, month, day] = value.split('-');
return `${day} ${MONTHS[Number(month) - 1]} ${year}`;
};
const dateDay = (value) => {
const [, month, day] = value.split('-');
return `${day} ${MONTHS[Number(month) - 1]}`;
};
const diffLabel = (difference) => {
if (difference === 0) return '0h00';
return `${difference > 0 ? '+' : '-'}${durationLabel(Math.abs(difference))}`;
};
const kpiValues = (page) => {
if (page.withPlanning && page.withPointage) return [
['HEURES PRÉVUES', durationLabel(page.plannedMinutes), GREEN],
['HEURES POINTÉES', durationLabel(page.pointedMinutes), BLUE],
['ÉCART', diffLabel(page.pointedMinutes - page.plannedMinutes), TEXT],
['POINTAGES INCOMPLETS', String(page.incompleteCount), page.incompleteCount ? '#AD6638' : TEXT],
];
if (page.withPlanning) return [
['HEURES PRÉVUES', durationLabel(page.plannedMinutes), GREEN],
['JOURS SÉLECTIONNÉS', String(page.includedDays), TEXT],
['CRÉNEAUX PRÉVUS', String(page.plannedSlots), TEXT],
];
return [
['HEURES POINTÉES', durationLabel(page.pointedMinutes), BLUE],
['JOURS POINTÉS', String(page.daysWithPointing), TEXT],
['ENREGISTREMENTS', String(page.pointedEntries), TEXT],
['POINTAGES INCOMPLETS', String(page.incompleteCount), page.incompleteCount ? '#AD6638' : TEXT],
];
};
const drawMetric = (canvas, x, y, width, label, value, accent) => {
canvas.rounded(x, y, width, 62, 9, '#FFFFFF', LINE);
canvas.rounded(x + 11, y + 12, 3, 38, 1.5, accent);
canvas.text(label, x + 22, y + 23, 8.1, MUTED, true);
canvas.text(value, x + 22, y + 47, 20.5, accent, true);
};
const drawItems = (canvas, strings, x, y, width, withTotal, total, accent, note = '', incomplete = 0) => {
const rightSpace = withTotal ? 70 : 4;
const lineWidth = width - rightSpace - 16;
const raw = strings.length ? strings.join(' • ') : '—';
let size = 8.3;
let lines = wrap(raw, lineWidth, size);
while (lines.length > 4 && size > 6.25) {
size -= 0.25;
lines = wrap(raw, lineWidth, size);
}
// Avec plus de plages qu'une cellule peut en afficher, la ligne finale
// indique qu'il reste des entrées plutôt que d'afficher du texte coupé.
if (lines.length > 4) {
lines = [...lines.slice(0, 3), `${lines.length - 3} lignes supplémentaires`];
}
const leading = Math.min(9.4, (36 / lines.length));
const firstY = y + (lines.length === 1 ? 23 : 11.5);
lines.forEach((line, i) => canvas.text(line, x + 11, firstY + i * leading, size, TEXT, false));
if (note && lines.length <= 2) {
const truncated = wrap(note, lineWidth, 7.1)[0];
if (truncated && !raw.toLowerCase().includes(note.toLowerCase()))
canvas.text(truncated, x + 11, y + 37, 7.1, MUTED, false);
}
if (withTotal && total !== null) {
canvas.rounded(x + width - 64, y + 9, 53, 21, 5, accent === GREEN ? '#EAF6F1' : '#EBF1FA');
canvas.text(durationLabel(total), x + width - 37.5, y + 23, 9.1, accent, true, 'center');
}
if (incomplete) canvas.text(`${incomplete} incomplet${incomplete > 1 ? 's' : ''}`, x + width - 12, y + 39, 6.7, '#B26A3D', true, 'right');
};
const drawPage = (page, index, count, now) => {
const c = new PdfCanvas();
c.rect(0, 0, W, H, PAPER);
c.rect(0, 0, W, 124, NAVY);
c.rect(M, 21, 27, 3, GOLD);
c.text('LE FOURNIL DU BOCAGE', M, 39, 9.1, '#E5EDF5', true);
const title = page.withPlanning && page.withPointage
? 'PLANNING & POINTAGES' : page.withPlanning ? 'PLANNING HEBDOMADAIRE' : 'POINTAGES HEBDOMADAIRES';
c.text(title, M, 70, 23, '#FFFFFF', true);
c.text(page.name, M, 96, fit(page.name, 510, 13.1, true, 8.5), '#FFFFFF', true);
c.text(`Sélection du ${dateLabel(page.start)} au ${dateLabel(page.end)}`, M, 112, 8.2, '#BCD0DE');
c.rounded(W - M - 202, 29, 202, 77, 10, '#1F3950');
c.text('SEMAINE DU', W - M - 188, 47, 8.4, '#D4DFE8', true);
c.text(dateShort(page.weekStart), W - M - 188, 73, 15, '#FFFFFF', true);
c.text(`AU ${dateShort(page.weekEnd)}`, W - M - 188, 91, 9.8, '#E4EDF4', true);
const metrics = kpiValues(page);
const gap = 10;
const metricWidth = (W - M * 2 - gap * (metrics.length - 1)) / metrics.length;
metrics.forEach(([label, value, color], i) => drawMetric(c, M + i * (metricWidth + gap), 135, metricWidth, label, value, color));
c.text('DÉTAIL DE LA SEMAINE', M, 222, 10.1, TEXT, true);
if (page.partial) c.text('Jours hors période grisés', W - M, 222, 8, MUTED, false, 'right');
const tableTop = 231;
const headH = 22;
const rowH = 42.7;
const tableWidth = W - M * 2;
const dateWidth = 111;
const columns = page.withPlanning && page.withPointage
? [{ type: 'plan', width: (tableWidth - dateWidth) / 2 }, { type: 'clock', width: (tableWidth - dateWidth) / 2 }]
: [{ type: page.withPlanning ? 'plan' : 'clock', width: tableWidth - dateWidth }];
c.rounded(M, tableTop, tableWidth, headH + rowH * 7, 6, '#FFFFFF', LINE);
c.rect(M, tableTop, tableWidth, headH, '#E9EEF5');
c.text('JOUR', M + 13, tableTop + 15, 8.2, MUTED, true);
let colX = M + dateWidth;
columns.forEach(({ type, width }) => {
c.text(type === 'plan' ? 'HORAIRES PRÉVUS' : 'POINTAGES ENREGISTRÉS', colX + 11, tableTop + 15, 8.5, type === 'plan' ? GREEN : BLUE, true);
c.line(colX, tableTop, colX, tableTop + headH + rowH * 7);
colX += width;
});
page.days.forEach((day, dayIndex) => {
const y = tableTop + headH + dayIndex * rowH;
const faded = !day.included;
c.rect(M + 0.5, y, tableWidth - 1, rowH, faded ? '#EDF1F5' : dayIndex % 2 ? '#F8FAFD' : '#FFFFFF');
c.rect(M + 0.5, y, dateWidth - 0.5, rowH, faded ? '#E6EBF0' : dayIndex % 2 ? '#ECF2F9' : '#F1F5FA');
if (dayIndex > 0) c.line(M, y, M + tableWidth, y, LINE, 0.55);
c.text(WEEKDAYS[dayIndex], M + 13, y + 15.5, 8.5, faded ? '#97A5B1' : MUTED, true);
c.text(dateDay(day.date), M + 13, y + 32, 11, faded ? '#97A5B1' : TEXT, true);
let x = M + dateWidth;
columns.forEach(({ type, width }) => {
if (faded) {
c.text('Hors période sélectionnée', x + 12, y + 25, 8.4, '#A6B0BB');
} else if (type === 'plan') {
drawItems(c, day.planLines, x, y, width, true, day.plannedMinutes, GREEN,
day.planLines.length > 1 ? day.planningLabel : '');
} else {
drawItems(c, day.clockLines, x, y, width, true, day.pointedMinutes, BLUE, '', day.incompleteCount);
}
x += width;
});
});
// Redessine les séparations verticales par-dessus les remplissages alternés.
let x = M + dateWidth;
columns.forEach(({ width }) => { c.line(x, tableTop, x, tableTop + headH + rowH * 7, LINE, 0.7); x += width; });
const footnoteY = 575;
if (page.weeklyTarget !== null && page.withPlanning) {
c.text('* L’objectif hebdomadaire concerne la semaine entière, même si la période est partielle.', M, 565, 7.1, MUTED);
}
c.line(M, 573.5, W - M, 573.5, LINE);
const generated = now instanceof Date ? now : new Date(now);
const printed = `${String(generated.getDate()).padStart(2, '0')}/${String(generated.getMonth() + 1).padStart(2, '0')}/${generated.getFullYear()}`;
c.text(`Le Fournil du Bocage • Édité le ${printed}`, M, footnoteY + 10, 7.7, MUTED);
c.text(`Page ${index + 1} / ${count}`, W - M, footnoteY + 10, 7.7, MUTED, true, 'right');
return c.content();
};
const writePdf = (streams) => {
const objects = [null];
const add = (value = '') => { objects.push(value); return objects.length - 1; };
const catalog = add();
const pagesRoot = add();
const regular = add('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>');
const bold = add('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>');
const pageIds = [];
streams.forEach((stream) => {
const encoded = stream;
const contents = add(`<< /Length ${encoded.length} >>\nstream\n${encoded}endstream`);
pageIds.push(add(`<< /Type /Page /Parent ${pagesRoot} 0 R /MediaBox [0 0 ${W} ${H}] /Resources << /Font << /F1 ${regular} 0 R /F2 ${bold} 0 R >> >> /Contents ${contents} 0 R >>`));
});
objects[pagesRoot] = `<< /Type /Pages /Count ${pageIds.length} /Kids [${pageIds.map((id) => `${id} 0 R`).join(' ')}] >>`;
objects[catalog] = `<< /Type /Catalog /Pages ${pagesRoot} 0 R >>`;
let output = '%PDF-1.4\n%\xE2\xE3\xCF\xD3\n';
const offsets = [0];
objects.forEach((object, id) => {
if (id === 0) return;
offsets[id] = output.length;
output += `${id} 0 obj\n${object}\nendobj\n`;
});
const startXref = output.length;
output += `xref\n0 ${objects.length}\n0000000000 65535 f \n`;
for (let id = 1; id < objects.length; id++) output += `${String(offsets[id]).padStart(10, '0')} 00000 n \n`;
output += `trailer\n<< /Size ${objects.length} /Root ${catalog} 0 R >>\nstartxref\n${startXref}\n%%EOF`;
return Uint8Array.from(output, (character) => character.charCodeAt(0));
};
/** Construit un PDF directement dans le navigateur, une page par semaine. */
export const createWeeklyPdfBytes = (pages, now = new Date()) => {
if (!Array.isArray(pages) || !pages.length) throw new Error('Aucune semaine à exporter.');
return writePdf(pages.map((page, index) => drawPage(page, index, pages.length, now)));
};
export const downloadWeeklyPdf = (pages, { start, end, name }) => {
const slug = String(name).normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'utilisateur';
const bytes = createWeeklyPdfBytes(pages);
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
const link = document.createElement('a');
link.href = url;
link.download = `planning-pointages-${slug}-${start}-${end}.pdf`;
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
};

View File

@ -6,6 +6,8 @@ import React, {
} from 'react'; } from 'react';
import { timeApi } from '../api/time'; import { timeApi } from '../api/time';
import { planningApi } from '../api/planning'; import { planningApi } from '../api/planning';
import { buildWeeklyPdfPages, validateExportPeriod } from '@/lib/weeklyExport';
import { downloadWeeklyPdf } from '@/lib/weeklyPdf';
import PlannedDay from '@/components/planning/PlannedDay'; import PlannedDay from '@/components/planning/PlannedDay';
import { dateKey, formatMinutes as formatPlannedMinutes } from '@/components/planning/utils'; import { dateKey, formatMinutes as formatPlannedMinutes } from '@/components/planning/utils';
import { useAuth } from '../context/useAuth'; import { useAuth } from '../context/useAuth';
@ -14,11 +16,14 @@ import MainCard from '@/components/MainCard';
import HoverInfo from '@/components/HoverInfo'; import HoverInfo from '@/components/HoverInfo';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { CardContent } from '@/components/ui/card'; import { CardContent } from '@/components/ui/card';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
CalendarDays, CalendarDays,
Download,
X, X,
} from 'lucide-react'; } from 'lucide-react';
@ -61,6 +66,13 @@ const WeeklySummaryPage = ({ userId }) => {
const [planningLoading, setPlanningLoading] = useState(false); const [planningLoading, setPlanningLoading] = useState(false);
const [selectedUserId, setSelectedUserId] = useState(''); const [selectedUserId, setSelectedUserId] = useState('');
const [showAllUsers, setShowAllUsers] = useState(false); const [showAllUsers, setShowAllUsers] = useState(false);
const [exportOpen, setExportOpen] = useState(false);
const [exportPlanning, setExportPlanning] = useState(true);
const [exportPointage, setExportPointage] = useState(true);
const [exportStart, setExportStart] = useState('');
const [exportEnd, setExportEnd] = useState('');
const [exportBusy, setExportBusy] = useState(false);
const [exportError, setExportError] = useState('');
const [currentWeek, setCurrentWeek] = useState(new Date()); const [currentWeek, setCurrentWeek] = useState(new Date());
@ -412,6 +424,50 @@ const WeeklySummaryPage = ({ userId }) => {
const plannedStart = dateKey(weekStart); const plannedStart = dateKey(weekStart);
const plannedEnd = dateKey(weekEnd); const plannedEnd = dateKey(weekEnd);
const plannedKey = `${plannedUserId}-${plannedStart}`; const plannedKey = `${plannedUserId}-${plannedStart}`;
const openExport = () => {
setExportStart(plannedStart);
setExportEnd(plannedEnd);
setExportPlanning(true);
setExportPointage(true);
setExportError('');
setExportOpen(true);
};
const exportPeriodError = validateExportPeriod(exportStart, exportEnd);
const canExport = Boolean(plannedUserId) && (!isAdmin || !showAllUsers) &&
(exportPlanning || exportPointage) && !exportPeriodError;
const handleExport = async () => {
if (exportBusy || !canExport) return;
// Capture la sélection au clic : on n'exporte jamais « tout le monde ».
const targetId = String(plannedUserId);
const targetName = isAdmin
? getUserLabel(users.find((user) => getUserId(user) === targetId))
: getUserLabel(authUser || currentUser);
const start = exportStart;
const end = exportEnd;
try {
setExportBusy(true);
setExportError('');
const [period, entries] = await Promise.all([
exportPlanning ? planningApi.effective(targetId, start, end) : Promise.resolve(null),
exportPointage ? timeApi.getTimeEntries() : Promise.resolve([]),
]);
const scopedEntries = entries.filter((entry) => getEntryUserId(entry) === targetId);
const pages = buildWeeklyPdfPages({
start, end, name: targetName,
withPlanning: exportPlanning, withPointage: exportPointage,
planning: period, attendances: scopedEntries,
});
downloadWeeklyPdf(pages, { start, end, name: targetName });
setExportOpen(false);
} catch (err) {
setExportError(err.message || 'Impossible de générer l’export. Réessayez.');
} finally {
setExportBusy(false);
}
};
const individualView = !(isAdmin && showAllUsers); const individualView = !(isAdmin && showAllUsers);
const plannedDays = individualView && planned.key === plannedKey ? planned.days : []; const plannedDays = individualView && planned.key === plannedKey ? planned.days : [];
const plannedSegments = individualView && planned.key === plannedKey ? planned.segments : []; const plannedSegments = individualView && planned.key === plannedKey ? planned.segments : [];
@ -1299,7 +1355,7 @@ const WeeklySummaryPage = ({ userId }) => {
return ( return (
<MainCard <MainCard
icon={CalendarDays} icon={CalendarDays}
className="overflow-hidden max-md:[&>[data-slot=card-header]]:flex-wrap" className="overflow-hidden max-md:[&>[data-slot=card-header]]:flex-wrap max-md:[&>[data-slot=card-header]>div]:flex-wrap max-md:[&>[data-slot=card-header]>div]:justify-center"
title={ title={
isAdmin ? ( isAdmin ? (
<> <>
@ -1319,6 +1375,17 @@ const WeeklySummaryPage = ({ userId }) => {
} }
headerContent={ headerContent={
<> <>
<Button
variant="outline"
size="sm"
onClick={openExport}
disabled={(isAdmin && showAllUsers) || !plannedUserId}
title={isAdmin && showAllUsers ? 'Sélectionnez un salarié pour exporter son planning' : 'Exporter le planning et/ou les pointages'}
className="flex shrink-0 items-center gap-1"
>
<Download className="h-4 w-4" />
Exporter
</Button>
{!isMobile && ( {!isMobile && (
<> <>
<Button <Button
@ -1399,6 +1466,52 @@ const WeeklySummaryPage = ({ userId }) => {
</> </>
} }
> >
<Dialog open={exportOpen} onOpenChange={(open) => { if (!exportBusy) setExportOpen(open); }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Exporter le planning</DialogTitle>
<DialogDescription>
{isAdmin
? `Salarié : ${getUserLabel(selectedUser)}`
: 'Exporter votre planning et vos pointages sur la période souhaitée.'}
{' '}PDF A4 paysage, avec une semaine par page.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="grid gap-1.5 text-sm font-medium">
Du
<Input type="date" value={exportStart} min="2000-01-01" max="2099-12-31" onChange={(event) => { setExportStart(event.target.value); setExportError(''); }} disabled={exportBusy} />
</label>
<label className="grid gap-1.5 text-sm font-medium">
Au
<Input type="date" value={exportEnd} min="2000-01-01" max="2099-12-31" onChange={(event) => { setExportEnd(event.target.value); setExportError(''); }} disabled={exportBusy} />
</label>
</div>
<fieldset className="space-y-2 rounded-lg border p-3">
<legend className="px-1 text-sm font-medium">Données à exporter</legend>
<label className="flex cursor-pointer items-center gap-2 text-sm">
<input type="checkbox" checked={exportPlanning} onChange={(event) => setExportPlanning(event.target.checked)} disabled={exportBusy} className="size-4 accent-primary" />
Planning prévisionnel
</label>
<label className="flex cursor-pointer items-center gap-2 text-sm">
<input type="checkbox" checked={exportPointage} onChange={(event) => setExportPointage(event.target.checked)} disabled={exportBusy} className="size-4 accent-primary" />
Pointages enregistrés
</label>
</fieldset>
{!exportPlanning && !exportPointage && <p className="text-sm text-destructive">Cochez au moins un élément à exporter.</p>}
{exportPeriodError && <p role="alert" className="text-sm text-destructive">{exportPeriodError}</p>}
{exportError && <p role="alert" className="text-sm text-destructive">{exportError}</p>}
</div>
<DialogFooter>
<Button variant="outline" disabled={exportBusy} onClick={() => setExportOpen(false)}>Annuler</Button>
<Button onClick={handleExport} disabled={!canExport || exportBusy}>
<Download className="h-4 w-4" />
{exportBusy ? 'Préparation…' : 'Télécharger le PDF'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<CardContent className="flex min-h-0 flex-1 flex-col overflow-hidden"> <CardContent className="flex min-h-0 flex-1 flex-col overflow-hidden">
{/* Sélection utilisateur */} {/* Sélection utilisateur */}
{isAdmin && ( {isAdmin && (

View File

@ -0,0 +1,112 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildWeeklyPdfPages, validateExportPeriod } from '../src/lib/weeklyExport.js';
import { createWeeklyPdfBytes } from '../src/lib/weeklyPdf.js';
const planning = {
days: [
{ date: '2026-10-05', schedule_mode: 'precise', label: 'Habituel', kind: 'work' },
{ date: '2026-10-06', schedule_mode: 'precise', label: 'Repos', kind: 'rest' },
{ date: '2026-10-07', schedule_mode: 'daily_hours', target_minutes: 420, label: 'Heures à effectuer', kind: 'work' },
{ date: '2026-10-08', schedule_mode: 'precise', label: 'Absence', kind: 'absence' },
],
segments: [
{ date: '2026-10-05', start_time: '08:00', end_time: '12:00', label: 'Habituel' },
{ date: '2026-10-05', start_time: '13:00', end_time: '17:00', label: 'Habituel' },
],
};
const attendances = [
{ start_date: '2026-10-05', end_date: '2026-10-05', start_time: '08:03:00', end_time: '12:00:00', duration: 3.95 },
{ start_date: '2026-10-05', end_date: '2026-10-05', start_time: '13:00:00', end_time: '17:00:00', duration: 4 },
{ start_date: '2026-10-06', end_date: '2026-10-06', start_time: '08:00:00', end_time: null, duration: null },
];
const base = { start: '2026-10-05', end: '2026-10-07', name: 'Émilie',
withPlanning: true, withPointage: true, planning, attendances };
const pdfString = (pages) => Buffer.from(createWeeklyPdfBytes(pages, new Date('2026-10-10T12:00:00Z'))).toString('latin1');
test('vérifie les bornes et la limite API de 366 jours', () => {
assert.equal(validateExportPeriod('2026-10-05', '2026-10-11'), '');
assert.match(validateExportPeriod('2026-10-11', '2026-10-05'), /postérieure/);
assert.match(validateExportPeriod('2026-02-30', '2026-03-01'), /valides/);
assert.match(validateExportPeriod('2026-01-01', '2027-01-02'), /366 jours/);
assert.equal(validateExportPeriod('2024-01-01', '2024-12-31'), '');
assert.throws(() => buildWeeklyPdfPages({ ...base, start: '2026-13-01' }), /date/);
assert.throws(() => buildWeeklyPdfPages({ ...base, withPlanning: false, withPointage: false }), /élément/);
});
test('export combiné : heures, objectifs, pointages et pointage incomplet', () => {
const [page] = buildWeeklyPdfPages({ ...base, end: '2026-10-08' });
assert.equal(page.days.length, 7);
assert.equal(page.weekStart, '2026-10-05');
assert.equal(page.weekEnd, '2026-10-11');
assert.equal(page.plannedMinutes, 900); // 8h précises + 7h journalières
assert.equal(page.pointedMinutes, 477);
assert.equal(page.incompleteCount, 1);
assert.deepEqual(page.days[0].planLines, ['08:00 - 12:00', '13:00 - 17:00']);
assert.match(page.days[2].planLines[0], /Heures à effectuer/);
assert.match(page.days[3].planLines[0], /Absence/);
assert.match(page.days[1].clockLines[0], /incomplet/);
});
test('1 page par semaine, y compris quand les dates ne sont pas des lundis et dimanches', () => {
const pages = buildWeeklyPdfPages({ ...base, start: '2026-10-07', end: '2026-10-22' });
assert.equal(pages.length, 3);
assert.deepEqual(pages.map((page) => page.weekStart), ['2026-10-05', '2026-10-12', '2026-10-19']);
assert.equal(pages[0].includedDays, 5);
assert.equal(pages[1].includedDays, 7);
assert.equal(pages[2].includedDays, 4);
assert.equal(pages[0].days[0].included, false);
assert.equal(pages[0].days[1].included, false);
assert.equal(pages[2].days[4].included, false);
assert.equal(pages[0].plannedMinutes, 420); // le 5 octobre est hors période
assert.equal(pages[0].pointedMinutes, 0);
});
test('choix planning seul ou pointages seuls ne mixe pas les données', () => {
const [onlyPlan] = buildWeeklyPdfPages({ ...base, withPointage: false });
const [onlyClock] = buildWeeklyPdfPages({ ...base, withPlanning: false });
assert.equal(onlyPlan.pointedMinutes, 0);
assert.equal(onlyClock.plannedMinutes, 0);
assert.equal(onlyClock.pointedMinutes, 477);
assert.equal(onlyPlan.days[0].clockLines.length, 0);
assert.equal(onlyClock.days[0].planLines.length, 0);
assert.match(pdfString([onlyPlan]), /PLANNING HEBDOMADAIRE/);
assert.match(pdfString([onlyClock]), /POINTAGES HEBDOMADAIRES/);
});
test('objectif hebdomadaire calculé une seule fois par semaine (même partielle)', () => {
const days = ['2026-10-05', '2026-10-06', '2026-10-07'].map((date) => ({
date, schedule_mode: 'weekly_hours', week_target_minutes: 2100, kind: 'weekly_target',
}));
const [page] = buildWeeklyPdfPages({ ...base, start: '2026-10-06',
withPointage: false, planning: { days, segments: [] } });
assert.equal(page.weeklyTarget, 2100);
assert.equal(page.plannedMinutes, 2100);
assert.equal(page.days[0].included, false);
assert.match(pdfString([page]), /objectif hebdomadaire concerne/);
});
test('pointage nocturne avec fin le lendemain et durée transmise', () => {
const [page] = buildWeeklyPdfPages({ ...base, withPlanning: false,
attendances: [{ start_date: '2026-10-05', end_date: '2026-10-06', start_time: '22:00:00', end_time: '03:00:00', duration: 5 }] });
assert.equal(page.pointedMinutes, 300);
assert.equal(page.days[0].pointedMinutes, 300);
assert.match(page.days[0].clockLines[0], /\(\+1j\)/);
assert.equal(page.days[1].pointedMinutes, null);
});
test('PDF A4 paysage valide : vrai PDF vectoriel, 1 page par semaine, texte français', () => {
const pages = buildWeeklyPdfPages({ ...base, start: '2026-10-07', end: '2026-10-19' });
const bytes = createWeeklyPdfBytes(pages);
const pdf = Buffer.from(bytes).toString('latin1');
assert.ok(bytes.length > 4000);
assert.match(pdf, /^%PDF-1\.4/);
assert.equal((pdf.match(/\/Type \/Page \/Parent/g) || []).length, 3);
assert.match(pdf, /\/MediaBox \[0 0 841\.89 595\.28\]/);
assert.match(pdf, /\/Count 3/);
assert.match(pdf, /PLANNING & POINTAGES/);
assert.match(pdf, /-7h00/); // écart négatif affiché sans caractère non pris en charge
assert.match(pdf, /\/Encoding \/WinAnsiEncoding/);
assert.match(pdf, /Page 3 \/ 3/);
});