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>
+11
View File
@@ -0,0 +1,11 @@
<?php
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (isset($_GET['lang']) && in_array($_GET['lang'], ['ru', 'fr'])) {
$_SESSION['lang'] = $_GET['lang'];
}
header('Location: ' . ($_SERVER['HTTP_REFERER'] ?? '/'));
exit;
+310
View File
@@ -0,0 +1,310 @@
<?php
// /public/choisir_abonnement.php
require_once __DIR__ . '/includes/init_praticien.php';
$praticien_id = $_SESSION['reference_id'] ?? 0;
// CSRF
if (empty($_SESSION['csrf_abon'])) {
$_SESSION['csrf_abon'] = bin2hex(random_bytes(32));
}
$CSRF = $_SESSION['csrf_abon'];
// Abonnement gratuit (id=1)
$FREE_PLAN_ID = 1;
// Abonnements éligibles au renouvellement automatique (Stripe subscription)
$AUTO_ELIGIBLE_IDS = [3, 4, 5]; // mensuel, annuel, pack pro (id=5)
// Déjà utilisé ?
$hasUsedFree = false;
if ($praticien_id) {
$sql = "SELECT 1
FROM paiement
WHERE praticien_id = ?
AND abonnement_id = ?
AND (statut IS NULL OR statut <> 'échoué')
LIMIT 1";
$st = $pdo->prepare($sql);
$st->execute([$praticien_id, $FREE_PLAN_ID]);
$hasUsedFree = (bool)$st->fetchColumn();
}
// Récupérer les abonnements actifs (sauf plan 2 promo si actif=0)
$stmt = $pdo->query("SELECT * FROM abonnement WHERE actif = 1 ORDER BY prix ASC, duree_jours ASC");
$abonnements = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Si essai gratuit utilisé → retirer l'id=1
if ($hasUsedFree) {
$abonnements = array_values(array_filter($abonnements, function($ab){
return (int)$ab['id'] !== 1;
}));
}
?>
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<title><?= htmlspecialchars($translations['seo_choisir_abonnement_title'] ?? 'Тарифы для практикующего — Услуги.фр') ?></title>
<meta name="description" content="<?= htmlspecialchars($translations['seo_choisir_abonnement_desc'] ?? '') ?>">
<meta name="keywords" content="<?= htmlspecialchars($translations['seo_choisir_abonnement_keywords'] ?? '') ?>">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link href="/css/bootstrap.min.css" rel="stylesheet">
<script src="/js/sweetalert2@11.js"></script>
<style>
:root {
--brand-blue:#38378B;
--brand-red:#E03C3F;
--page-blue:#8CA9FF;
--border:#e5e7eb;
--radius:18px;
--shadow:0 8px 20px rgba(0,0,0,.06);
}
body {
background:var(--page-blue);
font-family:'Montserrat',sans-serif;
color:#111;
}
.card { border:1px solid var(--border); border-radius:var(--radius); box-shadow:var(--shadow); }
.card-title { color:var(--brand-blue); font-weight:700; }
.btn-brand { background:var(--brand-red); border-color:var(--brand-red); color:#fff; border-radius:999px; font-weight:600; }
.btn-brand:hover { background:var(--brand-red); border-color:var(--brand-red); color:#fff; border-radius:999px; font-weight:600; }
/* Bouton principal (CONFIRMER) */
.swal2-confirm {
background-color: #E03C3F !important;
border-radius: 999px !important;
padding: 10px 28px !important;
font-weight: 600 !important;
color: #fff !important;
border: none !important;
}
.swal2-confirm:hover {
background-color: #E03C3F !important;
}
/* Bouton secondaire (ANNULER) */
.swal2-cancel {
background-color: #38378B !important; /* bleu foncé */
border-radius: 999px !important;
padding: 10px 28px !important;
font-weight: 600 !important;
color: #fff !important;
border: none !important;
}
.swal2-cancel:hover {
background-color: #38378B !important;
}
/* Champ texte */
.swal2-input {
border-radius: 12px !important;
border: 1px solid #d1d5db !important;
padding: 10px !important;
font-size: 15px !important;
}
/* Fenêtre principale */
.swal2-popup {
border-radius: 18px !important;
padding: 24px !important;
font-family: 'Montserrat', sans-serif !important;
}
</style>
</head>
<body>
<div class="container py-5">
<h2 class="text-center mb-4" style="color:#fff;">
<?= htmlspecialchars($translations['choisissez_votre_abonnement'] ?? 'Выберите ваш тариф') ?>
</h2>
<!-- Choix mode de paiement -->
<div class="d-flex justify-content-center mb-4">
<div class="btn-group" role="group" aria-label="<?= htmlspecialchars($translations['mode_paiement'] ?? 'Mode de paiement') ?>">
<input type="radio" class="btn-check" name="billing_mode_ui" id="bm_manual" value="manual">
<!--<label class="btn btn-outline-light" for="bm_manual">
<?= htmlspecialchars($translations['paiement_ponctuel'] ?? 'Paiement ponctuel') ?>
</label>-->
<input type="radio" class="btn-check" name="billing_mode_ui" id="bm_auto" value="auto" checked>
<label class="btn btn-outline-light" for="bm_auto">
<?= htmlspecialchars($translations['renouvellement_auto'] ?? 'Renouvellement automatique') ?>
</label>
</div>
</div>
<?php if (!$abonnements): ?>
<div class="alert alert-warning text-center">Нет доступных тарифов.</div>
<?php else: ?>
<div class="row g-4 justify-content-center">
<?php foreach ($abonnements as $ab): ?>
<?php
$isFree = ((float)$ab['prix'] == 0.0);
$btnLabel = $isFree
? ($translations['essai_gratuit'] ?? 'Бесплатно')
: ($translations['btn_choisir_abonnement'] ?? 'Выбрать');
$prixTxt = $isFree
? ($translations['gratuit'] ?? 'Бесплатно')
: number_format((float)$ab['prix'], 2, ',', ' ') . ' €';
$nomKey = trim((string)($ab['nom'] ?? ''));
$descKey = trim((string)($ab['description'] ?? ''));
// Si la clé existe dans $translations → on affiche la traduction, sinon on affiche brut (fallback)
$nomTxt = $translations[$nomKey] ?? $nomKey;
$descTxt = $translations[$descKey] ?? $descKey;
$isAutoEligible = in_array((int)$ab['id'], $AUTO_ELIGIBLE_IDS, true);
?>
<div class="col-12 col-sm-10 col-md-6 col-lg-4 col-xl-3">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<h5 class="card-title"><?= htmlspecialchars($nomTxt) ?></h5>
<?php if ($descKey !== ''): ?>
<p class="card-text"><?= nl2br(htmlspecialchars($descTxt)) ?></p>
<?php endif; ?>
<p class="fw-bold mb-1">
<?= $translations['col_duree'] ?? 'Длительность:' ?>
<?= (int)$ab['duree_jours'] ?>
<?= $translations['dnei'] ?? 'дней' ?>
</p>
<p class="fw-bold mb-3"><?= $prixTxt ?></p>
<form method="POST"
action="/public/souscrire_abonnement.php"
class="mt-auto js-abo-form"
data-abo-id="<?= (int)$ab['id'] ?>"
data-auto-eligible="<?= $isAutoEligible ? '1' : '0' ?>">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($CSRF) ?>">
<input type="hidden" name="abonnement_id" value="<?= (int)$ab['id'] ?>">
<input type="hidden" name="billing_mode" value="manual">
<input type="hidden" name="promo_code" value="">
<button type="submit" class="btn btn-brand w-100"><?= htmlspecialchars($btnLabel) ?></button>
</form>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<script>
const I18N_PROMO = {
title: <?= json_encode($translations['promo_title'] ?? 'Promo') ?>,
placeholder: <?= json_encode($translations['promo_placeholder'] ?? '') ?>,
confirm: <?= json_encode($translations['promo_confirm'] ?? 'OK') ?>,
cancel: <?= json_encode($translations['promo_cancel'] ?? 'Cancel') ?>,
autoIndispoTitle: <?= json_encode($translations['auto_indispo_title'] ?? 'Non disponible') ?>,
autoIndispoText: <?= json_encode($translations['auto_indispo_text'] ?? "Le renouvellement automatique n'est disponible que pour les offres mensuelle/annuelle.") ?>,
promoAutoNotSupported: <?= json_encode($translations['promo_auto_not_supported'] ?? "La promo n'est pas disponible en renouvellement automatique. Utilisez le paiement ponctuel pour cette offre.") ?>
};
</script>
<script>
// ------------------------
// SweetAlert PROMO LOGIC + Billing mode
// ------------------------
function getSelectedBillingMode() {
const el = document.querySelector('input[name="billing_mode_ui"]:checked');
return el ? el.value : 'manual';
}
document.querySelectorAll(".js-abo-form").forEach(function(form){
form.addEventListener("submit", function(e){
const aboId = parseInt(form.dataset.aboId, 10);
const selectedMode = getSelectedBillingMode();
const eligibleAuto = (form.dataset.autoEligible === '1');
// Pousser le mode choisi dans le champ hidden (important avant form.submit())
const bmInput = form.querySelector('input[name="billing_mode"]');
if (bmInput) bmInput.value = selectedMode;
// Si auto choisi mais offre non éligible -> bloquer
if (selectedMode === 'auto' && !eligibleAuto) {
e.preventDefault();
Swal.fire({
icon: 'warning',
title: I18N_PROMO.autoIndispoTitle,
text: I18N_PROMO.autoIndispoText,
confirmButtonText: I18N_PROMO.confirm
});
return;
}
// Promo uniquement pour l’abonnement PROFESSIONNEL (ID = 5)
if (aboId !== 5) return; // laisser fonctionner normalement
// Si l'utilisateur est en "auto", on bloque la promo (car promo bascule sur ID=2)
// et ID=2 n'est pas (encore) configuré avec un Price récurrent.
if (selectedMode === 'auto') {
e.preventDefault();
Swal.fire({
icon: 'info',
title: I18N_PROMO.title,
text: I18N_PROMO.promoAutoNotSupported,
confirmButtonText: I18N_PROMO.confirm
});
return;
}
e.preventDefault();
Swal.fire({
title: I18N_PROMO.title,
input: 'text',
inputPlaceholder: I18N_PROMO.placeholder,
showCancelButton: true,
confirmButtonText: I18N_PROMO.confirm,
cancelButtonText: I18N_PROMO.cancel
}).then(function(result){
// Annulé -> pas de code → abonnement 5
if (result.isDismissed) {
form.submit();
return;
}
const code = (result.value || '').trim();
// Le code correct
const CODE_VALID = "uslugiW@W2026";
if (code === CODE_VALID) {
// On remplace l’abonnement → on envoie ID=2 (promo)
form.querySelector('input[name="abonnement_id"]').value = 2;
// On garde en mémoire le code (optionnel)
form.querySelector('input[name="promo_code"]').value = code;
// Sécurité : on force le mode à "manual" car ID=2 n'a pas de price récurrent configuré
const bmInput2 = form.querySelector('input[name="billing_mode"]');
if (bmInput2) bmInput2.value = 'manual';
form.submit();
} else {
// Code faux → abonnement normal
form.submit();
}
});
});
});
</script>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
<?php
require_once __DIR__ . '/../includes/init_client.php';
$client_id = $_SESSION['reference_id'];
$praticien_id = intval($_POST['praticien_id'] ?? 0);
if ($praticien_id > 0) {
// Évite les doublons
$check = $pdo->prepare("SELECT * FROM praticien_client WHERE client_id = ? AND praticien_id = ?");
$check->execute([$client_id, $praticien_id]);
if (!$check->fetch()) {
$stmt = $pdo->prepare("INSERT INTO praticien_client (client_id, praticien_id) VALUES (?, ?)");
$stmt->execute([$client_id, $praticien_id]);
echo $translations['praticien_ajoute_favoris'];
} else {
echo $translations['praticien_deja_ajoute'];
}
} else {
echo $translations['praticien_id_invalide'];
}
+421
View File
@@ -0,0 +1,421 @@
<?php
require_once __DIR__ . '/../includes/init_client.php';
require_once __DIR__ . '/../../push/push_endpoint.php';
// PHPMailer (config centralisée)
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
// --------- Réponse JSON utilitaire ----------
function json_out(bool $ok, string $msg, array $extra = []): never {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok'=>$ok, 'message'=>$msg] + $extra);
exit;
}
// --------- Envoi e-mail via PHPMailer (avec ICS optionnel + fallback) ----------
function send_email_phpmailer(
string $toEmail,
string $subject,
string $textBody,
string $htmlBody,
?string $icsFilename = null,
?string $icsContent = null
): array {
global $MAILCFG;
$mail = new PHPMailer(true);
try {
// SMTP
$mail->isSMTP();
$mail->Host = $MAILCFG['smtp']['host'];
$mail->Port = $MAILCFG['smtp']['port'];
$mail->SMTPSecure = $MAILCFG['smtp']['secure'];
$mail->SMTPAuth = $MAILCFG['smtp']['auth'];
$mail->Username = $MAILCFG['smtp']['username'];
$mail->Password = $MAILCFG['smtp']['password'];
$mail->CharSet = 'UTF-8';
$mail->Encoding = 'base64';
$mail->XMailer = '';
// Debug ponctuel : ?debug_mail=1
if (!empty($_GET['debug_mail'])) {
$mail->SMTPDebug = 2;
$mail->Debugoutput = function($str,$lvl){ error_log("SMTP[$lvl] ".$str); };
}
if (!empty($MAILCFG['bounce'])) $mail->Sender = $MAILCFG['bounce'];
$from = $MAILCFG['from'] ?? ['no-reply@uslugi.fr','USLUGI.FR'];
$rpto = $MAILCFG['reply_to'] ?? ['contact@uslugi.fr','USLUGI.FR'];
$mail->setFrom($from[0], $from[1] ?? '');
if (!empty($rpto[0])) $mail->addReplyTo($rpto[0], $rpto[1] ?? '');
$mail->addAddress($toEmail);
if (!empty($MAILCFG['suppress_auto'])) {
$mail->addCustomHeader('Auto-Submitted', 'auto-generated');
$mail->addCustomHeader('X-Auto-Response-Suppress', 'All');
}
$mail->isHTML(true);
$mail->Subject = $subject;
$mail->Body = $htmlBody;
$mail->AltBody = $textBody;
if ($icsFilename && $icsContent !== null && empty($_GET['no_ics'])) {
$mail->addStringAttachment(
$icsContent,
$icsFilename,
'base64',
'text/calendar; method=CANCEL; charset=UTF-8'
);
}
$mail->send();
return [true, null];
} catch (Throwable $e) {
$err = 'PHPMailer error: '.$e->getMessage();
if (!empty($mail->ErrorInfo)) $err .= ' | ErrorInfo: '.$mail->ErrorInfo;
error_log($err);
// Fallback : réessaye SANS ICS
try {
if ($icsFilename && $icsContent !== null && empty($_GET['no_ics'])) {
$mail->clearAttachments();
$mail->send();
return [true, 'sent_without_ics'];
}
} catch (Throwable $e2) {
$err2 = 'PHPMailer fallback error: '.$e2->getMessage();
if (!empty($mail->ErrorInfo)) $err2 .= ' | ErrorInfo: '.$mail->ErrorInfo;
error_log($err2);
}
return [false, $err];
}
}
// --------- Helpers e-mail (HTML/Texte) ----------
function email_html_cancel(array $data, 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:#ffffff;border-radius:8px;overflow:hidden;">
<tr><td style="background:#38378B;color:#ffffff;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($data['hello_line'])).'</td></tr>
<tr><td style="padding:8px 24px;color:#111;font-size:16px;line-height:1.6;">'.nl2br(htmlspecialchars($data['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($data['who_label']).'</td><td style="padding:14px 16px;font-size:14px;color:#111;">'.htmlspecialchars($data['who_value']).'</td></tr>
<tr><td style="padding:14px 16px;font-size:14px;color:#6b7280;">'.htmlspecialchars($data['date_label']).'</td><td style="padding:14px 16px;font-size:14px;color:#111;">'.htmlspecialchars($data['when']).'</td></tr>
<tr><td style="padding:14px 16px;font-size:14px;color:#6b7280;">'.htmlspecialchars($data['statut_label']).'</td><td style="padding:14px 16px;font-size:14px;color:#111;">'.htmlspecialchars($data['statut_value']).'</td></tr>
</table>
</td></tr>'.
(!empty($data['cta_url']) ? '<tr><td align="center" style="padding:8px 24px 24px 24px;"><a href="'.htmlspecialchars($data['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($data['cta_label']).'</a></td></tr>' : '').
'<tr><td style="padding:8px 24px 24px 24px;color:#111;font-size:16px;line-height:1.6;">'.nl2br(htmlspecialchars($data['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_cancel(array $data, array $t): string {
$lines = [];
$lines[] = $data['hello_line']; $lines[]='';
$lines[] = $data['intro']; $lines[]='';
$lines[] = $data['who_label'].': '.$data['who_value'];
$lines[] = $data['date_label'].': '.$data['when'];
$lines[] = $data['statut_label'].': '.$data['statut_value'];
if (!empty($data['cta_url'])) { $lines[]=''; $lines[] = $data['cta_label'].': '.$data['cta_url']; }
$lines[]=''; $lines[] = $data['footer'];
return implode("\n", $lines);
}
// --------- ICS CANCEL ----------
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 build_ics_cancel(
string $uid,
DateTime $start,
?DateTime $end,
string $timezone,
string $summary,
string $organizerEmail,
array $attendees // [['cn'=>..., 'email'=>...]]
): string {
$lines=[];
$lines[]="BEGIN:VCALENDAR";
$lines[]="VERSION:2.0";
$lines[]="PRODID:-//УСЛУГИ.ФР//RDV//FR";
$lines[]="METHOD:CANCEL";
$lines[]="BEGIN:VEVENT";
$lines[]="UID:".$uid;
$lines[]="DTSTAMP:".gmdate('Ymd\THis\Z');
$lines[]="STATUS:CANCELLED";
$lines[]="SEQUENCE:2";
$lines[]="SUMMARY:".ics_escape($summary);
if ($start) $lines[]="DTSTART;TZID={$timezone}:".$start->format('Ymd\THis');
if ($end) $lines[]="DTEND;TZID={$timezone}:".$end->format('Ymd\THis');
$lines[]="ORGANIZER:mailto:".$organizerEmail;
foreach ($attendees as $a) {
if (empty($a['email'])) continue;
$cn = ics_escape($a['cn'] ?? '');
$lines[]="ATTENDEE;ROLE=REQ-PARTICIPANT;CN={$cn}:mailto:".$a['email'];
}
$lines[]="END:VEVENT";
$lines[]="END:VCALENDAR";
return implode("\r\n",$lines)."\r\n";
}
// =================== LOGIQUE ===================
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
json_out(false, 'Méthode invalide.');
}
$client_id = $_SESSION['reference_id'] ?? null;
$id = (int)($_POST['id'] ?? 0);
if (!$client_id || $id <= 0) {
json_out(false, $translations['erreur_generique'] ?? 'Erreur.');
}
try {
// =========================
// SNAPSHOT AVANT ANNULATION
// =========================
$beforeStmt = $pdo->prepare("
SELECT statut, date_rdv, duree_minutes, lieu, praticien_id, client_id
FROM rendez_vous
WHERE id = ? AND client_id = ?
LIMIT 1
");
$beforeStmt->execute([$id, $client_id]);
$beforeRow = $beforeStmt->fetch(PDO::FETCH_ASSOC);
if (!$beforeRow) {
json_out(false, $translations['erreur_generique'] ?? 'Rendez-vous introuvable.');
exit;
}
// ✅ compte_id praticien + client (pour badge counters)
$praticienCompteId = 0;
$clientCompteId = 0;
if (!empty($beforeRow['praticien_id'])) {
$st = $pdo->prepare("SELECT id FROM compte WHERE role='praticien' AND reference_id=? LIMIT 1");
$st->execute([(int)$beforeRow['praticien_id']]);
$praticienCompteId = (int)($st->fetchColumn() ?: 0);
}
if (!empty($beforeRow['client_id'])) {
$st = $pdo->prepare("SELECT id FROM compte WHERE role='client' AND reference_id=? LIMIT 1");
$st->execute([(int)$beforeRow['client_id']]);
$clientCompteId = (int)($st->fetchColumn() ?: 0);
}
// 1) Annuler (contrainte client_id)
$stmt = $pdo->prepare("UPDATE rendez_vous SET statut = 'annulé' WHERE id = ? AND client_id = ?");
$stmt->execute([$id, $client_id]);
if ($stmt->rowCount() === 0) {
// Rien modifié -> soit déjà annulé, soit pas à ce client, soit ID invalide
json_out(false, $translations['aucune_modification'] ?? 'Aucune modification effectuée.');
}
// =========================
// LOG RDV (rdv_actions_log) : cancel
// =========================
$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' => 'annulé',
'date_rdv' => $beforeRow['date_rdv'],
], 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
(?, 'cancel', ?, 'client', ?, ?, NOW())
");
$logStmt->execute([(int)$id, (int)$client_id, $beforeJson, $afterJson]);
// =========================
// PUSH RDV — annulation (client)
// =========================
try {
// ✅ Badge RDV : +1 pour le praticien (cancel par client)
if ($praticienCompteId > 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' => $praticienCompteId]);
}
// 🔔 Praticien : RDV annulé par le client
push_rdv_to_praticien(
$pdo,
(int)$beforeRow['praticien_id'],
$translations['push_rdv_cancel_praticien_title'] ?? 'Запись отменена',
$translations['push_rdv_cancel_praticien_body'] ?? 'Клиент отменил запись.',
'/public/praticien/rendez_vous.php?id='.(int)$id,
[
'rdv_id' => (int)$id,
'action' => 'cancel',
'from_role' => 'client',
'client_id' => (int)$beforeRow['client_id'],
'praticien_id' => (int)$beforeRow['praticien_id'],
'date_rdv' => $beforeRow['date_rdv'],
]
);
// 🔔 Client : confirmation annulation
push_rdv_to_client(
$pdo,
(int)$beforeRow['client_id'],
$translations['push_rdv_cancel_client_title'] ?? 'Запись отменена',
$translations['push_rdv_cancel_client_body'] ?? 'Ваша запись была успешно отменена.',
'/public/client/mes_rendez_vous.php',
[
'rdv_id' => (int)$id,
'action' => 'cancel'
]
);
} catch (Throwable $e) {
error_log('push rdv cancel (client) failed: '.$e->getMessage());
}
// 2) Infos RDV pour e-mails
$info = $pdo->prepare("
SELECT rv.date_rdv, rv.duree_minutes, rv.uuid_ical,
p.email AS praticien_email, p.nom AS praticien_nom, p.prenom AS praticien_prenom,
c.prenom AS client_prenom, c.nom AS client_nom, c.email AS client_email
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 = ? AND rv.client_id = ?
");
$info->execute([$id, $client_id]);
$r = $info->fetch(PDO::FETCH_ASSOC);
if (!$r) {
json_out(false, $translations['erreur_generique'] ?? 'Erreur.');
}
// 3) Prépare mails
$tz = new DateTimeZone('Europe/Paris');
$start = new DateTime($r['date_rdv'], $tz);
$end = (clone $start)->modify('+'.(int)($r['duree_minutes'] ?? 60).' minutes');
$when = $start->format('d/m/Y H:i');
$pratFull = $r['praticien_prenom'].' '.$r['praticien_nom'];
$cliFull = $r['client_prenom'].' '.$r['client_nom'];
$cta_url_prat = 'https://uslugi.fr/public/praticien/agenda.php';
$cta_url_cli = 'https://uslugi.fr/public/client/mes_rendez_vous.php';
$cta_label = $translations['consultez_espace_pour_details'] ?? 'Voir les détails';
$statut_label = $translations['statut_label'] ?? 'Statut';
$statut_value = $translations['statut_annule'] ?? 'Annulé';
// ---- Mail praticien
$subject_p = $translations['email_annulation_sujet'] ?? 'Rendez-vous annulé';
$hello_p = ($translations['email_bonjour_dr'] ?? 'Bonjour Dr').' '.$pratFull.',';
$intro_p = ($translations['email_rdv_du'] ?? 'Le rendez-vous du').' '.$when.' '.($translations['email_avec'] ?? 'avec').' '.$cliFull.' '.($translations['email_annule_par_client'] ?? 'a été annulé par le client.');
$data_p = [
'hello_line' => $hello_p,
'intro' => $intro_p,
'who_label' => $translations['client_label'] ?? 'Client',
'who_value' => $cliFull,
'date_label' => $translations['date_label'] ?? 'Date',
'when' => $when,
'statut_label'=> $statut_label,
'statut_value'=> $statut_value,
'cta_url' => $cta_url_prat,
'cta_label' => $cta_label,
'footer' => $translations['merci_simple'] ?? 'Merci.',
];
$text_p = email_text_cancel($data_p, $translations);
$html_p = email_html_cancel($data_p, $translations);
$ics_p = null; $ics_name_p = null;
if (!empty($r['uuid_ical'])) {
$ics_name_p = 'rdv_cancel_'.$r['uuid_ical'].'_praticien.ics';
$ics_p = build_ics_cancel(
$r['uuid_ical'], $start, $end, 'Europe/Paris',
($translations['rdv_avec'] ?? 'Rendez-vous avec').' '.$cliFull,
'rdv@uslugi.fr',
[
['cn'=>$cliFull, 'email'=>$r['client_email']],
['cn'=>$pratFull, 'email'=>$r['praticien_email']],
]
);
}
[$ok_p, $err_p] = send_email_phpmailer($r['praticien_email'], $subject_p, $text_p, $html_p, $ics_name_p, $ics_p);
if (!$ok_p) error_log("Annulation RDV: mail praticien NON envoyé: ".$err_p);
// ---- Mail client
$subject_c = $translations['email_annulation_client_sujet'] ?? 'Confirmation d’annulation de votre rendez-vous';
$hello_c = sprintf($translations['mail_valid_bonjour_nom'] ?? 'Bonjour %s', $cliFull);
$intro_c = ($translations['email_votre_rdv_du'] ?? 'Votre rendez-vous du').' '.$when.' '.($translations['email_avec'] ?? 'avec').' '.(($translations['titre_dr'] ?? 'Dr').' '.$pratFull).' '.($translations['email_a_ete_annule'] ?? 'a été annulé.');
$who_label_cli = $translations['praticien_label'] ?? ($translations['titre_dr'] ?? 'Dr');
$who_value_cli = ($translations['titre_dr'] ?? 'Dr').' '.$pratFull;
$data_c = [
'hello_line' => $hello_c,
'intro' => $intro_c,
'who_label' => $who_label_cli,
'who_value' => $who_value_cli,
'date_label' => $translations['date_label'] ?? 'Date',
'when' => $when,
'statut_label'=> $statut_label,
'statut_value'=> $statut_value,
'cta_url' => $cta_url_cli,
'cta_label' => $cta_label,
'footer' => $translations['merci_simple'] ?? 'Merci.',
];
$text_c = email_text_cancel($data_c, $translations);
$html_c = email_html_cancel($data_c, $translations);
$ics_c = null; $ics_name_c = null;
if (!empty($r['uuid_ical'])) {
$ics_name_c = 'rdv_cancel_'.$r['uuid_ical'].'_client.ics';
$ics_c = build_ics_cancel(
$r['uuid_ical'], $start, $end, 'Europe/Paris',
($translations['rdv_avec'] ?? 'Rendez-vous avec').' '.(($translations['titre_dr'] ?? 'Dr').' '.$pratFull),
'rdv@uslugi.fr',
[
['cn'=>$cliFull, 'email'=>$r['client_email']],
['cn'=>$pratFull, 'email'=>$r['praticien_email']],
]
);
}
[$ok_c, $err_c] = send_email_phpmailer($r['client_email'], $subject_c, $text_c, $html_c, $ics_name_c, $ics_c);
if (!$ok_c) error_log("Annulation RDV: mail client NON envoyé: ".$err_c);
json_out(true, $translations['rdv_annule_ok'] ?? 'Rendez-vous annulé.');
} catch (Throwable $e) {
error_log('Annulation RDV error: '.$e->getMessage());
json_out(false, $translations['erreur_generique'] ?? 'Erreur serveur.');
}
+210
View File
@@ -0,0 +1,210 @@
<?php
require_once __DIR__ . '/../includes/init_client.php';
require_once __DIR__.'/../includes/header.php';
$praticien_id = (int)($_GET['praticien_id'] ?? 0);
if ($praticien_id <= 0) { echo "<div class='container py-5'>ID invalide.</div>"; exit; }
if (empty($_SESSION['csrf_token'])) $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
$CSRF = $_SESSION['csrf_token'];
function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
function percent_from_5($n){ $n=max(0,min(5,(float)$n)); return (int)round($n/5*100); }
function pseudo_from($prenom,$nom){
$p = trim((string)$prenom); $n = trim((string)$nom);
$p = $p!=='' ? mb_convert_case($p, MB_CASE_TITLE, 'UTF-8') : 'Client';
$i = $n!=='' ? mb_strtoupper(mb_substr($nom,0,1,'UTF-8'),'UTF-8') : '';
return $i ? ($p.'.'.$i) : $p;
}
// Titre + stats
$pp = $pdo->prepare("SELECT titre, prenom, nom FROM praticien WHERE id=?");
$pp->execute([$praticien_id]);
$pr = $pp->fetch(PDO::FETCH_ASSOC);
if (!$pr) { echo "<div class='container py-5'>".$translations['praticien_introuvable']."</div>"; exit; }
$avg = $pdo->prepare("SELECT ROUND(AVG(note),1) AS m, COUNT(*) AS c FROM avis_praticien WHERE praticien_id=? AND statut='publie'");
$avg->execute([$praticien_id]); $stats = $avg->fetch(PDO::FETCH_ASSOC) ?: ['m'=>null,'c'=>0];
// Form POST
$flash = null;
if ($_SERVER['REQUEST_METHOD']==='POST' && ($_POST['do'] ?? '')==='add') {
if (!hash_equals($_SESSION['csrf_token'] ?? '', $_POST['csrf'] ?? '')) {
$flash = ['type'=>'danger','msg'=>$translations['avis_form_err']];
} elseif ($_SESSION['role'] !== 'client') {
$flash = ['type'=>'danger','msg'=>$translations['non_autorise'] ?? 'Non autorisé'];
} else {
$note = (int)($_POST['note'] ?? 0);
$titre = trim((string)($_POST['titre'] ?? ''));
$commentaire = trim((string)($_POST['commentaire'] ?? ''));
$visit_date = trim((string)($_POST['visit_date'] ?? ''));
$ok = $note>=1 && $note<=5 && mb_strlen($commentaire,'UTF-8')>=10;
$date_ok = true;
if ($visit_date !== '') {
$ts = strtotime($visit_date.' 00:00:00');
$date_ok = $ts && $ts <= time()+86400; // pas dans le futur
}
if ($ok && $date_ok) {
$st = $pdo->prepare("INSERT INTO avis_praticien (praticien_id, client_id, note, titre, commentaire, visit_date, statut)
VALUES (?, ?, ?, ?, ?, ?, 'publie')");
$st->execute([$praticien_id, (int)$_SESSION['reference_id'], $note,
($titre!==''?$titre:null), ($commentaire!==''?$commentaire:null),
($visit_date!==''?$visit_date:null)]);
$flash = ['type'=>'success','msg'=>$translations['avis_form_ok']];
// refresh stats
$avg->execute([$praticien_id]); $stats = $avg->fetch(PDO::FETCH_ASSOC) ?: ['m'=>null,'c'=>0];
} else {
$flash = ['type'=>'danger','msg'=>$translations['avis_form_err']];
}
}
}
// Pagination
$perPage = 10;
$page = max(1, (int)($_GET['page'] ?? 1));
$offset = ($page - 1) * $perPage;
$cnt = $pdo->prepare("SELECT COUNT(*) FROM avis_praticien WHERE praticien_id = :pid AND statut = 'publie'");
$cnt->execute([':pid' => $praticien_id]);
$total = (int)$cnt->fetchColumn();
$pages = max(1, (int)ceil($total / $perPage));
$sql = "SELECT a.note, a.titre, a.commentaire, a.visit_date, a.created_at,
c.prenom, c.nom
FROM avis_praticien a
LEFT JOIN client c ON c.id = a.client_id
WHERE a.praticien_id = :pid AND a.statut = 'publie'
ORDER BY a.created_at DESC
LIMIT :o, :l";
$ls = $pdo->prepare($sql);
$ls->bindValue(':pid', $praticien_id, PDO::PARAM_INT);
$ls->bindValue(':o', (int)$offset, PDO::PARAM_INT);
$ls->bindValue(':l', (int)$perPage, PDO::PARAM_INT);
$ls->execute();
$avis = $ls->fetchAll(PDO::FETCH_ASSOC);
// ID depuis l'URL (on passe bien ?praticien_id=...)
$praticien_id = (int)($_GET['praticien_id'] ?? 0);
if ($praticien_id <= 0) { echo "<div class='container py-5'>ID invalide.</div>"; exit; }
// Charger le praticien
$pp = $pdo->prepare("SELECT titre, prenom, nom FROM praticien WHERE id = :id");
$pp->execute([':id' => $praticien_id]);
$pr = $pp->fetch(PDO::FETCH_ASSOC);
if (!$pr) {
echo "<div class='container py-5'>".$translations['praticien_introuvable']."</div>";
exit;
}
// Définir $name AVANT de l'utiliser
$name = trim(
($pr['prenom'] ?? '')
. ' '
. ($pr['nom'] ?? '')
);
?>
<div class="main1">
<div class="container py-5">
<div class="shadow p-4 rounded bg-light">
<h2 class="mb-2"><?= h($translations['avis_titre_page']) ?> — <?= h($name) ?></h2>
<div class="d-flex align-items-center gap-3 mb-3">
<?php $m=(float)($stats['m'] ?? 0); $c=(int)($stats['c'] ?? 0); $w=percent_from_5($m); ?>
<div class="rating" aria-label="Note">
<div class="stars-base">★★★★★</div>
<div class="stars-fill" style="width:<?= $w ?>%">★★★★★</div>
</div>
<div class="text-muted"><?= $m ? number_format($m,1,',',' ') : '—' ?>/5 • <?= $c.' '.h($translations['avis_total']) ?></div>
</div>
<?php if ($flash): ?>
<div class="alert alert-<?= h($flash['type']) ?>"><?= h($flash['msg']) ?></div>
<?php endif; ?>
<?php if ($_SESSION['role']==='client'): ?>
<div class="card shadow-sm mb-4">
<div class="card-body">
<h5 class="mb-3"><?= h($translations['avis_form_titre']) ?></h5>
<form method="post" class="row g-3">
<div class="col-12 col-md-4">
<label class="form-label"><?= h($translations['avis_form_note']) ?></label>
<select name="note" class="form-select" required>
<?php for($i=5;$i>=1;$i--): ?>
<option value="<?= $i ?>"> <?= $i ?> ★</option>
<?php endfor; ?>
</select>
</div>
<div class="col-12 col-md-4">
<label class="form-label"><?= h($translations['avis_form_date_visite']) ?></label>
<input type="date" name="visit_date" class="form-control">
</div>
<div class="col-12">
<label class="form-label"><?= h($translations['avis_form_titre_lbl']) ?></label>
<input type="text" name="titre" class="form-control" maxlength="150">
</div>
<div class="col-12">
<label class="form-label"><?= h($translations['avis_form_commentaire']) ?></label>
<textarea name="commentaire" class="form-control" rows="4" minlength="10" required></textarea>
</div>
<div class="col-12">
<input type="hidden" name="csrf" value="<?= h($CSRF) ?>">
<input type="hidden" name="do" value="add">
<button class="btn btn-primary"><?= h($translations['avis_form_envoyer']) ?></button>
</div>
</form>
</div>
</div>
<?php endif; ?>
<?php if (!$avis): ?>
<div class="text-muted"><?= h($translations['avis_aucun']) ?></div>
<?php else: foreach ($avis as $a): $pw=percent_from_5((float)$a['note']); ?>
<div class="card shadow-sm mb-3">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-1">
<div>
<div class="rating mb-1">
<div class="stars-base">★★★★★</div>
<div class="stars-fill" style="width:<?= $pw ?>%">★★★★★</div>
</div>
<div class="small text-muted">
<strong><?= h(pseudo_from($a['prenom'] ?? '', $a['nom'] ?? '')) ?></strong>
<?php if(!empty($a['visit_date'])): ?> • <?= date('d/m/Y', strtotime($a['visit_date'])) ?><?php endif; ?>
</div>
</div>
<div class="small text-muted"><?= date('d/m/Y', strtotime($a['created_at'])) ?></div>
</div>
<?php if(!empty($a['titre'])): ?><div class="fw-semibold mb-1"><?= h($a['titre']) ?></div><?php endif; ?>
<div><?= nl2br(h($a['commentaire'] ?? '')) ?></div>
</div>
</div>
<?php endforeach; endif; ?>
<?php if ($pages>1): ?>
<nav class="mt-3">
<?php for ($i=1;$i<=$pages;$i++):
$href = '/public/client/avis.php?praticien_id='.$praticien_id.'&page='.$i;
$cls = $i===$page ? 'btn btn-sm btn-primary me-1' : 'btn btn-sm btn-outline-primary me-1';
?>
<a class="<?= $cls ?>" href="<?= h($href) ?>"><?= $i ?></a>
<?php endfor; ?>
</nav>
<?php endif; ?>
<div class="mt-4">
<a class="btn btn-outline-secondary" href="/public/client/detail_praticien.php?id=<?= (int)$praticien_id ?>">⬅️ <?= $translations['retour_avis_profil'] ?></a>
</div>
</div>
</div>
</div>
<footer class="site-footer">
<div class="container">
<p><?= $translations['copyr'] ?></p>
</div>
</footer>
+144
View File
@@ -0,0 +1,144 @@
<?php
require_once __DIR__ . '/../includes/init_client.php';
$success = $erreur = '';
$id = $_SESSION['compte_id'];
$stmt = $pdo->prepare("SELECT email FROM compte WHERE id = ?");
$stmt->execute([$id]);
$compte = $stmt->fetch();
if (!$compte) die($translations['compte_introuvable']);
$email_actuel = $compte['email'];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$nouvel_email = trim($_POST['email'] ?? '');
$mdp1 = $_POST['mot_de_passe'] ?? '';
$mdp2 = $_POST['confirm_mot_de_passe'] ?? '';
if (!filter_var($nouvel_email, FILTER_VALIDATE_EMAIL)) {
$erreur = $translations['erreur_email_invalide_format'];
} elseif ($mdp1 && ($mdp1 !== $mdp2 || strlen($mdp1) < 8)) {
$erreur = $translations['erreur_mdp_invalide_ou_non_confirme'];
} else {
// Vérifie que l'email n’est pas déjà pris (sauf pour soi)
$stmt = $pdo->prepare("SELECT id FROM compte WHERE email = ? AND id != ?");
$stmt->execute([$nouvel_email, $id]);
if ($stmt->fetch()) {
$erreur = $translations['email_deja_utilise'];
} else {
// Mise à jour
if ($mdp1) {
$hash = password_hash($mdp1, PASSWORD_DEFAULT);
$stmt = $pdo->prepare("UPDATE compte SET email = ?, mot_de_passe = ? WHERE id = ?");
$stmt->execute([$nouvel_email, $hash, $id]);
} else {
$stmt = $pdo->prepare("UPDATE compte SET email = ? WHERE id = ?");
$stmt->execute([$nouvel_email, $id]);
}
$success = $translations['maj_effectuee_succes'];
}
}
}
require_once '../includes/header.php';
?>
<div class="main1">
<div class="container py-5">
<?php if ($erreur): ?>
<div class="alert alert-danger"><?= $erreur ?></div>
<?php endif; ?>
<?php if ($success): ?>
<div class="alert alert-success"><?= $success ?></div>
<?php endif; ?>
<form method="POST" class="shadow p-4 rounded bg-light">
<h2 class="text-center mb-4"> <?= $translations['mes_infos_connexion'] ?></h2>
<div class="mb-3">
<label class="form-label"><?= $translations['label_email'] ?></label>
<input type="email" name="email" value="<?= htmlspecialchars($email_actuel) ?>" required class="form-control">
</div>
<div class="mb-3">
<label class="form-label"><?= $translations['label_nouveau_mdp_facultatif'] ?></label>
<div class="field-wrapper">
<input type="password" name="mot_de_passe" id="mot_de_passe" placeholder="<?= $translations['nouveau_mdp_placeholder'] ?>" class="form-control">
<span class="toggle-password" onclick="toggle('mot_de_passe')">👁️</span>
</div>
</div>
<div class="strength-bar mb-1">
<div class="strength-fill"></div>
<div class="strength-fill"></div>
<div class="strength-fill"></div>
</div>
<div id="niveau-securite" class="mb-3 text-muted"><?= $translations['securite_indetermine'] ?></div>
<div class="mb-4">
<label class="form-label"><?= $translations['label_confirmer_mdp'] ?></label>
<div class="field-wrapper">
<input type="password" name="confirm_mot_de_passe" id="confirm_mot_de_passe" placeholder="<?= $translations['confirm_mdp_placeholder'] ?>" class="form-control">
<span class="toggle-password" onclick="toggle('confirm_mot_de_passe')">👁️</span>
</div>
</div>
<div class="text-center">
<button type="submit" class="btn btn-primary"> <?= $translations['btn_mettre_a_jour'] ?></button>
</div>
</form>
</div>
<script>
function toggle(id) {
const el = document.getElementById(id);
el.type = el.type === 'password' ? 'text' : 'password';
}
document.getElementById('mot_de_passe').addEventListener('input', function () {
const val = this.value;
const segments = document.querySelectorAll(".strength-fill");
const niveauText = document.getElementById("niveau-securite");
segments.forEach(s => s.style.backgroundColor = '#e0e0e0');
niveauText.textContent = <?= json_encode($translations['securite_indetermine']) ?>;
niveauText.className = 'text-muted';
const longOk = val.length >= 8;
const lettre = /[A-Za-z]/.test(val);
const chiffre = /\d/.test(val);
const maj = /[A-Z]/.test(val);
const special = /[^A-Za-z0-9]/.test(val);
let force = 0;
if (longOk && lettre && chiffre) force++;
if (maj) force++;
if (special) force++;
if (force === 1) {
segments[0].style.backgroundColor = 'red';
niveauText.textContent = <?= json_encode($translations['securite_faible']) ?>;
niveauText.className = 'text-danger';
} else if (force === 2) {
segments[0].style.backgroundColor = 'orange';
segments[1].style.backgroundColor = 'orange';
niveauText.textContent = <?= json_encode($translations['securite_moyen']) ?>;
niveauText.className = 'text-warning';
} else if (force >= 3) {
segments.forEach(s => s.style.backgroundColor = 'green');
niveauText.textContent = <?= json_encode($translations['securite_fort']) ?>;
niveauText.className = 'text-success';
}
});
</script>
</div>
<footer class="site-footer">
<div class="container">
<p><?= $translations['copyr'] ?></p>
</div>
</footer>
</body>
</html>
+248
View File
@@ -0,0 +1,248 @@
<?php
require_once __DIR__ . '/../includes/init_client.php';
$praticien_id = (int)($_GET['id'] ?? 0);
if ($praticien_id <= 0) { echo "<div class='container py-5'>ID invalide.</div>"; exit; }
function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
function percent_from_5($note){ $n = max(0,min(5,(float)$note)); return (int)round($n/5*100); }
function pseudo_from($prenom,$nom){
$p = trim((string)$prenom); $n = trim((string)$nom);
$p = $p!=='' ? mb_convert_case($p, MB_CASE_TITLE, 'UTF-8') : 'Client';
$i = $n!=='' ? mb_strtoupper(mb_substr($nom,0,1,'UTF-8'),'UTF-8') : '';
return $i ? ($p.'.'.$i) : $p;
}
function video_thumb_info(?string $url): ?array {
if (!$url || !trim($url)) return null; $u = trim($url);
if (preg_match('~(?:youtube\.com/(?:watch\?v=|embed/|shorts/)|youtu\.be/)([A-Za-z0-9_-]{6,})~i',$u,$m)){
$id=$m[1]; return ['provider'=>'youtube','id'=>$id,'thumb'=>"https://i.ytimg.com/vi/$id/hqdefault.jpg"];
}
if (preg_match('~dailymotion\.com/video/([A-Za-z0-9]+)|dai\.ly/([A-Za-z0-9]+)~i',$u,$m)){
$id=$m[1] ?: $m[2]; return ['provider'=>'dailymotion','id'=>$id,'thumb'=>"https://www.dailymotion.com/thumbnail/video/$id"];
}
$path=parse_url($u,PHP_URL_PATH)??''; if(preg_match('~\.(mp4|webm|ogg)$~i',$path)){ return ['provider'=>'file','id'=>null,'thumb'=>null]; }
if (stripos($u,'vimeo.com')!==false){ return ['provider'=>'vimeo','id'=>null,'thumb'=>null]; }
return ['provider'=>'other','id'=>null,'thumb'=>null];
}
// 1) Praticien
$st = $pdo->prepare("SELECT id, titre, nom, prenom, telephone, adresse, ville, code_postal, specialite, presentation, photo, video, disponibilite
FROM praticien WHERE id = ?");
$st->execute([$praticien_id]);
$p = $st->fetch(PDO::FETCH_ASSOC);
if (!$p) { echo "<div class='container py-5'>".$translations['praticien_introuvable']."</div>"; exit; }
$name = $name = trim($p['prenom'] . ' ' . $p['nom']);
$photo = !empty($p['photo']) ? "/public/uploads/".ltrim($p['photo'],'/') : "/public/uploads/img/user_placeholder.png";
// 2) Vidéos additionnelles
$vs = $pdo->prepare("SELECT id, url, title, ordre FROM praticien_video WHERE praticien_id=? ORDER BY ordre ASC, id DESC");
$vs->execute([$praticien_id]);
$videos = $vs->fetchAll(PDO::FETCH_ASSOC);
// 3) Avis: moyenne + total + dernier
$avg = $pdo->prepare("SELECT ROUND(AVG(note),1) AS m, COUNT(*) AS c FROM avis_praticien WHERE praticien_id=? AND statut='publie'");
$avg->execute([$praticien_id]);
$stats = $avg->fetch(PDO::FETCH_ASSOC) ?: ['m'=>null,'c'=>0];
$last = $pdo->prepare("SELECT a.note,a.titre,a.commentaire,a.visit_date,a.created_at,c.prenom,c.nom
FROM avis_praticien a
LEFT JOIN client c ON c.id=a.client_id
WHERE a.praticien_id=? AND a.statut='publie'
ORDER BY a.created_at DESC LIMIT 1");
$last->execute([$praticien_id]);
$lrow = $last->fetch(PDO::FETCH_ASSOC);
require_once __DIR__.'/../includes/header.php'; // inclut lang + styles
?>
<div class="main1">
<div class="container py-4">
<div class="profile-card">
<div class="profile-grid profile-top">
<!-- Cadre 1 : Infos -->
<section class="panel">
<div class="info-scroll">
<h2 class="pro-name"><?= h($name) ?></h2>
<?php if($p['specialite']): ?><div><strong><?= h($translations['dp_specialite']) ?>:</strong> <?= h($p['specialite']) ?></div><?php endif; ?>
<!--<?php if($p['telephone']): ?><div><strong><?= h($translations['dp_telephone']) ?>:</strong> <?= h($p['telephone']) ?></div><?php endif; ?>-->
<?php if($p['titre']): ?><div><strong><?= h($translations['tarif_consultation']) ?>:</strong> <?= h($p['titre']) ?></div><?php endif; ?>
<?php if($p['disponibilite']): ?><div><strong><?= h($translations['moyens_payement']) ?>:</strong> <?= h($p['disponibilite']) ?></div><?php endif; ?>
<?php if($p['adresse']): ?><div><strong><?= h($translations['dp_adresse']) ?>:</strong> <?= nl2br(h($p['adresse'])) ?></div><?php endif; ?>
<?php if($p['ville'] || $p['code_postal']): ?><div><strong><?= h($translations['dp_ville_cp']) ?>:</strong> <?= h(trim(($p['ville']??'').' '.($p['code_postal']??''))) ?></div><?php endif; ?>
<?php if($p['presentation']): ?><div class="mt-3"><strong><?= h($translations['dp_description']) ?>:</strong><br><?= nl2br(h($p['presentation'])) ?></div><?php endif; ?>
</div>
</section>
<!-- Centre : grande photo -->
<section class="big-photo">
<img class="img-fluid w-100" src="<?= h($photo) ?>" alt="Photo">
</section>
<!-- Cadre 2 : Vidéos -->
<?php if($p['video'] != ''){ ?>
<aside class="panel">
<h2 class="pro-name"><?= h($translations['dp_videos']) ?></h2>
<div class="vlist">
<?php
$alt = h($translations['video_alt'] ?? 'Vidéo');
$printed = false;
// Petite fonction d'affichage d’une vignette
$printThumb = function($provider, $id, $thumb, $rawUrl) use ($alt){
if (in_array($provider, ['youtube','dailymotion']) && $id){
echo '<a href="#" class="vitem d-block pro-video"
data-bs-toggle="modal" data-bs-target="#videoModal"
data-provider="'.h($provider).'" data-id="'.h($id).'">';
echo $thumb
? '<img src="'.h($thumb).'" alt="'.$alt.'" loading="lazy">'
: '<img src="/public/uploads/img/video_placeholder.jpg" alt="'.$alt.'" loading="lazy">';
echo '</a>';
} else {
$safe = h($rawUrl);
echo '<a class="vitem d-block" href="'.$safe.'" target="_blank" rel="noopener">';
echo $thumb
? '<img src="'.h($thumb).'" alt="'.$alt.'" loading="lazy">'
: '<img src="/public/uploads/img/video_placeholder.jpg" alt="'.$alt.'" loading="lazy">';
echo '</a>';
}
};
if (!empty($p['video'])) {
$vi = video_thumb_info($p['video']);
$thumb = $vi['thumb'] ?? null;
$prov = $vi['provider'] ?? null;
$vid = $vi['id'] ?? null;
$printThumb($prov, $vid, $thumb, $p['video']);
$printed = true;
}
foreach ($videos as $v) {
$vi = video_thumb_info($v['url']);
$thumb = $vi['thumb'] ?? null;
$prov = $vi['provider'] ?? null;
$vid = $vi['id'] ?? null;
$printThumb($prov, $vid, $thumb, $v['url']);
$printed = true;
}
if (!$printed) {
echo '<div class="vitem"><img src="/public/uploads/img/video_placeholder.jpg" alt="'.$alt.'"></div>';
//echo '<div class="text-muted small mt-1">'.h($translations['dp_aucune_video']).'</div>';
}
?>
</div>
</aside>
<?php } ?>
</div>
<!-- Ligne du bas : Note / Dernier avis / Bouton RDV -->
<div class="bottom-grid mt-4">
<!-- Cadre 3 : Note -->
<div class="card shadow-sm p-3">
<div class="d-flex align-items-center gap-3">
<?php $m=(float)($stats['m'] ?? 0); $c=(int)($stats['c'] ?? 0); $w=percent_from_5($m); ?>
<div class="rating" aria-label="Note moyenne">
<div class="stars-base">★★★★★</div>
<div class="stars-fill" style="width:<?= $w ?>%">★★★★★</div>
</div>
<div>
<div><strong><?= h($translations['avis_note_moy']) ?>:</strong> <?= $m ? number_format($m,1,',',' ') : '—' ?>/5</div>
<div class="text-muted">(<?= $c.' '.h($translations['avis_total']) ?>)</div>
</div>
</div>
</div>
<!-- Cadre 4 : Dernier avis -->
<div class="card shadow-sm p-3 avis-card">
<div class="d-flex justify-content-between align-items-start mb-2">
<strong><?= h($translations['avis_dernier']) ?></strong>
<a class="btn btn-link p-0" href="/public/client/avis.php?praticien_id=<?= (int)$praticien_id ?>">
<?= h($translations['avis_lire_tous']) ?>
</a>
</div>
<?php if ($lrow): ?>
<?php $lw=percent_from_5((float)$lrow['note']); ?>
<div class="rating mb-1">
<div class="stars-base">★★★★★</div>
<div class="stars-fill" style="width:<?= $lw ?>%">★★★★★</div>
</div>
<div class="meta">
<?= h(pseudo_from($lrow['prenom'] ?? '', $lrow['nom'] ?? '')) ?>
<?php if(!empty($lrow['visit_date'])): ?> • <?= date('d/m/Y', strtotime($lrow['visit_date'])) ?><?php endif; ?>
</div>
<div><?= nl2br(h(mb_strimwidth((string)$lrow['commentaire'],0,320,'…','UTF-8'))) ?></div>
<?php else: ?>
<div class="text-muted"><?= h($translations['avis_aucun']) ?></div>
<?php endif; ?>
</div>
<!-- Bouton RDV -->
<div class="text-end">
<a class="btn btn-danger btn-lg" href="/public/client/reserver_rdv.php?praticien_id=<?= (int)$praticien_id ?>">
<?= h($translations['dp_btn_rdv']) ?>
</a>
</div>
</div>
</div>
</div>
</div>
<!-- Modal vidéo -->
<div class="modal fade" id="videoModal" tabindex="-1" aria-labelledby="videoModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-xl">
<div class="modal-content border-0 shadow-lg rounded-4">
<div class="modal-header border-0">
<h5 class="modal-title" id="videoModalLabel"><?= h($translations['video_titre'] ?? 'Vidéo') ?></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="<?= h($translations['fermer'] ?? 'Fermer') ?>"></button>
</div>
<div class="modal-body pt-0">
<div class="ratio ratio-16x9 rounded-3 overflow-hidden bg-black">
<iframe id="vPlayer" src="" title="Video player"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen
referrerpolicy="strict-origin-when-cross-origin"></iframe>
</div>
</div>
<div class="modal-footer border-0">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<?= h($translations['fermer'] ?? 'Fermer') ?>
</button>
</div>
</div>
</div>
</div>
<script>
(function(){
const modal = document.getElementById('videoModal');
const iframe = document.getElementById('vPlayer');
function buildEmbed(provider, id){
if (!provider || !id) return '';
if (provider === 'youtube')
return 'https://www.youtube-nocookie.com/embed/' + id + '?autoplay=1&rel=0&modestbranding=1';
if (provider === 'dailymotion')
return 'https://www.dailymotion.com/embed/video/' + id + '?autoplay=1&ui-start-screen-info=false';
return '';
}
document.addEventListener('click', function(e){
const a = e.target.closest('a.pro-video[data-provider][data-id]');
if (!a) return;
e.preventDefault();
const src = buildEmbed(a.dataset.provider, a.dataset.id);
if (src) iframe.src = src;
}, false);
modal.addEventListener('hidden.bs.modal', function(){
iframe.src = '';
});
})();
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" defer></script>
<footer class="site-footer">
<div class="container">
<p><?= $translations['copyr'] ?></p>
</div>
</footer>
</body>
</html>
+246
View File
@@ -0,0 +1,246 @@
<?php
require_once __DIR__ . '/../includes/init_client.php';
$praticien_id = (int)($_GET['id'] ?? 0);
if ($praticien_id <= 0) { echo "<div class='container py-5'>ID invalide.</div>"; exit; }
function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
function percent_from_5($note){ $n = max(0,min(5,(float)$note)); return (int)round($n/5*100); }
function pseudo_from($prenom,$nom){
$p = trim((string)$prenom); $n = trim((string)$nom);
$p = $p!=='' ? mb_convert_case($p, MB_CASE_TITLE, 'UTF-8') : 'Client';
$i = $n!=='' ? mb_strtoupper(mb_substr($nom,0,1,'UTF-8'),'UTF-8') : '';
return $i ? ($p.'.'.$i) : $p;
}
function video_thumb_info(?string $url): ?array {
if (!$url || !trim($url)) return null; $u = trim($url);
if (preg_match('~(?:youtube\.com/(?:watch\?v=|embed/|shorts/)|youtu\.be/)([A-Za-z0-9_-]{6,})~i',$u,$m)){
$id=$m[1]; return ['provider'=>'youtube','id'=>$id,'thumb'=>"https://i.ytimg.com/vi/$id/hqdefault.jpg"];
}
if (preg_match('~dailymotion\.com/video/([A-Za-z0-9]+)|dai\.ly/([A-Za-z0-9]+)~i',$u,$m)){
$id=$m[1] ?: $m[2]; return ['provider'=>'dailymotion','id'=>$id,'thumb'=>"https://www.dailymotion.com/thumbnail/video/$id"];
}
$path=parse_url($u,PHP_URL_PATH)??''; if(preg_match('~\.(mp4|webm|ogg)$~i',$path)){ return ['provider'=>'file','id'=>null,'thumb'=>null]; }
if (stripos($u,'vimeo.com')!==false){ return ['provider'=>'vimeo','id'=>null,'thumb'=>null]; }
return ['provider'=>'other','id'=>null,'thumb'=>null];
}
// 1) Praticien
$st = $pdo->prepare("SELECT id, titre, nom, prenom, telephone, adresse, ville, code_postal, specialite, presentation, photo, video, disponibilite
FROM praticien WHERE id = ?");
$st->execute([$praticien_id]);
$p = $st->fetch(PDO::FETCH_ASSOC);
if (!$p) { echo "<div class='container py-5'>".$translations['praticien_introuvable']."</div>"; exit; }
$name = $name = trim($p['prenom'] . ' ' . $p['nom']);
$photo = !empty($p['photo']) ? "/public/uploads/".ltrim($p['photo'],'/') : "/public/uploads/img/user_placeholder.png";
// 2) Vidéos additionnelles
$vs = $pdo->prepare("SELECT id, url, title, ordre FROM praticien_video WHERE praticien_id=? ORDER BY ordre ASC, id DESC");
$vs->execute([$praticien_id]);
$videos = $vs->fetchAll(PDO::FETCH_ASSOC);
// 3) Avis: moyenne + total + dernier
$avg = $pdo->prepare("SELECT ROUND(AVG(note),1) AS m, COUNT(*) AS c FROM avis_praticien WHERE praticien_id=? AND statut='publie'");
$avg->execute([$praticien_id]);
$stats = $avg->fetch(PDO::FETCH_ASSOC) ?: ['m'=>null,'c'=>0];
$last = $pdo->prepare("SELECT a.note,a.titre,a.commentaire,a.visit_date,a.created_at,c.prenom,c.nom
FROM avis_praticien a
LEFT JOIN client c ON c.id=a.client_id
WHERE a.praticien_id=? AND a.statut='publie'
ORDER BY a.created_at DESC LIMIT 1");
$last->execute([$praticien_id]);
$lrow = $last->fetch(PDO::FETCH_ASSOC);
require_once __DIR__.'/../includes/header.php'; // inclut lang + styles
?>
<div class="main1">
<div class="container py-4">
<div class="profile-card">
<div class="profile-grid profile-top">
<!-- Cadre 1 : Infos -->
<section class="panel">
<div class="info-scroll">
<h2 class="pro-name"><?= h($name) ?></h2>
<?php if($p['specialite']): ?><div><strong><?= h($translations['dp_specialite']) ?>:</strong> <?= h($p['specialite']) ?></div><?php endif; ?>
<!--<?php if($p['telephone']): ?><div><strong><?= h($translations['dp_telephone']) ?>:</strong> <?= h($p['telephone']) ?></div><?php endif; ?>-->
<?php if($p['titre']): ?><div><strong><?= h($translations['tarif_consultation']) ?>:</strong> <?= h($p['titre']) ?></div><?php endif; ?>
<?php if($p['disponibilite']): ?><div><strong><?= h($translations['moyens_payement']) ?>:</strong> <?= h($p['disponibilite']) ?></div><?php endif; ?>
<?php if($p['adresse']): ?><div><strong><?= h($translations['dp_adresse']) ?>:</strong> <?= nl2br(h($p['adresse'])) ?></div><?php endif; ?>
<?php if($p['ville'] || $p['code_postal']): ?><div><strong><?= h($translations['dp_ville_cp']) ?>:</strong> <?= h(trim(($p['ville']??'').' '.($p['code_postal']??''))) ?></div><?php endif; ?>
<?php if($p['presentation']): ?><div class="mt-3"><strong><?= h($translations['dp_description']) ?>:</strong><br><?= nl2br(h($p['presentation'])) ?></div><?php endif; ?>
</div>
</section>
<!-- Centre : grande photo -->
<section class="big-photo">
<img class="img-fluid w-100" src="<?= h($photo) ?>" alt="Photo">
</section>
<!-- Cadre 2 : Vidéos -->
<aside class="panel">
<h2 class="pro-name"><?= h($translations['dp_videos']) ?></h2>
<div class="vlist">
<?php
$alt = h($translations['video_alt'] ?? 'Vidéo');
$printed = false;
// Petite fonction d'affichage d’une vignette
$printThumb = function($provider, $id, $thumb, $rawUrl) use ($alt){
if (in_array($provider, ['youtube','dailymotion']) && $id){
echo '<a href="#" class="vitem d-block pro-video"
data-bs-toggle="modal" data-bs-target="#videoModal"
data-provider="'.h($provider).'" data-id="'.h($id).'">';
echo $thumb
? '<img src="'.h($thumb).'" alt="'.$alt.'" loading="lazy">'
: '<img src="/public/uploads/img/video_placeholder.jpg" alt="'.$alt.'" loading="lazy">';
echo '</a>';
} else {
$safe = h($rawUrl);
echo '<a class="vitem d-block" href="'.$safe.'" target="_blank" rel="noopener">';
echo $thumb
? '<img src="'.h($thumb).'" alt="'.$alt.'" loading="lazy">'
: '<img src="/public/uploads/img/video_placeholder.jpg" alt="'.$alt.'" loading="lazy">';
echo '</a>';
}
};
if (!empty($p['video'])) {
$vi = video_thumb_info($p['video']);
$thumb = $vi['thumb'] ?? null;
$prov = $vi['provider'] ?? null;
$vid = $vi['id'] ?? null;
$printThumb($prov, $vid, $thumb, $p['video']);
$printed = true;
}
foreach ($videos as $v) {
$vi = video_thumb_info($v['url']);
$thumb = $vi['thumb'] ?? null;
$prov = $vi['provider'] ?? null;
$vid = $vi['id'] ?? null;
$printThumb($prov, $vid, $thumb, $v['url']);
$printed = true;
}
if (!$printed) {
echo '<div class="vitem"><img src="/public/uploads/img/video_placeholder.jpg" alt="'.$alt.'"></div>';
echo '<div class="text-muted small mt-1">'.h($translations['dp_aucune_video']).'</div>';
}
?>
</div>
</aside>
</div>
<!-- Ligne du bas : Note / Dernier avis / Bouton RDV -->
<div class="bottom-grid mt-4">
<!-- Cadre 3 : Note -->
<div class="card shadow-sm p-3">
<div class="d-flex align-items-center gap-3">
<?php $m=(float)($stats['m'] ?? 0); $c=(int)($stats['c'] ?? 0); $w=percent_from_5($m); ?>
<div class="rating" aria-label="Note moyenne">
<div class="stars-base">★★★★★</div>
<div class="stars-fill" style="width:<?= $w ?>%">★★★★★</div>
</div>
<div>
<div><strong><?= h($translations['avis_note_moy']) ?>:</strong> <?= $m ? number_format($m,1,',',' ') : '—' ?>/5</div>
<div class="text-muted">(<?= $c.' '.h($translations['avis_total']) ?>)</div>
</div>
</div>
</div>
<!-- Cadre 4 : Dernier avis -->
<div class="card shadow-sm p-3 avis-card">
<div class="d-flex justify-content-between align-items-start mb-2">
<strong><?= h($translations['avis_dernier']) ?></strong>
<a class="btn btn-link p-0" href="/public/client/avis.php?praticien_id=<?= (int)$praticien_id ?>">
<?= h($translations['avis_lire_tous']) ?>
</a>
</div>
<?php if ($lrow): ?>
<?php $lw=percent_from_5((float)$lrow['note']); ?>
<div class="rating mb-1">
<div class="stars-base">★★★★★</div>
<div class="stars-fill" style="width:<?= $lw ?>%">★★★★★</div>
</div>
<div class="meta">
<?= h(pseudo_from($lrow['prenom'] ?? '', $lrow['nom'] ?? '')) ?>
<?php if(!empty($lrow['visit_date'])): ?> • <?= date('d/m/Y', strtotime($lrow['visit_date'])) ?><?php endif; ?>
</div>
<div><?= nl2br(h(mb_strimwidth((string)$lrow['commentaire'],0,320,'…','UTF-8'))) ?></div>
<?php else: ?>
<div class="text-muted"><?= h($translations['avis_aucun']) ?></div>
<?php endif; ?>
</div>
<!-- Bouton RDV -->
<div class="text-end">
<a class="btn btn-danger btn-lg" href="/public/client/reserver_rdv.php?praticien_id=<?= (int)$praticien_id ?>">
<?= h($translations['dp_btn_rdv']) ?>
</a>
</div>
</div>
</div>
</div>
</div>
<!-- Modal vidéo -->
<div class="modal fade" id="videoModal" tabindex="-1" aria-labelledby="videoModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-xl">
<div class="modal-content border-0 shadow-lg rounded-4">
<div class="modal-header border-0">
<h5 class="modal-title" id="videoModalLabel"><?= h($translations['video_titre'] ?? 'Vidéo') ?></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="<?= h($translations['fermer'] ?? 'Fermer') ?>"></button>
</div>
<div class="modal-body pt-0">
<div class="ratio ratio-16x9 rounded-3 overflow-hidden bg-black">
<iframe id="vPlayer" src="" title="Video player"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen
referrerpolicy="strict-origin-when-cross-origin"></iframe>
</div>
</div>
<div class="modal-footer border-0">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<?= h($translations['fermer'] ?? 'Fermer') ?>
</button>
</div>
</div>
</div>
</div>
<script>
(function(){
const modal = document.getElementById('videoModal');
const iframe = document.getElementById('vPlayer');
function buildEmbed(provider, id){
if (!provider || !id) return '';
if (provider === 'youtube')
return 'https://www.youtube-nocookie.com/embed/' + id + '?autoplay=1&rel=0&modestbranding=1';
if (provider === 'dailymotion')
return 'https://www.dailymotion.com/embed/video/' + id + '?autoplay=1&ui-start-screen-info=false';
return '';
}
document.addEventListener('click', function(e){
const a = e.target.closest('a.pro-video[data-provider][data-id]');
if (!a) return;
e.preventDefault();
const src = buildEmbed(a.dataset.provider, a.dataset.id);
if (src) iframe.src = src;
}, false);
modal.addEventListener('hidden.bs.modal', function(){
iframe.src = '';
});
})();
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" defer></script>
<footer class="site-footer">
<div class="container">
<p><?= $translations['copyr'] ?></p>
</div>
</footer>
</body>
</html>
+77
View File
@@ -0,0 +1,77 @@
<?php
require_once __DIR__ . '/../includes/init_client.php';
if (!isset($_GET['praticien_id'])) {
http_response_code(400);
echo json_encode(['error' => $translations['id_praticien_manquant']]);
exit;
}
$praticien_id = intval($_GET['praticien_id']);
// Récupérer la durée d'un rendez-vous
$stmt = $pdo->prepare("SELECT duree_rdv FROM praticien WHERE id = ?");
$stmt->execute([$praticien_id]);
$duree_minutes = (int)$stmt->fetchColumn();
if (!$duree_minutes) {
echo json_encode([]);
exit;
}
$date_debut = new DateTimeImmutable();
$date_fin = $date_debut->modify('+3 weeks');
// Récupérer toutes les disponibilités du 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);
// Récupérer tous les rendez-vous déjà réservés dans cette période
$stmt = $pdo->prepare("SELECT date_rdv FROM rendez_vous WHERE praticien_id = ? AND date_rdv BETWEEN ? AND ?");
$stmt->execute([$praticien_id, $date_debut->format('Y-m-d H:i:s'), $date_fin->format('Y-m-d H:i:s')]);
$reservations_existantes = array_map(function($r) {
return (new DateTimeImmutable($r['date_rdv']))->format('Y-m-d H:i');
}, $stmt->fetchAll(PDO::FETCH_ASSOC));
$events = [];
$periode = new DatePeriod(
$date_debut,
new DateInterval('P1D'),
$date_fin
);
foreach ($periode as $jour) {
$jour_semaine = (int)$jour->format('N'); // 1 = lundi, 7 = dimanche
foreach ($dispos as $dispo) {
if ((int)$dispo['jour_semaine'] !== $jour_semaine) continue;
$heure_debut = DateTime::createFromFormat('H:i:s', $dispo['heure_debut']);
$heure_fin = DateTime::createFromFormat('H:i:s', $dispo['heure_fin']);
if (!$heure_debut || !$heure_fin) continue;
$debut_creneau = (new DateTimeImmutable($jour->format('Y-m-d') . ' ' . $heure_debut->format('H:i:s')));
$fin_creneau = (new DateTimeImmutable($jour->format('Y-m-d') . ' ' . $heure_fin->format('H:i:s')));
while ($debut_creneau->add(new DateInterval('PT' . $duree_minutes . 'M')) <= $fin_creneau) {
$fin = $debut_creneau->add(new DateInterval('PT' . $duree_minutes . 'M'));
$start_str = $debut_creneau->format('Y-m-d H:i');
if (!in_array($start_str, $reservations_existantes)) {
$events[] = [
'title' => $debut_creneau->format('H:i'),
'start' => $debut_creneau->format(DateTime::ATOM),
'end' => $fin->format(DateTime::ATOM)
];
}
$debut_creneau = $fin;
}
}
}
header('Content-Type: application/json');
echo json_encode($events);
+50
View File
@@ -0,0 +1,50 @@
<?php
require_once __DIR__ . '/../includes/init_client.php';
// Récupérer les infos du client
$stmt = $pdo->prepare("SELECT * FROM client WHERE id = ?");
$stmt->execute([$_SESSION['reference_id']]);
$client = $stmt->fetch();
if (!$client) {
echo "<p>{$translations['impossible_charger_infos']}</p>";
exit;
}
require_once '../includes/header.php';
?>
<div class="main1">
<div class="container py-5">
<div class="shadow p-4 rounded bg-light">
<h2 class="mb-4 text-center"><?= $translations['mes_infos_heading'] ?></h2>
<div class="card-body">
<ul class="list-group list-group-flush">
<li class="list-group-item"><strong><?= $translations['label_nom'] ?> :</strong> <?= htmlspecialchars($client['nom'] ?? '') ?></li>
<li class="list-group-item"><strong><?= $translations['label_prenom'] ?> :</strong> <?= htmlspecialchars($client['prenom'] ?? '') ?></li>
<li class="list-group-item"><strong><?= $translations['label_email'] ?> :</strong> <?= htmlspecialchars($client['email'] ?? '') ?></li>
<li class="list-group-item"><strong><?= $translations['label_telephone'] ?> :</strong> <?= htmlspecialchars($client['telephone'] ?? '') ?></li>
<li class="list-group-item"><strong><?= $translations['label_genre'] ?> :</strong> <?= htmlspecialchars($client['genre'] ?? '') ?></li>
<li class="list-group-item"><strong><?= $translations['label_date_naissance'] ?> :</strong> <?= date('d/m/Y', strtotime(htmlspecialchars($client['date_naissance'] ?? ''))) ?></li>
<li class="list-group-item"><strong><?= $translations['label_adresse'] ?> :</strong> <?= nl2br(htmlspecialchars($client['adresse'] ?? '')) ?></li>
<li class="list-group-item"><strong><?= $translations['label_ville'] ?> :</strong> <?= htmlspecialchars($client['ville'] ?? '') ?></li>
<li class="list-group-item"><strong><?= $translations['label_code_postal'] ?> :</strong> <?= htmlspecialchars($client['code_postal'] ?? '') ?></li>
</ul>
<div class="mt-4 text-center">
<a href="/public/client/modifier.php" class="btn btn-primary">
<?= $translations['btn_modifier_mes_infos'] ?>
</a>
</div>
</div>
</div>
</div>
</div>
<footer class="site-footer">
<div class="container">
<p><?= $translations['copyr'] ?></p>
</a></p>
</div>
</footer>
</body>
</html>
+490
View File
@@ -0,0 +1,490 @@
<?php
require_once __DIR__ . '/../includes/init_client.php';
// Support recherche ville en russe + latin
// Essaie d'abord /public/includes/, sinon /includes/ (selon ton projet)
$__paths = [
__DIR__ . '/../includes/search_helpers.php',
dirname(__DIR__, 2) . '/includes/search_helpers.php',
];
foreach ($__paths as $__p) { if (is_file($__p)) { require_once $__p; break; } }
unset($__paths, $__p);
/* =========================
Paramètres / Recherche
========================= */
$perPage = 12;
$page = max(1, (int)($_GET['page'] ?? 1));
$qRaw = trim((string)($_GET['q'] ?? ''));
$wRaw = trim((string)($_GET['where'] ?? ''));
$q = mb_strtolower($qRaw);
$w = mb_strtolower($wRaw);
$isSearch = ($q !== '' || $w !== '');
/* =========================
Helpers
========================= */
function video_thumb_info(?string $url): ?array {
if (!$url || !trim($url)) return null;
$u = trim($url);
// YouTube
if (preg_match('~(?:youtube\\.com/(?:watch\\?v=|embed/|shorts/)|youtu\\.be/)([A-Za-z0-9_-]{6,})~i', $u, $m)) {
$id = $m[1];
return ['provider'=>'youtube','id'=>$id, 'thumb'=>"https://i.ytimg.com/vi/$id/hqdefault.jpg"];
}
// Dailymotion
if (preg_match('~dailymotion\\.com/video/([A-Za-z0-9]+)|dai\\.ly/([A-Za-z0-9]+)~i', $u, $m)) {
$id = $m[1] ?: $m[2];
return ['provider'=>'dailymotion','id'=>$id,'thumb'=>"https://www.dailymotion.com/thumbnail/video/$id"];
}
// Fichiers directs
$path = parse_url($u, PHP_URL_PATH) ?? '';
if (preg_match('~\\.(mp4|webm|ogg)$~i', $path)) return ['provider'=>'file','id'=>null,'thumb'=>null];
if (strpos(strtolower($u), 'vimeo.com') !== false) return ['provider'=>'vimeo','id'=>null,'thumb'=>null];
return ['provider'=>'other','id'=>null,'thumb'=>null];
}
function fullName(array $p): string {
return trim(($p['prenom'] ?? '').' '.($p['nom'] ?? ''));
}
function excerpt_words(string $html, int $words = 60): string {
$plain = trim(preg_replace('/\\s+/', ' ', strip_tags($html)));
if ($plain === '') return '';
$parts = preg_split('/\\s+/', $plain);
if (count($parts) <= $words) return $plain;
return implode(' ', array_slice($parts, 0, $words)).'…';
}
/* =========================
Requêtes (COUNT + LIST)
========================= */
$whereSql = [];
$params = [];
/*==========================BLOCK WHERE AVEC ABONNEMENT VALIDE ==========================*/
$whereSql[] = "(date_expiration IS NOT NULL AND date_expiration >= DATE_SUB(NOW(), INTERVAL 14 DAY))";
/*=======================================================================================*/
if ($q !== '') {
$words = preg_split('/\s+/', $q, -1, PREG_SPLIT_NO_EMPTY);
$and = [];
foreach ($words as $i => $word) {
$k1 = ":q_name_$i";
$k2 = ":q_spec_$i";
$and[] = "(
LOWER(CONCAT(COALESCE(prenom,''),' ',COALESCE(nom,''))) LIKE $k1
OR LOWER(COALESCE(specialite,'')) LIKE $k2
)";
$params[$k1] = '%'.$word.'%';
$params[$k2] = '%'.$word.'%';
}
$whereSql[] = '(' . implode(' AND ', $and) . ')';
}
if ($w !== '') {
// Candidats latins à partir d'une saisie RU (alias + translittération)
$villeCands = city_query_candidates($w);
// Ajoute aussi la saisie brute (utile si déjà en latin)
$villeCands[] = mb_strtolower($w, 'UTF-8');
$villeCands = array_values(array_unique($villeCands));
$ors = [];
// Ville (LOWER(...)) : on cherche par préfixe
foreach ($villeCands as $i => $cand) {
$key = ":wv$i";
$ors[] = "LOWER(COALESCE(ville,'')) LIKE $key";
$params[$key] = $cand . '%'; // ← passe en '%'.$cand.'%' si tu veux "contient"
}
// Code postal : on garde un LIKE simple sur la saisie brute
$ors[] = "COALESCE(code_postal,'') LIKE :wcp";
$params[':wcp'] = '%' . $w . '%';
$whereSql[] = '(' . implode(' OR ', $ors) . ')';
}
$whereClause = $whereSql ? (' WHERE '.implode(' AND ', $whereSql)) : '';
/* Total pour pagination (utile seulement si !isSearch, mais on calcule une fois) */
$sqlCount = "SELECT COUNT(*) FROM praticien".$whereClause;
$stmt = $pdo->prepare($sqlCount);
$stmt->execute($params);
$total = (int)$stmt->fetchColumn();
$totalPages = max(1, (int)ceil($total / $perPage));
/* Liste (paginée si pas de recherche, sinon tout) */
$sqlList = "
SELECT id, titre, nom, prenom, photo, specialite, ville, code_postal, presentation, video
FROM praticien
$whereClause
ORDER BY specialite ASC, nom ASC
";
if (!$isSearch) {
$sqlList .= " LIMIT :limit OFFSET :offset";
}
$stmt = $pdo->prepare($sqlList);
foreach ($params as $k=>$v) $stmt->bindValue($k, $v, PDO::PARAM_STR);
if (!$isSearch) {
$stmt->bindValue(':limit', $perPage, PDO::PARAM_INT);
$stmt->bindValue(':offset', ($page-1)*$perPage, PDO::PARAM_INT);
}
$stmt->execute();
$praticiens = $stmt->fetchAll(PDO::FETCH_ASSOC);
/* =========================
Rendu partiel AJAX (#results)
========================= */
function render_results(array $praticiens, array $translations, bool $isSearch, int $page, int $totalPages): void {
?>
<?php if ($isSearch): ?>
<h1 class="results-title"><?= htmlspecialchars($translations['res_poisk'] ?? 'Résultats', ENT_QUOTES, 'UTF-8') ?></h1>
<div id="resultCount" class="result-count">
<?php
$count = count($praticiens);
echo $count === 0
? htmlspecialchars($translations['ne_naideno'] ?? 'Aucun résultat', ENT_QUOTES, 'UTF-8')
: htmlspecialchars(($translations['naideno'] ?? 'Résultats')." : ".$count, ENT_QUOTES, 'UTF-8');
?>
</div>
<?php endif; ?>
<div class="pro-list" id="list">
<?php if (empty($praticiens)): ?>
<?php else: ?>
<?php foreach ($praticiens as $p):
$name = fullName($p);
$spec = $p['specialite'] ?? '';
$ville = $p['ville'] ?? '';
$cp = $p['code_postal'] ?? '';
$present= excerpt_words((string)($p['presentation'] ?? ''), 35);
$vinfo = !empty($p['video']) ? video_thumb_info($p['video']) : null;
$thumb = $vinfo['thumb'] ?? null;
$altVid = htmlspecialchars($translations['video_alt'] ?? 'Vidéo', ENT_QUOTES, 'UTF-8');
$href = !empty($p['video']) ? htmlspecialchars($p['video'], ENT_QUOTES, 'UTF-8') : '';
?>
<article class="pro-card praticien"
data-name="<?= htmlspecialchars(mb_strtolower($name), ENT_QUOTES, 'UTF-8') ?>"
data-spec="<?= htmlspecialchars(mb_strtolower($spec), ENT_QUOTES, 'UTF-8') ?>"
data-ville="<?= htmlspecialchars(mb_strtolower($ville), ENT_QUOTES, 'UTF-8') ?>"
data-cp="<?= htmlspecialchars(mb_strtolower($cp), ENT_QUOTES, 'UTF-8') ?>">
<!-- Photo -->
<div class="pro-photo">
<a href="/public/client/detail_praticien.php?id=<?= (int)$p['id'] ?>"><img
src="<?= !empty($p['photo']) ? '/public/uploads/'.htmlspecialchars($p['photo'], ENT_QUOTES, 'UTF-8') : '/public/uploads/img/user_placeholder.png' ?>"
alt="<?= htmlspecialchars($translations['alt_photo'] ?? 'Photo du praticien', ENT_QUOTES, 'UTF-8') ?>"
loading="lazy"></a>
</div>
<!-- Infos -->
<div class="pro-info">
<h2 class="pro-name"><a href="/public/client/detail_praticien.php?id=<?= (int)$p['id'] ?>"><?= htmlspecialchars($name, ENT_QUOTES, 'UTF-8') ?></a></h2>
<?php if ($spec): ?><div class="pro-spec"><?= htmlspecialchars($spec, ENT_QUOTES, 'UTF-8') ?></div><?php endif; ?>
<?php if ($present): ?>
<p class="pro-desc"><?= nl2br(htmlspecialchars($present, ENT_QUOTES, 'UTF-8')) ?></p>
<?php endif; ?>
<a href="#" class="favori_lien" data-id="<?= (int)$p['id'] ?>" data-loading="0">
<?= htmlspecialchars($translations['plus_favorits'] ?? 'Ajouter aux favoris', ENT_QUOTES, 'UTF-8') ?>
</a>
<a href="/public/client/detail_praticien.php?id=<?= (int)$p['id'] ?>">
<?= htmlspecialchars($translations['plus_details'] ?? 'Détails', ENT_QUOTES, 'UTF-8') ?>
</a>
</div>
<!-- Vidéo -->
<?php if(!empty($p['video'])): ?>
<div class="pro-video-col">
<a class="pro-video"
href="#"
data-bs-toggle="modal"
data-bs-target="#videoModal"
data-yturl="<?= $href ?>"
aria-label="<?= $altVid ?>">
<?php if ($thumb): ?>
<img src="<?= htmlspecialchars($thumb, ENT_QUOTES, 'UTF-8') ?>" alt="<?= $altVid ?>" loading="lazy">
<?php else: ?>
<img src="/public/uploads/img/video_placeholder.jpg" alt="<?= $altVid ?>" loading="lazy">
<?php endif; ?>
</a>
</div>
<?php endif; ?>
<!-- CTA -->
<div class="pro-cta-col">
<a class="pro-cta" href="/public/client/reserver_rdv.php?praticien_id=<?= (int)$p['id'] ?>">
<?= htmlspecialchars($translations['den'] ?? 'Prendre RDV', ENT_QUOTES, 'UTF-8') ?>
</a>
</div>
</article>
<?php endforeach; ?>
<?php endif; ?>
</div>
<?php if (!$isSearch && $totalPages > 1): ?>
<nav class="pager" aria-label="Pagination">
<a class="pager-arrow <?= $page<=1?'is-disabled':'' ?>"
href="?page=<?= max(1,$page-1) ?>" aria-label="Page précédente">&laquo;&laquo;</a>
<?php
$start = max(1, $page-2);
$end = min($totalPages, $page+2);
for ($p=$start; $p<=$end; $p++):
?>
<a class="pager-page <?= $p===$page?'is-active':'' ?>" href="?page=<?= $p ?>"><?= $p ?></a>
<?php endfor; ?>
<a class="pager-arrow <?= $page>=$totalPages?'is-disabled':'' ?>"
href="?page=<?= min($totalPages,$page+1) ?>" aria-label="Page suivante">&raquo;&raquo;</a>
</nav>
<?php endif;
}
/* Si appel AJAX, ne renvoyer que le fragment #results */
if (isset($_GET['ajax']) && $_GET['ajax'] === '1') {
header('Content-Type: text/html; charset=utf-8');
ob_start();
render_results($praticiens, $translations, $isSearch, $page, $totalPages);
echo ob_get_clean();
exit;
}
require_once '../includes/header.php';
?>
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title><?= htmlspecialchars($translations['menu_liste_specialistes'] ?? 'Liste des praticiens', ENT_QUOTES, 'UTF-8') ?></title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Design -->
<link rel="stylesheet" href="../../includes/custom_style.css">
<link rel="stylesheet" href="../../includes/mobil_style.css">
<style>
/* Pagination pills (<< >> ovale + ronds numérotés) */
.pager{display:flex;justify-content:center;align-items:center;gap:10px;margin:22px 0;}
.pager a{text-decoration:none;font-weight:700}
.pager-arrow{
padding:10px 18px;border-radius:999px;border:1px solid #E0E3EB;background:#fff;color:#38378B;
box-shadow:0 6px 14px rgba(0,0,0,.06);transition:background .2s, transform .1s, opacity .2s;
}
.pager-arrow:hover{background:#f8f9ff;transform:translateY(-1px)}
.pager-arrow.is-disabled{opacity:.4;pointer-events:none}
.pager-page{
width:40px;height:40px;display:inline-flex;align-items:center;justify-content:center;border-radius:50%;
border:1px solid #E0E3EB;background:#fff;color:#38378B;box-shadow:0 6px 14px rgba(0,0,0,.06);
transition:background .2s, transform .1s, color .2s, border-color .2s;
}
.pager-page:hover{background:#f8f9ff;transform:translateY(-1px)}
.pager-page.is-active{background:#E03C3F;color:#fff;border-color:#E03C3F;transform:none}
</style>
</head>
<body>
<!-- HERO + BARRE DE RECHERCHE -->
<section class="hero">
<br>
<div class="searchbar" role="search">
<span class="search-icon" aria-hidden="true">🔎</span>
<div class="input-wrap">
<input id="q" type="text"
placeholder="<?= htmlspecialchars($translations['n_special'] ?? 'spécialité, nom…', ENT_QUOTES, 'UTF-8') ?>"
aria-label="spécialité, nom" value="<?= htmlspecialchars($qRaw, ENT_QUOTES, 'UTF-8') ?>">
</div>
<div class="divider" aria-hidden="true"></div>
<div class="input-wrap">
<input id="where" type="text"
placeholder="<?= htmlspecialchars($translations['gde'] ?? 'ville ou code postal', ENT_QUOTES, 'UTF-8') ?>"
aria-label="ville ou code postal" value="<?= htmlspecialchars($wRaw, ENT_QUOTES, 'UTF-8') ?>">
</div>
</div>
</section>
<!-- LISTE + PAGINATION (wrapper remplaçable en AJAX) -->
<div class="container">
<div id="results">
<?php render_results($praticiens, $translations, $isSearch, $page, $totalPages); ?>
</div>
</div>
<footer class="site-footer">
<div class="container">
<p><?= $translations['copyr'] ?></p>
</div>
</footer>
<!-- Modal vidéo -->
<div class="modal fade" id="videoModal" tabindex="-1" aria-labelledby="videoModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-xl">
<div class="modal-content border-0 shadow-lg rounded-4">
<div class="modal-header border-0">
<h5 class="modal-title" id="videoModalLabel"><?= htmlspecialchars($translations['video_titre'] ?? 'Vidéo') ?></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="<?= htmlspecialchars($translations['fermer'] ?? 'Fermer') ?>"></button>
</div>
<div class="modal-body pt-0">
<div class="ratio ratio-16x9 rounded-3 overflow-hidden">
<iframe id="ytPlayer"
src=""
title="YouTube video player"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen
referrerpolicy="strict-origin-when-cross-origin"></iframe>
</div>
</div>
<div class="modal-footer border-0">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<?= htmlspecialchars($translations['fermer'] ?? 'Fermer') ?>
</button>
</div>
</div>
</div>
</div>
<!-- JS -->
<script>
(function(){
// Convertit n'importe quelle URL YouTube en URL d'embed nocookie
function toEmbed(url) {
if (!url) return '';
try {
const u = new URL(url), host = u.hostname;
let id = '';
if (host.includes('youtu.be')) {
id = u.pathname.split('/').filter(Boolean)[0] || '';
} else if (u.searchParams.get('v')) {
id = u.searchParams.get('v');
} else if (u.pathname.includes('/embed/')) {
id = u.pathname.split('/embed/')[1].split('/')[0];
} else if (u.pathname.includes('/shorts/')) {
id = u.pathname.split('/shorts/')[1].split('/')[0];
}
id = (id || '').replace(/[^a-zA-Z0-9_-]/g, '');
return id ? `https://www.youtube-nocookie.com/embed/${id}` : '';
} catch { return ''; }
}
const modal = document.getElementById('videoModal');
const iframe = document.getElementById('ytPlayer');
document.addEventListener('click', function(e) {
const a = e.target.closest('a.pro-video[data-yturl]');
if (!a) return;
e.preventDefault();
const embed = toEmbed(a.getAttribute('data-yturl'));
if (embed) iframe.src = embed + '?autoplay=1&rel=0&modestbranding=1';
}, false);
modal.addEventListener('hidden.bs.modal', function () { iframe.src = ''; });
// Recherche AJAX sur toute la table
const q = document.getElementById('q');
const where = document.getElementById('where');
const results = document.getElementById('results');
let t;
function reload(page=1) {
const qs = new URLSearchParams({
ajax: '1',
q: q.value.trim(),
where: where.value.trim(),
page: page
});
fetch(location.pathname + '?' + qs.toString(), { headers:{'X-Requested-With':'XMLHttpRequest'} })
.then(r => r.text())
.then(html => { results.innerHTML = html; })
.catch(()=>{ /* silencieux */ });
}
function onInput(){ clearTimeout(t); t = setTimeout(() => reload(1), 220); }
q.addEventListener('input', onInput);
where.addEventListener('input', onInput);
// Pagination progressive enhancement (charge dans #results)
document.addEventListener('click', function(e){
const a = e.target.closest('.pager a[href]');
if (!a) return;
e.preventDefault();
const url = new URL(a.getAttribute('href'), location.href);
// Conserve recherche si non vide
if (q.value.trim()) url.searchParams.set('q', q.value.trim());
if (where.value.trim()) url.searchParams.set('where', where.value.trim());
url.searchParams.set('ajax', '1');
fetch(url.toString(), { headers:{'X-Requested-With':'XMLHttpRequest'} })
.then(r=>r.text()).then(html=>{ results.innerHTML = html; });
}, false);
// Favoris (SweetAlert2)
function ajouterFavori(el, praticienId) {
if (el.dataset.loading === '1') return;
el.dataset.loading = '1';
fetch('/public/client/ajouter_favori.php', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest'
},
body: new URLSearchParams({ praticien_id: praticienId }).toString()
})
.then(async (r) => {
const text = await r.text();
if (!r.ok) throw new Error(text || 'Erreur réseau');
const deja = /déjà|already/i.test(text);
el.classList.add('ajoute'); el.setAttribute('aria-pressed', 'true'); el.title = text;
if (typeof Swal !== 'undefined') {
Swal.fire({
icon: deja ? 'info' : 'success',
title: <?= json_encode($translations['sw_favori_ajoute_title'] ?? 'Favori ajouté !', JSON_UNESCAPED_UNICODE) ?>,
text: text,
timer: 1800,
showConfirmButton: false
});
} else { alert(text); }
})
.catch((err) => {
if (typeof Swal !== 'undefined') {
Swal.fire({ icon:'error', title:'Oups', text: err.message || 'Erreur.' });
} else { console.error(err); alert('Erreur.'); }
})
.finally(() => { el.dataset.loading = '0'; });
}
document.addEventListener('click', (e)=>{
const a = e.target.closest('.favori_lien');
if (!a) return;
e.preventDefault();
const id = a.dataset.id;
const T = {
title: <?= json_encode($translations['confirmation'] ?? 'Confirmation') ?>,
text: <?= json_encode($translations['voulez_vous_ajouter_favori'] ?? 'Ajouter ce praticien à vos favoris ?') ?>,
ok: <?= json_encode($translations['btn_oui'] ?? 'Oui') ?>,
cancel:<?= json_encode($translations['btn_annuler'] ?? 'Annuler') ?>
};
if (typeof Swal !== 'undefined') {
Swal.fire({
icon: 'question',
title: T.title,
text: T.text,
showCancelButton: true,
confirmButtonText: T.ok,
cancelButtonText: T.cancel,
}).then(res => { if (res.isConfirmed) ajouterFavori(a, id); });
} else {
if (confirm(T.text)) ajouterFavori(a, id);
}
});
})();
</script>
<script src="/js/sweetalert2@11.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" defer></script>
</body>
</html>
+114
View File
@@ -0,0 +1,114 @@
<?php
require_once __DIR__ . '/../includes/init_client.php';
$client_id = $_SESSION['reference_id'];
$stmt = $pdo->prepare("
SELECT p.*
FROM praticien_client pc
JOIN praticien p ON pc.praticien_id = p.id
WHERE pc.client_id = ?
ORDER BY p.nom ASC
");
$stmt->execute([$client_id]);
$favoris = $stmt->fetchAll();
// util pour couper les mots de la description texte
function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
function excerpt($text, $len=160){
$t = trim(strip_tags((string)$text));
if (mb_strlen($t,'UTF-8') <= $len) return $t;
return mb_substr($t, 0, $len, 'UTF-8') . '…';
}
$PAGE_TITLE = $translations['mes_praticiens_favoris_title'] ?? 'Mes praticiens favoris';
$HEAD_EXTRA = <<<HTML
<script src="/js/sweetalert2@11.js"></script>
HTML;
require_once '../includes/header.php';
?>
<div class="main1">
<div class="container py-5">
<div class="shadow p-4 rounded bg-light">
<h2 class="mb-4 text-center"> <?= $translations['mes_praticiens_favoris_heading'] ?></h2>
<?php if (empty($favoris)): ?>
<div class="alert alert-info"><?= $translations['aucun_favori_pour_le_moment'] ?></div>
<?php else: ?>
<?php foreach ($favoris as $p): ?>
<div class="card mb-4 shadow-sm" id="praticien_<?= $p['id'] ?>">
<div class="card-body praticien-card">
<div>
<?php if ($p['photo']): ?>
<img src="/public/uploads/<?= htmlspecialchars($p['photo']) ?>" alt="<?= $translations['alt_photo'] ?>" class="card-img-top">
<?php else: ?>
<img src="/public/uploads/img/user_placeholder.png" alt="<?= $translations['alt_photo'] ?>" class="card-img-top">
<?php endif; ?>
</div>
<div class="card-body p-0">
<h2 class="pro-name"><?= htmlspecialchars($p['prenom']?? '') . ' ' . htmlspecialchars($p['nom']?? '') ?></h5>
<p class="mb-1"><strong><?= $translations['label_ville'] ?> :</strong> <?= htmlspecialchars($p['ville'] ?? '') ?></p>
<p class="mb-2">
<?php if (!empty($p['presentation'])): ?>
<?= h(excerpt($p['presentation'], 300)) ?>
<?php endif; ?>
</p>
<div class="d-flex gap-3">
<button class="btn btn-danger btn-sm" onclick="supprimerFavori(<?= $p['id'] ?>)"> <?= $translations['btn_retirer'] ?></button>
<a class="btn btn-outline-secondary btn-sm btn-table" href="/public/client/reserver_rdv.php?praticien_id=<?= $p['id'] ?>"> <?= $translations['btn_rendez_vous'] ?></a>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
<script>
function supprimerFavori(praticienId) {
Swal.fire({
title: <?= json_encode($translations['confirm_supprimer_favori_title']) ?>,
text: <?= json_encode($translations['confirm_supprimer_favori_text']) ?>,
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#dc3545',
cancelButtonColor: '#6c757d',
confirmButtonText: <?= json_encode($translations['btn_oui_supprimer']) ?>,
cancelButtonText: <?= json_encode($translations['btn_annuler']) ?>
}).then((result) => {
if (result.isConfirmed) {
fetch('/public/client/supprimer_favori.php', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'praticien_id=' + praticienId
})
.then(res => res.text())
.then(msg => {
Swal.fire({
icon: 'success',
title: <?= json_encode($translations['favori_supprime_title']) ?>,
text: msg,
timer: 1500,
showConfirmButton: false
});
const element = document.getElementById('praticien_' + praticienId);
if (element) element.remove();
});
}
});
}
</script>
</div>
<footer class="site-footer">
<div class="container">
<p><?= $translations['copyr'] ?></p>
</div>
</footer>
</body>
</html>
+249
View File
@@ -0,0 +1,249 @@
<?php
require_once __DIR__ . '/../includes/init_client.php';
$pdo->prepare("
INSERT INTO push_badge_counters (compte_id, msg_unread, rdv_unread, updated_at)
VALUES (:cid, 0, 0, NOW())
ON DUPLICATE KEY UPDATE
rdv_unread = 0,
updated_at = NOW()
")->execute([':cid' => (int)$_SESSION['compte_id']]);
$client_id = $_SESSION['reference_id'];
$stmt = $pdo->prepare("
SELECT
rv.*,
p.nom AS nom_praticien,
p.prenom AS prenom_praticien,
p.email AS email_praticien,
p.adresse AS p_adresse,
p.ville AS p_ville,
p.code_postal AS p_cp
FROM rendez_vous rv
JOIN praticien p ON rv.praticien_id = p.id
WHERE rv.client_id = ? AND rv.date_rdv >= NOW()
ORDER BY rv.date_rdv ASC
");
$stmt->execute([$client_id]);
$rdvs = $stmt->fetchAll(PDO::FETCH_ASSOC);
$PAGE_TITLE = $translations['mes_rdv_title'] ?? 'Mes rendez-vous';
$HEAD_EXTRA = <<<HTML
<script src="/js/sweetalert2@11.js"></script>
HTML;
require_once '../includes/header.php';
?>
<div class="main1">
<div class="container py-5">
<div class="shadow p-4 rounded bg-light">
<h2 class="mb-4 text-center">📅 <?= $translations['mes_rdv_heading'] ?></h2>
<div class="table-responsive">
<table class="table table-striped table-hover align-middle mobile-cards" id="table_rdv">
<thead class="table-light">
<tr align="center">
<th><?= $translations['col_date'] ?></th>
<th><?= $translations['col_heure'] ?></th>
<th><?= $translations['col_praticien'] ?></th>
<th><?= $translations['col_duree'] ?></th>
<th><?= $translations['col_statut'] ?></th>
<th><?= $translations['col_action'] ?></th>
<!-- Chat -->
<th><?= $translations['btn_chat'] ?></th>
<!-- Visio / Adresse -->
<th><?= htmlspecialchars($translations['status_online']) ?></th>
</tr>
</thead>
<tbody>
<?php if (empty($rdvs)): ?>
<tr><td colspan="8" class="text-center"><?= $translations['aucun_rdv_a_venir'] ?></td></tr>
<?php else: ?>
<?php foreach ($rdvs as $r):
$dt = new DateTime($r['date_rdv']);
$id = (int)$r['id'];
$ficheUrl = '/specialiste?id='.(int)$r['praticien_id'];
?>
<tr>
<td><?= $dt->format('d/m/Y') ?></td>
<td><?= $dt->format('H:i') ?></td>
<td>
<a href="/public/client/detail_praticien.php?id=<?= (int)$r['praticien_id'] ?>">
<?= htmlspecialchars($r['prenom_praticien'] . ' ' . $r['nom_praticien']) ?>
</a>
</td>
<td><?= (int)$r['duree_minutes'] ?> <?= $translations['min_abbr'] ?></td>
<td id="statut_<?= $id ?>">
<span class="badge rounded-pill <?= $r['statut'] === 'confirmé' ? 'bg-success' : ($r['statut'] === 'prévu' ? 'bg-warning text-dark' : 'bg-secondary') ?>">
<?= $r['statut'] === 'confirmé' ? $translations['statut_confirme'] : ($r['statut'] === 'prévu' ? $translations['statut_prevu'] : $translations['statut_annule']) ?>
</span>
</td>
<td>
<?php if ($r['statut'] !== 'annulé'): ?>
<button class="btn btn-outline-danger btn-sm" onclick="annulerRdv(<?= $id ?>)"> <?= $translations['btn_annuler'] ?></button>
<?php else: ?>
<em class="text-muted"><?= $translations['statut_annule'] ?></em>
<?php endif; ?>
</td>
<!-- Chat -->
<td>
<a class="btn btn-outline-secondary btn-sm btn-table" href="/messages/start.php?praticien_id=<?= (int)$r['praticien_id'] ?>">
<?= $translations['chat_praticien'] ?>
</a>
</td>
<!-- Visio (online) OU Adresse/Lien (offline) -->
<td>
<?php
$lieu = strtolower(trim((string)($r['lieu'] ?? '')));
if ($lieu === 'online') {
// --- Affichage visio comme avant ---
$statut = $r['statut']; // 'confirmé' | 'prévu' | 'annulé'
$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) {
// Bouton actif
?>
<a href="/visio/join.php?rdv=<?= (int)$r['id'] ?>&start=1" class="btn btn-outline-secondary btn-sm btn-table">
<?= htmlspecialchars($translations['visio_nachalo']) ?>
</a>
<?php
} else {
// Messages hors fenêtre
?>
<small class="text-muted d-block mt-1">
<?php
if ($now < $open) echo htmlspecialchars(($translations['ouvert_a'] ?? 'Откроется в')).' '.$open->format('H:i');
elseif ($now > $close) echo htmlspecialchars(($translations['ferme_a'] ?? 'Закрыто в')).' '.$close->format('H:i');
else echo htmlspecialchars(($translations['prev_a'] ?? 'Запланировано на')).' '.$start->format('d/m/Y \à H:i').'.';
?>
</small>
<?php
}
} elseif ($statut === 'prévu') {
// Pas encore confirmé
?>
<small class="text-danger d-block mt-1">
<?= htmlspecialchars($translations['votre_rdv_jdet'] ?? 'En attente de confirmation') ?>
</small>
<?php
} else {
?>
<small class="text-muted d-block mt-1">
<?= htmlspecialchars($translations['statut_annule'] ?? 'Annulé') ?>
</small>
<?php
}
} else {
// --- OFFLINE : afficher adresse (si dispo) + lien fiche praticien ---
$addrParts = array_filter([
trim((string)($r['p_adresse'] ?? '')),
trim((string)($r['p_cp'] ?? '')) . ' ' . trim((string)($r['p_ville'] ?? ''))
], fn($x) => $x !== '' && $x !== ' ');
$addr = trim(implode(', ', $addrParts));
if ($addr !== '') {
//echo '<div class="mb-1"> '.htmlspecialchars($addr).'</div>';
}
?>
<a href="<?= htmlspecialchars($ficheUrl) ?>" class="btn btn-outline-secondary btn-sm btn-table">
<?= htmlspecialchars($translations['voir_fiche_praticien'] ?? 'Voir la fiche') ?>
</a>
<?php
}
?>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
<script>
function annulerRdv(id) {
Swal.fire({
title: <?= json_encode($translations['confirm_annulation_title']) ?>,
icon: 'warning',
showCancelButton: true,
confirmButtonText: <?= json_encode($translations['btn_oui_annuler']) ?>,
cancelButtonText: <?= json_encode($translations['btn_annuler']) ?>,
confirmButtonColor: '#dc3545',
cancelButtonColor: '#6c757d'
}).then((result) => {
if (!result.isConfirmed) return;
fetch('/public/client/annuler_rdv.php', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'},
body: new URLSearchParams({ id: String(id) })
})
.then(r => r.json().catch(() => ({ok:false, message:'Bad JSON'})))
.then(data => {
if (data.ok) {
Swal.fire({ icon:'success', title: <?= json_encode($translations['rdv_annule_ok']) ?>, text: data.message, timer: 1600, showConfirmButton:false });
const statutEl = document.getElementById("statut_" + id);
if (statutEl) {
statutEl.innerHTML = '<span class="badge bg-secondary"><?= htmlspecialchars($translations['statut_annule']) ?></span>';
}
} else {
Swal.fire({ icon:'error', title: 'Oops', text: data.message || 'Erreur' });
}
})
.catch(() => {
Swal.fire({ icon:'error', title:'Erreur réseau', text:'Veuillez réessayer.' });
});
});
}
</script>
<!-- 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(){
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'] ?></p>
</div>
</footer>
</body>
</html>
+563
View File
@@ -0,0 +1,563 @@
<?php
require_once __DIR__ . '/../includes/init_client.php';
require_once __DIR__ . '/../../sms/notify_rdv.php';
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
function send_rdv_email_with_ics_phpmailer(
string $toEmail, string $toName,
string $subject, string $textBody, string $htmlBody,
string $ical, string $filename
): bool {
global $MAILCFG; // vient de /includes/mailer.php
// garde-fous utiles
if (!filter_var($toEmail, FILTER_VALIDATE_EMAIL)) {
error_log('PHPMailer RDV: invalid recipient email: ' . var_export($toEmail, true));
return false;
}
if (empty($MAILCFG) || empty($MAILCFG['smtp']['host'])) {
error_log('PHPMailer RDV: MAILCFG missing/incomplete.');
return false;
}
$mail = new PHPMailer(true);
try {
// SMTP
$mail->isSMTP();
$mail->Host = $MAILCFG['smtp']['host'];
$mail->Port = (int)$MAILCFG['smtp']['port'];
$mail->SMTPSecure = $MAILCFG['smtp']['secure'] ?? PHPMailer::ENCRYPTION_STARTTLS;
$mail->SMTPAuth = (bool)$MAILCFG['smtp']['auth'];
$mail->Username = $MAILCFG['smtp']['username'] ?? '';
$mail->Password = $MAILCFG['smtp']['password'] ?? '';
// Base
$mail->CharSet = 'UTF-8';
$mail->Encoding = 'base64';
$mail->XMailer = '';
// Envelope (Return-Path)
if (!empty($MAILCFG['bounce'])) $mail->Sender = $MAILCFG['bounce'];
// From / Reply-To
$from = $MAILCFG['from'] ?? ['no-reply@uslugi.fr', 'USLUGI.FR'];
$rpto = $MAILCFG['reply_to'] ?? ['contact@uslugi.fr', 'USLUGI.FR'];
$mail->setFrom($from[0], $from[1] ?? '');
if (!empty($rpto[0])) $mail->addReplyTo($rpto[0], $rpto[1] ?? '');
// Destinataire
$mail->addAddress($toEmail, $toName);
// Anti auto-replies (optionnel)
if (!empty($MAILCFG['suppress_auto'])) {
$mail->addCustomHeader('Auto-Submitted', 'auto-generated');
$mail->addCustomHeader('X-Auto-Response-Suppress', 'All');
}
// Corps
$mail->isHTML(true);
$mail->Subject = $subject;
$mail->Body = $htmlBody;
$mail->AltBody = $textBody;
// 📎 Invitation iCalendar
$mail->addStringAttachment(
$ical,
$filename,
'base64',
'text/calendar; method=REQUEST; charset=UTF-8'
);
$mail->send();
return true;
} catch (Throwable $e) {
error_log('PHPMailer RDV send exception: '.$e->getMessage());
if (property_exists($mail, 'ErrorInfo') && $mail->ErrorInfo) {
error_log('PHPMailer RDV ErrorInfo: '.$mail->ErrorInfo);
}
return false;
}
}
/* ================== Valeurs par défaut Online/Offline ================== */
if (!isset($c_online)) { $c_online = 'online'; }
if (!isset($c_offline)) { $c_offline = 'offline'; }
/* ================== CSRF pour l'appel AJAX ================== */
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(16));
}
$CSRF = $_SESSION['csrf_token'];
/* ================== Helpers communs (HTML UI) ================== */
function ui_head(string $title): void {
$HEAD_EXTRA = <<<HTML
<style>
html,body{height:100%}
body{
margin:0; background:#8CA9FF;
font-family:'Montserrat',system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;
display:flex; align-items:center; justify-content:center;
padding:32px 16px;
}
.page-loader{ position:fixed; inset:0; z-index:9999; background:#8CA9FF; display:flex; align-items:center; justify-content:center; transition:opacity .25s ease, visibility .25s ease; }
.hidden{opacity:0; visibility:hidden;}
.loader-card{ background:#fff; color:#38378B; border-radius:22px; padding:20px 22px; box-shadow:0 12px 40px rgba(0,0,0,.08); display:flex; align-items:center; gap:14px; }
.loader-spinner{ width:36px; height:36px; border-radius:50%; border:3px solid rgba(56,55,139,.15); border-top-color:#38378B; animation:spin 1s linear infinite; }
@keyframes spin{ to{ transform:rotate(360deg);} }
.loader-text{ font-weight:700; }
.wrap{ width:min(640px, 100%); margin:0 auto; }
.msg{ background:#fff; color:rgb(33,37,41); border-radius:22px; box-shadow:0 12px 40px rgba(0,0,0,.08); padding:22px; text-align:center; }
.msg h2{ margin:0 0 10px; font-size:16px; font-weight:400; }
.msg p{ margin:10px 0; line-height:1.5; }
.msg .meta{ margin:12px auto 0; padding:12px 14px; border:1px solid #f11e1eff; border-radius:16px; background:#F7F8FF; color:#f11e1eff; font-weight:600; display:inline-block; }
.msg .actions{ margin-top:16px; display:flex; gap:10px; justify-content:center; flex-wrap:wrap; }
.btn{ display:inline-block; text-decoration:none; font-weight:700; border-radius:999px; padding:10px 16px; }
.btn-primary{ background:#E03C3F; color:#fff; box-shadow:0 8px 22px rgba(0,0,0,.12); }
.btn-primary:hover{ filter:brightness(1.05); }
.btn-outline{ background:#fff; color:#38378B; border:1px solid #E0E3EB; }
.btn-outline:hover{ background:#f8faff; }
.msg--ok h2::before{ content:" "; }
.msg--err h2{ color:#666666; }
.msg--err h2::before{ content:"❌ "; }
</style>
HTML;
?>
<div class="page-loader" id="loader" role="status" aria-live="polite">
<div class="loader-card">
<div class="loader-spinner" aria-hidden="true"></div>
<div class="loader-text"><?= htmlspecialchars($GLOBALS['translations']['chargement_validation'] ?? 'Подождите, идёт подтверждение...') ?></div>
</div>
</div>
<div class="wrap" id="result"></div>
<?php }
function ui_foot(): void { ?>
</body></html>
<?php }
/* =================== Helpers e-mail (HTML/Texte) =================== */
function build_email_html(array $data, 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:#ffffff;border-radius:8px;overflow:hidden;">
<tr>
<td style="background:#38378B;color:#ffffff;padding:20px 24px;font-size:18px;font-weight:600;">'.$brand.'</td>
</tr>
<tr>
<td style="padding:24px 24px 8px 24px;color:#111111;font-size:16px;line-height:1.5;">'.nl2br(htmlspecialchars($data['hello_line'])).'</td>
</tr>
<tr>
<td style="padding:8px 24px;color:#111111;font-size:16px;line-height:1.6;">'.nl2br(htmlspecialchars($data['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($t['email_avec'] ?? 'Avec').'</td>
<td style="padding:14px 16px;font-size:14px;color:#111111;">'.htmlspecialchars($data['who']).'</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:#111111;">'.htmlspecialchars($data['when']).'</td>
</tr>
'.(!empty($data['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:#111111;">'.htmlspecialchars($data['format_line']).'</td>
</tr>' : '').'
'.(!empty($data['address_line']) ? '
<tr>
<td style="padding:14px 16px;font-size:14px;color:#6b7280;">'.htmlspecialchars($t['adresse_label_ru'] ?? 'Адрес').'</td>
<td style="padding:14px 16px;font-size:14px;color:#111111;">'.htmlspecialchars($data['address_line']).'</td>
</tr>' : '').'
</table>
</td>
</tr>'.
(!empty($data['cta_url']) ? '
<tr>
<td align="center" style="padding:8px 24px 24px 24px;">
<a href="'.htmlspecialchars($data['cta_url']).'" target="_blank"
style="display:inline-block;text-decoration:none;padding:12px 20px;border-radius:999px;background:#E03C3F;color:#ffffff;font-weight:600;">'.htmlspecialchars($data['cta_label']).'</a>
</td>
</tr>' : ''
).'
<tr>
<td style="padding:8px 24px 24px 24px;color:#111111;font-size:16px;line-height:1.6;">'.nl2br(htmlspecialchars($data['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 build_email_text(array $data, array $t): string {
$lines = [];
$lines[] = $data['hello_line']; $lines[] = '';
$lines[] = $data['intro']; $lines[] = '';
$lines[] = ($t['email_avec'] ?? 'Avec').' '.$data['who'];
$lines[] = ($t['date_label'] ?? 'Date').' '.$data['when'];
if (!empty($data['format_line'])) $lines[] = ($t['format_label'] ?? 'Формат').': '.$data['format_line'];
if (!empty($data['address_line'])) $lines[] = ($t['adresse_label_ru'] ?? 'Адрес').': '.$data['address_line'];
if (!empty($data['cta_url'])) {
$lines[] = '';
$lines[] = $data['cta_label'].': '.$data['cta_url'];
}
$lines[] = '';
$lines[] = $data['footer'];
return implode("\n", $lines);
}
/* =================== Helpers ICS =================== */
function ics_escape(string $s): string {
$s = str_replace("\\", "\\\\", $s);
$s = str_replace(";", "\;", $s);
$s = str_replace(",", "\,", $s);
$s = preg_replace("/\r\n|\r|\n/", "\\n", $s);
return $s;
}
function build_ics_common_lines(
string $uid, DateTime $start, DateTime $end, string $timezone,
string $summary, string $description, string $location, array $attendees
): string {
$lines = [];
$lines[] = "BEGIN:VCALENDAR";
$lines[] = "VERSION:2.0";
$lines[] = "PRODID:-//УСЛУГИ.ФР//RDV//FR";
$lines[] = "CALSCALE:GREGORIAN";
$lines[] = "METHOD:REQUEST";
$lines[] = "BEGIN:VEVENT";
$lines[] = "UID:{$uid}";
$lines[] = "DTSTAMP:" . gmdate('Ymd\THis\Z');
$lines[] = "DTSTART;TZID={$timezone}:" . $start->format('Ymd\THis');
$lines[] = "DTEND;TZID={$timezone}:" . $end->format('Ymd\THis');
$lines[] = "SUMMARY:" . ics_escape($summary);
$lines[] = "DESCRIPTION:" . ics_escape($description);
$lines[] = "LOCATION:" . ics_escape($location);
$lines[] = "ORGANIZER;CN=УСЛУГИ.ФР:mailto:rdv@uslugi.fr";
foreach ($attendees as $a) {
if (!isset($a['email']) || !$a['email']) continue;
$cn = ics_escape($a['cn'] ?? '');
$em = $a['email'];
$lines[] = "ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;CN={$cn};RSVP=TRUE:mailto:{$em}";
}
$lines[] = "STATUS:CONFIRMED";
$lines[] = "END:VEVENT";
$lines[] = "END:VCALENDAR";
return implode("\r\n", $lines) . "\r\n";
}
function build_ics_client(array $rdv, DateTime $start, DateTime $end, string $timezone, string $location, string $ctaUrl, array $t, string $uid): string {
$summary = ($t['rdv_avec'] ?? 'Rendez-vous avec').' '.($t['titre_dr'] ?? 'Dr').' '.$rdv['prenom_praticien'].' '.$rdv['nom_praticien'];
$desc = ($t['votre_rdv_attente'] ?? 'Votre rendez-vous est prévu')."\n\n".
($t['email_avec'] ?? 'Avec').' '.($t['titre_dr'] ?? 'Dr').' '.$rdv['prenom_praticien'].' '.$rdv['nom_praticien']."\n".
'Email: '.$rdv['email_praticien']."\n".
(($rdv['tel_praticien'] ?? '') ? ('Téléphone: '.$rdv['tel_praticien']."\n") : '').
($ctaUrl ? ("\n".($t['consultez_espace_pour_details'] ?? 'Consultez votre espace pour les détails')."\n".$ctaUrl) : '');
$attendees = [
['cn' => $rdv['prenom_praticien'].' '.$rdv['nom_praticien'], 'email' => $rdv['email_praticien']],
['cn' => $rdv['prenom_client'].' '.$rdv['nom_client'], 'email' => $rdv['email_client']],
];
return build_ics_common_lines($uid, $start, $end, $timezone, $summary, $desc, $location, $attendees);
}
function build_ics_praticien(array $rdv, DateTime $start, DateTime $end, string $timezone, string $location, string $ctaUrl, array $t, string $uid): string {
$summary = ($t['rdv_avec'] ?? 'Rendez-vous avec').' '.$rdv['prenom_client'].' '.$rdv['nom_client'];
$desc = ($t['nouveau_rdv_pris_avec'] ?? 'Nouveau rendez-vous pris avec')."\n\n".
($t['client_label'] ?? 'Client').': '.$rdv['prenom_client'].' '.$rdv['nom_client']."\n".
'Email: '.$rdv['email_client']."\n".
(($rdv['tel_client'] ?? '') ? ('Téléphone: '.$rdv['tel_client']."\n") : '').
($ctaUrl ? ("\n".($t['consultez_espace_pour_details'] ?? 'Consultez votre espace pour les détails')."\n".$ctaUrl) : '');
$attendees = [
['cn' => $rdv['prenom_client'].' '.$rdv['nom_client'], 'email' => $rdv['email_client']],
['cn' => $rdv['prenom_praticien'].' '.$rdv['nom_praticien'], 'email' => $rdv['email_praticien']],
];
return build_ics_common_lines($uid, $start, $end, $timezone, $summary, $desc, $location, $attendees);
}
/* ================== Mode POST (AJAX) : traitement & réponse JSON ================== */
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST' && ($_POST['action'] ?? '') === 'validate') {
header('Content-Type: application/json; charset=utf-8');
// CSRF
if (!hash_equals($_SESSION['csrf_token'] ?? '', $_POST['csrf'] ?? '')) {
echo json_encode(['ok'=>false, 'html'=>'<div class="msg msg--err"><h2>Сессия истекла</h2><p>Обновите страницу и попробуйте ещё раз.</p></div>']); exit;
}
$client_id = $_SESSION['reference_id'] ?? null;
$praticien_id = isset($_POST['praticien_id']) ? (int)$_POST['praticien_id'] : null;
$startIso = $_POST['start'] ?? null;
$mode_rdv_in = $_POST['mode_rdv'] ?? ($_GET['mode_rdv'] ?? null);
// Normalisation du mode (défaut = online)
$mode_rdv = ($mode_rdv_in === $GLOBALS['c_offline']) ? $GLOBALS['c_offline'] : $GLOBALS['c_online'];
$planningUrl = '/public/client/reserver_rdv.php?praticien_id='.(int)($praticien_id ?? 0);
if (!$praticien_id || !$startIso || !$client_id) {
$html = '
<div class="msg msg--err">
<h2>'.htmlspecialchars($translations['donnees_manquantes_reservation'] ?? 'Données manquantes pour la réservation').'</h2>
<p>'.htmlspecialchars($translations['retour_fiche_praticien_hint'] ?? 'Merci de revenir sur la fiche du praticien et de choisir un créneau.').'</p>
<div class="actions">
<a class="btn btn-outline" href="'.htmlspecialchars($planningUrl).'">'.htmlspecialchars($translations['retour_a_la_fiche'] ?? 'Retour à la fiche').'</a>
</div>
</div>';
echo json_encode(['ok'=>false, 'html'=>$html]); exit;
}
try {
$date_rdv_sql = (new DateTime($startIso))->format('Y-m-d H:i:s');
// Vérifier si le créneau est encore libre
$stmt = $pdo->prepare("SELECT COUNT(*) FROM rendez_vous WHERE praticien_id = ? AND date_rdv = ?");
$stmt->execute([$praticien_id, $date_rdv_sql]);
if ($stmt->fetchColumn() > 0) {
$html = '
<div class="msg msg--err">
<h2>'.htmlspecialchars($translations['creneau_deja_reserve'] ?? 'Créneau déjà réservé').'</h2>
<p>'.htmlspecialchars($translations['choisir_autre_creneau'] ?? 'Veuillez choisir un autre créneau.').'</p>
<div class="actions">
<a class="btn btn-outline" href="'.htmlspecialchars($planningUrl).'">'.htmlspecialchars($translations['retour_au_planning'] ?? 'Retour au planning').'</a>
</div>
</div>';
echo json_encode(['ok'=>false, 'html'=>$html]); exit;
}
// Récupérer durée + adresse praticien
$stmt = $pdo->prepare("SELECT duree_rdv, adresse, ville, code_postal FROM praticien WHERE id = ?");
$stmt->execute([$praticien_id]);
$p = $stmt->fetch(PDO::FETCH_ASSOC);
$duree = (int)($p['duree_rdv'] ?? 60);
// Adresse complète ?
$adresse = trim((string)($p['adresse'] ?? ''));
$ville = trim((string)($p['ville'] ?? ''));
$code_postal = trim((string)($p['code_postal'] ?? ''));
$has_address = ($adresse !== '' && $ville !== '' && $code_postal !== '');
// Si offline choisi mais adresse manquante → bascule online
if ($mode_rdv === $c_offline && !$has_address) {
$mode_rdv = $c_online;
}
// Snapshot adresse (pour email/ICS)
$addrSnapshot = $has_address ? "{$adresse}, {$code_postal} {$ville}" : '';
// Insert RDV avec lieu = online/offline
$uuid = bin2hex(random_bytes(16));
$lieu_insert = ($mode_rdv === $c_offline) ? 'offline' : 'online';
$stmt = $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())
");
$stmt->execute([$praticien_id, $client_id, $date_rdv_sql, $duree, $lieu_insert, $uuid]);
// Récupérer infos pour e-mails & ICS
$stmt = $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 = ?
");
$stmt->execute([$uuid]);
$rdv = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$rdv) { throw new RuntimeException('Rendez-vous introuvable après insertion.'); }
$rdvId = (int)$rdv['rdv_id'];
$tz = 'Europe/Paris';
$startDT = new DateTime($rdv['date_rdv'], new DateTimeZone($tz));
$endDT = (clone $startDT)->modify("+{$duree} minutes");
$when = $startDT->format('d/m/Y H:i');
// RU libellés
$ru_online = $translations['option_online'] ?? 'Онлайн';
$ru_offline = $translations['option_cabinet'] ?? 'В кабинете';
$ru_format = $translations['format_label'] ?? 'Формат';
$ru_addrLbl = $translations['adresse_label_ru'] ?? 'Адрес';
$is_offline = ($mode_rdv === $c_offline);
$mode_label_ru = $is_offline ? $ru_offline : $ru_online;
// ICS LOCATION
$location = $is_offline && $has_address ? $addrSnapshot : $ru_online;
$cta_client_url = 'https://uslugi.fr/public/client/mes_rendez_vous.php';
$cta_prat_url = "https://uslugi.fr/public/praticien/rendez_vous.php?id={$rdvId}";
// ICS
$ical_client = build_ics_client($rdv, $startDT, $endDT, $tz, $location, $cta_client_url, $translations, $uuid);
$ical_prat = build_ics_praticien($rdv, $startDT, $endDT, $tz, $location, $cta_prat_url, $translations, $uuid);
// E-mails (ajout format ou adresse)
$whoClientSees = ($translations['titre_dr'] ?? 'Dr').' '.$rdv['prenom_praticien'].' '.$rdv['nom_praticien'];
$whoPratSees = ($translations['client_label'] ?? 'Client').' '.$rdv['prenom_client'].' '.$rdv['nom_client'];
$cta_label = $translations['consultez_espace_pour_details'] ?? 'Voir les détails';
// Client
$subject_c = $translations['email_confirmation_rdv_sujet'] ?? 'Confirmation de rendez-vous';
$data_c = [
'hello_line' => sprintf($translations['mail_valid_bonjour_nom'] ?? 'Bonjour %s', $rdv['prenom_client'].' '.$rdv['nom_client']),
'intro' => ($translations['votre_rdv_attente'] ?? 'Votre rendez-vous est en attente de confirmation').' '.
($translations['email_avec'] ?? 'avec').' '.$whoClientSees.".\n".
($translations['veuillez_trouver_invitation'] ?? "Veuillez trouver l'invitation en pièce jointe."),
'who' => $whoClientSees,
'when' => $when,
'format_line' => $mode_label_ru,
'address_line' => ($is_offline && $has_address) ? $addrSnapshot : '',
'cta_url' => $cta_client_url,
'cta_label' => $cta_label,
'footer' => $translations['merci_simple'] ?? 'Merci.',
];
$ok_c = send_rdv_email_with_ics_phpmailer(
$rdv['email_client'], $rdv['prenom_client'].' '.$rdv['nom_client'],
$subject_c,
build_email_text($data_c, $translations),
build_email_html($data_c, $translations),
$ical_client, "rdv_client_{$uuid}.ics"
);
// Praticien
$subject_p = ($translations['nouveau_rdv_avec'] ?? 'Nouveau rendez-vous avec').' '.$rdv['prenom_client'].' '.$rdv['nom_client'];
$data_p = [
'hello_line' => ($translations['email_bonjour_dr'] ?? 'Bonjour Dr').' '.$rdv['prenom_praticien'].' '.$rdv['nom_praticien'],
'intro' => ($translations['nouveau_rdv_pris_avec'] ?? 'Un nouveau rendez-vous a été pris avec vous.'),
'who' => $whoPratSees,
'when' => $when,
'format_line' => $mode_label_ru,
'address_line' => ($is_offline && $has_address) ? $addrSnapshot : '',
'cta_url' => $cta_prat_url,
'cta_label' => $cta_label,
'footer' => $translations['merci_simple'] ?? 'Merci.',
];
$ok_p = send_rdv_email_with_ics_phpmailer(
$rdv['email_praticien'], $rdv['prenom_praticien'].' '.$rdv['nom_praticien'],
$subject_p,
build_email_text($data_p, $translations),
build_email_html($data_p, $translations),
$ical_prat, "rdv_praticien_{$uuid}.ics"
);
if (!$ok_c || !$ok_p) {
error_log('RDV: un ou plusieurs e-mails ICS n’ont pas été envoyés.');
}
// WhatsApp (inchangé)
try {
$waTo = $rdv['tel_praticien'] ?? '';
$clientFullName = trim(($rdv['prenom_client'] ?? '') . ' ' . ($rdv['nom_client'] ?? ''));
$dateHeure = $when;
if ($waTo && $clientFullName && $dateHeure && $rdvId) {
$waRes = Sms\notify_rdv_en_attente($waTo, $clientFullName, $dateHeure, $rdvId);
if ((int)($waRes['http_code'] ?? 0) !== 200) {
error_log('WA en_attente non-200: ' . json_encode($waRes, JSON_UNESCAPED_UNICODE));
}
} else {
error_log('WA en_attente: données incomplètes (tel praticien / nom client / dateHeure / rdvId).');
}
} catch (Throwable $e) {
error_log('WA en_attente_confirmation exception: ' . $e->getMessage());
}
// Réponse succès (meta inclut le format)
$html = '
<div class="msg msg--ok">
<h2>'.($translations['rdv_confirme_pour_le'] ?? 'Rendez-vous enregistré pour le').' '.$when.' '.($translations['spasibo_za_zapis'] ?? '— спасибо за запись!').'</h2>
<div class="meta">'.htmlspecialchars($translations['votre_rdv_attente'] ?? 'Votre rendez-vous est en attente de confirmation').' — '.htmlspecialchars($ru_format).': '.htmlspecialchars($mode_label_ru).'</div>
<div class="actions">
<a class="btn btn-primary" href="'.htmlspecialchars('/public/client/reserver_rdv.php?praticien_id='.$praticien_id).'">'.htmlspecialchars($translations['retour_au_planning'] ?? 'Retour au planning').'</a>
<a class="btn btn-outline" href="/public/client/mes_rendez_vous.php">'.htmlspecialchars($translations['voir_mes_rdv'] ?? 'Voir mes rendez-vous').'</a>
</div>
</div>';
echo json_encode(['ok'=>true, 'html'=>$html]); exit;
} catch (Throwable $e) {
$html = '
<div class="msg msg--err">
<h2>'.htmlspecialchars($translations['erreur_prefixe'] ?? 'Erreur').'</h2>
<p>'.htmlspecialchars($e->getMessage()).'</p>
<div class="actions">
<a class="btn btn-outline" href="'.htmlspecialchars('/public/client/reserver_rdv.php?praticien_id='.(int)($praticien_id ?? 0)).'">'.htmlspecialchars($translations['retour_a_la_fiche'] ?? 'Retour à la fiche').'</a>
</div>
</div>';
echo json_encode(['ok'=>false, 'html'=>$html]); exit;
}
}
/* ================== Mode GET : UI + loader + JS (déclenche l'AJAX) ================== */
$praticien_id = isset($_GET['praticien_id']) ? (int)$_GET['praticien_id'] : null;
$startIso = $_GET['start'] ?? null;
$modeGet = $_GET['mode_rdv'] ?? null;
ui_head($translations['validation_rdv_title'] ?? 'Validation du rendez-vous');
?>
<script>
(function(){
const loader = document.getElementById('loader');
const result = document.getElementById('result');
const praticien_id = <?= json_encode($praticien_id) ?>;
const start = <?= json_encode($startIso) ?>;
const csrf = <?= json_encode($CSRF) ?>;
const mode_rdv = <?= json_encode($modeGet) ?>;
if(!praticien_id || !start){
loader.classList.add('hidden');
const backUrl = praticien_id ? `/public/client/reserver_rdv.php?praticien_id=${praticien_id}` : `/public/client/reserver_rdv.php`;
result.innerHTML = `
<div class="msg msg--err">
<h2><?= htmlspecialchars($translations['donnees_manquantes_reservation'] ?? 'Données manquantes pour la réservation') ?></h2>
<p><?= htmlspecialchars($translations['retour_fiche_praticien_hint'] ?? 'Merci de revenir sur la fiche du praticien et de choisir un créneau.') ?></p>
<div class="actions">
<a class="btn btn-outline" href="${backUrl}"><?= htmlspecialchars($translations['retour_a_la_fiche'] ?? 'Retour à la fiche') ?></a>
</div>
</div>`;
return;
}
const body = new URLSearchParams({
action: 'validate',
csrf,
praticien_id,
start
});
if (mode_rdv) body.set('mode_rdv', mode_rdv);
fetch(location.pathname, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body
})
.then(r => r.json())
.then(data => {
result.innerHTML = data.html || '<div class="msg msg--err"><h2>Неизвестная ошибка</h2></div>';
})
.catch(() => {
result.innerHTML = '<div class="msg msg--err"><h2>Сетевая ошибка</h2><p>Попробуйте повторно.</p></div>';
})
.finally(() => {
loader.classList.add('hidden');
});
})();
</script>
<?php ui_foot(); ?>
+224
View File
@@ -0,0 +1,224 @@
<?php
require_once __DIR__ . '/../includes/init_client.php';
$id = (int)($_SESSION['reference_id'] ?? 0);
if (!$id) { http_response_code(403); exit($translations['client_introuvable'] ?? 'Client introuvable'); }
// Récupérer les données du client
$stmt = $pdo->prepare("SELECT * FROM client WHERE id = ?");
$stmt->execute([$id]);
$client = $stmt->fetch();
if (!$client) {
exit($translations['client_introuvable'] ?? 'Client introuvable');
}
// ---- Traitement du formulaire AVANT tout output ----
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') {
$nom = trim($_POST['nom'] ?? '');
$prenom = trim($_POST['prenom'] ?? '');
$telephone = trim($_POST['telephone'] ?? '');
$genre = trim($_POST['genre'] ?? '');
$date_naiss = $_POST['date_naissance'] ?? null;
$adresse = trim($_POST['adresse'] ?? '');
$ville = trim($_POST['ville'] ?? '');
$cp = trim($_POST['code_postal'] ?? '');
$stmt = $pdo->prepare("
UPDATE client SET nom=:nom, prenom=:prenom, telephone=:telephone,
genre=:genre, date_naissance=:date_naissance, adresse=:adresse,
ville=:ville, code_postal=:code_postal
WHERE id=:id
");
$stmt->execute([
'nom' => $nom,
'prenom' => $prenom,
'telephone' => $telephone,
'genre' => $genre,
'date_naissance' => $date_naiss ?: null,
'adresse' => $adresse,
'ville' => $ville,
'code_postal' => $cp,
'id' => $id
]);
// redirection (évite le repost + actualise la page infos)
header('Location: /public/client/informations.php?updated=1');
exit;
}
require_once __DIR__ . '/../includes/header.php';
?>
<div class="main1">
<div class="container py-5">
<form method="POST" class="shadow p-4 rounded bg-light">
<h2 class="mb-4 text-center"><?= $translations['modifier_infos_heading'] ?></h2>
<div class="mb-3">
<label class="form-label"><?= $translations['label_nom'] ?> *</label>
<input type="text" name="nom" value="<?= htmlspecialchars($client['nom'] ?? '') ?>" required class="form-control">
</div>
<div class="mb-3">
<label class="form-label"><?= $translations['label_prenom'] ?> *</label>
<input type="text" name="prenom" value="<?= htmlspecialchars($client['prenom'] ?? '') ?>" required class="form-control">
</div>
<div class="mb-3">
<label class="form-label"><?= $translations['label_telephone'] ?> *</label>
<input type="tel" name="telephone" id="telephone" value="<?= htmlspecialchars($client['telephone'] ?? '') ?>" class="form-control" placeholder="+33600000000"
required
pattern="^\+?[1-9]\d{6,14}$"
title="<?=htmlspecialchars($translations['telephone_format_message']?? '') ?>">
</div>
<div class="mb-3">
<label class="form-label"><?= $translations['label_genre'] ?></label>
<select name="genre" class="form-select">
<option value=""><?= $translations['select_selectionnez'] ?></option>
<option value="<?= $translations['genre_homme'] ?>" <?= $client['genre'] === $translations['genre_homme'] ? 'selected' : '' ?>><?= $translations['genre_homme'] ?></option>
<option value="<?= $translations['genre_femme'] ?>" <?= $client['genre'] === $translations['genre_femme'] ? 'selected' : '' ?>><?= $translations['genre_femme'] ?></option>
<option value="<?= $translations['genre_autre'] ?>" <?= $client['genre'] === $translations['genre_autre'] ? 'selected' : '' ?>><?= $translations['genre_autre'] ?></option>
</select>
</div>
<div class="mb-3">
<label class="form-label"><?= $translations['label_date_naissance'] ?></label>
<input type="date" name="date_naissance" value="<?= htmlspecialchars($client['date_naissance'] ?? '') ?>" class="form-control">
</div>
<div class="mb-3 input-wrapper">
<label class="form-label"><?= $translations['label_adresse'] ?></label>
<textarea name="adresse" id="adresse" class="form-control"><?= htmlspecialchars($client['adresse'] ?? '') ?></textarea>
<ul id="suggestions" class="list-group position-absolute"></ul>
</div>
<div class="mb-3">
<label class="form-label"><?= $translations['label_ville'] ?></label>
<input type="text" name="ville" id="ville" value="<?= htmlspecialchars($client['ville'] ?? '') ?>" class="form-control">
</div>
<div class="mb-4">
<label class="form-label"><?= $translations['label_code_postal'] ?></label>
<input type="text" name="code_postal" id="code_postal" value="<?= htmlspecialchars($client['code_postal'] ?? '') ?>" class="form-control">
</div>
<div class="text-center">
<button type="submit" class="btn btn-primary"><?= $translations['btn_mettre_a_jour'] ?></button>
</div>
<?= $translations['champs_obligatoire'] ?>
</form>
</div>
<script>
// === Téléphone: simple & efficace ===
const tel = document.getElementById('telephone');
// 1) Nettoyage en direct (espaces, tirets, points, parenthèses)
tel?.addEventListener('input', function () {
const pos = this.selectionStart;
this.value = this.value.replace(/[\s\-._()/]/g, '');
try { this.setSelectionRange(pos, pos); } catch(_) {}
});
// 2) Normalisation au blur
tel?.addEventListener('blur', function() {
let val = this.value.trim().replace(/[\s\-._()/]/g, '');
if (val.startsWith('00')) val = '+' + val.slice(2);
if (/^0\d{9}$/.test(val)) val = '+33' + val.substring(1);
if (val && !val.startsWith('+')) val = '+' + val;
this.value = val;
});
// 3) Au submit : applique la normalisation même si l’utilisateur n’a pas quitté le champ
tel?.form?.addEventListener('submit', function (e) {
tel.dispatchEvent(new Event('blur'));
if (!tel.checkValidity()) {
e.preventDefault();
tel.reportValidity();
tel.focus();
}
});
document.addEventListener("DOMContentLoaded", function () {
const adresseInput = document.getElementById("adresse");
const villeInput = document.getElementById("ville");
const cpInput = document.getElementById("code_postal");
const suggestions = document.getElementById("suggestions");
// petite fonction utilitaire : construit "numero + rue"
function buildStreet(props) {
const num = (props.housenumber || '').trim();
const street = (props.street || props.name || '').trim();
return [num, street].filter(Boolean).join(' ');
}
// fallback ville si city manquant (rare)
function getCity(props) {
if (props.city) return props.city;
// ex: "84160, Cucuron, Vaucluse, Provence-Alpes-Côte d'Azur"
if (props.context) {
const parts = props.context.split(',').map(s => s.trim());
// généralement la 2e valeur correspond à la commune
return parts[1] || parts[0] || '';
}
return '';
}
let abortCtrl = null;
adresseInput.addEventListener("input", function () {
const query = adresseInput.value.trim();
if (query.length < 3) {
suggestions.innerHTML = '';
return;
}
// annule la requête précédente si on retape vite
try { abortCtrl?.abort(); } catch(_) {}
abortCtrl = new AbortController();
fetch("https://api-adresse.data.gouv.fr/search/?limit=5&q=" + encodeURIComponent(query), {
signal: abortCtrl.signal
})
.then(res => res.json())
.then(data => {
suggestions.innerHTML = '';
(data.features || []).forEach(feature => {
const props = feature.properties || {};
const li = document.createElement("li");
li.className = 'list-group-item list-group-item-action';
li.textContent = props.label || buildStreet(props);
li.addEventListener("click", function () {
// ✅ Adresse = seulement numéro + rue
adresseInput.value = buildStreet(props);
// ✅ Ville + CP séparés
villeInput.value = getCity(props);
cpInput.value = props.postcode || '';
suggestions.innerHTML = '';
});
suggestions.appendChild(li);
});
})
.catch(() => {});
});
// clic hors zone -> ferme la liste
document.addEventListener("click", function (e) {
if (!suggestions.contains(e.target) && e.target !== adresseInput) {
suggestions.innerHTML = '';
}
});
});
</script>
</div>
<footer class="site-footer">
<div class="container">
<p><?= $translations['copyr'] ?></p>
</div>
</footer>
</body>
</html>
+318
View File
@@ -0,0 +1,318 @@
<?php
require_once __DIR__ . '/../includes/init_client.php';
// Valeurs par défaut au cas où
if (! isset($c_online)) {$c_online = 'online';}
if (! isset($c_offline)) {$c_offline = 'offline';}
if (! isset($_GET['praticien_id'])) {
exit($translations['praticien_non_precise']);
}
$praticien_id = intval($_GET['praticien_id']);
$client_id = $_SESSION['reference_id'] ?? null;
$page = isset($_GET['page']) ? max(1, (int) $_GET['page']) : 1;
$limit_columns = 5;
// Infos praticien (+ adresse pour décider des radios)
$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) {
die($translations['duree_rdv_non_definie']);
}
// Adresse complète ?
$has_address =
! empty(trim((string) ($praticien['adresse'] ?? ''))) &&
! empty(trim((string) ($praticien['ville'] ?? ''))) &&
! empty(trim((string) ($praticien['code_postal'] ?? '')));
// TZ + fenêtre 21 jours
$tz = new DateTimeZone('Europe/Paris');
$now = new DateTimeImmutable('now', $tz);
$limit = $now->modify('+60 days'); // exclusif dans DatePeriod
// Récupération des disponibilités HEBDO
$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);
// Récupération des disponibilités PONCTUELLES (dates)
$stmt = $pdo->prepare(
"SELECT date_dispo, heure_debut, heure_fin
FROM disponibilite_date
WHERE praticien_id = ?
AND date_dispo BETWEEN ? AND ?
ORDER BY date_dispo ASC, heure_debut ASC"
);
$stmt->execute([
$praticien_id,
$now->format('Y-m-d'),
$limit->format('Y-m-d'),
]);
$dispos_dates_raw = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Index par date (Y-m-d) pour accès rapide dans la boucle
$dispos_dates_by_day = [];
foreach ($dispos_dates_raw as $row) {
$k = (string) $row['date_dispo']; // Y-m-d
$dispos_dates_by_day[$k][] = $row;
}
// Récupération des rendez-vous réservés (prochains 21 jours) — on ignore les ANNULÉS
$stmt = $pdo->prepare(
"SELECT date_rdv
FROM rendez_vous
WHERE praticien_id = ?
AND statut <> 'annulé'
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()
);
// Map canon EN -> clé i18n existante
$DAY_I18N = [
'monday' => 'jour_lundi',
'tuesday' => 'jour_mardi',
'wednesday' => 'jour_mercredi',
'thursday' => 'jour_jeudi',
'friday' => 'jour_vendredi',
'saturday' => 'jour_samedi',
'sunday' => 'jour_dimanche',
];
// Si ta DB ne stocke pas en EN (lundi/Понедельник), normalise vers EN :
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;
}
// Ici on stocke en SET pour fusionner sans doublons : [jourAffichage][slotKey] = DateTimeImmutable
$creneaux_par_jour = [];
$periode = new DatePeriod($now, new DateInterval('P1D'), $limit); // $limit exclusif
$step = new DateInterval("PT{$duree_minutes}M");
foreach ($periode as $jour) {
$dayKey = strtolower($jour->format('l')); // monday..sunday
$dateKey = $jour->format('Y-m-d');
// Libellé traduit pour l'affichage
$i18nKey = $DAY_I18N[$dayKey] ?? null;
$jour_nom = $i18nKey ? ($translations[$i18nKey] ?? ucfirst($dayKey)) : ucfirst($dayKey);
// clé colonne (inchangée)
$colKey = $jour_nom . '<br> ' . $jour->format('d/m');
// --- 1) Dispos HEBDO ---
foreach ($dispos as $dispo) {
$dispoKey = normalize_day_key($dispo['jour_semaine'] ?? '');
if ($dispoKey !== $dayKey) {
continue;
}
// Parse HH:MM:SS
[$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;
}
for ($t = $debut_creneau; $t->add($step) <= $fin_creneau; $t = $t->add($step)) {
if ($t <= $now) {
continue;
}
$slotKey = $t->format('Y-m-d H:i');
if (! in_array($slotKey, $reservations, true)) {
$creneaux_par_jour[$colKey][$slotKey] = $t; // SET
}
}
}
// --- 2) Dispos PONCTUELLES (par date) ---
if (! empty($dispos_dates_by_day[$dateKey])) {
foreach ($dispos_dates_by_day[$dateKey] as $row) {
// Parse HH:MM:SS
[$h1, $m1, $s1] = array_map('intval', explode(':', (string) $row['heure_debut']));
[$h2, $m2, $s2] = array_map('intval', explode(':', (string) $row['heure_fin']));
$debut_creneau = $jour->setTime($h1, $m1, $s1);
$fin_creneau = $jour->setTime($h2, $m2, $s2);
if ($fin_creneau <= $debut_creneau) {
continue;
}
for ($t = $debut_creneau; $t->add($step) <= $fin_creneau; $t = $t->add($step)) {
if ($t <= $now) {
continue;
}
$slotKey = $t->format('Y-m-d H:i');
if (! in_array($slotKey, $reservations, true)) {
$creneaux_par_jour[$colKey][$slotKey] = $t; // SET + fusion
}
}
}
}
}
// Convertir SET -> liste triée
foreach ($creneaux_par_jour as $k => $set) {
$slots = array_values($set);
usort($slots, fn(DateTimeImmutable $a, DateTimeImmutable $b) => $a->getTimestamp() <=> $b->getTimestamp());
$creneaux_par_jour[$k] = $slots;
}
// Pagination des colonnes
$all_keys = array_keys($creneaux_par_jour);
$total_pages = (int) ceil(count($all_keys) / $limit_columns);
$offset = ($page - 1) * $limit_columns;
$visible_keys = array_slice($all_keys, $offset, $limit_columns);
require_once '../includes/header.php';
$PAGE_TITLE = $translations['creneaux_disponibles_title'] ?? 'Créneaux disponibles';
$HEAD_EXTRA = <<<HTML
<script src="/js/sweetalert2@11.js"></script>
HTML;
?>
<div class="main1">
<div class="container py-5">
<div class="shadow p-4 rounded bg-light rdv-card">
<h3 class="rdv-subtitle"><?php echo htmlspecialchars(($praticien['prenom'] ?? '') . ' ' . ($praticien['nom'] ?? '')) ?></h3>
<?php if ($total_pages > 1): ?>
<div class="pagination">
<?php if ($page > 1): ?>
<a href="?praticien_id=<?php echo $praticien_id ?>&page=<?php echo $page - 1 ?>">&laquo; <?php echo $translations['pagination_precedent'] ?></a>
<?php endif; ?>
<?php if ($page < $total_pages): ?>
<a href="?praticien_id=<?php echo $praticien_id ?>&page=<?php echo $page + 1 ?>"><?php echo $translations['pagination_suivant'] ?> &raquo;</a>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if (empty($creneaux_par_jour)): ?>
<p class="rdv-empty"><?php echo $translations['aucun_creneau_disponible_3_semaines'] ?><br><br>
<a class="btn btn-outline-secondary btn-sm btn-table" href="/messages/start.php?praticien_id=<?php echo $praticien_id ?>">
<?php echo $translations['chat_praticien'] ?>
</a></p>
<?php else: ?>
<center><a class="btn btn-outline-secondary btn-sm btn-table" href="/messages/start.php?praticien_id=<?php echo $praticien_id ?>">
<?php echo $translations['chat_praticien'] ?>
</a></center>
<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'] ?? 'В кабинете';
?>
<div class="slot">
<div class="slot-time"><?php echo $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 htmlspecialchars($slotName) ?>" value="<?php echo htmlspecialchars($c_online) ?>" checked>
<?php echo htmlspecialchars($onlineLabel) ?>
</label>
<label>
<input type="radio" name="<?php echo htmlspecialchars($slotName) ?>" value="<?php echo htmlspecialchars($c_offline) ?>">
<?php echo htmlspecialchars($cabinetLabel) ?>
</label>
</div>
<?php else: ?>
<div class="mode-group my-2">
<span><?php echo htmlspecialchars($onlineLabel) ?></span>
</div>
<?php endif; ?>
<a href="/public/client/valider_rdv.php?praticien_id=<?php echo $praticien_id ?>&start=<?php echo urlencode($startIso) ?>"
class="reserver-btn"
data-mode-name="<?php echo htmlspecialchars($has_address ? $slotName : '') ?>"
data-default-mode="<?php echo htmlspecialchars($c_online) ?>"
data-nom="<?php echo htmlspecialchars($praticien['nom'] ?? '') ?>"
data-prenom="<?php echo htmlspecialchars($praticien['prenom'] ?? '') ?>"
data-start="<?php echo htmlspecialchars($startIso) ?>">
<?php echo $translations['btn_reserver'] ?>
</a>
</div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</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;
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']) ?>,
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']) ?>,
cancelButtonText: <?php echo json_encode($translations['btn_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>
</div>
<footer class="site-footer">
<div class="container">
<p><?php echo $translations['copyr'] ?></p>
</div>
</footer>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
<?php
require_once __DIR__ . '/../includes/init_client.php';
$client_id = $_SESSION['reference_id'];
$praticien_id = intval($_POST['praticien_id'] ?? 0);
if ($praticien_id > 0) {
$stmt = $pdo->prepare("DELETE FROM praticien_client WHERE client_id = ? AND praticien_id = ?");
$stmt->execute([$client_id, $praticien_id]);
echo $translations['favori_supprime_title'];
} else {
echo $translations['praticien_id_invalide'];
}
?>
+647
View File
@@ -0,0 +1,647 @@
<?php
require_once __DIR__ . '/../includes/init_client.php';
require_once __DIR__ . '/../../push/push_endpoint.php';
require_once __DIR__ . '/../../sms/notify_rdv.php';
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
/* -------- PHPMailer helper (timeouts ajoutés) -------- */
function send_rdv_email_with_ics_phpmailer(
string $toEmail, string $toName,
string $subject, string $textBody, string $htmlBody,
string $ical, string $filename
): bool {
global $MAILCFG; // vient de /includes/mailer.php
if (!filter_var($toEmail, FILTER_VALIDATE_EMAIL)) {
error_log('PHPMailer RDV: invalid recipient email: ' . var_export($toEmail, true));
return false;
}
if (empty($MAILCFG) || empty($MAILCFG['smtp']['host'])) {
error_log('PHPMailer RDV: MAILCFG missing/incomplete.');
return false;
}
$mail = new PHPMailer(true);
try {
$mail->isSMTP();
$mail->Host = $MAILCFG['smtp']['host'];
$mail->Port = (int)$MAILCFG['smtp']['port'];
$mail->SMTPSecure = $MAILCFG['smtp']['secure'] ?? PHPMailer::ENCRYPTION_STARTTLS;
$mail->SMTPAuth = (bool)$MAILCFG['smtp']['auth'];
$mail->Username = $MAILCFG['smtp']['username'] ?? '';
$mail->Password = $MAILCFG['smtp']['password'] ?? '';
// 🔒 robustesse
$mail->Timeout = 8; // seconds (SMTP)
$mail->SMTPKeepAlive = false;
$mail->SMTPAutoTLS = true;
$mail->SMTPDebug = 0;
$mail->CharSet = 'UTF-8';
$mail->Encoding = 'base64';
$mail->XMailer = '';
if (!empty($MAILCFG['bounce'])) $mail->Sender = $MAILCFG['bounce'];
$from = $MAILCFG['from'] ?? ['contact@uslugi.fr', 'USLUGI.FR'];
$rpto = $MAILCFG['reply_to'] ?? ['contact@uslugi.fr', 'USLUGI.FR'];
$mail->setFrom($from[0], $from[1] ?? '');
if (!empty($rpto[0])) $mail->addReplyTo($rpto[0], $rpto[1] ?? '');
$mail->addAddress($toEmail, $toName);
if (!empty($MAILCFG['suppress_auto'])) {
$mail->addCustomHeader('Auto-Submitted', 'auto-generated');
$mail->addCustomHeader('X-Auto-Response-Suppress', 'All');
}
$mail->isHTML(true);
$mail->Subject = $subject;
$mail->Body = $htmlBody;
$mail->AltBody = $textBody;
// 📎 Invitation iCalendar
$mail->addStringAttachment(
$ical,
$filename,
'base64',
'text/calendar; method=REQUEST; charset=UTF-8'
);
$mail->send();
return true;
} catch (Throwable $e) {
error_log('PHPMailer RDV send exception: '.$e->getMessage());
if (!empty($mail->ErrorInfo)) {
error_log('PHPMailer RDV ErrorInfo: '.$mail->ErrorInfo);
}
return false;
}
}
/* ================== Valeurs par défaut Online/Offline ================== */
if (!isset($c_online)) { $c_online = 'online'; }
if (!isset($c_offline)) { $c_offline = 'offline'; }
/* ================== CSRF pour l'appel AJAX ================== */
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(16));
}
$CSRF = $_SESSION['csrf_token'];
/* ================== Helpers UI (inchangés) ================== */
function ui_head(string $title): void { ?>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><?= htmlspecialchars($title) ?></title>
<style>
html,body{height:100%}
body{
margin:0; background:#8CA9FF;
font-family:'Montserrat',system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;
display:flex; align-items:center; justify-content:center;
padding:32px 16px;
}
.page-loader{ position:fixed; inset:0; z-index:9999; background:#8CA9FF; display:flex; align-items:center; justify-content:center; transition:opacity .25s ease, visibility .25s ease; }
.hidden{opacity:0; visibility:hidden;}
.loader-card{ background:#fff; color:#38378B; border-radius:22px; padding:20px 22px; box-shadow:0 12px 40px rgba(0,0,0,.08); display:flex; align-items:center; gap:14px; }
.loader-spinner{ width:36px; height:36px; border-radius:50%; border:3px solid rgba(56,55,139,.15); border-top-color:#38378B; animation:spin 1s linear infinite; }
@keyframes spin{ to{ transform:rotate(360deg);} }
.loader-text{ font-weight:700; }
.wrap{ width:min(500px, 100%); margin:0 auto; }
.msg{ background:#fff; color:rgb(33,37,41); border-radius:22px; box-shadow:0 12px 40px rgba(0,0,0,.08); padding:22px; text-align:center; }
.msg h2{ margin:0 0 10px; font-size:16px; font-weight:400; }
.msg p{ margin:10px 0; line-height:1.5; }
.msg .meta{ margin:12px auto 0; padding:12px 14px; border:1px solid #f11e1eff; border-radius:16px; background:#F7F8FF; color:#f11e1eff; font-weight:600; display:inline-block; }
.msg .actions{ margin-top:16px; display:flex; gap:10px; justify-content:center; flex-wrap:wrap; }
.card-notice{ background:#fff; color:#111; border-radius:28px; box-shadow:0 20px 60px rgba(0,0,0,.08); padding:24px 22px; text-align:left; }
.cn-title{ margin:0 0 6px; font-size:clamp(22px,4.8vw,32px); font-weight:800; }
.cn-date{ color:#60646c; font-size:clamp(14px,3.6vw,18px); margin-bottom:12px; }
.notice-list{ list-style:none; padding:0; margin:10px 0 0; display:grid; gap:14px; }
.notice-list li{ display:grid; grid-template-columns:36px 1fr; align-items:flex-start; gap:10px; }
.txt{ line-height:1.5; }
.txt-warn{ color:#E03C3F; font-weight:700; }
.ico{ width:32px; height:32px; border-radius:8px; display:inline-grid; place-items:center; margin-top:2px; background:#fde9ea; flex:0 0 32px; }
.ico svg{ width:24px; height:24px; fill:#E03C3F; }
.ico-dark{ background:#eef0f3; }
.ico-dark svg{ fill:#333; }
.ico-info{ background:#e8f0ff; }
.ico-info svg{ color:#2b64d8; }
.btn{ display:inline-block; text-decoration:none; font-weight:700; border-radius:999px; padding:12px 18px; }
.btn-primary{ background:#E03C3F; color:#fff; box-shadow:0 8px 22px rgba(0,0,0,.12); }
.btn-primary:hover{ filter:brightness(1.05); }
.btn-outline{ background:#fff; color:#38378B; border:1px solid #E0E3EB; }
.btn-outline:hover{ background:#f8faff; }
.msg--ok h2::before{ content:" "; }
.msg--err h2{ color:#666666; }
.msg--err h2::before{ content:"❌ "; }
</style>
</head>
<body>
<div class="page-loader" id="loader" role="status" aria-live="polite">
<div class="loader-card">
<div class="loader-spinner" aria-hidden="true"></div>
<div class="loader-text"><?= htmlspecialchars($GLOBALS['translations']['chargement_validation'] ?? 'Подождите, идёт подтверждение...') ?></div>
</div>
</div>
<div class="wrap" id="result"></div>
<?php }
function ui_foot(): void { ?>
</body></html>
<?php }
/* =================== Helpers e-mail (HTML/Texte) =================== */
function build_email_html(array $data, 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:#ffffff;border-radius:8px;overflow:hidden;">
<tr><td style="background:#38378B;color:#ffffff;padding:20px 24px;font-size:18px;font-weight:600;">'.$brand.'</td></tr>
<tr><td style="padding:24px 24px 8px 24px;color:#111111;font-size:16px;line-height:1.5;">'.nl2br(htmlspecialchars($data['hello_line'])).'</td></tr>
<tr><td style="padding:8px 24px;color:#111111;font-size:16px;line-height:1.6;">'.nl2br(htmlspecialchars($data['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($t['email_avec'] ?? 'Avec').'</td><td style="padding:14px 16px;font-size:14px;color:#111111;">'.htmlspecialchars($data['who']).'</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:#111111;">'.htmlspecialchars($data['when']).'</td></tr>'.
(!empty($data['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:#111111;">'.htmlspecialchars($data['format_line']).'</td></tr>' : '').
(!empty($data['address_line']) ? '<tr><td style="padding:14px 16px;font-size:14px;color:#6b7280;">'.htmlspecialchars($t['adresse_label_ru'] ?? 'Адрес').'</td><td style="padding:14px 16px;font-size:14px;color:#111111;">'.htmlspecialchars($data['address_line']).'</td></tr>' : '').
'</table></td></tr>'.
(!empty($data['cta_url']) ? '<tr><td align="center" style="padding:8px 24px 24px 24px;"><a href="'.htmlspecialchars($data['cta_url']).'" target="_blank" style="display:inline-block;text-decoration:none;padding:12px 20px;border-radius:999px;background:#E03C3F;color:#ffffff;font-weight:600;">'.htmlspecialchars($data['cta_label']).'</a></td></tr>' : '').
'<tr><td style="padding:8px 24px 24px 24px;color:#111111;font-size:16px;line-height:1.6;">'.nl2br(htmlspecialchars($data['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 build_email_text(array $data, array $t): string {
$lines = [];
$lines[] = $data['hello_line']; $lines[] = '';
$lines[] = $data['intro']; $lines[] = '';
$lines[] = ($t['email_avec'] ?? 'Avec').' '.$data['who'];
$lines[] = ($t['date_label'] ?? 'Date').' '.$data['when'];
if (!empty($data['format_line'])) $lines[] = ($t['format_label'] ?? 'Формат').': '.$data['format_line'];
if (!empty($data['address_line'])) $lines[] = ($t['adresse_label_ru'] ?? 'Адрес').': '.$data['address_line'];
if (!empty($data['cta_url'])) { $lines[] = ''; $lines[] = $data['cta_label'].': '.$data['cta_url']; }
$lines[] = ''; $lines[] = $data['footer'];
return implode("\n", $lines);
}
/* =================== Helpers ICS (inchangés) =================== */
function ics_escape(string $s): string {
$s = str_replace("\\", "\\\\", $s);
$s = str_replace(";", "\;", $s);
$s = str_replace(",", "\,", $s);
$s = preg_replace("/\r\n|\r|\n/", "\\n", $s);
return $s;
}
function build_ics_common_lines(
string $uid, DateTime $start, DateTime $end, string $timezone,
string $summary, string $description, string $location, array $attendees
): string {
$lines = [];
$lines[] = "BEGIN:VCALENDAR";
$lines[] = "VERSION:2.0";
$lines[] = "PRODID:-//УСЛУГИ.ФР//RDV//FR";
$lines[] = "CALSCALE:GREGORIAN";
$lines[] = "METHOD:REQUEST";
$lines[] = "BEGIN:VEVENT";
$lines[] = "UID:{$uid}";
$lines[] = "DTSTAMP:" . gmdate('Ymd\THis\Z');
$lines[] = "DTSTART;TZID={$timezone}:" . $start->format('Ymd\THis');
$lines[] = "DTEND;TZID={$timezone}:" . $end->format('Ymd\THis');
$lines[] = "SUMMARY:" . ics_escape($summary);
$lines[] = "DESCRIPTION:" . ics_escape($description);
$lines[] = "LOCATION:" . ics_escape($location);
$lines[] = "ORGANIZER;CN=УСЛУГИ.ФР:mailto:rdv@uslugi.fr";
foreach ($attendees as $a) {
if (empty($a['email'])) continue;
$cn = ics_escape($a['cn'] ?? '');
$em = $a['email'];
$lines[] = "ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;CN={$cn};RSVP=TRUE:mailto:{$em}";
}
$lines[] = "STATUS:CONFIRMED";
$lines[] = "END:VEVENT";
$lines[] = "END:VCALENDAR";
return implode("\r\n", $lines) . "\r\n";
}
function build_ics_client(array $rdv, DateTime $start, DateTime $end, string $timezone, string $location, string $ctaUrl, array $t, string $uid): string {
$summary = ($t['rdv_avec'] ?? 'Rendez-vous avec').' '.($t['titre_dr'] ?? 'Dr').' '.$rdv['prenom_praticien'].' '.$rdv['nom_praticien'];
$desc = ($t['votre_rdv_attente'] ?? 'Votre rendez-vous est prévu')."\n\n".
($t['email_avec'] ?? 'Avec').' '.($t['titre_dr'] ?? 'Dr').' '.$rdv['prenom_praticien'].' '.$rdv['nom_praticien']."\n".
'Email: '.$rdv['email_praticien']."\n".
(($rdv['tel_praticien'] ?? '') ? ('Téléphone: '.$rdv['tel_praticien']."\n") : '').
($ctaUrl ? ("\n".($t['consultez_espace_pour_details'] ?? 'Consultez votre espace pour les détails')."\n".$ctaUrl) : '');
$attendees = [
['cn' => $rdv['prenom_praticien'].' '.$rdv['nom_praticien'], 'email' => $rdv['email_praticien']],
['cn' => $rdv['prenom_client'].' '.$rdv['nom_client'], 'email' => $rdv['email_client']],
];
return build_ics_common_lines($uid, $start, $end, $timezone, $summary, $desc, $location, $attendees);
}
function build_ics_praticien(array $rdv, DateTime $start, DateTime $end, string $timezone, string $location, string $ctaUrl, array $t, string $uid): string {
$summary = ($t['rdv_avec'] ?? 'Rendez-vous avec').' '.$rdv['prenom_client'].' '.$rdv['nom_client'];
$desc = ($t['nouveau_rdv_pris_avec'] ?? 'Nouveau rendez-vous pris avec')."\n\n".
($t['client_label'] ?? 'Client').': '.$rdv['prenom_client'].' '.$rdv['nom_client']."\n".
'Email: '.$rdv['email_client']."\n".
(($rdv['tel_client'] ?? '') ? ('Téléphone: '.$rdv['tel_client']."\n") : '').
($ctaUrl ? ("\n".($t['consultez_espace_pour_details'] ?? 'Consultez votre espace pour les détails')."\n".$ctaUrl) : '');
$attendees = [
['cn' => $rdv['prenom_client'].' '.$rdv['nom_client'], 'email' => $rdv['email_client']],
['cn' => $rdv['prenom_praticien'].' '.$rdv['nom_praticien'], 'email' => $rdv['email_praticien']],
];
return build_ics_common_lines($uid, $start, $end, $timezone, $summary, $desc, $location, $attendees);
}
/* ================== Mode POST (AJAX) ================== */
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST' && ($_POST['action'] ?? '') === 'validate') {
header('Content-Type: application/json; charset=utf-8');
// CSRF
if (!hash_equals($_SESSION['csrf_token'] ?? '', $_POST['csrf'] ?? '')) {
echo json_encode(['ok'=>false, 'html'=>'<div class="msg msg--err"><h2>Сессия истекла</h2><p>Обновите страницу и попробуйте ещё раз.</p></div>']); exit;
}
$client_id = $_SESSION['reference_id'] ?? null;
$praticien_id = isset($_POST['praticien_id']) ? (int)$_POST['praticien_id'] : null;
$startIso = $_POST['start'] ?? null;
$mode_rdv_in = $_POST['mode_rdv'] ?? ($_GET['mode_rdv'] ?? null);
$mode_rdv = ($mode_rdv_in === $GLOBALS['c_offline']) ? $GLOBALS['c_offline'] : $GLOBALS['c_online'];
$planningUrl = '/public/client/reserver_rdv.php?praticien_id='.(int)($praticien_id ?? 0);
if (!$praticien_id || !$startIso || !$client_id) {
$html = '
<div class="msg msg--err">
<h2>'.htmlspecialchars($translations['donnees_manquantes_reservation'] ?? 'Données manquantes pour la réservation').'</h2>
<p>'.htmlspecialchars($translations['retour_fiche_praticien_hint'] ?? 'Merci de revenir sur la fiche du praticien et de choisir un créneau.').'</p>
<div class="actions">
<a class="btn btn-outline" href="'.htmlspecialchars($planningUrl).'">'.htmlspecialchars($translations['retour_a_la_fiche'] ?? 'Retour à la fiche').'</a>
</div>
</div>';
echo json_encode(['ok'=>false, 'html'=>$html]); exit;
}
try {
$date_rdv_sql = (new DateTime($startIso))->format('Y-m-d H:i:s');
// Créneau libre ? (libre si seulement "annulé")
$stmt = $pdo->prepare("
SELECT COUNT(*)
FROM rendez_vous
WHERE praticien_id = ?
AND date_rdv = ?
AND statut <> 'annulé'
");
$stmt->execute([$praticien_id, $date_rdv_sql]);
if ((int)$stmt->fetchColumn() > 0) {
$html = '
<div class="msg msg--err">
<h2>'.htmlspecialchars($translations['creneau_deja_reserve'] ?? 'Créneau déjà réservé').'</h2>
<p>'.htmlspecialchars($translations['choisir_autre_creneau'] ?? 'Veuillez choisir un autre créneau.').'</p>
<div class="actions">
<a class="btn btn-outline" href="'.htmlspecialchars($planningUrl).'">'.htmlspecialchars($translations['retour_au_planning'] ?? 'Retour au planning').'</a>
</div>
</div>';
echo json_encode(['ok'=>false, 'html'=>$html]); exit;
}
// Récupérer durée + adresse praticien
$stmt = $pdo->prepare("SELECT duree_rdv, adresse, ville, code_postal FROM praticien WHERE id = ?");
$stmt->execute([$praticien_id]);
$p = $stmt->fetch(PDO::FETCH_ASSOC);
$duree = (int)($p['duree_rdv'] ?? 60);
$adresse = trim((string)($p['adresse'] ?? ''));
$ville = trim((string)($p['ville'] ?? ''));
$code_postal = trim((string)($p['code_postal'] ?? ''));
$has_address = ($adresse !== '' && $ville !== '' && $code_postal !== '');
if ($mode_rdv === $c_offline && !$has_address) $mode_rdv = $c_online;
$addrSnapshot = $has_address ? "{$adresse}, {$code_postal} {$ville}" : '';
// Insert RDV
$uuid = bin2hex(random_bytes(16));
$lieu_insert = ($mode_rdv === $c_offline) ? 'offline' : 'online';
$stmt = $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())
");
$stmt->execute([$praticien_id, $client_id, $date_rdv_sql, $duree, $lieu_insert, $uuid]);
// Infos pour e-mails & ICS
$stmt = $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 = ?
");
$stmt->execute([$uuid]);
$rdv = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$rdv) { throw new RuntimeException('Rendez-vous introuvable après insertion.'); }
$rdvId = (int)$rdv['rdv_id'];
// =========================
// LOG RDV (rdv_actions_log) : create
// =========================
$after = json_encode([
'statut' => 'prévu',
'date_rdv' => $rdv['date_rdv'], // datetime SQL
'duree_minutes'=> (int)$rdv['duree_minutes'],
'lieu' => $rdv['lieu'], // online/offline (chez toi)
'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', ?, 'client', NULL, ?, NOW())
");
$logStmt->execute([$rdvId, (int)$client_id, $after]);
// =========================
// =========================
// PUSH RDV — création (client)
// =========================
try {
// ✅ Récupérer compte_id du praticien (pour compteur badge)
$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);
// ✅ Récupérer compte_id du client (utile plus tard pour d'autres events, pas obligatoire ici)
$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);
// ✅ Incrémenter le compteur RDV (badge) pour le praticien
if ($praticienCompteId > 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' => $praticienCompteId]);
}
// 🔔 Praticien : nouvelle demande
push_rdv_to_praticien(
$pdo,
(int)$praticien_id,
$translations['push_rdv_new_title'] ?? 'Новая запись',
$translations['push_rdv_new_body'] ?? 'У вас новая запись на приём.',
'/public/praticien/rendez_vous.php?id='.(int)$rdvId,
[
'rdv_id' => (int)$rdvId,
'action' => 'create',
'from_role' => 'client',
'client_id' => (int)$client_id,
'praticien_id' => (int)$praticien_id,
'date_rdv' => $rdv['date_rdv'],
]
);
// 🔔 Client : confirmation
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)$rdvId,
'action' => 'create'
]
);
} catch (Throwable $e) {
error_log('push rdv create failed: '.$e->getMessage());
}
$tz = 'Europe/Paris';
$startDT = new DateTime($rdv['date_rdv'], new DateTimeZone($tz));
$endDT = (clone $startDT)->modify("+{$duree} minutes");
$when = $startDT->format('d/m/Y H:i');
$when_a = $startDT->format('d/m/Y') . ' ' . ($translations['a'] ?? 'в') . ' ' . $startDT->format('H:i');
$ru_online = $translations['option_online'] ?? 'Онлайн';
$ru_offline = $translations['option_cabinet'] ?? 'В кабинете';
$is_offline = ($mode_rdv === $c_offline);
$mode_label_ru = $is_offline ? $ru_offline : $ru_online;
$location = $is_offline && $has_address ? $addrSnapshot : $ru_online;
$cta_client_url = 'https://uslugi.fr/public/client/mes_rendez_vous.php';
$cta_prat_url = "https://uslugi.fr/public/praticien/rendez_vous.php?id={$rdvId}";
// ======= (1) RÉPONSE IMMÉDIATE AU CLIENT (évite le 503) =======
$html = '
<div class="card-notice">
<h1 class="cn-title">'.($translations['block_1'] ?? 'Спасибо за запись').'</h1>
<div class="cn-date">'.htmlspecialchars($when_a).'</div>
<ul class="notice-list">
<li>
<span class="ico">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 22a2.5 2.5 0 0 0 2.45-2h-4.9A2.5 2.5 0 0 0 12 22Zm7-6V11a7 7 0 1 0-14 0v5l-2 2v1h18v-1l-2-2Z"/></svg>
</span>
<span class="txt"><strong class="txt-warn">'.htmlspecialchars($translations['block_2'] ?? 'Ожидает подтверждения').'</strong></span>
</li>
<li>
<span class="ico ico-dark">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 4h16v12H7l-3 3V4z"/></svg>
</span>
<span class="txt">'.($translations['block_3'] ?? 'Оплата производится напрямую со специалистом.').'</span>
</li>
<li>
<span class="ico ico-info">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<circle cx="12" cy="12" r="10" fill="currentColor"/>
<rect x="11" y="6" width="2" height="8" fill="#fff"/>
<rect x="11" y="16" width="2" height="2" fill="#fff"/>
</svg>
</span>
<span class="txt">'.($translations['block_4'] ?? 'Ссылка появится за 15 минут до начала.').'</span>
</li>
<li>
<span> </span>
<span class="txt"><a class="btn btn-primary" href="/public/client/mes_rendez_vous.php">'.
htmlspecialchars($translations['ecrire_au_specialiste'] ?? ($translations['voir_mes_rdv'] ?? 'Написать специалисту'))
.'</a></span>
</li>
</ul>
</div>';
// ⚠️ libérer la session, envoyer la réponse, puis continuer en arrière-plan
session_write_close();
echo json_encode(['ok'=>true, 'html'=>$html]);
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request(); // le client & Varnish ont reçu la réponse => pas de 503
}
ignore_user_abort(true);
// ======= (2) TÂCHES ARRIÈRE-PLAN : e-mails + WhatsApp =======
try {
// ICS
$ical_client = build_ics_client($rdv, $startDT, $endDT, $tz, $location, $cta_client_url, $translations, $uuid);
$ical_prat = build_ics_praticien($rdv, $startDT, $endDT, $tz, $location, $cta_prat_url, $translations, $uuid);
$whoClientSees = ($translations['titre_dr'] ?? 'Dr').' '.$rdv['prenom_praticien'].' '.$rdv['nom_praticien'];
$whoPratSees = ($translations['client_label'] ?? 'Client').' '.$rdv['prenom_client'].' '.$rdv['nom_client'];
$cta_label = $translations['consultez_espace_pour_details'] ?? 'Voir les détails';
// Client
$subject_c = $translations['email_confirmation_rdv_sujet'] ?? 'Confirmation de rendez-vous';
$data_c = [
'hello_line' => sprintf($translations['mail_valid_bonjour_nom'] ?? 'Bonjour %s', $rdv['prenom_client'].' '.$rdv['nom_client']),
'intro' => ($translations['votre_rdv_attente'] ?? 'Votre rendez-vous est en attente de confirmation').' '.
($translations['email_avec'] ?? 'avec').' '.$whoClientSees.".\n".
($translations['veuillez_trouver_invitation'] ?? "Veuillez trouver l'invitation en pièce jointe."),
'who' => $whoClientSees,
'when' => $when_a,
'format_line' => $mode_label_ru,
'address_line' => ($is_offline && $has_address) ? $addrSnapshot : '',
'cta_url' => $cta_client_url,
'cta_label' => $cta_label,
'footer' => $translations['merci_simple'] ?? 'Merci.',
];
$ok_c = send_rdv_email_with_ics_phpmailer(
$rdv['email_client'], $rdv['prenom_client'].' '.$rdv['nom_client'],
$subject_c,
build_email_text($data_c, $translations),
build_email_html($data_c, $translations),
$ical_client, "rdv_client_{$uuid}.ics"
);
// Praticien
$subject_p = ($translations['nouveau_rdv_avec'] ?? 'Nouveau rendez-vous avec').' '.$rdv['prenom_client'].' '.$rdv['nom_client'];
$data_p = [
'hello_line' => ($translations['email_bonjour_dr'] ?? 'Bonjour Dr').' '.$rdv['prenom_praticien'].' '.$rdv['nom_praticien'],
'intro' => ($translations['nouveau_rdv_pris_avec'] ?? 'Un nouveau rendez-vous a été pris avec vous.'),
'who' => $whoPratSees,
'when' => $when_a,
'format_line' => $mode_label_ru,
'address_line' => ($is_offline && $has_address) ? $addrSnapshot : '',
'cta_url' => $cta_prat_url,
'cta_label' => $cta_label,
'footer' => $translations['merci_simple'] ?? 'Merci.',
];
$ok_p = send_rdv_email_with_ics_phpmailer(
$rdv['email_praticien'], $rdv['prenom_praticien'].' '.$rdv['nom_praticien'],
$subject_p,
build_email_text($data_p, $translations),
build_email_html($data_p, $translations),
$ical_prat, "rdv_praticien_{$uuid}.ics"
);
if (!$ok_c || !$ok_p) {
error_log('RDV: un ou plusieurs e-mails ICS n’ont pas été envoyés.');
}
} catch (Throwable $e) {
error_log('RDV: background email error: '.$e->getMessage());
}
// WhatsApp (ne doit JAMAIS bloquer)
try {
$waTo = $rdv['tel_praticien'] ?? '';
$clientFullName = trim(($rdv['prenom_client'] ?? '') . ' ' . ($rdv['nom_client'] ?? ''));
$dateHeure = $when;
if ($waTo && $clientFullName && $dateHeure && $rdvId) {
// Si votre lib permet un timeout, utilisez-le. Sinon, laissez tel quel.
$waRes = Sms\notify_rdv_en_attente($waTo, $clientFullName, $dateHeure, $rdvId /*, ['timeout'=>5] */);
if ((int)($waRes['http_code'] ?? 0) !== 200) {
error_log('WA en_attente non-200: ' . json_encode($waRes, JSON_UNESCAPED_UNICODE));
}
} else {
error_log('WA en_attente: données incomplètes (tel praticien / nom client / dateHeure / rdvId).');
}
} catch (Throwable $e) {
error_log('WA en_attente_confirmation exception: ' . $e->getMessage());
}
// Fin du background
exit;
} catch (Throwable $e) {
$html = '
<div class="msg msg--err">
<h2>'.htmlspecialchars($translations['erreur_prefixe'] ?? 'Erreur').'</h2>
<p>'.htmlspecialchars($e->getMessage()).'</p>
<div class="actions">
<a class="btn btn-outline" href="'.htmlspecialchars('/public/client/reserver_rdv.php?praticien_id='.(int)($praticien_id ?? 0)).'">'.htmlspecialchars($translations['retour_a_la_fiche'] ?? 'Retour à la fiche').'</a>
</div>
</div>';
echo json_encode(['ok'=>false, 'html'=>$html]); exit;
}
}
/* ================== Mode GET : UI + loader + JS ================== */
$praticien_id = isset($_GET['praticien_id']) ? (int)$_GET['praticien_id'] : null;
$startIso = $_GET['start'] ?? null;
$modeGet = $_GET['mode_rdv'] ?? null;
ui_head($translations['validation_rdv_title'] ?? 'Validation du rendez-vous');
?>
<script>
(function(){
const loader = document.getElementById('loader');
const result = document.getElementById('result');
const praticien_id = <?= json_encode($praticien_id) ?>;
const start = <?= json_encode($startIso) ?>;
const csrf = <?= json_encode($CSRF) ?>;
const mode_rdv = <?= json_encode($modeGet) ?>;
if(!praticien_id || !start){
loader.classList.add('hidden');
const backUrl = praticien_id ? `/public/client/reserver_rdv.php?praticien_id=${praticien_id}` : `/public/client/reserver_rdv.php`;
result.innerHTML = `
<div class="msg msg--err">
<h2><?= htmlspecialchars($translations['donnees_manquantes_reservation'] ?? 'Données manquantes pour la réservation') ?></h2>
<p><?= htmlspecialchars($translations['retour_fiche_praticien_hint'] ?? 'Merci de revenir sur la fiche du praticien et de choisir un créneau.') ?></p>
<div class="actions">
<a class="btn btn-outline" href="${backUrl}"><?= htmlspecialchars($translations['retour_a_la_fiche'] ?? 'Retour à la fiche') ?></a>
</div>
</div>`;
return;
}
const body = new URLSearchParams({ action: 'validate', csrf, praticien_id, start });
if (mode_rdv) body.set('mode_rdv', mode_rdv);
fetch(location.pathname, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body
})
.then(r => r.json())
.then(data => {
result.innerHTML = data.html || '<div class="msg msg--err"><h2>Неизвестная ошибка</h2></div>';
})
.catch(() => {
result.innerHTML = '<div class="msg msg--err"><h2>Сетевая ошибка</h2><p>Попробуйте повторно.</p></div>';
})
.finally(() => {
loader.classList.add('hidden');
});
})();
</script>
<?php ui_foot(); ?>
+321
View File
@@ -0,0 +1,321 @@
<?php
// /public/contact_admin.php (SAFE to include in header)
// - Si appelé directement par URL : répond aux GET ?meta=1 et POST (JSON) et exit.
// - Si inclus (ex. dans header.php) : n'injecte le JS que pour des pages HTML GET,
// ne touche jamais au Content-Type ni ne fait exit.
declare(strict_types=1);
$IS_DIRECT = isset($_SERVER['SCRIPT_FILENAME'])
&& realpath(__FILE__) === realpath($_SERVER['SCRIPT_FILENAME']);
require_once __DIR__ . '/../../includes/bootstrap_global.php';
/* ======================= Helpers ======================= */
$h = static fn($s)=>htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');
$ipToBinary = static function (?string $ip): ?string {
if (!$ip) return null;
$bin = @inet_pton($ip);
return $bin === false ? null : $bin;
};
$nz = static function($a, $b) {
$a = trim((string)($a ?? ''));
$b = trim((string)($b ?? ''));
return $a !== '' ? $a : $b;
};
/* ======================= CSRF ======================= */
if (empty($_SESSION['pub_csrf'])) $_SESSION['pub_csrf'] = bin2hex(random_bytes(32));
$CSRF = $_SESSION['pub_csrf'];
/* ======================= Auth / Roles ======================= */
$ALLOWED_ROLES = ['client','praticien','admin', 'secretaire', 'admin-praticien'];
$isLogged = !empty($_SESSION['compte_id']);
$user = ['compte_id'=>null,'role'=>null,'ref_id'=>null,'email'=>null];
if ($isLogged) {
try {
$st = $pdo->prepare("SELECT id, email, role, reference_id FROM compte WHERE id=? LIMIT 1");
$st->execute([(int)$_SESSION['compte_id']]);
if ($row = $st->fetch(PDO::FETCH_ASSOC)) {
$user = [
'compte_id' => (int)$row['id'],
'role' => (string)$row['role'],
'ref_id' => (int)$row['reference_id'],
'email' => (string)$row['email'],
];
}
} catch(Throwable $e) {}
}
$roleAllowed = $isLogged && in_array((string)$user['role'], $ALLOWED_ROLES, true);
/* ======================= DB Utils ======================= */
$tableExists = static function(PDO $pdo, string $t): bool {
$st = $pdo->prepare("SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ?");
$st->execute([$t]);
return (bool)$st->fetchColumn();
};
/* ======================= DIRECT: POST handler ======================= */
if ($IS_DIRECT && $_SERVER['REQUEST_METHOD'] === 'POST') {
header('Content-Type: application/json; charset=utf-8');
$out = ['ok'=>false,'msg'=>''];
try {
if (!$roleAllowed) throw new Exception('FORBIDDEN');
// anti-spam 10s
$last = (int)($_SESSION['last_contact_at'] ?? 0);
if (time() - $last < 10) throw new Exception('TOO_MANY');
// CSRF
if (!hash_equals($CSRF, $_POST['csrf'] ?? '')) throw new Exception('CSRF');
// Fallback intelligent: POST sinon PREF (mais pas vide)
$nom = $nz($_POST['nom'] ?? null, $_SESSION['_caf_nom'] ?? '');
$prenom = $nz($_POST['prenom'] ?? null, $_SESSION['_caf_prenom'] ?? '');
$email = $nz($_POST['email'] ?? null, $_SESSION['_caf_email'] ?? '');
$sujet = trim((string)($_POST['sujet'] ?? ''));
$msg = trim((string)($_POST['message'] ?? ''));
if ($nom==='' || $prenom==='' || $sujet==='' || $msg==='') throw new Exception('REQUIRED');
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) throw new Exception('EMAIL');
if (!$tableExists($pdo, 'contact_messages')) throw new Exception('TABLE_MISSING');
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255);
$st = $pdo->prepare(
"INSERT INTO contact_messages
(user_type,user_id,nom,prenom,email,sujet,message,statut,locale,ip,user_agent,created_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,NOW())"
);
$userType = in_array($user['role'], ['client','praticien'], true) ? $user['role'] : 'invite';
$st->execute([
$userType, $user['ref_id'] ?: null, $nom, $prenom, $email, $sujet, $msg,
'nouveau', 'ru', $ipToBinary($ip), $ua
]);
$_SESSION['last_contact_at'] = time();
$out['ok'] = true;
$out['msg'] = $translations['contact_admin_sent_success'];
} catch (Throwable $ex) {
$code = $ex->getMessage();
if ($code==='FORBIDDEN') $out['msg'] = $translations['contact_admin_forbidden'];
elseif ($code==='TOO_MANY') $out['msg'] = $translations['contact_admin_too_many'];
elseif ($code==='CSRF') $out['msg'] = $translations['contact_admin_csrf'];
elseif ($code==='REQUIRED') $out['msg'] = $translations['contact_admin_required_all'];
elseif ($code==='EMAIL') $out['msg'] = $translations['contact_admin_invalid_email'];
elseif ($code==='TABLE_MISSING') $out['msg'] = $translations['contact_admin_table_missing'];
else $out['msg'] = $translations['contact_admin_server_error'];
}
echo json_encode($out, JSON_UNESCAPED_UNICODE);
exit;
}
/* ======================= DIRECT: GET ?meta=1 ======================= */
if ($IS_DIRECT && isset($_GET['meta']) && $_GET['meta'] == '1') {
header('Content-Type: application/json; charset=utf-8');
// Empêcher tout cache (SW, proxy, navigateur)
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
header('Expires: 0');
header('Vary: Cookie');
if (!$roleAllowed) {
echo json_encode(['ok'=>false,'msg'=>$translations['contact_admin_forbidden']], JSON_UNESCAPED_UNICODE);
exit;
}
// Token **frais** à chaque ouverture
$_SESSION['pub_csrf'] = bin2hex(random_bytes(32));
$CSRF = $_SESSION['pub_csrf'];
// Préremplissage léger stocké en session pour fallback POST
$pref = ['nom'=>'','prenom'=>'','email'=>''];
try {
if ($user['role'] === 'client') {
$st = $pdo->prepare("SELECT prenom, nom, email FROM client WHERE id=? LIMIT 1");
$st->execute([$user['ref_id']]);
if ($r = $st->fetch(PDO::FETCH_ASSOC)) { $pref['prenom']=$r['prenom']??''; $pref['nom']=$r['nom']??''; }
$pref['email'] = $user['email'] ?? '';
} elseif (strpos($user['role'], 'praticien') !== false) {
$st = $pdo->prepare("SELECT prenom, nom, email FROM praticien WHERE id=? LIMIT 1");
$st->execute([$user['ref_id']]);
if ($r = $st->fetch(PDO::FETCH_ASSOC)) { $pref['prenom']=$r['prenom']??''; $pref['nom']=$r['nom']??''; }
$pref['email'] = $user['email'] ?? '';
} else {
$pref['email'] = $user['email'] ?? '';
}
} catch(Throwable $e) {}
$_SESSION['_caf_nom'] = $pref['nom'];
$_SESSION['_caf_prenom'] = $pref['prenom'];
$_SESSION['_caf_email'] = $pref['email'];
echo json_encode([
'ok' => true,
'title' => $translations['contact_admin_title'],
'labels' => [
'firstname' => $translations['contact_admin_firstname'],
'lastname' => $translations['contact_admin_lastname'],
'email' => $translations['contact_admin_email'],
'subject' => $translations['contact_admin_subject'],
'message' => $translations['contact_admin_message'],
'send' => $translations['contact_admin_send'],
'required' => $translations['contact_admin_required_mandatory'],
'invalid' => $translations['contact_admin_invalid_email']
],
'prefill' => $pref,
'csrf' => $CSRF,
'endpoint' => '/public/contact_admin.php'
], JSON_UNESCAPED_UNICODE);
exit;
}
/* ======================= INCLUDED: inject widget only on HTML GET ======================= */
$accept = (string)($_SERVER['HTTP_ACCEPT'] ?? '');
$isHtmlLike = stripos($accept, 'text/html') !== false || empty($accept);
$isGet = ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'GET';
if (!$IS_DIRECT && $isGet && $isHtmlLike) {
?>
<script>
// charge SweetAlert2 si absent
(function(){
if (!window.Swal) {
var s = document.createElement('script');
s.src = "https://cdn.jsdelivr.net/npm/sweetalert2@11";
s.defer = true;
document.head.appendChild(s);
}
})();
// inject style une seule fois
(function(){
if(!document.getElementById('caf-style')){
const css = `
.swal2-popup{border-radius:18px;padding:0}
.swal2-title{font-size:1.15rem;font-weight:600;margin:0;padding:18px 22px;color:#111;border-bottom:1px solid #eee}
.caf-body{padding:18px 22px}
.caf-grid{display:grid;gap:10px}
.caf-row{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.caf-input,.caf-textarea{
width:100%;border:1px solid #e5e7eb;border-radius:12px;padding:10px 12px;font-size:14px;outline:none;
}
.caf-input:focus,.caf-textarea:focus{border-color:#38378B;box-shadow:0 0 0 3px rgba(56,55,139,.12)}
.caf-footer{display:flex;gap:10px;justify-content:flex-end;padding:14px 18px;border-top:1px solid #eee}
.btn-primary{
background:#38378B;color:#fff;border:none;border-radius:999px;padding:10px 14px;font-weight:600;cursor:pointer
}
.btn-secondary{
background:#f3f4f6;color:#111;border:none;border-radius:999px;padding:10px 14px;cursor:pointer
}`;
const s=document.createElement('style'); s.id='caf-style'; s.textContent=css; document.head.appendChild(s);
}
})();
// bouton #open-contact optionnel : si présent on ouvre, sinon tu peux appeler window.openContactAdmin() toi-même
document.addEventListener('DOMContentLoaded', () => {
const btn = document.getElementById('open-contact');
if (btn) {
btn.addEventListener('click', function(e){
e.preventDefault(); window.openContactAdmin && window.openContactAdmin();
});
}
});
window.openContactAdmin = async function(){
try{
const res = await fetch('/public/contact_admin.php?meta=1', { credentials: 'same-origin', cache: 'no-store' });
const meta = await res.json();
if (!meta.ok) { return Swal.fire({icon:'error', text: meta.msg || <?= json_encode($translations['contact_admin_js_error'], JSON_UNESCAPED_UNICODE) ?>}); }
const L = meta.labels;
const hasIdentity = (meta.prefill?.prenom||'').trim() && (meta.prefill?.nom||'').trim() && (meta.prefill?.email||'').trim();
const identityBlock = hasIdentity
? `<div class="caf-grid" style="margin-bottom:8px">
<div class="caf-row">
<input id="caf-pre" class="caf-input" value="${meta.prefill.prenom||''}" readonly>
<input id="caf-nom" class="caf-input" value="${meta.prefill.nom||''}" readonly>
</div>
<input id="caf-mail" class="caf-input" value="${meta.prefill.email||''}" readonly>
</div>`
: `<div class="caf-grid" style="margin-bottom:8px">
<div class="caf-row">
<input id="caf-pre" class="caf-input" placeholder="${L.firstname}">
<input id="caf-nom" class="caf-input" placeholder="${L.lastname}">
</div>
<input id="caf-mail" class="caf-input" placeholder="${L.email}" type="email">
</div>`;
// attendre Swal si chargé dynamiquement
if (!window.Swal) await new Promise(r => setTimeout(r, 50));
await Swal.fire({
title: meta.title,
html: `<div class="caf-body">
${identityBlock}
<div class="caf-grid">
<input id="caf-subj" class="caf-input" placeholder="${L.subject}">
<textarea id="caf-msg" class="caf-textarea" rows="5" placeholder="${L.message}"></textarea>
</div>
</div>`,
showConfirmButton: false,
showCancelButton: false,
didOpen: () => {
const footer = document.createElement('div');
footer.className = 'caf-footer';
footer.innerHTML = `
<button type="button" class="btn-secondary" id="caf-cancel"><?= $h($translations['contact_admin_close']) ?></button>
<button type="button" class="btn-primary" id="caf-send">${L.send}</button>`;
document.querySelector('.swal2-popup').appendChild(footer);
document.getElementById('caf-cancel').onclick = () => Swal.close();
document.getElementById('caf-send').onclick = async () => {
const prenom = document.getElementById('caf-pre').value.trim();
const nom = document.getElementById('caf-nom').value.trim();
const email = document.getElementById('caf-mail').value.trim();
const sujet = document.getElementById('caf-subj').value.trim();
const message = document.getElementById('caf-msg').value.trim();
if (!prenom || !nom || !email || !sujet || !message) {
return Swal.showValidationMessage(L.required);
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return Swal.showValidationMessage(L.invalid);
}
Swal.showLoading();
const fd = new FormData();
fd.set('csrf', meta.csrf);
fd.set('prenom', prenom);
fd.set('nom', nom);
fd.set('email', email);
fd.set('sujet', sujet);
fd.set('message', message);
const resp = await fetch(meta.endpoint, {
method:'POST',
headers:{'X-Requested-With':'XMLHttpRequest'},
body: fd,
credentials:'same-origin'
});
const j = await resp.json();
if (j.ok) Swal.fire({ icon:'success', text: j.msg });
else Swal.fire({ icon:'error', text: j.msg || <?= json_encode($translations['contact_admin_js_error'], JSON_UNESCAPED_UNICODE) ?> });
};
}
});
}catch(e){
console.error(e);
alert(<?= json_encode($translations['contact_admin_network_error'], JSON_UNESCAPED_UNICODE) ?>);
}
};
</script>
<?php
}
// si inclus dans un contexte non-HTML (JSON/API), ne rien envoyer.
+8
View File
@@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../includes/bootstrap_global.php';
require_once __DIR__ . '/includes/securite.php';
// Déconnexion propre + redirection
logout_and_redirect_to_login('/');
+12
View File
@@ -0,0 +1,12 @@
# Interdit l'accès HTTP aux fichiers sensibles
<FilesMatch "\.(php|inc|phtml|phar|env|ini|json|ya?ml|sql|bak|zip)$">
Require all denied
</FilesMatch>
# Interdit l'accès aux fichiers cachés (.env, .git, etc.)
<FilesMatch "^\.">
Require all denied
</FilesMatch>
# Pas de listing de répertoire
Options -Indexes
+58
View File
@@ -0,0 +1,58 @@
<?php
// Si quelqu'un essaie d'appeler directement /includes/db.php dans le navigateur, on coupe
if (isset($_SERVER['SCRIPT_FILENAME']) && realpath(__FILE__) === realpath($_SERVER['SCRIPT_FILENAME'])) {
http_response_code(403);
exit('
<!doctype html>
<html lang="ru"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>403 — Accès refusé</title>
<style>
html,body{height:100%;margin:0}
body{display:flex;align-items:center;justify-content:center;background:#f7f7f9;font-family:system-ui,Segoe UI,Roboto,Arial}
.card{max-width:560px;width:92%;background:#fff;border:1px solid #e5e7eb;border-radius:14px;box-shadow:0 8px 24px rgba(0,0,0,.08);padding:28px;text-align:center}
.emoji{font-size:48px;margin-bottom:8px}
h1{margin:6px 0 10px;font-size:22px}
p{margin:0 0 18px;color:#6b7280}
.row{display:flex;gap:10px;justify-content:center;flex-wrap:wrap}
.btn{padding:10px 14px;border-radius:999px;text-decoration:none;display:inline-block}
.btn-primary{background:#E03C3F;color:#fff}
.btn-outline{border:1px solid #d1d5db;color:#fff;background:#38378B}
</style>
</head><body>
<div class="card">
<div class="emoji"></div>
<h1>Accès refusé</h1>
<p>Vous n’êtes pas autorisé à consulter cette page.</p>
<div class="row">
<a class="btn btn-primary" href="/connexion.php">Se connecter</a>
<a class="btn btn-outline" href="/">Retour à l’accueil</a>
</div>
</div>
</body></html>');
}
$host = getenv('APP_DB_HOST') ?: 'localhost';
$dbname = getenv('APP_DB_NAME') ?: '';
$user = getenv('APP_DB_USER') ?: '';
$password = getenv('APP_DB_PASS') ?: '';
$charset = getenv('APP_DB_CHARSET') ?: 'utf8mb4';
try {
$pdo = new PDO(
"mysql:host=$host;dbname=$dbname;charset=$charset",
$user,
$password,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
} catch (PDOException $e) {
error_log('DB connect error: ' . $e->getMessage());
http_response_code(500);
exit('Erreur de connexion à la base de données public.');
}
?>
+616
View File
@@ -0,0 +1,616 @@
<?php
$lang = $_SESSION['lang'] ?? 'fr';
$PAGE_TITLE = $PAGE_TITLE ?? ($translations['site_name'] ?? 'uslugi.fr');
$HEAD_EXTRA = $HEAD_EXTRA ?? '';
$initiales = $initiales ?? ($GLOBALS['initiales'] ?? 'U');
$avatarSvg = $avatar_svg ?? ($GLOBALS['avatar_svg'] ?? null);
$pushCfg = $pushCfg ?? ($GLOBALS['pushCfg'] ?? []);
$alerte_abonnement = $alerte_abonnement ?? ($GLOBALS['alerte_abonnement'] ?? null);
require_once __DIR__ . '/../contact_admin.php';
// =====================================================
// [RETOUR / REFERER SÉCURISÉ]
// - Autorise le retour seulement si le referer est sur le même host
// =====================================================
$ref = $_SERVER['HTTP_REFERER'] ?? '';
$host = $_SERVER['HTTP_HOST'] ?? '';
$safeRef = '';
if ($ref) {
$u = parse_url($ref);
if (!empty($u['host']) && $u['host'] === $host) {
$safeRef = $ref;
}
}
$fallbackBack = $safeRef ?: '/';
// =====================================================
// [DETECTION MOBILE]
// - Très simple détection UA pour le logo adaptatif
// =====================================================
$is_mobile = preg_match('/Mobile|Android|iPhone|iPad/i', $_SERVER['HTTP_USER_AGENT']);
?>
<!doctype html>
<html lang="fr">
<head>
<title><?= htmlspecialchars($PAGE_TITLE, ENT_QUOTES, 'UTF-8') ?></title>
<?= $HEAD_EXTRA ?>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<!-- Police Montserrat -->
<!-- Bootstrap -->
<link href="/css/bootstrap.min.css" rel="stylesheet">
<script defer src="/js/bootstrap.bundle.min.js"></script>
<!-- Styles personnalisés -->
<link rel="stylesheet" href="<?= asset('/includes/site_style.css') ?>">
<link rel="stylesheet" href="<?= asset('/includes/custom_style.css') ?>"> <!-- ta home -->
<link rel="stylesheet" href="<?= asset('/includes/mobil_style.css') ?>">
</head>
<body>
<?php
// Pour n'afficher qu'une fois par compte (PWA)
$pushOnboardKey = 'uslugi_push_onboard_v1_' . (int)($_SESSION['compte_id'] ?? 0);
// helper traduction (si tu n'as pas déjà $t ici)
$t = function(string $k, string $fallback = '') use (&$translations): string {
$val = $translations[$k] ?? null;
return ($val === null || $val === '') ? $fallback : (string)$val;
};
?>
<div class="modal fade" id="pushOnboardModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<?= htmlspecialchars($t('push_onboard_title', 'Activer les notifications ?'), ENT_QUOTES, 'UTF-8') ?>
</h5>
<button type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="<?= htmlspecialchars($t('push_onboard_close_aria', 'Fermer'), ENT_QUOTES, 'UTF-8') ?>">
</button>
</div>
<div class="modal-body">
<?= htmlspecialchars($t('push_onboard_body', 'Vous recevrez des alertes pour les nouveaux messages et rendez-vous.'), ENT_QUOTES, 'UTF-8') ?>
</div>
<div class="modal-footer">
<button type="button"
class="btn btn-outline-secondary"
data-bs-dismiss="modal"
id="pushLaterBtn">
<?= htmlspecialchars($t('push_onboard_later', 'Plus tard'), ENT_QUOTES, 'UTF-8') ?>
</button>
<button type="button"
class="btn btn-primary"
id="pushEnableBtn">
<?= htmlspecialchars($t('push_onboard_allow', 'Autoriser'), ENT_QUOTES, 'UTF-8') ?>
</button>
</div>
</div>
</div>
</div>
<!-- ================================================
[HEADER / BARRE DE NAVIGATION]
- Bouton retour sécurisé vers $fallbackBack
- Logo adaptatif mobile/desktop
- Icône messages avec badge non lus
- Menu principal + sous-menu Admin
- Menu "?" compact (aide)
================================================ -->
<header class="site-header py-2 border-bottom">
<?php if (!empty($_SESSION['admin_impersonate'])): ?>
<div class="alert alert-warning text-center mb-0 rounded-0">
Вы вошли как другой пользователь.
<a href="/admin/tools/retour_admin.php"
class="btn btn-sm btn-danger ms-2"
style="border-radius:999px;">
Вернуться в режим администратора
</a>
</div>
<?php endif; ?>
<?php $alerte_abonnement = $GLOBALS['alerte_abonnement'] ?? null; ?>
<?php if (!empty($alerte_abonnement)): ?>
<div class="alert alert-warning m-0 py-1 px-2 rounded"
role="alert"
style="background:#F1F1F1; color:#E03C3F; font-size:14px; text-align:center;">
<?= htmlspecialchars((string)$alerte_abonnement, ENT_QUOTES, 'UTF-8') ?>
</div>
<?php endif; ?>
<div class="container d-flex justify-content-between align-items-center">
<!-- Marque + Bouton retour + Logo -->
<div class="header-left">
<div class="brand">
<!-- Bouton retour -->
<a
href="<?= htmlspecialchars($fallbackBack, ENT_QUOTES, 'UTF-8') ?>"
id="btnBack"
class="btn btn-light btn-circle me-2"
style="border-radius:5px; box-shadow:0 1px 2px rgba(0,0,0,.08);"
aria-label="<?= htmlspecialchars($translations['retour'] ?? 'Retour', ENT_QUOTES, 'UTF-8') ?>"
>
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" aria-hidden="true">
<path
d="M15 6l-6 6 6 6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</a>
<!-- Logo -->
<a href="/index.php">
<img src="/public/uploads/img/logo_u.png"
alt="uslugi.fr"
class="logo-mobile">
<img src="/public/uploads/img/logo.png"
alt="uslugi.fr"
class="logo-desktop">
</a>
</div>
<div class="lang-switch">
<?php require __DIR__ . '/../../includes/menu_langue.php'; ?>
</div>
</div>
<!-- Actions header (avatar, messages, menu, aide) -->
<nav class="header-actions d-flex align-items-center gap-2 position-relative">
<!-- RDV (+ badge non lus) -->
<a
id="btn-rdv"
href="<?= ($_SESSION['role'] ?? '') === 'praticien'
? '/public/praticien/rendez_vous.php'
: '/public/client/mes_rendez_vous.php' ?>"
class="nav-rdv position-relative"
>
<img src="/public/uploads/img/bell-on.png" width="22" alt="Rendez-vous">
<span
id="rdv-badge"
style="position:absolute; top:-6px; right:-8px; background:#e11d48; color:#fff;
font-size:12px; min-width:18px; height:18px; line-height:18px;
text-align:center; border-radius:999px; padding:0 5px; display:none;"
>0</span>
</a>
<!-- Messages (+ badge non lus) -->
<a href="/messages/" class="nav-msg position-relative">
<img src="/public/uploads/img/message.png" width="20" alt="Messages">
<span
id="msg-badge"
style="position:absolute; top:-6px; right:-8px; background:#e11d48; color:#fff;
font-size:12px; min-width:18px; height:18px; line-height:18px;
text-align:center; border-radius:999px; padding:0 5px; display:none;"
>0</span>
</a>
<!-- Menu principal -->
<div class="menu-wrap">
<button type="button" id="btnMenu">
<div class="avatar"><?= $initiales ?></div>
</button>
<!-- Menu principal (liste) -->
<ul class="dropdown-menu-custom menu-panel" id="menu">
<?php if ($_SESSION['role'] === 'client'): ?>
<li onclick="location.href='/public/client/mes_rendez_vous.php'">
<?= htmlspecialchars($translations['menu_mes_rendez_vous']) ?>
</li>
<li onclick="location.href='/public/client/list_praticien.php'">
<?= htmlspecialchars($translations['menu_liste_specialistes']) ?>
</li>
<li onclick="location.href='/public/client/mes_favori.php'">
<?= htmlspecialchars($translations['menu_mes_favoris']) ?>
</li>
<?php else: ?>
<li onclick="location.href='/public/praticien/rendez_vous.php'">
<?= htmlspecialchars($translations['menu_mes_rendez_vous']) ?>
</li>
<li onclick="location.href='/public/praticien/agenda.php'">
<?= htmlspecialchars($translations['menu_mon_agenda']) ?>
</li>
<li onclick="location.href='/public/praticien/disponibilites.php'">
<?= htmlspecialchars($translations['menu_mes_disponibilites']) ?>
</li>
<li onclick="location.href='/public/praticien/synchronisation.php'">
<?= htmlspecialchars($translations['menu_sync_agendas']) ?>
</li>
<!--<li onclick="location.href='/public/praticien/clients/index.php'">
<?= htmlspecialchars($translations['menu_mes_clients'] ?? 'Mes clients') ?>
</li>-->
<?php endif; ?>
<!-- Sous-menu Admin -->
<li class="has-submenu">
<button
type="button"
class="submenu-toggle"
aria-expanded="false"
aria-controls="submenu-admin"
>
<?= htmlspecialchars($translations['menu_admin']) ?>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path d="M6 9l6 6 6-6" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
</button>
<ul id="submenu-admin" class="submenu" hidden>
<?php if ($_SESSION['role'] === 'praticien'): ?>
<li onclick="location.href='/public/praticien/resiliation.php'">
<?= htmlspecialchars($translations['menu_gerer_abonnement']) ?>
</li>
<li onclick="location.href='/public/praticien/factures.php'">
<?= htmlspecialchars($translations['menu_mes_factures']) ?>
</li>
<li onclick="location.href='/public/cabinet/invitations.php'">
<?= htmlspecialchars($translations['secretariat_inviter_secretaire']) ?>
</li>
<li onclick="location.href='/public/praticien/clients/index.php'">
<?= htmlspecialchars($translations['menu_mes_clients'] ?? 'Mes clients') ?>
</li>
<?php endif; ?>
<li onclick="location.href='/public/<?= $_SESSION['role'] ?>/informations.php'">
<?= htmlspecialchars($translations['menu_mes_informations']) ?>
</li>
<li onclick="location.href='/public/<?= $_SESSION['role'] ?>/compte.php'">
<?= htmlspecialchars($translations['menu_infos_connexion']) ?>
</li>
<li onclick="location.href='/supprimer_compte.php'">
<?= htmlspecialchars($translations['menu_supprimer_compte']) ?>
</li>
</ul>
</li>
<!-- Déconnexion -->
<li onclick="closeMenu(); openContactAdmin()">
<?= htmlspecialchars($translations['contact_admin']) ?>
</li>
<li onclick="location.href='/public/deconnexion.php'">
<?= htmlspecialchars($translations['menu_deconnexion']) ?>
</li>
</ul>
</div>
</nav>
</div>
</header>
<!-- ================================================
[SCRIPTS / LOGIQUE CLIENT]
- Badge messages non lus (polling)
- Ouverture/fermeture du menu principal
- Gestion du sous-menu Admin (ARIA)
- Bootstrap JS bundle
================================================ -->
<script>
(function(){
function setBadge(el, n){
if (!el) return;
n = Number(n || 0);
if (n > 0){
el.style.display = 'inline-block';
el.textContent = String(n > 99 ? '99+' : n);
} else {
el.style.display = 'none';
el.textContent = '0';
}
}
async function tickBadges(){
try {
const r = await fetch('/push/badge_count.php', { credentials:'same-origin', cache:'no-store' });
if (!r.ok) return;
const j = await r.json();
if (!j || !j.ok) return;
// UI header
setBadge(document.getElementById('msg-badge'), j.msg_unread);
setBadge(document.getElementById('rdv-badge'), j.rdv_unread);
// Badge PWA (si supporté)
const n = Number(j.app_badge || 0);
if ('setAppBadge' in navigator) {
try {
if (n > 0) await navigator.setAppBadge(n);
else if ('clearAppBadge' in navigator) await navigator.clearAppBadge();
} catch(_) {}
}
} catch(e){}
}
// 1) au chargement
tickBadges();
// 2) refresh (mets 10s au lieu de 5s pour soulager)
setInterval(tickBadges, 10000);
// 3) quand on revient sur l’onglet
document.addEventListener('visibilitychange', () => {
if (!document.hidden) tickBadges();
});
// optionnel : dispo ailleurs
window.refreshBadges = tickBadges;
})();
// -------------------------------------------------
// [MENU PRINCIPAL] — Ouverture / Fermeture
// -------------------------------------------------
function toggleMenu() {
const menu = document.getElementById('menu');
if (!menu) return;
menu.style.display = (menu.style.display === 'block') ? 'none' : 'block';
}
function closeMenu() {
const menu = document.getElementById('menu');
if (menu) menu.style.display = 'none';
closeSubmenus();
}
document.getElementById('btnMenu').addEventListener('click', toggleMenu);
// Ferme le menu en cliquant à l'extérieur
document.addEventListener('click', (e) => {
const menu = document.getElementById('menu');
if (!menu) return;
const toggle = document.getElementById('btnMenu');
if (!toggle.contains(e.target) && !menu.contains(e.target)) {
closeMenu();
}
});
// Ferme avec la touche Escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeMenu();
});
// -------------------------------------------------
// [SOUS-MENU ADMIN] — Toggle + ARIA
// -------------------------------------------------
document.addEventListener('click', function(e) {
const btn = e.target.closest('.submenu-toggle');
if (!btn) return;
const submenuId = btn.getAttribute('aria-controls');
const submenu = document.getElementById(submenuId);
if (!submenu) return;
const isOpen = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', String(!isOpen));
submenu.hidden = isOpen;
e.stopPropagation();
});
function closeSubmenus() {
document
.querySelectorAll('.submenu-toggle[aria-expanded="true"]')
.forEach(btn => {
btn.setAttribute('aria-expanded', 'false');
const sub = document.getElementById(btn.getAttribute('aria-controls'));
if (sub) sub.hidden = true;
});
}
</script>
<script>
(async function registerSW(){
if (!('serviceWorker' in navigator)) return;
try {
// IMPORTANT : scope racine pour couvrir /push/, /messages/, /public/...
const reg = await navigator.serviceWorker.register('/sw.js', { scope: '/' });
// optionnel : log utile
// console.log('SW registered', reg.scope);
} catch (e) {
console.error('SW register failed', e);
}
})();
</script>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', async (e) => {
const msg = e.data || {};
if (msg.type === 'SET_BADGE') {
const n = Number(msg.badgeCount ?? 0);
// iOS/Safari: parfois pas supporté => silence
if ('setAppBadge' in navigator) {
try {
if (n > 0) await navigator.setAppBadge(n);
else if ('clearAppBadge' in navigator) await navigator.clearAppBadge();
} catch (_) {}
}
}
});
}
</script>
<script>
(async function pushOnboardingPWA(){
const isPWA =
window.matchMedia('(display-mode: standalone)').matches ||
window.navigator.standalone === true;
if (!isPWA) return;
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
if (!('Notification' in window)) return;
const VAPID_PUBLIC = "<?= htmlspecialchars($pushCfg['vapid_public'] ?? '', ENT_QUOTES) ?>";
if (!VAPID_PUBLIC) return;
const onboardKey = "<?= htmlspecialchars($pushOnboardKey, ENT_QUOTES) ?>";
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(base64);
const arr = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
return arr;
}
async function getSub(){
const reg = await navigator.serviceWorker.ready;
return await reg.pushManager.getSubscription();
}
async function syncToServer(sub){
const res = await fetch('/push/subscribe.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
cache: 'no-store',
body: JSON.stringify({
endpoint: sub.endpoint,
keys: sub.toJSON().keys,
userAgent: navigator.userAgent,
deviceLabel: 'PWA'
})
});
if (!res.ok) throw new Error('subscribe.php failed: ' + res.status);
return true;
}
async function subscribeNow(){
const reg = await navigator.serviceWorker.ready;
// 1) permission
const perm = await Notification.requestPermission();
if (perm !== 'granted') {
// on note qu'on a déjà proposé (évite spam)
try { localStorage.setItem(onboardKey, 'done'); } catch(_) {}
return false;
}
// 2) subscribe
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC)
});
// 3) sync DB
await syncToServer(sub);
// 4) mark done
try { localStorage.setItem(onboardKey, 'done'); } catch(_) {}
return true;
}
// --- Step 1: si sub existe déjà => sync DB silencieux ---
try {
const existing = await getSub();
if (existing) {
await syncToServer(existing);
// on marque done pour ne pas reproposer
try { localStorage.setItem(onboardKey, 'done'); } catch(_) {}
return;
}
} catch(e) {
// silencieux (pas bloquant)
}
// --- Step 2: pas de sub => afficher modal une seule fois ---
let alreadyDone = false;
try { alreadyDone = localStorage.getItem(onboardKey) === 'done'; } catch(_) {}
// Si l'utilisateur a déjà refusé au niveau OS => pas de modal (ou tu peux afficher un toast)
if (Notification.permission === 'denied') {
try { localStorage.setItem(onboardKey, 'done'); } catch(_) {}
return;
}
// Si déjà proposé => stop
if (alreadyDone) return;
// Affiche modal (Bootstrap)
const modalEl = document.getElementById('pushOnboardModal');
if (!modalEl || typeof bootstrap === 'undefined') return;
const modal = new bootstrap.Modal(modalEl, { backdrop: 'static' });
const laterBtn = document.getElementById('pushLaterBtn');
const enableBtn = document.getElementById('pushEnableBtn');
if (laterBtn) {
laterBtn.addEventListener('click', () => {
try { localStorage.setItem(onboardKey, 'done'); } catch(_) {}
modal.hide();
}, { once:true });
}
if (enableBtn) {
enableBtn.addEventListener('click', async () => {
enableBtn.disabled = true;
try {
await subscribeNow();
} catch(e) {
// si échec (réseau / serveur), on ne spam pas l'utilisateur
try { localStorage.setItem(onboardKey, 'done'); } catch(_) {}
} finally {
modal.hide();
}
}, { once:true });
}
modal.show();
})();
</script>
<script>
window.applyBadges = function (data) {
const msgUnread = Number(data?.msg_unread || 0);
const rdvUnread = Number(data?.rdv_unread || 0);
const appBadge = Number(data?.app_badge || (msgUnread + rdvUnread));
const msgSpan = document.getElementById('msg-badge');
const rdvSpan = document.getElementById('rdv-badge');
if (msgSpan){
msgSpan.style.display = msgUnread > 0 ? 'inline-block' : 'none';
msgSpan.textContent = msgUnread > 99 ? '99+' : String(msgUnread);
}
if (rdvSpan){
rdvSpan.style.display = rdvUnread > 0 ? 'inline-block' : 'none';
rdvSpan.textContent = rdvUnread > 99 ? '99+' : String(rdvUnread);
}
if ('setAppBadge' in navigator) {
try {
if (appBadge > 0) navigator.setAppBadge(appBadge);
else navigator.clearAppBadge?.();
} catch(_) {}
}
};
</script>
+231
View File
@@ -0,0 +1,231 @@
<?php
// ⚠️ NE PAS inclure contact_admin.php ici (risque d'exécution à l'inclusion)
require_once __DIR__ . '/../contact_admin.php';
$sec_uid = (int)($_SESSION['compte_id'] ?? $_SESSION['user_id'] ?? 0);
// =====================================================
// [CONTEXT UTILISATEUR] — Initiales avatar secrétaire
// =====================================================
$prenomUser = trim((string)($_SESSION['user_prenom'] ?? $_SESSION['prenom'] ?? ''));
$nomUser = trim((string)($_SESSION['user_nom'] ?? $_SESSION['nom'] ?? ''));
if ($prenomUser === '' && $nomUser === '') {
// petit fallback
$prenomUser = 'Secr';
$nomUser = 'Etaire';
}
$initiales = mb_strtoupper(
mb_substr($prenomUser, 0, 1, 'UTF-8') .
mb_substr($nomUser, 0, 1, 'UTF-8'),
'UTF-8'
);
// =====================================================
// [PRATICIEN COURANT] — affichage nom/prénom dans le header
// - on prend ?praticien_id si présent, sinon une valeur de session si dispo
// =====================================================
$currentPraticienId = null;
if (isset($_GET['praticien_id'])) {
$currentPraticienId = (int)$_GET['praticien_id'];
$_SESSION['current_praticien_id'] = $currentPraticienId; // mémorise pour les autres pages
} elseif (!empty($_SESSION['current_praticien_id'])) {
$currentPraticienId = (int)$_SESSION['current_praticien_id'];
}
$currentPraticienName = '';
if ($currentPraticienId) {
$stmt = $pdo->prepare("SELECT id, prenom, nom FROM praticien WHERE id = ? LIMIT 1");
$stmt->execute([$currentPraticienId]);
if ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$p = trim((string)($row['prenom'] ?? ''));
$n = trim((string)($row['nom'] ?? ''));
$pid = trim((string)($row['id'] ?? ''));
$currentPraticienName = trim($p.' '.$n);
}
}
// =====================================================
// [Liste Praticien avec autorisations]
// =====================================================
$pid = (int)$currentPraticienId;
$permRow = null;
if ($pid && $sec_uid) {
$q = $pdo->prepare("
SELECT sp.can_manage_rdv, sp.can_edit_slots
FROM secretaire_praticien sp
WHERE sp.secretaire_user_id = :u
AND sp.praticien_id = :pid
AND sp.is_active = 1
LIMIT 1
");
$q->execute(['u' => $sec_uid, 'pid' => $pid]);
$permRow = $q->fetch(PDO::FETCH_ASSOC) ?: null;
}
// =====================================================
// [RETOUR / REFERER SÉCURISÉ]
// =====================================================
$ref = $_SERVER['HTTP_REFERER'] ?? '';
$host = $_SERVER['HTTP_HOST'] ?? '';
$safeRef = '';
if ($ref) {
$u = parse_url($ref);
if (!empty($u['host']) && $u['host'] === $host) $safeRef = $ref;
}
$fallbackBack = $safeRef ?: '/public/cabinet/dashboard.php';
// =====================================================
// [DETECTION MOBILE]
// =====================================================
$is_mobile = preg_match('/Mobile|Android|iPhone|iPad/i', $_SERVER['HTTP_USER_AGENT'] ?? '');
?>
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title><?= htmlspecialchars($PAGE_TITLE ?? 'Cabinet', ENT_QUOTES, 'UTF-8') ?></title>
<?= $HEAD_EXTRA ?? '' ?>
<!-- Montserrat -->
<!-- Bootstrap -->
<link href="/css/bootstrap.min.css" rel="stylesheet">
<!-- Styles (chemins corrigés) -->
<link rel="stylesheet" href="<?= asset('/includes/site_style.css') ?>">
<link rel="stylesheet" href="<?= asset('/includes/custom_style.css') ?>"> <!-- ta home -->
<link rel="stylesheet" href="<?= asset('/includes/admin_style.css') ?>">
<link rel="stylesheet" href="<?= asset('/includes/mobil_style.css') ?>">
</head>
<body>
<header class="site-header py-2 border-bottom">
<div class="container d-flex justify-content-between align-items-center">
<!-- Bouton retour + Logo -->
<div class="header-left">
<div class="brand">
<a
href="<?= htmlspecialchars($fallbackBack, ENT_QUOTES, 'UTF-8') ?>"
id="btnBack"
class="btn btn-light btn-circle me-2"
style="border-radius:5px; box-shadow:0 1px 2px rgba(0,0,0,.08);"
aria-label="<?= htmlspecialchars($translations['retour'] ?? 'Retour', ENT_QUOTES, 'UTF-8') ?>"
>
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" aria-hidden="true">
<path d="M15 6l-6 6 6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</a>
<a href="/public/cabinet/dashboard.php">
<img src="/public/uploads/img/logo_u.png"
alt="uslugi.fr"
class="logo-mobile">
<img src="/public/uploads/img/logo.png"
alt="uslugi.fr"
class="logo-desktop">
</a>
</div>
<div class="lang-switch">
<?php require __DIR__ . '/../../includes/menu_langue.php'; ?>
</div>
</div>
<!-- Actions header -->
<nav class="header-actions d-flex align-items-center gap-3 position-relative">
<!-- ⚡ A LA PLACE DE L’ICÔNE MESSAGES : le praticien courant -->
<div class="text-nowrap" style="font-weight:600;">
<?php if ($currentPraticienName): ?>
<?= htmlspecialchars($currentPraticienName, ENT_QUOTES, 'UTF-8') ?>
<?php else: ?>
<span class="text-muted"><?= htmlspecialchars($translations['aucun_praticien_selectionne'] ?? 'Praticien non sélectionné', ENT_QUOTES, 'UTF-8') ?></span>
<?php endif; ?>
</div>
<!-- Menu utilisateur (avatar) -->
<div class="menu-wrap">
<button type="button" id="btnMenu" class="btn p-0 border-0 bg-transparent">
<div class="avatar d-flex align-items-center justify-content-center">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 12a5 5 0 1 0-5-5 5 5 0 0 0 5 5m0 2c-5.33 0-8 2.67-8 4v2h16v-2c0-1.33-2.67-4-8-4"/>
</svg>
</div>
</button>
<ul class="dropdown-menu-custom menu-panel" id="menu" style="display:none; min-width: 220px;">
<?php if (($_SESSION['role'] ?? '') === 'secretaire'): ?>
<li onclick="location.href='/public/cabinet/dashboard.php'">
<?= htmlspecialchars($translations['home'] ?? 'Accueil') ?>
</li>
<?php if ($pid && $permRow): ?>
<?php if (!empty($permRow['can_manage_rdv'])): ?>
<li onclick="location.href='/public/cabinet/agenda.php?praticien_id=<?= $pid ?>'">
<?= htmlspecialchars($translations['sec_btn_agenda'] ?? 'Agenda') ?>
</li>
<li onclick="location.href='/public/cabinet/rendez_vous_du_jour.php?praticien_id=<?= $pid ?>'">
<?= htmlspecialchars($translations['sec_btn_rdv'] ?? 'Rendez-vous') ?>
</li>
<li onclick="location.href='/public/cabinet/rendez_vous.php?praticien_id=<?= $pid ?>'">
<?= htmlspecialchars($translations['sec_btn_rdv_jour'] ?? 'Les rendez-vous') ?>
</li>
<li onclick="location.href='/public/cabinet/reserver_rdv.php?praticien_id=<?= $pid ?>'">
<?= htmlspecialchars($translations['sec_btn_prendre'] ?? 'Prendre un RDV') ?>
</li>
<?php endif; ?>
<?php if (!empty($permRow['can_edit_slots'])): ?>
<li onclick="location.href='/public/cabinet/disponibilites.php?praticien_id=<?= $pid ?>'">
<?= htmlspecialchars($translations['sec_btn_dispos'] ?? 'Disponibilités') ?>
</li>
<?php endif; ?>
<?php endif; ?>
<li onclick="closeMenu(); openContactAdmin()">
<?= htmlspecialchars($translations['contact_admin'] ?? 'Contact Admin') ?>
</li>
<li onclick="location.href='/public/deconnexion.php'">
<?= htmlspecialchars($translations['menu_deconnexion'] ?? 'Déconnexion') ?>
</li>
<?php endif; ?>
</ul>
</div>
</nav>
</div>
</header>
<script>
// --- Menu principal
function toggleMenu() {
const menu = document.getElementById('menu');
if (!menu) return;
menu.style.display = (menu.style.display === 'block') ? 'none' : 'block';
}
function closeMenu() {
const menu = document.getElementById('menu');
if (menu) menu.style.display = 'none';
}
document.getElementById('btnMenu')?.addEventListener('click', function(e){
e.stopPropagation();
toggleMenu();
});
document.addEventListener('click', function(e){
const menu = document.getElementById('menu');
const btn = document.getElementById('btnMenu');
if (!menu || !btn) return;
if (!btn.contains(e.target) && !menu.contains(e.target)) closeMenu();
});
document.addEventListener('keydown', function(e){ if (e.key === 'Escape') closeMenu(); });
</script>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
+240
View File
@@ -0,0 +1,240 @@
<?php
require_once __DIR__ . '/securite.php';
require_once __DIR__ . '/db.php';
require_once __DIR__ . '/verifier_abonnement.php';
require_once __DIR__ . '/../contact_admin.php'; // ← depuis /public/includes/header.php
require_once __DIR__ . '/../includes/lang.php';
require_once __DIR__ . '/../../includes/pretty_urls.php';
require_once __DIR__ . '/../../includes/mailer.php'; // uslugi_mail()
if (!isset($_SESSION)) session_start();
// Charger les données client ou praticien
$nom = $prenom = '';
if ($_SESSION['role'] === 'client') {
$stmt = $pdo->prepare("SELECT nom, prenom FROM client WHERE id = ?");
} else {
$stmt = $pdo->prepare("SELECT nom, prenom FROM praticien WHERE id = ?");
}
$stmt->execute([$_SESSION['reference_id']]);
$user = $stmt->fetch();
if ($user) {
$nom = htmlspecialchars($user['nom']);
$prenom = htmlspecialchars($user['prenom']);
}
//Initiales pour Avatar
$initiales = mb_strtoupper(
mb_substr((string)$prenom, 0, 1, 'UTF-8') .
mb_substr((string)$nom, 0, 1, 'UTF-8'),
'UTF-8'
);
//Function pour gerer hystorique de la navigation
//*************************************************
$ref = $_SERVER['HTTP_REFERER'] ?? '';
$host = $_SERVER['HTTP_HOST'] ?? '';
$safeRef = '';
if ($ref) {
$u = parse_url($ref);
if (!empty($u['host']) && $u['host'] === $host) {
$safeRef = $ref; // referer même origine uniquement
}
}
// Mets ici ta route par défaut si pas d'historique / referer externe
$fallbackBack = $safeRef ?: '/';
//********************FIN********************************
?>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<link href="/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="../../includes/site_style.css">
<link rel="stylesheet" href="../../includes/mobil_style.css">
</head>
<body>
<header class="site-header">
<div class="container">
<div class="brand">
<!--Bouton hystory qui fait retour en arriere-->
<a href="<?= htmlspecialchars($fallbackBack, ENT_QUOTES, 'UTF-8') ?>"
id="btnBack"
class="btn btn-light btn-circle me-2" style="border-radius: 5px; box-shadow: 0 1px 2px rgba(0,0,0,.08);"
aria-label="<?= htmlspecialchars($translations['retour'] ?? 'Retour', ENT_QUOTES, 'UTF-8') ?>">
<!-- inline SVG, compatible partout -->
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" aria-hidden="true">
<path d="M15 6l-6 6 6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</a>
<!-- *************************FIN*************************-->
<a href="https://uslugi.fr/index.php"><img src="/public/uploads/img/logo.png" width="130"></a>
</div>
<?php if (isset($alerte_abonnement) && $alerte_abonnement): ?>
<div class="alert alert-warning m-0 py-1 px-2 rounded text-dark" role="alert" style="background:#fff3cd; border: 1px solid #ffeeba; font-size: 14px;">
<?= htmlspecialchars($alerte_abonnement) ?>
</div>
<?php endif; ?>
<nav class="header-actions">
<div class="avatar"><?= $initiales ?></div>
<!--**************************Bouton message conv*************************************************-->
<a href="/messages/" class="nav-msg" style="position:relative; display:inline-block; margin-left:12px;">
<span aria-hidden="true"><img src="/public/uploads/img/message.png" width="20px"></span>
<span id="msg-badge" style="
position:absolute; top:-6px; right:-8px;
background:#e11d48; color:#fff; font-size:12px; min-width:18px; height:18px; line-height:18px;
text-align:center; border-radius:999px; padding:0 5px; display:none;">0</span>
</a>
<script>
(function(){
async function tick(){
try{
const r = await fetch('/messages/api/unread_count.php', { credentials:'same-origin' });
if(!r.ok) throw 0;
const j = await r.json();
const b = document.getElementById('msg-badge');
const n = Number(j.total||0);
if(n>0){ b.style.display='inline-block'; b.textContent = String(n); }
else { b.style.display='none'; }
}catch(e){}
}
tick();
setInterval(tick, 5000);
})();
</script>
<!--*************************************FIN BOUTON**********************************************
KKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKK-->
<div class="lang-switch">
<?php require_once __DIR__ .'/../../includes/menu_langue.php'; ?>
</div>
<div class="menu-wrap">
<button
type="button"
id="btnMenu"
class="btn btn-light btn-circle me-2" style="border-radius: 5px; box-shadow: 0 1px 2px rgba(0,0,0,.08);"
aria-label="<?= htmlspecialchars($translations['menu'] ?? 'Menu', ENT_QUOTES, 'UTF-8') ?>"
onclick="toggleMenu()">
<!-- icône hamburger 20x20, même style que la flèche -->
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" aria-hidden="true">
<path d="M4 6h16M4 12h16M4 18h16"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<!-- Menu -->
<ul class="dropdown-menu-custom" id="menu">
<li onclick="location.href='/public/<?= $_SESSION['role'] ?>/informations.php'">
<?= htmlspecialchars($translations['menu_mes_informations']) ?>
</li>
<li onclick="location.href='/public/<?= $_SESSION['role'] ?>/compte.php'">
<?= htmlspecialchars($translations['menu_infos_connexion']) ?>
</li>
<?php if ($_SESSION['role'] === 'client'): ?>
<li onclick="location.href='/public/client/list_praticien.php'">
<?= htmlspecialchars($translations['menu_liste_specialistes']) ?>
</li>
<li onclick="location.href='/public/client/mes_favori.php'">
<?= htmlspecialchars($translations['menu_mes_favoris']) ?>
</li>
<li onclick="location.href='/public/client/mes_rendez_vous.php'">
<?= htmlspecialchars($translations['menu_mes_rendez_vous']) ?>
</li>
<?php else: ?>
<li onclick="location.href='/public/praticien/disponibilites.php'">
<?= htmlspecialchars($translations['menu_mes_disponibilites']) ?>
</li>
<li onclick="location.href='/public/praticien/rendez_vous.php'">
<?= htmlspecialchars($translations['menu_mes_rendez_vous']) ?>
</li>
<li onclick="location.href='/public/praticien/agenda.php'">
<?= htmlspecialchars($translations['menu_mon_agenda']) ?>
</li>
<li onclick="location.href='/public/praticien/synchronisation.php'">
<?= htmlspecialchars($translations['menu_sync_agendas']) ?>
</li>
<li onclick="location.href='/public/praticien/resiliation.php'">
<?= htmlspecialchars($translations['menu_gerer_abonnement']) ?>
</li>
<li onclick="location.href='/public/praticien/factures.php'">
<?= htmlspecialchars($translations['menu_mes_factures']) ?>
</li>
<?php endif; ?>
<li onclick="closeMenu(); openContactAdmin()" class="text-primary">
<?= htmlspecialchars($translations['contact_admin']) ?>
</li>
<li onclick="location.href='/public/deconnexion.php'" class="text-danger">
<?= htmlspecialchars($translations['menu_deconnexion']) ?>
</li>
</ul>
</div>
</nav>
</div>
</header>
<!-- JS -->
<script>
function toggleMenu() {
const menu = document.getElementById('menu');
if (!menu) return;
menu.style.display = (menu.style.display === 'block') ? 'none' : 'block';
}
document.addEventListener('click', function(e) {
const toggle = document.getElementById('btnMenu');
const menu = document.getElementById('menu');
if (!menu) return;
const clickOnToggle = toggle ? toggle.contains(e.target) : false;
const clickInMenu = menu.contains(e.target);
if (!clickOnToggle && !clickInMenu) {
menu.style.display = 'none';
}
});
</script>
<script>
// --- déjà présent ---
// function toggleMenu() { ... }
// document.addEventListener('click', ...);
// 1) Petite utilitaire pour fermer le menu
function closeMenu() {
const menu = document.getElementById('menu');
if (menu) menu.style.display = 'none';
// (optionnel) retire un flag si tu l'ajoutes ailleurs
document.body.classList.remove('menu-open');
}
// 2) Si ta popup est ouverte par une fonction JS (ex: openContactAdmin)
// on injecte la fermeture du menu AVANT l’ouverture
// -> Si openContactAdmin existe, on la “wrap”
if (typeof window.openContactAdmin === 'function') {
const _origOpenContactAdmin = window.openContactAdmin;
window.openContactAdmin = function(...args){
closeMenu();
return _origOpenContactAdmin.apply(this, args);
};
}
// 3) Bonus : touche Échap ferme le menu
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeMenu();
});
</script>
+15
View File
@@ -0,0 +1,15 @@
<?php
//init_admin.php
declare(strict_types=1);
require_once __DIR__ . '/init_auth.php';
init_auth_page(['admin']);
// ensuite, renfort strict admin (optionnel mais recommandé)
require_login_or_redirect(['admin'], [
'pdo' => $GLOBALS['pdo'] ?? null,
'recheck_db_role' => true, // recheck DB comme secure_admin.php si besoin
'admin_strict_reference' => 88888888 // ton super-admin
]);
// CSRF ready
ensure_csrf_token();
+64
View File
@@ -0,0 +1,64 @@
<?php
//init_auth.php
declare(strict_types=1);
require_once __DIR__ . '/../../../includes/bootstrap_global.php';
require_once __DIR__ . '/securite.php';
function init_auth_page(?array $roles = null): void
{
require_login_or_redirect($roles);
// Push config (si utilisé dans header site)
$pushCfgPath = __DIR__ . '/../../push/config.php'; // /public/push/config.php
$GLOBALS['pushCfg'] = is_file($pushCfgPath) ? require $pushCfgPath : [];
// Abonnement (si tu l’utilises dans le header site praticien)
$abonnementPath = __DIR__ . '/verifier_abonnement.php';
if (is_file($abonnementPath)) {
require $abonnementPath;
$GLOBALS['alerte_abonnement'] = $alerte_abonnement ?? null;
} else {
$GLOBALS['alerte_abonnement'] = null;
}
// Avatars
$GLOBALS['initiales'] = 'U';
$GLOBALS['avatar_svg'] = null;
$role = strtolower((string)($_SESSION['role'] ?? ''));
// Admin / Secrétaire => SVG
if ($role === 'admin') {
$GLOBALS['avatar_svg'] = '/public/uploads/img/avatar_admin.svg';
return;
}
if ($role === 'secretaire') {
$GLOBALS['avatar_svg'] = '/public/uploads/img/avatar_secretaire.svg';
return;
}
// Client / Praticien => initiales depuis tables
$refId = (int)($_SESSION['reference_id'] ?? 0);
$pdo = $GLOBALS['pdo'] ?? null;
if (!($pdo instanceof PDO) || $refId <= 0) return;
if ($role === 'client') {
$stmt = $pdo->prepare("SELECT nom, prenom FROM client WHERE id = ?");
} elseif ($role === 'praticien') {
$stmt = $pdo->prepare("SELECT nom, prenom FROM praticien WHERE id = ?");
} else {
return;
}
$stmt->execute([$refId]);
$u = $stmt->fetch(PDO::FETCH_ASSOC) ?: [];
$nom = trim((string)($u['nom'] ?? ''));
$prenom = trim((string)($u['prenom'] ?? ''));
$ini = mb_strtoupper(
mb_substr($prenom, 0, 1, 'UTF-8') . mb_substr($nom, 0, 1, 'UTF-8'),
'UTF-8'
);
$GLOBALS['initiales'] = $ini !== '' ? $ini : 'U';
}
+4
View File
@@ -0,0 +1,4 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/init_auth.php';
init_auth_page(['client']);
+4
View File
@@ -0,0 +1,4 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/init_auth.php';
init_auth_page(['client', 'praticien']);
+4
View File
@@ -0,0 +1,4 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/init_auth.php';
init_auth_page(['praticien']);
+4
View File
@@ -0,0 +1,4 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/init_auth.php';
init_auth_page(['secretaire']);
+36
View File
@@ -0,0 +1,36 @@
<?php
// Définir la langue par défaut
if (!isset($_SESSION['lang'])) {
$_SESSION['lang'] = 'ru'; // par défaut russe
}
// Charger le fichier de langue correspondant
$lang = $_SESSION['lang'];
$lang_file = __DIR__ . "/../lang/{$lang}.php";
// Si le fichier n'existe pas, on repasse en russe
if (!file_exists($lang_file)) {
$lang_file = __DIR__ . "/../lang/ru.php";
}
// Charger les traductions
$translations = require $lang_file;
/**
* Asset helper: ajoute une version ?v= basée sur filemtime()
* Usage: asset('/includes/custom_style.css')
*/
if (!function_exists('asset')) {
function asset(string $webPath): string
{
// Normalise
if ($webPath === '') return $webPath;
if ($webPath[0] !== '/') $webPath = '/' . $webPath;
$docRoot = rtrim($_SERVER['DOCUMENT_ROOT'] ?? '', '/');
$fsPath = $docRoot . $webPath;
$v = (is_file($fsPath) ? filemtime($fsPath) : time()); // fallback si non trouvé
return $webPath . '?v=' . $v;
}
}
+27
View File
@@ -0,0 +1,27 @@
(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] || '');
}
});
});
// ✅ une seule source de vérité
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);
});
})();
+175
View File
@@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
// Interdit l’accès direct au fichier (pas de HTML ici)
if (isset($_SERVER['SCRIPT_FILENAME']) && realpath(__FILE__) === realpath($_SERVER['SCRIPT_FILENAME'])) {
http_response_code(403);
exit('Forbidden');
}
/** Est-ce que l'utilisateur est connecté ? */
function is_logged_in(): bool {
return isset($_SESSION['compte_id'], $_SESSION['reference_id'])
&& $_SESSION['compte_id'] !== ''
&& $_SESSION['reference_id'] !== '';
}
/** Timeout inactivité (2h) */
function enforce_session_timeout(int $maxIdleSeconds = 7200): void {
$now = time();
$last = (int)($_SESSION['last_activity'] ?? 0);
if ($last > 0 && ($now - $last) > $maxIdleSeconds) {
logout_and_redirect_to_login($_SERVER['REQUEST_URI'] ?? '/');
}
$_SESSION['last_activity'] = $now;
}
/** Anti open-redirect : n’autorise que les chemins internes commençant par '/' */
function next_safe(?string $u): ?string {
if (!$u) return null;
if (!preg_match('#^/[^\\r\\n]*$#', $u)) return null;
if (strpos($u, '//') === 0) return null;
return $u;
}
function redirect_forbidden(?string $requestUri = null): void {
$u = $requestUri ?: ($_SERVER['REQUEST_URI'] ?? '/');
$u = next_safe($u) ?? '/';
header('Location: /403.php?next=' . rawurlencode($u), true, 303);
exit;
}
function redirect_to_login_with_next(?string $requestUri = null): void {
$u = $requestUri ?: ($_SERVER['REQUEST_URI'] ?? '/');
$u = next_safe($u) ?? '/';
$_SESSION['redirect_to'] = $u;
header('Location: /connexion.php?next=' . rawurlencode($u), true, 303);
exit;
}
function logout_and_redirect_to_login(?string $requestUri = null): void {
$_SESSION = [];
if (session_status() === PHP_SESSION_ACTIVE) {
$params = session_get_cookie_params();
setcookie(
session_name(),
'',
time() - 42000,
$params['path'] ?? '/',
$params['domain'] ?? '',
(bool)($params['secure'] ?? false),
(bool)($params['httponly'] ?? true)
);
}
if (session_status() === PHP_SESSION_ACTIVE) {
session_destroy();
}
redirect_to_login_with_next($requestUri ?? ($_SERVER['REQUEST_URI'] ?? '/'));
}
/**
* 🔒 Re-check rôle depuis DB (optionnel) + hydrate $_SESSION['role'] si absent.
* Retourne le rôle (string) ou '' si introuvable.
*/
function refresh_role_from_db(PDO $pdo): string {
$compteId = (int)($_SESSION['compte_id'] ?? 0);
if ($compteId <= 0) return '';
$stmt = $pdo->prepare("SELECT role FROM compte WHERE id=? LIMIT 1");
$stmt->execute([$compteId]);
$role = (string)($stmt->fetchColumn() ?: '');
if ($role !== '') {
$_SESSION['role'] = $role;
}
return strtolower($role);
}
/**
* ✅ À appeler en haut des pages protégées.
* - $allowedRoles = null => tous rôles
* - options:
* - 'pdo' => PDO (pour re-check DB si demandé)
* - 'recheck_db_role' => true/false
* - 'admin_strict_reference' => int|null (ex: 88888888)
*/
function require_login_or_redirect(?array $allowedRoles = null, array $options = []): void {
if (!is_logged_in()) {
redirect_to_login_with_next($_SERVER['REQUEST_URI'] ?? '/');
}
enforce_session_timeout(7200);
$pdo = $options['pdo'] ?? ($GLOBALS['pdo'] ?? null);
$recheck = (bool)($options['recheck_db_role'] ?? false);
// Rôle: session d'abord, DB si demandé (ou si role manquant)
$userRole = strtolower((string)($_SESSION['role'] ?? ''));
if (($userRole === '' || $recheck) && ($pdo instanceof PDO)) {
$userRole = refresh_role_from_db($pdo);
}
if ($allowedRoles) {
$allowed = array_map(static fn($r) => strtolower((string)$r), $allowedRoles);
if (!in_array($userRole, $allowed, true)) {
redirect_forbidden($_SERVER['REQUEST_URI'] ?? '/');
}
// 🔥 Renfort admin optionnel : role + reference_id
if (in_array('admin', $allowed, true)) {
$strictRef = $options['admin_strict_reference'] ?? null;
if ($strictRef !== null) {
$refId = (int)($_SESSION['reference_id'] ?? 0);
if ($refId !== (int)$strictRef) {
redirect_forbidden($_SERVER['REQUEST_URI'] ?? '/');
}
}
}
}
}
/* =========================
Admin helpers (CSRF + logs)
========================= */
/** Token CSRF session */
function ensure_csrf_token(): string {
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return (string)$_SESSION['csrf_token'];
}
/** Exige POST + CSRF (supporte aussi header X-CSRF-Token si tu veux fetch/AJAX) */
function require_post_csrf(): void {
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
exit('Méthode non autorisée');
}
$token = $_POST['csrf_token'] ?? '';
if ($token === '' && !empty($_SERVER['HTTP_X_CSRF_TOKEN'])) {
$token = (string)$_SERVER['HTTP_X_CSRF_TOKEN'];
}
if ($token === '' || !hash_equals((string)($_SESSION['csrf_token'] ?? ''), (string)$token)) {
http_response_code(419);
exit('CSRF invalide');
}
}
/** Log admin minimal */
function log_admin_action(PDO $pdo, int $userId, string $action, string $details = ''): void {
// limite simple pour éviter d'exploser la table
if (strlen($details) > 2000) $details = substr($details, 0, 2000);
$pdo->prepare(
"INSERT INTO admin_actions_log (user_id, action, details, ip) VALUES (?,?,?,?)"
)->execute([
$userId,
$action,
$details,
$_SERVER['REMOTE_ADDR'] ?? ''
]);
}
+102
View File
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
/**
* /public/includes/verifier_abonnement.php
*
* Rôle: praticien uniquement
* - Définit $alerte_abonnement (string|null) pour affichage dans header
* - Peut rediriger si abonnement expiré (selon règles)
*
* Pré-requis: session démarrée, $pdo dispo (via bootstrap_global.php / init_auth.php)
*/
$alerte_abonnement = null;
// --- Guard: uniquement praticien
$role = (string)($_SESSION['role'] ?? '');
if ($role !== 'praticien') {
return;
}
// --- PDO
$pdo = $GLOBALS['pdo'] ?? null;
if (!($pdo instanceof PDO)) {
return;
}
// --- IDs
$praticienId = (int)($_SESSION['reference_id'] ?? 0);
if ($praticienId <= 0) {
return;
}
// --- Traductions (fallbacks)
$translations = $GLOBALS['translations'] ?? [];
$t_expire = (string)($translations['alerte_abonnement_expire'] ?? "Votre abonnement est expiré.");
$t_resilie = (string)($translations['alerte_abonnement_resilie'] ?? "Votre abonnement est résilié. Il se termine le {date}.");
// --- Récupère abonnement
$stmt = $pdo->prepare("SELECT abonnement_id, date_expiration FROM praticien WHERE id = ?");
$stmt->execute([$praticienId]);
$p = $stmt->fetch(PDO::FETCH_ASSOC) ?: [];
$abonnement_id = $p['abonnement_id'] ?? null;
// date_expiration -> timestamp (0 si absent/invalide)
$expiration_ts = 0;
if (!empty($p['date_expiration'])) {
$ts = strtotime((string)$p['date_expiration']);
$expiration_ts = ($ts !== false) ? $ts : 0;
}
$now_ts = time();
// --- Pages autorisées même si expiré
$page_actuelle = basename((string)($_SERVER['PHP_SELF'] ?? ''));
$pages_autorisees = [
'choisir_abonnement.php',
'souscrire_abonnement.php',
'resiliation.php',
'resiliation_action.php',
'deconnexion.php',
'paiement_simule.php',
'create_checkout.php',
];
// --- État abonnement
$is_expired = ($expiration_ts > 0 && $expiration_ts < $now_ts);
// ⚠️ Cas "résilié mais encore actif": abonnement_id NULL mais expiration future
$is_cancelled_but_active = (!$abonnement_id && $expiration_ts >= $now_ts && $expiration_ts > 0);
// --- Bannière (affichage)
if ($is_cancelled_but_active) {
$alerte_abonnement = strtr($t_resilie, [
'{date}' => date('d/m/Y', $expiration_ts),
]);
} elseif ($is_expired) {
$alerte_abonnement = $t_expire;
}
// (Optionnel) expose aussi un état global utile
$GLOBALS['abonnement_state'] = [
'abonnement_id' => $abonnement_id,
'expiration_ts' => $expiration_ts,
'is_expired' => $is_expired,
'is_cancelled_but_active' => $is_cancelled_but_active,
];
// --- Redirection si expiré (uniquement si expiration connue)
if ($is_expired && !in_array($page_actuelle, $pages_autorisees, true)) {
// Important: éviter "headers already sent"
if (!headers_sent()) {
header('Location: /public/choisir_abonnement.php', true, 302);
exit;
}
// Si headers déjà envoyés, on ne peut pas header() -> fallback JS
echo '<script>location.href="/public/choisir_abonnement.php";</script>';
exit;
}
+2014
View File
File diff suppressed because it is too large. Load diff
+2024
View File
File diff suppressed because it is too large. Load diff
+186
View File
@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
/**
* Notification e-mail pour un nouveau message dans la messagerie interne.
*
* À inclure APRÈS $pdo->commit() dans le script d'envoi de message :
*
* try {
* require_once __DIR__ . '/../public/message_par_mail.php';
* notifier_nouveau_message_par_email($pdo, $convId, $role);
* } catch (Throwable $e) {
* error_log('Notif mail error: '.$e->getMessage());
* }
*
* - $pdo : PDO connecté à la base uslugi
* - $convId : ID de la conversation
* - $role : 'praticien' ou 'client' (l'expéditeur)
*/
require_once __DIR__ . '/../../includes/bootstrap_global.php';
// On suppose que $translations est déjà chargé par _bootstrap/lang.php.
// Si ce n’est pas le cas, on évite de crasher.
if (!isset($translations) || !is_array($translations)) {
$translations = [];
}
/**
* Envoie un e-mail de notification pour un nouveau message.
*
* @param PDO $pdo
* @param int $conversationId ID de la conversation
* @param string $senderRole 'praticien' ou 'client'
*/
function notifier_nouveau_message_par_email(PDO $pdo, int $conversationId, string $senderRole): void
{
global $translations;
if ($senderRole !== 'praticien' && $senderRole !== 'client') {
return;
}
// Récupérer la conversation + emails praticien/client
$sql = "
SELECT
c.id AS conv_id,
c.praticien_id AS prat_id,
c.client_id AS cli_id,
p.email AS prat_email,
p.nom AS prat_nom,
p.prenom AS prat_prenom,
cl.email AS cli_email,
cl.nom AS cli_nom,
cl.prenom AS cli_prenom
FROM conversations c
INNER JOIN praticien p ON p.id = c.praticien_id
INNER JOIN client cl ON cl.id = c.client_id
WHERE c.id = ?
LIMIT 1
";
$stmt = $pdo->prepare($sql);
$stmt->execute([$conversationId]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
if (!$row) {
return; // conversation introuvable
}
// Déterminer le destinataire
if ($senderRole === 'praticien') {
// Le praticien écrit → on notifie le client
$toEmail = (string)($row['cli_email'] ?? '');
$toName = trim(((string)($row['cli_prenom'] ?? '')) . ' ' . ((string)($row['cli_nom'] ?? '')));
} else {
// Le client écrit → on notifie le praticien
$toEmail = (string)($row['prat_email'] ?? '');
$toName = trim(((string)($row['prat_prenom'] ?? '')) . ' ' . ((string)($row['prat_nom'] ?? '')));
}
if (!filter_var($toEmail, FILTER_VALIDATE_EMAIL)) {
return;
}
// ===== Textes via $translations (avec fallback FR) =====
$subject = $translations['email_new_msg_subject']
?? 'Vous avez un nouveau message sur USLUGI.FR';
$helloTmpl = $translations['email_new_msg_hello']
?? 'Bonjour %s,';
$helloLine = sprintf($helloTmpl, $toName !== '' ? $toName : '');
$intro = $translations['email_new_msg_intro']
?? 'Vous avez reçu un nouveau message dans votre messagerie USLUGI.FR.';
$ctaLabel = $translations['email_new_msg_cta_label']
?? 'Se connecter à USLUGI.FR';
$footer = $translations['email_new_msg_footer']
?? 'Merci d\'utiliser USLUGI.FR.';
// Lien générique de connexion (sans rôle, ni token)
$ctaUrl = 'https://uslugi.fr/connexion.php';
// ===== Version texte =====
$textLines = [];
$textLines[] = $helloLine;
$textLines[] = '';
$textLines[] = $intro;
$textLines[] = '';
$textLines[] = $ctaLabel . ' : ' . $ctaUrl;
$textLines[] = '';
$textLines[] = $footer;
$textBody = implode("\n", $textLines);
// ===== Version HTML =====
$safeHello = htmlspecialchars($helloLine, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$safeIntro = nl2br(htmlspecialchars($intro, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'));
$safeFooter = nl2br(htmlspecialchars($footer, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'));
$safeCtaLbl = htmlspecialchars($ctaLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$safeCtaUrl = htmlspecialchars($ctaUrl, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$htmlBody = '<!DOCTYPE html>
<html lang="fr">
<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:#ffffff;border-radius:8px;overflow:hidden;">
<tr>
<td style="background:#38378B;color:#ffffff;padding:20px 24px;font-size:18px;font-weight:600;">
<img
src="https://uslugi.fr/public/uploads/img/logo_mail.png"
alt="USLUGI.FR"
width="150">
</td>
</tr>
<tr>
<td style="padding:24px 24px 8px 24px;color:#111111;font-size:16px;line-height:1.5;">
' . $safeHello . '
</td>
</tr>
<tr>
<td style="padding:8px 24px 16px 24px;color:#111111;font-size:15px;line-height:1.6;">
' . $safeIntro . '
</td>
</tr>
<tr>
<td align="center" style="padding:8px 24px 24px 24px;">
<a href="' . $safeCtaUrl . '" target="_blank"
style="display:inline-block;text-decoration:none;padding:12px 22px;border-radius:999px;background:#E03C3F;color:#ffffff;font-weight:600;">
' . $safeCtaLbl . '
</a>
</td>
</tr>
<tr>
<td style="padding:0 24px 24px 24px;color:#111111;font-size:14px;line-height:1.6;">
' . $safeFooter . '
</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') . ' USLUGI.FR
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>';
// ===== Envoi via helper uslugi_mail() =====
try {
[$ok, $err] = uslugi_mail($toEmail, $subject, $htmlBody, $textBody);
if (!$ok && $err) {
error_log('uslugi_mail(new_message) error: ' . $err);
}
} catch (\Throwable $e) {
error_log('Exception uslugi_mail(new_message): ' . $e->getMessage());
}
}
+241
View File
@@ -0,0 +1,241 @@
<?php
// /public/paiement_simule.php
require_once __DIR__ . '/includes/init_praticien.php';
if (isset($pdo) && $pdo instanceof PDO) {
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
}
function logErr(Throwable $e): void {
$info = ($e instanceof PDOException && isset($e->errorInfo)) ? json_encode($e->errorInfo, JSON_UNESCAPED_UNICODE) : '';
@file_put_contents('/tmp/pay_errors.log',
'['.date('Y-m-d H:i:s')."] ".$e->getMessage()." ".$info."\n",
FILE_APPEND
);
}
function colExists(PDO $pdo, string $table, string $col): bool {
$sql = "SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1";
$st = $pdo->prepare($sql);
$st->execute([$table,$col]);
return (bool)$st->fetchColumn();
}
/**
* Retourne la date de fin d’accès si un abonnement est encore actif (manual/auto),
* en priorité via billing_subscriptions, sinon via praticien.date_expiration.
*
* @return array{until:string|null, mode:string|null, status:string|null, sub_id:string|null}
*/
function activeAccessInfo(PDO $pdo, int $praticien_id): array {
$out = ['until'=>null, 'mode'=>null, 'status'=>null, 'sub_id'=>null];
// 1) via billing_subscriptions si dispo
if (colExists($pdo, 'billing_subscriptions', 'praticien_id') && colExists($pdo, 'billing_subscriptions', 'current_period_end')) {
$sql = "SELECT billing_mode, status, provider_subscription_id, current_period_end
FROM billing_subscriptions
WHERE praticien_id = ?
AND (provider IS NULL OR provider = 'stripe')
ORDER BY id DESC
LIMIT 1";
$st = $pdo->prepare($sql);
$st->execute([$praticien_id]);
$row = $st->fetch(PDO::FETCH_ASSOC);
if ($row) {
$cpe = trim((string)($row['current_period_end'] ?? ''));
if ($cpe !== '') {
$ts = strtotime($cpe);
if ($ts !== false && $ts > time()) {
$out['until'] = $cpe;
$out['mode'] = (string)($row['billing_mode'] ?? '');
$out['status'] = (string)($row['status'] ?? '');
$out['sub_id'] = (string)($row['provider_subscription_id'] ?? '');
return $out;
}
}
}
}
// 2) fallback via praticien.date_expiration
if (colExists($pdo, 'praticien', 'date_expiration')) {
$st = $pdo->prepare("SELECT date_expiration FROM praticien WHERE id = ? LIMIT 1");
$st->execute([$praticien_id]);
$de = (string)($st->fetchColumn() ?: '');
if ($de !== '') {
$ts = strtotime($de);
if ($ts !== false && $ts > time()) {
$out['until'] = $de;
return $out;
}
}
}
return $out;
}
$err = '';
$msg = '';
// Données nécessaires déjà posées en session avant d’arriver ici
$praticien_id = $_SESSION['reference_id'] ?? null;
$abonnement_id = $_SESSION['paiement_abonnement_id'] ?? null;
$prix = $_SESSION['paiement_prix'] ?? null;
// Nouveau: mode de facturation (manual|auto) + provider_plan_id (Stripe price_id pour auto)
$billing_mode = (string)($_SESSION['paiement_billing_mode'] ?? 'manual');
$billing_mode = in_array($billing_mode, ['manual','auto'], true) ? $billing_mode : 'manual';
$provider = (string)($_SESSION['paiement_provider'] ?? 'stripe');
$provider_plan = (string)($_SESSION['paiement_provider_plan_id'] ?? ''); // price_id si auto
if (!$praticien_id || !$abonnement_id || $prix === null) {
$err = $translations['erreur_donnees_paiement'] ?? 'Données de paiement manquantes.';
}
// Sécurité: si mode auto mais pas de plan provider → erreur
if (!$err && $billing_mode === 'auto' && $provider === 'stripe' && $provider_plan === '') {
$err = $translations['erreur_abonnement_auto'] ?? "Configuration manquante pour le renouvellement automatique.";
}
/* CSRF */
if (empty($_SESSION['csrf_pay'])) {
$_SESSION['csrf_pay'] = bin2hex(random_bytes(32));
}
$CSRF = $_SESSION['csrf_pay'] ?? '';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$err) {
try {
// Vérif CSRF
if (!hash_equals($CSRF, $_POST['csrf'] ?? '')) {
throw new RuntimeException('CSRF');
}
// Méthode choisie
$allowed = ['carte','paypal','virement'];
$methode = (string)($_POST['methode'] ?? '');
if (!in_array($methode, $allowed, true)) {
throw new InvalidArgumentException('BAD_METHOD');
}
// Carte bancaire → AVANT Stripe : bloquer si accès déjà actif
if ($methode === 'carte') {
$pid = (int)$praticien_id;
// Si un accès est encore actif, on bloque toute nouvelle tentative (manual/auto)
$active = activeAccessInfo($pdo, $pid);
if (!empty($active['until'])) {
// Message clair avec date (tu peux remplacer par une clé $translations si tu veux)
$until = $active['until'];
$modeTxt = ($active['mode'] === 'auto') ? 'auto' : (($active['mode'] === 'manual') ? 'manual' : '');
$extra = $modeTxt ? " (mode: ".$modeTxt.")" : '';
$err = ($translations['abonnement_deja_actif'] ?? "Un abonnement est déjà actif jusqu'au {date}. Vous ne pouvez pas en souscrire un autre avant expiration.")
;
$err = strtr($err, ['{date}' => $until]) . $extra;
}
if ($err) {
// on ne redirige pas, on affiche l’erreur dans la page
} else {
// OK -> redirection vers l’endpoint Stripe (création session Checkout)
// L’endpoint utilisera les variables de session (praticien_id, abonnement_id, prix, billing_mode, provider_plan_id)
$_SESSION['checkout_attempt_id'] = bin2hex(random_bytes(16));
header('Location: /plata/actions/create_checkout.php', true, 303);
exit;
}
}
// PayPal / Virement → à implémenter (on ne met pas à jour la BD ici)
if ($methode === 'paypal') {
$msg = $translations['paypal_pas_dispo'] ?? "PayPal n'est pas encore disponible. Merci de choisir Carte bancaire.";
} elseif ($methode === 'virement') {
$msg = $translations['virement_pas_dispo'] ?? "Virement non disponible en ligne. Contacte le support pour un règlement par virement.";
}
} catch (Throwable $e) {
logErr($e);
$code = $e->getMessage();
if ($code === 'CSRF') { $err = $translations['session_expiree'] ?? 'Session expirée. Recharge la page.'; }
elseif ($code === 'BAD_METHOD') { $err = $translations['methode_invalide'] ?? 'Méthode de paiement invalide.'; }
else { $err = $translations['erreur_traitement'] ?? 'Erreur serveur pendant le paiement.'; }
}
}
// Affichage
$montantAff = number_format((float)$prix, 2, ',', ' ') . ' €';
// Titre différent si auto
if ($billing_mode === 'auto') {
$titrePaiement = strtr(
$translations['paiement_abonnement_auto_de_montant'] ?? 'Abonnement automatique de {montant}',
['{montant}' => $montantAff]
);
} else {
$titrePaiement = strtr(
$translations['paiement_de_montant'] ?? 'Paiement de {montant}',
['{montant}' => $montantAff]
);
}
?>
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title><?= htmlspecialchars($translations['paiement_en_ligne_title'] ?? 'Paiement en ligne') ?></title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="/css/bootstrap.min.css" rel="stylesheet">
<style>
:root{ --brand-blue:#38378B; --brand-red:#E03C3F; --brand-blue-hover:#2f2e73; --page-blue:#8CA9FF; --text-dark:#111; --border:#e5e7eb; --radius:18px; --shadow:0 8px 20px rgba(0,0,0,.08); }
html,body{height:100%}
body{background:var(--page-blue); font-family:'Montserrat',system-ui,-apple-system,Segoe UI,Roboto,'Helvetica Neue',Arial; color:var(--text-dark);}
h3{color:var(--text-dark); font-weight:700;}
.card{border:1px solid var(--border); border-radius:var(--radius); box-shadow:var(--shadow); background:#fff;}
.form-label{font-weight:600; color:#0f172a;}
.form-select,.form-control{border-radius:12px; border-color:#d1d5db;}
.form-select:focus,.form-control:focus{border-color:var(--brand-blue); box-shadow:0 0 0 .25rem rgba(56,55,139,.15);}
.btn-primary{background:var(--brand-red); border-color:var(--brand-red); border-radius:999px; font-weight:600; box-shadow:0 8px 22px rgba(0,0,0,.18);}
.btn-primary:hover,.btn-primary:focus{background:var(--brand-red); border-color:var(--brand-red);}
</style>
</head>
<body class="d-flex justify-content-center align-items-center">
<div class="card p-4" style="min-width:360px; max-width:480px;">
<h3 class="mb-4 text-center"><?= htmlspecialchars($titrePaiement) ?></h3>
<?php if (!$err && $billing_mode === 'auto'): ?>
<div class="alert alert-light border" role="alert">
<?= htmlspecialchars($translations['info_abonnement_auto'] ?? "Le renouvellement automatique est activé. Vous pouvez résilier à tout moment depuis votre espace.") ?>
</div>
<?php endif; ?>
<?php if ($err): ?>
<div class="alert alert-danger" role="alert"><?= htmlspecialchars($err) ?><br>
<a href="/public/praticien/resiliation.php" class="text-decoration-none">
← <?= htmlspecialchars($translations['retour_resiliation'] ?? 'Retour à la résiliation') ?>
</a></div>
<?php endif; ?>
<?php if (!$err && $msg): ?>
<div class="alert alert-info" role="alert"><?= htmlspecialchars($msg) ?></div>
<?php endif; ?>
<form method="POST">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($CSRF, ENT_QUOTES, 'UTF-8') ?>">
<div class="mb-3">
<label for="methode" class="form-label"><?= htmlspecialchars($translations['label_methode_paiement'] ?? 'Méthode de paiement') ?></label>
<select name="methode" id="methode" class="form-select" required>
<option value="carte"><?= htmlspecialchars($translations['methode_cb'] ?? 'Carte bancaire') ?></option>
<!--<option value="paypal"><?= htmlspecialchars($translations['methode_paypal'] ?? 'PayPal') ?></option>
<option value="virement"><?= htmlspecialchars($translations['methode_virement'] ?? 'Virement') ?></option>-->
</select>
</div>
<button type="submit" class="btn btn-primary w-100">
<?= htmlspecialchars($translations['btn_payer_maintenant'] ?? 'Payer maintenant') ?>
</button>
</form>
</div>
</body>
</html>
+235
View File
@@ -0,0 +1,235 @@
<?php
require_once __DIR__ . '/../includes/init_praticien.php';
$praticien_id = (int)($_SESSION['reference_id'] ?? 0);
$lang = $_SESSION['lang'] ?? 'fr';
$fcLocale = ($lang === 'ru') ? 'ru' : 'fr';
// ✅ ne pas htmlspecialchars ici
$PAGE_TITLE = $translations['titre_agenda_page'] ?? 'Agenda';
$HEAD_EXTRA = <<<HTML
<!-- FullCalendar (bundle + toutes les locales) -->
<!--<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="/css/sweetalert2.min.css" rel="stylesheet">
<style>
/* ====== Theme léger & responsive ====== */
:root{
--card-bg:#fff;
--muted:#6c757d;
--brand:#38378B;
--accent:#e9ecef;
--text:#222;
--border:#e9ecef;
}
body{ background:#8CA9FF; color:var(--text); }
.agenda-wrap{ width:1000px; 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; }
.agenda-wrap{ width:750px; max-width:768px; }
}
</style>
HTML;
require_once __DIR__ . '/../includes/header.php';
?>
<div class="main1">
<div class="container py-5">
<div class="shadow p-4 rounded bg-light">
<h2 class="mb-4 text-center"><?= htmlspecialchars($translations['h2_mon_agenda']) ?></h2>
<div id="calendar" role="application" aria-label="calendar"></div>
<div id="emptyMsg" class="empty-state" style="display:none;">
<?= htmlspecialchars($translations['agenda_aucun_evenement'] ?? 'Aucun rendez-vous à afficher.') ?>
</div>
</div>
</div>
</div>
<footer class="site-footer">
<div class="container">
<p><?= $translations['copyr'] ?></p>
</div>
</footer>
<!-- Bootstrap JS -->
<script>
document.addEventListener('DOMContentLoaded', function () {
const calendarEl = document.getElementById('calendar');
const emptyMsgEl = document.getElementById('emptyMsg');
// Expose translations to JS
const t = <?= json_encode([
'rdv_avec' => $translations['sw_rdv_avec'],
'date' => $translations['sw_date'],
'heure' => $translations['sw_heure'],
'statut' => $translations['sw_statut'],
'gerer_rdv' => $translations['sw_gerer_rdv'],
'btn_fermer' => $translations['sw_btn_fermer'],
'err_chargement' => $translations['erreur_chargement_rdv'],
'statut_confirme' => $translations['statut_confirme'],
'statut_prevu' => $translations['statut_prevu'],
'statut_annule' => $translations['statut_annule'],
], JSON_UNESCAPED_UNICODE) ?>;
const fcLocale = <?= json_encode($fcLocale) ?>;
// Vue par défaut selon la largeur : liste sur mobile, semaine sur desktop
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, // Lundi
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' // ✅ affiche: Jour | Semaine | Mois
: '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') ?>'
},
// 👉 clé pour corriger "Liste Liste"
views: {
listDay: { buttonText: '<?= addslashes($translations['btn_jour'] ?? 'Jour') ?>' },
listWeek: { buttonText: '<?= addslashes($translations['btn_semaine'] ?? 'Semaine') ?>' }
},
// Événements
events: {
url: '/public/praticien/events.php',
method: 'POST',
extraParams: { praticien_id: <?= json_encode($praticien_id) ?> },
failure: function () {
Swal.fire({
icon: 'error',
title: 'Oops…',
text: t.err_chargement
});
}
},
// Ajuste l’état vide
loading: function(isLoading) {
if (isLoading) return;
// Quand le chargement est fini, teste s’il y a des events visibles
const hasEvents = calendar.getEvents().length > 0;
emptyMsgEl.style.display = hasEvents ? 'none' : 'block';
},
// Rendu compact des événements
eventContent: function(arg){
// Sur listView, FullCalendar gère déjà bien. En grille, compactons.
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';
// arg.timeText -> "10:00 - 10:30"
time.textContent = arg.timeText;
return { domNodes: [title, time] };
}
return true;
},
// Modale SweetAlert
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/praticien/rendez_vous.php?id=${info.event.id}" target="_blank">${t.gerer_rdv}</a>
`,
showConfirmButton: true,
confirmButtonText: t.btn_fermer
});
},
// Accessibilité basique
eventDidMount: function(info){
info.el.setAttribute('aria-label', info.event.title);
},
// Switch auto si on redimensionne
windowResize: function(view){
const mobile = window.matchMedia('(max-width: 768px)').matches;
calendar.setOption('height', mobile ? 'auto' : 680);
// Garde la vue “liste” sur mobile si l’utilisateur n’a pas changé manuellement
if (mobile && !calendar.view.type.startsWith('list')) {
calendar.changeView('listWeek');
}
}
});
calendar.render();
});
</script>
</body>
</html>
+649
View File
@@ -0,0 +1,649 @@
<?php
require_once __DIR__ . '/../includes/init_praticien.php';
require_once __DIR__ . '/../../push/push_endpoint.php';
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);
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 error: '.$e->getMessage().($m->ErrorInfo ? ' | '.$m->ErrorInfo : ''));
// retry sans ICS si c'est la PJ
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()];
}
}
// =================== Contrôles d’entrée ===================
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') out(false, 'Méthode invalide.');
$praticien_id = $_SESSION['reference_id'] ?? null;
$id = (int)($_POST['id'] ?? 0);
$statut = (string)($_POST['statut'] ?? '');
$whitelist = ['prévu','confirmé','annulé','terminé'];
if (!$praticien_id || $id <= 0 || !in_array($statut, $whitelist, true)) {
out(false, $translations['erreur_mise_a_jour'] ?? 'Erreur de mise à jour.');
}
// =================== Update DB sécurisé ===================
try {
// =========================
// SNAPSHOT AVANT UPDATE_STATUS (pour log)
// =========================
$beforeStmt = $pdo->prepare("
SELECT statut, date_rdv, duree_minutes, lieu
FROM rendez_vous
WHERE id = :id AND praticien_id = :pid
LIMIT 1
");
$beforeStmt->execute([':id'=>$id, ':pid'=>$praticien_id]);
$before = $beforeStmt->fetch(PDO::FETCH_ASSOC);
if (!$before) {
out(false, $translations['erreur_mise_a_jour'] ?? 'Erreur de mise à jour.');
}
// Mise à jour statut
$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
// =========================
$beforeJson = json_encode([
'statut' => $before['statut'],
'date_rdv' => $before['date_rdv'],
'duree_minutes' => (int)$before['duree_minutes'],
'lieu' => $before['lieu'],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$afterJson = json_encode([
'statut' => $statut,
'date_rdv' => $before['date_rdv'],
], 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
(:rid, 'update_status', :uid, 'praticien', :b, :a, NOW())
");
$logStmt->execute([
':rid' => (int)$id,
':uid' => (int)$praticien_id,
':b' => $beforeJson,
':a' => $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);
// AJOUT DE GARDE FOUS POUR CLIENT RENDEZ_VOUS
if (empty($rdv['client_id'])) {
error_log('changer_statut: client_id manquant pour RDV id='.$id);
}
// FIN DE GARDE FOUS
if (!$rdv) out(false, $translations['erreur_mise_a_jour'] ?? 'Erreur de mise à jour.');
// ✅ compte_id du client (pour compteur badge rdv)
$clientCompteId = 0;
if (!empty($rdv['client_id'])) {
$st = $pdo->prepare("SELECT id FROM compte WHERE role='client' AND reference_id=? LIMIT 1");
$st->execute([(int)$rdv['client_id']]);
$clientCompteId = (int)($st->fetchColumn() ?: 0);
}
// 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 = $rdv['prenom_client'].' '.$rdv['nom_client'];
$pratFull = $rdv['prenom_praticien'].' '.$rdv['nom_praticien'];
// ====== FORMAT (Онлайн/Очно + adresse si dispo) ======
$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 (Онлайн OU adresse, sinon Очно)
if ($lieu === 'online') {
$icsLocation = $onlineLabel; // "Онлайн"
} elseif ($lieu === 'offline') {
$icsLocation = $addr !== '' ? $addr : $cabinetLabel; // adresse si dispo, sinon "Очно"
} else {
$icsLocation = ''; // fallback neutre
}
// ======================================================
$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;
// ----- envois mails selon statut -----
$warnings=[];
// =====NOUVEAU BLOCK POUR CONTROLER LEZ RENDEZ-VOUS==============
//***************************************************************** */
// ===== HOOK SUIVI CLIENT — création/MAJ dossier + entrée auto =====
if ($statut === 'confirmé') {
// =========================
// PUSH RDV — confirmé (par praticien)
// =========================
try {
// ✅ Badge RDV : +1 pour le client (confirm)
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]);
}
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' => 'praticien',
'praticien_id' => (int)$praticien_id,
'date_rdv' => $rdv['date_rdv'],
]
);
} catch (Throwable $e) {
error_log('push rdv confirm failed: '.$e->getMessage());
}
try {
$pdo->beginTransaction();
// 1) 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();
}
// 2) Créer entrée de suivi automatique (une fois par RDV confirmé)
// On évite les doublons si reconfirmation
$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é) failed: '.$ex->getMessage());
// on n’interrompt pas le flux principal (mails etc.)
}
// ================================================
// ===== FIN HOOK INSERTION CLIENT POUR SUIVIE=====
//=================================================
// Client
$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.',
];
// Ajout format dans le corps du mail (intro)
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';
// Praticien
$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.',
];
// Ajout format pour le praticien
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 praticien)
// =========================
try {
// ✅ Badge RDV : +1 pour le client (cancel)
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]);
}
push_rdv_to_client(
$pdo,
(int)$rdv['client_id'],
$translations['push_rdv_cancel_praticien_title'] ?? 'Запись отменена',
$translations['push_rdv_cancel_praticien_body'] ?? 'Специалист отменил запись.',
'/public/client/mes_rendez_vous.php',
[
'rdv_id' => (int)$rdv['id'],
'action' => 'cancel',
'from_role' => 'praticien',
'praticien_id' => (int)$praticien_id,
'date_rdv' => $rdv['date_rdv'],
]
);
} catch (Throwable $e) {
error_log('push rdv cancel (praticien) 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 CANCEL — on met aussi la LOCATION correcte pour cohérence
$ics = !empty($rdv['uuid_ical'])
? ics_build($rdv['uuid_ical'],$start,$end,$tzStr,$summary,'',$icsLocation, $attBoth,'CANCEL','CANCELLED')
: null;
// Client
$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';
// Praticien
$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é
// =========================
try {
// ✅ Badge RDV : +1 pour le client (done)
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]);
}
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' => 'praticien',
'praticien_id' => (int)$praticien_id,
]
);
} catch (Throwable $e) {
error_log('push rdv done failed: '.$e->getMessage());
}
// Client
$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/avis.php?praticien_id='.(int)$praticien_id,
'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';
// Praticien
$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 fatal: '.$e->getMessage());
out(false, $translations['erreur_mise_a_jour'] ?? 'Erreur de mise à jour.');
}
@@ -0,0 +1,79 @@
<?php
require_once __DIR__ . '/../../includes/init_praticien.php';
header('Content-Type: application/json; charset=utf-8');
function out($ok,$msg,$extra=[]){ echo json_encode(['ok'=>$ok,'message'=>$msg]+$extra); exit; }
$praticien_id = (int)($_SESSION['reference_id'] ?? 0);
$csrf = (string)($_POST['csrf'] ?? '');
if (!hash_equals($_SESSION['cl_csrf'] ?? '', $csrf)) out(false, 'CSRF');
$dossier_id = (int)($_POST['dossier_id'] ?? 0);
if ($dossier_id<=0) out(false, 'Bad dossier');
// Owner
$st = $pdo->prepare("SELECT d.id, d.client_id FROM client_dossiers d WHERE d.id=:d AND d.praticien_id=:p AND d.archived_at IS NULL");
$st->execute([':d'=>$dossier_id, ':p'=>$praticien_id]);
$dos = $st->fetch(PDO::FETCH_ASSOC);
if(!$dos) out(false, 'Not allowed');
$date_rdv = (string)($_POST['date_rdv'] ?? '');
$type_rdv = (string)($_POST['type_rdv'] ?? 'autre');
$titre = trim((string)($_POST['titre'] ?? ''));
$notes = trim((string)($_POST['notes'] ?? ''));
$prochaine= trim((string)($_POST['prochaine_action'] ?? ''));
$duree = (int)($_POST['duree_minutes'] ?? 0);
if ($titre==='' || $notes==='') out(false, 'Champs requis');
try{
$pdo->beginTransaction();
$ins = $pdo->prepare("INSERT INTO client_suivi (dossier_id, rdv_id, date_rdv, type_rdv, titre, notes, prochaine_action, duree_minutes, prive)
VALUES (:d, NULL, :dt, :ty, :ti, :no, :pa, :du, 1)");
$ins->execute([
':d'=>$dossier_id,
':dt'=> (new DateTime($date_rdv ?: 'now'))->format('Y-m-d H:i:s'),
':ty'=> in_array($type_rdv,['online','cabinet','tel','autre'],true) ? $type_rdv : 'autre',
':ti'=> $titre,
':no'=> $notes,
':pa'=> ($prochaine !== '' ? $prochaine : null),
':du'=> ($duree > 0 ? $duree : null),
]);
$entry_id = (int)$pdo->lastInsertId();
// Upload files
if (!empty($_FILES['files']) && is_array($_FILES['files']['name'])) {
$baseDir = $_SERVER['DOCUMENT_ROOT'].'/uploads/suivi/'. $dossier_id;
if (!is_dir($baseDir)) @mkdir($baseDir, 0775, true);
$allowed = ['pdf','jpg','jpeg','png','doc','docx','txt'];
for($i=0;$i<count($_FILES['files']['name']);$i++){
$err = (int)($_FILES['files']['error'][$i] ?? UPLOAD_ERR_NO_FILE);
if ($err !== UPLOAD_ERR_OK) continue;
$name = (string)($_FILES['files']['name'][$i] ?? '');
$tmp = (string)($_FILES['files']['tmp_name'][$i] ?? '');
$size = (int)($_FILES['files']['size'][$i] ?? 0);
if ($size <= 0 || $size > 8*1024*1024) continue; // 8 Mo
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
if (!in_array($ext,$allowed,true)) continue;
$clean = preg_replace('~[^a-zA-Z0-9._-]+~','_', $name);
$new = $dossier_id.'_'.time().'_'.bin2hex(random_bytes(4)).'.'.$ext;
$dest = $baseDir.'/'.$new;
if (@move_uploaded_file($tmp, $dest)) {
$rel = '/uploads/suivi/'.$dossier_id.'/'.$new;
$mime= mime_content_type($dest) ?: null;
$pdo->prepare("INSERT INTO client_suivi_files (entry_id, path, mime, size_bytes) VALUES (:e,:p,:m,:s)")
->execute([':e'=>$entry_id, ':p'=>$rel, ':m'=>$mime, ':s'=>$size]);
}
}
}
$pdo->commit();
out(true, $translations['cl_enregistre_ok'] ?? 'Note enregistrée.');
}catch(Throwable $e){
if ($pdo->inTransaction()) $pdo->rollBack();
error_log('ajax_ajouter_note: '.$e->getMessage());
out(false, $translations['cl_enregistre_ko'] ?? 'Erreur lors de l’enregistrement.');
}
@@ -0,0 +1,80 @@
<?php
require_once __DIR__ . '/../../includes/init_praticien.php';
header('Content-Type: application/json; charset=utf-8');
function out($ok,$m,$x=[]){ echo json_encode(['ok'=>$ok,'message'=>$m]+$x); exit; }
$praticien_id = (int)($_SESSION['reference_id'] ?? 0);
$csrf=(string)($_POST['csrf'] ?? '');
if (!hash_equals($_SESSION['cl_csrf'] ?? '', $csrf)) out(false,'CSRF');
$note_id = (int)($_POST['note_id'] ?? 0);
if ($note_id<=0) out(false,'Bad note');
// Vérif propriétaire + dossier_id
$sql = "SELECT s.id, s.dossier_id, d.praticien_id
FROM client_suivi s JOIN client_dossiers d ON d.id=s.dossier_id
WHERE s.id=:id AND d.praticien_id=:p AND d.archived_at IS NULL";
$st = $pdo->prepare($sql);
$st->execute([':id'=>$note_id, ':p'=>$praticien_id]);
$own = $st->fetch(PDO::FETCH_ASSOC);
if(!$own) out(false,'Not allowed');
$date_rdv = (string)($_POST['date_rdv'] ?? '');
$type_rdv = (string)($_POST['type_rdv'] ?? 'autre');
$titre = trim((string)($_POST['titre'] ?? ''));
$notes = trim((string)($_POST['notes'] ?? ''));
$prochaine= trim((string)($_POST['prochaine_action'] ?? ''));
$duree = (int)($_POST['duree_minutes'] ?? 0);
if ($titre==='' || $notes==='') out(false,'Champs requis');
try{
$pdo->beginTransaction();
$upd = $pdo->prepare("UPDATE client_suivi
SET date_rdv=:dt, type_rdv=:ty, titre=:ti, notes=:no, prochaine_action=:pa, duree_minutes=:du
WHERE id=:id");
$upd->execute([
':dt'=> (new DateTime($date_rdv ?: 'now'))->format('Y-m-d H:i:s'),
':ty'=> in_array($type_rdv,['online','cabinet','tel','autre'],true) ? $type_rdv : 'autre',
':ti'=> $titre, ':no'=> $notes,
':pa'=> ($prochaine !== '' ? $prochaine : null),
':du'=> ($duree > 0 ? $duree : null),
':id'=> $note_id
]);
// Uploads additionnels
if (!empty($_FILES['files']) && is_array($_FILES['files']['name'])) {
$baseDir = $_SERVER['DOCUMENT_ROOT'].'/uploads/suivi/'. (int)$own['dossier_id'];
if (!is_dir($baseDir)) @mkdir($baseDir, 0775, true);
$allowed = ['pdf','jpg','jpeg','png','doc','docx','txt'];
for($i=0;$i<count($_FILES['files']['name']);$i++){
$err = (int)($_FILES['files']['error'][$i] ?? UPLOAD_ERR_NO_FILE);
if ($err !== UPLOAD_ERR_OK) continue;
$name = (string)($_FILES['files']['name'][$i] ?? '');
$tmp = (string)($_FILES['files']['tmp_name'][$i] ?? '');
$size = (int)($_FILES['files']['size'][$i] ?? 0);
if ($size <= 0 || $size > 8*1024*1024) continue; // 8 Mo
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
if (!in_array($ext,$allowed,true)) continue;
$new = (int)$own['dossier_id'].'_'.time().'_'.bin2hex(random_bytes(4)).'.'.$ext;
$dest = $baseDir.'/'.$new;
if (@move_uploaded_file($tmp, $dest)) {
$rel = '/uploads/suivi/'.(int)$own['dossier_id'].'/'.$new;
$mime= mime_content_type($dest) ?: null;
$pdo->prepare("INSERT INTO client_suivi_files (entry_id, path, mime, size_bytes) VALUES (:e,:p,:m,:s)")
->execute([':e'=>$note_id, ':p'=>$rel, ':m'=>$mime, ':s'=>$size]);
}
}
}
$pdo->commit();
out(true,'OK');
}catch(Throwable $e){
if($pdo->inTransaction()) $pdo->rollBack();
error_log('ajax_editer_note: '.$e->getMessage());
out(false,'Erreur');
}
+108
View File
@@ -0,0 +1,108 @@
<?php
require_once __DIR__ . '/../../includes/init_praticien.php';
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
$praticien_id = (int)($_SESSION['reference_id'] ?? 0);
if ($praticien_id <= 0) {
http_response_code(401);
echo json_encode(['ok'=>false,'message'=>'Unauthorized']); exit;
}
$qRaw = trim((string)($_GET['q'] ?? ''));
$hasQ = ($qRaw !== '');
$qLike = '%'.$qRaw.'%';
$page = max(1, (int)($_GET['page'] ?? 1));
$limit = 10;
$offset= ($page-1)*$limit;
try {
// ----- TOTAL
$sqlTotal = "SELECT COUNT(*) FROM client_dossiers d
JOIN client c ON c.id=d.client_id
WHERE d.praticien_id=:p AND d.archived_at IS NULL";
$paramsT = [':p'=>$praticien_id];
if ($hasQ) {
$sqlTotal .= " AND (
c.nom LIKE :q_nom
OR c.prenom LIKE :q_prenom
OR c.email LIKE :q_email
OR c.telephone LIKE :q_tel
OR d.tags LIKE :q_tags
)";
$paramsT[':q_nom'] = $qLike;
$paramsT[':q_prenom'] = $qLike;
$paramsT[':q_email'] = $qLike;
$paramsT[':q_tel'] = $qLike;
$paramsT[':q_tags'] = $qLike;
}
$stT = $pdo->prepare($sqlTotal);
foreach ($paramsT as $k=>$v) {
$stT->bindValue($k, $v, $k===':p' ? PDO::PARAM_INT : PDO::PARAM_STR);
}
$stT->execute();
$total = (int)$stT->fetchColumn();
// ----- PAGE
$sql = "SELECT d.id AS dossier_id, d.tags,
c.nom, c.prenom, c.email, c.telephone,
(SELECT MAX(rv.date_rdv) FROM rendez_vous rv
WHERE rv.client_id=d.client_id AND rv.praticien_id=d.praticien_id
AND rv.statut IN ('confirmé','terminé')) AS last_rdv,
(SELECT LEFT(s.prochaine_action, 120) FROM client_suivi s
WHERE s.dossier_id=d.id ORDER BY s.date_rdv DESC, s.id DESC LIMIT 1) AS last_note
FROM client_dossiers d
JOIN client c ON c.id=d.client_id
WHERE d.praticien_id=:p AND d.archived_at IS NULL";
$params = [':p'=>$praticien_id];
if ($hasQ) {
$sql .= " AND (
c.nom LIKE :q_nom
OR c.prenom LIKE :q_prenom
OR c.email LIKE :q_email
OR c.telephone LIKE :q_tel
OR d.tags LIKE :q_tags
)";
$params[':q_nom'] = $qLike;
$params[':q_prenom'] = $qLike;
$params[':q_email'] = $qLike;
$params[':q_tel'] = $qLike;
$params[':q_tags'] = $qLike;
}
// tri: d'abord ceux qui ont un last_rdv, puis par date desc, puis maj dossier
$sql .= " ORDER BY (last_rdv IS NULL) ASC, last_rdv DESC, d.updated_at DESC
LIMIT :lim OFFSET :off";
$st = $pdo->prepare($sql);
foreach ($params as $k=>$v) {
if ($k === ':p') $st->bindValue($k, $v, PDO::PARAM_INT);
else $st->bindValue($k, $v, PDO::PARAM_STR);
}
$st->bindValue(':lim', $limit, PDO::PARAM_INT);
$st->bindValue(':off', $offset, PDO::PARAM_INT);
$st->execute();
$items=[];
while($r=$st->fetch(PDO::FETCH_ASSOC)){
$items[]=[
'dossier_id' => (int)$r['dossier_id'],
'tags' => $r['tags'],
'client_name' => trim(($r['prenom'] ?? '').' '.($r['nom'] ?? '')),
'email' => $r['email'] ?? '',
'telephone' => $r['telephone'] ?? '',
'last_rdv_fmt' => !empty($r['last_rdv']) ? (new DateTime($r['last_rdv']))->format('d/m/Y H:i') : '',
'last_note' => $r['last_note'] ?? ''
];
}
echo json_encode(['ok'=>true,'items'=>$items,'total'=>$total,'limit'=>$limit]);
} catch (Throwable $e) {
error_log('clients/ajax_list error: '.$e->getMessage());
http_response_code(500);
echo json_encode(['ok'=>false,'message'=>'Erreur serveur']);
}
@@ -0,0 +1,70 @@
<?php
require_once __DIR__ . '/../../includes/init_praticien.php';
header('Content-Type: application/json; charset=utf-8');
function out($ok,$msg,$extra=[]){ echo json_encode(['ok'=>$ok,'message'=>$msg]+$extra); exit; }
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') out(false,'Méthode invalide');
$praticien_id = (int)($_SESSION['reference_id'] ?? 0);
$csrf = (string)($_POST['csrf'] ?? '');
if (!$praticien_id || !hash_equals($_SESSION['cl_csrf'] ?? '', $csrf)) out(false,'CSRF/Unauthorized');
$dossier_id = (int)($_POST['dossier_id'] ?? 0);
if ($dossier_id <= 0) out(false,'Requête invalide');
// Vérif propriété du dossier
$own = $pdo->prepare("SELECT 1 FROM client_dossiers WHERE id=:d AND praticien_id=:p AND archived_at IS NULL");
$own->execute([':d'=>$dossier_id, ':p'=>$praticien_id]);
if (!$own->fetchColumn()) out(false,'Accès refusé');
try{
// Récupérer chemins des fichiers pour suppression disque après commit
$filePaths = [];
$sf = $pdo->prepare("SELECT f.path
FROM client_suivi_files f
JOIN client_suivi s ON s.id=f.entry_id
WHERE s.dossier_id=:d");
$sf->execute([':d'=>$dossier_id]);
while($r = $sf->fetch(PDO::FETCH_ASSOC)){
$filePaths[] = (string)$r['path'];
}
// Récup IDs des notes
$noteIds = [];
$sn = $pdo->prepare("SELECT id FROM client_suivi WHERE dossier_id=:d");
$sn->execute([':d'=>$dossier_id]);
while($r = $sn->fetch(PDO::FETCH_ASSOC)){
$noteIds[] = (int)$r['id'];
}
$pdo->beginTransaction();
if ($noteIds) {
$ph = implode(',', array_fill(0, count($noteIds), '?'));
$delF = $pdo->prepare("DELETE FROM client_suivi_files WHERE entry_id IN ($ph)");
$delF->execute($noteIds);
$delN = $pdo->prepare("DELETE FROM client_suivi WHERE id IN ($ph)");
$delN->execute($noteIds);
}
$delD = $pdo->prepare("DELETE FROM client_dossiers WHERE id=:d");
$delD->execute([':d'=>$dossier_id]);
$pdo->commit();
// Suppression physique (best-effort)
$root = rtrim((string)$_SERVER['DOCUMENT_ROOT'],'/');
foreach ($filePaths as $rel) {
$abs = $root.$rel;
if (is_file($abs)) @unlink($abs);
}
out(true,'Dossier supprimé');
}catch(Throwable $e){
if ($pdo->inTransaction()) $pdo->rollBack();
error_log('ajax_supprimer_dossier: '.$e->getMessage());
out(false,'Erreur serveur');
}
@@ -0,0 +1,31 @@
<?php
require_once __DIR__ . '/../../includes/init_praticien.php';
header('Content-Type: application/json; charset=utf-8');
function out($ok,$m){ echo json_encode(['ok'=>$ok,'message'=>$m]); exit; }
$praticien_id = (int)($_SESSION['reference_id'] ?? 0);
$csrf=(string)($_POST['csrf'] ?? '');
if (!hash_equals($_SESSION['cl_csrf'] ?? '', $csrf)) out(false,'CSRF');
$file_id = (int)($_POST['file_id'] ?? 0);
if ($file_id<=0) out(false,'Bad file');
$sql = "SELECT f.id, f.path
FROM client_suivi_files f
JOIN client_suivi s ON s.id=f.entry_id
JOIN client_dossiers d ON d.id=s.dossier_id
WHERE f.id=:id AND d.praticien_id=:p AND d.archived_at IS NULL";
$st = $pdo->prepare($sql);
$st->execute([':id'=>$file_id, ':p'=>$praticien_id]);
$row = $st->fetch(PDO::FETCH_ASSOC);
if(!$row) out(false,'Not allowed');
$pdo->beginTransaction();
$pdo->prepare("DELETE FROM client_suivi_files WHERE id=:id")->execute([':id'=>$file_id]);
$pdo->commit();
// Supprime le fichier disque (best-effort)
$abs = rtrim((string)$_SERVER['DOCUMENT_ROOT'],'/').(string)$row['path'];
if (is_file($abs)) @unlink($abs);
out(true,'OK');
@@ -0,0 +1,63 @@
<?php
require_once __DIR__ . '/../../includes/init_praticien.php';
header('Content-Type: application/json; charset=utf-8');
function out($ok, $msg, $extra = []) {
echo json_encode(['ok'=>$ok, 'message'=>$msg] + $extra);
exit;
}
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') out(false, 'Méthode invalide');
$praticien_id = (int)($_SESSION['reference_id'] ?? 0);
$csrf = (string)($_POST['csrf'] ?? '');
if (!$praticien_id || !hash_equals($_SESSION['cl_csrf'] ?? '', $csrf)) {
out(false, 'CSRF/Unauthorized');
}
$note_id = (int)($_POST['note_id'] ?? 0);
if ($note_id <= 0) out(false, 'Requête invalide');
try {
// Vérifier que la note appartient bien au praticien
$check = $pdo->prepare("
SELECT s.id, s.dossier_id
FROM client_suivi s
JOIN client_dossiers d ON d.id = s.dossier_id
WHERE s.id = :id AND d.praticien_id = :p AND d.archived_at IS NULL
LIMIT 1
");
$check->execute([':id'=>$note_id, ':p'=>$praticien_id]);
$own = $check->fetch(PDO::FETCH_ASSOC);
if (!$own) out(false, 'Accès refusé');
// Récupérer la liste des fichiers pour suppression disque après commit
$files = [];
$sf = $pdo->prepare("SELECT id, path FROM client_suivi_files WHERE entry_id = :e");
$sf->execute([':e'=>$note_id]);
while ($row = $sf->fetch(PDO::FETCH_ASSOC)) {
$files[] = (string)$row['path'];
}
$pdo->beginTransaction();
// Supprimer lignes fichiers BDD puis la note
$pdo->prepare("DELETE FROM client_suivi_files WHERE entry_id = :e")->execute([':e'=>$note_id]);
$pdo->prepare("DELETE FROM client_suivi WHERE id = :id")->execute([':id'=>$note_id]);
$pdo->commit();
// Suppression physique (best-effort) après commit
$root = rtrim((string)$_SERVER['DOCUMENT_ROOT'], '/');
foreach ($files as $rel) {
$abs = $root.$rel;
if (is_file($abs)) @unlink($abs);
}
out(true, 'Note supprimée');
} catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
error_log('ajax_supprimer_note: '.$e->getMessage());
out(false, 'Erreur serveur');
}
+104
View File
@@ -0,0 +1,104 @@
<?php
require_once __DIR__ . '/../../includes/init_praticien.php';
require_once __DIR__ . '/../../includes/header.php';
function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
$praticien_id = (int)($_SESSION['reference_id'] ?? 0);
$dossier_id = (int)($_GET['dossier_id'] ?? 0);
if ($dossier_id<=0) { header('Location: index.php'); exit; }
// Vérif propriétaire
$ok = $pdo->prepare("SELECT d.id, d.praticien_id, c.nom, c.prenom
FROM client_dossiers d JOIN client c ON c.id=d.client_id
WHERE d.id=:d AND d.praticien_id=:p AND d.archived_at IS NULL");
$ok->execute([':d'=>$dossier_id, ':p'=>$praticien_id]);
$dos = $ok->fetch(PDO::FETCH_ASSOC);
if(!$dos){ header('Location: index.php'); exit; }
// CSRF
if (empty($_SESSION['cl_csrf'])) $_SESSION['cl_csrf'] = bin2hex(random_bytes(32));
$CSRF = $_SESSION['cl_csrf'];
?>
<div class="main1">
<div class="container py-5">
<div class="shadow p-4 rounded bg-light">
<main class="container my-4">
<div class="card p-3 shadow-sm">
<h4 class="mb-3"><?= h($translations['cl_ajouter_note_titre'] ?? 'Ajouter une note') ?></h4>
<form id="note-form" enctype="multipart/form-data" autocomplete="off">
<input type="hidden" name="csrf" value="<?= h($CSRF) ?>">
<input type="hidden" name="dossier_id" value="<?= (int)$dossier_id ?>">
<div class="row g-3">
<div class="col-12 col-md-4">
<label class="form-label"><?= h($translations['cl_date_rdv'] ?? 'Date') ?></label>
<input type="datetime-local" name="date_rdv" class="form-control" value="<?= h((new DateTime())->format('Y-m-d\TH:i')) ?>">
</div>
<div class="col-12 col-md-4">
<label class="form-label"><?= h($translations['cl_type_rdv'] ?? 'Type') ?></label>
<select class="form-select" name="type_rdv">
<option value="online"><?= h($translations['cl_type_online'] ?? 'En ligne') ?></option>
<option value="cabinet"><?= h($translations['cl_type_cabinet'] ?? 'Au cabinet') ?></option>
<option value="tel"><?= h($translations['cl_type_tel'] ?? 'Téléphone') ?></option>
<option value="autre"><?= h($translations['cl_type_autre'] ?? 'Autre') ?></option>
</select>
</div>
<div class="col-12 col-md-4">
<label class="form-label"><?= h($translations['cl_duree'] ?? 'Durée (min)') ?></label>
<input type="number" name="duree_minutes" class="form-control" min="1" max="600" value="60">
</div>
<div class="col-12">
<label class="form-label"><?= h($translations['cl_titre'] ?? 'Titre') ?></label>
<input type="text" name="titre" class="form-control" required>
</div>
<div class="col-12">
<label class="form-label"><?= h($translations['cl_notes'] ?? 'Notes') ?></label>
<textarea name="notes" class="form-control" rows="6" required></textarea>
</div>
<div class="col-12 col-md-6">
<label class="form-label"><?= h($translations['cl_prochaine_action'] ?? 'Prochaine action') ?></label>
<input type="text" name="prochaine_action" class="form-control">
</div>
<div class="col-12">
<label class="form-label"><?= h($translations['cl_pieces_jointes'] ?? 'Pièces jointes') ?></label>
<input type="file" name="files[]" class="form-control" multiple
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,.txt">
<div class="form-text">Max 8 Mo par fichier.</div>
</div>
<div class="col-12 d-flex justify-content-end gap-2">
<a href="fiche.php?dossier_id=<?= (int)$dossier_id ?>" class="btn btn-outline-secondary"><?= h($translations['cl_pagination_prec'] ?? 'Annuler') ?></a>
<button class="btn btn-primary" type="submit"><?= h($translations['cl_enregistrer'] ?? 'Enregistrer') ?></button>
</div>
</div>
</form>
</div>
</main>
</div>
</div>
</div>
<footer class="site-footer">
<div class="container">
<p><?= $translations['copyr'] ?></p>
</div>
</footer>
<script>
document.getElementById('note-form').addEventListener('submit', async (e)=>{
e.preventDefault();
const fd = new FormData(e.currentTarget);
const r = await fetch('ajax_ajouter_note.php', {method:'POST', body:fd, credentials:'same-origin'});
const j = await r.json();
if(j.ok){
window.location = 'fiche.php?dossier_id=' + encodeURIComponent(<?= (int)$dossier_id ?>);
} else {
alert(j.message || 'Erreur');
}
});
</script>
+31
View File
@@ -0,0 +1,31 @@
<?php
require_once __DIR__ . '/../../includes/init_praticien.php';
$praticien_id = (int)($_SESSION['reference_id'] ?? 0);
$file_id = (int)($_GET['id'] ?? 0);
if ($praticien_id<=0 || $file_id<=0) { http_response_code(400); exit('Bad request'); }
// Vérifie la propriété via jointure
$sql = "SELECT f.path, f.mime, f.size_bytes
FROM client_suivi_files f
JOIN client_suivi s ON s.id=f.entry_id
JOIN client_dossiers d ON d.id=s.dossier_id
WHERE f.id=:id AND d.praticien_id=:p AND d.archived_at IS NULL
LIMIT 1";
$st = $pdo->prepare($sql);
$st->execute([':id'=>$file_id, ':p'=>$praticien_id]);
$row = $st->fetch(PDO::FETCH_ASSOC);
if (!$row) { http_response_code(403); exit('Forbidden'); }
$rel = (string)$row['path'];
$abs = rtrim((string)$_SERVER['DOCUMENT_ROOT'], '/').$rel;
if (!is_file($abs) || !is_readable($abs)) { http_response_code(404); exit('Not found'); }
$mime = $row['mime'] ?: 'application/octet-stream';
$dlName = basename($rel);
header('Content-Type: '.$mime);
header('Content-Length: '.filesize($abs));
header('Content-Disposition: attachment; filename="'.rawurlencode($dlName).'"');
header('X-Content-Type-Options: nosniff');
readfile($abs);
+171
View File
@@ -0,0 +1,171 @@
<?php
require_once __DIR__ . '/../../includes/init_praticien.php';
require_once __DIR__ . '/../../includes/header.php';
function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
$praticien_id = (int)($_SESSION['reference_id'] ?? 0);
$note_id = (int)($_GET['note_id'] ?? 0);
if ($note_id<=0) { header('Location: index.php'); exit; }
// Vérif propriétaire + récupérer dossier_id pour le retour
$sql = "SELECT s.*, d.praticien_id, d.id AS dossier_id
FROM client_suivi s
JOIN client_dossiers d ON d.id=s.dossier_id
WHERE s.id=:id AND d.praticien_id=:p AND d.archived_at IS NULL";
$st = $pdo->prepare($sql);
$st->execute([':id'=>$note_id, ':p'=>$praticien_id]);
$note = $st->fetch(PDO::FETCH_ASSOC);
if (!$note) { header('Location: index.php'); exit; }
$stF = $pdo->prepare("SELECT id, path, mime, size_bytes FROM client_suivi_files WHERE entry_id=:e ORDER BY id ASC");
$stF->execute([':e'=>$note_id]);
$files = $stF->fetchAll(PDO::FETCH_ASSOC);
if (empty($_SESSION['cl_csrf'])) $_SESSION['cl_csrf'] = bin2hex(random_bytes(32));
$CSRF = $_SESSION['cl_csrf'];
?>
<div class="main1">
<div class="container py-5">
<div class="shadow p-4 rounded bg-light">
<main class="container my-4">
<div class="card p-3 shadow-sm">
<h4 class="mb-3"><?= h($translations['modifier'] ?? 'Modifier') ?></h4>
<form id="edit-form" enctype="multipart/form-data">
<input type="hidden" name="csrf" value="<?= h($CSRF) ?>">
<input type="hidden" name="note_id" value="<?= (int)$note_id ?>">
<div class="row g-3">
<div class="col-12 col-md-4">
<label class="form-label"><?= h($translations['cl_date_rdv'] ?? 'Date') ?></label>
<input type="datetime-local" name="date_rdv" class="form-control"
value="<?= h((new DateTime($note['date_rdv']))->format('Y-m-d\TH:i')) ?>">
</div>
<div class="col-12 col-md-4">
<label class="form-label"><?= h($translations['cl_type_rdv'] ?? 'Type') ?></label>
<select class="form-select" name="type_rdv">
<?php foreach([''.$translations['cl_type_online'].'',''.$translations['cl_type_cabinet'].'',''.$translations['cl_type_tel'].'',''.$translations['cl_type_autre'].''] as $opt): ?>
<option value="<?= $opt ?>" <?= $note['type_rdv']===$opt?'selected':'' ?>><?= h($opt) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12 col-md-4">
<label class="form-label"><?= h($translations['cl_duree'] ?? 'Durée (min)') ?></label>
<input type="number" name="duree_minutes" class="form-control" min="1" max="600" value="<?= h((string)($note['duree_minutes'] ?? '')) ?>">
</div>
<div class="col-12">
<label class="form-label"><?= h($translations['cl_titre'] ?? 'Titre') ?></label>
<input type="text" name="titre" class="form-control" required value="<?= h($note['titre']) ?>">
</div>
<div class="col-12">
<label class="form-label"><?= h($translations['cl_notes'] ?? 'Notes') ?></label>
<textarea name="notes" class="form-control" rows="6" required><?= h($note['notes']) ?></textarea>
</div>
<div class="col-12 col-md-6">
<label class="form-label"><?= h($translations['cl_prochaine_action'] ?? 'Prochaine action') ?></label>
<input type="text" name="prochaine_action" class="form-control" value="<?= h((string)$note['prochaine_action']) ?>">
</div>
<div class="col-12">
<label class="form-label"><?= h($translations['cl_pieces_jointes'] ?? 'Pièces jointes') ?></label>
<?php if($files): ?>
<ul class="list-unstyled">
<?php foreach($files as $f): $name = basename((string)$f['path']); ?>
<li class="d-flex align-items-center gap-2 mb-1">
<span class="small"><?= h($name) ?></span>
<a class="btn btn-outline-secondary btn--no-shadow" href="download.php?id=<?= (int)$f['id'] ?>">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16">
<path d="M.5 9.9a.5.5 0 0 1 .5.5V13a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V10.4a.5.5 0 0 1 1 0V13a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V10.4a.5.5 0 0 1 .5-.5z"/>
<path d="M7.646 10.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 9.293V1.5a.5.5 0 0 0-1 0v7.793L5.354 7.146a.5.5 0 1 0-.708.708l3 3z"/>
</svg>
</a>
<button type="button" class="btn btn-outline-danger btn--no-shadow"
data-file="<?= (int)$f['id'] ?>">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16">
<path d="M5.5 5.5A.5.5 0 0 1 6 5h4a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-1 0V6h-3v6.5a.5.5 0 0 1-1 0v-7z"/>
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1 0-2h3l.5-.5h4l.5.5h3a1 1 0 0 1 1 1zM4.118 4l.82 9.823A1 1 0 0 0 5.933 15h4.134a1 1 0 0 0 .995-.823L11.882 4H4.118z"/>
</svg>
</button>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<input type="file" name="files[]" class="form-control" multiple accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,.txt">
<div class="form-text"><?= $translations['8_max']?></div>
</div>
<div class="col-12 d-flex justify-content-end gap-2">
<button class="btn btn-primary" type="submit"><?= h($translations['enregistrer'] ?? 'Enregistrer') ?></button>
<a href="fiche.php?dossier_id=<?= (int)$note['dossier_id'] ?>" class="btn btn-outline-secondary"><?= h($translations['cl_pagination_prec'] ?? 'Annuler') ?></a>
<button type="button" id="btn-delete-note" class="btn btn-outline-danger btn--no-shadow">
<?= h($translations['supprimer'] ?? 'Supprimer') ?>
</button>
</div>
</div>
</form>
</div>
</main>
</div>
</div>
</div>
<footer class="site-footer">
<div class="container">
<p><?= $translations['copyr'] ?></p>
</div>
</footer>
<script>
document.getElementById('btn-delete-note')?.addEventListener('click', async ()=>{
const res = await Swal.fire({
title: <?= json_encode($translations['cl_confirmer_suppression']) ?>,
text: <?= json_encode($translations['cl_action_irreversible']) ?>,
icon: 'warning',
showCancelButton: true,
confirmButtonText: <?= json_encode($translations['btn_supprimer']) ?>,
cancelButtonText: <?= json_encode($translations['btn_annuler']) ?>
});
if (!res.isConfirmed) return;
const fd = new FormData();
fd.append('csrf', '<?= h($CSRF) ?>');
fd.append('note_id', '<?= (int)$note['id'] ?>');
const r = await fetch('ajax_supprimer_note.php', { method:'POST', body:fd, credentials:'same-origin' });
const j = await r.json();
if (j.ok) {
location.href = 'fiche.php?dossier_id=<?= (int)$note['dossier_id'] ?>';
} else {
Swal.fire('Oops', j.message || 'Erreur', 'error');
}
});
</script>
<script>
document.getElementById('edit-form').addEventListener('submit', async (e)=>{
e.preventDefault();
const fd = new FormData(e.currentTarget);
const r = await fetch('ajax_editer_note.php',{method:'POST', body:fd, credentials:'same-origin'});
const j = await r.json();
if(j.ok){
location.href = 'fiche.php?dossier_id=<?= (int)$note['dossier_id'] ?>';
}else{
alert(j.message||'Erreur');
}
});
document.addEventListener('click', async (e)=>{
const b = e.target.closest('button[data-file]');
if(!b) return;
if(!confirm('Supprimer ce fichier ?')) return;
const fd = new FormData();
fd.append('csrf', '<?= h($CSRF) ?>');
fd.append('file_id', b.getAttribute('data-file'));
const r = await fetch('ajax_supprimer_fichier.php',{method:'POST', body:fd, credentials:'same-origin'});
const j = await r.json();
if(j.ok) location.reload(); else alert(j.message||'Erreur');
});
</script>
+287
View File
@@ -0,0 +1,287 @@
<?php
require_once __DIR__ . '/../../includes/init_praticien.php';
function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
function humanSize($bytes){
$u=['B','KB','MB','GB']; $i=0;
while($bytes>=1024 && $i<count($u)-1){ $bytes/=1024; $i++; }
return number_format($bytes, $i?1:0, ',', ' ').' '.$u[$i];
}
function fmtDT(?string $s, string $format='d/m/Y H:i'): string {
$s = trim((string)$s);
if ($s === '' || $s === '0000-00-00' || $s === '0000-00-00 00:00:00') return '—';
try { return (new DateTime($s))->format($format); } catch(Throwable $e){ return '—'; }
}
$praticien_id = (int)($_SESSION['reference_id'] ?? 0);
$dossier_id = (int)($_GET['dossier_id'] ?? 0);
if ($praticien_id<=0 || $dossier_id<=0) { header('Location: index.php'); exit; }
/* ----- Dossier + Client (vérif propriétaire) ----- */
$st = $pdo->prepare("
SELECT
d.id, d.praticien_id, d.client_id, d.tags,
c.nom, c.prenom, c.email, c.telephone, c.adresse, c.ville, c.code_postal, c.date_naissance
FROM client_dossiers d
JOIN client c ON c.id = d.client_id
WHERE d.id = :d AND d.praticien_id = :p AND d.archived_at IS NULL
LIMIT 1
");
$st->execute([':d'=>$dossier_id, ':p'=>$praticien_id]);
$dos = $st->fetch(PDO::FETCH_ASSOC);
if(!$dos){ header('Location: index.php'); exit; }
/* ----- Historique RDV ----- */
$stR = $pdo->prepare("
SELECT date_rdv, duree_minutes, lieu, statut
FROM rendez_vous
WHERE praticien_id = :p AND client_id = :c AND (statut = 'terminé' OR statut = 'confirmé')
ORDER BY date_rdv DESC
LIMIT 100
");
$stR->execute([':p'=>$praticien_id, ':c'=>$dos['client_id']]);
$rdvs = $stR->fetchAll(PDO::FETCH_ASSOC);
/* ----- Notes + fichiers ----- */
$stN = $pdo->prepare("
SELECT s.*
FROM client_suivi s
WHERE s.dossier_id = :d
ORDER BY s.date_rdv DESC, s.id DESC
");
$stN->execute([':d'=>$dossier_id]);
$notes = $stN->fetchAll(PDO::FETCH_ASSOC);
$filesByEntry = [];
if ($notes) {
$ids = array_column($notes, 'id');
$ph = implode(',', array_fill(0, count($ids), '?'));
$stF = $pdo->prepare("
SELECT f.id, f.entry_id, f.path, f.mime, f.size_bytes
FROM client_suivi_files f
WHERE f.entry_id IN ($ph)
ORDER BY f.id ASC
");
$stF->execute($ids);
while($f = $stF->fetch(PDO::FETCH_ASSOC)){
$filesByEntry[(int)$f['entry_id']][] = $f;
}
}
/* ----- CSRF pour actions (si besoin) ----- */
if (empty($_SESSION['cl_csrf'])) $_SESSION['cl_csrf'] = bin2hex(random_bytes(32));
$CSRF = $_SESSION['cl_csrf']; ?>
<form id="csrf-form" class="d-none">
<input type="hidden" name="csrf" value="<?= h($CSRF) ?>">
</form>
<?php
/* Initiales avatar */
$initiales = mb_strtoupper(
mb_substr((string)$dos['prenom'],0,1,'UTF-8').
mb_substr((string)$dos['nom'],0,1,'UTF-8'),
'UTF-8'
);
$fullName = trim(($dos['prenom'] ?? '').' '.($dos['nom'] ?? ''));
require_once __DIR__ . '/../../includes/header.php';
?>
<div class="main1">
<div class="container py-5">
<div class="shadow p-4 rounded bg-light">
<main class="container my-4">
<div class="row g-3">
<!-- ===== COLONNE GAUCHE : Identité client ===== -->
<div class="col-12">
<div class="card p-3 shadow-sm">
<h4 class="mb-3"><?= h($translations['cl_identite'] ?? 'Identité') ?></h4>
<div class="d-flex align-items-center gap-3">
<!--<div class="avatar"><?= h($initiales) ?></div>-->
<div>
<div class="fw-bold fs-5"><?= h($fullName) ?></div>
<div class="text-muted">
<?= h($dos['email']??'') ?><?= !empty($dos['telephone']) ? ' - '.h($dos['telephone']) : '' ?>
</div>
<?php
$addr = trim(($dos['adresse']??'').' '.($dos['code_postal']??'').' '.($dos['ville']??''));
if ($addr !== ''):
?>
<div class="text-muted"><?= h($addr) ?></div>
<?php endif; ?>
<?php if (!empty($dos['date_naissance'])): ?>
<div class="text-muted">
<?= h($translations['label_date_naissance'] ?? 'Date de naissance') ?> :
<?= fmtDT($dos['date_naissance'], 'd/m/Y') ?>
</div>
<?php endif; ?>
<?php if (!empty($dos['tags'])): ?>
<div class="mt-2"><span class="badge bg-light text-dark"><?= h($dos['tags']) ?></span></div>
<?php endif; ?>
</div>
</div>
<div class="mt-3 d-flex gap-2">
<a class="btn btn-outline-secondary" href="ajouter_note.php?dossier_id=<?= (int)$dossier_id ?>">
<?= h($translations['cl_btn_ajouter_note'] ?? 'Ajouter une note') ?>
</a>
<a class="btn btn-outline-secondary" href="index.php">
<?= h($translations['cl_pagination_prec'] ?? 'Retour') ?>
</a>
</div>
</div>
</div>
<!-- ===== COLONNE DROITE : RDV + Notes ===== -->
<div class="col-12">
<!-- Notes de suivi -->
<div class="card p-3 shadow-sm">
<h4 class="mb-3"><?= h($translations['cl_notes_suivi'] ?? 'Notes de suivi') ?></h4>
<?php if(!$notes): ?>
<div class="text-muted"><?= h($translations['cl_aucun_resultat'] ?? 'Aucun résultat.') ?></div>
<?php else: ?>
<?php foreach($notes as $n):
$files = $filesByEntry[(int)$n['id']] ?? [];
?>
<div class="mb-3 p-3 border rounded-3">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="fw-bold"><?= h($n['titre']) ?></div>
<div class="text-muted small">
<?= h(fmtDT($n['date_rdv'])) ?>
· <?= h($translations['cl_type_rdv'] ?? 'Type') ?>: <?= h($n['type_rdv']) ?>
<?php if(!empty($n['duree_minutes'])): ?>
· <?= h($translations['cl_duree'] ?? 'Durée') ?>: <?= (int)$n['duree_minutes'] ?> min
<?php endif; ?>
<?php if(!empty($n['prochaine_action'])): ?>
<br><b><?= h($translations['cl_prochaine_action'] ?? 'Prochaine action') ?>: <?= h($n['prochaine_action']) ?></b>
<?php endif; ?>
</div>
</div>
</div>
<div class="mt-2"><?= nl2br(h($n['notes'])) ?></div>
<?php if ($files): ?>
<div class="mt-3">
<div class="fw-semibold mb-1">📎 <?= h($translations['pieces_jointes'] ?? 'Pièces jointes') ?></div>
<ul class="list-unstyled mb-0">
<?php foreach($files as $f):
$name = basename((string)$f['path']);
?>
<li class="d-flex align-items-center gap-2 mb-1">
<span class="small"><?= h($name) ?></span>
<?php if(!empty($f['size_bytes'])): ?>
<span class="text-muted small">· <?= h(humanSize((int)$f['size_bytes'])) ?></span>
<?php endif; ?>
<a href="download.php?id=<?= (int)$f['id'] ?>" class="btn btn-outline-secondary btn-sm" aria-label="Télécharger">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path d="M.5 9.9a.5.5 0 0 1 .5.5V13a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V10.4a.5.5 0 0 1 1 0V13a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V10.4a.5.5 0 0 1 .5-.5z"/>
<path d="M7.646 10.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 9.293V1.5a.5.5 0 0 0-1 0v7.793L5.354 7.146a.5.5 0 1 0-.708.708l3 3z"/>
</svg>
</a>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<br>
<a class="btn btn-outline-secondary"
href="editer_note.php?note_id=<?= (int)$n['id'] ?>">
<?= h($translations['modifier'] ?? 'Modifier') ?>
</a>
<!-- Ajout de la classe attendue + data-note existant -->
<button type="button"
class="btn btn-outline-danger btn--no-shadow btn-del-note"
data-note="<?= (int)$n['id'] ?>">
<?= h($translations['supprimer'] ?? 'Supprimer') ?>
</button>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
<br>
<!-- Historique des rendez-vous -->
<div class="card p-3 shadow-sm mb-3">
<h4 class="mb-3"><?= h($translations['cl_histo_rdv'] ?? 'Historique des rendez-vous') ?></h4>
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th><?= h($translations['date_label'] ?? 'Date') ?></th>
<th><?= h($translations['cl_type_rdv'] ?? 'Type') ?></th>
<th><?= h($translations['cl_duree'] ?? 'Durée') ?></th>
<th><?= h($translations['cl_col_tags'] ?? 'Statut') ?></th>
</tr>
</thead>
<tbody>
<?php if(!$rdvs): ?>
<tr><td colspan="4" class="text-muted"><?= h($translations['cl_aucun_resultat'] ?? 'Aucun résultat.') ?></td></tr>
<?php else: foreach($rdvs as $r): ?>
<tr>
<td><?= h(fmtDT($r['date_rdv'])) ?></td>
<td><?= h($r['lieu']==='online'
? ($translations['cl_type_online']??'En ligne')
: ($r['lieu']==='offline' ? ($translations['cl_type_cabinet']??'Au cabinet') : ($translations['cl_type_autre']??'Autre'))) ?></td>
<td><?= (int)($r['duree_minutes']??0) ?> min</td>
<td><?= h($r['statut']) ?></td>
</tr>
<?php endforeach; endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
</main>
</div>
</div>
</div>
<footer class="site-footer">
<div class="container">
<p><?= $translations['copyr'] ?></p>
</div>
</footer>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/sweetalert2@11/dist/sweetalert2.min.css">
<script src="/js/sweetalert2@11.js"></script>
<script>
document.addEventListener('click', async (e)=>{
// Plus robuste : on accepte soit .btn-del-note soit tout élément portant data-note
const btn = e.target.closest('.btn-del-note, [data-note]');
if (!btn) return;
const noteId = btn.getAttribute('data-note') || '';
const res = await Swal.fire({
title: <?= json_encode($translations['cl_confirmer_suppression']) ?>,
text: <?= json_encode($translations['cl_action_irreversible']) ?>,
icon: 'warning',
showCancelButton: true,
confirmButtonText: <?= json_encode($translations['btn_supprimer']) ?>,
cancelButtonText: <?= json_encode($translations['btn_annuler']) ?>
});
if (!res.isConfirmed) return;
const fd = new FormData(document.getElementById('csrf-form'));
fd.append('note_id', noteId);
try{
const r = await fetch('ajax_supprimer_note.php', {
method:'POST',
body: fd,
credentials:'same-origin'
});
const j = await r.json();
if (j.ok) {
location.reload();
} else {
Swal.fire('Oops', j.message || 'Erreur', 'error');
}
}catch(err){
Swal.fire('Oops', 'Erreur réseau', 'error');
}
});
</script>
+278
View File
@@ -0,0 +1,278 @@
<?php
require_once __DIR__ . '/../../includes/init_praticien.php';
function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
$praticien_id = (int)($_SESSION['reference_id'] ?? 0);
if ($praticien_id <= 0) { header('Location:/'); exit; }
/* CSRF */
if (empty($_SESSION['cl_csrf'])) $_SESSION['cl_csrf'] = bin2hex(random_bytes(32));
$CSRF = $_SESSION['cl_csrf'];
/* === SSR: première page serveur === */
$limit = 10; $offset = 0;
$sqlTotal = "SELECT COUNT(*) FROM client_dossiers d
JOIN client c ON c.id=d.client_id
WHERE d.praticien_id=:p AND d.archived_at IS NULL";
$stT = $pdo->prepare($sqlTotal);
$stT->execute([':p'=>$praticien_id]);
$total = (int)$stT->fetchColumn();
/* ➜ ICI on lit prochaine_action et on l’alias en last_note */
$sql = "SELECT d.id AS dossier_id, d.tags,
c.nom, c.prenom, c.email, c.telephone,
(SELECT MAX(rv.date_rdv) FROM rendez_vous rv
WHERE rv.client_id=d.client_id AND rv.praticien_id=d.praticien_id
AND rv.statut IN ('confirmé','terminé')) AS last_rdv,
(SELECT LEFT(s.prochaine_action, 120) FROM client_suivi s
WHERE s.dossier_id=d.id
ORDER BY s.date_rdv DESC, s.id DESC LIMIT 1) AS last_note
FROM client_dossiers d
JOIN client c ON c.id=d.client_id
WHERE d.praticien_id=:p AND d.archived_at IS NULL
ORDER BY (last_rdv IS NULL) ASC, last_rdv DESC, d.updated_at DESC
LIMIT :lim OFFSET :off";
$st = $pdo->prepare($sql);
$st->bindValue(':p',$praticien_id,PDO::PARAM_INT);
$st->bindValue(':lim',$limit,PDO::PARAM_INT);
$st->bindValue(':off',$offset,PDO::PARAM_INT);
$st->execute();
$itemsSSR = $st->fetchAll(PDO::FETCH_ASSOC);
$pages = max(1, (int)ceil($total/$limit));
require_once __DIR__ . '/../../includes/header.php';
?>
<div class="main1">
<div class="container py-5">
<div class="shadow p-4 rounded bg-light">
<main class="container my-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="m-0"><?= h($translations['cl_suivi_titre'] ?? 'Suivi clients') ?></h2>
<div>
<input id="search" type="search" class="form-control" style="min-width:260px"
placeholder="<?= h($translations['cl_recherche_placeholder'] ?? 'Rechercher (nom, email, téléphone, tag)…') ?>">
</div>
</div>
<div class="table-responsive mc-ready">
<table id="table_rdv" class="table table-striped align-middle text-center">
<thead>
<tr>
<th><?= h($translations['cl_col_client'] ?? 'Client') ?></th>
<th><?= h($translations['cl_col_dernier_rdv'] ?? 'Dernier RDV') ?></th>
<th><?= h($translations['cl_prochaine_action'] ?? 'Prochaine action') ?></th>
<th class="text-center"><?= h($translations['cl_actions'] ?? 'Actions') ?></th>
</tr>
</thead>
<tbody id="list-body">
<?php if (!$itemsSSR): ?>
<tr><td colspan="4"><?= h($translations['cl_aucun_resultat'] ?? 'Aucun résultat.') ?></td></tr>
<?php else: ?>
<?php foreach($itemsSSR as $r):
$last = !empty($r['last_rdv']) ? (new DateTime($r['last_rdv']))->format('d/m/Y H:i') : '';
$name = trim(($r['prenom'] ?? '').' '.($r['nom'] ?? ''));
?>
<tr>
<td data-label="Client">
<div class="fw-bold"><?= h($name) ?></div>
</td>
<td data-label="Dernier RDV"><?= h($last ?: '—') ?></td>
<td data-label="<?= h($translations['cl_col_prochaine_action'] ?? 'Prochaine action') ?>"><?= h($r['last_note'] ?? '') ?></td>
<td class="text-center" data-label="<?= h($translations['cl_actions'] ?? 'Actions') ?>">
<div class="row-actions d-flex justify-content-center gap-2">
<a class="btn btn-outline-secondary" href="fiche.php?dossier_id=<?= (int)$r['dossier_id'] ?>"><?= h($translations['cl_btn_infos'] ?? 'Informations') ?></a>
<a class="btn btn-outline-secondary" href="ajouter_note.php?dossier_id=<?= (int)$r['dossier_id'] ?>"><?= h($translations['cl_btn_ajouter_note'] ?? 'Ajouter une note') ?></a>
<button type="button"
class="btn btn-outline-danger"
onclick="deleteDossier(<?= (int)$r['dossier_id'] ?>, this)">
<?= h($translations['cl_btn_supprimer'] ?? 'Supprimer') ?>
</button>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<div class="d-flex justify-content-center gap-2 my-3" id="pager">
<?php for($p=1;$p<=$pages;$p++): ?>
<button type="button" class="btn btn-sm <?= $p===1?'btn-secondary':'btn-outline-secondary' ?>" data-page="<?= $p ?>"><?= $p ?></button>
<?php endfor; ?>
</div>
<form id="csrf-form" class="d-none"><input type="hidden" name="csrf" value="<?= h($CSRF) ?>"></form>
</main>
</div>
</div>
</div>
<footer class="site-footer">
<div class="container">
<p><?= $translations['copyr'] ?></p>
</div>
</footer>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/sweetalert2@11/dist/sweetalert2.min.css">
<script src="/js/sweetalert2@11.js"></script>
<?php
$I18N = [
'confirmer_suppression' => $translations['cl_confirmer_suppression'] ?? 'Confirmer la suppression ?',
'action_irreversible' => $translations['cl_action_irreversible'] ?? 'Cette action est irréversible.',
'supprimer' => $translations['cl_btn_supprimer'] ?? 'Supprimer',
'annuler' => $translations['btn_annuler'] ?? 'Annuler',
'aucun_resultat' => $translations['cl_aucun_resultat'] ?? 'Aucun résultat.',
'col_prochaine_action' => $translations['cl_col_prochaine_action'] ?? 'Prochaine action',
];
?>
<script>
// === i18n (sûr) ===
const I18N = <?= json_encode(
$I18N,
JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
) ?>;
function T(k, d){ return (Object.prototype.hasOwnProperty.call(I18N, k) ? I18N[k] : d); }
// === Utils ===
function escapeHtml(s){ return (s||'').replace(/[&<>"']/g, c=>({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c])); }
function fmtDate(s){ return s || '—'; }
function debounce(fn,ms){ let t; return (...a)=>{ clearTimeout(t); t=setTimeout(()=>fn(...a),ms); }; }
// === Rendu dynamique ===
function render(items,total,limit){
const tbody = document.getElementById('list-body');
tbody.innerHTML = '';
if(!items || !items.length){
const tr = document.createElement('tr');
const td = document.createElement('td');
td.colSpan = 4;
td.textContent = T('aucun_resultat');
tr.appendChild(td); tbody.appendChild(tr);
} else {
items.forEach(it=>{
const tr = document.createElement('tr');
tr.innerHTML = `
<td data-label="<?= h($translations['cl_col_client'] ?? 'Client') ?>">
<div class="fw-bold">${escapeHtml(it.client_name||'')}</div>
</td>
<td data-label="<?= h($translations['cl_col_dernier_rdv'] ?? 'Dernier RDV') ?>">${escapeHtml(fmtDate(it.last_rdv_fmt))}</td>
<td data-label="${escapeHtml(I18N.col_prochaine_action)}">${escapeHtml(it.last_note||'')}</td>
<td class="text-center" data-label="<?= h($translations['cl_actions'] ?? 'Actions') ?>">
<div class="row-actions d-flex justify-content-center gap-2">
<a class="btn btn-outline-secondary" href="fiche.php?dossier_id=${encodeURIComponent(it.dossier_id)}"><?= h($translations['cl_btn_infos'] ?? 'Informations') ?></a>
<a class="btn btn-outline-secondary" href="ajouter_note.php?dossier_id=${encodeURIComponent(it.dossier_id)}"><?= h($translations['cl_btn_ajouter_note'] ?? 'Ajouter une note') ?></a>
<button type="button"
class="btn btn-outline-danger"
onclick="deleteDossier(${Number(''+it.dossier_id)}, this)">
<?= h($translations['cl_btn_supprimer'] ?? 'Supprimer') ?>
</button>
</div>
</td>`;
tbody.appendChild(tr);
});
}
// Pager
const pager = document.getElementById('pager');
const pages = Math.max(1, Math.ceil(total/limit));
pager.innerHTML = '';
for(let p=1; p<=pages; p++){
const b=document.createElement('button');
b.type='button';
b.className='btn btn-sm '+(p===window._page?'btn-secondary':'btn-outline-secondary');
b.textContent=String(p);
b.dataset.page = String(p);
b.onclick=()=>{ window._page=p; load(); };
pager.appendChild(b);
}
}
// === Data ===
window._page = 1;
window._q = '';
async function load(){
try{
const url = `ajax_list.php?q=${encodeURIComponent(window._q)}&page=${window._page}&_=${Date.now()}`;
const r = await fetch(url, { credentials:'same-origin', cache:'no-store' });
const txt = await r.text();
let j;
try { j = JSON.parse(txt); }
catch(parseErr){ console.error('ajax_list.php non-JSON:', txt); throw new Error('Réponse invalide'); }
if(!j.ok) throw new Error(j.message||'Erreur');
render(j.items||[], j.total||0, j.limit||10);
}catch(e){
console.error('load() failed:', e);
render([], 0, 10);
}
}
// === Recherche ===
document.getElementById('search').addEventListener('input', debounce((e)=>{ window._q=e.target.value||''; window._page=1; load(); }, 300));
// === Suppression : fonction globale appelée par onclick ===
window.deleteDossier = async function(dossierId, btnEl){
try{
const confirmWithSwal = async ()=>{
if (typeof Swal !== 'undefined' && Swal.fire) {
const res = await Swal.fire({
title: T('confirmer_suppression'),
text: T('action_irreversible'),
icon: 'warning',
showCancelButton: true,
confirmButtonText: T('supprimer'),
cancelButtonText: T('annuler')
});
return res.isConfirmed;
}
return window.confirm('Supprimer ce dossier ?');
};
if (!(await confirmWithSwal())) return;
btnEl?.setAttribute('disabled','disabled');
btnEl?.classList.add('disabled');
const fd = new FormData(document.getElementById('csrf-form'));
fd.append('dossier_id', String(dossierId));
const r = await fetch('ajax_supprimer_dossier.php', {
method:'POST', body:fd, credentials:'same-origin', cache:'no-store'
});
let j;
try { j = await r.json(); }
catch(_){ const txt = await r.text(); console.error('Réponse non-JSON de ajax_supprimer_dossier.php:', txt); alert('Erreur serveur'); return; }
if (j.ok) {
const tr = btnEl?.closest('tr'); if (tr) tr.remove();
const rowsLeft = document.querySelectorAll('#list-body > tr').length;
if (rowsLeft === 0 && window._page > 1) window._page--;
await load();
if (typeof Swal !== 'undefined' && Swal.fire) {
Swal.fire({ icon:'success', title:'OK', text:'Dossier supprimé', timer:1200, showConfirmButton:false });
}
} else {
alert(j.message || 'Erreur');
}
}catch(err){
console.error(err);
alert('Erreur réseau');
}finally{
btnEl?.removeAttribute('disabled');
btnEl?.classList.remove('disabled');
}
};
// === Pager SSR (premier rendu) ===
document.getElementById('pager').addEventListener('click', (e)=>{
const b = e.target.closest('button[data-page]');
if(!b) return;
window._page = parseInt(b.dataset.page || '1', 10) || 1;
load();
});
// === Initial load ===
load();
</script>
+160
View File
@@ -0,0 +1,160 @@
<?php
require_once __DIR__ . '/../includes/init_praticien.php';
$success = $erreur = '';
$id = $_SESSION['compte_id'];
// Récupérer e-mail actuel
$stmt = $pdo->prepare("SELECT email FROM compte WHERE id = ?");
$stmt->execute([$id]);
$compte = $stmt->fetch();
if (!$compte) {
die($translations['compte_introuvable']);
}
$email_actuel = $compte['email'];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$nouvel_email = trim($_POST['email'] ?? '');
$mdp1 = $_POST['mot_de_passe'] ?? '';
$mdp2 = $_POST['confirm_mot_de_passe'] ?? '';
if (!filter_var($nouvel_email, FILTER_VALIDATE_EMAIL)) {
$erreur = $translations['erreur_email_non_valide'];
} elseif ($mdp1 && ($mdp1 !== $mdp2 || strlen($mdp1) < 8)) {
$erreur = $translations['erreur_mdp_invalides'];
} else {
// Vérifier si l'email est déjà pris (sauf si c’est le sien)
$stmt = $pdo->prepare("SELECT id FROM compte WHERE email = ? AND id != ?");
$stmt->execute([$nouvel_email, $id]);
if ($stmt->fetch()) {
$erreur = $translations['err_email_deja_utilise'];
} else {
// Mise à jour
if ($mdp1) {
$hash = password_hash($mdp1, PASSWORD_DEFAULT);
$stmt = $pdo->prepare("UPDATE compte SET email = ?, mot_de_passe = ? WHERE id = ?");
$stmt->execute([$nouvel_email, $hash, $id]);
} else {
$stmt = $pdo->prepare("UPDATE compte SET email = ? WHERE id = ?");
$stmt->execute([$nouvel_email, $id]);
}
$success = $translations['success_compte_mis_a_jour'];
// Optionnel : mettre à jour $email_actuel pour refléter l'écran
$email_actuel = $nouvel_email;
}
}
}
require_once '../includes/header.php';
?>
<div class="main1">
<div class="container py-5">
<form method="POST" class="shadow p-4 rounded bg-light">
<h2 class="text-center mb-4"><?= htmlspecialchars($translations['h2_infos_connexion']) ?></h2>
<?php if (!empty($erreur)): ?>
<div class="alert alert-danger"><?= htmlspecialchars($erreur) ?></div>
<?php endif; ?>
<?php if (!empty($success)): ?>
<div class="alert alert-success"><?= htmlspecialchars($success) ?></div>
<?php endif; ?>
<div class="mb-3">
<label class="form-label"><?= htmlspecialchars($translations['label_email']) ?></label>
<input type="email" name="email" class="form-control" value="<?= htmlspecialchars($email_actuel ?? '') ?>" required>
</div>
<div class="mb-3">
<label class="form-label"><?= htmlspecialchars($translations['label_nouveau_mdp_opt']) ?></label>
<div class="field-wrapper">
<input type="password" name="mot_de_passe" id="mot_de_passe" class="form-control"
placeholder="<?= htmlspecialchars($translations['placeholder_nouveau_mdp']) ?>">
<span class="toggle-password" onclick="toggle('mot_de_passe')">👁️</span>
</div>
<div class="strength-bar mt-2">
<div class="strength-fill"></div>
<div class="strength-fill"></div>
<div class="strength-fill"></div>
</div>
<div id="niveau-securite" class="mb-2 text-muted">
<?= htmlspecialchars($translations['securite_label']) ?> : <?= htmlspecialchars($translations['securite_vide']) ?>
</div>
</div>
<div class="mb-3">
<label class="form-label"><?= htmlspecialchars($translations['label_confirmer_mdp']) ?></label>
<div class="field-wrapper">
<input type="password" name="confirm_mot_de_passe" id="confirm_mot_de_passe" class="form-control"
placeholder="<?= htmlspecialchars($translations['placeholder_confirmer_mdp']) ?>">
<span class="toggle-password" onclick="toggle('confirm_mot_de_passe')">👁️</span>
</div>
</div>
<div class="mt-4 text-center">
<button type="submit" class="btn btn-primary">
<?= htmlspecialchars($translations['btn_mettre_a_jour']) ?>
</button>
</div>
</div>
</form>
<script>
function toggle(id) {
const el = document.getElementById(id);
el.type = (el.type === 'password') ? 'text' : 'password';
}
document.getElementById('mot_de_passe').addEventListener('input', function () {
const val = this.value;
const segments = document.querySelectorAll(".strength-fill");
const niveauText = document.getElementById("niveau-securite");
segments.forEach(s => s.style.backgroundColor = '#ccc');
// base label
const label = <?= json_encode($translations['securite_label'], JSON_UNESCAPED_UNICODE) ?>;
const faible = <?= json_encode($translations['securite_faible'], JSON_UNESCAPED_UNICODE) ?>;
const moyen = <?= json_encode($translations['securite_moyen'], JSON_UNESCAPED_UNICODE) ?>;
const fort = <?= json_encode($translations['securite_fort'], JSON_UNESCAPED_UNICODE) ?>;
const vide = <?= json_encode($translations['securite_vide'], JSON_UNESCAPED_UNICODE) ?>;
niveauText.textContent = `${label} : ${vide}`;
niveauText.classList.remove("text-danger", "text-warning", "text-success");
niveauText.classList.add("text-muted");
const longOk = val.length >= 8;
const lettre = /[A-Za-z]/.test(val);
const chiffre = /\d/.test(val);
const maj = /[A-Z]/.test(val);
const special = /[^A-Za-z0-9]/.test(val);
let force = 0;
if (longOk && lettre && chiffre) force++;
if (maj) force++;
if (special) force++;
if (force === 1) {
segments[0].style.backgroundColor = 'red';
niveauText.textContent = `${label} : ${faible}`;
niveauText.classList.replace("text-muted", "text-danger");
} else if (force === 2) {
segments[0].style.backgroundColor = 'orange';
segments[1].style.backgroundColor = 'orange';
niveauText.textContent = `${label} : ${moyen}`;
niveauText.classList.replace("text-muted", "text-warning");
} else if (force >= 3) {
segments.forEach(s => s.style.backgroundColor = 'green');
niveauText.textContent = `${label} : ${fort}`;
niveauText.classList.replace("text-muted", "text-success");
}
});
</script>
</div>
<footer class="site-footer">
<div class="container">
<p><?= $translations['copyr'] ?></p>
</div>
</footer>
</body>
</html>
+283
View File
@@ -0,0 +1,283 @@
<?php
// /public/praticien/disponibilites.php
require_once __DIR__ . '/../includes/init_praticien.php';
$praticien_id = (int) ($_SESSION['reference_id'] ?? 0);
if ($praticien_id <= 0) {http_response_code(403);exit('Forbidden');}
// Durée RDV praticien (garde-fou strict)
$stmt = $pdo->prepare("SELECT duree_rdv FROM praticien WHERE id = ?");
$stmt->execute([$praticien_id]);
$duree_minutes = (int) ($stmt->fetchColumn() ?: 0);
if ($duree_minutes <= 0) {
$duree_minutes = 30;
}
// fallback sécurité
// ---- Ajouter un créneau (POST) ----
$erreur = null;
$toMin = function (string $hhmm): ?int {
// input type="time" renvoie souvent HH:MM
if (! preg_match('/^\d{2}:\d{2}$/', $hhmm)) {
return null;
}
[$h, $m] = array_map('intval', explode(':', $hhmm));
if ($h < 0 || $h > 23 || $m < 0 || $m > 59) {
return null;
}
return $h * 60 + $m;
};
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']);
$md = $toMin($debut);
$mf = $toMin($fin);
if ($md === null || $mf === null) {
$erreur = $translations['erreur_format_heure'] ?? "Format d'heure invalide.";
} elseif ($md >= $mf) {
$erreur = $translations['erreur_debut_avant_fin'] ?? "Heure de début après l’heure de fin.";
} else {
$window = $mf - $md; // minutes
if ($window < $duree_minutes) {
$erreur = sprintf(
$translations['erreur_plage_trop_courte'] ?? "Créneau trop court (%d min). Minimum : %d min.",
$window,
$duree_minutes
);
} elseif (($window % $duree_minutes) !== 0) {
$erreur = sprintf(
$translations['erreur_plage_incompatible_duree'] ?? "Créneau incompatible. La durée doit être un multiple de %d min.",
$duree_minutes
);
} else {
$stmt = $pdo->prepare(
"INSERT INTO disponibilite (praticien_id, jour_semaine, heure_debut, heure_fin)
VALUES (?, ?, ?, ?)"
);
$stmt->execute([$praticien_id, $jour, $debut, $fin]);
header('Location: /public/praticien/disponibilites.php?ok=added', true, 303);
exit;
}
}
}
// ---- Supprimer un créneau (GET) ----
// (idéalement: POST + CSRF, on garde GET pour aller vite)
if (isset($_GET['supprimer'])) {
$id = (int) $_GET['supprimer'];
$stmt = $pdo->prepare("DELETE FROM disponibilite WHERE id = ? AND praticien_id = ?");
$stmt->execute([$id, $praticien_id]);
header('Location: /public/praticien/disponibilites.php?ok=deleted', true, 303);
exit;
}
// ---- 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',
];
// 1️⃣ Titre de la page
$PAGE_TITLE = $translations['titre_disponibilites'] ?? 'Disponibilités';
// 2️⃣ Ce qui doit aller dans <head> EN PLUS du header global
$HEAD_EXTRA = <<<HTML
<script src="/js/sweetalert2@11.js"></script>
HTML;
require_once __DIR__ . '/../includes/header.php';
?>
<div class="main1">
<div class="container py-5">
<div class="shadow p-4 rounded bg-light">
<h2 class="mb-4 text-center"><?php echo htmlspecialchars($translations['h2_disponibilites'] ?? 'Mes disponibilités') ?></h2>
<!-- Plus d'alertes Bootstrap ici : on affiche via Swal selon ?ok=... et $erreur -->
<div class="text-end">
<a href="/public/praticien/disponibilites_date.php" class="btn btn-outline-secondary mb-4">
<?php echo 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><?php echo htmlspecialchars($translations['table_jour'] ?? 'Jour') ?></th>
<th><?php echo htmlspecialchars($translations['table_debut'] ?? 'Début') ?></th>
<th><?php echo htmlspecialchars($translations['table_fin'] ?? 'Fin') ?></th>
<th><?php echo htmlspecialchars($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 htmlspecialchars($key ? ($translations[$key] ?? ucfirst($d['jour_semaine'])) : ucfirst($d['jour_semaine']));
?>
</td>
<td><?php echo htmlspecialchars(substr((string) $d['heure_debut'], 0, 5)) ?></td>
<td><?php echo htmlspecialchars(substr((string) $d['heure_fin'], 0, 5)) ?></td>
<td>
<button type="button"
class="btn btn-sm btn-danger js-del"
data-id="<?php echo (int) $d['id'] ?>">
<?php echo htmlspecialchars($translations['btn_supprimer'] ?? 'Supprimer') ?>
</button>
</td>
</tr>
<?php endforeach;else: ?>
<tr><td colspan="4"><?php echo htmlspecialchars($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"><?php echo htmlspecialchars($translations['card_ajouter_creneau'] ?? 'Ajouter un créneau') ?></h4>
<form method="POST">
<div class="mb-3">
<label class="form-label" for="jour"><?php echo htmlspecialchars($translations['label_jour'] ?? 'Jour') ?></label>
<select name="jour" id="jour" class="form-select" required>
<option value=""><?php echo htmlspecialchars($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="<?php echo htmlspecialchars($val) ?>"><?php echo htmlspecialchars($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="mb-3">
<label class="form-label" for="heure_debut"><?php echo htmlspecialchars($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"><?php echo htmlspecialchars($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">
<?php echo htmlspecialchars($translations['btn_ajouter'] ?? 'Ajouter') ?>
</button>
</form>
</div>
</div>
</div>
</div>
<script>
// --- SweetAlert2: messages succès / erreur depuis PHP ---
document.addEventListener('DOMContentLoaded', function(){
const okParam = '<?php echo isset($_GET['ok']) ? addslashes((string) $_GET['ok']) : '' ?>';
const erreurMsg = '<?php echo $erreur ? addslashes((string) $erreur) : '' ?>';
if (okParam === 'added') {
Swal.fire({
icon: 'success',
title: '<?php echo addslashes($translations["msg_creneau_ajoute"] ?? "Créneau ajouté.") ?>',
timer: 1800,
showConfirmButton: false
});
} else if (okParam === 'deleted') {
Swal.fire({
icon: 'success',
title: '<?php echo addslashes($translations["msg_creneau_supprime"] ?? "Créneau supprimé.") ?>',
timer: 1800,
showConfirmButton: false
});
}
if (erreurMsg) {
Swal.fire({
icon: 'error',
title: '<?php echo addslashes($translations["erreur_titre"] ?? "Erreur") ?>',
html: erreurMsg,
showCancelButton: true,
confirmButtonText: '<?php echo addslashes($translations["btn_modifier_profil"] ?? "Modifier mon profil") ?>',
cancelButtonText: 'OK'
}).then((result) => {
if (result.isConfirmed) {
window.location.href = '/public/praticien/modifier.php';
}
});
}
});
// --- 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: '<?php echo addslashes($translations["confirm_title"] ?? "Confirmation") ?>',
text: '<?php echo addslashes($translations["confirm_supprimer_creneau"] ?? "Supprimer ce créneau ?") ?>',
showCancelButton: true,
confirmButtonText: '<?php echo addslashes($translations["btn_da_udalit"] ?? "Oui") ?>',
cancelButtonText: '<?php echo addslashes($translations["btn_annuler"] ?? "Annuler") ?>',
reverseButtons: true
});
if (result.isConfirmed) {
// Suppression côté serveur (GET) — garde la mécanique existante
window.location.href = '?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><?php echo $translations['copyr'] ?? '&copy; ' . date('Y') ?></p>
</div>
</footer>
</body>
</html>
+249
View File
@@ -0,0 +1,249 @@
<?php
// /public/praticien/disponibilites_date.php
require_once __DIR__ . '/../includes/init_praticien.php';
$praticien_id = (int) ($_SESSION['reference_id'] ?? 0);
if ($praticien_id <= 0) {http_response_code(403);exit('Forbidden');}
// Durée RDV praticien (garde-fou strict)
$stmt = $pdo->prepare("SELECT duree_rdv FROM praticien WHERE id = ?");
$stmt->execute([$praticien_id]);
$duree_minutes = (int) ($stmt->fetchColumn() ?: 0);
if ($duree_minutes <= 0) {
$duree_minutes = 30;
}
// ---- Ajouter une dispo ponctuelle (POST) ----
$erreur = null;
$toMin = function (string $hhmm): ?int {
if (! preg_match('/^\d{2}:\d{2}$/', $hhmm)) {
return null;
}
[$h, $m] = array_map('intval', explode(':', $hhmm));
if ($h < 0 || $h > 23 || $m < 0 || $m > 59) {
return null;
}
return $h * 60 + $m;
};
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['date_dispo'], $_POST['heure_debut'], $_POST['heure_fin'])) {
$date_dispo = trim((string) $_POST['date_dispo']); // YYYY-MM-DD
$debut = trim((string) $_POST['heure_debut']); // HH:MM
$fin = trim((string) $_POST['heure_fin']); // HH:MM
// Validation date simple
$dt = DateTimeImmutable::createFromFormat('Y-m-d', $date_dispo);
if (! $dt || $dt->format('Y-m-d') !== $date_dispo) {
$erreur = $translations['erreur_format_date'] ?? "Format de date invalide.";
} else {
$md = $toMin($debut);
$mf = $toMin($fin);
if ($md === null || $mf === null) {
$erreur = $translations['erreur_format_heure'] ?? "Format d'heure invalide.";
} elseif ($md >= $mf) {
$erreur = $translations['erreur_debut_avant_fin'] ?? "Heure de début après l’heure de fin.";
} else {
$window = $mf - $md;
if ($window < $duree_minutes) {
$erreur = sprintf(
$translations['erreur_plage_trop_courte'] ?? "Créneau trop court (%d min). Minimum : %d min.",
$window,
$duree_minutes
);
} elseif (($window % $duree_minutes) !== 0) {
$erreur = sprintf(
$translations['erreur_plage_incompatible_duree'] ?? "Créneau incompatible. La durée doit être un multiple de %d min.",
$duree_minutes
);
} else {
$stmt = $pdo->prepare(
"INSERT INTO disponibilite_date (praticien_id, date_dispo, heure_debut, heure_fin)
VALUES (?, ?, ?, ?)"
);
$stmt->execute([$praticien_id, $date_dispo, $debut, $fin]);
header('Location: /public/praticien/disponibilites_date.php?ok=added', true, 303);
exit;
}
}
}
}
// ---- Supprimer une plage datée (GET) ----
if (isset($_GET['supprimer'])) {
$id = (int) $_GET['supprimer'];
$stmt = $pdo->prepare("DELETE FROM disponibilite_date WHERE id = ? AND praticien_id = ?");
$stmt->execute([$id, $praticien_id]);
header('Location: /public/praticien/disponibilites_date.php?ok=deleted', true, 303);
exit;
}
// ---- Récupérer les plages datées ----
$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);
// 1️⃣ Titre de la page
$PAGE_TITLE = $translations['titre_disponibilites_date'] ?? 'Disponibilités (dates)';
// 2️⃣ Ce qui doit aller dans <head> EN PLUS du header global
$HEAD_EXTRA = <<<HTML
<script src="/js/sweetalert2@11.js"></script>
HTML;
require_once __DIR__ . '/../includes/header.php';
?>
<div class="main1">
<div class="container py-5">
<div class="shadow p-4 rounded bg-light">
<h2 class="mb-4 text-center"><?php echo htmlspecialchars($translations['h2_disponibilites_date'] ?? 'Mes disponibilités ponctuelles') ?></h2>
<div class="d-flex justify-content-between align-items-center mb-3">
<a class="btn btn-outline-secondary"
href="/public/praticien/disponibilites.php">
<?php echo htmlspecialchars($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_rdv">
<thead class="table-light">
<tr>
<th><?php echo htmlspecialchars($translations['cl_date_rdv'] ?? 'Date') ?></th>
<th><?php echo htmlspecialchars($translations['table_debut'] ?? 'Début') ?></th>
<th><?php echo htmlspecialchars($translations['table_fin'] ?? 'Fin') ?></th>
<th><?php echo htmlspecialchars($translations['table_action'] ?? 'Action') ?></th>
</tr>
</thead>
<tbody>
<?php if ($dates): foreach ($dates as $d): ?>
<tr>
<td>
<?php echo htmlspecialchars(
date('d/m/Y', strtotime($d['date_dispo']))
) ?>
</td>
<td><?php echo htmlspecialchars(substr((string) $d['heure_debut'], 0, 5)) ?></td>
<td><?php echo htmlspecialchars(substr((string) $d['heure_fin'], 0, 5)) ?></td>
<td>
<button type="button"
class="btn btn-sm btn-danger js-del"
data-id="<?php echo (int) $d['id'] ?>">
<?php echo htmlspecialchars($translations['btn_supprimer'] ?? 'Supprimer') ?>
</button>
</td>
</tr>
<?php endforeach;else: ?>
<tr><td colspan="4"><?php echo htmlspecialchars($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"><?php echo htmlspecialchars($translations['card_ajouter_creneau_date'] ?? 'Ajouter une disponibilité ponctuelle') ?></h4>
<form method="POST">
<div class="mb-3">
<label class="form-label" for="date_dispo"><?php echo htmlspecialchars($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"><?php echo htmlspecialchars($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"><?php echo htmlspecialchars($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">
<?php echo htmlspecialchars($translations['btn_ajouter'] ?? 'Ajouter') ?>
</button>
</form>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function(){
const okParam = '<?php echo isset($_GET['ok']) ? addslashes((string) $_GET['ok']) : '' ?>';
const erreurMsg = '<?php echo $erreur ? addslashes((string) $erreur) : '' ?>';
if (okParam === 'added') {
Swal.fire({ icon:'success', title:'<?php echo addslashes($translations["msg_creneau_ajoute"] ?? "Créneau ajouté.") ?>', timer:1800, showConfirmButton:false });
} else if (okParam === 'deleted') {
Swal.fire({ icon:'success', title:'<?php echo addslashes($translations["msg_creneau_supprime"] ?? "Créneau supprimé.") ?>', timer:1800, showConfirmButton:false });
}
if (erreurMsg) {
Swal.fire({
icon: 'error',
title: '<?php echo addslashes($translations["erreur_titre"] ?? "Erreur") ?>',
html: erreurMsg,
showCancelButton: true,
confirmButtonText: '<?php echo addslashes($translations["btn_modifier_profil"] ?? "Modifier mon profil") ?>',
cancelButtonText: 'OK'
}).then((result) => {
if (result.isConfirmed) {
window.location.href = '/public/praticien/modifier.php';
}
});
}
});
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: '<?php echo addslashes($translations["confirm_title"] ?? "Confirmation") ?>',
text: '<?php echo addslashes($translations["confirm_supprimer_creneau"] ?? "Supprimer ce créneau ?") ?>',
showCancelButton: true,
confirmButtonText: '<?php echo addslashes($translations["btn_da_udalit"] ?? "Oui") ?>',
cancelButtonText: '<?php echo addslashes($translations["btn_annuler"] ?? "Annuler") ?>',
reverseButtons: true
});
if (result.isConfirmed) {
window.location.href = '?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><?php echo $translations['copyr'] ?? '&copy; ' . date('Y') ?></p>
</div>
</footer>
</body>
</html>
+47
View File
@@ -0,0 +1,47 @@
<?php
require_once __DIR__ . '/../includes/init_praticien.php';
$praticien_id = $_POST['praticien_id'] ?? 0;
if ($praticien_id != $_SESSION['reference_id']) {
http_response_code(403);
exit;
}
$stmt = $pdo->prepare("
SELECT rv.id, rv.date_rdv, rv.duree_minutes, rv.statut,
c.nom, c.prenom
FROM rendez_vous rv
JOIN client c ON rv.client_id = c.id
WHERE rv.praticien_id = ?
");
$stmt->execute([$praticien_id]);
$rdvs = $stmt->fetchAll();
$events = [];
$couleurs = [
'prévu' => '#f39c12', // orange
'confirmé' => '#27ae60', // vert
'annulé' => '#c0392b', // rouge
'terminé' => '#7f8c8d' // gris
];
foreach ($rdvs as $r) {
$start = new DateTime($r['date_rdv']);
$end = (clone $start)->modify("+{$r['duree_minutes']} minutes");
$statut = $r['statut'];
$events[] = [
'id' => $r['id'],
'title' => $r['prenom'] . ' ' . $r['nom'],
'start' => $start->format(DateTime::ATOM),
'end' => $end->format(DateTime::ATOM),
'color' => $couleurs[$statut] ?? '#999',
'statut'=> ucfirst($statut)
];
}
header('Content-Type: application/json');
echo json_encode($events);
+135
View File
@@ -0,0 +1,135 @@
<?php
require_once __DIR__ . '/../includes/init_praticien.php';
$praticien_id = $_SESSION['reference_id'];
$stmt = $pdo->prepare("SELECT * FROM paiement WHERE praticien_id = ? ORDER BY date_paiement DESC");
$stmt->execute([$praticien_id]);
$paiements = $stmt->fetchAll();
// Mappings d'affichage
$methodeMap = [
'cb' => $translations['methode_cb'] ?? 'CB',
'virement' => $translations['methode_virement'] ?? 'Virement',
'paypal' => $translations['methode_paypal'] ?? 'PayPal',
'stripe' => $translations['methode_stripe'] ?? 'Stripe',
];
$statutMap = [
'paye' => $translations['statut_paye'] ?? 'Payé',
'payé' => $translations['statut_paye'] ?? 'Payé',
'succeeded' => $translations['statut_paye'] ?? 'Payé',
'pending' => $translations['statut_en_attente'] ?? 'En attente',
'en_attente' => $translations['statut_en_attente'] ?? 'En attente',
'failed' => $translations['statut_echoue'] ?? 'Échoué',
'echoue' => $translations['statut_echoue'] ?? 'Échoué',
'échoué' => $translations['statut_echoue'] ?? 'Échoué',
'refunded' => $translations['statut_rembourse'] ?? 'Remboursé',
'rembourse' => $translations['statut_rembourse'] ?? 'Remboursé',
'remboursé' => $translations['statut_rembourse'] ?? 'Remboursé',
];
// helper badge
function badgeClassForStatus(string $raw): string {
$s = mb_strtolower($raw, 'UTF-8');
if (in_array($s, ['paye','payé','succeeded'], true)) return 'bg-success';
if (in_array($s, ['pending','en_attente'], true)) return 'bg-warning text-dark';
if (in_array($s, ['failed','echoue','échoué'], true)) return 'bg-danger';
if (in_array($s, ['refunded','rembourse','remboursé'], true)) return 'bg-secondary';
return 'bg-secondary';
}
require_once '../includes/header.php';
?>
<div class="main1">
<div class="container py-5">
<div class="shadow p-4 rounded bg-light">
<h3 class="mb-4 text-center"><?= htmlspecialchars($translations['h3_mes_factures']) ?></h3>
<?php if (empty($paiements)): ?>
<div class="alert alert-info">
<?= htmlspecialchars($translations['aucune_facture']) ?>
</div>
<?php else: ?>
<div class="table-responsive">
<table class="table table-bordered table-hover align-middle mobile-cards" id="table_rdv">
<thead class="table-light">
<tr>
<th scope="col"><?= htmlspecialchars($translations['th_numero']) ?></th>
<th scope="col"><?= htmlspecialchars($translations['th_date']) ?></th>
<th scope="col"><?= htmlspecialchars($translations['th_montant']) ?></th>
<th scope="col"><?= htmlspecialchars($translations['th_methode']) ?></th>
<th scope="col" class="text-center"><?= htmlspecialchars($translations['th_statut']) ?></th>
<th scope="col" class="text-center"><?= htmlspecialchars($translations['th_actions']) ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($paiements as $p):
$methodeRaw = mb_strtolower(trim((string)($p['methode'] ?? '')), 'UTF-8');
$methodeNorm = match ($methodeRaw) {
'cb' => 'carte',
default => $methodeRaw,
};
$libMethode = $translations['methode_' . $methodeNorm]?? ucfirst($methodeNorm);
$libStatut = $statutMap[mb_strtolower($p['statut'] ?? '', 'UTF-8')] ?? ($p['statut'] ?? '');
$badgeClass = badgeClassForStatus($p['statut'] ?? '');
?>
<tr>
<td class="text-start"><?= htmlspecialchars($translations['facture_prefix']) ?><?= str_pad((string)$p['id'], 3, '0', STR_PAD_LEFT) ?></td>
<td class="text-start"><?= date('d/m/Y', strtotime($p['date_paiement'])) ?></td>
<td class="text-start"><?= number_format((float)$p['montant'], 2, ',', ' ') ?> €</td>
<td class="text-start"><?= htmlspecialchars($libMethode) ?></td>
<td>
<span class="badge <?= $badgeClass ?>">
<?= htmlspecialchars($libStatut) ?>
</span>
</td>
<td class="text-center">
<a href="/public/praticien/factures_details.php?id=<?= (int)$p['id'] ?>" target="_blank" class="btn btn-outline-secondary btn-sm btn-table">
<?= htmlspecialchars($translations['btn_voir']) ?>
</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</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] || '');
}
});
});
// ✅ une seule source de vérité
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>
<!--*****************************FIN Affichage mobile************************-->
<footer class="site-footer">
<div class="container">
<p><?= $translations['copyr'] ?></p>
</div>
</footer>
</body>
</html>
+389
View File
@@ -0,0 +1,389 @@
<?php
require_once __DIR__ . '/../includes/init_praticien.php';
$paiement_id = intval($_GET['id'] ?? 0);
$stmt = $pdo->prepare("
SELECT p.*, a.nom AS formule
FROM paiement p
JOIN abonnement a ON p.abonnement_id = a.id
WHERE p.id = ? AND p.praticien_id = ?
");
$stmt->execute([$paiement_id, $_SESSION['reference_id']]);
$paiement = $stmt->fetch();
if (!$paiement) {
die(htmlspecialchars($translations['facture_introuvable']));
}
$methodeMap = [
'cb' => $translations['methode_carte'] ?? 'CB',
'virement' => $translations['methode_virement'] ?? 'Virement',
'paypal' => $translations['methode_paypal'] ?? 'PayPal',
'stripe' => $translations['methode_stripe'] ?? 'Stripe',
];
$statutMap = [
'paye' => $translations['statut_paye'] ?? 'Payé',
'payé' => $translations['statut_paye'] ?? 'Payé',
'succeeded' => $translations['statut_paye'] ?? 'Payé',
'pending' => $translations['statut_en_attente'] ?? 'En attente',
'en_attente' => $translations['statut_en_attente'] ?? 'En attente',
'failed' => $translations['statut_echoue'] ?? 'Échoué',
'echoue' => $translations['statut_echoue'] ?? 'Échoué',
'échoué' => $translations['statut_echoue'] ?? 'Échoué',
'refunded' => $translations['statut_rembourse'] ?? 'Remboursé',
'rembourse' => $translations['statut_rembourse'] ?? 'Remboursé',
'remboursé' => $translations['statut_rembourse'] ?? 'Remboursé',
];
$methode = strtolower($paiement['methode'] ?? '');
$translationKey = 'methode_' . $methode;
$libMethode = $translations[$translationKey] ?? ucfirst($methode);
$libStatut = $statutMap[strtolower($paiement['statut'] ?? '')] ?? ($paiement['statut'] ?? '');
$numero = str_pad((string)$paiement['id'], 6, '0', STR_PAD_LEFT);
$datePaiement = !empty($paiement['date_paiement']) ? date('d/m/Y', strtotime($paiement['date_paiement'])) : '';
$montant = number_format((float)$paiement['montant'], 2, ',', ' ') . ' €';
$formuleKey = trim((string)($paiement['formule'] ?? ''));
$formuleTxt = $formuleKey !== '' ? ($translations[$formuleKey] ?? $formuleKey) : '';
$statutLower = strtolower((string)($paiement['statut'] ?? ''));
$badgeClass =
in_array($statutLower, ['paye','payé','succeeded'], true) ? 'badge--paid' :
(in_array($statutLower, ['pending','en_attente'], true) ? 'badge--pending' :
(in_array($statutLower, ['failed','echoue','échoué'], true) ? 'badge--failed' :
(in_array($statutLower, ['refunded','rembourse','remboursé'], true) ? 'badge--refunded' : 'badge--neutral')));
?>
<!doctype html>
<html lang="<?= htmlspecialchars($_SESSION['lang'] ?? 'fr') ?>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= htmlspecialchars($translations['titre_facture']) ?> #<?= htmlspecialchars($numero) ?></title>
<style>
:root{
--brand:#38378B; /* même vibe que la capture */
--bg:#f3f4f6; /* gris clair */
--card:#ffffff;
--text:#111827;
--muted:#6b7280;
--line:#d9dde6;
--shadow:0 14px 40px rgba(17,24,39,.10);
--radius:18px;
--paid:#16a34a;
--pending:#f59e0b;
--failed:#dc2626;
--refunded:#2563eb;
--neutral:#6b7280;
--btn:#e53935; /* rouge bouton comme capture */
--btnHover:#cf2f2b;
}
*{box-sizing:border-box}
body{
margin:0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, "Noto Sans", sans-serif;
color:var(--text);
background:var(--bg);
}
.wrap{
max-width: 980px;
margin: 0 auto;
padding: 28px 18px 40px;
}
/* Carte principale */
.card{
background:var(--card);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow:hidden; /* pour arrondi de la bande top */
border: 1px solid rgba(17,24,39,.06);
}
/* Bande violette top */
.topbar{
background: var(--brand);
height: 90px;
position: relative;
}
.brand{
position:absolute;
left: 28px;
bottom: 22px;
display:flex;
align-items:center;
gap: 14px;
}
.brand__logo{
height: 46px;
width:auto;
display:block;
filter: drop-shadow(0 2px 0 rgba(0,0,0,.08));
}
/* Contenu */
.content{
padding: 34px 34px 18px;
}
.title{
margin:0 0 14px;
font-size: 28px;
font-weight: 800;
letter-spacing: .2px;
}
.subtitle{
margin:0 0 24px;
color:var(--muted);
font-size: 16px;
line-height: 1.5;
}
/* Ligne en-tête reçu */
.meta{
display:flex;
flex-wrap:wrap;
align-items:flex-start;
justify-content:space-between;
gap: 14px;
margin: 6px 0 18px;
}
.receipt-id{
display:flex;
flex-direction:column;
gap: 6px;
}
.receipt-id .k{
color:var(--muted);
font-size: 13px;
}
.receipt-id .v{
font-size: 18px;
font-weight: 800;
letter-spacing: .6px;
}
.badge{
display:inline-flex;
align-items:center;
gap: 8px;
padding: 10px 14px;
border-radius: 999px;
font-weight: 700;
font-size: 14px;
border: 1px solid rgba(17,24,39,.10);
background: #fff;
white-space:nowrap;
}
.dot{
width:10px; height:10px; border-radius:999px;
background: var(--neutral);
}
.badge--paid .dot{background:var(--paid)}
.badge--pending .dot{background:var(--pending)}
.badge--failed .dot{background:var(--failed)}
.badge--refunded .dot{background:var(--refunded)}
.badge--neutral .dot{background:var(--neutral)}
/* Bloc infos encadré (comme capture) */
.box{
border:1px solid var(--line);
border-radius: 14px;
overflow:hidden;
margin: 18px 0 24px;
}
.row{
display:grid;
grid-template-columns: 260px 1fr;
gap: 0;
padding: 16px 18px;
border-top: 1px solid var(--line);
}
.row:first-child{border-top:none}
.label{
color:var(--muted);
font-size: 16px;
padding-right: 18px;
border-right: 1px solid var(--line);
display:flex;
align-items:center;
min-height: 28px;
}
.value{
font-size: 18px;
font-weight: 700;
padding-left: 18px;
display:flex;
align-items:center;
min-height: 28px;
word-break: break-word;
}
/* Total */
.total{
display:flex;
align-items:center;
justify-content:space-between;
gap: 14px;
padding: 18px 18px;
border-top: 1px solid var(--line);
background: rgba(56,55,139,.04);
}
.total .k{ color:var(--muted); font-weight:700; }
.total .v{ font-size: 22px; font-weight: 900; }
/* Bouton */
.actions{
display:flex;
justify-content:center;
padding: 6px 0 18px;
}
.btn{
border:0;
background: var(--btn);
color:#fff;
font-weight: 800;
padding: 16px 26px;
border-radius: 999px;
cursor:pointer;
font-size: 16px;
box-shadow: 0 10px 22px rgba(229,57,53,.22);
transition: transform .05s ease, background .15s ease;
}
.btn:hover{ background: var(--btnHover); }
.btn:active{ transform: translateY(1px); }
/* Footer carte */
.card-footer{
padding: 16px 34px 28px;
color: var(--muted);
font-size: 14px;
border-top: 1px solid rgba(17,24,39,.06);
}
/* Footer page (comme capture) */
.page-footer{
text-align:center;
color:#9ca3af;
padding: 18px 6px 0;
font-size: 14px;
}
/* Mobile */
@media (max-width: 720px){
.topbar{height: 90px}
.brand{left: 18px; bottom: 18px}
.content{padding: 26px 18px 12px}
.card-footer{padding: 14px 18px 22px}
.row{grid-template-columns: 1fr; padding: 14px 14px}
.label{border-right:none; padding-right:0; margin-bottom:6px}
.value{padding-left:0}
.title{font-size:24px}
}
/* Impression */
@media print{
body{background:#fff}
.wrap{padding:0; max-width:100%}
.card{box-shadow:none; border:1px solid #e5e7eb}
.page-footer{display:none}
.btn{display:none}
.topbar{height: 90px}
}
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<div class="topbar">
<div class="brand">
<img class="brand__logo" src="/public/uploads/img/logo_mail.png" alt="Logo">
</div>
</div>
<div class="content">
<h1 class="title">
<?= htmlspecialchars($translations['titre_facture']) ?>
</h1>
<p class="subtitle">
<?= htmlspecialchars($translations['facture_prefix'] ?? 'Reçu') ?>
#<?= htmlspecialchars($numero) ?> —
<?= htmlspecialchars($translations['facture_date'] ?? 'Date') ?> : <?= htmlspecialchars($datePaiement) ?>
</p>
<div class="meta">
<div class="receipt-id">
<div class="k"><?= htmlspecialchars($translations['facture_prefix'] ?? 'Reçu') ?></div>
<div class="v">#<?= htmlspecialchars($numero) ?></div>
</div>
<div class="badge <?= $badgeClass ?>">
<span class="dot"></span>
<span><?= htmlspecialchars($libStatut) ?></span>
</div>
</div>
<div class="box">
<div class="row">
<div class="label"><?= htmlspecialchars($translations['facture_date']) ?></div>
<div class="value"><?= htmlspecialchars($datePaiement) ?></div>
</div>
<div class="row">
<div class="label"><?= htmlspecialchars($translations['facture_formule']) ?></div>
<div class="value"><?= htmlspecialchars($formuleTxt) ?></div>
</div>
<div class="row">
<div class="label"><?= htmlspecialchars($translations['facture_methode']) ?></div>
<div class="value"><?= htmlspecialchars($libMethode) ?></div>
</div>
<div class="row">
<div class="label"><?= htmlspecialchars($translations['facture_statut']) ?></div>
<div class="value"><?= htmlspecialchars($libStatut) ?></div>
</div>
<div class="total">
<div class="k"><?= htmlspecialchars($translations['facture_montant']) ?></div>
<div class="v"><?= htmlspecialchars($montant) ?></div>
</div>
</div>
<div class="actions">
<button class="btn" onclick="window.print()">
<?= htmlspecialchars($translations['btn_imprimer_facture']) ?>
</button>
</div>
</div>
<div class="card-footer">
<div>
<?= htmlspecialchars($translations['facture_note'] ?? 'Merci pour votre paiement. Conservez ce reçu pour vos archives.') ?>
</div>
</div>
</div>
<div class="page-footer">
© <?= date('Y') ?> УСЛУГИ.ФР — <?= htmlspecialchars($translations['merci'] ?? 'Спасибо.') ?>
</div>
</div>
</body>
</html>
+100
View File
@@ -0,0 +1,100 @@
<?php
require_once __DIR__ . '/../includes/init_praticien.php';
// Récupérer les infos du praticien
$stmt = $pdo->prepare("SELECT * FROM praticien WHERE id = ?");
$stmt->execute([$_SESSION['reference_id'] ?? 0]);
$praticien = $stmt->fetch();
$id = $_SESSION['reference_id'] ?? null;
if (!$id) { http_response_code(403); exit('Forbidden'); }
require_once __DIR__ . '/../includes/header.php';
?>
<div class="main1">
<div class="container py-5">
<div class="shadow p-4 rounded bg-light">
<h2 class="mb-4 text-center"><?= htmlspecialchars($translations['mes_infos_heading']) ?></h2>
<div class="card-body">
<div class="row g-4 mb-3">
<!-- Colonne gauche -->
<div class="col-12 col-sm-6">
<p><strong><?= htmlspecialchars($translations['label_nom']) ?> :</strong>
<?= htmlspecialchars($praticien['nom'] ?? '') ?></p>
<p><strong><?= htmlspecialchars($translations['label_prenom']) ?> :</strong>
<?= htmlspecialchars($praticien['prenom'] ?? '') ?></p>
<p><strong><?= htmlspecialchars($translations['label_email']) ?> :</strong>
<?= htmlspecialchars($praticien['email'] ?? '') ?></p>
<p><strong><?= htmlspecialchars($translations['label_telephone']) ?> :</strong>
<?= htmlspecialchars($praticien['telephone'] ?? '') ?></p>
<div>
<strong><?= htmlspecialchars($translations['alt_photo']) ?> :</strong><br><br>
<?php if (!empty($praticien['photo'])): ?>
<img
src="/public/uploads/<?= htmlspecialchars($praticien['photo']) ?>"
alt="<?= htmlspecialchars($translations['alt_photo']) ?>"
class="img-fluid rounded-4 shadow"
style="max-width:230px;height:auto;">
<?php else: ?>
<span class="text-muted"><?= htmlspecialchars($translations['aucune_photo'] ?? 'Aucune photo') ?></span>
<?php endif; ?>
</div>
</div>
<!-- Colonne droite -->
<div class="col-12 col-sm-6">
<p><strong><?= htmlspecialchars($translations['placeholder_siret']) ?> :</strong>
<?= htmlspecialchars($praticien['siret'] ?? '') ?></p>
<p><strong><?= htmlspecialchars($translations['label_specialite']) ?> :</strong>
<?= htmlspecialchars($praticien['specialite'] ?? '') ?></p>
<!-- Ajout tarif et moyen de paiement -->
<p><strong><?= htmlspecialchars($translations['tarif_consultation']) ?> :</strong>
<?= htmlspecialchars($praticien['titre'] ?? '') ?></p>
<p><strong><?= htmlspecialchars($translations['moyens_payement']) ?> :</strong>
<?= htmlspecialchars($praticien['disponibilite'] ?? '') ?></p>
<p><strong><?= htmlspecialchars($translations['label_ville']) ?> :</strong>
<?= htmlspecialchars($praticien['ville'] ?? '') ?></p>
<p><strong><?= htmlspecialchars($translations['label_code_postal']) ?> :</strong>
<?= htmlspecialchars($praticien['code_postal'] ?? '') ?></p>
<p><strong><?= htmlspecialchars($translations['label_adresse']) ?> :</strong><br>
<?= nl2br(htmlspecialchars($praticien['adresse'] ?? '')) ?></p>
<p><strong><?= htmlspecialchars($translations['label_presentation']) ?> :</strong><br>
<?= nl2br(htmlspecialchars($praticien['presentation'] ?? '')) ?></p>
</div>
</div>
<div class="mt-4 text-center">
<a href="/public/praticien/modifier.php" class="btn btn-primary">
<?= htmlspecialchars($translations['btn_modifier_mes_infos']) ?>
</a>
</div>
</div>
</div>
</div>
</div>
<footer class="site-footer">
<div class="container">
<p><?= $translations['copyr'] ?></p>
</div>
</footer>
<?php
// Si ton header.php ouvre <body>, pense à fermer proprement dans footer.php.
// Si tu n'as pas de footer commun, laisse comme ça.
?>
+492
View File
@@ -0,0 +1,492 @@
<?php
require_once __DIR__ . '/../includes/init_praticien.php';
$id = $_SESSION['reference_id'] ?? null;
if (!$id) { http_response_code(403); exit('Forbidden'); }
// Récupérer les données (pour affichage initial & valeurs par défaut)
$stmt = $pdo->prepare("SELECT * FROM praticien WHERE id = ?");
$stmt->execute([$id]);
$praticien = $stmt->fetch();
if (!$praticien) {
die(htmlspecialchars($translations['praticien_introuvable'] ?? 'Introuvable.'));
}
// ==== Traitement POST avant tout output ====
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$titre = trim($_POST['titre'] ?? '');
$nom = trim($_POST['nom'] ?? '');
$prenom = trim($_POST['prenom'] ?? '');
$telephone = trim($_POST['telephone'] ?? '');
$specialite = trim($_POST['specialite'] ?? '');
$adresse = trim($_POST['adresse'] ?? '');
$ville = trim($_POST['ville'] ?? '');
$code_postal = trim($_POST['code_postal'] ?? '');
$presentation = trim($_POST['presentation'] ?? '');
$duree_rdv = (int)($_POST['duree_rdv'] ?? 60);
$disponibilite = trim($_POST['disponibilite'] ?? '');
$photo = $praticien['photo'] ?? null;
// --- BLOC UPLOAD SÉCURISÉ ---
if (!empty($_FILES['photo']['tmp_name'])) {
$file = $_FILES['photo'];
$maxSize = 5 * 1024 * 1024; // 5 Mo
// 1. Vérification de la taille
if ($file['size'] > $maxSize) {
die("Erreur : Le fichier est trop volumineux (max 5 Mo).");
}
// 2. Vérification du type réel (MIME) pour bloquer les faux fichiers
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($file['tmp_name']);
$allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!in_array($mimeType, $allowedMimeTypes)) {
die("Erreur : Type de fichier non autorisé (Seuls JPG, PNG, GIF et WEBP sont acceptés).");
}
// 3. Préparation du nom et du chemin
$nom_nettoye = strtolower(preg_replace('/[^A-Za-z0-9]/', '', $nom));
$prenom_nettoye = strtolower(preg_replace('/[^A-Za-z0-9]/', '', $prenom));
$nouveau_nom_fichier = $nom_nettoye . '_' . $prenom_nettoye . '_' . time() . '.png';
$chemin_destination = __DIR__ . '/../uploads/' . $nouveau_nom_fichier;
// 4. Traitement de l'image (Conversion & Redimensionnement)
$image_source = @imagecreatefromstring(file_get_contents($file['tmp_name']));
if ($image_source) {
list($largeur_orig, $hauteur_orig) = getimagesize($file['tmp_name']);
$largeur_max = 800;
if ($largeur_orig > $largeur_max) {
$ratio = $largeur_max / $largeur_orig;
$nouvelle_largeur = $largeur_max;
$nouvelle_hauteur = (int)($hauteur_orig * $ratio);
} else {
$nouvelle_largeur = $largeur_orig;
$nouvelle_hauteur = $hauteur_orig;
}
$image_dest = imagecreatetruecolor($nouvelle_largeur, $nouvelle_hauteur);
imagealphablending($image_dest, false);
imagesavealpha($image_dest, true);
imagecopyresampled($image_dest, $image_source, 0, 0, 0, 0, $nouvelle_largeur, $nouvelle_hauteur, $largeur_orig, $hauteur_orig);
// Sauvegarde effective
if (imagepng($image_dest, $chemin_destination, 7)) {
// Suppression de l'ancienne photo si elle existe
if (!empty($praticien['photo'])) {
$ancien_chemin = __DIR__ . '/../uploads/' . $praticien['photo'];
if (file_exists($ancien_chemin)) {
unlink($ancien_chemin);
}
}
$photo = $nouveau_nom_fichier;
}
imagedestroy($image_source);
imagedestroy($image_dest);
} else {
die("Erreur : Le fichier image semble corrompu.");
}
}
// SIRET - normalisation + vérifs
$siret = preg_replace('/\D/', '', $_POST['siret'] ?? '');
if (strlen($siret) !== 14) {
die(htmlspecialchars($translations['siret_format_invalide'] ?? 'SIRET invalide (14 chiffres).'));
}
// Luhn
$luhnOk = (function($num){
$sum=0; $alt=false;
for ($i=strlen($num)-1; $i>=0; $i--){
$n=(int)$num[$i];
if($alt){ $n*=2; if($n>9)$n-=9; }
$sum+=$n; $alt=!$alt;
}
return $sum%10===0;
})($siret);
if (!$luhnOk) {
die(htmlspecialchars($translations['siret_format_invalide'] ?? 'SIRET invalide (Luhn).'));
}
// Unicité SIRET (exclure l'utilisateur courant)
$chk = $pdo->prepare("SELECT id FROM praticien WHERE siret = :s AND id <> :id LIMIT 1");
$chk->execute(['s' => $siret, 'id' => $id]);
if ($chk->fetch()) {
die(htmlspecialchars($translations['err_siret_deja_utilise'] ?? 'Ce SIRET est déjà utilisé.'));
}
// Mise à jour
$stmt = $pdo->prepare("
UPDATE praticien SET
titre = :titre,
nom = :nom,
prenom = :prenom,
telephone = :telephone,
specialite = :specialite,
adresse = :adresse,
ville = :ville,
code_postal = :code_postal,
presentation = :presentation,
photo = :photo,
disponibilite = :disponibilite,
duree_rdv = :duree_rdv,
siret = :siret
WHERE id = :id
");
$stmt->execute([
'titre' => $titre,
'nom' => $nom,
'prenom' => $prenom,
'telephone' => $telephone,
'specialite' => $specialite,
'adresse' => $adresse,
'ville' => $ville,
'code_postal' => $code_postal,
'presentation' => $presentation,
'photo' => $photo,
'disponibilite' => $disponibilite,
'duree_rdv' => $duree_rdv,
'siret' => $siret,
'id' => $id,
]);
// Redirection ABSOLUE (ou "informations.php" si même dossier)
header('Location: /public/praticien/informations.php');
exit;
}
$PAGE_TITLE = $translations['modifier_infos_title'];
$HEAD_EXTRA = <<<HTML
<style>
.message-erreur {
color: red;
font-size: 0.9em;
display: none;
margin-bottom: 10px;
}
</style>
HTML;
// ==== À partir d’ici, on peut afficher ====
require_once __DIR__ . '/../includes/header.php';
?>
<div class="main1">
<div class="container py-5">
<form id="form-praticien-edit" method="POST" enctype="multipart/form-data" class="shadow p-4 rounded bg-light">
<h2 class="text-center mb-4"><?= htmlspecialchars($translations['modifier_infos_title']) ?></h2>
<div class="mb-3">
<label class="form-label"><?= htmlspecialchars($translations['label_nom']) ?> *</label>
<input type="text" name="nom" class="form-control" value="<?= htmlspecialchars($praticien['nom']?? '') ?>" required>
</div>
<div class="mb-3">
<label class="form-label"><?= htmlspecialchars($translations['label_prenom']) ?> *</label>
<input type="text" name="prenom" class="form-control" value="<?= htmlspecialchars($praticien['prenom']?? '') ?>" required>
</div>
<div class="mb-3">
<label class="form-label"><?= htmlspecialchars($translations['label_telephone']) ?> *</label>
<input type="tel" name="telephone" id="telephone" class="form-control" value="<?= htmlspecialchars($praticien['telephone']?? '') ?>" required placeholder="+33600000000"
pattern="^\+?[1-9]\d{6,14}$"
title="<?=htmlspecialchars($translations['telephone_format_message']?? '') ?>">
</div>
<!-- Champ SIREN/SIRET -->
<div class="mb-3">
<label class="form-label"><?= htmlspecialchars($translations['placeholder_siret']) ?> *</label>
<input type="text"
name="siret"
id="siret"
class="form-control"
value="<?= htmlspecialchars($praticien['siret']?? '') ?>"
required
maxlength="17"
inputmode="numeric"
pattern="[0-9 ]+">
<div id="siret-feedback" class="message-erreur" style="display:none;"></div>
</div>
<div class="mb-3">
<label class="form-label"><?= htmlspecialchars($translations['label_specialite']) ?> *</label>
<input type="text" name="specialite" class="form-control" value="<?= htmlspecialchars($praticien['specialite']?? '') ?>" required>
</div>
<div class="mb-3">
<label class="form-label"><?= htmlspecialchars($translations['tarif_consultation']) ?></label>
<input type="text" name="titre" class="form-control" value="<?= htmlspecialchars($praticien['titre']?? '') ?>">
</div>
<div class="mb-3">
<label class="form-label"><?= htmlspecialchars($translations['moyens_payement']) ?></label>
<input type="text" name="disponibilite" class="form-control" value="<?= htmlspecialchars($praticien['disponibilite']?? '') ?>">
</div>
<div class="mb-3 position-relative">
<label class="form-label"><?= htmlspecialchars($translations['label_adresse']) ?></label>
<textarea name="adresse" id="adresse" class="form-control"><?= htmlspecialchars($praticien['adresse']?? '') ?></textarea>
<ul id="suggestions"></ul>
</div>
<div class="mb-3">
<label class="form-label"><?= htmlspecialchars($translations['label_ville']) ?></label>
<input type="text" name="ville" id="ville" class="form-control" value="<?= htmlspecialchars($praticien['ville']?? '') ?>">
</div>
<div class="mb-3">
<label class="form-label"><?= htmlspecialchars($translations['label_code_postal']) ?></label>
<input type="text" name="code_postal" id="code_postal" class="form-control" value="<?= htmlspecialchars($praticien['code_postal']?? '') ?>">
</div>
<div class="mb-3">
<label class="form-label"><?= htmlspecialchars($translations['label_presentation']) ?></label>
<textarea name="presentation" class="form-control"><?= htmlspecialchars($praticien['presentation']?? '') ?></textarea>
</div>
<div class="mb-3">
<label class="form-label"><?= htmlspecialchars($translations['label_duree_rdv']) ?></label>
<input type="number" name="duree_rdv" class="form-control" value="<?= $praticien['duree_rdv'] ?>" min="15" max="180" required>
</div>
<div class="mb-3">
<label class="form-label"><?= htmlspecialchars($translations['label_photo']) ?></label>
<input type="file" name="photo" class="form-control" accept="image/*">
</div>
<?php if ($praticien['photo']): ?>
<div class="mb-3">
<p><?= htmlspecialchars($translations['photo_actuelle']) ?></p>
<img src="/public/uploads/<?= htmlspecialchars($praticien['photo']) ?>" class="img-fluid rounded-4 shadow" style="width:230px;height:auto;">
</div>
<?php endif; ?>
<div class="mt-4 text-center">
<button type="submit" class="btn btn-primary"><?= htmlspecialchars($translations['btn_enregistrer']) ?></button>
</div>
<?= $translations['champs_obligatoire'] ?>
</div>
</form>
<script>
// === Téléphone: simple & efficace ===
const tel = document.getElementById('telephone');
// 1) Nettoyage en direct (espaces, tirets, points, parenthèses)
tel?.addEventListener('input', function () {
const pos = this.selectionStart;
this.value = this.value.replace(/[\s\-._()/]/g, '');
try { this.setSelectionRange(pos, pos); } catch(_) {}
});
// 2) Normalisation au blur
tel?.addEventListener('blur', function() {
let val = this.value.trim().replace(/[\s\-._()/]/g, '');
if (val.startsWith('00')) val = '+' + val.slice(2);
if (/^0\d{9}$/.test(val)) val = '+33' + val.substring(1);
if (val && !val.startsWith('+')) val = '+' + val;
this.value = val;
});
// 3) Au submit : applique la normalisation même si l’utilisateur n’a pas quitté le champ
tel?.form?.addEventListener('submit', function (e) {
tel.dispatchEvent(new Event('blur'));
if (!tel.checkValidity()) {
e.preventDefault();
tel.reportValidity();
tel.focus();
}
});
document.addEventListener("DOMContentLoaded", function () {
const adresseInput = document.getElementById("adresse");
const villeInput = document.getElementById("ville");
const cpInput = document.getElementById("code_postal");
const suggestions = document.getElementById("suggestions");
// petite fonction utilitaire : construit "numero + rue"
function buildStreet(props) {
const num = (props.housenumber || '').trim();
const street = (props.street || props.name || '').trim();
return [num, street].filter(Boolean).join(' ');
}
// fallback ville si city manquant (rare)
function getCity(props) {
if (props.city) return props.city;
// ex: "84160, Cucuron, Vaucluse, Provence-Alpes-Côte d'Azur"
if (props.context) {
const parts = props.context.split(',').map(s => s.trim());
// généralement la 2e valeur correspond à la commune
return parts[1] || parts[0] || '';
}
return '';
}
let abortCtrl = null;
adresseInput.addEventListener("input", function () {
const query = adresseInput.value.trim();
if (query.length < 3) {
suggestions.innerHTML = '';
return;
}
// annule la requête précédente si on retape vite
try { abortCtrl?.abort(); } catch(_) {}
abortCtrl = new AbortController();
fetch("https://api-adresse.data.gouv.fr/search/?limit=5&q=" + encodeURIComponent(query), {
signal: abortCtrl.signal
})
.then(res => res.json())
.then(data => {
suggestions.innerHTML = '';
(data.features || []).forEach(feature => {
const props = feature.properties || {};
const li = document.createElement("li");
li.className = 'list-group-item list-group-item-action';
li.textContent = props.label || buildStreet(props);
li.addEventListener("click", function () {
// ✅ Adresse = seulement numéro + rue
adresseInput.value = buildStreet(props);
// ✅ Ville + CP séparés
villeInput.value = getCity(props);
cpInput.value = props.postcode || '';
suggestions.innerHTML = '';
});
suggestions.appendChild(li);
});
})
.catch(() => {});
});
// clic hors zone -> ferme la liste
document.addEventListener("click", function (e) {
if (!suggestions.contains(e.target) && e.target !== adresseInput) {
suggestions.innerHTML = '';
}
});
});
</script>
<!--***********************************************************
Verification ajax pour SIRET
**************************************************************-->
<script>
// Luhn SIRET
function luhnCheck(num) {
const arr = (num + '').split('').reverse().map(x => parseInt(x, 10));
const sum = arr.reduce((acc, val, i) => {
if (i % 2) { val *= 2; if (val > 9) val -= 9; }
return acc + val;
}, 0);
return (sum % 10) === 0;
}
(function(){
const form = document.getElementById('form-praticien-edit'); // <-- bon id
const siretInput = document.getElementById('siret');
const feedback = document.getElementById('siret-feedback');
// init
form.dataset.siretExists = '0';
let debounceId = null;
let lastQueried = '';
const MSG = {
invalide: '<?= addslashes($translations["siret_format_invalide"] ?? "Numéro SIRET invalide.") ?>',
valide: '<?= addslashes($translations["siret_valide_format"] ?? "Format SIRET valide.") ?>',
existe: '<?= addslashes($translations["siret_trouve"] ?? "Numéro reconnu dans la base INSEE.") ?>',
nexiste: '<?= addslashes($translations["siret_non_trouve"] ?? "Numéro introuvable dans la base INSEE.") ?>',
attente: '<?= addslashes($translations["siret_verification_en_cours"] ?? "Vérification en cours…") ?>'
};
function formatSiret(value) {
value = value.replace(/\D/g, '');
if (value.length > 3) value = value.slice(0,3) + ' ' + value.slice(3);
if (value.length > 7) value = value.slice(0,7) + ' ' + value.slice(7);
if (value.length > 11) value = value.slice(0,11) + ' ' + value.slice(11);
return value;
}
function setFeedback(text, color) {
feedback.textContent = text;
feedback.style.color = color;
feedback.style.display = 'block';
}
// Pré-formate la valeur initiale si elle est brute (14 chiffres)
document.addEventListener('DOMContentLoaded', () => {
if (/^\d{14}$/.test(siretInput.value)) {
siretInput.value = formatSiret(siretInput.value);
}
});
function checkSiretImmediate() {
// formatage visuel
const formatted = formatSiret(siretInput.value);
if (siretInput.value !== formatted) siretInput.value = formatted;
const raw = siretInput.value.replace(/\s+/g, '');
if (raw.length === 0) { feedback.style.display = 'none'; form.dataset.siretExists = '0'; return; }
if (raw.length !== 14) { setFeedback(MSG.invalide, 'red'); form.dataset.siretExists = '0'; return; }
if (!luhnCheck(raw)) { setFeedback(MSG.invalide, 'red'); form.dataset.siretExists = '0'; return; }
setFeedback(MSG.attente, '#666');
form.dataset.siretExists = '0';
if (raw === lastQueried) return;
lastQueried = raw;
clearTimeout(debounceId);
debounceId = setTimeout(() => {
fetch('/../../js/ajax_verif_siret.php?siret=' + encodeURIComponent(raw)) // <-- chemin ABSOLU
.then(r => r.json())
.then(data => {
if (data && data.exists) {
setFeedback(MSG.existe, 'green');
form.dataset.siretExists = '1';
} else {
setFeedback(MSG.nexiste, 'red');
form.dataset.siretExists = '0';
}
})
.catch(() => {
setFeedback(MSG.nexiste, 'red');
form.dataset.siretExists = '0';
});
}, 500);
}
siretInput.addEventListener('input', checkSiretImmediate);
// Normalise AVANT envoi (supprime les espaces)
form.addEventListener('submit', function(e){
const raw = siretInput.value.replace(/\s+/g, '');
if (raw.length !== 14 || !luhnCheck(raw)) {
e.preventDefault();
setFeedback(MSG.invalide, 'red');
siretInput.focus();
return;
}
siretInput.value = raw; // <- on poste 14 chiffres à PHP
// Si tu veux forcer l’existence INSEE côté front, décommente :
// if (form.dataset.siretExists !== '1') { e.preventDefault(); setFeedback(MSG.nexiste, 'red'); siretInput.focus(); }
});
})();
</script>
</div>
<footer class="site-footer">
<div class="container">
<p><?= $translations['copyr'] ?></p>
</div>
</footer>
</body>
</html>
+315
View File
@@ -0,0 +1,315 @@
<?php
require_once __DIR__ . '/../includes/init_praticien.php';
require_once '../includes/header.php';
$pdo->prepare("
INSERT INTO push_badge_counters (compte_id, msg_unread, rdv_unread, updated_at)
VALUES (:cid, 0, 0, NOW())
ON DUPLICATE KEY UPDATE
rdv_unread = 0,
updated_at = NOW()
")->execute([':cid' => (int)$_SESSION['compte_id']]);
$praticien_id = $_SESSION['reference_id'];
$id_rdv = isset($_GET['id']) ? intval($_GET['id']) : null;
// Suppression si demandée (via POST confirmé par SweetAlert2 côté client)
if ($id_rdv && isset($_POST['delete'])) {
$stmt = $pdo->prepare("DELETE FROM rendez_vous
WHERE id = ? AND statut = 'annulé' AND praticien_id = ?");
$stmt->execute([$id_rdv, $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>'.htmlspecialchars($translations['rdv_supprime'] ?? 'Rendez-vous supprimé').'</h4>';
echo ' </div>';
echo ' <a href="/public/praticien/rendez_vous.php" class="btn btn-primary">';
echo ' <i class="bi bi-arrow-left-circle"></i> '.htmlspecialchars($translations['retour_a_la_liste'] ?? 'Retour à la liste').'
</a>';
echo ' </div>';
echo '</div>';
echo '</div>';
exit;
}
// Charger les rendez-vous
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 = ? AND rv.praticien_id = ?
");
$stmt->execute([$id_rdv, $praticien_id]);
$rdvs = $stmt->fetchAll();
} 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 = ?
ORDER BY rv.date_rdv DESC
");
$stmt->execute([$praticien_id]);
$rdvs = $stmt->fetchAll();
}
// 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_TITLE = $translations['mes_rdv_title'] ?? 'Mes rendez-vous';
$HEAD_EXTRA = <<<HTML
<script src="/js/sweetalert2@11.js"></script>
HTML;
?>
<div class="main1">
<div class="container py-5">
<div class="shadow p-4 rounded bg-light">
<h2 class="mb-4 text-center"><?= htmlspecialchars($translations['mes_rdv_title'] ?? 'Mes rendez-vous') ?></h2>
<?php if (!$id_rdv): ?>
<a href="/public/praticien/rendez_vous_du_jour.php" class="btn btn-outline-secondary mb-4">
<?= htmlspecialchars($translations['voir_rdv_du_jour'] ?? 'Voir les RDV du jour') ?>
</a>
<div class="mb-3">
<label for="filtre_statut" class="form-label"><?= htmlspecialchars($translations['filtrer_par_statut'] ?? 'Filtrer par statut') ?></label>
<select id="filtre_statut" class="form-select w-auto d-inline-block" onchange="filtrer()">
<option value=""><?= htmlspecialchars($translations['option_tous'] ?? 'Tous') ?></option>
<?php foreach (['prévu','confirmé','annulé','terminé'] as $s): ?>
<option value="<?= $s ?>"><?= htmlspecialchars($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><?= htmlspecialchars($translations['col_date'] ?? 'Date') ?></th>
<th><?= htmlspecialchars($translations['col_heure'] ?? 'Heure') ?></th>
<th><?= htmlspecialchars($translations['col_client'] ?? 'Client') ?></th>
<th><?= htmlspecialchars($translations['col_duree'] ?? 'Durée') ?></th>
<th><?= htmlspecialchars($translations['col_statut'] ?? 'Statut') ?></th>
<?php if (!$id_rdv): ?><th><?= htmlspecialchars($translations['col_action'] ?? 'Action') ?></th><?php endif; ?>
<th><?= htmlspecialchars($translations['btn_chat'] ?? 'Chat') ?></th>
<th><?= htmlspecialchars($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="<?= htmlspecialchars($r['statut']) ?>">
<td><?= htmlspecialchars($dt->format('d/m/Y')) ?></td>
<td><?= htmlspecialchars($dt->format('H:i')) ?></td>
<td><?= htmlspecialchars($r['prenom_client'] . ' ' . $r['nom_client']) ?></td>
<td><?= (int)$r['duree_minutes'] . ' ' . htmlspecialchars($translations['min_abbr'] ?? 'min') ?></td>
<td>
<select class="form-select form-select-sm" onchange="changerStatut(<?= $id ?>, this.value)">
<?php foreach (['prévu','confirmé','annulé','terminé'] as $s): ?>
<option value="<?= $s ?>" <?= ($r['statut'] === $s ? 'selected' : '') ?>>
<?= htmlspecialchars($statutMap[$s] ?? ucfirst($s)) ?>
</option>
<?php endforeach; ?>
</select>
</td>
<?php if (!$id_rdv): ?>
<td>
<?php if ($r['statut'] === 'annulé'): ?>
<form method="POST" action="?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">
<?= htmlspecialchars($translations['btn_supprimer'] ?? 'Supprimer') ?>
</button>
</form>
<?php else: ?>
<a href="?id=<?= $id ?>" class="btn btn-outline-secondary btn-sm btn-table">
<?= htmlspecialchars($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'] ?>">
<?= htmlspecialchars($translations['chat_praticien'] ?? 'Ouvrir le chat') ?>
</a>
</td>
<!-- ====== Colonne Visio / Cabinet ====== -->
<td>
<?php
$lieu = strtolower(trim((string)($r['lieu'] ?? '')));
if ($lieu === 'online') {
// --- on garde l'ancienne logique visio telle quelle ---
$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">
<?= htmlspecialchars($translations['visio_nachalo'] ?? 'Démarrer la visio') ?>
</a>
<?php
} else {
?>
<small class="text-muted d-block mt-1">
<?php
if ($now < $open) echo htmlspecialchars(($translations['ouvert_a'] ?? 'Ouvert à') . ' ' . $open->format('H:i'));
elseif ($now > $close) echo htmlspecialchars(($translations['ferme_a'] ?? 'Fermé à') . ' ' . $close->format('H:i'));
else echo htmlspecialchars(($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">
<?= htmlspecialchars($translations['votre_rdv_jdet'] ?? 'En attente de confirmation') ?>
</small>
<?php
} else {
?>
<small class="text-muted d-block mt-1">
<?= htmlspecialchars(($translations['zaverchen_a'] ?? 'Terminé') . ' ' . $close->format('H:i')) ?>
</small>
<?php
}
} else {
// --- offline : juste afficher "Очно" / libellé cabinet ---
echo '<span class="text-nowrap">'.htmlspecialchars($translations['option_cabinet'] ?? 'Очно').'</span>';
}
?>
</td>
<!-- ===================================== -->
</tr>
<?php endforeach; else: ?>
<tr>
<td colspan="<?= $id_rdv ? 6 : 8 ?>"><?= htmlspecialchars($translations['aucun_rdv_trouve'] ?? 'Aucun rendez-vous trouvé') ?></td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
<?php if ($id_rdv): ?>
<a href="/public/praticien/rendez_vous.php" class="btn btn-outline-secondary mb-4">
<?= htmlspecialchars($translations['retour_a_la_liste'] ?? 'Retour à la liste') ?>
</a>
<?php endif; ?>
</div>
</div>
<script>
function changerStatut(id, statut) {
fetch('/public/praticien/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) })
})
.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>
+240
View File
@@ -0,0 +1,240 @@
<?php
require_once __DIR__ . '/../includes/init_praticien.php';
$praticien_id = $_SESSION['reference_id'];
// --- Today 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');
$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 = ?
AND rv.date_rdv BETWEEN ? AND ?
ORDER BY rv.date_rdv ASC
");
$stmt->execute([$praticien_id, $today_start, $today_end]);
$rdvs = $stmt->fetchAll();
// 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_TITLE = $translations['titre_rdv_du_jour'] ?? 'Rendez-vous du jour';
$HEAD_EXTRA = <<<HTML
<script src="/js/sweetalert2@11.js"></script>
HTML;
require_once '../includes/header.php';
?>
<div class="main1">
<div class="container py-5">
<div class="shadow p-4 rounded bg-light">
<h2 class="mb-4 text-center"><?= htmlspecialchars($translations['titre_rdv_du_jour'] ?? 'Rendez-vous du jour') ?></h2>
<a href="/public/praticien/rendez_vous.php" class="btn btn-outline-secondary mb-4">
<?= htmlspecialchars($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><?= htmlspecialchars($translations['col_heure'] ?? 'Heure') ?></th>
<th><?= htmlspecialchars($translations['col_client'] ?? 'Client') ?></th>
<th><?= htmlspecialchars($translations['col_duree'] ?? 'Durée') ?></th>
<th><?= htmlspecialchars($translations['col_statut'] ?? 'Statut') ?></th>
<th><?= htmlspecialchars($translations['btn_chat'] ?? 'Chat') ?></th>
<th><?= htmlspecialchars($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="<?= htmlspecialchars($r['statut']) ?>">
<td><?= htmlspecialchars($dt->format('H:i')) ?></td>
<td><?= htmlspecialchars($r['prenom_client'] . ' ' . $r['nom_client']) ?></td>
<td><?= (int)$r['duree_minutes'] . ' ' . htmlspecialchars($translations['min_abbr'] ?? 'min') ?></td>
<td>
<select class="form-select form-select-sm" onchange="changerStatut(<?= $id ?>, this.value, this)">
<?php foreach (['prévu','confirmé','annulé','terminé'] as $s): ?>
<option value="<?= $s ?>" <?= ($r['statut'] === $s ? 'selected' : '') ?>>
<?= htmlspecialchars($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'] ?>">
<?= htmlspecialchars($translations['chat_praticien'] ?? 'Ouvrir le chat') ?>
</a>
</td>
<!-- Visio / Cabinet -->
<td>
<?php
$lieu = strtolower(trim((string)($r['lieu'] ?? '')));
if ($lieu === 'online') {
// LOGIQUE VISIO (inchangée)
$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">
<?= htmlspecialchars($translations['visio_nachalo'] ?? 'Démarrer la visio') ?>
</a>
<?php
} else {
?>
<small class="text-muted d-block mt-1">
<?php
if ($now < $open) echo htmlspecialchars(($translations['ouvert_a'] ?? 'Ouvert à') . ' ' . $open->format('H:i'));
elseif ($now > $close) echo htmlspecialchars(($translations['ferme_a'] ?? 'Fermé à') . ' ' . $close->format('H:i'));
else echo htmlspecialchars(($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">
<?= htmlspecialchars($translations['votre_rdv_jdet'] ?? 'En attente de confirmation') ?>
</small>
<?php
} else {
?>
<small class="text-muted d-block mt-1">
<?= htmlspecialchars(($translations['zaverchen_a'] ?? 'Terminé') . ' ' . $close->format('H:i')) ?>
</small>
<?php
}
} else {
// OFFLINE : afficher juste le libellé "Очно"
echo '<span class="text-nowrap">'.htmlspecialchars($translations['option_cabinet'] ?? 'Очно').'</span>';
}
?>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($rdvs)): ?>
<tr>
<td colspan="6" class="text-muted">
<?= htmlspecialchars($translations['aucun_rdv_aujourdhui'] ?? "Aucun rendez-vous aujourd'hui") ?>
</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
// --- Mise à jour du statut avec SweetAlert2 ---
// On gère JSON *et* fallback texte "OK" si l'endpoint n'est pas encore JSON.
function changerStatut(id, statut, selEl) {
fetch('/public/praticien/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) })
})
.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 (_) {
// Fallback: si l'API renvoie juste "OK" ou un message en clair
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 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(){
const tbl = document.getElementById('table_rdv') || document.querySelector('table.mobile-cards');
initMobileCards(tbl);
});
})();
</script>
<!--*****************************FIN Affichage mobile************************-->
<footer class="site-footer">
<div class="container">
<p><?= $translations['copyr'] ?? '&copy; '.date('Y') ?></p>
</div>
</footer>
</body>
</html>
+95
View File
@@ -0,0 +1,95 @@
<?php
require_once __DIR__ . '/../includes/init_praticien.php';
$praticien_id = $_SESSION['reference_id'] ?? null;
$stmt = $pdo->prepare("
SELECT a.nom, a.prix, a.duree_jours, p.date_expiration
FROM praticien p
JOIN abonnement a ON p.abonnement_id = a.id
WHERE p.id = ?
");
$stmt->execute([$praticien_id]);
$abonnement = $stmt->fetch();
$nomKey = trim((string)($abonnement['nom'] ?? ''));
$nomTxt = $translations[$nomKey] ?? $nomKey;
require_once '../includes/header.php';
?>
<div class="main1">
<div class="container mt-5">
<div class="shadow p-4 rounded bg-light">
<h3 class="mb-4 text-center"><?= htmlspecialchars($translations['titre_abonnement']) ?></h3>
<?php if ($abonnement):
$dateExp = date('d/m/Y', strtotime($abonnement['date_expiration']));
?>
<div class="mb-4">
<p><strong><?= htmlspecialchars($translations['facture_formule']) ?> :</strong> <?= $nomTxt ?></p>
<p><strong><?= htmlspecialchars($translations['label_date_expiration']) ?> :</strong> <?= $dateExp ?></p>
</div>
<div class="d-flex align-items-center">
<button type="button" class="btn btn-outline-primary custom-gray ms-3" data-bs-toggle="modal" data-bs-target="#modalResiliation">
<?= htmlspecialchars($translations['btn_resilier_abonnement']) ?>
</button>
<a href="/public/choisir_abonnement.php" class="btn btn-outline-primary custom-gray ms-3">
<?= htmlspecialchars($translations['btn_changer_formule']) ?>
</a>
</div>
<!-- MODALE -->
<div class="modal fade" id="modalResiliation" tabindex="-1" aria-labelledby="modalResiliationLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-danger">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title" id="modalResiliationLabel">
<?= htmlspecialchars($translations['modal_resiliation_titre']) ?>
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Fermer"></button>
</div>
<div class="modal-body">
⚠️ <?= htmlspecialchars($translations['modal_resiliation_texte']) ?><br>
<?php
$texteAcces = strtr($translations['modal_resiliation_acces_jusqua'], ['{date}' => $dateExp]);
?>
<?= $texteAcces ?>
</div>
<div class="modal-footer">
<form method="POST" action="/public/praticien/resiliation_action.php">
<button type="submit" class="btn btn-danger">
<?= htmlspecialchars($translations['btn_oui_resilier']) ?>
</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<?= htmlspecialchars($translations['btn_annuler']) ?>
</button>
</form>
</div>
</div>
</div>
</div>
<?php else: ?>
<div class="alert alert-warning">
<?= htmlspecialchars($translations['abonnement_absent']) ?>
</div>
<a href="/public/choisir_abonnement.php" class="btn btn-primary">
<?= htmlspecialchars($translations['btn_choisir_abonnement']) ?>
</a>
<?php endif; ?>
</div>
</div>
<!-- Bootstrap JS (si non inclus ailleurs) -->
<script src="/js/bootstrap.bundle.min.js"></script>
</div>
<footer class="site-footer">
<div class="container">
<p><?= $translations['copyr'] ?></p>
</div>
</footer>
</body>
</html>
+123
View File
@@ -0,0 +1,123 @@
<?php
// /public/resiliation_action.php
require_once __DIR__ . '/../includes/init_praticien.php';
if (!isset($pdo) || !($pdo instanceof PDO)) {
http_response_code(500);
exit('DB_ERROR');
}
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
$praticien_id = (int)($_SESSION['reference_id'] ?? 0);
if ($praticien_id <= 0) {
header('Location: /public/login.php');
exit;
}
// ---- Helper: colonne existe ? (évite de casser selon schéma) ----
function colExists(PDO $pdo, string $table, string $col): bool {
static $cache = [];
$k = $table.'.'.$col;
if (array_key_exists($k, $cache)) return $cache[$k];
$st = $pdo->prepare("
SELECT 1
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND COLUMN_NAME = ?
LIMIT 1
");
$st->execute([$table, $col]);
$cache[$k] = (bool)$st->fetchColumn();
return $cache[$k];
}
try {
// 🔎 Récupération du billing le plus récent "utile"
// On vise l’entrée la plus récente; si elle est déjà canceled, ça reste idempotent.
$stmt = $pdo->prepare("
SELECT id, billing_mode, provider, provider_subscription_id, current_period_end, status
FROM billing_subscriptions
WHERE praticien_id = ?
ORDER BY id DESC
LIMIT 1
");
$stmt->execute([$praticien_id]);
$billing = $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
// ==========================
// 1) Appel Stripe (si auto)
// ==========================
if ($billing) {
$isAutoStripe = (
(string)($billing['billing_mode'] ?? '') === 'auto'
&& (string)($billing['provider'] ?? '') === 'stripe'
&& !empty($billing['provider_subscription_id'])
);
if ($isAutoStripe) {
require_once __DIR__ . '/../../plata/lib/stripe_client.php'; // $stripe
try {
// Résiliation à fin de période
$stripe->subscriptions->update(
(string)$billing['provider_subscription_id'],
['cancel_at_period_end' => true]
);
} catch (Throwable $e) {
// On ne bloque pas la résiliation locale si Stripe échoue
@file_put_contents(
'/tmp/stripe_resiliation.log',
'['.date('c').'] '.$e->getMessage()."\n",
FILE_APPEND
);
}
}
}
// ==========================
// 2) Mise à jour locale (TX)
// ==========================
$pdo->beginTransaction();
// 2.1) billing_subscriptions -> canceled (+ flags si colonnes existent)
if ($billing && !empty($billing['id'])) {
$sets = ["status = 'canceled'"];
$vals = [];
if (colExists($pdo, 'billing_subscriptions', 'cancel_at_period_end')) {
$sets[] = "cancel_at_period_end = 1";
}
if (colExists($pdo, 'billing_subscriptions', 'canceled_at')) {
$sets[] = "canceled_at = NOW()";
}
$sql = "UPDATE billing_subscriptions SET ".implode(', ', $sets)." WHERE id = ?";
$vals[] = (int)$billing['id'];
$pdo->prepare($sql)->execute($vals);
}
// 2.2) praticien -> abonnement_id NULL (NE PAS toucher date_expiration)
if (colExists($pdo, 'praticien', 'abonnement_id')) {
$pdo->prepare("UPDATE praticien SET abonnement_id = NULL WHERE id = ?")
->execute([$praticien_id]);
}
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
@file_put_contents(
'/tmp/resiliation_error.log',
'['.date('c').'] '.$e->getMessage()."\n",
FILE_APPEND
);
}
// Redirection finale
header('Location: /public/praticien/resiliation.php?ok=1');
exit;
+48
View File
@@ -0,0 +1,48 @@
<?php
require_once __DIR__ . '/../includes/init_praticien.php';
require_once '../includes/header.php';
if ($_SESSION['role'] === 'praticien'): ?>
<div class="main1">
<div class="container py-5">
<div class="shadow p-4 rounded bg-light">
<h2 class="mb-4 text-center"><?= htmlspecialchars($translations['titre_sync_agenda']) ?></h2>
<!-- Google Agenda -->
<div class="card_sync p-4">
<h4><?= htmlspecialchars($translations['google_import_title']) ?></h4>
<p><?= htmlspecialchars($translations['google_import_text']) ?></p>
<a href="/google/connect_google.php">
<button class="btn btn-outline-secondary mb-4"><?= htmlspecialchars($translations['btn_connect_google_agenda']) ?></button>
</a>
</div>
<!-- iCal -->
<div class="card_sync p-4">
<h4><?= htmlspecialchars($translations['ical_import_title']) ?></h4>
<p><?= htmlspecialchars($translations['ical_import_text']) ?></p>
<a href="https://uslugi.fr/calendar.php?id=<?= (int)$_SESSION['reference_id'] ?>" target="_blank" rel="noopener">
<button class="btn btn-outline-secondary mb-4"><?= htmlspecialchars($translations['btn_add_to_ical']) ?></button>
</a>
</div>
<!-- Outlook -->
<div class="card_sync p-4">
<h4><?= htmlspecialchars($translations['outlook_import_title']) ?></h4>
<p><?= htmlspecialchars($translations['outlook_import_text']) ?></p>
<a href="https://uslugi.fr/outlook_connect.php" target="_blank" rel="noopener">
<button class="btn btn-outline-secondary mb-4"><?= htmlspecialchars($translations['btn_connect_outlook']) ?></button>
</a>
</div>
</div>
</div>
</div>
<footer class="site-footer">
<div class="container">
<p><?= $translations['copyr'] ?></p>
</div>
</footer>
</body>
</html>
<?php endif; ?>
+23
View File
@@ -0,0 +1,23 @@
<?php
require_once __DIR__ . '/includes/init_praticien.php';
header('Content-Type: application/json; charset=utf-8');
$compteId = (int)($_SESSION['compte_id'] ?? 0);
if ($compteId <= 0) {
echo json_encode(['total' => 0]);
exit;
}
$stmt = $pdo->prepare("
SELECT rdv_unread
FROM push_badge_counters
WHERE compte_id = ?
");
$stmt->execute([$compteId]);
$row = $stmt->fetch();
echo json_encode([
'total' => (int)($row['rdv_unread'] ?? 0)
]);
+265
View File
@@ -0,0 +1,265 @@
<?php
// /public/souscrire_abonnement.php
require_once __DIR__ . '/includes/init_praticien.php';
// --- Helpers DB sûrs ---
function colExists(PDO $pdo, string $table, string $col): bool
{
$sql = "SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1";
$st = $pdo->prepare($sql);
$st->execute([$table, $col]);
return (bool) $st->fetchColumn();
}
function enumOptions(PDO $pdo, string $table, string $col): array
{
$sql = "SELECT COLUMN_TYPE FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1";
$st = $pdo->prepare($sql);
$st->execute([$table, $col]);
$type = (string) $st->fetchColumn();
if (stripos($type, 'enum(') !== 0) {
return [];
}
preg_match_all("/'([^']*)'/", $type, $m);
return $m[1] ?? [];
}
function logErr(Throwable $e): void
{
$info = ($e instanceof PDOException && isset($e->errorInfo)) ? json_encode($e->errorInfo, JSON_UNESCAPED_UNICODE) : '';
@file_put_contents('/tmp/souscrire_abonnement.log',
'[' . date('c') . "] " . $e->getMessage() . " " . $info . "\n",
FILE_APPEND
);
}
function flash(string $type, string $msg): void
{
$_SESSION['flash_' . $type] = $msg;
}
// --- Sécurité PDO ---
if (isset($pdo) && $pdo instanceof PDO) {
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
}
$praticien_id = (int) ($_SESSION['reference_id'] ?? 0);
if (! $praticien_id) {
header('Location: /public/login.php');exit;
}
// --- CSRF depuis choisir_abonnement.php ---
$method = $_SERVER['REQUEST_METHOD'] ?? '';
$sessionCSRF = (string) ($_SESSION['csrf_abon'] ?? '');
$postedCSRF = (string) ($_POST['csrf'] ?? ($_POST['csrf_abon'] ?? ''));
if ($method !== 'POST' || $sessionCSRF === '' || $postedCSRF === '' || ! hash_equals($sessionCSRF, $postedCSRF)) {
flash('error', $translations['erreur_csrf'] ?? 'Session expirée. Merci de réessayer.');
header('Location: /public/choisir_abonnement.php');exit;
}
// --- Billing mode (manual|auto) ---
$billing_mode = (string) ($_POST['billing_mode'] ?? 'manual');
$billing_mode = in_array($billing_mode, ['manual', 'auto'], true) ? $billing_mode : 'manual';
// --- Mapping Stripe (TEST / staging) pour les abonnements auto ---
// NOTE: on gardera ça ici pour l’instant (staging). Plus tard on déplacera vers /plata/config/stripe.php
// Mensuel (id=3) -> price_... (25€/mois)
// Annuel (id=4) -> price_... (275€/an)
// Pro (id=5) -> price_... (575€/an)
/*$AUTO_PRICE_MAP = [
3 => 'price_1SoksWFEDARPqDCE0Ynql83K',
4 => 'price_1Sokt8FEDARPqDCEecbxJCZU',
5 => 'price_1Sol4VFEDARPqDCEdDpjrfUE',
];
Live;*/
$AUTO_PRICE_MAP = [
3 => 'price_1SpFEMFEDARPqDCEfGsL0SIN',
4 => 'price_1SpFFLFEDARPqDCE1fLBFWbq',
5 => 'price_1SpFGMFEDARPqDCEYW7FGU0P',
];
// --- Lecture de l’abonnement choisi ---
$abonnement_id = (int) ($_POST['abonnement_id'] ?? 0);
if ($abonnement_id <= 0) {
flash('error', $translations['abonnement_invalide'] ?? 'Abonnement invalide.');
header('Location: /public/choisir_abonnement.php');exit;
}
$st = $pdo->prepare("SELECT id, nom, prix, duree_jours, actif FROM abonnement WHERE id = ? LIMIT 1");
$st->execute([$abonnement_id]);
$ab = $st->fetch(PDO::FETCH_ASSOC);
if (! $ab || ((int) $ab['actif'] !== 1 && (int) $ab['id'] !== 2)) {
flash('error', $translations['abonnement_introuvable'] ?? 'Abonnement introuvable ou inactif.');
header('Location: /public/choisir_abonnement.php');exit;
}
$prix = (float) $ab['prix']; // 0.00 si gratuit
$duree_jours = (int) $ab['duree_jours'];
$nomKey = trim((string) ($ab['nom'] ?? ''));
$nomAb = $translations[$nomKey] ?? $nomKey;
// --- Payant → flux Stripe (ponctuel ou auto) ---
if ($prix >= 0.5) {
// Auto autorisé uniquement sur les abonnements configurés (3,4,5)
if ($billing_mode === 'auto') {
if (! isset($AUTO_PRICE_MAP[$abonnement_id])) {
flash('error', $translations['auto_indispo_text'] ?? "Le renouvellement automatique n'est disponible que pour les offres mensuelle/annuelle.");
header('Location: /public/choisir_abonnement.php');exit;
}
// Sécurité : si on arrive ici avec l'ID promo (2), on force manuel (pas de price récurrent mappé)
// (Normalement ton choisir_abonnement bloque déjà, mais on double-sécurise côté serveur.)
if ($abonnement_id === 2) {
$billing_mode = 'manual';
}
}
// On garde tes sessions actuelles (compatibles avec paiement_simule.php et create_checkout.php)
$_SESSION['paiement_abonnement_id'] = $abonnement_id;
$_SESSION['paiement_prix'] = $prix;
$_SESSION['paiement_abonnement_nom'] = $nomAb;
$_SESSION['paiement_abonnement_key'] = $nomKey;
// On ajoute les sessions pour le nouveau mode
$_SESSION['paiement_billing_mode'] = $billing_mode; // manual|auto
$_SESSION['paiement_provider'] = 'stripe';
if ($billing_mode === 'auto') {
$_SESSION['paiement_provider_plan_id'] = $AUTO_PRICE_MAP[$abonnement_id]; // Stripe price_id (recurring)
} else {
unset($_SESSION['paiement_provider_plan_id']);
}
header('Location: /public/paiement_simule.php');exit;
}
// --- Gratuit → activation locale (une seule fois) ---
$FREE_PLAN_ID = 1; // adapte si besoin
// Anti double-usage (cohérent avec ton affichage)
$st = $pdo->prepare("
SELECT 1
FROM paiement
WHERE praticien_id = ?
AND abonnement_id = ?
AND (statut IS NULL OR statut <> 'échoué')
LIMIT 1
");
$st->execute([$praticien_id, $FREE_PLAN_ID]);
$hasUsedFree = (bool) $st->fetchColumn();
if ($abonnement_id === $FREE_PLAN_ID && $hasUsedFree) {
flash('error', $translations['essai_deja_utilise'] ?? "L'offre gratuite a déjà été utilisée.");
header('Location: /public/choisir_abonnement.php');exit;
}
try {
// Choisir un statut compatible avec l’ENUM de la table paiement
$statut = null;
if (colExists($pdo, 'paiement', 'statut')) {
$opts = enumOptions($pdo, 'paiement', 'statut'); // ex: ['payé','en_attente','annulé']
if ($opts) {
$prefs = ['payé', 'paye', 'paid', 'validé', 'valide', 'completed', 'ok'];
foreach ($prefs as $p) {
if (in_array($p, $opts, true)) {$statut = $p;
break;}
}
if (! $statut) {
$statut = $opts[0];
}
}
}
// Calculer l’expiration
$date_expiration = (new DateTime('now'))->modify("+$duree_jours days")->format('Y-m-d H:i:s');
$pdo->beginTransaction();
// INSERT dans paiement (montant 0, méthode 'gratuit'), uniquement les colonnes existantes
$fields = [];
if (colExists($pdo, 'paiement', 'praticien_id')) {
$fields['praticien_id'] = $praticien_id;
}
if (colExists($pdo, 'paiement', 'abonnement_id')) {
$fields['abonnement_id'] = $abonnement_id;
}
if (colExists($pdo, 'paiement', 'montant')) {
$fields['montant'] = 0.0;
}
if (colExists($pdo, 'paiement', 'methode')) {
$fields['methode'] = 'gratuit';
}
if (colExists($pdo, 'paiement', 'statut') && $statut) {
$fields['statut'] = $statut;
}
if (colExists($pdo, 'paiement', 'currency')) {
$fields['currency'] = 'eur';
}
if (colExists($pdo, 'paiement', 'created_at')) {
$fields['created_at'] = ['expr' => 'NOW()'];
}
if (colExists($pdo, 'paiement', 'date_paiement')) {
$fields['date_paiement'] = ['expr' => 'NOW()'];
}
if (colExists($pdo, 'paiement', 'abonnement_nom') && $nomAb !== '') {
$fields['abonnement_nom'] = $nomAb;
}
if (! empty($fields)) {
$cols = [];
$holders = [];
$vals = [];
foreach ($fields as $col => $val) {
$cols[] = $col;
if (is_array($val) && isset($val['expr'])) {
$holders[] = $val['expr'];
} else {
$holders[] = '?';
$vals[] = $val;
}
}
$sql = "INSERT INTO paiement (" . implode(',', $cols) . ") VALUES (" . implode(',', $holders) . ")";
$pdo->prepare($sql)->execute($vals);
}
// UPDATE du praticien (abonnement + expiration)
if (colExists($pdo, 'praticien', 'date_expiration')) {
$pdo->prepare("UPDATE praticien SET abonnement_id = ?, date_expiration = ? WHERE id = ?")
->execute([$abonnement_id, $date_expiration, $praticien_id]);
} elseif (colExists($pdo, 'praticien', 'abonnement_expire_le')) {
$pdo->prepare("UPDATE praticien SET abonnement_id = ?, abonnement_expire_le = ? WHERE id = ?")
->execute([$abonnement_id, $date_expiration, $praticien_id]);
} // sinon, on ne bloque pas (pas de colonne), mais rien à faire.
$pdo->commit();
// Nettoyage session paiement (au cas où)
unset($_SESSION['paiement_abonnement_id'], $_SESSION['paiement_prix'], $_SESSION['paiement_billing_mode'], $_SESSION['paiement_provider'], $_SESSION['paiement_provider_plan_id']);
// Redirection finale
flash('success', $translations['essai_active'] ?? "L'essai gratuit a été activé.");
header('Location: /public/praticien/factures.php'); // ou ton tableau de bord
exit;
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
logErr($e);
http_response_code(500);
exit(($translations['erreur_traitement'] ?? 'Erreur serveur') . ' (FREE)');
}
+30
View File
@@ -0,0 +1,30 @@
# --- Bloquer l’exécution & l’index de dossier ---
Options -Indexes
# (FPM : pas besoin de php_flag engine off)
# --- Bloquer toute URL qui contient ".php" même en double extension (ex: .php.jpg) ---
RewriteEngine On
RewriteCond %{REQUEST_URI} \.php [NC]
RewriteRule ^ - [F]
# --- Interdire l’accès aux extensions exécutables ---
<FilesMatch "\.(php|phtml|phps|phar|cgi|pl|py|sh|rb|jsp|asp|aspx)$">
Require all denied
</FilesMatch>
# --- Neutraliser tout handler scripts dans ce répertoire ---
RemoveHandler .php .phtml .phps .phar .cgi .pl .py .sh .rb .jsp .asp .aspx
RemoveType .php .phtml .phps .phar .cgi .pl .py .sh .rb .jsp .asp .aspx
# --- (Optionnel) Renforcer la politique locale pour les SVG ---
<IfModule mod_headers.c>
<FilesMatch "\.svg$">
Header set Content-Security-Policy "script-src 'none'; style-src 'unsafe-inline'"
Header set X-Content-Type-Options "nosniff"
</FilesMatch>
</IfModule>
# (Optionnel) Forcer le téléchargement pour types sensibles
# AddType application/octet-stream .svg .xml .json
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 633 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 653 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Loaded 100 of 362 files, more files were not shown because too many files have changed in this diff. Show more