Motif de travail par défaut à la saisie et heures travaillées complètes dans les PDF

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 <noreply@anthropic.com>
This commit is contained in:
PTA
2026-08-11 14:45:52 +02:00
parent 13ee16e25d
commit b13b52b22b
8 changed files with 235 additions and 53 deletions

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Core;
/**
* Classement métier des motifs de planning, partagé par les modèles et les PDF.
*
* Le référentiel `motif_planning` distingue le temps de travail effectif (ALP matin,
* restauration, ALP soir, mercredi périscolaire, extrascolaire, préparations, ménage de fond…)
* du temps décompté mais non travaillé (formation, absence) et du temps hors quota
* (congé, autre absence, JNT). Tous ces motifs de travail doivent être présentés
* comme des heures travaillées, et pas seulement le motif générique `TRAVAIL`.
*/
final class MotifCategories
{
public const TRAVAIL = 'TRAVAIL';
public const FORMATION = 'FORMATION';
public const CONGE = 'CONGE';
public const ABSENCE = 'ABSENCE';
public const NON_DECOMPTE = 'NON_DECOMPTE';
/** Motifs comptant comme du temps de travail effectif. */
public const WORK_CODES = [
'TRAVAIL',
'ALP_MATIN',
'RESTAURATION',
'ALP_SOIR',
'MERCREDI_PERISCOLAIRE',
'EXTRASCOLAIRE',
'PREPARATION_PERISCOLAIRE',
'PREPARATION_EXTRASCOLAIRE',
'PREPARATION_LUNDI',
'MENAGE_FOND',
'JOUR_FRACTIONNEMENT',
];
/** Motifs décomptés du quota sans être du temps de travail effectif. */
private const FORMATION_CODES = ['FORMATION'];
private const ABSENCE_CODES = ['ABSENCE', 'MALADIE'];
private const CONGE_CODES = ['CONGE'];
private const NON_DECOMPTE_CODES = ['AUTRE_ABSENCE', 'JNT'];
public static function normalizeCode(?string $code): string
{
return strtoupper(trim((string) $code));
}
/**
* Un motif inconnu du référentiel est rattaché au temps de travail dès lors qu'il
* décompte le quota : c'est le comportement le moins surprenant pour un motif ajouté
* en base après coup, et il évite qu'un total disparaisse des documents.
*/
public static function isWork(?string $code, bool $countsInQuota = false): bool
{
return self::category($code, $countsInQuota) === self::TRAVAIL;
}
/**
* @return self::TRAVAIL|self::FORMATION|self::CONGE|self::ABSENCE|self::NON_DECOMPTE
*/
public static function category(?string $code, bool $countsInQuota = false): string
{
$normalized = self::normalizeCode($code);
if (in_array($normalized, self::WORK_CODES, true)) {
return self::TRAVAIL;
}
if (in_array($normalized, self::FORMATION_CODES, true)) {
return self::FORMATION;
}
if (in_array($normalized, self::ABSENCE_CODES, true)) {
return self::ABSENCE;
}
if (in_array($normalized, self::CONGE_CODES, true)) {
return self::CONGE;
}
if (in_array($normalized, self::NON_DECOMPTE_CODES, true)) {
return self::NON_DECOMPTE;
}
return $countsInQuota ? self::TRAVAIL : self::NON_DECOMPTE;
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Core\MotifCategories;
use DateInterval; use DateInterval;
use DatePeriod; use DatePeriod;
use DateTimeImmutable; use DateTimeImmutable;
@@ -12,12 +13,8 @@ use RuntimeException;
final class PtaModel extends BaseModel final class PtaModel extends BaseModel
{ {
private const WORK_CODES = [ /** Référentiel partagé avec les PDF : voir App\Core\MotifCategories. */
'TRAVAIL', 'ALP_MATIN', 'RESTAURATION', 'ALP_SOIR', private const WORK_CODES = MotifCategories::WORK_CODES;
'MERCREDI_PERISCOLAIRE', 'EXTRASCOLAIRE',
'PREPARATION_PERISCOLAIRE', 'PREPARATION_EXTRASCOLAIRE',
'PREPARATION_LUNDI', 'MENAGE_FOND', 'JOUR_FRACTIONNEMENT',
];
private const PREPARATION_CODES = [ private const PREPARATION_CODES = [
'PREPARATION_PERISCOLAIRE', 'PREPARATION_EXTRASCOLAIRE', 'PREPARATION_LUNDI', 'PREPARATION_PERISCOLAIRE', 'PREPARATION_EXTRASCOLAIRE', 'PREPARATION_LUNDI',

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Services; namespace App\Services;
use App\Core\MotifCategories;
use DateTimeImmutable; use DateTimeImmutable;
use DateTimeZone; use DateTimeZone;
@@ -66,11 +67,11 @@ final class AgentTimeSummaryPdfService
$rowH = 43.0; $rowH = 43.0;
$rows = [ $rows = [
['TRAVAIL', 'Heures travaillées', $totals['TRAVAIL']], [MotifCategories::TRAVAIL, 'Heures travaillées', $totals['TRAVAIL'], $totals['DETAIL_TRAVAIL']],
['MALADIE', 'Absence', $totals['MALADIE']], [MotifCategories::ABSENCE, 'Absence', $totals['ABSENCE'], []],
['FORMATION', 'Formation', $totals['FORMATION']], [MotifCategories::FORMATION, 'Formation', $totals['FORMATION'], []],
['CONGE', 'Congé', $totals['CONGE']], [MotifCategories::CONGE, 'Congé', $totals['CONGE'], []],
['AUTRE', 'Absence (non décomptée)', $totals['AUTRE']], [MotifCategories::NON_DECOMPTE, 'Absence (non décomptée)', $totals['NON_DECOMPTE'], []],
]; ];
$pdf->fillRect($x, $y, $w, $headerH, 0.92); $pdf->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); $pdf->text($x + 12, $y + 21, 'Répartition du temps', 11, true);
$cursorY = $y + $headerH; $cursorY = $y + $headerH;
foreach ($rows as [$code, $label, $minutes]) { foreach ($rows as [$code, $label, $minutes, $detail]) {
$palette = $this->palette($code); $palette = $this->palette($code);
$pdf->line($x, $cursorY, $x + $w, $cursorY, 0.83); $pdf->line($x, $cursorY, $x + $w, $cursorY, 0.83);
$pdf->fillRectColor($x + 10, $cursorY + 11, 10, 20, $palette['accent']); $pdf->fillRectColor($x + 10, $cursorY + 11, 10, 20, $palette['accent']);
$detailLines = $detail === [] ? [] : $this->detailLines($pdf, $detail, $w - 174);
if ($detailLines === []) {
$pdf->text($x + 32, $cursorY + 25, $label, 9.4, true); $pdf->text($x + 32, $cursorY + 25, $label, 9.4, true);
$pdf->text($x + $w - 130, $cursorY + 25, $this->minutesLabel($minutes), 10, 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; $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<string, int> $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 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); $target = (int) ($quota['quota_cible_minutes'] ?? 0);
$remaining = $target - $counted; $remaining = $target - $counted;
$validated = (int) ($quota['minutes_valides'] ?? 0); $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 private function normalizeTotals(array $rows): array
{ {
$totals = [ $totals = [
'TRAVAIL' => 0, MotifCategories::TRAVAIL => 0,
'MALADIE' => 0, MotifCategories::ABSENCE => 0,
'FORMATION' => 0, MotifCategories::FORMATION => 0,
'CONGE' => 0, MotifCategories::CONGE => 0,
'AUTRE' => 0, MotifCategories::NON_DECOMPTE => 0,
'OTHER_COUNTED' => 0, 'DECOMPTE' => 0,
'DETAIL_TRAVAIL' => [],
]; ];
foreach ($rows as $row) { foreach ($rows as $row) {
$code = strtoupper((string) ($row['motif_code'] ?? 'AUTRE')); $code = MotifCategories::normalizeCode($row['motif_code'] ?? null);
$minutes = (int) ($row['minutes_total'] ?? 0); $minutes = (int) ($row['minutes_total'] ?? 0);
if (array_key_exists($code, $totals) && $code !== 'OTHER_COUNTED') { $counts = (int) ($row['compte_dans_quota'] ?? 0) === 1;
$totals[$code] += $minutes; $category = MotifCategories::category($code, $counts);
continue;
$totals[$category] += $minutes;
if ($counts) {
$totals['DECOMPTE'] += $minutes;
} }
if ((int) ($row['compte_dans_quota'] ?? 0) === 1) {
$totals['OTHER_COUNTED'] += $minutes; if ($category === MotifCategories::TRAVAIL && $minutes > 0) {
} else { $label = trim((string) ($row['motif_libelle'] ?? '')) ?: $code;
$totals['AUTRE'] += $minutes; $totals['DETAIL_TRAVAIL'][$label] = ($totals['DETAIL_TRAVAIL'][$label] ?? 0) + $minutes;
} }
} }
@@ -167,10 +234,10 @@ final class AgentTimeSummaryPdfService
private function palette(string $code): array private function palette(string $code): array
{ {
return match (strtoupper($code)) { return match (strtoupper($code)) {
'TRAVAIL' => ['accent' => '#2563EB'], MotifCategories::TRAVAIL => ['accent' => '#2563EB'],
'FORMATION' => ['accent' => '#7C3AED'], MotifCategories::FORMATION => ['accent' => '#7C3AED'],
'CONGE' => ['accent' => '#059669'], MotifCategories::CONGE => ['accent' => '#059669'],
'MALADIE' => ['accent' => '#DC2626'], MotifCategories::ABSENCE, 'MALADIE' => ['accent' => '#DC2626'],
default => ['accent' => '#64748B'], default => ['accent' => '#64748B'],
}; };
} }

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Services; namespace App\Services;
use App\Core\MotifCategories;
use DateTimeImmutable; use DateTimeImmutable;
final class PlanningPdfService final class PlanningPdfService
@@ -170,7 +171,7 @@ final class PlanningPdfService
$blockY = $y + 5; $blockY = $y + 5;
foreach ($blocks as $block) { foreach ($blocks as $block) {
$entry = $block['entry']; $entry = $block['entry'];
$palette = $this->motifPalette((string) ($entry['motif_code'] ?? 'AUTRE')); $palette = $this->entryPalette($entry);
$blockHeight = (float) $block['height']; $blockHeight = (float) $block['height'];
$pdf->fillRectColor($cellX + 4, $blockY, $dayWidth - 8, $blockHeight, $palette['background']); $pdf->fillRectColor($cellX + 4, $blockY, $dayWidth - 8, $blockHeight, $palette['background']);
$pdf->fillRectColor($cellX + 4, $blockY, 3.0, $blockHeight, $palette['accent']); $pdf->fillRectColor($cellX + 4, $blockY, 3.0, $blockHeight, $palette['accent']);
@@ -269,7 +270,7 @@ final class PlanningPdfService
break; 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, $colWidth - 8, $cardHeight, $palette['background']);
$pdf->fillRectColor($colX + 4, $cursorY, 3.0, $cardHeight, $palette['accent']); $pdf->fillRectColor($colX + 4, $cursorY, 3.0, $cardHeight, $palette['accent']);
@@ -320,21 +321,35 @@ final class PlanningPdfService
return $pdf->wrap($first, $width, 7.0); 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 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'); : (string) ($entry['motif_libelle'] ?? 'Affectation');
} }
/** @return array{accent:string,background:string,label:string} */ /** @return array{accent:string,background:string,label:string} */
private function motifPalette(string $code): array private function entryPalette(array $entry): array
{ {
return match (strtoupper($code)) { return $this->motifPalette(
'TRAVAIL' => ['accent' => '#2563EB', 'background' => '#EFF6FF', 'label' => 'Travail'], (string) ($entry['motif_code'] ?? 'AUTRE'),
'FORMATION' => ['accent' => '#7C3AED', 'background' => '#F5F3FF', 'label' => 'Formation'], (int) ($entry['compte_dans_quota'] ?? 0) === 1
'CONGE' => ['accent' => '#059669', 'background' => '#ECFDF5', 'label' => 'Conge'], );
'MALADIE' => ['accent' => '#DC2626', 'background' => '#FEF2F2', 'label' => 'Absence'], }
/** @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'], 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 private function drawLegend(SimplePdf $pdf, float $x, float $y): void
{ {
$items = [ $items = [
['TRAVAIL', 'Travail'], ['TRAVAIL', 'Travail (dont ALP, restauration, preparation)'],
['FORMATION', 'Formation'], ['FORMATION', 'Formation'],
['CONGE', 'Conge'], ['CONGE', 'Conge'],
['MALADIE', 'Absence'], ['ABSENCE', 'Absence'],
['AUTRE', 'Autre absence'], ['JNT', 'Autre absence'],
]; ];
foreach ($items as [$code, $label]) { foreach ($items as [$code, $label]) {
$palette = $this->motifPalette($code); $palette = $this->motifPalette($code);
$pdf->fillRectColor($x, $y - 7, 8, 8, $palette['accent']); $pdf->fillRectColor($x, $y - 7, 8, 8, $palette['accent']);
$pdf->text($x + 12, $y, $label, 6.5); $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;
} }
} }

View File

@@ -28,10 +28,10 @@ window.PTA_PAGE_CONFIG = <?= json_encode([
'params' => $pageConfig, 'params' => $pageConfig,
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>; ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>;
</script> </script>
<script src="assets/js/core.js?v=7.9"></script> <script src="assets/js/core.js?v=7.10"></script>
<?php foreach ($scripts as $script): ?> <?php foreach ($scripts as $script): ?>
<script src="assets/js/<?= htmlspecialchars($script) ?>?v=7.9"></script> <script src="assets/js/<?= htmlspecialchars($script) ?>?v=7.10"></script>
<?php endforeach; ?> <?php endforeach; ?>
<script src="assets/js/page-bootstrap.js?v=7.9"></script> <script src="assets/js/page-bootstrap.js?v=7.10"></script>
</body> </body>
</html> </html>

View File

@@ -67,7 +67,7 @@ $showGroupLabels = $visiblePageCount > 4;
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= htmlspecialchars($pageTitle) ?> — PTA</title> <title><?= htmlspecialchars($pageTitle) ?> — PTA</title>
<link rel="stylesheet" href="assets/style.css?v=7.9"> <link rel="stylesheet" href="assets/style.css?v=7.10">
</head> </head>
<body> <body>
<main class="app-shell"> <main class="app-shell">

View File

@@ -12,7 +12,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Choisir votre accès — PTA</title> <title>Choisir votre accès — PTA</title>
<link rel="stylesheet" href="assets/style.css?v=7.9"> <link rel="stylesheet" href="assets/style.css?v=7.10">
</head> </head>
<body class="role-page-body"> <body class="role-page-body">
<main class="role-page-shell"> <main class="role-page-shell">

View File

@@ -110,6 +110,19 @@
updateAgentPdfButton(); 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) { function motifOptionsHtml(selectedMotifId = null) {
return (window.PTA_CONFIG?.motifs || []) return (window.PTA_CONFIG?.motifs || [])
.map((motif) => { .map((motif) => {
@@ -122,13 +135,16 @@
function timeRangeHtml(dayLabel, index = 0, selectedMotifId = null) { function timeRangeHtml(dayLabel, index = 0, selectedMotifId = null) {
const suffix = `${dayLabel} plage ${index + 1}`; const suffix = `${dayLabel} plage ${index + 1}`;
const fallbackMotifId = defaultMotifId();
const motifId = selectedMotifId ?? fallbackMotifId;
const placeholder = fallbackMotifId === null ? '<option value="">Choisir un motif</option>' : '';
return ` return `
<div class="weekly-time-range"> <div class="weekly-time-range">
<label class="weekly-range-motif-field"> <label class="weekly-range-motif-field">
<span>Motif</span> <span>Motif</span>
<select class="weekly-range-motif" aria-label="Motif ${escapeHtml(suffix)}"> <select class="weekly-range-motif" aria-label="Motif ${escapeHtml(suffix)}">
<option value="">Choisir un motif</option> ${placeholder}
${motifOptionsHtml(selectedMotifId)} ${motifOptionsHtml(motifId)}
</select> </select>
</label> </label>
<label> <label>