library: Lend / return, People and printable library cards in the Library site
Checkout via Koha REST, check-in through a secret-guarded script that runs Koha's AddReturn, renewals, overdue list, add-a-person with auto card number, Code 39 library card page. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
This commit is contained in:
@@ -207,3 +207,20 @@ quickadd/ the scan-to-add service (Python standard library + yaz)
|
||||
## Signing in once
|
||||
|
||||
Staff who are signed in to the library site (and allowed to edit the catalogue) open **Add books** without a second login. Koha itself confirms the sign-in; the shared secret is `SSO_SECRET` in `.env`. The read-only `guest` login still uses the Add books prompt, and the direct Add books port never accepts a Koha sign-in. If `SSO_SECRET` is empty, Add books just asks for its own login as before.
|
||||
|
||||
## Everyday use (the Library site at `/add/`)
|
||||
|
||||
One page for the day-to-day, so you rarely need Koha's own screens:
|
||||
|
||||
- **Library**: a cover grid of everything you own. Search by title, author or ISBN; filter by shelf, format
|
||||
(book/DVD/CD) and status (on the shelf / checked out); sort by title, author or newest. Click a book for its
|
||||
page: copies with barcode, status, who has it and when it is due; change a copy's shelf or call number,
|
||||
add another copy, remove a copy, or delete the book. (Title/author edits are one click away in Koha.)
|
||||
- **Lend / return**: scan or type a library card number (or search by name), then scan books to check them out;
|
||||
"Return a book" takes a scan with no person needed; **Renew** extends a loan; the list below shows everything
|
||||
that is out, overdue ones flagged. Check-in goes through Koha's own check-in code, so holds and rules still apply.
|
||||
- **People**: add someone with just a name (the card number is assigned automatically), search, and
|
||||
**Print card** for a library card with a scannable Code 39 barcode.
|
||||
- **Add books**: scan an ISBN (or use the phone camera), as before.
|
||||
|
||||
The read-only `guest` login only sees the Library and book pages (no copy status names, no editing, no lending).
|
||||
|
||||
@@ -41,7 +41,9 @@ RUN for c in koha-create koha-plack koha-zebra koha-rebuild-zebra koha-upgrade-s
|
||||
|
||||
# The endpoint Quick Add uses to recognise a signed-in staff user (see the file header).
|
||||
COPY quickadd-session.pl /usr/share/koha/intranet/cgi-bin/svc/quickadd-session
|
||||
RUN chmod 0755 /usr/share/koha/intranet/cgi-bin/svc/quickadd-session
|
||||
# (the return script lives beside the staff .pl scripts, not under svc/: Koha's svc Plack app 500s on it)
|
||||
COPY quickadd-return.pl /usr/share/koha/intranet/cgi-bin/quickadd-return.pl
|
||||
RUN chmod 0755 /usr/share/koha/intranet/cgi-bin/svc/quickadd-session /usr/share/koha/intranet/cgi-bin/quickadd-return.pl
|
||||
|
||||
COPY bootstrap.pl ensure.pl entrypoint.sh /opt/koha-init/
|
||||
COPY seed/ /opt/koha-init/seed/
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/perl
|
||||
# Installed as .../intranet/cgi-bin/quickadd-return.pl (URL /cgi-bin/koha/quickadd-return.pl).
|
||||
#
|
||||
# Koha's REST API has no "check in" call, so the Add books / Lend page asks this small script to do it
|
||||
# with Koha's own C4::Circulation::AddReturn (the same code the staff Check in screen runs: it clears
|
||||
# the loan, handles holds and fines rules). Same shared-secret guard as quickadd-session: only callers
|
||||
# that know the secret get an answer.
|
||||
use Modern::Perl;
|
||||
use CGI qw( -utf8 );
|
||||
use JSON qw( encode_json );
|
||||
use Digest::SHA qw( sha256_hex );
|
||||
use C4::Context;
|
||||
use C4::Circulation qw( AddReturn );
|
||||
use Koha::Items;
|
||||
|
||||
my $q = CGI->new;
|
||||
|
||||
sub reply {
|
||||
my ( $q, $status, $data ) = @_;
|
||||
print $q->header( -type => 'application/json', -status => $status, -charset => 'utf-8', -cache_control => 'no-store' ),
|
||||
encode_json($data);
|
||||
exit;
|
||||
}
|
||||
|
||||
my $secret = '';
|
||||
if ( open my $fh, '<', '/etc/koha/quickadd-sso.secret' ) { $secret = <$fh> // ''; $secret =~ s/\s+$//; close $fh }
|
||||
my $given = $ENV{HTTP_X_QUICKADD_SECRET} // '';
|
||||
reply( $q, '403 Forbidden', { status => 'forbidden' } )
|
||||
unless length($secret) >= 16 && sha256_hex($given) eq sha256_hex($secret);
|
||||
|
||||
my $barcode = $q->param('barcode') // '';
|
||||
reply( $q, '200 OK', { status => 'invalid' } ) unless $barcode =~ /\A[\x21-\x7e]{1,40}\z/;
|
||||
|
||||
my $item = Koha::Items->find( { barcode => $barcode } );
|
||||
reply( $q, '200 OK', { status => 'unknown_barcode' } ) unless $item;
|
||||
|
||||
# AddReturn needs to know which branch it is happening at.
|
||||
my $branch = $item->homebranch;
|
||||
my ( $doreturn, $messages, $borrower, $was_out );
|
||||
eval {
|
||||
C4::Context->set_userenv( 0, 0, 'quickadd', 'Quick', 'Add', $branch, $branch, 0, '', '' );
|
||||
$was_out = $item->checkout ? 1 : 0;
|
||||
( $doreturn, $messages, undef, $borrower ) = AddReturn( $barcode, $branch );
|
||||
1;
|
||||
} or reply( $q, '200 OK', { status => 'failed', error => "$@" } );
|
||||
reply(
|
||||
$q, '200 OK',
|
||||
{
|
||||
status => $doreturn && $was_out ? 'returned' : ( $doreturn || exists $messages->{NotIssued} ) ? 'not_out' : 'failed',
|
||||
patron => $borrower ? ( join ' ', grep { length } $borrower->{firstname}, $borrower->{surname} ) : '',
|
||||
messages => [ sort keys %{ $messages || {} } ],
|
||||
}
|
||||
);
|
||||
Binary file not shown.
+185
-2
@@ -32,6 +32,7 @@ PASSWORD = os.environ.get("ADMIN_PASSWORD", "")
|
||||
# X-Quickadd-Proxy; we then ask Koha whether the browser's login cookie is a staff user who may edit
|
||||
# the catalogue. Unset = feature off (only the Basic logins below work).
|
||||
SSO_SECRET = os.environ.get("SSO_SECRET", "")
|
||||
KOHA_RETURN_URL = KOHA_API.rsplit("/api/v1", 1)[0] + "/cgi-bin/koha/quickadd-return.pl"
|
||||
KOHA_SESSION_URL = KOHA_API.rsplit("/api/v1", 1)[0] + "/cgi-bin/koha/svc/quickadd-session"
|
||||
VIEWER_USER = os.environ.get("VIEWER_USER", "")
|
||||
VIEWER_PASSWORD = os.environ.get("VIEWER_PASSWORD", "")
|
||||
@@ -892,6 +893,174 @@ def op_item_copy(payload):
|
||||
return 200, {"status": "added", "item_id": item["item_id"], "barcode": item.get("external_id") or ""}
|
||||
|
||||
|
||||
# ------------------------------------------------------------ lending / people
|
||||
import datetime as _dt
|
||||
|
||||
PATRON_CATEGORY = os.environ.get("PATRON_CATEGORY", "FRIEND")
|
||||
|
||||
|
||||
def _patron_name(p):
|
||||
return ("%s %s" % (p.get("firstname") or "", p.get("surname") or "")).strip() or p.get("cardnumber") or "?"
|
||||
|
||||
|
||||
def _shape_patron(p):
|
||||
return {"id": p["patron_id"], "name": _patron_name(p), "card": p.get("cardnumber") or "",
|
||||
"email": p.get("email") or "", "phone": p.get("phone") or ""}
|
||||
|
||||
|
||||
def op_patron_search(term):
|
||||
term = re.sub(r"[%_\\]", "", term or "").strip()[:60]
|
||||
if not term:
|
||||
res = koha("GET", "/patrons", query={"_per_page": "50", "_order_by": "surname"}) or []
|
||||
else:
|
||||
like = {"-like": "%" + term + "%"}
|
||||
conds = [{"surname": like}, {"firstname": like}, {"cardnumber": {"-like": term + "%"}}]
|
||||
parts = term.split()
|
||||
if len(parts) == 2: # "Jane Smith"
|
||||
conds.append({"-and": [{"firstname": {"-like": parts[0] + "%"}}, {"surname": {"-like": parts[1] + "%"}}]})
|
||||
res = koha("GET", "/patrons", query={"q": json.dumps({"-or": conds}), "_per_page": "25", "_order_by": "surname"}) or []
|
||||
return {"patrons": [_shape_patron(p) for p in res]}
|
||||
|
||||
|
||||
def _loan_rows(cos):
|
||||
"""Checkouts (with embedded item) -> rows with titles and an overdue flag."""
|
||||
if not cos:
|
||||
return []
|
||||
bib_ids = sorted({c["item"]["biblio_id"] for c in cos if c.get("item")})
|
||||
titles = {}
|
||||
if bib_ids:
|
||||
for b in koha("GET", "/biblios", query={"q": json.dumps({"biblio_id": bib_ids}), "_per_page": str(len(bib_ids) + 1)}) or []:
|
||||
titles[b["biblio_id"]] = ((b.get("title") or "").rstrip(" /:"), b.get("author") or "", b.get("isbn") or "")
|
||||
today = _dt.date.today().isoformat()
|
||||
rows = []
|
||||
for c in cos:
|
||||
it = c.get("item") or {}
|
||||
t = titles.get(it.get("biblio_id"), ("(unknown)", "", ""))
|
||||
due = (c.get("due_date") or "")[:10]
|
||||
rows.append({"checkout_id": c["checkout_id"], "patron_id": c["patron_id"], "barcode": it.get("external_id") or "",
|
||||
"biblio_id": it.get("biblio_id"), "title": t[0], "author": t[1], "isbn": t[2], "due": due,
|
||||
"overdue": bool(due) and due < today, "renewals": c.get("renewals_count") or 0})
|
||||
return rows
|
||||
|
||||
|
||||
def op_patron(patron_id):
|
||||
p = koha("GET", "/patrons/%d" % patron_id)
|
||||
cos = koha("GET", "/checkouts", query={"q": json.dumps({"patron_id": patron_id}), "_per_page": "200",
|
||||
"_order_by": "due_date"}, headers={"x-koha-embed": "item"}) or []
|
||||
d = _shape_patron(p)
|
||||
d["loans"] = _loan_rows(cos)
|
||||
d["category"] = p.get("category_id") or ""
|
||||
return d
|
||||
|
||||
|
||||
def op_out():
|
||||
"""Everything checked out right now, soonest due first (overdue on top)."""
|
||||
cos = koha("GET", "/checkouts", query={"_per_page": "500", "_order_by": "due_date"}, headers={"x-koha-embed": "item"}) or []
|
||||
rows = _loan_rows(cos)
|
||||
names = {}
|
||||
for pid in sorted({r["patron_id"] for r in rows}):
|
||||
try:
|
||||
names[pid] = _patron_name(koha("GET", "/patrons/%d" % pid))
|
||||
except KohaError:
|
||||
names[pid] = "?"
|
||||
for r in rows:
|
||||
r["who"] = names.get(r["patron_id"], "?")
|
||||
return {"loans": rows}
|
||||
|
||||
|
||||
def _item_by_barcode(code):
|
||||
code = (code or "").strip()
|
||||
if not code or len(code) > 40:
|
||||
return None
|
||||
res = koha("GET", "/items", query={"q": json.dumps({"external_id": code}), "_per_page": "1"}) or []
|
||||
return res[0] if res else None
|
||||
|
||||
|
||||
def _title_of(biblio_id):
|
||||
try:
|
||||
return (koha("GET", "/biblios/%d" % biblio_id).get("title") or "").rstrip(" /:")
|
||||
except KohaError:
|
||||
return ""
|
||||
|
||||
|
||||
def op_checkout(payload):
|
||||
pid, code = payload.get("patron_id"), payload.get("barcode")
|
||||
if not isinstance(pid, int):
|
||||
return 400, {"status": "invalid", "message": "Pick the person first."}
|
||||
it = _item_by_barcode(code)
|
||||
if not it:
|
||||
return 200, {"status": "unknown", "message": "No book has the barcode %s." % str(code)[:40]}
|
||||
title = _title_of(it["biblio_id"])
|
||||
if item_status(it) == "Checked out":
|
||||
return 200, {"status": "already_out", "title": title, "barcode": it["external_id"],
|
||||
"message": "That one is already checked out. Scan it as a return first."}
|
||||
try:
|
||||
co = koha("POST", "/checkouts", {"patron_id": pid, "item_id": it["item_id"]})
|
||||
except KohaError as e:
|
||||
try:
|
||||
msg = json.loads(e.body).get("error") or ""
|
||||
except ValueError:
|
||||
msg = ""
|
||||
return 200, {"status": "refused", "title": title, "message": msg or "Koha would not allow that checkout."}
|
||||
return 200, {"status": "checked_out", "title": title, "barcode": it["external_id"], "due": (co.get("due_date") or "")[:10]}
|
||||
|
||||
|
||||
def op_return(payload):
|
||||
code = str(payload.get("barcode") or "").strip()
|
||||
if not SSO_SECRET:
|
||||
return 200, {"status": "error", "message": "Returns need SSO_SECRET in .env (run the installer's Update)."}
|
||||
req = urllib.request.Request(KOHA_RETURN_URL + "?" + urllib.parse.urlencode({"barcode": code}),
|
||||
headers={"X-Quickadd-Secret": SSO_SECRET})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as r:
|
||||
res = json.loads(r.read())
|
||||
except (urllib.error.URLError, OSError, ValueError):
|
||||
return 502, {"status": "error", "message": "Koha did not answer the return - try again."}
|
||||
it = _item_by_barcode(code) if res.get("status") in ("returned", "not_out") else None
|
||||
res["title"] = _title_of(it["biblio_id"]) if it else ""
|
||||
return 200, res
|
||||
|
||||
|
||||
def op_renew(payload):
|
||||
cid = payload.get("checkout_id")
|
||||
if not isinstance(cid, int):
|
||||
return 400, {"status": "invalid"}
|
||||
try:
|
||||
co = koha("POST", "/checkouts/%d/renewal" % cid)
|
||||
except KohaError as e:
|
||||
try:
|
||||
msg = json.loads(e.body).get("error") or ""
|
||||
except ValueError:
|
||||
msg = ""
|
||||
return 200, {"status": "refused", "message": msg or "That one can't be renewed (limit reached or someone is waiting)."}
|
||||
return 200, {"status": "renewed", "due": (co.get("due_date") or "")[:10]}
|
||||
|
||||
|
||||
_NAME_CACHE = {"at": 0.0, "value": ""}
|
||||
|
||||
|
||||
def library_name():
|
||||
if time.time() - _NAME_CACHE["at"] > 300:
|
||||
try:
|
||||
_NAME_CACHE["value"] = koha("GET", "/libraries/%s" % urllib.parse.quote(LIBRARY)).get("name") or ""
|
||||
except (KohaError, AttributeError):
|
||||
pass
|
||||
_NAME_CACHE["at"] = time.time()
|
||||
return _NAME_CACHE["value"]
|
||||
|
||||
|
||||
def op_patron_add(payload):
|
||||
def clean(k, n=60):
|
||||
return re.sub(r"\s+", " ", str(payload.get(k) or "")).strip()[:n]
|
||||
surname, first = clean("surname"), clean("firstname")
|
||||
if not surname:
|
||||
return 400, {"status": "invalid", "message": "A name is needed."}
|
||||
body = {"surname": surname, "firstname": first or None, "category_id": PATRON_CATEGORY, "library_id": LIBRARY,
|
||||
"email": clean("email", 100) or None, "phone": clean("phone", 30) or None}
|
||||
p = koha("POST", "/patrons", body)
|
||||
return 200, dict(_shape_patron(p), status="added")
|
||||
|
||||
|
||||
# ------------------------------------------------------------ photo scanning
|
||||
MAX_IMAGE = 12 * 1024 * 1024
|
||||
ZBAR_TIMEOUT = 20
|
||||
@@ -1058,11 +1227,24 @@ class Handler(BaseHTTPRequestHandler):
|
||||
"settings": {}, "staff_port": STAFF_PORT, "staff_url": STAFF_URL,
|
||||
"opac_url": OPAC_URL, "opac_port": OPAC_PORT, "sources": []})
|
||||
if path == "/api/state":
|
||||
return self._send(200, {"role": "admin", "recent": load_recent()[:100], "types": get_item_types(),
|
||||
return self._send(200, {"role": "admin", "library_name": library_name(), "recent": load_recent()[:100], "types": get_item_types(),
|
||||
"library": LIBRARY, "locations": get_locations(), "settings": load_settings(), "staff_port": STAFF_PORT, "staff_url": STAFF_URL, "opac_url": OPAC_URL, "opac_port": OPAC_PORT,
|
||||
"sources": [s.split(":", 1)[0] if s.startswith("z3950") else s for s in SOURCES]})
|
||||
if path == "/api/settings":
|
||||
return self._send(200, settings_view())
|
||||
if path in ("/api/patrons", "/api/out") or path.startswith("/api/patron/"):
|
||||
try:
|
||||
if path == "/api/out":
|
||||
return self._send(200, op_out())
|
||||
if path == "/api/patrons":
|
||||
return self._send(200, op_patron_search((urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query).get("q") or [""])[0]))
|
||||
m = re.match(r"^/api/patron/(\d{1,9})$", path)
|
||||
if not m:
|
||||
return self._send(404, {"error": "not found"})
|
||||
return self._send(200, op_patron(int(m.group(1))))
|
||||
except KohaError as e:
|
||||
return self._send(404 if e.status == 404 else 502, {"status": "error", "message":
|
||||
"Koha is not answering yet - try again in a minute." if e.status == 503 else "Failed: %s" % e.body[:120]})
|
||||
if path == "/api/library" or path.startswith("/api/book/"):
|
||||
try:
|
||||
if path == "/api/library":
|
||||
@@ -1107,7 +1289,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
"/api/finish": op_finish, "/api/cancel": op_cancel, "/api/settings": op_settings,
|
||||
"/api/barcodes/reserve": op_reserve, "/api/barcodes/assign": op_assign,
|
||||
"/api/item/update": op_item_update, "/api/item/delete": op_item_delete,
|
||||
"/api/item/copy": op_item_copy, "/api/book/delete": op_book_delete}
|
||||
"/api/item/copy": op_item_copy, "/api/book/delete": op_book_delete,
|
||||
"/api/checkout": op_checkout, "/api/return": op_return, "/api/renew": op_renew, "/api/patron/add": op_patron_add}
|
||||
fn = ops.get(self.path)
|
||||
if not fn:
|
||||
return self._send(404, {"error": "not found"})
|
||||
|
||||
+211
-8
@@ -121,6 +121,16 @@
|
||||
.copybox .row { display: flex; flex-wrap: wrap; gap: 8px 10px; align-items: center; margin-top: 8px; }
|
||||
.copybox select, .copybox input[type=text] { font: inherit; padding: 7px 9px; border: 1px solid var(--line); border-radius: 8px; background: var(--card); color: var(--ink); min-width: 0; }
|
||||
button.danger { color: var(--bad); border-color: var(--bad); }
|
||||
.person { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; padding: 10px 2px; border-bottom: 1px solid var(--line); }
|
||||
.person .t { flex: 1; min-width: 180px; }
|
||||
.loan { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--line); }
|
||||
.loan .t { flex: 1; min-width: 160px; overflow-wrap: anywhere; }
|
||||
.chip.late { background: var(--bad-bg); border-color: var(--bad); color: var(--bad); }
|
||||
.libcard { width: 85.6mm; height: 54mm; border: 1px solid #888; border-radius: 3mm; padding: 5mm; background: #fff; color: #000;
|
||||
display: flex; flex-direction: column; justify-content: space-between; font-family: system-ui, sans-serif; }
|
||||
.libcard .lib { font-size: 11pt; font-weight: 700; } .libcard .nm { font-size: 14pt; font-weight: 700; }
|
||||
.libcard svg { width: 100%; height: 16mm; } .libcard .no { font-size: 9pt; text-align: center; letter-spacing: .15em; }
|
||||
@media print { header, .noprint, nav { display: none !important; } body { background: #fff; } main { padding: 0; max-width: none; } .libcard { border: 1px solid #000; } }
|
||||
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
|
||||
</style>
|
||||
</head>
|
||||
@@ -130,6 +140,8 @@
|
||||
<h1 id="title">Library</h1>
|
||||
<nav class="tabs" aria-label="Sections">
|
||||
<a id="tab-library" href="#library" class="on">Library</a>
|
||||
<a id="tab-lend" href="#lend">Lend / return</a>
|
||||
<a id="tab-people" href="#people">People</a>
|
||||
<a id="tab-add" href="#add">Add books</a>
|
||||
<a id="lnk-staff" class="ext" href="#" target="_blank" rel="noopener">Manage</a>
|
||||
<a id="lnk-opac" class="ext" href="#" target="_blank" rel="noopener">Public catalogue</a>
|
||||
@@ -155,6 +167,55 @@
|
||||
<div class="actions"><button type="button" id="more" hidden>Show more</button></div>
|
||||
</section>
|
||||
|
||||
<section id="view-lend" hidden>
|
||||
<div class="copybox">
|
||||
<strong>Return a book</strong>
|
||||
<form class="scan" id="retform" autocomplete="off" style="margin-top:8px">
|
||||
<label class="sr" for="ret">Barcode to return</label>
|
||||
<input id="ret" inputmode="numeric" placeholder="Scan the book's barcode" autocomplete="off">
|
||||
<button class="primary" type="submit">Return</button>
|
||||
</form>
|
||||
<div id="retresult" role="status" aria-live="polite" class="small" style="margin-top:8px"></div>
|
||||
</div>
|
||||
<div class="copybox">
|
||||
<strong>Check out to…</strong>
|
||||
<form class="find-form" id="whoform" autocomplete="off">
|
||||
<label class="sr" for="who">Name or library card number</label>
|
||||
<input id="who" type="search" placeholder="Name, or scan the library card" enterkeyhint="search" autocomplete="off">
|
||||
<button type="submit">Find</button>
|
||||
</form>
|
||||
<div id="whoresults"></div>
|
||||
<div id="patronbox"></div>
|
||||
</div>
|
||||
<h2>Checked out right now <span id="outcount" class="small"></span></h2>
|
||||
<div id="outlist"></div>
|
||||
</section>
|
||||
|
||||
<section id="view-people" hidden>
|
||||
<details class="settings" id="newperson" open>
|
||||
<summary>Add a person</summary>
|
||||
<form class="manual" id="personform" autocomplete="off">
|
||||
<input name="firstname" placeholder="First name" aria-label="First name">
|
||||
<input name="surname" placeholder="Last name" required aria-label="Last name">
|
||||
<input name="phone" placeholder="Phone (optional)" aria-label="Phone">
|
||||
<input name="email" type="email" placeholder="Email (optional)" aria-label="Email">
|
||||
<div class="actions"><button class="primary" type="submit">Add person</button></div>
|
||||
</form>
|
||||
<div id="personresult" aria-live="polite"></div>
|
||||
</details>
|
||||
<form class="find-form" id="peopleform" autocomplete="off">
|
||||
<label class="sr" for="pq">Search people</label>
|
||||
<input id="pq" type="search" placeholder="Search by name or card number" enterkeyhint="search">
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
<div id="peoplelist"></div>
|
||||
</section>
|
||||
|
||||
<section id="view-card" hidden>
|
||||
<p class="noprint"><a href="#people">← Back to people</a> <button type="button" id="printcard" class="primary">Print this card</button></p>
|
||||
<div id="cardbox"></div>
|
||||
</section>
|
||||
|
||||
<section id="view-book" hidden>
|
||||
<p><a href="#library" id="back">← Back to the library</a></p>
|
||||
<div id="bookbox" aria-live="polite"></div>
|
||||
@@ -624,21 +685,162 @@
|
||||
return box;
|
||||
}
|
||||
|
||||
// ---- lend / return
|
||||
const fmtDue = d => d ? new Date(d + 'T12:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '';
|
||||
let curPatron = null, lendBusy = false;
|
||||
|
||||
function say(box, kind, text) { box.replaceChildren(el('div', { className: 'card ' + kind }, el('div', { className: 'body' }, text))); }
|
||||
|
||||
async function doReturn(code, box) {
|
||||
box.textContent = 'Returning…';
|
||||
let r; try { r = await api('/api/return', { barcode: code }); } catch (e) { r = { status: 'error', message: 'Could not reach the service.' }; }
|
||||
if (r.status === 'returned') { beep(SOUND.added); say(box, 'ok', 'Returned: ' + (r.title || code) + (r.patron ? ' (from ' + r.patron + ')' : '')); }
|
||||
else if (r.status === 'not_out') { beep(SOUND.duplicate); say(box, 'warn', (r.title || code) + ' was not checked out.'); }
|
||||
else if (r.status === 'unknown_barcode') { beep(SOUND.notfound); say(box, 'warn', 'No book has the barcode ' + code + '.'); }
|
||||
else { beep(SOUND.error); say(box, 'bad', r.message || 'Could not return that.'); }
|
||||
loadOut(); if (curPatron) openPatron(curPatron.id, true);
|
||||
return r;
|
||||
}
|
||||
$('retform').onsubmit = async ev => { ev.preventDefault(); const c = $('ret').value.trim(); $('ret').value = ''; if (c) { await doReturn(c, $('retresult')); $('ret').focus(); } };
|
||||
|
||||
async function loadOut() {
|
||||
let r; try { r = await api('/api/out'); } catch (e) { return; }
|
||||
if (!r.loans) return;
|
||||
$('outcount').textContent = r.loans.length ? '(' + r.loans.length + ')' : '';
|
||||
const late = r.loans.filter(l => l.overdue).length;
|
||||
if (!r.loans.length) { $('outlist').replaceChildren(el('div', { className: 'small' }, 'Nothing is checked out.')); return; }
|
||||
$('outlist').replaceChildren(...(late ? [el('div', { className: 'small' }, late + ' overdue')] : []), ...r.loans.map(l => loanRow(l, true)));
|
||||
}
|
||||
|
||||
function loanRow(l, showWho) {
|
||||
const ret = el('button', { type: 'button' }, 'Return');
|
||||
ret.onclick = async () => { ret.disabled = true; await doReturn(l.barcode, $('retresult')); };
|
||||
const ren = el('button', { type: 'button' }, 'Renew');
|
||||
ren.onclick = async () => {
|
||||
ren.disabled = true; const r = await api('/api/renew', { checkout_id: l.checkout_id });
|
||||
if (r.status === 'renewed') { loadOut(); if (curPatron) openPatron(curPatron.id, true); } else { ren.disabled = false; alert(r.message); }
|
||||
};
|
||||
const a = el('a', { href: '#book/' + l.biblio_id }, l.title);
|
||||
return el('div', { className: 'loan' }, el('span', { className: 'badge' }, l.barcode),
|
||||
el('span', { className: 't' }, a, showWho ? el('div', { className: 'small' }, l.who) : ''),
|
||||
el('span', { className: 'chip ' + (l.overdue ? 'late' : 'in') }, (l.overdue ? 'Overdue, was due ' : 'Due ') + fmtDue(l.due)), ret, ren);
|
||||
}
|
||||
|
||||
async function openPatron(id, quiet) {
|
||||
let p; try { p = await api('/api/patron/' + id); } catch (e) { p = {}; }
|
||||
if (!p.name) { $('patronbox').textContent = 'Could not load that person.'; return; }
|
||||
curPatron = p;
|
||||
$('whoresults').replaceChildren();
|
||||
const scan = el('input', { placeholder: 'Scan a book to check out', inputMode: 'numeric', autocomplete: 'off', 'aria-label': 'Barcode to check out' });
|
||||
const form = el('form', { className: 'scan' }, scan, el('button', { className: 'primary', type: 'submit' }, 'Check out'));
|
||||
const out = (quiet && document.getElementById('coresult')) || el('div', { id: 'coresult', role: 'status', className: 'small', style: 'margin-top:8px' });
|
||||
form.onsubmit = async ev => {
|
||||
ev.preventDefault(); const code = scan.value.trim(); scan.value = ''; if (!code || lendBusy) return; lendBusy = true;
|
||||
try {
|
||||
const r = await api('/api/checkout', { patron_id: p.id, barcode: code });
|
||||
if (r.status === 'checked_out') { beep(SOUND.added); say(out, 'ok', (r.title || code) + ' - due ' + fmtDue(r.due)); }
|
||||
else if (r.status === 'already_out') {
|
||||
beep(SOUND.duplicate);
|
||||
const rb = el('button', { type: 'button', className: 'primary' }, 'Return it, then check it out');
|
||||
rb.onclick = async () => { rb.disabled = true; await doReturn(code, out); const r2 = await api('/api/checkout', { patron_id: p.id, barcode: code });
|
||||
if (r2.status === 'checked_out') say(out, 'ok', (r2.title || code) + ' - due ' + fmtDue(r2.due)); await openPatron(p.id, true); loadOut(); };
|
||||
out.replaceChildren(el('div', { className: 'card warn' }, el('div', { className: 'body' }, (r.title || code) + ' is already checked out. ', rb)));
|
||||
} else { beep(SOUND.notfound); say(out, 'bad', r.message || 'That did not work.'); }
|
||||
} catch (e) { say(out, 'bad', 'Could not reach the service.'); }
|
||||
lendBusy = false; await openPatron(p.id, true); loadOut(); document.querySelector('#patronbox form input').focus();
|
||||
};
|
||||
const loans = p.loans.length ? p.loans.map(l => loanRow(l, false)) : [el('div', { className: 'small' }, 'Nothing checked out to ' + p.name + ' yet.')];
|
||||
$('patronbox').replaceChildren(el('div', { className: 'title', style: 'margin-top:10px' }, p.name, ' ', el('span', { className: 'badge' }, p.card || 'no card')),
|
||||
form, out, el('h2', {}, 'Has out'), ...loans,
|
||||
el('div', { className: 'small', style: 'margin-top:8px' }, el('a', { href: '#card/' + p.id }, 'Print library card'), ' · ',
|
||||
el('a', { href: '#lend' }, 'Choose someone else')));
|
||||
if (!quiet) scan.focus();
|
||||
}
|
||||
|
||||
async function findPeople(term, into, withActions) {
|
||||
let r; try { r = await api('/api/patrons?q=' + encodeURIComponent(term)); } catch (e) { r = {}; }
|
||||
if (!r.patrons) { into.textContent = (r && r.message) || 'Search failed.'; return []; }
|
||||
if (!r.patrons.length) { into.replaceChildren(el('div', { className: 'small' }, term ? 'Nobody matches "' + term + '".' : 'No people yet.')); return []; }
|
||||
into.replaceChildren(...r.patrons.map(p => el('div', { className: 'person' },
|
||||
el('span', { className: 't' }, el('strong', {}, p.name), el('div', { className: 'small' }, [p.card, p.phone, p.email].filter(Boolean).join(' · '))),
|
||||
...withActions(p))));
|
||||
return r.patrons;
|
||||
}
|
||||
$('whoform').onsubmit = async ev => {
|
||||
ev.preventDefault(); const term = $('who').value.trim(); if (!term) return;
|
||||
const list = await findPeople(term, $('whoresults'), p => { const b = el('button', { type: 'button', className: 'primary' }, 'Choose');
|
||||
b.onclick = () => { location.hash = '#lend/' + p.id; }; return [b]; });
|
||||
const exact = list.filter(p => p.card === term);
|
||||
if (exact.length === 1) { $('who').value = ''; location.hash = '#lend/' + exact[0].id; } // a scanned card goes straight through
|
||||
};
|
||||
|
||||
// ---- people
|
||||
async function loadPeople(term) {
|
||||
await findPeople(term, $('peoplelist'), p => {
|
||||
const lend = el('a', { href: '#lend/' + p.id }, el('button', { type: 'button' }, 'Lend / returns'));
|
||||
const card = el('a', { href: '#card/' + p.id }, el('button', { type: 'button' }, 'Print card'));
|
||||
return [lend, card];
|
||||
});
|
||||
}
|
||||
$('peopleform').onsubmit = ev => { ev.preventDefault(); loadPeople($('pq').value.trim()); };
|
||||
$('personform').onsubmit = async ev => {
|
||||
ev.preventDefault();
|
||||
const f = ev.target, r = await api('/api/patron/add', Object.fromEntries(new FormData(f)));
|
||||
if (r.status === 'added') {
|
||||
f.reset();
|
||||
$('personresult').replaceChildren(el('div', { className: 'result-note' }, r.name + ' added with library card number ', el('span', { className: 'badge' }, r.card), '. ',
|
||||
el('a', { href: '#card/' + r.id }, 'Print the card'), ' or ', el('a', { href: '#lend/' + r.id }, 'check something out')));
|
||||
loadPeople('');
|
||||
} else $('personresult').replaceChildren(el('div', { className: 'card bad' }, el('div', { className: 'body' }, r.message || 'Could not add that person.')));
|
||||
};
|
||||
|
||||
// ---- printable library card (Code 39 barcode drawn as SVG)
|
||||
const C39 = { '0': 'nnnwwnwnn', '1': 'wnnwnnnnw', '2': 'nnwwnnnnw', '3': 'wnwwnnnnn', '4': 'nnnwwnnnw', '5': 'wnnwwnnnn',
|
||||
'6': 'nnwwwnnnn', '7': 'nnnwnnwnw', '8': 'wnnwnnwnn', '9': 'nnwwnnwnn', '*': 'nwnnwnwnn' };
|
||||
function code39(text) {
|
||||
const W = 3; let x = 10, bars = '';
|
||||
for (const ch of '*' + text + '*') {
|
||||
const pat = C39[ch]; if (!pat) continue;
|
||||
for (let i = 0; i < 9; i++) { const w = pat[i] === 'w' ? W : 1; if (i % 2 === 0) bars += '<rect x="' + x + '" y="0" width="' + w + '" height="50"/>'; x += w; }
|
||||
x += 1; // narrow gap between characters
|
||||
}
|
||||
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + (x + 9) + ' 50" preserveAspectRatio="none" fill="#000" role="img" aria-label="Barcode ' + text + '">' + bars + '</svg>';
|
||||
}
|
||||
async function loadCard(id) {
|
||||
let p; try { p = await api('/api/patron/' + id); } catch (e) { p = {}; }
|
||||
if (!p.name) { $('cardbox').textContent = 'Could not load that person.'; return; }
|
||||
const lib = el('div', { className: 'libcard' }, el('div', { className: 'lib' }, libraryDisplay || 'Library card'), el('div', { className: 'nm' }, p.name),
|
||||
el('div', {}, el('div', { id: 'cardsvg' }), el('div', { className: 'no' }, p.card)));
|
||||
$('cardbox').replaceChildren(lib);
|
||||
$('cardsvg').innerHTML = code39(p.card);
|
||||
}
|
||||
$('printcard').onclick = () => window.print();
|
||||
|
||||
function route() {
|
||||
const h = location.hash.replace(/^#/, '');
|
||||
const m = h.match(/^book\/(\d+)$/);
|
||||
let view = m ? 'book' : (h === 'add' && !viewer) ? 'add' : 'library';
|
||||
$('view-library').hidden = view !== 'library'; $('view-book').hidden = view !== 'book'; $('view-add').hidden = view !== 'add';
|
||||
$('tab-library').classList.toggle('on', view !== 'add'); $('tab-add').classList.toggle('on', view === 'add');
|
||||
$('tab-add').hidden = viewer;
|
||||
document.title = view === 'add' ? 'Add books' : (libraryName || 'Library');
|
||||
$('title').textContent = view === 'add' ? 'Add books' : (libraryName || 'Library');
|
||||
let m, view = 'library';
|
||||
if ((m = h.match(/^book\/(\d+)$/))) view = 'book';
|
||||
else if (viewer) view = 'library';
|
||||
else if (h === 'add') view = 'add';
|
||||
else if ((m = h.match(/^lend(?:\/(\d+))?$/))) view = 'lend';
|
||||
else if (h === 'people') view = 'people';
|
||||
else if ((m = h.match(/^card\/(\d+)$/))) view = 'card';
|
||||
for (const v of ['library', 'book', 'add', 'lend', 'people', 'card']) $('view-' + v).hidden = v !== view;
|
||||
const tab = { library: 'library', book: 'library', add: 'add', lend: 'lend', people: 'people', card: 'people' }[view];
|
||||
for (const t of ['library', 'add', 'lend', 'people']) $('tab-' + t).classList.toggle('on', t === tab);
|
||||
for (const t of ['add', 'lend', 'people']) $('tab-' + t).hidden = viewer;
|
||||
const names = { library: 'Library', book: 'Library', add: 'Add books', lend: 'Lend / return', people: 'People', card: 'Library card' };
|
||||
$('title').textContent = names[view]; document.title = names[view];
|
||||
if (view === 'book') loadBook(m[1]);
|
||||
else if (view === 'add') $('code').focus();
|
||||
else if (view === 'lend') { loadOut(); curPatron = null; $('patronbox').replaceChildren();
|
||||
if (m && m[1]) openPatron(m[1]); else { $('whoresults').replaceChildren(); $('who').focus(); } }
|
||||
else if (view === 'people') { loadPeople($('pq').value.trim()); }
|
||||
else if (view === 'card') loadCard(m[1]);
|
||||
else { if (!libLoaded) { libLoaded = true; loadLibrary(true); } if (h === 'find') $('q').focus(); }
|
||||
}
|
||||
window.addEventListener('hashchange', route);
|
||||
let libraryName = '';
|
||||
let libraryDisplay = '';
|
||||
|
||||
(async () => {
|
||||
const s = await api('/api/state');
|
||||
@@ -663,6 +865,7 @@
|
||||
}
|
||||
if (!viewer) api('/api/settings').then(applySettings);
|
||||
$('earlier').replaceChildren(...s.recent.slice(0, 25).map(e => rowFor(e, false)));
|
||||
libraryDisplay = s.library_name || '';
|
||||
route();
|
||||
})();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user