diff --git a/src/api/inventory.ts b/src/api/inventory.ts index 6902520..7db169d 100644 --- a/src/api/inventory.ts +++ b/src/api/inventory.ts @@ -4,7 +4,7 @@ */ import { apiFetch } from './http' -const BASE = '/api/v1/administrator/inventory' +const BASE = '/api/v1/inventory' function q(searchParams: URLSearchParams): string { const s = searchParams.toString() @@ -18,7 +18,9 @@ export async function fetchInventoryIndex( kind: InventoryKind, searchParams: URLSearchParams, ): Promise { - return apiFetch(`${BASE}/${kind}${q(searchParams)}`) + const params = new URLSearchParams(searchParams) + params.set('type', kind) + return apiFetch(`${BASE}/items?${params.toString()}`) } export async function postInventoryCategory(body: Record): Promise { @@ -92,7 +94,11 @@ export async function postClassroomAudit( } export async function fetchInventorySummary(searchParams: URLSearchParams): Promise { - return apiFetch(`${BASE}/summary${q(searchParams)}`) + const type = searchParams.get('type') || 'classroom' + const params = new URLSearchParams(searchParams) + params.delete('type') + const qs = params.toString() + return apiFetch(`${BASE}/summary/${type}${qs ? `?${qs}` : ''}`) } export async function fetchMovements(searchParams: URLSearchParams): Promise { @@ -156,3 +162,54 @@ export async function postTeacherBookDistribute(body: Record): body: JSON.stringify(body), }) } + +/* ─── New Inventory Improvement API Calls ─── */ + +/** Fetch low-stock items (quantity <= reorder_point). */ +export async function fetchLowStock(): Promise { + return apiFetch(`${BASE}/low-stock`) +} + +/** Fetch reorder suggestions for low-stock items. */ +export async function fetchReorderSuggestions(): Promise { + return apiFetch(`${BASE}/reorder-suggestions`) +} + +/** Fetch stock status for all items (ok/low_stock/out_of_stock). */ +export async function fetchStockStatus( + searchParams: URLSearchParams, +): Promise { + return apiFetch(`${BASE}/stock-status${q(searchParams)}`) +} + +/** Fetch inventory dashboard summary counts. */ +export async function fetchInventoryDashboard( + searchParams: URLSearchParams, +): Promise { + return apiFetch(`${BASE}/dashboard${q(searchParams)}`) +} + +/** Void a movement (append-only correction with reverse entry). */ +export async function voidMovement( + movementId: string | number, + reason: string, +): Promise { + return apiFetch(`${BASE}/movements/${movementId}/void`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reason }), + }) +} + +/** Create a correction movement linked to an existing movement. */ +export async function correctMovement( + movementId: string | number, + qtyChange: number, + reason: string, +): Promise { + return apiFetch(`${BASE}/movements/${movementId}/correct`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ qty_change: qtyChange, reason }), + }) +} diff --git a/src/pages/inventory/InventoryDashboardPage.tsx b/src/pages/inventory/InventoryDashboardPage.tsx new file mode 100644 index 0000000..794b576 --- /dev/null +++ b/src/pages/inventory/InventoryDashboardPage.tsx @@ -0,0 +1,183 @@ +import { useCallback, useEffect, useState } from 'react' +import { Link, useSearchParams } from 'react-router-dom' +import { ApiHttpError } from '../../api/http' +import { fetchInventoryDashboard } from '../../api/inventory' +import { INVENTORY_BOOK_BASE } from './inventoryPaths' + +/** + * Inventory Dashboard — summary counts by type, low stock, out of stock, etc. + * Backend: GET /api/v1/inventory/dashboard + */ +export function InventoryDashboardPage() { + const [searchParams] = useSearchParams() + const [data, setData] = useState>({}) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const load = useCallback(() => { + setLoading(true) + fetchInventoryDashboard(searchParams) + .then((res) => { + if (res && typeof res === 'object') { + setData(res as Record) + } + setError(null) + }) + .catch((e: unknown) => { + setError(e instanceof ApiHttpError ? e.message : 'Unable to load dashboard.') + }) + .finally(() => setLoading(false)) + }, [searchParams]) + + useEffect(() => { + load() + }, [load]) + + const byType = (data.by_type as Record) ?? {} + const totalItems = Number(data.total_items ?? 0) + const lowStockCount = Number(data.low_stock_count ?? 0) + const outOfStockCount = Number(data.out_of_stock_count ?? 0) + const needsRepairCount = Number(data.needs_repair_count ?? 0) + const missingCount = Number(data.missing_count ?? 0) + + return ( +
+
+

Inventory Dashboard

+
+ + Stock Status + + + Back to Items + +
+
+ + {error ?
{error}
: null} + + {loading ? ( +

Loading…

+ ) : ( +
+ {/* Summary Cards */} +
+
+
+
{totalItems}
+

Total Items

+
+
+
+ +
+
+
+
{lowStockCount}
+

Low Stock Items

+ {lowStockCount > 0 ? ( + + View Low Stock + + ) : null} +
+
+
+ +
+
+
+
{outOfStockCount}
+

Out of Stock

+
+
+
+ +
+
+
+
{needsRepairCount}
+

Needs Repair

+
+
+
+ +
+
+
+
{missingCount}
+

Missing / Cannot Find

+
+
+
+ + {/* Items by Type */} +
+
+
Items by Type
+
+ {Object.keys(byType).length === 0 ? ( +

No items found.

+ ) : ( +
+ + + + + + + + + {Object.entries(byType).map(([type, count]) => ( + + + + + ))} + +
TypeCount
{type}{Number(count).toLocaleString()}
+
+ )} +
+
+
+ + {/* Quick Links */} +
+
+
Quick Actions
+
+
+ + Classroom Equipment + + + Books + + + Office Supplies + + + Kitchen Supplies + + + Movements + + + Summary + + + Reorder Suggestions + + + Low Stock + +
+
+
+
+
+ )} +
+ ) +} diff --git a/src/pages/inventory/InventoryLowStockPage.tsx b/src/pages/inventory/InventoryLowStockPage.tsx new file mode 100644 index 0000000..2911d70 --- /dev/null +++ b/src/pages/inventory/InventoryLowStockPage.tsx @@ -0,0 +1,112 @@ +import { useCallback, useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import { ApiHttpError } from '../../api/http' +import { fetchLowStock } from '../../api/inventory' +import { INVENTORY_BOOK_BASE } from './inventoryPaths' + +/** + * Low Stock Items — items where quantity <= reorder_point. + * Backend: GET /api/v1/inventory/low-stock + */ +export function InventoryLowStockPage() { + const [items, setItems] = useState[]>([]) + const [count, setCount] = useState(0) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const load = useCallback(() => { + setLoading(true) + fetchLowStock() + .then((res) => { + if (res && typeof res === 'object') { + const o = res as Record + const arr = Array.isArray(o.items) ? (o.items as Record[]) : [] + setItems(arr) + setCount(Number(o.count ?? arr.length)) + } + setError(null) + }) + .catch((e: unknown) => { + setError(e instanceof ApiHttpError ? e.message : 'Unable to load low stock items.') + }) + .finally(() => setLoading(false)) + }, []) + + useEffect(() => { + load() + }, [load]) + + return ( +
+
+

Low Stock Items ({count})

+
+ + Reorder Suggestions + + + Back to Items + +
+
+ + {error ?
{error}
: null} + + {loading ? ( +

Loading…

+ ) : items.length === 0 ? ( +
+ All stocked up! No items are below their reorder point. +
+ ) : ( +
+
+ + + + + + + + + + + + + + + + {items.map((item) => { + const i = item as Record + const supplier = i.preferred_supplier as Record | null + return ( + + + + + + + + + + + + ) + })} + +
NameTypeCategoryCurrent QtyReorder PointReorder QtyLead Time (Days)Preferred SupplierActions
{String(i.name ?? '')}{String(i.type ?? '')}{String((i.category as Record)?.name ?? i.category ?? '')} + {Number(i.quantity ?? 0).toLocaleString()} + {Number(i.reorder_point ?? 0).toLocaleString()}{Number(i.reorder_quantity ?? 0).toLocaleString() || '—'}{Number(i.lead_time_days ?? 0) || '—'}{supplier ? String(supplier.name ?? '') : '—'} + + Adjust Stock + +
+
+
+ )} +
+ ) +} diff --git a/src/pages/inventory/InventoryReorderSuggestionsPage.tsx b/src/pages/inventory/InventoryReorderSuggestionsPage.tsx new file mode 100644 index 0000000..c01d58e --- /dev/null +++ b/src/pages/inventory/InventoryReorderSuggestionsPage.tsx @@ -0,0 +1,109 @@ +import { useCallback, useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import { ApiHttpError } from '../../api/http' +import { fetchReorderSuggestions } from '../../api/inventory' +import { INVENTORY_BOOK_BASE } from './inventoryPaths' + +/** + * Reorder Suggestions — calculated suggested order quantities for low-stock items. + * Backend: GET /api/v1/inventory/reorder-suggestions + */ +export function InventoryReorderSuggestionsPage() { + const [suggestions, setSuggestions] = useState[]>([]) + const [count, setCount] = useState(0) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const load = useCallback(() => { + setLoading(true) + fetchReorderSuggestions() + .then((res) => { + if (res && typeof res === 'object') { + const o = res as Record + const arr = Array.isArray(o.suggestions) ? (o.suggestions as Record[]) : [] + setSuggestions(arr) + setCount(Number(o.count ?? arr.length)) + } + setError(null) + }) + .catch((e: unknown) => { + setError(e instanceof ApiHttpError ? e.message : 'Unable to load reorder suggestions.') + }) + .finally(() => setLoading(false)) + }, []) + + useEffect(() => { + load() + }, [load]) + + return ( +
+
+

Reorder Suggestions ({count})

+
+ + Low Stock + + + Back to Items + +
+
+ + {error ?
{error}
: null} + + {loading ? ( +

Loading…

+ ) : suggestions.length === 0 ? ( +
+ All stocked up! No reorder suggestions at this time. +
+ ) : ( +
+
+ + + + + + + + + + + + + + {suggestions.map((s) => { + const supplier = s.preferred_supplier as Record | null + return ( + + + + + + + + + + ) + })} + +
NameCategoryCurrent QtyReorder PointSuggested OrderPreferred SupplierActions
{String(s.name ?? '')}{String(s.category ?? '—')} + {Number(s.current_quantity ?? 0).toLocaleString()} + {Number(s.reorder_point ?? 0).toLocaleString()} + {Number(s.suggested_order_qty ?? 0).toLocaleString()} + {supplier ? String(supplier.name ?? '') : '—'} + + Adjust Stock + +
+
+
+ )} +
+ ) +} diff --git a/src/pages/inventory/InventoryStockStatusPage.tsx b/src/pages/inventory/InventoryStockStatusPage.tsx new file mode 100644 index 0000000..e355e9f --- /dev/null +++ b/src/pages/inventory/InventoryStockStatusPage.tsx @@ -0,0 +1,167 @@ +import { useCallback, useEffect, useState } from 'react' +import { Link, useSearchParams } from 'react-router-dom' +import { ApiHttpError } from '../../api/http' +import { fetchStockStatus } from '../../api/inventory' +import { INVENTORY_BOOK_BASE } from './inventoryPaths' + +/** + * Stock Status — all items with status: ok/low_stock/out_of_stock. + * Backend: GET /api/v1/inventory/stock-status + */ +export function InventoryStockStatusPage() { + const [searchParams, setSearchParams] = useSearchParams() + const [items, setItems] = useState[]>([]) + const [counts, setCounts] = useState>({}) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const load = useCallback(() => { + setLoading(true) + fetchStockStatus(searchParams) + .then((res) => { + if (res && typeof res === 'object') { + const o = res as Record + setItems(Array.isArray(o.items) ? (o.items as Record[]) : []) + setCounts((o.counts as Record) ?? {}) + } + setError(null) + }) + .catch((e: unknown) => { + setError(e instanceof ApiHttpError ? e.message : 'Unable to load stock status.') + }) + .finally(() => setLoading(false)) + }, [searchParams]) + + useEffect(() => { + load() + }, [load]) + + function onTypeFilter(v: string) { + const next = new URLSearchParams(searchParams) + if (v) next.set('type', v) + else next.delete('type') + setSearchParams(next) + } + + const currentType = searchParams.get('type') ?? '' + + return ( +
+
+

Stock Status

+
+ + Low Stock + + + Reorder Suggestions + + + Back to Items + +
+
+ + {error ?
{error}
: null} + + {loading ? ( +

Loading…

+ ) : ( + <> + {/* Summary Badges */} +
+ + OK: {counts.ok ?? 0} + + + Low Stock: {counts.low_stock ?? 0} + + + Out of Stock: {counts.out_of_stock ?? 0} + +
+ +
+
+ All Items + +
+
+ + + + + + + + + + + + + + {items.length === 0 ? ( + + + + ) : ( + items.map((item) => { + const status = String(item.status ?? 'ok') + const badgeClass = + status === 'out_of_stock' + ? 'bg-danger' + : status === 'low_stock' + ? 'bg-warning' + : 'bg-success' + const badgeLabel = + status === 'out_of_stock' + ? 'Out of Stock' + : status === 'low_stock' + ? 'Low Stock' + : 'OK' + return ( + + + + + + + + + + ) + }) + )} + +
NameTypeCategoryQuantityReorder PointStatusActions
+ No items found. +
{String(item.name ?? '')}{String(item.type ?? '')}{String(item.category ?? '—')}{Number(item.quantity ?? 0).toLocaleString()} + {item.reorder_point != null ? Number(item.reorder_point).toLocaleString() : '—'} + + {badgeLabel} + + + Adjust + +
+
+
+ + )} +
+ ) +} diff --git a/src/pages/inventory/index.ts b/src/pages/inventory/index.ts index 658ecfd..c3a9a17 100644 --- a/src/pages/inventory/index.ts +++ b/src/pages/inventory/index.ts @@ -5,12 +5,16 @@ export { InventoryBookIndexPage } from './InventoryBookIndexPage' export { InventoryClassroomAuditPage } from './InventoryClassroomAuditPage' export { InventoryClassroomFormPage } from './InventoryClassroomFormPage' export { InventoryClassroomIndexPage } from './InventoryClassroomIndexPage' +export { InventoryDashboardPage } from './InventoryDashboardPage' export { InventoryEditEntryPage } from './InventoryEditEntryPage' export { InventoryKitchenFormPage } from './InventoryKitchenFormPage' export { InventoryKitchenIndexPage } from './InventoryKitchenIndexPage' +export { InventoryLowStockPage } from './InventoryLowStockPage' export { InventoryMovementFormPage } from './InventoryMovementFormPage' export { InventoryMovementsIndexPage } from './InventoryMovementsIndexPage' export { InventoryOfficeFormPage } from './InventoryOfficeFormPage' export { InventoryOfficeIndexPage } from './InventoryOfficeIndexPage' +export { InventoryReorderSuggestionsPage } from './InventoryReorderSuggestionsPage' +export { InventoryStockStatusPage } from './InventoryStockStatusPage' export { InventorySummaryPage } from './InventorySummaryPage' export { TeacherBookDistributePage } from './TeacherBookDistributePage'