mirror of
https://github.com/andwati/cymelle.git
synced 2026-11-03 23:42:15 +00:00
completed frontend v1
This commit is contained in:
@@ -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> {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=http://localhost:8080/api
|
||||
@@ -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",
|
||||
|
||||
Generated
+20
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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},
|
||||
);
|
||||
}
|
||||
@@ -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},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
]);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
export type FareCalculationResponse = {
|
||||
distanceKm: number;
|
||||
baseFare: number;
|
||||
perKmRate: number;
|
||||
surgeMultiplier: number;
|
||||
minimumFare: number;
|
||||
calculatedFare: number;
|
||||
currency: string;
|
||||
formula: string;
|
||||
};
|
||||
@@ -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[];
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user