From b13b52b22b00e6d4f7ba7e0447a0a86e9b638309 Mon Sep 17 00:00:00 2001 From: PTA Date: Tue, 11 Aug 2026 14:45:52 +0200 Subject: [PATCH] =?UTF-8?q?Motif=20de=20travail=20par=20d=C3=A9faut=20?= =?UTF-8?q?=C3=A0=20la=20saisie=20et=20heures=20travaill=C3=A9es=20compl?= =?UTF-8?q?=C3=A8tes=20dans=20les=20PDF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La liste des motifs d'une nouvelle plage s'ouvrait sur un placeholder vide « Choisir un motif », alors que le temps de travail est le cas très majoritaire : elle propose désormais « Heure de travail » (code TRAVAIL), avec repli sur le premier motif décomptant le quota si le référentiel ne contient pas ce code. Côté PDF, seul le code générique TRAVAIL était traité comme du temps travaillé. Les motifs ALP matin, ALP soir, restauration, mercredi périscolaire, extrascolaire, préparations et ménage de fond tombaient donc : - dans un total invisible du bilan annuel (ligne « Heures travaillées » à 0 h 00 alors que les heures étaient bien décomptées plus bas) ; - dans la couleur grise « Autre absence » des plannings agent et lieu. La classification est centralisée dans App\Core\MotifCategories, réutilisée par PtaModel (qui portait déjà la même liste) et par les deux services PDF. Un motif inconnu du référentiel est rattaché au temps de travail dès lors qu'il décompte le quota, pour qu'aucun total ne puisse disparaître d'un document. La ligne « Heures travaillées » détaille en outre les motifs regroupés, et le total décompté suit maintenant le drapeau compte_dans_quota comme QuotaModel : il additionnait auparavant les congés, hors quota, en contradiction avec sa propre note de bas de tableau. Co-Authored-By: Claude Opus 5 --- app/Core/MotifCategories.php | 85 ++++++++++++++ app/Models/PtaModel.php | 9 +- app/Services/AgentTimeSummaryPdfService.php | 121 +++++++++++++++----- app/Services/PlanningPdfService.php | 43 ++++--- app/Views/layouts/footer.php | 6 +- app/Views/layouts/header.php | 2 +- app/Views/pages/role.php | 2 +- assets/js/planning-editor.js | 20 +++- 8 files changed, 235 insertions(+), 53 deletions(-) create mode 100644 app/Core/MotifCategories.php diff --git a/app/Core/MotifCategories.php b/app/Core/MotifCategories.php new file mode 100644 index 0000000..6c9870d --- /dev/null +++ b/app/Core/MotifCategories.php @@ -0,0 +1,85 @@ +fillRect($x, $y, $w, $headerH, 0.92); @@ -78,19 +79,75 @@ final class AgentTimeSummaryPdfService $pdf->text($x + 12, $y + 21, 'Répartition du temps', 11, true); $cursorY = $y + $headerH; - foreach ($rows as [$code, $label, $minutes]) { + foreach ($rows as [$code, $label, $minutes, $detail]) { $palette = $this->palette($code); $pdf->line($x, $cursorY, $x + $w, $cursorY, 0.83); $pdf->fillRectColor($x + 10, $cursorY + 11, 10, 20, $palette['accent']); - $pdf->text($x + 32, $cursorY + 25, $label, 9.4, true); - $pdf->text($x + $w - 130, $cursorY + 25, $this->minutesLabel($minutes), 10, true); + + $detailLines = $detail === [] ? [] : $this->detailLines($pdf, $detail, $w - 174); + if ($detailLines === []) { + $pdf->text($x + 32, $cursorY + 25, $label, 9.4, true); + $pdf->text($x + $w - 130, $cursorY + 25, $this->minutesLabel($minutes), 10, true); + } else { + $pdf->text($x + 32, $cursorY + 19, $label, 9.4, true); + $pdf->text($x + $w - 130, $cursorY + 19, $this->minutesLabel($minutes), 10, true); + $detailY = $cursorY + 30; + foreach ($detailLines as $line) { + $pdf->text($x + 32, $detailY, $line, 7); + $detailY += 9; + } + } + $cursorY += $rowH; } } + /** + * Détaille les motifs regroupés dans les heures travaillées (ALP matin, ALP soir, + * restauration, préparations…), sur deux lignes au maximum. + * + * @param array $detail + * @return string[] + */ + private function detailLines(SimplePdf $pdf, array $detail, float $width): array + { + arsort($detail); + $parts = []; + foreach ($detail as $label => $minutes) { + if ($minutes > 0) { + $parts[] = $label . ' ' . $this->minutesLabel($minutes); + } + } + + if ($parts === []) { + return []; + } + + // Les motifs sont ajoutés un par un : la coupure tombe ainsi entre deux motifs + // et jamais au milieu d'un libellé, en gardant la place des points de suspension. + $text = ''; + $truncated = false; + foreach ($parts as $part) { + $candidate = $text === '' ? 'Dont ' . $part : $text . ' · ' . $part; + if (count($pdf->wrap($candidate . ' …', $width, 7)) > 2) { + $truncated = true; + break; + } + $text = $candidate; + } + + if ($text === '') { + return []; + } + + return $pdf->wrap($truncated ? $text . ' …' : $text, $width, 7); + } + private function drawResult(SimplePdf $pdf, array $quota, array $totals): void { - $counted = $totals['TRAVAIL'] + $totals['MALADIE'] + $totals['FORMATION'] + $totals['CONGE'] + $totals['OTHER_COUNTED']; + // Le décompte suit le drapeau compte_dans_quota du motif, comme QuotaModel : + // additionner les catégories affichées y ferait entrer les congés, hors quota. + $counted = (int) $totals['DECOMPTE']; $target = (int) ($quota['quota_cible_minutes'] ?? 0); $remaining = $target - $counted; $validated = (int) ($quota['minutes_valides'] ?? 0); @@ -135,28 +192,38 @@ final class AgentTimeSummaryPdfService ); } + /** + * Regroupe les minutes par catégorie métier. Tous les motifs de travail effectif + * (ALP matin, ALP soir, restauration, mercredi périscolaire, extrascolaire, + * préparations, ménage de fond…) alimentent la ligne « Heures travaillées », et pas + * seulement le motif générique TRAVAIL. + */ private function normalizeTotals(array $rows): array { $totals = [ - 'TRAVAIL' => 0, - 'MALADIE' => 0, - 'FORMATION' => 0, - 'CONGE' => 0, - 'AUTRE' => 0, - 'OTHER_COUNTED' => 0, + MotifCategories::TRAVAIL => 0, + MotifCategories::ABSENCE => 0, + MotifCategories::FORMATION => 0, + MotifCategories::CONGE => 0, + MotifCategories::NON_DECOMPTE => 0, + 'DECOMPTE' => 0, + 'DETAIL_TRAVAIL' => [], ]; foreach ($rows as $row) { - $code = strtoupper((string) ($row['motif_code'] ?? 'AUTRE')); + $code = MotifCategories::normalizeCode($row['motif_code'] ?? null); $minutes = (int) ($row['minutes_total'] ?? 0); - if (array_key_exists($code, $totals) && $code !== 'OTHER_COUNTED') { - $totals[$code] += $minutes; - continue; + $counts = (int) ($row['compte_dans_quota'] ?? 0) === 1; + $category = MotifCategories::category($code, $counts); + + $totals[$category] += $minutes; + if ($counts) { + $totals['DECOMPTE'] += $minutes; } - if ((int) ($row['compte_dans_quota'] ?? 0) === 1) { - $totals['OTHER_COUNTED'] += $minutes; - } else { - $totals['AUTRE'] += $minutes; + + if ($category === MotifCategories::TRAVAIL && $minutes > 0) { + $label = trim((string) ($row['motif_libelle'] ?? '')) ?: $code; + $totals['DETAIL_TRAVAIL'][$label] = ($totals['DETAIL_TRAVAIL'][$label] ?? 0) + $minutes; } } @@ -167,10 +234,10 @@ final class AgentTimeSummaryPdfService private function palette(string $code): array { return match (strtoupper($code)) { - 'TRAVAIL' => ['accent' => '#2563EB'], - 'FORMATION' => ['accent' => '#7C3AED'], - 'CONGE' => ['accent' => '#059669'], - 'MALADIE' => ['accent' => '#DC2626'], + MotifCategories::TRAVAIL => ['accent' => '#2563EB'], + MotifCategories::FORMATION => ['accent' => '#7C3AED'], + MotifCategories::CONGE => ['accent' => '#059669'], + MotifCategories::ABSENCE, 'MALADIE' => ['accent' => '#DC2626'], default => ['accent' => '#64748B'], }; } diff --git a/app/Services/PlanningPdfService.php b/app/Services/PlanningPdfService.php index a423630..e00bdab 100644 --- a/app/Services/PlanningPdfService.php +++ b/app/Services/PlanningPdfService.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Services; +use App\Core\MotifCategories; use DateTimeImmutable; final class PlanningPdfService @@ -170,7 +171,7 @@ final class PlanningPdfService $blockY = $y + 5; foreach ($blocks as $block) { $entry = $block['entry']; - $palette = $this->motifPalette((string) ($entry['motif_code'] ?? 'AUTRE')); + $palette = $this->entryPalette($entry); $blockHeight = (float) $block['height']; $pdf->fillRectColor($cellX + 4, $blockY, $dayWidth - 8, $blockHeight, $palette['background']); $pdf->fillRectColor($cellX + 4, $blockY, 3.0, $blockHeight, $palette['accent']); @@ -269,7 +270,7 @@ final class PlanningPdfService break; } - $palette = $this->motifPalette((string) ($entry['motif_code'] ?? 'AUTRE')); + $palette = $this->entryPalette($entry); $pdf->fillRectColor($colX + 4, $cursorY, $colWidth - 8, $cardHeight, $palette['background']); $pdf->fillRectColor($colX + 4, $cursorY, 3.0, $cardHeight, $palette['accent']); @@ -320,21 +321,35 @@ final class PlanningPdfService return $pdf->wrap($first, $width, 7.0); } + /** + * Le motif générique TRAVAIL n'apporte aucune information sur une plage déjà colorée + * en « temps de travail » ; les motifs de travail spécialisés (ALP matin, ALP soir, + * restauration…) restent affichés, ils précisent la nature du temps travaillé. + */ private function motifDisplayLabel(array $entry): string { - return strtoupper((string) ($entry['motif_code'] ?? '')) === 'TRAVAIL' + return MotifCategories::normalizeCode($entry['motif_code'] ?? null) === 'TRAVAIL' ? '' : (string) ($entry['motif_libelle'] ?? 'Affectation'); } /** @return array{accent:string,background:string,label:string} */ - private function motifPalette(string $code): array + private function entryPalette(array $entry): array { - return match (strtoupper($code)) { - 'TRAVAIL' => ['accent' => '#2563EB', 'background' => '#EFF6FF', 'label' => 'Travail'], - 'FORMATION' => ['accent' => '#7C3AED', 'background' => '#F5F3FF', 'label' => 'Formation'], - 'CONGE' => ['accent' => '#059669', 'background' => '#ECFDF5', 'label' => 'Conge'], - 'MALADIE' => ['accent' => '#DC2626', 'background' => '#FEF2F2', 'label' => 'Absence'], + return $this->motifPalette( + (string) ($entry['motif_code'] ?? 'AUTRE'), + (int) ($entry['compte_dans_quota'] ?? 0) === 1 + ); + } + + /** @return array{accent:string,background:string,label:string} */ + private function motifPalette(string $code, bool $countsInQuota = false): array + { + return match (MotifCategories::category($code, $countsInQuota)) { + MotifCategories::TRAVAIL => ['accent' => '#2563EB', 'background' => '#EFF6FF', 'label' => 'Travail'], + MotifCategories::FORMATION => ['accent' => '#7C3AED', 'background' => '#F5F3FF', 'label' => 'Formation'], + MotifCategories::CONGE => ['accent' => '#059669', 'background' => '#ECFDF5', 'label' => 'Conge'], + MotifCategories::ABSENCE => ['accent' => '#DC2626', 'background' => '#FEF2F2', 'label' => 'Absence'], default => ['accent' => '#64748B', 'background' => '#F8FAFC', 'label' => 'Autre absence'], }; } @@ -342,18 +357,20 @@ final class PlanningPdfService private function drawLegend(SimplePdf $pdf, float $x, float $y): void { $items = [ - ['TRAVAIL', 'Travail'], + ['TRAVAIL', 'Travail (dont ALP, restauration, preparation)'], ['FORMATION', 'Formation'], ['CONGE', 'Conge'], - ['MALADIE', 'Absence'], - ['AUTRE', 'Autre absence'], + ['ABSENCE', 'Absence'], + ['JNT', 'Autre absence'], ]; foreach ($items as [$code, $label]) { $palette = $this->motifPalette($code); $pdf->fillRectColor($x, $y - 7, 8, 8, $palette['accent']); $pdf->text($x + 12, $y, $label, 6.5); - $x += 76; + // Largeur estimée du libellé (Helvetica ~0,52 em) plutôt qu'un pas fixe : + // la première entrée est trop longue pour une colonne de 76 points. + $x += 12 + (mb_strlen($label) * 6.5 * 0.52) + 18; } } diff --git a/app/Views/layouts/footer.php b/app/Views/layouts/footer.php index b4d9ac6..9993753 100644 --- a/app/Views/layouts/footer.php +++ b/app/Views/layouts/footer.php @@ -28,10 +28,10 @@ window.PTA_PAGE_CONFIG = $pageConfig, ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>; - + - + - + diff --git a/app/Views/layouts/header.php b/app/Views/layouts/header.php index ea590f0..aa2452f 100644 --- a/app/Views/layouts/header.php +++ b/app/Views/layouts/header.php @@ -67,7 +67,7 @@ $showGroupLabels = $visiblePageCount > 4; <?= htmlspecialchars($pageTitle) ?> — PTA - +
diff --git a/app/Views/pages/role.php b/app/Views/pages/role.php index f59ebbf..f07105b 100644 --- a/app/Views/pages/role.php +++ b/app/Views/pages/role.php @@ -12,7 +12,7 @@ Choisir votre accès — PTA - +
diff --git a/assets/js/planning-editor.js b/assets/js/planning-editor.js index 336304f..12c01fa 100644 --- a/assets/js/planning-editor.js +++ b/assets/js/planning-editor.js @@ -110,6 +110,19 @@ updateAgentPdfButton(); } + /** + * Motif proposé par défaut sur une nouvelle plage : le temps de travail, très + * majoritaire à la saisie. On se rabat sur le premier motif décomptant le quota + * si le référentiel ne contient pas le code TRAVAIL. + */ + function defaultMotifId() { + const motifs = window.PTA_CONFIG?.motifs || []; + const motif = motifs.find((item) => String(item.code).toUpperCase() === 'TRAVAIL') + || motifs.find((item) => item.quota) + || motifs[0]; + return motif ? Number(motif.id) : null; + } + function motifOptionsHtml(selectedMotifId = null) { return (window.PTA_CONFIG?.motifs || []) .map((motif) => { @@ -122,13 +135,16 @@ function timeRangeHtml(dayLabel, index = 0, selectedMotifId = null) { const suffix = `${dayLabel} plage ${index + 1}`; + const fallbackMotifId = defaultMotifId(); + const motifId = selectedMotifId ?? fallbackMotifId; + const placeholder = fallbackMotifId === null ? '' : ''; return `