Loading portal-gui/src/components/federation/confirm-unsubscribe-zone-dialog.tsx 0 → 100644 +74 −0 Changes for portal-gui/src/components/federation/confirm-unsubscribe-zone-dialog.tsx: 74 added lines, 0 removed lines. Original line number Diff line number Diff line import { useState } from 'react'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; import type { PartnerZone } from '@/types/federation'; export function ConfirmUnsubscribeZoneDialog({ zone, onOpenChange, onConfirm, }: { zone: PartnerZone; onOpenChange: (open: boolean) => void; onConfirm: () => Promise<void>; }) { const [pending, setPending] = useState(false); const [error, setError] = useState<string | null>(null); const handleConfirm = async () => { setPending(true); setError(null); try { await onConfirm(); onOpenChange(false); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to unsubscribe.'); } finally { setPending(false); } }; return ( <AlertDialog open onOpenChange={(open) => { if (!pending) onOpenChange(open); }} > <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle>Unsubscribe from zone?</AlertDialogTitle> <AlertDialogDescription> The partner releases the resources it reserved for us in " {zone.geographyDetails}". </AlertDialogDescription> </AlertDialogHeader> {error && <p className="text-sm text-destructive">{error}</p>} <AlertDialogFooter> <AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel> <AlertDialogAction onClick={(event) => { event.preventDefault(); void handleConfirm(); }} disabled={pending} className="text-destructive-foreground bg-destructive hover:bg-destructive/90" > {pending ? 'Unsubscribing…' : 'Unsubscribe'} </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> ); } portal-gui/src/components/federation/partner-columns.tsx 0 → 100644 +103 −0 Changes for portal-gui/src/components/federation/partner-columns.tsx: 103 added lines, 0 removed lines. Original line number Diff line number Diff line import type { ColumnDef } from '@tanstack/react-table'; import { EllipsisVerticalIcon } from 'lucide-react'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Button } from '@/components/ui/button'; import type { Partner } from '@/types/federation'; import { PartnerStatusBadge } from './partner-status-badge'; export function getPartnerColumns( onDetails: (partner: Partner) => void, onZones: (partner: Partner) => void, ): ColumnDef<Partner>[] { return [ { id: 'drag', header: () => null, enableHiding: false, size: 40, }, { id: 'name', accessorKey: 'name', header: 'Name', cell: ({ row }) => { const partner = row.original; return ( <> <button type="button" onClick={() => onDetails(partner)} className="block max-w-full truncate text-left font-medium hover:underline" title={partner.name} > {partner.name} </button> <p className="mt-1 font-mono text-xs text-muted-foreground"> {partner.mcc_mnc} </p> </> ); }, size: 200, }, { id: 'status', accessorKey: 'status', header: 'Registration', filterFn: 'equalsString', cell: ({ row }) => <PartnerStatusBadge status={row.original.status} />, size: 120, }, { id: 'endpoint', header: 'EWBI endpoint', cell: ({ row }) => ( <span className="block max-w-[320px] truncate font-mono text-xs" title={row.original.base_url ?? undefined} > {row.original.base_url ?? '—'} </span> ), size: 320, }, { id: 'credentials', header: 'Outbound credentials', cell: ({ row }) => ( <span className="text-muted-foreground"> {row.original.our_client_id ? 'Configured' : 'Not set'} </span> ), size: 160, }, { id: 'actions', enableHiding: false, cell: ({ row }) => ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" size="icon"> <EllipsisVerticalIcon className="h-4 w-4" /> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> <DropdownMenuItem onClick={() => onDetails(row.original)}> Details </DropdownMenuItem> <DropdownMenuItem onClick={() => onZones(row.original)}> Zones </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> ), size: 70, }, ]; } portal-gui/src/components/federation/partner-details-dialog.tsx 0 → 100644 +143 −0 Changes for portal-gui/src/components/federation/partner-details-dialog.tsx: 143 added lines, 0 removed lines. Original line number Diff line number Diff line import type { ReactNode } from 'react'; import { Loader2 } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { usePartnerFederation } from '@/hooks/usePartnerFederation'; import type { MobileNetworkIds, Partner, ServiceEndpoint, } from '@/types/federation'; import { PartnerStatusBadge } from './partner-status-badge'; function endpoint(value?: ServiceEndpoint): string { if (!value) return '—'; const host = value.fqdn ?? value.ipv4Addresses?.[0] ?? value.ipv6Addresses?.[0] ?? '?'; return `${host}:${value.port}`; } function networks(value?: MobileNetworkIds): string { if (!value?.mcc && !value?.mncs?.length) return '—'; return `MCC ${value.mcc ?? '?'} · MNC ${value.mncs?.join(', ') ?? '?'}`; } function Fields({ fields }: { fields: Record<string, ReactNode> }) { return ( <dl className="grid gap-3 sm:grid-cols-2"> {Object.entries(fields).map(([label, value]) => ( <div key={label} className="min-w-0"> <dt className="text-muted-foreground">{label}</dt> <dd className="font-mono text-xs break-all">{value}</dd> </div> ))} </dl> ); } export function PartnerDetailsDialog({ partner, onClose, }: { partner: Partner; onClose: () => void; }) { const { federation, loading, error } = usePartnerFederation(partner.id); return ( <Dialog open onOpenChange={(open) => { if (!open) onClose(); }} > <DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-xl"> <DialogHeader> <DialogTitle className="break-words">{partner.name}</DialogTitle> <DialogDescription> Partner registration and our federation with it. </DialogDescription> </DialogHeader> <div className="space-y-5 text-sm break-words"> <section className="space-y-3"> <h3 className="font-semibold">Registration</h3> <Fields fields={{ Registration: <PartnerStatusBadge status={partner.status} />, 'Partner ID': partner.id, 'MCC-MNC': partner.mcc_mnc, 'EWBI endpoint': partner.base_url ?? '—', 'Their client ID': partner.oauth2_client_id, 'Our client ID': partner.our_client_id ?? '—', 'Token endpoint': partner.token_endpoint ?? '—', 'Secret file': partner.our_client_secret_ref ?? '—', }} /> </section> <section className="space-y-3"> <h3 className="font-semibold">Federation</h3> {loading ? ( <p className="flex items-center gap-2 text-muted-foreground"> <Loader2 className="h-4 w-4 animate-spin" /> Loading federation… </p> ) : error ? ( <p className="text-destructive">{error}</p> ) : federation ? ( <div className="rounded border p-3"> <Fields fields={{ 'Context ID': federation.contextId, 'Edge discovery': endpoint( federation.details.edgeDiscoveryServiceEndPoint, ), 'LCM service': endpoint( federation.details.lcmServiceEndPoint, ), 'Platform capabilities': federation.details.platformCaps ?.length ? ( <span className="flex flex-wrap gap-1"> {federation.details.platformCaps.map((capability) => ( <Badge key={capability} variant="outline"> {capability} </Badge> ))} </span> ) : ( '—' ), 'Allowed networks': networks( federation.details.allowedMobileNetworkIds, ), }} /> </div> ) : ( <p className="text-muted-foreground"> {partner.status === 'active' ? 'Not federated yet.' : `Not federated. The partner is ${partner.status}; federation needs it active.`} </p> )} </section> </div> <div className="flex justify-end"> <Button variant="outline" onClick={onClose}> Close </Button> </div> </DialogContent> </Dialog> ); } portal-gui/src/components/federation/partner-status-badge.tsx 0 → 100644 +13 −0 Changes for portal-gui/src/components/federation/partner-status-badge.tsx: 13 added lines, 0 removed lines. Original line number Diff line number Diff line import { Badge } from '@/components/ui/badge'; import type { PartnerStatus } from '@/types/federation'; const VARIANTS = { active: 'default', pending: 'secondary', suspended: 'outline', decommissioned: 'destructive', } as const satisfies Record<PartnerStatus, string>; export function PartnerStatusBadge({ status }: { status: PartnerStatus }) { return <Badge variant={VARIANTS[status]}>{status}</Badge>; } portal-gui/src/components/federation/partner-table.tsx 0 → 100644 +155 −0 Changes for portal-gui/src/components/federation/partner-table.tsx: 155 added lines, 0 removed lines. Original line number Diff line number Diff line import React from 'react'; import { closestCenter, DndContext, KeyboardSensor, MouseSensor, TouchSensor, useSensor, useSensors, type DragEndEvent, } from '@dnd-kit/core'; import { restrictToVerticalAxis } from '@dnd-kit/modifiers'; import { arrayMove, SortableContext, verticalListSortingStrategy, } from '@dnd-kit/sortable'; import { flexRender, getCoreRowModel, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, useReactTable, type ColumnFiltersState, type SortingState, type VisibilityState, } from '@tanstack/react-table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { TablePagination } from '../data-table/table-pagination'; import { DraggableRow } from '../data-table/dragable-row'; import type { PartnerTableProps } from '@/types/federation'; import { getPartnerColumns } from './partner-columns'; import { PartnerToolbar } from './partner-toolbar'; export function PartnerTable({ partners, onDetails, onZones, onRefresh, }: PartnerTableProps) { const [data, setData] = React.useState(partners); const [source, setSource] = React.useState(partners); if (partners !== source) { setSource(partners); setData(partners); } const [sorting, setSorting] = React.useState<SortingState>([]); const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({}); const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>( [], ); const sortableId = React.useId(); const sensors = useSensors( useSensor(MouseSensor, {}), useSensor(TouchSensor, {}), useSensor(KeyboardSensor, {}), ); const columns = React.useMemo( () => getPartnerColumns(onDetails, onZones), [onDetails, onZones], ); const table = useReactTable({ data, columns, getRowId: (row) => row.id, state: { sorting, columnVisibility, columnFilters }, onSortingChange: setSorting, onColumnFiltersChange: setColumnFilters, onColumnVisibilityChange: setColumnVisibility, getCoreRowModel: getCoreRowModel(), getFilteredRowModel: getFilteredRowModel(), getPaginationRowModel: getPaginationRowModel(), getSortedRowModel: getSortedRowModel(), }); function handleDragEnd(event: DragEndEvent) { const { active, over } = event; if (!over || active.id === over.id) return; setData((current) => { const oldIndex = current.findIndex((partner) => partner.id === active.id); const newIndex = current.findIndex((partner) => partner.id === over.id); if (oldIndex === -1 || newIndex === -1) return current; return arrayMove(current, oldIndex, newIndex); }); } return ( <> <PartnerToolbar table={table} onRefresh={onRefresh} /> <div className="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6"> <div className="overflow-x-auto rounded-lg border"> <DndContext collisionDetection={closestCenter} modifiers={[restrictToVerticalAxis]} onDragEnd={handleDragEnd} sensors={sensors} id={sortableId} > <Table className="min-w-[760px]"> <TableHeader className="sticky top-0 z-10 bg-muted"> {table.getHeaderGroups().map((headerGroup) => ( <TableRow key={headerGroup.id}> {headerGroup.headers.map((header) => ( <TableHead key={header.id} colSpan={header.colSpan}> {header.isPlaceholder ? null : flexRender( header.column.columnDef.header, header.getContext(), )} </TableHead> ))} </TableRow> ))} </TableHeader> <TableBody className="**:data-[slot=table-cell]:first:w-8"> {table.getRowModel().rows.length ? ( <SortableContext items={data.map((partner) => partner.id)} strategy={verticalListSortingStrategy} > {table.getRowModel().rows.map((row) => ( <DraggableRow key={row.id} row={row} /> ))} </SortableContext> ) : ( <TableRow> <TableCell colSpan={columns.length} className="h-24 text-center" > No results. </TableCell> </TableRow> )} </TableBody> </Table> </DndContext> </div> <TablePagination table={table} /> </div> </> ); } Loading
portal-gui/src/components/federation/confirm-unsubscribe-zone-dialog.tsx 0 → 100644 +74 −0 Changes for portal-gui/src/components/federation/confirm-unsubscribe-zone-dialog.tsx: 74 added lines, 0 removed lines. Original line number Diff line number Diff line import { useState } from 'react'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; import type { PartnerZone } from '@/types/federation'; export function ConfirmUnsubscribeZoneDialog({ zone, onOpenChange, onConfirm, }: { zone: PartnerZone; onOpenChange: (open: boolean) => void; onConfirm: () => Promise<void>; }) { const [pending, setPending] = useState(false); const [error, setError] = useState<string | null>(null); const handleConfirm = async () => { setPending(true); setError(null); try { await onConfirm(); onOpenChange(false); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to unsubscribe.'); } finally { setPending(false); } }; return ( <AlertDialog open onOpenChange={(open) => { if (!pending) onOpenChange(open); }} > <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle>Unsubscribe from zone?</AlertDialogTitle> <AlertDialogDescription> The partner releases the resources it reserved for us in " {zone.geographyDetails}". </AlertDialogDescription> </AlertDialogHeader> {error && <p className="text-sm text-destructive">{error}</p>} <AlertDialogFooter> <AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel> <AlertDialogAction onClick={(event) => { event.preventDefault(); void handleConfirm(); }} disabled={pending} className="text-destructive-foreground bg-destructive hover:bg-destructive/90" > {pending ? 'Unsubscribing…' : 'Unsubscribe'} </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> ); }
portal-gui/src/components/federation/partner-columns.tsx 0 → 100644 +103 −0 Changes for portal-gui/src/components/federation/partner-columns.tsx: 103 added lines, 0 removed lines. Original line number Diff line number Diff line import type { ColumnDef } from '@tanstack/react-table'; import { EllipsisVerticalIcon } from 'lucide-react'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Button } from '@/components/ui/button'; import type { Partner } from '@/types/federation'; import { PartnerStatusBadge } from './partner-status-badge'; export function getPartnerColumns( onDetails: (partner: Partner) => void, onZones: (partner: Partner) => void, ): ColumnDef<Partner>[] { return [ { id: 'drag', header: () => null, enableHiding: false, size: 40, }, { id: 'name', accessorKey: 'name', header: 'Name', cell: ({ row }) => { const partner = row.original; return ( <> <button type="button" onClick={() => onDetails(partner)} className="block max-w-full truncate text-left font-medium hover:underline" title={partner.name} > {partner.name} </button> <p className="mt-1 font-mono text-xs text-muted-foreground"> {partner.mcc_mnc} </p> </> ); }, size: 200, }, { id: 'status', accessorKey: 'status', header: 'Registration', filterFn: 'equalsString', cell: ({ row }) => <PartnerStatusBadge status={row.original.status} />, size: 120, }, { id: 'endpoint', header: 'EWBI endpoint', cell: ({ row }) => ( <span className="block max-w-[320px] truncate font-mono text-xs" title={row.original.base_url ?? undefined} > {row.original.base_url ?? '—'} </span> ), size: 320, }, { id: 'credentials', header: 'Outbound credentials', cell: ({ row }) => ( <span className="text-muted-foreground"> {row.original.our_client_id ? 'Configured' : 'Not set'} </span> ), size: 160, }, { id: 'actions', enableHiding: false, cell: ({ row }) => ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" size="icon"> <EllipsisVerticalIcon className="h-4 w-4" /> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> <DropdownMenuItem onClick={() => onDetails(row.original)}> Details </DropdownMenuItem> <DropdownMenuItem onClick={() => onZones(row.original)}> Zones </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> ), size: 70, }, ]; }
portal-gui/src/components/federation/partner-details-dialog.tsx 0 → 100644 +143 −0 Changes for portal-gui/src/components/federation/partner-details-dialog.tsx: 143 added lines, 0 removed lines. Original line number Diff line number Diff line import type { ReactNode } from 'react'; import { Loader2 } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { usePartnerFederation } from '@/hooks/usePartnerFederation'; import type { MobileNetworkIds, Partner, ServiceEndpoint, } from '@/types/federation'; import { PartnerStatusBadge } from './partner-status-badge'; function endpoint(value?: ServiceEndpoint): string { if (!value) return '—'; const host = value.fqdn ?? value.ipv4Addresses?.[0] ?? value.ipv6Addresses?.[0] ?? '?'; return `${host}:${value.port}`; } function networks(value?: MobileNetworkIds): string { if (!value?.mcc && !value?.mncs?.length) return '—'; return `MCC ${value.mcc ?? '?'} · MNC ${value.mncs?.join(', ') ?? '?'}`; } function Fields({ fields }: { fields: Record<string, ReactNode> }) { return ( <dl className="grid gap-3 sm:grid-cols-2"> {Object.entries(fields).map(([label, value]) => ( <div key={label} className="min-w-0"> <dt className="text-muted-foreground">{label}</dt> <dd className="font-mono text-xs break-all">{value}</dd> </div> ))} </dl> ); } export function PartnerDetailsDialog({ partner, onClose, }: { partner: Partner; onClose: () => void; }) { const { federation, loading, error } = usePartnerFederation(partner.id); return ( <Dialog open onOpenChange={(open) => { if (!open) onClose(); }} > <DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-xl"> <DialogHeader> <DialogTitle className="break-words">{partner.name}</DialogTitle> <DialogDescription> Partner registration and our federation with it. </DialogDescription> </DialogHeader> <div className="space-y-5 text-sm break-words"> <section className="space-y-3"> <h3 className="font-semibold">Registration</h3> <Fields fields={{ Registration: <PartnerStatusBadge status={partner.status} />, 'Partner ID': partner.id, 'MCC-MNC': partner.mcc_mnc, 'EWBI endpoint': partner.base_url ?? '—', 'Their client ID': partner.oauth2_client_id, 'Our client ID': partner.our_client_id ?? '—', 'Token endpoint': partner.token_endpoint ?? '—', 'Secret file': partner.our_client_secret_ref ?? '—', }} /> </section> <section className="space-y-3"> <h3 className="font-semibold">Federation</h3> {loading ? ( <p className="flex items-center gap-2 text-muted-foreground"> <Loader2 className="h-4 w-4 animate-spin" /> Loading federation… </p> ) : error ? ( <p className="text-destructive">{error}</p> ) : federation ? ( <div className="rounded border p-3"> <Fields fields={{ 'Context ID': federation.contextId, 'Edge discovery': endpoint( federation.details.edgeDiscoveryServiceEndPoint, ), 'LCM service': endpoint( federation.details.lcmServiceEndPoint, ), 'Platform capabilities': federation.details.platformCaps ?.length ? ( <span className="flex flex-wrap gap-1"> {federation.details.platformCaps.map((capability) => ( <Badge key={capability} variant="outline"> {capability} </Badge> ))} </span> ) : ( '—' ), 'Allowed networks': networks( federation.details.allowedMobileNetworkIds, ), }} /> </div> ) : ( <p className="text-muted-foreground"> {partner.status === 'active' ? 'Not federated yet.' : `Not federated. The partner is ${partner.status}; federation needs it active.`} </p> )} </section> </div> <div className="flex justify-end"> <Button variant="outline" onClick={onClose}> Close </Button> </div> </DialogContent> </Dialog> ); }
portal-gui/src/components/federation/partner-status-badge.tsx 0 → 100644 +13 −0 Changes for portal-gui/src/components/federation/partner-status-badge.tsx: 13 added lines, 0 removed lines. Original line number Diff line number Diff line import { Badge } from '@/components/ui/badge'; import type { PartnerStatus } from '@/types/federation'; const VARIANTS = { active: 'default', pending: 'secondary', suspended: 'outline', decommissioned: 'destructive', } as const satisfies Record<PartnerStatus, string>; export function PartnerStatusBadge({ status }: { status: PartnerStatus }) { return <Badge variant={VARIANTS[status]}>{status}</Badge>; }
portal-gui/src/components/federation/partner-table.tsx 0 → 100644 +155 −0 Changes for portal-gui/src/components/federation/partner-table.tsx: 155 added lines, 0 removed lines. Original line number Diff line number Diff line import React from 'react'; import { closestCenter, DndContext, KeyboardSensor, MouseSensor, TouchSensor, useSensor, useSensors, type DragEndEvent, } from '@dnd-kit/core'; import { restrictToVerticalAxis } from '@dnd-kit/modifiers'; import { arrayMove, SortableContext, verticalListSortingStrategy, } from '@dnd-kit/sortable'; import { flexRender, getCoreRowModel, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, useReactTable, type ColumnFiltersState, type SortingState, type VisibilityState, } from '@tanstack/react-table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { TablePagination } from '../data-table/table-pagination'; import { DraggableRow } from '../data-table/dragable-row'; import type { PartnerTableProps } from '@/types/federation'; import { getPartnerColumns } from './partner-columns'; import { PartnerToolbar } from './partner-toolbar'; export function PartnerTable({ partners, onDetails, onZones, onRefresh, }: PartnerTableProps) { const [data, setData] = React.useState(partners); const [source, setSource] = React.useState(partners); if (partners !== source) { setSource(partners); setData(partners); } const [sorting, setSorting] = React.useState<SortingState>([]); const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({}); const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>( [], ); const sortableId = React.useId(); const sensors = useSensors( useSensor(MouseSensor, {}), useSensor(TouchSensor, {}), useSensor(KeyboardSensor, {}), ); const columns = React.useMemo( () => getPartnerColumns(onDetails, onZones), [onDetails, onZones], ); const table = useReactTable({ data, columns, getRowId: (row) => row.id, state: { sorting, columnVisibility, columnFilters }, onSortingChange: setSorting, onColumnFiltersChange: setColumnFilters, onColumnVisibilityChange: setColumnVisibility, getCoreRowModel: getCoreRowModel(), getFilteredRowModel: getFilteredRowModel(), getPaginationRowModel: getPaginationRowModel(), getSortedRowModel: getSortedRowModel(), }); function handleDragEnd(event: DragEndEvent) { const { active, over } = event; if (!over || active.id === over.id) return; setData((current) => { const oldIndex = current.findIndex((partner) => partner.id === active.id); const newIndex = current.findIndex((partner) => partner.id === over.id); if (oldIndex === -1 || newIndex === -1) return current; return arrayMove(current, oldIndex, newIndex); }); } return ( <> <PartnerToolbar table={table} onRefresh={onRefresh} /> <div className="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6"> <div className="overflow-x-auto rounded-lg border"> <DndContext collisionDetection={closestCenter} modifiers={[restrictToVerticalAxis]} onDragEnd={handleDragEnd} sensors={sensors} id={sortableId} > <Table className="min-w-[760px]"> <TableHeader className="sticky top-0 z-10 bg-muted"> {table.getHeaderGroups().map((headerGroup) => ( <TableRow key={headerGroup.id}> {headerGroup.headers.map((header) => ( <TableHead key={header.id} colSpan={header.colSpan}> {header.isPlaceholder ? null : flexRender( header.column.columnDef.header, header.getContext(), )} </TableHead> ))} </TableRow> ))} </TableHeader> <TableBody className="**:data-[slot=table-cell]:first:w-8"> {table.getRowModel().rows.length ? ( <SortableContext items={data.map((partner) => partner.id)} strategy={verticalListSortingStrategy} > {table.getRowModel().rows.map((row) => ( <DraggableRow key={row.id} row={row} /> ))} </SortableContext> ) : ( <TableRow> <TableCell colSpan={columns.length} className="h-24 text-center" > No results. </TableCell> </TableRow> )} </TableBody> </Table> </DndContext> </div> <TablePagination table={table} /> </div> </> ); }