V7.10 — Tableau de bord, guide, semaine type et enveloppe de formation
Reprise dans le répertoire de production du travail mené dans le worktree ux-service-enfance : - tableau de bord Service Enfance (dashboard.php) et navigation regroupée ; - guide d'utilisation (faq.php) accessible depuis toute la navigation ; - éditeur de semaine type mis en avant sur l'écran de saisie ; - droit à l'enveloppe de formation par agent, en remplacement de la case « formation déjà prévue dans la répartition annuelle » ; - correctif de « [object Object] » sur les cartes de quota du PTA ; - motif de travail par défaut à la saisie, heures travaillées complètes dans les PDF. Le docker-compose.yml de production est conservé : celui du worktree décrit une pile de test (conteneurs et volume suffixés « test », ports 9080/9081), qui aurait fait redémarrer l'application sur un volume MariaDB vide. Les migrations database/migration_v7_9*.sql sont obligatoires ; le service db-migrate les applique au déploiement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Core\MotifCategories;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
|
||||
@@ -66,11 +67,11 @@ final class AgentTimeSummaryPdfService
|
||||
$rowH = 43.0;
|
||||
|
||||
$rows = [
|
||||
['TRAVAIL', 'Heures travaillées', $totals['TRAVAIL']],
|
||||
['MALADIE', 'Absence', $totals['MALADIE']],
|
||||
['FORMATION', 'Formation', $totals['FORMATION']],
|
||||
['CONGE', 'Congé', $totals['CONGE']],
|
||||
['AUTRE', 'Absence (non décomptée)', $totals['AUTRE']],
|
||||
[MotifCategories::TRAVAIL, 'Heures travaillées', $totals['TRAVAIL'], $totals['DETAIL_TRAVAIL']],
|
||||
[MotifCategories::ABSENCE, 'Absence', $totals['ABSENCE'], []],
|
||||
[MotifCategories::FORMATION, 'Formation', $totals['FORMATION'], []],
|
||||
[MotifCategories::CONGE, 'Congé', $totals['CONGE'], []],
|
||||
[MotifCategories::NON_DECOMPTE, 'Absence (non décomptée)', $totals['NON_DECOMPTE'], []],
|
||||
];
|
||||
|
||||
$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);
|
||||
|
||||
$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<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
|
||||
{
|
||||
$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'],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user