Commit d8856b6a14432e1042c182507ad5f49b37f5ccd7
1 parent
e1d0c833
feat(web): edit forms for items + partners (PATCH)
Adds edit pages for items and partners — the two entities operators update most often. Each form loads the existing record, pre-fills all editable fields, and PATCHes on save. Code and baseUomCode are read-only after creation (by design). New pages: - EditItemPage: name, type, description, active toggle - EditPartnerPage: name, type, email, phone API client: catalog.updateItem, partners.update (PATCH). List pages: item/partner codes are now clickable links to the edit page instead of plain text. Routes wired at /items/:id/edit and /partners/:id/edit.
Showing
6 changed files
with
216 additions
and
2 deletions
web/src/App.tsx
| ... | ... | @@ -19,9 +19,11 @@ import { UserDetailPage } from '@/pages/UserDetailPage' |
| 19 | 19 | import { RolesPage } from '@/pages/RolesPage' |
| 20 | 20 | import { ItemsPage } from '@/pages/ItemsPage' |
| 21 | 21 | import { CreateItemPage } from '@/pages/CreateItemPage' |
| 22 | +import { EditItemPage } from '@/pages/EditItemPage' | |
| 22 | 23 | import { UomsPage } from '@/pages/UomsPage' |
| 23 | 24 | import { PartnersPage } from '@/pages/PartnersPage' |
| 24 | 25 | import { CreatePartnerPage } from '@/pages/CreatePartnerPage' |
| 26 | +import { EditPartnerPage } from '@/pages/EditPartnerPage' | |
| 25 | 27 | import { LocationsPage } from '@/pages/LocationsPage' |
| 26 | 28 | import { CreateLocationPage } from '@/pages/CreateLocationPage' |
| 27 | 29 | import { BalancesPage } from '@/pages/BalancesPage' |
| ... | ... | @@ -59,9 +61,11 @@ export default function App() { |
| 59 | 61 | <Route path="roles" element={<RolesPage />} /> |
| 60 | 62 | <Route path="items" element={<ItemsPage />} /> |
| 61 | 63 | <Route path="items/new" element={<CreateItemPage />} /> |
| 64 | + <Route path="items/:id/edit" element={<EditItemPage />} /> | |
| 62 | 65 | <Route path="uoms" element={<UomsPage />} /> |
| 63 | 66 | <Route path="partners" element={<PartnersPage />} /> |
| 64 | 67 | <Route path="partners/new" element={<CreatePartnerPage />} /> |
| 68 | + <Route path="partners/:id/edit" element={<EditPartnerPage />} /> | |
| 65 | 69 | <Route path="locations" element={<LocationsPage />} /> |
| 66 | 70 | <Route path="locations/new" element={<CreateLocationPage />} /> |
| 67 | 71 | <Route path="balances" element={<BalancesPage />} /> | ... | ... |
web/src/api/client.ts
| ... | ... | @@ -162,6 +162,9 @@ export const catalog = { |
| 162 | 162 | code: string; name: string; description?: string | null; |
| 163 | 163 | itemType: string; baseUomCode: string; active?: boolean |
| 164 | 164 | }) => apiFetch<Item>('/api/v1/catalog/items', { method: 'POST', body: JSON.stringify(body) }), |
| 165 | + updateItem: (id: string, body: { | |
| 166 | + name?: string; description?: string | null; itemType?: string; active?: boolean | |
| 167 | + }) => apiFetch<Item>(`/api/v1/catalog/items/${id}`, { method: 'PATCH', body: JSON.stringify(body) }), | |
| 165 | 168 | listUoms: () => apiFetch<Uom[]>('/api/v1/catalog/uoms'), |
| 166 | 169 | } |
| 167 | 170 | |
| ... | ... | @@ -175,6 +178,10 @@ export const partners = { |
| 175 | 178 | email?: string | null; phone?: string | null; |
| 176 | 179 | taxId?: string | null; website?: string | null |
| 177 | 180 | }) => apiFetch<Partner>('/api/v1/partners/partners', { method: 'POST', body: JSON.stringify(body) }), |
| 181 | + update: (id: string, body: { | |
| 182 | + name?: string; type?: string; | |
| 183 | + email?: string | null; phone?: string | null | |
| 184 | + }) => apiFetch<Partner>(`/api/v1/partners/partners/${id}`, { method: 'PATCH', body: JSON.stringify(body) }), | |
| 178 | 185 | } |
| 179 | 186 | |
| 180 | 187 | // ─── Inventory ─────────────────────────────────────────────────────── | ... | ... |
web/src/pages/EditItemPage.tsx
0 → 100644
| 1 | +import { useEffect, useState, type FormEvent } from 'react' | |
| 2 | +import { useNavigate, useParams } from 'react-router-dom' | |
| 3 | +import { catalog } from '@/api/client' | |
| 4 | +import type { Item } from '@/types/api' | |
| 5 | +import { PageHeader } from '@/components/PageHeader' | |
| 6 | +import { Loading } from '@/components/Loading' | |
| 7 | +import { ErrorBox } from '@/components/ErrorBox' | |
| 8 | + | |
| 9 | +const ITEM_TYPES = ['GOOD', 'SERVICE', 'DIGITAL'] as const | |
| 10 | + | |
| 11 | +export function EditItemPage() { | |
| 12 | + const { id = '' } = useParams<{ id: string }>() | |
| 13 | + const navigate = useNavigate() | |
| 14 | + const [item, setItem] = useState<Item | null>(null) | |
| 15 | + const [name, setName] = useState('') | |
| 16 | + const [description, setDescription] = useState('') | |
| 17 | + const [itemType, setItemType] = useState<string>('GOOD') | |
| 18 | + const [active, setActive] = useState(true) | |
| 19 | + const [loading, setLoading] = useState(true) | |
| 20 | + const [submitting, setSubmitting] = useState(false) | |
| 21 | + const [error, setError] = useState<Error | null>(null) | |
| 22 | + | |
| 23 | + useEffect(() => { | |
| 24 | + catalog.getItem(id) | |
| 25 | + .then((i) => { | |
| 26 | + setItem(i) | |
| 27 | + setName(i.name) | |
| 28 | + setDescription(i.description ?? '') | |
| 29 | + setItemType(i.itemType) | |
| 30 | + setActive(i.active) | |
| 31 | + }) | |
| 32 | + .catch((e: unknown) => setError(e instanceof Error ? e : new Error(String(e)))) | |
| 33 | + .finally(() => setLoading(false)) | |
| 34 | + }, [id]) | |
| 35 | + | |
| 36 | + const onSubmit = async (e: FormEvent) => { | |
| 37 | + e.preventDefault() | |
| 38 | + setError(null) | |
| 39 | + setSubmitting(true) | |
| 40 | + try { | |
| 41 | + await catalog.updateItem(id, { | |
| 42 | + name, itemType, active, | |
| 43 | + description: description || null, | |
| 44 | + }) | |
| 45 | + navigate('/items') | |
| 46 | + } catch (err: unknown) { | |
| 47 | + setError(err instanceof Error ? err : new Error(String(err))) | |
| 48 | + } finally { | |
| 49 | + setSubmitting(false) | |
| 50 | + } | |
| 51 | + } | |
| 52 | + | |
| 53 | + if (loading) return <Loading /> | |
| 54 | + if (!item) return <ErrorBox error={error ?? 'Item not found'} /> | |
| 55 | + | |
| 56 | + return ( | |
| 57 | + <div> | |
| 58 | + <PageHeader | |
| 59 | + title={`Edit ${item.code}`} | |
| 60 | + subtitle={`Base UoM: ${item.baseUomCode} (read-only after creation)`} | |
| 61 | + actions={<button className="btn-secondary" onClick={() => navigate('/items')}>Cancel</button>} | |
| 62 | + /> | |
| 63 | + <form onSubmit={onSubmit} className="card p-6 space-y-4 max-w-2xl"> | |
| 64 | + <div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> | |
| 65 | + <div> | |
| 66 | + <label className="block text-sm font-medium text-slate-700">Name</label> | |
| 67 | + <input type="text" required value={name} onChange={(e) => setName(e.target.value)} | |
| 68 | + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> | |
| 69 | + </div> | |
| 70 | + <div> | |
| 71 | + <label className="block text-sm font-medium text-slate-700">Type</label> | |
| 72 | + <select value={itemType} onChange={(e) => setItemType(e.target.value)} | |
| 73 | + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"> | |
| 74 | + {ITEM_TYPES.map((t) => <option key={t} value={t}>{t}</option>)} | |
| 75 | + </select> | |
| 76 | + </div> | |
| 77 | + </div> | |
| 78 | + <div> | |
| 79 | + <label className="block text-sm font-medium text-slate-700">Description</label> | |
| 80 | + <input type="text" value={description} onChange={(e) => setDescription(e.target.value)} | |
| 81 | + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> | |
| 82 | + </div> | |
| 83 | + <div className="flex items-center gap-2"> | |
| 84 | + <input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} | |
| 85 | + className="rounded border-slate-300" id="active" /> | |
| 86 | + <label htmlFor="active" className="text-sm text-slate-700">Active</label> | |
| 87 | + </div> | |
| 88 | + {error && <ErrorBox error={error} />} | |
| 89 | + <button type="submit" className="btn-primary" disabled={submitting}> | |
| 90 | + {submitting ? 'Saving…' : 'Save Changes'} | |
| 91 | + </button> | |
| 92 | + </form> | |
| 93 | + </div> | |
| 94 | + ) | |
| 95 | +} | ... | ... |
web/src/pages/EditPartnerPage.tsx
0 → 100644
| 1 | +import { useEffect, useState, type FormEvent } from 'react' | |
| 2 | +import { useNavigate, useParams } from 'react-router-dom' | |
| 3 | +import { partners } from '@/api/client' | |
| 4 | +import type { Partner } from '@/types/api' | |
| 5 | +import { PageHeader } from '@/components/PageHeader' | |
| 6 | +import { Loading } from '@/components/Loading' | |
| 7 | +import { ErrorBox } from '@/components/ErrorBox' | |
| 8 | + | |
| 9 | +const PARTNER_TYPES = ['CUSTOMER', 'SUPPLIER', 'BOTH'] as const | |
| 10 | + | |
| 11 | +export function EditPartnerPage() { | |
| 12 | + const { id = '' } = useParams<{ id: string }>() | |
| 13 | + const navigate = useNavigate() | |
| 14 | + const [partner, setPartner] = useState<Partner | null>(null) | |
| 15 | + const [name, setName] = useState('') | |
| 16 | + const [type, setType] = useState<string>('CUSTOMER') | |
| 17 | + const [email, setEmail] = useState('') | |
| 18 | + const [phone, setPhone] = useState('') | |
| 19 | + const [loading, setLoading] = useState(true) | |
| 20 | + const [submitting, setSubmitting] = useState(false) | |
| 21 | + const [error, setError] = useState<Error | null>(null) | |
| 22 | + | |
| 23 | + useEffect(() => { | |
| 24 | + partners.get(id) | |
| 25 | + .then((p) => { | |
| 26 | + setPartner(p) | |
| 27 | + setName(p.name) | |
| 28 | + setType(p.type) | |
| 29 | + setEmail(p.email ?? '') | |
| 30 | + setPhone(p.phone ?? '') | |
| 31 | + }) | |
| 32 | + .catch((e: unknown) => setError(e instanceof Error ? e : new Error(String(e)))) | |
| 33 | + .finally(() => setLoading(false)) | |
| 34 | + }, [id]) | |
| 35 | + | |
| 36 | + const onSubmit = async (e: FormEvent) => { | |
| 37 | + e.preventDefault() | |
| 38 | + setError(null) | |
| 39 | + setSubmitting(true) | |
| 40 | + try { | |
| 41 | + await partners.update(id, { | |
| 42 | + name, type, | |
| 43 | + email: email || null, | |
| 44 | + phone: phone || null, | |
| 45 | + }) | |
| 46 | + navigate('/partners') | |
| 47 | + } catch (err: unknown) { | |
| 48 | + setError(err instanceof Error ? err : new Error(String(err))) | |
| 49 | + } finally { | |
| 50 | + setSubmitting(false) | |
| 51 | + } | |
| 52 | + } | |
| 53 | + | |
| 54 | + if (loading) return <Loading /> | |
| 55 | + if (!partner) return <ErrorBox error={error ?? 'Partner not found'} /> | |
| 56 | + | |
| 57 | + return ( | |
| 58 | + <div> | |
| 59 | + <PageHeader | |
| 60 | + title={`Edit ${partner.code}`} | |
| 61 | + subtitle={`Partner code is read-only after creation`} | |
| 62 | + actions={<button className="btn-secondary" onClick={() => navigate('/partners')}>Cancel</button>} | |
| 63 | + /> | |
| 64 | + <form onSubmit={onSubmit} className="card p-6 space-y-4 max-w-2xl"> | |
| 65 | + <div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> | |
| 66 | + <div> | |
| 67 | + <label className="block text-sm font-medium text-slate-700">Name</label> | |
| 68 | + <input type="text" required value={name} onChange={(e) => setName(e.target.value)} | |
| 69 | + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> | |
| 70 | + </div> | |
| 71 | + <div> | |
| 72 | + <label className="block text-sm font-medium text-slate-700">Type</label> | |
| 73 | + <select value={type} onChange={(e) => setType(e.target.value)} | |
| 74 | + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"> | |
| 75 | + {PARTNER_TYPES.map((t) => <option key={t} value={t}>{t}</option>)} | |
| 76 | + </select> | |
| 77 | + </div> | |
| 78 | + <div> | |
| 79 | + <label className="block text-sm font-medium text-slate-700">Email</label> | |
| 80 | + <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} | |
| 81 | + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> | |
| 82 | + </div> | |
| 83 | + <div> | |
| 84 | + <label className="block text-sm font-medium text-slate-700">Phone</label> | |
| 85 | + <input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} | |
| 86 | + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> | |
| 87 | + </div> | |
| 88 | + </div> | |
| 89 | + {error && <ErrorBox error={error} />} | |
| 90 | + <button type="submit" className="btn-primary" disabled={submitting}> | |
| 91 | + {submitting ? 'Saving…' : 'Save Changes'} | |
| 92 | + </button> | |
| 93 | + </form> | |
| 94 | + </div> | |
| 95 | + ) | |
| 96 | +} | ... | ... |
web/src/pages/ItemsPage.tsx
| ... | ... | @@ -21,7 +21,13 @@ export function ItemsPage() { |
| 21 | 21 | }, []) |
| 22 | 22 | |
| 23 | 23 | const columns: Column<Item>[] = [ |
| 24 | - { header: 'Code', key: 'code', render: (r) => <span className="font-mono">{r.code}</span> }, | |
| 24 | + { | |
| 25 | + header: 'Code', | |
| 26 | + key: 'code', | |
| 27 | + render: (r) => ( | |
| 28 | + <Link to={`/items/${r.id}/edit`} className="font-mono text-brand-600 hover:underline">{r.code}</Link> | |
| 29 | + ), | |
| 30 | + }, | |
| 25 | 31 | { header: 'Name', key: 'name' }, |
| 26 | 32 | { header: 'Type', key: 'itemType' }, |
| 27 | 33 | { header: 'UoM', key: 'baseUomCode', render: (r) => <span className="font-mono">{r.baseUomCode}</span> }, | ... | ... |
web/src/pages/PartnersPage.tsx
| ... | ... | @@ -21,7 +21,13 @@ export function PartnersPage() { |
| 21 | 21 | }, []) |
| 22 | 22 | |
| 23 | 23 | const columns: Column<Partner>[] = [ |
| 24 | - { header: 'Code', key: 'code', render: (r) => <span className="font-mono">{r.code}</span> }, | |
| 24 | + { | |
| 25 | + header: 'Code', | |
| 26 | + key: 'code', | |
| 27 | + render: (r) => ( | |
| 28 | + <Link to={`/partners/${r.id}/edit`} className="font-mono text-brand-600 hover:underline">{r.code}</Link> | |
| 29 | + ), | |
| 30 | + }, | |
| 25 | 31 | { header: 'Name', key: 'name' }, |
| 26 | 32 | { header: 'Type', key: 'type' }, |
| 27 | 33 | { header: 'Email', key: 'email', render: (r) => r.email ?? '—' }, | ... | ... |