Compare commits
1 commit
main
...
worktree-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
892cd092dc |
1 changed files with 214 additions and 0 deletions
214
web/src/views/DashboardView.tsx
Normal file
214
web/src/views/DashboardView.tsx
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
ErrText,
|
||||
HStack,
|
||||
Muted,
|
||||
Pill,
|
||||
Bars,
|
||||
SectionLabel,
|
||||
Seg,
|
||||
SegButton,
|
||||
Title,
|
||||
Toast,
|
||||
VStack,
|
||||
} from '../ui';
|
||||
import { classifyText, getDashboard, type DashboardData } from '../api';
|
||||
import { usePoll } from '../usePoll';
|
||||
import { navigate } from '../useHashRoute';
|
||||
|
||||
export function DashboardView(): JSX.Element {
|
||||
const [days, setDays] = useState<number>(7);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
|
||||
const { data, error, loading } = usePoll<DashboardData>(() => getDashboard(days), 30_000);
|
||||
|
||||
const showToast = (msg: string): void => {
|
||||
setToast(msg);
|
||||
window.setTimeout(() => setToast(null), 2400);
|
||||
};
|
||||
|
||||
const handleAction = (action: string): void => {
|
||||
const lower = action.toLowerCase();
|
||||
if (lower.includes('triage') || lower.includes('held') || lower.includes('review')) {
|
||||
navigate('triage');
|
||||
} else if (lower.includes('backfill') || lower.includes('leaving')) {
|
||||
navigate('backfill');
|
||||
} else if (lower.includes('queue') || lower.includes('outbox')) {
|
||||
navigate('queue');
|
||||
} else if (lower.includes('classify') || lower.includes('gpu')) {
|
||||
void classifyText('demo inbound: hi, rates? available tonight?', false)
|
||||
.then((r) => showToast(`Classified demo: ${r.category ?? r.template ?? 'ok'}`))
|
||||
.catch((e) => showToast(`Classify failed: ${e instanceof Error ? e.message : String(e)}`));
|
||||
} else if (lower.includes('market') || lower.includes('idle')) {
|
||||
navigate('markets');
|
||||
} else if (lower.includes('reports')) {
|
||||
navigate('reports');
|
||||
} else {
|
||||
showToast(`Action: ${action}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<VStack $gap={8}>
|
||||
<ErrText>Dashboard error: {error}</ErrText>
|
||||
<Muted>Will retry automatically.</Muted>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<Card>
|
||||
<Muted>Loading dashboard…</Muted>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const { kpis, density, insights, actions } = data;
|
||||
|
||||
const densityRows: readonly { key: string; count: number }[] = density.map((d) => ({
|
||||
key: `${d.hour}h`,
|
||||
count: d.count,
|
||||
}));
|
||||
|
||||
const kpiEntries = Object.entries(kpis);
|
||||
const volumeRows: readonly { key: string; count: number }[] = kpiEntries
|
||||
.filter(([, v]) => typeof v === 'number')
|
||||
.map(([k, v]) => ({
|
||||
key: k.replace(/7d|InRange/gi, '').trim() || k,
|
||||
count: Number(v),
|
||||
}));
|
||||
|
||||
const peak = density.length
|
||||
? density.reduce((a, b) => (b.count > a.count ? b : a))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<VStack $gap={14}>
|
||||
<HStack $justify="space-between" $wrap>
|
||||
<Title>Today</Title>
|
||||
<Muted>NYC tour · runner active · through Jul 1</Muted>
|
||||
</HStack>
|
||||
|
||||
<HStack $gap={8} $align="center">
|
||||
<SectionLabel>Range</SectionLabel>
|
||||
<Seg>
|
||||
<SegButton $active={days === 7} onClick={() => setDays(7)}>
|
||||
7d
|
||||
</SegButton>
|
||||
<SegButton $active={days === 30} onClick={() => setDays(30)}>
|
||||
30d
|
||||
</SegButton>
|
||||
</Seg>
|
||||
{loading ? <Muted>(refreshing…)</Muted> : null}
|
||||
</HStack>
|
||||
|
||||
<SectionLabel>KPIs</SectionLabel>
|
||||
<HStack $wrap $gap={10}>
|
||||
{kpiEntries.map(([k, v]) => {
|
||||
const label = k === 'newInRange'
|
||||
? 'New (range)'
|
||||
: k === 'sent7d'
|
||||
? 'Sent 7d'
|
||||
: k === 'held7d'
|
||||
? 'Held 7d'
|
||||
: k === 'qualified'
|
||||
? 'Qualified'
|
||||
: k;
|
||||
const onClick = () => {
|
||||
if (k === 'qualified' || k === 'sent7d') {
|
||||
navigate('reports');
|
||||
} else if (k === 'held7d' || k.includes('held')) {
|
||||
navigate('queue');
|
||||
} else if (k.includes('new')) {
|
||||
navigate('triage');
|
||||
} else {
|
||||
navigate('reports');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Card
|
||||
key={k}
|
||||
onClick={onClick}
|
||||
style={{ flex: '1 1 120px', minWidth: 110, cursor: 'pointer' }}
|
||||
>
|
||||
<VStack $gap={4}>
|
||||
<Title style={{ fontSize: '22px', fontFamily: 'ui-monospace, Menlo, monospace' }}>
|
||||
{String(v)}
|
||||
</Title>
|
||||
<Muted>{label}</Muted>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</HStack>
|
||||
|
||||
<HStack $gap={12} $wrap>
|
||||
<Card style={{ flex: 1, minWidth: 280 }}>
|
||||
<VStack $gap={6}>
|
||||
<HStack $justify="space-between">
|
||||
<Title style={{ fontSize: '13px' }}>Inbound density · ET</Title>
|
||||
{peak ? <Pill $tone="success">peak {peak.hour}h</Pill> : null}
|
||||
</HStack>
|
||||
<Muted>time bumps here</Muted>
|
||||
<Bars rows={densityRows} tone="success" />
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
<Card style={{ flex: 1, minWidth: 280 }}>
|
||||
<VStack $gap={6}>
|
||||
<Title style={{ fontSize: '13px' }}>Volume (KPIs)</Title>
|
||||
{volumeRows.length > 0 ? (
|
||||
<Bars rows={volumeRows} tone="accent" />
|
||||
) : (
|
||||
<Muted>No volume data.</Muted>
|
||||
)}
|
||||
<HStack $gap={8}>
|
||||
<Pill $tone="success">numeric</Pill>
|
||||
<Muted>from dashboard</Muted>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
</HStack>
|
||||
|
||||
<SectionLabel>Insights</SectionLabel>
|
||||
<VStack $gap={8}>
|
||||
{insights.length ? (
|
||||
insights.map((ins, idx) => (
|
||||
<Card key={idx}>
|
||||
<Muted>{ins}</Muted>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<Muted>No insights.</Muted>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
<SectionLabel>Recommended actions</SectionLabel>
|
||||
<VStack $gap={8}>
|
||||
{actions.length ? (
|
||||
actions.map((a, idx) => (
|
||||
<Card key={idx}>
|
||||
<HStack $justify="space-between" $wrap $gap={8}>
|
||||
<Muted style={{ flex: 1 }}>{a}</Muted>
|
||||
<Button $variant="primary" onClick={() => handleAction(a)}>
|
||||
Take action →
|
||||
</Button>
|
||||
</HStack>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<Muted>No recommended actions.</Muted>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
{toast ? <Toast>{toast}</Toast> : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue