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 | 5 | import { Loading } from '@/components/Loading' |
| 6 | 6 | import { ErrorBox } from '@/components/ErrorBox' |
| 7 | 7 | import { DataTable, type Column } from '@/components/DataTable' |
| 8 | +import { useT } from '@/i18n/LocaleContext' | |
| 8 | 9 | |
| 9 | 10 | const ACCOUNT_TYPES = ['ASSET', 'LIABILITY', 'EQUITY', 'REVENUE', 'EXPENSE'] as const |
| 10 | 11 | |
| 11 | 12 | export function AccountsPage() { |
| 13 | + const t = useT() | |
| 12 | 14 | const [rows, setRows] = useState<Account[]>([]) |
| 13 | 15 | const [error, setError] = useState<Error | null>(null) |
| 14 | 16 | const [loading, setLoading] = useState(true) |
| ... | ... | @@ -46,44 +48,44 @@ export function AccountsPage() { |
| 46 | 48 | } |
| 47 | 49 | |
| 48 | 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 | 57 | return ( |
| 56 | 58 | <div> |
| 57 | 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 | 62 | actions={ |
| 61 | 63 | <button className="btn-primary" onClick={() => setShowCreate(!showCreate)}> |
| 62 | - {showCreate ? 'Cancel' : '+ New Account'} | |
| 64 | + {showCreate ? t('action.cancel') : t('action.newAccount')} | |
| 63 | 65 | </button> |
| 64 | 66 | } |
| 65 | 67 | /> |
| 66 | 68 | {showCreate && ( |
| 67 | 69 | <form onSubmit={onCreate} className="card p-4 mb-4 max-w-2xl flex flex-wrap items-end gap-3"> |
| 68 | 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 | 72 | <input type="text" required value={code} onChange={(e) => setCode(e.target.value)} |
| 71 | 73 | placeholder="1300" className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm" /> |
| 72 | 74 | </div> |
| 73 | 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 | 77 | <input type="text" required value={name} onChange={(e) => setName(e.target.value)} |
| 76 | 78 | placeholder="Prepaid expenses" className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm" /> |
| 77 | 79 | </div> |
| 78 | 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 | 82 | <select value={accountType} onChange={(e) => setAccountType(e.target.value)} |
| 81 | 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 | 85 | </select> |
| 84 | 86 | </div> |
| 85 | 87 | <button type="submit" className="btn-primary" disabled={creating}> |
| 86 | - {creating ? '...' : 'Create'} | |
| 88 | + {creating ? '...' : t('action.create')} | |
| 87 | 89 | </button> |
| 88 | 90 | </form> |
| 89 | 91 | )} | ... | ... |
web/src/pages/AdjustStockPage.tsx
| ... | ... | @@ -4,9 +4,11 @@ import { catalog, inventory } from '@/api/client' |
| 4 | 4 | import type { Item, Location } from '@/types/api' |
| 5 | 5 | import { PageHeader } from '@/components/PageHeader' |
| 6 | 6 | import { ErrorBox } from '@/components/ErrorBox' |
| 7 | +import { useT } from '@/i18n/LocaleContext' | |
| 7 | 8 | |
| 8 | 9 | export function AdjustStockPage() { |
| 9 | 10 | const navigate = useNavigate() |
| 11 | + const t = useT() | |
| 10 | 12 | const [items, setItems] = useState<Item[]>([]) |
| 11 | 13 | const [locations, setLocations] = useState<Location[]>([]) |
| 12 | 14 | const [itemCode, setItemCode] = useState('') |
| ... | ... | @@ -37,7 +39,9 @@ export function AdjustStockPage() { |
| 37 | 39 | quantity: Number(quantity), |
| 38 | 40 | }) |
| 39 | 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 | 46 | } catch (err: unknown) { |
| 43 | 47 | setError(err instanceof Error ? err : new Error(String(err))) |
| ... | ... | @@ -49,27 +53,27 @@ export function AdjustStockPage() { |
| 49 | 53 | return ( |
| 50 | 54 | <div> |
| 51 | 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 | 60 | <form onSubmit={onSubmit} className="card p-6 space-y-4 max-w-lg"> |
| 57 | 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 | 63 | <select required value={itemCode} onChange={(e) => setItemCode(e.target.value)} |
| 60 | 64 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"> |
| 61 | 65 | {items.map((i) => <option key={i.id} value={i.code}>{i.code} — {i.name}</option>)} |
| 62 | 66 | </select> |
| 63 | 67 | </div> |
| 64 | 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 | 70 | <select required value={locationId} onChange={(e) => setLocationId(e.target.value)} |
| 67 | 71 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"> |
| 68 | 72 | {locations.map((l) => <option key={l.id} value={l.id}>{l.code} — {l.name}</option>)} |
| 69 | 73 | </select> |
| 70 | 74 | </div> |
| 71 | 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 | 77 | <input type="number" required min="0" step="1" value={quantity} |
| 74 | 78 | onChange={(e) => setQuantity(e.target.value)} |
| 75 | 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 | 85 | </div> |
| 82 | 86 | )} |
| 83 | 87 | <button type="submit" className="btn-primary" disabled={submitting}> |
| 84 | - {submitting ? 'Adjusting...' : 'Set Balance'} | |
| 88 | + {submitting ? t('action.adjusting') : t('action.setBalance')} | |
| 85 | 89 | </button> |
| 86 | 90 | </form> |
| 87 | 91 | </div> | ... | ... |
web/src/pages/BalancesPage.tsx
| ... | ... | @@ -6,6 +6,7 @@ import { PageHeader } from '@/components/PageHeader' |
| 6 | 6 | import { Loading } from '@/components/Loading' |
| 7 | 7 | import { ErrorBox } from '@/components/ErrorBox' |
| 8 | 8 | import { DataTable, type Column } from '@/components/DataTable' |
| 9 | +import { useT } from '@/i18n/LocaleContext' | |
| 9 | 10 | |
| 10 | 11 | interface Row extends Record<string, unknown> { |
| 11 | 12 | id: string |
| ... | ... | @@ -15,6 +16,7 @@ interface Row extends Record<string, unknown> { |
| 15 | 16 | } |
| 16 | 17 | |
| 17 | 18 | export function BalancesPage() { |
| 19 | + const t = useT() | |
| 18 | 20 | const [rows, setRows] = useState<Row[]>([]) |
| 19 | 21 | const [error, setError] = useState<Error | null>(null) |
| 20 | 22 | const [loading, setLoading] = useState(true) |
| ... | ... | @@ -37,14 +39,14 @@ export function BalancesPage() { |
| 37 | 39 | }, []) |
| 38 | 40 | |
| 39 | 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 | 45 | key: 'locationCode', |
| 44 | 46 | render: (r) => <span className="font-mono">{r.locationCode}</span>, |
| 45 | 47 | }, |
| 46 | 48 | { |
| 47 | - header: 'Quantity', | |
| 49 | + header: t('label.quantity'), | |
| 48 | 50 | key: 'quantity', |
| 49 | 51 | render: (r) => <span className="font-mono tabular-nums">{String(r.quantity)}</span>, |
| 50 | 52 | }, |
| ... | ... | @@ -53,9 +55,9 @@ export function BalancesPage() { |
| 53 | 55 | return ( |
| 54 | 56 | <div> |
| 55 | 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 | 62 | {loading && <Loading />} |
| 61 | 63 | {error && <ErrorBox error={error} />} | ... | ... |
web/src/pages/CreateItemPage.tsx
| ... | ... | @@ -5,11 +5,13 @@ import type { Uom } from '@/types/api' |
| 5 | 5 | import { PageHeader } from '@/components/PageHeader' |
| 6 | 6 | import { ErrorBox } from '@/components/ErrorBox' |
| 7 | 7 | import { DynamicExtFields } from '@/components/DynamicExtFields' |
| 8 | +import { useT } from '@/i18n/LocaleContext' | |
| 8 | 9 | |
| 9 | 10 | const ITEM_TYPES = ['GOOD', 'SERVICE', 'DIGITAL'] as const |
| 10 | 11 | |
| 11 | 12 | export function CreateItemPage() { |
| 12 | 13 | const navigate = useNavigate() |
| 14 | + const t = useT() | |
| 13 | 15 | const [code, setCode] = useState('') |
| 14 | 16 | const [name, setName] = useState('') |
| 15 | 17 | const [description, setDescription] = useState('') |
| ... | ... | @@ -49,31 +51,31 @@ export function CreateItemPage() { |
| 49 | 51 | return ( |
| 50 | 52 | <div> |
| 51 | 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 | 58 | <form onSubmit={onSubmit} className="card p-6 space-y-4 max-w-2xl"> |
| 57 | 59 | <div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> |
| 58 | 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 | 62 | <input type="text" required value={code} onChange={(e) => setCode(e.target.value)} |
| 61 | 63 | placeholder="PAPER-120G-A3" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 62 | 64 | </div> |
| 63 | 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 | 67 | <input type="text" required value={name} onChange={(e) => setName(e.target.value)} |
| 66 | 68 | placeholder="120g A3 coated paper" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 67 | 69 | </div> |
| 68 | 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 | 72 | <select value={itemType} onChange={(e) => setItemType(e.target.value)} |
| 71 | 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 | 75 | </select> |
| 74 | 76 | </div> |
| 75 | 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 | 79 | <select value={baseUomCode} onChange={(e) => setBaseUomCode(e.target.value)} |
| 78 | 80 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"> |
| 79 | 81 | {uoms.map((u) => <option key={u.id} value={u.code}>{u.code} — {u.name}</option>)} |
| ... | ... | @@ -81,7 +83,7 @@ export function CreateItemPage() { |
| 81 | 83 | </div> |
| 82 | 84 | </div> |
| 83 | 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 | 87 | <input type="text" value={description} onChange={(e) => setDescription(e.target.value)} |
| 86 | 88 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 87 | 89 | </div> |
| ... | ... | @@ -92,7 +94,7 @@ export function CreateItemPage() { |
| 92 | 94 | /> |
| 93 | 95 | {error && <ErrorBox error={error} />} |
| 94 | 96 | <button type="submit" className="btn-primary" disabled={submitting}> |
| 95 | - {submitting ? 'Creating...' : 'Create Item'} | |
| 97 | + {submitting ? t('action.creating') : t('page.createItem.submit')} | |
| 96 | 98 | </button> |
| 97 | 99 | </form> |
| 98 | 100 | </div> | ... | ... |
web/src/pages/CreateLocationPage.tsx
| ... | ... | @@ -4,11 +4,13 @@ import { inventory } from '@/api/client' |
| 4 | 4 | import { PageHeader } from '@/components/PageHeader' |
| 5 | 5 | import { ErrorBox } from '@/components/ErrorBox' |
| 6 | 6 | import { DynamicExtFields } from '@/components/DynamicExtFields' |
| 7 | +import { useT } from '@/i18n/LocaleContext' | |
| 7 | 8 | |
| 8 | 9 | const LOCATION_TYPES = ['WAREHOUSE', 'BIN', 'VIRTUAL'] as const |
| 9 | 10 | |
| 10 | 11 | export function CreateLocationPage() { |
| 11 | 12 | const navigate = useNavigate() |
| 13 | + const t = useT() | |
| 12 | 14 | const [code, setCode] = useState('') |
| 13 | 15 | const [name, setName] = useState('') |
| 14 | 16 | const [type, setType] = useState<string>('WAREHOUSE') |
| ... | ... | @@ -34,26 +36,26 @@ export function CreateLocationPage() { |
| 34 | 36 | return ( |
| 35 | 37 | <div> |
| 36 | 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 | 43 | <form onSubmit={onSubmit} className="card p-6 space-y-4 max-w-lg"> |
| 42 | 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 | 46 | <input type="text" required value={code} onChange={(e) => setCode(e.target.value)} |
| 45 | 47 | placeholder="WH-NEW" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 46 | 48 | </div> |
| 47 | 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 | 51 | <input type="text" required value={name} onChange={(e) => setName(e.target.value)} |
| 50 | 52 | placeholder="New Warehouse" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 51 | 53 | </div> |
| 52 | 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 | 56 | <select value={type} onChange={(e) => setType(e.target.value)} |
| 55 | 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 | 59 | </select> |
| 58 | 60 | </div> |
| 59 | 61 | <DynamicExtFields |
| ... | ... | @@ -63,7 +65,7 @@ export function CreateLocationPage() { |
| 63 | 65 | /> |
| 64 | 66 | {error && <ErrorBox error={error} />} |
| 65 | 67 | <button type="submit" className="btn-primary" disabled={submitting}> |
| 66 | - {submitting ? 'Creating...' : 'Create Location'} | |
| 68 | + {submitting ? t('action.creating') : t('page.createLocation.submit')} | |
| 67 | 69 | </button> |
| 68 | 70 | </form> |
| 69 | 71 | </div> | ... | ... |
web/src/pages/CreatePartnerPage.tsx
| ... | ... | @@ -4,11 +4,13 @@ import { partners } from '@/api/client' |
| 4 | 4 | import { PageHeader } from '@/components/PageHeader' |
| 5 | 5 | import { ErrorBox } from '@/components/ErrorBox' |
| 6 | 6 | import { DynamicExtFields } from '@/components/DynamicExtFields' |
| 7 | +import { useT } from '@/i18n/LocaleContext' | |
| 7 | 8 | |
| 8 | 9 | const PARTNER_TYPES = ['CUSTOMER', 'SUPPLIER', 'BOTH'] as const |
| 9 | 10 | |
| 10 | 11 | export function CreatePartnerPage() { |
| 11 | 12 | const navigate = useNavigate() |
| 13 | + const t = useT() | |
| 12 | 14 | const [code, setCode] = useState('') |
| 13 | 15 | const [name, setName] = useState('') |
| 14 | 16 | const [type, setType] = useState<string>('CUSTOMER') |
| ... | ... | @@ -41,36 +43,36 @@ export function CreatePartnerPage() { |
| 41 | 43 | return ( |
| 42 | 44 | <div> |
| 43 | 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 | 50 | <form onSubmit={onSubmit} className="card p-6 space-y-4 max-w-2xl"> |
| 49 | 51 | <div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> |
| 50 | 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 | 54 | <input type="text" required value={code} onChange={(e) => setCode(e.target.value)} |
| 53 | 55 | placeholder="CUST-NEWCO" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 54 | 56 | </div> |
| 55 | 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 | 59 | <input type="text" required value={name} onChange={(e) => setName(e.target.value)} |
| 58 | 60 | placeholder="New Company Ltd." className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 59 | 61 | </div> |
| 60 | 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 | 64 | <select value={type} onChange={(e) => setType(e.target.value)} |
| 63 | 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 | 67 | </select> |
| 66 | 68 | </div> |
| 67 | 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 | 71 | <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} |
| 70 | 72 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 71 | 73 | </div> |
| 72 | 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 | 76 | <input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} |
| 75 | 77 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 76 | 78 | </div> |
| ... | ... | @@ -82,7 +84,7 @@ export function CreatePartnerPage() { |
| 82 | 84 | /> |
| 83 | 85 | {error && <ErrorBox error={error} />} |
| 84 | 86 | <button type="submit" className="btn-primary" disabled={submitting}> |
| 85 | - {submitting ? 'Creating...' : 'Create Partner'} | |
| 87 | + {submitting ? t('action.creating') : t('page.createPartner.submit')} | |
| 86 | 88 | </button> |
| 87 | 89 | </form> |
| 88 | 90 | </div> | ... | ... |
web/src/pages/CreatePurchaseOrderPage.tsx
| ... | ... | @@ -5,6 +5,7 @@ import type { Item, Partner } from '@/types/api' |
| 5 | 5 | import { PageHeader } from '@/components/PageHeader' |
| 6 | 6 | import { ErrorBox } from '@/components/ErrorBox' |
| 7 | 7 | import { DynamicExtFields } from '@/components/DynamicExtFields' |
| 8 | +import { useT } from '@/i18n/LocaleContext' | |
| 8 | 9 | |
| 9 | 10 | interface LineInput { |
| 10 | 11 | itemCode: string |
| ... | ... | @@ -14,6 +15,7 @@ interface LineInput { |
| 14 | 15 | |
| 15 | 16 | export function CreatePurchaseOrderPage() { |
| 16 | 17 | const navigate = useNavigate() |
| 18 | + const t = useT() | |
| 17 | 19 | const [code, setCode] = useState('') |
| 18 | 20 | const [partnerCode, setPartnerCode] = useState('') |
| 19 | 21 | const [expectedDate, setExpectedDate] = useState('') |
| ... | ... | @@ -78,19 +80,19 @@ export function CreatePurchaseOrderPage() { |
| 78 | 80 | return ( |
| 79 | 81 | <div> |
| 80 | 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 | 87 | <form onSubmit={onSubmit} className="card p-6 space-y-5 max-w-3xl"> |
| 86 | 88 | <div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> |
| 87 | 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 | 91 | <input type="text" required value={code} onChange={(e) => setCode(e.target.value)} |
| 90 | 92 | placeholder="PO-2026-0002" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 91 | 93 | </div> |
| 92 | 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 | 96 | <select required value={partnerCode} onChange={(e) => setPartnerCode(e.target.value)} |
| 95 | 97 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"> |
| 96 | 98 | {supplierList.map((p) => ( |
| ... | ... | @@ -99,7 +101,7 @@ export function CreatePurchaseOrderPage() { |
| 99 | 101 | </select> |
| 100 | 102 | </div> |
| 101 | 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 | 105 | <input type="date" value={expectedDate} onChange={(e) => setExpectedDate(e.target.value)} |
| 104 | 106 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 105 | 107 | </div> |
| ... | ... | @@ -107,8 +109,8 @@ export function CreatePurchaseOrderPage() { |
| 107 | 109 | |
| 108 | 110 | <div> |
| 109 | 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 | 114 | </div> |
| 113 | 115 | <div className="space-y-2"> |
| 114 | 116 | {lines.map((line, idx) => ( |
| ... | ... | @@ -116,17 +118,17 @@ export function CreatePurchaseOrderPage() { |
| 116 | 118 | <span className="w-6 text-xs text-slate-400 text-right">{idx + 1}</span> |
| 117 | 119 | <select value={line.itemCode} onChange={(e) => updateLine(idx, 'itemCode', e.target.value)} |
| 118 | 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 | 122 | {items.map((it) => <option key={it.id} value={it.code}>{it.code} — {it.name}</option>)} |
| 121 | 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 | 125 | onChange={(e) => updateLine(idx, 'quantity', e.target.value)} |
| 124 | 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 | 128 | onChange={(e) => updateLine(idx, 'unitPrice', e.target.value)} |
| 127 | 129 | className="w-24 rounded-md border border-slate-300 px-2 py-1.5 text-sm text-right" /> |
| 128 | 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 | 132 | </div> |
| 131 | 133 | ))} |
| 132 | 134 | </div> |
| ... | ... | @@ -139,7 +141,7 @@ export function CreatePurchaseOrderPage() { |
| 139 | 141 | /> |
| 140 | 142 | {error && <ErrorBox error={error} />} |
| 141 | 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 | 145 | </button> |
| 144 | 146 | </form> |
| 145 | 147 | </div> | ... | ... |
web/src/pages/CreateSalesOrderPage.tsx
| ... | ... | @@ -5,6 +5,7 @@ import type { Item, Partner } from '@/types/api' |
| 5 | 5 | import { PageHeader } from '@/components/PageHeader' |
| 6 | 6 | import { ErrorBox } from '@/components/ErrorBox' |
| 7 | 7 | import { DynamicExtFields } from '@/components/DynamicExtFields' |
| 8 | +import { useT } from '@/i18n/LocaleContext' | |
| 8 | 9 | |
| 9 | 10 | interface LineInput { |
| 10 | 11 | itemCode: string |
| ... | ... | @@ -14,6 +15,7 @@ interface LineInput { |
| 14 | 15 | |
| 15 | 16 | export function CreateSalesOrderPage() { |
| 16 | 17 | const navigate = useNavigate() |
| 18 | + const t = useT() | |
| 17 | 19 | const [code, setCode] = useState('') |
| 18 | 20 | const [partnerCode, setPartnerCode] = useState('') |
| 19 | 21 | const [currencyCode] = useState('USD') |
| ... | ... | @@ -80,18 +82,18 @@ export function CreateSalesOrderPage() { |
| 80 | 82 | return ( |
| 81 | 83 | <div> |
| 82 | 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 | 87 | actions={ |
| 86 | 88 | <button className="btn-secondary" onClick={() => navigate('/sales-orders')}> |
| 87 | - Cancel | |
| 89 | + {t('action.cancel')} | |
| 88 | 90 | </button> |
| 89 | 91 | } |
| 90 | 92 | /> |
| 91 | 93 | <form onSubmit={onSubmit} className="card p-6 space-y-5 max-w-3xl"> |
| 92 | 94 | <div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> |
| 93 | 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 | 97 | <input |
| 96 | 98 | type="text" |
| 97 | 99 | required |
| ... | ... | @@ -102,7 +104,7 @@ export function CreateSalesOrderPage() { |
| 102 | 104 | /> |
| 103 | 105 | </div> |
| 104 | 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 | 108 | <select |
| 107 | 109 | required |
| 108 | 110 | value={partnerCode} |
| ... | ... | @@ -117,7 +119,7 @@ export function CreateSalesOrderPage() { |
| 117 | 119 | </select> |
| 118 | 120 | </div> |
| 119 | 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 | 123 | <input |
| 122 | 124 | type="text" |
| 123 | 125 | value={currencyCode} |
| ... | ... | @@ -129,9 +131,9 @@ export function CreateSalesOrderPage() { |
| 129 | 131 | |
| 130 | 132 | <div> |
| 131 | 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 | 135 | <button type="button" className="btn-secondary text-xs" onClick={addLine}> |
| 134 | - + Add line | |
| 136 | + {t('action.addLine')} | |
| 135 | 137 | </button> |
| 136 | 138 | </div> |
| 137 | 139 | <div className="space-y-2"> |
| ... | ... | @@ -143,7 +145,7 @@ export function CreateSalesOrderPage() { |
| 143 | 145 | onChange={(e) => updateLine(idx, 'itemCode', e.target.value)} |
| 144 | 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 | 149 | {items.map((it) => ( |
| 148 | 150 | <option key={it.id} value={it.code}> |
| 149 | 151 | {it.code} — {it.name} |
| ... | ... | @@ -154,7 +156,7 @@ export function CreateSalesOrderPage() { |
| 154 | 156 | type="number" |
| 155 | 157 | min="1" |
| 156 | 158 | step="1" |
| 157 | - placeholder="Qty" | |
| 159 | + placeholder={t('label.qty')} | |
| 158 | 160 | value={line.quantity} |
| 159 | 161 | onChange={(e) => updateLine(idx, 'quantity', e.target.value)} |
| 160 | 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 | 165 | type="number" |
| 164 | 166 | min="0" |
| 165 | 167 | step="0.01" |
| 166 | - placeholder="Price" | |
| 168 | + placeholder={t('label.unitPrice')} | |
| 167 | 169 | value={line.unitPrice} |
| 168 | 170 | onChange={(e) => updateLine(idx, 'unitPrice', e.target.value)} |
| 169 | 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 | 174 | type="button" |
| 173 | 175 | className="text-slate-400 hover:text-rose-500" |
| 174 | 176 | onClick={() => removeLine(idx)} |
| 175 | - title="Remove line" | |
| 177 | + title={t('action.delete')} | |
| 176 | 178 | > |
| 177 | 179 | × |
| 178 | 180 | </button> |
| ... | ... | @@ -191,10 +193,10 @@ export function CreateSalesOrderPage() { |
| 191 | 193 | |
| 192 | 194 | <div className="flex items-center gap-3 pt-2"> |
| 193 | 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 | 197 | </button> |
| 196 | 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 | 200 | </span> |
| 199 | 201 | </div> |
| 200 | 202 | </form> | ... | ... |
web/src/pages/CreateUserPage.tsx
| ... | ... | @@ -3,9 +3,11 @@ import { useNavigate } from 'react-router-dom' |
| 3 | 3 | import { identity } from '@/api/client' |
| 4 | 4 | import { PageHeader } from '@/components/PageHeader' |
| 5 | 5 | import { ErrorBox } from '@/components/ErrorBox' |
| 6 | +import { useT } from '@/i18n/LocaleContext' | |
| 6 | 7 | |
| 7 | 8 | export function CreateUserPage() { |
| 8 | 9 | const navigate = useNavigate() |
| 10 | + const t = useT() | |
| 9 | 11 | const [username, setUsername] = useState('') |
| 10 | 12 | const [displayName, setDisplayName] = useState('') |
| 11 | 13 | const [email, setEmail] = useState('') |
| ... | ... | @@ -31,29 +33,29 @@ export function CreateUserPage() { |
| 31 | 33 | return ( |
| 32 | 34 | <div> |
| 33 | 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 | 40 | <form onSubmit={onSubmit} className="card p-6 space-y-4 max-w-lg"> |
| 39 | 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 | 43 | <input type="text" required value={username} onChange={(e) => setUsername(e.target.value)} |
| 42 | 44 | placeholder="jdoe" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 43 | 45 | </div> |
| 44 | 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 | 48 | <input type="text" required value={displayName} onChange={(e) => setDisplayName(e.target.value)} |
| 47 | 49 | placeholder="Jane Doe" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 48 | 50 | </div> |
| 49 | 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 | 53 | <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} |
| 52 | 54 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 53 | 55 | </div> |
| 54 | 56 | {error && <ErrorBox error={error} />} |
| 55 | 57 | <button type="submit" className="btn-primary" disabled={submitting}> |
| 56 | - {submitting ? 'Creating...' : 'Create User'} | |
| 58 | + {submitting ? t('action.creating') : t('page.createUser.submit')} | |
| 57 | 59 | </button> |
| 58 | 60 | </form> |
| 59 | 61 | </div> | ... | ... |
web/src/pages/CreateWorkOrderPage.tsx
| ... | ... | @@ -5,12 +5,14 @@ import type { Item, Location } from '@/types/api' |
| 5 | 5 | import { PageHeader } from '@/components/PageHeader' |
| 6 | 6 | import { ErrorBox } from '@/components/ErrorBox' |
| 7 | 7 | import { DynamicExtFields } from '@/components/DynamicExtFields' |
| 8 | +import { useT } from '@/i18n/LocaleContext' | |
| 8 | 9 | |
| 9 | 10 | interface BomLine { itemCode: string; quantityPerUnit: string; sourceLocationCode: string } |
| 10 | 11 | interface OpLine { operationCode: string; workCenter: string; standardMinutes: string } |
| 11 | 12 | |
| 12 | 13 | export function CreateWorkOrderPage() { |
| 13 | 14 | const navigate = useNavigate() |
| 15 | + const t = useT() | |
| 14 | 16 | const [code, setCode] = useState('') |
| 15 | 17 | const [outputItemCode, setOutputItemCode] = useState('') |
| 16 | 18 | const [outputQuantity, setOutputQuantity] = useState('') |
| ... | ... | @@ -77,55 +79,55 @@ export function CreateWorkOrderPage() { |
| 77 | 79 | return ( |
| 78 | 80 | <div> |
| 79 | 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 | 86 | <form onSubmit={onSubmit} className="card p-6 space-y-5 max-w-3xl"> |
| 85 | 87 | <div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> |
| 86 | 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 | 90 | <input type="text" required value={code} onChange={(e) => setCode(e.target.value)} |
| 89 | 91 | placeholder="WO-PRINT-0002" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 90 | 92 | </div> |
| 91 | 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 | 95 | <select required value={outputItemCode} onChange={(e) => setOutputItemCode(e.target.value)} |
| 94 | 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 | 98 | {items.map((it) => <option key={it.id} value={it.code}>{it.code} — {it.name}</option>)} |
| 97 | 99 | </select> |
| 98 | 100 | </div> |
| 99 | 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 | 103 | <input type="number" required min="1" step="1" value={outputQuantity} |
| 102 | 104 | onChange={(e) => setOutputQuantity(e.target.value)} |
| 103 | 105 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm text-right" /> |
| 104 | 106 | </div> |
| 105 | 107 | </div> |
| 106 | 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 | 110 | <input type="date" value={dueDate} onChange={(e) => setDueDate(e.target.value)} |
| 109 | 111 | className="mt-1 max-w-xs rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 110 | 112 | </div> |
| 111 | 113 | |
| 112 | - {/* ─── BOM inputs ─────────────────────────────────────── */} | |
| 114 | + {/* BOM inputs */} | |
| 113 | 115 | <div> |
| 114 | 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 | 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 | 121 | <div className="space-y-2"> |
| 120 | 122 | {bom.map((b, idx) => ( |
| 121 | 123 | <div key={idx} className="flex items-center gap-2"> |
| 122 | 124 | <span className="w-6 text-xs text-slate-400 text-right">{idx + 1}</span> |
| 123 | 125 | <select value={b.itemCode} onChange={(e) => updateBom(idx, 'itemCode', e.target.value)} |
| 124 | 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 | 128 | {items.map((it) => <option key={it.id} value={it.code}>{it.code}</option>)} |
| 127 | 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 | 131 | onChange={(e) => updateBom(idx, 'quantityPerUnit', e.target.value)} |
| 130 | 132 | className="w-24 rounded-md border border-slate-300 px-2 py-1.5 text-sm text-right" /> |
| 131 | 133 | <select value={b.sourceLocationCode} onChange={(e) => updateBom(idx, 'sourceLocationCode', e.target.value)} |
| ... | ... | @@ -138,24 +140,24 @@ export function CreateWorkOrderPage() { |
| 138 | 140 | </div> |
| 139 | 141 | </div> |
| 140 | 142 | |
| 141 | - {/* ─── Routing operations ─────────────────────────────── */} | |
| 143 | + {/* Routing operations */} | |
| 142 | 144 | <div> |
| 143 | 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 | 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 | 150 | <div className="space-y-2"> |
| 149 | 151 | {ops.map((o, idx) => ( |
| 150 | 152 | <div key={idx} className="flex items-center gap-2"> |
| 151 | 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 | 155 | onChange={(e) => updateOp(idx, 'operationCode', e.target.value)} |
| 154 | 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 | 158 | onChange={(e) => updateOp(idx, 'workCenter', e.target.value)} |
| 157 | 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 | 161 | onChange={(e) => updateOp(idx, 'standardMinutes', e.target.value)} |
| 160 | 162 | className="w-20 rounded-md border border-slate-300 px-2 py-1.5 text-sm text-right" /> |
| 161 | 163 | <button type="button" className="text-slate-400 hover:text-rose-500" onClick={() => removeOp(idx)}>×</button> |
| ... | ... | @@ -171,7 +173,7 @@ export function CreateWorkOrderPage() { |
| 171 | 173 | /> |
| 172 | 174 | {error && <ErrorBox error={error} />} |
| 173 | 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 | 177 | </button> |
| 176 | 178 | </form> |
| 177 | 179 | </div> | ... | ... |
web/src/pages/DashboardPage.tsx
| ... | ... | @@ -13,6 +13,7 @@ import { PageHeader } from '@/components/PageHeader' |
| 13 | 13 | import { Loading } from '@/components/Loading' |
| 14 | 14 | import { ErrorBox } from '@/components/ErrorBox' |
| 15 | 15 | import { useAuth } from '@/auth/AuthContext' |
| 16 | +import { useT } from '@/i18n/LocaleContext' | |
| 16 | 17 | |
| 17 | 18 | interface DashboardCounts { |
| 18 | 19 | items: number |
| ... | ... | @@ -27,6 +28,7 @@ interface DashboardCounts { |
| 27 | 28 | |
| 28 | 29 | export function DashboardPage() { |
| 29 | 30 | const { username } = useAuth() |
| 31 | + const t = useT() | |
| 30 | 32 | const [counts, setCounts] = useState<DashboardCounts | null>(null) |
| 31 | 33 | const [error, setError] = useState<Error | null>(null) |
| 32 | 34 | const [loading, setLoading] = useState(true) |
| ... | ... | @@ -69,73 +71,68 @@ export function DashboardPage() { |
| 69 | 71 | return ( |
| 70 | 72 | <div> |
| 71 | 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 | 77 | {loading && <Loading />} |
| 76 | 78 | {error && <ErrorBox error={error} />} |
| 77 | 79 | {counts && ( |
| 78 | 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 | 84 | <DashboardCard |
| 83 | - label="Work orders in progress" | |
| 85 | + label={t('page.dashboard.cardWoInProgress')} | |
| 84 | 86 | value={counts.inProgressWorkOrders} |
| 85 | 87 | to="/shop-floor" |
| 86 | 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 | 91 | <DashboardCard |
| 90 | - label="Purchase orders" | |
| 92 | + label={t('page.dashboard.cardPurchaseOrders')} | |
| 91 | 93 | value={counts.purchaseOrders} |
| 92 | 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 | 97 | <DashboardCard |
| 96 | - label="Journal entries" | |
| 98 | + label={t('page.dashboard.cardJournalEntries')} | |
| 97 | 99 | value={counts.journalEntries} |
| 98 | 100 | to="/journal-entries" |
| 99 | 101 | /> |
| 100 | 102 | </div> |
| 101 | 103 | )} |
| 102 | 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 | 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 | 108 | </p> |
| 107 | 109 | <ol className="list-decimal space-y-2 pl-5"> |
| 108 | 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 | 117 | </li> |
| 117 | 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 | 122 | </li> |
| 123 | 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 | 126 | </li> |
| 128 | 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 | 132 | </li> |
| 135 | 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 | 136 | </li> |
| 140 | 137 | </ol> |
| 141 | 138 | </div> | ... | ... |
web/src/pages/EditItemPage.tsx
| ... | ... | @@ -6,12 +6,14 @@ import { PageHeader } from '@/components/PageHeader' |
| 6 | 6 | import { Loading } from '@/components/Loading' |
| 7 | 7 | import { ErrorBox } from '@/components/ErrorBox' |
| 8 | 8 | import { DynamicExtFields } from '@/components/DynamicExtFields' |
| 9 | +import { useT } from '@/i18n/LocaleContext' | |
| 9 | 10 | |
| 10 | 11 | const ITEM_TYPES = ['GOOD', 'SERVICE', 'DIGITAL'] as const |
| 11 | 12 | |
| 12 | 13 | export function EditItemPage() { |
| 13 | 14 | const { id = '' } = useParams<{ id: string }>() |
| 14 | 15 | const navigate = useNavigate() |
| 16 | + const t = useT() | |
| 15 | 17 | const [item, setItem] = useState<Item | null>(null) |
| 16 | 18 | const [name, setName] = useState('') |
| 17 | 19 | const [description, setDescription] = useState('') |
| ... | ... | @@ -60,39 +62,39 @@ export function EditItemPage() { |
| 60 | 62 | return ( |
| 61 | 63 | <div> |
| 62 | 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 | 69 | <form onSubmit={onSubmit} className="card p-6 space-y-4 max-w-2xl"> |
| 68 | 70 | <div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> |
| 69 | 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 | 73 | <input type="text" required value={name} onChange={(e) => setName(e.target.value)} |
| 72 | 74 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 73 | 75 | </div> |
| 74 | 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 | 78 | <select value={itemType} onChange={(e) => setItemType(e.target.value)} |
| 77 | 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 | 81 | </select> |
| 80 | 82 | </div> |
| 81 | 83 | </div> |
| 82 | 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 | 86 | <input type="text" value={description} onChange={(e) => setDescription(e.target.value)} |
| 85 | 87 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 86 | 88 | </div> |
| 87 | 89 | <div className="flex items-center gap-2"> |
| 88 | 90 | <input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} |
| 89 | 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 | 93 | </div> |
| 92 | 94 | <DynamicExtFields entityName="Item" values={ext} onChange={(k, v) => setExt(prev => ({ ...prev, [k]: v }))} /> |
| 93 | 95 | {error && <ErrorBox error={error} />} |
| 94 | 96 | <button type="submit" className="btn-primary" disabled={submitting}> |
| 95 | - {submitting ? 'Saving…' : 'Save Changes'} | |
| 97 | + {submitting ? t('action.saving') : t('action.saveChanges')} | |
| 96 | 98 | </button> |
| 97 | 99 | </form> |
| 98 | 100 | </div> | ... | ... |
web/src/pages/EditPartnerPage.tsx
| ... | ... | @@ -6,12 +6,14 @@ import { PageHeader } from '@/components/PageHeader' |
| 6 | 6 | import { Loading } from '@/components/Loading' |
| 7 | 7 | import { ErrorBox } from '@/components/ErrorBox' |
| 8 | 8 | import { DynamicExtFields } from '@/components/DynamicExtFields' |
| 9 | +import { useT } from '@/i18n/LocaleContext' | |
| 9 | 10 | |
| 10 | 11 | const PARTNER_TYPES = ['CUSTOMER', 'SUPPLIER', 'BOTH'] as const |
| 11 | 12 | |
| 12 | 13 | export function EditPartnerPage() { |
| 13 | 14 | const { id = '' } = useParams<{ id: string }>() |
| 14 | 15 | const navigate = useNavigate() |
| 16 | + const t = useT() | |
| 15 | 17 | const [partner, setPartner] = useState<Partner | null>(null) |
| 16 | 18 | const [name, setName] = useState('') |
| 17 | 19 | const [type, setType] = useState<string>('CUSTOMER') |
| ... | ... | @@ -61,31 +63,31 @@ export function EditPartnerPage() { |
| 61 | 63 | return ( |
| 62 | 64 | <div> |
| 63 | 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 | 70 | <form onSubmit={onSubmit} className="card p-6 space-y-4 max-w-2xl"> |
| 69 | 71 | <div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> |
| 70 | 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 | 74 | <input type="text" required value={name} onChange={(e) => setName(e.target.value)} |
| 73 | 75 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 74 | 76 | </div> |
| 75 | 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 | 79 | <select value={type} onChange={(e) => setType(e.target.value)} |
| 78 | 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 | 82 | </select> |
| 81 | 83 | </div> |
| 82 | 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 | 86 | <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} |
| 85 | 87 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 86 | 88 | </div> |
| 87 | 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 | 91 | <input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} |
| 90 | 92 | className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> |
| 91 | 93 | </div> |
| ... | ... | @@ -93,7 +95,7 @@ export function EditPartnerPage() { |
| 93 | 95 | <DynamicExtFields entityName="Partner" values={ext} onChange={(k, v) => setExt(prev => ({ ...prev, [k]: v }))} /> |
| 94 | 96 | {error && <ErrorBox error={error} />} |
| 95 | 97 | <button type="submit" className="btn-primary" disabled={submitting}> |
| 96 | - {submitting ? 'Saving…' : 'Save Changes'} | |
| 98 | + {submitting ? t('action.saving') : t('action.saveChanges')} | |
| 97 | 99 | </button> |
| 98 | 100 | </form> |
| 99 | 101 | </div> | ... | ... |
web/src/pages/FormDesignerPage.tsx
| ... | ... | @@ -17,6 +17,7 @@ import { ErrorBox } from '@/components/ErrorBox' |
| 17 | 17 | import { Loading } from '@/components/Loading' |
| 18 | 18 | import { vibeWidgets } from '@/components/form-widgets' |
| 19 | 19 | import { vibeTemplates } from '@/components/form-widgets/vibeErpTheme' |
| 20 | +import { useT } from '@/i18n/LocaleContext' | |
| 20 | 21 | |
| 21 | 22 | // ── Types ────────────────────────────────────────────────────────── |
| 22 | 23 | |
| ... | ... | @@ -165,6 +166,7 @@ function hydrateFields(def: FormDefinition): DesignerField[] { |
| 165 | 166 | export function FormDesignerPage() { |
| 166 | 167 | const { slug: routeSlug } = useParams<{ slug: string }>() |
| 167 | 168 | const navigate = useNavigate() |
| 169 | + const t = useT() | |
| 168 | 170 | const isEdit = Boolean(routeSlug) |
| 169 | 171 | |
| 170 | 172 | // ── Top bar state ── |
| ... | ... | @@ -302,15 +304,15 @@ export function FormDesignerPage() { |
| 302 | 304 | return ( |
| 303 | 305 | <div> |
| 304 | 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 | 311 | {/* ── Top bar ── */} |
| 310 | 312 | <div className="card p-4 mb-4"> |
| 311 | 313 | <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5"> |
| 312 | 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 | 316 | <input |
| 315 | 317 | type="text" |
| 316 | 318 | value={title} |
| ... | ... | @@ -320,7 +322,7 @@ export function FormDesignerPage() { |
| 320 | 322 | /> |
| 321 | 323 | </div> |
| 322 | 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 | 326 | <input |
| 325 | 327 | type="text" |
| 326 | 328 | value={entityName} |
| ... | ... | @@ -330,7 +332,7 @@ export function FormDesignerPage() { |
| 330 | 332 | /> |
| 331 | 333 | </div> |
| 332 | 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 | 336 | <select |
| 335 | 337 | value={purpose} |
| 336 | 338 | onChange={(e) => setPurpose(e.target.value as FormPurpose)} |
| ... | ... | @@ -344,7 +346,7 @@ export function FormDesignerPage() { |
| 344 | 346 | </select> |
| 345 | 347 | </div> |
| 346 | 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 | 350 | <input |
| 349 | 351 | type="text" |
| 350 | 352 | value={slug} |
| ... | ... | @@ -363,14 +365,14 @@ export function FormDesignerPage() { |
| 363 | 365 | disabled={saving} |
| 364 | 366 | onClick={handleSave} |
| 365 | 367 | > |
| 366 | - {saving ? 'Saving...' : 'Save'} | |
| 368 | + {saving ? t('action.saving') : t('action.save')} | |
| 367 | 369 | </button> |
| 368 | 370 | <button |
| 369 | 371 | type="button" |
| 370 | 372 | className="btn-secondary" |
| 371 | 373 | onClick={() => navigate('/admin/metadata')} |
| 372 | 374 | > |
| 373 | - Discard | |
| 375 | + {t('action.discard')} | |
| 374 | 376 | </button> |
| 375 | 377 | </div> |
| 376 | 378 | </div> |
| ... | ... | @@ -387,7 +389,7 @@ export function FormDesignerPage() { |
| 387 | 389 | {/* ── Left panel: field list (3/5 = 60%) ── */} |
| 388 | 390 | <div className="lg:col-span-3 space-y-2"> |
| 389 | 391 | <h2 className="text-xs font-semibold uppercase tracking-wide text-slate-400 mb-3"> |
| 390 | - Fields | |
| 392 | + {t('label.fields')} | |
| 391 | 393 | </h2> |
| 392 | 394 | |
| 393 | 395 | {fields.map((field, idx) => ( |
| ... | ... | @@ -409,10 +411,10 @@ export function FormDesignerPage() { |
| 409 | 411 | |
| 410 | 412 | <div className="flex gap-2 pt-2"> |
| 411 | 413 | <button type="button" className="btn-secondary" onClick={addField}> |
| 412 | - + Add Field | |
| 414 | + {t('action.addField')} | |
| 413 | 415 | </button> |
| 414 | 416 | <button type="button" className="btn-secondary" onClick={addSection}> |
| 415 | - + Add Section Divider | |
| 417 | + {t('action.addSectionDivider')} | |
| 416 | 418 | </button> |
| 417 | 419 | </div> |
| 418 | 420 | </div> |
| ... | ... | @@ -420,12 +422,12 @@ export function FormDesignerPage() { |
| 420 | 422 | {/* ── Right panel: live preview (2/5 = 40%) ── */} |
| 421 | 423 | <div className="lg:col-span-2"> |
| 422 | 424 | <h2 className="text-xs font-semibold uppercase tracking-wide text-slate-400 mb-3"> |
| 423 | - Live Preview | |
| 425 | + {t('label.livePreview')} | |
| 424 | 426 | </h2> |
| 425 | 427 | <div className="card p-6"> |
| 426 | 428 | {Object.keys(jsonSchema.properties ?? {}).length === 0 ? ( |
| 427 | 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 | 431 | </p> |
| 430 | 432 | ) : ( |
| 431 | 433 | <Form |
| ... | ... | @@ -475,6 +477,8 @@ function FieldRow({ |
| 475 | 477 | onRemove, |
| 476 | 478 | onMove, |
| 477 | 479 | }: FieldRowProps) { |
| 480 | + const t = useT() | |
| 481 | + | |
| 478 | 482 | if (field.isSectionDivider) { |
| 479 | 483 | return ( |
| 480 | 484 | <div className="card p-3 border-l-4 border-indigo-300"> |
| ... | ... | @@ -485,20 +489,20 @@ function FieldRow({ |
| 485 | 489 | onMove={onMove} |
| 486 | 490 | /> |
| 487 | 491 | <span className="text-xs font-semibold uppercase text-indigo-500 mr-2"> |
| 488 | - Section | |
| 492 | + {t('label.section')} | |
| 489 | 493 | </span> |
| 490 | 494 | <input |
| 491 | 495 | type="text" |
| 492 | 496 | value={field.sectionTitle ?? ''} |
| 493 | 497 | onChange={(e) => onUpdate({ sectionTitle: e.target.value })} |
| 494 | - placeholder="Section title" | |
| 498 | + placeholder={t('label.sectionTitle')} | |
| 495 | 499 | className="flex-1 rounded-md border border-slate-300 px-2 py-1 text-sm" |
| 496 | 500 | /> |
| 497 | 501 | <button |
| 498 | 502 | type="button" |
| 499 | 503 | onClick={onRemove} |
| 500 | 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 | 507 | x |
| 504 | 508 | </button> |
| ... | ... | @@ -524,7 +528,7 @@ function FieldRow({ |
| 524 | 528 | type="text" |
| 525 | 529 | value={field.label} |
| 526 | 530 | onChange={(e) => onUpdate({ label: e.target.value })} |
| 527 | - placeholder="Label" | |
| 531 | + placeholder={t('label.label')} | |
| 528 | 532 | className="flex-1 rounded-md border border-slate-300 px-2 py-1 text-sm" |
| 529 | 533 | /> |
| 530 | 534 | <select |
| ... | ... | @@ -534,9 +538,9 @@ function FieldRow({ |
| 534 | 538 | } |
| 535 | 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 | 544 | </option> |
| 541 | 545 | ))} |
| 542 | 546 | </select> |
| ... | ... | @@ -547,7 +551,7 @@ function FieldRow({ |
| 547 | 551 | onChange={(e) => onUpdate({ required: e.target.checked })} |
| 548 | 552 | className="rounded border-slate-300" |
| 549 | 553 | /> |
| 550 | - Req | |
| 554 | + {t('label.req')} | |
| 551 | 555 | </label> |
| 552 | 556 | <select |
| 553 | 557 | value={field.width} |
| ... | ... | @@ -555,11 +559,11 @@ function FieldRow({ |
| 555 | 559 | onUpdate({ width: Number(e.target.value) as 1 | 2 | 3 }) |
| 556 | 560 | } |
| 557 | 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 | 564 | {WIDTH_OPTIONS.map((w) => ( |
| 561 | 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 | 567 | </option> |
| 564 | 568 | ))} |
| 565 | 569 | </select> |
| ... | ... | @@ -567,7 +571,7 @@ function FieldRow({ |
| 567 | 571 | type="button" |
| 568 | 572 | onClick={onToggleExpand} |
| 569 | 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 | 576 | {isExpanded ? '\u25B2' : '\u25BC'} |
| 573 | 577 | </button> |
| ... | ... | @@ -575,7 +579,7 @@ function FieldRow({ |
| 575 | 579 | type="button" |
| 576 | 580 | onClick={onRemove} |
| 577 | 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 | 584 | x |
| 581 | 585 | </button> |
| ... | ... | @@ -586,7 +590,7 @@ function FieldRow({ |
| 586 | 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 | 591 | <div> |
| 588 | 592 | <label className="block text-sm font-medium text-slate-700"> |
| 589 | - Label (English) | |
| 593 | + {t('label.labelEnglish')} | |
| 590 | 594 | </label> |
| 591 | 595 | <input |
| 592 | 596 | type="text" |
| ... | ... | @@ -597,7 +601,7 @@ function FieldRow({ |
| 597 | 601 | </div> |
| 598 | 602 | <div> |
| 599 | 603 | <label className="block text-sm font-medium text-slate-700"> |
| 600 | - Placeholder | |
| 604 | + {t('label.placeholder')} | |
| 601 | 605 | </label> |
| 602 | 606 | <input |
| 603 | 607 | type="text" |
| ... | ... | @@ -610,7 +614,7 @@ function FieldRow({ |
| 610 | 614 | </div> |
| 611 | 615 | <div className="sm:col-span-2"> |
| 612 | 616 | <label className="block text-sm font-medium text-slate-700"> |
| 613 | - Help text | |
| 617 | + {t('label.helpText')} | |
| 614 | 618 | </label> |
| 615 | 619 | <input |
| 616 | 620 | type="text" |
| ... | ... | @@ -623,7 +627,7 @@ function FieldRow({ |
| 623 | 627 | </div> |
| 624 | 628 | <div> |
| 625 | 629 | <label className="block text-sm font-medium text-slate-700"> |
| 626 | - Widget override | |
| 630 | + {t('label.widgetOverride')} | |
| 627 | 631 | </label> |
| 628 | 632 | <select |
| 629 | 633 | value={field.widgetOverride ?? ''} |
| ... | ... | @@ -634,17 +638,17 @@ function FieldRow({ |
| 634 | 638 | > |
| 635 | 639 | {WIDGET_OPTIONS.map((w) => ( |
| 636 | 640 | <option key={w} value={w}> |
| 637 | - {w || '(default)'} | |
| 641 | + {w || t('label.widgetDefault')} | |
| 638 | 642 | </option> |
| 639 | 643 | ))} |
| 640 | 644 | </select> |
| 641 | 645 | </div> |
| 642 | 646 | <div> |
| 643 | 647 | <label className="block text-sm font-medium text-slate-700"> |
| 644 | - Visibility condition | |
| 648 | + {t('label.visibilityCondition')} | |
| 645 | 649 | </label> |
| 646 | 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 | 652 | <select |
| 649 | 653 | value={field.visibleWhen?.field ?? ''} |
| 650 | 654 | onChange={(e) => |
| ... | ... | @@ -659,7 +663,7 @@ function FieldRow({ |
| 659 | 663 | } |
| 660 | 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 | 667 | {fieldKeys |
| 664 | 668 | .filter((k) => k !== field.key) |
| 665 | 669 | .map((k) => ( |
| ... | ... | @@ -668,7 +672,7 @@ function FieldRow({ |
| 668 | 672 | </option> |
| 669 | 673 | ))} |
| 670 | 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 | 676 | <input |
| 673 | 677 | type="text" |
| 674 | 678 | value={field.visibleWhen?.equals ?? ''} |
| ... | ... | @@ -702,6 +706,7 @@ function ReorderButtons({ |
| 702 | 706 | total: number |
| 703 | 707 | onMove: (direction: 'up' | 'down') => void |
| 704 | 708 | }) { |
| 709 | + const t = useT() | |
| 705 | 710 | return ( |
| 706 | 711 | <div className="flex flex-col"> |
| 707 | 712 | <button |
| ... | ... | @@ -709,7 +714,7 @@ function ReorderButtons({ |
| 709 | 714 | onClick={() => onMove('up')} |
| 710 | 715 | disabled={index === 0} |
| 711 | 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 | 720 | </button> |
| ... | ... | @@ -718,7 +723,7 @@ function ReorderButtons({ |
| 718 | 723 | onClick={() => onMove('down')} |
| 719 | 724 | disabled={index === total - 1} |
| 720 | 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 | 729 | </button> | ... | ... |
web/src/pages/ItemsPage.tsx
| ... | ... | @@ -6,8 +6,10 @@ import { PageHeader } from '@/components/PageHeader' |
| 6 | 6 | import { Loading } from '@/components/Loading' |
| 7 | 7 | import { ErrorBox } from '@/components/ErrorBox' |
| 8 | 8 | import { DataTable, type Column } from '@/components/DataTable' |
| 9 | +import { useT } from '@/i18n/LocaleContext' | |
| 9 | 10 | |
| 10 | 11 | export function ItemsPage() { |
| 12 | + const t = useT() | |
| 11 | 13 | const [rows, setRows] = useState<Item[]>([]) |
| 12 | 14 | const [error, setError] = useState<Error | null>(null) |
| 13 | 15 | const [loading, setLoading] = useState(true) |
| ... | ... | @@ -22,28 +24,28 @@ export function ItemsPage() { |
| 22 | 24 | |
| 23 | 25 | const columns: Column<Item>[] = [ |
| 24 | 26 | { |
| 25 | - header: 'Code', | |
| 27 | + header: t('label.code'), | |
| 26 | 28 | key: 'code', |
| 27 | 29 | render: (r) => ( |
| 28 | 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 | 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 | 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 | 43 | return ( |
| 42 | 44 | <div> |
| 43 | 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 | 50 | {loading && <Loading />} |
| 49 | 51 | {error && <ErrorBox error={error} />} | ... | ... |
web/src/pages/JournalEntriesPage.tsx
| ... | ... | @@ -5,8 +5,10 @@ import { PageHeader } from '@/components/PageHeader' |
| 5 | 5 | import { Loading } from '@/components/Loading' |
| 6 | 6 | import { ErrorBox } from '@/components/ErrorBox' |
| 7 | 7 | import { StatusBadge } from '@/components/StatusBadge' |
| 8 | +import { useT } from '@/i18n/LocaleContext' | |
| 8 | 9 | |
| 9 | 10 | export function JournalEntriesPage() { |
| 11 | + const t = useT() | |
| 10 | 12 | const [rows, setRows] = useState<JournalEntry[]>([]) |
| 11 | 13 | const [error, setError] = useState<Error | null>(null) |
| 12 | 14 | const [loading, setLoading] = useState(true) |
| ... | ... | @@ -34,26 +36,26 @@ export function JournalEntriesPage() { |
| 34 | 36 | return ( |
| 35 | 37 | <div> |
| 36 | 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 | 42 | {loading && <Loading />} |
| 41 | 43 | {error && <ErrorBox error={error} />} |
| 42 | 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 | 47 | {!loading && !error && rows.length > 0 && ( |
| 46 | 48 | <div className="card overflow-x-auto"> |
| 47 | 49 | <table className="table-base"> |
| 48 | 50 | <thead className="bg-slate-50"> |
| 49 | 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 | 59 | </tr> |
| 58 | 60 | </thead> |
| 59 | 61 | <tbody className="divide-y divide-slate-100"> |
| ... | ... | @@ -66,7 +68,7 @@ export function JournalEntriesPage() { |
| 66 | 68 | className="hover:bg-slate-50 cursor-pointer" |
| 67 | 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 | 72 | <td>{je.type}</td> |
| 71 | 73 | <td><StatusBadge status={je.status} /></td> |
| 72 | 74 | <td className="font-mono">{je.orderCode}</td> |
| ... | ... | @@ -83,11 +85,11 @@ export function JournalEntriesPage() { |
| 83 | 85 | <table className="min-w-full text-xs"> |
| 84 | 86 | <thead> |
| 85 | 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 | 93 | </tr> |
| 92 | 94 | </thead> |
| 93 | 95 | <tbody> | ... | ... |
web/src/pages/ListViewDesignerPage.tsx
| ... | ... | @@ -16,6 +16,7 @@ import { metadata } from '@/api/client' |
| 16 | 16 | import { PageHeader } from '@/components/PageHeader' |
| 17 | 17 | import { ErrorBox } from '@/components/ErrorBox' |
| 18 | 18 | import { DataTable, type Column } from '@/components/DataTable' |
| 19 | +import { useT } from '@/i18n/LocaleContext' | |
| 19 | 20 | |
| 20 | 21 | // ─── Designer state types ────────────────────────────────────────── |
| 21 | 22 | |
| ... | ... | @@ -65,6 +66,7 @@ function emptyState(): DesignerState { |
| 65 | 66 | |
| 66 | 67 | export function ListViewDesignerPage() { |
| 67 | 68 | const navigate = useNavigate() |
| 69 | + const t = useT() | |
| 68 | 70 | const { slug: routeSlug } = useParams<{ slug: string }>() |
| 69 | 71 | const isEdit = Boolean(routeSlug) |
| 70 | 72 | |
| ... | ... | @@ -254,7 +256,7 @@ export function ListViewDesignerPage() { |
| 254 | 256 | |
| 255 | 257 | if (loading) { |
| 256 | 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 | 270 | return ( |
| 269 | 271 | <div> |
| 270 | 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 | 275 | actions={ |
| 274 | 276 | <button |
| 275 | 277 | className="btn-secondary" |
| 276 | 278 | onClick={() => navigate('/admin/metadata')} |
| 277 | 279 | > |
| 278 | - Cancel | |
| 280 | + {t('action.cancel')} | |
| 279 | 281 | </button> |
| 280 | 282 | } |
| 281 | 283 | /> |
| ... | ... | @@ -285,11 +287,11 @@ export function ListViewDesignerPage() { |
| 285 | 287 | |
| 286 | 288 | {/* ── Top bar: title, entity, slug ───────────────────── */} |
| 287 | 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 | 291 | <div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> |
| 290 | 292 | <div> |
| 291 | 293 | <label className="block text-sm font-medium text-slate-700"> |
| 292 | - Title | |
| 294 | + {t('label.title')} | |
| 293 | 295 | </label> |
| 294 | 296 | <input |
| 295 | 297 | type="text" |
| ... | ... | @@ -302,7 +304,7 @@ export function ListViewDesignerPage() { |
| 302 | 304 | </div> |
| 303 | 305 | <div> |
| 304 | 306 | <label className="block text-sm font-medium text-slate-700"> |
| 305 | - Entity Name | |
| 307 | + {t('label.entityName')} | |
| 306 | 308 | </label> |
| 307 | 309 | <input |
| 308 | 310 | type="text" |
| ... | ... | @@ -315,7 +317,7 @@ export function ListViewDesignerPage() { |
| 315 | 317 | </div> |
| 316 | 318 | <div> |
| 317 | 319 | <label className="block text-sm font-medium text-slate-700"> |
| 318 | - Slug | |
| 320 | + {t('label.slug')} | |
| 319 | 321 | </label> |
| 320 | 322 | <input |
| 321 | 323 | type="text" |
| ... | ... | @@ -333,31 +335,31 @@ export function ListViewDesignerPage() { |
| 333 | 335 | {/* ── Columns section ────────────────────────────────── */} |
| 334 | 336 | <div className="card p-6 space-y-4"> |
| 335 | 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 | 339 | <button |
| 338 | 340 | type="button" |
| 339 | 341 | className="btn-secondary text-xs" |
| 340 | 342 | onClick={addColumn} |
| 341 | 343 | > |
| 342 | - + Add Column | |
| 344 | + {t('action.addColumn')} | |
| 343 | 345 | </button> |
| 344 | 346 | </div> |
| 345 | 347 | |
| 346 | 348 | {state.columns.length === 0 ? ( |
| 347 | 349 | <p className="text-sm text-slate-400"> |
| 348 | - No columns defined yet. Click "Add Column" to start. | |
| 350 | + {t('label.noColumnsHint')} | |
| 349 | 351 | </p> |
| 350 | 352 | ) : ( |
| 351 | 353 | <div className="overflow-x-auto"> |
| 352 | 354 | <table className="table-base text-sm"> |
| 353 | 355 | <thead className="bg-slate-50"> |
| 354 | 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 | 363 | <th className="w-10"></th> |
| 362 | 364 | </tr> |
| 363 | 365 | </thead> |
| ... | ... | @@ -428,7 +430,7 @@ export function ListViewDesignerPage() { |
| 428 | 430 | className="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600 disabled:opacity-30" |
| 429 | 431 | disabled={idx === 0} |
| 430 | 432 | onClick={() => moveColumn(idx, -1)} |
| 431 | - title="Move up" | |
| 433 | + title={t('label.moveUp')} | |
| 432 | 434 | > |
| 433 | 435 | ▲ |
| 434 | 436 | </button> |
| ... | ... | @@ -437,7 +439,7 @@ export function ListViewDesignerPage() { |
| 437 | 439 | className="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600 disabled:opacity-30" |
| 438 | 440 | disabled={idx === state.columns.length - 1} |
| 439 | 441 | onClick={() => moveColumn(idx, 1)} |
| 440 | - title="Move down" | |
| 442 | + title={t('label.moveDown')} | |
| 441 | 443 | > |
| 442 | 444 | ▼ |
| 443 | 445 | </button> |
| ... | ... | @@ -448,7 +450,7 @@ export function ListViewDesignerPage() { |
| 448 | 450 | type="button" |
| 449 | 451 | className="text-slate-400 hover:text-rose-500" |
| 450 | 452 | onClick={() => removeColumn(idx)} |
| 451 | - title="Remove column" | |
| 453 | + title={t('label.removeColumn')} | |
| 452 | 454 | > |
| 453 | 455 | × |
| 454 | 456 | </button> |
| ... | ... | @@ -464,19 +466,19 @@ export function ListViewDesignerPage() { |
| 464 | 466 | {/* ── Filters section ────────────────────────────────── */} |
| 465 | 467 | <div className="card p-6 space-y-4"> |
| 466 | 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 | 470 | <button |
| 469 | 471 | type="button" |
| 470 | 472 | className="btn-secondary text-xs" |
| 471 | 473 | onClick={addFilter} |
| 472 | 474 | > |
| 473 | - + Add Filter | |
| 475 | + {t('action.addFilter')} | |
| 474 | 476 | </button> |
| 475 | 477 | </div> |
| 476 | 478 | |
| 477 | 479 | {state.filters.length === 0 ? ( |
| 478 | 480 | <p className="text-sm text-slate-400"> |
| 479 | - No filters defined. Click "Add Filter" to add filterable fields. | |
| 481 | + {t('label.noFiltersHint')} | |
| 480 | 482 | </p> |
| 481 | 483 | ) : ( |
| 482 | 484 | <div className="space-y-2"> |
| ... | ... | @@ -488,7 +490,7 @@ export function ListViewDesignerPage() { |
| 488 | 490 | onChange={(e) => |
| 489 | 491 | updateFilter(idx, { field: e.target.value }) |
| 490 | 492 | } |
| 491 | - placeholder="Field name" | |
| 493 | + placeholder={t('label.fieldName')} | |
| 492 | 494 | className={smallInputCls + ' flex-1'} |
| 493 | 495 | /> |
| 494 | 496 | <select |
| ... | ... | @@ -510,14 +512,14 @@ export function ListViewDesignerPage() { |
| 510 | 512 | onChange={(e) => |
| 511 | 513 | updateFilter(idx, { label: e.target.value }) |
| 512 | 514 | } |
| 513 | - placeholder="Display label" | |
| 515 | + placeholder={t('label.displayLabel')} | |
| 514 | 516 | className={smallInputCls + ' flex-1'} |
| 515 | 517 | /> |
| 516 | 518 | <button |
| 517 | 519 | type="button" |
| 518 | 520 | className="text-slate-400 hover:text-rose-500" |
| 519 | 521 | onClick={() => removeFilter(idx)} |
| 520 | - title="Remove filter" | |
| 522 | + title={t('label.removeFilter')} | |
| 521 | 523 | > |
| 522 | 524 | × |
| 523 | 525 | </button> |
| ... | ... | @@ -530,12 +532,12 @@ export function ListViewDesignerPage() { |
| 530 | 532 | {/* ── Sorting & page size section ────────────────────── */} |
| 531 | 533 | <div className="card p-6 space-y-4"> |
| 532 | 534 | <h2 className="text-lg font-semibold text-slate-800"> |
| 533 | - Sorting & Pagination | |
| 535 | + {t('label.sortingPagination')} | |
| 534 | 536 | </h2> |
| 535 | 537 | <div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> |
| 536 | 538 | <div> |
| 537 | 539 | <label className="block text-sm font-medium text-slate-700"> |
| 538 | - Default Sort Field | |
| 540 | + {t('label.defaultSortField')} | |
| 539 | 541 | </label> |
| 540 | 542 | <select |
| 541 | 543 | value={state.defaultSort?.field ?? ''} |
| ... | ... | @@ -552,7 +554,7 @@ export function ListViewDesignerPage() { |
| 552 | 554 | }} |
| 553 | 555 | className={inputCls} |
| 554 | 556 | > |
| 555 | - <option value="">-- none --</option> | |
| 557 | + <option value="">{t('label.none')}</option> | |
| 556 | 558 | {state.columns |
| 557 | 559 | .filter((c) => c.field.trim()) |
| 558 | 560 | .map((c) => ( |
| ... | ... | @@ -564,7 +566,7 @@ export function ListViewDesignerPage() { |
| 564 | 566 | </div> |
| 565 | 567 | <div> |
| 566 | 568 | <label className="block text-sm font-medium text-slate-700"> |
| 567 | - Direction | |
| 569 | + {t('label.direction')} | |
| 568 | 570 | </label> |
| 569 | 571 | <select |
| 570 | 572 | value={state.defaultSort?.direction ?? 'asc'} |
| ... | ... | @@ -578,13 +580,13 @@ export function ListViewDesignerPage() { |
| 578 | 580 | disabled={!state.defaultSort} |
| 579 | 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 | 585 | </select> |
| 584 | 586 | </div> |
| 585 | 587 | <div> |
| 586 | 588 | <label className="block text-sm font-medium text-slate-700"> |
| 587 | - Page Size | |
| 589 | + {t('label.pageSize')} | |
| 588 | 590 | </label> |
| 589 | 591 | <input |
| 590 | 592 | type="number" |
| ... | ... | @@ -602,10 +604,10 @@ export function ListViewDesignerPage() { |
| 602 | 604 | |
| 603 | 605 | {/* ── Preview section ────────────────────────────────── */} |
| 604 | 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 | 608 | {previewColumns.length === 0 ? ( |
| 607 | 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 | 611 | </p> |
| 610 | 612 | ) : ( |
| 611 | 613 | <DataTable |
| ... | ... | @@ -619,14 +621,14 @@ export function ListViewDesignerPage() { |
| 619 | 621 | {/* ── Actions ────────────────────────────────────────── */} |
| 620 | 622 | <div className="flex items-center gap-3"> |
| 621 | 623 | <button type="submit" className="btn-primary" disabled={saving}> |
| 622 | - {saving ? 'Saving...' : 'Save List View'} | |
| 624 | + {saving ? t('action.saving') : t('action.saveListView')} | |
| 623 | 625 | </button> |
| 624 | 626 | <button |
| 625 | 627 | type="button" |
| 626 | 628 | className="btn-secondary" |
| 627 | 629 | onClick={() => navigate('/admin/metadata')} |
| 628 | 630 | > |
| 629 | - Cancel | |
| 631 | + {t('action.cancel')} | |
| 630 | 632 | </button> |
| 631 | 633 | </div> |
| 632 | 634 | </form> | ... | ... |
web/src/pages/LocationsPage.tsx
| ... | ... | @@ -6,8 +6,10 @@ import { PageHeader } from '@/components/PageHeader' |
| 6 | 6 | import { Loading } from '@/components/Loading' |
| 7 | 7 | import { ErrorBox } from '@/components/ErrorBox' |
| 8 | 8 | import { DataTable, type Column } from '@/components/DataTable' |
| 9 | +import { useT } from '@/i18n/LocaleContext' | |
| 9 | 10 | |
| 10 | 11 | export function LocationsPage() { |
| 12 | + const t = useT() | |
| 11 | 13 | const [rows, setRows] = useState<Location[]>([]) |
| 12 | 14 | const [error, setError] = useState<Error | null>(null) |
| 13 | 15 | const [loading, setLoading] = useState(true) |
| ... | ... | @@ -21,22 +23,22 @@ export function LocationsPage() { |
| 21 | 23 | }, []) |
| 22 | 24 | |
| 23 | 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 | 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 | 36 | return ( |
| 35 | 37 | <div> |
| 36 | 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 | 43 | {loading && <Loading />} |
| 42 | 44 | {error && <ErrorBox error={error} />} | ... | ... |
web/src/pages/LoginPage.tsx
| ... | ... | @@ -4,6 +4,7 @@ import { useAuth } from '@/auth/AuthContext' |
| 4 | 4 | import { meta } from '@/api/client' |
| 5 | 5 | import type { MetaInfo } from '@/types/api' |
| 6 | 6 | import { ErrorBox } from '@/components/ErrorBox' |
| 7 | +import { useT } from '@/i18n/LocaleContext' | |
| 7 | 8 | |
| 8 | 9 | interface LocationState { |
| 9 | 10 | from?: string |
| ... | ... | @@ -13,6 +14,7 @@ export function LoginPage() { |
| 13 | 14 | const { login, token, loading } = useAuth() |
| 14 | 15 | const navigate = useNavigate() |
| 15 | 16 | const location = useLocation() |
| 17 | + const t = useT() | |
| 16 | 18 | const [username, setUsername] = useState('admin') |
| 17 | 19 | const [password, setPassword] = useState('') |
| 18 | 20 | const [error, setError] = useState<Error | null>(null) |
| ... | ... | @@ -47,13 +49,13 @@ export function LoginPage() { |
| 47 | 49 | <div className="mb-6 text-center"> |
| 48 | 50 | <h1 className="text-3xl font-bold text-brand-600">vibe_erp</h1> |
| 49 | 51 | <p className="mt-1 text-sm text-slate-500"> |
| 50 | - Composable ERP framework for the printing industry | |
| 52 | + {t('page.login.tagline')} | |
| 51 | 53 | </p> |
| 52 | 54 | </div> |
| 53 | 55 | <div className="card p-6"> |
| 54 | 56 | <form onSubmit={onSubmit} className="space-y-4"> |
| 55 | 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 | 59 | <input |
| 58 | 60 | type="text" |
| 59 | 61 | value={username} |
| ... | ... | @@ -64,7 +66,7 @@ export function LoginPage() { |
| 64 | 66 | /> |
| 65 | 67 | </div> |
| 66 | 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 | 70 | <input |
| 69 | 71 | type="password" |
| 70 | 72 | value={password} |
| ... | ... | @@ -73,23 +75,23 @@ export function LoginPage() { |
| 73 | 75 | required |
| 74 | 76 | /> |
| 75 | 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 | 79 | </p> |
| 78 | 80 | </div> |
| 79 | 81 | {error ? <ErrorBox error={error} /> : null} |
| 80 | 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 | 84 | </button> |
| 83 | 85 | </form> |
| 84 | 86 | </div> |
| 85 | 87 | <p className="mt-4 text-center text-xs text-slate-400"> |
| 86 | 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 | 91 | <span className="font-mono">{info.implementationVersion}</span> |
| 90 | 92 | </> |
| 91 | 93 | ) : ( |
| 92 | - 'Connecting…' | |
| 94 | + t('page.login.connecting') | |
| 93 | 95 | )} |
| 94 | 96 | </p> |
| 95 | 97 | </div> | ... | ... |
web/src/pages/MetadataAdminPage.tsx
| ... | ... | @@ -363,8 +363,8 @@ export function MetadataAdminPage() { |
| 363 | 363 | |
| 364 | 364 | const entityCols: Column<MetadataEntity>[] = [ |
| 365 | 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 | 368 | { header: t('label.description'), key: 'description', render: (r) => r.description ?? '—' }, |
| 369 | 369 | { header: t('label.source'), key: 'source', render: (r) => <SourceBadge source={r.source ?? 'core'} /> }, |
| 370 | 370 | ] |
| ... | ... | @@ -373,8 +373,8 @@ export function MetadataAdminPage() { |
| 373 | 373 | { header: t('label.fieldKey'), key: 'key', render: (r) => <span className="font-mono">{r.key}</span> }, |
| 374 | 374 | { header: t('label.targetEntity'), key: 'targetEntity' }, |
| 375 | 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 | 378 | { header: t('label.source'), key: 'source', render: (r) => <SourceBadge source={r.source ?? 'core'} /> }, |
| 379 | 379 | { |
| 380 | 380 | header: '', |
| ... | ... | @@ -386,7 +386,7 @@ export function MetadataAdminPage() { |
| 386 | 386 | className="text-xs text-blue-600 hover:underline" |
| 387 | 387 | onClick={() => openCfEdit(r)} |
| 388 | 388 | > |
| 389 | - Edit | |
| 389 | + {t('action.edit')} | |
| 390 | 390 | </button> |
| 391 | 391 | <button |
| 392 | 392 | className="text-xs text-rose-600 hover:underline" |
| ... | ... | @@ -400,17 +400,17 @@ export function MetadataAdminPage() { |
| 400 | 400 | ] |
| 401 | 401 | |
| 402 | 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 | 404 | { header: t('label.description'), key: 'description' }, |
| 405 | 405 | { header: t('label.source'), key: 'source', render: (r) => <SourceBadge source={r.source ?? 'core'} /> }, |
| 406 | 406 | ] |
| 407 | 407 | |
| 408 | 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 | 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 | 428 | ), |
| 429 | 429 | }, |
| 430 | 430 | { header: t('label.entity'), key: 'entityName' }, |
| 431 | - { header: 'Title', key: 'title' }, | |
| 431 | + { header: t('label.title'), key: 'title' }, | |
| 432 | 432 | { header: t('label.purpose'), key: 'purpose' }, |
| 433 | - { header: 'Version', key: 'version' }, | |
| 433 | + { header: t('label.version'), key: 'version' }, | |
| 434 | 434 | { header: t('label.source'), key: 'source', render: (r) => <SourceBadge source={r.source ?? 'core'} /> }, |
| 435 | 435 | { |
| 436 | 436 | header: '', |
| ... | ... | @@ -463,9 +463,9 @@ export function MetadataAdminPage() { |
| 463 | 463 | ), |
| 464 | 464 | }, |
| 465 | 465 | { header: t('label.entity'), key: 'entityName' }, |
| 466 | - { header: 'Title', key: 'title' }, | |
| 466 | + { header: t('label.title'), key: 'title' }, | |
| 467 | 467 | { header: t('label.pageSize'), key: 'pageSize' }, |
| 468 | - { header: 'Version', key: 'version' }, | |
| 468 | + { header: t('label.version'), key: 'version' }, | |
| 469 | 469 | { header: t('label.source'), key: 'source', render: (r) => <SourceBadge source={r.source ?? 'core'} /> }, |
| 470 | 470 | { |
| 471 | 471 | header: '', |
| ... | ... | @@ -521,7 +521,7 @@ export function MetadataAdminPage() { |
| 521 | 521 | className="text-xs text-blue-600 hover:underline" |
| 522 | 522 | onClick={() => openRuleEdit(r)} |
| 523 | 523 | > |
| 524 | - Edit | |
| 524 | + {t('action.edit')} | |
| 525 | 525 | </button> |
| 526 | 526 | <button |
| 527 | 527 | className="text-xs text-rose-600 hover:underline" |
| ... | ... | @@ -624,7 +624,7 @@ export function MetadataAdminPage() { |
| 624 | 624 | {cfTypeKind === 'enum' && ( |
| 625 | 625 | <div> |
| 626 | 626 | <label className="block text-xs font-medium text-slate-700"> |
| 627 | - Allowed values (comma-separated) | |
| 627 | + {t('label.allowedValues')} | |
| 628 | 628 | </label> |
| 629 | 629 | <input |
| 630 | 630 | type="text" |
| ... | ... | @@ -638,7 +638,7 @@ export function MetadataAdminPage() { |
| 638 | 638 | {cfTypeKind === 'string' && ( |
| 639 | 639 | <div> |
| 640 | 640 | <label className="block text-xs font-medium text-slate-700"> |
| 641 | - Max length | |
| 641 | + {t('label.maxLength')} | |
| 642 | 642 | </label> |
| 643 | 643 | <input |
| 644 | 644 | type="number" |
| ... | ... | @@ -656,7 +656,7 @@ export function MetadataAdminPage() { |
| 656 | 656 | checked={cfRequired} |
| 657 | 657 | onChange={(e) => setCfRequired(e.target.checked)} |
| 658 | 658 | /> |
| 659 | - Required | |
| 659 | + {t('label.required')} | |
| 660 | 660 | </label> |
| 661 | 661 | <label className="flex items-center gap-1.5 text-sm text-slate-700"> |
| 662 | 662 | <input |
| ... | ... | @@ -664,14 +664,14 @@ export function MetadataAdminPage() { |
| 664 | 664 | checked={cfPii} |
| 665 | 665 | onChange={(e) => setCfPii(e.target.checked)} |
| 666 | 666 | /> |
| 667 | - PII | |
| 667 | + {t('label.pii')} | |
| 668 | 668 | </label> |
| 669 | 669 | </div> |
| 670 | 670 | </div> |
| 671 | 671 | <div className="grid grid-cols-2 gap-3"> |
| 672 | 672 | <div> |
| 673 | 673 | <label className="block text-xs font-medium text-slate-700"> |
| 674 | - Label EN | |
| 674 | + {t('label.labelEn')} | |
| 675 | 675 | </label> |
| 676 | 676 | <input |
| 677 | 677 | type="text" |
| ... | ... | @@ -683,7 +683,7 @@ export function MetadataAdminPage() { |
| 683 | 683 | </div> |
| 684 | 684 | <div> |
| 685 | 685 | <label className="block text-xs font-medium text-slate-700"> |
| 686 | - Label zh-CN | |
| 686 | + {t('label.labelZhCn')} | |
| 687 | 687 | </label> |
| 688 | 688 | <input |
| 689 | 689 | type="text" |
| ... | ... | @@ -745,7 +745,7 @@ export function MetadataAdminPage() { |
| 745 | 745 | className="btn-primary" |
| 746 | 746 | onClick={() => navigate('/admin/metadata/forms/new')} |
| 747 | 747 | > |
| 748 | - + New Form | |
| 748 | + {t('action.newForm')} | |
| 749 | 749 | </button> |
| 750 | 750 | </div> |
| 751 | 751 | <DataTable |
| ... | ... | @@ -764,7 +764,7 @@ export function MetadataAdminPage() { |
| 764 | 764 | className="btn-primary" |
| 765 | 765 | onClick={() => navigate('/admin/metadata/list-views/new')} |
| 766 | 766 | > |
| 767 | - + New List View | |
| 767 | + {t('action.newListView')} | |
| 768 | 768 | </button> |
| 769 | 769 | </div> |
| 770 | 770 | <DataTable |
| ... | ... | @@ -915,7 +915,7 @@ export function MetadataAdminPage() { |
| 915 | 915 | ]) |
| 916 | 916 | } |
| 917 | 917 | > |
| 918 | - + Add Condition | |
| 918 | + {t('action.addCondition')} | |
| 919 | 919 | </button> |
| 920 | 920 | </div> |
| 921 | 921 | |
| ... | ... | @@ -982,7 +982,7 @@ export function MetadataAdminPage() { |
| 982 | 982 | ]) |
| 983 | 983 | } |
| 984 | 984 | > |
| 985 | - + Add Action | |
| 985 | + {t('action.addAction')} | |
| 986 | 986 | </button> |
| 987 | 987 | </div> |
| 988 | 988 | |
| ... | ... | @@ -1030,7 +1030,7 @@ export function MetadataAdminPage() { |
| 1030 | 1030 | <div> |
| 1031 | 1031 | <PageHeader |
| 1032 | 1032 | title={t('page.metadataAdmin.title')} |
| 1033 | - subtitle="Browse and manage metadata definitions" | |
| 1033 | + subtitle={t('page.metadataAdmin.subtitle')} | |
| 1034 | 1034 | /> |
| 1035 | 1035 | |
| 1036 | 1036 | {/* Tab bar */} | ... | ... |
web/src/pages/MovementsPage.tsx
| ... | ... | @@ -5,6 +5,7 @@ import { PageHeader } from '@/components/PageHeader' |
| 5 | 5 | import { Loading } from '@/components/Loading' |
| 6 | 6 | import { ErrorBox } from '@/components/ErrorBox' |
| 7 | 7 | import { DataTable, type Column } from '@/components/DataTable' |
| 8 | +import { useT } from '@/i18n/LocaleContext' | |
| 8 | 9 | |
| 9 | 10 | interface Row extends Record<string, unknown> { |
| 10 | 11 | id: string |
| ... | ... | @@ -17,6 +18,7 @@ interface Row extends Record<string, unknown> { |
| 17 | 18 | } |
| 18 | 19 | |
| 19 | 20 | export function MovementsPage() { |
| 21 | + const t = useT() | |
| 20 | 22 | const [rows, setRows] = useState<Row[]>([]) |
| 21 | 23 | const [error, setError] = useState<Error | null>(null) |
| 22 | 24 | const [loading, setLoading] = useState(true) |
| ... | ... | @@ -46,15 +48,15 @@ export function MovementsPage() { |
| 46 | 48 | |
| 47 | 49 | const columns: Column<Row>[] = [ |
| 48 | 50 | { |
| 49 | - header: 'Occurred', | |
| 51 | + header: t('label.occurred'), | |
| 50 | 52 | key: 'occurredAt', |
| 51 | 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 | 60 | key: 'delta', |
| 59 | 61 | render: (r) => { |
| 60 | 62 | const n = Number(r.delta) |
| ... | ... | @@ -62,15 +64,15 @@ export function MovementsPage() { |
| 62 | 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 | 71 | return ( |
| 70 | 72 | <div> |
| 71 | 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 | 77 | {loading && <Loading />} |
| 76 | 78 | {error && <ErrorBox error={error} />} | ... | ... |
web/src/pages/PartnersPage.tsx
| ... | ... | @@ -6,8 +6,10 @@ import { PageHeader } from '@/components/PageHeader' |
| 6 | 6 | import { Loading } from '@/components/Loading' |
| 7 | 7 | import { ErrorBox } from '@/components/ErrorBox' |
| 8 | 8 | import { DataTable, type Column } from '@/components/DataTable' |
| 9 | +import { useT } from '@/i18n/LocaleContext' | |
| 9 | 10 | |
| 10 | 11 | export function PartnersPage() { |
| 12 | + const t = useT() | |
| 11 | 13 | const [rows, setRows] = useState<Partner[]>([]) |
| 12 | 14 | const [error, setError] = useState<Error | null>(null) |
| 13 | 15 | const [loading, setLoading] = useState(true) |
| ... | ... | @@ -22,29 +24,29 @@ export function PartnersPage() { |
| 22 | 24 | |
| 23 | 25 | const columns: Column<Partner>[] = [ |
| 24 | 26 | { |
| 25 | - header: 'Code', | |
| 27 | + header: t('label.code'), | |
| 26 | 28 | key: 'code', |
| 27 | 29 | render: (r) => ( |
| 28 | 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 | 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 | 44 | return ( |
| 43 | 45 | <div> |
| 44 | 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 | 51 | {loading && <Loading />} |
| 50 | 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 | 2 | // Confirm a DRAFT, receive a CONFIRMED into a warehouse, or cancel |
| 3 | 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 | 5 | // SETTLED on receive) inline. |
| 6 | 6 | |
| 7 | 7 | import { useCallback, useEffect, useState } from 'react' |
| ... | ... | @@ -12,10 +12,12 @@ import { PageHeader } from '@/components/PageHeader' |
| 12 | 12 | import { Loading } from '@/components/Loading' |
| 13 | 13 | import { ErrorBox } from '@/components/ErrorBox' |
| 14 | 14 | import { StatusBadge } from '@/components/StatusBadge' |
| 15 | +import { useT } from '@/i18n/LocaleContext' | |
| 15 | 16 | |
| 16 | 17 | export function PurchaseOrderDetailPage() { |
| 17 | 18 | const { id = '' } = useParams<{ id: string }>() |
| 18 | 19 | const navigate = useNavigate() |
| 20 | + const t = useT() | |
| 19 | 21 | const [order, setOrder] = useState<PurchaseOrder | null>(null) |
| 20 | 22 | const [locations, setLocations] = useState<Location[]>([]) |
| 21 | 23 | const [receivingLocation, setReceivingLocation] = useState<string>('') |
| ... | ... | @@ -68,7 +70,7 @@ export function PurchaseOrderDetailPage() { |
| 68 | 70 | const updated = await purchaseOrders.confirm(order.id) |
| 69 | 71 | setOrder(updated) |
| 70 | 72 | await reloadSideEffects(updated.code) |
| 71 | - setActionMessage('Confirmed. pbc-finance has posted an AP journal entry.') | |
| 73 | + setActionMessage(t('page.purchaseOrderDetail.confirmMsg')) | |
| 72 | 74 | } catch (e: unknown) { |
| 73 | 75 | setError(e instanceof Error ? e : new Error(String(e))) |
| 74 | 76 | } finally { |
| ... | ... | @@ -78,7 +80,7 @@ export function PurchaseOrderDetailPage() { |
| 78 | 80 | |
| 79 | 81 | const onReceive = async () => { |
| 80 | 82 | if (!receivingLocation) { |
| 81 | - setError(new Error('Pick a receiving location first.')) | |
| 83 | + setError(new Error(t('page.purchaseOrderDetail.pickLocation'))) | |
| 82 | 84 | return |
| 83 | 85 | } |
| 84 | 86 | setActing(true) |
| ... | ... | @@ -89,7 +91,7 @@ export function PurchaseOrderDetailPage() { |
| 89 | 91 | setOrder(updated) |
| 90 | 92 | await reloadSideEffects(updated.code) |
| 91 | 93 | setActionMessage( |
| 92 | - `Received into ${receivingLocation}. Stock credited, journal entry settled.`, | |
| 94 | + t('page.purchaseOrderDetail.receiveMsg').replace('{location}', receivingLocation), | |
| 93 | 95 | ) |
| 94 | 96 | } catch (e: unknown) { |
| 95 | 97 | setError(e instanceof Error ? e : new Error(String(e))) |
| ... | ... | @@ -106,7 +108,7 @@ export function PurchaseOrderDetailPage() { |
| 106 | 108 | const updated = await purchaseOrders.cancel(order.id) |
| 107 | 109 | setOrder(updated) |
| 108 | 110 | await reloadSideEffects(updated.code) |
| 109 | - setActionMessage('Cancelled. pbc-finance has reversed any open journal entry.') | |
| 111 | + setActionMessage(t('page.purchaseOrderDetail.cancelMsg')) | |
| 110 | 112 | } catch (e: unknown) { |
| 111 | 113 | setError(e instanceof Error ? e : new Error(String(e))) |
| 112 | 114 | } finally { |
| ... | ... | @@ -121,11 +123,11 @@ export function PurchaseOrderDetailPage() { |
| 121 | 123 | return ( |
| 122 | 124 | <div> |
| 123 | 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 | 128 | actions={ |
| 127 | 129 | <button className="btn-secondary" onClick={() => navigate('/purchase-orders')}> |
| 128 | - ← Back | |
| 130 | + {t('action.back')} | |
| 129 | 131 | </button> |
| 130 | 132 | } |
| 131 | 133 | /> |
| ... | ... | @@ -133,11 +135,11 @@ export function PurchaseOrderDetailPage() { |
| 133 | 135 | <div className="card mb-6 p-5"> |
| 134 | 136 | <div className="flex flex-wrap items-center justify-between gap-4"> |
| 135 | 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 | 139 | <StatusBadge status={order.status} /> |
| 138 | 140 | </div> |
| 139 | 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 | 143 | <div className="font-mono text-xl font-semibold"> |
| 142 | 144 | {Number(order.totalAmount).toLocaleString(undefined, { |
| 143 | 145 | minimumFractionDigits: 2, |
| ... | ... | @@ -154,10 +156,10 @@ export function PurchaseOrderDetailPage() { |
| 154 | 156 | </div> |
| 155 | 157 | |
| 156 | 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 | 160 | <div className="flex flex-wrap items-center gap-3"> |
| 159 | 161 | <button className="btn-primary" disabled={!canConfirm || acting} onClick={onConfirm}> |
| 160 | - Confirm | |
| 162 | + {t('action.confirm')} | |
| 161 | 163 | </button> |
| 162 | 164 | <div className="flex items-center gap-2"> |
| 163 | 165 | <select |
| ... | ... | @@ -173,27 +175,27 @@ export function PurchaseOrderDetailPage() { |
| 173 | 175 | ))} |
| 174 | 176 | </select> |
| 175 | 177 | <button className="btn-primary" disabled={!canReceive || acting} onClick={onReceive}> |
| 176 | - Receive | |
| 178 | + {t('action.receive')} | |
| 177 | 179 | </button> |
| 178 | 180 | </div> |
| 179 | 181 | <button className="btn-danger" disabled={!canCancel || acting} onClick={onCancel}> |
| 180 | - Cancel | |
| 182 | + {t('action.cancel')} | |
| 181 | 183 | </button> |
| 182 | 184 | </div> |
| 183 | 185 | </div> |
| 184 | 186 | |
| 185 | 187 | <div className="card mb-6"> |
| 186 | 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 | 190 | </div> |
| 189 | 191 | <table className="table-base"> |
| 190 | 192 | <thead className="bg-slate-50"> |
| 191 | 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 | 199 | </tr> |
| 198 | 200 | </thead> |
| 199 | 201 | <tbody className="divide-y divide-slate-100"> |
| ... | ... | @@ -222,18 +224,18 @@ export function PurchaseOrderDetailPage() { |
| 222 | 224 | <div className="grid gap-6 md:grid-cols-2"> |
| 223 | 225 | <div className="card"> |
| 224 | 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 | 228 | </div> |
| 227 | 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 | 232 | <table className="table-base"> |
| 231 | 233 | <thead className="bg-slate-50"> |
| 232 | 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 | 239 | </tr> |
| 238 | 240 | </thead> |
| 239 | 241 | <tbody className="divide-y divide-slate-100"> |
| ... | ... | @@ -242,7 +244,7 @@ export function PurchaseOrderDetailPage() { |
| 242 | 244 | <td className="font-mono">{m.itemCode}</td> |
| 243 | 245 | <td className="font-mono tabular-nums text-emerald-600">{String(m.delta)}</td> |
| 244 | 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 | 248 | </tr> |
| 247 | 249 | ))} |
| 248 | 250 | </tbody> |
| ... | ... | @@ -251,18 +253,18 @@ export function PurchaseOrderDetailPage() { |
| 251 | 253 | </div> |
| 252 | 254 | <div className="card"> |
| 253 | 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 | 257 | </div> |
| 256 | 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 | 261 | <table className="table-base"> |
| 260 | 262 | <thead className="bg-slate-50"> |
| 261 | 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 | 268 | </tr> |
| 267 | 269 | </thead> |
| 268 | 270 | <tbody className="divide-y divide-slate-100"> | ... | ... |
web/src/pages/PurchaseOrdersPage.tsx
| ... | ... | @@ -7,8 +7,10 @@ import { Loading } from '@/components/Loading' |
| 7 | 7 | import { ErrorBox } from '@/components/ErrorBox' |
| 8 | 8 | import { DataTable, type Column } from '@/components/DataTable' |
| 9 | 9 | import { StatusBadge } from '@/components/StatusBadge' |
| 10 | +import { useT } from '@/i18n/LocaleContext' | |
| 10 | 11 | |
| 11 | 12 | export function PurchaseOrdersPage() { |
| 13 | + const t = useT() | |
| 12 | 14 | const [rows, setRows] = useState<PurchaseOrder[]>([]) |
| 13 | 15 | const [error, setError] = useState<Error | null>(null) |
| 14 | 16 | const [loading, setLoading] = useState(true) |
| ... | ... | @@ -23,7 +25,7 @@ export function PurchaseOrdersPage() { |
| 23 | 25 | |
| 24 | 26 | const columns: Column<PurchaseOrder>[] = [ |
| 25 | 27 | { |
| 26 | - header: 'Code', | |
| 28 | + header: t('label.code'), | |
| 27 | 29 | key: 'code', |
| 28 | 30 | render: (r) => ( |
| 29 | 31 | <Link to={`/purchase-orders/${r.id}`} className="font-mono text-brand-600 hover:underline"> |
| ... | ... | @@ -31,12 +33,12 @@ export function PurchaseOrdersPage() { |
| 31 | 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 | 42 | key: 'totalAmount', |
| 41 | 43 | render: (r) => ( |
| 42 | 44 | <span className="font-mono tabular-nums"> |
| ... | ... | @@ -52,9 +54,9 @@ export function PurchaseOrdersPage() { |
| 52 | 54 | return ( |
| 53 | 55 | <div> |
| 54 | 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 | 61 | {loading && <Loading />} |
| 60 | 62 | {error && <ErrorBox error={error} />} | ... | ... |
web/src/pages/RolesPage.tsx
| ... | ... | @@ -5,8 +5,10 @@ import { PageHeader } from '@/components/PageHeader' |
| 5 | 5 | import { Loading } from '@/components/Loading' |
| 6 | 6 | import { ErrorBox } from '@/components/ErrorBox' |
| 7 | 7 | import { DataTable, type Column } from '@/components/DataTable' |
| 8 | +import { useT } from '@/i18n/LocaleContext' | |
| 8 | 9 | |
| 9 | 10 | export function RolesPage() { |
| 11 | + const t = useT() | |
| 10 | 12 | const [rows, setRows] = useState<Role[]>([]) |
| 11 | 13 | const [error, setError] = useState<Error | null>(null) |
| 12 | 14 | const [loading, setLoading] = useState(true) |
| ... | ... | @@ -43,36 +45,36 @@ export function RolesPage() { |
| 43 | 45 | } |
| 44 | 46 | |
| 45 | 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 | 53 | return ( |
| 52 | 54 | <div> |
| 53 | 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 | 58 | actions={ |
| 57 | 59 | <button className="btn-primary" onClick={() => setShowCreate(!showCreate)}> |
| 58 | - {showCreate ? 'Cancel' : '+ New Role'} | |
| 60 | + {showCreate ? t('action.cancel') : t('action.newRole')} | |
| 59 | 61 | </button> |
| 60 | 62 | } |
| 61 | 63 | /> |
| 62 | 64 | {showCreate && ( |
| 63 | 65 | <form onSubmit={onCreate} className="card p-4 mb-4 max-w-lg flex items-end gap-3"> |
| 64 | 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 | 68 | <input type="text" required value={code} onChange={(e) => setCode(e.target.value)} |
| 67 | 69 | placeholder="sales-clerk" className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm" /> |
| 68 | 70 | </div> |
| 69 | 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 | 73 | <input type="text" required value={name} onChange={(e) => setName(e.target.value)} |
| 72 | 74 | placeholder="Sales Clerk" className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm" /> |
| 73 | 75 | </div> |
| 74 | 76 | <button type="submit" className="btn-primary" disabled={creating}> |
| 75 | - {creating ? '...' : 'Create'} | |
| 77 | + {creating ? '...' : t('action.create')} | |
| 76 | 78 | </button> |
| 77 | 79 | </form> |
| 78 | 80 | )} | ... | ... |
web/src/pages/SalesOrderDetailPage.tsx
| ... | ... | @@ -3,11 +3,11 @@ |
| 3 | 3 | // **The headline demo flow.** Confirm a DRAFT, ship a CONFIRMED, |
| 4 | 4 | // or cancel either. Each action updates the order in place, |
| 5 | 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 | 7 | // SALES_SHIPMENT ledger entry appearing. |
| 8 | 8 | // |
| 9 | 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 | 11 | // inventory ledger tags the row with that location and the audit |
| 12 | 12 | // trail must always answer "which warehouse shipped this". The UI |
| 13 | 13 | // proposes the first WAREHOUSE-typed location it finds; an |
| ... | ... | @@ -21,10 +21,12 @@ import { PageHeader } from '@/components/PageHeader' |
| 21 | 21 | import { Loading } from '@/components/Loading' |
| 22 | 22 | import { ErrorBox } from '@/components/ErrorBox' |
| 23 | 23 | import { StatusBadge } from '@/components/StatusBadge' |
| 24 | +import { useT } from '@/i18n/LocaleContext' | |
| 24 | 25 | |
| 25 | 26 | export function SalesOrderDetailPage() { |
| 26 | 27 | const { id = '' } = useParams<{ id: string }>() |
| 27 | 28 | const navigate = useNavigate() |
| 29 | + const t = useT() | |
| 28 | 30 | const [order, setOrder] = useState<SalesOrder | null>(null) |
| 29 | 31 | const [locations, setLocations] = useState<Location[]>([]) |
| 30 | 32 | const [shippingLocation, setShippingLocation] = useState<string>('') |
| ... | ... | @@ -77,7 +79,7 @@ export function SalesOrderDetailPage() { |
| 77 | 79 | const updated = await salesOrders.confirm(order.id) |
| 78 | 80 | setOrder(updated) |
| 79 | 81 | await reloadSideEffects(updated.code) |
| 80 | - setActionMessage('Confirmed. pbc-finance has posted an AR journal entry.') | |
| 82 | + setActionMessage(t('page.salesOrderDetail.confirmMsg')) | |
| 81 | 83 | } catch (e: unknown) { |
| 82 | 84 | setError(e instanceof Error ? e : new Error(String(e))) |
| 83 | 85 | } finally { |
| ... | ... | @@ -87,7 +89,7 @@ export function SalesOrderDetailPage() { |
| 87 | 89 | |
| 88 | 90 | const onShip = async () => { |
| 89 | 91 | if (!shippingLocation) { |
| 90 | - setError(new Error('Pick a shipping location first.')) | |
| 92 | + setError(new Error(t('page.salesOrderDetail.pickLocation'))) | |
| 91 | 93 | return |
| 92 | 94 | } |
| 93 | 95 | setActing(true) |
| ... | ... | @@ -98,7 +100,7 @@ export function SalesOrderDetailPage() { |
| 98 | 100 | setOrder(updated) |
| 99 | 101 | await reloadSideEffects(updated.code) |
| 100 | 102 | setActionMessage( |
| 101 | - `Shipped from ${shippingLocation}. Stock debited, journal entry settled.`, | |
| 103 | + t('page.salesOrderDetail.shipMsg').replace('{location}', shippingLocation), | |
| 102 | 104 | ) |
| 103 | 105 | } catch (e: unknown) { |
| 104 | 106 | setError(e instanceof Error ? e : new Error(String(e))) |
| ... | ... | @@ -115,7 +117,7 @@ export function SalesOrderDetailPage() { |
| 115 | 117 | const updated = await salesOrders.cancel(order.id) |
| 116 | 118 | setOrder(updated) |
| 117 | 119 | await reloadSideEffects(updated.code) |
| 118 | - setActionMessage('Cancelled. pbc-finance has reversed any open journal entry.') | |
| 120 | + setActionMessage(t('page.salesOrderDetail.cancelMsg')) | |
| 119 | 121 | } catch (e: unknown) { |
| 120 | 122 | setError(e instanceof Error ? e : new Error(String(e))) |
| 121 | 123 | } finally { |
| ... | ... | @@ -130,11 +132,11 @@ export function SalesOrderDetailPage() { |
| 130 | 132 | return ( |
| 131 | 133 | <div> |
| 132 | 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 | 137 | actions={ |
| 136 | 138 | <button className="btn-secondary" onClick={() => navigate('/sales-orders')}> |
| 137 | - ← Back | |
| 139 | + {t('action.back')} | |
| 138 | 140 | </button> |
| 139 | 141 | } |
| 140 | 142 | /> |
| ... | ... | @@ -142,11 +144,11 @@ export function SalesOrderDetailPage() { |
| 142 | 144 | <div className="card mb-6 p-5"> |
| 143 | 145 | <div className="flex flex-wrap items-center justify-between gap-4"> |
| 144 | 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 | 148 | <StatusBadge status={order.status} /> |
| 147 | 149 | </div> |
| 148 | 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 | 152 | <div className="font-mono text-xl font-semibold"> |
| 151 | 153 | {Number(order.totalAmount).toLocaleString(undefined, { |
| 152 | 154 | minimumFractionDigits: 2, |
| ... | ... | @@ -164,10 +166,10 @@ export function SalesOrderDetailPage() { |
| 164 | 166 | </div> |
| 165 | 167 | |
| 166 | 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 | 170 | <div className="flex flex-wrap items-center gap-3"> |
| 169 | 171 | <button className="btn-primary" disabled={!canConfirm || acting} onClick={onConfirm}> |
| 170 | - Confirm | |
| 172 | + {t('action.confirm')} | |
| 171 | 173 | </button> |
| 172 | 174 | <div className="flex items-center gap-2"> |
| 173 | 175 | <select |
| ... | ... | @@ -183,27 +185,27 @@ export function SalesOrderDetailPage() { |
| 183 | 185 | ))} |
| 184 | 186 | </select> |
| 185 | 187 | <button className="btn-primary" disabled={!canShip || acting} onClick={onShip}> |
| 186 | - Ship | |
| 188 | + {t('action.ship')} | |
| 187 | 189 | </button> |
| 188 | 190 | </div> |
| 189 | 191 | <button className="btn-danger" disabled={!canCancel || acting} onClick={onCancel}> |
| 190 | - Cancel | |
| 192 | + {t('action.cancel')} | |
| 191 | 193 | </button> |
| 192 | 194 | </div> |
| 193 | 195 | </div> |
| 194 | 196 | |
| 195 | 197 | <div className="card mb-6"> |
| 196 | 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 | 200 | </div> |
| 199 | 201 | <table className="table-base"> |
| 200 | 202 | <thead className="bg-slate-50"> |
| 201 | 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 | 209 | </tr> |
| 208 | 210 | </thead> |
| 209 | 211 | <tbody className="divide-y divide-slate-100"> |
| ... | ... | @@ -232,18 +234,18 @@ export function SalesOrderDetailPage() { |
| 232 | 234 | <div className="grid gap-6 md:grid-cols-2"> |
| 233 | 235 | <div className="card"> |
| 234 | 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 | 238 | </div> |
| 237 | 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 | 242 | <table className="table-base"> |
| 241 | 243 | <thead className="bg-slate-50"> |
| 242 | 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 | 249 | </tr> |
| 248 | 250 | </thead> |
| 249 | 251 | <tbody className="divide-y divide-slate-100"> |
| ... | ... | @@ -252,7 +254,7 @@ export function SalesOrderDetailPage() { |
| 252 | 254 | <td className="font-mono">{m.itemCode}</td> |
| 253 | 255 | <td className="font-mono tabular-nums text-rose-600">{String(m.delta)}</td> |
| 254 | 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 | 258 | </tr> |
| 257 | 259 | ))} |
| 258 | 260 | </tbody> |
| ... | ... | @@ -261,18 +263,18 @@ export function SalesOrderDetailPage() { |
| 261 | 263 | </div> |
| 262 | 264 | <div className="card"> |
| 263 | 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 | 267 | </div> |
| 266 | 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 | 271 | <table className="table-base"> |
| 270 | 272 | <thead className="bg-slate-50"> |
| 271 | 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 | 278 | </tr> |
| 277 | 279 | </thead> |
| 278 | 280 | <tbody className="divide-y divide-slate-100"> |
| ... | ... | @@ -293,7 +295,7 @@ export function SalesOrderDetailPage() { |
| 293 | 295 | </table> |
| 294 | 296 | )} |
| 295 | 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 | 299 | </div> |
| 298 | 300 | </div> |
| 299 | 301 | </div> | ... | ... |
web/src/pages/SalesOrdersPage.tsx
| ... | ... | @@ -7,8 +7,10 @@ import { Loading } from '@/components/Loading' |
| 7 | 7 | import { ErrorBox } from '@/components/ErrorBox' |
| 8 | 8 | import { DataTable, type Column } from '@/components/DataTable' |
| 9 | 9 | import { StatusBadge } from '@/components/StatusBadge' |
| 10 | +import { useT } from '@/i18n/LocaleContext' | |
| 10 | 11 | |
| 11 | 12 | export function SalesOrdersPage() { |
| 13 | + const t = useT() | |
| 12 | 14 | const [rows, setRows] = useState<SalesOrder[]>([]) |
| 13 | 15 | const [error, setError] = useState<Error | null>(null) |
| 14 | 16 | const [loading, setLoading] = useState(true) |
| ... | ... | @@ -23,7 +25,7 @@ export function SalesOrdersPage() { |
| 23 | 25 | |
| 24 | 26 | const columns: Column<SalesOrder>[] = [ |
| 25 | 27 | { |
| 26 | - header: 'Code', | |
| 28 | + header: t('label.code'), | |
| 27 | 29 | key: 'code', |
| 28 | 30 | render: (r) => ( |
| 29 | 31 | <Link to={`/sales-orders/${r.id}`} className="font-mono text-brand-600 hover:underline"> |
| ... | ... | @@ -31,11 +33,11 @@ export function SalesOrdersPage() { |
| 31 | 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 | 41 | key: 'totalAmount', |
| 40 | 42 | render: (r) => ( |
| 41 | 43 | <span className="font-mono tabular-nums"> |
| ... | ... | @@ -48,7 +50,7 @@ export function SalesOrdersPage() { |
| 48 | 50 | ), |
| 49 | 51 | }, |
| 50 | 52 | { |
| 51 | - header: 'Lines', | |
| 53 | + header: t('label.lines'), | |
| 52 | 54 | key: 'lines', |
| 53 | 55 | render: (r) => <span className="text-slate-500">{r.lines.length}</span>, |
| 54 | 56 | }, |
| ... | ... | @@ -57,10 +59,10 @@ export function SalesOrdersPage() { |
| 57 | 59 | return ( |
| 58 | 60 | <div> |
| 59 | 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 | 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 | 68 | {loading && <Loading />} | ... | ... |
web/src/pages/ShopFloorPage.tsx
| ... | ... | @@ -3,7 +3,7 @@ |
| 3 | 3 | // Polls /api/v1/production/work-orders/shop-floor every 5s and |
| 4 | 4 | // renders one card per IN_PROGRESS work order with its current |
| 5 | 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 | 7 | // are large, the typography is high-contrast, and the only state |
| 8 | 8 | // is "what's running right now". |
| 9 | 9 | |
| ... | ... | @@ -15,10 +15,12 @@ import { PageHeader } from '@/components/PageHeader' |
| 15 | 15 | import { Loading } from '@/components/Loading' |
| 16 | 16 | import { ErrorBox } from '@/components/ErrorBox' |
| 17 | 17 | import { StatusBadge } from '@/components/StatusBadge' |
| 18 | +import { useT } from '@/i18n/LocaleContext' | |
| 18 | 19 | |
| 19 | 20 | const POLL_MS = 5000 |
| 20 | 21 | |
| 21 | 22 | export function ShopFloorPage() { |
| 23 | + const t = useT() | |
| 22 | 24 | const [rows, setRows] = useState<ShopFloorEntry[]>([]) |
| 23 | 25 | const [error, setError] = useState<Error | null>(null) |
| 24 | 26 | const [loading, setLoading] = useState(true) |
| ... | ... | @@ -52,16 +54,16 @@ export function ShopFloorPage() { |
| 52 | 54 | return ( |
| 53 | 55 | <div> |
| 54 | 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 | 62 | {loading && <Loading />} |
| 61 | 63 | {error && <ErrorBox error={error} />} |
| 62 | 64 | {!loading && rows.length === 0 && ( |
| 63 | 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 | 67 | </div> |
| 66 | 68 | )} |
| 67 | 69 | <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> |
| ... | ... | @@ -80,15 +82,15 @@ export function ShopFloorPage() { |
| 80 | 82 | {r.workOrderCode} |
| 81 | 83 | </span> |
| 82 | 84 | <span className="text-xs text-slate-500"> |
| 83 | - {r.operationsCompleted} / {r.operationsTotal} ops | |
| 85 | + {r.operationsCompleted} / {r.operationsTotal} {t('page.shopFloor.ops')} | |
| 84 | 86 | </span> |
| 85 | 87 | </div> |
| 86 | 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 | 90 | {String(r.outputQuantity)} |
| 89 | 91 | </div> |
| 90 | 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 | 94 | {r.currentOperationCode ? ( |
| 93 | 95 | <div className="mt-1 flex items-center gap-2"> |
| 94 | 96 | <span className="font-mono font-medium">{r.currentOperationCode}</span> |
| ... | ... | @@ -96,13 +98,13 @@ export function ShopFloorPage() { |
| 96 | 98 | {r.currentOperationStatus && <StatusBadge status={r.currentOperationStatus} />} |
| 97 | 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 | 103 | </div> |
| 102 | 104 | <div className="mt-2"> |
| 103 | 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 | 108 | </div> |
| 107 | 109 | <div className="h-2 overflow-hidden rounded-full bg-slate-100"> |
| 108 | 110 | <div | ... | ... |
web/src/pages/TaskDetailPage.tsx
| ... | ... | @@ -77,15 +77,15 @@ export function TaskDetailPage() { |
| 77 | 77 | |
| 78 | 78 | <div className="card p-4 space-y-4"> |
| 79 | 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 | 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 | 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 | 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 | 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 | 89 | <dd className="font-mono">{task.formKey ?? '\u2014'}</dd> |
| 90 | 90 | </dl> |
| 91 | 91 | |
| ... | ... | @@ -100,7 +100,7 @@ export function TaskDetailPage() { |
| 100 | 100 | ) : ( |
| 101 | 101 | <div className="space-y-4"> |
| 102 | 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 | 104 | <pre className="rounded-md bg-slate-50 border border-slate-200 p-3 text-xs overflow-x-auto max-h-64"> |
| 105 | 105 | {JSON.stringify(task.variables, null, 2)} |
| 106 | 106 | </pre> | ... | ... |
web/src/pages/UomsPage.tsx
| ... | ... | @@ -5,8 +5,10 @@ import { PageHeader } from '@/components/PageHeader' |
| 5 | 5 | import { Loading } from '@/components/Loading' |
| 6 | 6 | import { ErrorBox } from '@/components/ErrorBox' |
| 7 | 7 | import { DataTable, type Column } from '@/components/DataTable' |
| 8 | +import { useT } from '@/i18n/LocaleContext' | |
| 8 | 9 | |
| 9 | 10 | export function UomsPage() { |
| 11 | + const t = useT() | |
| 10 | 12 | const [rows, setRows] = useState<Uom[]>([]) |
| 11 | 13 | const [error, setError] = useState<Error | null>(null) |
| 12 | 14 | const [loading, setLoading] = useState(true) |
| ... | ... | @@ -20,16 +22,16 @@ export function UomsPage() { |
| 20 | 22 | }, []) |
| 21 | 23 | |
| 22 | 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 | 30 | return ( |
| 29 | 31 | <div> |
| 30 | 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 | 36 | {loading && <Loading />} |
| 35 | 37 | {error && <ErrorBox error={error} />} | ... | ... |
web/src/pages/UserDetailPage.tsx
| ... | ... | @@ -11,10 +11,12 @@ import type { Role, User } from '@/types/api' |
| 11 | 11 | import { PageHeader } from '@/components/PageHeader' |
| 12 | 12 | import { Loading } from '@/components/Loading' |
| 13 | 13 | import { ErrorBox } from '@/components/ErrorBox' |
| 14 | +import { useT } from '@/i18n/LocaleContext' | |
| 14 | 15 | |
| 15 | 16 | export function UserDetailPage() { |
| 16 | 17 | const { id = '' } = useParams<{ id: string }>() |
| 17 | 18 | const navigate = useNavigate() |
| 19 | + const t = useT() | |
| 18 | 20 | const [user, setUser] = useState<User | null>(null) |
| 19 | 21 | const [allRoles, setAllRoles] = useState<Role[]>([]) |
| 20 | 22 | const [userRoleCodes, setUserRoleCodes] = useState<string[]>([]) |
| ... | ... | @@ -64,10 +66,10 @@ export function UserDetailPage() { |
| 64 | 66 | <div> |
| 65 | 67 | <PageHeader |
| 66 | 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 | 70 | actions={ |
| 69 | 71 | <button className="btn-secondary" onClick={() => navigate('/users')}> |
| 70 | - ← Back | |
| 72 | + {t('action.back')} | |
| 71 | 73 | </button> |
| 72 | 74 | } |
| 73 | 75 | /> |
| ... | ... | @@ -75,12 +77,12 @@ export function UserDetailPage() { |
| 75 | 77 | {error && <ErrorBox error={error} />} |
| 76 | 78 | |
| 77 | 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 | 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 | 83 | </p> |
| 82 | 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 | 87 | <div className="space-y-2"> |
| 86 | 88 | {allRoles.map((role) => { |
| ... | ... | @@ -99,7 +101,7 @@ export function UserDetailPage() { |
| 99 | 101 | disabled={acting} |
| 100 | 102 | onClick={() => toggle(role.code, has)} |
| 101 | 103 | > |
| 102 | - {has ? 'Revoke' : 'Assign'} | |
| 104 | + {has ? t('action.revoke') : t('action.assign')} | |
| 103 | 105 | </button> |
| 104 | 106 | </div> |
| 105 | 107 | ) | ... | ... |
web/src/pages/UserTasksPage.tsx
| ... | ... | @@ -31,7 +31,7 @@ export function UserTasksPage() { |
| 31 | 31 | |
| 32 | 32 | const columns: Column<UserTaskSummary>[] = [ |
| 33 | 33 | { |
| 34 | - header: 'Task Name', | |
| 34 | + header: t('label.taskName'), | |
| 35 | 35 | key: 'taskName', |
| 36 | 36 | render: (r) => ( |
| 37 | 37 | <button |
| ... | ... | @@ -42,10 +42,10 @@ export function UserTasksPage() { |
| 42 | 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 | 51 | return ( |
| ... | ... | @@ -61,7 +61,7 @@ export function UserTasksPage() { |
| 61 | 61 | rows={rows} |
| 62 | 62 | columns={columns} |
| 63 | 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 | 67 | </div> | ... | ... |
web/src/pages/UsersPage.tsx
| ... | ... | @@ -6,8 +6,10 @@ import { PageHeader } from '@/components/PageHeader' |
| 6 | 6 | import { Loading } from '@/components/Loading' |
| 7 | 7 | import { ErrorBox } from '@/components/ErrorBox' |
| 8 | 8 | import { DataTable, type Column } from '@/components/DataTable' |
| 9 | +import { useT } from '@/i18n/LocaleContext' | |
| 9 | 10 | |
| 10 | 11 | export function UsersPage() { |
| 12 | + const t = useT() | |
| 11 | 13 | const [rows, setRows] = useState<User[]>([]) |
| 12 | 14 | const [error, setError] = useState<Error | null>(null) |
| 13 | 15 | const [loading, setLoading] = useState(true) |
| ... | ... | @@ -22,7 +24,7 @@ export function UsersPage() { |
| 22 | 24 | |
| 23 | 25 | const columns: Column<User>[] = [ |
| 24 | 26 | { |
| 25 | - header: 'Username', | |
| 27 | + header: t('label.username'), | |
| 26 | 28 | key: 'username', |
| 27 | 29 | render: (r) => ( |
| 28 | 30 | <Link to={`/users/${r.id}`} className="font-mono text-brand-600 hover:underline"> |
| ... | ... | @@ -30,16 +32,16 @@ export function UsersPage() { |
| 30 | 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 | 39 | key: 'enabled', |
| 38 | 40 | render: (r) => |
| 39 | 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 | 49 | return ( |
| 48 | 50 | <div> |
| 49 | 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 | 56 | {loading && <Loading />} |
| 55 | 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 | 2 | // action verbs that drive the v2 state machine. The shop-floor |
| 3 | 3 | // dashboard at /shop-floor handles the per-operation walk for v3 |
| 4 | 4 | // routing-equipped orders. v1 SPA keeps this screen simple: start a |
| ... | ... | @@ -12,10 +12,12 @@ import { PageHeader } from '@/components/PageHeader' |
| 12 | 12 | import { Loading } from '@/components/Loading' |
| 13 | 13 | import { ErrorBox } from '@/components/ErrorBox' |
| 14 | 14 | import { StatusBadge } from '@/components/StatusBadge' |
| 15 | +import { useT } from '@/i18n/LocaleContext' | |
| 15 | 16 | |
| 16 | 17 | export function WorkOrderDetailPage() { |
| 17 | 18 | const { id = '' } = useParams<{ id: string }>() |
| 18 | 19 | const navigate = useNavigate() |
| 20 | + const t = useT() | |
| 19 | 21 | const [order, setOrder] = useState<WorkOrder | null>(null) |
| 20 | 22 | const [locations, setLocations] = useState<Location[]>([]) |
| 21 | 23 | const [outputLocation, setOutputLocation] = useState<string>('') |
| ... | ... | @@ -60,7 +62,7 @@ export function WorkOrderDetailPage() { |
| 60 | 62 | try { |
| 61 | 63 | await production.startWorkOrder(order.id) |
| 62 | 64 | await refresh() |
| 63 | - setActionMessage('Started. Operations can now be walked from the Shop Floor screen.') | |
| 65 | + setActionMessage(t('page.workOrderDetail.startMsg')) | |
| 64 | 66 | } catch (e: unknown) { |
| 65 | 67 | setError(e instanceof Error ? e : new Error(String(e))) |
| 66 | 68 | } finally { |
| ... | ... | @@ -70,7 +72,7 @@ export function WorkOrderDetailPage() { |
| 70 | 72 | |
| 71 | 73 | const onComplete = async () => { |
| 72 | 74 | if (!outputLocation) { |
| 73 | - setError(new Error('Pick an output location first.')) | |
| 75 | + setError(new Error(t('page.workOrderDetail.pickLocation'))) | |
| 74 | 76 | return |
| 75 | 77 | } |
| 76 | 78 | setActing(true) |
| ... | ... | @@ -80,7 +82,7 @@ export function WorkOrderDetailPage() { |
| 80 | 82 | await production.completeWorkOrder(order.id, outputLocation) |
| 81 | 83 | await refresh() |
| 82 | 84 | setActionMessage( |
| 83 | - `Completed. Materials issued, finished goods credited to ${outputLocation}.`, | |
| 85 | + t('page.workOrderDetail.completeMsg').replace('{location}', outputLocation), | |
| 84 | 86 | ) |
| 85 | 87 | } catch (e: unknown) { |
| 86 | 88 | setError(e instanceof Error ? e : new Error(String(e))) |
| ... | ... | @@ -95,20 +97,20 @@ export function WorkOrderDetailPage() { |
| 95 | 97 | return ( |
| 96 | 98 | <div> |
| 97 | 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 | 104 | actions={ |
| 103 | 105 | <button className="btn-secondary" onClick={() => navigate('/work-orders')}> |
| 104 | - ← Back | |
| 106 | + {t('action.back')} | |
| 105 | 107 | </button> |
| 106 | 108 | } |
| 107 | 109 | /> |
| 108 | 110 | |
| 109 | 111 | <div className="card mb-6 p-5"> |
| 110 | 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 | 114 | <StatusBadge status={order.status} /> |
| 113 | 115 | </div> |
| 114 | 116 | {actionMessage && ( |
| ... | ... | @@ -119,10 +121,10 @@ export function WorkOrderDetailPage() { |
| 119 | 121 | </div> |
| 120 | 122 | |
| 121 | 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 | 125 | <div className="flex flex-wrap items-center gap-3"> |
| 124 | 126 | <button className="btn-primary" disabled={!canStart || acting} onClick={onStart}> |
| 125 | - Start | |
| 127 | + {t('action.start')} | |
| 126 | 128 | </button> |
| 127 | 129 | <div className="flex items-center gap-2"> |
| 128 | 130 | <select |
| ... | ... | @@ -138,7 +140,7 @@ export function WorkOrderDetailPage() { |
| 138 | 140 | ))} |
| 139 | 141 | </select> |
| 140 | 142 | <button className="btn-primary" disabled={!canComplete || acting} onClick={onComplete}> |
| 141 | - Complete | |
| 143 | + {t('action.complete')} | |
| 142 | 144 | </button> |
| 143 | 145 | </div> |
| 144 | 146 | </div> |
| ... | ... | @@ -147,18 +149,18 @@ export function WorkOrderDetailPage() { |
| 147 | 149 | <div className="grid gap-6 md:grid-cols-2"> |
| 148 | 150 | <div className="card"> |
| 149 | 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 | 153 | </div> |
| 152 | 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 | 157 | <table className="table-base"> |
| 156 | 158 | <thead className="bg-slate-50"> |
| 157 | 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 | 164 | </tr> |
| 163 | 165 | </thead> |
| 164 | 166 | <tbody className="divide-y divide-slate-100"> |
| ... | ... | @@ -176,19 +178,19 @@ export function WorkOrderDetailPage() { |
| 176 | 178 | </div> |
| 177 | 179 | <div className="card"> |
| 178 | 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 | 182 | </div> |
| 181 | 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 | 186 | <table className="table-base"> |
| 185 | 187 | <thead className="bg-slate-50"> |
| 186 | 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 | 194 | </tr> |
| 193 | 195 | </thead> |
| 194 | 196 | <tbody className="divide-y divide-slate-100"> | ... | ... |
web/src/pages/WorkOrdersPage.tsx
| ... | ... | @@ -7,8 +7,10 @@ import { Loading } from '@/components/Loading' |
| 7 | 7 | import { ErrorBox } from '@/components/ErrorBox' |
| 8 | 8 | import { DataTable, type Column } from '@/components/DataTable' |
| 9 | 9 | import { StatusBadge } from '@/components/StatusBadge' |
| 10 | +import { useT } from '@/i18n/LocaleContext' | |
| 10 | 11 | |
| 11 | 12 | export function WorkOrdersPage() { |
| 13 | + const t = useT() | |
| 12 | 14 | const [rows, setRows] = useState<WorkOrder[]>([]) |
| 13 | 15 | const [error, setError] = useState<Error | null>(null) |
| 14 | 16 | const [loading, setLoading] = useState(true) |
| ... | ... | @@ -23,7 +25,7 @@ export function WorkOrdersPage() { |
| 23 | 25 | |
| 24 | 26 | const columns: Column<WorkOrder>[] = [ |
| 25 | 27 | { |
| 26 | - header: 'Code', | |
| 28 | + header: t('label.code'), | |
| 27 | 29 | key: 'code', |
| 28 | 30 | render: (r) => ( |
| 29 | 31 | <Link to={`/work-orders/${r.id}`} className="font-mono text-brand-600 hover:underline"> |
| ... | ... | @@ -31,20 +33,20 @@ export function WorkOrdersPage() { |
| 31 | 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 | 39 | key: 'outputQuantity', |
| 38 | 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 | 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 | 50 | key: 'opsCount', |
| 49 | 51 | render: (r) => `${r.inputs.length} / ${r.operations.length}`, |
| 50 | 52 | }, |
| ... | ... | @@ -53,9 +55,9 @@ export function WorkOrdersPage() { |
| 53 | 55 | return ( |
| 54 | 56 | <div> |
| 55 | 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 | 62 | {loading && <Loading />} |
| 61 | 63 | {error && <ErrorBox error={error} />} | ... | ... |