diff --git a/frontend/src/lib/weeklyExport.js b/frontend/src/lib/weeklyExport.js new file mode 100644 index 0000000..27a7fb1 --- /dev/null +++ b/frontend/src/lib/weeklyExport.js @@ -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; +}; + diff --git a/frontend/src/lib/weeklyPdf.js b/frontend/src/lib/weeklyPdf.js new file mode 100644 index 0000000..0ede6ea --- /dev/null +++ b/frontend/src/lib/weeklyPdf.js @@ -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); +}; diff --git a/frontend/src/pages/WeeklySummaryPage.jsx b/frontend/src/pages/WeeklySummaryPage.jsx index eb55393..bbd3ba1 100644 --- a/frontend/src/pages/WeeklySummaryPage.jsx +++ b/frontend/src/pages/WeeklySummaryPage.jsx @@ -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 ( @@ -1319,6 +1375,17 @@ const WeeklySummaryPage = ({ userId }) => { } headerContent={ <> + {!isMobile && ( <> + + + + {/* Sélection utilisateur */} {isAdmin && ( diff --git a/frontend/tests/weeklyExport.test.js b/frontend/tests/weeklyExport.test.js new file mode 100644 index 0000000..a22af05 --- /dev/null +++ b/frontend/tests/weeklyExport.test.js @@ -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/); +});