nickel
This commit is contained in:
parent
aa3b5e6ae3
commit
e360b0b684
168
frontend/src/lib/weeklyExport.js
Normal file
168
frontend/src/lib/weeklyExport.js
Normal 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;
|
||||
};
|
||||
|
||||
360
frontend/src/lib/weeklyPdf.js
Normal file
360
frontend/src/lib/weeklyPdf.js
Normal 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);
|
||||
};
|
||||
@ -6,6 +6,8 @@ import React, {
|
||||
} from 'react';
|
||||
import { timeApi } from '../api/time';
|
||||
import { planningApi } from '../api/planning';
|
||||
import { buildWeeklyPdfPages, validateExportPeriod } from '@/lib/weeklyExport';
|
||||
import { downloadWeeklyPdf } from '@/lib/weeklyPdf';
|
||||
import PlannedDay from '@/components/planning/PlannedDay';
|
||||
import { dateKey, formatMinutes as formatPlannedMinutes } from '@/components/planning/utils';
|
||||
import { useAuth } from '../context/useAuth';
|
||||
@ -14,11 +16,14 @@ import MainCard from '@/components/MainCard';
|
||||
import HoverInfo from '@/components/HoverInfo';
|
||||
import { Button } from '@/components/ui/button';
|
||||
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 {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CalendarDays,
|
||||
Download,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
@ -61,6 +66,13 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
const [planningLoading, setPlanningLoading] = useState(false);
|
||||
const [selectedUserId, setSelectedUserId] = useState('');
|
||||
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());
|
||||
|
||||
@ -412,6 +424,50 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
const plannedStart = dateKey(weekStart);
|
||||
const plannedEnd = dateKey(weekEnd);
|
||||
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 plannedDays = individualView && planned.key === plannedKey ? planned.days : [];
|
||||
const plannedSegments = individualView && planned.key === plannedKey ? planned.segments : [];
|
||||
@ -1299,7 +1355,7 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
return (
|
||||
<MainCard
|
||||
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={
|
||||
isAdmin ? (
|
||||
<>
|
||||
@ -1319,6 +1375,17 @@ const WeeklySummaryPage = ({ userId }) => {
|
||||
}
|
||||
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 && (
|
||||
<>
|
||||
<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">
|
||||
{/* Sélection utilisateur */}
|
||||
{isAdmin && (
|
||||
|
||||
112
frontend/tests/weeklyExport.test.js
Normal file
112
frontend/tests/weeklyExport.test.js
Normal 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/);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user