init
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OrderOps</title>
|
||||
<link rel="stylesheet" href="/app.css" />
|
||||
</head>
|
||||
<body class="bg-slate-100 text-slate-900">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@orderops/web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "bun run build:css && vite build",
|
||||
"build:css": "tailwindcss -i ./src/styles/@tailwind.css -o ./public/app.css --minify",
|
||||
"dev:css": "tailwindcss -i ./src/styles/@tailwind.css -o ./public/app.css --watch",
|
||||
"check": "bun run tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@orderops/shared": "workspace:*",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"react-router-dom": "7.18.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/cli": "4.3.3",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.4",
|
||||
"@vitejs/plugin-react": "6.1.0",
|
||||
"tailwindcss": "4.3.3",
|
||||
"vite": "8.2.2"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,31 @@
|
||||
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||
import { AuthProvider } from "./auth/auth-provider";
|
||||
import { HomePage } from "./auth/home-page";
|
||||
import { LoginPage } from "./auth/login-page";
|
||||
import { RequireAuth } from "./auth/require-auth";
|
||||
import { RequireEmployee } from "./auth/require-employee";
|
||||
import { AppLayout } from "./layouts/app-layout";
|
||||
import { OrderPage } from "./orders/order-page";
|
||||
import { OrdersPage } from "./orders/orders-page";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="login" element={<LoginPage />} />
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route element={<RequireEmployee />}>
|
||||
<Route path="orders" element={<OrdersPage />} />
|
||||
<Route path="orders/:orderId" element={<OrderPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate replace to="/" />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { AuthUser } from "@orderops/shared";
|
||||
import { apiRequest } from "../lib/api-client";
|
||||
|
||||
interface AuthResponse {
|
||||
user: AuthUser | null;
|
||||
}
|
||||
|
||||
export async function getCurrentUser() {
|
||||
return (await apiRequest<AuthResponse>("/api/me")).user;
|
||||
}
|
||||
|
||||
export async function login(username: string) {
|
||||
return (await apiRequest<{ user: AuthUser }>("/api/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username })
|
||||
})).user;
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
await apiRequest<{ ok: true }>("/api/logout", { method: "POST", body: JSON.stringify({}) });
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { AuthUser } from "@orderops/shared";
|
||||
import { createContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { getCurrentUser, login as loginRequest, logout as logoutRequest } from "./auth-api";
|
||||
|
||||
interface AuthContextValue {
|
||||
user: AuthUser | null;
|
||||
isLoading: boolean;
|
||||
login: (username: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
void getCurrentUser()
|
||||
.then(setUser)
|
||||
.catch(() => setUser(null))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AuthContextValue>(() => ({
|
||||
user,
|
||||
isLoading,
|
||||
async login(username: string) {
|
||||
const nextUser = await loginRequest(username);
|
||||
setUser(nextUser);
|
||||
},
|
||||
async logout() {
|
||||
await logoutRequest();
|
||||
setUser(null);
|
||||
}
|
||||
}), [isLoading, user]);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { useAuth } from "./use-auth";
|
||||
|
||||
export function HomePage() {
|
||||
const { user } = useAuth();
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-4">
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h1 className="text-2xl font-semibold text-slate-900">Authenticated user</h1>
|
||||
<dl className="mt-4 grid gap-3 text-sm text-slate-700 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="font-medium text-slate-500">Name</dt>
|
||||
<dd>{user.name}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-medium text-slate-500">Username</dt>
|
||||
<dd>{user.username}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-medium text-slate-500">Role</dt>
|
||||
<dd className="capitalize">{user.role}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
{user.role === "employee" ? (
|
||||
<Link className="inline-flex rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white" to="/orders">
|
||||
Open orders
|
||||
</Link>
|
||||
) : (
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4 text-sm text-slate-600 shadow-sm">
|
||||
Orders workspace is available to employee accounts only.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useState } from "react";
|
||||
import { Navigate, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "./use-auth";
|
||||
|
||||
export function LoginPage() {
|
||||
const { isLoading, login, user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [username, setUsername] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="rounded-xl border border-slate-200 bg-white p-8 text-sm text-slate-600 shadow-sm">Loading session...</div>;
|
||||
}
|
||||
|
||||
if (user) {
|
||||
return <Navigate replace to="/" />;
|
||||
}
|
||||
|
||||
const from = typeof location.state === "object" && location.state && "from" in location.state && typeof location.state.from === "string"
|
||||
? location.state.from
|
||||
: "/";
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md space-y-4 rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-slate-900">Login</h1>
|
||||
<p className="text-sm text-slate-600">Use seeded customer username or create your own account with <code>bun run seed:user</code>.</p>
|
||||
</div>
|
||||
{error ? <div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div> : null}
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
await login(username.trim());
|
||||
navigate(from, { replace: true });
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Login failed.");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label className="block text-sm text-slate-700">
|
||||
<span className="mb-1 block font-medium">Username</span>
|
||||
<input
|
||||
className="w-full rounded-lg border border-slate-300 px-3 py-2"
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
value={username}
|
||||
/>
|
||||
</label>
|
||||
<button className="rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white" type="submit">
|
||||
Sign in
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "./use-auth";
|
||||
|
||||
export function RequireAuth() {
|
||||
const { isLoading, user } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="rounded-xl border border-slate-200 bg-white p-8 text-sm text-slate-600 shadow-sm">Loading session...</div>;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate replace state={{ from: location.pathname }} to="/login" />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Navigate, Outlet } from "react-router-dom";
|
||||
import { useAuth } from "./use-auth";
|
||||
|
||||
export function RequireEmployee() {
|
||||
const { user } = useAuth();
|
||||
|
||||
if (!user || user.role !== "employee") {
|
||||
return <Navigate replace to="/" />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useContext } from "react";
|
||||
import { AuthContext } from "./auth-provider";
|
||||
|
||||
export function useAuth() {
|
||||
const value = useContext(AuthContext);
|
||||
|
||||
if (!value) {
|
||||
throw new Error("Auth context not found.");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Navbar } from "../navigation/navbar";
|
||||
|
||||
export function AppLayout() {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-100 text-slate-900">
|
||||
<Navbar />
|
||||
<main className="mx-auto max-w-7xl px-4 py-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export async function apiRequest<T>(input: string, init?: RequestInit) {
|
||||
const response = await fetch(input, {
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {})
|
||||
},
|
||||
...init
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({ message: "Request failed." }));
|
||||
throw new Error(data.message ?? "Request failed.");
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./app";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
|
||||
if (!root) {
|
||||
throw new Error("Root element not found.");
|
||||
}
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,64 @@
|
||||
import { NavLink, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../auth/use-auth";
|
||||
|
||||
export function Navbar() {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<header className="border-b border-slate-200 bg-white">
|
||||
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-4 py-4">
|
||||
<div className="flex items-center gap-6">
|
||||
<NavLink className="text-lg font-semibold text-slate-900" to="/">
|
||||
OrderOps
|
||||
</NavLink>
|
||||
<nav className="flex items-center gap-2">
|
||||
<NavLink
|
||||
className={({ isActive }) =>
|
||||
`rounded-md px-3 py-2 text-sm font-medium transition ${isActive ? "bg-slate-900 text-white" : "text-slate-600 hover:bg-slate-100 hover:text-slate-900"}`
|
||||
}
|
||||
end
|
||||
to="/"
|
||||
>
|
||||
Home
|
||||
</NavLink>
|
||||
{user?.role === "employee" ? (
|
||||
<NavLink
|
||||
className={({ isActive }) =>
|
||||
`rounded-md px-3 py-2 text-sm font-medium transition ${isActive ? "bg-slate-900 text-white" : "text-slate-600 hover:bg-slate-100 hover:text-slate-900"}`
|
||||
}
|
||||
to="/orders"
|
||||
>
|
||||
Orders
|
||||
</NavLink>
|
||||
) : null}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
{user ? (
|
||||
<>
|
||||
<div className="text-right">
|
||||
<div className="font-medium text-slate-900">{user.name}</div>
|
||||
<div className="text-slate-500">{user.username} · {user.role}</div>
|
||||
</div>
|
||||
<button
|
||||
className="rounded-md border border-slate-300 px-3 py-2 text-slate-700 hover:bg-slate-100"
|
||||
onClick={async () => {
|
||||
await logout();
|
||||
navigate("/login", { replace: true });
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<NavLink className="rounded-md border border-slate-300 px-3 py-2 text-slate-700 hover:bg-slate-100" to="/login">
|
||||
Login
|
||||
</NavLink>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { AuditEvent } from "@orderops/shared";
|
||||
|
||||
interface AuditTimelineProps {
|
||||
events: AuditEvent[];
|
||||
}
|
||||
|
||||
export function AuditTimeline({ events }: AuditTimelineProps) {
|
||||
return (
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<h3 className="mb-3 text-sm font-semibold text-slate-700">Audit history</h3>
|
||||
<ul className="space-y-3">
|
||||
{events.map((event) => (
|
||||
<li key={event.id} className="rounded-lg border border-slate-200 p-3">
|
||||
<div className="text-sm font-medium text-slate-900">{event.message}</div>
|
||||
<div className="text-xs text-slate-500">{event.type} · {event.actor}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Address, AuditEvent, OrderDetail, OrderSummary } from "@orderops/shared";
|
||||
import { apiRequest } from "../lib/api-client";
|
||||
|
||||
export function listOrders(query: string) {
|
||||
const search = query.trim() ? `?query=${encodeURIComponent(query.trim())}` : "";
|
||||
return apiRequest<OrderSummary[]>(`/api/orders${search}`);
|
||||
}
|
||||
|
||||
export function getOrder(orderId: string) {
|
||||
return apiRequest<OrderDetail>(`/api/orders/${orderId}`);
|
||||
}
|
||||
|
||||
export function listAuditEvents(orderId: string) {
|
||||
return apiRequest<AuditEvent[]>(`/api/orders/${orderId}/audit-events`);
|
||||
}
|
||||
|
||||
export function updateOrderItemQuantity(orderId: string, itemId: string, quantity: number) {
|
||||
return apiRequest<OrderDetail>(`/api/orders/${orderId}/items/${itemId}/quantity`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ quantity })
|
||||
});
|
||||
}
|
||||
|
||||
export function updateShippingAddress(orderId: string, address: Address) {
|
||||
return apiRequest<OrderDetail>(`/api/orders/${orderId}/shipping-address`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(address)
|
||||
});
|
||||
}
|
||||
|
||||
export function cancelOrder(orderId: string, reason?: string) {
|
||||
return apiRequest<OrderDetail>(`/api/orders/${orderId}/cancel`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ reason })
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Address, AuditEvent, OrderDetail } from "@orderops/shared";
|
||||
import { AuditTimeline } from "./audit-timeline";
|
||||
import { OrderItemsPanel } from "./order-items-panel";
|
||||
import { OrderSummaryPanel } from "./order-summary";
|
||||
import { ShippingAddressForm } from "./shipping-address-form";
|
||||
|
||||
interface OrderDetailPageProps {
|
||||
order: OrderDetail;
|
||||
auditEvents: AuditEvent[];
|
||||
onUpdateQuantity: (itemId: string, quantity: number) => Promise<void>;
|
||||
onSaveAddress: (address: Address) => Promise<void>;
|
||||
onCancel: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function OrderDetailPage({
|
||||
order,
|
||||
auditEvents,
|
||||
onUpdateQuantity,
|
||||
onSaveAddress,
|
||||
onCancel
|
||||
}: OrderDetailPageProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<OrderSummaryPanel order={order} />
|
||||
<div className="flex justify-end">
|
||||
<button className="rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white" onClick={() => void onCancel()} type="button">
|
||||
Cancel order
|
||||
</button>
|
||||
</div>
|
||||
<OrderItemsPanel order={order} onUpdateQuantity={onUpdateQuantity} />
|
||||
<ShippingAddressForm order={order} onSave={onSaveAddress} />
|
||||
<AuditTimeline events={auditEvents} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useState } from "react";
|
||||
import type { OrderDetail } from "@orderops/shared";
|
||||
|
||||
interface OrderItemsPanelProps {
|
||||
order: OrderDetail;
|
||||
onUpdateQuantity: (itemId: string, quantity: number) => Promise<void>;
|
||||
}
|
||||
|
||||
function QuantityForm({
|
||||
itemId,
|
||||
quantity,
|
||||
onSave
|
||||
}: {
|
||||
itemId: string;
|
||||
quantity: number;
|
||||
onSave: (itemId: string, quantity: number) => Promise<void>;
|
||||
}) {
|
||||
const [value, setValue] = useState(String(quantity));
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex items-center gap-2"
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
await onSave(itemId, Number(value));
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="w-16 rounded-md border border-slate-300 px-2 py-1 text-sm"
|
||||
inputMode="numeric"
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
/>
|
||||
<button className="rounded-md bg-blue-600 px-3 py-1 text-sm font-medium text-white" type="submit">
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function OrderItemsPanel({ order, onUpdateQuantity }: OrderItemsPanelProps) {
|
||||
return (
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<h3 className="mb-3 text-sm font-semibold text-slate-700">Items</h3>
|
||||
<div className="space-y-3">
|
||||
{order.items.map((item) => (
|
||||
<div key={item.id} className="rounded-lg border border-slate-200 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">{item.productName}</div>
|
||||
<div className="text-sm text-slate-600">{item.sku}</div>
|
||||
<div className="text-sm text-slate-500">Available: {item.availableQuantity}</div>
|
||||
</div>
|
||||
<QuantityForm itemId={item.id} quantity={item.quantity} onSave={onUpdateQuantity} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { OrderSummary } from "@orderops/shared";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
interface OrderListProps {
|
||||
orders: OrderSummary[];
|
||||
}
|
||||
|
||||
export function OrderList({ orders }: OrderListProps) {
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="border-b border-slate-200 px-4 py-3 text-sm font-semibold text-slate-700">Orders</div>
|
||||
<ul className="divide-y divide-slate-200">
|
||||
{orders.map((order) => (
|
||||
<li key={order.id}>
|
||||
<Link className="flex flex-col gap-1 px-4 py-3 text-left transition hover:bg-slate-50" to={`/orders/${order.id}`}>
|
||||
<span className="text-sm font-semibold text-slate-900">{order.orderNumber}</span>
|
||||
<span className="text-sm text-slate-600">{order.customer.name}</span>
|
||||
<span className="text-xs text-slate-500">{order.status} · {order.itemCount} items</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Address, AuditEvent, OrderDetail } from "@orderops/shared";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { Toast } from "../ui/toast";
|
||||
import { cancelOrder, getOrder, listAuditEvents, updateOrderItemQuantity, updateShippingAddress } from "./order-api";
|
||||
import { OrderDetailPage } from "./order-detail-page";
|
||||
|
||||
interface ToastState {
|
||||
message: string;
|
||||
variant: "success" | "error";
|
||||
}
|
||||
|
||||
export function OrderPage() {
|
||||
const { orderId } = useParams();
|
||||
const [order, setOrder] = useState<OrderDetail | null>(null);
|
||||
const [auditEvents, setAuditEvents] = useState<AuditEvent[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [toast, setToast] = useState<ToastState | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
async function refreshOrder(nextOrderId: string) {
|
||||
const [nextOrder, nextEvents] = await Promise.all([getOrder(nextOrderId), listAuditEvents(nextOrderId)]);
|
||||
setOrder(nextOrder);
|
||||
setAuditEvents(nextEvents);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!toast) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = window.setTimeout(() => setToast(null), 3000);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!orderId) {
|
||||
setError("Order not found.");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setToast(null);
|
||||
|
||||
void refreshOrder(orderId)
|
||||
.catch((caught: unknown) => {
|
||||
setOrder(null);
|
||||
setAuditEvents([]);
|
||||
setError(caught instanceof Error ? caught.message : "Failed to load order.");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [orderId]);
|
||||
|
||||
async function runAction(action: () => Promise<OrderDetail>, successMessage: string) {
|
||||
if (!orderId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
const nextOrder = await action();
|
||||
setOrder(nextOrder);
|
||||
await refreshOrder(orderId);
|
||||
setToast({ message: successMessage, variant: "success" });
|
||||
} catch (caught) {
|
||||
setToast({
|
||||
message: caught instanceof Error ? caught.message : "Request failed.",
|
||||
variant: "error"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Link className="inline-flex text-sm font-medium text-blue-600 hover:text-blue-700" to="/orders">
|
||||
← Back to orders
|
||||
</Link>
|
||||
{toast ? <Toast message={toast.message} onDismiss={() => setToast(null)} variant={toast.variant} /> : null}
|
||||
{error ? <div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div> : null}
|
||||
{isLoading ? <div className="rounded-xl border border-slate-200 bg-white p-8 text-sm text-slate-600 shadow-sm">Loading order...</div> : null}
|
||||
{!isLoading && order ? (
|
||||
<OrderDetailPage
|
||||
order={order}
|
||||
auditEvents={auditEvents}
|
||||
onUpdateQuantity={(itemId, quantity) => runAction(() => updateOrderItemQuantity(order.id, itemId, quantity), "Quantity saved.")}
|
||||
onSaveAddress={(address: Address) => runAction(() => updateShippingAddress(order.id, address), "Shipping address saved.")}
|
||||
onCancel={() => runAction(() => cancelOrder(order.id), "Order cancelled.")}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
interface OrderSearchProps {
|
||||
query: string;
|
||||
onQueryChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function OrderSearch({ query, onQueryChange }: OrderSearchProps) {
|
||||
return (
|
||||
<label className="block rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<span className="mb-2 block text-sm font-medium text-slate-700">Search orders</span>
|
||||
<input
|
||||
className="w-full rounded-lg border border-slate-300 px-3 py-2 outline-none ring-0 transition focus:border-blue-500"
|
||||
value={query}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
placeholder="Search by order number, customer name, or email"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { OrderDetail } from "@orderops/shared";
|
||||
|
||||
interface OrderSummaryProps {
|
||||
order: OrderDetail;
|
||||
}
|
||||
|
||||
export function OrderSummaryPanel({ order }: OrderSummaryProps) {
|
||||
return (
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">{order.orderNumber}</h2>
|
||||
<p className="text-sm text-slate-600">{order.customer.name} · {order.customer.email}</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-slate-100 px-3 py-1 text-sm font-medium text-slate-700">{order.status}</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { OrderSummary } from "@orderops/shared";
|
||||
import { listOrders } from "./order-api";
|
||||
import { OrderList } from "./order-list";
|
||||
import { OrderSearch } from "./order-search";
|
||||
|
||||
export function OrdersPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [orders, setOrders] = useState<OrderSummary[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = window.setTimeout(() => {
|
||||
void listOrders(query)
|
||||
.then((nextOrders) => {
|
||||
setOrders(nextOrders);
|
||||
setError(null);
|
||||
})
|
||||
.catch((caught: unknown) => setError(caught instanceof Error ? caught.message : "Failed to load orders."));
|
||||
}, 150);
|
||||
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [query]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-slate-900">Orders</h1>
|
||||
<p className="text-sm text-slate-600">Search orders and open an order workspace.</p>
|
||||
</div>
|
||||
<OrderSearch query={query} onQueryChange={setQuery} />
|
||||
{error ? <div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div> : null}
|
||||
<OrderList orders={orders} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useState } from "react";
|
||||
import type { Address, OrderDetail } from "@orderops/shared";
|
||||
|
||||
interface ShippingAddressFormProps {
|
||||
order: OrderDetail;
|
||||
onSave: (address: Address) => Promise<void>;
|
||||
}
|
||||
|
||||
const addressFields: Array<{ key: keyof Address; label: string }> = [
|
||||
{ key: "fullName", label: "Full name" },
|
||||
{ key: "line1", label: "Line 1" },
|
||||
{ key: "line2", label: "Line 2" },
|
||||
{ key: "city", label: "City" },
|
||||
{ key: "state", label: "State" },
|
||||
{ key: "postalCode", label: "Postal code" },
|
||||
{ key: "country", label: "Country" }
|
||||
];
|
||||
|
||||
export function ShippingAddressForm({ order, onSave }: ShippingAddressFormProps) {
|
||||
const [form, setForm] = useState(order.shippingAddress);
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<h3 className="mb-3 text-sm font-semibold text-slate-700">Shipping address</h3>
|
||||
<form
|
||||
className="grid gap-3 md:grid-cols-2"
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
await onSave(form);
|
||||
}}
|
||||
>
|
||||
{addressFields.map(({ key, label }) => (
|
||||
<label key={key} className="block text-sm text-slate-700">
|
||||
<span className="mb-1 block">{label}</span>
|
||||
<input
|
||||
className="w-full rounded-lg border border-slate-300 px-3 py-2"
|
||||
value={form[key] ?? ""}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
[key]: event.target.value || (key === "line2" ? null : event.target.value)
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<div className="md:col-span-2">
|
||||
<button className="rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white" type="submit">
|
||||
Save address
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
@@ -0,0 +1,24 @@
|
||||
interface ToastProps {
|
||||
message: string;
|
||||
variant: "success" | "error";
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
const variantClasses = {
|
||||
success: "border-emerald-200 bg-emerald-50 text-emerald-800",
|
||||
error: "border-red-200 bg-red-50 text-red-700"
|
||||
} as const;
|
||||
|
||||
export function Toast({ message, variant, onDismiss }: ToastProps) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-start justify-between gap-3 rounded-xl border px-4 py-3 text-sm shadow-sm ${variantClasses[variant]}`}
|
||||
role={variant === "error" ? "alert" : "status"}
|
||||
>
|
||||
<span>{message}</span>
|
||||
<button className="font-medium" onClick={onDismiss} type="button">
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": "http://localhost:3001"
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user