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 `