Commit 7900923468f0ecfbdc879c210cd8d370a875394c
1 parent
95ed53bd
feat(web): complete i18n — externalize all hardcoded strings
Replace every hardcoded English string across all 35 SPA page files with useT() calls backed by message keys in messages.ts. Both en-US and zh-CN translations are provided for every key (~200 new keys). Pages updated: DashboardPage, LoginPage, FormDesignerPage, ListViewDesignerPage, MetadataAdminPage, SalesOrderDetailPage, PurchaseOrderDetailPage, WorkOrderDetailPage, ShopFloorPage, UserTasksPage, TaskDetailPage, UserDetailPage, all Create* pages, all Edit* pages, and all list pages (Items, Partners, Locations, Balances, Movements, SalesOrders, PurchaseOrders, WorkOrders, Users, Roles, Accounts, JournalEntries, UoMs, AdjustStock).
Showing
35 changed files
with
558 additions
and
494 deletions
web/src/pages/AccountsPage.tsx
| @@ -5,10 +5,12 @@ import { PageHeader } from '@/components/PageHeader' | @@ -5,10 +5,12 @@ import { PageHeader } from '@/components/PageHeader' | ||
| 5 | import { Loading } from '@/components/Loading' | 5 | import { Loading } from '@/components/Loading' |
| 6 | import { ErrorBox } from '@/components/ErrorBox' | 6 | import { ErrorBox } from '@/components/ErrorBox' |
| 7 | import { DataTable, type Column } from '@/components/DataTable' | 7 | import { DataTable, type Column } from '@/components/DataTable' |
| 8 | +import { useT } from '@/i18n/LocaleContext' | ||
| 8 | 9 | ||
| 9 | const ACCOUNT_TYPES = ['ASSET', 'LIABILITY', 'EQUITY', 'REVENUE', 'EXPENSE'] as const | 10 | const ACCOUNT_TYPES = ['ASSET', 'LIABILITY', 'EQUITY', 'REVENUE', 'EXPENSE'] as const |
| 10 | 11 | ||
| 11 | export function AccountsPage() { | 12 | export function AccountsPage() { |
| 13 | + const t = useT() | ||
| 12 | const [rows, setRows] = useState<Account[]>([]) | 14 | const [rows, setRows] = useState<Account[]>([]) |
| 13 | const [error, setError] = useState<Error | null>(null) | 15 | const [error, setError] = useState<Error | null>(null) |
| 14 | const [loading, setLoading] = useState(true) | 16 | const [loading, setLoading] = useState(true) |
| @@ -46,44 +48,44 @@ export function AccountsPage() { | @@ -46,44 +48,44 @@ export function AccountsPage() { | ||
| 46 | } | 48 | } |
| 47 | 49 | ||
| 48 | const columns: Column<Account>[] = [ | 50 | const columns: Column<Account>[] = [ |
| 49 | - { header: 'Code', key: 'code', render: (r) => <span className="font-mono">{r.code}</span> }, | ||
| 50 | - { header: 'Name', key: 'name' }, | ||
| 51 | - { header: 'Type', key: 'accountType' }, | ||
| 52 | - { header: 'Description', key: 'description', render: (r) => r.description ?? '—' }, | 51 | + { header: t('label.code'), key: 'code', render: (r) => <span className="font-mono">{r.code}</span> }, |
| 52 | + { header: t('label.name'), key: 'name' }, | ||
| 53 | + { header: t('label.type'), key: 'accountType' }, | ||
| 54 | + { header: t('label.description'), key: 'description', render: (r) => r.description ?? '\u2014' }, | ||
| 53 | ] | 55 | ] |
| 54 | 56 | ||
| 55 | return ( | 57 | return ( |
| 56 | <div> | 58 | <div> |
| 57 | <PageHeader | 59 | <PageHeader |
| 58 | - title="Chart of Accounts" | ||
| 59 | - subtitle="GL accounts that journal entries debit and credit. 6 accounts seeded by default." | 60 | + title={t('page.accounts.title')} |
| 61 | + subtitle={t('page.accounts.subtitle')} | ||
| 60 | actions={ | 62 | actions={ |
| 61 | <button className="btn-primary" onClick={() => setShowCreate(!showCreate)}> | 63 | <button className="btn-primary" onClick={() => setShowCreate(!showCreate)}> |
| 62 | - {showCreate ? 'Cancel' : '+ New Account'} | 64 | + {showCreate ? t('action.cancel') : t('action.newAccount')} |
| 63 | </button> | 65 | </button> |
| 64 | } | 66 | } |
| 65 | /> | 67 | /> |
| 66 | {showCreate && ( | 68 | {showCreate && ( |
| 67 | <form onSubmit={onCreate} className="card p-4 mb-4 max-w-2xl flex flex-wrap items-end gap-3"> | 69 | <form onSubmit={onCreate} className="card p-4 mb-4 max-w-2xl flex flex-wrap items-end gap-3"> |
| 68 | <div className="w-24"> | 70 | <div className="w-24"> |
| 69 | - <label className="block text-xs font-medium text-slate-700">Code</label> | 71 | + <label className="block text-xs font-medium text-slate-700">{t('label.code')}</label> |
| 70 | <input type="text" required value={code} onChange={(e) => setCode(e.target.value)} | 72 | <input type="text" required value={code} onChange={(e) => setCode(e.target.value)} |
| 71 | placeholder="1300" className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm" /> | 73 | placeholder="1300" className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm" /> |
| 72 | </div> | 74 | </div> |
| 73 | <div className="flex-1 min-w-[150px]"> | 75 | <div className="flex-1 min-w-[150px]"> |
| 74 | - <label className="block text-xs font-medium text-slate-700">Name</label> | 76 | + <label className="block text-xs font-medium text-slate-700">{t('label.name')}</label> |
| 75 | <input type="text" required value={name} onChange={(e) => setName(e.target.value)} | 77 | <input type="text" required value={name} onChange={(e) => setName(e.target.value)} |
| 76 | placeholder="Prepaid expenses" className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm" /> | 78 | placeholder="Prepaid expenses" className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm" /> |
| 77 | </div> | 79 | </div> |
| 78 | <div className="w-32"> | 80 | <div className="w-32"> |
| 79 | - <label className="block text-xs font-medium text-slate-700">Type</label> | 81 | + <label className="block text-xs font-medium text-slate-700">{t('label.type')}</label> |
| 80 | <select value={accountType} onChange={(e) => setAccountType(e.target.value)} | 82 | <select value={accountType} onChange={(e) => setAccountType(e.target.value)} |
| 81 | className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm"> | 83 | className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm"> |
| 82 | - {ACCOUNT_TYPES.map((t) => <option key={t} value={t}>{t}</option>)} | 84 | + {ACCOUNT_TYPES.map((at) => <option key={at} value={at}>{at}</option>)} |
| 83 | </select> | 85 | </select> |
| 84 | </div> | 86 | </div> |
| 85 | <button type="submit" className="btn-primary" disabled={creating}> | 87 | <button type="submit" className="btn-primary" disabled={creating}> |
| 86 | - {creating ? '...' : 'Create'} | 88 | + {creating ? '...' : t('action.create')} |
| 87 | </button> | 89 | </button> |
| 88 | </form> | 90 | </form> |
| 89 | )} | 91 | )} |
web/src/pages/AdjustStockPage.tsx
| @@ -4,9 +4,11 @@ import { catalog, inventory } from '@/api/client' | @@ -4,9 +4,11 @@ import { catalog, inventory } from '@/api/client' | ||
| 4 | import type { Item, Location } from '@/types/api' | 4 | import type { Item, Location } from '@/types/api' |
| 5 | import { PageHeader } from '@/components/PageHeader' | 5 | import { PageHeader } from '@/components/PageHeader' |
| 6 | import { ErrorBox } from '@/components/ErrorBox' | 6 | import { ErrorBox } from '@/components/ErrorBox' |
| 7 | +import { useT } from '@/i18n/LocaleContext' | ||
| 7 | 8 | ||
| 8 | export function AdjustStockPage() { | 9 | export function AdjustStockPage() { |
| 9 | const navigate = useNavigate() | 10 | const navigate = useNavigate() |
| 11 | + const t = useT() | ||
| 10 | const [items, setItems] = useState<Item[]>([]) | 12 | const [items, setItems] = useState<Item[]>([]) |
| 11 | const [locations, setLocations] = useState<Location[]>([]) | 13 | const [locations, setLocations] = useState<Location[]>([]) |
| 12 | const [itemCode, setItemCode] = useState('') | 14 | const [itemCode, setItemCode] = useState('') |
| @@ -37,7 +39,9 @@ export function AdjustStockPage() { | @@ -37,7 +39,9 @@ export function AdjustStockPage() { | ||
| 37 | quantity: Number(quantity), | 39 | quantity: Number(quantity), |
| 38 | }) | 40 | }) |
| 39 | setResult( | 41 | setResult( |
| 40 | - `Balance set: ${bal.itemCode} @ location = ${bal.quantity}`, | 42 | + t('page.adjustStock.result') |
| 43 | + .replace('{itemCode}', bal.itemCode) | ||
| 44 | + .replace('{quantity}', String(bal.quantity)), | ||
| 41 | ) | 45 | ) |
| 42 | } catch (err: unknown) { | 46 | } catch (err: unknown) { |
| 43 | setError(err instanceof Error ? err : new Error(String(err))) | 47 | setError(err instanceof Error ? err : new Error(String(err))) |
| @@ -49,27 +53,27 @@ export function AdjustStockPage() { | @@ -49,27 +53,27 @@ export function AdjustStockPage() { | ||
| 49 | return ( | 53 | return ( |
| 50 | <div> | 54 | <div> |
| 51 | <PageHeader | 55 | <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>} | 56 | + title={t('page.adjustStock.title')} |
| 57 | + subtitle={t('page.adjustStock.subtitle')} | ||
| 58 | + actions={<button className="btn-secondary" onClick={() => navigate('/balances')}>{t('action.back')}</button>} | ||
| 55 | /> | 59 | /> |
| 56 | <form onSubmit={onSubmit} className="card p-6 space-y-4 max-w-lg"> | 60 | <form onSubmit={onSubmit} className="card p-6 space-y-4 max-w-lg"> |
| 57 | <div> | 61 | <div> |
| 58 | - <label className="block text-sm font-medium text-slate-700">Item</label> | 62 | + <label className="block text-sm font-medium text-slate-700">{t('label.item')}</label> |
| 59 | <select required value={itemCode} onChange={(e) => setItemCode(e.target.value)} | 63 | <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"> | 64 | 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>)} | 65 | {items.map((i) => <option key={i.id} value={i.code}>{i.code} — {i.name}</option>)} |
| 62 | </select> | 66 | </select> |
| 63 | </div> | 67 | </div> |
| 64 | <div> | 68 | <div> |
| 65 | - <label className="block text-sm font-medium text-slate-700">Location</label> | 69 | + <label className="block text-sm font-medium text-slate-700">{t('label.location')}</label> |
| 66 | <select required value={locationId} onChange={(e) => setLocationId(e.target.value)} | 70 | <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"> | 71 | 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>)} | 72 | {locations.map((l) => <option key={l.id} value={l.id}>{l.code} — {l.name}</option>)} |
| 69 | </select> | 73 | </select> |
| 70 | </div> | 74 | </div> |
| 71 | <div> | 75 | <div> |
| 72 | - <label className="block text-sm font-medium text-slate-700">Quantity (absolute, not delta)</label> | 76 | + <label className="block text-sm font-medium text-slate-700">{t('label.quantityAbsolute')}</label> |
| 73 | <input type="number" required min="0" step="1" value={quantity} | 77 | <input type="number" required min="0" step="1" value={quantity} |
| 74 | onChange={(e) => setQuantity(e.target.value)} | 78 | 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" /> | 79 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm text-right" /> |
| @@ -81,7 +85,7 @@ export function AdjustStockPage() { | @@ -81,7 +85,7 @@ export function AdjustStockPage() { | ||
| 81 | </div> | 85 | </div> |
| 82 | )} | 86 | )} |
| 83 | <button type="submit" className="btn-primary" disabled={submitting}> | 87 | <button type="submit" className="btn-primary" disabled={submitting}> |
| 84 | - {submitting ? 'Adjusting...' : 'Set Balance'} | 88 | + {submitting ? t('action.adjusting') : t('action.setBalance')} |
| 85 | </button> | 89 | </button> |
| 86 | </form> | 90 | </form> |
| 87 | </div> | 91 | </div> |
web/src/pages/BalancesPage.tsx
| @@ -6,6 +6,7 @@ import { PageHeader } from '@/components/PageHeader' | @@ -6,6 +6,7 @@ import { PageHeader } from '@/components/PageHeader' | ||
| 6 | import { Loading } from '@/components/Loading' | 6 | import { Loading } from '@/components/Loading' |
| 7 | import { ErrorBox } from '@/components/ErrorBox' | 7 | import { ErrorBox } from '@/components/ErrorBox' |
| 8 | import { DataTable, type Column } from '@/components/DataTable' | 8 | import { DataTable, type Column } from '@/components/DataTable' |
| 9 | +import { useT } from '@/i18n/LocaleContext' | ||
| 9 | 10 | ||
| 10 | interface Row extends Record<string, unknown> { | 11 | interface Row extends Record<string, unknown> { |
| 11 | id: string | 12 | id: string |
| @@ -15,6 +16,7 @@ interface Row extends Record<string, unknown> { | @@ -15,6 +16,7 @@ interface Row extends Record<string, unknown> { | ||
| 15 | } | 16 | } |
| 16 | 17 | ||
| 17 | export function BalancesPage() { | 18 | export function BalancesPage() { |
| 19 | + const t = useT() | ||
| 18 | const [rows, setRows] = useState<Row[]>([]) | 20 | const [rows, setRows] = useState<Row[]>([]) |
| 19 | const [error, setError] = useState<Error | null>(null) | 21 | const [error, setError] = useState<Error | null>(null) |
| 20 | const [loading, setLoading] = useState(true) | 22 | const [loading, setLoading] = useState(true) |
| @@ -37,14 +39,14 @@ export function BalancesPage() { | @@ -37,14 +39,14 @@ export function BalancesPage() { | ||
| 37 | }, []) | 39 | }, []) |
| 38 | 40 | ||
| 39 | const columns: Column<Row>[] = [ | 41 | const columns: Column<Row>[] = [ |
| 40 | - { header: 'Item', key: 'itemCode', render: (r) => <span className="font-mono">{r.itemCode}</span> }, | 42 | + { header: t('label.item'), key: 'itemCode', render: (r) => <span className="font-mono">{r.itemCode}</span> }, |
| 41 | { | 43 | { |
| 42 | - header: 'Location', | 44 | + header: t('label.location'), |
| 43 | key: 'locationCode', | 45 | key: 'locationCode', |
| 44 | render: (r) => <span className="font-mono">{r.locationCode}</span>, | 46 | render: (r) => <span className="font-mono">{r.locationCode}</span>, |
| 45 | }, | 47 | }, |
| 46 | { | 48 | { |
| 47 | - header: 'Quantity', | 49 | + header: t('label.quantity'), |
| 48 | key: 'quantity', | 50 | key: 'quantity', |
| 49 | render: (r) => <span className="font-mono tabular-nums">{String(r.quantity)}</span>, | 51 | render: (r) => <span className="font-mono tabular-nums">{String(r.quantity)}</span>, |
| 50 | }, | 52 | }, |
| @@ -53,9 +55,9 @@ export function BalancesPage() { | @@ -53,9 +55,9 @@ export function BalancesPage() { | ||
| 53 | return ( | 55 | return ( |
| 54 | <div> | 56 | <div> |
| 55 | <PageHeader | 57 | <PageHeader |
| 56 | - title="Stock Balances" | ||
| 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>} | 58 | + title={t('page.balances.title')} |
| 59 | + subtitle={t('page.balances.subtitle')} | ||
| 60 | + actions={<Link to="/balances/adjust" className="btn-primary">{t('action.adjustStock')}</Link>} | ||
| 59 | /> | 61 | /> |
| 60 | {loading && <Loading />} | 62 | {loading && <Loading />} |
| 61 | {error && <ErrorBox error={error} />} | 63 | {error && <ErrorBox error={error} />} |
web/src/pages/CreateItemPage.tsx
| @@ -5,11 +5,13 @@ import type { Uom } from '@/types/api' | @@ -5,11 +5,13 @@ import type { Uom } from '@/types/api' | ||
| 5 | import { PageHeader } from '@/components/PageHeader' | 5 | import { PageHeader } from '@/components/PageHeader' |
| 6 | import { ErrorBox } from '@/components/ErrorBox' | 6 | import { ErrorBox } from '@/components/ErrorBox' |
| 7 | import { DynamicExtFields } from '@/components/DynamicExtFields' | 7 | import { DynamicExtFields } from '@/components/DynamicExtFields' |
| 8 | +import { useT } from '@/i18n/LocaleContext' | ||
| 8 | 9 | ||
| 9 | const ITEM_TYPES = ['GOOD', 'SERVICE', 'DIGITAL'] as const | 10 | const ITEM_TYPES = ['GOOD', 'SERVICE', 'DIGITAL'] as const |
| 10 | 11 | ||
| 11 | export function CreateItemPage() { | 12 | export function CreateItemPage() { |
| 12 | const navigate = useNavigate() | 13 | const navigate = useNavigate() |
| 14 | + const t = useT() | ||
| 13 | const [code, setCode] = useState('') | 15 | const [code, setCode] = useState('') |
| 14 | const [name, setName] = useState('') | 16 | const [name, setName] = useState('') |
| 15 | const [description, setDescription] = useState('') | 17 | const [description, setDescription] = useState('') |
| @@ -49,31 +51,31 @@ export function CreateItemPage() { | @@ -49,31 +51,31 @@ export function CreateItemPage() { | ||
| 49 | return ( | 51 | return ( |
| 50 | <div> | 52 | <div> |
| 51 | <PageHeader | 53 | <PageHeader |
| 52 | - title="New Item" | ||
| 53 | - subtitle="Add a raw material, finished good, or service to the catalog." | ||
| 54 | - actions={<button className="btn-secondary" onClick={() => navigate('/items')}>Cancel</button>} | 54 | + title={t('page.createItem.title')} |
| 55 | + subtitle={t('page.createItem.subtitle')} | ||
| 56 | + actions={<button className="btn-secondary" onClick={() => navigate('/items')}>{t('action.cancel')}</button>} | ||
| 55 | /> | 57 | /> |
| 56 | <form onSubmit={onSubmit} className="card p-6 space-y-4 max-w-2xl"> | 58 | <form onSubmit={onSubmit} className="card p-6 space-y-4 max-w-2xl"> |
| 57 | <div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> | 59 | <div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> |
| 58 | <div> | 60 | <div> |
| 59 | - <label className="block text-sm font-medium text-slate-700">Item code</label> | 61 | + <label className="block text-sm font-medium text-slate-700">{t('label.itemCode')}</label> |
| 60 | <input type="text" required value={code} onChange={(e) => setCode(e.target.value)} | 62 | <input type="text" required value={code} onChange={(e) => setCode(e.target.value)} |
| 61 | placeholder="PAPER-120G-A3" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> | 63 | placeholder="PAPER-120G-A3" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 62 | </div> | 64 | </div> |
| 63 | <div> | 65 | <div> |
| 64 | - <label className="block text-sm font-medium text-slate-700">Name</label> | 66 | + <label className="block text-sm font-medium text-slate-700">{t('label.name')}</label> |
| 65 | <input type="text" required value={name} onChange={(e) => setName(e.target.value)} | 67 | <input type="text" required value={name} onChange={(e) => setName(e.target.value)} |
| 66 | placeholder="120g A3 coated paper" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> | 68 | placeholder="120g A3 coated paper" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 67 | </div> | 69 | </div> |
| 68 | <div> | 70 | <div> |
| 69 | - <label className="block text-sm font-medium text-slate-700">Type</label> | 71 | + <label className="block text-sm font-medium text-slate-700">{t('label.type')}</label> |
| 70 | <select value={itemType} onChange={(e) => setItemType(e.target.value)} | 72 | <select value={itemType} onChange={(e) => setItemType(e.target.value)} |
| 71 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"> | 73 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"> |
| 72 | - {ITEM_TYPES.map((t) => <option key={t} value={t}>{t}</option>)} | 74 | + {ITEM_TYPES.map((it) => <option key={it} value={it}>{it}</option>)} |
| 73 | </select> | 75 | </select> |
| 74 | </div> | 76 | </div> |
| 75 | <div> | 77 | <div> |
| 76 | - <label className="block text-sm font-medium text-slate-700">Base UoM</label> | 78 | + <label className="block text-sm font-medium text-slate-700">{t('label.baseUom')}</label> |
| 77 | <select value={baseUomCode} onChange={(e) => setBaseUomCode(e.target.value)} | 79 | <select value={baseUomCode} onChange={(e) => setBaseUomCode(e.target.value)} |
| 78 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"> | 80 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"> |
| 79 | {uoms.map((u) => <option key={u.id} value={u.code}>{u.code} — {u.name}</option>)} | 81 | {uoms.map((u) => <option key={u.id} value={u.code}>{u.code} — {u.name}</option>)} |
| @@ -81,7 +83,7 @@ export function CreateItemPage() { | @@ -81,7 +83,7 @@ export function CreateItemPage() { | ||
| 81 | </div> | 83 | </div> |
| 82 | </div> | 84 | </div> |
| 83 | <div> | 85 | <div> |
| 84 | - <label className="block text-sm font-medium text-slate-700">Description (optional)</label> | 86 | + <label className="block text-sm font-medium text-slate-700">{t('label.descriptionOptional')}</label> |
| 85 | <input type="text" value={description} onChange={(e) => setDescription(e.target.value)} | 87 | <input type="text" value={description} onChange={(e) => setDescription(e.target.value)} |
| 86 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> | 88 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 87 | </div> | 89 | </div> |
| @@ -92,7 +94,7 @@ export function CreateItemPage() { | @@ -92,7 +94,7 @@ export function CreateItemPage() { | ||
| 92 | /> | 94 | /> |
| 93 | {error && <ErrorBox error={error} />} | 95 | {error && <ErrorBox error={error} />} |
| 94 | <button type="submit" className="btn-primary" disabled={submitting}> | 96 | <button type="submit" className="btn-primary" disabled={submitting}> |
| 95 | - {submitting ? 'Creating...' : 'Create Item'} | 97 | + {submitting ? t('action.creating') : t('page.createItem.submit')} |
| 96 | </button> | 98 | </button> |
| 97 | </form> | 99 | </form> |
| 98 | </div> | 100 | </div> |
web/src/pages/CreateLocationPage.tsx
| @@ -4,11 +4,13 @@ import { inventory } from '@/api/client' | @@ -4,11 +4,13 @@ import { inventory } from '@/api/client' | ||
| 4 | import { PageHeader } from '@/components/PageHeader' | 4 | import { PageHeader } from '@/components/PageHeader' |
| 5 | import { ErrorBox } from '@/components/ErrorBox' | 5 | import { ErrorBox } from '@/components/ErrorBox' |
| 6 | import { DynamicExtFields } from '@/components/DynamicExtFields' | 6 | import { DynamicExtFields } from '@/components/DynamicExtFields' |
| 7 | +import { useT } from '@/i18n/LocaleContext' | ||
| 7 | 8 | ||
| 8 | const LOCATION_TYPES = ['WAREHOUSE', 'BIN', 'VIRTUAL'] as const | 9 | const LOCATION_TYPES = ['WAREHOUSE', 'BIN', 'VIRTUAL'] as const |
| 9 | 10 | ||
| 10 | export function CreateLocationPage() { | 11 | export function CreateLocationPage() { |
| 11 | const navigate = useNavigate() | 12 | const navigate = useNavigate() |
| 13 | + const t = useT() | ||
| 12 | const [code, setCode] = useState('') | 14 | const [code, setCode] = useState('') |
| 13 | const [name, setName] = useState('') | 15 | const [name, setName] = useState('') |
| 14 | const [type, setType] = useState<string>('WAREHOUSE') | 16 | const [type, setType] = useState<string>('WAREHOUSE') |
| @@ -34,26 +36,26 @@ export function CreateLocationPage() { | @@ -34,26 +36,26 @@ export function CreateLocationPage() { | ||
| 34 | return ( | 36 | return ( |
| 35 | <div> | 37 | <div> |
| 36 | <PageHeader | 38 | <PageHeader |
| 37 | - title="New Location" | ||
| 38 | - subtitle="Add a warehouse, bin, or virtual location for inventory tracking." | ||
| 39 | - actions={<button className="btn-secondary" onClick={() => navigate('/locations')}>Cancel</button>} | 39 | + title={t('page.createLocation.title')} |
| 40 | + subtitle={t('page.createLocation.subtitle')} | ||
| 41 | + actions={<button className="btn-secondary" onClick={() => navigate('/locations')}>{t('action.cancel')}</button>} | ||
| 40 | /> | 42 | /> |
| 41 | <form onSubmit={onSubmit} className="card p-6 space-y-4 max-w-lg"> | 43 | <form onSubmit={onSubmit} className="card p-6 space-y-4 max-w-lg"> |
| 42 | <div> | 44 | <div> |
| 43 | - <label className="block text-sm font-medium text-slate-700">Location code</label> | 45 | + <label className="block text-sm font-medium text-slate-700">{t('label.locationCode')}</label> |
| 44 | <input type="text" required value={code} onChange={(e) => setCode(e.target.value)} | 46 | <input type="text" required value={code} onChange={(e) => setCode(e.target.value)} |
| 45 | placeholder="WH-NEW" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> | 47 | placeholder="WH-NEW" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 46 | </div> | 48 | </div> |
| 47 | <div> | 49 | <div> |
| 48 | - <label className="block text-sm font-medium text-slate-700">Name</label> | 50 | + <label className="block text-sm font-medium text-slate-700">{t('label.name')}</label> |
| 49 | <input type="text" required value={name} onChange={(e) => setName(e.target.value)} | 51 | <input type="text" required value={name} onChange={(e) => setName(e.target.value)} |
| 50 | placeholder="New Warehouse" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> | 52 | placeholder="New Warehouse" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 51 | </div> | 53 | </div> |
| 52 | <div> | 54 | <div> |
| 53 | - <label className="block text-sm font-medium text-slate-700">Type</label> | 55 | + <label className="block text-sm font-medium text-slate-700">{t('label.type')}</label> |
| 54 | <select value={type} onChange={(e) => setType(e.target.value)} | 56 | <select value={type} onChange={(e) => setType(e.target.value)} |
| 55 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"> | 57 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"> |
| 56 | - {LOCATION_TYPES.map((t) => <option key={t} value={t}>{t}</option>)} | 58 | + {LOCATION_TYPES.map((lt) => <option key={lt} value={lt}>{lt}</option>)} |
| 57 | </select> | 59 | </select> |
| 58 | </div> | 60 | </div> |
| 59 | <DynamicExtFields | 61 | <DynamicExtFields |
| @@ -63,7 +65,7 @@ export function CreateLocationPage() { | @@ -63,7 +65,7 @@ export function CreateLocationPage() { | ||
| 63 | /> | 65 | /> |
| 64 | {error && <ErrorBox error={error} />} | 66 | {error && <ErrorBox error={error} />} |
| 65 | <button type="submit" className="btn-primary" disabled={submitting}> | 67 | <button type="submit" className="btn-primary" disabled={submitting}> |
| 66 | - {submitting ? 'Creating...' : 'Create Location'} | 68 | + {submitting ? t('action.creating') : t('page.createLocation.submit')} |
| 67 | </button> | 69 | </button> |
| 68 | </form> | 70 | </form> |
| 69 | </div> | 71 | </div> |
web/src/pages/CreatePartnerPage.tsx
| @@ -4,11 +4,13 @@ import { partners } from '@/api/client' | @@ -4,11 +4,13 @@ import { partners } from '@/api/client' | ||
| 4 | import { PageHeader } from '@/components/PageHeader' | 4 | import { PageHeader } from '@/components/PageHeader' |
| 5 | import { ErrorBox } from '@/components/ErrorBox' | 5 | import { ErrorBox } from '@/components/ErrorBox' |
| 6 | import { DynamicExtFields } from '@/components/DynamicExtFields' | 6 | import { DynamicExtFields } from '@/components/DynamicExtFields' |
| 7 | +import { useT } from '@/i18n/LocaleContext' | ||
| 7 | 8 | ||
| 8 | const PARTNER_TYPES = ['CUSTOMER', 'SUPPLIER', 'BOTH'] as const | 9 | const PARTNER_TYPES = ['CUSTOMER', 'SUPPLIER', 'BOTH'] as const |
| 9 | 10 | ||
| 10 | export function CreatePartnerPage() { | 11 | export function CreatePartnerPage() { |
| 11 | const navigate = useNavigate() | 12 | const navigate = useNavigate() |
| 13 | + const t = useT() | ||
| 12 | const [code, setCode] = useState('') | 14 | const [code, setCode] = useState('') |
| 13 | const [name, setName] = useState('') | 15 | const [name, setName] = useState('') |
| 14 | const [type, setType] = useState<string>('CUSTOMER') | 16 | const [type, setType] = useState<string>('CUSTOMER') |
| @@ -41,36 +43,36 @@ export function CreatePartnerPage() { | @@ -41,36 +43,36 @@ export function CreatePartnerPage() { | ||
| 41 | return ( | 43 | return ( |
| 42 | <div> | 44 | <div> |
| 43 | <PageHeader | 45 | <PageHeader |
| 44 | - title="New Partner" | ||
| 45 | - subtitle="Add a customer, supplier, or dual-role partner." | ||
| 46 | - actions={<button className="btn-secondary" onClick={() => navigate('/partners')}>Cancel</button>} | 46 | + title={t('page.createPartner.title')} |
| 47 | + subtitle={t('page.createPartner.subtitle')} | ||
| 48 | + actions={<button className="btn-secondary" onClick={() => navigate('/partners')}>{t('action.cancel')}</button>} | ||
| 47 | /> | 49 | /> |
| 48 | <form onSubmit={onSubmit} className="card p-6 space-y-4 max-w-2xl"> | 50 | <form onSubmit={onSubmit} className="card p-6 space-y-4 max-w-2xl"> |
| 49 | <div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> | 51 | <div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> |
| 50 | <div> | 52 | <div> |
| 51 | - <label className="block text-sm font-medium text-slate-700">Partner code</label> | 53 | + <label className="block text-sm font-medium text-slate-700">{t('label.partnerCode')}</label> |
| 52 | <input type="text" required value={code} onChange={(e) => setCode(e.target.value)} | 54 | <input type="text" required value={code} onChange={(e) => setCode(e.target.value)} |
| 53 | placeholder="CUST-NEWCO" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> | 55 | placeholder="CUST-NEWCO" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 54 | </div> | 56 | </div> |
| 55 | <div> | 57 | <div> |
| 56 | - <label className="block text-sm font-medium text-slate-700">Name</label> | 58 | + <label className="block text-sm font-medium text-slate-700">{t('label.name')}</label> |
| 57 | <input type="text" required value={name} onChange={(e) => setName(e.target.value)} | 59 | <input type="text" required value={name} onChange={(e) => setName(e.target.value)} |
| 58 | placeholder="New Company Ltd." className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> | 60 | placeholder="New Company Ltd." className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 59 | </div> | 61 | </div> |
| 60 | <div> | 62 | <div> |
| 61 | - <label className="block text-sm font-medium text-slate-700">Type</label> | 63 | + <label className="block text-sm font-medium text-slate-700">{t('label.type')}</label> |
| 62 | <select value={type} onChange={(e) => setType(e.target.value)} | 64 | <select value={type} onChange={(e) => setType(e.target.value)} |
| 63 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"> | 65 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"> |
| 64 | - {PARTNER_TYPES.map((t) => <option key={t} value={t}>{t}</option>)} | 66 | + {PARTNER_TYPES.map((pt) => <option key={pt} value={pt}>{pt}</option>)} |
| 65 | </select> | 67 | </select> |
| 66 | </div> | 68 | </div> |
| 67 | <div> | 69 | <div> |
| 68 | - <label className="block text-sm font-medium text-slate-700">Email (optional)</label> | 70 | + <label className="block text-sm font-medium text-slate-700">{t('label.emailOptional')}</label> |
| 69 | <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} | 71 | <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} |
| 70 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> | 72 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 71 | </div> | 73 | </div> |
| 72 | <div> | 74 | <div> |
| 73 | - <label className="block text-sm font-medium text-slate-700">Phone (optional)</label> | 75 | + <label className="block text-sm font-medium text-slate-700">{t('label.phoneOptional')}</label> |
| 74 | <input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} | 76 | <input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} |
| 75 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> | 77 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 76 | </div> | 78 | </div> |
| @@ -82,7 +84,7 @@ export function CreatePartnerPage() { | @@ -82,7 +84,7 @@ export function CreatePartnerPage() { | ||
| 82 | /> | 84 | /> |
| 83 | {error && <ErrorBox error={error} />} | 85 | {error && <ErrorBox error={error} />} |
| 84 | <button type="submit" className="btn-primary" disabled={submitting}> | 86 | <button type="submit" className="btn-primary" disabled={submitting}> |
| 85 | - {submitting ? 'Creating...' : 'Create Partner'} | 87 | + {submitting ? t('action.creating') : t('page.createPartner.submit')} |
| 86 | </button> | 88 | </button> |
| 87 | </form> | 89 | </form> |
| 88 | </div> | 90 | </div> |
web/src/pages/CreatePurchaseOrderPage.tsx
| @@ -5,6 +5,7 @@ import type { Item, Partner } from '@/types/api' | @@ -5,6 +5,7 @@ import type { Item, Partner } from '@/types/api' | ||
| 5 | import { PageHeader } from '@/components/PageHeader' | 5 | import { PageHeader } from '@/components/PageHeader' |
| 6 | import { ErrorBox } from '@/components/ErrorBox' | 6 | import { ErrorBox } from '@/components/ErrorBox' |
| 7 | import { DynamicExtFields } from '@/components/DynamicExtFields' | 7 | import { DynamicExtFields } from '@/components/DynamicExtFields' |
| 8 | +import { useT } from '@/i18n/LocaleContext' | ||
| 8 | 9 | ||
| 9 | interface LineInput { | 10 | interface LineInput { |
| 10 | itemCode: string | 11 | itemCode: string |
| @@ -14,6 +15,7 @@ interface LineInput { | @@ -14,6 +15,7 @@ interface LineInput { | ||
| 14 | 15 | ||
| 15 | export function CreatePurchaseOrderPage() { | 16 | export function CreatePurchaseOrderPage() { |
| 16 | const navigate = useNavigate() | 17 | const navigate = useNavigate() |
| 18 | + const t = useT() | ||
| 17 | const [code, setCode] = useState('') | 19 | const [code, setCode] = useState('') |
| 18 | const [partnerCode, setPartnerCode] = useState('') | 20 | const [partnerCode, setPartnerCode] = useState('') |
| 19 | const [expectedDate, setExpectedDate] = useState('') | 21 | const [expectedDate, setExpectedDate] = useState('') |
| @@ -78,19 +80,19 @@ export function CreatePurchaseOrderPage() { | @@ -78,19 +80,19 @@ export function CreatePurchaseOrderPage() { | ||
| 78 | return ( | 80 | return ( |
| 79 | <div> | 81 | <div> |
| 80 | <PageHeader | 82 | <PageHeader |
| 81 | - title="New Purchase Order" | ||
| 82 | - subtitle="Order materials from a supplier. Confirm and receive to credit inventory." | ||
| 83 | - actions={<button className="btn-secondary" onClick={() => navigate('/purchase-orders')}>Cancel</button>} | 83 | + title={t('page.createPurchaseOrder.title')} |
| 84 | + subtitle={t('page.createPurchaseOrder.subtitle')} | ||
| 85 | + actions={<button className="btn-secondary" onClick={() => navigate('/purchase-orders')}>{t('action.cancel')}</button>} | ||
| 84 | /> | 86 | /> |
| 85 | <form onSubmit={onSubmit} className="card p-6 space-y-5 max-w-3xl"> | 87 | <form onSubmit={onSubmit} className="card p-6 space-y-5 max-w-3xl"> |
| 86 | <div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> | 88 | <div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> |
| 87 | <div> | 89 | <div> |
| 88 | - <label className="block text-sm font-medium text-slate-700">Order code</label> | 90 | + <label className="block text-sm font-medium text-slate-700">{t('label.orderCode')}</label> |
| 89 | <input type="text" required value={code} onChange={(e) => setCode(e.target.value)} | 91 | <input type="text" required value={code} onChange={(e) => setCode(e.target.value)} |
| 90 | placeholder="PO-2026-0002" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> | 92 | placeholder="PO-2026-0002" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 91 | </div> | 93 | </div> |
| 92 | <div> | 94 | <div> |
| 93 | - <label className="block text-sm font-medium text-slate-700">Supplier</label> | 95 | + <label className="block text-sm font-medium text-slate-700">{t('label.supplier')}</label> |
| 94 | <select required value={partnerCode} onChange={(e) => setPartnerCode(e.target.value)} | 96 | <select required value={partnerCode} onChange={(e) => setPartnerCode(e.target.value)} |
| 95 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"> | 97 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"> |
| 96 | {supplierList.map((p) => ( | 98 | {supplierList.map((p) => ( |
| @@ -99,7 +101,7 @@ export function CreatePurchaseOrderPage() { | @@ -99,7 +101,7 @@ export function CreatePurchaseOrderPage() { | ||
| 99 | </select> | 101 | </select> |
| 100 | </div> | 102 | </div> |
| 101 | <div> | 103 | <div> |
| 102 | - <label className="block text-sm font-medium text-slate-700">Expected date</label> | 104 | + <label className="block text-sm font-medium text-slate-700">{t('label.expectedDate')}</label> |
| 103 | <input type="date" value={expectedDate} onChange={(e) => setExpectedDate(e.target.value)} | 105 | <input type="date" value={expectedDate} onChange={(e) => setExpectedDate(e.target.value)} |
| 104 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> | 106 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 105 | </div> | 107 | </div> |
| @@ -107,8 +109,8 @@ export function CreatePurchaseOrderPage() { | @@ -107,8 +109,8 @@ export function CreatePurchaseOrderPage() { | ||
| 107 | 109 | ||
| 108 | <div> | 110 | <div> |
| 109 | <div className="flex items-center justify-between mb-2"> | 111 | <div className="flex items-center justify-between mb-2"> |
| 110 | - <label className="text-sm font-medium text-slate-700">Order lines</label> | ||
| 111 | - <button type="button" className="btn-secondary text-xs" onClick={addLine}>+ Add line</button> | 112 | + <label className="text-sm font-medium text-slate-700">{t('label.orderLines')}</label> |
| 113 | + <button type="button" className="btn-secondary text-xs" onClick={addLine}>{t('action.addLine')}</button> | ||
| 112 | </div> | 114 | </div> |
| 113 | <div className="space-y-2"> | 115 | <div className="space-y-2"> |
| 114 | {lines.map((line, idx) => ( | 116 | {lines.map((line, idx) => ( |
| @@ -116,17 +118,17 @@ export function CreatePurchaseOrderPage() { | @@ -116,17 +118,17 @@ export function CreatePurchaseOrderPage() { | ||
| 116 | <span className="w-6 text-xs text-slate-400 text-right">{idx + 1}</span> | 118 | <span className="w-6 text-xs text-slate-400 text-right">{idx + 1}</span> |
| 117 | <select value={line.itemCode} onChange={(e) => updateLine(idx, 'itemCode', e.target.value)} | 119 | <select value={line.itemCode} onChange={(e) => updateLine(idx, 'itemCode', e.target.value)} |
| 118 | className="flex-1 rounded-md border border-slate-300 px-2 py-1.5 text-sm"> | 120 | className="flex-1 rounded-md border border-slate-300 px-2 py-1.5 text-sm"> |
| 119 | - <option value="">Select item...</option> | 121 | + <option value="">{t('action.selectItem')}</option> |
| 120 | {items.map((it) => <option key={it.id} value={it.code}>{it.code} — {it.name}</option>)} | 122 | {items.map((it) => <option key={it.id} value={it.code}>{it.code} — {it.name}</option>)} |
| 121 | </select> | 123 | </select> |
| 122 | - <input type="number" min="1" step="1" placeholder="Qty" value={line.quantity} | 124 | + <input type="number" min="1" step="1" placeholder={t('label.qty')} value={line.quantity} |
| 123 | onChange={(e) => updateLine(idx, 'quantity', e.target.value)} | 125 | onChange={(e) => updateLine(idx, 'quantity', e.target.value)} |
| 124 | className="w-20 rounded-md border border-slate-300 px-2 py-1.5 text-sm text-right" /> | 126 | className="w-20 rounded-md border border-slate-300 px-2 py-1.5 text-sm text-right" /> |
| 125 | - <input type="number" min="0" step="0.01" placeholder="Price" value={line.unitPrice} | 127 | + <input type="number" min="0" step="0.01" placeholder={t('label.unitPrice')} value={line.unitPrice} |
| 126 | onChange={(e) => updateLine(idx, 'unitPrice', e.target.value)} | 128 | onChange={(e) => updateLine(idx, 'unitPrice', e.target.value)} |
| 127 | className="w-24 rounded-md border border-slate-300 px-2 py-1.5 text-sm text-right" /> | 129 | className="w-24 rounded-md border border-slate-300 px-2 py-1.5 text-sm text-right" /> |
| 128 | <button type="button" className="text-slate-400 hover:text-rose-500" | 130 | <button type="button" className="text-slate-400 hover:text-rose-500" |
| 129 | - onClick={() => removeLine(idx)} title="Remove line">×</button> | 131 | + onClick={() => removeLine(idx)} title={t('action.delete')}>×</button> |
| 130 | </div> | 132 | </div> |
| 131 | ))} | 133 | ))} |
| 132 | </div> | 134 | </div> |
| @@ -139,7 +141,7 @@ export function CreatePurchaseOrderPage() { | @@ -139,7 +141,7 @@ export function CreatePurchaseOrderPage() { | ||
| 139 | /> | 141 | /> |
| 140 | {error && <ErrorBox error={error} />} | 142 | {error && <ErrorBox error={error} />} |
| 141 | <button type="submit" className="btn-primary" disabled={submitting}> | 143 | <button type="submit" className="btn-primary" disabled={submitting}> |
| 142 | - {submitting ? 'Creating...' : 'Create Purchase Order'} | 144 | + {submitting ? t('action.creating') : t('page.createPurchaseOrder.submit')} |
| 143 | </button> | 145 | </button> |
| 144 | </form> | 146 | </form> |
| 145 | </div> | 147 | </div> |
web/src/pages/CreateSalesOrderPage.tsx
| @@ -5,6 +5,7 @@ import type { Item, Partner } from '@/types/api' | @@ -5,6 +5,7 @@ import type { Item, Partner } from '@/types/api' | ||
| 5 | import { PageHeader } from '@/components/PageHeader' | 5 | import { PageHeader } from '@/components/PageHeader' |
| 6 | import { ErrorBox } from '@/components/ErrorBox' | 6 | import { ErrorBox } from '@/components/ErrorBox' |
| 7 | import { DynamicExtFields } from '@/components/DynamicExtFields' | 7 | import { DynamicExtFields } from '@/components/DynamicExtFields' |
| 8 | +import { useT } from '@/i18n/LocaleContext' | ||
| 8 | 9 | ||
| 9 | interface LineInput { | 10 | interface LineInput { |
| 10 | itemCode: string | 11 | itemCode: string |
| @@ -14,6 +15,7 @@ interface LineInput { | @@ -14,6 +15,7 @@ interface LineInput { | ||
| 14 | 15 | ||
| 15 | export function CreateSalesOrderPage() { | 16 | export function CreateSalesOrderPage() { |
| 16 | const navigate = useNavigate() | 17 | const navigate = useNavigate() |
| 18 | + const t = useT() | ||
| 17 | const [code, setCode] = useState('') | 19 | const [code, setCode] = useState('') |
| 18 | const [partnerCode, setPartnerCode] = useState('') | 20 | const [partnerCode, setPartnerCode] = useState('') |
| 19 | const [currencyCode] = useState('USD') | 21 | const [currencyCode] = useState('USD') |
| @@ -80,18 +82,18 @@ export function CreateSalesOrderPage() { | @@ -80,18 +82,18 @@ export function CreateSalesOrderPage() { | ||
| 80 | return ( | 82 | return ( |
| 81 | <div> | 83 | <div> |
| 82 | <PageHeader | 84 | <PageHeader |
| 83 | - title="New Sales Order" | ||
| 84 | - subtitle="Create a sales order. Confirming it will auto-generate production work orders." | 85 | + title={t('page.createSalesOrder.title')} |
| 86 | + subtitle={t('page.createSalesOrder.subtitle')} | ||
| 85 | actions={ | 87 | actions={ |
| 86 | <button className="btn-secondary" onClick={() => navigate('/sales-orders')}> | 88 | <button className="btn-secondary" onClick={() => navigate('/sales-orders')}> |
| 87 | - Cancel | 89 | + {t('action.cancel')} |
| 88 | </button> | 90 | </button> |
| 89 | } | 91 | } |
| 90 | /> | 92 | /> |
| 91 | <form onSubmit={onSubmit} className="card p-6 space-y-5 max-w-3xl"> | 93 | <form onSubmit={onSubmit} className="card p-6 space-y-5 max-w-3xl"> |
| 92 | <div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> | 94 | <div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> |
| 93 | <div> | 95 | <div> |
| 94 | - <label className="block text-sm font-medium text-slate-700">Order code</label> | 96 | + <label className="block text-sm font-medium text-slate-700">{t('label.orderCode')}</label> |
| 95 | <input | 97 | <input |
| 96 | type="text" | 98 | type="text" |
| 97 | required | 99 | required |
| @@ -102,7 +104,7 @@ export function CreateSalesOrderPage() { | @@ -102,7 +104,7 @@ export function CreateSalesOrderPage() { | ||
| 102 | /> | 104 | /> |
| 103 | </div> | 105 | </div> |
| 104 | <div> | 106 | <div> |
| 105 | - <label className="block text-sm font-medium text-slate-700">Customer</label> | 107 | + <label className="block text-sm font-medium text-slate-700">{t('label.customer')}</label> |
| 106 | <select | 108 | <select |
| 107 | required | 109 | required |
| 108 | value={partnerCode} | 110 | value={partnerCode} |
| @@ -117,7 +119,7 @@ export function CreateSalesOrderPage() { | @@ -117,7 +119,7 @@ export function CreateSalesOrderPage() { | ||
| 117 | </select> | 119 | </select> |
| 118 | </div> | 120 | </div> |
| 119 | <div> | 121 | <div> |
| 120 | - <label className="block text-sm font-medium text-slate-700">Currency</label> | 122 | + <label className="block text-sm font-medium text-slate-700">{t('label.currency')}</label> |
| 121 | <input | 123 | <input |
| 122 | type="text" | 124 | type="text" |
| 123 | value={currencyCode} | 125 | value={currencyCode} |
| @@ -129,9 +131,9 @@ export function CreateSalesOrderPage() { | @@ -129,9 +131,9 @@ export function CreateSalesOrderPage() { | ||
| 129 | 131 | ||
| 130 | <div> | 132 | <div> |
| 131 | <div className="flex items-center justify-between mb-2"> | 133 | <div className="flex items-center justify-between mb-2"> |
| 132 | - <label className="text-sm font-medium text-slate-700">Order lines</label> | 134 | + <label className="text-sm font-medium text-slate-700">{t('label.orderLines')}</label> |
| 133 | <button type="button" className="btn-secondary text-xs" onClick={addLine}> | 135 | <button type="button" className="btn-secondary text-xs" onClick={addLine}> |
| 134 | - + Add line | 136 | + {t('action.addLine')} |
| 135 | </button> | 137 | </button> |
| 136 | </div> | 138 | </div> |
| 137 | <div className="space-y-2"> | 139 | <div className="space-y-2"> |
| @@ -143,7 +145,7 @@ export function CreateSalesOrderPage() { | @@ -143,7 +145,7 @@ export function CreateSalesOrderPage() { | ||
| 143 | onChange={(e) => updateLine(idx, 'itemCode', e.target.value)} | 145 | onChange={(e) => updateLine(idx, 'itemCode', e.target.value)} |
| 144 | className="flex-1 rounded-md border border-slate-300 px-2 py-1.5 text-sm" | 146 | className="flex-1 rounded-md border border-slate-300 px-2 py-1.5 text-sm" |
| 145 | > | 147 | > |
| 146 | - <option value="">Select item...</option> | 148 | + <option value="">{t('action.selectItem')}</option> |
| 147 | {items.map((it) => ( | 149 | {items.map((it) => ( |
| 148 | <option key={it.id} value={it.code}> | 150 | <option key={it.id} value={it.code}> |
| 149 | {it.code} — {it.name} | 151 | {it.code} — {it.name} |
| @@ -154,7 +156,7 @@ export function CreateSalesOrderPage() { | @@ -154,7 +156,7 @@ export function CreateSalesOrderPage() { | ||
| 154 | type="number" | 156 | type="number" |
| 155 | min="1" | 157 | min="1" |
| 156 | step="1" | 158 | step="1" |
| 157 | - placeholder="Qty" | 159 | + placeholder={t('label.qty')} |
| 158 | value={line.quantity} | 160 | value={line.quantity} |
| 159 | onChange={(e) => updateLine(idx, 'quantity', e.target.value)} | 161 | onChange={(e) => updateLine(idx, 'quantity', e.target.value)} |
| 160 | className="w-20 rounded-md border border-slate-300 px-2 py-1.5 text-sm text-right" | 162 | className="w-20 rounded-md border border-slate-300 px-2 py-1.5 text-sm text-right" |
| @@ -163,7 +165,7 @@ export function CreateSalesOrderPage() { | @@ -163,7 +165,7 @@ export function CreateSalesOrderPage() { | ||
| 163 | type="number" | 165 | type="number" |
| 164 | min="0" | 166 | min="0" |
| 165 | step="0.01" | 167 | step="0.01" |
| 166 | - placeholder="Price" | 168 | + placeholder={t('label.unitPrice')} |
| 167 | value={line.unitPrice} | 169 | value={line.unitPrice} |
| 168 | onChange={(e) => updateLine(idx, 'unitPrice', e.target.value)} | 170 | onChange={(e) => updateLine(idx, 'unitPrice', e.target.value)} |
| 169 | className="w-24 rounded-md border border-slate-300 px-2 py-1.5 text-sm text-right" | 171 | className="w-24 rounded-md border border-slate-300 px-2 py-1.5 text-sm text-right" |
| @@ -172,7 +174,7 @@ export function CreateSalesOrderPage() { | @@ -172,7 +174,7 @@ export function CreateSalesOrderPage() { | ||
| 172 | type="button" | 174 | type="button" |
| 173 | className="text-slate-400 hover:text-rose-500" | 175 | className="text-slate-400 hover:text-rose-500" |
| 174 | onClick={() => removeLine(idx)} | 176 | onClick={() => removeLine(idx)} |
| 175 | - title="Remove line" | 177 | + title={t('action.delete')} |
| 176 | > | 178 | > |
| 177 | × | 179 | × |
| 178 | </button> | 180 | </button> |
| @@ -191,10 +193,10 @@ export function CreateSalesOrderPage() { | @@ -191,10 +193,10 @@ export function CreateSalesOrderPage() { | ||
| 191 | 193 | ||
| 192 | <div className="flex items-center gap-3 pt-2"> | 194 | <div className="flex items-center gap-3 pt-2"> |
| 193 | <button type="submit" className="btn-primary" disabled={submitting}> | 195 | <button type="submit" className="btn-primary" disabled={submitting}> |
| 194 | - {submitting ? 'Creating...' : 'Create Sales Order'} | 196 | + {submitting ? t('action.creating') : t('page.createSalesOrder.submit')} |
| 195 | </button> | 197 | </button> |
| 196 | <span className="text-xs text-slate-400"> | 198 | <span className="text-xs text-slate-400"> |
| 197 | - After creation, confirm the order to auto-generate work orders. | 199 | + {t('page.createSalesOrder.afterHint')} |
| 198 | </span> | 200 | </span> |
| 199 | </div> | 201 | </div> |
| 200 | </form> | 202 | </form> |
web/src/pages/CreateUserPage.tsx
| @@ -3,9 +3,11 @@ import { useNavigate } from 'react-router-dom' | @@ -3,9 +3,11 @@ import { useNavigate } from 'react-router-dom' | ||
| 3 | import { identity } from '@/api/client' | 3 | import { identity } from '@/api/client' |
| 4 | import { PageHeader } from '@/components/PageHeader' | 4 | import { PageHeader } from '@/components/PageHeader' |
| 5 | import { ErrorBox } from '@/components/ErrorBox' | 5 | import { ErrorBox } from '@/components/ErrorBox' |
| 6 | +import { useT } from '@/i18n/LocaleContext' | ||
| 6 | 7 | ||
| 7 | export function CreateUserPage() { | 8 | export function CreateUserPage() { |
| 8 | const navigate = useNavigate() | 9 | const navigate = useNavigate() |
| 10 | + const t = useT() | ||
| 9 | const [username, setUsername] = useState('') | 11 | const [username, setUsername] = useState('') |
| 10 | const [displayName, setDisplayName] = useState('') | 12 | const [displayName, setDisplayName] = useState('') |
| 11 | const [email, setEmail] = useState('') | 13 | const [email, setEmail] = useState('') |
| @@ -31,29 +33,29 @@ export function CreateUserPage() { | @@ -31,29 +33,29 @@ export function CreateUserPage() { | ||
| 31 | return ( | 33 | return ( |
| 32 | <div> | 34 | <div> |
| 33 | <PageHeader | 35 | <PageHeader |
| 34 | - title="New User" | ||
| 35 | - subtitle="Create a user account. Assign roles on the detail page after creation." | ||
| 36 | - actions={<button className="btn-secondary" onClick={() => navigate('/users')}>Cancel</button>} | 36 | + title={t('page.createUser.title')} |
| 37 | + subtitle={t('page.createUser.subtitle')} | ||
| 38 | + actions={<button className="btn-secondary" onClick={() => navigate('/users')}>{t('action.cancel')}</button>} | ||
| 37 | /> | 39 | /> |
| 38 | <form onSubmit={onSubmit} className="card p-6 space-y-4 max-w-lg"> | 40 | <form onSubmit={onSubmit} className="card p-6 space-y-4 max-w-lg"> |
| 39 | <div> | 41 | <div> |
| 40 | - <label className="block text-sm font-medium text-slate-700">Username</label> | 42 | + <label className="block text-sm font-medium text-slate-700">{t('label.username')}</label> |
| 41 | <input type="text" required value={username} onChange={(e) => setUsername(e.target.value)} | 43 | <input type="text" required value={username} onChange={(e) => setUsername(e.target.value)} |
| 42 | placeholder="jdoe" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> | 44 | placeholder="jdoe" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 43 | </div> | 45 | </div> |
| 44 | <div> | 46 | <div> |
| 45 | - <label className="block text-sm font-medium text-slate-700">Display name</label> | 47 | + <label className="block text-sm font-medium text-slate-700">{t('label.displayName')}</label> |
| 46 | <input type="text" required value={displayName} onChange={(e) => setDisplayName(e.target.value)} | 48 | <input type="text" required value={displayName} onChange={(e) => setDisplayName(e.target.value)} |
| 47 | placeholder="Jane Doe" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> | 49 | placeholder="Jane Doe" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 48 | </div> | 50 | </div> |
| 49 | <div> | 51 | <div> |
| 50 | - <label className="block text-sm font-medium text-slate-700">Email (optional)</label> | 52 | + <label className="block text-sm font-medium text-slate-700">{t('label.emailOptional')}</label> |
| 51 | <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} | 53 | <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} |
| 52 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> | 54 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 53 | </div> | 55 | </div> |
| 54 | {error && <ErrorBox error={error} />} | 56 | {error && <ErrorBox error={error} />} |
| 55 | <button type="submit" className="btn-primary" disabled={submitting}> | 57 | <button type="submit" className="btn-primary" disabled={submitting}> |
| 56 | - {submitting ? 'Creating...' : 'Create User'} | 58 | + {submitting ? t('action.creating') : t('page.createUser.submit')} |
| 57 | </button> | 59 | </button> |
| 58 | </form> | 60 | </form> |
| 59 | </div> | 61 | </div> |
web/src/pages/CreateWorkOrderPage.tsx
| @@ -5,12 +5,14 @@ import type { Item, Location } from '@/types/api' | @@ -5,12 +5,14 @@ import type { Item, Location } from '@/types/api' | ||
| 5 | import { PageHeader } from '@/components/PageHeader' | 5 | import { PageHeader } from '@/components/PageHeader' |
| 6 | import { ErrorBox } from '@/components/ErrorBox' | 6 | import { ErrorBox } from '@/components/ErrorBox' |
| 7 | import { DynamicExtFields } from '@/components/DynamicExtFields' | 7 | import { DynamicExtFields } from '@/components/DynamicExtFields' |
| 8 | +import { useT } from '@/i18n/LocaleContext' | ||
| 8 | 9 | ||
| 9 | interface BomLine { itemCode: string; quantityPerUnit: string; sourceLocationCode: string } | 10 | interface BomLine { itemCode: string; quantityPerUnit: string; sourceLocationCode: string } |
| 10 | interface OpLine { operationCode: string; workCenter: string; standardMinutes: string } | 11 | interface OpLine { operationCode: string; workCenter: string; standardMinutes: string } |
| 11 | 12 | ||
| 12 | export function CreateWorkOrderPage() { | 13 | export function CreateWorkOrderPage() { |
| 13 | const navigate = useNavigate() | 14 | const navigate = useNavigate() |
| 15 | + const t = useT() | ||
| 14 | const [code, setCode] = useState('') | 16 | const [code, setCode] = useState('') |
| 15 | const [outputItemCode, setOutputItemCode] = useState('') | 17 | const [outputItemCode, setOutputItemCode] = useState('') |
| 16 | const [outputQuantity, setOutputQuantity] = useState('') | 18 | const [outputQuantity, setOutputQuantity] = useState('') |
| @@ -77,55 +79,55 @@ export function CreateWorkOrderPage() { | @@ -77,55 +79,55 @@ export function CreateWorkOrderPage() { | ||
| 77 | return ( | 79 | return ( |
| 78 | <div> | 80 | <div> |
| 79 | <PageHeader | 81 | <PageHeader |
| 80 | - title="New Work Order" | ||
| 81 | - subtitle="Create a production work order with optional BOM inputs and routing operations." | ||
| 82 | - actions={<button className="btn-secondary" onClick={() => navigate('/work-orders')}>Cancel</button>} | 82 | + title={t('page.createWorkOrder.title')} |
| 83 | + subtitle={t('page.createWorkOrder.subtitle')} | ||
| 84 | + actions={<button className="btn-secondary" onClick={() => navigate('/work-orders')}>{t('action.cancel')}</button>} | ||
| 83 | /> | 85 | /> |
| 84 | <form onSubmit={onSubmit} className="card p-6 space-y-5 max-w-3xl"> | 86 | <form onSubmit={onSubmit} className="card p-6 space-y-5 max-w-3xl"> |
| 85 | <div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> | 87 | <div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> |
| 86 | <div> | 88 | <div> |
| 87 | - <label className="block text-sm font-medium text-slate-700">WO code</label> | 89 | + <label className="block text-sm font-medium text-slate-700">{t('label.woCode')}</label> |
| 88 | <input type="text" required value={code} onChange={(e) => setCode(e.target.value)} | 90 | <input type="text" required value={code} onChange={(e) => setCode(e.target.value)} |
| 89 | placeholder="WO-PRINT-0002" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> | 91 | placeholder="WO-PRINT-0002" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 90 | </div> | 92 | </div> |
| 91 | <div> | 93 | <div> |
| 92 | - <label className="block text-sm font-medium text-slate-700">Output item</label> | 94 | + <label className="block text-sm font-medium text-slate-700">{t('label.outputItem')}</label> |
| 93 | <select required value={outputItemCode} onChange={(e) => setOutputItemCode(e.target.value)} | 95 | <select required value={outputItemCode} onChange={(e) => setOutputItemCode(e.target.value)} |
| 94 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"> | 96 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"> |
| 95 | - <option value="">Select item...</option> | 97 | + <option value="">{t('action.selectItem')}</option> |
| 96 | {items.map((it) => <option key={it.id} value={it.code}>{it.code} — {it.name}</option>)} | 98 | {items.map((it) => <option key={it.id} value={it.code}>{it.code} — {it.name}</option>)} |
| 97 | </select> | 99 | </select> |
| 98 | </div> | 100 | </div> |
| 99 | <div> | 101 | <div> |
| 100 | - <label className="block text-sm font-medium text-slate-700">Output qty</label> | 102 | + <label className="block text-sm font-medium text-slate-700">{t('label.outputQty')}</label> |
| 101 | <input type="number" required min="1" step="1" value={outputQuantity} | 103 | <input type="number" required min="1" step="1" value={outputQuantity} |
| 102 | onChange={(e) => setOutputQuantity(e.target.value)} | 104 | onChange={(e) => setOutputQuantity(e.target.value)} |
| 103 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm text-right" /> | 105 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm text-right" /> |
| 104 | </div> | 106 | </div> |
| 105 | </div> | 107 | </div> |
| 106 | <div> | 108 | <div> |
| 107 | - <label className="block text-sm font-medium text-slate-700">Due date (optional)</label> | 109 | + <label className="block text-sm font-medium text-slate-700">{t('label.dueDate')}</label> |
| 108 | <input type="date" value={dueDate} onChange={(e) => setDueDate(e.target.value)} | 110 | <input type="date" value={dueDate} onChange={(e) => setDueDate(e.target.value)} |
| 109 | className="mt-1 max-w-xs rounded-md border border-slate-300 px-3 py-2 text-sm" /> | 111 | className="mt-1 max-w-xs rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 110 | </div> | 112 | </div> |
| 111 | 113 | ||
| 112 | - {/* ─── BOM inputs ─────────────────────────────────────── */} | 114 | + {/* BOM inputs */} |
| 113 | <div> | 115 | <div> |
| 114 | <div className="flex items-center justify-between mb-2"> | 116 | <div className="flex items-center justify-between mb-2"> |
| 115 | - <label className="text-sm font-medium text-slate-700">BOM inputs (materials consumed per unit of output)</label> | ||
| 116 | - <button type="button" className="btn-secondary text-xs" onClick={addBom}>+ Add input</button> | 117 | + <label className="text-sm font-medium text-slate-700">{t('label.bomInputsDesc')}</label> |
| 118 | + <button type="button" className="btn-secondary text-xs" onClick={addBom}>{t('action.addInput')}</button> | ||
| 117 | </div> | 119 | </div> |
| 118 | - {bom.length === 0 && <p className="text-xs text-slate-400">No BOM lines. Output will be produced without consuming materials.</p>} | 120 | + {bom.length === 0 && <p className="text-xs text-slate-400">{t('label.noBomHint')}</p>} |
| 119 | <div className="space-y-2"> | 121 | <div className="space-y-2"> |
| 120 | {bom.map((b, idx) => ( | 122 | {bom.map((b, idx) => ( |
| 121 | <div key={idx} className="flex items-center gap-2"> | 123 | <div key={idx} className="flex items-center gap-2"> |
| 122 | <span className="w-6 text-xs text-slate-400 text-right">{idx + 1}</span> | 124 | <span className="w-6 text-xs text-slate-400 text-right">{idx + 1}</span> |
| 123 | <select value={b.itemCode} onChange={(e) => updateBom(idx, 'itemCode', e.target.value)} | 125 | <select value={b.itemCode} onChange={(e) => updateBom(idx, 'itemCode', e.target.value)} |
| 124 | className="flex-1 rounded-md border border-slate-300 px-2 py-1.5 text-sm"> | 126 | className="flex-1 rounded-md border border-slate-300 px-2 py-1.5 text-sm"> |
| 125 | - <option value="">Item...</option> | 127 | + <option value="">{t('label.item')}...</option> |
| 126 | {items.map((it) => <option key={it.id} value={it.code}>{it.code}</option>)} | 128 | {items.map((it) => <option key={it.id} value={it.code}>{it.code}</option>)} |
| 127 | </select> | 129 | </select> |
| 128 | - <input type="number" min="0.01" step="0.01" placeholder="Qty/unit" value={b.quantityPerUnit} | 130 | + <input type="number" min="0.01" step="0.01" placeholder={t('label.qtyPerUnit')} value={b.quantityPerUnit} |
| 129 | onChange={(e) => updateBom(idx, 'quantityPerUnit', e.target.value)} | 131 | onChange={(e) => updateBom(idx, 'quantityPerUnit', e.target.value)} |
| 130 | className="w-24 rounded-md border border-slate-300 px-2 py-1.5 text-sm text-right" /> | 132 | className="w-24 rounded-md border border-slate-300 px-2 py-1.5 text-sm text-right" /> |
| 131 | <select value={b.sourceLocationCode} onChange={(e) => updateBom(idx, 'sourceLocationCode', e.target.value)} | 133 | <select value={b.sourceLocationCode} onChange={(e) => updateBom(idx, 'sourceLocationCode', e.target.value)} |
| @@ -138,24 +140,24 @@ export function CreateWorkOrderPage() { | @@ -138,24 +140,24 @@ export function CreateWorkOrderPage() { | ||
| 138 | </div> | 140 | </div> |
| 139 | </div> | 141 | </div> |
| 140 | 142 | ||
| 141 | - {/* ─── Routing operations ─────────────────────────────── */} | 143 | + {/* Routing operations */} |
| 142 | <div> | 144 | <div> |
| 143 | <div className="flex items-center justify-between mb-2"> | 145 | <div className="flex items-center justify-between mb-2"> |
| 144 | - <label className="text-sm font-medium text-slate-700">Routing operations (sequential steps)</label> | ||
| 145 | - <button type="button" className="btn-secondary text-xs" onClick={addOp}>+ Add operation</button> | 146 | + <label className="text-sm font-medium text-slate-700">{t('label.routingOpsDesc')}</label> |
| 147 | + <button type="button" className="btn-secondary text-xs" onClick={addOp}>{t('action.addOperation')}</button> | ||
| 146 | </div> | 148 | </div> |
| 147 | - {ops.length === 0 && <p className="text-xs text-slate-400">No routing. Work order completes in one step.</p>} | 149 | + {ops.length === 0 && <p className="text-xs text-slate-400">{t('label.noRoutingHint')}</p>} |
| 148 | <div className="space-y-2"> | 150 | <div className="space-y-2"> |
| 149 | {ops.map((o, idx) => ( | 151 | {ops.map((o, idx) => ( |
| 150 | <div key={idx} className="flex items-center gap-2"> | 152 | <div key={idx} className="flex items-center gap-2"> |
| 151 | <span className="w-6 text-xs text-slate-400 text-right">{idx + 1}</span> | 153 | <span className="w-6 text-xs text-slate-400 text-right">{idx + 1}</span> |
| 152 | - <input type="text" required placeholder="Op code (e.g. CTP)" value={o.operationCode} | 154 | + <input type="text" required placeholder={t('label.operation')} value={o.operationCode} |
| 153 | onChange={(e) => updateOp(idx, 'operationCode', e.target.value)} | 155 | onChange={(e) => updateOp(idx, 'operationCode', e.target.value)} |
| 154 | className="w-28 rounded-md border border-slate-300 px-2 py-1.5 text-sm" /> | 156 | className="w-28 rounded-md border border-slate-300 px-2 py-1.5 text-sm" /> |
| 155 | - <input type="text" required placeholder="Work center" value={o.workCenter} | 157 | + <input type="text" required placeholder={t('label.workCenter')} value={o.workCenter} |
| 156 | onChange={(e) => updateOp(idx, 'workCenter', e.target.value)} | 158 | onChange={(e) => updateOp(idx, 'workCenter', e.target.value)} |
| 157 | className="flex-1 rounded-md border border-slate-300 px-2 py-1.5 text-sm" /> | 159 | className="flex-1 rounded-md border border-slate-300 px-2 py-1.5 text-sm" /> |
| 158 | - <input type="number" min="1" step="1" placeholder="Std min" value={o.standardMinutes} | 160 | + <input type="number" min="1" step="1" placeholder={t('label.stdMin')} value={o.standardMinutes} |
| 159 | onChange={(e) => updateOp(idx, 'standardMinutes', e.target.value)} | 161 | onChange={(e) => updateOp(idx, 'standardMinutes', e.target.value)} |
| 160 | className="w-20 rounded-md border border-slate-300 px-2 py-1.5 text-sm text-right" /> | 162 | className="w-20 rounded-md border border-slate-300 px-2 py-1.5 text-sm text-right" /> |
| 161 | <button type="button" className="text-slate-400 hover:text-rose-500" onClick={() => removeOp(idx)}>×</button> | 163 | <button type="button" className="text-slate-400 hover:text-rose-500" onClick={() => removeOp(idx)}>×</button> |
| @@ -171,7 +173,7 @@ export function CreateWorkOrderPage() { | @@ -171,7 +173,7 @@ export function CreateWorkOrderPage() { | ||
| 171 | /> | 173 | /> |
| 172 | {error && <ErrorBox error={error} />} | 174 | {error && <ErrorBox error={error} />} |
| 173 | <button type="submit" className="btn-primary" disabled={submitting}> | 175 | <button type="submit" className="btn-primary" disabled={submitting}> |
| 174 | - {submitting ? 'Creating...' : 'Create Work Order'} | 176 | + {submitting ? t('action.creating') : t('page.createWorkOrder.submit')} |
| 175 | </button> | 177 | </button> |
| 176 | </form> | 178 | </form> |
| 177 | </div> | 179 | </div> |
web/src/pages/DashboardPage.tsx
| @@ -13,6 +13,7 @@ import { PageHeader } from '@/components/PageHeader' | @@ -13,6 +13,7 @@ import { PageHeader } from '@/components/PageHeader' | ||
| 13 | import { Loading } from '@/components/Loading' | 13 | import { Loading } from '@/components/Loading' |
| 14 | import { ErrorBox } from '@/components/ErrorBox' | 14 | import { ErrorBox } from '@/components/ErrorBox' |
| 15 | import { useAuth } from '@/auth/AuthContext' | 15 | import { useAuth } from '@/auth/AuthContext' |
| 16 | +import { useT } from '@/i18n/LocaleContext' | ||
| 16 | 17 | ||
| 17 | interface DashboardCounts { | 18 | interface DashboardCounts { |
| 18 | items: number | 19 | items: number |
| @@ -27,6 +28,7 @@ interface DashboardCounts { | @@ -27,6 +28,7 @@ interface DashboardCounts { | ||
| 27 | 28 | ||
| 28 | export function DashboardPage() { | 29 | export function DashboardPage() { |
| 29 | const { username } = useAuth() | 30 | const { username } = useAuth() |
| 31 | + const t = useT() | ||
| 30 | const [counts, setCounts] = useState<DashboardCounts | null>(null) | 32 | const [counts, setCounts] = useState<DashboardCounts | null>(null) |
| 31 | const [error, setError] = useState<Error | null>(null) | 33 | const [error, setError] = useState<Error | null>(null) |
| 32 | const [loading, setLoading] = useState(true) | 34 | const [loading, setLoading] = useState(true) |
| @@ -69,73 +71,68 @@ export function DashboardPage() { | @@ -69,73 +71,68 @@ export function DashboardPage() { | ||
| 69 | return ( | 71 | return ( |
| 70 | <div> | 72 | <div> |
| 71 | <PageHeader | 73 | <PageHeader |
| 72 | - title={`Welcome${username ? ', ' + username : ''}`} | ||
| 73 | - subtitle="The framework's buy-make-sell loop, end to end through the same Postgres." | 74 | + title={`${t('page.dashboard.welcome')}${username ? ', ' + username : ''}`} |
| 75 | + subtitle={t('page.dashboard.subtitle')} | ||
| 74 | /> | 76 | /> |
| 75 | {loading && <Loading />} | 77 | {loading && <Loading />} |
| 76 | {error && <ErrorBox error={error} />} | 78 | {error && <ErrorBox error={error} />} |
| 77 | {counts && ( | 79 | {counts && ( |
| 78 | <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4"> | 80 | <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4"> |
| 79 | - <DashboardCard label="Items" value={counts.items} to="/items" /> | ||
| 80 | - <DashboardCard label="Partners" value={counts.partners} to="/partners" /> | ||
| 81 | - <DashboardCard label="Locations" value={counts.locations} to="/locations" /> | 81 | + <DashboardCard label={t('page.dashboard.cardItems')} value={counts.items} to="/items" /> |
| 82 | + <DashboardCard label={t('page.dashboard.cardPartners')} value={counts.partners} to="/partners" /> | ||
| 83 | + <DashboardCard label={t('page.dashboard.cardLocations')} value={counts.locations} to="/locations" /> | ||
| 82 | <DashboardCard | 84 | <DashboardCard |
| 83 | - label="Work orders in progress" | 85 | + label={t('page.dashboard.cardWoInProgress')} |
| 84 | value={counts.inProgressWorkOrders} | 86 | value={counts.inProgressWorkOrders} |
| 85 | to="/shop-floor" | 87 | to="/shop-floor" |
| 86 | highlight | 88 | highlight |
| 87 | /> | 89 | /> |
| 88 | - <DashboardCard label="Sales orders" value={counts.salesOrders} to="/sales-orders" /> | 90 | + <DashboardCard label={t('page.dashboard.cardSalesOrders')} value={counts.salesOrders} to="/sales-orders" /> |
| 89 | <DashboardCard | 91 | <DashboardCard |
| 90 | - label="Purchase orders" | 92 | + label={t('page.dashboard.cardPurchaseOrders')} |
| 91 | value={counts.purchaseOrders} | 93 | value={counts.purchaseOrders} |
| 92 | to="/purchase-orders" | 94 | to="/purchase-orders" |
| 93 | /> | 95 | /> |
| 94 | - <DashboardCard label="Work orders" value={counts.workOrders} to="/work-orders" /> | 96 | + <DashboardCard label={t('page.dashboard.cardWorkOrders')} value={counts.workOrders} to="/work-orders" /> |
| 95 | <DashboardCard | 97 | <DashboardCard |
| 96 | - label="Journal entries" | 98 | + label={t('page.dashboard.cardJournalEntries')} |
| 97 | value={counts.journalEntries} | 99 | value={counts.journalEntries} |
| 98 | to="/journal-entries" | 100 | to="/journal-entries" |
| 99 | /> | 101 | /> |
| 100 | </div> | 102 | </div> |
| 101 | )} | 103 | )} |
| 102 | <div className="mt-8 card p-5 text-sm text-slate-600"> | 104 | <div className="mt-8 card p-5 text-sm text-slate-600"> |
| 103 | - <h2 className="mb-2 text-base font-semibold text-slate-800">Getting started</h2> | 105 | + <h2 className="mb-2 text-base font-semibold text-slate-800">{t('page.dashboard.gettingStarted')}</h2> |
| 104 | <p className="mb-3 text-slate-500"> | 106 | <p className="mb-3 text-slate-500"> |
| 105 | - The framework's buy-make-sell loop, end to end. | 107 | + {t('page.dashboard.gettingStartedDesc')} |
| 106 | </p> | 108 | </p> |
| 107 | <ol className="list-decimal space-y-2 pl-5"> | 109 | <ol className="list-decimal space-y-2 pl-5"> |
| 108 | <li> | 110 | <li> |
| 109 | - <strong>Set up master data</strong> — create{' '} | ||
| 110 | - <Link to="/items/new" className="text-brand-600 hover:underline">items</Link>,{' '} | ||
| 111 | - <Link to="/partners/new" className="text-brand-600 hover:underline">partners</Link>, and{' '} | ||
| 112 | - <Link to="/locations/new" className="text-brand-600 hover:underline">locations</Link>. | ||
| 113 | - Then{' '} | ||
| 114 | - <Link to="/balances/adjust" className="text-brand-600 hover:underline">adjust stock</Link>{' '} | ||
| 115 | - to set opening balances. | 111 | + <strong>{t('page.dashboard.step1')}</strong> — {t('page.dashboard.step1Desc')}{' '} |
| 112 | + <Link to="/items/new" className="text-brand-600 hover:underline">{t('page.dashboard.step1DescItems')}</Link>,{' '} | ||
| 113 | + <Link to="/partners/new" className="text-brand-600 hover:underline">{t('page.dashboard.step1DescPartners')}</Link>{t('page.dashboard.step1DescAnd')}{' '} | ||
| 114 | + <Link to="/locations/new" className="text-brand-600 hover:underline">{t('page.dashboard.step1DescLocations')}</Link>{t('page.dashboard.step1DescThen')}{' '} | ||
| 115 | + <Link to="/balances/adjust" className="text-brand-600 hover:underline">{t('page.dashboard.step1DescAdjust')}</Link>{' '} | ||
| 116 | + {t('page.dashboard.step1DescEnd')} | ||
| 116 | </li> | 117 | </li> |
| 117 | <li> | 118 | <li> |
| 118 | - <strong>Create a sales order</strong> —{' '} | ||
| 119 | - <Link to="/sales-orders/new" className="text-brand-600 hover:underline">new order</Link>{' '} | ||
| 120 | - with line items. Confirm it — the system auto-generates production work orders and | ||
| 121 | - posts an AR journal entry with double-entry lines (DR Accounts Receivable, CR Revenue). | 119 | + <strong>{t('page.dashboard.step2')}</strong> —{' '} |
| 120 | + <Link to="/sales-orders/new" className="text-brand-600 hover:underline">{t('page.dashboard.step2Link')}</Link>{' '} | ||
| 121 | + {t('page.dashboard.step2Desc')} | ||
| 122 | </li> | 122 | </li> |
| 123 | <li> | 123 | <li> |
| 124 | - <strong>Walk the work order</strong> — start it, walk routing operations on the{' '} | ||
| 125 | - <Link to="/shop-floor" className="text-brand-600 hover:underline">Shop Floor</Link>, | ||
| 126 | - then complete it. Materials are consumed, finished goods credited. | 124 | + <strong>{t('page.dashboard.step3')}</strong> — {t('page.dashboard.step3Desc1')}{' '} |
| 125 | + <Link to="/shop-floor" className="text-brand-600 hover:underline">{t('page.dashboard.step3ShopFloor')}</Link>{t('page.dashboard.step3Desc2')} | ||
| 127 | </li> | 126 | </li> |
| 128 | <li> | 127 | <li> |
| 129 | - <strong>Ship the sales order</strong> — stock leaves the warehouse, the AR journal | ||
| 130 | - entry settles. View the ledger in{' '} | ||
| 131 | - <Link to="/movements" className="text-brand-600 hover:underline">Movements</Link>{' '} | ||
| 132 | - and double-entry lines in{' '} | ||
| 133 | - <Link to="/journal-entries" className="text-brand-600 hover:underline">Journal Entries</Link>. | 128 | + <strong>{t('page.dashboard.step4')}</strong> — {t('page.dashboard.step4Desc1')}{' '} |
| 129 | + <Link to="/movements" className="text-brand-600 hover:underline">{t('page.dashboard.step4Movements')}</Link>{' '} | ||
| 130 | + {t('page.dashboard.step4Desc2')}{' '} | ||
| 131 | + <Link to="/journal-entries" className="text-brand-600 hover:underline">{t('page.dashboard.step4JE')}</Link>. | ||
| 134 | </li> | 132 | </li> |
| 135 | <li> | 133 | <li> |
| 136 | - <strong>Restock via purchase</strong> — create a{' '} | ||
| 137 | - <Link to="/purchase-orders/new" className="text-brand-600 hover:underline">purchase order</Link>, | ||
| 138 | - confirm, and receive into a warehouse. AP journal entry posts and settles. | 134 | + <strong>{t('page.dashboard.step5')}</strong> — {t('page.dashboard.step5Desc1')}{' '} |
| 135 | + <Link to="/purchase-orders/new" className="text-brand-600 hover:underline">{t('page.dashboard.step5Link')}</Link>{t('page.dashboard.step5Desc2')} | ||
| 139 | </li> | 136 | </li> |
| 140 | </ol> | 137 | </ol> |
| 141 | </div> | 138 | </div> |
web/src/pages/EditItemPage.tsx
| @@ -6,12 +6,14 @@ import { PageHeader } from '@/components/PageHeader' | @@ -6,12 +6,14 @@ import { PageHeader } from '@/components/PageHeader' | ||
| 6 | import { Loading } from '@/components/Loading' | 6 | import { Loading } from '@/components/Loading' |
| 7 | import { ErrorBox } from '@/components/ErrorBox' | 7 | import { ErrorBox } from '@/components/ErrorBox' |
| 8 | import { DynamicExtFields } from '@/components/DynamicExtFields' | 8 | import { DynamicExtFields } from '@/components/DynamicExtFields' |
| 9 | +import { useT } from '@/i18n/LocaleContext' | ||
| 9 | 10 | ||
| 10 | const ITEM_TYPES = ['GOOD', 'SERVICE', 'DIGITAL'] as const | 11 | const ITEM_TYPES = ['GOOD', 'SERVICE', 'DIGITAL'] as const |
| 11 | 12 | ||
| 12 | export function EditItemPage() { | 13 | export function EditItemPage() { |
| 13 | const { id = '' } = useParams<{ id: string }>() | 14 | const { id = '' } = useParams<{ id: string }>() |
| 14 | const navigate = useNavigate() | 15 | const navigate = useNavigate() |
| 16 | + const t = useT() | ||
| 15 | const [item, setItem] = useState<Item | null>(null) | 17 | const [item, setItem] = useState<Item | null>(null) |
| 16 | const [name, setName] = useState('') | 18 | const [name, setName] = useState('') |
| 17 | const [description, setDescription] = useState('') | 19 | const [description, setDescription] = useState('') |
| @@ -60,39 +62,39 @@ export function EditItemPage() { | @@ -60,39 +62,39 @@ export function EditItemPage() { | ||
| 60 | return ( | 62 | return ( |
| 61 | <div> | 63 | <div> |
| 62 | <PageHeader | 64 | <PageHeader |
| 63 | - title={`Edit ${item.code}`} | ||
| 64 | - subtitle={`Base UoM: ${item.baseUomCode} (read-only after creation)`} | ||
| 65 | - actions={<button className="btn-secondary" onClick={() => navigate('/items')}>Cancel</button>} | 65 | + title={t('page.editItem.title').replace('{code}', item.code)} |
| 66 | + subtitle={t('page.editItem.subtitle').replace('{uom}', item.baseUomCode)} | ||
| 67 | + actions={<button className="btn-secondary" onClick={() => navigate('/items')}>{t('action.cancel')}</button>} | ||
| 66 | /> | 68 | /> |
| 67 | <form onSubmit={onSubmit} className="card p-6 space-y-4 max-w-2xl"> | 69 | <form onSubmit={onSubmit} className="card p-6 space-y-4 max-w-2xl"> |
| 68 | <div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> | 70 | <div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> |
| 69 | <div> | 71 | <div> |
| 70 | - <label className="block text-sm font-medium text-slate-700">Name</label> | 72 | + <label className="block text-sm font-medium text-slate-700">{t('label.name')}</label> |
| 71 | <input type="text" required value={name} onChange={(e) => setName(e.target.value)} | 73 | <input type="text" required value={name} onChange={(e) => setName(e.target.value)} |
| 72 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> | 74 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 73 | </div> | 75 | </div> |
| 74 | <div> | 76 | <div> |
| 75 | - <label className="block text-sm font-medium text-slate-700">Type</label> | 77 | + <label className="block text-sm font-medium text-slate-700">{t('label.type')}</label> |
| 76 | <select value={itemType} onChange={(e) => setItemType(e.target.value)} | 78 | <select value={itemType} onChange={(e) => setItemType(e.target.value)} |
| 77 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"> | 79 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"> |
| 78 | - {ITEM_TYPES.map((t) => <option key={t} value={t}>{t}</option>)} | 80 | + {ITEM_TYPES.map((it) => <option key={it} value={it}>{it}</option>)} |
| 79 | </select> | 81 | </select> |
| 80 | </div> | 82 | </div> |
| 81 | </div> | 83 | </div> |
| 82 | <div> | 84 | <div> |
| 83 | - <label className="block text-sm font-medium text-slate-700">Description</label> | 85 | + <label className="block text-sm font-medium text-slate-700">{t('label.description')}</label> |
| 84 | <input type="text" value={description} onChange={(e) => setDescription(e.target.value)} | 86 | <input type="text" value={description} onChange={(e) => setDescription(e.target.value)} |
| 85 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> | 87 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 86 | </div> | 88 | </div> |
| 87 | <div className="flex items-center gap-2"> | 89 | <div className="flex items-center gap-2"> |
| 88 | <input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} | 90 | <input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} |
| 89 | className="rounded border-slate-300" id="active" /> | 91 | className="rounded border-slate-300" id="active" /> |
| 90 | - <label htmlFor="active" className="text-sm text-slate-700">Active</label> | 92 | + <label htmlFor="active" className="text-sm text-slate-700">{t('label.active')}</label> |
| 91 | </div> | 93 | </div> |
| 92 | <DynamicExtFields entityName="Item" values={ext} onChange={(k, v) => setExt(prev => ({ ...prev, [k]: v }))} /> | 94 | <DynamicExtFields entityName="Item" values={ext} onChange={(k, v) => setExt(prev => ({ ...prev, [k]: v }))} /> |
| 93 | {error && <ErrorBox error={error} />} | 95 | {error && <ErrorBox error={error} />} |
| 94 | <button type="submit" className="btn-primary" disabled={submitting}> | 96 | <button type="submit" className="btn-primary" disabled={submitting}> |
| 95 | - {submitting ? 'Saving…' : 'Save Changes'} | 97 | + {submitting ? t('action.saving') : t('action.saveChanges')} |
| 96 | </button> | 98 | </button> |
| 97 | </form> | 99 | </form> |
| 98 | </div> | 100 | </div> |
web/src/pages/EditPartnerPage.tsx
| @@ -6,12 +6,14 @@ import { PageHeader } from '@/components/PageHeader' | @@ -6,12 +6,14 @@ import { PageHeader } from '@/components/PageHeader' | ||
| 6 | import { Loading } from '@/components/Loading' | 6 | import { Loading } from '@/components/Loading' |
| 7 | import { ErrorBox } from '@/components/ErrorBox' | 7 | import { ErrorBox } from '@/components/ErrorBox' |
| 8 | import { DynamicExtFields } from '@/components/DynamicExtFields' | 8 | import { DynamicExtFields } from '@/components/DynamicExtFields' |
| 9 | +import { useT } from '@/i18n/LocaleContext' | ||
| 9 | 10 | ||
| 10 | const PARTNER_TYPES = ['CUSTOMER', 'SUPPLIER', 'BOTH'] as const | 11 | const PARTNER_TYPES = ['CUSTOMER', 'SUPPLIER', 'BOTH'] as const |
| 11 | 12 | ||
| 12 | export function EditPartnerPage() { | 13 | export function EditPartnerPage() { |
| 13 | const { id = '' } = useParams<{ id: string }>() | 14 | const { id = '' } = useParams<{ id: string }>() |
| 14 | const navigate = useNavigate() | 15 | const navigate = useNavigate() |
| 16 | + const t = useT() | ||
| 15 | const [partner, setPartner] = useState<Partner | null>(null) | 17 | const [partner, setPartner] = useState<Partner | null>(null) |
| 16 | const [name, setName] = useState('') | 18 | const [name, setName] = useState('') |
| 17 | const [type, setType] = useState<string>('CUSTOMER') | 19 | const [type, setType] = useState<string>('CUSTOMER') |
| @@ -61,31 +63,31 @@ export function EditPartnerPage() { | @@ -61,31 +63,31 @@ export function EditPartnerPage() { | ||
| 61 | return ( | 63 | return ( |
| 62 | <div> | 64 | <div> |
| 63 | <PageHeader | 65 | <PageHeader |
| 64 | - title={`Edit ${partner.code}`} | ||
| 65 | - subtitle={`Partner code is read-only after creation`} | ||
| 66 | - actions={<button className="btn-secondary" onClick={() => navigate('/partners')}>Cancel</button>} | 66 | + title={t('page.editPartner.title').replace('{code}', partner.code)} |
| 67 | + subtitle={t('page.editPartner.subtitle')} | ||
| 68 | + actions={<button className="btn-secondary" onClick={() => navigate('/partners')}>{t('action.cancel')}</button>} | ||
| 67 | /> | 69 | /> |
| 68 | <form onSubmit={onSubmit} className="card p-6 space-y-4 max-w-2xl"> | 70 | <form onSubmit={onSubmit} className="card p-6 space-y-4 max-w-2xl"> |
| 69 | <div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> | 71 | <div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> |
| 70 | <div> | 72 | <div> |
| 71 | - <label className="block text-sm font-medium text-slate-700">Name</label> | 73 | + <label className="block text-sm font-medium text-slate-700">{t('label.name')}</label> |
| 72 | <input type="text" required value={name} onChange={(e) => setName(e.target.value)} | 74 | <input type="text" required value={name} onChange={(e) => setName(e.target.value)} |
| 73 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> | 75 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 74 | </div> | 76 | </div> |
| 75 | <div> | 77 | <div> |
| 76 | - <label className="block text-sm font-medium text-slate-700">Type</label> | 78 | + <label className="block text-sm font-medium text-slate-700">{t('label.type')}</label> |
| 77 | <select value={type} onChange={(e) => setType(e.target.value)} | 79 | <select value={type} onChange={(e) => setType(e.target.value)} |
| 78 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"> | 80 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"> |
| 79 | - {PARTNER_TYPES.map((t) => <option key={t} value={t}>{t}</option>)} | 81 | + {PARTNER_TYPES.map((pt) => <option key={pt} value={pt}>{pt}</option>)} |
| 80 | </select> | 82 | </select> |
| 81 | </div> | 83 | </div> |
| 82 | <div> | 84 | <div> |
| 83 | - <label className="block text-sm font-medium text-slate-700">Email</label> | 85 | + <label className="block text-sm font-medium text-slate-700">{t('label.email')}</label> |
| 84 | <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} | 86 | <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} |
| 85 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> | 87 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 86 | </div> | 88 | </div> |
| 87 | <div> | 89 | <div> |
| 88 | - <label className="block text-sm font-medium text-slate-700">Phone</label> | 90 | + <label className="block text-sm font-medium text-slate-700">{t('label.phone')}</label> |
| 89 | <input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} | 91 | <input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} |
| 90 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> | 92 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 91 | </div> | 93 | </div> |
| @@ -93,7 +95,7 @@ export function EditPartnerPage() { | @@ -93,7 +95,7 @@ export function EditPartnerPage() { | ||
| 93 | <DynamicExtFields entityName="Partner" values={ext} onChange={(k, v) => setExt(prev => ({ ...prev, [k]: v }))} /> | 95 | <DynamicExtFields entityName="Partner" values={ext} onChange={(k, v) => setExt(prev => ({ ...prev, [k]: v }))} /> |
| 94 | {error && <ErrorBox error={error} />} | 96 | {error && <ErrorBox error={error} />} |
| 95 | <button type="submit" className="btn-primary" disabled={submitting}> | 97 | <button type="submit" className="btn-primary" disabled={submitting}> |
| 96 | - {submitting ? 'Saving…' : 'Save Changes'} | 98 | + {submitting ? t('action.saving') : t('action.saveChanges')} |
| 97 | </button> | 99 | </button> |
| 98 | </form> | 100 | </form> |
| 99 | </div> | 101 | </div> |
web/src/pages/FormDesignerPage.tsx
| @@ -17,6 +17,7 @@ import { ErrorBox } from '@/components/ErrorBox' | @@ -17,6 +17,7 @@ import { ErrorBox } from '@/components/ErrorBox' | ||
| 17 | import { Loading } from '@/components/Loading' | 17 | import { Loading } from '@/components/Loading' |
| 18 | import { vibeWidgets } from '@/components/form-widgets' | 18 | import { vibeWidgets } from '@/components/form-widgets' |
| 19 | import { vibeTemplates } from '@/components/form-widgets/vibeErpTheme' | 19 | import { vibeTemplates } from '@/components/form-widgets/vibeErpTheme' |
| 20 | +import { useT } from '@/i18n/LocaleContext' | ||
| 20 | 21 | ||
| 21 | // ── Types ────────────────────────────────────────────────────────── | 22 | // ── Types ────────────────────────────────────────────────────────── |
| 22 | 23 | ||
| @@ -165,6 +166,7 @@ function hydrateFields(def: FormDefinition): DesignerField[] { | @@ -165,6 +166,7 @@ function hydrateFields(def: FormDefinition): DesignerField[] { | ||
| 165 | export function FormDesignerPage() { | 166 | export function FormDesignerPage() { |
| 166 | const { slug: routeSlug } = useParams<{ slug: string }>() | 167 | const { slug: routeSlug } = useParams<{ slug: string }>() |
| 167 | const navigate = useNavigate() | 168 | const navigate = useNavigate() |
| 169 | + const t = useT() | ||
| 168 | const isEdit = Boolean(routeSlug) | 170 | const isEdit = Boolean(routeSlug) |
| 169 | 171 | ||
| 170 | // ── Top bar state ── | 172 | // ── Top bar state ── |
| @@ -302,15 +304,15 @@ export function FormDesignerPage() { | @@ -302,15 +304,15 @@ export function FormDesignerPage() { | ||
| 302 | return ( | 304 | return ( |
| 303 | <div> | 305 | <div> |
| 304 | <PageHeader | 306 | <PageHeader |
| 305 | - title={isEdit ? 'Edit Form Definition' : 'New Form Definition'} | ||
| 306 | - subtitle="Design a metadata-driven form with a live preview." | 307 | + title={isEdit ? t('page.formDesigner.editTitle') : t('page.formDesigner.newTitle')} |
| 308 | + subtitle={t('page.formDesigner.subtitle')} | ||
| 307 | /> | 309 | /> |
| 308 | 310 | ||
| 309 | {/* ── Top bar ── */} | 311 | {/* ── Top bar ── */} |
| 310 | <div className="card p-4 mb-4"> | 312 | <div className="card p-4 mb-4"> |
| 311 | <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5"> | 313 | <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5"> |
| 312 | <div> | 314 | <div> |
| 313 | - <label className="block text-sm font-medium text-slate-700">Title</label> | 315 | + <label className="block text-sm font-medium text-slate-700">{t('label.title')}</label> |
| 314 | <input | 316 | <input |
| 315 | type="text" | 317 | type="text" |
| 316 | value={title} | 318 | value={title} |
| @@ -320,7 +322,7 @@ export function FormDesignerPage() { | @@ -320,7 +322,7 @@ export function FormDesignerPage() { | ||
| 320 | /> | 322 | /> |
| 321 | </div> | 323 | </div> |
| 322 | <div> | 324 | <div> |
| 323 | - <label className="block text-sm font-medium text-slate-700">Entity name</label> | 325 | + <label className="block text-sm font-medium text-slate-700">{t('label.entityName')}</label> |
| 324 | <input | 326 | <input |
| 325 | type="text" | 327 | type="text" |
| 326 | value={entityName} | 328 | value={entityName} |
| @@ -330,7 +332,7 @@ export function FormDesignerPage() { | @@ -330,7 +332,7 @@ export function FormDesignerPage() { | ||
| 330 | /> | 332 | /> |
| 331 | </div> | 333 | </div> |
| 332 | <div> | 334 | <div> |
| 333 | - <label className="block text-sm font-medium text-slate-700">Purpose</label> | 335 | + <label className="block text-sm font-medium text-slate-700">{t('label.purpose')}</label> |
| 334 | <select | 336 | <select |
| 335 | value={purpose} | 337 | value={purpose} |
| 336 | onChange={(e) => setPurpose(e.target.value as FormPurpose)} | 338 | onChange={(e) => setPurpose(e.target.value as FormPurpose)} |
| @@ -344,7 +346,7 @@ export function FormDesignerPage() { | @@ -344,7 +346,7 @@ export function FormDesignerPage() { | ||
| 344 | </select> | 346 | </select> |
| 345 | </div> | 347 | </div> |
| 346 | <div> | 348 | <div> |
| 347 | - <label className="block text-sm font-medium text-slate-700">Slug</label> | 349 | + <label className="block text-sm font-medium text-slate-700">{t('label.slug')}</label> |
| 348 | <input | 350 | <input |
| 349 | type="text" | 351 | type="text" |
| 350 | value={slug} | 352 | value={slug} |
| @@ -363,14 +365,14 @@ export function FormDesignerPage() { | @@ -363,14 +365,14 @@ export function FormDesignerPage() { | ||
| 363 | disabled={saving} | 365 | disabled={saving} |
| 364 | onClick={handleSave} | 366 | onClick={handleSave} |
| 365 | > | 367 | > |
| 366 | - {saving ? 'Saving...' : 'Save'} | 368 | + {saving ? t('action.saving') : t('action.save')} |
| 367 | </button> | 369 | </button> |
| 368 | <button | 370 | <button |
| 369 | type="button" | 371 | type="button" |
| 370 | className="btn-secondary" | 372 | className="btn-secondary" |
| 371 | onClick={() => navigate('/admin/metadata')} | 373 | onClick={() => navigate('/admin/metadata')} |
| 372 | > | 374 | > |
| 373 | - Discard | 375 | + {t('action.discard')} |
| 374 | </button> | 376 | </button> |
| 375 | </div> | 377 | </div> |
| 376 | </div> | 378 | </div> |
| @@ -387,7 +389,7 @@ export function FormDesignerPage() { | @@ -387,7 +389,7 @@ export function FormDesignerPage() { | ||
| 387 | {/* ── Left panel: field list (3/5 = 60%) ── */} | 389 | {/* ── Left panel: field list (3/5 = 60%) ── */} |
| 388 | <div className="lg:col-span-3 space-y-2"> | 390 | <div className="lg:col-span-3 space-y-2"> |
| 389 | <h2 className="text-xs font-semibold uppercase tracking-wide text-slate-400 mb-3"> | 391 | <h2 className="text-xs font-semibold uppercase tracking-wide text-slate-400 mb-3"> |
| 390 | - Fields | 392 | + {t('label.fields')} |
| 391 | </h2> | 393 | </h2> |
| 392 | 394 | ||
| 393 | {fields.map((field, idx) => ( | 395 | {fields.map((field, idx) => ( |
| @@ -409,10 +411,10 @@ export function FormDesignerPage() { | @@ -409,10 +411,10 @@ export function FormDesignerPage() { | ||
| 409 | 411 | ||
| 410 | <div className="flex gap-2 pt-2"> | 412 | <div className="flex gap-2 pt-2"> |
| 411 | <button type="button" className="btn-secondary" onClick={addField}> | 413 | <button type="button" className="btn-secondary" onClick={addField}> |
| 412 | - + Add Field | 414 | + {t('action.addField')} |
| 413 | </button> | 415 | </button> |
| 414 | <button type="button" className="btn-secondary" onClick={addSection}> | 416 | <button type="button" className="btn-secondary" onClick={addSection}> |
| 415 | - + Add Section Divider | 417 | + {t('action.addSectionDivider')} |
| 416 | </button> | 418 | </button> |
| 417 | </div> | 419 | </div> |
| 418 | </div> | 420 | </div> |
| @@ -420,12 +422,12 @@ export function FormDesignerPage() { | @@ -420,12 +422,12 @@ export function FormDesignerPage() { | ||
| 420 | {/* ── Right panel: live preview (2/5 = 40%) ── */} | 422 | {/* ── Right panel: live preview (2/5 = 40%) ── */} |
| 421 | <div className="lg:col-span-2"> | 423 | <div className="lg:col-span-2"> |
| 422 | <h2 className="text-xs font-semibold uppercase tracking-wide text-slate-400 mb-3"> | 424 | <h2 className="text-xs font-semibold uppercase tracking-wide text-slate-400 mb-3"> |
| 423 | - Live Preview | 425 | + {t('label.livePreview')} |
| 424 | </h2> | 426 | </h2> |
| 425 | <div className="card p-6"> | 427 | <div className="card p-6"> |
| 426 | {Object.keys(jsonSchema.properties ?? {}).length === 0 ? ( | 428 | {Object.keys(jsonSchema.properties ?? {}).length === 0 ? ( |
| 427 | <p className="text-sm text-slate-400 italic"> | 429 | <p className="text-sm text-slate-400 italic"> |
| 428 | - Add at least one field with a key to see the preview. | 430 | + {t('label.addFieldHint')} |
| 429 | </p> | 431 | </p> |
| 430 | ) : ( | 432 | ) : ( |
| 431 | <Form | 433 | <Form |
| @@ -475,6 +477,8 @@ function FieldRow({ | @@ -475,6 +477,8 @@ function FieldRow({ | ||
| 475 | onRemove, | 477 | onRemove, |
| 476 | onMove, | 478 | onMove, |
| 477 | }: FieldRowProps) { | 479 | }: FieldRowProps) { |
| 480 | + const t = useT() | ||
| 481 | + | ||
| 478 | if (field.isSectionDivider) { | 482 | if (field.isSectionDivider) { |
| 479 | return ( | 483 | return ( |
| 480 | <div className="card p-3 border-l-4 border-indigo-300"> | 484 | <div className="card p-3 border-l-4 border-indigo-300"> |
| @@ -485,20 +489,20 @@ function FieldRow({ | @@ -485,20 +489,20 @@ function FieldRow({ | ||
| 485 | onMove={onMove} | 489 | onMove={onMove} |
| 486 | /> | 490 | /> |
| 487 | <span className="text-xs font-semibold uppercase text-indigo-500 mr-2"> | 491 | <span className="text-xs font-semibold uppercase text-indigo-500 mr-2"> |
| 488 | - Section | 492 | + {t('label.section')} |
| 489 | </span> | 493 | </span> |
| 490 | <input | 494 | <input |
| 491 | type="text" | 495 | type="text" |
| 492 | value={field.sectionTitle ?? ''} | 496 | value={field.sectionTitle ?? ''} |
| 493 | onChange={(e) => onUpdate({ sectionTitle: e.target.value })} | 497 | onChange={(e) => onUpdate({ sectionTitle: e.target.value })} |
| 494 | - placeholder="Section title" | 498 | + placeholder={t('label.sectionTitle')} |
| 495 | className="flex-1 rounded-md border border-slate-300 px-2 py-1 text-sm" | 499 | className="flex-1 rounded-md border border-slate-300 px-2 py-1 text-sm" |
| 496 | /> | 500 | /> |
| 497 | <button | 501 | <button |
| 498 | type="button" | 502 | type="button" |
| 499 | onClick={onRemove} | 503 | onClick={onRemove} |
| 500 | className="text-slate-400 hover:text-rose-500 text-sm px-1" | 504 | className="text-slate-400 hover:text-rose-500 text-sm px-1" |
| 501 | - title="Remove section" | 505 | + title={t('label.removeSection')} |
| 502 | > | 506 | > |
| 503 | x | 507 | x |
| 504 | </button> | 508 | </button> |
| @@ -524,7 +528,7 @@ function FieldRow({ | @@ -524,7 +528,7 @@ function FieldRow({ | ||
| 524 | type="text" | 528 | type="text" |
| 525 | value={field.label} | 529 | value={field.label} |
| 526 | onChange={(e) => onUpdate({ label: e.target.value })} | 530 | onChange={(e) => onUpdate({ label: e.target.value })} |
| 527 | - placeholder="Label" | 531 | + placeholder={t('label.label')} |
| 528 | className="flex-1 rounded-md border border-slate-300 px-2 py-1 text-sm" | 532 | className="flex-1 rounded-md border border-slate-300 px-2 py-1 text-sm" |
| 529 | /> | 533 | /> |
| 530 | <select | 534 | <select |
| @@ -534,9 +538,9 @@ function FieldRow({ | @@ -534,9 +538,9 @@ function FieldRow({ | ||
| 534 | } | 538 | } |
| 535 | className="w-24 rounded-md border border-slate-300 px-2 py-1 text-sm" | 539 | className="w-24 rounded-md border border-slate-300 px-2 py-1 text-sm" |
| 536 | > | 540 | > |
| 537 | - {FIELD_TYPES.map((t) => ( | ||
| 538 | - <option key={t} value={t}> | ||
| 539 | - {t} | 541 | + {FIELD_TYPES.map((ft) => ( |
| 542 | + <option key={ft} value={ft}> | ||
| 543 | + {ft} | ||
| 540 | </option> | 544 | </option> |
| 541 | ))} | 545 | ))} |
| 542 | </select> | 546 | </select> |
| @@ -547,7 +551,7 @@ function FieldRow({ | @@ -547,7 +551,7 @@ function FieldRow({ | ||
| 547 | onChange={(e) => onUpdate({ required: e.target.checked })} | 551 | onChange={(e) => onUpdate({ required: e.target.checked })} |
| 548 | className="rounded border-slate-300" | 552 | className="rounded border-slate-300" |
| 549 | /> | 553 | /> |
| 550 | - Req | 554 | + {t('label.req')} |
| 551 | </label> | 555 | </label> |
| 552 | <select | 556 | <select |
| 553 | value={field.width} | 557 | value={field.width} |
| @@ -555,11 +559,11 @@ function FieldRow({ | @@ -555,11 +559,11 @@ function FieldRow({ | ||
| 555 | onUpdate({ width: Number(e.target.value) as 1 | 2 | 3 }) | 559 | onUpdate({ width: Number(e.target.value) as 1 | 2 | 3 }) |
| 556 | } | 560 | } |
| 557 | className="w-20 rounded-md border border-slate-300 px-2 py-1 text-sm" | 561 | className="w-20 rounded-md border border-slate-300 px-2 py-1 text-sm" |
| 558 | - title="Column span" | 562 | + title={t('label.colSpan')} |
| 559 | > | 563 | > |
| 560 | {WIDTH_OPTIONS.map((w) => ( | 564 | {WIDTH_OPTIONS.map((w) => ( |
| 561 | <option key={w} value={w}> | 565 | <option key={w} value={w}> |
| 562 | - {w} col{w > 1 ? 's' : ''} | 566 | + {w > 1 ? t('label.cols').replace('{n}', String(w)) : t('label.col').replace('{n}', String(w))} |
| 563 | </option> | 567 | </option> |
| 564 | ))} | 568 | ))} |
| 565 | </select> | 569 | </select> |
| @@ -567,7 +571,7 @@ function FieldRow({ | @@ -567,7 +571,7 @@ function FieldRow({ | ||
| 567 | type="button" | 571 | type="button" |
| 568 | onClick={onToggleExpand} | 572 | onClick={onToggleExpand} |
| 569 | className="text-slate-400 hover:text-slate-600 text-sm px-1" | 573 | className="text-slate-400 hover:text-slate-600 text-sm px-1" |
| 570 | - title="Toggle details" | 574 | + title={t('label.toggleDetails')} |
| 571 | > | 575 | > |
| 572 | {isExpanded ? '\u25B2' : '\u25BC'} | 576 | {isExpanded ? '\u25B2' : '\u25BC'} |
| 573 | </button> | 577 | </button> |
| @@ -575,7 +579,7 @@ function FieldRow({ | @@ -575,7 +579,7 @@ function FieldRow({ | ||
| 575 | type="button" | 579 | type="button" |
| 576 | onClick={onRemove} | 580 | onClick={onRemove} |
| 577 | className="text-slate-400 hover:text-rose-500 text-sm px-1" | 581 | className="text-slate-400 hover:text-rose-500 text-sm px-1" |
| 578 | - title="Remove field" | 582 | + title={t('label.removeField')} |
| 579 | > | 583 | > |
| 580 | x | 584 | x |
| 581 | </button> | 585 | </button> |
| @@ -586,7 +590,7 @@ function FieldRow({ | @@ -586,7 +590,7 @@ function FieldRow({ | ||
| 586 | <div className="mt-3 ml-10 grid grid-cols-1 gap-3 sm:grid-cols-2 border-t border-slate-100 pt-3"> | 590 | <div className="mt-3 ml-10 grid grid-cols-1 gap-3 sm:grid-cols-2 border-t border-slate-100 pt-3"> |
| 587 | <div> | 591 | <div> |
| 588 | <label className="block text-sm font-medium text-slate-700"> | 592 | <label className="block text-sm font-medium text-slate-700"> |
| 589 | - Label (English) | 593 | + {t('label.labelEnglish')} |
| 590 | </label> | 594 | </label> |
| 591 | <input | 595 | <input |
| 592 | type="text" | 596 | type="text" |
| @@ -597,7 +601,7 @@ function FieldRow({ | @@ -597,7 +601,7 @@ function FieldRow({ | ||
| 597 | </div> | 601 | </div> |
| 598 | <div> | 602 | <div> |
| 599 | <label className="block text-sm font-medium text-slate-700"> | 603 | <label className="block text-sm font-medium text-slate-700"> |
| 600 | - Placeholder | 604 | + {t('label.placeholder')} |
| 601 | </label> | 605 | </label> |
| 602 | <input | 606 | <input |
| 603 | type="text" | 607 | type="text" |
| @@ -610,7 +614,7 @@ function FieldRow({ | @@ -610,7 +614,7 @@ function FieldRow({ | ||
| 610 | </div> | 614 | </div> |
| 611 | <div className="sm:col-span-2"> | 615 | <div className="sm:col-span-2"> |
| 612 | <label className="block text-sm font-medium text-slate-700"> | 616 | <label className="block text-sm font-medium text-slate-700"> |
| 613 | - Help text | 617 | + {t('label.helpText')} |
| 614 | </label> | 618 | </label> |
| 615 | <input | 619 | <input |
| 616 | type="text" | 620 | type="text" |
| @@ -623,7 +627,7 @@ function FieldRow({ | @@ -623,7 +627,7 @@ function FieldRow({ | ||
| 623 | </div> | 627 | </div> |
| 624 | <div> | 628 | <div> |
| 625 | <label className="block text-sm font-medium text-slate-700"> | 629 | <label className="block text-sm font-medium text-slate-700"> |
| 626 | - Widget override | 630 | + {t('label.widgetOverride')} |
| 627 | </label> | 631 | </label> |
| 628 | <select | 632 | <select |
| 629 | value={field.widgetOverride ?? ''} | 633 | value={field.widgetOverride ?? ''} |
| @@ -634,17 +638,17 @@ function FieldRow({ | @@ -634,17 +638,17 @@ function FieldRow({ | ||
| 634 | > | 638 | > |
| 635 | {WIDGET_OPTIONS.map((w) => ( | 639 | {WIDGET_OPTIONS.map((w) => ( |
| 636 | <option key={w} value={w}> | 640 | <option key={w} value={w}> |
| 637 | - {w || '(default)'} | 641 | + {w || t('label.widgetDefault')} |
| 638 | </option> | 642 | </option> |
| 639 | ))} | 643 | ))} |
| 640 | </select> | 644 | </select> |
| 641 | </div> | 645 | </div> |
| 642 | <div> | 646 | <div> |
| 643 | <label className="block text-sm font-medium text-slate-700"> | 647 | <label className="block text-sm font-medium text-slate-700"> |
| 644 | - Visibility condition | 648 | + {t('label.visibilityCondition')} |
| 645 | </label> | 649 | </label> |
| 646 | <div className="mt-1 flex items-center gap-2"> | 650 | <div className="mt-1 flex items-center gap-2"> |
| 647 | - <span className="text-sm text-slate-500">Show when</span> | 651 | + <span className="text-sm text-slate-500">{t('label.showWhen')}</span> |
| 648 | <select | 652 | <select |
| 649 | value={field.visibleWhen?.field ?? ''} | 653 | value={field.visibleWhen?.field ?? ''} |
| 650 | onChange={(e) => | 654 | onChange={(e) => |
| @@ -659,7 +663,7 @@ function FieldRow({ | @@ -659,7 +663,7 @@ function FieldRow({ | ||
| 659 | } | 663 | } |
| 660 | className="rounded-md border border-slate-300 px-2 py-1 text-sm" | 664 | className="rounded-md border border-slate-300 px-2 py-1 text-sm" |
| 661 | > | 665 | > |
| 662 | - <option value="">(always)</option> | 666 | + <option value="">{t('label.always')}</option> |
| 663 | {fieldKeys | 667 | {fieldKeys |
| 664 | .filter((k) => k !== field.key) | 668 | .filter((k) => k !== field.key) |
| 665 | .map((k) => ( | 669 | .map((k) => ( |
| @@ -668,7 +672,7 @@ function FieldRow({ | @@ -668,7 +672,7 @@ function FieldRow({ | ||
| 668 | </option> | 672 | </option> |
| 669 | ))} | 673 | ))} |
| 670 | </select> | 674 | </select> |
| 671 | - <span className="text-sm text-slate-500">equals</span> | 675 | + <span className="text-sm text-slate-500">{t('label.equals')}</span> |
| 672 | <input | 676 | <input |
| 673 | type="text" | 677 | type="text" |
| 674 | value={field.visibleWhen?.equals ?? ''} | 678 | value={field.visibleWhen?.equals ?? ''} |
| @@ -702,6 +706,7 @@ function ReorderButtons({ | @@ -702,6 +706,7 @@ function ReorderButtons({ | ||
| 702 | total: number | 706 | total: number |
| 703 | onMove: (direction: 'up' | 'down') => void | 707 | onMove: (direction: 'up' | 'down') => void |
| 704 | }) { | 708 | }) { |
| 709 | + const t = useT() | ||
| 705 | return ( | 710 | return ( |
| 706 | <div className="flex flex-col"> | 711 | <div className="flex flex-col"> |
| 707 | <button | 712 | <button |
| @@ -709,7 +714,7 @@ function ReorderButtons({ | @@ -709,7 +714,7 @@ function ReorderButtons({ | ||
| 709 | onClick={() => onMove('up')} | 714 | onClick={() => onMove('up')} |
| 710 | disabled={index === 0} | 715 | disabled={index === 0} |
| 711 | className="text-slate-400 hover:text-slate-600 disabled:opacity-30 text-xs leading-none" | 716 | className="text-slate-400 hover:text-slate-600 disabled:opacity-30 text-xs leading-none" |
| 712 | - title="Move up" | 717 | + title={t('label.moveUp')} |
| 713 | > | 718 | > |
| 714 | ▲ | 719 | ▲ |
| 715 | </button> | 720 | </button> |
| @@ -718,7 +723,7 @@ function ReorderButtons({ | @@ -718,7 +723,7 @@ function ReorderButtons({ | ||
| 718 | onClick={() => onMove('down')} | 723 | onClick={() => onMove('down')} |
| 719 | disabled={index === total - 1} | 724 | disabled={index === total - 1} |
| 720 | className="text-slate-400 hover:text-slate-600 disabled:opacity-30 text-xs leading-none" | 725 | className="text-slate-400 hover:text-slate-600 disabled:opacity-30 text-xs leading-none" |
| 721 | - title="Move down" | 726 | + title={t('label.moveDown')} |
| 722 | > | 727 | > |
| 723 | ▼ | 728 | ▼ |
| 724 | </button> | 729 | </button> |
web/src/pages/ItemsPage.tsx
| @@ -6,8 +6,10 @@ import { PageHeader } from '@/components/PageHeader' | @@ -6,8 +6,10 @@ import { PageHeader } from '@/components/PageHeader' | ||
| 6 | import { Loading } from '@/components/Loading' | 6 | import { Loading } from '@/components/Loading' |
| 7 | import { ErrorBox } from '@/components/ErrorBox' | 7 | import { ErrorBox } from '@/components/ErrorBox' |
| 8 | import { DataTable, type Column } from '@/components/DataTable' | 8 | import { DataTable, type Column } from '@/components/DataTable' |
| 9 | +import { useT } from '@/i18n/LocaleContext' | ||
| 9 | 10 | ||
| 10 | export function ItemsPage() { | 11 | export function ItemsPage() { |
| 12 | + const t = useT() | ||
| 11 | const [rows, setRows] = useState<Item[]>([]) | 13 | const [rows, setRows] = useState<Item[]>([]) |
| 12 | const [error, setError] = useState<Error | null>(null) | 14 | const [error, setError] = useState<Error | null>(null) |
| 13 | const [loading, setLoading] = useState(true) | 15 | const [loading, setLoading] = useState(true) |
| @@ -22,28 +24,28 @@ export function ItemsPage() { | @@ -22,28 +24,28 @@ export function ItemsPage() { | ||
| 22 | 24 | ||
| 23 | const columns: Column<Item>[] = [ | 25 | const columns: Column<Item>[] = [ |
| 24 | { | 26 | { |
| 25 | - header: 'Code', | 27 | + header: t('label.code'), |
| 26 | key: 'code', | 28 | key: 'code', |
| 27 | render: (r) => ( | 29 | render: (r) => ( |
| 28 | <Link to={`/items/${r.id}/edit`} className="font-mono text-brand-600 hover:underline">{r.code}</Link> | 30 | <Link to={`/items/${r.id}/edit`} className="font-mono text-brand-600 hover:underline">{r.code}</Link> |
| 29 | ), | 31 | ), |
| 30 | }, | 32 | }, |
| 31 | - { header: 'Name', key: 'name' }, | ||
| 32 | - { header: 'Type', key: 'itemType' }, | 33 | + { header: t('label.name'), key: 'name' }, |
| 34 | + { header: t('label.type'), key: 'itemType' }, | ||
| 33 | { header: 'UoM', key: 'baseUomCode', render: (r) => <span className="font-mono">{r.baseUomCode}</span> }, | 35 | { header: 'UoM', key: 'baseUomCode', render: (r) => <span className="font-mono">{r.baseUomCode}</span> }, |
| 34 | { | 36 | { |
| 35 | - header: 'Active', | 37 | + header: t('label.active'), |
| 36 | key: 'active', | 38 | key: 'active', |
| 37 | - render: (r) => (r.active ? <span className="text-emerald-600">●</span> : <span className="text-slate-300">●</span>), | 39 | + render: (r) => (r.active ? <span className="text-emerald-600">{'\u25CF'}</span> : <span className="text-slate-300">{'\u25CF'}</span>), |
| 38 | }, | 40 | }, |
| 39 | ] | 41 | ] |
| 40 | 42 | ||
| 41 | return ( | 43 | return ( |
| 42 | <div> | 44 | <div> |
| 43 | <PageHeader | 45 | <PageHeader |
| 44 | - title="Items" | ||
| 45 | - subtitle="Catalog of items the framework can transact: raw materials, finished goods, services." | ||
| 46 | - actions={<Link to="/items/new" className="btn-primary">+ New Item</Link>} | 46 | + title={t('page.items.title')} |
| 47 | + subtitle={t('page.items.subtitle')} | ||
| 48 | + actions={<Link to="/items/new" className="btn-primary">{t('action.newItem')}</Link>} | ||
| 47 | /> | 49 | /> |
| 48 | {loading && <Loading />} | 50 | {loading && <Loading />} |
| 49 | {error && <ErrorBox error={error} />} | 51 | {error && <ErrorBox error={error} />} |
web/src/pages/JournalEntriesPage.tsx
| @@ -5,8 +5,10 @@ import { PageHeader } from '@/components/PageHeader' | @@ -5,8 +5,10 @@ import { PageHeader } from '@/components/PageHeader' | ||
| 5 | import { Loading } from '@/components/Loading' | 5 | import { Loading } from '@/components/Loading' |
| 6 | import { ErrorBox } from '@/components/ErrorBox' | 6 | import { ErrorBox } from '@/components/ErrorBox' |
| 7 | import { StatusBadge } from '@/components/StatusBadge' | 7 | import { StatusBadge } from '@/components/StatusBadge' |
| 8 | +import { useT } from '@/i18n/LocaleContext' | ||
| 8 | 9 | ||
| 9 | export function JournalEntriesPage() { | 10 | export function JournalEntriesPage() { |
| 11 | + const t = useT() | ||
| 10 | const [rows, setRows] = useState<JournalEntry[]>([]) | 12 | const [rows, setRows] = useState<JournalEntry[]>([]) |
| 11 | const [error, setError] = useState<Error | null>(null) | 13 | const [error, setError] = useState<Error | null>(null) |
| 12 | const [loading, setLoading] = useState(true) | 14 | const [loading, setLoading] = useState(true) |
| @@ -34,26 +36,26 @@ export function JournalEntriesPage() { | @@ -34,26 +36,26 @@ export function JournalEntriesPage() { | ||
| 34 | return ( | 36 | return ( |
| 35 | <div> | 37 | <div> |
| 36 | <PageHeader | 38 | <PageHeader |
| 37 | - title="Journal Entries" | ||
| 38 | - subtitle="Double-entry GL entries posted by pbc-finance from order lifecycle events. Click a row to see debit/credit lines." | 39 | + title={t('page.journalEntries.title')} |
| 40 | + subtitle={t('page.journalEntries.subtitle')} | ||
| 39 | /> | 41 | /> |
| 40 | {loading && <Loading />} | 42 | {loading && <Loading />} |
| 41 | {error && <ErrorBox error={error} />} | 43 | {error && <ErrorBox error={error} />} |
| 42 | {!loading && !error && rows.length === 0 && ( | 44 | {!loading && !error && rows.length === 0 && ( |
| 43 | - <div className="card p-6 text-sm text-slate-400">No journal entries yet.</div> | 45 | + <div className="card p-6 text-sm text-slate-400">{t('label.noJournalEntriesYet')}</div> |
| 44 | )} | 46 | )} |
| 45 | {!loading && !error && rows.length > 0 && ( | 47 | {!loading && !error && rows.length > 0 && ( |
| 46 | <div className="card overflow-x-auto"> | 48 | <div className="card overflow-x-auto"> |
| 47 | <table className="table-base"> | 49 | <table className="table-base"> |
| 48 | <thead className="bg-slate-50"> | 50 | <thead className="bg-slate-50"> |
| 49 | <tr> | 51 | <tr> |
| 50 | - <th>Posted</th> | ||
| 51 | - <th>Type</th> | ||
| 52 | - <th>Status</th> | ||
| 53 | - <th>Order</th> | ||
| 54 | - <th>Partner</th> | ||
| 55 | - <th>Amount</th> | ||
| 56 | - <th>Lines</th> | 52 | + <th>{t('label.posted')}</th> |
| 53 | + <th>{t('label.type')}</th> | ||
| 54 | + <th>{t('label.status')}</th> | ||
| 55 | + <th>{t('label.order')}</th> | ||
| 56 | + <th>{t('label.partner')}</th> | ||
| 57 | + <th>{t('label.amount')}</th> | ||
| 58 | + <th>{t('label.lines')}</th> | ||
| 57 | </tr> | 59 | </tr> |
| 58 | </thead> | 60 | </thead> |
| 59 | <tbody className="divide-y divide-slate-100"> | 61 | <tbody className="divide-y divide-slate-100"> |
| @@ -66,7 +68,7 @@ export function JournalEntriesPage() { | @@ -66,7 +68,7 @@ export function JournalEntriesPage() { | ||
| 66 | className="hover:bg-slate-50 cursor-pointer" | 68 | className="hover:bg-slate-50 cursor-pointer" |
| 67 | onClick={() => toggle(je.id)} | 69 | onClick={() => toggle(je.id)} |
| 68 | > | 70 | > |
| 69 | - <td>{je.postedAt ? new Date(je.postedAt).toLocaleString() : '—'}</td> | 71 | + <td>{je.postedAt ? new Date(je.postedAt).toLocaleString() : '\u2014'}</td> |
| 70 | <td>{je.type}</td> | 72 | <td>{je.type}</td> |
| 71 | <td><StatusBadge status={je.status} /></td> | 73 | <td><StatusBadge status={je.status} /></td> |
| 72 | <td className="font-mono">{je.orderCode}</td> | 74 | <td className="font-mono">{je.orderCode}</td> |
| @@ -83,11 +85,11 @@ export function JournalEntriesPage() { | @@ -83,11 +85,11 @@ export function JournalEntriesPage() { | ||
| 83 | <table className="min-w-full text-xs"> | 85 | <table className="min-w-full text-xs"> |
| 84 | <thead> | 86 | <thead> |
| 85 | <tr className="text-slate-400"> | 87 | <tr className="text-slate-400"> |
| 86 | - <th className="text-left px-2 py-1">#</th> | ||
| 87 | - <th className="text-left px-2 py-1">Account</th> | ||
| 88 | - <th className="text-right px-2 py-1">Debit</th> | ||
| 89 | - <th className="text-right px-2 py-1">Credit</th> | ||
| 90 | - <th className="text-left px-2 py-1">Description</th> | 88 | + <th className="text-left px-2 py-1">{t('label.lineNo')}</th> |
| 89 | + <th className="text-left px-2 py-1">{t('label.accountType')}</th> | ||
| 90 | + <th className="text-right px-2 py-1">{t('label.debit')}</th> | ||
| 91 | + <th className="text-right px-2 py-1">{t('label.credit')}</th> | ||
| 92 | + <th className="text-left px-2 py-1">{t('label.description')}</th> | ||
| 91 | </tr> | 93 | </tr> |
| 92 | </thead> | 94 | </thead> |
| 93 | <tbody> | 95 | <tbody> |
web/src/pages/ListViewDesignerPage.tsx
| @@ -16,6 +16,7 @@ import { metadata } from '@/api/client' | @@ -16,6 +16,7 @@ import { metadata } from '@/api/client' | ||
| 16 | import { PageHeader } from '@/components/PageHeader' | 16 | import { PageHeader } from '@/components/PageHeader' |
| 17 | import { ErrorBox } from '@/components/ErrorBox' | 17 | import { ErrorBox } from '@/components/ErrorBox' |
| 18 | import { DataTable, type Column } from '@/components/DataTable' | 18 | import { DataTable, type Column } from '@/components/DataTable' |
| 19 | +import { useT } from '@/i18n/LocaleContext' | ||
| 19 | 20 | ||
| 20 | // ─── Designer state types ────────────────────────────────────────── | 21 | // ─── Designer state types ────────────────────────────────────────── |
| 21 | 22 | ||
| @@ -65,6 +66,7 @@ function emptyState(): DesignerState { | @@ -65,6 +66,7 @@ function emptyState(): DesignerState { | ||
| 65 | 66 | ||
| 66 | export function ListViewDesignerPage() { | 67 | export function ListViewDesignerPage() { |
| 67 | const navigate = useNavigate() | 68 | const navigate = useNavigate() |
| 69 | + const t = useT() | ||
| 68 | const { slug: routeSlug } = useParams<{ slug: string }>() | 70 | const { slug: routeSlug } = useParams<{ slug: string }>() |
| 69 | const isEdit = Boolean(routeSlug) | 71 | const isEdit = Boolean(routeSlug) |
| 70 | 72 | ||
| @@ -254,7 +256,7 @@ export function ListViewDesignerPage() { | @@ -254,7 +256,7 @@ export function ListViewDesignerPage() { | ||
| 254 | 256 | ||
| 255 | if (loading) { | 257 | if (loading) { |
| 256 | return ( | 258 | return ( |
| 257 | - <div className="p-6 text-sm text-slate-500">Loading...</div> | 259 | + <div className="p-6 text-sm text-slate-500">{t('label.loading')}</div> |
| 258 | ) | 260 | ) |
| 259 | } | 261 | } |
| 260 | 262 | ||
| @@ -268,14 +270,14 @@ export function ListViewDesignerPage() { | @@ -268,14 +270,14 @@ export function ListViewDesignerPage() { | ||
| 268 | return ( | 270 | return ( |
| 269 | <div> | 271 | <div> |
| 270 | <PageHeader | 272 | <PageHeader |
| 271 | - title={isEdit ? 'Edit List View' : 'New List View'} | ||
| 272 | - subtitle="Configure columns, filters, sorting, and pagination for an entity list view." | 273 | + title={isEdit ? t('page.listViewDesigner.editTitle') : t('page.listViewDesigner.newTitle')} |
| 274 | + subtitle={t('page.listViewDesigner.subtitle')} | ||
| 273 | actions={ | 275 | actions={ |
| 274 | <button | 276 | <button |
| 275 | className="btn-secondary" | 277 | className="btn-secondary" |
| 276 | onClick={() => navigate('/admin/metadata')} | 278 | onClick={() => navigate('/admin/metadata')} |
| 277 | > | 279 | > |
| 278 | - Cancel | 280 | + {t('action.cancel')} |
| 279 | </button> | 281 | </button> |
| 280 | } | 282 | } |
| 281 | /> | 283 | /> |
| @@ -285,11 +287,11 @@ export function ListViewDesignerPage() { | @@ -285,11 +287,11 @@ export function ListViewDesignerPage() { | ||
| 285 | 287 | ||
| 286 | {/* ── Top bar: title, entity, slug ───────────────────── */} | 288 | {/* ── Top bar: title, entity, slug ───────────────────── */} |
| 287 | <div className="card p-6 space-y-4"> | 289 | <div className="card p-6 space-y-4"> |
| 288 | - <h2 className="text-lg font-semibold text-slate-800">General</h2> | 290 | + <h2 className="text-lg font-semibold text-slate-800">{t('label.general')}</h2> |
| 289 | <div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> | 291 | <div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> |
| 290 | <div> | 292 | <div> |
| 291 | <label className="block text-sm font-medium text-slate-700"> | 293 | <label className="block text-sm font-medium text-slate-700"> |
| 292 | - Title | 294 | + {t('label.title')} |
| 293 | </label> | 295 | </label> |
| 294 | <input | 296 | <input |
| 295 | type="text" | 297 | type="text" |
| @@ -302,7 +304,7 @@ export function ListViewDesignerPage() { | @@ -302,7 +304,7 @@ export function ListViewDesignerPage() { | ||
| 302 | </div> | 304 | </div> |
| 303 | <div> | 305 | <div> |
| 304 | <label className="block text-sm font-medium text-slate-700"> | 306 | <label className="block text-sm font-medium text-slate-700"> |
| 305 | - Entity Name | 307 | + {t('label.entityName')} |
| 306 | </label> | 308 | </label> |
| 307 | <input | 309 | <input |
| 308 | type="text" | 310 | type="text" |
| @@ -315,7 +317,7 @@ export function ListViewDesignerPage() { | @@ -315,7 +317,7 @@ export function ListViewDesignerPage() { | ||
| 315 | </div> | 317 | </div> |
| 316 | <div> | 318 | <div> |
| 317 | <label className="block text-sm font-medium text-slate-700"> | 319 | <label className="block text-sm font-medium text-slate-700"> |
| 318 | - Slug | 320 | + {t('label.slug')} |
| 319 | </label> | 321 | </label> |
| 320 | <input | 322 | <input |
| 321 | type="text" | 323 | type="text" |
| @@ -333,31 +335,31 @@ export function ListViewDesignerPage() { | @@ -333,31 +335,31 @@ export function ListViewDesignerPage() { | ||
| 333 | {/* ── Columns section ────────────────────────────────── */} | 335 | {/* ── Columns section ────────────────────────────────── */} |
| 334 | <div className="card p-6 space-y-4"> | 336 | <div className="card p-6 space-y-4"> |
| 335 | <div className="flex items-center justify-between"> | 337 | <div className="flex items-center justify-between"> |
| 336 | - <h2 className="text-lg font-semibold text-slate-800">Columns</h2> | 338 | + <h2 className="text-lg font-semibold text-slate-800">{t('label.columns')}</h2> |
| 337 | <button | 339 | <button |
| 338 | type="button" | 340 | type="button" |
| 339 | className="btn-secondary text-xs" | 341 | className="btn-secondary text-xs" |
| 340 | onClick={addColumn} | 342 | onClick={addColumn} |
| 341 | > | 343 | > |
| 342 | - + Add Column | 344 | + {t('action.addColumn')} |
| 343 | </button> | 345 | </button> |
| 344 | </div> | 346 | </div> |
| 345 | 347 | ||
| 346 | {state.columns.length === 0 ? ( | 348 | {state.columns.length === 0 ? ( |
| 347 | <p className="text-sm text-slate-400"> | 349 | <p className="text-sm text-slate-400"> |
| 348 | - No columns defined yet. Click "Add Column" to start. | 350 | + {t('label.noColumnsHint')} |
| 349 | </p> | 351 | </p> |
| 350 | ) : ( | 352 | ) : ( |
| 351 | <div className="overflow-x-auto"> | 353 | <div className="overflow-x-auto"> |
| 352 | <table className="table-base text-sm"> | 354 | <table className="table-base text-sm"> |
| 353 | <thead className="bg-slate-50"> | 355 | <thead className="bg-slate-50"> |
| 354 | <tr> | 356 | <tr> |
| 355 | - <th className="w-10">Show</th> | ||
| 356 | - <th>Field</th> | ||
| 357 | - <th>Label</th> | ||
| 358 | - <th className="w-28">Format</th> | ||
| 359 | - <th className="w-16">Sortable</th> | ||
| 360 | - <th className="w-24">Order</th> | 357 | + <th className="w-10">{t('label.show')}</th> |
| 358 | + <th>{t('label.field')}</th> | ||
| 359 | + <th>{t('label.label')}</th> | ||
| 360 | + <th className="w-28">{t('label.format')}</th> | ||
| 361 | + <th className="w-16">{t('label.sortable')}</th> | ||
| 362 | + <th className="w-24">{t('label.orderMeta')}</th> | ||
| 361 | <th className="w-10"></th> | 363 | <th className="w-10"></th> |
| 362 | </tr> | 364 | </tr> |
| 363 | </thead> | 365 | </thead> |
| @@ -428,7 +430,7 @@ export function ListViewDesignerPage() { | @@ -428,7 +430,7 @@ export function ListViewDesignerPage() { | ||
| 428 | className="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600 disabled:opacity-30" | 430 | className="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600 disabled:opacity-30" |
| 429 | disabled={idx === 0} | 431 | disabled={idx === 0} |
| 430 | onClick={() => moveColumn(idx, -1)} | 432 | onClick={() => moveColumn(idx, -1)} |
| 431 | - title="Move up" | 433 | + title={t('label.moveUp')} |
| 432 | > | 434 | > |
| 433 | ▲ | 435 | ▲ |
| 434 | </button> | 436 | </button> |
| @@ -437,7 +439,7 @@ export function ListViewDesignerPage() { | @@ -437,7 +439,7 @@ export function ListViewDesignerPage() { | ||
| 437 | className="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600 disabled:opacity-30" | 439 | className="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600 disabled:opacity-30" |
| 438 | disabled={idx === state.columns.length - 1} | 440 | disabled={idx === state.columns.length - 1} |
| 439 | onClick={() => moveColumn(idx, 1)} | 441 | onClick={() => moveColumn(idx, 1)} |
| 440 | - title="Move down" | 442 | + title={t('label.moveDown')} |
| 441 | > | 443 | > |
| 442 | ▼ | 444 | ▼ |
| 443 | </button> | 445 | </button> |
| @@ -448,7 +450,7 @@ export function ListViewDesignerPage() { | @@ -448,7 +450,7 @@ export function ListViewDesignerPage() { | ||
| 448 | type="button" | 450 | type="button" |
| 449 | className="text-slate-400 hover:text-rose-500" | 451 | className="text-slate-400 hover:text-rose-500" |
| 450 | onClick={() => removeColumn(idx)} | 452 | onClick={() => removeColumn(idx)} |
| 451 | - title="Remove column" | 453 | + title={t('label.removeColumn')} |
| 452 | > | 454 | > |
| 453 | × | 455 | × |
| 454 | </button> | 456 | </button> |
| @@ -464,19 +466,19 @@ export function ListViewDesignerPage() { | @@ -464,19 +466,19 @@ export function ListViewDesignerPage() { | ||
| 464 | {/* ── Filters section ────────────────────────────────── */} | 466 | {/* ── Filters section ────────────────────────────────── */} |
| 465 | <div className="card p-6 space-y-4"> | 467 | <div className="card p-6 space-y-4"> |
| 466 | <div className="flex items-center justify-between"> | 468 | <div className="flex items-center justify-between"> |
| 467 | - <h2 className="text-lg font-semibold text-slate-800">Filters</h2> | 469 | + <h2 className="text-lg font-semibold text-slate-800">{t('label.filters')}</h2> |
| 468 | <button | 470 | <button |
| 469 | type="button" | 471 | type="button" |
| 470 | className="btn-secondary text-xs" | 472 | className="btn-secondary text-xs" |
| 471 | onClick={addFilter} | 473 | onClick={addFilter} |
| 472 | > | 474 | > |
| 473 | - + Add Filter | 475 | + {t('action.addFilter')} |
| 474 | </button> | 476 | </button> |
| 475 | </div> | 477 | </div> |
| 476 | 478 | ||
| 477 | {state.filters.length === 0 ? ( | 479 | {state.filters.length === 0 ? ( |
| 478 | <p className="text-sm text-slate-400"> | 480 | <p className="text-sm text-slate-400"> |
| 479 | - No filters defined. Click "Add Filter" to add filterable fields. | 481 | + {t('label.noFiltersHint')} |
| 480 | </p> | 482 | </p> |
| 481 | ) : ( | 483 | ) : ( |
| 482 | <div className="space-y-2"> | 484 | <div className="space-y-2"> |
| @@ -488,7 +490,7 @@ export function ListViewDesignerPage() { | @@ -488,7 +490,7 @@ export function ListViewDesignerPage() { | ||
| 488 | onChange={(e) => | 490 | onChange={(e) => |
| 489 | updateFilter(idx, { field: e.target.value }) | 491 | updateFilter(idx, { field: e.target.value }) |
| 490 | } | 492 | } |
| 491 | - placeholder="Field name" | 493 | + placeholder={t('label.fieldName')} |
| 492 | className={smallInputCls + ' flex-1'} | 494 | className={smallInputCls + ' flex-1'} |
| 493 | /> | 495 | /> |
| 494 | <select | 496 | <select |
| @@ -510,14 +512,14 @@ export function ListViewDesignerPage() { | @@ -510,14 +512,14 @@ export function ListViewDesignerPage() { | ||
| 510 | onChange={(e) => | 512 | onChange={(e) => |
| 511 | updateFilter(idx, { label: e.target.value }) | 513 | updateFilter(idx, { label: e.target.value }) |
| 512 | } | 514 | } |
| 513 | - placeholder="Display label" | 515 | + placeholder={t('label.displayLabel')} |
| 514 | className={smallInputCls + ' flex-1'} | 516 | className={smallInputCls + ' flex-1'} |
| 515 | /> | 517 | /> |
| 516 | <button | 518 | <button |
| 517 | type="button" | 519 | type="button" |
| 518 | className="text-slate-400 hover:text-rose-500" | 520 | className="text-slate-400 hover:text-rose-500" |
| 519 | onClick={() => removeFilter(idx)} | 521 | onClick={() => removeFilter(idx)} |
| 520 | - title="Remove filter" | 522 | + title={t('label.removeFilter')} |
| 521 | > | 523 | > |
| 522 | × | 524 | × |
| 523 | </button> | 525 | </button> |
| @@ -530,12 +532,12 @@ export function ListViewDesignerPage() { | @@ -530,12 +532,12 @@ export function ListViewDesignerPage() { | ||
| 530 | {/* ── Sorting & page size section ────────────────────── */} | 532 | {/* ── Sorting & page size section ────────────────────── */} |
| 531 | <div className="card p-6 space-y-4"> | 533 | <div className="card p-6 space-y-4"> |
| 532 | <h2 className="text-lg font-semibold text-slate-800"> | 534 | <h2 className="text-lg font-semibold text-slate-800"> |
| 533 | - Sorting & Pagination | 535 | + {t('label.sortingPagination')} |
| 534 | </h2> | 536 | </h2> |
| 535 | <div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> | 537 | <div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> |
| 536 | <div> | 538 | <div> |
| 537 | <label className="block text-sm font-medium text-slate-700"> | 539 | <label className="block text-sm font-medium text-slate-700"> |
| 538 | - Default Sort Field | 540 | + {t('label.defaultSortField')} |
| 539 | </label> | 541 | </label> |
| 540 | <select | 542 | <select |
| 541 | value={state.defaultSort?.field ?? ''} | 543 | value={state.defaultSort?.field ?? ''} |
| @@ -552,7 +554,7 @@ export function ListViewDesignerPage() { | @@ -552,7 +554,7 @@ export function ListViewDesignerPage() { | ||
| 552 | }} | 554 | }} |
| 553 | className={inputCls} | 555 | className={inputCls} |
| 554 | > | 556 | > |
| 555 | - <option value="">-- none --</option> | 557 | + <option value="">{t('label.none')}</option> |
| 556 | {state.columns | 558 | {state.columns |
| 557 | .filter((c) => c.field.trim()) | 559 | .filter((c) => c.field.trim()) |
| 558 | .map((c) => ( | 560 | .map((c) => ( |
| @@ -564,7 +566,7 @@ export function ListViewDesignerPage() { | @@ -564,7 +566,7 @@ export function ListViewDesignerPage() { | ||
| 564 | </div> | 566 | </div> |
| 565 | <div> | 567 | <div> |
| 566 | <label className="block text-sm font-medium text-slate-700"> | 568 | <label className="block text-sm font-medium text-slate-700"> |
| 567 | - Direction | 569 | + {t('label.direction')} |
| 568 | </label> | 570 | </label> |
| 569 | <select | 571 | <select |
| 570 | value={state.defaultSort?.direction ?? 'asc'} | 572 | value={state.defaultSort?.direction ?? 'asc'} |
| @@ -578,13 +580,13 @@ export function ListViewDesignerPage() { | @@ -578,13 +580,13 @@ export function ListViewDesignerPage() { | ||
| 578 | disabled={!state.defaultSort} | 580 | disabled={!state.defaultSort} |
| 579 | className={`${inputCls}${!state.defaultSort ? ' bg-slate-50 text-slate-500' : ''}`} | 581 | className={`${inputCls}${!state.defaultSort ? ' bg-slate-50 text-slate-500' : ''}`} |
| 580 | > | 582 | > |
| 581 | - <option value="asc">Ascending</option> | ||
| 582 | - <option value="desc">Descending</option> | 583 | + <option value="asc">{t('label.ascending')}</option> |
| 584 | + <option value="desc">{t('label.descending')}</option> | ||
| 583 | </select> | 585 | </select> |
| 584 | </div> | 586 | </div> |
| 585 | <div> | 587 | <div> |
| 586 | <label className="block text-sm font-medium text-slate-700"> | 588 | <label className="block text-sm font-medium text-slate-700"> |
| 587 | - Page Size | 589 | + {t('label.pageSize')} |
| 588 | </label> | 590 | </label> |
| 589 | <input | 591 | <input |
| 590 | type="number" | 592 | type="number" |
| @@ -602,10 +604,10 @@ export function ListViewDesignerPage() { | @@ -602,10 +604,10 @@ export function ListViewDesignerPage() { | ||
| 602 | 604 | ||
| 603 | {/* ── Preview section ────────────────────────────────── */} | 605 | {/* ── Preview section ────────────────────────────────── */} |
| 604 | <div className="card p-6 space-y-4"> | 606 | <div className="card p-6 space-y-4"> |
| 605 | - <h2 className="text-lg font-semibold text-slate-800">Preview</h2> | 607 | + <h2 className="text-lg font-semibold text-slate-800">{t('label.preview')}</h2> |
| 606 | {previewColumns.length === 0 ? ( | 608 | {previewColumns.length === 0 ? ( |
| 607 | <p className="text-sm text-slate-400"> | 609 | <p className="text-sm text-slate-400"> |
| 608 | - Add visible columns with a field name to see a preview. | 610 | + {t('label.addColumnsHint')} |
| 609 | </p> | 611 | </p> |
| 610 | ) : ( | 612 | ) : ( |
| 611 | <DataTable | 613 | <DataTable |
| @@ -619,14 +621,14 @@ export function ListViewDesignerPage() { | @@ -619,14 +621,14 @@ export function ListViewDesignerPage() { | ||
| 619 | {/* ── Actions ────────────────────────────────────────── */} | 621 | {/* ── Actions ────────────────────────────────────────── */} |
| 620 | <div className="flex items-center gap-3"> | 622 | <div className="flex items-center gap-3"> |
| 621 | <button type="submit" className="btn-primary" disabled={saving}> | 623 | <button type="submit" className="btn-primary" disabled={saving}> |
| 622 | - {saving ? 'Saving...' : 'Save List View'} | 624 | + {saving ? t('action.saving') : t('action.saveListView')} |
| 623 | </button> | 625 | </button> |
| 624 | <button | 626 | <button |
| 625 | type="button" | 627 | type="button" |
| 626 | className="btn-secondary" | 628 | className="btn-secondary" |
| 627 | onClick={() => navigate('/admin/metadata')} | 629 | onClick={() => navigate('/admin/metadata')} |
| 628 | > | 630 | > |
| 629 | - Cancel | 631 | + {t('action.cancel')} |
| 630 | </button> | 632 | </button> |
| 631 | </div> | 633 | </div> |
| 632 | </form> | 634 | </form> |
web/src/pages/LocationsPage.tsx
| @@ -6,8 +6,10 @@ import { PageHeader } from '@/components/PageHeader' | @@ -6,8 +6,10 @@ import { PageHeader } from '@/components/PageHeader' | ||
| 6 | import { Loading } from '@/components/Loading' | 6 | import { Loading } from '@/components/Loading' |
| 7 | import { ErrorBox } from '@/components/ErrorBox' | 7 | import { ErrorBox } from '@/components/ErrorBox' |
| 8 | import { DataTable, type Column } from '@/components/DataTable' | 8 | import { DataTable, type Column } from '@/components/DataTable' |
| 9 | +import { useT } from '@/i18n/LocaleContext' | ||
| 9 | 10 | ||
| 10 | export function LocationsPage() { | 11 | export function LocationsPage() { |
| 12 | + const t = useT() | ||
| 11 | const [rows, setRows] = useState<Location[]>([]) | 13 | const [rows, setRows] = useState<Location[]>([]) |
| 12 | const [error, setError] = useState<Error | null>(null) | 14 | const [error, setError] = useState<Error | null>(null) |
| 13 | const [loading, setLoading] = useState(true) | 15 | const [loading, setLoading] = useState(true) |
| @@ -21,22 +23,22 @@ export function LocationsPage() { | @@ -21,22 +23,22 @@ export function LocationsPage() { | ||
| 21 | }, []) | 23 | }, []) |
| 22 | 24 | ||
| 23 | const columns: Column<Location>[] = [ | 25 | const columns: Column<Location>[] = [ |
| 24 | - { header: 'Code', key: 'code', render: (r) => <span className="font-mono">{r.code}</span> }, | ||
| 25 | - { header: 'Name', key: 'name' }, | ||
| 26 | - { header: 'Type', key: 'type' }, | 26 | + { header: t('label.code'), key: 'code', render: (r) => <span className="font-mono">{r.code}</span> }, |
| 27 | + { header: t('label.name'), key: 'name' }, | ||
| 28 | + { header: t('label.type'), key: 'type' }, | ||
| 27 | { | 29 | { |
| 28 | - header: 'Active', | 30 | + header: t('label.active'), |
| 29 | key: 'active', | 31 | key: 'active', |
| 30 | - render: (r) => (r.active ? <span className="text-emerald-600">●</span> : <span className="text-slate-300">●</span>), | 32 | + render: (r) => (r.active ? <span className="text-emerald-600">{'\u25CF'}</span> : <span className="text-slate-300">{'\u25CF'}</span>), |
| 31 | }, | 33 | }, |
| 32 | ] | 34 | ] |
| 33 | 35 | ||
| 34 | return ( | 36 | return ( |
| 35 | <div> | 37 | <div> |
| 36 | <PageHeader | 38 | <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>} | 39 | + title={t('page.locations.title')} |
| 40 | + subtitle={t('page.locations.subtitle')} | ||
| 41 | + actions={<Link to="/locations/new" className="btn-primary">{t('action.newLocation')}</Link>} | ||
| 40 | /> | 42 | /> |
| 41 | {loading && <Loading />} | 43 | {loading && <Loading />} |
| 42 | {error && <ErrorBox error={error} />} | 44 | {error && <ErrorBox error={error} />} |
web/src/pages/LoginPage.tsx
| @@ -4,6 +4,7 @@ import { useAuth } from '@/auth/AuthContext' | @@ -4,6 +4,7 @@ import { useAuth } from '@/auth/AuthContext' | ||
| 4 | import { meta } from '@/api/client' | 4 | import { meta } from '@/api/client' |
| 5 | import type { MetaInfo } from '@/types/api' | 5 | import type { MetaInfo } from '@/types/api' |
| 6 | import { ErrorBox } from '@/components/ErrorBox' | 6 | import { ErrorBox } from '@/components/ErrorBox' |
| 7 | +import { useT } from '@/i18n/LocaleContext' | ||
| 7 | 8 | ||
| 8 | interface LocationState { | 9 | interface LocationState { |
| 9 | from?: string | 10 | from?: string |
| @@ -13,6 +14,7 @@ export function LoginPage() { | @@ -13,6 +14,7 @@ export function LoginPage() { | ||
| 13 | const { login, token, loading } = useAuth() | 14 | const { login, token, loading } = useAuth() |
| 14 | const navigate = useNavigate() | 15 | const navigate = useNavigate() |
| 15 | const location = useLocation() | 16 | const location = useLocation() |
| 17 | + const t = useT() | ||
| 16 | const [username, setUsername] = useState('admin') | 18 | const [username, setUsername] = useState('admin') |
| 17 | const [password, setPassword] = useState('') | 19 | const [password, setPassword] = useState('') |
| 18 | const [error, setError] = useState<Error | null>(null) | 20 | const [error, setError] = useState<Error | null>(null) |
| @@ -47,13 +49,13 @@ export function LoginPage() { | @@ -47,13 +49,13 @@ export function LoginPage() { | ||
| 47 | <div className="mb-6 text-center"> | 49 | <div className="mb-6 text-center"> |
| 48 | <h1 className="text-3xl font-bold text-brand-600">vibe_erp</h1> | 50 | <h1 className="text-3xl font-bold text-brand-600">vibe_erp</h1> |
| 49 | <p className="mt-1 text-sm text-slate-500"> | 51 | <p className="mt-1 text-sm text-slate-500"> |
| 50 | - Composable ERP framework for the printing industry | 52 | + {t('page.login.tagline')} |
| 51 | </p> | 53 | </p> |
| 52 | </div> | 54 | </div> |
| 53 | <div className="card p-6"> | 55 | <div className="card p-6"> |
| 54 | <form onSubmit={onSubmit} className="space-y-4"> | 56 | <form onSubmit={onSubmit} className="space-y-4"> |
| 55 | <div> | 57 | <div> |
| 56 | - <label className="block text-sm font-medium text-slate-700">Username</label> | 58 | + <label className="block text-sm font-medium text-slate-700">{t('label.username')}</label> |
| 57 | <input | 59 | <input |
| 58 | type="text" | 60 | type="text" |
| 59 | value={username} | 61 | value={username} |
| @@ -64,7 +66,7 @@ export function LoginPage() { | @@ -64,7 +66,7 @@ export function LoginPage() { | ||
| 64 | /> | 66 | /> |
| 65 | </div> | 67 | </div> |
| 66 | <div> | 68 | <div> |
| 67 | - <label className="block text-sm font-medium text-slate-700">Password</label> | 69 | + <label className="block text-sm font-medium text-slate-700">{t('label.password')}</label> |
| 68 | <input | 70 | <input |
| 69 | type="password" | 71 | type="password" |
| 70 | value={password} | 72 | value={password} |
| @@ -73,23 +75,23 @@ export function LoginPage() { | @@ -73,23 +75,23 @@ export function LoginPage() { | ||
| 73 | required | 75 | required |
| 74 | /> | 76 | /> |
| 75 | <p className="mt-1 text-xs text-slate-400"> | 77 | <p className="mt-1 text-xs text-slate-400"> |
| 76 | - The bootstrap admin password is printed to the application boot log on first start. | 78 | + {t('page.login.passwordHint')} |
| 77 | </p> | 79 | </p> |
| 78 | </div> | 80 | </div> |
| 79 | {error ? <ErrorBox error={error} /> : null} | 81 | {error ? <ErrorBox error={error} /> : null} |
| 80 | <button type="submit" className="btn-primary w-full" disabled={loading}> | 82 | <button type="submit" className="btn-primary w-full" disabled={loading}> |
| 81 | - {loading ? 'Signing in…' : 'Sign in'} | 83 | + {loading ? t('action.signingIn') : t('action.signIn')} |
| 82 | </button> | 84 | </button> |
| 83 | </form> | 85 | </form> |
| 84 | </div> | 86 | </div> |
| 85 | <p className="mt-4 text-center text-xs text-slate-400"> | 87 | <p className="mt-4 text-center text-xs text-slate-400"> |
| 86 | {info ? ( | 88 | {info ? ( |
| 87 | <> | 89 | <> |
| 88 | - Connected to <span className="font-mono">{info.name}</span>{' '} | 90 | + {t('page.login.connectedTo')} <span className="font-mono">{info.name}</span>{' '} |
| 89 | <span className="font-mono">{info.implementationVersion}</span> | 91 | <span className="font-mono">{info.implementationVersion}</span> |
| 90 | </> | 92 | </> |
| 91 | ) : ( | 93 | ) : ( |
| 92 | - 'Connecting…' | 94 | + t('page.login.connecting') |
| 93 | )} | 95 | )} |
| 94 | </p> | 96 | </p> |
| 95 | </div> | 97 | </div> |
web/src/pages/MetadataAdminPage.tsx
| @@ -363,8 +363,8 @@ export function MetadataAdminPage() { | @@ -363,8 +363,8 @@ export function MetadataAdminPage() { | ||
| 363 | 363 | ||
| 364 | const entityCols: Column<MetadataEntity>[] = [ | 364 | const entityCols: Column<MetadataEntity>[] = [ |
| 365 | { header: t('label.name'), key: 'name' }, | 365 | { header: t('label.name'), key: 'name' }, |
| 366 | - { header: 'PBC', key: 'pbc' }, | ||
| 367 | - { header: 'Table', key: 'table' }, | 366 | + { header: t('label.pbc'), key: 'pbc' }, |
| 367 | + { header: t('label.table'), key: 'table' }, | ||
| 368 | { header: t('label.description'), key: 'description', render: (r) => r.description ?? '—' }, | 368 | { header: t('label.description'), key: 'description', render: (r) => r.description ?? '—' }, |
| 369 | { header: t('label.source'), key: 'source', render: (r) => <SourceBadge source={r.source ?? 'core'} /> }, | 369 | { header: t('label.source'), key: 'source', render: (r) => <SourceBadge source={r.source ?? 'core'} /> }, |
| 370 | ] | 370 | ] |
| @@ -373,8 +373,8 @@ export function MetadataAdminPage() { | @@ -373,8 +373,8 @@ export function MetadataAdminPage() { | ||
| 373 | { header: t('label.fieldKey'), key: 'key', render: (r) => <span className="font-mono">{r.key}</span> }, | 373 | { header: t('label.fieldKey'), key: 'key', render: (r) => <span className="font-mono">{r.key}</span> }, |
| 374 | { header: t('label.targetEntity'), key: 'targetEntity' }, | 374 | { header: t('label.targetEntity'), key: 'targetEntity' }, |
| 375 | { header: t('label.fieldType'), key: 'type', render: (r) => r.type.kind }, | 375 | { header: t('label.fieldType'), key: 'type', render: (r) => r.type.kind }, |
| 376 | - { header: 'Required', key: 'required', render: (r) => (r.required ? 'Yes' : 'No') }, | ||
| 377 | - { header: 'PII', key: 'pii', render: (r) => (r.pii ? 'Yes' : 'No') }, | 376 | + { header: t('label.required'), key: 'required', render: (r) => (r.required ? t('label.yes') : t('label.no')) }, |
| 377 | + { header: t('label.pii'), key: 'pii', render: (r) => (r.pii ? t('label.yes') : t('label.no')) }, | ||
| 378 | { header: t('label.source'), key: 'source', render: (r) => <SourceBadge source={r.source ?? 'core'} /> }, | 378 | { header: t('label.source'), key: 'source', render: (r) => <SourceBadge source={r.source ?? 'core'} /> }, |
| 379 | { | 379 | { |
| 380 | header: '', | 380 | header: '', |
| @@ -386,7 +386,7 @@ export function MetadataAdminPage() { | @@ -386,7 +386,7 @@ export function MetadataAdminPage() { | ||
| 386 | className="text-xs text-blue-600 hover:underline" | 386 | className="text-xs text-blue-600 hover:underline" |
| 387 | onClick={() => openCfEdit(r)} | 387 | onClick={() => openCfEdit(r)} |
| 388 | > | 388 | > |
| 389 | - Edit | 389 | + {t('action.edit')} |
| 390 | </button> | 390 | </button> |
| 391 | <button | 391 | <button |
| 392 | className="text-xs text-rose-600 hover:underline" | 392 | className="text-xs text-rose-600 hover:underline" |
| @@ -400,17 +400,17 @@ export function MetadataAdminPage() { | @@ -400,17 +400,17 @@ export function MetadataAdminPage() { | ||
| 400 | ] | 400 | ] |
| 401 | 401 | ||
| 402 | const permissionCols: Column<MetadataPermission>[] = [ | 402 | const permissionCols: Column<MetadataPermission>[] = [ |
| 403 | - { header: 'Key', key: 'key', render: (r) => <span className="font-mono">{r.key}</span> }, | 403 | + { header: t('label.key'), key: 'key', render: (r) => <span className="font-mono">{r.key}</span> }, |
| 404 | { header: t('label.description'), key: 'description' }, | 404 | { header: t('label.description'), key: 'description' }, |
| 405 | { header: t('label.source'), key: 'source', render: (r) => <SourceBadge source={r.source ?? 'core'} /> }, | 405 | { header: t('label.source'), key: 'source', render: (r) => <SourceBadge source={r.source ?? 'core'} /> }, |
| 406 | ] | 406 | ] |
| 407 | 407 | ||
| 408 | const menuCols: Column<MenuRow>[] = [ | 408 | const menuCols: Column<MenuRow>[] = [ |
| 409 | - { header: 'Path', key: 'path', render: (r) => <span className="font-mono">{r.path}</span> }, | ||
| 410 | - { header: 'Label', key: 'label' }, | ||
| 411 | - { header: 'Icon', key: 'icon', render: (r) => r.icon ?? '—' }, | ||
| 412 | - { header: 'Section', key: 'section', render: (r) => r.section ?? '—' }, | ||
| 413 | - { header: 'Order', key: 'order', render: (r) => r.order ?? '—' }, | 409 | + { header: t('label.path'), key: 'path', render: (r) => <span className="font-mono">{r.path}</span> }, |
| 410 | + { header: t('label.label'), key: 'label' }, | ||
| 411 | + { header: t('label.icon'), key: 'icon', render: (r) => r.icon ?? '—' }, | ||
| 412 | + { header: t('label.sectionMeta'), key: 'section', render: (r) => r.section ?? '—' }, | ||
| 413 | + { header: t('label.orderMeta'), key: 'order', render: (r) => r.order ?? '—' }, | ||
| 414 | { header: t('label.source'), key: 'source', render: (r) => <SourceBadge source={r.source ?? 'core'} /> }, | 414 | { header: t('label.source'), key: 'source', render: (r) => <SourceBadge source={r.source ?? 'core'} /> }, |
| 415 | ] | 415 | ] |
| 416 | 416 | ||
| @@ -428,9 +428,9 @@ export function MetadataAdminPage() { | @@ -428,9 +428,9 @@ export function MetadataAdminPage() { | ||
| 428 | ), | 428 | ), |
| 429 | }, | 429 | }, |
| 430 | { header: t('label.entity'), key: 'entityName' }, | 430 | { header: t('label.entity'), key: 'entityName' }, |
| 431 | - { header: 'Title', key: 'title' }, | 431 | + { header: t('label.title'), key: 'title' }, |
| 432 | { header: t('label.purpose'), key: 'purpose' }, | 432 | { header: t('label.purpose'), key: 'purpose' }, |
| 433 | - { header: 'Version', key: 'version' }, | 433 | + { header: t('label.version'), key: 'version' }, |
| 434 | { header: t('label.source'), key: 'source', render: (r) => <SourceBadge source={r.source ?? 'core'} /> }, | 434 | { header: t('label.source'), key: 'source', render: (r) => <SourceBadge source={r.source ?? 'core'} /> }, |
| 435 | { | 435 | { |
| 436 | header: '', | 436 | header: '', |
| @@ -463,9 +463,9 @@ export function MetadataAdminPage() { | @@ -463,9 +463,9 @@ export function MetadataAdminPage() { | ||
| 463 | ), | 463 | ), |
| 464 | }, | 464 | }, |
| 465 | { header: t('label.entity'), key: 'entityName' }, | 465 | { header: t('label.entity'), key: 'entityName' }, |
| 466 | - { header: 'Title', key: 'title' }, | 466 | + { header: t('label.title'), key: 'title' }, |
| 467 | { header: t('label.pageSize'), key: 'pageSize' }, | 467 | { header: t('label.pageSize'), key: 'pageSize' }, |
| 468 | - { header: 'Version', key: 'version' }, | 468 | + { header: t('label.version'), key: 'version' }, |
| 469 | { header: t('label.source'), key: 'source', render: (r) => <SourceBadge source={r.source ?? 'core'} /> }, | 469 | { header: t('label.source'), key: 'source', render: (r) => <SourceBadge source={r.source ?? 'core'} /> }, |
| 470 | { | 470 | { |
| 471 | header: '', | 471 | header: '', |
| @@ -521,7 +521,7 @@ export function MetadataAdminPage() { | @@ -521,7 +521,7 @@ export function MetadataAdminPage() { | ||
| 521 | className="text-xs text-blue-600 hover:underline" | 521 | className="text-xs text-blue-600 hover:underline" |
| 522 | onClick={() => openRuleEdit(r)} | 522 | onClick={() => openRuleEdit(r)} |
| 523 | > | 523 | > |
| 524 | - Edit | 524 | + {t('action.edit')} |
| 525 | </button> | 525 | </button> |
| 526 | <button | 526 | <button |
| 527 | className="text-xs text-rose-600 hover:underline" | 527 | className="text-xs text-rose-600 hover:underline" |
| @@ -624,7 +624,7 @@ export function MetadataAdminPage() { | @@ -624,7 +624,7 @@ export function MetadataAdminPage() { | ||
| 624 | {cfTypeKind === 'enum' && ( | 624 | {cfTypeKind === 'enum' && ( |
| 625 | <div> | 625 | <div> |
| 626 | <label className="block text-xs font-medium text-slate-700"> | 626 | <label className="block text-xs font-medium text-slate-700"> |
| 627 | - Allowed values (comma-separated) | 627 | + {t('label.allowedValues')} |
| 628 | </label> | 628 | </label> |
| 629 | <input | 629 | <input |
| 630 | type="text" | 630 | type="text" |
| @@ -638,7 +638,7 @@ export function MetadataAdminPage() { | @@ -638,7 +638,7 @@ export function MetadataAdminPage() { | ||
| 638 | {cfTypeKind === 'string' && ( | 638 | {cfTypeKind === 'string' && ( |
| 639 | <div> | 639 | <div> |
| 640 | <label className="block text-xs font-medium text-slate-700"> | 640 | <label className="block text-xs font-medium text-slate-700"> |
| 641 | - Max length | 641 | + {t('label.maxLength')} |
| 642 | </label> | 642 | </label> |
| 643 | <input | 643 | <input |
| 644 | type="number" | 644 | type="number" |
| @@ -656,7 +656,7 @@ export function MetadataAdminPage() { | @@ -656,7 +656,7 @@ export function MetadataAdminPage() { | ||
| 656 | checked={cfRequired} | 656 | checked={cfRequired} |
| 657 | onChange={(e) => setCfRequired(e.target.checked)} | 657 | onChange={(e) => setCfRequired(e.target.checked)} |
| 658 | /> | 658 | /> |
| 659 | - Required | 659 | + {t('label.required')} |
| 660 | </label> | 660 | </label> |
| 661 | <label className="flex items-center gap-1.5 text-sm text-slate-700"> | 661 | <label className="flex items-center gap-1.5 text-sm text-slate-700"> |
| 662 | <input | 662 | <input |
| @@ -664,14 +664,14 @@ export function MetadataAdminPage() { | @@ -664,14 +664,14 @@ export function MetadataAdminPage() { | ||
| 664 | checked={cfPii} | 664 | checked={cfPii} |
| 665 | onChange={(e) => setCfPii(e.target.checked)} | 665 | onChange={(e) => setCfPii(e.target.checked)} |
| 666 | /> | 666 | /> |
| 667 | - PII | 667 | + {t('label.pii')} |
| 668 | </label> | 668 | </label> |
| 669 | </div> | 669 | </div> |
| 670 | </div> | 670 | </div> |
| 671 | <div className="grid grid-cols-2 gap-3"> | 671 | <div className="grid grid-cols-2 gap-3"> |
| 672 | <div> | 672 | <div> |
| 673 | <label className="block text-xs font-medium text-slate-700"> | 673 | <label className="block text-xs font-medium text-slate-700"> |
| 674 | - Label EN | 674 | + {t('label.labelEn')} |
| 675 | </label> | 675 | </label> |
| 676 | <input | 676 | <input |
| 677 | type="text" | 677 | type="text" |
| @@ -683,7 +683,7 @@ export function MetadataAdminPage() { | @@ -683,7 +683,7 @@ export function MetadataAdminPage() { | ||
| 683 | </div> | 683 | </div> |
| 684 | <div> | 684 | <div> |
| 685 | <label className="block text-xs font-medium text-slate-700"> | 685 | <label className="block text-xs font-medium text-slate-700"> |
| 686 | - Label zh-CN | 686 | + {t('label.labelZhCn')} |
| 687 | </label> | 687 | </label> |
| 688 | <input | 688 | <input |
| 689 | type="text" | 689 | type="text" |
| @@ -745,7 +745,7 @@ export function MetadataAdminPage() { | @@ -745,7 +745,7 @@ export function MetadataAdminPage() { | ||
| 745 | className="btn-primary" | 745 | className="btn-primary" |
| 746 | onClick={() => navigate('/admin/metadata/forms/new')} | 746 | onClick={() => navigate('/admin/metadata/forms/new')} |
| 747 | > | 747 | > |
| 748 | - + New Form | 748 | + {t('action.newForm')} |
| 749 | </button> | 749 | </button> |
| 750 | </div> | 750 | </div> |
| 751 | <DataTable | 751 | <DataTable |
| @@ -764,7 +764,7 @@ export function MetadataAdminPage() { | @@ -764,7 +764,7 @@ export function MetadataAdminPage() { | ||
| 764 | className="btn-primary" | 764 | className="btn-primary" |
| 765 | onClick={() => navigate('/admin/metadata/list-views/new')} | 765 | onClick={() => navigate('/admin/metadata/list-views/new')} |
| 766 | > | 766 | > |
| 767 | - + New List View | 767 | + {t('action.newListView')} |
| 768 | </button> | 768 | </button> |
| 769 | </div> | 769 | </div> |
| 770 | <DataTable | 770 | <DataTable |
| @@ -915,7 +915,7 @@ export function MetadataAdminPage() { | @@ -915,7 +915,7 @@ export function MetadataAdminPage() { | ||
| 915 | ]) | 915 | ]) |
| 916 | } | 916 | } |
| 917 | > | 917 | > |
| 918 | - + Add Condition | 918 | + {t('action.addCondition')} |
| 919 | </button> | 919 | </button> |
| 920 | </div> | 920 | </div> |
| 921 | 921 | ||
| @@ -982,7 +982,7 @@ export function MetadataAdminPage() { | @@ -982,7 +982,7 @@ export function MetadataAdminPage() { | ||
| 982 | ]) | 982 | ]) |
| 983 | } | 983 | } |
| 984 | > | 984 | > |
| 985 | - + Add Action | 985 | + {t('action.addAction')} |
| 986 | </button> | 986 | </button> |
| 987 | </div> | 987 | </div> |
| 988 | 988 | ||
| @@ -1030,7 +1030,7 @@ export function MetadataAdminPage() { | @@ -1030,7 +1030,7 @@ export function MetadataAdminPage() { | ||
| 1030 | <div> | 1030 | <div> |
| 1031 | <PageHeader | 1031 | <PageHeader |
| 1032 | title={t('page.metadataAdmin.title')} | 1032 | title={t('page.metadataAdmin.title')} |
| 1033 | - subtitle="Browse and manage metadata definitions" | 1033 | + subtitle={t('page.metadataAdmin.subtitle')} |
| 1034 | /> | 1034 | /> |
| 1035 | 1035 | ||
| 1036 | {/* Tab bar */} | 1036 | {/* Tab bar */} |
web/src/pages/MovementsPage.tsx
| @@ -5,6 +5,7 @@ import { PageHeader } from '@/components/PageHeader' | @@ -5,6 +5,7 @@ import { PageHeader } from '@/components/PageHeader' | ||
| 5 | import { Loading } from '@/components/Loading' | 5 | import { Loading } from '@/components/Loading' |
| 6 | import { ErrorBox } from '@/components/ErrorBox' | 6 | import { ErrorBox } from '@/components/ErrorBox' |
| 7 | import { DataTable, type Column } from '@/components/DataTable' | 7 | import { DataTable, type Column } from '@/components/DataTable' |
| 8 | +import { useT } from '@/i18n/LocaleContext' | ||
| 8 | 9 | ||
| 9 | interface Row extends Record<string, unknown> { | 10 | interface Row extends Record<string, unknown> { |
| 10 | id: string | 11 | id: string |
| @@ -17,6 +18,7 @@ interface Row extends Record<string, unknown> { | @@ -17,6 +18,7 @@ interface Row extends Record<string, unknown> { | ||
| 17 | } | 18 | } |
| 18 | 19 | ||
| 19 | export function MovementsPage() { | 20 | export function MovementsPage() { |
| 21 | + const t = useT() | ||
| 20 | const [rows, setRows] = useState<Row[]>([]) | 22 | const [rows, setRows] = useState<Row[]>([]) |
| 21 | const [error, setError] = useState<Error | null>(null) | 23 | const [error, setError] = useState<Error | null>(null) |
| 22 | const [loading, setLoading] = useState(true) | 24 | const [loading, setLoading] = useState(true) |
| @@ -46,15 +48,15 @@ export function MovementsPage() { | @@ -46,15 +48,15 @@ export function MovementsPage() { | ||
| 46 | 48 | ||
| 47 | const columns: Column<Row>[] = [ | 49 | const columns: Column<Row>[] = [ |
| 48 | { | 50 | { |
| 49 | - header: 'Occurred', | 51 | + header: t('label.occurred'), |
| 50 | key: 'occurredAt', | 52 | key: 'occurredAt', |
| 51 | render: (r) => | 53 | render: (r) => |
| 52 | - r.occurredAt ? new Date(r.occurredAt).toLocaleString() : '—', | 54 | + r.occurredAt ? new Date(r.occurredAt).toLocaleString() : '\u2014', |
| 53 | }, | 55 | }, |
| 54 | - { header: 'Item', key: 'itemCode', render: (r) => <span className="font-mono">{r.itemCode}</span> }, | ||
| 55 | - { header: 'Location', key: 'locationCode', render: (r) => <span className="font-mono">{r.locationCode}</span> }, | 56 | + { header: t('label.item'), key: 'itemCode', render: (r) => <span className="font-mono">{r.itemCode}</span> }, |
| 57 | + { header: t('label.location'), key: 'locationCode', render: (r) => <span className="font-mono">{r.locationCode}</span> }, | ||
| 56 | { | 58 | { |
| 57 | - header: 'Δ', | 59 | + header: t('label.delta'), |
| 58 | key: 'delta', | 60 | key: 'delta', |
| 59 | render: (r) => { | 61 | render: (r) => { |
| 60 | const n = Number(r.delta) | 62 | const n = Number(r.delta) |
| @@ -62,15 +64,15 @@ export function MovementsPage() { | @@ -62,15 +64,15 @@ export function MovementsPage() { | ||
| 62 | return <span className={`font-mono tabular-nums ${cls}`}>{String(r.delta)}</span> | 64 | return <span className={`font-mono tabular-nums ${cls}`}>{String(r.delta)}</span> |
| 63 | }, | 65 | }, |
| 64 | }, | 66 | }, |
| 65 | - { header: 'Reason', key: 'reason' }, | ||
| 66 | - { header: 'Reference', key: 'reference', render: (r) => r.reference ?? '—' }, | 67 | + { header: t('label.reason'), key: 'reason' }, |
| 68 | + { header: t('label.reference'), key: 'reference', render: (r) => r.reference ?? '\u2014' }, | ||
| 67 | ] | 69 | ] |
| 68 | 70 | ||
| 69 | return ( | 71 | return ( |
| 70 | <div> | 72 | <div> |
| 71 | <PageHeader | 73 | <PageHeader |
| 72 | - title="Stock Movements" | ||
| 73 | - subtitle="Append-only ledger of every change to inventory. Most-recent first." | 74 | + title={t('page.movements.title')} |
| 75 | + subtitle={t('page.movements.subtitle')} | ||
| 74 | /> | 76 | /> |
| 75 | {loading && <Loading />} | 77 | {loading && <Loading />} |
| 76 | {error && <ErrorBox error={error} />} | 78 | {error && <ErrorBox error={error} />} |
web/src/pages/PartnersPage.tsx
| @@ -6,8 +6,10 @@ import { PageHeader } from '@/components/PageHeader' | @@ -6,8 +6,10 @@ import { PageHeader } from '@/components/PageHeader' | ||
| 6 | import { Loading } from '@/components/Loading' | 6 | import { Loading } from '@/components/Loading' |
| 7 | import { ErrorBox } from '@/components/ErrorBox' | 7 | import { ErrorBox } from '@/components/ErrorBox' |
| 8 | import { DataTable, type Column } from '@/components/DataTable' | 8 | import { DataTable, type Column } from '@/components/DataTable' |
| 9 | +import { useT } from '@/i18n/LocaleContext' | ||
| 9 | 10 | ||
| 10 | export function PartnersPage() { | 11 | export function PartnersPage() { |
| 12 | + const t = useT() | ||
| 11 | const [rows, setRows] = useState<Partner[]>([]) | 13 | const [rows, setRows] = useState<Partner[]>([]) |
| 12 | const [error, setError] = useState<Error | null>(null) | 14 | const [error, setError] = useState<Error | null>(null) |
| 13 | const [loading, setLoading] = useState(true) | 15 | const [loading, setLoading] = useState(true) |
| @@ -22,29 +24,29 @@ export function PartnersPage() { | @@ -22,29 +24,29 @@ export function PartnersPage() { | ||
| 22 | 24 | ||
| 23 | const columns: Column<Partner>[] = [ | 25 | const columns: Column<Partner>[] = [ |
| 24 | { | 26 | { |
| 25 | - header: 'Code', | 27 | + header: t('label.code'), |
| 26 | key: 'code', | 28 | key: 'code', |
| 27 | render: (r) => ( | 29 | render: (r) => ( |
| 28 | <Link to={`/partners/${r.id}/edit`} className="font-mono text-brand-600 hover:underline">{r.code}</Link> | 30 | <Link to={`/partners/${r.id}/edit`} className="font-mono text-brand-600 hover:underline">{r.code}</Link> |
| 29 | ), | 31 | ), |
| 30 | }, | 32 | }, |
| 31 | - { header: 'Name', key: 'name' }, | ||
| 32 | - { header: 'Type', key: 'type' }, | ||
| 33 | - { header: 'Email', key: 'email', render: (r) => r.email ?? '—' }, | ||
| 34 | - { header: 'Phone', key: 'phone', render: (r) => r.phone ?? '—' }, | 33 | + { header: t('label.name'), key: 'name' }, |
| 34 | + { header: t('label.type'), key: 'type' }, | ||
| 35 | + { header: t('label.email'), key: 'email', render: (r) => r.email ?? '\u2014' }, | ||
| 36 | + { header: t('label.phone'), key: 'phone', render: (r) => r.phone ?? '\u2014' }, | ||
| 35 | { | 37 | { |
| 36 | - header: 'Active', | 38 | + header: t('label.active'), |
| 37 | key: 'active', | 39 | key: 'active', |
| 38 | - render: (r) => (r.active ? <span className="text-emerald-600">●</span> : <span className="text-slate-300">●</span>), | 40 | + render: (r) => (r.active ? <span className="text-emerald-600">{'\u25CF'}</span> : <span className="text-slate-300">{'\u25CF'}</span>), |
| 39 | }, | 41 | }, |
| 40 | ] | 42 | ] |
| 41 | 43 | ||
| 42 | return ( | 44 | return ( |
| 43 | <div> | 45 | <div> |
| 44 | <PageHeader | 46 | <PageHeader |
| 45 | - title="Partners" | ||
| 46 | - subtitle="Customers, suppliers, and dual-role partners." | ||
| 47 | - actions={<Link to="/partners/new" className="btn-primary">+ New Partner</Link>} | 47 | + title={t('page.partners.title')} |
| 48 | + subtitle={t('page.partners.subtitle')} | ||
| 49 | + actions={<Link to="/partners/new" className="btn-primary">{t('action.newPartner')}</Link>} | ||
| 48 | /> | 50 | /> |
| 49 | {loading && <Loading />} | 51 | {loading && <Loading />} |
| 50 | {error && <ErrorBox error={error} />} | 52 | {error && <ErrorBox error={error} />} |
web/src/pages/PurchaseOrderDetailPage.tsx
| 1 | -// Purchase-order detail screen — symmetric to SalesOrderDetailPage. | 1 | +// Purchase-order detail screen -- symmetric to SalesOrderDetailPage. |
| 2 | // Confirm a DRAFT, receive a CONFIRMED into a warehouse, or cancel | 2 | // Confirm a DRAFT, receive a CONFIRMED into a warehouse, or cancel |
| 3 | // either. Each action surfaces the corresponding stock movement | 3 | // either. Each action surfaces the corresponding stock movement |
| 4 | -// (PURCHASE_RECEIPT) and pbc-finance journal entry (AP, POSTED → | 4 | +// (PURCHASE_RECEIPT) and pbc-finance journal entry (AP, POSTED -> |
| 5 | // SETTLED on receive) inline. | 5 | // SETTLED on receive) inline. |
| 6 | 6 | ||
| 7 | import { useCallback, useEffect, useState } from 'react' | 7 | import { useCallback, useEffect, useState } from 'react' |
| @@ -12,10 +12,12 @@ import { PageHeader } from '@/components/PageHeader' | @@ -12,10 +12,12 @@ import { PageHeader } from '@/components/PageHeader' | ||
| 12 | import { Loading } from '@/components/Loading' | 12 | import { Loading } from '@/components/Loading' |
| 13 | import { ErrorBox } from '@/components/ErrorBox' | 13 | import { ErrorBox } from '@/components/ErrorBox' |
| 14 | import { StatusBadge } from '@/components/StatusBadge' | 14 | import { StatusBadge } from '@/components/StatusBadge' |
| 15 | +import { useT } from '@/i18n/LocaleContext' | ||
| 15 | 16 | ||
| 16 | export function PurchaseOrderDetailPage() { | 17 | export function PurchaseOrderDetailPage() { |
| 17 | const { id = '' } = useParams<{ id: string }>() | 18 | const { id = '' } = useParams<{ id: string }>() |
| 18 | const navigate = useNavigate() | 19 | const navigate = useNavigate() |
| 20 | + const t = useT() | ||
| 19 | const [order, setOrder] = useState<PurchaseOrder | null>(null) | 21 | const [order, setOrder] = useState<PurchaseOrder | null>(null) |
| 20 | const [locations, setLocations] = useState<Location[]>([]) | 22 | const [locations, setLocations] = useState<Location[]>([]) |
| 21 | const [receivingLocation, setReceivingLocation] = useState<string>('') | 23 | const [receivingLocation, setReceivingLocation] = useState<string>('') |
| @@ -68,7 +70,7 @@ export function PurchaseOrderDetailPage() { | @@ -68,7 +70,7 @@ export function PurchaseOrderDetailPage() { | ||
| 68 | const updated = await purchaseOrders.confirm(order.id) | 70 | const updated = await purchaseOrders.confirm(order.id) |
| 69 | setOrder(updated) | 71 | setOrder(updated) |
| 70 | await reloadSideEffects(updated.code) | 72 | await reloadSideEffects(updated.code) |
| 71 | - setActionMessage('Confirmed. pbc-finance has posted an AP journal entry.') | 73 | + setActionMessage(t('page.purchaseOrderDetail.confirmMsg')) |
| 72 | } catch (e: unknown) { | 74 | } catch (e: unknown) { |
| 73 | setError(e instanceof Error ? e : new Error(String(e))) | 75 | setError(e instanceof Error ? e : new Error(String(e))) |
| 74 | } finally { | 76 | } finally { |
| @@ -78,7 +80,7 @@ export function PurchaseOrderDetailPage() { | @@ -78,7 +80,7 @@ export function PurchaseOrderDetailPage() { | ||
| 78 | 80 | ||
| 79 | const onReceive = async () => { | 81 | const onReceive = async () => { |
| 80 | if (!receivingLocation) { | 82 | if (!receivingLocation) { |
| 81 | - setError(new Error('Pick a receiving location first.')) | 83 | + setError(new Error(t('page.purchaseOrderDetail.pickLocation'))) |
| 82 | return | 84 | return |
| 83 | } | 85 | } |
| 84 | setActing(true) | 86 | setActing(true) |
| @@ -89,7 +91,7 @@ export function PurchaseOrderDetailPage() { | @@ -89,7 +91,7 @@ export function PurchaseOrderDetailPage() { | ||
| 89 | setOrder(updated) | 91 | setOrder(updated) |
| 90 | await reloadSideEffects(updated.code) | 92 | await reloadSideEffects(updated.code) |
| 91 | setActionMessage( | 93 | setActionMessage( |
| 92 | - `Received into ${receivingLocation}. Stock credited, journal entry settled.`, | 94 | + t('page.purchaseOrderDetail.receiveMsg').replace('{location}', receivingLocation), |
| 93 | ) | 95 | ) |
| 94 | } catch (e: unknown) { | 96 | } catch (e: unknown) { |
| 95 | setError(e instanceof Error ? e : new Error(String(e))) | 97 | setError(e instanceof Error ? e : new Error(String(e))) |
| @@ -106,7 +108,7 @@ export function PurchaseOrderDetailPage() { | @@ -106,7 +108,7 @@ export function PurchaseOrderDetailPage() { | ||
| 106 | const updated = await purchaseOrders.cancel(order.id) | 108 | const updated = await purchaseOrders.cancel(order.id) |
| 107 | setOrder(updated) | 109 | setOrder(updated) |
| 108 | await reloadSideEffects(updated.code) | 110 | await reloadSideEffects(updated.code) |
| 109 | - setActionMessage('Cancelled. pbc-finance has reversed any open journal entry.') | 111 | + setActionMessage(t('page.purchaseOrderDetail.cancelMsg')) |
| 110 | } catch (e: unknown) { | 112 | } catch (e: unknown) { |
| 111 | setError(e instanceof Error ? e : new Error(String(e))) | 113 | setError(e instanceof Error ? e : new Error(String(e))) |
| 112 | } finally { | 114 | } finally { |
| @@ -121,11 +123,11 @@ export function PurchaseOrderDetailPage() { | @@ -121,11 +123,11 @@ export function PurchaseOrderDetailPage() { | ||
| 121 | return ( | 123 | return ( |
| 122 | <div> | 124 | <div> |
| 123 | <PageHeader | 125 | <PageHeader |
| 124 | - title={`Purchase Order ${order.code}`} | ||
| 125 | - subtitle={`Supplier ${order.partnerCode} · ${order.orderDate} · ${order.currencyCode}`} | 126 | + title={t('page.purchaseOrderDetail.title').replace('{code}', order.code)} |
| 127 | + subtitle={`${t('page.purchaseOrderDetail.subtitle').replace('{partner}', order.partnerCode)} \u00B7 ${order.orderDate} \u00B7 ${order.currencyCode}`} | ||
| 126 | actions={ | 128 | actions={ |
| 127 | <button className="btn-secondary" onClick={() => navigate('/purchase-orders')}> | 129 | <button className="btn-secondary" onClick={() => navigate('/purchase-orders')}> |
| 128 | - ← Back | 130 | + {t('action.back')} |
| 129 | </button> | 131 | </button> |
| 130 | } | 132 | } |
| 131 | /> | 133 | /> |
| @@ -133,11 +135,11 @@ export function PurchaseOrderDetailPage() { | @@ -133,11 +135,11 @@ export function PurchaseOrderDetailPage() { | ||
| 133 | <div className="card mb-6 p-5"> | 135 | <div className="card mb-6 p-5"> |
| 134 | <div className="flex flex-wrap items-center justify-between gap-4"> | 136 | <div className="flex flex-wrap items-center justify-between gap-4"> |
| 135 | <div className="flex items-center gap-3"> | 137 | <div className="flex items-center gap-3"> |
| 136 | - <span className="text-sm text-slate-500">Status:</span> | 138 | + <span className="text-sm text-slate-500">{t('label.status')}:</span> |
| 137 | <StatusBadge status={order.status} /> | 139 | <StatusBadge status={order.status} /> |
| 138 | </div> | 140 | </div> |
| 139 | <div className="text-right"> | 141 | <div className="text-right"> |
| 140 | - <div className="text-xs uppercase text-slate-400">Total</div> | 142 | + <div className="text-xs uppercase text-slate-400">{t('label.total')}</div> |
| 141 | <div className="font-mono text-xl font-semibold"> | 143 | <div className="font-mono text-xl font-semibold"> |
| 142 | {Number(order.totalAmount).toLocaleString(undefined, { | 144 | {Number(order.totalAmount).toLocaleString(undefined, { |
| 143 | minimumFractionDigits: 2, | 145 | minimumFractionDigits: 2, |
| @@ -154,10 +156,10 @@ export function PurchaseOrderDetailPage() { | @@ -154,10 +156,10 @@ export function PurchaseOrderDetailPage() { | ||
| 154 | </div> | 156 | </div> |
| 155 | 157 | ||
| 156 | <div className="card mb-6 p-5"> | 158 | <div className="card mb-6 p-5"> |
| 157 | - <h2 className="mb-3 text-base font-semibold text-slate-800">Actions</h2> | 159 | + <h2 className="mb-3 text-base font-semibold text-slate-800">{t('label.actions')}</h2> |
| 158 | <div className="flex flex-wrap items-center gap-3"> | 160 | <div className="flex flex-wrap items-center gap-3"> |
| 159 | <button className="btn-primary" disabled={!canConfirm || acting} onClick={onConfirm}> | 161 | <button className="btn-primary" disabled={!canConfirm || acting} onClick={onConfirm}> |
| 160 | - Confirm | 162 | + {t('action.confirm')} |
| 161 | </button> | 163 | </button> |
| 162 | <div className="flex items-center gap-2"> | 164 | <div className="flex items-center gap-2"> |
| 163 | <select | 165 | <select |
| @@ -173,27 +175,27 @@ export function PurchaseOrderDetailPage() { | @@ -173,27 +175,27 @@ export function PurchaseOrderDetailPage() { | ||
| 173 | ))} | 175 | ))} |
| 174 | </select> | 176 | </select> |
| 175 | <button className="btn-primary" disabled={!canReceive || acting} onClick={onReceive}> | 177 | <button className="btn-primary" disabled={!canReceive || acting} onClick={onReceive}> |
| 176 | - Receive | 178 | + {t('action.receive')} |
| 177 | </button> | 179 | </button> |
| 178 | </div> | 180 | </div> |
| 179 | <button className="btn-danger" disabled={!canCancel || acting} onClick={onCancel}> | 181 | <button className="btn-danger" disabled={!canCancel || acting} onClick={onCancel}> |
| 180 | - Cancel | 182 | + {t('action.cancel')} |
| 181 | </button> | 183 | </button> |
| 182 | </div> | 184 | </div> |
| 183 | </div> | 185 | </div> |
| 184 | 186 | ||
| 185 | <div className="card mb-6"> | 187 | <div className="card mb-6"> |
| 186 | <div className="border-b border-slate-200 px-5 py-3"> | 188 | <div className="border-b border-slate-200 px-5 py-3"> |
| 187 | - <h2 className="text-base font-semibold text-slate-800">Lines</h2> | 189 | + <h2 className="text-base font-semibold text-slate-800">{t('label.lines')}</h2> |
| 188 | </div> | 190 | </div> |
| 189 | <table className="table-base"> | 191 | <table className="table-base"> |
| 190 | <thead className="bg-slate-50"> | 192 | <thead className="bg-slate-50"> |
| 191 | <tr> | 193 | <tr> |
| 192 | - <th>#</th> | ||
| 193 | - <th>Item</th> | ||
| 194 | - <th>Qty</th> | ||
| 195 | - <th>Unit price</th> | ||
| 196 | - <th>Line total</th> | 194 | + <th>{t('label.lineNo')}</th> |
| 195 | + <th>{t('label.item')}</th> | ||
| 196 | + <th>{t('label.qty')}</th> | ||
| 197 | + <th>{t('label.unitPrice')}</th> | ||
| 198 | + <th>{t('label.lineTotal')}</th> | ||
| 197 | </tr> | 199 | </tr> |
| 198 | </thead> | 200 | </thead> |
| 199 | <tbody className="divide-y divide-slate-100"> | 201 | <tbody className="divide-y divide-slate-100"> |
| @@ -222,18 +224,18 @@ export function PurchaseOrderDetailPage() { | @@ -222,18 +224,18 @@ export function PurchaseOrderDetailPage() { | ||
| 222 | <div className="grid gap-6 md:grid-cols-2"> | 224 | <div className="grid gap-6 md:grid-cols-2"> |
| 223 | <div className="card"> | 225 | <div className="card"> |
| 224 | <div className="border-b border-slate-200 px-5 py-3"> | 226 | <div className="border-b border-slate-200 px-5 py-3"> |
| 225 | - <h2 className="text-base font-semibold text-slate-800">Inventory movements</h2> | 227 | + <h2 className="text-base font-semibold text-slate-800">{t('label.inventoryMovements')}</h2> |
| 226 | </div> | 228 | </div> |
| 227 | {movements.length === 0 ? ( | 229 | {movements.length === 0 ? ( |
| 228 | - <div className="p-5 text-sm text-slate-400">No movements yet.</div> | 230 | + <div className="p-5 text-sm text-slate-400">{t('label.noMovementsYet')}</div> |
| 229 | ) : ( | 231 | ) : ( |
| 230 | <table className="table-base"> | 232 | <table className="table-base"> |
| 231 | <thead className="bg-slate-50"> | 233 | <thead className="bg-slate-50"> |
| 232 | <tr> | 234 | <tr> |
| 233 | - <th>Item</th> | ||
| 234 | - <th>Δ</th> | ||
| 235 | - <th>Reason</th> | ||
| 236 | - <th>Reference</th> | 235 | + <th>{t('label.item')}</th> |
| 236 | + <th>{t('label.delta')}</th> | ||
| 237 | + <th>{t('label.reason')}</th> | ||
| 238 | + <th>{t('label.reference')}</th> | ||
| 237 | </tr> | 239 | </tr> |
| 238 | </thead> | 240 | </thead> |
| 239 | <tbody className="divide-y divide-slate-100"> | 241 | <tbody className="divide-y divide-slate-100"> |
| @@ -242,7 +244,7 @@ export function PurchaseOrderDetailPage() { | @@ -242,7 +244,7 @@ export function PurchaseOrderDetailPage() { | ||
| 242 | <td className="font-mono">{m.itemCode}</td> | 244 | <td className="font-mono">{m.itemCode}</td> |
| 243 | <td className="font-mono tabular-nums text-emerald-600">{String(m.delta)}</td> | 245 | <td className="font-mono tabular-nums text-emerald-600">{String(m.delta)}</td> |
| 244 | <td>{m.reason}</td> | 246 | <td>{m.reason}</td> |
| 245 | - <td className="font-mono text-xs text-slate-500">{m.reference ?? '—'}</td> | 247 | + <td className="font-mono text-xs text-slate-500">{m.reference ?? '\u2014'}</td> |
| 246 | </tr> | 248 | </tr> |
| 247 | ))} | 249 | ))} |
| 248 | </tbody> | 250 | </tbody> |
| @@ -251,18 +253,18 @@ export function PurchaseOrderDetailPage() { | @@ -251,18 +253,18 @@ export function PurchaseOrderDetailPage() { | ||
| 251 | </div> | 253 | </div> |
| 252 | <div className="card"> | 254 | <div className="card"> |
| 253 | <div className="border-b border-slate-200 px-5 py-3"> | 255 | <div className="border-b border-slate-200 px-5 py-3"> |
| 254 | - <h2 className="text-base font-semibold text-slate-800">Journal entries</h2> | 256 | + <h2 className="text-base font-semibold text-slate-800">{t('label.journalEntries')}</h2> |
| 255 | </div> | 257 | </div> |
| 256 | {journalEntries.length === 0 ? ( | 258 | {journalEntries.length === 0 ? ( |
| 257 | - <div className="p-5 text-sm text-slate-400">No entries yet.</div> | 259 | + <div className="p-5 text-sm text-slate-400">{t('label.noEntriesYet')}</div> |
| 258 | ) : ( | 260 | ) : ( |
| 259 | <table className="table-base"> | 261 | <table className="table-base"> |
| 260 | <thead className="bg-slate-50"> | 262 | <thead className="bg-slate-50"> |
| 261 | <tr> | 263 | <tr> |
| 262 | - <th>Code</th> | ||
| 263 | - <th>Type</th> | ||
| 264 | - <th>Status</th> | ||
| 265 | - <th>Amount</th> | 264 | + <th>{t('label.code')}</th> |
| 265 | + <th>{t('label.type')}</th> | ||
| 266 | + <th>{t('label.status')}</th> | ||
| 267 | + <th>{t('label.amount')}</th> | ||
| 266 | </tr> | 268 | </tr> |
| 267 | </thead> | 269 | </thead> |
| 268 | <tbody className="divide-y divide-slate-100"> | 270 | <tbody className="divide-y divide-slate-100"> |
web/src/pages/PurchaseOrdersPage.tsx
| @@ -7,8 +7,10 @@ import { Loading } from '@/components/Loading' | @@ -7,8 +7,10 @@ import { Loading } from '@/components/Loading' | ||
| 7 | import { ErrorBox } from '@/components/ErrorBox' | 7 | import { ErrorBox } from '@/components/ErrorBox' |
| 8 | import { DataTable, type Column } from '@/components/DataTable' | 8 | import { DataTable, type Column } from '@/components/DataTable' |
| 9 | import { StatusBadge } from '@/components/StatusBadge' | 9 | import { StatusBadge } from '@/components/StatusBadge' |
| 10 | +import { useT } from '@/i18n/LocaleContext' | ||
| 10 | 11 | ||
| 11 | export function PurchaseOrdersPage() { | 12 | export function PurchaseOrdersPage() { |
| 13 | + const t = useT() | ||
| 12 | const [rows, setRows] = useState<PurchaseOrder[]>([]) | 14 | const [rows, setRows] = useState<PurchaseOrder[]>([]) |
| 13 | const [error, setError] = useState<Error | null>(null) | 15 | const [error, setError] = useState<Error | null>(null) |
| 14 | const [loading, setLoading] = useState(true) | 16 | const [loading, setLoading] = useState(true) |
| @@ -23,7 +25,7 @@ export function PurchaseOrdersPage() { | @@ -23,7 +25,7 @@ export function PurchaseOrdersPage() { | ||
| 23 | 25 | ||
| 24 | const columns: Column<PurchaseOrder>[] = [ | 26 | const columns: Column<PurchaseOrder>[] = [ |
| 25 | { | 27 | { |
| 26 | - header: 'Code', | 28 | + header: t('label.code'), |
| 27 | key: 'code', | 29 | key: 'code', |
| 28 | render: (r) => ( | 30 | render: (r) => ( |
| 29 | <Link to={`/purchase-orders/${r.id}`} className="font-mono text-brand-600 hover:underline"> | 31 | <Link to={`/purchase-orders/${r.id}`} className="font-mono text-brand-600 hover:underline"> |
| @@ -31,12 +33,12 @@ export function PurchaseOrdersPage() { | @@ -31,12 +33,12 @@ export function PurchaseOrdersPage() { | ||
| 31 | </Link> | 33 | </Link> |
| 32 | ), | 34 | ), |
| 33 | }, | 35 | }, |
| 34 | - { header: 'Supplier', key: 'partnerCode', render: (r) => <span className="font-mono">{r.partnerCode}</span> }, | ||
| 35 | - { header: 'Order date', key: 'orderDate' }, | ||
| 36 | - { header: 'Expected', key: 'expectedDate', render: (r) => r.expectedDate ?? '—' }, | ||
| 37 | - { header: 'Status', key: 'status', render: (r) => <StatusBadge status={r.status} /> }, | 36 | + { header: t('label.supplier'), key: 'partnerCode', render: (r) => <span className="font-mono">{r.partnerCode}</span> }, |
| 37 | + { header: t('label.orderDate'), key: 'orderDate' }, | ||
| 38 | + { header: t('label.expected'), key: 'expectedDate', render: (r) => r.expectedDate ?? '\u2014' }, | ||
| 39 | + { header: t('label.status'), key: 'status', render: (r) => <StatusBadge status={r.status} /> }, | ||
| 38 | { | 40 | { |
| 39 | - header: 'Total', | 41 | + header: t('label.total'), |
| 40 | key: 'totalAmount', | 42 | key: 'totalAmount', |
| 41 | render: (r) => ( | 43 | render: (r) => ( |
| 42 | <span className="font-mono tabular-nums"> | 44 | <span className="font-mono tabular-nums"> |
| @@ -52,9 +54,9 @@ export function PurchaseOrdersPage() { | @@ -52,9 +54,9 @@ export function PurchaseOrdersPage() { | ||
| 52 | return ( | 54 | return ( |
| 53 | <div> | 55 | <div> |
| 54 | <PageHeader | 56 | <PageHeader |
| 55 | - title="Purchase Orders" | ||
| 56 | - subtitle="Supplier-facing orders. Confirm and receive to credit inventory." | ||
| 57 | - actions={<Link to="/purchase-orders/new" className="btn-primary">+ New Order</Link>} | 57 | + title={t('page.purchaseOrders.title')} |
| 58 | + subtitle={t('page.purchaseOrders.subtitle')} | ||
| 59 | + actions={<Link to="/purchase-orders/new" className="btn-primary">{t('action.newOrder')}</Link>} | ||
| 58 | /> | 60 | /> |
| 59 | {loading && <Loading />} | 61 | {loading && <Loading />} |
| 60 | {error && <ErrorBox error={error} />} | 62 | {error && <ErrorBox error={error} />} |
web/src/pages/RolesPage.tsx
| @@ -5,8 +5,10 @@ import { PageHeader } from '@/components/PageHeader' | @@ -5,8 +5,10 @@ import { PageHeader } from '@/components/PageHeader' | ||
| 5 | import { Loading } from '@/components/Loading' | 5 | import { Loading } from '@/components/Loading' |
| 6 | import { ErrorBox } from '@/components/ErrorBox' | 6 | import { ErrorBox } from '@/components/ErrorBox' |
| 7 | import { DataTable, type Column } from '@/components/DataTable' | 7 | import { DataTable, type Column } from '@/components/DataTable' |
| 8 | +import { useT } from '@/i18n/LocaleContext' | ||
| 8 | 9 | ||
| 9 | export function RolesPage() { | 10 | export function RolesPage() { |
| 11 | + const t = useT() | ||
| 10 | const [rows, setRows] = useState<Role[]>([]) | 12 | const [rows, setRows] = useState<Role[]>([]) |
| 11 | const [error, setError] = useState<Error | null>(null) | 13 | const [error, setError] = useState<Error | null>(null) |
| 12 | const [loading, setLoading] = useState(true) | 14 | const [loading, setLoading] = useState(true) |
| @@ -43,36 +45,36 @@ export function RolesPage() { | @@ -43,36 +45,36 @@ export function RolesPage() { | ||
| 43 | } | 45 | } |
| 44 | 46 | ||
| 45 | const columns: Column<Role>[] = [ | 47 | const columns: Column<Role>[] = [ |
| 46 | - { header: 'Code', key: 'code', render: (r) => <span className="font-mono">{r.code}</span> }, | ||
| 47 | - { header: 'Name', key: 'name' }, | ||
| 48 | - { header: 'Description', key: 'description', render: (r) => r.description ?? '—' }, | 48 | + { header: t('label.code'), key: 'code', render: (r) => <span className="font-mono">{r.code}</span> }, |
| 49 | + { header: t('label.name'), key: 'name' }, | ||
| 50 | + { header: t('label.description'), key: 'description', render: (r) => r.description ?? '\u2014' }, | ||
| 49 | ] | 51 | ] |
| 50 | 52 | ||
| 51 | return ( | 53 | return ( |
| 52 | <div> | 54 | <div> |
| 53 | <PageHeader | 55 | <PageHeader |
| 54 | - title="Roles" | ||
| 55 | - subtitle="Named bundles of permissions. The 'admin' role has all permissions by default." | 56 | + title={t('page.roles.title')} |
| 57 | + subtitle={t('page.roles.subtitle')} | ||
| 56 | actions={ | 58 | actions={ |
| 57 | <button className="btn-primary" onClick={() => setShowCreate(!showCreate)}> | 59 | <button className="btn-primary" onClick={() => setShowCreate(!showCreate)}> |
| 58 | - {showCreate ? 'Cancel' : '+ New Role'} | 60 | + {showCreate ? t('action.cancel') : t('action.newRole')} |
| 59 | </button> | 61 | </button> |
| 60 | } | 62 | } |
| 61 | /> | 63 | /> |
| 62 | {showCreate && ( | 64 | {showCreate && ( |
| 63 | <form onSubmit={onCreate} className="card p-4 mb-4 max-w-lg flex items-end gap-3"> | 65 | <form onSubmit={onCreate} className="card p-4 mb-4 max-w-lg flex items-end gap-3"> |
| 64 | <div className="flex-1"> | 66 | <div className="flex-1"> |
| 65 | - <label className="block text-xs font-medium text-slate-700">Code</label> | 67 | + <label className="block text-xs font-medium text-slate-700">{t('label.code')}</label> |
| 66 | <input type="text" required value={code} onChange={(e) => setCode(e.target.value)} | 68 | <input type="text" required value={code} onChange={(e) => setCode(e.target.value)} |
| 67 | placeholder="sales-clerk" className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm" /> | 69 | placeholder="sales-clerk" className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm" /> |
| 68 | </div> | 70 | </div> |
| 69 | <div className="flex-1"> | 71 | <div className="flex-1"> |
| 70 | - <label className="block text-xs font-medium text-slate-700">Name</label> | 72 | + <label className="block text-xs font-medium text-slate-700">{t('label.name')}</label> |
| 71 | <input type="text" required value={name} onChange={(e) => setName(e.target.value)} | 73 | <input type="text" required value={name} onChange={(e) => setName(e.target.value)} |
| 72 | placeholder="Sales Clerk" className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm" /> | 74 | placeholder="Sales Clerk" className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm" /> |
| 73 | </div> | 75 | </div> |
| 74 | <button type="submit" className="btn-primary" disabled={creating}> | 76 | <button type="submit" className="btn-primary" disabled={creating}> |
| 75 | - {creating ? '...' : 'Create'} | 77 | + {creating ? '...' : t('action.create')} |
| 76 | </button> | 78 | </button> |
| 77 | </form> | 79 | </form> |
| 78 | )} | 80 | )} |
web/src/pages/SalesOrderDetailPage.tsx
| @@ -3,11 +3,11 @@ | @@ -3,11 +3,11 @@ | ||
| 3 | // **The headline demo flow.** Confirm a DRAFT, ship a CONFIRMED, | 3 | // **The headline demo flow.** Confirm a DRAFT, ship a CONFIRMED, |
| 4 | // or cancel either. Each action updates the order in place, | 4 | // or cancel either. Each action updates the order in place, |
| 5 | // reloads the journal entry list to show the AR row appearing | 5 | // reloads the journal entry list to show the AR row appearing |
| 6 | -// (POSTED → SETTLED), and reloads stock movements to show the | 6 | +// (POSTED -> SETTLED), and reloads stock movements to show the |
| 7 | // SALES_SHIPMENT ledger entry appearing. | 7 | // SALES_SHIPMENT ledger entry appearing. |
| 8 | // | 8 | // |
| 9 | // **Why ship asks for a location.** The framework requires every | 9 | // **Why ship asks for a location.** The framework requires every |
| 10 | -// shipment to name the warehouse the goods came from — the | 10 | +// shipment to name the warehouse the goods came from -- the |
| 11 | // inventory ledger tags the row with that location and the audit | 11 | // inventory ledger tags the row with that location and the audit |
| 12 | // trail must always answer "which warehouse shipped this". The UI | 12 | // trail must always answer "which warehouse shipped this". The UI |
| 13 | // proposes the first WAREHOUSE-typed location it finds; an | 13 | // proposes the first WAREHOUSE-typed location it finds; an |
| @@ -21,10 +21,12 @@ import { PageHeader } from '@/components/PageHeader' | @@ -21,10 +21,12 @@ import { PageHeader } from '@/components/PageHeader' | ||
| 21 | import { Loading } from '@/components/Loading' | 21 | import { Loading } from '@/components/Loading' |
| 22 | import { ErrorBox } from '@/components/ErrorBox' | 22 | import { ErrorBox } from '@/components/ErrorBox' |
| 23 | import { StatusBadge } from '@/components/StatusBadge' | 23 | import { StatusBadge } from '@/components/StatusBadge' |
| 24 | +import { useT } from '@/i18n/LocaleContext' | ||
| 24 | 25 | ||
| 25 | export function SalesOrderDetailPage() { | 26 | export function SalesOrderDetailPage() { |
| 26 | const { id = '' } = useParams<{ id: string }>() | 27 | const { id = '' } = useParams<{ id: string }>() |
| 27 | const navigate = useNavigate() | 28 | const navigate = useNavigate() |
| 29 | + const t = useT() | ||
| 28 | const [order, setOrder] = useState<SalesOrder | null>(null) | 30 | const [order, setOrder] = useState<SalesOrder | null>(null) |
| 29 | const [locations, setLocations] = useState<Location[]>([]) | 31 | const [locations, setLocations] = useState<Location[]>([]) |
| 30 | const [shippingLocation, setShippingLocation] = useState<string>('') | 32 | const [shippingLocation, setShippingLocation] = useState<string>('') |
| @@ -77,7 +79,7 @@ export function SalesOrderDetailPage() { | @@ -77,7 +79,7 @@ export function SalesOrderDetailPage() { | ||
| 77 | const updated = await salesOrders.confirm(order.id) | 79 | const updated = await salesOrders.confirm(order.id) |
| 78 | setOrder(updated) | 80 | setOrder(updated) |
| 79 | await reloadSideEffects(updated.code) | 81 | await reloadSideEffects(updated.code) |
| 80 | - setActionMessage('Confirmed. pbc-finance has posted an AR journal entry.') | 82 | + setActionMessage(t('page.salesOrderDetail.confirmMsg')) |
| 81 | } catch (e: unknown) { | 83 | } catch (e: unknown) { |
| 82 | setError(e instanceof Error ? e : new Error(String(e))) | 84 | setError(e instanceof Error ? e : new Error(String(e))) |
| 83 | } finally { | 85 | } finally { |
| @@ -87,7 +89,7 @@ export function SalesOrderDetailPage() { | @@ -87,7 +89,7 @@ export function SalesOrderDetailPage() { | ||
| 87 | 89 | ||
| 88 | const onShip = async () => { | 90 | const onShip = async () => { |
| 89 | if (!shippingLocation) { | 91 | if (!shippingLocation) { |
| 90 | - setError(new Error('Pick a shipping location first.')) | 92 | + setError(new Error(t('page.salesOrderDetail.pickLocation'))) |
| 91 | return | 93 | return |
| 92 | } | 94 | } |
| 93 | setActing(true) | 95 | setActing(true) |
| @@ -98,7 +100,7 @@ export function SalesOrderDetailPage() { | @@ -98,7 +100,7 @@ export function SalesOrderDetailPage() { | ||
| 98 | setOrder(updated) | 100 | setOrder(updated) |
| 99 | await reloadSideEffects(updated.code) | 101 | await reloadSideEffects(updated.code) |
| 100 | setActionMessage( | 102 | setActionMessage( |
| 101 | - `Shipped from ${shippingLocation}. Stock debited, journal entry settled.`, | 103 | + t('page.salesOrderDetail.shipMsg').replace('{location}', shippingLocation), |
| 102 | ) | 104 | ) |
| 103 | } catch (e: unknown) { | 105 | } catch (e: unknown) { |
| 104 | setError(e instanceof Error ? e : new Error(String(e))) | 106 | setError(e instanceof Error ? e : new Error(String(e))) |
| @@ -115,7 +117,7 @@ export function SalesOrderDetailPage() { | @@ -115,7 +117,7 @@ export function SalesOrderDetailPage() { | ||
| 115 | const updated = await salesOrders.cancel(order.id) | 117 | const updated = await salesOrders.cancel(order.id) |
| 116 | setOrder(updated) | 118 | setOrder(updated) |
| 117 | await reloadSideEffects(updated.code) | 119 | await reloadSideEffects(updated.code) |
| 118 | - setActionMessage('Cancelled. pbc-finance has reversed any open journal entry.') | 120 | + setActionMessage(t('page.salesOrderDetail.cancelMsg')) |
| 119 | } catch (e: unknown) { | 121 | } catch (e: unknown) { |
| 120 | setError(e instanceof Error ? e : new Error(String(e))) | 122 | setError(e instanceof Error ? e : new Error(String(e))) |
| 121 | } finally { | 123 | } finally { |
| @@ -130,11 +132,11 @@ export function SalesOrderDetailPage() { | @@ -130,11 +132,11 @@ export function SalesOrderDetailPage() { | ||
| 130 | return ( | 132 | return ( |
| 131 | <div> | 133 | <div> |
| 132 | <PageHeader | 134 | <PageHeader |
| 133 | - title={`Sales Order ${order.code}`} | ||
| 134 | - subtitle={`Customer ${order.partnerCode} · ${order.orderDate} · ${order.currencyCode}`} | 135 | + title={t('page.salesOrderDetail.title').replace('{code}', order.code)} |
| 136 | + subtitle={`${t('page.salesOrderDetail.subtitle').replace('{partner}', order.partnerCode)} \u00B7 ${order.orderDate} \u00B7 ${order.currencyCode}`} | ||
| 135 | actions={ | 137 | actions={ |
| 136 | <button className="btn-secondary" onClick={() => navigate('/sales-orders')}> | 138 | <button className="btn-secondary" onClick={() => navigate('/sales-orders')}> |
| 137 | - ← Back | 139 | + {t('action.back')} |
| 138 | </button> | 140 | </button> |
| 139 | } | 141 | } |
| 140 | /> | 142 | /> |
| @@ -142,11 +144,11 @@ export function SalesOrderDetailPage() { | @@ -142,11 +144,11 @@ export function SalesOrderDetailPage() { | ||
| 142 | <div className="card mb-6 p-5"> | 144 | <div className="card mb-6 p-5"> |
| 143 | <div className="flex flex-wrap items-center justify-between gap-4"> | 145 | <div className="flex flex-wrap items-center justify-between gap-4"> |
| 144 | <div className="flex items-center gap-3"> | 146 | <div className="flex items-center gap-3"> |
| 145 | - <span className="text-sm text-slate-500">Status:</span> | 147 | + <span className="text-sm text-slate-500">{t('label.status')}:</span> |
| 146 | <StatusBadge status={order.status} /> | 148 | <StatusBadge status={order.status} /> |
| 147 | </div> | 149 | </div> |
| 148 | <div className="text-right"> | 150 | <div className="text-right"> |
| 149 | - <div className="text-xs uppercase text-slate-400">Total</div> | 151 | + <div className="text-xs uppercase text-slate-400">{t('label.total')}</div> |
| 150 | <div className="font-mono text-xl font-semibold"> | 152 | <div className="font-mono text-xl font-semibold"> |
| 151 | {Number(order.totalAmount).toLocaleString(undefined, { | 153 | {Number(order.totalAmount).toLocaleString(undefined, { |
| 152 | minimumFractionDigits: 2, | 154 | minimumFractionDigits: 2, |
| @@ -164,10 +166,10 @@ export function SalesOrderDetailPage() { | @@ -164,10 +166,10 @@ export function SalesOrderDetailPage() { | ||
| 164 | </div> | 166 | </div> |
| 165 | 167 | ||
| 166 | <div className="card mb-6 p-5"> | 168 | <div className="card mb-6 p-5"> |
| 167 | - <h2 className="mb-3 text-base font-semibold text-slate-800">Actions</h2> | 169 | + <h2 className="mb-3 text-base font-semibold text-slate-800">{t('label.actions')}</h2> |
| 168 | <div className="flex flex-wrap items-center gap-3"> | 170 | <div className="flex flex-wrap items-center gap-3"> |
| 169 | <button className="btn-primary" disabled={!canConfirm || acting} onClick={onConfirm}> | 171 | <button className="btn-primary" disabled={!canConfirm || acting} onClick={onConfirm}> |
| 170 | - Confirm | 172 | + {t('action.confirm')} |
| 171 | </button> | 173 | </button> |
| 172 | <div className="flex items-center gap-2"> | 174 | <div className="flex items-center gap-2"> |
| 173 | <select | 175 | <select |
| @@ -183,27 +185,27 @@ export function SalesOrderDetailPage() { | @@ -183,27 +185,27 @@ export function SalesOrderDetailPage() { | ||
| 183 | ))} | 185 | ))} |
| 184 | </select> | 186 | </select> |
| 185 | <button className="btn-primary" disabled={!canShip || acting} onClick={onShip}> | 187 | <button className="btn-primary" disabled={!canShip || acting} onClick={onShip}> |
| 186 | - Ship | 188 | + {t('action.ship')} |
| 187 | </button> | 189 | </button> |
| 188 | </div> | 190 | </div> |
| 189 | <button className="btn-danger" disabled={!canCancel || acting} onClick={onCancel}> | 191 | <button className="btn-danger" disabled={!canCancel || acting} onClick={onCancel}> |
| 190 | - Cancel | 192 | + {t('action.cancel')} |
| 191 | </button> | 193 | </button> |
| 192 | </div> | 194 | </div> |
| 193 | </div> | 195 | </div> |
| 194 | 196 | ||
| 195 | <div className="card mb-6"> | 197 | <div className="card mb-6"> |
| 196 | <div className="border-b border-slate-200 px-5 py-3"> | 198 | <div className="border-b border-slate-200 px-5 py-3"> |
| 197 | - <h2 className="text-base font-semibold text-slate-800">Lines</h2> | 199 | + <h2 className="text-base font-semibold text-slate-800">{t('label.lines')}</h2> |
| 198 | </div> | 200 | </div> |
| 199 | <table className="table-base"> | 201 | <table className="table-base"> |
| 200 | <thead className="bg-slate-50"> | 202 | <thead className="bg-slate-50"> |
| 201 | <tr> | 203 | <tr> |
| 202 | - <th>#</th> | ||
| 203 | - <th>Item</th> | ||
| 204 | - <th>Qty</th> | ||
| 205 | - <th>Unit price</th> | ||
| 206 | - <th>Line total</th> | 204 | + <th>{t('label.lineNo')}</th> |
| 205 | + <th>{t('label.item')}</th> | ||
| 206 | + <th>{t('label.qty')}</th> | ||
| 207 | + <th>{t('label.unitPrice')}</th> | ||
| 208 | + <th>{t('label.lineTotal')}</th> | ||
| 207 | </tr> | 209 | </tr> |
| 208 | </thead> | 210 | </thead> |
| 209 | <tbody className="divide-y divide-slate-100"> | 211 | <tbody className="divide-y divide-slate-100"> |
| @@ -232,18 +234,18 @@ export function SalesOrderDetailPage() { | @@ -232,18 +234,18 @@ export function SalesOrderDetailPage() { | ||
| 232 | <div className="grid gap-6 md:grid-cols-2"> | 234 | <div className="grid gap-6 md:grid-cols-2"> |
| 233 | <div className="card"> | 235 | <div className="card"> |
| 234 | <div className="border-b border-slate-200 px-5 py-3"> | 236 | <div className="border-b border-slate-200 px-5 py-3"> |
| 235 | - <h2 className="text-base font-semibold text-slate-800">Inventory movements</h2> | 237 | + <h2 className="text-base font-semibold text-slate-800">{t('label.inventoryMovements')}</h2> |
| 236 | </div> | 238 | </div> |
| 237 | {movements.length === 0 ? ( | 239 | {movements.length === 0 ? ( |
| 238 | - <div className="p-5 text-sm text-slate-400">No movements yet.</div> | 240 | + <div className="p-5 text-sm text-slate-400">{t('label.noMovementsYet')}</div> |
| 239 | ) : ( | 241 | ) : ( |
| 240 | <table className="table-base"> | 242 | <table className="table-base"> |
| 241 | <thead className="bg-slate-50"> | 243 | <thead className="bg-slate-50"> |
| 242 | <tr> | 244 | <tr> |
| 243 | - <th>Item</th> | ||
| 244 | - <th>Δ</th> | ||
| 245 | - <th>Reason</th> | ||
| 246 | - <th>Reference</th> | 245 | + <th>{t('label.item')}</th> |
| 246 | + <th>{t('label.delta')}</th> | ||
| 247 | + <th>{t('label.reason')}</th> | ||
| 248 | + <th>{t('label.reference')}</th> | ||
| 247 | </tr> | 249 | </tr> |
| 248 | </thead> | 250 | </thead> |
| 249 | <tbody className="divide-y divide-slate-100"> | 251 | <tbody className="divide-y divide-slate-100"> |
| @@ -252,7 +254,7 @@ export function SalesOrderDetailPage() { | @@ -252,7 +254,7 @@ export function SalesOrderDetailPage() { | ||
| 252 | <td className="font-mono">{m.itemCode}</td> | 254 | <td className="font-mono">{m.itemCode}</td> |
| 253 | <td className="font-mono tabular-nums text-rose-600">{String(m.delta)}</td> | 255 | <td className="font-mono tabular-nums text-rose-600">{String(m.delta)}</td> |
| 254 | <td>{m.reason}</td> | 256 | <td>{m.reason}</td> |
| 255 | - <td className="font-mono text-xs text-slate-500">{m.reference ?? '—'}</td> | 257 | + <td className="font-mono text-xs text-slate-500">{m.reference ?? '\u2014'}</td> |
| 256 | </tr> | 258 | </tr> |
| 257 | ))} | 259 | ))} |
| 258 | </tbody> | 260 | </tbody> |
| @@ -261,18 +263,18 @@ export function SalesOrderDetailPage() { | @@ -261,18 +263,18 @@ export function SalesOrderDetailPage() { | ||
| 261 | </div> | 263 | </div> |
| 262 | <div className="card"> | 264 | <div className="card"> |
| 263 | <div className="border-b border-slate-200 px-5 py-3"> | 265 | <div className="border-b border-slate-200 px-5 py-3"> |
| 264 | - <h2 className="text-base font-semibold text-slate-800">Journal entries</h2> | 266 | + <h2 className="text-base font-semibold text-slate-800">{t('label.journalEntries')}</h2> |
| 265 | </div> | 267 | </div> |
| 266 | {journalEntries.length === 0 ? ( | 268 | {journalEntries.length === 0 ? ( |
| 267 | - <div className="p-5 text-sm text-slate-400">No entries yet.</div> | 269 | + <div className="p-5 text-sm text-slate-400">{t('label.noEntriesYet')}</div> |
| 268 | ) : ( | 270 | ) : ( |
| 269 | <table className="table-base"> | 271 | <table className="table-base"> |
| 270 | <thead className="bg-slate-50"> | 272 | <thead className="bg-slate-50"> |
| 271 | <tr> | 273 | <tr> |
| 272 | - <th>Code</th> | ||
| 273 | - <th>Type</th> | ||
| 274 | - <th>Status</th> | ||
| 275 | - <th>Amount</th> | 274 | + <th>{t('label.code')}</th> |
| 275 | + <th>{t('label.type')}</th> | ||
| 276 | + <th>{t('label.status')}</th> | ||
| 277 | + <th>{t('label.amount')}</th> | ||
| 276 | </tr> | 278 | </tr> |
| 277 | </thead> | 279 | </thead> |
| 278 | <tbody className="divide-y divide-slate-100"> | 280 | <tbody className="divide-y divide-slate-100"> |
| @@ -293,7 +295,7 @@ export function SalesOrderDetailPage() { | @@ -293,7 +295,7 @@ export function SalesOrderDetailPage() { | ||
| 293 | </table> | 295 | </table> |
| 294 | )} | 296 | )} |
| 295 | <div className="border-t border-slate-200 px-5 py-3 text-xs text-slate-400"> | 297 | <div className="border-t border-slate-200 px-5 py-3 text-xs text-slate-400"> |
| 296 | - <Link to="/journal-entries" className="hover:underline">View all journal entries →</Link> | 298 | + <Link to="/journal-entries" className="hover:underline">{t('label.viewAllJournalEntries')} →</Link> |
| 297 | </div> | 299 | </div> |
| 298 | </div> | 300 | </div> |
| 299 | </div> | 301 | </div> |
web/src/pages/SalesOrdersPage.tsx
| @@ -7,8 +7,10 @@ import { Loading } from '@/components/Loading' | @@ -7,8 +7,10 @@ import { Loading } from '@/components/Loading' | ||
| 7 | import { ErrorBox } from '@/components/ErrorBox' | 7 | import { ErrorBox } from '@/components/ErrorBox' |
| 8 | import { DataTable, type Column } from '@/components/DataTable' | 8 | import { DataTable, type Column } from '@/components/DataTable' |
| 9 | import { StatusBadge } from '@/components/StatusBadge' | 9 | import { StatusBadge } from '@/components/StatusBadge' |
| 10 | +import { useT } from '@/i18n/LocaleContext' | ||
| 10 | 11 | ||
| 11 | export function SalesOrdersPage() { | 12 | export function SalesOrdersPage() { |
| 13 | + const t = useT() | ||
| 12 | const [rows, setRows] = useState<SalesOrder[]>([]) | 14 | const [rows, setRows] = useState<SalesOrder[]>([]) |
| 13 | const [error, setError] = useState<Error | null>(null) | 15 | const [error, setError] = useState<Error | null>(null) |
| 14 | const [loading, setLoading] = useState(true) | 16 | const [loading, setLoading] = useState(true) |
| @@ -23,7 +25,7 @@ export function SalesOrdersPage() { | @@ -23,7 +25,7 @@ export function SalesOrdersPage() { | ||
| 23 | 25 | ||
| 24 | const columns: Column<SalesOrder>[] = [ | 26 | const columns: Column<SalesOrder>[] = [ |
| 25 | { | 27 | { |
| 26 | - header: 'Code', | 28 | + header: t('label.code'), |
| 27 | key: 'code', | 29 | key: 'code', |
| 28 | render: (r) => ( | 30 | render: (r) => ( |
| 29 | <Link to={`/sales-orders/${r.id}`} className="font-mono text-brand-600 hover:underline"> | 31 | <Link to={`/sales-orders/${r.id}`} className="font-mono text-brand-600 hover:underline"> |
| @@ -31,11 +33,11 @@ export function SalesOrdersPage() { | @@ -31,11 +33,11 @@ export function SalesOrdersPage() { | ||
| 31 | </Link> | 33 | </Link> |
| 32 | ), | 34 | ), |
| 33 | }, | 35 | }, |
| 34 | - { header: 'Customer', key: 'partnerCode', render: (r) => <span className="font-mono">{r.partnerCode}</span> }, | ||
| 35 | - { header: 'Date', key: 'orderDate' }, | ||
| 36 | - { header: 'Status', key: 'status', render: (r) => <StatusBadge status={r.status} /> }, | 36 | + { header: t('label.customer'), key: 'partnerCode', render: (r) => <span className="font-mono">{r.partnerCode}</span> }, |
| 37 | + { header: t('label.date'), key: 'orderDate' }, | ||
| 38 | + { header: t('label.status'), key: 'status', render: (r) => <StatusBadge status={r.status} /> }, | ||
| 37 | { | 39 | { |
| 38 | - header: 'Total', | 40 | + header: t('label.total'), |
| 39 | key: 'totalAmount', | 41 | key: 'totalAmount', |
| 40 | render: (r) => ( | 42 | render: (r) => ( |
| 41 | <span className="font-mono tabular-nums"> | 43 | <span className="font-mono tabular-nums"> |
| @@ -48,7 +50,7 @@ export function SalesOrdersPage() { | @@ -48,7 +50,7 @@ export function SalesOrdersPage() { | ||
| 48 | ), | 50 | ), |
| 49 | }, | 51 | }, |
| 50 | { | 52 | { |
| 51 | - header: 'Lines', | 53 | + header: t('label.lines'), |
| 52 | key: 'lines', | 54 | key: 'lines', |
| 53 | render: (r) => <span className="text-slate-500">{r.lines.length}</span>, | 55 | render: (r) => <span className="text-slate-500">{r.lines.length}</span>, |
| 54 | }, | 56 | }, |
| @@ -57,10 +59,10 @@ export function SalesOrdersPage() { | @@ -57,10 +59,10 @@ export function SalesOrdersPage() { | ||
| 57 | return ( | 59 | return ( |
| 58 | <div> | 60 | <div> |
| 59 | <PageHeader | 61 | <PageHeader |
| 60 | - title="Sales Orders" | ||
| 61 | - subtitle="Customer-facing orders. Confirm to auto-generate production work orders." | 62 | + title={t('page.salesOrders.title')} |
| 63 | + subtitle={t('page.salesOrders.subtitle')} | ||
| 62 | actions={ | 64 | actions={ |
| 63 | - <Link to="/sales-orders/new" className="btn-primary">+ New Order</Link> | 65 | + <Link to="/sales-orders/new" className="btn-primary">{t('action.newOrder')}</Link> |
| 64 | } | 66 | } |
| 65 | /> | 67 | /> |
| 66 | {loading && <Loading />} | 68 | {loading && <Loading />} |
web/src/pages/ShopFloorPage.tsx
| @@ -3,7 +3,7 @@ | @@ -3,7 +3,7 @@ | ||
| 3 | // Polls /api/v1/production/work-orders/shop-floor every 5s and | 3 | // Polls /api/v1/production/work-orders/shop-floor every 5s and |
| 4 | // renders one card per IN_PROGRESS work order with its current | 4 | // renders one card per IN_PROGRESS work order with its current |
| 5 | // operation, planned vs actual minutes, and operations completed. | 5 | // operation, planned vs actual minutes, and operations completed. |
| 6 | -// Designed to be projected on a wall-mounted screen — the cards | 6 | +// Designed to be projected on a wall-mounted screen -- the cards |
| 7 | // are large, the typography is high-contrast, and the only state | 7 | // are large, the typography is high-contrast, and the only state |
| 8 | // is "what's running right now". | 8 | // is "what's running right now". |
| 9 | 9 | ||
| @@ -15,10 +15,12 @@ import { PageHeader } from '@/components/PageHeader' | @@ -15,10 +15,12 @@ import { PageHeader } from '@/components/PageHeader' | ||
| 15 | import { Loading } from '@/components/Loading' | 15 | import { Loading } from '@/components/Loading' |
| 16 | import { ErrorBox } from '@/components/ErrorBox' | 16 | import { ErrorBox } from '@/components/ErrorBox' |
| 17 | import { StatusBadge } from '@/components/StatusBadge' | 17 | import { StatusBadge } from '@/components/StatusBadge' |
| 18 | +import { useT } from '@/i18n/LocaleContext' | ||
| 18 | 19 | ||
| 19 | const POLL_MS = 5000 | 20 | const POLL_MS = 5000 |
| 20 | 21 | ||
| 21 | export function ShopFloorPage() { | 22 | export function ShopFloorPage() { |
| 23 | + const t = useT() | ||
| 22 | const [rows, setRows] = useState<ShopFloorEntry[]>([]) | 24 | const [rows, setRows] = useState<ShopFloorEntry[]>([]) |
| 23 | const [error, setError] = useState<Error | null>(null) | 25 | const [error, setError] = useState<Error | null>(null) |
| 24 | const [loading, setLoading] = useState(true) | 26 | const [loading, setLoading] = useState(true) |
| @@ -52,16 +54,16 @@ export function ShopFloorPage() { | @@ -52,16 +54,16 @@ export function ShopFloorPage() { | ||
| 52 | return ( | 54 | return ( |
| 53 | <div> | 55 | <div> |
| 54 | <PageHeader | 56 | <PageHeader |
| 55 | - title="Shop Floor" | ||
| 56 | - subtitle={`Live view of every work order in progress · refreshes every ${POLL_MS / 1000}s${ | ||
| 57 | - updatedAt ? ' · last update ' + updatedAt.toLocaleTimeString() : '' | 57 | + title={t('page.shopFloor.title')} |
| 58 | + subtitle={`${t('page.shopFloor.subtitle')} \u00B7 ${t('page.shopFloor.refreshInterval').replace('{seconds}', String(POLL_MS / 1000))}${ | ||
| 59 | + updatedAt ? ' \u00B7 ' + t('page.shopFloor.lastUpdate') + ' ' + updatedAt.toLocaleTimeString() : '' | ||
| 58 | }`} | 60 | }`} |
| 59 | /> | 61 | /> |
| 60 | {loading && <Loading />} | 62 | {loading && <Loading />} |
| 61 | {error && <ErrorBox error={error} />} | 63 | {error && <ErrorBox error={error} />} |
| 62 | {!loading && rows.length === 0 && ( | 64 | {!loading && rows.length === 0 && ( |
| 63 | <div className="card p-8 text-center text-sm text-slate-400"> | 65 | <div className="card p-8 text-center text-sm text-slate-400"> |
| 64 | - No work orders are in progress right now. | 66 | + {t('page.shopFloor.noWorkOrders')} |
| 65 | </div> | 67 | </div> |
| 66 | )} | 68 | )} |
| 67 | <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> | 69 | <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> |
| @@ -80,15 +82,15 @@ export function ShopFloorPage() { | @@ -80,15 +82,15 @@ export function ShopFloorPage() { | ||
| 80 | {r.workOrderCode} | 82 | {r.workOrderCode} |
| 81 | </span> | 83 | </span> |
| 82 | <span className="text-xs text-slate-500"> | 84 | <span className="text-xs text-slate-500"> |
| 83 | - {r.operationsCompleted} / {r.operationsTotal} ops | 85 | + {r.operationsCompleted} / {r.operationsTotal} {t('page.shopFloor.ops')} |
| 84 | </span> | 86 | </span> |
| 85 | </div> | 87 | </div> |
| 86 | <div className="mb-3 text-xs text-slate-500"> | 88 | <div className="mb-3 text-xs text-slate-500"> |
| 87 | - Output: <span className="font-mono">{r.outputItemCode}</span> ×{' '} | 89 | + {t('label.output')}: <span className="font-mono">{r.outputItemCode}</span> {'\u00D7'}{' '} |
| 88 | {String(r.outputQuantity)} | 90 | {String(r.outputQuantity)} |
| 89 | </div> | 91 | </div> |
| 90 | <div className="mb-3"> | 92 | <div className="mb-3"> |
| 91 | - <div className="text-xs text-slate-400">Current operation</div> | 93 | + <div className="text-xs text-slate-400">{t('page.shopFloor.currentOp')}</div> |
| 92 | {r.currentOperationCode ? ( | 94 | {r.currentOperationCode ? ( |
| 93 | <div className="mt-1 flex items-center gap-2"> | 95 | <div className="mt-1 flex items-center gap-2"> |
| 94 | <span className="font-mono font-medium">{r.currentOperationCode}</span> | 96 | <span className="font-mono font-medium">{r.currentOperationCode}</span> |
| @@ -96,13 +98,13 @@ export function ShopFloorPage() { | @@ -96,13 +98,13 @@ export function ShopFloorPage() { | ||
| 96 | {r.currentOperationStatus && <StatusBadge status={r.currentOperationStatus} />} | 98 | {r.currentOperationStatus && <StatusBadge status={r.currentOperationStatus} />} |
| 97 | </div> | 99 | </div> |
| 98 | ) : ( | 100 | ) : ( |
| 99 | - <div className="mt-1 text-sm text-slate-400">No routing</div> | 101 | + <div className="mt-1 text-sm text-slate-400">{t('page.shopFloor.noRouting')}</div> |
| 100 | )} | 102 | )} |
| 101 | </div> | 103 | </div> |
| 102 | <div className="mt-2"> | 104 | <div className="mt-2"> |
| 103 | <div className="mb-1 flex items-center justify-between text-xs text-slate-500"> | 105 | <div className="mb-1 flex items-center justify-between text-xs text-slate-500"> |
| 104 | - <span>{act.toFixed(0)} actual min</span> | ||
| 105 | - <span>{std.toFixed(0)} std min</span> | 106 | + <span>{act.toFixed(0)} {t('page.shopFloor.actualMin')}</span> |
| 107 | + <span>{std.toFixed(0)} {t('page.shopFloor.stdMin')}</span> | ||
| 106 | </div> | 108 | </div> |
| 107 | <div className="h-2 overflow-hidden rounded-full bg-slate-100"> | 109 | <div className="h-2 overflow-hidden rounded-full bg-slate-100"> |
| 108 | <div | 110 | <div |
web/src/pages/TaskDetailPage.tsx
| @@ -77,15 +77,15 @@ export function TaskDetailPage() { | @@ -77,15 +77,15 @@ export function TaskDetailPage() { | ||
| 77 | 77 | ||
| 78 | <div className="card p-4 space-y-4"> | 78 | <div className="card p-4 space-y-4"> |
| 79 | <dl className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm max-w-lg"> | 79 | <dl className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm max-w-lg"> |
| 80 | - <dt className="font-medium text-slate-500">Task ID</dt> | 80 | + <dt className="font-medium text-slate-500">{t('label.taskId')}</dt> |
| 81 | <dd className="font-mono">{task.taskId}</dd> | 81 | <dd className="font-mono">{task.taskId}</dd> |
| 82 | - <dt className="font-medium text-slate-500">Process</dt> | 82 | + <dt className="font-medium text-slate-500">{t('label.process')}</dt> |
| 83 | <dd>{task.processDefinitionKey}</dd> | 83 | <dd>{task.processDefinitionKey}</dd> |
| 84 | - <dt className="font-medium text-slate-500">Created</dt> | 84 | + <dt className="font-medium text-slate-500">{t('label.created')}</dt> |
| 85 | <dd>{task.createTime}</dd> | 85 | <dd>{task.createTime}</dd> |
| 86 | - <dt className="font-medium text-slate-500">Assignee</dt> | 86 | + <dt className="font-medium text-slate-500">{t('label.assignee')}</dt> |
| 87 | <dd>{task.assignee ?? '\u2014'}</dd> | 87 | <dd>{task.assignee ?? '\u2014'}</dd> |
| 88 | - <dt className="font-medium text-slate-500">Form Key</dt> | 88 | + <dt className="font-medium text-slate-500">{t('label.formKey')}</dt> |
| 89 | <dd className="font-mono">{task.formKey ?? '\u2014'}</dd> | 89 | <dd className="font-mono">{task.formKey ?? '\u2014'}</dd> |
| 90 | </dl> | 90 | </dl> |
| 91 | 91 | ||
| @@ -100,7 +100,7 @@ export function TaskDetailPage() { | @@ -100,7 +100,7 @@ export function TaskDetailPage() { | ||
| 100 | ) : ( | 100 | ) : ( |
| 101 | <div className="space-y-4"> | 101 | <div className="space-y-4"> |
| 102 | <div> | 102 | <div> |
| 103 | - <h3 className="text-sm font-medium text-slate-700 mb-2">Variables</h3> | 103 | + <h3 className="text-sm font-medium text-slate-700 mb-2">{t('label.variables')}</h3> |
| 104 | <pre className="rounded-md bg-slate-50 border border-slate-200 p-3 text-xs overflow-x-auto max-h-64"> | 104 | <pre className="rounded-md bg-slate-50 border border-slate-200 p-3 text-xs overflow-x-auto max-h-64"> |
| 105 | {JSON.stringify(task.variables, null, 2)} | 105 | {JSON.stringify(task.variables, null, 2)} |
| 106 | </pre> | 106 | </pre> |
web/src/pages/UomsPage.tsx
| @@ -5,8 +5,10 @@ import { PageHeader } from '@/components/PageHeader' | @@ -5,8 +5,10 @@ import { PageHeader } from '@/components/PageHeader' | ||
| 5 | import { Loading } from '@/components/Loading' | 5 | import { Loading } from '@/components/Loading' |
| 6 | import { ErrorBox } from '@/components/ErrorBox' | 6 | import { ErrorBox } from '@/components/ErrorBox' |
| 7 | import { DataTable, type Column } from '@/components/DataTable' | 7 | import { DataTable, type Column } from '@/components/DataTable' |
| 8 | +import { useT } from '@/i18n/LocaleContext' | ||
| 8 | 9 | ||
| 9 | export function UomsPage() { | 10 | export function UomsPage() { |
| 11 | + const t = useT() | ||
| 10 | const [rows, setRows] = useState<Uom[]>([]) | 12 | const [rows, setRows] = useState<Uom[]>([]) |
| 11 | const [error, setError] = useState<Error | null>(null) | 13 | const [error, setError] = useState<Error | null>(null) |
| 12 | const [loading, setLoading] = useState(true) | 14 | const [loading, setLoading] = useState(true) |
| @@ -20,16 +22,16 @@ export function UomsPage() { | @@ -20,16 +22,16 @@ export function UomsPage() { | ||
| 20 | }, []) | 22 | }, []) |
| 21 | 23 | ||
| 22 | const columns: Column<Uom>[] = [ | 24 | const columns: Column<Uom>[] = [ |
| 23 | - { header: 'Code', key: 'code', render: (r) => <span className="font-mono">{r.code}</span> }, | ||
| 24 | - { header: 'Name', key: 'name' }, | ||
| 25 | - { header: 'Dimension', key: 'dimension' }, | 25 | + { header: t('label.code'), key: 'code', render: (r) => <span className="font-mono">{r.code}</span> }, |
| 26 | + { header: t('label.name'), key: 'name' }, | ||
| 27 | + { header: t('label.dimension'), key: 'dimension' }, | ||
| 26 | ] | 28 | ] |
| 27 | 29 | ||
| 28 | return ( | 30 | return ( |
| 29 | <div> | 31 | <div> |
| 30 | <PageHeader | 32 | <PageHeader |
| 31 | - title="Units of Measure" | ||
| 32 | - subtitle="Seeded set of UoMs the catalog and inventory PBCs use to quantify items." | 33 | + title={t('page.uoms.title')} |
| 34 | + subtitle={t('page.uoms.subtitle')} | ||
| 33 | /> | 35 | /> |
| 34 | {loading && <Loading />} | 36 | {loading && <Loading />} |
| 35 | {error && <ErrorBox error={error} />} | 37 | {error && <ErrorBox error={error} />} |
web/src/pages/UserDetailPage.tsx
| @@ -11,10 +11,12 @@ import type { Role, User } from '@/types/api' | @@ -11,10 +11,12 @@ import type { Role, User } from '@/types/api' | ||
| 11 | import { PageHeader } from '@/components/PageHeader' | 11 | import { PageHeader } from '@/components/PageHeader' |
| 12 | import { Loading } from '@/components/Loading' | 12 | import { Loading } from '@/components/Loading' |
| 13 | import { ErrorBox } from '@/components/ErrorBox' | 13 | import { ErrorBox } from '@/components/ErrorBox' |
| 14 | +import { useT } from '@/i18n/LocaleContext' | ||
| 14 | 15 | ||
| 15 | export function UserDetailPage() { | 16 | export function UserDetailPage() { |
| 16 | const { id = '' } = useParams<{ id: string }>() | 17 | const { id = '' } = useParams<{ id: string }>() |
| 17 | const navigate = useNavigate() | 18 | const navigate = useNavigate() |
| 19 | + const t = useT() | ||
| 18 | const [user, setUser] = useState<User | null>(null) | 20 | const [user, setUser] = useState<User | null>(null) |
| 19 | const [allRoles, setAllRoles] = useState<Role[]>([]) | 21 | const [allRoles, setAllRoles] = useState<Role[]>([]) |
| 20 | const [userRoleCodes, setUserRoleCodes] = useState<string[]>([]) | 22 | const [userRoleCodes, setUserRoleCodes] = useState<string[]>([]) |
| @@ -64,10 +66,10 @@ export function UserDetailPage() { | @@ -64,10 +66,10 @@ export function UserDetailPage() { | ||
| 64 | <div> | 66 | <div> |
| 65 | <PageHeader | 67 | <PageHeader |
| 66 | title={user.displayName} | 68 | title={user.displayName} |
| 67 | - subtitle={`@${user.username} · ${user.enabled ? 'Active' : 'Disabled'}${user.email ? ' · ' + user.email : ''}`} | 69 | + subtitle={`@${user.username} \u00B7 ${user.enabled ? t('label.activeStatus') : t('label.disabled')}${user.email ? ' \u00B7 ' + user.email : ''}`} |
| 68 | actions={ | 70 | actions={ |
| 69 | <button className="btn-secondary" onClick={() => navigate('/users')}> | 71 | <button className="btn-secondary" onClick={() => navigate('/users')}> |
| 70 | - ← Back | 72 | + {t('action.back')} |
| 71 | </button> | 73 | </button> |
| 72 | } | 74 | } |
| 73 | /> | 75 | /> |
| @@ -75,12 +77,12 @@ export function UserDetailPage() { | @@ -75,12 +77,12 @@ export function UserDetailPage() { | ||
| 75 | {error && <ErrorBox error={error} />} | 77 | {error && <ErrorBox error={error} />} |
| 76 | 78 | ||
| 77 | <div className="card p-5 max-w-lg"> | 79 | <div className="card p-5 max-w-lg"> |
| 78 | - <h2 className="mb-3 text-base font-semibold text-slate-800">Roles</h2> | 80 | + <h2 className="mb-3 text-base font-semibold text-slate-800">{t('page.userDetail.roles')}</h2> |
| 79 | <p className="mb-4 text-xs text-slate-400"> | 81 | <p className="mb-4 text-xs text-slate-400"> |
| 80 | - Toggle roles on/off. Changes take effect on the user's next login. | 82 | + {t('page.userDetail.rolesHint')} |
| 81 | </p> | 83 | </p> |
| 82 | {allRoles.length === 0 && ( | 84 | {allRoles.length === 0 && ( |
| 83 | - <p className="text-sm text-slate-400">No roles defined yet. Create one on the Roles page.</p> | 85 | + <p className="text-sm text-slate-400">{t('page.userDetail.noRoles')}</p> |
| 84 | )} | 86 | )} |
| 85 | <div className="space-y-2"> | 87 | <div className="space-y-2"> |
| 86 | {allRoles.map((role) => { | 88 | {allRoles.map((role) => { |
| @@ -99,7 +101,7 @@ export function UserDetailPage() { | @@ -99,7 +101,7 @@ export function UserDetailPage() { | ||
| 99 | disabled={acting} | 101 | disabled={acting} |
| 100 | onClick={() => toggle(role.code, has)} | 102 | onClick={() => toggle(role.code, has)} |
| 101 | > | 103 | > |
| 102 | - {has ? 'Revoke' : 'Assign'} | 104 | + {has ? t('action.revoke') : t('action.assign')} |
| 103 | </button> | 105 | </button> |
| 104 | </div> | 106 | </div> |
| 105 | ) | 107 | ) |
web/src/pages/UserTasksPage.tsx
| @@ -31,7 +31,7 @@ export function UserTasksPage() { | @@ -31,7 +31,7 @@ export function UserTasksPage() { | ||
| 31 | 31 | ||
| 32 | const columns: Column<UserTaskSummary>[] = [ | 32 | const columns: Column<UserTaskSummary>[] = [ |
| 33 | { | 33 | { |
| 34 | - header: 'Task Name', | 34 | + header: t('label.taskName'), |
| 35 | key: 'taskName', | 35 | key: 'taskName', |
| 36 | render: (r) => ( | 36 | render: (r) => ( |
| 37 | <button | 37 | <button |
| @@ -42,10 +42,10 @@ export function UserTasksPage() { | @@ -42,10 +42,10 @@ export function UserTasksPage() { | ||
| 42 | </button> | 42 | </button> |
| 43 | ), | 43 | ), |
| 44 | }, | 44 | }, |
| 45 | - { header: 'Process', key: 'processDefinitionKey' }, | ||
| 46 | - { header: 'Form Key', key: 'formKey', render: (r) => r.formKey ?? '\u2014' }, | ||
| 47 | - { header: 'Created', key: 'createTime' }, | ||
| 48 | - { header: 'Assignee', key: 'assignee', render: (r) => r.assignee ?? '\u2014' }, | 45 | + { header: t('label.process'), key: 'processDefinitionKey' }, |
| 46 | + { header: t('label.formKey'), key: 'formKey', render: (r) => r.formKey ?? '\u2014' }, | ||
| 47 | + { header: t('label.created'), key: 'createTime' }, | ||
| 48 | + { header: t('label.assignee'), key: 'assignee', render: (r) => r.assignee ?? '\u2014' }, | ||
| 49 | ] | 49 | ] |
| 50 | 50 | ||
| 51 | return ( | 51 | return ( |
| @@ -61,7 +61,7 @@ export function UserTasksPage() { | @@ -61,7 +61,7 @@ export function UserTasksPage() { | ||
| 61 | rows={rows} | 61 | rows={rows} |
| 62 | columns={columns} | 62 | columns={columns} |
| 63 | rowKey={(r) => r.taskId} | 63 | rowKey={(r) => r.taskId} |
| 64 | - empty={<div className="p-6 text-sm text-slate-400">No pending tasks</div>} | 64 | + empty={<div className="p-6 text-sm text-slate-400">{t('label.noPendingTasks')}</div>} |
| 65 | /> | 65 | /> |
| 66 | )} | 66 | )} |
| 67 | </div> | 67 | </div> |
web/src/pages/UsersPage.tsx
| @@ -6,8 +6,10 @@ import { PageHeader } from '@/components/PageHeader' | @@ -6,8 +6,10 @@ import { PageHeader } from '@/components/PageHeader' | ||
| 6 | import { Loading } from '@/components/Loading' | 6 | import { Loading } from '@/components/Loading' |
| 7 | import { ErrorBox } from '@/components/ErrorBox' | 7 | import { ErrorBox } from '@/components/ErrorBox' |
| 8 | import { DataTable, type Column } from '@/components/DataTable' | 8 | import { DataTable, type Column } from '@/components/DataTable' |
| 9 | +import { useT } from '@/i18n/LocaleContext' | ||
| 9 | 10 | ||
| 10 | export function UsersPage() { | 11 | export function UsersPage() { |
| 12 | + const t = useT() | ||
| 11 | const [rows, setRows] = useState<User[]>([]) | 13 | const [rows, setRows] = useState<User[]>([]) |
| 12 | const [error, setError] = useState<Error | null>(null) | 14 | const [error, setError] = useState<Error | null>(null) |
| 13 | const [loading, setLoading] = useState(true) | 15 | const [loading, setLoading] = useState(true) |
| @@ -22,7 +24,7 @@ export function UsersPage() { | @@ -22,7 +24,7 @@ export function UsersPage() { | ||
| 22 | 24 | ||
| 23 | const columns: Column<User>[] = [ | 25 | const columns: Column<User>[] = [ |
| 24 | { | 26 | { |
| 25 | - header: 'Username', | 27 | + header: t('label.username'), |
| 26 | key: 'username', | 28 | key: 'username', |
| 27 | render: (r) => ( | 29 | render: (r) => ( |
| 28 | <Link to={`/users/${r.id}`} className="font-mono text-brand-600 hover:underline"> | 30 | <Link to={`/users/${r.id}`} className="font-mono text-brand-600 hover:underline"> |
| @@ -30,16 +32,16 @@ export function UsersPage() { | @@ -30,16 +32,16 @@ export function UsersPage() { | ||
| 30 | </Link> | 32 | </Link> |
| 31 | ), | 33 | ), |
| 32 | }, | 34 | }, |
| 33 | - { header: 'Display name', key: 'displayName' }, | ||
| 34 | - { header: 'Email', key: 'email', render: (r) => r.email ?? '—' }, | 35 | + { header: t('label.displayName'), key: 'displayName' }, |
| 36 | + { header: t('label.email'), key: 'email', render: (r) => r.email ?? '\u2014' }, | ||
| 35 | { | 37 | { |
| 36 | - header: 'Enabled', | 38 | + header: t('label.enabled'), |
| 37 | key: 'enabled', | 39 | key: 'enabled', |
| 38 | render: (r) => | 40 | render: (r) => |
| 39 | r.enabled ? ( | 41 | r.enabled ? ( |
| 40 | - <span className="text-emerald-600">Active</span> | 42 | + <span className="text-emerald-600">{t('label.activeStatus')}</span> |
| 41 | ) : ( | 43 | ) : ( |
| 42 | - <span className="text-slate-400">Disabled</span> | 44 | + <span className="text-slate-400">{t('label.disabled')}</span> |
| 43 | ), | 45 | ), |
| 44 | }, | 46 | }, |
| 45 | ] | 47 | ] |
| @@ -47,9 +49,9 @@ export function UsersPage() { | @@ -47,9 +49,9 @@ export function UsersPage() { | ||
| 47 | return ( | 49 | return ( |
| 48 | <div> | 50 | <div> |
| 49 | <PageHeader | 51 | <PageHeader |
| 50 | - title="Users" | ||
| 51 | - subtitle="User accounts in this instance. The admin role has all permissions." | ||
| 52 | - actions={<Link to="/users/new" className="btn-primary">+ New User</Link>} | 52 | + title={t('page.users.title')} |
| 53 | + subtitle={t('page.users.subtitle')} | ||
| 54 | + actions={<Link to="/users/new" className="btn-primary">{t('action.newUser')}</Link>} | ||
| 53 | /> | 55 | /> |
| 54 | {loading && <Loading />} | 56 | {loading && <Loading />} |
| 55 | {error && <ErrorBox error={error} />} | 57 | {error && <ErrorBox error={error} />} |
web/src/pages/WorkOrderDetailPage.tsx
| 1 | -// Work-order detail screen — read-only header + start/complete | 1 | +// Work-order detail screen -- read-only header + start/complete |
| 2 | // action verbs that drive the v2 state machine. The shop-floor | 2 | // action verbs that drive the v2 state machine. The shop-floor |
| 3 | // dashboard at /shop-floor handles the per-operation walk for v3 | 3 | // dashboard at /shop-floor handles the per-operation walk for v3 |
| 4 | // routing-equipped orders. v1 SPA keeps this screen simple: start a | 4 | // routing-equipped orders. v1 SPA keeps this screen simple: start a |
| @@ -12,10 +12,12 @@ import { PageHeader } from '@/components/PageHeader' | @@ -12,10 +12,12 @@ import { PageHeader } from '@/components/PageHeader' | ||
| 12 | import { Loading } from '@/components/Loading' | 12 | import { Loading } from '@/components/Loading' |
| 13 | import { ErrorBox } from '@/components/ErrorBox' | 13 | import { ErrorBox } from '@/components/ErrorBox' |
| 14 | import { StatusBadge } from '@/components/StatusBadge' | 14 | import { StatusBadge } from '@/components/StatusBadge' |
| 15 | +import { useT } from '@/i18n/LocaleContext' | ||
| 15 | 16 | ||
| 16 | export function WorkOrderDetailPage() { | 17 | export function WorkOrderDetailPage() { |
| 17 | const { id = '' } = useParams<{ id: string }>() | 18 | const { id = '' } = useParams<{ id: string }>() |
| 18 | const navigate = useNavigate() | 19 | const navigate = useNavigate() |
| 20 | + const t = useT() | ||
| 19 | const [order, setOrder] = useState<WorkOrder | null>(null) | 21 | const [order, setOrder] = useState<WorkOrder | null>(null) |
| 20 | const [locations, setLocations] = useState<Location[]>([]) | 22 | const [locations, setLocations] = useState<Location[]>([]) |
| 21 | const [outputLocation, setOutputLocation] = useState<string>('') | 23 | const [outputLocation, setOutputLocation] = useState<string>('') |
| @@ -60,7 +62,7 @@ export function WorkOrderDetailPage() { | @@ -60,7 +62,7 @@ export function WorkOrderDetailPage() { | ||
| 60 | try { | 62 | try { |
| 61 | await production.startWorkOrder(order.id) | 63 | await production.startWorkOrder(order.id) |
| 62 | await refresh() | 64 | await refresh() |
| 63 | - setActionMessage('Started. Operations can now be walked from the Shop Floor screen.') | 65 | + setActionMessage(t('page.workOrderDetail.startMsg')) |
| 64 | } catch (e: unknown) { | 66 | } catch (e: unknown) { |
| 65 | setError(e instanceof Error ? e : new Error(String(e))) | 67 | setError(e instanceof Error ? e : new Error(String(e))) |
| 66 | } finally { | 68 | } finally { |
| @@ -70,7 +72,7 @@ export function WorkOrderDetailPage() { | @@ -70,7 +72,7 @@ export function WorkOrderDetailPage() { | ||
| 70 | 72 | ||
| 71 | const onComplete = async () => { | 73 | const onComplete = async () => { |
| 72 | if (!outputLocation) { | 74 | if (!outputLocation) { |
| 73 | - setError(new Error('Pick an output location first.')) | 75 | + setError(new Error(t('page.workOrderDetail.pickLocation'))) |
| 74 | return | 76 | return |
| 75 | } | 77 | } |
| 76 | setActing(true) | 78 | setActing(true) |
| @@ -80,7 +82,7 @@ export function WorkOrderDetailPage() { | @@ -80,7 +82,7 @@ export function WorkOrderDetailPage() { | ||
| 80 | await production.completeWorkOrder(order.id, outputLocation) | 82 | await production.completeWorkOrder(order.id, outputLocation) |
| 81 | await refresh() | 83 | await refresh() |
| 82 | setActionMessage( | 84 | setActionMessage( |
| 83 | - `Completed. Materials issued, finished goods credited to ${outputLocation}.`, | 85 | + t('page.workOrderDetail.completeMsg').replace('{location}', outputLocation), |
| 84 | ) | 86 | ) |
| 85 | } catch (e: unknown) { | 87 | } catch (e: unknown) { |
| 86 | setError(e instanceof Error ? e : new Error(String(e))) | 88 | setError(e instanceof Error ? e : new Error(String(e))) |
| @@ -95,20 +97,20 @@ export function WorkOrderDetailPage() { | @@ -95,20 +97,20 @@ export function WorkOrderDetailPage() { | ||
| 95 | return ( | 97 | return ( |
| 96 | <div> | 98 | <div> |
| 97 | <PageHeader | 99 | <PageHeader |
| 98 | - title={`Work Order ${order.code}`} | ||
| 99 | - subtitle={`Output: ${order.outputItemCode} × ${order.outputQuantity}${ | ||
| 100 | - order.sourceSalesOrderCode ? ' · from SO ' + order.sourceSalesOrderCode : '' | 100 | + title={t('page.workOrderDetail.title').replace('{code}', order.code)} |
| 101 | + subtitle={`${t('label.output')}: ${order.outputItemCode} \u00D7 ${order.outputQuantity}${ | ||
| 102 | + order.sourceSalesOrderCode ? ' \u00B7 from SO ' + order.sourceSalesOrderCode : '' | ||
| 101 | }`} | 103 | }`} |
| 102 | actions={ | 104 | actions={ |
| 103 | <button className="btn-secondary" onClick={() => navigate('/work-orders')}> | 105 | <button className="btn-secondary" onClick={() => navigate('/work-orders')}> |
| 104 | - ← Back | 106 | + {t('action.back')} |
| 105 | </button> | 107 | </button> |
| 106 | } | 108 | } |
| 107 | /> | 109 | /> |
| 108 | 110 | ||
| 109 | <div className="card mb-6 p-5"> | 111 | <div className="card mb-6 p-5"> |
| 110 | <div className="flex items-center gap-3"> | 112 | <div className="flex items-center gap-3"> |
| 111 | - <span className="text-sm text-slate-500">Status:</span> | 113 | + <span className="text-sm text-slate-500">{t('label.status')}:</span> |
| 112 | <StatusBadge status={order.status} /> | 114 | <StatusBadge status={order.status} /> |
| 113 | </div> | 115 | </div> |
| 114 | {actionMessage && ( | 116 | {actionMessage && ( |
| @@ -119,10 +121,10 @@ export function WorkOrderDetailPage() { | @@ -119,10 +121,10 @@ export function WorkOrderDetailPage() { | ||
| 119 | </div> | 121 | </div> |
| 120 | 122 | ||
| 121 | <div className="card mb-6 p-5"> | 123 | <div className="card mb-6 p-5"> |
| 122 | - <h2 className="mb-3 text-base font-semibold text-slate-800">Actions</h2> | 124 | + <h2 className="mb-3 text-base font-semibold text-slate-800">{t('label.actions')}</h2> |
| 123 | <div className="flex flex-wrap items-center gap-3"> | 125 | <div className="flex flex-wrap items-center gap-3"> |
| 124 | <button className="btn-primary" disabled={!canStart || acting} onClick={onStart}> | 126 | <button className="btn-primary" disabled={!canStart || acting} onClick={onStart}> |
| 125 | - Start | 127 | + {t('action.start')} |
| 126 | </button> | 128 | </button> |
| 127 | <div className="flex items-center gap-2"> | 129 | <div className="flex items-center gap-2"> |
| 128 | <select | 130 | <select |
| @@ -138,7 +140,7 @@ export function WorkOrderDetailPage() { | @@ -138,7 +140,7 @@ export function WorkOrderDetailPage() { | ||
| 138 | ))} | 140 | ))} |
| 139 | </select> | 141 | </select> |
| 140 | <button className="btn-primary" disabled={!canComplete || acting} onClick={onComplete}> | 142 | <button className="btn-primary" disabled={!canComplete || acting} onClick={onComplete}> |
| 141 | - Complete | 143 | + {t('action.complete')} |
| 142 | </button> | 144 | </button> |
| 143 | </div> | 145 | </div> |
| 144 | </div> | 146 | </div> |
| @@ -147,18 +149,18 @@ export function WorkOrderDetailPage() { | @@ -147,18 +149,18 @@ export function WorkOrderDetailPage() { | ||
| 147 | <div className="grid gap-6 md:grid-cols-2"> | 149 | <div className="grid gap-6 md:grid-cols-2"> |
| 148 | <div className="card"> | 150 | <div className="card"> |
| 149 | <div className="border-b border-slate-200 px-5 py-3"> | 151 | <div className="border-b border-slate-200 px-5 py-3"> |
| 150 | - <h2 className="text-base font-semibold text-slate-800">BOM inputs</h2> | 152 | + <h2 className="text-base font-semibold text-slate-800">{t('label.bomInputs')}</h2> |
| 151 | </div> | 153 | </div> |
| 152 | {order.inputs.length === 0 ? ( | 154 | {order.inputs.length === 0 ? ( |
| 153 | - <div className="p-5 text-sm text-slate-400">No BOM lines.</div> | 155 | + <div className="p-5 text-sm text-slate-400">{t('label.noBomLines')}</div> |
| 154 | ) : ( | 156 | ) : ( |
| 155 | <table className="table-base"> | 157 | <table className="table-base"> |
| 156 | <thead className="bg-slate-50"> | 158 | <thead className="bg-slate-50"> |
| 157 | <tr> | 159 | <tr> |
| 158 | - <th>#</th> | ||
| 159 | - <th>Item</th> | ||
| 160 | - <th>Qty / unit</th> | ||
| 161 | - <th>Source loc</th> | 160 | + <th>{t('label.lineNo')}</th> |
| 161 | + <th>{t('label.item')}</th> | ||
| 162 | + <th>{t('label.qtyPerUnit')}</th> | ||
| 163 | + <th>{t('label.sourceLoc')}</th> | ||
| 162 | </tr> | 164 | </tr> |
| 163 | </thead> | 165 | </thead> |
| 164 | <tbody className="divide-y divide-slate-100"> | 166 | <tbody className="divide-y divide-slate-100"> |
| @@ -176,19 +178,19 @@ export function WorkOrderDetailPage() { | @@ -176,19 +178,19 @@ export function WorkOrderDetailPage() { | ||
| 176 | </div> | 178 | </div> |
| 177 | <div className="card"> | 179 | <div className="card"> |
| 178 | <div className="border-b border-slate-200 px-5 py-3"> | 180 | <div className="border-b border-slate-200 px-5 py-3"> |
| 179 | - <h2 className="text-base font-semibold text-slate-800">Routing operations</h2> | 181 | + <h2 className="text-base font-semibold text-slate-800">{t('label.routingOperations')}</h2> |
| 180 | </div> | 182 | </div> |
| 181 | {order.operations.length === 0 ? ( | 183 | {order.operations.length === 0 ? ( |
| 182 | - <div className="p-5 text-sm text-slate-400">No routing.</div> | 184 | + <div className="p-5 text-sm text-slate-400">{t('label.noRouting')}</div> |
| 183 | ) : ( | 185 | ) : ( |
| 184 | <table className="table-base"> | 186 | <table className="table-base"> |
| 185 | <thead className="bg-slate-50"> | 187 | <thead className="bg-slate-50"> |
| 186 | <tr> | 188 | <tr> |
| 187 | - <th>#</th> | ||
| 188 | - <th>Operation</th> | ||
| 189 | - <th>Work center</th> | ||
| 190 | - <th>Std min</th> | ||
| 191 | - <th>Status</th> | 189 | + <th>{t('label.lineNo')}</th> |
| 190 | + <th>{t('label.operation')}</th> | ||
| 191 | + <th>{t('label.workCenter')}</th> | ||
| 192 | + <th>{t('label.stdMin')}</th> | ||
| 193 | + <th>{t('label.status')}</th> | ||
| 192 | </tr> | 194 | </tr> |
| 193 | </thead> | 195 | </thead> |
| 194 | <tbody className="divide-y divide-slate-100"> | 196 | <tbody className="divide-y divide-slate-100"> |
web/src/pages/WorkOrdersPage.tsx
| @@ -7,8 +7,10 @@ import { Loading } from '@/components/Loading' | @@ -7,8 +7,10 @@ import { Loading } from '@/components/Loading' | ||
| 7 | import { ErrorBox } from '@/components/ErrorBox' | 7 | import { ErrorBox } from '@/components/ErrorBox' |
| 8 | import { DataTable, type Column } from '@/components/DataTable' | 8 | import { DataTable, type Column } from '@/components/DataTable' |
| 9 | import { StatusBadge } from '@/components/StatusBadge' | 9 | import { StatusBadge } from '@/components/StatusBadge' |
| 10 | +import { useT } from '@/i18n/LocaleContext' | ||
| 10 | 11 | ||
| 11 | export function WorkOrdersPage() { | 12 | export function WorkOrdersPage() { |
| 13 | + const t = useT() | ||
| 12 | const [rows, setRows] = useState<WorkOrder[]>([]) | 14 | const [rows, setRows] = useState<WorkOrder[]>([]) |
| 13 | const [error, setError] = useState<Error | null>(null) | 15 | const [error, setError] = useState<Error | null>(null) |
| 14 | const [loading, setLoading] = useState(true) | 16 | const [loading, setLoading] = useState(true) |
| @@ -23,7 +25,7 @@ export function WorkOrdersPage() { | @@ -23,7 +25,7 @@ export function WorkOrdersPage() { | ||
| 23 | 25 | ||
| 24 | const columns: Column<WorkOrder>[] = [ | 26 | const columns: Column<WorkOrder>[] = [ |
| 25 | { | 27 | { |
| 26 | - header: 'Code', | 28 | + header: t('label.code'), |
| 27 | key: 'code', | 29 | key: 'code', |
| 28 | render: (r) => ( | 30 | render: (r) => ( |
| 29 | <Link to={`/work-orders/${r.id}`} className="font-mono text-brand-600 hover:underline"> | 31 | <Link to={`/work-orders/${r.id}`} className="font-mono text-brand-600 hover:underline"> |
| @@ -31,20 +33,20 @@ export function WorkOrdersPage() { | @@ -31,20 +33,20 @@ export function WorkOrdersPage() { | ||
| 31 | </Link> | 33 | </Link> |
| 32 | ), | 34 | ), |
| 33 | }, | 35 | }, |
| 34 | - { header: 'Output', key: 'outputItemCode', render: (r) => <span className="font-mono">{r.outputItemCode}</span> }, | 36 | + { header: t('label.output'), key: 'outputItemCode', render: (r) => <span className="font-mono">{r.outputItemCode}</span> }, |
| 35 | { | 37 | { |
| 36 | - header: 'Qty', | 38 | + header: t('label.qty'), |
| 37 | key: 'outputQuantity', | 39 | key: 'outputQuantity', |
| 38 | render: (r) => <span className="font-mono tabular-nums">{String(r.outputQuantity)}</span>, | 40 | render: (r) => <span className="font-mono tabular-nums">{String(r.outputQuantity)}</span>, |
| 39 | }, | 41 | }, |
| 40 | - { header: 'Status', key: 'status', render: (r) => <StatusBadge status={r.status} /> }, | 42 | + { header: t('label.status'), key: 'status', render: (r) => <StatusBadge status={r.status} /> }, |
| 41 | { | 43 | { |
| 42 | - header: 'Source SO', | 44 | + header: t('label.sourceSO'), |
| 43 | key: 'sourceSalesOrderCode', | 45 | key: 'sourceSalesOrderCode', |
| 44 | - render: (r) => r.sourceSalesOrderCode ?? '—', | 46 | + render: (r) => r.sourceSalesOrderCode ?? '\u2014', |
| 45 | }, | 47 | }, |
| 46 | { | 48 | { |
| 47 | - header: 'Inputs / Ops', | 49 | + header: t('label.inputsOps'), |
| 48 | key: 'opsCount', | 50 | key: 'opsCount', |
| 49 | render: (r) => `${r.inputs.length} / ${r.operations.length}`, | 51 | render: (r) => `${r.inputs.length} / ${r.operations.length}`, |
| 50 | }, | 52 | }, |
| @@ -53,9 +55,9 @@ export function WorkOrdersPage() { | @@ -53,9 +55,9 @@ export function WorkOrdersPage() { | ||
| 53 | return ( | 55 | return ( |
| 54 | <div> | 56 | <div> |
| 55 | <PageHeader | 57 | <PageHeader |
| 56 | - title="Work Orders" | ||
| 57 | - subtitle="Production orders with BOM inputs and routing operations." | ||
| 58 | - actions={<Link to="/work-orders/new" className="btn-primary">+ New Work Order</Link>} | 58 | + title={t('page.workOrders.title')} |
| 59 | + subtitle={t('page.workOrders.subtitle')} | ||
| 60 | + actions={<Link to="/work-orders/new" className="btn-primary">{t('action.newWorkOrder')}</Link>} | ||
| 59 | /> | 61 | /> |
| 60 | {loading && <Loading />} | 62 | {loading && <Loading />} |
| 61 | {error && <ErrorBox error={error} />} | 63 | {error && <ErrorBox error={error} />} |