Commit d8856b6a14432e1042c182507ad5f49b37f5ccd7

Authored by zichun
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.
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 ?? '—' },
... ...