diff --git a/db/migrations/20260822064630_add_is_gift_to_bookingclass.php b/db/migrations/20260822064630_add_is_gift_to_bookingclass.php new file mode 100644 index 00000000..494d369d --- /dev/null +++ b/db/migrations/20260822064630_add_is_gift_to_bookingclass.php @@ -0,0 +1,35 @@ +table('bookingclass'); + $table->addColumn('is_gift', 'char', [ + 'limit' => 1, + 'null' => false, + 'default' => 'N', + 'after' => 'is_reprogrammed', + ])->update(); + } + + public function down(): void + { + $table = $this->table('bookingclass'); + $table->removeColumn('is_gift')->update(); + } +} diff --git a/public/adminpanel.php b/public/adminpanel.php index 835e3000..c5a13795 100644 --- a/public/adminpanel.php +++ b/public/adminpanel.php @@ -479,6 +479,17 @@ $italianMonths = [ + 'Questo utente è già prenotato in questa classe.', + 'insert' => 'Errore durante l\'inserimento. Riprova.', + 'missingdata' => 'Dati mancanti per l\'inserimento.', + ]; + $em = $errMap[$_GET['error']] ?? 'Si è verificato un errore.'; + ?> + + +

@@ -542,7 +553,7 @@ $italianMonths = [ $ini = initials($b['first_name'] ?? '', $b['last_name'] ?? ''); $avColor = avatarColor($fullName); ?> -
+
@@ -755,6 +766,163 @@ $italianMonths = [ }); }); + // === Intercetta submit "Aggiungi partecipante" === + $(document).on('submit', '.add-booking-form', function(e) { + e.preventDefault(); + const form = $(this); + const userid = form.find('.userid').val(); + const name = form.find('.name').val().trim(); + const surname = form.find('.surname').val().trim(); + + if (!name || !surname) { + Swal.fire('Dati mancanti', 'Inserisci nome e cognome.', 'warning'); + return; + } + + const hasUser = userid && parseInt(userid) > 0; + + // Controllo anti-duplicato PRIMA di aprire il modale: + // l'utente è già presente in questa classe? + if (hasUser) { + const panel = form.closest('.bookings-panel'); + const already = panel.find('.booking-item[data-iduser="' + parseInt(userid) + '"]').length > 0; + if (already) { + Swal.fire( + 'Già prenotato', + 'Questa persona è già presente in questa classe.', + 'info' + ); + return; + } + } + + if (hasUser) { + $.getJSON('get_user_orders.php', { + userid: userid + }).done(function(data) { + showBookingModal(form, data.orders || []); + }).fail(function() { + showBookingModal(form, []); + }); + } else { + showBookingModal(form, []); + } + }); + + function showBookingModal(form, orders) { + const usableOrders = orders.filter(o => o.remaining > 0); + let orderOptions = ''; + usableOrders.forEach(function(o) { + const exp = o.expireon ? ' - scade ' + o.expireon : ''; + orderOptions += ''; + }); + + const hasUsableOrders = usableOrders.length > 0; + + const d = new Date(); + d.setMonth(d.getMonth() + 3); + const defExpiry = d.toISOString().slice(0, 10); + + const html = + '
' + + '' + + '' + + '' + + '' + + '' + + '
'; + + Swal.fire({ + title: 'Aggiungi partecipante', + html: html, + showCancelButton: true, + confirmButtonText: 'Inserisci', + cancelButtonText: 'Annulla', + confirmButtonColor: '#1ebf73', + didOpen: () => { + document.querySelectorAll('input[name="bkmode"]').forEach(function(r) { + r.addEventListener('change', function() { + document.getElementById('bk-nuovo-fields').style.display = + (this.value === 'nuovo') ? 'block' : 'none'; + }); + }); + }, + preConfirm: () => { + const mode = document.querySelector('input[name="bkmode"]:checked'); + if (!mode) { + Swal.showValidationMessage('Seleziona una modalità'); + return false; + } + const result = { + mode: mode.value + }; + if (mode.value === 'scala') { + const sel = document.getElementById('bk-order').value; + if (!sel) { + Swal.showValidationMessage('Seleziona un pacchetto'); + return false; + } + result.idorder = sel; + } + if (mode.value === 'nuovo') { + const exp = document.getElementById('bk-expiry').value; + if (!exp) { + Swal.showValidationMessage('Inserisci la scadenza'); + return false; + } + result.expiry = exp; + } + return result; + } + }).then((res) => { + if (!res.isConfirmed) return; + submitBooking(form, res.value); + }); + } + + function submitBooking(form, choice) { + form.find('input[name="bkmode"]').remove(); + form.find('input[name="new_expiry"]').remove(); + + $('').attr({ + type: 'hidden', + name: 'bkmode', + value: choice.mode + }).appendTo(form); + + if (choice.mode === 'scala') { + form.find('input[name="idorder"]').val(choice.idorder); + } else if (choice.mode === 'omaggio') { + form.find('input[name="idorder"]').val(''); + } else if (choice.mode === 'nuovo') { + form.find('input[name="idorder"]').val(''); + $('').attr({ + type: 'hidden', + name: 'new_expiry', + value: choice.expiry + }).appendTo(form); + } + + form.off('submit'); + form[0].submit(); + } + let searchTimeout; $(document).on('input', '.surname, .name', function() { clearTimeout(searchTimeout); diff --git a/public/api/my_lessons.php b/public/api/my_lessons.php index c481c59e..62d6d0bc 100644 --- a/public/api/my_lessons.php +++ b/public/api/my_lessons.php @@ -88,6 +88,7 @@ $stmt = $db->prepare(" ON bc.idserviceschedule = ss.idserviceschedule WHERE bc.iduser = :uid AND bc.status != 'cancelled' + AND bc.is_gift = 'N' "); $stmt->execute([ ':now1' => $now, @@ -128,6 +129,7 @@ $stmt = $db->prepare(" bc.idservice, bc.idorder, bc.lostlesson, + bc.is_gift, ss.dateschedule, s.servicename, s.colorclass, @@ -192,10 +194,17 @@ foreach ($rows as $r) { $reprogrammed = (int) ($r['reprogrammed'] ?? 0); $canReprogram = $reprogrammed < $maxreschedule; - // Nella webapp i bottoni "Riprogramma" e "Cancella" compaiono entrambi - // solo se ($canByTime && $canReprogram). Riproduciamo la stessa condizione. - $canReschedule = $canByTime && $canReprogram; - $canDelete = $canByTime && $canReprogram; + $isGift = ($r['is_gift'] ?? 'N') === 'Y'; + + if ($isGift) { + // Omaggio: mai riprogrammabile, cancellabile in base al tempo. + $canReschedule = false; + $canDelete = $canByTime; + } else { + // Lezione normale: entrambi soggetti a tempo + limite riprogrammazioni. + $canReschedule = $canByTime && $canReprogram; + $canDelete = $canByTime && $canReprogram; + } $lessons[] = [ 'booking_id' => (int) $r['idbookingclass'], diff --git a/public/get_user_orders.php b/public/get_user_orders.php new file mode 100644 index 00000000..2f4fccec --- /dev/null +++ b/public/get_user_orders.php @@ -0,0 +1,101 @@ + is_gift = 'N' esclude gli omaggi dal consumo. + * -------------------------------------------------------------------------- + */ + +$pdo = DBHandlerSelect::getInstance()->getConnection(); + +function jsonOut(array $payload): void +{ + if (ob_get_length() !== false) { + ob_end_clean(); + } + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($payload, JSON_UNESCAPED_UNICODE); + exit; +} + +$userid = isset($_REQUEST['userid']) ? (int) $_REQUEST['userid'] : 0; + +if ($userid <= 0) { + jsonOut(['error' => 'Utente non valido', 'orders' => []]); +} + +try { + $today = date('Y-m-d'); + + // Ordini dell'utente NON scaduti + $sql = "SELECT ob.idorderbook, ob.idservice, ob.nticket, ob.quantityclass, + ob.expireon, ob.maxreschedule, ob.reprogrammed, + s.servicename + FROM orderbook ob + LEFT JOIN service s ON ob.idservice = s.idservice + WHERE ob.iduser = :uid + AND (ob.expireon IS NULL OR ob.expireon >= :today) + ORDER BY ob.idorderbook DESC"; + $stmt = $pdo->prepare($sql); + $stmt->execute([':uid' => $userid, ':today' => $today]); + $rows = $stmt->fetchAll(); + + // Prepared per contare le prenotazioni che consumano ticket + // (non cancellate e non omaggio) + $usedStmt = $pdo->prepare( + "SELECT COUNT(*) AS n + FROM bookingclass + WHERE idorder = :oid + AND status != 'cancelled' + AND is_gift = 'N'" + ); + + $orders = []; + foreach ($rows as $o) { + $orderId = (int) $o['idorderbook']; + + // tickets totali: quantityclass se valorizzato, altrimenti nticket + $tickets = (int) (($o['quantityclass'] !== null && $o['quantityclass'] !== '') + ? $o['quantityclass'] + : ($o['nticket'] ?? 0)); + + $usedStmt->execute([':oid' => $orderId]); + $used = (int) ($usedStmt->fetch()['n'] ?? 0); + + $remaining = $tickets - $used; + if ($remaining < 0) { + $remaining = 0; + } + + $orders[] = [ + 'idorderbook' => $orderId, + 'service_name' => (string) ($o['servicename'] ?? ''), + 'tickets' => $tickets, + 'used' => $used, + 'remaining' => $remaining, + 'expireon' => $o['expireon'] ?? null, + 'maxreschedule' => (int) ($o['maxreschedule'] ?? 0), + 'reprogrammed' => (int) ($o['reprogrammed'] ?? 0), + ]; + } + + jsonOut(['orders' => $orders]); +} catch (Throwable $ex) { + error_log('get_user_orders error: ' . $ex->getMessage()); + jsonOut(['error' => 'Errore durante il recupero degli ordini.', 'orders' => []]); +} diff --git a/public/inserisci_record.php b/public/inserisci_record.php index b23187c0..e26283f5 100644 --- a/public/inserisci_record.php +++ b/public/inserisci_record.php @@ -3,7 +3,17 @@ require_once('include/headscript.php'); /** * Inserisce un partecipante in una classe (bookingclass). - * Se l'utente non esiste ancora (userid mancante), lo crea in auth_users + * + * Modalità (campo bkmode dal modale): + * - 'scala' : aggancia a un ordine esistente (idorder valorizzato), consuma ticket + * - 'omaggio' : idorder NULL, is_gift='Y' (non consuma, non spostabile) + * - 'nuovo' : crea un nuovo orderbook (1 ticket, maxreschedule 0, scadenza da new_expiry) + * e vi aggancia la prenotazione + * + * Nota FK: bookingclass.idorder ha una foreign key verso orderbook. + * Quindi idorder deve essere NULL o un idorderbook reale, MAI 0. + * + * Se l'utente non esiste (userid mancante), lo crea in auth_users * riusando un eventuale account con la stessa email. Tutto in transazione. */ $pdo = DBHandlerSelect::getInstance()->getConnection(); @@ -15,10 +25,14 @@ $email = trim($_POST['email'] ?? ''); $userid = isset($_POST['userid']) ? (int) $_POST['userid'] : 0; $idserviceschedule = isset($_POST['idserviceschedule']) ? (int) $_POST['idserviceschedule'] : 0; $status = trim($_POST['status'] ?? 'booked'); -$idorder = isset($_POST['idorder']) ? (int) $_POST['idorder'] : 0; $idservice = isset($_POST['idservice']) ? (int) $_POST['idservice'] : 0; $bookingstart = trim($_POST['bookingstart'] ?? ''); +// Nuovi campi dal modale +$bkmode = trim($_POST['bkmode'] ?? 'scala'); // scala | omaggio | nuovo +$idorderRaw = $_POST['idorder'] ?? ''; +$newExpiry = trim($_POST['new_expiry'] ?? ''); + // --- Validazione minima --- if ($idserviceschedule <= 0 || $idservice <= 0) { header('Location: adminpanel.php?error=missingdata'); @@ -28,10 +42,10 @@ if ($idserviceschedule <= 0 || $idservice <= 0) { try { $pdo->beginTransaction(); - // Se non abbiamo un userid valido, dobbiamo risolvere/creare l'utente + // ------------------------------------------------------------------ + // 1) Risolvi / crea utente + // ------------------------------------------------------------------ if ($userid <= 0) { - - // 1) Se è stata fornita un'email, controlliamo se l'utente esiste già if ($email !== '') { $check = $pdo->prepare("SELECT id FROM auth_users WHERE email = :email LIMIT 1"); $check->execute([':email' => $email]); @@ -41,9 +55,7 @@ try { } } - // 2) Se ancora non c'è, creiamo un nuovo utente if ($userid <= 0) { - // Password casuale hashata (questi account non usano una password condivisa) $randomPassword = bin2hex(random_bytes(8)); $hashed = password_hash($randomPassword, PASSWORD_BCRYPT); @@ -67,20 +79,136 @@ try { } } - // 3) Inserimento della prenotazione + // ------------------------------------------------------------------ + // 2) Determina idorder e is_gift in base alla modalità + // ------------------------------------------------------------------ + $idorder = null; // default NULL (rispetta la FK) + $isGift = 'N'; + + if ($bkmode === 'omaggio') { + // Omaggio: nessun ordine, flag gift + $idorder = null; + $isGift = 'Y'; + } elseif ($bkmode === 'nuovo') { + // Crea un nuovo ordine con 1 ticket, maxreschedule 0 + if ($newExpiry === '') { + throw new RuntimeException('Scadenza mancante per il nuovo ordine.'); + } + + // Recupera i dati reali dell'utente (email, nome, cognome) + $uStmt = $pdo->prepare( + "SELECT first_name, last_name, email FROM auth_users WHERE id = :uid LIMIT 1" + ); + $uStmt->execute([':uid' => $userid]); + $uRow = $uStmt->fetch() ?: []; + $uEmail = $uRow['email'] ?? ($email !== '' ? $email : null); + $uFirst = $uRow['first_name'] ?? $name; + $uLast = $uRow['last_name'] ?? $surname; + + // Recupera il nome del servizio (per cod / product_name) + $sStmt = $pdo->prepare( + "SELECT servicename FROM service WHERE idservice = :sid LIMIT 1" + ); + $sStmt->execute([':sid' => $idservice]); + $serviceName = (string) ($sStmt->fetchColumn() ?: ''); + + // first_lesson_date = data della lezione (solo parte data di bookingstart) + $firstLessonDate = null; + if ($bookingstart !== '') { + try { + $firstLessonDate = (new DateTime($bookingstart))->format('Y-m-d'); + } catch (Throwable $e) { + $firstLessonDate = null; + } + } + + $insOrder = $pdo->prepare( + "INSERT INTO orderbook + (iduser, idservice, nticket, quantity, quantityclass, + expireon, maxreschedule, reprogrammed, status, + order_billing_email, cod, product_name, + first_name, last_name, first_lesson_date, order_date_created) + VALUES + (:uid, :service, 1, 1, 1, + :expire, 0, 0, 'booked', + :bemail, :cod, :pname, + :first, :last, :firstlesson, :created)" + ); + $insOrder->execute([ + ':uid' => $userid, + ':service' => $idservice, + ':expire' => $newExpiry, + ':bemail' => $uEmail, + ':cod' => $serviceName, + ':pname' => $serviceName, + ':first' => $uFirst, + ':last' => $uLast, + ':firstlesson' => $firstLessonDate, + ':created' => date('Y-m-d'), + ]); + $idorder = (int) $pdo->lastInsertId(); + $isGift = 'N'; + } else { + // 'scala': aggancia a ordine esistente + $idorder = ($idorderRaw !== '' && (int) $idorderRaw > 0) ? (int) $idorderRaw : null; + + // Se non è stato passato un ordine valido in modalità scala, + // per sicurezza NON forziamo 0 (romperebbe la FK): resta NULL. + if ($idorder !== null) { + // Verifica che l'ordine sia dell'utente e non scaduto + $chk = $pdo->prepare( + "SELECT expireon FROM orderbook + WHERE idorderbook = :oid AND iduser = :uid LIMIT 1" + ); + $chk->execute([':oid' => $idorder, ':uid' => $userid]); + $row = $chk->fetch(); + if (!$row) { + throw new RuntimeException('Ordine non valido per questo utente.'); + } + $exp = $row['expireon'] ?? null; + if ($exp !== null && $exp < date('Y-m-d')) { + throw new RuntimeException('Il pacchetto selezionato è scaduto.'); + } + } + $isGift = 'N'; + } + + // ------------------------------------------------------------------ + // 2b) Controllo anti-duplicato: utente già in questa classe? + // ------------------------------------------------------------------ + $dup = $pdo->prepare( + "SELECT COUNT(*) FROM bookingclass + WHERE iduser = :uid + AND idserviceschedule = :idschedule + AND status != 'cancelled'" + ); + $dup->execute([ + ':uid' => $userid, + ':idschedule' => $idserviceschedule, + ]); + if ((int) $dup->fetchColumn() > 0) { + $pdo->rollBack(); + header('Location: adminpanel.php?error=duplicate'); + exit; + } + + // ------------------------------------------------------------------ + // 3) Inserimento prenotazione + // ------------------------------------------------------------------ $insBooking = $pdo->prepare( "INSERT INTO bookingclass - (iduser, idserviceschedule, status, idorder, idservice, bookingstart) + (iduser, idserviceschedule, status, idorder, idservice, bookingstart, is_gift) VALUES - (:iduser, :idschedule, :status, :idorder, :idservice, :bookingstart)" + (:iduser, :idschedule, :status, :idorder, :idservice, :bookingstart, :isgift)" ); $insBooking->execute([ ':iduser' => $userid, ':idschedule' => $idserviceschedule, ':status' => $status, - ':idorder' => $idorder, + ':idorder' => $idorder, // NULL o id reale, mai 0 ':idservice' => $idservice, ':bookingstart' => $bookingstart !== '' ? $bookingstart : null, + ':isgift' => $isGift, ]); $pdo->commit();