Compare commits
193 Commits
refactor/r
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| abe75fb1fd | |||
|
|
45fd6d34eb | ||
| 30233b430f | |||
|
|
924a28e201 | ||
|
|
2cd646ddea | ||
|
|
3f9e125412 | ||
| 7bca854058 | |||
|
|
96d2a966d7 | ||
|
|
666ae8d59b | ||
| 67f1e4a26a | |||
|
|
613784a1cc | ||
| ea9aceec46 | |||
|
|
bc8613bbbc | ||
|
|
a1dc4b0e47 | ||
| 81de80abb9 | |||
|
|
75c7c28f73 | ||
|
|
99fdaaa05a | ||
| 6870aea1a5 | |||
|
|
e589ff02f0 | ||
|
|
da4b765b14 | ||
|
|
326b30161b | ||
|
|
ac9b615470 | ||
|
|
785071e5fd | ||
|
|
0ca6ae7632 | ||
|
|
7d9aeaf188 | ||
|
|
be8cc0ad8b | ||
|
|
95cf017b4b | ||
|
|
a48da03289 | ||
|
|
e19cdc76e0 | ||
|
|
fe40b563d5 | ||
|
|
49db60e31f | ||
|
|
8820a56a51 | ||
|
|
0aa9345c27 | ||
|
|
3519db9bd9 | ||
|
|
0fd73015f7 | ||
|
|
d21972212b | ||
|
|
cf299ac1d2 | ||
|
|
6505b2dcb9 | ||
|
|
12a3e170de | ||
|
|
3ed2707a66 | ||
|
|
24ddb17ca8 | ||
|
|
bd2252e426 | ||
|
|
254fe0e5d5 | ||
|
|
5750af83d8 | ||
|
|
505f8fd4e4 | ||
|
|
a84530e80e | ||
|
|
b8e39369f1 | ||
|
|
81d37a2c68 | ||
|
|
70a924cfba | ||
| 2b9e99bbd6 | |||
|
|
a0be16d848 | ||
|
|
1673e18fe8 | ||
|
|
bd3a25aa1c | ||
| bd5f0ca511 | |||
|
|
aa5b53920d | ||
|
|
772d5578ad | ||
| 51e7902ee2 | |||
|
|
8256380cd1 | ||
|
|
199b9ab3b3 | ||
|
|
2b141d45f3 | ||
|
|
1c56023491 | ||
|
|
7b15f18e59 | ||
|
|
93450a1eb7 | ||
|
|
7aa81c4e7c | ||
|
|
b318345fdf | ||
|
|
cc5ab41da4 | ||
|
|
c54069ad99 | ||
|
|
04420dbd12 | ||
|
|
f538c60f3d | ||
|
|
1524153cfb | ||
|
|
4c31357428 | ||
|
|
b3e67d1483 | ||
|
|
3d081ee01f | ||
|
|
9942a5638f | ||
|
|
b40bab0391 | ||
|
|
de70fb47f9 | ||
|
|
c5d618698b | ||
|
|
54943ea1b7 | ||
|
|
ba1c385937 | ||
|
|
2231f06234 | ||
|
|
fe29e548fb | ||
|
|
3d72f04b14 | ||
|
|
36eb377583 | ||
|
|
b8f0c7ecba | ||
|
|
0034e0b8b3 | ||
|
|
d9a7409f9c | ||
|
|
358e07b1d5 | ||
|
|
5df914a40f | ||
|
|
1f8002629f | ||
|
|
ff2e4586f3 | ||
|
|
019867f1fa | ||
|
|
48d49a068a | ||
| 11f81cfd8e | |||
|
|
1601e0f849 | ||
|
|
0e3a8d7504 | ||
|
|
87456e3aac | ||
| d7249088e9 | |||
|
|
59ea9557d1 | ||
| 628c090dfd | |||
|
|
9fc42b7881 | ||
|
|
ce3ac3dfc0 | ||
|
|
75bc10fe3c | ||
|
|
d821302439 | ||
|
|
8eb116a9a1 | ||
|
|
038ea22068 | ||
|
|
c236059ce1 | ||
|
|
67f8881b3c | ||
|
|
1df4bb15a8 | ||
|
|
ddabcf19d1 | ||
|
|
9c8ceba461 | ||
|
|
384f985a77 | ||
|
|
5b56a2c219 | ||
|
|
4ef7f1977c | ||
|
|
bc2f39c208 | ||
|
|
41037af4f6 | ||
|
|
77e155b8c3 | ||
|
|
c01d19215d | ||
|
|
744256ae9f | ||
|
|
5993f8b370 | ||
|
|
4c971f90eb | ||
|
|
05f926c0dc | ||
|
|
a73c2de26e | ||
|
|
0f8aff9426 | ||
|
|
97638b888e | ||
|
|
6852a79f87 | ||
|
|
a561b184e1 | ||
|
|
7864c07be1 | ||
|
|
c3de66e554 | ||
|
|
1e38d69b21 | ||
|
|
eb9e669233 | ||
|
|
13db5ce5f1 | ||
|
|
babfccaa1b | ||
|
|
1aa29dfb31 | ||
|
|
efb55bc784 | ||
|
|
9cdb2cf427 | ||
|
|
135b88029a | ||
|
|
23df7feaf1 | ||
|
|
2e2832b1e3 | ||
|
|
dd846bc86a | ||
|
|
25e9177554 | ||
|
|
cfd1561de9 | ||
|
|
6304d14e56 | ||
|
|
e4fe42c6ec | ||
|
|
27a9900787 | ||
|
|
90cbff0611 | ||
|
|
ddae05dc3f | ||
|
|
12fa36a7b0 | ||
|
|
fc7d92e33f | ||
|
|
8f941381f9 | ||
|
|
7987818ae7 | ||
|
|
83b48cb3be | ||
|
|
c2b1073fef | ||
|
|
ffef4c8727 | ||
|
|
d18d431bd0 | ||
|
|
41b6f076cb | ||
|
|
fe3c7646d6 | ||
|
|
bde3ad64fd | ||
|
|
5f5ba9e4a2 | ||
|
|
ec09e319ed | ||
|
|
89b412374a | ||
|
|
07f199b69d | ||
|
|
ae3c85fee0 | ||
|
|
5ad27bc196 | ||
|
|
7680a73d88 | ||
|
|
2cc8f24535 | ||
|
|
5cd2d05983 | ||
|
|
aae5a022a8 | ||
|
|
31f661ff4f | ||
|
|
73360cd478 | ||
|
|
93405af3b2 | ||
|
|
f777ce5184 | ||
|
|
5dbe837c08 | ||
|
|
145eb0efce | ||
|
|
7176c1471e | ||
|
|
701e15ea6d | ||
|
|
9400828f62 | ||
|
|
535446696c | ||
|
|
a51f1f5141 | ||
|
|
4438188362 | ||
|
|
1510133074 | ||
|
|
328b4a1f7b | ||
|
|
e346e072b8 | ||
|
|
1f9297f533 | ||
|
|
7246587955 | ||
|
|
748ea59c80 | ||
|
|
f998b16a3f | ||
|
|
51ca2ca683 | ||
|
|
cbbf2713a1 | ||
|
|
1002117610 | ||
|
|
f9bf1c9bff | ||
|
|
8d75586f8b | ||
|
|
653fff6ce2 | ||
|
|
0f6cc104d2 |
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
.git
|
||||
.gitea
|
||||
docs
|
||||
planning
|
||||
deploy
|
||||
infra
|
||||
packages/admin
|
||||
!packages/admin/package.json
|
||||
Dockerfile*
|
||||
docker-compose*
|
||||
*.md
|
||||
@@ -1,9 +1,9 @@
|
||||
# Forte — Environment Variables
|
||||
# LunarFront — Environment Variables
|
||||
# Copy to .env and adjust values for your setup.
|
||||
# Docker Compose overrides host values (postgres, valkey) automatically.
|
||||
|
||||
# Database (PostgreSQL 16)
|
||||
DATABASE_URL=postgresql://forte:forte@localhost:5432/forte
|
||||
DATABASE_URL=postgresql://lunarfront:lunarfront@localhost:5432/lunarfront
|
||||
|
||||
# Valkey / Redis
|
||||
REDIS_URL=redis://localhost:6379
|
||||
@@ -20,7 +20,7 @@ NODE_ENV=development
|
||||
|
||||
# Logging (optional)
|
||||
# LOG_LEVEL=info
|
||||
# LOG_FILE=./logs/forte.log
|
||||
# LOG_FILE=./logs/lunarfront.log
|
||||
|
||||
# File Storage (optional — defaults to local)
|
||||
# STORAGE_PROVIDER=local
|
||||
|
||||
34
.gitea/workflows/build-devpod.yml
Normal file
34
.gitea/workflows/build-devpod.yml
Normal file
@@ -0,0 +1,34 @@
|
||||
name: Build Devpod
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- Dockerfile.devpod
|
||||
- entrypoint-devpod.sh
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
REGISTRY: registry.digitalocean.com/lunarfront
|
||||
DOCKER_HOST: tcp://localhost:2375
|
||||
VERSION: devpod-0.1.${{ github.run_number }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Login to DOCR
|
||||
run: echo "${{ secrets.DOCR_TOKEN }}" | docker login registry.digitalocean.com -u token --password-stdin
|
||||
|
||||
- name: Build and push devpod
|
||||
run: |
|
||||
docker build \
|
||||
-t $REGISTRY/manager:$VERSION \
|
||||
-t $REGISTRY/manager:devpod-latest \
|
||||
-f Dockerfile.devpod .
|
||||
docker push $REGISTRY/manager:$VERSION
|
||||
docker push $REGISTRY/manager:devpod-latest
|
||||
|
||||
66
.gitea/workflows/build.yml
Normal file
66
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,66 @@
|
||||
name: Build & Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: build
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
REGISTRY: registry.digitalocean.com/lunarfront
|
||||
DOCKER_HOST: tcp://localhost:2375
|
||||
VERSION: 0.1.${{ github.run_number }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Login to DOCR
|
||||
run: echo "${{ secrets.DOCR_TOKEN }}" | docker login registry.digitalocean.com -u token --password-stdin
|
||||
|
||||
- name: Build and push backend
|
||||
run: |
|
||||
SHA=$(git rev-parse --short HEAD)
|
||||
docker build \
|
||||
--build-arg APP_VERSION=$VERSION \
|
||||
-t $REGISTRY/lunarfront-app:$VERSION \
|
||||
-t $REGISTRY/lunarfront-app:$SHA \
|
||||
-t $REGISTRY/lunarfront-app:latest \
|
||||
-f Dockerfile .
|
||||
docker push $REGISTRY/lunarfront-app:$VERSION
|
||||
docker push $REGISTRY/lunarfront-app:$SHA
|
||||
docker push $REGISTRY/lunarfront-app:latest
|
||||
|
||||
- name: Build and push frontend
|
||||
run: |
|
||||
SHA=$(git rev-parse --short HEAD)
|
||||
docker build \
|
||||
-t $REGISTRY/lunarfront-frontend:$VERSION \
|
||||
-t $REGISTRY/lunarfront-frontend:$SHA \
|
||||
-t $REGISTRY/lunarfront-frontend:latest \
|
||||
-f Dockerfile.frontend .
|
||||
docker push $REGISTRY/lunarfront-frontend:$VERSION
|
||||
docker push $REGISTRY/lunarfront-frontend:$SHA
|
||||
docker push $REGISTRY/lunarfront-frontend:latest
|
||||
|
||||
- name: Install Helm
|
||||
run: curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
|
||||
|
||||
- name: Package and push Helm chart
|
||||
run: |
|
||||
sed -i "s/^version:.*/version: $VERSION/" chart/Chart.yaml
|
||||
sed -i "s/^appVersion:.*/appVersion: \"$VERSION\"/" chart/Chart.yaml
|
||||
sed -i "s|tag: .*|tag: $VERSION|g" chart/values.yaml
|
||||
helm registry login registry.digitalocean.com -u token --password "${{ secrets.DOCR_TOKEN }}"
|
||||
helm package chart/
|
||||
helm push lunarfront-$VERSION.tgz oci://registry.digitalocean.com/lunarfront
|
||||
|
||||
- name: Logout
|
||||
if: always()
|
||||
run: docker logout registry.digitalocean.com
|
||||
74
.gitea/workflows/ci.yml
Normal file
74
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,74 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Bun
|
||||
run: |
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
echo "$HOME/.bun/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Generate route tree
|
||||
working-directory: packages/admin
|
||||
run: bunx @tanstack/router-cli generate
|
||||
|
||||
- name: Lint
|
||||
run: bun run lint
|
||||
|
||||
- name: Test
|
||||
run: bun run test
|
||||
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
needs: ci
|
||||
env:
|
||||
DOCKER_HOST: tcp://localhost:2375
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Start services
|
||||
run: |
|
||||
docker run -d --name postgres \
|
||||
-e POSTGRES_USER=lunarfront \
|
||||
-e POSTGRES_PASSWORD=lunarfront \
|
||||
-e POSTGRES_DB=postgres \
|
||||
-p 5432:5432 \
|
||||
postgres:16
|
||||
docker run -d --name valkey \
|
||||
-p 6379:6379 \
|
||||
valkey/valkey:8
|
||||
until docker exec postgres pg_isready -U lunarfront; do sleep 1; done
|
||||
until docker exec valkey valkey-cli ping; do sleep 1; done
|
||||
|
||||
- name: Install Bun
|
||||
run: |
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
echo "$HOME/.bun/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Run API tests
|
||||
working-directory: packages/backend
|
||||
run: bun run api-test
|
||||
|
||||
- name: Stop services
|
||||
if: always()
|
||||
run: |
|
||||
docker stop postgres valkey || true
|
||||
docker rm postgres valkey || true
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -17,6 +17,13 @@ out/
|
||||
*.o
|
||||
*.pyc
|
||||
__pycache__/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Turbo cache
|
||||
.turbo/
|
||||
|
||||
# Infra (moved to lunarfront-infra repo)
|
||||
infra/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
|
||||
133
CLAUDE.md
133
CLAUDE.md
@@ -1,8 +1,8 @@
|
||||
# Forte — Project Conventions
|
||||
# LunarFront — Project Conventions
|
||||
|
||||
## App
|
||||
- **Name:** Forte
|
||||
- **Purpose:** Music store management platform (POS, inventory, rentals, lessons, repairs, accounting)
|
||||
- **Name:** LunarFront
|
||||
- **Purpose:** Small business management platform (POS, inventory, rentals, scheduling, repairs, accounting)
|
||||
- **Company:** Lunarfront Tech LLC
|
||||
|
||||
## Tech Stack
|
||||
@@ -18,14 +18,13 @@
|
||||
- **Linting:** ESLint 9 flat config + Prettier
|
||||
|
||||
## Package Namespace
|
||||
- `@forte/shared` — types, Zod schemas, business logic, utils
|
||||
- `@forte/backend` — Fastify API server
|
||||
- `@lunarfront/shared` — types, Zod schemas, business logic, utils
|
||||
- `@lunarfront/backend` — Fastify API server
|
||||
|
||||
## Database
|
||||
- Dev: `forte` on localhost:5432
|
||||
- Test: `forte_test` on localhost:5432
|
||||
- Multi-tenant: `company_id` (uuid FK) on all domain tables for tenant isolation
|
||||
- `location_id` (uuid FK) on tables that need per-location scoping (inventory, transactions, drawer)
|
||||
- Dev: `lunarfront` on localhost:5432
|
||||
- Test: `lunarfront_test` on localhost:5432
|
||||
- Each deployed instance has its own isolated database — no multi-tenancy, no `company_id`
|
||||
- Migrations via Drizzle Kit (`bunx drizzle-kit generate`, `bunx drizzle-kit migrate`)
|
||||
|
||||
## Key Entity Names
|
||||
@@ -46,7 +45,7 @@
|
||||
- `?sort=name&order=asc` — sorting by field name, asc or desc
|
||||
- List responses always return `{ data: [...], pagination: { page, limit, total, totalPages } }`
|
||||
- Search and filtering is ALWAYS server-side, never client-side
|
||||
- Use `PaginationSchema` from `@forte/shared/schemas` to parse query params
|
||||
- Use `PaginationSchema` from `@lunarfront/shared/schemas` to parse query params
|
||||
- Use pagination helpers from `packages/backend/src/utils/pagination.ts`
|
||||
- **Lookup endpoints** (e.g., `/roles/all`, `/statuses/all`) are the exception — these return a flat unpaginated list for populating dropdowns/selects. Use a `/all` suffix to distinguish from the paginated list endpoint for the same resource.
|
||||
|
||||
@@ -60,7 +59,119 @@
|
||||
|
||||
## Conventions
|
||||
- Shared Zod schemas are the single source of truth for validation (used on both frontend and backend)
|
||||
- Business logic lives in `@forte/shared`, not in individual app packages
|
||||
- Business logic lives in `@lunarfront/shared`, not in individual app packages
|
||||
- API routes are thin — validate with Zod, call a service, return result
|
||||
- All financial events must be auditable (append-only audit records)
|
||||
- JSON structured logging with request IDs on every log line
|
||||
|
||||
---
|
||||
|
||||
## Infrastructure
|
||||
|
||||
### Overview
|
||||
LunarFront runs on DigitalOcean Kubernetes (DOKS). Each customer gets an isolated namespace, database, and Helm release managed by ArgoCD.
|
||||
|
||||
### Key Services
|
||||
- **Cluster:** `lunarfront` DOKS cluster, region NYC
|
||||
- **Registry:** `registry.digitalocean.com/lunarfront` (DOCR) — stores Docker images and Helm charts
|
||||
- **Git:** `git.lunarfront.tech` — self-hosted Gitea, source of truth for code and charts
|
||||
- **CI:** Gitea Actions — builds Docker images and Helm charts on push to `main`
|
||||
- **CD:** ArgoCD at `argocd.lunarfront.tech` — deploys from `lunarfront-charts` repo
|
||||
- **Database:** DO Managed PostgreSQL — one database per customer, plus manager DB
|
||||
- **Cache/Queue:** DO Managed Valkey — shared across all customers (key-prefixed per customer)
|
||||
- **Ingress:** nginx ingress controller with Cloudflare proxy in front
|
||||
- **TLS:** cert-manager with Let's Encrypt (letsencrypt-prod cluster issuer)
|
||||
- **DNS:** Cloudflare — wildcard `*.lunarfront.tech` → cluster LB IP `167.99.21.170`
|
||||
|
||||
### Node Pools
|
||||
- `system` — 2x s-2vcpu-4gb, runs ingress, ArgoCD, manager, pgbouncer
|
||||
- `customers` — autoscales 0→N, s-4vcpu-8gb, runs customer app pods (tainted `role=customer`)
|
||||
- `dev` — autoscales 0→1, s-4vcpu-8gb, runs dev pod only (tainted `dedicated=dev:NoSchedule`)
|
||||
|
||||
### Repos
|
||||
- `lunarfront-app` — main application code (this repo)
|
||||
- `lunarfront-charts` — Helm charts and ArgoCD app definitions
|
||||
- `lunarfront-infra` — Terraform for DO infrastructure (DOKS, managed DBs, registry, DNS)
|
||||
- `lunarfront-manager` — internal ops tool for provisioning/deprovisioning customers
|
||||
|
||||
---
|
||||
|
||||
## Build & Deploy Pipeline
|
||||
|
||||
### How it works
|
||||
1. Push code to `main` on `lunarfront-app`
|
||||
2. Gitea Actions runs `.gitea/workflows/build.yml`:
|
||||
- Builds `lunarfront-app` Docker image → pushes as `0.1.{run_number}`, `{sha}`, `latest`
|
||||
- Builds `lunarfront-frontend` Docker image → same tags
|
||||
- Packages Helm chart → pushes as `0.1.{run_number}` to DOCR OCI registry
|
||||
3. ArgoCD image updater detects new image digests → updates customer deployments
|
||||
4. New customer provisions always get the latest chart version (queried from DOCR at provision time)
|
||||
5. Existing customers upgraded via `POST /customers/:slug/upgrade` or `POST /customers/upgrade-all` in the manager
|
||||
|
||||
### Versioning
|
||||
- Version format: `0.1.{gitea_run_number}` — always incrementing, no git commit-back needed
|
||||
- No version stored in git — source of truth is DOCR tags
|
||||
- Chart version and app version are kept in sync
|
||||
|
||||
### Key files
|
||||
- `Dockerfile` — backend image (bun runtime, runs `packages/backend/src/main.ts` directly)
|
||||
- `Dockerfile.frontend` — frontend nginx image
|
||||
- `chart/` — Helm chart for customer app deployments
|
||||
- `.gitea/workflows/build.yml` — CI pipeline
|
||||
- `.gitea/workflows/build-devpod.yml` — builds dev box image on Dockerfile.devpod changes
|
||||
|
||||
---
|
||||
|
||||
## Dev Box
|
||||
|
||||
### What it is
|
||||
A persistent development pod running in the `dev` namespace on the cluster. Provides a full remote dev environment accessible from anywhere.
|
||||
|
||||
- **VS Code in browser:** `dev.lunarfront.tech` (Cloudflare Access protected, OTP to email)
|
||||
- **SSH:** `ssh -p 2222 root@dev-ssh.lunarfront.tech`
|
||||
- **Storage:** 100GB DO block storage PVC mounted at `/root` — everything in home dir persists
|
||||
- **Image:** `registry.digitalocean.com/lunarfront/manager:devpod-latest`
|
||||
- **Tools:** bun, Claude Code CLI, code-server, kubectl, helm, k9s, doctl, psql, redis-cli, git
|
||||
|
||||
### Managing the dev pod
|
||||
```bash
|
||||
# Scale up (provisions node automatically)
|
||||
kubectl scale deployment dev -n dev --replicas=1
|
||||
|
||||
# Scale down (node auto-terminates after ~15 min)
|
||||
kubectl scale deployment dev -n dev --replicas=0
|
||||
```
|
||||
|
||||
### Running the app locally on the dev box (no containers)
|
||||
The dev box runs the app as plain Bun processes, connecting to the same DO managed services as production.
|
||||
|
||||
**Required env vars** (create a `.env` file in the repo root or export in `.bashrc`):
|
||||
```bash
|
||||
DATABASE_URL=postgresql://... # DO managed postgres, lunarfront database
|
||||
REDIS_URL=rediss://... # DO managed valkey
|
||||
JWT_SECRET=... # any random hex string for local dev
|
||||
PORT=8000
|
||||
```
|
||||
|
||||
**Start the app:**
|
||||
```bash
|
||||
cd ~/lunarfront-app
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Access the running backend at `dev.lunarfront.tech/proxy/8000/` in the browser (code-server proxy), or via SSH port forward:
|
||||
```bash
|
||||
ssh -p 2222 -L 8000:localhost:8000 root@dev-ssh.lunarfront.tech
|
||||
```
|
||||
|
||||
**Run migrations against the dev database:**
|
||||
```bash
|
||||
bunx drizzle-kit migrate
|
||||
```
|
||||
|
||||
### Workflow
|
||||
1. Edit code in VS Code at `dev.lunarfront.tech` or via SSH
|
||||
2. Run and test locally with `bun run dev` — app connects to DO managed postgres/valkey
|
||||
3. Push to `main` → Gitea Actions builds and pushes new Docker image + Helm chart
|
||||
4. ArgoCD deploys to the cluster automatically
|
||||
5. Use manager at `manager.lunarfront.tech` to upgrade customer instances if needed
|
||||
|
||||
27
Dockerfile
Normal file
27
Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
||||
FROM oven/bun:1.3.11-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json bun.lock ./
|
||||
COPY packages/shared/package.json packages/shared/
|
||||
COPY packages/backend/package.json packages/backend/
|
||||
COPY packages/admin/package.json packages/admin/
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
FROM oven/bun:1.3.11-alpine
|
||||
ARG APP_VERSION=dev
|
||||
ENV APP_VERSION=${APP_VERSION}
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
|
||||
COPY --from=deps /app/packages/backend/node_modules ./packages/backend/node_modules
|
||||
COPY packages/shared ./packages/shared
|
||||
COPY packages/backend ./packages/backend
|
||||
COPY package.json ./
|
||||
COPY tsconfig.base.json ./
|
||||
RUN addgroup -S app && adduser -S app -G app
|
||||
COPY packages/backend/src/db/migrations ./migrations
|
||||
ENV MIGRATIONS_DIR=/app/migrations
|
||||
EXPOSE 8000
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD wget -qO- http://localhost:8000/v1/health || exit 1
|
||||
USER app
|
||||
CMD ["bun", "run", "packages/backend/src/main.ts"]
|
||||
47
Dockerfile.devpod
Normal file
47
Dockerfile.devpod
Normal file
@@ -0,0 +1,47 @@
|
||||
FROM ubuntu:24.04
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV HOME=/root
|
||||
ENV PATH="/usr/local/bin:/root/.bun/bin:$PATH"
|
||||
|
||||
# Base tools
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl wget git openssh-server ca-certificates gnupg \
|
||||
build-essential unzip zip jq tmux zsh ripgrep \
|
||||
postgresql-client redis-tools haproxy tini \
|
||||
nano vim htop netcat-openbsd dnsutils iputils-ping lsof iproute2 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Bun — install then move to /usr/local/bin so it's on the image filesystem, not the /root PVC
|
||||
RUN curl -fsSL https://bun.sh/install | bash \
|
||||
&& mv /root/.bun/bin/bun /usr/local/bin/bun \
|
||||
&& ln -sf /usr/local/bin/bun /usr/local/bin/bunx \
|
||||
&& rm -rf /root/.bun
|
||||
|
||||
# code-server (VS Code in browser)
|
||||
RUN curl -fsSL https://code-server.dev/install.sh | sh
|
||||
|
||||
# kubectl
|
||||
RUN curl -fsSL "https://dl.k8s.io/release/$(curl -fsSL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" \
|
||||
-o /usr/local/bin/kubectl && chmod +x /usr/local/bin/kubectl
|
||||
|
||||
# doctl
|
||||
RUN curl -fsSL https://github.com/digitalocean/doctl/releases/download/v1.119.0/doctl-1.119.0-linux-amd64.tar.gz \
|
||||
| tar xz -C /usr/local/bin
|
||||
|
||||
# helm
|
||||
RUN curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
|
||||
|
||||
# k9s
|
||||
RUN curl -fsSL https://github.com/derailed/k9s/releases/latest/download/k9s_Linux_amd64.tar.gz \
|
||||
| tar xz -C /usr/local/bin k9s
|
||||
|
||||
# SSH setup — host keys generated at runtime via entrypoint
|
||||
RUN mkdir -p /run/sshd /root/.ssh && chmod 700 /root/.ssh
|
||||
|
||||
COPY entrypoint-devpod.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
WORKDIR /root
|
||||
EXPOSE 8080 22
|
||||
ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]
|
||||
28
Dockerfile.frontend
Normal file
28
Dockerfile.frontend
Normal file
@@ -0,0 +1,28 @@
|
||||
FROM oven/bun:1.3.11-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json bun.lock ./
|
||||
COPY packages/shared/package.json packages/shared/
|
||||
COPY packages/admin/package.json packages/admin/
|
||||
COPY packages/backend/package.json packages/backend/
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
FROM oven/bun:1.3.11-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
|
||||
COPY --from=deps /app/packages/admin/node_modules ./packages/admin/node_modules
|
||||
COPY packages/shared ./packages/shared
|
||||
COPY packages/admin ./packages/admin
|
||||
COPY package.json ./
|
||||
COPY tsconfig.base.json ./
|
||||
WORKDIR /app/packages/admin
|
||||
RUN bunx @tanstack/router-cli generate
|
||||
RUN bun run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/packages/admin/dist /usr/share/nginx/html
|
||||
# nginx docker image processes templates in /etc/nginx/templates/ with envsubst at startup
|
||||
COPY nginx.conf /etc/nginx/templates/default.conf.template
|
||||
ENV BACKEND_URL=http://localhost:8000
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
12
Dockerfile.frontend.dockerignore
Normal file
12
Dockerfile.frontend.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
.git
|
||||
.gitea
|
||||
docs
|
||||
planning
|
||||
deploy
|
||||
infra
|
||||
packages/backend
|
||||
!packages/backend/package.json
|
||||
Dockerfile*
|
||||
docker-compose*
|
||||
*.md
|
||||
@@ -1,6 +1,6 @@
|
||||
# Forte
|
||||
# LunarFront
|
||||
|
||||
Music store management platform — POS, inventory, rentals, lessons, repairs, and accounting.
|
||||
Small business management platform — POS, inventory, rentals, scheduling, repairs, and accounting.
|
||||
|
||||
Built by [Lunarfront Tech LLC](https://lunarfront.com).
|
||||
|
||||
|
||||
183
bun.lock
183
bun.lock
@@ -3,7 +3,7 @@
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "forte",
|
||||
"name": "lunarfront",
|
||||
"dependencies": {
|
||||
"zod": "^4.3.6",
|
||||
},
|
||||
@@ -16,11 +16,11 @@
|
||||
},
|
||||
},
|
||||
"packages/admin": {
|
||||
"name": "@forte/admin",
|
||||
"name": "@lunarfront/admin",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@forte/shared": "workspace:*",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@lunarfront/shared": "workspace:*",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
@@ -30,12 +30,17 @@
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@tanstack/react-query": "^5.75.5",
|
||||
"@tanstack/react-router": "^1.121.0",
|
||||
"@types/react-big-calendar": "^1.16.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"html2pdf.js": "^0.14.0",
|
||||
"jsbarcode": "^3.12.3",
|
||||
"jspdf": "^4.2.1",
|
||||
"lucide-react": "^1.7.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.1.0",
|
||||
"react-big-calendar": "^1.19.4",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.72.0",
|
||||
"sonner": "^2.0.3",
|
||||
@@ -55,15 +60,16 @@
|
||||
},
|
||||
},
|
||||
"packages/backend": {
|
||||
"name": "@forte/backend",
|
||||
"version": "0.0.1",
|
||||
"name": "@lunarfront/backend",
|
||||
"version": "0.1.1",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^10",
|
||||
"@fastify/jwt": "^9",
|
||||
"@fastify/multipart": "^9.4.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@forte/shared": "workspace:*",
|
||||
"bcrypt": "^6",
|
||||
"@lunarfront/shared": "workspace:*",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"drizzle-orm": "^0.38",
|
||||
"fastify": "^5",
|
||||
"fastify-plugin": "^5",
|
||||
@@ -72,7 +78,6 @@
|
||||
"zod": "^4",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5",
|
||||
"@types/node": "^22",
|
||||
"drizzle-kit": "^0.30",
|
||||
"pino-pretty": "^13",
|
||||
@@ -80,7 +85,7 @@
|
||||
},
|
||||
},
|
||||
"packages/shared": {
|
||||
"name": "@forte/shared",
|
||||
"name": "@lunarfront/shared",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"zod": "^4",
|
||||
@@ -243,12 +248,6 @@
|
||||
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
|
||||
|
||||
"@forte/admin": ["@forte/admin@workspace:packages/admin"],
|
||||
|
||||
"@forte/backend": ["@forte/backend@workspace:packages/backend"],
|
||||
|
||||
"@forte/shared": ["@forte/shared@workspace:packages/shared"],
|
||||
|
||||
"@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||
@@ -273,10 +272,18 @@
|
||||
|
||||
"@lukeed/ms": ["@lukeed/ms@2.0.2", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="],
|
||||
|
||||
"@lunarfront/admin": ["@lunarfront/admin@workspace:packages/admin"],
|
||||
|
||||
"@lunarfront/backend": ["@lunarfront/backend@workspace:packages/backend"],
|
||||
|
||||
"@lunarfront/shared": ["@lunarfront/shared@workspace:packages/shared"],
|
||||
|
||||
"@petamoriken/float16": ["@petamoriken/float16@3.9.3", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="],
|
||||
|
||||
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
|
||||
|
||||
"@popperjs/core": ["@popperjs/core@2.11.8", "", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="],
|
||||
|
||||
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
|
||||
|
||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
||||
@@ -397,57 +404,59 @@
|
||||
|
||||
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
|
||||
|
||||
"@restart/hooks": ["@restart/hooks@0.4.16", "", { "dependencies": { "dequal": "^2.0.3" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.0", "", { "os": "android", "cpu": "arm" }, "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A=="],
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.0", "", { "os": "android", "cpu": "arm64" }, "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw=="],
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.1", "", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA=="],
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw=="],
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw=="],
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA=="],
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.0", "", { "os": "linux", "cpu": "arm" }, "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g=="],
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ=="],
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A=="],
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ=="],
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw=="],
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog=="],
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ=="],
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg=="],
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA=="],
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ=="],
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ=="],
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.0", "", { "os": "linux", "cpu": "x64" }, "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg=="],
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.0", "", { "os": "linux", "cpu": "x64" }, "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw=="],
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw=="],
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.0", "", { "os": "none", "cpu": "arm64" }, "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA=="],
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ=="],
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w=="],
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.0", "", { "os": "win32", "cpu": "x64" }, "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA=="],
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.0", "", { "os": "win32", "cpu": "x64" }, "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w=="],
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="],
|
||||
|
||||
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
||||
|
||||
@@ -487,15 +496,15 @@
|
||||
|
||||
"@tanstack/react-query": ["@tanstack/react-query@5.95.2", "", { "dependencies": { "@tanstack/query-core": "5.95.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA=="],
|
||||
|
||||
"@tanstack/react-router": ["@tanstack/react-router@1.168.7", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.168.6", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-fW/HvQja4PQeu9lsoyh+pXpZ0UXezbpQkkJvCuH6tHAaW3jvPkjh14lfadrNNiY+pXT7WiMTB3afGhTCC78PDQ=="],
|
||||
"@tanstack/react-router": ["@tanstack/react-router@1.168.8", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.168.7", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-t0S0QueXubBKmI9eLPcN/A1sLQgTu8/yHerjrvvsGeD12zMdw0uJPKwEKpStQF2OThQtw64cs34uUSYXBUTSNw=="],
|
||||
|
||||
"@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="],
|
||||
|
||||
"@tanstack/router-core": ["@tanstack/router-core@1.168.6", "", { "dependencies": { "@tanstack/history": "1.161.6", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2" }, "bin": { "intent": "bin/intent.js" } }, "sha512-okCno3pImpLFQMJ/4zqEIGjIV5yhxLGj0JByrzQDQehORN1y1q6lJUezT0KPK5qCQiKUApeWaboLPjgBVx1kaQ=="],
|
||||
"@tanstack/router-core": ["@tanstack/router-core@1.168.7", "", { "dependencies": { "@tanstack/history": "1.161.6", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2" }, "bin": { "intent": "bin/intent.js" } }, "sha512-z4UEdlzMrFaKBsG4OIxlZEm+wsYBtEp//fnX6kW18jhQpETNcM6u2SXNdX+bcIYp6AaR7ERS3SBENzjC/xxwQQ=="],
|
||||
|
||||
"@tanstack/router-generator": ["@tanstack/router-generator@1.166.21", "", { "dependencies": { "@tanstack/router-core": "1.168.6", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.7", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-pJWsP6HaGrkIkfkcg6vzKyCBMbf1vV1BrQH+bFAVzXj3T/afmix3IPV2hiAj4zzjMxuddJD1on0Hn5+WDYA7zQ=="],
|
||||
"@tanstack/router-generator": ["@tanstack/router-generator@1.166.22", "", { "dependencies": { "@tanstack/router-core": "1.168.7", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.7", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-wQ7H8/Q2rmSPuaxWnurJ3DATNnqWV2tajxri9TSiW4QHsG7cWPD34+goeIinKG+GajJyEdfVpz6w/gRJXfbAPw=="],
|
||||
|
||||
"@tanstack/router-plugin": ["@tanstack/router-plugin@1.167.8", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.168.6", "@tanstack/router-generator": "1.166.21", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.7", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.168.7", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"], "bin": { "intent": "bin/intent.js" } }, "sha512-/X4ACYsSX4bRmomj5X2TBU75cHuIVI99Fsax6DWnP6hPb4PaSjPUHVBfHhk2NemJzEOZu1L31UQ9QDlbHU4ZTQ=="],
|
||||
"@tanstack/router-plugin": ["@tanstack/router-plugin@1.167.9", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.168.7", "@tanstack/router-generator": "1.166.22", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.7", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.168.8", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"], "bin": { "intent": "bin/intent.js" } }, "sha512-h/VV05FEHd4PVyc5Zy8B3trWLcdLt/Pmp+mfifmBKGRw+MUtvdQKbBHhmy4ouOf67s5zDJMc+n8R3xgU7bDwFA=="],
|
||||
|
||||
"@tanstack/router-utils": ["@tanstack/router-utils@1.161.6", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "ansis": "^4.1.0", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-nRcYw+w2OEgK6VfjirYvGyPLOK+tZQz1jkYcmH5AjMamQ9PycnlxZF2aEZtPpNoUsaceX2bHptn6Ub5hGXqNvw=="],
|
||||
|
||||
@@ -503,17 +512,17 @@
|
||||
|
||||
"@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.7", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ=="],
|
||||
|
||||
"@turbo/darwin-64": ["@turbo/darwin-64@2.8.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-FQ9EX1xMU5nbwjxXxM3yU88AQQ6Sqc6S44exPRroMcx9XZHqqppl5ymJF0Ig/z3nvQNwDmz1Gsnvxubo+nXWjQ=="],
|
||||
"@turbo/darwin-64": ["@turbo/darwin-64@2.8.21", "", { "os": "darwin", "cpu": "x64" }, "sha512-kfGoM0Iw8ZNZpbds+4IzOe0hjvHldqJwUPRAjXJi3KBxg/QOZL95N893SRoMtf2aJ+jJ3dk32yPkp8rvcIjP9g=="],
|
||||
|
||||
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.8.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gpyh9ATFGThD6/s9L95YWY54cizg/VRWl2B67h0yofG8BpHf67DFAh9nuJVKG7bY0+SBJDAo5cMur+wOl9YOYw=="],
|
||||
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.8.21", "", { "os": "darwin", "cpu": "arm64" }, "sha512-o9HEflxUEyr987x0cTUzZBhDOyL6u95JmdmlkH2VyxAw7zq2sdtM5e72y9ufv2N5SIoOBw1fVn9UES5VY5H6vQ=="],
|
||||
|
||||
"@turbo/linux-64": ["@turbo/linux-64@2.8.20", "", { "os": "linux", "cpu": "x64" }, "sha512-p2QxWUYyYUgUFG0b0kR+pPi8t7c9uaVlRtjTTI1AbCvVqkpjUfCcReBn6DgG/Hu8xrWdKLuyQFaLYFzQskZbcA=="],
|
||||
"@turbo/linux-64": ["@turbo/linux-64@2.8.21", "", { "os": "linux", "cpu": "x64" }, "sha512-uTxlCcXWy5h1fSSymP8XSJ+AudzEHMDV3IDfKX7+DGB8kgJ+SLoTUAH7z4OFA7I/l2sznz0upPdbNNZs91YMag=="],
|
||||
|
||||
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.8.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-Gn5yjlZGLRZWarLWqdQzv0wMqyBNIdq1QLi48F1oY5Lo9kiohuf7BPQWtWxeNVS2NgJ1+nb/DzK1JduYC4AWOA=="],
|
||||
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.8.21", "", { "os": "linux", "cpu": "arm64" }, "sha512-cdHIcxNcihHHkCHp0Y4Zb60K4Qz+CK4xw1gb6s/t/9o4SMeMj+hTBCtoW6QpPnl9xPYmxuTou8Zw6+cylTnREg=="],
|
||||
|
||||
"@turbo/windows-64": ["@turbo/windows-64@2.8.20", "", { "os": "win32", "cpu": "x64" }, "sha512-vyaDpYk/8T6Qz5V/X+ihKvKFEZFUoC0oxYpC1sZanK6gaESJlmV3cMRT3Qhcg4D2VxvtC2Jjs9IRkrZGL+exLw=="],
|
||||
"@turbo/windows-64": ["@turbo/windows-64@2.8.21", "", { "os": "win32", "cpu": "x64" }, "sha512-/iBj4OzbqEY8CX+eaeKbBTMZv2CLXNrt0692F7HnK7LcyYwyDecaAiSET6ZzL4opT7sbwkKvzAC/fhqT3Quu1A=="],
|
||||
|
||||
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.8.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-voicVULvUV5yaGXo0Iue13BcHGYW3u0VgqSbfQwBaHbpj1zLjYV4KIe+7fYIo6DO8FVUJzxFps3ODCQG/Wy2Qw=="],
|
||||
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.8.21", "", { "os": "win32", "cpu": "arm64" }, "sha512-95tMA/ZbIidJFUUtkmqioQ1gf3n3I1YbRP3ZgVdWTVn2qVbkodcIdGXBKRHHrIbRsLRl99SiHi/L7IxhpZDagQ=="],
|
||||
|
||||
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||
|
||||
@@ -523,7 +532,9 @@
|
||||
|
||||
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||
|
||||
"@types/bcrypt": ["@types/bcrypt@5.0.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ=="],
|
||||
"@types/bcryptjs": ["@types/bcryptjs@3.0.0", "", { "dependencies": { "bcryptjs": "*" } }, "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg=="],
|
||||
|
||||
"@types/date-arithmetic": ["@types/date-arithmetic@4.1.4", "", {}, "sha512-p9eZ2X9B80iKiTW4ukVj8B4K6q9/+xFtQ5MGYA5HWToY9nL4EkhV9+6ftT2VHpVMEZb5Tv00Iel516bVdO+yRw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
@@ -533,14 +544,20 @@
|
||||
|
||||
"@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="],
|
||||
|
||||
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
|
||||
|
||||
"@types/raf": ["@types/raf@3.4.3", "", {}, "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||
|
||||
"@types/react-big-calendar": ["@types/react-big-calendar@1.16.3", "", { "dependencies": { "@types/date-arithmetic": "*", "@types/prop-types": "*", "@types/react": "*" } }, "sha512-CR+5BKMhlr/wPgsp+sXOeNKNkoU1h/+6H1XoWuL7xnurvzGRQv/EnM8jPS9yxxBvXI8pjQBaJcI7RTSGiewG/Q=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||
|
||||
"@types/warning": ["@types/warning@3.0.4", "", {}, "sha512-CqN8MnISMwQbLJXO3doBAV4Yw9hx9/Pyr2rZ78+NfaCnhyRA/nKrpyk6E7mKw17ZOaQdLpK9GiUjrqLzBlN3sg=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.57.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/type-utils": "8.57.2", "@typescript-eslint/utils": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.57.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w=="],
|
||||
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.57.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", "@typescript-eslint/typescript-estree": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA=="],
|
||||
@@ -597,9 +614,9 @@
|
||||
|
||||
"base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg=="],
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.12", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ=="],
|
||||
|
||||
"bcrypt": ["bcrypt@6.0.0", "", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" } }, "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg=="],
|
||||
"bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="],
|
||||
|
||||
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||
|
||||
@@ -615,7 +632,7 @@
|
||||
|
||||
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001781", "", {}, "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw=="],
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001782", "", {}, "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw=="],
|
||||
|
||||
"canvg": ["canvg@3.0.11", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@types/raf": "^3.4.0", "core-js": "^3.8.3", "raf": "^3.4.1", "regenerator-runtime": "^0.13.7", "rgbcolor": "^1.0.1", "stackblur-canvas": "^2.0.0", "svg-pathdata": "^6.0.3" } }, "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA=="],
|
||||
|
||||
@@ -651,8 +668,14 @@
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"date-arithmetic": ["date-arithmetic@4.1.0", "", {}, "sha512-QWxYLR5P/6GStZcdem+V1xoto6DMadYWpMXU82ES3/RfR3Wdwr3D0+be7mgOJ+Ov0G9D5Dmb9T17sNLQYj9XOg=="],
|
||||
|
||||
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
|
||||
|
||||
"dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="],
|
||||
|
||||
"dayjs": ["dayjs@1.11.20", "", {}, "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||
@@ -667,6 +690,8 @@
|
||||
|
||||
"diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
|
||||
|
||||
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
|
||||
|
||||
"dompurify": ["dompurify@3.3.3", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA=="],
|
||||
|
||||
"drizzle-kit": ["drizzle-kit@0.30.6", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", "esbuild-register": "^3.5.0", "gel": "^2.0.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g=="],
|
||||
@@ -771,6 +796,8 @@
|
||||
|
||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
|
||||
"globalize": ["globalize@0.1.1", "", {}, "sha512-5e01v8eLGfuQSOvx2MsDMOWS0GFtCx1wPzQSmcHw4hkxFzrQDBO3Xwg/m8Hr/7qXMrHeOIE29qWVzyv06u1TZA=="],
|
||||
|
||||
"globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
@@ -781,6 +808,8 @@
|
||||
|
||||
"html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="],
|
||||
|
||||
"html2pdf.js": ["html2pdf.js@0.14.0", "", { "dependencies": { "dompurify": "^3.3.1", "html2canvas": "^1.0.0", "jspdf": "^4.0.0" } }, "sha512-yvNJgE/8yru2UeGflkPdjW8YEY+nDH5X7/2WG4uiuSCwYiCp8PZ8EKNiTAa6HxJ1NjC51fZSIEq6xld5CADKBQ=="],
|
||||
|
||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||
@@ -789,6 +818,8 @@
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="],
|
||||
|
||||
"iobuffer": ["iobuffer@5.4.0", "", {}, "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA=="],
|
||||
|
||||
"ioredis": ["ioredis@5.10.1", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA=="],
|
||||
@@ -815,6 +846,8 @@
|
||||
|
||||
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"jsbarcode": ["jsbarcode@3.12.3", "", {}, "sha512-CuHU9hC6dPsHF5oVFMo8NW76uQVjH4L22CsP4hW+dNnGywJHC/B0ThA1CTDVLnxKLrrpYdicBLnd2xsgTfRnvg=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||
@@ -861,18 +894,28 @@
|
||||
|
||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
|
||||
"lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
|
||||
|
||||
"lodash-es": ["lodash-es@4.17.23", "", {}, "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg=="],
|
||||
|
||||
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
|
||||
|
||||
"lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="],
|
||||
|
||||
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
||||
|
||||
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"lucide-react": ["lucide-react@1.7.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg=="],
|
||||
|
||||
"luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"memoize-one": ["memoize-one@6.0.0", "", {}, "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="],
|
||||
|
||||
"minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="],
|
||||
|
||||
"minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||
@@ -881,20 +924,22 @@
|
||||
|
||||
"mnemonist": ["mnemonist@0.40.0", "", { "dependencies": { "obliterator": "^2.0.4" } }, "sha512-kdd8AFNig2AD5Rkih7EPCXhu/iMvwevQFX/uEiGhZyPZi7fHqOoF4V4kHLpCfysxXMgQ4B52kdPMCwARshKvEg=="],
|
||||
|
||||
"moment": ["moment@2.30.1", "", {}, "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how=="],
|
||||
|
||||
"moment-timezone": ["moment-timezone@0.5.48", "", { "dependencies": { "moment": "^2.29.4" } }, "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||
|
||||
"node-addon-api": ["node-addon-api@8.7.0", "", {}, "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA=="],
|
||||
|
||||
"node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
|
||||
|
||||
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"obliterator": ["obliterator@2.0.5", "", {}, "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw=="],
|
||||
|
||||
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
|
||||
@@ -941,6 +986,8 @@
|
||||
|
||||
"process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
|
||||
|
||||
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
||||
|
||||
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
@@ -953,10 +1000,18 @@
|
||||
|
||||
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
||||
|
||||
"react-big-calendar": ["react-big-calendar@1.19.4", "", { "dependencies": { "@babel/runtime": "^7.20.7", "clsx": "^1.2.1", "date-arithmetic": "^4.1.0", "dayjs": "^1.11.7", "dom-helpers": "^5.2.1", "globalize": "^0.1.1", "invariant": "^2.2.4", "lodash": "^4.17.21", "lodash-es": "^4.17.21", "luxon": "^3.2.1", "memoize-one": "^6.0.0", "moment": "^2.29.4", "moment-timezone": "^0.5.40", "prop-types": "^15.8.1", "react-overlays": "^5.2.1", "uncontrollable": "^7.2.1" }, "peerDependencies": { "react": "^16.14.0 || ^17 || ^18 || ^19", "react-dom": "^16.14.0 || ^17 || ^18 || ^19" } }, "sha512-FrvbDx2LF6JAWFD96LU1jjloppC5OgIvMYUYIPzAw5Aq+ArYFPxAjLqXc4DyxfsQDN0TJTMuS/BIbcSB7Pg0YA=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
|
||||
|
||||
"react-hook-form": ["react-hook-form@7.72.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-V4v6jubaf6JAurEaVnT9aUPKFbNtDgohj5CIgVGyPHvT9wRx5OZHVjz31GsxnPNI278XMu+ruFz+wGOscHaLKw=="],
|
||||
|
||||
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
|
||||
"react-lifecycles-compat": ["react-lifecycles-compat@3.0.4", "", {}, "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="],
|
||||
|
||||
"react-overlays": ["react-overlays@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.13.8", "@popperjs/core": "^2.11.6", "@restart/hooks": "^0.4.7", "@types/warning": "^3.0.0", "dom-helpers": "^5.2.0", "prop-types": "^15.7.2", "uncontrollable": "^7.2.1", "warning": "^4.0.3" }, "peerDependencies": { "react": ">=16.3.0", "react-dom": ">=16.3.0" } }, "sha512-GLLSOLWr21CqtJn8geSwQfoJufdt3mfdsnIiQswouuQ2MMPns+ihZklxvsTDKD3cR2tF8ELbi5xUsvqVhR6WvA=="],
|
||||
|
||||
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||
|
||||
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
|
||||
@@ -991,7 +1046,7 @@
|
||||
|
||||
"rgbcolor": ["rgbcolor@1.0.1", "", {}, "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw=="],
|
||||
|
||||
"rollup": ["rollup@4.60.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.0", "@rollup/rollup-android-arm64": "4.60.0", "@rollup/rollup-darwin-arm64": "4.60.0", "@rollup/rollup-darwin-x64": "4.60.0", "@rollup/rollup-freebsd-arm64": "4.60.0", "@rollup/rollup-freebsd-x64": "4.60.0", "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", "@rollup/rollup-linux-arm-musleabihf": "4.60.0", "@rollup/rollup-linux-arm64-gnu": "4.60.0", "@rollup/rollup-linux-arm64-musl": "4.60.0", "@rollup/rollup-linux-loong64-gnu": "4.60.0", "@rollup/rollup-linux-loong64-musl": "4.60.0", "@rollup/rollup-linux-ppc64-gnu": "4.60.0", "@rollup/rollup-linux-ppc64-musl": "4.60.0", "@rollup/rollup-linux-riscv64-gnu": "4.60.0", "@rollup/rollup-linux-riscv64-musl": "4.60.0", "@rollup/rollup-linux-s390x-gnu": "4.60.0", "@rollup/rollup-linux-x64-gnu": "4.60.0", "@rollup/rollup-linux-x64-musl": "4.60.0", "@rollup/rollup-openbsd-x64": "4.60.0", "@rollup/rollup-openharmony-arm64": "4.60.0", "@rollup/rollup-win32-arm64-msvc": "4.60.0", "@rollup/rollup-win32-ia32-msvc": "4.60.0", "@rollup/rollup-win32-x64-gnu": "4.60.0", "@rollup/rollup-win32-x64-msvc": "4.60.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ=="],
|
||||
"rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
@@ -1067,7 +1122,7 @@
|
||||
|
||||
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
|
||||
|
||||
"turbo": ["turbo@2.8.20", "", { "optionalDependencies": { "@turbo/darwin-64": "2.8.20", "@turbo/darwin-arm64": "2.8.20", "@turbo/linux-64": "2.8.20", "@turbo/linux-arm64": "2.8.20", "@turbo/windows-64": "2.8.20", "@turbo/windows-arm64": "2.8.20" }, "bin": { "turbo": "bin/turbo" } }, "sha512-Rb4qk5YT8RUwwdXtkLpkVhNEe/lor6+WV7S5tTlLpxSz6MjV5Qi8jGNn4gS6NAvrYGA/rNrE6YUQM85sCZUDbQ=="],
|
||||
"turbo": ["turbo@2.8.21", "", { "optionalDependencies": { "@turbo/darwin-64": "2.8.21", "@turbo/darwin-arm64": "2.8.21", "@turbo/linux-64": "2.8.21", "@turbo/linux-arm64": "2.8.21", "@turbo/windows-64": "2.8.21", "@turbo/windows-arm64": "2.8.21" }, "bin": { "turbo": "bin/turbo" } }, "sha512-FlJ8OD5Qcp0jTAM7E4a/RhUzRNds2GzKlyxHKA6N247VLy628rrxAGlMpIXSz6VB430+TiQDJ/SMl6PL1lu6wQ=="],
|
||||
|
||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
|
||||
@@ -1075,6 +1130,8 @@
|
||||
|
||||
"typescript-eslint": ["typescript-eslint@8.57.2", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.57.2", "@typescript-eslint/parser": "8.57.2", "@typescript-eslint/typescript-estree": "8.57.2", "@typescript-eslint/utils": "8.57.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A=="],
|
||||
|
||||
"uncontrollable": ["uncontrollable@7.2.1", "", { "dependencies": { "@babel/runtime": "^7.6.3", "@types/react": ">=16.9.11", "invariant": "^2.2.4", "react-lifecycles-compat": "^3.0.4" }, "peerDependencies": { "react": ">=15.0.0" } }, "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
|
||||
@@ -1093,6 +1150,8 @@
|
||||
|
||||
"vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
|
||||
|
||||
"warning": ["warning@4.0.3", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w=="],
|
||||
|
||||
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
@@ -1153,7 +1212,7 @@
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
@@ -1189,6 +1248,8 @@
|
||||
|
||||
"radix-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"react-big-calendar/clsx": ["clsx@1.2.1", "", {}, "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="],
|
||||
|
||||
"readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
|
||||
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
6
chart/Chart.yaml
Normal file
6
chart/Chart.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
apiVersion: v2
|
||||
name: lunarfront
|
||||
description: LunarFront small business management platform
|
||||
type: application
|
||||
version: 0.1.1
|
||||
appVersion: "0.1.1"
|
||||
8
chart/templates/_helpers.tpl
Normal file
8
chart/templates/_helpers.tpl
Normal file
@@ -0,0 +1,8 @@
|
||||
{{- define "lunarfront.name" -}}
|
||||
{{- .Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "lunarfront.labels" -}}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end }}
|
||||
129
chart/templates/backend-deployment.yaml
Normal file
129
chart/templates/backend-deployment.yaml
Normal file
@@ -0,0 +1,129 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-backend
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "lunarfront.labels" . | nindent 4 }}
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: {{ .Release.Name }}-backend
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: {{ .Release.Name }}-backend
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
{{- toYaml .Values.imagePullSecrets | nindent 8 }}
|
||||
containers:
|
||||
- name: backend
|
||||
image: "{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag }}"
|
||||
imagePullPolicy: {{ .Values.backend.image.pullPolicy }}
|
||||
ports:
|
||||
- containerPort: {{ .Values.backend.port }}
|
||||
env:
|
||||
- name: PORT
|
||||
value: {{ .Values.backend.port | quote }}
|
||||
- name: NODE_ENV
|
||||
value: production
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: lunarfront-secrets
|
||||
key: database-url
|
||||
- name: REDIS_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: lunarfront-secrets
|
||||
key: redis-url
|
||||
- name: REDIS_KEY_PREFIX
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: lunarfront-secrets
|
||||
key: redis-key-prefix
|
||||
- name: SPACES_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: lunarfront-secrets
|
||||
key: spaces-key
|
||||
- name: SPACES_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: lunarfront-secrets
|
||||
key: spaces-secret
|
||||
- name: SPACES_BUCKET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: lunarfront-secrets
|
||||
key: spaces-bucket
|
||||
- name: SPACES_ENDPOINT
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: lunarfront-secrets
|
||||
key: spaces-endpoint
|
||||
- name: SPACES_PREFIX
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: lunarfront-secrets
|
||||
key: spaces-prefix
|
||||
- name: JWT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: lunarfront-secrets
|
||||
key: jwt-secret
|
||||
- name: ENCRYPTION_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: lunarfront-secrets
|
||||
key: encryption-key
|
||||
- name: RESEND_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: lunarfront-secrets
|
||||
key: resend-api-key
|
||||
- name: MAIL_FROM
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: lunarfront-secrets
|
||||
key: mail-from
|
||||
- name: BUSINESS_NAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: lunarfront-secrets
|
||||
key: business-name
|
||||
- name: APP_URL
|
||||
value: "https://{{ .Values.ingress.host }}"
|
||||
- name: INITIAL_USER_EMAIL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: lunarfront-secrets
|
||||
key: initial-user-email
|
||||
optional: true
|
||||
- name: INITIAL_USER_FIRST_NAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: lunarfront-secrets
|
||||
key: initial-user-first-name
|
||||
optional: true
|
||||
- name: INITIAL_USER_LAST_NAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: lunarfront-secrets
|
||||
key: initial-user-last-name
|
||||
optional: true
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /v1/health
|
||||
port: {{ .Values.backend.port }}
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /v1/health
|
||||
port: {{ .Values.backend.port }}
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
resources:
|
||||
{{- toYaml .Values.backend.resources | nindent 12 }}
|
||||
13
chart/templates/backend-service.yaml
Normal file
13
chart/templates/backend-service.yaml
Normal file
@@ -0,0 +1,13 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-backend
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "lunarfront.labels" . | nindent 4 }}
|
||||
spec:
|
||||
selector:
|
||||
app: {{ .Release.Name }}-backend
|
||||
ports:
|
||||
- port: {{ .Values.backend.port }}
|
||||
targetPort: {{ .Values.backend.port }}
|
||||
30
chart/templates/frontend-deployment.yaml
Normal file
30
chart/templates/frontend-deployment.yaml
Normal file
@@ -0,0 +1,30 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-frontend
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "lunarfront.labels" . | nindent 4 }}
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: {{ .Release.Name }}-frontend
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: {{ .Release.Name }}-frontend
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
{{- toYaml .Values.imagePullSecrets | nindent 8 }}
|
||||
containers:
|
||||
- name: frontend
|
||||
image: "{{ .Values.frontend.image.repository }}:{{ .Values.frontend.image.tag }}"
|
||||
imagePullPolicy: {{ .Values.frontend.image.pullPolicy }}
|
||||
ports:
|
||||
- containerPort: {{ .Values.frontend.port }}
|
||||
env:
|
||||
- name: BACKEND_URL
|
||||
value: "http://{{ .Release.Name }}-backend:{{ .Values.backend.port }}"
|
||||
resources:
|
||||
{{- toYaml .Values.frontend.resources | nindent 12 }}
|
||||
13
chart/templates/frontend-service.yaml
Normal file
13
chart/templates/frontend-service.yaml
Normal file
@@ -0,0 +1,13 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-frontend
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "lunarfront.labels" . | nindent 4 }}
|
||||
spec:
|
||||
selector:
|
||||
app: {{ .Release.Name }}-frontend
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: {{ .Values.frontend.port }}
|
||||
29
chart/templates/ingress.yaml
Normal file
29
chart/templates/ingress.yaml
Normal file
@@ -0,0 +1,29 @@
|
||||
{{- if .Values.ingress.host }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ .Release.Name }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "lunarfront.labels" . | nindent 4 }}
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: {{ .Values.ingress.className }}
|
||||
cert-manager.io/cluster-issuer: {{ .Values.ingress.tlsIssuer }}
|
||||
spec:
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
tls:
|
||||
- hosts:
|
||||
- {{ .Values.ingress.host }}
|
||||
secretName: {{ .Release.Name }}-tls
|
||||
rules:
|
||||
- host: {{ .Values.ingress.host }}
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: {{ .Release.Name }}-frontend
|
||||
port:
|
||||
number: 80
|
||||
{{- end }}
|
||||
39
chart/values.yaml
Normal file
39
chart/values.yaml
Normal file
@@ -0,0 +1,39 @@
|
||||
backend:
|
||||
image:
|
||||
repository: registry.digitalocean.com/lunarfront/lunarfront-app
|
||||
tag: 0.0.27
|
||||
pullPolicy: Always
|
||||
port: 8000
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: 1000m
|
||||
memory: 512Mi
|
||||
|
||||
frontend:
|
||||
image:
|
||||
repository: registry.digitalocean.com/lunarfront/lunarfront-frontend
|
||||
tag: 0.0.27
|
||||
pullPolicy: Always
|
||||
port: 80
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
cpu: 200m
|
||||
memory: 128Mi
|
||||
|
||||
ingress:
|
||||
host: ""
|
||||
className: nginx
|
||||
tlsIssuer: letsencrypt
|
||||
|
||||
imagePullSecrets:
|
||||
- name: registry-lunarfront
|
||||
|
||||
# Secrets are expected to exist in-namespace as 'lunarfront-secrets' with keys:
|
||||
# database-url, jwt-secret, redis-url
|
||||
# These are created by the manager during provisioning, not by this chart.
|
||||
27
deploy/deploy.sh
Executable file
27
deploy/deploy.sh
Executable file
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
# LunarFront — Redeploy script (run after pushing changes to main)
|
||||
# Usage: sudo bash deploy/deploy.sh
|
||||
set -euo pipefail
|
||||
|
||||
APP_DIR="/opt/lunarfront"
|
||||
APP_USER="ubuntu"
|
||||
BUN_BIN="/home/${APP_USER}/.bun/bin/bun"
|
||||
|
||||
cd "$APP_DIR"
|
||||
|
||||
echo "==> Installing dependencies..."
|
||||
sudo -u "$APP_USER" "$BUN_BIN" install --frozen-lockfile
|
||||
|
||||
echo "==> Building admin frontend..."
|
||||
sudo -u "$APP_USER" bash -c "cd ${APP_DIR}/packages/admin && ${BUN_BIN} run build"
|
||||
|
||||
echo "==> Running migrations..."
|
||||
sudo -u "$APP_USER" bash -c \
|
||||
"cd ${APP_DIR}/packages/backend && ${BUN_BIN} x drizzle-kit migrate"
|
||||
|
||||
echo "==> Restarting backend..."
|
||||
sudo systemctl restart lunarfront
|
||||
|
||||
echo "==> Done! Checking status..."
|
||||
sleep 2
|
||||
sudo systemctl status lunarfront --no-pager
|
||||
18
deploy/lunarfront.service
Normal file
18
deploy/lunarfront.service
Normal file
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=LunarFront API Server
|
||||
After=network.target postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=ubuntu
|
||||
WorkingDirectory=/opt/lunarfront/packages/backend
|
||||
EnvironmentFile=/opt/lunarfront/.env
|
||||
ExecStart=/home/ubuntu/.bun/bin/bun run src/main.ts
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=lunarfront
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
47
deploy/nginx.conf
Normal file
47
deploy/nginx.conf
Normal file
@@ -0,0 +1,47 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name YOUR_DOMAIN www.YOUR_DOMAIN;
|
||||
# Certbot will automatically add HTTPS redirect and SSL config below this line
|
||||
|
||||
root /opt/lunarfront/packages/admin/dist;
|
||||
index index.html;
|
||||
|
||||
# Proxy API requests to Bun backend
|
||||
location /v1/ {
|
||||
proxy_pass http://localhost:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
client_max_body_size 20M;
|
||||
}
|
||||
|
||||
# WebDAV passthrough (all HTTP methods)
|
||||
location /webdav/ {
|
||||
proxy_pass http://localhost:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
client_max_body_size 100M;
|
||||
}
|
||||
|
||||
# SPA fallback — serve index.html for all unmatched paths
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache hashed static assets aggressively
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;
|
||||
}
|
||||
128
deploy/setup.sh
Executable file
128
deploy/setup.sh
Executable file
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env bash
|
||||
# LunarFront — One-time EC2 provisioning script
|
||||
# Run as root (or with sudo) on a fresh Ubuntu 24.04 instance.
|
||||
# Usage: sudo bash deploy/setup.sh
|
||||
set -euo pipefail
|
||||
|
||||
REPO_URL="git@github.com:YOUR_ORG/YOUR_REPO.git"
|
||||
APP_DIR="/opt/lunarfront"
|
||||
APP_USER="ubuntu"
|
||||
DB_USER="lunarfront"
|
||||
DB_NAME="lunarfront"
|
||||
DB_PASS="$(openssl rand -hex 16)" # auto-generated; written to .env
|
||||
|
||||
# ── 1. System packages ────────────────────────────────────────────────────────
|
||||
echo "==> Updating system packages..."
|
||||
apt-get update -y && apt-get upgrade -y
|
||||
apt-get install -y curl git build-essential nginx certbot python3-certbot-nginx unzip
|
||||
|
||||
# ── 2. Bun runtime ────────────────────────────────────────────────────────────
|
||||
echo "==> Installing Bun..."
|
||||
sudo -u "$APP_USER" bash -c 'curl -fsSL https://bun.sh/install | bash'
|
||||
BUN_BIN="/home/${APP_USER}/.bun/bin/bun"
|
||||
|
||||
# ── 3. PostgreSQL 16 ──────────────────────────────────────────────────────────
|
||||
echo "==> Installing PostgreSQL 16..."
|
||||
apt-get install -y postgresql-16 postgresql-contrib-16
|
||||
systemctl enable --now postgresql
|
||||
|
||||
echo "==> Creating database user and database..."
|
||||
sudo -u postgres psql -tc "SELECT 1 FROM pg_roles WHERE rolname='${DB_USER}'" | grep -q 1 || \
|
||||
sudo -u postgres psql -c "CREATE USER ${DB_USER} WITH PASSWORD '${DB_PASS}';"
|
||||
sudo -u postgres psql -tc "SELECT 1 FROM pg_database WHERE datname='${DB_NAME}'" | grep -q 1 || \
|
||||
sudo -u postgres psql -c "CREATE DATABASE ${DB_NAME} OWNER ${DB_USER};"
|
||||
|
||||
# ── 4. Valkey 8 ───────────────────────────────────────────────────────────────
|
||||
echo "==> Installing Valkey..."
|
||||
# Try official Valkey apt repo first; fall back to Redis 7 if unavailable
|
||||
if curl -fsSL https://packages.valkey.io/ubuntu/gpg.asc 2>/dev/null | \
|
||||
gpg --dearmor -o /usr/share/keyrings/valkey.gpg; then
|
||||
echo "deb [signed-by=/usr/share/keyrings/valkey.gpg] https://packages.valkey.io/ubuntu noble main" \
|
||||
> /etc/apt/sources.list.d/valkey.list
|
||||
apt-get update -y && apt-get install -y valkey
|
||||
REDIS_SERVICE="valkey"
|
||||
else
|
||||
echo "Valkey repo unavailable, falling back to Redis 7..."
|
||||
apt-get install -y redis-server
|
||||
REDIS_SERVICE="redis-server"
|
||||
fi
|
||||
systemctl enable --now "$REDIS_SERVICE"
|
||||
|
||||
# ── 5. Clone repository ───────────────────────────────────────────────────────
|
||||
echo "==> Cloning repository to ${APP_DIR}..."
|
||||
if [ -d "$APP_DIR" ]; then
|
||||
echo " ${APP_DIR} already exists, skipping clone."
|
||||
else
|
||||
git clone "$REPO_URL" "$APP_DIR"
|
||||
chown -R "$APP_USER:$APP_USER" "$APP_DIR"
|
||||
fi
|
||||
cd "$APP_DIR"
|
||||
|
||||
# ── 6. Environment file ───────────────────────────────────────────────────────
|
||||
if [ ! -f "${APP_DIR}/.env" ]; then
|
||||
echo "==> Generating .env..."
|
||||
JWT_SECRET=$(openssl rand -hex 32)
|
||||
cat > "${APP_DIR}/.env" <<EOF
|
||||
DATABASE_URL=postgresql://${DB_USER}:${DB_PASS}@localhost:5432/${DB_NAME}
|
||||
REDIS_URL=redis://localhost:6379
|
||||
JWT_SECRET=${JWT_SECRET}
|
||||
PORT=8000
|
||||
HOST=0.0.0.0
|
||||
NODE_ENV=production
|
||||
CORS_ORIGINS=http://$(curl -s http://169.254.169.254/latest/meta-data/public-ipv4)
|
||||
STORAGE_LOCAL_PATH=${APP_DIR}/data/files
|
||||
EOF
|
||||
chown "$APP_USER:$APP_USER" "${APP_DIR}/.env"
|
||||
chmod 600 "${APP_DIR}/.env"
|
||||
echo " Generated JWT secret and wrote .env"
|
||||
echo " NOTE: Update CORS_ORIGINS once you have a domain."
|
||||
else
|
||||
echo " .env already exists, skipping generation."
|
||||
fi
|
||||
|
||||
# ── 7. Install dependencies + build frontend ──────────────────────────────────
|
||||
echo "==> Installing dependencies..."
|
||||
sudo -u "$APP_USER" "$BUN_BIN" install --frozen-lockfile
|
||||
|
||||
echo "==> Building admin frontend..."
|
||||
sudo -u "$APP_USER" bash -c "cd ${APP_DIR}/packages/admin && ${BUN_BIN} run build"
|
||||
|
||||
# ── 8. Run database migrations ────────────────────────────────────────────────
|
||||
echo "==> Running database migrations..."
|
||||
sudo -u "$APP_USER" bash -c \
|
||||
"cd ${APP_DIR}/packages/backend && ${BUN_BIN} x drizzle-kit migrate"
|
||||
|
||||
# ── 9. Create file storage directory ─────────────────────────────────────────
|
||||
mkdir -p "${APP_DIR}/data/files"
|
||||
chown -R "$APP_USER:$APP_USER" "${APP_DIR}/data"
|
||||
|
||||
# ── 10. Systemd service ───────────────────────────────────────────────────────
|
||||
echo "==> Installing systemd service..."
|
||||
# Substitute real Bun path into service file
|
||||
sed "s|/home/ubuntu/.bun/bin/bun|${BUN_BIN}|g" \
|
||||
"${APP_DIR}/deploy/lunarfront.service" > /etc/systemd/system/lunarfront.service
|
||||
systemctl daemon-reload
|
||||
systemctl enable lunarfront
|
||||
systemctl restart lunarfront
|
||||
|
||||
# ── 11. Nginx ─────────────────────────────────────────────────────────────────
|
||||
echo "==> Configuring Nginx..."
|
||||
cp "${APP_DIR}/deploy/nginx.conf" /etc/nginx/sites-available/lunarfront
|
||||
ln -sf /etc/nginx/sites-available/lunarfront /etc/nginx/sites-enabled/lunarfront
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
nginx -t && systemctl reload nginx
|
||||
|
||||
echo ""
|
||||
echo "========================================================"
|
||||
echo " Setup complete!"
|
||||
echo ""
|
||||
echo " Next steps:"
|
||||
echo " 1. Verify .env has correct values:"
|
||||
echo " nano ${APP_DIR}/.env"
|
||||
echo " 2. Restart backend after editing .env:"
|
||||
echo " sudo systemctl restart lunarfront"
|
||||
echo " 3. Set up HTTPS (after pointing DNS to this IP):"
|
||||
echo " sudo certbot --nginx -d YOUR_DOMAIN -d www.YOUR_DOMAIN"
|
||||
echo " 4. Check logs:"
|
||||
echo " journalctl -u lunarfront -f"
|
||||
echo "========================================================"
|
||||
25
deploy/sync-and-deploy.sh
Executable file
25
deploy/sync-and-deploy.sh
Executable file
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
# LunarFront — Sync local source to EC2 and redeploy
|
||||
# Usage: bash deploy/sync-and-deploy.sh
|
||||
set -euo pipefail
|
||||
|
||||
EC2_HOST="18.217.233.214"
|
||||
EC2_USER="ubuntu"
|
||||
SSH_KEY="$HOME/.ssh/lunarfront-dev.pem"
|
||||
APP_DIR="/opt/lunarfront"
|
||||
|
||||
echo "==> Syncing source to ${EC2_USER}@${EC2_HOST}:${APP_DIR} ..."
|
||||
rsync -az --delete \
|
||||
--exclude='.git' \
|
||||
--exclude='node_modules' \
|
||||
--exclude='packages/*/node_modules' \
|
||||
--exclude='packages/admin/dist' \
|
||||
--exclude='packages/backend/dist' \
|
||||
--exclude='*.env' \
|
||||
-e "ssh -i ${SSH_KEY} -o StrictHostKeyChecking=no" \
|
||||
/home/ryan/pos/ \
|
||||
"${EC2_USER}@${EC2_HOST}:${APP_DIR}/"
|
||||
|
||||
echo "==> Running deploy script on server..."
|
||||
ssh -i "${SSH_KEY}" -o StrictHostKeyChecking=no "${EC2_USER}@${EC2_HOST}" \
|
||||
"sudo bash ${APP_DIR}/deploy/deploy.sh"
|
||||
@@ -3,7 +3,7 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.dev
|
||||
container_name: forte-api
|
||||
container_name: lunarfront-api
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
@@ -14,7 +14,7 @@ services:
|
||||
- /app/packages/backend/node_modules
|
||||
- /app/packages/shared/node_modules
|
||||
environment:
|
||||
DATABASE_URL: postgresql://forte:forte@postgres:5432/forte
|
||||
DATABASE_URL: postgresql://lunarfront:lunarfront@postgres:5432/lunarfront
|
||||
REDIS_URL: redis://valkey:6379
|
||||
JWT_SECRET: dev-secret-do-not-use-in-production
|
||||
NODE_ENV: development
|
||||
@@ -28,30 +28,30 @@ services:
|
||||
|
||||
postgres:
|
||||
image: postgres:16
|
||||
container_name: forte-postgres
|
||||
container_name: lunarfront-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: forte
|
||||
POSTGRES_PASSWORD: forte
|
||||
POSTGRES_DB: forte
|
||||
POSTGRES_USER: lunarfront
|
||||
POSTGRES_PASSWORD: lunarfront
|
||||
POSTGRES_DB: lunarfront
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- forte-pgdata:/var/lib/postgresql/data
|
||||
- lunarfront-pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U forte"]
|
||||
test: ["CMD-SHELL", "pg_isready -U lunarfront"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
valkey:
|
||||
image: valkey/valkey:8
|
||||
container_name: forte-valkey
|
||||
container_name: lunarfront-valkey
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- forte-valkey:/data
|
||||
- lunarfront-valkey:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "valkey-cli", "ping"]
|
||||
interval: 5s
|
||||
@@ -59,5 +59,5 @@ services:
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
forte-pgdata:
|
||||
forte-valkey:
|
||||
lunarfront-pgdata:
|
||||
lunarfront-valkey:
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
## Monorepo Structure
|
||||
|
||||
```
|
||||
forte/
|
||||
lunarfront/
|
||||
packages/
|
||||
shared/ @forte/shared — Zod schemas, types, business logic, utils
|
||||
backend/ @forte/backend — Fastify API server
|
||||
admin/ @forte/admin — Admin UI (React + Vite)
|
||||
shared/ @lunarfront/shared — Zod schemas, types, business logic, utils
|
||||
backend/ @lunarfront/backend — Fastify API server
|
||||
admin/ @lunarfront/admin — Admin UI (React + Vite)
|
||||
planning/ Domain planning docs (01-26)
|
||||
docs/ Technical documentation
|
||||
```
|
||||
|
||||
Managed with Turborepo and Bun workspaces. `@forte/shared` is a dependency of both `backend` and `admin`.
|
||||
Managed with Turborepo and Bun workspaces. `@lunarfront/shared` is a dependency of both `backend` and `admin`.
|
||||
|
||||
## Backend
|
||||
|
||||
@@ -56,11 +56,10 @@ src/
|
||||
### Request Flow
|
||||
|
||||
1. Fastify receives request
|
||||
2. `onRequest` hook sets `companyId` from header
|
||||
3. `authenticate` preHandler verifies JWT, loads permissions, checks `is_active`
|
||||
4. `requirePermission` preHandler checks user has required permission slug
|
||||
5. Route handler validates input with Zod, calls service, returns response
|
||||
6. Error handler catches typed errors and maps to HTTP status codes
|
||||
2. `authenticate` preHandler verifies JWT, loads permissions, checks `is_active`
|
||||
3. `requirePermission` preHandler checks user has required permission slug
|
||||
4. Route handler validates input with Zod, calls service, returns response
|
||||
5. Error handler catches typed errors and maps to HTTP status codes
|
||||
|
||||
### Permission Inheritance
|
||||
|
||||
@@ -109,9 +108,9 @@ src/
|
||||
| Theme | Zustand store, persisted to localStorage |
|
||||
| Component state | React `useState` |
|
||||
|
||||
## Multi-Tenancy
|
||||
## Deployment Model
|
||||
|
||||
Every domain table has a `company_id` column. All queries filter by the authenticated user's company. Location-scoped tables (inventory, transactions) additionally filter by `location_id`.
|
||||
Each customer runs as a fully isolated deployment — their own Kubernetes namespace on DOKS, their own database on the shared managed Postgres instance. There is no multi-tenancy in the application layer. No `company_id`, no row-level isolation. One instance = one customer.
|
||||
|
||||
## Database
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ PostgreSQL 16. Two databases:
|
||||
|
||||
| Database | Port | Usage |
|
||||
|----------|------|-------|
|
||||
| `forte` | 5432 | Development |
|
||||
| `forte_api_test` | 5432 | API integration tests (auto-created by test runner) |
|
||||
| `lunarfront` | 5432 | Development |
|
||||
| `lunarfront_api_test` | 5432 | API integration tests (auto-created by test runner) |
|
||||
|
||||
## Migrations
|
||||
|
||||
@@ -35,7 +35,7 @@ All domain tables include `company_id` (uuid FK to `company`). Every query filte
|
||||
|
||||
| Table | Description |
|
||||
|-------|-------------|
|
||||
| `company` | Tenant (music store business) |
|
||||
| `company` | Tenant (tenant business) |
|
||||
| `location` | Physical store location |
|
||||
| `user` | Staff/admin user account |
|
||||
|
||||
|
||||
193
docs/deployment.md
Normal file
193
docs/deployment.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# Infrastructure & Deployment Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
LunarFront runs on DigitalOcean. Each customer is a fully isolated deployment — their own Kubernetes namespace, their own database. There is no multi-tenancy in the application layer.
|
||||
|
||||
**Stack:** DOKS (Kubernetes) · ArgoCD · Helm · Gitea · Terraform · Ansible
|
||||
|
||||
The guiding principle is simplicity — Docker Compose on Droplets for Gitea, Kubernetes for customer app instances. No surprise bills.
|
||||
|
||||
---
|
||||
|
||||
## Monthly Cost
|
||||
|
||||
| Resource | Spec | Cost |
|
||||
|---|---|---|
|
||||
| DOKS cluster | Control plane | $12/mo |
|
||||
| DOKS nodes (2x s-2vcpu-4gb) | Runs all customer apps | ~$48/mo |
|
||||
| Managed Postgres (shared) | All customer databases | $15-25/mo |
|
||||
| Managed Redis (shared) | All customer queues/cache | $15/mo |
|
||||
| Gitea Droplet | 1 vCPU, 1GB RAM | $6/mo |
|
||||
| Gitea Postgres | Dedicated managed Postgres | $15/mo |
|
||||
| Spaces | Registry, backups, files | ~$21/mo |
|
||||
| **Fixed total** | | **~$132-142/mo** |
|
||||
| **Per customer** | New database in shared Postgres | **~$0 marginal** |
|
||||
|
||||
---
|
||||
|
||||
## Environments
|
||||
|
||||
| Environment | Hostname | Purpose |
|
||||
|---|---|---|
|
||||
| Production (per customer) | `{customer}.app.lunarfront.tech` | Live customer instance on DOKS |
|
||||
| Gitea | `git.lunarfront.tech` | Source control, CI/CD, container registry |
|
||||
| Dev | Local / feature namespace on DOKS | Testing and staging |
|
||||
|
||||
---
|
||||
|
||||
## DNS
|
||||
|
||||
Managed through Cloudflare. Records defined in Terraform.
|
||||
|
||||
| Record | Type | Points To |
|
||||
|---|---|---|
|
||||
| `git.lunarfront.tech` | A | Gitea Droplet |
|
||||
| `git-ssh.lunarfront.tech` | A | Gitea Droplet (SSH port 2222) |
|
||||
| `registry.lunarfront.tech` | A | Gitea Droplet (container registry) |
|
||||
| `{customer}.app.lunarfront.tech` | CNAME | DOKS load balancer |
|
||||
|
||||
---
|
||||
|
||||
## Infrastructure (Terraform)
|
||||
|
||||
Terraform manages all DigitalOcean and Cloudflare resources. State stored in Spaces.
|
||||
|
||||
```
|
||||
/terraform
|
||||
main.tf providers, backend (Spaces state)
|
||||
variables.tf
|
||||
cluster.tf DOKS cluster + node pools
|
||||
databases.tf shared Postgres, shared Redis
|
||||
gitea.tf Gitea Droplet
|
||||
spaces.tf files, backups, tf-state buckets
|
||||
dns.tf Cloudflare DNS records
|
||||
outputs.tf cluster endpoint, DB URLs
|
||||
terraform.tfvars secrets (gitignored)
|
||||
```
|
||||
|
||||
### State Backend
|
||||
|
||||
```hcl
|
||||
terraform {
|
||||
backend "s3" {
|
||||
endpoint = "https://nyc3.digitaloceanspaces.com"
|
||||
bucket = "lunarfront-terraform-state"
|
||||
key = "terraform.tfstate"
|
||||
region = "us-east-1"
|
||||
skip_credentials_validation = true
|
||||
skip_metadata_api_check = true
|
||||
skip_region_validation = true
|
||||
force_path_style = true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Gitea (Source Control + CI/CD)
|
||||
|
||||
Gitea runs on its own Droplet with a dedicated managed Postgres. It hosts:
|
||||
- Source code repositories
|
||||
- Gitea Actions CI/CD pipelines
|
||||
- Docker container registry (`registry.lunarfront.tech`)
|
||||
|
||||
Managed by Ansible. See `lunarfront-infra/ansible/roles/gitea/`.
|
||||
|
||||
### CI Pipeline (Gitea Actions)
|
||||
|
||||
On push to `main`:
|
||||
|
||||
1. Run lint + unit tests using the shared `ci-runner` image
|
||||
2. Build Docker image, push to `registry.lunarfront.tech/ryan/lunarfront-app:{sha}`
|
||||
3. Update the Helm chart `values.yaml` with the new image tag
|
||||
4. ArgoCD detects the change and syncs all customer deployments
|
||||
|
||||
---
|
||||
|
||||
## Kubernetes (DOKS + ArgoCD)
|
||||
|
||||
All customer app instances run on a single DOKS cluster managed by ArgoCD (GitOps).
|
||||
|
||||
### Repository Structure
|
||||
|
||||
```
|
||||
/gitops
|
||||
apps/
|
||||
customer-acme/
|
||||
values.yaml
|
||||
customer-foo/
|
||||
values.yaml
|
||||
chart/
|
||||
Chart.yaml
|
||||
templates/
|
||||
deployment.yaml
|
||||
service.yaml
|
||||
ingress.yaml
|
||||
job-migrate.yaml # runs drizzle-kit migrate on deploy
|
||||
secret.yaml
|
||||
```
|
||||
|
||||
### Per-Customer values.yaml
|
||||
|
||||
```yaml
|
||||
customer: acme
|
||||
subdomain: acme
|
||||
|
||||
image:
|
||||
repository: registry.lunarfront.tech/ryan/lunarfront-app
|
||||
tag: "abc123"
|
||||
|
||||
database:
|
||||
url: "postgresql://lunarfront:pass@db-host:25060/customer_acme?sslmode=require"
|
||||
|
||||
redis:
|
||||
url: "rediss://..."
|
||||
|
||||
env:
|
||||
JWT_SECRET: "..."
|
||||
NODE_ENV: production
|
||||
```
|
||||
|
||||
### Adding a New Customer
|
||||
|
||||
1. `CREATE DATABASE customer_x;` on the shared managed Postgres
|
||||
2. Add `gitops/apps/customer-x/values.yaml`
|
||||
3. Push — ArgoCD syncs, migration Job runs, instance is live
|
||||
4. Add DNS CNAME in Terraform
|
||||
|
||||
---
|
||||
|
||||
## Database Strategy
|
||||
|
||||
One managed Postgres cluster shared across all customers. Each customer gets their own isolated database (`CREATE DATABASE customer_x`). No cross-customer queries are possible at the database level.
|
||||
|
||||
- New customer = new database, no new infrastructure cost
|
||||
- Managed DO Postgres handles backups, failover, and SSL
|
||||
- Resize the cluster as total load grows
|
||||
|
||||
---
|
||||
|
||||
## Day-to-Day Workflow
|
||||
|
||||
```bash
|
||||
# Provision infrastructure from scratch
|
||||
terraform init && terraform apply
|
||||
|
||||
# Configure Gitea server
|
||||
ansible-playbook ansible/gitea.yml -i ansible/inventory.ini
|
||||
|
||||
# Normal deploy flow — push to main
|
||||
git push origin main
|
||||
# → Gitea Actions: lint + test + build image
|
||||
# → Image pushed to registry
|
||||
# → Helm chart values updated
|
||||
# → ArgoCD syncs all customer deployments automatically
|
||||
|
||||
# Add a new customer
|
||||
# 1. Create database
|
||||
psql $DATABASE_URL -c "CREATE DATABASE customer_x;"
|
||||
# 2. Add values file, push
|
||||
git add gitops/apps/customer-x/values.yaml && git push
|
||||
# Done — ArgoCD handles the rest
|
||||
```
|
||||
@@ -9,7 +9,7 @@
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
git clone <repo-url> && cd forte
|
||||
git clone <repo-url> && cd lunarfront
|
||||
bun install
|
||||
```
|
||||
|
||||
@@ -18,7 +18,7 @@ bun install
|
||||
Create a `.env` file in the project root:
|
||||
|
||||
```env
|
||||
DATABASE_URL=postgresql://forte:forte@localhost:5432/forte
|
||||
DATABASE_URL=postgresql://lunarfront:lunarfront@localhost:5432/lunarfront
|
||||
REDIS_URL=redis://localhost:6379
|
||||
JWT_SECRET=your-secret-here
|
||||
NODE_ENV=development
|
||||
@@ -28,7 +28,7 @@ NODE_ENV=development
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DATABASE_URL` | `postgresql://forte:forte@localhost:5432/forte` | PostgreSQL connection string |
|
||||
| `DATABASE_URL` | `postgresql://lunarfront:lunarfront@localhost:5432/lunarfront` | PostgreSQL connection string |
|
||||
| `REDIS_URL` | `redis://localhost:6379` | Valkey/Redis connection string |
|
||||
| `JWT_SECRET` | (auto-generated in dev) | Secret for signing JWTs. **Required in production.** |
|
||||
| `PORT` | `8000` | Backend API port |
|
||||
@@ -45,7 +45,7 @@ NODE_ENV=development
|
||||
|
||||
```bash
|
||||
# Create the database
|
||||
createdb forte
|
||||
createdb lunarfront
|
||||
|
||||
# Run migrations
|
||||
cd packages/backend
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
The primary test suite lives at `packages/backend/api-tests/`. It uses a custom runner that:
|
||||
|
||||
1. Creates/migrates a `forte_api_test` database
|
||||
1. Creates/migrates a `lunarfront_api_test` database
|
||||
2. Seeds company, lookup tables, RBAC permissions/roles
|
||||
3. Starts the backend on port 8001
|
||||
4. Registers a test user with admin role
|
||||
|
||||
82
entrypoint-devpod.sh
Normal file
82
entrypoint-devpod.sh
Normal file
@@ -0,0 +1,82 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Generate SSH host keys if not present
|
||||
ssh-keygen -A
|
||||
|
||||
# Write authorized keys from env if provided
|
||||
if [ -n "$SSH_AUTHORIZED_KEYS" ]; then
|
||||
mkdir -p /root/.ssh
|
||||
chmod 700 /root/.ssh
|
||||
echo "$SSH_AUTHORIZED_KEYS" > /root/.ssh/authorized_keys
|
||||
chmod 600 /root/.ssh/authorized_keys
|
||||
fi
|
||||
|
||||
# Bootstrap home dir on fresh PVC
|
||||
if [ ! -f /root/.bashrc ]; then
|
||||
cp /etc/skel/.bashrc /root/.bashrc 2>/dev/null || true
|
||||
cat >> /root/.bashrc <<'EOF'
|
||||
export PATH="/usr/local/bin:$PATH"
|
||||
export HISTFILE=/root/.bash_history
|
||||
export HISTSIZE=10000
|
||||
EOF
|
||||
fi
|
||||
|
||||
if [ ! -f /root/.profile ]; then
|
||||
cat > /root/.profile <<'EOF'
|
||||
export PATH="/usr/local/bin:$PATH"
|
||||
[ -f /root/.bashrc ] && . /root/.bashrc
|
||||
EOF
|
||||
fi
|
||||
|
||||
if [ ! -f /root/.gitconfig ]; then
|
||||
cat > /root/.gitconfig <<'EOF'
|
||||
[user]
|
||||
name = ryan
|
||||
email = ryan@lunartech.com
|
||||
[init]
|
||||
defaultBranch = main
|
||||
[core]
|
||||
editor = code --wait
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Install Claude Code on first boot (installs to /root/.claude, persists on PVC)
|
||||
if [ ! -f /root/.claude/bin/claude ]; then
|
||||
curl -fsSL https://claude.ai/install.sh | bash
|
||||
fi
|
||||
|
||||
# Allow root login via SSH key, listen on internal port 2222
|
||||
echo "PermitRootLogin yes" >> /etc/ssh/sshd_config
|
||||
echo "Port 2222" >> /etc/ssh/sshd_config
|
||||
|
||||
# Start SSH daemon on internal port 2222
|
||||
/usr/sbin/sshd
|
||||
|
||||
# Start haproxy on port 22 to accept PROXY protocol from nginx and forward to sshd:2222
|
||||
cat > /etc/haproxy/haproxy.cfg <<'EOF'
|
||||
global
|
||||
daemon
|
||||
maxconn 256
|
||||
|
||||
defaults
|
||||
mode tcp
|
||||
timeout connect 5s
|
||||
timeout client 60s
|
||||
timeout server 60s
|
||||
|
||||
frontend ssh
|
||||
bind *:22 accept-proxy
|
||||
default_backend sshd
|
||||
|
||||
backend sshd
|
||||
server local 127.0.0.1:2222
|
||||
EOF
|
||||
haproxy -f /etc/haproxy/haproxy.cfg
|
||||
|
||||
# Start code-server
|
||||
exec code-server \
|
||||
--bind-addr 0.0.0.0:8080 \
|
||||
--auth none \
|
||||
--disable-telemetry \
|
||||
/root
|
||||
29
nginx.conf
Normal file
29
nginx.conf
Normal file
@@ -0,0 +1,29 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Proxy API and WebDAV to backend — BACKEND_URL injected at runtime via envsubst
|
||||
location /v1/ {
|
||||
proxy_pass ${BACKEND_URL};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /webdav/ {
|
||||
proxy_pass ${BACKEND_URL};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# SPA fallback — all other routes serve index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "forte",
|
||||
"name": "lunarfront",
|
||||
"private": true,
|
||||
"packageManager": "bun@1.3.11",
|
||||
"workspaces": ["packages/*"],
|
||||
"scripts": {
|
||||
"dev": "turbo run dev",
|
||||
|
||||
@@ -1,473 +0,0 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
|
||||
import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index'
|
||||
import { Route as AuthenticatedUsersRouteImport } from './routes/_authenticated/users'
|
||||
import { Route as AuthenticatedProfileRouteImport } from './routes/_authenticated/profile'
|
||||
import { Route as AuthenticatedHelpRouteImport } from './routes/_authenticated/help'
|
||||
import { Route as AuthenticatedRolesIndexRouteImport } from './routes/_authenticated/roles/index'
|
||||
import { Route as AuthenticatedMembersIndexRouteImport } from './routes/_authenticated/members/index'
|
||||
import { Route as AuthenticatedAccountsIndexRouteImport } from './routes/_authenticated/accounts/index'
|
||||
import { Route as AuthenticatedRolesNewRouteImport } from './routes/_authenticated/roles/new'
|
||||
import { Route as AuthenticatedRolesRoleIdRouteImport } from './routes/_authenticated/roles/$roleId'
|
||||
import { Route as AuthenticatedMembersMemberIdRouteImport } from './routes/_authenticated/members/$memberId'
|
||||
import { Route as AuthenticatedAccountsNewRouteImport } from './routes/_authenticated/accounts/new'
|
||||
import { Route as AuthenticatedAccountsAccountIdRouteImport } from './routes/_authenticated/accounts/$accountId'
|
||||
import { Route as AuthenticatedAccountsAccountIdIndexRouteImport } from './routes/_authenticated/accounts/$accountId/index'
|
||||
import { Route as AuthenticatedAccountsAccountIdTaxExemptionsRouteImport } from './routes/_authenticated/accounts/$accountId/tax-exemptions'
|
||||
import { Route as AuthenticatedAccountsAccountIdProcessorLinksRouteImport } from './routes/_authenticated/accounts/$accountId/processor-links'
|
||||
import { Route as AuthenticatedAccountsAccountIdPaymentMethodsRouteImport } from './routes/_authenticated/accounts/$accountId/payment-methods'
|
||||
import { Route as AuthenticatedAccountsAccountIdMembersRouteImport } from './routes/_authenticated/accounts/$accountId/members'
|
||||
|
||||
const LoginRoute = LoginRouteImport.update({
|
||||
id: '/login',
|
||||
path: '/login',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AuthenticatedRoute = AuthenticatedRouteImport.update({
|
||||
id: '/_authenticated',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AuthenticatedIndexRoute = AuthenticatedIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedUsersRoute = AuthenticatedUsersRouteImport.update({
|
||||
id: '/users',
|
||||
path: '/users',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedProfileRoute = AuthenticatedProfileRouteImport.update({
|
||||
id: '/profile',
|
||||
path: '/profile',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedHelpRoute = AuthenticatedHelpRouteImport.update({
|
||||
id: '/help',
|
||||
path: '/help',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedRolesIndexRoute = AuthenticatedRolesIndexRouteImport.update({
|
||||
id: '/roles/',
|
||||
path: '/roles/',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedMembersIndexRoute =
|
||||
AuthenticatedMembersIndexRouteImport.update({
|
||||
id: '/members/',
|
||||
path: '/members/',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedAccountsIndexRoute =
|
||||
AuthenticatedAccountsIndexRouteImport.update({
|
||||
id: '/accounts/',
|
||||
path: '/accounts/',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedRolesNewRoute = AuthenticatedRolesNewRouteImport.update({
|
||||
id: '/roles/new',
|
||||
path: '/roles/new',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedRolesRoleIdRoute =
|
||||
AuthenticatedRolesRoleIdRouteImport.update({
|
||||
id: '/roles/$roleId',
|
||||
path: '/roles/$roleId',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedMembersMemberIdRoute =
|
||||
AuthenticatedMembersMemberIdRouteImport.update({
|
||||
id: '/members/$memberId',
|
||||
path: '/members/$memberId',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedAccountsNewRoute =
|
||||
AuthenticatedAccountsNewRouteImport.update({
|
||||
id: '/accounts/new',
|
||||
path: '/accounts/new',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedAccountsAccountIdRoute =
|
||||
AuthenticatedAccountsAccountIdRouteImport.update({
|
||||
id: '/accounts/$accountId',
|
||||
path: '/accounts/$accountId',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedAccountsAccountIdIndexRoute =
|
||||
AuthenticatedAccountsAccountIdIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => AuthenticatedAccountsAccountIdRoute,
|
||||
} as any)
|
||||
const AuthenticatedAccountsAccountIdTaxExemptionsRoute =
|
||||
AuthenticatedAccountsAccountIdTaxExemptionsRouteImport.update({
|
||||
id: '/tax-exemptions',
|
||||
path: '/tax-exemptions',
|
||||
getParentRoute: () => AuthenticatedAccountsAccountIdRoute,
|
||||
} as any)
|
||||
const AuthenticatedAccountsAccountIdProcessorLinksRoute =
|
||||
AuthenticatedAccountsAccountIdProcessorLinksRouteImport.update({
|
||||
id: '/processor-links',
|
||||
path: '/processor-links',
|
||||
getParentRoute: () => AuthenticatedAccountsAccountIdRoute,
|
||||
} as any)
|
||||
const AuthenticatedAccountsAccountIdPaymentMethodsRoute =
|
||||
AuthenticatedAccountsAccountIdPaymentMethodsRouteImport.update({
|
||||
id: '/payment-methods',
|
||||
path: '/payment-methods',
|
||||
getParentRoute: () => AuthenticatedAccountsAccountIdRoute,
|
||||
} as any)
|
||||
const AuthenticatedAccountsAccountIdMembersRoute =
|
||||
AuthenticatedAccountsAccountIdMembersRouteImport.update({
|
||||
id: '/members',
|
||||
path: '/members',
|
||||
getParentRoute: () => AuthenticatedAccountsAccountIdRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof AuthenticatedIndexRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/help': typeof AuthenticatedHelpRoute
|
||||
'/profile': typeof AuthenticatedProfileRoute
|
||||
'/users': typeof AuthenticatedUsersRoute
|
||||
'/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren
|
||||
'/accounts/new': typeof AuthenticatedAccountsNewRoute
|
||||
'/members/$memberId': typeof AuthenticatedMembersMemberIdRoute
|
||||
'/roles/$roleId': typeof AuthenticatedRolesRoleIdRoute
|
||||
'/roles/new': typeof AuthenticatedRolesNewRoute
|
||||
'/accounts/': typeof AuthenticatedAccountsIndexRoute
|
||||
'/members/': typeof AuthenticatedMembersIndexRoute
|
||||
'/roles/': typeof AuthenticatedRolesIndexRoute
|
||||
'/accounts/$accountId/members': typeof AuthenticatedAccountsAccountIdMembersRoute
|
||||
'/accounts/$accountId/payment-methods': typeof AuthenticatedAccountsAccountIdPaymentMethodsRoute
|
||||
'/accounts/$accountId/processor-links': typeof AuthenticatedAccountsAccountIdProcessorLinksRoute
|
||||
'/accounts/$accountId/tax-exemptions': typeof AuthenticatedAccountsAccountIdTaxExemptionsRoute
|
||||
'/accounts/$accountId/': typeof AuthenticatedAccountsAccountIdIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/login': typeof LoginRoute
|
||||
'/help': typeof AuthenticatedHelpRoute
|
||||
'/profile': typeof AuthenticatedProfileRoute
|
||||
'/users': typeof AuthenticatedUsersRoute
|
||||
'/': typeof AuthenticatedIndexRoute
|
||||
'/accounts/new': typeof AuthenticatedAccountsNewRoute
|
||||
'/members/$memberId': typeof AuthenticatedMembersMemberIdRoute
|
||||
'/roles/$roleId': typeof AuthenticatedRolesRoleIdRoute
|
||||
'/roles/new': typeof AuthenticatedRolesNewRoute
|
||||
'/accounts': typeof AuthenticatedAccountsIndexRoute
|
||||
'/members': typeof AuthenticatedMembersIndexRoute
|
||||
'/roles': typeof AuthenticatedRolesIndexRoute
|
||||
'/accounts/$accountId/members': typeof AuthenticatedAccountsAccountIdMembersRoute
|
||||
'/accounts/$accountId/payment-methods': typeof AuthenticatedAccountsAccountIdPaymentMethodsRoute
|
||||
'/accounts/$accountId/processor-links': typeof AuthenticatedAccountsAccountIdProcessorLinksRoute
|
||||
'/accounts/$accountId/tax-exemptions': typeof AuthenticatedAccountsAccountIdTaxExemptionsRoute
|
||||
'/accounts/$accountId': typeof AuthenticatedAccountsAccountIdIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/_authenticated': typeof AuthenticatedRouteWithChildren
|
||||
'/login': typeof LoginRoute
|
||||
'/_authenticated/help': typeof AuthenticatedHelpRoute
|
||||
'/_authenticated/profile': typeof AuthenticatedProfileRoute
|
||||
'/_authenticated/users': typeof AuthenticatedUsersRoute
|
||||
'/_authenticated/': typeof AuthenticatedIndexRoute
|
||||
'/_authenticated/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren
|
||||
'/_authenticated/accounts/new': typeof AuthenticatedAccountsNewRoute
|
||||
'/_authenticated/members/$memberId': typeof AuthenticatedMembersMemberIdRoute
|
||||
'/_authenticated/roles/$roleId': typeof AuthenticatedRolesRoleIdRoute
|
||||
'/_authenticated/roles/new': typeof AuthenticatedRolesNewRoute
|
||||
'/_authenticated/accounts/': typeof AuthenticatedAccountsIndexRoute
|
||||
'/_authenticated/members/': typeof AuthenticatedMembersIndexRoute
|
||||
'/_authenticated/roles/': typeof AuthenticatedRolesIndexRoute
|
||||
'/_authenticated/accounts/$accountId/members': typeof AuthenticatedAccountsAccountIdMembersRoute
|
||||
'/_authenticated/accounts/$accountId/payment-methods': typeof AuthenticatedAccountsAccountIdPaymentMethodsRoute
|
||||
'/_authenticated/accounts/$accountId/processor-links': typeof AuthenticatedAccountsAccountIdProcessorLinksRoute
|
||||
'/_authenticated/accounts/$accountId/tax-exemptions': typeof AuthenticatedAccountsAccountIdTaxExemptionsRoute
|
||||
'/_authenticated/accounts/$accountId/': typeof AuthenticatedAccountsAccountIdIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/login'
|
||||
| '/help'
|
||||
| '/profile'
|
||||
| '/users'
|
||||
| '/accounts/$accountId'
|
||||
| '/accounts/new'
|
||||
| '/members/$memberId'
|
||||
| '/roles/$roleId'
|
||||
| '/roles/new'
|
||||
| '/accounts/'
|
||||
| '/members/'
|
||||
| '/roles/'
|
||||
| '/accounts/$accountId/members'
|
||||
| '/accounts/$accountId/payment-methods'
|
||||
| '/accounts/$accountId/processor-links'
|
||||
| '/accounts/$accountId/tax-exemptions'
|
||||
| '/accounts/$accountId/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/login'
|
||||
| '/help'
|
||||
| '/profile'
|
||||
| '/users'
|
||||
| '/'
|
||||
| '/accounts/new'
|
||||
| '/members/$memberId'
|
||||
| '/roles/$roleId'
|
||||
| '/roles/new'
|
||||
| '/accounts'
|
||||
| '/members'
|
||||
| '/roles'
|
||||
| '/accounts/$accountId/members'
|
||||
| '/accounts/$accountId/payment-methods'
|
||||
| '/accounts/$accountId/processor-links'
|
||||
| '/accounts/$accountId/tax-exemptions'
|
||||
| '/accounts/$accountId'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/_authenticated'
|
||||
| '/login'
|
||||
| '/_authenticated/help'
|
||||
| '/_authenticated/profile'
|
||||
| '/_authenticated/users'
|
||||
| '/_authenticated/'
|
||||
| '/_authenticated/accounts/$accountId'
|
||||
| '/_authenticated/accounts/new'
|
||||
| '/_authenticated/members/$memberId'
|
||||
| '/_authenticated/roles/$roleId'
|
||||
| '/_authenticated/roles/new'
|
||||
| '/_authenticated/accounts/'
|
||||
| '/_authenticated/members/'
|
||||
| '/_authenticated/roles/'
|
||||
| '/_authenticated/accounts/$accountId/members'
|
||||
| '/_authenticated/accounts/$accountId/payment-methods'
|
||||
| '/_authenticated/accounts/$accountId/processor-links'
|
||||
| '/_authenticated/accounts/$accountId/tax-exemptions'
|
||||
| '/_authenticated/accounts/$accountId/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
AuthenticatedRoute: typeof AuthenticatedRouteWithChildren
|
||||
LoginRoute: typeof LoginRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/login': {
|
||||
id: '/login'
|
||||
path: '/login'
|
||||
fullPath: '/login'
|
||||
preLoaderRoute: typeof LoginRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_authenticated': {
|
||||
id: '/_authenticated'
|
||||
path: ''
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof AuthenticatedRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_authenticated/': {
|
||||
id: '/_authenticated/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof AuthenticatedIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/users': {
|
||||
id: '/_authenticated/users'
|
||||
path: '/users'
|
||||
fullPath: '/users'
|
||||
preLoaderRoute: typeof AuthenticatedUsersRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/profile': {
|
||||
id: '/_authenticated/profile'
|
||||
path: '/profile'
|
||||
fullPath: '/profile'
|
||||
preLoaderRoute: typeof AuthenticatedProfileRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/help': {
|
||||
id: '/_authenticated/help'
|
||||
path: '/help'
|
||||
fullPath: '/help'
|
||||
preLoaderRoute: typeof AuthenticatedHelpRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/roles/': {
|
||||
id: '/_authenticated/roles/'
|
||||
path: '/roles'
|
||||
fullPath: '/roles/'
|
||||
preLoaderRoute: typeof AuthenticatedRolesIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/members/': {
|
||||
id: '/_authenticated/members/'
|
||||
path: '/members'
|
||||
fullPath: '/members/'
|
||||
preLoaderRoute: typeof AuthenticatedMembersIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/accounts/': {
|
||||
id: '/_authenticated/accounts/'
|
||||
path: '/accounts'
|
||||
fullPath: '/accounts/'
|
||||
preLoaderRoute: typeof AuthenticatedAccountsIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/roles/new': {
|
||||
id: '/_authenticated/roles/new'
|
||||
path: '/roles/new'
|
||||
fullPath: '/roles/new'
|
||||
preLoaderRoute: typeof AuthenticatedRolesNewRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/roles/$roleId': {
|
||||
id: '/_authenticated/roles/$roleId'
|
||||
path: '/roles/$roleId'
|
||||
fullPath: '/roles/$roleId'
|
||||
preLoaderRoute: typeof AuthenticatedRolesRoleIdRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/members/$memberId': {
|
||||
id: '/_authenticated/members/$memberId'
|
||||
path: '/members/$memberId'
|
||||
fullPath: '/members/$memberId'
|
||||
preLoaderRoute: typeof AuthenticatedMembersMemberIdRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/accounts/new': {
|
||||
id: '/_authenticated/accounts/new'
|
||||
path: '/accounts/new'
|
||||
fullPath: '/accounts/new'
|
||||
preLoaderRoute: typeof AuthenticatedAccountsNewRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/accounts/$accountId': {
|
||||
id: '/_authenticated/accounts/$accountId'
|
||||
path: '/accounts/$accountId'
|
||||
fullPath: '/accounts/$accountId'
|
||||
preLoaderRoute: typeof AuthenticatedAccountsAccountIdRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/accounts/$accountId/': {
|
||||
id: '/_authenticated/accounts/$accountId/'
|
||||
path: '/'
|
||||
fullPath: '/accounts/$accountId/'
|
||||
preLoaderRoute: typeof AuthenticatedAccountsAccountIdIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedAccountsAccountIdRoute
|
||||
}
|
||||
'/_authenticated/accounts/$accountId/tax-exemptions': {
|
||||
id: '/_authenticated/accounts/$accountId/tax-exemptions'
|
||||
path: '/tax-exemptions'
|
||||
fullPath: '/accounts/$accountId/tax-exemptions'
|
||||
preLoaderRoute: typeof AuthenticatedAccountsAccountIdTaxExemptionsRouteImport
|
||||
parentRoute: typeof AuthenticatedAccountsAccountIdRoute
|
||||
}
|
||||
'/_authenticated/accounts/$accountId/processor-links': {
|
||||
id: '/_authenticated/accounts/$accountId/processor-links'
|
||||
path: '/processor-links'
|
||||
fullPath: '/accounts/$accountId/processor-links'
|
||||
preLoaderRoute: typeof AuthenticatedAccountsAccountIdProcessorLinksRouteImport
|
||||
parentRoute: typeof AuthenticatedAccountsAccountIdRoute
|
||||
}
|
||||
'/_authenticated/accounts/$accountId/payment-methods': {
|
||||
id: '/_authenticated/accounts/$accountId/payment-methods'
|
||||
path: '/payment-methods'
|
||||
fullPath: '/accounts/$accountId/payment-methods'
|
||||
preLoaderRoute: typeof AuthenticatedAccountsAccountIdPaymentMethodsRouteImport
|
||||
parentRoute: typeof AuthenticatedAccountsAccountIdRoute
|
||||
}
|
||||
'/_authenticated/accounts/$accountId/members': {
|
||||
id: '/_authenticated/accounts/$accountId/members'
|
||||
path: '/members'
|
||||
fullPath: '/accounts/$accountId/members'
|
||||
preLoaderRoute: typeof AuthenticatedAccountsAccountIdMembersRouteImport
|
||||
parentRoute: typeof AuthenticatedAccountsAccountIdRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface AuthenticatedAccountsAccountIdRouteChildren {
|
||||
AuthenticatedAccountsAccountIdMembersRoute: typeof AuthenticatedAccountsAccountIdMembersRoute
|
||||
AuthenticatedAccountsAccountIdPaymentMethodsRoute: typeof AuthenticatedAccountsAccountIdPaymentMethodsRoute
|
||||
AuthenticatedAccountsAccountIdProcessorLinksRoute: typeof AuthenticatedAccountsAccountIdProcessorLinksRoute
|
||||
AuthenticatedAccountsAccountIdTaxExemptionsRoute: typeof AuthenticatedAccountsAccountIdTaxExemptionsRoute
|
||||
AuthenticatedAccountsAccountIdIndexRoute: typeof AuthenticatedAccountsAccountIdIndexRoute
|
||||
}
|
||||
|
||||
const AuthenticatedAccountsAccountIdRouteChildren: AuthenticatedAccountsAccountIdRouteChildren =
|
||||
{
|
||||
AuthenticatedAccountsAccountIdMembersRoute:
|
||||
AuthenticatedAccountsAccountIdMembersRoute,
|
||||
AuthenticatedAccountsAccountIdPaymentMethodsRoute:
|
||||
AuthenticatedAccountsAccountIdPaymentMethodsRoute,
|
||||
AuthenticatedAccountsAccountIdProcessorLinksRoute:
|
||||
AuthenticatedAccountsAccountIdProcessorLinksRoute,
|
||||
AuthenticatedAccountsAccountIdTaxExemptionsRoute:
|
||||
AuthenticatedAccountsAccountIdTaxExemptionsRoute,
|
||||
AuthenticatedAccountsAccountIdIndexRoute:
|
||||
AuthenticatedAccountsAccountIdIndexRoute,
|
||||
}
|
||||
|
||||
const AuthenticatedAccountsAccountIdRouteWithChildren =
|
||||
AuthenticatedAccountsAccountIdRoute._addFileChildren(
|
||||
AuthenticatedAccountsAccountIdRouteChildren,
|
||||
)
|
||||
|
||||
interface AuthenticatedRouteChildren {
|
||||
AuthenticatedHelpRoute: typeof AuthenticatedHelpRoute
|
||||
AuthenticatedProfileRoute: typeof AuthenticatedProfileRoute
|
||||
AuthenticatedUsersRoute: typeof AuthenticatedUsersRoute
|
||||
AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
|
||||
AuthenticatedAccountsAccountIdRoute: typeof AuthenticatedAccountsAccountIdRouteWithChildren
|
||||
AuthenticatedAccountsNewRoute: typeof AuthenticatedAccountsNewRoute
|
||||
AuthenticatedMembersMemberIdRoute: typeof AuthenticatedMembersMemberIdRoute
|
||||
AuthenticatedRolesRoleIdRoute: typeof AuthenticatedRolesRoleIdRoute
|
||||
AuthenticatedRolesNewRoute: typeof AuthenticatedRolesNewRoute
|
||||
AuthenticatedAccountsIndexRoute: typeof AuthenticatedAccountsIndexRoute
|
||||
AuthenticatedMembersIndexRoute: typeof AuthenticatedMembersIndexRoute
|
||||
AuthenticatedRolesIndexRoute: typeof AuthenticatedRolesIndexRoute
|
||||
}
|
||||
|
||||
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
|
||||
AuthenticatedHelpRoute: AuthenticatedHelpRoute,
|
||||
AuthenticatedProfileRoute: AuthenticatedProfileRoute,
|
||||
AuthenticatedUsersRoute: AuthenticatedUsersRoute,
|
||||
AuthenticatedIndexRoute: AuthenticatedIndexRoute,
|
||||
AuthenticatedAccountsAccountIdRoute:
|
||||
AuthenticatedAccountsAccountIdRouteWithChildren,
|
||||
AuthenticatedAccountsNewRoute: AuthenticatedAccountsNewRoute,
|
||||
AuthenticatedMembersMemberIdRoute: AuthenticatedMembersMemberIdRoute,
|
||||
AuthenticatedRolesRoleIdRoute: AuthenticatedRolesRoleIdRoute,
|
||||
AuthenticatedRolesNewRoute: AuthenticatedRolesNewRoute,
|
||||
AuthenticatedAccountsIndexRoute: AuthenticatedAccountsIndexRoute,
|
||||
AuthenticatedMembersIndexRoute: AuthenticatedMembersIndexRoute,
|
||||
AuthenticatedRolesIndexRoute: AuthenticatedRolesIndexRoute,
|
||||
}
|
||||
|
||||
const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
|
||||
AuthenticatedRouteChildren,
|
||||
)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
AuthenticatedRoute: AuthenticatedRouteWithChildren,
|
||||
LoginRoute: LoginRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
@@ -3,13 +3,13 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Forte Admin</title>
|
||||
<title>LunarFront Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
// Apply mode before React renders to prevent flash
|
||||
(function() {
|
||||
var mode = localStorage.getItem('forte-mode') || 'system';
|
||||
var mode = localStorage.getItem('lunarfront-mode') || 'system';
|
||||
var isDark = mode === 'dark' || (mode === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
if (isDark) document.documentElement.classList.add('dark');
|
||||
})();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@forte/admin",
|
||||
"name": "@lunarfront/admin",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -10,8 +10,8 @@
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@forte/shared": "workspace:*",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@lunarfront/shared": "workspace:*",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
@@ -21,12 +21,17 @@
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@tanstack/react-query": "^5.75.5",
|
||||
"@tanstack/react-router": "^1.121.0",
|
||||
"@types/react-big-calendar": "^1.16.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"html2pdf.js": "^0.14.0",
|
||||
"jsbarcode": "^3.12.3",
|
||||
"jspdf": "^4.2.1",
|
||||
"lucide-react": "^1.7.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.1.0",
|
||||
"react-big-calendar": "^1.19.4",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.72.0",
|
||||
"sonner": "^2.0.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api-client'
|
||||
import type { Account } from '@/types/account'
|
||||
import type { PaginatedResponse, PaginationInput } from '@forte/shared/schemas'
|
||||
import type { PaginatedResponse, PaginationInput } from '@lunarfront/shared/schemas'
|
||||
|
||||
export const accountKeys = {
|
||||
all: ['accounts'] as const,
|
||||
|
||||
@@ -14,3 +14,11 @@ interface LoginResponse {
|
||||
export async function login(email: string, password: string): Promise<LoginResponse> {
|
||||
return api.post<LoginResponse>('/v1/auth/login', { email, password })
|
||||
}
|
||||
|
||||
export async function forgotPassword(email: string): Promise<{ message: string }> {
|
||||
return api.post<{ message: string }>('/v1/auth/forgot-password', { email })
|
||||
}
|
||||
|
||||
export async function resetPassword(token: string, newPassword: string): Promise<{ message: string }> {
|
||||
return api.post<{ message: string }>('/v1/auth/reset-password', { token, newPassword })
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api-client'
|
||||
import type { MemberIdentifier } from '@/types/account'
|
||||
import type { PaginatedResponse } from '@forte/shared/schemas'
|
||||
import type { PaginatedResponse } from '@lunarfront/shared/schemas'
|
||||
|
||||
export const identifierKeys = {
|
||||
all: (memberId: string) => ['members', memberId, 'identifiers'] as const,
|
||||
|
||||
170
packages/admin/src/api/inventory.ts
Normal file
170
packages/admin/src/api/inventory.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api-client'
|
||||
import type { Category, Supplier, Product, InventoryUnit, ProductSupplier, StockReceipt, PriceHistory } from '@/types/inventory'
|
||||
import type { PaginatedResponse, PaginationInput } from '@lunarfront/shared/schemas'
|
||||
|
||||
// ─── Categories ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const categoryKeys = {
|
||||
all: ['categories'] as const,
|
||||
list: (params: PaginationInput) => [...categoryKeys.all, 'list', params] as const,
|
||||
allCategories: [...['categories'], 'all-flat'] as const,
|
||||
detail: (id: string) => [...categoryKeys.all, 'detail', id] as const,
|
||||
}
|
||||
|
||||
export function categoryListOptions(params: PaginationInput) {
|
||||
return queryOptions({
|
||||
queryKey: categoryKeys.list(params),
|
||||
queryFn: () => api.get<PaginatedResponse<Category>>('/v1/categories', params as Record<string, unknown>),
|
||||
})
|
||||
}
|
||||
|
||||
export function categoryAllOptions() {
|
||||
return queryOptions({
|
||||
queryKey: categoryKeys.allCategories,
|
||||
queryFn: () => api.get<{ data: Category[] }>('/v1/categories/all'),
|
||||
})
|
||||
}
|
||||
|
||||
export const categoryMutations = {
|
||||
create: (data: Record<string, unknown>) => api.post<Category>('/v1/categories', data),
|
||||
update: (id: string, data: Record<string, unknown>) => api.patch<Category>(`/v1/categories/${id}`, data),
|
||||
delete: (id: string) => api.del<Category>(`/v1/categories/${id}`),
|
||||
}
|
||||
|
||||
// ─── Suppliers ───────────────────────────────────────────────────────────────
|
||||
|
||||
export const supplierKeys = {
|
||||
all: ['suppliers'] as const,
|
||||
allSuppliers: ['suppliers', 'all'] as const,
|
||||
list: (params: PaginationInput) => [...supplierKeys.all, 'list', params] as const,
|
||||
detail: (id: string) => [...supplierKeys.all, 'detail', id] as const,
|
||||
}
|
||||
|
||||
export function supplierListOptions(params: PaginationInput) {
|
||||
return queryOptions({
|
||||
queryKey: supplierKeys.list(params),
|
||||
queryFn: () => api.get<PaginatedResponse<Supplier>>('/v1/suppliers', params as Record<string, unknown>),
|
||||
})
|
||||
}
|
||||
|
||||
export function supplierAllOptions() {
|
||||
return queryOptions({
|
||||
queryKey: supplierKeys.allSuppliers,
|
||||
queryFn: () => api.get<{ data: Supplier[] }>('/v1/suppliers/all'),
|
||||
})
|
||||
}
|
||||
|
||||
export const supplierMutations = {
|
||||
create: (data: Record<string, unknown>) => api.post<Supplier>('/v1/suppliers', data),
|
||||
update: (id: string, data: Record<string, unknown>) => api.patch<Supplier>(`/v1/suppliers/${id}`, data),
|
||||
delete: (id: string) => api.del<Supplier>(`/v1/suppliers/${id}`),
|
||||
}
|
||||
|
||||
// ─── Products ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const productKeys = {
|
||||
all: ['products'] as const,
|
||||
list: (params: Record<string, unknown>) => [...productKeys.all, 'list', params] as const,
|
||||
detail: (id: string) => [...productKeys.all, 'detail', id] as const,
|
||||
}
|
||||
|
||||
export function productListOptions(params: Record<string, unknown>) {
|
||||
return queryOptions({
|
||||
queryKey: productKeys.list(params),
|
||||
queryFn: () => api.get<PaginatedResponse<Product>>('/v1/products', params),
|
||||
})
|
||||
}
|
||||
|
||||
export function productDetailOptions(id: string) {
|
||||
return queryOptions({
|
||||
queryKey: productKeys.detail(id),
|
||||
queryFn: () => api.get<Product>(`/v1/products/${id}`),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export const productMutations = {
|
||||
create: (data: Record<string, unknown>) => api.post<Product>('/v1/products', data),
|
||||
update: (id: string, data: Record<string, unknown>) => api.patch<Product>(`/v1/products/${id}`, data),
|
||||
delete: (id: string) => api.del<Product>(`/v1/products/${id}`),
|
||||
}
|
||||
|
||||
// ─── Inventory Units ─────────────────────────────────────────────────────────
|
||||
|
||||
export const unitKeys = {
|
||||
all: ['inventory-units'] as const,
|
||||
byProduct: (productId: string) => [...unitKeys.all, 'product', productId] as const,
|
||||
detail: (id: string) => [...unitKeys.all, 'detail', id] as const,
|
||||
}
|
||||
|
||||
export function unitListOptions(productId: string) {
|
||||
return queryOptions({
|
||||
queryKey: unitKeys.byProduct(productId),
|
||||
queryFn: () => api.get<{ data: InventoryUnit[] }>(`/v1/products/${productId}/units`),
|
||||
enabled: !!productId,
|
||||
})
|
||||
}
|
||||
|
||||
export const unitMutations = {
|
||||
create: (productId: string, data: Record<string, unknown>) =>
|
||||
api.post<InventoryUnit>(`/v1/products/${productId}/units`, data),
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<InventoryUnit>(`/v1/units/${id}`, data),
|
||||
}
|
||||
|
||||
// ─── Product Suppliers ───────────────────────────────────────────────────────
|
||||
|
||||
export const productSupplierKeys = {
|
||||
byProduct: (productId: string) => ['products', productId, 'suppliers'] as const,
|
||||
}
|
||||
|
||||
export function productSupplierListOptions(productId: string) {
|
||||
return queryOptions({
|
||||
queryKey: productSupplierKeys.byProduct(productId),
|
||||
queryFn: () => api.get<{ data: ProductSupplier[] }>(`/v1/products/${productId}/suppliers`),
|
||||
enabled: !!productId,
|
||||
})
|
||||
}
|
||||
|
||||
export const productSupplierMutations = {
|
||||
create: (productId: string, data: Record<string, unknown>) =>
|
||||
api.post<ProductSupplier>(`/v1/products/${productId}/suppliers`, data),
|
||||
update: (productId: string, id: string, data: Record<string, unknown>) =>
|
||||
api.patch<ProductSupplier>(`/v1/products/${productId}/suppliers/${id}`, data),
|
||||
delete: (productId: string, id: string) =>
|
||||
api.del(`/v1/products/${productId}/suppliers/${id}`),
|
||||
}
|
||||
|
||||
// ─── Stock Receipts ──────────────────────────────────────────────────────────
|
||||
|
||||
export const stockReceiptKeys = {
|
||||
byProduct: (productId: string) => ['products', productId, 'stock-receipts'] as const,
|
||||
}
|
||||
|
||||
export function stockReceiptListOptions(productId: string) {
|
||||
return queryOptions({
|
||||
queryKey: stockReceiptKeys.byProduct(productId),
|
||||
queryFn: () => api.get<{ data: StockReceipt[] }>(`/v1/products/${productId}/stock-receipts`),
|
||||
enabled: !!productId,
|
||||
})
|
||||
}
|
||||
|
||||
export const stockReceiptMutations = {
|
||||
create: (productId: string, data: Record<string, unknown>) =>
|
||||
api.post<StockReceipt>(`/v1/products/${productId}/stock-receipts`, data),
|
||||
}
|
||||
|
||||
// ─── Price History ───────────────────────────────────────────────────────────
|
||||
|
||||
export const priceHistoryKeys = {
|
||||
byProduct: (productId: string) => ['products', productId, 'price-history'] as const,
|
||||
}
|
||||
|
||||
export function priceHistoryOptions(productId: string) {
|
||||
return queryOptions({
|
||||
queryKey: priceHistoryKeys.byProduct(productId),
|
||||
queryFn: () => api.get<{ data: PriceHistory[] }>(`/v1/products/${productId}/price-history`),
|
||||
enabled: !!productId,
|
||||
})
|
||||
}
|
||||
341
packages/admin/src/api/lessons.ts
Normal file
341
packages/admin/src/api/lessons.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api-client'
|
||||
import type {
|
||||
Instructor,
|
||||
InstructorBlockedDate,
|
||||
LessonType,
|
||||
ScheduleSlot,
|
||||
Enrollment,
|
||||
LessonSession,
|
||||
GradingScale,
|
||||
LessonPlan,
|
||||
LessonPlanItem,
|
||||
LessonPlanItemGradeHistory,
|
||||
LessonPlanTemplate,
|
||||
StoreClosure,
|
||||
SessionPlanItem,
|
||||
} from '@/types/lesson'
|
||||
import type { PaginatedResponse, PaginationInput } from '@lunarfront/shared/schemas'
|
||||
|
||||
// --- Instructors ---
|
||||
|
||||
export const instructorKeys = {
|
||||
all: ['instructors'] as const,
|
||||
list: (params: PaginationInput) => [...instructorKeys.all, 'list', params] as const,
|
||||
detail: (id: string) => [...instructorKeys.all, 'detail', id] as const,
|
||||
blockedDates: (id: string) => [...instructorKeys.all, id, 'blocked-dates'] as const,
|
||||
}
|
||||
|
||||
export function instructorListOptions(params: PaginationInput) {
|
||||
return queryOptions({
|
||||
queryKey: instructorKeys.list(params),
|
||||
queryFn: () => api.get<PaginatedResponse<Instructor>>('/v1/instructors', params),
|
||||
})
|
||||
}
|
||||
|
||||
export function instructorDetailOptions(id: string) {
|
||||
return queryOptions({
|
||||
queryKey: instructorKeys.detail(id),
|
||||
queryFn: () => api.get<Instructor>(`/v1/instructors/${id}`),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export function instructorBlockedDatesOptions(instructorId: string) {
|
||||
return queryOptions({
|
||||
queryKey: instructorKeys.blockedDates(instructorId),
|
||||
queryFn: () => api.get<InstructorBlockedDate[]>(`/v1/instructors/${instructorId}/blocked-dates`),
|
||||
enabled: !!instructorId,
|
||||
})
|
||||
}
|
||||
|
||||
export const instructorMutations = {
|
||||
create: (data: Record<string, unknown>) =>
|
||||
api.post<Instructor>('/v1/instructors', data),
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<Instructor>(`/v1/instructors/${id}`, data),
|
||||
delete: (id: string) =>
|
||||
api.del<Instructor>(`/v1/instructors/${id}`),
|
||||
addBlockedDate: (instructorId: string, data: Record<string, unknown>) =>
|
||||
api.post<InstructorBlockedDate>(`/v1/instructors/${instructorId}/blocked-dates`, data),
|
||||
deleteBlockedDate: (instructorId: string, id: string) =>
|
||||
api.del<InstructorBlockedDate>(`/v1/instructors/${instructorId}/blocked-dates/${id}`),
|
||||
}
|
||||
|
||||
// --- Lesson Types ---
|
||||
|
||||
export const lessonTypeKeys = {
|
||||
all: ['lesson-types'] as const,
|
||||
list: (params: PaginationInput) => [...lessonTypeKeys.all, 'list', params] as const,
|
||||
detail: (id: string) => [...lessonTypeKeys.all, 'detail', id] as const,
|
||||
}
|
||||
|
||||
export function lessonTypeListOptions(params: PaginationInput) {
|
||||
return queryOptions({
|
||||
queryKey: lessonTypeKeys.list(params),
|
||||
queryFn: () => api.get<PaginatedResponse<LessonType>>('/v1/lesson-types', params),
|
||||
})
|
||||
}
|
||||
|
||||
export const lessonTypeMutations = {
|
||||
create: (data: Record<string, unknown>) =>
|
||||
api.post<LessonType>('/v1/lesson-types', data),
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<LessonType>(`/v1/lesson-types/${id}`, data),
|
||||
delete: (id: string) =>
|
||||
api.del<LessonType>(`/v1/lesson-types/${id}`),
|
||||
}
|
||||
|
||||
// --- Schedule Slots ---
|
||||
|
||||
export const scheduleSlotKeys = {
|
||||
all: ['schedule-slots'] as const,
|
||||
list: (params: PaginationInput) => [...scheduleSlotKeys.all, 'list', params] as const,
|
||||
byInstructor: (instructorId: string, params: PaginationInput) =>
|
||||
[...scheduleSlotKeys.all, 'instructor', instructorId, params] as const,
|
||||
detail: (id: string) => [...scheduleSlotKeys.all, 'detail', id] as const,
|
||||
}
|
||||
|
||||
export function scheduleSlotListOptions(params: PaginationInput, filters?: { instructorId?: string; dayOfWeek?: number }) {
|
||||
const query = { ...params, ...filters }
|
||||
return queryOptions({
|
||||
queryKey: filters?.instructorId
|
||||
? scheduleSlotKeys.byInstructor(filters.instructorId, params)
|
||||
: scheduleSlotKeys.list(params),
|
||||
queryFn: () => api.get<PaginatedResponse<ScheduleSlot>>('/v1/schedule-slots', query),
|
||||
})
|
||||
}
|
||||
|
||||
export const scheduleSlotMutations = {
|
||||
create: (data: Record<string, unknown>) =>
|
||||
api.post<ScheduleSlot>('/v1/schedule-slots', data),
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<ScheduleSlot>(`/v1/schedule-slots/${id}`, data),
|
||||
delete: (id: string) =>
|
||||
api.del<ScheduleSlot>(`/v1/schedule-slots/${id}`),
|
||||
}
|
||||
|
||||
// --- Enrollments ---
|
||||
|
||||
export const enrollmentKeys = {
|
||||
all: ['enrollments'] as const,
|
||||
list: (params: Record<string, unknown>) => [...enrollmentKeys.all, 'list', params] as const,
|
||||
detail: (id: string) => [...enrollmentKeys.all, 'detail', id] as const,
|
||||
}
|
||||
|
||||
export function enrollmentListOptions(params: Record<string, unknown>) {
|
||||
return queryOptions({
|
||||
queryKey: enrollmentKeys.list(params),
|
||||
queryFn: () => api.get<PaginatedResponse<Enrollment>>('/v1/enrollments', params),
|
||||
})
|
||||
}
|
||||
|
||||
export function enrollmentDetailOptions(id: string) {
|
||||
return queryOptions({
|
||||
queryKey: enrollmentKeys.detail(id),
|
||||
queryFn: () => api.get<Enrollment>(`/v1/enrollments/${id}`),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export const enrollmentMutations = {
|
||||
create: (data: Record<string, unknown>) =>
|
||||
api.post<Enrollment>('/v1/enrollments', data),
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<Enrollment>(`/v1/enrollments/${id}`, data),
|
||||
updateStatus: (id: string, status: string) =>
|
||||
api.post<Enrollment>(`/v1/enrollments/${id}/status`, { status }),
|
||||
generateSessions: (id: string, weeks?: number) =>
|
||||
api.post<{ generated: number; sessions: LessonSession[] }>(
|
||||
`/v1/enrollments/${id}/generate-sessions${weeks ? `?weeks=${weeks}` : ''}`,
|
||||
{},
|
||||
),
|
||||
}
|
||||
|
||||
// --- Lesson Sessions ---
|
||||
|
||||
export const sessionKeys = {
|
||||
all: ['lesson-sessions'] as const,
|
||||
list: (params: Record<string, unknown>) => [...sessionKeys.all, 'list', params] as const,
|
||||
detail: (id: string) => [...sessionKeys.all, 'detail', id] as const,
|
||||
planItems: (id: string) => [...sessionKeys.all, id, 'plan-items'] as const,
|
||||
}
|
||||
|
||||
export function sessionListOptions(params: Record<string, unknown>) {
|
||||
return queryOptions({
|
||||
queryKey: sessionKeys.list(params),
|
||||
queryFn: () => api.get<PaginatedResponse<LessonSession>>('/v1/lesson-sessions', params),
|
||||
})
|
||||
}
|
||||
|
||||
export function sessionDetailOptions(id: string) {
|
||||
return queryOptions({
|
||||
queryKey: sessionKeys.detail(id),
|
||||
queryFn: () => api.get<LessonSession>(`/v1/lesson-sessions/${id}`),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export function sessionPlanItemsOptions(sessionId: string) {
|
||||
return queryOptions({
|
||||
queryKey: sessionKeys.planItems(sessionId),
|
||||
queryFn: () => api.get<SessionPlanItem[]>(`/v1/lesson-sessions/${sessionId}/plan-items`),
|
||||
enabled: !!sessionId,
|
||||
})
|
||||
}
|
||||
|
||||
export const sessionMutations = {
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<LessonSession>(`/v1/lesson-sessions/${id}`, data),
|
||||
updateStatus: (id: string, status: string) =>
|
||||
api.post<LessonSession>(`/v1/lesson-sessions/${id}/status`, { status }),
|
||||
updateNotes: (id: string, data: Record<string, unknown>) =>
|
||||
api.post<LessonSession>(`/v1/lesson-sessions/${id}/notes`, data),
|
||||
linkPlanItems: (id: string, lessonPlanItemIds: string[]) =>
|
||||
api.post<{ linked: number; items: SessionPlanItem[] }>(`/v1/lesson-sessions/${id}/plan-items`, { lessonPlanItemIds }),
|
||||
}
|
||||
|
||||
// --- Grading Scales ---
|
||||
|
||||
export const gradingScaleKeys = {
|
||||
all: ['grading-scales'] as const,
|
||||
list: (params: PaginationInput) => [...gradingScaleKeys.all, 'list', params] as const,
|
||||
allScales: [...['grading-scales'], 'all'] as const,
|
||||
detail: (id: string) => [...gradingScaleKeys.all, 'detail', id] as const,
|
||||
}
|
||||
|
||||
export function gradingScaleListOptions(params: PaginationInput) {
|
||||
return queryOptions({
|
||||
queryKey: gradingScaleKeys.list(params),
|
||||
queryFn: () => api.get<PaginatedResponse<GradingScale>>('/v1/grading-scales', params),
|
||||
})
|
||||
}
|
||||
|
||||
export function gradingScaleAllOptions() {
|
||||
return queryOptions({
|
||||
queryKey: gradingScaleKeys.allScales,
|
||||
queryFn: () => api.get<GradingScale[]>('/v1/grading-scales/all'),
|
||||
})
|
||||
}
|
||||
|
||||
export function gradingScaleDetailOptions(id: string) {
|
||||
return queryOptions({
|
||||
queryKey: gradingScaleKeys.detail(id),
|
||||
queryFn: () => api.get<GradingScale>(`/v1/grading-scales/${id}`),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export const gradingScaleMutations = {
|
||||
create: (data: Record<string, unknown>) =>
|
||||
api.post<GradingScale>('/v1/grading-scales', data),
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<GradingScale>(`/v1/grading-scales/${id}`, data),
|
||||
delete: (id: string) =>
|
||||
api.del<GradingScale>(`/v1/grading-scales/${id}`),
|
||||
}
|
||||
|
||||
// --- Lesson Plans ---
|
||||
|
||||
export const lessonPlanKeys = {
|
||||
all: ['lesson-plans'] as const,
|
||||
list: (params: Record<string, unknown>) => [...lessonPlanKeys.all, 'list', params] as const,
|
||||
detail: (id: string) => [...lessonPlanKeys.all, 'detail', id] as const,
|
||||
}
|
||||
|
||||
export function lessonPlanListOptions(params: Record<string, unknown>) {
|
||||
return queryOptions({
|
||||
queryKey: lessonPlanKeys.list(params),
|
||||
queryFn: () => api.get<PaginatedResponse<LessonPlan>>('/v1/lesson-plans', params),
|
||||
})
|
||||
}
|
||||
|
||||
export function lessonPlanDetailOptions(id: string) {
|
||||
return queryOptions({
|
||||
queryKey: lessonPlanKeys.detail(id),
|
||||
queryFn: () => api.get<LessonPlan>(`/v1/lesson-plans/${id}`),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export const lessonPlanMutations = {
|
||||
create: (data: Record<string, unknown>) =>
|
||||
api.post<LessonPlan>('/v1/lesson-plans', data),
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<LessonPlan>(`/v1/lesson-plans/${id}`, data),
|
||||
}
|
||||
|
||||
// --- Lesson Plan Items ---
|
||||
|
||||
export const lessonPlanItemKeys = {
|
||||
gradeHistory: (itemId: string) => ['lesson-plan-items', itemId, 'grade-history'] as const,
|
||||
}
|
||||
|
||||
export function lessonPlanItemGradeHistoryOptions(itemId: string) {
|
||||
return queryOptions({
|
||||
queryKey: lessonPlanItemKeys.gradeHistory(itemId),
|
||||
queryFn: () => api.get<LessonPlanItemGradeHistory[]>(`/v1/lesson-plan-items/${itemId}/grade-history`),
|
||||
enabled: !!itemId,
|
||||
})
|
||||
}
|
||||
|
||||
export const lessonPlanItemMutations = {
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<LessonPlanItem>(`/v1/lesson-plan-items/${id}`, data),
|
||||
addGrade: (id: string, data: Record<string, unknown>) =>
|
||||
api.post<{ record: LessonPlanItemGradeHistory; item: LessonPlanItem }>(`/v1/lesson-plan-items/${id}/grades`, data),
|
||||
}
|
||||
|
||||
// --- Lesson Plan Templates ---
|
||||
|
||||
export const lessonPlanTemplateKeys = {
|
||||
all: ['lesson-plan-templates'] as const,
|
||||
list: (params: PaginationInput) => [...lessonPlanTemplateKeys.all, 'list', params] as const,
|
||||
detail: (id: string) => [...lessonPlanTemplateKeys.all, 'detail', id] as const,
|
||||
}
|
||||
|
||||
export function lessonPlanTemplateListOptions(params: PaginationInput) {
|
||||
return queryOptions({
|
||||
queryKey: lessonPlanTemplateKeys.list(params),
|
||||
queryFn: () => api.get<PaginatedResponse<LessonPlanTemplate>>('/v1/lesson-plan-templates', params),
|
||||
})
|
||||
}
|
||||
|
||||
export function lessonPlanTemplateDetailOptions(id: string) {
|
||||
return queryOptions({
|
||||
queryKey: lessonPlanTemplateKeys.detail(id),
|
||||
queryFn: () => api.get<LessonPlanTemplate>(`/v1/lesson-plan-templates/${id}`),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export const lessonPlanTemplateMutations = {
|
||||
create: (data: Record<string, unknown>) =>
|
||||
api.post<LessonPlanTemplate>('/v1/lesson-plan-templates', data),
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<LessonPlanTemplate>(`/v1/lesson-plan-templates/${id}`, data),
|
||||
delete: (id: string) =>
|
||||
api.del<LessonPlanTemplate>(`/v1/lesson-plan-templates/${id}`),
|
||||
createPlan: (templateId: string, data: Record<string, unknown>) =>
|
||||
api.post<LessonPlan>(`/v1/lesson-plan-templates/${templateId}/create-plan`, data),
|
||||
}
|
||||
|
||||
// --- Store Closures ---
|
||||
|
||||
export const storeClosureKeys = {
|
||||
all: ['store-closures'] as const,
|
||||
}
|
||||
|
||||
export function storeClosureListOptions() {
|
||||
return queryOptions({
|
||||
queryKey: storeClosureKeys.all,
|
||||
queryFn: () => api.get<StoreClosure[]>('/v1/store-closures'),
|
||||
})
|
||||
}
|
||||
|
||||
export const storeClosureMutations = {
|
||||
create: (data: Record<string, unknown>) =>
|
||||
api.post<StoreClosure>('/v1/store-closures', data),
|
||||
delete: (id: string) =>
|
||||
api.del<StoreClosure>(`/v1/store-closures/${id}`),
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api-client'
|
||||
import type { Member } from '@/types/account'
|
||||
import type { PaginatedResponse, PaginationInput } from '@forte/shared/schemas'
|
||||
import type { PaginatedResponse, PaginationInput } from '@lunarfront/shared/schemas'
|
||||
|
||||
interface MemberWithAccount extends Member {
|
||||
accountName: string | null
|
||||
|
||||
28
packages/admin/src/api/modules.ts
Normal file
28
packages/admin/src/api/modules.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api-client'
|
||||
|
||||
export interface ModuleConfig {
|
||||
id: string
|
||||
slug: string
|
||||
name: string
|
||||
description: string | null
|
||||
licensed: boolean
|
||||
enabled: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export const moduleKeys = {
|
||||
list: ['modules'] as const,
|
||||
}
|
||||
|
||||
export function moduleListOptions() {
|
||||
return queryOptions({
|
||||
queryKey: moduleKeys.list,
|
||||
queryFn: () => api.get<{ data: ModuleConfig[] }>('/v1/modules'),
|
||||
})
|
||||
}
|
||||
|
||||
export const moduleMutations = {
|
||||
toggle: (slug: string, enabled: boolean) => api.patch<ModuleConfig>(`/v1/modules/${slug}`, { enabled }),
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api-client'
|
||||
import type { PaymentMethod } from '@/types/account'
|
||||
import type { PaginatedResponse, PaginationInput } from '@forte/shared/schemas'
|
||||
import type { PaginatedResponse, PaginationInput } from '@lunarfront/shared/schemas'
|
||||
|
||||
export const paymentMethodKeys = {
|
||||
all: (accountId: string) => ['accounts', accountId, 'payment-methods'] as const,
|
||||
|
||||
225
packages/admin/src/api/pos.ts
Normal file
225
packages/admin/src/api/pos.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api-client'
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface Transaction {
|
||||
id: string
|
||||
locationId: string | null
|
||||
transactionNumber: string
|
||||
accountId: string | null
|
||||
repairTicketId: string | null
|
||||
repairBatchId: string | null
|
||||
transactionType: string
|
||||
status: string
|
||||
subtotal: string
|
||||
discountTotal: string
|
||||
taxTotal: string
|
||||
total: string
|
||||
paymentMethod: string | null
|
||||
amountTendered: string | null
|
||||
changeGiven: string | null
|
||||
checkNumber: string | null
|
||||
roundingAdjustment: string
|
||||
taxExempt: boolean
|
||||
taxExemptReason: string | null
|
||||
processedBy: string
|
||||
drawerSessionId: string | null
|
||||
notes: string | null
|
||||
completedAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
lineItems?: TransactionLineItem[]
|
||||
}
|
||||
|
||||
export interface TransactionLineItem {
|
||||
id: string
|
||||
transactionId: string
|
||||
productId: string | null
|
||||
inventoryUnitId: string | null
|
||||
description: string
|
||||
qty: number
|
||||
unitPrice: string
|
||||
discountAmount: string
|
||||
discountReason: string | null
|
||||
taxRate: string
|
||||
taxAmount: string
|
||||
lineTotal: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface DrawerSession {
|
||||
id: string
|
||||
locationId: string | null
|
||||
openedBy: string
|
||||
closedBy: string | null
|
||||
openingBalance: string
|
||||
closingBalance: string | null
|
||||
expectedBalance: string | null
|
||||
overShort: string | null
|
||||
denominations: Record<string, number> | null
|
||||
status: string
|
||||
notes: string | null
|
||||
openedAt: string
|
||||
closedAt: string | null
|
||||
}
|
||||
|
||||
export interface Discount {
|
||||
id: string
|
||||
name: string
|
||||
discountType: string
|
||||
discountValue: string
|
||||
appliesTo: string
|
||||
requiresApprovalAbove: string | null
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: string
|
||||
name: string
|
||||
sku: string | null
|
||||
upc: string | null
|
||||
description: string | null
|
||||
price: string | null
|
||||
costPrice: string | null
|
||||
qtyOnHand: number | null
|
||||
taxCategory: string
|
||||
isSerialized: boolean
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export interface Register {
|
||||
id: string
|
||||
locationId: string
|
||||
name: string
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
// --- Query Keys ---
|
||||
|
||||
export interface DrawerAdjustment {
|
||||
id: string
|
||||
drawerSessionId: string
|
||||
type: string
|
||||
amount: string
|
||||
reason: string
|
||||
createdBy: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export const posKeys = {
|
||||
transaction: (id: string) => ['pos', 'transaction', id] as const,
|
||||
drawer: (locationId: string) => ['pos', 'drawer', locationId] as const,
|
||||
drawerAdjustments: (id: string) => ['pos', 'drawer-adjustments', id] as const,
|
||||
drawerReport: (id: string) => ['pos', 'drawer-report', id] as const,
|
||||
dailyReport: (locationId: string, date: string) => ['pos', 'daily-report', locationId, date] as const,
|
||||
registers: (locationId: string) => ['pos', 'registers', locationId] as const,
|
||||
products: (search: string) => ['pos', 'products', search] as const,
|
||||
discounts: ['pos', 'discounts'] as const,
|
||||
}
|
||||
|
||||
// --- Query Options ---
|
||||
|
||||
export function transactionOptions(id: string | null) {
|
||||
return queryOptions({
|
||||
queryKey: posKeys.transaction(id ?? ''),
|
||||
queryFn: () => api.get<Transaction>(`/v1/transactions/${id}`),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export function currentDrawerOptions(locationId: string | null) {
|
||||
return queryOptions({
|
||||
queryKey: posKeys.drawer(locationId ?? ''),
|
||||
queryFn: async (): Promise<DrawerSession | null> => {
|
||||
try {
|
||||
return await api.get<DrawerSession>('/v1/drawer/current', { locationId })
|
||||
} catch {
|
||||
return null // 404 = no open drawer
|
||||
}
|
||||
},
|
||||
enabled: !!locationId,
|
||||
retry: false,
|
||||
})
|
||||
}
|
||||
|
||||
export function productSearchOptions(search: string) {
|
||||
return queryOptions({
|
||||
queryKey: posKeys.products(search),
|
||||
queryFn: () => api.get<{ data: Product[]; pagination: { page: number; limit: number; total: number; totalPages: number } }>('/v1/products', { q: search, limit: 24, isActive: true, isConsumable: false }),
|
||||
enabled: search.length >= 1,
|
||||
})
|
||||
}
|
||||
|
||||
export function discountListOptions() {
|
||||
return queryOptions({
|
||||
queryKey: posKeys.discounts,
|
||||
queryFn: () => api.get<Discount[]>('/v1/discounts/all'),
|
||||
})
|
||||
}
|
||||
|
||||
export function registerListOptions(locationId: string | null) {
|
||||
return queryOptions({
|
||||
queryKey: posKeys.registers(locationId ?? ''),
|
||||
queryFn: () => api.get<{ data: Register[] }>('/v1/registers/all', { locationId }),
|
||||
enabled: !!locationId,
|
||||
})
|
||||
}
|
||||
|
||||
export function drawerReportOptions(drawerSessionId: string | null) {
|
||||
return queryOptions({
|
||||
queryKey: posKeys.drawerReport(drawerSessionId ?? ''),
|
||||
queryFn: () => api.get<any>(`/v1/reports/drawer/${drawerSessionId}`),
|
||||
enabled: !!drawerSessionId,
|
||||
})
|
||||
}
|
||||
|
||||
export function dailyReportOptions(locationId: string | null, date: string) {
|
||||
return queryOptions({
|
||||
queryKey: posKeys.dailyReport(locationId ?? '', date),
|
||||
queryFn: () => api.get<any>('/v1/reports/daily', { locationId, date }),
|
||||
enabled: !!locationId && !!date,
|
||||
})
|
||||
}
|
||||
|
||||
// --- Mutations ---
|
||||
|
||||
export const posMutations = {
|
||||
createTransaction: (data: { transactionType: string; locationId?: string; accountId?: string }) =>
|
||||
api.post<Transaction>('/v1/transactions', data),
|
||||
|
||||
addLineItem: (txnId: string, data: { productId?: string; inventoryUnitId?: string; description: string; qty: number; unitPrice: number }) =>
|
||||
api.post<TransactionLineItem>(`/v1/transactions/${txnId}/line-items`, data),
|
||||
|
||||
removeLineItem: (txnId: string, lineItemId: string) =>
|
||||
api.del<TransactionLineItem>(`/v1/transactions/${txnId}/line-items/${lineItemId}`),
|
||||
|
||||
applyDiscount: (txnId: string, data: { discountId?: string; amount: number; reason: string; lineItemId?: string }) =>
|
||||
api.post<Transaction>(`/v1/transactions/${txnId}/discounts`, data),
|
||||
|
||||
complete: (txnId: string, data: { paymentMethod: string; amountTendered?: number; checkNumber?: string }) =>
|
||||
api.post<Transaction>(`/v1/transactions/${txnId}/complete`, data),
|
||||
|
||||
void: (txnId: string) =>
|
||||
api.post<Transaction>(`/v1/transactions/${txnId}/void`, {}),
|
||||
|
||||
openDrawer: (data: { locationId?: string; registerId?: string; openingBalance: number }) =>
|
||||
api.post<DrawerSession>('/v1/drawer/open', data),
|
||||
|
||||
closeDrawer: (id: string, data: { closingBalance: number; denominations?: Record<string, number>; notes?: string }) =>
|
||||
api.post<DrawerSession>(`/v1/drawer/${id}/close`, data),
|
||||
|
||||
lookupUpc: (upc: string) =>
|
||||
api.get<Product>(`/v1/products/lookup/upc/${upc}`),
|
||||
|
||||
addAdjustment: (drawerId: string, data: { type: string; amount: number; reason: string }) =>
|
||||
api.post<DrawerAdjustment>(`/v1/drawer/${drawerId}/adjustments`, data),
|
||||
|
||||
getAdjustments: (drawerId: string) =>
|
||||
api.get<{ data: DrawerAdjustment[] }>(`/v1/drawer/${drawerId}/adjustments`),
|
||||
|
||||
createFromRepair: (ticketId: string, locationId?: string) =>
|
||||
api.post<Transaction>(`/v1/transactions/from-repair/${ticketId}`, { locationId }),
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api-client'
|
||||
import type { ProcessorLink } from '@/types/account'
|
||||
import type { PaginatedResponse, PaginationInput } from '@forte/shared/schemas'
|
||||
import type { PaginatedResponse, PaginationInput } from '@lunarfront/shared/schemas'
|
||||
|
||||
export const processorLinkKeys = {
|
||||
all: (accountId: string) => ['accounts', accountId, 'processor-links'] as const,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api-client'
|
||||
import type { Permission, Role } from '@/types/rbac'
|
||||
import type { PaginationInput, PaginatedResponse } from '@forte/shared/schemas'
|
||||
import type { PaginationInput, PaginatedResponse } from '@lunarfront/shared/schemas'
|
||||
|
||||
export const rbacKeys = {
|
||||
permissions: ['permissions'] as const,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api-client'
|
||||
import type { RepairTicket, RepairLineItem, RepairBatch, RepairNote, RepairServiceTemplate } from '@/types/repair'
|
||||
import type { PaginatedResponse, PaginationInput } from '@forte/shared/schemas'
|
||||
import type { PaginatedResponse, PaginationInput } from '@lunarfront/shared/schemas'
|
||||
|
||||
// --- Repair Tickets ---
|
||||
|
||||
|
||||
73
packages/admin/src/api/storage.ts
Normal file
73
packages/admin/src/api/storage.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api-client'
|
||||
import type { StorageFolder, StorageFolderPermission, StorageFile } from '@/types/storage'
|
||||
import type { PaginatedResponse, PaginationInput } from '@lunarfront/shared/schemas'
|
||||
|
||||
// --- Folders ---
|
||||
|
||||
export const storageFolderKeys = {
|
||||
all: ['storage-folders'] as const,
|
||||
tree: ['storage-folders', 'tree'] as const,
|
||||
children: (parentId: string | null) => ['storage-folders', 'children', parentId] as const,
|
||||
detail: (id: string) => ['storage-folders', 'detail', id] as const,
|
||||
permissions: (id: string) => ['storage-folders', id, 'permissions'] as const,
|
||||
}
|
||||
|
||||
export function storageFolderTreeOptions() {
|
||||
return queryOptions({
|
||||
queryKey: storageFolderKeys.tree,
|
||||
queryFn: () => api.get<{ data: StorageFolder[] }>('/v1/storage/folders/tree'),
|
||||
})
|
||||
}
|
||||
|
||||
export function storageFolderChildrenOptions(parentId: string | null) {
|
||||
return queryOptions({
|
||||
queryKey: storageFolderKeys.children(parentId),
|
||||
queryFn: () => api.get<{ data: StorageFolder[] }>('/v1/storage/folders', parentId ? { parentId } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
export function storageFolderDetailOptions(id: string) {
|
||||
return queryOptions({
|
||||
queryKey: storageFolderKeys.detail(id),
|
||||
queryFn: () => api.get<StorageFolder & { breadcrumbs: { id: string; name: string }[] }>(`/v1/storage/folders/${id}`),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export function storageFolderPermissionsOptions(id: string) {
|
||||
return queryOptions({
|
||||
queryKey: storageFolderKeys.permissions(id),
|
||||
queryFn: () => api.get<{ data: StorageFolderPermission[] }>(`/v1/storage/folders/${id}/permissions`),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export const storageFolderMutations = {
|
||||
create: (data: Record<string, unknown>) => api.post<StorageFolder>('/v1/storage/folders', data),
|
||||
update: (id: string, data: Record<string, unknown>) => api.patch<StorageFolder>(`/v1/storage/folders/${id}`, data),
|
||||
delete: (id: string) => api.del<StorageFolder>(`/v1/storage/folders/${id}`),
|
||||
addPermission: (folderId: string, data: Record<string, unknown>) => api.post<StorageFolderPermission>(`/v1/storage/folders/${folderId}/permissions`, data),
|
||||
removePermission: (permId: string) => api.del<StorageFolderPermission>(`/v1/storage/folder-permissions/${permId}`),
|
||||
}
|
||||
|
||||
// --- Files ---
|
||||
|
||||
export const storageFileKeys = {
|
||||
all: (folderId: string) => ['storage-files', folderId] as const,
|
||||
list: (folderId: string, params: PaginationInput) => ['storage-files', folderId, params] as const,
|
||||
search: (q: string) => ['storage-files', 'search', q] as const,
|
||||
}
|
||||
|
||||
export function storageFileListOptions(folderId: string, params: PaginationInput) {
|
||||
return queryOptions({
|
||||
queryKey: storageFileKeys.list(folderId, params),
|
||||
queryFn: () => api.get<PaginatedResponse<StorageFile>>(`/v1/storage/folders/${folderId}/files`, params),
|
||||
enabled: !!folderId,
|
||||
})
|
||||
}
|
||||
|
||||
export const storageFileMutations = {
|
||||
delete: (id: string) => api.del<StorageFile>(`/v1/storage/files/${id}`),
|
||||
getSignedUrl: (id: string) => api.get<{ url: string }>(`/v1/storage/files/${id}/signed-url`),
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api-client'
|
||||
import type { TaxExemption } from '@/types/account'
|
||||
import type { PaginatedResponse, PaginationInput } from '@forte/shared/schemas'
|
||||
import type { PaginatedResponse, PaginationInput } from '@lunarfront/shared/schemas'
|
||||
|
||||
export const taxExemptionKeys = {
|
||||
all: (accountId: string) => ['accounts', accountId, 'tax-exemptions'] as const,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api-client'
|
||||
import type { PaginationInput, PaginatedResponse } from '@forte/shared/schemas'
|
||||
import type { PaginationInput, PaginatedResponse } from '@lunarfront/shared/schemas'
|
||||
|
||||
export interface UserRole {
|
||||
id: string
|
||||
|
||||
91
packages/admin/src/api/vault.ts
Normal file
91
packages/admin/src/api/vault.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api-client'
|
||||
import type { VaultStatus, VaultCategory, VaultCategoryPermission, VaultEntry } from '@/types/vault'
|
||||
import type { PaginatedResponse, PaginationInput } from '@lunarfront/shared/schemas'
|
||||
|
||||
// --- Keys ---
|
||||
|
||||
export const vaultKeys = {
|
||||
status: ['vault-status'] as const,
|
||||
categories: ['vault-categories'] as const,
|
||||
categoryDetail: (id: string) => ['vault-categories', id] as const,
|
||||
categoryPermissions: (id: string) => ['vault-categories', id, 'permissions'] as const,
|
||||
entries: (categoryId: string) => ['vault-entries', categoryId] as const,
|
||||
entryList: (categoryId: string, params: PaginationInput) => ['vault-entries', categoryId, params] as const,
|
||||
entryDetail: (id: string) => ['vault-entry', id] as const,
|
||||
}
|
||||
|
||||
// --- Queries ---
|
||||
|
||||
export function vaultStatusOptions() {
|
||||
return queryOptions({
|
||||
queryKey: vaultKeys.status,
|
||||
queryFn: () => api.get<VaultStatus>('/v1/vault/status'),
|
||||
})
|
||||
}
|
||||
|
||||
export function vaultCategoryListOptions() {
|
||||
return queryOptions({
|
||||
queryKey: vaultKeys.categories,
|
||||
queryFn: () => api.get<{ data: VaultCategory[] }>('/v1/vault/categories'),
|
||||
})
|
||||
}
|
||||
|
||||
export function vaultCategoryDetailOptions(id: string) {
|
||||
return queryOptions({
|
||||
queryKey: vaultKeys.categoryDetail(id),
|
||||
queryFn: () => api.get<VaultCategory>(`/v1/vault/categories/${id}`),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export function vaultCategoryPermissionsOptions(id: string) {
|
||||
return queryOptions({
|
||||
queryKey: vaultKeys.categoryPermissions(id),
|
||||
queryFn: () => api.get<{ data: VaultCategoryPermission[] }>(`/v1/vault/categories/${id}/permissions`),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export function vaultEntryListOptions(categoryId: string, params: PaginationInput) {
|
||||
return queryOptions({
|
||||
queryKey: vaultKeys.entryList(categoryId, params),
|
||||
queryFn: () => api.get<PaginatedResponse<VaultEntry>>(`/v1/vault/categories/${categoryId}/entries`, params),
|
||||
enabled: !!categoryId,
|
||||
})
|
||||
}
|
||||
|
||||
export function vaultEntryDetailOptions(id: string) {
|
||||
return queryOptions({
|
||||
queryKey: vaultKeys.entryDetail(id),
|
||||
queryFn: () => api.get<VaultEntry>(`/v1/vault/entries/${id}`),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
// --- Mutations ---
|
||||
|
||||
export const vaultMutations = {
|
||||
initialize: (masterPassword: string) => api.post('/v1/vault/initialize', { masterPassword }),
|
||||
unlock: (masterPassword: string) => api.post('/v1/vault/unlock', { masterPassword }),
|
||||
lock: () => api.post('/v1/vault/lock', {}),
|
||||
changeMasterPassword: (currentPassword: string, newPassword: string) =>
|
||||
api.post('/v1/vault/change-master-password', { currentPassword, newPassword }),
|
||||
}
|
||||
|
||||
export const vaultCategoryMutations = {
|
||||
create: (data: Record<string, unknown>) => api.post<VaultCategory>('/v1/vault/categories', data),
|
||||
update: (id: string, data: Record<string, unknown>) => api.patch<VaultCategory>(`/v1/vault/categories/${id}`, data),
|
||||
delete: (id: string) => api.del<VaultCategory>(`/v1/vault/categories/${id}`),
|
||||
addPermission: (categoryId: string, data: Record<string, unknown>) =>
|
||||
api.post<VaultCategoryPermission>(`/v1/vault/categories/${categoryId}/permissions`, data),
|
||||
removePermission: (permId: string) => api.del<VaultCategoryPermission>(`/v1/vault/category-permissions/${permId}`),
|
||||
}
|
||||
|
||||
export const vaultEntryMutations = {
|
||||
create: (categoryId: string, data: Record<string, unknown>) =>
|
||||
api.post<VaultEntry>(`/v1/vault/categories/${categoryId}/entries`, data),
|
||||
update: (id: string, data: Record<string, unknown>) => api.patch<VaultEntry>(`/v1/vault/entries/${id}`, data),
|
||||
delete: (id: string) => api.del<VaultEntry>(`/v1/vault/entries/${id}`),
|
||||
reveal: (id: string) => api.post<{ value: string | null }>(`/v1/vault/entries/${id}/reveal`, {}),
|
||||
}
|
||||
@@ -88,3 +88,36 @@ body {
|
||||
border-color: #2a3a52 !important;
|
||||
transition: background-color 5000s ease-in-out 0s;
|
||||
}
|
||||
|
||||
/* Scrollbars — themed to match sidebar/app palette */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* Prevent browser autofill from overriding dark theme input colors */
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus {
|
||||
-webkit-box-shadow: 0 0 0 1000px hsl(var(--background)) inset !important;
|
||||
-webkit-text-fill-color: hsl(var(--foreground)) !important;
|
||||
transition: background-color 5000s ease-in-out 0s;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { AccountCreateSchema } from '@forte/shared/schemas'
|
||||
import { AccountCreateSchema } from '@lunarfront/shared/schemas'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { PaymentMethodCreateSchema } from '@forte/shared/schemas'
|
||||
import type { PaymentMethodCreateInput } from '@forte/shared/schemas'
|
||||
import { PaymentMethodCreateSchema } from '@lunarfront/shared/schemas'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { ProcessorLinkCreateSchema } from '@forte/shared/schemas'
|
||||
import type { ProcessorLinkCreateInput } from '@forte/shared/schemas'
|
||||
import { ProcessorLinkCreateSchema } from '@lunarfront/shared/schemas'
|
||||
import type { ProcessorLinkCreateInput } from '@lunarfront/shared/schemas'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { TaxExemptionCreateSchema } from '@forte/shared/schemas'
|
||||
import type { TaxExemptionCreateInput } from '@forte/shared/schemas'
|
||||
import { TaxExemptionCreateSchema } from '@lunarfront/shared/schemas'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
91
packages/admin/src/components/inventory/category-form.tsx
Normal file
91
packages/admin/src/components/inventory/category-form.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { categoryAllOptions } from '@/api/inventory'
|
||||
import type { Category } from '@/types/inventory'
|
||||
|
||||
interface Props {
|
||||
defaultValues?: Partial<Category>
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
onDelete?: () => void
|
||||
loading?: boolean
|
||||
deleteLoading?: boolean
|
||||
}
|
||||
|
||||
export function CategoryForm({ defaultValues, onSubmit, onDelete, loading, deleteLoading }: Props) {
|
||||
const { data: allCats } = useQuery(categoryAllOptions())
|
||||
const categories = (allCats?.data ?? []).filter((c) => c.id !== defaultValues?.id && c.isActive)
|
||||
|
||||
const { register, handleSubmit, setValue, watch } = useForm({
|
||||
defaultValues: {
|
||||
name: defaultValues?.name ?? '',
|
||||
parentId: defaultValues?.parentId ?? '',
|
||||
sortOrder: defaultValues?.sortOrder ?? 0,
|
||||
isActive: defaultValues?.isActive ?? true,
|
||||
},
|
||||
})
|
||||
|
||||
const parentId = watch('parentId')
|
||||
const isActive = watch('isActive')
|
||||
|
||||
function handleFormSubmit(data: { name: string; parentId: string; sortOrder: number; isActive: boolean }) {
|
||||
onSubmit({
|
||||
name: data.name,
|
||||
parentId: data.parentId || undefined,
|
||||
sortOrder: Number(data.sortOrder),
|
||||
isActive: data.isActive,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cat-name">Name *</Label>
|
||||
<Input id="cat-name" {...register('name')} placeholder="e.g. Guitars, Accessories" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Parent Category</Label>
|
||||
<Select value={parentId || 'none'} onValueChange={(v) => setValue('parentId', v === 'none' ? '' : v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="None (Top Level)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None (Top Level)</SelectItem>
|
||||
{categories.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cat-order">Sort Order</Label>
|
||||
<Input id="cat-order" type="number" min={0} {...register('sortOrder')} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-7">
|
||||
<input
|
||||
id="cat-active"
|
||||
type="checkbox"
|
||||
checked={isActive}
|
||||
onChange={(e) => setValue('isActive', e.target.checked)}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<Label htmlFor="cat-active">Active</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button type="submit" disabled={loading} className="flex-1">
|
||||
{loading ? 'Saving...' : defaultValues?.id ? 'Save Changes' : 'Create Category'}
|
||||
</Button>
|
||||
{onDelete && (
|
||||
<Button type="button" variant="destructive" disabled={deleteLoading} onClick={onDelete}>
|
||||
{deleteLoading ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
118
packages/admin/src/components/inventory/inventory-unit-form.tsx
Normal file
118
packages/admin/src/components/inventory/inventory-unit-form.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import type { InventoryUnit, UnitCondition, UnitStatus } from '@/types/inventory'
|
||||
|
||||
const CONDITIONS: { value: UnitCondition; label: string }[] = [
|
||||
{ value: 'new', label: 'New' },
|
||||
{ value: 'excellent', label: 'Excellent' },
|
||||
{ value: 'good', label: 'Good' },
|
||||
{ value: 'fair', label: 'Fair' },
|
||||
{ value: 'poor', label: 'Poor' },
|
||||
]
|
||||
|
||||
const STATUSES: { value: UnitStatus; label: string }[] = [
|
||||
{ value: 'available', label: 'Available' },
|
||||
{ value: 'sold', label: 'Sold' },
|
||||
{ value: 'rented', label: 'Rented' },
|
||||
{ value: 'on_trial', label: 'On Trial' },
|
||||
{ value: 'in_repair', label: 'In Repair' },
|
||||
{ value: 'layaway', label: 'Layaway' },
|
||||
{ value: 'lost', label: 'Lost' },
|
||||
{ value: 'retired', label: 'Retired' },
|
||||
]
|
||||
|
||||
interface Props {
|
||||
defaultValues?: Partial<InventoryUnit>
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function InventoryUnitForm({ defaultValues, onSubmit, loading }: Props) {
|
||||
const { register, handleSubmit, setValue, watch } = useForm({
|
||||
defaultValues: {
|
||||
serialNumber: defaultValues?.serialNumber ?? '',
|
||||
condition: (defaultValues?.condition ?? 'new') as UnitCondition,
|
||||
status: (defaultValues?.status ?? 'available') as UnitStatus,
|
||||
purchaseDate: defaultValues?.purchaseDate ?? '',
|
||||
purchaseCost: defaultValues?.purchaseCost ?? '',
|
||||
notes: defaultValues?.notes ?? '',
|
||||
},
|
||||
})
|
||||
|
||||
const condition = watch('condition')
|
||||
const status = watch('status')
|
||||
|
||||
function handleFormSubmit(data: Record<string, unknown>) {
|
||||
onSubmit({
|
||||
serialNumber: (data.serialNumber as string) || undefined,
|
||||
condition: data.condition,
|
||||
status: data.status,
|
||||
purchaseDate: (data.purchaseDate as string) || undefined,
|
||||
purchaseCost: (data.purchaseCost as string) || undefined,
|
||||
notes: (data.notes as string) || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="unit-serial">Serial Number</Label>
|
||||
<Input id="unit-serial" {...register('serialNumber')} placeholder="e.g. US22041234" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Condition</Label>
|
||||
<Select value={condition} onValueChange={(v) => setValue('condition', v as UnitCondition)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CONDITIONS.map((c) => (
|
||||
<SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Status</Label>
|
||||
<Select value={status} onValueChange={(v) => setValue('status', v as UnitStatus)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUSES.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="unit-date">Purchase Date</Label>
|
||||
<Input id="unit-date" type="date" {...register('purchaseDate')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="unit-cost">Purchase Cost</Label>
|
||||
<Input id="unit-cost" type="number" step="0.01" min="0" {...register('purchaseCost')} placeholder="0.00" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="unit-notes">Notes</Label>
|
||||
<textarea
|
||||
id="unit-notes"
|
||||
{...register('notes')}
|
||||
rows={2}
|
||||
placeholder="Any notes about this unit..."
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-none"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? 'Saving...' : defaultValues?.id ? 'Save Changes' : 'Add Unit'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
179
packages/admin/src/components/inventory/product-form.tsx
Normal file
179
packages/admin/src/components/inventory/product-form.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { categoryAllOptions } from '@/api/inventory'
|
||||
import type { Product } from '@/types/inventory'
|
||||
|
||||
interface Props {
|
||||
defaultValues?: Partial<Product>
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function ProductForm({ defaultValues, onSubmit, loading }: Props) {
|
||||
const { data: allCats } = useQuery(categoryAllOptions())
|
||||
const categories = (allCats?.data ?? []).filter((c) => c.isActive)
|
||||
|
||||
const { register, handleSubmit, setValue, watch } = useForm({
|
||||
defaultValues: {
|
||||
name: defaultValues?.name ?? '',
|
||||
sku: defaultValues?.sku ?? '',
|
||||
upc: defaultValues?.upc ?? '',
|
||||
brand: defaultValues?.brand ?? '',
|
||||
model: defaultValues?.model ?? '',
|
||||
description: defaultValues?.description ?? '',
|
||||
categoryId: defaultValues?.categoryId ?? '',
|
||||
price: defaultValues?.price ?? '',
|
||||
minPrice: defaultValues?.minPrice ?? '',
|
||||
rentalRateMonthly: defaultValues?.rentalRateMonthly ?? '',
|
||||
qtyOnHand: defaultValues?.qtyOnHand ?? 0,
|
||||
qtyReorderPoint: defaultValues?.qtyReorderPoint ?? '',
|
||||
isSerialized: defaultValues?.isSerialized ?? false,
|
||||
isRental: defaultValues?.isRental ?? false,
|
||||
isDualUseRepair: defaultValues?.isDualUseRepair ?? false,
|
||||
isConsumable: defaultValues?.isConsumable ?? false,
|
||||
isActive: defaultValues?.isActive ?? true,
|
||||
},
|
||||
})
|
||||
|
||||
const categoryId = watch('categoryId')
|
||||
const isRental = watch('isRental')
|
||||
const isSerialized = watch('isSerialized')
|
||||
const isDualUseRepair = watch('isDualUseRepair')
|
||||
const isConsumable = watch('isConsumable')
|
||||
const isActive = watch('isActive')
|
||||
|
||||
function handleFormSubmit(data: Record<string, unknown>) {
|
||||
onSubmit({
|
||||
name: data.name,
|
||||
sku: (data.sku as string) || undefined,
|
||||
upc: (data.upc as string) || undefined,
|
||||
brand: (data.brand as string) || undefined,
|
||||
model: (data.model as string) || undefined,
|
||||
description: (data.description as string) || undefined,
|
||||
categoryId: (data.categoryId as string) || undefined,
|
||||
price: (data.price as string) ? Number(data.price) : undefined,
|
||||
minPrice: (data.minPrice as string) ? Number(data.minPrice) : undefined,
|
||||
rentalRateMonthly: isRental && (data.rentalRateMonthly as string) ? Number(data.rentalRateMonthly) : undefined,
|
||||
qtyOnHand: Number(data.qtyOnHand),
|
||||
qtyReorderPoint: (data.qtyReorderPoint as string) ? Number(data.qtyReorderPoint) : undefined,
|
||||
isSerialized: data.isSerialized,
|
||||
isRental: data.isRental,
|
||||
isDualUseRepair: data.isDualUseRepair,
|
||||
isConsumable: data.isConsumable,
|
||||
isActive: data.isActive,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="p-name">Name *</Label>
|
||||
<Input id="p-name" {...register('name')} placeholder="e.g. Fender Player Stratocaster" required />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="p-sku">SKU</Label>
|
||||
<Input id="p-sku" {...register('sku')} placeholder="STR-001" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="p-upc">UPC / Barcode</Label>
|
||||
<Input id="p-upc" {...register('upc')} placeholder="0123456789" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="p-brand">Brand</Label>
|
||||
<Input id="p-brand" {...register('brand')} placeholder="Fender" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="p-model">Model</Label>
|
||||
<Input id="p-model" {...register('model')} placeholder="Player Stratocaster" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Category</Label>
|
||||
<Select value={categoryId || 'none'} onValueChange={(v) => setValue('categoryId', v === 'none' ? '' : v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select category..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No Category</SelectItem>
|
||||
{categories.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="p-price">Price</Label>
|
||||
<Input id="p-price" type="number" step="0.01" min="0" {...register('price')} placeholder="0.00" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="p-min-price">Min Price</Label>
|
||||
<Input id="p-min-price" type="number" step="0.01" min="0" {...register('minPrice')} placeholder="0.00" />
|
||||
</div>
|
||||
{isRental && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="p-rental-rate">Rental / Month</Label>
|
||||
<Input id="p-rental-rate" type="number" step="0.01" min="0" {...register('rentalRateMonthly')} placeholder="0.00" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isSerialized && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="p-qty">Qty On Hand</Label>
|
||||
<Input id="p-qty" type="number" min="0" {...register('qtyOnHand')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="p-reorder">Reorder Point</Label>
|
||||
<Input id="p-reorder" type="number" min="0" {...register('qtyReorderPoint')} placeholder="—" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="p-desc">Description</Label>
|
||||
<textarea
|
||||
id="p-desc"
|
||||
{...register('description')}
|
||||
rows={3}
|
||||
placeholder="Product description..."
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Options</Label>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" checked={isSerialized} onChange={(e) => setValue('isSerialized', e.target.checked)} className="h-4 w-4" />
|
||||
Serialized (track individual units)
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" checked={isRental} onChange={(e) => setValue('isRental', e.target.checked)} className="h-4 w-4" />
|
||||
Available for Rental
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" checked={isDualUseRepair} onChange={(e) => setValue('isDualUseRepair', e.target.checked)} className="h-4 w-4" />
|
||||
Available as Repair Line Item
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" checked={isConsumable} onChange={(e) => setValue('isConsumable', e.target.checked)} className="h-4 w-4" />
|
||||
Consumable (internal use, not sold at POS)
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" checked={isActive} onChange={(e) => setValue('isActive', e.target.checked)} className="h-4 w-4" />
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? 'Saving...' : defaultValues?.id ? 'Save Changes' : 'Create Product'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
88
packages/admin/src/components/inventory/supplier-form.tsx
Normal file
88
packages/admin/src/components/inventory/supplier-form.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import type { Supplier } from '@/types/inventory'
|
||||
|
||||
interface Props {
|
||||
defaultValues?: Partial<Supplier>
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
onDelete?: () => void
|
||||
loading?: boolean
|
||||
deleteLoading?: boolean
|
||||
}
|
||||
|
||||
export function SupplierForm({ defaultValues, onSubmit, onDelete, loading, deleteLoading }: Props) {
|
||||
const { register, handleSubmit } = useForm({
|
||||
defaultValues: {
|
||||
name: defaultValues?.name ?? '',
|
||||
contactName: defaultValues?.contactName ?? '',
|
||||
email: defaultValues?.email ?? '',
|
||||
phone: defaultValues?.phone ?? '',
|
||||
website: defaultValues?.website ?? '',
|
||||
accountNumber: defaultValues?.accountNumber ?? '',
|
||||
paymentTerms: defaultValues?.paymentTerms ?? '',
|
||||
},
|
||||
})
|
||||
|
||||
function handleFormSubmit(data: Record<string, string>) {
|
||||
onSubmit({
|
||||
name: data.name,
|
||||
contactName: data.contactName || undefined,
|
||||
email: data.email || undefined,
|
||||
phone: data.phone || undefined,
|
||||
website: data.website || undefined,
|
||||
accountNumber: data.accountNumber || undefined,
|
||||
paymentTerms: data.paymentTerms || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sup-name">Name *</Label>
|
||||
<Input id="sup-name" {...register('name')} placeholder="e.g. Fender Musical Instruments" required />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sup-contact">Contact Name</Label>
|
||||
<Input id="sup-contact" {...register('contactName')} placeholder="Jane Smith" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sup-email">Email</Label>
|
||||
<Input id="sup-email" type="email" {...register('email')} placeholder="orders@supplier.com" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sup-phone">Phone</Label>
|
||||
<Input id="sup-phone" {...register('phone')} placeholder="555-0100" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sup-website">Website</Label>
|
||||
<Input id="sup-website" {...register('website')} placeholder="https://supplier.com" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sup-acct">Account Number</Label>
|
||||
<Input id="sup-acct" {...register('accountNumber')} placeholder="ACC-12345" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sup-terms">Payment Terms</Label>
|
||||
<Input id="sup-terms" {...register('paymentTerms')} placeholder="Net 30" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button type="submit" disabled={loading} className="flex-1">
|
||||
{loading ? 'Saving...' : defaultValues?.id ? 'Save Changes' : 'Create Supplier'}
|
||||
</Button>
|
||||
{onDelete && (
|
||||
<Button type="button" variant="destructive" disabled={deleteLoading} onClick={onDelete}>
|
||||
{deleteLoading ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
45
packages/admin/src/components/lessons/blocked-date-form.tsx
Normal file
45
packages/admin/src/components/lessons/blocked-date-form.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
interface Props {
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function BlockedDateForm({ onSubmit, loading }: Props) {
|
||||
const { register, handleSubmit } = useForm({
|
||||
defaultValues: { startDate: '', endDate: '', reason: '' },
|
||||
})
|
||||
|
||||
function handleFormSubmit(data: { startDate: string; endDate: string; reason: string }) {
|
||||
onSubmit({
|
||||
startDate: data.startDate,
|
||||
endDate: data.endDate,
|
||||
reason: data.reason || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bd-start">Start Date *</Label>
|
||||
<Input id="bd-start" type="date" {...register('startDate')} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bd-end">End Date *</Label>
|
||||
<Input id="bd-end" type="date" {...register('endDate')} required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bd-reason">Reason</Label>
|
||||
<Input id="bd-reason" {...register('reason')} placeholder="e.g. Vacation, Conference" />
|
||||
</div>
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? 'Saving...' : 'Add Blocked Date'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
127
packages/admin/src/components/lessons/grade-entry-dialog.tsx
Normal file
127
packages/admin/src/components/lessons/grade-entry-dialog.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { gradingScaleAllOptions, lessonPlanItemMutations, lessonPlanItemGradeHistoryOptions, lessonPlanItemKeys } from '@/api/lessons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { toast } from 'sonner'
|
||||
import type { LessonPlanItem } from '@/types/lesson'
|
||||
|
||||
interface Props {
|
||||
item: LessonPlanItem
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function GradeEntryDialog({ item, open, onClose }: Props) {
|
||||
const queryClient = useQueryClient()
|
||||
const [selectedScaleId, setSelectedScaleId] = useState(item.gradingScaleId ?? '')
|
||||
const [selectedValue, setSelectedValue] = useState('')
|
||||
const [gradeNotes, setGradeNotes] = useState('')
|
||||
|
||||
const { data: scales } = useQuery(gradingScaleAllOptions())
|
||||
const { data: history } = useQuery(lessonPlanItemGradeHistoryOptions(item.id))
|
||||
|
||||
const selectedScale = scales?.find((s) => s.id === selectedScaleId)
|
||||
|
||||
const gradeMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
lessonPlanItemMutations.addGrade(item.id, {
|
||||
gradingScaleId: selectedScaleId || undefined,
|
||||
gradeValue: selectedValue,
|
||||
notes: gradeNotes || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: lessonPlanItemKeys.gradeHistory(item.id) })
|
||||
toast.success('Grade recorded')
|
||||
setSelectedValue('')
|
||||
setGradeNotes('')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose() }}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Grade: {item.title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Grading Scale</Label>
|
||||
<Select value={selectedScaleId || 'none'} onValueChange={(v) => { setSelectedScaleId(v === 'none' ? '' : v); setSelectedValue('') }}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No scale (freeform)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No scale (freeform)</SelectItem>
|
||||
{(scales ?? []).map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Grade Value *</Label>
|
||||
{selectedScale ? (
|
||||
<Select value={selectedValue} onValueChange={setSelectedValue}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select grade..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[...selectedScale.levels].sort((a, b) => a.sortOrder - b.sortOrder).map((level) => (
|
||||
<SelectItem key={level.id} value={level.value}>
|
||||
{level.value} — {level.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<input
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
value={selectedValue}
|
||||
onChange={(e) => setSelectedValue(e.target.value)}
|
||||
placeholder="Enter grade (e.g. A, Pass, 85)"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Notes</Label>
|
||||
<Textarea value={gradeNotes} onChange={(e) => setGradeNotes(e.target.value)} rows={2} />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => gradeMutation.mutate()}
|
||||
disabled={!selectedValue || gradeMutation.isPending}
|
||||
className="w-full"
|
||||
>
|
||||
{gradeMutation.isPending ? 'Recording...' : 'Record Grade'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Grade History */}
|
||||
{(history ?? []).length > 0 && (
|
||||
<div className="border-t pt-4 space-y-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">Grade History</p>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
{[...history!].reverse().map((h) => (
|
||||
<div key={h.id} className="flex items-start justify-between text-sm">
|
||||
<div>
|
||||
<span className="font-medium">{h.gradeValue}</span>
|
||||
{h.notes && <p className="text-xs text-muted-foreground">{h.notes}</p>}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{new Date(h.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
127
packages/admin/src/components/lessons/grading-scale-form.tsx
Normal file
127
packages/admin/src/components/lessons/grading-scale-form.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Trash2, Plus } from 'lucide-react'
|
||||
|
||||
interface LevelRow {
|
||||
value: string
|
||||
label: string
|
||||
numericValue: string
|
||||
colorHex: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_LEVELS: LevelRow[] = [
|
||||
{ value: 'A', label: 'Excellent', numericValue: '4', colorHex: '#22c55e' },
|
||||
{ value: 'B', label: 'Good', numericValue: '3', colorHex: '#84cc16' },
|
||||
{ value: 'C', label: 'Developing', numericValue: '2', colorHex: '#eab308' },
|
||||
{ value: 'D', label: 'Beginning', numericValue: '1', colorHex: '#f97316' },
|
||||
]
|
||||
|
||||
export function GradingScaleForm({ onSubmit, loading }: Props) {
|
||||
const { register, handleSubmit } = useForm({
|
||||
defaultValues: { name: '', description: '', isDefault: false },
|
||||
})
|
||||
const [levels, setLevels] = useState<LevelRow[]>(DEFAULT_LEVELS)
|
||||
|
||||
function addLevel() {
|
||||
setLevels((prev) => [...prev, { value: '', label: '', numericValue: String(prev.length + 1), colorHex: '' }])
|
||||
}
|
||||
|
||||
function removeLevel(idx: number) {
|
||||
setLevels((prev) => prev.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
function updateLevel(idx: number, field: keyof LevelRow, value: string) {
|
||||
setLevels((prev) => prev.map((l, i) => (i === idx ? { ...l, [field]: value } : l)))
|
||||
}
|
||||
|
||||
function handleFormSubmit(data: { name: string; description: string; isDefault: boolean }) {
|
||||
onSubmit({
|
||||
name: data.name,
|
||||
description: data.description || undefined,
|
||||
isDefault: data.isDefault,
|
||||
levels: levels.map((l, i) => ({
|
||||
value: l.value,
|
||||
label: l.label,
|
||||
numericValue: Number(l.numericValue) || i + 1,
|
||||
colorHex: l.colorHex || undefined,
|
||||
sortOrder: i,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gs-name">Name *</Label>
|
||||
<Input id="gs-name" {...register('name')} placeholder="e.g. RCM Performance Scale" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gs-desc">Description</Label>
|
||||
<Textarea id="gs-desc" {...register('description')} rows={2} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" id="gs-default" {...register('isDefault')} className="h-4 w-4" />
|
||||
<Label htmlFor="gs-default">Set as default scale</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Grade Levels</Label>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addLevel}>
|
||||
<Plus className="h-3 w-3 mr-1" />Add Level
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto pr-1">
|
||||
{levels.map((level, idx) => (
|
||||
<div key={idx} className="grid grid-cols-[1fr_2fr_1fr_auto_auto] gap-2 items-center">
|
||||
<Input
|
||||
placeholder="Value"
|
||||
value={level.value}
|
||||
onChange={(e) => updateLevel(idx, 'value', e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
placeholder="Label"
|
||||
value={level.label}
|
||||
onChange={(e) => updateLevel(idx, 'label', e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Score"
|
||||
value={level.numericValue}
|
||||
onChange={(e) => updateLevel(idx, 'numericValue', e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
value={level.colorHex || '#888888'}
|
||||
onChange={(e) => updateLevel(idx, 'colorHex', e.target.value)}
|
||||
className="h-9 w-9 rounded border border-input cursor-pointer"
|
||||
title="Color"
|
||||
/>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeLevel(idx)} className="h-9 w-9">
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{levels.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-2">No levels — add at least one.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={loading || levels.length === 0} className="w-full">
|
||||
{loading ? 'Saving...' : 'Create Grading Scale'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
52
packages/admin/src/components/lessons/instructor-form.tsx
Normal file
52
packages/admin/src/components/lessons/instructor-form.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import type { Instructor } from '@/types/lesson'
|
||||
|
||||
interface Props {
|
||||
defaultValues?: Partial<Instructor>
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function InstructorForm({ defaultValues, onSubmit, loading }: Props) {
|
||||
const { register, handleSubmit } = useForm({
|
||||
defaultValues: {
|
||||
displayName: defaultValues?.displayName ?? '',
|
||||
bio: defaultValues?.bio ?? '',
|
||||
instruments: defaultValues?.instruments?.join(', ') ?? '',
|
||||
},
|
||||
})
|
||||
|
||||
function handleFormSubmit(data: { displayName: string; bio: string; instruments: string }) {
|
||||
onSubmit({
|
||||
displayName: data.displayName,
|
||||
bio: data.bio || undefined,
|
||||
instruments: data.instruments
|
||||
? data.instruments.split(',').map((s) => s.trim()).filter(Boolean)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="displayName">Display Name *</Label>
|
||||
<Input id="displayName" {...register('displayName')} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bio">Bio</Label>
|
||||
<Textarea id="bio" {...register('bio')} rows={3} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="instruments">Instruments</Label>
|
||||
<Input id="instruments" {...register('instruments')} placeholder="Piano, Guitar, Voice (comma-separated)" />
|
||||
</div>
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? 'Saving...' : defaultValues ? 'Save Changes' : 'Create Instructor'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
99
packages/admin/src/components/lessons/lesson-type-form.tsx
Normal file
99
packages/admin/src/components/lessons/lesson-type-form.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import type { LessonType } from '@/types/lesson'
|
||||
|
||||
interface Props {
|
||||
defaultValues?: Partial<LessonType>
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function LessonTypeForm({ defaultValues, onSubmit, loading }: Props) {
|
||||
const { register, handleSubmit, setValue, watch } = useForm({
|
||||
defaultValues: {
|
||||
name: defaultValues?.name ?? '',
|
||||
instrument: defaultValues?.instrument ?? '',
|
||||
durationMinutes: defaultValues?.durationMinutes ?? 30,
|
||||
lessonFormat: (defaultValues?.lessonFormat ?? 'private') as 'private' | 'group',
|
||||
rateWeekly: defaultValues?.rateWeekly ?? '',
|
||||
rateMonthly: defaultValues?.rateMonthly ?? '',
|
||||
rateQuarterly: defaultValues?.rateQuarterly ?? '',
|
||||
},
|
||||
})
|
||||
|
||||
const lessonFormat = watch('lessonFormat')
|
||||
|
||||
function handleFormSubmit(data: {
|
||||
name: string
|
||||
instrument: string
|
||||
durationMinutes: number
|
||||
lessonFormat: string
|
||||
rateWeekly: string
|
||||
rateMonthly: string
|
||||
rateQuarterly: string
|
||||
}) {
|
||||
onSubmit({
|
||||
name: data.name,
|
||||
instrument: data.instrument || undefined,
|
||||
durationMinutes: Number(data.durationMinutes),
|
||||
lessonFormat: data.lessonFormat,
|
||||
rateWeekly: data.rateWeekly || undefined,
|
||||
rateMonthly: data.rateMonthly || undefined,
|
||||
rateQuarterly: data.rateQuarterly || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lt-name">Name *</Label>
|
||||
<Input id="lt-name" {...register('name')} placeholder="e.g. Piano — 30 min Private" required />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lt-instrument">Instrument</Label>
|
||||
<Input id="lt-instrument" {...register('instrument')} placeholder="e.g. Piano, Guitar" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lt-duration">Duration (minutes) *</Label>
|
||||
<Input id="lt-duration" type="number" min={5} step={5} {...register('durationMinutes')} required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Format *</Label>
|
||||
<Select value={lessonFormat} onValueChange={(v) => setValue('lessonFormat', v as 'private' | 'group')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="private">Private</SelectItem>
|
||||
<SelectItem value="group">Group</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="block mb-2">Default Rates (optional)</Label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="lt-rate-weekly" className="text-xs text-muted-foreground">Weekly</Label>
|
||||
<Input id="lt-rate-weekly" type="number" step="0.01" min="0" {...register('rateWeekly')} placeholder="—" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="lt-rate-monthly" className="text-xs text-muted-foreground">Monthly</Label>
|
||||
<Input id="lt-rate-monthly" type="number" step="0.01" min="0" {...register('rateMonthly')} placeholder="—" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="lt-rate-quarterly" className="text-xs text-muted-foreground">Quarterly</Label>
|
||||
<Input id="lt-rate-quarterly" type="number" step="0.01" min="0" {...register('rateQuarterly')} placeholder="—" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? 'Saving...' : defaultValues?.id ? 'Save Changes' : 'Create Lesson Type'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
124
packages/admin/src/components/lessons/schedule-slot-form.tsx
Normal file
124
packages/admin/src/components/lessons/schedule-slot-form.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import type { LessonType, ScheduleSlot } from '@/types/lesson'
|
||||
|
||||
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
||||
|
||||
interface Props {
|
||||
lessonTypes: LessonType[]
|
||||
defaultValues?: Partial<ScheduleSlot>
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function ScheduleSlotForm({ lessonTypes, defaultValues, onSubmit, loading }: Props) {
|
||||
const { register, handleSubmit, setValue, watch } = useForm({
|
||||
defaultValues: {
|
||||
dayOfWeek: String(defaultValues?.dayOfWeek ?? 1),
|
||||
startTime: defaultValues?.startTime ?? '',
|
||||
lessonTypeId: defaultValues?.lessonTypeId ?? '',
|
||||
room: defaultValues?.room ?? '',
|
||||
maxStudents: String(defaultValues?.maxStudents ?? 1),
|
||||
rateWeekly: defaultValues?.rateWeekly ?? '',
|
||||
rateMonthly: defaultValues?.rateMonthly ?? '',
|
||||
rateQuarterly: defaultValues?.rateQuarterly ?? '',
|
||||
},
|
||||
})
|
||||
|
||||
const dayOfWeek = watch('dayOfWeek')
|
||||
const lessonTypeId = watch('lessonTypeId')
|
||||
|
||||
function handleFormSubmit(data: {
|
||||
dayOfWeek: string
|
||||
startTime: string
|
||||
lessonTypeId: string
|
||||
room: string
|
||||
maxStudents: string
|
||||
rateWeekly: string
|
||||
rateMonthly: string
|
||||
rateQuarterly: string
|
||||
}) {
|
||||
onSubmit({
|
||||
dayOfWeek: Number(data.dayOfWeek),
|
||||
startTime: data.startTime,
|
||||
lessonTypeId: data.lessonTypeId,
|
||||
room: data.room || undefined,
|
||||
maxStudents: Number(data.maxStudents) || 1,
|
||||
rateWeekly: data.rateWeekly || undefined,
|
||||
rateMonthly: data.rateMonthly || undefined,
|
||||
rateQuarterly: data.rateQuarterly || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Day *</Label>
|
||||
<Select value={dayOfWeek} onValueChange={(v) => setValue('dayOfWeek', v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DAYS.map((day, i) => (
|
||||
<SelectItem key={i} value={String(i)}>{day}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slot-time">Start Time *</Label>
|
||||
<Input id="slot-time" type="time" {...register('startTime')} required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Lesson Type *</Label>
|
||||
<Select value={lessonTypeId} onValueChange={(v) => setValue('lessonTypeId', v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select lesson type..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{lessonTypes.map((lt) => (
|
||||
<SelectItem key={lt.id} value={lt.id}>
|
||||
{lt.name} ({lt.durationMinutes} min)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slot-room">Room</Label>
|
||||
<Input id="slot-room" {...register('room')} placeholder="e.g. Studio A" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slot-max">Max Students</Label>
|
||||
<Input id="slot-max" type="number" min={1} {...register('maxStudents')} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="block mb-2">Instructor Rates (override lesson type defaults)</Label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="slot-rate-weekly" className="text-xs text-muted-foreground">Weekly</Label>
|
||||
<Input id="slot-rate-weekly" type="number" step="0.01" min="0" {...register('rateWeekly')} placeholder="—" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="slot-rate-monthly" className="text-xs text-muted-foreground">Monthly</Label>
|
||||
<Input id="slot-rate-monthly" type="number" step="0.01" min="0" {...register('rateMonthly')} placeholder="—" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="slot-rate-quarterly" className="text-xs text-muted-foreground">Quarterly</Label>
|
||||
<Input id="slot-rate-quarterly" type="number" step="0.01" min="0" {...register('rateQuarterly')} placeholder="—" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" disabled={loading || !lessonTypeId} className="w-full">
|
||||
{loading ? 'Saving...' : defaultValues?.id ? 'Save Changes' : 'Add Slot'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
41
packages/admin/src/components/lessons/store-closure-form.tsx
Normal file
41
packages/admin/src/components/lessons/store-closure-form.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
interface Props {
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function StoreClosureForm({ onSubmit, loading }: Props) {
|
||||
const { register, handleSubmit } = useForm({
|
||||
defaultValues: { name: '', startDate: '', endDate: '' },
|
||||
})
|
||||
|
||||
function handleFormSubmit(data: { name: string; startDate: string; endDate: string }) {
|
||||
onSubmit({ name: data.name, startDate: data.startDate, endDate: data.endDate })
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="closure-name">Name *</Label>
|
||||
<Input id="closure-name" {...register('name')} placeholder="e.g. Thanksgiving Break" required />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="closure-start">Start Date *</Label>
|
||||
<Input id="closure-start" type="date" {...register('startDate')} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="closure-end">End Date *</Label>
|
||||
<Input id="closure-end" type="date" {...register('endDate')} required />
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? 'Saving...' : 'Add Closure'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Trash2, Plus, ChevronUp, ChevronDown } from 'lucide-react'
|
||||
|
||||
interface TemplateItemRow {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface TemplateSectionRow {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
items: TemplateItemRow[]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
sections: TemplateSectionRow[]
|
||||
onChange: (sections: TemplateSectionRow[]) => void
|
||||
}
|
||||
|
||||
function uid() {
|
||||
return Math.random().toString(36).slice(2)
|
||||
}
|
||||
|
||||
export function TemplateSectionBuilder({ sections, onChange }: Props) {
|
||||
function addSection() {
|
||||
onChange([...sections, { id: uid(), title: '', description: '', items: [] }])
|
||||
}
|
||||
|
||||
function removeSection(idx: number) {
|
||||
onChange(sections.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
function moveSection(idx: number, dir: -1 | 1) {
|
||||
const next = [...sections]
|
||||
const [removed] = next.splice(idx, 1)
|
||||
next.splice(idx + dir, 0, removed)
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
function updateSection(idx: number, field: 'title' | 'description', value: string) {
|
||||
onChange(sections.map((s, i) => (i === idx ? { ...s, [field]: value } : s)))
|
||||
}
|
||||
|
||||
function addItem(sIdx: number) {
|
||||
onChange(sections.map((s, i) =>
|
||||
i === sIdx ? { ...s, items: [...s.items, { id: uid(), title: '', description: '' }] } : s,
|
||||
))
|
||||
}
|
||||
|
||||
function removeItem(sIdx: number, iIdx: number) {
|
||||
onChange(sections.map((s, i) =>
|
||||
i === sIdx ? { ...s, items: s.items.filter((_, j) => j !== iIdx) } : s,
|
||||
))
|
||||
}
|
||||
|
||||
function moveItem(sIdx: number, iIdx: number, dir: -1 | 1) {
|
||||
onChange(sections.map((s, i) => {
|
||||
if (i !== sIdx) return s
|
||||
const next = [...s.items]
|
||||
const [removed] = next.splice(iIdx, 1)
|
||||
next.splice(iIdx + dir, 0, removed)
|
||||
return { ...s, items: next }
|
||||
}))
|
||||
}
|
||||
|
||||
function updateItem(sIdx: number, iIdx: number, field: 'title' | 'description', value: string) {
|
||||
onChange(sections.map((s, i) =>
|
||||
i === sIdx
|
||||
? { ...s, items: s.items.map((item, j) => (j === iIdx ? { ...item, [field]: value } : item)) }
|
||||
: s,
|
||||
))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{sections.map((section, sIdx) => (
|
||||
<div key={section.id} className="border rounded-lg overflow-hidden">
|
||||
<div className="bg-muted/40 px-3 py-2 flex items-center gap-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Button type="button" variant="ghost" size="icon" className="h-4 w-4" disabled={sIdx === 0} onClick={() => moveSection(sIdx, -1)}>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-4 w-4" disabled={sIdx === sections.length - 1} onClick={() => moveSection(sIdx, 1)}>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
className="h-7 text-sm font-medium flex-1"
|
||||
placeholder="Section title *"
|
||||
value={section.title}
|
||||
onChange={(e) => updateSection(sIdx, 'title', e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-7 w-7" onClick={() => removeSection(sIdx)}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-3 space-y-2">
|
||||
<Textarea
|
||||
className="text-xs resize-none"
|
||||
placeholder="Section description (optional)"
|
||||
rows={1}
|
||||
value={section.description}
|
||||
onChange={(e) => updateSection(sIdx, 'description', e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{section.items.map((item, iIdx) => (
|
||||
<div key={item.id} className="flex items-center gap-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Button type="button" variant="ghost" size="icon" className="h-4 w-4" disabled={iIdx === 0} onClick={() => moveItem(sIdx, iIdx, -1)}>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-4 w-4" disabled={iIdx === section.items.length - 1} onClick={() => moveItem(sIdx, iIdx, 1)}>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
className="h-7 text-xs flex-1"
|
||||
placeholder="Item title *"
|
||||
value={item.title}
|
||||
onChange={(e) => updateItem(sIdx, iIdx, 'title', e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
className="h-7 text-xs flex-1"
|
||||
placeholder="Description (optional)"
|
||||
value={item.description}
|
||||
onChange={(e) => updateItem(sIdx, iIdx, 'description', e.target.value)}
|
||||
/>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => removeItem(sIdx, iIdx)}>
|
||||
<Trash2 className="h-3 w-3 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button type="button" variant="outline" size="sm" className="text-xs h-7" onClick={() => addItem(sIdx)}>
|
||||
<Plus className="h-3 w-3 mr-1" />Add Item
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button type="button" variant="outline" size="sm" onClick={addSection}>
|
||||
<Plus className="h-4 w-4 mr-1" />Add Section
|
||||
</Button>
|
||||
|
||||
{sections.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-2">No sections yet — add one above.</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type { TemplateSectionRow, TemplateItemRow }
|
||||
87
packages/admin/src/components/lessons/weekly-slot-grid.tsx
Normal file
87
packages/admin/src/components/lessons/weekly-slot-grid.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Pencil, Trash2 } from 'lucide-react'
|
||||
import type { ScheduleSlot, LessonType } from '@/types/lesson'
|
||||
|
||||
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
|
||||
interface Props {
|
||||
slots: ScheduleSlot[]
|
||||
lessonTypes: LessonType[]
|
||||
onEdit: (slot: ScheduleSlot) => void
|
||||
onDelete: (slot: ScheduleSlot) => void
|
||||
}
|
||||
|
||||
function formatTime(t: string) {
|
||||
const [h, m] = t.split(':').map(Number)
|
||||
const ampm = h >= 12 ? 'PM' : 'AM'
|
||||
const hour = h % 12 || 12
|
||||
return `${hour}:${String(m).padStart(2, '0')} ${ampm}`
|
||||
}
|
||||
|
||||
export function WeeklySlotGrid({ slots, lessonTypes, onEdit, onDelete }: Props) {
|
||||
const ltMap = new Map(lessonTypes.map((lt) => [lt.id, lt]))
|
||||
|
||||
const slotsByDay = DAYS.map((_, day) =>
|
||||
slots.filter((s) => s.dayOfWeek === day).sort((a, b) => a.startTime.localeCompare(b.startTime)),
|
||||
)
|
||||
|
||||
const hasAny = slots.length > 0
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{DAYS.map((day, idx) => (
|
||||
<div key={day} className="min-h-[120px]">
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide text-center mb-2 py-1 border-b">
|
||||
{day}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{slotsByDay[idx].map((slot) => {
|
||||
const lt = ltMap.get(slot.lessonTypeId)
|
||||
return (
|
||||
<div
|
||||
key={slot.id}
|
||||
className="bg-sidebar-accent rounded-md p-2 text-xs group relative"
|
||||
>
|
||||
<div className="font-medium">{formatTime(slot.startTime)}</div>
|
||||
<div className="text-muted-foreground truncate">{lt?.name ?? 'Unknown'}</div>
|
||||
{slot.room && <div className="text-muted-foreground">{slot.room}</div>}
|
||||
{lt && (
|
||||
<Badge variant="outline" className="mt-1 text-[10px] py-0">
|
||||
{lt.lessonFormat}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="absolute top-1 right-1 hidden group-hover:flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={() => onEdit(slot)}
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={() => onDelete(slot)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="h-3 w-3 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!hasAny && (
|
||||
<div className="col-span-7 text-center text-sm text-muted-foreground py-8">
|
||||
No schedule slots yet — add one to get started.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
522
packages/admin/src/components/pos/pos-cart-panel.tsx
Normal file
522
packages/admin/src/components/pos/pos-cart-panel.tsx
Normal file
@@ -0,0 +1,522 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { usePOSStore } from '@/stores/pos.store'
|
||||
import { posMutations, posKeys, type Transaction } from '@/api/pos'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { X, Banknote, CreditCard, FileText, Ban, UserRound, Tag } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useState } from 'react'
|
||||
import { POSPaymentDialog } from './pos-payment-dialog'
|
||||
import { POSCustomerDialog } from './pos-customer-dialog'
|
||||
import { ManagerOverrideDialog, requiresOverride, requiresDiscountOverride } from './pos-manager-override'
|
||||
import type { TransactionLineItem } from '@/api/pos'
|
||||
|
||||
interface POSCartPanelProps {
|
||||
transaction: Transaction | null
|
||||
}
|
||||
|
||||
export function POSCartPanel({ transaction }: POSCartPanelProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const { currentTransactionId, setTransaction, accountName, accountPhone, accountEmail } = usePOSStore()
|
||||
const [paymentMethod, setPaymentMethod] = useState<string | null>(null)
|
||||
const [customerOpen, setCustomerOpen] = useState(false)
|
||||
const [overrideOpen, setOverrideOpen] = useState(false)
|
||||
const [priceItemId, setPriceItemId] = useState<string | null>(null)
|
||||
const [pendingDiscount, setPendingDiscount] = useState<{ lineItemId: string; amount: number; reason: string } | null>(null)
|
||||
const [pendingOrderDiscount, setPendingOrderDiscount] = useState<{ amount: number; reason: string } | null>(null)
|
||||
const [discountOverrideOpen, setDiscountOverrideOpen] = useState(false)
|
||||
const lineItems = transaction?.lineItems ?? []
|
||||
|
||||
const drawerSessionId = usePOSStore((s) => s.drawerSessionId)
|
||||
const drawerOpen = !!drawerSessionId
|
||||
|
||||
const removeItemMutation = useMutation({
|
||||
mutationFn: (lineItemId: string) =>
|
||||
posMutations.removeLineItem(currentTransactionId!, lineItemId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: posKeys.transaction(currentTransactionId!) })
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const discountMutation = useMutation({
|
||||
mutationFn: (data: { lineItemId: string; amount: number; reason: string }) =>
|
||||
posMutations.applyDiscount(currentTransactionId!, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: posKeys.transaction(currentTransactionId!) })
|
||||
setPriceItemId(null)
|
||||
toast.success('Price adjusted')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const orderDiscountMutation = useMutation({
|
||||
mutationFn: async ({ amount, reason }: { amount: number; reason: string }) => {
|
||||
// Distribute discount proportionally across all line items
|
||||
let remaining = amount
|
||||
for (let i = 0; i < lineItems.length; i++) {
|
||||
const item = lineItems[i]
|
||||
const itemTotal = parseFloat(item.unitPrice) * item.qty
|
||||
const isLast = i === lineItems.length - 1
|
||||
const share = isLast ? remaining : Math.round((itemTotal / subtotal) * amount * 100) / 100
|
||||
remaining -= share
|
||||
if (share > 0) {
|
||||
await posMutations.applyDiscount(currentTransactionId!, { lineItemId: item.id, amount: share, reason })
|
||||
}
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: posKeys.transaction(currentTransactionId!) })
|
||||
toast.success('Order discount applied')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const voidMutation = useMutation({
|
||||
mutationFn: () => posMutations.void(currentTransactionId!),
|
||||
onSuccess: () => {
|
||||
setTransaction(null)
|
||||
toast.success('Transaction voided')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const subtotal = parseFloat(transaction?.subtotal ?? '0')
|
||||
const discountTotal = parseFloat(transaction?.discountTotal ?? '0')
|
||||
const taxTotal = parseFloat(transaction?.taxTotal ?? '0')
|
||||
const total = parseFloat(transaction?.total ?? '0')
|
||||
const hasItems = lineItems.length > 0
|
||||
const isPending = transaction?.status === 'pending'
|
||||
|
||||
function handlePaymentComplete() {
|
||||
setPaymentMethod(null)
|
||||
setTransaction(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-card">
|
||||
{/* Header */}
|
||||
<div className="p-3 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold text-base">Current Sale</h2>
|
||||
{transaction && (
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{transaction.transactionNumber}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setCustomerOpen(true)}
|
||||
className="flex items-start gap-1.5 mt-1 text-xs text-muted-foreground hover:text-foreground text-left"
|
||||
>
|
||||
<UserRound className="h-3 w-3 mt-0.5 shrink-0" />
|
||||
{accountName ? (
|
||||
<span>
|
||||
<span className="font-medium text-foreground">{accountName}</span>
|
||||
{(accountPhone || accountEmail) && (
|
||||
<span className="block text-[11px]">
|
||||
{[accountPhone, accountEmail].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span>Walk-in — tap to add customer</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Line items */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{lineItems.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
No items yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{lineItems.map((item) => {
|
||||
const unitPrice = parseFloat(item.unitPrice)
|
||||
const discount = parseFloat(item.discountAmount)
|
||||
const hasDiscount = discount > 0
|
||||
const listTotal = unitPrice * item.qty
|
||||
const discountPct = listTotal > 0 ? Math.round((discount / listTotal) * 100) : 0
|
||||
|
||||
return (
|
||||
<div key={item.id} className="flex items-center gap-2 px-3 py-2.5 group">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{item.description}</p>
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-2">
|
||||
<span>{item.qty} x ${unitPrice.toFixed(2)}</span>
|
||||
{hasDiscount && (
|
||||
<span className="text-green-600">-${discount.toFixed(2)} ({discountPct}%)</span>
|
||||
)}
|
||||
{parseFloat(item.taxAmount) > 0 && (
|
||||
<span>tax ${parseFloat(item.taxAmount).toFixed(2)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-medium tabular-nums">
|
||||
${parseFloat(item.lineTotal).toFixed(2)}
|
||||
</span>
|
||||
{isPending && (
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 shrink-0">
|
||||
<PriceAdjuster
|
||||
item={item}
|
||||
open={priceItemId === item.id}
|
||||
onOpenChange={(o) => setPriceItemId(o ? item.id : null)}
|
||||
onApply={(amount, reason) => {
|
||||
const pct = listTotal > 0 ? (amount / listTotal) * 100 : 0
|
||||
if (requiresDiscountOverride(pct)) {
|
||||
setPendingDiscount({ lineItemId: item.id, amount, reason })
|
||||
setDiscountOverrideOpen(true)
|
||||
} else {
|
||||
discountMutation.mutate({ lineItemId: item.id, amount, reason })
|
||||
}
|
||||
}}
|
||||
isPending={discountMutation.isPending}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => removeItemMutation.mutate(item.id)}
|
||||
disabled={removeItemMutation.isPending}
|
||||
>
|
||||
<X className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Totals + payment */}
|
||||
<div className="shrink-0 border-t border-border">
|
||||
<div className="px-3 py-2 space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Subtotal</span>
|
||||
<span className="tabular-nums">${subtotal.toFixed(2)}</span>
|
||||
</div>
|
||||
{discountTotal > 0 && (
|
||||
<div className="flex justify-between text-green-600">
|
||||
<span>Discount</span>
|
||||
<span className="tabular-nums">-${discountTotal.toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Tax</span>
|
||||
<span className="tabular-nums">${taxTotal.toFixed(2)}</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-between text-lg font-bold pt-1">
|
||||
<span>Total</span>
|
||||
<span className="tabular-nums">${total.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Order discount button */}
|
||||
{hasItems && isPending && (
|
||||
<div className="px-3 pb-1">
|
||||
<OrderDiscountButton
|
||||
subtotal={subtotal}
|
||||
onApply={(amount, reason) => {
|
||||
const pct = subtotal > 0 ? (amount / subtotal) * 100 : 0
|
||||
if (requiresDiscountOverride(pct)) {
|
||||
setPendingOrderDiscount({ amount, reason })
|
||||
setDiscountOverrideOpen(true)
|
||||
} else {
|
||||
orderDiscountMutation.mutate({ amount, reason })
|
||||
}
|
||||
}}
|
||||
isPending={orderDiscountMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment buttons */}
|
||||
<div className="p-3 space-y-2">
|
||||
{!drawerOpen && hasItems && (
|
||||
<p className="text-xs text-destructive text-center">Open the drawer before accepting payment</p>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
className="h-12 text-sm gap-2"
|
||||
disabled={!hasItems || !isPending || !drawerOpen}
|
||||
onClick={() => setPaymentMethod('cash')}
|
||||
>
|
||||
<Banknote className="h-4 w-4" />
|
||||
Cash
|
||||
</Button>
|
||||
<Button
|
||||
className="h-12 text-sm gap-2"
|
||||
disabled={!hasItems || !isPending || !drawerOpen}
|
||||
onClick={() => setPaymentMethod('card_present')}
|
||||
>
|
||||
<CreditCard className="h-4 w-4" />
|
||||
Card
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-12 text-sm gap-2"
|
||||
disabled={!hasItems || !isPending || !drawerOpen}
|
||||
onClick={() => setPaymentMethod('check')}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
Check
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="h-12 text-sm gap-2"
|
||||
disabled={!hasItems || !isPending}
|
||||
onClick={() => {
|
||||
if (requiresOverride('void_transaction')) {
|
||||
setOverrideOpen(true)
|
||||
} else {
|
||||
voidMutation.mutate()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Ban className="h-4 w-4" />
|
||||
Void
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment dialog */}
|
||||
{paymentMethod && transaction && (
|
||||
<POSPaymentDialog
|
||||
open={!!paymentMethod}
|
||||
onOpenChange={(open) => { if (!open) setPaymentMethod(null) }}
|
||||
paymentMethod={paymentMethod}
|
||||
transaction={transaction}
|
||||
onComplete={handlePaymentComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Customer dialog */}
|
||||
<POSCustomerDialog open={customerOpen} onOpenChange={setCustomerOpen} />
|
||||
|
||||
{/* Manager override for void */}
|
||||
<ManagerOverrideDialog
|
||||
open={overrideOpen}
|
||||
onOpenChange={setOverrideOpen}
|
||||
action="Void transaction"
|
||||
onAuthorized={() => voidMutation.mutate()}
|
||||
/>
|
||||
|
||||
{/* Manager override for discount */}
|
||||
<ManagerOverrideDialog
|
||||
open={discountOverrideOpen}
|
||||
onOpenChange={setDiscountOverrideOpen}
|
||||
action="Price adjustment"
|
||||
onAuthorized={() => {
|
||||
if (pendingDiscount) {
|
||||
discountMutation.mutate(pendingDiscount)
|
||||
setPendingDiscount(null)
|
||||
} else if (pendingOrderDiscount) {
|
||||
orderDiscountMutation.mutate(pendingOrderDiscount)
|
||||
setPendingOrderDiscount(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Order Discount Button ---
|
||||
|
||||
function OrderDiscountButton({ subtotal, onApply, isPending }: {
|
||||
subtotal: number
|
||||
onApply: (amount: number, reason: string) => void
|
||||
isPending: boolean
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [mode, setMode] = useState<AdjustMode>('percent')
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
function calculate() {
|
||||
const v = parseFloat(value) || 0
|
||||
if (mode === 'amount_off') return Math.min(v, subtotal)
|
||||
if (mode === 'set_price') return Math.max(0, subtotal - v)
|
||||
return Math.round(subtotal * (v / 100) * 100) / 100
|
||||
}
|
||||
|
||||
const discountAmount = calculate()
|
||||
|
||||
function handleApply() {
|
||||
if (discountAmount <= 0) return
|
||||
const reason = mode === 'percent' ? `${parseFloat(value)}% order discount` : mode === 'set_price' ? `Order total set to $${parseFloat(value).toFixed(2)}` : `$${parseFloat(value).toFixed(2)} order discount`
|
||||
onApply(discountAmount, reason)
|
||||
setValue('')
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={(o) => { setOpen(o); if (!o) setValue('') }}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="w-full gap-2 text-xs h-8">
|
||||
<Tag className="h-3 w-3" />Order Discount
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-3" side="top" align="center">
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Subtotal: <span className="font-medium text-foreground">${subtotal.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex rounded-md border overflow-hidden text-xs">
|
||||
{([
|
||||
{ key: 'percent' as const, label: '% Off' },
|
||||
{ key: 'amount_off' as const, label: '$ Off' },
|
||||
{ key: 'set_price' as const, label: 'Set Total' },
|
||||
]).map((m) => (
|
||||
<button
|
||||
key={m.key}
|
||||
type="button"
|
||||
className={`flex-1 py-1.5 transition-colors ${mode === m.key ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'}`}
|
||||
onClick={() => { setMode(m.key); setValue('') }}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder={mode === 'percent' ? 'e.g. 10' : '0.00'}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
className="h-9"
|
||||
autoFocus
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleApply() }}
|
||||
/>
|
||||
{value && discountAmount > 0 && (
|
||||
<div className="text-xs flex justify-between font-medium">
|
||||
<span className="text-green-600">-${discountAmount.toFixed(2)}</span>
|
||||
<span>New total: ${(subtotal - discountAmount).toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
<Button size="sm" className="w-full" onClick={handleApply} disabled={isPending || discountAmount <= 0}>
|
||||
{isPending ? 'Applying...' : 'Apply Discount'}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Price Adjuster Popover ---
|
||||
|
||||
type AdjustMode = 'amount_off' | 'set_price' | 'percent'
|
||||
|
||||
function PriceAdjuster({ item, open, onOpenChange, onApply, isPending }: {
|
||||
item: TransactionLineItem
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onApply: (amount: number, reason: string) => void
|
||||
isPending: boolean
|
||||
}) {
|
||||
const [mode, setMode] = useState<AdjustMode>('percent')
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
const unitPrice = parseFloat(item.unitPrice)
|
||||
const listTotal = unitPrice * item.qty
|
||||
|
||||
function calculate(): { discountAmount: number; salePrice: number; pct: number } {
|
||||
const v = parseFloat(value) || 0
|
||||
if (mode === 'amount_off') {
|
||||
const d = Math.min(v, listTotal)
|
||||
return { discountAmount: d, salePrice: listTotal - d, pct: listTotal > 0 ? (d / listTotal) * 100 : 0 }
|
||||
}
|
||||
if (mode === 'set_price') {
|
||||
const d = Math.max(0, listTotal - v)
|
||||
return { discountAmount: d, salePrice: v, pct: listTotal > 0 ? (d / listTotal) * 100 : 0 }
|
||||
}
|
||||
// percent
|
||||
const d = Math.round(listTotal * (v / 100) * 100) / 100
|
||||
return { discountAmount: d, salePrice: listTotal - d, pct: v }
|
||||
}
|
||||
|
||||
const calc = calculate()
|
||||
|
||||
function handleApply() {
|
||||
if (calc.discountAmount <= 0) return
|
||||
const reason = mode === 'percent' ? `${parseFloat(value)}% off` : mode === 'set_price' ? `Price set to $${parseFloat(value).toFixed(2)}` : `$${parseFloat(value).toFixed(2)} off`
|
||||
onApply(calc.discountAmount, reason)
|
||||
setValue('')
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={(o) => { onOpenChange(o); if (!o) setValue('') }}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<Tag className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-3" side="left" align="start">
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
List: <span className="font-medium text-foreground">${listTotal.toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
{/* Mode tabs */}
|
||||
<div className="flex rounded-md border overflow-hidden text-xs">
|
||||
{([
|
||||
{ key: 'percent' as const, label: '% Off' },
|
||||
{ key: 'amount_off' as const, label: '$ Off' },
|
||||
{ key: 'set_price' as const, label: 'Set Price' },
|
||||
]).map((m) => (
|
||||
<button
|
||||
key={m.key}
|
||||
type="button"
|
||||
className={`flex-1 py-1.5 transition-colors ${mode === m.key ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'}`}
|
||||
onClick={() => { setMode(m.key); setValue('') }}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder={mode === 'percent' ? 'e.g. 10' : '0.00'}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
className="h-9"
|
||||
autoFocus
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleApply() }}
|
||||
/>
|
||||
|
||||
{value && parseFloat(value) > 0 && (
|
||||
<div className="text-xs space-y-0.5">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Discount</span>
|
||||
<span className="text-green-600">-${calc.discountAmount.toFixed(2)} ({calc.pct.toFixed(0)}%)</span>
|
||||
</div>
|
||||
<div className="flex justify-between font-medium">
|
||||
<span>Sale Price</span>
|
||||
<span>${calc.salePrice.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={handleApply}
|
||||
disabled={isPending || !value || calc.discountAmount <= 0}
|
||||
>
|
||||
{isPending ? 'Applying...' : 'Apply'}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
254
packages/admin/src/components/pos/pos-customer-dialog.tsx
Normal file
254
packages/admin/src/components/pos/pos-customer-dialog.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { usePOSStore } from '@/stores/pos.store'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Search, X, History } from 'lucide-react'
|
||||
|
||||
interface Account {
|
||||
id: string
|
||||
name: string
|
||||
email: string | null
|
||||
phone: string | null
|
||||
accountNumber: string | null
|
||||
}
|
||||
|
||||
interface TransactionLineItem {
|
||||
id: string
|
||||
description: string
|
||||
qty: number
|
||||
unitPrice: string
|
||||
lineTotal: string
|
||||
}
|
||||
|
||||
interface Transaction {
|
||||
id: string
|
||||
transactionNumber: string
|
||||
total: string
|
||||
status: string
|
||||
paymentMethod: string | null
|
||||
transactionType: string
|
||||
completedAt: string | null
|
||||
createdAt: string
|
||||
lineItems?: TransactionLineItem[]
|
||||
}
|
||||
|
||||
function accountSearchOptions(search: string) {
|
||||
return queryOptions({
|
||||
queryKey: ['pos', 'accounts', search],
|
||||
queryFn: () => api.get<{ data: Account[] }>('/v1/accounts', { q: search, limit: 10 }),
|
||||
enabled: search.length >= 2,
|
||||
})
|
||||
}
|
||||
|
||||
function customerHistoryOptions(accountId: string | null, itemSearch?: string) {
|
||||
return queryOptions({
|
||||
queryKey: ['pos', 'customer-history', accountId, itemSearch ?? ''],
|
||||
queryFn: () => api.get<{ data: Transaction[] }>('/v1/transactions', {
|
||||
accountId,
|
||||
limit: 10,
|
||||
sort: 'created_at',
|
||||
order: 'desc',
|
||||
...(itemSearch ? { itemSearch } : {}),
|
||||
}),
|
||||
enabled: !!accountId,
|
||||
})
|
||||
}
|
||||
|
||||
interface POSCustomerDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function POSCustomerDialog({ open, onOpenChange }: POSCustomerDialogProps) {
|
||||
const { accountId, accountName, setAccount, clearAccount } = usePOSStore()
|
||||
const [search, setSearch] = useState('')
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const [historySearch, setHistorySearch] = useState('')
|
||||
|
||||
const { data: searchData, isLoading } = useQuery(accountSearchOptions(search))
|
||||
const accounts = searchData?.data ?? []
|
||||
|
||||
const { data: historyData } = useQuery(customerHistoryOptions(showHistory ? accountId : null, historySearch || undefined))
|
||||
const history = historyData?.data ?? []
|
||||
|
||||
function handleSelect(account: Account) {
|
||||
setAccount(account.id, account.name, account.phone, account.email)
|
||||
setSearch('')
|
||||
setShowHistory(false)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
clearAccount()
|
||||
setSearch('')
|
||||
setShowHistory(false)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const [expandedTxn, setExpandedTxn] = useState<string | null>(null)
|
||||
|
||||
// Fetch detail for expanded transaction
|
||||
const { data: txnDetail } = useQuery({
|
||||
queryKey: ['pos', 'transaction-detail', expandedTxn],
|
||||
queryFn: () => api.get<Transaction>(`/v1/transactions/${expandedTxn}`),
|
||||
enabled: !!expandedTxn,
|
||||
})
|
||||
|
||||
const toggleExpand = useCallback((id: string) => {
|
||||
setExpandedTxn((prev) => prev === id ? null : id)
|
||||
}, [])
|
||||
|
||||
// History view
|
||||
if (showHistory && accountId) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center justify-between">
|
||||
<span>Order History — {accountName}</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => { setShowHistory(false); setExpandedTxn(null) }}>Back</Button>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{/* Search items in history */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={historySearch}
|
||||
onChange={(e) => setHistorySearch(e.target.value)}
|
||||
placeholder="Search items (e.g. strings, bow)..."
|
||||
className="pl-10 h-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{history.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
{historySearch ? `No orders with "${historySearch}"` : 'No transactions found'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{history.map((txn) => (
|
||||
<div key={txn.id}>
|
||||
<button
|
||||
onClick={() => toggleExpand(txn.id)}
|
||||
className="w-full text-left py-2.5 px-1 hover:bg-accent/50 rounded transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-mono">{txn.transactionNumber}</span>
|
||||
<span className="text-sm font-semibold">${parseFloat(txn.total).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<Badge variant={txn.status === 'completed' ? 'default' : 'outline'} className="text-[10px]">
|
||||
{txn.status}
|
||||
</Badge>
|
||||
{txn.paymentMethod && (
|
||||
<span className="text-xs text-muted-foreground">{txn.paymentMethod.replace('_', ' ')}</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{new Date(txn.completedAt ?? txn.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{expandedTxn === txn.id && txnDetail?.lineItems && (
|
||||
<div className="px-3 pb-2 space-y-1">
|
||||
{txnDetail.lineItems.map((item) => (
|
||||
<div key={item.id} className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{item.qty} x {item.description}</span>
|
||||
<span>${parseFloat(item.lineTotal).toFixed(2)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Customer</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Current selection */}
|
||||
{accountId && (
|
||||
<>
|
||||
<div className="flex items-center justify-between p-3 rounded-md border bg-muted/30">
|
||||
<div>
|
||||
<p className="font-medium text-sm">{accountName}</p>
|
||||
<p className="text-xs text-muted-foreground">Selected customer</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowHistory(true)}>
|
||||
<History className="h-4 w-4 mr-1" />
|
||||
History
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={handleClear}>
|
||||
<X className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search by name, phone, email, account #..."
|
||||
className="pl-10 h-11"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">Searching...</p>
|
||||
) : search.length >= 2 && accounts.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">No customers found</p>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{accounts.map((account) => (
|
||||
<button
|
||||
key={account.id}
|
||||
onClick={() => handleSelect(account)}
|
||||
className="w-full text-left px-2 py-3 hover:bg-accent rounded-md transition-colors"
|
||||
>
|
||||
<p className="font-medium text-sm">{account.name}</p>
|
||||
<div className="flex gap-3 text-xs text-muted-foreground mt-0.5">
|
||||
{account.phone && <span>{account.phone}</span>}
|
||||
{account.email && <span>{account.email}</span>}
|
||||
{account.accountNumber && <span>#{account.accountNumber}</span>}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Walk-in button */}
|
||||
{accountId && (
|
||||
<Button variant="outline" className="w-full h-11" onClick={handleClear}>
|
||||
Clear Customer (Walk-in)
|
||||
</Button>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
471
packages/admin/src/components/pos/pos-drawer-dialog.tsx
Normal file
471
packages/admin/src/components/pos/pos-drawer-dialog.tsx
Normal file
@@ -0,0 +1,471 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { usePOSStore } from '@/stores/pos.store'
|
||||
import { posMutations, posKeys, drawerReportOptions, type DrawerSession } from '@/api/pos'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { ArrowDownToLine, ArrowUpFromLine } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { ManagerOverrideDialog, requiresOverride } from './pos-manager-override'
|
||||
|
||||
interface POSDrawerDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
drawer: DrawerSession | null
|
||||
}
|
||||
|
||||
export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const { locationId, registerId, setDrawerSession } = usePOSStore()
|
||||
const isOpen = drawer?.status === 'open'
|
||||
|
||||
const [openingBalance, setOpeningBalance] = useState('200')
|
||||
const [closingBalance, setClosingBalance] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
const [adjustView, setAdjustView] = useState<'cash_in' | 'cash_out' | null>(null)
|
||||
const [adjAmount, setAdjAmount] = useState('')
|
||||
const [adjReason, setAdjReason] = useState('')
|
||||
const [overrideOpen, setOverrideOpen] = useState(false)
|
||||
const [pendingAdjustView, setPendingAdjustView] = useState<'cash_in' | 'cash_out' | null>(null)
|
||||
const [showZReport, setShowZReport] = useState(false)
|
||||
const [closedDrawerId, setClosedDrawerId] = useState<string | null>(null)
|
||||
const [showXReport, setShowXReport] = useState(false)
|
||||
|
||||
// Z Report data (after close)
|
||||
const { data: reportData } = useQuery(drawerReportOptions(closedDrawerId))
|
||||
|
||||
// X Report data (live, for open drawer)
|
||||
const { data: xReportData } = useQuery(drawerReportOptions(showXReport ? drawer?.id ?? null : null))
|
||||
|
||||
// Fetch adjustments for open drawer
|
||||
const { data: adjData } = useQuery({
|
||||
queryKey: posKeys.drawerAdjustments(drawer?.id ?? ''),
|
||||
queryFn: () => posMutations.getAdjustments(drawer!.id),
|
||||
enabled: !!drawer?.id && isOpen,
|
||||
})
|
||||
const adjustments = adjData?.data ?? []
|
||||
|
||||
const openMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
posMutations.openDrawer({
|
||||
locationId: locationId ?? undefined,
|
||||
registerId: registerId ?? undefined,
|
||||
openingBalance: parseFloat(openingBalance) || 0,
|
||||
}),
|
||||
onSuccess: async (session) => {
|
||||
setDrawerSession(session.id)
|
||||
await queryClient.invalidateQueries({ queryKey: posKeys.drawer(locationId ?? '') })
|
||||
toast.success('Drawer opened')
|
||||
onOpenChange(false)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const closeMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
posMutations.closeDrawer(drawer!.id, {
|
||||
closingBalance: parseFloat(closingBalance) || 0,
|
||||
notes: notes || undefined,
|
||||
}),
|
||||
onSuccess: async (session) => {
|
||||
setDrawerSession(null)
|
||||
const overShort = parseFloat(session.overShort ?? '0')
|
||||
if (Math.abs(overShort) < 0.01) {
|
||||
toast.success('Drawer closed - balanced')
|
||||
} else {
|
||||
toast.warning(`Drawer closed - ${overShort > 0 ? 'over' : 'short'} $${Math.abs(overShort).toFixed(2)}`)
|
||||
}
|
||||
// Show Z report
|
||||
setClosedDrawerId(session.id)
|
||||
setShowZReport(true)
|
||||
await queryClient.invalidateQueries({ queryKey: posKeys.drawer(locationId ?? '') })
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const adjustMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
posMutations.addAdjustment(drawer!.id, {
|
||||
type: adjustView!,
|
||||
amount: parseFloat(adjAmount) || 0,
|
||||
reason: adjReason,
|
||||
}),
|
||||
onSuccess: (adj) => {
|
||||
queryClient.invalidateQueries({ queryKey: posKeys.drawerAdjustments(drawer!.id) })
|
||||
toast.success(`${adj.type === 'cash_in' ? 'Cash added' : 'Cash removed'}: $${parseFloat(adj.amount).toFixed(2)}`)
|
||||
setAdjustView(null)
|
||||
setAdjAmount('')
|
||||
setAdjReason('')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
// Z Report view (shown after drawer close)
|
||||
if (showZReport && reportData) {
|
||||
const r = reportData
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={(o) => { onOpenChange(o); if (!o) { setShowZReport(false); setClosedDrawerId(null) } }}>
|
||||
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Z Report — Drawer Closed</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DrawerReportView report={r} />
|
||||
<Button variant="outline" className="w-full" onClick={() => { setShowZReport(false); setClosedDrawerId(null); onOpenChange(false) }}>
|
||||
Done
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// X Report view (mid-shift snapshot)
|
||||
if (showXReport && xReportData) {
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>X Report — Current Shift</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DrawerReportView report={xReportData} />
|
||||
<Button variant="outline" className="w-full" onClick={() => setShowXReport(false)}>
|
||||
Back
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Adjustment entry view
|
||||
if (adjustView && isOpen) {
|
||||
const isCashIn = adjustView === 'cash_in'
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isCashIn ? 'Cash In' : 'Cash Out'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Amount *</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
value={adjAmount}
|
||||
onChange={(e) => setAdjAmount(e.target.value)}
|
||||
placeholder="0.00"
|
||||
className="h-11 text-lg"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Reason *</Label>
|
||||
<Input
|
||||
value={adjReason}
|
||||
onChange={(e) => setAdjReason(e.target.value)}
|
||||
placeholder={isCashIn ? 'e.g. Extra change' : 'e.g. Bank deposit'}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="flex-1 h-12"
|
||||
onClick={() => adjustMutation.mutate()}
|
||||
disabled={!adjAmount || !adjReason || adjustMutation.isPending}
|
||||
>
|
||||
{adjustMutation.isPending ? 'Saving...' : isCashIn ? 'Add Cash' : 'Remove Cash'}
|
||||
</Button>
|
||||
<Button variant="outline" className="h-12" onClick={() => setAdjustView(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isOpen ? 'Drawer' : 'Open Drawer'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{isOpen ? (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Opening Balance</span>
|
||||
<span>${parseFloat(drawer!.openingBalance).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Opened</span>
|
||||
<span>{new Date(drawer!.openedAt).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cash In / Cash Out buttons */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-11 gap-2"
|
||||
onClick={() => {
|
||||
if (requiresOverride('cash_in_out')) {
|
||||
setPendingAdjustView('cash_in')
|
||||
setOverrideOpen(true)
|
||||
} else {
|
||||
setAdjustView('cash_in')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ArrowDownToLine className="h-4 w-4 text-green-600" />
|
||||
Cash In
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-11 gap-2"
|
||||
onClick={() => {
|
||||
if (requiresOverride('cash_in_out')) {
|
||||
setPendingAdjustView('cash_out')
|
||||
setOverrideOpen(true)
|
||||
} else {
|
||||
setAdjustView('cash_out')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ArrowUpFromLine className="h-4 w-4 text-red-600" />
|
||||
Cash Out
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* X Report button */}
|
||||
<Button variant="outline" className="w-full h-10 gap-2 text-sm" onClick={() => setShowXReport(true)}>
|
||||
Current Shift Report
|
||||
</Button>
|
||||
|
||||
{/* Adjustment history */}
|
||||
{adjustments.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">Adjustments</span>
|
||||
{adjustments.map((adj) => (
|
||||
<div key={adj.id} className="flex items-center justify-between text-sm py-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={adj.type === 'cash_in' ? 'default' : 'destructive'} className="text-[10px]">
|
||||
{adj.type === 'cash_in' ? 'IN' : 'OUT'}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground truncate max-w-[140px]">{adj.reason}</span>
|
||||
</div>
|
||||
<span className={adj.type === 'cash_in' ? 'text-green-600' : 'text-red-600'}>
|
||||
{adj.type === 'cash_in' ? '+' : '-'}${parseFloat(adj.amount).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Close drawer */}
|
||||
<div className="space-y-2">
|
||||
<Label>Closing Balance *</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={closingBalance}
|
||||
onChange={(e) => setClosingBalance(e.target.value)}
|
||||
placeholder="Count the cash in the drawer"
|
||||
className="h-11 text-lg"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Notes</Label>
|
||||
<Input
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="End of shift notes"
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full h-12"
|
||||
onClick={() => closeMutation.mutate()}
|
||||
disabled={!closingBalance || closeMutation.isPending}
|
||||
>
|
||||
{closeMutation.isPending ? 'Closing...' : 'Close Drawer'}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Opening Balance *</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={openingBalance}
|
||||
onChange={(e) => setOpeningBalance(e.target.value)}
|
||||
placeholder="Starting cash amount"
|
||||
className="h-11 text-lg"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full h-12"
|
||||
onClick={() => openMutation.mutate()}
|
||||
disabled={!openingBalance || openMutation.isPending}
|
||||
>
|
||||
{openMutation.isPending ? 'Opening...' : 'Open Drawer'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ManagerOverrideDialog
|
||||
open={overrideOpen}
|
||||
onOpenChange={setOverrideOpen}
|
||||
action={pendingAdjustView === 'cash_in' ? 'Cash In' : 'Cash Out'}
|
||||
onAuthorized={() => {
|
||||
if (pendingAdjustView) setAdjustView(pendingAdjustView)
|
||||
setPendingAdjustView(null)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Shared report view used by both X and Z reports ---
|
||||
|
||||
const PAYMENT_LABELS: Record<string, string> = {
|
||||
cash: 'Cash',
|
||||
card_present: 'Card (Present)',
|
||||
card_keyed: 'Card (Keyed)',
|
||||
check: 'Check',
|
||||
account_charge: 'Account',
|
||||
unknown: 'Other',
|
||||
}
|
||||
|
||||
function DrawerReportView({ report }: { report: any }) {
|
||||
const { session, sales, payments, discounts, cash, adjustments } = report
|
||||
|
||||
return (
|
||||
<div className="space-y-4 text-sm">
|
||||
{/* Session info */}
|
||||
<div className="space-y-1">
|
||||
{session.register && <div className="flex justify-between"><span className="text-muted-foreground">Register</span><span>{session.register.name}</span></div>}
|
||||
{session.openedBy && <div className="flex justify-between"><span className="text-muted-foreground">Opened by</span><span>{session.openedBy.firstName} {session.openedBy.lastName}</span></div>}
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">Opened</span><span>{new Date(session.openedAt).toLocaleString()}</span></div>
|
||||
{session.closedAt && (
|
||||
<>
|
||||
{session.closedBy && <div className="flex justify-between"><span className="text-muted-foreground">Closed by</span><span>{session.closedBy.firstName} {session.closedBy.lastName}</span></div>}
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">Closed</span><span>{new Date(session.closedAt).toLocaleString()}</span></div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Sales */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-xs uppercase text-muted-foreground mb-2">Sales</h4>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between"><span>Transactions</span><span>{sales.transactionCount}</span></div>
|
||||
<div className="flex justify-between"><span>Gross Sales</span><span className="tabular-nums">${sales.grossSales.toFixed(2)}</span></div>
|
||||
{sales.refundTotal > 0 && <div className="flex justify-between text-red-600"><span>Refunds</span><span className="tabular-nums">-${sales.refundTotal.toFixed(2)}</span></div>}
|
||||
<div className="flex justify-between font-medium"><span>Net Sales</span><span className="tabular-nums">${sales.netSales.toFixed(2)}</span></div>
|
||||
{sales.voidCount > 0 && <div className="flex justify-between text-muted-foreground"><span>Voided</span><span>{sales.voidCount}</span></div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Payment breakdown */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-xs uppercase text-muted-foreground mb-2">Payments</h4>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(payments as Record<string, { count: number; total: number }>).map(([method, data]) => (
|
||||
<div key={method} className="flex justify-between">
|
||||
<span>{PAYMENT_LABELS[method] ?? method} ({data.count})</span>
|
||||
<span className="tabular-nums">${data.total.toFixed(2)}</span>
|
||||
</div>
|
||||
))}
|
||||
{Object.keys(payments).length === 0 && <p className="text-muted-foreground">No payments</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Discounts */}
|
||||
{discounts.count > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<h4 className="font-semibold text-xs uppercase text-muted-foreground mb-2">Discounts</h4>
|
||||
<div className="flex justify-between"><span>Total ({discounts.count})</span><span className="tabular-nums text-green-600">-${discounts.total.toFixed(2)}</span></div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Cash accountability */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-xs uppercase text-muted-foreground mb-2">Cash</h4>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between"><span>Opening Balance</span><span className="tabular-nums">${cash.openingBalance.toFixed(2)}</span></div>
|
||||
<div className="flex justify-between"><span>Cash Sales</span><span className="tabular-nums">${cash.cashSales.toFixed(2)}</span></div>
|
||||
{cash.cashIn > 0 && <div className="flex justify-between text-green-600"><span>Cash In</span><span className="tabular-nums">+${cash.cashIn.toFixed(2)}</span></div>}
|
||||
{cash.cashOut > 0 && <div className="flex justify-between text-red-600"><span>Cash Out</span><span className="tabular-nums">-${cash.cashOut.toFixed(2)}</span></div>}
|
||||
<Separator />
|
||||
<div className="flex justify-between font-medium"><span>Expected</span><span className="tabular-nums">${cash.expectedBalance.toFixed(2)}</span></div>
|
||||
{cash.actualBalance !== null && (
|
||||
<>
|
||||
<div className="flex justify-between"><span>Actual Count</span><span className="tabular-nums">${cash.actualBalance.toFixed(2)}</span></div>
|
||||
<div className={`flex justify-between font-bold ${cash.overShort === 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
<span>{cash.overShort! >= 0 ? 'Over' : 'Short'}</span>
|
||||
<span className="tabular-nums">${Math.abs(cash.overShort!).toFixed(2)}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Adjustments */}
|
||||
{adjustments.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<h4 className="font-semibold text-xs uppercase text-muted-foreground mb-2">Adjustments</h4>
|
||||
<div className="space-y-1">
|
||||
{adjustments.map((adj: any) => (
|
||||
<div key={adj.id} className="flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={adj.type === 'cash_in' ? 'default' : 'destructive'} className="text-[10px]">
|
||||
{adj.type === 'cash_in' ? 'IN' : 'OUT'}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground truncate max-w-[150px]">{adj.reason}</span>
|
||||
</div>
|
||||
<span className="tabular-nums">${parseFloat(adj.amount).toFixed(2)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
285
packages/admin/src/components/pos/pos-item-panel.tsx
Normal file
285
packages/admin/src/components/pos/pos-item-panel.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { usePOSStore } from '@/stores/pos.store'
|
||||
import { productSearchOptions, posMutations, posKeys, type Transaction, type Product } from '@/api/pos'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Search, ScanBarcode, Wrench, PenLine, ClipboardList } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { POSTransactionsDialog } from './pos-transactions-dialog'
|
||||
import { POSRepairDialog } from './pos-repair-dialog'
|
||||
|
||||
interface POSItemPanelProps {
|
||||
transaction: Transaction | null
|
||||
}
|
||||
|
||||
export function POSItemPanel({ transaction: _transaction }: POSItemPanelProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const { currentTransactionId, setTransaction, locationId, accountId } = usePOSStore()
|
||||
const [search, setSearch] = useState('')
|
||||
const [customOpen, setCustomOpen] = useState(false)
|
||||
const [txnDialogOpen, setTxnDialogOpen] = useState(false)
|
||||
const [repairOpen, setRepairOpen] = useState(false)
|
||||
const [customDesc, setCustomDesc] = useState('')
|
||||
const [customPrice, setCustomPrice] = useState('')
|
||||
const [customQty, setCustomQty] = useState('1')
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Debounced product search
|
||||
const { data: productsData, isLoading: searchLoading } = useQuery({
|
||||
...productSearchOptions(search),
|
||||
enabled: search.length >= 1,
|
||||
})
|
||||
const products = productsData?.data ?? []
|
||||
|
||||
// Add line item mutation
|
||||
const addItemMutation = useMutation({
|
||||
mutationFn: async (product: Product) => {
|
||||
let txnId = currentTransactionId
|
||||
// Auto-create transaction if none exists
|
||||
if (!txnId) {
|
||||
const txn = await posMutations.createTransaction({
|
||||
transactionType: 'sale',
|
||||
locationId: locationId ?? undefined,
|
||||
accountId: accountId ?? undefined,
|
||||
})
|
||||
txnId = txn.id
|
||||
setTransaction(txnId)
|
||||
}
|
||||
return posMutations.addLineItem(txnId, {
|
||||
productId: product.id,
|
||||
description: product.name,
|
||||
qty: 1,
|
||||
unitPrice: parseFloat(product.price ?? '0'),
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
const txnId = usePOSStore.getState().currentTransactionId
|
||||
queryClient.invalidateQueries({ queryKey: posKeys.transaction(txnId ?? '') })
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
// Custom item mutation
|
||||
const addCustomMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
let txnId = currentTransactionId
|
||||
if (!txnId) {
|
||||
const txn = await posMutations.createTransaction({
|
||||
transactionType: 'sale',
|
||||
locationId: locationId ?? undefined,
|
||||
accountId: accountId ?? undefined,
|
||||
})
|
||||
txnId = txn.id
|
||||
setTransaction(txnId)
|
||||
}
|
||||
return posMutations.addLineItem(txnId, {
|
||||
description: customDesc,
|
||||
qty: parseInt(customQty) || 1,
|
||||
unitPrice: parseFloat(customPrice) || 0,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
const txnId = usePOSStore.getState().currentTransactionId
|
||||
queryClient.invalidateQueries({ queryKey: posKeys.transaction(txnId ?? '') })
|
||||
setCustomOpen(false)
|
||||
setCustomDesc('')
|
||||
setCustomPrice('')
|
||||
setCustomQty('1')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
// UPC scan
|
||||
const scanMutation = useMutation({
|
||||
mutationFn: async (upc: string) => {
|
||||
const product = await posMutations.lookupUpc(upc)
|
||||
let txnId = currentTransactionId
|
||||
if (!txnId) {
|
||||
const txn = await posMutations.createTransaction({
|
||||
transactionType: 'sale',
|
||||
locationId: locationId ?? undefined,
|
||||
accountId: accountId ?? undefined,
|
||||
})
|
||||
txnId = txn.id
|
||||
setTransaction(txnId)
|
||||
}
|
||||
return posMutations.addLineItem(txnId, {
|
||||
productId: product.id,
|
||||
description: product.name,
|
||||
qty: 1,
|
||||
unitPrice: parseFloat(product.price ?? '0'),
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: posKeys.transaction(currentTransactionId ?? '') })
|
||||
setSearch('')
|
||||
toast.success('Item scanned')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const handleSearchKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
// Barcode scanners typically send Enter after the code
|
||||
if (e.key === 'Enter' && search.length >= 6) {
|
||||
// Looks like a UPC — try scanning
|
||||
scanMutation.mutate(search)
|
||||
}
|
||||
}, [search, scanMutation])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Search bar */}
|
||||
<div className="p-3 border-b border-border">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
ref={searchRef}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
placeholder="Search products or scan barcode..."
|
||||
className="pl-10 h-11 text-base"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product grid */}
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
{searchLoading ? (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-24 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : products.length > 0 ? (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{products.map((product) => (
|
||||
<button
|
||||
key={product.id}
|
||||
onClick={() => addItemMutation.mutate(product)}
|
||||
disabled={addItemMutation.isPending}
|
||||
className="flex flex-col items-start p-3 rounded-lg border border-border bg-card hover:bg-accent active:bg-accent/80 transition-colors text-left min-h-[80px]"
|
||||
>
|
||||
<span className="font-medium text-sm line-clamp-2">{product.name}</span>
|
||||
<div className="mt-auto flex items-center justify-between w-full pt-1">
|
||||
<span className="text-base font-semibold">${parseFloat(product.price ?? '0').toFixed(2)}</span>
|
||||
{product.sku && <span className="text-xs text-muted-foreground">{product.sku}</span>}
|
||||
</div>
|
||||
{product.isSerialized ? (
|
||||
<span className="text-[10px] text-muted-foreground">Serialized</span>
|
||||
) : product.qtyOnHand !== null ? (
|
||||
<span className="text-[10px] text-muted-foreground">{product.qtyOnHand} in stock</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : search.length >= 1 ? (
|
||||
<div className="flex items-center justify-center h-32 text-muted-foreground text-sm">
|
||||
No products found for "{search}"
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-32 text-muted-foreground text-sm">
|
||||
Search for products to add to the sale
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick action buttons */}
|
||||
<div className="p-3 border-t border-border flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 h-11 text-sm gap-2"
|
||||
onClick={() => searchRef.current?.focus()}
|
||||
>
|
||||
<ScanBarcode className="h-4 w-4" />
|
||||
Scan
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 h-11 text-sm gap-2"
|
||||
onClick={() => setRepairOpen(true)}
|
||||
>
|
||||
<Wrench className="h-4 w-4" />
|
||||
Repairs
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 h-11 text-sm gap-2"
|
||||
onClick={() => setCustomOpen(true)}
|
||||
>
|
||||
<PenLine className="h-4 w-4" />
|
||||
Custom
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 h-11 text-sm gap-2"
|
||||
onClick={() => setTxnDialogOpen(true)}
|
||||
>
|
||||
<ClipboardList className="h-4 w-4" />
|
||||
Orders
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Custom item dialog */}
|
||||
<Dialog open={customOpen} onOpenChange={setCustomOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Custom Item</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); addCustomMutation.mutate() }}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label>Description *</Label>
|
||||
<Input
|
||||
value={customDesc}
|
||||
onChange={(e) => setCustomDesc(e.target.value)}
|
||||
placeholder="Item description"
|
||||
required
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Price *</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={customPrice}
|
||||
onChange={(e) => setCustomPrice(e.target.value)}
|
||||
placeholder="0.00"
|
||||
required
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Qty</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={customQty}
|
||||
onChange={(e) => setCustomQty(e.target.value)}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" className="w-full h-11" disabled={addCustomMutation.isPending}>
|
||||
{addCustomMutation.isPending ? 'Adding...' : 'Add Item'}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Transactions dialog */}
|
||||
<POSTransactionsDialog open={txnDialogOpen} onOpenChange={setTxnDialogOpen} />
|
||||
<POSRepairDialog open={repairOpen} onOpenChange={setRepairOpen} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
168
packages/admin/src/components/pos/pos-lock-screen.tsx
Normal file
168
packages/admin/src/components/pos/pos-lock-screen.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { usePOSStore } from '@/stores/pos.store'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Delete, Lock } from 'lucide-react'
|
||||
|
||||
interface PinUser {
|
||||
id: string
|
||||
email: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
role: string
|
||||
}
|
||||
|
||||
export function POSLockScreen() {
|
||||
const unlock = usePOSStore((s) => s.unlock)
|
||||
const [code, setCode] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Auto-focus on mount
|
||||
useEffect(() => {
|
||||
containerRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const handleDigit = useCallback((digit: string) => {
|
||||
setError('')
|
||||
setCode((p) => {
|
||||
if (p.length >= 10) return p
|
||||
return p + digit
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleBackspace = useCallback(() => {
|
||||
setError('')
|
||||
setCode((p) => p.slice(0, -1))
|
||||
}, [])
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setError('')
|
||||
setCode('')
|
||||
}, [])
|
||||
|
||||
const handleSubmit = useCallback(async (submitCode: string) => {
|
||||
if (submitCode.length < 8) {
|
||||
setError('Enter your employee # (4) + PIN (4)')
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.post<{ user: PinUser; token: string }>('/v1/auth/pin-login', { code: submitCode })
|
||||
unlock(res.user, res.token)
|
||||
setCode('')
|
||||
} catch {
|
||||
setError('Invalid code')
|
||||
setCode('')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [unlock])
|
||||
|
||||
// Auto-submit when 8 digits entered
|
||||
useEffect(() => {
|
||||
if (code.length === 8) {
|
||||
handleSubmit(code)
|
||||
}
|
||||
}, [code, handleSubmit])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key >= '0' && e.key <= '9') handleDigit(e.key)
|
||||
else if (e.key === 'Backspace') handleBackspace()
|
||||
else if (e.key === 'Enter' && code.length >= 8) handleSubmit(code)
|
||||
else if (e.key === 'Escape') handleClear()
|
||||
}, [handleDigit, handleBackspace, handleSubmit, handleClear, code])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute inset-0 z-50 bg-background flex items-center justify-center"
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="w-80 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="text-center space-y-2">
|
||||
<Lock className="h-10 w-10 mx-auto text-muted-foreground" />
|
||||
<h1 className="text-xl font-semibold">POS Locked</h1>
|
||||
<p className="text-sm text-muted-foreground">Employee # + PIN</p>
|
||||
</div>
|
||||
|
||||
{/* Code dots — 4 employee + 4 PIN with separator */}
|
||||
<div className="flex justify-center items-center gap-2">
|
||||
<div className="flex gap-1.5">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div
|
||||
key={`e${i}`}
|
||||
className={`w-3.5 h-3.5 rounded-full border-2 ${
|
||||
i < Math.min(code.length, 4) ? 'bg-foreground border-foreground' : 'border-muted-foreground/30'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-muted-foreground/40 text-lg">-</span>
|
||||
<div className="flex gap-1.5">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div
|
||||
key={`p${i}`}
|
||||
className={`w-3.5 h-3.5 rounded-full border-2 ${
|
||||
i < Math.max(0, code.length - 4) ? 'bg-foreground border-foreground' : 'border-muted-foreground/30'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<p className="text-sm text-destructive text-center">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Numpad */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{['1', '2', '3', '4', '5', '6', '7', '8', '9'].map((d) => (
|
||||
<Button
|
||||
key={d}
|
||||
variant="outline"
|
||||
className="h-14 text-xl font-medium"
|
||||
onClick={() => handleDigit(d)}
|
||||
disabled={loading}
|
||||
>
|
||||
{d}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-14 text-sm"
|
||||
onClick={handleClear}
|
||||
disabled={loading}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-14 text-xl font-medium"
|
||||
onClick={() => handleDigit('0')}
|
||||
disabled={loading}
|
||||
>
|
||||
0
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-14"
|
||||
onClick={handleBackspace}
|
||||
disabled={loading}
|
||||
>
|
||||
<Delete className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<p className="text-sm text-muted-foreground text-center">Verifying...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
201
packages/admin/src/components/pos/pos-manager-override.tsx
Normal file
201
packages/admin/src/components/pos/pos-manager-override.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { Delete, ShieldCheck } from 'lucide-react'
|
||||
|
||||
interface ManagerOverrideDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
action: string
|
||||
onAuthorized: () => void
|
||||
}
|
||||
|
||||
interface PinUser {
|
||||
id: string
|
||||
role: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
}
|
||||
|
||||
export function ManagerOverrideDialog({ open, onOpenChange, action, onAuthorized }: ManagerOverrideDialogProps) {
|
||||
const [code, setCode] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setCode('')
|
||||
setError('')
|
||||
containerRef.current?.focus()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleDigit = useCallback((digit: string) => {
|
||||
setError('')
|
||||
setCode((p) => (p.length >= 10 ? p : p + digit))
|
||||
}, [])
|
||||
|
||||
const handleBackspace = useCallback(() => {
|
||||
setError('')
|
||||
setCode((p) => p.slice(0, -1))
|
||||
}, [])
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setError('')
|
||||
setCode('')
|
||||
}, [])
|
||||
|
||||
const handleSubmit = useCallback(async (submitCode: string) => {
|
||||
if (submitCode.length < 8) {
|
||||
setError('Enter manager employee # + PIN')
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.post<{ user: PinUser; token: string }>('/v1/auth/pin-login', { code: submitCode })
|
||||
if (res.user.role === 'admin' || res.user.role === 'manager') {
|
||||
onAuthorized()
|
||||
onOpenChange(false)
|
||||
} else {
|
||||
setError('Manager or admin access required')
|
||||
setCode('')
|
||||
}
|
||||
} catch {
|
||||
setError('Invalid code')
|
||||
setCode('')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [onAuthorized, onOpenChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (code.length === 8) {
|
||||
handleSubmit(code)
|
||||
}
|
||||
}, [code, handleSubmit])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key >= '0' && e.key <= '9') handleDigit(e.key)
|
||||
else if (e.key === 'Backspace') handleBackspace()
|
||||
else if (e.key === 'Enter' && code.length >= 8) handleSubmit(code)
|
||||
else if (e.key === 'Escape') handleClear()
|
||||
}, [handleDigit, handleBackspace, handleSubmit, handleClear, code])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-xs">
|
||||
<div
|
||||
ref={containerRef}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={0}
|
||||
className="outline-none space-y-4"
|
||||
>
|
||||
<div className="text-center space-y-1">
|
||||
<ShieldCheck className="h-8 w-8 mx-auto text-amber-500" />
|
||||
<DialogTitle className="text-base">Manager Override</DialogTitle>
|
||||
<p className="text-xs text-muted-foreground">{action}</p>
|
||||
</div>
|
||||
|
||||
{/* Code dots */}
|
||||
<div className="flex justify-center items-center gap-2">
|
||||
<div className="flex gap-1.5">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div
|
||||
key={`e${i}`}
|
||||
className={`w-3 h-3 rounded-full border-2 ${
|
||||
i < Math.min(code.length, 4) ? 'bg-foreground border-foreground' : 'border-muted-foreground/30'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-muted-foreground/40">-</span>
|
||||
<div className="flex gap-1.5">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div
|
||||
key={`p${i}`}
|
||||
className={`w-3 h-3 rounded-full border-2 ${
|
||||
i < Math.max(0, code.length - 4) ? 'bg-foreground border-foreground' : 'border-muted-foreground/30'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-destructive text-center">{error}</p>}
|
||||
|
||||
{/* Numpad */}
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{['1', '2', '3', '4', '5', '6', '7', '8', '9'].map((d) => (
|
||||
<Button key={d} variant="outline" className="h-12 text-lg font-medium" onClick={() => handleDigit(d)} disabled={loading}>
|
||||
{d}
|
||||
</Button>
|
||||
))}
|
||||
<Button variant="outline" className="h-12 text-xs" onClick={handleClear} disabled={loading}>Clear</Button>
|
||||
<Button variant="outline" className="h-12 text-lg font-medium" onClick={() => handleDigit('0')} disabled={loading}>0</Button>
|
||||
<Button variant="outline" className="h-12" onClick={handleBackspace} disabled={loading}>
|
||||
<Delete className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading && <p className="text-xs text-muted-foreground text-center">Verifying...</p>}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Config types & helpers ---
|
||||
|
||||
export const OVERRIDE_ACTIONS = [
|
||||
{ key: 'void_transaction', label: 'Void Transaction', description: 'Cancel an in-progress sale' },
|
||||
{ key: 'refund', label: 'Refund', description: 'Process a return or refund' },
|
||||
{ key: 'manual_discount', label: 'Manual Discount', description: 'Apply a discount not from a preset' },
|
||||
{ key: 'price_override', label: 'Price Override', description: 'Change an item price at the register' },
|
||||
{ key: 'no_sale_drawer', label: 'No-Sale Drawer Open', description: 'Open the drawer without a transaction' },
|
||||
{ key: 'cash_in_out', label: 'Cash In / Cash Out', description: 'Add or remove cash from the drawer' },
|
||||
] as const
|
||||
|
||||
export type OverrideAction = typeof OVERRIDE_ACTIONS[number]['key']
|
||||
|
||||
const STORAGE_KEY = 'pos_manager_overrides'
|
||||
|
||||
export function getRequiredOverrides(): Set<OverrideAction> {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (!stored) return new Set()
|
||||
return new Set(JSON.parse(stored) as OverrideAction[])
|
||||
} catch {
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
|
||||
export function setRequiredOverrides(actions: Set<OverrideAction>) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify([...actions]))
|
||||
}
|
||||
|
||||
export function requiresOverride(action: OverrideAction): boolean {
|
||||
return getRequiredOverrides().has(action)
|
||||
}
|
||||
|
||||
// Discount threshold — discounts above this percentage require manager override
|
||||
const DISCOUNT_THRESHOLD_KEY = 'pos_discount_threshold_pct'
|
||||
|
||||
export function getDiscountThreshold(): number {
|
||||
const stored = localStorage.getItem(DISCOUNT_THRESHOLD_KEY)
|
||||
return stored ? parseInt(stored, 10) : 0 // 0 = disabled
|
||||
}
|
||||
|
||||
export function setDiscountThreshold(pct: number) {
|
||||
localStorage.setItem(DISCOUNT_THRESHOLD_KEY, String(pct))
|
||||
}
|
||||
|
||||
export function requiresDiscountOverride(discountPct: number): boolean {
|
||||
// Check percentage threshold first
|
||||
const threshold = getDiscountThreshold()
|
||||
if (threshold > 0 && discountPct >= threshold) return true
|
||||
// Fall back to the blanket manual_discount toggle
|
||||
return requiresOverride('manual_discount')
|
||||
}
|
||||
298
packages/admin/src/components/pos/pos-payment-dialog.tsx
Normal file
298
packages/admin/src/components/pos/pos-payment-dialog.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { usePOSStore } from '@/stores/pos.store'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { posMutations, posKeys, type Transaction } from '@/api/pos'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { CheckCircle, Printer, Mail } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { POSReceipt, downloadReceiptPDF, printReceipt } from './pos-receipt'
|
||||
|
||||
interface POSPaymentDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
paymentMethod: string
|
||||
transaction: Transaction
|
||||
onComplete: () => void
|
||||
}
|
||||
|
||||
export function POSPaymentDialog({ open, onOpenChange, paymentMethod, transaction, onComplete }: POSPaymentDialogProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const { currentTransactionId } = usePOSStore()
|
||||
const total = parseFloat(transaction.total)
|
||||
const [amountTendered, setAmountTendered] = useState('')
|
||||
const [checkNumber, setCheckNumber] = useState('')
|
||||
const [completed, setCompleted] = useState(false)
|
||||
const [result, setResult] = useState<Transaction | null>(null)
|
||||
|
||||
const completeMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const data: { paymentMethod: string; amountTendered?: number; checkNumber?: string } = {
|
||||
paymentMethod,
|
||||
}
|
||||
if (paymentMethod === 'cash') {
|
||||
data.amountTendered = parseFloat(amountTendered) || 0
|
||||
}
|
||||
if (paymentMethod === 'check') {
|
||||
data.checkNumber = checkNumber || undefined
|
||||
}
|
||||
return posMutations.complete(currentTransactionId!, data)
|
||||
},
|
||||
onSuccess: (txn) => {
|
||||
queryClient.invalidateQueries({ queryKey: posKeys.transaction(currentTransactionId!) })
|
||||
queryClient.invalidateQueries({ queryKey: ['pos', 'products'] })
|
||||
setResult(txn)
|
||||
setCompleted(true)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const tenderedAmount = parseFloat(amountTendered) || 0
|
||||
const changeDue = paymentMethod === 'cash' ? Math.max(0, tenderedAmount - total) : 0
|
||||
const canComplete = paymentMethod === 'cash'
|
||||
? tenderedAmount >= total
|
||||
: true
|
||||
|
||||
function handleDone() {
|
||||
onComplete()
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const QUICK_AMOUNTS = [1, 5, 10, 20, 50, 100]
|
||||
|
||||
// Fetch receipt config
|
||||
interface AppConfigEntry { key: string; value: string | null }
|
||||
const { data: configData } = useQuery({
|
||||
queryKey: ['config'],
|
||||
queryFn: () => api.get<{ data: AppConfigEntry[] }>('/v1/config'),
|
||||
enabled: !!result?.id,
|
||||
})
|
||||
const receiptFormat = usePOSStore((s) => s.receiptFormat)
|
||||
const receiptConfig = {
|
||||
header: configData?.data?.find((c) => c.key === 'receipt_header')?.value || undefined,
|
||||
footer: configData?.data?.find((c) => c.key === 'receipt_footer')?.value || undefined,
|
||||
returnPolicy: configData?.data?.find((c) => c.key === 'receipt_return_policy')?.value || undefined,
|
||||
social: configData?.data?.find((c) => c.key === 'receipt_social')?.value || undefined,
|
||||
}
|
||||
|
||||
// Fetch full receipt data after completion
|
||||
const { data: receiptData } = useQuery({
|
||||
queryKey: ['pos', 'receipt', result?.id],
|
||||
queryFn: () => api.get<{
|
||||
transaction: Transaction & { lineItems: { description: string; qty: number; unitPrice: string; taxAmount: string; lineTotal: string; discountAmount: string }[] }
|
||||
customerEmail: string | null
|
||||
company: { name: string; phone: string | null; email: string | null; address: { street?: string; city?: string; state?: string; zip?: string } | null }
|
||||
location: { name: string; phone: string | null; email: string | null; address: { street?: string; city?: string; state?: string; zip?: string } | null }
|
||||
}>(`/v1/transactions/${result!.id}/receipt`),
|
||||
enabled: !!result?.id,
|
||||
})
|
||||
|
||||
const [showReceipt, setShowReceipt] = useState(false)
|
||||
const [emailMode, setEmailMode] = useState(false)
|
||||
const [emailAddress, setEmailAddress] = useState('')
|
||||
const [emailSent, setEmailSent] = useState(false)
|
||||
|
||||
const emailReceiptMutation = useMutation({
|
||||
mutationFn: () => api.post<{ message: string }>(`/v1/transactions/${result!.id}/email-receipt`, { email: emailAddress }),
|
||||
onSuccess: () => {
|
||||
toast.success('Receipt emailed')
|
||||
setEmailMode(false)
|
||||
setEmailSent(true)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
if (completed && result) {
|
||||
const changeGiven = parseFloat(result.changeGiven ?? '0')
|
||||
|
||||
// Receipt print view
|
||||
if (showReceipt && receiptData) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={() => { setShowReceipt(false); handleDone() }}>
|
||||
<DialogContent className={`${receiptFormat === 'full' ? 'max-w-2xl' : 'max-w-sm'} max-h-[90vh] overflow-y-auto print:max-w-none print:max-h-none print:overflow-visible print:shadow-none print:border-none`}>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowReceipt(false)}>Back</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => downloadReceiptPDF(result.transactionNumber, receiptFormat)} className="gap-2">
|
||||
Save PDF
|
||||
</Button>
|
||||
<Button size="sm" onClick={printReceipt} className="gap-2">
|
||||
<Printer className="h-4 w-4" />Print
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="pos-receipt-print">
|
||||
<POSReceipt data={receiptData} size={receiptFormat} config={receiptConfig} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={() => handleDone()}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<div className="flex flex-col items-center text-center space-y-4 py-4">
|
||||
<CheckCircle className="h-12 w-12 text-green-500" />
|
||||
<h2 className="text-xl font-bold">Sale Complete</h2>
|
||||
<p className="text-muted-foreground text-sm">{result.transactionNumber}</p>
|
||||
|
||||
<div className="w-full text-sm space-y-1">
|
||||
<div className="flex justify-between font-semibold text-base">
|
||||
<span>Total</span>
|
||||
<span>${parseFloat(result.total).toFixed(2)}</span>
|
||||
</div>
|
||||
{paymentMethod === 'cash' && changeGiven > 0 && (
|
||||
<div className="flex justify-between text-lg font-bold text-green-600">
|
||||
<span>Change Due</span>
|
||||
<span>${changeGiven.toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{emailMode ? (
|
||||
<div className="w-full space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">Email receipt to:</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="email"
|
||||
value={emailAddress}
|
||||
onChange={(e) => setEmailAddress(e.target.value)}
|
||||
placeholder="customer@example.com"
|
||||
className="h-9"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-9 px-4"
|
||||
onClick={() => emailReceiptMutation.mutate()}
|
||||
disabled={!emailAddress || emailReceiptMutation.isPending}
|
||||
>
|
||||
{emailReceiptMutation.isPending ? 'Sending...' : 'Send'}
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="text-xs" onClick={() => setEmailMode(false)}>Cancel</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full grid grid-cols-2 gap-2">
|
||||
<Button variant="outline" className="h-11 gap-2" onClick={() => setShowReceipt(true)}>
|
||||
<Printer className="h-4 w-4" />Receipt
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-11 gap-2"
|
||||
onClick={() => {
|
||||
setEmailAddress(receiptData?.customerEmail ?? '')
|
||||
setEmailMode(true)
|
||||
}}
|
||||
disabled={emailSent}
|
||||
>
|
||||
<Mail className="h-4 w-4" />{emailSent ? 'Sent' : 'Email'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button className="w-full h-12 text-base" onClick={handleDone}>
|
||||
New Sale
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{paymentMethod === 'cash' ? 'Cash Payment' : paymentMethod === 'check' ? 'Check Payment' : 'Card Payment'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between text-lg font-bold">
|
||||
<span>Total Due</span>
|
||||
<span>${total.toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{paymentMethod === 'cash' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>Amount Tendered</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={amountTendered}
|
||||
onChange={(e) => setAmountTendered(e.target.value)}
|
||||
placeholder="0.00"
|
||||
className="h-12 text-xl text-right font-mono"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{QUICK_AMOUNTS.map((amt) => (
|
||||
<Button
|
||||
key={amt}
|
||||
variant="outline"
|
||||
className="h-11"
|
||||
onClick={() => setAmountTendered(String(amt))}
|
||||
>
|
||||
${amt}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-11"
|
||||
onClick={() => setAmountTendered(total.toFixed(2))}
|
||||
>
|
||||
Exact ${total.toFixed(2)}
|
||||
</Button>
|
||||
{tenderedAmount >= total && (
|
||||
<div className="flex justify-between text-lg font-bold text-green-600">
|
||||
<span>Change</span>
|
||||
<span>${changeDue.toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{paymentMethod === 'check' && (
|
||||
<div className="space-y-2">
|
||||
<Label>Check Number</Label>
|
||||
<Input
|
||||
value={checkNumber}
|
||||
onChange={(e) => setCheckNumber(e.target.value)}
|
||||
placeholder="Check #"
|
||||
className="h-11"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{paymentMethod === 'card_present' && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
Process card payment on terminal, then confirm below.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="w-full h-12 text-base"
|
||||
disabled={!canComplete || completeMutation.isPending}
|
||||
onClick={() => completeMutation.mutate()}
|
||||
>
|
||||
{completeMutation.isPending ? 'Processing...' : `Complete ${paymentMethod === 'cash' ? 'Cash' : paymentMethod === 'check' ? 'Check' : 'Card'} Sale`}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
443
packages/admin/src/components/pos/pos-receipt.tsx
Normal file
443
packages/admin/src/components/pos/pos-receipt.tsx
Normal file
@@ -0,0 +1,443 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { usePOSStore } from '@/stores/pos.store'
|
||||
import JsBarcode from 'jsbarcode'
|
||||
|
||||
interface ReceiptLineItem {
|
||||
description: string
|
||||
qty: number
|
||||
unitPrice: string
|
||||
taxAmount: string
|
||||
lineTotal: string
|
||||
discountAmount?: string
|
||||
}
|
||||
|
||||
interface ReceiptData {
|
||||
transaction: {
|
||||
transactionNumber: string
|
||||
transactionType: string
|
||||
status: string
|
||||
subtotal: string
|
||||
discountTotal: string
|
||||
taxTotal: string
|
||||
total: string
|
||||
paymentMethod: string | null
|
||||
amountTendered: string | null
|
||||
changeGiven: string | null
|
||||
roundingAdjustment: string
|
||||
completedAt: string | null
|
||||
createdAt: string
|
||||
lineItems: ReceiptLineItem[]
|
||||
}
|
||||
company: {
|
||||
name: string
|
||||
phone: string | null
|
||||
email: string | null
|
||||
address: { street?: string; city?: string; state?: string; zip?: string } | null
|
||||
}
|
||||
location: {
|
||||
name: string
|
||||
phone: string | null
|
||||
email: string | null
|
||||
address: { street?: string; city?: string; state?: string; zip?: string } | null
|
||||
}
|
||||
}
|
||||
|
||||
interface ReceiptConfig {
|
||||
header?: string
|
||||
footer?: string
|
||||
returnPolicy?: string
|
||||
social?: string
|
||||
}
|
||||
|
||||
interface POSReceiptProps {
|
||||
data: ReceiptData
|
||||
size?: 'thermal' | 'full'
|
||||
footerText?: string
|
||||
config?: ReceiptConfig
|
||||
}
|
||||
|
||||
function useStoreLogo(companyId?: string) {
|
||||
const token = usePOSStore((s) => s.token)
|
||||
const [logoSrc, setLogoSrc] = useState<string | null>(null)
|
||||
|
||||
const { data: storeData } = useQuery(queryOptions({
|
||||
queryKey: ['store'],
|
||||
queryFn: () => api.get<{ id: string }>('/v1/store'),
|
||||
enabled: !!token,
|
||||
}))
|
||||
|
||||
const storeId = companyId ?? storeData?.id
|
||||
const { data: filesData } = useQuery(queryOptions({
|
||||
queryKey: ['files', 'company', storeId ?? ''],
|
||||
queryFn: () => api.get<{ data: { id: string; path: string }[] }>('/v1/files', { entityType: 'company', entityId: storeId }),
|
||||
enabled: !!storeId,
|
||||
}))
|
||||
|
||||
const logoFile = filesData?.data?.find((f) => f.path.includes('/logo-'))
|
||||
|
||||
useEffect(() => {
|
||||
if (!logoFile || !token) { setLogoSrc(null); return }
|
||||
let cancelled = false
|
||||
let blobUrl: string | null = null
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch(`/v1/files/serve/${logoFile!.path}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (!res.ok || cancelled) return
|
||||
const blob = await res.blob()
|
||||
if (!cancelled) { blobUrl = URL.createObjectURL(blob); setLogoSrc(blobUrl) }
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
load()
|
||||
return () => { cancelled = true; if (blobUrl) URL.revokeObjectURL(blobUrl) }
|
||||
}, [logoFile?.path, token])
|
||||
|
||||
return logoSrc
|
||||
}
|
||||
|
||||
export function POSReceipt({ data, size = 'thermal', footerText, config }: POSReceiptProps) {
|
||||
const isThermal = size === 'thermal'
|
||||
|
||||
if (!isThermal) {
|
||||
return <FullPageReceipt data={data} config={config} footerText={footerText} />
|
||||
}
|
||||
|
||||
return <ThermalReceipt data={data} config={config} footerText={footerText} />
|
||||
}
|
||||
|
||||
function useReceiptData(data: POSReceiptProps['data']) {
|
||||
const { transaction: txn, company, location } = data
|
||||
return {
|
||||
txn,
|
||||
company,
|
||||
location,
|
||||
date: new Date(txn.completedAt ?? txn.createdAt),
|
||||
subtotal: parseFloat(txn.subtotal),
|
||||
discountTotal: parseFloat(txn.discountTotal),
|
||||
taxTotal: parseFloat(txn.taxTotal),
|
||||
total: parseFloat(txn.total),
|
||||
rounding: parseFloat(txn.roundingAdjustment),
|
||||
tendered: txn.amountTendered ? parseFloat(txn.amountTendered) : null,
|
||||
change: txn.changeGiven ? parseFloat(txn.changeGiven) : null,
|
||||
addr: location.address ?? company.address,
|
||||
phone: location.phone ?? company.phone,
|
||||
email: location.email ?? company.email,
|
||||
}
|
||||
}
|
||||
|
||||
function useBarcode(ref: React.RefObject<SVGSVGElement | null>, value: string, opts: { width: number; height: number; fontSize: number }) {
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
try {
|
||||
JsBarcode(ref.current, value, { format: 'CODE128', displayValue: true, margin: 4, ...opts })
|
||||
} catch { /* barcode generation failed — show text fallback */ }
|
||||
}
|
||||
}, [value])
|
||||
}
|
||||
|
||||
function ThermalReceipt({ data, config, footerText }: { data: POSReceiptProps['data']; config?: ReceiptConfig; footerText?: string }) {
|
||||
const barcodeRef = useRef<SVGSVGElement>(null)
|
||||
const logoSrc = useStoreLogo()
|
||||
const { txn, company, location, date, subtotal, discountTotal, taxTotal, total, rounding, tendered, change, addr, phone } = useReceiptData(data)
|
||||
useBarcode(barcodeRef, txn.transactionNumber, { width: 1.5, height: 40, fontSize: 10 })
|
||||
|
||||
const s = {
|
||||
row: { display: 'flex', justifyContent: 'space-between' } as const,
|
||||
section: { padding: '8px 0', borderBottom: '1px dashed #999' } as const,
|
||||
gray: { color: '#666' } as const,
|
||||
light: { color: '#999' } as const,
|
||||
bold: { fontWeight: 'bold' } as const,
|
||||
center: { textAlign: 'center' } as const,
|
||||
nums: { fontVariantNumeric: 'tabular-nums' } as const,
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: '#fff', color: '#000', fontFamily: 'monospace',
|
||||
width: 260, maxWidth: 260, fontSize: 10, lineHeight: '1.3', margin: '0 auto',
|
||||
}}>
|
||||
{/* Store header */}
|
||||
<div style={{ ...s.section, ...s.center }}>
|
||||
{logoSrc ? (
|
||||
<img src={logoSrc} alt={company.name} style={{ display: 'block', margin: '0 auto 4px', maxHeight: 48, maxWidth: 200, objectFit: 'contain' }} />
|
||||
) : (
|
||||
<div style={{ ...s.bold, fontSize: 14 }}>{company.name}</div>
|
||||
)}
|
||||
{location.name !== company.name && <div style={s.gray}>{location.name}</div>}
|
||||
{addr?.street && <div>{addr.street}</div>}
|
||||
{(addr?.city || addr?.state || addr?.zip) && (
|
||||
<div>{[addr?.city, addr?.state].filter(Boolean).join(', ')} {addr?.zip}</div>
|
||||
)}
|
||||
{phone && <div>{phone}</div>}
|
||||
{config?.header && <div style={{ ...s.gray, marginTop: 4 }}>{config.header}</div>}
|
||||
</div>
|
||||
|
||||
{/* Transaction info */}
|
||||
<div style={s.section}>
|
||||
<div style={s.row}>
|
||||
<span>{txn.transactionNumber}</span>
|
||||
<span>{txn.transactionType.replace('_', ' ')}</span>
|
||||
</div>
|
||||
<div style={{ ...s.row, ...s.gray }}>
|
||||
<span>{date.toLocaleDateString()}</span>
|
||||
<span>{date.toLocaleTimeString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Line items */}
|
||||
<div style={s.section}>
|
||||
{txn.lineItems.map((item, i) => (
|
||||
<div key={i} style={{ padding: '2px 0' }}>
|
||||
<div style={s.row}>
|
||||
<span style={{ flex: 1, paddingRight: 8 }}>{item.description}</span>
|
||||
<span style={s.nums}>${parseFloat(item.lineTotal).toFixed(2)}</span>
|
||||
</div>
|
||||
{(item.qty > 1 || parseFloat(item.discountAmount ?? '0') > 0) && (
|
||||
<div style={{ ...s.light, paddingLeft: 8 }}>
|
||||
{item.qty > 1 && <span>{item.qty} x ${parseFloat(item.unitPrice).toFixed(2)}</span>}
|
||||
{parseFloat(item.discountAmount ?? '0') > 0 && (
|
||||
<span style={{ marginLeft: 8 }}>disc -${parseFloat(item.discountAmount!).toFixed(2)}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Totals */}
|
||||
<div style={s.section}>
|
||||
<div style={s.row}><span>Subtotal</span><span style={s.nums}>${subtotal.toFixed(2)}</span></div>
|
||||
{discountTotal > 0 && (
|
||||
<div style={s.row}><span>Discount</span><span style={s.nums}>-${discountTotal.toFixed(2)}</span></div>
|
||||
)}
|
||||
<div style={s.row}><span>Tax</span><span style={s.nums}>${taxTotal.toFixed(2)}</span></div>
|
||||
{rounding !== 0 && (
|
||||
<div style={{ ...s.row, ...s.gray }}><span>Rounding</span><span style={s.nums}>{rounding > 0 ? '+' : ''}{rounding.toFixed(2)}</span></div>
|
||||
)}
|
||||
<div style={{ ...s.row, ...s.bold, fontSize: 14, paddingTop: 4 }}>
|
||||
<span>TOTAL</span><span style={s.nums}>${total.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment */}
|
||||
<div style={s.section}>
|
||||
<div style={s.row}><span>Payment</span><span style={{ textTransform: 'capitalize' }}>{txn.paymentMethod?.replace('_', ' ') ?? 'N/A'}</span></div>
|
||||
{tendered !== null && (
|
||||
<div style={s.row}><span>Tendered</span><span style={s.nums}>${tendered.toFixed(2)}</span></div>
|
||||
)}
|
||||
{change !== null && change > 0 && (
|
||||
<div style={{ ...s.row, ...s.bold }}><span>Change</span><span style={s.nums}>${change.toFixed(2)}</span></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Barcode */}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '12px 0' }}>
|
||||
<svg ref={barcodeRef} />
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{(config?.footer || footerText) && (
|
||||
<div style={{ ...s.center, ...s.light, paddingBottom: 4 }}>{config?.footer || footerText}</div>
|
||||
)}
|
||||
{config?.returnPolicy && (
|
||||
<div style={{ ...s.center, color: '#aaa', fontSize: 10, paddingBottom: 4 }}>{config.returnPolicy}</div>
|
||||
)}
|
||||
{config?.social && (
|
||||
<div style={{ ...s.center, ...s.light, paddingBottom: 8 }}>{config.social}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FullPageReceipt({ data, config, footerText }: { data: POSReceiptProps['data']; config?: ReceiptConfig; footerText?: string }) {
|
||||
const barcodeRef = useRef<SVGSVGElement>(null)
|
||||
const logoSrc = useStoreLogo()
|
||||
const { txn, company, location, date, subtotal, discountTotal, taxTotal, total, rounding, tendered, change, addr, phone, email } = useReceiptData(data)
|
||||
useBarcode(barcodeRef, txn.transactionNumber, { width: 2, height: 50, fontSize: 12 })
|
||||
|
||||
const f = (n: number) => `$${n.toFixed(2)}`
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: '#fff', color: '#000', fontFamily: 'Helvetica, Arial, sans-serif',
|
||||
width: '100%', maxWidth: 600, margin: '0 auto', fontSize: 13, lineHeight: '1.5',
|
||||
}}>
|
||||
{/* Header — company left, transaction right */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', paddingBottom: 16 }}>
|
||||
<div>
|
||||
{logoSrc ? (
|
||||
<img src={logoSrc} alt={company.name} style={{ maxHeight: 56, maxWidth: 200, objectFit: 'contain', marginBottom: 4 }} />
|
||||
) : (
|
||||
<div style={{ fontSize: 20, fontWeight: 'bold' }}>{company.name}</div>
|
||||
)}
|
||||
{location.name !== company.name && <div style={{ color: '#555', fontSize: 12 }}>{location.name}</div>}
|
||||
{addr?.street && <div style={{ fontSize: 12 }}>{addr.street}</div>}
|
||||
{(addr?.city || addr?.state || addr?.zip) && (
|
||||
<div style={{ fontSize: 12 }}>{[addr?.city, addr?.state].filter(Boolean).join(', ')} {addr?.zip}</div>
|
||||
)}
|
||||
{phone && <div style={{ fontSize: 12 }}>{phone}</div>}
|
||||
{email && <div style={{ fontSize: 12 }}>{email}</div>}
|
||||
{config?.header && <div style={{ fontSize: 12, color: '#555', marginTop: 2 }}>{config.header}</div>}
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 'bold' }}>{txn.transactionNumber}</div>
|
||||
<div style={{ fontSize: 12, color: '#555', textTransform: 'capitalize' }}>{txn.transactionType.replace('_', ' ')}</div>
|
||||
<div style={{ fontSize: 12, color: '#555' }}>{date.toLocaleDateString()}</div>
|
||||
<div style={{ fontSize: 12, color: '#555' }}>{date.toLocaleTimeString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={{ borderBottom: '1px solid #ddd', marginBottom: 16 }} />
|
||||
|
||||
{/* Line items table */}
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f5f5f5' }}>
|
||||
<th style={{ textAlign: 'left', padding: '6px 8px', fontWeight: 600, fontSize: 11, textTransform: 'uppercase', color: '#555' }}>Item</th>
|
||||
<th style={{ textAlign: 'right', padding: '6px 8px', fontWeight: 600, fontSize: 11, textTransform: 'uppercase', color: '#555', width: 50 }}>Qty</th>
|
||||
<th style={{ textAlign: 'right', padding: '6px 8px', fontWeight: 600, fontSize: 11, textTransform: 'uppercase', color: '#555', width: 80 }}>Price</th>
|
||||
<th style={{ textAlign: 'right', padding: '6px 8px', fontWeight: 600, fontSize: 11, textTransform: 'uppercase', color: '#555', width: 80 }}>Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{txn.lineItems.map((item, i) => (
|
||||
<tr key={i} style={{ borderBottom: '1px solid #eee' }}>
|
||||
<td style={{ padding: '6px 8px' }}>
|
||||
{item.description}
|
||||
{parseFloat(item.discountAmount ?? '0') > 0 && (
|
||||
<div style={{ fontSize: 11, color: '#999' }}>Discount: -{f(parseFloat(item.discountAmount!))}</div>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '6px 8px', textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>{item.qty}</td>
|
||||
<td style={{ padding: '6px 8px', textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>{f(parseFloat(item.unitPrice))}</td>
|
||||
<td style={{ padding: '6px 8px', textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>{f(parseFloat(item.lineTotal))}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Totals — right aligned */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 12 }}>
|
||||
<div style={{ width: 220 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '3px 0' }}>
|
||||
<span>Subtotal</span><span style={{ fontVariantNumeric: 'tabular-nums' }}>{f(subtotal)}</span>
|
||||
</div>
|
||||
{discountTotal > 0 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '3px 0', color: '#555' }}>
|
||||
<span>Discount</span><span style={{ fontVariantNumeric: 'tabular-nums' }}>-{f(discountTotal)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '3px 0' }}>
|
||||
<span>Tax</span><span style={{ fontVariantNumeric: 'tabular-nums' }}>{f(taxTotal)}</span>
|
||||
</div>
|
||||
{rounding !== 0 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '3px 0', color: '#888', fontSize: 12 }}>
|
||||
<span>Rounding</span><span style={{ fontVariantNumeric: 'tabular-nums' }}>{rounding > 0 ? '+' : ''}{rounding.toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ borderTop: '1px solid #ddd', marginTop: 4, paddingTop: 6, display: 'flex', justifyContent: 'space-between', fontWeight: 'bold', fontSize: 16 }}>
|
||||
<span>Total</span><span style={{ fontVariantNumeric: 'tabular-nums' }}>{f(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment info */}
|
||||
<div style={{ marginTop: 16, padding: '12px 0', borderTop: '1px solid #ddd' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>Payment Method</span>
|
||||
<span style={{ textTransform: 'capitalize', fontWeight: 500 }}>{txn.paymentMethod?.replace('_', ' ') ?? 'N/A'}</span>
|
||||
</div>
|
||||
{tendered !== null && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', color: '#555', fontSize: 12, marginTop: 2 }}>
|
||||
<span>Tendered</span><span style={{ fontVariantNumeric: 'tabular-nums' }}>{f(tendered)}</span>
|
||||
</div>
|
||||
)}
|
||||
{change !== null && change > 0 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontWeight: 'bold', marginTop: 2 }}>
|
||||
<span>Change</span><span style={{ fontVariantNumeric: 'tabular-nums' }}>{f(change)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Barcode */}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '16px 0' }}>
|
||||
<svg ref={barcodeRef} />
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{ borderTop: '1px solid #eee', paddingTop: 12, textAlign: 'center', fontSize: 11, color: '#999' }}>
|
||||
{(config?.footer || footerText) && <div>{config?.footer || footerText}</div>}
|
||||
{config?.returnPolicy && <div style={{ marginTop: 4 }}>{config.returnPolicy}</div>}
|
||||
{config?.social && <div style={{ marginTop: 4 }}>{config.social}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function printReceipt() {
|
||||
const el = document.getElementById('pos-receipt-print')
|
||||
if (!el) return
|
||||
|
||||
// Clone the receipt into an iframe for clean printing
|
||||
const iframe = document.createElement('iframe')
|
||||
iframe.style.cssText = 'position:fixed;left:-9999px;width:400px;height:800px;border:none;'
|
||||
document.body.appendChild(iframe)
|
||||
|
||||
const doc = iframe.contentDocument
|
||||
if (!doc) { document.body.removeChild(iframe); return }
|
||||
|
||||
doc.open()
|
||||
doc.write(`<!DOCTYPE html><html><head><style>body{margin:0;padding:8px;background:#fff;}</style></head><body>${el.innerHTML}</body></html>`)
|
||||
doc.close()
|
||||
|
||||
// Wait for content to render then print
|
||||
setTimeout(() => {
|
||||
try {
|
||||
iframe.contentWindow?.focus()
|
||||
iframe.contentWindow?.print()
|
||||
} catch {
|
||||
// Fallback: just use window.print
|
||||
window.print()
|
||||
}
|
||||
setTimeout(() => document.body.removeChild(iframe), 2000)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
export async function downloadReceiptPDF(txnNumber?: string, format: 'thermal' | 'full' = 'thermal') {
|
||||
const el = document.getElementById('pos-receipt-print')
|
||||
if (!el) return
|
||||
|
||||
const html2pdf = (await import('html2pdf.js')).default
|
||||
|
||||
if (format === 'full') {
|
||||
html2pdf()
|
||||
.set({
|
||||
margin: [10, 10, 10, 10],
|
||||
filename: `receipt-${txnNumber ?? 'unknown'}.pdf`,
|
||||
image: { type: 'jpeg', quality: 0.95 },
|
||||
html2canvas: { scale: 2, useCORS: true },
|
||||
jsPDF: { unit: 'mm', format: 'letter', orientation: 'portrait' },
|
||||
})
|
||||
.from(el)
|
||||
.save()
|
||||
} else {
|
||||
// Thermal — dynamic height based on content
|
||||
const heightPx = el.scrollHeight + 16
|
||||
const heightMm = Math.ceil(heightPx * 0.265) + 8
|
||||
html2pdf()
|
||||
.set({
|
||||
margin: [2, 2, 2, 2],
|
||||
filename: `receipt-${txnNumber ?? 'unknown'}.pdf`,
|
||||
image: { type: 'jpeg', quality: 0.95 },
|
||||
html2canvas: { scale: 2, useCORS: true, width: 280 },
|
||||
jsPDF: { unit: 'mm', format: [72, heightMm], orientation: 'portrait' },
|
||||
})
|
||||
.from(el)
|
||||
.save()
|
||||
}
|
||||
}
|
||||
138
packages/admin/src/components/pos/pos-register.tsx
Normal file
138
packages/admin/src/components/pos/pos-register.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { usePOSStore } from '@/stores/pos.store'
|
||||
import { currentDrawerOptions, transactionOptions } from '@/api/pos'
|
||||
import { POSTopBar } from './pos-top-bar'
|
||||
import { POSItemPanel } from './pos-item-panel'
|
||||
import { POSCartPanel } from './pos-cart-panel'
|
||||
import { POSLockScreen } from './pos-lock-screen'
|
||||
|
||||
interface Location {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface AppConfigEntry {
|
||||
key: string
|
||||
value: string | null
|
||||
}
|
||||
|
||||
function locationsOptions() {
|
||||
return queryOptions({
|
||||
queryKey: ['locations'],
|
||||
queryFn: () => api.get<{ data: Location[] }>('/v1/locations'),
|
||||
})
|
||||
}
|
||||
|
||||
function configOptions(key: string) {
|
||||
return queryOptions({
|
||||
queryKey: ['config', key],
|
||||
queryFn: async (): Promise<string | null> => {
|
||||
try {
|
||||
const entry = await api.get<AppConfigEntry>(`/v1/config/${key}`)
|
||||
return entry.value
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function POSRegister() {
|
||||
const { locationId, setLocation, currentTransactionId, setDrawerSession, locked, lock, touchActivity, token } = usePOSStore()
|
||||
|
||||
// Fetch lock timeout from config
|
||||
const { data: lockTimeoutStr } = useQuery({
|
||||
...configOptions('pos_lock_timeout'),
|
||||
enabled: !!token,
|
||||
})
|
||||
const lockTimeoutMinutes = parseInt(lockTimeoutStr ?? '15') || 15
|
||||
|
||||
// Auto-lock timer
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (locked || lockTimeoutMinutes === 0) {
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
return
|
||||
}
|
||||
|
||||
timerRef.current = setInterval(() => {
|
||||
const { lastActivity, locked: isLocked } = usePOSStore.getState()
|
||||
if (!isLocked && Date.now() - lastActivity > lockTimeoutMinutes * 60_000) {
|
||||
lock()
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
}
|
||||
}, [locked, lockTimeoutMinutes, lock])
|
||||
|
||||
// Track activity on any interaction
|
||||
const handleActivity = useCallback(() => {
|
||||
if (!locked) touchActivity()
|
||||
}, [locked, touchActivity])
|
||||
|
||||
// Fetch locations
|
||||
const { data: locationsData } = useQuery({
|
||||
...locationsOptions(),
|
||||
enabled: !!token,
|
||||
})
|
||||
const locations = locationsData?.data ?? []
|
||||
|
||||
// Auto-select first location
|
||||
useEffect(() => {
|
||||
if (!locationId && locations.length > 0) {
|
||||
setLocation(locations[0].id)
|
||||
}
|
||||
}, [locationId, locations, setLocation])
|
||||
|
||||
// Fetch current drawer for selected location
|
||||
const { data: drawer } = useQuery({
|
||||
...currentDrawerOptions(locationId),
|
||||
retry: false,
|
||||
enabled: !!locationId && !!token,
|
||||
})
|
||||
|
||||
// Sync drawer session ID
|
||||
useEffect(() => {
|
||||
if (drawer?.id && drawer.status === 'open') {
|
||||
setDrawerSession(drawer.id)
|
||||
} else {
|
||||
setDrawerSession(null)
|
||||
}
|
||||
}, [drawer, setDrawerSession])
|
||||
|
||||
// Fetch current transaction
|
||||
const { data: transaction } = useQuery({
|
||||
...transactionOptions(currentTransactionId),
|
||||
enabled: !!currentTransactionId && !!token,
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex flex-col h-full"
|
||||
onPointerDown={handleActivity}
|
||||
onKeyDown={handleActivity}
|
||||
>
|
||||
{locked && <POSLockScreen />}
|
||||
<POSTopBar
|
||||
locations={locations}
|
||||
locationId={locationId}
|
||||
onLocationChange={setLocation}
|
||||
drawer={drawer ?? null}
|
||||
/>
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<div className="w-[60%] border-r border-border overflow-hidden">
|
||||
<POSItemPanel transaction={transaction ?? null} />
|
||||
</div>
|
||||
<div className="w-[40%] overflow-hidden">
|
||||
<POSCartPanel transaction={transaction ?? null} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
263
packages/admin/src/components/pos/pos-repair-dialog.tsx
Normal file
263
packages/admin/src/components/pos/pos-repair-dialog.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { usePOSStore } from '@/stores/pos.store'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { posMutations, posKeys } from '@/api/pos'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Search, Wrench, Plus } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface Account {
|
||||
id: string
|
||||
name: string
|
||||
email: string | null
|
||||
phone: string | null
|
||||
accountNumber: string | null
|
||||
}
|
||||
|
||||
interface RepairTicketSummary {
|
||||
id: string
|
||||
ticketNumber: string | null
|
||||
customerName: string
|
||||
customerPhone: string | null
|
||||
itemDescription: string | null
|
||||
estimatedCost: string | null
|
||||
status: string
|
||||
}
|
||||
|
||||
interface POSRepairDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function POSRepairDialog({ open, onOpenChange }: POSRepairDialogProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const { locationId, setTransaction, setAccount } = usePOSStore()
|
||||
const [search, setSearch] = useState('')
|
||||
const [tab, setTab] = useState('pickup')
|
||||
|
||||
// --- Pickup tab ---
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['pos', 'repair-tickets-ready', search],
|
||||
queryFn: () => api.get<{ data: RepairTicketSummary[] }>('/v1/repair-tickets/ready', { q: search || undefined, limit: 20 }),
|
||||
enabled: open && tab === 'pickup',
|
||||
})
|
||||
const tickets = data?.data ?? []
|
||||
|
||||
const pickupMutation = useMutation({
|
||||
mutationFn: (ticketId: string) => posMutations.createFromRepair(ticketId, locationId ?? undefined),
|
||||
onSuccess: (txn) => {
|
||||
setTransaction(txn.id)
|
||||
if (txn.accountId) {
|
||||
const ticket = tickets.find((t) => t.id === pickupMutation.variables)
|
||||
if (ticket) setAccount(txn.accountId, ticket.customerName, ticket.customerPhone)
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: posKeys.transaction(txn.id) })
|
||||
toast.success(`Repair payment loaded — ${txn.transactionNumber}`)
|
||||
close()
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
// --- New intake tab ---
|
||||
const [customerName, setCustomerName] = useState('')
|
||||
const [customerPhone, setCustomerPhone] = useState('')
|
||||
const [itemDescription, setItemDescription] = useState('')
|
||||
const [problemDescription, setProblemDescription] = useState('')
|
||||
const [estimatedCost, setEstimatedCost] = useState('')
|
||||
const [accountId, setAccountId] = useState<string | null>(null)
|
||||
const [customerSearch, setCustomerSearch] = useState('')
|
||||
const [showCustomers, setShowCustomers] = useState(false)
|
||||
|
||||
const { data: customerData } = useQuery({
|
||||
queryKey: ['pos', 'accounts', customerSearch],
|
||||
queryFn: () => api.get<{ data: Account[] }>('/v1/accounts', { q: customerSearch, limit: 10 }),
|
||||
enabled: customerSearch.length >= 2 && tab === 'intake',
|
||||
})
|
||||
const customerResults = customerData?.data ?? []
|
||||
|
||||
function selectCustomer(account: Account) {
|
||||
setAccountId(account.id)
|
||||
setCustomerName(account.name)
|
||||
setCustomerPhone(account.phone ?? '')
|
||||
setCustomerSearch('')
|
||||
setShowCustomers(false)
|
||||
}
|
||||
|
||||
function clearCustomer() {
|
||||
setAccountId(null)
|
||||
setCustomerName('')
|
||||
setCustomerPhone('')
|
||||
}
|
||||
|
||||
const intakeMutation = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) =>
|
||||
api.post<{ id: string; ticketNumber: string }>('/v1/repair-tickets', data),
|
||||
onSuccess: (ticket) => {
|
||||
toast.success(`Repair ticket #${ticket.ticketNumber} created`)
|
||||
close()
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
function handleIntakeSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
intakeMutation.mutate({
|
||||
customerName,
|
||||
customerPhone: customerPhone || undefined,
|
||||
accountId: accountId ?? undefined,
|
||||
itemDescription: itemDescription || undefined,
|
||||
problemDescription,
|
||||
estimatedCost: estimatedCost ? parseFloat(estimatedCost) : undefined,
|
||||
locationId: locationId ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function close() {
|
||||
onOpenChange(false)
|
||||
setSearch('')
|
||||
setCustomerName('')
|
||||
setCustomerPhone('')
|
||||
setItemDescription('')
|
||||
setProblemDescription('')
|
||||
setEstimatedCost('')
|
||||
setAccountId(null)
|
||||
setCustomerSearch('')
|
||||
setShowCustomers(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) close(); else onOpenChange(true) }}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Wrench className="h-5 w-5" />Repairs
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs value={tab} onValueChange={setTab}>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="pickup" className="flex-1">Pickup</TabsTrigger>
|
||||
<TabsTrigger value="intake" className="flex-1">New Intake</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="pickup" className="mt-3 space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by ticket #, name, or phone..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
autoFocus={tab === 'pickup'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-64 overflow-y-auto space-y-1">
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">Loading...</p>
|
||||
) : tickets.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
{search ? 'No ready tickets found' : 'No tickets ready for pickup'}
|
||||
</p>
|
||||
) : (
|
||||
tickets.map((ticket) => (
|
||||
<button
|
||||
key={ticket.id}
|
||||
type="button"
|
||||
className="w-full text-left rounded-md border p-3 hover:bg-accent transition-colors"
|
||||
onClick={() => pickupMutation.mutate(ticket.id)}
|
||||
disabled={pickupMutation.isPending}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-sm">#{ticket.ticketNumber}</span>
|
||||
<Badge variant="outline" className="text-xs">Ready</Badge>
|
||||
</div>
|
||||
<div className="text-sm mt-0.5">{ticket.customerName}</div>
|
||||
{ticket.itemDescription && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5 truncate">{ticket.itemDescription}</div>
|
||||
)}
|
||||
{ticket.estimatedCost && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5">Est: ${ticket.estimatedCost}</div>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="intake" className="mt-3">
|
||||
<form onSubmit={handleIntakeSubmit} className="space-y-3">
|
||||
{/* Customer lookup */}
|
||||
<div className="relative space-y-1">
|
||||
<Label className="text-xs">Customer Lookup</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by name, phone, or email..."
|
||||
value={customerSearch}
|
||||
onChange={(e) => { setCustomerSearch(e.target.value); setShowCustomers(true) }}
|
||||
onFocus={() => customerSearch.length >= 2 && setShowCustomers(true)}
|
||||
className="pl-9 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{accountId && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Badge variant="secondary" className="text-[10px]">Linked</Badge>
|
||||
<span>{customerName}</span>
|
||||
<button type="button" className="underline text-destructive ml-1" onClick={clearCustomer}>clear</button>
|
||||
</div>
|
||||
)}
|
||||
{showCustomers && customerSearch.length >= 2 && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg max-h-40 overflow-auto">
|
||||
{customerResults.length === 0 ? (
|
||||
<div className="p-2 text-xs text-muted-foreground">No accounts found</div>
|
||||
) : customerResults.map((a) => (
|
||||
<button key={a.id} type="button" className="w-full text-left px-3 py-2 text-sm hover:bg-accent" onClick={() => selectCustomer(a)}>
|
||||
<div className="font-medium">{a.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{[a.phone, a.email].filter(Boolean).join(' · ')}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Customer Name *</Label>
|
||||
<Input value={customerName} onChange={(e) => setCustomerName(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Phone</Label>
|
||||
<Input value={customerPhone} onChange={(e) => setCustomerPhone(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Item Description</Label>
|
||||
<Input value={itemDescription} onChange={(e) => setItemDescription(e.target.value)} placeholder="e.g. Violin, iPhone 12, Trek bicycle" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Problem *</Label>
|
||||
<Textarea value={problemDescription} onChange={(e) => setProblemDescription(e.target.value)} rows={2} placeholder="What needs to be fixed?" required />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Estimated Cost</Label>
|
||||
<Input type="number" step="0.01" min="0" value={estimatedCost} onChange={(e) => setEstimatedCost(e.target.value)} placeholder="0.00" />
|
||||
</div>
|
||||
<Button type="submit" className="w-full gap-2" disabled={intakeMutation.isPending}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{intakeMutation.isPending ? 'Creating...' : 'Create Repair Ticket'}
|
||||
</Button>
|
||||
</form>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
98
packages/admin/src/components/pos/pos-top-bar.tsx
Normal file
98
packages/admin/src/components/pos/pos-top-bar.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { usePOSStore } from '@/stores/pos.store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { ArrowLeft, Lock, DollarSign, Receipt, FileText } from 'lucide-react'
|
||||
import type { DrawerSession } from '@/api/pos'
|
||||
import { useState } from 'react'
|
||||
import { POSDrawerDialog } from './pos-drawer-dialog'
|
||||
|
||||
interface POSTopBarProps {
|
||||
locations: { id: string; name: string }[]
|
||||
locationId: string | null
|
||||
onLocationChange: (id: string) => void
|
||||
drawer: DrawerSession | null
|
||||
}
|
||||
|
||||
export function POSTopBar({ locations, locationId, onLocationChange, drawer }: POSTopBarProps) {
|
||||
const cashier = usePOSStore((s) => s.cashier)
|
||||
const lockFn = usePOSStore((s) => s.lock)
|
||||
const receiptFormat = usePOSStore((s) => s.receiptFormat)
|
||||
const setReceiptFormat = usePOSStore((s) => s.setReceiptFormat)
|
||||
const [drawerDialogOpen, setDrawerDialogOpen] = useState(false)
|
||||
|
||||
const drawerOpen = drawer?.status === 'open'
|
||||
const isThermal = receiptFormat === 'thermal'
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-12 border-b border-border bg-card flex items-center justify-between px-3 shrink-0">
|
||||
{/* Left: back + location */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/login" className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Admin</span>
|
||||
</Link>
|
||||
|
||||
{locations.length > 1 ? (
|
||||
<Select value={locationId ?? ''} onValueChange={onLocationChange}>
|
||||
<SelectTrigger className="h-8 w-48 text-sm">
|
||||
<SelectValue placeholder="Select location" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{locations.map((loc) => (
|
||||
<SelectItem key={loc.id} value={loc.id}>{loc.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : locations.length === 1 ? (
|
||||
<span className="text-sm font-medium">{locations[0].name}</span>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setReceiptFormat(isThermal ? 'full' : 'thermal')}
|
||||
title={isThermal ? 'Receipt: Thermal — click to switch to Full Page' : 'Receipt: Full Page — click to switch to Thermal'}
|
||||
>
|
||||
{isThermal ? <Receipt className="h-3.5 w-3.5" /> : <FileText className="h-3.5 w-3.5" />}
|
||||
<span className="hidden sm:inline">{isThermal ? 'Thermal' : 'Full Page'}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Center: drawer status */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => setDrawerDialogOpen(true)}
|
||||
>
|
||||
<DollarSign className="h-4 w-4" />
|
||||
{drawerOpen ? (
|
||||
<Badge variant="default" className="text-xs">Drawer Open</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs">Drawer Closed</Badge>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Right: cashier + lock */}
|
||||
<div className="flex items-center gap-2">
|
||||
{cashier && (
|
||||
<span className="text-sm text-muted-foreground">{cashier.firstName}</span>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={lockFn} title="Lock POS">
|
||||
<Lock className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<POSDrawerDialog
|
||||
open={drawerDialogOpen}
|
||||
onOpenChange={setDrawerDialogOpen}
|
||||
drawer={drawer}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
145
packages/admin/src/components/pos/pos-transactions-dialog.tsx
Normal file
145
packages/admin/src/components/pos/pos-transactions-dialog.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { usePOSStore } from '@/stores/pos.store'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Printer } from 'lucide-react'
|
||||
import { POSReceipt, printReceipt, downloadReceiptPDF } from './pos-receipt'
|
||||
import type { Transaction } from '@/api/pos'
|
||||
|
||||
interface ReceiptData {
|
||||
transaction: Transaction & { lineItems: { description: string; qty: number; unitPrice: string; taxAmount: string; lineTotal: string; discountAmount: string }[] }
|
||||
company: { name: string; phone: string | null; email: string | null; address: { street?: string; city?: string; state?: string; zip?: string } | null }
|
||||
location: { name: string; phone: string | null; email: string | null; address: { street?: string; city?: string; state?: string; zip?: string } | null }
|
||||
}
|
||||
|
||||
interface AppConfigEntry { key: string; value: string | null }
|
||||
|
||||
function recentTransactionsOptions(search: string) {
|
||||
return queryOptions({
|
||||
queryKey: ['pos', 'recent-transactions', search],
|
||||
queryFn: () => api.get<{ data: Transaction[] }>('/v1/transactions', {
|
||||
limit: 15,
|
||||
sort: 'created_at',
|
||||
order: 'desc',
|
||||
...(search ? { q: search } : {}),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
interface POSTransactionsDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function POSTransactionsDialog({ open, onOpenChange }: POSTransactionsDialogProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [receiptTxnId, setReceiptTxnId] = useState<string | null>(null)
|
||||
|
||||
const { data: txnData } = useQuery({
|
||||
...recentTransactionsOptions(search),
|
||||
enabled: open,
|
||||
})
|
||||
const transactions = txnData?.data ?? []
|
||||
|
||||
// Fetch receipt for selected transaction
|
||||
const { data: receiptData } = useQuery({
|
||||
queryKey: ['pos', 'receipt', receiptTxnId],
|
||||
queryFn: () => api.get<ReceiptData>(`/v1/transactions/${receiptTxnId}/receipt`),
|
||||
enabled: !!receiptTxnId,
|
||||
})
|
||||
|
||||
const { data: configData } = useQuery({
|
||||
queryKey: ['config'],
|
||||
queryFn: () => api.get<{ data: AppConfigEntry[] }>('/v1/config'),
|
||||
enabled: !!receiptTxnId,
|
||||
})
|
||||
const receiptFormat = usePOSStore((s) => s.receiptFormat)
|
||||
const receiptConfig = {
|
||||
header: configData?.data?.find((c) => c.key === 'receipt_header')?.value || undefined,
|
||||
footer: configData?.data?.find((c) => c.key === 'receipt_footer')?.value || undefined,
|
||||
returnPolicy: configData?.data?.find((c) => c.key === 'receipt_return_policy')?.value || undefined,
|
||||
social: configData?.data?.find((c) => c.key === 'receipt_social')?.value || undefined,
|
||||
}
|
||||
|
||||
// Receipt view
|
||||
if (receiptTxnId && receiptData) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className={`${receiptFormat === 'full' ? 'max-w-2xl' : 'max-w-sm'} max-h-[90vh] overflow-y-auto`}>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setReceiptTxnId(null)}>Back</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => downloadReceiptPDF(receiptData.transaction.transactionNumber, receiptFormat)}>
|
||||
Save PDF
|
||||
</Button>
|
||||
<Button size="sm" onClick={printReceipt} className="gap-2">
|
||||
<Printer className="h-4 w-4" />Print
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="pos-receipt-print">
|
||||
<POSReceipt data={receiptData} size={receiptFormat} config={receiptConfig} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Recent Transactions</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search by transaction number..."
|
||||
className="h-10"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{transactions.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">No transactions found</p>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{transactions.map((txn) => (
|
||||
<button
|
||||
key={txn.id}
|
||||
onClick={() => setReceiptTxnId(txn.id)}
|
||||
className="w-full text-left py-2.5 px-1 hover:bg-accent/50 rounded transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-mono">{txn.transactionNumber}</span>
|
||||
<span className="text-sm font-semibold">${parseFloat(txn.total).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<Badge
|
||||
variant={txn.status === 'completed' ? 'default' : txn.status === 'voided' ? 'destructive' : 'outline'}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{txn.status}
|
||||
</Badge>
|
||||
{txn.paymentMethod && (
|
||||
<span className="text-xs text-muted-foreground">{txn.paymentMethod.replace('_', ' ')}</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{new Date(txn.completedAt ?? txn.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import jsPDF from 'jspdf'
|
||||
import { api } from '@/lib/api-client'
|
||||
import type { RepairTicket, RepairLineItem, RepairNote } from '@/types/repair'
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
@@ -24,7 +23,7 @@ interface GeneratePdfOptions {
|
||||
companyName?: string
|
||||
}
|
||||
|
||||
export function generateRepairPdf({ ticket, lineItems, notes, includeNotes, companyName = 'Forte Music' }: GeneratePdfOptions): jsPDF {
|
||||
export function generateRepairPdf({ ticket, lineItems, notes, includeNotes, companyName = 'LunarFront' }: GeneratePdfOptions): jsPDF {
|
||||
const doc = new jsPDF()
|
||||
let y = 20
|
||||
|
||||
@@ -57,11 +56,11 @@ export function generateRepairPdf({ ticket, lineItems, notes, includeNotes, comp
|
||||
doc.setFontSize(10)
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.text('Customer', 14, y)
|
||||
doc.text('Instrument', 110, y)
|
||||
doc.text('Item', 110, y)
|
||||
y += 5
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.text(ticket.customerName, 14, y)
|
||||
doc.text(ticket.instrumentDescription ?? '-', 110, y)
|
||||
doc.text(ticket.itemDescription ?? '-', 110, y)
|
||||
y += 5
|
||||
if (ticket.customerPhone) { doc.text(ticket.customerPhone, 14, y); y += 5 }
|
||||
if (ticket.serialNumber) { doc.text(`S/N: ${ticket.serialNumber}`, 110, y - 5) }
|
||||
@@ -86,8 +85,9 @@ export function generateRepairPdf({ ticket, lineItems, notes, includeNotes, comp
|
||||
doc.text(dateInfo, 14, y)
|
||||
y += 8
|
||||
|
||||
// Line items table
|
||||
if (lineItems.length > 0) {
|
||||
// Line items table (exclude consumables — internal only)
|
||||
const billableItems = lineItems.filter((i) => i.itemType !== 'consumable')
|
||||
if (billableItems.length > 0) {
|
||||
doc.setDrawColor(200)
|
||||
doc.line(14, y, 196, y)
|
||||
y += 6
|
||||
@@ -110,7 +110,7 @@ export function generateRepairPdf({ ticket, lineItems, notes, includeNotes, comp
|
||||
|
||||
// Table rows
|
||||
doc.setFont('helvetica', 'normal')
|
||||
for (const item of lineItems) {
|
||||
for (const item of billableItems) {
|
||||
if (y > 270) { doc.addPage(); y = 20 }
|
||||
doc.text(item.itemType.replace('_', ' '), 16, y)
|
||||
const descLines = doc.splitTextToSize(item.description, 85)
|
||||
@@ -128,7 +128,7 @@ export function generateRepairPdf({ ticket, lineItems, notes, includeNotes, comp
|
||||
y += 5
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(10)
|
||||
const total = lineItems.reduce((sum, i) => sum + parseFloat(i.totalPrice), 0)
|
||||
const total = billableItems.reduce((sum, i) => sum + parseFloat(i.totalPrice), 0)
|
||||
doc.text('Total:', 155, y, { align: 'right' })
|
||||
doc.text(`$${total.toFixed(2)}`, 190, y, { align: 'right' })
|
||||
y += 4
|
||||
|
||||
@@ -9,9 +9,9 @@ import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { FileText, Download, Check, Eye, Lock } from 'lucide-react'
|
||||
import { FileText, Download, Check, Eye } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { RepairTicket, RepairLineItem, RepairNote } from '@/types/repair'
|
||||
import type { RepairTicket, RepairLineItem } from '@/types/repair'
|
||||
|
||||
interface FileRecord {
|
||||
id: string
|
||||
@@ -65,7 +65,7 @@ export function PdfModal({ ticket, lineItems, ticketId }: PdfModalProps) {
|
||||
function toggleNote(id: string) {
|
||||
setSelectedNoteIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.has(id) ? next.delete(id) : next.add(id)
|
||||
if (next.has(id)) { next.delete(id) } else { next.add(id) }
|
||||
return next
|
||||
})
|
||||
}
|
||||
@@ -73,7 +73,7 @@ export function PdfModal({ ticket, lineItems, ticketId }: PdfModalProps) {
|
||||
function togglePhoto(id: string) {
|
||||
setSelectedPhotoIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.has(id) ? next.delete(id) : next.add(id)
|
||||
if (next.has(id)) { next.delete(id) } else { next.add(id) }
|
||||
return next
|
||||
})
|
||||
}
|
||||
@@ -102,7 +102,7 @@ export function PdfModal({ ticket, lineItems, ticketId }: PdfModalProps) {
|
||||
)
|
||||
toast.success('PDF generated and saved to documents')
|
||||
setOpen(false)
|
||||
} catch (err) {
|
||||
} catch {
|
||||
toast.error('Failed to generate PDF')
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Check, Truck, ClipboardList, Search, Clock, ThumbsUp, Wrench, Package, HandMetal, Ban, FilePlus } from 'lucide-react'
|
||||
import { Check, ClipboardList, Search, Clock, ThumbsUp, Wrench, Package, HandMetal, Ban, FilePlus } from 'lucide-react'
|
||||
|
||||
const STEPS = [
|
||||
{ key: 'new', label: 'New', icon: FilePlus },
|
||||
|
||||
@@ -4,7 +4,7 @@ import { queryOptions } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FileText, ImageIcon, Plus, Trash2 } from 'lucide-react'
|
||||
import { FileText, Plus, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
function AuthImage({ path, alt, className, onClick }: { path: string; alt: string; className?: string; onClick?: () => void }) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
@@ -23,9 +23,11 @@ function entityFilesOptions(entityType: string, entityId: string) {
|
||||
}
|
||||
|
||||
interface AvatarUploadProps {
|
||||
entityType: 'user' | 'member'
|
||||
entityType: 'user' | 'member' | 'company'
|
||||
entityId: string
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
category?: string
|
||||
placeholderIcon?: React.ComponentType<{ className?: string }>
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
@@ -40,16 +42,17 @@ const iconSizes = {
|
||||
lg: 'h-12 w-12',
|
||||
}
|
||||
|
||||
export function AvatarUpload({ entityType, entityId, size = 'lg' }: AvatarUploadProps) {
|
||||
export function AvatarUpload({ entityType, entityId, size = 'lg', category = 'profile', placeholderIcon: PlaceholderIcon }: AvatarUploadProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const token = useAuthStore((s) => s.token)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const IconComponent = PlaceholderIcon ?? User
|
||||
|
||||
const { data: filesData } = useQuery(entityFilesOptions(entityType, entityId))
|
||||
|
||||
// Find profile image from files
|
||||
const profileFile = filesData?.data?.find((f) => f.path.includes('/profile-'))
|
||||
// Find image by category
|
||||
const profileFile = filesData?.data?.find((f) => f.path.includes(`/${category}-`))
|
||||
const imageUrl = profileFile ? `/v1/files/serve/${profileFile.path}` : null
|
||||
|
||||
async function handleUpload(file: File) {
|
||||
@@ -59,7 +62,7 @@ export function AvatarUpload({ entityType, entityId, size = 'lg' }: AvatarUpload
|
||||
formData.append('file', file)
|
||||
formData.append('entityType', entityType)
|
||||
formData.append('entityId', entityId)
|
||||
formData.append('category', 'profile')
|
||||
formData.append('category', category)
|
||||
|
||||
// Delete existing profile image first
|
||||
if (profileFile) {
|
||||
@@ -105,7 +108,7 @@ export function AvatarUpload({ entityType, entityId, size = 'lg' }: AvatarUpload
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<User className={`${iconSizes[size]} text-muted-foreground`} />
|
||||
<IconComponent className={`${iconSizes[size]} text-muted-foreground`} />
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
|
||||
29
packages/admin/src/components/storage/file-icons.tsx
Normal file
29
packages/admin/src/components/storage/file-icons.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { FileText, Image, FileSpreadsheet, File, FileType, Film } from 'lucide-react'
|
||||
|
||||
const ICON_MAP: Record<string, { icon: typeof FileText; color: string }> = {
|
||||
'application/pdf': { icon: FileText, color: 'text-red-500' },
|
||||
'image/jpeg': { icon: Image, color: 'text-blue-500' },
|
||||
'image/png': { icon: Image, color: 'text-blue-500' },
|
||||
'image/webp': { icon: Image, color: 'text-blue-500' },
|
||||
'image/gif': { icon: Image, color: 'text-blue-500' },
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': { icon: FileType, color: 'text-blue-600' },
|
||||
'application/msword': { icon: FileType, color: 'text-blue-600' },
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': { icon: FileSpreadsheet, color: 'text-green-600' },
|
||||
'application/vnd.ms-excel': { icon: FileSpreadsheet, color: 'text-green-600' },
|
||||
'text/csv': { icon: FileSpreadsheet, color: 'text-green-600' },
|
||||
'text/plain': { icon: FileText, color: 'text-muted-foreground' },
|
||||
'video/mp4': { icon: Film, color: 'text-purple-500' },
|
||||
}
|
||||
|
||||
export function FileIcon({ contentType, className = 'h-8 w-8' }: { contentType: string; className?: string }) {
|
||||
const match = ICON_MAP[contentType] ?? { icon: File, color: 'text-muted-foreground' }
|
||||
const Icon = match.icon
|
||||
return <Icon className={`${className} ${match.color}`} />
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
storageFolderPermissionsOptions, storageFolderMutations, storageFolderKeys,
|
||||
} from '@/api/storage'
|
||||
import { roleListOptions } from '@/api/rbac'
|
||||
import { userListOptions } from '@/api/users'
|
||||
import type { UserRecord } from '@/api/users'
|
||||
import type { Role } from '@/types/rbac'
|
||||
import type { StorageFolderPermission } from '@/types/storage'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Trash2, Shield, Users, User } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface FolderPermissionsDialogProps {
|
||||
folderId: string
|
||||
folderName: string
|
||||
isPublic: boolean
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
const ACCESS_LEVELS = [
|
||||
{ value: 'traverse', label: 'Traverse', variant: 'outline' as const },
|
||||
{ value: 'view', label: 'View', variant: 'secondary' as const },
|
||||
{ value: 'edit', label: 'Edit', variant: 'default' as const },
|
||||
{ value: 'admin', label: 'Admin', variant: 'destructive' as const },
|
||||
]
|
||||
|
||||
export function FolderPermissionsDialog({ folderId, folderName, isPublic, open, onOpenChange }: FolderPermissionsDialogProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [assigneeType, setAssigneeType] = useState<'role' | 'user'>('role')
|
||||
const [assigneeId, setAssigneeId] = useState('')
|
||||
const [accessLevel, setAccessLevel] = useState('view')
|
||||
|
||||
const { data: permissionsData, isLoading: permsLoading } = useQuery({
|
||||
...storageFolderPermissionsOptions(folderId),
|
||||
enabled: open && !!folderId,
|
||||
})
|
||||
const { data: rolesData } = useQuery({ ...roleListOptions(), enabled: open })
|
||||
const { data: usersData } = useQuery({
|
||||
...userListOptions({ page: 1, limit: 100, order: 'asc' }),
|
||||
enabled: open && assigneeType === 'user',
|
||||
})
|
||||
|
||||
const permissions = permissionsData?.data ?? []
|
||||
const roles = rolesData?.data ?? []
|
||||
const users = usersData?.data ?? []
|
||||
|
||||
const togglePublicMutation = useMutation({
|
||||
mutationFn: (newIsPublic: boolean) => storageFolderMutations.update(folderId, { isPublic: newIsPublic }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: storageFolderKeys.all })
|
||||
queryClient.invalidateQueries({ queryKey: storageFolderKeys.detail(folderId) })
|
||||
toast.success(isPublic ? 'Folder set to private' : 'Folder set to public')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const addPermissionMutation = useMutation({
|
||||
mutationFn: () => storageFolderMutations.addPermission(folderId, {
|
||||
...(assigneeType === 'role' ? { roleId: assigneeId } : { userId: assigneeId }),
|
||||
accessLevel,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: storageFolderKeys.permissions(folderId) })
|
||||
setAssigneeId('')
|
||||
setAccessLevel('view')
|
||||
toast.success('Permission added')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const removePermissionMutation = useMutation({
|
||||
mutationFn: (permId: string) => storageFolderMutations.removePermission(permId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: storageFolderKeys.permissions(folderId) })
|
||||
toast.success('Permission removed')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
function getPermissionLabel(perm: StorageFolderPermission): { icon: typeof Shield; name: string } {
|
||||
if (perm.roleId) {
|
||||
const role = roles.find((r: Role) => r.id === perm.roleId)
|
||||
return { icon: Users, name: role?.name ?? 'Unknown role' }
|
||||
}
|
||||
const user = users.find((u: UserRecord) => u.id === perm.userId)
|
||||
return { icon: User, name: user ? `${user.firstName} ${user.lastName}` : 'Unknown user' }
|
||||
}
|
||||
|
||||
function handleAdd(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!assigneeId) return
|
||||
addPermissionMutation.mutate()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
Permissions — {folderName}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5">
|
||||
{/* Public toggle */}
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Public folder</Label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Public folders are viewable by all users with file access
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isPublic}
|
||||
onCheckedChange={(checked) => togglePublicMutation.mutate(checked)}
|
||||
disabled={togglePublicMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Current permissions */}
|
||||
<div>
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Permissions
|
||||
</Label>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{permsLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
) : permissions.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No specific permissions assigned</p>
|
||||
) : (
|
||||
permissions.map((perm) => {
|
||||
const { icon: Icon, name } = getPermissionLabel(perm)
|
||||
const level = ACCESS_LEVELS.find((l) => l.value === perm.accessLevel)
|
||||
return (
|
||||
<div key={perm.id} className="flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="text-sm truncate">{name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge variant={level?.variant ?? 'secondary'} className="text-xs">
|
||||
{level?.label ?? perm.accessLevel}
|
||||
</Badge>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removePermissionMutation.mutate(perm.id)}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors"
|
||||
disabled={removePermissionMutation.isPending}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add permission form */}
|
||||
<form onSubmit={handleAdd} className="space-y-3">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Add Permission
|
||||
</Label>
|
||||
|
||||
{/* Role / User toggle */}
|
||||
<div className="flex gap-1 rounded-md border p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setAssigneeType('role'); setAssigneeId('') }}
|
||||
className={`flex-1 rounded-sm px-3 py-1.5 text-sm font-medium transition-colors ${assigneeType === 'role' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'}`}
|
||||
>
|
||||
Role
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setAssigneeType('user'); setAssigneeId('') }}
|
||||
className={`flex-1 rounded-sm px-3 py-1.5 text-sm font-medium transition-colors ${assigneeType === 'user' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'}`}
|
||||
>
|
||||
User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{/* Assignee select */}
|
||||
<Select value={assigneeId} onValueChange={setAssigneeId}>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder={assigneeType === 'role' ? 'Select role...' : 'Select user...'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{assigneeType === 'role'
|
||||
? roles.map((role: Role) => (
|
||||
<SelectItem key={role.id} value={role.id}>{role.name}</SelectItem>
|
||||
))
|
||||
: users.map((user: UserRecord) => (
|
||||
<SelectItem key={user.id} value={user.id}>
|
||||
{user.firstName} {user.lastName}
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Access level select */}
|
||||
<Select value={accessLevel} onValueChange={setAccessLevel}>
|
||||
<SelectTrigger className="w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCESS_LEVELS.map((level) => (
|
||||
<SelectItem key={level.value} value={level.value}>{level.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button type="submit" size="sm" disabled={!assigneeId || addPermissionMutation.isPending} className="w-full">
|
||||
{addPermissionMutation.isPending ? 'Adding...' : 'Add Permission'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
91
packages/admin/src/components/storage/folder-tree.tsx
Normal file
91
packages/admin/src/components/storage/folder-tree.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronRight, ChevronDown, Folder, FolderOpen, Lock } from 'lucide-react'
|
||||
import type { StorageFolder } from '@/types/storage'
|
||||
|
||||
interface FolderTreeProps {
|
||||
folders: StorageFolder[]
|
||||
selectedFolderId: string | null
|
||||
onSelect: (folderId: string | null) => void
|
||||
}
|
||||
|
||||
interface TreeNode {
|
||||
folder: StorageFolder
|
||||
children: TreeNode[]
|
||||
}
|
||||
|
||||
function buildTree(folders: StorageFolder[]): TreeNode[] {
|
||||
const map = new Map<string, TreeNode>()
|
||||
const roots: TreeNode[] = []
|
||||
|
||||
for (const folder of folders) {
|
||||
map.set(folder.id, { folder, children: [] })
|
||||
}
|
||||
|
||||
for (const folder of folders) {
|
||||
const node = map.get(folder.id)!
|
||||
if (folder.parentId && map.has(folder.parentId)) {
|
||||
map.get(folder.parentId)!.children.push(node)
|
||||
} else {
|
||||
roots.push(node)
|
||||
}
|
||||
}
|
||||
|
||||
return roots
|
||||
}
|
||||
|
||||
export function FolderTree({ folders, selectedFolderId, onSelect }: FolderTreeProps) {
|
||||
const tree = buildTree(folders)
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(null)}
|
||||
className={`flex items-center gap-2 w-full text-left px-2 py-1.5 rounded-md text-sm transition-colors ${
|
||||
selectedFolderId === null ? 'bg-accent text-accent-foreground font-medium' : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
}`}
|
||||
>
|
||||
<Folder className="h-4 w-4 shrink-0" />
|
||||
<span>All Files</span>
|
||||
</button>
|
||||
{tree.map((node) => (
|
||||
<TreeItem key={node.folder.id} node={node} depth={0} selectedFolderId={selectedFolderId} onSelect={onSelect} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TreeItem({ node, depth, selectedFolderId, onSelect }: { node: TreeNode; depth: number; selectedFolderId: string | null; onSelect: (id: string) => void }) {
|
||||
const [expanded, setExpanded] = useState(depth < 2)
|
||||
const hasChildren = node.children.length > 0
|
||||
const isSelected = selectedFolderId === node.folder.id
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onSelect(node.folder.id); if (hasChildren) setExpanded(!expanded) }}
|
||||
className={`flex items-center gap-1 w-full text-left px-2 py-1.5 rounded-md text-sm transition-colors ${
|
||||
isSelected ? 'bg-accent text-accent-foreground font-medium' : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
}`}
|
||||
style={{ paddingLeft: `${8 + depth * 16}px` }}
|
||||
>
|
||||
{hasChildren ? (
|
||||
expanded ? <ChevronDown className="h-3 w-3 shrink-0" /> : <ChevronRight className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<span className="w-3 shrink-0" />
|
||||
)}
|
||||
{isSelected ? <FolderOpen className="h-4 w-4 shrink-0 text-primary" /> : <Folder className="h-4 w-4 shrink-0" />}
|
||||
<span className="truncate">{node.folder.name}</span>
|
||||
{!node.folder.isPublic && <Lock className="h-3 w-3 shrink-0 text-muted-foreground/50" />}
|
||||
</button>
|
||||
{expanded && hasChildren && (
|
||||
<div>
|
||||
{node.children.map((child) => (
|
||||
<TreeItem key={child.folder.id} node={child} depth={depth + 1} selectedFolderId={selectedFolderId} onSelect={onSelect} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
142
packages/admin/src/components/ui/alert-dialog.tsx
Normal file
142
packages/admin/src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import * as React from "react"
|
||||
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
66
packages/admin/src/components/ui/alert.tsx
Normal file
66
packages/admin/src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
30
packages/admin/src/components/ui/checkbox.tsx
Normal file
30
packages/admin/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as React from "react"
|
||||
import { Checkbox as CheckboxPrimitive } from "radix-ui"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary dark:bg-input/30 dark:data-[state=checked]:bg-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="flex items-center justify-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
46
packages/admin/src/components/ui/popover.tsx
Normal file
46
packages/admin/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import { Popover as PopoverPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
33
packages/admin/src/components/ui/switch.tsx
Normal file
33
packages/admin/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import * as React from "react"
|
||||
import { Switch as SwitchPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { vaultCategoryPermissionsOptions, vaultCategoryMutations, vaultKeys } from '@/api/vault'
|
||||
import { roleListOptions } from '@/api/rbac'
|
||||
import { userListOptions } from '@/api/users'
|
||||
import type { UserRecord } from '@/api/users'
|
||||
import type { Role } from '@/types/rbac'
|
||||
import type { VaultCategoryPermission } from '@/types/vault'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Trash2, Shield, Users, User } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface Props {
|
||||
categoryId: string
|
||||
categoryName: string
|
||||
isPublic: boolean
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
const ACCESS_LEVELS = [
|
||||
{ value: 'view', label: 'View', variant: 'secondary' as const },
|
||||
{ value: 'edit', label: 'Edit', variant: 'default' as const },
|
||||
{ value: 'admin', label: 'Admin', variant: 'destructive' as const },
|
||||
]
|
||||
|
||||
export function CategoryPermissionsDialog({ categoryId, categoryName, isPublic, open, onOpenChange }: Props) {
|
||||
const queryClient = useQueryClient()
|
||||
const [assigneeType, setAssigneeType] = useState<'role' | 'user'>('role')
|
||||
const [assigneeId, setAssigneeId] = useState('')
|
||||
const [accessLevel, setAccessLevel] = useState('view')
|
||||
|
||||
const { data: permissionsData, isLoading: permsLoading } = useQuery({
|
||||
...vaultCategoryPermissionsOptions(categoryId),
|
||||
enabled: open && !!categoryId,
|
||||
})
|
||||
const { data: rolesData } = useQuery({ ...roleListOptions(), enabled: open })
|
||||
const { data: usersData } = useQuery({
|
||||
...userListOptions({ page: 1, limit: 100, order: 'asc' }),
|
||||
enabled: open && assigneeType === 'user',
|
||||
})
|
||||
|
||||
const permissions = permissionsData?.data ?? []
|
||||
const roles = rolesData?.data ?? []
|
||||
const users = usersData?.data ?? []
|
||||
|
||||
const togglePublicMutation = useMutation({
|
||||
mutationFn: (newIsPublic: boolean) => vaultCategoryMutations.update(categoryId, { isPublic: newIsPublic }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: vaultKeys.categories })
|
||||
queryClient.invalidateQueries({ queryKey: vaultKeys.categoryDetail(categoryId) })
|
||||
toast.success(isPublic ? 'Category set to private' : 'Category set to public')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const addPermissionMutation = useMutation({
|
||||
mutationFn: () => vaultCategoryMutations.addPermission(categoryId, {
|
||||
...(assigneeType === 'role' ? { roleId: assigneeId } : { userId: assigneeId }),
|
||||
accessLevel,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: vaultKeys.categoryPermissions(categoryId) })
|
||||
setAssigneeId('')
|
||||
setAccessLevel('view')
|
||||
toast.success('Permission added')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const removePermissionMutation = useMutation({
|
||||
mutationFn: (permId: string) => vaultCategoryMutations.removePermission(permId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: vaultKeys.categoryPermissions(categoryId) })
|
||||
toast.success('Permission removed')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
function getPermissionLabel(perm: VaultCategoryPermission) {
|
||||
if (perm.roleId) {
|
||||
const role = roles.find((r: Role) => r.id === perm.roleId)
|
||||
return { icon: Users, name: role?.name ?? 'Unknown role' }
|
||||
}
|
||||
const user = users.find((u: UserRecord) => u.id === perm.userId)
|
||||
return { icon: User, name: user ? `${user.firstName} ${user.lastName}` : 'Unknown user' }
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
Permissions — {categoryName}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Public category</Label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Public categories are viewable by all users with vault access</p>
|
||||
</div>
|
||||
<Switch checked={isPublic} onCheckedChange={(checked) => togglePublicMutation.mutate(checked)} disabled={togglePublicMutation.isPending} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Permissions</Label>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{permsLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
) : permissions.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No specific permissions assigned</p>
|
||||
) : (
|
||||
permissions.map((perm) => {
|
||||
const { icon: Icon, name } = getPermissionLabel(perm)
|
||||
const level = ACCESS_LEVELS.find((l) => l.value === perm.accessLevel)
|
||||
return (
|
||||
<div key={perm.id} className="flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="text-sm truncate">{name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge variant={level?.variant ?? 'secondary'} className="text-xs">{level?.label ?? perm.accessLevel}</Badge>
|
||||
<button type="button" onClick={() => removePermissionMutation.mutate(perm.id)} className="text-muted-foreground hover:text-destructive transition-colors" disabled={removePermissionMutation.isPending}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={(e) => { e.preventDefault(); if (assigneeId) addPermissionMutation.mutate() }} className="space-y-3">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Add Permission</Label>
|
||||
<div className="flex gap-1 rounded-md border p-0.5">
|
||||
<button type="button" onClick={() => { setAssigneeType('role'); setAssigneeId('') }} className={`flex-1 rounded-sm px-3 py-1.5 text-sm font-medium transition-colors ${assigneeType === 'role' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'}`}>Role</button>
|
||||
<button type="button" onClick={() => { setAssigneeType('user'); setAssigneeId('') }} className={`flex-1 rounded-sm px-3 py-1.5 text-sm font-medium transition-colors ${assigneeType === 'user' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'}`}>User</button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select value={assigneeId} onValueChange={setAssigneeId}>
|
||||
<SelectTrigger className="flex-1"><SelectValue placeholder={assigneeType === 'role' ? 'Select role...' : 'Select user...'} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{assigneeType === 'role'
|
||||
? roles.map((role: Role) => <SelectItem key={role.id} value={role.id}>{role.name}</SelectItem>)
|
||||
: users.map((user: UserRecord) => <SelectItem key={user.id} value={user.id}>{user.firstName} {user.lastName}</SelectItem>)
|
||||
}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={accessLevel} onValueChange={setAccessLevel}>
|
||||
<SelectTrigger className="w-28"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCESS_LEVELS.map((level) => <SelectItem key={level.value} value={level.value}>{level.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button type="submit" size="sm" disabled={!assigneeId || addPermissionMutation.isPending} className="w-full">
|
||||
{addPermissionMutation.isPending ? 'Adding...' : 'Add Permission'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useNavigate, useSearch } from '@tanstack/react-router'
|
||||
import type { PaginationInput } from '@forte/shared/schemas'
|
||||
import type { PaginationInput } from '@lunarfront/shared/schemas'
|
||||
|
||||
interface PaginationSearch {
|
||||
page?: number
|
||||
@@ -23,12 +23,13 @@ export function usePagination() {
|
||||
|
||||
function setParams(updates: Partial<PaginationSearch>) {
|
||||
navigate({
|
||||
search: ((prev: PaginationSearch) => ({
|
||||
// @ts-expect-error: navigate without a route context resolves search as never; safe here since we use strict:false
|
||||
search: (prev: any) => ({
|
||||
...prev,
|
||||
...updates,
|
||||
// Reset to page 1 when search or sort changes
|
||||
page: updates.q !== undefined || updates.sort !== undefined ? 1 : (updates.page ?? prev.page),
|
||||
})) as any,
|
||||
page: updates.q !== undefined || updates.sort !== undefined ? 1 : (updates.page ?? (prev as PaginationSearch).page),
|
||||
}),
|
||||
replace: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { usePOSStore } from '@/stores/pos.store'
|
||||
|
||||
class ApiError extends Error {
|
||||
statusCode: number
|
||||
@@ -13,7 +14,8 @@ class ApiError extends Error {
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const { token } = useAuthStore.getState()
|
||||
// Use POS token if available (POS screen), otherwise admin token
|
||||
const token = usePOSStore.getState().token ?? useAuthStore.getState().token
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
|
||||
@@ -32,9 +34,12 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
|
||||
})
|
||||
|
||||
if (res.status === 401) {
|
||||
useAuthStore.getState().logout()
|
||||
// Don't use window.location — that causes a full reload and flash
|
||||
// The router's beforeLoad guard will redirect to /login on next navigation
|
||||
// On POS, lock the screen instead of logging out admin
|
||||
if (usePOSStore.getState().token) {
|
||||
usePOSStore.getState().lock()
|
||||
} else {
|
||||
useAuthStore.getState().logout()
|
||||
}
|
||||
throw new ApiError('Unauthorized', 401)
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user