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>
This commit is contained in:
52
client/src/App.tsx
Normal file
52
client/src/App.tsx
Normal 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
48
client/src/api/client.ts
Normal 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" }),
|
||||
};
|
||||
77
client/src/components/FormField.tsx
Normal file
77
client/src/components/FormField.tsx
Normal 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 },
|
||||
};
|
||||
116
client/src/components/Layout.tsx
Normal file
116
client/src/components/Layout.tsx
Normal 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)" },
|
||||
};
|
||||
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)" },
|
||||
};
|
||||
77
client/src/context/AuthContext.tsx
Normal file
77
client/src/context/AuthContext.tsx
Normal 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
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>
|
||||
);
|
||||
330
client/src/pages/CatalogPage.tsx
Normal file
330
client/src/pages/CatalogPage.tsx
Normal 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)",
|
||||
};
|
||||
47
client/src/pages/DashboardPage.tsx
Normal file
47
client/src/pages/DashboardPage.tsx
Normal 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",
|
||||
};
|
||||
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",
|
||||
},
|
||||
};
|
||||
193
client/src/pages/UsersPage.tsx
Normal file
193
client/src/pages/UsersPage.tsx
Normal 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,
|
||||
};
|
||||
129
client/src/pages/VendorPage.tsx
Normal file
129
client/src/pages/VendorPage.tsx
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user