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:
@@ -62,16 +62,175 @@ final class WeekTemplateModel extends BaseModel
|
||||
|
||||
public function saveFromWeek(int $agentId, int $year, int $weekNumber, string $name, bool $replaceExisting = false): array
|
||||
{
|
||||
$name = trim(preg_replace('/\s+/u', ' ', $name) ?? '');
|
||||
if ($name === '' || strlen($name) > 240) {
|
||||
throw new DomainException('Le nom de la semaine type doit contenir entre 1 et 120 caractères.');
|
||||
}
|
||||
// La colonne `nom` fait 120 caractères : la borne annoncée à
|
||||
// l'utilisateur et celle réellement appliquée doivent coïncider.
|
||||
$name = $this->normalizeName($name);
|
||||
|
||||
$sourceEntries = $this->sourceWeekEntries($agentId, $year, $weekNumber);
|
||||
if (!$sourceEntries) {
|
||||
throw new DomainException('La semaine sélectionnée ne contient aucune affectation à enregistrer comme modèle.');
|
||||
}
|
||||
|
||||
return $this->persistTemplate($agentId, $name, $sourceEntries, $replaceExisting);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre une semaine type composée directement dans l'éditeur, sans
|
||||
* partir d'une semaine réellement planifiée.
|
||||
*
|
||||
* Les plages arrivent du navigateur : elles sont intégralement revalidées
|
||||
* ici, y compris les droits de motif, car l'écran n'est qu'une commodité —
|
||||
* le serveur reste seul juge de ce qui entre en base.
|
||||
*
|
||||
* @param array<int, mixed> $rawEntries
|
||||
* @param int[] $allowedMotifIds motifs affectables à cet agent
|
||||
* @param int[] $allowedStructureIds lieux actifs visibles par l'utilisateur
|
||||
*/
|
||||
public function saveFromEntries(
|
||||
int $agentId,
|
||||
string $name,
|
||||
array $rawEntries,
|
||||
array $allowedMotifIds,
|
||||
array $allowedStructureIds,
|
||||
bool $replaceExisting = false
|
||||
): array {
|
||||
$name = $this->normalizeName($name);
|
||||
$entries = $this->normalizeEditorEntries($rawEntries, $allowedMotifIds, $allowedStructureIds);
|
||||
|
||||
if ($entries === []) {
|
||||
throw new DomainException('Une semaine type doit contenir au moins une plage horaire.');
|
||||
}
|
||||
|
||||
return $this->persistTemplate($agentId, $name, $entries, $replaceExisting);
|
||||
}
|
||||
|
||||
private function normalizeName(string $name): string
|
||||
{
|
||||
$name = trim(preg_replace('/\s+/u', ' ', $name) ?? '');
|
||||
if ($name === '' || mb_strlen($name) > 120) {
|
||||
throw new DomainException('Le nom de la semaine type doit contenir entre 1 et 120 caractères.');
|
||||
}
|
||||
return $name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $rawEntries
|
||||
* @param int[] $allowedMotifIds
|
||||
* @param int[] $allowedStructureIds
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function normalizeEditorEntries(array $rawEntries, array $allowedMotifIds, array $allowedStructureIds): array
|
||||
{
|
||||
$jours = [1 => 'lundi', 2 => 'mardi', 3 => 'mercredi', 4 => 'jeudi', 5 => 'vendredi'];
|
||||
$entries = [];
|
||||
|
||||
foreach ($rawEntries as $rawEntry) {
|
||||
if (!is_array($rawEntry)) {
|
||||
throw new DomainException('Une plage de la semaine type est illisible.');
|
||||
}
|
||||
|
||||
$weekday = filter_var($rawEntry['jour_semaine'] ?? null, FILTER_VALIDATE_INT, [
|
||||
'options' => ['min_range' => 1, 'max_range' => 5],
|
||||
]);
|
||||
if ($weekday === false) {
|
||||
throw new DomainException('Une semaine type ne peut contenir que des jours du lundi au vendredi.');
|
||||
}
|
||||
|
||||
$start = $this->normalizeQuarterHour($rawEntry['heure_debut'] ?? null, $jours[$weekday]);
|
||||
$end = $this->normalizeQuarterHour($rawEntry['heure_fin'] ?? null, $jours[$weekday]);
|
||||
if ($end <= $start) {
|
||||
throw new DomainException(sprintf(
|
||||
'Le %s, la fin (%s) doit être postérieure au début (%s).',
|
||||
$jours[$weekday],
|
||||
substr($end, 0, 5),
|
||||
substr($start, 0, 5)
|
||||
));
|
||||
}
|
||||
|
||||
$structureId = filter_var($rawEntry['id_structure'] ?? null, FILTER_VALIDATE_INT, [
|
||||
'options' => ['min_range' => 1],
|
||||
]);
|
||||
if ($structureId === false || !in_array((int) $structureId, $allowedStructureIds, true)) {
|
||||
throw new DomainException(sprintf('Le lieu d’affectation choisi le %s n’est pas disponible.', $jours[$weekday]));
|
||||
}
|
||||
|
||||
$motifId = filter_var($rawEntry['id_motif'] ?? null, FILTER_VALIDATE_INT, [
|
||||
'options' => ['min_range' => 1],
|
||||
]);
|
||||
if ($motifId === false || !in_array((int) $motifId, $allowedMotifIds, true)) {
|
||||
throw new DomainException(sprintf(
|
||||
'Le motif choisi le %s n’est pas affectable à cet agent.',
|
||||
$jours[$weekday]
|
||||
));
|
||||
}
|
||||
|
||||
$entries[] = [
|
||||
'jour_semaine' => (int) $weekday,
|
||||
'id_structure' => (int) $structureId,
|
||||
'id_motif' => (int) $motifId,
|
||||
'heure_debut' => $start,
|
||||
'heure_fin' => $end,
|
||||
];
|
||||
}
|
||||
|
||||
$this->assertEditorEntriesNoOverlap($entries, $jours);
|
||||
|
||||
usort($entries, static function (array $a, array $b): int {
|
||||
return [$a['jour_semaine'], $a['heure_debut']] <=> [$b['jour_semaine'], $b['heure_debut']];
|
||||
});
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
private function normalizeQuarterHour(mixed $value, string $dayLabel): string
|
||||
{
|
||||
$time = trim((string) $value);
|
||||
if (preg_match('/^([01]\d|2[0-3]):(00|15|30|45)(:00)?$/', $time) !== 1) {
|
||||
throw new DomainException(sprintf(
|
||||
'Le %s, les horaires doivent être renseignés par tranches de 15 minutes.',
|
||||
$dayLabel
|
||||
));
|
||||
}
|
||||
return substr($time, 0, 5) . ':00';
|
||||
}
|
||||
|
||||
/**
|
||||
* Deux plages du même jour ne peuvent pas se chevaucher, même sur deux
|
||||
* lieux différents : un agent n'est jamais à deux endroits à la fois.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $entries
|
||||
* @param array<int, string> $jours
|
||||
*/
|
||||
private function assertEditorEntriesNoOverlap(array $entries, array $jours): void
|
||||
{
|
||||
for ($i = 0, $count = count($entries); $i < $count; $i++) {
|
||||
for ($j = $i + 1; $j < $count; $j++) {
|
||||
$first = $entries[$i];
|
||||
$second = $entries[$j];
|
||||
if ($first['jour_semaine'] === $second['jour_semaine']
|
||||
&& $first['heure_debut'] < $second['heure_fin']
|
||||
&& $first['heure_fin'] > $second['heure_debut']) {
|
||||
throw new DomainException(sprintf(
|
||||
'Le %s, deux plages se chevauchent : %s-%s et %s-%s.',
|
||||
$jours[$first['jour_semaine']] ?? 'jour',
|
||||
substr($first['heure_debut'], 0, 5),
|
||||
substr($first['heure_fin'], 0, 5),
|
||||
substr($second['heure_debut'], 0, 5),
|
||||
substr($second['heure_fin'], 0, 5)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Écriture du modèle et de ses plages, quelle qu'en soit l'origine :
|
||||
* une semaine réellement planifiée ou l'éditeur de semaine type.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
*/
|
||||
private function persistTemplate(int $agentId, string $name, array $rows, bool $replaceExisting): array
|
||||
{
|
||||
try {
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
@@ -106,7 +265,7 @@ final class WeekTemplateModel extends BaseModel
|
||||
(:template_id, :weekday, :structure_id, :motif_id, :start, :end)"
|
||||
);
|
||||
|
||||
foreach ($sourceEntries as $entry) {
|
||||
foreach ($rows as $entry) {
|
||||
$insertEntry->execute([
|
||||
'template_id' => $templateId,
|
||||
'weekday' => (int) $entry['jour_semaine'],
|
||||
@@ -121,7 +280,7 @@ final class WeekTemplateModel extends BaseModel
|
||||
return [
|
||||
'id_modele' => $templateId,
|
||||
'nom' => $name,
|
||||
'nombre_creneaux' => count($sourceEntries),
|
||||
'nombre_creneaux' => count($rows),
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
if ($this->pdo->inTransaction()) {
|
||||
|
||||
Reference in New Issue
Block a user