phase 16: React dashboard with full platform control and analytics
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
import { useParams } from '@tanstack/react-router';
|
||||
import { useState } from 'react';
|
||||
import { useCompany, useCompanySources, useCreateAlias, useCreateSource } from '../api/hooks';
|
||||
import { StatusBadge, LoadingSpinner, Card } from '../components/ui';
|
||||
import { DataTable, type Column } from '../components/DataTable';
|
||||
import type { Source } from '../api/hooks';
|
||||
import type { Alias } from '../api/hooks';
|
||||
|
||||
const sourceCols: Column<Source>[] = [
|
||||
{ key: 'source_type', header: 'Type' },
|
||||
{ key: 'source_name', header: 'Name' },
|
||||
{ key: 'credibility_score', header: 'Credibility', render: (r) => <span>{(r.credibility_score * 100).toFixed(0)}%</span> },
|
||||
{ key: 'active', header: 'Status', render: (r) => <StatusBadge status={r.active ? 'active' : 'disabled'} /> },
|
||||
];
|
||||
|
||||
export function CompanyDetailPage() {
|
||||
const { id } = useParams({ from: '/companies/$id' });
|
||||
const { data: company, isLoading } = useCompany(id);
|
||||
const { data: sources } = useCompanySources(id);
|
||||
const [tab, setTab] = useState<'aliases' | 'sources'>('sources');
|
||||
|
||||
if (isLoading || !company) return <LoadingSpinner />;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl font-semibold text-gray-100">{company.ticker}</h1>
|
||||
<StatusBadge status={company.active ? 'active' : 'disabled'} />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<dl className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm sm:grid-cols-4">
|
||||
<div><dt className="text-gray-500">Name</dt><dd className="text-gray-200">{company.legal_name}</dd></div>
|
||||
<div><dt className="text-gray-500">Exchange</dt><dd className="text-gray-200">{company.exchange ?? '—'}</dd></div>
|
||||
<div><dt className="text-gray-500">Sector</dt><dd className="text-gray-200">{company.sector ?? '—'}</dd></div>
|
||||
<div><dt className="text-gray-500">Industry</dt><dd className="text-gray-200">{company.industry ?? '—'}</dd></div>
|
||||
<div><dt className="text-gray-500">Market Cap</dt><dd className="text-gray-200">{company.market_cap_bucket ?? '—'}</dd></div>
|
||||
<div><dt className="text-gray-500">Sources</dt><dd className="text-gray-200">{company.active_source_count ?? 0}</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-4 border-b border-surface-700">
|
||||
{(['sources', 'aliases'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`pb-2 text-sm font-medium capitalize transition-colors ${tab === t ? 'border-b-2 border-brand-500 text-brand-300' : 'text-gray-500 hover:text-gray-300'}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'sources' && (
|
||||
<div className="space-y-4">
|
||||
<DataTable<Source> data={sources ?? []} columns={sourceCols} keyField="id" />
|
||||
<AddSourceForm companyId={id} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'aliases' && (
|
||||
<div className="space-y-4">
|
||||
<AliasesList aliases={company.aliases ?? []} />
|
||||
<AddAliasForm companyId={id} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AliasesList({ aliases }: { aliases: Alias[] }) {
|
||||
if (aliases.length === 0) return <p className="text-sm text-gray-500">No aliases configured</p>;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{aliases.map((a) => (
|
||||
<span key={a.id} className="rounded-full border border-surface-700 bg-surface-800 px-3 py-1 text-xs text-gray-300">
|
||||
{a.alias} <span className="text-gray-500">({a.alias_type})</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddAliasForm({ companyId }: { companyId: string }) {
|
||||
const [alias, setAlias] = useState('');
|
||||
const mutation = useCreateAlias(companyId);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (alias.trim()) mutation.mutate({ alias: alias.trim() }, { onSuccess: () => setAlias('') });
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add alias…"
|
||||
value={alias}
|
||||
onChange={(e) => setAlias(e.target.value)}
|
||||
className="rounded-md border border-surface-700 bg-surface-900 px-3 py-1.5 text-sm text-gray-200 placeholder-gray-500 focus:border-brand-500 focus:outline-none"
|
||||
aria-label="New alias"
|
||||
/>
|
||||
<button type="submit" disabled={mutation.isPending} className="rounded-md bg-brand-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50">
|
||||
Add
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function AddSourceForm({ companyId }: { companyId: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [sourceType, setSourceType] = useState('market_api');
|
||||
const [sourceName, setSourceName] = useState('');
|
||||
const [credibility, setCredibility] = useState(0.5);
|
||||
const mutation = useCreateSource(companyId);
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button onClick={() => setOpen(true)} className="rounded-md bg-brand-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-700">
|
||||
Add Source
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
mutation.mutate(
|
||||
{ source_type: sourceType, source_name: sourceName, credibility_score: credibility },
|
||||
{ onSuccess: () => { setOpen(false); setSourceName(''); } },
|
||||
);
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500" htmlFor="source-type">Type</label>
|
||||
<select
|
||||
id="source-type"
|
||||
value={sourceType}
|
||||
onChange={(e) => setSourceType(e.target.value)}
|
||||
className="w-full rounded-md border border-surface-700 bg-surface-900 px-2 py-1.5 text-sm text-gray-200"
|
||||
>
|
||||
{['market_api', 'news_api', 'filings_api', 'web_scrape', 'broker'].map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500" htmlFor="source-name">Name</label>
|
||||
<input
|
||||
id="source-name"
|
||||
type="text"
|
||||
value={sourceName}
|
||||
onChange={(e) => setSourceName(e.target.value)}
|
||||
className="w-full rounded-md border border-surface-700 bg-surface-900 px-2 py-1.5 text-sm text-gray-200 placeholder-gray-500"
|
||||
placeholder="Source name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500" htmlFor="credibility">Credibility: {(credibility * 100).toFixed(0)}%</label>
|
||||
<input
|
||||
id="credibility"
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={credibility}
|
||||
onChange={(e) => setCredibility(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" disabled={mutation.isPending} className="rounded-md bg-brand-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50">
|
||||
Save
|
||||
</button>
|
||||
<button type="button" onClick={() => setOpen(false)} className="rounded-md border border-surface-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-surface-800">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user