Files
parking_solution/apps/web/src/lib/i18n/en.ts
T
julian d5e41500a8
Build desktop / desktop (push) Successful in 4m42s
Build & push images / images (push) Successful in 2m53s
CI / check (push) Successful in 39s
feat(backup): admin UI with admin-chosen target directory
The backup destination is now chosen by the on-site admin in the UI (Setup ->
Backup), not a server env var. An env-pinned target defeats the purpose: the admin
can't point backups at a freshly-plugged USB or a NAS mount without editing .env
and restarting. The encryption key stays a server secret.

Target storage:
- New site_config.backup_target_dir (migration 0016, nullable; null = not
  configured). BackupService reads it fresh each run, so a UI change takes effect
  with no restart. Only BACKUP_KEY stays env -- a key must never live in the DB it
  backs up.

Routes:
- PUT /api/backup/config  -- set/clear the target (backup:update; upserts id=1).
- POST /api/backup/test   -- probe a candidate path server-side (exists / is a
  directory / writable) so the admin gets feedback before relying on it.
- status() now exposes targetDir + keyPresent, so the UI distinguishes
  'no target set' from 'BACKUP_KEY missing'.

UI (apps/web/src/BackupSettings.tsx):
- A Setup -> Backup tab (gated backup:read): an editable target-path field with a
  Test-target probe (localized ok/missing/not-a-dir/not-writable), Save, the status
  panel (config state, last-run size/pruned/error, a distinct amber missing-key
  warning), a Back up now button, and the restore-is-out-of-band note. Full i18n
  (sq + en); nav.backup.
- API client: fetchBackupStatus / setBackupTarget / testBackupTarget / runBackup.

Also includes a small in-progress copy trim to the setup-intro i18n strings.

Verified live with Playwright: typed a path -> Test reported writable -> Save
persisted it -> status reflected it and showed the key-missing warning. Whole
monorepo build/lint/test green. Wiki backup-recovery + open-question #5 updated.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-29 12:21:26 +02:00

935 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// English (en). Mirrors the key structure of sq.ts (the default/fallback). Any key
// missing here falls back to Albanian. See wiki/concepts/i18n.md.
import type { Catalog } from "./sq.js";
export const en: Catalog = {
common: {
loading: "Loading…",
logout: "Log out",
cancel: "Cancel",
close: "Close",
save: "Save",
none: "—",
themeDark: "dark",
themeLight: "light",
theme: "Theme",
fontSmaller: "Smaller text",
fontLarger: "Larger text",
fontSize: "Text size",
today: "Today",
yesterday: "Yesterday",
months: [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
],
},
auth: {
title: "Parking System",
username: "Username",
password: "Password",
signIn: "Sign in",
signingIn: "Signing in…",
},
update: {
available: "Update available",
prompt: "Version {{version}} is available. Install now and restart?",
},
nav: {
booth: "Booth",
shift: "Shift",
setup: "Setup",
devices: "Devices",
tariff: "Tariff",
tariffLab: "Tariff Lab",
subscriptions: "Subscriptions",
plans: "Plans",
site: "Site",
users: "Users",
roles: "Roles",
shifts: "Shifts",
reports: "Reports",
recycleBin: "Recycle bin",
logs: "Logs",
backup: "Backup",
profile: "Profile",
},
profile: {
title: "My profile",
accountSection: "Account",
fullName: "Full name",
fullNamePh: "First and last name",
email: "Email",
emailPh: "you@example.com",
username: "Username",
role: "Role",
saveProfile: "Save profile",
profileSaved: "Profile saved.",
passwordSection: "Change password",
currentPassword: "Current password",
newPassword: "New password",
confirmPassword: "Confirm password",
changePassword: "Change password",
passwordChanged: "Password changed.",
passwordsDontMatch: "Passwords don't match.",
passwordTooShort: "Password must be at least {{min}} characters.",
},
status: {
live: "LIVE",
connecting: "CONNECTING",
offline: "OFFLINE",
},
devices: {
footerTitle: "Devices",
none: "No devices configured.",
catAccess: "Relay",
catReader: "Reader",
catCamera: "Camera",
catPrinter: "Printer",
catVision: "ANPR",
// Role/direction suffixes for the chip label (e.g. "Reader entry").
role: {
entry: "entry",
exit: "exit",
both: "entry/exit",
mixed: "entry/exit",
lane: "at lane",
booth: "at booth",
},
state: {
ready: "ready",
degraded: "degraded",
offline: "offline",
},
allOk: "all ready",
issuesCount: "{{count}} with issues",
issuesTitle: "Device issues",
clickForIssues: "Click for details",
checkedAt: "checked {{time}}",
},
booth: {
processTicket: "Process ticket",
scanPlaceholder: "Scan or type ticket number…",
laneEntry: "Entry",
laneExit: "Exit",
openTicket: "Read",
occupancy: "Occupancy",
occUnavailable: "occupancy unavailable",
inside: "inside",
of: "of",
uncapped: "uncapped",
free: "free",
lotFull: "● lot full",
liveFeed: "Live feed",
events: "events",
noEventsYet: "No events yet — entries and exits will stream here.",
activeSessions: "Active sessions",
insideCount: "inside",
noActiveSessions: "No active sessions.",
noMatch: "No sessions match the filter.",
// Active-sessions table column headers.
colWho: "Ticket / subscriber",
colPlate: "Plate",
colEntry: "Entry",
colElapsed: "Elapsed",
badgeOverstay: "overstay",
badgeOverstayTitle:
"Paid session. The customer failed to exit during the grace period. A new period began.",
plateTitle: "Licence plate recognized ANPR.",
// filters
filterSearchSessions: "Search ticket / subscriber / plate…",
filterSearchFeed: "Search event / identity / plate…",
filterAll: "All",
fKindTransient: "Transient",
fKindSubscription: "Subscribers",
fSrcBooth: "Booth",
fSrcReader: "Reader",
fEvtEntry: "Entry",
fEvtExit: "Exit",
fEvtPay: "Pay",
fEvtVoid: "Void",
fEvtAnomaly: "Anomaly",
openPayExit: "Open pay / exit",
openBarrier: "Open barrier",
openBarrierTitle: "Human-intervention barrier open (audited)",
barrierOpened: "barrier opened",
openManually: "open manually",
badgeExiting: "exiting",
badgePaid: "paid",
badgeUnpaid: "unpaid",
badgeSubscription: "subscription",
evtEntry: "ENTRY",
evtExit: "EXIT",
evtPay: "PAY",
evtVoid: "VOID",
evtOpenCmd: "OPEN→",
evtOpenObserved: "OPEN✓",
evtShiftOpen: "SHIFT+",
evtShiftZ: "SHIFT Z",
evtCashMovement: "CASH",
evtCashIn: "PAY-IN",
evtCashOut: "PAY-OUT",
evtAnomaly: "ANOMALY",
evtRefused: "REFUSED",
// live-feed event detail line + classification badges (computed from payload)
evtNoReason: "no reason recorded",
badgeEntryRefused: "entry refused",
badgeExitRefused: "exit refused",
badgeLotFull: "lot full",
badgeBarrierFailed: "barrier did not open",
badgeManualOpen: "manual open",
badgeSubRefused: "subscription refused",
badgeSubSale: "subscription sale",
badgeWindowCharge: "out-of-window — owes fee",
badgeNoTicket: "ticket not printed",
feedSourceBooth: "booth",
feedSourceReader: "reader",
// event-detail modal
eventDetail: "Event detail",
edType: "Type",
edTime: "Time",
edIndex: "Ledger index",
edDirection: "Direction",
edSource: "Source",
edIdentity: "Identity",
edReason: "Reason",
edSnapshots: "Snapshots",
edPayload: "Signed payload",
edChain: "Chain",
edSignature: "Signature",
edKeyId: "Key",
edPrevHash: "Prev hash",
edNoPayload: "No payload on this event.",
edCopy: "Copy",
edCopied: "Copied",
edDetails: "Details",
edAuditData: "Audit data (signature & chain)",
edAmount: "Amount",
edTender: "Tender",
edSession: "Session",
edPlate: "Plate",
edCategory: "Category",
edOperator: "Operator",
edTariffVersion: "Tariff version",
edRawPayload: "Raw signed payload",
edOccurrence: "Occurrence id",
subscriber: "Subscriber",
edVia: "Entry medium",
viaQr: "QR code",
viaCard: "RFID card/chip",
viaPlate: "Plate",
},
// Localized messages for the signed REASON_CODES (see @parking/shared). Keys MUST
// match the codes 1:1; {{param}} placeholders are filled from the event's
// reasonParams. Legacy events with no code fall back to the signed English `reason`.
reason: {
"entry.refused.full": "Entry refused — lot full ({{count}}/{{capacity}})",
"entry.held.noTicket": "Entry held — ticket not printed: {{detail}}",
"exit.refused.closed": "Exit refused — session already closed",
"exit.refused.noSession": "Exit refused — unknown ticket",
"exit.refused.unpaid": "Exit refused — not paid (take payment first)",
"exit.refused.graceExpired": "Exit refused — walk-back grace expired (top-up required)",
"exit.open.noBarrier": "Exit recorded, but no exit barrier is configured — open manually",
"exit.open.unavailable": "Exit recorded, but the barrier is unavailable — open manually",
"exit.open.failed": "Exit recorded, but the barrier did not open — open manually",
"exit.freeGrace": "Free entry-grace (no charge)",
"exit.manualOpen": "Manual barrier open (human intervention)",
"sub.refused.notFound": "Subscription refused — not found",
"sub.refused.outOfWindow": "Subscription refused — {{status}}/out-of-window",
"sub.refused.noSession": "Subscription exit with no open session (already out / never entered)",
"sub.refused.atCapacity": "Subscription refused — at capacity ({{inUse}}/{{max}} cars in)",
"sub.refused.unpaidWindow": "Exit refused — out-of-window charge unpaid ({{amount}} {{currency}}); pay at the booth",
"void.ticketCancelled": "Ticket cancelled — {{reason}}",
},
tariff: {
title: "Tariff",
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)",
billingIncrement: "Billing increment (min)",
dailyCap: "Daily cap (blank = none)",
dailyCapPh: "e.g. 12.00",
lostTicketFee: "Lost-ticket fee",
exitGrace: "Exit walk-back grace (min)",
rateBlocks: "Rate blocks",
rateBlocksHint: "Each band lasts a number of hours and bills at its own price; bands are consumed in order (the first hours, then the next hours). The last band is \"thereafter\" (open-ended) — its price applies once the ladder is exhausted. Price is per billing increment.",
bandDuration: "Band duration",
hoursUnit: "hours",
egHours: "e.g. 2",
pricePerIncrement: "Price / increment",
thereafter: "thereafter (open-ended)",
remove: "Remove",
addBlock: "+ Add block",
publishNewVersion: "Publish new version",
publishing: "Publishing…",
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",
modeStepped: "By duration (up-to)",
steppedHint:
"Set the TOTAL price for a stay up to a given time (e.g. up to 3h = 500). The first row whose limit ≥ the duration wins (the limit is inclusive). The last row's total repeats as a per-day price for longer stays.",
stepUpTo: "Up to",
stepTotal: "Total price",
addStep: "+ Add row",
steppedTiersConflict:
"⚠ Time/seasonal tiers do NOT apply when the base rate is 'By duration (up-to)' — the engine ignores them entirely. Remove the tiers, or switch the base rate to 'Hourly ladder' or 'Flat price'. Publishing is blocked until this is fixed.",
tiersAdvanced: "Advanced: time & seasonal tiers",
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",
tierCategoryPh: "e.g. bus",
tierDays: "Days",
tierHours: "Hours",
tierDates: "Dates",
tierOvernight: "(crosses midnight)",
addTier: "+ Add tier",
dow1: "Mon",
dow2: "Tue",
dow3: "Wed",
dow4: "Thu",
dow5: "Fri",
dow6: "Sat",
dow0: "Sun",
},
setup: {
title: "Setup",
intro:
"Add your barrier controllers first. Then add readers (QR/RF), cameras, printers",
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.",
outputsTitle: "Outputs — relays (barriers + lamp)",
outputsHint:
"Relays are OUTPUTS: each opens a barrier (or drives the button lamp). Set the relay number and direction. The input terminals (button, sensor) are in the Inputs section below.",
pulseOpenMs: "Pulse open (ms)",
pulseOpenHint: "How long a barrier relay is held open (jog). Applies to all barrier relays.",
inputsTitle: "Inputs — terminals (button, sensor)",
inputsHint:
"Inputs are TERMINALS the host READS: the entry button and the presence/radar sensor. Each belongs to an entry barrier — it triggers or gates that relay.",
inputsIdleHigh: "Inputs idle HIGH",
inputsIdleHighHint: "This board idles inputs HIGH (status 1111); a press pulls LOW.",
relay: "Relay",
// Generic input rows: terminal + role + the relay it serves.
inputTerminal: "Terminal",
inputServesRelay: "Serves relay",
roleButton: "Entry button",
rolePresenceLoop: "Presence (loop)",
rolePresenceRadar: "Presence (radar)",
roleAlertTrigger: "Alert trigger",
addInput: "+ Add input",
entryCooldown: "Cooldown after ticket (s)",
entryCooldownHint:
"When there's no presence sensor: repeat button presses are suppressed for this many seconds after a ticket. A fallback (not a guarantee) — a determined abuser can wait it out.",
activeLow: "Active-low",
activeLowHint:
"Tick if the presence sensor (e.g. a radar) idles HIGH and goes LOW on detection — the opposite of the button. This inverts that terminal's reading so 'present' is read correctly.",
eventRadarAlert: "Radar alert (lamp)",
triggerInput: "Trigger input",
triggerInputHint:
"The input terminal (the radar) that starts this relay blinking. Blinks while the trigger is active but the camera doesn't confirm a car; solid on once the camera confirms; off otherwise.",
lockLane: "Lock from",
lockLaneHint:
"Which camera locks the lamp solid: the entry or the exit camera. An exit radar must lock on the EXIT camera.",
lockLaneEntry: "Entry camera",
lockLaneExit: "Exit camera",
blinkOnMs: "Blink on (ms)",
blinkOffMs: "Blink off (ms)",
addRelay: "+ Add relay",
anpr: "Plate recognition (ANPR)",
anprHint:
"Read plates on this camera: the vision service reads the plate from each snapshot and records it (both entry and exit). Requires the vision service running.",
anprAuto: "Auto open/close on subscriber plate",
anprAutoHint:
"Let THIS camera auto-open the barrier when it recognises a subscriber's plate. Turn OFF on a shared entry/exit lane's exit camera, so a car driving IN isn't auto-EXITed by its back plate (recognition still runs — only the auto-trigger is off).",
testAnpr: "Test ANPR",
anprTesting: "Testing ANPR…",
testAnprHint:
"Takes a live snapshot from this camera and tries to read a plate, reporting the result and the time it took. Point a plate at the camera first.",
anprOk: "✓ Read plate {{plate}} — {{confidence}}% confidence, {{ms}} ms",
anprLowConfidence: "(low confidence — advisory only)",
anprTookMs: "{{ms}} ms",
"anprFail.vision-disabled": "Vision service is disabled — enable it (VISION_ENABLED) to test ANPR.",
"anprFail.snapshot-failed": "Couldn't take a snapshot from the camera (offline or unreachable).",
"anprFail.no-plate": "No plate found in the snapshot.",
// Printer test slip — pushes a real slip so the admin can confirm it physically prints.
testPrint: "Print test slip",
printTesting: "Printing…",
testPrintHint:
"Sends a test slip to the printer now. ‘Connected’ only opens the link — this confirms the printer actually feeds paper.",
printOk: "✓ Test slip sent ({{ms}} ms). Check the printer.",
"printFail.print-failed": "The printer rejected the job (out of paper, cover open, or the link dropped).",
// Reveal/hide toggle for a secret field (e.g. the device web password).
revealSecret: "Show password",
hideSecret: "Hide password",
alarmUrlTitle: "Alarm Server settings (enter these in the camera)",
alarmUrlHint:
"Enter these in the camera at Configuration → Event → … → Alarm Settings (or Notify Surveillance Center). The camera POSTs every event here — no polling.",
alarmUrlCopy: "Copy all",
alarmUrlCopied: "Copied ✓",
alarmUrlSaveFirst:
"Save the camera first — the address is generated once the device has an ID. Re-open it for editing to see it.",
alarmUrlTestFirst:
"Click “Test connection” first — that resolves this host's IP on the camera's network (so the camera can reach it).",
alarmFieldHost: "Destination IP / Host",
alarmFieldUrl: "URL",
alarmFieldProtocol: "Protocol",
alarmFieldPort: "Port",
whichBarrier: "Which barrier does this device serve?",
controller: "Controller",
choose: "Choose…",
relayLabel: "Relay {{relay}} ({{direction}})",
noRelaysConfigured: "This controller has no relays configured.",
},
lab: {
title: "Tariff Lab",
intro:
"Test rates in time (day/night windows, daily caps, overstay) in seconds, with no waiting. Pricing uses the same logic as the booth; nothing is written to the ledger.",
loadTicket: "Load from a real ticket",
loadTicketPh: "Ticket number / identity",
load: "Load",
loaded: "Loaded session {{id}}",
tariffVersion: "Tariff version",
activeVersion: "Active version (current)",
entered: "Entered",
asOf: "As of (now/exit)",
now: "Now",
category: "Category",
categoryPh: "e.g. bus (blank = car)",
payment: "Payment",
paid: "paid",
graceMin: "grace (min)",
price: "Compute price",
pricing: "Pricing…",
outcome: "Outcome",
amountDue: "Amount due",
billedPeriod: "Billed period",
overstay: "overstay",
settled: "settled",
periodStart: "Period start",
graceExpires: "Grace expires",
curve: "Duration curve",
curveHint: "Fee from entry at several durations — see where the daily cap flattens or windows shift.",
},
subs: {
title: "Subscriptions",
unnamed: "(unnamed)",
unbound: "unbound",
car_one: "{{count}} car",
car_other: "{{count}} cars",
cred: "cred",
plates: "{{count}} plate(s)",
noPrice: "no price",
perDay: "day",
perWeek: "week",
perMonth: "month",
plan: "Plan",
quantity: "Cars",
quantityHint: "cars covered by this subscription (price ×N)",
count: "How many",
planNone: "— comp / no charge —",
planNoneAvail: "No plans defined — an admin must create one first.",
version: "Version",
versionCurrent: "current",
versionHint: "Move this subscriber to another version of the same plan. The price stays as billed; only the access hours change going forward.",
versionOnlyOne: "only one version of this plan",
everyDay: "every day",
allDay: "24/7 (no window)",
quoting: "pricing…",
quotePrompt: "pick an end date",
quoteLine: "{{periods}} × {{unit}} · {{amount}} {{currency}}",
tender: "Paid by",
tenderCash: "Cash",
tenderCard: "Card",
tenderHint: "Recorded as a signed payment (feed, drawer, Z-report).",
saleRecorded: "Sale recorded: {{amount}} {{currency}} ({{tender}}).",
saleNoShift: "⚠ No shift was open — open one so the takings land in a Z-report.",
edit: "Edit",
revoke: "Revoke",
delete: "Delete",
noneYet: "No subscriptions yet.",
add: "+ Add subscription",
new: "New subscription",
editTitle: "Edit subscription",
holderName: "Holder name",
contact: "Contact",
carLimit: "Car limit",
limitCarsInAtOnce: "limit cars in at once",
validFrom: "Valid from",
validTo: "Valid to",
validToEnd: "Valid to (end)",
isoDateOptional: "ISO date (optional)",
boundPlates: "Bound plates",
commaSeparatedOptional: "comma-separated (optional)",
credentials: "Credentials",
credentialsCardQr: "Credentials (card / QR)",
rfCardTag: "RF card/tag",
rfCardTagSoon: "RF card/tag (soon)",
rfPlaceholder: "card number (or read the card)",
readCard: "Read card",
captureChooseReader: "Choose a reader, then present the card:",
captureNoReaders: "No readers configured.",
captureWaiting: "Present the card to the reader…",
captureTimeout: "Timed out with no card read. Try again.",
captured: "Card read: {{value}}",
qr: "QR",
qrAutoGen: "QR code is auto-generated on save",
credentialValue: "credential value",
addCredential: "+ credential",
needCredentialOrPlate: "A subscription needs at least one credential OR one bound plate.",
save: "Save",
cancel: "Cancel",
saved: "Subscription saved.",
savedPrinted: "Subscription saved — QR code printed.",
savedPrintFailed: "Subscription saved, but printing failed ({{error}}). Use \"Print code\".",
printCode: "Print code",
printedOn: "Code printed on {{printer}}.",
confirmRevoke: "Revoke subscription for {{name}}? It will be refused at the barrier.",
confirmDelete: "Delete subscription for {{name}}? (Past events are kept.)",
statusActive: "active",
statusSuspended: "suspended",
statusRevoked: "revoked",
},
plans: {
title: "Subscription plans",
intro: "Admin-defined plans the operator sells from — the price is looked up, never typed. Editing a plan publishes a new version; past sales keep their recorded price.",
add: "+ Add plan",
noneYet: "No plans yet. Add one so the booth can sell subscriptions.",
colName: "Name",
colPrice: "Price",
colHours: "Hours",
colUsedBy: "Used by",
colEffective: "Effective",
allHours: "24/7",
usedByCount: "{{active}} active / {{total}}",
usedByNone: "none",
usedByTitle: "Show the subscriptions on this plan",
subscribers: "Subscribers",
inForce: "in force",
retired: "retired",
versionCount: "{{count}} versions",
newVersion: "New version",
retire: "Retire",
reactivate: "Reactivate",
delete: "Delete",
deleteTitle: "Delete this plan permanently (only when no subscription uses it)",
reactivated: "“{{name}}” is sellable again.",
confirmDelete: "Delete the plan “{{name}}” permanently? This can't be undone.",
deleted: "Plan “{{name}}” deleted.",
deleteInUse: "Can't delete — subscriptions still use this plan. Retire it instead.",
newTitle: "New plan",
newVersionTitle: "Publish new version",
newVersionHint: "This publishes a NEW version of the plan — existing sales keep their original price.",
period: "Period",
pricePer: "Price per period",
namePlaceholder: "e.g. Hotel daily",
needName: "A plan name is required.",
needPrice: "Enter a price greater than zero.",
saved: "Plan saved.",
confirmRetire: "Retire the plan “{{name}}”? It will no longer be sellable (history is kept).",
needWindow: "Enter valid window times (HH:MM).",
needDays: "Select at least one day for the window.",
restrictTimes: "Restrict parking times (charge transient tariff outside the window)",
days: "Days",
window: "Window",
enterAfter: "enter after",
exitBefore: "· exit before",
grace: "Grace",
graceHint: "minutes tolerance around the window edges",
timeframesHint: "A scan outside the allowed window is charged the normal transient tariff for the out-of-window minutes (early entry is deferred to exit; late exit is gated until paid).",
},
site: {
occupancy: "Occupancy:",
noCapacitySet: "(no capacity set)",
free: "free",
full: "FULL",
capacityLabel: "Capacity (blank = no limit):",
capacityPlaceholder: "e.g. 120",
printExitDefault: "Print exit ticket by default",
printExitHint: "(booth far from exit → customer self-exits with a voucher)",
reserveSubs: "Reserve subscriber spots",
reserveSubsHint: "Hold a spot for each active subscriber's car(s) even when they're not parked — transients see 'full' sooner. Off: only cars inside count (handle overflow by valet).",
anprEntry: "Auto-open for subscriber plates (ANPR)",
anprEntryHint: "When on, a subscriber's plate read by a lane camera opens the barrier through the normal subscription gate. Off: subscribers must use their card/QR. Plate snapshots are still recorded either way.",
parkDetails: "Park details (optional — shown on tickets/receipts)",
save: "Save",
saved: "Saved.",
fieldParkName: "Park name",
fieldParkNamePh: "e.g. Airport Parking",
fieldOperator: "Operator (legal name)",
fieldOperatorPh: "operating company",
fieldNius: "NIUS",
fieldNiusPh: "e.g. L01234567A",
fieldAddress: "Address",
fieldPhone: "Phone",
fieldEmail: "Email",
},
users: {
title: "Users",
add: "+ Add user",
new: "New user",
none: "No users.",
username: "Username",
password: "Password",
passwordHint: "At least 8 characters.",
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",
add: "+ Add role",
new: "New role",
editTitle: "Edit role",
name: "Name",
permissions: "Permissions",
builtin: "built-in",
edit: "Edit",
delete: "Delete",
confirmDelete: "Delete role \"{{name}}\"?",
permCount_one: "{{count}} permission",
permCount_other: "{{count}} permissions",
userCount_one: "{{count}} user",
userCount_other: "{{count}} users",
},
shift: {
label: "Shift:",
open: "open",
notStarted: "not started",
since: "since",
startShift: "Start shift",
starting: "Starting…",
endShift: "End shift",
ending: "Ending…",
endConfirm: "End this shift? A signed Z-report is recorded and printed.",
drawer: "Drawer:",
openingFloatInherited: "(opening float inherited from the prior shift)",
drawerCashAdmin: "Drawer cash (admin) — load or remove the float",
drawerVoucher: "Drawer voucher — operator raises, an admin authorizes",
amount: "amount",
reasonPlaceholder: "reason (e.g. opening float)",
load: "Load +",
remove: "Remove −",
authName: "admin username",
authPassword: "admin password",
authRequired: "An admin must authorize: enter their username and password.",
mandatArketimi: "Receipt (in) +",
mandatPagese: "Disbursement (out) −",
voucherHint: "A receipt (Mandat Arkëtimi) adds cash; a disbursement (Mandat Pagese) removes it. The float only moves with an admin's sign-off.",
voucherRecorded: "Voucher {{no}} recorded. Drawer now {{amount}}.",
enterPositive: "Enter a positive amount.",
drawerNow: "Drawer now {{amount}}.",
zReport: "SHIFT CLOSE",
viewTakings: "Takings so far",
xReport: "TAKINGS SO FAR",
asOf: "as of",
xReportHint: "View only — nothing is recorded. These figures are signed when the shift is closed.",
payments: "Payments:",
cash: "Cash:",
card: "Card:",
srcTickets: "Tickets:",
srcSubscriptions: "Subscriptions:",
srcSubWindow: "out-of-window",
drawerSection: "— Drawer —",
openingFloat: "Opening cash:",
cashTaken: "Cash taken:",
cashAdded: "Cash added:",
cashRemoved: "Cash removed:",
expectedDrawer: "Expected drawer:",
printedToReceipt: "Printed to booth receipt.",
recordedNoPrinter: "Recorded (no printer to print to).",
// Header shift control + the booth shift gate.
headerNoShift: "No shift",
headerOpen: "Open shift",
headerClose: "Close shift",
headerHeldBy: "Shift open — {{operator}}",
headerHeldByShort: "Shift: {{operator}}",
gateTitle: "Open a shift to process tickets",
gateBody:
"No shift is open. Open your shift so payments and exits are recorded against it.",
gateOtherTitle: "The open shift belongs to another operator",
gateOtherBody:
"{{operator}} has an open shift. Only one shift may be open at a time — they must close theirs before you can open yours.",
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",
srcTickets: "Tickets",
srcSubscriptions: "Subscriptions",
srcSubWindow: "out-of-window",
expectedDrawer: "Expected drawer",
filterFrom: "From",
filterTo: "To",
allOperators: "All operators",
apply: "Apply",
clear: "Clear",
timeframe: "Timeframe",
preset_yesterday: "Yesterday",
preset_week: "Last week",
preset_month: "Last month",
preset_all: "All",
preset_custom: "Custom",
selectAShift: "Select a shift to see its activity log.",
noActivity: "No activity in this shift.",
current: "current",
drawerSection: "Drawer",
openingFloat: "Opening cash",
cashTaken: "Cash taken",
cashAdded: "Cash added",
cashRemoved: "Cash removed",
loadFailed: "Failed to load shifts.",
},
reports: {
title: "Reports",
groupBy: "Group by",
exportCsv: "Export CSV",
loadFailed: "Couldn't load the report: {{error}}",
noData: "No data in this range.",
footnote: "Counts and money are summed from the signed event log. Times shown in {{tz}}.",
preset: { today: "Today", "7d": "7 days", "30d": "30 days", "90d": "90 days" },
bucket: { hour: "Hour", day: "Day", month: "Month" },
kpi: {
entries: "Entries",
exits: "Exits",
revenue: "Revenue",
payments: "Payments",
avgStay: "Avg stay",
subscribers: "Subscribers",
},
chart: {
flow: "Entries & exits over time",
revenue: "Revenue ({{currency}})",
mix: "Revenue mix",
peakHours: "Entries by hour of day",
breakdown: "Breakdown",
},
mix: { ticket: "Transient", subSales: "Subscriptions", subWindow: "Out-of-window" },
row: {
cash: "Cash",
card: "Card",
closed: "Closed sessions",
medianStay: "Median stay",
subActive: "Active subscriptions",
subCars: "Cars covered",
},
},
recycleBin: {
title: "Recycle bin",
retentionNote: "Deleted items are kept for {{days}} days, then permanently removed.",
empty: "Nothing deleted. Items you delete appear here, recoverable until they expire.",
col: { type: "Type", item: "Item", deleted: "Deleted", actions: "" },
kind: {
user: "User",
role: "Role",
subscription: "Subscription",
plan: "Plan",
tariff: "Tariff",
},
restore: "Restore",
purge: "Purge",
purgeConfirmTitle: "Purge permanently?",
purgeConfirmBody: "Permanently delete “{{label}}”? It cannot be restored after this.",
purgeIrreversible: "This is irreversible.",
},
logs: {
title: "System logs",
refresh: "Refresh",
level: "Level",
source: "Source",
since: "Since",
apply: "Apply",
clear: "Clear",
allLevels: "All levels",
allSources: "All sources",
frontend: "Frontend",
backend: "Backend",
time: "Time",
message: "Message",
status: "Status",
path: "Path",
empty: "No logs.",
},
backup: {
title: "Backup",
intro:
"An encrypted copy of the database (the signed ledger) to an external disk. Runs automatically every day and from the button below.",
statusTitle: "Status",
configured: "Enabled",
notConfigured: "Not configured",
notConfiguredHint: "Set BACKUP_TARGET_DIR and BACKUP_KEY on the server to enable backups.",
running: "Running…",
idle: "Idle",
lastSuccess: "Last successful backup",
lastError: "Last error",
never: "Never",
lastFile: "File",
size: "Size",
pruned: "Pruned",
runNow: "Back up now",
runSuccess: "Backup complete.",
runFailed: "Backup failed.",
notConfiguredError: "Backup is not configured.",
restoreNote:
"Restore is not done here — it's an out-of-band step when provisioning a fresh appliance (needs the backup file + the keys you escrowed offsite).",
targetLabel: "Backup location",
targetPlaceholder: "e.g. /mnt/backup or /media/usb",
targetHint: "An absolute path to a mounted disk (USB/SATA) or a network share (SMB/NFS).",
save: "Save",
saved: "Saved.",
test: "Test target",
testOk: "The location is writable.",
testEmpty: "Enter a path.",
testMissing: "The location does not exist.",
testNotDir: "The path is not a directory.",
testNotWritable: "The directory is not writable.",
keyMissing: "The encryption key (BACKUP_KEY) is missing on the server — set it to enable backups.",
},
pay: {
ticket: "Ticket",
entry: "Entry",
now: "Now",
duration: "Duration",
statusLabel: "Status",
paid: "PAID",
unpaid: "UNPAID",
overstay: "OVERSTAY",
overstayHint: "Earlier session paid. The customer failed to exit during the grace period. Payment for the new period is required. The total below is the new period's fee.",
topUp: "New period due",
total: "Total",
noTariff: "no tariff",
tender: "Tender",
cash: "Cash",
card: "Card",
printExitVoucher: "Print exit ticket",
selfExitHint: "(customer self-exits at the exit)",
payAndOpen: "Pay + open barrier",
payAndVoucher: "Pay + print voucher",
openBarrier: "Open barrier",
printVoucher: "Print voucher",
takingPayment: "taking payment…",
printingVoucher: "printing voucher…",
opening: "opening…",
noSessionFound: "No session found for this ticket.",
alreadyClosed: "This session is already closed (exited {{time}}).",
lookingUp: "looking up…",
paidBarrierOpened: "Paid — barrier opened. Car may exit.",
paidExitRecorded: "Paid and exit recorded, but the barrier did not open: {{reason}}.",
subscription: "SUBSCRIPTION",
plan: "Plan",
prepaid: "PREPAID",
subAssistHint: "Prepaid subscription. Open the barrier to assist the exit (faulty reader / missing card). No payment.",
assistOpenReveal: "Assist open (faulty reader / lost card)…",
payWindowCharge: "Take payment",
windowPaidHint: "Window charge paid. Open the barrier to let the subscriber out.",
windowCharge: "OUT-OF-WINDOW",
windowChargeHint: "This subscriber parked outside their plan's allowed hours. They owe the transient tariff for the out-of-window time — take payment, then open the barrier.",
subBarrierOpened: "Barrier opened for the subscriber (intervention recorded).",
voucherPrinted: "Exit voucher printed on {{printer}}. Customer self-exits at the exit.",
// payment receipt (transparency slip)
receiptPrintFailed: "(receipt didn't print — use \"Reprint receipt\".)",
receiptReprinted: "Receipt reprinted on {{printer}}.",
reprintReceipt: "Reprint receipt",
reprinting: "printing…",
cancelTicket: "Cancel ticket",
cancelTicketTitle: "Cancel this ticket",
cancelTicketHint: "Cancels a wrongly-printed ticket. A signed record is kept (operator + reason); the original entry is never deleted.",
cancelReason: {
misprint: "Misprint",
test: "Test",
wrongVehicle: "Wrong vehicle",
},
cancelReasonPlaceholder: "Reason for cancelling (required)…",
confirmCancelTicket: "Confirm cancellation",
cancelling: "cancelling…",
ticketCancelled: "Ticket cancelled.",
noSnapshots: "no snapshots",
loadingSnapshots: "loading snapshots…",
snapEntry: "entry",
snapExit: "exit",
snapFailed: "camera unreachable",
plate: "Plate",
},
};