247 lines
9.0 KiB
TypeScript
247 lines
9.0 KiB
TypeScript
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>
|
||
)
|
||
}
|