mirror of
https://github.com/hanzoai/sign.git
synced 2026-10-30 14:57:59 +00:00
react-table went from 8 to 9 with nothing migrated. 9 has no useReactTable and no getCoreRowModel, so the one component every list in the app renders through could not be imported, and ColumnDef takes the table's features as its first type argument, so all 42 column lists stopped typing their rows: 217 of the 498 errors in the app's typecheck. DataTable now names the four features it uses — paging, row selection, column visibility, column sizing — and calls useTable. DataTableColumnDef is ColumnDef over those features, so a column list still says only what a row is. The second type parameter goes: 9's table takes columns of unknown value, and no caller ever passed one. Two things the types turned up. `enableSorting: false` sat on two select columns of a table that has never sorted; without the sorting feature the option does not exist, and neither does the sorting. The organisation insights table erased its three row types through `as unknown as` to feed one DataTable; it renders one per view, typed, and the casts are gone. The server rejects any other view before the component renders. data-table.test.tsx renders the table and drives the instance: headers and cells, a hidden column, a sized one, one-based paging read from the props and reported back, selection by the caller's row id. Against the old file it fails 7 of 7 at the import. Co-authored-by: Hanzo Dev <[email protected]>
283 lines
9.1 KiB
TypeScript
283 lines
9.1 KiB
TypeScript
import { useTransition } from 'react';
|
|
|
|
import { msg } from '@lingui/core/macro';
|
|
import { useLingui } from '@lingui/react';
|
|
import { Building2, Loader, TrendingUp, Users } from 'lucide-react';
|
|
import { Link } from 'react-router';
|
|
import { useNavigation } from 'react-router';
|
|
import { match } from 'ts-pattern';
|
|
|
|
import { useUpdateSearchParams } from '@hanzo/esign-lib/client-only/hooks/use-update-search-params';
|
|
import type { OrganisationDetailedInsights } from '@hanzo/esign-lib/server-only/admin/get-organisation-detailed-insights';
|
|
import type { DateRange } from '@hanzo/esign-lib/types/search-params';
|
|
import type { ExtendedDocumentStatus } from '@hanzo/esign-prisma/types/extended-document-status';
|
|
import { Button } from '@hanzo/esign-ui/primitives/button';
|
|
import type { DataTableColumnDef } from '@hanzo/esign-ui/primitives/data-table';
|
|
import { DataTable } from '@hanzo/esign-ui/primitives/data-table';
|
|
import { DataTablePagination } from '@hanzo/esign-ui/primitives/data-table-pagination';
|
|
|
|
import { DateRangeFilter } from '~/components/filters/date-range-filter';
|
|
import { DocumentStatus } from '~/components/general/document/document-status';
|
|
|
|
type OrganisationInsightsTableProps = {
|
|
insights: OrganisationDetailedInsights;
|
|
page: number;
|
|
perPage: number;
|
|
dateRange: DateRange;
|
|
view: 'teams' | 'users' | 'documents';
|
|
};
|
|
|
|
export const OrganisationInsightsTable = ({
|
|
insights,
|
|
page,
|
|
perPage,
|
|
dateRange,
|
|
view,
|
|
}: OrganisationInsightsTableProps) => {
|
|
const { _, i18n } = useLingui();
|
|
const [isPending, startTransition] = useTransition();
|
|
const navigation = useNavigation();
|
|
const updateSearchParams = useUpdateSearchParams();
|
|
|
|
const isLoading = isPending || navigation.state === 'loading';
|
|
|
|
const onPaginationChange = (newPage: number, newPerPage: number) => {
|
|
startTransition(() => {
|
|
updateSearchParams({
|
|
page: newPage,
|
|
perPage: newPerPage,
|
|
});
|
|
});
|
|
};
|
|
|
|
const handleViewChange = (newView: 'teams' | 'users' | 'documents') => {
|
|
startTransition(() => {
|
|
updateSearchParams({
|
|
view: newView,
|
|
page: 1,
|
|
});
|
|
});
|
|
};
|
|
|
|
const teamsColumns = [
|
|
{
|
|
header: _(msg`Team Name`),
|
|
accessorKey: 'name',
|
|
cell: ({ row }) => <span className="block max-w-full truncate">{row.getValue('name')}</span>,
|
|
size: 240,
|
|
},
|
|
{
|
|
header: _(msg`Members`),
|
|
accessorKey: 'memberCount',
|
|
cell: ({ row }) => Number(row.getValue('memberCount')),
|
|
size: 120,
|
|
},
|
|
{
|
|
header: _(msg`Documents`),
|
|
accessorKey: 'documentCount',
|
|
cell: ({ row }) => Number(row.getValue('documentCount')),
|
|
size: 140,
|
|
},
|
|
{
|
|
header: _(msg`Created`),
|
|
accessorKey: 'createdAt',
|
|
cell: ({ row }) => i18n.date(new Date(row.getValue('createdAt'))),
|
|
size: 160,
|
|
},
|
|
] satisfies DataTableColumnDef<(typeof insights.teams)[number]>[];
|
|
|
|
const usersColumns = [
|
|
{
|
|
header: () => <span className="whitespace-nowrap">{_(msg`Name`)}</span>,
|
|
accessorKey: 'name',
|
|
cell: ({ row }) => (
|
|
<Link
|
|
className="block max-w-full truncate hover:underline"
|
|
to={`/admin/users/${row.original.id}`}
|
|
>
|
|
{(row.getValue('name') as string) || (row.getValue('email') as string)}
|
|
</Link>
|
|
),
|
|
size: 220,
|
|
},
|
|
{
|
|
header: () => <span className="whitespace-nowrap">{_(msg`Email`)}</span>,
|
|
accessorKey: 'email',
|
|
cell: ({ row }) => <span className="block max-w-full truncate">{row.getValue('email')}</span>,
|
|
size: 260,
|
|
},
|
|
{
|
|
header: () => <span className="whitespace-nowrap">{_(msg`Documents Created`)}</span>,
|
|
accessorKey: 'documentCount',
|
|
cell: ({ row }) => Number(row.getValue('documentCount')),
|
|
size: 180,
|
|
},
|
|
{
|
|
header: () => <span className="whitespace-nowrap">{_(msg`Documents Completed`)}</span>,
|
|
accessorKey: 'signedDocumentCount',
|
|
cell: ({ row }) => Number(row.getValue('signedDocumentCount')),
|
|
size: 180,
|
|
},
|
|
{
|
|
header: () => <span className="whitespace-nowrap">{_(msg`Joined`)}</span>,
|
|
accessorKey: 'createdAt',
|
|
cell: ({ row }) => i18n.date(new Date(row.getValue('createdAt'))),
|
|
size: 160,
|
|
},
|
|
] satisfies DataTableColumnDef<(typeof insights.users)[number]>[];
|
|
|
|
const documentsColumns = [
|
|
{
|
|
header: () => <span className="whitespace-nowrap">{_(msg`Title`)}</span>,
|
|
accessorKey: 'title',
|
|
cell: ({ row }) => (
|
|
<Link
|
|
className="block max-w-[200px] truncate hover:underline"
|
|
to={`/admin/documents/${row.original.id}`}
|
|
title={row.getValue('title') as string}
|
|
>
|
|
{row.getValue('title')}
|
|
</Link>
|
|
),
|
|
size: 200,
|
|
},
|
|
{
|
|
header: () => <span className="whitespace-nowrap">{_(msg`Status`)}</span>,
|
|
accessorKey: 'status',
|
|
cell: ({ row }) => (
|
|
<DocumentStatus status={row.getValue('status') as ExtendedDocumentStatus} />
|
|
),
|
|
size: 120,
|
|
},
|
|
{
|
|
header: () => <span className="whitespace-nowrap">{_(msg`Team`)}</span>,
|
|
accessorKey: 'teamName',
|
|
cell: ({ row }) => (
|
|
<span className="block max-w-[150px] truncate" title={row.getValue('teamName') as string}>
|
|
{row.getValue('teamName')}
|
|
</span>
|
|
),
|
|
size: 150,
|
|
},
|
|
{
|
|
header: () => <span className="whitespace-nowrap">{_(msg`Created`)}</span>,
|
|
accessorKey: 'createdAt',
|
|
cell: ({ row }) => i18n.date(new Date(row.getValue('createdAt'))),
|
|
size: 140,
|
|
},
|
|
{
|
|
header: () => <span className="whitespace-nowrap">{_(msg`Completed`)}</span>,
|
|
accessorKey: 'completedAt',
|
|
cell: ({ row }) => {
|
|
const completedAt = row.getValue('completedAt') as Date | null;
|
|
|
|
return completedAt ? i18n.date(new Date(completedAt)) : '-';
|
|
},
|
|
size: 140,
|
|
},
|
|
] satisfies DataTableColumnDef<(typeof insights.documents)[number]>[];
|
|
|
|
const tableProps = {
|
|
perPage,
|
|
currentPage: page,
|
|
totalPages: insights.totalPages,
|
|
onPaginationChange,
|
|
};
|
|
|
|
return (
|
|
<div className="relative">
|
|
{insights.summary && (
|
|
<div className="mb-6 grid grid-cols-2 gap-4 md:grid-cols-3">
|
|
<SummaryCard icon={Building2} title={_(msg`Teams`)} value={insights.summary.totalTeams} />
|
|
<SummaryCard icon={Users} title={_(msg`Members`)} value={insights.summary.totalMembers} />
|
|
<SummaryCard
|
|
icon={TrendingUp}
|
|
title={_(msg`Documents Completed`)}
|
|
value={insights.summary.volumeThisPeriod}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="mb-6 flex items-center justify-between">
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant={view === 'teams' ? 'default' : 'outline'}
|
|
onClick={() => handleViewChange('teams')}
|
|
disabled={isLoading}
|
|
>
|
|
{_(msg`Teams`)}
|
|
</Button>
|
|
<Button
|
|
variant={view === 'users' ? 'default' : 'outline'}
|
|
onClick={() => handleViewChange('users')}
|
|
disabled={isLoading}
|
|
>
|
|
{_(msg`Users`)}
|
|
</Button>
|
|
<Button
|
|
variant={view === 'documents' ? 'default' : 'outline'}
|
|
onClick={() => handleViewChange('documents')}
|
|
disabled={isLoading}
|
|
>
|
|
{_(msg`Documents`)}
|
|
</Button>
|
|
</div>
|
|
<DateRangeFilter currentRange={dateRange} />
|
|
</div>
|
|
|
|
<div className={view === 'documents' ? 'overflow-hidden' : undefined}>
|
|
{match(view)
|
|
.with('teams', () => (
|
|
<DataTable columns={teamsColumns} data={insights.teams} {...tableProps}>
|
|
{(table) => (
|
|
<DataTablePagination additionalInformation="VisibleCount" table={table} />
|
|
)}
|
|
</DataTable>
|
|
))
|
|
.with('users', () => (
|
|
<DataTable columns={usersColumns} data={insights.users} {...tableProps}>
|
|
{(table) => (
|
|
<DataTablePagination additionalInformation="VisibleCount" table={table} />
|
|
)}
|
|
</DataTable>
|
|
))
|
|
.with('documents', () => (
|
|
<DataTable columns={documentsColumns} data={insights.documents} {...tableProps}>
|
|
{(table) => (
|
|
<DataTablePagination additionalInformation="VisibleCount" table={table} />
|
|
)}
|
|
</DataTable>
|
|
))
|
|
.exhaustive()}
|
|
</div>
|
|
|
|
{isLoading && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
|
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const SummaryCard = ({
|
|
icon: Icon,
|
|
title,
|
|
value,
|
|
subtitle,
|
|
}: {
|
|
icon: React.ComponentType<{ className?: string }>;
|
|
title: string;
|
|
value: number;
|
|
subtitle?: string;
|
|
}) => (
|
|
<div className="bg-card flex items-start gap-x-2 rounded-lg border px-4 py-3">
|
|
<Icon className="text-muted-foreground h-4 w-4 items-start" />
|
|
<div className="-mt-0.5 space-y-2">
|
|
<p className="text-muted-foreground text-sm font-medium">{title}</p>
|
|
<p className="text-2xl font-bold">{value}</p>
|
|
{subtitle && <p className="text-muted-foreground text-xs">{subtitle}</p>}
|
|
</div>
|
|
</div>
|
|
);
|