Build the agent-native compute CRM platform
CI / verify (push) Successful in 3m6s

This commit is contained in:
2026-08-13 01:39:01 -07:00
parent bfd2f8d95a
commit 853bde2265
160 changed files with 61812 additions and 483 deletions
+56 -94
View File
@@ -1,111 +1,73 @@
/**
* 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 type { ColumnDef } from '@tanstack/react-table';
import type { PermissionGrant } from '@pig/core';
import { Pencil, Plus, UserPlus } from 'lucide-react';
import { AccountSheet, ContactSheet, type AccountRecord, type ContactRecord, type ContactRow } from '@/components/RecordSheets';
import { DataTable, DataTableColumnHeader } from '@/components/DataTable';
import { Badge, Button, ConfidenceBadge, Skeleton } from '@/components/ui';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { get, relativeTime } from '@/lib/api';
import { Badge, ConfidenceBadge, EmptyState, Input, Skeleton } from '@/components/ui';
import { can } from '@/lib/permissions';
import { usePageTitle } from '@/lib/title';
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;
}
interface Me { permissions: PermissionGrant[] }
export function Accounts() {
usePageTitle('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}`);
},
const [view, setView] = useState<'accounts' | 'contacts'>('accounts');
const [accountSheet, setAccountSheet] = useState<{ open: boolean; record?: AccountRecord }>({ open: false });
const [contactSheet, setContactSheet] = useState<{ open: boolean; record?: ContactRecord; accountId?: string }>({ open: false });
const { data: me } = useQuery({ queryKey: ['me'], queryFn: () => get<Me>('/api/me') });
const { data: accountData, isLoading: accountsLoading } = useQuery({
queryKey: ['accounts', side],
queryFn: () => get<AccountRecord[]>(`/api/accounts${side === 'all' ? '' : `?side=${side}`}`),
});
const { data: contactData, isLoading: contactsLoading } = useQuery({
queryKey: ['contacts', 'table'],
queryFn: () => get<ContactRow[]>('/api/contacts'),
});
const canDemand = can(me, 'deal:write', 'demand');
const canSupply = can(me, 'deal:write', 'supply');
const canAny = canDemand || canSupply;
const canAccount = (account: AccountRecord) => account.side === 'both' ? canAny : account.side === 'demand' ? canDemand : canSupply;
const accountColumns: ColumnDef<AccountRecord>[] = [
{ id: 'account', accessorFn: (account) => `${account.name} ${account.domain ?? ''}`, header: ({ column }) => <DataTableColumnHeader column={column} title="Account" />, cell: ({ row }) => <div className="min-w-0 max-w-xs"><p className="truncate font-medium">{row.original.name}</p>{row.original.domain ? <p className="truncate text-xs text-muted">{row.original.domain}</p> : null}</div> },
{ accessorKey: 'side', header: ({ column }) => <DataTableColumnHeader column={column} title="Side" />, cell: ({ row }) => <Badge tone={row.original.side === 'supply' ? 'info' : row.original.side === 'both' ? 'accent' : 'neutral'}>{row.original.side}</Badge> },
{ id: 'type', accessorFn: (account) => account.supplierType ?? account.customerSegment ?? '', header: ({ column }) => <DataTableColumnHeader column={column} title="Type" />, cell: ({ row }) => { const type = row.original.supplierType ?? row.original.customerSegment; return type ? <span className="capitalize">{type.replace(/_/g, ' ')}</span> : '—'; } },
{ accessorKey: 'country', header: ({ column }) => <DataTableColumnHeader column={column} title="Country" />, cell: ({ row }) => row.original.country ?? '—' },
{ accessorKey: 'confidence', header: ({ column }) => <DataTableColumnHeader column={column} title="Confidence" />, cell: ({ row }) => row.original.confidence === 'confirmed' ? <span className="text-sm text-muted">Confirmed</span> : <ConfidenceBadge confidence={row.original.confidence} /> },
{ accessorKey: 'lastActivityAt', header: ({ column }) => <DataTableColumnHeader column={column} title="Last activity" />, cell: ({ row }) => row.original.lastActivityAt ? relativeTime(row.original.lastActivityAt) : '—' },
{ id: 'actions', enableHiding: false, enableSorting: false, header: 'Actions', cell: ({ row }) => <div className="flex justify-end gap-1"><Button size="icon" variant="ghost" title="Add contact" disabled={!canAccount(row.original)} onClick={() => setContactSheet({ open: true, accountId: row.original.id })}><UserPlus aria-hidden /><span className="sr-only">Add contact to {row.original.name}</span></Button><Button size="icon" variant="ghost" title="Edit account" disabled={!canAccount(row.original)} onClick={() => setAccountSheet({ open: true, record: row.original })}><Pencil aria-hidden /><span className="sr-only">Edit {row.original.name}</span></Button></div> },
];
const contactColumns: ColumnDef<ContactRow>[] = [
{ id: 'contact', accessorFn: (row) => `${row.contact.fullName} ${row.contact.email ?? ''}`, header: ({ column }) => <DataTableColumnHeader column={column} title="Contact" />, cell: ({ row }) => <div className="min-w-0 max-w-xs"><p className="truncate font-medium">{row.original.contact.fullName}</p>{row.original.contact.email ? <p className="truncate text-xs text-muted">{row.original.contact.email}</p> : <p className="text-xs text-muted">No email recorded</p>}</div> },
{ accessorKey: 'accountName', header: ({ column }) => <DataTableColumnHeader column={column} title="Account" />, cell: ({ row }) => row.original.accountName ?? 'Unassigned' },
{ id: 'title', accessorFn: (row) => row.contact.title ?? '', header: ({ column }) => <DataTableColumnHeader column={column} title="Title" />, cell: ({ row }) => row.original.contact.title ?? '—' },
{ id: 'affiliation', accessorFn: (row) => row.contact.affiliation, header: ({ column }) => <DataTableColumnHeader column={column} title="Affiliation" />, cell: ({ row }) => <span className="capitalize">{row.original.contact.affiliation.replace(/_/g, ' ')}</span> },
{ id: 'confidence', accessorFn: (row) => row.contact.confidence, header: ({ column }) => <DataTableColumnHeader column={column} title="Confidence" />, cell: ({ row }) => row.original.contact.confidence === 'confirmed' ? <span className="text-sm text-muted">Confirmed</span> : <ConfidenceBadge confidence={row.original.contact.confidence} /> },
{ id: 'lastActivityAt', accessorFn: (row) => row.contact.lastActivityAt ?? '', header: ({ column }) => <DataTableColumnHeader column={column} title="Last activity" />, cell: ({ row }) => row.original.contact.lastActivityAt ? relativeTime(row.original.contact.lastActivityAt) : '—' },
{ id: 'actions', enableHiding: false, enableSorting: false, header: 'Actions', cell: ({ row }) => { const allowed = row.original.accountSide === 'both' ? canAny : row.original.accountSide === 'demand' ? canDemand : row.original.accountSide === 'supply' ? canSupply : false; return <div className="flex justify-end"><Button size="icon" variant="ghost" title="Edit contact" disabled={!allowed} onClick={() => setContactSheet({ open: true, record: row.original.contact })}><Pencil aria-hidden /><span className="sr-only">Edit {row.original.contact.fullName}</span></Button></div>; } },
];
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>
<div className="flex flex-col gap-5">
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div><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 people who make each relationship real.</p></div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button variant="outline" disabled={!canAny} onClick={() => setContactSheet({ open: true })}><UserPlus aria-hidden />New contact</Button>
<Button variant="primary" disabled={!canAny} onClick={() => setAccountSheet({ open: true })}><Plus aria-hidden />New account</Button>
</div>
</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 className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Tabs value={view} onValueChange={(value) => setView(value as 'accounts' | 'contacts')}><TabsList><TabsTrigger value="accounts">Accounts</TabsTrigger><TabsTrigger value="contacts">Contacts</TabsTrigger></TabsList></Tabs>
{view === 'accounts' ? <div className="inline-flex w-full rounded-lg bg-surface-2 p-1 sm:w-auto">{(['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> : null}
</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>
)}
{view === 'accounts' ? accountsLoading ? <Skeleton className="h-64" /> : <DataTable columns={accountColumns} data={accountData ?? []} filterColumn="account" filterPlaceholder="Search account names or domains" emptyMessage="No accounts found." /> : contactsLoading ? <Skeleton className="h-64" /> : <DataTable columns={contactColumns} data={contactData ?? []} filterColumn="contact" filterPlaceholder="Search contact names or email" emptyMessage="No contacts found." />}
<AccountSheet open={accountSheet.open} onOpenChange={(open) => setAccountSheet((state) => ({ ...state, open }))} record={accountSheet.record} identity={me} />
<ContactSheet open={contactSheet.open} onOpenChange={(open) => setContactSheet((state) => ({ ...state, open }))} record={contactSheet.record} defaultAccountId={contactSheet.accountId} identity={me} />
</div>
);
}