From ef83aa153fac8cb644dee53a32026386dc7c2ed8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Louren=C3=A7o?= Date: Mon, 24 Aug 2026 15:21:24 +0100 Subject: [PATCH] Registo de chaves --- .dockerignore | 31 ++ .env.example | 26 ++ .forgejo/workflows/build.yml | 43 ++ .gitignore | 25 + Dockerfile | 41 ++ README.md | 666 ++++++++++++++++++++++++++ backup.sh | 73 +++ docker-compose.yml | 60 +++ package.json | 19 + public/admin.html | 37 ++ public/admin.js | 446 ++++++++++++++++++ public/app.js | 594 ++++++++++++++++++++++++ public/i18n.js | 462 ++++++++++++++++++ public/index.html | 53 +++ public/login.html | 133 ++++++ public/pedido.html | 164 +++++++ public/report.html | 26 ++ public/report.js | 234 ++++++++++ public/styles.css | 514 ++++++++++++++++++++ server.js | 874 +++++++++++++++++++++++++++++++++++ 20 files changed, 4521 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .forgejo/workflows/build.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 backup.sh create mode 100644 docker-compose.yml create mode 100644 package.json create mode 100644 public/admin.html create mode 100644 public/admin.js create mode 100644 public/app.js create mode 100644 public/i18n.js create mode 100644 public/index.html create mode 100644 public/login.html create mode 100644 public/pedido.html create mode 100644 public/report.html create mode 100644 public/report.js create mode 100644 public/styles.css create mode 100644 server.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4811f58 --- /dev/null +++ b/.dockerignore @@ -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* diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f3e995d --- /dev/null +++ b/.env.example @@ -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 diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml new file mode 100644 index 0000000..a6d9064 --- /dev/null +++ b/.forgejo/workflows/build.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..271b587 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e6efa6e --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..bf7d325 --- /dev/null +++ b/README.md @@ -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. diff --git a/backup.sh b/backup.sh new file mode 100644 index 0000000..6bd0ac3 --- /dev/null +++ b/backup.sh @@ -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. +# =========================================================================== diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..214d484 --- /dev/null +++ b/docker-compose.yml @@ -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 -f '{{json .NetworkSettings.Networks}}' +# name: CHANGEME_npm_default + +volumes: + keyreg_data: diff --git a/package.json b/package.json new file mode 100644 index 0000000..e0d0879 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/admin.html b/public/admin.html new file mode 100644 index 0000000..c2f45ec --- /dev/null +++ b/public/admin.html @@ -0,0 +1,37 @@ + + + + + + +Key Register — Admin + + + + + + +
+
+

+

+ ← +
+ +
+
+
+ +
+ + + + diff --git a/public/admin.js b/public/admin.js new file mode 100644 index 0000000..97c4295 --- /dev/null +++ b/public/admin.js @@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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 = `
${esc(T.noServer)}
${esc(e.message)}
`; } +} +function openSheet(h) { + $sheet.innerHTML = '
' + 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 = ` +

${T.aItems}

+

+ ${T.aItemsHelp(S.items.length)}

+ +
+
+ + ${list.map((i) => ` + + + + + + + `).join("") || ``} +
${T.aTag}${T.aDoor}${T.aCabinet}${T.aHook}${T.aStatus}
${esc(i.tag)}${i.is_master ? ' M' : ""} +
${esc((S.kinds.find((k) => k.id === i.kind) || {}).name || i.kind)}
${esc(i.door_id ? door(i.door_id).name : i.zone)}
${esc(i.building)}
${esc(cab(i.cabinet_id).name)}${esc(i.hook)}${esc((T.statuses && T.statuses[i.status]) || i.status)}
${T.aNothing}
`; + 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(`

${id ? T.aEdit + " " + esc(i.tag) : T.aAddItem.replace("+ ", "")}

+

${T.vKey} / ${T.vTag}

+
+
${T.aTag}
+
${T.fType}
+
+ ${T.fZoneKeys} + + ${T.fZoneTags} + +
+
${T.aCabinet}
+
${T.aHook}
+
+
+
${T.aStatus}
+
${T.fChipUid}
+
+ + ${T.fPermNote} + +
+ +
+ ${id ? ` +

${T.retireNote}

` : ""}`); + + 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 = ` +

${T.aDoors}

+

+ ${T.aDoorsHelp(S.doors.length)}

+
+ ${Object.entries(groups).map(([b, list]) => `
+

${esc(b)}

+ + ${list.map((d) => ` + + + `).join("")} +
${T.aNo}${T.aDoors}${T.aLock}${T.aCopies}
${esc(d.num)}${esc(d.name)}${esc(d.lock_group)}${S.items.filter((i) => i.door_id === d.id).length}
`).join("") || `
${T.aNothing}
`}`; +} + +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(`

${id ? T.aEdit + " " + esc(d.num) : T.aAddDoor.replace("+ ", "")}

+
+
${T.fNum}
+
${T.fLockGroup}
+
+ ${T.fBuilding} + + ${buildings.map((b) => ` + ${T.fDoorName} + + ${T.fNote} + +
+ +
+ ${id ? `` : ""} +

${T.lockGroupNote}

`); + 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 = ` +

${T.aPeople}

+

+ ${T.aPeopleHelp}

+
+
+ + ${S.people.map((p) => { + const held = S.items.filter((i) => i.loan && i.loan.person_id === p.id && i.status === "out").length; + return ` + + `; + }).join("") || ``} +
${T.aName}${T.aDept}${T.aHolding}
${esc(p.name)}${p.active ? "" : ` ${T.inactive}`} +
${esc(p.role)}
${esc(p.dept)}${held || "—"}
${T.aNothing}
`; +} + +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(`

${id ? T.aEdit : T.aAddPerson.replace("+ ", "")}

+ ${T.aName} +
+
${T.aDept}
+
${T.fFunction}
+
+
+
${T.fPhone}
+
${T.fEmail}
+
+ + ${held.length ? `
${esc(T.holdsWarn(held.length, held.map((i) => i.tag).join(", ")))}
` : ""} +
+ +
+ ${id ? ` +

${T.peopleNote}

` : ""}`); + 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 = ` +

${T.aCabinets}

+

+ ${T.aCabinetsHelp}

+
+ ${S.cabinets.map((c) => `
+
+

${esc(c.full_name)}

+
${esc(c.id)}${c.off_site ? " · " + T.aOffSite : ""}
+

${esc(c.note)}

+

${T.aItemsCount(S.items.filter((i) => i.cabinet_id === c.id).length)}

+
`).join("") + || `
${T.aNothing}
`}`; +} + +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(`

${id ? T.aEdit : T.aAddCabinet.replace("+ ", "")}

+
+
${T.fShortCode} +
+
${T.fShortName}
+
+ ${T.fFullName} + + ${T.fWhereIs} + + +

${T.offSiteNote}

+ ${T.fSort} + +

${T.fSortHelp}

+
+ +
+ ${id ? `` : ""}`); + 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 = ` +

${T.aKinds}

+

${T.aKindsHelp}

+
+ ${S.kinds.map((k) => { + const n = S.items.filter((i) => i.kind === k.id).length; + return `
+
+ +
+ ${esc(k.name)} +
${esc(k.id)} · ${esc(k.colour)}
+
${T.kindInUse(n)}
+
+
+ +
`; + }).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(`

${id ? T.aEdit : T.aAddKind.replace("+ ", "")}

+
+
${T.fKindCode} +
+
${T.fKindName} +
+
+
+
${T.fShape}
+
${T.fColour} +
+
+ ${T.fSort} +

${T.kindNote}

+ ${used ? `
${T.kindInUse(used)}
` : ""} +
+ +
+ ${id ? `` : ""}`); + 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 = ` +

${T.impTitle}

+ ${T.impWhat} +
+ ${[["items", T.impItems], ["doors", T.impDoors], ["people", T.impPeople], ["cabinets", T.impCabinets]] + .map(([k, l]) => ``).join("")} +
+

+ ${esc(spec.req)}${spec.opt ? " · " + esc(spec.opt) : ""}

+ + +
+ +

${T.expTitle}

+

${T.expHelp}

+ ${T.expBtn} + ${T.reports}
+ +

${T.bkTitle}

+

${T.bkHelp}

+ ${T.bkBtn}
+ +

${T.wipeTitle}

+
${T.wipeWarn}
+ +
`; + + 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 = + `
${body}${ + r.skipped && r.skipped.length + ? T.impSkipped(r.skipped.length, esc(r.skipped.slice(0, 12).join(", ")) + (r.skipped.length > 12 ? "…" : "")) + : ""}
`; + 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 = `

${T.auditTitle}

+

${T.auditHelp}

+
+ ${rows.map((r) => ` + `).join("") + || ``} +
${T.aWhen}${T.aWhat}${T.aDetail}
${esc(r.ts.slice(0, 16).replace("T", " "))}${esc(r.action)}${esc(r.detail)}
${T.aNothing}
`; +} + +/* ------------------------------------------------------------------ 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(); diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..0551373 --- /dev/null +++ b/public/app.js @@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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 = `
${esc(T.noServer)}
${esc(e.message)}
`; + } +} + +/* ------------------------------------------------------------------ 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 = ` + ${T.cabinetsN(S.cabinets.length)} + ${T.keysIn(live.filter((i) => i.status === "in").length, live.length)} + ${out} ${T.issuedN} + ${late ? `${late} ${T.overdueN}` : ""} + ${reserve ? `${reserve} ${T.reserveN}` : ""}`; +} + +/* ------------------------------------------------------------------- board */ +function peg(i) { + const late = isLate(i); + return ``; +} + +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 ``; + }).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]) => `
+
${esc(label)} ${T.outCount(list.filter((i) => i.status === "out").length)} · ${list.length}
+
${list.map(peg).join("")}
`).join("") + || `
+ ${S.items.length ? T.noMatch : T.noKeysYet + " " + T.admin + "."}
`; + + $scroll.innerHTML = `
+
${tiles}
+ ${cabinet !== "ALL" && cab(cabinet).note ? `

${esc(cab(cabinet).note)}

` : ""} +
${[["all", T.fEverything]] + .concat((S.kinds || []).map((k) => ["k:" + k.id, k.name])) + .concat([["out", T.fOut], ["late", T.fLate]]) + .map(([k, l]) => ``).join("")}
+ ${rails} +
+ ${T.lgIn}${T.lgOut} + ${T.lgRes}${T.lgLate} + ${(S.kinds || []).map((k) => `${esc(k.name)}`).join("")} +
+

${T.boardNote}

`; +} + +/* ------------------------------------------------------------------- rows */ +function itemRow(i) { + const late = isLate(i), l = i.loan; + const name = i.door_id ? door(i.door_id).name : i.zone; + return ``; +} + +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 ``; +} + +/* ------------------------------------------------------------------- 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 = `
+ + ${match.length || tags.length + ? Object.entries(groups).map(([b, list]) => + `
${esc(b)} · ${T.doorsN(list.length)}
${list.map(doorRow).join("")}`).join("") + + (tags.length ? `
${T.tagsSection} · ${tags.length}
${tags.map(itemRow).join("")}` : "") + : `
${esc(T.nothingFound(query))}
`} +

${T.doorsNote}

`; + + 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) => ``; + const holding = rows.filter((r) => r.held.length), free = rows.filter((r) => !r.held.length); + $scroll.innerHTML = `
+ ${holding.length ? `
${T.holdingNow}
${holding.map(row).join("")}` : ""} + ${free.length ? `
${T.nothingOut}
${free.map(row).join("")}` : ""} + ${rows.length ? "" : `
${T.noPeopleYet} ${T.admin}.
`}
`; +} + +/* --------------------------------------------------------------------- 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 = `
${T.logTitle}
+ ${ev.length ? `
${ev.map((e) => { + const it = item(e.h.item_id), p = person(e.h.person_id); + return `
+ ${short(e.d)} + ${esc(it ? it.tag : "?")} + ${e.dir === "out" ? T.toWord : T.backFrom} ${esc(p ? p.name : "?")} + ${e.h.has_sig ? ` ✓ ${T.signedMark}` : ""} + ${e.h.note ? `
${esc(e.h.note)}
` : ""}
+ ${e.dir === "out" ? T.dirOut : T.dirIn}
`; + }).join("")}
` : `
${T.noHandovers}
`} +

${T.logNote}

`; +} + +/* ----------------------------------------------------------------- journal */ +let requests = []; + +function requestCard(r) { + const done = r.status === "handled"; + return `
+

${esc(r.name)}

+
${esc(r.dept || "")}${r.dept && r.contact ? " · " : ""}${esc(r.contact || "")}
+
${esc(done ? T.reqHandledOn(short(r.handled)) : T.reqOn(short(r.created.slice(0, 10))))}
+ ${r.areas ? `

${esc(r.areas)}

` : ""} + ${r.detail ? `

${esc(r.detail)}

` : ""} + ${r.note ? `

${esc(r.note)}

` : ""} + ${done ? "" : `
+ +
`} +
`; +} + +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 + ? `
${T.reqFormPrivate}
` + : rf.pin + ? `
${T.reqFormPublic} + · ${T.reqFormOpenIt}
` + : `
${T.reqFormOpen} + · ${T.reqFormOpenIt}
`; + + $scroll.innerHTML = `
+
${T.reqInbox}${pending.length ? `${pending.length}` : ""}
+ ${status} + ${pending.length ? pending.map(requestCard).join("") + : `
${T.reqNone}
`} + ${done.length ? `
${T.reqHandled}
${done.map(requestCard).join("")}` : ""} + +
${T.newEntry}
+
+ +
+
${T.journalTitle}
+ ${S.journal.length ? S.journal.map((j) => `
+ ${short(j.date)} + + ${esc(j.text)} + ${j.author ? `${esc(j.author)}` : ""}
`).join("") + : `
${T.journalEmpty}
`} +

${T.journalNote}

`; + + 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 = '
' + 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(`

${esc(d.num)} · ${esc(d.name)}

${esc(d.building)}

+
+ ${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)}
+
+
${T.fLockGroup}
${esc(d.lock_group || "—")}
+
${T.fCopiesLiveIn}
${esc([...new Set(c.map((i) => cab(i.cabinet_id).name))].join(", ") || "—")}
+ ${d.notes ? `
${T.noteOnDoor}
${esc(d.notes)}
` : ""} +
${T.everyCopy}
${c.map(itemRow).join("")} + ${sameLock.length ? `
${T.sameLock}
${sameLock.map(doorRow).join("")}` : ""} +
`); +} + +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 + ? `
${T.withSince(esc(person(l.person_id).name), fmt(l.out_date))}${T.dueWas(fmt(l.due_date), days(l.due_date, S.today))}
` + : i.status === "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}
` + : i.status === "reserve" + ? `
${T.reserveLine}
` + : i.status === "lost" ? `
${T.lostLine}
` + : i.status === "retired" ? `
${T.retiredLine}
` + : `
${T.inCabinet(`${esc(c.full_name)}`)}${i.hook ? T.atHook(esc(i.hook)) : ""}
`; + + openSheet(`

${esc(i.tag)}

+

${esc(d ? d.name : i.zone)}${i.building ? " · " + esc(i.building) : ""}

${status} + ${l && l.note && i.status === "out" ? `
${T.noteOnHandover} +
${esc(l.note)}
` : ""} +
+
${T.fType}
${esc(kindOf(i.kind).name)}
+
${T.fKeptIn}
${esc(c.full_name)}${c.note ? `${esc(c.note)}` : ""}
+ ${i.hook ? `
${T.fHook}
${esc(i.hook)}
` : ""} + ${d ? `
${T.fLockGroup}
${esc(d.lock_group || "—")}
` : ""} + ${i.uid ? `
${T.fChipUid}
${esc(i.uid)}
` : ""} + ${i.is_master ? `
${T.fMaster}
${T.yes}
` : ""}
+ ${d ? `` : ""} +
${T.noteOnKey} + +
+
+ ${i.status === "in" || i.status === "reserve" + ? `` + : i.status === "out" ? `` : ""} +
+
${T.history}
+ ${hist.length ? `
${hist.map((h) => { + const p = person(h.person_id); + const hLate = !h.in_date && h.due_date && h.due_date < S.today; + return `
${short(h.out_date)} + ${esc(p ? p.name : "?")} +
${h.in_date ? T.returnedOn(fmt(h.in_date)) : T.stillHolding}
+ ${h.note ? `
${esc(h.note)}
` : ""} + ${h.signature ? `
Signature
` : ""}
+ ${h.in_date ? T.stClosed : hLate ? T.stLate : T.stOpen}
`; + }).join("")}
` : `
${T.neverIssued}
`} +
`); +} + +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(`

${esc(p.name)}

${esc(p.dept)}${p.role ? " · " + esc(p.role) : ""}

+
+
${T.fHoldingNow}
${T.itemsN(held.length)}${ + held.length ? `${esc(T.fromCabinets([...new Set(held.map((i) => cab(i.cabinet_id).name))].join(", ")))}` : ""}
+
${T.fReturnedPast}
${past.length}
+ ${p.phone ? `
${T.fPhone}
${esc(p.phone)}
` : ""}
+ ${held.length ? `
${T.currentlyHolds}
${held.map(itemRow).join("")} + ${T.printPdf} — ${T.repReceiptTitle}` + : `
${T.holdsNothing}
`} + ${past.length ? `
${T.returnedSection}
${past.map((h) => { + const it = item(h.item_id); + return `
${short(h.in_date)} + ${esc(it ? it.tag : "?")} +
${T.heldFromTo(fmt(h.out_date), fmt(h.in_date))}
`; + }).join("")}
` : ""} +
`); +} + +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 `
${q ? esc(T.nothingFound(pickQuery)) + : T.noActivePeople + ` ${T.admin}.`}
`; + } + return active.map((p) => { + const holds = S.items.filter((i) => i.loan && i.loan.person_id === p.id && i.status === "out").length; + return ``; + }).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(`

${esc(T.issueTitle(i.tag))}

+

${esc(i.door_id ? door(i.door_id).name : i.zone)} · ${esc(T.fromThe(cab(i.cabinet_id).full_name))}

+ ${i.status === "reserve" ? `
${T.reserveWarn}
` : ""} +
${T.whoTakes}
+ +
${personPickList(i.id)}
+
`); + 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(`

${T.handover}

+

${esc(i.tag)} · ${esc(i.door_id ? door(i.door_id).name : i.zone)} → ${esc(p.name)}

+
${T.backBy}
+
${[["none", T.dNone], ["7", T.d7], ["30", T.d30], ["90", T.d90]] + .map(([k, l]) => ``).join("")}
+
${T.whyNote} +
+
${T.signature}
+
+ ${pending.sig ? "" : `
${T.signHint}
`}
+
+
+
+ +
`); + + 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}${S.pending}`; + document.title = T.brand; +} + +function render() { + ({ board: viewBoard, doors: viewDoors, people: viewPeople, log: viewLog, journal: viewJournal })[tab](); +} +load(); diff --git a/public/i18n.js b/public/i18n.js new file mode 100644 index 0000000..1eafba4 --- /dev/null +++ b/public/i18n.js @@ -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.
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,
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 ${who} desde ${d}.`, + dueWas: (d, n) => `
Devolução prevista ${d} — há ${n} dias.`, + issuedToOn: (who, d) => `Entregue a ${who} em ${d}`, + dueBack: (d) => `
Devolução prevista ${d}.`, + noDueDate: "
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 ${total} ${total === 1 ? "cópia" : "cópias"}. ${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 reserva e não a no armário, 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 ${c}, atualizados ${u}.`, + impTitle: "Importar de uma folha de cálculo", + impHelp: "Cole CSV. Colunas obrigatórias: tag, door, building. 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 ${i} itens, criadas ${d} portas e ${c} armários.`, + impSkipped: (n, list) => `
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.
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,
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 ${w} since ${d}.`, + dueWas: (d, n) => `
Due back ${d} — ${n} days ago.`, + issuedToOn: (w, d) => `Issued to ${w} on ${d}`, + dueBack: (d) => `
Due back ${d}.`, + noDueDate: "
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 `${total} cop${total === 1 ? "y" : "ies"} 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 reserve rather than in cabinet, 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 ${c}, updated ${u}.`, + impTitle: "Import from a spreadsheet", + impHelp: "Paste CSV. Required columns: tag, door, building. 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 ${i} items, created ${d} doors and ${c} cabinets.`, + impSkipped: (n, list) => `
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; } diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..b909fce --- /dev/null +++ b/public/index.html @@ -0,0 +1,53 @@ + + + + + + +Key Register + + + + + + +
+
+

+

+ +
+
+
+ +
+
+ +
+ + + + diff --git a/public/login.html b/public/login.html new file mode 100644 index 0000000..ca5e5e8 --- /dev/null +++ b/public/login.html @@ -0,0 +1,133 @@ + + + + + + + +Registo de Chaves + + + + + + + + + + + + diff --git a/public/pedido.html b/public/pedido.html new file mode 100644 index 0000000..01b8690 --- /dev/null +++ b/public/pedido.html @@ -0,0 +1,164 @@ + + + + + + + +Pedido de acesso + + + + + + +
+
+
+

+

+

+
+
+ +
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+ +
+ + +
+ + + + + + + + +
+
+ + + + + diff --git a/public/report.html b/public/report.html new file mode 100644 index 0000000..58e571d --- /dev/null +++ b/public/report.html @@ -0,0 +1,26 @@ + + + + + +Relatório + + + + + + +
+
+ ← + + + +
+
+
+ + + + diff --git a/public/report.js b/public/report.js new file mode 100644 index 0000000..a751790 --- /dev/null +++ b/public/report.js @@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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 `
+

${esc(T.brand)}

+

${esc(title)}

+

${esc(T.repGenerated(fmt(S.today), now.toTimeString().slice(0, 5)))}${ + sub ? " · " + esc(sub) : ""}

`; +} + +const stat = (n, label) => `
${n}${esc(label)}
`; + +/* ---------------------------------------------------------------- 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) + `

${esc(T.repNothing)}

`; + + return head(onlyLate ? T.repOverdue : T.repOut) + + `
+ ${stat(out.length, T.stKeysOut)} + ${stat(ids.length, T.stPeople)} + ${stat(S.items.filter(isLate).length, T.stOverdue)} +
` + + ids.map((pid) => { + const p = person(pid), list = byPerson[pid]; + return `
+

${esc(p.name)}

+

${esc(p.dept)}${p.role ? " · " + esc(p.role) : ""}${ + p.phone ? " · " + esc(p.phone) : ""}

+ + + + + ${list.map((i) => { + const late = isLate(i); + return ` + + + + + + `; + }).join("")} +
${esc(T.cTag)}${esc(T.cDoor)}${esc(T.cBuilding)}${esc(T.cCabinet)}${esc(T.cOut)}${esc(T.cDue)}
${esc(i.tag)}${esc(doorName(i))}${esc(i.building)}${esc(cab(i.cabinet_id).name)}${fmt(i.loan.out_date)}${i.loan.due_date ? fmt(i.loan.due_date) : "—"}${ + late ? ` (+${days(i.loan.due_date, S.today)})` : ""}
`; + }).join("") + signOff(); +} + +/* ------------------------------------------------- receipt for one person */ +async function viewPerson(id) { + const p = person(id); + if (!p) return head(T.repReceiptTitle) + `

${esc(T.repChoosePerson)}

`; + 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) + `

${esc(T.repNothing)}

`; + + return head(T.repReceiptTitle, `${p.name}${p.dept ? " · " + p.dept : ""}`) + + `

${esc(T.repReceiptIntro(rows.length))}

+ + + + + ${rows.map((h) => { + const late = h.due_date && h.due_date < S.today; + return ` + + + + `; + }).join("")} +
${esc(T.cTag)}${esc(T.cDoor)}${esc(T.cBuilding)}${esc(T.cCabinet)}${esc(T.cOut)}${esc(T.cDue)}
${esc(h.tag)}${esc(h.door || "")}${esc(h.building)}${esc(cab(h.cabinet_id).name)}${fmt(h.out_date)}${h.due_date ? fmt(h.due_date) : "—"}
+
${esc(T.signature)}
+ ${rows.filter((h) => h.signature).map((h) => `
+
${esc(h.tag)} · ${esc(T.repSignedOn(fmt(h.out_date)))}
+
`).join("") + || `

${esc(T.repNoSig)}

`} + ${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) + + `
+ ${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)} +
` + + S.cabinets.filter((c) => groups[c.id]).map((c) => `
+

${esc(c.full_name)}

+

${esc(c.note || "")}

+ + + + + ${groups[c.id].map((i) => ` + + + + `).join("")} +
${esc(T.cTag)}${esc(T.cDoor)}${esc(T.cBuilding)}${esc(T.cHook)}${esc(T.cStatus)}${esc(T.cPerson)}
${esc(i.tag)}${esc(doorName(i))}${esc(i.building)}${esc(i.hook)}${esc(statusWord(i))}${i.loan && i.status === "out" ? esc(person(i.loan.person_id).name) : ""}
`).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) + + `

${esc(T.repAuditIntro)}

` + + cabs.map((c) => { + const list = S.items.filter((i) => i.cabinet_id === c.id && i.status !== "retired").sort(byHook); + if (!list.length) return ""; + return `
+

${esc(c.full_name)}

+

${esc(c.note || "")}

+ + + + + + ${list.map((i) => ` + + + + + `).join("")} +
${esc(T.cCheck)}${esc(T.cTag)}${esc(T.cHook)}${esc(T.cDoor)}${esc(T.cStatus)}${esc(T.cPerson)}
${esc(i.tag)}${esc(i.hook)}${esc(doorName(i))}${esc((T.statuses && T.statuses[i.status]) || i.status)}${i.loan && i.status === "out" ? esc(person(i.loan.person_id).name) : ""}
`; + }).join("") + + `
+
${esc(T.repAuditBy)}
+
${esc(T.repDate)}
+
+
${esc(T.repNotes)}
+
`; +} + +function signOff(holder) { + return `
+ ${holder ? `
+
${esc(T.repHolder)} — ${esc(holder)}
` : ""} +
${esc(T.repResponsible)}
+
${esc(T.repDate)}
+
`; +} + +/* ------------------------------------------------------------------ shell */ +async function render() { + $out.innerHTML = ""; + const isPerson = view === "person"; + const isAudit = view === "audit"; + $who.style.display = isPerson || isAudit ? "" : "none"; + + if (isPerson) { + $who.innerHTML = `` + + S.people.map((p) => ``).join(""); + $out.innerHTML = await viewPerson(whoId); + } else if (isAudit) { + $who.innerHTML = `` + + S.cabinets.map((c) => ``).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 = `

${esc(T ? T.noServer : "offline")}

`; + 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]) => ``).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(); +})(); diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..e3be05d --- /dev/null +++ b/public/styles.css @@ -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} diff --git a/server.js b/server.js new file mode 100644 index 0000000..c7613cc --- /dev/null +++ b/server.js @@ -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"}`); +});