Commit e1d0c8338d1479c2951bee98c199eee83893d094
1 parent
4f88c6d4
feat(web): SPA i18n — locale context, en-US + zh-CN, language switcher
Adds client-side i18n infrastructure to the SPA (CLAUDE.md
guardrail #6: global/i18n from day one).
New files:
- i18n/messages.ts: flat key-value message bundles for en-US
and zh-CN. Keys use dot-notation (nav.*, action.*, status.*,
label.*). ~80 keys per locale covering navigation, actions,
status badges, and common labels.
- i18n/LocaleContext.tsx: LocaleProvider + useT() hook + useLocale()
hook. Active locale stored in localStorage, defaults to the
browser's navigator.language. Auto-detects zh-* → zh-CN.
Wired into the SPA:
- main.tsx wraps the app in <LocaleProvider>
- AppLayout sidebar uses t(key) for every heading and item
- Top bar has a locale dropdown (English / 中文) that
switches the entire sidebar + status labels instantly
- StatusBadge uses t('status.DRAFT') etc. so statuses render
as '草稿' / '已确认' / '已发货' in Chinese
The i18n system is intentionally simple: plain strings, no ICU
MessageFormat patterns (those live on the backend via ICU4J).
A future chunk can adopt @formatjs/intl-messageformat if the
SPA needs plural/gender/number formatting client-side.
Not yet translated: page-level titles and form labels (they
still use hard-coded English). The infrastructure is in place;
translating individual pages is incremental.
Showing
5 changed files
with
346 additions
and
66 deletions
web/src/components/StatusBadge.tsx
| 1 | +import { useT } from '@/i18n/LocaleContext' | ||
| 2 | +import type { MessageKey } from '@/i18n/messages' | ||
| 3 | + | ||
| 1 | interface Props { | 4 | interface Props { |
| 2 | status: string | 5 | status: string |
| 3 | } | 6 | } |
| 4 | 7 | ||
| 5 | -// Status → Tailwind class via the shared @layer components classes | ||
| 6 | -// declared in index.css. Falls back to 'badge-pending' for unknown | ||
| 7 | -// statuses so a new server-side enum value renders as a neutral pill | ||
| 8 | -// instead of throwing. | 8 | +const CLS_MAP: Record<string, string> = { |
| 9 | + DRAFT: 'badge-draft', | ||
| 10 | + CONFIRMED: 'badge-confirmed', | ||
| 11 | + SHIPPED: 'badge-shipped', | ||
| 12 | + RECEIVED: 'badge-received', | ||
| 13 | + COMPLETED: 'badge-completed', | ||
| 14 | + CANCELLED: 'badge-cancelled', | ||
| 15 | + IN_PROGRESS: 'badge-in-progress', | ||
| 16 | + PENDING: 'badge-pending', | ||
| 17 | + POSTED: 'badge-confirmed', | ||
| 18 | + SETTLED: 'badge-shipped', | ||
| 19 | + REVERSED: 'badge-cancelled', | ||
| 20 | +} | ||
| 21 | + | ||
| 9 | export function StatusBadge({ status }: Props) { | 22 | export function StatusBadge({ status }: Props) { |
| 10 | - const cls = | ||
| 11 | - { | ||
| 12 | - DRAFT: 'badge-draft', | ||
| 13 | - CONFIRMED: 'badge-confirmed', | ||
| 14 | - SHIPPED: 'badge-shipped', | ||
| 15 | - RECEIVED: 'badge-received', | ||
| 16 | - COMPLETED: 'badge-completed', | ||
| 17 | - CANCELLED: 'badge-cancelled', | ||
| 18 | - IN_PROGRESS: 'badge-in-progress', | ||
| 19 | - PENDING: 'badge-pending', | ||
| 20 | - POSTED: 'badge-confirmed', | ||
| 21 | - SETTLED: 'badge-shipped', | ||
| 22 | - REVERSED: 'badge-cancelled', | ||
| 23 | - }[status] ?? 'badge-pending' | ||
| 24 | - return <span className={cls}>{status}</span> | 23 | + const t = useT() |
| 24 | + const cls = CLS_MAP[status] ?? 'badge-pending' | ||
| 25 | + const key = `status.${status}` as MessageKey | ||
| 26 | + const label = t(key) | ||
| 27 | + return <span className={cls}>{label}</span> | ||
| 25 | } | 28 | } |
web/src/i18n/LocaleContext.tsx
0 → 100644
| 1 | +// vibe_erp SPA locale context. | ||
| 2 | +// | ||
| 3 | +// Provides a `useT()` hook that returns a translation function | ||
| 4 | +// `t('key')` bound to the active locale. The active locale is | ||
| 5 | +// stored in localStorage and defaults to the browser's language. | ||
| 6 | +// | ||
| 7 | +// **Why client-side, not server-driven.** The backend already has | ||
| 8 | +// ICU4J i18n with per-plug-in locale chains. The SPA's UI chrome | ||
| 9 | +// (sidebar, buttons, status labels) is a separate concern — it | ||
| 10 | +// runs entirely in the browser and should not require an API call | ||
| 11 | +// to render a button label. The two i18n systems share the same | ||
| 12 | +// locale code (en-US, zh-CN) so they stay in sync when the user | ||
| 13 | +// picks a language. | ||
| 14 | + | ||
| 15 | +import { | ||
| 16 | + createContext, | ||
| 17 | + useCallback, | ||
| 18 | + useContext, | ||
| 19 | + useState, | ||
| 20 | + type ReactNode, | ||
| 21 | +} from 'react' | ||
| 22 | +import { en, locales, type LocaleCode, type MessageKey } from './messages' | ||
| 23 | + | ||
| 24 | +const STORAGE_KEY = 'vibeerp.locale' | ||
| 25 | + | ||
| 26 | +function detectLocale(): LocaleCode { | ||
| 27 | + const stored = localStorage.getItem(STORAGE_KEY) | ||
| 28 | + if (stored && stored in locales) return stored as LocaleCode | ||
| 29 | + | ||
| 30 | + const browserLang = navigator.language | ||
| 31 | + if (browserLang.startsWith('zh')) return 'zh-CN' | ||
| 32 | + return 'en-US' | ||
| 33 | +} | ||
| 34 | + | ||
| 35 | +interface LocaleState { | ||
| 36 | + locale: LocaleCode | ||
| 37 | + setLocale: (code: LocaleCode) => void | ||
| 38 | + t: (key: MessageKey) => string | ||
| 39 | +} | ||
| 40 | + | ||
| 41 | +const LocaleContext = createContext<LocaleState | null>(null) | ||
| 42 | + | ||
| 43 | +export function LocaleProvider({ children }: { children: ReactNode }) { | ||
| 44 | + const [locale, setLocaleState] = useState<LocaleCode>(detectLocale) | ||
| 45 | + | ||
| 46 | + const setLocale = useCallback((code: LocaleCode) => { | ||
| 47 | + localStorage.setItem(STORAGE_KEY, code) | ||
| 48 | + setLocaleState(code) | ||
| 49 | + }, []) | ||
| 50 | + | ||
| 51 | + const messages = locales[locale] | ||
| 52 | + | ||
| 53 | + const t = useCallback( | ||
| 54 | + (key: MessageKey): string => messages[key] ?? en[key] ?? key, | ||
| 55 | + [messages], | ||
| 56 | + ) | ||
| 57 | + | ||
| 58 | + return ( | ||
| 59 | + <LocaleContext.Provider value={{ locale, setLocale, t }}> | ||
| 60 | + {children} | ||
| 61 | + </LocaleContext.Provider> | ||
| 62 | + ) | ||
| 63 | +} | ||
| 64 | + | ||
| 65 | +export function useT() { | ||
| 66 | + const ctx = useContext(LocaleContext) | ||
| 67 | + if (!ctx) throw new Error('useT must be used inside <LocaleProvider>') | ||
| 68 | + return ctx.t | ||
| 69 | +} | ||
| 70 | + | ||
| 71 | +export function useLocale() { | ||
| 72 | + const ctx = useContext(LocaleContext) | ||
| 73 | + if (!ctx) throw new Error('useLocale must be used inside <LocaleProvider>') | ||
| 74 | + return ctx | ||
| 75 | +} | ||
| 76 | + | ||
| 77 | +export const AVAILABLE_LOCALES: { code: LocaleCode; label: string }[] = [ | ||
| 78 | + { code: 'en-US', label: 'English' }, | ||
| 79 | + { code: 'zh-CN', label: '中文' }, | ||
| 80 | +] |
web/src/i18n/messages.ts
0 → 100644
| 1 | +// vibe_erp SPA message bundles. | ||
| 2 | +// | ||
| 3 | +// Each locale is a flat key→string map. Keys use dot-notation | ||
| 4 | +// grouped by area (nav.*, page.*, action.*, status.*, label.*). | ||
| 5 | +// | ||
| 6 | +// The framework's backend i18n uses ICU4J with MessageFormat | ||
| 7 | +// patterns. The SPA uses plain strings for v1 — ICU MessageFormat | ||
| 8 | +// in the browser (via @formatjs/intl-messageformat) is a v1.x | ||
| 9 | +// enhancement when plural/gender/number formatting is needed | ||
| 10 | +// client-side. | ||
| 11 | + | ||
| 12 | +export type MessageKey = keyof typeof en | ||
| 13 | + | ||
| 14 | +export const en = { | ||
| 15 | + // ─── Navigation ──────────────────────────────────────────── | ||
| 16 | + 'nav.overview': 'Overview', | ||
| 17 | + 'nav.dashboard': 'Dashboard', | ||
| 18 | + 'nav.catalog': 'Catalog & Partners', | ||
| 19 | + 'nav.items': 'Items', | ||
| 20 | + 'nav.uoms': 'Units of Measure', | ||
| 21 | + 'nav.partners': 'Partners', | ||
| 22 | + 'nav.inventory': 'Inventory', | ||
| 23 | + 'nav.locations': 'Locations', | ||
| 24 | + 'nav.balances': 'Stock Balances', | ||
| 25 | + 'nav.movements': 'Stock Movements', | ||
| 26 | + 'nav.orders': 'Orders', | ||
| 27 | + 'nav.salesOrders': 'Sales Orders', | ||
| 28 | + 'nav.purchaseOrders': 'Purchase Orders', | ||
| 29 | + 'nav.production': 'Production', | ||
| 30 | + 'nav.workOrders': 'Work Orders', | ||
| 31 | + 'nav.shopFloor': 'Shop Floor', | ||
| 32 | + 'nav.finance': 'Finance', | ||
| 33 | + 'nav.accounts': 'Chart of Accounts', | ||
| 34 | + 'nav.journalEntries': 'Journal Entries', | ||
| 35 | + 'nav.system': 'System', | ||
| 36 | + 'nav.users': 'Users', | ||
| 37 | + 'nav.roles': 'Roles', | ||
| 38 | + | ||
| 39 | + // ─── Actions ─────────────────────────────────────────────── | ||
| 40 | + 'action.confirm': 'Confirm', | ||
| 41 | + 'action.ship': 'Ship', | ||
| 42 | + 'action.receive': 'Receive', | ||
| 43 | + 'action.cancel': 'Cancel', | ||
| 44 | + 'action.start': 'Start', | ||
| 45 | + 'action.complete': 'Complete', | ||
| 46 | + 'action.create': 'Create', | ||
| 47 | + 'action.save': 'Save', | ||
| 48 | + 'action.back': '← Back', | ||
| 49 | + 'action.logout': 'Logout', | ||
| 50 | + 'action.signIn': 'Sign in', | ||
| 51 | + 'action.signingIn': 'Signing in…', | ||
| 52 | + 'action.creating': 'Creating…', | ||
| 53 | + 'action.addLine': '+ Add line', | ||
| 54 | + 'action.assign': 'Assign', | ||
| 55 | + 'action.revoke': 'Revoke', | ||
| 56 | + 'action.newItem': '+ New Item', | ||
| 57 | + 'action.newPartner': '+ New Partner', | ||
| 58 | + 'action.newLocation': '+ New Location', | ||
| 59 | + 'action.newOrder': '+ New Order', | ||
| 60 | + 'action.newWorkOrder': '+ New Work Order', | ||
| 61 | + 'action.newUser': '+ New User', | ||
| 62 | + 'action.newRole': '+ New Role', | ||
| 63 | + 'action.newAccount': '+ New Account', | ||
| 64 | + 'action.adjustStock': 'Adjust Stock', | ||
| 65 | + | ||
| 66 | + // ─── Status badges ──────────────────────────────────────── | ||
| 67 | + 'status.DRAFT': 'Draft', | ||
| 68 | + 'status.CONFIRMED': 'Confirmed', | ||
| 69 | + 'status.SHIPPED': 'Shipped', | ||
| 70 | + 'status.RECEIVED': 'Received', | ||
| 71 | + 'status.COMPLETED': 'Completed', | ||
| 72 | + 'status.CANCELLED': 'Cancelled', | ||
| 73 | + 'status.IN_PROGRESS': 'In Progress', | ||
| 74 | + 'status.PENDING': 'Pending', | ||
| 75 | + 'status.POSTED': 'Posted', | ||
| 76 | + 'status.SETTLED': 'Settled', | ||
| 77 | + 'status.REVERSED': 'Reversed', | ||
| 78 | + | ||
| 79 | + // ─── Common labels ──────────────────────────────────────── | ||
| 80 | + 'label.code': 'Code', | ||
| 81 | + 'label.name': 'Name', | ||
| 82 | + 'label.type': 'Type', | ||
| 83 | + 'label.status': 'Status', | ||
| 84 | + 'label.active': 'Active', | ||
| 85 | + 'label.email': 'Email', | ||
| 86 | + 'label.phone': 'Phone', | ||
| 87 | + 'label.total': 'Total', | ||
| 88 | + 'label.quantity': 'Quantity', | ||
| 89 | + 'label.unitPrice': 'Unit Price', | ||
| 90 | + 'label.date': 'Date', | ||
| 91 | + 'label.currency': 'Currency', | ||
| 92 | + 'label.description': 'Description', | ||
| 93 | + 'label.username': 'Username', | ||
| 94 | + 'label.password': 'Password', | ||
| 95 | + 'label.signedInAs': 'Signed in as', | ||
| 96 | + 'label.noRows': 'No rows.', | ||
| 97 | + 'label.loading': 'Loading…', | ||
| 98 | + 'label.error': 'Error', | ||
| 99 | +} as const | ||
| 100 | + | ||
| 101 | +export const zhCN: Record<MessageKey, string> = { | ||
| 102 | + // ─── 导航 ────────────────────────────────────────────────── | ||
| 103 | + 'nav.overview': '概览', | ||
| 104 | + 'nav.dashboard': '仪表盘', | ||
| 105 | + 'nav.catalog': '产品与合作伙伴', | ||
| 106 | + 'nav.items': '物料', | ||
| 107 | + 'nav.uoms': '计量单位', | ||
| 108 | + 'nav.partners': '合作伙伴', | ||
| 109 | + 'nav.inventory': '库存', | ||
| 110 | + 'nav.locations': '库位', | ||
| 111 | + 'nav.balances': '库存余额', | ||
| 112 | + 'nav.movements': '库存变动', | ||
| 113 | + 'nav.orders': '订单', | ||
| 114 | + 'nav.salesOrders': '销售订单', | ||
| 115 | + 'nav.purchaseOrders': '采购订单', | ||
| 116 | + 'nav.production': '生产', | ||
| 117 | + 'nav.workOrders': '工单', | ||
| 118 | + 'nav.shopFloor': '车间看板', | ||
| 119 | + 'nav.finance': '财务', | ||
| 120 | + 'nav.accounts': '科目表', | ||
| 121 | + 'nav.journalEntries': '日记账', | ||
| 122 | + 'nav.system': '系统', | ||
| 123 | + 'nav.users': '用户', | ||
| 124 | + 'nav.roles': '角色', | ||
| 125 | + | ||
| 126 | + // ─── 操作 ────────────────────────────────────────────────── | ||
| 127 | + 'action.confirm': '确认', | ||
| 128 | + 'action.ship': '发货', | ||
| 129 | + 'action.receive': '收货', | ||
| 130 | + 'action.cancel': '取消', | ||
| 131 | + 'action.start': '开始', | ||
| 132 | + 'action.complete': '完成', | ||
| 133 | + 'action.create': '创建', | ||
| 134 | + 'action.save': '保存', | ||
| 135 | + 'action.back': '← 返回', | ||
| 136 | + 'action.logout': '退出', | ||
| 137 | + 'action.signIn': '登录', | ||
| 138 | + 'action.signingIn': '登录中…', | ||
| 139 | + 'action.creating': '创建中…', | ||
| 140 | + 'action.addLine': '+ 添加行', | ||
| 141 | + 'action.assign': '分配', | ||
| 142 | + 'action.revoke': '撤销', | ||
| 143 | + 'action.newItem': '+ 新物料', | ||
| 144 | + 'action.newPartner': '+ 新合作伙伴', | ||
| 145 | + 'action.newLocation': '+ 新库位', | ||
| 146 | + 'action.newOrder': '+ 新订单', | ||
| 147 | + 'action.newWorkOrder': '+ 新工单', | ||
| 148 | + 'action.newUser': '+ 新用户', | ||
| 149 | + 'action.newRole': '+ 新角色', | ||
| 150 | + 'action.newAccount': '+ 新科目', | ||
| 151 | + 'action.adjustStock': '库存调整', | ||
| 152 | + | ||
| 153 | + // ─── 状态 ────────────────────────────────────────────────── | ||
| 154 | + 'status.DRAFT': '草稿', | ||
| 155 | + 'status.CONFIRMED': '已确认', | ||
| 156 | + 'status.SHIPPED': '已发货', | ||
| 157 | + 'status.RECEIVED': '已收货', | ||
| 158 | + 'status.COMPLETED': '已完成', | ||
| 159 | + 'status.CANCELLED': '已取消', | ||
| 160 | + 'status.IN_PROGRESS': '进行中', | ||
| 161 | + 'status.PENDING': '待处理', | ||
| 162 | + 'status.POSTED': '已过账', | ||
| 163 | + 'status.SETTLED': '已结算', | ||
| 164 | + 'status.REVERSED': '已冲销', | ||
| 165 | + | ||
| 166 | + // ─── 通用标签 ────────────────────────────────────────────── | ||
| 167 | + 'label.code': '编码', | ||
| 168 | + 'label.name': '名称', | ||
| 169 | + 'label.type': '类型', | ||
| 170 | + 'label.status': '状态', | ||
| 171 | + 'label.active': '启用', | ||
| 172 | + 'label.email': '邮箱', | ||
| 173 | + 'label.phone': '电话', | ||
| 174 | + 'label.total': '合计', | ||
| 175 | + 'label.quantity': '数量', | ||
| 176 | + 'label.unitPrice': '单价', | ||
| 177 | + 'label.date': '日期', | ||
| 178 | + 'label.currency': '币种', | ||
| 179 | + 'label.description': '描述', | ||
| 180 | + 'label.username': '用户名', | ||
| 181 | + 'label.password': '密码', | ||
| 182 | + 'label.signedInAs': '当前用户', | ||
| 183 | + 'label.noRows': '暂无数据', | ||
| 184 | + 'label.loading': '加载中…', | ||
| 185 | + 'label.error': '错误', | ||
| 186 | +} | ||
| 187 | + | ||
| 188 | +export const locales = { | ||
| 189 | + 'en-US': en, | ||
| 190 | + 'zh-CN': zhCN, | ||
| 191 | +} as const | ||
| 192 | + | ||
| 193 | +export type LocaleCode = keyof typeof locales |
web/src/layout/AppLayout.tsx
| 1 | -// vibe_erp main app shell. | ||
| 2 | -// | ||
| 3 | -// Top bar (logo + version + user + logout) and a permanent left | ||
| 4 | -// sidebar grouped by Packaged Business Capability (PBC). The | ||
| 5 | -// `<Outlet />` renders whichever child route the router matched. | ||
| 6 | -// | ||
| 7 | -// **Why a static nav array.** The framework already exposes | ||
| 8 | -// menu metadata at /api/v1/_meta/metadata, but plumbing that into | ||
| 9 | -// the SPA is a Phase 3 (Tier 1 customization) concern. v1 SPA | ||
| 10 | -// hard-codes a sensible default that mirrors the implemented PBCs; | ||
| 11 | -// a future chunk replaces this with a metadata-driven sidebar so | ||
| 12 | -// plug-ins can contribute new menus through their YAML. | 1 | +// vibe_erp main app shell with i18n support. |
| 13 | 2 | ||
| 14 | import { NavLink, Outlet } from 'react-router-dom' | 3 | import { NavLink, Outlet } from 'react-router-dom' |
| 15 | import { useEffect, useState } from 'react' | 4 | import { useEffect, useState } from 'react' |
| 16 | import { useAuth } from '@/auth/AuthContext' | 5 | import { useAuth } from '@/auth/AuthContext' |
| 6 | +import { useLocale, useT, AVAILABLE_LOCALES } from '@/i18n/LocaleContext' | ||
| 7 | +import type { MessageKey } from '@/i18n/messages' | ||
| 17 | import { meta } from '@/api/client' | 8 | import { meta } from '@/api/client' |
| 18 | import type { MetaInfo } from '@/types/api' | 9 | import type { MetaInfo } from '@/types/api' |
| 19 | 10 | ||
| 20 | interface NavItem { | 11 | interface NavItem { |
| 21 | to: string | 12 | to: string |
| 22 | - label: string | 13 | + labelKey: MessageKey |
| 23 | } | 14 | } |
| 24 | interface NavGroup { | 15 | interface NavGroup { |
| 25 | - heading: string | 16 | + headingKey: MessageKey |
| 26 | items: NavItem[] | 17 | items: NavItem[] |
| 27 | } | 18 | } |
| 28 | 19 | ||
| 29 | const NAV: NavGroup[] = [ | 20 | const NAV: NavGroup[] = [ |
| 30 | { | 21 | { |
| 31 | - heading: 'Overview', | ||
| 32 | - items: [{ to: '/', label: 'Dashboard' }], | 22 | + headingKey: 'nav.overview', |
| 23 | + items: [{ to: '/', labelKey: 'nav.dashboard' }], | ||
| 33 | }, | 24 | }, |
| 34 | { | 25 | { |
| 35 | - heading: 'Catalog & Partners', | 26 | + headingKey: 'nav.catalog', |
| 36 | items: [ | 27 | items: [ |
| 37 | - { to: '/items', label: 'Items' }, | ||
| 38 | - { to: '/uoms', label: 'Units of Measure' }, | ||
| 39 | - { to: '/partners', label: 'Partners' }, | 28 | + { to: '/items', labelKey: 'nav.items' }, |
| 29 | + { to: '/uoms', labelKey: 'nav.uoms' }, | ||
| 30 | + { to: '/partners', labelKey: 'nav.partners' }, | ||
| 40 | ], | 31 | ], |
| 41 | }, | 32 | }, |
| 42 | { | 33 | { |
| 43 | - heading: 'Inventory', | 34 | + headingKey: 'nav.inventory', |
| 44 | items: [ | 35 | items: [ |
| 45 | - { to: '/locations', label: 'Locations' }, | ||
| 46 | - { to: '/balances', label: 'Stock Balances' }, | ||
| 47 | - { to: '/movements', label: 'Stock Movements' }, | 36 | + { to: '/locations', labelKey: 'nav.locations' }, |
| 37 | + { to: '/balances', labelKey: 'nav.balances' }, | ||
| 38 | + { to: '/movements', labelKey: 'nav.movements' }, | ||
| 48 | ], | 39 | ], |
| 49 | }, | 40 | }, |
| 50 | { | 41 | { |
| 51 | - heading: 'Orders', | 42 | + headingKey: 'nav.orders', |
| 52 | items: [ | 43 | items: [ |
| 53 | - { to: '/sales-orders', label: 'Sales Orders' }, | ||
| 54 | - { to: '/purchase-orders', label: 'Purchase Orders' }, | 44 | + { to: '/sales-orders', labelKey: 'nav.salesOrders' }, |
| 45 | + { to: '/purchase-orders', labelKey: 'nav.purchaseOrders' }, | ||
| 55 | ], | 46 | ], |
| 56 | }, | 47 | }, |
| 57 | { | 48 | { |
| 58 | - heading: 'Production', | 49 | + headingKey: 'nav.production', |
| 59 | items: [ | 50 | items: [ |
| 60 | - { to: '/work-orders', label: 'Work Orders' }, | ||
| 61 | - { to: '/shop-floor', label: 'Shop Floor' }, | 51 | + { to: '/work-orders', labelKey: 'nav.workOrders' }, |
| 52 | + { to: '/shop-floor', labelKey: 'nav.shopFloor' }, | ||
| 62 | ], | 53 | ], |
| 63 | }, | 54 | }, |
| 64 | { | 55 | { |
| 65 | - heading: 'Finance', | 56 | + headingKey: 'nav.finance', |
| 66 | items: [ | 57 | items: [ |
| 67 | - { to: '/accounts', label: 'Chart of Accounts' }, | ||
| 68 | - { to: '/journal-entries', label: 'Journal Entries' }, | 58 | + { to: '/accounts', labelKey: 'nav.accounts' }, |
| 59 | + { to: '/journal-entries', labelKey: 'nav.journalEntries' }, | ||
| 69 | ], | 60 | ], |
| 70 | }, | 61 | }, |
| 71 | { | 62 | { |
| 72 | - heading: 'System', | 63 | + headingKey: 'nav.system', |
| 73 | items: [ | 64 | items: [ |
| 74 | - { to: '/users', label: 'Users' }, | ||
| 75 | - { to: '/roles', label: 'Roles' }, | 65 | + { to: '/users', labelKey: 'nav.users' }, |
| 66 | + { to: '/roles', labelKey: 'nav.roles' }, | ||
| 76 | ], | 67 | ], |
| 77 | }, | 68 | }, |
| 78 | ] | 69 | ] |
| 79 | 70 | ||
| 80 | export function AppLayout() { | 71 | export function AppLayout() { |
| 81 | const { username, logout } = useAuth() | 72 | const { username, logout } = useAuth() |
| 73 | + const { locale, setLocale } = useLocale() | ||
| 74 | + const t = useT() | ||
| 82 | const [info, setInfo] = useState<MetaInfo | null>(null) | 75 | const [info, setInfo] = useState<MetaInfo | null>(null) |
| 83 | 76 | ||
| 84 | useEffect(() => { | 77 | useEffect(() => { |
| @@ -99,9 +92,9 @@ export function AppLayout() { | @@ -99,9 +92,9 @@ export function AppLayout() { | ||
| 99 | </div> | 92 | </div> |
| 100 | <nav className="flex-1 overflow-y-auto px-3 py-4"> | 93 | <nav className="flex-1 overflow-y-auto px-3 py-4"> |
| 101 | {NAV.map((group) => ( | 94 | {NAV.map((group) => ( |
| 102 | - <div key={group.heading} className="mb-5"> | 95 | + <div key={group.headingKey} className="mb-5"> |
| 103 | <div className="px-2 text-xs font-semibold uppercase tracking-wider text-slate-400"> | 96 | <div className="px-2 text-xs font-semibold uppercase tracking-wider text-slate-400"> |
| 104 | - {group.heading} | 97 | + {t(group.headingKey)} |
| 105 | </div> | 98 | </div> |
| 106 | <ul className="mt-1 space-y-0.5"> | 99 | <ul className="mt-1 space-y-0.5"> |
| 107 | {group.items.map((item) => ( | 100 | {group.items.map((item) => ( |
| @@ -118,7 +111,7 @@ export function AppLayout() { | @@ -118,7 +111,7 @@ export function AppLayout() { | ||
| 118 | ].join(' ') | 111 | ].join(' ') |
| 119 | } | 112 | } |
| 120 | > | 113 | > |
| 121 | - {item.label} | 114 | + {t(item.labelKey)} |
| 122 | </NavLink> | 115 | </NavLink> |
| 123 | </li> | 116 | </li> |
| 124 | ))} | 117 | ))} |
| @@ -141,17 +134,25 @@ export function AppLayout() { | @@ -141,17 +134,25 @@ export function AppLayout() { | ||
| 141 | <header className="flex h-14 items-center justify-between border-b border-slate-200 bg-white px-6 sm:justify-end"> | 134 | <header className="flex h-14 items-center justify-between border-b border-slate-200 bg-white px-6 sm:justify-end"> |
| 142 | <span className="text-lg font-bold text-brand-600 sm:hidden">vibe_erp</span> | 135 | <span className="text-lg font-bold text-brand-600 sm:hidden">vibe_erp</span> |
| 143 | <div className="flex items-center gap-3"> | 136 | <div className="flex items-center gap-3"> |
| 137 | + <select | ||
| 138 | + value={locale} | ||
| 139 | + onChange={(e) => setLocale(e.target.value as typeof locale)} | ||
| 140 | + className="rounded-md border border-slate-200 bg-slate-50 px-2 py-1 text-xs text-slate-600" | ||
| 141 | + > | ||
| 142 | + {AVAILABLE_LOCALES.map((l) => ( | ||
| 143 | + <option key={l.code} value={l.code}>{l.label}</option> | ||
| 144 | + ))} | ||
| 145 | + </select> | ||
| 144 | <span className="text-sm text-slate-500"> | 146 | <span className="text-sm text-slate-500"> |
| 145 | {username ? ( | 147 | {username ? ( |
| 146 | <> | 148 | <> |
| 147 | - Signed in as <span className="font-medium text-slate-700">{username}</span> | 149 | + {t('label.signedInAs')}{' '} |
| 150 | + <span className="font-medium text-slate-700">{username}</span> | ||
| 148 | </> | 151 | </> |
| 149 | - ) : ( | ||
| 150 | - 'Signed in' | ||
| 151 | - )} | 152 | + ) : null} |
| 152 | </span> | 153 | </span> |
| 153 | <button className="btn-secondary" onClick={logout}> | 154 | <button className="btn-secondary" onClick={logout}> |
| 154 | - Logout | 155 | + {t('action.logout')} |
| 155 | </button> | 156 | </button> |
| 156 | </div> | 157 | </div> |
| 157 | </header> | 158 | </header> |
web/src/main.tsx
| @@ -3,14 +3,17 @@ import ReactDOM from 'react-dom/client' | @@ -3,14 +3,17 @@ import ReactDOM from 'react-dom/client' | ||
| 3 | import { BrowserRouter } from 'react-router-dom' | 3 | import { BrowserRouter } from 'react-router-dom' |
| 4 | import App from './App' | 4 | import App from './App' |
| 5 | import { AuthProvider } from './auth/AuthContext' | 5 | import { AuthProvider } from './auth/AuthContext' |
| 6 | +import { LocaleProvider } from './i18n/LocaleContext' | ||
| 6 | import './index.css' | 7 | import './index.css' |
| 7 | 8 | ||
| 8 | ReactDOM.createRoot(document.getElementById('root')!).render( | 9 | ReactDOM.createRoot(document.getElementById('root')!).render( |
| 9 | <React.StrictMode> | 10 | <React.StrictMode> |
| 10 | <BrowserRouter> | 11 | <BrowserRouter> |
| 11 | - <AuthProvider> | ||
| 12 | - <App /> | ||
| 13 | - </AuthProvider> | 12 | + <LocaleProvider> |
| 13 | + <AuthProvider> | ||
| 14 | + <App /> | ||
| 15 | + </AuthProvider> | ||
| 16 | + </LocaleProvider> | ||
| 14 | </BrowserRouter> | 17 | </BrowserRouter> |
| 15 | </React.StrictMode>, | 18 | </React.StrictMode>, |
| 16 | ) | 19 | ) |