feat(i18n): add internationalization support with English and Spanish translations
feat(kiosk): implement kiosk report submission functionality with offline support test(outbox): add tests for outbox functionality including report submission and error handling feat(outbox): create outbox management for queued reports with IndexedDB persistence feat(uuid): add UUID generation utility for client IDs feat(qr): implement QR code generation for reporting links by site and area feat(kiosk): create Kiosk page for reporting incidents without an account
This commit is contained in:
1 parent
3f6dff5f24
commit
3e964d1176
64 files changed
+10056
-267
No files matched your search
@@ -1,7 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { Navigate, Outlet, useLocation } from 'react-router-dom'
|
||||
import { useActions, useMe, useSetupStatus } from '../lib/queries'
|
||||
import type { QueuedItem } from '../lib/outbox'
|
||||
import { useOutboxSync } from '../lib/use-outbox'
|
||||
import { Header } from './Header'
|
||||
import { OutboxBanner } from './OutboxBanner'
|
||||
import { ErrorMessage, Loading } from './QueryState'
|
||||
import { Sidebar } from './Sidebar'
|
||||
|
||||
@@ -12,6 +15,9 @@ export function AppLayout() {
|
||||
const location = useLocation()
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const myOpenActions = useActions('mine', 'open', !!me.data)
|
||||
const userId = me.data?.user.id
|
||||
const belongs = userId ? (item: QueuedItem) => item.kind === 'report' && item.userId === userId : null
|
||||
useOutboxSync(belongs)
|
||||
|
||||
if (me.isPending) return <Loading />
|
||||
if (me.isError) return <ErrorMessage error={me.error} className="m-6" />
|
||||
@@ -28,6 +34,7 @@ export function AppLayout() {
|
||||
<Sidebar me={me.data} open={menuOpen} onClose={() => setMenuOpen(false)} badges={{ '/actions': open.length }} />
|
||||
<main className="min-w-0 flex-1">
|
||||
<Header me={me.data} onOpenMenu={() => setMenuOpen(true)} overdueActions={overdue} />
|
||||
<OutboxBanner belongs={belongs!} />
|
||||
<Outlet context={me.data} />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ShieldCheck } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { LanguageToggle } from './LanguageToggle'
|
||||
import { panel } from './styles'
|
||||
|
||||
/** Centered card for sign-in, setup and email-link pages. */
|
||||
@@ -18,6 +19,9 @@ export function AuthLayout({ title, copy, children }: { title: string; copy?: st
|
||||
{copy && <p className="mt-1.5 text-xs leading-[1.6] text-muted">{copy}</p>}
|
||||
<div className="mt-6">{children}</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-center">
|
||||
<LanguageToggle className="text-muted hover:text-ink" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -17,4 +17,4 @@ export function SeverityLabel({ severity }: { severity: Severity }) {
|
||||
)
|
||||
}
|
||||
|
||||
export const OverdueBadge = () => <span className={`${chip} bg-coral-soft font-semibold text-coral-strong`}>Overdue</span>
|
||||
export const OverdueBadge = ({ label = 'Overdue' }: { label?: string }) => <span className={`${chip} bg-coral-soft font-semibold text-coral-strong`}>{label}</span>
|
||||
@@ -22,11 +22,19 @@ export function FormSection({ number, title, copy, children }: FormSectionProps)
|
||||
)
|
||||
}
|
||||
|
||||
export function Field({ label, optional = false, children }: { label: string; optional?: boolean; children: ReactNode }) {
|
||||
interface FieldProps {
|
||||
label: string
|
||||
optional?: boolean
|
||||
/** Translated "Optional" marker. */
|
||||
optionalLabel?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function Field({ label, optional = false, optionalLabel = 'Optional', children }: FieldProps) {
|
||||
return (
|
||||
<label className="mb-[17px] block text-2xs font-semibold text-[#586762]">
|
||||
{label}
|
||||
{optional && <em className="float-right font-normal text-[#9ba7a2] not-italic">Optional</em>}
|
||||
{optional && <em className="float-right font-normal text-[#9ba7a2] not-italic">{optionalLabel}</em>}
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Bell, Menu, Search } from 'lucide-react'
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useI18n } from '../i18n'
|
||||
import { initials } from '../lib/initials'
|
||||
import { pageTitle } from '../lib/navigation'
|
||||
import type { Me } from '../lib/queries'
|
||||
@@ -10,6 +11,7 @@ export function Header({ me, onOpenMenu, overdueActions }: { me: Me; onOpenMenu:
|
||||
const { pathname } = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const [query, setQuery] = useState('')
|
||||
const { t } = useI18n()
|
||||
|
||||
const search = (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
@@ -19,7 +21,7 @@ export function Header({ me, onOpenMenu, overdueActions }: { me: Me; onOpenMenu:
|
||||
|
||||
return (
|
||||
<header className="flex h-[62px] items-center justify-between gap-3 border-b border-line bg-white/60 px-[17px] sm:h-[72px] sm:px-6 lg:px-[42px] print:hidden">
|
||||
<button type="button" className={`${iconButton} sm:hidden`} onClick={onOpenMenu} aria-label="Open menu">
|
||||
<button type="button" className={`${iconButton} sm:hidden`} onClick={onOpenMenu} aria-label={t.nav.openMenu}>
|
||||
<Menu size={21} />
|
||||
</button>
|
||||
<div className="flex min-w-0 items-center gap-2.5 text-xs text-[#94a09b]">
|
||||
@@ -33,14 +35,14 @@ export function Header({ me, onOpenMenu, overdueActions }: { me: Me; onOpenMenu:
|
||||
<Search size={15} />
|
||||
<input
|
||||
className="h-[34px] w-[180px] bg-transparent text-xs text-ink outline-none"
|
||||
placeholder="Search incidents"
|
||||
aria-label="Search incidents"
|
||||
placeholder={t.nav.searchIncidents}
|
||||
aria-label={t.nav.searchIncidents}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</form>
|
||||
</search>
|
||||
<Link to="/incidents" className={`${iconButton} md:hidden`} aria-label="Search incidents">
|
||||
<Link to="/incidents" className={`${iconButton} md:hidden`} aria-label={t.nav.searchIncidents}>
|
||||
<Search size={19} />
|
||||
</Link>
|
||||
<Link to="/actions?filter=overdue" className={`${iconButton} relative`} aria-label={overdueActions ? `${overdueActions} overdue actions` : 'My actions'}>
|
||||
|
||||
@@ -45,7 +45,7 @@ export function IncidentTable({ incidents, wide = false }: { incidents: Incident
|
||||
{incident.title}
|
||||
</Link>
|
||||
<small className="mt-1 block text-3xs text-faint">
|
||||
{incident.reference} · {incident.anonymous ? 'Anonymous' : (incident.reporter?.name ?? 'Unknown')}
|
||||
{incident.reference} · {incident.anonymous ? 'Anonymous' : (incident.reporter?.name ?? incident.reporterName ?? 'Unknown')}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Languages } from 'lucide-react'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
/** Switches between English and Spanish; shows the language you'd switch to, in that language. */
|
||||
export function LanguageToggle({ className = '' }: { className?: string }) {
|
||||
const { lang, setLang, t } = useI18n()
|
||||
return (
|
||||
<button type="button" className={`inline-flex cursor-pointer items-center gap-1.5 text-2xs font-semibold ${className}`} onClick={() => setLang(lang === 'en' ? 'es' : 'en')} lang={lang === 'en' ? 'es' : 'en'}>
|
||||
<Languages size={15} /> {t.language.switchTo}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { CloudOff, RefreshCw, Trash2 } from 'lucide-react'
|
||||
import { useI18n } from '../i18n'
|
||||
import { outbox, type QueuedItem } from '../lib/outbox'
|
||||
import { useOnline, useOutbox } from '../lib/use-outbox'
|
||||
|
||||
/** Offline notice plus any reports still waiting on this device. */
|
||||
export function OutboxBanner({ belongs }: { belongs: (item: QueuedItem) => boolean }) {
|
||||
const { t } = useI18n()
|
||||
const online = useOnline()
|
||||
const { items, syncing } = useOutbox()
|
||||
const mine = items.filter(belongs)
|
||||
if (online && mine.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="border-b border-[#f3d9a4] bg-[#fff8e6] px-[17px] py-2.5 text-2xs text-[#7a5a12] sm:px-6 lg:px-[42px] print:hidden" role="status">
|
||||
{!online && (
|
||||
<p className="flex items-center gap-2">
|
||||
<CloudOff size={15} className="flex-none" /> {t.outbox.offline}
|
||||
</p>
|
||||
)}
|
||||
{mine.length > 0 && (
|
||||
<div className={!online ? 'mt-1.5' : ''}>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<strong className="font-semibold">{t.outbox.waiting(mine.length)}</strong>
|
||||
{online && (
|
||||
<button type="button" className="inline-flex items-center gap-1 font-semibold underline disabled:opacity-50" disabled={syncing} onClick={() => void outbox.flush(belongs)}>
|
||||
<RefreshCw size={13} className={syncing ? 'animate-spin' : ''} /> {syncing ? t.outbox.sending : t.outbox.retry}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{mine
|
||||
.filter((item) => item.rejected)
|
||||
.map((item) => (
|
||||
<p key={item.clientId} className="mt-1 flex items-center gap-2">
|
||||
“{item.report.title}”: {t.outbox.failed(item.lastError ?? '')}
|
||||
<button type="button" className="inline-flex items-center gap-1 underline" onClick={() => confirm(t.outbox.discardConfirm) && void outbox.discard(item.clientId)}>
|
||||
<Trash2 size={12} /> {t.outbox.discard}
|
||||
</button>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import QRCode from 'qrcode'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
/** QR code as crisp SVG squares (prints sharply at any size). */
|
||||
export function QrCode({ value, size = 160, label }: { value: string; size?: number; label: string }) {
|
||||
const { path, count } = useMemo(() => {
|
||||
const { modules } = QRCode.create(value, { errorCorrectionLevel: 'M' })
|
||||
let d = ''
|
||||
for (let y = 0; y < modules.size; y++) for (let x = 0; x < modules.size; x++) if (modules.get(x, y)) d += `M${x} ${y}h1v1h-1z`
|
||||
return { path: d, count: modules.size }
|
||||
}, [value])
|
||||
const margin = 2
|
||||
return (
|
||||
<svg width={size} height={size} viewBox={`${-margin} ${-margin} ${count + margin * 2} ${count + margin * 2}`} role="img" aria-label={label} shapeRendering="crispEdges">
|
||||
<rect x={-margin} y={-margin} width={count + margin * 2} height={count + margin * 2} fill="#fff" />
|
||||
<path d={path} fill="#000" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
+21
-10
@@ -1,10 +1,13 @@
|
||||
import { Info, LogOut, Plus, Settings, ShieldCheck, X } from 'lucide-react'
|
||||
import { NavLink, useNavigate } from 'react-router-dom'
|
||||
import { useI18n } from '../i18n'
|
||||
import { outbox } from '../lib/outbox'
|
||||
import { initials } from '../lib/initials'
|
||||
import { roleLabel } from '../lib/labels'
|
||||
import { primaryNav } from '../lib/navigation'
|
||||
import { useLogout, type Me } from '../lib/queries'
|
||||
import { Eyebrow } from './Eyebrow'
|
||||
import { LanguageToggle } from './LanguageToggle'
|
||||
import { iconButton } from './styles'
|
||||
|
||||
const navLink = ({ isActive }: { isActive: boolean }) =>
|
||||
@@ -21,6 +24,13 @@ interface SidebarProps {
|
||||
export function Sidebar({ me, open, onClose, badges }: SidebarProps) {
|
||||
const logout = useLogout()
|
||||
const navigate = useNavigate()
|
||||
const { t } = useI18n()
|
||||
|
||||
const signOut = () => {
|
||||
const unsent = outbox.getState().items.filter((item) => item.kind === 'report' && item.userId === me.user.id).length
|
||||
if (unsent && !confirm(t.outbox.signOutWarning(unsent))) return
|
||||
logout.mutate(undefined, { onSuccess: () => navigate('/login') })
|
||||
}
|
||||
return (
|
||||
<aside
|
||||
className={`fixed inset-y-0 z-10 flex w-[260px] flex-none flex-col overflow-y-auto bg-forest-950 px-[15px] pt-7 pb-[18px] text-forest-100 shadow-[8px_0_30px_#163c3740] transition-[left] duration-200 sm:static sm:min-h-screen sm:w-[230px] sm:shadow-none lg:w-[246px] print:hidden ${open ? 'left-0' : '-left-[270px]'}`}
|
||||
@@ -33,7 +43,7 @@ export function Sidebar({ me, open, onClose, badges }: SidebarProps) {
|
||||
<strong className="block font-display text-[19px] leading-tight font-bold tracking-[-0.5px] whitespace-nowrap sm:text-[17px] lg:text-[19px]">Open SafetyLog</strong>
|
||||
<span className="mt-0.5 block text-3xs text-forest-300">EHS · Incident intelligence</span>
|
||||
</div>
|
||||
<button type="button" className={`${iconButton} absolute top-2 right-2 sm:hidden`} onClick={onClose} aria-label="Close menu">
|
||||
<button type="button" className={`${iconButton} absolute top-2 right-2 sm:hidden`} onClick={onClose} aria-label={t.nav.closeMenu}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -41,7 +51,7 @@ export function Sidebar({ me, open, onClose, badges }: SidebarProps) {
|
||||
<div className="mb-[27px] flex items-center gap-2.5 rounded-[7px] border border-[#316158] p-[11px]">
|
||||
<div className="grid size-[29px] flex-none place-items-center rounded-full bg-[#f1c865] font-mono text-2xs font-bold text-[#143d37]">{initials(me.organization.name)}</div>
|
||||
<div className="min-w-0">
|
||||
<Eyebrow>Workspace</Eyebrow>
|
||||
<Eyebrow>{t.nav.workspace}</Eyebrow>
|
||||
<strong className="block truncate text-[13px] text-white">{me.organization.name}</strong>
|
||||
</div>
|
||||
</div>
|
||||
@@ -50,29 +60,30 @@ export function Sidebar({ me, open, onClose, badges }: SidebarProps) {
|
||||
{primaryNav.map(({ label, to, icon: Icon }) => (
|
||||
<NavLink key={to} to={to} end={to === '/'} onClick={onClose} className={navLink}>
|
||||
<Icon size={18} />
|
||||
<span>{label}</span>
|
||||
<span>{t.nav[label]}</span>
|
||||
{!!badges[to] && <em className="ml-auto rounded-[10px] bg-coral px-[7px] py-0.5 font-mono text-2xs text-white not-italic">{badges[to]}</em>}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className={navSection}>
|
||||
<Eyebrow className="px-3 pb-2 text-[#8da29b]">Manage</Eyebrow>
|
||||
<Eyebrow className="px-3 pb-2 text-[#8da29b]">{t.nav.manage}</Eyebrow>
|
||||
<NavLink to="/report" onClick={onClose} className={navLink}>
|
||||
<Plus size={18} />
|
||||
<span>Report an incident</span>
|
||||
<span>{t.nav.reportIncident}</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto">
|
||||
<NavLink to="/about" onClick={onClose} className={navLink}>
|
||||
<Info size={18} />
|
||||
<span>About</span>
|
||||
<span>{t.nav.about}</span>
|
||||
</NavLink>
|
||||
<NavLink to="/settings" onClick={onClose} className={navLink}>
|
||||
<Settings size={18} />
|
||||
<span>Settings</span>
|
||||
<span>{t.nav.settings}</span>
|
||||
</NavLink>
|
||||
<LanguageToggle className="h-[42px] w-full px-3 text-forest-200 hover:text-white" />
|
||||
<div className="mt-4 flex items-center gap-2.5 border-t border-forest-700 px-2 pt-5">
|
||||
<div className="grid size-[31px] flex-none place-items-center rounded-full bg-[#5c8274] font-mono text-2xs text-white">{initials(me.user.name)}</div>
|
||||
<div className="min-w-0">
|
||||
@@ -82,9 +93,9 @@ export function Sidebar({ me, open, onClose, badges }: SidebarProps) {
|
||||
<button
|
||||
type="button"
|
||||
className={`${iconButton} ml-auto text-[#91aaa0] hover:text-white`}
|
||||
aria-label="Sign out"
|
||||
title="Sign out"
|
||||
onClick={() => logout.mutate(undefined, { onSuccess: () => navigate('/login') })}
|
||||
aria-label={t.nav.signOut}
|
||||
title={t.nav.signOut}
|
||||
onClick={signOut}
|
||||
>
|
||||
<LogOut size={17} />
|
||||
</button>
|
||||
|
||||
@@ -4,8 +4,8 @@ import { useOutletContext } from 'react-router-dom'
|
||||
import type { ActionKind, ActionStatus, CorrectiveAction, IncidentDetail } from '../../../shared/api-types.ts'
|
||||
import { actionKinds } from '../../../shared/enums.ts'
|
||||
import { formatDay, toIsoDate } from '../../lib/dates'
|
||||
import { actionStatusLabel } from '../../lib/labels'
|
||||
import { statusOptions } from '../../lib/permissions'
|
||||
import { useI18n } from '../../i18n'
|
||||
import { useAssignableUsers, useIncidentMutations, type Me } from '../../lib/queries'
|
||||
import { ActionStatusBadge, OverdueBadge } from '../Badges'
|
||||
import { Field } from '../FormSection'
|
||||
@@ -15,6 +15,7 @@ import { Section } from './Section'
|
||||
|
||||
export function ActionStatusControl({ action, onChange, disabled }: { action: CorrectiveAction; onChange: (status: ActionStatus) => void; disabled?: boolean }) {
|
||||
const me = useOutletContext<Me>().user
|
||||
const { t } = useI18n()
|
||||
const options = statusOptions(action, me)
|
||||
if (options.length === 0) return <ActionStatusBadge status={action.status} />
|
||||
return (
|
||||
@@ -22,13 +23,13 @@ export function ActionStatusControl({ action, onChange, disabled }: { action: Co
|
||||
className="rounded-[5px] border border-line bg-white px-2 py-1 text-2xs text-ink outline-none print:hidden"
|
||||
value={action.status}
|
||||
disabled={disabled}
|
||||
aria-label={`Status of ${action.title}`}
|
||||
aria-label={t.actions.statusOf(action.title)}
|
||||
onChange={(event) => onChange(event.target.value as ActionStatus)}
|
||||
>
|
||||
{!options.includes(action.status) && <option value={action.status}>{actionStatusLabel[action.status]}</option>}
|
||||
{!options.includes(action.status) && <option value={action.status}>{t.actionStatuses[action.status]}</option>}
|
||||
{options.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{actionStatusLabel[status]}
|
||||
{t.actionStatuses[status]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
Reference in new issue
Block a user