Init Git Prod Mars 2026

This commit is contained in:
Oleg Barykin committed 2026-03-05 10:25:39 +01:00
commit d81efe93b1
362 files changed
+86627

No files matched your search

+297
View File
@@ -0,0 +1,297 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../includes/header_cabinet.php';
if (!function_exists('h')) { function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); } }
$token = trim((string)($_GET['token'] ?? ''));
$inv = null;
if ($token) {
$q = $pdo->prepare("SELECT * FROM invitations WHERE token=:t AND status='envoyee' LIMIT 1");
$q->execute(['t'=>$token]);
$inv = $q->fetch(PDO::FETCH_ASSOC);
}
if (!$inv) {
echo '<div class="container my-4"><div class="alert alert-danger">'.h($translations['inv_token_invalide'] ?? 'Lien invalide.').'</div></div>';
exit;
}
if (!empty($inv['expires_at']) && new DateTime($inv['expires_at']) < new DateTime()) {
echo '<div class="container my-4"><div class="alert alert-warning">'.h($translations['inv_token_expire'] ?? 'Lien expiré.').'</div></div>';
exit;
}
$email = strtolower((string)$inv['email']);
$praticien_id = (int)$inv['praticien_id'];
$connectedRole = strtolower((string)($_SESSION['role'] ?? ''));
$connectedId = (int)($_SESSION['compte_id'] ?? 0);
$connectedEmail= strtolower((string)($_SESSION['email'] ?? ''));
$done = false;
$msg = '';
// =====================================================
// CAS 1 : Déjà connecté comme secrétaire
// =====================================================
if ($connectedRole === 'secretaire' && $connectedId > 0) {
if ($connectedEmail === $email) {
// rattachement au praticien si pas encore lié
$chk = $pdo->prepare("SELECT id FROM secretaire_praticien WHERE secretaire_user_id=:u AND praticien_id=:p");
$chk->execute(['u'=>$connectedId,'p'=>$praticien_id]);
if (!$chk->fetch()) {
$ins = $pdo->prepare("INSERT INTO secretaire_praticien (secretaire_user_id, praticien_id, can_manage_rdv, can_edit_slots, is_active)
VALUES (:u,:p,1,1,1)");
$ins->execute(['u'=>$connectedId,'p'=>$praticien_id]);
}
$pdo->prepare("UPDATE invitations SET status='acceptee' WHERE id=:id")->execute(['id'=>$inv['id']]);
$done = true;
} else {
// autre email → doit se connecter avec le bon compte
$msg = sprintf(
$translations['inv_email_diff'] ?? "Vous êtes connecté avec %s, mais l’invitation est pour %s. Veuillez vous connecter avec le bon compte.",
h($connectedEmail), h($email)
);
}
}
// =====================================================
// CAS 2 : POST (création compte si inexistant)
// =====================================================
elseif (($_SERVER['REQUEST_METHOD']??'')==='POST') {
$pwd = (string)($_POST['pwd'] ?? '');
$pwd2 = (string)($_POST['pwd2'] ?? '');
if (strlen($pwd) < 8 || $pwd !== $pwd2) {
$msg = $translations['inv_pwd_invalid'] ?? 'Mot de passe invalide ou non identique.';
} else {
// Vérifier si un compte existe déjà avec ce mail
$chk = $pdo->prepare("SELECT id, role FROM compte WHERE email=:e LIMIT 1");
$chk->execute(['e'=>$email]);
$acc = $chk->fetch(PDO::FETCH_ASSOC);
if ($acc) {
if ($acc['role'] !== 'secretaire') {
$msg = $translations['inv_email_occupe'] ?? "Un compte avec cet email existe déjà avec un autre rôle.";
} else {
// compte secrétaire déjà existant → rediriger vers login AVEC token
header("Location: /connexion.php?next=" . urlencode("/public/cabinet/accepter_invitation.php?token=".$token));
exit;
}
} else {
// création nouveau compte secrétaire
$ins = $pdo->prepare("INSERT INTO compte (email, mot_de_passe, role, reference_id, token_validation, email_valide)
VALUES (:e, :pwd, 'secretaire', 0, NULL, 1)");
$ins->execute([
'e' => $email,
'pwd' => password_hash($pwd, PASSWORD_BCRYPT)
]);
$newId = (int)$pdo->lastInsertId();
$_SESSION['compte_id'] = $newId;
$_SESSION['role'] = 'secretaire';
$_SESSION['reference_id'] = 0;
$_SESSION['email'] = $email;
$pdo->prepare("INSERT INTO secretaire_praticien (secretaire_user_id, praticien_id, can_manage_rdv, can_edit_slots, is_active)
VALUES (:u,:p,1,1,1)")
->execute(['u'=>$newId,'p'=>$praticien_id]);
$pdo->prepare("UPDATE invitations SET status='acceptee' WHERE id=:id")->execute(['id'=>$inv['id']]);
$done = true;
}
}
}
// =====================================================
// CAS 3 : GET initial (ni connecté ni posté)
// Vérifier si compte existe déjà
// =====================================================
else {
$chk = $pdo->prepare("SELECT id, role FROM compte WHERE email=:e LIMIT 1");
$chk->execute(['e'=>$email]);
$acc = $chk->fetch(PDO::FETCH_ASSOC);
if ($acc && $acc['role']==='secretaire') {
echo '<div class="container my-4"><div class="alert alert-info">'.
h($translations['inv_compte_existe'] ?? 'Un compte secrétaire existe déjà, veuillez vous connecter.').
'</div><a href="/connexion.php?next='.urlencode("/public/cabinet/accepter_invitation.php?token=".$token).'" class="btn btn-primary">Connexion</a></div>';
exit;
}
}
?>
<div class="main1">
<div class="container py-5">
<div class="shadow p-4 rounded bg-light mx-auto" style="max-width:600px;">
<h1 class="h4 mb-3"><?= h($translations['inv_accepter_titre'] ?? 'Accepter l’invitation') ?></h1>
<?php if($done): ?>
<div class="alert alert-success mb-4"><?= h($translations['inv_ok'] ?? 'Invitation acceptée. Vous avez désormais accès au secrétariat.') ?></div>
<a class="btn btn-primary" href="/public/cabinet/dashboard.php"><?= h($translations['btn_aller_dashboard_sec'] ?? 'Aller au tableau de bord secrétaire') ?></a>
<?php else: ?>
<div class="card mb-3"><div class="card-body">
<p class="mb-1"><strong><?= h($translations['inv_email'] ?? 'Email') ?>:</strong> <?= h($email) ?></p>
<p class="mb-0"><strong><?= h($translations['inv_praticien'] ?? 'Praticien') ?>:</strong> #<?= (int)$praticien_id ?></p>
</div></div>
<?php if(!empty($msg)): ?>
<div class="alert alert-warning"><?= $msg ?></div>
<?php endif; ?>
<?php if($connectedRole!=='secretaire' || $connectedEmail!==$email): ?>
<form method="post" class="card p-3" id="create-secretary-form">
<label class="form-label mb-2">
<?= h($translations['inv_choisir_pwd'] ?? 'Choisissez un mot de passe (min. 8 caractères)') ?>
</label>
<!-- Mot de passe -->
<!-- MDP -->
<div class="mb-2 position-relative">
<input type="password" name="pwd" id="pwd" class="form-control pe-5"
minlength="8" required autocomplete="new-password"
placeholder="<?= h($translations['motdepasse_placeholder'] ?? 'Mot de passe') ?>">
<span class="toggle-password" onclick="togglePassword('pwd')"
style="position:absolute;right:12px;top:50%;transform:translateY(-50%);cursor:pointer;user-select:none;">👁️</span>
</div>
<div id="niveau-securite" class="mb-2 text-muted">
<?= h($translations['mdp_regle_min'] ?? 'min 8: (A–z)+(1–9)+(&–!)') ?>
</div>
<div class="strength-bar d-flex gap-1 mb-3" aria-hidden="true" style="height:8px;">
<div class="strength-fill flex-fill" style="background:#d9d9d9;border-radius:6px;"></div>
<div class="strength-fill flex-fill" style="background:#d9d9d9;border-radius:6px;"></div>
<div class="strength-fill flex-fill" style="background:#d9d9d9;border-radius:6px;"></div>
</div>
<!-- Confirmation -->
<div class="mb-2 position-relative">
<input type="password" name="pwd2" id="pwd2" class="form-control pe-5"
minlength="8" required autocomplete="new-password"
placeholder="<?= h($translations['confirm_mdp_placeholder'] ?? 'Confirmer le mot de passe') ?>">
<span class="toggle-password" onclick="togglePassword('pwd2')"
style="position:absolute;right:12px;top:50%;transform:translateY(-50%);cursor:pointer;user-select:none;">👁️</span>
</div>
<small id="confirm-feedback" class="d-block mb-3 text-muted" style="display:none;">—</small>
<button id="btn-submit" class="btn btn-primary" disabled>
<?= h($translations['inv_btn_creer_compte'] ?? 'Créer mon compte & accepter') ?>
</button>
</form>
<?php endif; ?>
<?php endif; ?>
</div>
</div>
</div>
<footer class="site-footer">
<div class="container">
<p><?= $translations['copyr'] ?? '&copy; '.date('Y') ?></p>
</div>
</footer>
<script>
function togglePassword(id){
const i = document.getElementById(id);
if (i) i.type = (i.type === 'password') ? 'text' : 'password';
}
(function(){
const form = document.getElementById('create-secretary-form') || document.getElementById('form-inscription') || document.forms[0];
const pwd = document.getElementById('pwd');
const cpwd = document.getElementById('pwd2');
const fills = Array.from(document.querySelectorAll('.strength-fill'));
const hint = document.getElementById('niveau-securite');
const fb = document.getElementById('confirm-feedback');
const submit = document.getElementById('btn-submit');
const T = {
rule : '<?= addslashes($translations['mdp_regle_min'] ?? 'min 8: (A–z)+(1–9)+(&–!)') ?>',
weak : '<?= addslashes($translations['securite_faible'] ?? 'Sécurité : faible') ?>',
medium: '<?= addslashes($translations['securite_moyen'] ?? 'Sécurité : moyen') ?>',
strong: '<?= addslashes($translations['securite_fort'] ?? 'Sécurité : fort') ?>',
ok : '<?= addslashes($translations['mdp_identiques_ok'] ?? 'Les mots de passe correspondent.') ?>',
ko : '<?= addslashes($translations['mdp_non_identiques'] ?? 'Les mots de passe ne correspondent pas.') ?>'
};
function score(val){
if(!val) return 0;
let s = 0;
if(/[a-z]/.test(val)) s++;
if(/[A-Z]/.test(val)) s++;
if(/\d/.test(val)) s++;
if(/[\W_]/.test(val)) s++;
if(val.length>=8) s++;
return Math.min(s,5); // 0..5
}
function paintStrength(s){
// reset
fills.forEach(el => el.style.background = '#d9d9d9');
// niveaux: 0-1 faible(1 segment), 2-3 moyen(2), 4-5 fort(3)
let n = 0, color = '#dc3545'; // rouge
if (s <= 1) { n = 1; color = '#dc3545'; }
else if (s <= 3) { n = 2; color = '#ffc107'; } // jaune
else { n = 3; color = '#28a745'; } // vert
for (let i=0;i<n;i++) fills[i].style.background = color;
if (hint){
hint.classList.remove('text-danger','text-warning','text-success');
if (n===1){ hint.textContent = T.weak; hint.classList.add('text-danger'); }
else if (n===2){ hint.textContent = T.medium; hint.classList.add('text-warning'); }
else { hint.textContent = T.strong; hint.classList.add('text-success'); }
}
}
function checkMatch(){
if (!fb) return false;
if (!cpwd.value){ fb.style.display='none'; return false; }
const same = (pwd.value === cpwd.value) && cpwd.value.length>=8;
fb.textContent = same ? T.ok : T.ko;
fb.style.color = same ? '#198754' : '#dc3545';
fb.style.display = 'block';
return same;
}
function update(){
const v = pwd.value;
// ——— ÉTAT NEUTRE TANT QUE RIEN N'EST SAISI ———
if (!v) {
// barre toute grise
fills.forEach(el => el.style.background = '#d9d9d9');
// texte d'aide (règle) en gris
if (hint){
hint.textContent = T.rule;
hint.classList.remove('text-danger','text-warning','text-success');
hint.classList.add('text-muted');
}
// reset confirmation
if (fb){ fb.style.display = 'none'; }
// bouton désactivé
if (submit){ submit.disabled = true; }
return; // <<< on sort ici, pas de rouge par défaut
}
// ——— LOGIQUE NORMALE SI QUELQUE CHOSE EST SAISI ———
const sc = score(v);
paintStrength(sc);
const same = checkMatch();
if (submit){
// OK si force >= moyen (>=2 segments), longueur >=8 et confirmation OK
const ok = (sc >= 2) && (v.length >= 8) && same;
submit.disabled = !ok;
}
}
if (hint) hint.classList.remove('text-muted');
if (pwd) pwd.addEventListener('input', update);
if (cpwd) cpwd.addEventListener('input', update);
if (form) form.addEventListener('submit', function(e){
if (submit && submit.disabled){ e.preventDefault(); }
});
update(); // init
})();
</script>
+216
View File
@@ -0,0 +1,216 @@
<?php
// /public/cabinet/agenda.php
require_once __DIR__ . '/../includes/init_secretaire.php';
if (!function_exists('h')) { function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); } }
$compteId = (int)($_SESSION['compte_id'] ?? 0);
$lang = $_SESSION['lang'] ?? 'fr'; // 'fr' ou 'ru'
$fcLocale = ($lang === 'ru') ? 'ru' : 'fr';
// --- on attend ?praticien_id=... (ex. produit par le dashboard secrétaire)
$praticien_id = isset($_GET['praticien_id']) ? (int)$_GET['praticien_id'] : 0;
if ($compteId <= 0 || $praticien_id <= 0) { http_response_code(403); exit('Forbidden'); }
// --- vérification pivot secrétaire -> praticien
$st = $pdo->prepare("
SELECT can_manage_rdv
FROM secretaire_praticien
WHERE secretaire_user_id = :u
AND praticien_id = :p
AND is_active = 1
LIMIT 1
");
$st->execute(['u'=>$compteId,'p'=>$praticien_id]);
$pivot = $st->fetch(PDO::FETCH_ASSOC);
if (!$pivot) { http_response_code(403); exit('Forbidden'); }
// petit helper perm (même si on n’édite rien ici)
$canManage = (int)($pivot['can_manage_rdv'] ?? 0) === 1;
$PAGE_TITLE = $translations['titre_agenda_page'] ?? 'Agenda';
$HEAD_EXTRA = <<<HTML
<!-- FullCalendar -->
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.8/index.global.min.css" rel="stylesheet" />
<script src="/js/index.global.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.8/locales-all.global.min.js"></script>
<!-- SweetAlert2 -->
<script src="/js/sweetalert2@11.js"></script>
<link href="/js/sweetalert2.min.css" rel="stylesheet">
<style>
:root{
--card-bg:#fff; --muted:#6c757d; --brand:#38378B; --accent:#e9ecef; --text:#222; --border:#e9ecef;
}
body{ background:#8CA9FF; color:var(--text); }
.agenda-wrap{ max-width:1100px; margin:clamp(8px, 2vw, 24px) auto; padding:clamp(8px, 2vw, 24px); }
.agenda-card{ background:var(--card-bg); border:1px solid var(--border); border-radius:16px; box-shadow:0 6px 18px rgba(0,0,0,.06); padding:clamp(12px,2.4vw,28px); }
.agenda-title{ margin:0 0 12px; text-align:center; font-weight:700; letter-spacing:.2px; }
#calendar{ min-height:420px; }
.fc .fc-toolbar.fc-header-toolbar{ gap:8px; flex-wrap:wrap; }
.fc .fc-toolbar-title{ font-size:clamp(18px, 2.4vw, 22px); color:var(--brand); }
.fc .fc-button{ border-radius:999px; border:1px solid var(--border); background:#fff; color:#111; box-shadow:0 2px 6px rgba(0,0,0,.05); padding:6px 10px; }
.fc .fc-button-primary:not(:disabled).fc-button-active, .fc .fc-button-primary:not(:disabled):active, .fc .fc-button-primary:hover{
background:var(--accent); border-color:transparent; color:#000;
}
.fc .fc-col-header-cell-cushion{ color: var(--brand); font-weight:600; }
.fc .fc-timegrid-slot-label, .fc .fc-timegrid-axis-cushion{ color:var(--muted); }
.fc .fc-event{ border-radius:12px; border:0; padding:2px 4px; }
.fc .fc-daygrid-event{ padding:2px 6px; }
.empty-state{ text-align:center; color:var(--muted); padding:28px 12px 8px; font-size:15px; }
.fc .fc-timegrid-now-indicator-line{ border-color:#ff5a5f; }
@media (max-width: 768px){
.agenda-card{ max-width: 720px; border-radius:14px; }
.fc .fc-toolbar.fc-header-toolbar{ justify-content:center; }
}
</style>
HTML;
require_once __DIR__ . '/../includes/header_cabinet.php';
?>
<div class="main1">
<div class="container py-5">
<div class="shadow p-4 rounded bg-light">
<h2 class="mb-4 text-center"><?= htmlspecialchars($currentPraticienName, ENT_QUOTES, 'UTF-8') ?> - <?= h($translations['raspisanie'] ?? 'Agenda') ?></h2>
<div id="calendar" role="application" aria-label="calendar"></div>
<div id="emptyMsg" class="empty-state" style="display:none;">
<?= h($translations['agenda_aucun_evenement'] ?? 'Aucun rendez-vous à afficher.') ?>
</div>
</div>
</div>
</div>
<footer class="site-footer">
<div class="container">
<p><?= $translations['copyr'] ?? '&copy; '.date('Y') ?></p>
</div>
</footer>
<script>
document.addEventListener('DOMContentLoaded', function () {
const calendarEl = document.getElementById('calendar');
const emptyMsgEl = document.getElementById('emptyMsg');
const t = <?= json_encode([
'rdv_avec' => $translations['sw_rdv_avec'] ?? 'RDV avec',
'date' => $translations['sw_date'] ?? 'Date',
'heure' => $translations['sw_heure'] ?? 'Heure',
'statut' => $translations['sw_statut'] ?? 'Statut',
'gerer_rdv' => $translations['sw_gerer_rdv'] ?? 'Gérer le RDV',
'btn_fermer' => $translations['sw_btn_fermer'] ?? 'Fermer',
'erreur' => $translations['erreur_chargement_rdv'] ?? 'Erreur de chargement',
'statut_confirme' => $translations['statut_confirme'] ?? 'Confirmé',
'statut_prevu' => $translations['statut_prevu'] ?? 'Prévu',
'statut_annule' => $translations['statut_annule'] ?? 'Annulé',
], JSON_UNESCAPED_UNICODE) ?>;
const fcLocale = <?= json_encode($fcLocale) ?>;
const praticienId = <?= (int)$praticien_id ?>;
const isMobile = window.matchMedia('(max-width: 768px)').matches;
const initialView = isMobile ? 'listWeek' : 'timeGridWeek';
const calendar = new window.FullCalendar.Calendar(calendarEl, {
locale: fcLocale,
timeZone: 'Europe/Paris',
initialView,
nowIndicator: true,
stickyHeaderDates: true,
expandRows: true,
slotMinTime: "07:00:00",
slotMaxTime: "21:00:00",
firstDay: 1,
height: isMobile ? 'auto' : 680,
customButtons: {
btnJour: { text: '<?= addslashes($translations['btn_jour'] ?? 'Jour') ?>', click(){ calendar.changeView('listDay'); } },
btnSemaine: { text: '<?= addslashes($translations['btn_semaine'] ?? 'Semaine') ?>', click(){ calendar.changeView('listWeek'); } }
},
headerToolbar: {
left: 'prev,next,today',
center: 'title',
right: isMobile ? 'btnJour,btnSemaine,dayGridMonth' : 'timeGridWeek,dayGridMonth'
},
buttonText: {
today: '<?= addslashes($translations['btn_auj'] ?? "Aujourd'hui") ?>',
month: '<?= addslashes($translations['btn_mois'] ?? 'Mois') ?>',
week: '<?= addslashes($translations['btn_semaine'] ?? 'Semaine') ?>',
day: '<?= addslashes($translations['btn_jour'] ?? 'Jour') ?>',
list: '<?= addslashes($translations['btn_liste'] ?? 'Liste') ?>'
},
views: { listDay:{buttonText:'<?= addslashes($translations['btn_jour'] ?? 'Jour') ?>'},
listWeek:{buttonText:'<?= addslashes($translations['btn_semaine'] ?? 'Semaine') ?>'} },
events: {
url: '/public/cabinet/events.php',
method: 'POST',
extraParams: { praticien_id: praticienId },
failure: function () {
Swal.fire({ icon: 'error', title: 'Oops…', text: t.erreur });
}
},
loading: function(isLoading) {
if (isLoading) return;
emptyMsgEl.style.display = calendar.getEvents().length > 0 ? 'none' : 'block';
},
eventContent: function(arg){
if (arg.view.type.includes('timeGrid') || arg.view.type.includes('dayGrid')) {
const title = document.createElement('div');
title.style.fontWeight = '600';
title.style.fontSize = '12px';
title.textContent = arg.event.title;
const time = document.createElement('div');
time.style.fontSize = '11px';
time.style.opacity = '.9';
time.textContent = arg.timeText;
return { domNodes: [title, time] };
}
return true;
},
eventClick: function(info) {
const start = new Date(info.event.start);
const end = new Date(info.event.end);
const dateStr = start.toLocaleDateString(<?= json_encode($lang === 'ru' ? 'ru-RU' : 'fr-FR') ?>, { weekday:'long', year:'numeric', month:'long', day:'numeric' });
const heureDebut = start.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const heureFin = end.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const raw = (info.event.extendedProps.statut || '').trim().toLowerCase();
const fr2key = { 'prévu': 'planned', 'confirmé': 'confirmed', 'annulé': 'cancelled' };
const key = fr2key[raw] || raw;
const statutMap = { planned: t.statut_prevu, confirmed: t.statut_confirme, cancelled: t.statut_annule };
const statutLabel = statutMap[key] ?? key;
Swal.fire({
title: `${t.rdv_avec} ${info.event.title}`,
html: `
<p><strong>${t.date} :</strong> ${dateStr}</p>
<p><strong>${t.heure} :</strong> ${heureDebut} – ${heureFin}</p>
<p><strong>${t.statut} :</strong> ${statutLabel}</p>
<a class="swal2-confirm swal2-styled" style="display:inline-block;margin-top:8px;border-radius:999px"
href="/public/cabinet/rendez_vous.php?id=${info.event.id}&praticien_id=${praticienId}" target="_blank">
${t.gerer_rdv}
</a>
`,
showConfirmButton: true,
confirmButtonText: t.btn_fermer
});
},
eventDidMount: function(info){ info.el.setAttribute('aria-label', info.event.title); },
windowResize: function(){
const mobile = window.matchMedia('(max-width: 768px)').matches;
calendar.setOption('height', mobile ? 'auto' : 680);
if (mobile && !calendar.view.type.startsWith('list')) calendar.changeView('listWeek');
}
});
calendar.render();
});
</script>
</body>
</html>
+685
View File
@@ -0,0 +1,685 @@
<?php
require_once __DIR__ . '/../includes/init_secretaire.php';
require_once APP_HTDOCS . '/push/push_endpoint.php';
// PHPMailer (config centralisée)
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
header('Content-Type: application/json; charset=utf-8');
// ------- helpers JSON -------
function out(bool $ok, string $message, array $extra = []): never {
echo json_encode(['ok'=>$ok, 'message'=>$message] + $extra, JSON_UNESCAPED_UNICODE);
exit;
}
// ------- helpers e-mail HTML/TXT -------
function email_html_status(array $d, array $t): string {
$brand = '<img src="https://uslugi.fr/public/uploads/img/logo_mail.png" width="150" alt="USLUGI.FR">';
$brand_bas ='УСЛУГИ.ФР';
return '<!DOCTYPE html><html lang="ru"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>USLUGI.FR</title></head>
<body style="margin:0;padding:0;background:#f4f6f8;font-family:Arial,Helvetica,sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f4f6f8;padding:24px 0;"><tr><td align="center">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:8px;overflow:hidden;">
<tr><td style="background:#38378B;color:#fff;padding:20px 24px;font-size:18px;font-weight:600;">'.$brand.'</td></tr>
<tr><td style="padding:24px 24px 8px 24px;color:#111;font-size:16px;line-height:1.5;">'.nl2br(htmlspecialchars($d['hello_line'])).'</td></tr>
<tr><td style="padding:8px 24px;color:#111;font-size:16px;line-height:1.6;">'.nl2br(htmlspecialchars($d['intro'])).'</td></tr>
<tr><td style="padding:16px 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e5e7eb;border-radius:8px;">
<tr><td style="padding:14px 16px;font-size:14px;color:#6b7280;border-bottom:1px solid #e5e7eb;">'.htmlspecialchars($d['who_label']).'</td><td style="padding:14px 16px;font-size:14px;color:#111;">'.htmlspecialchars($d['who_value']).'</td></tr>
<tr><td style="padding:14px 16px;font-size:14px;color:#6b7280;">'.htmlspecialchars($d['date_label']).'</td><td style="padding:14px 16px;font-size:14px;color:#111;">'.htmlspecialchars($d['when']).'</td></tr>
<tr><td style="padding:14px 16px;font-size:14px;color:#6b7280;">'.htmlspecialchars($d['statut_label']).'</td><td style="padding:14px 16px;font-size:14px;color:#111;">'.htmlspecialchars($d['statut_value']).'</td></tr>
</table>
</td></tr>'.
(!empty($d['cta_url']) ? '<tr><td align="center" style="padding:8px 24px 24px 24px;"><a href="'.htmlspecialchars($d['cta_url']).'" target="_blank" style="display:inline-block;text-decoration:none;padding:12px 20px;border-radius:999px;background:#E03C3F;color:#fff;font-weight:600;">'.htmlspecialchars($d['cta_label']).'</a></td></tr>' : '').
'<tr><td style="padding:8px 24px 24px 24px;color:#111;font-size:16px;line-height:1.6;">'.nl2br(htmlspecialchars($d['footer'])).'</td></tr>
</table>
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="margin-top:12px;"><tr><td style="text-align:center;color:#9ca3af;font-size:12px;padding:8px 0;">© '.date('Y').' '.$brand_bas.' — '.htmlspecialchars($t['merci_simple'] ?? 'Merci').'</td></tr></table>
</td></tr></table></body></html>';
}
function email_text_status(array $d, array $t): string {
$L=[]; $L[]=$d['hello_line']; $L[]=''; $L[]=$d['intro']; $L[]='';
$L[]=$d['who_label'].': '.$d['who_value']; $L[]=$d['date_label'].': '.$d['when']; $L[]=$d['statut_label'].': '.$d['statut_value'];
if (!empty($d['cta_url'])) { $L[]=''; $L[] = $d['cta_label'].': '.$d['cta_url']; }
$L[]=''; $L[]=$d['footer']; return implode("\n",$L);
}
// ------- ICS helpers -------
function ics_escape(string $s): string {
$s = str_replace("\\","\\\\",$s);
$s = str_replace(";","\;",$s);
$s = str_replace(",","\,",$s);
return preg_replace("/\r\n|\r|\n/","\\n",$s);
}
function ics_build(
string $uid, DateTime $start, DateTime $end, string $tz, string $summary, string $desc, string $loc,
array $attendees, string $method='REQUEST', string $status='CONFIRMED'
): string {
$L=[];
$L[]="BEGIN:VCALENDAR";
$L[]="VERSION:2.0";
$L[]="PRODID:-//УСЛУГИ.ФР//RDV//FR";
$L[]="CALSCALE:GREGORIAN";
$L[]="METHOD:{$method}";
$L[]="BEGIN:VEVENT";
$L[]="UID:{$uid}";
$L[]="DTSTAMP:".gmdate('Ymd\THis\Z');
$L[]="DTSTART;TZID={$tz}:".$start->format('Ymd\THis');
$L[]="DTEND;TZID={$tz}:".$end->format('Ymd\THis');
if ($summary) $L[]="SUMMARY:".ics_escape($summary);
if ($desc) $L[]="DESCRIPTION:".ics_escape($desc);
if ($loc) $L[]="LOCATION:".ics_escape($loc);
$L[]="ORGANIZER;CN=УСЛУГИ.ФР:mailto:rdv@uslugi.fr";
foreach ($attendees as $a) {
if (empty($a['email'])) continue;
$cn = ics_escape($a['cn'] ?? '');
$L[]="ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;CN={$cn};RSVP=TRUE:mailto:".$a['email'];
}
$L[]="STATUS:{$status}";
if ($method==='CANCEL') $L[]="SEQUENCE:2";
$L[]="END:VEVENT";
$L[]="END:VCALENDAR";
return implode("\r\n",$L)."\r\n";
}
// ------- SMTP send (PHPMailer) -------
function smtp_send(
string $to, string $subject, string $text, string $html,
?string $icsName=null, ?string $icsContent=null, ?string $icsMethod=null
): array {
global $MAILCFG;
$m = new PHPMailer(true);
try {
$m->isSMTP();
$m->Host = $MAILCFG['smtp']['host'];
$m->Port = $MAILCFG['smtp']['port'];
$m->SMTPSecure = $MAILCFG['smtp']['secure'];
$m->SMTPAuth = $MAILCFG['smtp']['auth'];
$m->Username = $MAILCFG['smtp']['username'];
$m->Password = $MAILCFG['smtp']['password'];
$m->CharSet = 'UTF-8';
$m->Encoding = 'base64';
$m->XMailer = '';
if (!empty($_GET['debug_mail'])) {
$m->SMTPDebug = 2;
$m->Debugoutput = function($s,$lvl){ error_log("SMTP[$lvl] ".$s); };
}
if (!empty($MAILCFG['bounce'])) $m->Sender = $MAILCFG['bounce'];
$from = $MAILCFG['from'] ?? ['no-reply@uslugi.fr','USLUGI.FR'];
$rpto = $MAILCFG['reply_to'] ?? ['contact@uslugi.fr','USLUGI.FR'];
$m->setFrom($from[0], $from[1] ?? '');
if (!empty($rpto[0])) $m->addReplyTo($rpto[0], $rpto[1] ?? '');
$m->addAddress($to);
if (!empty($MAILCFG['suppress_auto'])) {
$m->addCustomHeader('Auto-Submitted', 'auto-generated');
$m->addCustomHeader('X-Auto-Response-Suppress', 'All');
}
$m->isHTML(true);
$m->Subject = $subject;
$m->Body = $html;
$m->AltBody = $text;
if ($icsName && $icsContent !== null && $icsMethod) {
$m->addStringAttachment($icsContent, $icsName, 'base64', 'text/calendar; method='.$icsMethod.'; charset=UTF-8');
}
$m->send();
return [true, null];
} catch (Throwable $e) {
error_log('PHPMailer changer_statut (cabinet) error: '.$e->getMessage().($m->ErrorInfo ? ' | '.$m->ErrorInfo : ''));
// retry sans ICS
if ($icsName && $icsContent !== null && $icsMethod) {
try { $m->clearAttachments(); $m->send(); return [true, 'sent_without_ics']; }
catch(Throwable $e2){ error_log('PHPMailer fallback: '.$e2->getMessage()); }
}
return [false, $e->getMessage()];
}
}
// ===== Badge helpers =====
function get_compte_id(PDO $pdo, string $role, int $reference_id): int {
$st = $pdo->prepare("SELECT id FROM compte WHERE role = ? AND reference_id = ? LIMIT 1");
$st->execute([$role, $reference_id]);
return (int)($st->fetchColumn() ?: 0);
}
function badge_rdv_inc(PDO $pdo, int $compte_id, int $delta = 1): void {
if ($compte_id <= 0) return;
$pdo->prepare("
INSERT INTO push_badge_counters (compte_id, msg_unread, rdv_unread, updated_at)
VALUES (:cid, 0, :d, NOW())
ON DUPLICATE KEY UPDATE
rdv_unread = LEAST(99, GREATEST(0, rdv_unread + :d)),
updated_at = NOW()
")->execute([':cid' => $compte_id, ':d' => $delta]);
}
// =================== Contrôles d’entrée ===================
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') out(false, 'Méthode invalide.');
$compteId = (int)($_SESSION['compte_id'] ?? 0);
$userRole = strtolower((string)($_SESSION['role'] ?? ''));
$praticien_id = (int)($_POST['praticien_id'] ?? 0); // <<< IMPORTANT côté secrétaire
$id = (int)($_POST['id'] ?? 0);
$statut = (string)($_POST['statut'] ?? '');
$whitelist = ['prévu','confirmé','annulé','terminé'];
if ($userRole !== 'secretaire' || $compteId<=0 || $praticien_id<=0 || $id<=0 || !in_array($statut, $whitelist, true)) {
out(false, $translations['erreur_mise_a_jour'] ?? 'Erreur de mise à jour.');
}
// =================== Vérif pivot secrétaire ===================
$st = $pdo->prepare("
SELECT can_manage_rdv
FROM secretaire_praticien
WHERE secretaire_user_id = :u
AND praticien_id = :p
AND is_active = 1
LIMIT 1
");
$st->execute(['u'=>$compteId,'p'=>$praticien_id]);
$pivot = $st->fetch(PDO::FETCH_ASSOC);
if (!$pivot || (int)$pivot['can_manage_rdv'] !== 1) {
out(false, 'Forbidden');
}
// =================== Update DB sécurisé ===================
try {
// =========================
// SNAPSHOT AVANT UPDATE (secrétaire)
// =========================
$beforeStmt = $pdo->prepare("
SELECT statut, date_rdv, duree_minutes, lieu, praticien_id, client_id
FROM rendez_vous
WHERE id = :id AND praticien_id = :pid
LIMIT 1
");
$beforeStmt->execute([':id'=>$id, ':pid'=>$praticien_id]);
$beforeRow = $beforeStmt->fetch(PDO::FETCH_ASSOC);
if (!$beforeRow) {
out(false, $translations['erreur_mise_a_jour'] ?? 'Erreur de mise à jour.');
}
// bornage au praticien cible
$upd = $pdo->prepare("UPDATE rendez_vous SET statut = :s WHERE id = :id AND praticien_id = :pid");
$upd->execute([':s'=>$statut, ':id'=>$id, ':pid'=>$praticien_id]);
if ($upd->rowCount() === 0) {
out(false, $translations['aucune_modification'] ?? 'Aucune modification.');
}
// =========================
// LOG RDV (rdv_actions_log) : update_status (secrétaire)
// =========================
$beforeJson = json_encode([
'statut' => $beforeRow['statut'],
'date_rdv' => $beforeRow['date_rdv'],
'duree_minutes' => (int)$beforeRow['duree_minutes'],
'lieu' => $beforeRow['lieu'],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$afterJson = json_encode([
'statut' => $statut,
'date_rdv' => $beforeRow['date_rdv'], // date inchangée ici
'duree_minutes' => (int)$beforeRow['duree_minutes'], // idem
'lieu' => $beforeRow['lieu'],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$logStmt = $pdo->prepare("
INSERT INTO rdv_actions_log
(rdv_id, action, by_user_id, by_role, before_json, after_json, created_at)
VALUES
(?, 'update_status', ?, 'secretaire', ?, ?, NOW())
");
$logStmt->execute([(int)$id, (int)$compteId, $beforeJson, $afterJson]);
// Récup info RDV (+ lieu + adresse praticien)
$q = $pdo->prepare("
SELECT rv.id, rv.client_id, rv.date_rdv, rv.duree_minutes, rv.uuid_ical, rv.lieu,
p.email AS email_praticien, p.prenom AS prenom_praticien, p.nom AS nom_praticien,
p.adresse AS p_adresse, p.ville AS p_ville, p.code_postal AS p_cp,
c.email AS email_client, c.prenom AS prenom_client, c.nom AS nom_client
FROM rendez_vous rv
JOIN praticien p ON rv.praticien_id = p.id
JOIN client c ON rv.client_id = c.id
WHERE rv.id = :id AND rv.praticien_id = :pid
");
$q->execute([':id'=>$id, ':pid'=>$praticien_id]);
$rdv = $q->fetch(PDO::FETCH_ASSOC);
if (!$rdv) out(false, $translations['erreur_mise_a_jour'] ?? 'Erreur de mise à jour.');
// ✅ compte_id pour badges
$clientCompteId = get_compte_id($pdo, 'client', (int)$rdv['client_id']);
$praticienCompteId = get_compte_id($pdo, 'praticien', (int)$praticien_id);
// Données communes
$tzStr='Europe/Paris'; $tz=new DateTimeZone($tzStr);
$start=new DateTime($rdv['date_rdv'], $tz);
$end=(clone $start)->modify('+'.(int)($rdv['duree_minutes'] ?? 60).' minutes');
$when=$start->format('d/m/Y H:i');
$when_a=$start->format('d/m/Y') . ' ' . ($translations['a'] ?? 'в') . ' ' . $start->format('H:i');
$cliFull = trim(($rdv['prenom_client'] ?? '').' '.($rdv['nom_client'] ?? ''));
$pratFull = trim(($rdv['prenom_praticien'] ?? '').' '.($rdv['nom_praticien'] ?? ''));
// ====== FORMAT (online/offline + adresse) ======
$lieu = strtolower(trim((string)($rdv['lieu'] ?? '')));
$addrParts = array_filter([
trim((string)($rdv['p_adresse'] ?? '')),
trim((string)($rdv['p_cp'] ?? '') . ' ' . (string)($rdv['p_ville'] ?? ''))
], fn($x) => $x !== '' && $x !== ' ');
$addr = trim(implode(', ', $addrParts));
$formatLabel = $translations['format_label'] ?? 'Формат';
$onlineLabel = $translations['option_online'] ?? 'Онлайн';
$cabinetLabel = $translations['option_cabinet'] ?? 'Очно';
$addressLabel = $translations['adresse_label'] ?? 'Адрес';
$formatLine = '';
if ($lieu === 'online') {
$formatLine = "\n\n{$formatLabel}: {$onlineLabel}";
} elseif ($lieu === 'offline') {
$formatLine = "\n\n{$formatLabel}: {$cabinetLabel}" . ($addr ? "\n{$addressLabel}: {$addr}" : '');
}
// LOCATION pour ICS
if ($lieu === 'online') {
$icsLocation = $onlineLabel;
} elseif ($lieu === 'offline') {
$icsLocation = $addr !== '' ? $addr : $cabinetLabel;
} else {
$icsLocation = '';
}
$cta_client_url='https://uslugi.fr/public/client/mes_rendez_vous.php';
$cta_prat_url ='https://uslugi.fr/public/praticien/agenda.php';
$cta_label =$translations['consultez_espace_pour_details'] ?? 'Voir les détails';
$statutLabelMap=[
'confirmé'=>$translations['statut_confirme'] ?? 'Confirmé',
'annulé' =>$translations['statut_annule'] ?? 'Annulé',
'prévu' =>$translations['statut_prevu'] ?? 'Prévu',
'terminé' =>$translations['statut_termine'] ?? 'Terminé',
];
$statutLibelle=$statutLabelMap[$statut] ?? $statut;
$warnings=[];
// ===== HOOK SUIVI CLIENT — création/MAJ dossier + entrée auto (sur confirmation) =====
if ($statut === 'confirmé') {
// =========================
// PUSH RDV — confirmé (par secrétaire)
// =========================
try {
// 🔔 Praticien : confirmé par secrétaire
push_rdv_to_praticien(
$pdo,
(int)$praticien_id,
$translations['push_rdv_confirmed_praticien_title'] ?? 'Запись подтверждена',
$translations['push_rdv_confirmed_praticien_body'] ?? 'Секретарь подтвердил(а) запись.',
'/public/praticien/agenda.php',
[
'rdv_id' => (int)$rdv['id'],
'action' => 'confirm',
'from_role' => 'secretaire',
'secretaire_id' => (int)$compteId,
'client_id' => (int)$rdv['client_id'],
'praticien_id' => (int)$praticien_id,
'date_rdv' => $rdv['date_rdv'],
]
);
// ✅ badges
badge_rdv_inc($pdo, $clientCompteId, 1);
badge_rdv_inc($pdo, $praticienCompteId, 1);
push_rdv_to_client(
$pdo,
(int)$rdv['client_id'],
$translations['push_rdv_confirmed_title'] ?? 'Запись подтверждена',
$translations['push_rdv_confirmed_body'] ?? 'Ваша запись подтверждена администратором.',
'/public/client/mes_rendez_vous.php',
[
'rdv_id' => (int)$rdv['id'],
'action' => 'confirm',
'from_role' => 'secretaire',
'praticien_id' => (int)$praticien_id,
'date_rdv' => $rdv['date_rdv'],
]
);
} catch (Throwable $e) {
error_log('push rdv confirm (secretaire) failed: '.$e->getMessage());
}
try {
$pdo->beginTransaction();
// Upsert dossier praticien↔client
$upsert = $pdo->prepare("
INSERT INTO client_dossiers (praticien_id, client_id, first_rdv_id)
VALUES (:p, :c, :r)
ON DUPLICATE KEY UPDATE updated_at = CURRENT_TIMESTAMP
");
$upsert->execute([
':p' => (int)$praticien_id,
':c' => (int)$rdv['client_id'],
':r' => (int)$rdv['id']
]);
// Récup id dossier (si existed déjà, lastInsertId = 0)
$dossierId = (int)$pdo->lastInsertId();
if ($dossierId === 0) {
$getId = $pdo->prepare("SELECT id FROM client_dossiers WHERE praticien_id=:p AND client_id=:c LIMIT 1");
$getId->execute([':p'=>(int)$praticien_id, ':c'=>(int)$rdv['client_id']]);
$dossierId = (int)$getId->fetchColumn();
}
// Éviter doublon côté suivi
$exists = $pdo->prepare("SELECT 1 FROM client_suivi WHERE dossier_id=:d AND rdv_id=:r LIMIT 1");
$exists->execute([':d'=>$dossierId, ':r'=>(int)$rdv['id']]);
if (!$exists->fetchColumn()) {
$type = ($lieu === 'online') ? 'online' : (($lieu === 'offline') ? 'cabinet' : 'autre');
$ins = $pdo->prepare("
INSERT INTO client_suivi (dossier_id, rdv_id, date_rdv, type_rdv, titre, notes, prive, duree_minutes)
VALUES (:d, :r, :dt, :ty, :ti, :no, 1, :du)
");
$ins->execute([
':d' => $dossierId,
':r' => (int)$rdv['id'],
':dt' => $start->format('Y-m-d H:i:s'),
':ty' => $type,
':ti' => $translations['suivi_rdv_confirme_titre'] ?? 'RDV confirmé',
':no' => ($translations['suivi_rdv_confirme_note'] ?? 'Créé automatiquement suite à la confirmation du rendez-vous.'),
':du' => (int)($rdv['duree_minutes'] ?? 60),
]);
}
$pdo->commit();
} catch (Throwable $ex) {
if ($pdo->inTransaction()) $pdo->rollBack();
error_log('Suivi auto (confirmé/cabinet) failed: '.$ex->getMessage());
// on continue quand même (mails, etc.)
}
// E-mails confirmation
$d_c = [
'hello_line'=>sprintf($translations['mail_valid_bonjour_nom'] ?? 'Bonjour %s',$cliFull),
'intro'=>($translations['votre_rdv_confirme'] ?? 'Votre rendez-vous est confirmé').' '.
($translations['email_avec'] ?? 'avec').' '.$pratFull.'.',
'who_label'=>$translations['praticien_label'] ?? 'Praticien',
'who_value'=>$pratFull,
'date_label'=>$translations['date_label'] ?? 'Date',
'when'=>$when_a,
'statut_label'=>$translations['statut_label'] ?? 'Statut',
'statut_value'=>$statutLibelle,
'cta_url'=>$cta_client_url,
'cta_label'=>$cta_label,
'footer'=>$translations['merci_simple'] ?? 'Merci.',
];
if ($formatLine !== '') $d_c['intro'] .= $formatLine;
$icsC = !empty($rdv['uuid_ical'])
? ics_build($rdv['uuid_ical'],$start,$end,$tzStr,
($translations['rdv_avec'] ?? 'Rendez-vous avec').' '.$pratFull,
'', $icsLocation,
[['cn'=>$pratFull,'email'=>$rdv['email_praticien']],['cn'=>$cliFull,'email'=>$rdv['email_client']]],
'REQUEST','CONFIRMED')
: null;
[$okC,$errC] = smtp_send(
$rdv['email_client'],
$translations['email_confirmation_rdv_sujet'] ?? 'Confirmation de rendez-vous',
email_text_status($d_c,$translations),
email_html_status($d_c,$translations),
$icsC?('rdv_'.$rdv['uuid_ical'].'_client.ics'):null,
$icsC,
$icsC?'REQUEST':null
);
if (!$okC) $warnings[]='mail_client';
$d_p = [
'hello_line'=>($translations['email_bonjour_dr'] ?? 'Bonjour').' '.$pratFull,
'intro'=>$translations['nouveau_rdv_pris_avec'] ?? 'Un nouveau rendez-vous a été confirmé.',
'who_label'=>$translations['client_label'] ?? 'Client',
'who_value'=>$cliFull,
'date_label'=>$translations['date_label'] ?? 'Date',
'when'=>$when_a,
'statut_label'=>$translations['statut_label'] ?? 'Statut',
'statut_value'=>$statutLibelle,
'cta_url'=>$cta_prat_url,
'cta_label'=>$cta_label,
'footer'=>$translations['merci_simple'] ?? 'Merci.',
];
if ($formatLine !== '') $d_p['intro'] .= $formatLine;
$icsP = !empty($rdv['uuid_ical'])
? ics_build($rdv['uuid_ical'],$start,$end,$tzStr,
($translations['rdv_avec'] ?? 'Rendez-vous avec').' '.$cliFull,
'', $icsLocation,
[['cn'=>$cliFull,'email'=>$rdv['email_client']],['cn'=>$pratFull,'email'=>$rdv['email_praticien']]],
'REQUEST','CONFIRMED')
: null;
[$okP,$errP] = smtp_send(
$rdv['email_praticien'],
($translations['rdv_confirme_pour'] ?? 'RDV confirmé pour').' '.$cliFull,
email_text_status($d_p,$translations),
email_html_status($d_p,$translations),
$icsP?('rdv_'.$rdv['uuid_ical'].'_praticien.ics'):null,
$icsP,
$icsP?'REQUEST':null
);
if (!$okP) $warnings[]='mail_praticien';
}
if ($statut === 'annulé') {
// =========================
// PUSH RDV — annulé (par secrétaire)
// =========================
try {
// 🔔 Praticien : annulé par secrétaire
push_rdv_to_praticien(
$pdo,
(int)$praticien_id,
$translations['push_rdv_cancel_praticien_title'] ?? 'Запись отменена',
$translations['push_rdv_cancel_praticien_body'] ?? 'Секретарь отменил(а) запись.',
'/public/praticien/rendez_vous.php',
[
'rdv_id' => (int)$rdv['id'],
'action' => 'cancel',
'from_role' => 'secretaire',
'secretaire_id' => (int)$compteId,
'client_id' => (int)$rdv['client_id'],
'praticien_id' => (int)$praticien_id,
'date_rdv' => $rdv['date_rdv'],
]
);
// ✅ badges
badge_rdv_inc($pdo, $clientCompteId, 1);
badge_rdv_inc($pdo, $praticienCompteId, 1);
push_rdv_to_client(
$pdo,
(int)$rdv['client_id'],
$translations['push_rdv_cancel_secretaire_title'] ?? 'Запись отменена',
$translations['push_rdv_cancel_secretaire_body'] ?? 'Администратор отменил запись.',
'/public/client/mes_rendez_vous.php',
[
'rdv_id' => (int)$rdv['id'],
'action' => 'cancel',
'from_role' => 'secretaire',
'praticien_id' => (int)$praticien_id,
'date_rdv' => $rdv['date_rdv'],
]
);
} catch (Throwable $e) {
error_log('push rdv cancel (secretaire) failed: '.$e->getMessage());
}
$summary = ($translations['rdv_avec'] ?? 'Rendez-vous avec').' '.$pratFull;
$attBoth = [
['cn'=>$cliFull,'email'=>$rdv['email_client']],
['cn'=>$pratFull,'email'=>$rdv['email_praticien']]
];
$ics = !empty($rdv['uuid_ical'])
? ics_build($rdv['uuid_ical'],$start,$end,$tzStr,$summary,'',$icsLocation, $attBoth,'CANCEL','CANCELLED')
: null;
$d_c = [
'hello_line'=>sprintf($translations['mail_valid_bonjour_nom'] ?? 'Bonjour %s',$cliFull),
'intro'=>($translations['email_votre_rdv_du'] ?? 'Votre rendez-vous du').' '.$when_a.' '.
($translations['email_avec'] ?? 'avec').' '.$pratFull.' '.
($translations['email_a_ete_annule'] ?? 'a été annulé.'),
'who_label'=>$translations['praticien_label'] ?? 'Praticien',
'who_value'=>$pratFull,
'date_label'=>$translations['date_label'] ?? 'Date',
'when'=>$when_a,
'statut_label'=>$translations['statut_label'] ?? 'Statut',
'statut_value'=>$statutLibelle,
'cta_url'=>'https://uslugi.fr/public/client/mes_rendez_vous.php',
'cta_label'=>$translations['consultez_espace_pour_details'] ?? 'Voir les détails',
'footer'=>$translations['merci_simple'] ?? 'Merci.',
];
[$okC,$errC] = smtp_send(
$rdv['email_client'],
$translations['email_annulation_client_sujet'] ?? 'Confirmation d’annulation de votre rendez-vous',
email_text_status($d_c,$translations),
email_html_status($d_c,$translations),
$ics?('rdv_cancel_'.$rdv['uuid_ical'].'_client.ics'):null,
$ics,
$ics?'CANCEL':null
);
if (!$okC) $warnings[]='mail_client';
$d_p = [
'hello_line'=>($translations['email_bonjour_dr'] ?? 'Bonjour').' '.$pratFull,
'intro'=>($translations['email_rdv_du'] ?? 'Le rendez-vous du').' '.$when_a.' '.
($translations['email_avec'] ?? 'avec').' '.$cliFull.' '.
($translations['email_annule_par_praticien'] ?? 'a été annulé.'),
'who_label'=>$translations['client_label'] ?? 'Client',
'who_value'=>$cliFull,
'date_label'=>$translations['date_label'] ?? 'Date',
'when'=>$when_a,
'statut_label'=>$translations['statut_label'] ?? 'Statut',
'statut_value'=>$statutLibelle,
'cta_url'=>'https://uslugi.fr/public/praticien/agenda.php',
'cta_label'=>$translations['consultez_espace_pour_details'] ?? 'Voir les détails',
'footer'=>$translations['merci_simple'] ?? 'Merci.',
];
[$okP,$errP] = smtp_send(
$rdv['email_praticien'],
$translations['email_annulation_sujet'] ?? 'Rendez-vous annulé',
email_text_status($d_p,$translations),
email_html_status($d_p,$translations),
$ics?('rdv_cancel_'.$rdv['uuid_ical'].'_praticien.ics'):null,
$ics,
$ics?'CANCEL':null
);
if (!$okP) $warnings[]='mail_praticien';
}
if ($statut === 'terminé') {
// =========================
// PUSH RDV — terminé (par secrétaire)
// =========================
try {
// 🔔 Praticien : marqué terminé par secrétaire
push_rdv_to_praticien(
$pdo,
(int)$praticien_id,
$translations['push_rdv_done_praticien_title'] ?? 'Приём завершён',
$translations['push_rdv_done_praticien_body'] ?? 'Секретарь отметил(а) приём как завершённый.',
'/public/praticien/agenda.php',
[
'rdv_id' => (int)$rdv['id'],
'action' => 'done',
'from_role' => 'secretaire',
'secretaire_id' => (int)$compteId,
'client_id' => (int)$rdv['client_id'],
'praticien_id' => (int)$praticien_id,
'date_rdv' => $rdv['date_rdv'],
]
);
// ✅ badges
badge_rdv_inc($pdo, $clientCompteId, 1);
badge_rdv_inc($pdo, $praticienCompteId, 1);
push_rdv_to_client(
$pdo,
(int)$rdv['client_id'],
$translations['push_rdv_done_title'] ?? 'Приём завершён',
$translations['push_rdv_done_body'] ?? 'Ваш визит завершён.',
'/public/client/mes_rendez_vous.php',
[
'rdv_id' => (int)$rdv['id'],
'action' => 'done',
'from_role' => 'secretaire',
'praticien_id' => (int)$praticien_id,
]
);
} catch (Throwable $e) {
error_log('push rdv done (secretaire) failed: '.$e->getMessage());
}
$d_c = [
'hello_line'=>sprintf($translations['mail_valid_bonjour_nom'] ?? 'Bonjour %s',$cliFull),
'intro'=>$translations['email_rdv_termine_message_client'] ?? 'Votre rendez-vous est marqué comme terminé. Merci pour votre confiance.',
'who_label'=>$translations['praticien_label'] ?? 'Praticien',
'who_value'=>$pratFull,
'date_label'=>$translations['date_label'] ?? 'Date',
'when'=>$when_a,
'statut_label'=>$translations['statut_label'] ?? 'Statut',
'statut_value'=>$statutLibelle,
'cta_url' => 'https://uslugi.fr/public/client/mes_rendez_vous.php',
'cta_label'=>$translations['avis_suite_consultation'] ?? 'Voir mes rendez-vous',
'footer'=>$translations['merci_simple'] ?? 'Merci.',
];
[$okC,$errC] = smtp_send(
$rdv['email_client'],
$translations['email_rdv_termine_client_sujet'] ?? 'Merci de votre visite',
email_text_status($d_c,$translations),
email_html_status($d_c,$translations)
);
if (!$okC) $warnings[]='mail_client';
$d_p = [
'hello_line'=>($translations['email_bonjour_dr'] ?? 'Bonjour').' '.$pratFull,
'intro'=>$translations['email_rdv_termine_message_praticien'] ?? 'Vous avez marqué ce rendez-vous comme terminé.',
'who_label'=>$translations['client_label'] ?? 'Client',
'who_value'=>$cliFull,
'date_label'=>$translations['date_label'] ?? 'Date',
'when'=>$when_a,
'statut_label'=>$translations['statut_label'] ?? 'Statut',
'statut_value'=>$statutLibelle,
'cta_url'=>'https://uslugi.fr/public/praticien/agenda.php',
'cta_label'=>$translations['consultez_espace_pour_details'] ?? 'Voir les détails',
'footer'=>$translations['merci_simple'] ?? 'Merci.',
];
[$okP,$errP] = smtp_send(
$rdv['email_praticien'],
$translations['email_rdv_termine_praticien_sujet'] ?? 'Rendez-vous marqué terminé',
email_text_status($d_p,$translations),
email_html_status($d_p,$translations)
);
if (!$okP) $warnings[]='mail_praticien';
}
$msg = $translations['statut_mis_a_jour'] ?? 'Statut mis à jour.';
if ($warnings) $msg .= ' (mail: '.implode(',',$warnings).')';
out(true, $msg, ['status'=>$statut]);
} catch (Throwable $e) {
error_log('changer_statut (cabinet) fatal: '.$e->getMessage());
out(false, $translations['erreur_mise_a_jour'] ?? 'Erreur de mise à jour.');
}
+97
View File
@@ -0,0 +1,97 @@
<?php
require_once __DIR__ . '/../includes/init_secretaire.php';
if (!function_exists('h')) { function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); } }
$sec_uid = (int)($_SESSION['compte_id'] ?? 0);
if ($sec_uid<=0) { header('Location:/index.php'); exit; }
// Liste praticiens
$q = $pdo->prepare("
SELECT sp.id as spid, sp.praticien_id, sp.can_manage_rdv, sp.can_edit_slots, sp.is_active,
p.nom, p.prenom, p.specialite, p.ville
FROM secretaire_praticien sp
JOIN praticien p ON p.id = sp.praticien_id
WHERE sp.secretaire_user_id = :u AND sp.is_active=1
ORDER BY p.nom, p.prenom
");
$q->execute(['u'=>$sec_uid]);
$rows = $q->fetchAll(PDO::FETCH_ASSOC);
// Compteurs simples Today/Tomorrow par praticien
$today = (new DateTime('today'))->format('Y-m-d 00:00:00');
$tomorrow = (new DateTime('tomorrow'))->format('Y-m-d 00:00:00');
$counts = [];
if ($rows) {
$ids = array_column($rows,'praticien_id');
$in = implode(',', array_fill(0, count($ids), '?'));
$s = $pdo->prepare("
SELECT praticien_id,
SUM(date_rdv >= ? AND date_rdv < ?) AS today_count,
SUM(date_rdv >= ? AND date_rdv < DATE_ADD(?, INTERVAL 1 DAY)) AS tomorrow_count
FROM rendez_vous
WHERE praticien_id IN ($in) AND statut IN ('prévu','confirmé')
GROUP BY praticien_id
");
$params = [$today,(new DateTime('tomorrow'))->format('Y-m-d 00:00:00'), (new DateTime('tomorrow'))->format('Y-m-d 00:00:00'), (new DateTime('tomorrow'))->format('Y-m-d 00:00:00')];
$s->execute(array_merge($params, $ids));
foreach($s->fetchAll(PDO::FETCH_ASSOC) as $r){ $counts[(int)$r['praticien_id']] = $r; }
}
require_once __DIR__ . '/../includes/header_cabinet.php';
?>
<div class="main1">
<div class="shadow p-4 rounded bg-light">
<h1 class="h4 mb-3"><?= h($translations['sec_dashboard_titre'] ?? 'Secrétariat — Tableau de bord') ?></h1>
<?php if(!$rows): ?>
<div class="alert alert-info"><?= h($translations['sec_aucun_praticien'] ?? "Aucun praticien lié pour l’instant.") ?></div>
<?php else: ?>
<div class="row g-4">
<?php foreach($rows as $r):
$pid = (int)$r['praticien_id'];
$c = $counts[$pid] ?? ['today_count'=>0,'tomorrow_count'=>0];
?>
<div class="col-12 col-md-6 col-lg-6">
<div class="card h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<div class="fw-semibold"><?= h($r['prenom'].' '.$r['nom']) ?></div>
<div class="text-muted small"><?= h(trim(($r['specialite']??'').' · '.($r['ville']??''), ' · ')) ?></div>
</div>
<span class="badge bg-secondary"><?= h($translations['sec_actif'] ?? 'Actif') ?></span>
</div>
<div class="d-flex gap-3 mb-3">
<div><span class="fw-bold"><?= (int)$c['today_count'] ?></span> <span class="text-muted small"><?= h($translations['sec_aujourdhui'] ?? 'aujourd’hui') ?></span></div>
<div><span class="fw-bold"><?= (int)$c['tomorrow_count'] ?></span> <span class="text-muted small"><?= h($translations['sec_demain'] ?? 'demain') ?></span></div>
</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-primary btn-sm" href="agenda.php?praticien_id=<?= $pid ?>"><?= h($translations['choisir'] ?? 'Choisir') ?></a>
<?php if ($r['can_manage_rdv']): ?>
<!--<a class="btn btn-outline-secondary btn-sm" href="agenda.php?praticien_id=<?= $pid ?>"><?= h($translations['choisir'] ?? 'Choisir') ?></a>
<a class="btn btn-outline-secondary btn-sm" href="rendez_vous_du_jour.php?praticien_id=<?= $pid ?>"><?= h($translations['sec_btn_rdv'] ?? 'Rendez-vous') ?></a>
<a class="btn btn-outline-secondary btn-sm" href="rendez_vous.php?praticien_id=<?= $pid ?>"><?= h($translations['sec_btn_rdv_jour'] ?? 'Les rendez-vous') ?></a>
<a class="btn btn-outline-secondary btn-sm" href="reserver_rdv.php?praticien_id=<?= $pid ?>"><?= h($translations['sec_btn_prendre'] ?? 'Prendre un RDV') ?></a>-->
<?php endif; ?>
<?php if ($r['can_edit_slots']): ?>
<!--<a class="btn btn-outline-secondary btn-sm" href="disponibilites.php?praticien_id=<?= $pid ?>"><?= h($translations['sec_btn_dispos'] ?? 'Disponibilités') ?></a>-->
<?php endif; ?>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
<footer class="site-footer">
<div class="container">
<p><?= $translations['copyr'] ?? '&copy; '.date('Y') ?></p>
</div>
</footer>
+269
View File
@@ -0,0 +1,269 @@
<?php
// /public/cabinet/disponibilites.php (VERSION SECRÉTAIRE)
require_once __DIR__ . '/../includes/init_secretaire.php';
// --- Helpers
if (!function_exists('h')) { function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); } }
// --- Contexte praticien obligatoire (depuis le lien du dashboard)
$compteId = (int)($_SESSION['compte_id'] ?? 0);
$userRole = strtolower((string)($_SESSION['role'] ?? ''));
$praticien_id = isset($_GET['praticien_id']) ? (int)$_GET['praticien_id'] : 0;
if ($praticien_id <= 0 || $userRole !== 'secretaire') { http_response_code(403); exit('Forbidden'); }
// --- Vérifier pivot + droit can_edit_slots
$st = $pdo->prepare("
SELECT can_edit_slots
FROM secretaire_praticien
WHERE secretaire_user_id = :u
AND praticien_id = :p
AND is_active = 1
LIMIT 1
");
$st->execute(['u'=>$compteId,'p'=>$praticien_id]);
$pivot = $st->fetch(PDO::FETCH_ASSOC);
if (!$pivot || (int)$pivot['can_edit_slots'] !== 1) { http_response_code(403); exit('Forbidden'); }
// ---- Ajouter un créneau (POST)
$erreur = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['jour'], $_POST['heure_debut'], $_POST['heure_fin'])) {
$jour = trim((string)$_POST['jour']);
$debut = trim((string)$_POST['heure_debut']);
$fin = trim((string)$_POST['heure_fin']);
// mini validation
$joursValides = ['lundi','mardi','mercredi','jeudi','vendredi','samedi','dimanche'];
if (!in_array($jour, $joursValides, true)) {
$erreur = $translations['erreur_jour_invalide'] ?? "Jour invalide.";
} elseif (!preg_match('#^\d{2}:\d{2}$#', $debut) || !preg_match('#^\d{2}:\d{2}$#', $fin)) {
$erreur = $translations['erreur_format_heure'] ?? "Format d’heure invalide.";
} elseif ($debut >= $fin) {
$erreur = $translations['erreur_debut_avant_fin'] ?? "Heure de début après l’heure de fin.";
}
if (!$erreur) {
$stmt = $pdo->prepare(
"INSERT INTO disponibilite (praticien_id, jour_semaine, heure_debut, heure_fin)
VALUES (:p, :j, :hd, :hf)"
);
$stmt->execute([
'p'=>$praticien_id,
'j'=>$jour,
'hd'=>$debut,
'hf'=>$fin
]);
header('Location: /public/cabinet/disponibilites.php?praticien_id='.$praticien_id.'&ok=added', true, 303);
exit;
}
}
// ---- Supprimer un créneau (GET)
// (idéalement: POST + CSRF, on garde GET pour coller à l’original, avec une vérif d’appartenance)
if (isset($_GET['supprimer'])) {
$id = (int)$_GET['supprimer'];
// Vérifier que ce slot appartient bien à ce praticien
$own = $pdo->prepare("SELECT 1 FROM disponibilite WHERE id = :id AND praticien_id = :p LIMIT 1");
$own->execute(['id'=>$id,'p'=>$praticien_id]);
if ($own->fetchColumn()) {
$stmt = $pdo->prepare("DELETE FROM disponibilite WHERE id = :id AND praticien_id = :p");
$stmt->execute(['id'=>$id,'p'=>$praticien_id]);
header('Location: /public/cabinet/disponibilites.php?praticien_id='.$praticien_id.'&ok=deleted', true, 303);
exit;
} else {
http_response_code(403); exit('Forbidden');
}
}
// ---- Récupérer les créneaux
$stmt = $pdo->prepare(
"SELECT * FROM disponibilite
WHERE praticien_id = ?
ORDER BY FIELD(jour_semaine,'lundi','mardi','mercredi','jeudi','vendredi','samedi','dimanche'), heure_debut"
);
$stmt->execute([$praticien_id]);
$dispos = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Prépare les traductions de jours
$jourKeyMap = [
'lundi'=>'jour_lundi','mardi'=>'jour_mardi','mercredi'=>'jour_mercredi',
'jeudi'=>'jour_jeudi','vendredi'=>'jour_vendredi','samedi'=>'jour_samedi','dimanche'=>'jour_dimanche',
];
$PAGE_TITRE = $translations['titre_disponibilites_date'] ?? 'Disponibilités (dates)';
$HEAD_EXTRA = <<<HTML
<script src="/js/sweetalert2@11.js"></script>
HTML;
require_once __DIR__ . '/../includes/header_cabinet.php';
?>
<div class="main1">
<div class="container py-5">
<div class="shadow p-4 rounded bg-light">
<h2 class="mb-4 text-center"><?= htmlspecialchars($currentPraticienName, ENT_QUOTES, 'UTF-8') ?> - <?= h($translations['sloti'] ?? 'Mes disponibilités') ?></h2>
<div class="text-end">
<a href="/public/cabinet/disponibilites_date.php?praticien_id=<?= (int)$praticien_id ?>" class="btn btn-outline-secondary mb-4">
<?= htmlspecialchars($translations['btn_ajouter_par_date'] ?? 'Ajouter par date') ?>
</a>
</div>
<div class="table-responsive mb-4">
<table class="table table-bordered table-striped align-middle text-center mobile-cards" id="table_rdv">
<thead class="table-light">
<tr>
<th><?= h($translations['table_jour'] ?? 'Jour') ?></th>
<th><?= h($translations['table_debut'] ?? 'Début') ?></th>
<th><?= h($translations['table_fin'] ?? 'Fin') ?></th>
<th><?= h($translations['table_action'] ?? 'Action') ?></th>
</tr>
</thead>
<tbody>
<?php if ($dispos): foreach ($dispos as $d): ?>
<tr>
<td>
<?php
$key = $jourKeyMap[$d['jour_semaine']] ?? null;
echo h($key ? ($translations[$key] ?? ucfirst($d['jour_semaine'])) : ucfirst($d['jour_semaine']));
?>
</td>
<td><?= h(substr((string)$d['heure_debut'], 0, 5)) ?></td>
<td><?= h(substr((string)$d['heure_fin'], 0, 5)) ?></td>
<td>
<button type="button"
class="btn btn-sm btn-danger js-del"
data-id="<?= (int)$d['id'] ?>">
<?= h($translations['btn_supprimer'] ?? 'Supprimer') ?>
</button>
</td>
</tr>
<?php endforeach; else: ?>
<tr><td colspan="4"><?= h($translations['aucun_creneau'] ?? 'Aucun créneau.') ?></td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<div class="card shadow-sm p-4">
<h4 class="mb-3 text-center"><?= h($translations['card_ajouter_creneau'] ?? 'Ajouter un créneau') ?></h4>
<form method="POST">
<input type="hidden" name="praticien_id" value="<?= (int)$praticien_id ?>">
<div class="mb-3">
<label class="form-label" for="jour"><?= h($translations['label_jour'] ?? 'Jour') ?></label>
<select name="jour" id="jour" class="form-select" required>
<option value=""><?= h($translations['option_choisir'] ?? '— Choisir —') ?></option>
<?php
$jours = [
'lundi' => $translations['jour_lundi'] ?? 'Lundi',
'mardi' => $translations['jour_mardi'] ?? 'Mardi',
'mercredi' => $translations['jour_mercredi'] ?? 'Mercredi',
'jeudi' => $translations['jour_jeudi'] ?? 'Jeudi',
'vendredi' => $translations['jour_vendredi'] ?? 'Vendredi',
'samedi' => $translations['jour_samedi'] ?? 'Samedi',
'dimanche' => $translations['jour_dimanche'] ?? 'Dimanche',
];
foreach ($jours as $val => $label):
?>
<option value="<?= h($val) ?>"><?= h($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="mb-3">
<label class="form-label" for="heure_debut"><?= h($translations['label_heure_debut'] ?? 'Heure de début') ?></label>
<input type="time" name="heure_debut" id="heure_debut" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label" for="heure_fin"><?= h($translations['label_heure_fin'] ?? 'Heure de fin') ?></label>
<input type="time" name="heure_fin" id="heure_fin" class="form-control" required>
</div>
<button type="submit" class="btn btn-primary w-100">
<?= h($translations['btn_ajouter'] ?? 'Ajouter') ?>
</button>
</form>
</div>
</div>
</div>
</div>
<script>
// --- SweetAlert2: messages succès / erreur depuis PHP ---
document.addEventListener('DOMContentLoaded', function(){
const okParam = '<?= isset($_GET['ok']) ? addslashes((string)$_GET['ok']) : '' ?>';
const erreurMsg = '<?= $erreur ? addslashes((string)$erreur) : '' ?>';
if (okParam === 'added') {
Swal.fire({
icon: 'success',
title: '<?= addslashes($translations["msg_creneau_ajoute"] ?? "Créneau ajouté.") ?>',
timer: 1800,
showConfirmButton: false
});
} else if (okParam === 'deleted') {
Swal.fire({
icon: 'success',
title: '<?= addslashes($translations["msg_creneau_supprime"] ?? "Créneau supprimé.") ?>',
timer: 1800,
showConfirmButton: false
});
}
if (erreurMsg) {
Swal.fire({
icon: 'error',
title: '<?= addslashes($translations["erreur_titre"] ?? "Erreur") ?>',
text: erreurMsg,
confirmButtonText: 'OK'
});
}
});
// --- Confirmation de suppression avec SweetAlert2 ---
document.addEventListener('click', async function(e){
const btn = e.target.closest('.js-del');
if (!btn) return;
const id = btn.dataset.id;
const result = await Swal.fire({
icon: 'warning',
title: '<?= addslashes($translations["confirm_title"] ?? "Confirmation") ?>',
text: '<?= addslashes($translations["confirm_supprimer_creneau"] ?? "Supprimer ce créneau ?") ?>',
showCancelButton: true,
confirmButtonText: '<?= addslashes($translations["btn_da_udalit"] ?? "Oui") ?>',
cancelButtonText: '<?= addslashes($translations["btn_annuler"] ?? "Annuler") ?>',
reverseButtons: true
});
if (result.isConfirmed) {
// GET comme dans la page d’origine, on garde le contexte praticien_id
const pid = '<?= (int)$praticien_id ?>';
window.location.href = '?praticien_id=' + encodeURIComponent(pid) + '&supprimer=' + encodeURIComponent(id);
}
});
</script>
<!--*********Adaptation des tables pour un affichage mobile***************-->
<script defer>
(function(){
function initMobileCards(tbl){
if (!tbl) return;
const heads = Array.from(tbl.querySelectorAll('thead th')).map(th => th.textContent.trim());
tbl.querySelectorAll('tbody tr').forEach(tr => {
Array.from(tr.children).forEach((td, i) => {
if (!td.hasAttribute('data-label')) td.setAttribute('data-label', heads[i] || '');
});
});
document.documentElement.classList.add('mc-ready');
}
function ready(fn){ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn); else fn(); }
ready(function(){ initMobileCards(document.getElementById('table_rdv')); });
})();
</script>
<footer class="site-footer">
<div class="container">
<p><?= $translations['copyr'] ?? '&copy; '.date('Y') ?></p>
</div>
</footer>
</body>
</html>
+252
View File
@@ -0,0 +1,252 @@
<?php
// /public/cabinet/disponibilites_date.php (VERSION SECRÉTAIRE)
require_once __DIR__ . '/../includes/init_secretaire.php';
if (!function_exists('h')) {
function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
}
// --- Contexte praticien obligatoire
$compteId = (int)($_SESSION['compte_id'] ?? 0);
$userRole = strtolower((string)($_SESSION['role'] ?? ''));
$praticien_id = isset($_GET['praticien_id']) ? (int)$_GET['praticien_id'] : 0;
if ($praticien_id <= 0 || $userRole !== 'secretaire') { http_response_code(403); exit('Forbidden'); }
// --- Vérifier pivot + droit can_edit_slots
$st = $pdo->prepare("
SELECT can_edit_slots
FROM secretaire_praticien
WHERE secretaire_user_id = :u
AND praticien_id = :p
AND is_active = 1
LIMIT 1
");
$st->execute(['u'=>$compteId,'p'=>$praticien_id]);
$pivot = $st->fetch(PDO::FETCH_ASSOC);
if (!$pivot || (int)$pivot['can_edit_slots'] !== 1) { http_response_code(403); exit('Forbidden'); }
// --- Optionnel: récupérer nom praticien pour titre
$st = $pdo->prepare("SELECT nom, prenom FROM praticien WHERE id = ? LIMIT 1");
$st->execute([$praticien_id]);
$prat = $st->fetch(PDO::FETCH_ASSOC);
$currentPraticienName = trim(($prat['prenom'] ?? '').' '.($prat['nom'] ?? ''));
$erreur = null;
// ---- Ajouter une plage datée (POST)
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['date_dispo'], $_POST['heure_debut'], $_POST['heure_fin'])) {
$date = trim((string)$_POST['date_dispo']); // YYYY-MM-DD
$debut = trim((string)$_POST['heure_debut']); // HH:MM
$fin = trim((string)$_POST['heure_fin']); // HH:MM
$tz = new DateTimeZone('Europe/Paris');
$today = (new DateTimeImmutable('now', $tz))->format('Y-m-d');
if (!preg_match('#^\d{4}-\d{2}-\d{2}$#', $date)) {
$erreur = $translations['erreur_format_date'] ?? "Format de date invalide.";
} elseif (!preg_match('#^\d{2}:\d{2}$#', $debut) || !preg_match('#^\d{2}:\d{2}$#', $fin)) {
$erreur = $translations['erreur_format_heure'] ?? "Format d’heure invalide.";
} elseif ($date < $today) {
$erreur = $translations['erreur_date_passee'] ?? "Date dans le passé.";
} elseif ($debut >= $fin) {
$erreur = $translations['erreur_debut_avant_fin'] ?? "Heure de début après l’heure de fin.";
}
if (!$erreur) {
try {
$stmt = $pdo->prepare("
INSERT INTO disponibilite_date (praticien_id, date_dispo, heure_debut, heure_fin)
VALUES (:p, :d, :hd, :hf)
");
$stmt->execute([
'p' => $praticien_id,
'd' => $date,
'hd' => $debut . ':00',
'hf' => $fin . ':00',
]);
header('Location: /public/cabinet/disponibilites_date.php?praticien_id='.$praticien_id.'&ok=added', true, 303);
exit;
} catch (PDOException $e) {
$erreur = $translations['erreur_enregistrement'] ?? "Impossible d’enregistrer (doublon ou erreur).";
}
}
}
// ---- Supprimer une plage datée (GET)
if (isset($_GET['supprimer'])) {
$id = (int)$_GET['supprimer'];
// Vérifier appartenance
$own = $pdo->prepare("SELECT 1 FROM disponibilite_date WHERE id = :id AND praticien_id = :p LIMIT 1");
$own->execute(['id'=>$id,'p'=>$praticien_id]);
if ($own->fetchColumn()) {
$stmt = $pdo->prepare("DELETE FROM disponibilite_date WHERE id = :id AND praticien_id = :p");
$stmt->execute(['id'=>$id,'p'=>$praticien_id]);
header('Location: /public/cabinet/disponibilites_date.php?praticien_id='.$praticien_id.'&ok=deleted', true, 303);
exit;
} else {
http_response_code(403); exit('Forbidden');
}
}
// ---- Liste
$stmt = $pdo->prepare("
SELECT id, date_dispo, heure_debut, heure_fin
FROM disponibilite_date
WHERE praticien_id = ?
ORDER BY date_dispo ASC, heure_debut ASC
");
$stmt->execute([$praticien_id]);
$dates = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Header cabinet
$PAGE_TITRE = $translations['titre_disponibilites_date'] ?? 'Disponibilités (dates)';
$HEAD_EXTRA = <<<HTML
<script src="/js/sweetalert2@11.js"></script>
HTML;
require_once __DIR__ . '/../includes/header_cabinet.php';
?>
<div class="main1">
<div class="container py-5">
<div class="shadow p-4 rounded bg-light">
<h2 class="mb-4 text-center">
<?= h($currentPraticienName ?: ($translations['h2_disponibilites_date'] ?? 'Disponibilités ponctuelles')) ?>
<?php if ($currentPraticienName): ?>
- <?= h($translations['h2_disponibilites_date'] ?? 'Disponibilités ponctuelles') ?>
<?php endif; ?>
</h2>
<div class="d-flex justify-content-between align-items-center mb-3">
<a class="btn btn-outline-secondary"
href="/public/cabinet/disponibilites.php?praticien_id=<?= (int)$praticien_id ?>">
<?= h($translations['retour_au_planning'] ?? 'Retour') ?>
</a>
</div>
<div class="table-responsive mb-4">
<table class="table table-bordered table-striped align-middle text-center mobile-cards" id="table_dispo_date">
<thead class="table-light">
<tr>
<th><?= h($translations['cl_date_rdv'] ?? 'Date') ?></th>
<th><?= h($translations['table_debut'] ?? 'Début') ?></th>
<th><?= h($translations['table_fin'] ?? 'Fin') ?></th>
<th><?= h($translations['table_action'] ?? 'Action') ?></th>
</tr>
</thead>
<tbody>
<?php if ($dates): foreach ($dates as $d): ?>
<tr>
<td><?= h(date('d/m/Y', strtotime((string)$d['date_dispo']))) ?></td>
<td><?= h(substr((string)$d['heure_debut'], 0, 5)) ?></td>
<td><?= h(substr((string)$d['heure_fin'], 0, 5)) ?></td>
<td>
<button type="button" class="btn btn-sm btn-danger js-del" data-id="<?= (int)$d['id'] ?>">
<?= h($translations['btn_supprimer'] ?? 'Supprimer') ?>
</button>
</td>
</tr>
<?php endforeach; else: ?>
<tr><td colspan="4"><?= h($translations['aucun_creneau'] ?? 'Aucun créneau.') ?></td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<div class="card shadow-sm p-4">
<h4 class="mb-3 text-center"><?= h($translations['card_ajouter_creneau_date'] ?? 'Ajouter une disponibilité ponctuelle') ?></h4>
<form method="POST">
<input type="hidden" name="praticien_id" value="<?= (int)$praticien_id ?>">
<div class="mb-3">
<label class="form-label" for="date_dispo"><?= h($translations['cl_date_rdv'] ?? 'Date') ?></label>
<input type="date" name="date_dispo" id="date_dispo" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label" for="heure_debut"><?= h($translations['label_heure_debut'] ?? 'Heure de début') ?></label>
<input type="time" name="heure_debut" id="heure_debut" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label" for="heure_fin"><?= h($translations['label_heure_fin'] ?? 'Heure de fin') ?></label>
<input type="time" name="heure_fin" id="heure_fin" class="form-control" required>
</div>
<button type="submit" class="btn btn-primary w-100">
<?= h($translations['btn_ajouter'] ?? 'Ajouter') ?>
</button>
</form>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function(){
const okParam = '<?= isset($_GET['ok']) ? addslashes((string)$_GET['ok']) : '' ?>';
const erreurMsg = '<?= $erreur ? addslashes((string)$erreur) : '' ?>';
if (okParam === 'added') {
Swal.fire({ icon:'success', title:'<?= addslashes($translations["msg_creneau_ajoute"] ?? "Créneau ajouté.") ?>', timer:1800, showConfirmButton:false });
} else if (okParam === 'deleted') {
Swal.fire({ icon:'success', title:'<?= addslashes($translations["msg_creneau_supprime"] ?? "Créneau supprimé.") ?>', timer:1800, showConfirmButton:false });
}
if (erreurMsg) {
Swal.fire({ icon:'error', title:'<?= addslashes($translations["erreur_titre"] ?? "Erreur") ?>', text:erreurMsg, confirmButtonText:'OK' });
}
});
document.addEventListener('click', async function(e){
const btn = e.target.closest('.js-del');
if (!btn) return;
const id = btn.dataset.id;
const result = await Swal.fire({
icon: 'warning',
title: '<?= addslashes($translations["confirm_title"] ?? "Confirmation") ?>',
text: '<?= addslashes($translations["confirm_supprimer_creneau"] ?? "Supprimer ce créneau ?") ?>',
showCancelButton: true,
confirmButtonText: '<?= addslashes($translations["btn_da_udalit"] ?? "Oui") ?>',
cancelButtonText: '<?= addslashes($translations["btn_annuler"] ?? "Annuler") ?>',
reverseButtons: true
});
if (result.isConfirmed) {
const pid = '<?= (int)$praticien_id ?>';
window.location.href = '?praticien_id=' + encodeURIComponent(pid) + '&supprimer=' + encodeURIComponent(id);
}
});
</script>
<script defer>
(function(){
function initMobileCards(tbl){
if (!tbl) return;
const heads = Array.from(tbl.querySelectorAll('thead th')).map(th => th.textContent.trim());
tbl.querySelectorAll('tbody tr').forEach(tr => {
Array.from(tr.children).forEach((td, i) => {
if (!td.hasAttribute('data-label')) td.setAttribute('data-label', heads[i] || '');
});
});
document.documentElement.classList.add('mc-ready');
}
function ready(fn){ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn); else fn(); }
ready(function(){ initMobileCards(document.getElementById('table_dispo_date')); });
})();
</script>
<footer class="site-footer">
<div class="container">
<p><?= $translations['copyr'] ?? '&copy; '.date('Y') ?></p>
</div>
</footer>
</body>
</html>
+83
View File
@@ -0,0 +1,83 @@
<?php
// /public/cabinet/events.php
require_once __DIR__ . '/../includes/init_secretaire.php';
// --- Inputs ---
$compteId = (int)($_SESSION['compte_id'] ?? 0);
$praticien_id = (int)($_POST['praticien_id'] ?? $_GET['praticien_id'] ?? 0);
// FullCalendar envoie start/end en ISO (UTC). On les rend optionnels.
$startIso = isset($_POST['start']) ? (string)$_POST['start'] : (isset($_GET['start']) ? (string)$_GET['start'] : null);
$endIso = isset($_POST['end']) ? (string)$_POST['end'] : (isset($_GET['end']) ? (string)$_GET['end'] : null);
if ($compteId <= 0 || $praticien_id <= 0) { echo json_encode([]); exit; }
// --- Sécurité: vérifier appartenance secrétaire → praticien ---
$chk = $pdo->prepare("
SELECT 1
FROM secretaire_praticien
WHERE secretaire_user_id = :u
AND praticien_id = :p
AND is_active = 1
LIMIT 1
");
$chk->execute(['u'=>$compteId,'p'=>$praticien_id]);
if (!$chk->fetchColumn()) { echo json_encode([]); exit; }
// --- Requête RDV (avec plage si fournie) ---
$params = [':p' => $praticien_id];
$whereDates = '';
if ($startIso && $endIso) {
// On normalise en 'Y-m-d H:i:s'
$startSql = date('Y-m-d H:i:s', strtotime($startIso));
$endSql = date('Y-m-d H:i:s', strtotime($endIso));
$whereDates = " AND rv.date_rdv BETWEEN :d1 AND :d2 ";
$params[':d1'] = $startSql;
$params[':d2'] = $endSql;
}
$sql = "
SELECT rv.id, rv.date_rdv, rv.duree_minutes, rv.statut, rv.lieu,
c.nom, c.prenom
FROM rendez_vous rv
JOIN client c ON rv.client_id = c.id
WHERE rv.praticien_id = :p
$whereDates
ORDER BY rv.date_rdv ASC
";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$rdvs = $stmt->fetchAll(PDO::FETCH_ASSOC);
// --- Mappage couleurs par statut ---
$couleurs = [
'prévu' => '#f39c12', // orange
'confirmé' => '#27ae60', // vert
'annulé' => '#c0392b', // rouge
'terminé' => '#7f8c8d', // gris
];
// --- Construction des events ---
$tz = new DateTimeZone('Europe/Paris');
$events = [];
foreach ($rdvs as $r) {
$start = new DateTime($r['date_rdv'], $tz);
$end = (clone $start)->modify('+' . (int)($r['duree_minutes'] ?? 60) . ' minutes');
$statut = (string)$r['statut'];
$events[] = [
'id' => (int)$r['id'],
'title' => trim(($r['prenom'] ?? '') . ' ' . ($r['nom'] ?? '')),
'start' => $start->format(DateTime::ATOM), // ISO 8601
'end' => $end->format(DateTime::ATOM),
'allDay' => false,
'color' => $couleurs[$statut] ?? '#999',
// Champ non standard → disponible via event.extendedProps.statut côté FC
'statut' => ucfirst($statut),
];
}
echo json_encode($events, JSON_UNESCAPED_UNICODE);
+320
View File
@@ -0,0 +1,320 @@
<?php
// /public/cabinet/invitations.php
require_once __DIR__ . '/../includes/init_praticien.php';
if (!function_exists('h')) { function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); } }
$praticien_id = (int)($_SESSION['reference_id'] ?? 0);
if ($praticien_id<=0) { header('Location:/index.php'); exit; }
// CSRF
if (empty($_SESSION['csrf'])) $_SESSION['csrf']=bin2hex(random_bytes(16));
$csrf = $_SESSION['csrf'];
// Handle POST (invite or toggle)
$msg = '';
if (($_SERVER['REQUEST_METHOD']??'')==='POST' && hash_equals($csrf, $_POST['csrf'] ?? '')) {
if (isset($_POST['action']) && $_POST['action']==='invite') {
$email = trim((string)($_POST['email'] ?? ''));
$can_manage_rdv = !empty($_POST['can_manage_rdv']) ? 1 : 0;
$can_edit_slots = !empty($_POST['can_edit_slots']) ? 1 : 0;
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$msg = $translations['inv_email_invalide'] ?? 'Email invalide.';
} else {
// token & expiration
$token = bin2hex(random_bytes(24));
$expires = (new DateTime('+10 days'))->format('Y-m-d H:i:s');
// Insert invitation (role 'secretaire')
$ins = $pdo->prepare("INSERT INTO invitations (email, role, praticien_id, token, status, expires_at, invited_by)
VALUES (:email,'secretaire',:pid,:tk,'envoyee',:exp,:by)");
$ins->execute([
'email'=>$email,
'pid'=>$praticien_id,
'tk'=>$token,
'exp'=>$expires,
'by'=> (int)($_SESSION['compte_id'] ?? 0),
]);
// Stocker droits sélectionnés côté "mémo" (façon simple : dans token via table dédiée ?)
// Ici on ajoute un petit "mémo" côté invitations.meta_json si tu souhaites :
// Si tu veux, ajoute une colonne meta_json JSON dans invitations. Sinon, on appliquera des droits par défaut à l'acceptation, et on pourra basculer ensuite.
// Pour rester 100% compatible avec ta structure actuelle, on applique par défaut can_manage_rdv=1, can_edit_slots=0, puis on laisse l'UI permettre de modifier après acceptation.
// Envoi email (si tu as un mailer)
if (function_exists('uslugi_mail')) {
$acceptUrl = sprintf(
'%s/public/cabinet/accepter_invitation.php?token=%s',
rtrim((isset($_SERVER['REQUEST_SCHEME']) ? $_SERVER['REQUEST_SCHEME'].'://' : '').($_SERVER['HTTP_HOST']??''), '/'),
$token
);
$subject = $translations['inv_mail_sujet']
?? 'Invitation à rejoindre un cabinet sur uslugi.fr';
$body = '
<div style="font-family:Arial,Helvetica,sans-serif;font-size:15px;color:#333;background:#f9f9fb;padding:20px;">
<div style="max-width:600px;margin:0 auto;background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 10px rgba(0,0,0,.05);">
<!-- Titre -->
<div style="background:#38378B;color:#fff;padding:18px 24px;font-size:18px;font-weight:bold;">
<img src="https://uslugi.fr/public/uploads/img/logo_mail.png"
alt="uslugi.fr" style="max-height:30px;display:block;">
</div>
<!-- Contenu -->
<div style="padding:24px;">
<p>'.h($translations['inv_mail_bonjour'] ?? 'Bonjour,').'</p>
<p>'.
h($translations['inv_mail_intro'] ??
'Vous avez été invité(e) à rejoindre le secrétariat d’un praticien sur uslugi.fr.
Grâce à cet accès, vous pourrez gérer les rendez-vous et organiser l’agenda du cabinet.').
'</p>
<p style="margin:24px 0;text-align:center;">
<a href="'.h($acceptUrl).'"
style="background:#E03C3F;color:#fff;text-decoration:none;
padding:12px 22px;border-radius:999px;font-weight:bold;display:inline-block;">
'.h($translations['inv_mail_btn'] ?? 'Accepter l’invitation').'
</a>
</p>
<p>'.
h($translations['inv_mail_validite'] ??
'Ce lien est valide pendant 10 jours. Passé ce délai, il expirera automatiquement.').
'</p>
<p style="font-size:13px;color:#777;margin-top:24px;">
'.h($translations['inv_mail_ignore'] ??
'Si vous n’êtes pas concerné(e) par cette invitation, vous pouvez ignorer cet email.').'
</p>
</div>
<!-- Footer -->
<div style="background:#f1f1f1;color:#555;padding:12px 24px;font-size:13px;text-align:center;">
'.h($translations['inv_mail_footer'] ?? ('© '.date('Y').' uslugi.fr – Tous droits réservés')).'
</div>
</div>
</div>';
uslugi_mail($email, $subject, $body);
}
$msg = $translations['invitation_envoyee'] ?? 'Invitation envoyée.';
}
}
if (isset($_POST['action']) && $_POST['action']==='toggle_rights') {
$spid = (int)($_POST['spid'] ?? 0);
$field = ($_POST['field'] ?? '');
if ($spid>0 && in_array($field,['can_manage_rdv','can_edit_slots','is_active'],true)) {
// secure update scoped to this praticien
$upd = $pdo->prepare("UPDATE secretaire_praticien SET $field = 1 - $field WHERE id=:id AND praticien_id=:pid");
$upd->execute(['id'=>$spid,'pid'=>$praticien_id]);
$msg = $translations['droits_mis_a_jour'] ?? 'Droits mis à jour.';
}
}
}
// DATA: invitations + secretaire_praticien liés à CE praticien
$invitations = $pdo->prepare("SELECT id,email,status,expires_at,created_at FROM invitations WHERE praticien_id=:pid ORDER BY created_at DESC");
$invitations->execute(['pid'=>$praticien_id]);
$invRows = $invitations->fetchAll(PDO::FETCH_ASSOC);
// secrétaires liées
$secs = $pdo->prepare("
SELECT sp.id, sp.secretaire_user_id, sp.can_manage_rdv, sp.can_edit_slots, sp.is_active, sp.created_at,
c.email
FROM secretaire_praticien sp
JOIN compte c ON c.id = sp.secretaire_user_id
WHERE sp.praticien_id = :pid
ORDER BY sp.created_at DESC
");
$secs->execute(['pid'=>$praticien_id]);
$secRows = $secs->fetchAll(PDO::FETCH_ASSOC);
require_once __DIR__ . '/../includes/header.php';
?>
<div class="main1">
<div class="container py-5">
<div class="shadow p-4 rounded bg-light">
<h1 class="h3 mb-3"><?= h($translations['secretariat_titre'] ?? 'Secrétariat') ?></h1>
<?php if(!empty($msg)): ?>
<div class="alert alert-info"><?= h($msg) ?></div>
<?php endif; ?>
<div class="card mb-4">
<div class="card-body">
<h2 class="h5 mb-3"><?= h($translations['secretariat_inviter_secretaire'] ?? 'Inviter une secrétaire') ?></h2>
<form method="post" class="row g-3">
<input type="hidden" name="csrf" value="<?= h($csrf) ?>">
<input type="hidden" name="action" value="invite">
<div class="col-md-6">
<label class="form-label"><?= h($translations['inv_email_label'] ?? 'Email de la secrétaire') ?></label>
<input type="email" name="email" class="form-control" required placeholder="email@example.com">
</div>
<div class="col-md-6">
<label class="form-label"><?= h($translations['inv_droits_label'] ?? 'Droits initiaux') ?></label>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="can_manage_rdv" id="c1" checked>
<label class="form-check-label" for="c1"><?= h($translations['droits_peut_gestion_rdv'] ?? 'Gérer les rendez-vous') ?></label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="can_edit_slots" id="c2" checked>
<label class="form-check-label" for="c2"><?= h($translations['droits_peut_modifier_slots'] ?? 'Modifier les disponibilités') ?></label>
</div>
</div>
<div class="col-12">
<button class="btn btn-primary"><?= h($translations['btn_envoyer_invitation'] ?? 'Envoyer l’invitation') ?></button>
</div>
</form>
</div>
</div>
<div class="card mb-4">
<div class="card-body">
<h2 class="h6 mb-3"><?= h($translations['inv_invitations_envoyees'] ?? 'Invitations envoyées') ?></h2>
<div class="table-responsive">
<table class="table align-middle mobile-cards1" id="table_rdv1">
<thead><tr>
<th>Email</th><th><?= h($translations['inv_statut'] ?? 'Statut') ?></th>
<th><?= h($translations['inv_expires'] ?? 'Expire le') ?></th>
<th><?= h($translations['inv_envoyee_le'] ?? 'Envoyée le') ?></th>
</tr></thead>
<tbody>
<?php foreach($invRows as $r): ?>
<tr>
<td><?= h($r['email']) ?></td>
<td><?= h($r['status']) ?></td>
<td><?= h($r['expires_at'] ?? '') ?></td>
<td><?= h($r['created_at'] ?? '') ?></td>
</tr>
<?php endforeach; if(!$invRows): ?>
<tr><td colspan="4" class="text-muted"><?= h($translations['inv_aucune'] ?? 'Aucune invitation pour le moment.') ?></td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
<div class="card mb-5">
<div class="card-body">
<h2 class="h6 mb-3"><?= h($translations['inv_secretaires_liees'] ?? 'Secrétaires liées') ?></h2>
<div class="table-responsive">
<table class="table align-middle mobile-cards" id="table_rdv">
<thead><tr>
<th>Email</th><th><?= h($translations['droits_gestion_rdv'] ?? 'RDV') ?></th><th><?= h($translations['droits_dispos'] ?? 'Dispos') ?></th><th><?= h($translations['etat'] ?? 'État') ?></th>
</tr></thead>
<tbody>
<?php foreach($secRows as $r): ?>
<tr>
<td><?= h($r['email']) ?></td>
<td>
<form method="post" class="d-inline">
<input type="hidden" name="csrf" value="<?= h($csrf) ?>">
<input type="hidden" name="action" value="toggle_rights">
<input type="hidden" name="spid" value="<?= (int)$r['id'] ?>">
<input type="hidden" name="field" value="can_manage_rdv">
<button class="btn btn-sm <?= $r['can_manage_rdv']?'btn-success':'btn-outline-secondary' ?>">
<?= $r['can_manage_rdv'] ? h($translations['actif'] ?? 'Actif') : h($translations['inactif'] ?? 'Inactif') ?>
</button>
</form>
</td>
<td>
<form method="post" class="d-inline">
<input type="hidden" name="csrf" value="<?= h($csrf) ?>">
<input type="hidden" name="action" value="toggle_rights">
<input type="hidden" name="spid" value="<?= (int)$r['id'] ?>">
<input type="hidden" name="field" value="can_edit_slots">
<button class="btn btn-sm <?= $r['can_edit_slots']?'btn-success':'btn-outline-secondary' ?>">
<?= $r['can_edit_slots'] ? h($translations['actif'] ?? 'Actif') : h($translations['inactif'] ?? 'Inactif') ?>
</button>
</form>
</td>
<td>
<form method="post" class="d-inline">
<input type="hidden" name="csrf" value="<?= h($csrf) ?>">
<input type="hidden" name="action" value="toggle_rights">
<input type="hidden" name="spid" value="<?= (int)$r['id'] ?>">
<input type="hidden" name="field" value="is_active">
<button class="btn btn-sm <?= $r['is_active']?'btn-success':'btn-outline-secondary' ?>">
<?= $r['is_active'] ? h($translations['actif'] ?? 'Actif') : h($translations['inactif'] ?? 'Inactif') ?>
</button>
</form>
</td>
</tr>
<?php endforeach; if(!$secRows): ?>
<tr><td colspan="4" class="text-muted"><?= h($translations['inv_aucune_secretaire'] ?? 'Aucune secrétaire liée pour le moment.') ?></td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<script defer>
(function(){
function initMobileCards(tbl){
if (!tbl) return;
const heads = Array.from(tbl.querySelectorAll('thead th'))
.map(th => th.textContent.trim());
tbl.querySelectorAll('tbody tr').forEach(tr => {
Array.from(tr.children).forEach((td, i) => {
if (!td.hasAttribute('data-label')) {
td.setAttribute('data-label', heads[i] || '');
}
});
});
}
function ready(fn){
if (document.readyState === 'loading')
document.addEventListener('DOMContentLoaded', fn);
else
fn();
}
ready(function(){
// 👉 liste de toutes les variantes
const selectors = [
'#table_rdv',
'table.mobile-cards',
'#table_rdv1',
'table.mobile-cards1'
];
// 👉 récupérer toutes les tables existantes dans la page
const tables = selectors
.map(sel => document.querySelector(sel))
.filter(Boolean); // retire les null
tables.forEach(initMobileCards);
if (tables.length > 0) {
document.documentElement.classList.add('mc-ready');
}
});
})();
</script>
<footer class="site-footer">
<div class="container">
<p><?= $translations['copyr'] ?? '&copy; '.date('Y') ?></p>
</div>
</footer>
+325
View File
@@ -0,0 +1,325 @@
<?php
// /public/cabinet/rendez_vous.php (VERSION SECRÉTAIRE)
require_once __DIR__ . '/../includes/init_secretaire.php';
if (!function_exists('h')) { function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); } }
// --- Contexte secrétaire + praticien obligatoire
$compteId = (int)($_SESSION['compte_id'] ?? 0);
$userRole = strtolower((string)($_SESSION['role'] ?? ''));
$praticien_id = isset($_GET['praticien_id']) ? (int)$_GET['praticien_id'] : 0;
if ($userRole !== 'secretaire' || $praticien_id <= 0) { http_response_code(403); exit('Forbidden'); }
// --- Vérifier pivot + droit can_manage_rdv
$st = $pdo->prepare("
SELECT can_manage_rdv
FROM secretaire_praticien
WHERE secretaire_user_id = :u
AND praticien_id = :p
AND is_active = 1
LIMIT 1
");
$st->execute(['u'=>$compteId,'p'=>$praticien_id]);
$pivot = $st->fetch(PDO::FETCH_ASSOC);
if (!$pivot || (int)$pivot['can_manage_rdv'] !== 1) { http_response_code(403); exit('Forbidden'); }
// --- Paramètres
$id_rdv = isset($_GET['id']) ? (int)$_GET['id'] : null;
// --- Suppression (POST) : bornée au praticien + statut 'annulé'
if ($id_rdv && isset($_POST['delete'])) {
// delete uniquement si le RDV appartient à ce praticien et est annulé
$del = $pdo->prepare("DELETE FROM rendez_vous WHERE id = :id AND praticien_id = :p AND statut = 'annulé'");
$del->execute(['id'=>$id_rdv,'p'=>$praticien_id]);
echo '<div class="main1">';
echo '<div class="container py-5">';
echo ' <div class="shadow p-4 rounded bg-light text-center">';
echo ' <div class="alert alert mb-4" role="alert">';
echo ' <h4>'.h($translations['rdv_supprime'] ?? 'Rendez-vous supprimé').'</h4>';
echo ' </div>';
echo ' <a href="/public/cabinet/rendez_vous.php?praticien_id='. (int)$praticien_id .'" class="btn btn-primary">';
echo ' <i class="bi bi-arrow-left-circle"></i> '.h($translations['retour_a_la_liste'] ?? 'Retour à la liste').'
</a>';
echo ' </div>';
echo '</div>';
echo '</div>';
exit;
}
// --- Charger les RDV (bornés au praticien)
if ($id_rdv) {
$stmt = $pdo->prepare("
SELECT rv.*, c.nom AS nom_client, c.prenom AS prenom_client
FROM rendez_vous rv
JOIN client c ON rv.client_id = c.id
WHERE rv.id = :id
AND rv.praticien_id = :p
LIMIT 1
");
$stmt->execute(['id'=>$id_rdv,'p'=>$praticien_id]);
$one = $stmt->fetch(PDO::FETCH_ASSOC);
$rdvs = $one ? [$one] : [];
} else {
$stmt = $pdo->prepare("
SELECT rv.*, c.nom AS nom_client, c.prenom AS prenom_client
FROM rendez_vous rv
JOIN client c ON rv.client_id = c.id
WHERE rv.praticien_id = :p
ORDER BY rv.date_rdv DESC
");
$stmt->execute(['p'=>$praticien_id]);
$rdvs = $stmt->fetchAll(PDO::FETCH_ASSOC);
}
// --- Map statuts (valeurs BDD -> libellés traduits)
$statutMap = [
'prévu' => $translations['statut_prevu'] ?? 'Prévu',
'confirmé' => $translations['statut_confirme'] ?? 'Confirmé',
'annulé' => $translations['statut_annule'] ?? 'Annulé',
'terminé' => $translations['statut_termine'] ?? 'Terminé',
];
$PAGE_TITRE = $translations['mes_rdv_title'] ?? 'Mes rendez-vous';
$HEAD_EXTRA = <<<HTML
<script src="/js/sweetalert2@11.js"></script>
HTML;
require_once __DIR__ . '/../includes/header_cabinet.php';
?>
<div class="main1">
<div class="container py-5">
<div class="shadow p-4 rounded bg-light">
<h2 class="mb-4 text-center"><?= htmlspecialchars($currentPraticienName, ENT_QUOTES, 'UTF-8') ?> - <?= h($translations['vse_consult'] ?? 'Mes rendez-vous') ?></h2>
<?php if (!$id_rdv): ?>
<a href="/public/cabinet/rendez_vous_du_jour.php?praticien_id=<?= (int)$praticien_id ?>" class="btn btn-outline-secondary mb-4">
<?= h($translations['voir_rdv_du_jour'] ?? 'Voir les RDV du jour') ?>
</a>
<div class="mb-3">
<label for="filtre_statut" class="form-label"><?= h($translations['filtrer_par_statut'] ?? 'Filtrer par statut') ?></label>
<select id="filtre_statut" class="form-select w-auto d-inline-block" onchange="filtrer()">
<option value=""><?= h($translations['option_tous'] ?? 'Tous') ?></option>
<?php foreach (['prévu','confirmé','annulé','terminé'] as $s): ?>
<option value="<?= $s ?>"><?= h($statutMap[$s] ?? ucfirst($s)) ?></option>
<?php endforeach; ?>
</select>
</div>
<?php endif; ?>
<div class="table-responsive">
<table class="table table-bordered table-striped align-middle text-center mobile-cards" id="table_rdv">
<thead class="table-light">
<tr>
<th><?= h($translations['col_date'] ?? 'Date') ?></th>
<th><?= h($translations['col_heure'] ?? 'Heure') ?></th>
<th><?= h($translations['col_client'] ?? 'Client') ?></th>
<th><?= h($translations['col_duree'] ?? 'Durée') ?></th>
<th><?= h($translations['col_statut'] ?? 'Statut') ?></th>
<?php if (!$id_rdv): ?><th><?= h($translations['col_action'] ?? 'Action') ?></th><?php endif; ?>
<!--<th><?= h($translations['btn_chat'] ?? 'Chat') ?></th>
<th><?= h($translations['status_online'] ?? 'Visio') ?></th>-->
</tr>
</thead>
<tbody>
<?php if (!empty($rdvs)): foreach ($rdvs as $r):
$dt = new DateTime($r['date_rdv']);
$id = (int)$r['id'];
?>
<tr data-statut="<?= h($r['statut']) ?>">
<td><?= h($dt->format('d/m/Y')) ?></td>
<td><?= h($dt->format('H:i')) ?></td>
<td><?= h(($r['prenom_client'] ?? '').' '.($r['nom_client'] ?? '')) ?></td>
<td><?= (int)$r['duree_minutes'] . ' ' . h($translations['min_abbr'] ?? 'min') ?></td>
<td>
<select class="form-select form-select-sm" onchange="changerStatut(<?= $id ?>, this.value, <?= (int)$praticien_id ?>)">
<?php foreach (['prévu','confirmé','annulé','terminé'] as $s): ?>
<option value="<?= $s ?>" <?= ($r['statut'] === $s ? 'selected' : '') ?>>
<?= h($statutMap[$s] ?? ucfirst($s)) ?>
</option>
<?php endforeach; ?>
</select>
</td>
<?php if (!$id_rdv): ?>
<td>
<?php if (($r['statut'] ?? '') === 'annulé'): ?>
<form method="POST" action="?praticien_id=<?= (int)$praticien_id ?>&id=<?= $id ?>" class="js-del-form d-inline">
<input type="hidden" name="delete" value="1">
<button type="button" class="btn btn-sm btn-table_del btn-table_del-danger js-del-btn">
<?= h($translations['btn_supprimer'] ?? 'Supprimer') ?>
</button>
</form>
<?php else: ?>
<a href="?praticien_id=<?= (int)$praticien_id ?>&id=<?= $id ?>" class="btn btn-outline-secondary btn-sm btn-table">
<?= h($translations['btn_voir'] ?? 'Voir') ?>
</a>
<?php endif; ?>
</td>
<?php endif; ?>
<!--<td>
<a class="btn btn-outline-secondary btn-sm btn-table" href="/messages/start.php?client_id=<?= (int)$r['client_id'] ?>">
<?= h($translations['chat_praticien'] ?? 'Ouvrir le chat') ?>
</a>
</td>-->
<!-- ====== Colonne Visio / Cabinet ======
<td>
<?php
$lieu = strtolower(trim((string)($r['lieu'] ?? '')));
if ($lieu === 'online') {
$statut = $r['statut'];
$tzParis = new DateTimeZone('Europe/Paris');
$now = new DateTime('now', $tzParis);
$start = new DateTime($r['date_rdv'], $tzParis);
$open = (clone $start)->modify('-15 minutes');
$close = (clone $start)->modify('+'.(int)($r['duree_minutes'] ?? 120).' minutes');
if ($statut === 'confirmé') {
if ($now >= $open && $now <= $close) {
?>
<a href="/visio/join.php?rdv=<?= (int)$r['id'] ?>&start=1" class="btn btn-outline-secondary btn-sm btn-table">
<?= h($translations['visio_nachalo'] ?? 'Démarrer la visio') ?>
</a>
<?php
} else {
?>
<small class="text-muted d-block mt-1">
<?php
if ($now < $open) echo h(($translations['ouvert_a'] ?? 'Ouvert à').' '.$open->format('H:i'));
elseif ($now > $close) echo h(($translations['ferme_a'] ?? 'Fermé à').' '.$close->format('H:i'));
else echo h(($translations['prev_a'] ?? 'Prévu le').' '.$start->format('d/m/Y \à H:i').'.');
?>
</small>
<?php
}
} elseif ($statut === 'prévu') {
?>
<small class="text-danger d-block mt-1"><?= h($translations['votre_rdv_jdet'] ?? 'En attente de confirmation') ?></small>
<?php
} else {
?>
<small class="text-muted d-block mt-1"><?= h(($translations['zaverchen_a'] ?? 'Terminé').' '.$close->format('H:i')) ?></small>
<?php
}
} else {
echo '<span class="text-nowrap">'.h($translations['option_cabinet'] ?? 'Очно').'</span>';
}
?>
</td>
===================================== -->
</tr>
<?php endforeach; else: ?>
<tr>
<td colspan="<?= $id_rdv ? 6 : 8 ?>"><?= h($translations['aucun_rdv_trouve'] ?? 'Aucun rendez-vous trouvé') ?></td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
<?php if ($id_rdv): ?>
<a href="/public/cabinet/rendez_vous.php?praticien_id=<?= (int)$praticien_id ?>" class="btn btn-outline-secondary mb-4">
<?= h($translations['retour_a_la_liste'] ?? 'Retour à la liste') ?>
</a>
<?php endif; ?>
</div>
</div>
<script>
function changerStatut(id, statut, praticienId) {
fetch('/public/cabinet/changer_statut.php', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'},
body: new URLSearchParams({ id: String(id), statut: String(statut), praticien_id: String(praticienId) })
})
.then(async (r) => {
const text = await r.text();
let data;
try { data = JSON.parse(text); }
catch { throw new Error('Réponse invalide: ' + text.slice(0,180)); }
if (!data.ok) throw new Error(data.message || 'Erreur');
Swal.fire({
icon: 'success',
title: data.message || '<?= addslashes($translations['maj_ok'] ?? "Mise à jour réussie") ?>',
timer: 1800,
showConfirmButton: false
});
})
.catch((e) => {
Swal.fire({
icon: 'error',
title: '<?= addslashes($translations['erreur_mise_a_jour'] ?? "Erreur de mise à jour") ?>',
text: e.message || '<?= addslashes($translations['erreur_mise_a_jour'] ?? "Erreur de mise à jour.") ?>',
confirmButtonText: 'OK'
});
});
}
</script>
<script>
function filtrer() {
const valeur = document.getElementById("filtre_statut").value;
const lignes = document.querySelectorAll("#table_rdv tbody tr");
lignes.forEach(tr => {
const statut = tr.dataset.statut;
tr.style.display = (!valeur || statut === valeur) ? '' : 'none';
});
}
</script>
<script>
document.addEventListener('click', async function(e){
const btn = e.target.closest('.js-del-btn');
if (!btn) return;
const form = btn.closest('.js-del-form');
if (!form) return;
const result = await Swal.fire({
icon: 'warning',
title: '<?= addslashes($translations["confirm_title"] ?? "Confirmation") ?>',
text: '<?= addslashes($translations["confirm_supprimer_rdv"] ?? "Supprimer définitivement ce rendez-vous annulé ?") ?>',
showCancelButton: true,
confirmButtonText: '<?= addslashes($translations["btn_da_udalit"] ?? "Oui") ?>',
cancelButtonText: '<?= addslashes($translations["btn_annuler"] ?? "Annuler") ?>',
reverseButtons: true
});
if (result.isConfirmed) form.submit();
});
</script>
<script defer>
(function(){
function initMobileCards(tbl){
if (!tbl) return;
const heads = Array.from(tbl.querySelectorAll('thead th')).map(th => th.textContent.trim());
tbl.querySelectorAll('tbody tr').forEach(tr => {
Array.from(tr.children).forEach((td, i) => {
if (!td.hasAttribute('data-label')) td.setAttribute('data-label', heads[i] || '');
});
});
document.documentElement.classList.add('mc-ready');
}
function ready(fn){
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn);
else fn();
}
ready(function(){
const tbl = document.getElementById('table_rdv') || document.querySelector('table.mobile-cards');
initMobileCards(tbl);
});
})();
</script>
</div>
<footer class="site-footer">
<div class="container">
<p><?= $translations['copyr'] ?? '&copy; '.date('Y') ?></p>
</div>
</footer>
</body>
</html>
+244
View File
@@ -0,0 +1,244 @@
<?php
// /public/cabinet/rendez_vous_du_jour.php
require_once __DIR__ . '/../includes/init_secretaire.php';
if (!function_exists('h')) { function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); } }
// --- contexte secrétaire + praticien requis ---
$compteId = (int)($_SESSION['compte_id'] ?? 0);
$userRole = strtolower((string)($_SESSION['role'] ?? ''));
$praticien_id = isset($_GET['praticien_id']) ? (int)$_GET['praticien_id'] : 0;
if ($userRole !== 'secretaire' || $compteId <= 0 || $praticien_id <= 0) {
http_response_code(403); exit('Forbidden');
}
// --- vérification pivot + droit can_manage_rdv ---
$st = $pdo->prepare("
SELECT can_manage_rdv
FROM secretaire_praticien
WHERE secretaire_user_id = :u
AND praticien_id = :p
AND is_active = 1
LIMIT 1
");
$st->execute(['u'=>$compteId,'p'=>$praticien_id]);
$pivot = $st->fetch(PDO::FETCH_ASSOC);
if (!$pivot || (int)$pivot['can_manage_rdv'] !== 1) {
http_response_code(403); exit('Forbidden');
}
// --- Aujourd’hui en Europe/Paris ---
date_default_timezone_set('Europe/Paris');
$tz = new DateTimeZone('Europe/Paris');
$todayStart = (new DateTime('today', $tz))->setTime(0, 0, 0);
$todayEnd = (clone $todayStart)->setTime(23, 59, 59);
$today_start = $todayStart->format('Y-m-d H:i:s');
$today_end = $todayEnd->format('Y-m-d H:i:s');
// --- RDV du jour pour ce praticien ---
$stmt = $pdo->prepare("
SELECT rv.*, c.nom AS nom_client, c.prenom AS prenom_client, c.email
FROM rendez_vous rv
JOIN client c ON rv.client_id = c.id
WHERE rv.praticien_id = :p
AND rv.date_rdv BETWEEN :d1 AND :d2
ORDER BY rv.date_rdv ASC
");
$stmt->execute(['p'=>$praticien_id, 'd1'=>$today_start, 'd2'=>$today_end]);
$rdvs = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Map statuts (valeurs BDD -> libellés traduits)
$statutMap = [
'prévu' => $translations['statut_prevu'] ?? 'Prévu',
'confirmé' => $translations['statut_confirme'] ?? 'Confirmé',
'annulé' => $translations['statut_annule'] ?? 'Annulé',
'terminé' => $translations['statut_termine'] ?? 'Terminé',
];
$PAGE_TITRE = $translations['titre_rdv_du_jour'] ?? 'Rendez-vous du jour';
$HEAD_EXTRA = <<<HTML
<script src="/js/sweetalert2@11.js"></script>
HTML;
require_once __DIR__ . '/../includes/header_cabinet.php';
?>
<div class="main1">
<div class="container py-5">
<div class="shadow p-4 rounded bg-light">
<h2 class="mb-4 text-center"><?= htmlspecialchars($currentPraticienName, ENT_QUOTES, 'UTF-8') ?> - <?= h($translations['titre_rdv_du_jour'] ?? 'Rendez-vous du jour') ?></h2>
<a href="/public/cabinet/rendez_vous.php?praticien_id=<?= (int)$praticien_id ?>" class="btn btn-outline-secondary mb-4">
<?= h($translations['retour_liste_complete'] ?? 'Retour à la liste complète') ?>
</a>
<div class="table-responsive">
<table class="table table-bordered table-hover align-middle text-center mobile-cards" id="table_rdv">
<thead class="table-light">
<tr>
<th><?= h($translations['col_heure'] ?? 'Heure') ?></th>
<th><?= h($translations['col_client'] ?? 'Client') ?></th>
<th><?= h($translations['col_duree'] ?? 'Durée') ?></th>
<th><?= h($translations['col_statut'] ?? 'Statut') ?></th>
<!--<th><?= h($translations['btn_chat'] ?? 'Chat') ?></th>
<th><?= h($translations['status_online'] ?? 'Visio') ?></th>-->
</tr>
</thead>
<tbody>
<?php foreach ($rdvs as $r):
$dt = new DateTime($r['date_rdv'], $tz);
$id = (int)$r['id'];
?>
<tr data-statut="<?= h($r['statut']) ?>">
<td><?= h($dt->format('H:i')) ?></td>
<td><?= h(($r['prenom_client'] ?? '').' '.($r['nom_client'] ?? '')) ?></td>
<td><?= (int)$r['duree_minutes'] . ' ' . h($translations['min_abbr'] ?? 'min') ?></td>
<td>
<select class="form-select form-select-sm" onchange="changerStatut(<?= $id ?>, this.value, this, <?= (int)$praticien_id ?>)">
<?php foreach (['prévu','confirmé','annulé','terminé'] as $s): ?>
<option value="<?= $s ?>" <?= ($r['statut'] === $s ? 'selected' : '') ?>>
<?= h($statutMap[$s] ?? ucfirst($s)) ?>
</option>
<?php endforeach; ?>
</select>
</td>
<!-- Chat -->
<!--<td>
<a class="btn btn-outline-secondary btn-sm btn-table" href="/messages/start.php?client_id=<?= (int)$r['client_id'] ?>">
<?= h($translations['chat_praticien'] ?? 'Ouvrir le chat') ?>
</a>
</td>-->
<!-- Visio / Cabinet -->
<!--<td>
<?php
$lieu = strtolower(trim((string)($r['lieu'] ?? '')));
if ($lieu === 'online') {
$statut = $r['statut']; // 'confirmé' | 'prévu' | 'annulé' | 'terminé'
$now = new DateTime('now', $tz);
$start = new DateTime($r['date_rdv'], $tz);
$open = (clone $start)->modify('-15 minutes');
$close = (clone $start)->modify('+'.(int)($r['duree_minutes'] ?? 120).' minutes');
if ($statut === 'confirmé') {
if ($now >= $open && $now <= $close) {
?>
<a href="/visio/join.php?rdv=<?= (int)$r['id'] ?>&start=1" class="btn btn-outline-secondary btn-sm btn-table">
<?= h($translations['visio_nachalo'] ?? 'Démarrer la visio') ?>
</a>
<?php
} else {
?>
<small class="text-muted d-block mt-1">
<?php
if ($now < $open) echo h(($translations['ouvert_a'] ?? 'Ouvert à').' '.$open->format('H:i'));
elseif ($now > $close) echo h(($translations['ferme_a'] ?? 'Fermé à').' '.$close->format('H:i'));
else echo h(($translations['prev_a'] ?? 'Prévu le').' '.$start->format('d/m/Y \à H:i').'.');
?>
</small>
<?php
}
} elseif ($statut === 'prévu') {
?>
<small class="text-danger d-block mt-1"><?= h($translations['votre_rdv_jdet'] ?? 'En attente de confirmation') ?></small>
<?php
} else {
?>
<small class="text-muted d-block mt-1"><?= h(($translations['zaverchen_a'] ?? 'Terminé').' '.$close->format('H:i')) ?></small>
<?php
}
} else {
echo '<span class="text-nowrap">'.h($translations['option_cabinet'] ?? 'Очно').'</span>';
}
?>
</td>-->
</tr>
<?php endforeach; ?>
<?php if (empty($rdvs)): ?>
<tr>
<td colspan="6" class="text-muted">
<?= h($translations['aucun_rdv_aujourdhui'] ?? "Aucun rendez-vous aujourd'hui") ?>
</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
// MAJ statut via endpoint cabinet + praticien_id
function changerStatut(id, statut, selEl, praticienId) {
fetch('/public/cabinet/changer_statut.php', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'},
body: new URLSearchParams({ id: String(id), statut: String(statut), praticien_id: String(praticienId) })
})
.then(async (r) => {
const raw = (await r.text()).trim();
let ok = false, message = '';
try {
const data = JSON.parse(raw);
ok = !!data.ok;
message = data.message || '';
} catch (_) {
if (raw.toUpperCase().includes('OK')) { ok = true; message = raw; }
else { throw new Error(raw.slice(0, 180)); }
}
if (!ok) throw new Error(message || 'Erreur');
Swal.fire({
icon: 'success',
title: message || '<?= addslashes($translations['maj_ok'] ?? "Mise à jour réussie") ?>',
timer: 1800,
showConfirmButton: false
});
try {
const tr = selEl && selEl.closest('tr');
if (tr) tr.dataset.statut = statut;
} catch(e){}
})
.catch((e) => {
Swal.fire({
icon: 'error',
title: '<?= addslashes($translations['erreur_mise_a_jour'] ?? "Erreur de mise à jour") ?>',
text: e.message || '<?= addslashes($translations['erreur_mise_a_jour'] ?? "Erreur de mise à jour.") ?>',
confirmButtonText: 'OK'
});
});
}
</script>
<!--********* Adaptation mobile *********-->
<script defer>
(function(){
function initMobileCards(tbl){
if (!tbl) return;
const heads = Array.from(tbl.querySelectorAll('thead th')).map(th => th.textContent.trim());
tbl.querySelectorAll('tbody tr').forEach(tr => {
Array.from(tr.children).forEach((td, i) => {
if (!td.hasAttribute('data-label')) {
td.setAttribute('data-label', heads[i] || '');
}
});
});
document.documentElement.classList.add('mc-ready');
}
function ready(fn){ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn); else fn(); }
ready(function(){ initMobileCards(document.getElementById('table_rdv')); });
})();
</script>
<footer class="site-footer">
<div class="container">
<p><?= $translations['copyr'] ?? '&copy; '.date('Y') ?></p>
</div>
</footer>
</body>
</html>
+296
View File
@@ -0,0 +1,296 @@
<?php
// /public/cabinet/reserver_rdv.php
require_once __DIR__ . '/../includes/init_secretaire.php';
// Valeurs par défaut pour les modes
$c_online = $c_online ?? 'online';
$c_offline = $c_offline ?? 'offline';
// ---- Entrées ----
if (! isset($_GET['praticien_id'])) {
exit($translations['praticien_non_precise'] ?? 'Praticien non précisé.');
}
$praticien_id = (int) $_GET['praticien_id'];
$client_id = isset($_GET['client_id']) ? (int) $_GET['client_id'] : null;
$secretaire_user_id = (int) ($_SESSION['compte_id'] ?? 0);
if ($secretaire_user_id <= 0 || $praticien_id <= 0) {http_response_code(403);exit('Forbidden');}
// ---- Sécurité pivot secrétaire → praticien ----
$chk = $pdo->prepare("
SELECT can_manage_rdv
FROM secretaire_praticien
WHERE secretaire_user_id = :u
AND praticien_id = :p
AND is_active = 1
LIMIT 1
");
$chk->execute([':u' => $secretaire_user_id, ':p' => $praticien_id]);
$pivot = $chk->fetch(PDO::FETCH_ASSOC);
if (! $pivot) {http_response_code(403);exit('Forbidden');}
$canManage = (int) ($pivot['can_manage_rdv'] ?? 0) === 1;
// ---- Infos praticien ----
$stmt = $pdo->prepare("SELECT nom, prenom, duree_rdv, adresse, ville, code_postal FROM praticien WHERE id = ?");
$stmt->execute([$praticien_id]);
$praticien = $stmt->fetch(PDO::FETCH_ASSOC);
$duree_minutes = (int) ($praticien['duree_rdv'] ?? 0);
if ($duree_minutes <= 0) {
die($translations['duree_rdv_non_definie'] ?? "Durée de RDV non définie.");
}
// Adresse complète ?
$has_address =
! empty(trim((string) ($praticien['adresse'] ?? ''))) &&
! empty(trim((string) ($praticien['ville'] ?? ''))) &&
! empty(trim((string) ($praticien['code_postal'] ?? '')));
// ---- Dispos praticien ----
$stmt = $pdo->prepare("SELECT jour_semaine, heure_debut, heure_fin FROM disponibilite WHERE praticien_id = ?");
$stmt->execute([$praticien_id]);
$dispos = $stmt->fetchAll(PDO::FETCH_ASSOC);
// ---- RDV réservés (prochains 21 jours) ----
$tz = new DateTimeZone('Europe/Paris');
$now = new DateTimeImmutable('now', $tz);
$limit = $now->modify('+60 days');
$stmt = $pdo->prepare("SELECT date_rdv FROM rendez_vous WHERE praticien_id = ? AND date_rdv BETWEEN ? AND ?");
$stmt->execute([$praticien_id, $now->format('Y-m-d H:i:s'), $limit->format('Y-m-d H:i:s')]);
$reservations = array_map(
fn($r) => (new DateTimeImmutable($r['date_rdv'], $tz))->format('Y-m-d H:i'),
$stmt->fetchAll(PDO::FETCH_ASSOC)
);
// ---- i18n jours ----
$DAY_I18N = [
'monday' => 'jour_lundi',
'tuesday' => 'jour_mardi',
'wednesday' => 'jour_mercredi',
'thursday' => 'jour_jeudi',
'friday' => 'jour_vendredi',
'saturday' => 'jour_samedi',
'sunday' => 'jour_dimanche',
];
function normalize_day_key(string $label): ?string
{
$l = mb_strtolower(trim($label));
static $map = [
// EN
'monday' => 'monday', 'tuesday' => 'tuesday', 'wednesday' => 'wednesday',
'thursday' => 'thursday', 'friday' => 'friday', 'saturday' => 'saturday', 'sunday' => 'sunday',
// FR
'lundi' => 'monday', 'mardi' => 'tuesday', 'mercredi' => 'wednesday',
'jeudi' => 'thursday', 'vendredi' => 'friday', 'samedi' => 'saturday', 'dimanche' => 'sunday',
// RU
'понедельник' => 'monday', 'вторник' => 'tuesday', 'среда' => 'wednesday',
'четверг' => 'thursday', 'пятница' => 'friday', 'суббота' => 'saturday', 'воскресенье' => 'sunday',
];
return $map[$l] ?? null;
}
// ---- Génération créneaux par jour ----
$creneaux_par_jour = [];
$periode = new DatePeriod($now, new DateInterval('P1D'), $limit); // $limit exclusif
foreach ($periode as $jour) {
$dayKey = strtolower($jour->format('l'));
$i18nKey = $DAY_I18N[$dayKey] ?? null;
$jour_nom = $i18nKey ? ($translations[$i18nKey] ?? ucfirst($dayKey)) : ucfirst($dayKey);
foreach ($dispos as $dispo) {
$dispoKey = normalize_day_key($dispo['jour_semaine'] ?? '');
if ($dispoKey !== $dayKey) {
continue;
}
[$h1, $m1, $s1] = array_map('intval', explode(':', (string) $dispo['heure_debut']));
[$h2, $m2, $s2] = array_map('intval', explode(':', (string) $dispo['heure_fin']));
$debut_creneau = $jour->setTime($h1, $m1, $s1);
$fin_creneau = $jour->setTime($h2, $m2, $s2);
if ($fin_creneau <= $debut_creneau) {
continue;
}
$step = new DateInterval("PT{$duree_minutes}M");
for ($t = $debut_creneau; $t->add($step) <= $fin_creneau; $t = $t->add($step)) {
if ($t <= $now) {
continue;
}
$debut_str = $t->format('Y-m-d H:i');
if (! in_array($debut_str, $reservations, true)) {
$key = $jour_nom . '<br> ' . $jour->format('d/m');
$creneaux_par_jour[$key][] = $t;
}
}
}
}
// Tri horaire dans chaque colonne
foreach ($creneaux_par_jour as &$slots) {
usort(
$slots,
fn(DateTimeImmutable $a, DateTimeImmutable $b) => $a->getTimestamp() <=> $b->getTimestamp()
);
}
unset($slots);
// Pagination colonnes
$page = isset($_GET['page']) ? max(1, (int) $_GET['page']) : 1;
$limit_columns = 5;
$all_keys = array_keys($creneaux_par_jour);
$total_pages = max(1, (int) ceil(count($all_keys) / $limit_columns));
$offset = ($page - 1) * $limit_columns;
$visible_keys = array_slice($all_keys, $offset, $limit_columns);
// petit helper
if (! function_exists('h')) {function h($s)
{return htmlspecialchars((string) $s, ENT_QUOTES, 'UTF-8');}}
$PAGE_TITRE = $translations['creneaux_disponibles_title'] ?? 'Créneaux disponibles';
$HEAD_EXTRA = <<<HTML
<script src="/js/sweetalert2@11.js"></script>
HTML;
require_once __DIR__ . '/../includes/header_cabinet.php';
?>
<div class="main1">
<div class="container py-5">
<div class="shadow p-4 rounded bg-light rdv-card">
<h2 class="mb-4 text-center">
<?php echo h(($praticien['prenom'] ?? '') . ' ' . ($praticien['nom'] ?? '')) ?> - <?php echo h($translations['zapis'] ?? 'Prendre rendez-vous') ?>
<?php if (! $canManage): ?>
<small class="text-danger d-block mt-1"><?php echo h($translations['droits_insuffisants'] ?? 'Droits insuffisants pour réserver') ?></small>
<?php endif; ?>
</h3>
<?php if ($total_pages > 1): ?>
<div class="pagination mb-3">
<?php if ($page > 1): ?>
<a class="btn btn-outline-secondary btn-sm"
href="?praticien_id=<?php echo (int) $praticien_id ?>&<?php echo $client_id ? 'client_id=' . (int) $client_id . '&' : '' ?>page=<?php echo (int) ($page - 1) ?>">
&laquo; <?php echo h($translations['pagination_precedent'] ?? 'Précédent') ?>
</a>
<?php endif; ?>
<?php if ($page < $total_pages): ?>
<a class="btn btn-outline-secondary btn-sm"
href="?praticien_id=<?php echo (int) $praticien_id ?>&<?php echo $client_id ? 'client_id=' . (int) $client_id . '&' : '' ?>page=<?php echo (int) ($page + 1) ?>">
<?php echo h($translations['pagination_suivant'] ?? 'Suivant') ?> &raquo;
</a>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if (empty($creneaux_par_jour)): ?>
<p class="rdv-empty">
<?php echo h($translations['aucun_creneau_disponible_3_semaines'] ?? 'Aucun créneau disponible sur 3 semaines.') ?><br><br>
<a class="btn btn-outline-secondary btn-sm btn-table" href="/messages/start.php?praticien_id=<?php echo (int) $praticien_id ?>">
<?php echo h($translations['chat_praticien'] ?? 'Ouvrir le chat') ?>
</a>
</p>
<?php else: ?>
<div class="grid-container">
<?php foreach ($visible_keys as $jour): ?>
<div class="day-column">
<h4><?php echo $jour ?></h4>
<?php foreach ($creneaux_par_jour[$jour] as $c): ?>
<?php
$startIso = $c->format(DateTime::ATOM);
$slotName = 'mode_rdv_' . $c->format('YmdHi');
$onlineLabel = $translations['option_online'] ?? 'Онлайн';
$cabinetLabel = $translations['option_cabinet'] ?? 'В кабинете';
// URL base vers l’étape de validation (version cabinet)
$validerBase = '/public/cabinet/valider_rdv.php';
$validerHref = $validerBase
. '?praticien_id=' . (int) $praticien_id
. '&start=' . rawurlencode($startIso)
. ($client_id ? '&client_id=' . (int) $client_id : '');
?>
<div class="slot">
<div class="slot-time"><?php echo h($c->format('H:i')) ?></div>
<?php if ($has_address): ?>
<div class="mode-group my-2">
<label class="me-3">
<input type="radio" name="<?php echo h($slotName) ?>" value="<?php echo h($c_online) ?>" checked>
<?php echo h($onlineLabel) ?>
</label>
<label>
<input type="radio" name="<?php echo h($slotName) ?>" value="<?php echo h($c_offline) ?>">
<?php echo h($cabinetLabel) ?>
</label>
</div>
<?php else: ?>
<div class="mode-group my-2"><span><?php echo h($onlineLabel) ?></span></div>
<?php endif; ?>
<a href="<?php echo h($validerHref) ?>"
class="reserver-btn"
data-mode-name="<?php echo h($has_address ? $slotName : '') ?>"
data-default-mode="<?php echo h($c_online) ?>"
data-nom="<?php echo h($praticien['nom'] ?? '') ?>"
data-prenom="<?php echo h($praticien['prenom'] ?? '') ?>"
data-start="<?php echo h($startIso) ?>"
<?php echo $canManage ? '' : 'aria-disabled="true" tabindex="-1" style="pointer-events:none;opacity:.6;"' ?>
>
<?php echo h($translations['btn_reserver'] ?? 'Réserver') ?>
</a>
</div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
</div>
<script>
document.querySelectorAll('.reserver-btn').forEach(btn => {
btn.addEventListener('click', function (e) {
e.preventDefault();
const start = this.dataset.start;
const nom = this.dataset.nom;
const prenom = this.dataset.prenom;
// Récupérer le mode choisi
const groupName = this.dataset.modeName;
let modeVal = this.dataset.defaultMode || 'online';
if (groupName) {
const checked = this.closest('.slot').querySelector(`input[name="${groupName}"]:checked`);
if (checked) modeVal = checked.value;
}
const date = new Date(start);
const jour = date.toLocaleDateString('ru-RU', { weekday: 'long', day: '2-digit', month: '2-digit' });
const heure = date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
Swal.fire({
title: <?php echo json_encode($translations['confirmation'] ?? 'Confirmation') ?>,
html: `<b>${prenom} ${nom}<br>${jour} <?php echo $translations['a'] ?? 'à' ?> ${heure}</b>`,
icon: 'question',
showCancelButton: true,
confirmButtonText: <?php echo json_encode($translations['btn_oui_reserver'] ?? 'Oui, réserver') ?>,
cancelButtonText: <?php echo json_encode($translations['btn_annuler'] ?? 'Annuler') ?>
}).then((result) => {
if (result.isConfirmed) {
const url = new URL(this.href, location.origin);
url.searchParams.set('mode_rdv', modeVal);
window.location.href = url.toString();
}
});
});
});
</script>
<footer class="site-footer">
<div class="container">
<p><?php echo $translations['copyr'] ?? '&copy; ' . date('Y') ?></p>
</div>
</footer>
</body>
</html>
+879
View File
@@ -0,0 +1,879 @@
<?php
// /public/cabinet/valider_rdv.php
require_once __DIR__ . '/../includes/init_secretaire.php';
$ROOT = dirname(__DIR__, 2);
require_once APP_HTDOCS . '/push/push_endpoint.php';
// $MAILCFG
// (Facultatif) SMS/WhatsApp si tu l'utilises côté client
$notifyPath = APP_HTDOCS . '/sms/notify_rdv.php';
if (is_file($notifyPath)) require_once $notifyPath;
use PHPMailer\PHPMailer\PHPMailer;
/* ================== Const / helpers ================== */
$c_online = $c_online ?? 'online';
$c_offline = $c_offline ?? 'offline';
if (empty($_SESSION['csrf_token'])) $_SESSION['csrf_token'] = bin2hex(random_bytes(16));
$CSRF = $_SESSION['csrf_token'];
function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
function email_norm(string $e): string { return strtolower(trim($e)); }
/* ------- Mail helpers ------- */
function send_mail_ics(
string $to, string $toName, string $subject, string $text, string $html,
?string $icsName=null, ?string $icsContent=null, ?string $icsMethod='REQUEST'
): bool {
global $MAILCFG;
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) return false;
$m = new PHPMailer(true);
try {
$m->isSMTP();
$m->Host = $MAILCFG['smtp']['host']; $m->Port = (int)$MAILCFG['smtp']['port'];
$m->SMTPSecure = $MAILCFG['smtp']['secure'] ?? PHPMailer::ENCRYPTION_STARTTLS;
$m->SMTPAuth = (bool)($MAILCFG['smtp']['auth'] ?? true);
$m->Username = $MAILCFG['smtp']['username'] ?? '';
$m->Password = $MAILCFG['smtp']['password'] ?? '';
$m->CharSet = 'UTF-8'; $m->Encoding = 'base64'; $m->XMailer = '';
if (!empty($MAILCFG['bounce'])) $m->Sender = $MAILCFG['bounce'];
$from = $MAILCFG['from'] ?? ['no-reply@uslugi.fr','USLUGI.FR'];
$rpto = $MAILCFG['reply_to'] ?? ['contact@uslugi.fr','USLUGI.FR'];
$m->setFrom($from[0], $from[1] ?? '');
if (!empty($rpto[0])) $m->addReplyTo($rpto[0], $rpto[1] ?? '');
$m->addAddress($to, $toName);
if (!empty($MAILCFG['suppress_auto'])) {
$m->addCustomHeader('Auto-Submitted','auto-generated');
$m->addCustomHeader('X-Auto-Response-Suppress','All');
}
$m->isHTML(true); $m->Subject = $subject; $m->Body = $html; $m->AltBody = $text;
if ($icsName && $icsContent !== null && $icsMethod) {
$m->addStringAttachment($icsContent, $icsName, 'base64', 'text/calendar; method='.$icsMethod.'; charset=UTF-8');
}
$m->send(); return true;
} catch(Throwable $e){ error_log('send_mail_ics: '.$e->getMessage()); return false; }
}
/* ====== Email template (même rendu pour client/praticien) ====== */
function email_tpl_html(array $d, array $t): string {
$brand = '<img src="https://uslugi.fr/public/uploads/img/logo_mail.png" width="150" alt="USLUGI.FR">';
$brand_bas ='УСЛУГИ.ФР';
return '<!DOCTYPE html><html lang="ru"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>USLUGI.FR</title></head>
<body style="margin:0;padding:0;background:#f4f6f8;font-family:Arial,Helvetica,sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f4f6f8;padding:24px 0;"><tr><td align="center">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:8px;overflow:hidden;">
<tr><td style="background:#38378B;color:#fff;padding:20px 24px;font-size:18px;font-weight:600;">'.$brand.'</td></tr>
<tr><td style="padding:24px 24px 8px 24px;color:#111;font-size:16px;line-height:1.5;">'.nl2br(htmlspecialchars($d['hello'])).'</td></tr>
<tr><td style="padding:8px 24px;color:#111;font-size:16px;line-height:1.6;">'.nl2br(htmlspecialchars($d['intro'])).'</td></tr>
<tr><td style="padding:16px 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e5e7eb;border-radius:8px;">
<tr><td style="padding:14px 16px;font-size:14px;color:#6b7280;border-bottom:1px solid #e5e7eb;">'.htmlspecialchars($d['who_label']).'</td><td style="padding:14px 16px;font-size:14px;color:#111;">'.htmlspecialchars($d['who_value']).'</td></tr>
<tr><td style="padding:14px 16px;font-size:14px;color:#6b7280;">'.htmlspecialchars($t['date_label'] ?? 'Date').'</td><td style="padding:14px 16px;font-size:14px;color:#111;">'.htmlspecialchars($d['when']).'</td></tr>'.
(!empty($d['format_line']) ? '<tr><td style="padding:14px 16px;font-size:14px;color:#6b7280;">'.htmlspecialchars($t['format_label'] ?? 'Формат').'</td><td style="padding:14px 16px;font-size:14px;color:#111;">'.htmlspecialchars($d['format_line']).'</td></tr>' : '').
(!empty($d['address_line']) ? '<tr><td style="padding:14px 16px;font-size:14px;color:#6b7280;">'.htmlspecialchars($t['adresse_label'] ?? 'Adresse').'</td><td style="padding:14px 16px;font-size:14px;color:#111;">'.htmlspecialchars($d['address_line']).'</td></tr>' : '').
'</table>
</td></tr>'.
(!empty($d['cta_url']) ? '<tr><td align="center" style="padding:8px 24px 24px 24px;"><a href="'.htmlspecialchars($d['cta_url']).'" target="_blank" style="display:inline-block;text-decoration:none;padding:12px 20px;border-radius:999px;background:#E03C3F;color:#fff;font-weight:600;">'.htmlspecialchars($d['cta_label']).'</a></td></tr>' : '').
'<tr><td style="padding:8px 24px 24px 24px;color:#111;font-size:16px;line-height:1.6;">'.nl2br(htmlspecialchars($d['footer'])).'</td></tr>
</table>
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="margin-top:12px;"><tr><td style="text-align:center;color:#9ca3af;font-size:12px;padding:8px 0;">© '.date('Y').' '.$brand_bas.' — '.htmlspecialchars($t['merci_simple'] ?? 'Merci').'</td></tr></table>
</td></tr></table></body></html>';
}
function email_tpl_text(array $d, array $t): string {
$L=[]; $L[]=$d['hello']; $L[]=''; $L[]=$d['intro']; $L[]='';
$L[]=($d['who_label']).': '.$d['who_value'];
$L[]=($t['date_label'] ?? 'Date').': '.$d['when'];
if (!empty($d['format_line'])) $L[]=($t['format_label'] ?? 'Формат').': '.$d['format_line'];
if (!empty($d['address_line'])) $L[]=($t['adresse_label'] ?? 'Adresse').': '.$d['address_line'];
if (!empty($d['cta_url'])) { $L[]=''; $L[]=($d['cta_label'] ?? 'Voir les détails').': '.$d['cta_url']; }
$L[]=''; $L[]=$d['footer']; return implode("\n",$L);
}
/* ------- ICS helpers ------- */
function ics_escape(string $s): string {
$s = str_replace("\\","\\\\",$s);
$s = str_replace(";","\;",$s);
$s = str_replace(",","\,",$s);
return preg_replace("/\r\n|\r|\n/","\\n",$s);
}
function ics_build(
string $uid, DateTime $start, DateTime $end, string $tz, string $summary,
string $desc, string $loc, array $attendees, string $method='REQUEST', string $status='CONFIRMED'
): string {
$L=[];
$L[]="BEGIN:VCALENDAR";
$L[]="VERSION:2.0";
$L[]="PRODID:-//УСЛУГИ.ФР//RDV//FR";
$L[]="CALSCALE:GREGORIAN";
$L[]="METHOD:{$method}";
$L[]="BEGIN:VEVENT";
$L[]="UID:{$uid}";
$L[]="DTSTAMP:".gmdate('Ymd\THis\Z');
$L[]="DTSTART;TZID={$tz}:".$start->format('Ymd\THis');
$L[]="DTEND;TZID={$tz}:".$end->format('Ymd\THis');
if ($summary) $L[]="SUMMARY:".ics_escape($summary);
if ($desc) $L[]="DESCRIPTION:".ics_escape($desc);
if ($loc) $L[]="LOCATION:".ics_escape($loc);
$L[]="ORGANIZER;CN=УСЛУГИ.ФР:mailto:rdv@uslugi.fr";
foreach ($attendees as $a) {
if (empty($a['email'])) continue;
$cn = ics_escape($a['cn'] ?? '');
$L[]="ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;CN={$cn};RSVP=TRUE:mailto:".$a['email'];
}
$L[]="STATUS:{$status}";
$L[]="END:VEVENT";
$L[]="END:VCALENDAR";
return implode("\r\n",$L)."\r\n";
}
/* ================== Entrées ================== */
$secretaire_user_id = (int)($_SESSION['compte_id'] ?? 0);
$praticien_id = isset($_GET['praticien_id']) ? (int)$_GET['praticien_id'] : (int)($_POST['praticien_id'] ?? 0);
$startIso = $_GET['start'] ?? ($_POST['start'] ?? null);
$mode_rdv_in = $_GET['mode_rdv'] ?? ($_POST['mode_rdv'] ?? null);
$client_id = isset($_GET['client_id']) ? (int)$_GET['client_id'] : (isset($_POST['client_id']) ? (int)$_POST['client_id'] : 0);
/* ================== Vérif pivot secrétaire ↔ praticien ================== */
$chk = $pdo->prepare("
SELECT can_manage_rdv
FROM secretaire_praticien
WHERE secretaire_user_id = :u
AND praticien_id = :p
AND is_active = 1
LIMIT 1
");
$chk->execute([':u'=>$secretaire_user_id, ':p'=>$praticien_id]);
$pivot = $chk->fetch(PDO::FETCH_ASSOC);
if (!$pivot) { http_response_code(403); exit('<div style="padding:24px">Forbidden</div>'); }
if (((int)($pivot['can_manage_rdv'] ?? 0)) !== 1) {
http_response_code(403);
exit('<div style="padding:24px">'.h($translations['erreur_droits_reservation'] ?? 'Droits insuffisants pour réserver').'</div>');
}
/* ================== Récup praticien ================== */
$stmt = $pdo->prepare("SELECT nom, prenom, duree_rdv, adresse, ville, code_postal, email FROM praticien WHERE id=?");
$stmt->execute([$praticien_id]);
$praticien = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$praticien) { http_response_code(404); exit('Praticien introuvable'); }
$duree = (int)($praticien['duree_rdv'] ?? 60);
$has_address =
!empty(trim((string)($praticien['adresse'] ?? ''))) &&
!empty(trim((string)($praticien['ville'] ?? ''))) &&
!empty(trim((string)($praticien['code_postal'] ?? '')));
/* ================== Mode POST: AJAX (recherche / création / validation) ================== */
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
/* ----- Recherche dynamique de client plateforme non lié ----- */
// ----- Recherche dynamique de client plateforme non lié -----
if (($_POST['action'] ?? '') === 'search_client') {
header('Content-Type: application/json; charset=utf-8');
if (!hash_equals($_SESSION['csrf_token'] ?? '', $_POST['csrf'] ?? '')) {
echo json_encode(['ok'=>false, 'items'=>[], 'msg'=>'CSRF']); exit;
}
$q = trim((string)($_POST['q'] ?? ''));
if ($q === '' || mb_strlen($q) < 2) { // évite spam / vide
echo json_encode(['ok'=>true, 'items'=>[]]); exit;
}
try {
$like = '%'.$q.'%';
// NOTE: si votre table client n'a PAS adresse/ville/code_postal,
// remplacez la ligne SELECT par: SELECT c.id, c.prenom, c.nom, c.email, c.telephone
$sql = "
SELECT c.id, c.prenom, c.nom, c.email, c.telephone, c.adresse, c.ville, c.code_postal
FROM client c
WHERE (
c.email LIKE :like_email
OR CONCAT(c.prenom,' ',c.nom) LIKE :like_np1
OR CONCAT(c.nom,' ',c.prenom) LIKE :like_np2
OR c.telephone LIKE :like_tel
)
AND NOT EXISTS (
SELECT 1 FROM client_dossiers d
WHERE d.client_id = c.id
AND d.praticien_id = :pid
)
ORDER BY c.prenom, c.nom
LIMIT 12
";
$s = $pdo->prepare($sql);
$s->execute([
':like_email' => $like,
':like_np1' => $like,
':like_np2' => $like,
':like_tel' => $like,
':pid' => $praticien_id,
]);
$items = $s->fetchAll(PDO::FETCH_ASSOC) ?: [];
echo json_encode(['ok'=>true, 'items'=>$items]); exit;
} catch (Throwable $e) {
error_log('search_client: '.$e->getMessage());
echo json_encode(['ok'=>false, 'items'=>[], 'msg'=>'Erreur serveur']); exit;
}
}
// création client rapide ?
if (($_POST['action'] ?? '') === 'create_client') {
header('Content-Type: application/json; charset=utf-8');
if (!hash_equals($_SESSION['csrf_token'] ?? '', $_POST['csrf'] ?? '')) {
http_response_code(400);
exit(json_encode(['ok'=>false,'msg'=>'CSRF']));
}
$nom = trim((string)($_POST['nom'] ?? ''));
$prenom = trim((string)($_POST['prenom'] ?? ''));
$email = email_norm((string)($_POST['email'] ?? ''));
$tel = trim((string)($_POST['telephone'] ?? ''));
$dob = trim((string)($_POST['date_naissance'] ?? '')); // YYYY-MM-DD
if ($nom === '' || $prenom === '' || $email === '' || $tel === '') {
exit(json_encode(['ok'=>false,'msg'=>($translations['donnees_manquantes_reservation'] ?? 'Champs manquants')]));
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
exit(json_encode(['ok'=>false,'msg'=>($translations['email_invalide'] ?? 'Email invalide')]));
}
try {
$pdo->beginTransaction();
// Si un compte existe déjà pour cet email
$acc = $pdo->prepare("SELECT id, role, reference_id FROM compte WHERE email = ? LIMIT 1");
$acc->execute([$email]);
$exist = $acc->fetch(PDO::FETCH_ASSOC);
if ($exist) {
if (($exist['role'] ?? '') !== 'client') {
// Email déjà utilisé par praticien/admin/secretaire => refuser création
$pdo->rollBack();
exit(json_encode(['ok'=>false,'msg'=>($translations['email_deja_utilise'] ?? 'Cet email est déjà utilisé.')]));
}
// Récupérer le client lié via reference_id
$cid = (int)($exist['reference_id'] ?? 0);
if ($cid > 0) {
// Pas de re-création, on renvoie le client existant pour réserver directement
$pdo->commit();
exit(json_encode(['ok'=>true,'client_id'=>$cid,'msg'=>($translations['client_existant_utilise'] ?? 'Client existant sélectionné.')]));
}
// Compte client sans fiche client (rare) -> on crée la fiche et on lie le compte
$insC = $pdo->prepare("
INSERT INTO client (nom, prenom, email, telephone, date_naissance, accepted_cgu)
VALUES (?,?,?,?,?,1)
");
$insC->execute([$nom, $prenom, $email, $tel, ($dob ?: null)]);
$newClientId = (int)$pdo->lastInsertId();
$upd = $pdo->prepare("UPDATE compte SET reference_id = ? WHERE id = ? LIMIT 1");
$upd->execute([$newClientId, (int)$exist['id']]);
$pdo->commit();
exit(json_encode(['ok'=>true,'client_id'=>$newClientId,'msg'=>($translations['client_cree_ok'] ?? 'Client créé.')]));
}
// Sinon : créer la fiche client
$ins = $pdo->prepare("
INSERT INTO client (nom, prenom, email, telephone, date_naissance, accepted_cgu)
VALUES (?,?,?,?,?,1)
");
$ins->execute([$nom, $prenom, $email, $tel, ($dob ?: null)]);
$newClientId = (int)$pdo->lastInsertId();
// Créer le compte avec MDP provisoire hashé + token de validation email
$provisional = 'Uslugi_11992288';
$hash = password_hash($provisional, PASSWORD_DEFAULT);
$token = bin2hex(random_bytes(32)); // TTL 24h géré côté endpoint (via cree_le ou table dédiée)
$accIns = $pdo->prepare("
INSERT INTO compte (email, mot_de_passe, role, reference_id, token_validation, email_valide, cree_le, modifie_le)
VALUES (?,?,?,?,?,0,NOW(),NOW())
");
$accIns->execute([$email, $hash, 'client', $newClientId, $token]);
$pdo->commit();
// Envoyer l'email d'activation (sans ICS)
try {
$activationUrl = 'https://uslugi.fr/valider_email_cabinet.php?token='.urlencode($token).'&email='.urlencode($email);
$subj_a = $translations['activation_compte_sujet'] ?? 'Активируйте ваш аккаунт / Activez votre compte';
$helloName = trim($prenom.' '.$nom);
$dataA = [
'hello' => sprintf($translations['mail_valid_bonjour_nom'] ?? 'Bonjour %s', $helloName),
'intro' => $translations['activation_compte_intro'] ??
"Чтобы завершить создание аккаунта, пожалуйста подтвердите ваш e-mail и задайте новый пароль.\nPour finaliser la création de votre compte, confirmez votre e-mail et définissez votre mot de passe.",
'who_label' => $translations['email_label'] ?? 'E-mail',
'who_value' => $email,
'when' => date('d/m/Y H:i'),
'format_line' => '',
'address_line'=> '',
'cta_url' => $activationUrl,
'cta_label' => $translations['placeholder_nouveau_mdp'] ?? 'Définir le mot de passe',
'footer' => $translations['merci_simple'] ?? 'Merci.',
];
$text_a = email_tpl_text($dataA, $translations);
$html_a = email_tpl_html($dataA, $translations);
// Pas de pièce jointe ICS ici
send_mail_ics($email, $helloName, $subj_a, $text_a, $html_a, null, null, null);
} catch(Throwable $e) {
error_log('activation email send failed: '.$e->getMessage());
}
exit(json_encode([
'ok' => true,
'client_id' => $newClientId,
'msg' => ($translations['client_cree_ok'] ?? 'Client créé.')
]));
} catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
error_log('create_client: '.$e->getMessage());
exit(json_encode(['ok'=>false,'msg'=>'Erreur']));
}
}
// validation RDV
if (($_POST['action'] ?? '') === 'validate') {
header('Content-Type: application/json; charset=utf-8');
if (!hash_equals($_SESSION['csrf_token'] ?? '', $_POST['csrf'] ?? '')) {
echo json_encode(['ok'=>false,'html'=>'<div class="msg"><h2>CSRF</h2></div>']); exit;
}
$client_id = (int)($_POST['client_id'] ?? 0);
$startIso = $_POST['start'] ?? null;
$mode_in = $_POST['mode_rdv'] ?? null;
if ($client_id <= 0 || !$startIso || $praticien_id <= 0) {
echo json_encode(['ok'=>false,'html'=>'<div class="msg"><h2>'.h($translations['donnees_manquantes_reservation'] ?? 'Données manquantes').'</h2></div>']); exit;
}
// vérifie que le client existe
$c = $pdo->prepare("SELECT id, email, nom, prenom, telephone FROM client WHERE id=?");
$c->execute([$client_id]);
$client = $c->fetch(PDO::FETCH_ASSOC);
if (!$client) {
echo json_encode(['ok'=>false,'html'=>'<div class="msg"><h2>Client introuvable</h2></div>']); exit;
}
// Optionnel: lier au praticien ici si tu veux (sinon, tu as dit que ça se fera à la confirmation)
// $pl = $pdo->prepare("INSERT IGNORE INTO client_dossiers (praticien_id, client_id, cree_le) VALUES (?,?,NOW())");
// $pl->execute([$praticien_id, $client_id]);
// créneau libre ?
$date_sql = (new DateTime($startIso))->format('Y-m-d H:i:s');
$dup = $pdo->prepare("SELECT COUNT(*) FROM rendez_vous WHERE praticien_id=? AND date_rdv=?");
$dup->execute([$praticien_id, $date_sql]);
if ((int)$dup->fetchColumn() > 0) {
echo json_encode(['ok'=>false,'html'=>'<div class="msg"><h2>'.h($translations['creneau_deja_reserve'] ?? 'Créneau déjà réservé').'</h2></div>']); exit;
}
// mode / location
$mode = ($mode_in === $GLOBALS['c_offline']) ? 'offline' : 'online';
if ($mode === 'offline' && !$GLOBALS['has_address']) $mode = 'online';
// insert RDV (statut 'prévu')
$uuid = bin2hex(random_bytes(16));
$ins = $pdo->prepare("
INSERT INTO rendez_vous (praticien_id, client_id, date_rdv, duree_minutes, lieu, statut, commentaire, uuid_ical, created_at)
VALUES (?, ?, ?, ?, ?, 'prévu', '', ?, NOW())
");
$ins->execute([$praticien_id, $client_id, $date_sql, $duree, $mode, $uuid]);
// =========================
// LOG RDV (rdv_actions_log) : create (secrétaire)
// =========================
$rdvIdCreated = (int)$pdo->lastInsertId();
// ✅ compte_id client + praticien (pour badge counters)
$clientCompteId = 0;
$praticienCompteId = 0;
$st = $pdo->prepare("SELECT id FROM compte WHERE role='client' AND reference_id=? LIMIT 1");
$st->execute([(int)$client_id]);
$clientCompteId = (int)($st->fetchColumn() ?: 0);
$st = $pdo->prepare("SELECT id FROM compte WHERE role='praticien' AND reference_id=? LIMIT 1");
$st->execute([(int)$praticien_id]);
$praticienCompteId = (int)($st->fetchColumn() ?: 0);
// ✅ Badge RDV : +1 pour le client (RDV créé par secrétaire)
if ($clientCompteId > 0) {
$pdo->prepare("
INSERT INTO push_badge_counters (compte_id, msg_unread, rdv_unread)
VALUES (:cid, 0, 1)
ON DUPLICATE KEY UPDATE
rdv_unread = LEAST(99, rdv_unread + 1),
updated_at = NOW()
")->execute([':cid' => $clientCompteId]);
}
// ✅ Badge RDV : +1 pour le praticien (nouveau RDV à confirmer/voir)
if ($praticienCompteId > 0) {
$pdo->prepare("
INSERT INTO push_badge_counters (compte_id, msg_unread, rdv_unread)
VALUES (:pid, 0, 1)
ON DUPLICATE KEY UPDATE
rdv_unread = LEAST(99, rdv_unread + 1),
updated_at = NOW()
")->execute([':pid' => $praticienCompteId]);
}
$afterJson = json_encode([
'statut' => 'prévu',
'date_rdv' => $date_sql,
'duree_minutes' => (int)$duree,
'lieu' => $mode, // 'online'/'offline'
'praticien_id' => (int)$praticien_id,
'client_id' => (int)$client_id,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$logStmt = $pdo->prepare("
INSERT INTO rdv_actions_log
(rdv_id, action, by_user_id, by_role, before_json, after_json, created_at)
VALUES
(?, 'create', ?, 'secretaire', NULL, ?, NOW())
");
$logStmt->execute([$rdvIdCreated, $secretaire_user_id, $afterJson]);
// =========================
// =========================
// PUSH RDV — création (par secrétaire)
// =========================
try {
// 🔔 Client : nouveau RDV créé
push_rdv_to_client(
$pdo,
(int)$client_id,
$translations['push_rdv_created_title'] ?? 'Новая запись',
$translations['push_rdv_created_body'] ?? 'Для вас создана новая запись.',
'/public/client/mes_rendez_vous.php',
[
'rdv_id' => (int)$rdvIdCreated,
'action' => 'create',
'from_role' => 'secretaire',
'praticien_id' => (int)$praticien_id,
'client_id' => (int)$client_id,
'date_rdv' => $date_sql,
]
);
// 🔔 Praticien : nouveau RDV créé par secrétaire
push_rdv_to_praticien(
$pdo,
(int)$praticien_id,
$translations['push_rdv_created_praticien_title'] ?? 'Новая запись',
$translations['push_rdv_created_praticien_body'] ?? 'Секретарь создал(а) новую запись.',
'/public/praticien/rendez_vous.php',
[
'rdv_id' => (int)$rdvIdCreated,
'action' => 'create',
'from_role' => 'secretaire',
'secretaire_id' => (int)$secretaire_user_id,
'praticien_id' => (int)$praticien_id,
'client_id' => (int)$client_id,
'date_rdv' => $date_sql,
]
);
} catch (Throwable $e) {
error_log('push rdv create (secretaire) failed: '.$e->getMessage());
}
// Récup infos complètes pour e-mails
$q = $pdo->prepare("
SELECT rv.id AS rdv_id, rv.date_rdv, rv.duree_minutes, rv.lieu,
p.nom AS nom_praticien, p.prenom AS prenom_praticien, p.email AS email_praticien, p.telephone AS tel_praticien,
p.adresse AS p_adresse, p.ville AS p_ville, p.code_postal AS p_cp,
c.email AS email_client, c.nom AS nom_client, c.prenom AS prenom_client, c.telephone AS tel_client
FROM rendez_vous rv
JOIN praticien p ON rv.praticien_id = p.id
JOIN client c ON rv.client_id = c.id
WHERE rv.uuid_ical = ?
LIMIT 1
");
$q->execute([$uuid]);
$rdv = $q->fetch(PDO::FETCH_ASSOC);
if (!$rdv) {
echo json_encode(['ok'=>false,'html'=>'<div class="msg"><h2>RDV introuvable</h2></div>']); exit;
}
// Construire la réponse UI immédiatement
$tz='Europe/Paris'; $tzObj = new DateTimeZone($tz);
$start = new DateTime($rdv['date_rdv'], $tzObj);
$end = (clone $start)->modify('+'.(int)$rdv['duree_minutes'].' minutes');
$when_a = $start->format('d/m/Y') . ' ' . ($translations['a'] ?? 'à') . ' ' . $start->format('H:i');
$html = '<div class="card-notice" style="background:#fff;border-radius:22px;box-shadow:0 12px 40px rgba(0,0,0,.08);padding:22px;">
<h1 class="cn-title" style="margin:0 0 6px">'.h($translations['block_1'] ?? 'Спасибо за запись').'</h1>
<div class="cn-date" style="color:#60646c;margin-bottom:8px">'.h($when_a).'</div>
<p>'.h($translations['block_2'] ?? 'Ожидает подтверждения').'</p>
<div class="actions" style="margin-top:12px">
<a class="btn btn-outline" href="/public/cabinet/rendez_vous.php?praticien_id='.(int)$praticien_id.'">'.h($translations['retour_a_la_liste'] ?? 'Retour à la liste').'</a>
</div>
</div>';
// envoyer la réponse puis continuer (si FastCGI)
session_write_close();
echo json_encode(['ok'=>true,'html'=>$html]);
if (function_exists('fastcgi_finish_request')) fastcgi_finish_request();
ignore_user_abort(true);
// e-mails + ICS en arrière-plan
try {
$addr = trim(implode(', ', array_filter([
trim((string)$rdv['p_adresse']),
trim((string)$rdv['p_cp'].' '.$rdv['p_ville'])
])));
$is_offline = ($rdv['lieu'] === 'offline');
$loc = $is_offline ? ($addr ?: ($translations['option_cabinet'] ?? 'Очно')) : ($translations['option_online'] ?? 'Онлайн');
$uid = $uuid;
$ics_client = ics_build(
$uid,$start,$end,$tz,
($translations['rdv_avec'] ?? 'Rendez-vous avec').' '.($rdv['prenom_praticien'].' '.$rdv['nom_praticien']),
'', $loc,
[
['cn'=>$rdv['prenom_praticien'].' '.$rdv['nom_praticien'],'email'=>$rdv['email_praticien']],
['cn'=>$rdv['prenom_client'].' '.$rdv['nom_client'],'email'=>$rdv['email_client']]
],
'REQUEST','CONFIRMED'
);
$ics_prat = ics_build(
$uid,$start,$end,$tz,
($translations['rdv_avec'] ?? 'Rendez-vous avec').' '.($rdv['prenom_client'].' '.$rdv['nom_client']),
'', $loc,
[
['cn'=>$rdv['prenom_client'].' '.$rdv['nom_client'],'email'=>$rdv['email_client']],
['cn'=>$rdv['prenom_praticien'].' '.$rdv['nom_praticien'],'email'=>$rdv['email_praticien']]
],
'REQUEST','CONFIRMED'
);
$cta_client_url='/public/client/mes_rendez_vous.php';
$cta_prat_url ='/public/praticien/rendez_vous.php?id='.(int)$rdv['rdv_id'];
$cta_label = $translations['consultez_espace_pour_details'] ?? 'Voir les détails';
// Client — même template propre que pour le praticien
$subj_c = $translations['email_confirmation_rdv_sujet'] ?? 'Confirmation de rendez-vous';
$dataC = [
'hello' => sprintf($translations['mail_valid_bonjour_nom'] ?? 'Bonjour %s',
trim(($rdv['prenom_client'] ?? '').' '.($rdv['nom_client'] ?? ''))),
'intro' => ($translations['votre_rdv_attente'] ?? 'Votre rendez-vous est en attente de confirmation').' '.
($translations['email_avec'] ?? 'avec').' '.
trim(($rdv['prenom_praticien'] ?? '').' '.($rdv['nom_praticien'] ?? '')).'.',
'who_label' => $translations['praticien_label'] ?? 'Praticien',
'who_value' => trim(($rdv['prenom_praticien'] ?? '').' '.($rdv['nom_praticien'] ?? '')),
'when' => $when_a,
'format_line' => ($rdv['lieu'] === 'offline')
? ($translations['option_cabinet'] ?? 'В кабинете')
: ($translations['option_online'] ?? 'Онлайн'),
'address_line'=> ($rdv['lieu'] === 'offline') ? ($addr ?: '') : '',
'cta_url' => $cta_client_url,
'cta_label' => $translations['consultez_espace_pour_details'] ?? 'Voir les détails',
'footer' => $translations['merci_simple'] ?? 'Merci.',
];
$text_c = email_tpl_text($dataC, $translations);
$html_c = email_tpl_html($dataC, $translations);
send_mail_ics(
(string)$rdv['email_client'],
(string)trim(($rdv['prenom_client'] ?? '').' '.($rdv['nom_client'] ?? '')),
$subj_c, $text_c, $html_c,
'rdv_'.$uid.'_client.ics', $ics_client, 'REQUEST'
);
// Praticien
$subj_p = ($translations['nouveau_rdv_avec'] ?? 'Nouveau rendez-vous avec').' '.($rdv['prenom_client'].' '.$rdv['nom_client']);
$dataP = [
'hello' => ($translations['email_bonjour_dr'] ?? 'Bonjour').' '.($rdv['prenom_praticien'].' '.$rdv['nom_praticien']),
'intro' => ($translations['nouveau_rdv_pris_avec'] ?? 'Un nouveau rendez-vous a été pris.'),
'who_label' => $translations['client_label'] ?? 'Client',
'who_value' => ($rdv['prenom_client'].' '.$rdv['nom_client']),
'when' => $when_a,
'format_line' => ($rdv['lieu'] === 'offline')
? ($translations['option_cabinet'] ?? 'В кабинете')
: ($translations['option_online'] ?? 'Онлайн'),
'address_line'=> ($rdv['lieu'] === 'offline') ? ($addr ?: '') : '',
'cta_url' => $cta_prat_url,
'cta_label' => $cta_label,
'footer' => $translations['merci_simple'] ?? 'Merci.',
];
$text_p = email_tpl_text($dataP, $translations);
$html_p = email_tpl_html($dataP, $translations);
send_mail_ics(
(string)$rdv['email_praticien'],
(string)($rdv['prenom_praticien'].' '.$rdv['nom_praticien']),
$subj_p, $text_p, $html_p,
'rdv_'.$uid.'_praticien.ics', $ics_prat, 'REQUEST'
);
// WhatsApp côté praticien (optionnel)
if (function_exists('Sms\\notify_rdv_en_attente')) {
$waTo = (string)($rdv['tel_praticien'] ?? '');
if ($waTo) {
try { \Sms\notify_rdv_en_attente($waTo, trim($rdv['prenom_client'].' '.$rdv['nom_client']), $start->format('d/m/Y H:i'), (int)$rdv['rdv_id']); }
catch(Throwable $e){ error_log('WA notify error: '.$e->getMessage()); }
}
}
} catch(Throwable $e){
error_log('cabinet valider_rdv bg: '.$e->getMessage());
}
exit;
}
http_response_code(400);
exit('Bad request');
}
/* ================== Mode GET : UI ================== */
$title = $translations['titre_validation_secretaire'] ?? 'Validation du rendez-vous (secrétaire)';
?>
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title><?= h($title) ?></title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<script src="/js/sweetalert2@11.js"></script>
<style>
body{margin:0;background:#8CA9FF;font-family:system-ui,Segoe UI,Roboto,Arial}
.wrap{max-width:720px;margin:32px auto;padding:16px}
.card{background:#fff;border-radius:18px;box-shadow:0 12px 36px rgba(0,0,0,.08);padding:20px}
.row{display:grid;gap:12px}
label{font-weight:600}
input,select{width:97%;padding:10px;border:1px solid #e2e8f0;border-radius:10px}
.btn{display:inline-block;border-radius:999px;padding:10px 16px;text-decoration:none;font-weight:700}
.btn-primary{background:#E03C3F;color:#fff}
.btn-outline{background:#fff;border:1px solid #e2e8f0;color:#38378B}
.actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:12px}
.muted{color:#6b7280}
.hint{font-size:12px;color:#6b7280}
.pill{display:inline-block;padding:2px 8px;border-radius:999px;border:1px solid #e5e7eb;font-size:12px}
.res-item{padding:8px 10px;border:1px solid #e5e7eb;border-radius:10px;margin-bottom:8px;background:#fafafa}
.res-item .meta{color:#6b7280;font-size:12px}
.err{color:#E03C3F;font-size:12px;display:none}
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<h2 style="margin:0 0 8px"><?= h($title) ?></h2>
<p class="muted" style="margin:0 0 18px">
<?= h(($praticien['prenom'] ?? '').' '.($praticien['nom'] ?? '')) ?> — <?= h($startIso ?: '') ?>
</p>
<?php if (!$praticien_id || !$startIso): ?>
<div class="row">
<p><?= h($translations['donnees_manquantes_reservation'] ?? 'Données manquantes pour la réservation.') ?></p>
<div class="actions">
<a class="btn btn-outline" href="/public/cabinet/rendez_vous.php?praticien_id=<?= (int)$praticien_id ?>"><?= h($translations['retour_a_la_liste'] ?? 'Retour à la liste') ?></a>
</div>
</div>
<?php elseif ($client_id > 0): ?>
<!-- On a déjà un client -> lance validation directe -->
<form id="f-validate" method="post">
<input type="hidden" name="csrf" value="<?= h($CSRF) ?>">
<input type="hidden" name="action" value="validate">
<input type="hidden" name="praticien_id" value="<?= (int)$praticien_id ?>">
<input type="hidden" name="start" value="<?= h($startIso) ?>">
<input type="hidden" name="client_id" value="<?= (int)$client_id ?>">
<input type="hidden" name="mode_rdv" value="<?= h($mode_rdv_in ?? $c_online) ?>">
<p class="muted"><?= h($translations['chargement_validation'] ?? 'Подождите, идёт подтверждение...') ?></p>
</form>
<script>
(function(){
const f = document.getElementById('f-validate');
fetch(location.pathname, {
method:'POST',
headers:{'Content-Type':'application/x-www-form-urlencoded; charset=UTF-8'},
body: new URLSearchParams(new FormData(f))
}).then(r=>r.json()).then(d=>{
document.querySelector('.card').innerHTML = d.html || '<p>Erreur</p>';
}).catch(()=>{ document.querySelector('.card').innerHTML = '<p>Erreur réseau</p>'; });
})();
</script>
<?php else: ?>
<!-- Sélection / création client -->
<?php
// clients déjà liés à ce praticien
$cli = $pdo->prepare("
SELECT c.id, c.prenom, c.nom, c.email, c.telephone
FROM client_dossiers d
JOIN client c ON c.id = d.client_id
WHERE d.praticien_id = ?
ORDER BY c.prenom, c.nom
");
$cli->execute([$praticien_id]);
$clients = $cli->fetchAll(PDO::FETCH_ASSOC);
?>
<div class="row" style="margin-bottom:16px">
<label><?= h($translations['selectionner_client'] ?? 'Sélectionner un client') ?></label>
<select id="clientSelect">
<option value="0"><?= h($translations['client_existant_placeholder'] ?? '— Choisir un client existant —') ?></option>
<?php foreach ($clients as $c): ?>
<option value="<?= (int)$c['id'] ?>">
<?= h(trim(($c['prenom'] ?? '').' '.($c['nom'] ?? ''))) ?>
<?php if(!empty($c['email'])) echo ' — '.h($c['email']); ?>
</option>
<?php endforeach; ?>
</select>
</div>
<!-- Recherche globale des clients plateforme non liés à ce praticien -->
<div class="row" style="border-top:1px dashed #e2e8f0;padding-top:12px">
<label><?= h($translations['recherche_client_plateforme'] ?? 'Rechercher un client (plateforme)') ?></label>
<input type="text" id="searchClient" placeholder="<?= h($translations['placeholder_recherche_client'] ?? 'Email, Nom Prénom ou Téléphone') ?>">
<div class="hint"><?= h($translations['hint_clients_non_lies'] ?? 'Astuce : les résultats n’incluent pas les clients déjà liés à ce praticien.') ?></div>
<div id="searchResults"></div>
</div>
<div class="row" style="border-top:1px dashed #e2e8f0;padding-top:12px">
<label><?= h($translations['creer_client'] ?? 'Créer un nouveau client') ?></label>
<div class="row">
<input type="text" id="newPrenom" placeholder="<?= h($translations['prenom'] ?? 'Prénom') ?>">
<input type="text" id="newNom" placeholder="<?= h($translations['nom'] ?? 'Nom') ?>">
<input type="email" id="newEmail" placeholder="<?= h($translations['email'] ?? 'Email') ?>">
<small id="emailError" class="err"><?= h($translations['email_deja_utilise'] ?? 'Cet email est déjà utilisé.') ?></small>
<input type="text" id="newTel" placeholder="<?= h($translations['telephone'] ?? 'Téléphone') ?>">
<input type="date" id="newDob" placeholder="<?= h($translations['date_naissance'] ?? 'Date de naissance') ?>">
</div>
</div>
<div class="actions">
<a href="#" class="btn btn-primary" id="btn-continue"><?= h($translations['btn_continuer'] ?? 'Continuer') ?></a>
<a href="/public/cabinet/rendez_vous.php?praticien_id=<?= (int)$praticien_id ?>" class="btn btn-outline"><?= h($translations['btn_annuler'] ?? 'Annuler') ?></a>
</div>
<script>
(function(){
const csrf = <?= json_encode($CSRF) ?>;
const praticien_id = <?= (int)$praticien_id ?>;
const start = <?= json_encode($startIso) ?>;
const mode_rdv = <?= json_encode($mode_rdv_in ?? $c_online) ?>;
function validateWith(client_id){
const body = new URLSearchParams({ action:'validate', csrf, praticien_id, start, client_id, mode_rdv });
fetch(location.pathname, { method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded; charset=UTF-8'}, body })
.then(r=>r.json()).then(d=>{
document.querySelector('.card').innerHTML = d.html || '<p>Erreur</p>';
}).catch(()=>{ Swal.fire('Erreur', 'Réseau', 'error'); });
}
// Recherche dynamique (clients plateforme non liés)
const search = document.getElementById('searchClient');
const results = document.getElementById('searchResults');
let tId = null;
function renderResults(items){
results.innerHTML = '';
if (!items || !items.length) {
results.innerHTML = '<div class="hint"><?= h($translations['cl_aucun_resultat'] ?? 'Aucun résultat') ?></div>';
return;
}
items.forEach(it=>{
const div = document.createElement('div');
div.className = 'res-item';
const name = [it.prenom||'', it.nom||''].join(' ').trim();
const adr = [it.adresse||'', [it.code_postal||'', it.ville||''].join(' ').trim()].filter(Boolean).join(', ');
div.innerHTML =
'<div><strong>'+ (name || '—') +'</strong> <span class="pill">'+ (it.email || '—') +'</span></div>'+
'<div class="meta"><?= h($translations['telephone'] ?? 'Téléphone') ?>: '+ (it.telephone || '—') + (adr? ' • '+adr : '') +'</div>'+
'<div style="margin-top:6px"><button class="btn btn-outline" data-id="'+ it.id +'"><?= h($translations['choisir'] ?? 'Choisir') ?></button></div>';
results.appendChild(div);
});
results.querySelectorAll('button[data-id]').forEach(btn=>{
btn.addEventListener('click', (e)=>{
e.preventDefault();
const id = parseInt(btn.getAttribute('data-id'), 10)||0;
if (id>0) validateWith(id);
});
});
}
function doSearch(q){
if (!q || q.length < 2) { results.innerHTML = ''; return; }
const body = new URLSearchParams({ action:'search_client', csrf, praticien_id, q });
fetch(location.pathname, { method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded; charset=UTF-8'}, body })
.then(r => r.json())
.then(d => {
if (d.ok) { renderResults(d.items || []); }
else { results.innerHTML = '<div class="hint">'+ (d.msg || 'Erreur') +'</div>'; }
})
.catch(() => { results.innerHTML = '<div class="hint">Erreur réseau</div>'; });
}
search.addEventListener('input', function(){
if (tId) clearTimeout(tId);
tId = setTimeout(()=>doSearch(search.value.trim()), 250);
});
// Vérif e-mail live (utilise /verifier_email.php à la racine)
const emailInput = document.getElementById('newEmail');
const emailErr = document.getElementById('emailError');
let emailOk = true;
function checkEmail(){
const v = (emailInput.value||'').trim();
emailOk = true;
emailErr.style.display = 'none';
if (!v.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) return;
fetch('/verifier_email.php', {
method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded; charset=UTF-8'},
body: 'email='+encodeURIComponent(v)
}).then(r=>r.text()).then(tx=>{
if ((tx||'').trim()==='existe') {
emailOk = false;
emailErr.style.display = 'block';
}
}).catch(()=>{ /* silence */ });
}
emailInput.addEventListener('input', checkEmail);
document.getElementById('btn-continue').addEventListener('click', function(e){
e.preventDefault();
const selected = parseInt(document.getElementById('clientSelect').value, 10) || 0;
if (selected > 0) return validateWith(selected);
const prenom = document.getElementById('newPrenom').value.trim();
const nom = document.getElementById('newNom').value.trim();
const email = document.getElementById('newEmail').value.trim();
const tel = document.getElementById('newTel').value.trim();
const dob = document.getElementById('newDob').value.trim();
if (!prenom || !nom || !email || !tel) {
Swal.fire('<?= addslashes($translations['donnees_manquantes_reservation'] ?? 'Champs manquants') ?>','','warning');
return;
}
if (!emailOk) {
Swal.fire('<?= addslashes($translations['email_deja_utilise'] ?? 'Cet email est déjà utilisé.') ?>','','warning');
return;
}
const body = new URLSearchParams({ action:'create_client', csrf, praticien_id, nom, prenom, email, telephone:tel, date_naissance:dob });
fetch(location.pathname, { method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded; charset=UTF-8'}, body })
.then(r=>r.json()).then(d=>{
if (!d.ok) throw new Error(d.msg || 'Erreur');
validateWith(d.client_id);
}).catch(err=>{
Swal.fire('Erreur', err.message || 'Erreur', 'error');
});
});
})();
</script>
<?php endif; ?>
</div>
</div>
</body>
</html>