Registo de chaves

This commit is contained in:
Luis Lourenço 2026-08-24 15:21:24 +01:00
commit ef83aa153f
20 changed files with 4521 additions and 0 deletions

31
.dockerignore Normal file
View file

@ -0,0 +1,31 @@
# ---------------------------------------------------------------------------
# What must NEVER end up inside the image.
#
# The Dockerfile now does `COPY . .`, so this file is the only thing standing
# between your key register and a container image you might push to a public
# registry. Treat additions here with care.
# ---------------------------------------------------------------------------
# The register itself. Baking the database into an image would ship a list of
# every door in the building and who holds each key.
data/
*.db
*.db-wal
*.db-shm
backups/
# Secrets.
.env
.env.*
!.env.example
# Rebuilt inside the image; copying the host's would break native modules
# built for a different architecture.
node_modules/
# Noise.
.git/
.gitignore
*.log
.DS_Store
npm-debug.log*

26
.env.example Normal file
View file

@ -0,0 +1,26 @@
# Copy to .env if you want to change any of these.
TZ=Europe/Lisbon
# Interface language: pt or en
UI_LANG=pt
# Optional shared passcode on top of Tailscale. Empty = no login at all.
ACCESS_CODE=
# Number of reverse proxies in front of the app. 1 for Nginx Proxy Manager,
# 2 for Cloudflare in front of NPM. Leave EMPTY when nothing proxies it —
# trusting a forwarded header that nobody sets lets clients forge their IP.
TRUST_PROXY=
# --- access request form (/pedido) ---------------------------------------
# A shared PIN people must type. Change it when it leaks; it will.
REQUEST_PIN=
# 1 = make /pedido reachable WITHOUT the app passcode, so people outside your
# Tailscale can submit. Read Part 5 of the README before turning this on: it
# is the only endpoint an outsider can reach. Leave at 0 for staff-only use.
PUBLIC_REQUESTS=0
# 1 = load sample data on first start, so you can click around.
# Set back to 0 and use Admin > Clear everything before real data.
SEED_DEMO=0

View file

@ -0,0 +1,43 @@
# Optional: build and publish the image whenever you push a tag.
#
# Requires a Forgejo Actions runner registered to this repository or instance.
# If you have no runner, ignore this file and build from your laptop or the
# VPS instead — see Part 10 of the README.
#
# Tag a release with: git tag v1.0 && git push origin v1.0
name: build image
on:
push:
tags: ["v*"]
workflow_dispatch:
jobs:
publish:
runs-on: docker
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- name: Log in to the Forgejo registry
uses: docker/login-action@v3
with:
registry: git.plascloud.pt
username: ${{ github.actor }}
# Forgejo provides this automatically; no secret to configure.
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
# Add linux/arm64 only if you also run it on ARM. Building the
# native SQLite module under emulation is slow.
platforms: linux/amd64
push: true
tags: |
git.plascloud.pt/luisl/lxc-keyreg:${{ github.ref_name }}
git.plascloud.pt/luisl/lxc-keyreg:latest

25
.gitignore vendored Normal file
View file

@ -0,0 +1,25 @@
# ---------------------------------------------------------------------------
# The register itself. A git repository is copied, mirrored and cloned — the
# one place a list of every door and who holds each key must never end up.
# ---------------------------------------------------------------------------
data/
*.db
*.db-wal
*.db-shm
backups/
*.backup
# Secrets: ACCESS_CODE, REQUEST_PIN, database passwords.
.env
.env.*
!.env.example
# Built locally, per architecture.
node_modules/
# Noise
*.log
npm-debug.log*
.DS_Store
.idea/
.vscode/

41
Dockerfile Normal file
View file

@ -0,0 +1,41 @@
# ---------------------------------------------------------------------------
# Church key register
# Self-contained image: source, dependencies and static assets all baked in.
# Push it to a registry and run it anywhere without the source tree.
# ---------------------------------------------------------------------------
# Stage 1 — dependencies only.
# package.json is copied on its own so `npm install` is cached and does not
# re-run every time you edit a .js file. better-sqlite3 is native, and on ARM
# hardware there may be no prebuilt binary, so a compiler must be present.
FROM node:22-bookworm-slim AS deps
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ ca-certificates && rm -rf /var/lib/apt/lists/*
COPY package.json ./
RUN npm install --omit=dev --no-audit --no-fund
# Stage 2 — runtime. No compiler shipped.
FROM node:22-bookworm-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
sqlite3 ca-certificates && rm -rf /var/lib/apt/lists/* \
&& mkdir -p /data && chown node:node /data
COPY --from=deps --chown=node:node /app/node_modules ./node_modules
# Everything else. .dockerignore decides what stays out — read it before
# changing anything here, because that file is what keeps your database and
# your .env from being baked into an image you might push to a registry.
COPY --chown=node:node . .
# Files downloaded from a browser often arrive owner-only (600), and COPY
# preserves the mode. Without this the non-root user cannot read its own code.
RUN chmod -R a+rX /app
ENV NODE_ENV=production DATA_DIR=/data PORT=3000 UI_LANG=pt
EXPOSE 3000
USER node
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s \
CMD node -e "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "server.js"]

666
README.md Normal file
View file

@ -0,0 +1,666 @@
# Church Key Register
Tracks which physical key copy and which NFC alarm tag is held by which person,
across several cabinets, with a signed record of every handover.
Node + SQLite in one container. The whole register is a single file.
**Print Part 5 and tape it inside the key cabinet.**
---
## What it does
- **Board** — one peg per piece of metal, grouped by cabinet, in hook order.
Filled means it's on its hook, hollow means it's out with someone.
- **Doors** — one row per door, showing all its copies and everyone holding one.
- **People** — who holds what, and everything they've ever returned.
- **Log** — every handover, append-only, with signatures.
- **Journal** — cabinet-level events that belong to no single key.
- **Admin** at `/admin` — keys, doors, people, cabinets, CSV import, backup.
Key holders never log in. Only you and your colleague use it.
---
## Part 1 — Install
```bash
sudo mkdir -p /opt/keyreg && cd /opt/keyreg
# copy the project files here
mkdir -p data
docker compose up -d --build
docker compose logs -f keyreg
```
First build takes a few minutes — `better-sqlite3` is compiled from source.
### Access, via Tailscale
```bash
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
sudo tailscale serve --bg 3000
tailscale status # gives you https://yournas.tailXXXX.ts.net
```
Install Tailscale on your phone, sign in to the same account, open that
address. Nothing is exposed to the internet and no ports are opened.
**Real HTTPS is what makes the phone camera and signature pad behave.** Over
plain `http://` on a LAN IP, some browsers restrict things.
### Optional passcode
Set `ACCESS_CODE` in a `.env` file next to `docker-compose.yml` for a second
layer on top of Tailscale. Leave it empty and there is no login at all, which
is a reasonable choice when only your own devices can reach the app.
When it is set, a login screen appears. The cookie stores a hash rather than
the passcode itself, and five wrong attempts from one address trigger a
one-minute pause — a shared passcode is short enough to be worth guessing
otherwise. Failed attempts appear in Admin → Change log.
### Want to click around first?
Set `SEED_DEMO=1`, start it, explore, then wipe from **Admin → Import /
backup → Clear everything** and set it back to `0`.
---
## Part 2 — Load your keys
The model is three tables, and getting this right matters more than the app:
| Table | What it holds |
|---|---|
| **Doors** | The 48 physical doors. One row each. |
| **Items** | The ~80 pieces of metal, plus 30 alarm tags. `12-A`, `12-B`, `12-C`. |
| **Handovers** | Who took which copy, when, and when it came back. |
**Every physical copy needs its own tag number.** Your 48 numbers identify
doors; add a letter for each copy. Without this, the register can tell you a
key is out but never *which one* — and then it is no better than paper.
### Import
Admin → Importar / cópias, then pick what you are importing. Four separate
importers. **Load them in this order** — keys point at doors and cabinets, so
those must exist first:
1. Cabinets → 2. Doors → 3. People → 4. Keys & tags
#### How the CSV is read
- The **first row must be the header**, naming the columns.
- Column **order does not matter**, and names are **not case-sensitive**
(`Tag`, `tag` and `TAG` are the same).
- Columns you don't need can be **left out entirely**, or left blank per row.
- Extra columns the importer doesn't recognise are **ignored**, so you can
paste a wider spreadsheet without cleaning it up first.
- Comma, semicolon or tab all work as separators — Excel in Portuguese usually
exports semicolons, and that is fine.
- Use quotes around any value containing the separator:
`12-A,"Sacristia, porta interior",Igreja`
- Wherever a column is described as **yes/no** below, these all count as yes:
`1`, `sim`, `s`, `yes`, `y`, `true`, `x`. Anything else, including blank,
counts as no.
#### 1. Cabinets
| Column | Required | What it is |
|---|---|---|
| `id` | **yes** | Short code, e.g. `SAC`. Forced to uppercase. This is what the keys file refers to. |
| `name` | **yes** | Short label for the tab on the board, e.g. `Sacristia`. Keep it under ~12 characters. |
| `full_name` | no | Full name used in headings, e.g. `Armário da sacristia`. Defaults to `name`. |
| `note` | no | Where it is and what opens it. Shown under the tab. |
| `off_site` | no | yes/no. Yes for your home reserve box: keys returned there go back to *reserve* instead of *in cabinet*. |
| `sort` | no | Number. Lower comes first on the board. Give the reserve a high number so it sits last. Default `0`. |
```csv
id,name,full_name,note,off_site,sort
SAC,Sacristia,Armário da sacristia,Sacristia atrás da porta,0,1
CASA,Reserva,Conjunto de reserva,Caixa selada fora das instalações,1,20
```
#### 2. Doors
| Column | Required | What it is |
|---|---|---|
| `num` | **yes** | The door number, exactly as on your list. Must be unique. |
| `building` | **yes** | The grouping the door belongs to, e.g. `Igreja`. Also the list of **areas** offered on the public request form, so avoid anything you would not show an outsider. |
| `name` | **yes** | The door itself, e.g. `Sacristia`. |
| `lock_group` | no | Doors sharing a key profile get the same code, e.g. `B`. Lets you see what else a lost key might open. Fill in later if you don't know yet. |
| `notes` | no | Anything about the door itself. |
```csv
num,building,name,lock_group,notes
1,Igreja,Entrada principal poente,A,
2,Igreja,Sacristia,B,Fechadura dura no inverno
```
#### 3. People
| Column | Required | What it is |
|---|---|---|
| `name` | **yes** | Full name. This is the match key — re-importing the same name **updates** that person. |
| `dept` | no | Department or group, e.g. `Coro`. |
| `role` | no | Function, e.g. `Diretora do coro`. |
| `phone` | no | Useful when chasing an overdue key. |
| `email` | no | Stored only; the app sends no email. |
| `active` | no | yes/no. **Blank means yes.** Set no for people who no longer hold keys — they stay in old handovers but cannot be given new ones. |
```csv
name,dept,role,phone
Maria Fonseca,Secretaria,Secretária,910000000
João Ribeiro,Manutenção,Sacristão,
```
#### 4. Keys & tags
One row per **physical piece of metal**, not per door.
| Column | Required | What it is |
|---|---|---|
| `tag` | **yes** | The number stamped on that copy, e.g. `12-A`. Must be unique — this is what makes the register able to say *which* copy is out. |
| `door` | **yes** | The door **name**, matched against the doors you already imported. If no door matches, one is created. |
| `building` | **yes** | Used together with `door` to find the right door, since two buildings may both have a `Cozinha`. |
| `lock_group` | no | **Only used if this row has to create a new door.** For doors that already exist it is ignored — edit the door instead. |
| `cabinet` | no | The cabinet `id`, e.g. `SAC`. Created if it does not exist, but then it gets no proper name, so import cabinets first. |
| `hook` | no | Position in the cabinet, e.g. `SAC·R2·05`. Sets the order keys appear on the board and on the audit sheet. Blank falls back to tag order. |
| `master` | no | yes/no. Marks it as a master key on the board. |
| `status` | no | `in` (default), `out`, `reserve`, `lost`, `retired`. Use `reserve` for copies in the off-site box. Do **not** import `out` — issue the key in the app instead, so there is a handover record. |
| `kind` | no | The type **code** from Admin → Tipos, e.g. `key`, `nfc`, `cadeado`. Defaults to the first type. A code that does not exist is **refused**, and the row is listed as skipped — create the type first. |
| `notes` | no | Permanent note about this copy. Searchable. |
```csv
tag,door,building,cabinet,hook,master,status,notes
12-A,Sacristia,Igreja,SAC,SAC·R1·03,,in,
12-B,Sacristia,Igreja,SEC,SEC·R2·01,,in,Cópia guardada de propósito na secretaria
12-R,Sacristia,Igreja,CASA,CASA·12,,reserve,
```
#### Re-running an import
All four are safe to run again. Cabinets match on `id`, doors on `num`, people
on `name` (ignoring capitals) — so a corrected spreadsheet **updates** those
records instead of creating duplicates. Keys match on `tag` and are **skipped**
if the tag already exists, so you can never end up with two records for one
piece of metal. The result box tells you how many were created, updated and
skipped.
There is a useful distinction between a **missing column** and an **empty
cell**:
- A column you leave out is **not touched**. Re-importing just
`name,role` updates everyone's role and leaves phone and department alone.
- A column that is present but blank on that row **clears** the field. That is
how you empty something on purpose.
So to fix one field across many people, export, delete every column except
`name` and the one you are correcting, and re-import. Nothing else changes.
To change a key that already exists, edit it in Admin → Chaves e etiquetas.
### Types
Out of the box there are two: **Chave** (circle, green) and **Etiqueta de
alarme** (rounded, violet). Add your own in Admin → Tipos — padlocks, gate
remotes, fobs, meter-cupboard keys, whatever you keep on those hooks.
Each type has:
- a **code**, used by the `kind` column on import. Lowercase, no spaces. Do not
change it once keys exist with it.
- a **name**, shown everywhere. Change it whenever you like.
- a **shape** — circle, rounded or square — and a **colour**.
The board reads shape as *what a thing is* and fill as *where it is*, so give
each type a distinct shape rather than relying on colour alone. Types appear
automatically as filters on the board and in the legend.
A type cannot be deleted while any item uses it, and you cannot delete the last
one.
### Cabinets
Create them first (Admin → Cabinets), because import references them by code.
**Board order** controls where a cabinet appears on the board and in reports.
Lower numbers come first. Give the reserve store a high number — 20, say — so
it always sits at the end, after the cabinets you use daily.
Within a cabinet, keys are shown in **hook order**, using the hook field. Fill
it in and the board reads down the cabinet exactly as the hooks do, which is
what makes the audit sheet countable. Leave it blank and keys fall back to tag
order.
Tick **off site** for your home reserve box. Keys returning to an off-site
store go back to *reserve* rather than *in cabinet*, so they stay out of
everyday circulation. Issuing one writes itself into the journal.
### Two physical rules
- **Label each key with the number only.** `12-A`, never "Sacristy". A lost
key labelled with its door is an invitation.
- **Never store a cabinet's own key inside that cabinet.** Cross-store them,
and keep a reserve copy of each off site.
---
## Part 3 — Everyday use
- **Giving out a key:** Board or Doors → tap the key → Issue → pick the person
→ set a return date if it's temporary → they sign on your phone.
- **Taking it back:** tap the key → Take it back.
- **Who has what:** People tab, or Doors for a whole door at once.
- **Overdue:** the Overdue filter on the Board. Check it monthly — nothing
emails you.
Most church keys are held permanently, so "no return date" is the default.
Only set a date for genuinely temporary loans.
---
## Part 4 — Printed reports
The printer icon in the header, or `/report`. Everything renders as a plain
web page, so your browser's own **Print → Save as PDF** produces the file.
Nothing to install, nothing to keep updated.
| Report | Use it for |
|---|---|
| **Keys currently issued** | Grouped by person. The one to print for the council. |
| **Overdue keys** | Only what is past its return date. |
| **Receipt per person** | What that person holds, with their captured signatures, and a line to sign. Print when someone takes on a permanent set. |
| **Full inventory** | Every key by cabinet. A copy in the folder, and proof of the whole register on paper. |
| **Audit sheet** | A tick-box list per cabinet, in hook order. Take it to the cabinet and count along. |
The audit sheet is the one to use for the monthly check. It marks which keys
should be absent, so a missing key is obvious rather than something you have
to remember.
Reports link deeply: `/report?view=person&id=3` goes straight to one person's
receipt. There is a shortcut on each person's card in the app.
---
## Part 5 — Access requests (`/pedido`)
A form where someone asks for access. It creates **nothing** — no person, no
key, no handover. It files a request and writes a line in the journal. You
still decide.
Requests appear at the top of the **Journal** tab, with a count on the tab so
you notice them. Handle one by writing what you decided; it stays on record.
### Checking whether the form is actually protected
The Journal tab shows the live state of the form, read from the running
server rather than from what you think is in `.env`:
- *Formulário só acessível a quem entra na aplicação* — staff only.
- *Formulário público, protegido por código* — public, PIN required.
- *FORMULÁRIO PÚBLICO SEM CÓDIGO* — in red. Public with no PIN, which means
anyone who finds the address can post. Set `REQUEST_PIN` and restart.
The server prints the same thing on startup, so `docker compose logs keyreg`
tells you too. The PIN field only appears on the form when a PIN is
configured — so if you open `/pedido` and see no PIN box, there is no PIN.
### Handing your phone to someone
The **✚** icon in the app header opens the form in hand-over mode
(`/pedido?kiosk=1`). Give the person your phone, they fill it in, and the
confirmation says *"Pode devolver o telemóvel"* with a button for a fresh
blank form if two people are waiting.
No PIN is asked for in this case. The PIN exists to stop strangers on the
internet, and you are standing next to them — so a request submitted from
inside the app skips it. The journal records which way it came, *Formulário
(no local)* or *Formulário (online)*, so months later you can tell whether you
were there.
**One caveat, and it is a real one.** The form page has no link back into the
app, but the browser's back button still works. If you hand over an unlocked
phone, that person can return to the register. For a visitor you do not know,
use Guided Access on iOS or Screen Pinning on Android to lock the phone to
that one screen first. For a colleague, it does not matter.
### It is staff-only until you say otherwise
`PUBLIC_REQUESTS=0` is the default, which means `/pedido` sits behind the same
passcode as the rest of the app. Useful as a tablet form in the office, but
nobody outside can submit.
Setting `PUBLIC_REQUESTS=1` makes `/pedido` and its two endpoints reachable
without the passcode. **That is the only part of this system an outsider can
reach**, so understand what you are opening:
- Set `REQUEST_PIN` as well. Without it, anything that finds the URL can post.
- The PIN is shared, so treat it as semi-public — print it in the church
bulletin if you like, and change it when it spreads too far.
- Five wrong PINs from one address pause that address for five minutes. A
hidden field catches bots that fill in every input; their submissions are
silently discarded rather than rejected, so they get no feedback to learn from.
### What the form deliberately does not show
It offers **areas** — your building names — never door names. A public page
listing "Sacristia, Torre sineira, Casa das caldeiras" is a map of the building
for anyone who finds it. The `/api/request/areas` endpoint returns building
names only.
Check what your building names actually are before opening this up: they come
from the `building` field on your doors, so if you have a building called
"Armários" it will be offered as a requestable area. Rename anything that
reads oddly to an outsider.
### Behind a reverse proxy
Set `TRUST_PROXY=1` (or `2` for Cloudflare in front of Nginx Proxy Manager).
Without it every request looks like it came from the proxy, so all rate limits
share one bucket and five wrong attempts lock out everyone. Leave it **empty**
when nothing proxies the app — trusting a forwarded header nobody sets lets a
client forge their own address and dodge the limits.
### Reaching it from outside
Tailscale keeps everything private, which is the point — so publishing
`/pedido` needs a deliberate second path. A Cloudflare Tunnel restricted to
that one path is the usual approach. Whatever you choose, confirm that `/`,
`/admin` and `/api/state` are **not** reachable by the same route before you
share the link.
---
## Part 6 — Backups
```bash
chmod +x backup.sh
# edit BACKUP_DIR and OFFSITE_DEST at the top
sudo ./backup.sh # run once by hand
sudo crontab -e
# 20 3 * * * /opt/keyreg/backup.sh >> /var/log/keyreg-backup.log 2>&1
```
It writes a verified SQLite snapshot **and** a CSV. The CSV matters: if the app
is ever gone, a spreadsheet still tells you who holds which key.
**Do a test restore before you trust it.** The instructions are at the bottom
of `backup.sh` and amount to copying one file back.
---
## Part 7 — Handover to the next person
Fill this in and keep it current:
- Machine: `________________________`
- Address: `https://________________.ts.net`
- Tailscale account: `________________________`
- Passcode, if set, is in: `________________________`
- Backups run at 03:20 to: `________________________`
- Off-site copy at: `________________________`
- Technical help: `________________________`
**Monthly, five minutes:**
1. Board → Overdue filter. Chase anything red.
2. Check `/var/log/keyreg-backup.log` for failures.
3. Print the audit sheet for one cabinet and count along the hooks.
**Yearly:** full audit of every cabinet, and confirm the reserve box is still
sealed. Write it in the Journal.
**On handover:** add their device to Tailscale, walk them through one real
handover and one restore, then remove your own access.
---
## Design decisions worth keeping
- **Nothing is ever deleted.** People are deactivated, keys are retired. Both
appear in old handovers, and deleting them would make that history
anonymous — which is exactly what the register exists to prevent.
- **Handovers are append-only.** Returning fills in a date; it never erases.
- **Cabinet and building are separate fields**, because the office cabinet can
hold spare masters for the church.
- **Admin changes are logged** in Admin → Change log. With two of you editing,
"who moved 12-B and when" comes up eventually.
---
## Part 8 — Running it on a VPS
Works well: the app is small, and a VPS gives you a real hostname, automatic
certificates and uptime independent of the church building. But the security
assumptions change, because the login page now faces the internet.
### Non-negotiable once it is public
1. **Set `ACCESS_CODE`.** On a private network, running with no login was a
defensible choice. On a VPS it is not.
2. **Set `TRUST_PROXY=1`**, or all rate limiting collapses into one bucket.
3. **Add Basic auth in NPM** (Access Lists) as a second, independent layer, so
bots never reach the application at all. Skip this only if you need
`/pedido` publicly reachable — in which case rely on the app's own gate,
which already allows exactly the request paths and nothing else.
4. **Force SSL, HTTP/2 and HSTS** on the proxy host.
5. **Pull backups off the VPS.** Set `OFFSITE_DEST` in `backup.sh`. If the
server dies or the account is suspended, whatever is only on that disk is
gone.
### Do not publish the port
If NPM runs in Docker on the same host, delete the `ports:` block from
`docker-compose.yml` and join NPM's network instead (both are commented in the
file). Point the proxy host at `keyreg` port `3000`. Nothing then listens on
any public interface.
Confirm it afterwards:
```bash
ss -lntp | grep 3000 # should print nothing
curl -sS http://YOUR_VPS_IP:3000/health # should refuse to connect
```
### Choosing how to protect it
Three workable shapes, in order of how much they expose:
**1. Nothing public.** Run Tailscale on the VPS as well and keep the app
private, exactly as it would be at home. You still get the VPS's uptime and no
dependency on the church building. Choose this unless you actually need
outsiders to submit requests.
**2. Basic auth over everything.** An NPM **Access List** puts a browser
password prompt in front of the whole host, and `ACCESS_CODE` sits behind it —
two independent layers, and bots never reach the application at all. Simple:
no path rules to maintain. The cost is that `/pedido` is behind the prompt too,
so only people with that password can submit a request. Fine when you are
always the one holding the phone.
**3. Public request form, everything else closed.** Do **not** add an Access
List. Set `ACCESS_CODE`, `REQUEST_PIN` and `PUBLIC_REQUESTS=1`, and rely on the
app's own gate: it allows exactly `/pedido`, `/api/request`,
`/api/request/areas`, `/styles.css` and `/i18n.js`, and returns the login
screen or 401 for everything else. That whitelist lives in the code, so it
cannot drift out of step with nginx location blocks the day a route is added.
A neat hybrid, if you want both: Tailscale for the app *and* NPM publishing
only `/pedido`.
### Nginx Proxy Manager settings
On the proxy host: **Block Common Exploits**, **Websockets off** (unused),
and under SSL: **Force SSL**, **HTTP/2**, **HSTS enabled**.
Forward to `keyreg` port `3000` over the shared Docker network — not to
`127.0.0.1`, which would not resolve from inside the NPM container.
### Basic auth has no lockout
NPM will not rate-limit it. If you use an Access List, run fail2ban on the VPS
watching NPM's log for repeated 401s.
### One thing you lose
When the VPS or your connection is down, you cannot look up a key while
standing at the cabinet. Print the full inventory report and keep it in the
cabinet — worth doing regardless, and the reason the report exists.
---
## Part 9 — Git and the Forgejo registry
### Push the code
```bash
cd /path/to/keyreg
git init
git add .
git commit -m "Registo de chaves"
git branch -M main
git remote add origin https://git.plascloud.pt/LuisL/lxc-keyreg.git
git push -u origin main
```
**Check `git status` before the first commit.** `.gitignore` keeps `data/`,
any `.db` file and `.env` out — a repository gets cloned and mirrored, and a
list of every door with its key holders is the last thing that should travel.
If `git status` shows `.env` or anything under `data/`, stop and fix it first.
Once a secret has been committed, removing it in a later commit is not enough
— it stays in the history. Change the value instead.
### Build and publish the image
Create a token in Forgejo: **Settings → Applications → Generate token**, with
the `write:package` scope. Then:
```bash
docker login git.plascloud.pt # username LuisL, password = the token
docker buildx build \
--platform linux/amd64 \
-t git.plascloud.pt/luisl/lxc-keyreg:1.0 \
-t git.plascloud.pt/luisl/lxc-keyreg:latest \
--push .
```
The owner part of the image name must be **lowercase**, whatever case your
username uses.
Pick the platform to match the VPS, not your laptop. A Mac builds `arm64` by
default and that image will not start on an x86 VPS. `uname -m` on the VPS
tells you: `x86_64` → `linux/amd64`, `aarch64` → `linux/arm64`.
### Run it on the VPS
Only two files are needed there — `docker-compose.yml` and `.env`:
```bash
docker login git.plascloud.pt
echo "IMAGE=git.plascloud.pt/luisl/lxc-keyreg:1.0" >> .env
docker compose pull
docker compose up -d
```
Delete the `build: .` line from `docker-compose.yml` once you are pulling a
published image, so a stray `--build` cannot rebuild from a stale checkout.
### Upgrading, and going back
```bash
./backup.sh # always first
# bump IMAGE to the new tag in .env
docker compose pull && docker compose up -d
```
Tag every build with a real version. If `1.1` misbehaves, putting `1.0` back
in `.env` and running `docker compose up -d` restores the previous image in
seconds. `latest` gives you nothing to go back to.
### Simpler, if you would rather skip the registry
The VPS can build it itself:
```bash
git clone https://git.plascloud.pt/LuisL/lxc-keyreg.git keyreg
cd keyreg && cp .env.example .env # edit it
docker compose up -d --build
```
No registry, no token, no cross-architecture problem — it builds for whatever
the VPS is. `git pull && docker compose up -d --build` to upgrade. The registry
is worth it once you are running it in more than one place.
### Automating it
`.forgejo/workflows/build.yml` builds and publishes on every `v*` tag, if you
have a Forgejo Actions runner. No runner, no problem — the file is inert.
---
## Part 10 — Building once, running anywhere
The image is self-contained: source, dependencies and static files are all
inside it. You can build it once, push it to a registry, and run it on the NAS
without ever copying the source there.
### What is not in the image
`.dockerignore` keeps three things out, deliberately:
- **`data/` and any `.db` file.** Baking the register into an image would ship
a list of every door in the building and who holds each key — to whatever
registry you push to. Never remove this rule.
- **`.env`.** Your passcode.
- **`node_modules/`.** Rebuilt inside the image, because a native module built
on your Mac will not run on the NAS.
### Build and push
Your Mac is almost certainly ARM64. If the NAS is x86, an image built plainly
on the Mac **will not start there**. Build for both:
```bash
docker buildx create --use --name keyreg-builder # once
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t ghcr.io/YOURNAME/keyreg:1.0 \
-t ghcr.io/YOURNAME/keyreg:latest \
--push .
```
For a private registry, log in first with `docker login ghcr.io`.
### Run from the registry
On the NAS, you need only `docker-compose.yml`:
```bash
echo "IMAGE=ghcr.io/YOURNAME/keyreg:1.0" >> .env
docker compose pull
docker compose up -d
```
Delete the `build: .` line there and the source tree is never needed.
### Version your tags
Tag every build with a real version, not just `latest`. When an upgrade
misbehaves, `IMAGE=ghcr.io/YOURNAME/keyreg:1.0 docker compose up -d` rolls you
back in seconds. `latest` gives you nothing to roll back to.
### Upgrading
```bash
./backup.sh # always first
docker compose pull
docker compose up -d
```
The database lives in a Docker volume, not in the image, so it survives every
upgrade untouched.

73
backup.sh Normal file
View file

@ -0,0 +1,73 @@
#!/usr/bin/env bash
#
# Nightly backup for the key register.
#
# SQLite's .backup makes a consistent copy while the app is running — do NOT
# just cp the .db file, because the WAL may hold recent writes that a plain
# copy would miss.
#
# Install:
# chmod +x backup.sh
# sudo crontab -e
# 20 3 * * * /opt/keyreg/backup.sh >> /var/log/keyreg-backup.log 2>&1
set -Eeuo pipefail
APP_DIR="/opt/keyreg"
BACKUP_DIR="/mnt/backup/keyreg" # MUST be a different physical disk
OFFSITE_DEST="" # e.g. "backup@nas2:/volume1/keyreg"
KEEP_DAYS=60
STAMP="$(date +%Y-%m-%d_%H%M)"
OUT="${BACKUP_DIR}/keyreg-${STAMP}.db"
log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*"; }
trap 'log "FAILED at line $LINENO — backup NOT complete"; exit 1' ERR
mkdir -p "$BACKUP_DIR"
cd "$APP_DIR"
# 1. Consistent snapshot
log "Backing up database..."
docker compose exec -T keyreg \
sqlite3 /data/keyreg.db ".backup '/data/_tmp_backup.db'"
docker compose cp keyreg:/data/_tmp_backup.db "$OUT"
docker compose exec -T keyreg rm -f /data/_tmp_backup.db
# 2. Verify — an unreadable backup is not a backup
sqlite3 "$OUT" "PRAGMA integrity_check;" | grep -q '^ok$' \
|| { log "Integrity check FAILED"; exit 1; }
ITEMS=$(sqlite3 "$OUT" "SELECT COUNT(*) FROM items;")
[ "$ITEMS" -gt 0 ] || { log "Backup has zero keys — refusing to call this good"; exit 1; }
gzip -9 "$OUT"
log "OK — ${ITEMS} keys, $(stat -c %s "${OUT}.gz") bytes"
# 3. A readable copy too. If the app is ever gone, a CSV still tells you
# who holds which key — which is the whole point of the register.
curl -fsS "http://127.0.0.1:3000/api/admin/export.csv" \
-o "${BACKUP_DIR}/keyreg-${STAMP}.csv" || log "WARNING: CSV export failed"
# 4. Off site — survives fire and theft, which is when you most need to know
# who has keys to the building
if [ -n "$OFFSITE_DEST" ]; then
rsync -az --delete "${BACKUP_DIR}/" "${OFFSITE_DEST}/"
log "Off-site sync done"
else
log "WARNING: OFFSITE_DEST empty — local copy only"
fi
# 5. Retention
find "$BACKUP_DIR" -maxdepth 1 -type f -mtime "+${KEEP_DAYS}" -delete
log "Complete"
# ===========================================================================
# RESTORE — test this once now, not on the day you need it
#
# cd /opt/keyreg
# docker compose down
# gunzip -c /mnt/backup/keyreg/keyreg-DATE.db.gz > ./data/keyreg.db
# rm -f ./data/keyreg.db-wal ./data/keyreg.db-shm
# docker compose up -d
#
# That is the whole restore. One file.
# ===========================================================================

60
docker-compose.yml Normal file
View file

@ -0,0 +1,60 @@
# Church key register
#
# One container, one SQLite file. Access comes from Tailscale, not from
# opening a port to the network.
services:
keyreg:
# Build locally, or point IMAGE at a registry copy and drop `build:`
# entirely — the image is self-contained and needs no source tree.
# IMAGE=ghcr.io/you/keyreg:1.0 docker compose up -d
build: .
image: ${IMAGE:-keyreg:local}
container_name: keyreg
restart: unless-stopped
environment:
TZ: ${TZ:-Europe/Lisbon}
# Interface language: pt (Portugal) or en.
UI_LANG: ${UI_LANG:-pt}
# Optional shared passcode. Leave empty and there is no gate at all —
# correct when only your Tailscale devices can reach this.
ACCESS_CODE: ${ACCESS_CODE:-}
# Reverse proxies in front (1 = NPM, 2 = Cloudflare + NPM). Empty = none.
TRUST_PROXY: ${TRUST_PROXY:-}
# Shared PIN for the /pedido request form.
REQUEST_PIN: ${REQUEST_PIN:-}
# 1 = /pedido bypasses ACCESS_CODE so outsiders can submit. See README.
PUBLIC_REQUESTS: ${PUBLIC_REQUESTS:-0}
# Set to 1 for the very first run if you want sample data to click around.
# Turn it back to 0 before entering anything real.
SEED_DEMO: ${SEED_DEMO:-0}
volumes:
# A named volume, not a bind mount. SQLite in WAL mode needs shared
# memory mapping, which Docker Desktop's file sharing on macOS and
# Windows does not support — a bind mount there fails to open the
# database, or corrupts it later. This works the same everywhere.
- keyreg_data:/data
ports:
# Localhost only. `tailscale serve --bg 3000` puts HTTPS in front.
# Change to "3000:3000" ONLY for temporary testing on your own LAN.
#
# ON A VPS WITH NGINX PROXY MANAGER: delete this whole `ports:` block and
# uncomment `networks:` below. NPM then reaches the app by container name
# over the shared Docker network, and nothing at all listens on the public
# interface — not even on localhost, where any other user on the box could
# curl it.
- "127.0.0.1:3000:3000"
# networks:
# - npm
# networks:
# npm:
# external: true
# # The network NPM already runs on. Find it with:
# # docker network ls
# # docker inspect <npm-container> -f '{{json .NetworkSettings.Networks}}'
# name: CHANGEME_npm_default
volumes:
keyreg_data:

19
package.json Normal file
View file

@ -0,0 +1,19 @@
{
"name": "keyreg",
"version": "1.0.0",
"private": true,
"description": "Church key and alarm-tag register",
"type": "commonjs",
"main": "server.js",
"scripts": {
"start": "node server.js",
"seed": "node server.js --seed-demo"
},
"dependencies": {
"better-sqlite3": "^11.7.0",
"express": "^4.21.2"
},
"engines": {
"node": ">=20"
}
}

37
public/admin.html Normal file
View file

@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="pt">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#131E1B">
<title>Key Register — Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans+Condensed:wght@600;700&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<div class="admin">
<header>
<p class="eyebrow" id="brand"></p>
<h1 id="apptitle"></h1>
<a class="gear" href="/" id="backlink">←</a>
</header>
<div class="adm-nav" id="nav">
<button data-p="items" aria-pressed="true"><span data-t="aItems"></span></button>
<button data-p="doors" aria-pressed="false"><span data-t="aDoors"></span></button>
<button data-p="people" aria-pressed="false"><span data-t="aPeople"></span></button>
<button data-p="cabinets" aria-pressed="false"><span data-t="aCabinets"></span></button>
<button data-p="kinds" aria-pressed="false"><span data-t="aKinds"></span></button>
<button data-p="data" aria-pressed="false"><span data-t="aData"></span></button>
<button data-p="audit" aria-pressed="false"><span data-t="aAudit"></span></button>
</div>
<div id="body" style="padding:14px"><div class="empty" id="loading"></div></div>
</div>
<div class="scrim" id="scrim"></div>
<div class="sheet" id="sheet" role="dialog" aria-modal="true"></div>
<div class="toast" id="toast"></div>
<script src="/i18n.js"></script>
<script src="/admin.js"></script>
</body>
</html>

446
public/admin.js Normal file
View file

@ -0,0 +1,446 @@
/* Key register — admin panel */
"use strict";
let S = null, page = "items", q = "";
const $body = document.getElementById("body");
const $sheet = document.getElementById("sheet");
const $scrim = document.getElementById("scrim");
const $toast = document.getElementById("toast");
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
const cab = (id) => S.cabinets.find((c) => c.id === id) || { name: "—", full_name: "Unassigned" };
const door = (id) => S.doors.find((d) => d.id === +id);
let tt;
function toast(m, err) {
$toast.textContent = m; $toast.className = "toast on" + (err ? " err" : "");
clearTimeout(tt); tt = setTimeout(() => ($toast.className = "toast"), 3000);
}
async function api(url, opts) {
const r = await fetch(url, opts);
const b = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(b.error || r.statusText);
return b;
}
async function load() {
try {
S = await api("/api/state");
setLang(S.lang);
chrome();
render();
} catch (e) { $body.innerHTML = `<div class="empty">${esc(T.noServer)}<br>${esc(e.message)}</div>`; }
}
function openSheet(h) {
$sheet.innerHTML = '<div class="grab"></div>' + h;
$sheet.classList.add("on"); $scrim.classList.add("on"); $sheet.scrollTop = 0;
}
function closeSheet() { $sheet.classList.remove("on"); $scrim.classList.remove("on"); }
$scrim.onclick = closeSheet;
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeSheet(); });
const val = (id) => (document.getElementById(id) || {}).value || "";
const checked = (id) => !!(document.getElementById(id) || {}).checked;
async function post(url, data, msg) {
try {
const r = await api(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) });
closeSheet(); await load(); toast(r.note || msg || T.tSaved);
} catch (e) { toast(errMsg(e.message), true); }
}
async function del(url, msg) {
try {
const r = await api(url, { method: "DELETE" });
closeSheet(); await load(); toast(r.note || msg || T.tRemoved);
} catch (e) { toast(errMsg(e.message), true); }
}
/* ------------------------------------------------------------- keys & tags */
function pageItems() {
const term = q.trim().toLowerCase();
const list = S.items.filter((i) => !term ||
(i.tag + " " + (i.door_id ? door(i.door_id).name : i.zone) + " " + i.building + " " + i.notes).toLowerCase().includes(term));
$body.innerHTML = `
<div class="card"><h3>${T.aItems}</h3>
<p style="color:var(--slate);font-size:13px;margin:0 0 10px">
${T.aItemsHelp(S.items.length)}</p>
<input class="field" id="q" placeholder="${esc(T.aFilter)}" value="${esc(q)}" style="margin-bottom:10px">
<button class="mini" data-new="item">${T.aAddItem}</button></div>
<div class="card" style="padding:0;overflow-x:auto">
<table><thead><tr><th>${T.aTag}</th><th>${T.aDoor}</th><th>${T.aCabinet}</th><th>${T.aHook}</th><th>${T.aStatus}</th><th></th></tr></thead>
<tbody>${list.map((i) => `<tr>
<td class="mono"><b>${esc(i.tag)}</b>${i.is_master ? ' <span style="color:var(--brass);font-size:10px">M</span>' : ""}
<div style="font-size:11px;color:var(--slate-2)">${esc((S.kinds.find((k) => k.id === i.kind) || {}).name || i.kind)}</div></td>
<td>${esc(i.door_id ? door(i.door_id).name : i.zone)}<div style="color:var(--slate-2);font-size:11.5px">${esc(i.building)}</div></td>
<td>${esc(cab(i.cabinet_id).name)}</td>
<td class="mono" style="font-size:11.5px">${esc(i.hook)}</td>
<td>${esc((T.statuses && T.statuses[i.status]) || i.status)}</td>
<td style="text-align:right"><button class="mini" data-edit-item="${i.id}">${T.aEdit}</button></td>
</tr>`).join("") || `<tr><td colspan="6" style="text-align:center;color:var(--slate);padding:24px">${T.aNothing}</td></tr>`}
</tbody></table></div>`;
const el = document.getElementById("q");
el.oninput = (e) => { q = e.target.value; pageItems(); const f = document.getElementById("q"); f.focus(); f.setSelectionRange(q.length, q.length); };
}
function sheetItem(id) {
const i = id ? S.items.find((x) => x.id === +id) : { tag: "", kind: "key", door_id: "", zone: "", cabinet_id: "", hook: "", is_master: 0, uid: "", status: "in", notes: "" };
openSheet(`<h2>${id ? T.aEdit + " " + esc(i.tag) : T.aAddItem.replace("+ ", "")}</h2>
<p class="sub">${T.vKey} / ${T.vTag}</p>
<div class="grid2">
<div><span class="lab">${T.aTag}</span><input class="field" id="f_tag" value="${esc(i.tag)}" placeholder="12-B"></div>
<div><span class="lab">${T.fType}</span><select class="field" id="f_kind">
${S.kinds.map((k) => `<option value="${esc(k.id)}"${i.kind === k.id ? " selected" : ""}>${esc(k.name)}</option>`).join("")}</select></div>
</div>
<span class="lab">${T.fZoneKeys}</span>
<select class="field" id="f_door"><option value="">${T.fNoneTag}</option>
${S.doors.map((d) => `<option value="${d.id}"${i.door_id === d.id ? " selected" : ""}>${esc(d.num)} · ${esc(d.name)} (${esc(d.building)})</option>`).join("")}</select>
<span class="lab">${T.fZoneTags}</span>
<input class="field" id="f_zone" value="${esc(i.zone)}" placeholder="Nave &amp; sacristy">
<div class="grid2">
<div><span class="lab">${T.aCabinet}</span><select class="field" id="f_cab"><option value="">${T.fNone}</option>
${S.cabinets.map((c) => `<option value="${esc(c.id)}"${i.cabinet_id === c.id ? " selected" : ""}>${esc(c.full_name)}</option>`).join("")}</select></div>
<div><span class="lab">${T.aHook}</span><input class="field" id="f_hook" value="${esc(i.hook)}" placeholder="SAC·R2·05"></div>
</div>
<div class="grid2">
<div><span class="lab">${T.aStatus}</span><select class="field" id="f_status">
${["in", "out", "reserve", "lost", "retired"].map((s) => `<option value="${s}"${i.status === s ? " selected" : ""}>${esc((T.statuses && T.statuses[s]) || s)}</option>`).join("")}</select></div>
<div><span class="lab">${T.fChipUid}</span><input class="field" id="f_uid" value="${esc(i.uid)}"></div>
</div>
<label style="display:flex;gap:9px;align-items:center;margin-bottom:12px;font-size:14px">
<input type="checkbox" id="f_master"${i.is_master ? " checked" : ""}> ${T.fIsMaster}</label>
<span class="lab">${T.fPermNote}</span>
<textarea class="field" id="f_notes" style="min-height:70px">${esc(i.notes)}</textarea>
<div class="actions">
<button class="btn btn-p" id="save">${T.save}</button>
<button class="btn btn-g" data-close>Cancel</button></div>
${id ? `<button class="btn btn-d" style="width:100%;margin-bottom:16px" id="rm">${T.deleteRetire}</button>
<p class="note" style="text-align:left">${T.retireNote}</p>` : ""}`);
document.getElementById("save").onclick = () => post("/api/admin/items", {
id: id || undefined, tag: val("f_tag").trim(), kind: val("f_kind"),
door_id: val("f_door") ? +val("f_door") : null, zone: val("f_zone").trim(),
cabinet_id: val("f_cab") || null, hook: val("f_hook").trim(),
is_master: checked("f_master") ? 1 : 0, uid: val("f_uid").trim(),
status: val("f_status"), notes: val("f_notes").trim(),
});
if (id) document.getElementById("rm").onclick = () => del("/api/admin/items/" + id);
}
/* -------------------------------------------------------------------- doors */
function pageDoors() {
const groups = {};
S.doors.forEach((d) => (groups[d.building] = groups[d.building] || []).push(d));
$body.innerHTML = `
<div class="card"><h3>${T.aDoors}</h3>
<p style="color:var(--slate);font-size:13px;margin:0 0 10px">
${T.aDoorsHelp(S.doors.length)}</p>
<button class="mini" data-new="door">${T.aAddDoor}</button></div>
${Object.entries(groups).map(([b, list]) => `<div class="card" style="padding:0">
<div style="padding:11px 14px 0"><h3>${esc(b)}</h3></div>
<table><thead><tr><th>${T.aNo}</th><th>${T.aDoors}</th><th>${T.aLock}</th><th>${T.aCopies}</th><th></th></tr></thead><tbody>
${list.map((d) => `<tr><td class="mono"><b>${esc(d.num)}</b></td><td>${esc(d.name)}</td>
<td class="mono">${esc(d.lock_group)}</td>
<td>${S.items.filter((i) => i.door_id === d.id).length}</td>
<td style="text-align:right"><button class="mini" data-edit-door="${d.id}">${T.aEdit}</button></td></tr>`).join("")}
</tbody></table></div>`).join("") || `<div class="empty">${T.aNothing}</div>`}`;
}
function sheetDoor(id) {
const d = id ? door(id) : { num: "", building: "", name: "", lock_group: "", notes: "" };
const buildings = [...new Set(S.doors.map((x) => x.building))];
openSheet(`<h2>${id ? T.aEdit + " " + esc(d.num) : T.aAddDoor.replace("+ ", "")}</h2>
<div class="grid2">
<div><span class="lab">${T.fNum}</span><input class="field" id="f_num" value="${esc(d.num)}" placeholder="12"></div>
<div><span class="lab">${T.fLockGroup}</span><input class="field" id="f_group" value="${esc(d.lock_group)}" placeholder="B"></div>
</div>
<span class="lab">${T.fBuilding}</span>
<input class="field" id="f_building" value="${esc(d.building)}" list="blist" placeholder="Church">
<datalist id="blist">${buildings.map((b) => `<option value="${esc(b)}">`).join("")}</datalist>
<span class="lab">${T.fDoorName}</span>
<input class="field" id="f_name" value="${esc(d.name)}" placeholder="Sacristy">
<span class="lab">${T.fNote}</span>
<textarea class="field" id="f_notes" style="min-height:60px">${esc(d.notes)}</textarea>
<div class="actions">
<button class="btn btn-p" id="save">${T.save}</button>
<button class="btn btn-g" data-close>Cancel</button></div>
${id ? `<button class="btn btn-d" style="width:100%;margin-bottom:16px" id="rm">${T.deleteDoor}</button>` : ""}
<p class="note" style="text-align:left">${T.lockGroupNote}</p>`);
document.getElementById("save").onclick = () => post("/api/admin/doors", {
id: id || undefined, num: val("f_num").trim(), building: val("f_building").trim(),
name: val("f_name").trim(), lock_group: val("f_group").trim(), notes: val("f_notes").trim(),
});
if (id) document.getElementById("rm").onclick = () => del("/api/admin/doors/" + id);
}
/* ------------------------------------------------------------------- people */
function pagePeople() {
$body.innerHTML = `
<div class="card"><h3>${T.aPeople}</h3>
<p style="color:var(--slate);font-size:13px;margin:0 0 10px">
${T.aPeopleHelp}</p>
<button class="mini" data-new="person">${T.aAddPerson}</button></div>
<div class="card" style="padding:0;overflow-x:auto">
<table><thead><tr><th>${T.aName}</th><th>${T.aDept}</th><th>${T.aHolding}</th><th></th></tr></thead><tbody>
${S.people.map((p) => {
const held = S.items.filter((i) => i.loan && i.loan.person_id === p.id && i.status === "out").length;
return `<tr><td><b>${esc(p.name)}</b>${p.active ? "" : ` <span style="color:var(--slate-2);font-size:11px">${T.inactive}</span>`}
<div style="color:var(--slate-2);font-size:11.5px">${esc(p.role)}</div></td>
<td>${esc(p.dept)}</td><td>${held || "—"}</td>
<td style="text-align:right"><button class="mini" data-edit-person="${p.id}">${T.aEdit}</button></td></tr>`;
}).join("") || `<tr><td colspan="4" style="text-align:center;color:var(--slate);padding:24px">${T.aNothing}</td></tr>`}
</tbody></table></div>`;
}
function sheetPerson(id) {
const p = id ? S.people.find((x) => x.id === +id) : { name: "", dept: "", role: "", phone: "", email: "", active: 1 };
const held = id ? S.items.filter((i) => i.loan && i.loan.person_id === +id && i.status === "out") : [];
openSheet(`<h2>${id ? T.aEdit : T.aAddPerson.replace("+ ", "")}</h2>
<span class="lab">${T.aName}</span><input class="field" id="f_name" value="${esc(p.name)}">
<div class="grid2">
<div><span class="lab">${T.aDept}</span><input class="field" id="f_dept" value="${esc(p.dept)}" placeholder="Choir"></div>
<div><span class="lab">${T.fFunction}</span><input class="field" id="f_role" value="${esc(p.role)}" placeholder="Director"></div>
</div>
<div class="grid2">
<div><span class="lab">${T.fPhone}</span><input class="field" id="f_phone" value="${esc(p.phone)}"></div>
<div><span class="lab">${T.fEmail}</span><input class="field" id="f_email" value="${esc(p.email)}"></div>
</div>
<label style="display:flex;gap:9px;align-items:center;margin-bottom:12px;font-size:14px">
<input type="checkbox" id="f_active"${p.active ? " checked" : ""}> ${T.fActiveLabel}</label>
${held.length ? `<div class="warnbox">${esc(T.holdsWarn(held.length, held.map((i) => i.tag).join(", ")))}</div>` : ""}
<div class="actions">
<button class="btn btn-p" id="save">${T.save}</button>
<button class="btn btn-g" data-close>Cancel</button></div>
${id ? `<button class="btn btn-d" style="width:100%;margin-bottom:16px" id="rm">${T.deactivate}</button>
<p class="note" style="text-align:left">${T.peopleNote}</p>` : ""}`);
document.getElementById("save").onclick = () => post("/api/admin/people", {
id: id || undefined, name: val("f_name").trim(), dept: val("f_dept").trim(),
role: val("f_role").trim(), phone: val("f_phone").trim(), email: val("f_email").trim(),
active: checked("f_active") ? 1 : 0,
});
if (id) document.getElementById("rm").onclick = () => del("/api/admin/people/" + id);
}
/* ----------------------------------------------------------------- cabinets */
function pageCabinets() {
$body.innerHTML = `
<div class="card"><h3>${T.aCabinets}</h3>
<p style="color:var(--slate);font-size:13px;margin:0 0 10px">
${T.aCabinetsHelp}</p>
<button class="mini" data-new="cabinet">${T.aAddCabinet}</button></div>
${S.cabinets.map((c) => `<div class="card">
<div style="display:flex;justify-content:space-between;gap:12px;align-items:flex-start">
<div><h3 style="margin-bottom:4px">${esc(c.full_name)}</h3>
<div class="mono" style="font-size:11.5px;color:var(--slate-2)">${esc(c.id)}${c.off_site ? " · " + T.aOffSite : ""}</div>
<p style="color:var(--slate);font-size:13px;margin:6px 0 0">${esc(c.note)}</p>
<p style="font-size:13px;margin:6px 0 0">${T.aItemsCount(S.items.filter((i) => i.cabinet_id === c.id).length)}</p></div>
<button class="mini" data-edit-cab="${esc(c.id)}">${T.aEdit}</button></div></div>`).join("")
|| `<div class="empty">${T.aNothing}</div>`}`;
}
function sheetCabinet(id) {
const c = id ? S.cabinets.find((x) => x.id === id) : { id: "", name: "", full_name: "", note: "", off_site: 0, sort: S.cabinets.length + 1 };
openSheet(`<h2>${id ? T.aEdit : T.aAddCabinet.replace("+ ", "")}</h2>
<div class="grid2">
<div><span class="lab">${T.fShortCode}</span>
<input class="field" id="f_id" value="${esc(c.id)}" placeholder="SAC"${id ? " readonly" : ""}></div>
<div><span class="lab">${T.fShortName}</span><input class="field" id="f_name" value="${esc(c.name)}" placeholder="Sacristy"></div>
</div>
<span class="lab">${T.fFullName}</span>
<input class="field" id="f_full" value="${esc(c.full_name)}" placeholder="Sacristy cabinet">
<span class="lab">${T.fWhereIs}</span>
<textarea class="field" id="f_note" style="min-height:70px" placeholder="Sacristy, behind the door. Its own key lives in the office cabinet.">${esc(c.note)}</textarea>
<label style="display:flex;gap:9px;align-items:center;margin-bottom:6px;font-size:14px">
<input type="checkbox" id="f_off"${c.off_site ? " checked" : ""}> ${T.fOffSiteLabel}</label>
<p class="note" style="text-align:left;margin-top:0">${T.offSiteNote}</p>
<span class="lab">${T.fSort}</span>
<input class="field" id="f_sort" type="number" value="${c.sort || 0}" style="margin-bottom:4px">
<p class="note" style="text-align:left;margin:0 0 12px">${T.fSortHelp}</p>
<div class="actions">
<button class="btn btn-p" id="save">${T.save}</button>
<button class="btn btn-g" data-close>Cancel</button></div>
${id ? `<button class="btn btn-d" style="width:100%;margin-bottom:16px" id="rm">${T.deleteCabinet}</button>` : ""}`);
document.getElementById("save").onclick = () => post("/api/admin/cabinets", {
id: val("f_id").trim().toUpperCase(), name: val("f_name").trim(),
full_name: val("f_full").trim() || val("f_name").trim(), note: val("f_note").trim(),
off_site: checked("f_off") ? 1 : 0, sort: +val("f_sort") || 0,
});
if (id) document.getElementById("rm").onclick = () => del("/api/admin/cabinets/" + id);
}
/* -------------------------------------------------------------------- types */
const SHAPE_CSS = { circle: "border-radius:50%", rounded: "border-radius:5px", square: "border-radius:0" };
function pageKinds() {
$body.innerHTML = `
<div class="card"><h3>${T.aKinds}</h3>
<p style="color:var(--slate);font-size:13px;margin:0 0 10px">${T.aKindsHelp}</p>
<button class="mini" data-new="kind">${T.aAddKind}</button></div>
${S.kinds.map((k) => {
const n = S.items.filter((i) => i.kind === k.id).length;
return `<div class="card" style="display:flex;justify-content:space-between;gap:12px;align-items:center">
<div style="display:flex;align-items:center;gap:12px;min-width:0">
<span class="kind-dot" style="background:${esc(k.colour)};border-color:${esc(k.colour)};${SHAPE_CSS[k.shape] || ""}"></span>
<div style="min-width:0">
<b>${esc(k.name)}</b>
<div class="mono" style="font-size:11.5px;color:var(--slate-2)">${esc(k.id)} · ${esc(k.colour)}</div>
<div style="font-size:12.5px;color:var(--slate)">${T.kindInUse(n)}</div>
</div>
</div>
<button class="mini" data-edit-kind="${esc(k.id)}">${T.aEdit}</button>
</div>`;
}).join("")}`;
}
function sheetKind(id) {
const k = id ? S.kinds.find((x) => x.id === id)
: { id: "", name: "", shape: "circle", colour: "#2E6E60", sort: S.kinds.length + 1 };
const used = id ? S.items.filter((i) => i.kind === id).length : 0;
openSheet(`<h2>${id ? T.aEdit : T.aAddKind.replace("+ ", "")}</h2>
<div class="grid2">
<div><span class="lab">${T.fKindCode}</span>
<input class="field" id="f_id" value="${esc(k.id)}" placeholder="comando"${id ? " readonly" : ""}></div>
<div><span class="lab">${T.fKindName}</span>
<input class="field" id="f_name" value="${esc(k.name)}" placeholder="Comando de portão"></div>
</div>
<div class="grid2">
<div><span class="lab">${T.fShape}</span><select class="field" id="f_shape">
${[["circle", T.shCircle], ["rounded", T.shRounded], ["square", T.shSquare]]
.map(([v, l]) => `<option value="${v}"${k.shape === v ? " selected" : ""}>${esc(l)}</option>`).join("")}</select></div>
<div><span class="lab">${T.fColour}</span>
<input class="field" id="f_colour" type="color" value="${esc(k.colour)}" style="height:46px;padding:4px"></div>
</div>
<span class="lab">${T.fSort}</span><input class="field" id="f_sort" type="number" value="${k.sort || 0}">
<p class="note" style="text-align:left">${T.kindNote}</p>
${used ? `<div class="warnbox">${T.kindInUse(used)}</div>` : ""}
<div class="actions">
<button class="btn btn-p" id="save">${T.save}</button>
<button class="btn btn-g" data-close>${T.cancel}</button></div>
${id ? `<button class="btn btn-d" style="width:100%;margin-bottom:16px" id="rm">${T.deleteKind}</button>` : ""}`);
document.getElementById("save").onclick = () => post("/api/admin/kinds", {
id: val("f_id"), name: val("f_name").trim(), shape: val("f_shape"),
colour: val("f_colour"), sort: +val("f_sort") || 0,
});
if (id) document.getElementById("rm").onclick = () => del("/api/admin/kinds/" + id);
}
/* --------------------------------------------------------- import / backup */
let impKind = "items";
const IMP_SPEC = {
items: { url: "/api/admin/import", req: "tag, door, building",
opt: "lock_group, cabinet, hook, master, status, kind, notes",
ex: "tag,door,building,lock_group,cabinet,hook\n12-A,Sacristia,Igreja,B,SAC,SAC·R1·03\n12-B,Sacristia,Igreja,B,SEC,SEC·R2·01" },
doors: { url: "/api/admin/import/doors", req: "num, building, name",
opt: "lock_group, notes",
ex: "num,building,name,lock_group\n01,Igreja,Entrada principal,A\n02,Igreja,Sacristia,B" },
people: { url: "/api/admin/import/people", req: "name",
opt: "dept, role, phone, email, active",
ex: "name,dept,role,phone\nMaria Fonseca,Secretaria,Secretária,910000000\nJoão Ribeiro,Manutenção,Sacristão," },
cabinets: { url: "/api/admin/import/cabinets", req: "id, name",
opt: "full_name, note, off_site, sort",
ex: "id,name,full_name,note,off_site\nSAC,Sacristia,Armário da sacristia,Atrás da porta,0\nCASA,Reserva,Conjunto de reserva,Caixa selada fora,1" },
};
function pageData() {
const spec = IMP_SPEC[impKind];
$body.innerHTML = `
<div class="card"><h3>${T.impTitle}</h3>
<span class="lab">${T.impWhat}</span>
<div class="adm-nav" style="padding:0 0 12px">
${[["items", T.impItems], ["doors", T.impDoors], ["people", T.impPeople], ["cabinets", T.impCabinets]]
.map(([k, l]) => `<button data-imp="${k}" aria-pressed="${impKind === k}">${l}</button>`).join("")}
</div>
<p style="color:var(--slate);font-size:13px">
<b>${esc(spec.req)}</b>${spec.opt ? " · " + esc(spec.opt) : ""}</p>
<textarea class="field" id="csv" style="min-height:150px;font-family:'IBM Plex Mono',monospace;font-size:12.5px"
placeholder="${esc(spec.ex)}"></textarea>
<button class="mini" id="imp">${T.impBtn}</button>
<div id="impres" style="margin-top:10px"></div></div>
<div class="card"><h3>${T.expTitle}</h3>
<p style="color:var(--slate);font-size:13px">${T.expHelp}</p>
<a class="mini" style="display:inline-block;text-decoration:none" href="/api/admin/export.csv">${T.expBtn}</a>
<a class="mini" style="display:inline-block;text-decoration:none;margin-left:6px;background:var(--card);color:var(--ink);border:1px solid var(--line)" href="/report">${T.reports}</a></div>
<div class="card"><h3>${T.bkTitle}</h3>
<p style="color:var(--slate);font-size:13px">${T.bkHelp}</p>
<a class="mini" style="display:inline-block;text-decoration:none" href="/api/admin/backup.db">${T.bkBtn}</a></div>
<div class="card"><h3 style="color:var(--late)">${T.wipeTitle}</h3>
<div class="warnbox">${T.wipeWarn}</div>
<input class="field" id="wipe" placeholder="${esc(T.wipePlaceholder)}">
<button class="btn btn-d" id="wipebtn" style="width:100%">${T.wipeBtn}</button></div>`;
document.getElementById("imp").onclick = async () => {
const csv = document.getElementById("csv").value;
if (!csv.trim()) return toast(T.impNothing, true);
try {
const r = await api(spec.url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ csv }) });
/* the combined importer reports items/doors/cabinets, the single-table
ones report created/updated — show whichever came back */
const body = r.items !== undefined
? T.impResult(r.items, r.doors, r.cabinets)
: T.impResult2(r.created, r.updated);
document.getElementById("impres").innerHTML =
`<div class="status-line in">${body}${
r.skipped && r.skipped.length
? T.impSkipped(r.skipped.length, esc(r.skipped.slice(0, 12).join(", ")) + (r.skipped.length > 12 ? "…" : ""))
: ""}</div>`;
await load(); toast(T.imported);
} catch (e) { toast(errMsg(e.message), true); }
};
document.getElementById("wipebtn").onclick = async () => {
try {
await api("/api/admin/wipe", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirm: document.getElementById("wipe").value }) });
await load(); toast(T.wipeDone);
} catch (e) { toast(errMsg(e.message), true); }
};
}
/* -------------------------------------------------------------------- audit */
async function pageAudit() {
let rows = [];
try { rows = await api("/api/admin/audit"); } catch (e) { /* non-fatal */ }
$body.innerHTML = `<div class="card"><h3>${T.auditTitle}</h3>
<p style="color:var(--slate);font-size:13px;margin:0">${T.auditHelp}</p></div>
<div class="card" style="padding:0"><table><thead><tr><th>${T.aWhen}</th><th>${T.aWhat}</th><th>${T.aDetail}</th></tr></thead><tbody>
${rows.map((r) => `<tr><td class="mono" style="font-size:11.5px">${esc(r.ts.slice(0, 16).replace("T", " "))}</td>
<td>${esc(r.action)}</td><td style="color:var(--slate)">${esc(r.detail)}</td></tr>`).join("")
|| `<tr><td colspan="3" style="text-align:center;color:var(--slate);padding:24px">${T.aNothing}</td></tr>`}
</tbody></table></div>`;
}
/* ------------------------------------------------------------------ events */
document.addEventListener("click", (e) => {
const t = e.target.closest("[data-p],[data-imp],[data-new],[data-edit-item],[data-edit-door],[data-edit-person],[data-edit-cab],[data-edit-kind],[data-close]");
if (!t) return;
if (t.dataset.close !== undefined) return closeSheet();
if (t.dataset.imp) { impKind = t.dataset.imp; return pageData(); }
if (t.dataset.p) {
page = t.dataset.p; q = "";
document.querySelectorAll("#nav button").forEach((b) => b.setAttribute("aria-pressed", b.dataset.p === page));
return render();
}
if (t.dataset.new === "item") return sheetItem(null);
if (t.dataset.new === "door") return sheetDoor(null);
if (t.dataset.new === "person") return sheetPerson(null);
if (t.dataset.new === "cabinet") return sheetCabinet(null);
if (t.dataset.new === "kind") return sheetKind(null);
if (t.dataset.editItem) return sheetItem(t.dataset.editItem);
if (t.dataset.editDoor) return sheetDoor(t.dataset.editDoor);
if (t.dataset.editPerson) return sheetPerson(t.dataset.editPerson);
if (t.dataset.editCab) return sheetCabinet(t.dataset.editCab);
if (t.dataset.editKind) return sheetKind(t.dataset.editKind);
});
function chrome() {
document.getElementById("brand").textContent = T.brand;
document.getElementById("apptitle").textContent = T.admin;
document.getElementById("backlink").title = T.back;
document.querySelectorAll("[data-t]").forEach((el) => (el.textContent = T[el.dataset.t]));
document.title = T.brand + " — " + T.admin;
}
function render() {
({ items: pageItems, doors: pageDoors, people: pagePeople, cabinets: pageCabinets, kinds: pageKinds, data: pageData, audit: pageAudit })[page]();
}
load();

594
public/app.js Normal file
View file

@ -0,0 +1,594 @@
/* Key register — front end. Talks to the API, holds no truth of its own. */
"use strict";
let S = null; // server state
let tab = "board", cabinet = "ALL", filter = "all", query = "", pending = null;
const $scroll = document.getElementById("scroll");
const $sheet = document.getElementById("sheet");
const $scrim = document.getElementById("scrim");
const $toast = document.getElementById("toast");
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
const fmt = (d) => { if (!d) return "—"; const [y, m, dd] = d.split("-"); return `${dd} ${T._months[+m - 1]} ${y}`; };
const short = (d) => { if (!d) return ""; const [y, m, dd] = d.split("-"); return `${dd}/${m}/${y.slice(2)}`; };
const initials = (n) => n.split(" ").filter((w) => w.length > 2).slice(0, 2).map((w) => w[0]).join("") || n[0];
const days = (a, b) => Math.round((new Date(b) - new Date(a)) / 864e5);
const item = (id) => S.items.find((i) => i.id === +id);
const person = (id) => S.people.find((p) => p.id === +id);
const door = (id) => S.doors.find((d) => d.id === +id);
const cab = (id) => S.cabinets.find((c) => c.id === id) || { id: "", name: "—", full_name: "Unassigned", note: "" };
const copiesOf = (did) => S.items.filter((i) => i.door_id === +did);
const kindOf = (id) => (S.kinds || []).find((k) => k.id === id)
|| { id, name: id, shape: "circle", colour: "#2E6E60" };
/* one place that decides how any item is drawn */
const kindClass = (i) => `shape-${kindOf(i.kind).shape}`;
const kindStyle = (i) => `--k:${kindOf(i.kind).colour}`;
const isLate = (i) => i.loan && i.loan.due_date && i.loan.due_date < S.today;
/* Hook order, so the board reads in the same order as the physical hooks and
you can count along during an audit. Numeric-aware, so R2 sorts before R10.
Falls back to the tag when hooks are not filled in. */
const byHook = (a, b) =>
(a.hook || "").localeCompare(b.hook || "", undefined, { numeric: true }) ||
a.tag.localeCompare(b.tag, undefined, { numeric: true });
let toastTimer;
function toast(msg, err) {
$toast.textContent = msg;
$toast.className = "toast on" + (err ? " err" : "");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => ($toast.className = "toast"), 2600);
}
async function api(url, opts) {
const r = await fetch(url, opts);
const body = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(body.error || r.statusText);
return body;
}
async function load() {
try {
S = await api("/api/state");
setLang(S.lang);
chrome();
counts();
render();
} catch (e) {
$scroll.innerHTML = `<div class="pane"><div class="empty">${esc(T.noServer)}<br>${esc(e.message)}</div></div>`;
}
}
/* ------------------------------------------------------------------ header */
function counts() {
const live = S.items.filter((i) => i.status !== "reserve" && i.status !== "retired");
const reserve = S.items.filter((i) => i.status === "reserve").length;
const late = S.items.filter(isLate).length;
const out = S.items.filter((i) => i.status === "out").length;
document.getElementById("counts").innerHTML = `
<span class="count">${T.cabinetsN(S.cabinets.length)}</span>
<span class="count"><span class="swatch in"></span>${T.keysIn(live.filter((i) => i.status === "in").length, live.length)}</span>
<span class="count"><span class="swatch out"></span><b>${out}</b> ${T.issuedN}</span>
${late ? `<span class="count warn"><b>${late}</b> ${T.overdueN}</span>` : ""}
${reserve ? `<span class="count"><span class="swatch res"></span>${reserve} ${T.reserveN}</span>` : ""}`;
}
/* ------------------------------------------------------------------- board */
function peg(i) {
const late = isLate(i);
return `<button class="peg ${kindClass(i)} st-${i.status}${late ? " late" : ""}"
style="${kindStyle(i)}" data-item="${i.id}"
aria-label="${esc(i.tag)}, ${esc(i.door_id ? door(i.door_id).name : i.zone)}, ${esc(kindOf(i.kind).name)}, ${late ? "overdue" : i.status}">
${esc(i.tag)}${i.is_master ? "<small>MASTER</small>" : ""}
${late ? '<span class="flag"></span>' : ""}${i.notes ? '<span class="memo">✎</span>' : ""}</button>`;
}
function viewBoard() {
const pass = (i) =>
filter === "all" ? i.status !== "retired" :
filter === "out" ? i.status === "out" :
filter === "late" ? isLate(i) :
filter.startsWith("k:") ? i.kind === filter.slice(2) && i.status !== "retired" :
true;
const show = S.items.filter((i) => pass(i) && (cabinet === "ALL" || i.cabinet_id === cabinet));
const tiles = [{ id: "ALL", name: T.allTile }].concat(S.cabinets).map((c) => {
const list = c.id === "ALL" ? S.items : S.items.filter((i) => i.cabinet_id === c.id);
return `<button class="cab" data-cab="${esc(c.id)}" aria-pressed="${cabinet === c.id}">
<span class="cn">${esc(c.name)}</span>
<span class="cs">${T.aItemsCount(list.length)} · <span class="o">${T.outCount(list.filter((i) => i.status === "out").length)}</span></span></button>`;
}).join("");
/* Section order comes from the cabinets themselves, not from the order keys
happen to appear in. Grouping by insertion order used to put a cabinet
wherever its lowest-numbered key fell, ignoring its sort value entirely. */
let sections;
if (cabinet === "ALL") {
sections = S.cabinets
.map((c) => [c.full_name, show.filter((i) => i.cabinet_id === c.id).sort(byHook)])
.filter(([, list]) => list.length);
const orphans = show.filter((i) => !S.cabinets.some((c) => c.id === i.cabinet_id));
if (orphans.length) sections.push(["—", orphans.sort(byHook)]);
} else {
/* Inside one cabinet, group by building in the order buildings appear in
the door list, so it is stable rather than dependent on key numbering. */
const order = [];
S.doors.forEach((d) => { if (!order.includes(d.building)) order.push(d.building); });
show.forEach((i) => { if (i.building && !order.includes(i.building)) order.push(i.building); });
sections = order
.map((b) => [b, show.filter((i) => i.building === b).sort(byHook)])
.filter(([, list]) => list.length);
}
const rails = sections.map(([label, list]) => `<div class="rail">
<div class="rail-label">${esc(label)} <span>${T.outCount(list.filter((i) => i.status === "out").length)} · ${list.length}</span></div>
<div class="pegs">${list.map(peg).join("")}</div></div>`).join("")
|| `<div class="empty" style="background:transparent;border-color:#3A4C46;color:#8A968F">
${S.items.length ? T.noMatch : T.noKeysYet + " <a href='/admin' style='color:#9FADA7'>" + T.admin + "</a>."}</div>`;
$scroll.innerHTML = `<div class="board">
<div class="cabs">${tiles}</div>
${cabinet !== "ALL" && cab(cabinet).note ? `<p class="where">${esc(cab(cabinet).note)}</p>` : ""}
<div class="filters">${[["all", T.fEverything]]
.concat((S.kinds || []).map((k) => ["k:" + k.id, k.name]))
.concat([["out", T.fOut], ["late", T.fLate]])
.map(([k, l]) => `<button class="chip" data-filter="${esc(k)}" aria-pressed="${filter === k}">${esc(l)}</button>`).join("")}</div>
${rails}
<div class="legend">
<span><i class="lg-in"></i>${T.lgIn}</span><span><i class="lg-out"></i>${T.lgOut}</span>
<span><i class="lg-res"></i>${T.lgRes}</span><span><i class="lg-late"></i>${T.lgLate}</span>
${(S.kinds || []).map((k) => `<span><i class="lg-kind" style="background:${esc(k.colour)};border-color:${esc(k.colour)};${
k.shape === "circle" ? "border-radius:50%" : k.shape === "square" ? "border-radius:0" : ""}"></i>${esc(k.name)}</span>`).join("")}
</div>
<p class="note">${T.boardNote}</p></div>`;
}
/* ------------------------------------------------------------------- rows */
function itemRow(i) {
const late = isLate(i), l = i.loan;
const name = i.door_id ? door(i.door_id).name : i.zone;
return `<button class="row${late ? " late" : ""}" data-item="${i.id}">
<span class="badge ${kindClass(i)} st-${i.status}" style="${kindStyle(i)}">${esc(i.tag)}</span>
<span class="row-main"><span class="row-t">${esc(name)}</span>
<span class="row-s">${l && i.status === "out"
? `<span class="${late ? "od" : "held"}">${esc(person(l.person_id) ? person(l.person_id).name : "?")}</span>${late ? ` · ${days(l.due_date, S.today)}d` : ""}`
: i.status === "reserve" ? T.reserveCopy : i.status === "lost" ? T.lost
: i.status === "retired" ? T.retired : T.onHook}</span></span>
<span class="row-n">${esc(cab(i.cabinet_id).name)}</span></button>`;
}
function doorRow(d) {
const c = copiesOf(d.id).filter((i) => i.status !== "retired");
const out = c.filter((i) => i.status === "out");
const late = c.filter(isLate).length;
const who = out.map((i) => { const p = person(i.loan.person_id); return p ? p.name.split(" ")[0] : "?"; });
return `<button class="row${late ? " late" : ""}" data-door="${d.id}">
<span class="badge door">${esc(d.num)}</span>
<span class="row-main"><span class="row-t">${esc(d.name)}</span>
<span class="row-s">${esc(d.building)}${who.length ? ` · <span class="${late ? "od" : "held"}">${esc(who.join(", "))}</span>` : " · " + T.allIn}</span></span>
<span class="row-n">${T.copies(c.length)}
<span class="dots">${c.map((i) => `<span class="dot ${isLate(i) ? "out" : i.status}" style="${kindStyle(i)}"></span>`).join("")}</span></span></button>`;
}
/* ------------------------------------------------------------------- doors */
function viewDoors() {
const q = query.trim().toLowerCase();
const hay = (d) => {
const c = copiesOf(d.id);
const who = c.map((i) => { if (!i.loan) return ""; const p = person(i.loan.person_id); return p ? p.name + " " + p.dept : ""; }).join(" ");
return (d.num + " " + d.name + " " + d.building + " " + d.lock_group + " " + who + " " +
c.map((i) => i.tag + " " + i.notes).join(" ")).toLowerCase();
};
const match = S.doors.filter((d) => !q || hay(d).includes(q));
/* items not attached to a door — alarm tags, remotes, anything zone-based */
const tags = S.items.filter((i) => !i.door_id &&
(!q || (i.tag + " " + i.zone + " " + i.building + " " + kindOf(i.kind).name).toLowerCase().includes(q)));
const groups = {};
match.forEach((d) => (groups[d.building] = groups[d.building] || []).push(d));
$scroll.innerHTML = `<div class="pane">
<input class="search" id="q" placeholder="${esc(T.searchPlaceholder)}" value="${esc(query)}">
${match.length || tags.length
? Object.entries(groups).map(([b, list]) =>
`<div class="sec">${esc(b)} · ${T.doorsN(list.length)}</div>${list.map(doorRow).join("")}`).join("")
+ (tags.length ? `<div class="sec">${T.tagsSection} · ${tags.length}</div>${tags.map(itemRow).join("")}` : "")
: `<div class="empty">${esc(T.nothingFound(query))}</div>`}
<p class="note">${T.doorsNote}</p></div>`;
const el = document.getElementById("q");
el.oninput = (e) => {
query = e.target.value; viewDoors();
const f = document.getElementById("q"); f.focus(); f.setSelectionRange(query.length, query.length);
};
}
/* ------------------------------------------------------------------ people */
function viewPeople() {
const rows = S.people.map((p) => {
const held = S.items.filter((i) => i.loan && i.loan.person_id === p.id && i.status === "out");
return { p, held, late: held.filter(isLate).length };
}).sort((a, b) => b.held.length - a.held.length);
const row = (r) => `<button class="row${r.late ? " late" : ""}" data-person="${r.p.id}">
<span class="badge person">${esc(initials(r.p.name))}</span>
<span class="row-main"><span class="row-t">${esc(r.p.name)}${r.p.active ? "" : ` (${T.inactive})`}</span>
<span class="row-s">${esc(r.p.dept)}${r.p.role ? " · " + esc(r.p.role) : ""}${r.late ? ` · <span class="od">${r.late} ${T.overdueN}</span>` : ""}</span></span>
<span class="row-n">${r.held.length ? T.itemsN(r.held.length) : "—"}</span></button>`;
const holding = rows.filter((r) => r.held.length), free = rows.filter((r) => !r.held.length);
$scroll.innerHTML = `<div class="pane">
${holding.length ? `<div class="sec">${T.holdingNow}</div>${holding.map(row).join("")}` : ""}
${free.length ? `<div class="sec">${T.nothingOut}</div>${free.map(row).join("")}` : ""}
${rows.length ? "" : `<div class="empty">${T.noPeopleYet} <a href="/admin">${T.admin}</a>.</div>`}</div>`;
}
/* --------------------------------------------------------------------- log */
function viewLog() {
const ev = [];
S.handovers.forEach((h) => {
ev.push({ d: h.out_date, dir: "out", h });
if (h.in_date) ev.push({ d: h.in_date, dir: "in", h });
});
ev.sort((a, b) => b.d.localeCompare(a.d));
$scroll.innerHTML = `<div class="pane"><div class="sec">${T.logTitle}</div>
${ev.length ? `<div class="log">${ev.map((e) => {
const it = item(e.h.item_id), p = person(e.h.person_id);
return `<div class="log-row">
<span class="log-d">${short(e.d)}</span>
<span class="log-b"><span class="log-tag">${esc(it ? it.tag : "?")}</span>
${e.dir === "out" ? T.toWord : T.backFrom} ${esc(p ? p.name : "?")}
${e.h.has_sig ? ` <span style="color:var(--verdigris)">✓ ${T.signedMark}</span>` : ""}
${e.h.note ? `<div class="memo-line">${esc(e.h.note)}</div>` : ""}</span>
<span class="dir ${e.dir}">${e.dir === "out" ? T.dirOut : T.dirIn}</span></div>`;
}).join("")}</div>` : `<div class="empty">${T.noHandovers}</div>`}
<p class="note">${T.logNote}</p></div>`;
}
/* ----------------------------------------------------------------- journal */
let requests = [];
function requestCard(r) {
const done = r.status === "handled";
return `<div class="req-card${done ? " done" : ""}">
<h4>${esc(r.name)}</h4>
<div class="meta">${esc(r.dept || "")}${r.dept && r.contact ? " · " : ""}${esc(r.contact || "")}</div>
<div class="meta">${esc(done ? T.reqHandledOn(short(r.handled)) : T.reqOn(short(r.created.slice(0, 10))))}</div>
${r.areas ? `<p class="areas-line">${esc(r.areas)}</p>` : ""}
${r.detail ? `<p class="detail">${esc(r.detail)}</p>` : ""}
${r.note ? `<p class="detail" style="font-style:italic">${esc(r.note)}</p>` : ""}
${done ? "" : `<div style="margin-top:10px">
<input class="search" id="rn${r.id}" placeholder="${esc(T.reqNotePh)}" style="margin-bottom:7px">
<button class="mini" data-handle="${r.id}">${T.reqMarkDone}</button></div>`}
</div>`;
}
async function viewJournal() {
try { requests = await api("/api/requests"); } catch (e) { requests = []; }
const pending = requests.filter((r) => r.status === "pending");
const done = requests.filter((r) => r.status === "handled").slice(0, 8);
/* State of the public form, read from the server rather than from memory —
a form that is public with no PIN is the one thing worth noticing. */
const rf = S.reqForm || { public: false, pin: false };
const status = !rf.public
? `<div class="status-line in" style="font-size:12.5px">${T.reqFormPrivate}</div>`
: rf.pin
? `<div class="status-line in" style="font-size:12.5px">${T.reqFormPublic}
· <a href="/pedido" style="color:inherit">${T.reqFormOpenIt}</a></div>`
: `<div class="status-line late" style="font-size:12.5px"><b>${T.reqFormOpen}</b>
· <a href="/pedido" style="color:inherit">${T.reqFormOpenIt}</a></div>`;
$scroll.innerHTML = `<div class="pane">
<div class="sec">${T.reqInbox}${pending.length ? `<span class="badge-count">${pending.length}</span>` : ""}</div>
${status}
${pending.length ? pending.map(requestCard).join("")
: `<div class="empty">${T.reqNone}</div>`}
${done.length ? `<div class="sec">${T.reqHandled}</div>${done.map(requestCard).join("")}` : ""}
<div class="sec">${T.newEntry}</div>
<div class="note-box">
<textarea id="jt" placeholder="${esc(T.journalPlaceholder)}"></textarea>
<div style="text-align:right;margin-top:8px"><button class="mini" id="jadd">${T.addJournal}</button></div></div>
<div class="sec">${T.journalTitle}</div>
${S.journal.length ? S.journal.map((j) => `<div class="row" style="align-items:flex-start;cursor:default">
<span class="row-n" style="width:52px;text-align:left;padding-top:2px">${short(j.date)}</span>
<span class="row-main" style="white-space:normal">
<span style="font-size:14px;line-height:1.5">${esc(j.text)}</span>
${j.author ? `<span class="row-s">${esc(j.author)}</span>` : ""}</span></div>`).join("")
: `<div class="empty">${T.journalEmpty}</div>`}
<p class="note">${T.journalNote}</p></div>`;
document.getElementById("jadd").onclick = async () => {
const t = document.getElementById("jt").value.trim();
if (!t) return;
try {
await api("/api/journal", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: t }) });
await load(); toast(T.tJournalAdded);
} catch (e) { toast(errMsg(e.message), true); }
};
}
/* ------------------------------------------------------------------ sheets */
function openSheet(html) {
$sheet.innerHTML = '<div class="grab"></div>' + html;
$sheet.classList.add("on"); $scrim.classList.add("on"); $sheet.scrollTop = 0;
}
function closeSheet() { $sheet.classList.remove("on"); $scrim.classList.remove("on"); pending = null; }
$scrim.onclick = closeSheet;
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeSheet(); });
function sheetDoor(did) {
const d = door(did), c = copiesOf(did).filter((i) => i.status !== "retired");
const out = c.filter((i) => i.status === "out");
const sameLock = d.lock_group ? S.doors.filter((x) => x.lock_group === d.lock_group && x.id !== d.id) : [];
openSheet(`<h2>${esc(d.num)} · ${esc(d.name)}</h2><p class="sub">${esc(d.building)}</p>
<div class="status-line ${out.length ? "out" : "in"}">
${T.doorSummary(c.length, out.length, c.filter((i) => i.status === "in").length,
c.filter((i) => i.status === "reserve").length, c.filter((i) => i.status === "lost").length)}</div>
<dl class="facts">
<div class="fact"><dt>${T.fLockGroup}</dt><dd class="mono">${esc(d.lock_group || "—")}</dd></div>
<div class="fact"><dt>${T.fCopiesLiveIn}</dt><dd>${esc([...new Set(c.map((i) => cab(i.cabinet_id).name))].join(", ") || "—")}</dd></div></dl>
${d.notes ? `<div class="note-box"><span class="cap">${T.noteOnDoor}</span><div style="font-size:14px">${esc(d.notes)}</div></div>` : ""}
<div class="sec">${T.everyCopy}</div>${c.map(itemRow).join("")}
${sameLock.length ? `<div class="sec">${T.sameLock}</div>${sameLock.map(doorRow).join("")}` : ""}
<div class="actions" style="margin-top:16px"><button class="btn btn-g" data-close>${T.close}</button></div>`);
}
async function sheetItem(id) {
const i = item(id);
if (!i) return;
const l = i.loan, late = isLate(i), c = cab(i.cabinet_id);
const d = i.door_id ? door(i.door_id) : null;
let hist = [];
try { hist = await api(`/api/items/${i.id}/history`); } catch (e) { /* non-fatal */ }
const status = late
? `<div class="status-line late">${T.withSince(esc(person(l.person_id).name), fmt(l.out_date))}${T.dueWas(fmt(l.due_date), days(l.due_date, S.today))}</div>`
: i.status === "out"
? `<div class="status-line out">${T.issuedToOn(esc(person(l.person_id) ? person(l.person_id).name : "?"), fmt(l.out_date))}${
l.due_date ? T.dueBack(fmt(l.due_date)) : T.noDueDate}</div>`
: i.status === "reserve"
? `<div class="status-line reserve">${T.reserveLine}</div>`
: i.status === "lost" ? `<div class="status-line lost">${T.lostLine}</div>`
: i.status === "retired" ? `<div class="status-line retired">${T.retiredLine}</div>`
: `<div class="status-line in">${T.inCabinet(`<b>${esc(c.full_name)}</b>`)}${i.hook ? T.atHook(esc(i.hook)) : ""}</div>`;
openSheet(`<h2>${esc(i.tag)}</h2>
<p class="sub">${esc(d ? d.name : i.zone)}${i.building ? " · " + esc(i.building) : ""}</p>${status}
${l && l.note && i.status === "out" ? `<div class="note-box"><span class="cap">${T.noteOnHandover}</span>
<div style="font-size:14px">${esc(l.note)}</div></div>` : ""}
<dl class="facts">
<div class="fact"><dt>${T.fType}</dt><dd>${esc(kindOf(i.kind).name)}</dd></div>
<div class="fact"><dt>${T.fKeptIn}</dt><dd>${esc(c.full_name)}${c.note ? `<span class="tiny">${esc(c.note)}</span>` : ""}</dd></div>
${i.hook ? `<div class="fact"><dt>${T.fHook}</dt><dd class="mono">${esc(i.hook)}</dd></div>` : ""}
${d ? `<div class="fact"><dt>${T.fLockGroup}</dt><dd class="mono">${esc(d.lock_group || "—")}</dd></div>` : ""}
${i.uid ? `<div class="fact"><dt>${T.fChipUid}</dt><dd class="mono">${esc(i.uid)}</dd></div>` : ""}
${i.is_master ? `<div class="fact"><dt>${T.fMaster}</dt><dd>${T.yes}</dd></div>` : ""}</dl>
${d ? `<button class="row" data-door="${d.id}" style="margin-bottom:14px">
<span class="badge door">${esc(d.num)}</span>
<span class="row-main"><span class="row-t">${T.seeAllCopies(copiesOf(d.id).length)}</span>
<span class="row-s">${esc(d.name)}</span></span></button>` : ""}
<div class="note-box"><span class="cap">${T.noteOnKey}</span>
<textarea id="inote" placeholder="${esc(T.notePlaceholder)}">${esc(i.notes)}</textarea>
<div style="text-align:right;margin-top:6px"><button class="mini" data-savenote="${i.id}">${T.saveNote}</button></div></div>
<div class="actions">
${i.status === "in" || i.status === "reserve"
? `<button class="btn btn-o" data-issue="${i.id}">${i.status === "reserve" ? T.breakSeal : T.issueTo}</button>`
: i.status === "out" ? `<button class="btn btn-p" data-return="${i.id}">${T.takeBack}</button>` : ""}
<button class="btn btn-g" data-close>${T.close}</button></div>
<div class="sec">${T.history}</div>
${hist.length ? `<div class="log">${hist.map((h) => {
const p = person(h.person_id);
const hLate = !h.in_date && h.due_date && h.due_date < S.today;
return `<div class="log-row"><span class="log-d">${short(h.out_date)}</span>
<span class="log-b">${esc(p ? p.name : "?")}
<div style="color:var(--slate);font-size:12px">${h.in_date ? T.returnedOn(fmt(h.in_date)) : T.stillHolding}</div>
${h.note ? `<div class="memo-line">${esc(h.note)}</div>` : ""}
${h.signature ? `<div class="sigshot"><img src="${esc(h.signature)}" alt="Signature"></div>` : ""}</span>
<span class="dir ${h.in_date ? "in" : hLate ? "late" : "out"}">${h.in_date ? T.stClosed : hLate ? T.stLate : T.stOpen}</span></div>`;
}).join("")}</div>` : `<div class="empty">${T.neverIssued}</div>`}
<div style="height:8px"></div>`);
}
function sheetPerson(id) {
const p = person(id);
const held = S.items.filter((i) => i.loan && i.loan.person_id === p.id && i.status === "out");
const past = S.handovers.filter((h) => h.person_id === p.id && h.in_date);
openSheet(`<h2>${esc(p.name)}</h2><p class="sub">${esc(p.dept)}${p.role ? " · " + esc(p.role) : ""}</p>
<dl class="facts">
<div class="fact"><dt>${T.fHoldingNow}</dt><dd>${T.itemsN(held.length)}${
held.length ? `<span class="tiny">${esc(T.fromCabinets([...new Set(held.map((i) => cab(i.cabinet_id).name))].join(", ")))}</span>` : ""}</dd></div>
<div class="fact"><dt>${T.fReturnedPast}</dt><dd>${past.length}</dd></div>
${p.phone ? `<div class="fact"><dt>${T.fPhone}</dt><dd class="mono">${esc(p.phone)}</dd></div>` : ""}</dl>
${held.length ? `<div class="sec">${T.currentlyHolds}</div>${held.map(itemRow).join("")}
<a class="btn btn-g" style="display:block;text-decoration:none;margin:10px 0 4px"
href="/report?view=person&id=${p.id}">${T.printPdf} — ${T.repReceiptTitle}</a>`
: `<div class="empty">${T.holdsNothing}</div>`}
${past.length ? `<div class="sec">${T.returnedSection}</div><div class="log">${past.map((h) => {
const it = item(h.item_id);
return `<div class="log-row"><span class="log-d">${short(h.in_date)}</span>
<span class="log-b"><span class="log-tag">${esc(it ? it.tag : "?")}</span>
<div style="color:var(--slate);font-size:12px">${T.heldFromTo(fmt(h.out_date), fmt(h.in_date))}</div></span></div>`;
}).join("")}</div>` : ""}
<div class="actions" style="margin-top:16px"><button class="btn btn-g" data-close>${T.close}</button></div>`);
}
let pickQuery = "";
function personPickList(itemId) {
const q = pickQuery.trim().toLowerCase();
const active = S.people.filter((p) => p.active &&
(!q || (p.name + " " + p.dept + " " + p.role).toLowerCase().includes(q)));
if (!active.length) {
return `<div class="empty">${q ? esc(T.nothingFound(pickQuery))
: T.noActivePeople + ` <a href="/admin">${T.admin}</a>.`}</div>`;
}
return active.map((p) => {
const holds = S.items.filter((i) => i.loan && i.loan.person_id === p.id && i.status === "out").length;
return `<button class="row" data-give="${p.id}" data-on="${itemId}">
<span class="badge person">${esc(initials(p.name))}</span>
<span class="row-main"><span class="row-t">${esc(p.name)}</span>
<span class="row-s">${esc(p.dept)}${p.role ? " · " + esc(p.role) : ""}</span></span>
<span class="row-n">${holds ? T.itemsN(holds) : ""}</span></button>`;
}).join("");
}
function bindPicker(itemId) {
const el = document.getElementById("pq");
if (!el) return;
el.oninput = (e) => {
pickQuery = e.target.value;
document.getElementById("plist").innerHTML = personPickList(itemId);
};
}
function sheetIssue(id) {
const i = item(id);
pickQuery = "";
openSheet(`<h2>${esc(T.issueTitle(i.tag))}</h2>
<p class="sub">${esc(i.door_id ? door(i.door_id).name : i.zone)} · ${esc(T.fromThe(cab(i.cabinet_id).full_name))}</p>
${i.status === "reserve" ? `<div class="status-line reserve">${T.reserveWarn}</div>` : ""}
<div class="sec">${T.whoTakes}</div>
<input class="search" id="pq" placeholder="${esc(T.searchPerson)}" autocomplete="off">
<div id="plist">${personPickList(i.id)}</div>
<div class="actions" style="margin-top:14px"><button class="btn btn-g" data-close>${T.cancel}</button></div>`);
bindPicker(i.id);
}
const addDays = (d, k) => { const t = new Date(d); t.setDate(t.getDate() + k); return t.toISOString().slice(0, 10); };
function sheetConfirm() {
const i = item(pending.item), p = person(pending.person);
openSheet(`<h2>${T.handover}</h2>
<p class="sub">${esc(i.tag)} · ${esc(i.door_id ? door(i.door_id).name : i.zone)} → ${esc(p.name)}</p>
<div class="sec">${T.backBy}</div>
<div class="due-opts">${[["none", T.dNone], ["7", T.d7], ["30", T.d30], ["90", T.d90]]
.map(([k, l]) => `<button class="due" data-due="${k}" aria-pressed="${pending.dueKey === k}">${l}</button>`).join("")}</div>
<div class="note-box"><span class="cap">${T.whyNote}</span>
<textarea id="hnote" placeholder="${esc(T.whyPlaceholder)}">${esc(pending.note)}</textarea></div>
<div class="sec">${T.signature}</div>
<div class="sigpad"><canvas id="sig"></canvas><div class="sigline"></div>
${pending.sig ? "" : `<div class="hint" id="sighint">${T.signHint}</div>`}</div>
<div style="text-align:right;margin-bottom:14px">
<button class="mini" id="sigclear" style="background:var(--card);color:var(--slate);border:1px solid var(--line)">${T.clearSig}</button></div>
<div class="actions">
<button class="btn btn-o" id="confirm">${T.confirmHandover}</button>
<button class="btn btn-g" data-close>${T.cancel}</button></div>`);
const c = document.getElementById("sig"), ctx = c.getContext("2d"), dpr = window.devicePixelRatio || 1;
requestAnimationFrame(() => {
c.width = c.offsetWidth * dpr; c.height = c.offsetHeight * dpr; ctx.scale(dpr, dpr);
ctx.lineWidth = 2.2; ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.strokeStyle = "#131E1B";
if (pending && pending.sig) {
const im = new Image();
im.onload = () => ctx.drawImage(im, 0, 0, c.offsetWidth, c.offsetHeight);
im.src = pending.sig;
}
});
let drawing = false;
const pos = (e) => { const r = c.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top]; };
c.addEventListener("pointerdown", (e) => {
drawing = true; c.setPointerCapture(e.pointerId);
const h = document.getElementById("sighint"); if (h) h.remove();
const [x, y] = pos(e); ctx.beginPath(); ctx.moveTo(x, y);
});
c.addEventListener("pointermove", (e) => {
if (!drawing) return;
const [x, y] = pos(e); ctx.lineTo(x, y); ctx.stroke();
pending.sig = c.toDataURL("image/png");
});
c.addEventListener("pointerup", () => { drawing = false; });
document.getElementById("sigclear").onclick = () => { ctx.clearRect(0, 0, c.width, c.height); pending.sig = null; };
document.getElementById("confirm").onclick = async (ev) => {
ev.target.disabled = true;
const note = document.getElementById("hnote").value.trim();
const due = pending.dueKey === "none" ? null : addDays(S.today, +pending.dueKey);
const id = pending.item;
try {
await api(`/api/items/${id}/issue`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ person_id: pending.person, due_date: due, note, signature: pending.sig }),
});
pending = null;
await load();
toast(T.tHandedOver);
sheetItem(id);
} catch (e) { toast(errMsg(e.message), true); ev.target.disabled = false; }
};
}
/* ------------------------------------------------------------------ events */
document.addEventListener("click", async (e) => {
const t = e.target.closest("[data-handle],[data-item],[data-door],[data-person],[data-filter],[data-tab],[data-cab],[data-issue],[data-return],[data-give],[data-close],[data-savenote],[data-due]");
if (!t) return;
if (t.dataset.close !== undefined) return closeSheet();
if (t.dataset.savenote) {
const id = +t.dataset.savenote, notes = document.getElementById("inote").value.trim();
try {
await api(`/api/items/${id}/notes`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ notes }) });
await load(); toast(T.tNoteSaved); sheetItem(id);
} catch (err) { toast(errMsg(err.message), true); }
return;
}
if (t.dataset.due) {
pending.note = document.getElementById("hnote").value;
pending.dueKey = t.dataset.due;
return sheetConfirm();
}
if (t.dataset.give) {
pending = { item: +t.dataset.on, person: +t.dataset.give, dueKey: "none", note: "", sig: null };
return sheetConfirm();
}
if (t.dataset.door !== undefined && t.dataset.door !== "") return sheetDoor(+t.dataset.door);
if (t.dataset.item) return sheetItem(+t.dataset.item);
if (t.dataset.person) return sheetPerson(+t.dataset.person);
if (t.dataset.issue) return sheetIssue(+t.dataset.issue);
if (t.dataset.return) {
const id = +t.dataset.return;
try {
await api(`/api/items/${id}/return`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
await load(); toast(T.tBackIn); sheetItem(id);
} catch (err) { toast(errMsg(err.message), true); }
return;
}
if (t.dataset.handle) {
const id = +t.dataset.handle;
const note = (document.getElementById("rn" + id) || {}).value || "";
try {
await api(`/api/requests/${id}/handle`, { method: "POST",
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ note }) });
await load(); toast(T.tSaved);
} catch (err) { toast(errMsg(err.message), true); }
return;
}
if (t.dataset.cab) { cabinet = t.dataset.cab; return render(); }
if (t.dataset.filter) { filter = t.dataset.filter; return render(); }
if (t.dataset.tab) {
tab = t.dataset.tab;
document.querySelectorAll(".tab").forEach((x) => x.setAttribute("aria-selected", x.dataset.tab === tab));
$scroll.scrollTop = 0;
return render();
}
});
function chrome() {
document.getElementById("brand").textContent = T.brand;
document.getElementById("apptitle").textContent = T.appTitle;
[["gear", T.admin], ["reports", T.reports], ["askform", T.reqHandPhone]].forEach(([id, label]) => {
const el = document.getElementById(id);
if (!el) return;
el.title = label;
el.setAttribute("aria-label", label);
});
document.querySelectorAll("[data-t]").forEach((el) => (el.textContent = T[el.dataset.t]));
/* pending requests are easy to miss on a tab you rarely open */
const jt = document.querySelector('[data-tab="journal"] [data-t]');
if (jt && S.pending) jt.innerHTML = `${T.tabJournal}<span class="badge-count">${S.pending}</span>`;
document.title = T.brand;
}
function render() {
({ board: viewBoard, doors: viewDoors, people: viewPeople, log: viewLog, journal: viewJournal })[tab]();
}
load();

462
public/i18n.js Normal file
View file

@ -0,0 +1,462 @@
/* Key register — translations.
Add a language by copying the `pt` block. Set UI_LANG in .env to choose. */
"use strict";
var I18N = {
pt: {
_months: ["jan", "fev", "mar", "abr", "mai", "jun", "jul", "ago", "set", "out", "nov", "dez"],
brand: "Registo de Chaves", appTitle: "Armários de chaves", admin: "Administração",
loginSub: "Introduza o código partilhado.",
loginPlaceholder: "Código de acesso", loginBtn: "Entrar",
loginWrong: "Código incorreto.", loginMany: "Demasiadas tentativas. Aguarde um minuto.",
loginShow: "Mostrar código", loginHide: "Ocultar código",
loginFoot: "Acesso restrito à rede privada da igreja.",
loading: "A carregar…", noServer: "Não foi possível contactar o servidor.",
back: "Voltar ao registo",
tabBoard: "Quadro", tabDoors: "Portas", tabPeople: "Pessoas",
tabLog: "Movimentos", tabJournal: "Diário",
cabinetsN: (n) => `${n} ${n === 1 ? "armário" : "armários"}`,
keysIn: (a, b) => `${a} de ${b} chaves no armário`,
issuedN: "emprestadas", overdueN: "em atraso", reserveN: "de reserva",
searchPerson: "Procurar pessoa…",
/* pedido de acesso */
reqTitle: "Pedido de acesso",
reqIntro: "Preencha este formulário para pedir acesso a uma zona da igreja. O pedido é analisado pelo responsável pelas chaves — não recebe nenhuma chave por aqui.",
reqName: "Nome completo", reqDept: "Departamento ou grupo",
reqContact: "Telefone ou email", reqAreas: "Zonas a que precisa de aceder",
reqDetail: "Para quê, e durante quanto tempo",
reqDetailPh: "Ex.: ensaios do coro às quartas, a partir de setembro.",
reqPin: "Código da igreja", reqPinHelp: "Peça o código ao responsável pelas chaves.",
reqSend: "Enviar pedido", reqSending: "A enviar…",
reqOk: "Pedido enviado.",
reqOkMore: "O responsável pelas chaves vai analisá-lo. Não guarde este separador aberto.",
reqAnother: "Enviar outro pedido",
reqNeedName: "Indique o seu nome.", reqNeedArea: "Escolha pelo menos uma zona.",
reqWrongPin: "Código incorreto.", reqTooMany: "Demasiados pedidos. Tente mais tarde.",
reqFoot: "Nenhuma chave é entregue através deste formulário.",
/* caixa de pedidos */
reqHandPhone: "Passar o telemóvel para alguém preencher",
reqKioskDone: "Pode devolver o telemóvel.",
reqKioskNext: "Novo pedido",
reqNoPinStaff: "Sem código — o pedido está a ser feito a partir da aplicação.",
reqFormPublic: "Formulário público, protegido por código.",
reqFormOpen: "FORMULÁRIO PÚBLICO SEM CÓDIGO — qualquer pessoa que descubra o endereço pode enviar pedidos.",
reqFormPrivate: "Formulário só acessível a quem entra na aplicação.",
reqFormOpenIt: "Abrir formulário",
reqInbox: "Pedidos de acesso", reqPending: (n) => `${n} por tratar`,
reqNone: "Nenhum pedido pendente.",
reqOn: (d) => `Recebido em ${d}`, reqMarkDone: "Marcar como tratado",
reqNotePh: "O que foi decidido?", reqHandled: "Tratado",
reqHandledOn: (d) => `Tratado em ${d}`,
/* reports */
reports: "Relatórios", print: "Imprimir", printPdf: "Imprimir / PDF",
repOut: "Chaves emprestadas", repOverdue: "Chaves em atraso",
repPerson: "Recibo por pessoa", repInventory: "Inventário completo",
repAudit: "Folha de auditoria",
repGenerated: (d, h) => `Gerado em ${d} às ${h}`,
repNothing: "Nada a mostrar.",
repChoosePerson: "Escolha uma pessoa",
stKeysOut: "emprestadas", stPeople: "pessoas", stOverdue: "em atraso",
stTotal: "total", stInCab: "no armário", stReserve: "reserva",
cTag: "Nº", cDoor: "Porta", cBuilding: "Edifício", cCabinet: "Armário",
cPerson: "Pessoa", cDept: "Departamento", cOut: "Entregue", cDue: "Devolver até",
cDays: "Dias", cStatus: "Estado", cHook: "Gancho", cCheck: "Conf.",
repSignedOn: (d) => `Assinado em ${d}`, repNoSig: "Sem assinatura registada",
repReceiptTitle: "Recibo de chaves",
repReceiptIntro: (n) => `Declaro ter em minha posse ${n === 1 ? "a chave" : "as"} ${n === 1 ? "" : n + " chaves"} abaixo indicadas, e comprometo-me a devolvê-${n === 1 ? "la" : "las"} quando solicitado.`,
repHolder: "Titular", repResponsible: "Responsável pelas chaves", repDate: "Data",
repAuditIntro: "Confirme cada chave presente no armário e assinale a caixa. As chaves emprestadas estão indicadas.",
repAuditBy: "Auditoria feita por", repNotes: "Observações",
repAllCabinets: "Todos os armários",
allTile: "Todos",
statuses: { in: "no armário", out: "emprestada", reserve: "reserva", lost: "perdida", retired: "retirada" },
fEverything: "Tudo", fKeys: "Só chaves", fTags: "Etiquetas",
fOut: "Emprestadas", fLate: "Em atraso",
lgIn: "No armário", lgOut: "Emprestada", lgRes: "Reserva",
lgLate: "Em atraso", lgTag: "Etiqueta de alarme",
outCount: (n) => `${n} fora`,
boardNote: "Um gancho por cada chave física, pela ordem do armário.<br>Para saber quem abre uma porta, use Portas.",
noMatch: "Nada corresponde a esse filtro.",
noKeysYet: "Ainda não há chaves. Adicione-as na",
onHook: "No seu gancho", reserveCopy: "Cópia de reserva",
lost: "Perdida", retired: "Retirada", allIn: "todas no armário",
copies: (n) => `${n} ${n === 1 ? "cópia" : "cópias"}`,
doorsN: (n) => `${n} ${n === 1 ? "porta" : "portas"}`,
searchPlaceholder: "Porta, número de chave, pessoa ou nota",
nothingFound: (q) => `Nada encontrado para «${q}».`,
doorsNote: "Uma linha por porta. Os pontos são as cópias — cheio está no armário,<br>vazio está com alguém, cinzento é a reserva.",
tagsSection: "Etiquetas de alarme",
holdingNow: "Com chaves neste momento", nothingOut: "Sem chaves",
inactive: "inativo",
keysWord: (n) => `${n} ${n === 1 ? "chave" : "chaves"}`,
tagsWord: (n) => `${n} ${n === 1 ? "etiqueta" : "etiquetas"}`,
noPeopleYet: "Ainda não há pessoas. Adicione-as na",
logTitle: "Todas as entregas, mais recentes primeiro",
toWord: "para", backFrom: "devolvida por", signedMark: "assinado",
noHandovers: "Ainda não há entregas registadas.",
logNote: "As linhas só são acrescentadas, nunca alteradas.",
dirOut: "saída", dirIn: "entrada",
newEntry: "Nova entrada",
journalPlaceholder: "Auditoria feita, fechadura mudada, selo de reserva quebrado…",
addJournal: "Adicionar ao diário", journalTitle: "Diário dos armários",
journalEmpty: "Ainda não há registos.",
journalNote: "Para tudo o que pertence a um armário e não a uma chave.",
withSince: (who, d) => `Com <b>${who}</b> desde ${d}.`,
dueWas: (d, n) => `<br>Devolução prevista ${d} — <b>há ${n} dias</b>.`,
issuedToOn: (who, d) => `Entregue a <b>${who}</b> em ${d}`,
dueBack: (d) => `<br>Devolução prevista ${d}.`,
noDueDate: "<br>Sem data de devolução — posse permanente.",
reserveLine: "Cópia de reserva, fora das instalações. A entrega fica automaticamente no diário.",
lostLine: "Dada como perdida. Considere mudar a fechadura desta porta.",
retiredLine: "Retirada. Mantida apenas pelo histórico.",
inCabinet: (c) => `No ${c}`, atHook: (h) => `, gancho ${h}`,
fType: "Tipo", vKey: "Chave de porta", vTag: "Etiqueta NFC de alarme",
fKeptIn: "Guardada em", fHook: "Gancho", fLockGroup: "Grupo de fechadura",
fChipUid: "UID do chip", fMaster: "Chave-mestra", yes: "Sim", no: "Não",
fCopiesLiveIn: "Cópias guardadas em", fHoldingNow: "Tem agora",
fReturnedPast: "Devolvidas no passado", fPhone: "Telefone",
seeAllCopies: (n) => `Ver todas as ${n} cópias`,
noteOnHandover: "Nota desta entrega",
noteOnKey: "Nota sobre esta chave — permanente",
notePlaceholder: "Algo que a próxima pessoa deva saber.",
saveNote: "Guardar nota",
noteOnDoor: "Nota sobre esta porta",
issueTo: "Entregar a alguém", breakSeal: "Quebrar o selo e entregar",
takeBack: "Receber de volta", close: "Fechar", cancel: "Cancelar",
history: "Histórico", neverIssued: "Nunca foi entregue.",
returnedOn: (d) => `devolvida em ${d}`, stillHolding: "ainda a tem",
stClosed: "fechado", stOpen: "aberto", stLate: "atraso",
currentlyHolds: "Tem neste momento", holdsNothing: "Não tem nada de momento.",
returnedSection: "Devolvidas", heldFromTo: (a, b) => `esteve de ${a} a ${b}`,
itemsN: (n) => `${n} ${n === 1 ? "item" : "itens"}`,
fromCabinets: (s) => `de ${s}`,
issueTitle: (t) => `Entregar ${t}`, fromThe: (c) => `do ${c}`,
whoTakes: "Quem a vai levar?",
reserveWarn: "Esta é uma cópia de último recurso. Ao entregá-la, a porta fica sem reserva.",
noActivePeople: "Não há pessoas ativas. Adicione-as na",
handover: "Entrega", backBy: "Devolver até",
dNone: "Sem data — permanente", d7: "Uma semana", d30: "Um mês", d90: "Três meses",
whyNote: "Motivo — nota desta entrega",
whyPlaceholder: "Concerto de Natal, revisão da caldeira, novo sacristão…",
signature: "Assinatura", signHint: "Dê o telemóvel para assinar",
clearSig: "Limpar", confirmHandover: "Confirmar entrega",
everyCopy: "Todas as cópias desta chave",
sameLock: "Mesmo grupo de fechadura — estas também abrem",
doorSummary: (total, out, inC, res, lst) => {
const parts = [];
if (out) parts.push(`${out} com pessoas`);
parts.push(`${inC} no armário`);
if (res) parts.push(`${res} de reserva`);
if (lst) parts.push(`${lst} perdidas`);
return `Existem <b>${total} ${total === 1 ? "cópia" : "cópias"}</b>. ${parts.join(", ")}.`;
},
tHandedOver: "Entregue", tBackIn: "De volta ao armário",
tNoteSaved: "Nota guardada", tJournalAdded: "Entrada adicionada",
tSaved: "Guardado", tRemoved: "Removido",
errors: {
"already issued": "Esta chave já está emprestada.",
"not currently issued": "Esta chave não está emprestada.",
"no such item": "Chave não encontrada.",
"no such person": "Pessoa não encontrada.",
"not issuable": "Esta chave não pode ser entregue.",
"tag required": "O número da chave é obrigatório.",
"name required": "O nome é obrigatório.",
"that tag is already used": "Esse número de chave já existe.",
"door number already used": "Esse número de porta já existe.",
"num, building and name required": "Número, edifício e nome são obrigatórios.",
"id and name required": "Código e nome são obrigatórios.",
"confirmation phrase required": "Tem de escrever a frase de confirmação.",
"empty": "Está vazio.",
"locked": "Sessão terminada. Recarregue a página.",
"wrong pin": "Código incorreto.",
"too many requests": "Demasiados pedidos. Tente mais tarde.",
},
/* admin */
aItems: "Chaves e etiquetas", aDoors: "Portas", aPeople: "Pessoas",
aCabinets: "Armários", aKinds: "Tipos", aData: "Importar / cópias", aAudit: "Alterações",
aKindsHelp: "Os tipos de objeto que guarda. A forma diz o que é, o preenchimento diz onde está.",
aAddKind: "+ Adicionar tipo",
fKindCode: "Código", fKindName: "Nome", fShape: "Forma", fColour: "Cor",
shCircle: "Círculo", shRounded: "Quadrado arredondado", shSquare: "Quadrado",
deleteKind: "Eliminar este tipo",
kindNote: "O código é usado nas importações (coluna kind) e não deve mudar depois de haver chaves com ele. O nome pode mudar à vontade.",
kindInUse: (n) => `${n} itens usam este tipo.`,
aItemsHelp: (n) => `Uma linha por cada chave física. ${n} no total.`,
aFilter: "Filtrar por número, porta ou nota",
aAddItem: "+ Adicionar chave ou etiqueta", aAddDoor: "+ Adicionar porta",
aAddPerson: "+ Adicionar pessoa", aAddCabinet: "+ Adicionar armário",
aTag: "Nº", aDoor: "Porta / zona", aCabinet: "Armário", aHook: "Gancho",
aStatus: "Estado", aEdit: "Editar", aNothing: "Ainda não há nada.",
aDoorsHelp: (n) => `As portas físicas. As chaves apontam para estas. ${n} no total.`,
aNo: "Nº", aLock: "Fechadura", aCopies: "Cópias",
aPeopleHelp: "Titulares de chaves. Nunca fazem login — isto é só quem pode receber uma chave.",
aName: "Nome", aDept: "Departamento", aHolding: "Tem",
aCabinetsHelp: "Onde as chaves ficam fisicamente. Um armário não é um edifício — o escritório pode guardar mestras da igreja.",
aOffSite: "fora das instalações", aItemsCount: (n) => `${n} itens`,
fNum: "Número", fBuilding: "Edifício", fDoorName: "Nome da porta", fNote: "Nota",
fShortCode: "Código curto", fShortName: "Nome curto", fFullName: "Nome completo",
fWhereIs: "Onde está e o que a abre", fSort: "Ordem no quadro",
fSortHelp: "Número menor aparece primeiro. Deixe a reserva com um número alto para ficar no fim.",
fOffSiteLabel: "Fora das instalações — depósito de reserva",
fZoneKeys: "Porta (chaves)", fZoneTags: "Zona de alarme (etiquetas)",
fNoneTag: "— nenhuma / etiqueta de alarme —", fNone: "— nenhum —",
fIsMaster: "É uma chave-mestra", fPermNote: "Nota permanente",
fFunction: "Função", fEmail: "Email",
fActiveLabel: "Ativo — pode receber chaves",
save: "Guardar", deleteRetire: "Eliminar ou retirar esta chave",
deleteDoor: "Eliminar esta porta", deactivate: "Desativar",
deleteCabinet: "Eliminar este armário",
retireNote: "Se tiver histórico de entregas é retirada, nunca eliminada — o histórico tem de sobreviver.",
peopleNote: "As pessoas nunca são eliminadas. Aparecem em entregas antigas, e eliminá-las tornaria esse histórico anónimo.",
lockGroupNote: "O grupo de fechadura liga portas que partilham o mesmo perfil de chave, para saber o que mais uma chave perdida poderá abrir.",
offSiteNote: "As chaves devolvidas a um depósito fora das instalações voltam a <em>reserva</em> e não a <em>no armário</em>, ficando fora da circulação normal.",
holdsWarn: (n, tags) => `Tem ${n} ${n === 1 ? "item" : "itens"}: ${tags}. Receba-os de volta antes de desativar.`,
impWhat: "O que quer importar?",
impItems: "Chaves e etiquetas", impDoors: "Portas", impPeople: "Pessoas", impCabinets: "Armários",
impResult2: (c, u) => `Criados <b>${c}</b>, atualizados <b>${u}</b>.`,
impTitle: "Importar de uma folha de cálculo",
impHelp: "Cole CSV. Colunas obrigatórias: <b>tag</b>, <b>door</b>, <b>building</b>. Opcionais: lock_group, cabinet, hook, master, status, kind, notes. As portas e armários que não existirem são criados.",
impBtn: "Importar",
impResult: (i, d, c) => `Importados <b>${i}</b> itens, criadas ${d} portas e ${c} armários.`,
impSkipped: (n, list) => `<br>Ignorados ${n}: ${list}`,
impNothing: "Não há nada para importar",
expTitle: "Exportar",
expHelp: "Todo o registo como folha de cálculo. Útil para uma cópia impressa no armário, e prova de que nunca fica preso a esta aplicação.",
expBtn: "Descarregar CSV",
bkTitle: "Cópia de segurança",
bkHelp: "Um único ficheiro SQLite, seguro de descarregar com a aplicação a funcionar. Guarde uma cópia fora desta máquina.",
bkBtn: "Descarregar base de dados",
wipeTitle: "Apagar tudo",
wipeWarn: "Elimina todas as chaves, portas, pessoas, entregas e entradas do diário. Os armários são mantidos. Não há como desfazer — faça primeiro uma cópia de segurança.",
wipePlaceholder: "Escreva: DELETE EVERYTHING",
wipeBtn: "Apagar o registo", wipeDone: "Registo apagado",
auditTitle: "Registo de alterações",
auditHelp: "Alterações administrativas, mais recentes primeiro. Com duas pessoas a editar, «quem mudou a 12-B e quando» é uma pergunta que acaba por surgir.",
aWhen: "Quando", aWhat: "O quê", aDetail: "Detalhe",
imported: "Importado",
},
en: {
_months: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
brand: "Key Register", appTitle: "Key cabinets", admin: "Admin",
loginSub: "Enter the shared passcode.",
loginPlaceholder: "Passcode", loginBtn: "Unlock",
loginWrong: "Wrong passcode.", loginMany: "Too many attempts. Wait a minute.",
loginShow: "Show passcode", loginHide: "Hide passcode",
loginFoot: "Restricted to the church private network.",
loading: "Loading…", noServer: "Can't reach the server.", back: "Back to the register",
tabBoard: "Board", tabDoors: "Doors", tabPeople: "People", tabLog: "Log", tabJournal: "Journal",
cabinetsN: (n) => `${n} store${n === 1 ? "" : "s"}`,
keysIn: (a, b) => `${a} of ${b} keys in`,
issuedN: "issued", overdueN: "overdue", reserveN: "reserve",
searchPerson: "Search for a person…",
/* access request */
reqTitle: "Access request",
reqIntro: "Use this form to request access to an area of the church. The keyholder reviews every request — no key is issued here.",
reqName: "Full name", reqDept: "Department or group",
reqContact: "Phone or email", reqAreas: "Areas you need access to",
reqDetail: "What for, and for how long",
reqDetailPh: "e.g. choir rehearsals on Wednesdays, from September.",
reqPin: "Church code", reqPinHelp: "Ask the keyholder for the code.",
reqSend: "Send request", reqSending: "Sending…",
reqOk: "Request sent.",
reqOkMore: "The keyholder will review it. You can close this tab.",
reqAnother: "Send another request",
reqNeedName: "Please enter your name.", reqNeedArea: "Choose at least one area.",
reqWrongPin: "Wrong code.", reqTooMany: "Too many requests. Try again later.",
reqFoot: "No key is handed over through this form.",
reqHandPhone: "Hand the phone to someone to fill in",
reqKioskDone: "You can hand the phone back.",
reqKioskNext: "New request",
reqNoPinStaff: "No code needed — this request is being made from inside the app.",
reqFormPublic: "Form is public, protected by a code.",
reqFormOpen: "FORM IS PUBLIC WITH NO CODE — anyone who finds the address can submit.",
reqFormPrivate: "Form is only reachable by people signed in to the app.",
reqFormOpenIt: "Open the form",
reqInbox: "Access requests", reqPending: (n) => `${n} to handle`,
reqNone: "No pending requests.",
reqOn: (d) => `Received ${d}`, reqMarkDone: "Mark as handled",
reqNotePh: "What was decided?", reqHandled: "Handled",
reqHandledOn: (d) => `Handled ${d}`,
/* reports */
reports: "Reports", print: "Print", printPdf: "Print / PDF",
repOut: "Keys currently issued", repOverdue: "Overdue keys",
repPerson: "Receipt per person", repInventory: "Full inventory",
repAudit: "Audit sheet",
repGenerated: (d, h) => `Generated ${d} at ${h}`,
repNothing: "Nothing to show.",
repChoosePerson: "Choose a person",
stKeysOut: "issued", stPeople: "people", stOverdue: "overdue",
stTotal: "total", stInCab: "in cabinet", stReserve: "reserve",
cTag: "Tag", cDoor: "Door", cBuilding: "Building", cCabinet: "Cabinet",
cPerson: "Person", cDept: "Department", cOut: "Issued", cDue: "Due back",
cDays: "Days", cStatus: "Status", cHook: "Hook", cCheck: "Check",
repSignedOn: (d) => `Signed ${d}`, repNoSig: "No signature on record",
repReceiptTitle: "Key receipt",
repReceiptIntro: (n) => `I confirm that I hold the ${n === 1 ? "key" : n + " keys"} listed below, and undertake to return ${n === 1 ? "it" : "them"} on request.`,
repHolder: "Holder", repResponsible: "Keyholder in charge", repDate: "Date",
repAuditIntro: "Confirm each key present in the cabinet and tick the box. Issued keys are marked.",
repAuditBy: "Audit carried out by", repNotes: "Notes",
repAllCabinets: "All cabinets",
allTile: "All",
statuses: { in: "in cabinet", out: "issued", reserve: "reserve", lost: "lost", retired: "retired" },
fEverything: "Everything", fKeys: "Keys only", fTags: "Alarm tags",
fOut: "Currently out", fLate: "Overdue",
lgIn: "In cabinet", lgOut: "Issued", lgRes: "Reserve", lgLate: "Overdue", lgTag: "Alarm tag",
outCount: (n) => `${n} out`,
boardNote: "One peg per piece of metal, in hook order.<br>For \u201cwho can open this door\u201d, use Doors.",
noMatch: "Nothing matches that filter.", noKeysYet: "No keys yet. Add them in",
onHook: "On its hook", reserveCopy: "Reserve copy", lost: "Lost", retired: "Retired",
allIn: "all in",
copies: (n) => `${n} cop${n === 1 ? "y" : "ies"}`,
doorsN: (n) => `${n} door${n === 1 ? "" : "s"}`,
searchPlaceholder: "Door, key number, person, or note",
nothingFound: (q) => `Nothing found for \u201c${q}\u201d.`,
doorsNote: "One row per door. Dots are its copies — filled is in a cabinet,<br>hollow is out with someone, grey is reserve.",
tagsSection: "Alarm tags",
holdingNow: "Holding something now", nothingOut: "Nothing out", inactive: "inactive",
keysWord: (n) => `${n} key${n === 1 ? "" : "s"}`,
tagsWord: (n) => `${n} tag${n === 1 ? "" : "s"}`,
noPeopleYet: "No people yet. Add them in",
logTitle: "Every handover, newest first", toWord: "to", backFrom: "back from",
signedMark: "signed", noHandovers: "No handovers recorded yet.",
logNote: "Rows are only ever added, never edited.", dirOut: "out", dirIn: "in",
newEntry: "New entry", journalPlaceholder: "Audit done, lock changed, reserve seal broken…",
addJournal: "Add to journal", journalTitle: "Cabinet journal",
journalEmpty: "Nothing recorded yet.",
journalNote: "For everything that belongs to a cabinet rather than to one key.",
withSince: (w, d) => `With <b>${w}</b> since ${d}.`,
dueWas: (d, n) => `<br>Due back ${d} — <b>${n} days ago</b>.`,
issuedToOn: (w, d) => `Issued to <b>${w}</b> on ${d}`,
dueBack: (d) => `<br>Due back ${d}.`,
noDueDate: "<br>No return date — held permanently.",
reserveLine: "Reserve copy, off site. Issuing it is logged in the journal automatically.",
lostLine: "Reported lost. Consider re-keying this door.",
retiredLine: "Retired. Kept for its history only.",
inCabinet: (c) => `In the ${c}`, atHook: (h) => `, hook ${h}`,
fType: "Type", vKey: "Door key", vTag: "NFC alarm tag",
fKeptIn: "Kept in", fHook: "Hook", fLockGroup: "Lock group", fChipUid: "Chip UID",
fMaster: "Master key", yes: "Yes", no: "No",
fCopiesLiveIn: "Copies live in", fHoldingNow: "Holding now",
fReturnedPast: "Returned in the past", fPhone: "Phone",
seeAllCopies: (n) => `See all ${n} copies`,
noteOnHandover: "Note on this handover", noteOnKey: "Note on this key — permanent",
notePlaceholder: "Anything the next person should know.", saveNote: "Save note",
noteOnDoor: "Note on this door",
issueTo: "Issue to someone", breakSeal: "Break seal and issue", takeBack: "Take it back",
close: "Close", cancel: "Cancel", history: "History", neverIssued: "Never been issued.",
returnedOn: (d) => `returned ${d}`, stillHolding: "still holding it",
stClosed: "closed", stOpen: "open", stLate: "late",
currentlyHolds: "Currently holds", holdsNothing: "Holds nothing right now.",
returnedSection: "Returned", heldFromTo: (a, b) => `held ${a} → ${b}`,
itemsN: (n) => `${n} item${n === 1 ? "" : "s"}`,
fromCabinets: (s) => `from ${s}`,
issueTitle: (t) => `Issue ${t}`, fromThe: (c) => `from the ${c}`,
whoTakes: "Who is taking it?",
reserveWarn: "This is a last-resort copy. Issuing it leaves the door without a reserve.",
noActivePeople: "No active people. Add them in",
handover: "Handover", backBy: "Back by",
dNone: "No date — permanent", d7: "One week", d30: "One month", d90: "Three months",
whyNote: "Why — note on this handover",
whyPlaceholder: "Christmas concert, boiler service, new caretaker…",
signature: "Signature", signHint: "Hand them the phone to sign",
clearSig: "Clear", confirmHandover: "Confirm handover",
everyCopy: "Every copy of this key", sameLock: "Same lock group — these open too",
doorSummary: (total, out, inC, res, lst) => {
const parts = [];
if (out) parts.push(`${out} with people`);
parts.push(`${inC} in cabinets`);
if (res) parts.push(`${res} reserve`);
if (lst) parts.push(`${lst} lost`);
return `<b>${total} cop${total === 1 ? "y" : "ies"}</b> exist. ${parts.join(", ")}.`;
},
tHandedOver: "Handed over", tBackIn: "Back in the cabinet", tNoteSaved: "Note saved",
tJournalAdded: "Journal entry added", tSaved: "Saved", tRemoved: "Removed",
errors: {},
aItems: "Keys & tags", aDoors: "Doors", aPeople: "People", aCabinets: "Cabinets",
aData: "Import / backup", aAudit: "Change log",
aItemsHelp: (n) => `One row per physical piece of metal. ${n} in total.`,
aFilter: "Filter by tag, door, or note",
aAddItem: "+ Add a key or tag", aAddDoor: "+ Add a door",
aAddPerson: "+ Add a person", aAddCabinet: "+ Add a cabinet",
aTag: "Tag", aDoor: "Door / zone", aCabinet: "Cabinet", aHook: "Hook",
aStatus: "Status", aEdit: "Edit", aNothing: "Nothing yet.",
aDoorsHelp: (n) => `The physical doors. Keys point at these. ${n} in total.`,
aNo: "No.", aLock: "Lock", aCopies: "Copies",
aPeopleHelp: "Key holders. They never log in — this is just who can be handed a key.",
aName: "Name", aDept: "Department", aHolding: "Holding",
aCabinetsHelp: "Where keys physically live. A cabinet is not a building — the office can hold church masters.",
aOffSite: "off site", aItemsCount: (n) => `${n} items`,
fNum: "Number", fBuilding: "Building", fDoorName: "Door name", fNote: "Note",
fShortCode: "Short code", fShortName: "Short name", fFullName: "Full name",
fWhereIs: "Where it is, and what opens it", fSort: "Board order",
fSortHelp: "Lower numbers come first. Give the reserve store a high number to keep it last.",
fOffSiteLabel: "Off site — reserve store",
fZoneKeys: "Door (keys)", fZoneTags: "Alarm zone (tags)",
fNoneTag: "— none / alarm tag —", fNone: "— none —",
fIsMaster: "This is a master key", fPermNote: "Permanent note",
fFunction: "Function", fEmail: "Email", fActiveLabel: "Active — can be handed keys",
save: "Save", deleteRetire: "Delete or retire this key", deleteDoor: "Delete this door",
deactivate: "Deactivate", deleteCabinet: "Delete this cabinet",
retireNote: "If it has handover history it is retired, never deleted — the history has to survive.",
peopleNote: "People are never deleted. They appear in old handovers, and deleting them would make that history anonymous.",
lockGroupNote: "Lock group ties doors that share a key profile, so you can see what else a lost key might open.",
offSiteNote: "Keys returned to an off-site store go back to <em>reserve</em> rather than <em>in cabinet</em>, so they stay out of everyday circulation.",
holdsWarn: (n, tags) => `Currently holds ${n} item${n === 1 ? "" : "s"}: ${tags}. Take those back before deactivating.`,
impWhat: "What do you want to import?",
impItems: "Keys & tags", impDoors: "Doors", impPeople: "People", impCabinets: "Cabinets",
impResult2: (c, u) => `Created <b>${c}</b>, updated <b>${u}</b>.`,
impTitle: "Import from a spreadsheet",
impHelp: "Paste CSV. Required columns: <b>tag</b>, <b>door</b>, <b>building</b>. Optional: lock_group, cabinet, hook, master, status, kind, notes. Doors and cabinets that don't exist are created.",
impBtn: "Import",
impResult: (i, d, c) => `Imported <b>${i}</b> items, created ${d} doors and ${c} cabinets.`,
impSkipped: (n, list) => `<br>Skipped ${n}: ${list}`,
impNothing: "Nothing to import",
expTitle: "Export",
expHelp: "The whole register as a spreadsheet. Useful for a printed copy in the cabinet, and proof you are never locked into this app.",
expBtn: "Download CSV",
bkTitle: "Backup",
bkHelp: "One SQLite file, safe to download while the app is running. Keep a copy somewhere that is not this machine.",
bkBtn: "Download database",
wipeTitle: "Clear everything",
wipeWarn: "Deletes every key, door, person, handover and journal entry. Cabinets are kept. There is no undo — download a backup first.",
wipePlaceholder: "Type: DELETE EVERYTHING",
wipeBtn: "Clear the register", wipeDone: "Register cleared",
auditTitle: "Change log",
auditHelp: "Admin changes, newest first. With two of you editing, \u201cwho moved 12-B and when\u201d is a question that comes up.",
aWhen: "When", aWhat: "What", aDetail: "Detail",
imported: "Imported",
},
};
/* Chosen by the server from UI_LANG; falls back to Portuguese.
`var`, not `let`, so the binding is a real global shared with app.js and
admin.js rather than scoped to this script. */
var T = I18N.pt;
function setLang(code) {
T = I18N[code] || I18N.pt;
document.documentElement.lang = I18N[code] ? code : "pt";
}
/* Server errors arrive in English; show a translated version when we have one. */
function errMsg(m) { return (T.errors && T.errors[m]) || m; }

53
public/index.html Normal file
View file

@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="pt">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#131E1B">
<title>Key Register</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans+Condensed:wght@600;700&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<div class="app">
<header>
<p class="eyebrow" id="brand"></p>
<h1 id="apptitle"></h1>
<div class="tools">
<a class="tool" href="/pedido?kiosk=1" id="askform" aria-label="">
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="4.5" y="3.5" width="15" height="17" rx="2.5"/>
<path d="M9 3.5h6M12 9.5v6M9 12.5h6"/>
</svg></a>
<a class="tool" href="/report" id="reports" aria-label="">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M7 9V3.5h10V9"/>
<path d="M5 9h14a2 2 0 0 1 2 2v5h-4v4.5H7V16H3v-5a2 2 0 0 1 2-2Z"/>
<path d="M7 16h10"/>
</svg></a>
<a class="tool" href="/admin" id="gear" aria-label="">
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="3.2"/>
<path d="M12 2.8v2.4M12 18.8v2.4M21.2 12h-2.4M5.2 12H2.8M18.5 5.5l-1.7 1.7M7.2 16.8l-1.7 1.7M18.5 18.5l-1.7-1.7M7.2 7.2 5.5 5.5"/>
</svg></a>
</div>
<div class="counts" id="counts"></div>
</header>
<div class="scroll" id="scroll"><div class="pane"><div class="empty" id="loading"></div></div></div>
<nav class="tabs" role="tablist">
<button class="tab" role="tab" data-tab="board" aria-selected="true"><span class="ic"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="8" cy="8" r="3.1"/><circle cx="16" cy="8" r="3.1"/><circle cx="8" cy="16" r="3.1"/><circle cx="16" cy="16" r="3.1"/></svg></span><span data-t="tabBoard"></span></button>
<button class="tab" role="tab" data-tab="doors" aria-selected="false"><span class="ic"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6.5 3.5h11v17h-11z"/><path d="M5 20.5h14"/><circle cx="14.6" cy="12.2" r="1"/></svg></span><span data-t="tabDoors"></span></button>
<button class="tab" role="tab" data-tab="people" aria-selected="false"><span class="ic"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8.4" r="3.5"/><path d="M5.2 20.3a6.8 6.8 0 0 1 13.6 0"/></svg></span><span data-t="tabPeople"></span></button>
<button class="tab" role="tab" data-tab="log" aria-selected="false"><span class="ic"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 8.6h13"/><path d="M13.6 5.2 17 8.6l-3.4 3.4"/><path d="M20 15.4H7"/><path d="M10.4 12 7 15.4l3.4 3.4"/></svg></span><span data-t="tabLog"></span></button>
<button class="tab" role="tab" data-tab="journal" aria-selected="false"><span class="ic"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6.5 3.5h8l4.5 4.5v12.5h-12.5z"/><path d="M14.5 3.5V8H19"/><path d="M9.5 12.5h6M9.5 16h4"/></svg></span><span data-t="tabJournal"></span></button>
</nav>
</div>
<div class="scrim" id="scrim"></div>
<div class="sheet" id="sheet" role="dialog" aria-modal="true"></div>
<div class="toast" id="toast"></div>
<script src="/i18n.js"></script>
<script src="/app.js"></script>
</body>
</html>

133
public/login.html Normal file
View file

@ -0,0 +1,133 @@
<!DOCTYPE html>
<html lang="pt">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#131E1B">
<meta name="robots" content="noindex, nofollow">
<title>Registo de Chaves</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans+Condensed:wght@700&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<div class="login">
<!-- the key board, as wallpaper -->
<svg id="pegfield" aria-hidden="true"></svg>
<div class="login-card">
<div class="login-mark" aria-hidden="true">
<svg viewBox="0 0 24 24">
<circle cx="8" cy="8" r="4.2"/>
<path d="M11 11 L19 19"/>
<path d="M16.4 16.4 L18.6 14.2"/>
<path d="M18.6 18.6 L20.6 16.6"/>
</svg>
</div>
<p class="eyebrow" id="brand"></p>
<h1 id="title"></h1>
<p class="login-sub" id="sub"></p>
<div class="pw-wrap">
<input class="pw" id="code" type="password" inputmode="text"
autocomplete="current-password" autocapitalize="off"
autocorrect="off" spellcheck="false" autofocus>
<button class="pw-toggle" id="toggle" type="button" aria-pressed="false">👁</button>
</div>
<button class="btn btn-p" id="go"></button>
<p class="login-err" id="err" role="alert" aria-live="polite"></p>
</div>
<p class="login-foot" id="foot"></p>
</div>
<script src="/i18n.js"></script>
<script>
/* The server substitutes the configured language before sending this page. */
setLang("__LANG__".replace(/^__.*__$/, "pt"));
document.getElementById("brand").textContent = T.brand;
document.getElementById("title").textContent = T.brand;
document.getElementById("sub").textContent = T.loginSub;
document.getElementById("go").textContent = T.loginBtn;
document.getElementById("foot").textContent = T.loginFoot;
document.title = T.brand;
const $code = document.getElementById("code");
const $err = document.getElementById("err");
const $card = document.querySelector(".login-card");
const $toggle = document.getElementById("toggle");
$code.placeholder = T.loginPlaceholder;
$toggle.title = T.loginShow;
$toggle.setAttribute("aria-label", T.loginShow);
/* Decorative peg board. Drawn rather than an image so it scales to any
screen and stays a few hundred bytes. */
(function pegfield() {
const svg = document.getElementById("pegfield");
const W = 1200, H = 900, step = 66, r = 15;
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
svg.setAttribute("preserveAspectRatio", "xMidYMid slice");
let out = "";
for (let y = step / 2; y < H; y += step) {
for (let x = step / 2; x < W; x += step) {
/* deterministic pseudo-random, so the pattern is stable across reloads */
const n = Math.abs(Math.sin(x * 12.9898 + y * 78.233) * 43758.5453) % 1;
if (n < 0.10) continue; // empty hook
if (n < 0.26) { // issued
out += `<circle cx="${x}" cy="${y}" r="${r}" fill="none" stroke="#A87F3C" stroke-width="2"/>`;
} else if (n < 0.34) { // alarm tag
out += `<rect x="${x - r}" y="${y - r}" width="${r * 2}" height="${r * 2}" rx="4" fill="#5F55A0"/>`;
} else { // on its hook
out += `<circle cx="${x}" cy="${y}" r="${r}" fill="#2E6E60"/>`;
}
}
}
svg.innerHTML = out;
})();
let busy = false;
function fail(msg) {
$err.textContent = msg;
$err.classList.add("on");
$card.classList.remove("shake");
void $card.offsetWidth; // restart the animation
$card.classList.add("shake");
$code.value = "";
$code.focus();
}
async function submit() {
if (busy || !$code.value) return;
busy = true;
$err.classList.remove("on");
try {
const r = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: $code.value }),
});
if (r.ok) { location.href = "/"; return; }
fail(r.status === 429 ? T.loginMany : T.loginWrong);
} catch (e) {
fail(T.noServer);
}
busy = false;
}
document.getElementById("go").addEventListener("click", submit);
$code.addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); });
$toggle.addEventListener("click", () => {
const shown = $code.type === "text";
$code.type = shown ? "password" : "text";
$toggle.setAttribute("aria-pressed", String(!shown));
$toggle.title = shown ? T.loginShow : T.loginHide;
$toggle.setAttribute("aria-label", $toggle.title);
$code.focus();
});
</script>
</body>
</html>

164
public/pedido.html Normal file
View file

@ -0,0 +1,164 @@
<!DOCTYPE html>
<html lang="pt">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#131E1B">
<meta name="robots" content="noindex, nofollow">
<title>Pedido de acesso</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans+Condensed:wght@700&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<div class="req-page">
<div class="req-top">
<div class="inner">
<p class="eyebrow" id="brand"></p>
<h1 id="title"></h1>
<p id="intro"></p>
</div>
</div>
<div class="req-wrap" id="wrap">
<div class="req-msg bad" id="msg" role="alert" aria-live="polite"></div>
<div class="req-field">
<span class="lab" id="l_name"></span>
<input type="text" id="f_name" autocomplete="name" maxlength="80">
</div>
<div class="req-field">
<span class="lab" id="l_dept"></span>
<input type="text" id="f_dept" autocomplete="organization" maxlength="80">
</div>
<div class="req-field">
<span class="lab" id="l_contact"></span>
<input type="text" id="f_contact" autocomplete="tel" maxlength="80">
</div>
<div class="req-field">
<span class="lab" id="l_areas"></span>
<div class="areas" id="areas"></div>
</div>
<div class="req-field">
<span class="lab" id="l_detail"></span>
<textarea id="f_detail" maxlength="600"></textarea>
</div>
<div class="req-field" id="pinbox" style="display:none">
<span class="lab" id="l_pin"></span>
<input type="text" id="f_pin" inputmode="numeric" autocomplete="off" maxlength="24">
<p class="req-hint" id="h_pin"></p>
</div>
<!-- Bots fill every field they can find; a person never sees this one. -->
<div class="hp" aria-hidden="true">
<label>Website<input type="text" id="f_website" tabindex="-1" autocomplete="off"></label>
</div>
<button class="btn btn-p" id="send" style="width:100%"></button>
<p class="req-foot" id="foot"></p>
</div>
</div>
<script src="/i18n.js"></script>
<script>
setLang("__LANG__".replace(/^__.*__$/, "pt"));
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
$("brand").textContent = T.brand;
$("title").textContent = T.reqTitle;
$("intro").textContent = T.reqIntro;
$("l_name").textContent = T.reqName;
$("l_dept").textContent = T.reqDept;
$("l_contact").textContent = T.reqContact;
$("l_areas").textContent = T.reqAreas;
$("l_detail").textContent = T.reqDetail;
$("f_detail").placeholder = T.reqDetailPh;
$("l_pin").textContent = T.reqPin;
$("h_pin").textContent = T.reqPinHelp;
$("send").textContent = T.reqSend;
$("foot").textContent = T.reqFoot;
document.title = T.reqTitle;
/* Areas, not doors. The endpoint returns building names only — this page is
the one an outsider might reach, and a door list is a map of the building. */
/* kiosk=1 means the keyholder handed their own phone over: no PIN prompt,
and the confirmation offers a fresh blank form rather than a dead end. */
const KIOSK = new URLSearchParams(location.search).get("kiosk") === "1";
(async () => {
try {
const r = await fetch("/api/request/areas").then((x) => x.json());
$("areas").innerHTML = (r.areas || []).map((a, n) =>
`<label class="area"><input type="checkbox" value="${esc(a)}" id="a${n}"><span>${esc(a)}</span></label>`
).join("");
if (r.pin) $("pinbox").style.display = "";
else if (KIOSK) $("foot").textContent = T.reqNoPinStaff;
} catch (e) { /* leave the section empty; the free-text field still works */ }
})();
function fail(m) {
const el = $("msg");
el.textContent = m;
el.classList.add("on");
if (el.scrollIntoView) el.scrollIntoView({ behavior: "smooth", block: "center" });
}
let busy = false;
$("send").addEventListener("click", async () => {
if (busy) return;
$("msg").classList.remove("on");
const name = $("f_name").value.trim();
if (!name) return fail(T.reqNeedName);
const areas = [...document.querySelectorAll("#areas input:checked")].map((i) => i.value);
if (!areas.length && !$("f_detail").value.trim()) return fail(T.reqNeedArea);
busy = true;
$("send").textContent = T.reqSending;
$("send").disabled = true;
try {
const res = await fetch("/api/request", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
dept: $("f_dept").value.trim(),
contact: $("f_contact").value.trim(),
areas,
detail: $("f_detail").value.trim(),
pin: $("f_pin").value.trim(),
website: $("f_website").value,
}),
});
if (res.ok) {
$("wrap").innerHTML = `<div class="req-done">
<div class="tickmark">✓</div>
<h2>${esc(T.reqOk)}</h2>
<p>${esc(KIOSK ? T.reqKioskDone : T.reqOkMore)}</p>
<button class="btn btn-g" onclick="location.reload()">${
esc(KIOSK ? T.reqKioskNext : T.reqAnother)}</button>
</div>`;
return;
}
const b = await res.json().catch(() => ({}));
fail(res.status === 429 ? T.reqTooMany
: res.status === 401 ? T.reqWrongPin
: (T.errors && T.errors[b.error]) || b.error || T.noServer);
} catch (e) {
fail(T.noServer);
}
busy = false;
$("send").textContent = T.reqSend;
$("send").disabled = false;
});
</script>
</body>
</html>

26
public/report.html Normal file
View file

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="pt">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Relatório</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans+Condensed:wght@600;700&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<div class="report">
<div class="rep-bar no-print">
<a class="mini" style="text-decoration:none;background:var(--card);color:var(--ink);border:1px solid var(--line)"
href="/" id="backlink">←</a>
<select id="view"></select>
<select id="who" style="display:none"></select>
<button class="mini" id="printbtn"></button>
</div>
<div id="out"></div>
</div>
<script src="/i18n.js"></script>
<script src="/report.js"></script>
</body>
</html>

234
public/report.js Normal file
View file

@ -0,0 +1,234 @@
/* Key register — printable reports.
Everything renders to plain HTML so the browser's own Print / Save as PDF
produces the document. No PDF library, nothing to keep up to date. */
"use strict";
let S = null, view = "out", whoId = null;
const $out = document.getElementById("out");
const $view = document.getElementById("view");
const $who = document.getElementById("who");
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
const fmt = (d) => { if (!d) return "—"; const [y, m, dd] = d.split("-"); return `${dd} ${T._months[+m - 1]} ${y}`; };
const days = (a, b) => Math.round((new Date(b) - new Date(a)) / 864e5);
const person = (id) => S.people.find((p) => p.id === +id);
const door = (id) => S.doors.find((d) => d.id === +id);
const cab = (id) => S.cabinets.find((c) => c.id === id) || { name: "—", full_name: "—" };
const doorName = (i) => (i.door_id ? door(i.door_id).name : i.zone);
const isLate = (i) => i.loan && i.loan.due_date && i.loan.due_date < S.today;
/* Hook order — the audit sheet is meant to be read down the cabinet. */
const byHook = (a, b) =>
(a.hook || "").localeCompare(b.hook || "", undefined, { numeric: true }) ||
a.tag.localeCompare(b.tag, undefined, { numeric: true });
function head(title, sub) {
const now = new Date();
return `<div class="rep-head">
<p class="eyebrow">${esc(T.brand)}</p>
<h1>${esc(title)}</h1>
<p class="rep-meta">${esc(T.repGenerated(fmt(S.today), now.toTimeString().slice(0, 5)))}${
sub ? " · " + esc(sub) : ""}</p></div>`;
}
const stat = (n, label) => `<div class="rep-stat"><b>${n}</b><span>${esc(label)}</span></div>`;
/* ---------------------------------------------------------------- issued */
function viewOut(onlyLate) {
const out = S.items.filter((i) => i.status === "out" && (!onlyLate || isLate(i)));
const byPerson = {};
out.forEach((i) => (byPerson[i.loan.person_id] = byPerson[i.loan.person_id] || []).push(i));
Object.values(byPerson).forEach((l) => l.sort(byHook));
const ids = Object.keys(byPerson).sort((a, b) =>
person(a).name.localeCompare(person(b).name, "pt"));
if (!out.length) return head(onlyLate ? T.repOverdue : T.repOut) + `<p>${esc(T.repNothing)}</p>`;
return head(onlyLate ? T.repOverdue : T.repOut) +
`<div class="rep-stats">
${stat(out.length, T.stKeysOut)}
${stat(ids.length, T.stPeople)}
${stat(S.items.filter(isLate).length, T.stOverdue)}
</div><div style="height:22px"></div>` +
ids.map((pid) => {
const p = person(pid), list = byPerson[pid];
return `<div class="rep-group">
<h2>${esc(p.name)}</h2>
<p class="who">${esc(p.dept)}${p.role ? " · " + esc(p.role) : ""}${
p.phone ? " · " + esc(p.phone) : ""}</p>
<table class="rep"><thead><tr>
<th>${esc(T.cTag)}</th><th>${esc(T.cDoor)}</th><th>${esc(T.cBuilding)}</th>
<th>${esc(T.cCabinet)}</th><th>${esc(T.cOut)}</th><th>${esc(T.cDue)}</th>
</tr></thead><tbody>
${list.map((i) => {
const late = isLate(i);
return `<tr>
<td class="num">${esc(i.tag)}</td>
<td>${esc(doorName(i))}</td>
<td>${esc(i.building)}</td>
<td>${esc(cab(i.cabinet_id).name)}</td>
<td>${fmt(i.loan.out_date)}</td>
<td class="${late ? "od" : ""}">${i.loan.due_date ? fmt(i.loan.due_date) : "—"}${
late ? ` (+${days(i.loan.due_date, S.today)})` : ""}</td></tr>`;
}).join("")}
</tbody></table></div>`;
}).join("") + signOff();
}
/* ------------------------------------------------- receipt for one person */
async function viewPerson(id) {
const p = person(id);
if (!p) return head(T.repReceiptTitle) + `<p>${esc(T.repChoosePerson)}</p>`;
let rows = [];
try { rows = await fetch("/api/report/person/" + id).then((r) => r.json()); } catch (e) { /* offline */ }
if (!rows.length) return head(T.repReceiptTitle, p.name) + `<p>${esc(T.repNothing)}</p>`;
return head(T.repReceiptTitle, `${p.name}${p.dept ? " · " + p.dept : ""}`) +
`<p style="font-size:14px;max-width:62ch">${esc(T.repReceiptIntro(rows.length))}</p>
<table class="rep" style="margin-top:14px"><thead><tr>
<th>${esc(T.cTag)}</th><th>${esc(T.cDoor)}</th><th>${esc(T.cBuilding)}</th>
<th>${esc(T.cCabinet)}</th><th>${esc(T.cOut)}</th><th>${esc(T.cDue)}</th>
</tr></thead><tbody>
${rows.map((h) => {
const late = h.due_date && h.due_date < S.today;
return `<tr>
<td class="num">${esc(h.tag)}</td><td>${esc(h.door || "")}</td>
<td>${esc(h.building)}</td><td>${esc(cab(h.cabinet_id).name)}</td>
<td>${fmt(h.out_date)}</td>
<td class="${late ? "od" : ""}">${h.due_date ? fmt(h.due_date) : "—"}</td></tr>`;
}).join("")}
</tbody></table>
<div class="sec" style="margin-top:24px">${esc(T.signature)}</div>
${rows.filter((h) => h.signature).map((h) => `<div class="sig-block">
<div class="sig-cap">${esc(h.tag)} · ${esc(T.repSignedOn(fmt(h.out_date)))}</div>
<img src="${esc(h.signature)}" alt=""></div>`).join("")
|| `<p style="font-size:13px;color:var(--slate)">${esc(T.repNoSig)}</p>`}
${signOff(p.name)}`;
}
/* ------------------------------------------------------------- inventory */
function viewInventory() {
const live = S.items.filter((i) => i.status !== "retired");
const groups = {};
S.cabinets.forEach((c) => {
const list = live.filter((i) => i.cabinet_id === c.id).sort(byHook);
if (list.length) groups[c.id] = list;
});
const statusWord = (i) => (T.statuses && T.statuses[i.status]) || i.status;
return head(T.repInventory) +
`<div class="rep-stats">
${stat(S.items.length, T.stTotal)}
${stat(S.items.filter((i) => i.status === "in").length, T.stInCab)}
${stat(S.items.filter((i) => i.status === "out").length, T.stKeysOut)}
${stat(S.items.filter((i) => i.status === "reserve").length, T.stReserve)}
</div><div style="height:22px"></div>` +
S.cabinets.filter((c) => groups[c.id]).map((c) => `<div class="rep-group">
<h2>${esc(c.full_name)}</h2>
<p class="who">${esc(c.note || "")}</p>
<table class="rep"><thead><tr>
<th>${esc(T.cTag)}</th><th>${esc(T.cDoor)}</th><th>${esc(T.cBuilding)}</th>
<th>${esc(T.cHook)}</th><th>${esc(T.cStatus)}</th><th>${esc(T.cPerson)}</th>
</tr></thead><tbody>
${groups[c.id].map((i) => `<tr>
<td class="num">${esc(i.tag)}</td><td>${esc(doorName(i))}</td>
<td>${esc(i.building)}</td><td>${esc(i.hook)}</td>
<td class="${isLate(i) ? "od" : ""}">${esc(statusWord(i))}</td>
<td>${i.loan && i.status === "out" ? esc(person(i.loan.person_id).name) : ""}</td></tr>`).join("")}
</tbody></table></div>`).join("");
}
/* ------------------------------------------- audit sheet, one per cabinet */
function viewAudit() {
const cabs = whoId && whoId !== "ALL" ? S.cabinets.filter((c) => c.id === whoId) : S.cabinets;
return head(T.repAudit) +
`<p style="font-size:13.5px;max-width:62ch">${esc(T.repAuditIntro)}</p>` +
cabs.map((c) => {
const list = S.items.filter((i) => i.cabinet_id === c.id && i.status !== "retired").sort(byHook);
if (!list.length) return "";
return `<div class="rep-group">
<h2>${esc(c.full_name)}</h2>
<p class="who">${esc(c.note || "")}</p>
<table class="rep"><thead><tr>
<th style="width:34px">${esc(T.cCheck)}</th><th>${esc(T.cTag)}</th>
<th>${esc(T.cHook)}</th><th>${esc(T.cDoor)}</th>
<th>${esc(T.cStatus)}</th><th>${esc(T.cPerson)}</th>
</tr></thead><tbody>
${list.map((i) => `<tr>
<td><span class="tick"></span></td>
<td class="num">${esc(i.tag)}</td><td>${esc(i.hook)}</td>
<td>${esc(doorName(i))}</td>
<td>${esc((T.statuses && T.statuses[i.status]) || i.status)}</td>
<td>${i.loan && i.status === "out" ? esc(person(i.loan.person_id).name) : ""}</td></tr>`).join("")}
</tbody></table></div>`;
}).join("") +
`<div class="sign-off">
<div><div class="sign-line"></div><div class="sign-lab">${esc(T.repAuditBy)}</div></div>
<div><div class="sign-line"></div><div class="sign-lab">${esc(T.repDate)}</div></div>
</div>
<div style="margin-top:20px"><div class="sign-lab">${esc(T.repNotes)}</div>
<div class="sign-line"></div><div class="sign-line"></div></div>`;
}
function signOff(holder) {
return `<div class="sign-off">
${holder ? `<div><div class="sign-line"></div>
<div class="sign-lab">${esc(T.repHolder)} — ${esc(holder)}</div></div>` : ""}
<div><div class="sign-line"></div><div class="sign-lab">${esc(T.repResponsible)}</div></div>
<div><div class="sign-line"></div><div class="sign-lab">${esc(T.repDate)}</div></div>
</div>`;
}
/* ------------------------------------------------------------------ shell */
async function render() {
$out.innerHTML = "";
const isPerson = view === "person";
const isAudit = view === "audit";
$who.style.display = isPerson || isAudit ? "" : "none";
if (isPerson) {
$who.innerHTML = `<option value="">${esc(T.repChoosePerson)}</option>` +
S.people.map((p) => `<option value="${p.id}"${String(whoId) === String(p.id) ? " selected" : ""}>${esc(p.name)}</option>`).join("");
$out.innerHTML = await viewPerson(whoId);
} else if (isAudit) {
$who.innerHTML = `<option value="ALL">${esc(T.repAllCabinets)}</option>` +
S.cabinets.map((c) => `<option value="${esc(c.id)}"${whoId === c.id ? " selected" : ""}>${esc(c.full_name)}</option>`).join("");
$out.innerHTML = viewAudit();
} else if (view === "overdue") {
$out.innerHTML = viewOut(true);
} else if (view === "inventory") {
$out.innerHTML = viewInventory();
} else {
$out.innerHTML = viewOut(false);
}
document.title = `${T.brand} — ${$view.selectedOptions[0].textContent}`;
}
(async () => {
try {
S = await fetch("/api/state").then((r) => r.json());
} catch (e) {
$out.innerHTML = `<p>${esc(T ? T.noServer : "offline")}</p>`;
return;
}
setLang(S.lang);
document.getElementById("printbtn").textContent = T.printPdf;
document.getElementById("backlink").title = T.back;
$view.innerHTML = [["out", T.repOut], ["overdue", T.repOverdue], ["person", T.repPerson],
["inventory", T.repInventory], ["audit", T.repAudit]]
.map(([v, l]) => `<option value="${v}">${esc(l)}</option>`).join("");
/* deep links: /report?view=person&id=3 */
const q = new URLSearchParams(location.search);
if (q.get("view")) view = q.get("view");
if (q.get("id")) whoId = q.get("id");
$view.value = view;
$view.onchange = () => { view = $view.value; whoId = view === "audit" ? "ALL" : null; render(); };
$who.onchange = () => { whoId = $who.value; render(); };
document.getElementById("printbtn").onclick = () => window.print();
await render();
})();

514
public/styles.css Normal file
View file

@ -0,0 +1,514 @@
:root{
--ink:#131E1B; --ink-2:#20302B; --ink-3:#2C3F39;
--slate:#5C6B65; --slate-2:#8A968F; --line:#DCDFD9;
--paper:#F4F5F2; --card:#FFFFFF;
--verdigris:#2E6E60; --verdigris-soft:#E4EFEB;
--brass:#A87F3C; --brass-soft:#F6EEDF;
--violet:#5F55A0; --steel:#54646D; --steel-soft:#E9EDEF;
--late:#AE4034; --late-soft:#F8E9E7; --r:10px;
}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{margin:0;padding:0}
body{background:var(--paper);color:var(--ink);font-family:"IBM Plex Sans",system-ui,sans-serif;
font-size:15px;line-height:1.45;-webkit-font-smoothing:antialiased}
button{font:inherit;color:inherit;border:0;background:none;cursor:pointer}
textarea,input,select{font:inherit}
:focus-visible{outline:2px solid var(--verdigris);outline-offset:2px;border-radius:4px}
.app{max-width:560px;margin:0 auto;min-height:100vh;display:flex;flex-direction:column}
header{background:var(--ink);color:#fff;padding:16px 18px 14px;position:relative}
.eyebrow{font-family:"IBM Plex Mono",monospace;font-size:10px;letter-spacing:.18em;
text-transform:uppercase;color:var(--slate-2);margin:0 0 3px}
h1{font-family:"IBM Plex Sans Condensed",sans-serif;font-weight:700;font-size:23px;margin:0;text-transform:uppercase}
/* One flex row of equal boxes. Positioning each icon with its own `right`
offset gave uneven gaps, because the glyphs are not the same width — and a
mix of emoji and text glyphs also renders at different sizes and colours.
Matched SVGs in fixed boxes solve both. */
.tools{position:absolute;top:12px;right:12px;display:flex;gap:2px}
.tool{
width:36px;height:36px;display:flex;align-items:center;justify-content:center;
border-radius:8px;color:#7A8A83;transition:color .12s,background .12s
}
.tool svg{
width:19px;height:19px;fill:none;stroke:currentColor;stroke-width:1.6;
stroke-linecap:round;stroke-linejoin:round
}
.tool:hover{color:#DDE4E0;background:rgba(255,255,255,.07)}
.tool:active{background:rgba(255,255,255,.12)}
/* the admin and report pages still use a single back/settings link */
.gear{position:absolute;top:14px;right:14px;color:#7A8A83;font-size:19px;padding:6px}
.counts{display:flex;gap:7px;margin-top:13px;flex-wrap:wrap}
.count{font-family:"IBM Plex Mono",monospace;font-size:11px;padding:5px 9px;border-radius:100px;
background:var(--ink-3);color:#D7DEDA;display:flex;align-items:center;gap:6px}
.count b{font-weight:600;color:#fff}
.count.warn{background:#43221D;color:#F0BDB6} .count.warn b{color:#fff}
.swatch{width:8px;height:8px;border-radius:50%}
.swatch.out{background:var(--brass)} .swatch.in{background:var(--verdigris)} .swatch.res{background:var(--steel)}
.scroll{flex:1;overflow-y:auto;padding-bottom:80px}
.board{background:var(--ink-2);padding:14px 14px 20px;min-height:60vh}
/* Cabinets wrap onto as many rows as they need. A horizontal scroller hides
cabinets behind the edge, on the one screen you look at most. */
.cabs{
display:grid;grid-template-columns:repeat(auto-fit,minmax(96px,1fr));
gap:6px;margin-bottom:11px
}
.cab{
padding:8px 10px;border-radius:8px;background:var(--ink);border:1px solid var(--ink-3);
text-align:left;min-width:0
}
.cab[aria-pressed="true"]{background:#fff;border-color:#fff}
.cab .cn{
display:block;font-family:"IBM Plex Sans Condensed",sans-serif;font-weight:700;
font-size:12.5px;text-transform:uppercase;color:#CBD5D0;letter-spacing:.01em;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis
}
.cab[aria-pressed="true"] .cn{color:var(--ink)}
.cab .cs{
display:block;font-family:"IBM Plex Mono",monospace;font-size:9.5px;color:#7A8A83;
margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis
}
.cab[aria-pressed="true"] .cs{color:var(--slate)}
.cab .cs .o{color:var(--brass)}
/* wider screens fit more per row without the tiles becoming huge */
@media (min-width:460px){
.cabs{grid-template-columns:repeat(auto-fit,minmax(112px,1fr))}
.cab{padding:9px 12px}
.cab .cn{font-size:13px}
.cab .cs{font-size:10px}
}
.where{font-size:11.5px;color:#8A968F;margin:0 0 12px;padding-left:2px;line-height:1.5}
.filters{display:flex;gap:6px;margin-bottom:14px;flex-wrap:wrap}
.chip{font-size:12px;padding:6px 11px;border-radius:100px;background:transparent;color:#9FADA7;border:1px solid var(--ink-3)}
.chip[aria-pressed="true"]{background:#fff;color:var(--ink);border-color:#fff;font-weight:500}
.rail{margin-bottom:15px}
.rail-label{font-family:"IBM Plex Mono",monospace;font-size:10px;letter-spacing:.14em;text-transform:uppercase;
color:var(--slate-2);padding-bottom:6px;margin-bottom:9px;border-bottom:1px solid var(--ink-3);
display:flex;justify-content:space-between;align-items:baseline;gap:10px}
.rail-label span{color:#6E7D77;flex:none}
.pegs{display:grid;grid-template-columns:repeat(auto-fill,minmax(46px,1fr));gap:7px}
.peg{aspect-ratio:1;display:flex;flex-direction:column;align-items:center;justify-content:center;
font-family:"IBM Plex Mono",monospace;font-size:11.5px;font-weight:500;border:1.5px solid transparent;
position:relative;transition:transform .1s}
.peg:active{transform:scale(.93)}
/* shape = what it is, fill = where it is. Both now come from the type record
rather than being hardcoded for two kinds. */
.peg.shape-circle{border-radius:50%}
.peg.shape-rounded{border-radius:5px}
.peg.shape-square{border-radius:0}
.peg.st-in{background:var(--k,var(--verdigris));color:#fff;border-color:var(--k,var(--verdigris))}
.peg.st-reserve{background:#48585F;color:#C6D2D7;border-color:#5E7079}
.peg.st-out{background:transparent;color:var(--brass);border-color:var(--brass)}
.peg.st-lost,.peg.st-retired{background:transparent;color:#6E7D77;border-color:#4A5A54;border-style:dashed}
.peg.late{color:#E8907F;border-color:var(--late);border-width:2px}
.peg .flag{position:absolute;top:-2px;right:-2px;width:9px;height:9px;border-radius:50%;
background:var(--late);border:1.5px solid var(--ink-2)}
.peg .memo{position:absolute;bottom:-1px;right:2px;font-size:8px;opacity:.85}
.peg small{font-size:8px;opacity:.75;margin-top:1px}
.legend{display:flex;gap:13px;flex-wrap:wrap;margin-top:4px;font-family:"IBM Plex Mono",monospace;
font-size:10px;color:#8A968F}
.legend i{display:inline-block;width:11px;height:11px;margin-right:5px;vertical-align:-2px;border:1.5px solid}
.lg-in{background:var(--verdigris);border-color:var(--verdigris);border-radius:50%}
.lg-out{border-color:var(--brass);border-radius:50%}
.lg-res{background:#48585F;border-color:#5E7079;border-radius:50%}
.lg-late{border-color:var(--late);border-radius:50%;border-width:2px}
.lg-kind{border-radius:3px}
.pane{padding:14px}
.search,.field{width:100%;padding:11px 13px;border:1px solid var(--line);border-radius:var(--r);
background:var(--card);font-size:15px;margin-bottom:12px}
.search:focus,.field:focus{outline:none;border-color:var(--verdigris);box-shadow:0 0 0 3px var(--verdigris-soft)}
.row{display:flex;align-items:center;gap:12px;width:100%;text-align:left;background:var(--card);
border:1px solid var(--line);border-radius:var(--r);padding:11px 13px;margin-bottom:7px}
.row:active{background:#FAFBF9}
.row.late{border-color:#E4BDB6;background:#FEFAF9}
.badge{flex:none;width:42px;height:42px;display:flex;align-items:center;justify-content:center;
font-family:"IBM Plex Mono",monospace;font-size:11px;font-weight:600;border:1.5px solid}
.badge.shape-circle{border-radius:50%}
.badge.shape-rounded{border-radius:6px}
.badge.shape-square{border-radius:0}
.badge.st-in{background:var(--k,var(--verdigris));border-color:var(--k,var(--verdigris));color:#fff}
.badge.st-reserve{background:var(--steel);border-color:var(--steel);color:#fff}
.badge.st-out{border-color:var(--brass);color:var(--brass)}
.badge.st-lost,.badge.st-retired{border-color:#B9BEB8;color:#909790;border-style:dashed}
.badge.person{background:var(--paper);border-color:var(--line);color:var(--slate);border-radius:50%;
font-family:"IBM Plex Sans Condensed",sans-serif;font-size:14px}
.badge.door{background:var(--ink);border-color:var(--ink);color:#fff;border-radius:8px;
font-family:"IBM Plex Sans Condensed",sans-serif;font-size:15px}
.row-main{flex:1;min-width:0}
.row-t{font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.row-s{font-size:12.5px;color:var(--slate);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.row-s .held{color:var(--brass);font-weight:500} .row-s .od{color:var(--late);font-weight:500}
.row-n{flex:none;font-family:"IBM Plex Mono",monospace;font-size:11px;color:var(--slate-2);
text-align:right;line-height:1.3}
.dots{display:flex;gap:3px;justify-content:flex-end;margin-top:3px;flex-wrap:wrap;max-width:70px}
.dot{width:9px;height:9px;border-radius:50%;border:1.5px solid}
.dot.in{background:var(--k,var(--verdigris));border-color:var(--k,var(--verdigris))}
.dot.out{border-color:var(--brass)}
.dot.reserve{background:var(--steel);border-color:var(--steel)}
.dot.lost,.dot.retired{border-color:#C3C8C2;border-style:dashed}
.sec{font-family:"IBM Plex Mono",monospace;font-size:10px;letter-spacing:.14em;text-transform:uppercase;
color:var(--slate);margin:18px 0 8px;padding-bottom:5px;border-bottom:1px solid var(--line)}
.sec:first-child{margin-top:0}
.empty{text-align:center;padding:32px 22px;color:var(--slate);font-size:14px;border:1px dashed var(--line);
border-radius:var(--r);background:var(--card)}
.log{background:var(--card);border:1px solid var(--line);border-radius:var(--r);overflow:hidden}
.log-row{display:flex;gap:11px;padding:11px 13px;border-bottom:1px solid var(--line);align-items:baseline}
.log-row:last-child{border-bottom:0}
.log-d{font-family:"IBM Plex Mono",monospace;font-size:10.5px;color:var(--slate-2);flex:none;width:52px}
.log-b{flex:1;font-size:13.5px;min-width:0}
.log-tag{font-family:"IBM Plex Mono",monospace;font-weight:500}
.memo-line{color:var(--slate);font-size:12px;font-style:italic;margin-top:2px}
.dir{font-family:"IBM Plex Mono",monospace;font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;
padding:2px 6px;border-radius:3px;flex:none}
.dir.out{background:var(--brass-soft);color:var(--brass)}
.dir.in{background:var(--verdigris-soft);color:var(--verdigris)}
.dir.late{background:var(--late-soft);color:var(--late)}
.scrim{position:fixed;inset:0;background:rgba(10,18,16,.5);z-index:40;opacity:0;pointer-events:none;transition:opacity .2s}
.scrim.on{opacity:1;pointer-events:auto}
.sheet{position:fixed;left:50%;bottom:0;transform:translate(-50%,100%);width:100%;max-width:560px;
max-height:90vh;overflow-y:auto;z-index:50;background:var(--paper);border-radius:16px 16px 0 0;
transition:transform .24s cubic-bezier(.32,.72,0,1);padding:0 16px calc(20px + env(safe-area-inset-bottom))}
.sheet.on{transform:translate(-50%,0)}
.grab{width:34px;height:4px;background:#C8CEC8;border-radius:3px;margin:9px auto 4px}
.sheet h2{font-family:"IBM Plex Sans Condensed",sans-serif;font-weight:700;font-size:24px;margin:8px 0 2px;text-transform:uppercase}
.sheet .sub{color:var(--slate);font-size:13.5px;margin:0 0 14px}
.status-line{padding:11px 13px;border-radius:var(--r);margin-bottom:14px;font-size:13.5px}
.status-line.out{background:var(--brass-soft);color:#6E5223}
.status-line.in{background:var(--verdigris-soft);color:#1D4A40}
.status-line.reserve{background:var(--steel-soft);color:#33454D}
.status-line.late{background:var(--late-soft);color:#7A2C23}
.status-line.lost,.status-line.retired{background:#F0F0EE;color:var(--slate)}
.status-line b{font-weight:600}
.facts{background:var(--card);border:1px solid var(--line);border-radius:var(--r);margin-bottom:14px}
.fact{display:flex;justify-content:space-between;gap:14px;padding:9px 13px;border-bottom:1px solid var(--line);font-size:13.5px}
.fact:last-child{border-bottom:0}
.fact dt{color:var(--slate);margin:0;flex:none} .fact dd{margin:0;text-align:right;font-weight:500;min-width:0}
.mono{font-family:"IBM Plex Mono",monospace;font-size:12.5px}
.tiny{display:block;font-weight:400;font-size:11.5px;color:var(--slate-2)}
.actions{display:flex;gap:8px;margin-bottom:16px}
.btn{flex:1;padding:13px;border-radius:var(--r);font-weight:600;font-size:15px;text-align:center}
.btn:active{filter:brightness(.92)} .btn:disabled{opacity:.45}
.btn-p{background:var(--verdigris);color:#fff}
.btn-o{background:var(--brass);color:#fff}
.btn-d{background:var(--late);color:#fff}
.btn-g{background:var(--card);color:var(--ink);border:1px solid var(--line)}
.note-box{background:var(--card);border:1px solid var(--line);border-radius:var(--r);padding:11px 13px;margin-bottom:14px}
.note-box textarea{width:100%;border:0;resize:vertical;min-height:52px;font-size:14px;background:transparent}
.note-box textarea:focus{outline:none}
.note-box .cap{font-family:"IBM Plex Mono",monospace;font-size:9.5px;letter-spacing:.12em;
text-transform:uppercase;color:var(--slate-2);margin-bottom:6px;display:block}
.mini{padding:8px 14px;border-radius:8px;font-size:13px;font-weight:600;background:var(--ink);color:#fff}
.sigpad{background:#fff;border:1px solid var(--line);border-radius:var(--r);margin-bottom:8px;position:relative;overflow:hidden}
.sigpad canvas{display:block;width:100%;height:150px;touch-action:none}
.sigpad .hint{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#B7BEB8;font-size:13px;pointer-events:none}
.sigline{position:absolute;left:16px;right:16px;bottom:30px;border-bottom:1px dashed #D4D9D3;pointer-events:none}
.sigshot{background:#fff;border:1px solid var(--line);border-radius:8px;padding:5px;margin-top:6px}
.sigshot img{display:block;width:100%;max-width:220px}
.due-opts{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px}
.due{padding:8px 12px;border-radius:100px;border:1px solid var(--line);background:var(--card);font-size:13px;color:var(--slate)}
.due[aria-pressed="true"]{background:var(--ink);color:#fff;border-color:var(--ink);font-weight:500}
.tabs{position:fixed;bottom:0;left:50%;transform:translateX(-50%);width:100%;max-width:560px;
background:rgba(255,255,255,.94);backdrop-filter:blur(12px);border-top:1px solid var(--line);
display:flex;z-index:30;padding-bottom:env(safe-area-inset-bottom)}
.tab{flex:1;padding:9px 2px 11px;text-align:center;color:var(--slate-2);font-size:10px;font-weight:500}
.tab .ic{display:flex;align-items:center;justify-content:center;margin-bottom:3px}
.tab .ic svg{
width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:1.6;
stroke-linecap:round;stroke-linejoin:round
}
.tab[aria-selected="true"]{color:var(--verdigris)}
.note{font-size:11.5px;color:var(--slate);text-align:center;padding:14px 20px 4px;line-height:1.5}
.toast{position:fixed;left:50%;bottom:78px;transform:translate(-50%,20px);background:var(--ink);color:#fff;
padding:10px 18px;border-radius:100px;font-size:13.5px;z-index:60;opacity:0;pointer-events:none;
transition:opacity .2s,transform .2s;max-width:90vw;text-align:center}
.toast.on{opacity:1;transform:translate(-50%,0)}
.toast.err{background:var(--late)}
/* ---- admin ---- */
.admin{max-width:760px;margin:0 auto;padding-bottom:40px}
.adm-nav{display:flex;gap:6px;flex-wrap:wrap;padding:12px 14px 0}
.adm-nav button{padding:8px 13px;border-radius:100px;border:1px solid var(--line);
background:var(--card);font-size:13px;color:var(--slate)}
.adm-nav button[aria-pressed="true"]{background:var(--ink);color:#fff;border-color:var(--ink);font-weight:500}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.lab{display:block;font-family:"IBM Plex Mono",monospace;font-size:9.5px;letter-spacing:.12em;
text-transform:uppercase;color:var(--slate-2);margin-bottom:4px}
.card{background:var(--card);border:1px solid var(--line);border-radius:var(--r);padding:14px;margin-bottom:12px}
.card h3{font-family:"IBM Plex Sans Condensed",sans-serif;text-transform:uppercase;font-size:16px;margin:0 0 10px}
table{width:100%;border-collapse:collapse;font-size:13.5px}
th{text-align:left;font-family:"IBM Plex Mono",monospace;font-size:9.5px;letter-spacing:.1em;
text-transform:uppercase;color:var(--slate-2);padding:6px 8px;border-bottom:1px solid var(--line)}
td{padding:7px 8px;border-bottom:1px solid var(--line)}
tr:last-child td{border-bottom:0}
.warnbox{background:var(--late-soft);border:1px solid #E4BDB6;color:#7A2C23;
border-radius:var(--r);padding:12px 14px;margin-bottom:12px;font-size:13.5px}
@media (prefers-reduced-motion:reduce){*{transition:none!important}}
/* ---- login ---------------------------------------------------------------
Full-bleed dark, with the peg board itself as the backdrop — the same
motif the app opens onto, so the first screen already looks like the thing
it protects. */
.login{
min-height:100vh;min-height:100dvh;display:flex;align-items:center;justify-content:center;
padding:24px;background:var(--ink);position:relative;overflow:hidden
}
#pegfield{position:absolute;inset:0;width:100%;height:100%;opacity:.28;pointer-events:none}
.login-card{
position:relative;z-index:1;width:100%;max-width:330px;text-align:center
}
.login-mark{
width:54px;height:54px;margin:0 auto 18px;border-radius:50%;
border:2px solid var(--verdigris);display:flex;align-items:center;justify-content:center;
background:rgba(46,110,96,.14)
}
.login-mark svg{width:26px;height:26px;stroke:var(--verdigris);fill:none;stroke-width:1.9;
stroke-linecap:round;stroke-linejoin:round}
.login-card .eyebrow{margin-bottom:5px}
.login-card h1{color:#fff;font-size:26px;margin-bottom:8px}
.login-sub{color:#8A968F;font-size:13.5px;margin:0 0 22px}
.pw-wrap{position:relative;margin-bottom:10px}
.pw{
width:100%;padding:14px 46px 14px 16px;border-radius:var(--r);
background:var(--ink-2);border:1px solid var(--ink-3);color:#fff;font-size:16px;
font-family:"IBM Plex Mono",monospace;letter-spacing:.08em;text-align:center;
transition:border-color .15s,box-shadow .15s
}
.pw::placeholder{color:#5E6E68;letter-spacing:0;font-family:"IBM Plex Sans",sans-serif}
.pw:focus{outline:none;border-color:var(--verdigris);box-shadow:0 0 0 3px rgba(46,110,96,.25)}
.pw-toggle{
position:absolute;right:6px;top:50%;transform:translateY(-50%);
width:36px;height:36px;border-radius:8px;color:#7A8A83;font-size:15px;
display:flex;align-items:center;justify-content:center
}
.pw-toggle:hover{color:#B9C4BF}
.login .btn-p{width:100%;padding:14px}
.login-err{
color:#E8907F;font-size:13px;min-height:19px;margin:12px 0 0;
opacity:0;transition:opacity .15s
}
.login-err.on{opacity:1}
.shake{animation:shake .32s cubic-bezier(.36,.07,.19,.97)}
@keyframes shake{
10%,90%{transform:translateX(-2px)} 20%,80%{transform:translateX(4px)}
30%,50%,70%{transform:translateX(-7px)} 40%,60%{transform:translateX(7px)}
}
.login-foot{
position:absolute;bottom:22px;left:0;right:0;text-align:center;z-index:1;
color:#4E5E58;font-size:11px;font-family:"IBM Plex Mono",monospace;letter-spacing:.05em;
padding:0 24px
}
@media (prefers-reduced-motion:reduce){.shake{animation:none}}
/* ---- reports --------------------------------------------------------------
Screen styling stays close to the app; print styling drops to plain black
on white, because these end up in a folder or taped inside a cabinet. */
.report{max-width:900px;margin:0 auto;padding:0 18px 60px}
.rep-bar{
display:flex;gap:8px;flex-wrap:wrap;align-items:center;
padding:14px 0;position:sticky;top:0;background:var(--paper);z-index:5;
border-bottom:1px solid var(--line);margin-bottom:20px
}
.rep-bar select,.rep-bar .mini{margin:0}
.rep-bar select{
padding:9px 12px;border:1px solid var(--line);border-radius:8px;
background:var(--card);font-size:14px
}
.rep-head{margin-bottom:22px}
.rep-head h1{font-size:26px;margin:0 0 4px}
.rep-meta{
font-family:"IBM Plex Mono",monospace;font-size:11px;color:var(--slate);
letter-spacing:.03em
}
.rep-stats{display:flex;gap:20px;flex-wrap:wrap;margin:14px 0 0;padding:12px 0;
border-top:1px solid var(--line);border-bottom:1px solid var(--line)}
.rep-stat b{display:block;font-family:"IBM Plex Sans Condensed",sans-serif;
font-size:22px;line-height:1.1}
.rep-stat span{font-size:11px;color:var(--slate);text-transform:uppercase;letter-spacing:.08em;
font-family:"IBM Plex Mono",monospace}
.rep-group{margin-bottom:26px;break-inside:avoid}
.rep-group h2{
font-family:"IBM Plex Sans Condensed",sans-serif;font-size:17px;text-transform:uppercase;
margin:0 0 2px;padding-bottom:5px;border-bottom:2px solid var(--ink)
}
.rep-group .who{font-size:12.5px;color:var(--slate);margin:0 0 9px}
table.rep{width:100%;border-collapse:collapse;font-size:13px}
table.rep th{
text-align:left;font-family:"IBM Plex Mono",monospace;font-size:9.5px;letter-spacing:.1em;
text-transform:uppercase;color:var(--slate);padding:5px 7px;border-bottom:1px solid var(--ink)
}
table.rep td{padding:6px 7px;border-bottom:1px solid var(--line);vertical-align:top}
table.rep tr{break-inside:avoid}
table.rep .num{font-family:"IBM Plex Mono",monospace;font-weight:600;white-space:nowrap}
table.rep .od{color:var(--late);font-weight:600}
.tick{display:inline-block;width:13px;height:13px;border:1.4px solid #556;border-radius:3px}
.sig-block{
break-inside:avoid;border:1px solid var(--line);border-radius:8px;padding:10px;
margin-bottom:10px;background:var(--card)
}
.sig-block img{display:block;max-width:230px;max-height:80px}
.sig-cap{font-family:"IBM Plex Mono",monospace;font-size:10px;color:var(--slate);margin-bottom:6px}
.sign-off{margin-top:34px;display:flex;gap:40px;flex-wrap:wrap;break-inside:avoid}
.sign-off div{flex:1;min-width:200px}
.sign-line{border-bottom:1px solid var(--ink);height:38px;margin-bottom:5px}
.sign-lab{font-size:11px;color:var(--slate);font-family:"IBM Plex Mono",monospace}
@media print{
@page{size:A4;margin:14mm 12mm}
html,body{background:#fff!important;color:#000!important}
.rep-bar,.no-print{display:none!important}
.report{max-width:none;padding:0}
.rep-head h1{font-size:20px}
table.rep{font-size:10.5pt}
table.rep thead{display:table-header-group} /* repeat headers across pages */
.rep-group{page-break-inside:avoid}
a{text-decoration:none;color:#000}
.rep-stat b{font-size:16pt}
}
/* ---- access request form -------------------------------------------------
The only page an outsider may ever see, so it is calmer and plainer than
the rest of the app: no peg board, no counts, nothing about the building. */
.req-page{min-height:100vh;background:var(--paper)}
.req-top{background:var(--ink);color:#fff;padding:26px 20px 24px}
.req-top .inner{max-width:520px;margin:0 auto}
.req-top h1{color:#fff;font-size:24px;margin:2px 0 10px}
.req-top p{color:#9FADA7;font-size:13.5px;line-height:1.55;margin:0;max-width:46ch}
.req-wrap{max-width:520px;margin:0 auto;padding:20px 16px 60px}
.req-field{margin-bottom:16px}
.req-field .lab{margin-bottom:6px}
.req-field input[type=text],.req-field textarea{
width:100%;padding:12px 13px;border:1px solid var(--line);border-radius:var(--r);
background:var(--card);font-size:16px
}
.req-field textarea{min-height:86px;resize:vertical}
.req-field input:focus,.req-field textarea:focus{
outline:none;border-color:var(--verdigris);box-shadow:0 0 0 3px var(--verdigris-soft)
}
.req-hint{font-size:12px;color:var(--slate);margin:5px 0 0}
.areas{display:flex;flex-direction:column;gap:7px}
.area{
display:flex;align-items:center;gap:11px;padding:12px 13px;background:var(--card);
border:1px solid var(--line);border-radius:var(--r);cursor:pointer;font-size:15px
}
.area:has(input:checked){border-color:var(--verdigris);background:var(--verdigris-soft)}
.area input{width:19px;height:19px;accent-color:var(--verdigris);flex:none}
.pin-row{display:flex;gap:10px;align-items:flex-start}
.pin-row .req-field{flex:1;margin-bottom:0}
.req-msg{
padding:12px 14px;border-radius:var(--r);font-size:13.5px;margin-bottom:14px;display:none
}
.req-msg.on{display:block}
.req-msg.bad{background:var(--late-soft);color:#7A2C23}
.req-done{
text-align:center;padding:52px 20px;max-width:400px;margin:0 auto
}
.req-done .tickmark{
width:60px;height:60px;border-radius:50%;background:var(--verdigris-soft);
color:var(--verdigris);display:flex;align-items:center;justify-content:center;
font-size:30px;margin:0 auto 18px
}
.req-done h2{font-family:"IBM Plex Sans Condensed",sans-serif;text-transform:uppercase;
font-size:22px;margin:0 0 8px}
.req-done p{color:var(--slate);font-size:14px;line-height:1.6;margin:0 0 22px}
.req-foot{
text-align:center;font-size:11.5px;color:var(--slate-2);margin-top:26px;
font-family:"IBM Plex Mono",monospace
}
.hp{position:absolute;left:-9999px;width:1px;height:1px;opacity:0}
/* request inbox inside the app */
.req-card{
background:var(--card);border:1px solid var(--line);border-left:3px solid var(--brass);
border-radius:var(--r);padding:12px 13px;margin-bottom:8px
}
.req-card.done{border-left-color:var(--line);opacity:.62}
.req-card h4{margin:0 0 2px;font-size:15px;font-weight:600}
.req-card .meta{font-family:"IBM Plex Mono",monospace;font-size:10.5px;color:var(--slate-2)}
.req-card .areas-line{font-size:13px;color:var(--brass);font-weight:500;margin:6px 0 0}
.req-card .detail{font-size:13.5px;color:var(--slate);margin:6px 0 0;line-height:1.5}
.badge-count{
background:var(--brass);color:#fff;border-radius:100px;padding:1px 7px;
font-size:11px;font-weight:600;margin-left:7px;font-family:"IBM Plex Sans",sans-serif
}
/* ---- board fills its column, so the dark panel never stops mid-screen ---- */
.scroll{display:flex;flex-direction:column}
.board{flex:1 0 auto}
.board .note{color:#77857F}
/* ---------------------------------------------------------------------------
Desktop. Same markup, different shape: the bottom tab bar becomes a side
rail and the content gets the width it was already capable of using.
--------------------------------------------------------------------------- */
@media (min-width:900px){
body{background:var(--paper)}
.app{
max-width:1120px;min-height:100vh;
display:grid;
grid-template-columns:212px minmax(0,1fr);
grid-template-rows:auto minmax(0,1fr);
border-left:1px solid var(--line);border-right:1px solid var(--line)
}
header{grid-column:1 / -1;grid-row:1;padding:20px 26px 18px}
header h1{font-size:26px}
.tools{top:18px;right:18px}
.gear{top:20px}
/* side rail */
.tabs{
position:static;transform:none;grid-column:1;grid-row:2;
max-width:none;width:auto;flex-direction:column;align-items:stretch;
background:transparent;backdrop-filter:none;
border-top:0;border-right:1px solid var(--line);
padding:16px 12px 24px;gap:2px;
justify-content:flex-start;align-content:flex-start
}
.tab{
/* the mobile bar sets flex:1 so five tabs share the width; in a vertical
rail that makes each item stretch to fill the whole column height */
flex:0 0 auto;
display:flex;align-items:center;gap:11px;
padding:11px 13px;border-radius:8px;text-align:left;font-size:14px;
transition:background .12s
}
.tab:hover{background:#EDEFEA}
.tab .ic{margin:0;width:20px;flex:none}
.tab[aria-selected="true"]{background:var(--verdigris-soft);color:var(--verdigris);font-weight:600}
.scroll{grid-column:2;grid-row:2;padding-bottom:0;overflow-y:auto}
.board{padding:20px 22px 26px}
.pane{padding:20px 22px 40px;max-width:720px}
/* more pegs per row when there is room for them */
.pegs{grid-template-columns:repeat(auto-fill,minmax(52px,1fr));gap:8px}
.cabs{grid-template-columns:repeat(auto-fit,minmax(132px,1fr));gap:8px}
/* the bottom sheet becomes a centred dialog */
.sheet{
top:50%;bottom:auto;left:50%;
transform:translate(-50%,-46%) scale(.97);
width:min(560px,92vw);max-height:86vh;border-radius:14px;
box-shadow:0 24px 70px rgba(10,18,16,.32);
padding:0 22px 24px;opacity:0;transition:opacity .16s,transform .16s
}
.sheet.on{transform:translate(-50%,-50%) scale(1);opacity:1}
.grab{display:none}
.sheet h2{margin-top:20px}
.toast{bottom:26px}
/* wider forms and tables have room to breathe */
.admin{max-width:1000px}
.grid2{gap:14px}
}
@media (min-width:900px) and (prefers-reduced-motion:reduce){
.sheet{transition:none}
}
/* type swatch in the admin list */
.kind-chip{
display:inline-flex;align-items:center;gap:8px;padding:5px 10px;border:1px solid var(--line);
background:var(--card);font-size:13px;margin:0 6px 6px 0
}
.kind-dot{width:15px;height:15px;border:1.5px solid;flex:none}

874
server.js Normal file
View file

@ -0,0 +1,874 @@
/* ---------------------------------------------------------------------------
Church key register — server
Node + Express + SQLite. No build step, no ORM, no accounts.
Access control is the network (Tailscale), not the app.
--------------------------------------------------------------------------- */
"use strict";
const path = require("path");
const fs = require("fs");
const express = require("express");
const Database = require("better-sqlite3");
const PORT = process.env.PORT || 3000;
const DATA_DIR = process.env.DATA_DIR || "/data";
const DB_PATH = path.join(DATA_DIR, "keyreg.db");
const ACCESS_CODE = process.env.ACCESS_CODE || "";
const UI_LANG = process.env.UI_LANG || "pt";
const REQUEST_PIN = process.env.REQUEST_PIN || "";
/* Requests are staff-only unless you deliberately open them up. See the
README before setting this — it is the one endpoint people outside your
Tailscale can reach. */
const PUBLIC_REQUESTS = process.env.PUBLIC_REQUESTS === "1";
const crypto = require("crypto");
const SESSION_TOKEN = ACCESS_CODE
? crypto.createHash("sha256").update("keyreg:" + ACCESS_CODE).digest("hex").slice(0, 32)
: "";
/* Is this request coming from someone already inside the app?
Used so that handing your own phone to a visitor does not make you type the
shared PIN — the PIN exists to stop strangers on the internet, and you are
standing right there. When no passcode is configured, everyone who can reach
the app is behind Tailscale, so they count as staff unless the form has been
deliberately published. */
function isStaff(req) {
if (!ACCESS_CODE) return !PUBLIC_REQUESTS;
const c = (req.headers.cookie || "").match(/keyreg=([^;]+)/);
return !!(c && c[1] === SESSION_TOKEN);
}
fs.mkdirSync(DATA_DIR, { recursive: true });
/* --------------------------------------------------------------------------
Schema
-------------------------------------------------------------------------- */
const db = new Database(DB_PATH);
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
db.exec(`
CREATE TABLE IF NOT EXISTS cabinets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
full_name TEXT NOT NULL,
note TEXT DEFAULT '',
off_site INTEGER DEFAULT 0,
sort INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS doors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
num TEXT NOT NULL UNIQUE,
building TEXT NOT NULL,
name TEXT NOT NULL,
lock_group TEXT DEFAULT '',
notes TEXT DEFAULT ''
);
CREATE TABLE IF NOT EXISTS people (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
dept TEXT DEFAULT '',
role TEXT DEFAULT '',
phone TEXT DEFAULT '',
email TEXT DEFAULT '',
active INTEGER DEFAULT 1
);
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tag TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL DEFAULT 'key', -- key | nfc
door_id INTEGER REFERENCES doors(id),
zone TEXT DEFAULT '', -- alarm tags only
building TEXT DEFAULT '',
cabinet_id TEXT REFERENCES cabinets(id),
hook TEXT DEFAULT '',
is_master INTEGER DEFAULT 0,
uid TEXT DEFAULT '', -- NFC chip serial
status TEXT NOT NULL DEFAULT 'in', -- in | out | reserve | lost | retired
notes TEXT DEFAULT ''
);
CREATE TABLE IF NOT EXISTS handovers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id INTEGER NOT NULL REFERENCES items(id),
person_id INTEGER NOT NULL REFERENCES people(id),
out_date TEXT NOT NULL,
in_date TEXT,
due_date TEXT,
note TEXT DEFAULT '',
issued_by TEXT DEFAULT '',
signature TEXT -- data URL, captured on the phone
);
CREATE TABLE IF NOT EXISTS journal (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
text TEXT NOT NULL,
author TEXT DEFAULT ''
);
-- Every admin change gets a line. With two of you editing, "who moved 12-B
-- and when" is a question that will come up.
CREATE TABLE IF NOT EXISTS audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
action TEXT NOT NULL,
detail TEXT DEFAULT ''
);
-- Item types. Started as two hardcoded values (key / nfc); a table instead so
-- you can add padlocks, remotes, fobs or anything else without a code change.
-- shape and colour are what the board draws: shape says what a thing is,
-- fill says where it is.
CREATE TABLE IF NOT EXISTS kinds (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
shape TEXT NOT NULL DEFAULT 'circle', -- circle | rounded | square
colour TEXT NOT NULL DEFAULT '#2E6E60',
sort INTEGER DEFAULT 0
);
-- Access requests. Deliberately separate from people and handovers: a request
-- is something someone asked for, not a decision you made. Nothing here grants
-- anything.
CREATE TABLE IF NOT EXISTS requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created TEXT NOT NULL,
name TEXT NOT NULL,
dept TEXT DEFAULT '',
contact TEXT DEFAULT '',
areas TEXT DEFAULT '',
detail TEXT DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending', -- pending | handled
handled TEXT,
note TEXT DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_handover_item ON handovers(item_id);
CREATE INDEX IF NOT EXISTS idx_handover_open ON handovers(item_id) WHERE in_date IS NULL;
CREATE INDEX IF NOT EXISTS idx_items_door ON items(door_id);
`);
/* Seed the two original types on an existing database, so upgrading needs no
migration step. */
if (db.prepare("SELECT COUNT(*) c FROM kinds").get().c === 0) {
const ins = db.prepare("INSERT INTO kinds (id,name,shape,colour,sort) VALUES (?,?,?,?,?)");
const pt = (process.env.UI_LANG || "pt") === "pt";
ins.run("key", pt ? "Chave" : "Key", "circle", "#2E6E60", 1);
ins.run("nfc", pt ? "Etiqueta de alarme" : "Alarm tag", "rounded", "#5F55A0", 2);
}
const today = () => new Date().toISOString().slice(0, 10);
const now = () => new Date().toISOString();
const audit = (action, detail = "") =>
db.prepare("INSERT INTO audit (ts,action,detail) VALUES (?,?,?)").run(now(), action, detail);
/* --------------------------------------------------------------------------
Demo data — only if the database is empty and --seed-demo is passed
-------------------------------------------------------------------------- */
function seedDemo() {
if (db.prepare("SELECT COUNT(*) c FROM items").get().c > 0) {
console.log("Database already has items — not seeding.");
return;
}
const cabs = [
["SAC", "Sacristia", "Armário da sacristia", "Sacristia, atrás da porta.", 0, 1],
["SEC", "Secretaria", "Armário da secretaria", "Secretaria, armário de parede junto ao cofre.", 0, 2],
["SAL", "Salão", "Armário do salão", "Corredor do salão, ao lado da porta da cozinha.", 0, 3],
["CASA", "Reserva", "Conjunto de reserva — fora", "Caixa selada, fora das instalações. Só para emergências.", 1, 4],
];
const insCab = db.prepare("INSERT INTO cabinets (id,name,full_name,note,off_site,sort) VALUES (?,?,?,?,?,?)");
cabs.forEach((c) => insCab.run(...c));
const doors = [
["Igreja", "Entrada principal, poente", "A", 3], ["Igreja", "Sacristia", "B", 3],
["Igreja", "Torre sineira", "C", 2], ["Igreja", "Sala do coro", "D", 2],
["Casa da igreja", "Porta da rua", "E", 4], ["Casa da igreja", "Secretaria", "E", 2],
["Casa da igreja", "Casa das caldeiras", "H", 2], ["Salão", "Porta principal", "J", 3],
["Salão", "Cozinha", "K", 2], ["Exterior", "Portão do cemitério", "L", 2],
["Armários", "Armário da sacristia", "N", 2], ["Armários", "Armário da secretaria", "N", 2],
];
const home = { "Igreja": "SAC", "Casa da igreja": "SEC", "Salão": "SAL",
"Exterior": "SAL", "Armários": "SEC" };
const insDoor = db.prepare("INSERT INTO doors (num,building,name,lock_group) VALUES (?,?,?,?)");
const insItem = db.prepare(
`INSERT INTO items (tag,kind,door_id,building,cabinet_id,hook,is_master,status,notes)
VALUES (?,?,?,?,?,?,?,?,?)`
);
const counters = {};
doors.forEach((d, i) => {
const [building, name, group, copies] = d;
const num = String(i + 1).padStart(2, "0");
const doorId = insDoor.run(num, building, name, group).lastInsertRowid;
for (let c = 0; c < copies; c++) {
const cabinet = home[building];
counters[cabinet] = (counters[cabinet] || 0) + 1;
insItem.run(num + "-" + "ABCDE"[c], "key", doorId, building, cabinet,
`${cabinet}·${String(counters[cabinet]).padStart(2, "0")}`,
group === "A" && c === 0 ? 1 : 0, "in", "");
}
counters.CASA = (counters.CASA || 0) + 1;
insItem.run(num + "-R", "key", doorId, building, "CASA",
`CASA·${String(counters.CASA).padStart(2, "0")}`, 0, "reserve", "");
});
const insNfc = db.prepare(
`INSERT INTO items (tag,kind,zone,building,cabinet_id,hook,uid,status) VALUES (?,?,?,?,?,?,?,?)`
);
[["Igreja", "Nave"], ["Casa da igreja", "Piso térreo"], ["Salão", "Salão e cozinha"]]
.forEach(([b, z], zi) => {
for (let c = 0; c < 4; c++) {
const nn = String(zi * 4 + c + 1).padStart(2, "0");
insNfc.run("N-" + nn, "nfc", z, b, "SEC", "SEC·N" + nn,
"04:" + (0x1a + zi * 4 + c).toString(16).toUpperCase() + ":9C", "in");
}
});
const insPerson = db.prepare("INSERT INTO people (name,dept,role) VALUES (?,?,?)");
[["Maria Fonseca", "Secretaria", "Secretária"],
["João Ribeiro", "Manutenção", "Sacristão"],
["Pe. Anselmo", "Clero", "Padre"],
["Teresa Lopes", "Coro", "Diretora do coro"],
["Sofia Braga", "Reservas do salão", "Voluntária"],
["Paulo Esteves", "Manutenção", "Técnico de aquecimento"]].forEach((p) => insPerson.run(...p));
const give = db.prepare(
"INSERT INTO handovers (item_id,person_id,out_date,in_date,due_date,note,issued_by) VALUES (?,?,?,?,?,?,?)"
);
const setOut = db.prepare("UPDATE items SET status='out' WHERE id=?");
const byTag = db.prepare("SELECT id FROM items WHERE tag=?");
const loan = (tag, pid, out, back, due, note) => {
const it = byTag.get(tag);
if (!it) return;
give.run(it.id, pid, out, back, due, note || "", "Instalação");
if (!back) setOut.run(it.id);
};
loan("02-A", 3, "2019-04-02", null, null, "");
loan("05-A", 1, "2021-09-06", null, null, "");
loan("06-A", 1, "2021-09-06", null, null, "");
loan("01-B", 2, "2018-01-15", null, null, "");
loan("07-A", 2, "2018-01-15", null, null, "");
loan("04-A", 4, "2022-02-14", null, null, "");
loan("08-B", 5, "2026-06-01", null, "2026-08-20", "Concertos de verão");
loan("07-B", 6, "2026-01-12", null, "2026-02-28", "Revisão da caldeira — temporário");
loan("09-B", 5, "2021-05-02", "2023-08-30", null, "");
db.prepare("INSERT INTO journal (date,text,author) VALUES (?,?,?)")
.run(today(), "Registo criado com dados de exemplo. Apague-os em /admin antes de usar a sério.", "Instalação");
audit("seed", "demo data");
console.log("Demo data loaded.");
}
if (process.argv.includes("--seed-demo")) {
seedDemo();
if (process.argv.includes("--exit")) process.exit(0);
}
if (process.env.SEED_DEMO === "1") seedDemo();
/* --------------------------------------------------------------------------
App
-------------------------------------------------------------------------- */
const app = express();
app.use(express.json({ limit: "4mb" })); // signatures are data URLs
app.disable("x-powered-by");
/* Behind a reverse proxy (Nginx Proxy Manager, Cloudflare, Traefik), every
request arrives from the proxy's address. Without this, req.ip is the proxy
for everyone, so the login and PIN rate limits share a single bucket — five
wrong attempts from one attacker would lock out the whole church.
Set TRUST_PROXY=1 for one proxy in front, 2 for two (e.g. Cloudflare → NPM).
Leave it unset when the app is reached directly, otherwise a client can
forge X-Forwarded-For and dodge the limits entirely. */
if (process.env.TRUST_PROXY) {
const hops = Number(process.env.TRUST_PROXY);
app.set("trust proxy", Number.isFinite(hops) ? hops : process.env.TRUST_PROXY);
}
/* Optional shared passcode. Defence in depth only — the real boundary is
Tailscale. Leave ACCESS_CODE empty and there is no gate at all. */
if (ACCESS_CODE) {
/* The cookie holds a hash, not the passcode itself, so a stolen cookie
does not hand over the code you also use elsewhere. */
const TOKEN = SESSION_TOKEN;
/* The login page needs its stylesheet and translations before the user has
a cookie, so these three paths sit outside the gate. */
const OPEN = new Set(["/api/login", "/styles.css", "/i18n.js"]);
if (PUBLIC_REQUESTS) ["/pedido", "/api/request", "/api/request/areas"].forEach((p) => OPEN.add(p));
const attempts = new Map(); // ip -> { n, until }
const loginPage = () =>
fs.readFileSync(path.join(__dirname, "public", "login.html"), "utf8")
.replace("__LANG__", UI_LANG);
app.use((req, res, next) => {
if (OPEN.has(req.path)) return next();
const cookie = (req.headers.cookie || "").match(/keyreg=([^;]+)/);
if (cookie && cookie[1] === TOKEN) return next();
if (req.path.startsWith("/api/")) return res.status(401).json({ error: "locked" });
res.type("html").send(loginPage());
});
app.post("/api/login", (req, res) => {
const ip = req.ip || "?";
const rec = attempts.get(ip) || { n: 0, until: 0 };
if (Date.now() < rec.until) return res.status(429).json({ error: "too many attempts" });
if (req.body.code !== ACCESS_CODE) {
rec.n++;
/* Back off after repeated failures. A shared passcode is short, so
without this it is worth guessing. */
if (rec.n >= 5) { rec.until = Date.now() + 60000; rec.n = 0; }
attempts.set(ip, rec);
audit("login failed", ip);
return res.status(401).json({ error: "wrong code" });
}
attempts.delete(ip);
res.setHeader("Set-Cookie",
`keyreg=${TOKEN}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000`);
res.json({ ok: true });
});
}
const bad = (res, msg) => res.status(400).json({ error: msg });
/* ---- read: the whole register in one call. At this size it's simpler and
faster than a dozen endpoints, and the UI never shows a half-state. ---- */
app.get("/api/state", (req, res) => {
const items = db.prepare("SELECT * FROM items ORDER BY tag").all();
const open = db.prepare(`SELECT id,item_id,person_id,out_date,in_date,due_date,note,issued_by,
(signature IS NOT NULL) AS has_sig
FROM handovers WHERE in_date IS NULL`).all();
const openBy = new Map(open.map((h) => [h.item_id, h]));
res.json({
today: today(),
lang: UI_LANG,
kinds: db.prepare("SELECT * FROM kinds ORDER BY sort, name").all(),
cabinets: db.prepare("SELECT * FROM cabinets ORDER BY sort, name").all(),
doors: db.prepare("SELECT * FROM doors ORDER BY CAST(num AS INTEGER), num").all(),
people: db.prepare("SELECT * FROM people ORDER BY name").all(),
items: items.map((i) => ({ ...i, loan: openBy.get(i.id) || null })),
handovers: db.prepare(`SELECT id,item_id,person_id,out_date,in_date,due_date,note,issued_by,
(signature IS NOT NULL) AS has_sig
FROM handovers ORDER BY out_date DESC, id DESC LIMIT 400`).all(),
journal: db.prepare("SELECT * FROM journal ORDER BY date DESC, id DESC LIMIT 200").all(),
pending: db.prepare("SELECT COUNT(*) c FROM requests WHERE status='pending'").get().c,
/* So you can see from inside the app whether the request form is actually
protected, instead of having to remember what is in .env. */
reqForm: { public: PUBLIC_REQUESTS, pin: !!REQUEST_PIN },
});
});
app.get("/api/items/:id/history", (req, res) => {
res.json(db.prepare("SELECT * FROM handovers WHERE item_id=? ORDER BY out_date DESC, id DESC")
.all(req.params.id));
});
/* ---- issue ---- */
app.post("/api/items/:id/issue", (req, res) => {
const item = db.prepare("SELECT * FROM items WHERE id=?").get(req.params.id);
if (!item) return bad(res, "no such item");
if (item.status === "out") return bad(res, "already issued");
if (item.status === "lost" || item.status === "retired") return bad(res, "not issuable");
const { person_id, due_date, note, signature, issued_by } = req.body;
if (!db.prepare("SELECT 1 FROM people WHERE id=?").get(person_id)) return bad(res, "no such person");
const wasReserve = item.status === "reserve";
db.transaction(() => {
db.prepare(`INSERT INTO handovers (item_id,person_id,out_date,due_date,note,issued_by,signature)
VALUES (?,?,?,?,?,?,?)`)
.run(item.id, person_id, today(), due_date || null, note || "", issued_by || "", signature || null);
db.prepare("UPDATE items SET status='out' WHERE id=?").run(item.id);
if (wasReserve) {
const p = db.prepare("SELECT name FROM people WHERE id=?").get(person_id);
const msg = UI_LANG === "pt"
? `Selo de reserva quebrado: ${item.tag} entregue a ${p.name}.`
: `Reserve seal broken: ${item.tag} issued to ${p.name}.`;
db.prepare("INSERT INTO journal (date,text,author) VALUES (?,?,?)")
.run(today(), msg, UI_LANG === "pt" ? "Automático" : "Automatic");
}
})();
audit("issue", `${item.tag} → person ${person_id}`);
res.json({ ok: true });
});
/* ---- return ---- */
app.post("/api/items/:id/return", (req, res) => {
const item = db.prepare("SELECT * FROM items WHERE id=?").get(req.params.id);
if (!item) return bad(res, "no such item");
const open = db.prepare("SELECT * FROM handovers WHERE item_id=? AND in_date IS NULL").get(item.id);
if (!open) return bad(res, "not currently issued");
const cabinet = db.prepare("SELECT off_site FROM cabinets WHERE id=?").get(item.cabinet_id);
db.transaction(() => {
db.prepare("UPDATE handovers SET in_date=?, note=COALESCE(NULLIF(?,''),note) WHERE id=?")
.run(today(), req.body.note || "", open.id);
/* a key goes back to whatever its home cabinet implies */
db.prepare("UPDATE items SET status=? WHERE id=?")
.run(cabinet && cabinet.off_site ? "reserve" : "in", item.id);
})();
audit("return", item.tag);
res.json({ ok: true });
});
/* ---- notes and journal ---- */
app.patch("/api/items/:id/notes", (req, res) => {
db.prepare("UPDATE items SET notes=? WHERE id=?").run(req.body.notes || "", req.params.id);
res.json({ ok: true });
});
app.post("/api/journal", (req, res) => {
if (!req.body.text || !req.body.text.trim()) return bad(res, "empty");
db.prepare("INSERT INTO journal (date,text,author) VALUES (?,?,?)")
.run(req.body.date || today(), req.body.text.trim(), req.body.author || "");
res.json({ ok: true });
});
/* --------------------------------------------------------------------------
Access requests
-------------------------------------------------------------------------- */
const reqHits = new Map(); // ip -> { n, until }
/* Areas only — never the door list. If this form is publicly reachable, a
list of every door is a map of the building for anyone who finds the page. */
app.get("/api/request/areas", (req, res) => {
const rows = db.prepare("SELECT DISTINCT building FROM doors WHERE building<>'' ORDER BY building").all();
res.json({ areas: rows.map((r) => r.building), pin: !!REQUEST_PIN && !isStaff(req) });
});
app.post("/api/request", (req, res) => {
const ip = req.ip || "?";
const hit = reqHits.get(ip) || { n: 0, until: 0 };
if (Date.now() < hit.until) return res.status(429).json({ error: "too many requests" });
/* Hidden field a person never sees. Bots fill every input they find. */
if (req.body.website) return res.json({ ok: true });
if (REQUEST_PIN && !isStaff(req) && String(req.body.pin || "").trim() !== REQUEST_PIN) {
hit.n++;
if (hit.n >= 5) { hit.until = Date.now() + 300000; hit.n = 0; }
reqHits.set(ip, hit);
return res.status(401).json({ error: "wrong pin" });
}
const clip = (v, n) => String(v || "").trim().slice(0, n);
const name = clip(req.body.name, 80);
if (!name) return bad(res, "name required");
const areas = Array.isArray(req.body.areas)
? req.body.areas.map((a) => clip(a, 60)).filter(Boolean).slice(0, 12).join(", ")
: clip(req.body.areas, 200);
const dept = clip(req.body.dept, 80);
const contact = clip(req.body.contact, 80);
const detail = clip(req.body.detail, 600);
hit.n = (hit.n || 0) + 1;
if (hit.n >= 8) hit.until = Date.now() + 300000;
reqHits.set(ip, hit);
db.transaction(() => {
db.prepare(`INSERT INTO requests (created,name,dept,contact,areas,detail)
VALUES (?,?,?,?,?,?)`).run(now(), name, dept, contact, areas, detail);
const label = UI_LANG === "pt" ? "Pedido de acesso" : "Access request";
const via = isStaff(req)
? (UI_LANG === "pt" ? "Formulário (no local)" : "Form (in person)")
: (UI_LANG === "pt" ? "Formulário (online)" : "Form (online)");
db.prepare("INSERT INTO journal (date,text,author) VALUES (?,?,?)").run(
today(),
`${label}: ${name}${dept ? " (" + dept + ")" : ""}` +
`${areas ? " — " + areas : ""}${detail ? ". " + detail : ""}`,
via);
})();
audit("request", name);
res.json({ ok: true });
});
app.get("/api/requests", (req, res) => {
res.json(db.prepare("SELECT * FROM requests ORDER BY status='handled', id DESC LIMIT 100").all());
});
app.post("/api/requests/:id/handle", (req, res) => {
const r = db.prepare("SELECT * FROM requests WHERE id=?").get(req.params.id);
if (!r) return bad(res, "no such request");
db.prepare("UPDATE requests SET status='handled', handled=?, note=? WHERE id=?")
.run(today(), String(req.body.note || "").slice(0, 300), r.id);
audit("request handled", r.name);
res.json({ ok: true });
});
/* --------------------------------------------------------------------------
Admin
-------------------------------------------------------------------------- */
const A = "/api/admin";
app.post(A + "/cabinets", (req, res) => {
const { id, name, full_name, note, off_site, sort } = req.body;
if (!id || !name) return bad(res, "id and name required");
db.prepare(`INSERT INTO cabinets (id,name,full_name,note,off_site,sort) VALUES (?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET name=excluded.name, full_name=excluded.full_name,
note=excluded.note, off_site=excluded.off_site, sort=excluded.sort`)
.run(id, name, full_name || name, note || "", off_site ? 1 : 0, sort || 0);
audit("cabinet", id);
res.json({ ok: true });
});
app.delete(A + "/cabinets/:id", (req, res) => {
const n = db.prepare("SELECT COUNT(*) c FROM items WHERE cabinet_id=?").get(req.params.id).c;
if (n) return bad(res, `${n} keys still live in this cabinet — move them first`);
db.prepare("DELETE FROM cabinets WHERE id=?").run(req.params.id);
audit("cabinet delete", req.params.id);
res.json({ ok: true });
});
app.post(A + "/kinds", (req, res) => {
const { id, name, shape, colour, sort } = req.body;
if (!id || !name) return bad(res, "id and name required");
const code = String(id).trim().toLowerCase().replace(/[^a-z0-9_-]/g, "");
if (!code) return bad(res, "id must contain letters or numbers");
db.prepare(`INSERT INTO kinds (id,name,shape,colour,sort) VALUES (?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET name=excluded.name, shape=excluded.shape,
colour=excluded.colour, sort=excluded.sort`)
.run(code, String(name).trim(),
["circle", "rounded", "square"].includes(shape) ? shape : "circle",
/^#[0-9a-fA-F]{6}$/.test(colour || "") ? colour : "#2E6E60", +sort || 0);
audit("kind", code);
res.json({ ok: true });
});
app.delete(A + "/kinds/:id", (req, res) => {
const n = db.prepare("SELECT COUNT(*) c FROM items WHERE kind=?").get(req.params.id).c;
if (n) return bad(res, `${n} items still use this type — change them first`);
if (db.prepare("SELECT COUNT(*) c FROM kinds").get().c <= 1) return bad(res, "at least one type is required");
db.prepare("DELETE FROM kinds WHERE id=?").run(req.params.id);
audit("kind delete", req.params.id);
res.json({ ok: true });
});
app.post(A + "/doors", (req, res) => {
const { id, num, building, name, lock_group, notes } = req.body;
if (!num || !building || !name) return bad(res, "num, building and name required");
try {
if (id) {
db.prepare("UPDATE doors SET num=?,building=?,name=?,lock_group=?,notes=? WHERE id=?")
.run(num, building, name, lock_group || "", notes || "", id);
db.prepare("UPDATE items SET building=? WHERE door_id=?").run(building, id);
} else {
db.prepare("INSERT INTO doors (num,building,name,lock_group,notes) VALUES (?,?,?,?,?)")
.run(num, building, name, lock_group || "", notes || "");
}
} catch (e) { return bad(res, "door number already used"); }
audit("door", num + " " + name);
res.json({ ok: true });
});
app.delete(A + "/doors/:id", (req, res) => {
const n = db.prepare("SELECT COUNT(*) c FROM items WHERE door_id=?").get(req.params.id).c;
if (n) return bad(res, `${n} keys belong to this door — delete or move them first`);
db.prepare("DELETE FROM doors WHERE id=?").run(req.params.id);
audit("door delete", req.params.id);
res.json({ ok: true });
});
app.post(A + "/people", (req, res) => {
const { id, name, dept, role, phone, email, active } = req.body;
if (!name) return bad(res, "name required");
if (id) {
db.prepare("UPDATE people SET name=?,dept=?,role=?,phone=?,email=?,active=? WHERE id=?")
.run(name, dept || "", role || "", phone || "", email || "", active ? 1 : 0, id);
} else {
db.prepare("INSERT INTO people (name,dept,role,phone,email) VALUES (?,?,?,?,?)")
.run(name, dept || "", role || "", phone || "", email || "");
}
audit("person", name);
res.json({ ok: true });
});
/* People are never deleted — they appear in old handovers. Deactivate instead. */
app.delete(A + "/people/:id", (req, res) => {
db.prepare("UPDATE people SET active=0 WHERE id=?").run(req.params.id);
audit("person deactivate", req.params.id);
res.json({ ok: true, note: "deactivated, not deleted — they appear in past handovers" });
});
app.post(A + "/items", (req, res) => {
const { id, tag, kind, door_id, zone, cabinet_id, hook, is_master, uid, status, notes } = req.body;
if (!tag) return bad(res, "tag required");
const door = door_id ? db.prepare("SELECT * FROM doors WHERE id=?").get(door_id) : null;
const building = door ? door.building : req.body.building || "";
const theKind = db.prepare("SELECT id FROM kinds WHERE id=?").get(kind || "key")
? (kind || "key")
: db.prepare("SELECT id FROM kinds ORDER BY sort LIMIT 1").get().id;
try {
if (id) {
db.prepare(`UPDATE items SET tag=?,kind=?,door_id=?,zone=?,building=?,cabinet_id=?,hook=?,
is_master=?,uid=?,status=?,notes=? WHERE id=?`)
.run(tag, theKind, door_id || null, zone || "", building, cabinet_id || null,
hook || "", is_master ? 1 : 0, uid || "", status || "in", notes || "", id);
} else {
db.prepare(`INSERT INTO items (tag,kind,door_id,zone,building,cabinet_id,hook,is_master,uid,status,notes)
VALUES (?,?,?,?,?,?,?,?,?,?,?)`)
.run(tag, theKind, door_id || null, zone || "", building, cabinet_id || null,
hook || "", is_master ? 1 : 0, uid || "", status || "in", notes || "");
}
} catch (e) { return bad(res, "that tag is already used"); }
audit("item", tag);
res.json({ ok: true });
});
/* Items are retired, not deleted — their handover history must survive. */
app.delete(A + "/items/:id", (req, res) => {
const n = db.prepare("SELECT COUNT(*) c FROM handovers WHERE item_id=?").get(req.params.id).c;
if (n) {
db.prepare("UPDATE items SET status='retired' WHERE id=?").run(req.params.id);
audit("item retire", req.params.id);
return res.json({ ok: true, note: "retired — it has handover history that must be kept" });
}
db.prepare("DELETE FROM items WHERE id=?").run(req.params.id);
audit("item delete", req.params.id);
res.json({ ok: true });
});
/* ---- CSV import: the thing that decides whether this project ever starts ---- */
function parseCSV(text) {
const rows = [];
let row = [], cell = "", q = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (q) {
if (c === '"' && text[i + 1] === '"') { cell += '"'; i++; }
else if (c === '"') q = false;
else cell += c;
} else if (c === '"') q = true;
else if (c === "," || c === ";" || c === "\t") { row.push(cell); cell = ""; }
else if (c === "\n") { row.push(cell); rows.push(row); row = []; cell = ""; }
else if (c !== "\r") cell += c;
}
if (cell || row.length) { row.push(cell); rows.push(row); }
return rows.filter((r) => r.some((x) => x.trim() !== ""));
}
app.post(A + "/import", (req, res) => {
const rows = parseCSV(req.body.csv || "");
if (rows.length < 2) return bad(res, "needs a header row and at least one key");
const head = rows[0].map((h) => h.trim().toLowerCase());
const col = (n) => head.indexOf(n);
const need = ["tag", "door", "building"];
const missing = need.filter((n) => col(n) < 0);
if (missing.length) return bad(res, "missing columns: " + missing.join(", "));
const findDoor = db.prepare("SELECT id FROM doors WHERE name=? AND building=?");
const nextNum = () => {
const m = db.prepare("SELECT MAX(CAST(num AS INTEGER)) m FROM doors").get().m || 0;
return String(m + 1).padStart(2, "0");
};
const out = { doors: 0, items: 0, cabinets: 0, skipped: [] };
db.transaction(() => {
for (const r of rows.slice(1)) {
const tag = (r[col("tag")] || "").trim();
const doorName = (r[col("door")] || "").trim();
const building = (r[col("building")] || "").trim();
if (!tag || !doorName || !building) { out.skipped.push(tag || "(no tag)"); continue; }
if (db.prepare("SELECT 1 FROM items WHERE tag=?").get(tag)) {
out.skipped.push(tag + " (already exists)"); continue;
}
const group = col("lock_group") >= 0 ? (r[col("lock_group")] || "").trim() : "";
let door = findDoor.get(doorName, building);
if (!door) {
const id = db.prepare("INSERT INTO doors (num,building,name,lock_group) VALUES (?,?,?,?)")
.run(nextNum(), building, doorName, group).lastInsertRowid;
door = { id }; out.doors++;
}
let cabId = col("cabinet") >= 0 ? (r[col("cabinet")] || "").trim() : "";
if (cabId && !db.prepare("SELECT 1 FROM cabinets WHERE id=?").get(cabId)) {
db.prepare("INSERT INTO cabinets (id,name,full_name) VALUES (?,?,?)").run(cabId, cabId, cabId);
out.cabinets++;
}
/* An unknown type is refused rather than created. A malformed row or a
typo would otherwise litter the type list with junk, and types are now
two clicks away in Admin. */
let kindId = col("kind") >= 0 ? (r[col("kind")] || "").trim().toLowerCase() : "";
if (!kindId) kindId = db.prepare("SELECT id FROM kinds ORDER BY sort LIMIT 1").get().id;
if (!db.prepare("SELECT 1 FROM kinds WHERE id=?").get(kindId)) {
out.skipped.push(`${tag} (unknown type: ${kindId})`);
continue;
}
db.prepare(`INSERT INTO items (tag,kind,door_id,building,cabinet_id,hook,is_master,status,notes)
VALUES (?,?,?,?,?,?,?,?,?)`)
.run(tag,
kindId,
door.id, building, cabId || null,
col("hook") >= 0 ? (r[col("hook")] || "").trim() : "",
col("master") >= 0 && /^(1|yes|y|true)$/i.test((r[col("master")] || "").trim()) ? 1 : 0,
col("status") >= 0 && (r[col("status")] || "").trim() ? (r[col("status")]).trim() : "in",
col("notes") >= 0 ? (r[col("notes")] || "").trim() : "");
out.items++;
}
})();
audit("import", `${out.items} items, ${out.doors} doors`);
res.json(out);
});
/* Separate importers. The combined one creates doors and cabinets implicitly,
which is convenient but gives you no control over numbering or names. These
let you load each table deliberately, and re-run safely to update. */
function importer(res, csv, required, handle) {
const rows = parseCSV(csv || "");
if (rows.length < 2) return bad(res, "needs a header row and at least one line");
const head = rows[0].map((h) => h.trim().toLowerCase());
const col = (n) => head.indexOf(n);
const missing = required.filter((n) => col(n) < 0);
if (missing.length) return bad(res, "missing columns: " + missing.join(", "));
const get = (r, n) => (col(n) >= 0 ? (r[col(n)] || "").trim() : "");
/* Whether the column exists at all in this file. An absent column must not
overwrite existing data with blanks — re-importing `name,role` should
update the role and leave the department alone. */
const has = (n) => col(n) >= 0;
const out = { created: 0, updated: 0, skipped: [] };
db.transaction(() => { rows.slice(1).forEach((r) => handle(r, get, out, has)); })();
return out;
}
const truthy = (v) => /^(1|yes|y|true|sim|s|x)$/i.test(v);
app.post(A + "/import/doors", (req, res) => {
const out = importer(res, req.body.csv, ["num", "building", "name"], (r, get, o, has) => {
const num = get(r, "num"), building = get(r, "building"), name = get(r, "name");
if (!num || !building || !name) { o.skipped.push(num || "(sem número)"); return; }
const exists = db.prepare("SELECT id FROM doors WHERE num=?").get(num);
if (exists) {
const set = { building, name };
if (has("lock_group")) set.lock_group = get(r, "lock_group");
if (has("notes")) set.notes = get(r, "notes");
const cols = Object.keys(set);
db.prepare(`UPDATE doors SET ${cols.map((c) => c + "=?").join(",")} WHERE id=?`)
.run(...cols.map((c) => set[c]), exists.id);
db.prepare("UPDATE items SET building=? WHERE door_id=?").run(building, exists.id);
o.updated++;
} else {
db.prepare("INSERT INTO doors (num,building,name,lock_group,notes) VALUES (?,?,?,?,?)")
.run(num, building, name, get(r, "lock_group"), get(r, "notes"));
o.created++;
}
});
if (!out || out.error) return;
audit("import doors", `${out.created} new, ${out.updated} updated`);
res.json(out);
});
app.post(A + "/import/people", (req, res) => {
const out = importer(res, req.body.csv, ["name"], (r, get, o, has) => {
const name = get(r, "name");
if (!name) { o.skipped.push("(sem nome)"); return; }
/* Matched on name, so re-importing an updated spreadsheet edits people
rather than creating duplicates. */
const exists = db.prepare("SELECT id FROM people WHERE name=? COLLATE NOCASE").get(name);
if (exists) {
const set = {};
["dept", "role", "phone", "email"].forEach((c) => { if (has(c)) set[c] = get(r, c); });
if (has("active")) set.active = col_active(r, get);
const cols = Object.keys(set);
if (cols.length) {
db.prepare(`UPDATE people SET ${cols.map((c) => c + "=?").join(",")} WHERE id=?`)
.run(...cols.map((c) => set[c]), exists.id);
}
o.updated++;
} else {
db.prepare("INSERT INTO people (name,dept,role,phone,email,active) VALUES (?,?,?,?,?,?)")
.run(name, get(r, "dept"), get(r, "role"), get(r, "phone"), get(r, "email"), col_active(r, get));
o.created++;
}
});
if (!out || out.error) return;
audit("import people", `${out.created} new, ${out.updated} updated`);
res.json(out);
});
function col_active(r, get) {
const v = get(r, "active");
return v === "" ? 1 : truthy(v) ? 1 : 0;
}
app.post(A + "/import/cabinets", (req, res) => {
const out = importer(res, req.body.csv, ["id", "name"], (r, get, o, has) => {
const id = get(r, "id").toUpperCase(), name = get(r, "name");
if (!id || !name) { o.skipped.push(id || "(sem código)"); return; }
const exists = db.prepare("SELECT * FROM cabinets WHERE id=?").get(id);
if (exists) {
const set = { name };
if (has("full_name")) set.full_name = get(r, "full_name") || name;
if (has("note")) set.note = get(r, "note");
if (has("off_site")) set.off_site = truthy(get(r, "off_site")) ? 1 : 0;
if (has("sort")) set.sort = +get(r, "sort") || 0;
const cols = Object.keys(set);
db.prepare(`UPDATE cabinets SET ${cols.map((c) => c + "=?").join(",")} WHERE id=?`)
.run(...cols.map((c) => set[c]), id);
o.updated++;
} else {
db.prepare("INSERT INTO cabinets (id,name,full_name,note,off_site,sort) VALUES (?,?,?,?,?,?)")
.run(id, name, get(r, "full_name") || name, get(r, "note"),
truthy(get(r, "off_site")) ? 1 : 0, +get(r, "sort") || 0);
o.created++;
}
});
if (!out || out.error) return;
audit("import cabinets", `${out.created} new, ${out.updated} updated`);
res.json(out);
});
/* Signatures for one person, for the printable receipt. Kept out of
/api/state so the images are only fetched when actually shown. */
app.get("/api/report/person/:id", (req, res) => {
res.json(db.prepare(`SELECT h.*, i.tag, d.name AS door, i.building, i.cabinet_id
FROM handovers h JOIN items i ON i.id=h.item_id
LEFT JOIN doors d ON d.id=i.door_id
WHERE h.person_id=? AND h.in_date IS NULL
ORDER BY h.out_date DESC`).all(req.params.id));
});
app.get(A + "/export.csv", (req, res) => {
const rows = db.prepare(`
SELECT i.tag, d.name AS door, i.building, d.lock_group, i.cabinet_id AS cabinet, i.hook,
i.is_master AS master, i.status, i.kind, i.uid, i.notes,
(SELECT p.name FROM handovers h JOIN people p ON p.id=h.person_id
WHERE h.item_id=i.id AND h.in_date IS NULL) AS held_by
FROM items i LEFT JOIN doors d ON d.id=i.door_id ORDER BY i.tag`).all();
const head = Object.keys(rows[0] || { tag: "" });
const q = (v) => `"${String(v ?? "").replace(/"/g, '""')}"`;
res.setHeader("Content-Type", "text/csv; charset=utf-8");
res.setHeader("Content-Disposition", `attachment; filename="key-register-${today()}.csv"`);
res.send([head.join(","), ...rows.map((r) => head.map((h) => q(r[h])).join(","))].join("\n"));
});
/* Consistent snapshot even with writes in flight — this is why SQLite is a
good choice here: the whole backup is one file. */
app.get(A + "/backup.db", (req, res) => {
const tmp = path.join(DATA_DIR, `backup-${Date.now()}.db`);
db.backup(tmp).then(() => {
res.setHeader("Content-Disposition", `attachment; filename="keyreg-${today()}.db"`);
res.sendFile(tmp, () => fs.unlink(tmp, () => {}));
}).catch((e) => res.status(500).json({ error: String(e) }));
});
app.get(A + "/audit", (req, res) => {
res.json(db.prepare("SELECT * FROM audit ORDER BY id DESC LIMIT 200").all());
});
app.post(A + "/wipe", (req, res) => {
if (req.body.confirm !== "DELETE EVERYTHING") return bad(res, "confirmation phrase required");
db.transaction(() => {
["handovers", "items", "doors", "people", "journal"].forEach((t) => db.prepare(`DELETE FROM ${t}`).run());
})();
audit("wipe", "all register data cleared");
res.json({ ok: true });
});
/* --------------------------------------------------------------------------
Static
-------------------------------------------------------------------------- */
app.use(express.static(path.join(__dirname, "public")));
app.get("/admin", (req, res) => res.sendFile(path.join(__dirname, "public", "admin.html")));
app.get("/report", (req, res) => res.sendFile(path.join(__dirname, "public", "report.html")));
app.get("/pedido", (req, res) => {
res.type("html").send(
fs.readFileSync(path.join(__dirname, "public", "pedido.html"), "utf8")
.replace("__LANG__", UI_LANG));
});
app.get("/health", (req, res) => res.json({ ok: true, items: db.prepare("SELECT COUNT(*) c FROM items").get().c }));
app.listen(PORT, "0.0.0.0", () => {
console.log(`Key register on http://0.0.0.0:${PORT} (db: ${DB_PATH})`);
if (!ACCESS_CODE) console.log("No ACCESS_CODE set — relying on the network for access control.");
if (PUBLIC_REQUESTS) console.log(`Request form is PUBLIC at /pedido${REQUEST_PIN ? " (PIN required)" : " — NO PIN SET"}`);
});