This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user