fix teacher, parent and admin pages
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 (1–13)
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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 []
|
||||
}
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user