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
+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",