Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,7 @@ jobs:
- group: catalogue
specs: >-
tests/attendance-090.spec.js
tests/attendance-member-search.spec.js
tests/catalogue-cards-090.spec.js
tests/feature-toggle-catalogue-cards.spec.js
tests/feature-toggles-followups.spec.js
Expand Down
9 changes: 7 additions & 2 deletions app/Controllers/AttendanceController.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,15 @@ public function scan(Request $request, Response $response, mysqli $db): Response
$body = (array) $request->getParsedBody();
$repo = new AttendanceRepository($db);
$card = is_string($body['codice_tessera'] ?? null) ? $body['codice_tessera'] : '';
// A member picked from the name search arrives as utente_id, and the
// field then holds the label, not a code: the id wins over the card.
$pickedId = filter_var($body['utente_id'] ?? null, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
try {
$member = $repo->findMemberByCard($card);
$member = $pickedId !== false ? $repo->findMemberById($pickedId) : $repo->findMemberByCard($card);
if ($member === null) {
$_SESSION['error_message'] = __('Tessera non riconosciuta. Controlla il codice o registra la visita come ospite.');
$_SESSION['error_message'] = $pickedId !== false
? __('Utente non trovato')
: __('Tessera non riconosciuta. Controlla il codice o registra la visita come ospite.');
return $this->back($response);
}
$repo->recordMember($member['id'], $this->rememberBranch($repo, $body));
Expand Down
28 changes: 27 additions & 1 deletion app/Models/AttendanceRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,35 @@ public function findMemberByCard(string $card): ?array
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();
$stmt->close();
if (!$row) {
return $row ? self::member($row) : null;
}

/**
* The member picked by name in the desk search, for a reader who forgot
* the card. Same reach as findMemberByCard().
*
* @return array{id:int, nome:string, cognome:string, stato:string}|null
*/
public function findMemberById(int $userId): ?array
{
if ($userId <= 0) {
return null;
}
$scope = Permissions::accountScopeSql('', Permissions::SCOPE_BORROWER);
$stmt = $this->db->prepare('SELECT id, nome, cognome, stato FROM utenti WHERE id = ?' . $scope . ' LIMIT 1');
$stmt->bind_param('i', $userId);
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();
$stmt->close();
return $row ? self::member($row) : null;
}

/**
* @param array<string, mixed> $row
* @return array{id:int, nome:string, cognome:string, stato:string}
*/
private static function member(array $row): array
{
return [
'id' => (int) $row['id'],
'nome' => (string) $row['nome'],
Expand Down
188 changes: 186 additions & 2 deletions app/Views/attendance/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,14 @@
<form method="post" action="<?= htmlspecialchars(url('/admin/attendance/scan'), ENT_QUOTES, 'UTF-8') ?>" id="attendance-scan-form" class="space-y-4" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf, ENT_QUOTES, 'UTF-8') ?>">
<div>
<label for="codice_tessera" class="form-label"><?= __("Codice tessera") ?></label>
<label for="codice_tessera" class="form-label"><?= __("Tessera o nome del socio") ?></label>
<div class="flex gap-2">
<input id="codice_tessera" name="codice_tessera" type="text" maxlength="20" required autofocus class="form-input" placeholder="<?= htmlspecialchars(__('Passa la tessera sul lettore o digita il codice'), ENT_QUOTES, 'UTF-8') ?>" />
<div class="relative flex-1">
<input id="codice_tessera" name="codice_tessera" type="text" maxlength="150" required autofocus class="form-input" placeholder="<?= htmlspecialchars(__('Passa la tessera, digita il codice o cerca il socio per nome'), ENT_QUOTES, 'UTF-8') ?>"
role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="attendance-member-suggest" />
<div id="attendance-member-suggest" class="suggestions-box" role="listbox" aria-label="<?= htmlspecialchars(__('Tessera o nome del socio'), ENT_QUOTES, 'UTF-8') ?>"></div>
</div>
<input type="hidden" name="utente_id" id="attendance-member-id" value="" />
<button type="button" id="attendance-card-scan" data-copy-scan data-copy-scan-target="codice_tessera" aria-label="<?= htmlspecialchars(__('Scansiona la tessera con la fotocamera'), ENT_QUOTES, 'UTF-8') ?>"
class="inline-flex items-center gap-2 whitespace-nowrap px-4 py-2 bg-gray-100 text-gray-900 border border-gray-300 rounded-lg hover:bg-gray-200 transition-colors font-medium">
<i class="fas fa-camera"></i><span class="hidden sm:inline"><?= __("Scansiona") ?></span>
Expand Down Expand Up @@ -309,12 +314,191 @@ class="inline-flex items-center gap-2 whitespace-nowrap px-4 py-2 bg-gray-100 te
</div>
</div>
</section>
<style>
.suggestions-box {
position: absolute;
background: white;
border: 1px solid #e2e8f0;
z-index: 50;
width: 100%;
max-height: 250px;
overflow-y: auto;
border-radius: 0.5rem;
display: none;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.suggestion-item {
padding: 0.75rem 1rem;
cursor: pointer;
border-bottom: 1px solid #f1f5f9;
}
.suggestion-item:last-child {
border-bottom: none;
}
.suggestion-item:hover {
background-color: #f8fafc;
}
.suggestion-item.selected {
background-color: #f1f5f9;
}
</style>
<?php include __DIR__ . '/../partials/copy-scanner-i18n.php'; ?>
<script src="<?= htmlspecialchars(assetUrl('copy-scanner.bundle.js'), ENT_QUOTES, 'UTF-8') ?>" defer></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
const card = document.getElementById('codice_tessera');
if (card) { card.focus(); }
// Name search: the same borrower search as the loan form. Picking a member
// fills utente_id and the operator confirms with "Registra ingresso". Enter
// picks a suggestion only when one is highlighted with the arrows, so a
// desk reader that types the code and sends Enter is never captured.
(function () {
const suggest = document.getElementById('attendance-member-suggest');
const picked = document.getElementById('attendance-member-id');
if (!card || !suggest || !picked) { return; }
const endpoint = <?= json_encode(url('/api/search/utenti'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
const noResults = <?= json_encode(__('Nessun risultato'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
const searchFailed = <?= json_encode(__('Ricerca non disponibile. Riprova.'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
let timer = null;
let request = null;
let active = -1;

function items() { return Array.prototype.slice.call(suggest.querySelectorAll('.suggestion-item[data-id]')); }
// Every way out of a search goes through here, so a timer or an answer
// still on its way cannot reopen a list the operator has already left.
function cancel() {
clearTimeout(timer);
if (request) { request.abort(); request = null; }
}
function hide() {
suggest.style.display = 'none';
suggest.innerHTML = '';
active = -1;
card.setAttribute('aria-expanded', 'false');
card.removeAttribute('aria-activedescendant');
}
function open() {
suggest.style.display = 'block';
card.setAttribute('aria-expanded', 'true');
card.removeAttribute('aria-activedescendant');
active = -1;
}
function highlight(index) {
const list = items();
list.forEach(function (el, i) { el.classList.toggle('selected', i === index); el.setAttribute('aria-selected', i === index ? 'true' : 'false'); });
active = index;
if (list[index]) {
card.setAttribute('aria-activedescendant', list[index].id);
list[index].scrollIntoView({ block: 'nearest' });
} else {
card.removeAttribute('aria-activedescendant');
}
}
function choose(el) {
cancel();
picked.value = el.getAttribute('data-id') || '';
card.value = el.textContent.trim();
hide();
// The focus stays in the field, with its text selected: Enter there
// confirms the pick, and a card swiped by the next reader replaces the
// name, which drops the pick and sends that card, instead of reaching
// a focused button that would confirm the previous member.
card.focus();
card.select();
}
// A line that cannot be picked: no match, or a search that did not run.
function notice(text) {
suggest.innerHTML = '';
const el = document.createElement('div');
el.className = 'suggestion-item';
el.setAttribute('role', 'option');
el.setAttribute('aria-disabled', 'true');
el.style.color = '#6b7280';
el.style.cursor = 'default';
el.textContent = text;
suggest.appendChild(el);
open();
}
function show(rows) {
if (rows.length === 0) { notice(noResults); return; }
suggest.innerHTML = '';
rows.forEach(function (row) {
const id = parseInt(row.id, 10);
if (isNaN(id)) { return; }
const el = document.createElement('div');
el.className = 'suggestion-item';
el.id = 'attendance-member-option-' + id;
el.setAttribute('role', 'option');
el.setAttribute('aria-selected', 'false');
el.setAttribute('data-id', String(id));
el.textContent = String(row.label || '');
suggest.appendChild(el);
});
open();
}

card.addEventListener('input', function () {
picked.value = '';
// The list on screen answers the previous text: it goes at once, so
// neither a click nor the Enter a reader sends right after the new code
// can pick a member the field no longer names.
hide();
const query = card.value.trim();
cancel();
if (query.length < 2) { return; }
timer = setTimeout(function () {
Comment thread
fabiodalez-dev marked this conversation as resolved.
Resolved
request = new AbortController();
fetch(endpoint + '?q=' + encodeURIComponent(query), { signal: request.signal, credentials: 'same-origin', headers: { 'Accept': 'application/json' } })
.then(function (r) { if (!r.ok) { throw new Error('search ' + r.status); } return r.json(); })
.then(function (data) {
if (!Array.isArray(data)) { throw new Error('search answer'); }
// A late answer for text the operator has since changed is dropped.
if (card.value.trim() !== query) { return; }
show(data);
})
.catch(function (err) {
// "Nobody matches" and "the search failed" are not the same news.
if (err && err.name !== 'AbortError' && card.value.trim() === query) { notice(searchFailed); }
});
}, 300);
});
card.addEventListener('keydown', function (event) {
const list = items();
if (event.key === 'ArrowDown' && list.length > 0) {
event.preventDefault();
highlight(active + 1 >= list.length ? 0 : active + 1);
} else if (event.key === 'ArrowUp' && list.length > 0) {
event.preventDefault();
highlight(active <= 0 ? list.length - 1 : active - 1);
} else if (event.key === 'Enter') {
if (active >= 0 && list[active]) {
event.preventDefault();
choose(list[active]);
} else {
cancel();
hide();
}
} else if (event.key === 'Escape') {
cancel();
hide();
}
});
card.addEventListener('blur', function () {
cancel();
hide();
});
suggest.addEventListener('mousedown', function (event) {
// Always, not only on an option: pressing the list or its scrollbar must
// not take the focus away, or the blur above would close it.
event.preventDefault();
const el = event.target.closest('.suggestion-item[data-id]');
if (el) { choose(el); }
});
// iOS Safari keeps the focus on a tap outside the field, so no blur there.
document.addEventListener('click', function (event) {
if (event.target !== card && !suggest.contains(event.target)) { cancel(); hide(); }
});
})();
// A card read by the camera registers the entry at once, as a desk reader
// does with Enter. The scanner fills the field with a synthetic change
// event (isTrusted false); typing, pasting or autofill fire trusted ones
Expand Down
5 changes: 3 additions & 2 deletions locale/da_DK.json
Original file line number Diff line number Diff line change
Expand Up @@ -8630,7 +8630,6 @@
"Ospiti": "Gæster",
"P. IVA": "Momsnr.",
"Partita IVA": "Momsnummer",
"Passa la tessera sul lettore o digita il codice": "Scan kortet eller indtast koden",
"Presenza dell'ospite registrata.": "Gæstens besøg er registreret.",
"Presenza registrata per %s, ma la tessera non è attiva.": "Besøg registreret for %s, men kortet er ikke aktivt.",
"Presenze": "Besøg",
Expand Down Expand Up @@ -9322,5 +9321,7 @@
"L'esito di questa importazione non è stato salvato: controlla il catalogo prima di importare di nuovo, con una nuova chiave.": "Resultatet af denne import blev ikke gemt: kontroller kataloget, før du importerer igen, med en ny nøgle.",
"Impossibile salvare l'avanzamento dell'importazione: la riga non è stata contata, riprova.": "Importens fremskridt kunne ikke gemmes: rækken blev ikke talt med, prøv igen.",
"Utente importato, ma l'invito potrebbe essere già stato inviato: se non è arrivato, invialo di nuovo dalla scheda dell'utente.": "Bruger importeret, men invitationen er muligvis allerede sendt: hvis den ikke er nået frem, så send den igen fra brugerens side.",
"Questa ricerca ha raggiunto il numero massimo di importazioni: ripeti la ricerca per continuare.": "Denne søgning har nået det maksimale antal importer: søg igen for at fortsætte."
"Questa ricerca ha raggiunto il numero massimo di importazioni: ripeti la ricerca per continuare.": "Denne søgning har nået det maksimale antal importer: søg igen for at fortsætte.",
"Tessera o nome del socio": "Lånerkort eller medlemmets navn",
"Passa la tessera, digita il codice o cerca il socio per nome": "Scan kortet, indtast koden eller søg efter medlemmet ved navn"
}
5 changes: 3 additions & 2 deletions locale/de_DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -8630,7 +8630,6 @@
"Ospiti": "Gäste",
"P. IVA": "USt-IdNr.",
"Partita IVA": "Umsatzsteuer-Identifikationsnummer",
"Passa la tessera sul lettore o digita il codice": "Karte scannen oder Code eingeben",
"Presenza dell'ospite registrata.": "Gastbesuch erfasst.",
"Presenza registrata per %s, ma la tessera non è attiva.": "Besuch für %s erfasst, aber die Karte ist nicht aktiv.",
"Presenze": "Besuche",
Expand Down Expand Up @@ -9322,5 +9321,7 @@
"L'esito di questa importazione non è stato salvato: controlla il catalogo prima di importare di nuovo, con una nuova chiave.": "Das Ergebnis dieses Imports wurde nicht gespeichert: Prüfe den Katalog, bevor du erneut importierst, mit einem neuen Schlüssel.",
"Impossibile salvare l'avanzamento dell'importazione: la riga non è stata contata, riprova.": "Der Importfortschritt konnte nicht gespeichert werden: Die Zeile wurde nicht gezählt, versuche es erneut.",
"Utente importato, ma l'invito potrebbe essere già stato inviato: se non è arrivato, invialo di nuovo dalla scheda dell'utente.": "Benutzer importiert, aber die Einladung wurde möglicherweise bereits gesendet: Falls sie nicht angekommen ist, sende sie erneut aus dem Benutzerdatensatz.",
"Questa ricerca ha raggiunto il numero massimo di importazioni: ripeti la ricerca per continuare.": "Diese Suche hat die maximale Anzahl an Importen erreicht: Bitte erneut suchen, um fortzufahren."
"Questa ricerca ha raggiunto il numero massimo di importazioni: ripeti la ricerca per continuare.": "Diese Suche hat die maximale Anzahl an Importen erreicht: Bitte erneut suchen, um fortzufahren.",
"Tessera o nome del socio": "Ausweis oder Name des Mitglieds",
"Passa la tessera, digita il codice o cerca il socio per nome": "Ausweis scannen, Code eingeben oder das Mitglied nach Namen suchen"
}
5 changes: 3 additions & 2 deletions locale/en_US.json
Original file line number Diff line number Diff line change
Expand Up @@ -8630,7 +8630,6 @@
"Ospiti": "Guests",
"P. IVA": "VAT no.",
"Partita IVA": "VAT number",
"Passa la tessera sul lettore o digita il codice": "Scan the card or type its code",
"Presenza dell'ospite registrata.": "Guest visit recorded.",
"Presenza registrata per %s, ma la tessera non è attiva.": "Visit recorded for %s, but the card is not active.",
"Presenze": "Visits",
Expand Down Expand Up @@ -9322,5 +9321,7 @@
"L'esito di questa importazione non è stato salvato: controlla il catalogo prima di importare di nuovo, con una nuova chiave.": "The outcome of this import was not saved: check the catalogue before importing again, with a new key.",
"Impossibile salvare l'avanzamento dell'importazione: la riga non è stata contata, riprova.": "The import progress could not be saved: the row was not counted, try again.",
"Utente importato, ma l'invito potrebbe essere già stato inviato: se non è arrivato, invialo di nuovo dalla scheda dell'utente.": "User imported, but the invitation may already have been sent: if it did not arrive, send it again from the user record.",
"Questa ricerca ha raggiunto il numero massimo di importazioni: ripeti la ricerca per continuare.": "This search has reached the maximum number of imports: search again to continue."
"Questa ricerca ha raggiunto il numero massimo di importazioni: ripeti la ricerca per continuare.": "This search has reached the maximum number of imports: search again to continue.",
"Tessera o nome del socio": "Card or member name",
"Passa la tessera, digita il codice o cerca il socio per nome": "Scan the card, type its code or search for the member by name"
}
5 changes: 3 additions & 2 deletions locale/fr_FR.json
Original file line number Diff line number Diff line change
Expand Up @@ -8630,7 +8630,6 @@
"Ospiti": "Invités",
"P. IVA": "N° TVA",
"Partita IVA": "Numéro de TVA",
"Passa la tessera sul lettore o digita il codice": "Passez la carte sur le lecteur ou saisissez le code",
"Presenza dell'ospite registrata.": "Visite de l’invité enregistrée.",
"Presenza registrata per %s, ma la tessera non è attiva.": "Visite enregistrée pour %s, mais la carte n’est pas active.",
"Presenze": "Visites",
Expand Down Expand Up @@ -9322,5 +9321,7 @@
"L'esito di questa importazione non è stato salvato: controlla il catalogo prima di importare di nuovo, con una nuova chiave.": "Le résultat de cette importation n'a pas été enregistré : vérifiez le catalogue avant d'importer à nouveau, avec une nouvelle clé.",
"Impossibile salvare l'avanzamento dell'importazione: la riga non è stata contata, riprova.": "La progression de l'importation n'a pas pu être enregistrée : la ligne n'a pas été comptée, réessayez.",
"Utente importato, ma l'invito potrebbe essere già stato inviato: se non è arrivato, invialo di nuovo dalla scheda dell'utente.": "Utilisateur importé, mais l'invitation a peut-être déjà été envoyée : si elle n'est pas arrivée, renvoyez-la depuis la fiche de l'utilisateur.",
"Questa ricerca ha raggiunto il numero massimo di importazioni: ripeti la ricerca per continuare.": "Cette recherche a atteint le nombre maximal d'importations : relancez la recherche pour continuer."
"Questa ricerca ha raggiunto il numero massimo di importazioni: ripeti la ricerca per continuare.": "Cette recherche a atteint le nombre maximal d'importations : relancez la recherche pour continuer.",
"Tessera o nome del socio": "Carte ou nom du membre",
"Passa la tessera, digita il codice o cerca il socio per nome": "Passez la carte, saisissez son code ou cherchez le membre par son nom"
}
Loading