feat: tabbed setup, user metadata, light theme, scoped shift history

Consolidate the config screens under a single /setup hub with permission-
gated tabs (Devices/Tariff/Subscriptions/Site/Users/Roles/Shifts), collapsing
the top nav to Booth·Shift·Setup; old top-level paths redirect.

Users: add optional profile metadata (full name, phone, email, address) on
create/edit. Theme: a light palette saved to the user's profile (users.theme),
toggled in the header beside the language switch and applied on load like the
language preference. Both ride on a single additive migration (0008).

Shift history: a new GET /api/shifts folds the signed shift_z_report chain into
completed shifts, SCOPED server-side — operators see only their own; holders of
shift:cash see all with an operator + date-range filter. Surfaced as the Shifts
tab; an operator cannot read another operator's takings (param spoofing is
ignored).

These three features share the router, api client and i18n catalogs, so they
land together. Verified live: theme persists across reload, metadata round-
trips to the DB, and shift scoping holds (operator self-only, admin all+filter).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-19 10:09:18 +02:00
parent 8444bf34c3
commit 040c0ff4ca
16 changed files with 1062 additions and 99 deletions
+106 -4
View File
@@ -11,6 +11,9 @@ export const en: Catalog = {
close: "Close",
save: "Save",
none: "—",
themeDark: "dark",
themeLight: "light",
theme: "Theme",
},
auth: {
title: "Parking System",
@@ -23,11 +26,13 @@ export const en: Catalog = {
booth: "Booth",
shift: "Shift",
setup: "Setup",
devices: "Devices",
tariff: "Tariff",
subscriptions: "Subscriptions",
site: "Site",
users: "Users",
roles: "Roles",
shifts: "Shifts",
},
status: {
live: "LIVE",
@@ -101,7 +106,7 @@ export const en: Catalog = {
},
tariff: {
title: "Tariff",
noRateCard: "No rate card published yet — the pay station can't charge until you publish one.",
noRateCard: "No tariff published yet — the pay station can't charge until you publish one.",
activeSince: "Active since {{date}} · {{count}} version(s) in history. Publishing creates a new version; past sessions keep their original pricing.",
currency: "Currency",
freeEntryGrace: "Free entry grace (min)",
@@ -121,13 +126,13 @@ export const en: Catalog = {
addBlock: "+ Add block",
publishNewVersion: "Publish new version",
publishing: "Publishing…",
publishedOk: "New tariff version published — it's now the active rate card.",
defaultCard: "Default card (always active)",
publishedOk: "New tariff version published — it's now the active rate.",
defaultCard: "Base rate (always active)",
defaultCardHint: "The base rate applied when no time/seasonal tier matches. This alone is enough for most car parks.",
modeLadder: "Hourly ladder",
modeFlat: "Flat price",
tiersAdvanced: "Advanced: time & seasonal tiers",
tiersHint: "Optional. Add cards that apply only at certain hours/days/dates or for a category (e.g. happy hour, night rate, weekend, bus). With no tiers, the simple card is published.",
tiersHint: "Optional. Add tiers that apply only at certain hours/days/dates or for a category (e.g. happy hour, night rate, weekend, bus). With no tiers, just the base rate is published.",
tierName: "Name",
tierPriority: "Priority",
tierCategory: "Category",
@@ -145,6 +150,72 @@ export const en: Catalog = {
dow6: "Sat",
dow0: "Sun",
},
setup: {
title: "Setup",
intro:
"Add your barrier controllers first — set which relay is entry/exit and which terminal the entry button is wired to. Then add readers, cameras and printers and point each at the barrier it serves.",
catControllers: "Controllers (barriers + entry button)",
catReaders: "Readers (QR / RFID)",
catCameras: "Cameras (snapshot + plate)",
catPrinters: "Printers (tickets / vouchers)",
nounController: "controller",
nounReader: "reader",
nounCamera: "camera",
nounPrinter: "printer",
add: "+ Add {{noun}}",
addAnother: "+ Add another {{noun}}",
addTitle: "Add {{noun}}",
editTitle: "Edit {{noun}}",
needControllerFirst: "Add a controller first — a {{noun}} points at one of its relays.",
failedToLoad: "Failed to load setup: {{error}}",
loadingCatalog: "Loading device catalog…",
dirEntry: "Entry",
dirExit: "Exit",
dirBoth: "Both (entry + exit)",
inherits: "inherits {{direction}}",
warnTitle: "⚠ Saved, but action needed:",
dismiss: "Dismiss",
disabled: "(disabled)",
edit: "Edit",
remove: "Remove",
removing: "Removing…",
confirmRemove: "Remove this {{driver}} device?",
noRelaysSet: "no relays set",
unbound: "unbound",
noDrivers: "No drivers registered.",
chooseDevice: "Choose a device…",
scan: "Scan for controllers",
scanning: "Scanning…",
noControllersFound: "No controllers found on the LAN.",
use: "Use",
test: "Test connection",
testing: "Testing…",
saveConfigure: "Save & configure",
saveChanges: "Save changes",
saving: "Saving…",
cancel: "Cancel",
testFailed: "Test failed: {{error}}",
saveFailed: "Save failed: {{error}}",
deviceLabel: "Device:",
preconditionsOk: "● preconditions OK",
autoFixedOnSave: "(auto-fixed on save)",
backendPushIp: "Backend push IP",
chooseAddress: "Choose an address…",
onDeviceSubnet: "— on device subnet",
noNicOnSubnet: "⚠ no NIC on the device's subnet — the device may not reach the backend",
backendIpHint: "The address this device will POST input events to.",
relaysTitle: "Relays on this controller",
relaysHint:
"Each relay opens one barrier. Set its direction; for transient entry, set which input terminal the entry button is wired to.",
relay: "Relay",
entryButtonTerminal: "Entry button on terminal",
addRelay: "+ Add relay",
whichBarrier: "Which barrier does this device serve?",
controller: "Controller",
choose: "Choose…",
relayLabel: "Relay {{relay}} ({{direction}})",
noRelaysConfigured: "This controller has no relays configured.",
},
subs: {
title: "Subscriptions",
unnamed: "(unnamed)",
@@ -240,8 +311,16 @@ export const en: Catalog = {
newPassword: "new password",
role: "Role",
resetPassword: "Reset password",
edit: "Edit",
editTitle: "Edit user",
save: "Save",
delete: "Delete",
confirmDelete: "Delete user \"{{name}}\"?",
detailsSection: "Details (optional)",
fullName: "Full name",
phone: "Phone",
email: "Email",
address: "Address",
},
roles: {
title: "Roles",
@@ -304,6 +383,29 @@ export const en: Catalog = {
openNow: "Open shift now",
opening: "Opening…",
},
shifts: {
title: "Shift history",
myTitle: "My shifts",
none: "No closed shifts.",
operator: "Operator",
started: "Started",
ended: "Ended",
payments: "Payments",
cash: "Cash",
card: "Card",
expectedDrawer: "Expected drawer",
filterFrom: "From",
filterTo: "To",
allOperators: "All operators",
apply: "Apply",
clear: "Clear",
drawerSection: "Drawer",
openingFloat: "Opening float",
cashTaken: "Cash taken",
cashAdded: "Cash added",
cashRemoved: "Cash removed",
loadFailed: "Failed to load shifts.",
},
pay: {
ticket: "Ticket",
entry: "Entry",
+119 -7
View File
@@ -11,6 +11,9 @@ export const sq = {
close: "Mbyll",
save: "Ruaj",
none: "—",
themeDark: "errët",
themeLight: "çelët",
theme: "Tema",
},
auth: {
title: "Sistemi i Parkimit",
@@ -23,11 +26,13 @@ export const sq = {
booth: "Kabina",
shift: "Turni",
setup: "Konfigurimi",
devices: "Pajisjet",
tariff: "Tarifa",
subscriptions: "Abonimet",
site: "Vendi",
site: "Park",
users: "Përdoruesit",
roles: "Rolet",
shifts: "Turnet",
},
status: {
live: "LIVE",
@@ -103,7 +108,7 @@ export const sq = {
},
tariff: {
title: "Tarifa",
noRateCard: "Asnjë kartë tarifore e publikuar — arka nuk mund të faturojë derisa të publikoni një.",
noRateCard: "Asnjë tarifë e publikuar — arka nuk mund të faturojë derisa të publikoni një.",
activeSince: "Aktive që nga {{date}} · {{count}} version(e) në histori. Publikimi krijon një version të ri; sesionet e kaluara ruajnë çmimin origjinal.",
currency: "Monedha",
freeEntryGrace: "Periudha pa pagesë në hyrje (min)",
@@ -123,13 +128,13 @@ export const sq = {
addBlock: "+ Shto bllok",
publishNewVersion: "Publiko version të ri",
publishing: "Duke publikuar…",
publishedOk: "U publikua versioni i ri i tarifës — tani është karta tarifore aktive.",
defaultCard: "Karta e parazgjedhur (gjithmonë aktive)",
publishedOk: "U publikua versioni i ri i tarifës — tani është tarifa aktive.",
defaultCard: "Tarifa bazë (gjithmonë aktive)",
defaultCardHint: "Çmimi bazë i zbatuar kur asnjë nivel kohor/sezonal nuk vlen. Kjo e vetme është mjaftueshëm për shumicën e parkimeve.",
modeLadder: "Shkallë orësh",
modeFlat: "Çmim fiks",
tiersAdvanced: "Të avancuara: nivele kohore & sezonale",
tiersHint: "Opsionale. Shto karta që vlejnë vetëm në orë/ditë/data ose kategori të caktuara (p.sh. orë e lirë, tarifë nate, fundjavë, autobus). Pa nivele, publikohet karta e thjeshtë.",
tiersHint: "Opsionale. Shto nivele tarifore që vlejnë vetëm në orë/ditë/data ose kategori të caktuara (p.sh. orë e lirë, tarifë nate, fundjavë, autobus). Pa nivele, publikohet vetëm tarifa bazë.",
tierName: "Emri",
tierPriority: "Përparësia",
tierCategory: "Kategoria",
@@ -147,6 +152,79 @@ export const sq = {
dow6: "Sht",
dow0: "Die",
},
setup: {
title: "Konfigurimi",
intro:
"Shto fillimisht kontrolluesit e barrierave — cakto cili rele është hyrje/dalje dhe në cilin terminal është lidhur butoni i hyrjes. Pastaj shto lexues, kamera dhe printera dhe drejto secilin te barriera që shërben.",
// Category titles + the singular noun used in buttons/modal titles.
catControllers: "Kontrolluesit (barrierat + butoni i hyrjes)",
catReaders: "Lexuesit (QR / RFID)",
catCameras: "Kamerat (foto + targë)",
catPrinters: "Printerat (bileta / vouchera)",
nounController: "kontrollues",
nounReader: "lexues",
nounCamera: "kamerë",
nounPrinter: "printer",
add: "+ Shto {{noun}}",
addAnother: "+ Shto edhe një {{noun}}",
addTitle: "Shto {{noun}}",
editTitle: "Ndrysho {{noun}}",
needControllerFirst: "Shto fillimisht një kontrollues — {{noun}} drejtohet te një prej releve të tij.",
failedToLoad: "Ngarkimi i konfigurimit dështoi: {{error}}",
loadingCatalog: "Duke ngarkuar katalogun e pajisjeve…",
// Direction labels (relay direction + inherited binding).
dirEntry: "Hyrje",
dirExit: "Dalje",
dirBoth: "Hyrje + dalje",
inherits: "trashëgon {{direction}}",
// Warnings panel.
warnTitle: "⚠ U ruajt, por nevojitet veprim:",
dismiss: "Mbyll",
// Assignment row.
disabled: "(çaktivizuar)",
edit: "Ndrysho",
remove: "Hiq",
removing: "Duke hequr…",
confirmRemove: "Të hiqet kjo pajisje {{driver}}?",
noRelaysSet: "asnjë rele e caktuar",
unbound: "e palidhur",
// Device form.
noDrivers: "Asnjë drejtues i regjistruar.",
chooseDevice: "Zgjidh një pajisje…",
scan: "Skano për kontrollues",
scanning: "Duke skanuar…",
noControllersFound: "Asnjë kontrollues në LAN.",
use: "Përdor",
test: "Testo lidhjen",
testing: "Duke testuar…",
saveConfigure: "Ruaj & konfiguro",
saveChanges: "Ruaj ndryshimet",
saving: "Duke ruajtur…",
cancel: "Anulo",
testFailed: "Testi dështoi: {{error}}",
saveFailed: "Ruajtja dështoi: {{error}}",
deviceLabel: "Pajisja:",
preconditionsOk: "● parakushtet OK",
autoFixedOnSave: "(rregullohet vetë në ruajtje)",
backendPushIp: "IP-ja e backend-it",
chooseAddress: "Zgjidh një adresë…",
onDeviceSubnet: "— në subnetin e pajisjes",
noNicOnSubnet: "⚠ asnjë NIC në subnetin e pajisjes — pajisja mund të mos arrijë backend-in",
backendIpHint: "Adresa te e cila kjo pajisje do të dërgojë eventet e hyrjes.",
// Relay editor.
relaysTitle: "Relet në këtë kontrollues",
relaysHint:
"Çdo rele hap një barrierë. Cakto drejtimin e saj; për hyrje kalimtare, cakto në cilin terminal hyrës është lidhur butoni i hyrjes.",
relay: "Rele",
entryButtonTerminal: "Butoni i hyrjes në terminalin",
addRelay: "+ Shto rele",
// Binding picker.
whichBarrier: "Cilën barrierë shërben kjo pajisje?",
controller: "Kontrolluesi",
choose: "Zgjidh…",
relayLabel: "Rele {{relay}} ({{direction}})",
noRelaysConfigured: "Ky kontrollues nuk ka rele të konfiguruar.",
},
subs: {
title: "Abonimet",
unnamed: "(pa emër)",
@@ -182,8 +260,8 @@ export const sq = {
commaSeparatedOptional: "të ndara me presje (opsionale)",
credentials: "Kredencialet",
credentialsCardQr: "Kredencialet (kartë / QR)",
rfCardTag: "Kartë/etiketë RF",
rfCardTagSoon: "Kartë/etiketë RF (së shpejti)",
rfCardTag: "Kartë/Tag RF",
rfCardTagSoon: "Kartë/Tag RF (së shpejti)",
rfPlaceholder: "numri i kartës (ose lexo kartën)",
readCard: "Lexo kartën",
captureChooseReader: "Zgjidh lexuesin, pastaj afro kartën:",
@@ -242,8 +320,17 @@ export const sq = {
newPassword: "fjalëkalim i ri",
role: "Roli",
resetPassword: "Rivendos fjalëkalimin",
edit: "Ndrysho",
editTitle: "Ndrysho përdoruesin",
save: "Ruaj",
delete: "Fshi",
confirmDelete: "Të fshihet përdoruesi \"{{name}}\"?",
// Optional profile metadata.
detailsSection: "Të dhënat (opsionale)",
fullName: "Emri i plotë",
phone: "Telefoni",
email: "Email",
address: "Adresa",
},
roles: {
title: "Rolet",
@@ -306,6 +393,31 @@ export const sq = {
openNow: "Hap turnin tani",
opening: "Duke hapur…",
},
shifts: {
title: "Historiku i turneve",
myTitle: "Turnet e mia",
none: "Asnjë turn i mbyllur.",
operator: "Operatori",
started: "Filloi",
ended: "Mbaroi",
payments: "Pagesa",
cash: "Para",
card: "Kartë",
expectedDrawer: "Arka e pritshme",
// Filter (admin only).
filterFrom: "Nga",
filterTo: "Deri",
allOperators: "Të gjithë operatorët",
apply: "Apliko",
clear: "Pastro",
// Expanded drawer detail.
drawerSection: "Arka",
openingFloat: "Bilanci fillestar",
cashTaken: "Para të marra",
cashAdded: "Para të shtuara",
cashRemoved: "Para të hequra",
loadFailed: "Ngarkimi i turneve dështoi.",
},
pay: {
ticket: "Bileta",
entry: "Hyrja",