Init Git Prod Mars 2026

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

No files matched your search

@@ -0,0 +1,79 @@
<?php
require_once __DIR__ . '/../../includes/init_praticien.php';
header('Content-Type: application/json; charset=utf-8');
function out($ok,$msg,$extra=[]){ echo json_encode(['ok'=>$ok,'message'=>$msg]+$extra); exit; }
$praticien_id = (int)($_SESSION['reference_id'] ?? 0);
$csrf = (string)($_POST['csrf'] ?? '');
if (!hash_equals($_SESSION['cl_csrf'] ?? '', $csrf)) out(false, 'CSRF');
$dossier_id = (int)($_POST['dossier_id'] ?? 0);
if ($dossier_id<=0) out(false, 'Bad dossier');
// Owner
$st = $pdo->prepare("SELECT d.id, d.client_id FROM client_dossiers d WHERE d.id=:d AND d.praticien_id=:p AND d.archived_at IS NULL");
$st->execute([':d'=>$dossier_id, ':p'=>$praticien_id]);
$dos = $st->fetch(PDO::FETCH_ASSOC);
if(!$dos) out(false, 'Not allowed');
$date_rdv = (string)($_POST['date_rdv'] ?? '');
$type_rdv = (string)($_POST['type_rdv'] ?? 'autre');
$titre = trim((string)($_POST['titre'] ?? ''));
$notes = trim((string)($_POST['notes'] ?? ''));
$prochaine= trim((string)($_POST['prochaine_action'] ?? ''));
$duree = (int)($_POST['duree_minutes'] ?? 0);
if ($titre==='' || $notes==='') out(false, 'Champs requis');
try{
$pdo->beginTransaction();
$ins = $pdo->prepare("INSERT INTO client_suivi (dossier_id, rdv_id, date_rdv, type_rdv, titre, notes, prochaine_action, duree_minutes, prive)
VALUES (:d, NULL, :dt, :ty, :ti, :no, :pa, :du, 1)");
$ins->execute([
':d'=>$dossier_id,
':dt'=> (new DateTime($date_rdv ?: 'now'))->format('Y-m-d H:i:s'),
':ty'=> in_array($type_rdv,['online','cabinet','tel','autre'],true) ? $type_rdv : 'autre',
':ti'=> $titre,
':no'=> $notes,
':pa'=> ($prochaine !== '' ? $prochaine : null),
':du'=> ($duree > 0 ? $duree : null),
]);
$entry_id = (int)$pdo->lastInsertId();
// Upload files
if (!empty($_FILES['files']) && is_array($_FILES['files']['name'])) {
$baseDir = $_SERVER['DOCUMENT_ROOT'].'/uploads/suivi/'. $dossier_id;
if (!is_dir($baseDir)) @mkdir($baseDir, 0775, true);
$allowed = ['pdf','jpg','jpeg','png','doc','docx','txt'];
for($i=0;$i<count($_FILES['files']['name']);$i++){
$err = (int)($_FILES['files']['error'][$i] ?? UPLOAD_ERR_NO_FILE);
if ($err !== UPLOAD_ERR_OK) continue;
$name = (string)($_FILES['files']['name'][$i] ?? '');
$tmp = (string)($_FILES['files']['tmp_name'][$i] ?? '');
$size = (int)($_FILES['files']['size'][$i] ?? 0);
if ($size <= 0 || $size > 8*1024*1024) continue; // 8 Mo
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
if (!in_array($ext,$allowed,true)) continue;
$clean = preg_replace('~[^a-zA-Z0-9._-]+~','_', $name);
$new = $dossier_id.'_'.time().'_'.bin2hex(random_bytes(4)).'.'.$ext;
$dest = $baseDir.'/'.$new;
if (@move_uploaded_file($tmp, $dest)) {
$rel = '/uploads/suivi/'.$dossier_id.'/'.$new;
$mime= mime_content_type($dest) ?: null;
$pdo->prepare("INSERT INTO client_suivi_files (entry_id, path, mime, size_bytes) VALUES (:e,:p,:m,:s)")
->execute([':e'=>$entry_id, ':p'=>$rel, ':m'=>$mime, ':s'=>$size]);
}
}
}
$pdo->commit();
out(true, $translations['cl_enregistre_ok'] ?? 'Note enregistrée.');
}catch(Throwable $e){
if ($pdo->inTransaction()) $pdo->rollBack();
error_log('ajax_ajouter_note: '.$e->getMessage());
out(false, $translations['cl_enregistre_ko'] ?? 'Erreur lors de l’enregistrement.');
}
@@ -0,0 +1,80 @@
<?php
require_once __DIR__ . '/../../includes/init_praticien.php';
header('Content-Type: application/json; charset=utf-8');
function out($ok,$m,$x=[]){ echo json_encode(['ok'=>$ok,'message'=>$m]+$x); exit; }
$praticien_id = (int)($_SESSION['reference_id'] ?? 0);
$csrf=(string)($_POST['csrf'] ?? '');
if (!hash_equals($_SESSION['cl_csrf'] ?? '', $csrf)) out(false,'CSRF');
$note_id = (int)($_POST['note_id'] ?? 0);
if ($note_id<=0) out(false,'Bad note');
// Vérif propriétaire + dossier_id
$sql = "SELECT s.id, s.dossier_id, d.praticien_id
FROM client_suivi s JOIN client_dossiers d ON d.id=s.dossier_id
WHERE s.id=:id AND d.praticien_id=:p AND d.archived_at IS NULL";
$st = $pdo->prepare($sql);
$st->execute([':id'=>$note_id, ':p'=>$praticien_id]);
$own = $st->fetch(PDO::FETCH_ASSOC);
if(!$own) out(false,'Not allowed');
$date_rdv = (string)($_POST['date_rdv'] ?? '');
$type_rdv = (string)($_POST['type_rdv'] ?? 'autre');
$titre = trim((string)($_POST['titre'] ?? ''));
$notes = trim((string)($_POST['notes'] ?? ''));
$prochaine= trim((string)($_POST['prochaine_action'] ?? ''));
$duree = (int)($_POST['duree_minutes'] ?? 0);
if ($titre==='' || $notes==='') out(false,'Champs requis');
try{
$pdo->beginTransaction();
$upd = $pdo->prepare("UPDATE client_suivi
SET date_rdv=:dt, type_rdv=:ty, titre=:ti, notes=:no, prochaine_action=:pa, duree_minutes=:du
WHERE id=:id");
$upd->execute([
':dt'=> (new DateTime($date_rdv ?: 'now'))->format('Y-m-d H:i:s'),
':ty'=> in_array($type_rdv,['online','cabinet','tel','autre'],true) ? $type_rdv : 'autre',
':ti'=> $titre, ':no'=> $notes,
':pa'=> ($prochaine !== '' ? $prochaine : null),
':du'=> ($duree > 0 ? $duree : null),
':id'=> $note_id
]);
// Uploads additionnels
if (!empty($_FILES['files']) && is_array($_FILES['files']['name'])) {
$baseDir = $_SERVER['DOCUMENT_ROOT'].'/uploads/suivi/'. (int)$own['dossier_id'];
if (!is_dir($baseDir)) @mkdir($baseDir, 0775, true);
$allowed = ['pdf','jpg','jpeg','png','doc','docx','txt'];
for($i=0;$i<count($_FILES['files']['name']);$i++){
$err = (int)($_FILES['files']['error'][$i] ?? UPLOAD_ERR_NO_FILE);
if ($err !== UPLOAD_ERR_OK) continue;
$name = (string)($_FILES['files']['name'][$i] ?? '');
$tmp = (string)($_FILES['files']['tmp_name'][$i] ?? '');
$size = (int)($_FILES['files']['size'][$i] ?? 0);
if ($size <= 0 || $size > 8*1024*1024) continue; // 8 Mo
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
if (!in_array($ext,$allowed,true)) continue;
$new = (int)$own['dossier_id'].'_'.time().'_'.bin2hex(random_bytes(4)).'.'.$ext;
$dest = $baseDir.'/'.$new;
if (@move_uploaded_file($tmp, $dest)) {
$rel = '/uploads/suivi/'.(int)$own['dossier_id'].'/'.$new;
$mime= mime_content_type($dest) ?: null;
$pdo->prepare("INSERT INTO client_suivi_files (entry_id, path, mime, size_bytes) VALUES (:e,:p,:m,:s)")
->execute([':e'=>$note_id, ':p'=>$rel, ':m'=>$mime, ':s'=>$size]);
}
}
}
$pdo->commit();
out(true,'OK');
}catch(Throwable $e){
if($pdo->inTransaction()) $pdo->rollBack();
error_log('ajax_editer_note: '.$e->getMessage());
out(false,'Erreur');
}
+108
View File
@@ -0,0 +1,108 @@
<?php
require_once __DIR__ . '/../../includes/init_praticien.php';
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
$praticien_id = (int)($_SESSION['reference_id'] ?? 0);
if ($praticien_id <= 0) {
http_response_code(401);
echo json_encode(['ok'=>false,'message'=>'Unauthorized']); exit;
}
$qRaw = trim((string)($_GET['q'] ?? ''));
$hasQ = ($qRaw !== '');
$qLike = '%'.$qRaw.'%';
$page = max(1, (int)($_GET['page'] ?? 1));
$limit = 10;
$offset= ($page-1)*$limit;
try {
// ----- TOTAL
$sqlTotal = "SELECT COUNT(*) FROM client_dossiers d
JOIN client c ON c.id=d.client_id
WHERE d.praticien_id=:p AND d.archived_at IS NULL";
$paramsT = [':p'=>$praticien_id];
if ($hasQ) {
$sqlTotal .= " AND (
c.nom LIKE :q_nom
OR c.prenom LIKE :q_prenom
OR c.email LIKE :q_email
OR c.telephone LIKE :q_tel
OR d.tags LIKE :q_tags
)";
$paramsT[':q_nom'] = $qLike;
$paramsT[':q_prenom'] = $qLike;
$paramsT[':q_email'] = $qLike;
$paramsT[':q_tel'] = $qLike;
$paramsT[':q_tags'] = $qLike;
}
$stT = $pdo->prepare($sqlTotal);
foreach ($paramsT as $k=>$v) {
$stT->bindValue($k, $v, $k===':p' ? PDO::PARAM_INT : PDO::PARAM_STR);
}
$stT->execute();
$total = (int)$stT->fetchColumn();
// ----- PAGE
$sql = "SELECT d.id AS dossier_id, d.tags,
c.nom, c.prenom, c.email, c.telephone,
(SELECT MAX(rv.date_rdv) FROM rendez_vous rv
WHERE rv.client_id=d.client_id AND rv.praticien_id=d.praticien_id
AND rv.statut IN ('confirmé','terminé')) AS last_rdv,
(SELECT LEFT(s.prochaine_action, 120) FROM client_suivi s
WHERE s.dossier_id=d.id ORDER BY s.date_rdv DESC, s.id DESC LIMIT 1) AS last_note
FROM client_dossiers d
JOIN client c ON c.id=d.client_id
WHERE d.praticien_id=:p AND d.archived_at IS NULL";
$params = [':p'=>$praticien_id];
if ($hasQ) {
$sql .= " AND (
c.nom LIKE :q_nom
OR c.prenom LIKE :q_prenom
OR c.email LIKE :q_email
OR c.telephone LIKE :q_tel
OR d.tags LIKE :q_tags
)";
$params[':q_nom'] = $qLike;
$params[':q_prenom'] = $qLike;
$params[':q_email'] = $qLike;
$params[':q_tel'] = $qLike;
$params[':q_tags'] = $qLike;
}
// tri: d'abord ceux qui ont un last_rdv, puis par date desc, puis maj dossier
$sql .= " ORDER BY (last_rdv IS NULL) ASC, last_rdv DESC, d.updated_at DESC
LIMIT :lim OFFSET :off";
$st = $pdo->prepare($sql);
foreach ($params as $k=>$v) {
if ($k === ':p') $st->bindValue($k, $v, PDO::PARAM_INT);
else $st->bindValue($k, $v, PDO::PARAM_STR);
}
$st->bindValue(':lim', $limit, PDO::PARAM_INT);
$st->bindValue(':off', $offset, PDO::PARAM_INT);
$st->execute();
$items=[];
while($r=$st->fetch(PDO::FETCH_ASSOC)){
$items[]=[
'dossier_id' => (int)$r['dossier_id'],
'tags' => $r['tags'],
'client_name' => trim(($r['prenom'] ?? '').' '.($r['nom'] ?? '')),
'email' => $r['email'] ?? '',
'telephone' => $r['telephone'] ?? '',
'last_rdv_fmt' => !empty($r['last_rdv']) ? (new DateTime($r['last_rdv']))->format('d/m/Y H:i') : '',
'last_note' => $r['last_note'] ?? ''
];
}
echo json_encode(['ok'=>true,'items'=>$items,'total'=>$total,'limit'=>$limit]);
} catch (Throwable $e) {
error_log('clients/ajax_list error: '.$e->getMessage());
http_response_code(500);
echo json_encode(['ok'=>false,'message'=>'Erreur serveur']);
}
@@ -0,0 +1,70 @@
<?php
require_once __DIR__ . '/../../includes/init_praticien.php';
header('Content-Type: application/json; charset=utf-8');
function out($ok,$msg,$extra=[]){ echo json_encode(['ok'=>$ok,'message'=>$msg]+$extra); exit; }
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') out(false,'Méthode invalide');
$praticien_id = (int)($_SESSION['reference_id'] ?? 0);
$csrf = (string)($_POST['csrf'] ?? '');
if (!$praticien_id || !hash_equals($_SESSION['cl_csrf'] ?? '', $csrf)) out(false,'CSRF/Unauthorized');
$dossier_id = (int)($_POST['dossier_id'] ?? 0);
if ($dossier_id <= 0) out(false,'Requête invalide');
// Vérif propriété du dossier
$own = $pdo->prepare("SELECT 1 FROM client_dossiers WHERE id=:d AND praticien_id=:p AND archived_at IS NULL");
$own->execute([':d'=>$dossier_id, ':p'=>$praticien_id]);
if (!$own->fetchColumn()) out(false,'Accès refusé');
try{
// Récupérer chemins des fichiers pour suppression disque après commit
$filePaths = [];
$sf = $pdo->prepare("SELECT f.path
FROM client_suivi_files f
JOIN client_suivi s ON s.id=f.entry_id
WHERE s.dossier_id=:d");
$sf->execute([':d'=>$dossier_id]);
while($r = $sf->fetch(PDO::FETCH_ASSOC)){
$filePaths[] = (string)$r['path'];
}
// Récup IDs des notes
$noteIds = [];
$sn = $pdo->prepare("SELECT id FROM client_suivi WHERE dossier_id=:d");
$sn->execute([':d'=>$dossier_id]);
while($r = $sn->fetch(PDO::FETCH_ASSOC)){
$noteIds[] = (int)$r['id'];
}
$pdo->beginTransaction();
if ($noteIds) {
$ph = implode(',', array_fill(0, count($noteIds), '?'));
$delF = $pdo->prepare("DELETE FROM client_suivi_files WHERE entry_id IN ($ph)");
$delF->execute($noteIds);
$delN = $pdo->prepare("DELETE FROM client_suivi WHERE id IN ($ph)");
$delN->execute($noteIds);
}
$delD = $pdo->prepare("DELETE FROM client_dossiers WHERE id=:d");
$delD->execute([':d'=>$dossier_id]);
$pdo->commit();
// Suppression physique (best-effort)
$root = rtrim((string)$_SERVER['DOCUMENT_ROOT'],'/');
foreach ($filePaths as $rel) {
$abs = $root.$rel;
if (is_file($abs)) @unlink($abs);
}
out(true,'Dossier supprimé');
}catch(Throwable $e){
if ($pdo->inTransaction()) $pdo->rollBack();
error_log('ajax_supprimer_dossier: '.$e->getMessage());
out(false,'Erreur serveur');
}
@@ -0,0 +1,31 @@
<?php
require_once __DIR__ . '/../../includes/init_praticien.php';
header('Content-Type: application/json; charset=utf-8');
function out($ok,$m){ echo json_encode(['ok'=>$ok,'message'=>$m]); exit; }
$praticien_id = (int)($_SESSION['reference_id'] ?? 0);
$csrf=(string)($_POST['csrf'] ?? '');
if (!hash_equals($_SESSION['cl_csrf'] ?? '', $csrf)) out(false,'CSRF');
$file_id = (int)($_POST['file_id'] ?? 0);
if ($file_id<=0) out(false,'Bad file');
$sql = "SELECT f.id, f.path
FROM client_suivi_files f
JOIN client_suivi s ON s.id=f.entry_id
JOIN client_dossiers d ON d.id=s.dossier_id
WHERE f.id=:id AND d.praticien_id=:p AND d.archived_at IS NULL";
$st = $pdo->prepare($sql);
$st->execute([':id'=>$file_id, ':p'=>$praticien_id]);
$row = $st->fetch(PDO::FETCH_ASSOC);
if(!$row) out(false,'Not allowed');
$pdo->beginTransaction();
$pdo->prepare("DELETE FROM client_suivi_files WHERE id=:id")->execute([':id'=>$file_id]);
$pdo->commit();
// Supprime le fichier disque (best-effort)
$abs = rtrim((string)$_SERVER['DOCUMENT_ROOT'],'/').(string)$row['path'];
if (is_file($abs)) @unlink($abs);
out(true,'OK');
@@ -0,0 +1,63 @@
<?php
require_once __DIR__ . '/../../includes/init_praticien.php';
header('Content-Type: application/json; charset=utf-8');
function out($ok, $msg, $extra = []) {
echo json_encode(['ok'=>$ok, 'message'=>$msg] + $extra);
exit;
}
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') out(false, 'Méthode invalide');
$praticien_id = (int)($_SESSION['reference_id'] ?? 0);
$csrf = (string)($_POST['csrf'] ?? '');
if (!$praticien_id || !hash_equals($_SESSION['cl_csrf'] ?? '', $csrf)) {
out(false, 'CSRF/Unauthorized');
}
$note_id = (int)($_POST['note_id'] ?? 0);
if ($note_id <= 0) out(false, 'Requête invalide');
try {
// Vérifier que la note appartient bien au praticien
$check = $pdo->prepare("
SELECT s.id, s.dossier_id
FROM client_suivi s
JOIN client_dossiers d ON d.id = s.dossier_id
WHERE s.id = :id AND d.praticien_id = :p AND d.archived_at IS NULL
LIMIT 1
");
$check->execute([':id'=>$note_id, ':p'=>$praticien_id]);
$own = $check->fetch(PDO::FETCH_ASSOC);
if (!$own) out(false, 'Accès refusé');
// Récupérer la liste des fichiers pour suppression disque après commit
$files = [];
$sf = $pdo->prepare("SELECT id, path FROM client_suivi_files WHERE entry_id = :e");
$sf->execute([':e'=>$note_id]);
while ($row = $sf->fetch(PDO::FETCH_ASSOC)) {
$files[] = (string)$row['path'];
}
$pdo->beginTransaction();
// Supprimer lignes fichiers BDD puis la note
$pdo->prepare("DELETE FROM client_suivi_files WHERE entry_id = :e")->execute([':e'=>$note_id]);
$pdo->prepare("DELETE FROM client_suivi WHERE id = :id")->execute([':id'=>$note_id]);
$pdo->commit();
// Suppression physique (best-effort) après commit
$root = rtrim((string)$_SERVER['DOCUMENT_ROOT'], '/');
foreach ($files as $rel) {
$abs = $root.$rel;
if (is_file($abs)) @unlink($abs);
}
out(true, 'Note supprimée');
} catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
error_log('ajax_supprimer_note: '.$e->getMessage());
out(false, 'Erreur serveur');
}
+104
View File
@@ -0,0 +1,104 @@
<?php
require_once __DIR__ . '/../../includes/init_praticien.php';
require_once __DIR__ . '/../../includes/header.php';
function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
$praticien_id = (int)($_SESSION['reference_id'] ?? 0);
$dossier_id = (int)($_GET['dossier_id'] ?? 0);
if ($dossier_id<=0) { header('Location: index.php'); exit; }
// Vérif propriétaire
$ok = $pdo->prepare("SELECT d.id, d.praticien_id, c.nom, c.prenom
FROM client_dossiers d JOIN client c ON c.id=d.client_id
WHERE d.id=:d AND d.praticien_id=:p AND d.archived_at IS NULL");
$ok->execute([':d'=>$dossier_id, ':p'=>$praticien_id]);
$dos = $ok->fetch(PDO::FETCH_ASSOC);
if(!$dos){ header('Location: index.php'); exit; }
// CSRF
if (empty($_SESSION['cl_csrf'])) $_SESSION['cl_csrf'] = bin2hex(random_bytes(32));
$CSRF = $_SESSION['cl_csrf'];
?>
<div class="main1">
<div class="container py-5">
<div class="shadow p-4 rounded bg-light">
<main class="container my-4">
<div class="card p-3 shadow-sm">
<h4 class="mb-3"><?= h($translations['cl_ajouter_note_titre'] ?? 'Ajouter une note') ?></h4>
<form id="note-form" enctype="multipart/form-data" autocomplete="off">
<input type="hidden" name="csrf" value="<?= h($CSRF) ?>">
<input type="hidden" name="dossier_id" value="<?= (int)$dossier_id ?>">
<div class="row g-3">
<div class="col-12 col-md-4">
<label class="form-label"><?= h($translations['cl_date_rdv'] ?? 'Date') ?></label>
<input type="datetime-local" name="date_rdv" class="form-control" value="<?= h((new DateTime())->format('Y-m-d\TH:i')) ?>">
</div>
<div class="col-12 col-md-4">
<label class="form-label"><?= h($translations['cl_type_rdv'] ?? 'Type') ?></label>
<select class="form-select" name="type_rdv">
<option value="online"><?= h($translations['cl_type_online'] ?? 'En ligne') ?></option>
<option value="cabinet"><?= h($translations['cl_type_cabinet'] ?? 'Au cabinet') ?></option>
<option value="tel"><?= h($translations['cl_type_tel'] ?? 'Téléphone') ?></option>
<option value="autre"><?= h($translations['cl_type_autre'] ?? 'Autre') ?></option>
</select>
</div>
<div class="col-12 col-md-4">
<label class="form-label"><?= h($translations['cl_duree'] ?? 'Durée (min)') ?></label>
<input type="number" name="duree_minutes" class="form-control" min="1" max="600" value="60">
</div>
<div class="col-12">
<label class="form-label"><?= h($translations['cl_titre'] ?? 'Titre') ?></label>
<input type="text" name="titre" class="form-control" required>
</div>
<div class="col-12">
<label class="form-label"><?= h($translations['cl_notes'] ?? 'Notes') ?></label>
<textarea name="notes" class="form-control" rows="6" required></textarea>
</div>
<div class="col-12 col-md-6">
<label class="form-label"><?= h($translations['cl_prochaine_action'] ?? 'Prochaine action') ?></label>
<input type="text" name="prochaine_action" class="form-control">
</div>
<div class="col-12">
<label class="form-label"><?= h($translations['cl_pieces_jointes'] ?? 'Pièces jointes') ?></label>
<input type="file" name="files[]" class="form-control" multiple
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,.txt">
<div class="form-text">Max 8 Mo par fichier.</div>
</div>
<div class="col-12 d-flex justify-content-end gap-2">
<a href="fiche.php?dossier_id=<?= (int)$dossier_id ?>" class="btn btn-outline-secondary"><?= h($translations['cl_pagination_prec'] ?? 'Annuler') ?></a>
<button class="btn btn-primary" type="submit"><?= h($translations['cl_enregistrer'] ?? 'Enregistrer') ?></button>
</div>
</div>
</form>
</div>
</main>
</div>
</div>
</div>
<footer class="site-footer">
<div class="container">
<p><?= $translations['copyr'] ?></p>
</div>
</footer>
<script>
document.getElementById('note-form').addEventListener('submit', async (e)=>{
e.preventDefault();
const fd = new FormData(e.currentTarget);
const r = await fetch('ajax_ajouter_note.php', {method:'POST', body:fd, credentials:'same-origin'});
const j = await r.json();
if(j.ok){
window.location = 'fiche.php?dossier_id=' + encodeURIComponent(<?= (int)$dossier_id ?>);
} else {
alert(j.message || 'Erreur');
}
});
</script>
+31
View File
@@ -0,0 +1,31 @@
<?php
require_once __DIR__ . '/../../includes/init_praticien.php';
$praticien_id = (int)($_SESSION['reference_id'] ?? 0);
$file_id = (int)($_GET['id'] ?? 0);
if ($praticien_id<=0 || $file_id<=0) { http_response_code(400); exit('Bad request'); }
// Vérifie la propriété via jointure
$sql = "SELECT f.path, f.mime, f.size_bytes
FROM client_suivi_files f
JOIN client_suivi s ON s.id=f.entry_id
JOIN client_dossiers d ON d.id=s.dossier_id
WHERE f.id=:id AND d.praticien_id=:p AND d.archived_at IS NULL
LIMIT 1";
$st = $pdo->prepare($sql);
$st->execute([':id'=>$file_id, ':p'=>$praticien_id]);
$row = $st->fetch(PDO::FETCH_ASSOC);
if (!$row) { http_response_code(403); exit('Forbidden'); }
$rel = (string)$row['path'];
$abs = rtrim((string)$_SERVER['DOCUMENT_ROOT'], '/').$rel;
if (!is_file($abs) || !is_readable($abs)) { http_response_code(404); exit('Not found'); }
$mime = $row['mime'] ?: 'application/octet-stream';
$dlName = basename($rel);
header('Content-Type: '.$mime);
header('Content-Length: '.filesize($abs));
header('Content-Disposition: attachment; filename="'.rawurlencode($dlName).'"');
header('X-Content-Type-Options: nosniff');
readfile($abs);
+171
View File
@@ -0,0 +1,171 @@
<?php
require_once __DIR__ . '/../../includes/init_praticien.php';
require_once __DIR__ . '/../../includes/header.php';
function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
$praticien_id = (int)($_SESSION['reference_id'] ?? 0);
$note_id = (int)($_GET['note_id'] ?? 0);
if ($note_id<=0) { header('Location: index.php'); exit; }
// Vérif propriétaire + récupérer dossier_id pour le retour
$sql = "SELECT s.*, d.praticien_id, d.id AS dossier_id
FROM client_suivi s
JOIN client_dossiers d ON d.id=s.dossier_id
WHERE s.id=:id AND d.praticien_id=:p AND d.archived_at IS NULL";
$st = $pdo->prepare($sql);
$st->execute([':id'=>$note_id, ':p'=>$praticien_id]);
$note = $st->fetch(PDO::FETCH_ASSOC);
if (!$note) { header('Location: index.php'); exit; }
$stF = $pdo->prepare("SELECT id, path, mime, size_bytes FROM client_suivi_files WHERE entry_id=:e ORDER BY id ASC");
$stF->execute([':e'=>$note_id]);
$files = $stF->fetchAll(PDO::FETCH_ASSOC);
if (empty($_SESSION['cl_csrf'])) $_SESSION['cl_csrf'] = bin2hex(random_bytes(32));
$CSRF = $_SESSION['cl_csrf'];
?>
<div class="main1">
<div class="container py-5">
<div class="shadow p-4 rounded bg-light">
<main class="container my-4">
<div class="card p-3 shadow-sm">
<h4 class="mb-3"><?= h($translations['modifier'] ?? 'Modifier') ?></h4>
<form id="edit-form" enctype="multipart/form-data">
<input type="hidden" name="csrf" value="<?= h($CSRF) ?>">
<input type="hidden" name="note_id" value="<?= (int)$note_id ?>">
<div class="row g-3">
<div class="col-12 col-md-4">
<label class="form-label"><?= h($translations['cl_date_rdv'] ?? 'Date') ?></label>
<input type="datetime-local" name="date_rdv" class="form-control"
value="<?= h((new DateTime($note['date_rdv']))->format('Y-m-d\TH:i')) ?>">
</div>
<div class="col-12 col-md-4">
<label class="form-label"><?= h($translations['cl_type_rdv'] ?? 'Type') ?></label>
<select class="form-select" name="type_rdv">
<?php foreach([''.$translations['cl_type_online'].'',''.$translations['cl_type_cabinet'].'',''.$translations['cl_type_tel'].'',''.$translations['cl_type_autre'].''] as $opt): ?>
<option value="<?= $opt ?>" <?= $note['type_rdv']===$opt?'selected':'' ?>><?= h($opt) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12 col-md-4">
<label class="form-label"><?= h($translations['cl_duree'] ?? 'Durée (min)') ?></label>
<input type="number" name="duree_minutes" class="form-control" min="1" max="600" value="<?= h((string)($note['duree_minutes'] ?? '')) ?>">
</div>
<div class="col-12">
<label class="form-label"><?= h($translations['cl_titre'] ?? 'Titre') ?></label>
<input type="text" name="titre" class="form-control" required value="<?= h($note['titre']) ?>">
</div>
<div class="col-12">
<label class="form-label"><?= h($translations['cl_notes'] ?? 'Notes') ?></label>
<textarea name="notes" class="form-control" rows="6" required><?= h($note['notes']) ?></textarea>
</div>
<div class="col-12 col-md-6">
<label class="form-label"><?= h($translations['cl_prochaine_action'] ?? 'Prochaine action') ?></label>
<input type="text" name="prochaine_action" class="form-control" value="<?= h((string)$note['prochaine_action']) ?>">
</div>
<div class="col-12">
<label class="form-label"><?= h($translations['cl_pieces_jointes'] ?? 'Pièces jointes') ?></label>
<?php if($files): ?>
<ul class="list-unstyled">
<?php foreach($files as $f): $name = basename((string)$f['path']); ?>
<li class="d-flex align-items-center gap-2 mb-1">
<span class="small"><?= h($name) ?></span>
<a class="btn btn-outline-secondary btn--no-shadow" href="download.php?id=<?= (int)$f['id'] ?>">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16">
<path d="M.5 9.9a.5.5 0 0 1 .5.5V13a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V10.4a.5.5 0 0 1 1 0V13a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V10.4a.5.5 0 0 1 .5-.5z"/>
<path d="M7.646 10.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 9.293V1.5a.5.5 0 0 0-1 0v7.793L5.354 7.146a.5.5 0 1 0-.708.708l3 3z"/>
</svg>
</a>
<button type="button" class="btn btn-outline-danger btn--no-shadow"
data-file="<?= (int)$f['id'] ?>">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16">
<path d="M5.5 5.5A.5.5 0 0 1 6 5h4a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-1 0V6h-3v6.5a.5.5 0 0 1-1 0v-7z"/>
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1 0-2h3l.5-.5h4l.5.5h3a1 1 0 0 1 1 1zM4.118 4l.82 9.823A1 1 0 0 0 5.933 15h4.134a1 1 0 0 0 .995-.823L11.882 4H4.118z"/>
</svg>
</button>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<input type="file" name="files[]" class="form-control" multiple accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,.txt">
<div class="form-text"><?= $translations['8_max']?></div>
</div>
<div class="col-12 d-flex justify-content-end gap-2">
<button class="btn btn-primary" type="submit"><?= h($translations['enregistrer'] ?? 'Enregistrer') ?></button>
<a href="fiche.php?dossier_id=<?= (int)$note['dossier_id'] ?>" class="btn btn-outline-secondary"><?= h($translations['cl_pagination_prec'] ?? 'Annuler') ?></a>
<button type="button" id="btn-delete-note" class="btn btn-outline-danger btn--no-shadow">
<?= h($translations['supprimer'] ?? 'Supprimer') ?>
</button>
</div>
</div>
</form>
</div>
</main>
</div>
</div>
</div>
<footer class="site-footer">
<div class="container">
<p><?= $translations['copyr'] ?></p>
</div>
</footer>
<script>
document.getElementById('btn-delete-note')?.addEventListener('click', async ()=>{
const res = await Swal.fire({
title: <?= json_encode($translations['cl_confirmer_suppression']) ?>,
text: <?= json_encode($translations['cl_action_irreversible']) ?>,
icon: 'warning',
showCancelButton: true,
confirmButtonText: <?= json_encode($translations['btn_supprimer']) ?>,
cancelButtonText: <?= json_encode($translations['btn_annuler']) ?>
});
if (!res.isConfirmed) return;
const fd = new FormData();
fd.append('csrf', '<?= h($CSRF) ?>');
fd.append('note_id', '<?= (int)$note['id'] ?>');
const r = await fetch('ajax_supprimer_note.php', { method:'POST', body:fd, credentials:'same-origin' });
const j = await r.json();
if (j.ok) {
location.href = 'fiche.php?dossier_id=<?= (int)$note['dossier_id'] ?>';
} else {
Swal.fire('Oops', j.message || 'Erreur', 'error');
}
});
</script>
<script>
document.getElementById('edit-form').addEventListener('submit', async (e)=>{
e.preventDefault();
const fd = new FormData(e.currentTarget);
const r = await fetch('ajax_editer_note.php',{method:'POST', body:fd, credentials:'same-origin'});
const j = await r.json();
if(j.ok){
location.href = 'fiche.php?dossier_id=<?= (int)$note['dossier_id'] ?>';
}else{
alert(j.message||'Erreur');
}
});
document.addEventListener('click', async (e)=>{
const b = e.target.closest('button[data-file]');
if(!b) return;
if(!confirm('Supprimer ce fichier ?')) return;
const fd = new FormData();
fd.append('csrf', '<?= h($CSRF) ?>');
fd.append('file_id', b.getAttribute('data-file'));
const r = await fetch('ajax_supprimer_fichier.php',{method:'POST', body:fd, credentials:'same-origin'});
const j = await r.json();
if(j.ok) location.reload(); else alert(j.message||'Erreur');
});
</script>
+287
View File
@@ -0,0 +1,287 @@
<?php
require_once __DIR__ . '/../../includes/init_praticien.php';
function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
function humanSize($bytes){
$u=['B','KB','MB','GB']; $i=0;
while($bytes>=1024 && $i<count($u)-1){ $bytes/=1024; $i++; }
return number_format($bytes, $i?1:0, ',', ' ').' '.$u[$i];
}
function fmtDT(?string $s, string $format='d/m/Y H:i'): string {
$s = trim((string)$s);
if ($s === '' || $s === '0000-00-00' || $s === '0000-00-00 00:00:00') return '—';
try { return (new DateTime($s))->format($format); } catch(Throwable $e){ return '—'; }
}
$praticien_id = (int)($_SESSION['reference_id'] ?? 0);
$dossier_id = (int)($_GET['dossier_id'] ?? 0);
if ($praticien_id<=0 || $dossier_id<=0) { header('Location: index.php'); exit; }
/* ----- Dossier + Client (vérif propriétaire) ----- */
$st = $pdo->prepare("
SELECT
d.id, d.praticien_id, d.client_id, d.tags,
c.nom, c.prenom, c.email, c.telephone, c.adresse, c.ville, c.code_postal, c.date_naissance
FROM client_dossiers d
JOIN client c ON c.id = d.client_id
WHERE d.id = :d AND d.praticien_id = :p AND d.archived_at IS NULL
LIMIT 1
");
$st->execute([':d'=>$dossier_id, ':p'=>$praticien_id]);
$dos = $st->fetch(PDO::FETCH_ASSOC);
if(!$dos){ header('Location: index.php'); exit; }
/* ----- Historique RDV ----- */
$stR = $pdo->prepare("
SELECT date_rdv, duree_minutes, lieu, statut
FROM rendez_vous
WHERE praticien_id = :p AND client_id = :c AND (statut = 'terminé' OR statut = 'confirmé')
ORDER BY date_rdv DESC
LIMIT 100
");
$stR->execute([':p'=>$praticien_id, ':c'=>$dos['client_id']]);
$rdvs = $stR->fetchAll(PDO::FETCH_ASSOC);
/* ----- Notes + fichiers ----- */
$stN = $pdo->prepare("
SELECT s.*
FROM client_suivi s
WHERE s.dossier_id = :d
ORDER BY s.date_rdv DESC, s.id DESC
");
$stN->execute([':d'=>$dossier_id]);
$notes = $stN->fetchAll(PDO::FETCH_ASSOC);
$filesByEntry = [];
if ($notes) {
$ids = array_column($notes, 'id');
$ph = implode(',', array_fill(0, count($ids), '?'));
$stF = $pdo->prepare("
SELECT f.id, f.entry_id, f.path, f.mime, f.size_bytes
FROM client_suivi_files f
WHERE f.entry_id IN ($ph)
ORDER BY f.id ASC
");
$stF->execute($ids);
while($f = $stF->fetch(PDO::FETCH_ASSOC)){
$filesByEntry[(int)$f['entry_id']][] = $f;
}
}
/* ----- CSRF pour actions (si besoin) ----- */
if (empty($_SESSION['cl_csrf'])) $_SESSION['cl_csrf'] = bin2hex(random_bytes(32));
$CSRF = $_SESSION['cl_csrf']; ?>
<form id="csrf-form" class="d-none">
<input type="hidden" name="csrf" value="<?= h($CSRF) ?>">
</form>
<?php
/* Initiales avatar */
$initiales = mb_strtoupper(
mb_substr((string)$dos['prenom'],0,1,'UTF-8').
mb_substr((string)$dos['nom'],0,1,'UTF-8'),
'UTF-8'
);
$fullName = trim(($dos['prenom'] ?? '').' '.($dos['nom'] ?? ''));
require_once __DIR__ . '/../../includes/header.php';
?>
<div class="main1">
<div class="container py-5">
<div class="shadow p-4 rounded bg-light">
<main class="container my-4">
<div class="row g-3">
<!-- ===== COLONNE GAUCHE : Identité client ===== -->
<div class="col-12">
<div class="card p-3 shadow-sm">
<h4 class="mb-3"><?= h($translations['cl_identite'] ?? 'Identité') ?></h4>
<div class="d-flex align-items-center gap-3">
<!--<div class="avatar"><?= h($initiales) ?></div>-->
<div>
<div class="fw-bold fs-5"><?= h($fullName) ?></div>
<div class="text-muted">
<?= h($dos['email']??'') ?><?= !empty($dos['telephone']) ? ' - '.h($dos['telephone']) : '' ?>
</div>
<?php
$addr = trim(($dos['adresse']??'').' '.($dos['code_postal']??'').' '.($dos['ville']??''));
if ($addr !== ''):
?>
<div class="text-muted"><?= h($addr) ?></div>
<?php endif; ?>
<?php if (!empty($dos['date_naissance'])): ?>
<div class="text-muted">
<?= h($translations['label_date_naissance'] ?? 'Date de naissance') ?> :
<?= fmtDT($dos['date_naissance'], 'd/m/Y') ?>
</div>
<?php endif; ?>
<?php if (!empty($dos['tags'])): ?>
<div class="mt-2"><span class="badge bg-light text-dark"><?= h($dos['tags']) ?></span></div>
<?php endif; ?>
</div>
</div>
<div class="mt-3 d-flex gap-2">
<a class="btn btn-outline-secondary" href="ajouter_note.php?dossier_id=<?= (int)$dossier_id ?>">
<?= h($translations['cl_btn_ajouter_note'] ?? 'Ajouter une note') ?>
</a>
<a class="btn btn-outline-secondary" href="index.php">
<?= h($translations['cl_pagination_prec'] ?? 'Retour') ?>
</a>
</div>
</div>
</div>
<!-- ===== COLONNE DROITE : RDV + Notes ===== -->
<div class="col-12">
<!-- Notes de suivi -->
<div class="card p-3 shadow-sm">
<h4 class="mb-3"><?= h($translations['cl_notes_suivi'] ?? 'Notes de suivi') ?></h4>
<?php if(!$notes): ?>
<div class="text-muted"><?= h($translations['cl_aucun_resultat'] ?? 'Aucun résultat.') ?></div>
<?php else: ?>
<?php foreach($notes as $n):
$files = $filesByEntry[(int)$n['id']] ?? [];
?>
<div class="mb-3 p-3 border rounded-3">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="fw-bold"><?= h($n['titre']) ?></div>
<div class="text-muted small">
<?= h(fmtDT($n['date_rdv'])) ?>
· <?= h($translations['cl_type_rdv'] ?? 'Type') ?>: <?= h($n['type_rdv']) ?>
<?php if(!empty($n['duree_minutes'])): ?>
· <?= h($translations['cl_duree'] ?? 'Durée') ?>: <?= (int)$n['duree_minutes'] ?> min
<?php endif; ?>
<?php if(!empty($n['prochaine_action'])): ?>
<br><b><?= h($translations['cl_prochaine_action'] ?? 'Prochaine action') ?>: <?= h($n['prochaine_action']) ?></b>
<?php endif; ?>
</div>
</div>
</div>
<div class="mt-2"><?= nl2br(h($n['notes'])) ?></div>
<?php if ($files): ?>
<div class="mt-3">
<div class="fw-semibold mb-1">📎 <?= h($translations['pieces_jointes'] ?? 'Pièces jointes') ?></div>
<ul class="list-unstyled mb-0">
<?php foreach($files as $f):
$name = basename((string)$f['path']);
?>
<li class="d-flex align-items-center gap-2 mb-1">
<span class="small"><?= h($name) ?></span>
<?php if(!empty($f['size_bytes'])): ?>
<span class="text-muted small">· <?= h(humanSize((int)$f['size_bytes'])) ?></span>
<?php endif; ?>
<a href="download.php?id=<?= (int)$f['id'] ?>" class="btn btn-outline-secondary btn-sm" aria-label="Télécharger">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path d="M.5 9.9a.5.5 0 0 1 .5.5V13a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V10.4a.5.5 0 0 1 1 0V13a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V10.4a.5.5 0 0 1 .5-.5z"/>
<path d="M7.646 10.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 9.293V1.5a.5.5 0 0 0-1 0v7.793L5.354 7.146a.5.5 0 1 0-.708.708l3 3z"/>
</svg>
</a>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<br>
<a class="btn btn-outline-secondary"
href="editer_note.php?note_id=<?= (int)$n['id'] ?>">
<?= h($translations['modifier'] ?? 'Modifier') ?>
</a>
<!-- Ajout de la classe attendue + data-note existant -->
<button type="button"
class="btn btn-outline-danger btn--no-shadow btn-del-note"
data-note="<?= (int)$n['id'] ?>">
<?= h($translations['supprimer'] ?? 'Supprimer') ?>
</button>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
<br>
<!-- Historique des rendez-vous -->
<div class="card p-3 shadow-sm mb-3">
<h4 class="mb-3"><?= h($translations['cl_histo_rdv'] ?? 'Historique des rendez-vous') ?></h4>
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th><?= h($translations['date_label'] ?? 'Date') ?></th>
<th><?= h($translations['cl_type_rdv'] ?? 'Type') ?></th>
<th><?= h($translations['cl_duree'] ?? 'Durée') ?></th>
<th><?= h($translations['cl_col_tags'] ?? 'Statut') ?></th>
</tr>
</thead>
<tbody>
<?php if(!$rdvs): ?>
<tr><td colspan="4" class="text-muted"><?= h($translations['cl_aucun_resultat'] ?? 'Aucun résultat.') ?></td></tr>
<?php else: foreach($rdvs as $r): ?>
<tr>
<td><?= h(fmtDT($r['date_rdv'])) ?></td>
<td><?= h($r['lieu']==='online'
? ($translations['cl_type_online']??'En ligne')
: ($r['lieu']==='offline' ? ($translations['cl_type_cabinet']??'Au cabinet') : ($translations['cl_type_autre']??'Autre'))) ?></td>
<td><?= (int)($r['duree_minutes']??0) ?> min</td>
<td><?= h($r['statut']) ?></td>
</tr>
<?php endforeach; endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
</main>
</div>
</div>
</div>
<footer class="site-footer">
<div class="container">
<p><?= $translations['copyr'] ?></p>
</div>
</footer>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/sweetalert2@11/dist/sweetalert2.min.css">
<script src="/js/sweetalert2@11.js"></script>
<script>
document.addEventListener('click', async (e)=>{
// Plus robuste : on accepte soit .btn-del-note soit tout élément portant data-note
const btn = e.target.closest('.btn-del-note, [data-note]');
if (!btn) return;
const noteId = btn.getAttribute('data-note') || '';
const res = await Swal.fire({
title: <?= json_encode($translations['cl_confirmer_suppression']) ?>,
text: <?= json_encode($translations['cl_action_irreversible']) ?>,
icon: 'warning',
showCancelButton: true,
confirmButtonText: <?= json_encode($translations['btn_supprimer']) ?>,
cancelButtonText: <?= json_encode($translations['btn_annuler']) ?>
});
if (!res.isConfirmed) return;
const fd = new FormData(document.getElementById('csrf-form'));
fd.append('note_id', noteId);
try{
const r = await fetch('ajax_supprimer_note.php', {
method:'POST',
body: fd,
credentials:'same-origin'
});
const j = await r.json();
if (j.ok) {
location.reload();
} else {
Swal.fire('Oops', j.message || 'Erreur', 'error');
}
}catch(err){
Swal.fire('Oops', 'Erreur réseau', 'error');
}
});
</script>
+278
View File
@@ -0,0 +1,278 @@
<?php
require_once __DIR__ . '/../../includes/init_praticien.php';
function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
$praticien_id = (int)($_SESSION['reference_id'] ?? 0);
if ($praticien_id <= 0) { header('Location:/'); exit; }
/* CSRF */
if (empty($_SESSION['cl_csrf'])) $_SESSION['cl_csrf'] = bin2hex(random_bytes(32));
$CSRF = $_SESSION['cl_csrf'];
/* === SSR: première page serveur === */
$limit = 10; $offset = 0;
$sqlTotal = "SELECT COUNT(*) FROM client_dossiers d
JOIN client c ON c.id=d.client_id
WHERE d.praticien_id=:p AND d.archived_at IS NULL";
$stT = $pdo->prepare($sqlTotal);
$stT->execute([':p'=>$praticien_id]);
$total = (int)$stT->fetchColumn();
/* ➜ ICI on lit prochaine_action et on l’alias en last_note */
$sql = "SELECT d.id AS dossier_id, d.tags,
c.nom, c.prenom, c.email, c.telephone,
(SELECT MAX(rv.date_rdv) FROM rendez_vous rv
WHERE rv.client_id=d.client_id AND rv.praticien_id=d.praticien_id
AND rv.statut IN ('confirmé','terminé')) AS last_rdv,
(SELECT LEFT(s.prochaine_action, 120) FROM client_suivi s
WHERE s.dossier_id=d.id
ORDER BY s.date_rdv DESC, s.id DESC LIMIT 1) AS last_note
FROM client_dossiers d
JOIN client c ON c.id=d.client_id
WHERE d.praticien_id=:p AND d.archived_at IS NULL
ORDER BY (last_rdv IS NULL) ASC, last_rdv DESC, d.updated_at DESC
LIMIT :lim OFFSET :off";
$st = $pdo->prepare($sql);
$st->bindValue(':p',$praticien_id,PDO::PARAM_INT);
$st->bindValue(':lim',$limit,PDO::PARAM_INT);
$st->bindValue(':off',$offset,PDO::PARAM_INT);
$st->execute();
$itemsSSR = $st->fetchAll(PDO::FETCH_ASSOC);
$pages = max(1, (int)ceil($total/$limit));
require_once __DIR__ . '/../../includes/header.php';
?>
<div class="main1">
<div class="container py-5">
<div class="shadow p-4 rounded bg-light">
<main class="container my-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="m-0"><?= h($translations['cl_suivi_titre'] ?? 'Suivi clients') ?></h2>
<div>
<input id="search" type="search" class="form-control" style="min-width:260px"
placeholder="<?= h($translations['cl_recherche_placeholder'] ?? 'Rechercher (nom, email, téléphone, tag)…') ?>">
</div>
</div>
<div class="table-responsive mc-ready">
<table id="table_rdv" class="table table-striped align-middle text-center">
<thead>
<tr>
<th><?= h($translations['cl_col_client'] ?? 'Client') ?></th>
<th><?= h($translations['cl_col_dernier_rdv'] ?? 'Dernier RDV') ?></th>
<th><?= h($translations['cl_prochaine_action'] ?? 'Prochaine action') ?></th>
<th class="text-center"><?= h($translations['cl_actions'] ?? 'Actions') ?></th>
</tr>
</thead>
<tbody id="list-body">
<?php if (!$itemsSSR): ?>
<tr><td colspan="4"><?= h($translations['cl_aucun_resultat'] ?? 'Aucun résultat.') ?></td></tr>
<?php else: ?>
<?php foreach($itemsSSR as $r):
$last = !empty($r['last_rdv']) ? (new DateTime($r['last_rdv']))->format('d/m/Y H:i') : '';
$name = trim(($r['prenom'] ?? '').' '.($r['nom'] ?? ''));
?>
<tr>
<td data-label="Client">
<div class="fw-bold"><?= h($name) ?></div>
</td>
<td data-label="Dernier RDV"><?= h($last ?: '—') ?></td>
<td data-label="<?= h($translations['cl_col_prochaine_action'] ?? 'Prochaine action') ?>"><?= h($r['last_note'] ?? '') ?></td>
<td class="text-center" data-label="<?= h($translations['cl_actions'] ?? 'Actions') ?>">
<div class="row-actions d-flex justify-content-center gap-2">
<a class="btn btn-outline-secondary" href="fiche.php?dossier_id=<?= (int)$r['dossier_id'] ?>"><?= h($translations['cl_btn_infos'] ?? 'Informations') ?></a>
<a class="btn btn-outline-secondary" href="ajouter_note.php?dossier_id=<?= (int)$r['dossier_id'] ?>"><?= h($translations['cl_btn_ajouter_note'] ?? 'Ajouter une note') ?></a>
<button type="button"
class="btn btn-outline-danger"
onclick="deleteDossier(<?= (int)$r['dossier_id'] ?>, this)">
<?= h($translations['cl_btn_supprimer'] ?? 'Supprimer') ?>
</button>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<div class="d-flex justify-content-center gap-2 my-3" id="pager">
<?php for($p=1;$p<=$pages;$p++): ?>
<button type="button" class="btn btn-sm <?= $p===1?'btn-secondary':'btn-outline-secondary' ?>" data-page="<?= $p ?>"><?= $p ?></button>
<?php endfor; ?>
</div>
<form id="csrf-form" class="d-none"><input type="hidden" name="csrf" value="<?= h($CSRF) ?>"></form>
</main>
</div>
</div>
</div>
<footer class="site-footer">
<div class="container">
<p><?= $translations['copyr'] ?></p>
</div>
</footer>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/sweetalert2@11/dist/sweetalert2.min.css">
<script src="/js/sweetalert2@11.js"></script>
<?php
$I18N = [
'confirmer_suppression' => $translations['cl_confirmer_suppression'] ?? 'Confirmer la suppression ?',
'action_irreversible' => $translations['cl_action_irreversible'] ?? 'Cette action est irréversible.',
'supprimer' => $translations['cl_btn_supprimer'] ?? 'Supprimer',
'annuler' => $translations['btn_annuler'] ?? 'Annuler',
'aucun_resultat' => $translations['cl_aucun_resultat'] ?? 'Aucun résultat.',
'col_prochaine_action' => $translations['cl_col_prochaine_action'] ?? 'Prochaine action',
];
?>
<script>
// === i18n (sûr) ===
const I18N = <?= json_encode(
$I18N,
JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
) ?>;
function T(k, d){ return (Object.prototype.hasOwnProperty.call(I18N, k) ? I18N[k] : d); }
// === Utils ===
function escapeHtml(s){ return (s||'').replace(/[&<>"']/g, c=>({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c])); }
function fmtDate(s){ return s || '—'; }
function debounce(fn,ms){ let t; return (...a)=>{ clearTimeout(t); t=setTimeout(()=>fn(...a),ms); }; }
// === Rendu dynamique ===
function render(items,total,limit){
const tbody = document.getElementById('list-body');
tbody.innerHTML = '';
if(!items || !items.length){
const tr = document.createElement('tr');
const td = document.createElement('td');
td.colSpan = 4;
td.textContent = T('aucun_resultat');
tr.appendChild(td); tbody.appendChild(tr);
} else {
items.forEach(it=>{
const tr = document.createElement('tr');
tr.innerHTML = `
<td data-label="<?= h($translations['cl_col_client'] ?? 'Client') ?>">
<div class="fw-bold">${escapeHtml(it.client_name||'')}</div>
</td>
<td data-label="<?= h($translations['cl_col_dernier_rdv'] ?? 'Dernier RDV') ?>">${escapeHtml(fmtDate(it.last_rdv_fmt))}</td>
<td data-label="${escapeHtml(I18N.col_prochaine_action)}">${escapeHtml(it.last_note||'')}</td>
<td class="text-center" data-label="<?= h($translations['cl_actions'] ?? 'Actions') ?>">
<div class="row-actions d-flex justify-content-center gap-2">
<a class="btn btn-outline-secondary" href="fiche.php?dossier_id=${encodeURIComponent(it.dossier_id)}"><?= h($translations['cl_btn_infos'] ?? 'Informations') ?></a>
<a class="btn btn-outline-secondary" href="ajouter_note.php?dossier_id=${encodeURIComponent(it.dossier_id)}"><?= h($translations['cl_btn_ajouter_note'] ?? 'Ajouter une note') ?></a>
<button type="button"
class="btn btn-outline-danger"
onclick="deleteDossier(${Number(''+it.dossier_id)}, this)">
<?= h($translations['cl_btn_supprimer'] ?? 'Supprimer') ?>
</button>
</div>
</td>`;
tbody.appendChild(tr);
});
}
// Pager
const pager = document.getElementById('pager');
const pages = Math.max(1, Math.ceil(total/limit));
pager.innerHTML = '';
for(let p=1; p<=pages; p++){
const b=document.createElement('button');
b.type='button';
b.className='btn btn-sm '+(p===window._page?'btn-secondary':'btn-outline-secondary');
b.textContent=String(p);
b.dataset.page = String(p);
b.onclick=()=>{ window._page=p; load(); };
pager.appendChild(b);
}
}
// === Data ===
window._page = 1;
window._q = '';
async function load(){
try{
const url = `ajax_list.php?q=${encodeURIComponent(window._q)}&page=${window._page}&_=${Date.now()}`;
const r = await fetch(url, { credentials:'same-origin', cache:'no-store' });
const txt = await r.text();
let j;
try { j = JSON.parse(txt); }
catch(parseErr){ console.error('ajax_list.php non-JSON:', txt); throw new Error('Réponse invalide'); }
if(!j.ok) throw new Error(j.message||'Erreur');
render(j.items||[], j.total||0, j.limit||10);
}catch(e){
console.error('load() failed:', e);
render([], 0, 10);
}
}
// === Recherche ===
document.getElementById('search').addEventListener('input', debounce((e)=>{ window._q=e.target.value||''; window._page=1; load(); }, 300));
// === Suppression : fonction globale appelée par onclick ===
window.deleteDossier = async function(dossierId, btnEl){
try{
const confirmWithSwal = async ()=>{
if (typeof Swal !== 'undefined' && Swal.fire) {
const res = await Swal.fire({
title: T('confirmer_suppression'),
text: T('action_irreversible'),
icon: 'warning',
showCancelButton: true,
confirmButtonText: T('supprimer'),
cancelButtonText: T('annuler')
});
return res.isConfirmed;
}
return window.confirm('Supprimer ce dossier ?');
};
if (!(await confirmWithSwal())) return;
btnEl?.setAttribute('disabled','disabled');
btnEl?.classList.add('disabled');
const fd = new FormData(document.getElementById('csrf-form'));
fd.append('dossier_id', String(dossierId));
const r = await fetch('ajax_supprimer_dossier.php', {
method:'POST', body:fd, credentials:'same-origin', cache:'no-store'
});
let j;
try { j = await r.json(); }
catch(_){ const txt = await r.text(); console.error('Réponse non-JSON de ajax_supprimer_dossier.php:', txt); alert('Erreur serveur'); return; }
if (j.ok) {
const tr = btnEl?.closest('tr'); if (tr) tr.remove();
const rowsLeft = document.querySelectorAll('#list-body > tr').length;
if (rowsLeft === 0 && window._page > 1) window._page--;
await load();
if (typeof Swal !== 'undefined' && Swal.fire) {
Swal.fire({ icon:'success', title:'OK', text:'Dossier supprimé', timer:1200, showConfirmButton:false });
}
} else {
alert(j.message || 'Erreur');
}
}catch(err){
console.error(err);
alert('Erreur réseau');
}finally{
btnEl?.removeAttribute('disabled');
btnEl?.classList.remove('disabled');
}
};
// === Pager SSR (premier rendu) ===
document.getElementById('pager').addEventListener('click', (e)=>{
const b = e.target.closest('button[data-page]');
if(!b) return;
window._page = parseInt(b.dataset.page || '1', 10) || 1;
load();
});
// === Initial load ===
load();
</script>