Init Git Prod Mars 2026
No files matched your search
@@ -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'] ?? '© '.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>
|
||||
@@ -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'] ?? '© '.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>
|
||||
@@ -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.');
|
||||
}
|
||||
@@ -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'] ?? '© '.date('Y') ?></p>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -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'] ?? '© '.date('Y') ?></p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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'] ?? '© '.date('Y') ?></p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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);
|
||||
@@ -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'] ?? '© '.date('Y') ?></p>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -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'] ?? '© '.date('Y') ?></p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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'] ?? '© '.date('Y') ?></p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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) ?>">
|
||||
« <?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') ?> »
|
||||
</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'] ?? '© ' . date('Y') ?></p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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'];
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
@@ -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">««</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">»»</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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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(); ?>
|
||||
@@ -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>
|
||||
@@ -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 ?>">« <?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'] ?> »</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>
|
||||
@@ -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'];
|
||||
}
|
||||
?>
|
||||
@@ -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(); ?>
|
||||
@@ -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.
|
||||
|
||||
@@ -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('/');
|
||||
@@ -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
|
||||
@@ -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.');
|
||||
}
|
||||
?>
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -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';
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/init_auth.php';
|
||||
init_auth_page(['client']);
|
||||
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/init_auth.php';
|
||||
init_auth_page(['client', 'praticien']);
|
||||
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/init_auth.php';
|
||||
init_auth_page(['praticien']);
|
||||
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/init_auth.php';
|
||||
init_auth_page(['secretaire']);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
})();
|
||||
|
||||
|
||||
@@ -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'] ?? ''
|
||||
]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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=>({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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'] ?? '© ' . date('Y') ?></p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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'] ?? '© ' . date('Y') ?></p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
?>
|
||||
@@ -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>
|
||||
@@ -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'] ?? '© '.date('Y') ?></p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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'] ?? '© '.date('Y') ?></p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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; ?>
|
||||
@@ -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)
|
||||
]);
|
||||
@@ -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)');
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 450 KiB |
|
After Width: | Height: | Size: 450 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 295 KiB |
|
After Width: | Height: | Size: 4.4 MiB |
|
After Width: | Height: | Size: 450 KiB |
|
After Width: | Height: | Size: 633 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 653 KiB |
|
After Width: | Height: | Size: 234 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 367 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 11 KiB |