ShadCN Implementation

This commit is contained in:
sandy
2026-04-01 03:57:04 +02:00
parent d68d476482
commit 3546239396
37 changed files with 1073 additions and 320 deletions

1
.gitignore vendored
View File

@@ -13,7 +13,6 @@ venv/
env/
# Django
backend/db.sqlite3
backend/media/
backend/staticfiles/

View File

@@ -1,12 +1,84 @@
# Django Port Workspace
This folder contains the rewrite target for the current FastAPI + React application.
It is intended to be self-contained.
## What is here
- [`backend`](/home/sandy/HUB-master/django-port/backend): Django project and domain apps
- [`frontend`](/home/sandy/HUB-master/django-port/frontend): SvelteKit frontend prepared for `shadcn-svelte`
- [`docs/port-plan.md`](/home/sandy/HUB-master/django-port/docs/port-plan.md): migration strategy and scope
- [`backend`](backend): Django project and domain apps
- [`frontend`](frontend): SvelteKit frontend with a local manual shadcn-style component layer
- [`data/legacy`](data/legacy): bundled legacy SQLite sources used by `import_legacy_data`
- [`docs/port-plan.md`](docs/port-plan.md): migration strategy and scope
## Self-contained data
The folder already contains:
- the live Django database at [`backend/db.sqlite3`](backend/db.sqlite3)
- the bundled legacy import sources at [`data/legacy/cincin_phase1.sqlite`](data/legacy/cincin_phase1.sqlite) and [`data/legacy/dalcorso.sqlite`](data/legacy/dalcorso.sqlite)
That means `django-port` no longer depends on the repo-root legacy databases for normal use or for re-running the importer.
## Fresh Machine Setup
`django-port` is now structured so it can be pushed as its own repository and used without the rest of the original workspace.
Backend setup:
```bash
cd backend
python3 -m venv .venv
source .venv/bin/activate
pip install -e .
cp .env.example .env
python manage.py migrate
python manage.py runserver
```
Frontend setup:
```bash
cd frontend
npm install
cp .env.example .env
npm run dev
```
Default local URLs:
- frontend: `http://localhost:5173`
- backend API: `http://localhost:8000/api`
## Pushing As Its Own Repo
If this folder is moved into a new repository, include at minimum:
- `backend/`
- `frontend/`
- `data/legacy/`
- `docs/`
- `.gitignore`
- `README.md`
If you want the seeded app data on the destination machine, commit and push:
- [`backend/db.sqlite3`](backend/db.sqlite3)
- [`data/legacy/cincin_phase1.sqlite`](data/legacy/cincin_phase1.sqlite)
- [`data/legacy/dalcorso.sqlite`](data/legacy/dalcorso.sqlite)
The repo does not need to include:
- any files outside `django-port`
- a local virtualenv
- `frontend/node_modules`
- `frontend/.svelte-kit`
## Environment Files
Example env files are included at:
- [`backend/.env.example`](backend/.env.example)
- [`frontend/.env.example`](frontend/.env.example)
## What is intentionally not done
@@ -14,4 +86,4 @@ This folder contains the rewrite target for the current FastAPI + React applicat
- No Svelte or Django bootstrapping commands
- No heavy build, dev-server, or migration runs
Everything committed here is code and structure only.
Everything needed for the port itself now lives under this folder.

10
backend/.env.example Normal file
View File

@@ -0,0 +1,10 @@
DJANGO_SECRET_KEY=change-me
DJANGO_DEBUG=1
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
DJANGO_TIME_ZONE=Europe/Budapest
DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
DJANGO_CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
# Optional overrides. The defaults already point at ../data/legacy/.
# LEGACY_CINCIN_DB=/absolute/path/to/cincin_phase1.sqlite
# LEGACY_DALCORSO_DB=/absolute/path/to/dalcorso.sqlite

View File

@@ -14,14 +14,24 @@ This is the replacement backend for the legacy FastAPI app.
## Expected setup
1. Install dependencies from [`pyproject.toml`](/home/sandy/HUB-master/django-port/backend/pyproject.toml).
2. Run `python manage.py makemigrations`.
3. Run `python manage.py migrate`.
4. Run `python manage.py createsuperuser`.
5. Run `python manage.py import_legacy_data`.
1. Create a virtualenv in `backend/`.
2. Install dependencies from [`pyproject.toml`](pyproject.toml) with `pip install -e .`.
3. Copy [`backend/.env.example`](.env.example) to `.env` or export the same variables in your shell.
4. Run `python manage.py migrate`.
5. Run `python manage.py createsuperuser` if you are starting from a blank DB.
6. Run `python manage.py import_legacy_data` if you want to rebuild from the bundled legacy sources instead of using the committed `db.sqlite3`.
The import command now defaults to the bundled databases inside:
- [`../data/legacy/cincin_phase1.sqlite`](../data/legacy/cincin_phase1.sqlite)
- [`../data/legacy/dalcorso.sqlite`](../data/legacy/dalcorso.sqlite)
You can still override them with `LEGACY_CINCIN_DB` and `LEGACY_DALCORSO_DB` if needed.
## Notes
- Auth uses Django sessions instead of custom JWT cookies.
- The import command consolidates both legacy SQLite files into one Django schema.
- Password hashes are not carried over directly; imported users get a forced reset placeholder.
- The backend is intended to be runnable from the `django-port` subtree without depending on workspace-root data files.
- The committed `db.sqlite3` is optional but supported. Keep it in the repo if you want seeded data to travel with the project.

View File

@@ -110,5 +110,6 @@ CORS_ALLOWED_ORIGINS = [
)
]
LEGACY_CINCIN_DB = Path(os.getenv("LEGACY_CINCIN_DB", BASE_DIR.parent.parent / "cincin_phase1.sqlite"))
LEGACY_DALCORSO_DB = Path(os.getenv("LEGACY_DALCORSO_DB", BASE_DIR.parent.parent / "dalcorso.sqlite"))
LEGACY_DATA_DIR = BASE_DIR.parent / "data" / "legacy"
LEGACY_CINCIN_DB = Path(os.getenv("LEGACY_CINCIN_DB", LEGACY_DATA_DIR / "cincin_phase1.sqlite"))
LEGACY_DALCORSO_DB = Path(os.getenv("LEGACY_DALCORSO_DB", LEGACY_DATA_DIR / "dalcorso.sqlite"))

BIN
backend/db.sqlite3 Normal file

Binary file not shown.

13
data/legacy/README.md Normal file
View File

@@ -0,0 +1,13 @@
# Bundled Legacy Data
This directory contains the legacy SQLite databases used by the Django import command:
- `cincin_phase1.sqlite`
- `dalcorso.sqlite`
The defaults in [`backend/config/settings.py`](../../backend/config/settings.py) point here so the `django-port` subtree can be used without depending on workspace-root copies of those files.
If needed, the importer can still be redirected with:
- `LEGACY_CINCIN_DB`
- `LEGACY_DALCORSO_DB`

Binary file not shown.

BIN
data/legacy/dalcorso.sqlite Normal file

Binary file not shown.

View File

@@ -21,7 +21,7 @@
1. Create Django migrations from the new model set.
2. Migrate the new database.
3. Run `import_legacy_data` to ingest `cincin_phase1.sqlite` and `dalcorso.sqlite`.
3. Run `import_legacy_data` to ingest the bundled legacy SQLite files in `data/legacy/`.
4. Rebuild auth credentials by forcing password resets for imported users.
5. Move ETL scripts into Django management commands later instead of repo-root scripts.

View File

@@ -0,0 +1,396 @@
# Session Handoff
Date: April 1, 2026
Workspace root: [`/home/sandy/HUB-master`](/home/sandy/HUB-master)
Port root: [`/home/sandy/HUB-master/django-port`](/home/sandy/HUB-master/django-port)
This document supersedes the earlier handoff for the continuation work done after [`session-handoff-2026-04-01.md`](/home/sandy/HUB-master/django-port/docs/session-handoff-2026-04-01.md).
## 1. What Happened In This Continuation
The continuation work completed six major areas:
1. business access scoping was tightened across the backend API
2. missing user and invoice update/delete flows were added
3. a local `.gitignore` was added for the `django-port` subtree
4. the temporary UI layer was replaced manually with a local `shadcn-svelte` style component system, then restyled again into a strict black-background, white-text, vanilla shadcn look
5. the `django-port` subtree was made self-contained for data and repo portability
6. startup and portability docs were tightened so the folder can be pushed as its own repo and started on another machine with only local env files and dependency installs
Important note:
- this UI work does **not** depend on the external `shadcn-svelte` CLI
- the component layer now exists as local source files inside the repo
## 2. Backend Changes Since The Previous Handoff
Primary backend file still in play:
- [`/home/sandy/HUB-master/django-port/backend/apps/api/views.py`](/home/sandy/HUB-master/django-port/backend/apps/api/views.py)
### 2.1 Business Scoping Hardening
Scoping helpers were added or expanded so non-superusers now default to business-limited data more consistently:
- scoped vendors
- scoped categories
- scoped products
- scoped invoices
- vendor access checks
- invoice payload access validation
Effects:
- dashboard overview is no longer global for non-superusers
- vendors list now defaults to allowed businesses
- vendor detail is denied outside allowed businesses
- invoices list defaults to allowed businesses
- invoice detail is resolved through scoped querysets
- inventory now derives visibility from scoped products instead of the earlier brittle ad hoc filter
- business summary inventory count is tied to that businesss vendor/category graph instead of the earlier global count
### 2.2 User And Invoice CRUD Completion
Invoice service logic was consolidated in:
- [`/home/sandy/HUB-master/django-port/backend/apps/operations/services.py`](/home/sandy/HUB-master/django-port/backend/apps/operations/services.py)
The service now has a shared persistence path for create and update:
- `create_invoice_from_payload`
- `update_invoice_from_payload`
Added backend routes:
- `PUT /api/invoices/<invoice_id>/`
- `DELETE /api/invoices/<invoice_id>/`
- `GET /api/settings/users/<user_id>/`
- `PUT /api/settings/users/<user_id>/`
- `DELETE /api/settings/users/<user_id>/`
Routing file:
- [`/home/sandy/HUB-master/django-port/backend/apps/api/urls.py`](/home/sandy/HUB-master/django-port/backend/apps/api/urls.py)
### 2.3 Safety Notes
Current user deletion behavior:
- users cannot delete their own account through the new user detail endpoint
Current invoice write validation:
- business access is checked
- vendor access is checked
- category access is checked for non-superusers
- product access is checked for non-superusers
## 3. Frontend/API Surface Added Since The Previous Handoff
API client file:
- [`/home/sandy/HUB-master/django-port/frontend/src/lib/api/client.ts`](/home/sandy/HUB-master/django-port/frontend/src/lib/api/client.ts)
Added frontend client methods:
- `updateInvoice`
- `deleteInvoice`
- `updateUser`
- `deleteUser`
Frontend pages updated to use those flows:
- [`/home/sandy/HUB-master/django-port/frontend/src/routes/app/invoices/+page.svelte`](/home/sandy/HUB-master/django-port/frontend/src/routes/app/invoices/+page.svelte)
- [`/home/sandy/HUB-master/django-port/frontend/src/routes/app/settings/+page.svelte`](/home/sandy/HUB-master/django-port/frontend/src/routes/app/settings/+page.svelte)
Behavior:
- invoice page now supports create, edit, and delete
- settings page now supports create, edit, and delete for users
## 4. Gitignore Added
Added:
- [`/home/sandy/HUB-master/django-port/.gitignore`](/home/sandy/HUB-master/django-port/.gitignore)
It ignores:
- `venv`
- Python caches
- Django static/media outputs
- `frontend/node_modules`
- Svelte/Vite build artifacts
- common log and editor files
Important update from the latest continuation:
- the live Django database is **no longer ignored**
- this was changed intentionally so `backend/db.sqlite3` can be committed if the user wants the shipped data included when pushing `django-port` to another repository
## 4.1 Self-Contained Data Packaging
The project no longer depends on workspace-root legacy SQLite files.
Bundled data now lives inside the port itself:
- [`/home/sandy/HUB-master/django-port/backend/db.sqlite3`](/home/sandy/HUB-master/django-port/backend/db.sqlite3)
- [`/home/sandy/HUB-master/django-port/data/legacy/cincin_phase1.sqlite`](/home/sandy/HUB-master/django-port/data/legacy/cincin_phase1.sqlite)
- [`/home/sandy/HUB-master/django-port/data/legacy/dalcorso.sqlite`](/home/sandy/HUB-master/django-port/data/legacy/dalcorso.sqlite)
Defaults in:
- [`/home/sandy/HUB-master/django-port/backend/config/settings.py`](/home/sandy/HUB-master/django-port/backend/config/settings.py)
now point `LEGACY_CINCIN_DB` and `LEGACY_DALCORSO_DB` at `django-port/data/legacy/` instead of the workspace root.
Documentation for this was added/updated in:
- [`/home/sandy/HUB-master/django-port/README.md`](/home/sandy/HUB-master/django-port/README.md)
- [`/home/sandy/HUB-master/django-port/backend/README.md`](/home/sandy/HUB-master/django-port/backend/README.md)
- [`/home/sandy/HUB-master/django-port/docs/port-plan.md`](/home/sandy/HUB-master/django-port/docs/port-plan.md)
- [`/home/sandy/HUB-master/django-port/data/legacy/README.md`](/home/sandy/HUB-master/django-port/data/legacy/README.md)
Additional portability setup in the latest pass:
- [`/home/sandy/HUB-master/django-port/backend/.env.example`](/home/sandy/HUB-master/django-port/backend/.env.example)
- [`/home/sandy/HUB-master/django-port/frontend/.env.example`](/home/sandy/HUB-master/django-port/frontend/.env.example)
The root README now includes:
- fresh-machine backend setup
- fresh-machine frontend setup
- repo contents that should be pushed
- explicit note that files outside `django-port` are no longer required
## 5. Current UI Architecture
This changed substantially.
The old situation was:
- temporary `button.svelte`
- temporary `card.svelte`
- warm custom palette
- mixed per-page raw inputs/selects/textareas
The current situation is:
- a local manual `shadcn-svelte` style component layer lives under [`/home/sandy/HUB-master/django-port/frontend/src/lib/components/ui`](/home/sandy/HUB-master/django-port/frontend/src/lib/components/ui)
- global styling now uses a dark vanilla shadcn-like token set
- screens have been migrated onto those primitives
### 5.1 Core UI Files
Utility helper:
- [`/home/sandy/HUB-master/django-port/frontend/src/lib/utils.ts`](/home/sandy/HUB-master/django-port/frontend/src/lib/utils.ts)
Core primitives:
- [`/home/sandy/HUB-master/django-port/frontend/src/lib/components/ui/button.svelte`](/home/sandy/HUB-master/django-port/frontend/src/lib/components/ui/button.svelte)
- [`/home/sandy/HUB-master/django-port/frontend/src/lib/components/ui/card.svelte`](/home/sandy/HUB-master/django-port/frontend/src/lib/components/ui/card.svelte)
- [`/home/sandy/HUB-master/django-port/frontend/src/lib/components/ui/card-header.svelte`](/home/sandy/HUB-master/django-port/frontend/src/lib/components/ui/card-header.svelte)
- [`/home/sandy/HUB-master/django-port/frontend/src/lib/components/ui/card-title.svelte`](/home/sandy/HUB-master/django-port/frontend/src/lib/components/ui/card-title.svelte)
- [`/home/sandy/HUB-master/django-port/frontend/src/lib/components/ui/card-description.svelte`](/home/sandy/HUB-master/django-port/frontend/src/lib/components/ui/card-description.svelte)
- [`/home/sandy/HUB-master/django-port/frontend/src/lib/components/ui/card-content.svelte`](/home/sandy/HUB-master/django-port/frontend/src/lib/components/ui/card-content.svelte)
- [`/home/sandy/HUB-master/django-port/frontend/src/lib/components/ui/card-footer.svelte`](/home/sandy/HUB-master/django-port/frontend/src/lib/components/ui/card-footer.svelte)
- [`/home/sandy/HUB-master/django-port/frontend/src/lib/components/ui/input.svelte`](/home/sandy/HUB-master/django-port/frontend/src/lib/components/ui/input.svelte)
- [`/home/sandy/HUB-master/django-port/frontend/src/lib/components/ui/label.svelte`](/home/sandy/HUB-master/django-port/frontend/src/lib/components/ui/label.svelte)
- [`/home/sandy/HUB-master/django-port/frontend/src/lib/components/ui/select.svelte`](/home/sandy/HUB-master/django-port/frontend/src/lib/components/ui/select.svelte)
- [`/home/sandy/HUB-master/django-port/frontend/src/lib/components/ui/textarea.svelte`](/home/sandy/HUB-master/django-port/frontend/src/lib/components/ui/textarea.svelte)
Global theme:
- [`/home/sandy/HUB-master/django-port/frontend/src/app.css`](/home/sandy/HUB-master/django-port/frontend/src/app.css)
### 5.2 Visual Direction Now In Effect
The latest user correction explicitly required:
- white text
- black backgrounds
- purely vanilla shadcn look
- no leftover warm/custom brand styling
Current global direction:
- background is near-black
- foreground is near-white
- cards are dark
- primary action is light-on-dark inversion
- muted surfaces are dark zinc-like blocks
- typography uses a plain system sans stack rather than the earlier decorative direction
### 5.3 Shell Layout
Shell files:
- [`/home/sandy/HUB-master/django-port/frontend/src/lib/components/app-shell/sidebar.svelte`](/home/sandy/HUB-master/django-port/frontend/src/lib/components/app-shell/sidebar.svelte)
- [`/home/sandy/HUB-master/django-port/frontend/src/routes/app/+layout.svelte`](/home/sandy/HUB-master/django-port/frontend/src/routes/app/+layout.svelte)
Current shell behavior:
- dark app frame
- left navigation in a sticky card
- active route highlighted
- main content area wrapped in a dark bordered panel
## 6. Pages Converted To The Manual Shadcn Layer
The following pages are now on the new local component layer and dark token system:
- [`/home/sandy/HUB-master/django-port/frontend/src/routes/login/+page.svelte`](/home/sandy/HUB-master/django-port/frontend/src/routes/login/+page.svelte)
- [`/home/sandy/HUB-master/django-port/frontend/src/routes/app/dashboard/+page.svelte`](/home/sandy/HUB-master/django-port/frontend/src/routes/app/dashboard/+page.svelte)
- [`/home/sandy/HUB-master/django-port/frontend/src/routes/app/invoices/+page.svelte`](/home/sandy/HUB-master/django-port/frontend/src/routes/app/invoices/+page.svelte)
- [`/home/sandy/HUB-master/django-port/frontend/src/routes/app/vendors/+page.svelte`](/home/sandy/HUB-master/django-port/frontend/src/routes/app/vendors/+page.svelte)
- [`/home/sandy/HUB-master/django-port/frontend/src/routes/app/inventory/+page.svelte`](/home/sandy/HUB-master/django-port/frontend/src/routes/app/inventory/+page.svelte)
- [`/home/sandy/HUB-master/django-port/frontend/src/routes/app/events/+page.svelte`](/home/sandy/HUB-master/django-port/frontend/src/routes/app/events/+page.svelte)
- [`/home/sandy/HUB-master/django-port/frontend/src/routes/app/schedule/+page.svelte`](/home/sandy/HUB-master/django-port/frontend/src/routes/app/schedule/+page.svelte)
- [`/home/sandy/HUB-master/django-port/frontend/src/routes/app/settings/+page.svelte`](/home/sandy/HUB-master/django-port/frontend/src/routes/app/settings/+page.svelte)
- [`/home/sandy/HUB-master/django-port/frontend/src/routes/app/devices/+page.svelte`](/home/sandy/HUB-master/django-port/frontend/src/routes/app/devices/+page.svelte)
- [`/home/sandy/HUB-master/django-port/frontend/src/routes/app/business/[id]/+page.svelte`](/home/sandy/HUB-master/django-port/frontend/src/routes/app/business/[id]/+page.svelte)
## 7. Current Quality/Verification State
Backend verification used:
```bash
python3 -m compileall django-port/backend
```
This passed after the backend continuation work.
Frontend verification used:
```bash
npm run check
```
This now passes with:
- `0 errors`
- `0 warnings`
That is an improvement over the prior state where the frontend still had a set of accessibility warnings and one intermediate TypeScript regression during the CRUD/UI migration.
## 8. What Is Better Now
Compared with the prior handoff, the project is in a materially better state:
- backend scoping is more defensible
- missing user/invoice CRUD is no longer missing
- repo noise in `django-port` is reduced via `.gitignore`
- the data needed to rerun the importer now lives under `django-port`
- the live Django database can now be committed with the subtree for portability
- UI is no longer based on temporary one-off primitives
- the frontend no longer mixes multiple visual directions
- the current visual direction now matches the users final explicit correction: black backgrounds, white text, vanilla shadcn feel
## 9. Remaining Gaps / Risks
Even after this continuation, there are still real gaps.
### 9.1 UI Is Manual, Not CLI-Generated
The component layer is local and manual.
That is acceptable for the users stated direction, but it means:
- future new components are still hand-authored unless someone later chooses to bring in the CLI workflow
### 9.2 Some Interaction Primitives Are Still Native
The visual system is consistent enough now, but some lower-level interaction pieces are still native rather than componentized, for example:
- plain checkbox controls
- some inline destructive text buttons
- some per-row action buttons inside lists
If the next session wants to push polish further, likely next UI candidates are:
- checkbox
- badge
- alert
- table or data-list wrappers
- reusable stat-card component
### 9.3 API Structure Is Still Large
This is unchanged from the earlier handoff:
- [`/home/sandy/HUB-master/django-port/backend/apps/api/views.py`](/home/sandy/HUB-master/django-port/backend/apps/api/views.py) is still too large
Correctness was prioritized over structural refactor.
### 9.4 No Automated Backend Test Coverage
Still missing:
- API tests
- model/service tests
- end-to-end tests
### 9.5 Repo Portability Depends On What Gets Committed
The folder is now prepared so it can stand alone, but another machine still only gets what is actually pushed.
To make the subtree truly self-contained in a new repository, the pushed repo should include at minimum:
- `backend/`
- `frontend/`
- `data/legacy/`
- `docs/`
- `.gitignore`
- `README.md`
- `backend/db.sqlite3` if the preloaded application data should ship too
It should **not** need:
- repo-root legacy databases outside `django-port`
- repo-root frontend/backend folders outside `django-port`
- local `venv`
- local `node_modules`
- `.svelte-kit`
Recommended startup files for another machine:
- `backend/.env` copied from `backend/.env.example`
- `frontend/.env` copied from `frontend/.env.example`
Only the host/origin values should need adjustment if the deployment URLs differ from the local defaults.
## 10. Recommended Next Steps
If another Codex instance continues from here, recommended order is:
1. do manual browser QA on the dark UI across the main routes
2. fix any remaining visual inconsistencies discovered in that manual pass
3. decide whether `backend/db.sqlite3` should be committed in the destination repo as seeded app data
4. add reusable primitives for checkbox/badge/alert if more UI polish is desired
5. only after UI stabilizes, consider splitting `apps/api/views.py`
6. add targeted API tests for business scoping and user/invoice CRUD
## 11. Immediate Startup Context For The Next Codex
The project is no longer at a bootstrap stage.
The current state should be understood as:
- Django backend is functioning
- Svelte frontend is functioning
- auth works
- scoping is better than before
- user/invoice CRUD exists
- UI layer is now local manual shadcn-style and dark
So the next session should not redo scaffolding.
It should assume the task is now one of:
- polish
- QA
- test coverage
- structural cleanup

1
frontend/.env.example Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE=http://localhost:8000/api

View File

@@ -1,38 +1,50 @@
@import "tailwindcss";
:root {
--background: #f5f1e8;
--foreground: #201916;
--card: #fffcf6;
--card-foreground: #201916;
--popover: #fff9ef;
--popover-foreground: #201916;
--primary: #16302b;
--primary-foreground: #f6efe4;
--secondary: #d8c3a8;
--secondary-foreground: #2d241e;
--muted: #ece2d3;
--muted-foreground: #615246;
--accent: #ba6c46;
--accent-foreground: #fff7ef;
--border: #cdbca5;
--input: #e7dbca;
--ring: #16302b;
--background: #09090b;
--foreground: #fafafa;
--card: #09090b;
--card-foreground: #fafafa;
--popover: #09090b;
--popover-foreground: #fafafa;
--primary: #fafafa;
--primary-foreground: #09090b;
--secondary: #18181b;
--secondary-foreground: #fafafa;
--muted: #18181b;
--muted-foreground: #a1a1aa;
--accent: #27272a;
--accent-foreground: #fafafa;
--border: #27272a;
--input: #27272a;
--ring: #d4d4d8;
}
body {
background:
radial-gradient(circle at top left, rgba(186, 108, 70, 0.18), transparent 30%),
radial-gradient(circle at bottom right, rgba(22, 48, 43, 0.18), transparent 35%),
var(--background);
background: var(--background);
color: var(--foreground);
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
font-family: Arial, Helvetica, sans-serif;
min-height: 100vh;
}
.panel {
background: color-mix(in srgb, var(--card) 92%, white);
border: 1px solid var(--border);
border-radius: 1rem;
box-shadow: 0 18px 60px rgba(32, 25, 22, 0.08);
* {
border-color: var(--border);
box-sizing: border-box;
}
::selection {
background: #3f3f46;
color: var(--foreground);
}
button,
input,
select,
textarea {
font: inherit;
}
a {
color: inherit;
text-decoration: none;
}

View File

@@ -1,7 +1,9 @@
<script lang="ts">
import { page } from "$app/state";
import { goto } from "$app/navigation";
import { api } from "$lib/api/client";
import Button from "$lib/components/ui/button.svelte";
import Card from "$lib/components/ui/card.svelte";
import { authUser, clearAuth, hasPermission } from "$lib/stores/auth";
const items = [
@@ -28,14 +30,22 @@
$: visibleItems = items.filter((item) => !$authUser || hasPermission(item.permission));
</script>
<aside class="panel flex w-full max-w-64 flex-col gap-3 p-4">
<Card className="flex w-full max-w-72 flex-col gap-4 bg-[var(--secondary)] p-4 lg:sticky lg:top-4 lg:h-[calc(100vh-2rem)]">
<div class="border-b border-[var(--border)] pb-4">
<p class="text-xs uppercase tracking-[0.25em] text-[var(--muted-foreground)]">Central Hub</p>
<h1 class="mt-2 text-xl font-semibold">Hospitality Ops</h1>
<p class="mt-1 text-sm text-[var(--muted-foreground)]">Django control surface</p>
</div>
<nav class="flex flex-col gap-2">
{#each visibleItems as item}
<a class="rounded-xl px-3 py-2 text-sm text-[var(--foreground)] transition hover:bg-black/5" href={item.href}>
<a
class={`rounded-lg px-3 py-2 text-sm transition ${
page.url.pathname === item.href
? "bg-[var(--foreground)] text-[var(--background)]"
: "text-[var(--muted-foreground)] hover:bg-[var(--accent)] hover:text-[var(--foreground)]"
}`}
href={item.href}
>
{item.label}
</a>
{/each}
@@ -49,4 +59,4 @@
<Button type="button" variant="ghost" on:click={signOut}>Sign out</Button>
</div>
</div>
</aside>
</Card>

View File

@@ -1,19 +1,44 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "$lib/utils";
export let href: string | undefined = undefined;
export let type: "button" | "submit" | "reset" = "button";
export let variant: "primary" | "secondary" | "ghost" | "destructive" = "primary";
export let variant: ButtonVariant = "default";
export let size: ButtonSize = "default";
export let disabled = false;
export let className = "";
const dispatch = createEventDispatcher<{ click: MouseEvent }>();
const variants = {
primary: "bg-[var(--primary)] text-[var(--primary-foreground)] hover:opacity-95",
secondary: "bg-[var(--secondary)] text-[var(--secondary-foreground)] hover:opacity-95",
ghost: "bg-transparent text-[var(--foreground)] hover:bg-black/5",
destructive: "bg-red-700 text-white hover:bg-red-800"
};
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors ring-offset-[var(--background)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-[var(--primary)] text-[var(--primary-foreground)] shadow-sm hover:opacity-95",
secondary: "bg-[var(--secondary)] text-[var(--secondary-foreground)] shadow-sm hover:opacity-95",
ghost: "hover:bg-[var(--accent)] text-[var(--foreground)]",
destructive: "bg-red-700 text-white shadow-sm hover:bg-red-800",
outline: "border border-[var(--border)] bg-[var(--background)] shadow-sm hover:bg-[var(--accent)]"
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10"
}
},
defaultVariants: {
variant: "default",
size: "default"
}
}
);
type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
type ButtonSize = VariantProps<typeof buttonVariants>["size"];
function handleClick(event: MouseEvent) {
dispatch("click", event);
@@ -22,7 +47,7 @@
{#if href}
<a
class={`inline-flex items-center justify-center rounded-xl px-4 py-2 text-sm font-medium transition ${variants[variant]}`}
class={cn(buttonVariants({ variant, size }), className)}
{href}
{...$$restProps}
on:click={handleClick}
@@ -31,7 +56,7 @@
</a>
{:else}
<button
class={`inline-flex items-center justify-center rounded-xl px-4 py-2 text-sm font-medium transition disabled:opacity-50 ${variants[variant]}`}
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{...$$restProps}

View File

@@ -0,0 +1,9 @@
<script lang="ts">
import { cn } from "$lib/utils";
export let className = "";
</script>
<div class={cn("p-6 pt-0", className)}>
<slot />
</div>

View File

@@ -0,0 +1,9 @@
<script lang="ts">
import { cn } from "$lib/utils";
export let className = "";
</script>
<p class={cn("text-sm text-[var(--muted-foreground)]", className)}>
<slot />
</p>

View File

@@ -0,0 +1,9 @@
<script lang="ts">
import { cn } from "$lib/utils";
export let className = "";
</script>
<div class={cn("flex items-center p-6 pt-0", className)}>
<slot />
</div>

View File

@@ -0,0 +1,9 @@
<script lang="ts">
import { cn } from "$lib/utils";
export let className = "";
</script>
<div class={cn("flex flex-col space-y-1.5 p-6", className)}>
<slot />
</div>

View File

@@ -0,0 +1,9 @@
<script lang="ts">
import { cn } from "$lib/utils";
export let className = "";
</script>
<h3 class={cn("leading-none font-semibold tracking-tight", className)}>
<slot />
</h3>

View File

@@ -1,3 +1,14 @@
<div class="panel p-5">
<script lang="ts">
import { cn } from "$lib/utils";
export let className = "";
</script>
<div
class={cn(
"rounded-xl border border-[var(--border)] bg-[var(--card)] text-[var(--card-foreground)] shadow-sm",
className
)}
>
<slot />
</div>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { cn } from "$lib/utils";
export let type: string = "text";
export let value: string | number = "";
export let className = "";
</script>
<input
{type}
bind:value
class={cn(
"flex h-10 w-full rounded-md border border-[var(--input)] bg-[var(--background)] px-3 py-2 text-sm ring-offset-[var(--background)] file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-[var(--muted-foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...$$restProps}
/>

View File

@@ -0,0 +1,10 @@
<script lang="ts">
import { cn } from "$lib/utils";
export let forId: string | undefined = undefined;
export let className = "";
</script>
<label for={forId} class={cn("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", className)}>
<slot />
</label>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { cn } from "$lib/utils";
export let value: string | number | null = "";
export let className = "";
</script>
<select
bind:value
class={cn(
"flex h-10 w-full rounded-md border border-[var(--input)] bg-[var(--background)] px-3 py-2 text-sm ring-offset-[var(--background)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...$$restProps}
>
<slot />
</select>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { cn } from "$lib/utils";
export let value: string = "";
export let className = "";
</script>
<textarea
bind:value
class={cn(
"flex min-h-20 w-full rounded-md border border-[var(--input)] bg-[var(--background)] px-3 py-2 text-sm ring-offset-[var(--background)] placeholder:text-[var(--muted-foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...$$restProps}
>
</textarea>

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -2,6 +2,7 @@
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import Sidebar from "$lib/components/app-shell/sidebar.svelte";
import Card from "$lib/components/ui/card.svelte";
import { authReady, bootstrapAuth } from "$lib/stores/auth";
let loading = true;
@@ -24,21 +25,21 @@
{#if loading || !$authReady}
<div class="mx-auto flex min-h-screen max-w-7xl items-center justify-center px-6 py-10">
<div class="panel p-6 text-center">
<Card className="p-6 text-center">
<p class="text-sm uppercase tracking-[0.3em] text-[var(--muted-foreground)]">Session</p>
<p class="mt-2 text-lg font-medium">Checking authentication…</p>
</div>
</Card>
</div>
{:else if error}
<div class="mx-auto flex min-h-screen max-w-7xl items-center justify-center px-6 py-10">
<div class="panel p-6 text-center">
<p class="text-sm text-red-700">{error}</p>
</div>
<Card className="p-6 text-center">
<p class="text-sm text-red-300">{error}</p>
</Card>
</div>
{:else}
<div class="mx-auto flex min-h-screen max-w-7xl gap-6 px-4 py-4 lg:px-6">
<div class="mx-auto flex min-h-screen max-w-[1600px] gap-6 px-4 py-4 lg:px-6">
<Sidebar />
<main class="min-w-0 flex-1">
<main class="min-w-0 flex-1 rounded-2xl border border-[var(--border)] bg-[var(--secondary)]/40 p-4 lg:p-6">
<slot />
</main>
</div>

View File

@@ -2,6 +2,10 @@
import { onMount } from "svelte";
import { page } from "$app/state";
import Card from "$lib/components/ui/card.svelte";
import CardContent from "$lib/components/ui/card-content.svelte";
import CardDescription from "$lib/components/ui/card-description.svelte";
import CardHeader from "$lib/components/ui/card-header.svelte";
import CardTitle from "$lib/components/ui/card-title.svelte";
import { api } from "$lib/api/client";
import type { BusinessSummaryPayload } from "$lib/types/domain";
@@ -19,7 +23,7 @@
<div class="space-y-6">
{#if error}
<Card><p class="text-sm text-red-700">{error}</p></Card>
<Card><CardContent className="p-5"><p class="text-sm text-red-300">{error}</p></CardContent></Card>
{:else if summary}
<div>
<p class="text-sm uppercase tracking-[0.3em] text-[var(--muted-foreground)]">{summary.business.short_code}</p>
@@ -27,21 +31,21 @@
</div>
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<Card><p class="text-sm text-[var(--muted-foreground)]">Revenue</p><p class="mt-2 text-3xl font-semibold">{summary.stats.total_revenue.toFixed(2)} {summary.business.currency}</p></Card>
<Card><p class="text-sm text-[var(--muted-foreground)]">Expenses</p><p class="mt-2 text-3xl font-semibold">{summary.stats.total_expenses.toFixed(2)} {summary.business.currency}</p></Card>
<Card><p class="text-sm text-[var(--muted-foreground)]">Outstanding invoices</p><p class="mt-2 text-3xl font-semibold">{summary.stats.outstanding_invoices}</p></Card>
<Card><p class="text-sm text-[var(--muted-foreground)]">Invoices</p><p class="mt-2 text-3xl font-semibold">{summary.stats.invoice_count}</p></Card>
<Card><p class="text-sm text-[var(--muted-foreground)]">VAT tracked</p><p class="mt-2 text-3xl font-semibold">{summary.stats.total_vat.toFixed(2)} {summary.business.currency}</p></Card>
<Card><p class="text-sm text-[var(--muted-foreground)]">Inventory items</p><p class="mt-2 text-3xl font-semibold">{summary.stats.inventory_items}</p></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">Revenue</p><p class="mt-2 text-3xl font-semibold">{summary.stats.total_revenue.toFixed(2)} {summary.business.currency}</p></CardContent></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">Expenses</p><p class="mt-2 text-3xl font-semibold">{summary.stats.total_expenses.toFixed(2)} {summary.business.currency}</p></CardContent></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">Outstanding invoices</p><p class="mt-2 text-3xl font-semibold">{summary.stats.outstanding_invoices}</p></CardContent></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">Invoices</p><p class="mt-2 text-3xl font-semibold">{summary.stats.invoice_count}</p></CardContent></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">VAT tracked</p><p class="mt-2 text-3xl font-semibold">{summary.stats.total_vat.toFixed(2)} {summary.business.currency}</p></CardContent></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">Inventory items</p><p class="mt-2 text-3xl font-semibold">{summary.stats.inventory_items}</p></CardContent></Card>
</div>
<div class="grid gap-6 xl:grid-cols-[1fr_1fr]">
<Card>
<div class="mb-4">
<h3 class="text-xl font-semibold">Recent revenue</h3>
<p class="text-sm text-[var(--muted-foreground)]">Latest imported revenue summary rows for this business.</p>
</div>
<div class="space-y-3">
<CardHeader>
<CardTitle className="text-xl">Recent revenue</CardTitle>
<CardDescription>Latest imported revenue summary rows for this business.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{#each summary.recent_revenue as row}
<div class="flex items-center justify-between border-b border-[var(--border)] py-2 last:border-0">
<div>
@@ -54,15 +58,15 @@
</div>
</div>
{/each}
</div>
</CardContent>
</Card>
<Card>
<div class="mb-4">
<h3 class="text-xl font-semibold">Recent invoices</h3>
<p class="text-sm text-[var(--muted-foreground)]">Most recent invoice activity linked to this business.</p>
</div>
<div class="space-y-3">
<CardHeader>
<CardTitle className="text-xl">Recent invoices</CardTitle>
<CardDescription>Most recent invoice activity linked to this business.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{#each summary.recent_invoices as invoice}
<div class="flex items-center justify-between border-b border-[var(--border)] py-2 last:border-0">
<div>
@@ -75,10 +79,10 @@
</div>
</div>
{/each}
</div>
</CardContent>
</Card>
</div>
{:else}
<Card>Loading business summary…</Card>
<Card><CardContent className="p-5">Loading business summary…</CardContent></Card>
{/if}
</div>

View File

@@ -2,6 +2,10 @@
import { onMount } from "svelte";
import Button from "$lib/components/ui/button.svelte";
import Card from "$lib/components/ui/card.svelte";
import CardContent from "$lib/components/ui/card-content.svelte";
import CardDescription from "$lib/components/ui/card-description.svelte";
import CardHeader from "$lib/components/ui/card-header.svelte";
import CardTitle from "$lib/components/ui/card-title.svelte";
import { api } from "$lib/api/client";
import type { DashboardBusinessSummary, DashboardOverview } from "$lib/types/domain";
@@ -30,27 +34,25 @@
</div>
{#if error}
<Card><p class="text-sm text-red-700">{error}</p></Card>
<Card><CardContent className="p-5"><p class="text-sm text-red-300">{error}</p></CardContent></Card>
{:else if overview}
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<Card><p class="text-sm text-[var(--muted-foreground)]">Revenue</p><p class="mt-2 text-3xl font-semibold">{overview.total_revenue.toFixed(2)}</p></Card>
<Card><p class="text-sm text-[var(--muted-foreground)]">Expenses</p><p class="mt-2 text-3xl font-semibold">{overview.total_expenses.toFixed(2)}</p></Card>
<Card><p class="text-sm text-[var(--muted-foreground)]">Outstanding invoices</p><p class="mt-2 text-3xl font-semibold">{overview.outstanding_invoices}</p></Card>
<Card><p class="text-sm text-[var(--muted-foreground)]">VAT tracked</p><p class="mt-2 text-3xl font-semibold">{overview.total_vat.toFixed(2)}</p></Card>
<Card><p class="text-sm text-[var(--muted-foreground)]">Vendors</p><p class="mt-2 text-3xl font-semibold">{overview.vendor_count}</p></Card>
<Card><p class="text-sm text-[var(--muted-foreground)]">Unread notifications</p><p class="mt-2 text-3xl font-semibold">{overview.unread_notifications}</p></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">Revenue</p><p class="mt-2 text-3xl font-semibold">{overview.total_revenue.toFixed(2)}</p></CardContent></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">Expenses</p><p class="mt-2 text-3xl font-semibold">{overview.total_expenses.toFixed(2)}</p></CardContent></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">Outstanding invoices</p><p class="mt-2 text-3xl font-semibold">{overview.outstanding_invoices}</p></CardContent></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">VAT tracked</p><p class="mt-2 text-3xl font-semibold">{overview.total_vat.toFixed(2)}</p></CardContent></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">Vendors</p><p class="mt-2 text-3xl font-semibold">{overview.vendor_count}</p></CardContent></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">Unread notifications</p><p class="mt-2 text-3xl font-semibold">{overview.unread_notifications}</p></CardContent></Card>
</div>
<Card>
<div class="mb-4 flex items-center justify-between">
<div>
<h3 class="text-xl font-semibold">Business roll-up</h3>
<p class="text-sm text-[var(--muted-foreground)]">Per-business revenue, expenses, and invoice pressure.</p>
</div>
</div>
<div class="space-y-3">
<CardHeader>
<CardTitle className="text-xl">Business roll-up</CardTitle>
<CardDescription>Per-business revenue, expenses, and invoice pressure.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{#each businesses as business}
<div class="rounded-2xl border border-[var(--border)] bg-white/60 p-4">
<div class="rounded-2xl border border-[var(--border)] bg-[var(--muted)] p-4">
<div class="flex items-start justify-between gap-4">
<div>
<p class="font-medium">{business.business_name}</p>
@@ -66,9 +68,9 @@
</div>
</div>
{/each}
</div>
</CardContent>
</Card>
{:else}
<Card>Loading dashboard…</Card>
<Card><CardContent className="p-5">Loading dashboard…</CardContent></Card>
{/if}
</div>

View File

@@ -2,6 +2,11 @@
import { onMount } from "svelte";
import Button from "$lib/components/ui/button.svelte";
import Card from "$lib/components/ui/card.svelte";
import CardContent from "$lib/components/ui/card-content.svelte";
import CardDescription from "$lib/components/ui/card-description.svelte";
import CardHeader from "$lib/components/ui/card-header.svelte";
import CardTitle from "$lib/components/ui/card-title.svelte";
import Input from "$lib/components/ui/input.svelte";
import { api } from "$lib/api/client";
import type { DeviceItem, DeviceRegistrationTokenItem } from "$lib/types/domain";
@@ -112,28 +117,28 @@
</div>
{#if error}
<Card><p class="text-sm text-red-700">{error}</p></Card>
<Card><CardContent className="p-5"><p class="text-sm text-red-300">{error}</p></CardContent></Card>
{/if}
{#if loading}
<Card>Loading devices…</Card>
<Card><CardContent className="p-5">Loading devices…</CardContent></Card>
{:else}
<div class="grid gap-4 md:grid-cols-3">
<Card><p class="text-sm text-[var(--muted-foreground)]">Allowed devices</p><p class="mt-2 text-3xl font-semibold">{devices.length}</p></Card>
<Card><p class="text-sm text-[var(--muted-foreground)]">Active devices</p><p class="mt-2 text-3xl font-semibold">{devices.filter((device) => device.is_active).length}</p></Card>
<Card><p class="text-sm text-[var(--muted-foreground)]">Registration tokens</p><p class="mt-2 text-3xl font-semibold">{tokens.length}</p></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">Allowed devices</p><p class="mt-2 text-3xl font-semibold">{devices.length}</p></CardContent></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">Active devices</p><p class="mt-2 text-3xl font-semibold">{devices.filter((device) => device.is_active).length}</p></CardContent></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">Registration tokens</p><p class="mt-2 text-3xl font-semibold">{tokens.length}</p></CardContent></Card>
</div>
<div class="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<div class="space-y-6">
<Card>
<div class="mb-4">
<h3 class="text-xl font-semibold">Whitelisted devices</h3>
<p class="text-sm text-[var(--muted-foreground)]">Imported device/IP allowlist plus new Django-managed entries.</p>
</div>
<div class="space-y-3">
<CardHeader>
<CardTitle className="text-xl">Whitelisted devices</CardTitle>
<CardDescription>Imported device/IP allowlist plus new Django-managed entries.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{#each devices as device}
<div class="rounded-2xl border border-[var(--border)] bg-white/60 p-4">
<div class="rounded-2xl border border-[var(--border)] bg-[var(--muted)] p-4">
<div class="flex items-start justify-between gap-4">
<div>
<p class="font-medium">{device.label || device.ip_address}</p>
@@ -143,65 +148,69 @@
</p>
</div>
<div class="text-right">
<p class={`text-sm ${device.is_active ? "text-green-700" : "text-red-700"}`}>{device.is_active ? "Active" : "Inactive"}</p>
<button class="mt-2 text-sm text-red-700 hover:opacity-80" on:click={() => removeDevice(device.id)}>Delete</button>
<p class="text-sm text-[var(--muted-foreground)]">{device.is_active ? "Active" : "Inactive"}</p>
<button class="mt-2 text-sm text-red-300 hover:text-red-200" on:click={() => removeDevice(device.id)}>Delete</button>
</div>
</div>
</div>
{/each}
</div>
</CardContent>
</Card>
<Card>
<div class="mb-4">
<h3 class="text-xl font-semibold">Registration tokens</h3>
<p class="text-sm text-[var(--muted-foreground)]">Manual tokens for controlled device onboarding.</p>
</div>
<div class="space-y-3">
<CardHeader>
<CardTitle className="text-xl">Registration tokens</CardTitle>
<CardDescription>Manual tokens for controlled device onboarding.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{#each tokens as token}
<div class="rounded-2xl border border-[var(--border)] bg-white/60 p-4">
<div class="rounded-2xl border border-[var(--border)] bg-[var(--muted)] p-4">
<p class="font-medium">{token.label || "Untitled token"}</p>
<p class="mt-1 break-all text-sm text-[var(--muted-foreground)]">{token.token}</p>
<p class="mt-1 text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">
Expires {new Date(token.expires_at).toLocaleString()} {token.used_at ? ` used ${new Date(token.used_at).toLocaleString()}` : "• unused"}
</p>
<button class="mt-3 text-sm text-red-700 hover:opacity-80" on:click={() => removeToken(token.id)}>Delete</button>
<button class="mt-3 text-sm text-red-300 hover:text-red-200" on:click={() => removeToken(token.id)}>Delete</button>
</div>
{/each}
</div>
</CardContent>
</Card>
</div>
<div class="space-y-6">
<Card>
<div class="mb-4">
<h3 class="text-xl font-semibold">Add device</h3>
<p class="text-sm text-[var(--muted-foreground)]">Create a whitelist entry directly in the new backend.</p>
</div>
<CardHeader>
<CardTitle className="text-xl">Add device</CardTitle>
<CardDescription>Create a whitelist entry directly in the new backend.</CardDescription>
</CardHeader>
<CardContent>
<form class="space-y-4" on:submit|preventDefault={saveDevice}>
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={deviceForm.ip_address} placeholder="IP address" required />
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={deviceForm.label} placeholder="Label" />
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={deviceForm.user_agent} placeholder="User agent" />
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={deviceForm.ipv6_prefix} placeholder="IPv6 prefix" />
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={deviceForm.known_ips} placeholder="Known IPs, comma separated" />
<Input bind:value={deviceForm.ip_address} placeholder="IP address" required />
<Input bind:value={deviceForm.label} placeholder="Label" />
<Input bind:value={deviceForm.user_agent} placeholder="User agent" />
<Input bind:value={deviceForm.ipv6_prefix} placeholder="IPv6 prefix" />
<Input bind:value={deviceForm.known_ips} placeholder="Known IPs, comma separated" />
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" bind:checked={deviceForm.is_active} />
Active device
</label>
<Button type="submit" disabled={savingDevice}>{savingDevice ? "Saving…" : "Add device"}</Button>
</form>
</CardContent>
</Card>
<Card>
<div class="mb-4">
<h3 class="text-xl font-semibold">Create registration token</h3>
<p class="text-sm text-[var(--muted-foreground)]">Generate a fresh onboarding token with an expiry window.</p>
</div>
<CardHeader>
<CardTitle className="text-xl">Create registration token</CardTitle>
<CardDescription>Generate a fresh onboarding token with an expiry window.</CardDescription>
</CardHeader>
<CardContent>
<form class="space-y-4" on:submit|preventDefault={saveToken}>
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={tokenForm.label} placeholder="Label" />
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" type="number" min="1" bind:value={tokenForm.expires_in_days} />
<Input bind:value={tokenForm.label} placeholder="Label" />
<Input type="number" min="1" bind:value={tokenForm.expires_in_days} />
<Button type="submit" disabled={savingToken}>{savingToken ? "Saving…" : "Create token"}</Button>
</form>
</CardContent>
</Card>
</div>
</div>

View File

@@ -2,6 +2,13 @@
import { onMount } from "svelte";
import Button from "$lib/components/ui/button.svelte";
import Card from "$lib/components/ui/card.svelte";
import CardContent from "$lib/components/ui/card-content.svelte";
import CardDescription from "$lib/components/ui/card-description.svelte";
import CardHeader from "$lib/components/ui/card-header.svelte";
import CardTitle from "$lib/components/ui/card-title.svelte";
import Input from "$lib/components/ui/input.svelte";
import Select from "$lib/components/ui/select.svelte";
import Textarea from "$lib/components/ui/textarea.svelte";
import { api } from "$lib/api/client";
import type { Business, EventItem } from "$lib/types/domain";
@@ -22,7 +29,7 @@
end_datetime: "",
all_day: false,
location: "",
color: "#ba6c46",
color: "#09090b",
recurrence_type: "none",
recurrence_end_date: ""
};
@@ -70,7 +77,7 @@
end_datetime: "",
all_day: false,
location: "",
color: "#ba6c46",
color: "#09090b",
recurrence_type: "none",
recurrence_end_date: ""
};
@@ -94,7 +101,7 @@
end_datetime: event.end_datetime.slice(0, 16),
all_day: event.all_day,
location: event.location,
color: event.color || "#ba6c46",
color: event.color || "#09090b",
recurrence_type: event.recurrence_type,
recurrence_end_date: event.recurrence_end_date || ""
};
@@ -119,32 +126,33 @@
<h2 class="mt-2 text-3xl font-semibold">Event planner</h2>
</div>
<div class="flex gap-2">
<select class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={selectedBusiness}>
<Select bind:value={selectedBusiness}>
<option value="">All businesses</option>
{#each businesses as business}
<option value={business.id}>{business.name}</option>
{/each}
</select>
</Select>
<Button type="button" on:click={loadData}>Filter</Button>
</div>
</div>
{#if error}
<Card><p class="text-sm text-red-700">{error}</p></Card>
<Card><CardContent className="p-5"><p class="text-sm text-red-300">{error}</p></CardContent></Card>
{/if}
<div class="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
<Card>
<div class="mb-4">
<h3 class="text-xl font-semibold">Upcoming events</h3>
<p class="text-sm text-[var(--muted-foreground)]">Imported events plus newly created Django-side events.</p>
</div>
<CardHeader>
<CardTitle className="text-xl">Upcoming events</CardTitle>
<CardDescription>Imported events plus newly created Django-side events.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{#if loading}
<p>Loading events…</p>
{:else}
<div class="space-y-3">
{#each events as event}
<div class="rounded-2xl border border-[var(--border)] bg-white/60 p-4">
<div class="rounded-2xl border border-[var(--border)] bg-[var(--muted)] p-4">
<div class="flex items-start justify-between gap-4">
<div>
<p class="font-medium">{event.title}</p>
@@ -154,49 +162,51 @@
</div>
<div class="flex items-center gap-2">
<button class="text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)]" on:click={() => editEvent(event)}>Edit</button>
<button class="text-sm text-red-700 hover:opacity-80" on:click={() => removeEvent(event.id)}>Delete</button>
<div class="h-4 w-4 rounded-full border border-black/10" style={`background:${event.color || "#ba6c46"}`}></div>
<button class="text-sm text-red-300 hover:text-red-200" on:click={() => removeEvent(event.id)}>Delete</button>
<div class="h-4 w-4 rounded-full border border-white/10" style={`background:${event.color || "#09090b"}`}></div>
</div>
</div>
</div>
{/each}
</div>
{/if}
</CardContent>
</Card>
<Card>
<div class="mb-4">
<h3 class="text-xl font-semibold">Create event</h3>
<p class="text-sm text-[var(--muted-foreground)]">{editingEventId ? "Update an existing event." : "A lean replacement for the old calendar modal stack."}</p>
</div>
<CardHeader>
<CardTitle className="text-xl">{editingEventId ? "Edit event" : "Create event"}</CardTitle>
<CardDescription>{editingEventId ? "Update an existing event." : "A lean replacement for the old calendar modal stack."}</CardDescription>
</CardHeader>
<CardContent>
<form class="space-y-4" on:submit|preventDefault={saveEvent}>
<select class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.business_id}>
<Select bind:value={form.business_id}>
{#each businesses as business}
<option value={business.id}>{business.name}</option>
{/each}
</select>
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.title} placeholder="Event title" required />
<textarea class="min-h-24 w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.description} placeholder="Description"></textarea>
</Select>
<Input bind:value={form.title} placeholder="Event title" required />
<Textarea className="min-h-24" bind:value={form.description} placeholder="Description"></Textarea>
<div class="grid gap-4 md:grid-cols-2">
<select class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.event_type}>
<Select bind:value={form.event_type}>
<option value="other">Other</option>
<option value="service">Service</option>
<option value="private">Private</option>
<option value="maintenance">Maintenance</option>
</select>
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.location} placeholder="Location" />
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" type="datetime-local" bind:value={form.start_datetime} required />
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" type="datetime-local" bind:value={form.end_datetime} required />
<select class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.recurrence_type}>
</Select>
<Input bind:value={form.location} placeholder="Location" />
<Input type="datetime-local" bind:value={form.start_datetime} required />
<Input type="datetime-local" bind:value={form.end_datetime} required />
<Select bind:value={form.recurrence_type}>
<option value="none">No recurrence</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</select>
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" type="date" bind:value={form.recurrence_end_date} />
</Select>
<Input type="date" bind:value={form.recurrence_end_date} />
</div>
<div class="flex items-center gap-3">
<input class="h-10 w-16 rounded-xl border border-[var(--input)] bg-white/70 px-2 py-1" type="color" bind:value={form.color} />
<Input className="h-10 w-16 px-2 py-1" type="color" bind:value={form.color} />
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" bind:checked={form.all_day} />
All day
@@ -204,6 +214,7 @@
</div>
<Button type="submit" disabled={saving}>{saving ? "Saving…" : editingEventId ? "Update event" : "Create event"}</Button>
</form>
</CardContent>
</Card>
</div>
</div>

View File

@@ -2,6 +2,9 @@
import { onMount } from "svelte";
import Button from "$lib/components/ui/button.svelte";
import Card from "$lib/components/ui/card.svelte";
import CardContent from "$lib/components/ui/card-content.svelte";
import Input from "$lib/components/ui/input.svelte";
import Select from "$lib/components/ui/select.svelte";
import { api } from "$lib/api/client";
import type { Category, InventoryItem } from "$lib/types/domain";
@@ -42,52 +45,52 @@
<h2 class="mt-2 text-3xl font-semibold">Inventory health</h2>
</div>
<div class="flex gap-2">
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={query} placeholder="Search product or GTIN" />
<select class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={selectedCategory}>
<Input bind:value={query} placeholder="Search product or GTIN" />
<Select bind:value={selectedCategory}>
<option value="">All categories</option>
{#each categories as category}
<option value={category.id}>{category.name}</option>
{/each}
</select>
</Select>
<Button type="button" on:click={loadData}>Filter</Button>
</div>
</div>
{#if error}
<Card><p class="text-sm text-red-700">{error}</p></Card>
<Card><CardContent className="p-5"><p class="text-sm text-red-300">{error}</p></CardContent></Card>
{/if}
<div class="grid gap-4 md:grid-cols-3">
<Card><p class="text-sm text-[var(--muted-foreground)]">Tracked products</p><p class="mt-2 text-3xl font-semibold">{rows.length}</p></Card>
<Card><p class="text-sm text-[var(--muted-foreground)]">Zero stock</p><p class="mt-2 text-3xl font-semibold">{rows.filter((row) => row.quantity_on_hand <= 0).length}</p></Card>
<Card><p class="text-sm text-[var(--muted-foreground)]">Low stock</p><p class="mt-2 text-3xl font-semibold">{rows.filter((row) => row.quantity_on_hand > 0 && row.quantity_on_hand < 5).length}</p></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">Tracked products</p><p class="mt-2 text-3xl font-semibold">{rows.length}</p></CardContent></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">Zero stock</p><p class="mt-2 text-3xl font-semibold">{rows.filter((row) => row.quantity_on_hand <= 0).length}</p></CardContent></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">Low stock</p><p class="mt-2 text-3xl font-semibold">{rows.filter((row) => row.quantity_on_hand > 0 && row.quantity_on_hand < 5).length}</p></CardContent></Card>
</div>
<Card>
<CardContent className="space-y-3 p-5">
{#if loading}
<p>Loading inventory…</p>
{:else}
<div class="space-y-3">
{#each rows as row}
<div class="rounded-2xl border border-[var(--border)] bg-white/60 p-4">
<div class="flex items-start justify-between gap-4">
<div>
<p class="font-medium">{row.product_name}</p>
<p class="text-sm text-[var(--muted-foreground)]">{row.gtin || "No GTIN"}{row.category_names.join(", ") || "Uncategorized"}</p>
<p class="mt-1 text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">
VAT {row.vat_rate.toFixed(2)} • Purchase {row.net_purchase_price.toFixed(2)} • Sales {row.display_sales_price.toFixed(2)}
</p>
</div>
<div class="text-right">
<p class={`text-2xl font-semibold ${row.quantity_on_hand <= 0 ? "text-red-700" : row.quantity_on_hand < 5 ? "text-amber-700" : ""}`}>
{row.quantity_on_hand.toFixed(3)}
</p>
<p class="text-sm text-[var(--muted-foreground)]">{row.uom}</p>
</div>
{#each rows as row}
<div class="rounded-2xl border border-[var(--border)] bg-[var(--muted)] p-4">
<div class="flex items-start justify-between gap-4">
<div>
<p class="font-medium">{row.product_name}</p>
<p class="text-sm text-[var(--muted-foreground)]">{row.gtin || "No GTIN"}{row.category_names.join(", ") || "Uncategorized"}</p>
<p class="mt-1 text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">
VAT {row.vat_rate.toFixed(2)} • Purchase {row.net_purchase_price.toFixed(2)} • Sales {row.display_sales_price.toFixed(2)}
</p>
</div>
<div class="text-right">
<p class={`text-2xl font-semibold ${row.quantity_on_hand <= 0 ? "text-red-300" : row.quantity_on_hand < 5 ? "text-zinc-300" : ""}`}>
{row.quantity_on_hand.toFixed(3)}
</p>
<p class="text-sm text-[var(--muted-foreground)]">{row.uom}</p>
</div>
</div>
{/each}
</div>
</div>
{/each}
{/if}
</CardContent>
</Card>
</div>

View File

@@ -2,6 +2,10 @@
import { onMount } from "svelte";
import Card from "$lib/components/ui/card.svelte";
import Button from "$lib/components/ui/button.svelte";
import Input from "$lib/components/ui/input.svelte";
import Label from "$lib/components/ui/label.svelte";
import Select from "$lib/components/ui/select.svelte";
import Textarea from "$lib/components/ui/textarea.svelte";
import { api } from "$lib/api/client";
import type { Business, Category, Invoice, Product, Vendor } from "$lib/types/domain";
@@ -173,17 +177,12 @@
<h2 class="mt-2 text-3xl font-semibold">Invoice tracker</h2>
</div>
<div class="w-full max-w-sm">
<input
class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2"
bind:value={query}
on:change={() => loadData(query)}
placeholder="Search vendor, note, or invoice number"
/>
<Input bind:value={query} on:change={() => loadData(query)} placeholder="Search vendor, note, or invoice number" />
</div>
</div>
{#if error}
<Card><p class="text-sm text-red-700">{error}</p></Card>
<Card><div class="p-5"><p class="text-sm text-red-300">{error}</p></div></Card>
{/if}
<div class="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
@@ -203,7 +202,7 @@
<div class="space-y-3">
{#each invoices as invoice}
<button
class="w-full rounded-2xl border border-[var(--border)] bg-white/60 p-4 text-left transition hover:bg-white"
class="w-full rounded-2xl border border-[var(--border)] bg-[var(--muted)] p-4 text-left transition hover:bg-[var(--accent)]"
on:click={() => startInvoiceDraft(invoice)}
>
<div class="flex items-start justify-between gap-3">
@@ -239,9 +238,9 @@
</div>
<div class="grid gap-3 md:grid-cols-3">
<div class="rounded-xl bg-black/5 p-3"><p class="text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">Business</p><p class="mt-1">{selectedInvoice.business_name ?? "Unassigned"}</p></div>
<div class="rounded-xl bg-black/5 p-3"><p class="text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">Status</p><p class="mt-1">{selectedInvoice.payment_status}</p></div>
<div class="rounded-xl bg-black/5 p-3"><p class="text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">Due</p><p class="mt-1">{selectedInvoice.due_date ?? "Not set"}</p></div>
<div class="rounded-xl bg-[var(--muted)] p-3"><p class="text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">Business</p><p class="mt-1">{selectedInvoice.business_name ?? "Unassigned"}</p></div>
<div class="rounded-xl bg-[var(--muted)] p-3"><p class="text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">Status</p><p class="mt-1">{selectedInvoice.payment_status}</p></div>
<div class="rounded-xl bg-[var(--muted)] p-3"><p class="text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">Due</p><p class="mt-1">{selectedInvoice.due_date ?? "Not set"}</p></div>
</div>
<div class="mt-4 space-y-2">
@@ -276,61 +275,61 @@
<form class="space-y-4" on:submit|preventDefault={saveInvoice}>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="mb-2 block text-sm font-medium">Business</label>
<select class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.business_id}>
<Label forId="invoice-business" className="mb-2 block">Business</Label>
<Select id="invoice-business" bind:value={form.business_id}>
<option value="">Unassigned</option>
{#each businesses as business}
<option value={business.id}>{business.name}</option>
{/each}
</select>
</Select>
</div>
<div>
<label class="mb-2 block text-sm font-medium">Vendor</label>
<select class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.vendor_id} required>
<Label forId="invoice-vendor" className="mb-2 block">Vendor</Label>
<Select id="invoice-vendor" bind:value={form.vendor_id} required>
<option value={0}>Select vendor</option>
{#each vendors as vendor}
<option value={vendor.id}>{vendor.name}</option>
{/each}
</select>
</Select>
</div>
<div>
<label class="mb-2 block text-sm font-medium">Invoice number</label>
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.invoice_number} />
<Label forId="invoice-number" className="mb-2 block">Invoice number</Label>
<Input id="invoice-number" bind:value={form.invoice_number} />
</div>
<div>
<label class="mb-2 block text-sm font-medium">Invoice date</label>
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" type="date" bind:value={form.invoice_date} />
<Label forId="invoice-date" className="mb-2 block">Invoice date</Label>
<Input id="invoice-date" type="date" bind:value={form.invoice_date} />
</div>
<div>
<label class="mb-2 block text-sm font-medium">Due date</label>
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" type="date" bind:value={form.due_date} />
<Label forId="invoice-due-date" className="mb-2 block">Due date</Label>
<Input id="invoice-due-date" type="date" bind:value={form.due_date} />
</div>
<div>
<label class="mb-2 block text-sm font-medium">Currency</label>
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.currency} />
<Label forId="invoice-currency" className="mb-2 block">Currency</Label>
<Input id="invoice-currency" bind:value={form.currency} />
</div>
<div>
<label class="mb-2 block text-sm font-medium">Payment status</label>
<select class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.payment_status}>
<Label forId="invoice-payment-status" className="mb-2 block">Payment status</Label>
<Select id="invoice-payment-status" bind:value={form.payment_status}>
<option value="unpaid">Unpaid</option>
<option value="paid">Paid</option>
</select>
</Select>
</div>
<div>
<label class="mb-2 block text-sm font-medium">Goods received</label>
<select class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.goods_received_status}>
<Label forId="invoice-goods-status" className="mb-2 block">Goods received</Label>
<Select id="invoice-goods-status" bind:value={form.goods_received_status}>
<option value="not_received">Not received</option>
<option value="received">Received</option>
</select>
</Select>
</div>
</div>
<div>
<label class="mb-2 block text-sm font-medium">Categories</label>
<p class="mb-2 text-sm font-medium">Categories</p>
<div class="flex flex-wrap gap-2">
{#each categories as category}
<button
class={`rounded-full border px-3 py-1 text-sm ${form.category_ids.includes(category.id) ? "border-[var(--primary)] bg-[var(--primary)] text-[var(--primary-foreground)]" : "border-[var(--border)] bg-white/70"}`}
class={`rounded-full border px-3 py-1 text-sm ${form.category_ids.includes(category.id) ? "border-[var(--primary)] bg-[var(--primary)] text-[var(--primary-foreground)]" : "border-[var(--border)] bg-[var(--secondary)] text-[var(--muted-foreground)]"}`}
type="button"
on:click={() => toggleCategory(category.id)}
>
@@ -342,19 +341,19 @@
<div class="space-y-3">
<div class="flex items-center justify-between">
<label class="block text-sm font-medium">Line items</label>
<p class="text-sm font-medium">Line items</p>
<Button type="button" variant="secondary" on:click={addLine}>Add line</Button>
</div>
{#each form.line_items as line, index}
<div class="grid gap-3 md:grid-cols-[1.5fr_0.7fr_0.8fr_auto]">
<select class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={line.product_id}>
<Select bind:value={line.product_id}>
<option value={0}>Select product</option>
{#each products as product}
<option value={product.id}>{product.name}</option>
{/each}
</select>
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" type="number" min="0" step="0.001" bind:value={line.quantity} />
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" type="number" min="0" step="0.01" bind:value={line.unit_price} />
</Select>
<Input type="number" min="0" step="0.001" bind:value={line.quantity} />
<Input type="number" min="0" step="0.01" bind:value={line.unit_price} />
<div class="flex items-center justify-end text-sm text-[var(--muted-foreground)]">
{(Number(line.quantity || 0) * Number(line.unit_price || 0)).toFixed(2)}
</div>
@@ -363,11 +362,11 @@
</div>
<div>
<label class="mb-2 block text-sm font-medium">Notes</label>
<textarea class="min-h-28 w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.notes}></textarea>
<Label forId="invoice-notes" className="mb-2 block">Notes</Label>
<Textarea id="invoice-notes" className="min-h-28" bind:value={form.notes}></Textarea>
</div>
<div class="flex items-center justify-between rounded-2xl bg-black/5 p-4">
<div class="flex items-center justify-between rounded-2xl bg-[var(--muted)] p-4">
<div>
<p class="text-sm text-[var(--muted-foreground)]">Draft subtotal</p>
<p class="text-2xl font-semibold">{subtotal.toFixed(2)} {form.currency}</p>

View File

@@ -2,6 +2,11 @@
import { onMount } from "svelte";
import Button from "$lib/components/ui/button.svelte";
import Card from "$lib/components/ui/card.svelte";
import CardContent from "$lib/components/ui/card-content.svelte";
import CardDescription from "$lib/components/ui/card-description.svelte";
import CardHeader from "$lib/components/ui/card-header.svelte";
import CardTitle from "$lib/components/ui/card-title.svelte";
import Select from "$lib/components/ui/select.svelte";
import { api } from "$lib/api/client";
import type { Business, ScheduleOverview } from "$lib/types/domain";
@@ -38,54 +43,54 @@
<h2 class="mt-2 text-3xl font-semibold">Schedule overview</h2>
</div>
<div class="flex gap-2">
<select class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={selectedBusiness}>
<Select bind:value={selectedBusiness}>
<option value="">All businesses</option>
{#each businesses as business}
<option value={business.id}>{business.name}</option>
{/each}
</select>
</Select>
<Button type="button" on:click={loadData}>Filter</Button>
</div>
</div>
{#if error}
<Card><p class="text-sm text-red-700">{error}</p></Card>
<Card><CardContent className="p-5"><p class="text-sm text-red-300">{error}</p></CardContent></Card>
{:else if loading || !overview}
<Card>Loading schedule…</Card>
<Card><CardContent className="p-5">Loading schedule…</CardContent></Card>
{:else}
<div class="grid gap-4 md:grid-cols-3">
<Card><p class="text-sm text-[var(--muted-foreground)]">Roles</p><p class="mt-2 text-3xl font-semibold">{overview.roles.length}</p></Card>
<Card><p class="text-sm text-[var(--muted-foreground)]">Templates</p><p class="mt-2 text-3xl font-semibold">{overview.templates.length}</p></Card>
<Card><p class="text-sm text-[var(--muted-foreground)]">Assignments</p><p class="mt-2 text-3xl font-semibold">{overview.assignments.length}</p></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">Roles</p><p class="mt-2 text-3xl font-semibold">{overview.roles.length}</p></CardContent></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">Templates</p><p class="mt-2 text-3xl font-semibold">{overview.templates.length}</p></CardContent></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">Assignments</p><p class="mt-2 text-3xl font-semibold">{overview.assignments.length}</p></CardContent></Card>
</div>
<div class="grid gap-6 xl:grid-cols-[0.7fr_1.2fr_1.1fr]">
<Card>
<div class="mb-4">
<h3 class="text-xl font-semibold">Roles</h3>
<p class="text-sm text-[var(--muted-foreground)]">Shift role catalogue by business.</p>
</div>
<div class="space-y-3">
<CardHeader>
<CardTitle className="text-xl">Roles</CardTitle>
<CardDescription>Shift role catalogue by business.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{#each overview.roles as role}
<div class="flex items-center justify-between border-b border-[var(--border)] py-2 last:border-0">
<div>
<p class="font-medium">{role.name}</p>
<p class="text-sm text-[var(--muted-foreground)]">{role.business_name}</p>
</div>
<div class="h-4 w-4 rounded-full border border-black/10" style={`background:${role.color || "#16302b"}`}></div>
<div class="h-4 w-4 rounded-full border border-white/10" style={`background:${role.color || "#09090b"}`}></div>
</div>
{/each}
</div>
</CardContent>
</Card>
<Card>
<div class="mb-4">
<h3 class="text-xl font-semibold">Upcoming templates</h3>
<p class="text-sm text-[var(--muted-foreground)]">Recurring shift definitions and staffing targets.</p>
</div>
<div class="space-y-3">
<CardHeader>
<CardTitle className="text-xl">Upcoming templates</CardTitle>
<CardDescription>Recurring shift definitions and staffing targets.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{#each overview.templates as template}
<div class="rounded-2xl border border-[var(--border)] bg-white/60 p-4">
<div class="rounded-2xl border border-[var(--border)] bg-[var(--muted)] p-4">
<div class="flex items-start justify-between gap-3">
<div>
<p class="font-medium">{template.name}</p>
@@ -102,15 +107,15 @@
</div>
</div>
{/each}
</div>
</CardContent>
</Card>
<Card>
<div class="mb-4">
<h3 class="text-xl font-semibold">Assignments</h3>
<p class="text-sm text-[var(--muted-foreground)]">Upcoming user allocations against templates.</p>
</div>
<div class="space-y-3">
<CardHeader>
<CardTitle className="text-xl">Assignments</CardTitle>
<CardDescription>Upcoming user allocations against templates.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{#each overview.assignments as assignment}
<div class="flex items-start justify-between border-b border-[var(--border)] py-2 last:border-0">
<div>
@@ -124,7 +129,7 @@
</div>
</div>
{/each}
</div>
</CardContent>
</Card>
</div>
{/if}

View File

@@ -2,6 +2,8 @@
import { onMount } from "svelte";
import Button from "$lib/components/ui/button.svelte";
import Card from "$lib/components/ui/card.svelte";
import Input from "$lib/components/ui/input.svelte";
import Select from "$lib/components/ui/select.svelte";
import { api } from "$lib/api/client";
import type { Business, SettingsOverview, SettingsUser } from "$lib/types/domain";
@@ -132,7 +134,7 @@
</div>
{#if error}
<Card><p class="text-sm text-red-700">{error}</p></Card>
<Card><div class="p-5"><p class="text-sm text-red-300">{error}</p></div></Card>
{/if}
{#if loading || !overview}
@@ -153,7 +155,7 @@
</div>
<div class="space-y-3">
{#each overview.users as user}
<button class="w-full rounded-2xl border border-[var(--border)] bg-white/60 p-4 text-left transition hover:bg-white" on:click={() => startEditUser(user)}>
<button class="w-full rounded-2xl border border-[var(--border)] bg-[var(--muted)] p-4 text-left transition hover:bg-[var(--accent)]" on:click={() => startEditUser(user)}>
<div class="flex items-start justify-between gap-4">
<div>
<p class="font-medium">{user.display_name || user.username}</p>
@@ -162,7 +164,7 @@
{user.role_name || "No role"} • {user.business_ids.length} businesses
</p>
</div>
<p class={`text-sm ${user.is_active ? "text-green-700" : "text-red-700"}`}>{user.is_active ? "Active" : "Inactive"}</p>
<p class="text-sm text-[var(--muted-foreground)]">{user.is_active ? "Active" : "Inactive"}</p>
</div>
</button>
{/each}
@@ -176,7 +178,7 @@
</div>
<div class="space-y-3">
{#each overview.roles as role}
<div class="rounded-2xl border border-[var(--border)] bg-white/60 p-4">
<div class="rounded-2xl border border-[var(--border)] bg-[var(--muted)] p-4">
<div class="flex items-start justify-between gap-4">
<div>
<p class="font-medium">{role.name}</p>
@@ -208,17 +210,17 @@
</div>
<form class="space-y-4" on:submit|preventDefault={saveUser}>
<div class="grid gap-4 md:grid-cols-2">
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.username} placeholder="Username" required />
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.password} type="password" placeholder={editingUserId ? "Leave blank to keep password" : "Password"} required={!editingUserId} />
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.display_name} placeholder="Display name" />
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.email} type="email" placeholder="Email" />
<Input bind:value={form.username} placeholder="Username" required />
<Input bind:value={form.password} type="password" placeholder={editingUserId ? "Leave blank to keep password" : "Password"} required={!editingUserId} />
<Input bind:value={form.display_name} placeholder="Display name" />
<Input bind:value={form.email} type="email" placeholder="Email" />
</div>
<select class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.role_id}>
<Select bind:value={form.role_id}>
<option value="">No role</option>
{#each overview.roles as role}
<option value={role.id}>{role.name}</option>
{/each}
</select>
</Select>
<div>
<p class="mb-2 text-sm font-medium">Business access</p>
@@ -226,7 +228,7 @@
{#each businesses as business}
<button
type="button"
class={`rounded-full border px-3 py-1 text-sm ${form.business_ids.includes(business.id) ? "border-[var(--primary)] bg-[var(--primary)] text-[var(--primary-foreground)]" : "border-[var(--border)] bg-white/70"}`}
class={`rounded-full border px-3 py-1 text-sm ${form.business_ids.includes(business.id) ? "border-[var(--primary)] bg-[var(--primary)] text-[var(--primary-foreground)]" : "border-[var(--border)] bg-[var(--secondary)] text-[var(--muted-foreground)]"}`}
on:click={() => toggleBusiness(business.id)}
>
{business.name}

View File

@@ -2,6 +2,13 @@
import { onMount } from "svelte";
import Button from "$lib/components/ui/button.svelte";
import Card from "$lib/components/ui/card.svelte";
import CardContent from "$lib/components/ui/card-content.svelte";
import CardDescription from "$lib/components/ui/card-description.svelte";
import CardHeader from "$lib/components/ui/card-header.svelte";
import CardTitle from "$lib/components/ui/card-title.svelte";
import Input from "$lib/components/ui/input.svelte";
import Select from "$lib/components/ui/select.svelte";
import Textarea from "$lib/components/ui/textarea.svelte";
import { api } from "$lib/api/client";
import type { Business, Category, Vendor } from "$lib/types/domain";
@@ -116,30 +123,31 @@
<h2 class="mt-2 text-3xl font-semibold">Vendor management</h2>
</div>
<div class="flex gap-2">
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={query} placeholder="Search vendors" />
<Input bind:value={query} placeholder="Search vendors" />
<Button type="button" on:click={loadData}>Filter</Button>
</div>
</div>
{#if error}
<Card><p class="text-sm text-red-700">{error}</p></Card>
<Card><CardContent className="p-5"><p class="text-sm text-red-300">{error}</p></CardContent></Card>
{/if}
<div class="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<Card>
<div class="mb-4 grid gap-3 md:grid-cols-2">
<select class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={selectedBusiness}>
<CardContent className="space-y-4 p-5">
<div class="grid gap-3 md:grid-cols-2">
<Select bind:value={selectedBusiness}>
<option value="">All businesses</option>
{#each businesses as business}
<option value={business.id}>{business.name}</option>
{/each}
</select>
<select class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={selectedCategory}>
</Select>
<Select bind:value={selectedCategory}>
<option value="">All categories</option>
{#each categories as category}
<option value={category.id}>{category.name}</option>
{/each}
</select>
</Select>
</div>
{#if loading}
@@ -147,7 +155,7 @@
{:else}
<div class="space-y-3">
{#each vendors as vendor}
<button class="w-full rounded-2xl border border-[var(--border)] bg-white/60 p-4 text-left transition hover:bg-white" on:click={() => startEdit(vendor)}>
<button class="w-full rounded-2xl border border-[var(--border)] bg-[var(--muted)] p-4 text-left transition hover:bg-[var(--accent)]" on:click={() => startEdit(vendor)}>
<div class="flex items-start justify-between gap-3">
<div>
<p class="font-medium">{vendor.name}</p>
@@ -156,35 +164,37 @@
{vendor.business_ids.length} businesses • {vendor.category_ids.length} categories
</p>
</div>
<p class={`text-sm ${vendor.is_active ? "text-green-700" : "text-red-700"}`}>{vendor.is_active ? "Active" : "Inactive"}</p>
<p class="text-sm text-[var(--muted-foreground)]">{vendor.is_active ? "Active" : "Inactive"}</p>
</div>
</button>
{/each}
</div>
{/if}
</CardContent>
</Card>
<Card>
<div class="mb-4 flex items-center justify-between">
<CardHeader className="flex-row items-start justify-between space-y-0">
<div>
<h3 class="text-xl font-semibold">{editingVendorId ? "Edit vendor" : "Create vendor"}</h3>
<p class="text-sm text-[var(--muted-foreground)]">Manage linkage to businesses and invoice categories.</p>
<CardTitle className="text-xl">{editingVendorId ? "Edit vendor" : "Create vendor"}</CardTitle>
<CardDescription>Manage linkage to businesses and invoice categories.</CardDescription>
</div>
{#if editingVendorId}
<Button type="button" variant="ghost" on:click={resetForm}>Reset</Button>
{/if}
</div>
</CardHeader>
<CardContent>
<form class="space-y-4" on:submit|preventDefault={saveVendor}>
<div class="grid gap-4 md:grid-cols-2">
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.name} placeholder="Vendor name" required />
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.vat_number} placeholder="VAT number" />
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.registration_id} placeholder="Registration ID" />
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.contact_email} placeholder="Email" />
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2 md:col-span-2" bind:value={form.contact_phone} placeholder="Phone" />
<Input bind:value={form.name} placeholder="Vendor name" required />
<Input bind:value={form.vat_number} placeholder="VAT number" />
<Input bind:value={form.registration_id} placeholder="Registration ID" />
<Input bind:value={form.contact_email} placeholder="Email" />
<Input className="md:col-span-2" bind:value={form.contact_phone} placeholder="Phone" />
</div>
<textarea class="min-h-20 w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.address} placeholder="Address"></textarea>
<textarea class="min-h-24 w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.notes} placeholder="Notes"></textarea>
<Textarea className="min-h-20" bind:value={form.address} placeholder="Address"></Textarea>
<Textarea className="min-h-24" bind:value={form.notes} placeholder="Notes"></Textarea>
<div>
<p class="mb-2 text-sm font-medium">Businesses</p>
@@ -192,7 +202,7 @@
{#each businesses as business}
<button
type="button"
class={`rounded-full border px-3 py-1 text-sm ${form.business_ids.includes(business.id) ? "border-[var(--primary)] bg-[var(--primary)] text-[var(--primary-foreground)]" : "border-[var(--border)] bg-white/70"}`}
class={`rounded-full border px-3 py-1 text-sm ${form.business_ids.includes(business.id) ? "border-[var(--primary)] bg-[var(--primary)] text-[var(--primary-foreground)]" : "border-[var(--border)] bg-[var(--secondary)] text-[var(--muted-foreground)]"}`}
on:click={() => (form.business_ids = toggleId(form.business_ids, business.id))}
>
{business.name}
@@ -207,7 +217,7 @@
{#each categories as category}
<button
type="button"
class={`rounded-full border px-3 py-1 text-sm ${form.category_ids.includes(category.id) ? "border-[var(--primary)] bg-[var(--primary)] text-[var(--primary-foreground)]" : "border-[var(--border)] bg-white/70"}`}
class={`rounded-full border px-3 py-1 text-sm ${form.category_ids.includes(category.id) ? "border-[var(--primary)] bg-[var(--primary)] text-[var(--primary-foreground)]" : "border-[var(--border)] bg-[var(--secondary)] text-[var(--muted-foreground)]"}`}
on:click={() => (form.category_ids = toggleId(form.category_ids, category.id))}
>
{category.name}
@@ -225,6 +235,7 @@
{saving ? "Saving…" : editingVendorId ? "Update vendor" : "Create vendor"}
</Button>
</form>
</CardContent>
</Card>
</div>
</div>

View File

@@ -3,6 +3,12 @@
import { goto } from "$app/navigation";
import Button from "$lib/components/ui/button.svelte";
import Card from "$lib/components/ui/card.svelte";
import CardContent from "$lib/components/ui/card-content.svelte";
import CardDescription from "$lib/components/ui/card-description.svelte";
import CardHeader from "$lib/components/ui/card-header.svelte";
import CardTitle from "$lib/components/ui/card-title.svelte";
import Input from "$lib/components/ui/input.svelte";
import Label from "$lib/components/ui/label.svelte";
import { api } from "$lib/api/client";
import { authUser, authReady, bootstrapAuth } from "$lib/stores/auth";
@@ -43,41 +49,70 @@
{#if checkingSession}
<div class="mx-auto flex min-h-screen max-w-6xl items-center justify-center px-6 py-10">
<div class="panel p-6 text-center">
<Card className="w-full max-w-md bg-[var(--secondary)] p-6 text-center">
<p class="text-sm uppercase tracking-[0.3em] text-[var(--muted-foreground)]">Session</p>
<p class="mt-2 text-lg font-medium">Checking authentication…</p>
</div>
</Card>
</div>
{:else}
<div class="mx-auto flex min-h-screen max-w-6xl items-center px-6 py-10">
<div class="grid w-full gap-8 lg:grid-cols-[1.2fr_0.8fr]">
<div class="space-y-6">
<p class="text-sm uppercase tracking-[0.35em] text-[var(--muted-foreground)]">Django + Svelte port</p>
<h1 class="max-w-2xl text-5xl font-semibold leading-tight">
Replace the brittle FastAPI tangle with a session-based operations platform.
</h1>
<p class="max-w-xl text-lg text-[var(--muted-foreground)]">
The new frontend is organized around stable domains, smaller route modules, and a backend that uses Django ORM and admin instead of runtime schema patching.
</p>
<div class="mx-auto flex min-h-screen max-w-7xl items-center px-6 py-10">
<div class="grid w-full gap-10 lg:grid-cols-[1.3fr_0.7fr]">
<div class="flex flex-col justify-center space-y-8">
<div class="space-y-4">
<p class="text-sm uppercase tracking-[0.35em] text-[var(--muted-foreground)]">Django + Svelte port</p>
<h1 class="max-w-3xl text-5xl font-semibold leading-tight lg:text-6xl">
Hospitality operations, rebuilt as a clean black-and-white control surface.
</h1>
<p class="max-w-2xl text-lg text-[var(--muted-foreground)]">
Session auth, Django ORM, scoped business access, and domain-focused screens without the legacy FastAPI sprawl.
</p>
</div>
<div class="grid gap-4 sm:grid-cols-3">
<Card className="bg-[var(--secondary)]">
<CardContent className="p-5">
<p class="text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">Backend</p>
<p class="mt-2 text-lg font-semibold">Django</p>
</CardContent>
</Card>
<Card className="bg-[var(--secondary)]">
<CardContent className="p-5">
<p class="text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">Frontend</p>
<p class="mt-2 text-lg font-semibold">SvelteKit</p>
</CardContent>
</Card>
<Card className="bg-[var(--secondary)]">
<CardContent className="p-5">
<p class="text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">UI</p>
<p class="mt-2 text-lg font-semibold">Vanilla shadcn style</p>
</CardContent>
</Card>
</div>
</div>
<Card>
<form class="space-y-4" on:submit|preventDefault={submit}>
<div>
<label class="mb-2 block text-sm font-medium">Username</label>
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={username} />
</div>
<div>
<label class="mb-2 block text-sm font-medium">Password</label>
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" type="password" bind:value={password} />
</div>
{#if error}
<p class="text-sm text-red-700">{error}</p>
{/if}
<Button type="submit" disabled={submitting}>
{submitting ? "Signing in..." : "Sign in"}
</Button>
</form>
<Card className="border-[var(--foreground)]/10 bg-[var(--secondary)]">
<CardHeader>
<CardTitle className="text-2xl">Sign in</CardTitle>
<CardDescription>Use the Django session auth flow for the new operations app.</CardDescription>
</CardHeader>
<CardContent>
<form class="space-y-4" on:submit|preventDefault={submit}>
<div class="space-y-2">
<Label forId="login-username">Username</Label>
<Input id="login-username" bind:value={username} autocomplete="username" />
</div>
<div class="space-y-2">
<Label forId="login-password">Password</Label>
<Input id="login-password" type="password" bind:value={password} autocomplete="current-password" />
</div>
{#if error}
<p class="rounded-md border border-red-900 bg-red-950/40 px-3 py-2 text-sm text-red-300">{error}</p>
{/if}
<Button type="submit" disabled={submitting} className="w-full">
{submitting ? "Signing in..." : "Sign in"}
</Button>
</form>
</CardContent>
</Card>
</div>
</div>