Add the web app, seed data, and user-selectable theming
apps/web — React, Vite, Tailwind, shadcn-idiom components. Mobile Safari is a first-class target, not an afterthought: - Two navigation treatments rather than one compromise. A bottom tab bar on phones, because the top of a large phone is out of thumb reach; a persistent sidebar from lg upward, so an iPad in portrait gets it too. - Safe-area insets throughout, so the tab bar clears the home indicator and the last row of a list is actually reachable. - Inputs are pinned to a 16px minimum, which is the correct fix for Safari zooming on focus. user-scalable=no is not used: it breaks pinch-zoom for everyone and recent iOS ignores it anyway. - The pipeline board becomes a stage picker on phones. An eight-column board scrolling horizontally on a 390px screen is technically responsive and practically useless. Theming: users pick an accent and the whole interface re-tints. Accent values live once, in @pig/core, and are written onto the root element at runtime — there is no CSS copy to drift from the TypeScript. Preferences are stored server-side so they follow a person between laptop and phone, mirrored into localStorage only so the pre-paint script can avoid a white flash. Status colours stay fixed regardless of accent: if "at risk" re-tinted to whatever someone picked, the signal would be gone. Seed data is public research, every record carrying a confidence grade and a source URL. No email addresses are seeded or inferred — none are published, and guessing them from a name and a domain is unreliable and rude. Authorship is not promoted to employment: contributors, residency participants and alumni are recorded as what the evidence actually shows, and a name that could not be sourced at all is listed as unresolved rather than invented. Three defects found and fixed by actually running it rather than assuming: 1. The seed was not idempotent. onConflictDoNothing() with no target is a no-op without a matching unique constraint, so a second run duplicated 27 contacts. There is deliberately no unique index on (account, name) — two people at one company can share a name — so idempotency is enforced in the seed instead of by bending the schema. 2. /capacity scrolled sideways on a phone. Grid items default to min-width:auto and `truncate` sets nowrap, so a long title became unshrinkable content and widened the track. Fixed with min-w-0 on every truncating grid child. 3. The idle-capacity alert silently failed to fire at exactly 80% utilisation, losing a float comparison against a 0.2 threshold. Moved to 0.15, which is also a more sensible line for "worth attention". The worked example is tuned to teach rather than to flatter: 70% sold at a 53% markup lands at +6.7% margin with 20% still idle, so both the healthy number and the alert are visible. Drop the sold share to 55% and the same block goes underwater — that sensitivity is the argument for the product. Verified in a real browser at 393px and 1440px, light and dark: zero horizontal overflow on every route, zero console errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Accounts — suppliers and customers in one list, filtered by side.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Building2 } from 'lucide-react';
|
||||
import { get, relativeTime } from '@/lib/api';
|
||||
import { Badge, ConfidenceBadge, EmptyState, Input, Skeleton } from '@/components/ui';
|
||||
|
||||
interface Account {
|
||||
id: string;
|
||||
name: string;
|
||||
domain: string | null;
|
||||
side: string;
|
||||
supplierType: string | null;
|
||||
customerSegment: string | null;
|
||||
country: string | null;
|
||||
confidence: string;
|
||||
lastActivityAt: string | null;
|
||||
}
|
||||
|
||||
export function Accounts() {
|
||||
const [side, setSide] = useState<'all' | 'supply' | 'demand'>('all');
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['accounts', side, query],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
if (side !== 'all') params.set('side', side);
|
||||
if (query) params.set('q', query);
|
||||
return get<Account[]>(`/api/accounts?${params}`);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<header>
|
||||
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Accounts</h1>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
Providers we buy from, customers we sell to — and the ones who are both.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search accounts"
|
||||
type="search"
|
||||
className="sm:max-w-xs"
|
||||
/>
|
||||
<div className="inline-flex rounded-lg bg-surface-2 p-1">
|
||||
{(['all', 'supply', 'demand'] as const).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setSide(value)}
|
||||
aria-pressed={side === value}
|
||||
className={[
|
||||
'tap flex-1 rounded-md px-4 text-sm font-medium capitalize transition-colors',
|
||||
side === value ? 'bg-surface text-fg shadow-sm' : 'text-muted',
|
||||
].join(' ')}
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-64" />
|
||||
) : !data || data.length === 0 ? (
|
||||
<EmptyState icon={<Building2 className="h-8 w-8" />} title="No accounts found" />
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{data.map((account) => (
|
||||
<article key={account.id} className="card min-w-0 p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{account.name}</p>
|
||||
{account.domain ? (
|
||||
<p className="truncate text-xs text-muted">{account.domain}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<ConfidenceBadge confidence={account.confidence} />
|
||||
</div>
|
||||
<div className="mt-2.5 flex flex-wrap gap-1.5">
|
||||
<Badge tone={account.side === 'supply' ? 'info' : account.side === 'both' ? 'accent' : 'neutral'}>
|
||||
{account.side}
|
||||
</Badge>
|
||||
{account.supplierType ? (
|
||||
<Badge tone="neutral">{account.supplierType.replace(/_/g, ' ')}</Badge>
|
||||
) : null}
|
||||
{account.customerSegment ? (
|
||||
<Badge tone="neutral">{account.customerSegment.replace(/_/g, ' ')}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{account.lastActivityAt ? (
|
||||
<p className="mt-2 text-[11px] text-muted">
|
||||
Active {relativeTime(account.lastActivityAt)}
|
||||
</p>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user