Registo de chaves
This commit is contained in:
commit
ef83aa153f
31
.dockerignore
Normal file
31
.dockerignore
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# ---------------------------------------------------------------------------
|
||||
# What must NEVER end up inside the image.
|
||||
#
|
||||
# The Dockerfile now does `COPY . .`, so this file is the only thing standing
|
||||
# between your key register and a container image you might push to a public
|
||||
# registry. Treat additions here with care.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# The register itself. Baking the database into an image would ship a list of
|
||||
# every door in the building and who holds each key.
|
||||
data/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
backups/
|
||||
|
||||
# Secrets.
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Rebuilt inside the image; copying the host's would break native modules
|
||||
# built for a different architecture.
|
||||
node_modules/
|
||||
|
||||
# Noise.
|
||||
.git/
|
||||
.gitignore
|
||||
*.log
|
||||
.DS_Store
|
||||
npm-debug.log*
|
||||
26
.env.example
Normal file
26
.env.example
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# Copy to .env if you want to change any of these.
|
||||
TZ=Europe/Lisbon
|
||||
|
||||
# Interface language: pt or en
|
||||
UI_LANG=pt
|
||||
|
||||
# Optional shared passcode on top of Tailscale. Empty = no login at all.
|
||||
ACCESS_CODE=
|
||||
|
||||
# Number of reverse proxies in front of the app. 1 for Nginx Proxy Manager,
|
||||
# 2 for Cloudflare in front of NPM. Leave EMPTY when nothing proxies it —
|
||||
# trusting a forwarded header that nobody sets lets clients forge their IP.
|
||||
TRUST_PROXY=
|
||||
|
||||
# --- access request form (/pedido) ---------------------------------------
|
||||
# A shared PIN people must type. Change it when it leaks; it will.
|
||||
REQUEST_PIN=
|
||||
|
||||
# 1 = make /pedido reachable WITHOUT the app passcode, so people outside your
|
||||
# Tailscale can submit. Read Part 5 of the README before turning this on: it
|
||||
# is the only endpoint an outsider can reach. Leave at 0 for staff-only use.
|
||||
PUBLIC_REQUESTS=0
|
||||
|
||||
# 1 = load sample data on first start, so you can click around.
|
||||
# Set back to 0 and use Admin > Clear everything before real data.
|
||||
SEED_DEMO=0
|
||||
43
.forgejo/workflows/build.yml
Normal file
43
.forgejo/workflows/build.yml
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# Optional: build and publish the image whenever you push a tag.
|
||||
#
|
||||
# Requires a Forgejo Actions runner registered to this repository or instance.
|
||||
# If you have no runner, ignore this file and build from your laptop or the
|
||||
# VPS instead — see Part 10 of the README.
|
||||
#
|
||||
# Tag a release with: git tag v1.0 && git push origin v1.0
|
||||
|
||||
name: build image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Forgejo registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.plascloud.pt
|
||||
username: ${{ github.actor }}
|
||||
# Forgejo provides this automatically; no secret to configure.
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
# Add linux/arm64 only if you also run it on ARM. Building the
|
||||
# native SQLite module under emulation is slow.
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: |
|
||||
git.plascloud.pt/luisl/lxc-keyreg:${{ github.ref_name }}
|
||||
git.plascloud.pt/luisl/lxc-keyreg:latest
|
||||
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# ---------------------------------------------------------------------------
|
||||
# The register itself. A git repository is copied, mirrored and cloned — the
|
||||
# one place a list of every door and who holds each key must never end up.
|
||||
# ---------------------------------------------------------------------------
|
||||
data/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
backups/
|
||||
*.backup
|
||||
|
||||
# Secrets: ACCESS_CODE, REQUEST_PIN, database passwords.
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Built locally, per architecture.
|
||||
node_modules/
|
||||
|
||||
# Noise
|
||||
*.log
|
||||
npm-debug.log*
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
41
Dockerfile
Normal file
41
Dockerfile
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# ---------------------------------------------------------------------------
|
||||
# Church key register
|
||||
# Self-contained image: source, dependencies and static assets all baked in.
|
||||
# Push it to a registry and run it anywhere without the source tree.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Stage 1 — dependencies only.
|
||||
# package.json is copied on its own so `npm install` is cached and does not
|
||||
# re-run every time you edit a .js file. better-sqlite3 is native, and on ARM
|
||||
# hardware there may be no prebuilt binary, so a compiler must be present.
|
||||
FROM node:22-bookworm-slim AS deps
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 make g++ ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev --no-audit --no-fund
|
||||
|
||||
# Stage 2 — runtime. No compiler shipped.
|
||||
FROM node:22-bookworm-slim
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
sqlite3 ca-certificates && rm -rf /var/lib/apt/lists/* \
|
||||
&& mkdir -p /data && chown node:node /data
|
||||
|
||||
COPY --from=deps --chown=node:node /app/node_modules ./node_modules
|
||||
|
||||
# Everything else. .dockerignore decides what stays out — read it before
|
||||
# changing anything here, because that file is what keeps your database and
|
||||
# your .env from being baked into an image you might push to a registry.
|
||||
COPY --chown=node:node . .
|
||||
|
||||
# Files downloaded from a browser often arrive owner-only (600), and COPY
|
||||
# preserves the mode. Without this the non-root user cannot read its own code.
|
||||
RUN chmod -R a+rX /app
|
||||
|
||||
ENV NODE_ENV=production DATA_DIR=/data PORT=3000 UI_LANG=pt
|
||||
EXPOSE 3000
|
||||
USER node
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s \
|
||||
CMD node -e "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
CMD ["node", "server.js"]
|
||||
666
README.md
Normal file
666
README.md
Normal file
|
|
@ -0,0 +1,666 @@
|
|||
# Church Key Register
|
||||
|
||||
Tracks which physical key copy and which NFC alarm tag is held by which person,
|
||||
across several cabinets, with a signed record of every handover.
|
||||
|
||||
Node + SQLite in one container. The whole register is a single file.
|
||||
|
||||
**Print Part 5 and tape it inside the key cabinet.**
|
||||
|
||||
---
|
||||
|
||||
## What it does
|
||||
|
||||
- **Board** — one peg per piece of metal, grouped by cabinet, in hook order.
|
||||
Filled means it's on its hook, hollow means it's out with someone.
|
||||
- **Doors** — one row per door, showing all its copies and everyone holding one.
|
||||
- **People** — who holds what, and everything they've ever returned.
|
||||
- **Log** — every handover, append-only, with signatures.
|
||||
- **Journal** — cabinet-level events that belong to no single key.
|
||||
- **Admin** at `/admin` — keys, doors, people, cabinets, CSV import, backup.
|
||||
|
||||
Key holders never log in. Only you and your colleague use it.
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — Install
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/keyreg && cd /opt/keyreg
|
||||
# copy the project files here
|
||||
mkdir -p data
|
||||
docker compose up -d --build
|
||||
docker compose logs -f keyreg
|
||||
```
|
||||
|
||||
First build takes a few minutes — `better-sqlite3` is compiled from source.
|
||||
|
||||
### Access, via Tailscale
|
||||
|
||||
```bash
|
||||
curl -fsSL https://tailscale.com/install.sh | sh
|
||||
sudo tailscale up
|
||||
sudo tailscale serve --bg 3000
|
||||
tailscale status # gives you https://yournas.tailXXXX.ts.net
|
||||
```
|
||||
|
||||
Install Tailscale on your phone, sign in to the same account, open that
|
||||
address. Nothing is exposed to the internet and no ports are opened.
|
||||
|
||||
**Real HTTPS is what makes the phone camera and signature pad behave.** Over
|
||||
plain `http://` on a LAN IP, some browsers restrict things.
|
||||
|
||||
### Optional passcode
|
||||
|
||||
Set `ACCESS_CODE` in a `.env` file next to `docker-compose.yml` for a second
|
||||
layer on top of Tailscale. Leave it empty and there is no login at all, which
|
||||
is a reasonable choice when only your own devices can reach the app.
|
||||
|
||||
When it is set, a login screen appears. The cookie stores a hash rather than
|
||||
the passcode itself, and five wrong attempts from one address trigger a
|
||||
one-minute pause — a shared passcode is short enough to be worth guessing
|
||||
otherwise. Failed attempts appear in Admin → Change log.
|
||||
|
||||
### Want to click around first?
|
||||
|
||||
Set `SEED_DEMO=1`, start it, explore, then wipe from **Admin → Import /
|
||||
backup → Clear everything** and set it back to `0`.
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — Load your keys
|
||||
|
||||
The model is three tables, and getting this right matters more than the app:
|
||||
|
||||
| Table | What it holds |
|
||||
|---|---|
|
||||
| **Doors** | The 48 physical doors. One row each. |
|
||||
| **Items** | The ~80 pieces of metal, plus 30 alarm tags. `12-A`, `12-B`, `12-C`. |
|
||||
| **Handovers** | Who took which copy, when, and when it came back. |
|
||||
|
||||
**Every physical copy needs its own tag number.** Your 48 numbers identify
|
||||
doors; add a letter for each copy. Without this, the register can tell you a
|
||||
key is out but never *which one* — and then it is no better than paper.
|
||||
|
||||
### Import
|
||||
|
||||
Admin → Importar / cópias, then pick what you are importing. Four separate
|
||||
importers. **Load them in this order** — keys point at doors and cabinets, so
|
||||
those must exist first:
|
||||
|
||||
1. Cabinets → 2. Doors → 3. People → 4. Keys & tags
|
||||
|
||||
#### How the CSV is read
|
||||
|
||||
- The **first row must be the header**, naming the columns.
|
||||
- Column **order does not matter**, and names are **not case-sensitive**
|
||||
(`Tag`, `tag` and `TAG` are the same).
|
||||
- Columns you don't need can be **left out entirely**, or left blank per row.
|
||||
- Extra columns the importer doesn't recognise are **ignored**, so you can
|
||||
paste a wider spreadsheet without cleaning it up first.
|
||||
- Comma, semicolon or tab all work as separators — Excel in Portuguese usually
|
||||
exports semicolons, and that is fine.
|
||||
- Use quotes around any value containing the separator:
|
||||
`12-A,"Sacristia, porta interior",Igreja`
|
||||
- Wherever a column is described as **yes/no** below, these all count as yes:
|
||||
`1`, `sim`, `s`, `yes`, `y`, `true`, `x`. Anything else, including blank,
|
||||
counts as no.
|
||||
|
||||
#### 1. Cabinets
|
||||
|
||||
| Column | Required | What it is |
|
||||
|---|---|---|
|
||||
| `id` | **yes** | Short code, e.g. `SAC`. Forced to uppercase. This is what the keys file refers to. |
|
||||
| `name` | **yes** | Short label for the tab on the board, e.g. `Sacristia`. Keep it under ~12 characters. |
|
||||
| `full_name` | no | Full name used in headings, e.g. `Armário da sacristia`. Defaults to `name`. |
|
||||
| `note` | no | Where it is and what opens it. Shown under the tab. |
|
||||
| `off_site` | no | yes/no. Yes for your home reserve box: keys returned there go back to *reserve* instead of *in cabinet*. |
|
||||
| `sort` | no | Number. Lower comes first on the board. Give the reserve a high number so it sits last. Default `0`. |
|
||||
|
||||
```csv
|
||||
id,name,full_name,note,off_site,sort
|
||||
SAC,Sacristia,Armário da sacristia,Sacristia atrás da porta,0,1
|
||||
CASA,Reserva,Conjunto de reserva,Caixa selada fora das instalações,1,20
|
||||
```
|
||||
|
||||
#### 2. Doors
|
||||
|
||||
| Column | Required | What it is |
|
||||
|---|---|---|
|
||||
| `num` | **yes** | The door number, exactly as on your list. Must be unique. |
|
||||
| `building` | **yes** | The grouping the door belongs to, e.g. `Igreja`. Also the list of **areas** offered on the public request form, so avoid anything you would not show an outsider. |
|
||||
| `name` | **yes** | The door itself, e.g. `Sacristia`. |
|
||||
| `lock_group` | no | Doors sharing a key profile get the same code, e.g. `B`. Lets you see what else a lost key might open. Fill in later if you don't know yet. |
|
||||
| `notes` | no | Anything about the door itself. |
|
||||
|
||||
```csv
|
||||
num,building,name,lock_group,notes
|
||||
1,Igreja,Entrada principal poente,A,
|
||||
2,Igreja,Sacristia,B,Fechadura dura no inverno
|
||||
```
|
||||
|
||||
#### 3. People
|
||||
|
||||
| Column | Required | What it is |
|
||||
|---|---|---|
|
||||
| `name` | **yes** | Full name. This is the match key — re-importing the same name **updates** that person. |
|
||||
| `dept` | no | Department or group, e.g. `Coro`. |
|
||||
| `role` | no | Function, e.g. `Diretora do coro`. |
|
||||
| `phone` | no | Useful when chasing an overdue key. |
|
||||
| `email` | no | Stored only; the app sends no email. |
|
||||
| `active` | no | yes/no. **Blank means yes.** Set no for people who no longer hold keys — they stay in old handovers but cannot be given new ones. |
|
||||
|
||||
```csv
|
||||
name,dept,role,phone
|
||||
Maria Fonseca,Secretaria,Secretária,910000000
|
||||
João Ribeiro,Manutenção,Sacristão,
|
||||
```
|
||||
|
||||
#### 4. Keys & tags
|
||||
|
||||
One row per **physical piece of metal**, not per door.
|
||||
|
||||
| Column | Required | What it is |
|
||||
|---|---|---|
|
||||
| `tag` | **yes** | The number stamped on that copy, e.g. `12-A`. Must be unique — this is what makes the register able to say *which* copy is out. |
|
||||
| `door` | **yes** | The door **name**, matched against the doors you already imported. If no door matches, one is created. |
|
||||
| `building` | **yes** | Used together with `door` to find the right door, since two buildings may both have a `Cozinha`. |
|
||||
| `lock_group` | no | **Only used if this row has to create a new door.** For doors that already exist it is ignored — edit the door instead. |
|
||||
| `cabinet` | no | The cabinet `id`, e.g. `SAC`. Created if it does not exist, but then it gets no proper name, so import cabinets first. |
|
||||
| `hook` | no | Position in the cabinet, e.g. `SAC·R2·05`. Sets the order keys appear on the board and on the audit sheet. Blank falls back to tag order. |
|
||||
| `master` | no | yes/no. Marks it as a master key on the board. |
|
||||
| `status` | no | `in` (default), `out`, `reserve`, `lost`, `retired`. Use `reserve` for copies in the off-site box. Do **not** import `out` — issue the key in the app instead, so there is a handover record. |
|
||||
| `kind` | no | The type **code** from Admin → Tipos, e.g. `key`, `nfc`, `cadeado`. Defaults to the first type. A code that does not exist is **refused**, and the row is listed as skipped — create the type first. |
|
||||
| `notes` | no | Permanent note about this copy. Searchable. |
|
||||
|
||||
```csv
|
||||
tag,door,building,cabinet,hook,master,status,notes
|
||||
12-A,Sacristia,Igreja,SAC,SAC·R1·03,,in,
|
||||
12-B,Sacristia,Igreja,SEC,SEC·R2·01,,in,Cópia guardada de propósito na secretaria
|
||||
12-R,Sacristia,Igreja,CASA,CASA·12,,reserve,
|
||||
```
|
||||
|
||||
#### Re-running an import
|
||||
|
||||
All four are safe to run again. Cabinets match on `id`, doors on `num`, people
|
||||
on `name` (ignoring capitals) — so a corrected spreadsheet **updates** those
|
||||
records instead of creating duplicates. Keys match on `tag` and are **skipped**
|
||||
if the tag already exists, so you can never end up with two records for one
|
||||
piece of metal. The result box tells you how many were created, updated and
|
||||
skipped.
|
||||
|
||||
There is a useful distinction between a **missing column** and an **empty
|
||||
cell**:
|
||||
|
||||
- A column you leave out is **not touched**. Re-importing just
|
||||
`name,role` updates everyone's role and leaves phone and department alone.
|
||||
- A column that is present but blank on that row **clears** the field. That is
|
||||
how you empty something on purpose.
|
||||
|
||||
So to fix one field across many people, export, delete every column except
|
||||
`name` and the one you are correcting, and re-import. Nothing else changes.
|
||||
|
||||
To change a key that already exists, edit it in Admin → Chaves e etiquetas.
|
||||
|
||||
### Types
|
||||
|
||||
Out of the box there are two: **Chave** (circle, green) and **Etiqueta de
|
||||
alarme** (rounded, violet). Add your own in Admin → Tipos — padlocks, gate
|
||||
remotes, fobs, meter-cupboard keys, whatever you keep on those hooks.
|
||||
|
||||
Each type has:
|
||||
|
||||
- a **code**, used by the `kind` column on import. Lowercase, no spaces. Do not
|
||||
change it once keys exist with it.
|
||||
- a **name**, shown everywhere. Change it whenever you like.
|
||||
- a **shape** — circle, rounded or square — and a **colour**.
|
||||
|
||||
The board reads shape as *what a thing is* and fill as *where it is*, so give
|
||||
each type a distinct shape rather than relying on colour alone. Types appear
|
||||
automatically as filters on the board and in the legend.
|
||||
|
||||
A type cannot be deleted while any item uses it, and you cannot delete the last
|
||||
one.
|
||||
|
||||
### Cabinets
|
||||
|
||||
Create them first (Admin → Cabinets), because import references them by code.
|
||||
|
||||
**Board order** controls where a cabinet appears on the board and in reports.
|
||||
Lower numbers come first. Give the reserve store a high number — 20, say — so
|
||||
it always sits at the end, after the cabinets you use daily.
|
||||
|
||||
Within a cabinet, keys are shown in **hook order**, using the hook field. Fill
|
||||
it in and the board reads down the cabinet exactly as the hooks do, which is
|
||||
what makes the audit sheet countable. Leave it blank and keys fall back to tag
|
||||
order.
|
||||
|
||||
Tick **off site** for your home reserve box. Keys returning to an off-site
|
||||
store go back to *reserve* rather than *in cabinet*, so they stay out of
|
||||
everyday circulation. Issuing one writes itself into the journal.
|
||||
|
||||
### Two physical rules
|
||||
|
||||
- **Label each key with the number only.** `12-A`, never "Sacristy". A lost
|
||||
key labelled with its door is an invitation.
|
||||
- **Never store a cabinet's own key inside that cabinet.** Cross-store them,
|
||||
and keep a reserve copy of each off site.
|
||||
|
||||
---
|
||||
|
||||
## Part 3 — Everyday use
|
||||
|
||||
- **Giving out a key:** Board or Doors → tap the key → Issue → pick the person
|
||||
→ set a return date if it's temporary → they sign on your phone.
|
||||
- **Taking it back:** tap the key → Take it back.
|
||||
- **Who has what:** People tab, or Doors for a whole door at once.
|
||||
- **Overdue:** the Overdue filter on the Board. Check it monthly — nothing
|
||||
emails you.
|
||||
|
||||
Most church keys are held permanently, so "no return date" is the default.
|
||||
Only set a date for genuinely temporary loans.
|
||||
|
||||
---
|
||||
|
||||
## Part 4 — Printed reports
|
||||
|
||||
The printer icon in the header, or `/report`. Everything renders as a plain
|
||||
web page, so your browser's own **Print → Save as PDF** produces the file.
|
||||
Nothing to install, nothing to keep updated.
|
||||
|
||||
| Report | Use it for |
|
||||
|---|---|
|
||||
| **Keys currently issued** | Grouped by person. The one to print for the council. |
|
||||
| **Overdue keys** | Only what is past its return date. |
|
||||
| **Receipt per person** | What that person holds, with their captured signatures, and a line to sign. Print when someone takes on a permanent set. |
|
||||
| **Full inventory** | Every key by cabinet. A copy in the folder, and proof of the whole register on paper. |
|
||||
| **Audit sheet** | A tick-box list per cabinet, in hook order. Take it to the cabinet and count along. |
|
||||
|
||||
The audit sheet is the one to use for the monthly check. It marks which keys
|
||||
should be absent, so a missing key is obvious rather than something you have
|
||||
to remember.
|
||||
|
||||
Reports link deeply: `/report?view=person&id=3` goes straight to one person's
|
||||
receipt. There is a shortcut on each person's card in the app.
|
||||
|
||||
---
|
||||
|
||||
## Part 5 — Access requests (`/pedido`)
|
||||
|
||||
A form where someone asks for access. It creates **nothing** — no person, no
|
||||
key, no handover. It files a request and writes a line in the journal. You
|
||||
still decide.
|
||||
|
||||
Requests appear at the top of the **Journal** tab, with a count on the tab so
|
||||
you notice them. Handle one by writing what you decided; it stays on record.
|
||||
|
||||
### Checking whether the form is actually protected
|
||||
|
||||
The Journal tab shows the live state of the form, read from the running
|
||||
server rather than from what you think is in `.env`:
|
||||
|
||||
- *Formulário só acessível a quem entra na aplicação* — staff only.
|
||||
- *Formulário público, protegido por código* — public, PIN required.
|
||||
- *FORMULÁRIO PÚBLICO SEM CÓDIGO* — in red. Public with no PIN, which means
|
||||
anyone who finds the address can post. Set `REQUEST_PIN` and restart.
|
||||
|
||||
The server prints the same thing on startup, so `docker compose logs keyreg`
|
||||
tells you too. The PIN field only appears on the form when a PIN is
|
||||
configured — so if you open `/pedido` and see no PIN box, there is no PIN.
|
||||
|
||||
### Handing your phone to someone
|
||||
|
||||
The **✚** icon in the app header opens the form in hand-over mode
|
||||
(`/pedido?kiosk=1`). Give the person your phone, they fill it in, and the
|
||||
confirmation says *"Pode devolver o telemóvel"* with a button for a fresh
|
||||
blank form if two people are waiting.
|
||||
|
||||
No PIN is asked for in this case. The PIN exists to stop strangers on the
|
||||
internet, and you are standing next to them — so a request submitted from
|
||||
inside the app skips it. The journal records which way it came, *Formulário
|
||||
(no local)* or *Formulário (online)*, so months later you can tell whether you
|
||||
were there.
|
||||
|
||||
**One caveat, and it is a real one.** The form page has no link back into the
|
||||
app, but the browser's back button still works. If you hand over an unlocked
|
||||
phone, that person can return to the register. For a visitor you do not know,
|
||||
use Guided Access on iOS or Screen Pinning on Android to lock the phone to
|
||||
that one screen first. For a colleague, it does not matter.
|
||||
|
||||
### It is staff-only until you say otherwise
|
||||
|
||||
`PUBLIC_REQUESTS=0` is the default, which means `/pedido` sits behind the same
|
||||
passcode as the rest of the app. Useful as a tablet form in the office, but
|
||||
nobody outside can submit.
|
||||
|
||||
Setting `PUBLIC_REQUESTS=1` makes `/pedido` and its two endpoints reachable
|
||||
without the passcode. **That is the only part of this system an outsider can
|
||||
reach**, so understand what you are opening:
|
||||
|
||||
- Set `REQUEST_PIN` as well. Without it, anything that finds the URL can post.
|
||||
- The PIN is shared, so treat it as semi-public — print it in the church
|
||||
bulletin if you like, and change it when it spreads too far.
|
||||
- Five wrong PINs from one address pause that address for five minutes. A
|
||||
hidden field catches bots that fill in every input; their submissions are
|
||||
silently discarded rather than rejected, so they get no feedback to learn from.
|
||||
|
||||
### What the form deliberately does not show
|
||||
|
||||
It offers **areas** — your building names — never door names. A public page
|
||||
listing "Sacristia, Torre sineira, Casa das caldeiras" is a map of the building
|
||||
for anyone who finds it. The `/api/request/areas` endpoint returns building
|
||||
names only.
|
||||
|
||||
Check what your building names actually are before opening this up: they come
|
||||
from the `building` field on your doors, so if you have a building called
|
||||
"Armários" it will be offered as a requestable area. Rename anything that
|
||||
reads oddly to an outsider.
|
||||
|
||||
### Behind a reverse proxy
|
||||
|
||||
Set `TRUST_PROXY=1` (or `2` for Cloudflare in front of Nginx Proxy Manager).
|
||||
Without it every request looks like it came from the proxy, so all rate limits
|
||||
share one bucket and five wrong attempts lock out everyone. Leave it **empty**
|
||||
when nothing proxies the app — trusting a forwarded header nobody sets lets a
|
||||
client forge their own address and dodge the limits.
|
||||
|
||||
### Reaching it from outside
|
||||
|
||||
Tailscale keeps everything private, which is the point — so publishing
|
||||
`/pedido` needs a deliberate second path. A Cloudflare Tunnel restricted to
|
||||
that one path is the usual approach. Whatever you choose, confirm that `/`,
|
||||
`/admin` and `/api/state` are **not** reachable by the same route before you
|
||||
share the link.
|
||||
|
||||
---
|
||||
|
||||
## Part 6 — Backups
|
||||
|
||||
```bash
|
||||
chmod +x backup.sh
|
||||
# edit BACKUP_DIR and OFFSITE_DEST at the top
|
||||
sudo ./backup.sh # run once by hand
|
||||
sudo crontab -e
|
||||
# 20 3 * * * /opt/keyreg/backup.sh >> /var/log/keyreg-backup.log 2>&1
|
||||
```
|
||||
|
||||
It writes a verified SQLite snapshot **and** a CSV. The CSV matters: if the app
|
||||
is ever gone, a spreadsheet still tells you who holds which key.
|
||||
|
||||
**Do a test restore before you trust it.** The instructions are at the bottom
|
||||
of `backup.sh` and amount to copying one file back.
|
||||
|
||||
---
|
||||
|
||||
## Part 7 — Handover to the next person
|
||||
|
||||
Fill this in and keep it current:
|
||||
|
||||
- Machine: `________________________`
|
||||
- Address: `https://________________.ts.net`
|
||||
- Tailscale account: `________________________`
|
||||
- Passcode, if set, is in: `________________________`
|
||||
- Backups run at 03:20 to: `________________________`
|
||||
- Off-site copy at: `________________________`
|
||||
- Technical help: `________________________`
|
||||
|
||||
**Monthly, five minutes:**
|
||||
|
||||
1. Board → Overdue filter. Chase anything red.
|
||||
2. Check `/var/log/keyreg-backup.log` for failures.
|
||||
3. Print the audit sheet for one cabinet and count along the hooks.
|
||||
|
||||
**Yearly:** full audit of every cabinet, and confirm the reserve box is still
|
||||
sealed. Write it in the Journal.
|
||||
|
||||
**On handover:** add their device to Tailscale, walk them through one real
|
||||
handover and one restore, then remove your own access.
|
||||
|
||||
---
|
||||
|
||||
## Design decisions worth keeping
|
||||
|
||||
- **Nothing is ever deleted.** People are deactivated, keys are retired. Both
|
||||
appear in old handovers, and deleting them would make that history
|
||||
anonymous — which is exactly what the register exists to prevent.
|
||||
- **Handovers are append-only.** Returning fills in a date; it never erases.
|
||||
- **Cabinet and building are separate fields**, because the office cabinet can
|
||||
hold spare masters for the church.
|
||||
- **Admin changes are logged** in Admin → Change log. With two of you editing,
|
||||
"who moved 12-B and when" comes up eventually.
|
||||
|
||||
---
|
||||
|
||||
## Part 8 — Running it on a VPS
|
||||
|
||||
Works well: the app is small, and a VPS gives you a real hostname, automatic
|
||||
certificates and uptime independent of the church building. But the security
|
||||
assumptions change, because the login page now faces the internet.
|
||||
|
||||
### Non-negotiable once it is public
|
||||
|
||||
1. **Set `ACCESS_CODE`.** On a private network, running with no login was a
|
||||
defensible choice. On a VPS it is not.
|
||||
2. **Set `TRUST_PROXY=1`**, or all rate limiting collapses into one bucket.
|
||||
3. **Add Basic auth in NPM** (Access Lists) as a second, independent layer, so
|
||||
bots never reach the application at all. Skip this only if you need
|
||||
`/pedido` publicly reachable — in which case rely on the app's own gate,
|
||||
which already allows exactly the request paths and nothing else.
|
||||
4. **Force SSL, HTTP/2 and HSTS** on the proxy host.
|
||||
5. **Pull backups off the VPS.** Set `OFFSITE_DEST` in `backup.sh`. If the
|
||||
server dies or the account is suspended, whatever is only on that disk is
|
||||
gone.
|
||||
|
||||
### Do not publish the port
|
||||
|
||||
If NPM runs in Docker on the same host, delete the `ports:` block from
|
||||
`docker-compose.yml` and join NPM's network instead (both are commented in the
|
||||
file). Point the proxy host at `keyreg` port `3000`. Nothing then listens on
|
||||
any public interface.
|
||||
|
||||
Confirm it afterwards:
|
||||
|
||||
```bash
|
||||
ss -lntp | grep 3000 # should print nothing
|
||||
curl -sS http://YOUR_VPS_IP:3000/health # should refuse to connect
|
||||
```
|
||||
|
||||
### Choosing how to protect it
|
||||
|
||||
Three workable shapes, in order of how much they expose:
|
||||
|
||||
**1. Nothing public.** Run Tailscale on the VPS as well and keep the app
|
||||
private, exactly as it would be at home. You still get the VPS's uptime and no
|
||||
dependency on the church building. Choose this unless you actually need
|
||||
outsiders to submit requests.
|
||||
|
||||
**2. Basic auth over everything.** An NPM **Access List** puts a browser
|
||||
password prompt in front of the whole host, and `ACCESS_CODE` sits behind it —
|
||||
two independent layers, and bots never reach the application at all. Simple:
|
||||
no path rules to maintain. The cost is that `/pedido` is behind the prompt too,
|
||||
so only people with that password can submit a request. Fine when you are
|
||||
always the one holding the phone.
|
||||
|
||||
**3. Public request form, everything else closed.** Do **not** add an Access
|
||||
List. Set `ACCESS_CODE`, `REQUEST_PIN` and `PUBLIC_REQUESTS=1`, and rely on the
|
||||
app's own gate: it allows exactly `/pedido`, `/api/request`,
|
||||
`/api/request/areas`, `/styles.css` and `/i18n.js`, and returns the login
|
||||
screen or 401 for everything else. That whitelist lives in the code, so it
|
||||
cannot drift out of step with nginx location blocks the day a route is added.
|
||||
|
||||
A neat hybrid, if you want both: Tailscale for the app *and* NPM publishing
|
||||
only `/pedido`.
|
||||
|
||||
### Nginx Proxy Manager settings
|
||||
|
||||
On the proxy host: **Block Common Exploits**, **Websockets off** (unused),
|
||||
and under SSL: **Force SSL**, **HTTP/2**, **HSTS enabled**.
|
||||
|
||||
Forward to `keyreg` port `3000` over the shared Docker network — not to
|
||||
`127.0.0.1`, which would not resolve from inside the NPM container.
|
||||
|
||||
### Basic auth has no lockout
|
||||
|
||||
NPM will not rate-limit it. If you use an Access List, run fail2ban on the VPS
|
||||
watching NPM's log for repeated 401s.
|
||||
|
||||
### One thing you lose
|
||||
|
||||
When the VPS or your connection is down, you cannot look up a key while
|
||||
standing at the cabinet. Print the full inventory report and keep it in the
|
||||
cabinet — worth doing regardless, and the reason the report exists.
|
||||
|
||||
---
|
||||
|
||||
## Part 9 — Git and the Forgejo registry
|
||||
|
||||
### Push the code
|
||||
|
||||
```bash
|
||||
cd /path/to/keyreg
|
||||
git init
|
||||
git add .
|
||||
git commit -m "Registo de chaves"
|
||||
git branch -M main
|
||||
git remote add origin https://git.plascloud.pt/LuisL/lxc-keyreg.git
|
||||
git push -u origin main
|
||||
```
|
||||
|
||||
**Check `git status` before the first commit.** `.gitignore` keeps `data/`,
|
||||
any `.db` file and `.env` out — a repository gets cloned and mirrored, and a
|
||||
list of every door with its key holders is the last thing that should travel.
|
||||
If `git status` shows `.env` or anything under `data/`, stop and fix it first.
|
||||
|
||||
Once a secret has been committed, removing it in a later commit is not enough
|
||||
— it stays in the history. Change the value instead.
|
||||
|
||||
### Build and publish the image
|
||||
|
||||
Create a token in Forgejo: **Settings → Applications → Generate token**, with
|
||||
the `write:package` scope. Then:
|
||||
|
||||
```bash
|
||||
docker login git.plascloud.pt # username LuisL, password = the token
|
||||
|
||||
docker buildx build \
|
||||
--platform linux/amd64 \
|
||||
-t git.plascloud.pt/luisl/lxc-keyreg:1.0 \
|
||||
-t git.plascloud.pt/luisl/lxc-keyreg:latest \
|
||||
--push .
|
||||
```
|
||||
|
||||
The owner part of the image name must be **lowercase**, whatever case your
|
||||
username uses.
|
||||
|
||||
Pick the platform to match the VPS, not your laptop. A Mac builds `arm64` by
|
||||
default and that image will not start on an x86 VPS. `uname -m` on the VPS
|
||||
tells you: `x86_64` → `linux/amd64`, `aarch64` → `linux/arm64`.
|
||||
|
||||
### Run it on the VPS
|
||||
|
||||
Only two files are needed there — `docker-compose.yml` and `.env`:
|
||||
|
||||
```bash
|
||||
docker login git.plascloud.pt
|
||||
echo "IMAGE=git.plascloud.pt/luisl/lxc-keyreg:1.0" >> .env
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Delete the `build: .` line from `docker-compose.yml` once you are pulling a
|
||||
published image, so a stray `--build` cannot rebuild from a stale checkout.
|
||||
|
||||
### Upgrading, and going back
|
||||
|
||||
```bash
|
||||
./backup.sh # always first
|
||||
# bump IMAGE to the new tag in .env
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
Tag every build with a real version. If `1.1` misbehaves, putting `1.0` back
|
||||
in `.env` and running `docker compose up -d` restores the previous image in
|
||||
seconds. `latest` gives you nothing to go back to.
|
||||
|
||||
### Simpler, if you would rather skip the registry
|
||||
|
||||
The VPS can build it itself:
|
||||
|
||||
```bash
|
||||
git clone https://git.plascloud.pt/LuisL/lxc-keyreg.git keyreg
|
||||
cd keyreg && cp .env.example .env # edit it
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
No registry, no token, no cross-architecture problem — it builds for whatever
|
||||
the VPS is. `git pull && docker compose up -d --build` to upgrade. The registry
|
||||
is worth it once you are running it in more than one place.
|
||||
|
||||
### Automating it
|
||||
|
||||
`.forgejo/workflows/build.yml` builds and publishes on every `v*` tag, if you
|
||||
have a Forgejo Actions runner. No runner, no problem — the file is inert.
|
||||
|
||||
---
|
||||
|
||||
## Part 10 — Building once, running anywhere
|
||||
|
||||
The image is self-contained: source, dependencies and static files are all
|
||||
inside it. You can build it once, push it to a registry, and run it on the NAS
|
||||
without ever copying the source there.
|
||||
|
||||
### What is not in the image
|
||||
|
||||
`.dockerignore` keeps three things out, deliberately:
|
||||
|
||||
- **`data/` and any `.db` file.** Baking the register into an image would ship
|
||||
a list of every door in the building and who holds each key — to whatever
|
||||
registry you push to. Never remove this rule.
|
||||
- **`.env`.** Your passcode.
|
||||
- **`node_modules/`.** Rebuilt inside the image, because a native module built
|
||||
on your Mac will not run on the NAS.
|
||||
|
||||
### Build and push
|
||||
|
||||
Your Mac is almost certainly ARM64. If the NAS is x86, an image built plainly
|
||||
on the Mac **will not start there**. Build for both:
|
||||
|
||||
```bash
|
||||
docker buildx create --use --name keyreg-builder # once
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
-t ghcr.io/YOURNAME/keyreg:1.0 \
|
||||
-t ghcr.io/YOURNAME/keyreg:latest \
|
||||
--push .
|
||||
```
|
||||
|
||||
For a private registry, log in first with `docker login ghcr.io`.
|
||||
|
||||
### Run from the registry
|
||||
|
||||
On the NAS, you need only `docker-compose.yml`:
|
||||
|
||||
```bash
|
||||
echo "IMAGE=ghcr.io/YOURNAME/keyreg:1.0" >> .env
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Delete the `build: .` line there and the source tree is never needed.
|
||||
|
||||
### Version your tags
|
||||
|
||||
Tag every build with a real version, not just `latest`. When an upgrade
|
||||
misbehaves, `IMAGE=ghcr.io/YOURNAME/keyreg:1.0 docker compose up -d` rolls you
|
||||
back in seconds. `latest` gives you nothing to roll back to.
|
||||
|
||||
### Upgrading
|
||||
|
||||
```bash
|
||||
./backup.sh # always first
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The database lives in a Docker volume, not in the image, so it survives every
|
||||
upgrade untouched.
|
||||
73
backup.sh
Normal file
73
backup.sh
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Nightly backup for the key register.
|
||||
#
|
||||
# SQLite's .backup makes a consistent copy while the app is running — do NOT
|
||||
# just cp the .db file, because the WAL may hold recent writes that a plain
|
||||
# copy would miss.
|
||||
#
|
||||
# Install:
|
||||
# chmod +x backup.sh
|
||||
# sudo crontab -e
|
||||
# 20 3 * * * /opt/keyreg/backup.sh >> /var/log/keyreg-backup.log 2>&1
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
APP_DIR="/opt/keyreg"
|
||||
BACKUP_DIR="/mnt/backup/keyreg" # MUST be a different physical disk
|
||||
OFFSITE_DEST="" # e.g. "backup@nas2:/volume1/keyreg"
|
||||
KEEP_DAYS=60
|
||||
|
||||
STAMP="$(date +%Y-%m-%d_%H%M)"
|
||||
OUT="${BACKUP_DIR}/keyreg-${STAMP}.db"
|
||||
|
||||
log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*"; }
|
||||
trap 'log "FAILED at line $LINENO — backup NOT complete"; exit 1' ERR
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
cd "$APP_DIR"
|
||||
|
||||
# 1. Consistent snapshot
|
||||
log "Backing up database..."
|
||||
docker compose exec -T keyreg \
|
||||
sqlite3 /data/keyreg.db ".backup '/data/_tmp_backup.db'"
|
||||
docker compose cp keyreg:/data/_tmp_backup.db "$OUT"
|
||||
docker compose exec -T keyreg rm -f /data/_tmp_backup.db
|
||||
|
||||
# 2. Verify — an unreadable backup is not a backup
|
||||
sqlite3 "$OUT" "PRAGMA integrity_check;" | grep -q '^ok$' \
|
||||
|| { log "Integrity check FAILED"; exit 1; }
|
||||
ITEMS=$(sqlite3 "$OUT" "SELECT COUNT(*) FROM items;")
|
||||
[ "$ITEMS" -gt 0 ] || { log "Backup has zero keys — refusing to call this good"; exit 1; }
|
||||
gzip -9 "$OUT"
|
||||
log "OK — ${ITEMS} keys, $(stat -c %s "${OUT}.gz") bytes"
|
||||
|
||||
# 3. A readable copy too. If the app is ever gone, a CSV still tells you
|
||||
# who holds which key — which is the whole point of the register.
|
||||
curl -fsS "http://127.0.0.1:3000/api/admin/export.csv" \
|
||||
-o "${BACKUP_DIR}/keyreg-${STAMP}.csv" || log "WARNING: CSV export failed"
|
||||
|
||||
# 4. Off site — survives fire and theft, which is when you most need to know
|
||||
# who has keys to the building
|
||||
if [ -n "$OFFSITE_DEST" ]; then
|
||||
rsync -az --delete "${BACKUP_DIR}/" "${OFFSITE_DEST}/"
|
||||
log "Off-site sync done"
|
||||
else
|
||||
log "WARNING: OFFSITE_DEST empty — local copy only"
|
||||
fi
|
||||
|
||||
# 5. Retention
|
||||
find "$BACKUP_DIR" -maxdepth 1 -type f -mtime "+${KEEP_DAYS}" -delete
|
||||
log "Complete"
|
||||
|
||||
# ===========================================================================
|
||||
# RESTORE — test this once now, not on the day you need it
|
||||
#
|
||||
# cd /opt/keyreg
|
||||
# docker compose down
|
||||
# gunzip -c /mnt/backup/keyreg/keyreg-DATE.db.gz > ./data/keyreg.db
|
||||
# rm -f ./data/keyreg.db-wal ./data/keyreg.db-shm
|
||||
# docker compose up -d
|
||||
#
|
||||
# That is the whole restore. One file.
|
||||
# ===========================================================================
|
||||
60
docker-compose.yml
Normal file
60
docker-compose.yml
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# Church key register
|
||||
#
|
||||
# One container, one SQLite file. Access comes from Tailscale, not from
|
||||
# opening a port to the network.
|
||||
|
||||
services:
|
||||
keyreg:
|
||||
# Build locally, or point IMAGE at a registry copy and drop `build:`
|
||||
# entirely — the image is self-contained and needs no source tree.
|
||||
# IMAGE=ghcr.io/you/keyreg:1.0 docker compose up -d
|
||||
build: .
|
||||
image: ${IMAGE:-keyreg:local}
|
||||
container_name: keyreg
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
TZ: ${TZ:-Europe/Lisbon}
|
||||
# Interface language: pt (Portugal) or en.
|
||||
UI_LANG: ${UI_LANG:-pt}
|
||||
# Optional shared passcode. Leave empty and there is no gate at all —
|
||||
# correct when only your Tailscale devices can reach this.
|
||||
ACCESS_CODE: ${ACCESS_CODE:-}
|
||||
# Reverse proxies in front (1 = NPM, 2 = Cloudflare + NPM). Empty = none.
|
||||
TRUST_PROXY: ${TRUST_PROXY:-}
|
||||
# Shared PIN for the /pedido request form.
|
||||
REQUEST_PIN: ${REQUEST_PIN:-}
|
||||
# 1 = /pedido bypasses ACCESS_CODE so outsiders can submit. See README.
|
||||
PUBLIC_REQUESTS: ${PUBLIC_REQUESTS:-0}
|
||||
# Set to 1 for the very first run if you want sample data to click around.
|
||||
# Turn it back to 0 before entering anything real.
|
||||
SEED_DEMO: ${SEED_DEMO:-0}
|
||||
volumes:
|
||||
# A named volume, not a bind mount. SQLite in WAL mode needs shared
|
||||
# memory mapping, which Docker Desktop's file sharing on macOS and
|
||||
# Windows does not support — a bind mount there fails to open the
|
||||
# database, or corrupts it later. This works the same everywhere.
|
||||
- keyreg_data:/data
|
||||
ports:
|
||||
# Localhost only. `tailscale serve --bg 3000` puts HTTPS in front.
|
||||
# Change to "3000:3000" ONLY for temporary testing on your own LAN.
|
||||
#
|
||||
# ON A VPS WITH NGINX PROXY MANAGER: delete this whole `ports:` block and
|
||||
# uncomment `networks:` below. NPM then reaches the app by container name
|
||||
# over the shared Docker network, and nothing at all listens on the public
|
||||
# interface — not even on localhost, where any other user on the box could
|
||||
# curl it.
|
||||
- "127.0.0.1:3000:3000"
|
||||
|
||||
# networks:
|
||||
# - npm
|
||||
|
||||
# networks:
|
||||
# npm:
|
||||
# external: true
|
||||
# # The network NPM already runs on. Find it with:
|
||||
# # docker network ls
|
||||
# # docker inspect <npm-container> -f '{{json .NetworkSettings.Networks}}'
|
||||
# name: CHANGEME_npm_default
|
||||
|
||||
volumes:
|
||||
keyreg_data:
|
||||
19
package.json
Normal file
19
package.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "keyreg",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Church key and alarm-tag register",
|
||||
"type": "commonjs",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"seed": "node server.js --seed-demo"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"express": "^4.21.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
37
public/admin.html
Normal file
37
public/admin.html
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="pt">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#131E1B">
|
||||
<title>Key Register — Admin</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans+Condensed:wght@600;700&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="admin">
|
||||
<header>
|
||||
<p class="eyebrow" id="brand"></p>
|
||||
<h1 id="apptitle"></h1>
|
||||
<a class="gear" href="/" id="backlink">←</a>
|
||||
</header>
|
||||
<div class="adm-nav" id="nav">
|
||||
<button data-p="items" aria-pressed="true"><span data-t="aItems"></span></button>
|
||||
<button data-p="doors" aria-pressed="false"><span data-t="aDoors"></span></button>
|
||||
<button data-p="people" aria-pressed="false"><span data-t="aPeople"></span></button>
|
||||
<button data-p="cabinets" aria-pressed="false"><span data-t="aCabinets"></span></button>
|
||||
<button data-p="kinds" aria-pressed="false"><span data-t="aKinds"></span></button>
|
||||
<button data-p="data" aria-pressed="false"><span data-t="aData"></span></button>
|
||||
<button data-p="audit" aria-pressed="false"><span data-t="aAudit"></span></button>
|
||||
</div>
|
||||
<div id="body" style="padding:14px"><div class="empty" id="loading"></div></div>
|
||||
</div>
|
||||
<div class="scrim" id="scrim"></div>
|
||||
<div class="sheet" id="sheet" role="dialog" aria-modal="true"></div>
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="/i18n.js"></script>
|
||||
<script src="/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
446
public/admin.js
Normal file
446
public/admin.js
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
/* Key register — admin panel */
|
||||
"use strict";
|
||||
|
||||
let S = null, page = "items", q = "";
|
||||
const $body = document.getElementById("body");
|
||||
const $sheet = document.getElementById("sheet");
|
||||
const $scrim = document.getElementById("scrim");
|
||||
const $toast = document.getElementById("toast");
|
||||
|
||||
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||||
const cab = (id) => S.cabinets.find((c) => c.id === id) || { name: "—", full_name: "Unassigned" };
|
||||
const door = (id) => S.doors.find((d) => d.id === +id);
|
||||
|
||||
let tt;
|
||||
function toast(m, err) {
|
||||
$toast.textContent = m; $toast.className = "toast on" + (err ? " err" : "");
|
||||
clearTimeout(tt); tt = setTimeout(() => ($toast.className = "toast"), 3000);
|
||||
}
|
||||
async function api(url, opts) {
|
||||
const r = await fetch(url, opts);
|
||||
const b = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(b.error || r.statusText);
|
||||
return b;
|
||||
}
|
||||
async function load() {
|
||||
try {
|
||||
S = await api("/api/state");
|
||||
setLang(S.lang);
|
||||
chrome();
|
||||
render();
|
||||
} catch (e) { $body.innerHTML = `<div class="empty">${esc(T.noServer)}<br>${esc(e.message)}</div>`; }
|
||||
}
|
||||
function openSheet(h) {
|
||||
$sheet.innerHTML = '<div class="grab"></div>' + h;
|
||||
$sheet.classList.add("on"); $scrim.classList.add("on"); $sheet.scrollTop = 0;
|
||||
}
|
||||
function closeSheet() { $sheet.classList.remove("on"); $scrim.classList.remove("on"); }
|
||||
$scrim.onclick = closeSheet;
|
||||
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeSheet(); });
|
||||
const val = (id) => (document.getElementById(id) || {}).value || "";
|
||||
const checked = (id) => !!(document.getElementById(id) || {}).checked;
|
||||
|
||||
async function post(url, data, msg) {
|
||||
try {
|
||||
const r = await api(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) });
|
||||
closeSheet(); await load(); toast(r.note || msg || T.tSaved);
|
||||
} catch (e) { toast(errMsg(e.message), true); }
|
||||
}
|
||||
async function del(url, msg) {
|
||||
try {
|
||||
const r = await api(url, { method: "DELETE" });
|
||||
closeSheet(); await load(); toast(r.note || msg || T.tRemoved);
|
||||
} catch (e) { toast(errMsg(e.message), true); }
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- keys & tags */
|
||||
function pageItems() {
|
||||
const term = q.trim().toLowerCase();
|
||||
const list = S.items.filter((i) => !term ||
|
||||
(i.tag + " " + (i.door_id ? door(i.door_id).name : i.zone) + " " + i.building + " " + i.notes).toLowerCase().includes(term));
|
||||
$body.innerHTML = `
|
||||
<div class="card"><h3>${T.aItems}</h3>
|
||||
<p style="color:var(--slate);font-size:13px;margin:0 0 10px">
|
||||
${T.aItemsHelp(S.items.length)}</p>
|
||||
<input class="field" id="q" placeholder="${esc(T.aFilter)}" value="${esc(q)}" style="margin-bottom:10px">
|
||||
<button class="mini" data-new="item">${T.aAddItem}</button></div>
|
||||
<div class="card" style="padding:0;overflow-x:auto">
|
||||
<table><thead><tr><th>${T.aTag}</th><th>${T.aDoor}</th><th>${T.aCabinet}</th><th>${T.aHook}</th><th>${T.aStatus}</th><th></th></tr></thead>
|
||||
<tbody>${list.map((i) => `<tr>
|
||||
<td class="mono"><b>${esc(i.tag)}</b>${i.is_master ? ' <span style="color:var(--brass);font-size:10px">M</span>' : ""}
|
||||
<div style="font-size:11px;color:var(--slate-2)">${esc((S.kinds.find((k) => k.id === i.kind) || {}).name || i.kind)}</div></td>
|
||||
<td>${esc(i.door_id ? door(i.door_id).name : i.zone)}<div style="color:var(--slate-2);font-size:11.5px">${esc(i.building)}</div></td>
|
||||
<td>${esc(cab(i.cabinet_id).name)}</td>
|
||||
<td class="mono" style="font-size:11.5px">${esc(i.hook)}</td>
|
||||
<td>${esc((T.statuses && T.statuses[i.status]) || i.status)}</td>
|
||||
<td style="text-align:right"><button class="mini" data-edit-item="${i.id}">${T.aEdit}</button></td>
|
||||
</tr>`).join("") || `<tr><td colspan="6" style="text-align:center;color:var(--slate);padding:24px">${T.aNothing}</td></tr>`}
|
||||
</tbody></table></div>`;
|
||||
const el = document.getElementById("q");
|
||||
el.oninput = (e) => { q = e.target.value; pageItems(); const f = document.getElementById("q"); f.focus(); f.setSelectionRange(q.length, q.length); };
|
||||
}
|
||||
|
||||
function sheetItem(id) {
|
||||
const i = id ? S.items.find((x) => x.id === +id) : { tag: "", kind: "key", door_id: "", zone: "", cabinet_id: "", hook: "", is_master: 0, uid: "", status: "in", notes: "" };
|
||||
openSheet(`<h2>${id ? T.aEdit + " " + esc(i.tag) : T.aAddItem.replace("+ ", "")}</h2>
|
||||
<p class="sub">${T.vKey} / ${T.vTag}</p>
|
||||
<div class="grid2">
|
||||
<div><span class="lab">${T.aTag}</span><input class="field" id="f_tag" value="${esc(i.tag)}" placeholder="12-B"></div>
|
||||
<div><span class="lab">${T.fType}</span><select class="field" id="f_kind">
|
||||
${S.kinds.map((k) => `<option value="${esc(k.id)}"${i.kind === k.id ? " selected" : ""}>${esc(k.name)}</option>`).join("")}</select></div>
|
||||
</div>
|
||||
<span class="lab">${T.fZoneKeys}</span>
|
||||
<select class="field" id="f_door"><option value="">${T.fNoneTag}</option>
|
||||
${S.doors.map((d) => `<option value="${d.id}"${i.door_id === d.id ? " selected" : ""}>${esc(d.num)} · ${esc(d.name)} (${esc(d.building)})</option>`).join("")}</select>
|
||||
<span class="lab">${T.fZoneTags}</span>
|
||||
<input class="field" id="f_zone" value="${esc(i.zone)}" placeholder="Nave & sacristy">
|
||||
<div class="grid2">
|
||||
<div><span class="lab">${T.aCabinet}</span><select class="field" id="f_cab"><option value="">${T.fNone}</option>
|
||||
${S.cabinets.map((c) => `<option value="${esc(c.id)}"${i.cabinet_id === c.id ? " selected" : ""}>${esc(c.full_name)}</option>`).join("")}</select></div>
|
||||
<div><span class="lab">${T.aHook}</span><input class="field" id="f_hook" value="${esc(i.hook)}" placeholder="SAC·R2·05"></div>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<div><span class="lab">${T.aStatus}</span><select class="field" id="f_status">
|
||||
${["in", "out", "reserve", "lost", "retired"].map((s) => `<option value="${s}"${i.status === s ? " selected" : ""}>${esc((T.statuses && T.statuses[s]) || s)}</option>`).join("")}</select></div>
|
||||
<div><span class="lab">${T.fChipUid}</span><input class="field" id="f_uid" value="${esc(i.uid)}"></div>
|
||||
</div>
|
||||
<label style="display:flex;gap:9px;align-items:center;margin-bottom:12px;font-size:14px">
|
||||
<input type="checkbox" id="f_master"${i.is_master ? " checked" : ""}> ${T.fIsMaster}</label>
|
||||
<span class="lab">${T.fPermNote}</span>
|
||||
<textarea class="field" id="f_notes" style="min-height:70px">${esc(i.notes)}</textarea>
|
||||
<div class="actions">
|
||||
<button class="btn btn-p" id="save">${T.save}</button>
|
||||
<button class="btn btn-g" data-close>Cancel</button></div>
|
||||
${id ? `<button class="btn btn-d" style="width:100%;margin-bottom:16px" id="rm">${T.deleteRetire}</button>
|
||||
<p class="note" style="text-align:left">${T.retireNote}</p>` : ""}`);
|
||||
|
||||
document.getElementById("save").onclick = () => post("/api/admin/items", {
|
||||
id: id || undefined, tag: val("f_tag").trim(), kind: val("f_kind"),
|
||||
door_id: val("f_door") ? +val("f_door") : null, zone: val("f_zone").trim(),
|
||||
cabinet_id: val("f_cab") || null, hook: val("f_hook").trim(),
|
||||
is_master: checked("f_master") ? 1 : 0, uid: val("f_uid").trim(),
|
||||
status: val("f_status"), notes: val("f_notes").trim(),
|
||||
});
|
||||
if (id) document.getElementById("rm").onclick = () => del("/api/admin/items/" + id);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------- doors */
|
||||
function pageDoors() {
|
||||
const groups = {};
|
||||
S.doors.forEach((d) => (groups[d.building] = groups[d.building] || []).push(d));
|
||||
$body.innerHTML = `
|
||||
<div class="card"><h3>${T.aDoors}</h3>
|
||||
<p style="color:var(--slate);font-size:13px;margin:0 0 10px">
|
||||
${T.aDoorsHelp(S.doors.length)}</p>
|
||||
<button class="mini" data-new="door">${T.aAddDoor}</button></div>
|
||||
${Object.entries(groups).map(([b, list]) => `<div class="card" style="padding:0">
|
||||
<div style="padding:11px 14px 0"><h3>${esc(b)}</h3></div>
|
||||
<table><thead><tr><th>${T.aNo}</th><th>${T.aDoors}</th><th>${T.aLock}</th><th>${T.aCopies}</th><th></th></tr></thead><tbody>
|
||||
${list.map((d) => `<tr><td class="mono"><b>${esc(d.num)}</b></td><td>${esc(d.name)}</td>
|
||||
<td class="mono">${esc(d.lock_group)}</td>
|
||||
<td>${S.items.filter((i) => i.door_id === d.id).length}</td>
|
||||
<td style="text-align:right"><button class="mini" data-edit-door="${d.id}">${T.aEdit}</button></td></tr>`).join("")}
|
||||
</tbody></table></div>`).join("") || `<div class="empty">${T.aNothing}</div>`}`;
|
||||
}
|
||||
|
||||
function sheetDoor(id) {
|
||||
const d = id ? door(id) : { num: "", building: "", name: "", lock_group: "", notes: "" };
|
||||
const buildings = [...new Set(S.doors.map((x) => x.building))];
|
||||
openSheet(`<h2>${id ? T.aEdit + " " + esc(d.num) : T.aAddDoor.replace("+ ", "")}</h2>
|
||||
<div class="grid2">
|
||||
<div><span class="lab">${T.fNum}</span><input class="field" id="f_num" value="${esc(d.num)}" placeholder="12"></div>
|
||||
<div><span class="lab">${T.fLockGroup}</span><input class="field" id="f_group" value="${esc(d.lock_group)}" placeholder="B"></div>
|
||||
</div>
|
||||
<span class="lab">${T.fBuilding}</span>
|
||||
<input class="field" id="f_building" value="${esc(d.building)}" list="blist" placeholder="Church">
|
||||
<datalist id="blist">${buildings.map((b) => `<option value="${esc(b)}">`).join("")}</datalist>
|
||||
<span class="lab">${T.fDoorName}</span>
|
||||
<input class="field" id="f_name" value="${esc(d.name)}" placeholder="Sacristy">
|
||||
<span class="lab">${T.fNote}</span>
|
||||
<textarea class="field" id="f_notes" style="min-height:60px">${esc(d.notes)}</textarea>
|
||||
<div class="actions">
|
||||
<button class="btn btn-p" id="save">${T.save}</button>
|
||||
<button class="btn btn-g" data-close>Cancel</button></div>
|
||||
${id ? `<button class="btn btn-d" style="width:100%;margin-bottom:16px" id="rm">${T.deleteDoor}</button>` : ""}
|
||||
<p class="note" style="text-align:left">${T.lockGroupNote}</p>`);
|
||||
document.getElementById("save").onclick = () => post("/api/admin/doors", {
|
||||
id: id || undefined, num: val("f_num").trim(), building: val("f_building").trim(),
|
||||
name: val("f_name").trim(), lock_group: val("f_group").trim(), notes: val("f_notes").trim(),
|
||||
});
|
||||
if (id) document.getElementById("rm").onclick = () => del("/api/admin/doors/" + id);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------- people */
|
||||
function pagePeople() {
|
||||
$body.innerHTML = `
|
||||
<div class="card"><h3>${T.aPeople}</h3>
|
||||
<p style="color:var(--slate);font-size:13px;margin:0 0 10px">
|
||||
${T.aPeopleHelp}</p>
|
||||
<button class="mini" data-new="person">${T.aAddPerson}</button></div>
|
||||
<div class="card" style="padding:0;overflow-x:auto">
|
||||
<table><thead><tr><th>${T.aName}</th><th>${T.aDept}</th><th>${T.aHolding}</th><th></th></tr></thead><tbody>
|
||||
${S.people.map((p) => {
|
||||
const held = S.items.filter((i) => i.loan && i.loan.person_id === p.id && i.status === "out").length;
|
||||
return `<tr><td><b>${esc(p.name)}</b>${p.active ? "" : ` <span style="color:var(--slate-2);font-size:11px">${T.inactive}</span>`}
|
||||
<div style="color:var(--slate-2);font-size:11.5px">${esc(p.role)}</div></td>
|
||||
<td>${esc(p.dept)}</td><td>${held || "—"}</td>
|
||||
<td style="text-align:right"><button class="mini" data-edit-person="${p.id}">${T.aEdit}</button></td></tr>`;
|
||||
}).join("") || `<tr><td colspan="4" style="text-align:center;color:var(--slate);padding:24px">${T.aNothing}</td></tr>`}
|
||||
</tbody></table></div>`;
|
||||
}
|
||||
|
||||
function sheetPerson(id) {
|
||||
const p = id ? S.people.find((x) => x.id === +id) : { name: "", dept: "", role: "", phone: "", email: "", active: 1 };
|
||||
const held = id ? S.items.filter((i) => i.loan && i.loan.person_id === +id && i.status === "out") : [];
|
||||
openSheet(`<h2>${id ? T.aEdit : T.aAddPerson.replace("+ ", "")}</h2>
|
||||
<span class="lab">${T.aName}</span><input class="field" id="f_name" value="${esc(p.name)}">
|
||||
<div class="grid2">
|
||||
<div><span class="lab">${T.aDept}</span><input class="field" id="f_dept" value="${esc(p.dept)}" placeholder="Choir"></div>
|
||||
<div><span class="lab">${T.fFunction}</span><input class="field" id="f_role" value="${esc(p.role)}" placeholder="Director"></div>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<div><span class="lab">${T.fPhone}</span><input class="field" id="f_phone" value="${esc(p.phone)}"></div>
|
||||
<div><span class="lab">${T.fEmail}</span><input class="field" id="f_email" value="${esc(p.email)}"></div>
|
||||
</div>
|
||||
<label style="display:flex;gap:9px;align-items:center;margin-bottom:12px;font-size:14px">
|
||||
<input type="checkbox" id="f_active"${p.active ? " checked" : ""}> ${T.fActiveLabel}</label>
|
||||
${held.length ? `<div class="warnbox">${esc(T.holdsWarn(held.length, held.map((i) => i.tag).join(", ")))}</div>` : ""}
|
||||
<div class="actions">
|
||||
<button class="btn btn-p" id="save">${T.save}</button>
|
||||
<button class="btn btn-g" data-close>Cancel</button></div>
|
||||
${id ? `<button class="btn btn-d" style="width:100%;margin-bottom:16px" id="rm">${T.deactivate}</button>
|
||||
<p class="note" style="text-align:left">${T.peopleNote}</p>` : ""}`);
|
||||
document.getElementById("save").onclick = () => post("/api/admin/people", {
|
||||
id: id || undefined, name: val("f_name").trim(), dept: val("f_dept").trim(),
|
||||
role: val("f_role").trim(), phone: val("f_phone").trim(), email: val("f_email").trim(),
|
||||
active: checked("f_active") ? 1 : 0,
|
||||
});
|
||||
if (id) document.getElementById("rm").onclick = () => del("/api/admin/people/" + id);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- cabinets */
|
||||
function pageCabinets() {
|
||||
$body.innerHTML = `
|
||||
<div class="card"><h3>${T.aCabinets}</h3>
|
||||
<p style="color:var(--slate);font-size:13px;margin:0 0 10px">
|
||||
${T.aCabinetsHelp}</p>
|
||||
<button class="mini" data-new="cabinet">${T.aAddCabinet}</button></div>
|
||||
${S.cabinets.map((c) => `<div class="card">
|
||||
<div style="display:flex;justify-content:space-between;gap:12px;align-items:flex-start">
|
||||
<div><h3 style="margin-bottom:4px">${esc(c.full_name)}</h3>
|
||||
<div class="mono" style="font-size:11.5px;color:var(--slate-2)">${esc(c.id)}${c.off_site ? " · " + T.aOffSite : ""}</div>
|
||||
<p style="color:var(--slate);font-size:13px;margin:6px 0 0">${esc(c.note)}</p>
|
||||
<p style="font-size:13px;margin:6px 0 0">${T.aItemsCount(S.items.filter((i) => i.cabinet_id === c.id).length)}</p></div>
|
||||
<button class="mini" data-edit-cab="${esc(c.id)}">${T.aEdit}</button></div></div>`).join("")
|
||||
|| `<div class="empty">${T.aNothing}</div>`}`;
|
||||
}
|
||||
|
||||
function sheetCabinet(id) {
|
||||
const c = id ? S.cabinets.find((x) => x.id === id) : { id: "", name: "", full_name: "", note: "", off_site: 0, sort: S.cabinets.length + 1 };
|
||||
openSheet(`<h2>${id ? T.aEdit : T.aAddCabinet.replace("+ ", "")}</h2>
|
||||
<div class="grid2">
|
||||
<div><span class="lab">${T.fShortCode}</span>
|
||||
<input class="field" id="f_id" value="${esc(c.id)}" placeholder="SAC"${id ? " readonly" : ""}></div>
|
||||
<div><span class="lab">${T.fShortName}</span><input class="field" id="f_name" value="${esc(c.name)}" placeholder="Sacristy"></div>
|
||||
</div>
|
||||
<span class="lab">${T.fFullName}</span>
|
||||
<input class="field" id="f_full" value="${esc(c.full_name)}" placeholder="Sacristy cabinet">
|
||||
<span class="lab">${T.fWhereIs}</span>
|
||||
<textarea class="field" id="f_note" style="min-height:70px" placeholder="Sacristy, behind the door. Its own key lives in the office cabinet.">${esc(c.note)}</textarea>
|
||||
<label style="display:flex;gap:9px;align-items:center;margin-bottom:6px;font-size:14px">
|
||||
<input type="checkbox" id="f_off"${c.off_site ? " checked" : ""}> ${T.fOffSiteLabel}</label>
|
||||
<p class="note" style="text-align:left;margin-top:0">${T.offSiteNote}</p>
|
||||
<span class="lab">${T.fSort}</span>
|
||||
<input class="field" id="f_sort" type="number" value="${c.sort || 0}" style="margin-bottom:4px">
|
||||
<p class="note" style="text-align:left;margin:0 0 12px">${T.fSortHelp}</p>
|
||||
<div class="actions">
|
||||
<button class="btn btn-p" id="save">${T.save}</button>
|
||||
<button class="btn btn-g" data-close>Cancel</button></div>
|
||||
${id ? `<button class="btn btn-d" style="width:100%;margin-bottom:16px" id="rm">${T.deleteCabinet}</button>` : ""}`);
|
||||
document.getElementById("save").onclick = () => post("/api/admin/cabinets", {
|
||||
id: val("f_id").trim().toUpperCase(), name: val("f_name").trim(),
|
||||
full_name: val("f_full").trim() || val("f_name").trim(), note: val("f_note").trim(),
|
||||
off_site: checked("f_off") ? 1 : 0, sort: +val("f_sort") || 0,
|
||||
});
|
||||
if (id) document.getElementById("rm").onclick = () => del("/api/admin/cabinets/" + id);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------- types */
|
||||
const SHAPE_CSS = { circle: "border-radius:50%", rounded: "border-radius:5px", square: "border-radius:0" };
|
||||
|
||||
function pageKinds() {
|
||||
$body.innerHTML = `
|
||||
<div class="card"><h3>${T.aKinds}</h3>
|
||||
<p style="color:var(--slate);font-size:13px;margin:0 0 10px">${T.aKindsHelp}</p>
|
||||
<button class="mini" data-new="kind">${T.aAddKind}</button></div>
|
||||
${S.kinds.map((k) => {
|
||||
const n = S.items.filter((i) => i.kind === k.id).length;
|
||||
return `<div class="card" style="display:flex;justify-content:space-between;gap:12px;align-items:center">
|
||||
<div style="display:flex;align-items:center;gap:12px;min-width:0">
|
||||
<span class="kind-dot" style="background:${esc(k.colour)};border-color:${esc(k.colour)};${SHAPE_CSS[k.shape] || ""}"></span>
|
||||
<div style="min-width:0">
|
||||
<b>${esc(k.name)}</b>
|
||||
<div class="mono" style="font-size:11.5px;color:var(--slate-2)">${esc(k.id)} · ${esc(k.colour)}</div>
|
||||
<div style="font-size:12.5px;color:var(--slate)">${T.kindInUse(n)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="mini" data-edit-kind="${esc(k.id)}">${T.aEdit}</button>
|
||||
</div>`;
|
||||
}).join("")}`;
|
||||
}
|
||||
|
||||
function sheetKind(id) {
|
||||
const k = id ? S.kinds.find((x) => x.id === id)
|
||||
: { id: "", name: "", shape: "circle", colour: "#2E6E60", sort: S.kinds.length + 1 };
|
||||
const used = id ? S.items.filter((i) => i.kind === id).length : 0;
|
||||
openSheet(`<h2>${id ? T.aEdit : T.aAddKind.replace("+ ", "")}</h2>
|
||||
<div class="grid2">
|
||||
<div><span class="lab">${T.fKindCode}</span>
|
||||
<input class="field" id="f_id" value="${esc(k.id)}" placeholder="comando"${id ? " readonly" : ""}></div>
|
||||
<div><span class="lab">${T.fKindName}</span>
|
||||
<input class="field" id="f_name" value="${esc(k.name)}" placeholder="Comando de portão"></div>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<div><span class="lab">${T.fShape}</span><select class="field" id="f_shape">
|
||||
${[["circle", T.shCircle], ["rounded", T.shRounded], ["square", T.shSquare]]
|
||||
.map(([v, l]) => `<option value="${v}"${k.shape === v ? " selected" : ""}>${esc(l)}</option>`).join("")}</select></div>
|
||||
<div><span class="lab">${T.fColour}</span>
|
||||
<input class="field" id="f_colour" type="color" value="${esc(k.colour)}" style="height:46px;padding:4px"></div>
|
||||
</div>
|
||||
<span class="lab">${T.fSort}</span><input class="field" id="f_sort" type="number" value="${k.sort || 0}">
|
||||
<p class="note" style="text-align:left">${T.kindNote}</p>
|
||||
${used ? `<div class="warnbox">${T.kindInUse(used)}</div>` : ""}
|
||||
<div class="actions">
|
||||
<button class="btn btn-p" id="save">${T.save}</button>
|
||||
<button class="btn btn-g" data-close>${T.cancel}</button></div>
|
||||
${id ? `<button class="btn btn-d" style="width:100%;margin-bottom:16px" id="rm">${T.deleteKind}</button>` : ""}`);
|
||||
document.getElementById("save").onclick = () => post("/api/admin/kinds", {
|
||||
id: val("f_id"), name: val("f_name").trim(), shape: val("f_shape"),
|
||||
colour: val("f_colour"), sort: +val("f_sort") || 0,
|
||||
});
|
||||
if (id) document.getElementById("rm").onclick = () => del("/api/admin/kinds/" + id);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------- import / backup */
|
||||
let impKind = "items";
|
||||
|
||||
const IMP_SPEC = {
|
||||
items: { url: "/api/admin/import", req: "tag, door, building",
|
||||
opt: "lock_group, cabinet, hook, master, status, kind, notes",
|
||||
ex: "tag,door,building,lock_group,cabinet,hook\n12-A,Sacristia,Igreja,B,SAC,SAC·R1·03\n12-B,Sacristia,Igreja,B,SEC,SEC·R2·01" },
|
||||
doors: { url: "/api/admin/import/doors", req: "num, building, name",
|
||||
opt: "lock_group, notes",
|
||||
ex: "num,building,name,lock_group\n01,Igreja,Entrada principal,A\n02,Igreja,Sacristia,B" },
|
||||
people: { url: "/api/admin/import/people", req: "name",
|
||||
opt: "dept, role, phone, email, active",
|
||||
ex: "name,dept,role,phone\nMaria Fonseca,Secretaria,Secretária,910000000\nJoão Ribeiro,Manutenção,Sacristão," },
|
||||
cabinets: { url: "/api/admin/import/cabinets", req: "id, name",
|
||||
opt: "full_name, note, off_site, sort",
|
||||
ex: "id,name,full_name,note,off_site\nSAC,Sacristia,Armário da sacristia,Atrás da porta,0\nCASA,Reserva,Conjunto de reserva,Caixa selada fora,1" },
|
||||
};
|
||||
|
||||
function pageData() {
|
||||
const spec = IMP_SPEC[impKind];
|
||||
$body.innerHTML = `
|
||||
<div class="card"><h3>${T.impTitle}</h3>
|
||||
<span class="lab">${T.impWhat}</span>
|
||||
<div class="adm-nav" style="padding:0 0 12px">
|
||||
${[["items", T.impItems], ["doors", T.impDoors], ["people", T.impPeople], ["cabinets", T.impCabinets]]
|
||||
.map(([k, l]) => `<button data-imp="${k}" aria-pressed="${impKind === k}">${l}</button>`).join("")}
|
||||
</div>
|
||||
<p style="color:var(--slate);font-size:13px">
|
||||
<b>${esc(spec.req)}</b>${spec.opt ? " · " + esc(spec.opt) : ""}</p>
|
||||
<textarea class="field" id="csv" style="min-height:150px;font-family:'IBM Plex Mono',monospace;font-size:12.5px"
|
||||
placeholder="${esc(spec.ex)}"></textarea>
|
||||
<button class="mini" id="imp">${T.impBtn}</button>
|
||||
<div id="impres" style="margin-top:10px"></div></div>
|
||||
|
||||
<div class="card"><h3>${T.expTitle}</h3>
|
||||
<p style="color:var(--slate);font-size:13px">${T.expHelp}</p>
|
||||
<a class="mini" style="display:inline-block;text-decoration:none" href="/api/admin/export.csv">${T.expBtn}</a>
|
||||
<a class="mini" style="display:inline-block;text-decoration:none;margin-left:6px;background:var(--card);color:var(--ink);border:1px solid var(--line)" href="/report">${T.reports}</a></div>
|
||||
|
||||
<div class="card"><h3>${T.bkTitle}</h3>
|
||||
<p style="color:var(--slate);font-size:13px">${T.bkHelp}</p>
|
||||
<a class="mini" style="display:inline-block;text-decoration:none" href="/api/admin/backup.db">${T.bkBtn}</a></div>
|
||||
|
||||
<div class="card"><h3 style="color:var(--late)">${T.wipeTitle}</h3>
|
||||
<div class="warnbox">${T.wipeWarn}</div>
|
||||
<input class="field" id="wipe" placeholder="${esc(T.wipePlaceholder)}">
|
||||
<button class="btn btn-d" id="wipebtn" style="width:100%">${T.wipeBtn}</button></div>`;
|
||||
|
||||
document.getElementById("imp").onclick = async () => {
|
||||
const csv = document.getElementById("csv").value;
|
||||
if (!csv.trim()) return toast(T.impNothing, true);
|
||||
try {
|
||||
const r = await api(spec.url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ csv }) });
|
||||
/* the combined importer reports items/doors/cabinets, the single-table
|
||||
ones report created/updated — show whichever came back */
|
||||
const body = r.items !== undefined
|
||||
? T.impResult(r.items, r.doors, r.cabinets)
|
||||
: T.impResult2(r.created, r.updated);
|
||||
document.getElementById("impres").innerHTML =
|
||||
`<div class="status-line in">${body}${
|
||||
r.skipped && r.skipped.length
|
||||
? T.impSkipped(r.skipped.length, esc(r.skipped.slice(0, 12).join(", ")) + (r.skipped.length > 12 ? "…" : ""))
|
||||
: ""}</div>`;
|
||||
await load(); toast(T.imported);
|
||||
} catch (e) { toast(errMsg(e.message), true); }
|
||||
};
|
||||
document.getElementById("wipebtn").onclick = async () => {
|
||||
try {
|
||||
await api("/api/admin/wipe", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ confirm: document.getElementById("wipe").value }) });
|
||||
await load(); toast(T.wipeDone);
|
||||
} catch (e) { toast(errMsg(e.message), true); }
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------- audit */
|
||||
async function pageAudit() {
|
||||
let rows = [];
|
||||
try { rows = await api("/api/admin/audit"); } catch (e) { /* non-fatal */ }
|
||||
$body.innerHTML = `<div class="card"><h3>${T.auditTitle}</h3>
|
||||
<p style="color:var(--slate);font-size:13px;margin:0">${T.auditHelp}</p></div>
|
||||
<div class="card" style="padding:0"><table><thead><tr><th>${T.aWhen}</th><th>${T.aWhat}</th><th>${T.aDetail}</th></tr></thead><tbody>
|
||||
${rows.map((r) => `<tr><td class="mono" style="font-size:11.5px">${esc(r.ts.slice(0, 16).replace("T", " "))}</td>
|
||||
<td>${esc(r.action)}</td><td style="color:var(--slate)">${esc(r.detail)}</td></tr>`).join("")
|
||||
|| `<tr><td colspan="3" style="text-align:center;color:var(--slate);padding:24px">${T.aNothing}</td></tr>`}
|
||||
</tbody></table></div>`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ events */
|
||||
document.addEventListener("click", (e) => {
|
||||
const t = e.target.closest("[data-p],[data-imp],[data-new],[data-edit-item],[data-edit-door],[data-edit-person],[data-edit-cab],[data-edit-kind],[data-close]");
|
||||
if (!t) return;
|
||||
if (t.dataset.close !== undefined) return closeSheet();
|
||||
if (t.dataset.imp) { impKind = t.dataset.imp; return pageData(); }
|
||||
if (t.dataset.p) {
|
||||
page = t.dataset.p; q = "";
|
||||
document.querySelectorAll("#nav button").forEach((b) => b.setAttribute("aria-pressed", b.dataset.p === page));
|
||||
return render();
|
||||
}
|
||||
if (t.dataset.new === "item") return sheetItem(null);
|
||||
if (t.dataset.new === "door") return sheetDoor(null);
|
||||
if (t.dataset.new === "person") return sheetPerson(null);
|
||||
if (t.dataset.new === "cabinet") return sheetCabinet(null);
|
||||
if (t.dataset.new === "kind") return sheetKind(null);
|
||||
if (t.dataset.editItem) return sheetItem(t.dataset.editItem);
|
||||
if (t.dataset.editDoor) return sheetDoor(t.dataset.editDoor);
|
||||
if (t.dataset.editPerson) return sheetPerson(t.dataset.editPerson);
|
||||
if (t.dataset.editCab) return sheetCabinet(t.dataset.editCab);
|
||||
if (t.dataset.editKind) return sheetKind(t.dataset.editKind);
|
||||
});
|
||||
|
||||
function chrome() {
|
||||
document.getElementById("brand").textContent = T.brand;
|
||||
document.getElementById("apptitle").textContent = T.admin;
|
||||
document.getElementById("backlink").title = T.back;
|
||||
document.querySelectorAll("[data-t]").forEach((el) => (el.textContent = T[el.dataset.t]));
|
||||
document.title = T.brand + " — " + T.admin;
|
||||
}
|
||||
|
||||
function render() {
|
||||
({ items: pageItems, doors: pageDoors, people: pagePeople, cabinets: pageCabinets, kinds: pageKinds, data: pageData, audit: pageAudit })[page]();
|
||||
}
|
||||
load();
|
||||
594
public/app.js
Normal file
594
public/app.js
Normal file
|
|
@ -0,0 +1,594 @@
|
|||
/* Key register — front end. Talks to the API, holds no truth of its own. */
|
||||
"use strict";
|
||||
|
||||
let S = null; // server state
|
||||
let tab = "board", cabinet = "ALL", filter = "all", query = "", pending = null;
|
||||
|
||||
const $scroll = document.getElementById("scroll");
|
||||
const $sheet = document.getElementById("sheet");
|
||||
const $scrim = document.getElementById("scrim");
|
||||
const $toast = document.getElementById("toast");
|
||||
|
||||
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||||
const fmt = (d) => { if (!d) return "—"; const [y, m, dd] = d.split("-"); return `${dd} ${T._months[+m - 1]} ${y}`; };
|
||||
const short = (d) => { if (!d) return ""; const [y, m, dd] = d.split("-"); return `${dd}/${m}/${y.slice(2)}`; };
|
||||
const initials = (n) => n.split(" ").filter((w) => w.length > 2).slice(0, 2).map((w) => w[0]).join("") || n[0];
|
||||
const days = (a, b) => Math.round((new Date(b) - new Date(a)) / 864e5);
|
||||
|
||||
const item = (id) => S.items.find((i) => i.id === +id);
|
||||
const person = (id) => S.people.find((p) => p.id === +id);
|
||||
const door = (id) => S.doors.find((d) => d.id === +id);
|
||||
const cab = (id) => S.cabinets.find((c) => c.id === id) || { id: "", name: "—", full_name: "Unassigned", note: "" };
|
||||
const copiesOf = (did) => S.items.filter((i) => i.door_id === +did);
|
||||
const kindOf = (id) => (S.kinds || []).find((k) => k.id === id)
|
||||
|| { id, name: id, shape: "circle", colour: "#2E6E60" };
|
||||
/* one place that decides how any item is drawn */
|
||||
const kindClass = (i) => `shape-${kindOf(i.kind).shape}`;
|
||||
const kindStyle = (i) => `--k:${kindOf(i.kind).colour}`;
|
||||
const isLate = (i) => i.loan && i.loan.due_date && i.loan.due_date < S.today;
|
||||
/* Hook order, so the board reads in the same order as the physical hooks and
|
||||
you can count along during an audit. Numeric-aware, so R2 sorts before R10.
|
||||
Falls back to the tag when hooks are not filled in. */
|
||||
const byHook = (a, b) =>
|
||||
(a.hook || "").localeCompare(b.hook || "", undefined, { numeric: true }) ||
|
||||
a.tag.localeCompare(b.tag, undefined, { numeric: true });
|
||||
|
||||
let toastTimer;
|
||||
function toast(msg, err) {
|
||||
$toast.textContent = msg;
|
||||
$toast.className = "toast on" + (err ? " err" : "");
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => ($toast.className = "toast"), 2600);
|
||||
}
|
||||
|
||||
async function api(url, opts) {
|
||||
const r = await fetch(url, opts);
|
||||
const body = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(body.error || r.statusText);
|
||||
return body;
|
||||
}
|
||||
async function load() {
|
||||
try {
|
||||
S = await api("/api/state");
|
||||
setLang(S.lang);
|
||||
chrome();
|
||||
counts();
|
||||
render();
|
||||
} catch (e) {
|
||||
$scroll.innerHTML = `<div class="pane"><div class="empty">${esc(T.noServer)}<br>${esc(e.message)}</div></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ header */
|
||||
function counts() {
|
||||
const live = S.items.filter((i) => i.status !== "reserve" && i.status !== "retired");
|
||||
const reserve = S.items.filter((i) => i.status === "reserve").length;
|
||||
const late = S.items.filter(isLate).length;
|
||||
const out = S.items.filter((i) => i.status === "out").length;
|
||||
document.getElementById("counts").innerHTML = `
|
||||
<span class="count">${T.cabinetsN(S.cabinets.length)}</span>
|
||||
<span class="count"><span class="swatch in"></span>${T.keysIn(live.filter((i) => i.status === "in").length, live.length)}</span>
|
||||
<span class="count"><span class="swatch out"></span><b>${out}</b> ${T.issuedN}</span>
|
||||
${late ? `<span class="count warn"><b>${late}</b> ${T.overdueN}</span>` : ""}
|
||||
${reserve ? `<span class="count"><span class="swatch res"></span>${reserve} ${T.reserveN}</span>` : ""}`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------- board */
|
||||
function peg(i) {
|
||||
const late = isLate(i);
|
||||
return `<button class="peg ${kindClass(i)} st-${i.status}${late ? " late" : ""}"
|
||||
style="${kindStyle(i)}" data-item="${i.id}"
|
||||
aria-label="${esc(i.tag)}, ${esc(i.door_id ? door(i.door_id).name : i.zone)}, ${esc(kindOf(i.kind).name)}, ${late ? "overdue" : i.status}">
|
||||
${esc(i.tag)}${i.is_master ? "<small>MASTER</small>" : ""}
|
||||
${late ? '<span class="flag"></span>' : ""}${i.notes ? '<span class="memo">✎</span>' : ""}</button>`;
|
||||
}
|
||||
|
||||
function viewBoard() {
|
||||
const pass = (i) =>
|
||||
filter === "all" ? i.status !== "retired" :
|
||||
filter === "out" ? i.status === "out" :
|
||||
filter === "late" ? isLate(i) :
|
||||
filter.startsWith("k:") ? i.kind === filter.slice(2) && i.status !== "retired" :
|
||||
true;
|
||||
const show = S.items.filter((i) => pass(i) && (cabinet === "ALL" || i.cabinet_id === cabinet));
|
||||
|
||||
const tiles = [{ id: "ALL", name: T.allTile }].concat(S.cabinets).map((c) => {
|
||||
const list = c.id === "ALL" ? S.items : S.items.filter((i) => i.cabinet_id === c.id);
|
||||
return `<button class="cab" data-cab="${esc(c.id)}" aria-pressed="${cabinet === c.id}">
|
||||
<span class="cn">${esc(c.name)}</span>
|
||||
<span class="cs">${T.aItemsCount(list.length)} · <span class="o">${T.outCount(list.filter((i) => i.status === "out").length)}</span></span></button>`;
|
||||
}).join("");
|
||||
|
||||
/* Section order comes from the cabinets themselves, not from the order keys
|
||||
happen to appear in. Grouping by insertion order used to put a cabinet
|
||||
wherever its lowest-numbered key fell, ignoring its sort value entirely. */
|
||||
let sections;
|
||||
if (cabinet === "ALL") {
|
||||
sections = S.cabinets
|
||||
.map((c) => [c.full_name, show.filter((i) => i.cabinet_id === c.id).sort(byHook)])
|
||||
.filter(([, list]) => list.length);
|
||||
const orphans = show.filter((i) => !S.cabinets.some((c) => c.id === i.cabinet_id));
|
||||
if (orphans.length) sections.push(["—", orphans.sort(byHook)]);
|
||||
} else {
|
||||
/* Inside one cabinet, group by building in the order buildings appear in
|
||||
the door list, so it is stable rather than dependent on key numbering. */
|
||||
const order = [];
|
||||
S.doors.forEach((d) => { if (!order.includes(d.building)) order.push(d.building); });
|
||||
show.forEach((i) => { if (i.building && !order.includes(i.building)) order.push(i.building); });
|
||||
sections = order
|
||||
.map((b) => [b, show.filter((i) => i.building === b).sort(byHook)])
|
||||
.filter(([, list]) => list.length);
|
||||
}
|
||||
|
||||
const rails = sections.map(([label, list]) => `<div class="rail">
|
||||
<div class="rail-label">${esc(label)} <span>${T.outCount(list.filter((i) => i.status === "out").length)} · ${list.length}</span></div>
|
||||
<div class="pegs">${list.map(peg).join("")}</div></div>`).join("")
|
||||
|| `<div class="empty" style="background:transparent;border-color:#3A4C46;color:#8A968F">
|
||||
${S.items.length ? T.noMatch : T.noKeysYet + " <a href='/admin' style='color:#9FADA7'>" + T.admin + "</a>."}</div>`;
|
||||
|
||||
$scroll.innerHTML = `<div class="board">
|
||||
<div class="cabs">${tiles}</div>
|
||||
${cabinet !== "ALL" && cab(cabinet).note ? `<p class="where">${esc(cab(cabinet).note)}</p>` : ""}
|
||||
<div class="filters">${[["all", T.fEverything]]
|
||||
.concat((S.kinds || []).map((k) => ["k:" + k.id, k.name]))
|
||||
.concat([["out", T.fOut], ["late", T.fLate]])
|
||||
.map(([k, l]) => `<button class="chip" data-filter="${esc(k)}" aria-pressed="${filter === k}">${esc(l)}</button>`).join("")}</div>
|
||||
${rails}
|
||||
<div class="legend">
|
||||
<span><i class="lg-in"></i>${T.lgIn}</span><span><i class="lg-out"></i>${T.lgOut}</span>
|
||||
<span><i class="lg-res"></i>${T.lgRes}</span><span><i class="lg-late"></i>${T.lgLate}</span>
|
||||
${(S.kinds || []).map((k) => `<span><i class="lg-kind" style="background:${esc(k.colour)};border-color:${esc(k.colour)};${
|
||||
k.shape === "circle" ? "border-radius:50%" : k.shape === "square" ? "border-radius:0" : ""}"></i>${esc(k.name)}</span>`).join("")}
|
||||
</div>
|
||||
<p class="note">${T.boardNote}</p></div>`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------- rows */
|
||||
function itemRow(i) {
|
||||
const late = isLate(i), l = i.loan;
|
||||
const name = i.door_id ? door(i.door_id).name : i.zone;
|
||||
return `<button class="row${late ? " late" : ""}" data-item="${i.id}">
|
||||
<span class="badge ${kindClass(i)} st-${i.status}" style="${kindStyle(i)}">${esc(i.tag)}</span>
|
||||
<span class="row-main"><span class="row-t">${esc(name)}</span>
|
||||
<span class="row-s">${l && i.status === "out"
|
||||
? `<span class="${late ? "od" : "held"}">${esc(person(l.person_id) ? person(l.person_id).name : "?")}</span>${late ? ` · ${days(l.due_date, S.today)}d` : ""}`
|
||||
: i.status === "reserve" ? T.reserveCopy : i.status === "lost" ? T.lost
|
||||
: i.status === "retired" ? T.retired : T.onHook}</span></span>
|
||||
<span class="row-n">${esc(cab(i.cabinet_id).name)}</span></button>`;
|
||||
}
|
||||
|
||||
function doorRow(d) {
|
||||
const c = copiesOf(d.id).filter((i) => i.status !== "retired");
|
||||
const out = c.filter((i) => i.status === "out");
|
||||
const late = c.filter(isLate).length;
|
||||
const who = out.map((i) => { const p = person(i.loan.person_id); return p ? p.name.split(" ")[0] : "?"; });
|
||||
return `<button class="row${late ? " late" : ""}" data-door="${d.id}">
|
||||
<span class="badge door">${esc(d.num)}</span>
|
||||
<span class="row-main"><span class="row-t">${esc(d.name)}</span>
|
||||
<span class="row-s">${esc(d.building)}${who.length ? ` · <span class="${late ? "od" : "held"}">${esc(who.join(", "))}</span>` : " · " + T.allIn}</span></span>
|
||||
<span class="row-n">${T.copies(c.length)}
|
||||
<span class="dots">${c.map((i) => `<span class="dot ${isLate(i) ? "out" : i.status}" style="${kindStyle(i)}"></span>`).join("")}</span></span></button>`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------- doors */
|
||||
function viewDoors() {
|
||||
const q = query.trim().toLowerCase();
|
||||
const hay = (d) => {
|
||||
const c = copiesOf(d.id);
|
||||
const who = c.map((i) => { if (!i.loan) return ""; const p = person(i.loan.person_id); return p ? p.name + " " + p.dept : ""; }).join(" ");
|
||||
return (d.num + " " + d.name + " " + d.building + " " + d.lock_group + " " + who + " " +
|
||||
c.map((i) => i.tag + " " + i.notes).join(" ")).toLowerCase();
|
||||
};
|
||||
const match = S.doors.filter((d) => !q || hay(d).includes(q));
|
||||
/* items not attached to a door — alarm tags, remotes, anything zone-based */
|
||||
const tags = S.items.filter((i) => !i.door_id &&
|
||||
(!q || (i.tag + " " + i.zone + " " + i.building + " " + kindOf(i.kind).name).toLowerCase().includes(q)));
|
||||
const groups = {};
|
||||
match.forEach((d) => (groups[d.building] = groups[d.building] || []).push(d));
|
||||
|
||||
$scroll.innerHTML = `<div class="pane">
|
||||
<input class="search" id="q" placeholder="${esc(T.searchPlaceholder)}" value="${esc(query)}">
|
||||
${match.length || tags.length
|
||||
? Object.entries(groups).map(([b, list]) =>
|
||||
`<div class="sec">${esc(b)} · ${T.doorsN(list.length)}</div>${list.map(doorRow).join("")}`).join("")
|
||||
+ (tags.length ? `<div class="sec">${T.tagsSection} · ${tags.length}</div>${tags.map(itemRow).join("")}` : "")
|
||||
: `<div class="empty">${esc(T.nothingFound(query))}</div>`}
|
||||
<p class="note">${T.doorsNote}</p></div>`;
|
||||
|
||||
const el = document.getElementById("q");
|
||||
el.oninput = (e) => {
|
||||
query = e.target.value; viewDoors();
|
||||
const f = document.getElementById("q"); f.focus(); f.setSelectionRange(query.length, query.length);
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ people */
|
||||
function viewPeople() {
|
||||
const rows = S.people.map((p) => {
|
||||
const held = S.items.filter((i) => i.loan && i.loan.person_id === p.id && i.status === "out");
|
||||
return { p, held, late: held.filter(isLate).length };
|
||||
}).sort((a, b) => b.held.length - a.held.length);
|
||||
const row = (r) => `<button class="row${r.late ? " late" : ""}" data-person="${r.p.id}">
|
||||
<span class="badge person">${esc(initials(r.p.name))}</span>
|
||||
<span class="row-main"><span class="row-t">${esc(r.p.name)}${r.p.active ? "" : ` (${T.inactive})`}</span>
|
||||
<span class="row-s">${esc(r.p.dept)}${r.p.role ? " · " + esc(r.p.role) : ""}${r.late ? ` · <span class="od">${r.late} ${T.overdueN}</span>` : ""}</span></span>
|
||||
<span class="row-n">${r.held.length ? T.itemsN(r.held.length) : "—"}</span></button>`;
|
||||
const holding = rows.filter((r) => r.held.length), free = rows.filter((r) => !r.held.length);
|
||||
$scroll.innerHTML = `<div class="pane">
|
||||
${holding.length ? `<div class="sec">${T.holdingNow}</div>${holding.map(row).join("")}` : ""}
|
||||
${free.length ? `<div class="sec">${T.nothingOut}</div>${free.map(row).join("")}` : ""}
|
||||
${rows.length ? "" : `<div class="empty">${T.noPeopleYet} <a href="/admin">${T.admin}</a>.</div>`}</div>`;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------- log */
|
||||
function viewLog() {
|
||||
const ev = [];
|
||||
S.handovers.forEach((h) => {
|
||||
ev.push({ d: h.out_date, dir: "out", h });
|
||||
if (h.in_date) ev.push({ d: h.in_date, dir: "in", h });
|
||||
});
|
||||
ev.sort((a, b) => b.d.localeCompare(a.d));
|
||||
$scroll.innerHTML = `<div class="pane"><div class="sec">${T.logTitle}</div>
|
||||
${ev.length ? `<div class="log">${ev.map((e) => {
|
||||
const it = item(e.h.item_id), p = person(e.h.person_id);
|
||||
return `<div class="log-row">
|
||||
<span class="log-d">${short(e.d)}</span>
|
||||
<span class="log-b"><span class="log-tag">${esc(it ? it.tag : "?")}</span>
|
||||
${e.dir === "out" ? T.toWord : T.backFrom} ${esc(p ? p.name : "?")}
|
||||
${e.h.has_sig ? ` <span style="color:var(--verdigris)">✓ ${T.signedMark}</span>` : ""}
|
||||
${e.h.note ? `<div class="memo-line">${esc(e.h.note)}</div>` : ""}</span>
|
||||
<span class="dir ${e.dir}">${e.dir === "out" ? T.dirOut : T.dirIn}</span></div>`;
|
||||
}).join("")}</div>` : `<div class="empty">${T.noHandovers}</div>`}
|
||||
<p class="note">${T.logNote}</p></div>`;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- journal */
|
||||
let requests = [];
|
||||
|
||||
function requestCard(r) {
|
||||
const done = r.status === "handled";
|
||||
return `<div class="req-card${done ? " done" : ""}">
|
||||
<h4>${esc(r.name)}</h4>
|
||||
<div class="meta">${esc(r.dept || "")}${r.dept && r.contact ? " · " : ""}${esc(r.contact || "")}</div>
|
||||
<div class="meta">${esc(done ? T.reqHandledOn(short(r.handled)) : T.reqOn(short(r.created.slice(0, 10))))}</div>
|
||||
${r.areas ? `<p class="areas-line">${esc(r.areas)}</p>` : ""}
|
||||
${r.detail ? `<p class="detail">${esc(r.detail)}</p>` : ""}
|
||||
${r.note ? `<p class="detail" style="font-style:italic">${esc(r.note)}</p>` : ""}
|
||||
${done ? "" : `<div style="margin-top:10px">
|
||||
<input class="search" id="rn${r.id}" placeholder="${esc(T.reqNotePh)}" style="margin-bottom:7px">
|
||||
<button class="mini" data-handle="${r.id}">${T.reqMarkDone}</button></div>`}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function viewJournal() {
|
||||
try { requests = await api("/api/requests"); } catch (e) { requests = []; }
|
||||
const pending = requests.filter((r) => r.status === "pending");
|
||||
const done = requests.filter((r) => r.status === "handled").slice(0, 8);
|
||||
|
||||
/* State of the public form, read from the server rather than from memory —
|
||||
a form that is public with no PIN is the one thing worth noticing. */
|
||||
const rf = S.reqForm || { public: false, pin: false };
|
||||
const status = !rf.public
|
||||
? `<div class="status-line in" style="font-size:12.5px">${T.reqFormPrivate}</div>`
|
||||
: rf.pin
|
||||
? `<div class="status-line in" style="font-size:12.5px">${T.reqFormPublic}
|
||||
· <a href="/pedido" style="color:inherit">${T.reqFormOpenIt}</a></div>`
|
||||
: `<div class="status-line late" style="font-size:12.5px"><b>${T.reqFormOpen}</b>
|
||||
· <a href="/pedido" style="color:inherit">${T.reqFormOpenIt}</a></div>`;
|
||||
|
||||
$scroll.innerHTML = `<div class="pane">
|
||||
<div class="sec">${T.reqInbox}${pending.length ? `<span class="badge-count">${pending.length}</span>` : ""}</div>
|
||||
${status}
|
||||
${pending.length ? pending.map(requestCard).join("")
|
||||
: `<div class="empty">${T.reqNone}</div>`}
|
||||
${done.length ? `<div class="sec">${T.reqHandled}</div>${done.map(requestCard).join("")}` : ""}
|
||||
|
||||
<div class="sec">${T.newEntry}</div>
|
||||
<div class="note-box">
|
||||
<textarea id="jt" placeholder="${esc(T.journalPlaceholder)}"></textarea>
|
||||
<div style="text-align:right;margin-top:8px"><button class="mini" id="jadd">${T.addJournal}</button></div></div>
|
||||
<div class="sec">${T.journalTitle}</div>
|
||||
${S.journal.length ? S.journal.map((j) => `<div class="row" style="align-items:flex-start;cursor:default">
|
||||
<span class="row-n" style="width:52px;text-align:left;padding-top:2px">${short(j.date)}</span>
|
||||
<span class="row-main" style="white-space:normal">
|
||||
<span style="font-size:14px;line-height:1.5">${esc(j.text)}</span>
|
||||
${j.author ? `<span class="row-s">${esc(j.author)}</span>` : ""}</span></div>`).join("")
|
||||
: `<div class="empty">${T.journalEmpty}</div>`}
|
||||
<p class="note">${T.journalNote}</p></div>`;
|
||||
|
||||
document.getElementById("jadd").onclick = async () => {
|
||||
const t = document.getElementById("jt").value.trim();
|
||||
if (!t) return;
|
||||
try {
|
||||
await api("/api/journal", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: t }) });
|
||||
await load(); toast(T.tJournalAdded);
|
||||
} catch (e) { toast(errMsg(e.message), true); }
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ sheets */
|
||||
function openSheet(html) {
|
||||
$sheet.innerHTML = '<div class="grab"></div>' + html;
|
||||
$sheet.classList.add("on"); $scrim.classList.add("on"); $sheet.scrollTop = 0;
|
||||
}
|
||||
function closeSheet() { $sheet.classList.remove("on"); $scrim.classList.remove("on"); pending = null; }
|
||||
$scrim.onclick = closeSheet;
|
||||
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeSheet(); });
|
||||
|
||||
function sheetDoor(did) {
|
||||
const d = door(did), c = copiesOf(did).filter((i) => i.status !== "retired");
|
||||
const out = c.filter((i) => i.status === "out");
|
||||
const sameLock = d.lock_group ? S.doors.filter((x) => x.lock_group === d.lock_group && x.id !== d.id) : [];
|
||||
openSheet(`<h2>${esc(d.num)} · ${esc(d.name)}</h2><p class="sub">${esc(d.building)}</p>
|
||||
<div class="status-line ${out.length ? "out" : "in"}">
|
||||
${T.doorSummary(c.length, out.length, c.filter((i) => i.status === "in").length,
|
||||
c.filter((i) => i.status === "reserve").length, c.filter((i) => i.status === "lost").length)}</div>
|
||||
<dl class="facts">
|
||||
<div class="fact"><dt>${T.fLockGroup}</dt><dd class="mono">${esc(d.lock_group || "—")}</dd></div>
|
||||
<div class="fact"><dt>${T.fCopiesLiveIn}</dt><dd>${esc([...new Set(c.map((i) => cab(i.cabinet_id).name))].join(", ") || "—")}</dd></div></dl>
|
||||
${d.notes ? `<div class="note-box"><span class="cap">${T.noteOnDoor}</span><div style="font-size:14px">${esc(d.notes)}</div></div>` : ""}
|
||||
<div class="sec">${T.everyCopy}</div>${c.map(itemRow).join("")}
|
||||
${sameLock.length ? `<div class="sec">${T.sameLock}</div>${sameLock.map(doorRow).join("")}` : ""}
|
||||
<div class="actions" style="margin-top:16px"><button class="btn btn-g" data-close>${T.close}</button></div>`);
|
||||
}
|
||||
|
||||
async function sheetItem(id) {
|
||||
const i = item(id);
|
||||
if (!i) return;
|
||||
const l = i.loan, late = isLate(i), c = cab(i.cabinet_id);
|
||||
const d = i.door_id ? door(i.door_id) : null;
|
||||
let hist = [];
|
||||
try { hist = await api(`/api/items/${i.id}/history`); } catch (e) { /* non-fatal */ }
|
||||
|
||||
const status = late
|
||||
? `<div class="status-line late">${T.withSince(esc(person(l.person_id).name), fmt(l.out_date))}${T.dueWas(fmt(l.due_date), days(l.due_date, S.today))}</div>`
|
||||
: i.status === "out"
|
||||
? `<div class="status-line out">${T.issuedToOn(esc(person(l.person_id) ? person(l.person_id).name : "?"), fmt(l.out_date))}${
|
||||
l.due_date ? T.dueBack(fmt(l.due_date)) : T.noDueDate}</div>`
|
||||
: i.status === "reserve"
|
||||
? `<div class="status-line reserve">${T.reserveLine}</div>`
|
||||
: i.status === "lost" ? `<div class="status-line lost">${T.lostLine}</div>`
|
||||
: i.status === "retired" ? `<div class="status-line retired">${T.retiredLine}</div>`
|
||||
: `<div class="status-line in">${T.inCabinet(`<b>${esc(c.full_name)}</b>`)}${i.hook ? T.atHook(esc(i.hook)) : ""}</div>`;
|
||||
|
||||
openSheet(`<h2>${esc(i.tag)}</h2>
|
||||
<p class="sub">${esc(d ? d.name : i.zone)}${i.building ? " · " + esc(i.building) : ""}</p>${status}
|
||||
${l && l.note && i.status === "out" ? `<div class="note-box"><span class="cap">${T.noteOnHandover}</span>
|
||||
<div style="font-size:14px">${esc(l.note)}</div></div>` : ""}
|
||||
<dl class="facts">
|
||||
<div class="fact"><dt>${T.fType}</dt><dd>${esc(kindOf(i.kind).name)}</dd></div>
|
||||
<div class="fact"><dt>${T.fKeptIn}</dt><dd>${esc(c.full_name)}${c.note ? `<span class="tiny">${esc(c.note)}</span>` : ""}</dd></div>
|
||||
${i.hook ? `<div class="fact"><dt>${T.fHook}</dt><dd class="mono">${esc(i.hook)}</dd></div>` : ""}
|
||||
${d ? `<div class="fact"><dt>${T.fLockGroup}</dt><dd class="mono">${esc(d.lock_group || "—")}</dd></div>` : ""}
|
||||
${i.uid ? `<div class="fact"><dt>${T.fChipUid}</dt><dd class="mono">${esc(i.uid)}</dd></div>` : ""}
|
||||
${i.is_master ? `<div class="fact"><dt>${T.fMaster}</dt><dd>${T.yes}</dd></div>` : ""}</dl>
|
||||
${d ? `<button class="row" data-door="${d.id}" style="margin-bottom:14px">
|
||||
<span class="badge door">${esc(d.num)}</span>
|
||||
<span class="row-main"><span class="row-t">${T.seeAllCopies(copiesOf(d.id).length)}</span>
|
||||
<span class="row-s">${esc(d.name)}</span></span></button>` : ""}
|
||||
<div class="note-box"><span class="cap">${T.noteOnKey}</span>
|
||||
<textarea id="inote" placeholder="${esc(T.notePlaceholder)}">${esc(i.notes)}</textarea>
|
||||
<div style="text-align:right;margin-top:6px"><button class="mini" data-savenote="${i.id}">${T.saveNote}</button></div></div>
|
||||
<div class="actions">
|
||||
${i.status === "in" || i.status === "reserve"
|
||||
? `<button class="btn btn-o" data-issue="${i.id}">${i.status === "reserve" ? T.breakSeal : T.issueTo}</button>`
|
||||
: i.status === "out" ? `<button class="btn btn-p" data-return="${i.id}">${T.takeBack}</button>` : ""}
|
||||
<button class="btn btn-g" data-close>${T.close}</button></div>
|
||||
<div class="sec">${T.history}</div>
|
||||
${hist.length ? `<div class="log">${hist.map((h) => {
|
||||
const p = person(h.person_id);
|
||||
const hLate = !h.in_date && h.due_date && h.due_date < S.today;
|
||||
return `<div class="log-row"><span class="log-d">${short(h.out_date)}</span>
|
||||
<span class="log-b">${esc(p ? p.name : "?")}
|
||||
<div style="color:var(--slate);font-size:12px">${h.in_date ? T.returnedOn(fmt(h.in_date)) : T.stillHolding}</div>
|
||||
${h.note ? `<div class="memo-line">${esc(h.note)}</div>` : ""}
|
||||
${h.signature ? `<div class="sigshot"><img src="${esc(h.signature)}" alt="Signature"></div>` : ""}</span>
|
||||
<span class="dir ${h.in_date ? "in" : hLate ? "late" : "out"}">${h.in_date ? T.stClosed : hLate ? T.stLate : T.stOpen}</span></div>`;
|
||||
}).join("")}</div>` : `<div class="empty">${T.neverIssued}</div>`}
|
||||
<div style="height:8px"></div>`);
|
||||
}
|
||||
|
||||
function sheetPerson(id) {
|
||||
const p = person(id);
|
||||
const held = S.items.filter((i) => i.loan && i.loan.person_id === p.id && i.status === "out");
|
||||
const past = S.handovers.filter((h) => h.person_id === p.id && h.in_date);
|
||||
openSheet(`<h2>${esc(p.name)}</h2><p class="sub">${esc(p.dept)}${p.role ? " · " + esc(p.role) : ""}</p>
|
||||
<dl class="facts">
|
||||
<div class="fact"><dt>${T.fHoldingNow}</dt><dd>${T.itemsN(held.length)}${
|
||||
held.length ? `<span class="tiny">${esc(T.fromCabinets([...new Set(held.map((i) => cab(i.cabinet_id).name))].join(", ")))}</span>` : ""}</dd></div>
|
||||
<div class="fact"><dt>${T.fReturnedPast}</dt><dd>${past.length}</dd></div>
|
||||
${p.phone ? `<div class="fact"><dt>${T.fPhone}</dt><dd class="mono">${esc(p.phone)}</dd></div>` : ""}</dl>
|
||||
${held.length ? `<div class="sec">${T.currentlyHolds}</div>${held.map(itemRow).join("")}
|
||||
<a class="btn btn-g" style="display:block;text-decoration:none;margin:10px 0 4px"
|
||||
href="/report?view=person&id=${p.id}">${T.printPdf} — ${T.repReceiptTitle}</a>`
|
||||
: `<div class="empty">${T.holdsNothing}</div>`}
|
||||
${past.length ? `<div class="sec">${T.returnedSection}</div><div class="log">${past.map((h) => {
|
||||
const it = item(h.item_id);
|
||||
return `<div class="log-row"><span class="log-d">${short(h.in_date)}</span>
|
||||
<span class="log-b"><span class="log-tag">${esc(it ? it.tag : "?")}</span>
|
||||
<div style="color:var(--slate);font-size:12px">${T.heldFromTo(fmt(h.out_date), fmt(h.in_date))}</div></span></div>`;
|
||||
}).join("")}</div>` : ""}
|
||||
<div class="actions" style="margin-top:16px"><button class="btn btn-g" data-close>${T.close}</button></div>`);
|
||||
}
|
||||
|
||||
let pickQuery = "";
|
||||
|
||||
function personPickList(itemId) {
|
||||
const q = pickQuery.trim().toLowerCase();
|
||||
const active = S.people.filter((p) => p.active &&
|
||||
(!q || (p.name + " " + p.dept + " " + p.role).toLowerCase().includes(q)));
|
||||
if (!active.length) {
|
||||
return `<div class="empty">${q ? esc(T.nothingFound(pickQuery))
|
||||
: T.noActivePeople + ` <a href="/admin">${T.admin}</a>.`}</div>`;
|
||||
}
|
||||
return active.map((p) => {
|
||||
const holds = S.items.filter((i) => i.loan && i.loan.person_id === p.id && i.status === "out").length;
|
||||
return `<button class="row" data-give="${p.id}" data-on="${itemId}">
|
||||
<span class="badge person">${esc(initials(p.name))}</span>
|
||||
<span class="row-main"><span class="row-t">${esc(p.name)}</span>
|
||||
<span class="row-s">${esc(p.dept)}${p.role ? " · " + esc(p.role) : ""}</span></span>
|
||||
<span class="row-n">${holds ? T.itemsN(holds) : ""}</span></button>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function bindPicker(itemId) {
|
||||
const el = document.getElementById("pq");
|
||||
if (!el) return;
|
||||
el.oninput = (e) => {
|
||||
pickQuery = e.target.value;
|
||||
document.getElementById("plist").innerHTML = personPickList(itemId);
|
||||
};
|
||||
}
|
||||
|
||||
function sheetIssue(id) {
|
||||
const i = item(id);
|
||||
pickQuery = "";
|
||||
openSheet(`<h2>${esc(T.issueTitle(i.tag))}</h2>
|
||||
<p class="sub">${esc(i.door_id ? door(i.door_id).name : i.zone)} · ${esc(T.fromThe(cab(i.cabinet_id).full_name))}</p>
|
||||
${i.status === "reserve" ? `<div class="status-line reserve">${T.reserveWarn}</div>` : ""}
|
||||
<div class="sec">${T.whoTakes}</div>
|
||||
<input class="search" id="pq" placeholder="${esc(T.searchPerson)}" autocomplete="off">
|
||||
<div id="plist">${personPickList(i.id)}</div>
|
||||
<div class="actions" style="margin-top:14px"><button class="btn btn-g" data-close>${T.cancel}</button></div>`);
|
||||
bindPicker(i.id);
|
||||
}
|
||||
|
||||
const addDays = (d, k) => { const t = new Date(d); t.setDate(t.getDate() + k); return t.toISOString().slice(0, 10); };
|
||||
|
||||
function sheetConfirm() {
|
||||
const i = item(pending.item), p = person(pending.person);
|
||||
openSheet(`<h2>${T.handover}</h2>
|
||||
<p class="sub">${esc(i.tag)} · ${esc(i.door_id ? door(i.door_id).name : i.zone)} → ${esc(p.name)}</p>
|
||||
<div class="sec">${T.backBy}</div>
|
||||
<div class="due-opts">${[["none", T.dNone], ["7", T.d7], ["30", T.d30], ["90", T.d90]]
|
||||
.map(([k, l]) => `<button class="due" data-due="${k}" aria-pressed="${pending.dueKey === k}">${l}</button>`).join("")}</div>
|
||||
<div class="note-box"><span class="cap">${T.whyNote}</span>
|
||||
<textarea id="hnote" placeholder="${esc(T.whyPlaceholder)}">${esc(pending.note)}</textarea></div>
|
||||
<div class="sec">${T.signature}</div>
|
||||
<div class="sigpad"><canvas id="sig"></canvas><div class="sigline"></div>
|
||||
${pending.sig ? "" : `<div class="hint" id="sighint">${T.signHint}</div>`}</div>
|
||||
<div style="text-align:right;margin-bottom:14px">
|
||||
<button class="mini" id="sigclear" style="background:var(--card);color:var(--slate);border:1px solid var(--line)">${T.clearSig}</button></div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-o" id="confirm">${T.confirmHandover}</button>
|
||||
<button class="btn btn-g" data-close>${T.cancel}</button></div>`);
|
||||
|
||||
const c = document.getElementById("sig"), ctx = c.getContext("2d"), dpr = window.devicePixelRatio || 1;
|
||||
requestAnimationFrame(() => {
|
||||
c.width = c.offsetWidth * dpr; c.height = c.offsetHeight * dpr; ctx.scale(dpr, dpr);
|
||||
ctx.lineWidth = 2.2; ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.strokeStyle = "#131E1B";
|
||||
if (pending && pending.sig) {
|
||||
const im = new Image();
|
||||
im.onload = () => ctx.drawImage(im, 0, 0, c.offsetWidth, c.offsetHeight);
|
||||
im.src = pending.sig;
|
||||
}
|
||||
});
|
||||
let drawing = false;
|
||||
const pos = (e) => { const r = c.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top]; };
|
||||
c.addEventListener("pointerdown", (e) => {
|
||||
drawing = true; c.setPointerCapture(e.pointerId);
|
||||
const h = document.getElementById("sighint"); if (h) h.remove();
|
||||
const [x, y] = pos(e); ctx.beginPath(); ctx.moveTo(x, y);
|
||||
});
|
||||
c.addEventListener("pointermove", (e) => {
|
||||
if (!drawing) return;
|
||||
const [x, y] = pos(e); ctx.lineTo(x, y); ctx.stroke();
|
||||
pending.sig = c.toDataURL("image/png");
|
||||
});
|
||||
c.addEventListener("pointerup", () => { drawing = false; });
|
||||
document.getElementById("sigclear").onclick = () => { ctx.clearRect(0, 0, c.width, c.height); pending.sig = null; };
|
||||
|
||||
document.getElementById("confirm").onclick = async (ev) => {
|
||||
ev.target.disabled = true;
|
||||
const note = document.getElementById("hnote").value.trim();
|
||||
const due = pending.dueKey === "none" ? null : addDays(S.today, +pending.dueKey);
|
||||
const id = pending.item;
|
||||
try {
|
||||
await api(`/api/items/${id}/issue`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ person_id: pending.person, due_date: due, note, signature: pending.sig }),
|
||||
});
|
||||
pending = null;
|
||||
await load();
|
||||
toast(T.tHandedOver);
|
||||
sheetItem(id);
|
||||
} catch (e) { toast(errMsg(e.message), true); ev.target.disabled = false; }
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ events */
|
||||
document.addEventListener("click", async (e) => {
|
||||
const t = e.target.closest("[data-handle],[data-item],[data-door],[data-person],[data-filter],[data-tab],[data-cab],[data-issue],[data-return],[data-give],[data-close],[data-savenote],[data-due]");
|
||||
if (!t) return;
|
||||
|
||||
if (t.dataset.close !== undefined) return closeSheet();
|
||||
|
||||
if (t.dataset.savenote) {
|
||||
const id = +t.dataset.savenote, notes = document.getElementById("inote").value.trim();
|
||||
try {
|
||||
await api(`/api/items/${id}/notes`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ notes }) });
|
||||
await load(); toast(T.tNoteSaved); sheetItem(id);
|
||||
} catch (err) { toast(errMsg(err.message), true); }
|
||||
return;
|
||||
}
|
||||
if (t.dataset.due) {
|
||||
pending.note = document.getElementById("hnote").value;
|
||||
pending.dueKey = t.dataset.due;
|
||||
return sheetConfirm();
|
||||
}
|
||||
if (t.dataset.give) {
|
||||
pending = { item: +t.dataset.on, person: +t.dataset.give, dueKey: "none", note: "", sig: null };
|
||||
return sheetConfirm();
|
||||
}
|
||||
if (t.dataset.door !== undefined && t.dataset.door !== "") return sheetDoor(+t.dataset.door);
|
||||
if (t.dataset.item) return sheetItem(+t.dataset.item);
|
||||
if (t.dataset.person) return sheetPerson(+t.dataset.person);
|
||||
if (t.dataset.issue) return sheetIssue(+t.dataset.issue);
|
||||
if (t.dataset.return) {
|
||||
const id = +t.dataset.return;
|
||||
try {
|
||||
await api(`/api/items/${id}/return`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
|
||||
await load(); toast(T.tBackIn); sheetItem(id);
|
||||
} catch (err) { toast(errMsg(err.message), true); }
|
||||
return;
|
||||
}
|
||||
if (t.dataset.handle) {
|
||||
const id = +t.dataset.handle;
|
||||
const note = (document.getElementById("rn" + id) || {}).value || "";
|
||||
try {
|
||||
await api(`/api/requests/${id}/handle`, { method: "POST",
|
||||
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ note }) });
|
||||
await load(); toast(T.tSaved);
|
||||
} catch (err) { toast(errMsg(err.message), true); }
|
||||
return;
|
||||
}
|
||||
if (t.dataset.cab) { cabinet = t.dataset.cab; return render(); }
|
||||
if (t.dataset.filter) { filter = t.dataset.filter; return render(); }
|
||||
if (t.dataset.tab) {
|
||||
tab = t.dataset.tab;
|
||||
document.querySelectorAll(".tab").forEach((x) => x.setAttribute("aria-selected", x.dataset.tab === tab));
|
||||
$scroll.scrollTop = 0;
|
||||
return render();
|
||||
}
|
||||
});
|
||||
|
||||
function chrome() {
|
||||
document.getElementById("brand").textContent = T.brand;
|
||||
document.getElementById("apptitle").textContent = T.appTitle;
|
||||
[["gear", T.admin], ["reports", T.reports], ["askform", T.reqHandPhone]].forEach(([id, label]) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.title = label;
|
||||
el.setAttribute("aria-label", label);
|
||||
});
|
||||
document.querySelectorAll("[data-t]").forEach((el) => (el.textContent = T[el.dataset.t]));
|
||||
/* pending requests are easy to miss on a tab you rarely open */
|
||||
const jt = document.querySelector('[data-tab="journal"] [data-t]');
|
||||
if (jt && S.pending) jt.innerHTML = `${T.tabJournal}<span class="badge-count">${S.pending}</span>`;
|
||||
document.title = T.brand;
|
||||
}
|
||||
|
||||
function render() {
|
||||
({ board: viewBoard, doors: viewDoors, people: viewPeople, log: viewLog, journal: viewJournal })[tab]();
|
||||
}
|
||||
load();
|
||||
462
public/i18n.js
Normal file
462
public/i18n.js
Normal file
|
|
@ -0,0 +1,462 @@
|
|||
/* Key register — translations.
|
||||
Add a language by copying the `pt` block. Set UI_LANG in .env to choose. */
|
||||
"use strict";
|
||||
|
||||
var I18N = {
|
||||
pt: {
|
||||
_months: ["jan", "fev", "mar", "abr", "mai", "jun", "jul", "ago", "set", "out", "nov", "dez"],
|
||||
brand: "Registo de Chaves", appTitle: "Armários de chaves", admin: "Administração",
|
||||
loginSub: "Introduza o código partilhado.",
|
||||
loginPlaceholder: "Código de acesso", loginBtn: "Entrar",
|
||||
loginWrong: "Código incorreto.", loginMany: "Demasiadas tentativas. Aguarde um minuto.",
|
||||
loginShow: "Mostrar código", loginHide: "Ocultar código",
|
||||
loginFoot: "Acesso restrito à rede privada da igreja.",
|
||||
loading: "A carregar…", noServer: "Não foi possível contactar o servidor.",
|
||||
back: "Voltar ao registo",
|
||||
|
||||
tabBoard: "Quadro", tabDoors: "Portas", tabPeople: "Pessoas",
|
||||
tabLog: "Movimentos", tabJournal: "Diário",
|
||||
|
||||
cabinetsN: (n) => `${n} ${n === 1 ? "armário" : "armários"}`,
|
||||
keysIn: (a, b) => `${a} de ${b} chaves no armário`,
|
||||
issuedN: "emprestadas", overdueN: "em atraso", reserveN: "de reserva",
|
||||
|
||||
searchPerson: "Procurar pessoa…",
|
||||
/* pedido de acesso */
|
||||
reqTitle: "Pedido de acesso",
|
||||
reqIntro: "Preencha este formulário para pedir acesso a uma zona da igreja. O pedido é analisado pelo responsável pelas chaves — não recebe nenhuma chave por aqui.",
|
||||
reqName: "Nome completo", reqDept: "Departamento ou grupo",
|
||||
reqContact: "Telefone ou email", reqAreas: "Zonas a que precisa de aceder",
|
||||
reqDetail: "Para quê, e durante quanto tempo",
|
||||
reqDetailPh: "Ex.: ensaios do coro às quartas, a partir de setembro.",
|
||||
reqPin: "Código da igreja", reqPinHelp: "Peça o código ao responsável pelas chaves.",
|
||||
reqSend: "Enviar pedido", reqSending: "A enviar…",
|
||||
reqOk: "Pedido enviado.",
|
||||
reqOkMore: "O responsável pelas chaves vai analisá-lo. Não guarde este separador aberto.",
|
||||
reqAnother: "Enviar outro pedido",
|
||||
reqNeedName: "Indique o seu nome.", reqNeedArea: "Escolha pelo menos uma zona.",
|
||||
reqWrongPin: "Código incorreto.", reqTooMany: "Demasiados pedidos. Tente mais tarde.",
|
||||
reqFoot: "Nenhuma chave é entregue através deste formulário.",
|
||||
/* caixa de pedidos */
|
||||
reqHandPhone: "Passar o telemóvel para alguém preencher",
|
||||
reqKioskDone: "Pode devolver o telemóvel.",
|
||||
reqKioskNext: "Novo pedido",
|
||||
reqNoPinStaff: "Sem código — o pedido está a ser feito a partir da aplicação.",
|
||||
reqFormPublic: "Formulário público, protegido por código.",
|
||||
reqFormOpen: "FORMULÁRIO PÚBLICO SEM CÓDIGO — qualquer pessoa que descubra o endereço pode enviar pedidos.",
|
||||
reqFormPrivate: "Formulário só acessível a quem entra na aplicação.",
|
||||
reqFormOpenIt: "Abrir formulário",
|
||||
reqInbox: "Pedidos de acesso", reqPending: (n) => `${n} por tratar`,
|
||||
reqNone: "Nenhum pedido pendente.",
|
||||
reqOn: (d) => `Recebido em ${d}`, reqMarkDone: "Marcar como tratado",
|
||||
reqNotePh: "O que foi decidido?", reqHandled: "Tratado",
|
||||
reqHandledOn: (d) => `Tratado em ${d}`,
|
||||
|
||||
/* reports */
|
||||
reports: "Relatórios", print: "Imprimir", printPdf: "Imprimir / PDF",
|
||||
repOut: "Chaves emprestadas", repOverdue: "Chaves em atraso",
|
||||
repPerson: "Recibo por pessoa", repInventory: "Inventário completo",
|
||||
repAudit: "Folha de auditoria",
|
||||
repGenerated: (d, h) => `Gerado em ${d} às ${h}`,
|
||||
repNothing: "Nada a mostrar.",
|
||||
repChoosePerson: "Escolha uma pessoa",
|
||||
stKeysOut: "emprestadas", stPeople: "pessoas", stOverdue: "em atraso",
|
||||
stTotal: "total", stInCab: "no armário", stReserve: "reserva",
|
||||
cTag: "Nº", cDoor: "Porta", cBuilding: "Edifício", cCabinet: "Armário",
|
||||
cPerson: "Pessoa", cDept: "Departamento", cOut: "Entregue", cDue: "Devolver até",
|
||||
cDays: "Dias", cStatus: "Estado", cHook: "Gancho", cCheck: "Conf.",
|
||||
repSignedOn: (d) => `Assinado em ${d}`, repNoSig: "Sem assinatura registada",
|
||||
repReceiptTitle: "Recibo de chaves",
|
||||
repReceiptIntro: (n) => `Declaro ter em minha posse ${n === 1 ? "a chave" : "as"} ${n === 1 ? "" : n + " chaves"} abaixo indicadas, e comprometo-me a devolvê-${n === 1 ? "la" : "las"} quando solicitado.`,
|
||||
repHolder: "Titular", repResponsible: "Responsável pelas chaves", repDate: "Data",
|
||||
repAuditIntro: "Confirme cada chave presente no armário e assinale a caixa. As chaves emprestadas estão indicadas.",
|
||||
repAuditBy: "Auditoria feita por", repNotes: "Observações",
|
||||
repAllCabinets: "Todos os armários",
|
||||
allTile: "Todos",
|
||||
statuses: { in: "no armário", out: "emprestada", reserve: "reserva", lost: "perdida", retired: "retirada" },
|
||||
fEverything: "Tudo", fKeys: "Só chaves", fTags: "Etiquetas",
|
||||
fOut: "Emprestadas", fLate: "Em atraso",
|
||||
lgIn: "No armário", lgOut: "Emprestada", lgRes: "Reserva",
|
||||
lgLate: "Em atraso", lgTag: "Etiqueta de alarme",
|
||||
outCount: (n) => `${n} fora`,
|
||||
boardNote: "Um gancho por cada chave física, pela ordem do armário.<br>Para saber quem abre uma porta, use Portas.",
|
||||
noMatch: "Nada corresponde a esse filtro.",
|
||||
noKeysYet: "Ainda não há chaves. Adicione-as na",
|
||||
|
||||
onHook: "No seu gancho", reserveCopy: "Cópia de reserva",
|
||||
lost: "Perdida", retired: "Retirada", allIn: "todas no armário",
|
||||
copies: (n) => `${n} ${n === 1 ? "cópia" : "cópias"}`,
|
||||
doorsN: (n) => `${n} ${n === 1 ? "porta" : "portas"}`,
|
||||
searchPlaceholder: "Porta, número de chave, pessoa ou nota",
|
||||
nothingFound: (q) => `Nada encontrado para «${q}».`,
|
||||
doorsNote: "Uma linha por porta. Os pontos são as cópias — cheio está no armário,<br>vazio está com alguém, cinzento é a reserva.",
|
||||
tagsSection: "Etiquetas de alarme",
|
||||
|
||||
holdingNow: "Com chaves neste momento", nothingOut: "Sem chaves",
|
||||
inactive: "inativo",
|
||||
keysWord: (n) => `${n} ${n === 1 ? "chave" : "chaves"}`,
|
||||
tagsWord: (n) => `${n} ${n === 1 ? "etiqueta" : "etiquetas"}`,
|
||||
noPeopleYet: "Ainda não há pessoas. Adicione-as na",
|
||||
|
||||
logTitle: "Todas as entregas, mais recentes primeiro",
|
||||
toWord: "para", backFrom: "devolvida por", signedMark: "assinado",
|
||||
noHandovers: "Ainda não há entregas registadas.",
|
||||
logNote: "As linhas só são acrescentadas, nunca alteradas.",
|
||||
dirOut: "saída", dirIn: "entrada",
|
||||
|
||||
newEntry: "Nova entrada",
|
||||
journalPlaceholder: "Auditoria feita, fechadura mudada, selo de reserva quebrado…",
|
||||
addJournal: "Adicionar ao diário", journalTitle: "Diário dos armários",
|
||||
journalEmpty: "Ainda não há registos.",
|
||||
journalNote: "Para tudo o que pertence a um armário e não a uma chave.",
|
||||
|
||||
withSince: (who, d) => `Com <b>${who}</b> desde ${d}.`,
|
||||
dueWas: (d, n) => `<br>Devolução prevista ${d} — <b>há ${n} dias</b>.`,
|
||||
issuedToOn: (who, d) => `Entregue a <b>${who}</b> em ${d}`,
|
||||
dueBack: (d) => `<br>Devolução prevista ${d}.`,
|
||||
noDueDate: "<br>Sem data de devolução — posse permanente.",
|
||||
reserveLine: "Cópia de reserva, fora das instalações. A entrega fica automaticamente no diário.",
|
||||
lostLine: "Dada como perdida. Considere mudar a fechadura desta porta.",
|
||||
retiredLine: "Retirada. Mantida apenas pelo histórico.",
|
||||
inCabinet: (c) => `No ${c}`, atHook: (h) => `, gancho ${h}`,
|
||||
|
||||
fType: "Tipo", vKey: "Chave de porta", vTag: "Etiqueta NFC de alarme",
|
||||
fKeptIn: "Guardada em", fHook: "Gancho", fLockGroup: "Grupo de fechadura",
|
||||
fChipUid: "UID do chip", fMaster: "Chave-mestra", yes: "Sim", no: "Não",
|
||||
fCopiesLiveIn: "Cópias guardadas em", fHoldingNow: "Tem agora",
|
||||
fReturnedPast: "Devolvidas no passado", fPhone: "Telefone",
|
||||
seeAllCopies: (n) => `Ver todas as ${n} cópias`,
|
||||
|
||||
noteOnHandover: "Nota desta entrega",
|
||||
noteOnKey: "Nota sobre esta chave — permanente",
|
||||
notePlaceholder: "Algo que a próxima pessoa deva saber.",
|
||||
saveNote: "Guardar nota",
|
||||
noteOnDoor: "Nota sobre esta porta",
|
||||
|
||||
issueTo: "Entregar a alguém", breakSeal: "Quebrar o selo e entregar",
|
||||
takeBack: "Receber de volta", close: "Fechar", cancel: "Cancelar",
|
||||
history: "Histórico", neverIssued: "Nunca foi entregue.",
|
||||
returnedOn: (d) => `devolvida em ${d}`, stillHolding: "ainda a tem",
|
||||
stClosed: "fechado", stOpen: "aberto", stLate: "atraso",
|
||||
currentlyHolds: "Tem neste momento", holdsNothing: "Não tem nada de momento.",
|
||||
returnedSection: "Devolvidas", heldFromTo: (a, b) => `esteve de ${a} a ${b}`,
|
||||
itemsN: (n) => `${n} ${n === 1 ? "item" : "itens"}`,
|
||||
fromCabinets: (s) => `de ${s}`,
|
||||
|
||||
issueTitle: (t) => `Entregar ${t}`, fromThe: (c) => `do ${c}`,
|
||||
whoTakes: "Quem a vai levar?",
|
||||
reserveWarn: "Esta é uma cópia de último recurso. Ao entregá-la, a porta fica sem reserva.",
|
||||
noActivePeople: "Não há pessoas ativas. Adicione-as na",
|
||||
|
||||
handover: "Entrega", backBy: "Devolver até",
|
||||
dNone: "Sem data — permanente", d7: "Uma semana", d30: "Um mês", d90: "Três meses",
|
||||
whyNote: "Motivo — nota desta entrega",
|
||||
whyPlaceholder: "Concerto de Natal, revisão da caldeira, novo sacristão…",
|
||||
signature: "Assinatura", signHint: "Dê o telemóvel para assinar",
|
||||
clearSig: "Limpar", confirmHandover: "Confirmar entrega",
|
||||
|
||||
everyCopy: "Todas as cópias desta chave",
|
||||
sameLock: "Mesmo grupo de fechadura — estas também abrem",
|
||||
doorSummary: (total, out, inC, res, lst) => {
|
||||
const parts = [];
|
||||
if (out) parts.push(`${out} com pessoas`);
|
||||
parts.push(`${inC} no armário`);
|
||||
if (res) parts.push(`${res} de reserva`);
|
||||
if (lst) parts.push(`${lst} perdidas`);
|
||||
return `Existem <b>${total} ${total === 1 ? "cópia" : "cópias"}</b>. ${parts.join(", ")}.`;
|
||||
},
|
||||
|
||||
tHandedOver: "Entregue", tBackIn: "De volta ao armário",
|
||||
tNoteSaved: "Nota guardada", tJournalAdded: "Entrada adicionada",
|
||||
tSaved: "Guardado", tRemoved: "Removido",
|
||||
|
||||
errors: {
|
||||
"already issued": "Esta chave já está emprestada.",
|
||||
"not currently issued": "Esta chave não está emprestada.",
|
||||
"no such item": "Chave não encontrada.",
|
||||
"no such person": "Pessoa não encontrada.",
|
||||
"not issuable": "Esta chave não pode ser entregue.",
|
||||
"tag required": "O número da chave é obrigatório.",
|
||||
"name required": "O nome é obrigatório.",
|
||||
"that tag is already used": "Esse número de chave já existe.",
|
||||
"door number already used": "Esse número de porta já existe.",
|
||||
"num, building and name required": "Número, edifício e nome são obrigatórios.",
|
||||
"id and name required": "Código e nome são obrigatórios.",
|
||||
"confirmation phrase required": "Tem de escrever a frase de confirmação.",
|
||||
"empty": "Está vazio.",
|
||||
"locked": "Sessão terminada. Recarregue a página.",
|
||||
"wrong pin": "Código incorreto.",
|
||||
"too many requests": "Demasiados pedidos. Tente mais tarde.",
|
||||
},
|
||||
|
||||
/* admin */
|
||||
aItems: "Chaves e etiquetas", aDoors: "Portas", aPeople: "Pessoas",
|
||||
aCabinets: "Armários", aKinds: "Tipos", aData: "Importar / cópias", aAudit: "Alterações",
|
||||
aKindsHelp: "Os tipos de objeto que guarda. A forma diz o que é, o preenchimento diz onde está.",
|
||||
aAddKind: "+ Adicionar tipo",
|
||||
fKindCode: "Código", fKindName: "Nome", fShape: "Forma", fColour: "Cor",
|
||||
shCircle: "Círculo", shRounded: "Quadrado arredondado", shSquare: "Quadrado",
|
||||
deleteKind: "Eliminar este tipo",
|
||||
kindNote: "O código é usado nas importações (coluna kind) e não deve mudar depois de haver chaves com ele. O nome pode mudar à vontade.",
|
||||
kindInUse: (n) => `${n} itens usam este tipo.`,
|
||||
aItemsHelp: (n) => `Uma linha por cada chave física. ${n} no total.`,
|
||||
aFilter: "Filtrar por número, porta ou nota",
|
||||
aAddItem: "+ Adicionar chave ou etiqueta", aAddDoor: "+ Adicionar porta",
|
||||
aAddPerson: "+ Adicionar pessoa", aAddCabinet: "+ Adicionar armário",
|
||||
aTag: "Nº", aDoor: "Porta / zona", aCabinet: "Armário", aHook: "Gancho",
|
||||
aStatus: "Estado", aEdit: "Editar", aNothing: "Ainda não há nada.",
|
||||
aDoorsHelp: (n) => `As portas físicas. As chaves apontam para estas. ${n} no total.`,
|
||||
aNo: "Nº", aLock: "Fechadura", aCopies: "Cópias",
|
||||
aPeopleHelp: "Titulares de chaves. Nunca fazem login — isto é só quem pode receber uma chave.",
|
||||
aName: "Nome", aDept: "Departamento", aHolding: "Tem",
|
||||
aCabinetsHelp: "Onde as chaves ficam fisicamente. Um armário não é um edifício — o escritório pode guardar mestras da igreja.",
|
||||
aOffSite: "fora das instalações", aItemsCount: (n) => `${n} itens`,
|
||||
|
||||
fNum: "Número", fBuilding: "Edifício", fDoorName: "Nome da porta", fNote: "Nota",
|
||||
fShortCode: "Código curto", fShortName: "Nome curto", fFullName: "Nome completo",
|
||||
fWhereIs: "Onde está e o que a abre", fSort: "Ordem no quadro",
|
||||
fSortHelp: "Número menor aparece primeiro. Deixe a reserva com um número alto para ficar no fim.",
|
||||
fOffSiteLabel: "Fora das instalações — depósito de reserva",
|
||||
fZoneKeys: "Porta (chaves)", fZoneTags: "Zona de alarme (etiquetas)",
|
||||
fNoneTag: "— nenhuma / etiqueta de alarme —", fNone: "— nenhum —",
|
||||
fIsMaster: "É uma chave-mestra", fPermNote: "Nota permanente",
|
||||
fFunction: "Função", fEmail: "Email",
|
||||
fActiveLabel: "Ativo — pode receber chaves",
|
||||
save: "Guardar", deleteRetire: "Eliminar ou retirar esta chave",
|
||||
deleteDoor: "Eliminar esta porta", deactivate: "Desativar",
|
||||
deleteCabinet: "Eliminar este armário",
|
||||
retireNote: "Se tiver histórico de entregas é retirada, nunca eliminada — o histórico tem de sobreviver.",
|
||||
peopleNote: "As pessoas nunca são eliminadas. Aparecem em entregas antigas, e eliminá-las tornaria esse histórico anónimo.",
|
||||
lockGroupNote: "O grupo de fechadura liga portas que partilham o mesmo perfil de chave, para saber o que mais uma chave perdida poderá abrir.",
|
||||
offSiteNote: "As chaves devolvidas a um depósito fora das instalações voltam a <em>reserva</em> e não a <em>no armário</em>, ficando fora da circulação normal.",
|
||||
holdsWarn: (n, tags) => `Tem ${n} ${n === 1 ? "item" : "itens"}: ${tags}. Receba-os de volta antes de desativar.`,
|
||||
|
||||
impWhat: "O que quer importar?",
|
||||
impItems: "Chaves e etiquetas", impDoors: "Portas", impPeople: "Pessoas", impCabinets: "Armários",
|
||||
impResult2: (c, u) => `Criados <b>${c}</b>, atualizados <b>${u}</b>.`,
|
||||
impTitle: "Importar de uma folha de cálculo",
|
||||
impHelp: "Cole CSV. Colunas obrigatórias: <b>tag</b>, <b>door</b>, <b>building</b>. Opcionais: lock_group, cabinet, hook, master, status, kind, notes. As portas e armários que não existirem são criados.",
|
||||
impBtn: "Importar",
|
||||
impResult: (i, d, c) => `Importados <b>${i}</b> itens, criadas ${d} portas e ${c} armários.`,
|
||||
impSkipped: (n, list) => `<br>Ignorados ${n}: ${list}`,
|
||||
impNothing: "Não há nada para importar",
|
||||
expTitle: "Exportar",
|
||||
expHelp: "Todo o registo como folha de cálculo. Útil para uma cópia impressa no armário, e prova de que nunca fica preso a esta aplicação.",
|
||||
expBtn: "Descarregar CSV",
|
||||
bkTitle: "Cópia de segurança",
|
||||
bkHelp: "Um único ficheiro SQLite, seguro de descarregar com a aplicação a funcionar. Guarde uma cópia fora desta máquina.",
|
||||
bkBtn: "Descarregar base de dados",
|
||||
wipeTitle: "Apagar tudo",
|
||||
wipeWarn: "Elimina todas as chaves, portas, pessoas, entregas e entradas do diário. Os armários são mantidos. Não há como desfazer — faça primeiro uma cópia de segurança.",
|
||||
wipePlaceholder: "Escreva: DELETE EVERYTHING",
|
||||
wipeBtn: "Apagar o registo", wipeDone: "Registo apagado",
|
||||
auditTitle: "Registo de alterações",
|
||||
auditHelp: "Alterações administrativas, mais recentes primeiro. Com duas pessoas a editar, «quem mudou a 12-B e quando» é uma pergunta que acaba por surgir.",
|
||||
aWhen: "Quando", aWhat: "O quê", aDetail: "Detalhe",
|
||||
imported: "Importado",
|
||||
},
|
||||
|
||||
en: {
|
||||
_months: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
|
||||
brand: "Key Register", appTitle: "Key cabinets", admin: "Admin",
|
||||
loginSub: "Enter the shared passcode.",
|
||||
loginPlaceholder: "Passcode", loginBtn: "Unlock",
|
||||
loginWrong: "Wrong passcode.", loginMany: "Too many attempts. Wait a minute.",
|
||||
loginShow: "Show passcode", loginHide: "Hide passcode",
|
||||
loginFoot: "Restricted to the church private network.",
|
||||
loading: "Loading…", noServer: "Can't reach the server.", back: "Back to the register",
|
||||
tabBoard: "Board", tabDoors: "Doors", tabPeople: "People", tabLog: "Log", tabJournal: "Journal",
|
||||
cabinetsN: (n) => `${n} store${n === 1 ? "" : "s"}`,
|
||||
keysIn: (a, b) => `${a} of ${b} keys in`,
|
||||
issuedN: "issued", overdueN: "overdue", reserveN: "reserve",
|
||||
searchPerson: "Search for a person…",
|
||||
/* access request */
|
||||
reqTitle: "Access request",
|
||||
reqIntro: "Use this form to request access to an area of the church. The keyholder reviews every request — no key is issued here.",
|
||||
reqName: "Full name", reqDept: "Department or group",
|
||||
reqContact: "Phone or email", reqAreas: "Areas you need access to",
|
||||
reqDetail: "What for, and for how long",
|
||||
reqDetailPh: "e.g. choir rehearsals on Wednesdays, from September.",
|
||||
reqPin: "Church code", reqPinHelp: "Ask the keyholder for the code.",
|
||||
reqSend: "Send request", reqSending: "Sending…",
|
||||
reqOk: "Request sent.",
|
||||
reqOkMore: "The keyholder will review it. You can close this tab.",
|
||||
reqAnother: "Send another request",
|
||||
reqNeedName: "Please enter your name.", reqNeedArea: "Choose at least one area.",
|
||||
reqWrongPin: "Wrong code.", reqTooMany: "Too many requests. Try again later.",
|
||||
reqFoot: "No key is handed over through this form.",
|
||||
reqHandPhone: "Hand the phone to someone to fill in",
|
||||
reqKioskDone: "You can hand the phone back.",
|
||||
reqKioskNext: "New request",
|
||||
reqNoPinStaff: "No code needed — this request is being made from inside the app.",
|
||||
reqFormPublic: "Form is public, protected by a code.",
|
||||
reqFormOpen: "FORM IS PUBLIC WITH NO CODE — anyone who finds the address can submit.",
|
||||
reqFormPrivate: "Form is only reachable by people signed in to the app.",
|
||||
reqFormOpenIt: "Open the form",
|
||||
reqInbox: "Access requests", reqPending: (n) => `${n} to handle`,
|
||||
reqNone: "No pending requests.",
|
||||
reqOn: (d) => `Received ${d}`, reqMarkDone: "Mark as handled",
|
||||
reqNotePh: "What was decided?", reqHandled: "Handled",
|
||||
reqHandledOn: (d) => `Handled ${d}`,
|
||||
|
||||
/* reports */
|
||||
reports: "Reports", print: "Print", printPdf: "Print / PDF",
|
||||
repOut: "Keys currently issued", repOverdue: "Overdue keys",
|
||||
repPerson: "Receipt per person", repInventory: "Full inventory",
|
||||
repAudit: "Audit sheet",
|
||||
repGenerated: (d, h) => `Generated ${d} at ${h}`,
|
||||
repNothing: "Nothing to show.",
|
||||
repChoosePerson: "Choose a person",
|
||||
stKeysOut: "issued", stPeople: "people", stOverdue: "overdue",
|
||||
stTotal: "total", stInCab: "in cabinet", stReserve: "reserve",
|
||||
cTag: "Tag", cDoor: "Door", cBuilding: "Building", cCabinet: "Cabinet",
|
||||
cPerson: "Person", cDept: "Department", cOut: "Issued", cDue: "Due back",
|
||||
cDays: "Days", cStatus: "Status", cHook: "Hook", cCheck: "Check",
|
||||
repSignedOn: (d) => `Signed ${d}`, repNoSig: "No signature on record",
|
||||
repReceiptTitle: "Key receipt",
|
||||
repReceiptIntro: (n) => `I confirm that I hold the ${n === 1 ? "key" : n + " keys"} listed below, and undertake to return ${n === 1 ? "it" : "them"} on request.`,
|
||||
repHolder: "Holder", repResponsible: "Keyholder in charge", repDate: "Date",
|
||||
repAuditIntro: "Confirm each key present in the cabinet and tick the box. Issued keys are marked.",
|
||||
repAuditBy: "Audit carried out by", repNotes: "Notes",
|
||||
repAllCabinets: "All cabinets",
|
||||
allTile: "All",
|
||||
statuses: { in: "in cabinet", out: "issued", reserve: "reserve", lost: "lost", retired: "retired" },
|
||||
fEverything: "Everything", fKeys: "Keys only", fTags: "Alarm tags",
|
||||
fOut: "Currently out", fLate: "Overdue",
|
||||
lgIn: "In cabinet", lgOut: "Issued", lgRes: "Reserve", lgLate: "Overdue", lgTag: "Alarm tag",
|
||||
outCount: (n) => `${n} out`,
|
||||
boardNote: "One peg per piece of metal, in hook order.<br>For \u201cwho can open this door\u201d, use Doors.",
|
||||
noMatch: "Nothing matches that filter.", noKeysYet: "No keys yet. Add them in",
|
||||
onHook: "On its hook", reserveCopy: "Reserve copy", lost: "Lost", retired: "Retired",
|
||||
allIn: "all in",
|
||||
copies: (n) => `${n} cop${n === 1 ? "y" : "ies"}`,
|
||||
doorsN: (n) => `${n} door${n === 1 ? "" : "s"}`,
|
||||
searchPlaceholder: "Door, key number, person, or note",
|
||||
nothingFound: (q) => `Nothing found for \u201c${q}\u201d.`,
|
||||
doorsNote: "One row per door. Dots are its copies — filled is in a cabinet,<br>hollow is out with someone, grey is reserve.",
|
||||
tagsSection: "Alarm tags",
|
||||
holdingNow: "Holding something now", nothingOut: "Nothing out", inactive: "inactive",
|
||||
keysWord: (n) => `${n} key${n === 1 ? "" : "s"}`,
|
||||
tagsWord: (n) => `${n} tag${n === 1 ? "" : "s"}`,
|
||||
noPeopleYet: "No people yet. Add them in",
|
||||
logTitle: "Every handover, newest first", toWord: "to", backFrom: "back from",
|
||||
signedMark: "signed", noHandovers: "No handovers recorded yet.",
|
||||
logNote: "Rows are only ever added, never edited.", dirOut: "out", dirIn: "in",
|
||||
newEntry: "New entry", journalPlaceholder: "Audit done, lock changed, reserve seal broken…",
|
||||
addJournal: "Add to journal", journalTitle: "Cabinet journal",
|
||||
journalEmpty: "Nothing recorded yet.",
|
||||
journalNote: "For everything that belongs to a cabinet rather than to one key.",
|
||||
withSince: (w, d) => `With <b>${w}</b> since ${d}.`,
|
||||
dueWas: (d, n) => `<br>Due back ${d} — <b>${n} days ago</b>.`,
|
||||
issuedToOn: (w, d) => `Issued to <b>${w}</b> on ${d}`,
|
||||
dueBack: (d) => `<br>Due back ${d}.`,
|
||||
noDueDate: "<br>No return date — held permanently.",
|
||||
reserveLine: "Reserve copy, off site. Issuing it is logged in the journal automatically.",
|
||||
lostLine: "Reported lost. Consider re-keying this door.",
|
||||
retiredLine: "Retired. Kept for its history only.",
|
||||
inCabinet: (c) => `In the ${c}`, atHook: (h) => `, hook ${h}`,
|
||||
fType: "Type", vKey: "Door key", vTag: "NFC alarm tag",
|
||||
fKeptIn: "Kept in", fHook: "Hook", fLockGroup: "Lock group", fChipUid: "Chip UID",
|
||||
fMaster: "Master key", yes: "Yes", no: "No",
|
||||
fCopiesLiveIn: "Copies live in", fHoldingNow: "Holding now",
|
||||
fReturnedPast: "Returned in the past", fPhone: "Phone",
|
||||
seeAllCopies: (n) => `See all ${n} copies`,
|
||||
noteOnHandover: "Note on this handover", noteOnKey: "Note on this key — permanent",
|
||||
notePlaceholder: "Anything the next person should know.", saveNote: "Save note",
|
||||
noteOnDoor: "Note on this door",
|
||||
issueTo: "Issue to someone", breakSeal: "Break seal and issue", takeBack: "Take it back",
|
||||
close: "Close", cancel: "Cancel", history: "History", neverIssued: "Never been issued.",
|
||||
returnedOn: (d) => `returned ${d}`, stillHolding: "still holding it",
|
||||
stClosed: "closed", stOpen: "open", stLate: "late",
|
||||
currentlyHolds: "Currently holds", holdsNothing: "Holds nothing right now.",
|
||||
returnedSection: "Returned", heldFromTo: (a, b) => `held ${a} → ${b}`,
|
||||
itemsN: (n) => `${n} item${n === 1 ? "" : "s"}`,
|
||||
fromCabinets: (s) => `from ${s}`,
|
||||
issueTitle: (t) => `Issue ${t}`, fromThe: (c) => `from the ${c}`,
|
||||
whoTakes: "Who is taking it?",
|
||||
reserveWarn: "This is a last-resort copy. Issuing it leaves the door without a reserve.",
|
||||
noActivePeople: "No active people. Add them in",
|
||||
handover: "Handover", backBy: "Back by",
|
||||
dNone: "No date — permanent", d7: "One week", d30: "One month", d90: "Three months",
|
||||
whyNote: "Why — note on this handover",
|
||||
whyPlaceholder: "Christmas concert, boiler service, new caretaker…",
|
||||
signature: "Signature", signHint: "Hand them the phone to sign",
|
||||
clearSig: "Clear", confirmHandover: "Confirm handover",
|
||||
everyCopy: "Every copy of this key", sameLock: "Same lock group — these open too",
|
||||
doorSummary: (total, out, inC, res, lst) => {
|
||||
const parts = [];
|
||||
if (out) parts.push(`${out} with people`);
|
||||
parts.push(`${inC} in cabinets`);
|
||||
if (res) parts.push(`${res} reserve`);
|
||||
if (lst) parts.push(`${lst} lost`);
|
||||
return `<b>${total} cop${total === 1 ? "y" : "ies"}</b> exist. ${parts.join(", ")}.`;
|
||||
},
|
||||
tHandedOver: "Handed over", tBackIn: "Back in the cabinet", tNoteSaved: "Note saved",
|
||||
tJournalAdded: "Journal entry added", tSaved: "Saved", tRemoved: "Removed",
|
||||
errors: {},
|
||||
aItems: "Keys & tags", aDoors: "Doors", aPeople: "People", aCabinets: "Cabinets",
|
||||
aData: "Import / backup", aAudit: "Change log",
|
||||
aItemsHelp: (n) => `One row per physical piece of metal. ${n} in total.`,
|
||||
aFilter: "Filter by tag, door, or note",
|
||||
aAddItem: "+ Add a key or tag", aAddDoor: "+ Add a door",
|
||||
aAddPerson: "+ Add a person", aAddCabinet: "+ Add a cabinet",
|
||||
aTag: "Tag", aDoor: "Door / zone", aCabinet: "Cabinet", aHook: "Hook",
|
||||
aStatus: "Status", aEdit: "Edit", aNothing: "Nothing yet.",
|
||||
aDoorsHelp: (n) => `The physical doors. Keys point at these. ${n} in total.`,
|
||||
aNo: "No.", aLock: "Lock", aCopies: "Copies",
|
||||
aPeopleHelp: "Key holders. They never log in — this is just who can be handed a key.",
|
||||
aName: "Name", aDept: "Department", aHolding: "Holding",
|
||||
aCabinetsHelp: "Where keys physically live. A cabinet is not a building — the office can hold church masters.",
|
||||
aOffSite: "off site", aItemsCount: (n) => `${n} items`,
|
||||
fNum: "Number", fBuilding: "Building", fDoorName: "Door name", fNote: "Note",
|
||||
fShortCode: "Short code", fShortName: "Short name", fFullName: "Full name",
|
||||
fWhereIs: "Where it is, and what opens it", fSort: "Board order",
|
||||
fSortHelp: "Lower numbers come first. Give the reserve store a high number to keep it last.",
|
||||
fOffSiteLabel: "Off site — reserve store",
|
||||
fZoneKeys: "Door (keys)", fZoneTags: "Alarm zone (tags)",
|
||||
fNoneTag: "— none / alarm tag —", fNone: "— none —",
|
||||
fIsMaster: "This is a master key", fPermNote: "Permanent note",
|
||||
fFunction: "Function", fEmail: "Email", fActiveLabel: "Active — can be handed keys",
|
||||
save: "Save", deleteRetire: "Delete or retire this key", deleteDoor: "Delete this door",
|
||||
deactivate: "Deactivate", deleteCabinet: "Delete this cabinet",
|
||||
retireNote: "If it has handover history it is retired, never deleted — the history has to survive.",
|
||||
peopleNote: "People are never deleted. They appear in old handovers, and deleting them would make that history anonymous.",
|
||||
lockGroupNote: "Lock group ties doors that share a key profile, so you can see what else a lost key might open.",
|
||||
offSiteNote: "Keys returned to an off-site store go back to <em>reserve</em> rather than <em>in cabinet</em>, so they stay out of everyday circulation.",
|
||||
holdsWarn: (n, tags) => `Currently holds ${n} item${n === 1 ? "" : "s"}: ${tags}. Take those back before deactivating.`,
|
||||
impWhat: "What do you want to import?",
|
||||
impItems: "Keys & tags", impDoors: "Doors", impPeople: "People", impCabinets: "Cabinets",
|
||||
impResult2: (c, u) => `Created <b>${c}</b>, updated <b>${u}</b>.`,
|
||||
impTitle: "Import from a spreadsheet",
|
||||
impHelp: "Paste CSV. Required columns: <b>tag</b>, <b>door</b>, <b>building</b>. Optional: lock_group, cabinet, hook, master, status, kind, notes. Doors and cabinets that don't exist are created.",
|
||||
impBtn: "Import",
|
||||
impResult: (i, d, c) => `Imported <b>${i}</b> items, created ${d} doors and ${c} cabinets.`,
|
||||
impSkipped: (n, list) => `<br>Skipped ${n}: ${list}`,
|
||||
impNothing: "Nothing to import",
|
||||
expTitle: "Export",
|
||||
expHelp: "The whole register as a spreadsheet. Useful for a printed copy in the cabinet, and proof you are never locked into this app.",
|
||||
expBtn: "Download CSV",
|
||||
bkTitle: "Backup",
|
||||
bkHelp: "One SQLite file, safe to download while the app is running. Keep a copy somewhere that is not this machine.",
|
||||
bkBtn: "Download database",
|
||||
wipeTitle: "Clear everything",
|
||||
wipeWarn: "Deletes every key, door, person, handover and journal entry. Cabinets are kept. There is no undo — download a backup first.",
|
||||
wipePlaceholder: "Type: DELETE EVERYTHING",
|
||||
wipeBtn: "Clear the register", wipeDone: "Register cleared",
|
||||
auditTitle: "Change log",
|
||||
auditHelp: "Admin changes, newest first. With two of you editing, \u201cwho moved 12-B and when\u201d is a question that comes up.",
|
||||
aWhen: "When", aWhat: "What", aDetail: "Detail",
|
||||
imported: "Imported",
|
||||
},
|
||||
};
|
||||
|
||||
/* Chosen by the server from UI_LANG; falls back to Portuguese.
|
||||
`var`, not `let`, so the binding is a real global shared with app.js and
|
||||
admin.js rather than scoped to this script. */
|
||||
var T = I18N.pt;
|
||||
function setLang(code) {
|
||||
T = I18N[code] || I18N.pt;
|
||||
document.documentElement.lang = I18N[code] ? code : "pt";
|
||||
}
|
||||
/* Server errors arrive in English; show a translated version when we have one. */
|
||||
function errMsg(m) { return (T.errors && T.errors[m]) || m; }
|
||||
53
public/index.html
Normal file
53
public/index.html
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="pt">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#131E1B">
|
||||
<title>Key Register</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans+Condensed:wght@600;700&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<header>
|
||||
<p class="eyebrow" id="brand"></p>
|
||||
<h1 id="apptitle"></h1>
|
||||
<div class="tools">
|
||||
<a class="tool" href="/pedido?kiosk=1" id="askform" aria-label="">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="4.5" y="3.5" width="15" height="17" rx="2.5"/>
|
||||
<path d="M9 3.5h6M12 9.5v6M9 12.5h6"/>
|
||||
</svg></a>
|
||||
<a class="tool" href="/report" id="reports" aria-label="">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M7 9V3.5h10V9"/>
|
||||
<path d="M5 9h14a2 2 0 0 1 2 2v5h-4v4.5H7V16H3v-5a2 2 0 0 1 2-2Z"/>
|
||||
<path d="M7 16h10"/>
|
||||
</svg></a>
|
||||
<a class="tool" href="/admin" id="gear" aria-label="">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="3.2"/>
|
||||
<path d="M12 2.8v2.4M12 18.8v2.4M21.2 12h-2.4M5.2 12H2.8M18.5 5.5l-1.7 1.7M7.2 16.8l-1.7 1.7M18.5 18.5l-1.7-1.7M7.2 7.2 5.5 5.5"/>
|
||||
</svg></a>
|
||||
</div>
|
||||
<div class="counts" id="counts"></div>
|
||||
</header>
|
||||
<div class="scroll" id="scroll"><div class="pane"><div class="empty" id="loading"></div></div></div>
|
||||
<nav class="tabs" role="tablist">
|
||||
<button class="tab" role="tab" data-tab="board" aria-selected="true"><span class="ic"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="8" cy="8" r="3.1"/><circle cx="16" cy="8" r="3.1"/><circle cx="8" cy="16" r="3.1"/><circle cx="16" cy="16" r="3.1"/></svg></span><span data-t="tabBoard"></span></button>
|
||||
<button class="tab" role="tab" data-tab="doors" aria-selected="false"><span class="ic"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6.5 3.5h11v17h-11z"/><path d="M5 20.5h14"/><circle cx="14.6" cy="12.2" r="1"/></svg></span><span data-t="tabDoors"></span></button>
|
||||
<button class="tab" role="tab" data-tab="people" aria-selected="false"><span class="ic"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8.4" r="3.5"/><path d="M5.2 20.3a6.8 6.8 0 0 1 13.6 0"/></svg></span><span data-t="tabPeople"></span></button>
|
||||
<button class="tab" role="tab" data-tab="log" aria-selected="false"><span class="ic"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 8.6h13"/><path d="M13.6 5.2 17 8.6l-3.4 3.4"/><path d="M20 15.4H7"/><path d="M10.4 12 7 15.4l3.4 3.4"/></svg></span><span data-t="tabLog"></span></button>
|
||||
<button class="tab" role="tab" data-tab="journal" aria-selected="false"><span class="ic"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6.5 3.5h8l4.5 4.5v12.5h-12.5z"/><path d="M14.5 3.5V8H19"/><path d="M9.5 12.5h6M9.5 16h4"/></svg></span><span data-t="tabJournal"></span></button>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="scrim" id="scrim"></div>
|
||||
<div class="sheet" id="sheet" role="dialog" aria-modal="true"></div>
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="/i18n.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
133
public/login.html
Normal file
133
public/login.html
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="pt">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#131E1B">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>Registo de Chaves</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans+Condensed:wght@700&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="login">
|
||||
<!-- the key board, as wallpaper -->
|
||||
<svg id="pegfield" aria-hidden="true"></svg>
|
||||
|
||||
<div class="login-card">
|
||||
<div class="login-mark" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<circle cx="8" cy="8" r="4.2"/>
|
||||
<path d="M11 11 L19 19"/>
|
||||
<path d="M16.4 16.4 L18.6 14.2"/>
|
||||
<path d="M18.6 18.6 L20.6 16.6"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<p class="eyebrow" id="brand"></p>
|
||||
<h1 id="title"></h1>
|
||||
<p class="login-sub" id="sub"></p>
|
||||
|
||||
<div class="pw-wrap">
|
||||
<input class="pw" id="code" type="password" inputmode="text"
|
||||
autocomplete="current-password" autocapitalize="off"
|
||||
autocorrect="off" spellcheck="false" autofocus>
|
||||
<button class="pw-toggle" id="toggle" type="button" aria-pressed="false">👁</button>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-p" id="go"></button>
|
||||
<p class="login-err" id="err" role="alert" aria-live="polite"></p>
|
||||
</div>
|
||||
|
||||
<p class="login-foot" id="foot"></p>
|
||||
</div>
|
||||
|
||||
<script src="/i18n.js"></script>
|
||||
<script>
|
||||
/* The server substitutes the configured language before sending this page. */
|
||||
setLang("__LANG__".replace(/^__.*__$/, "pt"));
|
||||
|
||||
document.getElementById("brand").textContent = T.brand;
|
||||
document.getElementById("title").textContent = T.brand;
|
||||
document.getElementById("sub").textContent = T.loginSub;
|
||||
document.getElementById("go").textContent = T.loginBtn;
|
||||
document.getElementById("foot").textContent = T.loginFoot;
|
||||
document.title = T.brand;
|
||||
|
||||
const $code = document.getElementById("code");
|
||||
const $err = document.getElementById("err");
|
||||
const $card = document.querySelector(".login-card");
|
||||
const $toggle = document.getElementById("toggle");
|
||||
$code.placeholder = T.loginPlaceholder;
|
||||
$toggle.title = T.loginShow;
|
||||
$toggle.setAttribute("aria-label", T.loginShow);
|
||||
|
||||
/* Decorative peg board. Drawn rather than an image so it scales to any
|
||||
screen and stays a few hundred bytes. */
|
||||
(function pegfield() {
|
||||
const svg = document.getElementById("pegfield");
|
||||
const W = 1200, H = 900, step = 66, r = 15;
|
||||
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
|
||||
svg.setAttribute("preserveAspectRatio", "xMidYMid slice");
|
||||
let out = "";
|
||||
for (let y = step / 2; y < H; y += step) {
|
||||
for (let x = step / 2; x < W; x += step) {
|
||||
/* deterministic pseudo-random, so the pattern is stable across reloads */
|
||||
const n = Math.abs(Math.sin(x * 12.9898 + y * 78.233) * 43758.5453) % 1;
|
||||
if (n < 0.10) continue; // empty hook
|
||||
if (n < 0.26) { // issued
|
||||
out += `<circle cx="${x}" cy="${y}" r="${r}" fill="none" stroke="#A87F3C" stroke-width="2"/>`;
|
||||
} else if (n < 0.34) { // alarm tag
|
||||
out += `<rect x="${x - r}" y="${y - r}" width="${r * 2}" height="${r * 2}" rx="4" fill="#5F55A0"/>`;
|
||||
} else { // on its hook
|
||||
out += `<circle cx="${x}" cy="${y}" r="${r}" fill="#2E6E60"/>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
svg.innerHTML = out;
|
||||
})();
|
||||
|
||||
let busy = false;
|
||||
function fail(msg) {
|
||||
$err.textContent = msg;
|
||||
$err.classList.add("on");
|
||||
$card.classList.remove("shake");
|
||||
void $card.offsetWidth; // restart the animation
|
||||
$card.classList.add("shake");
|
||||
$code.value = "";
|
||||
$code.focus();
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (busy || !$code.value) return;
|
||||
busy = true;
|
||||
$err.classList.remove("on");
|
||||
try {
|
||||
const r = await fetch("/api/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code: $code.value }),
|
||||
});
|
||||
if (r.ok) { location.href = "/"; return; }
|
||||
fail(r.status === 429 ? T.loginMany : T.loginWrong);
|
||||
} catch (e) {
|
||||
fail(T.noServer);
|
||||
}
|
||||
busy = false;
|
||||
}
|
||||
|
||||
document.getElementById("go").addEventListener("click", submit);
|
||||
$code.addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); });
|
||||
$toggle.addEventListener("click", () => {
|
||||
const shown = $code.type === "text";
|
||||
$code.type = shown ? "password" : "text";
|
||||
$toggle.setAttribute("aria-pressed", String(!shown));
|
||||
$toggle.title = shown ? T.loginShow : T.loginHide;
|
||||
$toggle.setAttribute("aria-label", $toggle.title);
|
||||
$code.focus();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
164
public/pedido.html
Normal file
164
public/pedido.html
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="pt">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#131E1B">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>Pedido de acesso</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans+Condensed:wght@700&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="req-page">
|
||||
<div class="req-top">
|
||||
<div class="inner">
|
||||
<p class="eyebrow" id="brand"></p>
|
||||
<h1 id="title"></h1>
|
||||
<p id="intro"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="req-wrap" id="wrap">
|
||||
<div class="req-msg bad" id="msg" role="alert" aria-live="polite"></div>
|
||||
|
||||
<div class="req-field">
|
||||
<span class="lab" id="l_name"></span>
|
||||
<input type="text" id="f_name" autocomplete="name" maxlength="80">
|
||||
</div>
|
||||
|
||||
<div class="req-field">
|
||||
<span class="lab" id="l_dept"></span>
|
||||
<input type="text" id="f_dept" autocomplete="organization" maxlength="80">
|
||||
</div>
|
||||
|
||||
<div class="req-field">
|
||||
<span class="lab" id="l_contact"></span>
|
||||
<input type="text" id="f_contact" autocomplete="tel" maxlength="80">
|
||||
</div>
|
||||
|
||||
<div class="req-field">
|
||||
<span class="lab" id="l_areas"></span>
|
||||
<div class="areas" id="areas"></div>
|
||||
</div>
|
||||
|
||||
<div class="req-field">
|
||||
<span class="lab" id="l_detail"></span>
|
||||
<textarea id="f_detail" maxlength="600"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="req-field" id="pinbox" style="display:none">
|
||||
<span class="lab" id="l_pin"></span>
|
||||
<input type="text" id="f_pin" inputmode="numeric" autocomplete="off" maxlength="24">
|
||||
<p class="req-hint" id="h_pin"></p>
|
||||
</div>
|
||||
|
||||
<!-- Bots fill every field they can find; a person never sees this one. -->
|
||||
<div class="hp" aria-hidden="true">
|
||||
<label>Website<input type="text" id="f_website" tabindex="-1" autocomplete="off"></label>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-p" id="send" style="width:100%"></button>
|
||||
<p class="req-foot" id="foot"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/i18n.js"></script>
|
||||
<script>
|
||||
setLang("__LANG__".replace(/^__.*__$/, "pt"));
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||||
|
||||
$("brand").textContent = T.brand;
|
||||
$("title").textContent = T.reqTitle;
|
||||
$("intro").textContent = T.reqIntro;
|
||||
$("l_name").textContent = T.reqName;
|
||||
$("l_dept").textContent = T.reqDept;
|
||||
$("l_contact").textContent = T.reqContact;
|
||||
$("l_areas").textContent = T.reqAreas;
|
||||
$("l_detail").textContent = T.reqDetail;
|
||||
$("f_detail").placeholder = T.reqDetailPh;
|
||||
$("l_pin").textContent = T.reqPin;
|
||||
$("h_pin").textContent = T.reqPinHelp;
|
||||
$("send").textContent = T.reqSend;
|
||||
$("foot").textContent = T.reqFoot;
|
||||
document.title = T.reqTitle;
|
||||
|
||||
/* Areas, not doors. The endpoint returns building names only — this page is
|
||||
the one an outsider might reach, and a door list is a map of the building. */
|
||||
/* kiosk=1 means the keyholder handed their own phone over: no PIN prompt,
|
||||
and the confirmation offers a fresh blank form rather than a dead end. */
|
||||
const KIOSK = new URLSearchParams(location.search).get("kiosk") === "1";
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch("/api/request/areas").then((x) => x.json());
|
||||
$("areas").innerHTML = (r.areas || []).map((a, n) =>
|
||||
`<label class="area"><input type="checkbox" value="${esc(a)}" id="a${n}"><span>${esc(a)}</span></label>`
|
||||
).join("");
|
||||
if (r.pin) $("pinbox").style.display = "";
|
||||
else if (KIOSK) $("foot").textContent = T.reqNoPinStaff;
|
||||
} catch (e) { /* leave the section empty; the free-text field still works */ }
|
||||
})();
|
||||
|
||||
function fail(m) {
|
||||
const el = $("msg");
|
||||
el.textContent = m;
|
||||
el.classList.add("on");
|
||||
if (el.scrollIntoView) el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
|
||||
let busy = false;
|
||||
$("send").addEventListener("click", async () => {
|
||||
if (busy) return;
|
||||
$("msg").classList.remove("on");
|
||||
|
||||
const name = $("f_name").value.trim();
|
||||
if (!name) return fail(T.reqNeedName);
|
||||
const areas = [...document.querySelectorAll("#areas input:checked")].map((i) => i.value);
|
||||
if (!areas.length && !$("f_detail").value.trim()) return fail(T.reqNeedArea);
|
||||
|
||||
busy = true;
|
||||
$("send").textContent = T.reqSending;
|
||||
$("send").disabled = true;
|
||||
try {
|
||||
const res = await fetch("/api/request", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
dept: $("f_dept").value.trim(),
|
||||
contact: $("f_contact").value.trim(),
|
||||
areas,
|
||||
detail: $("f_detail").value.trim(),
|
||||
pin: $("f_pin").value.trim(),
|
||||
website: $("f_website").value,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
$("wrap").innerHTML = `<div class="req-done">
|
||||
<div class="tickmark">✓</div>
|
||||
<h2>${esc(T.reqOk)}</h2>
|
||||
<p>${esc(KIOSK ? T.reqKioskDone : T.reqOkMore)}</p>
|
||||
<button class="btn btn-g" onclick="location.reload()">${
|
||||
esc(KIOSK ? T.reqKioskNext : T.reqAnother)}</button>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
const b = await res.json().catch(() => ({}));
|
||||
fail(res.status === 429 ? T.reqTooMany
|
||||
: res.status === 401 ? T.reqWrongPin
|
||||
: (T.errors && T.errors[b.error]) || b.error || T.noServer);
|
||||
} catch (e) {
|
||||
fail(T.noServer);
|
||||
}
|
||||
busy = false;
|
||||
$("send").textContent = T.reqSend;
|
||||
$("send").disabled = false;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
26
public/report.html
Normal file
26
public/report.html
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="pt">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Relatório</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans+Condensed:wght@600;700&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="report">
|
||||
<div class="rep-bar no-print">
|
||||
<a class="mini" style="text-decoration:none;background:var(--card);color:var(--ink);border:1px solid var(--line)"
|
||||
href="/" id="backlink">←</a>
|
||||
<select id="view"></select>
|
||||
<select id="who" style="display:none"></select>
|
||||
<button class="mini" id="printbtn"></button>
|
||||
</div>
|
||||
<div id="out"></div>
|
||||
</div>
|
||||
<script src="/i18n.js"></script>
|
||||
<script src="/report.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
234
public/report.js
Normal file
234
public/report.js
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
/* Key register — printable reports.
|
||||
Everything renders to plain HTML so the browser's own Print / Save as PDF
|
||||
produces the document. No PDF library, nothing to keep up to date. */
|
||||
"use strict";
|
||||
|
||||
let S = null, view = "out", whoId = null;
|
||||
|
||||
const $out = document.getElementById("out");
|
||||
const $view = document.getElementById("view");
|
||||
const $who = document.getElementById("who");
|
||||
|
||||
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||||
const fmt = (d) => { if (!d) return "—"; const [y, m, dd] = d.split("-"); return `${dd} ${T._months[+m - 1]} ${y}`; };
|
||||
const days = (a, b) => Math.round((new Date(b) - new Date(a)) / 864e5);
|
||||
const person = (id) => S.people.find((p) => p.id === +id);
|
||||
const door = (id) => S.doors.find((d) => d.id === +id);
|
||||
const cab = (id) => S.cabinets.find((c) => c.id === id) || { name: "—", full_name: "—" };
|
||||
const doorName = (i) => (i.door_id ? door(i.door_id).name : i.zone);
|
||||
const isLate = (i) => i.loan && i.loan.due_date && i.loan.due_date < S.today;
|
||||
/* Hook order — the audit sheet is meant to be read down the cabinet. */
|
||||
const byHook = (a, b) =>
|
||||
(a.hook || "").localeCompare(b.hook || "", undefined, { numeric: true }) ||
|
||||
a.tag.localeCompare(b.tag, undefined, { numeric: true });
|
||||
|
||||
function head(title, sub) {
|
||||
const now = new Date();
|
||||
return `<div class="rep-head">
|
||||
<p class="eyebrow">${esc(T.brand)}</p>
|
||||
<h1>${esc(title)}</h1>
|
||||
<p class="rep-meta">${esc(T.repGenerated(fmt(S.today), now.toTimeString().slice(0, 5)))}${
|
||||
sub ? " · " + esc(sub) : ""}</p></div>`;
|
||||
}
|
||||
|
||||
const stat = (n, label) => `<div class="rep-stat"><b>${n}</b><span>${esc(label)}</span></div>`;
|
||||
|
||||
/* ---------------------------------------------------------------- issued */
|
||||
function viewOut(onlyLate) {
|
||||
const out = S.items.filter((i) => i.status === "out" && (!onlyLate || isLate(i)));
|
||||
const byPerson = {};
|
||||
out.forEach((i) => (byPerson[i.loan.person_id] = byPerson[i.loan.person_id] || []).push(i));
|
||||
Object.values(byPerson).forEach((l) => l.sort(byHook));
|
||||
const ids = Object.keys(byPerson).sort((a, b) =>
|
||||
person(a).name.localeCompare(person(b).name, "pt"));
|
||||
|
||||
if (!out.length) return head(onlyLate ? T.repOverdue : T.repOut) + `<p>${esc(T.repNothing)}</p>`;
|
||||
|
||||
return head(onlyLate ? T.repOverdue : T.repOut) +
|
||||
`<div class="rep-stats">
|
||||
${stat(out.length, T.stKeysOut)}
|
||||
${stat(ids.length, T.stPeople)}
|
||||
${stat(S.items.filter(isLate).length, T.stOverdue)}
|
||||
</div><div style="height:22px"></div>` +
|
||||
ids.map((pid) => {
|
||||
const p = person(pid), list = byPerson[pid];
|
||||
return `<div class="rep-group">
|
||||
<h2>${esc(p.name)}</h2>
|
||||
<p class="who">${esc(p.dept)}${p.role ? " · " + esc(p.role) : ""}${
|
||||
p.phone ? " · " + esc(p.phone) : ""}</p>
|
||||
<table class="rep"><thead><tr>
|
||||
<th>${esc(T.cTag)}</th><th>${esc(T.cDoor)}</th><th>${esc(T.cBuilding)}</th>
|
||||
<th>${esc(T.cCabinet)}</th><th>${esc(T.cOut)}</th><th>${esc(T.cDue)}</th>
|
||||
</tr></thead><tbody>
|
||||
${list.map((i) => {
|
||||
const late = isLate(i);
|
||||
return `<tr>
|
||||
<td class="num">${esc(i.tag)}</td>
|
||||
<td>${esc(doorName(i))}</td>
|
||||
<td>${esc(i.building)}</td>
|
||||
<td>${esc(cab(i.cabinet_id).name)}</td>
|
||||
<td>${fmt(i.loan.out_date)}</td>
|
||||
<td class="${late ? "od" : ""}">${i.loan.due_date ? fmt(i.loan.due_date) : "—"}${
|
||||
late ? ` (+${days(i.loan.due_date, S.today)})` : ""}</td></tr>`;
|
||||
}).join("")}
|
||||
</tbody></table></div>`;
|
||||
}).join("") + signOff();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------- receipt for one person */
|
||||
async function viewPerson(id) {
|
||||
const p = person(id);
|
||||
if (!p) return head(T.repReceiptTitle) + `<p>${esc(T.repChoosePerson)}</p>`;
|
||||
let rows = [];
|
||||
try { rows = await fetch("/api/report/person/" + id).then((r) => r.json()); } catch (e) { /* offline */ }
|
||||
|
||||
if (!rows.length) return head(T.repReceiptTitle, p.name) + `<p>${esc(T.repNothing)}</p>`;
|
||||
|
||||
return head(T.repReceiptTitle, `${p.name}${p.dept ? " · " + p.dept : ""}`) +
|
||||
`<p style="font-size:14px;max-width:62ch">${esc(T.repReceiptIntro(rows.length))}</p>
|
||||
<table class="rep" style="margin-top:14px"><thead><tr>
|
||||
<th>${esc(T.cTag)}</th><th>${esc(T.cDoor)}</th><th>${esc(T.cBuilding)}</th>
|
||||
<th>${esc(T.cCabinet)}</th><th>${esc(T.cOut)}</th><th>${esc(T.cDue)}</th>
|
||||
</tr></thead><tbody>
|
||||
${rows.map((h) => {
|
||||
const late = h.due_date && h.due_date < S.today;
|
||||
return `<tr>
|
||||
<td class="num">${esc(h.tag)}</td><td>${esc(h.door || "")}</td>
|
||||
<td>${esc(h.building)}</td><td>${esc(cab(h.cabinet_id).name)}</td>
|
||||
<td>${fmt(h.out_date)}</td>
|
||||
<td class="${late ? "od" : ""}">${h.due_date ? fmt(h.due_date) : "—"}</td></tr>`;
|
||||
}).join("")}
|
||||
</tbody></table>
|
||||
<div class="sec" style="margin-top:24px">${esc(T.signature)}</div>
|
||||
${rows.filter((h) => h.signature).map((h) => `<div class="sig-block">
|
||||
<div class="sig-cap">${esc(h.tag)} · ${esc(T.repSignedOn(fmt(h.out_date)))}</div>
|
||||
<img src="${esc(h.signature)}" alt=""></div>`).join("")
|
||||
|| `<p style="font-size:13px;color:var(--slate)">${esc(T.repNoSig)}</p>`}
|
||||
${signOff(p.name)}`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- inventory */
|
||||
function viewInventory() {
|
||||
const live = S.items.filter((i) => i.status !== "retired");
|
||||
const groups = {};
|
||||
S.cabinets.forEach((c) => {
|
||||
const list = live.filter((i) => i.cabinet_id === c.id).sort(byHook);
|
||||
if (list.length) groups[c.id] = list;
|
||||
});
|
||||
const statusWord = (i) => (T.statuses && T.statuses[i.status]) || i.status;
|
||||
|
||||
return head(T.repInventory) +
|
||||
`<div class="rep-stats">
|
||||
${stat(S.items.length, T.stTotal)}
|
||||
${stat(S.items.filter((i) => i.status === "in").length, T.stInCab)}
|
||||
${stat(S.items.filter((i) => i.status === "out").length, T.stKeysOut)}
|
||||
${stat(S.items.filter((i) => i.status === "reserve").length, T.stReserve)}
|
||||
</div><div style="height:22px"></div>` +
|
||||
S.cabinets.filter((c) => groups[c.id]).map((c) => `<div class="rep-group">
|
||||
<h2>${esc(c.full_name)}</h2>
|
||||
<p class="who">${esc(c.note || "")}</p>
|
||||
<table class="rep"><thead><tr>
|
||||
<th>${esc(T.cTag)}</th><th>${esc(T.cDoor)}</th><th>${esc(T.cBuilding)}</th>
|
||||
<th>${esc(T.cHook)}</th><th>${esc(T.cStatus)}</th><th>${esc(T.cPerson)}</th>
|
||||
</tr></thead><tbody>
|
||||
${groups[c.id].map((i) => `<tr>
|
||||
<td class="num">${esc(i.tag)}</td><td>${esc(doorName(i))}</td>
|
||||
<td>${esc(i.building)}</td><td>${esc(i.hook)}</td>
|
||||
<td class="${isLate(i) ? "od" : ""}">${esc(statusWord(i))}</td>
|
||||
<td>${i.loan && i.status === "out" ? esc(person(i.loan.person_id).name) : ""}</td></tr>`).join("")}
|
||||
</tbody></table></div>`).join("");
|
||||
}
|
||||
|
||||
/* ------------------------------------------- audit sheet, one per cabinet */
|
||||
function viewAudit() {
|
||||
const cabs = whoId && whoId !== "ALL" ? S.cabinets.filter((c) => c.id === whoId) : S.cabinets;
|
||||
return head(T.repAudit) +
|
||||
`<p style="font-size:13.5px;max-width:62ch">${esc(T.repAuditIntro)}</p>` +
|
||||
cabs.map((c) => {
|
||||
const list = S.items.filter((i) => i.cabinet_id === c.id && i.status !== "retired").sort(byHook);
|
||||
if (!list.length) return "";
|
||||
return `<div class="rep-group">
|
||||
<h2>${esc(c.full_name)}</h2>
|
||||
<p class="who">${esc(c.note || "")}</p>
|
||||
<table class="rep"><thead><tr>
|
||||
<th style="width:34px">${esc(T.cCheck)}</th><th>${esc(T.cTag)}</th>
|
||||
<th>${esc(T.cHook)}</th><th>${esc(T.cDoor)}</th>
|
||||
<th>${esc(T.cStatus)}</th><th>${esc(T.cPerson)}</th>
|
||||
</tr></thead><tbody>
|
||||
${list.map((i) => `<tr>
|
||||
<td><span class="tick"></span></td>
|
||||
<td class="num">${esc(i.tag)}</td><td>${esc(i.hook)}</td>
|
||||
<td>${esc(doorName(i))}</td>
|
||||
<td>${esc((T.statuses && T.statuses[i.status]) || i.status)}</td>
|
||||
<td>${i.loan && i.status === "out" ? esc(person(i.loan.person_id).name) : ""}</td></tr>`).join("")}
|
||||
</tbody></table></div>`;
|
||||
}).join("") +
|
||||
`<div class="sign-off">
|
||||
<div><div class="sign-line"></div><div class="sign-lab">${esc(T.repAuditBy)}</div></div>
|
||||
<div><div class="sign-line"></div><div class="sign-lab">${esc(T.repDate)}</div></div>
|
||||
</div>
|
||||
<div style="margin-top:20px"><div class="sign-lab">${esc(T.repNotes)}</div>
|
||||
<div class="sign-line"></div><div class="sign-line"></div></div>`;
|
||||
}
|
||||
|
||||
function signOff(holder) {
|
||||
return `<div class="sign-off">
|
||||
${holder ? `<div><div class="sign-line"></div>
|
||||
<div class="sign-lab">${esc(T.repHolder)} — ${esc(holder)}</div></div>` : ""}
|
||||
<div><div class="sign-line"></div><div class="sign-lab">${esc(T.repResponsible)}</div></div>
|
||||
<div><div class="sign-line"></div><div class="sign-lab">${esc(T.repDate)}</div></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ shell */
|
||||
async function render() {
|
||||
$out.innerHTML = "";
|
||||
const isPerson = view === "person";
|
||||
const isAudit = view === "audit";
|
||||
$who.style.display = isPerson || isAudit ? "" : "none";
|
||||
|
||||
if (isPerson) {
|
||||
$who.innerHTML = `<option value="">${esc(T.repChoosePerson)}</option>` +
|
||||
S.people.map((p) => `<option value="${p.id}"${String(whoId) === String(p.id) ? " selected" : ""}>${esc(p.name)}</option>`).join("");
|
||||
$out.innerHTML = await viewPerson(whoId);
|
||||
} else if (isAudit) {
|
||||
$who.innerHTML = `<option value="ALL">${esc(T.repAllCabinets)}</option>` +
|
||||
S.cabinets.map((c) => `<option value="${esc(c.id)}"${whoId === c.id ? " selected" : ""}>${esc(c.full_name)}</option>`).join("");
|
||||
$out.innerHTML = viewAudit();
|
||||
} else if (view === "overdue") {
|
||||
$out.innerHTML = viewOut(true);
|
||||
} else if (view === "inventory") {
|
||||
$out.innerHTML = viewInventory();
|
||||
} else {
|
||||
$out.innerHTML = viewOut(false);
|
||||
}
|
||||
document.title = `${T.brand} — ${$view.selectedOptions[0].textContent}`;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
S = await fetch("/api/state").then((r) => r.json());
|
||||
} catch (e) {
|
||||
$out.innerHTML = `<p>${esc(T ? T.noServer : "offline")}</p>`;
|
||||
return;
|
||||
}
|
||||
setLang(S.lang);
|
||||
|
||||
document.getElementById("printbtn").textContent = T.printPdf;
|
||||
document.getElementById("backlink").title = T.back;
|
||||
$view.innerHTML = [["out", T.repOut], ["overdue", T.repOverdue], ["person", T.repPerson],
|
||||
["inventory", T.repInventory], ["audit", T.repAudit]]
|
||||
.map(([v, l]) => `<option value="${v}">${esc(l)}</option>`).join("");
|
||||
|
||||
/* deep links: /report?view=person&id=3 */
|
||||
const q = new URLSearchParams(location.search);
|
||||
if (q.get("view")) view = q.get("view");
|
||||
if (q.get("id")) whoId = q.get("id");
|
||||
$view.value = view;
|
||||
|
||||
$view.onchange = () => { view = $view.value; whoId = view === "audit" ? "ALL" : null; render(); };
|
||||
$who.onchange = () => { whoId = $who.value; render(); };
|
||||
document.getElementById("printbtn").onclick = () => window.print();
|
||||
|
||||
await render();
|
||||
})();
|
||||
514
public/styles.css
Normal file
514
public/styles.css
Normal file
|
|
@ -0,0 +1,514 @@
|
|||
:root{
|
||||
--ink:#131E1B; --ink-2:#20302B; --ink-3:#2C3F39;
|
||||
--slate:#5C6B65; --slate-2:#8A968F; --line:#DCDFD9;
|
||||
--paper:#F4F5F2; --card:#FFFFFF;
|
||||
--verdigris:#2E6E60; --verdigris-soft:#E4EFEB;
|
||||
--brass:#A87F3C; --brass-soft:#F6EEDF;
|
||||
--violet:#5F55A0; --steel:#54646D; --steel-soft:#E9EDEF;
|
||||
--late:#AE4034; --late-soft:#F8E9E7; --r:10px;
|
||||
}
|
||||
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
|
||||
html,body{margin:0;padding:0}
|
||||
body{background:var(--paper);color:var(--ink);font-family:"IBM Plex Sans",system-ui,sans-serif;
|
||||
font-size:15px;line-height:1.45;-webkit-font-smoothing:antialiased}
|
||||
button{font:inherit;color:inherit;border:0;background:none;cursor:pointer}
|
||||
textarea,input,select{font:inherit}
|
||||
:focus-visible{outline:2px solid var(--verdigris);outline-offset:2px;border-radius:4px}
|
||||
.app{max-width:560px;margin:0 auto;min-height:100vh;display:flex;flex-direction:column}
|
||||
header{background:var(--ink);color:#fff;padding:16px 18px 14px;position:relative}
|
||||
.eyebrow{font-family:"IBM Plex Mono",monospace;font-size:10px;letter-spacing:.18em;
|
||||
text-transform:uppercase;color:var(--slate-2);margin:0 0 3px}
|
||||
h1{font-family:"IBM Plex Sans Condensed",sans-serif;font-weight:700;font-size:23px;margin:0;text-transform:uppercase}
|
||||
/* One flex row of equal boxes. Positioning each icon with its own `right`
|
||||
offset gave uneven gaps, because the glyphs are not the same width — and a
|
||||
mix of emoji and text glyphs also renders at different sizes and colours.
|
||||
Matched SVGs in fixed boxes solve both. */
|
||||
.tools{position:absolute;top:12px;right:12px;display:flex;gap:2px}
|
||||
.tool{
|
||||
width:36px;height:36px;display:flex;align-items:center;justify-content:center;
|
||||
border-radius:8px;color:#7A8A83;transition:color .12s,background .12s
|
||||
}
|
||||
.tool svg{
|
||||
width:19px;height:19px;fill:none;stroke:currentColor;stroke-width:1.6;
|
||||
stroke-linecap:round;stroke-linejoin:round
|
||||
}
|
||||
.tool:hover{color:#DDE4E0;background:rgba(255,255,255,.07)}
|
||||
.tool:active{background:rgba(255,255,255,.12)}
|
||||
|
||||
/* the admin and report pages still use a single back/settings link */
|
||||
.gear{position:absolute;top:14px;right:14px;color:#7A8A83;font-size:19px;padding:6px}
|
||||
.counts{display:flex;gap:7px;margin-top:13px;flex-wrap:wrap}
|
||||
.count{font-family:"IBM Plex Mono",monospace;font-size:11px;padding:5px 9px;border-radius:100px;
|
||||
background:var(--ink-3);color:#D7DEDA;display:flex;align-items:center;gap:6px}
|
||||
.count b{font-weight:600;color:#fff}
|
||||
.count.warn{background:#43221D;color:#F0BDB6} .count.warn b{color:#fff}
|
||||
.swatch{width:8px;height:8px;border-radius:50%}
|
||||
.swatch.out{background:var(--brass)} .swatch.in{background:var(--verdigris)} .swatch.res{background:var(--steel)}
|
||||
.scroll{flex:1;overflow-y:auto;padding-bottom:80px}
|
||||
.board{background:var(--ink-2);padding:14px 14px 20px;min-height:60vh}
|
||||
/* Cabinets wrap onto as many rows as they need. A horizontal scroller hides
|
||||
cabinets behind the edge, on the one screen you look at most. */
|
||||
.cabs{
|
||||
display:grid;grid-template-columns:repeat(auto-fit,minmax(96px,1fr));
|
||||
gap:6px;margin-bottom:11px
|
||||
}
|
||||
.cab{
|
||||
padding:8px 10px;border-radius:8px;background:var(--ink);border:1px solid var(--ink-3);
|
||||
text-align:left;min-width:0
|
||||
}
|
||||
.cab[aria-pressed="true"]{background:#fff;border-color:#fff}
|
||||
.cab .cn{
|
||||
display:block;font-family:"IBM Plex Sans Condensed",sans-serif;font-weight:700;
|
||||
font-size:12.5px;text-transform:uppercase;color:#CBD5D0;letter-spacing:.01em;
|
||||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis
|
||||
}
|
||||
.cab[aria-pressed="true"] .cn{color:var(--ink)}
|
||||
.cab .cs{
|
||||
display:block;font-family:"IBM Plex Mono",monospace;font-size:9.5px;color:#7A8A83;
|
||||
margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis
|
||||
}
|
||||
.cab[aria-pressed="true"] .cs{color:var(--slate)}
|
||||
.cab .cs .o{color:var(--brass)}
|
||||
/* wider screens fit more per row without the tiles becoming huge */
|
||||
@media (min-width:460px){
|
||||
.cabs{grid-template-columns:repeat(auto-fit,minmax(112px,1fr))}
|
||||
.cab{padding:9px 12px}
|
||||
.cab .cn{font-size:13px}
|
||||
.cab .cs{font-size:10px}
|
||||
}
|
||||
.where{font-size:11.5px;color:#8A968F;margin:0 0 12px;padding-left:2px;line-height:1.5}
|
||||
.filters{display:flex;gap:6px;margin-bottom:14px;flex-wrap:wrap}
|
||||
.chip{font-size:12px;padding:6px 11px;border-radius:100px;background:transparent;color:#9FADA7;border:1px solid var(--ink-3)}
|
||||
.chip[aria-pressed="true"]{background:#fff;color:var(--ink);border-color:#fff;font-weight:500}
|
||||
.rail{margin-bottom:15px}
|
||||
.rail-label{font-family:"IBM Plex Mono",monospace;font-size:10px;letter-spacing:.14em;text-transform:uppercase;
|
||||
color:var(--slate-2);padding-bottom:6px;margin-bottom:9px;border-bottom:1px solid var(--ink-3);
|
||||
display:flex;justify-content:space-between;align-items:baseline;gap:10px}
|
||||
.rail-label span{color:#6E7D77;flex:none}
|
||||
.pegs{display:grid;grid-template-columns:repeat(auto-fill,minmax(46px,1fr));gap:7px}
|
||||
.peg{aspect-ratio:1;display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||
font-family:"IBM Plex Mono",monospace;font-size:11.5px;font-weight:500;border:1.5px solid transparent;
|
||||
position:relative;transition:transform .1s}
|
||||
.peg:active{transform:scale(.93)}
|
||||
/* shape = what it is, fill = where it is. Both now come from the type record
|
||||
rather than being hardcoded for two kinds. */
|
||||
.peg.shape-circle{border-radius:50%}
|
||||
.peg.shape-rounded{border-radius:5px}
|
||||
.peg.shape-square{border-radius:0}
|
||||
.peg.st-in{background:var(--k,var(--verdigris));color:#fff;border-color:var(--k,var(--verdigris))}
|
||||
.peg.st-reserve{background:#48585F;color:#C6D2D7;border-color:#5E7079}
|
||||
.peg.st-out{background:transparent;color:var(--brass);border-color:var(--brass)}
|
||||
.peg.st-lost,.peg.st-retired{background:transparent;color:#6E7D77;border-color:#4A5A54;border-style:dashed}
|
||||
.peg.late{color:#E8907F;border-color:var(--late);border-width:2px}
|
||||
.peg .flag{position:absolute;top:-2px;right:-2px;width:9px;height:9px;border-radius:50%;
|
||||
background:var(--late);border:1.5px solid var(--ink-2)}
|
||||
.peg .memo{position:absolute;bottom:-1px;right:2px;font-size:8px;opacity:.85}
|
||||
.peg small{font-size:8px;opacity:.75;margin-top:1px}
|
||||
.legend{display:flex;gap:13px;flex-wrap:wrap;margin-top:4px;font-family:"IBM Plex Mono",monospace;
|
||||
font-size:10px;color:#8A968F}
|
||||
.legend i{display:inline-block;width:11px;height:11px;margin-right:5px;vertical-align:-2px;border:1.5px solid}
|
||||
.lg-in{background:var(--verdigris);border-color:var(--verdigris);border-radius:50%}
|
||||
.lg-out{border-color:var(--brass);border-radius:50%}
|
||||
.lg-res{background:#48585F;border-color:#5E7079;border-radius:50%}
|
||||
.lg-late{border-color:var(--late);border-radius:50%;border-width:2px}
|
||||
.lg-kind{border-radius:3px}
|
||||
.pane{padding:14px}
|
||||
.search,.field{width:100%;padding:11px 13px;border:1px solid var(--line);border-radius:var(--r);
|
||||
background:var(--card);font-size:15px;margin-bottom:12px}
|
||||
.search:focus,.field:focus{outline:none;border-color:var(--verdigris);box-shadow:0 0 0 3px var(--verdigris-soft)}
|
||||
.row{display:flex;align-items:center;gap:12px;width:100%;text-align:left;background:var(--card);
|
||||
border:1px solid var(--line);border-radius:var(--r);padding:11px 13px;margin-bottom:7px}
|
||||
.row:active{background:#FAFBF9}
|
||||
.row.late{border-color:#E4BDB6;background:#FEFAF9}
|
||||
.badge{flex:none;width:42px;height:42px;display:flex;align-items:center;justify-content:center;
|
||||
font-family:"IBM Plex Mono",monospace;font-size:11px;font-weight:600;border:1.5px solid}
|
||||
.badge.shape-circle{border-radius:50%}
|
||||
.badge.shape-rounded{border-radius:6px}
|
||||
.badge.shape-square{border-radius:0}
|
||||
.badge.st-in{background:var(--k,var(--verdigris));border-color:var(--k,var(--verdigris));color:#fff}
|
||||
.badge.st-reserve{background:var(--steel);border-color:var(--steel);color:#fff}
|
||||
.badge.st-out{border-color:var(--brass);color:var(--brass)}
|
||||
.badge.st-lost,.badge.st-retired{border-color:#B9BEB8;color:#909790;border-style:dashed}
|
||||
.badge.person{background:var(--paper);border-color:var(--line);color:var(--slate);border-radius:50%;
|
||||
font-family:"IBM Plex Sans Condensed",sans-serif;font-size:14px}
|
||||
.badge.door{background:var(--ink);border-color:var(--ink);color:#fff;border-radius:8px;
|
||||
font-family:"IBM Plex Sans Condensed",sans-serif;font-size:15px}
|
||||
.row-main{flex:1;min-width:0}
|
||||
.row-t{font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.row-s{font-size:12.5px;color:var(--slate);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.row-s .held{color:var(--brass);font-weight:500} .row-s .od{color:var(--late);font-weight:500}
|
||||
.row-n{flex:none;font-family:"IBM Plex Mono",monospace;font-size:11px;color:var(--slate-2);
|
||||
text-align:right;line-height:1.3}
|
||||
.dots{display:flex;gap:3px;justify-content:flex-end;margin-top:3px;flex-wrap:wrap;max-width:70px}
|
||||
.dot{width:9px;height:9px;border-radius:50%;border:1.5px solid}
|
||||
.dot.in{background:var(--k,var(--verdigris));border-color:var(--k,var(--verdigris))}
|
||||
.dot.out{border-color:var(--brass)}
|
||||
.dot.reserve{background:var(--steel);border-color:var(--steel)}
|
||||
.dot.lost,.dot.retired{border-color:#C3C8C2;border-style:dashed}
|
||||
.sec{font-family:"IBM Plex Mono",monospace;font-size:10px;letter-spacing:.14em;text-transform:uppercase;
|
||||
color:var(--slate);margin:18px 0 8px;padding-bottom:5px;border-bottom:1px solid var(--line)}
|
||||
.sec:first-child{margin-top:0}
|
||||
.empty{text-align:center;padding:32px 22px;color:var(--slate);font-size:14px;border:1px dashed var(--line);
|
||||
border-radius:var(--r);background:var(--card)}
|
||||
.log{background:var(--card);border:1px solid var(--line);border-radius:var(--r);overflow:hidden}
|
||||
.log-row{display:flex;gap:11px;padding:11px 13px;border-bottom:1px solid var(--line);align-items:baseline}
|
||||
.log-row:last-child{border-bottom:0}
|
||||
.log-d{font-family:"IBM Plex Mono",monospace;font-size:10.5px;color:var(--slate-2);flex:none;width:52px}
|
||||
.log-b{flex:1;font-size:13.5px;min-width:0}
|
||||
.log-tag{font-family:"IBM Plex Mono",monospace;font-weight:500}
|
||||
.memo-line{color:var(--slate);font-size:12px;font-style:italic;margin-top:2px}
|
||||
.dir{font-family:"IBM Plex Mono",monospace;font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;
|
||||
padding:2px 6px;border-radius:3px;flex:none}
|
||||
.dir.out{background:var(--brass-soft);color:var(--brass)}
|
||||
.dir.in{background:var(--verdigris-soft);color:var(--verdigris)}
|
||||
.dir.late{background:var(--late-soft);color:var(--late)}
|
||||
.scrim{position:fixed;inset:0;background:rgba(10,18,16,.5);z-index:40;opacity:0;pointer-events:none;transition:opacity .2s}
|
||||
.scrim.on{opacity:1;pointer-events:auto}
|
||||
.sheet{position:fixed;left:50%;bottom:0;transform:translate(-50%,100%);width:100%;max-width:560px;
|
||||
max-height:90vh;overflow-y:auto;z-index:50;background:var(--paper);border-radius:16px 16px 0 0;
|
||||
transition:transform .24s cubic-bezier(.32,.72,0,1);padding:0 16px calc(20px + env(safe-area-inset-bottom))}
|
||||
.sheet.on{transform:translate(-50%,0)}
|
||||
.grab{width:34px;height:4px;background:#C8CEC8;border-radius:3px;margin:9px auto 4px}
|
||||
.sheet h2{font-family:"IBM Plex Sans Condensed",sans-serif;font-weight:700;font-size:24px;margin:8px 0 2px;text-transform:uppercase}
|
||||
.sheet .sub{color:var(--slate);font-size:13.5px;margin:0 0 14px}
|
||||
.status-line{padding:11px 13px;border-radius:var(--r);margin-bottom:14px;font-size:13.5px}
|
||||
.status-line.out{background:var(--brass-soft);color:#6E5223}
|
||||
.status-line.in{background:var(--verdigris-soft);color:#1D4A40}
|
||||
.status-line.reserve{background:var(--steel-soft);color:#33454D}
|
||||
.status-line.late{background:var(--late-soft);color:#7A2C23}
|
||||
.status-line.lost,.status-line.retired{background:#F0F0EE;color:var(--slate)}
|
||||
.status-line b{font-weight:600}
|
||||
.facts{background:var(--card);border:1px solid var(--line);border-radius:var(--r);margin-bottom:14px}
|
||||
.fact{display:flex;justify-content:space-between;gap:14px;padding:9px 13px;border-bottom:1px solid var(--line);font-size:13.5px}
|
||||
.fact:last-child{border-bottom:0}
|
||||
.fact dt{color:var(--slate);margin:0;flex:none} .fact dd{margin:0;text-align:right;font-weight:500;min-width:0}
|
||||
.mono{font-family:"IBM Plex Mono",monospace;font-size:12.5px}
|
||||
.tiny{display:block;font-weight:400;font-size:11.5px;color:var(--slate-2)}
|
||||
.actions{display:flex;gap:8px;margin-bottom:16px}
|
||||
.btn{flex:1;padding:13px;border-radius:var(--r);font-weight:600;font-size:15px;text-align:center}
|
||||
.btn:active{filter:brightness(.92)} .btn:disabled{opacity:.45}
|
||||
.btn-p{background:var(--verdigris);color:#fff}
|
||||
.btn-o{background:var(--brass);color:#fff}
|
||||
.btn-d{background:var(--late);color:#fff}
|
||||
.btn-g{background:var(--card);color:var(--ink);border:1px solid var(--line)}
|
||||
.note-box{background:var(--card);border:1px solid var(--line);border-radius:var(--r);padding:11px 13px;margin-bottom:14px}
|
||||
.note-box textarea{width:100%;border:0;resize:vertical;min-height:52px;font-size:14px;background:transparent}
|
||||
.note-box textarea:focus{outline:none}
|
||||
.note-box .cap{font-family:"IBM Plex Mono",monospace;font-size:9.5px;letter-spacing:.12em;
|
||||
text-transform:uppercase;color:var(--slate-2);margin-bottom:6px;display:block}
|
||||
.mini{padding:8px 14px;border-radius:8px;font-size:13px;font-weight:600;background:var(--ink);color:#fff}
|
||||
.sigpad{background:#fff;border:1px solid var(--line);border-radius:var(--r);margin-bottom:8px;position:relative;overflow:hidden}
|
||||
.sigpad canvas{display:block;width:100%;height:150px;touch-action:none}
|
||||
.sigpad .hint{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#B7BEB8;font-size:13px;pointer-events:none}
|
||||
.sigline{position:absolute;left:16px;right:16px;bottom:30px;border-bottom:1px dashed #D4D9D3;pointer-events:none}
|
||||
.sigshot{background:#fff;border:1px solid var(--line);border-radius:8px;padding:5px;margin-top:6px}
|
||||
.sigshot img{display:block;width:100%;max-width:220px}
|
||||
.due-opts{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px}
|
||||
.due{padding:8px 12px;border-radius:100px;border:1px solid var(--line);background:var(--card);font-size:13px;color:var(--slate)}
|
||||
.due[aria-pressed="true"]{background:var(--ink);color:#fff;border-color:var(--ink);font-weight:500}
|
||||
.tabs{position:fixed;bottom:0;left:50%;transform:translateX(-50%);width:100%;max-width:560px;
|
||||
background:rgba(255,255,255,.94);backdrop-filter:blur(12px);border-top:1px solid var(--line);
|
||||
display:flex;z-index:30;padding-bottom:env(safe-area-inset-bottom)}
|
||||
.tab{flex:1;padding:9px 2px 11px;text-align:center;color:var(--slate-2);font-size:10px;font-weight:500}
|
||||
.tab .ic{display:flex;align-items:center;justify-content:center;margin-bottom:3px}
|
||||
.tab .ic svg{
|
||||
width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:1.6;
|
||||
stroke-linecap:round;stroke-linejoin:round
|
||||
}
|
||||
.tab[aria-selected="true"]{color:var(--verdigris)}
|
||||
.note{font-size:11.5px;color:var(--slate);text-align:center;padding:14px 20px 4px;line-height:1.5}
|
||||
.toast{position:fixed;left:50%;bottom:78px;transform:translate(-50%,20px);background:var(--ink);color:#fff;
|
||||
padding:10px 18px;border-radius:100px;font-size:13.5px;z-index:60;opacity:0;pointer-events:none;
|
||||
transition:opacity .2s,transform .2s;max-width:90vw;text-align:center}
|
||||
.toast.on{opacity:1;transform:translate(-50%,0)}
|
||||
.toast.err{background:var(--late)}
|
||||
|
||||
/* ---- admin ---- */
|
||||
.admin{max-width:760px;margin:0 auto;padding-bottom:40px}
|
||||
.adm-nav{display:flex;gap:6px;flex-wrap:wrap;padding:12px 14px 0}
|
||||
.adm-nav button{padding:8px 13px;border-radius:100px;border:1px solid var(--line);
|
||||
background:var(--card);font-size:13px;color:var(--slate)}
|
||||
.adm-nav button[aria-pressed="true"]{background:var(--ink);color:#fff;border-color:var(--ink);font-weight:500}
|
||||
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
||||
.lab{display:block;font-family:"IBM Plex Mono",monospace;font-size:9.5px;letter-spacing:.12em;
|
||||
text-transform:uppercase;color:var(--slate-2);margin-bottom:4px}
|
||||
.card{background:var(--card);border:1px solid var(--line);border-radius:var(--r);padding:14px;margin-bottom:12px}
|
||||
.card h3{font-family:"IBM Plex Sans Condensed",sans-serif;text-transform:uppercase;font-size:16px;margin:0 0 10px}
|
||||
table{width:100%;border-collapse:collapse;font-size:13.5px}
|
||||
th{text-align:left;font-family:"IBM Plex Mono",monospace;font-size:9.5px;letter-spacing:.1em;
|
||||
text-transform:uppercase;color:var(--slate-2);padding:6px 8px;border-bottom:1px solid var(--line)}
|
||||
td{padding:7px 8px;border-bottom:1px solid var(--line)}
|
||||
tr:last-child td{border-bottom:0}
|
||||
.warnbox{background:var(--late-soft);border:1px solid #E4BDB6;color:#7A2C23;
|
||||
border-radius:var(--r);padding:12px 14px;margin-bottom:12px;font-size:13.5px}
|
||||
@media (prefers-reduced-motion:reduce){*{transition:none!important}}
|
||||
|
||||
/* ---- login ---------------------------------------------------------------
|
||||
Full-bleed dark, with the peg board itself as the backdrop — the same
|
||||
motif the app opens onto, so the first screen already looks like the thing
|
||||
it protects. */
|
||||
.login{
|
||||
min-height:100vh;min-height:100dvh;display:flex;align-items:center;justify-content:center;
|
||||
padding:24px;background:var(--ink);position:relative;overflow:hidden
|
||||
}
|
||||
#pegfield{position:absolute;inset:0;width:100%;height:100%;opacity:.28;pointer-events:none}
|
||||
.login-card{
|
||||
position:relative;z-index:1;width:100%;max-width:330px;text-align:center
|
||||
}
|
||||
.login-mark{
|
||||
width:54px;height:54px;margin:0 auto 18px;border-radius:50%;
|
||||
border:2px solid var(--verdigris);display:flex;align-items:center;justify-content:center;
|
||||
background:rgba(46,110,96,.14)
|
||||
}
|
||||
.login-mark svg{width:26px;height:26px;stroke:var(--verdigris);fill:none;stroke-width:1.9;
|
||||
stroke-linecap:round;stroke-linejoin:round}
|
||||
.login-card .eyebrow{margin-bottom:5px}
|
||||
.login-card h1{color:#fff;font-size:26px;margin-bottom:8px}
|
||||
.login-sub{color:#8A968F;font-size:13.5px;margin:0 0 22px}
|
||||
.pw-wrap{position:relative;margin-bottom:10px}
|
||||
.pw{
|
||||
width:100%;padding:14px 46px 14px 16px;border-radius:var(--r);
|
||||
background:var(--ink-2);border:1px solid var(--ink-3);color:#fff;font-size:16px;
|
||||
font-family:"IBM Plex Mono",monospace;letter-spacing:.08em;text-align:center;
|
||||
transition:border-color .15s,box-shadow .15s
|
||||
}
|
||||
.pw::placeholder{color:#5E6E68;letter-spacing:0;font-family:"IBM Plex Sans",sans-serif}
|
||||
.pw:focus{outline:none;border-color:var(--verdigris);box-shadow:0 0 0 3px rgba(46,110,96,.25)}
|
||||
.pw-toggle{
|
||||
position:absolute;right:6px;top:50%;transform:translateY(-50%);
|
||||
width:36px;height:36px;border-radius:8px;color:#7A8A83;font-size:15px;
|
||||
display:flex;align-items:center;justify-content:center
|
||||
}
|
||||
.pw-toggle:hover{color:#B9C4BF}
|
||||
.login .btn-p{width:100%;padding:14px}
|
||||
.login-err{
|
||||
color:#E8907F;font-size:13px;min-height:19px;margin:12px 0 0;
|
||||
opacity:0;transition:opacity .15s
|
||||
}
|
||||
.login-err.on{opacity:1}
|
||||
.shake{animation:shake .32s cubic-bezier(.36,.07,.19,.97)}
|
||||
@keyframes shake{
|
||||
10%,90%{transform:translateX(-2px)} 20%,80%{transform:translateX(4px)}
|
||||
30%,50%,70%{transform:translateX(-7px)} 40%,60%{transform:translateX(7px)}
|
||||
}
|
||||
.login-foot{
|
||||
position:absolute;bottom:22px;left:0;right:0;text-align:center;z-index:1;
|
||||
color:#4E5E58;font-size:11px;font-family:"IBM Plex Mono",monospace;letter-spacing:.05em;
|
||||
padding:0 24px
|
||||
}
|
||||
@media (prefers-reduced-motion:reduce){.shake{animation:none}}
|
||||
|
||||
/* ---- reports --------------------------------------------------------------
|
||||
Screen styling stays close to the app; print styling drops to plain black
|
||||
on white, because these end up in a folder or taped inside a cabinet. */
|
||||
.report{max-width:900px;margin:0 auto;padding:0 18px 60px}
|
||||
.rep-bar{
|
||||
display:flex;gap:8px;flex-wrap:wrap;align-items:center;
|
||||
padding:14px 0;position:sticky;top:0;background:var(--paper);z-index:5;
|
||||
border-bottom:1px solid var(--line);margin-bottom:20px
|
||||
}
|
||||
.rep-bar select,.rep-bar .mini{margin:0}
|
||||
.rep-bar select{
|
||||
padding:9px 12px;border:1px solid var(--line);border-radius:8px;
|
||||
background:var(--card);font-size:14px
|
||||
}
|
||||
.rep-head{margin-bottom:22px}
|
||||
.rep-head h1{font-size:26px;margin:0 0 4px}
|
||||
.rep-meta{
|
||||
font-family:"IBM Plex Mono",monospace;font-size:11px;color:var(--slate);
|
||||
letter-spacing:.03em
|
||||
}
|
||||
.rep-stats{display:flex;gap:20px;flex-wrap:wrap;margin:14px 0 0;padding:12px 0;
|
||||
border-top:1px solid var(--line);border-bottom:1px solid var(--line)}
|
||||
.rep-stat b{display:block;font-family:"IBM Plex Sans Condensed",sans-serif;
|
||||
font-size:22px;line-height:1.1}
|
||||
.rep-stat span{font-size:11px;color:var(--slate);text-transform:uppercase;letter-spacing:.08em;
|
||||
font-family:"IBM Plex Mono",monospace}
|
||||
.rep-group{margin-bottom:26px;break-inside:avoid}
|
||||
.rep-group h2{
|
||||
font-family:"IBM Plex Sans Condensed",sans-serif;font-size:17px;text-transform:uppercase;
|
||||
margin:0 0 2px;padding-bottom:5px;border-bottom:2px solid var(--ink)
|
||||
}
|
||||
.rep-group .who{font-size:12.5px;color:var(--slate);margin:0 0 9px}
|
||||
table.rep{width:100%;border-collapse:collapse;font-size:13px}
|
||||
table.rep th{
|
||||
text-align:left;font-family:"IBM Plex Mono",monospace;font-size:9.5px;letter-spacing:.1em;
|
||||
text-transform:uppercase;color:var(--slate);padding:5px 7px;border-bottom:1px solid var(--ink)
|
||||
}
|
||||
table.rep td{padding:6px 7px;border-bottom:1px solid var(--line);vertical-align:top}
|
||||
table.rep tr{break-inside:avoid}
|
||||
table.rep .num{font-family:"IBM Plex Mono",monospace;font-weight:600;white-space:nowrap}
|
||||
table.rep .od{color:var(--late);font-weight:600}
|
||||
.tick{display:inline-block;width:13px;height:13px;border:1.4px solid #556;border-radius:3px}
|
||||
.sig-block{
|
||||
break-inside:avoid;border:1px solid var(--line);border-radius:8px;padding:10px;
|
||||
margin-bottom:10px;background:var(--card)
|
||||
}
|
||||
.sig-block img{display:block;max-width:230px;max-height:80px}
|
||||
.sig-cap{font-family:"IBM Plex Mono",monospace;font-size:10px;color:var(--slate);margin-bottom:6px}
|
||||
.sign-off{margin-top:34px;display:flex;gap:40px;flex-wrap:wrap;break-inside:avoid}
|
||||
.sign-off div{flex:1;min-width:200px}
|
||||
.sign-line{border-bottom:1px solid var(--ink);height:38px;margin-bottom:5px}
|
||||
.sign-lab{font-size:11px;color:var(--slate);font-family:"IBM Plex Mono",monospace}
|
||||
|
||||
@media print{
|
||||
@page{size:A4;margin:14mm 12mm}
|
||||
html,body{background:#fff!important;color:#000!important}
|
||||
.rep-bar,.no-print{display:none!important}
|
||||
.report{max-width:none;padding:0}
|
||||
.rep-head h1{font-size:20px}
|
||||
table.rep{font-size:10.5pt}
|
||||
table.rep thead{display:table-header-group} /* repeat headers across pages */
|
||||
.rep-group{page-break-inside:avoid}
|
||||
a{text-decoration:none;color:#000}
|
||||
.rep-stat b{font-size:16pt}
|
||||
}
|
||||
|
||||
/* ---- access request form -------------------------------------------------
|
||||
The only page an outsider may ever see, so it is calmer and plainer than
|
||||
the rest of the app: no peg board, no counts, nothing about the building. */
|
||||
.req-page{min-height:100vh;background:var(--paper)}
|
||||
.req-top{background:var(--ink);color:#fff;padding:26px 20px 24px}
|
||||
.req-top .inner{max-width:520px;margin:0 auto}
|
||||
.req-top h1{color:#fff;font-size:24px;margin:2px 0 10px}
|
||||
.req-top p{color:#9FADA7;font-size:13.5px;line-height:1.55;margin:0;max-width:46ch}
|
||||
.req-wrap{max-width:520px;margin:0 auto;padding:20px 16px 60px}
|
||||
.req-field{margin-bottom:16px}
|
||||
.req-field .lab{margin-bottom:6px}
|
||||
.req-field input[type=text],.req-field textarea{
|
||||
width:100%;padding:12px 13px;border:1px solid var(--line);border-radius:var(--r);
|
||||
background:var(--card);font-size:16px
|
||||
}
|
||||
.req-field textarea{min-height:86px;resize:vertical}
|
||||
.req-field input:focus,.req-field textarea:focus{
|
||||
outline:none;border-color:var(--verdigris);box-shadow:0 0 0 3px var(--verdigris-soft)
|
||||
}
|
||||
.req-hint{font-size:12px;color:var(--slate);margin:5px 0 0}
|
||||
.areas{display:flex;flex-direction:column;gap:7px}
|
||||
.area{
|
||||
display:flex;align-items:center;gap:11px;padding:12px 13px;background:var(--card);
|
||||
border:1px solid var(--line);border-radius:var(--r);cursor:pointer;font-size:15px
|
||||
}
|
||||
.area:has(input:checked){border-color:var(--verdigris);background:var(--verdigris-soft)}
|
||||
.area input{width:19px;height:19px;accent-color:var(--verdigris);flex:none}
|
||||
.pin-row{display:flex;gap:10px;align-items:flex-start}
|
||||
.pin-row .req-field{flex:1;margin-bottom:0}
|
||||
.req-msg{
|
||||
padding:12px 14px;border-radius:var(--r);font-size:13.5px;margin-bottom:14px;display:none
|
||||
}
|
||||
.req-msg.on{display:block}
|
||||
.req-msg.bad{background:var(--late-soft);color:#7A2C23}
|
||||
.req-done{
|
||||
text-align:center;padding:52px 20px;max-width:400px;margin:0 auto
|
||||
}
|
||||
.req-done .tickmark{
|
||||
width:60px;height:60px;border-radius:50%;background:var(--verdigris-soft);
|
||||
color:var(--verdigris);display:flex;align-items:center;justify-content:center;
|
||||
font-size:30px;margin:0 auto 18px
|
||||
}
|
||||
.req-done h2{font-family:"IBM Plex Sans Condensed",sans-serif;text-transform:uppercase;
|
||||
font-size:22px;margin:0 0 8px}
|
||||
.req-done p{color:var(--slate);font-size:14px;line-height:1.6;margin:0 0 22px}
|
||||
.req-foot{
|
||||
text-align:center;font-size:11.5px;color:var(--slate-2);margin-top:26px;
|
||||
font-family:"IBM Plex Mono",monospace
|
||||
}
|
||||
.hp{position:absolute;left:-9999px;width:1px;height:1px;opacity:0}
|
||||
|
||||
/* request inbox inside the app */
|
||||
.req-card{
|
||||
background:var(--card);border:1px solid var(--line);border-left:3px solid var(--brass);
|
||||
border-radius:var(--r);padding:12px 13px;margin-bottom:8px
|
||||
}
|
||||
.req-card.done{border-left-color:var(--line);opacity:.62}
|
||||
.req-card h4{margin:0 0 2px;font-size:15px;font-weight:600}
|
||||
.req-card .meta{font-family:"IBM Plex Mono",monospace;font-size:10.5px;color:var(--slate-2)}
|
||||
.req-card .areas-line{font-size:13px;color:var(--brass);font-weight:500;margin:6px 0 0}
|
||||
.req-card .detail{font-size:13.5px;color:var(--slate);margin:6px 0 0;line-height:1.5}
|
||||
.badge-count{
|
||||
background:var(--brass);color:#fff;border-radius:100px;padding:1px 7px;
|
||||
font-size:11px;font-weight:600;margin-left:7px;font-family:"IBM Plex Sans",sans-serif
|
||||
}
|
||||
|
||||
/* ---- board fills its column, so the dark panel never stops mid-screen ---- */
|
||||
.scroll{display:flex;flex-direction:column}
|
||||
.board{flex:1 0 auto}
|
||||
.board .note{color:#77857F}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Desktop. Same markup, different shape: the bottom tab bar becomes a side
|
||||
rail and the content gets the width it was already capable of using.
|
||||
--------------------------------------------------------------------------- */
|
||||
@media (min-width:900px){
|
||||
body{background:var(--paper)}
|
||||
|
||||
.app{
|
||||
max-width:1120px;min-height:100vh;
|
||||
display:grid;
|
||||
grid-template-columns:212px minmax(0,1fr);
|
||||
grid-template-rows:auto minmax(0,1fr);
|
||||
border-left:1px solid var(--line);border-right:1px solid var(--line)
|
||||
}
|
||||
header{grid-column:1 / -1;grid-row:1;padding:20px 26px 18px}
|
||||
header h1{font-size:26px}
|
||||
.tools{top:18px;right:18px}
|
||||
.gear{top:20px}
|
||||
|
||||
/* side rail */
|
||||
.tabs{
|
||||
position:static;transform:none;grid-column:1;grid-row:2;
|
||||
max-width:none;width:auto;flex-direction:column;align-items:stretch;
|
||||
background:transparent;backdrop-filter:none;
|
||||
border-top:0;border-right:1px solid var(--line);
|
||||
padding:16px 12px 24px;gap:2px;
|
||||
justify-content:flex-start;align-content:flex-start
|
||||
}
|
||||
.tab{
|
||||
/* the mobile bar sets flex:1 so five tabs share the width; in a vertical
|
||||
rail that makes each item stretch to fill the whole column height */
|
||||
flex:0 0 auto;
|
||||
display:flex;align-items:center;gap:11px;
|
||||
padding:11px 13px;border-radius:8px;text-align:left;font-size:14px;
|
||||
transition:background .12s
|
||||
}
|
||||
.tab:hover{background:#EDEFEA}
|
||||
.tab .ic{margin:0;width:20px;flex:none}
|
||||
.tab[aria-selected="true"]{background:var(--verdigris-soft);color:var(--verdigris);font-weight:600}
|
||||
|
||||
.scroll{grid-column:2;grid-row:2;padding-bottom:0;overflow-y:auto}
|
||||
.board{padding:20px 22px 26px}
|
||||
.pane{padding:20px 22px 40px;max-width:720px}
|
||||
|
||||
/* more pegs per row when there is room for them */
|
||||
.pegs{grid-template-columns:repeat(auto-fill,minmax(52px,1fr));gap:8px}
|
||||
.cabs{grid-template-columns:repeat(auto-fit,minmax(132px,1fr));gap:8px}
|
||||
|
||||
/* the bottom sheet becomes a centred dialog */
|
||||
.sheet{
|
||||
top:50%;bottom:auto;left:50%;
|
||||
transform:translate(-50%,-46%) scale(.97);
|
||||
width:min(560px,92vw);max-height:86vh;border-radius:14px;
|
||||
box-shadow:0 24px 70px rgba(10,18,16,.32);
|
||||
padding:0 22px 24px;opacity:0;transition:opacity .16s,transform .16s
|
||||
}
|
||||
.sheet.on{transform:translate(-50%,-50%) scale(1);opacity:1}
|
||||
.grab{display:none}
|
||||
.sheet h2{margin-top:20px}
|
||||
|
||||
.toast{bottom:26px}
|
||||
|
||||
/* wider forms and tables have room to breathe */
|
||||
.admin{max-width:1000px}
|
||||
.grid2{gap:14px}
|
||||
}
|
||||
|
||||
@media (min-width:900px) and (prefers-reduced-motion:reduce){
|
||||
.sheet{transition:none}
|
||||
}
|
||||
|
||||
/* type swatch in the admin list */
|
||||
.kind-chip{
|
||||
display:inline-flex;align-items:center;gap:8px;padding:5px 10px;border:1px solid var(--line);
|
||||
background:var(--card);font-size:13px;margin:0 6px 6px 0
|
||||
}
|
||||
.kind-dot{width:15px;height:15px;border:1.5px solid;flex:none}
|
||||
874
server.js
Normal file
874
server.js
Normal file
|
|
@ -0,0 +1,874 @@
|
|||
/* ---------------------------------------------------------------------------
|
||||
Church key register — server
|
||||
Node + Express + SQLite. No build step, no ORM, no accounts.
|
||||
Access control is the network (Tailscale), not the app.
|
||||
--------------------------------------------------------------------------- */
|
||||
"use strict";
|
||||
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const express = require("express");
|
||||
const Database = require("better-sqlite3");
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const DATA_DIR = process.env.DATA_DIR || "/data";
|
||||
const DB_PATH = path.join(DATA_DIR, "keyreg.db");
|
||||
const ACCESS_CODE = process.env.ACCESS_CODE || "";
|
||||
const UI_LANG = process.env.UI_LANG || "pt";
|
||||
const REQUEST_PIN = process.env.REQUEST_PIN || "";
|
||||
/* Requests are staff-only unless you deliberately open them up. See the
|
||||
README before setting this — it is the one endpoint people outside your
|
||||
Tailscale can reach. */
|
||||
const PUBLIC_REQUESTS = process.env.PUBLIC_REQUESTS === "1";
|
||||
const crypto = require("crypto");
|
||||
const SESSION_TOKEN = ACCESS_CODE
|
||||
? crypto.createHash("sha256").update("keyreg:" + ACCESS_CODE).digest("hex").slice(0, 32)
|
||||
: "";
|
||||
|
||||
/* Is this request coming from someone already inside the app?
|
||||
Used so that handing your own phone to a visitor does not make you type the
|
||||
shared PIN — the PIN exists to stop strangers on the internet, and you are
|
||||
standing right there. When no passcode is configured, everyone who can reach
|
||||
the app is behind Tailscale, so they count as staff unless the form has been
|
||||
deliberately published. */
|
||||
function isStaff(req) {
|
||||
if (!ACCESS_CODE) return !PUBLIC_REQUESTS;
|
||||
const c = (req.headers.cookie || "").match(/keyreg=([^;]+)/);
|
||||
return !!(c && c[1] === SESSION_TOKEN);
|
||||
}
|
||||
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Schema
|
||||
-------------------------------------------------------------------------- */
|
||||
const db = new Database(DB_PATH);
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.pragma("foreign_keys = ON");
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS cabinets (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
full_name TEXT NOT NULL,
|
||||
note TEXT DEFAULT '',
|
||||
off_site INTEGER DEFAULT 0,
|
||||
sort INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS doors (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
num TEXT NOT NULL UNIQUE,
|
||||
building TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
lock_group TEXT DEFAULT '',
|
||||
notes TEXT DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS people (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
dept TEXT DEFAULT '',
|
||||
role TEXT DEFAULT '',
|
||||
phone TEXT DEFAULT '',
|
||||
email TEXT DEFAULT '',
|
||||
active INTEGER DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tag TEXT NOT NULL UNIQUE,
|
||||
kind TEXT NOT NULL DEFAULT 'key', -- key | nfc
|
||||
door_id INTEGER REFERENCES doors(id),
|
||||
zone TEXT DEFAULT '', -- alarm tags only
|
||||
building TEXT DEFAULT '',
|
||||
cabinet_id TEXT REFERENCES cabinets(id),
|
||||
hook TEXT DEFAULT '',
|
||||
is_master INTEGER DEFAULT 0,
|
||||
uid TEXT DEFAULT '', -- NFC chip serial
|
||||
status TEXT NOT NULL DEFAULT 'in', -- in | out | reserve | lost | retired
|
||||
notes TEXT DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS handovers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_id INTEGER NOT NULL REFERENCES items(id),
|
||||
person_id INTEGER NOT NULL REFERENCES people(id),
|
||||
out_date TEXT NOT NULL,
|
||||
in_date TEXT,
|
||||
due_date TEXT,
|
||||
note TEXT DEFAULT '',
|
||||
issued_by TEXT DEFAULT '',
|
||||
signature TEXT -- data URL, captured on the phone
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS journal (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
author TEXT DEFAULT ''
|
||||
);
|
||||
|
||||
-- Every admin change gets a line. With two of you editing, "who moved 12-B
|
||||
-- and when" is a question that will come up.
|
||||
CREATE TABLE IF NOT EXISTS audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
detail TEXT DEFAULT ''
|
||||
);
|
||||
|
||||
-- Item types. Started as two hardcoded values (key / nfc); a table instead so
|
||||
-- you can add padlocks, remotes, fobs or anything else without a code change.
|
||||
-- shape and colour are what the board draws: shape says what a thing is,
|
||||
-- fill says where it is.
|
||||
CREATE TABLE IF NOT EXISTS kinds (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
shape TEXT NOT NULL DEFAULT 'circle', -- circle | rounded | square
|
||||
colour TEXT NOT NULL DEFAULT '#2E6E60',
|
||||
sort INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
-- Access requests. Deliberately separate from people and handovers: a request
|
||||
-- is something someone asked for, not a decision you made. Nothing here grants
|
||||
-- anything.
|
||||
CREATE TABLE IF NOT EXISTS requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
dept TEXT DEFAULT '',
|
||||
contact TEXT DEFAULT '',
|
||||
areas TEXT DEFAULT '',
|
||||
detail TEXT DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending | handled
|
||||
handled TEXT,
|
||||
note TEXT DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_handover_item ON handovers(item_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_handover_open ON handovers(item_id) WHERE in_date IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_items_door ON items(door_id);
|
||||
`);
|
||||
|
||||
/* Seed the two original types on an existing database, so upgrading needs no
|
||||
migration step. */
|
||||
if (db.prepare("SELECT COUNT(*) c FROM kinds").get().c === 0) {
|
||||
const ins = db.prepare("INSERT INTO kinds (id,name,shape,colour,sort) VALUES (?,?,?,?,?)");
|
||||
const pt = (process.env.UI_LANG || "pt") === "pt";
|
||||
ins.run("key", pt ? "Chave" : "Key", "circle", "#2E6E60", 1);
|
||||
ins.run("nfc", pt ? "Etiqueta de alarme" : "Alarm tag", "rounded", "#5F55A0", 2);
|
||||
}
|
||||
|
||||
const today = () => new Date().toISOString().slice(0, 10);
|
||||
const now = () => new Date().toISOString();
|
||||
const audit = (action, detail = "") =>
|
||||
db.prepare("INSERT INTO audit (ts,action,detail) VALUES (?,?,?)").run(now(), action, detail);
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Demo data — only if the database is empty and --seed-demo is passed
|
||||
-------------------------------------------------------------------------- */
|
||||
function seedDemo() {
|
||||
if (db.prepare("SELECT COUNT(*) c FROM items").get().c > 0) {
|
||||
console.log("Database already has items — not seeding.");
|
||||
return;
|
||||
}
|
||||
const cabs = [
|
||||
["SAC", "Sacristia", "Armário da sacristia", "Sacristia, atrás da porta.", 0, 1],
|
||||
["SEC", "Secretaria", "Armário da secretaria", "Secretaria, armário de parede junto ao cofre.", 0, 2],
|
||||
["SAL", "Salão", "Armário do salão", "Corredor do salão, ao lado da porta da cozinha.", 0, 3],
|
||||
["CASA", "Reserva", "Conjunto de reserva — fora", "Caixa selada, fora das instalações. Só para emergências.", 1, 4],
|
||||
];
|
||||
const insCab = db.prepare("INSERT INTO cabinets (id,name,full_name,note,off_site,sort) VALUES (?,?,?,?,?,?)");
|
||||
cabs.forEach((c) => insCab.run(...c));
|
||||
|
||||
const doors = [
|
||||
["Igreja", "Entrada principal, poente", "A", 3], ["Igreja", "Sacristia", "B", 3],
|
||||
["Igreja", "Torre sineira", "C", 2], ["Igreja", "Sala do coro", "D", 2],
|
||||
["Casa da igreja", "Porta da rua", "E", 4], ["Casa da igreja", "Secretaria", "E", 2],
|
||||
["Casa da igreja", "Casa das caldeiras", "H", 2], ["Salão", "Porta principal", "J", 3],
|
||||
["Salão", "Cozinha", "K", 2], ["Exterior", "Portão do cemitério", "L", 2],
|
||||
["Armários", "Armário da sacristia", "N", 2], ["Armários", "Armário da secretaria", "N", 2],
|
||||
];
|
||||
const home = { "Igreja": "SAC", "Casa da igreja": "SEC", "Salão": "SAL",
|
||||
"Exterior": "SAL", "Armários": "SEC" };
|
||||
const insDoor = db.prepare("INSERT INTO doors (num,building,name,lock_group) VALUES (?,?,?,?)");
|
||||
const insItem = db.prepare(
|
||||
`INSERT INTO items (tag,kind,door_id,building,cabinet_id,hook,is_master,status,notes)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)`
|
||||
);
|
||||
const counters = {};
|
||||
doors.forEach((d, i) => {
|
||||
const [building, name, group, copies] = d;
|
||||
const num = String(i + 1).padStart(2, "0");
|
||||
const doorId = insDoor.run(num, building, name, group).lastInsertRowid;
|
||||
for (let c = 0; c < copies; c++) {
|
||||
const cabinet = home[building];
|
||||
counters[cabinet] = (counters[cabinet] || 0) + 1;
|
||||
insItem.run(num + "-" + "ABCDE"[c], "key", doorId, building, cabinet,
|
||||
`${cabinet}·${String(counters[cabinet]).padStart(2, "0")}`,
|
||||
group === "A" && c === 0 ? 1 : 0, "in", "");
|
||||
}
|
||||
counters.CASA = (counters.CASA || 0) + 1;
|
||||
insItem.run(num + "-R", "key", doorId, building, "CASA",
|
||||
`CASA·${String(counters.CASA).padStart(2, "0")}`, 0, "reserve", "");
|
||||
});
|
||||
|
||||
const insNfc = db.prepare(
|
||||
`INSERT INTO items (tag,kind,zone,building,cabinet_id,hook,uid,status) VALUES (?,?,?,?,?,?,?,?)`
|
||||
);
|
||||
[["Igreja", "Nave"], ["Casa da igreja", "Piso térreo"], ["Salão", "Salão e cozinha"]]
|
||||
.forEach(([b, z], zi) => {
|
||||
for (let c = 0; c < 4; c++) {
|
||||
const nn = String(zi * 4 + c + 1).padStart(2, "0");
|
||||
insNfc.run("N-" + nn, "nfc", z, b, "SEC", "SEC·N" + nn,
|
||||
"04:" + (0x1a + zi * 4 + c).toString(16).toUpperCase() + ":9C", "in");
|
||||
}
|
||||
});
|
||||
|
||||
const insPerson = db.prepare("INSERT INTO people (name,dept,role) VALUES (?,?,?)");
|
||||
[["Maria Fonseca", "Secretaria", "Secretária"],
|
||||
["João Ribeiro", "Manutenção", "Sacristão"],
|
||||
["Pe. Anselmo", "Clero", "Padre"],
|
||||
["Teresa Lopes", "Coro", "Diretora do coro"],
|
||||
["Sofia Braga", "Reservas do salão", "Voluntária"],
|
||||
["Paulo Esteves", "Manutenção", "Técnico de aquecimento"]].forEach((p) => insPerson.run(...p));
|
||||
|
||||
const give = db.prepare(
|
||||
"INSERT INTO handovers (item_id,person_id,out_date,in_date,due_date,note,issued_by) VALUES (?,?,?,?,?,?,?)"
|
||||
);
|
||||
const setOut = db.prepare("UPDATE items SET status='out' WHERE id=?");
|
||||
const byTag = db.prepare("SELECT id FROM items WHERE tag=?");
|
||||
const loan = (tag, pid, out, back, due, note) => {
|
||||
const it = byTag.get(tag);
|
||||
if (!it) return;
|
||||
give.run(it.id, pid, out, back, due, note || "", "Instalação");
|
||||
if (!back) setOut.run(it.id);
|
||||
};
|
||||
loan("02-A", 3, "2019-04-02", null, null, "");
|
||||
loan("05-A", 1, "2021-09-06", null, null, "");
|
||||
loan("06-A", 1, "2021-09-06", null, null, "");
|
||||
loan("01-B", 2, "2018-01-15", null, null, "");
|
||||
loan("07-A", 2, "2018-01-15", null, null, "");
|
||||
loan("04-A", 4, "2022-02-14", null, null, "");
|
||||
loan("08-B", 5, "2026-06-01", null, "2026-08-20", "Concertos de verão");
|
||||
loan("07-B", 6, "2026-01-12", null, "2026-02-28", "Revisão da caldeira — temporário");
|
||||
loan("09-B", 5, "2021-05-02", "2023-08-30", null, "");
|
||||
|
||||
db.prepare("INSERT INTO journal (date,text,author) VALUES (?,?,?)")
|
||||
.run(today(), "Registo criado com dados de exemplo. Apague-os em /admin antes de usar a sério.", "Instalação");
|
||||
audit("seed", "demo data");
|
||||
console.log("Demo data loaded.");
|
||||
}
|
||||
|
||||
if (process.argv.includes("--seed-demo")) {
|
||||
seedDemo();
|
||||
if (process.argv.includes("--exit")) process.exit(0);
|
||||
}
|
||||
if (process.env.SEED_DEMO === "1") seedDemo();
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
App
|
||||
-------------------------------------------------------------------------- */
|
||||
const app = express();
|
||||
app.use(express.json({ limit: "4mb" })); // signatures are data URLs
|
||||
app.disable("x-powered-by");
|
||||
|
||||
/* Behind a reverse proxy (Nginx Proxy Manager, Cloudflare, Traefik), every
|
||||
request arrives from the proxy's address. Without this, req.ip is the proxy
|
||||
for everyone, so the login and PIN rate limits share a single bucket — five
|
||||
wrong attempts from one attacker would lock out the whole church.
|
||||
Set TRUST_PROXY=1 for one proxy in front, 2 for two (e.g. Cloudflare → NPM).
|
||||
Leave it unset when the app is reached directly, otherwise a client can
|
||||
forge X-Forwarded-For and dodge the limits entirely. */
|
||||
if (process.env.TRUST_PROXY) {
|
||||
const hops = Number(process.env.TRUST_PROXY);
|
||||
app.set("trust proxy", Number.isFinite(hops) ? hops : process.env.TRUST_PROXY);
|
||||
}
|
||||
|
||||
/* Optional shared passcode. Defence in depth only — the real boundary is
|
||||
Tailscale. Leave ACCESS_CODE empty and there is no gate at all. */
|
||||
if (ACCESS_CODE) {
|
||||
/* The cookie holds a hash, not the passcode itself, so a stolen cookie
|
||||
does not hand over the code you also use elsewhere. */
|
||||
const TOKEN = SESSION_TOKEN;
|
||||
/* The login page needs its stylesheet and translations before the user has
|
||||
a cookie, so these three paths sit outside the gate. */
|
||||
const OPEN = new Set(["/api/login", "/styles.css", "/i18n.js"]);
|
||||
if (PUBLIC_REQUESTS) ["/pedido", "/api/request", "/api/request/areas"].forEach((p) => OPEN.add(p));
|
||||
const attempts = new Map(); // ip -> { n, until }
|
||||
|
||||
const loginPage = () =>
|
||||
fs.readFileSync(path.join(__dirname, "public", "login.html"), "utf8")
|
||||
.replace("__LANG__", UI_LANG);
|
||||
|
||||
app.use((req, res, next) => {
|
||||
if (OPEN.has(req.path)) return next();
|
||||
const cookie = (req.headers.cookie || "").match(/keyreg=([^;]+)/);
|
||||
if (cookie && cookie[1] === TOKEN) return next();
|
||||
if (req.path.startsWith("/api/")) return res.status(401).json({ error: "locked" });
|
||||
res.type("html").send(loginPage());
|
||||
});
|
||||
|
||||
app.post("/api/login", (req, res) => {
|
||||
const ip = req.ip || "?";
|
||||
const rec = attempts.get(ip) || { n: 0, until: 0 };
|
||||
if (Date.now() < rec.until) return res.status(429).json({ error: "too many attempts" });
|
||||
|
||||
if (req.body.code !== ACCESS_CODE) {
|
||||
rec.n++;
|
||||
/* Back off after repeated failures. A shared passcode is short, so
|
||||
without this it is worth guessing. */
|
||||
if (rec.n >= 5) { rec.until = Date.now() + 60000; rec.n = 0; }
|
||||
attempts.set(ip, rec);
|
||||
audit("login failed", ip);
|
||||
return res.status(401).json({ error: "wrong code" });
|
||||
}
|
||||
attempts.delete(ip);
|
||||
res.setHeader("Set-Cookie",
|
||||
`keyreg=${TOKEN}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000`);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
}
|
||||
|
||||
const bad = (res, msg) => res.status(400).json({ error: msg });
|
||||
|
||||
/* ---- read: the whole register in one call. At this size it's simpler and
|
||||
faster than a dozen endpoints, and the UI never shows a half-state. ---- */
|
||||
app.get("/api/state", (req, res) => {
|
||||
const items = db.prepare("SELECT * FROM items ORDER BY tag").all();
|
||||
const open = db.prepare(`SELECT id,item_id,person_id,out_date,in_date,due_date,note,issued_by,
|
||||
(signature IS NOT NULL) AS has_sig
|
||||
FROM handovers WHERE in_date IS NULL`).all();
|
||||
const openBy = new Map(open.map((h) => [h.item_id, h]));
|
||||
res.json({
|
||||
today: today(),
|
||||
lang: UI_LANG,
|
||||
kinds: db.prepare("SELECT * FROM kinds ORDER BY sort, name").all(),
|
||||
cabinets: db.prepare("SELECT * FROM cabinets ORDER BY sort, name").all(),
|
||||
doors: db.prepare("SELECT * FROM doors ORDER BY CAST(num AS INTEGER), num").all(),
|
||||
people: db.prepare("SELECT * FROM people ORDER BY name").all(),
|
||||
items: items.map((i) => ({ ...i, loan: openBy.get(i.id) || null })),
|
||||
handovers: db.prepare(`SELECT id,item_id,person_id,out_date,in_date,due_date,note,issued_by,
|
||||
(signature IS NOT NULL) AS has_sig
|
||||
FROM handovers ORDER BY out_date DESC, id DESC LIMIT 400`).all(),
|
||||
journal: db.prepare("SELECT * FROM journal ORDER BY date DESC, id DESC LIMIT 200").all(),
|
||||
pending: db.prepare("SELECT COUNT(*) c FROM requests WHERE status='pending'").get().c,
|
||||
/* So you can see from inside the app whether the request form is actually
|
||||
protected, instead of having to remember what is in .env. */
|
||||
reqForm: { public: PUBLIC_REQUESTS, pin: !!REQUEST_PIN },
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/api/items/:id/history", (req, res) => {
|
||||
res.json(db.prepare("SELECT * FROM handovers WHERE item_id=? ORDER BY out_date DESC, id DESC")
|
||||
.all(req.params.id));
|
||||
});
|
||||
|
||||
/* ---- issue ---- */
|
||||
app.post("/api/items/:id/issue", (req, res) => {
|
||||
const item = db.prepare("SELECT * FROM items WHERE id=?").get(req.params.id);
|
||||
if (!item) return bad(res, "no such item");
|
||||
if (item.status === "out") return bad(res, "already issued");
|
||||
if (item.status === "lost" || item.status === "retired") return bad(res, "not issuable");
|
||||
const { person_id, due_date, note, signature, issued_by } = req.body;
|
||||
if (!db.prepare("SELECT 1 FROM people WHERE id=?").get(person_id)) return bad(res, "no such person");
|
||||
|
||||
const wasReserve = item.status === "reserve";
|
||||
db.transaction(() => {
|
||||
db.prepare(`INSERT INTO handovers (item_id,person_id,out_date,due_date,note,issued_by,signature)
|
||||
VALUES (?,?,?,?,?,?,?)`)
|
||||
.run(item.id, person_id, today(), due_date || null, note || "", issued_by || "", signature || null);
|
||||
db.prepare("UPDATE items SET status='out' WHERE id=?").run(item.id);
|
||||
if (wasReserve) {
|
||||
const p = db.prepare("SELECT name FROM people WHERE id=?").get(person_id);
|
||||
const msg = UI_LANG === "pt"
|
||||
? `Selo de reserva quebrado: ${item.tag} entregue a ${p.name}.`
|
||||
: `Reserve seal broken: ${item.tag} issued to ${p.name}.`;
|
||||
db.prepare("INSERT INTO journal (date,text,author) VALUES (?,?,?)")
|
||||
.run(today(), msg, UI_LANG === "pt" ? "Automático" : "Automatic");
|
||||
}
|
||||
})();
|
||||
audit("issue", `${item.tag} → person ${person_id}`);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
/* ---- return ---- */
|
||||
app.post("/api/items/:id/return", (req, res) => {
|
||||
const item = db.prepare("SELECT * FROM items WHERE id=?").get(req.params.id);
|
||||
if (!item) return bad(res, "no such item");
|
||||
const open = db.prepare("SELECT * FROM handovers WHERE item_id=? AND in_date IS NULL").get(item.id);
|
||||
if (!open) return bad(res, "not currently issued");
|
||||
const cabinet = db.prepare("SELECT off_site FROM cabinets WHERE id=?").get(item.cabinet_id);
|
||||
db.transaction(() => {
|
||||
db.prepare("UPDATE handovers SET in_date=?, note=COALESCE(NULLIF(?,''),note) WHERE id=?")
|
||||
.run(today(), req.body.note || "", open.id);
|
||||
/* a key goes back to whatever its home cabinet implies */
|
||||
db.prepare("UPDATE items SET status=? WHERE id=?")
|
||||
.run(cabinet && cabinet.off_site ? "reserve" : "in", item.id);
|
||||
})();
|
||||
audit("return", item.tag);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
/* ---- notes and journal ---- */
|
||||
app.patch("/api/items/:id/notes", (req, res) => {
|
||||
db.prepare("UPDATE items SET notes=? WHERE id=?").run(req.body.notes || "", req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
app.post("/api/journal", (req, res) => {
|
||||
if (!req.body.text || !req.body.text.trim()) return bad(res, "empty");
|
||||
db.prepare("INSERT INTO journal (date,text,author) VALUES (?,?,?)")
|
||||
.run(req.body.date || today(), req.body.text.trim(), req.body.author || "");
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Access requests
|
||||
-------------------------------------------------------------------------- */
|
||||
const reqHits = new Map(); // ip -> { n, until }
|
||||
|
||||
/* Areas only — never the door list. If this form is publicly reachable, a
|
||||
list of every door is a map of the building for anyone who finds the page. */
|
||||
app.get("/api/request/areas", (req, res) => {
|
||||
const rows = db.prepare("SELECT DISTINCT building FROM doors WHERE building<>'' ORDER BY building").all();
|
||||
res.json({ areas: rows.map((r) => r.building), pin: !!REQUEST_PIN && !isStaff(req) });
|
||||
});
|
||||
|
||||
app.post("/api/request", (req, res) => {
|
||||
const ip = req.ip || "?";
|
||||
const hit = reqHits.get(ip) || { n: 0, until: 0 };
|
||||
if (Date.now() < hit.until) return res.status(429).json({ error: "too many requests" });
|
||||
|
||||
/* Hidden field a person never sees. Bots fill every input they find. */
|
||||
if (req.body.website) return res.json({ ok: true });
|
||||
|
||||
if (REQUEST_PIN && !isStaff(req) && String(req.body.pin || "").trim() !== REQUEST_PIN) {
|
||||
hit.n++;
|
||||
if (hit.n >= 5) { hit.until = Date.now() + 300000; hit.n = 0; }
|
||||
reqHits.set(ip, hit);
|
||||
return res.status(401).json({ error: "wrong pin" });
|
||||
}
|
||||
|
||||
const clip = (v, n) => String(v || "").trim().slice(0, n);
|
||||
const name = clip(req.body.name, 80);
|
||||
if (!name) return bad(res, "name required");
|
||||
const areas = Array.isArray(req.body.areas)
|
||||
? req.body.areas.map((a) => clip(a, 60)).filter(Boolean).slice(0, 12).join(", ")
|
||||
: clip(req.body.areas, 200);
|
||||
const dept = clip(req.body.dept, 80);
|
||||
const contact = clip(req.body.contact, 80);
|
||||
const detail = clip(req.body.detail, 600);
|
||||
|
||||
hit.n = (hit.n || 0) + 1;
|
||||
if (hit.n >= 8) hit.until = Date.now() + 300000;
|
||||
reqHits.set(ip, hit);
|
||||
|
||||
db.transaction(() => {
|
||||
db.prepare(`INSERT INTO requests (created,name,dept,contact,areas,detail)
|
||||
VALUES (?,?,?,?,?,?)`).run(now(), name, dept, contact, areas, detail);
|
||||
const label = UI_LANG === "pt" ? "Pedido de acesso" : "Access request";
|
||||
const via = isStaff(req)
|
||||
? (UI_LANG === "pt" ? "Formulário (no local)" : "Form (in person)")
|
||||
: (UI_LANG === "pt" ? "Formulário (online)" : "Form (online)");
|
||||
db.prepare("INSERT INTO journal (date,text,author) VALUES (?,?,?)").run(
|
||||
today(),
|
||||
`${label}: ${name}${dept ? " (" + dept + ")" : ""}` +
|
||||
`${areas ? " — " + areas : ""}${detail ? ". " + detail : ""}`,
|
||||
via);
|
||||
})();
|
||||
audit("request", name);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get("/api/requests", (req, res) => {
|
||||
res.json(db.prepare("SELECT * FROM requests ORDER BY status='handled', id DESC LIMIT 100").all());
|
||||
});
|
||||
|
||||
app.post("/api/requests/:id/handle", (req, res) => {
|
||||
const r = db.prepare("SELECT * FROM requests WHERE id=?").get(req.params.id);
|
||||
if (!r) return bad(res, "no such request");
|
||||
db.prepare("UPDATE requests SET status='handled', handled=?, note=? WHERE id=?")
|
||||
.run(today(), String(req.body.note || "").slice(0, 300), r.id);
|
||||
audit("request handled", r.name);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Admin
|
||||
-------------------------------------------------------------------------- */
|
||||
const A = "/api/admin";
|
||||
|
||||
app.post(A + "/cabinets", (req, res) => {
|
||||
const { id, name, full_name, note, off_site, sort } = req.body;
|
||||
if (!id || !name) return bad(res, "id and name required");
|
||||
db.prepare(`INSERT INTO cabinets (id,name,full_name,note,off_site,sort) VALUES (?,?,?,?,?,?)
|
||||
ON CONFLICT(id) DO UPDATE SET name=excluded.name, full_name=excluded.full_name,
|
||||
note=excluded.note, off_site=excluded.off_site, sort=excluded.sort`)
|
||||
.run(id, name, full_name || name, note || "", off_site ? 1 : 0, sort || 0);
|
||||
audit("cabinet", id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
app.delete(A + "/cabinets/:id", (req, res) => {
|
||||
const n = db.prepare("SELECT COUNT(*) c FROM items WHERE cabinet_id=?").get(req.params.id).c;
|
||||
if (n) return bad(res, `${n} keys still live in this cabinet — move them first`);
|
||||
db.prepare("DELETE FROM cabinets WHERE id=?").run(req.params.id);
|
||||
audit("cabinet delete", req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post(A + "/kinds", (req, res) => {
|
||||
const { id, name, shape, colour, sort } = req.body;
|
||||
if (!id || !name) return bad(res, "id and name required");
|
||||
const code = String(id).trim().toLowerCase().replace(/[^a-z0-9_-]/g, "");
|
||||
if (!code) return bad(res, "id must contain letters or numbers");
|
||||
db.prepare(`INSERT INTO kinds (id,name,shape,colour,sort) VALUES (?,?,?,?,?)
|
||||
ON CONFLICT(id) DO UPDATE SET name=excluded.name, shape=excluded.shape,
|
||||
colour=excluded.colour, sort=excluded.sort`)
|
||||
.run(code, String(name).trim(),
|
||||
["circle", "rounded", "square"].includes(shape) ? shape : "circle",
|
||||
/^#[0-9a-fA-F]{6}$/.test(colour || "") ? colour : "#2E6E60", +sort || 0);
|
||||
audit("kind", code);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
app.delete(A + "/kinds/:id", (req, res) => {
|
||||
const n = db.prepare("SELECT COUNT(*) c FROM items WHERE kind=?").get(req.params.id).c;
|
||||
if (n) return bad(res, `${n} items still use this type — change them first`);
|
||||
if (db.prepare("SELECT COUNT(*) c FROM kinds").get().c <= 1) return bad(res, "at least one type is required");
|
||||
db.prepare("DELETE FROM kinds WHERE id=?").run(req.params.id);
|
||||
audit("kind delete", req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post(A + "/doors", (req, res) => {
|
||||
const { id, num, building, name, lock_group, notes } = req.body;
|
||||
if (!num || !building || !name) return bad(res, "num, building and name required");
|
||||
try {
|
||||
if (id) {
|
||||
db.prepare("UPDATE doors SET num=?,building=?,name=?,lock_group=?,notes=? WHERE id=?")
|
||||
.run(num, building, name, lock_group || "", notes || "", id);
|
||||
db.prepare("UPDATE items SET building=? WHERE door_id=?").run(building, id);
|
||||
} else {
|
||||
db.prepare("INSERT INTO doors (num,building,name,lock_group,notes) VALUES (?,?,?,?,?)")
|
||||
.run(num, building, name, lock_group || "", notes || "");
|
||||
}
|
||||
} catch (e) { return bad(res, "door number already used"); }
|
||||
audit("door", num + " " + name);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
app.delete(A + "/doors/:id", (req, res) => {
|
||||
const n = db.prepare("SELECT COUNT(*) c FROM items WHERE door_id=?").get(req.params.id).c;
|
||||
if (n) return bad(res, `${n} keys belong to this door — delete or move them first`);
|
||||
db.prepare("DELETE FROM doors WHERE id=?").run(req.params.id);
|
||||
audit("door delete", req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post(A + "/people", (req, res) => {
|
||||
const { id, name, dept, role, phone, email, active } = req.body;
|
||||
if (!name) return bad(res, "name required");
|
||||
if (id) {
|
||||
db.prepare("UPDATE people SET name=?,dept=?,role=?,phone=?,email=?,active=? WHERE id=?")
|
||||
.run(name, dept || "", role || "", phone || "", email || "", active ? 1 : 0, id);
|
||||
} else {
|
||||
db.prepare("INSERT INTO people (name,dept,role,phone,email) VALUES (?,?,?,?,?)")
|
||||
.run(name, dept || "", role || "", phone || "", email || "");
|
||||
}
|
||||
audit("person", name);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
/* People are never deleted — they appear in old handovers. Deactivate instead. */
|
||||
app.delete(A + "/people/:id", (req, res) => {
|
||||
db.prepare("UPDATE people SET active=0 WHERE id=?").run(req.params.id);
|
||||
audit("person deactivate", req.params.id);
|
||||
res.json({ ok: true, note: "deactivated, not deleted — they appear in past handovers" });
|
||||
});
|
||||
|
||||
app.post(A + "/items", (req, res) => {
|
||||
const { id, tag, kind, door_id, zone, cabinet_id, hook, is_master, uid, status, notes } = req.body;
|
||||
if (!tag) return bad(res, "tag required");
|
||||
const door = door_id ? db.prepare("SELECT * FROM doors WHERE id=?").get(door_id) : null;
|
||||
const building = door ? door.building : req.body.building || "";
|
||||
const theKind = db.prepare("SELECT id FROM kinds WHERE id=?").get(kind || "key")
|
||||
? (kind || "key")
|
||||
: db.prepare("SELECT id FROM kinds ORDER BY sort LIMIT 1").get().id;
|
||||
try {
|
||||
if (id) {
|
||||
db.prepare(`UPDATE items SET tag=?,kind=?,door_id=?,zone=?,building=?,cabinet_id=?,hook=?,
|
||||
is_master=?,uid=?,status=?,notes=? WHERE id=?`)
|
||||
.run(tag, theKind, door_id || null, zone || "", building, cabinet_id || null,
|
||||
hook || "", is_master ? 1 : 0, uid || "", status || "in", notes || "", id);
|
||||
} else {
|
||||
db.prepare(`INSERT INTO items (tag,kind,door_id,zone,building,cabinet_id,hook,is_master,uid,status,notes)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)`)
|
||||
.run(tag, theKind, door_id || null, zone || "", building, cabinet_id || null,
|
||||
hook || "", is_master ? 1 : 0, uid || "", status || "in", notes || "");
|
||||
}
|
||||
} catch (e) { return bad(res, "that tag is already used"); }
|
||||
audit("item", tag);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
/* Items are retired, not deleted — their handover history must survive. */
|
||||
app.delete(A + "/items/:id", (req, res) => {
|
||||
const n = db.prepare("SELECT COUNT(*) c FROM handovers WHERE item_id=?").get(req.params.id).c;
|
||||
if (n) {
|
||||
db.prepare("UPDATE items SET status='retired' WHERE id=?").run(req.params.id);
|
||||
audit("item retire", req.params.id);
|
||||
return res.json({ ok: true, note: "retired — it has handover history that must be kept" });
|
||||
}
|
||||
db.prepare("DELETE FROM items WHERE id=?").run(req.params.id);
|
||||
audit("item delete", req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
/* ---- CSV import: the thing that decides whether this project ever starts ---- */
|
||||
function parseCSV(text) {
|
||||
const rows = [];
|
||||
let row = [], cell = "", q = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i];
|
||||
if (q) {
|
||||
if (c === '"' && text[i + 1] === '"') { cell += '"'; i++; }
|
||||
else if (c === '"') q = false;
|
||||
else cell += c;
|
||||
} else if (c === '"') q = true;
|
||||
else if (c === "," || c === ";" || c === "\t") { row.push(cell); cell = ""; }
|
||||
else if (c === "\n") { row.push(cell); rows.push(row); row = []; cell = ""; }
|
||||
else if (c !== "\r") cell += c;
|
||||
}
|
||||
if (cell || row.length) { row.push(cell); rows.push(row); }
|
||||
return rows.filter((r) => r.some((x) => x.trim() !== ""));
|
||||
}
|
||||
|
||||
app.post(A + "/import", (req, res) => {
|
||||
const rows = parseCSV(req.body.csv || "");
|
||||
if (rows.length < 2) return bad(res, "needs a header row and at least one key");
|
||||
const head = rows[0].map((h) => h.trim().toLowerCase());
|
||||
const col = (n) => head.indexOf(n);
|
||||
const need = ["tag", "door", "building"];
|
||||
const missing = need.filter((n) => col(n) < 0);
|
||||
if (missing.length) return bad(res, "missing columns: " + missing.join(", "));
|
||||
|
||||
const findDoor = db.prepare("SELECT id FROM doors WHERE name=? AND building=?");
|
||||
const nextNum = () => {
|
||||
const m = db.prepare("SELECT MAX(CAST(num AS INTEGER)) m FROM doors").get().m || 0;
|
||||
return String(m + 1).padStart(2, "0");
|
||||
};
|
||||
const out = { doors: 0, items: 0, cabinets: 0, skipped: [] };
|
||||
|
||||
db.transaction(() => {
|
||||
for (const r of rows.slice(1)) {
|
||||
const tag = (r[col("tag")] || "").trim();
|
||||
const doorName = (r[col("door")] || "").trim();
|
||||
const building = (r[col("building")] || "").trim();
|
||||
if (!tag || !doorName || !building) { out.skipped.push(tag || "(no tag)"); continue; }
|
||||
if (db.prepare("SELECT 1 FROM items WHERE tag=?").get(tag)) {
|
||||
out.skipped.push(tag + " (already exists)"); continue;
|
||||
}
|
||||
const group = col("lock_group") >= 0 ? (r[col("lock_group")] || "").trim() : "";
|
||||
let door = findDoor.get(doorName, building);
|
||||
if (!door) {
|
||||
const id = db.prepare("INSERT INTO doors (num,building,name,lock_group) VALUES (?,?,?,?)")
|
||||
.run(nextNum(), building, doorName, group).lastInsertRowid;
|
||||
door = { id }; out.doors++;
|
||||
}
|
||||
let cabId = col("cabinet") >= 0 ? (r[col("cabinet")] || "").trim() : "";
|
||||
if (cabId && !db.prepare("SELECT 1 FROM cabinets WHERE id=?").get(cabId)) {
|
||||
db.prepare("INSERT INTO cabinets (id,name,full_name) VALUES (?,?,?)").run(cabId, cabId, cabId);
|
||||
out.cabinets++;
|
||||
}
|
||||
/* An unknown type is refused rather than created. A malformed row or a
|
||||
typo would otherwise litter the type list with junk, and types are now
|
||||
two clicks away in Admin. */
|
||||
let kindId = col("kind") >= 0 ? (r[col("kind")] || "").trim().toLowerCase() : "";
|
||||
if (!kindId) kindId = db.prepare("SELECT id FROM kinds ORDER BY sort LIMIT 1").get().id;
|
||||
if (!db.prepare("SELECT 1 FROM kinds WHERE id=?").get(kindId)) {
|
||||
out.skipped.push(`${tag} (unknown type: ${kindId})`);
|
||||
continue;
|
||||
}
|
||||
db.prepare(`INSERT INTO items (tag,kind,door_id,building,cabinet_id,hook,is_master,status,notes)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)`)
|
||||
.run(tag,
|
||||
kindId,
|
||||
door.id, building, cabId || null,
|
||||
col("hook") >= 0 ? (r[col("hook")] || "").trim() : "",
|
||||
col("master") >= 0 && /^(1|yes|y|true)$/i.test((r[col("master")] || "").trim()) ? 1 : 0,
|
||||
col("status") >= 0 && (r[col("status")] || "").trim() ? (r[col("status")]).trim() : "in",
|
||||
col("notes") >= 0 ? (r[col("notes")] || "").trim() : "");
|
||||
out.items++;
|
||||
}
|
||||
})();
|
||||
audit("import", `${out.items} items, ${out.doors} doors`);
|
||||
res.json(out);
|
||||
});
|
||||
|
||||
/* Separate importers. The combined one creates doors and cabinets implicitly,
|
||||
which is convenient but gives you no control over numbering or names. These
|
||||
let you load each table deliberately, and re-run safely to update. */
|
||||
function importer(res, csv, required, handle) {
|
||||
const rows = parseCSV(csv || "");
|
||||
if (rows.length < 2) return bad(res, "needs a header row and at least one line");
|
||||
const head = rows[0].map((h) => h.trim().toLowerCase());
|
||||
const col = (n) => head.indexOf(n);
|
||||
const missing = required.filter((n) => col(n) < 0);
|
||||
if (missing.length) return bad(res, "missing columns: " + missing.join(", "));
|
||||
const get = (r, n) => (col(n) >= 0 ? (r[col(n)] || "").trim() : "");
|
||||
/* Whether the column exists at all in this file. An absent column must not
|
||||
overwrite existing data with blanks — re-importing `name,role` should
|
||||
update the role and leave the department alone. */
|
||||
const has = (n) => col(n) >= 0;
|
||||
const out = { created: 0, updated: 0, skipped: [] };
|
||||
db.transaction(() => { rows.slice(1).forEach((r) => handle(r, get, out, has)); })();
|
||||
return out;
|
||||
}
|
||||
const truthy = (v) => /^(1|yes|y|true|sim|s|x)$/i.test(v);
|
||||
|
||||
app.post(A + "/import/doors", (req, res) => {
|
||||
const out = importer(res, req.body.csv, ["num", "building", "name"], (r, get, o, has) => {
|
||||
const num = get(r, "num"), building = get(r, "building"), name = get(r, "name");
|
||||
if (!num || !building || !name) { o.skipped.push(num || "(sem número)"); return; }
|
||||
const exists = db.prepare("SELECT id FROM doors WHERE num=?").get(num);
|
||||
if (exists) {
|
||||
const set = { building, name };
|
||||
if (has("lock_group")) set.lock_group = get(r, "lock_group");
|
||||
if (has("notes")) set.notes = get(r, "notes");
|
||||
const cols = Object.keys(set);
|
||||
db.prepare(`UPDATE doors SET ${cols.map((c) => c + "=?").join(",")} WHERE id=?`)
|
||||
.run(...cols.map((c) => set[c]), exists.id);
|
||||
db.prepare("UPDATE items SET building=? WHERE door_id=?").run(building, exists.id);
|
||||
o.updated++;
|
||||
} else {
|
||||
db.prepare("INSERT INTO doors (num,building,name,lock_group,notes) VALUES (?,?,?,?,?)")
|
||||
.run(num, building, name, get(r, "lock_group"), get(r, "notes"));
|
||||
o.created++;
|
||||
}
|
||||
});
|
||||
if (!out || out.error) return;
|
||||
audit("import doors", `${out.created} new, ${out.updated} updated`);
|
||||
res.json(out);
|
||||
});
|
||||
|
||||
app.post(A + "/import/people", (req, res) => {
|
||||
const out = importer(res, req.body.csv, ["name"], (r, get, o, has) => {
|
||||
const name = get(r, "name");
|
||||
if (!name) { o.skipped.push("(sem nome)"); return; }
|
||||
/* Matched on name, so re-importing an updated spreadsheet edits people
|
||||
rather than creating duplicates. */
|
||||
const exists = db.prepare("SELECT id FROM people WHERE name=? COLLATE NOCASE").get(name);
|
||||
if (exists) {
|
||||
const set = {};
|
||||
["dept", "role", "phone", "email"].forEach((c) => { if (has(c)) set[c] = get(r, c); });
|
||||
if (has("active")) set.active = col_active(r, get);
|
||||
const cols = Object.keys(set);
|
||||
if (cols.length) {
|
||||
db.prepare(`UPDATE people SET ${cols.map((c) => c + "=?").join(",")} WHERE id=?`)
|
||||
.run(...cols.map((c) => set[c]), exists.id);
|
||||
}
|
||||
o.updated++;
|
||||
} else {
|
||||
db.prepare("INSERT INTO people (name,dept,role,phone,email,active) VALUES (?,?,?,?,?,?)")
|
||||
.run(name, get(r, "dept"), get(r, "role"), get(r, "phone"), get(r, "email"), col_active(r, get));
|
||||
o.created++;
|
||||
}
|
||||
});
|
||||
if (!out || out.error) return;
|
||||
audit("import people", `${out.created} new, ${out.updated} updated`);
|
||||
res.json(out);
|
||||
});
|
||||
function col_active(r, get) {
|
||||
const v = get(r, "active");
|
||||
return v === "" ? 1 : truthy(v) ? 1 : 0;
|
||||
}
|
||||
|
||||
app.post(A + "/import/cabinets", (req, res) => {
|
||||
const out = importer(res, req.body.csv, ["id", "name"], (r, get, o, has) => {
|
||||
const id = get(r, "id").toUpperCase(), name = get(r, "name");
|
||||
if (!id || !name) { o.skipped.push(id || "(sem código)"); return; }
|
||||
const exists = db.prepare("SELECT * FROM cabinets WHERE id=?").get(id);
|
||||
if (exists) {
|
||||
const set = { name };
|
||||
if (has("full_name")) set.full_name = get(r, "full_name") || name;
|
||||
if (has("note")) set.note = get(r, "note");
|
||||
if (has("off_site")) set.off_site = truthy(get(r, "off_site")) ? 1 : 0;
|
||||
if (has("sort")) set.sort = +get(r, "sort") || 0;
|
||||
const cols = Object.keys(set);
|
||||
db.prepare(`UPDATE cabinets SET ${cols.map((c) => c + "=?").join(",")} WHERE id=?`)
|
||||
.run(...cols.map((c) => set[c]), id);
|
||||
o.updated++;
|
||||
} else {
|
||||
db.prepare("INSERT INTO cabinets (id,name,full_name,note,off_site,sort) VALUES (?,?,?,?,?,?)")
|
||||
.run(id, name, get(r, "full_name") || name, get(r, "note"),
|
||||
truthy(get(r, "off_site")) ? 1 : 0, +get(r, "sort") || 0);
|
||||
o.created++;
|
||||
}
|
||||
});
|
||||
if (!out || out.error) return;
|
||||
audit("import cabinets", `${out.created} new, ${out.updated} updated`);
|
||||
res.json(out);
|
||||
});
|
||||
|
||||
/* Signatures for one person, for the printable receipt. Kept out of
|
||||
/api/state so the images are only fetched when actually shown. */
|
||||
app.get("/api/report/person/:id", (req, res) => {
|
||||
res.json(db.prepare(`SELECT h.*, i.tag, d.name AS door, i.building, i.cabinet_id
|
||||
FROM handovers h JOIN items i ON i.id=h.item_id
|
||||
LEFT JOIN doors d ON d.id=i.door_id
|
||||
WHERE h.person_id=? AND h.in_date IS NULL
|
||||
ORDER BY h.out_date DESC`).all(req.params.id));
|
||||
});
|
||||
|
||||
app.get(A + "/export.csv", (req, res) => {
|
||||
const rows = db.prepare(`
|
||||
SELECT i.tag, d.name AS door, i.building, d.lock_group, i.cabinet_id AS cabinet, i.hook,
|
||||
i.is_master AS master, i.status, i.kind, i.uid, i.notes,
|
||||
(SELECT p.name FROM handovers h JOIN people p ON p.id=h.person_id
|
||||
WHERE h.item_id=i.id AND h.in_date IS NULL) AS held_by
|
||||
FROM items i LEFT JOIN doors d ON d.id=i.door_id ORDER BY i.tag`).all();
|
||||
const head = Object.keys(rows[0] || { tag: "" });
|
||||
const q = (v) => `"${String(v ?? "").replace(/"/g, '""')}"`;
|
||||
res.setHeader("Content-Type", "text/csv; charset=utf-8");
|
||||
res.setHeader("Content-Disposition", `attachment; filename="key-register-${today()}.csv"`);
|
||||
res.send([head.join(","), ...rows.map((r) => head.map((h) => q(r[h])).join(","))].join("\n"));
|
||||
});
|
||||
|
||||
/* Consistent snapshot even with writes in flight — this is why SQLite is a
|
||||
good choice here: the whole backup is one file. */
|
||||
app.get(A + "/backup.db", (req, res) => {
|
||||
const tmp = path.join(DATA_DIR, `backup-${Date.now()}.db`);
|
||||
db.backup(tmp).then(() => {
|
||||
res.setHeader("Content-Disposition", `attachment; filename="keyreg-${today()}.db"`);
|
||||
res.sendFile(tmp, () => fs.unlink(tmp, () => {}));
|
||||
}).catch((e) => res.status(500).json({ error: String(e) }));
|
||||
});
|
||||
|
||||
app.get(A + "/audit", (req, res) => {
|
||||
res.json(db.prepare("SELECT * FROM audit ORDER BY id DESC LIMIT 200").all());
|
||||
});
|
||||
|
||||
app.post(A + "/wipe", (req, res) => {
|
||||
if (req.body.confirm !== "DELETE EVERYTHING") return bad(res, "confirmation phrase required");
|
||||
db.transaction(() => {
|
||||
["handovers", "items", "doors", "people", "journal"].forEach((t) => db.prepare(`DELETE FROM ${t}`).run());
|
||||
})();
|
||||
audit("wipe", "all register data cleared");
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Static
|
||||
-------------------------------------------------------------------------- */
|
||||
app.use(express.static(path.join(__dirname, "public")));
|
||||
app.get("/admin", (req, res) => res.sendFile(path.join(__dirname, "public", "admin.html")));
|
||||
app.get("/report", (req, res) => res.sendFile(path.join(__dirname, "public", "report.html")));
|
||||
app.get("/pedido", (req, res) => {
|
||||
res.type("html").send(
|
||||
fs.readFileSync(path.join(__dirname, "public", "pedido.html"), "utf8")
|
||||
.replace("__LANG__", UI_LANG));
|
||||
});
|
||||
app.get("/health", (req, res) => res.json({ ok: true, items: db.prepare("SELECT COUNT(*) c FROM items").get().c }));
|
||||
|
||||
app.listen(PORT, "0.0.0.0", () => {
|
||||
console.log(`Key register on http://0.0.0.0:${PORT} (db: ${DB_PATH})`);
|
||||
if (!ACCESS_CODE) console.log("No ACCESS_CODE set — relying on the network for access control.");
|
||||
if (PUBLIC_REQUESTS) console.log(`Request form is PUBLIC at /pedido${REQUEST_PIN ? " (PIN required)" : " — NO PIN SET"}`);
|
||||
});
|
||||
Loading…
Reference in a new issue