diff --git a/.gitignore b/.gitignore index 83972fa..2c6195e 100644 --- a/.gitignore +++ b/.gitignore @@ -216,3 +216,9 @@ __marimo__/ # Streamlit .streamlit/secrets.toml + +# ClubLedger runtime data +clubledger.db +clubledger.db-wal +clubledger.db-shm +staff.json diff --git a/docs/admin-guide.md b/docs/admin-guide.md new file mode 100644 index 0000000..5243644 --- /dev/null +++ b/docs/admin-guide.md @@ -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 1–4** | 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; +``` diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..7f27240 --- /dev/null +++ b/docs/deployment.md @@ -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 + + + + + Label + com.clubledger + ProgramArguments + + /Users/youruser/ClubLedger/.venv/bin/python + /Users/youruser/ClubLedger/main.py + + WorkingDirectory + /Users/youruser/ClubLedger + RunAtLoad + + KeepAlive + + StandardOutPath + /Users/youruser/ClubLedger/clubledger.log + StandardErrorPath + /Users/youruser/ClubLedger/clubledger.log + + +``` + +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://: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://: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://: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` | diff --git a/docs/developer-guide.md b/docs/developer-guide.md new file mode 100644 index 0000000..750afe9 --- /dev/null +++ b/docs/developer-guide.md @@ -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 +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=` 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.`, 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. diff --git a/docs/user-guide.md b/docs/user-guide.md new file mode 100644 index 0000000..b3b2d94 --- /dev/null +++ b/docs/user-guide.md @@ -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. diff --git a/main.py b/main.py new file mode 100644 index 0000000..e14fdd1 --- /dev/null +++ b/main.py @@ -0,0 +1,1208 @@ +""" +ClubLedger – Store Credit Web App +Hard defaults live in CONFIG below; everything is overridable via the Admin UI. +""" + +import sqlite3 +import json +import os +import secrets +from contextlib import contextmanager, asynccontextmanager +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + +import bcrypt +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError +from fastapi import FastAPI, HTTPException, Cookie, Depends, Response, UploadFile, File +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel, field_validator + +# --------------------------------------------------------------------------- +# Hard defaults (overridden by app_settings table via Admin area) +# --------------------------------------------------------------------------- + +def _server_timezone() -> str: + """Detect the server's IANA timezone name for use as the default.""" + try: + p = Path('/etc/timezone') + if p.exists(): + return p.read_text().strip() + p = Path('/etc/localtime') + if p.is_symlink(): + target = str(p.resolve()) + if 'zoneinfo/' in target: + return target.split('zoneinfo/', 1)[-1] + except Exception: + pass + return 'UTC' + +CONFIG = { + "club_name": "ClubLedger", + "currency_symbol": "£", + "currency_major": "pounds", + "currency_minor": "pence", + "currency_divisor": 100, + "overdraft_policy": "never", + "min_topup": 100, + "max_topup": 100_000, + "max_charge": 50_000, + # Business contact + "biz_address1": "", + "biz_address2": "", + "biz_address3": "", + "biz_address4": "", + "biz_country": "", + "biz_phone": "", + "biz_email": "", + "biz_website": "", + # Branding + "logo_url": "", + "logo_align": "left", + "logo_max_width": 200, + "logo_max_height": 80, + "bar_name": "Bar", + "cashier_name": "Cashier", + # Transactions + "txn_ref_prefix": "TXN", + "transfer_types": "Bank Transfer,Cash,QR", + # Receipt labels (localizable) + "lbl_receipt": "RECEIPT", + "lbl_topup_receipt": "TOP-UP RECEIPT", + "lbl_withdrawal_receipt": "WITHDRAWAL RECEIPT", + "lbl_staff": "STAFF", + "lbl_transaction": "TRANSACTION", + "lbl_charge_venue": "CHARGE", + "lbl_txn_time": "TRANSACTION TIME", + "lbl_amount_charged": "AMOUNT CHARGED", + "lbl_remaining_balance": "REMAINING BALANCE", + "lbl_balance_transfer": "BALANCE TRANSFER", + "lbl_amount_topup": "AMOUNT TOPPED-UP", + "lbl_amount_withdrawal": "AMOUNT WITHDRAWN", + "lbl_transfer_type": "TRANSFER TYPE", + "lbl_transfer_ref": "TRANSFER REFERENCE", + # Receipt footers + "receipt_footer": "", + "receipt_footer_charge": "", + "receipt_footer_cashier": "", + # Timezone for display (IANA name); defaults to server local timezone + "timezone": _server_timezone(), + # Default paper size for receipts and statements + "paper_size": "A4", +} + +DB_PATH = "clubledger.db" +STAFF_FILE = Path(__file__).parent / "staff.json" +static_dir = Path(__file__).parent / "static" + +# In-memory sessions: token → {user_id, name, role, expires} +_sessions: dict = {} + +# Cached settings merged from CONFIG + DB app_settings +_settings: dict = {} + +# --------------------------------------------------------------------------- +# Database +# --------------------------------------------------------------------------- + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + return conn + +@contextmanager +def db_conn(): + conn = get_db() + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + +def init_db(): + with db_conn() as conn: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + member_number TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + pin_hash TEXT NOT NULL, + overdraft_override INTEGER DEFAULT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS ledger_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + member_id INTEGER NOT NULL REFERENCES members(id), + amount INTEGER NOT NULL, + type TEXT NOT NULL CHECK(type IN ('topup','charge','withdrawal')), + venue TEXT NOT NULL CHECK(venue IN ('cashier','bar')), + note TEXT, + staff_name TEXT NOT NULL, + transfer_type TEXT, + transfer_ref TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS products ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + brand TEXT, + price INTEGER NOT NULL, + member_price INTEGER, + search_tags TEXT, + active INTEGER NOT NULL DEFAULT 1 + ); + CREATE TABLE IF NOT EXISTS staff_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'pos-staff' + CHECK(role IN ('cashier','pos-staff','admin')), + active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_ledger_member + ON ledger_entries(member_id); + """) + +def migrate_db(): + """Run schema migrations that can't be expressed as CREATE TABLE IF NOT EXISTS.""" + with db_conn() as conn: + # --- members: add overdraft_override column --- + cols = [r[1] for r in conn.execute("PRAGMA table_info(members)").fetchall()] + if "overdraft_override" not in cols: + conn.execute("ALTER TABLE members ADD COLUMN overdraft_override INTEGER DEFAULT NULL") + + # --- staff_accounts: add cashier/pos-staff roles --- + schema = conn.execute( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='staff_accounts'" + ).fetchone() + if schema and "'pos-staff'" not in schema["sql"]: + conn.execute("ALTER TABLE staff_accounts RENAME TO _staff_accounts_old") + conn.execute(""" + CREATE TABLE staff_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'pos-staff' + CHECK(role IN ('cashier','pos-staff','admin')), + active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute(""" + INSERT INTO staff_accounts + SELECT id, name, username, password_hash, + CASE role WHEN 'staff' THEN 'pos-staff' ELSE role END, + active, created_at + FROM _staff_accounts_old + """) + conn.execute("DROP TABLE _staff_accounts_old") + + # --- ledger_entries: add withdrawal type --- + le_schema = conn.execute( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='ledger_entries'" + ).fetchone() + if le_schema and "'withdrawal'" not in le_schema["sql"]: + conn.execute("ALTER TABLE ledger_entries RENAME TO _ledger_entries_old") + conn.execute(""" + CREATE TABLE ledger_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + member_id INTEGER NOT NULL REFERENCES members(id), + amount INTEGER NOT NULL, + type TEXT NOT NULL CHECK(type IN ('topup','charge','withdrawal')), + venue TEXT NOT NULL CHECK(venue IN ('cashier','bar')), + note TEXT, + staff_name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute("INSERT INTO ledger_entries SELECT * FROM _ledger_entries_old") + conn.execute("DROP TABLE _ledger_entries_old") + conn.execute("CREATE INDEX IF NOT EXISTS idx_ledger_member ON ledger_entries(member_id)") + + # --- app_settings: rename allow_negative_balance → overdraft_policy --- + row = conn.execute( + "SELECT value FROM app_settings WHERE key='allow_negative_balance'" + ).fetchone() + if row is not None: + old_val = json.loads(row[0]) + conn.execute( + "INSERT OR IGNORE INTO app_settings (key,value) VALUES (?,?)", + ("overdraft_policy", json.dumps("always" if old_val else "never")) + ) + conn.execute("DELETE FROM app_settings WHERE key='allow_negative_balance'") + + # --- ledger_entries: add transfer_type and transfer_ref columns --- + le_cols = [r[1] for r in conn.execute("PRAGMA table_info(ledger_entries)").fetchall()] + if "transfer_type" not in le_cols: + conn.execute("ALTER TABLE ledger_entries ADD COLUMN transfer_type TEXT") + if "transfer_ref" not in le_cols: + conn.execute("ALTER TABLE ledger_entries ADD COLUMN transfer_ref TEXT") + +def seed_admin(): + with db_conn() as conn: + if conn.execute("SELECT COUNT(*) FROM staff_accounts WHERE role='admin'").fetchone()[0] == 0: + pw = bcrypt.hashpw(b"admin", bcrypt.gensalt()).decode() + conn.execute( + "INSERT INTO staff_accounts (name, username, password_hash, role) VALUES (?,?,?,?)", + ("Administrator", "admin", pw, "admin") + ) + print("=" * 60) + print(" Default admin created → username: admin password: admin") + print(" Change this immediately in the Admin → Staff Accounts area.") + print("=" * 60) + +def refresh_settings(): + global _settings + with db_conn() as conn: + rows = conn.execute("SELECT key, value FROM app_settings").fetchall() + overrides = {r["key"]: json.loads(r["value"]) for r in rows} + _settings = {**CONFIG, **overrides} + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def hash_pin(pin: str) -> str: + return bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode() + +def verify_pin(pin: str, hashed: str) -> bool: + return bcrypt.checkpw(pin.encode(), hashed.encode()) + +def member_balance(conn, member_id: int) -> int: + row = conn.execute(""" + SELECT COALESCE(SUM(CASE WHEN type='topup' THEN amount ELSE -amount END),0) AS b + FROM ledger_entries WHERE member_id=? + """, (member_id,)).fetchone() + return row["b"] if row else 0 + +def format_amount(pence: int) -> str: + sym = _settings.get("currency_symbol") or CONFIG["currency_symbol"] + div = _settings.get("currency_divisor") or CONFIG["currency_divisor"] + return f"{sym}{pence / div:.2f}" + +def _display_tz(s: dict): + """Return a ZoneInfo (or local tzinfo) for the configured display timezone.""" + tz_name = (s.get("timezone") or "").strip() + if tz_name: + try: + return ZoneInfo(tz_name) + except (ZoneInfoNotFoundError, KeyError): + pass + return datetime.now().astimezone().tzinfo # server local + +def _fmt_dt(dt_str: str, s: dict) -> str: + """Convert a stored UTC datetime string to the configured display timezone.""" + try: + dt_utc = datetime.fromisoformat(dt_str.replace(' ', 'T')).replace(tzinfo=timezone.utc) + local = dt_utc.astimezone(_display_tz(s)) + return local.strftime('%Y-%m-%d %H:%M %Z') + except Exception: + return dt_str[:16] + ' UTC' + +def _now_display(s: dict) -> str: + """Current time formatted in the configured display timezone.""" + try: + local = datetime.now(timezone.utc).astimezone(_display_tz(s)) + return local.strftime('%Y-%m-%d %H:%M %Z') + except Exception: + return datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC') + +def load_staff() -> list: + if STAFF_FILE.exists(): + return json.loads(STAFF_FILE.read_text()).get("staff", []) + return [] + +def save_staff(names: list): + STAFF_FILE.write_text(json.dumps({"staff": sorted(set(names))}, indent=2)) + +# --------------------------------------------------------------------------- +# Auth helpers +# --------------------------------------------------------------------------- + +SESSION_TTL = 8 * 3600 # seconds + +def current_user(session: Optional[str] = Cookie(default=None)): + if not session or session not in _sessions: + raise HTTPException(401, "Not authenticated") + s = _sessions[session] + if datetime.now(timezone.utc).timestamp() > s["expires"]: + del _sessions[session] + raise HTTPException(401, "Session expired") + return s + +def admin_user(user: dict = Depends(current_user)): + if user["role"] != "admin": + raise HTTPException(403, "Admin access required") + +def cashier_user(user: dict = Depends(current_user)): + if user["role"] not in ("cashier", "admin"): + raise HTTPException(403, "Cashier access required") + return user + +def pos_user(user: dict = Depends(current_user)): + if user["role"] not in ("pos-staff", "admin"): + raise HTTPException(403, "POS staff access required") + return user + +# --------------------------------------------------------------------------- +# App +# --------------------------------------------------------------------------- + +@asynccontextmanager +async def lifespan(app): + init_db() + migrate_db() + seed_admin() + refresh_settings() + yield + +app = FastAPI(title="ClubLedger", lifespan=lifespan) +static_dir.mkdir(exist_ok=True) +app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") + +# --------------------------------------------------------------------------- +# Pydantic models +# --------------------------------------------------------------------------- + +class MemberCreate(BaseModel): + member_number: str + name: str + pin: str + + @field_validator("pin") + @classmethod + def pin_length(cls, v): + if len(v) < 4: + raise ValueError("PIN must be at least 4 characters") + return v + + @field_validator("member_number") + @classmethod + def member_number_nonempty(cls, v): + v = v.strip() + if not v: + raise ValueError("member_number cannot be empty") + return v + +class MemberUpdate(BaseModel): + member_number: Optional[str] = None + name: Optional[str] = None + pin: Optional[str] = None + overdraft_override: Optional[int] = None # NULL=default, 1=allow, 0=block + +class TopupRequest(BaseModel): + member_id: int + amount: int + note: Optional[str] = None + transfer_type: Optional[str] = None + transfer_ref: Optional[str] = None + +class ChargeRequest(BaseModel): + member_id: int + amount: int + pin: str + note: Optional[str] = None + +class WithdrawalRequest(BaseModel): + member_id: int + amount: int + pin: str + note: Optional[str] = None + transfer_type: Optional[str] = None + transfer_ref: Optional[str] = None + +class ProductCreate(BaseModel): + name: str + brand: Optional[str] = None + price: int + member_price: Optional[int] = None + search_tags: Optional[str] = None + +class StaffAdd(BaseModel): + name: str + +class LoginRequest(BaseModel): + username: str + password: str + +class StaffAccountCreate(BaseModel): + name: str + username: str + password: str + role: str = "pos-staff" + +class StaffAccountUpdate(BaseModel): + name: Optional[str] = None + username: Optional[str] = None + password: Optional[str] = None + role: Optional[str] = None + active: Optional[bool] = None + +class AppSettingsUpdate(BaseModel): + club_name: Optional[str] = None + currency_symbol: Optional[str] = None + currency_major: Optional[str] = None + currency_minor: Optional[str] = None + currency_divisor: Optional[int] = None + overdraft_policy: Optional[str] = None + min_topup: Optional[int] = None + max_topup: Optional[int] = None + max_charge: Optional[int] = None + # Business contact + biz_address1: Optional[str] = None + biz_address2: Optional[str] = None + biz_address3: Optional[str] = None + biz_address4: Optional[str] = None + biz_country: Optional[str] = None + biz_phone: Optional[str] = None + biz_email: Optional[str] = None + biz_website: Optional[str] = None + # Branding + logo_url: Optional[str] = None + logo_align: Optional[str] = None + logo_max_width: Optional[int] = None + logo_max_height: Optional[int] = None + bar_name: Optional[str] = None + cashier_name: Optional[str] = None + # Transactions + txn_ref_prefix: Optional[str] = None + transfer_types: Optional[str] = None + # Receipt labels + lbl_receipt: Optional[str] = None + lbl_topup_receipt: Optional[str] = None + lbl_withdrawal_receipt: Optional[str] = None + lbl_staff: Optional[str] = None + lbl_transaction: Optional[str] = None + lbl_charge_venue: Optional[str] = None + lbl_txn_time: Optional[str] = None + lbl_amount_charged: Optional[str] = None + lbl_remaining_balance: Optional[str] = None + lbl_balance_transfer: Optional[str] = None + lbl_amount_topup: Optional[str] = None + lbl_amount_withdrawal: Optional[str] = None + lbl_transfer_type: Optional[str] = None + lbl_transfer_ref: Optional[str] = None + # Receipt footers + receipt_footer: Optional[str] = None + receipt_footer_charge: Optional[str] = None + receipt_footer_cashier: Optional[str] = None + # Timezone + timezone: Optional[str] = None + # Default paper size + paper_size: Optional[str] = None + +# --------------------------------------------------------------------------- +# Page routes +# --------------------------------------------------------------------------- + +@app.get("/", response_class=HTMLResponse) +async def root(): + return (static_dir / "index.html").read_text() + +@app.get("/cashier", response_class=HTMLResponse) +async def cashier_page(): + return (static_dir / "cashier.html").read_text() + +@app.get("/bar", response_class=HTMLResponse) +async def bar_page(): + return (static_dir / "bar.html").read_text() + +# --------------------------------------------------------------------------- +# Auth endpoints +# --------------------------------------------------------------------------- + +@app.post("/auth/login") +def login(body: LoginRequest, response: Response): + with db_conn() as conn: + row = conn.execute( + "SELECT * FROM staff_accounts WHERE username=? AND active=1", + (body.username.strip(),) + ).fetchone() + if not row or not bcrypt.checkpw(body.password.encode(), row["password_hash"].encode()): + raise HTTPException(401, "Invalid username or password") + token = secrets.token_hex(32) + _sessions[token] = { + "user_id": row["id"], + "name": row["name"], + "role": row["role"], + "expires": datetime.now(timezone.utc).timestamp() + SESSION_TTL, + } + response.set_cookie("session", token, httponly=True, max_age=SESSION_TTL, samesite="strict") + return {"name": row["name"], "role": row["role"]} + +@app.post("/auth/logout") +def logout(response: Response, session: Optional[str] = Cookie(default=None)): + if session and session in _sessions: + del _sessions[session] + response.delete_cookie("session") + return {"ok": True} + +@app.get("/auth/me") +def auth_me(user: dict = Depends(current_user)): + return {"name": user["name"], "role": user["role"]} + +# --------------------------------------------------------------------------- +# Member endpoints +# --------------------------------------------------------------------------- + +@app.post("/members") +def create_member(body: MemberCreate, user: dict = Depends(current_user)): + with db_conn() as conn: + if conn.execute("SELECT id FROM members WHERE member_number=?", + (body.member_number.strip(),)).fetchone(): + raise HTTPException(400, "Member number already exists") + cur = conn.execute( + "INSERT INTO members (member_number, name, pin_hash) VALUES (?,?,?)", + (body.member_number.strip(), body.name.strip(), hash_pin(body.pin)) + ) + mid = cur.lastrowid + with db_conn() as conn: + r = conn.execute("SELECT * FROM members WHERE id=?", (mid,)).fetchone() + return {"id": r["id"], "member_number": r["member_number"], + "name": r["name"], "created_at": r["created_at"]} + +@app.put("/members/{member_id}") +def update_member(member_id: int, body: MemberUpdate, user: dict = Depends(current_user)): + with db_conn() as conn: + if not conn.execute("SELECT id FROM members WHERE id=?", (member_id,)).fetchone(): + raise HTTPException(404, "Member not found") + updates = {} + if body.name is not None: + n = body.name.strip() + if not n: raise HTTPException(400, "Name cannot be empty") + updates["name"] = n + if body.member_number is not None: + mn = body.member_number.strip() + if not mn: raise HTTPException(400, "Member number cannot be empty") + if conn.execute("SELECT id FROM members WHERE member_number=? AND id!=?", + (mn, member_id)).fetchone(): + raise HTTPException(400, "Member number already in use") + updates["member_number"] = mn + if body.pin is not None: + if len(body.pin) < 4: raise HTTPException(400, "PIN must be at least 4 characters") + updates["pin_hash"] = hash_pin(body.pin) + if "overdraft_override" in body.model_fields_set: + updates["overdraft_override"] = body.overdraft_override # None, 0, or 1 + if updates: + conn.execute( + f"UPDATE members SET {', '.join(f'{k}=?' for k in updates)} WHERE id=?", + list(updates.values()) + [member_id] + ) + r = conn.execute("SELECT * FROM members WHERE id=?", (member_id,)).fetchone() + return {"id": r["id"], "member_number": r["member_number"], "name": r["name"]} + +@app.delete("/members/{member_id}") +def delete_member(member_id: int, user: dict = Depends(current_user)): + with db_conn() as conn: + if not conn.execute("SELECT id FROM members WHERE id=?", (member_id,)).fetchone(): + raise HTTPException(404, "Member not found") + bal = member_balance(conn, member_id) + if bal != 0: + raise HTTPException(400, f"Cannot delete: balance is {format_amount(bal)}") + conn.execute("DELETE FROM ledger_entries WHERE member_id=?", (member_id,)) + conn.execute("DELETE FROM members WHERE id=?", (member_id,)) + return {"ok": True} + +@app.get("/members") +def list_members(q: Optional[str] = None, user: dict = Depends(current_user)): + with db_conn() as conn: + if q: + pat = f"%{q}%" + rows = conn.execute( + "SELECT * FROM members WHERE name LIKE ? OR member_number LIKE ? ORDER BY name", + (pat, pat) + ).fetchall() + else: + rows = conn.execute("SELECT * FROM members ORDER BY name").fetchall() + result = [] + for r in rows: + bal = member_balance(conn, r["id"]) + result.append({ + "id": r["id"], "member_number": r["member_number"], "name": r["name"], + "overdraft_override": r["overdraft_override"], + "balance": bal, "balance_display": format_amount(bal), "created_at": r["created_at"], + }) + return result + +@app.post("/topup") +def topup(body: TopupRequest, user: dict = Depends(cashier_user)): + s = _settings + if body.amount < s["min_topup"]: + raise HTTPException(400, f"Minimum top-up is {format_amount(s['min_topup'])}") + if body.amount > s["max_topup"]: + raise HTTPException(400, f"Maximum top-up is {format_amount(s['max_topup'])}") + with db_conn() as conn: + if not conn.execute("SELECT id FROM members WHERE id=?", (body.member_id,)).fetchone(): + raise HTTPException(404, "Member not found") + cur = conn.execute( + "INSERT INTO ledger_entries (member_id,amount,type,venue,note,staff_name,transfer_type,transfer_ref) VALUES (?,?,?,?,?,?,?,?)", + (body.member_id, body.amount, "topup", "cashier", body.note, user["name"], + body.transfer_type, body.transfer_ref) + ) + eid = cur.lastrowid + bal = member_balance(conn, body.member_id) + return {"ok": True, "entry_id": eid, "new_balance": bal, "new_balance_display": format_amount(bal)} + +@app.post("/charge") +def charge(body: ChargeRequest, user: dict = Depends(pos_user)): + s = _settings + if body.amount <= 0: + raise HTTPException(400, "Amount must be positive") + if body.amount > s["max_charge"]: + raise HTTPException(400, f"Maximum single charge is {format_amount(s['max_charge'])}") + with db_conn() as conn: + member = conn.execute("SELECT * FROM members WHERE id=?", (body.member_id,)).fetchone() + if not member: + raise HTTPException(404, "Member not found") + if not verify_pin(body.pin, member["pin_hash"]): + raise HTTPException(403, "Incorrect PIN") + bal = member_balance(conn, body.member_id) + policy = s.get("overdraft_policy", "never") + member_ov = member["overdraft_override"] # None, 0, or 1 + if policy == "never": + overdraft_ok = False + elif policy == "always": + overdraft_ok = True + elif policy in ("staff_override", "admin_override"): + overdraft_ok = (member_ov == 1) + elif policy == "staff_block": + overdraft_ok = (member_ov != 0) # None or 1 = allowed; 0 = explicitly blocked + else: + overdraft_ok = False + if not overdraft_ok and bal < body.amount: + raise HTTPException(400, f"Insufficient balance ({format_amount(bal)})") + cur = conn.execute( + "INSERT INTO ledger_entries (member_id,amount,type,venue,note,staff_name) VALUES (?,?,?,?,?,?)", + (body.member_id, body.amount, "charge", "bar", body.note, user["name"]) + ) + eid = cur.lastrowid + new_bal = member_balance(conn, body.member_id) + return {"ok": True, "entry_id": eid, "new_balance": new_bal, "new_balance_display": format_amount(new_bal)} + +@app.post("/withdrawal") +def withdrawal(body: WithdrawalRequest, user: dict = Depends(cashier_user)): + if body.amount <= 0: + raise HTTPException(400, "Amount must be positive") + with db_conn() as conn: + member = conn.execute("SELECT * FROM members WHERE id=?", (body.member_id,)).fetchone() + if not member: + raise HTTPException(404, "Member not found") + if not verify_pin(body.pin, member["pin_hash"]): + raise HTTPException(403, "Incorrect PIN") + bal = member_balance(conn, body.member_id) + if bal < body.amount: + raise HTTPException(400, f"Insufficient balance ({format_amount(bal)})") + cur = conn.execute( + "INSERT INTO ledger_entries (member_id,amount,type,venue,note,staff_name,transfer_type,transfer_ref) VALUES (?,?,?,?,?,?,?,?)", + (body.member_id, body.amount, "withdrawal", "cashier", body.note, user["name"], + body.transfer_type, body.transfer_ref) + ) + eid = cur.lastrowid + new_bal = member_balance(conn, body.member_id) + return {"ok": True, "entry_id": eid, "new_balance": new_bal, "new_balance_display": format_amount(new_bal)} + +@app.get("/members/{member_id}/transactions") +def transactions(member_id: int, limit: int = 50, offset: int = 0, + user: dict = Depends(current_user)): + with db_conn() as conn: + member = conn.execute("SELECT * FROM members WHERE id=?", (member_id,)).fetchone() + if not member: + raise HTTPException(404, "Member not found") + rows = conn.execute(""" + SELECT * FROM ledger_entries WHERE member_id=? + ORDER BY created_at DESC LIMIT ? OFFSET ? + """, (member_id, limit, offset)).fetchall() + bal = member_balance(conn, member_id) + return { + "member": {"id": member["id"], "member_number": member["member_number"], "name": member["name"]}, + "balance": bal, "balance_display": format_amount(bal), + "transactions": [ + {"id": r["id"], "amount": r["amount"], "amount_display": format_amount(r["amount"]), + "type": r["type"], "venue": r["venue"], "note": r["note"], + "staff_name": r["staff_name"], "created_at": r["created_at"]} + for r in rows + ], + } + +# --------------------------------------------------------------------------- +# Print views (no auth – opened as new-tab popups) +# --------------------------------------------------------------------------- + +def _print_size_script(s: dict): + size = "A5" if (s.get("paper_size") or "A4").upper() == "A5" else "A4" + return f"""""" + +def _print_controls(s: dict): + size = "A5" if (s.get("paper_size") or "A4").upper() == "A5" else "A4" + a4_chk = ' checked' if size == "A4" else '' + a5_chk = ' checked' if size == "A5" else '' + return f"""
+ Paper: + + + +
""" + +def _txn_ref(entry_id: int, s: dict) -> str: + prefix = (s.get("txn_ref_prefix") or "TXN").strip() + return f"{prefix}{entry_id:07d}" + +def _logo_html(s: dict) -> str: + url = (s.get("logo_url") or "").strip() + if not url: + return "" + align = s.get("logo_align", "left") + max_w = int(s.get("logo_max_width", 200) or 200) + max_h = int(s.get("logo_max_height", 80) or 80) + style = f"max-width:{max_w}px;max-height:{max_h}px;" + css_cl = f"biz-logo align-{align}" if align in ("left","center","right") else "biz-logo" + return f'logo' + +def _biz_header_html(s: dict) -> str: + logo = _logo_html(s) + name = s.get("club_name") or "ClubLedger" + + addr = [( s.get(f"biz_address{i}") or "").strip() for i in range(1,5)] + addr += [(s.get("biz_country") or "").strip()] + addr = [l for l in addr if l] + + contacts = [] + if (s.get("biz_phone") or "").strip(): contacts.append(f'Tel.   {s["biz_phone"]}') + if (s.get("biz_email") or "").strip(): contacts.append(f'Email: {s["biz_email"]}') + if (s.get("biz_website") or "").strip(): contacts.append(f'Web:   {s["biz_website"]}') + + parts = [] + if logo: parts.append(logo) + parts.append(f'
{name}
') + + if addr and contacts: + parts.append( + f'
' + f'
{"
".join(addr)}
' + f'
{"
".join(contacts)}
' + f'
' + ) + elif addr: + parts.append(f'
{"
".join(addr)}
') + elif contacts: + parts.append(f'
{"
".join(contacts)}
') + + return '
' + "\n".join(parts) + "
" + +def _rx_cell(label: str, value: str, extra_cls: str = "") -> str: + val_cls = ("rx-val " + extra_cls).strip() + return f'
{label}
{value}
' + +RECEIPT_CSS = """ + body{font-family:Arial,sans-serif;font-size:11pt;color:#111;margin:28px;} + hr{border:none;border-top:1px solid #ccc;margin:12px 0;} + .controls{display:flex;align-items:center;gap:12px;margin-bottom:16px;flex-wrap:wrap;} + .size-label{font-size:10pt;color:#555;} + .controls label{font-size:10pt;cursor:pointer;} + .print-btn{padding:6px 16px;font-size:10pt;cursor:pointer;margin-left:auto;} + @media print{.no-print{display:none;}} + /* Business header */ + .biz-logo{display:block;margin-bottom:8px;} + .biz-logo.align-center{margin-left:auto;margin-right:auto;} + .biz-logo.align-right{margin-left:auto;} + .biz-name{font-size:14pt;font-weight:bold;margin:4px 0 6px;} + .biz-info-row{display:flex;justify-content:space-between;align-items:flex-start;gap:24px;font-size:10pt;line-height:1.7;} + .biz-addr{line-height:1.7;} + .biz-contacts{text-align:right;white-space:nowrap;line-height:1.7;} + /* Receipt */ + .rx-title{font-size:13pt;font-weight:bold;text-transform:uppercase;letter-spacing:.06em;margin:14px 0 12px;} + .rx-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px 40px;margin:10px 0;} + .rx-cell{} + .rx-lbl{font-size:9pt;font-weight:700;color:#555;text-transform:uppercase;letter-spacing:.05em;margin-bottom:3px;} + .rx-val{font-size:11pt;} + .rx-val.bold{font-weight:bold;} + .rx-val.large{font-size:13pt;font-weight:bold;} + .rx-val.charge{color:#c00;} + .rx-val.credit{color:#080;} + .footer{margin-top:20px;font-size:10pt;color:#444;line-height:1.7;white-space:pre-wrap;} + /* Statement */ + h2{font-size:13pt;font-weight:bold;margin:14px 0 4px;} + .stmt-info{font-size:10pt;color:#555;margin-bottom:12px;line-height:1.6;} + table{width:100%;border-collapse:collapse;margin-top:4px;font-size:10pt;} + th{border-bottom:2px solid #222;padding:6px 8px 6px 0;text-align:left;font-size:9pt;font-weight:700;white-space:nowrap;} + td{padding:5px 8px 5px 0;border-bottom:1px solid #e0e0e0;vertical-align:top;} + th.rnum,td.rnum{text-align:right;padding-right:0;} + .credit{color:#080;} + .debit{color:#c00;} + .sub-row td{font-size:10pt;color:#555;padding-top:0;border-bottom:none;padding-left:88px;} + .balance-box{margin-top:14px;text-align:right;font-size:11pt;font-weight:bold;} +""" + +@app.get("/members/{member_id}/statement", response_class=HTMLResponse) +def statement(member_id: int): + s = _settings + with db_conn() as conn: + member = conn.execute("SELECT * FROM members WHERE id=?", (member_id,)).fetchone() + if not member: raise HTTPException(404, "Member not found") + rows = conn.execute( + "SELECT * FROM ledger_entries WHERE member_id=? ORDER BY created_at ASC", + (member_id,) + ).fetchall() + bal = member_balance(conn, member_id) + + sym, div = s.get("currency_symbol","£"), s.get("currency_divisor",100) + footer = s.get("receipt_footer","") + bar_name = s.get("bar_name","Bar") + cashier_name= s.get("cashier_name","Cashier") + + def fmt(p): return f"{sym}{p/div:.2f}" + + rows_html, running = "", 0 + for r in rows: + txn_ref = _txn_ref(r["id"], s) + venue = bar_name if r["venue"] == "bar" else cashier_name + if r["type"] == "topup": + running += r["amount"] + amt_html = f'+ {fmt(r["amount"])}' + type_lbl = "Top-up" + elif r["type"] == "withdrawal": + running -= r["amount"] + amt_html = f'- {fmt(r["amount"])}' + type_lbl = "Withdrawal" + else: + running -= r["amount"] + amt_html = f'- {fmt(r["amount"])}' + type_lbl = "Charge" + + rows_html += ( + f"{_fmt_dt(r['created_at'], s)}{txn_ref}" + f"{type_lbl}{venue}{r['staff_name']}" + f"{amt_html}{fmt(running)}" + ) + + # Detail sub-row + sub = "" + if r["type"] in ("topup","withdrawal"): + tf_type = r["transfer_type"] or "" + tf_ref = r["transfer_ref"] or "" + if tf_type and tf_ref: + sub = f"Transfer type: {tf_type} — {tf_ref}" + elif tf_type: + sub = f"Transfer type: {tf_type}" + elif tf_ref: + sub = f"Ref: {tf_ref}" + elif r["note"]: + sub = r["note"] + + if sub: + rows_html += f'{sub}' + + return f""" +Statement — {member['name']} +{_print_controls(s)} +{_biz_header_html(s)} +
+

Account Statement

+
+ Member: {member['name']} — #{member['member_number']} — + Generated: {_now_display(s)} +
+ + + +{rows_html}
Date and TimeReferenceTypeVenueStaffAmountBalance
+
Current Balance: {fmt(bal)}
+{('') if footer else ''} +{_print_size_script(s)}""" + +@app.get("/receipt/{entry_id}", response_class=HTMLResponse) +def receipt(entry_id: int): + s = _settings + with db_conn() as conn: + entry = conn.execute("SELECT * FROM ledger_entries WHERE id=?", (entry_id,)).fetchone() + if not entry: raise HTTPException(404, "Receipt not found") + member = conn.execute("SELECT * FROM members WHERE id=?", (entry["member_id"],)).fetchone() + bal_after = conn.execute(""" + SELECT COALESCE(SUM(CASE WHEN type='topup' THEN amount ELSE -amount END),0) + FROM ledger_entries WHERE member_id=? AND id<=? + """, (entry["member_id"], entry_id)).fetchone()[0] + + sym, div = s.get("currency_symbol","£"), s.get("currency_divisor",100) + def fmt(p): return f"{sym}{p/div:.2f}" + + txn_ref = _txn_ref(entry_id, s) + etype = entry["type"] + venue_name = s.get("bar_name","Bar") if entry["venue"]=="bar" else s.get("cashier_name","Cashier") + tf_type = entry["transfer_type"] or "" + tf_ref = entry["transfer_ref"] or "" + timestamp = _fmt_dt(entry["created_at"], s) + + lbl_staff = s.get("lbl_staff", "STAFF") + lbl_txn = s.get("lbl_transaction", "TRANSACTION") + lbl_txn_time = s.get("lbl_txn_time", "TRANSACTION TIME") + lbl_remaining = s.get("lbl_remaining_balance", "REMAINING BALANCE") + + if etype == "topup": + title = s.get("lbl_topup_receipt", "TOP-UP RECEIPT") + footer = s.get("receipt_footer_cashier") or s.get("receipt_footer","") + lbl_tf_sec = s.get("lbl_balance_transfer", "BALANCE TRANSFER") + lbl_amount = s.get("lbl_amount_topup", "AMOUNT TOPPED-UP") + tf_label = "Top-up" + amount_cls = "large credit" + elif etype == "withdrawal": + title = s.get("lbl_withdrawal_receipt","WITHDRAWAL RECEIPT") + footer = s.get("receipt_footer_cashier") or s.get("receipt_footer","") + lbl_tf_sec = s.get("lbl_balance_transfer", "BALANCE TRANSFER") + lbl_amount = s.get("lbl_amount_withdrawal","AMOUNT WITHDRAWN") + tf_label = "Withdrawal" + amount_cls = "large charge" + else: + title = s.get("lbl_receipt", "RECEIPT") + footer = s.get("receipt_footer_charge") or s.get("receipt_footer","") + lbl_charge = s.get("lbl_charge_venue", "CHARGE") + lbl_amount = s.get("lbl_amount_charged", "AMOUNT CHARGED") + + if etype == "charge": + body_html = f"""
+ {_rx_cell(lbl_staff, entry['staff_name'])} + {_rx_cell(lbl_txn, txn_ref)} +
+
+
+ {_rx_cell(lbl_charge, venue_name)} + {_rx_cell(lbl_txn_time, timestamp)} +
+
+
+ {_rx_cell(lbl_amount, fmt(entry['amount']), 'large charge')} + {_rx_cell(lbl_remaining, fmt(bal_after), 'large')} +
""" + else: + lbl_tf_type = s.get("lbl_transfer_type", "TRANSFER TYPE") + lbl_tf_ref = s.get("lbl_transfer_ref", "TRANSFER REFERENCE") + body_html = f"""
+ {_rx_cell(lbl_staff, entry['staff_name'])} + {_rx_cell(lbl_txn, txn_ref)} +
+
+
+ {_rx_cell(lbl_tf_sec, tf_label)} + {_rx_cell(lbl_txn_time, timestamp)} +
+
+
+ {_rx_cell(lbl_amount, fmt(entry['amount']), amount_cls)} + {_rx_cell(lbl_remaining, fmt(bal_after), 'large')} +
+
+
+ {_rx_cell(lbl_tf_type, tf_type or '—')} + {_rx_cell(lbl_tf_ref, tf_ref or '—')} +
""" + + return f""" +Receipt — {member['name']} +{_print_controls(s)} +{_biz_header_html(s)} +
+
{title}
+{body_html} +
+{('') if footer else ''} +{_print_size_script(s)}""" + +# --------------------------------------------------------------------------- +# Products +# --------------------------------------------------------------------------- + +@app.get("/products") +def list_products(q: Optional[str] = None, active_only: bool = True, + user: dict = Depends(current_user)): + with db_conn() as conn: + conds, params = [], [] + if active_only: conds.append("active=1") + if q: + conds.append("(name LIKE ? OR brand LIKE ? OR search_tags LIKE ?)") + p = f"%{q}%"; params += [p, p, p] + sql = "SELECT * FROM products" + (" WHERE " + " AND ".join(conds) if conds else "") + " ORDER BY name" + rows = conn.execute(sql, params).fetchall() + return [{"id": r["id"], "name": r["name"], "brand": r["brand"], + "price": r["price"], "price_display": format_amount(r["price"]), + "member_price": r["member_price"], + "member_price_display": format_amount(r["member_price"]) if r["member_price"] else None, + "search_tags": r["search_tags"], "active": bool(r["active"])} for r in rows] + +@app.post("/products") +def create_product(body: ProductCreate, user: dict = Depends(current_user)): + with db_conn() as conn: + cur = conn.execute( + "INSERT INTO products (name,brand,price,member_price,search_tags) VALUES (?,?,?,?,?)", + (body.name, body.brand, body.price, body.member_price, body.search_tags) + ) + return {"id": cur.lastrowid, "ok": True} + +# --------------------------------------------------------------------------- +# Legacy staff name list (backward compat with cashier.html / bar.html) +# --------------------------------------------------------------------------- + +@app.get("/staff") +def get_staff(user: dict = Depends(current_user)): + return {"staff": load_staff()} + +@app.post("/staff") +def add_staff(body: StaffAdd, user: dict = Depends(current_user)): + name = body.name.strip() + if not name: raise HTTPException(400, "Name cannot be empty") + staff = load_staff() + if name not in staff: + staff.append(name); save_staff(staff) + return {"staff": sorted(staff)} + +@app.delete("/staff/{name}") +def remove_staff(name: str, user: dict = Depends(current_user)): + staff = [s for s in load_staff() if s != name] + save_staff(staff) + return {"staff": staff} + +# --------------------------------------------------------------------------- +# Admin – staff accounts +# --------------------------------------------------------------------------- + +@app.get("/admin/staff-accounts") +def list_staff_accounts(user: dict = Depends(admin_user)): + with db_conn() as conn: + rows = conn.execute( + "SELECT id,name,username,role,active,created_at FROM staff_accounts ORDER BY name" + ).fetchall() + return [dict(r) for r in rows] + +@app.post("/admin/staff-accounts") +def create_staff_account(body: StaffAccountCreate, user: dict = Depends(admin_user)): + if body.role not in ("cashier", "pos-staff", "admin"): + raise HTTPException(400, "Role must be 'cashier', 'pos-staff', or 'admin'") + with db_conn() as conn: + if conn.execute("SELECT id FROM staff_accounts WHERE username=?", + (body.username.strip(),)).fetchone(): + raise HTTPException(400, "Username already taken") + pw = bcrypt.hashpw(body.password.encode(), bcrypt.gensalt()).decode() + cur = conn.execute( + "INSERT INTO staff_accounts (name,username,password_hash,role) VALUES (?,?,?,?)", + (body.name.strip(), body.username.strip(), pw, body.role) + ) + return {"id": cur.lastrowid, "ok": True} + +@app.put("/admin/staff-accounts/{account_id}") +def update_staff_account(account_id: int, body: StaffAccountUpdate, + user: dict = Depends(admin_user)): + with db_conn() as conn: + if not conn.execute("SELECT id FROM staff_accounts WHERE id=?", (account_id,)).fetchone(): + raise HTTPException(404, "Account not found") + updates = {} + if body.name is not None: updates["name"] = body.name.strip() + if body.username is not None: + if conn.execute("SELECT id FROM staff_accounts WHERE username=? AND id!=?", + (body.username.strip(), account_id)).fetchone(): + raise HTTPException(400, "Username already taken") + updates["username"] = body.username.strip() + if body.password is not None: + updates["password_hash"] = bcrypt.hashpw(body.password.encode(), bcrypt.gensalt()).decode() + if body.role is not None: + if body.role not in ("cashier","pos-staff","admin"): raise HTTPException(400, "Invalid role") + updates["role"] = body.role + if body.active is not None: + updates["active"] = 1 if body.active else 0 + if updates: + conn.execute( + f"UPDATE staff_accounts SET {', '.join(f'{k}=?' for k in updates)} WHERE id=?", + list(updates.values()) + [account_id] + ) + return {"ok": True} + +@app.delete("/admin/staff-accounts/{account_id}") +def delete_staff_account(account_id: int, user: dict = Depends(admin_user)): + if account_id == user["user_id"]: + raise HTTPException(400, "Cannot delete your own account") + with db_conn() as conn: + row = conn.execute("SELECT * FROM staff_accounts WHERE id=?", (account_id,)).fetchone() + if not row: raise HTTPException(404, "Account not found") + if row["role"] == "admin": + if conn.execute("SELECT COUNT(*) FROM staff_accounts WHERE role='admin'").fetchone()[0] <= 1: + raise HTTPException(400, "Cannot delete the last admin account") + conn.execute("DELETE FROM staff_accounts WHERE id=?", (account_id,)) + return {"ok": True} + +# --------------------------------------------------------------------------- +# Admin – app settings +# --------------------------------------------------------------------------- + +@app.get("/admin/settings") +def get_admin_settings(user: dict = Depends(admin_user)): + return _settings + +_OVERDRAFT_POLICIES = ("never", "always", "staff_override", "admin_override", "staff_block") + +@app.post("/admin/settings") +def update_admin_settings(body: AppSettingsUpdate, user: dict = Depends(admin_user)): + if body.overdraft_policy is not None and body.overdraft_policy not in _OVERDRAFT_POLICIES: + raise HTTPException(400, "Invalid overdraft policy") + with db_conn() as conn: + for field in body.model_fields_set: + val = getattr(body, field) + if val is not None: + conn.execute( + "INSERT OR REPLACE INTO app_settings (key,value) VALUES (?,?)", + (field, json.dumps(val)) + ) + refresh_settings() + return _settings + +# --------------------------------------------------------------------------- +# Admin – logo upload +# --------------------------------------------------------------------------- + +@app.post("/admin/logo") +async def upload_logo(file: UploadFile = File(...), user: dict = Depends(admin_user)): + content_type = file.content_type or "" + if not content_type.startswith("image/"): + raise HTTPException(400, "Only image files are allowed") + suffix = Path(file.filename or "logo.png").suffix.lower() + if suffix not in (".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"): + suffix = ".png" + dest = static_dir / f"logo{suffix}" + dest.write_bytes(await file.read()) + url = f"/static/logo{suffix}" + with db_conn() as conn: + conn.execute("INSERT OR REPLACE INTO app_settings (key,value) VALUES (?,?)", + ("logo_url", json.dumps(url))) + refresh_settings() + return {"url": url} + +# --------------------------------------------------------------------------- +# Config (public – loaded by frontend before login screen shows) +# --------------------------------------------------------------------------- + +@app.get("/config") +def get_config(): + s = dict(_settings) + s["currency_unit"] = s.get("currency_major", "pounds") + raw_tt = s.get("transfer_types", "Bank Transfer,Cash,QR") + s["transfer_types"] = [t.strip() for t in raw_tt.split(",") if t.strip()] + return s + +if __name__ == "__main__": + import uvicorn + uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..73017ff --- /dev/null +++ b/manage.py @@ -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]() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d36de0e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi +uvicorn[standard] +bcrypt +python-multipart diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..67e1ed9 --- /dev/null +++ b/run.bat @@ -0,0 +1,3 @@ +python -m venv .venv +.venv\Scripts\pip install -r requirements.txt +.venv\Scripts\python main.py diff --git a/run.sh b/run.sh new file mode 100644 index 0000000..e8213af --- /dev/null +++ b/run.sh @@ -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 "$@" diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..b8f1e62 --- /dev/null +++ b/static/app.js @@ -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 = '' + + types.map(t => ``).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 = 'No members found'; + return; + } + tbody.innerHTML = members.map(m => ` + + ${esc(m.member_number)} + ${esc(m.name)} + ${esc(m.balance_display)} + ${m.created_at ? m.created_at.slice(0, 10) : ''} + + Statement + + ${m.balance === 0 + ? `` + : ''} + + `).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 => ` +
+
${esc(m.name)}
#${esc(m.member_number)}
+
${esc(m.balance_display)}
+
`).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 = + `${esc(name)}   #${esc(number)}   Balance: ${esc(balanceDisplay)}`; + 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 => ` +
+
${esc(m.name)}
#${esc(m.member_number)}
+
${esc(m.balance_display)}
+
`).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 = + `${esc(name)}   #${esc(number)}   Balance: ${esc(balanceDisplay)}`; + 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 => ` + + ${esc(a.name)} + ${esc(a.username)} + ${esc(fmtRole(a.role))} + ${a.active ? 'Active' : 'Inactive'} + + + + + `).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); } +} diff --git a/static/bar.html b/static/bar.html new file mode 100644 index 0000000..0306a34 --- /dev/null +++ b/static/bar.html @@ -0,0 +1,57 @@ + + + + + +Bar – ClubLedger + + + + + + +
+ +
+

Charge Account

+
+ + +
+
+ + +
+
+ +
+ + + + + diff --git a/static/bar.js b/static/bar.js new file mode 100644 index 0000000..23adcbd --- /dev/null +++ b/static/bar.js @@ -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 => ` +
+
+
${esc(m.name)}
+
#${esc(m.member_number)}
+
+
${esc(m.balance_display)}
+
`).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 = + `${esc(name)}   #${esc(number)}   Balance: ${esc(balanceDisplay)}`; + 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'); + } +} diff --git a/static/cashier.html b/static/cashier.html new file mode 100644 index 0000000..e32add2 --- /dev/null +++ b/static/cashier.html @@ -0,0 +1,98 @@ + + + + + +Cashier – ClubLedger + + + + + + +
+ + +
+

Register New Member

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ + +
+

Top Up Account

+
+ + +
+
+ + +
+
+ + +
+

Members

+
+ + +
+ + + +
#NameBalanceJoined
+
+ + +
+

Staff

+
+ + +
+
+
+
+ +
+ + + + + diff --git a/static/cashier.js b/static/cashier.js new file mode 100644 index 0000000..f016316 --- /dev/null +++ b/static/cashier.js @@ -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 = 'No members found'; + return; + } + tbody.innerHTML = members.map(m => ` + + ${esc(m.member_number)} + ${esc(m.name)} + ${esc(m.balance_display)} + ${m.created_at ? m.created_at.slice(0, 10) : ''} + + Statement + + `).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 => ` +
+
+
${esc(m.name)}
+
#${esc(m.member_number)}
+
+
${esc(m.balance_display)}
+
`).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 = + `${esc(name)}   #${esc(number)}   Balance: ${esc(balanceDisplay)}`; + 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'); + } +} diff --git a/static/common.js b/static/common.js new file mode 100644 index 0000000..bdedb58 --- /dev/null +++ b/static/common.js @@ -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, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +// --------------------------------------------------------------------------- +// 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 = '' + + data.staff.map(n => ``).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 = '' + + data.staff.map(n => ``).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 => ` + + ${esc(n)} + + `).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); } +} diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..4463b3c --- /dev/null +++ b/static/index.html @@ -0,0 +1,382 @@ + + + + + +ClubLedger + + + + + + + + + + + +
+ +
+

Register New Member

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ +
+

Members

+
+ + +
+ + + +
#NameBalanceJoined
+
+ +
+ + + + + + + + + + + + + + + + + + + + diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..fd53bef --- /dev/null +++ b/static/style.css @@ -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); }