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,7 +23,9 @@ import { UomsPage } from '@/pages/UomsPage' | ||
| 23 | import { PartnersPage } from '@/pages/PartnersPage' | 23 | import { PartnersPage } from '@/pages/PartnersPage' |
| 24 | import { CreatePartnerPage } from '@/pages/CreatePartnerPage' | 24 | import { CreatePartnerPage } from '@/pages/CreatePartnerPage' |
| 25 | import { LocationsPage } from '@/pages/LocationsPage' | 25 | import { LocationsPage } from '@/pages/LocationsPage' |
| 26 | +import { CreateLocationPage } from '@/pages/CreateLocationPage' | ||
| 26 | import { BalancesPage } from '@/pages/BalancesPage' | 27 | import { BalancesPage } from '@/pages/BalancesPage' |
| 28 | +import { AdjustStockPage } from '@/pages/AdjustStockPage' | ||
| 27 | import { MovementsPage } from '@/pages/MovementsPage' | 29 | import { MovementsPage } from '@/pages/MovementsPage' |
| 28 | import { SalesOrdersPage } from '@/pages/SalesOrdersPage' | 30 | import { SalesOrdersPage } from '@/pages/SalesOrdersPage' |
| 29 | import { CreateSalesOrderPage } from '@/pages/CreateSalesOrderPage' | 31 | import { CreateSalesOrderPage } from '@/pages/CreateSalesOrderPage' |
| @@ -60,7 +62,9 @@ export default function App() { | @@ -60,7 +62,9 @@ export default function App() { | ||
| 60 | <Route path="partners" element={<PartnersPage />} /> | 62 | <Route path="partners" element={<PartnersPage />} /> |
| 61 | <Route path="partners/new" element={<CreatePartnerPage />} /> | 63 | <Route path="partners/new" element={<CreatePartnerPage />} /> |
| 62 | <Route path="locations" element={<LocationsPage />} /> | 64 | <Route path="locations" element={<LocationsPage />} /> |
| 65 | + <Route path="locations/new" element={<CreateLocationPage />} /> | ||
| 63 | <Route path="balances" element={<BalancesPage />} /> | 66 | <Route path="balances" element={<BalancesPage />} /> |
| 67 | + <Route path="balances/adjust" element={<AdjustStockPage />} /> | ||
| 64 | <Route path="movements" element={<MovementsPage />} /> | 68 | <Route path="movements" element={<MovementsPage />} /> |
| 65 | <Route path="sales-orders" element={<SalesOrdersPage />} /> | 69 | <Route path="sales-orders" element={<SalesOrdersPage />} /> |
| 66 | <Route path="sales-orders/new" element={<CreateSalesOrderPage />} /> | 70 | <Route path="sales-orders/new" element={<CreateSalesOrderPage />} /> |
web/src/api/client.ts
| @@ -180,7 +180,11 @@ export const partners = { | @@ -180,7 +180,11 @@ export const partners = { | ||
| 180 | 180 | ||
| 181 | export const inventory = { | 181 | export const inventory = { |
| 182 | listLocations: () => apiFetch<Location[]>('/api/v1/inventory/locations'), | 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 | listBalances: () => apiFetch<StockBalance[]>('/api/v1/inventory/balances'), | 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 | listMovements: () => apiFetch<StockMovement[]>('/api/v1/inventory/movements'), | 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 | import { useEffect, useState } from 'react' | 1 | import { useEffect, useState } from 'react' |
| 2 | +import { Link } from 'react-router-dom' | ||
| 2 | import { inventory } from '@/api/client' | 3 | import { inventory } from '@/api/client' |
| 3 | import type { Location, StockBalance } from '@/types/api' | 4 | import type { Location, StockBalance } from '@/types/api' |
| 4 | import { PageHeader } from '@/components/PageHeader' | 5 | import { PageHeader } from '@/components/PageHeader' |
| @@ -54,6 +55,7 @@ export function BalancesPage() { | @@ -54,6 +55,7 @@ export function BalancesPage() { | ||
| 54 | <PageHeader | 55 | <PageHeader |
| 55 | title="Stock Balances" | 56 | title="Stock Balances" |
| 56 | subtitle="On-hand quantities per (item, location). Updates atomically with every movement." | 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 | {loading && <Loading />} | 60 | {loading && <Loading />} |
| 59 | {error && <ErrorBox error={error} />} | 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 | import { useEffect, useState } from 'react' | 1 | import { useEffect, useState } from 'react' |
| 2 | +import { Link } from 'react-router-dom' | ||
| 2 | import { inventory } from '@/api/client' | 3 | import { inventory } from '@/api/client' |
| 3 | import type { Location } from '@/types/api' | 4 | import type { Location } from '@/types/api' |
| 4 | import { PageHeader } from '@/components/PageHeader' | 5 | import { PageHeader } from '@/components/PageHeader' |
| @@ -32,7 +33,11 @@ export function LocationsPage() { | @@ -32,7 +33,11 @@ export function LocationsPage() { | ||
| 32 | 33 | ||
| 33 | return ( | 34 | return ( |
| 34 | <div> | 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 | {loading && <Loading />} | 41 | {loading && <Loading />} |
| 37 | {error && <ErrorBox error={error} />} | 42 | {error && <ErrorBox error={error} />} |
| 38 | {!loading && !error && <DataTable rows={rows} columns={columns} />} | 43 | {!loading && !error && <DataTable rows={rows} columns={columns} />} |
-
mentioned in commit 34c8c92f