feat: i18n — EN/MS/ZH translations with cookie-based locale switching, report form translated
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FFDuBhMKvoWjrWT3ZnGmmr
This commit is contained in:
+34
-17
@@ -1,33 +1,50 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import type { Metadata } from 'next'
|
||||
import { Geist, Geist_Mono } from 'next/font/google'
|
||||
import './globals.css'
|
||||
import { I18nProvider } from '@/lib/i18n/context'
|
||||
import { getLocale, loadMessages } from '@/lib/i18n/server'
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
variable: '--font-geist-sans',
|
||||
subsets: ['latin'],
|
||||
})
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
variable: '--font-geist-mono',
|
||||
subsets: ['latin'],
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "IMS — HSE Incident Management",
|
||||
description: "Setia Corporation HSE Incident Management System",
|
||||
};
|
||||
title: 'IMS — HSE Incident Management',
|
||||
description: 'Setia Corporation HSE Incident Management System',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
const locale = await getLocale()
|
||||
const messages = await loadMessages(locale)
|
||||
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
lang={locale}
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<head>
|
||||
<link rel="manifest" href="/ims/manifest.json" />
|
||||
</head>
|
||||
<body className="min-h-full flex flex-col">
|
||||
<I18nProvider messages={messages}>
|
||||
{children}
|
||||
</I18nProvider>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `if('serviceWorker'in navigator){navigator.serviceWorker.register('/ims/sw.js',{scope:'/ims/'}).catch(console.error)}`,
|
||||
}}
|
||||
/>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
+5
-1
@@ -3,6 +3,7 @@ export const dynamic = 'force-dynamic'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { ReportForm } from '@/components/incidents/report-form'
|
||||
import { LanguageSwitcher } from '@/components/language-switcher'
|
||||
|
||||
interface Props {
|
||||
searchParams: Promise<{ zone?: string }>
|
||||
@@ -33,7 +34,10 @@ export default async function ReportPage({ searchParams }: Props) {
|
||||
return (
|
||||
<main className="min-h-screen bg-gray-50 py-6 px-4 max-w-lg mx-auto">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Report an Incident</h1>
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">{/* will be translated via ReportForm */}Report an Incident</h1>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
{zoneName ? (
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{siteName ?? 'Unknown Site'} — {zoneName}
|
||||
|
||||
@@ -1,26 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { FileUpload } from '@/components/incidents/file-upload'
|
||||
import type { IncidentType, MedicalStatus } from '@/lib/incidents/validate'
|
||||
|
||||
const INCIDENT_TYPE_LABELS: Record<IncidentType, string> = {
|
||||
injury: 'Injury / Medical',
|
||||
near_miss: 'Near Miss',
|
||||
hazard: 'Hazard / Unsafe Condition',
|
||||
asset_damage: 'Asset / Equipment Damage',
|
||||
environmental: 'Environmental Incident',
|
||||
security: 'Security Incident',
|
||||
fire: 'Fire / Emergency',
|
||||
}
|
||||
|
||||
const MEDICAL_STATUS_LABELS: Record<MedicalStatus, string> = {
|
||||
none: 'No treatment needed',
|
||||
first_aid: 'First aid only',
|
||||
medical_treatment: 'Medical treatment (non-LTI)',
|
||||
lti: 'Lost Time Injury (LTI)',
|
||||
}
|
||||
import { useTranslations } from '@/lib/i18n/context'
|
||||
|
||||
interface Props {
|
||||
zoneToken: string | null
|
||||
@@ -30,9 +14,32 @@ interface Props {
|
||||
|
||||
export function ReportForm({ zoneToken }: Props) {
|
||||
const router = useRouter()
|
||||
const t = useTranslations('ReportForm')
|
||||
const itLabels = useTranslations('IncidentType')
|
||||
const msLabels = useTranslations('MedicalStatus')
|
||||
|
||||
const INCIDENT_TYPE_OPTIONS: [IncidentType, string][] = [
|
||||
['injury', itLabels.injury],
|
||||
['near_miss', itLabels.near_miss],
|
||||
['hazard', itLabels.hazard],
|
||||
['asset_damage', itLabels.asset_damage],
|
||||
['environmental', itLabels.environmental],
|
||||
['security', itLabels.security],
|
||||
['fire', itLabels.fire],
|
||||
]
|
||||
|
||||
const MEDICAL_STATUS_OPTIONS: [MedicalStatus, string][] = [
|
||||
['none', msLabels.none],
|
||||
['first_aid', msLabels.first_aid],
|
||||
['medical_treatment', msLabels.medical_treatment],
|
||||
['lti', msLabels.lti],
|
||||
]
|
||||
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [isOnline, setIsOnline] = useState(true)
|
||||
const [savedOffline, setSavedOffline] = useState(false)
|
||||
const [qualityCheck, setQualityCheck] = useState<{
|
||||
score: number
|
||||
passes: boolean
|
||||
@@ -49,11 +56,46 @@ export function ReportForm({ zoneToken }: Props) {
|
||||
asset_involved: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setIsOnline(navigator.onLine)
|
||||
const onOnline = () => setIsOnline(true)
|
||||
const onOffline = () => setIsOnline(false)
|
||||
window.addEventListener('online', onOnline)
|
||||
window.addEventListener('offline', onOffline)
|
||||
return () => {
|
||||
window.removeEventListener('online', onOnline)
|
||||
window.removeEventListener('offline', onOffline)
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setSubmitting(true)
|
||||
|
||||
// Offline path — save to IndexedDB
|
||||
if (!isOnline) {
|
||||
try {
|
||||
const { addPendingReport } = await import('@/lib/offline/db')
|
||||
await addPendingReport({
|
||||
zone_token: zoneToken ?? '',
|
||||
incident_type: form.incident_type as IncidentType,
|
||||
description: form.description,
|
||||
injury_involved: form.injury_involved,
|
||||
asset_involved: form.asset_involved,
|
||||
medical_status: form.medical_status || undefined,
|
||||
created_at: new Date().toISOString(),
|
||||
})
|
||||
setSavedOffline(true)
|
||||
} catch (err) {
|
||||
console.error('Offline save error:', err)
|
||||
setError(t.errorGeneric)
|
||||
}
|
||||
setSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (!overrideQuality) {
|
||||
try {
|
||||
const qcRes = await fetch('/api/incidents/ai/quality-check', {
|
||||
@@ -78,7 +120,7 @@ export function ReportForm({ zoneToken }: Props) {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Quality check failure is non-blocking — proceed with submission
|
||||
// Quality check failure is non-blocking
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,14 +146,28 @@ export function ReportForm({ zoneToken }: Props) {
|
||||
|
||||
router.push(`/report/success?ref=${data.reference_no}`)
|
||||
} catch {
|
||||
setError('Something went wrong. Please try again.')
|
||||
setError(t.errorGeneric)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (savedOffline) {
|
||||
return (
|
||||
<div className="bg-green-50 border border-green-200 rounded-xl p-5 text-sm text-green-700">
|
||||
{t.savedOffline}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5 bg-white rounded-xl shadow-sm p-5">
|
||||
{!isOnline && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded p-3 text-sm text-yellow-700">
|
||||
{t.offlineBanner}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded p-3 text-sm text-red-700">
|
||||
{error}
|
||||
@@ -120,7 +176,7 @@ export function ReportForm({ zoneToken }: Props) {
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Incident type <span className="text-red-500">*</span>
|
||||
{t.incidentTypeLabel} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
required
|
||||
@@ -128,8 +184,8 @@ export function ReportForm({ zoneToken }: Props) {
|
||||
value={form.incident_type}
|
||||
onChange={e => setForm(f => ({ ...f, incident_type: e.target.value as IncidentType }))}
|
||||
>
|
||||
<option value="">Select type…</option>
|
||||
{Object.entries(INCIDENT_TYPE_LABELS).map(([v, l]) => (
|
||||
<option value="">{t.incidentTypePlaceholder}</option>
|
||||
{INCIDENT_TYPE_OPTIONS.map(([v, l]) => (
|
||||
<option key={v} value={v}>{l}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -137,13 +193,13 @@ export function ReportForm({ zoneToken }: Props) {
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
What happened? <span className="text-red-500">*</span>
|
||||
{t.descriptionLabel} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
required
|
||||
minLength={10}
|
||||
rows={4}
|
||||
placeholder="Describe what happened, where, and any immediate actions taken…"
|
||||
placeholder={t.descriptionPlaceholder}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||
value={form.description}
|
||||
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
|
||||
@@ -153,7 +209,7 @@ export function ReportForm({ zoneToken }: Props) {
|
||||
{qualityCheck && !qualityCheck.passes && (
|
||||
<div className="rounded-lg bg-amber-50 border border-amber-200 p-3 space-y-2">
|
||||
<p className="text-xs font-semibold text-amber-700 uppercase tracking-wide">
|
||||
Report quality — {qualityCheck.score}/10
|
||||
{t.qualityScoreLabel.replace('{score}', String(qualityCheck.score))}
|
||||
</p>
|
||||
<p className="text-sm text-amber-800">{qualityCheck.feedback}</p>
|
||||
{qualityCheck.suggestions.length > 0 && (
|
||||
@@ -170,7 +226,7 @@ export function ReportForm({ zoneToken }: Props) {
|
||||
onChange={e => setOverrideQuality(e.target.checked)}
|
||||
className="rounded border-amber-300 text-amber-600"
|
||||
/>
|
||||
Submit anyway
|
||||
{t.submitAnyway}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
@@ -183,13 +239,13 @@ export function ReportForm({ zoneToken }: Props) {
|
||||
checked={form.injury_involved}
|
||||
onChange={e => setForm(f => ({ ...f, injury_involved: e.target.checked, medical_status: '' }))}
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Person was injured</span>
|
||||
<span className="text-sm font-medium text-gray-700">{t.injuryInvolved}</span>
|
||||
</label>
|
||||
|
||||
{form.injury_involved && (
|
||||
<div className="ml-7">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Treatment level <span className="text-red-500">*</span>
|
||||
{t.treatmentLevel} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
required={form.injury_involved}
|
||||
@@ -197,8 +253,8 @@ export function ReportForm({ zoneToken }: Props) {
|
||||
value={form.medical_status}
|
||||
onChange={e => setForm(f => ({ ...f, medical_status: e.target.value as MedicalStatus }))}
|
||||
>
|
||||
<option value="">Select treatment…</option>
|
||||
{Object.entries(MEDICAL_STATUS_LABELS).map(([v, l]) => (
|
||||
<option value="">{t.treatmentPlaceholder}</option>
|
||||
{MEDICAL_STATUS_OPTIONS.map(([v, l]) => (
|
||||
<option key={v} value={v}>{l}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -212,13 +268,13 @@ export function ReportForm({ zoneToken }: Props) {
|
||||
checked={form.asset_involved}
|
||||
onChange={e => setForm(f => ({ ...f, asset_involved: e.target.checked }))}
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Equipment / asset was damaged</span>
|
||||
<span className="text-sm font-medium text-gray-700">{t.assetInvolved}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Photos / Videos / Documents
|
||||
{t.filesLabel}
|
||||
</label>
|
||||
<FileUpload onFilesChange={setFiles} disabled={submitting} />
|
||||
</div>
|
||||
@@ -229,7 +285,7 @@ export function ReportForm({ zoneToken }: Props) {
|
||||
className="w-full bg-blue-600 text-white py-3 rounded-lg font-medium text-sm
|
||||
hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{submitting ? 'Submitting…' : 'Submit Incident Report'}
|
||||
{submitting ? t.submitting : t.submitButton}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const LOCALES: Record<string, string> = {
|
||||
en: 'English',
|
||||
ms: 'Bahasa Malaysia',
|
||||
zh: '中文',
|
||||
}
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const router = useRouter()
|
||||
const [current, setCurrent] = useState('en')
|
||||
|
||||
useEffect(() => {
|
||||
const match = document.cookie
|
||||
.split('; ')
|
||||
.find(c => c.startsWith('locale='))
|
||||
?.split('=')[1]
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (match && match in LOCALES) setCurrent(match)
|
||||
}, [])
|
||||
|
||||
function handleChange(locale: string) {
|
||||
document.cookie = `locale=${locale}; path=/; max-age=31536000; SameSite=Lax`
|
||||
setCurrent(locale)
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
return (
|
||||
<select
|
||||
value={current}
|
||||
onChange={e => handleChange(e.target.value)}
|
||||
aria-label="Select language"
|
||||
className="text-xs border border-gray-300 rounded px-2 py-1 bg-white text-gray-700 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
{Object.entries(LOCALES).map(([code, label]) => (
|
||||
<option key={code} value={code}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext } from 'react'
|
||||
import type en from '../../messages/en.json'
|
||||
|
||||
export type Messages = typeof en
|
||||
|
||||
const I18nContext = createContext<Messages | null>(null)
|
||||
|
||||
export function I18nProvider({
|
||||
messages,
|
||||
children,
|
||||
}: {
|
||||
messages: Messages
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return <I18nContext.Provider value={messages}>{children}</I18nContext.Provider>
|
||||
}
|
||||
|
||||
export function useTranslations<K extends keyof Messages>(namespace: K): Messages[K] {
|
||||
const ctx = useContext(I18nContext)
|
||||
if (!ctx) throw new Error('useTranslations must be used inside I18nProvider')
|
||||
return ctx[namespace]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const SUPPORTED_LOCALES = ['en', 'ms', 'zh'] as const
|
||||
export type Locale = typeof SUPPORTED_LOCALES[number]
|
||||
@@ -0,0 +1,20 @@
|
||||
import { cookies } from 'next/headers'
|
||||
import { SUPPORTED_LOCALES, type Locale } from './locales'
|
||||
import type { Messages } from './context'
|
||||
|
||||
export async function getLocale(): Promise<Locale> {
|
||||
const cookieStore = await cookies()
|
||||
const lang = cookieStore.get('locale')?.value
|
||||
if (lang && (SUPPORTED_LOCALES as readonly string[]).includes(lang)) {
|
||||
return lang as Locale
|
||||
}
|
||||
return 'en'
|
||||
}
|
||||
|
||||
export async function loadMessages(locale: Locale): Promise<Messages> {
|
||||
switch (locale) {
|
||||
case 'ms': return (await import('../../messages/ms.json')).default as Messages
|
||||
case 'zh': return (await import('../../messages/zh.json')).default as Messages
|
||||
default: return (await import('../../messages/en.json')).default as Messages
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Stub — full implementation in Task 5 (IndexedDB offline queue)
|
||||
// This file exists so TypeScript can resolve the dynamic import in report-form.tsx.
|
||||
|
||||
export interface PendingReport {
|
||||
zone_token: string
|
||||
incident_type: string
|
||||
description: string
|
||||
injury_involved: boolean
|
||||
asset_involved: boolean
|
||||
medical_status?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export async function addPendingReport(_report: PendingReport): Promise<void> {
|
||||
throw new Error('IndexedDB offline store not yet implemented — Task 5 will replace this stub')
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"ReportForm": {
|
||||
"title": "Report an Incident",
|
||||
"incidentTypeLabel": "Incident type",
|
||||
"incidentTypePlaceholder": "Select type…",
|
||||
"descriptionLabel": "What happened?",
|
||||
"descriptionPlaceholder": "Describe what happened, where, and any immediate actions taken…",
|
||||
"injuryInvolved": "Person was injured",
|
||||
"treatmentLevel": "Treatment level",
|
||||
"treatmentPlaceholder": "Select treatment…",
|
||||
"assetInvolved": "Equipment / asset was damaged",
|
||||
"filesLabel": "Photos / Videos / Documents",
|
||||
"submitButton": "Submit Incident Report",
|
||||
"submitting": "Submitting…",
|
||||
"submitAnyway": "Submit anyway",
|
||||
"qualityScoreLabel": "Report quality — {score}/10",
|
||||
"errorGeneric": "Something went wrong. Please try again.",
|
||||
"savedOffline": "Report saved. It will be submitted automatically when you're back online.",
|
||||
"offlineBanner": "You're offline. Your report will be saved and submitted when you reconnect."
|
||||
},
|
||||
"IncidentType": {
|
||||
"injury": "Injury / Medical",
|
||||
"near_miss": "Near Miss",
|
||||
"hazard": "Hazard / Unsafe Condition",
|
||||
"asset_damage": "Asset / Equipment Damage",
|
||||
"environmental": "Environmental Incident",
|
||||
"security": "Security Incident",
|
||||
"fire": "Fire / Emergency"
|
||||
},
|
||||
"MedicalStatus": {
|
||||
"none": "No treatment needed",
|
||||
"first_aid": "First aid only",
|
||||
"medical_treatment": "Medical treatment (non-LTI)",
|
||||
"lti": "Lost Time Injury (LTI)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"ReportForm": {
|
||||
"title": "Laporkan Insiden",
|
||||
"incidentTypeLabel": "Jenis insiden",
|
||||
"incidentTypePlaceholder": "Pilih jenis…",
|
||||
"descriptionLabel": "Apa yang berlaku?",
|
||||
"descriptionPlaceholder": "Terangkan apa yang berlaku, di mana, dan tindakan segera yang diambil…",
|
||||
"injuryInvolved": "Seseorang telah cedera",
|
||||
"treatmentLevel": "Tahap rawatan",
|
||||
"treatmentPlaceholder": "Pilih rawatan…",
|
||||
"assetInvolved": "Peralatan / aset rosak",
|
||||
"filesLabel": "Foto / Video / Dokumen",
|
||||
"submitButton": "Hantar Laporan Insiden",
|
||||
"submitting": "Menghantar…",
|
||||
"submitAnyway": "Hantar juga",
|
||||
"qualityScoreLabel": "Kualiti laporan — {score}/10",
|
||||
"errorGeneric": "Berlaku ralat. Sila cuba lagi.",
|
||||
"savedOffline": "Laporan disimpan. Ia akan dihantar secara automatik apabila anda dalam talian semula.",
|
||||
"offlineBanner": "Anda tiada sambungan. Laporan anda akan disimpan dan dihantar apabila disambungkan semula."
|
||||
},
|
||||
"IncidentType": {
|
||||
"injury": "Kecederaan / Perubatan",
|
||||
"near_miss": "Hampir Berlaku",
|
||||
"hazard": "Bahaya / Keadaan Tidak Selamat",
|
||||
"asset_damage": "Kerosakan Aset / Peralatan",
|
||||
"environmental": "Insiden Alam Sekitar",
|
||||
"security": "Insiden Keselamatan",
|
||||
"fire": "Kebakaran / Kecemasan"
|
||||
},
|
||||
"MedicalStatus": {
|
||||
"none": "Tiada rawatan diperlukan",
|
||||
"first_aid": "Pertolongan cemas sahaja",
|
||||
"medical_treatment": "Rawatan perubatan (bukan LTI)",
|
||||
"lti": "Kecederaan Masa Hilang (LTI)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"ReportForm": {
|
||||
"title": "事故报告",
|
||||
"incidentTypeLabel": "事故类型",
|
||||
"incidentTypePlaceholder": "选择类型…",
|
||||
"descriptionLabel": "发生了什么?",
|
||||
"descriptionPlaceholder": "描述发生了什么、在哪里,以及采取的即时行动…",
|
||||
"injuryInvolved": "有人受伤",
|
||||
"treatmentLevel": "治疗级别",
|
||||
"treatmentPlaceholder": "选择治疗方式…",
|
||||
"assetInvolved": "设备/资产受损",
|
||||
"filesLabel": "照片/视频/文件",
|
||||
"submitButton": "提交事故报告",
|
||||
"submitting": "提交中…",
|
||||
"submitAnyway": "仍然提交",
|
||||
"qualityScoreLabel": "报告质量 — {score}/10",
|
||||
"errorGeneric": "出现错误,请重试。",
|
||||
"savedOffline": "报告已保存。当您重新联网时将自动提交。",
|
||||
"offlineBanner": "您处于离线状态。您的报告将在重新联网时自动提交。"
|
||||
},
|
||||
"IncidentType": {
|
||||
"injury": "受伤/医疗",
|
||||
"near_miss": "未遂事故",
|
||||
"hazard": "危险/不安全状况",
|
||||
"asset_damage": "资产/设备损坏",
|
||||
"environmental": "环境事故",
|
||||
"security": "安全事故",
|
||||
"fire": "火灾/紧急情况"
|
||||
},
|
||||
"MedicalStatus": {
|
||||
"none": "无需治疗",
|
||||
"first_aid": "仅急救",
|
||||
"medical_treatment": "医疗治疗(非 LTI)",
|
||||
"lti": "工伤失时(LTI)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import en from '@/messages/en.json'
|
||||
import ms from '@/messages/ms.json'
|
||||
import zh from '@/messages/zh.json'
|
||||
|
||||
const NAMESPACES = ['ReportForm', 'IncidentType', 'MedicalStatus'] as const
|
||||
|
||||
NAMESPACES.forEach(ns => {
|
||||
describe(`${ns} namespace`, () => {
|
||||
const enKeys = Object.keys(en[ns])
|
||||
|
||||
it(`ms.${ns} has all keys present in en.${ns}`, () => {
|
||||
const msKeys = Object.keys(ms[ns])
|
||||
enKeys.forEach(key => expect(msKeys, `missing key: ${key}`).toContain(key))
|
||||
})
|
||||
|
||||
it(`zh.${ns} has all keys present in en.${ns}`, () => {
|
||||
const zhKeys = Object.keys(zh[ns])
|
||||
enKeys.forEach(key => expect(zhKeys, `missing key: ${key}`).toContain(key))
|
||||
})
|
||||
|
||||
it(`en.${ns} values are non-empty strings`, () => {
|
||||
enKeys.forEach(key => {
|
||||
const val = (en[ns] as Record<string, string>)[key]
|
||||
expect(typeof val).toBe('string')
|
||||
expect(val.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user