feat: initialize project structure with core components and pages
- Added shared styles for UI components in `src/components/styles.ts`. - Created configuration constants in `src/lib/config.ts`. - Implemented date formatting and greeting functions in `src/lib/dates.ts` and corresponding tests in `src/lib/dates.test.ts`. - Developed incident management functions in `src/lib/incidents.ts` with tests in `src/lib/incidents.test.ts`. - Established navigation structure in `src/lib/navigation.ts`. - Seeded demo data for incidents and users in `src/lib/seed.ts`. - Defined tone classes for UI elements in `src/lib/tones.ts`. - Created type definitions for incidents and related entities in `src/lib/types.ts`. - Set up main application entry point in `src/main.tsx`. - Developed various pages including About, Analytics, Incidents, Locations, Overview, and Report Incident. - Configured testing setup in `src/test/setup.ts` and TypeScript configurations. - Integrated Vite for development and testing with appropriate plugins.
This commit is contained in:
1 parent
732d4d8931
commit
a382fa904a
53 files changed
+3523
-31
No files matched your search
@@ -0,0 +1,17 @@
|
||||
import { useState } from 'react'
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { Header } from './Header'
|
||||
import { Sidebar } from './Sidebar'
|
||||
|
||||
export function AppLayout({ openIncidentCount }: { openIncidentCount: number }) {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
return (
|
||||
<div className="flex min-h-screen bg-canvas">
|
||||
<Sidebar open={menuOpen} onClose={() => setMenuOpen(false)} openIncidentCount={openIncidentCount} />
|
||||
<main className="min-w-0 flex-1">
|
||||
<Header onOpenMenu={() => setMenuOpen(true)} />
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export function Eyebrow({ children, className = 'text-[#8da29b]' }: { children: ReactNode; className?: string }) {
|
||||
return <span className={`block font-mono text-3xs font-medium tracking-[0.09em] uppercase ${className}`}>{children}</span>
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface FormSectionProps {
|
||||
number: string
|
||||
title: string
|
||||
copy: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function FormSection({ number, title, copy, children }: FormSectionProps) {
|
||||
return (
|
||||
<div className="border-b border-line px-5 py-6 sm:px-9 sm:py-7">
|
||||
<div className="mb-6 flex gap-[13px]">
|
||||
<span className="pt-[3px] font-mono text-xs text-coral">{number}</span>
|
||||
<div>
|
||||
<h2 className="mb-1 font-display text-[19px] font-semibold">{title}</h2>
|
||||
<p className="text-xs text-muted">{copy}</p>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Field({ label, optional = false, children }: { label: string; optional?: boolean; children: ReactNode }) {
|
||||
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>}
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Bell, Menu, Search } from 'lucide-react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { pageTitles } from '../lib/navigation'
|
||||
import { currentUser } from '../lib/seed'
|
||||
import { iconButton } from './styles'
|
||||
|
||||
export function Header({ onOpenMenu }: { onOpenMenu: () => void }) {
|
||||
const { pathname } = useLocation()
|
||||
return (
|
||||
<header className="flex h-[62px] items-center justify-between border-b border-line bg-white/60 px-[17px] sm:h-[72px] sm:px-6 lg:px-[42px]">
|
||||
<button type="button" className={`${iconButton} mr-2.5 sm:hidden`} onClick={onOpenMenu} aria-label="Open menu">
|
||||
<Menu size={21} />
|
||||
</button>
|
||||
<div className="flex items-center gap-2.5 text-xs text-[#94a09b]">
|
||||
<span className="max-sm:hidden">Safety workspace</span>
|
||||
<span className="max-sm:hidden">/</span>
|
||||
<strong className="text-ink">{pageTitles[pathname] ?? 'Not found'}</strong>
|
||||
</div>
|
||||
<div className="flex items-center gap-[15px]">
|
||||
<button type="button" className={iconButton} aria-label="Search">
|
||||
<Search size={19} />
|
||||
</button>
|
||||
<button type="button" className={`${iconButton} relative`} aria-label="Notifications">
|
||||
<Bell size={19} />
|
||||
<i className="absolute top-1 right-[3px] size-1.5 rounded-full border border-canvas bg-coral" />
|
||||
</button>
|
||||
<div className="grid size-8 place-items-center rounded-full bg-[#d2e0d8] font-mono text-2xs text-teal">{currentUser.initials}</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { AlertTriangle, MapPin } from 'lucide-react'
|
||||
import { formatDate } from '../lib/dates'
|
||||
import { severityDotClass, statusClass, typeTone } from '../lib/incidents'
|
||||
import { toneClasses } from '../lib/tones'
|
||||
import type { Incident } from '../lib/types'
|
||||
import { iconButton } from './styles'
|
||||
|
||||
const th = 'border-b border-line px-2.5 py-3 font-mono text-3xs font-medium tracking-[0.06em] text-faint uppercase first:pl-6 last:pr-[18px]'
|
||||
const td = 'border-b border-line-soft px-2.5 py-3.5 text-2xs whitespace-nowrap text-body first:pl-6 last:pr-[18px]'
|
||||
|
||||
interface IncidentTableProps {
|
||||
incidents: Incident[]
|
||||
limit?: number
|
||||
wide?: boolean
|
||||
}
|
||||
|
||||
export function IncidentTable({ incidents, limit, wide = false }: IncidentTableProps) {
|
||||
const rows = limit ? incidents.slice(0, limit) : incidents
|
||||
return (
|
||||
<div className="overflow-auto">
|
||||
<table className={`w-full border-collapse text-left ${wide ? 'min-w-[900px]' : 'min-w-[720px]'}`}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={th}>Incident</th>
|
||||
<th className={th}>Type</th>
|
||||
<th className={th}>Severity</th>
|
||||
<th className={th}>Location</th>
|
||||
<th className={th}>Status</th>
|
||||
<th className={th}>Date</th>
|
||||
<th className={th}>
|
||||
<span className="sr-only">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((incident) => (
|
||||
<tr key={incident.id} className="[&:last-child>td]:border-b-0">
|
||||
<td className={td}>
|
||||
<div className="flex min-w-[250px] items-center gap-2.5">
|
||||
<span className={`grid size-7 place-items-center rounded-[7px] ${toneClasses[typeTone[incident.type]]}`}>
|
||||
<AlertTriangle size={14} />
|
||||
</span>
|
||||
<div>
|
||||
<strong className="block text-xs font-semibold text-ink">{incident.title}</strong>
|
||||
<small className="mt-1 block text-3xs text-faint">
|
||||
{incident.id} · {incident.reporter}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className={td}>{incident.type}</td>
|
||||
<td className={td}>
|
||||
<span className="inline-flex items-center gap-[5px]">
|
||||
<i className={`size-1.5 rounded-full ${severityDotClass[incident.severity]}`} />
|
||||
{incident.severity}
|
||||
</span>
|
||||
</td>
|
||||
<td className={td}>
|
||||
<div className="flex max-w-[120px] flex-wrap items-center gap-1">
|
||||
<MapPin size={14} />
|
||||
{incident.location}
|
||||
<small className="mt-1 ml-[18px] block w-full text-3xs text-faint">{incident.building}</small>
|
||||
</div>
|
||||
</td>
|
||||
<td className={td}>
|
||||
<span className={`rounded px-2 py-[5px] text-3xs ${statusClass(incident.status)}`}>{incident.status}</span>
|
||||
</td>
|
||||
<td className={`${td} text-[#87938f]`}>{formatDate(incident.date)}</td>
|
||||
<td className={td}>
|
||||
<button type="button" className={iconButton} aria-label={`More actions for ${incident.id}`}>
|
||||
<span className="tracking-[2px] text-[#91aaa0]">•••</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { toneClasses, type Tone } from '../lib/tones'
|
||||
|
||||
interface MetricCardProps {
|
||||
label: string
|
||||
value: string
|
||||
delta: string
|
||||
icon: LucideIcon
|
||||
tone: Tone
|
||||
}
|
||||
|
||||
export function MetricCard({ label, value, delta, icon: Icon, tone }: MetricCardProps) {
|
||||
return (
|
||||
<div className="rounded-lg border border-line bg-white p-[13px] sm:p-[18px]">
|
||||
<div className={`grid size-[35px] place-items-center rounded-[7px] ${toneClasses[tone]}`}>
|
||||
<Icon size={19} />
|
||||
</div>
|
||||
<span className="mt-2.5 mb-1.5 block text-xs text-muted sm:mt-[15px]">{label}</span>
|
||||
<strong className="block font-display text-[25px] font-semibold tracking-[-1px] text-ink sm:text-[30px]">{value}</strong>
|
||||
<small className="mt-[9px] block text-[9px] text-[#7d8b86] sm:text-2xs">
|
||||
<b className="text-positive">↗</b> {delta}
|
||||
</small>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export function Page({ children, narrow = false }: { children: ReactNode; narrow?: boolean }) {
|
||||
return (
|
||||
<div className={`mx-auto px-[17px] pt-7 pb-10 sm:px-6 sm:pt-10 sm:pb-[60px] lg:px-[42px] ${narrow ? 'max-w-[900px]' : 'max-w-[1360px]'}`}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Eyebrow } from './Eyebrow'
|
||||
|
||||
interface PageIntroProps {
|
||||
eyebrow: string
|
||||
title: string
|
||||
copy?: string
|
||||
action?: ReactNode
|
||||
}
|
||||
|
||||
export function PageIntro({ eyebrow, title, copy, action }: PageIntroProps) {
|
||||
return (
|
||||
<div className="mb-[31px] flex flex-col items-start gap-5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<Eyebrow>{eyebrow}</Eyebrow>
|
||||
<h1 className="mt-[7px] mb-[5px] font-display text-[29px] font-semibold tracking-[-1px] text-ink sm:text-[34px]">{title}</h1>
|
||||
{copy && <p className="text-sm text-muted">{copy}</p>}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ArrowUpRight } from 'lucide-react'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import { Eyebrow } from './Eyebrow'
|
||||
|
||||
interface PanelHeadingProps {
|
||||
eyebrow: string
|
||||
title: string
|
||||
link?: { label: string; to: string }
|
||||
}
|
||||
|
||||
export function PanelHeading({ eyebrow, title, link }: PanelHeadingProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-[17px] pt-[19px] pb-3.5 sm:px-6 sm:pt-6 sm:pb-[18px]">
|
||||
<div>
|
||||
<Eyebrow>{eyebrow}</Eyebrow>
|
||||
<h2 className="mt-1.5 font-display text-[19px] font-semibold text-ink">{title}</h2>
|
||||
</div>
|
||||
{link && (
|
||||
<NavLink to={link.to} className="inline-flex items-center gap-[5px] text-xs font-bold text-coral">
|
||||
{link.label} <ArrowUpRight size={15} />
|
||||
</NavLink>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
interface ProgressBarProps {
|
||||
/** 0–100 */
|
||||
percent: number
|
||||
barClassName?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ProgressBar({ percent, barClassName = 'bg-coral', className = '' }: ProgressBarProps) {
|
||||
return (
|
||||
<div className={`mt-2 h-1.5 overflow-hidden rounded-full bg-[#edf1ed] ${className}`}>
|
||||
<i className={`block h-full rounded-full ${barClassName}`} style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ProgressRowProps {
|
||||
label: string
|
||||
value: string
|
||||
percent: number
|
||||
variant?: 'action' | 'location'
|
||||
}
|
||||
|
||||
export function ProgressRow({ label, value, percent, variant = 'action' }: ProgressRowProps) {
|
||||
const isLocation = variant === 'location'
|
||||
return (
|
||||
<div className={isLocation ? 'px-6 py-[7px]' : 'px-6 pt-3'}>
|
||||
<div className={`flex justify-between text-2xs ${isLocation ? 'text-[#63726d]' : 'text-muted'}`}>
|
||||
<span>{label}</span>
|
||||
<strong className="font-mono font-normal text-ink">{value}</strong>
|
||||
</div>
|
||||
<ProgressBar percent={percent} barClassName={isLocation ? 'bg-sage' : 'bg-coral'} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { ChevronDown, FileText, Info, Plus, Settings, ShieldCheck, X } from 'lucide-react'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import { primaryNav } from '../lib/navigation'
|
||||
import { currentUser, workspace } from '../lib/seed'
|
||||
import { Eyebrow } from './Eyebrow'
|
||||
import { iconButton } from './styles'
|
||||
|
||||
const navLink = ({ isActive }: { isActive: boolean }) =>
|
||||
`flex h-[42px] items-center gap-3 rounded-md px-3 text-[13px] hover:bg-forest-800 hover:text-white ${isActive ? 'bg-forest-800 text-white' : 'text-forest-200'}`
|
||||
const navSection = 'mb-6 border-b border-forest-700 pb-[23px]'
|
||||
|
||||
interface SidebarProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
openIncidentCount: number
|
||||
}
|
||||
|
||||
export function Sidebar({ open, onClose, openIncidentCount }: SidebarProps) {
|
||||
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] ${open ? 'left-0' : '-left-[270px]'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 px-3 pb-[31px] text-white">
|
||||
<div className="grid size-[34px] flex-none place-items-center rounded-[9px] bg-coral text-white">
|
||||
<ShieldCheck size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<strong className="block font-display text-[19px] leading-tight sm:text-[17px] lg:text-[19px] font-bold tracking-[-0.5px] whitespace-nowrap">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">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-[27px] flex items-center gap-2.5 rounded-[7px] border border-[#316158] p-[11px]">
|
||||
<div className="grid size-[29px] place-items-center rounded-full bg-[#f1c865] font-mono text-2xs font-bold text-[#143d37]">{workspace.initials}</div>
|
||||
<div>
|
||||
<Eyebrow>Workspace</Eyebrow>
|
||||
<strong className="block text-[13px] text-white">{workspace.name}</strong>
|
||||
</div>
|
||||
<ChevronDown size={16} className="ml-auto" />
|
||||
</div>
|
||||
|
||||
<nav className={navSection}>
|
||||
{primaryNav.map(({ label, to, icon: Icon }) => (
|
||||
<NavLink key={to} to={to} end onClick={onClose} className={navLink}>
|
||||
<Icon size={18} />
|
||||
<span>{label}</span>
|
||||
{to === '/incidents' && openIncidentCount > 0 && (
|
||||
<em className="ml-auto rounded-[10px] bg-coral px-[7px] py-0.5 font-mono text-2xs text-white not-italic">{openIncidentCount}</em>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className={navSection}>
|
||||
<Eyebrow className="px-3 pb-2 text-[#8da29b]">Manage</Eyebrow>
|
||||
<NavLink to="/report" onClick={onClose} className={navLink}>
|
||||
<Plus size={18} />
|
||||
<span>Report an incident</span>
|
||||
</NavLink>
|
||||
<a href="#resources" className={navLink({ isActive: false })}>
|
||||
<FileText size={18} />
|
||||
<span>Resources</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto">
|
||||
<NavLink to="/about" onClick={onClose} className={navLink}>
|
||||
<Info size={18} />
|
||||
<span>About</span>
|
||||
</NavLink>
|
||||
<a href="#settings" className={navLink({ isActive: false })}>
|
||||
<Settings size={18} />
|
||||
<span>Settings</span>
|
||||
</a>
|
||||
<div className="mt-4 flex items-center gap-2.5 border-t border-forest-700 px-2 pt-5">
|
||||
<div className="grid size-[31px] place-items-center rounded-full bg-[#5c8274] font-mono text-2xs text-white">{currentUser.initials}</div>
|
||||
<div>
|
||||
<strong className="block text-xs text-white">{currentUser.name}</strong>
|
||||
<span className="mt-0.5 block text-3xs text-forest-300">{currentUser.role}</span>
|
||||
</div>
|
||||
<span className="ml-auto tracking-[2px] text-[#91aaa0]">•••</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Shared Tailwind class strings for elements reused across pages.
|
||||
|
||||
export const panel = 'min-w-0 rounded-lg border border-line bg-white shadow-[0_5px_20px_#1b252305]'
|
||||
|
||||
const buttonBase = 'inline-flex cursor-pointer items-center justify-center gap-2 rounded-[5px] px-3.5 py-[11px] text-xs font-bold no-underline'
|
||||
export const primaryButton = `${buttonBase} bg-coral text-white shadow-[0_4px_12px_#ee725833]`
|
||||
export const secondaryButton = `${buttonBase} border border-line bg-white text-ink`
|
||||
export const lightButton = `${buttonBase} bg-[#edf6ef] text-teal`
|
||||
|
||||
export const iconButton = 'grid cursor-pointer place-items-center bg-transparent p-1.5 text-[#72817d]'
|
||||
|
||||
export const field =
|
||||
'mt-[7px] block w-full rounded-[5px] border border-line bg-field px-3 py-[11px] text-xs text-ink outline-none focus:border-[#83b3a5] focus:shadow-[0_0_0_3px_#83b3a522]'
|
||||
|
||||
export const filterBox = 'flex h-[38px] items-center gap-2 rounded-[5px] border border-line px-[11px] text-[#85938e]'
|
||||
Reference in new issue
Block a user