From 0d98a060e70fb1b04c337d9285da50bffa805747 Mon Sep 17 00:00:00 2001 From: andwati Date: Sat, 13 Jun 2026 00:49:16 +0300 Subject: [PATCH] completed frontend v1 --- orders-backend/build.gradle.kts | 4 + orders-frontend/.env.example | 1 + orders-frontend/package.json | 1 + orders-frontend/pnpm-lock.yaml | 20 + orders-frontend/src/api/client.ts | 62 ++++ orders-frontend/src/api/fare.ts | 20 + orders-frontend/src/api/inventory.ts | 24 ++ orders-frontend/src/api/orders.ts | 41 ++ .../src/components/dashboard/Dashboard.tsx | 80 ++++ .../dashboard/FareCalculatorCard.tsx | 154 ++++++++ .../components/dashboard/InventoryView.tsx | 81 ++++ .../components/dashboard/OrderHistoryView.tsx | 70 ++++ .../inventory/InventoryStatusBadge.tsx | 27 ++ .../components/inventory/InventoryTable.tsx | 53 +++ .../components/orders/CancelOrderButton.tsx | 48 +++ .../src/components/orders/OrderFilters.tsx | 67 ++++ .../components/orders/OrderHistoryTable.tsx | 59 +++ .../components/orders/OrderStatusBadge.tsx | 20 + .../src/components/ui/EmptyState.tsx | 13 + .../src/components/ui/ErrorState.tsx | 28 ++ .../src/components/ui/LoadingState.tsx | 18 + orders-frontend/src/hooks/formatCurrency.ts | 7 + orders-frontend/src/hooks/formatDate.ts | 10 + .../src/hooks/useFareCalculation.ts | 14 + orders-frontend/src/hooks/useInventory.ts | 23 ++ orders-frontend/src/hooks/useOrders.ts | 35 ++ orders-frontend/src/lib/queryClient.ts | 17 + orders-frontend/src/router.tsx | 35 +- orders-frontend/src/routes/__root.tsx | 95 +++-- orders-frontend/src/routes/index.tsx | 18 +- orders-frontend/src/styles.css | 349 +----------------- orders-frontend/src/types/api.ts | 15 + orders-frontend/src/types/fare.ts | 10 + orders-frontend/src/types/inventory.ts | 21 ++ orders-frontend/src/types/order.ts | 44 +++ 35 files changed, 1175 insertions(+), 409 deletions(-) create mode 100644 orders-frontend/src/api/client.ts create mode 100644 orders-frontend/src/api/fare.ts create mode 100644 orders-frontend/src/api/inventory.ts create mode 100644 orders-frontend/src/api/orders.ts create mode 100644 orders-frontend/src/components/dashboard/Dashboard.tsx create mode 100644 orders-frontend/src/components/dashboard/FareCalculatorCard.tsx create mode 100644 orders-frontend/src/components/dashboard/InventoryView.tsx create mode 100644 orders-frontend/src/components/dashboard/OrderHistoryView.tsx create mode 100644 orders-frontend/src/components/inventory/InventoryStatusBadge.tsx create mode 100644 orders-frontend/src/components/inventory/InventoryTable.tsx create mode 100644 orders-frontend/src/components/orders/CancelOrderButton.tsx create mode 100644 orders-frontend/src/components/orders/OrderFilters.tsx create mode 100644 orders-frontend/src/components/orders/OrderHistoryTable.tsx create mode 100644 orders-frontend/src/components/orders/OrderStatusBadge.tsx create mode 100644 orders-frontend/src/components/ui/EmptyState.tsx create mode 100644 orders-frontend/src/components/ui/ErrorState.tsx create mode 100644 orders-frontend/src/components/ui/LoadingState.tsx create mode 100644 orders-frontend/src/hooks/formatCurrency.ts create mode 100644 orders-frontend/src/hooks/formatDate.ts create mode 100644 orders-frontend/src/hooks/useFareCalculation.ts create mode 100644 orders-frontend/src/hooks/useInventory.ts create mode 100644 orders-frontend/src/hooks/useOrders.ts create mode 100644 orders-frontend/src/lib/queryClient.ts create mode 100644 orders-frontend/src/types/api.ts create mode 100644 orders-frontend/src/types/fare.ts create mode 100644 orders-frontend/src/types/inventory.ts create mode 100644 orders-frontend/src/types/order.ts diff --git a/orders-backend/build.gradle.kts b/orders-backend/build.gradle.kts index 6bd7ce7..f340714 100644 --- a/orders-backend/build.gradle.kts +++ b/orders-backend/build.gradle.kts @@ -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 { diff --git a/orders-frontend/.env.example b/orders-frontend/.env.example index e69de29..dcb77f9 100644 --- a/orders-frontend/.env.example +++ b/orders-frontend/.env.example @@ -0,0 +1 @@ +VITE_API_BASE_URL=http://localhost:8080/api \ No newline at end of file diff --git a/orders-frontend/package.json b/orders-frontend/package.json index 7fa3bcb..2d0f30c 100644 --- a/orders-frontend/package.json +++ b/orders-frontend/package.json @@ -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", diff --git a/orders-frontend/pnpm-lock.yaml b/orders-frontend/pnpm-lock.yaml index 70fbf51..215bda9 100644 --- a/orders-frontend/pnpm-lock.yaml +++ b/orders-frontend/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@tanstack/react-query': specifier: ^5.101.0 version: 5.101.0(react@19.2.7) + '@tanstack/react-query-devtools': + specifier: ^5.101.0 + version: 5.101.0(@tanstack/react-query@5.101.0(react@19.2.7))(react@19.2.7) '@tanstack/react-router': specifier: latest version: 1.170.15(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -1768,6 +1771,9 @@ packages: '@tanstack/query-core@5.101.0': resolution: {integrity: sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==} + '@tanstack/query-devtools@5.101.0': + resolution: {integrity: sha512-MVqw17k08RQtGGLEL654+dX/btbX9p/8WjkznO//zusLTMaObxi3Q+MoFwGVkC9K3tqjn8qrrNhJevXx4fJTeQ==} + '@tanstack/react-devtools@0.10.5': resolution: {integrity: sha512-orVsRJ7oAXFb7oyafQCgx9YuK44jpILh5T/ddYuxAsolNfN5DZBr5/NLrWErD7HCGIzvYzg1TZI4sPxmiKvtvA==} engines: {node: '>=18'} @@ -1786,6 +1792,12 @@ packages: '@tanstack/react-start': optional: true + '@tanstack/react-query-devtools@5.101.0': + resolution: {integrity: sha512-cpZA0+WqKXwrwMfiWZEGGF6QrIWVQFbhBtxqDF5sQsAfrFf47HIE6fiPbQU3wyAUEN2+7UNqLCQe7oG6m3f93w==} + peerDependencies: + '@tanstack/react-query': ^5.101.0 + react: ^18 || ^19 + '@tanstack/react-query@5.101.0': resolution: {integrity: sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==} peerDependencies: @@ -4575,6 +4587,8 @@ snapshots: '@tanstack/query-core@5.101.0': {} + '@tanstack/query-devtools@5.101.0': {} + '@tanstack/react-devtools@0.10.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.13)': dependencies: '@tanstack/devtools': 0.12.2(csstype@3.2.3)(solid-js@1.9.13) @@ -4598,6 +4612,12 @@ snapshots: transitivePeerDependencies: - react-dom + '@tanstack/react-query-devtools@5.101.0(@tanstack/react-query@5.101.0(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/query-devtools': 5.101.0 + '@tanstack/react-query': 5.101.0(react@19.2.7) + react: 19.2.7 + '@tanstack/react-query@5.101.0(react@19.2.7)': dependencies: '@tanstack/query-core': 5.101.0 diff --git a/orders-frontend/src/api/client.ts b/orders-frontend/src/api/client.ts new file mode 100644 index 0000000..5c14e84 --- /dev/null +++ b/orders-frontend/src/api/client.ts @@ -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( + path: string, + options: RequestOptions = {}, +): Promise { + 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; +} \ No newline at end of file diff --git a/orders-frontend/src/api/fare.ts b/orders-frontend/src/api/fare.ts new file mode 100644 index 0000000..e6b9992 --- /dev/null +++ b/orders-frontend/src/api/fare.ts @@ -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( + `/fare/calculate?${params.toString()}`, + {signal}, + ); +} \ No newline at end of file diff --git a/orders-frontend/src/api/inventory.ts b/orders-frontend/src/api/inventory.ts new file mode 100644 index 0000000..a5d06e9 --- /dev/null +++ b/orders-frontend/src/api/inventory.ts @@ -0,0 +1,24 @@ +import {apiRequest} from "#/api/client"; +import type {InventoryResponse, LowStockInventoryResponse,} from "#/types/inventory"; + +export function getInventory(signal?: AbortSignal) { + return apiRequest("/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( + `/inventory/low-stock${query ? `?${query}` : ""}`, + {signal}, + ); +} \ No newline at end of file diff --git a/orders-frontend/src/api/orders.ts b/orders-frontend/src/api/orders.ts new file mode 100644 index 0000000..de494d6 --- /dev/null +++ b/orders-frontend/src/api/orders.ts @@ -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>(`/orders?${params.toString()}`, { + signal, + }); +} + +export function getOrder(id: string, signal?: AbortSignal) { + return apiRequest(`/orders/${id}`, {signal}); +} + +export function cancelOrder(id: string) { + return apiRequest(`/orders/${id}`, { + method: "DELETE", + }); +} \ No newline at end of file diff --git a/orders-frontend/src/components/dashboard/Dashboard.tsx b/orders-frontend/src/components/dashboard/Dashboard.tsx new file mode 100644 index 0000000..52a9f43 --- /dev/null +++ b/orders-frontend/src/components/dashboard/Dashboard.tsx @@ -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("inventory"); + + return ( +
+
+
+

+ Ops Dashboard +

+

+ Order Management + Fare Engine +

+

+ Monitor stock levels, review order history, cancel orders with stock + rollback, and calculate trip fares. +

+
+ +
+ +
+ +
+ setActiveTab("inventory")} + icon={} + > + Inventory + + + setActiveTab("history")} + icon={} + > + Trip / Order History + +
+ + {activeTab === "inventory" ? : } +
+
+ ); +} + +type TabButtonProps = { + active: boolean; + onClick: () => void; + icon: React.ReactNode; + children: React.ReactNode; +}; + +function TabButton({active, onClick, icon, children}: TabButtonProps) { + return ( + + ); +} \ No newline at end of file diff --git a/orders-frontend/src/components/dashboard/FareCalculatorCard.tsx b/orders-frontend/src/components/dashboard/FareCalculatorCard.tsx new file mode 100644 index 0000000..e09c981 --- /dev/null +++ b/orders-frontend/src/components/dashboard/FareCalculatorCard.tsx @@ -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(null); + + const fareMutation = useFareCalculation(); + + function handleSubmit(event: React.FormEvent) { + 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 ( +
+
+
+ +
+
+

Fare calculator

+

+ Base fare + distance rate, with surge and minimum fare applied. +

+
+
+ +
+ + + + +
+ +
+
+ + {validationError ? ( +

+ {validationError} +

+ ) : null} + + {fareMutation.isError ? ( +

+ {fareMutation.error instanceof Error + ? fareMutation.error.message + : "Failed to calculate fare"} +

+ ) : null} + + {data ? ( +
+ + + + + +
+ ) : null} +
+ ); +} + +type FareMetricProps = { + label: string; + value: string; + strong?: boolean; +}; + +function FareMetric({label, value, strong = false}: FareMetricProps) { + return ( +
+

{label}

+

+ {value} +

+
+ ); +} \ No newline at end of file diff --git a/orders-frontend/src/components/dashboard/InventoryView.tsx b/orders-frontend/src/components/dashboard/InventoryView.tsx new file mode 100644 index 0000000..50bb777 --- /dev/null +++ b/orders-frontend/src/components/dashboard/InventoryView.tsx @@ -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 ( + + ); + } + + if (inventoryQuery.isError) { + return ( + inventoryQuery.refetch()} + /> + ); + } + + const items = inventoryQuery.data?.items ?? []; + + if (items.length === 0) { + return ( + + ); + } + + const lowStockCount = items.filter((item) => item.status === "LOW_STOCK").length; + const outOfStockCount = items.filter( + (item) => item.status === "OUT_OF_STOCK", + ).length; + + return ( +
+
+
+

Total products

+

+ {items.length} +

+
+ +
+

Low stock

+

+ {lowStockCount} +

+
+ +
+

Out of stock

+

+ {outOfStockCount} +

+
+
+ + {inventoryQuery.isFetching ? ( +

Refreshing inventory...

+ ) : null} + + +
+ ); +} \ No newline at end of file diff --git a/orders-frontend/src/components/dashboard/OrderHistoryView.tsx b/orders-frontend/src/components/dashboard/OrderHistoryView.tsx new file mode 100644 index 0000000..cc69bc5 --- /dev/null +++ b/orders-frontend/src/components/dashboard/OrderHistoryView.tsx @@ -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({ + status: "", + from: "", + to: "", + }); + + const ordersQuery = useOrders(filters); + + return ( +
+ + + {ordersQuery.isLoading ? ( + + ) : null} + + {ordersQuery.isError ? ( + ordersQuery.refetch()} + /> + ) : null} + + {!ordersQuery.isLoading && + !ordersQuery.isError && + ordersQuery.data?.items.length === 0 ? ( + + ) : null} + + {!ordersQuery.isLoading && + !ordersQuery.isError && + ordersQuery.data && + ordersQuery.data.items.length > 0 ? ( + <> + {ordersQuery.isFetching ? ( +

Refreshing order history...

+ ) : null} + + + +

+ Showing {ordersQuery.data.items.length} of{" "} + {ordersQuery.data.totalItems} orders. +

+ + ) : null} +
+ ); +} \ No newline at end of file diff --git a/orders-frontend/src/components/inventory/InventoryStatusBadge.tsx b/orders-frontend/src/components/inventory/InventoryStatusBadge.tsx new file mode 100644 index 0000000..fc14999 --- /dev/null +++ b/orders-frontend/src/components/inventory/InventoryStatusBadge.tsx @@ -0,0 +1,27 @@ +import {cn} from "#/lib/utils"; +import type {InventoryStatus} from "#/types/inventory"; + +type Props = { + status: InventoryStatus; +}; + +const labelMap: Record = { + IN_STOCK: "In stock", + LOW_STOCK: "Low stock", + OUT_OF_STOCK: "Out of stock", +}; + +export function InventoryStatusBadge({status}: Props) { + return ( + + {labelMap[status]} + + ); +} \ No newline at end of file diff --git a/orders-frontend/src/components/inventory/InventoryTable.tsx b/orders-frontend/src/components/inventory/InventoryTable.tsx new file mode 100644 index 0000000..064721c --- /dev/null +++ b/orders-frontend/src/components/inventory/InventoryTable.tsx @@ -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 ( +
+ + + + + + + + + + + + + + + {items.map((item) => ( + + + + + + + + + + ))} + +
ProductSKUAvailableReservedReorder LevelStatusUpdated
+ {item.productName} + {item.sku} + {item.availableQuantity} + + {item.reservedQuantity} + + {item.reorderLevel} + + + + {formatDate(item.updatedAt)} +
+
+ ); +} \ No newline at end of file diff --git a/orders-frontend/src/components/orders/CancelOrderButton.tsx b/orders-frontend/src/components/orders/CancelOrderButton.tsx new file mode 100644 index 0000000..4174f16 --- /dev/null +++ b/orders-frontend/src/components/orders/CancelOrderButton.tsx @@ -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 —; + } + + function handleCancel() { + const confirmed = window.confirm( + "Cancel this order? Stock will be rolled back.", + ); + + if (!confirmed) { + return; + } + + cancelMutation.mutate(orderId); + } + + return ( +
+ + + {cancelMutation.error ? ( +

+ {cancelMutation.error instanceof Error + ? cancelMutation.error.message + : "Failed to cancel order"} +

+ ) : null} +
+ ); +} \ No newline at end of file diff --git a/orders-frontend/src/components/orders/OrderFilters.tsx b/orders-frontend/src/components/orders/OrderFilters.tsx new file mode 100644 index 0000000..34b85e4 --- /dev/null +++ b/orders-frontend/src/components/orders/OrderFilters.tsx @@ -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: Key, + value: OrderFiltersType[Key], + ) { + onChange({ + ...filters, + [key]: value, + }); + } + + return ( +
+ + + + + + +
+ +
+
+ ); +} \ No newline at end of file diff --git a/orders-frontend/src/components/orders/OrderHistoryTable.tsx b/orders-frontend/src/components/orders/OrderHistoryTable.tsx new file mode 100644 index 0000000..c55d7dd --- /dev/null +++ b/orders-frontend/src/components/orders/OrderHistoryTable.tsx @@ -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 ( +
+ + + + + + + + + + + + + + + + {orders.map((order) => ( + + + + + + + + + + + ))} + +
OrderCustomerStatusItemsTotalCreatedCancelledAction
+ {order.id.slice(0, 8)} + + {order.customerName} + + + {order.itemCount} + {formatCurrency(order.totalAmount, order.currency)} + + {formatDate(order.createdAt)} + + {formatDate(order.cancelledAt)} + + +
+
+ ); +} \ No newline at end of file diff --git a/orders-frontend/src/components/orders/OrderStatusBadge.tsx b/orders-frontend/src/components/orders/OrderStatusBadge.tsx new file mode 100644 index 0000000..92842d1 --- /dev/null +++ b/orders-frontend/src/components/orders/OrderStatusBadge.tsx @@ -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 ( + + {status === "PLACED" ? "Placed" : "Cancelled"} + + ); +} \ No newline at end of file diff --git a/orders-frontend/src/components/ui/EmptyState.tsx b/orders-frontend/src/components/ui/EmptyState.tsx new file mode 100644 index 0000000..aa82fbc --- /dev/null +++ b/orders-frontend/src/components/ui/EmptyState.tsx @@ -0,0 +1,13 @@ +type EmptyStateProps = { + title: string; + description: string; +}; + +export function EmptyState({title, description}: EmptyStateProps) { + return ( +
+

{title}

+

{description}

+
+ ); +} \ No newline at end of file diff --git a/orders-frontend/src/components/ui/ErrorState.tsx b/orders-frontend/src/components/ui/ErrorState.tsx new file mode 100644 index 0000000..f1bee34 --- /dev/null +++ b/orders-frontend/src/components/ui/ErrorState.tsx @@ -0,0 +1,28 @@ +type ErrorStateProps = { + title?: string; + message: string; + onRetry?: () => void; +}; + +export function ErrorState({ + title = "Something went wrong", + message, + onRetry, + }: ErrorStateProps) { + return ( +
+

{title}

+

{message}

+ + {onRetry ? ( + + ) : null} +
+ ); +} \ No newline at end of file diff --git a/orders-frontend/src/components/ui/LoadingState.tsx b/orders-frontend/src/components/ui/LoadingState.tsx new file mode 100644 index 0000000..45b1845 --- /dev/null +++ b/orders-frontend/src/components/ui/LoadingState.tsx @@ -0,0 +1,18 @@ +type LoadingStateProps = { + title?: string; + description?: string; +}; + +export function LoadingState({ + title = "Loading", + description = "Fetching the latest data...", + }: LoadingStateProps) { + return ( +
+
+

{title}

+

{description}

+
+ ); +} \ No newline at end of file diff --git a/orders-frontend/src/hooks/formatCurrency.ts b/orders-frontend/src/hooks/formatCurrency.ts new file mode 100644 index 0000000..98065a4 --- /dev/null +++ b/orders-frontend/src/hooks/formatCurrency.ts @@ -0,0 +1,7 @@ +export function formatCurrency(amount: number, currency = "KES") { + return new Intl.NumberFormat("en-KE", { + style: "currency", + currency, + maximumFractionDigits: 2, + }).format(amount); +} \ No newline at end of file diff --git a/orders-frontend/src/hooks/formatDate.ts b/orders-frontend/src/hooks/formatDate.ts new file mode 100644 index 0000000..531f112 --- /dev/null +++ b/orders-frontend/src/hooks/formatDate.ts @@ -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)); +} \ No newline at end of file diff --git a/orders-frontend/src/hooks/useFareCalculation.ts b/orders-frontend/src/hooks/useFareCalculation.ts new file mode 100644 index 0000000..4ef678e --- /dev/null +++ b/orders-frontend/src/hooks/useFareCalculation.ts @@ -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), + }); +} \ No newline at end of file diff --git a/orders-frontend/src/hooks/useInventory.ts b/orders-frontend/src/hooks/useInventory.ts new file mode 100644 index 0000000..1972495 --- /dev/null +++ b/orders-frontend/src/hooks/useInventory.ts @@ -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), + }); +} \ No newline at end of file diff --git a/orders-frontend/src/hooks/useOrders.ts b/orders-frontend/src/hooks/useOrders.ts new file mode 100644 index 0000000..15b1e6f --- /dev/null +++ b/orders-frontend/src/hooks/useOrders.ts @@ -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, + }), + ]); + }, + }); +} \ No newline at end of file diff --git a/orders-frontend/src/lib/queryClient.ts b/orders-frontend/src/lib/queryClient.ts new file mode 100644 index 0000000..2075c4a --- /dev/null +++ b/orders-frontend/src/lib/queryClient.ts @@ -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, + }, + }, + }); +} diff --git a/orders-frontend/src/router.tsx b/orders-frontend/src/router.tsx index e7b1c4d..1e49935 100644 --- a/orders-frontend/src/router.tsx +++ b/orders-frontend/src/router.tsx @@ -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 - } +declare module "@tanstack/react-router" { + interface Register { + router: ReturnType; + } } diff --git a/orders-frontend/src/routes/__root.tsx b/orders-frontend/src/routes/__root.tsx index f4b63b1..abe14c4 100644 --- a/orders-frontend/src/routes/__root.tsx +++ b/orders-frontend/src/routes/__root.tsx @@ -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 ( - - - - - - {children} - , - }, - ]} - /> - - - - ) +function RootComponent() { + return ( + + + + + + + + + + + + ); } diff --git a/orders-frontend/src/routes/index.tsx b/orders-frontend/src/routes/index.tsx index 6678649..27096ac 100644 --- a/orders-frontend/src/routes/index.tsx +++ b/orders-frontend/src/routes/index.tsx @@ -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 ( -
-

Welcome to TanStack Start

-

- Edit src/routes/index.tsx to get started. -

-
- ) -} +export const Route = createFileRoute("/")({ + component: Dashboard, +}); \ No newline at end of file diff --git a/orders-frontend/src/styles.css b/orders-frontend/src/styles.css index 3295f79..4cc60fd 100644 --- a/orders-frontend/src/styles.css +++ b/orders-frontend/src/styles.css @@ -1,347 +1,32 @@ -@import url('https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,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; +} \ No newline at end of file diff --git a/orders-frontend/src/types/api.ts b/orders-frontend/src/types/api.ts new file mode 100644 index 0000000..54a8d8d --- /dev/null +++ b/orders-frontend/src/types/api.ts @@ -0,0 +1,15 @@ +export type ApiErrorResponse = { + error: string; + message: string; + details: unknown; + timestamp: string; + path: string; +}; + +export type PageResponse = { + items: T[]; + page: number; + size: number; + totalItems: number; + totalPages: number; +}; \ No newline at end of file diff --git a/orders-frontend/src/types/fare.ts b/orders-frontend/src/types/fare.ts new file mode 100644 index 0000000..9ad31d3 --- /dev/null +++ b/orders-frontend/src/types/fare.ts @@ -0,0 +1,10 @@ +export type FareCalculationResponse = { + distanceKm: number; + baseFare: number; + perKmRate: number; + surgeMultiplier: number; + minimumFare: number; + calculatedFare: number; + currency: string; + formula: string; +}; \ No newline at end of file diff --git a/orders-frontend/src/types/inventory.ts b/orders-frontend/src/types/inventory.ts new file mode 100644 index 0000000..84c0bc5 --- /dev/null +++ b/orders-frontend/src/types/inventory.ts @@ -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[]; +}; \ No newline at end of file diff --git a/orders-frontend/src/types/order.ts b/orders-frontend/src/types/order.ts new file mode 100644 index 0000000..1b3bf03 --- /dev/null +++ b/orders-frontend/src/types/order.ts @@ -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; +}; \ No newline at end of file