Merge feature branch: store-credit webapp

This commit is contained in:
Claude 2026-05-30 17:31:39 +00:00
commit 4054667b38
No known key found for this signature in database
18 changed files with 4439 additions and 0 deletions

6
.gitignore vendored
View file

@ -216,3 +216,9 @@ __marimo__/
# Streamlit
.streamlit/secrets.toml
# ClubLedger runtime data
clubledger.db
clubledger.db-wal
clubledger.db-shm
staff.json

276
docs/admin-guide.md Normal file
View file

@ -0,0 +1,276 @@
# ClubLedger Administrator Guide
Administrators have access to everything in the [Staff User Guide](user-guide.md) plus the **Admin** tab.
---
## First Login
On first startup the system creates a default admin account:
| Username | Password |
|---|---|
| `admin` | `admin` |
**Change this password immediately.** Go to **Admin → Staff Accounts**, find the admin row, click **Edit**, and set a strong password.
---
## Roles
ClubLedger has three roles:
| Role | Tabs visible |
|---|---|
| **POS Staff** | Members, Bar |
| **Cashier** | Members, Cashier |
| **Admin** | Members, Cashier, Bar, Admin |
---
## Admin Tab
The Admin tab contains two sections: **App Settings** and **Staff Accounts**.
---
## App Settings
These settings control how ClubLedger looks and behaves. Changes take effect immediately without restarting the server.
### General
| Setting | Description |
|---|---|
| **Club Name** | Appears in the navigation bar, on receipts, and on statements |
| **Timezone** | IANA timezone name (e.g. `Europe/London`, `Asia/Bangkok`). All receipt and statement timestamps are shown in this timezone. Leave blank to use the server's local time. |
### Currency
ClubLedger stores all monetary values as integers in a minor unit (e.g. pence) internally. The settings below control how amounts are displayed and entered.
| Setting | Example | Description |
|---|---|---|
| **Currency Symbol** | `£`, `$`, `€`, `฿` | Prepended to every displayed amount |
| **Currency Name** | `pounds` | The major unit. Shown in amount field labels. Users enter amounts in this unit. |
| **Subunit Name** | `pence` | The minor unit stored in the database. Used in internal labels only. |
| **Subunits per unit** | `100` | How many minor units make one major unit. 100 for most currencies, 1 for currencies with no subunit. |
> **Important:** If you change **Subunits per unit**, all existing balances will be re-displayed using the new divisor. The stored integers do not change. Only change this on a fresh installation.
### Transaction Limits
All limits are entered in the **major unit** (e.g. pounds).
| Setting | Description |
|---|---|
| **Minimum top-up** | Cashier cannot top up less than this amount |
| **Maximum top-up** | Cashier cannot top up more than this in a single transaction |
| **Maximum single charge** | Bar cannot charge more than this in a single transaction |
### Business Address
These fields populate the business header printed at the top of every receipt and statement. All fields are optional — leave blank to omit.
| Field | Description |
|---|---|
| **Address Line 14** | Street address, city, postcode, etc. |
| **Country** | Country name or code |
| **Phone** | Contact phone number |
| **Email** | Contact email address |
| **Website** | Contact website URL |
### Branding
| Setting | Description |
|---|---|
| **Logo** | Upload an image file (PNG, JPG, GIF, WebP, or SVG). The file is stored in the `static/` folder of the application. |
| **Logo URL** | The path used to display the logo — set automatically when you upload. Can also be set manually (e.g. `/static/yourlogo.png`) if you are copying a file directly to the server. |
| **Logo Alignment** | `Left`, `Centre`, or `Right` — controls where the logo appears in the receipt/statement header |
| **Logo Max Width** | Maximum display width in pixels (default 200) |
| **Logo Max Height** | Maximum display height in pixels (default 80) |
| **Bar Name** | Label used for the bar/POS venue on receipts (default `Bar`) |
| **Cashier Name** | Label used for the cashier venue on receipts (default `Cashier`) |
### Transactions
| Setting | Description |
|---|---|
| **Transaction Reference Prefix** | Prepended to the auto-generated transaction number. For example, `TXN` produces references like `TXN0000001` (default `TXN`). |
| **Transfer Types** | Comma-separated list of payment methods shown to cashiers in the Transfer Type dropdown on the Cashier tab (e.g. `Bank Transfer,Cash,QR`). |
### Overdraft Policy
Controls whether members are allowed to have a negative balance. The setting is a dropdown with five options:
| Policy | Meaning |
|---|---|
| **Never allowed** | No member can ever go into overdraft |
| **Always allowed** | All members can always go into overdraft |
| **Staff override** | Staff can tick a per-member checkbox to allow overdraft for that specific member |
| **Admin override** | Only admins can tick the per-member overdraft checkbox |
| **Staff block** | Staff can tick a per-member checkbox to block overdraft for a specific member (all others are allowed) |
When the policy is **Staff override**, **Admin override**, or **Staff block**, an **Overdraft override** checkbox appears in the Edit Member modal.
### Receipt Labels
All fields in this section are optional and are provided for localisation. Each field overrides the default label printed on receipts and statements.
| Field | Default |
|---|---|
| Receipt title | `RECEIPT` |
| Top-up receipt title | `TOP-UP RECEIPT` |
| Withdrawal receipt title | `WITHDRAWAL RECEIPT` |
| Staff label | `STAFF` |
| Transaction label | `TRANSACTION` |
| Charge venue label | `CHARGE` |
| Transaction time label | `TRANSACTION TIME` |
| Amount charged label | `AMOUNT CHARGED` |
| Remaining balance label | `REMAINING BALANCE` |
| Balance transfer label | `BALANCE TRANSFER` |
| Amount topped-up label | `AMOUNT TOPPED-UP` |
| Amount withdrawn label | `AMOUNT WITHDRAWN` |
| Transfer type label | `TRANSFER TYPE` |
| Transfer reference label | `TRANSFER REFERENCE` |
### Receipt Footers
Optional text printed at the bottom of receipts. Useful for thank-you messages, refund policies, or contact details. Plain text; line breaks are preserved.
| Field | Appears on |
|---|---|
| **Footer — charge receipts** | Bar charge receipts |
| **Footer — cashier receipts** | Top-up and withdrawal receipts |
---
## Staff Accounts
### Creating an Account
Fill in the **Add Account** form at the bottom of the Staff Accounts panel:
| Field | Notes |
|---|---|
| **Name** | The person's real name — appears on receipts and transaction logs |
| **Username** | Used to sign in. Lowercase letters and numbers recommended. |
| **Password** | Choose something strong. |
| **Role** | **POS Staff**, **Cashier**, or **Admin** — see the Roles section above |
### Editing an Account
Click **Edit** on any row. You can change:
- Name
- Username
- Password (leave blank to keep the current one)
- Role
- **Active** checkbox — untick to disable login without deleting the account
### Deleting an Account
Click **Delete**. You cannot delete your own account, and you cannot delete the last remaining admin account.
### Resetting a Staff Member's Password
Open the edit modal for their account, enter a new password, and save. The next time they log in they use the new password. There is no email reset — passwords are changed directly by an admin.
---
## Managing Members
### Correcting a Transaction
There is no transaction editing or deletion by design (audit trail). To correct a mistake:
- **Overcharged at bar:** Apply a top-up for the difference, with a note explaining the correction.
- **Under-charged at bar:** Apply a charge for the difference, with a note.
- **Wrong member charged:** Top up the affected member and charge the correct one, with matching notes on both.
- **Incorrect top-up or withdrawal:** Apply an equal and opposite transaction (top-up to reverse a withdrawal, or withdrawal to reverse a top-up) with a note.
### Resetting a Member's PIN
Members tab → click **Edit** on the row → enter a new PIN → Save.
### Deleting a Member
The **Delete** button only appears when a member's balance is exactly zero. Deletion is permanent and removes all transaction history for that member. If a member has a non-zero balance, bring it to zero first with a correcting transaction before deleting.
### Printing Statements
Members tab → click **Statement** on any row. Statements open in a new browser tab. Use the A4/A5 toggle before printing.
---
## Backing Up Data
All application data is stored in a SQLite database in the application folder. To take a full backup, copy the following files to another location:
**Database files:**
- `clubledger.db` — the main database
- `clubledger.db-wal` and `clubledger.db-shm` — write-ahead log files that may be present while the app is running
If the app is stopped, only `clubledger.db` needs to be copied (the WAL files will have been checkpointed). If the app is running, copy all three files.
**Logo file (if applicable):**
- `static/logo.png` (or `.jpg`, `.gif`, etc.) — the uploaded logo image. Copy this if you have uploaded a logo via the Branding settings.
```
# Linux / Mac back up the database to the home directory
cp /path/to/ClubLedger/clubledger.db ~/clubledger-backup-$(date +%Y%m%d).db
```
To restore, stop the server, replace `clubledger.db` with the backup copy (and the logo file if needed), and restart.
---
## Command-Line Tools (`manage.py`)
Two administrative commands are available from the server terminal. They do not require using the web interface.
### Reset an Admin Password
```
python manage.py reset-admin
```
- Interactively resets the password for an admin account.
- If there are multiple admin accounts, lists them and prompts you to select one.
- Prompts for a new password and a confirmation (minimum 4 characters). The password is not echoed to the screen.
- The app does **not** need to be stopped first — WAL mode allows concurrent access.
- Existing sessions for that account remain valid until they expire naturally (8 hours). To invalidate them immediately, restart the app after running this command.
### Reset the Database
```
python manage.py reset-db
```
- **Permanently deletes all data:** members, balances, transactions, staff accounts, and settings. This cannot be undone.
- You must type `RESET` to confirm. Anything else cancels the operation.
- The app **must be stopped** before running this command.
- After running: restart the app. It will create a fresh database with the default `admin` / `admin` credentials.
- Change the admin password immediately after the fresh start.
---
## Checking Transaction Logs
The database can be queried directly with any SQLite tool (e.g. [DB Browser for SQLite](https://sqlitebrowser.org/), which is free and cross-platform):
```sql
-- All transactions in the last 7 days
SELECT m.name, m.member_number, l.type, l.amount, l.staff_name, l.created_at
FROM ledger_entries l
JOIN members m ON m.id = l.member_id
WHERE l.created_at >= datetime('now', '-7 days')
ORDER BY l.created_at DESC;
-- Current balance for every member
SELECT m.name, m.member_number,
SUM(CASE WHEN l.type='topup' THEN l.amount ELSE -l.amount END) AS balance
FROM members m
LEFT JOIN ledger_entries l ON l.member_id = m.id
GROUP BY m.id
ORDER BY m.name;
```

438
docs/deployment.md Normal file
View file

@ -0,0 +1,438 @@
# ClubLedger Deployment Guide
## Overview
ClubLedger runs as a small web server on **one computer** (the server). Every other device on the same Wi-Fi network — tablets at the bar, a laptop at the cashier desk, a phone — opens the app in a normal web browser. No software is installed on the client devices.
```
Wi-Fi Router
┌────────────────┼────────────────┐
│ │ │
Server PC Cashier Tablet Bar Tablet
runs ClubLedger opens browser opens browser
:8000 http://192.168.1.x:8000
```
---
## Part 1 Initial Setup
### Prerequisites by Operating System
#### Linux (Debian / Ubuntu)
```bash
sudo apt update
sudo apt install git python3 python3-venv python3-full
```
#### macOS
Install [Homebrew](https://brew.sh/) if you haven't already, then:
```bash
brew install git python3
```
Alternatively, download Python from [python.org](https://www.python.org/downloads/) and install Git from [git-scm.com](https://git-scm.com/).
#### Windows
1. Download and install **Python 3.11+** from [python.org](https://www.python.org/downloads/).
- On the first installer screen, tick **"Add Python to PATH"** before clicking Install.
2. Download and install **Git** from [git-scm.com](https://git-scm.com/download/win). Accept all defaults.
---
### Get the Code
Open a terminal (Linux/Mac) or **Git Bash** / **Command Prompt** (Windows):
```bash
# Clone the repository
git clone https://github.com/kbenestad/clubledger.git
cd ClubLedger
# Or, if you downloaded a ZIP instead:
# Unzip it, then open a terminal in that folder
```
---
### Start the Server
**Linux / macOS:**
```bash
chmod +x run.sh
./run.sh
```
**Windows** — open **Command Prompt** or **PowerShell** in the project folder:
```cmd
python -m venv .venv
.venv\Scripts\pip install -r requirements.txt
.venv\Scripts\python main.py
```
> On Windows you can also create a `run.bat` file with those three lines so double-clicking it starts the server in future.
The first time it runs, the server prints the default admin credentials to the terminal:
```
============================================================
Default admin created → username: admin password: admin
Change this immediately in the Admin → Staff Accounts area.
============================================================
```
It then starts listening:
```
INFO: Uvicorn running on http://0.0.0.0:8000
```
Open `http://localhost:8000` in a browser on the same machine to confirm it works.
---
## Part 2 Finding the Server's IP Address
For other devices to connect, you need the **local IP address** of the server machine — the address your router has assigned to it on the Wi-Fi network. This is usually something like `192.168.1.42` or `10.0.0.15`.
### Linux
```bash
hostname -I
```
The first address listed is normally the right one. Example output: `192.168.1.42`
Or with more detail:
```bash
ip addr show | grep "inet " | grep -v "127.0.0.1"
```
### macOS
Open **System Settings → Network → Wi-Fi → Details**. The IP address is listed there.
Or in a terminal:
```bash
ipconfig getifaddr en0 # Wi-Fi
ipconfig getifaddr en1 # Try this if the above gives nothing
```
### Windows
Open **Command Prompt** and run:
```cmd
ipconfig
```
Look for the section labelled **Wi-Fi** (or **Wireless LAN adapter Wi-Fi**). The value next to **IPv4 Address** is the server's address — for example `192.168.1.42`.
---
## Part 3 Connecting Other Devices
Once you have the server's IP address, any device on the same Wi-Fi network can open ClubLedger by entering this address in any browser:
```
http://192.168.1.42:8000
```
Replace `192.168.1.42` with your actual IP address.
This works on:
- Other laptops and desktops (any browser)
- iPads and Android tablets
- Smartphones
> **No app installation needed.** The browser is the client.
---
## Part 4 Keeping It Running
By default, ClubLedger stops when you close the terminal. To keep it running continuously, set it up as a background service.
### Linux systemd Service
Create a service file. Replace `/home/youruser/ClubLedger` with the actual path.
```bash
sudo nano /etc/systemd/system/clubledger.service
```
Paste the following (adjust `User`, `WorkingDirectory`, and the path to Python):
```ini
[Unit]
Description=ClubLedger Store Credit App
After=network.target
[Service]
Type=simple
User=youruser
WorkingDirectory=/home/youruser/ClubLedger
ExecStart=/home/youruser/ClubLedger/.venv/bin/python main.py
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
```
Enable and start:
```bash
sudo systemctl daemon-reload
sudo systemctl enable clubledger
sudo systemctl start clubledger
```
Check it is running:
```bash
sudo systemctl status clubledger
```
View logs:
```bash
journalctl -u clubledger -f
```
ClubLedger now starts automatically when the computer boots.
---
### macOS launchd
Create a file at `~/Library/LaunchAgents/com.clubledger.plist`.
Adjust the paths below to match your actual username and ClubLedger folder:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.clubledger</string>
<key>ProgramArguments</key>
<array>
<string>/Users/youruser/ClubLedger/.venv/bin/python</string>
<string>/Users/youruser/ClubLedger/main.py</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/youruser/ClubLedger</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/Users/youruser/ClubLedger/clubledger.log</string>
<key>StandardErrorPath</key>
<string>/Users/youruser/ClubLedger/clubledger.log</string>
</dict>
</plist>
```
Load it:
```bash
launchctl load ~/Library/LaunchAgents/com.clubledger.plist
```
To stop it:
```bash
launchctl unload ~/Library/LaunchAgents/com.clubledger.plist
```
Logs are written to `clubledger.log` in the project folder.
---
### Windows Task Scheduler
1. Create a file called `start_clubledger.bat` in the ClubLedger folder:
```bat
@echo off
cd /d "C:\Users\YourName\ClubLedger"
.venv\Scripts\python main.py
```
2. Open **Task Scheduler** (search for it in the Start menu).
3. Click **Create Basic Task…**
4. Name it `ClubLedger`. Click Next.
5. Trigger: **When the computer starts**. Click Next.
6. Action: **Start a program**. Click Next.
7. Browse to `start_clubledger.bat`. Click Next, then Finish.
8. Right-click the new task → **Properties****General** tab → tick **Run whether user is logged on or not**.
ClubLedger will now start automatically at boot.
To check it manually, right-click the task and choose **Run**.
---
## Part 5 Using a Fixed IP Address
Router DHCP can change the server's IP over time, which would break the URL bookmarked on client devices. Prevent this by assigning a **static IP** to the server machine.
### Option A Reserve the IP in Your Router (Recommended)
1. Log in to your router admin page (usually `http://192.168.1.1` or `http://192.168.0.1`).
2. Find **DHCP Reservations** or **Static DHCP** (varies by router brand).
3. Find the server's MAC address and assign it a fixed IP (e.g. `192.168.1.10`).
The router always gives the same IP to that machine. No changes needed on the machine itself.
### Option B Static IP on the Machine
See your operating system's network settings to assign a static IP manually. Use an address outside the router's DHCP range to avoid conflicts. Set the gateway to your router's IP and DNS to `8.8.8.8` or your router's IP.
---
## Part 6 Changing the Port
If port 8000 is in use by something else, edit the last line of `main.py`:
```python
uvicorn.run("main:app", host="0.0.0.0", port=8080, reload=True)
```
Or pass it as an argument:
```bash
./run.sh --port 8080 # Linux / macOS
.venv\Scripts\python main.py --port 8080 # Windows
```
Devices would then connect to `http://192.168.1.42:8080`.
---
## Part 7 Keeping It Off the Internet
ClubLedger has no HTTPS, no brute-force protection, and no rate limiting. It is designed exclusively for use on a private local network. This section explains why it is safe by default, and how to make that guarantee explicit.
### Why it is already protected by default
Every home and business router uses **NAT (Network Address Translation)**. NAT means:
- Your router has one public IP address (assigned by your internet provider).
- All devices on your network share that one address.
- Inbound connections from the internet are **dropped by the router** unless you explicitly tell it to forward them.
ClubLedger listens on port 8000. Unless you specifically create a port-forwarding rule for port 8000 in your router settings, **no one on the internet can reach it**. This is the default state of every standard router.
### What never to do
- **Do not create a port-forwarding rule** for port 8000 (or whichever port ClubLedger uses) in your router's admin panel. This is the only action that would expose it.
- **Do not use a reverse proxy** (nginx, Caddy, Apache) to publish it under a public domain unless you have added authentication, HTTPS, and rate limiting first.
- **Do not run it on a cloud server** (VPS, AWS, etc.) without those same protections.
### Verify it is not reachable from outside
To confirm ClubLedger is not publicly accessible, check from a device that is **not on your Wi-Fi** (e.g. a phone on mobile data, or ask someone outside the building):
1. Find your public IP address — visit [https://ifconfig.me](https://ifconfig.me) from a computer on your network.
2. From the external device, try to open `http://<your-public-ip>:8000`.
3. It should time out or refuse the connection. If it loads ClubLedger, you have an unintended port-forwarding rule — remove it from your router immediately.
### Restrict the server to the local network interface (belt-and-suspenders)
By default ClubLedger binds to `0.0.0.0`, meaning it accepts connections on all network interfaces, including the local one. For extra certainty you can bind it only to your specific Wi-Fi interface IP so it cannot be reached even if a forwarding rule is accidentally added.
Find the server's local IP (e.g. `192.168.1.42`), then edit the last line of `main.py`:
```python
uvicorn.run("main:app", host="192.168.1.42", port=8000, reload=True)
```
With this change, the app only accepts connections from devices on your local network. Nothing outside the router can reach it regardless of router configuration.
> Note: if the server's local IP changes (e.g. after a reboot), the app will fail to start. Use this option only after setting a fixed/reserved IP (see Part 5).
### Add a firewall rule (optional, Linux only)
On Linux you can use `ufw` (Uncomplicated Firewall) to explicitly allow port 8000 only from your local network range and block everything else:
```bash
sudo ufw enable
# Allow from local network (adjust 192.168.1.0/24 to match your network)
sudo ufw allow from 192.168.1.0/24 to any port 8000
# Block port 8000 from everywhere else
sudo ufw deny 8000
sudo ufw status
```
This means even if your router accidentally forwarded port 8000, the firewall on the server itself would drop those packets.
To find your local network range: if your server's IP is `192.168.1.42`, your range is almost certainly `192.168.1.0/24`.
---
## Part 8 Backing Up Data
All club data is stored in a single SQLite file: `clubledger.db`. To back up:
1. **If the app is stopped:** copy `clubledger.db` to a safe location.
2. **If the app is running:** SQLite may be in WAL (Write-Ahead Log) mode. Copy all three files if they exist:
- `clubledger.db`
- `clubledger.db-wal`
- `clubledger.db-shm`
Copy all three together in one operation so the backup is consistent.
3. **Logo file:** if an admin has uploaded a club logo, also copy `static/logo.*` (e.g. `static/logo.png`). The exact extension depends on the file that was uploaded.
**To restore from a backup:**
1. Stop the server.
2. Replace `clubledger.db` (and `clubledger.db-wal` / `clubledger.db-shm` if present) with the backed-up copies.
3. Copy any backed-up logo file back to the `static/` folder.
4. Restart the server.
---
## Part 9 Security Notes
| Risk | Mitigation |
|---|---|
| Internet exposure | NAT protects by default — do not add port-forwarding rules (see Part 7) |
| Unauthorised Wi-Fi access | Use WPA2/WPA3 with a strong passphrase; consider a guest network for members separate from the staff network |
| Staff accessing admin settings | Use separate staff and admin accounts; do not share admin credentials |
| Weak member PINs | Encourage members to set at least 6-digit PINs |
| Data loss | Back up `clubledger.db` regularly — just copy the file |
| Accidental deletion | Delete only appears on zero-balance accounts; there is no bulk delete |
---
## Quick Reference
| Task | Command |
|---|---|
| Start the server (Linux/Mac) | `./run.sh` |
| Start the server (Windows) | `.venv\Scripts\python main.py` |
| Find server IP (Linux) | `hostname -I` |
| Find server IP (Mac) | `ipconfig getifaddr en0` |
| Find server IP (Windows) | `ipconfig` → IPv4 Address under Wi-Fi |
| Access from another device | `http://<server-ip>:8000` |
| Stop the server | Press `Ctrl+C` in the terminal |
| View systemd logs (Linux) | `journalctl -u clubledger -f` |
| Backup data | Copy `clubledger.db` to a safe location |
| Verify not internet-accessible | From mobile data: `http://<public-ip>:8000` should time out |
| Reset admin password | `python manage.py reset-admin` (app can be running) |
| Wipe database (start fresh) | Stop app first, then `python manage.py reset-db` |

343
docs/developer-guide.md Normal file
View file

@ -0,0 +1,343 @@
# ClubLedger Developer Guide
## Technology Stack
| Layer | Technology |
|---|---|
| Backend | Python 3.11+ · FastAPI · SQLite (via stdlib `sqlite3`) |
| Auth | bcrypt password hashing · in-memory session tokens · httpOnly cookies |
| Frontend | Vanilla HTML/CSS/JS — no build step, no framework |
| Dependencies | `fastapi`, `uvicorn[standard]`, `bcrypt`, `python-multipart` |
`python-multipart` is required for file upload support via FastAPI's `UploadFile`.
---
## Project Structure
```
ClubLedger/
├── main.py # Entire backend — one file
├── manage.py # CLI for database/admin management (reset-admin, reset-db)
├── requirements.txt # pip dependencies
├── run.sh # Start script (creates venv, installs deps, runs server)
├── clubledger.db # SQLite database (created on first run, git-ignored)
├── staff.json # Legacy staff name list (created on first use)
├── docs/
│ ├── user-guide.md
│ ├── admin-guide.md
│ ├── developer-guide.md
│ └── deployment.md
└── static/
├── index.html # Main SPA (Members / Cashier / Bar / Admin tabs)
├── style.css # All styles
├── app.js # Main SPA logic
├── common.js # Shared helpers (also used by standalone pages)
├── cashier.html # Standalone cashier page (/cashier)
├── cashier.js
├── bar.html # Standalone bar page (/bar)
├── bar.js
└── logo.* # Uploaded logo file (created when admin uploads a logo; git-ignored)
```
`main.py` is kept as a single file for simplicity. Split it only if it grows substantially.
---
## Running Locally
```bash
git clone <repo>
cd ClubLedger
./run.sh # creates .venv, installs deps, starts server on :8000
```
With auto-reload during development:
```bash
source .venv/bin/activate
uvicorn main:app --reload --port 8000
```
The default admin account (`admin` / `admin`) is printed to the console on first run.
---
## manage.py
The `manage.py` script provides CLI commands for server-side administration. Run it from the project root with the virtual environment active.
### `python manage.py reset-admin`
Interactively resets an admin account password from the terminal. Safe to run while the app is running (SQLite WAL mode). Uses `getpass` so the password is not echoed.
### `python manage.py reset-db`
Wipes `clubledger.db`, `clubledger.db-wal`, and `clubledger.db-shm`. Requires typing `RESET` to confirm. The app must be stopped before running this command.
---
## Database Schema
### `members`
| Column | Type | Notes |
|---|---|---|
| id | INTEGER PK | |
| member_number | TEXT UNIQUE | Human-readable ID |
| name | TEXT | |
| pin_hash | TEXT | bcrypt hash |
| overdraft_override | INTEGER | NULL = use global policy; 1 = override allowed; 0 = override blocked |
| created_at | TEXT | `datetime('now')` UTC |
### `ledger_entries`
| Column | Type | Notes |
|---|---|---|
| id | INTEGER PK | |
| member_id | INTEGER FK | → members.id |
| amount | INTEGER | Minor currency units — always positive |
| type | TEXT | `topup`, `charge`, or `withdrawal` |
| venue | TEXT | `cashier` or `bar` |
| note | TEXT | Optional free text |
| staff_name | TEXT | Name of logged-in staff at time of transaction |
| transfer_type | TEXT | Payment method for top-ups/withdrawals (e.g. "Cash") |
| transfer_ref | TEXT | Optional payment reference for top-ups/withdrawals |
| created_at | TEXT | UTC datetime |
Balance is computed on-the-fly: `SUM(topups) - SUM(charges) - SUM(withdrawals)`. There is no stored balance column — this avoids drift and makes the audit trail self-consistent.
### `staff_accounts`
| Column | Type | Notes |
|---|---|---|
| id | INTEGER PK | |
| name | TEXT | Display name, used as `staff_name` on transactions |
| username | TEXT UNIQUE | Login credential |
| password_hash | TEXT | bcrypt hash |
| role | TEXT | `pos-staff`, `cashier`, or `admin` |
| active | INTEGER | 0 or 1 |
| created_at | TEXT | |
On startup, any existing rows with `role = 'staff'` are automatically migrated to `role = 'pos-staff'`.
### `products`
| Column | Type | Notes |
|---|---|---|
| id | INTEGER PK | |
| name | TEXT | |
| brand | TEXT | Optional |
| price | INTEGER | Minor units |
| member_price | INTEGER | Optional discounted price |
| search_tags | TEXT | Space/comma separated search terms |
| active | INTEGER | 0 or 1 |
Products are searchable via `GET /products?q=<term>` and managed via `POST /products`. No UI exists yet — use the API directly or SQLite directly to seed products.
### `app_settings`
| Column | Type |
|---|---|
| key | TEXT PK |
| value | TEXT (JSON) |
Settings are loaded at startup into the module-level `_settings` dict and re-read after every admin save. The `CONFIG` dict in `main.py` provides fallback defaults.
---
## Settings System
```
CONFIG dict ← hard defaults in main.py
+
app_settings table ← admin overrides stored as JSON strings
_settings dict ← merged at startup and after each /admin/settings POST
format_amount() ← reads _settings at call time
/config endpoint ← returns _settings to the frontend on every page load
```
### CONFIG keys
| Key | Default / Notes |
|---|---|
| `club_name` | Club display name |
| `currency_symbol` | e.g. `£` |
| `currency_major` | e.g. `GBP` |
| `currency_minor` | e.g. `pence` |
| `currency_divisor` | e.g. `100` |
| `overdraft_policy` | `"never"` / `"always"` / `"staff-override"` / `"admin-override"` / `"staff-block"` |
| `min_topup` | Minimum top-up amount (minor units) |
| `max_topup` | Maximum top-up amount (minor units) |
| `max_charge` | Maximum single charge amount (minor units) |
| `biz_address1` `biz_address4` | Business address lines |
| `biz_country` | |
| `biz_phone` | |
| `biz_email` | |
| `biz_website` | |
| `logo_url` | URL path to uploaded logo (set automatically on upload) |
| `logo_align` | |
| `logo_max_width` | Default `200` |
| `logo_max_height` | Default `80` |
| `bar_name` | Default `"Bar"` |
| `cashier_name` | Default `"Cashier"` |
| `txn_ref_prefix` | Default `"TXN"` |
| `transfer_types` | Comma-separated string (e.g. `"Bank Transfer,Cash,QR"`); returned as an array by `/config` |
| `lbl_receipt` | Receipt label keys (14 total — see source for full list) |
| `lbl_topup_receipt` | |
| `lbl_withdrawal_receipt` | |
| `lbl_staff` | |
| `lbl_transaction` | |
| `lbl_charge_venue` | |
| `lbl_txn_time` | |
| `lbl_amount_charged` | |
| `lbl_remaining_balance` | |
| `lbl_balance_transfer` | |
| `lbl_amount_topup` | |
| `lbl_amount_withdrawal` | |
| `lbl_transfer_type` | |
| `lbl_transfer_ref` | |
| `receipt_footer` | Footer text for all receipts |
| `receipt_footer_charge` | Override footer for charge receipts |
| `receipt_footer_cashier` | Override footer for cashier receipts |
| `timezone` | IANA timezone name; defaults to server local timezone via `_server_timezone()` |
To add a new configurable value:
1. Add a default to `CONFIG`
2. Add the field to the `AppSettingsUpdate` Pydantic model
3. Add the input to the Admin settings form in `index.html`
4. Load and save it in `loadAdminSettings()` / `saveSettings()` in `app.js`
---
## Auth System
- `POST /auth/login` validates credentials against `staff_accounts`, creates a `secrets.token_hex(32)` token, stores it in the module-level `_sessions` dict, and sets it as an `httpOnly` cookie.
- All protected endpoints use a `Depends()` guard appropriate to the required role:
- `Depends(pos_user)` — allows `pos-staff` and `admin`. Used by bar endpoints.
- `Depends(cashier_user)` — allows `cashier` and `admin`. Used by cashier endpoints.
- `Depends(admin_user)``admin` only.
- Sessions expire after 8 hours (configurable via `SESSION_TTL` in `main.py`).
- Sessions are lost on server restart (in-memory). This is intentional for simplicity; upgrade to a DB-backed session store if persistence is needed.
- Print views (`/receipt/`, `/members/*/statement`) have no auth — they are opened as pop-up tabs from an authenticated session.
---
## API Reference
All endpoints except `/config`, `/auth/login`, and the print views require a valid session cookie.
### Auth
| Method | Path | Body | Returns |
|---|---|---|---|
| POST | `/auth/login` | `{username, password}` | `{name, role}` + sets cookie |
| POST | `/auth/logout` | — | `{ok}` + clears cookie |
| GET | `/auth/me` | — | `{name, role}` |
### Members
| Method | Path | Notes |
|---|---|---|
| GET | `/members?q=` | List/search. Returns balance per member. |
| POST | `/members` | `{member_number, name, pin}` |
| PUT | `/members/{id}` | `{member_number?, name?, pin?, overdraft_override?}` — all optional |
| DELETE | `/members/{id}` | Blocked if balance ≠ 0 |
| GET | `/members/{id}/transactions` | `?limit=50&offset=0` |
| GET | `/members/{id}/statement` | Returns printable HTML. No auth. |
### Transactions
| Method | Path | Body |
|---|---|---|
| POST | `/topup` | `{member_id, amount, transfer_type?, transfer_ref?, note?}` — amount in minor units |
| POST | `/charge` | `{member_id, amount, pin, note?}` |
| POST | `/withdrawal` | `{member_id, amount, pin, transfer_type?, transfer_ref?, note?}` |
All return `{ok, entry_id, new_balance, new_balance_display}`.
### Receipts
| Method | Path | Notes |
|---|---|---|
| GET | `/receipt/{entry_id}` | Returns printable HTML. No auth. |
### Products
| Method | Path | Notes |
|---|---|---|
| GET | `/products?q=` | Search by name/brand/tags |
| POST | `/products` | `{name, brand?, price, member_price?, search_tags?}` |
### Admin
| Method | Path | Notes |
|---|---|---|
| GET | `/admin/settings` | Admin only |
| POST | `/admin/settings` | Admin only. Partial update — only sent fields are changed. |
| POST | `/admin/logo` | Admin only. Multipart file upload. Saves image to `static/logo.<ext>`, updates `logo_url` setting, returns `{url}`. Accepts PNG, JPG, GIF, WebP, SVG. |
| GET | `/admin/staff-accounts` | Admin only |
| POST | `/admin/staff-accounts` | `{name, username, password, role}` |
| PUT | `/admin/staff-accounts/{id}` | All fields optional |
| DELETE | `/admin/staff-accounts/{id}` | Cannot delete self or last admin |
### Config (public)
| Method | Path | Notes |
|---|---|---|
| GET | `/config` | Returns live `_settings`. Called by the frontend on every page load. `transfer_types` is returned as an array. |
---
## Frontend Architecture
`index.html` loads `common.js` then `app.js`.
- **`common.js`** — shared utilities: `loadConfig()`, `apiFetch()`, `esc()`, `fmtAmount()`, `balanceClass()`, `setMsg()`, and the staff name dropdown helpers (used only by the standalone cashier/bar pages).
- **`app.js`** — all SPA logic: auth flow, tab switching, Members/Cashier/Bar/Admin views, modal management.
The boot sequence in `app.js`:
1. `loadConfig()` — fetches `/config` so the login screen shows the club name.
2. `GET /auth/me` — if 401, show login overlay and stop.
3. On successful login or existing session: `startApp()` which wires up all event listeners and loads the members list.
Amount conversion: `toMinor(inputId)` reads a decimal input and multiplies by `cfg.currency_divisor` before posting. All amounts sent to the API are in minor units.
---
## Extending the App
### Adding a new setting
See the Settings System section above.
### Adding a new transaction venue
1. Add the new venue value to the `CHECK` constraint in the `ledger_entries` schema — requires a migration or database recreation.
2. Add a new endpoint (or extend existing transaction endpoints with a `venue` parameter).
3. Add a new tab or form in `index.html` / `app.js`.
### Adding product management UI
The `/products` API exists but only the GET endpoint is exposed in the main UI (products were removed from the bar tab). A products management panel in the Admin tab would follow the same pattern as Staff Accounts.
### Persistent sessions
Replace the `_sessions` dict with a `sessions` table in SQLite:
```sql
CREATE TABLE sessions (
token TEXT PRIMARY KEY,
user_id INTEGER REFERENCES staff_accounts(id),
expires TEXT
);
```
Adjust `current_user()` to query the table instead of the dict.
---
## Environment Notes
- The database file `clubledger.db` is created automatically in the working directory on first run. Add it to `.gitignore`.
- `staff.json` is also created in the working directory. Add to `.gitignore`.
- `static/logo.*` is created when an admin uploads a logo via the Admin panel. Add to `.gitignore`.
- No environment variables are required. All configuration is in `CONFIG` (code) or `app_settings` (database).
- The app binds to `0.0.0.0:8000` by default — accessible from any device on the network. Pass `--host 127.0.0.1` to restrict to localhost only.

219
docs/user-guide.md Normal file
View file

@ -0,0 +1,219 @@
# ClubLedger Staff User Guide
## What is ClubLedger?
ClubLedger is a store-credit system for clubs and venues. Members load credit onto their account at the cashier desk, then spend it at the bar or other service points. All transactions are tracked and receipts are generated automatically.
---
## Signing In
Open the ClubLedger address in any web browser. Enter the username and password provided by your administrator, then click **Sign In**.
Your name appears in the top corner of every screen while you are signed in. Click **Sign out** when you are finished.
> **Sessions expire after 8 hours.** The sign-in screen reappears automatically when your session ends. If the server is restarted, everyone is logged out regardless of how long they have been signed in.
---
## Tabs and Roles
The navigation bar shows tabs depending on your role. You will only see the tabs listed for your role below.
| Role | Tabs visible |
|---|---|
| **POS Staff** | Members, Bar |
| **Cashier** | Members, Cashier |
| **Admin** | Members, Cashier, Bar, Admin |
Click any tab to switch to it.
---
## Members Tab
All roles can see this tab. Use it to register new members, search for existing members, and manage member records.
### Registering a New Member
Fill in the **Register New Member** form at the top of the tab:
| Field | Notes |
|---|---|
| Member Number | A unique identifier — a number, a code, or any text your venue uses |
| Full Name | The member's name as it will appear on receipts and statements |
| PIN | A secret code (minimum 4 characters) the member uses to authorise charges. Tell the member their PIN privately. |
Click **Register**. The new member appears in the table below.
### Searching for a Member
Type part of a name or member number into the search box and click **Search** (or press **Enter**). Searching with an empty box lists all members.
### The Member Table
Search results appear in a table with these columns:
| Column | Meaning |
|---|---|
| # | Member number |
| Name | Full name |
| Balance | Current account balance |
| Joined | Registration date |
| Actions | Buttons to act on this member |
### Actions
Each row has up to three action buttons:
| Button | What it does |
|---|---|
| **Statement** | Opens a printable full transaction history in a new tab |
| **Edit** | Opens a modal to change the member's details |
| **Delete** | Permanently removes the member. Only appears when the balance is exactly zero. |
### Editing a Member
Click **Edit** on a row. A modal appears with the current details pre-filled.
| Field | Notes |
|---|---|
| Member Number | Change the member's unique ID if needed |
| Full Name | Update the name |
| New PIN | Enter a new PIN to change it. Leave blank to keep the existing PIN. |
| Overdraft override | May appear depending on the global overdraft policy and your role — see the Overdraft section below. |
Click **Save** to apply changes or close the modal to cancel.
### Printing a Statement
Click **Statement** on any member's row. The full transaction history opens in a new tab. Use the **A4 / A5** toggle to select paper size, then print from your browser.
---
## Cashier Tab
Cashiers and Admins can see this tab. Use it to add credit to a member's account (top-up) or withdraw credit from it.
### Selecting a Member
Search for the member and click their row to select them. Their name and current balance appear at the top of the panels. Click **Cancel** at any time to deselect the member and clear all fields.
### Top Up Panel
Use this panel to add credit to the member's account.
| Field | Notes |
|---|---|
| Amount | The amount to add, in the major currency unit (e.g. `10.00`) |
| Transfer Type | How the payment was made — options are configured by your administrator (e.g. Bank Transfer, Cash, QR) |
| Transfer Reference | Optional. A reference for your records, such as a payment reference number |
| Note | Optional. Any additional note about this transaction |
Click **Top Up**. A receipt opens automatically in a new tab.
### Withdrawal Panel
Use this panel to remove credit from the member's account.
| Field | Notes |
|---|---|
| Amount | The amount to withdraw |
| Member PIN | Required. The member must provide their PIN to authorise every withdrawal. |
| Transfer Type | How the funds are being returned — options configured by your administrator |
| Transfer Reference | Optional. A reference for your records |
| Note | Optional. Any additional note |
Click **Withdraw**. A receipt opens automatically in a new tab.
---
## Bar Tab
POS Staff and Admins can see this tab. Use it to charge a member's account for purchases.
### How to Charge
1. Search for the member by name or number and click their row.
2. Enter the **Amount** to charge.
3. Enter the member's **PIN** — this is always required.
4. Optionally add a **Note** (for example, what was purchased).
5. Click **Charge**.
If the PIN is incorrect, an error appears and nothing is charged. If the balance is insufficient, the charge is blocked unless the member has overdraft permission (see the Overdraft section).
A receipt opens automatically in a new tab on a successful charge.
Click **Cancel** to deselect the member and clear all fields.
---
## Receipts and Statements
### Receipts
A receipt opens in a new tab automatically after every successful transaction. Receipts include:
- Business header (logo, name, address, contact details)
- Receipt title and transaction reference (e.g. `TXN0000001`)
- Staff name who processed the transaction
- Venue and timestamp (in the configured timezone)
- Amount and remaining balance
- For top-ups and withdrawals: the transfer type and transfer reference
Use the **A4 / A5** toggle at the top of the receipt before printing.
> **Tip:** If the receipt tab does not open, your browser may be blocking pop-ups. Allow pop-ups for this site in your browser settings.
### Statements
Statements are accessed via the **Statement** button in the Members tab. They show the member's complete transaction history as a table:
| Column | Content |
|---|---|
| Date/Time | When the transaction occurred (configured timezone) |
| Reference | Transaction reference (e.g. `TXN0000001`) |
| Type | Top-up, Withdrawal, or Charge |
| Venue | Where the transaction was processed |
| Staff | Who processed the transaction |
| Amount | Positive for top-ups, negative for charges and withdrawals |
| Balance | Account balance after that transaction |
Each transaction also has a second row showing transfer details (for top-ups and withdrawals) or the note (for charges).
Use the **A4 / A5** toggle before printing.
---
## Overdraft
By default, charges that would take a member's balance below zero are blocked. Your administrator can change this behaviour using a global overdraft policy. The policy affects what you see in the **Edit Member** modal:
| Policy | What you see in Edit Member | What it means |
|---|---|---|
| Never allowed | No checkbox | No member can go into overdraft, ever |
| Always allowed | No checkbox | All members can always go into overdraft |
| Staff override | Checkbox (staff can tick it) | Ticking the checkbox for a member allows them to go into overdraft |
| Admin override | Checkbox (only admins can tick it) | Same as above, but only admins can set it |
| Staff block | Checkbox (staff can tick it) | Ticking the checkbox for a member blocks them from overdraft |
If you are unsure whether a member should be allowed to go into overdraft, check with your administrator before changing any checkbox.
---
## Common Questions
**The member forgot their PIN.**
An admin or cashier with edit access can reset it: Members tab → Edit → enter a new PIN in the **New PIN** field → Save. Leave the field blank if you do not want to change it.
**I entered the wrong amount.**
There is no undo button. A correcting transaction must be applied manually. For a top-up error, process a withdrawal for the difference (or the full amount and re-top-up correctly). For a bar charge error, contact an administrator.
**The receipt tab did not open.**
Your browser is likely blocking pop-ups. Find the pop-up blocked notification in your browser's address bar and allow pop-ups for this site, then try the transaction again.
**A member's balance is wrong.**
Use the **Statement** button on the member's row to view their full transaction history and identify any discrepancies. Contact an administrator if a correction is needed.
**I cannot see a tab I expect.**
Tab visibility depends on your role. If you believe your role is incorrect, contact your administrator.

1208
main.py Normal file

File diff suppressed because it is too large Load diff

141
manage.py Normal file
View file

@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""
ClubLedger management CLI
Run from the server terminal (NOT through the web UI).
The app does not need to be stopped first for reset-admin;
it MUST be stopped before reset-db.
Usage:
python manage.py reset-admin reset an admin account password
python manage.py reset-db wipe all data and start fresh
"""
import sys
import getpass
import sqlite3
from pathlib import Path
DB_PATH = Path("clubledger.db")
def _connect():
if not DB_PATH.exists():
print(f"Database not found: {DB_PATH}")
print("Start the app at least once to create it.")
sys.exit(1)
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
return conn
def cmd_reset_admin():
"""Interactively reset the password for an admin account."""
import bcrypt
conn = _connect()
admins = conn.execute(
"SELECT id, name, username FROM staff_accounts WHERE role='admin' ORDER BY name"
).fetchall()
if not admins:
print("No admin accounts exist.")
print("Start the app — it will create the default admin/admin account automatically.")
conn.close()
sys.exit(0)
if len(admins) == 1:
target = admins[0]
else:
print("Admin accounts:")
for i, a in enumerate(admins, 1):
print(f" {i}. {a['name']} ({a['username']})")
while True:
raw = input("Select account number: ").strip()
try:
target = admins[int(raw) - 1]
break
except (ValueError, IndexError):
print(" Invalid — enter the number shown above.")
print(f"\nResetting password for: {target['name']} ({target['username']})")
while True:
pw = getpass.getpass("New password: ")
if len(pw) < 4:
print(" Password must be at least 4 characters.")
continue
pw2 = getpass.getpass("Confirm password: ")
if pw != pw2:
print(" Passwords do not match — try again.")
continue
break
hashed = bcrypt.hashpw(pw.encode(), bcrypt.gensalt()).decode()
conn.execute(
"UPDATE staff_accounts SET password_hash=? WHERE id=?",
(hashed, target["id"]),
)
conn.commit()
conn.close()
print(f"\nDone. Password updated for '{target['username']}'.")
print("Any existing sessions for this account will still be valid until they expire (8 h).")
print("Restart the app now to invalidate all active sessions immediately.")
def cmd_reset_db():
"""Delete all data files and prepare for a clean start."""
print("=" * 60)
print(" DATABASE RESET")
print("=" * 60)
print()
print("This will permanently delete:")
print(" • All members and their balances")
print(" • All transactions and receipts")
print(" • All staff accounts")
print(" • All app settings")
print()
print("STOP THE APP before continuing.")
print()
confirm = input('Type RESET to confirm (anything else cancels): ').strip()
if confirm != "RESET":
print("Cancelled — nothing was changed.")
sys.exit(0)
deleted = []
for suffix in ("", "-wal", "-shm"):
p = DB_PATH.parent / (DB_PATH.name + suffix)
if p.exists():
p.unlink()
deleted.append(p.name)
if deleted:
print(f"\nDeleted: {', '.join(deleted)}")
else:
print("\nNo database files found — nothing to delete.")
print()
print("Reset complete. Start the app to create a fresh database.")
print("Default admin credentials after restart: username=admin password=admin")
print("Change the password immediately after logging in.")
# ---------------------------------------------------------------------------
COMMANDS = {
"reset-admin": (cmd_reset_admin, "Reset an admin account password"),
"reset-db": (cmd_reset_db, "Wipe all data and start fresh (irreversible)"),
}
def usage():
print(__doc__)
print("Commands:")
for name, (_, desc) in COMMANDS.items():
print(f" {name:<16} {desc}")
sys.exit(1)
if __name__ == "__main__":
if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
usage()
COMMANDS[sys.argv[1]][0]()

4
requirements.txt Normal file
View file

@ -0,0 +1,4 @@
fastapi
uvicorn[standard]
bcrypt
python-multipart

3
run.bat Normal file
View file

@ -0,0 +1,3 @@
python -m venv .venv
.venv\Scripts\pip install -r requirements.txt
.venv\Scripts\python main.py

14
run.sh Normal file
View file

@ -0,0 +1,14 @@
#!/bin/bash
# Run ClubLedger creates a virtualenv if needed, ensures deps are installed, starts the server.
set -e
VENV=".venv"
if [ ! -d "$VENV" ]; then
echo "Creating virtual environment..."
python3 -m venv "$VENV"
fi
"$VENV/bin/pip" install -r requirements.txt
exec "$VENV/bin/python" main.py "$@"

642
static/app.js Normal file
View file

@ -0,0 +1,642 @@
/* ClubLedger main SPA */
let currentUser = null;
let cashierMember = null;
let barMember = null;
let editMemberId = null;
let editAccountId = null;
// ---------------------------------------------------------------------------
// Boot check session, then either show login or start the app
// ---------------------------------------------------------------------------
(async function boot() {
// Load config first so the login page shows the club name
await loadConfig();
document.getElementById('loginBrand').textContent = cfg.club_name;
let me = null;
try { me = await apiFetch('/auth/me'); } catch (e) { /* not logged in */ }
if (!me) { showLogin(); return; }
currentUser = me;
await startApp();
})();
function showLogin() {
document.getElementById('loginOverlay').classList.remove('hidden');
document.getElementById('loginUsername').focus();
document.getElementById('loginForm').addEventListener('submit', doLogin, { once: true });
}
async function doLogin(e) {
e.preventDefault();
const username = document.getElementById('loginUsername').value.trim();
const password = document.getElementById('loginPassword').value;
try {
currentUser = await apiFetch('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
document.getElementById('loginOverlay').classList.add('hidden');
await startApp();
} catch (err) {
setMsg('loginMsg', err.message, 'err');
document.getElementById('loginForm').addEventListener('submit', doLogin, { once: true });
}
}
async function doLogout() {
try { await fetch('/auth/logout', { method: 'POST' }); } catch (e) { /* ignore */ }
currentUser = null;
// Reset tab visibility for next login
document.getElementById('adminTabBtn').classList.add('hidden');
document.querySelector('[data-view="cashier"]').classList.remove('hidden');
document.querySelector('[data-view="bar"]').classList.remove('hidden');
// Reset to members tab
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
document.querySelector('[data-view="members"]').classList.add('active');
document.querySelectorAll('.view').forEach(v => v.classList.add('hidden'));
document.getElementById('view-members').classList.remove('hidden');
showLogin();
}
function populateTransferTypes() {
const types = Array.isArray(cfg.transfer_types) ? cfg.transfer_types : [];
['cashierTransferType', 'withdrawalTransferType'].forEach(id => {
const sel = document.getElementById(id);
if (!sel) return;
const prev = sel.value;
sel.innerHTML = '<option value="">&mdash; select &mdash;</option>' +
types.map(t => `<option value="${esc(t)}">${esc(t)}</option>`).join('');
if (prev && types.includes(prev)) sel.value = prev;
});
}
async function startApp() {
document.getElementById('loginOverlay').classList.add('hidden');
await loadConfig();
populateTransferTypes();
const brand = document.getElementById('navBrand');
if (brand) brand.textContent = cfg.club_name;
document.getElementById('navUser').textContent = currentUser.name;
// Role-based tab visibility
if (currentUser.role === 'admin') {
document.getElementById('adminTabBtn').classList.remove('hidden');
}
if (currentUser.role === 'pos-staff') {
document.querySelector('[data-view="cashier"]').classList.add('hidden');
}
if (currentUser.role === 'cashier') {
document.querySelector('[data-view="bar"]').classList.add('hidden');
}
// Nav tab switching
document.querySelectorAll('.nav-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.querySelectorAll('.view').forEach(v => v.classList.add('hidden'));
document.getElementById('view-' + btn.dataset.view).classList.remove('hidden');
if (btn.dataset.view === 'admin') loadAdminView();
});
});
// Form submit handlers
document.getElementById('registerForm').addEventListener('submit', e => { e.preventDefault(); registerMember(); });
document.getElementById('editForm').addEventListener('submit', e => { e.preventDefault(); saveEdit(); });
document.getElementById('editAccountForm').addEventListener('submit', e => { e.preventDefault(); saveEditAccount(); });
document.getElementById('settingsForm').addEventListener('submit', e => { e.preventDefault(); saveSettings(); });
document.getElementById('addAccountForm').addEventListener('submit', e => { e.preventDefault(); addAccount(); });
// Enter-key on search inputs
document.getElementById('memberSearch').addEventListener('keydown', e => { if (e.key === 'Enter') searchMembers(); });
document.getElementById('cashierSearch').addEventListener('keydown', e => { if (e.key === 'Enter') cashierSearchMembers(); });
document.getElementById('barSearch').addEventListener('keydown', e => { if (e.key === 'Enter') barSearchMembers(); });
searchMembers();
}
// ---------------------------------------------------------------------------
// Amount helpers (users enter major units, we send minor units)
// ---------------------------------------------------------------------------
const ROLE_LABELS = { admin: 'Admin', cashier: 'Cashier', 'pos-staff': 'POS Staff' };
function fmtRole(role) { return ROLE_LABELS[role] || role; }
function toMinor(inputId) {
const v = parseFloat(document.getElementById(inputId).value);
if (isNaN(v) || v <= 0) return null;
return Math.round(v * cfg.currency_divisor);
}
// ---------------------------------------------------------------------------
// Members view
// ---------------------------------------------------------------------------
async function registerMember() {
const number = document.getElementById('reg-number').value.trim();
const name = document.getElementById('reg-name').value.trim();
const pin = document.getElementById('reg-pin').value;
if (!number || !name || !pin) { setMsg('registerMsg', 'All fields required.', 'err'); return; }
try {
const m = await apiFetch('/members', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ member_number: number, name, pin })
});
setMsg('registerMsg', `Registered: ${m.name} (#${m.member_number})`, 'ok');
document.getElementById('registerForm').reset();
searchMembers();
} catch (err) { setMsg('registerMsg', err.message, 'err'); }
}
async function searchMembers() {
const q = document.getElementById('memberSearch').value.trim();
try {
const members = await apiFetch(q ? `/members?q=${encodeURIComponent(q)}` : '/members');
renderMemberTable(members);
} catch (e) { console.error(e); }
}
function renderMemberTable(members) {
const tbody = document.querySelector('#memberTable tbody');
if (!members.length) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:#888">No members found</td></tr>';
return;
}
tbody.innerHTML = members.map(m => `
<tr>
<td>${esc(m.member_number)}</td>
<td>${esc(m.name)}</td>
<td class="num ${balanceClass(m.balance)}">${esc(m.balance_display)}</td>
<td>${m.created_at ? m.created_at.slice(0, 10) : ''}</td>
<td class="row-actions">
<a href="/members/${m.id}/statement" target="_blank" class="btn row-btn">Statement</a>
<button class="btn row-btn" onclick="openEditModal(${m.id},'${esc(m.name)}','${esc(m.member_number)}',${JSON.stringify(m.overdraft_override)})">Edit</button>
${m.balance === 0
? `<button class="btn btn-danger row-btn" onclick="deleteMember(${m.id},'${esc(m.name)}')">Delete</button>`
: ''}
</td>
</tr>`).join('');
}
// Edit member modal
function openEditModal(id, name, number, overdraftOverride) {
editMemberId = id;
document.getElementById('edit-number').value = number;
document.getElementById('edit-name').value = name;
document.getElementById('edit-pin').value = '';
setMsg('editMsg', '', '');
const policy = cfg.overdraft_policy || 'never';
const overrideRow = document.getElementById('editOverdraftRow');
const overrideCheck = document.getElementById('edit-overdraft');
const overrideLabel = document.getElementById('editOverdraftLabel');
if (policy === 'staff_override' || (policy === 'admin_override' && currentUser.role === 'admin')) {
overrideLabel.textContent = 'Allow overdraft for this member';
overrideCheck.checked = (overdraftOverride === 1);
overrideRow.classList.remove('hidden');
} else if (policy === 'staff_block') {
overrideLabel.textContent = 'Block overdraft for this member';
overrideCheck.checked = (overdraftOverride === 0);
overrideRow.classList.remove('hidden');
} else {
overrideRow.classList.add('hidden');
}
document.getElementById('editModal').classList.remove('hidden');
document.getElementById('edit-name').focus();
}
function closeEditModal() {
editMemberId = null;
document.getElementById('editModal').classList.add('hidden');
}
async function saveEdit() {
if (!editMemberId) return;
const body = {
member_number: document.getElementById('edit-number').value.trim(),
name: document.getElementById('edit-name').value.trim(),
};
const pin = document.getElementById('edit-pin').value;
if (pin) body.pin = pin;
const policy = cfg.overdraft_policy || 'never';
const overrideRow = document.getElementById('editOverdraftRow');
if (!overrideRow.classList.contains('hidden')) {
const checked = document.getElementById('edit-overdraft').checked;
if (policy === 'staff_block') {
body.overdraft_override = checked ? 0 : null;
} else {
body.overdraft_override = checked ? 1 : null;
}
}
try {
await apiFetch(`/members/${editMemberId}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
closeEditModal();
searchMembers();
} catch (err) { setMsg('editMsg', err.message, 'err'); }
}
async function deleteMember(id, name) {
if (!confirm(`Delete member "${name}"?\n\nThis permanently removes their account and transaction history.`)) return;
try {
await apiFetch(`/members/${id}`, { method: 'DELETE' });
searchMembers();
} catch (err) { alert(err.message); }
}
// ---------------------------------------------------------------------------
// Cashier view
// ---------------------------------------------------------------------------
async function cashierSearchMembers() {
const q = document.getElementById('cashierSearch').value.trim();
try {
const members = await apiFetch(q ? `/members?q=${encodeURIComponent(q)}` : '/members');
document.getElementById('cashierMemberList').innerHTML = members.map(m => `
<div class="member-pick-item" onclick="selectCashierMember(${m.id},'${esc(m.name)}','${esc(m.member_number)}',${m.balance},'${esc(m.balance_display)}')">
<div><div class="member-pick-name">${esc(m.name)}</div><div class="member-pick-sub">#${esc(m.member_number)}</div></div>
<div class="${balanceClass(m.balance)}">${esc(m.balance_display)}</div>
</div>`).join('');
} catch (e) { console.error(e); }
}
function selectCashierMember(id, name, number, balance, balanceDisplay) {
cashierMember = { id, name, number };
document.getElementById('cashierMemberList').innerHTML = '';
document.getElementById('cashierSelected').innerHTML =
`<strong>${esc(name)}</strong> &nbsp; #${esc(number)} &nbsp; Balance: <span class="${balanceClass(balance)}">${esc(balanceDisplay)}</span>`;
document.getElementById('cashierForm').classList.remove('hidden');
setMsg('cashierTopupMsg', '', '');
setMsg('cashierWithdrawalMsg', '', '');
setMsg('cashierMsg', '', '');
}
function clearCashierSelection() {
cashierMember = null;
document.getElementById('cashierForm').classList.add('hidden');
document.getElementById('cashierAmount').value = '';
document.getElementById('cashierTransferType').value = '';
document.getElementById('cashierTransferRef').value = '';
document.getElementById('cashierNote').value = '';
document.getElementById('withdrawalAmount').value = '';
document.getElementById('withdrawalPin').value = '';
document.getElementById('withdrawalTransferType').value = '';
document.getElementById('withdrawalTransferRef').value = '';
document.getElementById('withdrawalNote').value = '';
setMsg('cashierTopupMsg', '', '');
setMsg('cashierWithdrawalMsg', '', '');
setMsg('cashierMsg', '', '');
}
async function doTopup() {
if (!cashierMember) return;
const amount = toMinor('cashierAmount');
const transferType = document.getElementById('cashierTransferType').value || null;
const transferRef = document.getElementById('cashierTransferRef').value.trim() || null;
const note = document.getElementById('cashierNote').value.trim() || null;
if (!amount) { setMsg('cashierTopupMsg', 'Enter a valid amount.', 'err'); return; }
try {
const r = await apiFetch('/topup', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ member_id: cashierMember.id, amount, note,
transfer_type: transferType, transfer_ref: transferRef })
});
window.open(`/receipt/${r.entry_id}`, '_blank');
document.getElementById('cashierAmount').value = '';
document.getElementById('cashierTransferType').value = '';
document.getElementById('cashierTransferRef').value = '';
document.getElementById('cashierNote').value = '';
setMsg('cashierTopupMsg', `Top-up complete. New balance: ${r.new_balance_display}`, 'ok');
} catch (err) { setMsg('cashierTopupMsg', err.message, 'err'); }
}
async function doWithdrawal() {
if (!cashierMember) return;
const amount = toMinor('withdrawalAmount');
const pin = document.getElementById('withdrawalPin').value;
const transferType = document.getElementById('withdrawalTransferType').value || null;
const transferRef = document.getElementById('withdrawalTransferRef').value.trim() || null;
const note = document.getElementById('withdrawalNote').value.trim() || null;
if (!amount) { setMsg('cashierWithdrawalMsg', 'Enter a valid amount.', 'err'); return; }
if (!pin) { setMsg('cashierWithdrawalMsg', 'PIN is required.', 'err'); return; }
try {
const r = await apiFetch('/withdrawal', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ member_id: cashierMember.id, amount, pin, note,
transfer_type: transferType, transfer_ref: transferRef })
});
window.open(`/receipt/${r.entry_id}`, '_blank');
document.getElementById('withdrawalAmount').value = '';
document.getElementById('withdrawalPin').value = '';
document.getElementById('withdrawalTransferType').value = '';
document.getElementById('withdrawalTransferRef').value = '';
document.getElementById('withdrawalNote').value = '';
setMsg('cashierWithdrawalMsg', `Withdrawal complete. New balance: ${r.new_balance_display}`, 'ok');
} catch (err) { setMsg('cashierWithdrawalMsg', err.message, 'err'); }
}
// ---------------------------------------------------------------------------
// Bar view
// ---------------------------------------------------------------------------
async function barSearchMembers() {
const q = document.getElementById('barSearch').value.trim();
try {
const members = await apiFetch(q ? `/members?q=${encodeURIComponent(q)}` : '/members');
document.getElementById('barMemberList').innerHTML = members.map(m => `
<div class="member-pick-item" onclick="selectBarMember(${m.id},'${esc(m.name)}','${esc(m.member_number)}',${m.balance},'${esc(m.balance_display)}')">
<div><div class="member-pick-name">${esc(m.name)}</div><div class="member-pick-sub">#${esc(m.member_number)}</div></div>
<div class="${balanceClass(m.balance)}">${esc(m.balance_display)}</div>
</div>`).join('');
} catch (e) { console.error(e); }
}
function selectBarMember(id, name, number, balance, balanceDisplay) {
barMember = { id, name, number };
document.getElementById('barMemberList').innerHTML = '';
document.getElementById('barSelected').innerHTML =
`<strong>${esc(name)}</strong> &nbsp; #${esc(number)} &nbsp; Balance: <span class="${balanceClass(balance)}">${esc(balanceDisplay)}</span>`;
document.getElementById('barForm').classList.remove('hidden');
setMsg('barMsg', '', '');
}
function clearBarSelection() {
barMember = null;
document.getElementById('barForm').classList.add('hidden');
document.getElementById('barAmount').value = '';
document.getElementById('barPin').value = '';
document.getElementById('barNote').value = '';
setMsg('barMsg', '', '');
}
async function doCharge() {
if (!barMember) return;
const amount = toMinor('barAmount');
const pin = document.getElementById('barPin').value;
const note = document.getElementById('barNote').value.trim();
if (!amount) { setMsg('barMsg', 'Enter a valid amount.', 'err'); return; }
if (!pin) { setMsg('barMsg', 'PIN required.', 'err'); return; }
try {
const r = await apiFetch('/charge', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ member_id: barMember.id, amount, pin, note: note || null })
});
window.open(`/receipt/${r.entry_id}`, '_blank');
setMsg('barMsg', `Charge complete. New balance: ${r.new_balance_display}`, 'ok');
clearBarSelection();
} catch (err) { setMsg('barMsg', err.message, 'err'); }
}
// ---------------------------------------------------------------------------
// Admin view
// ---------------------------------------------------------------------------
async function loadAdminView() {
await Promise.all([loadAdminSettings(), loadStaffAccounts()]);
setupLogoUpload();
}
let _logoUploadWired = false;
function setupLogoUpload() {
if (_logoUploadWired) return;
const input = document.getElementById('s-logo-upload');
if (!input) return;
_logoUploadWired = true;
input.addEventListener('change', async function() {
const file = this.files[0];
if (!file) return;
const fd = new FormData();
fd.append('file', file);
try {
const r = await fetch('/admin/logo', { method: 'POST', body: fd });
const json = await r.json();
if (!r.ok) throw new Error(json.detail || 'Upload failed');
document.getElementById('s-logo-url').value = json.url;
setMsg('logoUploadMsg', 'Logo uploaded.', 'ok');
} catch (e) { setMsg('logoUploadMsg', e.message, 'err'); }
this.value = '';
});
}
async function loadAdminSettings() {
try {
const s = await apiFetch('/admin/settings');
const div = s.currency_divisor || 100;
const majorUnit = s.currency_major || 'major units';
// General
document.getElementById('s-club-name').value = s.club_name || '';
document.getElementById('s-currency-symbol').value = s.currency_symbol || '';
document.getElementById('s-currency-major').value = s.currency_major || '';
document.getElementById('s-currency-minor').value = s.currency_minor || '';
document.getElementById('s-currency-divisor').value = div;
document.getElementById('s-min-topup').value = ((s.min_topup || 0) / div).toFixed(2);
document.getElementById('s-max-topup').value = ((s.max_topup || 0) / div).toFixed(2);
document.getElementById('s-max-charge').value = ((s.max_charge || 0) / div).toFixed(2);
document.getElementById('s-overdraft-policy').value = s.overdraft_policy || 'never';
document.getElementById('s-timezone').value = s.timezone || '';
document.getElementById('s-paper-size').value = s.paper_size || 'A4';
document.getElementById('s-min-hint').textContent = `in ${majorUnit}`;
document.getElementById('s-max-hint').textContent = `in ${majorUnit}`;
document.getElementById('s-charge-hint').textContent= `in ${majorUnit}`;
// Business address
document.getElementById('s-biz-address1').value = s.biz_address1 || '';
document.getElementById('s-biz-address2').value = s.biz_address2 || '';
document.getElementById('s-biz-address3').value = s.biz_address3 || '';
document.getElementById('s-biz-address4').value = s.biz_address4 || '';
document.getElementById('s-biz-country').value = s.biz_country || '';
document.getElementById('s-biz-phone').value = s.biz_phone || '';
document.getElementById('s-biz-email').value = s.biz_email || '';
document.getElementById('s-biz-website').value = s.biz_website || '';
// Branding
document.getElementById('s-logo-url').value = s.logo_url || '';
document.getElementById('s-logo-align').value = s.logo_align || 'left';
document.getElementById('s-logo-max-width').value = s.logo_max_width || '';
document.getElementById('s-logo-max-height').value = s.logo_max_height || '';
document.getElementById('s-bar-name').value = s.bar_name || '';
document.getElementById('s-cashier-name').value = s.cashier_name || '';
// Transactions
document.getElementById('s-txn-ref-prefix').value = s.txn_ref_prefix || '';
// transfer_types from /admin/settings is the raw comma-separated string
const rawTT = Array.isArray(s.transfer_types) ? s.transfer_types.join(',') : (s.transfer_types || '');
document.getElementById('s-transfer-types').value = rawTT;
// Receipt labels
document.getElementById('s-lbl-receipt').value = s.lbl_receipt || '';
document.getElementById('s-lbl-topup-receipt').value = s.lbl_topup_receipt || '';
document.getElementById('s-lbl-withdrawal-receipt').value = s.lbl_withdrawal_receipt || '';
document.getElementById('s-lbl-staff').value = s.lbl_staff || '';
document.getElementById('s-lbl-transaction').value = s.lbl_transaction || '';
document.getElementById('s-lbl-charge').value = s.lbl_charge_venue || '';
document.getElementById('s-lbl-txn-time').value = s.lbl_txn_time || '';
document.getElementById('s-lbl-amount-charged').value = s.lbl_amount_charged || '';
document.getElementById('s-lbl-remaining-balance').value = s.lbl_remaining_balance || '';
document.getElementById('s-lbl-balance-transfer').value = s.lbl_balance_transfer || '';
document.getElementById('s-lbl-amount-topup').value = s.lbl_amount_topup || '';
document.getElementById('s-lbl-amount-withdrawal').value = s.lbl_amount_withdrawal || '';
document.getElementById('s-lbl-transfer-type').value = s.lbl_transfer_type || '';
document.getElementById('s-lbl-transfer-ref').value = s.lbl_transfer_ref || '';
// Footers
document.getElementById('s-receipt-footer').value = s.receipt_footer || '';
document.getElementById('s-receipt-footer-charge').value = s.receipt_footer_charge || '';
document.getElementById('s-receipt-footer-cashier').value = s.receipt_footer_cashier || '';
} catch (err) { setMsg('settingsMsg', err.message, 'err'); }
}
function _sv(id) { return document.getElementById(id).value; }
function _svt(id) { return _sv(id).trim(); }
async function saveSettings() {
const div = parseInt(_sv('s-currency-divisor'), 10) || 100;
const body = {
// General
club_name: _svt('s-club-name'),
currency_symbol: _svt('s-currency-symbol'),
currency_major: _svt('s-currency-major'),
currency_minor: _svt('s-currency-minor'),
currency_divisor: div,
min_topup: Math.round(parseFloat(_sv('s-min-topup')) * div),
max_topup: Math.round(parseFloat(_sv('s-max-topup')) * div),
max_charge: Math.round(parseFloat(_sv('s-max-charge')) * div),
overdraft_policy: _sv('s-overdraft-policy'),
timezone: _svt('s-timezone'),
paper_size: _sv('s-paper-size'),
// Business address
biz_address1: _svt('s-biz-address1'),
biz_address2: _svt('s-biz-address2'),
biz_address3: _svt('s-biz-address3'),
biz_address4: _svt('s-biz-address4'),
biz_country: _svt('s-biz-country'),
biz_phone: _svt('s-biz-phone'),
biz_email: _svt('s-biz-email'),
biz_website: _svt('s-biz-website'),
// Branding
logo_url: _svt('s-logo-url'),
logo_align: _sv('s-logo-align'),
logo_max_width: parseInt(_sv('s-logo-max-width'), 10) || null,
logo_max_height: parseInt(_sv('s-logo-max-height'), 10) || null,
bar_name: _svt('s-bar-name'),
cashier_name: _svt('s-cashier-name'),
// Transactions
txn_ref_prefix: _svt('s-txn-ref-prefix'),
transfer_types: _svt('s-transfer-types'),
// Receipt labels
lbl_receipt: _svt('s-lbl-receipt'),
lbl_topup_receipt: _svt('s-lbl-topup-receipt'),
lbl_withdrawal_receipt: _svt('s-lbl-withdrawal-receipt'),
lbl_staff: _svt('s-lbl-staff'),
lbl_transaction: _svt('s-lbl-transaction'),
lbl_charge_venue: _svt('s-lbl-charge'),
lbl_txn_time: _svt('s-lbl-txn-time'),
lbl_amount_charged: _svt('s-lbl-amount-charged'),
lbl_remaining_balance: _svt('s-lbl-remaining-balance'),
lbl_balance_transfer: _svt('s-lbl-balance-transfer'),
lbl_amount_topup: _svt('s-lbl-amount-topup'),
lbl_amount_withdrawal: _svt('s-lbl-amount-withdrawal'),
lbl_transfer_type: _svt('s-lbl-transfer-type'),
lbl_transfer_ref: _svt('s-lbl-transfer-ref'),
// Footers
receipt_footer: _sv('s-receipt-footer'),
receipt_footer_charge: _sv('s-receipt-footer-charge'),
receipt_footer_cashier: _sv('s-receipt-footer-cashier'),
};
try {
await apiFetch('/admin/settings', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
setMsg('settingsMsg', 'Settings saved.', 'ok');
await loadConfig();
populateTransferTypes();
document.querySelectorAll('.currency-unit').forEach(el => { el.textContent = cfg.currency_major || cfg.currency_unit; });
if (document.getElementById('navBrand'))
document.getElementById('navBrand').textContent = cfg.club_name;
} catch (err) { setMsg('settingsMsg', err.message, 'err'); }
}
// Staff accounts table
async function loadStaffAccounts() {
try {
const accounts = await apiFetch('/admin/staff-accounts');
const tbody = document.querySelector('#staffAccountsTable tbody');
tbody.innerHTML = accounts.map(a => `
<tr>
<td>${esc(a.name)}</td>
<td>${esc(a.username)}</td>
<td>${esc(fmtRole(a.role))}</td>
<td>${a.active ? '<span style="color:#080">Active</span>' : '<span style="color:#999">Inactive</span>'}</td>
<td class="row-actions">
<button class="btn row-btn" onclick="openEditAccountModal(${a.id},'${esc(a.name)}','${esc(a.username)}','${esc(a.role)}',${a.active})">Edit</button>
<button class="btn btn-danger row-btn" onclick="deleteAccount(${a.id},'${esc(a.name)}')">Delete</button>
</td>
</tr>`).join('');
} catch (err) { console.error(err); }
}
async function addAccount() {
const body = {
name: document.getElementById('acc-name').value.trim(),
username: document.getElementById('acc-username').value.trim(),
password: document.getElementById('acc-password').value,
role: document.getElementById('acc-role').value,
};
if (!body.name || !body.username || !body.password) {
setMsg('accountMsg', 'All fields required.', 'err'); return;
}
try {
await apiFetch('/admin/staff-accounts', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
setMsg('accountMsg', `Account created for ${body.name}.`, 'ok');
document.getElementById('addAccountForm').reset();
loadStaffAccounts();
} catch (err) { setMsg('accountMsg', err.message, 'err'); }
}
function openEditAccountModal(id, name, username, role, active) {
editAccountId = id;
document.getElementById('eacc-name').value = name;
document.getElementById('eacc-username').value = username;
document.getElementById('eacc-password').value = '';
document.getElementById('eacc-role').value = role;
document.getElementById('eacc-active').checked = !!active;
setMsg('editAccountMsg', '', '');
document.getElementById('editAccountModal').classList.remove('hidden');
}
function closeEditAccountModal() {
editAccountId = null;
document.getElementById('editAccountModal').classList.add('hidden');
}
async function saveEditAccount() {
if (!editAccountId) return;
const body = {
name: document.getElementById('eacc-name').value.trim(),
username: document.getElementById('eacc-username').value.trim(),
role: document.getElementById('eacc-role').value,
active: document.getElementById('eacc-active').checked,
};
const pw = document.getElementById('eacc-password').value;
if (pw) body.password = pw;
try {
await apiFetch(`/admin/staff-accounts/${editAccountId}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
closeEditAccountModal();
loadStaffAccounts();
} catch (err) { setMsg('editAccountMsg', err.message, 'err'); }
}
async function deleteAccount(id, name) {
if (!confirm(`Delete account for "${name}"?`)) return;
try {
await apiFetch(`/admin/staff-accounts/${id}`, { method: 'DELETE' });
loadStaffAccounts();
} catch (err) { alert(err.message); }
}

57
static/bar.html Normal file
View file

@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bar ClubLedger</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<nav>
<span class="brand" id="navBrand">ClubLedger</span>
<a class="nav-link" href="/cashier">Cashier</a>
<a class="nav-link active" href="/bar">Bar</a>
</nav>
<div class="view">
<div class="panel">
<h2>Charge Account</h2>
<div class="search-row">
<input type="text" id="barSearch" placeholder="Search member…">
<button class="btn" onclick="barSearchMembers()">Search</button>
</div>
<div id="barMemberList" class="member-pick-list"></div>
<div id="barForm" class="hidden">
<div class="selected-member-box" id="barSelected"></div>
<div class="form-row">
<label>Amount (<span class="currency-unit"></span>)</label>
<input type="number" id="barAmount" placeholder="e.g. 350" min="1" step="1">
</div>
<div class="form-row">
<label>PIN</label>
<input type="password" id="barPin" placeholder="Member PIN" maxlength="20">
</div>
<div class="form-row">
<label>Staff</label>
<select id="barStaff"></select>
</div>
<div class="form-row">
<label>Note (optional)</label>
<input type="text" id="barNote" placeholder="">
</div>
<button class="btn btn-danger" onclick="doCharge()">Charge</button>
<button class="btn" onclick="clearBarSelection()">Cancel</button>
</div>
<div id="barMsg" class="msg"></div>
</div>
</div>
<script src="/static/common.js"></script>
<script src="/static/bar.js"></script>
</body>
</html>

74
static/bar.js Normal file
View file

@ -0,0 +1,74 @@
/* ClubLedger bar page */
let barMember = null;
(async function init() {
await loadConfig();
await loadStaffInto('barStaff');
document.getElementById('barSearch').addEventListener('keydown', e => { if (e.key === 'Enter') barSearchMembers(); });
})();
// ---------------------------------------------------------------------------
// Member selection
// ---------------------------------------------------------------------------
async function barSearchMembers() {
const q = document.getElementById('barSearch').value.trim();
const url = q ? `/members?q=${encodeURIComponent(q)}` : '/members';
try {
const members = await apiFetch(url);
const list = document.getElementById('barMemberList');
list.innerHTML = members.map(m => `
<div class="member-pick-item" onclick="selectBarMember(${m.id},'${esc(m.name)}','${esc(m.member_number)}',${m.balance},'${esc(m.balance_display)}')">
<div>
<div class="member-pick-name">${esc(m.name)}</div>
<div class="member-pick-sub">#${esc(m.member_number)}</div>
</div>
<div class="${balanceClass(m.balance)}">${esc(m.balance_display)}</div>
</div>`).join('');
} catch (e) { console.error(e); }
}
function selectBarMember(id, name, number, balance, balanceDisplay) {
barMember = { id, name, number };
document.getElementById('barMemberList').innerHTML = '';
document.getElementById('barSelected').innerHTML =
`<strong>${esc(name)}</strong> &nbsp; #${esc(number)} &nbsp; Balance: <span class="${balanceClass(balance)}">${esc(balanceDisplay)}</span>`;
document.getElementById('barForm').classList.remove('hidden');
setMsg('barMsg', '', '');
}
function clearBarSelection() {
barMember = null;
document.getElementById('barForm').classList.add('hidden');
document.getElementById('barAmount').value = '';
document.getElementById('barPin').value = '';
document.getElementById('barNote').value = '';
setMsg('barMsg', '', '');
}
// ---------------------------------------------------------------------------
// Charge
// ---------------------------------------------------------------------------
async function doCharge() {
if (!barMember) return;
const amount = parseInt(document.getElementById('barAmount').value, 10);
const pin = document.getElementById('barPin').value;
const staff = document.getElementById('barStaff').value;
const note = document.getElementById('barNote').value.trim();
if (!amount || isNaN(amount) || amount <= 0) { setMsg('barMsg', 'Enter a valid amount.', 'err'); return; }
if (!pin) { setMsg('barMsg', 'PIN required.', 'err'); return; }
if (!staff) { setMsg('barMsg', 'Select a staff member.', 'err'); return; }
try {
const r = await apiFetch('/charge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ member_id: barMember.id, amount, pin, staff_name: staff, note: note || null })
});
window.open(`/receipt/${r.entry_id}`, '_blank');
setMsg('barMsg', `Charge complete. New balance: ${r.new_balance_display}`, 'ok');
clearBarSelection();
} catch (e) {
setMsg('barMsg', e.message, 'err');
}
}

98
static/cashier.html Normal file
View file

@ -0,0 +1,98 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cashier ClubLedger</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<nav>
<span class="brand" id="navBrand">ClubLedger</span>
<a class="nav-link active" href="/cashier">Cashier</a>
<a class="nav-link" href="/bar">Bar</a>
</nav>
<div class="view">
<!-- Register -->
<div class="panel">
<h2>Register New Member</h2>
<form id="registerForm">
<div class="form-row">
<label>Member Number</label>
<input type="text" id="reg-number" placeholder="e.g. 001" required>
</div>
<div class="form-row">
<label>Full Name</label>
<input type="text" id="reg-name" placeholder="Name" required>
</div>
<div class="form-row">
<label>PIN</label>
<input type="password" id="reg-pin" placeholder="Min 4 digits" required>
</div>
<button type="submit" class="btn btn-primary">Register</button>
</form>
<div id="registerMsg" class="msg"></div>
</div>
<!-- Top Up -->
<div class="panel">
<h2>Top Up Account</h2>
<div class="search-row">
<input type="text" id="cashierSearch" placeholder="Search member…">
<button class="btn" onclick="cashierSearchMembers()">Search</button>
</div>
<div id="cashierMemberList" class="member-pick-list"></div>
<div id="cashierForm" class="hidden">
<div class="selected-member-box" id="cashierSelected"></div>
<div class="form-row">
<label>Amount (<span class="currency-unit"></span>)</label>
<input type="number" id="cashierAmount" placeholder="e.g. 1000" min="1" step="1">
</div>
<div class="form-row">
<label>Staff</label>
<select id="cashierStaff"></select>
</div>
<div class="form-row">
<label>Note (optional)</label>
<input type="text" id="cashierNote" placeholder="">
</div>
<button class="btn btn-primary" onclick="doTopup()">Top Up</button>
<button class="btn" onclick="clearCashierSelection()">Cancel</button>
</div>
<div id="cashierMsg" class="msg"></div>
</div>
<!-- Member list -->
<div class="panel">
<h2>Members</h2>
<div class="search-row">
<input type="text" id="memberSearch" placeholder="Search name or number…">
<button class="btn" onclick="searchMembers()">Search</button>
</div>
<table id="memberTable" class="data-table">
<thead><tr><th>#</th><th>Name</th><th>Balance</th><th>Joined</th><th></th></tr></thead>
<tbody></tbody>
</table>
</div>
<!-- Staff management -->
<div class="panel">
<h2>Staff</h2>
<div class="search-row">
<input type="text" id="staffNameInput" placeholder="Staff name" id="staffNameInput">
<button class="btn btn-primary" onclick="addStaff()">Add</button>
</div>
<div id="staffChips" class="staff-chips"></div>
<div id="staffMsg" class="msg"></div>
</div>
</div>
<script src="/static/common.js"></script>
<script src="/static/cashier.js"></script>
</body>
</html>

132
static/cashier.js Normal file
View file

@ -0,0 +1,132 @@
/* ClubLedger cashier page */
let cashierMember = null;
(async function init() {
await loadConfig();
await loadStaffInto('cashierStaff');
// load initial staff chips
try {
const data = await apiFetch('/staff');
renderStaffChips(data.staff);
} catch (e) { /* ignore */ }
document.getElementById('registerForm').addEventListener('submit', async e => {
e.preventDefault();
await registerMember();
});
document.getElementById('memberSearch').addEventListener('keydown', e => { if (e.key === 'Enter') searchMembers(); });
document.getElementById('cashierSearch').addEventListener('keydown', e => { if (e.key === 'Enter') cashierSearchMembers(); });
document.getElementById('staffNameInput').addEventListener('keydown', e => { if (e.key === 'Enter') addStaff(); });
searchMembers();
})();
// ---------------------------------------------------------------------------
// Register
// ---------------------------------------------------------------------------
async function registerMember() {
const number = document.getElementById('reg-number').value.trim();
const name = document.getElementById('reg-name').value.trim();
const pin = document.getElementById('reg-pin').value;
if (!number || !name || !pin) { setMsg('registerMsg', 'All fields required.', 'err'); return; }
try {
const m = await apiFetch('/members', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ member_number: number, name, pin })
});
setMsg('registerMsg', `Registered: ${m.name} (#${m.member_number})`, 'ok');
document.getElementById('registerForm').reset();
searchMembers();
} catch (e) {
setMsg('registerMsg', e.message, 'err');
}
}
// ---------------------------------------------------------------------------
// Member list
// ---------------------------------------------------------------------------
async function searchMembers() {
const q = document.getElementById('memberSearch').value.trim();
const url = q ? `/members?q=${encodeURIComponent(q)}` : '/members';
try {
const members = await apiFetch(url);
const tbody = document.querySelector('#memberTable tbody');
if (!members.length) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:#888">No members found</td></tr>';
return;
}
tbody.innerHTML = members.map(m => `
<tr>
<td>${esc(m.member_number)}</td>
<td>${esc(m.name)}</td>
<td class="num ${balanceClass(m.balance)}">${esc(m.balance_display)}</td>
<td>${m.created_at ? m.created_at.slice(0, 10) : ''}</td>
<td>
<a href="/members/${m.id}/statement" target="_blank" class="btn" style="padding:4px 10px;font-size:.82rem">Statement</a>
</td>
</tr>`).join('');
} catch (e) { console.error(e); }
}
// ---------------------------------------------------------------------------
// Top-up
// ---------------------------------------------------------------------------
async function cashierSearchMembers() {
const q = document.getElementById('cashierSearch').value.trim();
const url = q ? `/members?q=${encodeURIComponent(q)}` : '/members';
try {
const members = await apiFetch(url);
const list = document.getElementById('cashierMemberList');
list.innerHTML = members.map(m => `
<div class="member-pick-item" onclick="selectCashierMember(${m.id},'${esc(m.name)}','${esc(m.member_number)}',${m.balance},'${esc(m.balance_display)}')">
<div>
<div class="member-pick-name">${esc(m.name)}</div>
<div class="member-pick-sub">#${esc(m.member_number)}</div>
</div>
<div class="${balanceClass(m.balance)}">${esc(m.balance_display)}</div>
</div>`).join('');
} catch (e) { console.error(e); }
}
function selectCashierMember(id, name, number, balance, balanceDisplay) {
cashierMember = { id, name, number };
document.getElementById('cashierMemberList').innerHTML = '';
document.getElementById('cashierSelected').innerHTML =
`<strong>${esc(name)}</strong> &nbsp; #${esc(number)} &nbsp; Balance: <span class="${balanceClass(balance)}">${esc(balanceDisplay)}</span>`;
document.getElementById('cashierForm').classList.remove('hidden');
setMsg('cashierMsg', '', '');
}
function clearCashierSelection() {
cashierMember = null;
document.getElementById('cashierForm').classList.add('hidden');
document.getElementById('cashierAmount').value = '';
document.getElementById('cashierNote').value = '';
setMsg('cashierMsg', '', '');
}
async function doTopup() {
if (!cashierMember) return;
const amount = parseInt(document.getElementById('cashierAmount').value, 10);
const staff = document.getElementById('cashierStaff').value;
const note = document.getElementById('cashierNote').value.trim();
if (!amount || isNaN(amount) || amount <= 0) { setMsg('cashierMsg', 'Enter a valid amount.', 'err'); return; }
if (!staff) { setMsg('cashierMsg', 'Select a staff member.', 'err'); return; }
try {
const r = await apiFetch('/topup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ member_id: cashierMember.id, amount, staff_name: staff, note: note || null })
});
window.open(`/receipt/${r.entry_id}`, '_blank');
setMsg('cashierMsg', `Top-up complete. New balance: ${r.new_balance_display}`, 'ok');
clearCashierSelection();
searchMembers();
} catch (e) {
setMsg('cashierMsg', e.message, 'err');
}
}

112
static/common.js Normal file
View file

@ -0,0 +1,112 @@
/* ClubLedger shared helpers */
let cfg = { currency_unit: 'pence', currency_symbol: '£', currency_divisor: 100, club_name: 'ClubLedger' };
async function loadConfig() {
try {
const r = await fetch('/config');
cfg = await r.json();
const brand = document.getElementById('navBrand');
if (brand) brand.textContent = cfg.club_name;
document.title = document.title.replace('ClubLedger', cfg.club_name);
document.querySelectorAll('.currency-unit').forEach(el => { el.textContent = cfg.currency_unit; });
} catch (e) { /* use defaults */ }
}
function fmtAmount(pence) {
return cfg.currency_symbol + (pence / cfg.currency_divisor).toFixed(2);
}
function balanceClass(v) {
return v < 0 ? 'balance-neg' : 'balance-pos';
}
function setMsg(id, text, type) {
const el = document.getElementById(id);
el.textContent = text;
el.className = 'msg ' + (type || '');
}
async function apiFetch(url, opts) {
const r = await fetch(url, opts);
const json = await r.json();
if (!r.ok) throw new Error(json.detail || 'Server error');
return json;
}
function esc(str) {
if (str == null) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// ---------------------------------------------------------------------------
// Staff dropdown
// ---------------------------------------------------------------------------
async function loadStaffInto(selectId) {
const sel = document.getElementById(selectId);
if (!sel) return;
try {
const data = await apiFetch('/staff');
const saved = sessionStorage.getItem('lastStaff') || '';
sel.innerHTML = '<option value="">&mdash; select staff &mdash;</option>' +
data.staff.map(n => `<option value="${esc(n)}"${n === saved ? ' selected' : ''}>${esc(n)}</option>`).join('');
sel.addEventListener('change', () => {
if (sel.value) sessionStorage.setItem('lastStaff', sel.value);
});
} catch (e) { console.error('Could not load staff', e); }
}
async function refreshAllStaffDropdowns() {
try {
const data = await apiFetch('/staff');
const saved = sessionStorage.getItem('lastStaff') || '';
document.querySelectorAll('select[id$="Staff"]').forEach(sel => {
sel.innerHTML = '<option value="">&mdash; select staff &mdash;</option>' +
data.staff.map(n => `<option value="${esc(n)}"${n === saved ? ' selected' : ''}>${esc(n)}</option>`).join('');
});
renderStaffChips(data.staff);
} catch (e) { console.error(e); }
}
function renderStaffChips(staffList) {
const div = document.getElementById('staffChips');
if (!div) return;
div.innerHTML = staffList.map(n => `
<span class="staff-chip">
${esc(n)}
<button class="chip-del" onclick="removeStaff('${esc(n)}')" title="Remove">&times;</button>
</span>`).join('');
}
async function addStaff() {
const input = document.getElementById('staffNameInput');
const name = input.value.trim();
if (!name) return;
try {
const data = await apiFetch('/staff', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
input.value = '';
setMsg('staffMsg', `Added: ${name}`, 'ok');
renderStaffChips(data.staff);
await refreshAllStaffDropdowns();
} catch (e) {
setMsg('staffMsg', e.message, 'err');
}
}
async function removeStaff(name) {
try {
const data = await apiFetch(`/staff/${encodeURIComponent(name)}`, { method: 'DELETE' });
renderStaffChips(data.staff);
await refreshAllStaffDropdowns();
} catch (e) { console.error(e); }
}

382
static/index.html Normal file
View file

@ -0,0 +1,382 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ClubLedger</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<!-- ===================== LOGIN OVERLAY ===================== -->
<div id="loginOverlay" class="login-overlay hidden">
<div class="login-card">
<h1 id="loginBrand">ClubLedger</h1>
<p class="login-sub">Staff sign in</p>
<form id="loginForm">
<div class="form-row">
<label>Username</label>
<input type="text" id="loginUsername" autocomplete="username" required>
</div>
<div class="form-row">
<label>Password</label>
<input type="password" id="loginPassword" autocomplete="current-password" required>
</div>
<button type="submit" class="btn btn-primary" style="width:100%;margin-top:4px">Sign In</button>
</form>
<div id="loginMsg" class="msg"></div>
</div>
</div>
<!-- ===================== NAV ===================== -->
<nav>
<span class="brand" id="navBrand">ClubLedger</span>
<button class="nav-btn active" data-view="members">Members</button>
<button class="nav-btn" data-view="cashier">Cashier</button>
<button class="nav-btn" data-view="bar">Bar</button>
<button class="nav-btn hidden" data-view="admin" id="adminTabBtn">Admin</button>
<div class="nav-right">
<span class="nav-user" id="navUser"></span>
<button class="nav-logout" onclick="doLogout()">Sign out</button>
</div>
</nav>
<!-- ===================== MEMBERS VIEW ===================== -->
<div id="view-members" class="view">
<div class="panel">
<h2>Register New Member</h2>
<form id="registerForm">
<div class="form-row">
<label>Member Number</label>
<input type="text" id="reg-number" placeholder="e.g. 001" required>
</div>
<div class="form-row">
<label>Full Name</label>
<input type="text" id="reg-name" placeholder="Name" required>
</div>
<div class="form-row">
<label>PIN</label>
<input type="password" id="reg-pin" placeholder="Min 4 digits" required>
</div>
<button type="submit" class="btn btn-primary">Register</button>
</form>
<div id="registerMsg" class="msg"></div>
</div>
<div class="panel">
<h2>Members</h2>
<div class="search-row">
<input type="text" id="memberSearch" placeholder="Search name or number…">
<button class="btn" onclick="searchMembers()">Search</button>
</div>
<table id="memberTable" class="data-table">
<thead><tr><th>#</th><th>Name</th><th>Balance</th><th>Joined</th><th class="actions-col"></th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
<!-- ===================== CASHIER VIEW ===================== -->
<div id="view-cashier" class="view hidden">
<div class="panel">
<h2>Top Up Account</h2>
<div class="search-row">
<input type="text" id="cashierSearch" placeholder="Search member…">
<button class="btn" onclick="cashierSearchMembers()">Search</button>
</div>
<div id="cashierMemberList" class="member-pick-list"></div>
<div id="cashierForm" class="hidden">
<div class="selected-member-box" id="cashierSelected"></div>
<div class="cashier-action-panel">
<h3>Top Up</h3>
<div class="form-row">
<label>Amount (<span class="currency-unit"></span>)</label>
<input type="number" id="cashierAmount" placeholder="e.g. 10.00" min="0.01" step="0.01">
</div>
<div class="form-row">
<label>Transfer Type</label>
<select id="cashierTransferType">
<option value="">&mdash; select &mdash;</option>
</select>
</div>
<div class="form-row">
<label>Transfer Reference <span class="label-hint">(optional)</span></label>
<input type="text" id="cashierTransferRef" placeholder="">
</div>
<div class="form-row">
<label>Note <span class="label-hint">(optional)</span></label>
<input type="text" id="cashierNote" placeholder="">
</div>
<button class="btn btn-primary" onclick="doTopup()">Top Up</button>
<div id="cashierTopupMsg" class="msg"></div>
</div>
<div class="cashier-action-panel">
<h3>Withdrawal</h3>
<div class="form-row">
<label>Amount (<span class="currency-unit"></span>)</label>
<input type="number" id="withdrawalAmount" placeholder="e.g. 10.00" min="0.01" step="0.01">
</div>
<div class="form-row">
<label>Member PIN</label>
<input type="password" id="withdrawalPin" placeholder="" autocomplete="off">
</div>
<div class="form-row">
<label>Transfer Type</label>
<select id="withdrawalTransferType">
<option value="">&mdash; select &mdash;</option>
</select>
</div>
<div class="form-row">
<label>Transfer Reference <span class="label-hint">(optional)</span></label>
<input type="text" id="withdrawalTransferRef" placeholder="">
</div>
<div class="form-row">
<label>Note <span class="label-hint">(optional)</span></label>
<input type="text" id="withdrawalNote" placeholder="">
</div>
<button class="btn btn-danger" onclick="doWithdrawal()">Withdraw</button>
<div id="cashierWithdrawalMsg" class="msg"></div>
</div>
<button class="btn" onclick="clearCashierSelection()" style="margin-top:8px">Cancel</button>
</div>
<div id="cashierMsg" class="msg"></div>
</div>
</div>
<!-- ===================== BAR VIEW ===================== -->
<div id="view-bar" class="view hidden">
<div class="panel">
<h2>Charge Account</h2>
<div class="search-row">
<input type="text" id="barSearch" placeholder="Search member…">
<button class="btn" onclick="barSearchMembers()">Search</button>
</div>
<div id="barMemberList" class="member-pick-list"></div>
<div id="barForm" class="hidden">
<div class="selected-member-box" id="barSelected"></div>
<div class="form-row">
<label>Amount (<span class="currency-unit"></span>)</label>
<input type="number" id="barAmount" placeholder="e.g. 3.50" min="0.01" step="0.01">
</div>
<div class="form-row">
<label>Member PIN</label>
<input type="password" id="barPin" placeholder="Member PIN" maxlength="20">
</div>
<div class="form-row">
<label>Note (optional)</label>
<input type="text" id="barNote" placeholder="">
</div>
<button class="btn btn-danger" onclick="doCharge()">Charge</button>
<button class="btn" onclick="clearBarSelection()">Cancel</button>
</div>
<div id="barMsg" class="msg"></div>
</div>
</div>
<!-- ===================== ADMIN VIEW ===================== -->
<div id="view-admin" class="view hidden">
<div class="panel">
<h2>App Settings</h2>
<form id="settingsForm">
<h3 class="sub-heading">General</h3>
<div class="form-row"><label>Club Name</label>
<input type="text" id="s-club-name"></div>
<div class="form-row"><label>Currency Symbol</label>
<input type="text" id="s-currency-symbol" style="max-width:80px"></div>
<div class="form-row"><label>Currency Name <span class="label-hint">(major unit, e.g. pounds)</span></label>
<input type="text" id="s-currency-major" placeholder="pounds"></div>
<div class="form-row"><label>Subunit Name <span class="label-hint">(minor unit, e.g. pence)</span></label>
<input type="text" id="s-currency-minor" placeholder="pence"></div>
<div class="form-row"><label>Subunits per unit <span class="label-hint">(e.g. 100)</span></label>
<input type="number" id="s-currency-divisor" min="1" step="1" style="max-width:100px"></div>
<div class="form-row"><label>Minimum top-up <span class="label-hint" id="s-min-hint"></span></label>
<input type="number" id="s-min-topup" step="0.01" min="0.01"></div>
<div class="form-row"><label>Maximum top-up <span class="label-hint" id="s-max-hint"></span></label>
<input type="number" id="s-max-topup" step="0.01"></div>
<div class="form-row"><label>Maximum single charge <span class="label-hint" id="s-charge-hint"></span></label>
<input type="number" id="s-max-charge" step="0.01"></div>
<div class="form-row">
<label>Overdraft (bar charges)</label>
<select id="s-overdraft-policy">
<option value="never">Not allowed</option>
<option value="always">Allowed for all</option>
<option value="staff_override">Default not allowed &mdash; staff may override per charge</option>
<option value="admin_override">Default not allowed &mdash; admin may override per charge</option>
<option value="staff_block">Default allowed &mdash; staff may block per charge</option>
</select>
</div>
<div class="form-row">
<label>Timezone <span class="label-hint">(IANA name, e.g. Europe/London, Asia/Bangkok &mdash; default is server timezone)</span></label>
<input type="text" id="s-timezone" placeholder="e.g. Europe/London">
</div>
<div class="form-row">
<label>Default paper size <span class="label-hint">(for receipts and statements)</span></label>
<select id="s-paper-size">
<option value="A4">A4</option>
<option value="A5">A5</option>
</select>
</div>
<div class="panel-divider"></div>
<h3 class="sub-heading">Business Address</h3>
<div class="form-row"><label>Address line 1</label><input type="text" id="s-biz-address1"></div>
<div class="form-row"><label>Address line 2</label><input type="text" id="s-biz-address2"></div>
<div class="form-row"><label>Address line 3</label><input type="text" id="s-biz-address3"></div>
<div class="form-row"><label>Address line 4</label><input type="text" id="s-biz-address4"></div>
<div class="form-row"><label>Country</label><input type="text" id="s-biz-country"></div>
<div class="form-row"><label>Phone</label><input type="text" id="s-biz-phone"></div>
<div class="form-row"><label>Email</label><input type="text" id="s-biz-email"></div>
<div class="form-row"><label>Website</label><input type="text" id="s-biz-website"></div>
<div class="panel-divider"></div>
<h3 class="sub-heading">Branding</h3>
<div class="form-row">
<label>Logo <span class="label-hint">(upload image file)</span></label>
<input type="file" id="s-logo-upload" accept="image/*" style="padding:4px 0;border:none;background:none;">
<div id="logoUploadMsg" class="msg" style="margin-top:4px"></div>
</div>
<div class="form-row"><label>Logo URL <span class="label-hint">(or paste URL; upload above sets this automatically)</span></label>
<input type="text" id="s-logo-url" placeholder="https://..."></div>
<div class="form-row">
<label>Logo alignment</label>
<select id="s-logo-align">
<option value="left">Left</option>
<option value="center">Center</option>
<option value="right">Right</option>
</select>
</div>
<div class="form-row" style="flex-direction:row;gap:24px;align-items:flex-end">
<div style="flex:1"><label>Logo max width (px)</label>
<input type="number" id="s-logo-max-width" min="20" step="10" placeholder="200"></div>
<div style="flex:1"><label>Logo max height (px)</label>
<input type="number" id="s-logo-max-height" min="20" step="10" placeholder="80"></div>
</div>
<div class="form-row"><label>Bar venue name</label>
<input type="text" id="s-bar-name" placeholder="Bar"></div>
<div class="form-row"><label>Cashier venue name</label>
<input type="text" id="s-cashier-name" placeholder="Cashier"></div>
<div class="panel-divider"></div>
<h3 class="sub-heading">Transactions</h3>
<div class="form-row"><label>Transaction reference prefix</label>
<input type="text" id="s-txn-ref-prefix" placeholder="TXN" style="max-width:120px"></div>
<div class="form-row"><label>Transfer types <span class="label-hint">(comma-separated)</span></label>
<input type="text" id="s-transfer-types" placeholder="Bank Transfer,Cash,QR"></div>
<div class="panel-divider"></div>
<h3 class="sub-heading">Receipt Labels <span class="label-hint">(for localisation)</span></h3>
<div class="form-row"><label>Receipt title (charge)</label><input type="text" id="s-lbl-receipt" placeholder="RECEIPT"></div>
<div class="form-row"><label>Receipt title (top-up)</label><input type="text" id="s-lbl-topup-receipt" placeholder="TOP-UP RECEIPT"></div>
<div class="form-row"><label>Receipt title (withdrawal)</label><input type="text" id="s-lbl-withdrawal-receipt" placeholder="WITHDRAWAL RECEIPT"></div>
<div class="form-row"><label>Staff label</label><input type="text" id="s-lbl-staff" placeholder="STAFF"></div>
<div class="form-row"><label>Transaction label</label><input type="text" id="s-lbl-transaction" placeholder="TRANSACTION"></div>
<div class="form-row"><label>Charge/venue label</label><input type="text" id="s-lbl-charge" placeholder="CHARGE"></div>
<div class="form-row"><label>Transaction time label</label><input type="text" id="s-lbl-txn-time" placeholder="TRANSACTION TIME"></div>
<div class="form-row"><label>Amount charged label</label><input type="text" id="s-lbl-amount-charged" placeholder="AMOUNT CHARGED"></div>
<div class="form-row"><label>Remaining balance label</label><input type="text" id="s-lbl-remaining-balance" placeholder="REMAINING BALANCE"></div>
<div class="form-row"><label>Balance transfer section header</label><input type="text" id="s-lbl-balance-transfer" placeholder="BALANCE TRANSFER"></div>
<div class="form-row"><label>Amount topped-up label</label><input type="text" id="s-lbl-amount-topup" placeholder="AMOUNT TOPPED-UP"></div>
<div class="form-row"><label>Amount withdrawn label</label><input type="text" id="s-lbl-amount-withdrawal" placeholder="AMOUNT WITHDRAWN"></div>
<div class="form-row"><label>Transfer type label</label><input type="text" id="s-lbl-transfer-type" placeholder="TRANSFER TYPE"></div>
<div class="form-row"><label>Transfer reference label</label><input type="text" id="s-lbl-transfer-ref" placeholder="TRANSFER REFERENCE"></div>
<div class="panel-divider"></div>
<h3 class="sub-heading">Receipt Footers</h3>
<div class="form-row"><label>Footer &mdash; all <span class="label-hint">(fallback for all receipts and statement)</span></label>
<textarea id="s-receipt-footer" rows="2" placeholder="Printed at the bottom of every receipt and statement"></textarea></div>
<div class="form-row"><label>Footer &mdash; charge receipts <span class="label-hint">(overrides all-footer for bar charges)</span></label>
<textarea id="s-receipt-footer-charge" rows="2"></textarea></div>
<div class="form-row"><label>Footer &mdash; cashier receipts <span class="label-hint">(overrides all-footer for top-ups and withdrawals)</span></label>
<textarea id="s-receipt-footer-cashier" rows="2"></textarea></div>
<button type="submit" class="btn btn-primary" style="margin-top:8px">Save Settings</button>
</form>
<div id="settingsMsg" class="msg"></div>
</div>
<div class="panel">
<h2>Staff Accounts</h2>
<table id="staffAccountsTable" class="data-table">
<thead><tr><th>Name</th><th>Username</th><th>Role</th><th>Status</th><th></th></tr></thead>
<tbody></tbody>
</table>
<div class="panel-divider"></div>
<h3 class="sub-heading">Add Account</h3>
<form id="addAccountForm">
<div class="form-row"><label>Name</label><input type="text" id="acc-name" required></div>
<div class="form-row"><label>Username</label><input type="text" id="acc-username" required autocomplete="off"></div>
<div class="form-row"><label>Password</label><input type="password" id="acc-password" required autocomplete="new-password"></div>
<div class="form-row"><label>Role</label>
<select id="acc-role"><option value="pos-staff">POS Staff</option><option value="cashier">Cashier</option><option value="admin">Admin</option></select>
</div>
<button type="submit" class="btn btn-primary">Add Account</button>
</form>
<div id="accountMsg" class="msg"></div>
</div>
</div>
<!-- ===================== EDIT MEMBER MODAL ===================== -->
<div id="editModal" class="modal-overlay hidden" onclick="if(event.target===this)closeEditModal()">
<div class="modal">
<h3>Edit Member</h3>
<form id="editForm">
<div class="form-row"><label>Member Number</label><input type="text" id="edit-number" required></div>
<div class="form-row"><label>Full Name</label><input type="text" id="edit-name" required></div>
<div class="form-row">
<label>New PIN <span class="label-hint">(leave blank to keep current)</span></label>
<input type="password" id="edit-pin" placeholder="Leave blank to keep">
</div>
<div id="editOverdraftRow" class="form-row-check hidden">
<input type="checkbox" id="edit-overdraft">
<label for="edit-overdraft" id="editOverdraftLabel"></label>
</div>
<div class="modal-actions">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn" onclick="closeEditModal()">Cancel</button>
</div>
</form>
<div id="editMsg" class="msg"></div>
</div>
</div>
<!-- ===================== EDIT ACCOUNT MODAL ===================== -->
<div id="editAccountModal" class="modal-overlay hidden" onclick="if(event.target===this)closeEditAccountModal()">
<div class="modal">
<h3>Edit Account</h3>
<form id="editAccountForm">
<div class="form-row"><label>Name</label><input type="text" id="eacc-name"></div>
<div class="form-row"><label>Username</label><input type="text" id="eacc-username" autocomplete="off"></div>
<div class="form-row">
<label>New Password <span class="label-hint">(leave blank to keep)</span></label>
<input type="password" id="eacc-password" placeholder="Leave blank to keep" autocomplete="new-password">
</div>
<div class="form-row"><label>Role</label>
<select id="eacc-role"><option value="pos-staff">POS Staff</option><option value="cashier">Cashier</option><option value="admin">Admin</option></select>
</div>
<div class="form-row form-row-check">
<input type="checkbox" id="eacc-active">
<label for="eacc-active">Active (can log in)</label>
</div>
<div class="modal-actions">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn" onclick="closeEditAccountModal()">Cancel</button>
</div>
</form>
<div id="editAccountMsg" class="msg"></div>
</div>
</div>
<script src="/static/common.js"></script>
<script src="/static/app.js"></script>
</body>
</html>

290
static/style.css Normal file
View file

@ -0,0 +1,290 @@
/* ClubLedger main styles */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--primary: #1a56db;
--primary-dark: #1140a6;
--danger: #d63b3b;
--danger-dark: #a82e2e;
--success: #1a7f3c;
--bg: #f4f6fb;
--panel-bg: #ffffff;
--border: #d1d5db;
--text: #111827;
--muted: #6b7280;
--nav-bg: #1a1a2e;
--nav-text: #e0e0e0;
}
body { font-family: 'Segoe UI', system-ui, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
/* ---- Nav ---- */
nav {
background: var(--nav-bg);
color: var(--nav-text);
display: flex;
align-items: center;
padding: 0 20px;
height: 52px;
gap: 8px;
position: sticky;
top: 0;
z-index: 100;
}
.brand { font-size: 1.2rem; font-weight: 700; letter-spacing: .5px; margin-right: 16px; color: #fff; }
.nav-btn {
background: transparent;
border: 2px solid transparent;
color: var(--nav-text);
padding: 6px 16px;
border-radius: 6px;
cursor: pointer;
font-size: .95rem;
transition: background .15s, border-color .15s;
}
.nav-btn:hover { background: rgba(255,255,255,.1); }
.nav-btn.active { border-color: #4a9eff; color: #fff; }
.nav-link {
color: var(--nav-text);
text-decoration: none;
padding: 6px 16px;
border-radius: 6px;
border: 2px solid transparent;
font-size: .95rem;
transition: background .15s, border-color .15s;
}
.nav-link:hover { background: rgba(255,255,255,.1); }
.nav-link.active { border-color: #4a9eff; color: #fff; }
/* ---- Views ---- */
.view { max-width: 900px; margin: 28px auto; padding: 0 16px; display: flex; flex-direction: column; gap: 24px; }
.hidden { display: none !important; }
/* ---- Panel ---- */
.panel { background: var(--panel-bg); border: 1px solid var(--border); border-radius: 10px; padding: 24px; }
.panel h2 { font-size: 1.15rem; margin-bottom: 18px; color: var(--text); }
/* ---- Forms ---- */
.form-row { display: flex; flex-direction: column; gap: 4px; margin-bottom: 14px; }
.form-row label { font-size: .85rem; font-weight: 600; color: var(--muted); }
.form-row input {
padding: 9px 12px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 1rem;
outline: none;
transition: border-color .15s;
}
.form-row input:focus { border-color: var(--primary); }
.search-row { display: flex; gap: 8px; margin-bottom: 14px; }
.search-row input { flex: 1; padding: 9px 12px; border: 1px solid var(--border); border-radius: 6px; font-size: 1rem; outline: none; }
.search-row input:focus { border-color: var(--primary); }
/* ---- Buttons ---- */
.btn {
padding: 9px 20px;
border: 1px solid var(--border);
border-radius: 6px;
background: #fff;
cursor: pointer;
font-size: .95rem;
transition: background .15s;
margin-right: 6px;
}
.btn:hover { background: #f0f0f0; }
.btn-primary { background: var(--primary); color: #fff; border-color: var(--primary); }
.btn-primary:hover { background: var(--primary-dark); }
.btn-danger { background: var(--danger); color: #fff; border-color: var(--danger); }
.btn-danger:hover { background: var(--danger-dark); }
/* ---- Data table ---- */
.data-table { width: 100%; border-collapse: collapse; font-size: .93rem; margin-top: 8px; }
.data-table th { background: #f0f2f7; padding: 8px 10px; text-align: left; font-weight: 600; border-bottom: 2px solid var(--border); }
.data-table td { padding: 8px 10px; border-bottom: 1px solid #eee; }
.data-table tr:last-child td { border-bottom: none; }
.data-table tr:hover td { background: #f9f9ff; }
.num { text-align: right; font-variant-numeric: tabular-nums; }
.balance-pos { color: var(--success); font-weight: 600; }
.balance-neg { color: var(--danger); font-weight: 600; }
/* ---- Member pick list ---- */
.member-pick-list { margin-bottom: 14px; }
.member-pick-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 6px;
cursor: pointer;
transition: background .12s, border-color .12s;
}
.member-pick-item:hover { background: #eef2ff; border-color: var(--primary); }
.member-pick-name { font-weight: 600; }
.member-pick-sub { font-size: .83rem; color: var(--muted); }
/* ---- Selected member box ---- */
.selected-member-box {
background: #eef2ff;
border: 1px solid #c7d2f7;
border-radius: 8px;
padding: 12px 16px;
margin-bottom: 16px;
font-size: .95rem;
}
.selected-member-box strong { font-size: 1.05rem; }
/* ---- Cashier dual-action panels ---- */
.cashier-action-panel {
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
}
.cashier-action-panel h3 {
margin: 0 0 12px;
font-size: 1rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: .04em;
}
/* ---- Product results ---- */
.product-results { margin-bottom: 14px; }
.product-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 5px;
cursor: pointer;
transition: background .12s;
}
.product-item:hover { background: #f0fff4; border-color: #34d399; }
.product-price { font-weight: 700; color: var(--primary); }
/* ---- Staff chips ---- */
.staff-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.staff-chip {
display: inline-flex;
align-items: center;
gap: 6px;
background: #eef2ff;
border: 1px solid #c7d2f7;
border-radius: 20px;
padding: 4px 10px 4px 12px;
font-size: .88rem;
}
.chip-del {
background: none;
border: none;
cursor: pointer;
color: #888;
font-size: 1rem;
line-height: 1;
padding: 0;
}
.chip-del:hover { color: var(--danger); }
/* ---- Select / dropdown ---- */
.form-row select {
padding: 9px 12px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 1rem;
outline: none;
background: #fff;
cursor: pointer;
transition: border-color .15s;
}
.form-row select:focus { border-color: var(--primary); }
/* ---- Row action buttons ---- */
.row-actions { white-space: nowrap; }
.row-btn { padding: 4px 10px !important; font-size: .82rem !important; margin-right: 4px !important; }
/* ---- Edit modal ---- */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal {
background: #fff;
border-radius: 10px;
padding: 28px 32px;
width: 420px;
max-width: calc(100vw - 32px);
box-shadow: 0 20px 60px rgba(0,0,0,.3);
}
.modal h3 { font-size: 1.1rem; margin-bottom: 18px; }
.modal-actions { display: flex; gap: 8px; margin-top: 18px; }
/* ---- Messages ---- */
.msg { margin-top: 12px; padding: 10px 14px; border-radius: 6px; font-size: .93rem; }
.msg:empty { display: none; }
.msg.ok { background: #d1fae5; color: #065f46; border: 1px solid #6ee7b7; }
.msg.err { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; }
/* ---- Login overlay ---- */
.login-overlay {
position: fixed;
inset: 0;
background: #1a1a2e;
display: flex;
align-items: center;
justify-content: center;
z-index: 500;
}
.login-card {
background: #fff;
border-radius: 12px;
padding: 40px;
width: 380px;
max-width: calc(100vw - 32px);
box-shadow: 0 20px 60px rgba(0,0,0,.4);
}
.login-card h1 { font-size: 1.6rem; margin-bottom: 4px; text-align: center; }
.login-sub { text-align: center; color: var(--muted); font-size: .9rem; margin-bottom: 24px; }
/* ---- Nav right (user + logout) ---- */
.nav-right { margin-left: auto; display: flex; align-items: center; gap: 12px; }
.nav-user { color: #aaa; font-size: .88rem; }
.nav-logout {
background: transparent;
border: 1px solid #555;
color: #ccc;
padding: 4px 12px;
border-radius: 4px;
cursor: pointer;
font-size: .85rem;
transition: background .15s;
}
.nav-logout:hover { background: rgba(255,255,255,.1); }
/* ---- Admin form extras ---- */
.label-hint { font-weight: 400; color: #aaa; font-size: .8rem; }
.form-row textarea {
padding: 9px 12px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 1rem;
outline: none;
resize: vertical;
font-family: inherit;
transition: border-color .15s;
}
.form-row textarea:focus { border-color: var(--primary); }
.form-row-check { flex-direction: row !important; align-items: center; gap: 8px; }
.form-row-check label { font-weight: 400; color: var(--text); font-size: 1rem; }
.panel-divider { border: none; border-top: 1px solid var(--border); margin: 20px 0; }
.sub-heading { font-size: 1rem; margin-bottom: 14px; color: var(--text); }