fix teacher, parent and admin pages

This commit is contained in:
root
2026-04-25 00:00:10 -04:00
parent 7fe34dde0d
commit 3e77fc92c7
275 changed files with 46412 additions and 3325 deletions
+105
View File
@@ -0,0 +1,105 @@
import { type FormEvent, useState } from 'react'
import { ApiHttpError } from '../../api/http'
import { postInventoryCategory } from '../../api/inventory'
type Props = {
modalId: string
inventoryType: 'book' | 'classroom' | 'kitchen' | 'office'
title: string
includeGradeRange?: boolean
onSaved?: () => void
}
export function CategoryAddModal({
modalId,
inventoryType,
title,
includeGradeRange,
onSaved,
}: Props) {
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
async function onSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
setSaving(true)
setError(null)
const fd = new FormData(e.currentTarget)
try {
await postInventoryCategory({
type: inventoryType,
name: fd.get('name'),
description: fd.get('description') || undefined,
grade_min: fd.get('grade_min') === '' ? undefined : Number(fd.get('grade_min')),
grade_max: fd.get('grade_max') === '' ? undefined : Number(fd.get('grade_max')),
})
onSaved?.()
e.currentTarget.reset()
} catch (err: unknown) {
setError(err instanceof ApiHttpError ? err.message : 'Save failed.')
} finally {
setSaving(false)
}
}
return (
<div className="modal fade" id={modalId} tabIndex={-1} aria-hidden="true">
<div className="modal-dialog">
<form className="modal-content" onSubmit={(ev) => void onSubmit(ev)}>
<div className="modal-header">
<h5 className="modal-title">{title}</h5>
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close" />
</div>
<div className="modal-body">
{error ? <div className="alert alert-danger py-2 small">{error}</div> : null}
<div className="mb-3">
<label className="form-label">Name</label>
<input type="text" name="name" className="form-control" required />
</div>
{includeGradeRange ? (
<div className="row g-2 mb-3">
<div className="col-6">
<label className="form-label">Grade Min</label>
<input
type="number"
name="grade_min"
className="form-control"
min={0}
max={13}
placeholder="e.g., 1"
/>
</div>
<div className="col-6">
<label className="form-label">Grade Max</label>
<input
type="number"
name="grade_max"
className="form-control"
min={0}
max={13}
placeholder="e.g., 3"
/>
</div>
<div className="col-12">
<small className="text-muted">Optional. Used for book categories.</small>
</div>
</div>
) : null}
<div className="mb-0">
<label className="form-label">Description (optional)</label>
<textarea name="description" className="form-control" rows={2} />
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-outline-secondary" data-bs-dismiss="modal">
Close
</button>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? 'Saving…' : 'Save Category'}
</button>
</div>
</form>
</div>
</div>
)
}
+135
View File
@@ -0,0 +1,135 @@
import { type FormEvent, useEffect, useState } from 'react'
import { Link, useParams, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import { fetchInventoryItem, postInventoryAdjust } from '../../api/inventory'
/** CI `inventory/adjust_form.php` */
export function InventoryAdjustPage() {
const { itemId } = useParams()
const [searchParams] = useSearchParams()
const [name, setName] = useState('')
const [itemType, setItemType] = useState('')
const [mode, setMode] = useState('in')
const [quantity, setQuantity] = useState('1')
const [reason, setReason] = useState('')
const [note, setNote] = useState('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!itemId) return
let c = false
setLoading(true)
fetchInventoryItem(itemId)
.then((data) => {
if (c) return
const item = (data?.item ?? data) as Record<string, unknown>
setName(String(item.name ?? ''))
setItemType(String(item.type ?? ''))
setError(null)
})
.catch((e: unknown) => {
if (!c) setError(e instanceof ApiHttpError ? e.message : 'Unable to load item.')
})
.finally(() => {
if (!c) setLoading(false)
})
return () => {
c = true
}
}, [itemId])
const summaryHref =
itemType && itemType !== ''
? `/app/administrator/inventory/summary?${searchParams.toString()}`
: '/app/administrator/inventory/summary'
async function onSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
setSaving(true)
setError(null)
try {
await postInventoryAdjust(itemId ?? '', {
mode,
quantity: Number(quantity),
reason: reason || undefined,
note: note || undefined,
})
window.location.href = summaryHref
} catch (err: unknown) {
setError(err instanceof ApiHttpError ? err.message : 'Save failed.')
} finally {
setSaving(false)
}
}
if (!itemId) {
return (
<div className="container my-4">
<div className="alert alert-warning">Missing item id.</div>
</div>
)
}
return (
<div className="container my-4">
<h3 className="mb-3">Adjust Stock {name || '…'}</h3>
{error ? <div className="alert alert-danger">{error}</div> : null}
{loading ? <p className="text-muted">Loading</p> : null}
{!loading ? (
<form onSubmit={(ev) => void onSubmit(ev)}>
<div className="row g-3">
<div className="col-md-3">
<label className="form-label">Mode</label>
<select
className="form-select"
required
value={mode}
onChange={(e) => setMode(e.target.value)}
>
<option value="in">Add to Stock</option>
<option value="out">Deduct from Stock</option>
<option value="adjust">Adjust (signed)</option>
</select>
</div>
<div className="col-md-3">
<label className="form-label">Quantity</label>
<input
type="number"
min={1}
className="form-control"
required
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
/>
</div>
<div className="col-md-6">
<label className="form-label">Reason</label>
<input
className="form-control"
placeholder="Purchase, Donation, Correction, etc."
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
</div>
<div className="col-12">
<label className="form-label">Note (optional)</label>
<textarea className="form-control" rows={3} value={note} onChange={(e) => setNote(e.target.value)} />
</div>
</div>
<div className="mt-3 d-flex gap-2">
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? 'Saving…' : 'Save'}
</button>
<Link className="btn btn-outline-secondary" to={summaryHref}>
Cancel
</Link>
</div>
</form>
) : null}
</div>
)
}
@@ -0,0 +1,246 @@
import { type FormEvent, useEffect, useState } from 'react'
import { Link, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import {
fetchInventoryCreateForm,
fetchInventoryItem,
postInventoryItem,
putInventoryItem,
} from '../../api/inventory'
import { catLabelBook, gradeLabel, parseClassesSelection } from './inventoryHelpers'
import { INVENTORY_BOOK_BASE } from './inventoryPaths'
/** CI `inventory/book/form.php` */
export function InventoryBookFormPage() {
const { itemId } = useParams()
const { pathname } = useLocation()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const isCreate = pathname.endsWith('/create')
const [categories, setCategories] = useState<Record<string, unknown>[]>([])
const [name, setName] = useState('')
const [categoryId, setCategoryId] = useState('')
const [classes, setClasses] = useState<number[]>([])
const [author, setAuthor] = useState('')
const [isbn, setIsbn] = useState('')
const [edition, setEdition] = useState('')
const [quantity, setQuantity] = useState('0')
const [unit, setUnit] = useState('')
const [sku, setSku] = useState('')
const [description, setDescription] = useState('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let c = false
setLoading(true)
const run = isCreate
? fetchInventoryCreateForm('book', searchParams)
: fetchInventoryItem(itemId ?? '')
run
.then((data) => {
if (c) return
if (data && typeof data === 'object') {
const o = data as Record<string, unknown>
const cats = Array.isArray(o.categories)
? (o.categories as Record<string, unknown>[])
: []
setCategories(cats)
const item = (o.item ?? o) as Record<string, unknown>
if (!isCreate && item && typeof item === 'object') {
setName(String(item.name ?? ''))
setCategoryId(item.category_id != null ? String(item.category_id) : '')
setClasses(parseClassesSelection(item))
setAuthor(String(item.author ?? ''))
setIsbn(String(item.isbn ?? ''))
setEdition(String(item.edition ?? ''))
setQuantity(String(item.quantity ?? 0))
setUnit(String(item.unit ?? ''))
setSku(String(item.sku ?? ''))
setDescription(String(item.description ?? ''))
}
}
setError(null)
})
.catch((e: unknown) => {
if (!c) setError(e instanceof ApiHttpError ? e.message : 'Unable to load form.')
})
.finally(() => {
if (!c) setLoading(false)
})
return () => {
c = true
}
}, [isCreate, itemId, searchParams])
const selectedCat = categories.find((x) => String(x.id) === categoryId)
const gradeRangeText = gradeLabel(selectedCat ?? null)
function toggleClass(n: number) {
setClasses((prev) =>
prev.includes(n) ? prev.filter((x) => x !== n) : [...prev, n].sort((a, b) => a - b),
)
}
async function onSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
setSaving(true)
setError(null)
const body: Record<string, unknown> = {
type: 'book',
name,
category_id: categoryId === '' ? null : Number(categoryId),
classes,
author: author || undefined,
isbn: isbn || undefined,
edition: edition || undefined,
quantity: quantity === '' ? 0 : Number(quantity),
unit: unit || undefined,
sku: sku || undefined,
description: description || undefined,
}
try {
if (isCreate) await postInventoryItem(body)
else await putInventoryItem(itemId ?? '', body)
navigate(INVENTORY_BOOK_BASE)
} catch (err: unknown) {
setError(err instanceof ApiHttpError ? err.message : 'Save failed.')
} finally {
setSaving(false)
}
}
return (
<div className="container my-4">
<h3 className="mb-3">{isCreate ? 'Add' : 'Edit'} Book</h3>
{error ? <div className="alert alert-danger">{error}</div> : null}
{loading ? <p className="text-muted">Loading</p> : null}
{!loading ? (
<form onSubmit={(ev) => void onSubmit(ev)}>
<div className="row g-3">
<div className="col-md-6">
<label className="form-label">Title</label>
<input
className="form-control"
required
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="col-md-6">
<label className="form-label">Category</label>
<select
className="form-select"
value={categoryId}
onChange={(e) => setCategoryId(e.target.value)}
>
<option value=""> None </option>
{categories.map((c) => (
<option key={String(c.id)} value={String(c.id)}>
{catLabelBook(c)}
</option>
))}
</select>
<div className="form-text">Grade Range: {gradeRangeText}</div>
</div>
<div className="col-12">
<label className="form-label d-flex align-items-center gap-2 flex-wrap">
Classes (113)
<button
type="button"
className="btn btn-sm btn-outline-secondary"
onClick={() => setClasses(Array.from({ length: 13 }, (_, i) => i + 1))}
>
Select All
</button>
<button type="button" className="btn btn-sm btn-outline-secondary" onClick={() => setClasses([])}>
Clear
</button>
</label>
<div className="row g-2">
{Array.from({ length: 13 }, (_, i) => i + 1).map((n) => (
<div key={n} className="col-6 col-sm-4 col-md-3 col-lg-2">
<div className="form-check">
<input
className="form-check-input"
type="checkbox"
id={`class${n}`}
checked={classes.includes(n)}
onChange={() => toggleClass(n)}
/>
<label className="form-check-label" htmlFor={`class${n}`}>
Class {n}
</label>
</div>
</div>
))}
</div>
<div className="form-text">Choose all class numbers that use this book.</div>
</div>
<div className="col-md-4">
<label className="form-label">Author</label>
<input className="form-control" value={author} onChange={(e) => setAuthor(e.target.value)} />
</div>
<div className="col-md-4">
<label className="form-label">ISBN</label>
<input className="form-control" value={isbn} onChange={(e) => setIsbn(e.target.value)} />
</div>
<div className="col-md-4">
<label className="form-label">Edition</label>
<input className="form-control" value={edition} onChange={(e) => setEdition(e.target.value)} />
</div>
<div className="col-md-2">
<label className="form-label">Quantity</label>
<input
type="number"
min={0}
className="form-control"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
/>
</div>
<div className="col-md-2">
<label className="form-label">Unit</label>
<input
className="form-control"
placeholder="pcs, set"
value={unit}
onChange={(e) => setUnit(e.target.value)}
/>
</div>
<div className="col-md-4">
<label className="form-label">SKU</label>
<input className="form-control" value={sku} onChange={(e) => setSku(e.target.value)} />
</div>
<div className="col-12">
<label className="form-label">Description / Notes</label>
<textarea
className="form-control"
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
</div>
<div className="mt-3 d-flex gap-2">
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? 'Saving…' : isCreate ? 'Save' : 'Update'}
</button>
<Link className="btn btn-outline-secondary" to={INVENTORY_BOOK_BASE}>
Cancel
</Link>
</div>
</form>
) : null}
</div>
)
}
@@ -0,0 +1,176 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import { deleteInventoryItem, fetchInventoryIndex } from '../../api/inventory'
import { AcademicFilterBar } from '../discounts/AcademicFilterBar'
import { CategoryAddModal } from './CategoryAddModal'
import {
catLabelBook,
gradeLabel,
parseInventoryList,
userDisplay,
} from './inventoryHelpers'
import { INVENTORY_BOOK_BASE } from './inventoryPaths'
/** CI `inventory/book/index.php` */
export function InventoryBookIndexPage() {
const [searchParams] = useSearchParams()
const [categoryFilter, setCategoryFilter] = useState('')
const [payload, setPayload] = useState<ReturnType<typeof parseInventoryList>>({
items: [],
categories: [],
userNames: {},
})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const load = useCallback(() => {
setLoading(true)
fetchInventoryIndex('book', searchParams)
.then((data) => {
setPayload(parseInventoryList(data))
setError(null)
})
.catch((e: unknown) => {
setError(e instanceof ApiHttpError ? e.message : 'Unable to load books.')
})
.finally(() => setLoading(false))
}, [searchParams])
useEffect(() => {
load()
}, [load])
const catById = useMemo(() => {
const m = new Map<number, Record<string, unknown>>()
for (const c of payload.categories) {
const id = Number(c.id ?? 0)
if (id) m.set(id, c)
}
return m
}, [payload.categories])
async function onDelete(id: number) {
if (!confirm('Delete this item?')) return
try {
await deleteInventoryItem(id)
load()
} catch (e: unknown) {
setError(e instanceof ApiHttpError ? e.message : 'Delete failed.')
}
}
return (
<div className="container-fluid px-0 mt-3">
<div className="d-flex justify-content-between align-items-center mb-3 px-3">
<h3 className="mb-0">Books</h3>
<div className="btn-group">
<Link className="btn btn-primary" to={`${INVENTORY_BOOK_BASE}/create`}>
Add Book
</Link>
<button
type="button"
className="btn btn-outline-secondary"
data-bs-toggle="modal"
data-bs-target="#addCategoryModalBook"
>
Add Category
</button>
</div>
</div>
{error ? <div className="alert alert-danger mx-3">{error}</div> : null}
<div className="px-3 mb-2">
<AcademicFilterBar />
</div>
<div className="card mb-4 mx-3">
<div className="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<span>Books</span>
<select
className="form-select form-select-sm"
style={{ minWidth: 260 }}
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
>
<option value="">All Categories</option>
{payload.categories.map((c) => (
<option key={String(c.id)} value={String(c.id)}>
{catLabelBook(c)}
</option>
))}
</select>
</div>
<div className="card-body table-responsive">
{loading ? <p className="text-muted mb-0">Loading</p> : null}
{!loading ? (
<table className="table table-striped align-middle" id="inventoryTableBook">
<thead>
<tr>
<th>Name</th>
<th>ISBN</th>
<th>Edition</th>
<th>Category</th>
<th>Grade Range</th>
<th>Qty</th>
<th>Unit</th>
<th>Updated By</th>
<th>Updated Date</th>
<th>SKU</th>
<th style={{ width: 110 }}>Actions</th>
</tr>
</thead>
<tbody>
{payload.items.map((i) => {
const cid = i.category_id != null ? Number(i.category_id) : NaN
const cat = Number.isFinite(cid) ? catById.get(cid) : undefined
const show =
!categoryFilter ||
String(i.category_id ?? '') === categoryFilter
if (!show) return null
return (
<tr key={String(i.id)} data-category={String(i.category_id ?? '')}>
<td>{String(i.name ?? '')}</td>
<td>{String(i.isbn ?? '')}</td>
<td>{String(i.edition ?? '')}</td>
<td>{String(cat?.name ?? '—')}</td>
<td>{gradeLabel(cat)}</td>
<td>{String(i.quantity ?? '')}</td>
<td>{String(i.unit ?? '')}</td>
<td>{userDisplay(payload.userNames, i.updated_by)}</td>
<td>{String(i.updated_at ?? '—')}</td>
<td>{String(i.sku ?? '')}</td>
<td>
<Link
className="btn btn-sm btn-outline-primary me-1"
to={`${INVENTORY_BOOK_BASE}/${String(i.id)}/edit`}
>
Edit
</Link>
<button
type="button"
className="btn btn-sm btn-outline-danger"
onClick={() => void onDelete(Number(i.id))}
>
Del
</button>
</td>
</tr>
)
})}
</tbody>
</table>
) : null}
</div>
</div>
<CategoryAddModal
modalId="addCategoryModalBook"
inventoryType="book"
title="Add Category (Books)"
includeGradeRange
onSaved={load}
/>
</div>
)
}
@@ -0,0 +1,136 @@
import { type FormEvent, useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import { fetchInventoryItem, postClassroomAudit } from '../../api/inventory'
/** CI `inventory/classroom/audit_form.php` */
export function InventoryClassroomAuditPage() {
const { itemId } = useParams()
const [name, setName] = useState('')
const [qty, setQty] = useState(0)
const [needsRepair, setNeedsRepair] = useState(0)
const [needReplace, setNeedReplace] = useState(0)
const [cannotFind, setCannotFind] = useState(0)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!itemId) return
let c = false
setLoading(true)
fetchInventoryItem(itemId)
.then((data) => {
if (c) return
const item = (data?.item ?? data) as Record<string, unknown>
setName(String(item.name ?? ''))
setQty(Number(item.quantity ?? 0))
setNeedsRepair(Number(item.needs_repair_qty ?? 0))
setNeedReplace(Number(item.need_replace_qty ?? 0))
setCannotFind(Number(item.cannot_find_qty ?? 0))
setError(null)
})
.catch((e: unknown) => {
if (!c) setError(e instanceof ApiHttpError ? e.message : 'Unable to load item.')
})
.finally(() => {
if (!c) setLoading(false)
})
return () => {
c = true
}
}, [itemId])
const derivedGood = Math.max(0, qty - needsRepair)
async function onSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
setSaving(true)
setError(null)
try {
await postClassroomAudit(itemId ?? '', {
needs_repair_qty: needsRepair,
need_replace_qty: needReplace,
cannot_find_qty: cannotFind,
})
window.location.href = '/app/administrator/inventory/classroom'
} catch (err: unknown) {
setError(err instanceof ApiHttpError ? err.message : 'Save failed.')
} finally {
setSaving(false)
}
}
if (!itemId) {
return (
<div className="container my-4">
<div className="alert alert-warning">Missing item id.</div>
</div>
)
}
return (
<div className="container my-4">
<h3 className="mb-3">Audit {name || '…'}</h3>
{error ? <div className="alert alert-danger">{error}</div> : null}
{loading ? <p className="text-muted">Loading</p> : null}
{!loading ? (
<>
<div className="alert alert-info">
<strong>Current On Hand:</strong> {qty} pcs
</div>
<form onSubmit={(ev) => void onSubmit(ev)}>
<div className="row g-3">
<div className="col-md-3">
<label className="form-label">Needs Repair</label>
<input
type="number"
min={0}
className="form-control"
value={needsRepair}
onChange={(e) => setNeedsRepair(Number(e.target.value))}
/>
</div>
<div className="col-md-3">
<label className="form-label">Total Loss (Need Replace)</label>
<input
type="number"
min={0}
className="form-control"
value={needReplace}
onChange={(e) => setNeedReplace(Number(e.target.value))}
/>
</div>
<div className="col-md-3">
<label className="form-label">Cannot Find</label>
<input
type="number"
min={0}
className="form-control"
value={cannotFind}
onChange={(e) => setCannotFind(Number(e.target.value))}
/>
</div>
<div className="col-md-3">
<label className="form-label">Derived Good (read-only)</label>
<input type="number" className="form-control" value={derivedGood} readOnly />
<div className="form-text">Good = On Hand Needs Repair</div>
</div>
</div>
<div className="mt-3 d-flex gap-2">
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? 'Saving…' : 'Save Audit'}
</button>
<Link className="btn btn-outline-secondary" to="/app/administrator/inventory/classroom">
Cancel
</Link>
</div>
</form>
</>
) : null}
</div>
)
}
@@ -0,0 +1,335 @@
import { type FormEvent, useEffect, useMemo, useState } from 'react'
import { Link, useLocation, useParams, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import {
fetchInventoryCreateForm,
fetchInventoryItem,
postInventoryItem,
putInventoryItem,
} from '../../api/inventory'
const DEFAULT_CONDITIONS: Record<string, string> = {
good: 'Good condition',
needs_repair: 'Needs repair',
need_replace: 'Need replace',
cannot_find: 'Cannot find',
}
/** CI `inventory/classroom/form.php` */
export function InventoryClassroomFormPage() {
const { itemId } = useParams()
const { pathname } = useLocation()
const [searchParams] = useSearchParams()
const isCreate = pathname.endsWith('/create')
const [categories, setCategories] = useState<Record<string, unknown>[]>([])
const [conditionOptions, setConditionOptions] = useState<Record<string, string>>(DEFAULT_CONDITIONS)
const [name, setName] = useState('')
const [categoryId, setCategoryId] = useState('')
const [condition, setCondition] = useState('')
const [quantity, setQuantity] = useState('0')
const [unit, setUnit] = useState('')
const [sku, setSku] = useState('')
const [description, setDescription] = useState('')
const [updatedAt, setUpdatedAt] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [onHand, setOnHand] = useState(0)
const [needsRepairQty, setNeedsRepairQty] = useState(0)
const [lossQty, setLossQty] = useState(0)
const [missingQty, setMissingQty] = useState(0)
useEffect(() => {
let c = false
setLoading(true)
const run = isCreate
? fetchInventoryCreateForm('classroom', searchParams)
: fetchInventoryItem(itemId ?? '')
run
.then((data) => {
if (c) return
if (data && typeof data === 'object') {
const o = data as Record<string, unknown>
const cats = Array.isArray(o.categories)
? (o.categories as Record<string, unknown>[])
: []
setCategories(cats)
if (o.conditionOptions && typeof o.conditionOptions === 'object') {
setConditionOptions(o.conditionOptions as Record<string, string>)
}
const item = (o.item ?? (isCreate ? {} : o)) as Record<string, unknown>
if (!isCreate && item?.id != null) {
setName(String(item.name ?? ''))
setCategoryId(item.category_id != null ? String(item.category_id) : '')
setCondition(String(item.condition ?? ''))
const q = Number(item.quantity ?? 0)
setQuantity(String(q))
setOnHand(q)
setNeedsRepairQty(Number(item.needs_repair_qty ?? 0))
setLossQty(Number(item.need_replace_qty ?? 0))
setMissingQty(Number(item.cannot_find_qty ?? 0))
setUnit(String(item.unit ?? ''))
setSku(String(item.sku ?? ''))
setDescription(String(item.description ?? ''))
setUpdatedAt(item.updated_at != null ? String(item.updated_at) : null)
}
}
setError(null)
})
.catch((e: unknown) => {
if (!c) setError(e instanceof ApiHttpError ? e.message : 'Unable to load form.')
})
.finally(() => {
if (!c) setLoading(false)
})
return () => {
c = true
}
}, [isCreate, itemId, searchParams])
const goodCalc = useMemo(
() => Math.max(0, onHand - needsRepairQty),
[onHand, needsRepairQty],
)
async function onSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
setSaving(true)
setError(null)
const body: Record<string, unknown> = {
type: 'classroom',
name,
category_id: categoryId === '' ? null : Number(categoryId),
condition: condition || undefined,
quantity: isCreate ? (quantity === '' ? 0 : Number(quantity)) : onHand,
unit: unit || undefined,
sku: sku || undefined,
description: description || undefined,
}
try {
if (isCreate) await postInventoryItem(body)
else await putInventoryItem(itemId ?? '', body)
window.location.href = '/app/administrator/inventory/classroom'
} catch (err: unknown) {
setError(err instanceof ApiHttpError ? err.message : 'Save failed.')
} finally {
setSaving(false)
}
}
return (
<div className="container my-4">
<div className="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<h3 className="mb-0">{isCreate ? 'Add' : 'Edit'} Classroom Item</h3>
{!isCreate && itemId ? (
<div className="btn-group">
<Link className="btn btn-sm btn-outline-secondary" to="/app/administrator/inventory/classroom">
Items
</Link>
<Link
className="btn btn-sm btn-outline-secondary"
to={`/app/administrator/inventory/items/${itemId}/adjust`}
>
Adjust Stock
</Link>
<Link
className="btn btn-sm btn-outline-secondary"
to={`/app/administrator/inventory/classroom/${itemId}/audit`}
>
Audit
</Link>
</div>
) : null}
</div>
{error ? <div className="alert alert-danger">{error}</div> : null}
{loading ? <p className="text-muted">Loading</p> : null}
{!loading ? (
<>
<form onSubmit={(ev) => void onSubmit(ev)}>
<div className="row g-3">
<div className="col-md-6">
<label className="form-label">Name</label>
<input
className="form-control"
required
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="col-md-6">
<label className="form-label">Category</label>
<select
className="form-select"
value={categoryId}
onChange={(e) => setCategoryId(e.target.value)}
>
<option value=""> None </option>
{categories.map((c) => (
<option key={String(c.id)} value={String(c.id)}>
{String(c.name ?? '')}
</option>
))}
</select>
</div>
<div className="col-md-4">
<label className="form-label">Condition (overall)</label>
<select
className="form-select"
value={condition}
onChange={(e) => setCondition(e.target.value)}
>
<option value=""> Select </option>
{Object.entries(conditionOptions).map(([val, label]) => (
<option key={val} value={val}>
{label}
</option>
))}
</select>
<div className="form-text">Quick overall tag. Detailed states shown below.</div>
</div>
<div className="col-md-2">
<label className="form-label">Quantity</label>
<input
type="number"
min={0}
className="form-control"
value={isCreate ? quantity : String(onHand)}
onChange={(e) => {
if (isCreate) setQuantity(e.target.value)
}}
readOnly={!isCreate}
/>
{!isCreate ? (
<div className="form-text">
Change on-hand via{' '}
<Link to={`/app/administrator/inventory/items/${itemId}/adjust`}>Adjust Stock</Link>.
</div>
) : null}
</div>
<div className="col-md-2">
<label className="form-label">Unit</label>
<input
className="form-control"
placeholder="pcs, set, box"
value={unit}
onChange={(e) => setUnit(e.target.value)}
/>
</div>
<div className="col-md-4">
<label className="form-label">SKU</label>
<input className="form-control" value={sku} onChange={(e) => setSku(e.target.value)} />
</div>
<div className="col-12">
<label className="form-label">Description / Notes</label>
<textarea
className="form-control"
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
</div>
{!isCreate ? (
<div className="card mt-4">
<div className="card-header">Item State</div>
<div className="card-body table-responsive">
<table className="table table-sm align-middle">
<thead className="table-light">
<tr>
<th>State</th>
<th className="text-end">Count</th>
<th>Included in On-Hand?</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<strong>On Hand (now)</strong>
</td>
<td className="text-end">
<strong>{onHand}</strong>
</td>
<td></td>
<td>Sum of movements; authoritative stock.</td>
</tr>
<tr>
<td>Good</td>
<td className="text-end">{goodCalc}</td>
<td>Yes</td>
<td>Available to use (derived = On Hand Needs Repair).</td>
</tr>
<tr>
<td>Needs Repair</td>
<td className="text-end">{needsRepairQty}</td>
<td>Yes</td>
<td>Unavailable until fixed.</td>
</tr>
<tr>
<td>Total Loss</td>
<td className="text-end">{lossQty}</td>
<td>No</td>
<td>Deducted via audit (Out).</td>
</tr>
<tr>
<td>Cannot Find</td>
<td className="text-end">{missingQty}</td>
<td>No</td>
<td>Deducted via audit (Out).</td>
</tr>
</tbody>
<tfoot>
<tr className="table-light">
<th>Available to Use</th>
<th className="text-end">{goodCalc}</th>
<th colSpan={2} />
</tr>
<tr className="table-light">
<th>Unavailable but On-Hand (Repair)</th>
<th className="text-end">{needsRepairQty}</th>
<th colSpan={2} />
</tr>
<tr className="table-light">
<th>Removed (Loss + Missing)</th>
<th className="text-end">{lossQty + missingQty}</th>
<th colSpan={2} />
</tr>
</tfoot>
</table>
<div className="small text-muted">
Update states via <strong>Audit</strong>; stock movements are created automatically.
</div>
</div>
</div>
) : null}
<div className="mt-3 d-flex gap-2">
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? 'Saving…' : isCreate ? 'Save' : 'Update'}
</button>
<Link className="btn btn-outline-secondary" to="/app/administrator/inventory/classroom">
Cancel
</Link>
</div>
</form>
{!isCreate && updatedAt ? (
<div className="text-muted mt-3">
<small>Updated Date: {updatedAt}</small>
</div>
) : null}
</>
) : null}
</div>
)
}
@@ -0,0 +1,170 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import { deleteInventoryItem, fetchInventoryIndex } from '../../api/inventory'
import { CategoryAddModal } from './CategoryAddModal'
import {
conditionBadgeClass,
formatConditionLabel,
parseInventoryList,
userDisplay,
} from './inventoryHelpers'
/** CI `inventory/classroom/index.php` */
export function InventoryClassroomIndexPage() {
const [categoryFilter, setCategoryFilter] = useState('')
const [payload, setPayload] = useState<ReturnType<typeof parseInventoryList>>({
items: [],
categories: [],
userNames: {},
})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const load = useCallback(() => {
setLoading(true)
fetchInventoryIndex('classroom', new URLSearchParams())
.then((data) => {
setPayload(parseInventoryList(data))
setError(null)
})
.catch((e: unknown) => {
setError(e instanceof ApiHttpError ? e.message : 'Unable to load classroom inventory.')
})
.finally(() => setLoading(false))
}, [])
useEffect(() => {
load()
}, [load])
const catById = useMemo(() => {
const m = new Map<number, Record<string, unknown>>()
for (const c of payload.categories) {
const id = Number(c.id ?? 0)
if (id) m.set(id, c)
}
return m
}, [payload.categories])
async function onDelete(id: number) {
if (!confirm('Delete this item?')) return
try {
await deleteInventoryItem(id)
load()
} catch (e: unknown) {
setError(e instanceof ApiHttpError ? e.message : 'Delete failed.')
}
}
return (
<div className="container-fluid px-0 mt-3">
<div className="d-flex justify-content-between align-items-center mb-3 px-3">
<h3 className="mb-0">Classroom Equipment</h3>
<div className="btn-group">
<Link className="btn btn-primary" to="/app/administrator/inventory/classroom/create">
Add Item
</Link>
<button
type="button"
className="btn btn-outline-secondary"
data-bs-toggle="modal"
data-bs-target="#addCategoryModalClassroom"
>
Add Category
</button>
</div>
</div>
{error ? <div className="alert alert-danger mx-3">{error}</div> : null}
<div className="card mb-4 mx-3">
<div className="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<span>Items</span>
<select
className="form-select form-select-sm"
style={{ minWidth: 220 }}
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
>
<option value="">All Categories</option>
{payload.categories.map((c) => (
<option key={String(c.id)} value={String(c.id)}>
{String(c.name ?? '')}
</option>
))}
</select>
</div>
<div className="card-body table-responsive">
{loading ? <p className="text-muted mb-0">Loading</p> : null}
{!loading ? (
<table className="table table-striped align-middle" id="inventoryTableClassroom">
<thead>
<tr>
<th>Name</th>
<th>Condition</th>
<th>Category</th>
<th>Qty</th>
<th>Unit</th>
<th>Updated By</th>
<th>Updated Date</th>
<th>SKU</th>
<th style={{ width: 110 }}>Actions</th>
</tr>
</thead>
<tbody>
{payload.items.map((i) => {
const cid = i.category_id != null ? Number(i.category_id) : NaN
const cat = Number.isFinite(cid) ? catById.get(cid) : undefined
const show =
!categoryFilter ||
String(i.category_id ?? '') === categoryFilter
if (!show) return null
const cond = i.condition
return (
<tr key={String(i.id)} data-category={String(i.category_id ?? '')}>
<td>{String(i.name ?? '')}</td>
<td>
<span className={`badge bg-${conditionBadgeClass(cond)}`}>
{formatConditionLabel(cond)}
</span>
</td>
<td>{String(cat?.name ?? '—')}</td>
<td>{String(i.quantity ?? '')}</td>
<td>{String(i.unit ?? '')}</td>
<td>{userDisplay(payload.userNames, i.updated_by)}</td>
<td>{String(i.updated_at ?? '—')}</td>
<td>{String(i.sku ?? '')}</td>
<td>
<Link
className="btn btn-sm btn-outline-primary me-1"
to={`/app/administrator/inventory/classroom/${String(i.id)}/edit`}
>
Edit
</Link>
<button
type="button"
className="btn btn-sm btn-outline-danger"
onClick={() => void onDelete(Number(i.id))}
>
Del
</button>
</td>
</tr>
)
})}
</tbody>
</table>
) : null}
</div>
</div>
<CategoryAddModal
modalId="addCategoryModalClassroom"
inventoryType="classroom"
title="Add Category (Classroom)"
onSaved={load}
/>
</div>
)
}
@@ -0,0 +1,53 @@
import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import { fetchInventoryItem } from '../../api/inventory'
/**
* CI `inventory/edit/:id` — resolves item type and redirects to the typed edit route.
*/
export function InventoryEditEntryPage() {
const { itemId } = useParams()
const navigate = useNavigate()
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!itemId) return
let c = false
fetchInventoryItem(itemId)
.then((data) => {
if (c) return
const item = (data && typeof data === 'object' ? (data as { item?: unknown }).item : null) ?? data
const rec = (typeof item === 'object' && item !== null ? item : {}) as Record<string, unknown>
const t = String(rec.type ?? '')
const map: Record<string, string> = {
book: 'book',
classroom: 'classroom',
kitchen: 'kitchen',
office: 'office',
}
const seg = map[t]
if (seg) {
navigate(`/app/administrator/inventory/${seg}/${itemId}/edit`, { replace: true })
} else {
setError('Unknown inventory item type.')
}
})
.catch((e: unknown) => {
if (!c) setError(e instanceof ApiHttpError ? e.message : 'Unable to load item.')
})
return () => {
c = true
}
}, [itemId, navigate])
if (!itemId) {
return <div className="alert alert-warning m-3">Missing item id.</div>
}
return (
<div className="p-3">
{error ? <div className="alert alert-danger">{error}</div> : <p className="text-muted mb-0">Redirecting</p>}
</div>
)
}
@@ -0,0 +1,164 @@
import { type FormEvent, useEffect, useState } from 'react'
import { Link, useLocation, useParams, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import {
fetchInventoryCreateForm,
fetchInventoryItem,
postInventoryItem,
putInventoryItem,
} from '../../api/inventory'
/** CI `inventory/kitchen/form.php` */
export function InventoryKitchenFormPage() {
const { itemId } = useParams()
const { pathname } = useLocation()
const [searchParams] = useSearchParams()
const isCreate = pathname.endsWith('/create')
const [categories, setCategories] = useState<Record<string, unknown>[]>([])
const [name, setName] = useState('')
const [categoryId, setCategoryId] = useState('')
const [quantity, setQuantity] = useState('0')
const [unit, setUnit] = useState('')
const [sku, setSku] = useState('')
const [description, setDescription] = useState('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let c = false
setLoading(true)
const run = isCreate
? fetchInventoryCreateForm('kitchen', searchParams)
: fetchInventoryItem(itemId ?? '')
run
.then((data) => {
if (c) return
if (data && typeof data === 'object') {
const o = data as Record<string, unknown>
const cats = Array.isArray(o.categories)
? (o.categories as Record<string, unknown>[])
: []
setCategories(cats)
const item = (o.item ?? o) as Record<string, unknown>
if (!isCreate && item?.id != null) {
setName(String(item.name ?? ''))
setCategoryId(item.category_id != null ? String(item.category_id) : '')
setQuantity(String(item.quantity ?? 0))
setUnit(String(item.unit ?? ''))
setSku(String(item.sku ?? ''))
setDescription(String(item.description ?? ''))
}
}
setError(null)
})
.catch((e: unknown) => {
if (!c) setError(e instanceof ApiHttpError ? e.message : 'Unable to load form.')
})
.finally(() => {
if (!c) setLoading(false)
})
return () => {
c = true
}
}, [isCreate, itemId, searchParams])
async function onSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
setSaving(true)
setError(null)
const body: Record<string, unknown> = {
type: 'kitchen',
name,
category_id: categoryId === '' ? null : Number(categoryId),
quantity: quantity === '' ? 0 : Number(quantity),
unit: unit || undefined,
sku: sku || undefined,
description: description || undefined,
}
try {
if (isCreate) await postInventoryItem(body)
else await putInventoryItem(itemId ?? '', body)
window.location.href = '/app/administrator/inventory/kitchen'
} catch (err: unknown) {
setError(err instanceof ApiHttpError ? err.message : 'Save failed.')
} finally {
setSaving(false)
}
}
return (
<div className="container my-4">
<h3 className="mb-3">{isCreate ? 'Add' : 'Edit'} Kitchen Supply</h3>
{error ? <div className="alert alert-danger">{error}</div> : null}
{loading ? <p className="text-muted">Loading</p> : null}
{!loading ? (
<form onSubmit={(ev) => void onSubmit(ev)}>
<div className="row g-3">
<div className="col-md-6">
<label className="form-label">Name</label>
<input
className="form-control"
required
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="col-md-6">
<label className="form-label">Category</label>
<select
className="form-select"
value={categoryId}
onChange={(e) => setCategoryId(e.target.value)}
>
<option value=""> None </option>
{categories.map((c) => (
<option key={String(c.id)} value={String(c.id)}>
{String(c.name ?? '')}
</option>
))}
</select>
</div>
<div className="col-md-2">
<label className="form-label">Quantity</label>
<input
type="number"
min={0}
className="form-control"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
/>
</div>
<div className="col-md-2">
<label className="form-label">Unit</label>
<input className="form-control" value={unit} onChange={(e) => setUnit(e.target.value)} />
</div>
<div className="col-md-4">
<label className="form-label">SKU</label>
<input className="form-control" value={sku} onChange={(e) => setSku(e.target.value)} />
</div>
<div className="col-12">
<label className="form-label">Description / Notes</label>
<textarea
className="form-control"
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
</div>
<div className="mt-3 d-flex gap-2">
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? 'Saving…' : isCreate ? 'Save' : 'Update'}
</button>
<Link className="btn btn-outline-secondary" to="/app/administrator/inventory/kitchen">
Cancel
</Link>
</div>
</form>
) : null}
</div>
)
}
@@ -0,0 +1,163 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import { deleteInventoryItem, fetchInventoryIndex } from '../../api/inventory'
import { AcademicFilterBar } from '../discounts/AcademicFilterBar'
import { CategoryAddModal } from './CategoryAddModal'
import { parseInventoryList, userDisplay } from './inventoryHelpers'
/** CI `inventory/kitchen/index.php` */
export function InventoryKitchenIndexPage() {
const [searchParams] = useSearchParams()
const [categoryFilter, setCategoryFilter] = useState('')
const [payload, setPayload] = useState<ReturnType<typeof parseInventoryList>>({
items: [],
categories: [],
userNames: {},
})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const load = useCallback(() => {
setLoading(true)
fetchInventoryIndex('kitchen', searchParams)
.then((data) => {
setPayload(parseInventoryList(data))
setError(null)
})
.catch((e: unknown) => {
setError(e instanceof ApiHttpError ? e.message : 'Unable to load kitchen inventory.')
})
.finally(() => setLoading(false))
}, [searchParams])
useEffect(() => {
load()
}, [load])
const catById = useMemo(() => {
const m = new Map<number, Record<string, unknown>>()
for (const c of payload.categories) {
const id = Number(c.id ?? 0)
if (id) m.set(id, c)
}
return m
}, [payload.categories])
async function onDelete(id: number) {
if (!confirm('Delete this item?')) return
try {
await deleteInventoryItem(id)
load()
} catch (e: unknown) {
setError(e instanceof ApiHttpError ? e.message : 'Delete failed.')
}
}
return (
<div className="container-fluid px-0 mt-3">
<div className="d-flex justify-content-between align-items-center mb-3 px-3">
<h3 className="mb-0">Kitchen Supplies</h3>
<div className="btn-group">
<Link className="btn btn-primary" to="/app/administrator/inventory/kitchen/create">
Add Item
</Link>
<button
type="button"
className="btn btn-outline-secondary"
data-bs-toggle="modal"
data-bs-target="#addCategoryModalKitchen"
>
Add Category
</button>
</div>
</div>
{error ? <div className="alert alert-danger mx-3">{error}</div> : null}
<div className="px-3 mb-2">
<AcademicFilterBar />
</div>
<div className="card mb-4 mx-3">
<div className="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<span>Items</span>
<select
className="form-select form-select-sm"
style={{ minWidth: 220 }}
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
>
<option value="">All Categories</option>
{payload.categories.map((c) => (
<option key={String(c.id)} value={String(c.id)}>
{String(c.name ?? '')}
</option>
))}
</select>
</div>
<div className="card-body table-responsive">
{loading ? <p className="text-muted mb-0">Loading</p> : null}
{!loading ? (
<table className="table table-striped align-middle">
<thead>
<tr>
<th>Name</th>
<th>Category</th>
<th>Qty</th>
<th>Unit</th>
<th>Updated By</th>
<th>Updated Date</th>
<th>SKU</th>
<th style={{ width: 110 }}>Actions</th>
</tr>
</thead>
<tbody>
{payload.items.map((i) => {
const cid = i.category_id != null ? Number(i.category_id) : NaN
const cat = Number.isFinite(cid) ? catById.get(cid) : undefined
const show =
!categoryFilter ||
String(i.category_id ?? '') === categoryFilter
if (!show) return null
return (
<tr key={String(i.id)} data-category={String(i.category_id ?? '')}>
<td>{String(i.name ?? '')}</td>
<td>{String(cat?.name ?? '—')}</td>
<td>{String(i.quantity ?? '')}</td>
<td>{String(i.unit ?? '')}</td>
<td>{userDisplay(payload.userNames, i.updated_by)}</td>
<td>{String(i.updated_at ?? '—')}</td>
<td>{String(i.sku ?? '')}</td>
<td>
<Link
className="btn btn-sm btn-outline-primary me-1"
to={`/app/administrator/inventory/kitchen/${String(i.id)}/edit`}
>
Edit
</Link>
<button
type="button"
className="btn btn-sm btn-outline-danger"
onClick={() => void onDelete(Number(i.id))}
>
Del
</button>
</td>
</tr>
)
})}
</tbody>
</table>
) : null}
</div>
</div>
<CategoryAddModal
modalId="addCategoryModalKitchen"
inventoryType="kitchen"
title="Add Category (Kitchen)"
onSaved={load}
/>
</div>
)
}
@@ -0,0 +1,344 @@
import { type FormEvent, useEffect, useState } from 'react'
import { Link, useLocation, useParams, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import { fetchMovementForm, postMovement, putMovement } from '../../api/inventory'
/** CI `inventory/movements/form.php` + `movements/edit.php` (single React screen). */
export function InventoryMovementFormPage() {
const { movementId } = useParams()
const { pathname } = useLocation()
const [searchParams] = useSearchParams()
const isCreate = pathname.includes('/create')
const [movement, setMovement] = useState<Record<string, unknown>>({})
const [items, setItems] = useState<Record<string, unknown>[]>([])
const [movementTypes, setMovementTypes] = useState<string[]>([
'initial',
'in',
'out',
'adjust',
'distribution',
])
const [semesters, setSemesters] = useState<string[]>(['Fall', 'Spring'])
const [teachers, setTeachers] = useState<Record<string, unknown>[]>([])
const [students, setStudents] = useState<Record<string, unknown>[]>([])
const [classSections, setClassSections] = useState<Record<string, unknown>[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [itemId, setItemId] = useState('')
const [qtyChange, setQtyChange] = useState('0')
const [movementType, setMovementType] = useState('adjust')
const [reason, setReason] = useState('')
const [note, setNote] = useState('')
const [semester, setSemester] = useState('')
const [schoolYear, setSchoolYear] = useState('')
const [teacherId, setTeacherId] = useState('')
const [studentId, setStudentId] = useState('')
const [classSectionId, setClassSectionId] = useState('')
useEffect(() => {
let c = false
setLoading(true)
fetchMovementForm(isCreate ? undefined : movementId, searchParams)
.then((data) => {
if (c) return
if (data && typeof data === 'object') {
const o = data as Record<string, unknown>
if (Array.isArray(o.movementTypes)) {
setMovementTypes(o.movementTypes as string[])
}
if (Array.isArray(o.semesters)) setSemesters(o.semesters as string[])
if (Array.isArray(o.items)) setItems(o.items as Record<string, unknown>[])
if (Array.isArray(o.teachers)) setTeachers(o.teachers as Record<string, unknown>[])
if (Array.isArray(o.students)) setStudents(o.students as Record<string, unknown>[])
if (Array.isArray(o.classSections)) {
setClassSections(o.classSections as Record<string, unknown>[])
}
const mov = (o.movement ?? {}) as Record<string, unknown>
setMovement(mov)
if (!isCreate && mov.id != null) {
setItemId(String(mov.item_id ?? ''))
setQtyChange(String(mov.qty_change ?? 0))
setMovementType(String(mov.movement_type ?? 'adjust'))
setReason(String(mov.reason ?? ''))
setNote(String(mov.note ?? ''))
setSemester(String(mov.semester ?? ''))
setSchoolYear(String(mov.school_year ?? ''))
setTeacherId(mov.teacher_id != null ? String(mov.teacher_id) : '')
setStudentId(mov.student_id != null ? String(mov.student_id) : '')
setClassSectionId(mov.class_section_id != null ? String(mov.class_section_id) : '')
}
}
setError(null)
})
.catch((e: unknown) => {
if (!c) setError(e instanceof ApiHttpError ? e.message : 'Unable to load form.')
})
.finally(() => {
if (!c) setLoading(false)
})
return () => {
c = true
}
}, [isCreate, movementId, searchParams])
async function onSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
setSaving(true)
setError(null)
const body: Record<string, unknown> = {
item_id: itemId === '' ? undefined : Number(itemId),
qty_change: qtyChange === '' ? 0 : Number(qtyChange),
movement_type: movementType,
reason: reason || undefined,
note: note || undefined,
semester: semester || undefined,
school_year: schoolYear || undefined,
teacher_id: teacherId === '' ? undefined : Number(teacherId),
student_id: studentId === '' ? undefined : Number(studentId),
class_section_id: classSectionId === '' ? undefined : classSectionId,
}
try {
if (isCreate) await postMovement(body)
else await putMovement(movementId ?? '', body)
window.location.href = '/app/administrator/inventory/movements'
} catch (err: unknown) {
setError(err instanceof ApiHttpError ? err.message : 'Save failed.')
} finally {
setSaving(false)
}
}
const itemLabel =
String(movement.item_name ?? movement.item_id ?? '') || '—'
return (
<div className="container my-4">
<div className="d-flex justify-content-between align-items-center mb-3">
<h3 className="mb-0">{isCreate ? 'Create Inventory Movement' : 'Edit Inventory Movement'}</h3>
<Link className="btn btn-outline-secondary" to="/app/administrator/inventory/movements">
Back to List
</Link>
</div>
{error ? <div className="alert alert-danger">{error}</div> : null}
{loading ? <p className="text-muted">Loading</p> : null}
{!loading ? (
<form onSubmit={(ev) => void onSubmit(ev)} className="card shadow-sm">
<div className="card-body">
<div className="mb-3">
<label className="form-label">
Item <span className="text-danger">*</span>
</label>
{isCreate ? (
<select
className="form-select"
required
value={itemId}
onChange={(e) => setItemId(e.target.value)}
>
<option value="">-- Select Item --</option>
{items.map((it) => (
<option key={String(it.id)} value={String(it.id)}>
{String(it.name ?? it.id)}
</option>
))}
</select>
) : (
<>
<input type="text" className="form-control" value={itemLabel} disabled />
<div className="form-text">Item cannot be changed after creation.</div>
</>
)}
</div>
<div className="row">
<div className="col-md-6 mb-3">
<label className="form-label">
Quantity Change <span className="text-danger">*</span>
</label>
<input
type="number"
required
className="form-control"
value={qtyChange}
onChange={(e) => setQtyChange(e.target.value)}
/>
<div className="form-text">
Use negative values for outbound movements if type is <code>out</code>.
</div>
</div>
<div className="col-md-6 mb-3">
<label className="form-label">
Movement Type <span className="text-danger">*</span>
</label>
<select
className="form-select"
required
value={movementType}
onChange={(e) => setMovementType(e.target.value)}
>
{movementTypes.map((t) => (
<option key={t} value={t}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</option>
))}
</select>
</div>
</div>
<div className="mb-3">
<label className="form-label">Reason</label>
<input
type="text"
maxLength={120}
className="form-control"
placeholder="Short reason (max 120 chars)"
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
</div>
<div className="mb-3">
<label className="form-label">Note</label>
<textarea
className="form-control"
rows={3}
placeholder="Any additional details about this movement..."
value={note}
onChange={(e) => setNote(e.target.value)}
/>
</div>
<div className="row">
<div className="col-md-6 mb-3">
<label className="form-label">Semester</label>
<select
className="form-select"
value={semester}
onChange={(e) => setSemester(e.target.value)}
>
<option value="">-- None --</option>
{semesters.map((sem) => (
<option key={sem} value={sem}>
{sem}
</option>
))}
</select>
</div>
<div className="col-md-6 mb-3">
<label className="form-label">School Year</label>
<input
type="text"
className="form-control"
placeholder="e.g., 2025-2026"
value={schoolYear}
onChange={(e) => setSchoolYear(e.target.value)}
/>
</div>
</div>
<div className="row">
<div className="col-md-4 mb-3">
<label className="form-label">Teacher (optional)</label>
<select
className="form-select"
value={teacherId}
onChange={(e) => setTeacherId(e.target.value)}
>
<option value="">-- None --</option>
{teachers.map((t) => (
<option key={String(t.id)} value={String(t.id)}>
{String(t.fullname ?? t.id)} ({String(t.role ?? '')})
</option>
))}
</select>
</div>
<div className="col-md-4 mb-3">
<label className="form-label">Student (optional)</label>
<select
className="form-select"
value={studentId}
onChange={(e) => setStudentId(e.target.value)}
>
<option value="">-- None --</option>
{students.map((s) => (
<option key={String(s.id)} value={String(s.id)}>
{String(s.fullname ?? s.id)}
</option>
))}
</select>
</div>
<div className="col-md-4 mb-3">
<label className="form-label">Class Section (optional)</label>
<select
className="form-select"
value={classSectionId}
onChange={(e) => setClassSectionId(e.target.value)}
>
<option value="">-- None --</option>
{classSections.map((cs) => (
<option
key={String(cs.class_section_id)}
value={String(cs.class_section_id)}
>
{String(cs.class_section_name ?? cs.class_section_id)}
</option>
))}
</select>
<div className="form-text">Uses the class_section_id code.</div>
</div>
</div>
{!isCreate ? (
<>
<hr />
<div className="row g-3">
<div className="col-md-4">
<label className="form-label">Performed By</label>
<input
type="text"
className="form-control"
value={String(movement.performed_by_name ?? movement.performed_by ?? '—')}
disabled
/>
</div>
<div className="col-md-4">
<label className="form-label">Created At</label>
<input
type="text"
className="form-control"
value={String(movement.created_at ?? '—')}
disabled
/>
</div>
<div className="col-md-4">
<label className="form-label">Last Updated</label>
<input
type="text"
className="form-control"
value={String(movement.updated_at ?? '—')}
disabled
/>
</div>
</div>
</>
) : null}
</div>
<div className="card-footer d-flex gap-2">
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? 'Saving…' : isCreate ? 'Create Movement' : 'Save Changes'}
</button>
<Link className="btn btn-secondary" to="/app/administrator/inventory/movements">
Cancel
</Link>
</div>
</form>
) : null}
</div>
)
}
@@ -0,0 +1,205 @@
import { type FormEvent, useCallback, useEffect, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import {
deleteMovement,
fetchMovements,
postMovementsBulkDelete,
} from '../../api/inventory'
import { AcademicFilterBar } from '../discounts/AcademicFilterBar'
/** CI `inventory/movements/index.php` (DataTables replaced with static table). */
export function InventoryMovementsIndexPage() {
const [searchParams] = useSearchParams()
const [movements, setMovements] = useState<Record<string, unknown>[]>([])
const [selected, setSelected] = useState<Record<number, boolean>>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const load = useCallback(() => {
setLoading(true)
fetchMovements(searchParams)
.then((data) => {
if (data && typeof data === 'object') {
const o = data as Record<string, unknown>
setMovements(Array.isArray(o.movements) ? (o.movements as Record<string, unknown>[]) : [])
} else {
setMovements([])
}
setSelected({})
setError(null)
})
.catch((e: unknown) => {
setError(e instanceof ApiHttpError ? e.message : 'Unable to load movements.')
})
.finally(() => setLoading(false))
}, [searchParams])
useEffect(() => {
load()
}, [load])
const selectedIds = Object.entries(selected)
.filter(([, v]) => v)
.map(([k]) => Number(k))
.filter((n) => Number.isFinite(n))
function toggleRow(id: number, checked: boolean) {
setSelected((prev) => ({ ...prev, [id]: checked }))
}
function toggleAll(checked: boolean) {
const next: Record<number, boolean> = {}
if (checked) {
for (const m of movements) {
const id = Number(m.id)
if (Number.isFinite(id)) next[id] = true
}
}
setSelected(next)
}
async function onBulkDelete(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
if (selectedIds.length === 0) return
if (!confirm(`Delete ${selectedIds.length} selected movement(s)?`)) return
try {
await postMovementsBulkDelete(selectedIds)
load()
} catch (err: unknown) {
setError(err instanceof ApiHttpError ? err.message : 'Bulk delete failed.')
}
}
async function onDeleteOne(id: number) {
if (!confirm('Delete this movement?')) return
try {
await deleteMovement(id)
load()
} catch (err: unknown) {
setError(err instanceof ApiHttpError ? err.message : 'Delete failed.')
}
}
return (
<div className="container-fluid">
<div className="d-flex justify-content-between align-items-center my-3 flex-wrap gap-2">
<h2 className="mb-0">Inventory Movements</h2>
<Link className="btn btn-success" to="/app/administrator/inventory/movements/create">
New Movement
</Link>
</div>
<div className="mb-2">
<AcademicFilterBar />
</div>
{error ? <div className="alert alert-danger">{error}</div> : null}
<form id="bulkDeleteForm" onSubmit={(ev) => void onBulkDelete(ev)}>
<div className="d-flex gap-2 mb-2">
<button type="submit" className="btn btn-danger btn-sm" disabled={selectedIds.length === 0}>
Delete Selected
</button>
</div>
<div className="table-responsive">
<table className="table table-striped table-hover align-middle" id="movementsTable">
<thead className="table-dark">
<tr>
<th style={{ width: 36 }}>
<input
type="checkbox"
title="Select all"
checked={(() => {
const ids = movements.map((m) => Number(m.id)).filter((n) => Number.isFinite(n))
return ids.length > 0 && ids.every((id) => selected[id])
})()}
onChange={(e) => toggleAll(e.target.checked)}
/>
</th>
<th>ID</th>
<th>Item</th>
<th>Qty Change</th>
<th>Movement Type</th>
<th>Reason</th>
<th>Semester</th>
<th>School Year</th>
<th>Teacher</th>
<th>Student</th>
<th>Class Section</th>
<th>Performed By</th>
<th>Created At</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={14} className="text-muted">
Loading
</td>
</tr>
) : movements.length === 0 ? (
<tr>
<td colSpan={14} className="text-muted">
No movements.
</td>
</tr>
) : (
movements.map((m) => {
const id = Number(m.id)
return (
<tr key={id}>
<td>
<input
type="checkbox"
checked={Boolean(selected[id])}
onChange={(e) => toggleRow(id, e.target.checked)}
/>
</td>
<td>{String(m.id ?? '')}</td>
<td>{String(m.item_name ?? m.item_id ?? '')}</td>
<td>{String(m.qty_change ?? '')}</td>
<td>
<span className="badge bg-info">{String(m.movement_type ?? '')}</span>
</td>
<td>{String(m.reason ?? '')}</td>
<td>{String(m.semester ?? '')}</td>
<td>{String(m.school_year ?? '')}</td>
<td>{String(m.teacher_name ?? m.teacher_id ?? '')}</td>
<td>{String(m.student_name ?? m.student_id ?? '')}</td>
<td>{String(m.class_section_name ?? m.class_section_id ?? '')}</td>
<td>{String(m.performed_by_name ?? m.performed_by ?? '')}</td>
<td>{String(m.created_at ?? '')}</td>
<td className="text-nowrap">
<Link
className="btn btn-sm btn-primary me-1"
to={`/app/administrator/inventory/movements/${id}/edit`}
>
Edit
</Link>
<button
type="button"
className="btn btn-sm btn-danger"
onClick={() => void onDeleteOne(id)}
>
Delete
</button>
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
<div className="mt-2">
<button type="submit" className="btn btn-danger btn-sm" disabled={selectedIds.length === 0}>
Delete Selected
</button>
</div>
</form>
</div>
)
}
@@ -0,0 +1,164 @@
import { type FormEvent, useEffect, useState } from 'react'
import { Link, useLocation, useParams, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import {
fetchInventoryCreateForm,
fetchInventoryItem,
postInventoryItem,
putInventoryItem,
} from '../../api/inventory'
/** CI `inventory/office/form.php` */
export function InventoryOfficeFormPage() {
const { itemId } = useParams()
const { pathname } = useLocation()
const [searchParams] = useSearchParams()
const isCreate = pathname.endsWith('/create')
const [categories, setCategories] = useState<Record<string, unknown>[]>([])
const [name, setName] = useState('')
const [categoryId, setCategoryId] = useState('')
const [quantity, setQuantity] = useState('0')
const [unit, setUnit] = useState('')
const [sku, setSku] = useState('')
const [description, setDescription] = useState('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let c = false
setLoading(true)
const run = isCreate
? fetchInventoryCreateForm('office', searchParams)
: fetchInventoryItem(itemId ?? '')
run
.then((data) => {
if (c) return
if (data && typeof data === 'object') {
const o = data as Record<string, unknown>
const cats = Array.isArray(o.categories)
? (o.categories as Record<string, unknown>[])
: []
setCategories(cats)
const item = (o.item ?? o) as Record<string, unknown>
if (!isCreate && item?.id != null) {
setName(String(item.name ?? ''))
setCategoryId(item.category_id != null ? String(item.category_id) : '')
setQuantity(String(item.quantity ?? 0))
setUnit(String(item.unit ?? ''))
setSku(String(item.sku ?? ''))
setDescription(String(item.description ?? ''))
}
}
setError(null)
})
.catch((e: unknown) => {
if (!c) setError(e instanceof ApiHttpError ? e.message : 'Unable to load form.')
})
.finally(() => {
if (!c) setLoading(false)
})
return () => {
c = true
}
}, [isCreate, itemId, searchParams])
async function onSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
setSaving(true)
setError(null)
const body: Record<string, unknown> = {
type: 'office',
name,
category_id: categoryId === '' ? null : Number(categoryId),
quantity: quantity === '' ? 0 : Number(quantity),
unit: unit || undefined,
sku: sku || undefined,
description: description || undefined,
}
try {
if (isCreate) await postInventoryItem(body)
else await putInventoryItem(itemId ?? '', body)
window.location.href = '/app/administrator/inventory/office'
} catch (err: unknown) {
setError(err instanceof ApiHttpError ? err.message : 'Save failed.')
} finally {
setSaving(false)
}
}
return (
<div className="container my-4">
<h3 className="mb-3">{isCreate ? 'Add' : 'Edit'} Office Supply</h3>
{error ? <div className="alert alert-danger">{error}</div> : null}
{loading ? <p className="text-muted">Loading</p> : null}
{!loading ? (
<form onSubmit={(ev) => void onSubmit(ev)}>
<div className="row g-3">
<div className="col-md-6">
<label className="form-label">Name</label>
<input
className="form-control"
required
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="col-md-6">
<label className="form-label">Category</label>
<select
className="form-select"
value={categoryId}
onChange={(e) => setCategoryId(e.target.value)}
>
<option value=""> None </option>
{categories.map((c) => (
<option key={String(c.id)} value={String(c.id)}>
{String(c.name ?? '')}
</option>
))}
</select>
</div>
<div className="col-md-2">
<label className="form-label">Quantity</label>
<input
type="number"
min={0}
className="form-control"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
/>
</div>
<div className="col-md-2">
<label className="form-label">Unit</label>
<input className="form-control" value={unit} onChange={(e) => setUnit(e.target.value)} />
</div>
<div className="col-md-4">
<label className="form-label">SKU</label>
<input className="form-control" value={sku} onChange={(e) => setSku(e.target.value)} />
</div>
<div className="col-12">
<label className="form-label">Description / Notes</label>
<textarea
className="form-control"
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
</div>
<div className="mt-3 d-flex gap-2">
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? 'Saving…' : isCreate ? 'Save' : 'Update'}
</button>
<Link className="btn btn-outline-secondary" to="/app/administrator/inventory/office">
Cancel
</Link>
</div>
</form>
) : null}
</div>
)
}
@@ -0,0 +1,163 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import { deleteInventoryItem, fetchInventoryIndex } from '../../api/inventory'
import { AcademicFilterBar } from '../discounts/AcademicFilterBar'
import { CategoryAddModal } from './CategoryAddModal'
import { parseInventoryList, userDisplay } from './inventoryHelpers'
/** CI `inventory/office/index.php` */
export function InventoryOfficeIndexPage() {
const [searchParams] = useSearchParams()
const [categoryFilter, setCategoryFilter] = useState('')
const [payload, setPayload] = useState<ReturnType<typeof parseInventoryList>>({
items: [],
categories: [],
userNames: {},
})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const load = useCallback(() => {
setLoading(true)
fetchInventoryIndex('office', searchParams)
.then((data) => {
setPayload(parseInventoryList(data))
setError(null)
})
.catch((e: unknown) => {
setError(e instanceof ApiHttpError ? e.message : 'Unable to load office inventory.')
})
.finally(() => setLoading(false))
}, [searchParams])
useEffect(() => {
load()
}, [load])
const catById = useMemo(() => {
const m = new Map<number, Record<string, unknown>>()
for (const c of payload.categories) {
const id = Number(c.id ?? 0)
if (id) m.set(id, c)
}
return m
}, [payload.categories])
async function onDelete(id: number) {
if (!confirm('Delete this item?')) return
try {
await deleteInventoryItem(id)
load()
} catch (e: unknown) {
setError(e instanceof ApiHttpError ? e.message : 'Delete failed.')
}
}
return (
<div className="container-fluid px-0 mt-3">
<div className="d-flex justify-content-between align-items-center mb-3 px-3">
<h3 className="mb-0">Office Supplies</h3>
<div className="btn-group">
<Link className="btn btn-primary" to="/app/administrator/inventory/office/create">
Add Item
</Link>
<button
type="button"
className="btn btn-outline-secondary"
data-bs-toggle="modal"
data-bs-target="#addCategoryModalOffice"
>
Add Category
</button>
</div>
</div>
{error ? <div className="alert alert-danger mx-3">{error}</div> : null}
<div className="px-3 mb-2">
<AcademicFilterBar />
</div>
<div className="card mb-4 mx-3">
<div className="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<span>Items</span>
<select
className="form-select form-select-sm"
style={{ minWidth: 220 }}
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
>
<option value="">All Categories</option>
{payload.categories.map((c) => (
<option key={String(c.id)} value={String(c.id)}>
{String(c.name ?? '')}
</option>
))}
</select>
</div>
<div className="card-body table-responsive">
{loading ? <p className="text-muted mb-0">Loading</p> : null}
{!loading ? (
<table className="table table-striped align-middle">
<thead>
<tr>
<th>Name</th>
<th>Category</th>
<th>Qty</th>
<th>Unit</th>
<th>Updated By</th>
<th>Updated Date</th>
<th>SKU</th>
<th style={{ width: 110 }}>Actions</th>
</tr>
</thead>
<tbody>
{payload.items.map((i) => {
const cid = i.category_id != null ? Number(i.category_id) : NaN
const cat = Number.isFinite(cid) ? catById.get(cid) : undefined
const show =
!categoryFilter ||
String(i.category_id ?? '') === categoryFilter
if (!show) return null
return (
<tr key={String(i.id)} data-category={String(i.category_id ?? '')}>
<td>{String(i.name ?? '')}</td>
<td>{String(cat?.name ?? '—')}</td>
<td>{String(i.quantity ?? '')}</td>
<td>{String(i.unit ?? '')}</td>
<td>{userDisplay(payload.userNames, i.updated_by)}</td>
<td>{String(i.updated_at ?? '—')}</td>
<td>{String(i.sku ?? '')}</td>
<td>
<Link
className="btn btn-sm btn-outline-primary me-1"
to={`/app/administrator/inventory/office/${String(i.id)}/edit`}
>
Edit
</Link>
<button
type="button"
className="btn btn-sm btn-outline-danger"
onClick={() => void onDelete(Number(i.id))}
>
Del
</button>
</td>
</tr>
)
})}
</tbody>
</table>
) : null}
</div>
</div>
<CategoryAddModal
modalId="addCategoryModalOffice"
inventoryType="office"
title="Add Category (Office)"
onSaved={load}
/>
</div>
)
}
@@ -0,0 +1,189 @@
import { useCallback, useEffect, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import { fetchInventorySummary } from '../../api/inventory'
import { AcademicFilterBar } from '../discounts/AcademicFilterBar'
import { INVENTORY_BOOK_BASE } from './inventoryPaths'
function num(v: unknown): number {
const n = typeof v === 'number' ? v : Number(v ?? 0)
return Number.isFinite(n) ? Math.floor(n) : 0
}
/** CI `inventory/summary.php` */
export function InventorySummaryPage() {
const [searchParams, setSearchParams] = useSearchParams()
const [rows, setRows] = useState<Record<string, unknown>[]>([])
const [totals, setTotals] = useState<Record<string, number>>({})
const [selectedYear, setSelectedYear] = useState('')
const [currentYear, setCurrentYear] = useState('')
const [schoolYears, setSchoolYears] = useState<string[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const load = useCallback(() => {
setLoading(true)
fetchInventorySummary(searchParams)
.then((data) => {
if (data && typeof data === 'object') {
const o = data as Record<string, unknown>
setRows(Array.isArray(o.rows) ? (o.rows as Record<string, unknown>[]) : [])
const t = o.totals
if (t && typeof t === 'object') {
const rec = t as Record<string, unknown>
setTotals({
opening: num(rec.opening),
received: num(rec.received),
distributed: num(rec.distributed),
other_out: num(rec.other_out),
adjust_net: num(rec.adjust_net),
ending: num(rec.ending),
onhand: num(rec.onhand),
variance: num(rec.variance),
})
} else {
setTotals({})
}
setSelectedYear(String(o.selectedYear ?? o.selected_year ?? ''))
setCurrentYear(String(o.currentYear ?? o.current_year ?? ''))
setSchoolYears(Array.isArray(o.schoolYears) ? (o.schoolYears as string[]) : [])
}
setError(null)
})
.catch((e: unknown) => {
setError(e instanceof ApiHttpError ? e.message : 'Unable to load summary.')
})
.finally(() => setLoading(false))
}, [searchParams])
useEffect(() => {
load()
}, [load])
function onYearFilter(v: string) {
const next = new URLSearchParams(searchParams)
if (v === '__current__') next.delete('school_year')
else next.set('school_year', v)
setSearchParams(next)
}
const yearLabel =
selectedYear.toLowerCase() === 'all' ? 'All Years' : selectedYear || '—'
return (
<div className="container-fluid px-0 mt-3">
<div className="px-3 mb-2">
<AcademicFilterBar schoolYears={schoolYears.length ? schoolYears : undefined} />
</div>
<div className="d-flex justify-content-between align-items-center mb-3 px-3 flex-wrap gap-2">
<h3 className="mb-0">Inventory Summary {yearLabel}</h3>
<Link className="btn btn-outline-secondary" to={INVENTORY_BOOK_BASE}>
Back to Items
</Link>
</div>
{error ? <div className="alert alert-danger mx-3">{error}</div> : null}
<div className="card mb-3 mx-3">
<div className="card-header d-flex gap-2 flex-wrap align-items-center justify-content-between">
<div>Filter</div>
<select
id="yearFilter"
className="form-select form-select-sm"
style={{ minWidth: 220 }}
value={
selectedYear.toLowerCase() === 'all'
? 'all'
: selectedYear === currentYear && !searchParams.get('school_year')
? '__current__'
: selectedYear
}
onChange={(e) => onYearFilter(e.target.value)}
>
<option value="__current__">Current ({currentYear || '—'})</option>
<option value="all">All Years</option>
{schoolYears
.filter((y) => y !== currentYear)
.map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</select>
</div>
<div className="card-body table-responsive">
{loading ? <p className="text-muted mb-0">Loading</p> : null}
{!loading ? (
<table className="table table-striped align-middle" id="summaryTable">
<thead>
<tr>
<th>Type</th>
<th>Category</th>
<th>Name</th>
<th className="text-end">Opening</th>
<th className="text-end">Received</th>
<th className="text-end">Distributed</th>
<th className="text-end">Other Out</th>
<th className="text-end">Adjust ±</th>
<th className="text-end">Ending</th>
<th className="text-end">On Hand (Now)</th>
<th className="text-end">Variance</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={11} className="text-center text-muted py-4">
No data to display for {yearLabel}.
</td>
</tr>
) : (
rows.map((r, i) => {
const variance = num(r.variance)
return (
<tr key={i}>
<td>{String(r.type ?? '')}</td>
<td>{String(r.category ?? '')}</td>
<td>{String(r.name ?? '')}</td>
<td className="text-end">{num(r.opening).toLocaleString()}</td>
<td className="text-end">{num(r.received).toLocaleString()}</td>
<td className="text-end">{num(r.distributed).toLocaleString()}</td>
<td className="text-end">{num(r.other_out).toLocaleString()}</td>
<td className="text-end">{num(r.adjust_net).toLocaleString()}</td>
<td className="text-end fw-semibold">{num(r.ending).toLocaleString()}</td>
<td className="text-end">{num(r.onhand).toLocaleString()}</td>
<td className="text-end">
{variance === 0 ? (
<span className="text-success">{variance.toLocaleString()}</span>
) : (
<span className="text-danger fw-semibold">{variance.toLocaleString()}</span>
)}
</td>
</tr>
)
})
)}
</tbody>
<tfoot>
<tr className="table-light">
<th colSpan={3} className="text-end">
Totals
</th>
<th className="text-end">{(totals.opening ?? 0).toLocaleString()}</th>
<th className="text-end">{(totals.received ?? 0).toLocaleString()}</th>
<th className="text-end">{(totals.distributed ?? 0).toLocaleString()}</th>
<th className="text-end">{(totals.other_out ?? 0).toLocaleString()}</th>
<th className="text-end">{(totals.adjust_net ?? 0).toLocaleString()}</th>
<th className="text-end">{(totals.ending ?? 0).toLocaleString()}</th>
<th className="text-end">{(totals.onhand ?? 0).toLocaleString()}</th>
<th className="text-end">{(totals.variance ?? 0).toLocaleString()}</th>
</tr>
</tfoot>
</table>
) : null}
</div>
</div>
</div>
)
}
@@ -0,0 +1,292 @@
import { type FormEvent, useCallback, useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import { fetchTeacherBookDistribute, postTeacherBookDistribute } from '../../api/inventory'
type BookGroup = { label: string; items: Record<string, unknown>[] }
/** CI `inventory/teacher_distribute.php` (teacher main layout). */
export function TeacherBookDistributePage() {
const [searchParams, setSearchParams] = useSearchParams()
const classSectionId = searchParams.get('class_section_id') ?? ''
const itemIdParam = searchParams.get('item_id') ?? ''
const [booksGrouped, setBooksGrouped] = useState<BookGroup[]>([])
const [students, setStudents] = useState<Record<string, unknown>[]>([])
const [already, setAlready] = useState<Record<string, number>>({})
const [onHand, setOnHand] = useState(0)
const [schoolYear, setSchoolYear] = useState('')
const [semester, setSemester] = useState('')
const [itemId, setItemId] = useState(itemIdParam)
const [note, setNote] = useState('')
const [selected, setSelected] = useState<Record<string, boolean>>({})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const load = useCallback(() => {
setLoading(true)
fetchTeacherBookDistribute(searchParams)
.then((data) => {
if (data && typeof data === 'object') {
const o = data as Record<string, unknown>
const bg = o.booksGrouped
setBooksGrouped(Array.isArray(bg) ? (bg as BookGroup[]) : [])
setStudents(Array.isArray(o.students) ? (o.students as Record<string, unknown>[]) : [])
const alRaw = o.already
const alMap: Record<string, number> =
alRaw && typeof alRaw === 'object' && !Array.isArray(alRaw)
? (alRaw as Record<string, number>)
: {}
setAlready(alMap)
setOnHand(Number(o.onHand ?? o.on_hand ?? 0))
setSchoolYear(String(o.schoolYear ?? o.school_year ?? ''))
setSemester(String(o.semester ?? ''))
const iid = o.item_id != null ? String(o.item_id) : itemIdParam
setItemId(iid)
const sel: Record<string, boolean> = {}
if (Array.isArray(o.students)) {
for (const st of o.students as Record<string, unknown>[]) {
const sid = st.student_id ?? st.id ?? st.user_id
if (sid != null && Number(alMap[String(sid)] ?? 0) > 0) sel[String(sid)] = true
}
}
setSelected(sel)
}
setError(null)
})
.catch((e: unknown) => {
setError(e instanceof ApiHttpError ? e.message : 'Unable to load distribution.')
})
.finally(() => setLoading(false))
}, [searchParams, itemIdParam])
useEffect(() => {
load()
}, [load])
function studentKey(st: Record<string, unknown>): string | null {
const sid = st.student_id ?? st.id ?? st.user_id
return sid != null ? String(sid) : null
}
function toggleStudent(k: string, checked: boolean) {
setSelected((prev) => ({ ...prev, [k]: checked }))
}
function checkAll(on: boolean) {
const next: Record<string, boolean> = {}
if (on) {
for (const st of students) {
const k = studentKey(st)
if (k) next[k] = true
}
}
setSelected(next)
}
function onFilterSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
const fd = new FormData(e.currentTarget)
const next = new URLSearchParams(searchParams)
const cs = String(fd.get('class_section_id') ?? '')
const it = String(fd.get('item_id') ?? '')
if (cs) next.set('class_section_id', cs)
else next.delete('class_section_id')
if (it) next.set('item_id', it)
else next.delete('item_id')
setSearchParams(next)
}
async function onDistribute(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
setSaving(true)
setError(null)
const ids = Object.entries(selected)
.filter(([, v]) => v)
.map(([k]) => Number(k))
.filter((n) => Number.isFinite(n))
try {
await postTeacherBookDistribute({
class_section_id: classSectionId || undefined,
item_id: itemId === '' ? undefined : Number(itemId),
student_ids: ids,
note: note || undefined,
})
load()
} catch (err: unknown) {
setError(err instanceof ApiHttpError ? err.message : 'Save failed.')
} finally {
setSaving(false)
}
}
return (
<div className="container my-4">
<h4 className="text-dark mb-3" style={{ fontFamily: 'Arial, sans-serif' }}>
Distribute Books to Students
</h4>
{error ? <div className="alert alert-danger">{error}</div> : null}
<form id="filterForm" className="row g-3 mb-3" onSubmit={onFilterSubmit}>
<input type="hidden" name="class_section_id" value={classSectionId} />
<div className="col-md-5">
<label className="form-label">Book</label>
<select
className="form-select"
name="item_id"
id="bookSelect"
required
value={itemId}
onChange={(e) => {
const v = e.target.value
setItemId(v)
const next = new URLSearchParams(searchParams)
if (v) next.set('item_id', v)
else next.delete('item_id')
setSearchParams(next)
}}
>
<option value=""> Select a book </option>
{booksGrouped.map((group) => (
<optgroup key={group.label} label={group.label}>
{group.items.map((b) => (
<option key={String(b.id)} value={String(b.id)}>
{String(b.display ?? b.name)} (On Hand: {Number(b.quantity ?? 0)})
</option>
))}
</optgroup>
))}
</select>
</div>
<div className="col-md-2 d-flex align-items-end">
<button type="submit" className="btn btn-secondary w-100">
Load
</button>
</div>
</form>
{loading ? <p className="text-muted">Loading</p> : null}
{!loading && itemId && students.length > 0 ? (
<form onSubmit={(ev) => void onDistribute(ev)}>
<input type="hidden" name="class_section_id" value={classSectionId} />
<input type="hidden" name="item_id" value={itemId} />
<div className="alert alert-info py-2">
<div className="d-flex justify-content-between flex-wrap gap-2">
<div>
<strong>School Year:</strong> {schoolYear}, <strong>Semester:</strong> {semester}
</div>
<div>
<strong>On Hand:</strong> {onHand}
</div>
</div>
</div>
<div className="card">
<div className="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<span>Students</span>
<div className="d-flex gap-2">
<button type="button" className="btn btn-sm btn-info" onClick={() => checkAll(true)}>
Check All
</button>
<button type="button" className="btn btn-sm btn-secondary" onClick={() => checkAll(false)}>
Uncheck All
</button>
</div>
</div>
<div className="card-body table-responsive">
<table className="table table-bordered table-striped align-middle">
<thead className="table-light">
<tr>
<th style={{ width: 48, textAlign: 'center' }}>#</th>
<th style={{ width: 56, textAlign: 'center' }}>Give</th>
<th>School ID</th>
<th>First Name</th>
<th>Last Name</th>
<th style={{ textAlign: 'center' }}>Age</th>
<th style={{ textAlign: 'center' }}>Semester</th>
<th style={{ textAlign: 'center' }}>School Year</th>
<th style={{ textAlign: 'center' }}>History</th>
</tr>
</thead>
<tbody>
{students.map((student, idx) => {
const sid = studentKey(student)
if (!sid) return null
const given = Number(already[sid] ?? 0) > 0
return (
<tr key={sid}>
<td style={{ textAlign: 'center' }}>{idx + 1}</td>
<td style={{ textAlign: 'center' }}>
<input
type="checkbox"
className="form-check-input"
checked={Boolean(selected[sid])}
onChange={(e) => toggleStudent(sid, e.target.checked)}
/>
</td>
<td>{String(student.school_id ?? '-')}</td>
<td>{String(student.firstname ?? '')}</td>
<td>{String(student.lastname ?? '')}</td>
<td style={{ textAlign: 'center' }}>{String(student.age ?? '-')}</td>
<td style={{ textAlign: 'center' }}>
{String(student.semester ?? semester)}
</td>
<td style={{ textAlign: 'center' }}>
{String(student.school_year ?? schoolYear)}
</td>
<td style={{ textAlign: 'center' }}>
{given ? (
<span className="badge bg-success">Given</span>
) : (
<span className="text-muted"></span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
<div className="card-footer">
<div className="mb-2">
<label className="form-label">Note (optional)</label>
<textarea
className="form-control"
rows={2}
name="note"
placeholder="e.g., Distributed in class today"
value={note}
onChange={(e) => setNote(e.target.value)}
/>
</div>
<div className="d-flex gap-2">
<button type="submit" className="btn btn-success" disabled={saving}>
{saving ? 'Saving…' : 'Deduct & Save'}
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => checkAll(false)}
>
Cancel
</button>
</div>
</div>
</div>
</form>
) : null}
{!loading && itemId && students.length === 0 ? (
<div className="alert alert-warning">No students found for this class.</div>
) : null}
</div>
)
}
+16
View File
@@ -0,0 +1,16 @@
export { CategoryAddModal } from './CategoryAddModal'
export { InventoryAdjustPage } from './InventoryAdjustPage'
export { InventoryBookFormPage } from './InventoryBookFormPage'
export { InventoryBookIndexPage } from './InventoryBookIndexPage'
export { InventoryClassroomAuditPage } from './InventoryClassroomAuditPage'
export { InventoryClassroomFormPage } from './InventoryClassroomFormPage'
export { InventoryClassroomIndexPage } from './InventoryClassroomIndexPage'
export { InventoryEditEntryPage } from './InventoryEditEntryPage'
export { InventoryKitchenFormPage } from './InventoryKitchenFormPage'
export { InventoryKitchenIndexPage } from './InventoryKitchenIndexPage'
export { InventoryMovementFormPage } from './InventoryMovementFormPage'
export { InventoryMovementsIndexPage } from './InventoryMovementsIndexPage'
export { InventoryOfficeFormPage } from './InventoryOfficeFormPage'
export { InventoryOfficeIndexPage } from './InventoryOfficeIndexPage'
export { InventorySummaryPage } from './InventorySummaryPage'
export { TeacherBookDistributePage } from './TeacherBookDistributePage'
+87
View File
@@ -0,0 +1,87 @@
/** Shared UI helpers for inventory pages (parity with CI views). */
export function gradeLabel(cat: Record<string, unknown> | null | undefined): string {
if (!cat) return '—'
const gmin = cat.grade_min
const gmax = cat.grade_max
if (gmin == null && gmax == null) return '—'
const nmin = typeof gmin === 'number' ? gmin : gmin !== '' ? Number(gmin) : null
const nmax = typeof gmax === 'number' ? gmax : gmax !== '' ? Number(gmax) : null
if (nmin != null && Number.isFinite(nmin) && nmax != null && Number.isFinite(nmax))
return `G${Math.floor(nmin)}G${Math.floor(nmax)}`
if (nmin != null && Number.isFinite(nmin)) return `G${Math.floor(nmin)}+`
if (nmax != null && Number.isFinite(nmax)) return `≤G${Math.floor(nmax)}`
return '—'
}
export function catLabelBook(c: Record<string, unknown>): string {
let name = String(c.name ?? '')
const gl = gradeLabel(c)
if (gl !== '—') name += ` (${gl})`
return name
}
export function conditionBadgeClass(condition: unknown): string {
const map: Record<string, string> = {
good: 'success',
needs_repair: 'warning',
need_replace: 'danger',
cannot_find: 'secondary',
}
const k = typeof condition === 'string' ? condition : ''
return map[k] ?? 'light'
}
export function formatConditionLabel(condition: unknown): string {
if (condition == null || condition === '') return ''
return String(condition)
.split('_')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ')
}
export function parseInventoryList(data: unknown): {
items: Record<string, unknown>[]
categories: Record<string, unknown>[]
userNames: Record<string, string>
} {
if (!data || typeof data !== 'object')
return { items: [], categories: [], userNames: {} }
const o = data as Record<string, unknown>
const items = Array.isArray(o.items) ? (o.items as Record<string, unknown>[]) : []
const categories = Array.isArray(o.categories) ? (o.categories as Record<string, unknown>[]) : []
const userNames =
o.userNames && typeof o.userNames === 'object'
? (o.userNames as Record<string, string>)
: {}
return { items, categories, userNames }
}
export function userDisplay(
userNames: Record<string, string>,
updatedBy: unknown,
): string {
const id = typeof updatedBy === 'number' ? updatedBy : Number(updatedBy ?? 0)
if (!id) return '—'
return userNames[String(id)] ?? userNames[id] ?? '—'
}
export function parseClassesSelection(item: Record<string, unknown>): number[] {
const raw = item.classes ?? item.class_list ?? item.class_ids
if (Array.isArray(raw)) {
return raw.map((x) => Number(x)).filter((n) => n >= 1 && n <= 13)
}
if (typeof raw === 'string' && raw !== '') {
try {
const decoded = JSON.parse(raw) as unknown
if (Array.isArray(decoded)) {
return decoded.map((x) => Number(x)).filter((n) => n >= 1 && n <= 13)
}
} catch {
/* fall through */
}
const parts = raw.split(/[,\s]+/)
return parts.map((p) => Number(p)).filter((n) => n >= 1 && n <= 13 && Number.isFinite(n))
}
return []
}
+2
View File
@@ -0,0 +1,2 @@
/** Canonical SPA base for books inventory (JWT `GET /api/v1/administrator/inventory/book`). */
export const INVENTORY_BOOK_BASE = '/app/inventory/book'