Compare commits

...

2 Commits

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

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

17
.claude/launch.json Normal file
View File

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

View File

@@ -0,0 +1,14 @@
{
"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\")"
]
}
}

1
.env.example Normal file
View File

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

22
.gitignore vendored Normal file
View File

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

44
Dockerfile Normal file
View File

@@ -0,0 +1,44 @@
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
# Install server deps and build
COPY server/package*.json ./server/
RUN cd server && npm ci
COPY server/ ./server/
RUN cd server && npm run db:generate && npm run build
# Install client deps and build
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
WORKDIR /app
ENV NODE_ENV=production
# Copy server production deps
COPY server/package*.json ./server/
RUN cd server && npm ci --omit=dev
# Copy built server
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
# Copy built client
COPY --from=builder /app/client/dist ./client/dist
EXPOSE 8080
WORKDIR /app/server
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/index.js"]

109
INSTRUCTIONS.md Normal file
View File

@@ -0,0 +1,109 @@
# 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 |
---
## 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
```

45
ROADMAP.md Normal file
View File

@@ -0,0 +1,45 @@
# 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
- [ ] `GET /api/v1/catalog/sync?since=` for delta syncs
- [ ] `POST /api/v1/transactions/batch` with idempotency keys
- [ ] Android Kotlin app: MVVM, Room, offline-first flows
- [ ] Background sync worker (Android)
- [ ] Conflict resolution strategy: server-authoritative for payments
- [ ] Admin reporting: Android-originated transactions appear in reports
---
## Milestone 4 — Payments & Hardening
- [ ] Payment abstraction layer (cash + card stub; provider-agnostic)
- [ ] Shift/daily summary endpoint and UI
- [ ] Receipt generation (print / email hooks)
- [ ] Advanced reporting: sales by product, tax summaries
- [ ] Telemetry and structured logging
- [ ] Production Docker hardening (non-root user, health checks, secrets)
- [ ] CI/CD pipeline skeleton

13
client/index.html Normal file
View File

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

1771
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
client/package.json Normal file
View File

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

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

@@ -0,0 +1,52 @@
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";
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="users" element={<UsersPage />} />
<Route path="vendor" element={<VendorPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</AuthProvider>
);
}

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

@@ -0,0 +1,48 @@
const BASE = "/api/v1";
function getToken(): string | null {
return localStorage.getItem("accessToken");
}
export function setTokens(accessToken: string, refreshToken: string) {
localStorage.setItem("accessToken", accessToken);
localStorage.setItem("refreshToken", refreshToken);
}
export function clearTokens() {
localStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken");
}
async function request<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
const token = getToken();
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(options.headers as Record<string, string>),
};
if (token) headers["Authorization"] = `Bearer ${token}`;
const res = await fetch(`${BASE}${path}`, { ...options, headers });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const message = body?.error?.message ?? `HTTP ${res.status}`;
throw new Error(message);
}
return res.json() as Promise<T>;
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body: unknown) =>
request<T>(path, { method: "POST", body: JSON.stringify(body) }),
put: <T>(path: string, body: unknown) =>
request<T>(path, { method: "PUT", body: JSON.stringify(body) }),
patch: <T>(path: string, body: unknown) =>
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
};

View File

@@ -0,0 +1,77 @@
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",
disabled,
onClick,
style,
}: {
children: React.ReactNode;
variant?: "primary" | "danger" | "ghost";
type?: "button" | "submit" | "reset";
disabled?: boolean;
onClick?: () => void;
style?: React.CSSProperties;
}) {
const base: React.CSSProperties = {
border: "none",
borderRadius: "var(--radius)",
padding: "8px 16px",
fontWeight: 600,
fontSize: 13,
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.6 : 1,
};
const variants = {
primary: { background: "var(--color-primary)", color: "#fff" },
danger: { background: "var(--color-danger)", color: "#fff" },
ghost: {
background: "none",
color: "var(--color-text-muted)",
border: "1px solid var(--color-border)",
},
};
return (
<button type={type} style={{ ...base, ...variants[variant], ...style }} disabled={disabled} onClick={onClick}>
{children}
</button>
);
}
const s: Record<string, React.CSSProperties> = {
field: { display: "flex", flexDirection: "column", gap: 4, marginBottom: 16 },
label: { fontWeight: 500, fontSize: 13 },
req: { color: "var(--color-danger)" },
error: { color: "var(--color-danger)", fontSize: 12 },
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,77 @@
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 });
}
}, [fetchMe]);
const login = async (email: string, password: string) => {
const res = await api.post<{ accessToken: string; refreshToken: string; user: User }>(
"/auth/login",
{ email, password }
);
setTokens(res.accessToken, res.refreshToken);
setState({ user: res.user, loading: false });
};
const logout = async () => {
const refreshToken = localStorage.getItem("refreshToken");
try {
await api.post("/auth/logout", { refreshToken });
} catch {
// best-effort
}
clearTokens();
setState({ user: null, loading: false });
};
return (
<AuthContext.Provider value={{ ...state, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}

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

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

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

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

View File

@@ -0,0 +1,330 @@
import React, { useEffect, useState, useCallback } from "react";
import { api } from "../api/client";
import { Table } from "../components/Table";
import { Modal } from "../components/Modal";
import { PageHeader } from "../components/PageHeader";
import { FormField, inputStyle, Btn } from "../components/FormField";
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 [tab, setTab] = useState<Tab>("products");
return (
<div style={{ padding: "32px 28px" }}>
<PageHeader title="Catalog" subtitle="Products, categories, and tax rates" />
<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 />}
{tab === "categories" && <CategoriesTab />}
{tab === "taxes" && <TaxesTab />}
</div>
);
}
// ─── Products ──────────────────────────────────────────────────────────────
function ProductsTab() {
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 [p, c, t] = await Promise.all([
api.get<ApiList<Product>>("/products"),
api.get<ApiList<Category>>("/categories"),
api.get<ApiList<Tax>>("/taxes"),
]);
setProducts(p.data);
setCategories(c.data);
setTaxes(t.data);
setLoading(false);
}, []);
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 };
if (modal === "create") await api.post("/products", payload);
else if (selected) await api.put(`/products/${selected.id}`, 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() {
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");
setItems(res.data);
setLoading(false);
}, []);
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 {
if (modal === "create") await api.post("/categories", { name });
else if (selected) await api.put(`/categories/${selected.id}`, { 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() {
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");
setItems(res.data);
setLoading(false);
}, []);
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) };
if (modal === "create") await api.post("/taxes", payload);
else if (selected) await api.put(`/taxes/${selected.id}`, 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(key: string, set: React.Dispatch<React.SetStateAction<Record<string, string>>>) {
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)", 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)",
};

View File

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

View File

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

View File

@@ -0,0 +1,193 @@
import React, { useEffect, useState, useCallback } from "react";
import { api } from "../api/client";
import { Table } from "../components/Table";
import { Modal } from "../components/Modal";
import { PageHeader } from "../components/PageHeader";
import { FormField, inputStyle, Btn } from "../components/FormField";
interface Role { id: string; name: string; }
interface User {
id: string;
name: string;
email: string;
role: Role;
createdAt: string;
}
interface ApiList<T> { data: T[]; pagination: { total: number; page: number; limit: number; totalPages: number }; }
const EMPTY_FORM = { name: "", email: "", password: "", roleId: "" };
export default function UsersPage() {
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 [usersRes, rolesRes] = await Promise.all([
api.get<ApiList<User>>("/users"),
api.get<Role[]>("/users/roles/list"),
]);
setUsers(usersRes.data);
setRoles(rolesRes);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const openCreate = () => {
setSelected(null);
setForm(EMPTY_FORM);
setError("");
setModal("create");
};
const openEdit = (user: User) => {
setSelected(user);
setForm({ name: user.name, email: user.email, password: "", roleId: user.role.id });
setError("");
setModal("edit");
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
setError("");
try {
if (modal === "create") {
await api.post("/users", form);
} 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 columns = [
{ key: "name", header: "Name" },
{ key: "email", header: "Email" },
{
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" }}>
<PageHeader
title="Users"
subtitle="Manage staff accounts and roles"
action={<Btn onClick={openCreate}>+ Add User</Btn>}
/>
<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 }> = {
owner: { bg: "#fef3c7", color: "#92400e" },
manager: { bg: "#dbeafe", color: "#1e3a8a" },
cashier: { bg: "#f0fdf4", color: "#14532d" },
};
const c = colors[role] ?? { bg: "#f1f5f9", color: "#475569" };
return {
display: "inline-block",
padding: "2px 10px",
borderRadius: 999,
fontSize: 12,
fontWeight: 600,
textTransform: "capitalize",
...c,
};
}
const errStyle: React.CSSProperties = {
background: "#fef2f2",
border: "1px solid #fecaca",
color: "var(--color-danger)",
borderRadius: "var(--radius)",
padding: "10px 12px",
fontSize: 13,
marginBottom: 16,
};

View File

@@ -0,0 +1,129 @@
import React, { useEffect, useState } from "react";
import { api } from "../api/client";
import { PageHeader } from "../components/PageHeader";
import { FormField, inputStyle, Btn } from "../components/FormField";
interface Vendor {
id: string;
name: string;
businessNum: string | null;
taxSettings: string | null;
createdAt: string;
updatedAt: string;
}
export default function VendorPage() {
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({ name: "", businessNum: "" });
useEffect(() => {
api
.get<{ data: 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>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div style={{ display: "flex", gap: 16, padding: "10px 0", borderBottom: "1px solid var(--color-border)" }}>
<div style={{ width: 160, fontWeight: 500, fontSize: 13, color: "var(--color-text-muted)" }}>{label}</div>
<div style={{ flex: 1, fontSize: 14 }}>{value}</div>
</div>
);
}
const card: React.CSSProperties = {
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
borderRadius: "var(--radius)",
padding: "20px",
};
const errStyle: React.CSSProperties = {
background: "#fef2f2",
border: "1px solid #fecaca",
color: "var(--color-danger)",
borderRadius: "var(--radius)",
padding: "10px 12px",
fontSize: 13,
marginBottom: 16,
};

21
client/tsconfig.json Normal file
View File

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

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

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

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

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

37
docker-compose.yml Normal file
View File

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

6
server/.env Normal file
View File

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

6
server/.env.example Normal file
View File

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

1817
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
server/package.json Normal file
View File

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

BIN
server/prisma/dev.db Normal file

Binary file not shown.

View File

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

View File

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

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

@@ -0,0 +1,134 @@
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[]
}
model Role {
id String @id @default(cuid())
name String @unique // cashier | manager | owner
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[]
}
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[]
}
model Transaction {
id String @id @default(cuid())
idempotencyKey String @unique
vendorId String
userId 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])
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])
}

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

@@ -0,0 +1,53 @@
import { PrismaClient } from "@prisma/client";
import bcrypt from "bcryptjs";
const prisma = new PrismaClient();
async function main() {
// Seed roles
const ownerRole = await prisma.role.upsert({
where: { name: "owner" },
update: {},
create: { name: "owner" },
});
await prisma.role.upsert({
where: { name: "manager" },
update: {},
create: { name: "manager" },
});
await prisma.role.upsert({
where: { name: "cashier" },
update: {},
create: { name: "cashier" },
});
// 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 owner 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: ownerRole.id,
},
});
console.log("Seed complete");
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());

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

@@ -0,0 +1,50 @@
import express from "express";
import cors from "cors";
import path from "path";
import { fileURLToPath } from "url";
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 { errorHandler } from "./middleware/errorHandler.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
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 }));
// 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);
// Serve React admin UI static assets in production
if (process.env.NODE_ENV === "production") {
const clientDist = path.join(__dirname, "../../client/dist");
app.use(express.static(clientDist));
app.get(/^(?!\/api).*/, (_req, res) => {
res.sendFile(path.join(clientDist, "index.html"));
});
}
app.use(errorHandler);
return app;
}

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

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

View File

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

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

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

View File

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

View File

@@ -0,0 +1,51 @@
import { Request, Response, NextFunction } from "express";
import { ZodError } from "zod";
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;
}
console.error("Unhandled error:", err);
res.status(500).json({
error: {
code: "INTERNAL_ERROR",
message: "An unexpected error occurred",
},
});
}

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

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

View File

@@ -0,0 +1,90 @@
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 managerUp = requireRole("owner", "manager") 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 where = { vendorId: authReq.auth.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 cat = await prisma.category.findFirst({
where: { id: req.params.id, 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, managerUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const body = CategorySchema.parse(req.body);
const cat = await prisma.category.create({
data: { ...body, vendorId: authReq.auth.vendorId },
});
res.status(201).json(cat);
} catch (err) {
next(err);
}
});
router.put("/:id", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const existing = await prisma.category.findFirst({
where: { id: req.params.id, 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, managerUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const existing = await prisma.category.findFirst({
where: { id: req.params.id, 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;

View File

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

View File

@@ -0,0 +1,116 @@
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 managerUp = requireRole("owner", "manager") 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 where = {
vendorId: authReq.auth.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 product = await prisma.product.findFirst({
where: { id: req.params.id, 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, managerUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const body = ProductSchema.parse(req.body);
const product = await prisma.product.create({
data: { ...body, vendorId: authReq.auth.vendorId },
include: { category: true, tax: true },
});
res.status(201).json(product);
} catch (err) {
next(err);
}
});
router.put("/:id", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const existing = await prisma.product.findFirst({
where: { id: req.params.id, 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, managerUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const existing = await prisma.product.findFirst({
where: { id: req.params.id, vendorId: authReq.auth.vendorId },
});
if (!existing) throw new AppError(404, "NOT_FOUND", "Product not found");
await prisma.product.delete({ where: { id: req.params.id } });
res.status(204).send();
} catch (err) {
next(err);
}
});
export default router;

View File

@@ -0,0 +1,91 @@
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 managerUp = requireRole("owner", "manager") 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 where = { vendorId: authReq.auth.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 tax = await prisma.tax.findFirst({
where: { id: req.params.id, 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, managerUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const body = TaxSchema.parse(req.body);
const tax = await prisma.tax.create({
data: { ...body, vendorId: authReq.auth.vendorId },
});
res.status(201).json(tax);
} catch (err) {
next(err);
}
});
router.put("/:id", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const existing = await prisma.tax.findFirst({
where: { id: req.params.id, 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, managerUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const existing = await prisma.tax.findFirst({
where: { id: req.params.id, 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;

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

@@ -0,0 +1,172 @@
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 managerUp = requireRole("owner", "manager") 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),
});
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
router.get("/", auth, managerUp, 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 = { vendorId: authReq.auth.vendorId };
const [users, total] = await Promise.all([
prisma.user.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: "desc" },
include: { role: 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, managerUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const user = await prisma.user.findFirst({
where: { id: req.params.id, vendorId: authReq.auth.vendorId },
include: { role: 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, managerUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
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");
// Managers cannot create owners
if (authReq.auth.roleName === "manager" && role.name === "owner") {
throw new AppError(403, "FORBIDDEN", "Managers cannot create owner accounts");
}
const user = await prisma.user.create({
data: {
email: body.email,
passwordHash: await bcrypt.hash(body.password, 10),
name: body.name,
vendorId: authReq.auth.vendorId,
roleId: body.roleId,
},
include: { role: true },
});
res.status(201).json(safe(user));
} catch (err) {
next(err);
}
});
// PUT /api/v1/users/:id
router.put("/:id", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const body = UpdateUserSchema.parse(req.body);
const existing = await prisma.user.findFirst({
where: { id: req.params.id, 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 === "manager" && role.name === "owner") {
throw new AppError(403, "FORBIDDEN", "Managers cannot assign owner 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 },
});
res.json(safe(user));
} catch (err) {
next(err);
}
});
// DELETE /api/v1/users/:id
router.delete("/:id", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
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, vendorId: authReq.auth.vendorId },
});
if (!existing) throw new AppError(404, "NOT_FOUND", "User not found");
await prisma.user.delete({ where: { id: req.params.id } });
res.status(204).send();
} catch (err) {
next(err);
}
});
export default router;

View File

@@ -0,0 +1,92 @@
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 ownerOnly = requireRole("owner") 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 — list (owner sees their own vendor)
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 [data, total] = await Promise.all([
prisma.vendor.findMany({
where: { id: authReq.auth.vendorId },
skip,
take: limit,
orderBy: { createdAt: "desc" },
}),
prisma.vendor.count({ where: { id: authReq.auth.vendorId } }),
]);
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 vendor = await prisma.vendor.findFirst({
where: { id: req.params.id, ...(authReq.auth.roleName !== "owner" ? { 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
router.post("/", auth, ownerOnly, 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);
}
});
// PUT /api/v1/vendors/:id
router.put("/:id", auth, ownerOnly, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
if (req.params.id !== authReq.auth.vendorId) {
throw new AppError(403, "FORBIDDEN", "Cannot modify another vendor");
}
const body = VendorSchema.parse(req.body);
const vendor = await prisma.vendor.update({
where: { id: req.params.id },
data: {
...body,
taxSettings: body.taxSettings ? JSON.stringify(body.taxSettings) : undefined,
},
});
res.json(vendor);
} catch (err) {
next(err);
}
});
export default router;

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

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

19
server/tsconfig.json Normal file
View File

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