Commit 1777189431ebf614b5ed0b878345b520dacc9bb6
1 parent
6ad72c7c
feat(web): create-location + adjust-stock forms, close SPA CRUD gap
Two more operator-facing forms:
- CreateLocationPage: code, name, type (WAREHOUSE/BIN/VIRTUAL)
- AdjustStockPage: item dropdown, location dropdown, absolute
quantity. Creates the balance row if absent; sets it to the
given value if present. Shows the resulting balance inline.
API client: inventory.createLocation, inventory.adjustBalance.
Locations list gets "+ New Location"; Balances list gets "Adjust
Stock". Routes wired at /locations/new and /balances/adjust.
With this commit, every PBC entity that operators need to create
or manage has a SPA form: items, partners, locations, stock
balances, sales orders, purchase orders, work orders (with BOM +
routing), users, and roles. The only create-less entities are
journal entries (read-only, event-driven) and stock movements
(append-only ledger).
Showing
6 changed files
with
168 additions
and
1 deletions
web/src/App.tsx
| ... | ... | @@ -23,7 +23,9 @@ import { UomsPage } from '@/pages/UomsPage' |
| 23 | 23 | import { PartnersPage } from '@/pages/PartnersPage' |
| 24 | 24 | import { CreatePartnerPage } from '@/pages/CreatePartnerPage' |
| 25 | 25 | import { LocationsPage } from '@/pages/LocationsPage' |
| 26 | +import { CreateLocationPage } from '@/pages/CreateLocationPage' | |
| 26 | 27 | import { BalancesPage } from '@/pages/BalancesPage' |
| 28 | +import { AdjustStockPage } from '@/pages/AdjustStockPage' | |
| 27 | 29 | import { MovementsPage } from '@/pages/MovementsPage' |
| 28 | 30 | import { SalesOrdersPage } from '@/pages/SalesOrdersPage' |
| 29 | 31 | import { CreateSalesOrderPage } from '@/pages/CreateSalesOrderPage' |
| ... | ... | @@ -60,7 +62,9 @@ export default function App() { |
| 60 | 62 | <Route path="partners" element={<PartnersPage />} /> |
| 61 | 63 | <Route path="partners/new" element={<CreatePartnerPage />} /> |
| 62 | 64 | <Route path="locations" element={<LocationsPage />} /> |
| 65 | + <Route path="locations/new" element={<CreateLocationPage />} /> | |
| 63 | 66 | <Route path="balances" element={<BalancesPage />} /> |
| 67 | + <Route path="balances/adjust" element={<AdjustStockPage />} /> | |
| 64 | 68 | <Route path="movements" element={<MovementsPage />} /> |
| 65 | 69 | <Route path="sales-orders" element={<SalesOrdersPage />} /> |
| 66 | 70 | <Route path="sales-orders/new" element={<CreateSalesOrderPage />} /> | ... | ... |
web/src/api/client.ts
| ... | ... | @@ -180,7 +180,11 @@ export const partners = { |
| 180 | 180 | |
| 181 | 181 | export const inventory = { |
| 182 | 182 | listLocations: () => apiFetch<Location[]>('/api/v1/inventory/locations'), |
| 183 | + createLocation: (body: { code: string; name: string; type: string }) => | |
| 184 | + apiFetch<Location>('/api/v1/inventory/locations', { method: 'POST', body: JSON.stringify(body) }), | |
| 183 | 185 | listBalances: () => apiFetch<StockBalance[]>('/api/v1/inventory/balances'), |
| 186 | + adjustBalance: (body: { itemCode: string; locationId: string; quantity: number }) => | |
| 187 | + apiFetch<StockBalance>('/api/v1/inventory/balances/adjust', { method: 'POST', body: JSON.stringify(body) }), | |
| 184 | 188 | listMovements: () => apiFetch<StockMovement[]>('/api/v1/inventory/movements'), |
| 185 | 189 | } |
| 186 | 190 | ... | ... |
web/src/pages/AdjustStockPage.tsx
0 → 100644
| 1 | +import { useEffect, useState, type FormEvent } from 'react' | |
| 2 | +import { useNavigate } from 'react-router-dom' | |
| 3 | +import { catalog, inventory } from '@/api/client' | |
| 4 | +import type { Item, Location } from '@/types/api' | |
| 5 | +import { PageHeader } from '@/components/PageHeader' | |
| 6 | +import { ErrorBox } from '@/components/ErrorBox' | |
| 7 | + | |
| 8 | +export function AdjustStockPage() { | |
| 9 | + const navigate = useNavigate() | |
| 10 | + const [items, setItems] = useState<Item[]>([]) | |
| 11 | + const [locations, setLocations] = useState<Location[]>([]) | |
| 12 | + const [itemCode, setItemCode] = useState('') | |
| 13 | + const [locationId, setLocationId] = useState('') | |
| 14 | + const [quantity, setQuantity] = useState('') | |
| 15 | + const [submitting, setSubmitting] = useState(false) | |
| 16 | + const [error, setError] = useState<Error | null>(null) | |
| 17 | + const [result, setResult] = useState<string | null>(null) | |
| 18 | + | |
| 19 | + useEffect(() => { | |
| 20 | + Promise.all([catalog.listItems(), inventory.listLocations()]).then(([i, l]) => { | |
| 21 | + setItems(i) | |
| 22 | + setLocations(l.filter((x) => x.active)) | |
| 23 | + if (i.length > 0) setItemCode(i[0].code) | |
| 24 | + if (l.length > 0) setLocationId(l[0].id) | |
| 25 | + }) | |
| 26 | + }, []) | |
| 27 | + | |
| 28 | + const onSubmit = async (e: FormEvent) => { | |
| 29 | + e.preventDefault() | |
| 30 | + setError(null) | |
| 31 | + setResult(null) | |
| 32 | + setSubmitting(true) | |
| 33 | + try { | |
| 34 | + const bal = await inventory.adjustBalance({ | |
| 35 | + itemCode, | |
| 36 | + locationId, | |
| 37 | + quantity: Number(quantity), | |
| 38 | + }) | |
| 39 | + setResult( | |
| 40 | + `Balance set: ${bal.itemCode} @ location = ${bal.quantity}`, | |
| 41 | + ) | |
| 42 | + } catch (err: unknown) { | |
| 43 | + setError(err instanceof Error ? err : new Error(String(err))) | |
| 44 | + } finally { | |
| 45 | + setSubmitting(false) | |
| 46 | + } | |
| 47 | + } | |
| 48 | + | |
| 49 | + return ( | |
| 50 | + <div> | |
| 51 | + <PageHeader | |
| 52 | + title="Adjust Stock" | |
| 53 | + subtitle="Set the on-hand quantity for an item at a location. Creates the balance row if it doesn't exist." | |
| 54 | + actions={<button className="btn-secondary" onClick={() => navigate('/balances')}>← Balances</button>} | |
| 55 | + /> | |
| 56 | + <form onSubmit={onSubmit} className="card p-6 space-y-4 max-w-lg"> | |
| 57 | + <div> | |
| 58 | + <label className="block text-sm font-medium text-slate-700">Item</label> | |
| 59 | + <select required value={itemCode} onChange={(e) => setItemCode(e.target.value)} | |
| 60 | + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"> | |
| 61 | + {items.map((i) => <option key={i.id} value={i.code}>{i.code} — {i.name}</option>)} | |
| 62 | + </select> | |
| 63 | + </div> | |
| 64 | + <div> | |
| 65 | + <label className="block text-sm font-medium text-slate-700">Location</label> | |
| 66 | + <select required value={locationId} onChange={(e) => setLocationId(e.target.value)} | |
| 67 | + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"> | |
| 68 | + {locations.map((l) => <option key={l.id} value={l.id}>{l.code} — {l.name}</option>)} | |
| 69 | + </select> | |
| 70 | + </div> | |
| 71 | + <div> | |
| 72 | + <label className="block text-sm font-medium text-slate-700">Quantity (absolute, not delta)</label> | |
| 73 | + <input type="number" required min="0" step="1" value={quantity} | |
| 74 | + onChange={(e) => setQuantity(e.target.value)} | |
| 75 | + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm text-right" /> | |
| 76 | + </div> | |
| 77 | + {error && <ErrorBox error={error} />} | |
| 78 | + {result && ( | |
| 79 | + <div className="rounded-md border border-emerald-200 bg-emerald-50 px-4 py-2 text-sm text-emerald-800"> | |
| 80 | + {result} | |
| 81 | + </div> | |
| 82 | + )} | |
| 83 | + <button type="submit" className="btn-primary" disabled={submitting}> | |
| 84 | + {submitting ? 'Adjusting...' : 'Set Balance'} | |
| 85 | + </button> | |
| 86 | + </form> | |
| 87 | + </div> | |
| 88 | + ) | |
| 89 | +} | ... | ... |
web/src/pages/BalancesPage.tsx
| 1 | 1 | import { useEffect, useState } from 'react' |
| 2 | +import { Link } from 'react-router-dom' | |
| 2 | 3 | import { inventory } from '@/api/client' |
| 3 | 4 | import type { Location, StockBalance } from '@/types/api' |
| 4 | 5 | import { PageHeader } from '@/components/PageHeader' |
| ... | ... | @@ -54,6 +55,7 @@ export function BalancesPage() { |
| 54 | 55 | <PageHeader |
| 55 | 56 | title="Stock Balances" |
| 56 | 57 | subtitle="On-hand quantities per (item, location). Updates atomically with every movement." |
| 58 | + actions={<Link to="/balances/adjust" className="btn-primary">Adjust Stock</Link>} | |
| 57 | 59 | /> |
| 58 | 60 | {loading && <Loading />} |
| 59 | 61 | {error && <ErrorBox error={error} />} | ... | ... |
web/src/pages/CreateLocationPage.tsx
0 → 100644
| 1 | +import { useState, type FormEvent } from 'react' | |
| 2 | +import { useNavigate } from 'react-router-dom' | |
| 3 | +import { inventory } from '@/api/client' | |
| 4 | +import { PageHeader } from '@/components/PageHeader' | |
| 5 | +import { ErrorBox } from '@/components/ErrorBox' | |
| 6 | + | |
| 7 | +const LOCATION_TYPES = ['WAREHOUSE', 'BIN', 'VIRTUAL'] as const | |
| 8 | + | |
| 9 | +export function CreateLocationPage() { | |
| 10 | + const navigate = useNavigate() | |
| 11 | + const [code, setCode] = useState('') | |
| 12 | + const [name, setName] = useState('') | |
| 13 | + const [type, setType] = useState<string>('WAREHOUSE') | |
| 14 | + const [submitting, setSubmitting] = useState(false) | |
| 15 | + const [error, setError] = useState<Error | null>(null) | |
| 16 | + | |
| 17 | + const onSubmit = async (e: FormEvent) => { | |
| 18 | + e.preventDefault() | |
| 19 | + setError(null) | |
| 20 | + setSubmitting(true) | |
| 21 | + try { | |
| 22 | + await inventory.createLocation({ code, name, type }) | |
| 23 | + navigate('/locations') | |
| 24 | + } catch (err: unknown) { | |
| 25 | + setError(err instanceof Error ? err : new Error(String(err))) | |
| 26 | + } finally { | |
| 27 | + setSubmitting(false) | |
| 28 | + } | |
| 29 | + } | |
| 30 | + | |
| 31 | + return ( | |
| 32 | + <div> | |
| 33 | + <PageHeader | |
| 34 | + title="New Location" | |
| 35 | + subtitle="Add a warehouse, bin, or virtual location for inventory tracking." | |
| 36 | + actions={<button className="btn-secondary" onClick={() => navigate('/locations')}>Cancel</button>} | |
| 37 | + /> | |
| 38 | + <form onSubmit={onSubmit} className="card p-6 space-y-4 max-w-lg"> | |
| 39 | + <div> | |
| 40 | + <label className="block text-sm font-medium text-slate-700">Location code</label> | |
| 41 | + <input type="text" required value={code} onChange={(e) => setCode(e.target.value)} | |
| 42 | + placeholder="WH-NEW" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> | |
| 43 | + </div> | |
| 44 | + <div> | |
| 45 | + <label className="block text-sm font-medium text-slate-700">Name</label> | |
| 46 | + <input type="text" required value={name} onChange={(e) => setName(e.target.value)} | |
| 47 | + placeholder="New Warehouse" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> | |
| 48 | + </div> | |
| 49 | + <div> | |
| 50 | + <label className="block text-sm font-medium text-slate-700">Type</label> | |
| 51 | + <select value={type} onChange={(e) => setType(e.target.value)} | |
| 52 | + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"> | |
| 53 | + {LOCATION_TYPES.map((t) => <option key={t} value={t}>{t}</option>)} | |
| 54 | + </select> | |
| 55 | + </div> | |
| 56 | + {error && <ErrorBox error={error} />} | |
| 57 | + <button type="submit" className="btn-primary" disabled={submitting}> | |
| 58 | + {submitting ? 'Creating...' : 'Create Location'} | |
| 59 | + </button> | |
| 60 | + </form> | |
| 61 | + </div> | |
| 62 | + ) | |
| 63 | +} | ... | ... |
web/src/pages/LocationsPage.tsx
| 1 | 1 | import { useEffect, useState } from 'react' |
| 2 | +import { Link } from 'react-router-dom' | |
| 2 | 3 | import { inventory } from '@/api/client' |
| 3 | 4 | import type { Location } from '@/types/api' |
| 4 | 5 | import { PageHeader } from '@/components/PageHeader' |
| ... | ... | @@ -32,7 +33,11 @@ export function LocationsPage() { |
| 32 | 33 | |
| 33 | 34 | return ( |
| 34 | 35 | <div> |
| 35 | - <PageHeader title="Locations" subtitle="Warehouses, bins, and virtual locations the inventory PBC tracks." /> | |
| 36 | + <PageHeader | |
| 37 | + title="Locations" | |
| 38 | + subtitle="Warehouses, bins, and virtual locations the inventory PBC tracks." | |
| 39 | + actions={<Link to="/locations/new" className="btn-primary">+ New Location</Link>} | |
| 40 | + /> | |
| 36 | 41 | {loading && <Loading />} |
| 37 | 42 | {error && <ErrorBox error={error} />} |
| 38 | 43 | {!loading && !error && <DataTable rows={rows} columns={columns} />} | ... | ... |
-
mentioned in commit 34c8c92f