Skip to content

Instantly share code, notes, and snippets.

@julp
Last active July 4, 2022 17:42
Show Gist options
  • Save julp/9f12f1f55099c7ef0c69974ef4bc8516 to your computer and use it in GitHub Desktop.
Save julp/9f12f1f55099c7ef0c69974ef4bc8516 to your computer and use it in GitHub Desktop.
Gestion de planning/rendez-vous/créneaux horaires

Gestion de planning/rendez-vous/créneaux horaires

Détail de l'approche

Mon approche consiste à pré-générer dans une table la liste de tous les créneaux (table slots) possibles. C'est beaucoup plus efficace et facile ensuite à exploiter car chercher un créneau disponible est une simple requête SQL (jointure LEFT JOIN). Cette pré-génération doit être exécutée en cron (ou au moins en CLI), ce n'est pas grave si elle devait mettre une heure à s'exécuter, surtout que c'est une tâche qui est faite d'avance, l'important c'est que l'application ne soit pas lente pour vos clients à trouver un créneau disponible (sinon elle risque de tomber à la moindre charge - utilisateurs simultanés). Imaginez, sans ce prémâchage sur lequel se baser, comment trouver un rendez-vous disponible ? En tenant compte des jours + horaires d'ouverture ainsi que des jours fériés : ça demanderait beaucoup de calculs et possiblement beaucoup d'allers/retours PHP/base de données pour chaque recherche :/

Avantages de ces slots précalculés :

  • trouver un créneau libre est très rapide (tant que la base de données est correctement indexée)
  • dynamicité et souplesse : vous devez exceptionnellement fermer un jour, une heure ou peu importe, il est aisé de désallouer (supprimer) les slots concernés pour qu'ils ne soient définitivement plus disponibles. Inversement pour une ouverture exceptionnelle qui ne requerra qu'une création de ces slots
  • portable : ça fonctionne même avec MySQL qui ne dispose malheureusement pas de fonction équivalente à generate_series de PostgreSQL

Note : le script pour générer ces slots peut être exécuté à intervalle régulier. Il n'est pas requis de l'exécuter pour un an voir plus. Il peut parfaitement l'être tous les mois pour une période de 3 mois si les rendez-vous n'ont pas à être pris plus tôt à l'avance.

Exemple d'exécution du script pour des slots commençant au 1^er mai 2021 (option --debut) pour une période de 3 mois (option --fin) (au-delà, il faudra le réexécuter), des créneaux de 30 minutes (option --duree) et des horaires d'ouverture tels que :

  • Lundi de 14H à 18H
  • Mardi de 9H30 à 12H
  • Mardi de 14H à 18H
  • Jeudi de 9H à 12H
  • Jeudi de 14H à 18H
php slots_generation.php -d --debut=2021-05-01 --fin="3 months" --duree="30 minutes" --lundi=14-18 --mardi=9:30-12 --mardi=14-18 --jeudi=9-12 --jeudi=14-18

FAQ

N'est-ce pas lourd car cela va représenter beaucoup de lignes ?

Disons qu'une personne travaille 40 heures par semaine, avec des slots de 5 minutes seulement et en considérant qu'il y a 53 semaines par an, cela représente 40 * (60 / 5) * 53 = 25440 slots et lignes par personne (ou ressource) par an, ce qui est loin d'être énorme pour un SGBD. S'il n'est pas nécessaire de conserver les données des rendez-vous passés, ils peuvent être supprimés sinon éventuellement être archivés (déplacés vers une table annexe) au fur et à mesure (par une tâche périodique/cron par exemple). Le tout, pour avoir de bonnes performances, c'est que les colonnes, à commencer par slots.debut, soient bien indexées.

Comment gérer des prestations de durées différentes ?

Il faudrait générer des slots d'au moins le plus grand facteur commun de celles-ci (exemple : 20 minutes et 30 minutes = 10 minutes) sinon moins (5 minutes) et alors attribuer autant de slots consécutifs que nécessaires à la réalisation de la prestation désirée.

Comment gérer plusieurs personnes (ou ressources) qui n'ont pas les mêmes horaires ?

Il faudra générer les slots pour chacune d'elles en ajoutant une clé étrangère vers cette ressource dans la table slots (et ne pas oublier de l'intégrer aux contraintes uniques - UNIQUE KEY). Pour des personnes, vous n'avez alors plus des horaires d'ouverture globaux mais individuel de travail.

Pourquoi une relation bidirectionnelle entre créneaux (slots) et rendez-vous (appointments) ?

On aurait pu se contenter d'une relation unidirectionnelle créneaux vers rendez-vous (en d'autres termes, une clé étrangère dans la table slots vers le rendez-vous le pourvoyant - sinon NULL si le slot est libre). Toutefois l'avantage d'avoir une relation (facultative) inverse, c'est-à-dire associer le rendez-vous à un créneau permet de retrouver les rendez-vous à réassigner, ceux que vous auriez explicitement mis de côté en assignant NULL comme slot ou suite à la suppression d'un créneau qui était utilisé (via l'attribut ON DELETE SET NULL de la FK). Ainsi, par la requête :

SELECT *
    FROM appointments
    LEFT JOIN appointments_slots
        ON appointments.id = appointments_slots.appointment_id
    WHERE appointments_slots.slot_id IS NULL
;

Vous retrouvez tous les rendez-vous qui sont en attente ou à reporter.

Notes importantes

  • Requiert PHP >= 8.1.0
  • Prévu pour MySQL (écrit et testé sur une version 8)
  • Ce n'est qu'une base, il est impossible de faire quelque chose qui corresponde aux besoins de chacun, vous aurez sans doute des choses à ajouter et adapter (des tables, des clés étrangères, modifier les contraintes, prendre en charge une session PHP pour savoir à qui ça correspond, etc)
  • C'est le fruit de ma propre réflexion, c'est une approche possible (avec PostgreSQL, il aurait sans doute possible de faire mieux à certains égards et possiblement autrement) et si vous voulez en discuter vous savez où et comment me trouver

Aperçu

Ci-dessous un rendu sur la semaine du 10 au 16 mai 2021 suite à la commande :

php slots_generation.php -d --debut=2021-05-01 --fin="3 months" --duree="30 minutes" --lundi=14-18 --mardi=9:30-12 --mardi=14-18 --jeudi=9-12 --jeudi=14-18 --vendredi=9-12 --vendredi=14-17

Soit, pour horaires :

  • lundi : 14H00 - 18H00
  • mardi :
    • 09H30 - 12H00
    • 14H00 - 18H00
  • jeudi (qui n'apparaît pas car le 13/05/2021 était férié) :
    • 09H00 - 12H00
    • 14H00 - 18H00
  • vendredi :
    • 09H00 - 12H00
    • 14H00 - 17H00

rdv

Avec :

  • quelques RDV de tests en vert (2 d'un client "xxx" et 1 d'un client "test")
  • en violet, le créneau actuel (j'ai artificiellement bloqué now à "2021-05-11 11:38:00")
  • en blanc, tous les créneaux libres

Et le formulaire (sommaire) de réservation :

rdv2

<?php
declare(strict_types=1);
require __DIR__ . '/shared.php';
?>
<style type="text/css">
.track-slot {
font-weight: bold;
}
.time-slot {
border: 1px solid black;
/*
box-shadow:
rgba(255,255,255,.6) 1px 1px 0,
rgba(0,0,0,.3) 4px 4px 0;
*/
color: #fff;
background-color: #687f00;
//background-color: #1259B2;
//background-color: #c35500;
}
.time-slot.now {
background-color: #544D69 !important;
}
.time-slot.free {
color: #000;
box-shadow: none;
background-color: transparent;
}
.time-slot > label, .time-slot-content {
height: 100%;
width: 100%;
}
.time-slot input[type="checkbox"] {
display: none;
}
.time-slot input[type="checkbox"]:checked + .time-slot-content {
background-color: #1259B2;
}
.schedule {
display: grid;
grid-gap: 1em;
margin-top: 4rem;
grid-template-rows: repeat(7, 1fr);
}
/*
.schedule {
grid-template-rows: repeat(<?= $rows ?>, auto);
grid-template-columns: repeat(7, 1fr);
}
*/
</style>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
<?php
$now = new DateTimeImmutable;
if (isset($_GET['from'])) {
$from = new DateTimeImmutable($_GET['from']); // TODO: vérifier sa validité ?
} else {
$from = beginning_of_week($now);
#$to = end_of_week($now);
}
$to = $from->add(DateInterval::createFromDateString('6 days'));
function beginning_of_week(DateTimeInterface $dt): DateTimeInterface {
return $dt->sub(DateInterval::createFromDateString((intval($dt->format('N')) - 1) . ' days'));
}
/*
function end_of_week(DateTimeInterface $dt): DateTimeInterface {
return $dt->add(DateInterval::createFromDateString((7 - intval($dt->format('N'))) . ' days'));
}
*/
function add_days(DateTimeInterface $dt, int $days): DateTimeInterface {
return $dt->add(DateInterval::createFromDateString($days . ' days'));
}
function overlaps(DateTimeInterface $now, DateTimeInterface $debut, DateTimeInterface $fin): bool {
return $now >= $debut && $now <= $fin;
}
function to_time(DateTimeInterface $dt): string {
return $dt->format('H:i');
}
function to_date(DateTimeInterface $dt): string {
return $dt->format('d/m/Y');
}
$stmt = $bdd->prepare(<<<EOS
/*SELECT
t.*,
TIMEDIFF(max, min) AS diff,
TIME_TO_SEC(TIMEDIFF(max, min)) DIV TIME_TO_SEC(TIMEDIFF('09:30:00', '09:00:00')) AS x
FROM (*/
SELECT
MIN(TIME(debut)) AS min,
MAX(TIME(fin)) AS max
FROM slots
WHERE
TRUE
AND debut >= :from
AND debut <= :to
/*) t*/
EOS
);
$stmt->execute(
[
'to' => $to->format('Y-m-d H:i:s'),
'from' => $from->format('Y-m-d H:i:s'),
]
);
$stats = $stmt->fetch(PDO::FETCH_ASSOC);
$stmt = $bdd->prepare(<<<EOS
SELECT
DATE(debut) AS date,
slots.*,
appointments.*,
slots.id AS slot_id,
appointments.id AS appointment_id,
TIME_TO_SEC(TIMEDIFF(TIME(debut), :min)) DIV TIME_TO_SEC(TIMEDIFF(fin, debut)) AS row
FROM slots
LEFT JOIN appointments_slots ON slots.id = appointments_slots.slot_id
LEFT JOIN appointments ON appointments_slots.appointment_id = appointments.id
WHERE
TRUE
AND debut >= :from
AND debut <= :to
ORDER BY debut
EOS
);
$stmt->execute(
[
'min' => $stats['min'],
'to' => $to->format('Y-m-d H:i:s'),
'from' => $from->format('Y-m-d H:i:s'),
]
);
$slots = $stmt->fetchAll(PDO::FETCH_GROUP | PDO::FETCH_ASSOC);
?>
<div class="text-center m-2">
<a href="?from=<?= add_days($from, -7)->format('Y-m-d') ?>" class="btn btn-primary">‹</a>
Du <?= to_date($from) ?> au <?= to_date($to) ?>
<a href="?from=<?= add_days($from, 7)->format('Y-m-d') ?>" class="btn btn-primary">›</a>
</div>
<p>
<a href="reserve.php">Prendre un RDV</a>
</p>
<div class="schedule">
<?php
# NOTE: itérer sur DatePeriod exclut la dernière valeur donc on rajoute 1 jour pour ne pas perdre dimanche
foreach(new DatePeriod($from, DateInterval::createFromDateString('1 day'), $to->add(DateInterval::createFromDateString('1 day'))) as $dt):
?>
<?php $date = $dt->format('Y-m-d') ?>
<?php $dow = day_of_week($dt) + 1 ?>
<?php $column = $dow + 1 ?>
<div class="track-slot" style="grid-column: <?= $column ?> / <?= $column + 1 ?> ; grid-row: 1 / 2">
<?= JOURS[$dow - 1] ?>
<br>
<?= $date ?>
</div>
<?php foreach(($slots[$date] ?? []) as $slot): ?>
<?php $slot['debut'] = new DateTimeImmutable($slot['debut']) ?>
<?php $slot['fin'] = new DateTimeImmutable($slot['fin']) ?>
<? $row = $slot['row'] + 2 ?>
<div
class="time-slot <?= is_null($slot['appointment_id']) ? "free" : '' ?> <?= overlaps($now, $slot['debut'], $slot['fin']) ? 'now' : '' ?>"
style="grid-column: <?= $column ?> / <?= $column + 1 ?> ; grid-row: <?= $row ?> / <?= $row + 1 ?>"
>
<label>
<input type="checkbox" name="slots[]" value="<?= $slot['slot_id'] ?>">
<div class="time-slot-content">
<?= to_time($slot['debut']) ?> - <?= to_time($slot['fin']) ?>
<br>
<?php if (!is_null($slot['nom_client'])): ?>
<?= htmlspecialchars($slot['nom_client']) ?>
<?php else: ?>
-
<?php endif ?>
</div>
</label>
</div>
<?php endforeach ?>
<?php endforeach ?>
</div>
<?php
declare(strict_types=1);
require __DIR__ . '/shared.php';
$erreurs = [];
if (isset($_POST['reserve'])) {
if (empty($_POST['nom'])) {
$erreurs['nom'] = "Merci de renseigner votre nom";
}
if (!$erreurs) {
try {
$bdd->beginTransaction();
$stmt = $bdd->prepare('INSERT INTO appointments(nom_client) VALUES(:nom_client)');
$stmt->execute(
[
'nom_client' => $_POST['nom'],
]
);
$stmt = $bdd->prepare('INSERT INTO appointments_slots(appointment_id, slot_id) VALUES(LAST_INSERT_ID(), :slot_id)');
$stmt->execute(
[
'slot_id' => $_POST['slot'],
]
);
$bdd->commit();
//$_SESSION['flash']['info'] = "Rendez-vous enregistré";
header('location: planning.php');
exit;
} catch (PDOException $e) {
if (23000 == $e->getCode()) {
$erreurs['slot'] = "Désolé, ce créneau a été pourvu entre temps, veuillez en choisir un autre";
} else {
throw $e;
}
}
}
}
$conditions = ['slot_id IS NULL'];
if (isset($_POST['affine'])) {
if (isset($_POST['dow']) && is_array($_POST['dow'])) {
$conditions[] = 'WEEKDAY(debut) IN(' . implode(', ', array_map('intval', $_POST['dow'])) . ')';
}
if (isset($_POST['from'])) { # TODO: un minimum de validation
$conditions[] = 'DATE(debut) > "' . date_create($_POST['from'])->format('Y-m-d') . '"';
} else {
$conditions[] = 'debut > NOW()';
}
} else {
$conditions[] = 'debut > NOW()';
}
$stmt = $bdd->query('SELECT slots.* FROM slots LEFT JOIN appointments_slots ON slots.id = appointments_slots.slot_id WHERE ' . implode(' AND ', $conditions) . ' LIMIT 50');
$slots = $stmt->fetchAll(PDO::FETCH_ASSOC);
?>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
<?php if ($erreurs): ?>
<ul>
<?php foreach($erreurs as $erreur): ?>
<li>
<?= $erreur ?>
</li>
<?php endforeach ?>
</ul>
<?php endif ?>
<div>
<p>Affiner la recherche :</p>
<form method="POST">
<div>
<label>
A partir du : <input type="date" name="from" value="<?= isset($_POST['from']) ? htmlspecialchars(date_create($_POST['from'])->format('Y-m-d')) : '' ?>">
</label>
</div>
<div>
<?php foreach (JOURS as $dow => $name): ?>
<label>
<input type="checkbox" name="dow[]" value="<?= $dow ?>" <?= isset($_POST['dow']) && is_array($_POST['dow']) && in_array($dow, $_POST['dow']) ? 'checked' : '' ?>> <?= htmlspecialchars($name) ?>
</label>
<?php endforeach ?>
</div>
<input type="submit" name="affine" value="Affiner la recherche">
</form>
</div>
<?php if ($slots): ?>
<div>
<p>Effectuer votre réservation :</p>
<form method="POST">
<div>
<label>
Nom : <input type="text" name="nom">
</label>
</div>
<div>
<label>
Créneau :
<select name="slot">
<?php foreach($slots as $slot): ?>
<?php $slot['debut'] = new DateTimeImmutable($slot['debut']) ?>
<?php $slot['fin'] = new DateTimeImmutable($slot['fin']) ?>
<option value="<?= $slot['id'] ?>" <?= isset($_POST['slot']) && $_POST['slot'] == $slot['id'] ? 'selected' : '' ?>>
<?= htmlspecialchars($slot['debut']->format('d/m/Y H:i')) ?> - <?= htmlspecialchars($slot['fin']->format('H:i')) ?>
</option>
<?php endforeach ?>
</select>
</label>
</div>
<div>
<a href="planning.php" class=btn">Annuler</a>
<input type="submit" name="reserve" value="Réserver ce créneau">
</div>
</form>
</div>
<?php else: ?>
<p>Désolé, aucune disponibilité actuellement</p>
<?php endif ?>
<?php
declare(strict_types=1);
$bdd = new PDO('mysql:host=localhost;dbname=***;charset=utf8', '***', '***');
$bdd->setAttribute(PDO::ATTR_EMULATE_PREPARES, FALSE);
$bdd->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
const JOURS = [
'lundi',
'mardi',
'mercredi',
'jeudi',
'vendredi',
'samedi',
'dimanche',
];
function add_days(DateTimeInterface $dt, int $days): DateTimeInterface {
return $dt->add(DateInterval::createFromDateString($days . ' days'));
}
function day_of_week(DateTimeInterface $dt): int {
return intval($dt->format('N')) - 1;
}
function parse_date(string $string): DateTimeImmutable|false {
if (preg_match("#^(?P<y>[0-9]{4})(?P<s>[/-])(?P<m>[0-9]{2})(?P=s)(?P<d>[0-9]{2})$#", $string, $m) && checkdate((int) $m['m'], (int) $m['d'], (int) $m['y'])) {
return new DateTimeImmutable($string);
} else {
return false;
}
}
/* ===== <gestion des jours fériés> ===== */
// jours fériés fixes au format mois-jour sans 0
const JOURS_FERIES_FIXES = [
# Nouvel an
'1-1',
# 1er mai
'5-1',
# 8 mai
'5-8',
# 14 juillet
'7-14',
# 15 août (assomption)
'8-15',
# Toussaint
'11-1',
# 11 novembre
'11-11',
# Noël
'12-25',
];
function dimanche_de_paques(int $y): DateTimeImmutable {
$n = $y % 19;
$c = intdiv($y, 100);
$u = $y % 100;
$s = intdiv($c, 4);
$t = $c % 4;
$p = intdiv($c + 8, 25);
$q = intdiv($c - $p + 1, 3);
$e = (19 * $n + $c - $s - $q + 15) % 30;
$b = intdiv($u, 4);
$d = $u % 4;
$l = (2 * $t + 2 * $b - $e - $d + 32) % 7;
$h = intdiv($n + 11 * $e + 22 * $l, 451);
$m = intdiv($e + $l - 7 * $h + 114, 31);
$j = ($e + $l - 7 * $h + 114) % 31;
return new DateTimeImmutable(implode('-', [$y, $m, $j + 1]));
}
const FORMAT_COMPARAISON_DATE = 'n-j';
function ferie(DateTimeInterface $dt): bool {
static $cache = [];
$nj = $dt->format(FORMAT_COMPARAISON_DATE);
if (in_array($nj, JOURS_FERIES_FIXES, true)) {
return true;
}
$annee = (int) $dt->format('Y');
if (!array_key_exists($annee, $cache)) {
$paques = dimanche_de_paques($annee);
$non_fixes = [
$paques,
# Lundi de Pâques
add_days($paques, 1),
# Jeudi de l'Ascension (39 jours après Pâques)
add_days($paques, 39),
# Dimanche et Lundi de la Pentecôte (49 et 50 jours après Pâques)
add_days($paques, 49),
add_days($paques, 50),
];
$cache[$annee] = array_map(fn ($v) => $v->format(FORMAT_COMPARAISON_DATE), $non_fixes);
}
return in_array($nj, $cache[$annee]);
}
/* ===== </gestion des jours fériés> ===== */
/*
class Time {
private int $_hour;
private int $_minute;
private int $_second;
private function __construct(int $hour, int $minute, int $second = 0) {
$this->_hour = $hour;
$this->_minute = $minute;
$this->_second = $second;
}
public function setDate(DateTimeInterface $dt): DateTimeInterface {
return $dt->setTime($this->_hour, $this->_minute, $this->_second);
}
public static function parse(string $string): static|false {
if (preg_match('#^(?P<h>\d{1,2})(?::(?P<m>\d{2}))?$#', $heure, $m) && $m['h'] < 24 && ($m['m'] ?? 0) < 60) {
return new static($m['h'], $m['m'] ?? 0);
} else {
return false;
}
}
public function __toString(): string {
return "{$this->_hour}:{$this->_minute}:{$this->_second}";
}
}
*/
<?php
declare(strict_types=1);
require __DIR__ . '/shared.php';
function debug(... $args) {
if ($GLOBALS['debug'] ?? false) {
echo implode('', $args), PHP_EOL;
}
}
/* ===== <gestion des arguments reçus> ===== */
const PARAMETRES_OBLIGATOIRES = [
'duree',
'debut',
'fin',
];
function usage(?string $hint = NULL): never {
if ($hint) {
echo $hint, str_repeat(PHP_EOL, 2);
}
echo <<<'EOS'
Paramètres facultatifs :
-d : afficher des informations de débogage
Paramètres obligatoires :
--debut=AAAA-MM-DD : date à partir de laquelle les slots doivent être générés
--fin=AAAA-MM-DD : date (exclue) jusqu'à laquelle sont générés les slosts
ou
--fin=intervalle : période (exemple : "1 month", ajoutée à la date en --debut) sur laquelle générer les slots
Autres :
--lundi
--mardi
--...
--dimanche
Pour définir les horaires d'ouverture. Exemple pour une ouverture le Lundi de 9H30 à 12H et de 14H à 18H :
--lundi 9:30-12 --lundi 14-18
EOS;
exit;
}
$options = array_map(fn ($v) => $v . ':', array_merge(PARAMETRES_OBLIGATOIRES, JOURS));
$args = getopt('d', $options);
var_dump($args);
$horaires = [];
foreach (PARAMETRES_OBLIGATOIRES as $param) {
if (!array_key_exists($param, $args)) {
usage('paramètre --' . $param . ' manquant');
}
}
# -d
$debug = array_key_exists('d', $args);
# --debut
if (false === ($debut = parse_date($args['debut']))) {
usage("la valeur du paramètre --debut n'est pas une date valide");
}
# --fin
if (false === ($fin = parse_date($args['fin']))) {
if (false === ($interval = DateInterval::createFromDateString($args['fin']))) {
usage("la valeur du paramètre --fin n'est ni une date valide ni un intervalle valide");
} else {
$fin = $debut->add($interval);
}
}
if ($fin <= $debut) {
usage("la valeur du paramètre --fin ne peut être antérieure à celle de --debut");
}
# --duree
if (false === ($interval = DateInterval::createFromDateString($args['duree']))) {
usage("la valeur du paramètre --duree n'est pas un intervalle valide");
}
$intervalle = DateInterval::createFromDateString($args['duree']);
# --(lundi|mardi|mercredi|jeudi|vendredi|samedi|dimanche)
foreach (JOURS as $dow => $nom) {
if (array_key_exists($nom, $args)) {
foreach ((array) $args[$nom] as $h) {
$heures = explode('-', $h);
if (2 != count($heures)) {
usage("valeur {$h} invalide pour l'option --{$nom}");
}
foreach ($heures as $heure) {
if (preg_match('#^(?P<h>\d{1,2})(?::(?P<m>\d{2}))?$#', $heure, $m) && $m['h'] < 24 && ($m['m'] ?? 0) < 60) {
// OK
} else {
usage("valeur {$h} pour l'option --{$nom} : {$heure} n'est pas une heure valide");
}
}
if (!array_key_exists($dow, $horaires)) {
$horaires[$dow] = [];
}
$horaires[$dow][] = $heures;
}
}
}
/* ===== </gestion des arguments reçus> ===== */
/* ===== <génération et insertion des créneaux> ===== */
$stmt = $bdd->prepare('INSERT IGNORE INTO slots(debut, fin) VALUES(:debut, :fin)');
// itération sur les jours entre $debut et $fin
foreach (new DatePeriod($debut, DateInterval::createFromDateString('1 day'), $fin) as $dt) {
debug($dt->format('Y-m-d'));
$dow = day_of_week($dt);
if (!array_key_exists($dow, $horaires)) {
debug("\t", "jour de fermeture");
continue;
}
if (ferie($dt)) {
debug("\t", "férié");
continue;
}
foreach ($horaires[$dow] as $array) {
[$ouverture, $fermeture] = array_map(fn($v) => new DateTimeImmutable($dt->format('Y-m-d ') . $v . ':00'), $array);
foreach (new DatePeriod($ouverture, $intervalle, $fermeture) as $dt) {
debug("\t", $dt->format('Y-m-d H:i:s'), ' - ', $dt->add($intervalle)->format('Y-m-d H:i:s'));
$stmt->execute(
[
'debut' => $dt->format('Y-m-d H:i:s'),
'fin' => $dt->add($intervalle)->format('Y-m-d H:i:s'),
]
);
}
}
}
/* ===== </génération et insertion des créneaux> ===== */
<?php
declare(strict_types=1);
require __DIR__ . '/shared.php';
const DUREES = [
5 => '5 minutes',
10 => '10 minutes',
15 => '15 minutes',
20 => '20 minutes',
30 => '30 minutes',
60 => '1 heure',
];
function template(?int $dow = null, ?string $ouverture = null, ?string $fermeture = null) {
?>
<p>
<label>
De :
<input type="time" name="horaires[<?= $dow ?>][]" value="<?= htmlspecialchars($ouverture ?? '') ?>">
</label>
<label>
A :
<input type="time" name="horaires[<?= $dow ?>][]" value="<?= htmlspecialchars($fermeture ?? '') ?>">
</label>
<button type="button" class="remove btn btn-primary">-</button>
</p>
<?php
}
function has_error(array $erreurs, string $field): bool {
return array_key_exists($field, $erreurs);
}
function feedback(array $erreurs, string $field): string {
return has_error($erreurs, $field) ? '<div class="invalid-feedback">' . htmlspecialchars($erreurs[$field]) . '</div>' : '';
}
$erreurs = [];
if ('POST' == $_SERVER['REQUEST_METHOD']) {
if (isset($_POST['debut'])) {
if (false === ($debut = parse_date($_POST['debut']))) {
$erreurs['debut'] = "Début n'est pas une date valide";
}
} else {
$erreurs['debut'] = "Champ \"début\" manquant";
}
if (isset($_POST['fin'])) {
if (false === ($fin = parse_date($_POST['fin']))) {
$erreurs['fin'] = "Fin n'est pas une date valide";
}
} else {
$erreurs['fin'] = "Champ \"fin\" manquant";
}
if (isset($_POST['duree'])) {
if (false === ($duree = filter_var($_POST['duree'], FILTER_VALIDATE_INT))) {
$erreurs['duree'] = "Durée n'est pas une valeur entière";
} else {
if ($duree < 1) {
$erreurs['duree'] = "Durée ne peut avoir une valeur inférieure à 1 minute";
}
/*
if ($duree > DUREE_MAX) {
$erreurs['duree'] = sprintf("Durée ne peut avoir une valeur supérieure à %d minutes", DUREE_MAX);
}
*/
}
} else {
$erreurs['duree'] = "Champ \"durée\" manquant";
}
if (!isset($_POST['horaires'])) {
$erreurs['horaires'] = "Aucun horaire n'a été défini";
}
if (!$erreurs) {
$intervalle = DateInterval::createFromDateString($duree . ' minutes');
$stmt = $bdd->prepare('INSERT IGNORE INTO slots(debut, fin) VALUES(:debut, :fin)');
// echo '<pre>';
foreach (new DatePeriod($debut, DateInterval::createFromDateString('1 day'), $fin) as $dt) {
// echo $dt->format('Y-m-d'), PHP_EOL;
$dow = day_of_week($dt);
if (!array_key_exists($dow, $_POST['horaires'])) {
// echo "\t", "jour de fermeture", PHP_EOL;
continue;
}
if (ferie($dt)) {
// echo "\t", "férié", PHP_EOL;
continue;
}
foreach (array_chunk($_POST['horaires'][$dow], 2) as $array) {
[$ouverture, $fermeture] = array_map(fn($v) => new DateTimeImmutable($dt->format('Y-m-d ') . $v . ':00'), $array);
foreach (new DatePeriod($ouverture, $intervalle, $fermeture) as $dt) {
// echo "\t", $dt->format('Y-m-d H:i:s'), ' - ', $dt->add($intervalle)->format('Y-m-d H:i:s'), PHP_EOL;
$stmt->execute(
[
'debut' => $dt->format('Y-m-d H:i:s'),
'fin' => $dt->add($intervalle)->format('Y-m-d H:i:s'),
]
);
}
}
}
// echo '</pre>';
/*
$_SESSION['flash']['info'] = "Créneaux créés avec succès !";
header('location: /');
exit;
*/
}
}
?>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
<script type="text/javascript">
document.addEventListener(
'DOMContentLoaded',
function() {
document.querySelectorAll('button.add').forEach(
(element) => {
element.addEventListener(
'click',
(event) => {
var template = document.querySelector('#horaire');
var clone = document.importNode(template.content, true);
clone.querySelectorAll('input[name="horaires[][]"]').forEach(
(input) => {
input.setAttribute('name', 'horaires[' + event.target.getAttribute('value') + '][]');
}
);
var imported = event.target.parentNode.insertBefore(clone, event.target);
}
);
}
);
document.addEventListener(
'click',
(event) => {
if (event.target.closest('button.remove')) {
event.target.parentNode.parentNode.removeChild(event.target.parentNode);
}
}
);
}
);
</script>
<template id="horaire">
<?= template() ?>
</template>
<details>
<pre>
<?php var_dump($_POST) ?>
</pre>
</details>
<form method="POST">
<?php if ($erreurs): ?>
<div>
Veuillez corriger les erreurs suivantes :
<ul>
<?php foreach ($erreurs as $erreur): ?>
<li>
<?= htmlspecialchars($erreur) ?>
</li>
<?php endforeach ?>
</ul>
</div>
<?php endif ?>
<?php foreach (JOURS as $dow => $nom): ?>
<fieldset>
<legend><?= $nom ?></legend>
<?php if (isset($_POST['horaires']) && is_array($_POST['horaires']) && array_key_exists($dow, $_POST['horaires']) && is_array($_POST['horaires'][$dow])): ?>
<?php foreach (array_chunk($_POST['horaires'][$dow], 2) as $v): ?>
<?= template($dow, $v[0], $v[1]) ?>
<?php endforeach ?>
<?php endif ?>
<button type="button" class="add btn btn-primary" value="<?= $dow ?>">+</button>
</fieldset>
<?php endforeach ?>
<div>
<label>
Début :
<input type="date" name="debut" class="form-control <?= has_error($erreurs, 'debut') ? 'is-invalid' : 'is-valid' ?>" value="<?= htmlspecialchars($_POST['debut'] ?? '') ?>">
</label>
<?= feedback($erreurs, 'debut') ?>
</div>
<div>
<label>
Fin :
<input type="date" name="fin" class="form-control <?= has_error($erreurs, 'fin') ? 'is-invalid' : 'is-valid' ?>" value="<?= htmlspecialchars($_POST['fin'] ?? '') ?>">
</label>
<?= feedback($erreurs, 'fin') ?>
</div>
<div>
<label>
Durée d'un créneau :
<!--<input type="time" name="duree" value="<?= htmlspecialchars($_POST['duree'] ?? '') ?>">-->
<select name="duree" class="form-select <?= has_error($erreurs, 'duree') ? 'is-invalid' : 'is-valid' ?>">
<?php foreach (DUREES as $k => $v): ?>
<option value="<?= $k ?>" <?= isset($_POST['duree']) && $_POST['duree'] == $k ? 'selected' : '' ?>><?= htmlspecialchars($v) ?></option>
<?php endforeach ?>
</select>
</label>
<?= feedback($erreurs, 'duree') ?>
</div>
<div>
<input type="submit">
</div>
</form>
CREATE TABLE slots(
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
-- tel que [debut; fin[
debut DATETIME NOT NULL,
fin DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY (debut),
UNIQUE KEY (fin)
);
CREATE TABLE appointments(
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
nom_client VARCHAR(80) NOT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id)
);
CREATE TABLE appointments_slots(
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
slot_id INT UNSIGNED NULL DEFAULT NULL,
FOREIGN KEY (slot_id) REFERENCES slots(id) ON UPDATE CASCADE ON DELETE SET NULL,
appointment_id INT UNSIGNED NOT NULL,
FOREIGN KEY (appointment_id) REFERENCES appointments(id) ON UPDATE CASCADE ON DELETE CASCADE,
PRIMARY KEY (id),
UNIQUE KEY (slot_id),
UNIQUE KEY (appointment_id)
);
/*
INSERT INTO appointments(created_at, nom_client) VALUES(NOW(), 'test');
INSERT INTO appointments_slots(appointment_id, slot_id)
SELECT LAST_INSERT_ID(), id
FROM slots
WHERE debut = '2021-05-11 15:00:00'
;
SELECT * FROM slots JOIN appointments_slots WHERE slots.id = appointments_slots.slot_id;
*/
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment