Compare commits
88 Commits
feat/ci-cd
...
feature/po
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
97638b888e | ||
|
|
6852a79f87 | ||
|
|
a561b184e1 | ||
|
|
7864c07be1 | ||
|
|
1e38d69b21 | ||
|
|
eb9e669233 | ||
|
|
13db5ce5f1 | ||
|
|
babfccaa1b | ||
|
|
1aa29dfb31 | ||
|
|
efb55bc784 | ||
|
|
9cdb2cf427 | ||
|
|
135b88029a | ||
|
|
23df7feaf1 | ||
|
|
2e2832b1e3 | ||
|
|
dd846bc86a | ||
|
|
25e9177554 | ||
|
|
cfd1561de9 | ||
|
|
6304d14e56 | ||
|
|
e4fe42c6ec | ||
|
|
27a9900787 | ||
|
|
90cbff0611 | ||
|
|
ddae05dc3f | ||
|
|
12fa36a7b0 | ||
|
|
fc7d92e33f | ||
|
|
8f941381f9 | ||
|
|
83b48cb3be |
@@ -6,6 +6,7 @@ planning
|
||||
deploy
|
||||
infra
|
||||
packages/admin
|
||||
!packages/admin/package.json
|
||||
Dockerfile*
|
||||
docker-compose*
|
||||
*.md
|
||||
|
||||
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
|
||||
|
||||
@@ -5,95 +5,62 @@ on:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: build
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
if: "!startsWith(github.event.head_commit.message, 'chore: bump version')"
|
||||
env:
|
||||
REGISTRY: registry.digitalocean.com/lunarfront
|
||||
DOCKER_HOST: tcp://localhost:2375
|
||||
VERSION: 0.1.${{ github.run_number }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.BOT_TOKEN }}
|
||||
|
||||
- name: Determine version bump
|
||||
id: bump
|
||||
run: |
|
||||
COMMIT_MSG=$(git log -1 --pretty=%s)
|
||||
if echo "$COMMIT_MSG" | grep -qiE "^breaking(\(.+\))?:|^.+!:"; then
|
||||
echo "type=major" >> $GITHUB_OUTPUT
|
||||
elif echo "$COMMIT_MSG" | grep -qiE "^feat(\(.+\))?:"; then
|
||||
echo "type=minor" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "type=patch" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Bump version in package.json
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(node -e "
|
||||
const fs = require('fs');
|
||||
const pkg = JSON.parse(fs.readFileSync('packages/backend/package.json', 'utf8'));
|
||||
const [major, minor, patch] = pkg.version.split('.').map(Number);
|
||||
const type = '${{ steps.bump.outputs.type }}';
|
||||
if (type === 'major') pkg.version = \`\${major + 1}.0.0\`;
|
||||
else if (type === 'minor') pkg.version = \`\${major}.\${minor + 1}.0\`;
|
||||
else pkg.version = \`\${major}.\${minor}.\${patch + 1}\`;
|
||||
fs.writeFileSync('packages/backend/package.json', JSON.stringify(pkg, null, 2) + '\n');
|
||||
console.log(pkg.version);
|
||||
")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Commit version bump
|
||||
run: |
|
||||
git config user.name "lunarfront-bot"
|
||||
git config user.email "bot@lunarfront.tech"
|
||||
git remote set-url origin https://lunarfront-bot:${{ secrets.BOT_TOKEN }}@git.lunarfront.tech/ryan/lunarfront-app.git
|
||||
git add packages/backend/package.json
|
||||
git commit -m "chore: bump version to v${{ steps.version.outputs.version }}"
|
||||
git push origin main
|
||||
|
||||
- name: Install Docker CLI
|
||||
run: |
|
||||
apt-get update -qq
|
||||
apt-get install -y ca-certificates curl
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo $VERSION_CODENAME) stable" > /etc/apt/sources.list.d/docker.list
|
||||
apt-get update -qq
|
||||
apt-get install -y docker-ce-cli
|
||||
|
||||
- name: Login to registry
|
||||
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login registry.lunarfront.tech -u ryan --password-stdin
|
||||
- name: Login to DOCR
|
||||
run: echo "${{ secrets.DOCR_TOKEN }}" | docker login registry.digitalocean.com -u token --password-stdin
|
||||
|
||||
- name: Build and push backend
|
||||
run: |
|
||||
VERSION=${{ steps.version.outputs.version }}
|
||||
SHA=$(git rev-parse --short HEAD)
|
||||
docker build \
|
||||
--build-arg APP_VERSION=$VERSION \
|
||||
-t registry.lunarfront.tech/ryan/lunarfront-app:$VERSION \
|
||||
-t registry.lunarfront.tech/ryan/lunarfront-app:$SHA \
|
||||
-t registry.lunarfront.tech/ryan/lunarfront-app:latest \
|
||||
-t $REGISTRY/lunarfront-app:$VERSION \
|
||||
-t $REGISTRY/lunarfront-app:$SHA \
|
||||
-t $REGISTRY/lunarfront-app:latest \
|
||||
-f Dockerfile .
|
||||
docker push registry.lunarfront.tech/ryan/lunarfront-app:$VERSION
|
||||
docker push registry.lunarfront.tech/ryan/lunarfront-app:$SHA
|
||||
docker push registry.lunarfront.tech/ryan/lunarfront-app:latest
|
||||
docker push $REGISTRY/lunarfront-app:$VERSION
|
||||
docker push $REGISTRY/lunarfront-app:$SHA
|
||||
docker push $REGISTRY/lunarfront-app:latest
|
||||
|
||||
- name: Build and push frontend
|
||||
run: |
|
||||
VERSION=${{ steps.version.outputs.version }}
|
||||
SHA=$(git rev-parse --short HEAD)
|
||||
docker build \
|
||||
-t registry.lunarfront.tech/ryan/lunarfront-frontend:$VERSION \
|
||||
-t registry.lunarfront.tech/ryan/lunarfront-frontend:$SHA \
|
||||
-t registry.lunarfront.tech/ryan/lunarfront-frontend:latest \
|
||||
-t $REGISTRY/lunarfront-frontend:$VERSION \
|
||||
-t $REGISTRY/lunarfront-frontend:$SHA \
|
||||
-t $REGISTRY/lunarfront-frontend:latest \
|
||||
-f Dockerfile.frontend .
|
||||
docker push registry.lunarfront.tech/ryan/lunarfront-frontend:$VERSION
|
||||
docker push registry.lunarfront.tech/ryan/lunarfront-frontend:$SHA
|
||||
docker push registry.lunarfront.tech/ryan/lunarfront-frontend:latest
|
||||
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.lunarfront.tech
|
||||
run: docker logout registry.digitalocean.com
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
@@ -32,6 +30,8 @@ jobs:
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
needs: ci
|
||||
env:
|
||||
DOCKER_HOST: tcp://localhost:2375
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
112
CLAUDE.md
112
CLAUDE.md
@@ -63,3 +63,115 @@
|
||||
- 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
|
||||
|
||||
15
Dockerfile
15
Dockerfile
@@ -3,10 +3,12 @@ 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 AS build
|
||||
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
|
||||
@@ -15,18 +17,11 @@ COPY packages/shared ./packages/shared
|
||||
COPY packages/backend ./packages/backend
|
||||
COPY package.json ./
|
||||
COPY tsconfig.base.json ./
|
||||
WORKDIR /app/packages/backend
|
||||
RUN bun build src/main.ts --compile --outfile /app/server \
|
||||
--define "process.env.APP_VERSION='${APP_VERSION}'"
|
||||
|
||||
FROM alpine:3.21
|
||||
RUN addgroup -S app && adduser -S app -G app
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/server ./server
|
||||
COPY --from=build /app/packages/backend/src/db/migrations ./migrations
|
||||
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 ["./server"]
|
||||
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 \
|
||||
nano vim htop netcat-openbsd dnsutils iputils-ping \
|
||||
&& 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 ["/entrypoint.sh"]
|
||||
@@ -3,6 +3,7 @@ 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
|
||||
@@ -19,6 +20,8 @@ RUN bun run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/packages/admin/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
# 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;"]
|
||||
|
||||
@@ -6,6 +6,7 @@ planning
|
||||
deploy
|
||||
infra
|
||||
packages/backend
|
||||
!packages/backend/package.json
|
||||
Dockerfile*
|
||||
docker-compose*
|
||||
*.md
|
||||
|
||||
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 }}
|
||||
89
chart/templates/backend-deployment.yaml
Normal file
89
chart/templates/backend-deployment.yaml
Normal file
@@ -0,0 +1,89 @@
|
||||
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
|
||||
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.
|
||||
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
|
||||
@@ -3,9 +3,9 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Proxy API and WebDAV to backend
|
||||
# Proxy API and WebDAV to backend — BACKEND_URL injected at runtime via envsubst
|
||||
location /v1/ {
|
||||
proxy_pass http://localhost:8000;
|
||||
proxy_pass ${BACKEND_URL};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -14,7 +14,7 @@ server {
|
||||
}
|
||||
|
||||
location /webdav/ {
|
||||
proxy_pass http://localhost:8000;
|
||||
proxy_pass ${BACKEND_URL};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
163
packages/admin/src/api/pos.ts
Normal file
163
packages/admin/src/api/pos.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
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
|
||||
}
|
||||
|
||||
// --- Query Keys ---
|
||||
|
||||
export const posKeys = {
|
||||
transaction: (id: string) => ['pos', 'transaction', id] as const,
|
||||
drawer: (locationId: string) => ['pos', 'drawer', 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: () => api.get<DrawerSession>('/v1/drawer/current', { locationId }),
|
||||
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 }),
|
||||
enabled: search.length >= 1,
|
||||
})
|
||||
}
|
||||
|
||||
export function discountListOptions() {
|
||||
return queryOptions({
|
||||
queryKey: posKeys.discounts,
|
||||
queryFn: () => api.get<Discount[]>('/v1/discounts/all'),
|
||||
})
|
||||
}
|
||||
|
||||
// --- Mutations ---
|
||||
|
||||
export const posMutations = {
|
||||
createTransaction: (data: { transactionType: string; locationId?: 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; 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}`),
|
||||
}
|
||||
187
packages/admin/src/components/pos/pos-cart-panel.tsx
Normal file
187
packages/admin/src/components/pos/pos-cart-panel.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
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 { X, Banknote, CreditCard, FileText, Ban } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useState } from 'react'
|
||||
import { POSPaymentDialog } from './pos-payment-dialog'
|
||||
|
||||
interface POSCartPanelProps {
|
||||
transaction: Transaction | null
|
||||
}
|
||||
|
||||
export function POSCartPanel({ transaction }: POSCartPanelProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const { currentTransactionId, setTransaction } = usePOSStore()
|
||||
const [paymentMethod, setPaymentMethod] = useState<string | null>(null)
|
||||
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 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>
|
||||
</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) => (
|
||||
<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>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{item.qty} x ${parseFloat(item.unitPrice).toFixed(2)}
|
||||
{parseFloat(item.taxAmount) > 0 && (
|
||||
<span className="ml-2">tax ${parseFloat(item.taxAmount).toFixed(2)}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm font-medium tabular-nums">
|
||||
${parseFloat(item.lineTotal).toFixed(2)}
|
||||
</span>
|
||||
{isPending && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 opacity-0 group-hover:opacity-100 shrink-0"
|
||||
onClick={() => removeItemMutation.mutate(item.id)}
|
||||
disabled={removeItemMutation.isPending}
|
||||
>
|
||||
<X className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* 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={() => 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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
139
packages/admin/src/components/pos/pos-drawer-dialog.tsx
Normal file
139
packages/admin/src/components/pos/pos-drawer-dialog.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { usePOSStore } from '@/stores/pos.store'
|
||||
import { posMutations, posKeys, 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 { toast } from 'sonner'
|
||||
|
||||
interface POSDrawerDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
drawer: DrawerSession | null
|
||||
}
|
||||
|
||||
export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const { locationId, setDrawerSession } = usePOSStore()
|
||||
const isOpen = drawer?.status === 'open'
|
||||
|
||||
const [openingBalance, setOpeningBalance] = useState('200')
|
||||
const [closingBalance, setClosingBalance] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
|
||||
const openMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
posMutations.openDrawer({
|
||||
locationId: locationId ?? undefined,
|
||||
openingBalance: parseFloat(openingBalance) || 0,
|
||||
}),
|
||||
onSuccess: (session) => {
|
||||
setDrawerSession(session.id)
|
||||
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: (session) => {
|
||||
setDrawerSession(null)
|
||||
queryClient.invalidateQueries({ queryKey: posKeys.drawer(locationId ?? '') })
|
||||
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)}`)
|
||||
}
|
||||
onOpenChange(false)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isOpen ? 'Close 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>
|
||||
<Separator />
|
||||
<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"
|
||||
autoFocus
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
266
packages/admin/src/components/pos/pos-item-panel.tsx
Normal file
266
packages/admin/src/components/pos/pos-item-panel.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
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 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface POSItemPanelProps {
|
||||
transaction: Transaction | null
|
||||
}
|
||||
|
||||
export function POSItemPanel({ transaction: _transaction }: POSItemPanelProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const { currentTransactionId, setTransaction, locationId } = usePOSStore()
|
||||
const [search, setSearch] = useState('')
|
||||
const [customOpen, setCustomOpen] = 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,
|
||||
})
|
||||
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,
|
||||
})
|
||||
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,
|
||||
})
|
||||
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"
|
||||
disabled
|
||||
>
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
202
packages/admin/src/components/pos/pos-payment-dialog.tsx
Normal file
202
packages/admin/src/components/pos/pos-payment-dialog.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { usePOSStore } from '@/stores/pos.store'
|
||||
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 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
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!) })
|
||||
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]
|
||||
|
||||
if (completed && result) {
|
||||
const changeGiven = parseFloat(result.changeGiven ?? '0')
|
||||
const roundingAdj = parseFloat(result.roundingAdjustment ?? '0')
|
||||
|
||||
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>
|
||||
{roundingAdj !== 0 && (
|
||||
<div className="flex justify-between text-muted-foreground">
|
||||
<span>Rounding</span>
|
||||
<span>{roundingAdj > 0 ? '+' : ''}{roundingAdj.toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
{paymentMethod === 'cash' && (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<span>Tendered</span>
|
||||
<span>${parseFloat(result.amountTendered ?? '0').toFixed(2)}</span>
|
||||
</div>
|
||||
{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>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
71
packages/admin/src/components/pos/pos-register.tsx
Normal file
71
packages/admin/src/components/pos/pos-register.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useEffect } 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'
|
||||
|
||||
interface Location {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
function locationsOptions() {
|
||||
return queryOptions({
|
||||
queryKey: ['locations'],
|
||||
queryFn: () => api.get<{ data: Location[] }>('/v1/locations'),
|
||||
})
|
||||
}
|
||||
|
||||
export function POSRegister() {
|
||||
const { locationId, setLocation, currentTransactionId, setDrawerSession } = usePOSStore()
|
||||
|
||||
// Fetch locations
|
||||
const { data: locationsData } = useQuery(locationsOptions())
|
||||
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,
|
||||
})
|
||||
|
||||
// Sync drawer session ID
|
||||
useEffect(() => {
|
||||
if (drawer?.id && drawer.status === 'open') {
|
||||
setDrawerSession(drawer.id)
|
||||
}
|
||||
}, [drawer, setDrawerSession])
|
||||
|
||||
// Fetch current transaction
|
||||
const { data: transaction } = useQuery(transactionOptions(currentTransactionId))
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
91
packages/admin/src/components/pos/pos-top-bar.tsx
Normal file
91
packages/admin/src/components/pos/pos-top-bar.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Link, useRouter } from '@tanstack/react-router'
|
||||
import { useAuthStore } from '@/stores/auth.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, LogOut, DollarSign } 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 router = useRouter()
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const logout = useAuthStore((s) => s.logout)
|
||||
const [drawerDialogOpen, setDrawerDialogOpen] = useState(false)
|
||||
|
||||
const drawerOpen = drawer?.status === 'open'
|
||||
|
||||
function handleLogout() {
|
||||
logout()
|
||||
router.navigate({ to: '/login', replace: true })
|
||||
}
|
||||
|
||||
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="/" 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}
|
||||
</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 — ${parseFloat(drawer!.openingBalance).toFixed(2)}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs">Drawer Closed</Badge>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Right: user + logout */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">{user?.firstName}</span>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={handleLogout} title="Sign out">
|
||||
<LogOut className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<POSDrawerDialog
|
||||
open={drawerDialogOpen}
|
||||
onOpenChange={setDrawerDialogOpen}
|
||||
drawer={drawer}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -23,12 +23,12 @@ export function usePagination() {
|
||||
|
||||
function setParams(updates: Partial<PaginationSearch>) {
|
||||
navigate({
|
||||
search: ((prev: PaginationSearch) => ({
|
||||
search: ((prev: Record<string, unknown>) => ({
|
||||
...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),
|
||||
})) as (prev: Record<string, unknown>) => Record<string, unknown>,
|
||||
replace: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
// 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 PosRouteImport } from './routes/pos'
|
||||
import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
|
||||
import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index'
|
||||
@@ -56,6 +57,11 @@ import { Route as AuthenticatedAccountsAccountIdMembersRouteImport } from './rou
|
||||
import { Route as AuthenticatedAccountsAccountIdEnrollmentsRouteImport } from './routes/_authenticated/accounts/$accountId/enrollments'
|
||||
import { Route as AuthenticatedLessonsScheduleInstructorsInstructorIdRouteImport } from './routes/_authenticated/lessons/schedule/instructors/$instructorId'
|
||||
|
||||
const PosRoute = PosRouteImport.update({
|
||||
id: '/pos',
|
||||
path: '/pos',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const LoginRoute = LoginRouteImport.update({
|
||||
id: '/login',
|
||||
path: '/login',
|
||||
@@ -323,6 +329,7 @@ const AuthenticatedLessonsScheduleInstructorsInstructorIdRoute =
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof AuthenticatedIndexRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/pos': typeof PosRoute
|
||||
'/help': typeof AuthenticatedHelpRoute
|
||||
'/profile': typeof AuthenticatedProfileRoute
|
||||
'/settings': typeof AuthenticatedSettingsRoute
|
||||
@@ -369,6 +376,7 @@ export interface FileRoutesByFullPath {
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/login': typeof LoginRoute
|
||||
'/pos': typeof PosRoute
|
||||
'/help': typeof AuthenticatedHelpRoute
|
||||
'/profile': typeof AuthenticatedProfileRoute
|
||||
'/settings': typeof AuthenticatedSettingsRoute
|
||||
@@ -417,6 +425,7 @@ export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/_authenticated': typeof AuthenticatedRouteWithChildren
|
||||
'/login': typeof LoginRoute
|
||||
'/pos': typeof PosRoute
|
||||
'/_authenticated/help': typeof AuthenticatedHelpRoute
|
||||
'/_authenticated/profile': typeof AuthenticatedProfileRoute
|
||||
'/_authenticated/settings': typeof AuthenticatedSettingsRoute
|
||||
@@ -467,6 +476,7 @@ export interface FileRouteTypes {
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/login'
|
||||
| '/pos'
|
||||
| '/help'
|
||||
| '/profile'
|
||||
| '/settings'
|
||||
@@ -513,6 +523,7 @@ export interface FileRouteTypes {
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/login'
|
||||
| '/pos'
|
||||
| '/help'
|
||||
| '/profile'
|
||||
| '/settings'
|
||||
@@ -560,6 +571,7 @@ export interface FileRouteTypes {
|
||||
| '__root__'
|
||||
| '/_authenticated'
|
||||
| '/login'
|
||||
| '/pos'
|
||||
| '/_authenticated/help'
|
||||
| '/_authenticated/profile'
|
||||
| '/_authenticated/settings'
|
||||
@@ -609,10 +621,18 @@ export interface FileRouteTypes {
|
||||
export interface RootRouteChildren {
|
||||
AuthenticatedRoute: typeof AuthenticatedRouteWithChildren
|
||||
LoginRoute: typeof LoginRoute
|
||||
PosRoute: typeof PosRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/pos': {
|
||||
id: '/pos'
|
||||
path: '/pos'
|
||||
fullPath: '/pos'
|
||||
preLoaderRoute: typeof PosRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/login': {
|
||||
id: '/login'
|
||||
path: '/login'
|
||||
@@ -1069,6 +1089,7 @@ const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
AuthenticatedRoute: AuthenticatedRouteWithChildren,
|
||||
LoginRoute: LoginRoute,
|
||||
PosRoute: PosRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
@@ -8,7 +8,7 @@ import { myPermissionsOptions } from '@/api/rbac'
|
||||
import { moduleListOptions } from '@/api/modules'
|
||||
import { Avatar } from '@/components/shared/avatar-upload'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Users, UserRound, HelpCircle, Shield, UserCog, LogOut, User, Wrench, Package, ClipboardList, FolderOpen, KeyRound, Settings, PanelLeftClose, PanelLeft, CalendarDays, GraduationCap, CalendarRange, BookOpen, BookMarked, Package2, Tag, Truck } from 'lucide-react'
|
||||
import { Users, UserRound, HelpCircle, Shield, UserCog, LogOut, User, Wrench, Package, ClipboardList, FolderOpen, KeyRound, Settings, PanelLeftClose, PanelLeft, CalendarDays, GraduationCap, CalendarRange, BookOpen, BookMarked, Package2, Tag, Truck, ShoppingCart } from 'lucide-react'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated')({
|
||||
beforeLoad: () => {
|
||||
@@ -67,7 +67,7 @@ function NavLink({ to, icon, label, collapsed }: { to: string; icon: React.React
|
||||
return (
|
||||
<Link
|
||||
to={to as '/accounts'}
|
||||
search={{} as any}
|
||||
search={{} as Record<string, unknown>}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent"
|
||||
activeProps={{ className: 'flex items-center gap-2 px-3 py-2 rounded-md text-sm bg-sidebar-accent text-sidebar-accent-foreground' }}
|
||||
title={collapsed ? label : undefined}
|
||||
@@ -145,6 +145,7 @@ function AuthenticatedLayout() {
|
||||
const canViewLessons = !permissionsLoaded || hasPermission('lessons.view')
|
||||
const canViewInventory = !permissionsLoaded || hasPermission('inventory.view')
|
||||
const canViewUsers = !permissionsLoaded || hasPermission('users.view')
|
||||
const canViewPOS = !permissionsLoaded || hasPermission('pos.view')
|
||||
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
|
||||
@@ -173,6 +174,11 @@ function AuthenticatedLayout() {
|
||||
|
||||
{/* Scrollable nav links */}
|
||||
<div className="flex-1 overflow-y-auto px-2 space-y-1 scrollbar-thin">
|
||||
{isModuleEnabled('pos') && canViewPOS && (
|
||||
<div className="mb-2">
|
||||
<NavLink to="/pos" icon={<ShoppingCart className="h-4 w-4" />} label="Point of Sale" collapsed={collapsed} />
|
||||
</div>
|
||||
)}
|
||||
{canViewAccounts && (
|
||||
<NavGroup label="Customers" collapsed={collapsed}>
|
||||
<NavLink to="/accounts" icon={<Users className="h-4 w-4" />} label="Accounts" collapsed={collapsed} />
|
||||
|
||||
@@ -20,7 +20,7 @@ function statusBadge(status: string) {
|
||||
}
|
||||
|
||||
const columns: Column<Enrollment & { memberName?: string }>[] = [
|
||||
{ key: 'member_name', header: 'Member', sortable: true, render: (e) => <span className="font-medium">{(e as any).memberName ?? e.memberId}</span> },
|
||||
{ key: 'member_name', header: 'Member', sortable: true, render: (e) => <span className="font-medium">{e.memberName ?? e.memberId}</span> },
|
||||
{ key: 'status', header: 'Status', sortable: true, render: (e) => statusBadge(e.status) },
|
||||
{ key: 'start_date', header: 'Start', sortable: true, render: (e) => <>{new Date(e.startDate + 'T00:00:00').toLocaleDateString()}</> },
|
||||
{ key: 'rate', header: 'Rate', render: (e) => <>{e.rate ? `$${e.rate}${e.billingInterval ? ` / ${e.billingInterval} ${e.billingUnit}` : ''}` : <span className="text-muted-foreground">—</span>}</> },
|
||||
@@ -41,7 +41,7 @@ function AccountEnrollmentsTab() {
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">{data?.pagination.total ?? 0} enrollment(s)</p>
|
||||
{hasPermission('lessons.edit') && (
|
||||
<Button size="sm" onClick={() => navigate({ to: '/lessons/enrollments/new', search: {} as any })}>
|
||||
<Button size="sm" onClick={() => navigate({ to: '/lessons/enrollments/new', search: {} as Record<string, unknown> })}>
|
||||
<Plus className="h-4 w-4 mr-1" />Enroll a Member
|
||||
</Button>
|
||||
)}
|
||||
@@ -55,7 +55,7 @@ function AccountEnrollmentsTab() {
|
||||
total={data?.data?.length ?? 0}
|
||||
onPageChange={() => {}}
|
||||
onSort={() => {}}
|
||||
onRowClick={(e) => navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId: e.id }, search: {} as any })}
|
||||
onRowClick={(e) => navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId: e.id }, search: {} as Record<string, unknown> })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -281,7 +281,7 @@ function MembersTab() {
|
||||
<DropdownMenuItem onClick={() => navigate({
|
||||
to: '/members/$memberId',
|
||||
params: { memberId: m.id },
|
||||
search: {} as any,
|
||||
search: {} as Record<string, unknown>,
|
||||
})}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
|
||||
@@ -92,7 +92,7 @@ function FileManagerPage() {
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
toast.error(`Upload failed: ${(err as any).error?.message ?? file.name}`)
|
||||
toast.error(`Upload failed: ${(err as { error?: { message?: string } }).error?.message ?? file.name}`)
|
||||
}
|
||||
} catch {
|
||||
toast.error(`Upload failed: ${file.name}`)
|
||||
|
||||
@@ -2,6 +2,6 @@ import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/')({
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: '/accounts', search: {} as any })
|
||||
throw redirect({ to: '/accounts', search: {} as Record<string, unknown> })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -159,7 +159,7 @@ function ProductDetailPage() {
|
||||
})
|
||||
|
||||
function setTab(t: string) {
|
||||
navigate({ to: '/inventory/$productId', params: { productId }, search: { tab: t } as any })
|
||||
navigate({ to: '/inventory/$productId', params: { productId }, search: { tab: t } as Record<string, unknown> })
|
||||
}
|
||||
|
||||
function handleQtySave() {
|
||||
@@ -192,7 +192,7 @@ function ProductDetailPage() {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/inventory', search: {} as any })}>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/inventory', search: {} as Record<string, unknown> })}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
@@ -507,12 +507,12 @@ function SuppliersTab({
|
||||
setAddOpen: (v: boolean) => void
|
||||
editTarget: ProductSupplier | null
|
||||
setEditTarget: (v: ProductSupplier | null) => void
|
||||
addMutation: any
|
||||
updateMutation: any
|
||||
removeMutation: any
|
||||
addMutation: { mutate: (data: Record<string, unknown>) => void; isPending: boolean }
|
||||
updateMutation: { mutate: (args: { id: string; data: Record<string, unknown> }) => void; isPending: boolean }
|
||||
removeMutation: { mutate: (id: string) => void; isPending: boolean }
|
||||
canEdit: boolean
|
||||
}) {
|
||||
const { data: allSuppliersData } = useQuery(supplierListOptions({ page: 1, limit: 500, order: 'asc', sort: 'name' } as any))
|
||||
const { data: allSuppliersData } = useQuery(supplierListOptions({ page: 1, limit: 500, order: 'asc', sort: 'name' }))
|
||||
const allSuppliers = allSuppliersData?.data ?? []
|
||||
const linkedIds = new Set(linkedSuppliers.map((s) => s.supplierId))
|
||||
const availableSuppliers = allSuppliers.filter((s) => !linkedIds.has(s.id))
|
||||
|
||||
@@ -71,7 +71,7 @@ function InventoryPage() {
|
||||
queryClient.invalidateQueries({ queryKey: productKeys.all })
|
||||
toast.success('Product created')
|
||||
setCreateOpen(false)
|
||||
navigate({ to: '/inventory/$productId', params: { productId: product.id }, search: {} as any })
|
||||
navigate({ to: '/inventory/$productId', params: { productId: product.id }, search: {} as Record<string, unknown> })
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
@@ -83,23 +83,23 @@ function InventoryPage() {
|
||||
|
||||
function handleCategoryChange(v: string) {
|
||||
setCategoryFilter(v === 'all' ? '' : v)
|
||||
navigate({ to: '/inventory', search: { ...search, categoryId: v === 'all' ? undefined : v, page: 1 } as any })
|
||||
navigate({ to: '/inventory', search: { ...search, categoryId: v === 'all' ? undefined : v, page: 1 } as Record<string, unknown> })
|
||||
}
|
||||
|
||||
function handleActiveChange(v: string) {
|
||||
setActiveFilter(v === 'all' ? '' : v)
|
||||
navigate({ to: '/inventory', search: { ...search, isActive: v === 'all' ? undefined : v, page: 1 } as any })
|
||||
navigate({ to: '/inventory', search: { ...search, isActive: v === 'all' ? undefined : v, page: 1 } as Record<string, unknown> })
|
||||
}
|
||||
|
||||
function handleTypeChange(v: string) {
|
||||
setTypeFilter(v === 'all' ? '' : v)
|
||||
navigate({ to: '/inventory', search: { ...search, type: v === 'all' ? undefined : v, page: 1 } as any })
|
||||
navigate({ to: '/inventory', search: { ...search, type: v === 'all' ? undefined : v, page: 1 } as Record<string, unknown> })
|
||||
}
|
||||
|
||||
function handleLowStockChange(v: string) {
|
||||
const on = v === 'true'
|
||||
setLowStockFilter(on)
|
||||
navigate({ to: '/inventory', search: { ...search, lowStock: on ? 'true' : undefined, page: 1 } as any })
|
||||
navigate({ to: '/inventory', search: { ...search, lowStock: on ? 'true' : undefined, page: 1 } as Record<string, unknown> })
|
||||
}
|
||||
|
||||
const columns: Column<Product>[] = [
|
||||
@@ -246,7 +246,7 @@ function InventoryPage() {
|
||||
order={params.order}
|
||||
onPageChange={setPage}
|
||||
onSort={setSort}
|
||||
onRowClick={(p) => navigate({ to: '/inventory/$productId', params: { productId: p.id }, search: {} as any })}
|
||||
onRowClick={(p) => navigate({ to: '/inventory/$productId', params: { productId: p.id }, search: {} as Record<string, unknown> })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -130,7 +130,7 @@ function SuppliersPage() {
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>New Supplier</DialogTitle></DialogHeader>
|
||||
<SupplierForm
|
||||
onSubmit={supplierMutations.create.bind(null) as any}
|
||||
onSubmit={(data) => { createMutation.mutate(data) }}
|
||||
loading={createMutation.isPending}
|
||||
/>
|
||||
</DialogContent>
|
||||
|
||||
@@ -21,7 +21,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
|
||||
import { ArrowLeft, RefreshCw } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { LessonSession, LessonPlan, LessonPlanTemplate } from '@/types/lesson'
|
||||
import type { Enrollment, LessonSession, LessonPlan, LessonPlanTemplate } from '@/types/lesson'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/lessons/enrollments/$enrollmentId')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
@@ -81,7 +81,7 @@ function EnrollmentDetailPage() {
|
||||
const tab = search.tab
|
||||
|
||||
function setTab(t: string) {
|
||||
navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId }, search: { tab: t } as any })
|
||||
navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId }, search: { tab: t } as Record<string, unknown> })
|
||||
}
|
||||
|
||||
const { data: enrollment, isLoading } = useQuery(enrollmentDetailOptions(enrollmentId))
|
||||
@@ -131,7 +131,7 @@ function EnrollmentDetailPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/enrollments', search: {} as any })}>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/enrollments', search: {} as Record<string, unknown> })}>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />Back
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
@@ -193,7 +193,17 @@ const BILLING_UNITS = [
|
||||
function DetailsTab({
|
||||
enrollment, slotLabel, lessonTypeName, instructorName,
|
||||
canEdit, onSave, saving, onStatusChange, statusChanging,
|
||||
}: any) {
|
||||
}: {
|
||||
enrollment: Enrollment
|
||||
slotLabel: string
|
||||
lessonTypeName: string | undefined
|
||||
instructorName: string | undefined
|
||||
canEdit: boolean
|
||||
onSave: (data: Record<string, unknown>) => void
|
||||
saving: boolean
|
||||
onStatusChange: (status: string) => void
|
||||
statusChanging: boolean
|
||||
}) {
|
||||
const [rate, setRate] = useState(enrollment.rate ?? '')
|
||||
const [billingInterval, setBillingInterval] = useState(String(enrollment.billingInterval ?? 1))
|
||||
const [billingUnit, setBillingUnit] = useState(enrollment.billingUnit ?? 'month')
|
||||
@@ -334,7 +344,7 @@ function SessionsTab({ enrollmentId, onGenerate, generating }: { enrollmentId: s
|
||||
total={data?.data?.length ?? 0}
|
||||
onPageChange={() => {}}
|
||||
onSort={() => {}}
|
||||
onRowClick={(s) => navigate({ to: '/lessons/sessions/$sessionId', params: { sessionId: s.id }, search: {} as any })}
|
||||
onRowClick={(s) => navigate({ to: '/lessons/sessions/$sessionId', params: { sessionId: s.id }, search: {} as Record<string, unknown> })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -373,7 +383,7 @@ function LessonPlanTab({ enrollmentId, memberId, canEdit }: { enrollmentId: stri
|
||||
queryClient.invalidateQueries({ queryKey: lessonPlanKeys.all })
|
||||
toast.success('Plan created from template')
|
||||
setTemplatePickerOpen(false)
|
||||
navigate({ to: '/lessons/plans/$planId', params: { planId: plan.id }, search: {} as any })
|
||||
navigate({ to: '/lessons/plans/$planId', params: { planId: plan.id }, search: {} as Record<string, unknown> })
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
@@ -391,7 +401,7 @@ function LessonPlanTab({ enrollmentId, memberId, canEdit }: { enrollmentId: stri
|
||||
{Math.round(activePlan.progress)}% complete
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => navigate({ to: '/lessons/plans/$planId', params: { planId: activePlan.id }, search: {} as any })}>
|
||||
<Button variant="outline" size="sm" onClick={() => navigate({ to: '/lessons/plans/$planId', params: { planId: activePlan.id }, search: {} as Record<string, unknown> })}>
|
||||
View Plan
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -43,9 +43,9 @@ function statusBadge(status: string) {
|
||||
}
|
||||
|
||||
const columns: Column<Enrollment & { memberName?: string; instructorName?: string; slotInfo?: string; lessonTypeName?: string }>[] = [
|
||||
{ key: 'member_name', header: 'Member', sortable: true, render: (e) => <span className="font-medium">{(e as any).memberName ?? e.memberId}</span> },
|
||||
{ key: 'instructor_name', header: 'Instructor', render: (e) => <>{(e as any).instructorName ?? e.instructorId}</> },
|
||||
{ key: 'slot_info', header: 'Day / Time', render: (e) => <>{(e as any).slotInfo ?? '—'}</> },
|
||||
{ key: 'member_name', header: 'Member', sortable: true, render: (e) => <span className="font-medium">{e.memberName ?? e.memberId}</span> },
|
||||
{ key: 'instructor_name', header: 'Instructor', render: (e) => <>{e.instructorName ?? e.instructorId}</> },
|
||||
{ key: 'slot_info', header: 'Day / Time', render: (e) => <>{e.slotInfo ?? '—'}</> },
|
||||
{ key: 'status', header: 'Status', sortable: true, render: (e) => statusBadge(e.status) },
|
||||
{ key: 'start_date', header: 'Start', sortable: true, render: (e) => <>{new Date(e.startDate + 'T00:00:00').toLocaleDateString()}</> },
|
||||
{ key: 'rate', header: 'Rate', render: (e) => <>{e.rate ? `$${e.rate}${e.billingInterval ? ` / ${e.billingInterval} ${e.billingUnit}` : ''}` : <span className="text-muted-foreground">—</span>}</> },
|
||||
@@ -72,7 +72,7 @@ function EnrollmentsListPage() {
|
||||
function handleStatusChange(v: string) {
|
||||
const s = v === 'all' ? '' : v
|
||||
setStatusFilter(s)
|
||||
navigate({ to: '/lessons/enrollments', search: { ...search, status: s || undefined, page: 1 } as any })
|
||||
navigate({ to: '/lessons/enrollments', search: { ...search, status: s || undefined, page: 1 } as Record<string, unknown> })
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -80,7 +80,7 @@ function EnrollmentsListPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Enrollments</h1>
|
||||
{hasPermission('lessons.edit') && (
|
||||
<Button onClick={() => navigate({ to: '/lessons/enrollments/new', search: {} as any })}>
|
||||
<Button onClick={() => navigate({ to: '/lessons/enrollments/new', search: {} as Record<string, unknown> })}>
|
||||
<Plus className="mr-2 h-4 w-4" />New Enrollment
|
||||
</Button>
|
||||
)}
|
||||
@@ -125,7 +125,7 @@ function EnrollmentsListPage() {
|
||||
order={params.order}
|
||||
onPageChange={setPage}
|
||||
onSort={setSort}
|
||||
onRowClick={(e) => navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId: e.id }, search: {} as any })}
|
||||
onRowClick={(e) => navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId: e.id }, search: {} as Record<string, unknown> })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -108,7 +108,7 @@ function NewEnrollmentPage() {
|
||||
},
|
||||
onSuccess: (enrollment) => {
|
||||
toast.success('Enrollment created')
|
||||
navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId: enrollment.id }, search: {} as any })
|
||||
navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId: enrollment.id }, search: {} as Record<string, unknown> })
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
@@ -141,7 +141,7 @@ function NewEnrollmentPage() {
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/enrollments', search: {} as any })}>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/enrollments', search: {} as Record<string, unknown> })}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold">New Enrollment</h1>
|
||||
@@ -282,7 +282,7 @@ function NewEnrollmentPage() {
|
||||
<Button type="submit" disabled={mutation.isPending || !selectedMember || !selectedSlotId || !startDate} size="lg">
|
||||
{mutation.isPending ? 'Creating...' : 'Create Enrollment'}
|
||||
</Button>
|
||||
<Button variant="secondary" type="button" size="lg" onClick={() => navigate({ to: '/lessons/enrollments', search: {} as any })}>
|
||||
<Button variant="secondary" type="button" size="lg" onClick={() => navigate({ to: '/lessons/enrollments', search: {} as Record<string, unknown> })}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -93,7 +93,7 @@ function LessonPlanDetailPage() {
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/plans', search: {} as any })}>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/plans', search: {} as Record<string, unknown> })}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
|
||||
@@ -84,7 +84,7 @@ function LessonPlansPage() {
|
||||
order={params.order}
|
||||
onPageChange={setPage}
|
||||
onSort={setSort}
|
||||
onRowClick={(p) => navigate({ to: '/lessons/plans/$planId', params: { planId: p.id }, search: {} as any })}
|
||||
onRowClick={(p) => navigate({ to: '/lessons/plans/$planId', params: { planId: p.id }, search: {} as Record<string, unknown> })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -49,7 +49,7 @@ function ScheduleHubPage() {
|
||||
const canAdmin = hasPermission('lessons.admin')
|
||||
|
||||
function setTab(t: string) {
|
||||
navigate({ to: '/lessons/schedule', search: { ...search, tab: t, page: 1 } as any })
|
||||
navigate({ to: '/lessons/schedule', search: { ...search, tab: t, page: 1 } as Record<string, unknown> })
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -90,7 +90,7 @@ const instructorColumns: Column<Instructor>[] = [
|
||||
},
|
||||
]
|
||||
|
||||
function InstructorsTab({ canAdmin, search: _search }: { canAdmin: boolean; search: any }) {
|
||||
function InstructorsTab({ canAdmin, search: _search }: { canAdmin: boolean; search: Record<string, unknown> }) {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const { params, setPage, setSearch, setSort } = usePagination()
|
||||
@@ -152,7 +152,7 @@ function InstructorsTab({ canAdmin, search: _search }: { canAdmin: boolean; sear
|
||||
order={params.order}
|
||||
onPageChange={setPage}
|
||||
onSort={setSort}
|
||||
onRowClick={(i) => navigate({ to: '/lessons/schedule/instructors/$instructorId', params: { instructorId: i.id }, search: {} as any })}
|
||||
onRowClick={(i) => navigate({ to: '/lessons/schedule/instructors/$instructorId', params: { instructorId: i.id }, search: {} as Record<string, unknown> })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -169,7 +169,7 @@ const lessonTypeColumns: Column<LessonType>[] = [
|
||||
{ key: 'is_active', header: 'Status', render: (lt) => <Badge variant={lt.isActive ? 'default' : 'secondary'}>{lt.isActive ? 'Active' : 'Inactive'}</Badge> },
|
||||
]
|
||||
|
||||
function LessonTypesTab({ canAdmin, search: _search }: { canAdmin: boolean; search: any }) {
|
||||
function LessonTypesTab({ canAdmin, search: _search }: { canAdmin: boolean; search: Record<string, unknown> }) {
|
||||
const queryClient = useQueryClient()
|
||||
const { params, setPage, setSearch, setSort } = usePagination()
|
||||
const [searchInput, setSearchInput] = useState(params.q ?? '')
|
||||
@@ -215,8 +215,8 @@ function LessonTypesTab({ canAdmin, search: _search }: { canAdmin: boolean; sear
|
||||
const columnsWithActions: Column<LessonType>[] = [
|
||||
...lessonTypeColumns,
|
||||
...(canAdmin ? [{
|
||||
key: 'actions' as any,
|
||||
header: '' as any,
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (lt: LessonType) => (
|
||||
<div className="flex gap-1 justify-end">
|
||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); setEditTarget(lt) }}>Edit</Button>
|
||||
@@ -298,7 +298,7 @@ const gradingScaleColumns: Column<GradingScale>[] = [
|
||||
{ key: 'is_active', header: 'Status', render: (gs) => <Badge variant={gs.isActive ? 'default' : 'secondary'}>{gs.isActive ? 'Active' : 'Inactive'}</Badge> },
|
||||
]
|
||||
|
||||
function GradingScalesTab({ canAdmin, search: _search }: { canAdmin: boolean; search: any }) {
|
||||
function GradingScalesTab({ canAdmin, search: _search }: { canAdmin: boolean; search: Record<string, unknown> }) {
|
||||
const queryClient = useQueryClient()
|
||||
const { params, setPage, setSort } = usePagination()
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
@@ -327,8 +327,8 @@ function GradingScalesTab({ canAdmin, search: _search }: { canAdmin: boolean; se
|
||||
const columnsWithActions: Column<GradingScale>[] = [
|
||||
...gradingScaleColumns,
|
||||
...(canAdmin ? [{
|
||||
key: 'actions' as any,
|
||||
header: '' as any,
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (gs: GradingScale) => (
|
||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(gs.id) }}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
|
||||
@@ -42,7 +42,7 @@ function InstructorDetailPage() {
|
||||
const tab = search.tab
|
||||
|
||||
function setTab(t: string) {
|
||||
navigate({ to: '/lessons/schedule/instructors/$instructorId', params: { instructorId }, search: { tab: t } as any })
|
||||
navigate({ to: '/lessons/schedule/instructors/$instructorId', params: { instructorId }, search: { tab: t } as Record<string, unknown> })
|
||||
}
|
||||
|
||||
const { data: instructor, isLoading } = useQuery(instructorDetailOptions(instructorId))
|
||||
@@ -62,7 +62,7 @@ function InstructorDetailPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/schedule', search: { tab: 'instructors' } as any })}>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/schedule', search: { tab: 'instructors' } as Record<string, unknown> })}>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />Back
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { ArrowLeft, CheckSquare, Square } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { LessonPlan, LessonPlanSection } from '@/types/lesson'
|
||||
import type { LessonPlan, LessonPlanSection, LessonSession } from '@/types/lesson'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/lessons/sessions/$sessionId')({
|
||||
component: SessionDetailPage,
|
||||
@@ -126,7 +126,7 @@ function SessionDetailPage() {
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/sessions', search: {} as any })}>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/sessions', search: {} as Record<string, unknown> })}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
@@ -137,7 +137,7 @@ function SessionDetailPage() {
|
||||
<Link
|
||||
to="/lessons/enrollments/$enrollmentId"
|
||||
params={{ enrollmentId: enrollment.id }}
|
||||
search={{} as any}
|
||||
search={{} as Record<string, unknown>}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
View Enrollment
|
||||
@@ -209,7 +209,12 @@ function SessionDetailPage() {
|
||||
|
||||
// ─── Notes Card ───────────────────────────────────────────────────────────────
|
||||
|
||||
function NotesCard({ session, canEdit, onSave, saving }: any) {
|
||||
function NotesCard({ session, canEdit, onSave, saving }: {
|
||||
session: LessonSession
|
||||
canEdit: boolean
|
||||
onSave: (data: Record<string, unknown>) => void
|
||||
saving: boolean
|
||||
}) {
|
||||
const [instructorNotes, setInstructorNotes] = useState(session.instructorNotes ?? '')
|
||||
const [memberNotes, setMemberNotes] = useState(session.memberNotes ?? '')
|
||||
const [homeworkAssigned, setHomeworkAssigned] = useState(session.homeworkAssigned ?? '')
|
||||
|
||||
@@ -92,13 +92,13 @@ function SessionsPage() {
|
||||
const weekEnd = endOfWeek(weekStart, { weekStartsOn: 0 })
|
||||
|
||||
function setView(v: 'list' | 'week') {
|
||||
navigate({ to: '/lessons/sessions', search: { ...search, view: v, page: 1 } as any })
|
||||
navigate({ to: '/lessons/sessions', search: { ...search, view: v, page: 1 } as Record<string, unknown> })
|
||||
}
|
||||
|
||||
function handleStatusChange(v: string) {
|
||||
const s = v === 'all' ? '' : v
|
||||
setStatusFilter(s)
|
||||
navigate({ to: '/lessons/sessions', search: { ...search, status: s || undefined, page: 1 } as any })
|
||||
navigate({ to: '/lessons/sessions', search: { ...search, status: s || undefined, page: 1 } as Record<string, unknown> })
|
||||
}
|
||||
|
||||
// List query
|
||||
@@ -189,7 +189,7 @@ function SessionsPage() {
|
||||
order={params.order}
|
||||
onPageChange={setPage}
|
||||
onSort={setSort}
|
||||
onRowClick={(s) => navigate({ to: '/lessons/sessions/$sessionId', params: { sessionId: s.id }, search: {} as any })}
|
||||
onRowClick={(s) => navigate({ to: '/lessons/sessions/$sessionId', params: { sessionId: s.id }, search: {} as Record<string, unknown> })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -249,7 +249,7 @@ function SessionsPage() {
|
||||
{daySessions.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => navigate({ to: '/lessons/sessions/$sessionId', params: { sessionId: s.id }, search: {} as any })}
|
||||
onClick={() => navigate({ to: '/lessons/sessions/$sessionId', params: { sessionId: s.id }, search: {} as Record<string, unknown> })}
|
||||
className={`w-full text-left rounded border px-1.5 py-1 text-xs hover:opacity-80 transition-opacity ${STATUS_COLORS[s.status] ?? STATUS_COLORS.scheduled}`}
|
||||
>
|
||||
<p className="font-semibold">{formatTime(s.scheduledTime)}</p>
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
|
||||
import { ArrowLeft, Search, X, Zap } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { LessonPlanTemplate } from '@/types/lesson'
|
||||
import type { Enrollment, LessonPlanTemplate } from '@/types/lesson'
|
||||
import type { MemberWithAccount } from '@/api/members'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/lessons/templates/$templateId')({
|
||||
@@ -42,7 +42,7 @@ function TemplateDetailPage() {
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/templates', search: {} as any })}>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/templates', search: {} as Record<string, unknown> })}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
@@ -93,7 +93,7 @@ function TemplateDetailPage() {
|
||||
|
||||
// ─── Edit Form ────────────────────────────────────────────────────────────────
|
||||
|
||||
function EditTemplateForm({ template, templateId, queryClient }: { template: LessonPlanTemplate; templateId: string; queryClient: any }) {
|
||||
function EditTemplateForm({ template, templateId, queryClient }: { template: LessonPlanTemplate; templateId: string; queryClient: ReturnType<typeof useQueryClient> }) {
|
||||
const [name, setName] = useState(template.name)
|
||||
const [description, setDescription] = useState(template.description ?? '')
|
||||
const [instrument, setInstrument] = useState(template.instrument ?? '')
|
||||
@@ -218,7 +218,7 @@ function InstantiateDialog({ template, templateId, open, onClose }: {
|
||||
}),
|
||||
onSuccess: (plan) => {
|
||||
toast.success('Plan created from template')
|
||||
navigate({ to: '/lessons/plans/$planId', params: { planId: plan.id }, search: {} as any })
|
||||
navigate({ to: '/lessons/plans/$planId', params: { planId: plan.id }, search: {} as Record<string, unknown> })
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
@@ -293,7 +293,7 @@ function InstantiateDialog({ template, templateId, open, onClose }: {
|
||||
<SelectTrigger><SelectValue placeholder="Not linked to enrollment" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Not linked to enrollment</SelectItem>
|
||||
{enrollments.map((e: any) => (
|
||||
{enrollments.map((e: Enrollment) => (
|
||||
<SelectItem key={e.id} value={e.id}>Enrollment {e.id.slice(-6)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -81,8 +81,8 @@ function TemplatesListPage() {
|
||||
const columnsWithActions: Column<LessonPlanTemplate>[] = [
|
||||
...columns,
|
||||
...(canAdmin ? [{
|
||||
key: 'actions' as any,
|
||||
header: '' as any,
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (t: LessonPlanTemplate) => (
|
||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(t.id) }}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
@@ -96,7 +96,7 @@ function TemplatesListPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Lesson Plan Templates</h1>
|
||||
{canAdmin && (
|
||||
<Button onClick={() => navigate({ to: '/lessons/templates/new', search: {} as any })}>
|
||||
<Button onClick={() => navigate({ to: '/lessons/templates/new', search: {} as Record<string, unknown> })}>
|
||||
<Plus className="mr-2 h-4 w-4" />New Template
|
||||
</Button>
|
||||
)}
|
||||
@@ -126,7 +126,7 @@ function TemplatesListPage() {
|
||||
order={params.order}
|
||||
onPageChange={setPage}
|
||||
onSort={setSort}
|
||||
onRowClick={(t) => navigate({ to: '/lessons/templates/$templateId', params: { templateId: t.id }, search: {} as any })}
|
||||
onRowClick={(t) => navigate({ to: '/lessons/templates/$templateId', params: { templateId: t.id }, search: {} as Record<string, unknown> })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -45,7 +45,7 @@ function NewTemplatePage() {
|
||||
}),
|
||||
onSuccess: (template) => {
|
||||
toast.success('Template created')
|
||||
navigate({ to: '/lessons/templates/$templateId', params: { templateId: template.id }, search: {} as any })
|
||||
navigate({ to: '/lessons/templates/$templateId', params: { templateId: template.id }, search: {} as Record<string, unknown> })
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
@@ -63,7 +63,7 @@ function NewTemplatePage() {
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6 max-w-3xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/templates', search: {} as any })}>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/templates', search: {} as Record<string, unknown> })}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold">New Template</h1>
|
||||
@@ -112,7 +112,7 @@ function NewTemplatePage() {
|
||||
<Button type="submit" disabled={mutation.isPending || !name.trim() || !allSectionsValid} size="lg">
|
||||
{mutation.isPending ? 'Creating...' : 'Create Template'}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" size="lg" onClick={() => navigate({ to: '/lessons/templates', search: {} as any })}>
|
||||
<Button type="button" variant="secondary" size="lg" onClick={() => navigate({ to: '/lessons/templates', search: {} as Record<string, unknown> })}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -70,11 +70,11 @@ function statusBadge(status: string) {
|
||||
return <Badge variant={variants[status] ?? 'outline'}>{status}</Badge>
|
||||
}
|
||||
|
||||
const enrollmentColumns: Column<Enrollment>[] = [
|
||||
const enrollmentColumns: Column<Enrollment & { instructorName?: string; slotInfo?: string; lessonTypeName?: string }>[] = [
|
||||
{ key: 'status', header: 'Status', sortable: true, render: (e) => statusBadge(e.status) },
|
||||
{ key: 'instructor_name', header: 'Instructor', render: (e) => <>{(e as any).instructorName ?? e.instructorId}</> },
|
||||
{ key: 'slot_info', header: 'Day / Time', render: (e) => <>{(e as any).slotInfo ?? '—'}</> },
|
||||
{ key: 'lesson_type', header: 'Lesson', render: (e) => <>{(e as any).lessonTypeName ?? '—'}</> },
|
||||
{ key: 'instructor_name', header: 'Instructor', render: (e) => <>{e.instructorName ?? e.instructorId}</> },
|
||||
{ key: 'slot_info', header: 'Day / Time', render: (e) => <>{e.slotInfo ?? '—'}</> },
|
||||
{ key: 'lesson_type', header: 'Lesson', render: (e) => <>{e.lessonTypeName ?? '—'}</> },
|
||||
{ key: 'start_date', header: 'Start', sortable: true, render: (e) => <>{new Date(e.startDate + 'T00:00:00').toLocaleDateString()}</> },
|
||||
{ key: 'rate', header: 'Rate', render: (e) => <>{e.rate ? `$${e.rate} / ${e.billingInterval} ${e.billingUnit}` : <span className="text-muted-foreground">—</span>}</> },
|
||||
]
|
||||
@@ -161,7 +161,7 @@ function MemberDetailPage() {
|
||||
})
|
||||
|
||||
function setTab(t: string) {
|
||||
navigate({ to: '/members/$memberId', params: { memberId }, search: { tab: t } as any })
|
||||
navigate({ to: '/members/$memberId', params: { memberId }, search: { tab: t } as Record<string, unknown> })
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
@@ -188,7 +188,7 @@ function MemberDetailPage() {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/accounts/$accountId/members', params: { accountId: member.accountId }, search: {} as any })}>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/accounts/$accountId/members', params: { accountId: member.accountId }, search: {} as Record<string, unknown> })}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
@@ -293,7 +293,7 @@ function MemberDetailPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">{enrollmentsData?.pagination.total ?? 0} enrollment(s)</p>
|
||||
{hasPermission('lessons.edit') && (
|
||||
<Button size="sm" onClick={() => navigate({ to: '/lessons/enrollments/new', search: { memberId } as any })}>
|
||||
<Button size="sm" onClick={() => navigate({ to: '/lessons/enrollments/new', search: { memberId } as Record<string, unknown> })}>
|
||||
<Plus className="h-4 w-4 mr-1" />Enroll
|
||||
</Button>
|
||||
)}
|
||||
@@ -307,7 +307,7 @@ function MemberDetailPage() {
|
||||
total={enrollmentsData?.data?.length ?? 0}
|
||||
onPageChange={() => {}}
|
||||
onSort={() => {}}
|
||||
onRowClick={(e) => navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId: e.id }, search: {} as any })}
|
||||
onRowClick={(e) => navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId: e.id }, search: {} as Record<string, unknown> })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -84,7 +84,7 @@ function MembersListPage() {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => navigate({ to: '/members/$memberId', params: { memberId: row.id }, search: {} as any })}>
|
||||
<DropdownMenuItem onClick={() => navigate({ to: '/members/$memberId', params: { memberId: row.id }, search: {} as Record<string, unknown> })}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
@@ -134,7 +134,7 @@ function MembersListPage() {
|
||||
order={params.order}
|
||||
onPageChange={setPage}
|
||||
onSort={setSort}
|
||||
onRowClick={(member) => navigate({ to: '/members/$memberId', params: { memberId: member.id }, search: {} as any })}
|
||||
onRowClick={(member) => navigate({ to: '/members/$memberId', params: { memberId: member.id }, search: {} as Record<string, unknown> })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -96,12 +96,12 @@ function RepairBatchDetailPage() {
|
||||
const totalActual = tickets.reduce((sum, t) => sum + (t.actualCost ? parseFloat(t.actualCost) : 0), 0)
|
||||
|
||||
function handleTicketClick(ticket: RepairTicket) {
|
||||
navigate({ to: '/repairs/$ticketId', params: { ticketId: ticket.id }, search: {} as any })
|
||||
navigate({ to: '/repairs/$ticketId', params: { ticketId: ticket.id }, search: {} as Record<string, unknown> })
|
||||
}
|
||||
|
||||
function handleAddRepair() {
|
||||
// Navigate to new repair with batch and account pre-linked
|
||||
navigate({ to: '/repairs/new', search: { batchId, batchName: batch!.batchNumber ?? '', accountId: batch!.accountId, contactName: batch!.contactName ?? '' } as any })
|
||||
navigate({ to: '/repairs/new', search: { batchId, batchName: batch!.batchNumber ?? '', accountId: batch!.accountId, contactName: batch!.contactName ?? '' } as Record<string, unknown> })
|
||||
}
|
||||
|
||||
async function generateBatchPdf() {
|
||||
@@ -233,7 +233,7 @@ function RepairBatchDetailPage() {
|
||||
return (
|
||||
<div className="space-y-6 max-w-5xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/repair-batches', search: {} as any })}>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/repair-batches', search: {} as Record<string, unknown> })}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
|
||||
@@ -75,7 +75,7 @@ function RepairBatchesListPage() {
|
||||
}
|
||||
|
||||
function handleRowClick(batch: RepairBatch) {
|
||||
navigate({ to: '/repair-batches/$batchId', params: { batchId: batch.id }, search: {} as any })
|
||||
navigate({ to: '/repair-batches/$batchId', params: { batchId: batch.id }, search: {} as Record<string, unknown> })
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -50,7 +50,7 @@ function NewRepairBatchPage() {
|
||||
mutationFn: repairBatchMutations.create,
|
||||
onSuccess: (batch) => {
|
||||
toast.success('Repair batch created')
|
||||
navigate({ to: '/repair-batches/$batchId', params: { batchId: batch.id }, search: {} as any })
|
||||
navigate({ to: '/repair-batches/$batchId', params: { batchId: batch.id }, search: {} as Record<string, unknown> })
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
@@ -78,7 +78,7 @@ function NewRepairBatchPage() {
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/repair-batches', search: {} as any })}>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/repair-batches', search: {} as Record<string, unknown> })}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold">New Repair Batch</h1>
|
||||
@@ -176,7 +176,7 @@ function NewRepairBatchPage() {
|
||||
<Button type="submit" disabled={mutation.isPending} size="lg">
|
||||
{mutation.isPending ? 'Creating...' : 'Create Batch'}
|
||||
</Button>
|
||||
<Button variant="secondary" type="button" size="lg" onClick={() => navigate({ to: '/repair-batches', search: {} as any })}>
|
||||
<Button variant="secondary" type="button" size="lg" onClick={() => navigate({ to: '/repair-batches', search: {} as Record<string, unknown> })}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -175,7 +175,7 @@ function RepairTicketDetailPage() {
|
||||
<div className="space-y-4 max-w-5xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/repairs', search: {} as any })}>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/repairs', search: {} as Record<string, unknown> })}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
|
||||
@@ -129,7 +129,7 @@ function RepairsListPage() {
|
||||
}
|
||||
|
||||
function handleRowClick(ticket: RepairTicket) {
|
||||
navigate({ to: '/repairs/$ticketId', params: { ticketId: ticket.id }, search: {} as any })
|
||||
navigate({ to: '/repairs/$ticketId', params: { ticketId: ticket.id }, search: {} as Record<string, unknown> })
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -137,7 +137,7 @@ function RepairsListPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Repairs</h1>
|
||||
{hasPermission('repairs.edit') && (
|
||||
<Button onClick={() => navigate({ to: '/repairs/new', search: {} as any })}>
|
||||
<Button onClick={() => navigate({ to: '/repairs/new', search: {} as Record<string, unknown> })}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Repair
|
||||
</Button>
|
||||
|
||||
@@ -136,7 +136,7 @@ function NewRepairPage() {
|
||||
},
|
||||
onSuccess: (ticket) => {
|
||||
toast.success('Repair ticket created')
|
||||
navigate({ to: '/repairs/$ticketId', params: { ticketId: ticket.id }, search: {} as any })
|
||||
navigate({ to: '/repairs/$ticketId', params: { ticketId: ticket.id }, search: {} as Record<string, unknown> })
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
@@ -210,7 +210,7 @@ function NewRepairPage() {
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/repairs', search: {} as any })}>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/repairs', search: {} as Record<string, unknown> })}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold">New Repair Ticket</h1>
|
||||
@@ -314,7 +314,7 @@ function NewRepairPage() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Condition at Intake</Label>
|
||||
<Select onValueChange={(v) => setValue('conditionIn', v as any)}>
|
||||
<Select onValueChange={(v) => setValue('conditionIn', v as 'excellent' | 'good' | 'fair' | 'poor')}>
|
||||
<SelectTrigger><SelectValue placeholder="Select condition" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="excellent">Excellent</SelectItem>
|
||||
@@ -486,7 +486,7 @@ function NewRepairPage() {
|
||||
<Button type="submit" disabled={mutation.isPending} size="lg">
|
||||
{mutation.isPending ? 'Creating...' : 'Create Ticket'}
|
||||
</Button>
|
||||
<Button variant="secondary" type="button" size="lg" onClick={() => navigate({ to: '/repairs', search: {} as any })}>
|
||||
<Button variant="secondary" type="button" size="lg" onClick={() => navigate({ to: '/repairs', search: {} as Record<string, unknown> })}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -100,7 +100,7 @@ function RoleDetailPage() {
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/roles', search: {} as any })}>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/roles', search: {} as Record<string, unknown> })}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
@@ -177,7 +177,7 @@ function RoleDetailPage() {
|
||||
<Button onClick={handleSave} disabled={updateMutation.isPending}>
|
||||
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => navigate({ to: '/roles', search: {} as any })}>
|
||||
<Button variant="secondary" onClick={() => navigate({ to: '/roles', search: {} as Record<string, unknown> })}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,7 @@ function NewRolePage() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: rbacKeys.roles })
|
||||
toast.success('Role created')
|
||||
navigate({ to: '/roles', search: {} as any })
|
||||
navigate({ to: '/roles', search: {} as Record<string, unknown> })
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
@@ -153,7 +153,7 @@ function NewRolePage() {
|
||||
<Button onClick={handleSubmit} disabled={mutation.isPending}>
|
||||
{mutation.isPending ? 'Creating...' : 'Create Role'}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => navigate({ to: '/roles', search: {} as any })}>
|
||||
<Button variant="secondary" onClick={() => navigate({ to: '/roles', search: {} as Record<string, unknown> })}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -13,8 +13,9 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { moduleListOptions, moduleMutations, moduleKeys } from '@/api/modules'
|
||||
import { Save, Plus, Trash2, MapPin, Building, ImageIcon, Blocks, Lock } from 'lucide-react'
|
||||
import { Save, Plus, Trash2, MapPin, Building, ImageIcon, Blocks, Lock, Settings2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface StoreSettings {
|
||||
@@ -236,6 +237,9 @@ function SettingsPage() {
|
||||
|
||||
{/* Modules */}
|
||||
<ModulesCard />
|
||||
|
||||
{/* App Configuration */}
|
||||
<AppConfigCard />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -296,6 +300,82 @@ function ModulesCard() {
|
||||
)
|
||||
}
|
||||
|
||||
interface AppConfigEntry {
|
||||
key: string
|
||||
value: string | null
|
||||
description: string | null
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
const LOG_LEVELS = ['fatal', 'error', 'warn', 'info', 'debug', 'trace'] as const
|
||||
|
||||
function configOptions() {
|
||||
return queryOptions({
|
||||
queryKey: ['config'],
|
||||
queryFn: () => api.get<{ data: AppConfigEntry[] }>('/v1/config'),
|
||||
})
|
||||
}
|
||||
|
||||
function AppConfigCard() {
|
||||
const queryClient = useQueryClient()
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||
const canEdit = hasPermission('settings.edit')
|
||||
|
||||
const { data: configData, isLoading } = useQuery(configOptions())
|
||||
const configs = configData?.data ?? []
|
||||
const logLevel = configs.find((c) => c.key === 'log_level')?.value ?? 'info'
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ key, value }: { key: string; value: string }) =>
|
||||
api.patch<AppConfigEntry>(`/v1/config/${key}`, { value }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['config'] })
|
||||
toast.success('Configuration updated')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Settings2 className="h-5 w-5" />App Configuration
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-12 w-full" />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-3 rounded-md border">
|
||||
<div className="min-w-0">
|
||||
<span className="font-medium text-sm">Log Level</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Controls the verbosity of application logs</p>
|
||||
</div>
|
||||
<Select
|
||||
value={logLevel}
|
||||
onValueChange={(value) => updateMutation.mutate({ key: 'log_level', value })}
|
||||
disabled={!canEdit || updateMutation.isPending}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LOG_LEVELS.map((level) => (
|
||||
<SelectItem key={level} value={level}>
|
||||
{level}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function LocationCard({ location }: { location: Location }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [editing, setEditing] = useState(false)
|
||||
|
||||
@@ -7,7 +7,7 @@ export const Route = createFileRoute('/login')({
|
||||
beforeLoad: () => {
|
||||
const { token } = useAuthStore.getState()
|
||||
if (token) {
|
||||
throw redirect({ to: '/accounts', search: {} as any })
|
||||
throw redirect({ to: '/accounts', search: {} as Record<string, unknown> })
|
||||
}
|
||||
},
|
||||
component: LoginPage,
|
||||
@@ -30,7 +30,7 @@ function LoginPage() {
|
||||
const res = await login(email, password)
|
||||
setAuth(res.token, res.user)
|
||||
await router.invalidate()
|
||||
await router.navigate({ to: '/accounts', search: {} as any, replace: true })
|
||||
await router.navigate({ to: '/accounts', search: {} as Record<string, unknown>, replace: true })
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Login failed')
|
||||
} finally {
|
||||
|
||||
21
packages/admin/src/routes/pos.tsx
Normal file
21
packages/admin/src/routes/pos.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { POSRegister } from '@/components/pos/pos-register'
|
||||
|
||||
export const Route = createFileRoute('/pos')({
|
||||
beforeLoad: () => {
|
||||
const { token } = useAuthStore.getState()
|
||||
if (!token) {
|
||||
throw redirect({ to: '/login' })
|
||||
}
|
||||
},
|
||||
component: POSPage,
|
||||
})
|
||||
|
||||
function POSPage() {
|
||||
return (
|
||||
<div className="h-screen w-screen overflow-hidden bg-background text-foreground">
|
||||
<POSRegister />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
21
packages/admin/src/stores/pos.store.ts
Normal file
21
packages/admin/src/stores/pos.store.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
interface POSState {
|
||||
currentTransactionId: string | null
|
||||
locationId: string | null
|
||||
drawerSessionId: string | null
|
||||
setTransaction: (id: string | null) => void
|
||||
setLocation: (id: string) => void
|
||||
setDrawerSession: (id: string | null) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export const usePOSStore = create<POSState>((set) => ({
|
||||
currentTransactionId: null,
|
||||
locationId: null,
|
||||
drawerSessionId: null,
|
||||
setTransaction: (id) => set({ currentTransactionId: id }),
|
||||
setLocation: (id) => set({ locationId: id }),
|
||||
setDrawerSession: (id) => set({ drawerSessionId: id }),
|
||||
reset: () => set({ currentTransactionId: null }),
|
||||
}))
|
||||
@@ -17,6 +17,7 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
allowedHosts: ['dev.lunarfront.tech'],
|
||||
proxy: {
|
||||
'/v1': {
|
||||
target: 'http://localhost:8000',
|
||||
|
||||
119
packages/backend/__tests__/services/tax.test.ts
Normal file
119
packages/backend/__tests__/services/tax.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { describe, it, expect } from 'bun:test'
|
||||
import { TaxService } from '../../src/services/tax.service.js'
|
||||
|
||||
describe('TaxService.calculateTax', () => {
|
||||
it('calculates tax on a simple amount', () => {
|
||||
// 8.25% on $100
|
||||
expect(TaxService.calculateTax(100, 0.0825)).toBe(8.25)
|
||||
})
|
||||
|
||||
it('rounds to 2 decimal places', () => {
|
||||
// 8.25% on $10.01 = 0.825825 → 0.83
|
||||
expect(TaxService.calculateTax(10.01, 0.0825)).toBe(0.83)
|
||||
})
|
||||
|
||||
it('returns 0 for zero rate', () => {
|
||||
expect(TaxService.calculateTax(100, 0)).toBe(0)
|
||||
})
|
||||
|
||||
it('returns 0 for zero amount', () => {
|
||||
expect(TaxService.calculateTax(0, 0.0825)).toBe(0)
|
||||
})
|
||||
|
||||
it('handles small amounts', () => {
|
||||
// 8.25% on $0.99 = 0.081675 → 0.08
|
||||
expect(TaxService.calculateTax(0.99, 0.0825)).toBe(0.08)
|
||||
})
|
||||
|
||||
it('handles large amounts', () => {
|
||||
// 8.25% on $9999.99 = 824.999175 → 825.00
|
||||
expect(TaxService.calculateTax(9999.99, 0.0825)).toBe(825)
|
||||
})
|
||||
|
||||
it('handles 5% service tax rate', () => {
|
||||
// 5% on $60 = 3.00
|
||||
expect(TaxService.calculateTax(60, 0.05)).toBe(3)
|
||||
})
|
||||
|
||||
it('handles fractional cent rounding down', () => {
|
||||
// 7% on $1.01 = 0.0707 → 0.07
|
||||
expect(TaxService.calculateTax(1.01, 0.07)).toBe(0.07)
|
||||
})
|
||||
|
||||
it('handles fractional cent rounding up', () => {
|
||||
// 7% on $1.05 = 0.0735 → 0.07
|
||||
expect(TaxService.calculateTax(1.05, 0.07)).toBe(0.07)
|
||||
})
|
||||
})
|
||||
|
||||
describe('TaxService.roundToNickel', () => {
|
||||
it('rounds .01 down to .00', () => {
|
||||
expect(TaxService.roundToNickel(10.01)).toBe(10.00)
|
||||
})
|
||||
|
||||
it('rounds .02 down to .00', () => {
|
||||
expect(TaxService.roundToNickel(10.02)).toBe(10.00)
|
||||
})
|
||||
|
||||
it('rounds .03 up to .05', () => {
|
||||
expect(TaxService.roundToNickel(10.03)).toBe(10.05)
|
||||
})
|
||||
|
||||
it('rounds .04 up to .05', () => {
|
||||
expect(TaxService.roundToNickel(10.04)).toBe(10.05)
|
||||
})
|
||||
|
||||
it('keeps .05 as is', () => {
|
||||
expect(TaxService.roundToNickel(10.05)).toBe(10.05)
|
||||
})
|
||||
|
||||
it('rounds .06 down to .05', () => {
|
||||
expect(TaxService.roundToNickel(10.06)).toBe(10.05)
|
||||
})
|
||||
|
||||
it('rounds .07 down to .05', () => {
|
||||
expect(TaxService.roundToNickel(10.07)).toBe(10.05)
|
||||
})
|
||||
|
||||
it('rounds .08 up to .10', () => {
|
||||
expect(TaxService.roundToNickel(10.08)).toBe(10.10)
|
||||
})
|
||||
|
||||
it('rounds .09 up to .10', () => {
|
||||
expect(TaxService.roundToNickel(10.09)).toBe(10.10)
|
||||
})
|
||||
|
||||
it('keeps .00 as is', () => {
|
||||
expect(TaxService.roundToNickel(10.00)).toBe(10.00)
|
||||
})
|
||||
|
||||
it('keeps .10 as is', () => {
|
||||
expect(TaxService.roundToNickel(10.10)).toBe(10.10)
|
||||
})
|
||||
|
||||
it('handles zero', () => {
|
||||
expect(TaxService.roundToNickel(0)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('TaxService.repairItemTypeToTaxCategory', () => {
|
||||
it('maps labor to service', () => {
|
||||
expect(TaxService.repairItemTypeToTaxCategory('labor')).toBe('service')
|
||||
})
|
||||
|
||||
it('maps part to goods', () => {
|
||||
expect(TaxService.repairItemTypeToTaxCategory('part')).toBe('goods')
|
||||
})
|
||||
|
||||
it('maps flat_rate to goods', () => {
|
||||
expect(TaxService.repairItemTypeToTaxCategory('flat_rate')).toBe('goods')
|
||||
})
|
||||
|
||||
it('maps misc to goods', () => {
|
||||
expect(TaxService.repairItemTypeToTaxCategory('misc')).toBe('goods')
|
||||
})
|
||||
|
||||
it('maps unknown type to goods (default)', () => {
|
||||
expect(TaxService.repairItemTypeToTaxCategory('something_else')).toBe('goods')
|
||||
})
|
||||
})
|
||||
@@ -5,15 +5,18 @@ import { getSuites, runSuite } from './lib/context.js'
|
||||
import { createClient } from './lib/client.js'
|
||||
|
||||
// --- Config ---
|
||||
// Use DATABASE_URL from env if available, otherwise construct from individual vars
|
||||
const DB_HOST = process.env.DB_HOST ?? 'localhost'
|
||||
const DB_PORT = Number(process.env.DB_PORT ?? '5432')
|
||||
const DB_USER = process.env.DB_USER ?? 'lunarfront'
|
||||
const DB_PASS = process.env.DB_PASS ?? 'lunarfront'
|
||||
const TEST_DB = 'lunarfront_api_test'
|
||||
const DB_URL = process.env.DATABASE_URL ?? `postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${TEST_DB}`
|
||||
const USE_EXTERNAL_DB = !!process.env.DATABASE_URL
|
||||
const TEST_PORT = 8001
|
||||
const BASE_URL = `http://localhost:${TEST_PORT}`
|
||||
const COMPANY_ID = 'a0000000-0000-0000-0000-000000000001'
|
||||
const LOCATION_ID = 'a0000000-0000-0000-0000-000000000002'
|
||||
const COMPANY_ID = '10000000-1000-4000-8000-000000000001'
|
||||
const LOCATION_ID = '10000000-1000-4000-8000-000000000002'
|
||||
|
||||
// --- Parse CLI args ---
|
||||
const args = process.argv.slice(2)
|
||||
@@ -27,13 +30,16 @@ for (let i = 0; i < args.length; i++) {
|
||||
|
||||
// --- DB setup ---
|
||||
async function setupDatabase() {
|
||||
const adminSql = postgres(`postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/postgres`)
|
||||
const [exists] = await adminSql`SELECT 1 FROM pg_database WHERE datname = ${TEST_DB}`
|
||||
if (!exists) {
|
||||
await adminSql.unsafe(`CREATE DATABASE ${TEST_DB}`)
|
||||
console.log(` Created database ${TEST_DB}`)
|
||||
if (!USE_EXTERNAL_DB) {
|
||||
// Local: create test DB if needed
|
||||
const adminSql = postgres(`postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/postgres`)
|
||||
const [exists] = await adminSql`SELECT 1 FROM pg_database WHERE datname = ${TEST_DB}`
|
||||
if (!exists) {
|
||||
await adminSql.unsafe(`CREATE DATABASE ${TEST_DB}`)
|
||||
console.log(` Created database ${TEST_DB}`)
|
||||
}
|
||||
await adminSql.end()
|
||||
}
|
||||
await adminSql.end()
|
||||
|
||||
// Run migrations
|
||||
const { execSync } = await import('child_process')
|
||||
@@ -41,13 +47,13 @@ async function setupDatabase() {
|
||||
cwd: new URL('..', import.meta.url).pathname,
|
||||
env: {
|
||||
...process.env,
|
||||
DATABASE_URL: `postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${TEST_DB}`,
|
||||
DATABASE_URL: DB_URL,
|
||||
},
|
||||
stdio: 'pipe',
|
||||
})
|
||||
|
||||
// Truncate all tables
|
||||
const testSql = postgres(`postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${TEST_DB}`)
|
||||
const testSql = postgres(DB_URL)
|
||||
await testSql.unsafe(`
|
||||
DO $$ DECLARE r RECORD;
|
||||
BEGIN
|
||||
@@ -61,7 +67,8 @@ async function setupDatabase() {
|
||||
|
||||
// Seed company + location (company table stays as store settings)
|
||||
await testSql`INSERT INTO company (id, name, timezone) VALUES (${COMPANY_ID}, 'Test Store', 'America/Chicago')`
|
||||
await testSql`INSERT INTO location (id, name) VALUES (${LOCATION_ID}, 'Test Location')`
|
||||
await testSql`INSERT INTO location (id, name, tax_rate, service_tax_rate) VALUES (${LOCATION_ID}, 'Test Location', '0.0825', '0.0500')`
|
||||
await testSql`INSERT INTO location (id, name, tax_rate, service_tax_rate, cash_rounding) VALUES ('10000000-1000-4000-8000-000000000003', 'Rounding Location', '0.0825', '0.0500', true)`
|
||||
|
||||
// Seed lookup tables
|
||||
const { SYSTEM_UNIT_STATUSES, SYSTEM_ITEM_CONDITIONS } = await import('../src/db/schema/lookups.js')
|
||||
@@ -115,7 +122,12 @@ async function setupDatabase() {
|
||||
async function killPort(port: number) {
|
||||
try {
|
||||
const { execSync } = await import('child_process')
|
||||
execSync(`lsof -ti:${port} | xargs kill -9 2>/dev/null || true`, { stdio: 'pipe' })
|
||||
// Try lsof first, fall back to fuser
|
||||
try {
|
||||
execSync(`lsof -ti:${port} | xargs kill -9 2>/dev/null || true`, { stdio: 'pipe' })
|
||||
} catch {
|
||||
execSync(`fuser -k ${port}/tcp 2>/dev/null || true`, { stdio: 'pipe' })
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1000))
|
||||
} catch {}
|
||||
}
|
||||
@@ -127,7 +139,7 @@ async function startBackend(): Promise<Subprocess> {
|
||||
cwd: new URL('..', import.meta.url).pathname,
|
||||
env: {
|
||||
...process.env,
|
||||
DATABASE_URL: `postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${TEST_DB}`,
|
||||
DATABASE_URL: DB_URL,
|
||||
REDIS_URL: process.env.REDIS_URL ?? 'redis://localhost:6379',
|
||||
JWT_SECRET: 'test-secret-for-api-tests',
|
||||
PORT: String(TEST_PORT),
|
||||
@@ -181,7 +193,7 @@ async function registerTestUser(): Promise<string> {
|
||||
|
||||
// Assign admin role to the user via direct SQL
|
||||
if (registerRes.status === 201 && registerData.user) {
|
||||
const assignSql = postgres(`postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${TEST_DB}`)
|
||||
const assignSql = postgres(DB_URL)
|
||||
const [adminRole] = await assignSql`SELECT id FROM role WHERE slug = 'admin' LIMIT 1`
|
||||
if (adminRole) {
|
||||
await assignSql`INSERT INTO user_role_assignment (user_id, role_id) VALUES (${registerData.user.id}, ${adminRole.id}) ON CONFLICT DO NOTHING`
|
||||
|
||||
623
packages/backend/api-tests/suites/pos.ts
Normal file
623
packages/backend/api-tests/suites/pos.ts
Normal file
@@ -0,0 +1,623 @@
|
||||
import { suite } from '../lib/context.js'
|
||||
|
||||
const LOCATION_ID = '10000000-1000-4000-8000-000000000002'
|
||||
const ROUNDING_LOCATION_ID = '10000000-1000-4000-8000-000000000003'
|
||||
|
||||
suite('POS', { tags: ['pos'] }, (t) => {
|
||||
// ─── Discounts (CRUD) ──────────────────────────────────────────────────────
|
||||
|
||||
t.test('creates a discount', { tags: ['discounts', 'create'] }, async () => {
|
||||
const res = await t.api.post('/v1/discounts', {
|
||||
name: 'Employee 10%',
|
||||
discountType: 'percent',
|
||||
discountValue: 10,
|
||||
appliesTo: 'line_item',
|
||||
})
|
||||
t.assert.status(res, 201)
|
||||
t.assert.equal(res.data.name, 'Employee 10%')
|
||||
t.assert.equal(res.data.discountType, 'percent')
|
||||
t.assert.ok(res.data.id)
|
||||
})
|
||||
|
||||
t.test('rejects discount without name', { tags: ['discounts', 'validation'] }, async () => {
|
||||
const res = await t.api.post('/v1/discounts', { discountType: 'fixed', discountValue: 5 })
|
||||
t.assert.status(res, 400)
|
||||
})
|
||||
|
||||
t.test('lists discounts with pagination', { tags: ['discounts', 'list'] }, async () => {
|
||||
await t.api.post('/v1/discounts', { name: 'Disc A', discountType: 'fixed', discountValue: 5 })
|
||||
await t.api.post('/v1/discounts', { name: 'Disc B', discountType: 'percent', discountValue: 15 })
|
||||
const res = await t.api.get('/v1/discounts', { page: 1, limit: 25 })
|
||||
t.assert.status(res, 200)
|
||||
t.assert.ok(res.data.data.length >= 2)
|
||||
t.assert.ok(res.data.pagination)
|
||||
})
|
||||
|
||||
t.test('lists all discounts (lookup)', { tags: ['discounts', 'list'] }, async () => {
|
||||
const res = await t.api.get('/v1/discounts/all')
|
||||
t.assert.status(res, 200)
|
||||
t.assert.ok(Array.isArray(res.data))
|
||||
})
|
||||
|
||||
t.test('gets discount by id', { tags: ['discounts', 'read'] }, async () => {
|
||||
const created = await t.api.post('/v1/discounts', { name: 'Get Me', discountType: 'fixed', discountValue: 3 })
|
||||
const res = await t.api.get(`/v1/discounts/${created.data.id}`)
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.name, 'Get Me')
|
||||
})
|
||||
|
||||
t.test('updates a discount', { tags: ['discounts', 'update'] }, async () => {
|
||||
const created = await t.api.post('/v1/discounts', { name: 'Before', discountType: 'fixed', discountValue: 1 })
|
||||
const res = await t.api.patch(`/v1/discounts/${created.data.id}`, { name: 'After', discountValue: 99 })
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.name, 'After')
|
||||
})
|
||||
|
||||
t.test('soft-deletes a discount', { tags: ['discounts', 'delete'] }, async () => {
|
||||
const created = await t.api.post('/v1/discounts', { name: 'Delete Me', discountType: 'fixed', discountValue: 1 })
|
||||
const res = await t.api.del(`/v1/discounts/${created.data.id}`)
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.isActive, false)
|
||||
})
|
||||
|
||||
// ─── Drawer Sessions ───────────────────────────────────────────────────────
|
||||
|
||||
t.test('opens a drawer session', { tags: ['drawer', 'create'] }, async () => {
|
||||
const res = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 200 })
|
||||
t.assert.status(res, 201)
|
||||
t.assert.equal(res.data.status, 'open')
|
||||
t.assert.ok(res.data.id)
|
||||
// Close it so future tests can open a new one
|
||||
await t.api.post(`/v1/drawer/${res.data.id}/close`, { closingBalance: 200 })
|
||||
})
|
||||
|
||||
t.test('rejects opening second drawer at same location', { tags: ['drawer', 'validation'] }, async () => {
|
||||
const first = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 100 })
|
||||
t.assert.status(first, 201)
|
||||
|
||||
const second = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 100 })
|
||||
t.assert.status(second, 409)
|
||||
|
||||
// Cleanup
|
||||
await t.api.post(`/v1/drawer/${first.data.id}/close`, { closingBalance: 100 })
|
||||
})
|
||||
|
||||
t.test('closes a drawer session with denominations', { tags: ['drawer', 'close'] }, async () => {
|
||||
const opened = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 150 })
|
||||
t.assert.status(opened, 201)
|
||||
|
||||
const res = await t.api.post(`/v1/drawer/${opened.data.id}/close`, {
|
||||
closingBalance: 155,
|
||||
denominations: { ones: 50, fives: 20, tens: 5, twenties: 2 },
|
||||
notes: 'End of shift',
|
||||
})
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.status, 'closed')
|
||||
t.assert.ok(res.data.closedAt)
|
||||
})
|
||||
|
||||
t.test('gets current open drawer for location', { tags: ['drawer', 'read'] }, async () => {
|
||||
const opened = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 100 })
|
||||
t.assert.status(opened, 201)
|
||||
|
||||
const res = await t.api.get('/v1/drawer/current', { locationId: LOCATION_ID })
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.id, opened.data.id)
|
||||
|
||||
// Cleanup
|
||||
await t.api.post(`/v1/drawer/${opened.data.id}/close`, { closingBalance: 100 })
|
||||
})
|
||||
|
||||
t.test('lists drawer sessions with pagination', { tags: ['drawer', 'list'] }, async () => {
|
||||
const res = await t.api.get('/v1/drawer', { page: 1, limit: 25 })
|
||||
t.assert.status(res, 200)
|
||||
t.assert.ok(res.data.pagination)
|
||||
})
|
||||
|
||||
// ─── Transactions ──────────────────────────────────────────────────────────
|
||||
|
||||
t.test('creates a sale transaction', { tags: ['transactions', 'create'] }, async () => {
|
||||
const res = await t.api.post('/v1/transactions', {
|
||||
transactionType: 'sale',
|
||||
locationId: LOCATION_ID,
|
||||
})
|
||||
t.assert.status(res, 201)
|
||||
t.assert.equal(res.data.transactionType, 'sale')
|
||||
t.assert.equal(res.data.status, 'pending')
|
||||
t.assert.ok(res.data.transactionNumber)
|
||||
t.assert.contains(res.data.transactionNumber, 'TXN-')
|
||||
})
|
||||
|
||||
t.test('rejects transaction without type', { tags: ['transactions', 'validation'] }, async () => {
|
||||
const res = await t.api.post('/v1/transactions', { locationId: LOCATION_ID })
|
||||
t.assert.status(res, 400)
|
||||
})
|
||||
|
||||
t.test('adds line items and calculates tax', { tags: ['transactions', 'line-items', 'tax'] }, async () => {
|
||||
const txn = await t.api.post('/v1/transactions', {
|
||||
transactionType: 'sale',
|
||||
locationId: LOCATION_ID,
|
||||
})
|
||||
t.assert.status(txn, 201)
|
||||
|
||||
// Add a line item (no product — ad hoc, taxed as goods at 8.25%)
|
||||
const item = await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
||||
description: 'Violin Strings',
|
||||
qty: 2,
|
||||
unitPrice: 12.99,
|
||||
})
|
||||
t.assert.status(item, 201)
|
||||
t.assert.equal(item.data.description, 'Violin Strings')
|
||||
t.assert.equal(item.data.qty, 2)
|
||||
|
||||
// Verify tax was applied (8.25% on $25.98 = ~$2.14)
|
||||
const taxAmount = parseFloat(item.data.taxAmount)
|
||||
t.assert.greaterThan(taxAmount, 0)
|
||||
|
||||
// Verify transaction totals were recalculated
|
||||
const updated = await t.api.get(`/v1/transactions/${txn.data.id}`)
|
||||
t.assert.status(updated, 200)
|
||||
const total = parseFloat(updated.data.total)
|
||||
t.assert.greaterThan(total, 0)
|
||||
})
|
||||
|
||||
t.test('removes a line item and recalculates', { tags: ['transactions', 'line-items'] }, async () => {
|
||||
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
|
||||
const item1 = await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
||||
description: 'Item A',
|
||||
qty: 1,
|
||||
unitPrice: 10,
|
||||
})
|
||||
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
||||
description: 'Item B',
|
||||
qty: 1,
|
||||
unitPrice: 20,
|
||||
})
|
||||
|
||||
// Remove item A
|
||||
const del = await t.api.del(`/v1/transactions/${txn.data.id}/line-items/${item1.data.id}`)
|
||||
t.assert.status(del, 200)
|
||||
|
||||
// Transaction should only have item B's total
|
||||
const updated = await t.api.get(`/v1/transactions/${txn.data.id}`)
|
||||
const subtotal = parseFloat(updated.data.subtotal)
|
||||
t.assert.equal(subtotal, 20)
|
||||
})
|
||||
|
||||
t.test('tax exempt transaction has zero tax', { tags: ['transactions', 'tax'] }, async () => {
|
||||
const txn = await t.api.post('/v1/transactions', {
|
||||
transactionType: 'sale',
|
||||
locationId: LOCATION_ID,
|
||||
taxExempt: true,
|
||||
taxExemptReason: 'Non-profit org',
|
||||
})
|
||||
t.assert.status(txn, 201)
|
||||
|
||||
const item = await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
||||
description: 'Exempt Item',
|
||||
qty: 1,
|
||||
unitPrice: 100,
|
||||
})
|
||||
t.assert.status(item, 201)
|
||||
t.assert.equal(parseFloat(item.data.taxAmount), 0)
|
||||
|
||||
const updated = await t.api.get(`/v1/transactions/${txn.data.id}`)
|
||||
t.assert.equal(parseFloat(updated.data.taxTotal), 0)
|
||||
t.assert.equal(parseFloat(updated.data.total), 100)
|
||||
})
|
||||
|
||||
t.test('transaction without location has zero tax', { tags: ['transactions', 'tax'] }, async () => {
|
||||
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale' })
|
||||
t.assert.status(txn, 201)
|
||||
|
||||
const item = await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
||||
description: 'No-location Item',
|
||||
qty: 1,
|
||||
unitPrice: 50,
|
||||
})
|
||||
t.assert.status(item, 201)
|
||||
t.assert.equal(parseFloat(item.data.taxAmount), 0)
|
||||
})
|
||||
|
||||
// ─── Discounts on Transactions ─────────────────────────────────────────────
|
||||
|
||||
t.test('applies a line-item discount', { tags: ['transactions', 'discounts'] }, async () => {
|
||||
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
|
||||
const item = await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
||||
description: 'Discountable Item',
|
||||
qty: 1,
|
||||
unitPrice: 100,
|
||||
})
|
||||
|
||||
const res = await t.api.post(`/v1/transactions/${txn.data.id}/discounts`, {
|
||||
amount: 15,
|
||||
reason: 'Loyalty discount',
|
||||
lineItemId: item.data.id,
|
||||
})
|
||||
t.assert.status(res, 200)
|
||||
|
||||
// Verify discount reduced the total
|
||||
const updated = await t.api.get(`/v1/transactions/${txn.data.id}`)
|
||||
const discountTotal = parseFloat(updated.data.discountTotal)
|
||||
t.assert.equal(discountTotal, 15)
|
||||
// Total should be (100 - 15) + tax on 85
|
||||
const total = parseFloat(updated.data.total)
|
||||
t.assert.greaterThan(total, 80)
|
||||
})
|
||||
|
||||
t.test('rejects discount exceeding approval threshold', { tags: ['transactions', 'discounts', 'validation'] }, async () => {
|
||||
// Create a discount with approval threshold
|
||||
const disc = await t.api.post('/v1/discounts', {
|
||||
name: 'Threshold Disc',
|
||||
discountType: 'fixed',
|
||||
discountValue: 50,
|
||||
requiresApprovalAbove: 20,
|
||||
})
|
||||
|
||||
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
|
||||
const item = await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
||||
description: 'Expensive Item',
|
||||
qty: 1,
|
||||
unitPrice: 200,
|
||||
})
|
||||
|
||||
// Try to apply $25 discount (above $20 threshold)
|
||||
const res = await t.api.post(`/v1/transactions/${txn.data.id}/discounts`, {
|
||||
discountId: disc.data.id,
|
||||
amount: 25,
|
||||
reason: 'Trying too much',
|
||||
lineItemId: item.data.id,
|
||||
})
|
||||
t.assert.status(res, 400)
|
||||
})
|
||||
|
||||
// ─── Complete Transaction ──────────────────────────────────────────────────
|
||||
|
||||
t.test('rejects completing transaction without open drawer', { tags: ['transactions', 'complete', 'validation', 'drawer'] }, async () => {
|
||||
// Ensure no drawer is open at LOCATION_ID
|
||||
const current = await t.api.get('/v1/drawer/current', { locationId: LOCATION_ID })
|
||||
if (current.status === 200 && current.data.id) {
|
||||
await t.api.post(`/v1/drawer/${current.data.id}/close`, { closingBalance: 0 })
|
||||
}
|
||||
|
||||
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
|
||||
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
||||
description: 'No Drawer Item',
|
||||
qty: 1,
|
||||
unitPrice: 10,
|
||||
})
|
||||
|
||||
const res = await t.api.post(`/v1/transactions/${txn.data.id}/complete`, {
|
||||
paymentMethod: 'cash',
|
||||
amountTendered: 20,
|
||||
})
|
||||
t.assert.status(res, 400)
|
||||
|
||||
// Void to clean up
|
||||
await t.api.post(`/v1/transactions/${txn.data.id}/void`)
|
||||
})
|
||||
|
||||
// Open a drawer for the remaining complete tests
|
||||
t.test('opens drawer for complete tests', { tags: ['transactions', 'complete', 'setup'] }, async () => {
|
||||
const res = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 200 })
|
||||
t.assert.status(res, 201)
|
||||
})
|
||||
|
||||
t.test('completes a cash transaction with change', { tags: ['transactions', 'complete', 'cash'] }, async () => {
|
||||
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
|
||||
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
||||
description: 'Cash Sale Item',
|
||||
qty: 1,
|
||||
unitPrice: 10,
|
||||
})
|
||||
|
||||
// Get updated total
|
||||
const pending = await t.api.get(`/v1/transactions/${txn.data.id}`)
|
||||
const total = parseFloat(pending.data.total)
|
||||
|
||||
const res = await t.api.post(`/v1/transactions/${txn.data.id}/complete`, {
|
||||
paymentMethod: 'cash',
|
||||
amountTendered: 20,
|
||||
})
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.status, 'completed')
|
||||
t.assert.equal(res.data.paymentMethod, 'cash')
|
||||
|
||||
const changeGiven = parseFloat(res.data.changeGiven)
|
||||
// change = 20 - total
|
||||
const expectedChange = 20 - total
|
||||
t.assert.equal(changeGiven, expectedChange)
|
||||
})
|
||||
|
||||
t.test('rejects cash payment with insufficient amount', { tags: ['transactions', 'complete', 'validation'] }, async () => {
|
||||
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
|
||||
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
||||
description: 'Underpay Item',
|
||||
qty: 1,
|
||||
unitPrice: 100,
|
||||
})
|
||||
|
||||
const res = await t.api.post(`/v1/transactions/${txn.data.id}/complete`, {
|
||||
paymentMethod: 'cash',
|
||||
amountTendered: 5,
|
||||
})
|
||||
t.assert.status(res, 400)
|
||||
})
|
||||
|
||||
t.test('completes a card transaction', { tags: ['transactions', 'complete'] }, async () => {
|
||||
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
|
||||
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
||||
description: 'Card Sale Item',
|
||||
qty: 1,
|
||||
unitPrice: 49.99,
|
||||
})
|
||||
|
||||
const res = await t.api.post(`/v1/transactions/${txn.data.id}/complete`, {
|
||||
paymentMethod: 'card_present',
|
||||
})
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.status, 'completed')
|
||||
t.assert.equal(res.data.paymentMethod, 'card_present')
|
||||
})
|
||||
|
||||
t.test('rejects completing a non-pending transaction', { tags: ['transactions', 'complete', 'validation'] }, async () => {
|
||||
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
|
||||
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
||||
description: 'Double Complete',
|
||||
qty: 1,
|
||||
unitPrice: 10,
|
||||
})
|
||||
await t.api.post(`/v1/transactions/${txn.data.id}/complete`, {
|
||||
paymentMethod: 'card_present',
|
||||
})
|
||||
|
||||
// Try completing again
|
||||
const res = await t.api.post(`/v1/transactions/${txn.data.id}/complete`, {
|
||||
paymentMethod: 'cash',
|
||||
amountTendered: 100,
|
||||
})
|
||||
t.assert.status(res, 409)
|
||||
})
|
||||
|
||||
// ─── Void Transaction ──────────────────────────────────────────────────────
|
||||
|
||||
t.test('voids a pending transaction', { tags: ['transactions', 'void'] }, async () => {
|
||||
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
|
||||
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
||||
description: 'Void Me',
|
||||
qty: 1,
|
||||
unitPrice: 25,
|
||||
})
|
||||
|
||||
const res = await t.api.post(`/v1/transactions/${txn.data.id}/void`)
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.status, 'voided')
|
||||
})
|
||||
|
||||
t.test('rejects voiding a completed transaction', { tags: ['transactions', 'void', 'validation'] }, async () => {
|
||||
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
|
||||
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
||||
description: 'No Void',
|
||||
qty: 1,
|
||||
unitPrice: 10,
|
||||
})
|
||||
await t.api.post(`/v1/transactions/${txn.data.id}/complete`, { paymentMethod: 'card_present' })
|
||||
|
||||
const res = await t.api.post(`/v1/transactions/${txn.data.id}/void`)
|
||||
t.assert.status(res, 409)
|
||||
})
|
||||
|
||||
// ─── Receipt ───────────────────────────────────────────────────────────────
|
||||
|
||||
t.test('gets transaction receipt', { tags: ['transactions', 'receipt'] }, async () => {
|
||||
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
|
||||
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
||||
description: 'Receipt Item',
|
||||
qty: 1,
|
||||
unitPrice: 42,
|
||||
})
|
||||
await t.api.post(`/v1/transactions/${txn.data.id}/complete`, { paymentMethod: 'card_present' })
|
||||
|
||||
const res = await t.api.get(`/v1/transactions/${txn.data.id}/receipt`)
|
||||
t.assert.status(res, 200)
|
||||
t.assert.ok(res.data.transaction)
|
||||
t.assert.ok(res.data.company)
|
||||
t.assert.ok(res.data.location)
|
||||
t.assert.equal(res.data.transaction.transactionNumber.startsWith('TXN-'), true)
|
||||
})
|
||||
|
||||
// ─── List Transactions ─────────────────────────────────────────────────────
|
||||
|
||||
t.test('lists transactions with pagination', { tags: ['transactions', 'list'] }, async () => {
|
||||
const res = await t.api.get('/v1/transactions', { page: 1, limit: 25 })
|
||||
t.assert.status(res, 200)
|
||||
t.assert.ok(res.data.data.length >= 1)
|
||||
t.assert.ok(res.data.pagination)
|
||||
})
|
||||
|
||||
t.test('filters transactions by status', { tags: ['transactions', 'list', 'filter'] }, async () => {
|
||||
const res = await t.api.get('/v1/transactions', { status: 'completed' })
|
||||
t.assert.status(res, 200)
|
||||
for (const txn of res.data.data) {
|
||||
t.assert.equal(txn.status, 'completed')
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Tax Lookup (stub) ────────────────────────────────────────────────────
|
||||
|
||||
t.test('tax lookup returns 501 (not configured)', { tags: ['tax'] }, async () => {
|
||||
const res = await t.api.get('/v1/tax/lookup/90210')
|
||||
t.assert.status(res, 501)
|
||||
})
|
||||
|
||||
t.test('rejects invalid zip format', { tags: ['tax', 'validation'] }, async () => {
|
||||
const res = await t.api.get('/v1/tax/lookup/abc')
|
||||
t.assert.status(res, 400)
|
||||
})
|
||||
|
||||
// ─── Cash Rounding ─────────────────────────────────────────────────────────
|
||||
|
||||
// Close the LOCATION_ID drawer and open one at ROUNDING_LOCATION_ID
|
||||
t.test('setup drawer for rounding tests', { tags: ['transactions', 'rounding', 'setup'] }, async () => {
|
||||
// Close drawer at LOCATION_ID
|
||||
const current = await t.api.get('/v1/drawer/current', { locationId: LOCATION_ID })
|
||||
if (current.status === 200 && current.data.id) {
|
||||
await t.api.post(`/v1/drawer/${current.data.id}/close`, { closingBalance: 200 })
|
||||
}
|
||||
// Open drawer at ROUNDING_LOCATION_ID
|
||||
const res = await t.api.post('/v1/drawer/open', { locationId: ROUNDING_LOCATION_ID, openingBalance: 200 })
|
||||
t.assert.status(res, 201)
|
||||
})
|
||||
|
||||
t.test('cash rounding adjusts total to nearest nickel', { tags: ['transactions', 'rounding'] }, async () => {
|
||||
// Create transaction at the rounding-enabled location
|
||||
const txn = await t.api.post('/v1/transactions', {
|
||||
transactionType: 'sale',
|
||||
locationId: ROUNDING_LOCATION_ID,
|
||||
})
|
||||
t.assert.status(txn, 201)
|
||||
|
||||
// Add item that will produce a total not divisible by $0.05
|
||||
// $10.01 + 8.25% tax = $10.01 + $0.83 = $10.84 → rounds to $10.85
|
||||
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
||||
description: 'Rounding Test Item',
|
||||
qty: 1,
|
||||
unitPrice: 10.01,
|
||||
})
|
||||
|
||||
const pending = await t.api.get(`/v1/transactions/${txn.data.id}`)
|
||||
const exactTotal = parseFloat(pending.data.total)
|
||||
|
||||
// Complete with cash — should round
|
||||
const res = await t.api.post(`/v1/transactions/${txn.data.id}/complete`, {
|
||||
paymentMethod: 'cash',
|
||||
amountTendered: 20,
|
||||
})
|
||||
t.assert.status(res, 200)
|
||||
|
||||
const roundingAdj = parseFloat(res.data.roundingAdjustment)
|
||||
const changeGiven = parseFloat(res.data.changeGiven)
|
||||
const roundedTotal = exactTotal + roundingAdj
|
||||
|
||||
// Rounded total should be divisible by 0.05
|
||||
t.assert.equal(Math.round(roundedTotal * 100) % 5, 0)
|
||||
// Change should be based on rounded total
|
||||
t.assert.equal(changeGiven, Math.round((20 - roundedTotal) * 100) / 100)
|
||||
// Adjustment should be small (-0.02 to +0.02)
|
||||
t.assert.ok(Math.abs(roundingAdj) <= 0.02)
|
||||
})
|
||||
|
||||
t.test('card payment skips rounding even at rounding location', { tags: ['transactions', 'rounding'] }, async () => {
|
||||
const txn = await t.api.post('/v1/transactions', {
|
||||
transactionType: 'sale',
|
||||
locationId: ROUNDING_LOCATION_ID,
|
||||
})
|
||||
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
||||
description: 'Card Rounding Test',
|
||||
qty: 1,
|
||||
unitPrice: 10.01,
|
||||
})
|
||||
|
||||
const res = await t.api.post(`/v1/transactions/${txn.data.id}/complete`, {
|
||||
paymentMethod: 'card_present',
|
||||
})
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(parseFloat(res.data.roundingAdjustment), 0)
|
||||
})
|
||||
|
||||
t.test('no rounding at non-rounding location', { tags: ['transactions', 'rounding'] }, async () => {
|
||||
// Open drawer at LOCATION_ID for this test
|
||||
const drawer = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 100 })
|
||||
t.assert.status(drawer, 201)
|
||||
|
||||
const txn = await t.api.post('/v1/transactions', {
|
||||
transactionType: 'sale',
|
||||
locationId: LOCATION_ID,
|
||||
})
|
||||
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
||||
description: 'No Rounding Item',
|
||||
qty: 1,
|
||||
unitPrice: 10.01,
|
||||
})
|
||||
|
||||
const res = await t.api.post(`/v1/transactions/${txn.data.id}/complete`, {
|
||||
paymentMethod: 'cash',
|
||||
amountTendered: 20,
|
||||
})
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(parseFloat(res.data.roundingAdjustment), 0)
|
||||
|
||||
// Cleanup
|
||||
await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: 100 })
|
||||
})
|
||||
|
||||
// Close rounding location drawer
|
||||
t.test('cleanup rounding drawer', { tags: ['transactions', 'rounding', 'setup'] }, async () => {
|
||||
const current = await t.api.get('/v1/drawer/current', { locationId: ROUNDING_LOCATION_ID })
|
||||
if (current.status === 200 && current.data.id) {
|
||||
await t.api.post(`/v1/drawer/${current.data.id}/close`, { closingBalance: 200 })
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Full POS Flow ────────────────────────────────────────────────────────
|
||||
|
||||
t.test('full sale flow: open drawer, sell, close drawer', { tags: ['e2e'] }, async () => {
|
||||
// 1. Open drawer
|
||||
const drawer = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 100 })
|
||||
t.assert.status(drawer, 201)
|
||||
|
||||
// 2. Create transaction
|
||||
const txn = await t.api.post('/v1/transactions', {
|
||||
transactionType: 'sale',
|
||||
locationId: LOCATION_ID,
|
||||
})
|
||||
t.assert.status(txn, 201)
|
||||
|
||||
// 3. Add line items
|
||||
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
||||
description: 'Guitar Pick (12pk)',
|
||||
qty: 3,
|
||||
unitPrice: 5.99,
|
||||
})
|
||||
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
||||
description: 'Capo',
|
||||
qty: 1,
|
||||
unitPrice: 19.99,
|
||||
})
|
||||
|
||||
// 4. Verify totals
|
||||
const pending = await t.api.get(`/v1/transactions/${txn.data.id}`)
|
||||
t.assert.status(pending, 200)
|
||||
const subtotal = parseFloat(pending.data.subtotal)
|
||||
const taxTotal = parseFloat(pending.data.taxTotal)
|
||||
const total = parseFloat(pending.data.total)
|
||||
// subtotal = 3*5.99 + 19.99 = 37.96
|
||||
t.assert.equal(subtotal, 37.96)
|
||||
t.assert.greaterThan(taxTotal, 0)
|
||||
t.assert.equal(total, subtotal + taxTotal)
|
||||
t.assert.equal(pending.data.lineItems.length, 2)
|
||||
|
||||
// 5. Complete with cash
|
||||
const completed = await t.api.post(`/v1/transactions/${txn.data.id}/complete`, {
|
||||
paymentMethod: 'cash',
|
||||
amountTendered: 50,
|
||||
})
|
||||
t.assert.status(completed, 200)
|
||||
t.assert.equal(completed.data.status, 'completed')
|
||||
const change = parseFloat(completed.data.changeGiven)
|
||||
t.assert.equal(change, Math.round((50 - total) * 100) / 100)
|
||||
|
||||
// 6. Get receipt
|
||||
const receipt = await t.api.get(`/v1/transactions/${txn.data.id}/receipt`)
|
||||
t.assert.status(receipt, 200)
|
||||
t.assert.equal(receipt.data.transaction.status, 'completed')
|
||||
|
||||
// 7. Close drawer
|
||||
const closed = await t.api.post(`/v1/drawer/${drawer.data.id}/close`, {
|
||||
closingBalance: 100 + total - change,
|
||||
notes: 'End of day',
|
||||
})
|
||||
t.assert.status(closed, 200)
|
||||
t.assert.equal(closed.data.status, 'closed')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lunarfront/backend",
|
||||
"version": "0.0.1",
|
||||
"version": "0.1.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from './schema/stores.js'
|
||||
export * from './schema/users.js'
|
||||
export * from './schema/accounts.js'
|
||||
export * from './schema/inventory.js'
|
||||
export * from './schema/pos.js'
|
||||
|
||||
111
packages/backend/src/db/migrations/0038_pos-core.sql
Normal file
111
packages/backend/src/db/migrations/0038_pos-core.sql
Normal file
@@ -0,0 +1,111 @@
|
||||
-- POS core: enums, tables, and new columns on existing tables
|
||||
|
||||
-- New enums
|
||||
CREATE TYPE "public"."transaction_type" AS ENUM('sale', 'repair_payment', 'rental_deposit', 'account_payment', 'refund');
|
||||
CREATE TYPE "public"."transaction_status" AS ENUM('pending', 'completed', 'voided', 'refunded');
|
||||
CREATE TYPE "public"."payment_method" AS ENUM('cash', 'card_present', 'card_keyed', 'check', 'account_charge');
|
||||
CREATE TYPE "public"."discount_type" AS ENUM('percent', 'fixed');
|
||||
CREATE TYPE "public"."discount_applies_to" AS ENUM('order', 'line_item', 'category');
|
||||
CREATE TYPE "public"."drawer_status" AS ENUM('open', 'closed');
|
||||
CREATE TYPE "public"."tax_category" AS ENUM('goods', 'service', 'exempt');
|
||||
|
||||
-- New columns on existing tables
|
||||
ALTER TABLE "product" ADD COLUMN "tax_category" "tax_category" NOT NULL DEFAULT 'goods';
|
||||
ALTER TABLE "location" ADD COLUMN "tax_rate" numeric(5, 4) NOT NULL DEFAULT '0';
|
||||
ALTER TABLE "location" ADD COLUMN "service_tax_rate" numeric(5, 4) NOT NULL DEFAULT '0';
|
||||
|
||||
-- Discount table
|
||||
CREATE TABLE "discount" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"location_id" uuid REFERENCES "location"("id"),
|
||||
"name" varchar(255) NOT NULL,
|
||||
"discount_type" "discount_type" NOT NULL,
|
||||
"discount_value" numeric(10, 2) NOT NULL,
|
||||
"applies_to" "discount_applies_to" NOT NULL DEFAULT 'line_item',
|
||||
"requires_approval_above" numeric(10, 2),
|
||||
"is_active" boolean NOT NULL DEFAULT true,
|
||||
"valid_from" timestamp with time zone,
|
||||
"valid_until" timestamp with time zone,
|
||||
"created_at" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Drawer session table
|
||||
CREATE TABLE "drawer_session" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"location_id" uuid REFERENCES "location"("id"),
|
||||
"opened_by" uuid NOT NULL REFERENCES "user"("id"),
|
||||
"closed_by" uuid REFERENCES "user"("id"),
|
||||
"opening_balance" numeric(10, 2) NOT NULL,
|
||||
"closing_balance" numeric(10, 2),
|
||||
"expected_balance" numeric(10, 2),
|
||||
"over_short" numeric(10, 2),
|
||||
"denominations" jsonb,
|
||||
"status" "drawer_status" NOT NULL DEFAULT 'open',
|
||||
"notes" text,
|
||||
"opened_at" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
"closed_at" timestamp with time zone
|
||||
);
|
||||
|
||||
-- Transaction table
|
||||
CREATE TABLE "transaction" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"location_id" uuid REFERENCES "location"("id"),
|
||||
"transaction_number" varchar(50) NOT NULL UNIQUE,
|
||||
"account_id" uuid REFERENCES "account"("id"),
|
||||
"repair_ticket_id" uuid REFERENCES "repair_ticket"("id"),
|
||||
"repair_batch_id" uuid REFERENCES "repair_batch"("id"),
|
||||
"transaction_type" "transaction_type" NOT NULL,
|
||||
"status" "transaction_status" NOT NULL DEFAULT 'pending',
|
||||
"subtotal" numeric(10, 2) NOT NULL DEFAULT '0',
|
||||
"discount_total" numeric(10, 2) NOT NULL DEFAULT '0',
|
||||
"tax_total" numeric(10, 2) NOT NULL DEFAULT '0',
|
||||
"total" numeric(10, 2) NOT NULL DEFAULT '0',
|
||||
"payment_method" "payment_method",
|
||||
"amount_tendered" numeric(10, 2),
|
||||
"change_given" numeric(10, 2),
|
||||
"check_number" varchar(50),
|
||||
"stripe_payment_intent_id" varchar(255),
|
||||
"tax_exempt" boolean NOT NULL DEFAULT false,
|
||||
"tax_exempt_reason" text,
|
||||
"processed_by" uuid NOT NULL REFERENCES "user"("id"),
|
||||
"drawer_session_id" uuid REFERENCES "drawer_session"("id"),
|
||||
"notes" text,
|
||||
"completed_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Transaction line item table
|
||||
CREATE TABLE "transaction_line_item" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"transaction_id" uuid NOT NULL REFERENCES "transaction"("id"),
|
||||
"product_id" uuid REFERENCES "product"("id"),
|
||||
"inventory_unit_id" uuid REFERENCES "inventory_unit"("id"),
|
||||
"description" varchar(255) NOT NULL,
|
||||
"qty" integer NOT NULL DEFAULT 1,
|
||||
"unit_price" numeric(10, 2) NOT NULL,
|
||||
"discount_amount" numeric(10, 2) NOT NULL DEFAULT '0',
|
||||
"discount_reason" text,
|
||||
"tax_rate" numeric(5, 4) NOT NULL DEFAULT '0',
|
||||
"tax_amount" numeric(10, 2) NOT NULL DEFAULT '0',
|
||||
"line_total" numeric(10, 2) NOT NULL DEFAULT '0',
|
||||
"created_at" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Discount audit table (append-only)
|
||||
CREATE TABLE "discount_audit" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"transaction_id" uuid NOT NULL REFERENCES "transaction"("id"),
|
||||
"transaction_line_item_id" uuid REFERENCES "transaction_line_item"("id"),
|
||||
"discount_id" uuid REFERENCES "discount"("id"),
|
||||
"applied_by" uuid NOT NULL REFERENCES "user"("id"),
|
||||
"approved_by" uuid REFERENCES "user"("id"),
|
||||
"original_amount" numeric(10, 2) NOT NULL,
|
||||
"discounted_amount" numeric(10, 2) NOT NULL,
|
||||
"reason" text NOT NULL,
|
||||
"created_at" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- SKU unique partial index (from prior untracked migration)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS products_sku_unique ON product (sku) WHERE sku IS NOT NULL;
|
||||
@@ -1,2 +0,0 @@
|
||||
-- Add unique index on products.sku (null values are excluded from uniqueness)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS products_sku_unique ON product (sku) WHERE sku IS NOT NULL;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Cash rounding: location setting + transaction adjustment tracking
|
||||
ALTER TABLE "location" ADD COLUMN "cash_rounding" boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE "transaction" ADD COLUMN "rounding_adjustment" numeric(10, 2) NOT NULL DEFAULT '0';
|
||||
11
packages/backend/src/db/migrations/0040_app-config.sql
Normal file
11
packages/backend/src/db/migrations/0040_app-config.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE IF NOT EXISTS "app_config" (
|
||||
"key" varchar(100) PRIMARY KEY NOT NULL,
|
||||
"value" text,
|
||||
"description" text,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
-- Seed default log level
|
||||
INSERT INTO "app_config" ("key", "value", "description")
|
||||
VALUES ('log_level', 'info', 'Application log level (fatal, error, warn, info, debug, trace)')
|
||||
ON CONFLICT ("key") DO NOTHING;
|
||||
@@ -267,6 +267,27 @@
|
||||
"when": 1774970000000,
|
||||
"tag": "0037_rate_cycles",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 38,
|
||||
"version": "7",
|
||||
"when": 1775321562910,
|
||||
"tag": "0038_pos-core",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 39,
|
||||
"version": "7",
|
||||
"when": 1775408000000,
|
||||
"tag": "0039_cash-rounding",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 40,
|
||||
"version": "7",
|
||||
"when": 1775494000000,
|
||||
"tag": "0040_app-config",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
integer,
|
||||
numeric,
|
||||
date,
|
||||
pgEnum,
|
||||
} from 'drizzle-orm/pg-core'
|
||||
import { locations } from './stores.js'
|
||||
|
||||
@@ -41,6 +42,8 @@ export const suppliers = pgTable('supplier', {
|
||||
// See lookups.ts for inventory_unit_status and item_condition tables.
|
||||
// Columns below use varchar referencing the lookup slug.
|
||||
|
||||
export const taxCategoryEnum = pgEnum('tax_category', ['goods', 'service', 'exempt'])
|
||||
|
||||
export const products = pgTable('product', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
locationId: uuid('location_id').references(() => locations.id),
|
||||
@@ -54,6 +57,7 @@ export const products = pgTable('product', {
|
||||
isSerialized: boolean('is_serialized').notNull().default(false),
|
||||
isRental: boolean('is_rental').notNull().default(false),
|
||||
isDualUseRepair: boolean('is_dual_use_repair').notNull().default(false),
|
||||
taxCategory: taxCategoryEnum('tax_category').notNull().default('goods'),
|
||||
price: numeric('price', { precision: 10, scale: 2 }),
|
||||
minPrice: numeric('min_price', { precision: 10, scale: 2 }),
|
||||
rentalRateMonthly: numeric('rental_rate_monthly', { precision: 10, scale: 2 }),
|
||||
|
||||
166
packages/backend/src/db/schema/pos.ts
Normal file
166
packages/backend/src/db/schema/pos.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
varchar,
|
||||
text,
|
||||
timestamp,
|
||||
boolean,
|
||||
integer,
|
||||
numeric,
|
||||
pgEnum,
|
||||
jsonb,
|
||||
} from 'drizzle-orm/pg-core'
|
||||
import { locations } from './stores.js'
|
||||
import { accounts } from './accounts.js'
|
||||
import { products, inventoryUnits } from './inventory.js'
|
||||
import { users } from './users.js'
|
||||
import { repairTickets, repairBatches } from './repairs.js'
|
||||
|
||||
// --- Enums ---
|
||||
|
||||
export const transactionTypeEnum = pgEnum('transaction_type', [
|
||||
'sale',
|
||||
'repair_payment',
|
||||
'rental_deposit',
|
||||
'account_payment',
|
||||
'refund',
|
||||
])
|
||||
|
||||
export const transactionStatusEnum = pgEnum('transaction_status', [
|
||||
'pending',
|
||||
'completed',
|
||||
'voided',
|
||||
'refunded',
|
||||
])
|
||||
|
||||
export const paymentMethodEnum = pgEnum('payment_method', [
|
||||
'cash',
|
||||
'card_present',
|
||||
'card_keyed',
|
||||
'check',
|
||||
'account_charge',
|
||||
])
|
||||
|
||||
export const discountTypeEnum = pgEnum('discount_type', ['percent', 'fixed'])
|
||||
|
||||
export const discountAppliesToEnum = pgEnum('discount_applies_to', [
|
||||
'order',
|
||||
'line_item',
|
||||
'category',
|
||||
])
|
||||
|
||||
export const drawerStatusEnum = pgEnum('drawer_status', ['open', 'closed'])
|
||||
|
||||
// --- Tables ---
|
||||
|
||||
export const discounts = pgTable('discount', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
locationId: uuid('location_id').references(() => locations.id),
|
||||
name: varchar('name', { length: 255 }).notNull(),
|
||||
discountType: discountTypeEnum('discount_type').notNull(),
|
||||
discountValue: numeric('discount_value', { precision: 10, scale: 2 }).notNull(),
|
||||
appliesTo: discountAppliesToEnum('applies_to').notNull().default('line_item'),
|
||||
requiresApprovalAbove: numeric('requires_approval_above', { precision: 10, scale: 2 }),
|
||||
isActive: boolean('is_active').notNull().default(true),
|
||||
validFrom: timestamp('valid_from', { withTimezone: true }),
|
||||
validUntil: timestamp('valid_until', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const drawerSessions = pgTable('drawer_session', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
locationId: uuid('location_id').references(() => locations.id),
|
||||
openedBy: uuid('opened_by')
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
closedBy: uuid('closed_by').references(() => users.id),
|
||||
openingBalance: numeric('opening_balance', { precision: 10, scale: 2 }).notNull(),
|
||||
closingBalance: numeric('closing_balance', { precision: 10, scale: 2 }),
|
||||
expectedBalance: numeric('expected_balance', { precision: 10, scale: 2 }),
|
||||
overShort: numeric('over_short', { precision: 10, scale: 2 }),
|
||||
denominations: jsonb('denominations').$type<Record<string, number>>(),
|
||||
status: drawerStatusEnum('status').notNull().default('open'),
|
||||
notes: text('notes'),
|
||||
openedAt: timestamp('opened_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
closedAt: timestamp('closed_at', { withTimezone: true }),
|
||||
})
|
||||
|
||||
export const transactions = pgTable('transaction', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
locationId: uuid('location_id').references(() => locations.id),
|
||||
transactionNumber: varchar('transaction_number', { length: 50 }).notNull().unique(),
|
||||
accountId: uuid('account_id').references(() => accounts.id),
|
||||
repairTicketId: uuid('repair_ticket_id').references(() => repairTickets.id),
|
||||
repairBatchId: uuid('repair_batch_id').references(() => repairBatches.id),
|
||||
transactionType: transactionTypeEnum('transaction_type').notNull(),
|
||||
status: transactionStatusEnum('status').notNull().default('pending'),
|
||||
subtotal: numeric('subtotal', { precision: 10, scale: 2 }).notNull().default('0'),
|
||||
discountTotal: numeric('discount_total', { precision: 10, scale: 2 }).notNull().default('0'),
|
||||
taxTotal: numeric('tax_total', { precision: 10, scale: 2 }).notNull().default('0'),
|
||||
total: numeric('total', { precision: 10, scale: 2 }).notNull().default('0'),
|
||||
paymentMethod: paymentMethodEnum('payment_method'),
|
||||
amountTendered: numeric('amount_tendered', { precision: 10, scale: 2 }),
|
||||
changeGiven: numeric('change_given', { precision: 10, scale: 2 }),
|
||||
checkNumber: varchar('check_number', { length: 50 }),
|
||||
stripePaymentIntentId: varchar('stripe_payment_intent_id', { length: 255 }),
|
||||
roundingAdjustment: numeric('rounding_adjustment', { precision: 10, scale: 2 }).notNull().default('0'),
|
||||
taxExempt: boolean('tax_exempt').notNull().default(false),
|
||||
taxExemptReason: text('tax_exempt_reason'),
|
||||
processedBy: uuid('processed_by')
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
drawerSessionId: uuid('drawer_session_id').references(() => drawerSessions.id),
|
||||
notes: text('notes'),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const transactionLineItems = pgTable('transaction_line_item', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
transactionId: uuid('transaction_id')
|
||||
.notNull()
|
||||
.references(() => transactions.id),
|
||||
productId: uuid('product_id').references(() => products.id),
|
||||
inventoryUnitId: uuid('inventory_unit_id').references(() => inventoryUnits.id),
|
||||
description: varchar('description', { length: 255 }).notNull(),
|
||||
qty: integer('qty').notNull().default(1),
|
||||
unitPrice: numeric('unit_price', { precision: 10, scale: 2 }).notNull(),
|
||||
discountAmount: numeric('discount_amount', { precision: 10, scale: 2 }).notNull().default('0'),
|
||||
discountReason: text('discount_reason'),
|
||||
taxRate: numeric('tax_rate', { precision: 5, scale: 4 }).notNull().default('0'),
|
||||
taxAmount: numeric('tax_amount', { precision: 10, scale: 2 }).notNull().default('0'),
|
||||
lineTotal: numeric('line_total', { precision: 10, scale: 2 }).notNull().default('0'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const discountAudits = pgTable('discount_audit', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
transactionId: uuid('transaction_id')
|
||||
.notNull()
|
||||
.references(() => transactions.id),
|
||||
transactionLineItemId: uuid('transaction_line_item_id').references(() => transactionLineItems.id),
|
||||
discountId: uuid('discount_id').references(() => discounts.id),
|
||||
appliedBy: uuid('applied_by')
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
approvedBy: uuid('approved_by').references(() => users.id),
|
||||
originalAmount: numeric('original_amount', { precision: 10, scale: 2 }).notNull(),
|
||||
discountedAmount: numeric('discounted_amount', { precision: 10, scale: 2 }).notNull(),
|
||||
reason: text('reason').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
})
|
||||
|
||||
// --- Type exports ---
|
||||
|
||||
export type Discount = typeof discounts.$inferSelect
|
||||
export type DiscountInsert = typeof discounts.$inferInsert
|
||||
export type Transaction = typeof transactions.$inferSelect
|
||||
export type TransactionInsert = typeof transactions.$inferInsert
|
||||
export type TransactionLineItem = typeof transactionLineItems.$inferSelect
|
||||
export type TransactionLineItemInsert = typeof transactionLineItems.$inferInsert
|
||||
export type DiscountAudit = typeof discountAudits.$inferSelect
|
||||
export type DiscountAuditInsert = typeof discountAudits.$inferInsert
|
||||
export type DrawerSession = typeof drawerSessions.$inferSelect
|
||||
export type DrawerSessionInsert = typeof drawerSessions.$inferInsert
|
||||
@@ -1,4 +1,4 @@
|
||||
import { pgTable, uuid, varchar, text, jsonb, timestamp, boolean } from 'drizzle-orm/pg-core'
|
||||
import { pgTable, uuid, varchar, text, jsonb, timestamp, boolean, numeric } from 'drizzle-orm/pg-core'
|
||||
|
||||
export const companies = pgTable('company', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
@@ -30,12 +30,24 @@ export const locations = pgTable('location', {
|
||||
phone: varchar('phone', { length: 50 }),
|
||||
email: varchar('email', { length: 255 }),
|
||||
timezone: varchar('timezone', { length: 100 }),
|
||||
taxRate: numeric('tax_rate', { precision: 5, scale: 4 }),
|
||||
serviceTaxRate: numeric('service_tax_rate', { precision: 5, scale: 4 }),
|
||||
cashRounding: boolean('cash_rounding').notNull().default(false),
|
||||
isActive: boolean('is_active').notNull().default(true),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const appConfig = pgTable('app_config', {
|
||||
key: varchar('key', { length: 100 }).primaryKey(),
|
||||
value: text('value'),
|
||||
description: text('description'),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export type Company = typeof companies.$inferSelect
|
||||
export type CompanyInsert = typeof companies.$inferInsert
|
||||
export type Location = typeof locations.$inferSelect
|
||||
export type LocationInsert = typeof locations.$inferInsert
|
||||
export type AppConfig = typeof appConfig.$inferSelect
|
||||
export type AppConfigInsert = typeof appConfig.$inferInsert
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import postgres from 'postgres'
|
||||
|
||||
const DB_URL = process.env.DATABASE_URL ?? 'postgresql://lunarfront:lunarfront@localhost:5432/lunarfront'
|
||||
const COMPANY_ID = 'a0000000-0000-0000-0000-000000000001'
|
||||
const COMPANY_ID = 'a0000000-0000-4000-8000-000000000001'
|
||||
|
||||
const sql = postgres(DB_URL)
|
||||
|
||||
@@ -18,7 +18,7 @@ async function seed() {
|
||||
const [company] = await sql`SELECT id FROM company WHERE id = ${COMPANY_ID}`
|
||||
if (!company) {
|
||||
await sql`INSERT INTO company (id, name, timezone) VALUES (${COMPANY_ID}, 'Demo Store', 'America/Chicago')`
|
||||
await sql`INSERT INTO location (id, name) VALUES ('a0000000-0000-0000-0000-000000000002', 'Main Store')`
|
||||
await sql`INSERT INTO location (id, name, tax_rate, service_tax_rate) VALUES ('a0000000-0000-4000-8000-000000000002', 'Main Store', '0.0825', '0.0825')`
|
||||
console.log(' Created company and location')
|
||||
|
||||
// Seed RBAC
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import postgres from 'postgres'
|
||||
|
||||
const DB_URL = process.env.DATABASE_URL ?? 'postgresql://lunarfront:lunarfront@localhost:5432/lunarfront'
|
||||
const COMPANY_ID = 'a0000000-0000-0000-0000-000000000001'
|
||||
const COMPANY_ID = 'a0000000-0000-4000-8000-000000000001'
|
||||
|
||||
const sql = postgres(DB_URL)
|
||||
|
||||
|
||||
@@ -21,13 +21,19 @@ import { fileRoutes } from './routes/v1/files.js'
|
||||
import { rbacRoutes } from './routes/v1/rbac.js'
|
||||
import { repairRoutes } from './routes/v1/repairs.js'
|
||||
import { lessonRoutes } from './routes/v1/lessons.js'
|
||||
import { transactionRoutes } from './routes/v1/transactions.js'
|
||||
import { drawerRoutes } from './routes/v1/drawer.js'
|
||||
import { discountRoutes } from './routes/v1/discounts.js'
|
||||
import { taxRoutes } from './routes/v1/tax.js'
|
||||
import { storageRoutes } from './routes/v1/storage.js'
|
||||
import { storeRoutes } from './routes/v1/store.js'
|
||||
import { vaultRoutes } from './routes/v1/vault.js'
|
||||
import { webdavRoutes } from './routes/webdav/index.js'
|
||||
import { moduleRoutes } from './routes/v1/modules.js'
|
||||
import { configRoutes } from './routes/v1/config.js'
|
||||
import { RbacService } from './services/rbac.service.js'
|
||||
import { ModuleService } from './services/module.service.js'
|
||||
import { AppConfigService } from './services/config.service.js'
|
||||
|
||||
export async function buildApp() {
|
||||
const app = Fastify({
|
||||
@@ -102,6 +108,7 @@ export async function buildApp() {
|
||||
await app.register(rbacRoutes, { prefix: '/v1' })
|
||||
await app.register(storeRoutes, { prefix: '/v1' })
|
||||
await app.register(moduleRoutes, { prefix: '/v1' })
|
||||
await app.register(configRoutes, { prefix: '/v1' })
|
||||
await app.register(lookupRoutes, { prefix: '/v1' })
|
||||
|
||||
// Module-gated routes
|
||||
@@ -111,6 +118,10 @@ export async function buildApp() {
|
||||
await app.register(withModule('files', storageRoutes), { prefix: '/v1' })
|
||||
await app.register(withModule('repairs', repairRoutes), { prefix: '/v1' })
|
||||
await app.register(withModule('lessons', lessonRoutes), { prefix: '/v1' })
|
||||
await app.register(withModule('pos', transactionRoutes), { prefix: '/v1' })
|
||||
await app.register(withModule('pos', drawerRoutes), { prefix: '/v1' })
|
||||
await app.register(withModule('pos', discountRoutes), { prefix: '/v1' })
|
||||
await app.register(withModule('pos', taxRoutes), { prefix: '/v1' })
|
||||
await app.register(withModule('vault', vaultRoutes), { prefix: '/v1' })
|
||||
// Register WebDAV custom HTTP methods before routes
|
||||
app.addHttpMethod('PROPFIND', { hasBody: true })
|
||||
@@ -138,6 +149,16 @@ export async function buildApp() {
|
||||
} catch (err) {
|
||||
app.log.error({ err }, 'Failed to load module cache')
|
||||
}
|
||||
try {
|
||||
await AppConfigService.refreshCache(app.db)
|
||||
const dbLogLevel = await AppConfigService.get(app.db, 'log_level')
|
||||
if (dbLogLevel) {
|
||||
app.log.level = dbLogLevel
|
||||
app.log.info({ level: dbLogLevel }, 'Log level loaded from config')
|
||||
}
|
||||
} catch (err) {
|
||||
app.log.error({ err }, 'Failed to load app config')
|
||||
}
|
||||
})
|
||||
|
||||
return app
|
||||
|
||||
@@ -5,8 +5,9 @@ import * as storeSchema from '../db/schema/stores.js'
|
||||
import * as userSchema from '../db/schema/users.js'
|
||||
import * as accountSchema from '../db/schema/accounts.js'
|
||||
import * as inventorySchema from '../db/schema/inventory.js'
|
||||
import * as posSchema from '../db/schema/pos.js'
|
||||
|
||||
const schema = { ...storeSchema, ...userSchema, ...accountSchema, ...inventorySchema }
|
||||
const schema = { ...storeSchema, ...userSchema, ...accountSchema, ...inventorySchema, ...posSchema }
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
|
||||
@@ -9,7 +9,8 @@ declare module 'fastify' {
|
||||
|
||||
export const redisPlugin = fp(async (app) => {
|
||||
const redisUrl = process.env.REDIS_URL ?? 'redis://localhost:6379'
|
||||
const redis = new Redis(redisUrl)
|
||||
const keyPrefix = process.env.REDIS_KEY_PREFIX ? `${process.env.REDIS_KEY_PREFIX}:` : ''
|
||||
const redis = new Redis(redisUrl, { keyPrefix })
|
||||
|
||||
app.decorate('redis', redis)
|
||||
|
||||
|
||||
48
packages/backend/src/routes/v1/config.ts
Normal file
48
packages/backend/src/routes/v1/config.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { AppConfigService } from '../../services/config.service.js'
|
||||
import { AppConfigUpdateSchema, LogLevel } from '@lunarfront/shared/schemas'
|
||||
|
||||
export const configRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get('/config', { preHandler: [app.authenticate, app.requirePermission('settings.view')] }, async (_request, reply) => {
|
||||
const configs = await AppConfigService.getAll(app.db)
|
||||
return reply.send({ data: configs })
|
||||
})
|
||||
|
||||
app.get('/config/:key', { preHandler: [app.authenticate, app.requirePermission('settings.view')] }, async (request, reply) => {
|
||||
const { key } = request.params as { key: string }
|
||||
const configs = await AppConfigService.getAll(app.db)
|
||||
const entry = configs.find((c) => c.key === key)
|
||||
if (!entry) return reply.status(404).send({ error: { message: 'Config key not found', statusCode: 404 } })
|
||||
return reply.send(entry)
|
||||
})
|
||||
|
||||
app.patch('/config/:key', { preHandler: [app.authenticate, app.requirePermission('settings.edit')] }, async (request, reply) => {
|
||||
const { key } = request.params as { key: string }
|
||||
const parsed = AppConfigUpdateSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
|
||||
}
|
||||
|
||||
const value = parsed.data.value === null ? null : String(parsed.data.value)
|
||||
|
||||
// Key-specific validation
|
||||
if (key === 'log_level') {
|
||||
const levelResult = LogLevel.safeParse(value)
|
||||
if (!levelResult.success) {
|
||||
return reply.status(400).send({
|
||||
error: { message: 'Invalid log level. Must be one of: fatal, error, warn, info, debug, trace', statusCode: 400 },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await AppConfigService.set(app.db, key, value)
|
||||
|
||||
// Apply log level change immediately
|
||||
if (key === 'log_level' && value) {
|
||||
app.log.level = value
|
||||
request.log.info({ level: value, changedBy: request.user.id }, 'Log level changed')
|
||||
}
|
||||
|
||||
return reply.send(updated)
|
||||
})
|
||||
}
|
||||
54
packages/backend/src/routes/v1/discounts.ts
Normal file
54
packages/backend/src/routes/v1/discounts.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { PaginationSchema, DiscountCreateSchema, DiscountUpdateSchema } from '@lunarfront/shared/schemas'
|
||||
import { DiscountService } from '../../services/discount.service.js'
|
||||
|
||||
export const discountRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.post('/discounts', { preHandler: [app.authenticate, app.requirePermission('pos.admin')] }, async (request, reply) => {
|
||||
const parsed = DiscountCreateSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
|
||||
}
|
||||
const discount = await DiscountService.create(app.db, parsed.data)
|
||||
request.log.info({ discountId: discount.id, name: parsed.data.name, userId: request.user.id }, 'Discount created')
|
||||
return reply.status(201).send(discount)
|
||||
})
|
||||
|
||||
app.get('/discounts', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
|
||||
const query = request.query as Record<string, string | undefined>
|
||||
const params = PaginationSchema.parse(query)
|
||||
const result = await DiscountService.list(app.db, params)
|
||||
return reply.send(result)
|
||||
})
|
||||
|
||||
app.get('/discounts/all', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
|
||||
const discounts = await DiscountService.listAll(app.db)
|
||||
return reply.send(discounts)
|
||||
})
|
||||
|
||||
app.get('/discounts/:id', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const discount = await DiscountService.getById(app.db, id)
|
||||
if (!discount) return reply.status(404).send({ error: { message: 'Discount not found', statusCode: 404 } })
|
||||
return reply.send(discount)
|
||||
})
|
||||
|
||||
app.patch('/discounts/:id', { preHandler: [app.authenticate, app.requirePermission('pos.admin')] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const parsed = DiscountUpdateSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
|
||||
}
|
||||
const discount = await DiscountService.update(app.db, id, parsed.data)
|
||||
if (!discount) return reply.status(404).send({ error: { message: 'Discount not found', statusCode: 404 } })
|
||||
request.log.info({ discountId: id, userId: request.user.id }, 'Discount updated')
|
||||
return reply.send(discount)
|
||||
})
|
||||
|
||||
app.delete('/discounts/:id', { preHandler: [app.authenticate, app.requirePermission('pos.admin')] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const discount = await DiscountService.softDelete(app.db, id)
|
||||
if (!discount) return reply.status(404).send({ error: { message: 'Discount not found', statusCode: 404 } })
|
||||
request.log.info({ discountId: id, userId: request.user.id }, 'Discount deactivated')
|
||||
return reply.send(discount)
|
||||
})
|
||||
}
|
||||
51
packages/backend/src/routes/v1/drawer.ts
Normal file
51
packages/backend/src/routes/v1/drawer.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { PaginationSchema, DrawerOpenSchema, DrawerCloseSchema } from '@lunarfront/shared/schemas'
|
||||
import { DrawerService } from '../../services/drawer.service.js'
|
||||
|
||||
export const drawerRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.post('/drawer/open', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => {
|
||||
const parsed = DrawerOpenSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
|
||||
}
|
||||
const session = await DrawerService.open(app.db, parsed.data, request.user.id)
|
||||
request.log.info({ drawerSessionId: session.id, locationId: parsed.data.locationId, openingBalance: parsed.data.openingBalance, userId: request.user.id }, 'Drawer opened')
|
||||
return reply.status(201).send(session)
|
||||
})
|
||||
|
||||
app.post('/drawer/:id/close', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const parsed = DrawerCloseSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
|
||||
}
|
||||
const session = await DrawerService.close(app.db, id, parsed.data, request.user.id)
|
||||
request.log.info({ drawerSessionId: id, closingBalance: parsed.data.closingBalance, expectedBalance: session.expectedBalance, overShort: session.overShort, closedBy: request.user.id }, 'Drawer closed')
|
||||
return reply.send(session)
|
||||
})
|
||||
|
||||
app.get('/drawer/current', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
|
||||
const query = request.query as Record<string, string | undefined>
|
||||
const locationId = query.locationId
|
||||
if (!locationId) {
|
||||
return reply.status(400).send({ error: { message: 'locationId query param is required', statusCode: 400 } })
|
||||
}
|
||||
const session = await DrawerService.getOpen(app.db, locationId)
|
||||
if (!session) return reply.status(404).send({ error: { message: 'No open drawer session found', statusCode: 404 } })
|
||||
return reply.send(session)
|
||||
})
|
||||
|
||||
app.get('/drawer/:id', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const session = await DrawerService.getById(app.db, id)
|
||||
if (!session) return reply.status(404).send({ error: { message: 'Drawer session not found', statusCode: 404 } })
|
||||
return reply.send(session)
|
||||
})
|
||||
|
||||
app.get('/drawer', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
|
||||
const query = request.query as Record<string, string | undefined>
|
||||
const params = PaginationSchema.parse(query)
|
||||
const result = await DrawerService.list(app.db, params)
|
||||
return reply.send(result)
|
||||
})
|
||||
}
|
||||
@@ -12,6 +12,15 @@ import {
|
||||
import { ProductService, InventoryUnitService, ProductSupplierService, StockReceiptService } from '../../services/product.service.js'
|
||||
|
||||
export const productRoutes: FastifyPluginAsync = async (app) => {
|
||||
// --- UPC Barcode Lookup ---
|
||||
|
||||
app.get('/products/lookup/upc/:upc', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
|
||||
const { upc } = request.params as { upc: string }
|
||||
const product = await ProductService.getByUpc(app.db, upc)
|
||||
if (!product) return reply.status(404).send({ error: { message: 'No product found for this UPC', statusCode: 404 } })
|
||||
return reply.send(product)
|
||||
})
|
||||
|
||||
// --- Products ---
|
||||
|
||||
app.post('/products', { preHandler: [app.authenticate, app.requirePermission('inventory.edit')] }, async (request, reply) => {
|
||||
|
||||
15
packages/backend/src/routes/v1/tax.ts
Normal file
15
packages/backend/src/routes/v1/tax.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { TaxService } from '../../services/tax.service.js'
|
||||
|
||||
export const taxRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get('/tax/lookup/:zip', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
|
||||
const { zip } = request.params as { zip: string }
|
||||
|
||||
if (!/^\d{5}(-\d{4})?$/.test(zip)) {
|
||||
return reply.status(400).send({ error: { message: 'Invalid zip code format', statusCode: 400 } })
|
||||
}
|
||||
|
||||
const result = await TaxService.lookupByZip(zip)
|
||||
return reply.send(result)
|
||||
})
|
||||
}
|
||||
95
packages/backend/src/routes/v1/transactions.ts
Normal file
95
packages/backend/src/routes/v1/transactions.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import {
|
||||
PaginationSchema,
|
||||
TransactionCreateSchema,
|
||||
TransactionLineItemCreateSchema,
|
||||
ApplyDiscountSchema,
|
||||
CompleteTransactionSchema,
|
||||
} from '@lunarfront/shared/schemas'
|
||||
import { TransactionService } from '../../services/transaction.service.js'
|
||||
|
||||
export const transactionRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.post('/transactions', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => {
|
||||
const parsed = TransactionCreateSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
|
||||
}
|
||||
const txn = await TransactionService.create(app.db, parsed.data, request.user.id)
|
||||
request.log.info({ transactionId: txn.id, type: parsed.data.transactionType, userId: request.user.id }, 'Transaction created')
|
||||
return reply.status(201).send(txn)
|
||||
})
|
||||
|
||||
app.get('/transactions', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
|
||||
const query = request.query as Record<string, string | undefined>
|
||||
const params = PaginationSchema.parse(query)
|
||||
|
||||
const filters = {
|
||||
status: query.status,
|
||||
transactionType: query.transactionType,
|
||||
locationId: query.locationId,
|
||||
}
|
||||
|
||||
const result = await TransactionService.list(app.db, params, filters)
|
||||
return reply.send(result)
|
||||
})
|
||||
|
||||
app.get('/transactions/:id', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const txn = await TransactionService.getById(app.db, id)
|
||||
if (!txn) return reply.status(404).send({ error: { message: 'Transaction not found', statusCode: 404 } })
|
||||
return reply.send(txn)
|
||||
})
|
||||
|
||||
app.get('/transactions/:id/receipt', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const receipt = await TransactionService.getReceipt(app.db, id)
|
||||
return reply.send(receipt)
|
||||
})
|
||||
|
||||
app.post('/transactions/:id/line-items', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const parsed = TransactionLineItemCreateSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
|
||||
}
|
||||
const lineItem = await TransactionService.addLineItem(app.db, id, parsed.data)
|
||||
return reply.status(201).send(lineItem)
|
||||
})
|
||||
|
||||
app.delete('/transactions/:id/line-items/:lineItemId', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => {
|
||||
const { id, lineItemId } = request.params as { id: string; lineItemId: string }
|
||||
const deleted = await TransactionService.removeLineItem(app.db, id, lineItemId)
|
||||
return reply.send(deleted)
|
||||
})
|
||||
|
||||
app.post('/transactions/:id/discounts', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const parsed = ApplyDiscountSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
|
||||
}
|
||||
await TransactionService.applyDiscount(app.db, id, parsed.data, request.user.id)
|
||||
const txn = await TransactionService.getById(app.db, id)
|
||||
return reply.send(txn)
|
||||
})
|
||||
|
||||
app.post('/transactions/:id/complete', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const parsed = CompleteTransactionSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
|
||||
}
|
||||
await TransactionService.complete(app.db, id, parsed.data)
|
||||
const txn = await TransactionService.getById(app.db, id)
|
||||
request.log.info({ transactionId: id, paymentMethod: parsed.data.paymentMethod, userId: request.user.id }, 'Transaction completed')
|
||||
return reply.send(txn)
|
||||
})
|
||||
|
||||
app.post('/transactions/:id/void', { preHandler: [app.authenticate, app.requirePermission('pos.admin')] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
await TransactionService.void(app.db, id, request.user.id)
|
||||
const txn = await TransactionService.getById(app.db, id)
|
||||
request.log.info({ transactionId: id, voidedBy: request.user.id }, 'Transaction voided')
|
||||
return reply.send(txn)
|
||||
})
|
||||
}
|
||||
46
packages/backend/src/services/config.service.ts
Normal file
46
packages/backend/src/services/config.service.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
|
||||
import { appConfig } from '../db/schema/stores.js'
|
||||
|
||||
let configCache: Map<string, string | null> | null = null
|
||||
|
||||
export const AppConfigService = {
|
||||
async getAll(db: PostgresJsDatabase<any>) {
|
||||
return db.select().from(appConfig)
|
||||
},
|
||||
|
||||
async get(db: PostgresJsDatabase<any>, key: string): Promise<string | null> {
|
||||
if (!configCache) await this.refreshCache(db)
|
||||
return configCache!.get(key) ?? null
|
||||
},
|
||||
|
||||
async set(db: PostgresJsDatabase<any>, key: string, value: string | null, description?: string) {
|
||||
const [existing] = await db.select().from(appConfig).where(eq(appConfig.key, key)).limit(1)
|
||||
|
||||
if (existing) {
|
||||
const [updated] = await db
|
||||
.update(appConfig)
|
||||
.set({ value, updatedAt: new Date(), ...(description !== undefined ? { description } : {}) })
|
||||
.where(eq(appConfig.key, key))
|
||||
.returning()
|
||||
configCache = null
|
||||
return updated
|
||||
}
|
||||
|
||||
const [inserted] = await db
|
||||
.insert(appConfig)
|
||||
.values({ key, value, description, updatedAt: new Date() })
|
||||
.returning()
|
||||
configCache = null
|
||||
return inserted
|
||||
},
|
||||
|
||||
async refreshCache(db: PostgresJsDatabase<any>) {
|
||||
const rows = await db.select({ key: appConfig.key, value: appConfig.value }).from(appConfig)
|
||||
configCache = new Map(rows.map((r) => [r.key, r.value]))
|
||||
},
|
||||
|
||||
invalidateCache() {
|
||||
configCache = null
|
||||
},
|
||||
}
|
||||
89
packages/backend/src/services/discount.service.ts
Normal file
89
packages/backend/src/services/discount.service.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { eq, and, count, type Column } from 'drizzle-orm'
|
||||
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
|
||||
import { discounts } from '../db/schema/pos.js'
|
||||
import type { DiscountCreateInput, DiscountUpdateInput, PaginationInput } from '@lunarfront/shared/schemas'
|
||||
import { withPagination, withSort, buildSearchCondition, paginatedResponse } from '../utils/pagination.js'
|
||||
|
||||
export const DiscountService = {
|
||||
async create(db: PostgresJsDatabase<any>, input: DiscountCreateInput) {
|
||||
const [discount] = await db
|
||||
.insert(discounts)
|
||||
.values({
|
||||
...input,
|
||||
discountValue: input.discountValue.toString(),
|
||||
requiresApprovalAbove: input.requiresApprovalAbove?.toString(),
|
||||
validFrom: input.validFrom ? new Date(input.validFrom) : undefined,
|
||||
validUntil: input.validUntil ? new Date(input.validUntil) : undefined,
|
||||
})
|
||||
.returning()
|
||||
return discount
|
||||
},
|
||||
|
||||
async getById(db: PostgresJsDatabase<any>, id: string) {
|
||||
const [discount] = await db
|
||||
.select()
|
||||
.from(discounts)
|
||||
.where(eq(discounts.id, id))
|
||||
.limit(1)
|
||||
return discount ?? null
|
||||
},
|
||||
|
||||
async list(db: PostgresJsDatabase<any>, params: PaginationInput) {
|
||||
const conditions = [eq(discounts.isActive, true)]
|
||||
|
||||
if (params.q) {
|
||||
conditions.push(buildSearchCondition(params.q, [discounts.name])!)
|
||||
}
|
||||
|
||||
const where = conditions.length === 1 ? conditions[0] : and(...conditions)
|
||||
|
||||
const sortableColumns: Record<string, Column> = {
|
||||
name: discounts.name,
|
||||
discount_type: discounts.discountType,
|
||||
created_at: discounts.createdAt,
|
||||
}
|
||||
|
||||
let query = db.select().from(discounts).where(where).$dynamic()
|
||||
query = withSort(query, params.sort, params.order, sortableColumns, discounts.name)
|
||||
query = withPagination(query, params.page, params.limit)
|
||||
|
||||
const [data, [{ total }]] = await Promise.all([
|
||||
query,
|
||||
db.select({ total: count() }).from(discounts).where(where),
|
||||
])
|
||||
|
||||
return paginatedResponse(data, total, params.page, params.limit)
|
||||
},
|
||||
|
||||
async listAll(db: PostgresJsDatabase<any>) {
|
||||
return db
|
||||
.select()
|
||||
.from(discounts)
|
||||
.where(eq(discounts.isActive, true))
|
||||
.orderBy(discounts.name)
|
||||
},
|
||||
|
||||
async update(db: PostgresJsDatabase<any>, id: string, input: DiscountUpdateInput) {
|
||||
const updates: Record<string, unknown> = { ...input, updatedAt: new Date() }
|
||||
if (input.discountValue !== undefined) updates.discountValue = input.discountValue.toString()
|
||||
if (input.requiresApprovalAbove !== undefined) updates.requiresApprovalAbove = input.requiresApprovalAbove.toString()
|
||||
if (input.validFrom !== undefined) updates.validFrom = input.validFrom ? new Date(input.validFrom) : null
|
||||
if (input.validUntil !== undefined) updates.validUntil = input.validUntil ? new Date(input.validUntil) : null
|
||||
|
||||
const [discount] = await db
|
||||
.update(discounts)
|
||||
.set(updates)
|
||||
.where(eq(discounts.id, id))
|
||||
.returning()
|
||||
return discount ?? null
|
||||
},
|
||||
|
||||
async softDelete(db: PostgresJsDatabase<any>, id: string) {
|
||||
const [discount] = await db
|
||||
.update(discounts)
|
||||
.set({ isActive: false, updatedAt: new Date() })
|
||||
.where(eq(discounts.id, id))
|
||||
.returning()
|
||||
return discount ?? null
|
||||
},
|
||||
}
|
||||
114
packages/backend/src/services/drawer.service.ts
Normal file
114
packages/backend/src/services/drawer.service.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { eq, and, count, sum, type Column } from 'drizzle-orm'
|
||||
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
|
||||
import { drawerSessions, transactions } from '../db/schema/pos.js'
|
||||
import { ConflictError, NotFoundError } from '../lib/errors.js'
|
||||
import type { DrawerOpenInput, DrawerCloseInput, PaginationInput } from '@lunarfront/shared/schemas'
|
||||
import { withPagination, withSort, paginatedResponse } from '../utils/pagination.js'
|
||||
|
||||
export const DrawerService = {
|
||||
async open(db: PostgresJsDatabase<any>, input: DrawerOpenInput, openedBy: string) {
|
||||
// Ensure no other open session at this location
|
||||
if (input.locationId) {
|
||||
const existing = await this.getOpen(db, input.locationId)
|
||||
if (existing) {
|
||||
throw new ConflictError('A drawer session is already open at this location')
|
||||
}
|
||||
}
|
||||
|
||||
const [session] = await db
|
||||
.insert(drawerSessions)
|
||||
.values({
|
||||
locationId: input.locationId,
|
||||
openedBy,
|
||||
openingBalance: input.openingBalance.toString(),
|
||||
})
|
||||
.returning()
|
||||
return session
|
||||
},
|
||||
|
||||
async close(db: PostgresJsDatabase<any>, sessionId: string, input: DrawerCloseInput, closedBy: string) {
|
||||
const session = await this.getById(db, sessionId)
|
||||
if (!session) throw new NotFoundError('Drawer session')
|
||||
if (session.status === 'closed') throw new ConflictError('Drawer session is already closed')
|
||||
|
||||
// Calculate expected balance from cash transactions in this drawer session
|
||||
// Net cash kept = total + rounding_adjustment (change is already accounted for)
|
||||
const [cashTotals] = await db
|
||||
.select({
|
||||
total: sum(transactions.total),
|
||||
rounding: sum(transactions.roundingAdjustment),
|
||||
})
|
||||
.from(transactions)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.drawerSessionId, sessionId),
|
||||
eq(transactions.status, 'completed'),
|
||||
eq(transactions.paymentMethod, 'cash')
|
||||
)
|
||||
)
|
||||
|
||||
const cashIn = parseFloat(cashTotals?.total ?? '0') + parseFloat(cashTotals?.rounding ?? '0')
|
||||
const openingBalance = parseFloat(session.openingBalance)
|
||||
const expectedBalance = openingBalance + cashIn
|
||||
const closingBalance = input.closingBalance
|
||||
const overShort = closingBalance - expectedBalance
|
||||
|
||||
const [updated] = await db
|
||||
.update(drawerSessions)
|
||||
.set({
|
||||
closedBy,
|
||||
closingBalance: closingBalance.toString(),
|
||||
expectedBalance: expectedBalance.toString(),
|
||||
overShort: overShort.toString(),
|
||||
denominations: input.denominations,
|
||||
notes: input.notes,
|
||||
status: 'closed',
|
||||
closedAt: new Date(),
|
||||
})
|
||||
.where(eq(drawerSessions.id, sessionId))
|
||||
.returning()
|
||||
return updated
|
||||
},
|
||||
|
||||
async getOpen(db: PostgresJsDatabase<any>, locationId: string) {
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(drawerSessions)
|
||||
.where(
|
||||
and(
|
||||
eq(drawerSessions.locationId, locationId),
|
||||
eq(drawerSessions.status, 'open')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
return session ?? null
|
||||
},
|
||||
|
||||
async getById(db: PostgresJsDatabase<any>, id: string) {
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(drawerSessions)
|
||||
.where(eq(drawerSessions.id, id))
|
||||
.limit(1)
|
||||
return session ?? null
|
||||
},
|
||||
|
||||
async list(db: PostgresJsDatabase<any>, params: PaginationInput) {
|
||||
const sortableColumns: Record<string, Column> = {
|
||||
opened_at: drawerSessions.openedAt,
|
||||
closed_at: drawerSessions.closedAt,
|
||||
status: drawerSessions.status,
|
||||
}
|
||||
|
||||
let query = db.select().from(drawerSessions).$dynamic()
|
||||
query = withSort(query, params.sort, params.order, sortableColumns, drawerSessions.openedAt)
|
||||
query = withPagination(query, params.page, params.limit)
|
||||
|
||||
const [data, [{ total }]] = await Promise.all([
|
||||
query,
|
||||
db.select({ total: count() }).from(drawerSessions),
|
||||
])
|
||||
|
||||
return paginatedResponse(data, total, params.page, params.limit)
|
||||
},
|
||||
}
|
||||
@@ -140,6 +140,15 @@ export const ProductService = {
|
||||
return product ?? null
|
||||
},
|
||||
|
||||
async getByUpc(db: PostgresJsDatabase<any>, upc: string) {
|
||||
const [product] = await db
|
||||
.select()
|
||||
.from(products)
|
||||
.where(and(eq(products.upc, upc), eq(products.isActive, true)))
|
||||
.limit(1)
|
||||
return product ?? null
|
||||
},
|
||||
|
||||
async listPriceHistory(db: PostgresJsDatabase<any>, productId: string) {
|
||||
return db
|
||||
.select()
|
||||
|
||||
86
packages/backend/src/services/tax.service.ts
Normal file
86
packages/backend/src/services/tax.service.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
|
||||
import { locations } from '../db/schema/stores.js'
|
||||
import { AppError } from '../lib/errors.js'
|
||||
|
||||
export type TaxCategory = 'goods' | 'service' | 'exempt'
|
||||
|
||||
export const TaxService = {
|
||||
/**
|
||||
* Get the tax rate for a location, resolved by tax category:
|
||||
* - "goods" → location.taxRate (default)
|
||||
* - "service" → location.serviceTaxRate, falls back to taxRate
|
||||
* - "exempt" → 0
|
||||
*
|
||||
* Returns 0 with no warning for exempt items.
|
||||
* Returns 0 with warning if no rate is configured on the location.
|
||||
*/
|
||||
async getRateForLocation(
|
||||
db: PostgresJsDatabase<any>,
|
||||
locationId: string,
|
||||
taxCategory: TaxCategory = 'goods',
|
||||
): Promise<number> {
|
||||
if (taxCategory === 'exempt') return 0
|
||||
|
||||
const [location] = await db
|
||||
.select({ taxRate: locations.taxRate, serviceTaxRate: locations.serviceTaxRate })
|
||||
.from(locations)
|
||||
.where(eq(locations.id, locationId))
|
||||
.limit(1)
|
||||
|
||||
if (!location) return 0
|
||||
|
||||
if (taxCategory === 'service') {
|
||||
// Use service rate if set, otherwise fall back to goods rate
|
||||
const rate = location.serviceTaxRate ?? location.taxRate
|
||||
return rate ? parseFloat(rate) : 0
|
||||
}
|
||||
|
||||
// Default: goods rate
|
||||
return location.taxRate ? parseFloat(location.taxRate) : 0
|
||||
},
|
||||
|
||||
calculateTax(amount: number, rate: number): number {
|
||||
return Math.round(amount * rate * 100) / 100
|
||||
},
|
||||
|
||||
/**
|
||||
* Swedish rounding: round to nearest $0.05 for cash payments.
|
||||
* Only affects the final total — tax and line items stay exact.
|
||||
*/
|
||||
roundToNickel(amount: number): number {
|
||||
return Math.round(amount * 20) / 20
|
||||
},
|
||||
|
||||
/**
|
||||
* Map repair line item types to tax categories:
|
||||
* - "part" → goods (taxable)
|
||||
* - "labor" → service (may be taxed differently)
|
||||
* - "flat_rate" → goods (conservative — includes parts)
|
||||
* - "misc" → goods (default)
|
||||
*/
|
||||
repairItemTypeToTaxCategory(itemType: string): TaxCategory {
|
||||
switch (itemType) {
|
||||
case 'labor':
|
||||
return 'service'
|
||||
case 'part':
|
||||
case 'flat_rate':
|
||||
case 'misc':
|
||||
default:
|
||||
return 'goods'
|
||||
}
|
||||
},
|
||||
|
||||
// TODO: Integrate with a real tax rate API (TaxJar, Avalara, etc.)
|
||||
// Set TAX_API_KEY env var when ready.
|
||||
async lookupByZip(
|
||||
_zip: string,
|
||||
): Promise<{ zip: string; rate: number; state_rate: number; county_rate: number; city_rate: number }> {
|
||||
if (!process.env.TAX_API_KEY) {
|
||||
throw new AppError('Tax rate lookup is not configured. Set TAX_API_KEY to enable automatic lookup.', 501)
|
||||
}
|
||||
|
||||
// Placeholder — replace with real API call
|
||||
throw new AppError('Tax rate lookup not yet implemented', 501)
|
||||
},
|
||||
}
|
||||
453
packages/backend/src/services/transaction.service.ts
Normal file
453
packages/backend/src/services/transaction.service.ts
Normal file
@@ -0,0 +1,453 @@
|
||||
import { eq, and, count, sql, desc, type Column } from 'drizzle-orm'
|
||||
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
|
||||
import {
|
||||
transactions,
|
||||
transactionLineItems,
|
||||
discountAudits,
|
||||
discounts,
|
||||
drawerSessions,
|
||||
} from '../db/schema/pos.js'
|
||||
import { products, inventoryUnits } from '../db/schema/inventory.js'
|
||||
import { companies, locations } from '../db/schema/stores.js'
|
||||
import { NotFoundError, ValidationError, ConflictError } from '../lib/errors.js'
|
||||
import { TaxService } from './tax.service.js'
|
||||
import type {
|
||||
TransactionCreateInput,
|
||||
TransactionLineItemCreateInput,
|
||||
ApplyDiscountInput,
|
||||
CompleteTransactionInput,
|
||||
PaginationInput,
|
||||
} from '@lunarfront/shared/schemas'
|
||||
import {
|
||||
withPagination,
|
||||
withSort,
|
||||
buildSearchCondition,
|
||||
paginatedResponse,
|
||||
} from '../utils/pagination.js'
|
||||
|
||||
export const TransactionService = {
|
||||
async create(db: PostgresJsDatabase<any>, input: TransactionCreateInput, processedBy: string) {
|
||||
const transactionNumber = await generateTransactionNumber(db)
|
||||
|
||||
const [txn] = await db
|
||||
.insert(transactions)
|
||||
.values({
|
||||
transactionNumber,
|
||||
transactionType: input.transactionType,
|
||||
locationId: input.locationId,
|
||||
accountId: input.accountId,
|
||||
repairTicketId: input.repairTicketId,
|
||||
repairBatchId: input.repairBatchId,
|
||||
notes: input.notes,
|
||||
taxExempt: input.taxExempt,
|
||||
taxExemptReason: input.taxExemptReason,
|
||||
processedBy,
|
||||
})
|
||||
.returning()
|
||||
return txn
|
||||
},
|
||||
|
||||
async addLineItem(db: PostgresJsDatabase<any>, transactionId: string, input: TransactionLineItemCreateInput) {
|
||||
const txn = await this.getById(db, transactionId)
|
||||
if (!txn) throw new NotFoundError('Transaction')
|
||||
if (txn.status !== 'pending') throw new ConflictError('Can only add items to pending transactions')
|
||||
|
||||
// Resolve tax category from the product (defaults to "goods")
|
||||
let taxCategory: 'goods' | 'service' | 'exempt' = 'goods'
|
||||
if (input.productId) {
|
||||
const [product] = await db
|
||||
.select({ taxCategory: products.taxCategory })
|
||||
.from(products)
|
||||
.where(eq(products.id, input.productId))
|
||||
.limit(1)
|
||||
if (product?.taxCategory) {
|
||||
taxCategory = product.taxCategory as 'goods' | 'service' | 'exempt'
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot the tax rate at time of sale
|
||||
let taxRate = 0
|
||||
if (!txn.taxExempt && txn.locationId) {
|
||||
taxRate = await TaxService.getRateForLocation(db, txn.locationId, taxCategory)
|
||||
}
|
||||
|
||||
const lineSubtotal = input.unitPrice * input.qty
|
||||
const taxAmount = TaxService.calculateTax(lineSubtotal, taxRate)
|
||||
const lineTotal = lineSubtotal + taxAmount
|
||||
|
||||
const [lineItem] = await db
|
||||
.insert(transactionLineItems)
|
||||
.values({
|
||||
transactionId,
|
||||
productId: input.productId,
|
||||
inventoryUnitId: input.inventoryUnitId,
|
||||
description: input.description,
|
||||
qty: input.qty,
|
||||
unitPrice: input.unitPrice.toString(),
|
||||
taxRate: taxRate.toString(),
|
||||
taxAmount: taxAmount.toString(),
|
||||
lineTotal: lineTotal.toString(),
|
||||
})
|
||||
.returning()
|
||||
|
||||
await this.recalculateTotals(db, transactionId)
|
||||
return lineItem
|
||||
},
|
||||
|
||||
async removeLineItem(db: PostgresJsDatabase<any>, transactionId: string, lineItemId: string) {
|
||||
const txn = await this.getById(db, transactionId)
|
||||
if (!txn) throw new NotFoundError('Transaction')
|
||||
if (txn.status !== 'pending') throw new ConflictError('Can only remove items from pending transactions')
|
||||
|
||||
const [deleted] = await db
|
||||
.delete(transactionLineItems)
|
||||
.where(
|
||||
and(
|
||||
eq(transactionLineItems.id, lineItemId),
|
||||
eq(transactionLineItems.transactionId, transactionId)
|
||||
)
|
||||
)
|
||||
.returning()
|
||||
|
||||
if (!deleted) throw new NotFoundError('Line item')
|
||||
|
||||
await this.recalculateTotals(db, transactionId)
|
||||
return deleted
|
||||
},
|
||||
|
||||
async applyDiscount(
|
||||
db: PostgresJsDatabase<any>,
|
||||
transactionId: string,
|
||||
input: ApplyDiscountInput,
|
||||
appliedBy: string,
|
||||
) {
|
||||
const txn = await this.getById(db, transactionId)
|
||||
if (!txn) throw new NotFoundError('Transaction')
|
||||
if (txn.status !== 'pending') throw new ConflictError('Can only apply discounts to pending transactions')
|
||||
|
||||
// If applying a predefined discount, check approval threshold
|
||||
if (input.discountId) {
|
||||
const [discount] = await db
|
||||
.select()
|
||||
.from(discounts)
|
||||
.where(eq(discounts.id, input.discountId))
|
||||
.limit(1)
|
||||
|
||||
if (!discount) throw new NotFoundError('Discount')
|
||||
|
||||
if (
|
||||
discount.requiresApprovalAbove &&
|
||||
input.amount > parseFloat(discount.requiresApprovalAbove)
|
||||
) {
|
||||
throw new ValidationError('Discount amount exceeds approval threshold — manager approval required')
|
||||
}
|
||||
}
|
||||
|
||||
// Apply to specific line item or order level
|
||||
if (input.lineItemId) {
|
||||
const [lineItem] = await db
|
||||
.select()
|
||||
.from(transactionLineItems)
|
||||
.where(
|
||||
and(
|
||||
eq(transactionLineItems.id, input.lineItemId),
|
||||
eq(transactionLineItems.transactionId, transactionId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!lineItem) throw new NotFoundError('Line item')
|
||||
|
||||
const originalAmount = parseFloat(lineItem.unitPrice) * lineItem.qty
|
||||
|
||||
await db
|
||||
.update(transactionLineItems)
|
||||
.set({
|
||||
discountAmount: input.amount.toString(),
|
||||
discountReason: input.reason,
|
||||
})
|
||||
.where(eq(transactionLineItems.id, input.lineItemId))
|
||||
|
||||
// Recalculate line total (subtotal - discount + tax)
|
||||
const lineSubtotal = originalAmount - input.amount
|
||||
const taxAmount = TaxService.calculateTax(lineSubtotal, parseFloat(lineItem.taxRate))
|
||||
const lineTotal = lineSubtotal + taxAmount
|
||||
|
||||
await db
|
||||
.update(transactionLineItems)
|
||||
.set({
|
||||
taxAmount: taxAmount.toString(),
|
||||
lineTotal: lineTotal.toString(),
|
||||
})
|
||||
.where(eq(transactionLineItems.id, input.lineItemId))
|
||||
|
||||
// Create audit record
|
||||
await db.insert(discountAudits).values({
|
||||
transactionId,
|
||||
transactionLineItemId: input.lineItemId,
|
||||
discountId: input.discountId,
|
||||
appliedBy,
|
||||
originalAmount: originalAmount.toString(),
|
||||
discountedAmount: input.amount.toString(),
|
||||
reason: input.reason,
|
||||
})
|
||||
}
|
||||
|
||||
await this.recalculateTotals(db, transactionId)
|
||||
},
|
||||
|
||||
async recalculateTotals(db: PostgresJsDatabase<any>, transactionId: string) {
|
||||
const lineItems = await db
|
||||
.select()
|
||||
.from(transactionLineItems)
|
||||
.where(eq(transactionLineItems.transactionId, transactionId))
|
||||
|
||||
let subtotal = 0
|
||||
let discountTotal = 0
|
||||
let taxTotal = 0
|
||||
|
||||
for (const item of lineItems) {
|
||||
const itemSubtotal = parseFloat(item.unitPrice) * item.qty
|
||||
subtotal += itemSubtotal
|
||||
discountTotal += parseFloat(item.discountAmount)
|
||||
taxTotal += parseFloat(item.taxAmount)
|
||||
}
|
||||
|
||||
const total = subtotal - discountTotal + taxTotal
|
||||
|
||||
await db
|
||||
.update(transactions)
|
||||
.set({
|
||||
subtotal: subtotal.toString(),
|
||||
discountTotal: discountTotal.toString(),
|
||||
taxTotal: taxTotal.toString(),
|
||||
total: total.toString(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(transactions.id, transactionId))
|
||||
},
|
||||
|
||||
async complete(db: PostgresJsDatabase<any>, transactionId: string, input: CompleteTransactionInput) {
|
||||
const txn = await this.getById(db, transactionId)
|
||||
if (!txn) throw new NotFoundError('Transaction')
|
||||
if (txn.status !== 'pending') throw new ConflictError('Transaction is not pending')
|
||||
|
||||
// Require an open drawer session at the transaction's location
|
||||
if (txn.locationId) {
|
||||
const [openDrawer] = await db
|
||||
.select({ id: drawerSessions.id })
|
||||
.from(drawerSessions)
|
||||
.where(and(eq(drawerSessions.locationId, txn.locationId), eq(drawerSessions.status, 'open')))
|
||||
.limit(1)
|
||||
if (!openDrawer) {
|
||||
throw new ValidationError('Cannot complete transaction without an open drawer at this location')
|
||||
}
|
||||
}
|
||||
|
||||
// Validate cash payment (with optional nickel rounding)
|
||||
let changeGiven: string | undefined
|
||||
let roundingAdjustment = 0
|
||||
if (input.paymentMethod === 'cash') {
|
||||
let total = parseFloat(txn.total)
|
||||
|
||||
// Apply Swedish rounding if location has cash_rounding enabled
|
||||
if (txn.locationId) {
|
||||
const [loc] = await db
|
||||
.select({ cashRounding: locations.cashRounding })
|
||||
.from(locations)
|
||||
.where(eq(locations.id, txn.locationId))
|
||||
.limit(1)
|
||||
if (loc?.cashRounding) {
|
||||
const rounded = TaxService.roundToNickel(total)
|
||||
roundingAdjustment = Math.round((rounded - total) * 100) / 100
|
||||
total = rounded
|
||||
}
|
||||
}
|
||||
|
||||
if (!input.amountTendered || input.amountTendered < total) {
|
||||
throw new ValidationError('Amount tendered must be >= transaction total for cash payments')
|
||||
}
|
||||
changeGiven = (input.amountTendered - total).toString()
|
||||
}
|
||||
|
||||
// Update inventory for each line item
|
||||
const lineItems = await db
|
||||
.select()
|
||||
.from(transactionLineItems)
|
||||
.where(eq(transactionLineItems.transactionId, transactionId))
|
||||
|
||||
for (const item of lineItems) {
|
||||
if (item.inventoryUnitId) {
|
||||
// Serialized item — mark as sold
|
||||
await db
|
||||
.update(inventoryUnits)
|
||||
.set({ status: 'sold' })
|
||||
.where(eq(inventoryUnits.id, item.inventoryUnitId))
|
||||
} else if (item.productId) {
|
||||
// Non-serialized — decrement qty_on_hand
|
||||
await db
|
||||
.update(products)
|
||||
.set({
|
||||
qtyOnHand: sql`${products.qtyOnHand} - ${item.qty}`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(products.id, item.productId))
|
||||
}
|
||||
}
|
||||
|
||||
const [completed] = await db
|
||||
.update(transactions)
|
||||
.set({
|
||||
status: 'completed',
|
||||
paymentMethod: input.paymentMethod,
|
||||
amountTendered: input.amountTendered?.toString(),
|
||||
changeGiven,
|
||||
roundingAdjustment: roundingAdjustment.toString(),
|
||||
checkNumber: input.checkNumber,
|
||||
completedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(transactions.id, transactionId))
|
||||
.returning()
|
||||
return completed
|
||||
},
|
||||
|
||||
async void(db: PostgresJsDatabase<any>, transactionId: string, _voidedBy: string) {
|
||||
const txn = await this.getById(db, transactionId)
|
||||
if (!txn) throw new NotFoundError('Transaction')
|
||||
if (txn.status !== 'pending') throw new ConflictError('Can only void pending transactions')
|
||||
|
||||
// Restore inventory (in case items were reserved, though we only decrement on complete)
|
||||
const [voided] = await db
|
||||
.update(transactions)
|
||||
.set({
|
||||
status: 'voided',
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(transactions.id, transactionId))
|
||||
.returning()
|
||||
|
||||
return voided
|
||||
},
|
||||
|
||||
async getById(db: PostgresJsDatabase<any>, id: string) {
|
||||
const [txn] = await db
|
||||
.select()
|
||||
.from(transactions)
|
||||
.where(eq(transactions.id, id))
|
||||
.limit(1)
|
||||
|
||||
if (!txn) return null
|
||||
|
||||
const lineItems = await db
|
||||
.select()
|
||||
.from(transactionLineItems)
|
||||
.where(eq(transactionLineItems.transactionId, id))
|
||||
|
||||
return { ...txn, lineItems }
|
||||
},
|
||||
|
||||
async getReceipt(db: PostgresJsDatabase<any>, id: string) {
|
||||
const txn = await this.getById(db, id)
|
||||
if (!txn) throw new NotFoundError('Transaction')
|
||||
|
||||
// Get company info
|
||||
const [company] = await db.select().from(companies).limit(1)
|
||||
|
||||
// Get location info if available
|
||||
let location = null
|
||||
if (txn.locationId) {
|
||||
const [loc] = await db
|
||||
.select()
|
||||
.from(locations)
|
||||
.where(eq(locations.id, txn.locationId))
|
||||
.limit(1)
|
||||
location = loc ?? null
|
||||
}
|
||||
|
||||
return {
|
||||
transaction: txn,
|
||||
company: company
|
||||
? {
|
||||
name: company.name,
|
||||
phone: company.phone,
|
||||
email: company.email,
|
||||
address: company.address,
|
||||
}
|
||||
: null,
|
||||
location: location
|
||||
? {
|
||||
name: location.name,
|
||||
phone: location.phone,
|
||||
email: location.email,
|
||||
address: location.address,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
},
|
||||
|
||||
async list(db: PostgresJsDatabase<any>, params: PaginationInput, filters?: {
|
||||
status?: string
|
||||
transactionType?: string
|
||||
locationId?: string
|
||||
}) {
|
||||
const conditions: ReturnType<typeof eq>[] = []
|
||||
|
||||
if (params.q) {
|
||||
conditions.push(buildSearchCondition(params.q, [transactions.transactionNumber])!)
|
||||
}
|
||||
if (filters?.status) {
|
||||
conditions.push(eq(transactions.status, filters.status as any))
|
||||
}
|
||||
if (filters?.transactionType) {
|
||||
conditions.push(eq(transactions.transactionType, filters.transactionType as any))
|
||||
}
|
||||
if (filters?.locationId) {
|
||||
conditions.push(eq(transactions.locationId, filters.locationId))
|
||||
}
|
||||
|
||||
const where = conditions.length === 0 ? undefined : conditions.length === 1 ? conditions[0] : and(...conditions)
|
||||
|
||||
const sortableColumns: Record<string, Column> = {
|
||||
transaction_number: transactions.transactionNumber,
|
||||
total: transactions.total,
|
||||
status: transactions.status,
|
||||
created_at: transactions.createdAt,
|
||||
completed_at: transactions.completedAt,
|
||||
}
|
||||
|
||||
let query = db.select().from(transactions).where(where).$dynamic()
|
||||
query = withSort(query, params.sort, params.order, sortableColumns, transactions.createdAt)
|
||||
query = withPagination(query, params.page, params.limit)
|
||||
|
||||
const countQuery = where
|
||||
? db.select({ total: count() }).from(transactions).where(where)
|
||||
: db.select({ total: count() }).from(transactions)
|
||||
|
||||
const [data, [{ total }]] = await Promise.all([query, countQuery])
|
||||
|
||||
return paginatedResponse(data, total, params.page, params.limit)
|
||||
},
|
||||
}
|
||||
|
||||
async function generateTransactionNumber(db: PostgresJsDatabase<any>): Promise<string> {
|
||||
const today = new Date()
|
||||
const dateStr = today.toISOString().slice(0, 10).replace(/-/g, '')
|
||||
const prefix = `TXN-${dateStr}-`
|
||||
|
||||
// Find the highest sequence number for today
|
||||
const [latest] = await db
|
||||
.select({ transactionNumber: transactions.transactionNumber })
|
||||
.from(transactions)
|
||||
.where(sql`${transactions.transactionNumber} LIKE ${prefix + '%'}`)
|
||||
.orderBy(desc(transactions.transactionNumber))
|
||||
.limit(1)
|
||||
|
||||
let seq = 1
|
||||
if (latest?.transactionNumber) {
|
||||
const lastSeq = parseInt(latest.transactionNumber.replace(prefix, ''), 10)
|
||||
if (!isNaN(lastSeq)) seq = lastSeq + 1
|
||||
}
|
||||
|
||||
return `${prefix}${seq.toString().padStart(4, '0')}`
|
||||
}
|
||||
344
packages/shared/__tests__/schemas/pos.test.ts
Normal file
344
packages/shared/__tests__/schemas/pos.test.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
import { describe, it, expect } from 'bun:test'
|
||||
import {
|
||||
TransactionCreateSchema,
|
||||
TransactionLineItemCreateSchema,
|
||||
ApplyDiscountSchema,
|
||||
CompleteTransactionSchema,
|
||||
DiscountCreateSchema,
|
||||
DiscountUpdateSchema,
|
||||
DrawerOpenSchema,
|
||||
DrawerCloseSchema,
|
||||
TransactionType,
|
||||
TransactionStatus,
|
||||
PaymentMethod,
|
||||
DiscountType,
|
||||
DiscountAppliesTo,
|
||||
DrawerStatus,
|
||||
TaxCategory,
|
||||
} from '../../src/schemas/pos.schema.js'
|
||||
|
||||
// ─── Enums ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('POS enums', () => {
|
||||
it('TransactionType accepts valid values', () => {
|
||||
expect(TransactionType.parse('sale')).toBe('sale')
|
||||
expect(TransactionType.parse('repair_payment')).toBe('repair_payment')
|
||||
expect(TransactionType.parse('refund')).toBe('refund')
|
||||
})
|
||||
|
||||
it('TransactionType rejects invalid value', () => {
|
||||
expect(() => TransactionType.parse('layaway')).toThrow()
|
||||
})
|
||||
|
||||
it('TransactionStatus accepts valid values', () => {
|
||||
expect(TransactionStatus.parse('pending')).toBe('pending')
|
||||
expect(TransactionStatus.parse('voided')).toBe('voided')
|
||||
})
|
||||
|
||||
it('PaymentMethod accepts valid values', () => {
|
||||
expect(PaymentMethod.parse('cash')).toBe('cash')
|
||||
expect(PaymentMethod.parse('card_present')).toBe('card_present')
|
||||
expect(PaymentMethod.parse('account_charge')).toBe('account_charge')
|
||||
})
|
||||
|
||||
it('TaxCategory accepts valid values', () => {
|
||||
expect(TaxCategory.parse('goods')).toBe('goods')
|
||||
expect(TaxCategory.parse('service')).toBe('service')
|
||||
expect(TaxCategory.parse('exempt')).toBe('exempt')
|
||||
})
|
||||
|
||||
it('TaxCategory rejects invalid value', () => {
|
||||
expect(() => TaxCategory.parse('luxury')).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── TransactionCreateSchema ─────────────────────────────────────────────────
|
||||
|
||||
describe('TransactionCreateSchema', () => {
|
||||
it('parses minimal valid input', () => {
|
||||
const result = TransactionCreateSchema.parse({ transactionType: 'sale' })
|
||||
expect(result.transactionType).toBe('sale')
|
||||
expect(result.taxExempt).toBe(false)
|
||||
})
|
||||
|
||||
it('parses full input with optional fields', () => {
|
||||
const result = TransactionCreateSchema.parse({
|
||||
transactionType: 'repair_payment',
|
||||
locationId: '10000000-1000-4000-8000-000000000001',
|
||||
accountId: '10000000-1000-4000-8000-000000000002',
|
||||
taxExempt: true,
|
||||
taxExemptReason: 'Non-profit',
|
||||
notes: 'Customer walkup',
|
||||
})
|
||||
expect(result.transactionType).toBe('repair_payment')
|
||||
expect(result.taxExempt).toBe(true)
|
||||
expect(result.taxExemptReason).toBe('Non-profit')
|
||||
})
|
||||
|
||||
it('rejects missing transactionType', () => {
|
||||
expect(() => TransactionCreateSchema.parse({})).toThrow()
|
||||
})
|
||||
|
||||
it('coerces empty string locationId to undefined', () => {
|
||||
const result = TransactionCreateSchema.parse({ transactionType: 'sale', locationId: '' })
|
||||
expect(result.locationId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects invalid UUID for locationId', () => {
|
||||
expect(() =>
|
||||
TransactionCreateSchema.parse({ transactionType: 'sale', locationId: 'not-a-uuid' })
|
||||
).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── TransactionLineItemCreateSchema ─────────────────────────────────────────
|
||||
|
||||
describe('TransactionLineItemCreateSchema', () => {
|
||||
it('parses valid line item', () => {
|
||||
const result = TransactionLineItemCreateSchema.parse({
|
||||
description: 'Violin Strings',
|
||||
qty: 2,
|
||||
unitPrice: 12.99,
|
||||
})
|
||||
expect(result.description).toBe('Violin Strings')
|
||||
expect(result.qty).toBe(2)
|
||||
expect(result.unitPrice).toBe(12.99)
|
||||
})
|
||||
|
||||
it('defaults qty to 1', () => {
|
||||
const result = TransactionLineItemCreateSchema.parse({
|
||||
description: 'Capo',
|
||||
unitPrice: 19.99,
|
||||
})
|
||||
expect(result.qty).toBe(1)
|
||||
})
|
||||
|
||||
it('coerces string unitPrice to number', () => {
|
||||
const result = TransactionLineItemCreateSchema.parse({
|
||||
description: 'Pick',
|
||||
unitPrice: '5.99',
|
||||
})
|
||||
expect(result.unitPrice).toBe(5.99)
|
||||
})
|
||||
|
||||
it('rejects empty description', () => {
|
||||
expect(() =>
|
||||
TransactionLineItemCreateSchema.parse({ description: '', unitPrice: 10 })
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('rejects description over 255 chars', () => {
|
||||
expect(() =>
|
||||
TransactionLineItemCreateSchema.parse({ description: 'x'.repeat(256), unitPrice: 10 })
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('rejects negative unitPrice', () => {
|
||||
expect(() =>
|
||||
TransactionLineItemCreateSchema.parse({ description: 'Bad', unitPrice: -1 })
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('rejects qty of 0', () => {
|
||||
expect(() =>
|
||||
TransactionLineItemCreateSchema.parse({ description: 'Zero', qty: 0, unitPrice: 10 })
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('rejects non-integer qty', () => {
|
||||
expect(() =>
|
||||
TransactionLineItemCreateSchema.parse({ description: 'Frac', qty: 1.5, unitPrice: 10 })
|
||||
).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── ApplyDiscountSchema ─────────────────────────────────────────────────────
|
||||
|
||||
describe('ApplyDiscountSchema', () => {
|
||||
it('parses valid discount application', () => {
|
||||
const result = ApplyDiscountSchema.parse({
|
||||
amount: 10,
|
||||
reason: 'Employee discount',
|
||||
lineItemId: '10000000-1000-4000-8000-000000000001',
|
||||
})
|
||||
expect(result.amount).toBe(10)
|
||||
expect(result.reason).toBe('Employee discount')
|
||||
})
|
||||
|
||||
it('rejects missing reason', () => {
|
||||
expect(() => ApplyDiscountSchema.parse({ amount: 5 })).toThrow()
|
||||
})
|
||||
|
||||
it('rejects empty reason', () => {
|
||||
expect(() => ApplyDiscountSchema.parse({ amount: 5, reason: '' })).toThrow()
|
||||
})
|
||||
|
||||
it('rejects negative amount', () => {
|
||||
expect(() => ApplyDiscountSchema.parse({ amount: -1, reason: 'Nope' })).toThrow()
|
||||
})
|
||||
|
||||
it('allows zero amount', () => {
|
||||
const result = ApplyDiscountSchema.parse({ amount: 0, reason: 'Remove discount' })
|
||||
expect(result.amount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── CompleteTransactionSchema ───────────────────────────────────────────────
|
||||
|
||||
describe('CompleteTransactionSchema', () => {
|
||||
it('parses cash payment with amount tendered', () => {
|
||||
const result = CompleteTransactionSchema.parse({
|
||||
paymentMethod: 'cash',
|
||||
amountTendered: 50,
|
||||
})
|
||||
expect(result.paymentMethod).toBe('cash')
|
||||
expect(result.amountTendered).toBe(50)
|
||||
})
|
||||
|
||||
it('parses card payment without amount tendered', () => {
|
||||
const result = CompleteTransactionSchema.parse({ paymentMethod: 'card_present' })
|
||||
expect(result.paymentMethod).toBe('card_present')
|
||||
expect(result.amountTendered).toBeUndefined()
|
||||
})
|
||||
|
||||
it('parses check payment with check number', () => {
|
||||
const result = CompleteTransactionSchema.parse({
|
||||
paymentMethod: 'check',
|
||||
checkNumber: '1234',
|
||||
})
|
||||
expect(result.checkNumber).toBe('1234')
|
||||
})
|
||||
|
||||
it('rejects missing paymentMethod', () => {
|
||||
expect(() => CompleteTransactionSchema.parse({})).toThrow()
|
||||
})
|
||||
|
||||
it('rejects invalid payment method', () => {
|
||||
expect(() => CompleteTransactionSchema.parse({ paymentMethod: 'bitcoin' })).toThrow()
|
||||
})
|
||||
|
||||
it('rejects check number over 50 chars', () => {
|
||||
expect(() =>
|
||||
CompleteTransactionSchema.parse({ paymentMethod: 'check', checkNumber: 'x'.repeat(51) })
|
||||
).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── DiscountCreateSchema ────────────────────────────────────────────────────
|
||||
|
||||
describe('DiscountCreateSchema', () => {
|
||||
it('parses valid discount', () => {
|
||||
const result = DiscountCreateSchema.parse({
|
||||
name: '10% Off',
|
||||
discountType: 'percent',
|
||||
discountValue: 10,
|
||||
})
|
||||
expect(result.name).toBe('10% Off')
|
||||
expect(result.appliesTo).toBe('line_item') // default
|
||||
expect(result.isActive).toBe(true) // default
|
||||
})
|
||||
|
||||
it('rejects missing name', () => {
|
||||
expect(() => DiscountCreateSchema.parse({ discountType: 'fixed', discountValue: 5 })).toThrow()
|
||||
})
|
||||
|
||||
it('rejects empty name', () => {
|
||||
expect(() =>
|
||||
DiscountCreateSchema.parse({ name: '', discountType: 'fixed', discountValue: 5 })
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('accepts order-level discount', () => {
|
||||
const result = DiscountCreateSchema.parse({
|
||||
name: 'Order Disc',
|
||||
discountType: 'fixed',
|
||||
discountValue: 20,
|
||||
appliesTo: 'order',
|
||||
})
|
||||
expect(result.appliesTo).toBe('order')
|
||||
})
|
||||
|
||||
it('accepts optional approval threshold', () => {
|
||||
const result = DiscountCreateSchema.parse({
|
||||
name: 'Big Disc',
|
||||
discountType: 'percent',
|
||||
discountValue: 50,
|
||||
requiresApprovalAbove: 25,
|
||||
})
|
||||
expect(result.requiresApprovalAbove).toBe(25)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── DiscountUpdateSchema ────────────────────────────────────────────────────
|
||||
|
||||
describe('DiscountUpdateSchema', () => {
|
||||
it('accepts partial update', () => {
|
||||
const result = DiscountUpdateSchema.parse({ name: 'Updated Name' })
|
||||
expect(result.name).toBe('Updated Name')
|
||||
expect(result.discountType).toBeUndefined()
|
||||
})
|
||||
|
||||
it('accepts empty object (defaults still apply)', () => {
|
||||
const result = DiscountUpdateSchema.parse({})
|
||||
expect(result.appliesTo).toBe('line_item')
|
||||
expect(result.isActive).toBe(true)
|
||||
expect(result.name).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── DrawerOpenSchema ────────────────────────────────────────────────────────
|
||||
|
||||
describe('DrawerOpenSchema', () => {
|
||||
it('parses valid drawer open', () => {
|
||||
const result = DrawerOpenSchema.parse({
|
||||
locationId: '10000000-1000-4000-8000-000000000001',
|
||||
openingBalance: 200,
|
||||
})
|
||||
expect(result.openingBalance).toBe(200)
|
||||
})
|
||||
|
||||
it('allows opening without location (floating drawer)', () => {
|
||||
const result = DrawerOpenSchema.parse({ openingBalance: 100 })
|
||||
expect(result.locationId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects negative opening balance', () => {
|
||||
expect(() => DrawerOpenSchema.parse({ openingBalance: -50 })).toThrow()
|
||||
})
|
||||
|
||||
it('coerces string opening balance', () => {
|
||||
const result = DrawerOpenSchema.parse({ openingBalance: '150' })
|
||||
expect(result.openingBalance).toBe(150)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── DrawerCloseSchema ───────────────────────────────────────────────────────
|
||||
|
||||
describe('DrawerCloseSchema', () => {
|
||||
it('parses valid drawer close', () => {
|
||||
const result = DrawerCloseSchema.parse({ closingBalance: 250 })
|
||||
expect(result.closingBalance).toBe(250)
|
||||
})
|
||||
|
||||
it('accepts denominations', () => {
|
||||
const result = DrawerCloseSchema.parse({
|
||||
closingBalance: 100,
|
||||
denominations: { ones: 20, fives: 10, tens: 3 },
|
||||
})
|
||||
expect(result.denominations!.ones).toBe(20)
|
||||
})
|
||||
|
||||
it('accepts notes', () => {
|
||||
const result = DrawerCloseSchema.parse({ closingBalance: 100, notes: 'Short $5' })
|
||||
expect(result.notes).toBe('Short $5')
|
||||
})
|
||||
|
||||
it('coerces empty notes to undefined', () => {
|
||||
const result = DrawerCloseSchema.parse({ closingBalance: 100, notes: '' })
|
||||
expect(result.notes).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects negative closing balance', () => {
|
||||
expect(() => DrawerCloseSchema.parse({ closingBalance: -10 })).toThrow()
|
||||
})
|
||||
})
|
||||
9
packages/shared/src/schemas/config.schema.ts
Normal file
9
packages/shared/src/schemas/config.schema.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const LogLevel = z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace'])
|
||||
export type LogLevel = z.infer<typeof LogLevel>
|
||||
|
||||
export const AppConfigUpdateSchema = z.object({
|
||||
value: z.union([z.string(), z.number(), z.boolean(), z.null()]),
|
||||
})
|
||||
export type AppConfigUpdateInput = z.infer<typeof AppConfigUpdateSchema>
|
||||
@@ -163,3 +163,34 @@ export type {
|
||||
LessonPlanTemplateUpdateInput,
|
||||
TemplateInstantiateInput,
|
||||
} from './lessons.schema.js'
|
||||
|
||||
export {
|
||||
TransactionType,
|
||||
TransactionStatus,
|
||||
PaymentMethod,
|
||||
DiscountType,
|
||||
DiscountAppliesTo,
|
||||
DrawerStatus,
|
||||
TaxCategory,
|
||||
TransactionCreateSchema,
|
||||
TransactionLineItemCreateSchema,
|
||||
ApplyDiscountSchema,
|
||||
CompleteTransactionSchema,
|
||||
DiscountCreateSchema,
|
||||
DiscountUpdateSchema,
|
||||
DrawerOpenSchema,
|
||||
DrawerCloseSchema,
|
||||
} from './pos.schema.js'
|
||||
export type {
|
||||
TransactionCreateInput,
|
||||
TransactionLineItemCreateInput,
|
||||
ApplyDiscountInput,
|
||||
CompleteTransactionInput,
|
||||
DiscountCreateInput,
|
||||
DiscountUpdateInput,
|
||||
DrawerOpenInput,
|
||||
DrawerCloseInput,
|
||||
} from './pos.schema.js'
|
||||
|
||||
export { LogLevel, AppConfigUpdateSchema } from './config.schema.js'
|
||||
export type { AppConfigUpdateInput } from './config.schema.js'
|
||||
|
||||
114
packages/shared/src/schemas/pos.schema.ts
Normal file
114
packages/shared/src/schemas/pos.schema.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
/** Coerce empty strings to undefined — solves HTML form inputs sending '' for blank optional fields */
|
||||
function opt<T extends z.ZodTypeAny>(schema: T) {
|
||||
return z.preprocess((v) => (v === '' ? undefined : v), schema.optional())
|
||||
}
|
||||
|
||||
// --- Enum values (must match pgEnum definitions in pos.ts) ---
|
||||
|
||||
export const TransactionType = z.enum([
|
||||
'sale',
|
||||
'repair_payment',
|
||||
'rental_deposit',
|
||||
'account_payment',
|
||||
'refund',
|
||||
])
|
||||
export type TransactionType = z.infer<typeof TransactionType>
|
||||
|
||||
export const TransactionStatus = z.enum(['pending', 'completed', 'voided', 'refunded'])
|
||||
export type TransactionStatus = z.infer<typeof TransactionStatus>
|
||||
|
||||
export const PaymentMethod = z.enum([
|
||||
'cash',
|
||||
'card_present',
|
||||
'card_keyed',
|
||||
'check',
|
||||
'account_charge',
|
||||
])
|
||||
export type PaymentMethod = z.infer<typeof PaymentMethod>
|
||||
|
||||
export const DiscountType = z.enum(['percent', 'fixed'])
|
||||
export type DiscountType = z.infer<typeof DiscountType>
|
||||
|
||||
export const DiscountAppliesTo = z.enum(['order', 'line_item', 'category'])
|
||||
export type DiscountAppliesTo = z.infer<typeof DiscountAppliesTo>
|
||||
|
||||
export const DrawerStatus = z.enum(['open', 'closed'])
|
||||
export type DrawerStatus = z.infer<typeof DrawerStatus>
|
||||
|
||||
export const TaxCategory = z.enum(['goods', 'service', 'exempt'])
|
||||
export type TaxCategory = z.infer<typeof TaxCategory>
|
||||
|
||||
// --- Transaction schemas ---
|
||||
|
||||
export const TransactionCreateSchema = z.object({
|
||||
transactionType: TransactionType,
|
||||
locationId: opt(z.string().uuid()),
|
||||
accountId: opt(z.string().uuid()),
|
||||
repairTicketId: opt(z.string().uuid()),
|
||||
repairBatchId: opt(z.string().uuid()),
|
||||
notes: opt(z.string()),
|
||||
taxExempt: z.boolean().default(false),
|
||||
taxExemptReason: opt(z.string()),
|
||||
})
|
||||
export type TransactionCreateInput = z.infer<typeof TransactionCreateSchema>
|
||||
|
||||
export const TransactionLineItemCreateSchema = z.object({
|
||||
productId: opt(z.string().uuid()),
|
||||
inventoryUnitId: opt(z.string().uuid()),
|
||||
description: z.string().min(1).max(255),
|
||||
qty: z.number().int().min(1).default(1),
|
||||
unitPrice: z.coerce.number().min(0),
|
||||
})
|
||||
export type TransactionLineItemCreateInput = z.infer<typeof TransactionLineItemCreateSchema>
|
||||
|
||||
export const ApplyDiscountSchema = z.object({
|
||||
discountId: opt(z.string().uuid()),
|
||||
amount: z.coerce.number().min(0),
|
||||
reason: z.string().min(1),
|
||||
lineItemId: opt(z.string().uuid()),
|
||||
})
|
||||
export type ApplyDiscountInput = z.infer<typeof ApplyDiscountSchema>
|
||||
|
||||
export const CompleteTransactionSchema = z.object({
|
||||
paymentMethod: PaymentMethod,
|
||||
amountTendered: z.coerce.number().min(0).optional(),
|
||||
checkNumber: opt(z.string().max(50)),
|
||||
})
|
||||
export type CompleteTransactionInput = z.infer<typeof CompleteTransactionSchema>
|
||||
|
||||
// --- Discount schemas ---
|
||||
|
||||
export const DiscountCreateSchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
discountType: DiscountType,
|
||||
discountValue: z.coerce.number().min(0),
|
||||
appliesTo: DiscountAppliesTo.default('line_item'),
|
||||
locationId: opt(z.string().uuid()),
|
||||
requiresApprovalAbove: z.coerce.number().min(0).optional(),
|
||||
isActive: z.boolean().default(true),
|
||||
validFrom: opt(z.string()),
|
||||
validUntil: opt(z.string()),
|
||||
})
|
||||
export type DiscountCreateInput = z.infer<typeof DiscountCreateSchema>
|
||||
|
||||
export const DiscountUpdateSchema = DiscountCreateSchema.partial()
|
||||
export type DiscountUpdateInput = z.infer<typeof DiscountUpdateSchema>
|
||||
|
||||
// --- Drawer schemas ---
|
||||
|
||||
export const DrawerOpenSchema = z.object({
|
||||
locationId: opt(z.string().uuid()),
|
||||
openingBalance: z.coerce.number().min(0),
|
||||
})
|
||||
export type DrawerOpenInput = z.infer<typeof DrawerOpenSchema>
|
||||
|
||||
export const DrawerCloseSchema = z.object({
|
||||
closingBalance: z.coerce.number().min(0),
|
||||
denominations: z
|
||||
.record(z.string(), z.number())
|
||||
.optional(),
|
||||
notes: opt(z.string()),
|
||||
})
|
||||
export type DrawerCloseInput = z.infer<typeof DrawerCloseSchema>
|
||||
Reference in New Issue
Block a user