Compare commits

...

14 Commits

Author SHA1 Message Date
ba9f74afd5 fix build
All checks were successful
Build and Push Docker Image / build (push) Successful in 19s
2026-03-29 14:12:09 -05:00
7528b36c48 Merge branch 'main' of https://git.alwisp.com/jason/pos
Some checks failed
Build and Push Docker Image / build (push) Failing after 15s
2026-03-29 14:09:37 -05:00
84571c3516 fix build 2026-03-29 14:09:10 -05:00
f7f5ac7e3b Delete .github/workflows/ci.yml
Some checks failed
Build and Push Docker Image / build (push) Failing after 9s
2026-03-29 14:05:26 -05:00
7c71af2a9f Add .github/workflows/docker-build.yml
Some checks failed
CI / Client — typecheck & build (push) Has been cancelled
CI / Server — typecheck & build (push) Has been cancelled
CI / Docker build (smoke test) (push) Has been cancelled
Build and Push Docker Image / build (push) Failing after 15s
2026-03-29 13:57:52 -05:00
31e539102b Vendor-assign events and scope event catalog to vendor
Some checks failed
CI / Server — typecheck & build (push) Has been cancelled
CI / Client — typecheck & build (push) Has been cancelled
CI / Docker build (smoke test) (push) Has been cancelled
- Add vendorWhereClause() helper: admin + ?vendorId= filters to that
  vendor; admin with no filter sees all; other roles locked to own
- Fix events GET / to use vendorWhereClause so vendor filter works
- EventFormModal: admin sees a Vendor picker when creating a new event,
  pre-populated from the active VendorFilter; POST includes ?vendorId=
- EventConfigPanel: scope /taxes and /products fetches to event.vendorId
  so only the event's vendor's catalog items are selectable

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 17:48:44 -05:00
e1b1a82e07 Add multi-vendor capability with admin vendor management
- Add resolveVendorId() helper — admin can pass ?vendorId= to scope
  catalog operations to any vendor; other roles locked to JWT vendorId
- Thread ?vendorId= through products, categories, taxes, events routes
- Add DELETE /vendors/:id (admin only) with cascade-safe guard:
  blocks if vendor has users or transactions; otherwise cascade-deletes
  EventProduct → EventTax → Event → Product → Tax → Category → Vendor
- Rewrite VendorPage: admin gets full CRUD list, vendor gets own settings
- Add VendorFilter shared component (admin-only dropdown)
- Integrate VendorFilter into Catalog, Users, and Events pages so admin
  can switch vendor context for all create/read operations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 07:59:58 -05:00
65eb405cf1 Rename roles, add multi-vendor support, and Events system
Roles: owner→admin, manager→vendor, cashier→user across all routes,
seed, and client UI. Role badge colours updated in UsersPage.

Multi-vendor:
- GET /vendors and GET /users now return all records for admin role;
  vendor/user roles remain scoped to their vendorId
- POST /users: admin can specify vendorId to assign user to any vendor
- vendors/users now include vendor name in responses for admin context

Events (new):
- Prisma schema: Event, EventTax, EventProduct models; Transaction.eventId
- POST/GET/PUT/DELETE /api/v1/events — full CRUD, vendor-scoped
- PUT /events/:id/taxes + DELETE — upsert/remove per-event tax rate overrides
- POST/GET/DELETE /events/:id/products — product allowlist (empty=all)
- GET /events/:id/transactions — paginated list scoped to event
- GET /events/:id/reports/summary — revenue, avg tx, top products for event
- Transactions: eventId accepted in both single POST and batch POST
- Catalog sync: active/upcoming events included in /catalog/sync response

Client:
- Layout nav filtered by role (user role sees Catalog only)
- Dashboard cards filtered by role
- Events page: list, create/edit modal, detail modal with Configuration
  (tax overrides + product allowlist) and Reports tabs

DB: DATABASE_URL updated to file:./prisma/dev.db in .env.example

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 07:27:30 -05:00
c426b19b7c Add token auto-refresh, single-transaction endpoint, shift summary, and CI
- client/api/client.ts: shared refreshPromise prevents concurrent refresh races;
  dispatches auth:logout event when refresh fails
- client/context/AuthContext.tsx: listen for auth:logout to clear user state
- server/routes/transactions.ts: POST / real-time single transaction through
  payment abstraction (201 completed, 202 pending); GET /reports/shift shift
  window totals with averageTransaction, shiftOpen/shiftClose timestamps
- .github/workflows/ci.yml: server typecheck+build, client typecheck+build,
  Docker smoke-test on push/PR to main

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 07:06:59 -05:00
2aa041d45e Milestone 4: payment abstraction, receipts, refunds, logging, hardened Docker
- lib/payments.ts: provider-agnostic payment interface; cash (immediate) and
  card stub (swappable for Square/Stripe Terminal/Tyro)
- POST /transactions/:id/refund — manager+, server-authoritative, blocks double-refund
- GET /transactions/:id/receipt — structured receipt payload for print/email/SMS
- lib/logger.ts: minimal structured JSON logger respecting LOG_LEVEL env var
- middleware/requestLogger.ts: per-request method/path/status/ms logging
- errorHandler now uses structured logger instead of console.error
- Dockerfile: non-root user (appuser), HEALTHCHECK via /api/v1/health,
  npm cache cleared in runtime stage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 06:57:33 -05:00
d78ce35104 Remove dev.db from git tracking
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 06:54:29 -05:00
91e1a1ffbf Milestone 3: catalog sync, batch transactions, and reports
- GET /api/v1/catalog/sync?since= — delta sync for Android offline-first
- POST /api/v1/transactions/batch — idempotency-keyed batch upload (207 Multi-Status),
  validates product ownership, skips duplicates silently
- GET /api/v1/transactions + /reports/summary — paginated list and aggregated
  revenue/tax/top-product reporting with date range filters
- ReportsPage: stat cards, payment method breakdown, top-10 products, transaction table
- Reports added to sidebar nav and router

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 06:51:27 -05:00
c35f92f18b Add .gitignore and remove node_modules/migrations from tracking
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 23:19:25 -05:00
d53c772dd6 Add Milestones 1 & 2: full-stack POS foundation with admin UI
- Node/Express/TypeScript API under /api/v1 with JWT auth (login, refresh, logout, /me)
- Prisma schema: vendors, users, roles, products, categories, taxes, transactions
- SQLite for local dev; Postgres via docker-compose for production
- Full CRUD routes for vendors, users, categories, taxes, products with Zod validation and RBAC
- Paginated list endpoints scoped per vendor; refresh token rotation
- React/TypeScript admin SPA (Vite): login, protected routing, sidebar layout
- Pages: Dashboard, Catalog (tabbed Products/Categories/Taxes), Users, Vendor Settings
- Shared UI: Table, Modal, FormField, Btn, PageHeader components
- Multi-stage Dockerfile; docker-compose with Postgres healthcheck
- Seed script with demo vendor and owner account
- INSTRUCTIONS.md, ROADMAP.md, .claude/launch.json for dev server config

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 23:18:04 -05:00
63 changed files with 8991 additions and 0 deletions

17
.claude/launch.json Normal file
View File

@@ -0,0 +1,17 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "API Server",
"runtimeExecutable": "node",
"runtimeArgs": ["C:/Program Files/nodejs/node_modules/npm/bin/npm-cli.js", "--prefix", "server", "run", "dev"],
"port": 8080
},
{
"name": "Admin UI (Vite)",
"runtimeExecutable": "node",
"runtimeArgs": ["C:/Program Files/nodejs/node_modules/npm/bin/npm-cli.js", "--prefix", "client", "run", "dev"],
"port": 5173
}
]
}

View File

@@ -0,0 +1,20 @@
{
"permissions": {
"allow": [
"Bash(npm install:*)",
"Bash(cp .env.example .env)",
"Bash(npx prisma:*)",
"Bash(npm run:*)",
"mcp__Claude_Preview__preview_start",
"Bash(curl -s http://localhost:8080/api/v1/users -H \"Authorization: Bearer test\")",
"Bash(curl -s -X POST http://localhost:8080/api/v1/auth/login -H \"Content-Type: application/json\" -d \"{\"\"email\"\":\"\"admin@demo.com\"\",\"\"password\"\":\"\"password123\"\"}\")",
"Bash(curl -s \"http://localhost:8080/api/v1/users\" -H \"Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJjbW16c3JsdXowMDA0dTVqM2JldWwyMnM3IiwidmVuZG9ySWQiOiJkZW1vLXZlbmRvciIsInJvbGVJZCI6ImNtbXpzcmx0ZDAwMDB1NWozdnV6Y2QzZW0iLCJyb2xlTmFtZSI6Im93bmVyIiwiaWF0IjoxNzc0MDY2MjU4LCJleHAiOjE3NzQwNjcxNTh9.eBSLkZVXafSBE-o6A2I626EgBcxxXSKGVu7pv3yQdhU\")",
"Bash(DATABASE_URL=\"file:./prisma/dev.db\" npx prisma migrate dev --name add_events_rename_roles)",
"Bash(DATABASE_URL=\"file:./prisma/dev.db\" npx tsx prisma/seed.ts)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(npx tsc:*)",
"Bash(curl -s http://localhost:8080/api/v1/health)"
]
}
}

1
.env.example Normal file
View File

@@ -0,0 +1 @@
JWT_SECRET=change-me-in-production

25
.github/workflows/docker-build.yml vendored Normal file
View File

@@ -0,0 +1,25 @@
name: Build and Push Docker Image
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: registry.alwisp.com
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and Push
run: |
docker build -t registry.alwisp.com/${{ gitea.repository_owner }}/${{ gitea.repository }}:latest .
docker push registry.alwisp.com/${{ gitea.repository_owner }}/${{ gitea.repository }}:latest

22
.gitignore vendored Normal file
View File

@@ -0,0 +1,22 @@
node_modules/
dist/
*.db
*.db-journal
.env
.env.local
# Prisma
server/prisma/migrations/
!server/prisma/schema.prisma
# Build outputs
client/dist/
server/dist/
# Logs
*.log
npm-debug.log*
# OS
.DS_Store
Thumbs.db

55
Dockerfile Normal file
View File

@@ -0,0 +1,55 @@
# ─── Stage 1: Build ───────────────────────────────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /app
# Server
COPY server/package*.json ./server/
RUN cd server && npm ci
COPY server/ ./server/
RUN cd server && npm run db:generate && npm run build
# Client
COPY client/package*.json ./client/
RUN cd client && npm ci
COPY client/ ./client/
RUN cd client && npm run build
# ─── Stage 2: Runtime ─────────────────────────────────────────────────────
FROM node:20-alpine AS runtime
# Security: run as non-root
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
ENV NODE_ENV=production
# Server production deps only
COPY server/package*.json ./server/
RUN cd server && npm ci --omit=dev && npm cache clean --force
# Built artifacts
COPY --from=builder /app/server/dist ./server/dist
COPY --from=builder /app/server/prisma ./server/prisma
COPY --from=builder /app/server/node_modules/.prisma ./server/node_modules/.prisma
COPY --from=builder /app/server/node_modules/@prisma ./server/node_modules/@prisma
# React SPA
COPY --from=builder /app/client/dist ./client/dist
# Data directory for SQLite (bind-mount or volume in production)
RUN mkdir -p /data && chown appuser:appgroup /data
USER appuser
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- http://localhost:8080/api/v1/health || exit 1
WORKDIR /app/server
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/index.js"]

125
INSTRUCTIONS.md Normal file
View File

@@ -0,0 +1,125 @@
# INSTRUCTIONS.md — Local Development Guide
## Prerequisites
- Node.js 20+
- npm 10+
- Docker + Docker Compose (for containerized runs)
---
## Local Development (No Docker)
### 1. Server
```bash
cd server
cp .env.example .env # edit DATABASE_URL and JWT_SECRET
npm install
npx prisma migrate dev # creates SQLite DB and runs migrations
npm run db:seed # seeds demo vendor + admin user
npm run dev # starts API on :8080 with hot-reload
```
Default demo credentials: `admin@demo.com` / `password123`
### 2. Client
```bash
cd client
npm install
npm run dev # starts Vite dev server on :5173
```
The Vite dev server proxies `/api` to `http://localhost:8080`.
Open `http://localhost:5173` in your browser.
---
## Docker (Single Container + SQLite)
```bash
docker build -t vendor-pos:latest .
docker run --rm -p 8080:8080 \
-e NODE_ENV=production \
-e PORT=8080 \
-e DATABASE_URL=file:/data/pos.db \
-e JWT_SECRET=change-me \
-v pos_data:/data \
vendor-pos:latest
```
Admin UI: `http://localhost:8080`
API: `http://localhost:8080/api/v1`
---
## Docker Compose (App + PostgreSQL)
```bash
cp .env.example .env # set JWT_SECRET
docker compose up --build
```
App: `http://localhost:8080`
---
## API Overview
All endpoints live under `/api/v1`.
| Method | Path | Auth | Description |
|--------|-----------------------------------|---------------|------------------------------------|
| GET | /health | None | Health check |
| POST | /auth/login | None | Obtain tokens |
| POST | /auth/refresh | None | Rotate refresh token |
| POST | /auth/logout | Bearer | Invalidate tokens |
| GET | /auth/me | Bearer | Current user info |
| GET | /vendors | Bearer | List vendor |
| PUT | /vendors/:id | owner | Update vendor settings |
| GET | /users | manager+ | List users |
| POST | /users | manager+ | Create user |
| PUT | /users/:id | manager+ | Update user |
| DELETE | /users/:id | manager+ | Delete user |
| GET | /users/roles/list | Bearer | List available roles |
| GET/POST/PUT/DELETE | /categories, /taxes, /products | manager+ | Catalog CRUD |
| GET | /catalog/sync?since= | Bearer | Delta sync for Android |
| POST | /transactions/batch | Bearer | Batch upload (idempotent) |
| GET | /transactions | manager+ | List transactions |
| GET | /transactions/:id | manager+ | Get transaction detail |
| POST | /transactions/:id/refund | manager+ | Refund a completed transaction |
| GET | /transactions/:id/receipt | Bearer | Structured receipt payload |
| GET | /transactions/reports/summary | manager+ | Revenue/tax/top-product summary |
| GET | /transactions/reports/shift | manager+ | Shift window totals + avg tx value |
---
## Environment Variables
| Variable | Required | Default | Description |
|----------------|----------|---------------|----------------------------------------|
| PORT | No | 8080 | HTTP port |
| NODE_ENV | No | development | `development` or `production` |
| DATABASE_URL | Yes | — | Prisma connection string |
| JWT_SECRET | Yes | — | Secret for signing JWT tokens |
| LOG_LEVEL | No | info | Logging verbosity |
| CORS_ORIGIN | No | * | Allowed CORS origin |
For SQLite: `DATABASE_URL=file:./dev.db`
For Postgres: `DATABASE_URL=postgresql://user:pass@host:5432/db`
---
## Database Migrations
```bash
# Create a new migration (dev only)
cd server && npx prisma migrate dev --name <migration-name>
# Apply pending migrations (production)
cd server && npx prisma migrate deploy
# Open Prisma Studio (GUI)
cd server && npx prisma studio
```

46
ROADMAP.md Normal file
View File

@@ -0,0 +1,46 @@
# ROADMAP.md
## Milestone 1 — Foundation ✅
- [x] Node/TypeScript API skeleton with Express
- [x] Health check endpoint (`GET /api/v1/health`)
- [x] JWT auth: login, refresh, logout, /me
- [x] Prisma schema: vendors, users, roles, products, categories, taxes, transactions
- [x] SQLite for local dev; Postgres for production
- [x] React admin SPA (Vite + TypeScript)
- [x] Login page + protected routing
- [x] Dashboard shell with auth context
- [x] Multi-stage Dockerfile; docker-compose with Postgres
- [x] Seed script with demo data
---
## Milestone 2 — Core Data & Admin ✅
- [x] Full CRUD: vendors, users, categories, products, taxes
- [x] RBAC enforcement on all routes (owner / manager / cashier)
- [x] Vendor settings page in admin UI
- [x] User management UI (add, edit, delete, assign role)
- [x] Catalog management UI (products, categories, taxes — tabbed)
- [x] Input validation with Zod on all endpoints
- [x] Pagination on list endpoints
---
## Milestone 3 — Android & Offline Sync ✅ (server-side)
- [x] `GET /api/v1/catalog/sync?since=<ISO>` — delta sync for products, categories, taxes
- [x] `POST /api/v1/transactions/batch` — idempotency-keyed batch upload (207 Multi-Status)
- [x] `GET /api/v1/transactions` — paginated list with date/status/payment filters
- [x] `GET /api/v1/transactions/reports/summary` — revenue, tax, top products, payment breakdown
- [x] Reports page: stat cards, payment method breakdown, top products, transaction table
- [ ] Android Kotlin app: MVVM, Room, offline-first flows (separate deliverable)
- [ ] Background sync worker (Android)
- [ ] Conflict resolution: server-authoritative for payments (enforced via idempotency)
---
## Milestone 4 — Payments & Hardening ✅
- [x] Payment abstraction layer (`lib/payments.ts`) — cash + card stub; swap processCard() for real SDK
- [x] `POST /api/v1/transactions/:id/refund` — manager/owner only, server-authoritative
- [x] `GET /api/v1/transactions/:id/receipt` — structured receipt payload for print/email/SMS
- [x] Structured JSON request logging (`lib/logger.ts`, `middleware/requestLogger.ts`)
- [x] Dockerfile hardened: non-root user (`appuser`), `HEALTHCHECK`, npm cache cleared
- [x] Error handler uses structured logger instead of console.error

13
client/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>POS Admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1771
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
client/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "pos-client",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.23.1"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.4.5",
"vite": "^5.3.1"
}
}

56
client/src/App.tsx Normal file
View File

@@ -0,0 +1,56 @@
import { Routes, Route, Navigate } from "react-router-dom";
import { AuthProvider, useAuth } from "./context/AuthContext";
import Layout from "./components/Layout";
import LoginPage from "./pages/LoginPage";
import DashboardPage from "./pages/DashboardPage";
import UsersPage from "./pages/UsersPage";
import CatalogPage from "./pages/CatalogPage";
import VendorPage from "./pages/VendorPage";
import ReportsPage from "./pages/ReportsPage";
import EventsPage from "./pages/EventsPage";
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
if (loading) return <div style={{ padding: 32 }}>Loading</div>;
if (!user) return <Navigate to="/login" replace />;
return <>{children}</>;
}
function PublicRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
if (loading) return null;
if (user) return <Navigate to="/" replace />;
return <>{children}</>;
}
export default function App() {
return (
<AuthProvider>
<Routes>
<Route
path="/login"
element={
<PublicRoute>
<LoginPage />
</PublicRoute>
}
/>
<Route
element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}
>
<Route index element={<DashboardPage />} />
<Route path="catalog" element={<CatalogPage />} />
<Route path="events" element={<EventsPage />} />
<Route path="users" element={<UsersPage />} />
<Route path="vendor" element={<VendorPage />} />
<Route path="reports" element={<ReportsPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</AuthProvider>
);
}

86
client/src/api/client.ts Normal file
View File

@@ -0,0 +1,86 @@
const BASE = "/api/v1";
export function setTokens(accessToken: string, refreshToken: string) {
localStorage.setItem("accessToken", accessToken);
localStorage.setItem("refreshToken", refreshToken);
}
export function clearTokens() {
localStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken");
}
function getToken(): string | null {
return localStorage.getItem("accessToken");
}
// Single in-flight refresh promise so concurrent 401s don't fire multiple refreshes
let refreshPromise: Promise<string | null> | null = null;
async function tryRefresh(): Promise<string | null> {
const refreshToken = localStorage.getItem("refreshToken");
if (!refreshToken) return null;
try {
const res = await fetch(`${BASE}/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
if (!res.ok) {
clearTokens();
return null;
}
const data = await res.json() as { accessToken: string; refreshToken: string };
setTokens(data.accessToken, data.refreshToken);
return data.accessToken;
} catch {
clearTokens();
return null;
}
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const doFetch = async (token: string | null) => {
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(options.headers as Record<string, string>),
};
if (token) headers["Authorization"] = `Bearer ${token}`;
return fetch(`${BASE}${path}`, { ...options, headers });
};
let res = await doFetch(getToken());
// On 401, attempt one token refresh then retry
if (res.status === 401) {
if (!refreshPromise) {
refreshPromise = tryRefresh().finally(() => { refreshPromise = null; });
}
const newToken = await refreshPromise;
if (!newToken) {
// Refresh failed — signal the app to go to login
window.dispatchEvent(new CustomEvent("auth:logout"));
throw new Error("Session expired. Please sign in again.");
}
res = await doFetch(newToken);
}
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const message = (body as { error?: { message?: string } })?.error?.message ?? `HTTP ${res.status}`;
throw new Error(message);
}
return res.json() as Promise<T>;
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body: unknown) => request<T>(path, { method: "POST", body: JSON.stringify(body) }),
put: <T>(path: string, body: unknown) => request<T>(path, { method: "PUT", body: JSON.stringify(body) }),
patch: <T>(path: string, body: unknown) => request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
};

View File

@@ -0,0 +1,79 @@
import React from "react";
interface FormFieldProps {
label: string;
error?: string;
children: React.ReactNode;
required?: boolean;
}
export function FormField({ label, error, children, required }: FormFieldProps) {
return (
<div style={s.field}>
<label style={s.label}>
{label}{required && <span style={s.req}> *</span>}
</label>
{children}
{error && <span style={s.error}>{error}</span>}
</div>
);
}
export const inputStyle: React.CSSProperties = {
width: "100%",
border: "1px solid var(--color-border)",
borderRadius: "var(--radius)",
padding: "8px 10px",
fontSize: 14,
outline: "none",
boxSizing: "border-box",
};
export function Btn({
children,
variant = "primary",
type = "button",
size,
disabled,
onClick,
style,
}: {
children: React.ReactNode;
variant?: "primary" | "danger" | "ghost";
type?: "button" | "submit" | "reset";
size?: "sm";
disabled?: boolean;
onClick?: () => void;
style?: React.CSSProperties;
}) {
const base: React.CSSProperties = {
border: "none",
borderRadius: "var(--radius)",
padding: size === "sm" ? "4px 10px" : "8px 16px",
fontWeight: 600,
fontSize: size === "sm" ? 12 : 13,
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.6 : 1,
};
const variants = {
primary: { background: "var(--color-primary)", color: "#fff" },
danger: { background: "var(--color-danger)", color: "#fff" },
ghost: {
background: "none",
color: "var(--color-text-muted)",
border: "1px solid var(--color-border)",
},
};
return (
<button type={type} style={{ ...base, ...variants[variant], ...style }} disabled={disabled} onClick={onClick}>
{children}
</button>
);
}
const s: Record<string, React.CSSProperties> = {
field: { display: "flex", flexDirection: "column", gap: 4, marginBottom: 16 },
label: { fontWeight: 500, fontSize: 13 },
req: { color: "var(--color-danger)" },
error: { color: "var(--color-danger)", fontSize: 12 },
};

View File

@@ -0,0 +1,120 @@
import React from "react";
import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
const NAV = [
{ to: "/", label: "Dashboard", exact: true, roles: ["admin", "vendor", "user"] },
{ to: "/catalog", label: "Catalog", roles: ["admin", "vendor", "user"] },
{ to: "/events", label: "Events", roles: ["admin", "vendor"] },
{ to: "/users", label: "Users", roles: ["admin", "vendor"] },
{ to: "/vendor", label: "Vendor", roles: ["admin", "vendor"] },
{ to: "/reports", label: "Reports", roles: ["admin", "vendor"] },
];
export default function Layout() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const handleLogout = async () => {
await logout();
navigate("/login", { replace: true });
};
const visibleNav = NAV.filter((item) => item.roles.includes(user?.role ?? ""));
return (
<div style={s.shell}>
<aside style={s.sidebar}>
<div style={s.brand}>POS Admin</div>
<nav style={s.nav}>
{visibleNav.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.exact}
style={({ isActive }) => ({
...s.navLink,
...(isActive ? s.navLinkActive : {}),
})}
>
{item.label}
</NavLink>
))}
</nav>
<div style={s.sidebarFooter}>
<div style={s.userBlock}>
<div style={s.userName}>{user?.name}</div>
<div style={s.userRole}>{user?.role}</div>
</div>
<button type="button" style={s.logoutBtn} onClick={handleLogout}>
Sign out
</button>
</div>
</aside>
<main style={s.main}>
<Outlet />
</main>
</div>
);
}
const s: Record<string, React.CSSProperties> = {
shell: { display: "flex", minHeight: "100vh" },
sidebar: {
width: 220,
flexShrink: 0,
background: "#1e293b",
color: "#cbd5e1",
display: "flex",
flexDirection: "column",
},
brand: {
padding: "20px 20px 12px",
fontSize: 16,
fontWeight: 700,
color: "#f8fafc",
borderBottom: "1px solid #334155",
},
nav: {
flex: 1,
display: "flex",
flexDirection: "column",
gap: 2,
padding: "12px 8px",
},
navLink: {
display: "block",
padding: "9px 12px",
borderRadius: 6,
color: "#94a3b8",
fontSize: 14,
fontWeight: 500,
textDecoration: "none",
transition: "background 0.1s",
},
navLinkActive: {
background: "#334155",
color: "#f8fafc",
},
sidebarFooter: {
padding: "12px 16px",
borderTop: "1px solid #334155",
display: "flex",
flexDirection: "column",
gap: 8,
},
userBlock: {},
userName: { fontSize: 13, fontWeight: 600, color: "#e2e8f0" },
userRole: { fontSize: 12, color: "#64748b", textTransform: "capitalize" },
logoutBtn: {
background: "none",
border: "1px solid #334155",
borderRadius: 6,
padding: "5px 10px",
color: "#64748b",
fontSize: 12,
cursor: "pointer",
alignSelf: "flex-start",
},
main: { flex: 1, overflow: "auto", background: "var(--color-bg)" },
};

View File

@@ -0,0 +1,61 @@
import React, { useEffect } from "react";
interface ModalProps {
title: string;
onClose: () => void;
children: React.ReactNode;
width?: number;
}
export function Modal({ title, onClose, children, width = 480 }: ModalProps) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
return (
<div style={s.backdrop} onClick={onClose}>
<div style={{ ...s.panel, width }} onClick={(e) => e.stopPropagation()}>
<div style={s.header}>
<span style={s.title}>{title}</span>
<button style={s.close} onClick={onClose}></button>
</div>
<div style={s.body}>{children}</div>
</div>
</div>
);
}
const s: Record<string, React.CSSProperties> = {
backdrop: {
position: "fixed", inset: 0,
background: "rgba(0,0,0,0.4)",
display: "flex", alignItems: "center", justifyContent: "center",
zIndex: 100,
},
panel: {
background: "var(--color-surface)",
borderRadius: 8,
boxShadow: "0 8px 32px rgba(0,0,0,0.2)",
display: "flex",
flexDirection: "column",
maxHeight: "90vh",
overflow: "hidden",
},
header: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "16px 20px",
borderBottom: "1px solid var(--color-border)",
},
title: { fontWeight: 600, fontSize: 16 },
close: {
background: "none", border: "none",
fontSize: 18, cursor: "pointer",
color: "var(--color-text-muted)",
lineHeight: 1,
},
body: { padding: "20px", overflowY: "auto" },
};

View File

@@ -0,0 +1,30 @@
import React from "react";
interface PageHeaderProps {
title: string;
subtitle?: string;
action?: React.ReactNode;
}
export function PageHeader({ title, subtitle, action }: PageHeaderProps) {
return (
<div style={s.header}>
<div>
<h1 style={s.title}>{title}</h1>
{subtitle && <p style={s.subtitle}>{subtitle}</p>}
</div>
{action && <div>{action}</div>}
</div>
);
}
const s: Record<string, React.CSSProperties> = {
header: {
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
marginBottom: 24,
},
title: { fontSize: 20, fontWeight: 700, marginBottom: 2 },
subtitle: { color: "var(--color-text-muted)", fontSize: 14 },
};

View File

@@ -0,0 +1,83 @@
import React from "react";
interface Column<T> {
key: string;
header: string;
render?: (row: T) => React.ReactNode;
}
interface TableProps<T> {
columns: Column<T>[];
data: T[];
keyField: keyof T;
loading?: boolean;
emptyText?: string;
}
export function Table<T>({ columns, data, keyField, loading, emptyText = "No records found." }: TableProps<T>) {
return (
<div style={s.wrapper}>
<table style={s.table}>
<thead>
<tr>
{columns.map((col) => (
<th key={col.key} style={s.th}>
{col.header}
</th>
))}
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={columns.length} style={s.empty}>
Loading
</td>
</tr>
) : data.length === 0 ? (
<tr>
<td colSpan={columns.length} style={s.empty}>
{emptyText}
</td>
</tr>
) : (
data.map((row) => (
<tr key={String(row[keyField])} style={s.tr}>
{columns.map((col) => (
<td key={col.key} style={s.td}>
{col.render ? col.render(row) : String((row as Record<string, unknown>)[col.key] ?? "")}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
);
}
const s: Record<string, React.CSSProperties> = {
wrapper: {
overflowX: "auto",
border: "1px solid var(--color-border)",
borderRadius: "var(--radius)",
background: "var(--color-surface)",
},
table: { width: "100%", borderCollapse: "collapse", fontSize: 14 },
th: {
textAlign: "left",
padding: "10px 16px",
background: "#f8fafc",
borderBottom: "1px solid var(--color-border)",
fontWeight: 600,
color: "var(--color-text-muted)",
fontSize: 12,
textTransform: "uppercase",
letterSpacing: "0.04em",
whiteSpace: "nowrap",
},
tr: { borderBottom: "1px solid var(--color-border)" },
td: { padding: "10px 16px", verticalAlign: "middle" },
empty: { padding: "32px 16px", textAlign: "center", color: "var(--color-text-muted)" },
};

View File

@@ -0,0 +1,53 @@
import React, { useEffect, useState } from "react";
import { api } from "../api/client";
import { useAuth } from "../context/AuthContext";
interface Vendor { id: string; name: string; }
interface Props {
vendorId: string;
onChange: (vendorId: string) => void;
}
/**
* Dropdown that lets admin users switch vendor context.
* Renders nothing for non-admin roles.
*/
export function VendorFilter({ vendorId, onChange }: Props) {
const { user } = useAuth();
const [vendors, setVendors] = useState<Vendor[]>([]);
useEffect(() => {
if (user?.role !== "admin") return;
api.get<{ data: Vendor[] }>("/vendors?limit=200")
.then((r) => setVendors(r.data))
.catch(console.error);
}, [user?.role]);
if (user?.role !== "admin" || vendors.length === 0) return null;
return (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 13, fontWeight: 500, color: "var(--color-text-muted)" }}>Vendor:</span>
<select
style={sel}
value={vendorId}
onChange={(e) => onChange(e.target.value)}
>
{vendors.map((v) => (
<option key={v.id} value={v.id}>{v.name}</option>
))}
</select>
</div>
);
}
const sel: React.CSSProperties = {
border: "1px solid var(--color-border)",
borderRadius: "var(--radius)",
padding: "5px 8px",
fontSize: 13,
background: "var(--color-surface)",
cursor: "pointer",
minWidth: 140,
};

View File

@@ -0,0 +1,82 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from "react";
import { api, setTokens, clearTokens } from "../api/client";
interface User {
id: string;
email: string;
name: string;
role: string;
vendorId: string;
vendorName: string;
}
interface AuthState {
user: User | null;
loading: boolean;
}
interface AuthContextValue extends AuthState {
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<AuthState>({ user: null, loading: true });
const fetchMe = useCallback(async () => {
try {
const user = await api.get<User>("/auth/me");
setState({ user, loading: false });
} catch {
setState({ user: null, loading: false });
}
}, []);
useEffect(() => {
const token = localStorage.getItem("accessToken");
if (token) {
fetchMe();
} else {
setState({ user: null, loading: false });
}
// When the API layer exhausts its refresh retry, force logout
const onForcedLogout = () => setState({ user: null, loading: false });
window.addEventListener("auth:logout", onForcedLogout);
return () => window.removeEventListener("auth:logout", onForcedLogout);
}, [fetchMe]);
const login = async (email: string, password: string) => {
const res = await api.post<{ accessToken: string; refreshToken: string; user: User }>(
"/auth/login",
{ email, password }
);
setTokens(res.accessToken, res.refreshToken);
setState({ user: res.user, loading: false });
};
const logout = async () => {
const refreshToken = localStorage.getItem("refreshToken");
try {
await api.post("/auth/logout", { refreshToken });
} catch {
// best-effort
}
clearTokens();
setState({ user: null, loading: false });
};
return (
<AuthContext.Provider value={{ ...state, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}

44
client/src/index.css Normal file
View File

@@ -0,0 +1,44 @@
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--color-primary: #2563eb;
--color-primary-hover: #1d4ed8;
--color-danger: #dc2626;
--color-success: #16a34a;
--color-warning: #d97706;
--color-bg: #f8fafc;
--color-surface: #ffffff;
--color-border: #e2e8f0;
--color-text: #0f172a;
--color-text-muted: #64748b;
--radius: 6px;
--shadow: 0 1px 3px rgba(0,0,0,0.1);
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 14px;
color: var(--color-text);
background: var(--color-bg);
line-height: 1.5;
}
a {
color: var(--color-primary);
text-decoration: none;
}
button {
cursor: pointer;
font-family: inherit;
font-size: inherit;
}
input, select, textarea {
font-family: inherit;
font-size: inherit;
}

13
client/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);

View File

@@ -0,0 +1,316 @@
import React, { useEffect, useState, useCallback } from "react";
import { api } from "../api/client";
import { useAuth } from "../context/AuthContext";
import { Table } from "../components/Table";
import { Modal } from "../components/Modal";
import { PageHeader } from "../components/PageHeader";
import { FormField, inputStyle, Btn } from "../components/FormField";
import { VendorFilter } from "../components/VendorFilter";
interface Category { id: string; name: string; }
interface Tax { id: string; name: string; rate: number; }
interface Product {
id: string; name: string; sku: string | null; price: number;
category: Category | null; tax: Tax | null; description: string | null;
}
interface ApiList<T> { data: T[]; }
type Tab = "products" | "categories" | "taxes";
export default function CatalogPage() {
const { user } = useAuth();
const [tab, setTab] = useState<Tab>("products");
const [vendorId, setVendorId] = useState(user?.vendorId ?? "");
return (
<div style={{ padding: "32px 28px" }}>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", flexWrap: "wrap", gap: 12, marginBottom: 4 }}>
<PageHeader title="Catalog" subtitle="Products, categories, and tax rates" />
<VendorFilter vendorId={vendorId} onChange={setVendorId} />
</div>
<div style={tabs}>
{(["products", "categories", "taxes"] as Tab[]).map((t) => (
<button key={t} style={{ ...tabBtn, ...(tab === t ? tabBtnActive : {}) }} onClick={() => setTab(t)}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</button>
))}
</div>
{tab === "products" && <ProductsTab vendorId={vendorId} />}
{tab === "categories" && <CategoriesTab vendorId={vendorId} />}
{tab === "taxes" && <TaxesTab vendorId={vendorId} />}
</div>
);
}
function qs(vendorId: string) {
return vendorId ? `?vendorId=${encodeURIComponent(vendorId)}` : "";
}
// ─── Products ──────────────────────────────────────────────────────────────
function ProductsTab({ vendorId }: { vendorId: string }) {
const [products, setProducts] = useState<Product[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [taxes, setTaxes] = useState<Tax[]>([]);
const [loading, setLoading] = useState(true);
const [modal, setModal] = useState<"create" | "edit" | null>(null);
const [selected, setSelected] = useState<Product | null>(null);
const [form, setForm] = useState(emptyProduct());
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const load = useCallback(async () => {
setLoading(true);
const q = qs(vendorId);
const [p, c, t] = await Promise.all([
api.get<ApiList<Product>>(`/products${q}`),
api.get<ApiList<Category>>(`/categories${q}`),
api.get<ApiList<Tax>>(`/taxes${q}`),
]);
setProducts(p.data);
setCategories(c.data);
setTaxes(t.data);
setLoading(false);
}, [vendorId]);
useEffect(() => { load(); }, [load]);
const openCreate = () => { setSelected(null); setForm(emptyProduct()); setError(""); setModal("create"); };
const openEdit = (p: Product) => {
setSelected(p);
setForm({ name: p.name, sku: p.sku ?? "", price: String(p.price), categoryId: p.category?.id ?? "", taxId: p.tax?.id ?? "", description: p.description ?? "" });
setError(""); setModal("edit");
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
setError("");
try {
const payload = { ...form, price: parseFloat(form.price), categoryId: form.categoryId || null, taxId: form.taxId || null };
const q = qs(vendorId);
if (modal === "create") await api.post(`/products${q}`, payload);
else if (selected) await api.put(`/products/${selected.id}${q}`, payload);
setModal(null);
load();
} catch (err) { setError(err instanceof Error ? err.message : "Save failed"); }
finally { setSaving(false); }
};
const handleDelete = async (p: Product) => {
if (!confirm(`Delete "${p.name}"?`)) return;
await api.delete(`/products/${p.id}`).catch((e) => alert(e.message));
load();
};
const columns = [
{ key: "name", header: "Name" },
{ key: "sku", header: "SKU", render: (p: Product) => p.sku ?? "—" },
{ key: "price", header: "Price", render: (p: Product) => `$${p.price.toFixed(2)}` },
{ key: "category", header: "Category", render: (p: Product) => p.category?.name ?? "—" },
{ key: "tax", header: "Tax", render: (p: Product) => p.tax ? `${p.tax.name} (${p.tax.rate}%)` : "—" },
{ key: "actions", header: "", render: (p: Product) => (
<div style={{ display: "flex", gap: 8 }}>
<Btn variant="ghost" onClick={() => openEdit(p)} style={{ padding: "4px 10px" }}>Edit</Btn>
<Btn variant="danger" onClick={() => handleDelete(p)} style={{ padding: "4px 10px" }}>Delete</Btn>
</div>
)},
];
return (
<>
<div style={{ marginBottom: 16 }}><Btn onClick={openCreate}>+ Add Product</Btn></div>
<Table columns={columns} data={products} keyField="id" loading={loading} />
{modal && (
<Modal title={modal === "create" ? "Add Product" : "Edit Product"} onClose={() => setModal(null)}>
<form onSubmit={handleSubmit}>
{error && <div style={errStyle}>{error}</div>}
<FormField label="Name" required>
<input style={inputStyle} value={form.name} onChange={f("name", setForm)} required />
</FormField>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
<FormField label="SKU"><input style={inputStyle} value={form.sku} onChange={f("sku", setForm)} /></FormField>
<FormField label="Price" required><input style={inputStyle} type="number" min="0" step="0.01" value={form.price} onChange={f("price", setForm)} required /></FormField>
</div>
<FormField label="Description">
<textarea style={{ ...inputStyle, resize: "vertical", minHeight: 60 }} value={form.description} onChange={f("description", setForm)} />
</FormField>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
<FormField label="Category">
<select style={inputStyle} value={form.categoryId} onChange={f("categoryId", setForm)}>
<option value="">None</option>
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</FormField>
<FormField label="Tax">
<select style={inputStyle} value={form.taxId} onChange={f("taxId", setForm)}>
<option value="">None</option>
{taxes.map((t) => <option key={t.id} value={t.id}>{t.name} ({t.rate}%)</option>)}
</select>
</FormField>
</div>
<div style={{ display: "flex", gap: 8 }}>
<Btn type="submit" disabled={saving}>{saving ? "Saving…" : "Save"}</Btn>
<Btn variant="ghost" onClick={() => setModal(null)}>Cancel</Btn>
</div>
</form>
</Modal>
)}
</>
);
}
// ─── Categories ────────────────────────────────────────────────────────────
function CategoriesTab({ vendorId }: { vendorId: string }) {
const [items, setItems] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
const [modal, setModal] = useState<"create" | "edit" | null>(null);
const [selected, setSelected] = useState<Category | null>(null);
const [name, setName] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const load = useCallback(async () => {
setLoading(true);
const res = await api.get<ApiList<Category>>(`/categories${qs(vendorId)}`);
setItems(res.data);
setLoading(false);
}, [vendorId]);
useEffect(() => { load(); }, [load]);
const openCreate = () => { setSelected(null); setName(""); setError(""); setModal("create"); };
const openEdit = (c: Category) => { setSelected(c); setName(c.name); setError(""); setModal("edit"); };
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true); setError("");
try {
const q = qs(vendorId);
if (modal === "create") await api.post(`/categories${q}`, { name });
else if (selected) await api.put(`/categories/${selected.id}${q}`, { name });
setModal(null); load();
} catch (err) { setError(err instanceof Error ? err.message : "Save failed"); }
finally { setSaving(false); }
};
const columns = [
{ key: "name", header: "Name" },
{ key: "actions", header: "", render: (c: Category) => (
<div style={{ display: "flex", gap: 8 }}>
<Btn variant="ghost" onClick={() => openEdit(c)} style={{ padding: "4px 10px" }}>Edit</Btn>
<Btn variant="danger" onClick={async () => { if (!confirm(`Delete "${c.name}"?`)) return; await api.delete(`/categories/${c.id}`).catch((e) => alert(e.message)); load(); }} style={{ padding: "4px 10px" }}>Delete</Btn>
</div>
)},
];
return (
<>
<div style={{ marginBottom: 16 }}><Btn onClick={openCreate}>+ Add Category</Btn></div>
<Table columns={columns} data={items} keyField="id" loading={loading} />
{modal && (
<Modal title={modal === "create" ? "Add Category" : "Edit Category"} onClose={() => setModal(null)}>
<form onSubmit={handleSubmit}>
{error && <div style={errStyle}>{error}</div>}
<FormField label="Name" required>
<input style={inputStyle} value={name} onChange={(e) => setName(e.target.value)} required autoFocus />
</FormField>
<div style={{ display: "flex", gap: 8 }}>
<Btn type="submit" disabled={saving}>{saving ? "Saving…" : "Save"}</Btn>
<Btn variant="ghost" onClick={() => setModal(null)}>Cancel</Btn>
</div>
</form>
</Modal>
)}
</>
);
}
// ─── Taxes ─────────────────────────────────────────────────────────────────
function TaxesTab({ vendorId }: { vendorId: string }) {
const [items, setItems] = useState<Tax[]>([]);
const [loading, setLoading] = useState(true);
const [modal, setModal] = useState<"create" | "edit" | null>(null);
const [selected, setSelected] = useState<Tax | null>(null);
const [form, setForm] = useState({ name: "", rate: "" });
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const load = useCallback(async () => {
setLoading(true);
const res = await api.get<ApiList<Tax>>(`/taxes${qs(vendorId)}`);
setItems(res.data);
setLoading(false);
}, [vendorId]);
useEffect(() => { load(); }, [load]);
const openCreate = () => { setSelected(null); setForm({ name: "", rate: "" }); setError(""); setModal("create"); };
const openEdit = (t: Tax) => { setSelected(t); setForm({ name: t.name, rate: String(t.rate) }); setError(""); setModal("edit"); };
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true); setError("");
try {
const payload = { name: form.name, rate: parseFloat(form.rate) };
const q = qs(vendorId);
if (modal === "create") await api.post(`/taxes${q}`, payload);
else if (selected) await api.put(`/taxes/${selected.id}${q}`, payload);
setModal(null); load();
} catch (err) { setError(err instanceof Error ? err.message : "Save failed"); }
finally { setSaving(false); }
};
const columns = [
{ key: "name", header: "Name" },
{ key: "rate", header: "Rate", render: (t: Tax) => `${t.rate}%` },
{ key: "actions", header: "", render: (t: Tax) => (
<div style={{ display: "flex", gap: 8 }}>
<Btn variant="ghost" onClick={() => openEdit(t)} style={{ padding: "4px 10px" }}>Edit</Btn>
<Btn variant="danger" onClick={async () => { if (!confirm(`Delete "${t.name}"?`)) return; await api.delete(`/taxes/${t.id}`).catch((e) => alert(e.message)); load(); }} style={{ padding: "4px 10px" }}>Delete</Btn>
</div>
)},
];
return (
<>
<div style={{ marginBottom: 16 }}><Btn onClick={openCreate}>+ Add Tax Rate</Btn></div>
<Table columns={columns} data={items} keyField="id" loading={loading} />
{modal && (
<Modal title={modal === "create" ? "Add Tax Rate" : "Edit Tax Rate"} onClose={() => setModal(null)}>
<form onSubmit={handleSubmit}>
{error && <div style={errStyle}>{error}</div>}
<FormField label="Name" required>
<input style={inputStyle} value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} required autoFocus placeholder="e.g. GST" />
</FormField>
<FormField label="Rate (%)" required>
<input style={inputStyle} type="number" min="0" max="100" step="0.01" value={form.rate} onChange={(e) => setForm((f) => ({ ...f, rate: e.target.value }))} required placeholder="e.g. 10" />
</FormField>
<div style={{ display: "flex", gap: 8 }}>
<Btn type="submit" disabled={saving}>{saving ? "Saving…" : "Save"}</Btn>
<Btn variant="ghost" onClick={() => setModal(null)}>Cancel</Btn>
</div>
</form>
</Modal>
)}
</>
);
}
// ─── Helpers ───────────────────────────────────────────────────────────────
function emptyProduct() {
return { name: "", sku: "", price: "", categoryId: "", taxId: "", description: "" };
}
function f<T extends Record<string, string>>(key: keyof T, set: React.Dispatch<React.SetStateAction<T>>) {
return (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) =>
set((prev) => ({ ...prev, [key]: e.target.value }));
}
const errStyle: React.CSSProperties = {
background: "#fef2f2", border: "1px solid #fecaca",
color: "var(--color-danger)", borderRadius: "var(--radius)",
padding: "10px 12px", fontSize: 13, marginBottom: 16,
};
const tabs: React.CSSProperties = { display: "flex", gap: 4, marginBottom: 20, borderBottom: "1px solid var(--color-border)" };
const tabBtn: React.CSSProperties = { padding: "8px 16px", background: "none", border: "none", borderBottom: "2px solid transparent", cursor: "pointer", fontWeight: 500, fontSize: 14, color: "var(--color-text-muted)", marginBottom: -1 };
const tabBtnActive: React.CSSProperties = { color: "var(--color-primary)", borderBottomColor: "var(--color-primary)" };

View File

@@ -0,0 +1,50 @@
import React from "react";
import { useAuth } from "../context/AuthContext";
import { PageHeader } from "../components/PageHeader";
const ALL_CARDS = [
{ label: "Catalog", desc: "Products, categories, pricing", to: "/catalog", roles: ["admin", "vendor", "user"] },
{ label: "Events", desc: "Events, tax overrides, reports", to: "/events", roles: ["admin", "vendor"] },
{ label: "Users", desc: "Manage roles and access", to: "/users", roles: ["admin", "vendor"] },
{ label: "Vendor", desc: "Business details and tax settings", to: "/vendor", roles: ["admin", "vendor"] },
{ label: "Reports", desc: "Sales and tax summaries", to: "/reports", roles: ["admin", "vendor"] },
];
export default function DashboardPage() {
const { user } = useAuth();
const cards = ALL_CARDS.filter((c) => c.roles.includes(user?.role ?? ""));
return (
<div style={{ padding: "32px 28px", maxWidth: 900 }}>
<PageHeader
title="Dashboard"
subtitle={`Welcome back, ${user?.name} · ${user?.vendorName}`}
/>
<div style={grid}>
{cards.map((card) => (
<a key={card.label} href={card.to} style={cardStyle}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>{card.label}</div>
<div style={{ color: "var(--color-text-muted)", fontSize: 13 }}>{card.desc}</div>
</a>
))}
</div>
</div>
);
}
const grid: React.CSSProperties = {
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))",
gap: 16,
};
const cardStyle: React.CSSProperties = {
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
borderRadius: "var(--radius)",
padding: "20px",
boxShadow: "var(--shadow)",
textDecoration: "none",
color: "var(--color-text)",
display: "block",
};

View File

@@ -0,0 +1,505 @@
import React, { useEffect, useState, useCallback } from "react";
import { api } from "../api/client";
import { useAuth } from "../context/AuthContext";
import { PageHeader } from "../components/PageHeader";
import { VendorFilter } from "../components/VendorFilter";
import { Table } from "../components/Table";
import { Modal } from "../components/Modal";
import { FormField, Btn, inputStyle } from "../components/FormField";
// ─── Types ──────────────────────────────────────────────────────────────────
interface Vendor { id: string; name: string; }
interface EventTaxOverride {
id: string;
taxId: string;
rate: number;
tax: { name: string; rate: number };
}
interface EventProductItem {
id: string;
productId: string;
product: { id: string; name: string; price: number; sku?: string };
}
interface Event {
id: string;
name: string;
description?: string;
startsAt: string;
endsAt: string;
isActive: boolean;
vendorId: string;
vendor?: Vendor;
taxOverrides?: EventTaxOverride[];
products?: EventProductItem[];
_count?: { products: number; taxOverrides: number; transactions: number };
}
interface Tax { id: string; name: string; rate: number; }
interface Product { id: string; name: string; price: number; sku?: string; }
interface ApiList<T> { data: T[]; pagination: { total: number } }
interface EventSummary {
event: { id: string; name: string; startsAt: string; endsAt: string };
totals: { revenue: number; tax: number; discounts: number; transactionCount: number; averageTransaction: number };
byPaymentMethod: { method: string; revenue: number; count: number }[];
topProducts: { productId: string; productName: string; revenue: number; unitsSold: number }[];
}
// ─── Main Page ──────────────────────────────────────────────────────────────
export default function EventsPage() {
const { user } = useAuth();
const [vendorId, setVendorId] = useState(user?.vendorId ?? "");
const [events, setEvents] = useState<Event[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState<Event | null>(null);
const [detail, setDetail] = useState<Event | null>(null);
const [detailView, setDetailView] = useState<"config" | "report">("config");
const load = useCallback(async () => {
setLoading(true);
try {
const q = vendorId && user?.role === "admin" ? `?vendorId=${encodeURIComponent(vendorId)}&limit=50` : "?limit=50";
const res = await api.get<ApiList<Event>>(`/events${q}`);
setEvents(res.data);
setTotal(res.pagination.total);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}, [vendorId, user?.role]);
useEffect(() => { load(); }, [load]);
const openDetail = async (ev: Event) => {
try {
const full = await api.get<Event>(`/events/${ev.id}`);
setDetail(full);
setDetailView("config");
} catch (err) {
console.error(err);
}
};
const handleDelete = async (id: string) => {
if (!confirm("Delete this event? This cannot be undone.")) return;
await api.delete(`/events/${id}`);
load();
};
const columns = [
{ key: "name", header: "Name", render: (ev: Event) => ev.name },
{
key: "dates", header: "Dates", render: (ev: Event) =>
`${fmtDate(ev.startsAt)}${fmtDate(ev.endsAt)}`
},
{ key: "vendor", header: "Vendor", render: (ev: Event) => ev.vendor?.name ?? ev.vendorId },
{
key: "status", header: "Status", render: (ev: Event) => (
<span style={ev.isActive ? activeBadge : inactiveBadge}>
{ev.isActive ? "Active" : "Inactive"}
</span>
)
},
{
key: "counts", header: "Products / Taxes / Txns", render: (ev: Event) =>
`${ev._count?.products ?? 0} / ${ev._count?.taxOverrides ?? 0} / ${ev._count?.transactions ?? 0}`
},
{
key: "actions", header: "", render: (ev: Event) => (
<div style={{ display: "flex", gap: 6 }}>
<Btn size="sm" onClick={() => openDetail(ev)}>View</Btn>
<Btn size="sm" onClick={() => { setEditing(ev); setShowForm(true); }}>Edit</Btn>
<Btn size="sm" variant="danger" onClick={() => handleDelete(ev.id)}>Delete</Btn>
</div>
)
},
];
return (
<div style={{ padding: "32px 28px" }}>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", flexWrap: "wrap", gap: 12, marginBottom: 4 }}>
<PageHeader
title="Events"
subtitle={`${total} event${total !== 1 ? "s" : ""}`}
action={<Btn onClick={() => { setEditing(null); setShowForm(true); }}>+ New Event</Btn>}
/>
<VendorFilter vendorId={vendorId} onChange={setVendorId} />
</div>
<Table columns={columns} data={events} keyField="id" loading={loading} emptyText="No events yet." />
{showForm && (
<EventFormModal
event={editing}
defaultVendorId={vendorId}
onClose={() => setShowForm(false)}
onSaved={() => { setShowForm(false); load(); }}
/>
)}
{detail && (
<EventDetailModal
event={detail}
view={detailView}
onViewChange={setDetailView}
onClose={() => setDetail(null)}
onRefresh={() => openDetail(detail)}
/>
)}
</div>
);
}
// ─── Event Form Modal ────────────────────────────────────────────────────────
function EventFormModal({ event, defaultVendorId, onClose, onSaved }: {
event: Event | null;
defaultVendorId?: string;
onClose: () => void;
onSaved: () => void;
}) {
const { user } = useAuth();
const isAdmin = user?.role === "admin";
const [name, setName] = useState(event?.name ?? "");
const [description, setDescription] = useState(event?.description ?? "");
const [startsAt, setStartsAt] = useState(event ? toDatetimeLocal(event.startsAt) : "");
const [endsAt, setEndsAt] = useState(event ? toDatetimeLocal(event.endsAt) : "");
const [isActive, setIsActive] = useState(event?.isActive ?? true);
const [selectedVendorId, setSelectedVendorId] = useState(
event?.vendorId ?? defaultVendorId ?? ""
);
const [vendors, setVendors] = useState<Vendor[]>([]);
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
useEffect(() => {
if (isAdmin && !event) {
api.get<{ data: Vendor[] }>("/vendors?limit=200")
.then((r) => setVendors(r.data))
.catch(console.error);
}
}, [isAdmin, event]);
const save = async () => {
if (isAdmin && !event && !selectedVendorId) {
setError("Please select a vendor for this event.");
return;
}
setSaving(true);
setError("");
try {
const body = {
name, description: description || undefined,
startsAt: new Date(startsAt).toISOString(),
endsAt: new Date(endsAt).toISOString(),
isActive,
};
if (event) {
await api.put(`/events/${event.id}`, body);
} else {
const q = isAdmin && selectedVendorId ? `?vendorId=${encodeURIComponent(selectedVendorId)}` : "";
await api.post(`/events${q}`, body);
}
onSaved();
} catch (err) {
setError(err instanceof Error ? err.message : "Save failed");
} finally {
setSaving(false);
}
};
return (
<Modal title={event ? "Edit Event" : "New Event"} onClose={onClose}>
{isAdmin && !event && (
<FormField label="Vendor" required>
<select style={inputStyle} value={selectedVendorId}
onChange={(e) => setSelectedVendorId(e.target.value)} required>
<option value="">Select vendor</option>
{vendors.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
</select>
</FormField>
)}
<FormField label="Name">
<input style={input} value={name} onChange={(e) => setName(e.target.value)} />
</FormField>
<FormField label="Description">
<textarea style={{ ...input, height: 64, resize: "vertical" }} value={description}
onChange={(e) => setDescription(e.target.value)} />
</FormField>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
<FormField label="Starts At">
<input type="datetime-local" style={input} value={startsAt}
onChange={(e) => setStartsAt(e.target.value)} />
</FormField>
<FormField label="Ends At">
<input type="datetime-local" style={input} value={endsAt}
onChange={(e) => setEndsAt(e.target.value)} />
</FormField>
</div>
<FormField label="">
<label style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer" }}>
<input type="checkbox" checked={isActive} onChange={(e) => setIsActive(e.target.checked)} />
<span style={{ fontSize: 14 }}>Active</span>
</label>
</FormField>
{error && <div style={errStyle}>{error}</div>}
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 8 }}>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn onClick={save} disabled={saving}>{saving ? "Saving…" : "Save"}</Btn>
</div>
</Modal>
);
}
// ─── Event Detail Modal ──────────────────────────────────────────────────────
function EventDetailModal({ event, view, onViewChange, onClose, onRefresh }: {
event: Event;
view: "config" | "report";
onViewChange: (v: "config" | "report") => void;
onClose: () => void;
onRefresh: () => void;
}) {
return (
<Modal title={event.name} onClose={onClose} width={760}>
<div style={{ color: "var(--color-text-muted)", fontSize: 13, marginBottom: 16 }}>
{fmtDate(event.startsAt)} {fmtDate(event.endsAt)}
{event.description && <span style={{ marginLeft: 12 }}>· {event.description}</span>}
</div>
<div style={tabs}>
{(["config", "report"] as const).map((t) => (
<button key={t} type="button"
style={{ ...tabBtn, ...(view === t ? tabBtnActive : {}) }}
onClick={() => onViewChange(t)}>
{t === "config" ? "Configuration" : "Reports"}
</button>
))}
</div>
{view === "config" && (
<EventConfigPanel event={event} onRefresh={onRefresh} />
)}
{view === "report" && (
<EventReportPanel eventId={event.id} />
)}
</Modal>
);
}
// ─── Event Config Panel ──────────────────────────────────────────────────────
function EventConfigPanel({ event, onRefresh }: { event: Event; onRefresh: () => void }) {
const [taxes, setTaxes] = useState<Tax[]>([]);
const [products, setProducts] = useState<Product[]>([]);
const [taxId, setTaxId] = useState("");
const [taxRate, setTaxRate] = useState("");
const [productId, setProductId] = useState("");
const [err, setErr] = useState("");
useEffect(() => {
const q = `?vendorId=${encodeURIComponent(event.vendorId)}&limit=200`;
Promise.all([
api.get<ApiList<Tax>>(`/taxes${q}`).then((r) => setTaxes(r.data)),
api.get<ApiList<Product>>(`/products${q}`).then((r) => setProducts(r.data)),
]).catch(console.error);
}, [event.vendorId]);
const addTax = async () => {
setErr("");
try {
await api.put(`/events/${event.id}/taxes`, { taxId, rate: parseFloat(taxRate) });
setTaxId(""); setTaxRate("");
onRefresh();
} catch (e) { setErr(e instanceof Error ? e.message : "Failed"); }
};
const removeTax = async (tId: string) => {
await api.delete(`/events/${event.id}/taxes/${tId}`);
onRefresh();
};
const addProduct = async () => {
setErr("");
try {
await api.post(`/events/${event.id}/products`, { productId });
setProductId("");
onRefresh();
} catch (e) { setErr(e instanceof Error ? e.message : "Failed"); }
};
const removeProduct = async (pId: string) => {
await api.delete(`/events/${event.id}/products/${pId}`);
onRefresh();
};
return (
<div>
{err && <div style={errStyle}>{err}</div>}
{/* Tax overrides */}
<div style={sectionTitle}>Tax Rate Overrides</div>
<div style={{ color: "var(--color-text-muted)", fontSize: 12, marginBottom: 8 }}>
Override the default tax rate for this event. Empty = use vendor defaults.
</div>
{(event.taxOverrides ?? []).map((o) => (
<div key={o.id} style={listRow}>
<span style={{ flex: 1 }}>{o.tax.name}</span>
<span style={{ color: "var(--color-text-muted)", fontSize: 13 }}>
{o.tax.rate}% <strong>{o.rate}%</strong>
</span>
<Btn size="sm" variant="danger" onClick={() => removeTax(o.taxId)}>Remove</Btn>
</div>
))}
<div style={{ display: "flex", gap: 8, marginTop: 8, alignItems: "center" }}>
<select style={input} value={taxId} onChange={(e) => setTaxId(e.target.value)}>
<option value="">Select tax</option>
{taxes.map((t) => <option key={t.id} value={t.id}>{t.name} ({t.rate}%)</option>)}
</select>
<input style={{ ...input, width: 90 }} placeholder="Rate %" type="number" min="0" max="100"
value={taxRate} onChange={(e) => setTaxRate(e.target.value)} />
<Btn onClick={addTax} disabled={!taxId || !taxRate}>Add</Btn>
</div>
{/* Product allowlist */}
<div style={{ ...sectionTitle, marginTop: 24 }}>Product Allowlist</div>
<div style={{ color: "var(--color-text-muted)", fontSize: 12, marginBottom: 8 }}>
Restrict which products are available at this event. Empty = all vendor products available.
</div>
{(event.products ?? []).map((ep) => (
<div key={ep.id} style={listRow}>
<span style={{ flex: 1 }}>{ep.product.name}</span>
<span style={{ color: "var(--color-text-muted)", fontSize: 13 }}>${ep.product.price.toFixed(2)}</span>
<Btn size="sm" variant="danger" onClick={() => removeProduct(ep.productId)}>Remove</Btn>
</div>
))}
<div style={{ display: "flex", gap: 8, marginTop: 8 }}>
<select style={input} value={productId} onChange={(e) => setProductId(e.target.value)}>
<option value="">Add product</option>
{products
.filter((p) => !(event.products ?? []).find((ep) => ep.productId === p.id))
.map((p) => <option key={p.id} value={p.id}>{p.name} ${p.price.toFixed(2)}</option>)}
</select>
<Btn onClick={addProduct} disabled={!productId}>Add</Btn>
</div>
</div>
);
}
// ─── Event Report Panel ──────────────────────────────────────────────────────
function EventReportPanel({ eventId }: { eventId: string }) {
const [summary, setSummary] = useState<EventSummary | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.get<EventSummary>(`/events/${eventId}/reports/summary`)
.then(setSummary)
.catch(console.error)
.finally(() => setLoading(false));
}, [eventId]);
if (loading) return <div style={{ color: "var(--color-text-muted)" }}>Loading</div>;
if (!summary) return <div style={{ color: "var(--color-text-muted)" }}>No data</div>;
const { totals, byPaymentMethod, topProducts } = summary;
return (
<div>
<div style={statGrid}>
<StatCard label="Revenue" value={`$${totals.revenue.toFixed(2)}`} />
<StatCard label="Transactions" value={String(totals.transactionCount)} />
<StatCard label="Avg Transaction" value={`$${totals.averageTransaction.toFixed(2)}`} />
<StatCard label="Tax Collected" value={`$${totals.tax.toFixed(2)}`} />
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
<div style={card}>
<div style={cardTitle}>By Payment Method</div>
{byPaymentMethod.length === 0
? <p style={{ color: "var(--color-text-muted)", fontSize: 13 }}>No sales yet</p>
: byPaymentMethod.map((m) => (
<div key={m.method} style={listRow}>
<span style={methodBadge(m.method)}>{m.method}</span>
<span style={{ marginLeft: "auto" }}>${m.revenue.toFixed(2)}</span>
<span style={{ color: "var(--color-text-muted)", fontSize: 12 }}>({m.count})</span>
</div>
))
}
</div>
<div style={card}>
<div style={cardTitle}>Top Products</div>
{topProducts.length === 0
? <p style={{ color: "var(--color-text-muted)", fontSize: 13 }}>No sales yet</p>
: topProducts.map((p, i) => (
<div key={p.productId} style={listRow}>
<span style={rank}>{i + 1}</span>
<span style={{ flex: 1 }}>{p.productName}</span>
<span style={{ color: "var(--color-text-muted)", fontSize: 12 }}>{p.unitsSold} sold</span>
<span style={{ fontWeight: 600, marginLeft: 8 }}>${p.revenue.toFixed(2)}</span>
</div>
))
}
</div>
</div>
</div>
);
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function StatCard({ label, value }: { label: string; value: string }) {
return (
<div style={statCard}>
<div style={{ color: "var(--color-text-muted)", fontSize: 12, fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.04em", marginBottom: 8 }}>{label}</div>
<div style={{ fontSize: 24, fontWeight: 700 }}>{value}</div>
</div>
);
}
function methodBadge(method: string): React.CSSProperties {
return {
display: "inline-block", padding: "2px 10px", borderRadius: 999,
fontSize: 12, fontWeight: 600, textTransform: "capitalize",
background: method === "cash" ? "#f0fdf4" : "#eff6ff",
color: method === "cash" ? "#166534" : "#1e40af",
};
}
function fmtDate(iso: string) {
return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric", hour: "2-digit", minute: "2-digit" });
}
function toDatetimeLocal(iso: string) {
const d = new Date(iso);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
// ─── Styles ───────────────────────────────────────────────────────────────────
const input: React.CSSProperties = {
width: "100%", border: "1px solid var(--color-border)", borderRadius: "var(--radius)",
padding: "7px 10px", fontSize: 14, boxSizing: "border-box",
};
const errStyle: React.CSSProperties = { color: "#dc2626", fontSize: 13, marginBottom: 8 };
const activeBadge: React.CSSProperties = { display: "inline-block", padding: "2px 10px", borderRadius: 999, fontSize: 12, fontWeight: 600, background: "#f0fdf4", color: "#166534" };
const inactiveBadge: React.CSSProperties = { display: "inline-block", padding: "2px 10px", borderRadius: 999, fontSize: 12, fontWeight: 600, background: "#f1f5f9", color: "#64748b" };
const sectionTitle: React.CSSProperties = { fontWeight: 600, fontSize: 14, marginBottom: 6 };
const listRow: React.CSSProperties = { display: "flex", alignItems: "center", gap: 8, padding: "7px 0", borderBottom: "1px solid var(--color-border)" };
const tabs: React.CSSProperties = { display: "flex", gap: 4, marginBottom: 20, borderBottom: "1px solid var(--color-border)" };
const tabBtn: React.CSSProperties = { padding: "8px 16px", background: "none", border: "none", borderBottom: "2px solid transparent", cursor: "pointer", fontWeight: 500, fontSize: 14, color: "var(--color-text-muted)", marginBottom: -1 };
const tabBtnActive: React.CSSProperties = { color: "var(--color-primary)", borderBottomColor: "var(--color-primary)" };
const statGrid: React.CSSProperties = { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))", gap: 16, marginBottom: 20 };
const statCard: React.CSSProperties = { background: "var(--color-surface)", border: "1px solid var(--color-border)", borderRadius: "var(--radius)", padding: "16px 20px" };
const card: React.CSSProperties = { background: "var(--color-surface)", border: "1px solid var(--color-border)", borderRadius: "var(--radius)", padding: "16px 20px" };
const cardTitle: React.CSSProperties = { fontWeight: 600, marginBottom: 12, fontSize: 14 };
const rank: React.CSSProperties = { width: 20, height: 20, borderRadius: "50%", background: "#e2e8f0", fontSize: 11, fontWeight: 700, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 };

View File

@@ -0,0 +1,128 @@
import React, { useState, FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
export default function LoginPage() {
const { login } = useAuth();
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
await login(email, password);
navigate("/", { replace: true });
} catch (err) {
setError(err instanceof Error ? err.message : "Login failed");
} finally {
setLoading(false);
}
};
return (
<div style={styles.page}>
<div style={styles.card}>
<h1 style={styles.title}>POS Admin</h1>
<p style={styles.subtitle}>Sign in to your account</p>
<form onSubmit={handleSubmit} style={styles.form}>
{error && <div style={styles.error}>{error}</div>}
<label style={styles.label}>
Email
<input
style={styles.input}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoFocus
autoComplete="email"
/>
</label>
<label style={styles.label}>
Password
<input
style={styles.input}
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
/>
</label>
<button style={styles.button} type="submit" disabled={loading}>
{loading ? "Signing in…" : "Sign in"}
</button>
</form>
</div>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
page: {
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "var(--color-bg)",
},
card: {
background: "var(--color-surface)",
borderRadius: "var(--radius)",
boxShadow: "var(--shadow)",
border: "1px solid var(--color-border)",
padding: "40px",
width: "100%",
maxWidth: "380px",
},
title: {
fontSize: "22px",
fontWeight: 700,
marginBottom: "4px",
},
subtitle: {
color: "var(--color-text-muted)",
marginBottom: "24px",
},
form: {
display: "flex",
flexDirection: "column",
gap: "16px",
},
label: {
display: "flex",
flexDirection: "column",
gap: "4px",
fontWeight: 500,
},
input: {
border: "1px solid var(--color-border)",
borderRadius: "var(--radius)",
padding: "8px 12px",
outline: "none",
fontSize: "14px",
},
button: {
background: "var(--color-primary)",
color: "#fff",
border: "none",
borderRadius: "var(--radius)",
padding: "10px",
fontWeight: 600,
fontSize: "14px",
marginTop: "4px",
},
error: {
background: "#fef2f2",
border: "1px solid #fecaca",
color: "var(--color-danger)",
borderRadius: "var(--radius)",
padding: "10px 12px",
fontSize: "13px",
},
};

View File

@@ -0,0 +1,220 @@
import React, { useEffect, useState, useCallback } from "react";
import { api } from "../api/client";
import { PageHeader } from "../components/PageHeader";
import { Table } from "../components/Table";
interface Summary {
period: { from: string | null; to: string | null };
totals: {
revenue: number;
subtotal: number;
tax: number;
discounts: number;
transactionCount: number;
};
byPaymentMethod: { method: string; revenue: number; count: number }[];
topProducts: { productId: string; productName: string; revenue: number; unitsSold: number }[];
}
interface Transaction {
id: string;
status: string;
paymentMethod: string;
total: number;
taxTotal: number;
discountTotal: number;
createdAt: string;
user: { name: string; email: string };
items: { productName: string; quantity: number; unitPrice: number; total: number }[];
}
interface ApiList<T> { data: T[]; pagination: { total: number; page: number; totalPages: number }; }
export default function ReportsPage() {
const [tab, setTab] = useState<"summary" | "transactions">("summary");
const [summary, setSummary] = useState<Summary | null>(null);
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [txTotal, setTxTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [from, setFrom] = useState("");
const [to, setTo] = useState("");
const loadSummary = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (from) params.set("from", new Date(from).toISOString());
if (to) params.set("to", new Date(to + "T23:59:59").toISOString());
const s = await api.get<Summary>(`/transactions/reports/summary?${params}`);
setSummary(s);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}, [from, to]);
const loadTransactions = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (from) params.set("from", new Date(from).toISOString());
if (to) params.set("to", new Date(to + "T23:59:59").toISOString());
const res = await api.get<ApiList<Transaction>>(`/transactions?limit=50&${params}`);
setTransactions(res.data);
setTxTotal(res.pagination.total);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}, [from, to]);
useEffect(() => {
if (tab === "summary") loadSummary();
else loadTransactions();
}, [tab, loadSummary, loadTransactions]);
const txColumns = [
{ key: "createdAt", header: "Date", render: (t: Transaction) => new Date(t.createdAt).toLocaleString() },
{ key: "user", header: "Staff", render: (t: Transaction) => t.user.name },
{ key: "paymentMethod", header: "Payment", render: (t: Transaction) => <span style={methodBadge(t.paymentMethod)}>{t.paymentMethod}</span> },
{ key: "status", header: "Status", render: (t: Transaction) => <span style={statusBadge(t.status)}>{t.status}</span> },
{ key: "items", header: "Items", render: (t: Transaction) => t.items.length },
{ key: "total", header: "Total", render: (t: Transaction) => `$${t.total.toFixed(2)}` },
];
return (
<div style={{ padding: "32px 28px" }}>
<PageHeader title="Reports" subtitle="Sales and tax summaries" />
{/* Date range filter */}
<div style={filterRow}>
<label style={filterLabel}>From</label>
<input type="date" style={dateInput} value={from} onChange={(e) => setFrom(e.target.value)} />
<label style={filterLabel}>To</label>
<input type="date" style={dateInput} value={to} onChange={(e) => setTo(e.target.value)} />
<button type="button" style={applyBtn} onClick={() => tab === "summary" ? loadSummary() : loadTransactions()}>
Apply
</button>
{(from || to) && (
<button type="button" style={clearBtn} onClick={() => { setFrom(""); setTo(""); }}>
Clear
</button>
)}
</div>
{/* Tabs */}
<div style={tabs}>
{(["summary", "transactions"] as const).map((t) => (
<button key={t} type="button" style={{ ...tabBtn, ...(tab === t ? tabBtnActive : {}) }} onClick={() => setTab(t)}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</button>
))}
</div>
{tab === "summary" && (
loading ? <div style={{ color: "var(--color-text-muted)" }}>Loading</div> :
summary && (
<div>
{/* Stat cards */}
<div style={statGrid}>
<StatCard label="Total Revenue" value={`$${summary.totals.revenue.toFixed(2)}`} />
<StatCard label="Transactions" value={String(summary.totals.transactionCount)} />
<StatCard label="Tax Collected" value={`$${summary.totals.tax.toFixed(2)}`} />
<StatCard label="Discounts Given" value={`$${summary.totals.discounts.toFixed(2)}`} />
</div>
<div style={twoCol}>
{/* Payment method breakdown */}
<div style={card}>
<div style={cardTitle}>By Payment Method</div>
{summary.byPaymentMethod.length === 0
? <p style={{ color: "var(--color-text-muted)", fontSize: 13 }}>No data</p>
: summary.byPaymentMethod.map((m) => (
<div key={m.method} style={methodRow}>
<span style={methodBadge(m.method)}>{m.method}</span>
<span style={{ marginLeft: "auto" }}>${m.revenue.toFixed(2)}</span>
<span style={{ color: "var(--color-text-muted)", fontSize: 12, marginLeft: 8 }}>({m.count} txns)</span>
</div>
))
}
</div>
{/* Top products */}
<div style={card}>
<div style={cardTitle}>Top Products</div>
{summary.topProducts.length === 0
? <p style={{ color: "var(--color-text-muted)", fontSize: 13 }}>No data</p>
: summary.topProducts.map((p, i) => (
<div key={p.productId} style={productRow}>
<span style={rank}>{i + 1}</span>
<span style={{ flex: 1 }}>{p.productName}</span>
<span style={{ color: "var(--color-text-muted)", fontSize: 12 }}>{p.unitsSold} sold</span>
<span style={{ fontWeight: 600, marginLeft: 12 }}>${p.revenue.toFixed(2)}</span>
</div>
))
}
</div>
</div>
</div>
)
)}
{tab === "transactions" && (
<div>
<div style={{ color: "var(--color-text-muted)", fontSize: 13, marginBottom: 12 }}>
{txTotal} transaction{txTotal !== 1 ? "s" : ""}
</div>
<Table columns={txColumns} data={transactions} keyField="id" loading={loading} emptyText="No transactions found." />
</div>
)}
</div>
);
}
function StatCard({ label, value }: { label: string; value: string }) {
return (
<div style={statCard}>
<div style={{ color: "var(--color-text-muted)", fontSize: 12, fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.04em", marginBottom: 8 }}>{label}</div>
<div style={{ fontSize: 26, fontWeight: 700 }}>{value}</div>
</div>
);
}
function methodBadge(method: string): React.CSSProperties {
return {
display: "inline-block", padding: "2px 10px", borderRadius: 999,
fontSize: 12, fontWeight: 600, textTransform: "capitalize",
background: method === "cash" ? "#f0fdf4" : "#eff6ff",
color: method === "cash" ? "#166534" : "#1e40af",
};
}
function statusBadge(status: string): React.CSSProperties {
const map: Record<string, { bg: string; color: string }> = {
completed: { bg: "#f0fdf4", color: "#166534" },
pending: { bg: "#fefce8", color: "#854d0e" },
failed: { bg: "#fef2f2", color: "#991b1b" },
refunded: { bg: "#f5f3ff", color: "#4c1d95" },
};
const c = map[status] ?? { bg: "#f1f5f9", color: "#475569" };
return { display: "inline-block", padding: "2px 10px", borderRadius: 999, fontSize: 12, fontWeight: 600, textTransform: "capitalize", ...c };
}
const filterRow: React.CSSProperties = { display: "flex", alignItems: "center", gap: 8, marginBottom: 20, flexWrap: "wrap" };
const filterLabel: React.CSSProperties = { fontSize: 13, fontWeight: 500, color: "var(--color-text-muted)" };
const dateInput: React.CSSProperties = { border: "1px solid var(--color-border)", borderRadius: "var(--radius)", padding: "6px 10px", fontSize: 13 };
const applyBtn: React.CSSProperties = { background: "var(--color-primary)", color: "#fff", border: "none", borderRadius: "var(--radius)", padding: "6px 14px", fontWeight: 600, fontSize: 13, cursor: "pointer" };
const clearBtn: React.CSSProperties = { background: "none", color: "var(--color-text-muted)", border: "1px solid var(--color-border)", borderRadius: "var(--radius)", padding: "6px 12px", fontSize: 13, cursor: "pointer" };
const tabs: React.CSSProperties = { display: "flex", gap: 4, marginBottom: 20, borderBottom: "1px solid var(--color-border)", paddingBottom: 0 };
const tabBtn: React.CSSProperties = { padding: "8px 16px", background: "none", border: "none", borderBottom: "2px solid transparent", cursor: "pointer", fontWeight: 500, fontSize: 14, color: "var(--color-text-muted)", marginBottom: -1 };
const tabBtnActive: React.CSSProperties = { color: "var(--color-primary)", borderBottomColor: "var(--color-primary)" };
const statGrid: React.CSSProperties = { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))", gap: 16, marginBottom: 24 };
const statCard: React.CSSProperties = { background: "var(--color-surface)", border: "1px solid var(--color-border)", borderRadius: "var(--radius)", padding: "20px 24px", boxShadow: "var(--shadow)" };
const twoCol: React.CSSProperties = { display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 };
const card: React.CSSProperties = { background: "var(--color-surface)", border: "1px solid var(--color-border)", borderRadius: "var(--radius)", padding: "16px 20px" };
const cardTitle: React.CSSProperties = { fontWeight: 600, marginBottom: 12, fontSize: 14 };
const methodRow: React.CSSProperties = { display: "flex", alignItems: "center", gap: 8, padding: "8px 0", borderBottom: "1px solid var(--color-border)" };
const productRow: React.CSSProperties = { display: "flex", alignItems: "center", gap: 8, padding: "7px 0", borderBottom: "1px solid var(--color-border)" };
const rank: React.CSSProperties = { width: 20, height: 20, borderRadius: "50%", background: "#e2e8f0", fontSize: 11, fontWeight: 700, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 };

View File

@@ -0,0 +1,205 @@
import React, { useEffect, useState, useCallback } from "react";
import { api } from "../api/client";
import { useAuth } from "../context/AuthContext";
import { Table } from "../components/Table";
import { Modal } from "../components/Modal";
import { PageHeader } from "../components/PageHeader";
import { FormField, inputStyle, Btn } from "../components/FormField";
import { VendorFilter } from "../components/VendorFilter";
interface Role { id: string; name: string; }
interface User {
id: string;
name: string;
email: string;
role: Role;
vendor?: { id: string; name: string };
createdAt: string;
}
interface ApiList<T> { data: T[]; pagination: { total: number; page: number; limit: number; totalPages: number }; }
const EMPTY_FORM = { name: "", email: "", password: "", roleId: "", vendorId: "" };
export default function UsersPage() {
const { user: me } = useAuth();
const [vendorId, setVendorId] = useState(me?.vendorId ?? "");
const [users, setUsers] = useState<User[]>([]);
const [roles, setRoles] = useState<Role[]>([]);
const [loading, setLoading] = useState(true);
const [modal, setModal] = useState<"create" | "edit" | null>(null);
const [selected, setSelected] = useState<User | null>(null);
const [form, setForm] = useState(EMPTY_FORM);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const load = useCallback(async () => {
setLoading(true);
try {
const q = vendorId && me?.role === "admin" ? `?vendorId=${encodeURIComponent(vendorId)}` : "";
const [usersRes, rolesRes] = await Promise.all([
api.get<ApiList<User>>(`/users${q}`),
api.get<Role[]>("/users/roles/list"),
]);
setUsers(usersRes.data);
setRoles(rolesRes);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}, [vendorId, me?.role]);
useEffect(() => { load(); }, [load]);
const openCreate = () => {
setSelected(null);
setForm({ ...EMPTY_FORM, vendorId });
setError("");
setModal("create");
};
const openEdit = (user: User) => {
setSelected(user);
setForm({ name: user.name, email: user.email, password: "", roleId: user.role.id, vendorId: user.vendor?.id ?? "" });
setError("");
setModal("edit");
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
setError("");
try {
if (modal === "create") {
await api.post("/users", { ...form, vendorId: form.vendorId || undefined });
} else if (selected) {
const patch: Record<string, string> = { name: form.name, roleId: form.roleId };
if (form.password) patch.password = form.password;
await api.put(`/users/${selected.id}`, patch);
}
setModal(null);
load();
} catch (err) {
setError(err instanceof Error ? err.message : "Save failed");
} finally {
setSaving(false);
}
};
const handleDelete = async (user: User) => {
if (!confirm(`Delete user "${user.name}"?`)) return;
try {
await api.delete(`/users/${user.id}`);
load();
} catch (err) {
alert(err instanceof Error ? err.message : "Delete failed");
}
};
const isAdmin = me?.role === "admin";
const columns = [
{ key: "name", header: "Name" },
{ key: "email", header: "Email" },
...(isAdmin ? [{ key: "vendor", header: "Vendor", render: (u: User) => u.vendor?.name ?? "—" }] : []),
{
key: "role",
header: "Role",
render: (u: User) => (
<span style={roleBadge(u.role.name)}>{u.role.name}</span>
),
},
{
key: "createdAt",
header: "Created",
render: (u: User) => new Date(u.createdAt).toLocaleDateString(),
},
{
key: "actions",
header: "",
render: (u: User) => (
<div style={{ display: "flex", gap: 8 }}>
<Btn variant="ghost" onClick={() => openEdit(u)} style={{ padding: "4px 10px" }}>Edit</Btn>
<Btn variant="danger" onClick={() => handleDelete(u)} style={{ padding: "4px 10px" }}>Delete</Btn>
</div>
),
},
];
return (
<div style={{ padding: "32px 28px" }}>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", flexWrap: "wrap", gap: 12, marginBottom: 16 }}>
<PageHeader
title="Users"
subtitle="Manage staff accounts and roles"
action={<Btn onClick={openCreate}>+ Add User</Btn>}
/>
<VendorFilter vendorId={vendorId} onChange={setVendorId} />
</div>
<Table columns={columns} data={users} keyField="id" loading={loading} />
{modal && (
<Modal
title={modal === "create" ? "Add User" : "Edit User"}
onClose={() => setModal(null)}
>
<form onSubmit={handleSubmit}>
{error && <div style={errStyle}>{error}</div>}
<FormField label="Name" required>
<input style={inputStyle} value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} required />
</FormField>
{modal === "create" && (
<FormField label="Email" required>
<input style={inputStyle} type="email" value={form.email} onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))} required />
</FormField>
)}
<FormField label={modal === "edit" ? "New Password (leave blank to keep)" : "Password"} required={modal === "create"}>
<input style={inputStyle} type="password" value={form.password} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} minLength={8} required={modal === "create"} />
</FormField>
<FormField label="Role" required>
<select style={inputStyle} value={form.roleId} onChange={(e) => setForm((f) => ({ ...f, roleId: e.target.value }))} required>
<option value="">Select role</option>
{roles.map((r) => (
<option key={r.id} value={r.id}>{r.name}</option>
))}
</select>
</FormField>
<div style={{ display: "flex", gap: 8, marginTop: 8 }}>
<Btn type="submit" disabled={saving}>{saving ? "Saving…" : "Save"}</Btn>
<Btn variant="ghost" onClick={() => setModal(null)}>Cancel</Btn>
</div>
</form>
</Modal>
)}
</div>
);
}
function roleBadge(role: string): React.CSSProperties {
const colors: Record<string, { bg: string; color: string }> = {
admin: { bg: "#fef3c7", color: "#92400e" },
vendor: { bg: "#dbeafe", color: "#1e3a8a" },
user: { bg: "#f0fdf4", color: "#14532d" },
};
const c = colors[role] ?? { bg: "#f1f5f9", color: "#475569" };
return {
display: "inline-block",
padding: "2px 10px",
borderRadius: 999,
fontSize: 12,
fontWeight: 600,
textTransform: "capitalize",
...c,
};
}
const errStyle: React.CSSProperties = {
background: "#fef2f2",
border: "1px solid #fecaca",
color: "var(--color-danger)",
borderRadius: "var(--radius)",
padding: "10px 12px",
fontSize: 13,
marginBottom: 16,
};

View File

@@ -0,0 +1,238 @@
import React, { useEffect, useState, useCallback } from "react";
import { api } from "../api/client";
import { useAuth } from "../context/AuthContext";
import { PageHeader } from "../components/PageHeader";
import { Table } from "../components/Table";
import { Modal } from "../components/Modal";
import { FormField, inputStyle, Btn } from "../components/FormField";
interface Vendor {
id: string;
name: string;
businessNum: string | null;
taxSettings: string | null;
createdAt: string;
updatedAt: string;
}
interface ApiList<T> { data: T[]; pagination: { total: number } }
const EMPTY_FORM = { name: "", businessNum: "" };
// ─── Admin view: list all vendors, create/edit/delete ────────────────────────
function AdminVendorPage() {
const [vendors, setVendors] = useState<Vendor[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [modal, setModal] = useState<"create" | "edit" | null>(null);
const [selected, setSelected] = useState<Vendor | null>(null);
const [form, setForm] = useState(EMPTY_FORM);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const load = useCallback(async () => {
setLoading(true);
try {
const res = await api.get<ApiList<Vendor>>("/vendors?limit=100");
setVendors(res.data);
setTotal(res.pagination.total);
} catch (err) { console.error(err); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
const openCreate = () => {
setSelected(null);
setForm(EMPTY_FORM);
setError("");
setModal("create");
};
const openEdit = (v: Vendor) => {
setSelected(v);
setForm({ name: v.name, businessNum: v.businessNum ?? "" });
setError("");
setModal("edit");
};
const handleDelete = async (id: string) => {
if (!confirm("Delete this vendor? All associated data will be removed.")) return;
try {
await api.delete(`/vendors/${id}`);
load();
} catch (err) {
alert(err instanceof Error ? err.message : "Delete failed");
}
};
const handleSave = async () => {
setSaving(true);
setError("");
try {
if (modal === "edit" && selected) {
await api.put(`/vendors/${selected.id}`, form);
} else {
await api.post("/vendors", form);
}
setModal(null);
load();
} catch (err) {
setError(err instanceof Error ? err.message : "Save failed");
} finally { setSaving(false); }
};
const columns = [
{ key: "name", header: "Name", render: (v: Vendor) => v.name },
{ key: "businessNum", header: "Business No.", render: (v: Vendor) => v.businessNum ?? "—" },
{ key: "createdAt", header: "Created", render: (v: Vendor) => new Date(v.createdAt).toLocaleDateString() },
{
key: "actions", header: "", render: (v: Vendor) => (
<div style={{ display: "flex", gap: 6 }}>
<Btn size="sm" onClick={() => openEdit(v)}>Edit</Btn>
<Btn size="sm" variant="danger" onClick={() => handleDelete(v.id)}>Delete</Btn>
</div>
)
},
];
return (
<div style={{ padding: "32px 28px" }}>
<PageHeader
title="Vendors"
subtitle={`${total} vendor${total !== 1 ? "s" : ""}`}
action={<Btn onClick={openCreate}>+ New Vendor</Btn>}
/>
<Table columns={columns} data={vendors} keyField="id" loading={loading} emptyText="No vendors found." />
{modal && (
<Modal title={modal === "create" ? "New Vendor" : "Edit Vendor"} onClose={() => setModal(null)}>
<FormField label="Business Name" required>
<input style={inputStyle} value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} required />
</FormField>
<FormField label="Business Number / ABN">
<input style={inputStyle} value={form.businessNum}
onChange={(e) => setForm((f) => ({ ...f, businessNum: e.target.value }))} />
</FormField>
{error && <div style={errStyle}>{error}</div>}
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 8 }}>
<Btn variant="ghost" onClick={() => setModal(null)}>Cancel</Btn>
<Btn onClick={handleSave} disabled={saving}>{saving ? "Saving…" : "Save"}</Btn>
</div>
</Modal>
)}
</div>
);
}
// ─── Vendor/user view: own settings only ────────────────────────────────────
function OwnVendorPage() {
const [vendor, setVendor] = useState<Vendor | null>(null);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [form, setForm] = useState(EMPTY_FORM);
useEffect(() => {
api.get<ApiList<Vendor>>("/vendors")
.then((res) => {
const v = res.data[0] ?? null;
setVendor(v);
if (v) setForm({ name: v.name, businessNum: v.businessNum ?? "" });
})
.catch(console.error)
.finally(() => setLoading(false));
}, []);
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
if (!vendor) return;
setSaving(true);
setError("");
try {
const updated = await api.put<Vendor>(`/vendors/${vendor.id}`, form);
setVendor(updated);
setEditing(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Save failed");
} finally { setSaving(false); }
};
if (loading) return <div style={{ padding: 32 }}>Loading</div>;
if (!vendor) return <div style={{ padding: 32 }}>No vendor found.</div>;
return (
<div style={{ padding: "32px 28px", maxWidth: 600 }}>
<PageHeader
title="Vendor Settings"
subtitle="Business details and configuration"
action={!editing && <Btn onClick={() => setEditing(true)}>Edit</Btn>}
/>
{editing ? (
<form onSubmit={handleSave} style={card}>
{error && <div style={errStyle}>{error}</div>}
<FormField label="Business Name" required>
<input style={inputStyle} value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} required />
</FormField>
<FormField label="Business Number / ABN">
<input style={inputStyle} value={form.businessNum}
onChange={(e) => setForm((f) => ({ ...f, businessNum: e.target.value }))} />
</FormField>
<div style={{ display: "flex", gap: 8, marginTop: 8 }}>
<Btn type="submit" disabled={saving}>{saving ? "Saving…" : "Save changes"}</Btn>
<Btn variant="ghost" onClick={() => setEditing(false)}>Cancel</Btn>
</div>
</form>
) : (
<div style={card}>
<Row label="Business Name" value={vendor.name} />
<Row label="Business Number" value={vendor.businessNum ?? "—"} />
<Row label="Created" value={new Date(vendor.createdAt).toLocaleDateString()} />
<Row label="Last Updated" value={new Date(vendor.updatedAt).toLocaleDateString()} />
</div>
)}
</div>
);
}
// ─── Root export — branches on role ─────────────────────────────────────────
export default function VendorPage() {
const { user } = useAuth();
return user?.role === "admin" ? <AdminVendorPage /> : <OwnVendorPage />;
}
// ─── Shared helpers ──────────────────────────────────────────────────────────
function Row({ label, value }: { label: string; value: string }) {
return (
<div style={{ display: "flex", gap: 16, padding: "10px 0", borderBottom: "1px solid var(--color-border)" }}>
<div style={{ width: 160, fontWeight: 500, fontSize: 13, color: "var(--color-text-muted)" }}>{label}</div>
<div style={{ flex: 1, fontSize: 14 }}>{value}</div>
</div>
);
}
const card: React.CSSProperties = {
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
borderRadius: "var(--radius)",
padding: "20px",
};
const errStyle: React.CSSProperties = {
background: "#fef2f2",
border: "1px solid #fecaca",
color: "var(--color-danger)",
borderRadius: "var(--radius)",
padding: "10px 12px",
fontSize: 13,
marginBottom: 16,
};

21
client/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
client/tsconfig.node.json Normal file
View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

19
client/vite.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": {
target: "http://localhost:8080",
changeOrigin: true,
},
},
},
build: {
outDir: "dist",
sourcemap: true,
},
});

37
docker-compose.yml Normal file
View File

@@ -0,0 +1,37 @@
version: "3.9"
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: pos_user
POSTGRES_PASSWORD: pos_password
POSTGRES_DB: pos_db
ports:
- "5432:5432"
volumes:
- pos_db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pos_user -d pos_db"]
interval: 10s
timeout: 5s
retries: 5
app:
build: .
restart: unless-stopped
depends_on:
db:
condition: service_healthy
ports:
- "8080:8080"
environment:
NODE_ENV: production
PORT: 8080
DATABASE_URL: postgresql://pos_user:pos_password@db:5432/pos_db
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
LOG_LEVEL: info
volumes:
pos_db_data:

6
server/.env Normal file
View File

@@ -0,0 +1,6 @@
PORT=8080
NODE_ENV=development
DATABASE_URL=file:./prisma/dev.db
JWT_SECRET=change-me-in-production
LOG_LEVEL=info
CORS_ORIGIN=http://localhost:5173

6
server/.env.example Normal file
View File

@@ -0,0 +1,6 @@
PORT=8080
NODE_ENV=development
DATABASE_URL=file:./prisma/dev.db
JWT_SECRET=change-me-in-production
LOG_LEVEL=info
CORS_ORIGIN=http://localhost:5173

1817
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
server/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "pos-server",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate deploy",
"db:migrate:dev": "prisma migrate dev",
"db:seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@prisma/client": "^5.14.0",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.2",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^20.14.0",
"prisma": "^5.14.0",
"tsx": "^4.15.7",
"typescript": "^5.4.5"
}
}

View File

@@ -0,0 +1,125 @@
-- CreateTable
CREATE TABLE "Vendor" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"businessNum" TEXT,
"taxSettings" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Role" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"name" TEXT NOT NULL,
"vendorId" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "User_vendorId_fkey" FOREIGN KEY ("vendorId") REFERENCES "Vendor" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "User_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "RefreshToken" (
"id" TEXT NOT NULL PRIMARY KEY,
"token" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expiresAt" DATETIME NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Category" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"vendorId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Category_vendorId_fkey" FOREIGN KEY ("vendorId") REFERENCES "Vendor" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Tax" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"rate" REAL NOT NULL,
"vendorId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Tax_vendorId_fkey" FOREIGN KEY ("vendorId") REFERENCES "Vendor" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Product" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"sku" TEXT,
"description" TEXT,
"price" REAL NOT NULL,
"vendorId" TEXT NOT NULL,
"categoryId" TEXT,
"taxId" TEXT,
"tags" TEXT,
"version" INTEGER NOT NULL DEFAULT 1,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Product_vendorId_fkey" FOREIGN KEY ("vendorId") REFERENCES "Vendor" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Product_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Product_taxId_fkey" FOREIGN KEY ("taxId") REFERENCES "Tax" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Transaction" (
"id" TEXT NOT NULL PRIMARY KEY,
"idempotencyKey" TEXT NOT NULL,
"vendorId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"status" TEXT NOT NULL,
"paymentMethod" TEXT NOT NULL,
"subtotal" REAL NOT NULL,
"taxTotal" REAL NOT NULL,
"discountTotal" REAL NOT NULL,
"total" REAL NOT NULL,
"notes" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Transaction_vendorId_fkey" FOREIGN KEY ("vendorId") REFERENCES "Vendor" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Transaction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "TransactionItem" (
"id" TEXT NOT NULL PRIMARY KEY,
"transactionId" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"productName" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"unitPrice" REAL NOT NULL,
"taxRate" REAL NOT NULL,
"discount" REAL NOT NULL,
"total" REAL NOT NULL,
CONSTRAINT "TransactionItem_transactionId_fkey" FOREIGN KEY ("transactionId") REFERENCES "Transaction" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "TransactionItem_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "Role_name_key" ON "Role"("name");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "RefreshToken_token_key" ON "RefreshToken"("token");
-- CreateIndex
CREATE UNIQUE INDEX "Transaction_idempotencyKey_key" ON "Transaction"("idempotencyKey");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "sqlite"

184
server/prisma/schema.prisma Normal file
View File

@@ -0,0 +1,184 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model Vendor {
id String @id @default(cuid())
name String
businessNum String?
taxSettings String? // JSON string
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
categories Category[]
products Product[]
taxes Tax[]
transactions Transaction[]
events Event[]
}
model Role {
id String @id @default(cuid())
name String @unique // admin | vendor | user
users User[]
}
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
name String
vendorId String
roleId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
vendor Vendor @relation(fields: [vendorId], references: [id])
role Role @relation(fields: [roleId], references: [id])
refreshTokens RefreshToken[]
transactions Transaction[]
}
model RefreshToken {
id String @id @default(cuid())
token String @unique
userId String
expiresAt DateTime
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Category {
id String @id @default(cuid())
name String
vendorId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
vendor Vendor @relation(fields: [vendorId], references: [id])
products Product[]
}
model Tax {
id String @id @default(cuid())
name String
rate Float
vendorId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
vendor Vendor @relation(fields: [vendorId], references: [id])
products Product[]
eventOverrides EventTax[]
}
model Product {
id String @id @default(cuid())
name String
sku String?
description String?
price Float
vendorId String
categoryId String?
taxId String?
tags String? // comma-separated or JSON string
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
vendor Vendor @relation(fields: [vendorId], references: [id])
category Category? @relation(fields: [categoryId], references: [id])
tax Tax? @relation(fields: [taxId], references: [id])
transactionItems TransactionItem[]
eventProducts EventProduct[]
}
model Transaction {
id String @id @default(cuid())
idempotencyKey String @unique
vendorId String
userId String
eventId String?
status String // pending | completed | failed | refunded
paymentMethod String // cash | card
subtotal Float
taxTotal Float
discountTotal Float
total Float
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
vendor Vendor @relation(fields: [vendorId], references: [id])
user User @relation(fields: [userId], references: [id])
event Event? @relation(fields: [eventId], references: [id])
items TransactionItem[]
}
model TransactionItem {
id String @id @default(cuid())
transactionId String
productId String
productName String
quantity Int
unitPrice Float
taxRate Float
discount Float
total Float
transaction Transaction @relation(fields: [transactionId], references: [id])
product Product @relation(fields: [productId], references: [id])
}
// ─── Events ───────────────────────────────────────────────────────────────────
model Event {
id String @id @default(cuid())
vendorId String
name String
description String?
startsAt DateTime
endsAt DateTime
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
vendor Vendor @relation(fields: [vendorId], references: [id])
taxOverrides EventTax[]
products EventProduct[]
transactions Transaction[]
}
// Tax rate overrides for a specific event. Shadows the vendor-level Tax for the
// event duration. Empty = use vendor defaults.
model EventTax {
id String @id @default(cuid())
eventId String
taxId String
rate Float // override rate in percent
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
tax Tax @relation(fields: [taxId], references: [id])
@@unique([eventId, taxId])
}
// Allowlist of products available at an event. Empty = all vendor products available.
model EventProduct {
id String @id @default(cuid())
eventId String
productId String
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id])
@@unique([eventId, productId])
}

53
server/prisma/seed.ts Normal file
View File

@@ -0,0 +1,53 @@
import { PrismaClient } from "@prisma/client";
import bcrypt from "bcryptjs";
const prisma = new PrismaClient();
async function main() {
// Seed roles
const adminRole = await prisma.role.upsert({
where: { name: "admin" },
update: {},
create: { name: "admin" },
});
await prisma.role.upsert({
where: { name: "vendor" },
update: {},
create: { name: "vendor" },
});
await prisma.role.upsert({
where: { name: "user" },
update: {},
create: { name: "user" },
});
// Seed demo vendor
const vendor = await prisma.vendor.upsert({
where: { id: "demo-vendor" },
update: {},
create: {
id: "demo-vendor",
name: "Demo Store",
businessNum: "123-456",
},
});
// Seed demo admin user
await prisma.user.upsert({
where: { email: "admin@demo.com" },
update: {},
create: {
email: "admin@demo.com",
passwordHash: await bcrypt.hash("password123", 10),
name: "Demo Admin",
vendorId: vendor.id,
roleId: adminRole.id,
},
});
console.log("Seed complete");
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());

54
server/src/app.ts Normal file
View File

@@ -0,0 +1,54 @@
import express from "express";
import cors from "cors";
import path from "path";
import healthRouter from "./routes/health.js";
import authRouter from "./routes/auth.js";
import vendorsRouter from "./routes/vendors.js";
import usersRouter from "./routes/users.js";
import categoriesRouter from "./routes/categories.js";
import taxesRouter from "./routes/taxes.js";
import productsRouter from "./routes/products.js";
import catalogRouter from "./routes/catalog.js";
import transactionsRouter from "./routes/transactions.js";
import eventsRouter from "./routes/events.js";
import { errorHandler } from "./middleware/errorHandler.js";
import { requestLogger } from "./middleware/requestLogger.js";
export function createApp() {
const app = express();
app.use(
cors({
origin: process.env.CORS_ORIGIN ?? "*",
credentials: true,
})
);
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(requestLogger);
// API routes
app.use("/api/v1", healthRouter);
app.use("/api/v1/auth", authRouter);
app.use("/api/v1/vendors", vendorsRouter);
app.use("/api/v1/users", usersRouter);
app.use("/api/v1/categories", categoriesRouter);
app.use("/api/v1/taxes", taxesRouter);
app.use("/api/v1/products", productsRouter);
app.use("/api/v1/catalog", catalogRouter);
app.use("/api/v1/transactions", transactionsRouter);
app.use("/api/v1/events", eventsRouter);
// Serve React admin UI static assets in production
if (process.env.NODE_ENV === "production") {
const clientDist = path.join(__dirname, "../../client/dist");
app.use(express.static(clientDist));
app.get(/^(?!\/api).*/, (_req, res) => {
res.sendFile(path.join(clientDist, "index.html"));
});
}
app.use(errorHandler);
return app;
}

35
server/src/index.ts Normal file
View File

@@ -0,0 +1,35 @@
import "dotenv/config";
import { createApp } from "./app.js";
import { prisma } from "./lib/prisma.js";
const PORT = Number(process.env.PORT ?? 8080);
async function main() {
// Verify DB connectivity on startup
try {
await prisma.$connect();
console.log("Database connected");
} catch (err) {
console.error("Failed to connect to database:", err);
process.exit(1);
}
const app = createApp();
const server = app.listen(PORT, () => {
console.log(`POS API running on port ${PORT} [${process.env.NODE_ENV ?? "development"}]`);
});
const shutdown = async () => {
console.log("Shutting down...");
server.close(async () => {
await prisma.$disconnect();
process.exit(0);
});
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
}
main();

38
server/src/lib/logger.ts Normal file
View File

@@ -0,0 +1,38 @@
/**
* Minimal structured logger.
* In production, swap the console calls for a real sink (Pino, Winston, etc.).
*/
type Level = "debug" | "info" | "warn" | "error";
const LEVEL_RANK: Record<Level, number> = { debug: 0, info: 1, warn: 2, error: 3 };
function currentLevel(): Level {
const env = (process.env.LOG_LEVEL ?? "info").toLowerCase() as Level;
return LEVEL_RANK[env] !== undefined ? env : "info";
}
function log(level: Level, msg: string, data?: unknown) {
if (LEVEL_RANK[level] < LEVEL_RANK[currentLevel()]) return;
const entry = {
ts: new Date().toISOString(),
level,
msg,
...(data !== undefined ? { data } : {}),
};
const line = JSON.stringify(entry);
if (level === "error") {
console.error(line);
} else if (level === "warn") {
console.warn(line);
} else {
console.log(line);
}
}
export const logger = {
debug: (msg: string, data?: unknown) => log("debug", msg, data),
info: (msg: string, data?: unknown) => log("info", msg, data),
warn: (msg: string, data?: unknown) => log("warn", msg, data),
error: (msg: string, data?: unknown) => log("error", msg, data),
};

View File

@@ -0,0 +1,27 @@
export interface PageParams {
page: number;
limit: number;
skip: number;
}
export function parsePage(query: Record<string, unknown>): PageParams {
const page = Math.max(1, parseInt(String(query.page ?? "1"), 10) || 1);
const limit = Math.min(100, Math.max(1, parseInt(String(query.limit ?? "20"), 10) || 20));
return { page, limit, skip: (page - 1) * limit };
}
export function paginatedResponse<T>(
data: T[],
total: number,
{ page, limit }: PageParams
) {
return {
data,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
};
}

View File

@@ -0,0 +1,66 @@
/**
* Payment provider abstraction.
*
* All payment processing goes through this interface so the rest of the
* codebase is decoupled from any specific provider SDK.
*
* Current implementations:
* - "cash" — no external call needed; always succeeds immediately.
* - "card" — stub that simulates a card terminal response.
* Replace processCard() body with a real provider SDK
* (Square, Stripe Terminal, Tyro, etc.) when ready.
*/
export type PaymentMethod = "cash" | "card";
export type PaymentStatus = "completed" | "failed" | "pending";
export interface PaymentRequest {
method: PaymentMethod;
amount: number; // in dollars (e.g. 12.50)
currency: string; // ISO 4217, e.g. "AUD"
reference: string; // idempotency key / order ref
metadata?: Record<string, string>;
}
export interface PaymentResult {
status: PaymentStatus;
providerRef?: string; // provider transaction ID
errorCode?: string;
errorMessage?: string;
}
// ─── Provider implementations ─────────────────────────────────────────────
async function processCash(_req: PaymentRequest): Promise<PaymentResult> {
return { status: "completed", providerRef: `cash-${Date.now()}` };
}
async function processCard(req: PaymentRequest): Promise<PaymentResult> {
// STUB — replace with real terminal SDK (Square, Stripe Terminal, etc.)
// Simulates ~300ms network latency and a 5% random failure rate for testing.
await new Promise((r) => setTimeout(r, 300));
if (Math.random() < 0.05) {
return { status: "failed", errorCode: "DECLINED", errorMessage: "Card declined (stub)" };
}
return {
status: "completed",
providerRef: `card-stub-${req.reference}-${Date.now()}`,
};
}
// ─── Public API ───────────────────────────────────────────────────────────
export async function processPayment(req: PaymentRequest): Promise<PaymentResult> {
switch (req.method) {
case "cash":
return processCash(req);
case "card":
return processCard(req);
default:
return {
status: "failed",
errorCode: "UNSUPPORTED_METHOD",
errorMessage: `Payment method "${req.method}" is not supported`,
};
}
}

18
server/src/lib/prisma.ts Normal file
View File

@@ -0,0 +1,18 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log:
process.env.NODE_ENV === "development"
? ["query", "error", "warn"]
: ["error"],
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}

View File

@@ -0,0 +1,35 @@
import { AuthenticatedRequest } from "../types/index.js";
/**
* Resolves the effective vendorId for a request.
* Admin users may pass ?vendorId= to operate on any vendor's data.
* All other roles are locked to their own vendorId.
*/
export function resolveVendorId(
authReq: AuthenticatedRequest,
query: Record<string, unknown> = {}
): string {
if (authReq.auth.roleName === "admin" && typeof query.vendorId === "string" && query.vendorId) {
return query.vendorId;
}
return authReq.auth.vendorId;
}
/**
* Returns a Prisma `where` fragment for vendor filtering on list queries.
* - Admin with ?vendorId= → filter to that vendor
* - Admin without ?vendorId= → no filter (sees all)
* - Other roles → always locked to own vendorId
*/
export function vendorWhereClause(
authReq: AuthenticatedRequest,
query: Record<string, unknown> = {}
): Record<string, string> {
if (authReq.auth.roleName === "admin") {
if (typeof query.vendorId === "string" && query.vendorId) {
return { vendorId: query.vendorId };
}
return {};
}
return { vendorId: authReq.auth.vendorId };
}

View File

@@ -0,0 +1,43 @@
import { Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
import { AuthPayload, AuthenticatedRequest } from "../types/index.js";
import { AppError } from "./errorHandler.js";
export function requireAuth(
req: AuthenticatedRequest,
_res: Response,
next: NextFunction
): void {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
return next(new AppError(401, "UNAUTHORIZED", "Missing or invalid token"));
}
const token = authHeader.slice(7);
const secret = process.env.JWT_SECRET;
if (!secret) {
return next(new AppError(500, "CONFIG_ERROR", "JWT secret not configured"));
}
try {
const payload = jwt.verify(token, secret) as AuthPayload;
req.auth = payload;
next();
} catch {
next(new AppError(401, "UNAUTHORIZED", "Invalid or expired token"));
}
}
export function requireRole(...roles: string[]) {
return (req: AuthenticatedRequest, _res: Response, next: NextFunction) => {
if (!req.auth) {
return next(new AppError(401, "UNAUTHORIZED", "Not authenticated"));
}
if (!roles.includes(req.auth.roleName)) {
return next(
new AppError(403, "FORBIDDEN", "Insufficient permissions")
);
}
next();
};
}

View File

@@ -0,0 +1,52 @@
import { Request, Response, NextFunction } from "express";
import { ZodError } from "zod";
import { logger } from "../lib/logger.js";
export class AppError extends Error {
constructor(
public statusCode: number,
public code: string,
message: string,
public details?: unknown
) {
super(message);
this.name = "AppError";
}
}
export function errorHandler(
err: unknown,
_req: Request,
res: Response,
_next: NextFunction
): void {
if (err instanceof AppError) {
res.status(err.statusCode).json({
error: {
code: err.code,
message: err.message,
details: err.details,
},
});
return;
}
if (err instanceof ZodError) {
res.status(422).json({
error: {
code: "VALIDATION_ERROR",
message: "Invalid request data",
details: err.flatten(),
},
});
return;
}
logger.error("unhandled_error", err instanceof Error ? { message: err.message, stack: err.stack } : err);
res.status(500).json({
error: {
code: "INTERNAL_ERROR",
message: "An unexpected error occurred",
},
});
}

View File

@@ -0,0 +1,18 @@
import { Request, Response, NextFunction } from "express";
import { logger } from "../lib/logger.js";
export function requestLogger(req: Request, res: Response, next: NextFunction) {
const start = Date.now();
res.on("finish", () => {
const ms = Date.now() - start;
const level = res.statusCode >= 500 ? "error" : res.statusCode >= 400 ? "warn" : "info";
logger[level]("http", {
method: req.method,
path: req.path,
status: res.statusCode,
ms,
ip: req.ip,
});
});
next();
}

198
server/src/routes/auth.ts Normal file
View File

@@ -0,0 +1,198 @@
import { Router, Request, Response, NextFunction } from "express";
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import { z } from "zod";
import { prisma } from "../lib/prisma.js";
import { AppError } from "../middleware/errorHandler.js";
import { requireAuth } from "../middleware/auth.js";
import { AuthenticatedRequest } from "../types/index.js";
const router = Router();
const ACCESS_TOKEN_TTL = "15m";
const REFRESH_TOKEN_TTL = "7d";
const REFRESH_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
function signAccessToken(payload: object): string {
const secret = process.env.JWT_SECRET;
if (!secret) throw new Error("JWT_SECRET not configured");
return jwt.sign(payload, secret, { expiresIn: ACCESS_TOKEN_TTL });
}
function signRefreshToken(payload: object): string {
const secret = process.env.JWT_SECRET;
if (!secret) throw new Error("JWT_SECRET not configured");
return jwt.sign(payload, secret, { expiresIn: REFRESH_TOKEN_TTL });
}
// POST /api/v1/auth/login
const LoginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
router.post(
"/login",
async (req: Request, res: Response, next: NextFunction) => {
try {
const body = LoginSchema.parse(req.body);
const user = await prisma.user.findUnique({
where: { email: body.email },
include: { role: true, vendor: true },
});
if (!user || !(await bcrypt.compare(body.password, user.passwordHash))) {
throw new AppError(401, "INVALID_CREDENTIALS", "Invalid email or password");
}
const tokenPayload = {
userId: user.id,
vendorId: user.vendorId,
roleId: user.roleId,
roleName: user.role.name,
};
const accessToken = signAccessToken(tokenPayload);
const refreshToken = signRefreshToken({ userId: user.id });
await prisma.refreshToken.create({
data: {
token: refreshToken,
userId: user.id,
expiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS),
},
});
res.json({
accessToken,
refreshToken,
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role.name,
vendorId: user.vendorId,
vendorName: user.vendor.name,
},
});
} catch (err) {
next(err);
}
}
);
// POST /api/v1/auth/refresh
const RefreshSchema = z.object({
refreshToken: z.string().min(1),
});
router.post(
"/refresh",
async (req: Request, res: Response, next: NextFunction) => {
try {
const { refreshToken } = RefreshSchema.parse(req.body);
const secret = process.env.JWT_SECRET;
if (!secret) throw new AppError(500, "CONFIG_ERROR", "JWT secret not configured");
let decoded: { userId: string };
try {
decoded = jwt.verify(refreshToken, secret) as { userId: string };
} catch {
throw new AppError(401, "INVALID_TOKEN", "Invalid or expired refresh token");
}
const stored = await prisma.refreshToken.findUnique({
where: { token: refreshToken },
include: { user: { include: { role: true } } },
});
if (!stored || stored.userId !== decoded.userId || stored.expiresAt < new Date()) {
throw new AppError(401, "INVALID_TOKEN", "Refresh token not found or expired");
}
// Rotate refresh token
await prisma.refreshToken.delete({ where: { id: stored.id } });
const user = stored.user;
const tokenPayload = {
userId: user.id,
vendorId: user.vendorId,
roleId: user.roleId,
roleName: user.role.name,
};
const newAccessToken = signAccessToken(tokenPayload);
const newRefreshToken = signRefreshToken({ userId: user.id });
await prisma.refreshToken.create({
data: {
token: newRefreshToken,
userId: user.id,
expiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS),
},
});
res.json({ accessToken: newAccessToken, refreshToken: newRefreshToken });
} catch (err) {
next(err);
}
}
);
// POST /api/v1/auth/logout
router.post(
"/logout",
requireAuth as unknown as (req: Request, res: Response, next: NextFunction) => void,
async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const { refreshToken } = z.object({ refreshToken: z.string().optional() }).parse(req.body);
if (refreshToken) {
await prisma.refreshToken.deleteMany({
where: { token: refreshToken, userId: authReq.auth.userId },
});
} else {
// Logout all devices
await prisma.refreshToken.deleteMany({
where: { userId: authReq.auth.userId },
});
}
res.json({ message: "Logged out successfully" });
} catch (err) {
next(err);
}
}
);
// GET /api/v1/auth/me
router.get(
"/me",
requireAuth as unknown as (req: Request, res: Response, next: NextFunction) => void,
async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const user = await prisma.user.findUnique({
where: { id: authReq.auth.userId },
include: { role: true, vendor: true },
});
if (!user) throw new AppError(404, "NOT_FOUND", "User not found");
res.json({
id: user.id,
email: user.email,
name: user.name,
role: user.role.name,
vendorId: user.vendorId,
vendorName: user.vendor.name,
});
} catch (err) {
next(err);
}
}
);
export default router;

View File

@@ -0,0 +1,83 @@
import { Router, Request, Response, NextFunction } from "express";
import { prisma } from "../lib/prisma.js";
import { requireAuth } from "../middleware/auth.js";
import { AuthenticatedRequest } from "../types/index.js";
const router = Router();
const auth = requireAuth as unknown as (r: Request, s: Response, n: NextFunction) => void;
/**
* GET /api/v1/catalog/sync?since=<ISO8601>
*
* Delta-sync endpoint for Android offline-first client.
* Returns all catalog entities updated after `since` (or all if omitted).
* Response is versioned so Android can detect stale caches.
*/
router.get("/sync", auth, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const { vendorId } = authReq.auth;
const sinceRaw = req.query.since as string | undefined;
const since = sinceRaw ? new Date(sinceRaw) : undefined;
if (since && isNaN(since.getTime())) {
res.status(400).json({
error: { code: "BAD_REQUEST", message: "Invalid `since` date" },
});
return;
}
const updatedAfter = since ? { updatedAt: { gt: since } } : {};
const now = new Date();
const [products, categories, taxes, events] = await Promise.all([
prisma.product.findMany({
where: { vendorId, ...updatedAfter },
include: { category: true, tax: true },
orderBy: { updatedAt: "asc" },
}),
prisma.category.findMany({
where: { vendorId, ...updatedAfter },
orderBy: { updatedAt: "asc" },
}),
prisma.tax.findMany({
where: { vendorId, ...updatedAfter },
orderBy: { updatedAt: "asc" },
}),
// Active events (currently running or upcoming within range)
prisma.event.findMany({
where: {
vendorId,
isActive: true,
endsAt: { gte: now },
...(since ? { updatedAt: { gt: since } } : {}),
},
include: {
taxOverrides: true,
products: { select: { productId: true } },
},
orderBy: { startsAt: "asc" },
}),
]);
res.json({
syncedAt: new Date().toISOString(),
since: since?.toISOString() ?? null,
products,
categories,
taxes,
events,
counts: {
products: products.length,
categories: categories.length,
taxes: taxes.length,
events: events.length,
},
});
} catch (err) {
next(err);
}
});
export default router;

View File

@@ -0,0 +1,80 @@
import { Router, Request, Response, NextFunction } from "express";
import { z } from "zod";
import { prisma } from "../lib/prisma.js";
import { requireAuth, requireRole } from "../middleware/auth.js";
import { AppError } from "../middleware/errorHandler.js";
import { parsePage, paginatedResponse } from "../lib/pagination.js";
import { AuthenticatedRequest } from "../types/index.js";
import { resolveVendorId } from "../lib/vendorScope.js";
const router = Router();
const auth = requireAuth as unknown as (r: Request, s: Response, n: NextFunction) => void;
const vendorUp = requireRole("admin", "vendor") as unknown as (r: Request, s: Response, n: NextFunction) => void;
const CategorySchema = z.object({ name: z.string().min(1).max(100) });
router.get("/", auth, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const { page, limit, skip } = parsePage(req.query as Record<string, unknown>);
const vendorId = resolveVendorId(authReq, req.query as Record<string, unknown>);
const where = { vendorId };
const [data, total] = await Promise.all([
prisma.category.findMany({ where, skip, take: limit, orderBy: { name: "asc" } }),
prisma.category.count({ where }),
]);
res.json(paginatedResponse(data, total, { page, limit, skip }));
} catch (err) { next(err); }
});
router.get("/:id", auth, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const isAdmin = authReq.auth.roleName === "admin";
const cat = await prisma.category.findFirst({
where: { id: req.params.id, ...(isAdmin ? {} : { vendorId: authReq.auth.vendorId }) },
});
if (!cat) throw new AppError(404, "NOT_FOUND", "Category not found");
res.json(cat);
} catch (err) { next(err); }
});
router.post("/", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const vendorId = resolveVendorId(authReq, req.query as Record<string, unknown>);
const body = CategorySchema.parse(req.body);
const cat = await prisma.category.create({ data: { ...body, vendorId } });
res.status(201).json(cat);
} catch (err) { next(err); }
});
router.put("/:id", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const isAdmin = authReq.auth.roleName === "admin";
const existing = await prisma.category.findFirst({
where: { id: req.params.id, ...(isAdmin ? {} : { vendorId: authReq.auth.vendorId }) },
});
if (!existing) throw new AppError(404, "NOT_FOUND", "Category not found");
const body = CategorySchema.parse(req.body);
const cat = await prisma.category.update({ where: { id: req.params.id }, data: body });
res.json(cat);
} catch (err) { next(err); }
});
router.delete("/:id", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const isAdmin = authReq.auth.roleName === "admin";
const existing = await prisma.category.findFirst({
where: { id: req.params.id, ...(isAdmin ? {} : { vendorId: authReq.auth.vendorId }) },
});
if (!existing) throw new AppError(404, "NOT_FOUND", "Category not found");
await prisma.category.delete({ where: { id: req.params.id } });
res.status(204).send();
} catch (err) { next(err); }
});
export default router;

371
server/src/routes/events.ts Normal file
View File

@@ -0,0 +1,371 @@
import { Router, Request, Response, NextFunction } from "express";
import { z } from "zod";
import { prisma } from "../lib/prisma.js";
import { requireAuth, requireRole } from "../middleware/auth.js";
import { AppError } from "../middleware/errorHandler.js";
import { parsePage, paginatedResponse } from "../lib/pagination.js";
import { AuthenticatedRequest } from "../types/index.js";
import { resolveVendorId, vendorWhereClause } from "../lib/vendorScope.js";
const router = Router();
const auth = requireAuth as unknown as (r: Request, s: Response, n: NextFunction) => void;
const vendorUp = requireRole("admin", "vendor") as unknown as (r: Request, s: Response, n: NextFunction) => void;
const EventSchema = z.object({
name: z.string().min(1).max(200),
description: z.string().max(1000).optional(),
startsAt: z.string().datetime(),
endsAt: z.string().datetime(),
isActive: z.boolean().default(true),
});
const EventTaxSchema = z.object({
taxId: z.string().min(1),
rate: z.number().min(0).max(100),
});
const EventProductSchema = z.object({
productId: z.string().min(1),
});
// Helper: resolve vendorId scope (admin sees all, vendor sees own)
function vendorScope(authReq: AuthenticatedRequest) {
return authReq.auth.roleName === "admin" ? {} : { vendorId: authReq.auth.vendorId };
}
// ─── GET /api/v1/events ────────────────────────────────────────────────────
router.get("/", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const { page, limit, skip } = parsePage(req.query as Record<string, unknown>);
const where = vendorWhereClause(authReq, req.query as Record<string, unknown>);
const [data, total] = await Promise.all([
prisma.event.findMany({
where,
skip,
take: limit,
orderBy: { startsAt: "asc" },
include: {
vendor: { select: { id: true, name: true } },
_count: { select: { products: true, taxOverrides: true, transactions: true } },
},
}),
prisma.event.count({ where }),
]);
res.json(paginatedResponse(data, total, { page, limit, skip }));
} catch (err) {
next(err);
}
});
// ─── POST /api/v1/events ───────────────────────────────────────────────────
router.post("/", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const body = EventSchema.parse(req.body);
if (new Date(body.endsAt) <= new Date(body.startsAt)) {
throw new AppError(400, "BAD_REQUEST", "endsAt must be after startsAt");
}
const targetVendorId = resolveVendorId(authReq, req.query as Record<string, unknown>);
const event = await prisma.event.create({
data: {
...body,
startsAt: new Date(body.startsAt),
endsAt: new Date(body.endsAt),
vendorId: targetVendorId,
},
include: { vendor: { select: { id: true, name: true } } },
});
res.status(201).json(event);
} catch (err) {
next(err);
}
});
// ─── GET /api/v1/events/:id ────────────────────────────────────────────────
router.get("/:id", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const event = await prisma.event.findFirst({
where: { id: req.params.id, ...vendorScope(authReq) },
include: {
vendor: { select: { id: true, name: true } },
taxOverrides: { include: { tax: true } },
products: { include: { product: { select: { id: true, name: true, price: true, sku: true } } } },
},
});
if (!event) throw new AppError(404, "NOT_FOUND", "Event not found");
res.json(event);
} catch (err) {
next(err);
}
});
// ─── PUT /api/v1/events/:id ────────────────────────────────────────────────
router.put("/:id", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const existing = await prisma.event.findFirst({
where: { id: req.params.id, ...vendorScope(authReq) },
});
if (!existing) throw new AppError(404, "NOT_FOUND", "Event not found");
const body = EventSchema.parse(req.body);
if (new Date(body.endsAt) <= new Date(body.startsAt)) {
throw new AppError(400, "BAD_REQUEST", "endsAt must be after startsAt");
}
const event = await prisma.event.update({
where: { id: req.params.id },
data: { ...body, startsAt: new Date(body.startsAt), endsAt: new Date(body.endsAt) },
include: { vendor: { select: { id: true, name: true } } },
});
res.json(event);
} catch (err) {
next(err);
}
});
// ─── DELETE /api/v1/events/:id ─────────────────────────────────────────────
router.delete("/:id", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const existing = await prisma.event.findFirst({
where: { id: req.params.id, ...vendorScope(authReq) },
});
if (!existing) throw new AppError(404, "NOT_FOUND", "Event not found");
await prisma.event.delete({ where: { id: req.params.id } });
res.status(204).send();
} catch (err) {
next(err);
}
});
// ─── Tax overrides ─────────────────────────────────────────────────────────
// PUT /api/v1/events/:id/taxes — upsert a tax override (idempotent)
router.put("/:id/taxes", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const event = await prisma.event.findFirst({
where: { id: req.params.id, ...vendorScope(authReq) },
});
if (!event) throw new AppError(404, "NOT_FOUND", "Event not found");
const { taxId, rate } = EventTaxSchema.parse(req.body);
// Verify tax belongs to same vendor
const tax = await prisma.tax.findFirst({ where: { id: taxId, vendorId: event.vendorId } });
if (!tax) throw new AppError(404, "NOT_FOUND", "Tax not found");
const override = await prisma.eventTax.upsert({
where: { eventId_taxId: { eventId: event.id, taxId } },
create: { eventId: event.id, taxId, rate },
update: { rate },
include: { tax: true },
});
res.json(override);
} catch (err) {
next(err);
}
});
// DELETE /api/v1/events/:id/taxes/:taxId
router.delete("/:id/taxes/:taxId", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const event = await prisma.event.findFirst({
where: { id: req.params.id, ...vendorScope(authReq) },
});
if (!event) throw new AppError(404, "NOT_FOUND", "Event not found");
const existing = await prisma.eventTax.findUnique({
where: { eventId_taxId: { eventId: event.id, taxId: req.params.taxId } },
});
if (!existing) throw new AppError(404, "NOT_FOUND", "Tax override not found");
await prisma.eventTax.delete({
where: { eventId_taxId: { eventId: event.id, taxId: req.params.taxId } },
});
res.status(204).send();
} catch (err) {
next(err);
}
});
// ─── Product allowlist ─────────────────────────────────────────────────────
// GET /api/v1/events/:id/products
router.get("/:id/products", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const event = await prisma.event.findFirst({
where: { id: req.params.id, ...vendorScope(authReq) },
});
if (!event) throw new AppError(404, "NOT_FOUND", "Event not found");
const items = await prisma.eventProduct.findMany({
where: { eventId: event.id },
include: { product: { select: { id: true, name: true, price: true, sku: true } } },
});
res.json(items);
} catch (err) {
next(err);
}
});
// POST /api/v1/events/:id/products — add product to allowlist
router.post("/:id/products", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const event = await prisma.event.findFirst({
where: { id: req.params.id, ...vendorScope(authReq) },
});
if (!event) throw new AppError(404, "NOT_FOUND", "Event not found");
const { productId } = EventProductSchema.parse(req.body);
// Verify product belongs to same vendor
const product = await prisma.product.findFirst({ where: { id: productId, vendorId: event.vendorId } });
if (!product) throw new AppError(404, "NOT_FOUND", "Product not found");
const item = await prisma.eventProduct.upsert({
where: { eventId_productId: { eventId: event.id, productId } },
create: { eventId: event.id, productId },
update: {},
include: { product: { select: { id: true, name: true, price: true, sku: true } } },
});
res.status(201).json(item);
} catch (err) {
next(err);
}
});
// DELETE /api/v1/events/:id/products/:productId
router.delete("/:id/products/:productId", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const event = await prisma.event.findFirst({
where: { id: req.params.id, ...vendorScope(authReq) },
});
if (!event) throw new AppError(404, "NOT_FOUND", "Event not found");
const existing = await prisma.eventProduct.findUnique({
where: { eventId_productId: { eventId: event.id, productId: req.params.productId } },
});
if (!existing) throw new AppError(404, "NOT_FOUND", "Product not in event allowlist");
await prisma.eventProduct.delete({
where: { eventId_productId: { eventId: event.id, productId: req.params.productId } },
});
res.status(204).send();
} catch (err) {
next(err);
}
});
// ─── GET /api/v1/events/:id/transactions ──────────────────────────────────
router.get("/:id/transactions", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const event = await prisma.event.findFirst({
where: { id: req.params.id, ...vendorScope(authReq) },
});
if (!event) throw new AppError(404, "NOT_FOUND", "Event not found");
const { page, limit, skip } = parsePage(req.query as Record<string, unknown>);
const where = { eventId: event.id };
const [data, total] = await Promise.all([
prisma.transaction.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: "desc" },
include: { user: { select: { id: true, name: true, email: true } }, items: true },
}),
prisma.transaction.count({ where }),
]);
res.json(paginatedResponse(data, total, { page, limit, skip }));
} catch (err) {
next(err);
}
});
// ─── GET /api/v1/events/:id/reports/summary ───────────────────────────────
router.get("/:id/reports/summary", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const event = await prisma.event.findFirst({
where: { id: req.params.id, ...vendorScope(authReq) },
});
if (!event) throw new AppError(404, "NOT_FOUND", "Event not found");
const where = { eventId: event.id, status: "completed" };
const [totals, byPayment, topProducts] = await Promise.all([
prisma.transaction.aggregate({
where,
_sum: { total: true, taxTotal: true, discountTotal: true, subtotal: true },
_count: { id: true },
_avg: { total: true },
}),
prisma.transaction.groupBy({
by: ["paymentMethod"],
where,
_sum: { total: true },
_count: { id: true },
}),
prisma.transactionItem.groupBy({
by: ["productId", "productName"],
where: { transaction: where },
_sum: { total: true, quantity: true },
orderBy: { _sum: { total: "desc" } },
take: 10,
}),
]);
res.json({
event: { id: event.id, name: event.name, startsAt: event.startsAt, endsAt: event.endsAt },
totals: {
revenue: totals._sum.total ?? 0,
subtotal: totals._sum.subtotal ?? 0,
tax: totals._sum.taxTotal ?? 0,
discounts: totals._sum.discountTotal ?? 0,
transactionCount: totals._count.id,
averageTransaction: totals._avg.total ?? 0,
},
byPaymentMethod: byPayment.map((r) => ({
method: r.paymentMethod,
revenue: r._sum.total ?? 0,
count: r._count.id,
})),
topProducts: topProducts.map((r) => ({
productId: r.productId,
productName: r.productName,
revenue: r._sum.total ?? 0,
unitsSold: r._sum.quantity ?? 0,
})),
});
} catch (err) {
next(err);
}
});
export default router;

View File

@@ -0,0 +1,25 @@
import { Router, Request, Response } from "express";
import { prisma } from "../lib/prisma.js";
const router = Router();
router.get("/health", async (_req: Request, res: Response) => {
let dbStatus = "ok";
try {
await prisma.$queryRaw`SELECT 1`;
} catch {
dbStatus = "error";
}
const status = dbStatus === "ok" ? "ok" : "degraded";
res.status(status === "ok" ? 200 : 503).json({
status,
timestamp: new Date().toISOString(),
version: process.env.npm_package_version ?? "0.1.0",
services: {
database: dbStatus,
},
});
});
export default router;

View File

@@ -0,0 +1,104 @@
import { Router, Request, Response, NextFunction } from "express";
import { z } from "zod";
import { prisma } from "../lib/prisma.js";
import { requireAuth, requireRole } from "../middleware/auth.js";
import { AppError } from "../middleware/errorHandler.js";
import { parsePage, paginatedResponse } from "../lib/pagination.js";
import { AuthenticatedRequest } from "../types/index.js";
import { resolveVendorId } from "../lib/vendorScope.js";
const router = Router();
const auth = requireAuth as unknown as (r: Request, s: Response, n: NextFunction) => void;
const vendorUp = requireRole("admin", "vendor") as unknown as (r: Request, s: Response, n: NextFunction) => void;
const ProductSchema = z.object({
name: z.string().min(1).max(200),
sku: z.string().max(100).optional(),
description: z.string().max(1000).optional(),
price: z.number().min(0),
categoryId: z.string().optional().nullable(),
taxId: z.string().optional().nullable(),
tags: z.string().max(500).optional(),
});
router.get("/", auth, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const { page, limit, skip } = parsePage(req.query as Record<string, unknown>);
const { categoryId, search } = req.query as { categoryId?: string; search?: string };
const vendorId = resolveVendorId(authReq, req.query as Record<string, unknown>);
const where = {
vendorId,
...(categoryId ? { categoryId } : {}),
...(search ? { name: { contains: search } } : {}),
};
const [data, total] = await Promise.all([
prisma.product.findMany({ where, skip, take: limit, orderBy: { name: "asc" }, include: { category: true, tax: true } }),
prisma.product.count({ where }),
]);
res.json(paginatedResponse(data, total, { page, limit, skip }));
} catch (err) { next(err); }
});
router.get("/:id", auth, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const isAdmin = authReq.auth.roleName === "admin";
const product = await prisma.product.findFirst({
where: { id: req.params.id, ...(isAdmin ? {} : { vendorId: authReq.auth.vendorId }) },
include: { category: true, tax: true },
});
if (!product) throw new AppError(404, "NOT_FOUND", "Product not found");
res.json(product);
} catch (err) { next(err); }
});
router.post("/", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const vendorId = resolveVendorId(authReq, req.query as Record<string, unknown>);
const body = ProductSchema.parse(req.body);
const product = await prisma.product.create({
data: { ...body, vendorId },
include: { category: true, tax: true },
});
res.status(201).json(product);
} catch (err) { next(err); }
});
router.put("/:id", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const isAdmin = authReq.auth.roleName === "admin";
const existing = await prisma.product.findFirst({
where: { id: req.params.id, ...(isAdmin ? {} : { vendorId: authReq.auth.vendorId }) },
});
if (!existing) throw new AppError(404, "NOT_FOUND", "Product not found");
const body = ProductSchema.parse(req.body);
const product = await prisma.product.update({
where: { id: req.params.id },
data: { ...body, version: { increment: 1 } },
include: { category: true, tax: true },
});
res.json(product);
} catch (err) { next(err); }
});
router.delete("/:id", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const isAdmin = authReq.auth.roleName === "admin";
const existing = await prisma.product.findFirst({
where: { id: req.params.id, ...(isAdmin ? {} : { vendorId: authReq.auth.vendorId }) },
});
if (!existing) throw new AppError(404, "NOT_FOUND", "Product not found");
await prisma.product.delete({ where: { id: req.params.id } });
res.status(204).send();
} catch (err) { next(err); }
});
export default router;

View File

@@ -0,0 +1,83 @@
import { Router, Request, Response, NextFunction } from "express";
import { z } from "zod";
import { prisma } from "../lib/prisma.js";
import { requireAuth, requireRole } from "../middleware/auth.js";
import { AppError } from "../middleware/errorHandler.js";
import { parsePage, paginatedResponse } from "../lib/pagination.js";
import { AuthenticatedRequest } from "../types/index.js";
import { resolveVendorId } from "../lib/vendorScope.js";
const router = Router();
const auth = requireAuth as unknown as (r: Request, s: Response, n: NextFunction) => void;
const vendorUp = requireRole("admin", "vendor") as unknown as (r: Request, s: Response, n: NextFunction) => void;
const TaxSchema = z.object({
name: z.string().min(1).max(100),
rate: z.number().min(0).max(100),
});
router.get("/", auth, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const { page, limit, skip } = parsePage(req.query as Record<string, unknown>);
const vendorId = resolveVendorId(authReq, req.query as Record<string, unknown>);
const where = { vendorId };
const [data, total] = await Promise.all([
prisma.tax.findMany({ where, skip, take: limit, orderBy: { name: "asc" } }),
prisma.tax.count({ where }),
]);
res.json(paginatedResponse(data, total, { page, limit, skip }));
} catch (err) { next(err); }
});
router.get("/:id", auth, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const isAdmin = authReq.auth.roleName === "admin";
const tax = await prisma.tax.findFirst({
where: { id: req.params.id, ...(isAdmin ? {} : { vendorId: authReq.auth.vendorId }) },
});
if (!tax) throw new AppError(404, "NOT_FOUND", "Tax not found");
res.json(tax);
} catch (err) { next(err); }
});
router.post("/", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const vendorId = resolveVendorId(authReq, req.query as Record<string, unknown>);
const body = TaxSchema.parse(req.body);
const tax = await prisma.tax.create({ data: { ...body, vendorId } });
res.status(201).json(tax);
} catch (err) { next(err); }
});
router.put("/:id", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const isAdmin = authReq.auth.roleName === "admin";
const existing = await prisma.tax.findFirst({
where: { id: req.params.id, ...(isAdmin ? {} : { vendorId: authReq.auth.vendorId }) },
});
if (!existing) throw new AppError(404, "NOT_FOUND", "Tax not found");
const body = TaxSchema.parse(req.body);
const tax = await prisma.tax.update({ where: { id: req.params.id }, data: body });
res.json(tax);
} catch (err) { next(err); }
});
router.delete("/:id", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const isAdmin = authReq.auth.roleName === "admin";
const existing = await prisma.tax.findFirst({
where: { id: req.params.id, ...(isAdmin ? {} : { vendorId: authReq.auth.vendorId }) },
});
if (!existing) throw new AppError(404, "NOT_FOUND", "Tax not found");
await prisma.tax.delete({ where: { id: req.params.id } });
res.status(204).send();
} catch (err) { next(err); }
});
export default router;

View File

@@ -0,0 +1,496 @@
import { Router, Request, Response, NextFunction } from "express";
import { z } from "zod";
import { prisma } from "../lib/prisma.js";
import { requireAuth, requireRole } from "../middleware/auth.js";
import { AppError } from "../middleware/errorHandler.js";
import { parsePage, paginatedResponse } from "../lib/pagination.js";
import { AuthenticatedRequest } from "../types/index.js";
import { logger } from "../lib/logger.js";
import { processPayment } from "../lib/payments.js";
const router = Router();
const auth = requireAuth as unknown as (r: Request, s: Response, n: NextFunction) => void;
const vendorUp = requireRole("admin", "vendor") as unknown as (r: Request, s: Response, n: NextFunction) => void;
// ─── Schemas ──────────────────────────────────────────────────────────────
const TransactionItemSchema = z.object({
productId: z.string().min(1),
productName: z.string().min(1),
quantity: z.number().int().positive(),
unitPrice: z.number().min(0),
taxRate: z.number().min(0).max(100),
discount: z.number().min(0).default(0),
total: z.number().min(0),
});
const TransactionSchema = z.object({
idempotencyKey: z.string().min(1).max(200),
status: z.enum(["pending", "completed", "failed", "refunded"]),
paymentMethod: z.enum(["cash", "card"]),
subtotal: z.number().min(0),
taxTotal: z.number().min(0),
discountTotal: z.number().min(0),
total: z.number().min(0),
notes: z.string().max(500).optional(),
items: z.array(TransactionItemSchema).min(1),
eventId: z.string().optional(),
// Android includes a local timestamp for ordering
createdAt: z.string().datetime().optional(),
});
const BatchSchema = z.object({
transactions: z.array(TransactionSchema).min(1).max(500),
});
// ─── POST /api/v1/transactions/batch ──────────────────────────────────────
// Android pushes locally-recorded transactions. Server is authoritative on
// payments — duplicate idempotency keys are silently skipped (already processed).
router.post("/batch", auth, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const { vendorId, userId } = authReq.auth;
const { transactions } = BatchSchema.parse(req.body);
const results: Array<{ idempotencyKey: string; status: "created" | "duplicate" | "error"; id?: string; error?: string }> = [];
for (const tx of transactions) {
try {
// Check for existing transaction with same idempotency key
const existing = await prisma.transaction.findUnique({
where: { idempotencyKey: tx.idempotencyKey },
select: { id: true },
});
if (existing) {
results.push({ idempotencyKey: tx.idempotencyKey, status: "duplicate", id: existing.id });
continue;
}
// Validate all product IDs belong to this vendor
const productIds = tx.items.map((i) => i.productId);
const products = await prisma.product.findMany({
where: { id: { in: productIds }, vendorId },
select: { id: true },
});
const validIds = new Set(products.map((p) => p.id));
const invalidIds = productIds.filter((id) => !validIds.has(id));
if (invalidIds.length > 0) {
results.push({
idempotencyKey: tx.idempotencyKey,
status: "error",
error: `Invalid product IDs: ${invalidIds.join(", ")}`,
});
continue;
}
const created = await prisma.transaction.create({
data: {
idempotencyKey: tx.idempotencyKey,
vendorId,
userId,
status: tx.status,
paymentMethod: tx.paymentMethod,
subtotal: tx.subtotal,
taxTotal: tx.taxTotal,
discountTotal: tx.discountTotal,
total: tx.total,
notes: tx.notes,
...(tx.eventId ? { eventId: tx.eventId } : {}),
...(tx.createdAt ? { createdAt: new Date(tx.createdAt) } : {}),
items: {
create: tx.items.map((item) => ({
productId: item.productId,
productName: item.productName,
quantity: item.quantity,
unitPrice: item.unitPrice,
taxRate: item.taxRate,
discount: item.discount,
total: item.total,
})),
},
},
select: { id: true },
});
results.push({ idempotencyKey: tx.idempotencyKey, status: "created", id: created.id });
} catch (err) {
results.push({
idempotencyKey: tx.idempotencyKey,
status: "error",
error: err instanceof Error ? err.message : "Unknown error",
});
}
}
const created = results.filter((r) => r.status === "created").length;
const duplicates = results.filter((r) => r.status === "duplicate").length;
const errors = results.filter((r) => r.status === "error").length;
res.status(207).json({ results, summary: { created, duplicates, errors } });
} catch (err) {
next(err);
}
});
// ─── POST /api/v1/transactions (single, real-time) ────────────────────────
// Used by the Android POS for live transactions. Runs through the payment
// abstraction before persisting; returns immediately with success/failure.
const SingleTransactionSchema = z.object({
idempotencyKey: z.string().min(1).max(200),
paymentMethod: z.enum(["cash", "card"]),
subtotal: z.number().min(0),
taxTotal: z.number().min(0),
discountTotal: z.number().min(0),
total: z.number().min(0),
notes: z.string().max(500).optional(),
items: z.array(TransactionItemSchema).min(1),
eventId: z.string().optional(),
});
router.post("/", auth, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const { vendorId, userId } = authReq.auth;
const body = SingleTransactionSchema.parse(req.body);
// Idempotency guard
const existing = await prisma.transaction.findUnique({
where: { idempotencyKey: body.idempotencyKey },
});
if (existing) {
res.json(existing);
return;
}
// Run payment through provider abstraction
const paymentResult = await processPayment({
method: body.paymentMethod,
amount: body.total,
currency: process.env.CURRENCY ?? "AUD",
reference: body.idempotencyKey,
});
const status = paymentResult.status === "completed" ? "completed"
: paymentResult.status === "pending" ? "pending"
: "failed";
const tx = await prisma.transaction.create({
data: {
idempotencyKey: body.idempotencyKey,
vendorId,
userId,
status,
paymentMethod: body.paymentMethod,
subtotal: body.subtotal,
taxTotal: body.taxTotal,
discountTotal: body.discountTotal,
total: body.total,
notes: body.notes,
...(body.eventId ? { eventId: body.eventId } : {}),
items: {
create: body.items.map((item) => ({
productId: item.productId,
productName: item.productName,
quantity: item.quantity,
unitPrice: item.unitPrice,
taxRate: item.taxRate,
discount: item.discount,
total: item.total,
})),
},
},
include: { items: true },
});
logger.info("transaction.created", {
id: tx.id,
status,
total: tx.total,
providerRef: paymentResult.providerRef,
});
res.status(status === "completed" ? 201 : 202).json({
...tx,
payment: {
status: paymentResult.status,
providerRef: paymentResult.providerRef,
errorCode: paymentResult.errorCode,
errorMessage: paymentResult.errorMessage,
},
});
} catch (err) {
next(err);
}
});
// ─── GET /api/v1/transactions ──────────────────────────────────────────────
router.get("/", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const { vendorId } = authReq.auth;
const { page, limit, skip } = parsePage(req.query as Record<string, unknown>);
const { status, paymentMethod, from, to } = req.query as Record<string, string>;
const where = {
vendorId,
...(status ? { status } : {}),
...(paymentMethod ? { paymentMethod } : {}),
...(from || to
? {
createdAt: {
...(from ? { gte: new Date(from) } : {}),
...(to ? { lte: new Date(to) } : {}),
},
}
: {}),
};
const [data, total] = await Promise.all([
prisma.transaction.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: "desc" },
include: { user: { select: { id: true, name: true, email: true } }, items: true },
}),
prisma.transaction.count({ where }),
]);
res.json(paginatedResponse(data, total, { page, limit, skip }));
} catch (err) {
next(err);
}
});
// ─── GET /api/v1/transactions/:id ─────────────────────────────────────────
router.get("/:id", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const tx = await prisma.transaction.findFirst({
where: { id: req.params.id, vendorId: authReq.auth.vendorId },
include: {
user: { select: { id: true, name: true, email: true } },
items: true,
},
});
if (!tx) throw new AppError(404, "NOT_FOUND", "Transaction not found");
res.json(tx);
} catch (err) {
next(err);
}
});
// ─── GET /api/v1/transactions/reports/summary ─────────────────────────────
// Daily totals, payment method breakdown, top products.
router.get("/reports/summary", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const { vendorId } = authReq.auth;
const { from, to } = req.query as { from?: string; to?: string };
const dateFilter = {
...(from ? { gte: new Date(from) } : {}),
...(to ? { lte: new Date(to) } : {}),
};
const where = {
vendorId,
status: "completed",
...(from || to ? { createdAt: dateFilter } : {}),
};
const [totals, byPayment, topProducts] = await Promise.all([
// Overall totals
prisma.transaction.aggregate({
where,
_sum: { total: true, taxTotal: true, discountTotal: true, subtotal: true },
_count: { id: true },
}),
// Breakdown by payment method
prisma.transaction.groupBy({
by: ["paymentMethod"],
where,
_sum: { total: true },
_count: { id: true },
}),
// Top 10 products by revenue
prisma.transactionItem.groupBy({
by: ["productId", "productName"],
where: { transaction: where },
_sum: { total: true, quantity: true },
orderBy: { _sum: { total: "desc" } },
take: 10,
}),
]);
res.json({
period: { from: from ?? null, to: to ?? null },
totals: {
revenue: totals._sum.total ?? 0,
subtotal: totals._sum.subtotal ?? 0,
tax: totals._sum.taxTotal ?? 0,
discounts: totals._sum.discountTotal ?? 0,
transactionCount: totals._count.id,
},
byPaymentMethod: byPayment.map((r) => ({
method: r.paymentMethod,
revenue: r._sum.total ?? 0,
count: r._count.id,
})),
topProducts: topProducts.map((r) => ({
productId: r.productId,
productName: r.productName,
revenue: r._sum.total ?? 0,
unitsSold: r._sum.quantity ?? 0,
})),
});
} catch (err) {
next(err);
}
});
// ─── GET /api/v1/transactions/reports/shift ───────────────────────────────
// Totals for a single shift window (e.g. today). Same shape as summary but
// also returns an average transaction value and opening/closing time of the
// first and last completed transaction in the period.
router.get("/reports/shift", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const { vendorId } = authReq.auth;
const { from, to } = req.query as { from?: string; to?: string };
const dateFilter = {
...(from ? { gte: new Date(from) } : {}),
...(to ? { lte: new Date(to) } : {}),
};
const where = {
vendorId,
status: "completed",
...(from || to ? { createdAt: dateFilter } : {}),
};
const [totals, byPayment, firstTx, lastTx] = await Promise.all([
prisma.transaction.aggregate({
where,
_sum: { total: true, taxTotal: true, discountTotal: true, subtotal: true },
_count: { id: true },
_avg: { total: true },
}),
prisma.transaction.groupBy({
by: ["paymentMethod"],
where,
_sum: { total: true },
_count: { id: true },
}),
prisma.transaction.findFirst({
where,
orderBy: { createdAt: "asc" },
select: { createdAt: true },
}),
prisma.transaction.findFirst({
where,
orderBy: { createdAt: "desc" },
select: { createdAt: true },
}),
]);
res.json({
period: { from: from ?? null, to: to ?? null },
shiftOpen: firstTx?.createdAt ?? null,
shiftClose: lastTx?.createdAt ?? null,
totals: {
revenue: totals._sum.total ?? 0,
subtotal: totals._sum.subtotal ?? 0,
tax: totals._sum.taxTotal ?? 0,
discounts: totals._sum.discountTotal ?? 0,
transactionCount: totals._count.id,
averageTransaction: totals._avg.total ?? 0,
},
byPaymentMethod: byPayment.map((r) => ({
method: r.paymentMethod,
revenue: r._sum.total ?? 0,
count: r._count.id,
})),
});
} catch (err) {
next(err);
}
});
// ─── POST /api/v1/transactions/:id/refund ─────────────────────────────────
// Server-authoritative: only managers/owners can issue refunds.
router.post("/:id/refund", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const tx = await prisma.transaction.findFirst({
where: { id: req.params.id, vendorId: authReq.auth.vendorId },
});
if (!tx) throw new AppError(404, "NOT_FOUND", "Transaction not found");
if (tx.status !== "completed") {
throw new AppError(400, "BAD_REQUEST", `Cannot refund a transaction with status "${tx.status}"`);
}
const updated = await prisma.transaction.update({
where: { id: tx.id },
data: { status: "refunded" },
include: { user: { select: { id: true, name: true } }, items: true },
});
logger.info("transaction.refunded", { id: tx.id, vendorId: tx.vendorId, total: tx.total });
res.json(updated);
} catch (err) {
next(err);
}
});
// ─── GET /api/v1/transactions/:id/receipt ─────────────────────────────────
// Returns a structured receipt payload. Clients can render it, print it,
// or forward it to an email/SMS hook. No delivery logic here.
router.get("/:id/receipt", auth, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const tx = await prisma.transaction.findFirst({
where: { id: req.params.id, vendorId: authReq.auth.vendorId },
include: {
vendor: true,
user: { select: { id: true, name: true } },
items: true,
},
});
if (!tx) throw new AppError(404, "NOT_FOUND", "Transaction not found");
res.json({
receiptNumber: tx.id.slice(-8).toUpperCase(),
vendor: { name: tx.vendor.name, businessNum: tx.vendor.businessNum },
cashier: tx.user.name,
paymentMethod: tx.paymentMethod,
status: tx.status,
issuedAt: new Date().toISOString(),
transactionDate: tx.createdAt,
lineItems: tx.items.map((item) => ({
name: item.productName,
quantity: item.quantity,
unitPrice: item.unitPrice,
taxRate: item.taxRate,
discount: item.discount,
total: item.total,
})),
subtotal: tx.subtotal,
taxTotal: tx.taxTotal,
discountTotal: tx.discountTotal,
total: tx.total,
});
} catch (err) {
next(err);
}
});
export default router;

180
server/src/routes/users.ts Normal file
View File

@@ -0,0 +1,180 @@
import { Router, Request, Response, NextFunction } from "express";
import { z } from "zod";
import bcrypt from "bcryptjs";
import { prisma } from "../lib/prisma.js";
import { requireAuth, requireRole } from "../middleware/auth.js";
import { AppError } from "../middleware/errorHandler.js";
import { parsePage, paginatedResponse } from "../lib/pagination.js";
import { AuthenticatedRequest } from "../types/index.js";
const router = Router();
const auth = requireAuth as unknown as (r: Request, s: Response, n: NextFunction) => void;
const vendorUp = requireRole("admin", "vendor") as unknown as (r: Request, s: Response, n: NextFunction) => void;
// Strip passwordHash from any user object before sending
function safe<T extends { passwordHash?: string }>(u: T): Omit<T, "passwordHash"> {
const { passwordHash: _, ...rest } = u;
return rest;
}
const CreateUserSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().min(1).max(100),
roleId: z.string().min(1),
vendorId: z.string().min(1).optional(), // admin can assign to any vendor
});
const UpdateUserSchema = z.object({
name: z.string().min(1).max(100).optional(),
roleId: z.string().min(1).optional(),
password: z.string().min(8).optional(),
});
// GET /api/v1/users — admin sees all users; vendor sees their own vendor
router.get("/", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const { page, limit, skip } = parsePage(req.query as Record<string, unknown>);
const isAdmin = authReq.auth.roleName === "admin";
const where = isAdmin ? {} : { vendorId: authReq.auth.vendorId };
const [users, total] = await Promise.all([
prisma.user.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: "desc" },
include: { role: true, vendor: { select: { id: true, name: true } } },
}),
prisma.user.count({ where }),
]);
res.json(paginatedResponse(users.map(safe), total, { page, limit, skip }));
} catch (err) {
next(err);
}
});
// GET /api/v1/users/roles/list — must be before /:id
router.get("/roles/list", auth, async (_req: Request, res: Response, next: NextFunction) => {
try {
const roles = await prisma.role.findMany({ orderBy: { name: "asc" } });
res.json(roles);
} catch (err) {
next(err);
}
});
// GET /api/v1/users/:id
router.get("/:id", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const isAdmin = authReq.auth.roleName === "admin";
const user = await prisma.user.findFirst({
where: { id: req.params.id, ...(isAdmin ? {} : { vendorId: authReq.auth.vendorId }) },
include: { role: true, vendor: { select: { id: true, name: true } } },
});
if (!user) throw new AppError(404, "NOT_FOUND", "User not found");
res.json(safe(user));
} catch (err) {
next(err);
}
});
// POST /api/v1/users
router.post("/", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const isAdmin = authReq.auth.roleName === "admin";
const body = CreateUserSchema.parse(req.body);
const existing = await prisma.user.findUnique({ where: { email: body.email } });
if (existing) throw new AppError(409, "CONFLICT", "Email already in use");
const role = await prisma.role.findUnique({ where: { id: body.roleId } });
if (!role) throw new AppError(400, "BAD_REQUEST", "Invalid role");
// Vendors cannot create admin accounts
if (authReq.auth.roleName === "vendor" && role.name === "admin") {
throw new AppError(403, "FORBIDDEN", "Vendors cannot create admin accounts");
}
const targetVendorId = isAdmin && body.vendorId ? body.vendorId : authReq.auth.vendorId;
const user = await prisma.user.create({
data: {
email: body.email,
passwordHash: await bcrypt.hash(body.password, 10),
name: body.name,
vendorId: targetVendorId,
roleId: body.roleId,
},
include: { role: true, vendor: { select: { id: true, name: true } } },
});
res.status(201).json(safe(user));
} catch (err) {
next(err);
}
});
// PUT /api/v1/users/:id
router.put("/:id", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const isAdmin = authReq.auth.roleName === "admin";
const body = UpdateUserSchema.parse(req.body);
const existing = await prisma.user.findFirst({
where: { id: req.params.id, ...(isAdmin ? {} : { vendorId: authReq.auth.vendorId }) },
include: { role: true },
});
if (!existing) throw new AppError(404, "NOT_FOUND", "User not found");
if (body.roleId) {
const role = await prisma.role.findUnique({ where: { id: body.roleId } });
if (!role) throw new AppError(400, "BAD_REQUEST", "Invalid role");
if (authReq.auth.roleName === "vendor" && role.name === "admin") {
throw new AppError(403, "FORBIDDEN", "Vendors cannot assign admin role");
}
}
const updateData: Record<string, unknown> = {};
if (body.name) updateData.name = body.name;
if (body.roleId) updateData.roleId = body.roleId;
if (body.password) updateData.passwordHash = await bcrypt.hash(body.password, 10);
const user = await prisma.user.update({
where: { id: req.params.id },
data: updateData,
include: { role: true, vendor: { select: { id: true, name: true } } },
});
res.json(safe(user));
} catch (err) {
next(err);
}
});
// DELETE /api/v1/users/:id
router.delete("/:id", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const isAdmin = authReq.auth.roleName === "admin";
if (req.params.id === authReq.auth.userId) {
throw new AppError(400, "BAD_REQUEST", "Cannot delete your own account");
}
const existing = await prisma.user.findFirst({
where: { id: req.params.id, ...(isAdmin ? {} : { vendorId: authReq.auth.vendorId }) },
});
if (!existing) throw new AppError(404, "NOT_FOUND", "User not found");
await prisma.user.delete({ where: { id: req.params.id } });
res.status(204).send();
} catch (err) {
next(err);
}
});
export default router;

View File

@@ -0,0 +1,132 @@
import { Router, Request, Response, NextFunction } from "express";
import { z } from "zod";
import { prisma } from "../lib/prisma.js";
import { requireAuth, requireRole } from "../middleware/auth.js";
import { AppError } from "../middleware/errorHandler.js";
import { parsePage, paginatedResponse } from "../lib/pagination.js";
import { AuthenticatedRequest } from "../types/index.js";
const router = Router();
const auth = requireAuth as unknown as (r: Request, s: Response, n: NextFunction) => void;
const adminOnly = requireRole("admin") as unknown as (r: Request, s: Response, n: NextFunction) => void;
const VendorSchema = z.object({
name: z.string().min(1).max(100),
businessNum: z.string().max(50).optional(),
taxSettings: z.record(z.unknown()).optional(),
});
// GET /api/v1/vendors — admin sees all vendors; vendor/user sees their own
router.get("/", auth, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const { page, limit, skip } = parsePage(req.query as Record<string, unknown>);
const isAdmin = authReq.auth.roleName === "admin";
const where = isAdmin ? {} : { id: authReq.auth.vendorId };
const [data, total] = await Promise.all([
prisma.vendor.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: "desc" },
}),
prisma.vendor.count({ where }),
]);
res.json(paginatedResponse(data, total, { page, limit, skip }));
} catch (err) {
next(err);
}
});
// GET /api/v1/vendors/:id
router.get("/:id", auth, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const isAdmin = authReq.auth.roleName === "admin";
const vendor = await prisma.vendor.findFirst({
where: { id: req.params.id, ...(isAdmin ? {} : { id: authReq.auth.vendorId }) },
});
if (!vendor) throw new AppError(404, "NOT_FOUND", "Vendor not found");
res.json(vendor);
} catch (err) {
next(err);
}
});
// POST /api/v1/vendors — admin only
router.post("/", auth, adminOnly, async (req: Request, res: Response, next: NextFunction) => {
try {
const body = VendorSchema.parse(req.body);
const vendor = await prisma.vendor.create({
data: {
...body,
taxSettings: body.taxSettings ? JSON.stringify(body.taxSettings) : null,
},
});
res.status(201).json(vendor);
} catch (err) {
next(err);
}
});
// DELETE /api/v1/vendors/:id — admin only
router.delete("/:id", auth, adminOnly, async (req: Request, res: Response, next: NextFunction) => {
try {
const vendor = await prisma.vendor.findUnique({ where: { id: req.params.id } });
if (!vendor) throw new AppError(404, "NOT_FOUND", "Vendor not found");
// Check for dependent data before deleting
const [users, transactions] = await Promise.all([
prisma.user.count({ where: { vendorId: req.params.id } }),
prisma.transaction.count({ where: { vendorId: req.params.id } }),
]);
if (users > 0 || transactions > 0) {
throw new AppError(
409,
"CONFLICT",
`Cannot delete vendor with existing data (${users} user(s), ${transactions} transaction(s)). Remove all associated data first.`
);
}
// Safe to delete — cascade via Prisma in order
await prisma.eventProduct.deleteMany({ where: { event: { vendorId: req.params.id } } });
await prisma.eventTax.deleteMany({ where: { event: { vendorId: req.params.id } } });
await prisma.event.deleteMany({ where: { vendorId: req.params.id } });
await prisma.product.deleteMany({ where: { vendorId: req.params.id } });
await prisma.tax.deleteMany({ where: { vendorId: req.params.id } });
await prisma.category.deleteMany({ where: { vendorId: req.params.id } });
await prisma.vendor.delete({ where: { id: req.params.id } });
res.status(204).send();
} catch (err) {
next(err);
}
});
// PUT /api/v1/vendors/:id — admin or vendor (own only)
router.put("/:id", auth, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const isAdmin = authReq.auth.roleName === "admin";
if (!isAdmin && req.params.id !== authReq.auth.vendorId) {
throw new AppError(403, "FORBIDDEN", "Cannot modify another vendor");
}
if (!isAdmin && !["admin", "vendor"].includes(authReq.auth.roleName)) {
throw new AppError(403, "FORBIDDEN", "Insufficient permissions");
}
const body = VendorSchema.parse(req.body);
const vendor = await prisma.vendor.update({
where: { id: req.params.id },
data: {
...body,
taxSettings: body.taxSettings ? JSON.stringify(body.taxSettings) : undefined,
},
});
res.json(vendor);
} catch (err) {
next(err);
}
});
export default router;

33
server/src/types/index.ts Normal file
View File

@@ -0,0 +1,33 @@
import { Request } from "express";
export interface AuthPayload {
userId: string;
vendorId: string;
roleId: string;
roleName: string;
}
export interface AuthenticatedRequest extends Request {
auth: AuthPayload;
}
export interface ApiError {
code: string;
message: string;
details?: unknown;
}
export interface PaginationQuery {
page?: number;
limit?: number;
}
export interface PaginatedResponse<T> {
data: T[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}

19
server/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}