completed frontend v1

This commit is contained in:
2026-06-13 00:49:16 +03:00
parent 4a5357ee5f
commit 0d98a060e7
35 changed files with 1175 additions and 409 deletions
+4
View File
@@ -39,6 +39,10 @@ dependencies {
//swagger
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.16")
// spring security
implementation("org.springframework.boot:spring-boot-starter-security")
}
tasks.withType<Test> {
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:8080/api
+1
View File
@@ -22,6 +22,7 @@
"@tanstack/react-devtools": "latest",
"@tanstack/react-form": "latest",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-query-devtools": "^5.101.0",
"@tanstack/react-router": "latest",
"@tanstack/react-router-devtools": "latest",
"@tanstack/react-router-ssr-query": "latest",
+20
View File
@@ -26,6 +26,9 @@ importers:
'@tanstack/react-query':
specifier: ^5.101.0
version: 5.101.0([email protected])
'@tanstack/react-query-devtools':
specifier: ^5.101.0
version: 5.101.0(@tanstack/[email protected]([email protected]))([email protected])
'@tanstack/react-router':
specifier: latest
version: 1.170.15([email protected]([email protected]))([email protected])
@@ -1768,6 +1771,9 @@ packages:
'@tanstack/[email protected]':
resolution: {integrity: sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==}
'@tanstack/[email protected]':
resolution: {integrity: sha512-MVqw17k08RQtGGLEL654+dX/btbX9p/8WjkznO//zusLTMaObxi3Q+MoFwGVkC9K3tqjn8qrrNhJevXx4fJTeQ==}
'@tanstack/[email protected]':
resolution: {integrity: sha512-orVsRJ7oAXFb7oyafQCgx9YuK44jpILh5T/ddYuxAsolNfN5DZBr5/NLrWErD7HCGIzvYzg1TZI4sPxmiKvtvA==}
engines: {node: '>=18'}
@@ -1786,6 +1792,12 @@ packages:
'@tanstack/react-start':
optional: true
'@tanstack/[email protected]':
resolution: {integrity: sha512-cpZA0+WqKXwrwMfiWZEGGF6QrIWVQFbhBtxqDF5sQsAfrFf47HIE6fiPbQU3wyAUEN2+7UNqLCQe7oG6m3f93w==}
peerDependencies:
'@tanstack/react-query': ^5.101.0
react: ^18 || ^19
'@tanstack/[email protected]':
resolution: {integrity: sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==}
peerDependencies:
@@ -4575,6 +4587,8 @@ snapshots:
'@tanstack/[email protected]': {}
'@tanstack/[email protected]': {}
'@tanstack/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])([email protected])':
dependencies:
'@tanstack/devtools': 0.12.2([email protected])([email protected])
@@ -4598,6 +4612,12 @@ snapshots:
transitivePeerDependencies:
- react-dom
'@tanstack/[email protected](@tanstack/[email protected]([email protected]))([email protected])':
dependencies:
'@tanstack/query-devtools': 5.101.0
'@tanstack/react-query': 5.101.0([email protected])
react: 19.2.7
'@tanstack/[email protected]([email protected])':
dependencies:
'@tanstack/query-core': 5.101.0
+62
View File
@@ -0,0 +1,62 @@
import type {ApiErrorResponse} from "#/types/api";
const API_BASE_URL =
import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8080/api";
export class ApiError extends Error {
status: number;
code: string;
details: unknown;
constructor(status: number, error: ApiErrorResponse) {
super(error.message || "Request failed");
this.name = "ApiError";
this.status = status;
this.code = error.error;
this.details = error.details;
}
}
type RequestOptions = {
method?: "GET" | "POST" | "DELETE" | "PUT" | "PATCH";
body?: unknown;
signal?: AbortSignal;
};
export async function apiRequest<T>(
path: string,
options: RequestOptions = {},
): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
method: options.method ?? "GET",
headers: {
"Content-Type": "application/json",
},
body: options.body ? JSON.stringify(options.body) : undefined,
signal: options.signal,
});
if (!response.ok) {
let errorBody: ApiErrorResponse;
try {
errorBody = await response.json();
} catch {
errorBody = {
error: "REQUEST_FAILED",
message: `Request failed with status ${response.status}`,
details: null,
timestamp: new Date().toISOString(),
path,
};
}
throw new ApiError(response.status, errorBody);
}
if (response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
}
+20
View File
@@ -0,0 +1,20 @@
import {apiRequest} from "#/api/client";
import type {FareCalculationResponse} from "#/types/fare";
export function calculateFare(
distanceKm: number,
surgeMultiplier?: number,
signal?: AbortSignal,
) {
const params = new URLSearchParams();
params.set("distanceKm", String(distanceKm));
if (surgeMultiplier !== undefined) {
params.set("surgeMultiplier", String(surgeMultiplier));
}
return apiRequest<FareCalculationResponse>(
`/fare/calculate?${params.toString()}`,
{signal},
);
}
+24
View File
@@ -0,0 +1,24 @@
import {apiRequest} from "#/api/client";
import type {InventoryResponse, LowStockInventoryResponse,} from "#/types/inventory";
export function getInventory(signal?: AbortSignal) {
return apiRequest<InventoryResponse>("/inventory", {signal});
}
export function getLowStockInventory(
threshold?: number,
signal?: AbortSignal,
) {
const params = new URLSearchParams();
if (threshold !== undefined) {
params.set("threshold", String(threshold));
}
const query = params.toString();
return apiRequest<LowStockInventoryResponse>(
`/inventory/low-stock${query ? `?${query}` : ""}`,
{signal},
);
}
+41
View File
@@ -0,0 +1,41 @@
import {apiRequest} from "#/api/client";
import type {PageResponse} from "#/types/api";
import type {CancelOrderResponse, Order, OrderFilters, OrderSummary,} from "#/types/order";
export function getOrders(
filters: OrderFilters,
page = 0,
size = 20,
signal?: AbortSignal,
) {
const params = new URLSearchParams();
if (filters.status) {
params.set("status", filters.status);
}
if (filters.from) {
params.set("from", filters.from);
}
if (filters.to) {
params.set("to", filters.to);
}
params.set("page", String(page));
params.set("size", String(size));
return apiRequest<PageResponse<OrderSummary>>(`/orders?${params.toString()}`, {
signal,
});
}
export function getOrder(id: string, signal?: AbortSignal) {
return apiRequest<Order>(`/orders/${id}`, {signal});
}
export function cancelOrder(id: string) {
return apiRequest<CancelOrderResponse>(`/orders/${id}`, {
method: "DELETE",
});
}
@@ -0,0 +1,80 @@
import {FareCalculatorCard} from "#/components/dashboard/FareCalculatorCard";
import {InventoryView} from "#/components/dashboard/InventoryView";
import {OrderHistoryView} from "#/components/dashboard/OrderHistoryView";
import {cn} from "#/lib/utils";
import {Boxes, ClipboardList} from "lucide-react";
import {useState} from "react";
type Tab = "inventory" | "history";
export function Dashboard() {
const [activeTab, setActiveTab] = useState<Tab>("inventory");
return (
<main className="min-h-screen bg-slate-100">
<div className="mx-auto max-w-7xl px-4 py-8">
<header className="mb-6">
<p className="text-sm font-medium uppercase tracking-wide text-slate-500">
Ops Dashboard
</p>
<h1 className="mt-1 text-3xl font-bold text-slate-950">
Order Management + Fare Engine
</h1>
<p className="mt-2 max-w-2xl text-slate-600">
Monitor stock levels, review order history, cancel orders with stock
rollback, and calculate trip fares.
</p>
</header>
<div className="mb-6">
<FareCalculatorCard/>
</div>
<div className="mb-4 flex rounded-xl border border-slate-200 bg-white p-1 shadow-sm">
<TabButton
active={activeTab === "inventory"}
onClick={() => setActiveTab("inventory")}
icon={<Boxes size={16}/>}
>
Inventory
</TabButton>
<TabButton
active={activeTab === "history"}
onClick={() => setActiveTab("history")}
icon={<ClipboardList size={16}/>}
>
Trip / Order History
</TabButton>
</div>
{activeTab === "inventory" ? <InventoryView/> : <OrderHistoryView/>}
</div>
</main>
);
}
type TabButtonProps = {
active: boolean;
onClick: () => void;
icon: React.ReactNode;
children: React.ReactNode;
};
function TabButton({active, onClick, icon, children}: TabButtonProps) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex flex-1 items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition",
active
? "bg-slate-900 text-white shadow-sm"
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900",
)}
>
{icon}
{children}
</button>
);
}
@@ -0,0 +1,154 @@
import {useFareCalculation} from "#/hooks/useFareCalculation";
import {formatCurrency} from "#/hooks/formatCurrency";
import {Calculator} from "lucide-react";
import {useState} from "react";
export function FareCalculatorCard() {
const [distanceKm, setDistanceKm] = useState("10");
const [surgeMultiplier, setSurgeMultiplier] = useState("1.0");
const [validationError, setValidationError] = useState<string | null>(null);
const fareMutation = useFareCalculation();
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const distance = Number(distanceKm);
const surge = surgeMultiplier ? Number(surgeMultiplier) : undefined;
if (Number.isNaN(distance) || distance <= 0) {
setValidationError("Distance must be greater than 0.");
return;
}
if (surge !== undefined && (Number.isNaN(surge) || surge < 1)) {
setValidationError("Surge multiplier must be at least 1.0.");
return;
}
setValidationError(null);
fareMutation.mutate({
distanceKm: distance,
surgeMultiplier: surge,
});
}
const data = fareMutation.data;
return (
<section className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
<div className="flex items-center gap-2">
<div className="rounded-lg bg-slate-900 p-2 text-white">
<Calculator size={18}/>
</div>
<div>
<h2 className="font-semibold text-slate-900">Fare calculator</h2>
<p className="text-sm text-slate-500">
Base fare + distance rate, with surge and minimum fare applied.
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="mt-4 grid gap-3 md:grid-cols-3">
<label className="space-y-1">
<span className="text-xs font-medium text-slate-600">
Distance KM
</span>
<input
type="number"
min="0"
step="0.1"
value={distanceKm}
onChange={(event) => setDistanceKm(event.target.value)}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm outline-none focus:border-slate-900"
/>
</label>
<label className="space-y-1">
<span className="text-xs font-medium text-slate-600">
Surge multiplier
</span>
<input
type="number"
min="1"
step="0.1"
value={surgeMultiplier}
onChange={(event) => setSurgeMultiplier(event.target.value)}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm outline-none focus:border-slate-900"
/>
</label>
<div className="flex items-end">
<button
type="submit"
disabled={fareMutation.isPending}
className="w-full rounded-lg bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{fareMutation.isPending ? "Calculating..." : "Calculate fare"}
</button>
</div>
</form>
{validationError ? (
<p className="mt-4 rounded-lg bg-amber-50 p-3 text-sm text-amber-700">
{validationError}
</p>
) : null}
{fareMutation.isError ? (
<p className="mt-4 rounded-lg bg-red-50 p-3 text-sm text-red-700">
{fareMutation.error instanceof Error
? fareMutation.error.message
: "Failed to calculate fare"}
</p>
) : null}
{data ? (
<div className="mt-4 grid gap-3 rounded-xl bg-slate-50 p-4 md:grid-cols-5">
<FareMetric
label="Base"
value={formatCurrency(data.baseFare, data.currency)}
/>
<FareMetric
label="Per KM"
value={formatCurrency(data.perKmRate, data.currency)}
/>
<FareMetric label="Surge" value={`${data.surgeMultiplier}x`}/>
<FareMetric
label="Minimum"
value={formatCurrency(data.minimumFare, data.currency)}
/>
<FareMetric
label="Total"
value={formatCurrency(data.calculatedFare, data.currency)}
strong
/>
</div>
) : null}
</section>
);
}
type FareMetricProps = {
label: string;
value: string;
strong?: boolean;
};
function FareMetric({label, value, strong = false}: FareMetricProps) {
return (
<div>
<p className="text-xs text-slate-500">{label}</p>
<p
className={
strong
? "mt-1 text-lg font-bold text-slate-900"
: "mt-1 font-semibold text-slate-800"
}
>
{value}
</p>
</div>
);
}
@@ -0,0 +1,81 @@
import {InventoryTable} from "#/components/inventory/InventoryTable";
import {EmptyState} from "#/components/ui/EmptyState";
import {LoadingState} from "#/components/ui/LoadingState";
import {useInventory} from "#/hooks/useInventory";
import {ErrorState} from "#/components/ui/ErrorState.tsx";
export function InventoryView() {
const inventoryQuery = useInventory();
if (inventoryQuery.isLoading) {
return (
<LoadingState
title="Loading inventory"
description="Fetching current stock levels."
/>
);
}
if (inventoryQuery.isError) {
return (
<ErrorState
title="Could not load inventory"
message={
inventoryQuery.error instanceof Error
? inventoryQuery.error.message
: "Failed to load inventory"
}
onRetry={() => inventoryQuery.refetch()}
/>
);
}
const items = inventoryQuery.data?.items ?? [];
if (items.length === 0) {
return (
<EmptyState
title="No inventory found"
description="Inventory records will appear here once products are seeded or added."
/>
);
}
const lowStockCount = items.filter((item) => item.status === "LOW_STOCK").length;
const outOfStockCount = items.filter(
(item) => item.status === "OUT_OF_STOCK",
).length;
return (
<section className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<p className="text-sm text-slate-500">Total products</p>
<p className="mt-2 text-2xl font-semibold text-slate-900">
{items.length}
</p>
</div>
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4">
<p className="text-sm text-amber-700">Low stock</p>
<p className="mt-2 text-2xl font-semibold text-amber-900">
{lowStockCount}
</p>
</div>
<div className="rounded-xl border border-red-200 bg-red-50 p-4">
<p className="text-sm text-red-700">Out of stock</p>
<p className="mt-2 text-2xl font-semibold text-red-900">
{outOfStockCount}
</p>
</div>
</div>
{inventoryQuery.isFetching ? (
<p className="text-sm text-slate-500">Refreshing inventory...</p>
) : null}
<InventoryTable items={items}/>
</section>
);
}
@@ -0,0 +1,70 @@
import {OrderFilters} from "#/components/orders/OrderFilters";
import {OrderHistoryTable} from "#/components/orders/OrderHistoryTable";
import {EmptyState} from "#/components/ui/EmptyState";
import {ErrorState} from "#/components/ui/ErrorState";
import {LoadingState} from "#/components/ui/LoadingState";
import {useOrders} from "#/hooks/useOrders";
import type {OrderFilters as OrderFiltersType} from "#/types/order";
import {useState} from "react";
export function OrderHistoryView() {
const [filters, setFilters] = useState<OrderFiltersType>({
status: "",
from: "",
to: "",
});
const ordersQuery = useOrders(filters);
return (
<section className="space-y-4">
<OrderFilters filters={filters} onChange={setFilters}/>
{ordersQuery.isLoading ? (
<LoadingState
title="Loading order history"
description="Fetching orders matching your filters."
/>
) : null}
{ordersQuery.isError ? (
<ErrorState
title="Could not load order history"
message={
ordersQuery.error instanceof Error
? ordersQuery.error.message
: "Failed to load order history"
}
onRetry={() => ordersQuery.refetch()}
/>
) : null}
{!ordersQuery.isLoading &&
!ordersQuery.isError &&
ordersQuery.data?.items.length === 0 ? (
<EmptyState
title="No orders found"
description="Try changing the status or date range filters."
/>
) : null}
{!ordersQuery.isLoading &&
!ordersQuery.isError &&
ordersQuery.data &&
ordersQuery.data.items.length > 0 ? (
<>
{ordersQuery.isFetching ? (
<p className="text-sm text-slate-500">Refreshing order history...</p>
) : null}
<OrderHistoryTable orders={ordersQuery.data.items}/>
<p className="text-sm text-slate-500">
Showing {ordersQuery.data.items.length} of{" "}
{ordersQuery.data.totalItems} orders.
</p>
</>
) : null}
</section>
);
}
@@ -0,0 +1,27 @@
import {cn} from "#/lib/utils";
import type {InventoryStatus} from "#/types/inventory";
type Props = {
status: InventoryStatus;
};
const labelMap: Record<InventoryStatus, string> = {
IN_STOCK: "In stock",
LOW_STOCK: "Low stock",
OUT_OF_STOCK: "Out of stock",
};
export function InventoryStatusBadge({status}: Props) {
return (
<span
className={cn(
"inline-flex rounded-full px-2.5 py-1 text-xs font-medium",
status === "IN_STOCK" && "bg-emerald-50 text-emerald-700",
status === "LOW_STOCK" && "bg-amber-50 text-amber-700",
status === "OUT_OF_STOCK" && "bg-red-50 text-red-700",
)}
>
{labelMap[status]}
</span>
);
}
@@ -0,0 +1,53 @@
import {InventoryStatusBadge} from "#/components/inventory/InventoryStatusBadge";
import type {InventoryItem} from "#/types/inventory";
import {formatDate} from "#/hooks/formatDate";
type Props = {
items: InventoryItem[];
};
export function InventoryTable({items}: Props) {
return (
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<table className="w-full border-collapse text-left text-sm">
<thead className="bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
<tr>
<th className="px-4 py-3">Product</th>
<th className="px-4 py-3">SKU</th>
<th className="px-4 py-3">Available</th>
<th className="px-4 py-3">Reserved</th>
<th className="px-4 py-3">Reorder Level</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3">Updated</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{items.map((item) => (
<tr key={item.productId} className="hover:bg-slate-50">
<td className="px-4 py-3 font-medium text-slate-900">
{item.productName}
</td>
<td className="px-4 py-3 text-slate-600">{item.sku}</td>
<td className="px-4 py-3 text-slate-900">
{item.availableQuantity}
</td>
<td className="px-4 py-3 text-slate-600">
{item.reservedQuantity}
</td>
<td className="px-4 py-3 text-slate-600">
{item.reorderLevel}
</td>
<td className="px-4 py-3">
<InventoryStatusBadge status={item.status}/>
</td>
<td className="px-4 py-3 text-slate-600">
{formatDate(item.updatedAt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,48 @@
import {useCancelOrder} from "#/hooks/useOrders";
import type {OrderStatus} from "#/types/order";
type Props = {
orderId: string;
status: OrderStatus;
};
export function CancelOrderButton({orderId, status}: Props) {
const cancelMutation = useCancelOrder();
if (status !== "PLACED") {
return <span className="text-sm text-slate-400">—</span>;
}
function handleCancel() {
const confirmed = window.confirm(
"Cancel this order? Stock will be rolled back.",
);
if (!confirmed) {
return;
}
cancelMutation.mutate(orderId);
}
return (
<div className="space-y-1">
<button
type="button"
disabled={cancelMutation.isPending}
onClick={handleCancel}
className="rounded-lg bg-red-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{cancelMutation.isPending ? "Cancelling..." : "Cancel"}
</button>
{cancelMutation.error ? (
<p className="text-xs text-red-600">
{cancelMutation.error instanceof Error
? cancelMutation.error.message
: "Failed to cancel order"}
</p>
) : null}
</div>
);
}
@@ -0,0 +1,67 @@
import type {OrderFilters as OrderFiltersType, OrderStatus,} from "#/types/order";
type Props = {
filters: OrderFiltersType;
onChange: (filters: OrderFiltersType) => void;
};
export function OrderFilters({filters, onChange}: Props) {
function updateFilter<Key extends keyof OrderFiltersType>(
key: Key,
value: OrderFiltersType[Key],
) {
onChange({
...filters,
[key]: value,
});
}
return (
<div className="grid gap-3 rounded-xl border border-slate-200 bg-white p-4 shadow-sm md:grid-cols-4">
<label className="space-y-1">
<span className="text-xs font-medium text-slate-600">Status</span>
<select
value={filters.status ?? ""}
onChange={(event) =>
updateFilter("status", event.target.value as OrderStatus | "")
}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm outline-none focus:border-slate-900"
>
<option value="">All</option>
<option value="PLACED">Placed</option>
<option value="CANCELLED">Cancelled</option>
</select>
</label>
<label className="space-y-1">
<span className="text-xs font-medium text-slate-600">From</span>
<input
type="date"
value={filters.from ?? ""}
onChange={(event) => updateFilter("from", event.target.value)}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm outline-none focus:border-slate-900"
/>
</label>
<label className="space-y-1">
<span className="text-xs font-medium text-slate-600">To</span>
<input
type="date"
value={filters.to ?? ""}
onChange={(event) => updateFilter("to", event.target.value)}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm outline-none focus:border-slate-900"
/>
</label>
<div className="flex items-end">
<button
type="button"
onClick={() => onChange({status: "", from: "", to: ""})}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
>
Reset filters
</button>
</div>
</div>
);
}
@@ -0,0 +1,59 @@
import {CancelOrderButton} from "#/components/orders/CancelOrderButton";
import {OrderStatusBadge} from "#/components/orders/OrderStatusBadge";
import type {OrderSummary} from "#/types/order";
import {formatCurrency} from "#/hooks/formatCurrency";
import {formatDate} from "#/hooks/formatDate";
type Props = {
orders: OrderSummary[];
};
export function OrderHistoryTable({orders}: Props) {
return (
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<table className="w-full border-collapse text-left text-sm">
<thead className="bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
<tr>
<th className="px-4 py-3">Order</th>
<th className="px-4 py-3">Customer</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3">Items</th>
<th className="px-4 py-3">Total</th>
<th className="px-4 py-3">Created</th>
<th className="px-4 py-3">Cancelled</th>
<th className="px-4 py-3">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{orders.map((order) => (
<tr key={order.id} className="hover:bg-slate-50">
<td className="px-4 py-3 font-mono text-xs text-slate-600">
{order.id.slice(0, 8)}
</td>
<td className="px-4 py-3 font-medium text-slate-900">
{order.customerName}
</td>
<td className="px-4 py-3">
<OrderStatusBadge status={order.status}/>
</td>
<td className="px-4 py-3 text-slate-600">{order.itemCount}</td>
<td className="px-4 py-3 font-medium text-slate-900">
{formatCurrency(order.totalAmount, order.currency)}
</td>
<td className="px-4 py-3 text-slate-600">
{formatDate(order.createdAt)}
</td>
<td className="px-4 py-3 text-slate-600">
{formatDate(order.cancelledAt)}
</td>
<td className="px-4 py-3">
<CancelOrderButton orderId={order.id} status={order.status}/>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,20 @@
import {cn} from "#/lib/utils";
import type {OrderStatus} from "#/types/order";
type Props = {
status: OrderStatus;
};
export function OrderStatusBadge({status}: Props) {
return (
<span
className={cn(
"inline-flex rounded-full px-2.5 py-1 text-xs font-medium",
status === "PLACED" && "bg-blue-50 text-blue-700",
status === "CANCELLED" && "bg-slate-100 text-slate-600",
)}
>
{status === "PLACED" ? "Placed" : "Cancelled"}
</span>
);
}
@@ -0,0 +1,13 @@
type EmptyStateProps = {
title: string;
description: string;
};
export function EmptyState({title, description}: EmptyStateProps) {
return (
<div className="rounded-xl border border-dashed border-slate-300 bg-white p-8 text-center">
<h3 className="font-semibold text-slate-900">{title}</h3>
<p className="mt-1 text-sm text-slate-500">{description}</p>
</div>
);
}
@@ -0,0 +1,28 @@
type ErrorStateProps = {
title?: string;
message: string;
onRetry?: () => void;
};
export function ErrorState({
title = "Something went wrong",
message,
onRetry,
}: ErrorStateProps) {
return (
<div className="rounded-xl border border-red-200 bg-red-50 p-8 text-center">
<h3 className="font-semibold text-red-900">{title}</h3>
<p className="mt-1 text-sm text-red-700">{message}</p>
{onRetry ? (
<button
type="button"
onClick={onRetry}
className="mt-4 rounded-lg bg-red-700 px-4 py-2 text-sm font-medium text-white hover:bg-red-800"
>
Try again
</button>
) : null}
</div>
);
}
@@ -0,0 +1,18 @@
type LoadingStateProps = {
title?: string;
description?: string;
};
export function LoadingState({
title = "Loading",
description = "Fetching the latest data...",
}: LoadingStateProps) {
return (
<div className="rounded-xl border border-slate-200 bg-white p-8 text-center shadow-sm">
<div
className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-slate-200 border-t-slate-900"/>
<h3 className="font-semibold text-slate-900">{title}</h3>
<p className="mt-1 text-sm text-slate-500">{description}</p>
</div>
);
}
@@ -0,0 +1,7 @@
export function formatCurrency(amount: number, currency = "KES") {
return new Intl.NumberFormat("en-KE", {
style: "currency",
currency,
maximumFractionDigits: 2,
}).format(amount);
}
+10
View File
@@ -0,0 +1,10 @@
export function formatDate(value: string | null | undefined) {
if (!value) {
return "—";
}
return new Intl.DateTimeFormat("en-KE", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
@@ -0,0 +1,14 @@
import {calculateFare} from "#/api/fare";
import {useMutation} from "@tanstack/react-query";
type FareCalculationInput = {
distanceKm: number;
surgeMultiplier?: number;
};
export function useFareCalculation() {
return useMutation({
mutationFn: ({distanceKm, surgeMultiplier}: FareCalculationInput) =>
calculateFare(distanceKm, surgeMultiplier),
});
}
+23
View File
@@ -0,0 +1,23 @@
import {getInventory, getLowStockInventory} from "#/api/inventory";
import {useQuery} from "@tanstack/react-query";
export const inventoryQueryKeys = {
all: ["inventory"] as const,
list: () => [...inventoryQueryKeys.all, "list"] as const,
lowStock: (threshold?: number) =>
[...inventoryQueryKeys.all, "low-stock", threshold ?? "default"] as const,
};
export function useInventory() {
return useQuery({
queryKey: inventoryQueryKeys.list(),
queryFn: ({signal}) => getInventory(signal),
});
}
export function useLowStockInventory(threshold?: number) {
return useQuery({
queryKey: inventoryQueryKeys.lowStock(threshold),
queryFn: ({signal}) => getLowStockInventory(threshold, signal),
});
}
+35
View File
@@ -0,0 +1,35 @@
import {cancelOrder, getOrders} from "#/api/orders";
import {inventoryQueryKeys} from "#/hooks/useInventory";
import type {OrderFilters} from "#/types/order";
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query";
export const orderQueryKeys = {
all: ["orders"] as const,
list: (filters: OrderFilters, page = 0, size = 20) =>
[...orderQueryKeys.all, "list", filters, page, size] as const,
};
export function useOrders(filters: OrderFilters, page = 0, size = 20) {
return useQuery({
queryKey: orderQueryKeys.list(filters, page, size),
queryFn: ({signal}) => getOrders(filters, page, size, signal),
});
}
export function useCancelOrder() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: cancelOrder,
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({
queryKey: orderQueryKeys.all,
}),
queryClient.invalidateQueries({
queryKey: inventoryQueryKeys.all,
}),
]);
},
});
}
+17
View File
@@ -0,0 +1,17 @@
import {QueryClient} from "@tanstack/react-query";
export function createQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 15_000,
gcTime: 5 * 60_000,
retry: 1,
refetchOnWindowFocus: false,
},
mutations: {
retry: 0,
},
},
});
}
+22 -13
View File
@@ -1,19 +1,28 @@
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
import {createRouter as createTanStackRouter} from "@tanstack/react-router";
import {setupRouterSsrQueryIntegration} from "@tanstack/react-router-ssr-query";
import {createQueryClient} from "#/lib/queryClient";
import {routeTree} from "./routeTree.gen";
export function getRouter() {
const router = createTanStackRouter({
routeTree,
scrollRestoration: true,
defaultPreload: 'intent',
defaultPreloadStaleTime: 0,
})
const queryClient = createQueryClient();
return router
const router = createTanStackRouter({
routeTree,
context: {queryClient},
scrollRestoration: true,
defaultPreload: "intent",
});
setupRouterSsrQueryIntegration({
router,
queryClient,
});
return router;
}
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof getRouter>
}
declare module "@tanstack/react-router" {
interface Register {
router: ReturnType<typeof getRouter>;
}
}
+44 -51
View File
@@ -1,54 +1,47 @@
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import { TanStackDevtools } from '@tanstack/react-devtools'
import type {QueryClient} from "@tanstack/react-query";
import {ReactQueryDevtools} from "@tanstack/react-query-devtools";
import {createRootRouteWithContext, HeadContent, Outlet, Scripts,} from "@tanstack/react-router";
import {TanStackRouterDevtools} from "@tanstack/react-router-devtools";
import appCss from "#/styles.css?url";
import appCss from '../styles.css?url'
export const Route = createRootRouteWithContext<{
queryClient: QueryClient;
}>()({
head: () => ({
meta: [
{
charSet: "utf-8",
},
{
name: "viewport",
content: "width=device-width, initial-scale=1",
},
{
title: "Orders Dashboard",
},
],
links: [
{
rel: "stylesheet",
href: appCss,
},
],
}),
component: RootComponent,
});
export const Route = createRootRoute({
head: () => ({
meta: [
{
charSet: 'utf-8',
},
{
name: 'viewport',
content: 'width=device-width, initial-scale=1',
},
{
title: 'TanStack Start Starter',
},
],
links: [
{
rel: 'stylesheet',
href: appCss,
},
],
}),
shellComponent: RootDocument,
})
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
{children}
<TanStackDevtools
config={{
position: 'bottom-right',
}}
plugins={[
{
name: 'Tanstack Router',
render: <TanStackRouterDevtoolsPanel />,
},
]}
/>
<Scripts />
</body>
</html>
)
function RootComponent() {
return (
<html lang="en">
<head>
<HeadContent/>
</head>
<body>
<Outlet/>
<TanStackRouterDevtools position="bottom-right"/>
<ReactQueryDevtools initialIsOpen={false}/>
<Scripts/>
</body>
</html>
);
}
+5 -13
View File
@@ -1,14 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import {Dashboard} from "#/components/dashboard/Dashboard";
import {createFileRoute} from "@tanstack/react-router";
export const Route = createFileRoute('/')({ component: Home })
function Home() {
return (
<div className="p-8">
<h1 className="text-4xl font-bold">Welcome to TanStack Start</h1>
<p className="mt-4 text-lg">
Edit <code>src/routes/index.tsx</code> to get started.
</p>
</div>
)
}
export const Route = createFileRoute("/")({
component: Dashboard,
});
+17 -332
View File
@@ -1,347 +1,32 @@
@import url('https://fonts.googleapis.com/css2?family=Fraunces:opsz,[email protected],500;9..144,700&family=Manrope:wght@400;500;600;700;800&display=swap');
@import 'tailwindcss';
@plugin '@tailwindcss/typography';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
@import "tailwindcss";
:root {
--sea-ink: #173a40;
--sea-ink-soft: #416166;
--lagoon: #4fb8b2;
--lagoon-deep: #328f97;
--palm: #2f6a4a;
--sand: #e7f0e8;
--foam: #f3faf5;
--surface: rgba(255, 255, 255, 0.74);
--surface-strong: rgba(255, 255, 255, 0.9);
--line: rgba(23, 58, 64, 0.14);
--inset-glint: rgba(255, 255, 255, 0.82);
--kicker: rgba(47, 106, 74, 0.9);
--bg-base: #e7f3ec;
--header-bg: rgba(251, 255, 248, 0.84);
--chip-bg: rgba(255, 255, 255, 0.8);
--chip-line: rgba(47, 106, 74, 0.18);
--link-bg-hover: rgba(255, 255, 255, 0.9);
--hero-a: rgba(79, 184, 178, 0.36);
--hero-b: rgba(47, 106, 74, 0.2);
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.871 0.006 286.286);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.871 0.006 286.286);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
color: #0f172a;
background: #f1f5f9;
}
.dark {
--sea-ink: #d7ece8;
--sea-ink-soft: #afcdc8;
--lagoon: #60d7cf;
--lagoon-deep: #8de5db;
--palm: #6ec89a;
--sand: #0f1a1e;
--foam: #101d22;
--surface: rgba(16, 30, 34, 0.8);
--surface-strong: rgba(15, 27, 31, 0.92);
--line: rgba(141, 229, 219, 0.18);
--inset-glint: rgba(194, 247, 238, 0.14);
--kicker: #b8efe5;
--bg-base: #0a1418;
--header-bg: rgba(10, 20, 24, 0.8);
--chip-bg: rgba(13, 28, 32, 0.9);
--chip-line: rgba(141, 229, 219, 0.24);
--link-bg-hover: rgba(24, 44, 49, 0.8);
--hero-a: rgba(96, 215, 207, 0.18);
--hero-b: rgba(110, 200, 154, 0.12);
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.141 0.005 285.823);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.141 0.005 285.823);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.274 0.006 286.033);
--input: oklch(0.274 0.006 286.033);
--ring: oklch(0.442 0.017 285.786);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.274 0.006 286.033);
--sidebar-ring: oklch(0.442 0.017 285.786);
}
@theme inline {
--font-sans: 'Manrope', ui-sans-serif, system-ui, sans-serif;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
html,
body,
#app {
min-height: 100%;
* {
box-sizing: border-box;
}
body {
margin: 0;
color: var(--sea-ink);
font-family: var(--font-sans);
background-color: var(--bg-base);
background:
radial-gradient(1100px 620px at -8% -10%, var(--hero-a), transparent 58%),
radial-gradient(1050px 620px at 112% -12%, var(--hero-b), transparent 62%),
radial-gradient(720px 380px at 50% 115%, rgba(79, 184, 178, 0.1), transparent 68%),
linear-gradient(180deg, color-mix(in oklab, var(--sand) 68%, white) 0%, var(--foam) 44%, var(--bg-base) 100%);
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: -1;
opacity: 0.28;
background:
radial-gradient(circle at 20% 15%, rgba(255, 255, 255, 0.8), transparent 34%),
radial-gradient(circle at 78% 26%, rgba(79, 184, 178, 0.2), transparent 42%),
radial-gradient(circle at 42% 82%, rgba(47, 106, 74, 0.14), transparent 36%);
}
body::after {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: -1;
opacity: 0.14;
background-image:
linear-gradient(rgba(255, 255, 255, 0.07) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.06) 1px, transparent 1px);
background-size: 28px 28px;
mask-image: radial-gradient(circle at 50% 30%, black, transparent 78%);
}
a {
color: var(--lagoon-deep);
text-decoration-color: rgba(50, 143, 151, 0.4);
text-decoration-thickness: 1px;
text-underline-offset: 2px;
}
a:hover {
color: #246f76;
}
code {
font-size: 0.9em;
border: 1px solid var(--line);
background: color-mix(in oklab, var(--surface-strong) 82%, white 18%);
border-radius: 7px;
padding: 2px 7px;
}
pre code {
border: 0;
background: transparent;
padding: 0;
border-radius: 0;
font-size: inherit;
color: inherit;
}
.prose pre {
border: 1px solid var(--line);
border-radius: 12px;
background: #1d2e45;
color: #e8efff;
}
.page-wrap {
width: min(1080px, calc(100% - 2rem));
margin-inline: auto;
}
.display-title {
font-family: 'Fraunces', Georgia, serif;
}
.island-shell {
border: 1px solid var(--line);
background: linear-gradient(165deg, var(--surface-strong), var(--surface));
box-shadow:
0 1px 0 var(--inset-glint) inset,
0 22px 44px rgba(30, 90, 72, 0.1),
0 6px 18px rgba(23, 58, 64, 0.08);
backdrop-filter: blur(4px);
}
.feature-card {
background: linear-gradient(165deg, color-mix(in oklab, var(--surface-strong) 93%, white 7%), var(--surface));
box-shadow:
0 1px 0 var(--inset-glint) inset,
0 18px 34px rgba(30, 90, 72, 0.1),
0 4px 14px rgba(23, 58, 64, 0.06);
}
.feature-card:hover {
transform: translateY(-2px);
border-color: color-mix(in oklab, var(--lagoon-deep) 35%, var(--line));
min-width: 320px;
min-height: 100vh;
}
button,
.island-shell,
a {
transition: background-color 180ms ease, color 180ms ease, border-color 180ms ease,
transform 180ms ease;
input,
select {
font: inherit;
}
.island-kicker {
letter-spacing: 0.16em;
text-transform: uppercase;
font-weight: 700;
font-size: 0.69rem;
color: var(--kicker);
button {
cursor: pointer;
}
.nav-link {
position: relative;
text-decoration: none;
color: var(--sea-ink-soft);
}
.nav-link::after {
content: '';
position: absolute;
left: 0;
bottom: -8px;
width: 100%;
height: 2px;
transform: scaleX(0);
transform-origin: left;
background: linear-gradient(90deg, var(--lagoon), #7ed3bf);
transition: transform 170ms ease;
}
.nav-link:hover,
.nav-link.is-active {
color: var(--sea-ink);
}
.nav-link:hover::after,
.nav-link.is-active::after {
transform: scaleX(1);
}
.rise-in {
animation: rise-in 700ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes rise-in {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.site-footer {
border-top: 1px solid var(--line);
background: color-mix(in oklab, var(--header-bg) 84%, transparent 16%);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
background-color: var(--background);
color: var(--foreground);
}
}
button:disabled {
cursor: not-allowed;
}
+15
View File
@@ -0,0 +1,15 @@
export type ApiErrorResponse = {
error: string;
message: string;
details: unknown;
timestamp: string;
path: string;
};
export type PageResponse<T> = {
items: T[];
page: number;
size: number;
totalItems: number;
totalPages: number;
};
+10
View File
@@ -0,0 +1,10 @@
export type FareCalculationResponse = {
distanceKm: number;
baseFare: number;
perKmRate: number;
surgeMultiplier: number;
minimumFare: number;
calculatedFare: number;
currency: string;
formula: string;
};
+21
View File
@@ -0,0 +1,21 @@
export type InventoryStatus = "IN_STOCK" | "LOW_STOCK" | "OUT_OF_STOCK";
export type InventoryItem = {
productId: string;
productName: string;
sku: string;
availableQuantity: number;
reservedQuantity: number;
reorderLevel: number;
status: InventoryStatus;
updatedAt: string;
};
export type InventoryResponse = {
items: InventoryItem[];
};
export type LowStockInventoryResponse = {
threshold: number;
items: InventoryItem[];
};
+44
View File
@@ -0,0 +1,44 @@
export type OrderStatus = "PLACED" | "CANCELLED";
export type OrderItem = {
productId: string;
productName: string;
quantity: number;
unitPrice: number;
lineTotal: number;
};
export type Order = {
id: string;
customerName: string;
status: OrderStatus;
items: OrderItem[];
totalAmount: number;
currency: string;
createdAt: string;
cancelledAt: string | null;
};
export type OrderSummary = {
id: string;
customerName: string;
status: OrderStatus;
totalAmount: number;
currency: string;
itemCount: number;
createdAt: string;
cancelledAt: string | null;
};
export type OrderFilters = {
status?: OrderStatus | "";
from?: string;
to?: string;
};
export type CancelOrderResponse = {
id: string;
status: OrderStatus;
cancelledAt: string;
stockRolledBack: boolean;
};