fix inventory

This commit is contained in:
root
2026-06-11 11:06:41 -04:00
parent e8d1b8c1c7
commit 7365c14506
6 changed files with 635 additions and 3 deletions
+60 -3
View File
@@ -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<unknown> {
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<string, unknown>): Promise<unknown> {
@@ -92,7 +94,11 @@ export async function postClassroomAudit(
}
export async function fetchInventorySummary(searchParams: URLSearchParams): Promise<unknown> {
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<unknown> {
@@ -156,3 +162,54 @@ export async function postTeacherBookDistribute(body: Record<string, unknown>):
body: JSON.stringify(body),
})
}
/* ─── New Inventory Improvement API Calls ─── */
/** Fetch low-stock items (quantity <= reorder_point). */
export async function fetchLowStock(): Promise<unknown> {
return apiFetch(`${BASE}/low-stock`)
}
/** Fetch reorder suggestions for low-stock items. */
export async function fetchReorderSuggestions(): Promise<unknown> {
return apiFetch(`${BASE}/reorder-suggestions`)
}
/** Fetch stock status for all items (ok/low_stock/out_of_stock). */
export async function fetchStockStatus(
searchParams: URLSearchParams,
): Promise<unknown> {
return apiFetch(`${BASE}/stock-status${q(searchParams)}`)
}
/** Fetch inventory dashboard summary counts. */
export async function fetchInventoryDashboard(
searchParams: URLSearchParams,
): Promise<unknown> {
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<unknown> {
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<unknown> {
return apiFetch(`${BASE}/movements/${movementId}/correct`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ qty_change: qtyChange, reason }),
})
}
@@ -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<Record<string, unknown>>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const load = useCallback(() => {
setLoading(true)
fetchInventoryDashboard(searchParams)
.then((res) => {
if (res && typeof res === 'object') {
setData(res as Record<string, unknown>)
}
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<string, number>) ?? {}
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 (
<div className="container-fluid px-0 mt-3">
<div className="d-flex justify-content-between align-items-center mb-3 px-3 flex-wrap gap-2">
<h3 className="mb-0">Inventory Dashboard</h3>
<div className="d-flex gap-2">
<Link className="btn btn-outline-info btn-sm" to="/app/administrator/inventory/stock-status">
Stock Status
</Link>
<Link className="btn btn-outline-secondary" to={INVENTORY_BOOK_BASE}>
Back to Items
</Link>
</div>
</div>
{error ? <div className="alert alert-danger mx-3">{error}</div> : null}
{loading ? (
<p className="text-muted px-3">Loading</p>
) : (
<div className="row g-3 px-3">
{/* Summary Cards */}
<div className="col-sm-6 col-lg-4">
<div className="card border-primary h-100">
<div className="card-body text-center">
<h5 className="card-title text-primary">{totalItems}</h5>
<p className="card-text">Total Items</p>
</div>
</div>
</div>
<div className="col-sm-6 col-lg-4">
<div className="card border-warning h-100">
<div className="card-body text-center">
<h5 className="card-title text-warning">{lowStockCount}</h5>
<p className="card-text">Low Stock Items</p>
{lowStockCount > 0 ? (
<Link className="btn btn-sm btn-warning" to="/app/administrator/inventory/low-stock">
View Low Stock
</Link>
) : null}
</div>
</div>
</div>
<div className="col-sm-6 col-lg-4">
<div className="card border-danger h-100">
<div className="card-body text-center">
<h5 className="card-title text-danger">{outOfStockCount}</h5>
<p className="card-text">Out of Stock</p>
</div>
</div>
</div>
<div className="col-sm-6 col-lg-4">
<div className="card border-info h-100">
<div className="card-body text-center">
<h5 className="card-title text-info">{needsRepairCount}</h5>
<p className="card-text">Needs Repair</p>
</div>
</div>
</div>
<div className="col-sm-6 col-lg-4">
<div className="card border-secondary h-100">
<div className="card-body text-center">
<h5 className="card-title text-secondary">{missingCount}</h5>
<p className="card-text">Missing / Cannot Find</p>
</div>
</div>
</div>
{/* Items by Type */}
<div className="col-12">
<div className="card">
<div className="card-header">Items by Type</div>
<div className="card-body">
{Object.keys(byType).length === 0 ? (
<p className="text-muted mb-0">No items found.</p>
) : (
<div className="table-responsive">
<table className="table table-striped align-middle mb-0">
<thead>
<tr>
<th>Type</th>
<th className="text-end">Count</th>
</tr>
</thead>
<tbody>
{Object.entries(byType).map(([type, count]) => (
<tr key={type}>
<td style={{ textTransform: 'capitalize' }}>{type}</td>
<td className="text-end">{Number(count).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
{/* Quick Links */}
<div className="col-12">
<div className="card">
<div className="card-header">Quick Actions</div>
<div className="card-body">
<div className="d-flex gap-2 flex-wrap">
<Link className="btn btn-outline-primary" to="/app/administrator/inventory/classroom">
Classroom Equipment
</Link>
<Link className="btn btn-outline-primary" to={INVENTORY_BOOK_BASE}>
Books
</Link>
<Link className="btn btn-outline-primary" to="/app/administrator/inventory/office">
Office Supplies
</Link>
<Link className="btn btn-outline-primary" to="/app/administrator/inventory/kitchen">
Kitchen Supplies
</Link>
<Link className="btn btn-outline-primary" to="/app/administrator/inventory/movements">
Movements
</Link>
<Link className="btn btn-outline-primary" to="/app/administrator/inventory/summary">
Summary
</Link>
<Link className="btn btn-outline-primary" to="/app/administrator/inventory/reorder-suggestions">
Reorder Suggestions
</Link>
<Link className="btn btn-outline-primary" to="/app/administrator/inventory/low-stock">
Low Stock
</Link>
</div>
</div>
</div>
</div>
</div>
)}
</div>
)
}
@@ -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<Record<string, unknown>[]>([])
const [count, setCount] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const load = useCallback(() => {
setLoading(true)
fetchLowStock()
.then((res) => {
if (res && typeof res === 'object') {
const o = res as Record<string, unknown>
const arr = Array.isArray(o.items) ? (o.items as Record<string, unknown>[]) : []
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 (
<div className="container-fluid px-0 mt-3">
<div className="d-flex justify-content-between align-items-center mb-3 px-3 flex-wrap gap-2">
<h3 className="mb-0">Low Stock Items ({count})</h3>
<div className="d-flex gap-2">
<Link className="btn btn-outline-info btn-sm" to="/app/administrator/inventory/reorder-suggestions">
Reorder Suggestions
</Link>
<Link className="btn btn-outline-secondary" to={INVENTORY_BOOK_BASE}>
Back to Items
</Link>
</div>
</div>
{error ? <div className="alert alert-danger mx-3">{error}</div> : null}
{loading ? (
<p className="text-muted px-3">Loading</p>
) : items.length === 0 ? (
<div className="alert alert-success mx-3">
<strong>All stocked up!</strong> No items are below their reorder point.
</div>
) : (
<div className="card mx-3">
<div className="card-body table-responsive">
<table className="table table-striped align-middle" id="lowStockTable">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Category</th>
<th className="text-end">Current Qty</th>
<th className="text-end">Reorder Point</th>
<th className="text-end">Reorder Qty</th>
<th className="text-end">Lead Time (Days)</th>
<th>Preferred Supplier</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{items.map((item) => {
const i = item as Record<string, unknown>
const supplier = i.preferred_supplier as Record<string, unknown> | null
return (
<tr key={String(i.id)}>
<td>{String(i.name ?? '')}</td>
<td style={{ textTransform: 'capitalize' }}>{String(i.type ?? '')}</td>
<td>{String((i.category as Record<string, unknown>)?.name ?? i.category ?? '')}</td>
<td className="text-end text-danger fw-semibold">
{Number(i.quantity ?? 0).toLocaleString()}
</td>
<td className="text-end">{Number(i.reorder_point ?? 0).toLocaleString()}</td>
<td className="text-end">{Number(i.reorder_quantity ?? 0).toLocaleString() || '—'}</td>
<td className="text-end">{Number(i.lead_time_days ?? 0) || '—'}</td>
<td>{supplier ? String(supplier.name ?? '') : '—'}</td>
<td>
<Link
className="btn btn-sm btn-warning"
to={`/app/administrator/inventory/items/${String(i.id)}/adjust`}
>
Adjust Stock
</Link>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)}
</div>
)
}
@@ -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<Record<string, unknown>[]>([])
const [count, setCount] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const load = useCallback(() => {
setLoading(true)
fetchReorderSuggestions()
.then((res) => {
if (res && typeof res === 'object') {
const o = res as Record<string, unknown>
const arr = Array.isArray(o.suggestions) ? (o.suggestions as Record<string, unknown>[]) : []
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 (
<div className="container-fluid px-0 mt-3">
<div className="d-flex justify-content-between align-items-center mb-3 px-3 flex-wrap gap-2">
<h3 className="mb-0">Reorder Suggestions ({count})</h3>
<div className="d-flex gap-2">
<Link className="btn btn-outline-warning btn-sm" to="/app/administrator/inventory/low-stock">
Low Stock
</Link>
<Link className="btn btn-outline-secondary" to={INVENTORY_BOOK_BASE}>
Back to Items
</Link>
</div>
</div>
{error ? <div className="alert alert-danger mx-3">{error}</div> : null}
{loading ? (
<p className="text-muted px-3">Loading</p>
) : suggestions.length === 0 ? (
<div className="alert alert-success mx-3">
<strong>All stocked up!</strong> No reorder suggestions at this time.
</div>
) : (
<div className="card mx-3">
<div className="card-body table-responsive">
<table className="table table-striped align-middle" id="reorderSuggestionsTable">
<thead>
<tr>
<th>Name</th>
<th>Category</th>
<th className="text-end">Current Qty</th>
<th className="text-end">Reorder Point</th>
<th className="text-end">Suggested Order</th>
<th>Preferred Supplier</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{suggestions.map((s) => {
const supplier = s.preferred_supplier as Record<string, unknown> | null
return (
<tr key={String(s.id)}>
<td>{String(s.name ?? '')}</td>
<td>{String(s.category ?? '—')}</td>
<td className="text-end text-danger fw-semibold">
{Number(s.current_quantity ?? 0).toLocaleString()}
</td>
<td className="text-end">{Number(s.reorder_point ?? 0).toLocaleString()}</td>
<td className="text-end fw-semibold">
{Number(s.suggested_order_qty ?? 0).toLocaleString()}
</td>
<td>{supplier ? String(supplier.name ?? '') : '—'}</td>
<td>
<Link
className="btn btn-sm btn-warning"
to={`/app/administrator/inventory/items/${String(s.id)}/adjust`}
>
Adjust Stock
</Link>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)}
</div>
)
}
@@ -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<Record<string, unknown>[]>([])
const [counts, setCounts] = useState<Record<string, number>>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const load = useCallback(() => {
setLoading(true)
fetchStockStatus(searchParams)
.then((res) => {
if (res && typeof res === 'object') {
const o = res as Record<string, unknown>
setItems(Array.isArray(o.items) ? (o.items as Record<string, unknown>[]) : [])
setCounts((o.counts as Record<string, number>) ?? {})
}
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 (
<div className="container-fluid px-0 mt-3">
<div className="d-flex justify-content-between align-items-center mb-3 px-3 flex-wrap gap-2">
<h3 className="mb-0">Stock Status</h3>
<div className="d-flex gap-2">
<Link className="btn btn-outline-warning btn-sm" to="/app/administrator/inventory/low-stock">
Low Stock
</Link>
<Link className="btn btn-outline-info btn-sm" to="/app/administrator/inventory/reorder-suggestions">
Reorder Suggestions
</Link>
<Link className="btn btn-outline-secondary" to={INVENTORY_BOOK_BASE}>
Back to Items
</Link>
</div>
</div>
{error ? <div className="alert alert-danger mx-3">{error}</div> : null}
{loading ? (
<p className="text-muted px-3">Loading</p>
) : (
<>
{/* Summary Badges */}
<div className="d-flex gap-3 mb-3 px-3 flex-wrap">
<span className="badge bg-success fs-6">
OK: {counts.ok ?? 0}
</span>
<span className="badge bg-warning fs-6">
Low Stock: {counts.low_stock ?? 0}
</span>
<span className="badge bg-danger fs-6">
Out of Stock: {counts.out_of_stock ?? 0}
</span>
</div>
<div className="card mx-3">
<div className="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<span>All Items</span>
<select
className="form-select form-select-sm"
style={{ minWidth: 180 }}
value={currentType}
onChange={(e) => onTypeFilter(e.target.value)}
>
<option value="">All Types</option>
<option value="book">Books</option>
<option value="classroom">Classroom</option>
<option value="office">Office</option>
<option value="kitchen">Kitchen</option>
</select>
</div>
<div className="card-body table-responsive">
<table className="table table-striped align-middle" id="stockStatusTable">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Category</th>
<th className="text-end">Quantity</th>
<th className="text-end">Reorder Point</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{items.length === 0 ? (
<tr>
<td colSpan={7} className="text-center text-muted py-4">
No items found.
</td>
</tr>
) : (
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 (
<tr key={String(item.id)}>
<td>{String(item.name ?? '')}</td>
<td style={{ textTransform: 'capitalize' }}>{String(item.type ?? '')}</td>
<td>{String(item.category ?? '—')}</td>
<td className="text-end">{Number(item.quantity ?? 0).toLocaleString()}</td>
<td className="text-end">
{item.reorder_point != null ? Number(item.reorder_point).toLocaleString() : '—'}
</td>
<td>
<span className={`badge ${badgeClass}`}>{badgeLabel}</span>
</td>
<td>
<Link
className="btn btn-sm btn-outline-primary me-1"
to={`/app/administrator/inventory/items/${String(item.id)}/adjust`}
>
Adjust
</Link>
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
</div>
</>
)}
</div>
)
}
+4
View File
@@ -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'