Commit 1777189431ebf614b5ed0b878345b520dacc9bb6

Authored by zichun
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).
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} />}
... ...