asset-management/src/pages/Dashboard.jsx

290 lines
9.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
)
}