Compare commits
14 Commits
fb62439eab
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ba9f74afd5 | |||
| 7528b36c48 | |||
| 84571c3516 | |||
| f7f5ac7e3b | |||
| 7c71af2a9f | |||
| 31e539102b | |||
| e1b1a82e07 | |||
| 65eb405cf1 | |||
| c426b19b7c | |||
| 2aa041d45e | |||
| d78ce35104 | |||
| 91e1a1ffbf | |||
| c35f92f18b | |||
| d53c772dd6 |
17
.claude/launch.json
Normal file
17
.claude/launch.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
20
.claude/settings.local.json
Normal file
20
.claude/settings.local.json
Normal 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
1
.env.example
Normal file
@@ -0,0 +1 @@
|
||||
JWT_SECRET=change-me-in-production
|
||||
25
.github/workflows/docker-build.yml
vendored
Normal file
25
.github/workflows/docker-build.yml
vendored
Normal 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
22
.gitignore
vendored
Normal 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
55
Dockerfile
Normal 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
125
INSTRUCTIONS.md
Normal 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
46
ROADMAP.md
Normal 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
13
client/index.html
Normal 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
1771
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
client/package.json
Normal file
23
client/package.json
Normal 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
56
client/src/App.tsx
Normal 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
86
client/src/api/client.ts
Normal 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" }),
|
||||
};
|
||||
79
client/src/components/FormField.tsx
Normal file
79
client/src/components/FormField.tsx
Normal 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 },
|
||||
};
|
||||
120
client/src/components/Layout.tsx
Normal file
120
client/src/components/Layout.tsx
Normal 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)" },
|
||||
};
|
||||
61
client/src/components/Modal.tsx
Normal file
61
client/src/components/Modal.tsx
Normal 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" },
|
||||
};
|
||||
30
client/src/components/PageHeader.tsx
Normal file
30
client/src/components/PageHeader.tsx
Normal 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 },
|
||||
};
|
||||
83
client/src/components/Table.tsx
Normal file
83
client/src/components/Table.tsx
Normal 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)" },
|
||||
};
|
||||
53
client/src/components/VendorFilter.tsx
Normal file
53
client/src/components/VendorFilter.tsx
Normal 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,
|
||||
};
|
||||
82
client/src/context/AuthContext.tsx
Normal file
82
client/src/context/AuthContext.tsx
Normal 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
44
client/src/index.css
Normal 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
13
client/src/main.tsx
Normal 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>
|
||||
);
|
||||
316
client/src/pages/CatalogPage.tsx
Normal file
316
client/src/pages/CatalogPage.tsx
Normal 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)" };
|
||||
50
client/src/pages/DashboardPage.tsx
Normal file
50
client/src/pages/DashboardPage.tsx
Normal 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",
|
||||
};
|
||||
505
client/src/pages/EventsPage.tsx
Normal file
505
client/src/pages/EventsPage.tsx
Normal 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 };
|
||||
128
client/src/pages/LoginPage.tsx
Normal file
128
client/src/pages/LoginPage.tsx
Normal 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",
|
||||
},
|
||||
};
|
||||
220
client/src/pages/ReportsPage.tsx
Normal file
220
client/src/pages/ReportsPage.tsx
Normal 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 };
|
||||
205
client/src/pages/UsersPage.tsx
Normal file
205
client/src/pages/UsersPage.tsx
Normal 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,
|
||||
};
|
||||
238
client/src/pages/VendorPage.tsx
Normal file
238
client/src/pages/VendorPage.tsx
Normal 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
21
client/tsconfig.json
Normal 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
10
client/tsconfig.node.json
Normal 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
19
client/vite.config.ts
Normal 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
37
docker-compose.yml
Normal 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
6
server/.env
Normal 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
6
server/.env.example
Normal 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
1817
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
server/package.json
Normal file
33
server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
125
server/prisma/migrations/20260321035729_init/migration.sql
Normal file
125
server/prisma/migrations/20260321035729_init/migration.sql
Normal 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");
|
||||
3
server/prisma/migrations/migration_lock.toml
Normal file
3
server/prisma/migrations/migration_lock.toml
Normal 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
184
server/prisma/schema.prisma
Normal 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
53
server/prisma/seed.ts
Normal 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
54
server/src/app.ts
Normal 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
35
server/src/index.ts
Normal 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
38
server/src/lib/logger.ts
Normal 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),
|
||||
};
|
||||
27
server/src/lib/pagination.ts
Normal file
27
server/src/lib/pagination.ts
Normal 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),
|
||||
},
|
||||
};
|
||||
}
|
||||
66
server/src/lib/payments.ts
Normal file
66
server/src/lib/payments.ts
Normal 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
18
server/src/lib/prisma.ts
Normal 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;
|
||||
}
|
||||
35
server/src/lib/vendorScope.ts
Normal file
35
server/src/lib/vendorScope.ts
Normal 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 };
|
||||
}
|
||||
43
server/src/middleware/auth.ts
Normal file
43
server/src/middleware/auth.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
52
server/src/middleware/errorHandler.ts
Normal file
52
server/src/middleware/errorHandler.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
18
server/src/middleware/requestLogger.ts
Normal file
18
server/src/middleware/requestLogger.ts
Normal 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
198
server/src/routes/auth.ts
Normal 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;
|
||||
83
server/src/routes/catalog.ts
Normal file
83
server/src/routes/catalog.ts
Normal 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;
|
||||
80
server/src/routes/categories.ts
Normal file
80
server/src/routes/categories.ts
Normal 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
371
server/src/routes/events.ts
Normal 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;
|
||||
25
server/src/routes/health.ts
Normal file
25
server/src/routes/health.ts
Normal 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;
|
||||
104
server/src/routes/products.ts
Normal file
104
server/src/routes/products.ts
Normal 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;
|
||||
83
server/src/routes/taxes.ts
Normal file
83
server/src/routes/taxes.ts
Normal 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;
|
||||
496
server/src/routes/transactions.ts
Normal file
496
server/src/routes/transactions.ts
Normal 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
180
server/src/routes/users.ts
Normal 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;
|
||||
132
server/src/routes/vendors.ts
Normal file
132
server/src/routes/vendors.ts
Normal 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
33
server/src/types/index.ts
Normal 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
19
server/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user