fixed reprogrm

This commit is contained in:
2026-10-14 15:02:51 +02:00
parent 45c432916a
commit f17f45f5fd
3 changed files with 406 additions and 81 deletions
+214
View File
@@ -0,0 +1,214 @@
<?php
/**
* Riprogrammazione di una prenotazione da parte della scuola.
*
* POST: id = idbookingclass
* idschedule = idserviceschedule della nuova lezione
* send_email = Y/N email all'allieva con la nuova data
* count_reprog = Y/N conta come riprogrammazione (orderbook.reprogrammed + 1)
*
* Sposta la prenotazione salvando la vecchia lezione in idprevserviceschedule / prevbookingstart.
* Controlla posti liberi e doppia prenotazione con le righe bloccate (FOR UPDATE).
*/
ob_start();
require_once('include/headscript.php');
function jsonOut(array $payload, int $code = 200): void
{
if (ob_get_length() !== false) ob_end_clean();
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($payload, JSON_UNESCAPED_UNICODE);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonOut(['success' => false, 'message' => 'Metodo non consentito.'], 405);
}
$id = (int) ($_POST['id'] ?? 0);
$idSchedule = (int) ($_POST['idschedule'] ?? 0);
$sendEmail = ($_POST['send_email'] ?? 'N') === 'Y';
$countReprog = ($_POST['count_reprog'] ?? 'N') === 'Y';
$staffId = (int) $iduserlogin;
if ($id <= 0 || $idSchedule <= 0) {
jsonOut(['success' => false, 'message' => 'Seleziona la nuova lezione.'], 400);
}
$errors = [
'notfound' => 'Prenotazione non trovata (forse è stata cancellata).',
'notarget' => 'La lezione scelta non esiste più.',
'same' => 'Hai scelto la stessa lezione di adesso.',
'past' => 'La lezione scelta è già iniziata o passata.',
'already' => 'L\'allieva è già iscritta alla lezione scelta.',
'full' => 'La lezione scelta è piena.',
];
$pdo = DBHandlerSelect::getInstance()->getConnection();
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$bk = null;
$target = null;
$incremented = 'N';
try {
$pdo->beginTransaction();
// Prenotazione attuale (bloccata)
$st = $pdo->prepare("
SELECT bc.idbookingclass, bc.iduser, bc.idorder, bc.idserviceschedule, bc.bookingstart,
s.servicename AS old_servicename, ss.dateschedule AS old_date,
u.email, u.first_name
FROM bookingclass bc
LEFT JOIN service s ON s.idservice = bc.idservice
LEFT JOIN serviceschedule ss ON ss.idserviceschedule = bc.idserviceschedule
LEFT JOIN auth_users u ON u.id = bc.iduser
WHERE bc.idbookingclass = ?
FOR UPDATE
");
$st->execute([$id]);
$bk = $st->fetch(PDO::FETCH_ASSOC);
if (!$bk) throw new RuntimeException('notfound');
if ((int) $bk['idserviceschedule'] === $idSchedule) throw new RuntimeException('same');
// Nuova lezione (bloccata: evita due spostamenti contemporanei sull'ultimo posto)
$st = $pdo->prepare("
SELECT ss.idserviceschedule, ss.idservice, ss.dateschedule, s.servicename, s.maxcapacity
FROM serviceschedule ss
JOIN service s ON s.idservice = ss.idservice
WHERE ss.idserviceschedule = ?
FOR UPDATE
");
$st->execute([$idSchedule]);
$target = $st->fetch(PDO::FETCH_ASSOC);
if (!$target) throw new RuntimeException('notarget');
if (strtotime($target['dateschedule']) <= time()) throw new RuntimeException('past');
// L'allieva è già su quella lezione?
$st = $pdo->prepare("SELECT COUNT(*) FROM bookingclass WHERE idserviceschedule = ? AND iduser = ?");
$st->execute([$idSchedule, (int) $bk['iduser']]);
if ((int) $st->fetchColumn() > 0) throw new RuntimeException('already');
// Posti liberi (booked + pending)
if ($target['maxcapacity'] !== null) {
$st = $pdo->prepare("SELECT COUNT(*) FROM bookingclass WHERE idserviceschedule = ? AND status IN ('booked','pending')");
$st->execute([$idSchedule]);
if ((int) $st->fetchColumn() >= (int) $target['maxcapacity']) throw new RuntimeException('full');
}
// Spostamento: la vecchia lezione resta in idprevserviceschedule / prevbookingstart
$up = $pdo->prepare("
UPDATE bookingclass
SET idprevserviceschedule = :prevsched,
prevbookingstart = :prevstart,
idserviceschedule = :sched,
idservice = :svc,
bookingstart = :start,
is_reprogrammed = 'Y'
WHERE idbookingclass = :id
");
$up->execute([
':prevsched' => $bk['idserviceschedule'] !== null ? (int) $bk['idserviceschedule'] : null,
':prevstart' => $bk['bookingstart'],
':sched' => $idSchedule,
':svc' => (int) $target['idservice'],
':start' => $target['dateschedule'],
':id' => $id,
]);
// Conta come riprogrammazione
if ($countReprog && $bk['idorder'] !== null) {
$st = $pdo->prepare("UPDATE orderbook SET reprogrammed = reprogrammed + 1 WHERE idorderbook = ?");
$st->execute([(int) $bk['idorder']]);
if ($st->rowCount() === 1) $incremented = 'Y';
}
$pdo->commit();
} catch (PDOException $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
error_log("admin_reprogram_booking #{$id} -> {$idSchedule} (staff #{$staffId}): " . $e->getMessage());
jsonOut(['success' => false, 'message' => 'Errore durante la riprogrammazione. Riprova.'], 500);
} catch (RuntimeException $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
$code = $e->getMessage();
jsonOut(['success' => false, 'message' => $errors[$code] ?? 'Errore durante la riprogrammazione.'], 409);
}
/* -------------------------------------------------------------------------
* Email all'allieva (dopo il commit, solo se richiesta)
* ---------------------------------------------------------------------- */
$fmt = fn($d) => $d ? date('d/m/Y H:i', strtotime($d)) : '';
$emailNote = '';
if ($sendEmail) {
$to = trim((string) ($bk['email'] ?? ''));
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
$emailNote = ' Email non inviata: indirizzo dell\'allieva mancante o non valido.';
} else {
try {
require_once(__DIR__ . '/class/mailer.php');
$clean = fn($v) => trim((string) $v, "\" \t\n\r\0\x0B");
$enc = strtolower($clean($_ENV['MAIL_ENCRYPTION'] ?? 'ssl'));
$mail = new \PHPMailer\PHPMailer\PHPMailer(true);
$mail->isSMTP();
$mail->Host = $clean($_ENV['MAIL_HOST'] ?? '');
$mail->SMTPAuth = true;
$mail->Username = $_ENV['MAIL_USERNAME'] ?? '';
$mail->Password = $_ENV['MAIL_PASSWORD'] ?? '';
$mail->Port = (int) ($_ENV['MAIL_PORT'] ?? 465);
if (in_array($enc, ['tls', 'ssl'], true)) {
$mail->SMTPSecure = $enc;
}
$mail->Timeout = 20;
$mail->CharSet = 'UTF-8';
$fromName = $clean($_ENV['MAIL_FROM_NAME'] ?? 'YogiBook');
$mail->setFrom($clean($_ENV['MAIL_FROM_ADDRESS'] ?? ''), $fromName);
$h = fn($v) => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
$nome = trim((string) ($bk['first_name'] ?? ''));
$oldName = $bk['old_servicename'] ?: 'Lezione';
$oldDate = $fmt($bk['old_date'] ?: $bk['bookingstart']);
$newName = $target['servicename'] ?: 'Lezione';
$newDate = $fmt($target['dateschedule']);
$mail->addAddress($to, $nome);
$mail->isHTML(true);
$mail->Subject = 'Lezione riprogrammata - ' . $newName . ' ' . $newDate;
$mail->Body = "
<div style='font-family:Arial,sans-serif;font-size:14px;color:#222'>
<p>Ciao " . $h($nome !== '' ? $nome : 'Yogi') . ",</p>
<p>la tua lezione è stata <b>riprogrammata</b>.</p>
<p>
<b>Prima:</b> " . $h($oldName) . " &ndash; " . $h($oldDate) . "<br>
<b>Adesso:</b> " . $h($newName) . " &ndash; <b>" . $h($newDate) . "</b>
</p>
<p>Se hai domande, rispondi pure a questa email.</p>
<p>A presto,<br>" . $h($fromName) . "</p>
</div>";
$mail->AltBody = "Ciao " . ($nome !== '' ? $nome : 'Yogi') . ",\n"
. "la tua lezione è stata riprogrammata.\n"
. "Prima: {$oldName} - {$oldDate}\nAdesso: {$newName} - {$newDate}\n\n{$fromName}";
$mail->send();
$emailNote = ' Email inviata all\'allieva.';
} catch (Throwable $e) {
error_log("admin_reprogram_booking #{$id}: email non inviata: " . $e->getMessage());
$emailNote = ' Attenzione: la lezione è stata spostata ma l\'email non è partita.';
}
}
}
$reprogNote = $incremented === 'Y' ? ' Conteggiata come riprogrammazione.' : '';
jsonOut([
'success' => true,
'message' => 'Lezione spostata a ' . $fmt($target['dateschedule']) . '.' . $reprogNote . $emailNote,
]);
+109
View File
@@ -0,0 +1,109 @@
<?php
/**
* Elenco delle lezioni in calendario verso cui spostare una prenotazione (uso scuola).
* GET: id = idbookingclass
*
* Restituisce le lezioni future (prossimi 90 giorni) della stessa classe
* e delle classi associate (associateclass), con posti occupati e flag.
*/
ob_start();
require_once('include/headscript.php');
function jsonOut(array $payload, int $code = 200): void
{
if (ob_get_length() !== false) ob_end_clean();
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($payload, JSON_UNESCAPED_UNICODE);
exit;
}
$id = (int) ($_GET['id'] ?? 0);
if ($id <= 0) {
jsonOut(['success' => false, 'message' => 'Prenotazione non valida.'], 400);
}
$pdo = DBHandlerSelect::getInstance()->getConnection();
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
try {
// Prenotazione + ordine collegato
$st = $pdo->prepare("
SELECT bc.idbookingclass, bc.iduser, bc.idservice, bc.idserviceschedule, bc.idorder,
o.expireon, o.reprogrammed, o.maxreschedule
FROM bookingclass bc
LEFT JOIN orderbook o ON o.idorderbook = bc.idorder
WHERE bc.idbookingclass = ?
");
$st->execute([$id]);
$bk = $st->fetch(PDO::FETCH_ASSOC);
if (!$bk) {
jsonOut(['success' => false, 'message' => 'Prenotazione non trovata.'], 404);
}
// Classi ammesse: la stessa + quelle associate
$services = [(int) $bk['idservice']];
$as = $pdo->prepare("SELECT idassociateservice FROM associateclass WHERE idmainservice = ?");
$as->execute([(int) $bk['idservice']]);
foreach ($as->fetchAll(PDO::FETCH_COLUMN) as $sid) {
$services[] = (int) $sid;
}
$services = array_values(array_unique($services));
$ph = implode(',', array_fill(0, count($services), '?'));
$sql = "
SELECT ss.idserviceschedule, ss.dateschedule,
s.servicename, s.maxcapacity,
(SELECT COUNT(*) FROM bookingclass b
WHERE b.idserviceschedule = ss.idserviceschedule
AND b.status IN ('booked','pending')) AS occupied,
(SELECT COUNT(*) FROM bookingclass b2
WHERE b2.idserviceschedule = ss.idserviceschedule
AND b2.iduser = ?) AS mine
FROM serviceschedule ss
JOIN service s ON s.idservice = ss.idservice
WHERE ss.idservice IN ($ph)
AND ss.dateschedule > NOW()
AND ss.dateschedule <= DATE_ADD(NOW(), INTERVAL 90 DAY)
ORDER BY ss.dateschedule
LIMIT 200";
$ls = $pdo->prepare($sql);
$ls->execute(array_merge([(int) $bk['iduser']], $services));
$giorni = ['Dom', 'Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab'];
$expiry = $bk['expireon'] ? strtotime($bk['expireon'] . ' 23:59:59') : null;
$lessons = [];
foreach ($ls->fetchAll(PDO::FETCH_ASSOC) as $l) {
$ts = strtotime($l['dateschedule']);
$max = $l['maxcapacity'] !== null ? (int) $l['maxcapacity'] : null;
$occ = (int) $l['occupied'];
$lessons[] = [
'id' => (int) $l['idserviceschedule'],
'label' => $giorni[(int) date('w', $ts)] . ' ' . date('d/m/Y H:i', $ts),
'servicename' => $l['servicename'],
'occupied' => $occ,
'max' => $max,
'full' => $max !== null && $occ >= $max,
'mine' => (int) $l['mine'] > 0,
'after_expiry' => $expiry !== null && $ts > $expiry,
];
}
jsonOut([
'success' => true,
'lessons' => $lessons,
'order' => [
'has_order' => $bk['idorder'] !== null,
'reprogrammed' => (int) ($bk['reprogrammed'] ?? 0),
'maxreschedule' => (int) ($bk['maxreschedule'] ?? 0),
'expireon' => $bk['expireon'] ? date('d/m/Y', strtotime($bk['expireon'])) : null,
],
]);
} catch (PDOException $e) {
error_log("admin_reprogram_lessons #{$id}: " . $e->getMessage());
jsonOut(['success' => false, 'message' => 'Errore nel caricamento delle lezioni.'], 500);
}
+82 -80
View File
@@ -475,12 +475,18 @@ $expiredCertCount = count(array_filter($users, fn($u) => (int) $u['ha_ordine_att
<div class="modal-body">
<form id="reprogramForm">
<div class="mb-3">
<label for="classSelect" class="form-label">Seleziona classe</label>
<select class="form-control" id="classSelect" required></select>
<label for="lessonSelect" class="form-label">Nuova lezione</label>
<select class="form-select" id="lessonSelect"></select>
<div class="form-text">Lezioni della stessa classe e delle classi associate, nei prossimi 90 giorni.</div>
</div>
<div class="mb-3">
<label for="reprogramDate" class="form-label">Seleziona data</label>
<input type="text" class="form-control" id="reprogramDate" readonly required>
<div id="reprogramInfo" class="text-muted mb-3" style="font-size:13px;"></div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="rpSendEmail" checked>
<label class="form-check-label" for="rpSendEmail">Invia email all'allieva con la nuova data</label>
</div>
<div class="form-check" id="rpCountWrap">
<input class="form-check-input" type="checkbox" id="rpCountReprog">
<label class="form-check-label" for="rpCountReprog">Conta come riprogrammazione</label>
</div>
<input type="hidden" id="reprogramIdBookingClass">
</form>
@@ -748,108 +754,104 @@ Ti ringraziamo per la collaborazione e per l’attenzione alla sicurezza di tutt
});
});
// Riprogramma: apri modale
// Riprogramma (scuola): scegli una lezione già in calendario
$(document).on('click', '.reprogram-class', function() {
var id = $(this).data('id'),
servicename = $(this).data('servicename'),
day = $(this).data('day'),
time = $(this).data('time');
var id = $(this).data('id');
$('#reprogramIdBookingClass').val(id);
$('#reprogramModalLabel').text('Riprogramma: ' + servicename);
$.ajax({
url: 'fetch_available_classes.php',
method: 'GET',
dataType: 'json',
success: function(data) {
var sel = $('#classSelect').empty();
$.each(data, function(i, c) {
var opt = $('<option>').val(c.id).text(c.servicename + ' (' + c.day + ' ' + c.time + ')');
if (c.servicename === servicename && c.day === day && c.time === time) opt.prop('selected', true);
sel.append(opt);
});
$('#reprogramDate').datepicker({
dateFormat: 'yy-mm-dd',
changeMonth: true,
changeYear: true
});
loadAvailableDates(sel.val());
$('#reprogramModal').modal('show');
},
error: () => alertBox({
title: 'Errore',
text: 'Errore nel caricamento delle classi.',
icon: 'error'
})
});
});
$('#reprogramModalLabel').text('Riprogramma: ' + $(this).data('servicename'));
$('#lessonSelect').html('<option>Caricamento…</option>').prop('disabled', true);
$('#reprogramInfo').text('');
$('#rpSendEmail').prop('checked', true);
$('#rpCountReprog').prop('checked', false);
$('#reprogramModal').data('hasorder', false).modal('show');
$('#classSelect').on('change', function() {
loadAvailableDates($(this).val());
});
function loadAvailableDates(classId) {
$.ajax({
url: 'fetch_available_dates.php',
method: 'POST',
data: {
class_id: classId
},
dataType: 'json',
success: function(data) {
$('#reprogramDate').datepicker('option', 'beforeShowDay', function(date) {
var s = $.datepicker.formatDate('yy-mm-dd', date);
return [data.availableDates.includes(s), ''];
});
},
error: () => alertBox({
title: 'Errore',
text: 'Errore nel caricamento delle date.',
icon: 'error'
$.getJSON('admin_reprogram_lessons.php', {
id: id
})
});
.done(function(data) {
var sel = $('#lessonSelect').empty();
if (!data.success) {
sel.append($('<option>').text(data.message || 'Errore'));
return;
}
sel.prop('disabled', false)
.append($('<option value="">').text('— Scegli la nuova lezione —'));
if (!data.lessons.length) {
sel.append($('<option disabled>').text('Nessuna lezione nei prossimi 90 giorni'));
}
$.each(data.lessons, function(i, l) {
var txt = l.label + ' · ' + l.servicename + ' · ' + l.occupied + (l.max !== null ? '/' + l.max : '') + ' posti';
if (l.mine) txt += ' (già iscritta)';
else if (l.full) txt += ' (piena)';
else if (l.after_expiry) txt += ' (oltre la scadenza del pacchetto)';
sel.append($('<option>').val(l.id).text(txt).prop('disabled', l.mine || l.full));
});
var o = data.order;
$('#reprogramModal').data('hasorder', o.has_order);
$('#rpCountWrap').toggle(o.has_order);
$('#reprogramInfo').text(o.has_order ?
'Riprogrammazioni usate: ' + o.reprogrammed + ' su ' + o.maxreschedule +
(o.expireon ? ' · Scadenza pacchetto: ' + o.expireon : '') :
'Lezione omaggio: nessuna riprogrammazione da conteggiare.');
})
.fail(function() {
$('#lessonSelect').html('<option>Errore nel caricamento delle lezioni</option>');
});
});
$('#reprogramBtn').on('click', function() {
var btn = $(this);
var id = $('#reprogramIdBookingClass').val(),
classId = $('#classSelect').val(),
newDate = $('#reprogramDate').val();
sched = $('#lessonSelect').val();
var idU = $('#classModal').data('current-id'),
nomeU = $('#userName').text();
if (!newDate || !classId) {
if (!sched) {
alertBox({
title: 'Errore',
text: 'Seleziona classe e data.',
icon: 'error'
title: 'Attenzione',
text: 'Scegli la nuova lezione.',
icon: 'warning'
});
return;
}
$.post('reprogram_class.php', {
btn.prop('disabled', true);
$.post('admin_reprogram_booking.php', {
id: id,
class_id: classId,
new_date: newDate
idschedule: sched,
send_email: $('#rpSendEmail').is(':checked') ? 'Y' : 'N',
count_reprog: ($('#reprogramModal').data('hasorder') && $('#rpCountReprog').is(':checked')) ? 'Y' : 'N'
}, null, 'json')
.done(function(resp) {
if (resp.success) {
if (resp && resp.success) {
$('#reprogramModal').modal('hide');
alertBox({
title: 'Fatto',
text: 'Lezione riprogrammata.',
text: resp.message,
icon: 'success'
});
$('#reprogramModal').modal('hide');
refreshTable(idU, nomeU);
} else alertBox({
} else {
alertBox({
title: 'Errore',
text: resp.message || 'Errore durante la riprogrammazione.',
text: (resp && resp.message) || 'Errore durante la riprogrammazione.',
icon: 'error'
});
}
})
.fail(function(xhr) {
var msg = (xhr.responseJSON && xhr.responseJSON.message) || 'Errore durante la riprogrammazione.';
alertBox({
title: 'Errore',
text: msg,
icon: 'error'
});
})
.fail(() => alertBox({
title: 'Errore',
text: 'Errore durante la riprogrammazione.',
icon: 'error'
}));
.always(function() {
btn.prop('disabled', false);
});
});
// Email singola
$('.send-email').on('click', function() {
var id = $(this).data('id'),