290 lines
9.4 KiB
JavaScript
290 lines
9.4 KiB
JavaScript
import { useCallback, useEffect, useRef, useState } from 'react'
|
||
import { Link, useNavigate } from 'react-router-dom'
|
||
import { Avatar, Button, Card, EmptyState, Icon, PageHeader } from '../components/ui'
|
||
import Timeline from '../components/common/Timeline'
|
||
import { fetchDashboard } from '../api/dashboard'
|
||
import { ASSET_STATE } from '../api/assets'
|
||
import { useAuth } from '../store/AuthContext'
|
||
import { formatCurrency } from '../utils/format'
|
||
|
||
/**
|
||
* Colour per asset state — categorical slots 1–3, fixed to the state (never to
|
||
* its rank), so a state keeps its colour whatever the counts. Validated
|
||
* all-pairs on the white card surface; aqua is under 3:1 there, so every
|
||
* segment also carries a visible label in the legend.
|
||
*/
|
||
const STATE_COLOR = {
|
||
[ASSET_STATE.ASSIGNED]: 'var(--series-1)',
|
||
[ASSET_STATE.AVAILABLE]: 'var(--series-3)',
|
||
[ASSET_STATE.CLOSED]: 'var(--series-2)',
|
||
}
|
||
const OTHER_COLOR = 'var(--border-strong)'
|
||
|
||
const pct = (n, total) => (total ? Math.round((n / total) * 100) : 0)
|
||
|
||
/** Overview of the inventory, read live from the asset and employee workflows. */
|
||
export default function Dashboard() {
|
||
const navigate = useNavigate()
|
||
const { logout } = useAuth()
|
||
const [data, setData] = useState(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState('')
|
||
const requestId = useRef(0)
|
||
|
||
const load = useCallback(async () => {
|
||
const id = ++requestId.current
|
||
setLoading(true)
|
||
setError('')
|
||
try {
|
||
const next = await fetchDashboard()
|
||
if (id === requestId.current) setData(next)
|
||
} catch (err) {
|
||
if (id !== requestId.current) return
|
||
if (err?.status === 401) {
|
||
logout()
|
||
navigate('/login', { replace: true })
|
||
return
|
||
}
|
||
setError(err?.message || 'Could not load the dashboard.')
|
||
} finally {
|
||
if (id === requestId.current) setLoading(false)
|
||
}
|
||
}, [logout, navigate])
|
||
|
||
useEffect(() => {
|
||
load()
|
||
}, [load])
|
||
|
||
const header = (
|
||
<PageHeader
|
||
title="Dashboard"
|
||
subtitle="Asset inventory at a glance, read live from the workflows."
|
||
actions={
|
||
<Button icon="history" onClick={load} disabled={loading}>
|
||
{loading ? 'Refreshing…' : 'Refresh'}
|
||
</Button>
|
||
}
|
||
/>
|
||
)
|
||
|
||
if (!data) {
|
||
return (
|
||
<>
|
||
{header}
|
||
<Card>
|
||
{error ? (
|
||
<EmptyState
|
||
icon="🙁"
|
||
title="Could not load the dashboard"
|
||
text={error}
|
||
action={<Button onClick={load}>Try again</Button>}
|
||
/>
|
||
) : (
|
||
<EmptyState icon="⏳" title="Loading dashboard" text="Fetching from the workflows…" />
|
||
)}
|
||
</Card>
|
||
</>
|
||
)
|
||
}
|
||
|
||
const count = (state) => data.states.find((s) => s.state === state)?.count ?? 0
|
||
|
||
return (
|
||
<>
|
||
{header}
|
||
|
||
{error && (
|
||
<div className="notice notice--danger" role="alert">
|
||
<Icon name="alert" size={15} />
|
||
<span>{error} Showing the last loaded numbers.</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* A refetch keeps the previous numbers, dimmed, instead of flashing. */}
|
||
<div className={`dash${loading ? ' dash--stale' : ''}`}>
|
||
<div className="stat-grid">
|
||
<StatTile label="Total assets" value={data.total} to="/assets" />
|
||
<StatTile label="Assigned" value={count(ASSET_STATE.ASSIGNED)} to="/assign" />
|
||
<StatTile label="Available" value={count(ASSET_STATE.AVAILABLE)} to="/assign" />
|
||
<StatTile label="Closed (lost or retired)" value={count(ASSET_STATE.CLOSED)} to="/history" />
|
||
<StatTile
|
||
label="Active employees"
|
||
value={data.employees.active}
|
||
note={data.employees.inactive ? `${data.employees.inactive} inactive` : undefined}
|
||
to="/employees"
|
||
/>
|
||
<StatTile label="Inventory value" value={formatCurrency(data.inventoryValue)} note="Sum of purchase costs" />
|
||
</div>
|
||
|
||
<div className="dash-grid">
|
||
<Card title="Asset status" subtitle={`Share of all ${data.total} assets by workflow state`}>
|
||
<StateMeter states={data.states} total={data.total} />
|
||
</Card>
|
||
|
||
<Card title="Assets by category" subtitle="Number of assets in each category">
|
||
<CategoryBars categories={data.categories} />
|
||
</Card>
|
||
|
||
<Card
|
||
title="Who has assets now"
|
||
subtitle="Employees holding assigned assets"
|
||
actions={
|
||
<Link className="link text-sm" to="/assign">
|
||
View all
|
||
</Link>
|
||
}
|
||
flush
|
||
>
|
||
<HolderList holders={data.holders} />
|
||
</Card>
|
||
|
||
<Card
|
||
title="Recent activity"
|
||
subtitle="Latest purchases, assignments, returns and losses"
|
||
actions={
|
||
<Link className="link text-sm" to="/history">
|
||
Full history
|
||
</Link>
|
||
}
|
||
flush
|
||
>
|
||
{data.recent.length ? (
|
||
<Timeline
|
||
events={data.recent.map((e) => ({
|
||
...e,
|
||
title: [e.action, e.asset_code].filter(Boolean).join(' · '),
|
||
meta: [e.employee, e.meta].filter(Boolean).join(' · ') || undefined,
|
||
}))}
|
||
/>
|
||
) : (
|
||
<EmptyState icon="🕘" title="No activity yet" text="Asset events show up here." />
|
||
)}
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)
|
||
}
|
||
|
||
/** One headline number. Proportional figures; the label says what it counts. */
|
||
function StatTile({ label, value, note, to }) {
|
||
const body = (
|
||
<>
|
||
<span className="stat__label">{label}</span>
|
||
<span className="stat__value">{typeof value === 'number' ? value.toLocaleString('en-IN') : value}</span>
|
||
{note && <span className="stat__note">{note}</span>}
|
||
</>
|
||
)
|
||
return to ? (
|
||
<Link className="stat stat--link" to={to}>
|
||
{body}
|
||
</Link>
|
||
) : (
|
||
<div className="stat">{body}</div>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Part-to-whole of the asset states: one stacked bar with 2px surface gaps,
|
||
* and a legend that always shows each state's count and share, so no value
|
||
* lives only in the tooltip.
|
||
*/
|
||
function StateMeter({ states, total }) {
|
||
const [tip, setTip] = useState(null)
|
||
const shown = states.filter((s) => s.count > 0)
|
||
if (!total) return <EmptyState icon="📦" title="No assets yet" text="Add an asset to see its status here." />
|
||
|
||
const show = (s, e) => {
|
||
const box = e.currentTarget.parentElement.getBoundingClientRect()
|
||
const mark = e.currentTarget.getBoundingClientRect()
|
||
const x = e.clientX ?? mark.left + mark.width / 2
|
||
setTip({ s, x: x - box.left })
|
||
}
|
||
|
||
return (
|
||
<div className="meter-wrap">
|
||
<div className="meter" role="img" aria-label={shown.map((s) => `${s.state} ${s.count}`).join(', ')}>
|
||
{shown.map((s) => (
|
||
<span
|
||
key={s.state}
|
||
className="meter__seg"
|
||
style={{ flexGrow: s.count, background: STATE_COLOR[s.state] ?? OTHER_COLOR }}
|
||
tabIndex={0}
|
||
aria-label={`${s.state}: ${s.count} (${pct(s.count, total)}%)`}
|
||
onPointerMove={(e) => show(s, e)}
|
||
onPointerLeave={() => setTip(null)}
|
||
onFocus={(e) => show(s, e)}
|
||
onBlur={() => setTip(null)}
|
||
/>
|
||
))}
|
||
{tip && (
|
||
<div className="chart-tip" style={{ left: tip.x }} role="presentation">
|
||
<strong>{tip.s.count}</strong> <span>{tip.s.state} · {pct(tip.s.count, total)}%</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<ul className="legend">
|
||
{states.map((s) => (
|
||
<li key={s.state} className="legend__item">
|
||
<span className="legend__swatch" style={{ background: STATE_COLOR[s.state] ?? OTHER_COLOR }} />
|
||
<span className="legend__label">{s.state}</span>
|
||
<span className="legend__value">
|
||
{s.count} · {pct(s.count, total)}%
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Horizontal bars, one series (slot 1), most first. Each bar's value sits at
|
||
* its tip, so the chart is also its own table; hover lifts the bar.
|
||
*/
|
||
function CategoryBars({ categories }) {
|
||
if (!categories.length) return <EmptyState icon="📦" title="No assets yet" />
|
||
const max = Math.max(...categories.map((c) => c.count))
|
||
|
||
return (
|
||
<ul className="hbars">
|
||
{categories.map((c) => (
|
||
<li
|
||
key={c.label}
|
||
className="hbars__row"
|
||
tabIndex={0}
|
||
aria-label={`${c.label}: ${c.count}`}
|
||
title={`${c.label}: ${c.count} asset${c.count === 1 ? '' : 's'}`}
|
||
>
|
||
<span className="hbars__label">{c.label}</span>
|
||
<span className="hbars__track">
|
||
<span className="hbars__bar" style={{ width: `${(c.count / max) * 100}%` }} />
|
||
<span className="hbars__value">{c.count}</span>
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)
|
||
}
|
||
|
||
/** Employees with assets right now, most first. */
|
||
function HolderList({ holders }) {
|
||
if (!holders.length) {
|
||
return <EmptyState icon="🙌" title="Nobody has an asset" text="Assigned assets show up here." />
|
||
}
|
||
return (
|
||
<ul className="holders">
|
||
{holders.slice(0, 6).map((h) => (
|
||
<li key={h.id} className="holders__item">
|
||
<Avatar name={h.name} />
|
||
<span className="holders__name">{h.name}</span>
|
||
<span className="holders__count">
|
||
{h.count} asset{h.count === 1 ? '' : 's'}
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)
|
||
}
|