@burdenoff/microfe-admin 2026.624.1 → 2026.625.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin/components/DataTable.js +5 -5
- package/dist/admin/components/DataTable.js.map +1 -1
- package/dist/admin/components/MetricCard.js +40 -37
- package/dist/admin/components/MetricCard.js.map +1 -1
- package/dist/admin/components/PageLayout.js +28 -22
- package/dist/admin/components/PageLayout.js.map +1 -1
- package/dist/admin/components/ui.js +45 -38
- package/dist/admin/components/ui.js.map +1 -1
- package/dist/admin/pages/AuditLogsPage.js +2 -0
- package/dist/admin/pages/AuditLogsPage.js.map +1 -1
- package/dist/admin/pages/BillingPage.js +51 -43
- package/dist/admin/pages/BillingPage.js.map +1 -1
- package/dist/admin/pages/CommunicationsPage.js +3 -0
- package/dist/admin/pages/CommunicationsPage.js.map +1 -1
- package/dist/admin/pages/ContentPagesPage.js +2 -0
- package/dist/admin/pages/ContentPagesPage.js.map +1 -1
- package/dist/admin/pages/DashboardPage.js +7 -4
- package/dist/admin/pages/DashboardPage.js.map +1 -1
- package/dist/admin/pages/FeatureFlagsPage.js +2 -0
- package/dist/admin/pages/FeatureFlagsPage.js.map +1 -1
- package/dist/admin/pages/OrganizationsListPage.js +2 -0
- package/dist/admin/pages/OrganizationsListPage.js.map +1 -1
- package/dist/admin/pages/ProductsPage.js +3 -0
- package/dist/admin/pages/ProductsPage.js.map +1 -1
- package/dist/admin/pages/ReferralsPage.js +4 -0
- package/dist/admin/pages/ReferralsPage.js.map +1 -1
- package/dist/admin/pages/RolesPermissionsPage.js +3 -0
- package/dist/admin/pages/RolesPermissionsPage.js.map +1 -1
- package/dist/admin/pages/SREPage.js +4 -0
- package/dist/admin/pages/SREPage.js.map +1 -1
- package/dist/admin/pages/SandboxSettingsPage.js +1 -0
- package/dist/admin/pages/SandboxSettingsPage.js.map +1 -1
- package/dist/admin/pages/SettingsPage.js +139 -127
- package/dist/admin/pages/SettingsPage.js.map +1 -1
- package/dist/admin/pages/SyncFailuresPage.js +3 -0
- package/dist/admin/pages/SyncFailuresPage.js.map +1 -1
- package/dist/admin/pages/TenantsListPage.js +2 -0
- package/dist/admin/pages/TenantsListPage.js.map +1 -1
- package/dist/admin/pages/UsersListPage.js +2 -0
- package/dist/admin/pages/UsersListPage.js.map +1 -1
- package/dist/admin/pages/WaitlistPage.js +2 -0
- package/dist/admin/pages/WaitlistPage.js.map +1 -1
- package/dist/admin/pages/WorkflowsPage.js +3 -0
- package/dist/admin/pages/WorkflowsPage.js.map +1 -1
- package/dist/admin/pages/WorkspacesListPage.js +1 -0
- package/dist/admin/pages/WorkspacesListPage.js.map +1 -1
- package/package.json +2 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SyncFailuresPage.js","names":[],"sources":["../../../src/admin/pages/SyncFailuresPage.tsx"],"sourcesContent":["import { useCallback, useMemo, useState } from 'react';\nimport { RefreshCw, AlertOctagon, Clock, CheckCircle2, RotateCw } from 'lucide-react';\nimport { useAdminPermissions } from '../hooks/useAdminPermissions';\nimport {\n PageLayout,\n MetricCard,\n MetricsGrid,\n Card,\n CardContent,\n Badge,\n Button,\n DataTable,\n Pagination,\n EmptyState,\n PermissionGuard,\n ConfirmDialog,\n useToast,\n} from '../components';\nimport { formatDateTime, formatRelativeTime } from '../utils/formatters';\nimport { SyncOutboxStatus } from '../../generated/global-types';\nimport {\n useListSyncOutboxEntriesQuery,\n useSyncOutboxStatsQuery,\n useRetrySyncOutboxEntryMutation,\n type ListSyncOutboxEntriesQuery,\n} from '../../generated/global-operations';\n\nconst PAGE_SIZE = 25;\n\n// Single source of truth for the underlying string values. Authored as a\n// const-tuple so TypeScript derives both the union type and a runtime guard.\n// The mapping below is type-checked: if codegen ever drops or renames a\n// SyncOutboxStatus enum member, the build fails at STATUS_TO_GQL, not at\n// runtime.\nconst STATUS_VALUES = ['PENDING', 'RETRYING', 'SUCCESS', 'PERMANENTLY_FAILED'] as const;\ntype StatusValue = (typeof STATUS_VALUES)[number];\ntype StatusFilter = 'all' | StatusValue;\n\ntype OutboxEntry = ListSyncOutboxEntriesQuery['listSyncOutboxEntries']['items'][number];\n\n// Typed bridge between our boundary string union and the codegen-generated\n// enum. Explicit mapping makes the codegen casing dependency a compile-time\n// constraint instead of a runtime cast.\nconst STATUS_TO_GQL: Record<StatusValue, SyncOutboxStatus> = {\n PENDING: SyncOutboxStatus.Pending,\n RETRYING: SyncOutboxStatus.Retrying,\n SUCCESS: SyncOutboxStatus.Success,\n PERMANENTLY_FAILED: SyncOutboxStatus.PermanentlyFailed,\n};\n\n// Reverse map: codegen enum value → boundary string. Falls back to\n// PERMANENTLY_FAILED if codegen ever emits an unknown value — a loud-but-safe\n// default that surfaces the drift without breaking the page.\nfunction asStatusValue(s: SyncOutboxStatus): StatusValue {\n const raw = String(s) as StatusValue;\n return (STATUS_VALUES as readonly string[]).includes(raw) ? raw : 'PERMANENTLY_FAILED';\n}\n\nconst STATUS_VARIANTS: Record<StatusValue, string> = {\n PENDING: 'bg-warning/10 text-warning border-warning/30',\n RETRYING: 'bg-warning/10 text-warning border-warning/30',\n SUCCESS: 'bg-success/10 text-success border-success/30',\n PERMANENTLY_FAILED: 'bg-status-error-bg/10 text-status-error-text border-status-error-border/30',\n};\n\nconst STATUS_LABELS: Record<StatusValue, string> = {\n PENDING: 'Pending',\n RETRYING: 'Retrying',\n SUCCESS: 'Success',\n PERMANENTLY_FAILED: 'Permanently failed',\n};\n\nfunction StatusBadge({ status }: { status: SyncOutboxStatus }) {\n const key = asStatusValue(status);\n return (\n <Badge variant=\"outline\" className={STATUS_VARIANTS[key]}>\n {STATUS_LABELS[key]}\n </Badge>\n );\n}\n\n/**\n * Sync Failures admin page.\n *\n * Shows the state of the cross-layer sync outbox so a super-admin can spot\n * users/workspaces that didn't get fully provisioned (e.g. signup happened\n * while a downstream service was down) and manually requeue them once the\n * underlying problem is fixed.\n *\n * Manual retry only flips the row to RETRYING; the background reconciler in\n * global-tenant-svc actually performs the sync on its next tick. That keeps\n * the existing single-writer retry semantics intact.\n */\nexport function SyncFailuresPage() {\n const {\n canViewSyncFailures,\n canManageSyncFailures,\n isLoading: permissionsLoading,\n } = useAdminPermissions();\n const toast = useToast();\n\n const [page, setPage] = useState(1);\n const [statusFilter, setStatusFilter] = useState<StatusFilter>('PERMANENTLY_FAILED');\n const [confirmRetryId, setConfirmRetryId] = useState<string | null>(null);\n const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null);\n\n const filter = useMemo(() => {\n if (statusFilter === 'all') return undefined;\n // Explicit, type-checked mapping — replaces the previous `as unknown as`\n // double-cast. If codegen ever renames a SyncOutboxStatus enum member,\n // the build fails at STATUS_TO_GQL above, not silently at runtime.\n return { statuses: [STATUS_TO_GQL[statusFilter]] };\n }, [statusFilter]);\n\n const skipQueries = permissionsLoading || !canViewSyncFailures;\n\n const {\n data: listData,\n loading: listLoading,\n error: listError,\n refetch: refetchList,\n } = useListSyncOutboxEntriesQuery({\n variables: {\n filter,\n limit: PAGE_SIZE,\n offset: (page - 1) * PAGE_SIZE,\n },\n fetchPolicy: 'cache-and-network',\n skip: skipQueries,\n });\n\n const {\n data: statsData,\n loading: statsLoading,\n refetch: refetchStats,\n } = useSyncOutboxStatsQuery({\n fetchPolicy: 'cache-and-network',\n skip: skipQueries,\n });\n\n const [retryEntry, { loading: retrying }] = useRetrySyncOutboxEntryMutation({\n onCompleted: (data) => {\n const result = data.retrySyncOutboxEntry;\n if (result.success) {\n toast.success(\n 'Requeued for retry',\n result.message ?? 'The reconciler will pick it up on the next tick.'\n );\n void refetchList();\n void refetchStats();\n } else {\n toast.error('Retry failed', result.message ?? 'Could not requeue this entry.');\n }\n },\n onError: (err) => {\n toast.error('Retry failed', err.message);\n },\n });\n\n const handleRefresh = useCallback(() => {\n void refetchList();\n void refetchStats();\n }, [refetchList, refetchStats]);\n\n const handleConfirmRetry = useCallback(async () => {\n if (!confirmRetryId) return;\n // Apollo's mutate promise rejects on network/GraphQL error; without the\n // finally the dialog would stay open and ConfirmDialog's awaited\n // onConfirm would see an unhandled rejection. The onError toast still\n // surfaces the failure to the user.\n try {\n await retryEntry({ variables: { id: confirmRetryId } });\n } finally {\n setConfirmRetryId(null);\n }\n }, [confirmRetryId, retryEntry]);\n\n const entries = listData?.listSyncOutboxEntries.items ?? [];\n const total = listData?.listSyncOutboxEntries.total ?? 0;\n const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));\n const stats = statsData?.syncOutboxStats;\n\n const columns = useMemo(\n () => [\n {\n key: 'operation',\n header: 'Operation',\n width: '180px',\n render: (entry: OutboxEntry) => (\n <code className=\"text-xs bg-bg-sunken px-1.5 py-0.5 rounded\">{entry.operation}</code>\n ),\n },\n {\n key: 'workspaceId',\n header: 'Workspace',\n render: (entry: OutboxEntry) => (\n <span className=\"font-mono text-xs text-text-secondary\">{entry.workspaceId}</span>\n ),\n },\n {\n key: 'status',\n header: 'Status',\n width: '170px',\n render: (entry: OutboxEntry) => <StatusBadge status={entry.status} />,\n },\n {\n key: 'attempts',\n header: 'Attempts',\n width: '100px',\n align: 'right' as const,\n render: (entry: OutboxEntry) => (\n <span className=\"text-sm\">\n {entry.attempts}\n <span className=\"text-text-secondary\">/{entry.maxAttempts}</span>\n </span>\n ),\n },\n {\n key: 'nextRetryAt',\n header: 'Next retry',\n width: '160px',\n render: (entry: OutboxEntry) =>\n entry.nextRetryAt ? (\n <span className=\"text-sm text-text-secondary\">\n {formatRelativeTime(entry.nextRetryAt)}\n </span>\n ) : (\n <span className=\"text-xs text-text-secondary\">—</span>\n ),\n },\n {\n key: 'createdAt',\n header: 'Created',\n width: '160px',\n render: (entry: OutboxEntry) => (\n <span className=\"text-sm text-text-secondary\">\n {formatRelativeTime(entry.createdAt)}\n </span>\n ),\n },\n {\n key: 'actions',\n header: '',\n width: '120px',\n align: 'right' as const,\n render: (entry: OutboxEntry) => {\n // Retry is only meaningful for entries that have exhausted automatic\n // retries. PENDING/RETRYING entries already get picked up by the\n // background reconciler. SUCCESS entries don't need retrying.\n const isRetryable = asStatusValue(entry.status) === 'PERMANENTLY_FAILED';\n if (!canManageSyncFailures || !isRetryable) {\n return null;\n }\n return (\n <Button\n variant=\"outline\"\n size=\"sm\"\n onClick={(e) => {\n e.stopPropagation();\n setConfirmRetryId(entry.id);\n }}\n disabled={retrying}\n >\n <RotateCw className=\"mr-1.5 h-3.5 w-3.5\" />\n Retry\n </Button>\n );\n },\n },\n ],\n [canManageSyncFailures, retrying]\n );\n\n return (\n <PermissionGuard\n allowed={canViewSyncFailures}\n isLoading={permissionsLoading}\n pageTitle=\"Sync Failures\"\n denyMessage=\"Only super-admins can view cross-layer sync failures.\"\n >\n <PageLayout\n title=\"Sync Failures\"\n description=\"Cross-layer sync operations (global → workspace) that did not complete. Automatic retries run in the background; use the manual retry once the underlying issue is fixed.\"\n actions={\n <Button variant=\"outline\" onClick={handleRefresh} disabled={listLoading || statsLoading}>\n <RefreshCw className={`mr-2 h-4 w-4 ${listLoading ? 'animate-spin' : ''}`} />\n Refresh\n </Button>\n }\n >\n <div className=\"space-y-6\">\n {/* Stats */}\n <MetricsGrid>\n <MetricCard\n title=\"Pending\"\n value={stats?.pending ?? 0}\n icon={<Clock className=\"h-5 w-5\" />}\n subtitle=\"Waiting for first retry\"\n loading={statsLoading}\n />\n <MetricCard\n title=\"Retrying\"\n value={stats?.retrying ?? 0}\n icon={<RotateCw className=\"h-5 w-5\" />}\n subtitle=\"Automatic backoff in progress\"\n loading={statsLoading}\n />\n <MetricCard\n title=\"Permanently failed\"\n value={stats?.permanentlyFailed ?? 0}\n icon={<AlertOctagon className=\"h-5 w-5\" />}\n subtitle=\"Exhausted automatic retries\"\n priority={stats?.permanentlyFailed ? 'critical' : 'normal'}\n loading={statsLoading}\n />\n <MetricCard\n title=\"Successful\"\n value={stats?.successful ?? 0}\n icon={<CheckCircle2 className=\"h-5 w-5\" />}\n subtitle=\"Synced (auto-purged after 7 days)\"\n loading={statsLoading}\n />\n </MetricsGrid>\n\n {/* Filters */}\n <div className=\"flex flex-wrap items-center gap-2\">\n {(\n [\n ['PERMANENTLY_FAILED', 'Permanently failed'],\n ['RETRYING', 'Retrying'],\n ['PENDING', 'Pending'],\n ['SUCCESS', 'Successful'],\n ['all', 'All'],\n ] as Array<[StatusFilter, string]>\n ).map(([value, label]) => (\n <Button\n key={value}\n variant={statusFilter === value ? 'default' : 'outline'}\n size=\"sm\"\n onClick={() => {\n setStatusFilter(value);\n setPage(1);\n // Clear any expanded error detail card — the row it referenced\n // may not be in the new result set.\n setExpandedErrorId(null);\n }}\n >\n {label}\n </Button>\n ))}\n </div>\n\n {/* Error banner */}\n {listError && (\n <Card>\n <CardContent className=\"py-4 text-sm text-status-error-text\">\n Failed to load sync outbox entries: {listError.message}\n </CardContent>\n </Card>\n )}\n\n {/* Table */}\n <DataTable\n columns={columns}\n data={entries}\n keyExtractor={(entry) => entry.id}\n loading={listLoading}\n onRowClick={(entry: OutboxEntry) =>\n setExpandedErrorId((current) => (current === entry.id ? null : entry.id))\n }\n emptyState={\n <EmptyState\n icon={<CheckCircle2 className=\"h-8 w-8\" />}\n title=\"No sync failures\"\n description={\n statusFilter === 'PERMANENTLY_FAILED'\n ? 'Nothing has exhausted automatic retries.'\n : 'No outbox entries match the current filter.'\n }\n />\n }\n />\n\n {/* Expanded error detail */}\n {expandedErrorId &&\n entries\n .filter((e: OutboxEntry) => e.id === expandedErrorId)\n .map((entry: OutboxEntry) => (\n <Card key={entry.id}>\n <CardContent className=\"space-y-2 py-4 text-sm\">\n <div className=\"flex items-center justify-between\">\n <span className=\"font-medium\">Last error</span>\n <span className=\"text-xs text-text-secondary\">\n {entry.lastAttemptAt\n ? `Last attempt: ${formatDateTime(entry.lastAttemptAt)}`\n : 'Never attempted'}\n </span>\n </div>\n <pre className=\"whitespace-pre-wrap break-words rounded bg-bg-sunken px-3 py-2 text-xs text-text-secondary\">\n {entry.lastError ?? 'No error recorded'}\n </pre>\n <div className=\"text-xs text-text-secondary\">\n Entry id: <code className=\"font-mono\">{entry.id}</code>\n </div>\n </CardContent>\n </Card>\n ))}\n\n {/* Pagination */}\n {total > PAGE_SIZE && (\n <Pagination\n page={page}\n totalPages={totalPages}\n totalItems={total}\n pageSize={PAGE_SIZE}\n onPageChange={(p) => {\n setPage(p);\n // Same rationale as the filter buttons — the previously\n // expanded row may not be in the new page's result set.\n setExpandedErrorId(null);\n }}\n />\n )}\n </div>\n\n {/* Retry confirm */}\n <ConfirmDialog\n open={!!confirmRetryId}\n onClose={() => setConfirmRetryId(null)}\n onConfirm={handleConfirmRetry}\n title=\"Retry this sync?\"\n description={\n <>\n The entry will be flipped to <strong>RETRYING</strong> and the background reconciler\n will pick it up on its next tick. Attempts will reset to 0 so the entry gets a fresh\n retry budget.\n <br />\n <br />\n Make sure the underlying issue (e.g. a downstream service that was down) is actually\n resolved — otherwise it will fail again.\n </>\n }\n confirmLabel={retrying ? 'Requeueing…' : 'Retry'}\n loading={retrying}\n />\n </PageLayout>\n </PermissionGuard>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"SyncFailuresPage.js","names":[],"sources":["../../../src/admin/pages/SyncFailuresPage.tsx"],"sourcesContent":["import { useCallback, useMemo, useState } from 'react';\nimport { RefreshCw, AlertOctagon, Clock, CheckCircle2, RotateCw } from 'lucide-react';\nimport { useAdminPermissions } from '../hooks/useAdminPermissions';\nimport {\n PageLayout,\n MetricCard,\n MetricsGrid,\n Card,\n CardContent,\n Badge,\n Button,\n DataTable,\n Pagination,\n EmptyState,\n PermissionGuard,\n ConfirmDialog,\n useToast,\n} from '../components';\nimport { formatDateTime, formatRelativeTime } from '../utils/formatters';\nimport { SyncOutboxStatus } from '../../generated/global-types';\nimport {\n useListSyncOutboxEntriesQuery,\n useSyncOutboxStatsQuery,\n useRetrySyncOutboxEntryMutation,\n type ListSyncOutboxEntriesQuery,\n} from '../../generated/global-operations';\n\nconst PAGE_SIZE = 25;\n\n// Single source of truth for the underlying string values. Authored as a\n// const-tuple so TypeScript derives both the union type and a runtime guard.\n// The mapping below is type-checked: if codegen ever drops or renames a\n// SyncOutboxStatus enum member, the build fails at STATUS_TO_GQL, not at\n// runtime.\nconst STATUS_VALUES = ['PENDING', 'RETRYING', 'SUCCESS', 'PERMANENTLY_FAILED'] as const;\ntype StatusValue = (typeof STATUS_VALUES)[number];\ntype StatusFilter = 'all' | StatusValue;\n\ntype OutboxEntry = ListSyncOutboxEntriesQuery['listSyncOutboxEntries']['items'][number];\n\n// Typed bridge between our boundary string union and the codegen-generated\n// enum. Explicit mapping makes the codegen casing dependency a compile-time\n// constraint instead of a runtime cast.\nconst STATUS_TO_GQL: Record<StatusValue, SyncOutboxStatus> = {\n PENDING: SyncOutboxStatus.Pending,\n RETRYING: SyncOutboxStatus.Retrying,\n SUCCESS: SyncOutboxStatus.Success,\n PERMANENTLY_FAILED: SyncOutboxStatus.PermanentlyFailed,\n};\n\n// Reverse map: codegen enum value → boundary string. Falls back to\n// PERMANENTLY_FAILED if codegen ever emits an unknown value — a loud-but-safe\n// default that surfaces the drift without breaking the page.\nfunction asStatusValue(s: SyncOutboxStatus): StatusValue {\n const raw = String(s) as StatusValue;\n return (STATUS_VALUES as readonly string[]).includes(raw) ? raw : 'PERMANENTLY_FAILED';\n}\n\nconst STATUS_VARIANTS: Record<StatusValue, string> = {\n PENDING: 'bg-warning/10 text-warning border-warning/30',\n RETRYING: 'bg-warning/10 text-warning border-warning/30',\n SUCCESS: 'bg-success/10 text-success border-success/30',\n PERMANENTLY_FAILED: 'bg-status-error-bg/10 text-status-error-text border-status-error-border/30',\n};\n\nconst STATUS_LABELS: Record<StatusValue, string> = {\n PENDING: 'Pending',\n RETRYING: 'Retrying',\n SUCCESS: 'Success',\n PERMANENTLY_FAILED: 'Permanently failed',\n};\n\nfunction StatusBadge({ status }: { status: SyncOutboxStatus }) {\n const key = asStatusValue(status);\n return (\n <Badge variant=\"outline\" className={STATUS_VARIANTS[key]}>\n {STATUS_LABELS[key]}\n </Badge>\n );\n}\n\n/**\n * Sync Failures admin page.\n *\n * Shows the state of the cross-layer sync outbox so a super-admin can spot\n * users/workspaces that didn't get fully provisioned (e.g. signup happened\n * while a downstream service was down) and manually requeue them once the\n * underlying problem is fixed.\n *\n * Manual retry only flips the row to RETRYING; the background reconciler in\n * global-tenant-svc actually performs the sync on its next tick. That keeps\n * the existing single-writer retry semantics intact.\n */\nexport function SyncFailuresPage() {\n const {\n canViewSyncFailures,\n canManageSyncFailures,\n isLoading: permissionsLoading,\n } = useAdminPermissions();\n const toast = useToast();\n\n const [page, setPage] = useState(1);\n const [statusFilter, setStatusFilter] = useState<StatusFilter>('PERMANENTLY_FAILED');\n const [confirmRetryId, setConfirmRetryId] = useState<string | null>(null);\n const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null);\n\n const filter = useMemo(() => {\n if (statusFilter === 'all') return undefined;\n // Explicit, type-checked mapping — replaces the previous `as unknown as`\n // double-cast. If codegen ever renames a SyncOutboxStatus enum member,\n // the build fails at STATUS_TO_GQL above, not silently at runtime.\n return { statuses: [STATUS_TO_GQL[statusFilter]] };\n }, [statusFilter]);\n\n const skipQueries = permissionsLoading || !canViewSyncFailures;\n\n const {\n data: listData,\n loading: listLoading,\n error: listError,\n refetch: refetchList,\n } = useListSyncOutboxEntriesQuery({\n variables: {\n filter,\n limit: PAGE_SIZE,\n offset: (page - 1) * PAGE_SIZE,\n },\n fetchPolicy: 'cache-and-network',\n skip: skipQueries,\n });\n\n const {\n data: statsData,\n loading: statsLoading,\n refetch: refetchStats,\n } = useSyncOutboxStatsQuery({\n fetchPolicy: 'cache-and-network',\n skip: skipQueries,\n });\n\n const [retryEntry, { loading: retrying }] = useRetrySyncOutboxEntryMutation({\n onCompleted: (data) => {\n const result = data.retrySyncOutboxEntry;\n if (result.success) {\n toast.success(\n 'Requeued for retry',\n result.message ?? 'The reconciler will pick it up on the next tick.'\n );\n void refetchList();\n void refetchStats();\n } else {\n toast.error('Retry failed', result.message ?? 'Could not requeue this entry.');\n }\n },\n onError: (err) => {\n toast.error('Retry failed', err.message);\n },\n });\n\n const handleRefresh = useCallback(() => {\n void refetchList();\n void refetchStats();\n }, [refetchList, refetchStats]);\n\n const handleConfirmRetry = useCallback(async () => {\n if (!confirmRetryId) return;\n // Apollo's mutate promise rejects on network/GraphQL error; without the\n // finally the dialog would stay open and ConfirmDialog's awaited\n // onConfirm would see an unhandled rejection. The onError toast still\n // surfaces the failure to the user.\n try {\n await retryEntry({ variables: { id: confirmRetryId } });\n } finally {\n setConfirmRetryId(null);\n }\n }, [confirmRetryId, retryEntry]);\n\n const entries = listData?.listSyncOutboxEntries.items ?? [];\n const total = listData?.listSyncOutboxEntries.total ?? 0;\n const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));\n const stats = statsData?.syncOutboxStats;\n\n const columns = useMemo(\n () => [\n {\n key: 'operation',\n header: 'Operation',\n width: '180px',\n render: (entry: OutboxEntry) => (\n <code className=\"text-xs bg-bg-sunken px-1.5 py-0.5 rounded\">{entry.operation}</code>\n ),\n },\n {\n key: 'workspaceId',\n header: 'Workspace',\n render: (entry: OutboxEntry) => (\n <span className=\"font-mono text-xs text-text-secondary\">{entry.workspaceId}</span>\n ),\n },\n {\n key: 'status',\n header: 'Status',\n width: '170px',\n render: (entry: OutboxEntry) => <StatusBadge status={entry.status} />,\n },\n {\n key: 'attempts',\n header: 'Attempts',\n width: '100px',\n align: 'right' as const,\n render: (entry: OutboxEntry) => (\n <span className=\"text-sm\">\n {entry.attempts}\n <span className=\"text-text-secondary\">/{entry.maxAttempts}</span>\n </span>\n ),\n },\n {\n key: 'nextRetryAt',\n header: 'Next retry',\n width: '160px',\n render: (entry: OutboxEntry) =>\n entry.nextRetryAt ? (\n <span className=\"text-sm text-text-secondary\">\n {formatRelativeTime(entry.nextRetryAt)}\n </span>\n ) : (\n <span className=\"text-xs text-text-secondary\">—</span>\n ),\n },\n {\n key: 'createdAt',\n header: 'Created',\n width: '160px',\n render: (entry: OutboxEntry) => (\n <span className=\"text-sm text-text-secondary\">\n {formatRelativeTime(entry.createdAt)}\n </span>\n ),\n },\n {\n key: 'actions',\n header: '',\n width: '120px',\n align: 'right' as const,\n render: (entry: OutboxEntry) => {\n // Retry is only meaningful for entries that have exhausted automatic\n // retries. PENDING/RETRYING entries already get picked up by the\n // background reconciler. SUCCESS entries don't need retrying.\n const isRetryable = asStatusValue(entry.status) === 'PERMANENTLY_FAILED';\n if (!canManageSyncFailures || !isRetryable) {\n return null;\n }\n return (\n <Button\n variant=\"outline\"\n size=\"sm\"\n onClick={(e) => {\n e.stopPropagation();\n setConfirmRetryId(entry.id);\n }}\n disabled={retrying}\n >\n <RotateCw className=\"mr-1.5 h-3.5 w-3.5\" />\n Retry\n </Button>\n );\n },\n },\n ],\n [canManageSyncFailures, retrying]\n );\n\n return (\n <PermissionGuard\n allowed={canViewSyncFailures}\n isLoading={permissionsLoading}\n pageTitle=\"Sync Failures\"\n denyMessage=\"Only super-admins can view cross-layer sync failures.\"\n >\n <PageLayout\n title=\"Sync Failures\"\n gradientWord=\"Failures\"\n description=\"Cross-layer sync operations (global → workspace) that did not complete. Automatic retries run in the background; use the manual retry once the underlying issue is fixed.\"\n actions={\n <Button variant=\"outline\" onClick={handleRefresh} disabled={listLoading || statsLoading}>\n <RefreshCw className={`mr-2 h-4 w-4 ${listLoading ? 'animate-spin' : ''}`} />\n Refresh\n </Button>\n }\n >\n <div className=\"space-y-6\">\n {/* Stats */}\n <MetricsGrid>\n <MetricCard\n title=\"Pending\"\n hero\n value={stats?.pending ?? 0}\n icon={<Clock className=\"h-5 w-5\" />}\n subtitle=\"Waiting for first retry\"\n loading={statsLoading}\n />\n <MetricCard\n title=\"Retrying\"\n value={stats?.retrying ?? 0}\n icon={<RotateCw className=\"h-5 w-5\" />}\n subtitle=\"Automatic backoff in progress\"\n loading={statsLoading}\n />\n <MetricCard\n title=\"Permanently failed\"\n value={stats?.permanentlyFailed ?? 0}\n icon={<AlertOctagon className=\"h-5 w-5\" />}\n subtitle=\"Exhausted automatic retries\"\n priority={stats?.permanentlyFailed ? 'critical' : 'normal'}\n loading={statsLoading}\n />\n <MetricCard\n title=\"Successful\"\n value={stats?.successful ?? 0}\n icon={<CheckCircle2 className=\"h-5 w-5\" />}\n subtitle=\"Synced (auto-purged after 7 days)\"\n loading={statsLoading}\n />\n </MetricsGrid>\n\n {/* Filters */}\n <div className=\"flex flex-wrap items-center gap-2\">\n {(\n [\n ['PERMANENTLY_FAILED', 'Permanently failed'],\n ['RETRYING', 'Retrying'],\n ['PENDING', 'Pending'],\n ['SUCCESS', 'Successful'],\n ['all', 'All'],\n ] as Array<[StatusFilter, string]>\n ).map(([value, label]) => (\n <Button\n key={value}\n variant={statusFilter === value ? 'default' : 'outline'}\n size=\"sm\"\n onClick={() => {\n setStatusFilter(value);\n setPage(1);\n // Clear any expanded error detail card — the row it referenced\n // may not be in the new result set.\n setExpandedErrorId(null);\n }}\n >\n {label}\n </Button>\n ))}\n </div>\n\n {/* Error banner */}\n {listError && (\n <Card>\n <CardContent className=\"py-4 text-sm text-status-error-text\">\n Failed to load sync outbox entries: {listError.message}\n </CardContent>\n </Card>\n )}\n\n {/* Table */}\n <DataTable\n columns={columns}\n data={entries}\n keyExtractor={(entry) => entry.id}\n loading={listLoading}\n onRowClick={(entry: OutboxEntry) =>\n setExpandedErrorId((current) => (current === entry.id ? null : entry.id))\n }\n emptyState={\n <EmptyState\n illustration=\"empty-data\"\n icon={<CheckCircle2 className=\"h-8 w-8\" />}\n title=\"No sync failures\"\n description={\n statusFilter === 'PERMANENTLY_FAILED'\n ? 'Nothing has exhausted automatic retries.'\n : 'No outbox entries match the current filter.'\n }\n />\n }\n />\n\n {/* Expanded error detail */}\n {expandedErrorId &&\n entries\n .filter((e: OutboxEntry) => e.id === expandedErrorId)\n .map((entry: OutboxEntry) => (\n <Card key={entry.id}>\n <CardContent className=\"space-y-2 py-4 text-sm\">\n <div className=\"flex items-center justify-between\">\n <span className=\"font-medium\">Last error</span>\n <span className=\"text-xs text-text-secondary\">\n {entry.lastAttemptAt\n ? `Last attempt: ${formatDateTime(entry.lastAttemptAt)}`\n : 'Never attempted'}\n </span>\n </div>\n <pre className=\"whitespace-pre-wrap break-words rounded bg-bg-sunken px-3 py-2 text-xs text-text-secondary\">\n {entry.lastError ?? 'No error recorded'}\n </pre>\n <div className=\"text-xs text-text-secondary\">\n Entry id: <code className=\"font-mono\">{entry.id}</code>\n </div>\n </CardContent>\n </Card>\n ))}\n\n {/* Pagination */}\n {total > PAGE_SIZE && (\n <Pagination\n page={page}\n totalPages={totalPages}\n totalItems={total}\n pageSize={PAGE_SIZE}\n onPageChange={(p) => {\n setPage(p);\n // Same rationale as the filter buttons — the previously\n // expanded row may not be in the new page's result set.\n setExpandedErrorId(null);\n }}\n />\n )}\n </div>\n\n {/* Retry confirm */}\n <ConfirmDialog\n open={!!confirmRetryId}\n onClose={() => setConfirmRetryId(null)}\n onConfirm={handleConfirmRetry}\n title=\"Retry this sync?\"\n description={\n <>\n The entry will be flipped to <strong>RETRYING</strong> and the background reconciler\n will pick it up on its next tick. Attempts will reset to 0 so the entry gets a fresh\n retry budget.\n <br />\n <br />\n Make sure the underlying issue (e.g. a downstream service that was down) is actually\n resolved — otherwise it will fail again.\n </>\n }\n confirmLabel={retrying ? 'Requeueing…' : 'Retry'}\n loading={retrying}\n />\n </PageLayout>\n </PermissionGuard>\n );\n}\n"],"mappings":"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"}
|
|
@@ -227,6 +227,7 @@ function w() {
|
|
|
227
227
|
denyMessage: L("admin.tenants.denyMessage", "You don't have permission to view tenants."),
|
|
228
228
|
children: /* @__PURE__ */ x(te, {
|
|
229
229
|
title: L("admin.tenants.title", "Tenants"),
|
|
230
|
+
gradientWord: L("admin.tenants.title", "Tenants"),
|
|
230
231
|
description: X ? L("admin.tenants.descriptionWithTotal", "Manage all tenants on the platform · {{total}} total", { total: X }) : L("admin.tenants.description", "Manage all tenants on the platform"),
|
|
231
232
|
actions: T && P ? /* @__PURE__ */ x(i, {
|
|
232
233
|
onClick: Te,
|
|
@@ -304,6 +305,7 @@ function w() {
|
|
|
304
305
|
}),
|
|
305
306
|
loading: be,
|
|
306
307
|
emptyState: /* @__PURE__ */ b(a, {
|
|
308
|
+
illustration: "empty-search",
|
|
307
309
|
icon: /* @__PURE__ */ b(y, { className: "h-8 w-8" }),
|
|
308
310
|
title: L("admin.tenants.empty.title", "No tenants found"),
|
|
309
311
|
description: L("admin.tenants.empty.description", "Try adjusting your search or filters"),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TenantsListPage.js","names":[],"sources":["../../../src/admin/pages/TenantsListPage.tsx"],"sourcesContent":["import { useState, useMemo, useCallback } from 'react';\nimport { useNavigate } from 'react-router-dom';\nimport { Plus, Search, Building2 } from 'lucide-react';\nimport { useAdmin } from '../providers/AdminProvider';\nimport { useAdminStore } from '../store/adminStore';\nimport { useAdminPermissions } from '../hooks/useAdminPermissions';\nimport {\n PageLayout,\n Button,\n Input,\n DataTable,\n Pagination,\n TenantStatusBadge,\n EmptyState,\n Avatar,\n TenantForm,\n RowActions,\n createRowActions,\n useToast,\n PermissionGuard,\n} from '../components';\nimport { joinPath } from '../utils/navigation';\nimport { formatDate, formatNumber, formatBytes } from '../utils/formatters';\nimport { mapTenantToUi } from '../utils/mappers';\nimport type { Tenant, TenantFilter } from '../types';\nimport type { CreateTenantInput, UpdateTenantInput } from '../schemas';\nimport {\n useListTenantsQuery,\n useCreateTenantMutation,\n useUpdateTenantMutation,\n useSuspendTenantMutation,\n useActivateTenantMutation,\n} from '../../generated/global-operations';\nimport type { RowAction } from '../components/RowActions';\nimport type { TenantStatus as GqlTenantStatus } from '../../generated/global-types';\nimport { useI18n } from '@burdenoff/fe-libs/shared/providers/shell/I18nProvider';\nimport { useFeatureFlag } from '@burdenoff/fe-libs/shared/feature-flags';\nimport { useEventBus } from '@burdenoff/fe-libs/shared/events';\nimport { tWithFallback } from '../utils/i18n';\nimport { emitTenantLifecycle } from '../utils/tenantEvents';\n\nconst PAGE_SIZE = 20;\n\n/**\n * Tenants list page\n */\nexport function TenantsListPage() {\n const { canViewTenants, canManageTenants, isLoading: permissionsLoading } = useAdminPermissions();\n\n const navigate = useNavigate();\n const { basePath = '' } = useAdmin();\n const { tenantFilter, updateTenantFilter, resetTenantFilter } = useAdminStore();\n const toast = useToast();\n const { t } = useI18n();\n // Page-level feature flags. Default-allow keeps prior behavior intact.\n // Both list-page entry points (the Create CTA and the row Edit action that\n // opens the local TenantForm modal) have to honor the flags — otherwise\n // turning the edit flag off would still let an admin edit via the modal\n // even though the route-level gate denies /admin/tenants/:id/edit.\n const createFlagEnabled = useFeatureFlag('admin.tenant.create', true);\n const editFlagEnabled = useFeatureFlag('admin.tenant.edit', true);\n const events = useEventBus();\n const tr = useCallback(\n (key: string, fallback: string, params?: Record<string, string | number>): string =>\n tWithFallback(t, key, fallback, params),\n [t]\n );\n\n const [page, setPage] = useState(1);\n\n // Form state\n const [formOpen, setFormOpen] = useState(false);\n const [editingTenant, setEditingTenant] = useState<Tenant | undefined>(undefined);\n\n // Build filter for GraphQL — only pass supported fields\n const gqlFilter = useMemo(() => {\n const f: { search?: string; status?: GqlTenantStatus } = {};\n if (tenantFilter.search) f.search = tenantFilter.search;\n const statusMap: Record<string, GqlTenantStatus> = {\n active: 'ACTIVE' as GqlTenantStatus,\n suspended: 'SUSPENDED' as GqlTenantStatus,\n deleted: 'DELETED' as GqlTenantStatus,\n };\n if (tenantFilter.status !== 'all' && statusMap[tenantFilter.status]) {\n f.status = statusMap[tenantFilter.status];\n }\n return f;\n }, [tenantFilter.search, tenantFilter.status]);\n\n const skipQueries = permissionsLoading || !canViewTenants;\n\n // Query tenants from backend\n const { data, loading, error, refetch } = useListTenantsQuery({\n variables: {\n filter: gqlFilter,\n limit: PAGE_SIZE,\n offset: (page - 1) * PAGE_SIZE,\n },\n fetchPolicy: 'cache-and-network',\n skip: skipQueries,\n });\n\n // Mutations\n const [createTenant, { loading: creating }] = useCreateTenantMutation({\n onCompleted: () => {\n void refetch();\n },\n });\n const [updateTenant, { loading: updating }] = useUpdateTenantMutation({\n onCompleted: () => {\n void refetch();\n },\n });\n const [suspendTenant] = useSuspendTenantMutation({\n onCompleted: () => {\n void refetch();\n },\n });\n const [activateTenant] = useActivateTenantMutation({\n onCompleted: () => {\n void refetch();\n },\n });\n\n const tenants: Tenant[] = useMemo(() => (data?.tenants?.items ?? []).map(mapTenantToUi), [data]);\n\n const total = data?.tenants?.total ?? 0;\n const totalPages = Math.ceil(total / PAGE_SIZE);\n\n const handleCreateTenant = useCallback(() => {\n setEditingTenant(undefined);\n setFormOpen(true);\n }, []);\n\n const handleEditTenant = useCallback((tenant: Tenant) => {\n setEditingTenant(tenant);\n setFormOpen(true);\n }, []);\n\n const handleSuspendTenant = useCallback(\n async (tenant: Tenant) => {\n try {\n await suspendTenant({ variables: { id: tenant.id } });\n emitTenantLifecycle(events, 'tenant.tenant.suspended', {\n tenantId: tenant.id,\n tenantSlug: tenant.slug,\n status: 'SUSPENDED',\n });\n toast.success(\n tr('admin.tenants.toast.suspendedTitle', 'Tenant suspended'),\n tr('admin.tenants.toast.suspendedDescription', '{{name}} has been suspended.', {\n name: tenant.name,\n })\n );\n } catch {\n toast.error(\n tr('admin.tenants.toast.errorTitle', 'Error'),\n tr('admin.tenants.toast.suspendFailed', 'Failed to suspend tenant.')\n );\n }\n },\n [suspendTenant, toast, tr, events]\n );\n\n const handleActivateTenant = useCallback(\n async (tenant: Tenant) => {\n try {\n await activateTenant({ variables: { id: tenant.id } });\n emitTenantLifecycle(events, 'tenant.tenant.activated', {\n tenantId: tenant.id,\n tenantSlug: tenant.slug,\n status: 'ACTIVE',\n });\n toast.success(\n tr('admin.tenants.toast.activatedTitle', 'Tenant activated'),\n tr('admin.tenants.toast.activatedDescription', '{{name}} has been activated.', {\n name: tenant.name,\n })\n );\n } catch {\n toast.error(\n tr('admin.tenants.toast.errorTitle', 'Error'),\n tr('admin.tenants.toast.activateFailed', 'Failed to activate tenant.')\n );\n }\n },\n [activateTenant, toast, tr, events]\n );\n\n const handleSaveTenant = useCallback(\n async (data: CreateTenantInput | UpdateTenantInput) => {\n const isEditing = 'id' in data && data.id;\n try {\n if (isEditing) {\n const updated = await updateTenant({\n variables: {\n id: (data as UpdateTenantInput).id,\n input: {\n name: data.name,\n customDomain: data.domain,\n metadata: {\n ownerEmail: data.ownerEmail,\n billingEmail: data.billingEmail,\n plan: data.plan,\n } as Record<string, unknown>,\n },\n },\n });\n const updatedId = updated.data?.updateTenant?.id ?? (data as UpdateTenantInput).id;\n // Use the slug from the persisted record. The update mutation does\n // not accept slug as input, so reading it from the form would emit\n // a value that may not match what the backend actually has.\n const persistedSlug = updated.data?.updateTenant?.slug;\n emitTenantLifecycle(events, 'tenant.tenant.updated', {\n tenantId: updatedId,\n tenantSlug: persistedSlug,\n });\n toast.success(\n tr('admin.tenants.toast.updatedTitle', 'Tenant updated'),\n tr('admin.tenants.toast.updatedDescription', '{{name}} has been updated.', {\n name: data.name ?? '',\n })\n );\n } else {\n const created = await createTenant({\n variables: {\n input: {\n name: data.name!,\n slug: data.slug!,\n customDomain: data.domain,\n metadata: {\n ownerEmail: data.ownerEmail,\n billingEmail: data.billingEmail,\n plan: data.plan,\n } as Record<string, unknown>,\n },\n },\n });\n const createdRecord = created.data?.createTenant;\n if (createdRecord?.id) {\n // Slug from the server-confirmed record so listeners never see a\n // form value the backend rejected (e.g. uniqueness collision).\n emitTenantLifecycle(events, 'tenant.tenant.created', {\n tenantId: createdRecord.id,\n tenantSlug: createdRecord.slug,\n });\n }\n toast.success(\n tr('admin.tenants.toast.createdTitle', 'Tenant created'),\n tr('admin.tenants.toast.createdDescription', '{{name}} has been created.', {\n name: data.name ?? '',\n })\n );\n }\n setFormOpen(false);\n } catch (err) {\n const msg = err instanceof Error ? err.message : 'Unknown error';\n toast.error(\n isEditing\n ? tr('admin.tenants.toast.updateFailedTitle', 'Update failed')\n : tr('admin.tenants.toast.createFailedTitle', 'Create failed'),\n msg\n );\n // Rethrow so TenantForm.onSubmit's await rejects and the modal stays\n // open with the user's input intact. Symmetric to the rethrow already\n // applied in TenantFormRoutePage.handleSave.\n throw err;\n }\n },\n [createTenant, updateTenant, toast, tr, events]\n );\n\n const handleRowClick = (tenant: Tenant) => {\n navigate(joinPath(basePath, `tenants/${tenant.id}`));\n };\n\n const getRowActions = useCallback(\n (tenant: Tenant): RowAction[] => {\n const actions = createRowActions({\n onView: () => navigate(joinPath(basePath, `tenants/${tenant.id}`)),\n onEdit: canManageTenants && editFlagEnabled ? () => handleEditTenant(tenant) : undefined,\n });\n if (canManageTenants) {\n actions.push({\n key: tenant.status === 'active' ? 'suspend' : 'activate',\n label:\n tenant.status === 'active'\n ? tr('admin.tenants.actions.suspend', 'Suspend')\n : tr('admin.tenants.actions.activate', 'Activate'),\n separator: true,\n onClick:\n tenant.status === 'active'\n ? () => handleSuspendTenant(tenant)\n : () => handleActivateTenant(tenant),\n });\n }\n return actions;\n },\n [\n navigate,\n basePath,\n canManageTenants,\n editFlagEnabled,\n handleEditTenant,\n handleSuspendTenant,\n handleActivateTenant,\n tr,\n ]\n );\n\n const columns = [\n {\n key: 'name',\n header: tr('admin.tenants.table.tenant', 'Tenant'),\n sortable: true,\n render: (tenant: Tenant) => (\n <div className=\"flex items-center gap-3\">\n <Avatar fallback={tenant.name.slice(0, 2).toUpperCase()} size=\"sm\" />\n <div className=\"min-w-0\">\n <div className=\"truncate font-medium text-text-primary\">{tenant.name}</div>\n <div className=\"truncate text-xs text-text-secondary\">{tenant.slug}</div>\n </div>\n </div>\n ),\n },\n {\n key: 'status',\n header: tr('admin.tenants.table.status', 'Status'),\n width: '120px',\n render: (tenant: Tenant) => <TenantStatusBadge status={tenant.status} />,\n },\n {\n key: 'plan',\n header: tr('admin.tenants.table.plan', 'Plan'),\n width: '100px',\n },\n {\n key: 'userCount',\n header: tr('admin.tenants.table.users', 'Users'),\n width: '80px',\n align: 'right' as const,\n sortable: true,\n render: (tenant: Tenant) => formatNumber(tenant.userCount),\n },\n {\n key: 'storageUsed',\n header: tr('admin.tenants.table.storage', 'Storage'),\n width: '100px',\n align: 'right' as const,\n render: (tenant: Tenant) => formatBytes(tenant.storageUsed),\n },\n {\n key: 'createdAt',\n header: tr('admin.tenants.table.created', 'Created'),\n width: '120px',\n sortable: true,\n render: (tenant: Tenant) => formatDate(tenant.createdAt),\n },\n {\n key: 'actions',\n header: '',\n width: '50px',\n align: 'right' as const,\n render: (tenant: Tenant) => (\n <RowActions\n actions={getRowActions(tenant)}\n ariaLabel={tr('admin.tenants.table.actionsFor', 'Actions for {{name}}', {\n name: tenant.name,\n })}\n />\n ),\n },\n ];\n\n return (\n <PermissionGuard\n allowed={canViewTenants}\n isLoading={permissionsLoading}\n pageTitle={tr('admin.tenants.title', 'Tenants')}\n denyMessage={tr('admin.tenants.denyMessage', \"You don't have permission to view tenants.\")}\n >\n <PageLayout\n title={tr('admin.tenants.title', 'Tenants')}\n description={\n total\n ? tr(\n 'admin.tenants.descriptionWithTotal',\n 'Manage all tenants on the platform · {{total}} total',\n { total }\n )\n : tr('admin.tenants.description', 'Manage all tenants on the platform')\n }\n actions={\n canManageTenants && createFlagEnabled ? (\n <Button onClick={handleCreateTenant} disabled={creating || updating}>\n <Plus className=\"mr-2 h-4 w-4\" />\n {tr('admin.tenants.actions.createTenant', 'Create Tenant')}\n </Button>\n ) : undefined\n }\n >\n <div className=\"space-y-4\">\n {/* Filters */}\n <div className=\"flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between\">\n <div className=\"relative max-w-sm flex-1\">\n <Search className=\"absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-secondary\" />\n <Input\n placeholder={tr('admin.tenants.searchPlaceholder', 'Search tenants...')}\n value={tenantFilter.search}\n onChange={(e) => {\n setPage(1);\n updateTenantFilter({ search: e.target.value });\n }}\n className=\"pl-9\"\n />\n </div>\n <div className=\"flex items-center gap-2\">\n <select\n value={tenantFilter.status}\n onChange={(e) => {\n setPage(1);\n updateTenantFilter({ status: e.target.value as TenantFilter['status'] });\n }}\n className=\"h-10 rounded-md border border-border-default bg-bg-surface px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-[var(--color-focus-ring)] focus:ring-offset-2\"\n >\n <option value=\"all\">{tr('admin.tenants.filters.status.all', 'All Status')}</option>\n <option value=\"active\">\n {tr('admin.tenants.filters.status.active', 'Active')}\n </option>\n <option value=\"suspended\">\n {tr('admin.tenants.filters.status.suspended', 'Suspended')}\n </option>\n <option value=\"deleted\">\n {tr('admin.tenants.filters.status.deleted', 'Deleted')}\n </option>\n </select>\n {(tenantFilter.search || tenantFilter.status !== 'all') && (\n <Button\n variant=\"ghost\"\n onClick={() => {\n setPage(1);\n resetTenantFilter();\n }}\n >\n {tr('admin.tenants.filters.clear', 'Clear')}\n </Button>\n )}\n </div>\n </div>\n\n {/* Error state */}\n {error && (\n <div className=\"rounded-md border border-status-error-border/50 bg-status-error-bg/10 p-4 text-sm text-status-error-text\">\n {tr('admin.tenants.error.loadFailed', 'Failed to load tenants')}: {error.message}\n </div>\n )}\n\n {/* Table */}\n <DataTable\n columns={columns}\n data={tenants}\n keyExtractor={(t) => t.id}\n onRowClick={handleRowClick}\n sortBy={tenantFilter.sortBy}\n sortOrder={tenantFilter.sortOrder}\n onSort={(column, order) =>\n updateTenantFilter({\n sortBy: column as TenantFilter['sortBy'],\n sortOrder: order,\n })\n }\n loading={loading}\n emptyState={\n <EmptyState\n icon={<Building2 className=\"h-8 w-8\" />}\n title={tr('admin.tenants.empty.title', 'No tenants found')}\n description={tr(\n 'admin.tenants.empty.description',\n 'Try adjusting your search or filters'\n )}\n action={\n <Button\n variant=\"outline\"\n onClick={() => {\n setPage(1);\n resetTenantFilter();\n }}\n >\n {tr('admin.tenants.empty.clearFilters', 'Clear filters')}\n </Button>\n }\n />\n }\n />\n\n {/* Pagination */}\n {total > PAGE_SIZE && (\n <Pagination\n page={page}\n totalPages={totalPages}\n totalItems={total}\n pageSize={PAGE_SIZE}\n onPageChange={setPage}\n />\n )}\n </div>\n\n {/* Tenant Form */}\n <TenantForm\n open={formOpen}\n onClose={() => setFormOpen(false)}\n tenant={editingTenant}\n onSave={handleSaveTenant}\n />\n </PageLayout>\n </PermissionGuard>\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AAyCA,IAAM,IAAY;AAKlB,SAAgB,IAAkB;CAChC,IAAM,EAAE,mBAAgB,qBAAkB,WAAW,MAAuB,GAAqB,EAE3F,IAAW,IAAa,EACxB,EAAE,cAAW,OAAO,GAAU,EAC9B,EAAE,iBAAc,uBAAoB,yBAAsB,GAAe,EACzE,IAAQ,GAAU,EAClB,EAAE,SAAM,IAAS,EAMjB,IAAoB,EAAe,uBAAuB,GAAK,EAC/D,IAAkB,EAAe,qBAAqB,GAAK,EAC3D,IAAS,IAAa,EACtB,IAAK,GACR,GAAa,GAAkB,MAC9B,GAAc,GAAG,GAAK,GAAU,EAAO,EACzC,CAAC,EAAE,CACJ,EAEK,CAAC,GAAM,KAAW,EAAS,EAAE,EAG7B,CAAC,GAAU,KAAe,EAAS,GAAM,EACzC,CAAC,IAAe,KAAoB,EAA6B,KAAA,EAAU,EAG3E,KAAY,QAAc;EAC9B,IAAM,IAAmD,EAAE;AAC3D,EAAI,EAAa,WAAQ,EAAE,SAAS,EAAa;EACjD,IAAM,IAA6C;GACjD,QAAQ;GACR,WAAW;GACX,SAAS;GACV;AAID,SAHI,EAAa,WAAW,SAAS,EAAU,EAAa,YAC1D,EAAE,SAAS,EAAU,EAAa,UAE7B;IACN,CAAC,EAAa,QAAQ,EAAa,OAAO,CAAC,EAExC,KAAc,KAAsB,CAAC,GAGrC,EAAE,SAAM,aAAS,UAAO,eAAY,GAAoB;EAC5D,WAAW;GACT,QAAQ;GACR,OAAO;GACP,SAAS,IAAO,KAAK;GACtB;EACD,aAAa;EACb,MAAM;EACP,CAAC,EAGI,CAAC,GAAc,EAAE,SAAS,QAAc,EAAwB,EACpE,mBAAmB;AACZ,KAAS;IAEjB,CAAC,EACI,CAAC,GAAc,EAAE,SAAS,QAAc,GAAwB,EACpE,mBAAmB;AACZ,KAAS;IAEjB,CAAC,EACI,CAAC,KAAiB,GAAyB,EAC/C,mBAAmB;AACZ,KAAS;IAEjB,CAAC,EACI,CAAC,KAAkB,EAA0B,EACjD,mBAAmB;AACZ,KAAS;IAEjB,CAAC,EAEI,KAAoB,SAAe,GAAM,SAAS,SAAS,EAAE,EAAE,IAAI,EAAc,EAAE,CAAC,EAAK,CAAC,EAE1F,IAAQ,GAAM,SAAS,SAAS,GAChC,KAAa,KAAK,KAAK,IAAQ,EAAU,EAEzC,KAAqB,QAAkB;AAE3C,EADA,EAAiB,KAAA,EAAU,EAC3B,EAAY,GAAK;IAChB,EAAE,CAAC,EAEA,IAAmB,GAAa,MAAmB;AAEvD,EADA,EAAiB,EAAO,EACxB,EAAY,GAAK;IAChB,EAAE,CAAC,EAEA,IAAsB,EAC1B,OAAO,MAAmB;AACxB,MAAI;AAOF,GANA,MAAM,EAAc,EAAE,WAAW,EAAE,IAAI,EAAO,IAAI,EAAE,CAAC,EACrD,EAAoB,GAAQ,2BAA2B;IACrD,UAAU,EAAO;IACjB,YAAY,EAAO;IACnB,QAAQ;IACT,CAAC,EACF,EAAM,QACJ,EAAG,sCAAsC,mBAAmB,EAC5D,EAAG,4CAA4C,gCAAgC,EAC7E,MAAM,EAAO,MACd,CAAC,CACH;UACK;AACN,KAAM,MACJ,EAAG,kCAAkC,QAAQ,EAC7C,EAAG,qCAAqC,4BAA4B,CACrE;;IAGL;EAAC;EAAe;EAAO;EAAI;EAAO,CACnC,EAEK,IAAuB,EAC3B,OAAO,MAAmB;AACxB,MAAI;AAOF,GANA,MAAM,EAAe,EAAE,WAAW,EAAE,IAAI,EAAO,IAAI,EAAE,CAAC,EACtD,EAAoB,GAAQ,2BAA2B;IACrD,UAAU,EAAO;IACjB,YAAY,EAAO;IACnB,QAAQ;IACT,CAAC,EACF,EAAM,QACJ,EAAG,sCAAsC,mBAAmB,EAC5D,EAAG,4CAA4C,gCAAgC,EAC7E,MAAM,EAAO,MACd,CAAC,CACH;UACK;AACN,KAAM,MACJ,EAAG,kCAAkC,QAAQ,EAC7C,EAAG,sCAAsC,6BAA6B,CACvE;;IAGL;EAAC;EAAgB;EAAO;EAAI;EAAO,CACpC,EAEK,KAAmB,EACvB,OAAO,MAAgD;EACrD,IAAM,IAAY,QAAQ,KAAQ,EAAK;AACvC,MAAI;AACF,OAAI,GAAW;IACb,IAAM,IAAU,MAAM,EAAa,EACjC,WAAW;KACT,IAAK,EAA2B;KAChC,OAAO;MACL,MAAM,EAAK;MACX,cAAc,EAAK;MACnB,UAAU;OACR,YAAY,EAAK;OACjB,cAAc,EAAK;OACnB,MAAM,EAAK;OACZ;MACF;KACF,EACF,CAAC,EACI,IAAY,EAAQ,MAAM,cAAc,MAAO,EAA2B,IAI1E,IAAgB,EAAQ,MAAM,cAAc;AAKlD,IAJA,EAAoB,GAAQ,yBAAyB;KACnD,UAAU;KACV,YAAY;KACb,CAAC,EACF,EAAM,QACJ,EAAG,oCAAoC,iBAAiB,EACxD,EAAG,0CAA0C,8BAA8B,EACzE,MAAM,EAAK,QAAQ,IACpB,CAAC,CACH;UACI;IAeL,IAAM,KAdU,MAAM,EAAa,EACjC,WAAW,EACT,OAAO;KACL,MAAM,EAAK;KACX,MAAM,EAAK;KACX,cAAc,EAAK;KACnB,UAAU;MACR,YAAY,EAAK;MACjB,cAAc,EAAK;MACnB,MAAM,EAAK;MACZ;KACF,EACF,EACF,CAAC,EAC4B,MAAM;AASpC,IARI,GAAe,MAGjB,EAAoB,GAAQ,yBAAyB;KACnD,UAAU,EAAc;KACxB,YAAY,EAAc;KAC3B,CAAC,EAEJ,EAAM,QACJ,EAAG,oCAAoC,iBAAiB,EACxD,EAAG,0CAA0C,8BAA8B,EACzE,MAAM,EAAK,QAAQ,IACpB,CAAC,CACH;;AAEH,KAAY,GAAM;WACX,GAAK;GACZ,IAAM,IAAM,aAAe,QAAQ,EAAI,UAAU;AAUjD,SATA,EAAM,MACJ,IACI,EAAG,yCAAyC,gBAAgB,GAC5D,EAAG,yCAAyC,gBAAgB,EAChE,EACD,EAIK;;IAGV;EAAC;EAAc;EAAc;EAAO;EAAI;EAAO,CAChD,EAEK,MAAkB,MAAmB;AACzC,IAAS,EAAS,GAAU,WAAW,EAAO,KAAK,CAAC;IAGhD,KAAgB,GACnB,MAAgC;EAC/B,IAAM,IAAU,GAAiB;GAC/B,cAAc,EAAS,EAAS,GAAU,WAAW,EAAO,KAAK,CAAC;GAClE,QAAQ,KAAoB,UAAwB,EAAiB,EAAO,GAAG,KAAA;GAChF,CAAC;AAeF,SAdI,KACF,EAAQ,KAAK;GACX,KAAK,EAAO,WAAW,WAAW,YAAY;GAC9C,OACE,EAAO,WAAW,WACd,EAAG,iCAAiC,UAAU,GAC9C,EAAG,kCAAkC,WAAW;GACtD,WAAW;GACX,SACE,EAAO,WAAW,iBACR,EAAoB,EAAO,SAC3B,EAAqB,EAAO;GACzC,CAAC,EAEG;IAET;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACD,CACF,EAEK,KAAU;EACd;GACE,KAAK;GACL,QAAQ,EAAG,8BAA8B,SAAS;GAClD,UAAU;GACV,SAAS,MACP,kBAAC,OAAD;IAAK,WAAU;cAAf,CACE,kBAAC,GAAD;KAAQ,UAAU,EAAO,KAAK,MAAM,GAAG,EAAE,CAAC,aAAa;KAAE,MAAK;KAAO,CAAA,EACrE,kBAAC,OAAD;KAAK,WAAU;eAAf,CACE,kBAAC,OAAD;MAAK,WAAU;gBAA0C,EAAO;MAAW,CAAA,EAC3E,kBAAC,OAAD;MAAK,WAAU;gBAAwC,EAAO;MAAW,CAAA,CACrE;OACF;;GAET;EACD;GACE,KAAK;GACL,QAAQ,EAAG,8BAA8B,SAAS;GAClD,OAAO;GACP,SAAS,MAAmB,kBAAC,IAAD,EAAmB,QAAQ,EAAO,QAAU,CAAA;GACzE;EACD;GACE,KAAK;GACL,QAAQ,EAAG,4BAA4B,OAAO;GAC9C,OAAO;GACR;EACD;GACE,KAAK;GACL,QAAQ,EAAG,6BAA6B,QAAQ;GAChD,OAAO;GACP,OAAO;GACP,UAAU;GACV,SAAS,MAAmB,EAAa,EAAO,UAAU;GAC3D;EACD;GACE,KAAK;GACL,QAAQ,EAAG,+BAA+B,UAAU;GACpD,OAAO;GACP,OAAO;GACP,SAAS,MAAmB,GAAY,EAAO,YAAY;GAC5D;EACD;GACE,KAAK;GACL,QAAQ,EAAG,+BAA+B,UAAU;GACpD,OAAO;GACP,UAAU;GACV,SAAS,MAAmB,EAAW,EAAO,UAAU;GACzD;EACD;GACE,KAAK;GACL,QAAQ;GACR,OAAO;GACP,OAAO;GACP,SAAS,MACP,kBAAC,IAAD;IACE,SAAS,GAAc,EAAO;IAC9B,WAAW,EAAG,kCAAkC,wBAAwB,EACtE,MAAM,EAAO,MACd,CAAC;IACF,CAAA;GAEL;EACF;AAED,QACE,kBAAC,IAAD;EACE,SAAS;EACT,WAAW;EACX,WAAW,EAAG,uBAAuB,UAAU;EAC/C,aAAa,EAAG,6BAA6B,6CAA6C;YAE1F,kBAAC,IAAD;GACE,OAAO,EAAG,uBAAuB,UAAU;GAC3C,aACE,IACI,EACE,sCACA,wDACA,EAAE,UAAO,CACV,GACD,EAAG,6BAA6B,qCAAqC;GAE3E,SACE,KAAoB,IAClB,kBAAC,GAAD;IAAQ,SAAS;IAAoB,UAAU,MAAY;cAA3D,CACE,kBAAC,IAAD,EAAM,WAAU,gBAAiB,CAAA,EAChC,EAAG,sCAAsC,gBAAgB,CACnD;QACP,KAAA;aAjBR,CAoBE,kBAAC,OAAD;IAAK,WAAU;cAAf;KAEE,kBAAC,OAAD;MAAK,WAAU;gBAAf,CACE,kBAAC,OAAD;OAAK,WAAU;iBAAf,CACE,kBAAC,IAAD,EAAQ,WAAU,wEAAyE,CAAA,EAC3F,kBAAC,IAAD;QACE,aAAa,EAAG,mCAAmC,oBAAoB;QACvE,OAAO,EAAa;QACpB,WAAW,MAAM;AAEf,SADA,EAAQ,EAAE,EACV,EAAmB,EAAE,QAAQ,EAAE,OAAO,OAAO,CAAC;;QAEhD,WAAU;QACV,CAAA,CACE;UACN,kBAAC,OAAD;OAAK,WAAU;iBAAf,CACE,kBAAC,UAAD;QACE,OAAO,EAAa;QACpB,WAAW,MAAM;AAEf,SADA,EAAQ,EAAE,EACV,EAAmB,EAAE,QAAQ,EAAE,OAAO,OAAiC,CAAC;;QAE1E,WAAU;kBANZ;SAQE,kBAAC,UAAD;UAAQ,OAAM;oBAAO,EAAG,oCAAoC,aAAa;UAAU,CAAA;SACnF,kBAAC,UAAD;UAAQ,OAAM;oBACX,EAAG,uCAAuC,SAAS;UAC7C,CAAA;SACT,kBAAC,UAAD;UAAQ,OAAM;oBACX,EAAG,0CAA0C,YAAY;UACnD,CAAA;SACT,kBAAC,UAAD;UAAQ,OAAM;oBACX,EAAG,wCAAwC,UAAU;UAC/C,CAAA;SACF;YACP,EAAa,UAAU,EAAa,WAAW,UAC/C,kBAAC,GAAD;QACE,SAAQ;QACR,eAAe;AAEb,SADA,EAAQ,EAAE,EACV,GAAmB;;kBAGpB,EAAG,+BAA+B,QAAQ;QACpC,CAAA,CAEP;SACF;;KAGL,KACC,kBAAC,OAAD;MAAK,WAAU;gBAAf;OACG,EAAG,kCAAkC,yBAAyB;OAAC;OAAG,EAAM;OACrE;;KAIR,kBAAC,GAAD;MACW;MACT,MAAM;MACN,eAAe,MAAM,EAAE;MACvB,YAAY;MACZ,QAAQ,EAAa;MACrB,WAAW,EAAa;MACxB,SAAS,GAAQ,MACf,EAAmB;OACjB,QAAQ;OACR,WAAW;OACZ,CAAC;MAEK;MACT,YACE,kBAAC,GAAD;OACE,MAAM,kBAAC,GAAD,EAAW,WAAU,WAAY,CAAA;OACvC,OAAO,EAAG,6BAA6B,mBAAmB;OAC1D,aAAa,EACX,mCACA,uCACD;OACD,QACE,kBAAC,GAAD;QACE,SAAQ;QACR,eAAe;AAEb,SADA,EAAQ,EAAE,EACV,GAAmB;;kBAGpB,EAAG,oCAAoC,gBAAgB;QACjD,CAAA;OAEX,CAAA;MAEJ,CAAA;KAGD,IAAQ,KACP,kBAAC,GAAD;MACQ;MACM;MACZ,YAAY;MACZ,UAAU;MACV,cAAc;MACd,CAAA;KAEA;OAGN,kBAAC,IAAD;IACE,MAAM;IACN,eAAe,EAAY,GAAM;IACjC,QAAQ;IACR,QAAQ;IACR,CAAA,CACS;;EACG,CAAA"}
|
|
1
|
+
{"version":3,"file":"TenantsListPage.js","names":[],"sources":["../../../src/admin/pages/TenantsListPage.tsx"],"sourcesContent":["import { useState, useMemo, useCallback } from 'react';\nimport { useNavigate } from 'react-router-dom';\nimport { Plus, Search, Building2 } from 'lucide-react';\nimport { useAdmin } from '../providers/AdminProvider';\nimport { useAdminStore } from '../store/adminStore';\nimport { useAdminPermissions } from '../hooks/useAdminPermissions';\nimport {\n PageLayout,\n Button,\n Input,\n DataTable,\n Pagination,\n TenantStatusBadge,\n EmptyState,\n Avatar,\n TenantForm,\n RowActions,\n createRowActions,\n useToast,\n PermissionGuard,\n} from '../components';\nimport { joinPath } from '../utils/navigation';\nimport { formatDate, formatNumber, formatBytes } from '../utils/formatters';\nimport { mapTenantToUi } from '../utils/mappers';\nimport type { Tenant, TenantFilter } from '../types';\nimport type { CreateTenantInput, UpdateTenantInput } from '../schemas';\nimport {\n useListTenantsQuery,\n useCreateTenantMutation,\n useUpdateTenantMutation,\n useSuspendTenantMutation,\n useActivateTenantMutation,\n} from '../../generated/global-operations';\nimport type { RowAction } from '../components/RowActions';\nimport type { TenantStatus as GqlTenantStatus } from '../../generated/global-types';\nimport { useI18n } from '@burdenoff/fe-libs/shared/providers/shell/I18nProvider';\nimport { useFeatureFlag } from '@burdenoff/fe-libs/shared/feature-flags';\nimport { useEventBus } from '@burdenoff/fe-libs/shared/events';\nimport { tWithFallback } from '../utils/i18n';\nimport { emitTenantLifecycle } from '../utils/tenantEvents';\n\nconst PAGE_SIZE = 20;\n\n/**\n * Tenants list page\n */\nexport function TenantsListPage() {\n const { canViewTenants, canManageTenants, isLoading: permissionsLoading } = useAdminPermissions();\n\n const navigate = useNavigate();\n const { basePath = '' } = useAdmin();\n const { tenantFilter, updateTenantFilter, resetTenantFilter } = useAdminStore();\n const toast = useToast();\n const { t } = useI18n();\n // Page-level feature flags. Default-allow keeps prior behavior intact.\n // Both list-page entry points (the Create CTA and the row Edit action that\n // opens the local TenantForm modal) have to honor the flags — otherwise\n // turning the edit flag off would still let an admin edit via the modal\n // even though the route-level gate denies /admin/tenants/:id/edit.\n const createFlagEnabled = useFeatureFlag('admin.tenant.create', true);\n const editFlagEnabled = useFeatureFlag('admin.tenant.edit', true);\n const events = useEventBus();\n const tr = useCallback(\n (key: string, fallback: string, params?: Record<string, string | number>): string =>\n tWithFallback(t, key, fallback, params),\n [t]\n );\n\n const [page, setPage] = useState(1);\n\n // Form state\n const [formOpen, setFormOpen] = useState(false);\n const [editingTenant, setEditingTenant] = useState<Tenant | undefined>(undefined);\n\n // Build filter for GraphQL — only pass supported fields\n const gqlFilter = useMemo(() => {\n const f: { search?: string; status?: GqlTenantStatus } = {};\n if (tenantFilter.search) f.search = tenantFilter.search;\n const statusMap: Record<string, GqlTenantStatus> = {\n active: 'ACTIVE' as GqlTenantStatus,\n suspended: 'SUSPENDED' as GqlTenantStatus,\n deleted: 'DELETED' as GqlTenantStatus,\n };\n if (tenantFilter.status !== 'all' && statusMap[tenantFilter.status]) {\n f.status = statusMap[tenantFilter.status];\n }\n return f;\n }, [tenantFilter.search, tenantFilter.status]);\n\n const skipQueries = permissionsLoading || !canViewTenants;\n\n // Query tenants from backend\n const { data, loading, error, refetch } = useListTenantsQuery({\n variables: {\n filter: gqlFilter,\n limit: PAGE_SIZE,\n offset: (page - 1) * PAGE_SIZE,\n },\n fetchPolicy: 'cache-and-network',\n skip: skipQueries,\n });\n\n // Mutations\n const [createTenant, { loading: creating }] = useCreateTenantMutation({\n onCompleted: () => {\n void refetch();\n },\n });\n const [updateTenant, { loading: updating }] = useUpdateTenantMutation({\n onCompleted: () => {\n void refetch();\n },\n });\n const [suspendTenant] = useSuspendTenantMutation({\n onCompleted: () => {\n void refetch();\n },\n });\n const [activateTenant] = useActivateTenantMutation({\n onCompleted: () => {\n void refetch();\n },\n });\n\n const tenants: Tenant[] = useMemo(() => (data?.tenants?.items ?? []).map(mapTenantToUi), [data]);\n\n const total = data?.tenants?.total ?? 0;\n const totalPages = Math.ceil(total / PAGE_SIZE);\n\n const handleCreateTenant = useCallback(() => {\n setEditingTenant(undefined);\n setFormOpen(true);\n }, []);\n\n const handleEditTenant = useCallback((tenant: Tenant) => {\n setEditingTenant(tenant);\n setFormOpen(true);\n }, []);\n\n const handleSuspendTenant = useCallback(\n async (tenant: Tenant) => {\n try {\n await suspendTenant({ variables: { id: tenant.id } });\n emitTenantLifecycle(events, 'tenant.tenant.suspended', {\n tenantId: tenant.id,\n tenantSlug: tenant.slug,\n status: 'SUSPENDED',\n });\n toast.success(\n tr('admin.tenants.toast.suspendedTitle', 'Tenant suspended'),\n tr('admin.tenants.toast.suspendedDescription', '{{name}} has been suspended.', {\n name: tenant.name,\n })\n );\n } catch {\n toast.error(\n tr('admin.tenants.toast.errorTitle', 'Error'),\n tr('admin.tenants.toast.suspendFailed', 'Failed to suspend tenant.')\n );\n }\n },\n [suspendTenant, toast, tr, events]\n );\n\n const handleActivateTenant = useCallback(\n async (tenant: Tenant) => {\n try {\n await activateTenant({ variables: { id: tenant.id } });\n emitTenantLifecycle(events, 'tenant.tenant.activated', {\n tenantId: tenant.id,\n tenantSlug: tenant.slug,\n status: 'ACTIVE',\n });\n toast.success(\n tr('admin.tenants.toast.activatedTitle', 'Tenant activated'),\n tr('admin.tenants.toast.activatedDescription', '{{name}} has been activated.', {\n name: tenant.name,\n })\n );\n } catch {\n toast.error(\n tr('admin.tenants.toast.errorTitle', 'Error'),\n tr('admin.tenants.toast.activateFailed', 'Failed to activate tenant.')\n );\n }\n },\n [activateTenant, toast, tr, events]\n );\n\n const handleSaveTenant = useCallback(\n async (data: CreateTenantInput | UpdateTenantInput) => {\n const isEditing = 'id' in data && data.id;\n try {\n if (isEditing) {\n const updated = await updateTenant({\n variables: {\n id: (data as UpdateTenantInput).id,\n input: {\n name: data.name,\n customDomain: data.domain,\n metadata: {\n ownerEmail: data.ownerEmail,\n billingEmail: data.billingEmail,\n plan: data.plan,\n } as Record<string, unknown>,\n },\n },\n });\n const updatedId = updated.data?.updateTenant?.id ?? (data as UpdateTenantInput).id;\n // Use the slug from the persisted record. The update mutation does\n // not accept slug as input, so reading it from the form would emit\n // a value that may not match what the backend actually has.\n const persistedSlug = updated.data?.updateTenant?.slug;\n emitTenantLifecycle(events, 'tenant.tenant.updated', {\n tenantId: updatedId,\n tenantSlug: persistedSlug,\n });\n toast.success(\n tr('admin.tenants.toast.updatedTitle', 'Tenant updated'),\n tr('admin.tenants.toast.updatedDescription', '{{name}} has been updated.', {\n name: data.name ?? '',\n })\n );\n } else {\n const created = await createTenant({\n variables: {\n input: {\n name: data.name!,\n slug: data.slug!,\n customDomain: data.domain,\n metadata: {\n ownerEmail: data.ownerEmail,\n billingEmail: data.billingEmail,\n plan: data.plan,\n } as Record<string, unknown>,\n },\n },\n });\n const createdRecord = created.data?.createTenant;\n if (createdRecord?.id) {\n // Slug from the server-confirmed record so listeners never see a\n // form value the backend rejected (e.g. uniqueness collision).\n emitTenantLifecycle(events, 'tenant.tenant.created', {\n tenantId: createdRecord.id,\n tenantSlug: createdRecord.slug,\n });\n }\n toast.success(\n tr('admin.tenants.toast.createdTitle', 'Tenant created'),\n tr('admin.tenants.toast.createdDescription', '{{name}} has been created.', {\n name: data.name ?? '',\n })\n );\n }\n setFormOpen(false);\n } catch (err) {\n const msg = err instanceof Error ? err.message : 'Unknown error';\n toast.error(\n isEditing\n ? tr('admin.tenants.toast.updateFailedTitle', 'Update failed')\n : tr('admin.tenants.toast.createFailedTitle', 'Create failed'),\n msg\n );\n // Rethrow so TenantForm.onSubmit's await rejects and the modal stays\n // open with the user's input intact. Symmetric to the rethrow already\n // applied in TenantFormRoutePage.handleSave.\n throw err;\n }\n },\n [createTenant, updateTenant, toast, tr, events]\n );\n\n const handleRowClick = (tenant: Tenant) => {\n navigate(joinPath(basePath, `tenants/${tenant.id}`));\n };\n\n const getRowActions = useCallback(\n (tenant: Tenant): RowAction[] => {\n const actions = createRowActions({\n onView: () => navigate(joinPath(basePath, `tenants/${tenant.id}`)),\n onEdit: canManageTenants && editFlagEnabled ? () => handleEditTenant(tenant) : undefined,\n });\n if (canManageTenants) {\n actions.push({\n key: tenant.status === 'active' ? 'suspend' : 'activate',\n label:\n tenant.status === 'active'\n ? tr('admin.tenants.actions.suspend', 'Suspend')\n : tr('admin.tenants.actions.activate', 'Activate'),\n separator: true,\n onClick:\n tenant.status === 'active'\n ? () => handleSuspendTenant(tenant)\n : () => handleActivateTenant(tenant),\n });\n }\n return actions;\n },\n [\n navigate,\n basePath,\n canManageTenants,\n editFlagEnabled,\n handleEditTenant,\n handleSuspendTenant,\n handleActivateTenant,\n tr,\n ]\n );\n\n const columns = [\n {\n key: 'name',\n header: tr('admin.tenants.table.tenant', 'Tenant'),\n sortable: true,\n render: (tenant: Tenant) => (\n <div className=\"flex items-center gap-3\">\n <Avatar fallback={tenant.name.slice(0, 2).toUpperCase()} size=\"sm\" />\n <div className=\"min-w-0\">\n <div className=\"truncate font-medium text-text-primary\">{tenant.name}</div>\n <div className=\"truncate text-xs text-text-secondary\">{tenant.slug}</div>\n </div>\n </div>\n ),\n },\n {\n key: 'status',\n header: tr('admin.tenants.table.status', 'Status'),\n width: '120px',\n render: (tenant: Tenant) => <TenantStatusBadge status={tenant.status} />,\n },\n {\n key: 'plan',\n header: tr('admin.tenants.table.plan', 'Plan'),\n width: '100px',\n },\n {\n key: 'userCount',\n header: tr('admin.tenants.table.users', 'Users'),\n width: '80px',\n align: 'right' as const,\n sortable: true,\n render: (tenant: Tenant) => formatNumber(tenant.userCount),\n },\n {\n key: 'storageUsed',\n header: tr('admin.tenants.table.storage', 'Storage'),\n width: '100px',\n align: 'right' as const,\n render: (tenant: Tenant) => formatBytes(tenant.storageUsed),\n },\n {\n key: 'createdAt',\n header: tr('admin.tenants.table.created', 'Created'),\n width: '120px',\n sortable: true,\n render: (tenant: Tenant) => formatDate(tenant.createdAt),\n },\n {\n key: 'actions',\n header: '',\n width: '50px',\n align: 'right' as const,\n render: (tenant: Tenant) => (\n <RowActions\n actions={getRowActions(tenant)}\n ariaLabel={tr('admin.tenants.table.actionsFor', 'Actions for {{name}}', {\n name: tenant.name,\n })}\n />\n ),\n },\n ];\n\n return (\n <PermissionGuard\n allowed={canViewTenants}\n isLoading={permissionsLoading}\n pageTitle={tr('admin.tenants.title', 'Tenants')}\n denyMessage={tr('admin.tenants.denyMessage', \"You don't have permission to view tenants.\")}\n >\n <PageLayout\n title={tr('admin.tenants.title', 'Tenants')}\n gradientWord={tr('admin.tenants.title', 'Tenants')}\n description={\n total\n ? tr(\n 'admin.tenants.descriptionWithTotal',\n 'Manage all tenants on the platform · {{total}} total',\n { total }\n )\n : tr('admin.tenants.description', 'Manage all tenants on the platform')\n }\n actions={\n canManageTenants && createFlagEnabled ? (\n <Button onClick={handleCreateTenant} disabled={creating || updating}>\n <Plus className=\"mr-2 h-4 w-4\" />\n {tr('admin.tenants.actions.createTenant', 'Create Tenant')}\n </Button>\n ) : undefined\n }\n >\n <div className=\"space-y-4\">\n {/* Filters */}\n <div className=\"flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between\">\n <div className=\"relative max-w-sm flex-1\">\n <Search className=\"absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-secondary\" />\n <Input\n placeholder={tr('admin.tenants.searchPlaceholder', 'Search tenants...')}\n value={tenantFilter.search}\n onChange={(e) => {\n setPage(1);\n updateTenantFilter({ search: e.target.value });\n }}\n className=\"pl-9\"\n />\n </div>\n <div className=\"flex items-center gap-2\">\n <select\n value={tenantFilter.status}\n onChange={(e) => {\n setPage(1);\n updateTenantFilter({ status: e.target.value as TenantFilter['status'] });\n }}\n className=\"h-10 rounded-md border border-border-default bg-bg-surface px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-[var(--color-focus-ring)] focus:ring-offset-2\"\n >\n <option value=\"all\">{tr('admin.tenants.filters.status.all', 'All Status')}</option>\n <option value=\"active\">\n {tr('admin.tenants.filters.status.active', 'Active')}\n </option>\n <option value=\"suspended\">\n {tr('admin.tenants.filters.status.suspended', 'Suspended')}\n </option>\n <option value=\"deleted\">\n {tr('admin.tenants.filters.status.deleted', 'Deleted')}\n </option>\n </select>\n {(tenantFilter.search || tenantFilter.status !== 'all') && (\n <Button\n variant=\"ghost\"\n onClick={() => {\n setPage(1);\n resetTenantFilter();\n }}\n >\n {tr('admin.tenants.filters.clear', 'Clear')}\n </Button>\n )}\n </div>\n </div>\n\n {/* Error state */}\n {error && (\n <div className=\"rounded-md border border-status-error-border/50 bg-status-error-bg/10 p-4 text-sm text-status-error-text\">\n {tr('admin.tenants.error.loadFailed', 'Failed to load tenants')}: {error.message}\n </div>\n )}\n\n {/* Table */}\n <DataTable\n columns={columns}\n data={tenants}\n keyExtractor={(t) => t.id}\n onRowClick={handleRowClick}\n sortBy={tenantFilter.sortBy}\n sortOrder={tenantFilter.sortOrder}\n onSort={(column, order) =>\n updateTenantFilter({\n sortBy: column as TenantFilter['sortBy'],\n sortOrder: order,\n })\n }\n loading={loading}\n emptyState={\n <EmptyState\n illustration=\"empty-search\"\n icon={<Building2 className=\"h-8 w-8\" />}\n title={tr('admin.tenants.empty.title', 'No tenants found')}\n description={tr(\n 'admin.tenants.empty.description',\n 'Try adjusting your search or filters'\n )}\n action={\n <Button\n variant=\"outline\"\n onClick={() => {\n setPage(1);\n resetTenantFilter();\n }}\n >\n {tr('admin.tenants.empty.clearFilters', 'Clear filters')}\n </Button>\n }\n />\n }\n />\n\n {/* Pagination */}\n {total > PAGE_SIZE && (\n <Pagination\n page={page}\n totalPages={totalPages}\n totalItems={total}\n pageSize={PAGE_SIZE}\n onPageChange={setPage}\n />\n )}\n </div>\n\n {/* Tenant Form */}\n <TenantForm\n open={formOpen}\n onClose={() => setFormOpen(false)}\n tenant={editingTenant}\n onSave={handleSaveTenant}\n />\n </PageLayout>\n </PermissionGuard>\n );\n}\n"],"mappings":"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"}
|
|
@@ -144,6 +144,7 @@ function j() {
|
|
|
144
144
|
denyMessage: B("admin.users.denyMessage", "You don't have permission to view users."),
|
|
145
145
|
children: /* @__PURE__ */ D(s, {
|
|
146
146
|
title: B("admin.users.title", "Users"),
|
|
147
|
+
gradientWord: B("admin.users.title", "Users"),
|
|
147
148
|
description: $ ? B("admin.users.descriptionWithTotal", "Manage all users on the platform · {{total}} total", { total: $ }) : B("admin.users.description", "Manage all users on the platform"),
|
|
148
149
|
children: /* @__PURE__ */ O("div", {
|
|
149
150
|
className: "space-y-4",
|
|
@@ -219,6 +220,7 @@ function j() {
|
|
|
219
220
|
}),
|
|
220
221
|
loading: K || X,
|
|
221
222
|
emptyState: /* @__PURE__ */ D(a, {
|
|
223
|
+
illustration: "empty-people",
|
|
222
224
|
icon: /* @__PURE__ */ D(T, { className: "h-8 w-8" }),
|
|
223
225
|
title: B("admin.users.empty.title", "No users found"),
|
|
224
226
|
description: B("admin.users.empty.description", "Try adjusting your search or filters"),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"UsersListPage.js","names":[],"sources":["../../../src/admin/pages/UsersListPage.tsx"],"sourcesContent":["import { useState, useMemo, useCallback } from 'react';\nimport { useNavigate } from 'react-router-dom';\nimport { Search, Users as UsersIcon } from 'lucide-react';\nimport { useAdmin } from '../providers/AdminProvider';\nimport { useAdminStore } from '../store/adminStore';\nimport { useAdminPermissions } from '../hooks/useAdminPermissions';\nimport {\n PageLayout,\n Input,\n DataTable,\n Pagination,\n UserStatusBadge,\n EmptyState,\n Avatar,\n Button,\n RowActions,\n useToast,\n PermissionGuard,\n} from '../components';\nimport { joinPath } from '../utils/navigation';\nimport { formatDate, formatRelativeTime } from '../utils/formatters';\nimport { mapUserToUi } from '../utils/mappers';\nimport type { User, UserFilter } from '../types';\nimport { useListUsersQuery, useUpdateUserMutation } from '../../generated/global-operations';\nimport { UserStatus as GqlUserStatus } from '../../generated/global-types';\nimport { useI18n } from '@burdenoff/fe-libs/shared/providers/shell/I18nProvider';\nimport { tWithFallback } from '../utils/i18n';\n\nconst PAGE_SIZE = 20;\n\n/**\n * Users list page — connected to real backend with enable/disable actions\n */\nexport function UsersListPage() {\n const { canViewUsers, canUpdateUsers, isLoading: permissionsLoading } = useAdminPermissions();\n\n const navigate = useNavigate();\n const { basePath = '' } = useAdmin();\n const { userFilter, updateUserFilter } = useAdminStore();\n const toast = useToast();\n const { t } = useI18n();\n const tr = useCallback(\n (key: string, fallback: string, params?: Record<string, string | number>): string =>\n tWithFallback(t, key, fallback, params),\n [t]\n );\n\n const [page, setPage] = useState(1);\n\n // Build GraphQL filter\n const gqlFilter = useMemo(() => {\n const f: {\n search?: string;\n status?: GqlUserStatus;\n } = {};\n if (userFilter.search) f.search = userFilter.search;\n const statusMap: Record<string, GqlUserStatus> = {\n active: GqlUserStatus.Active,\n inactive: GqlUserStatus.Inactive,\n blocked: GqlUserStatus.Suspended,\n };\n if (userFilter.status !== 'all' && statusMap[userFilter.status]) {\n f.status = statusMap[userFilter.status];\n }\n return f;\n }, [userFilter.search, userFilter.status]);\n\n const skipQueries = permissionsLoading || !canViewUsers;\n\n const { data, loading, error, refetch } = useListUsersQuery({\n variables: {\n filter: gqlFilter,\n pagination: {\n limit: PAGE_SIZE,\n offset: (page - 1) * PAGE_SIZE,\n },\n },\n fetchPolicy: 'cache-and-network',\n skip: skipQueries,\n });\n\n const [updateUser, { loading: isUpdating }] = useUpdateUserMutation({\n onCompleted: () => {\n toast.success(\n tr('admin.users.toast.updatedTitle', 'User updated'),\n tr('admin.users.toast.updatedDescription', 'User status has been successfully updated.')\n );\n void refetch();\n },\n onError: (err) => {\n toast.error(tr('admin.users.toast.updateFailedTitle', 'Update failed'), err.message);\n },\n });\n\n const handleToggleStatus = useCallback(\n async (user: User) => {\n const newStatus = user.status === 'active' ? GqlUserStatus.Inactive : GqlUserStatus.Active;\n await updateUser({\n variables: {\n id: user.id,\n input: {\n status: newStatus,\n },\n },\n });\n },\n [updateUser]\n );\n\n const users: User[] = useMemo(() => (data?.users?.users ?? []).map(mapUserToUi), [data]);\n\n const total = data?.users?.totalCount ?? 0;\n const totalPages = Math.ceil(total / PAGE_SIZE);\n\n const getRowActions = useCallback(\n (user: User) => {\n const actions = [\n {\n key: 'view',\n label: tr('admin.users.actions.viewDetails', 'View Details'),\n onClick: () => navigate(joinPath(basePath, `users/${user.id}`)),\n },\n ];\n\n if (canUpdateUsers) {\n actions.push({\n key: 'toggle-status',\n label:\n user.status === 'active'\n ? tr('admin.users.actions.disableUser', 'Disable User')\n : tr('admin.users.actions.enableUser', 'Enable User'),\n onClick: () => handleToggleStatus(user),\n });\n }\n\n return actions;\n },\n [navigate, basePath, canUpdateUsers, handleToggleStatus, tr]\n );\n\n const columns = [\n {\n key: 'name',\n header: tr('admin.users.table.user', 'User'),\n render: (user: User) => (\n <div className=\"flex items-center gap-3\">\n <Avatar fallback={(user.firstName?.[0] ?? user.email[0]).toUpperCase()} size=\"sm\" />\n <div className=\"min-w-0\">\n <div className=\"truncate font-medium text-text-primary\">{user.name || user.email}</div>\n <div className=\"truncate text-xs text-text-secondary\">{user.email}</div>\n </div>\n </div>\n ),\n },\n {\n key: 'status',\n header: tr('admin.users.table.status', 'Status'),\n width: '120px',\n render: (user: User) => <UserStatusBadge status={user.status} />,\n },\n {\n key: 'role',\n header: tr('admin.users.table.role', 'Role'),\n width: '120px',\n },\n {\n key: 'tenantId',\n header: tr('admin.users.table.tenant', 'Tenant'),\n width: '150px',\n render: (user: User) => (\n <span className=\"text-xs text-text-secondary\">{user.tenantId || '—'}</span>\n ),\n },\n {\n key: 'createdAt',\n header: tr('admin.users.table.joined', 'Joined'),\n width: '120px',\n sortable: true,\n render: (user: User) => formatDate(user.createdAt),\n },\n {\n key: 'lastLoginAt',\n header: tr('admin.users.table.lastLogin', 'Last Login'),\n width: '130px',\n render: (user: User) =>\n user.lastLoginAt\n ? formatRelativeTime(user.lastLoginAt)\n : tr('admin.users.table.never', 'Never'),\n },\n {\n key: 'actions',\n header: '',\n width: '50px',\n align: 'right' as const,\n render: (user: User) => (\n <RowActions\n actions={getRowActions(user)}\n ariaLabel={tr('admin.users.table.actionsFor', 'Actions for {{email}}', {\n email: user.email,\n })}\n />\n ),\n },\n ];\n\n return (\n <PermissionGuard\n allowed={canViewUsers}\n isLoading={permissionsLoading}\n pageTitle={tr('admin.users.title', 'Users')}\n denyMessage={tr('admin.users.denyMessage', \"You don't have permission to view users.\")}\n >\n <PageLayout\n title={tr('admin.users.title', 'Users')}\n description={\n total\n ? tr(\n 'admin.users.descriptionWithTotal',\n 'Manage all users on the platform · {{total}} total',\n { total }\n )\n : tr('admin.users.description', 'Manage all users on the platform')\n }\n >\n <div className=\"space-y-4\">\n {/* Filters */}\n <div className=\"flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between\">\n <div className=\"relative max-w-sm flex-1\">\n <Search className=\"absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-secondary\" />\n <Input\n placeholder={tr('admin.users.searchPlaceholder', 'Search by email or name...')}\n value={userFilter.search}\n onChange={(e) => {\n setPage(1);\n updateUserFilter({ search: e.target.value });\n }}\n className=\"pl-9\"\n />\n </div>\n <div className=\"flex items-center gap-2\">\n <select\n value={userFilter.status}\n onChange={(e) => {\n setPage(1);\n updateUserFilter({ status: e.target.value as UserFilter['status'] });\n }}\n className=\"h-10 rounded-md border border-border-default bg-bg-surface px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-[var(--color-focus-ring)] focus:ring-offset-2\"\n >\n <option value=\"all\">{tr('admin.users.filters.status.all', 'All Status')}</option>\n <option value=\"active\">{tr('admin.users.filters.status.active', 'Active')}</option>\n <option value=\"inactive\">\n {tr('admin.users.filters.status.inactive', 'Inactive')}\n </option>\n <option value=\"blocked\">\n {tr('admin.users.filters.status.suspended', 'Suspended')}\n </option>\n </select>\n {(userFilter.search || userFilter.status !== 'all') && (\n <Button\n variant=\"ghost\"\n onClick={() => {\n setPage(1);\n updateUserFilter({ search: '', status: 'all' });\n }}\n >\n {tr('admin.users.filters.clear', 'Clear')}\n </Button>\n )}\n </div>\n </div>\n\n {/* Error */}\n {error && (\n <div className=\"rounded-md border border-status-error-border/50 bg-status-error-bg/10 p-4 text-sm text-status-error-text\">\n {tr('admin.users.error.loadFailed', 'Failed to load users')}: {error.message}\n </div>\n )}\n\n {/* Table */}\n <DataTable\n columns={columns}\n data={users}\n keyExtractor={(u) => u.id}\n onRowClick={(u) => navigate(joinPath(basePath, `users/${u.id}`))}\n sortBy={userFilter.sortBy}\n sortOrder={userFilter.sortOrder}\n onSort={(column, order) =>\n updateUserFilter({\n sortBy: column as UserFilter['sortBy'],\n sortOrder: order,\n })\n }\n loading={loading || isUpdating}\n emptyState={\n <EmptyState\n icon={<UsersIcon className=\"h-8 w-8\" />}\n title={tr('admin.users.empty.title', 'No users found')}\n description={tr(\n 'admin.users.empty.description',\n 'Try adjusting your search or filters'\n )}\n action={\n <Button\n variant=\"outline\"\n onClick={() => updateUserFilter({ search: '', status: 'all' })}\n >\n {tr('admin.users.empty.clearFilters', 'Clear filters')}\n </Button>\n }\n />\n }\n />\n\n {/* Pagination */}\n {total > PAGE_SIZE && (\n <Pagination\n page={page}\n totalPages={totalPages}\n totalItems={total}\n pageSize={PAGE_SIZE}\n onPageChange={setPage}\n />\n )}\n </div>\n </PageLayout>\n </PermissionGuard>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"UsersListPage.js","names":[],"sources":["../../../src/admin/pages/UsersListPage.tsx"],"sourcesContent":["import { useState, useMemo, useCallback } from 'react';\nimport { useNavigate } from 'react-router-dom';\nimport { Search, Users as UsersIcon } from 'lucide-react';\nimport { useAdmin } from '../providers/AdminProvider';\nimport { useAdminStore } from '../store/adminStore';\nimport { useAdminPermissions } from '../hooks/useAdminPermissions';\nimport {\n PageLayout,\n Input,\n DataTable,\n Pagination,\n UserStatusBadge,\n EmptyState,\n Avatar,\n Button,\n RowActions,\n useToast,\n PermissionGuard,\n} from '../components';\nimport { joinPath } from '../utils/navigation';\nimport { formatDate, formatRelativeTime } from '../utils/formatters';\nimport { mapUserToUi } from '../utils/mappers';\nimport type { User, UserFilter } from '../types';\nimport { useListUsersQuery, useUpdateUserMutation } from '../../generated/global-operations';\nimport { UserStatus as GqlUserStatus } from '../../generated/global-types';\nimport { useI18n } from '@burdenoff/fe-libs/shared/providers/shell/I18nProvider';\nimport { tWithFallback } from '../utils/i18n';\n\nconst PAGE_SIZE = 20;\n\n/**\n * Users list page — connected to real backend with enable/disable actions\n */\nexport function UsersListPage() {\n const { canViewUsers, canUpdateUsers, isLoading: permissionsLoading } = useAdminPermissions();\n\n const navigate = useNavigate();\n const { basePath = '' } = useAdmin();\n const { userFilter, updateUserFilter } = useAdminStore();\n const toast = useToast();\n const { t } = useI18n();\n const tr = useCallback(\n (key: string, fallback: string, params?: Record<string, string | number>): string =>\n tWithFallback(t, key, fallback, params),\n [t]\n );\n\n const [page, setPage] = useState(1);\n\n // Build GraphQL filter\n const gqlFilter = useMemo(() => {\n const f: {\n search?: string;\n status?: GqlUserStatus;\n } = {};\n if (userFilter.search) f.search = userFilter.search;\n const statusMap: Record<string, GqlUserStatus> = {\n active: GqlUserStatus.Active,\n inactive: GqlUserStatus.Inactive,\n blocked: GqlUserStatus.Suspended,\n };\n if (userFilter.status !== 'all' && statusMap[userFilter.status]) {\n f.status = statusMap[userFilter.status];\n }\n return f;\n }, [userFilter.search, userFilter.status]);\n\n const skipQueries = permissionsLoading || !canViewUsers;\n\n const { data, loading, error, refetch } = useListUsersQuery({\n variables: {\n filter: gqlFilter,\n pagination: {\n limit: PAGE_SIZE,\n offset: (page - 1) * PAGE_SIZE,\n },\n },\n fetchPolicy: 'cache-and-network',\n skip: skipQueries,\n });\n\n const [updateUser, { loading: isUpdating }] = useUpdateUserMutation({\n onCompleted: () => {\n toast.success(\n tr('admin.users.toast.updatedTitle', 'User updated'),\n tr('admin.users.toast.updatedDescription', 'User status has been successfully updated.')\n );\n void refetch();\n },\n onError: (err) => {\n toast.error(tr('admin.users.toast.updateFailedTitle', 'Update failed'), err.message);\n },\n });\n\n const handleToggleStatus = useCallback(\n async (user: User) => {\n const newStatus = user.status === 'active' ? GqlUserStatus.Inactive : GqlUserStatus.Active;\n await updateUser({\n variables: {\n id: user.id,\n input: {\n status: newStatus,\n },\n },\n });\n },\n [updateUser]\n );\n\n const users: User[] = useMemo(() => (data?.users?.users ?? []).map(mapUserToUi), [data]);\n\n const total = data?.users?.totalCount ?? 0;\n const totalPages = Math.ceil(total / PAGE_SIZE);\n\n const getRowActions = useCallback(\n (user: User) => {\n const actions = [\n {\n key: 'view',\n label: tr('admin.users.actions.viewDetails', 'View Details'),\n onClick: () => navigate(joinPath(basePath, `users/${user.id}`)),\n },\n ];\n\n if (canUpdateUsers) {\n actions.push({\n key: 'toggle-status',\n label:\n user.status === 'active'\n ? tr('admin.users.actions.disableUser', 'Disable User')\n : tr('admin.users.actions.enableUser', 'Enable User'),\n onClick: () => handleToggleStatus(user),\n });\n }\n\n return actions;\n },\n [navigate, basePath, canUpdateUsers, handleToggleStatus, tr]\n );\n\n const columns = [\n {\n key: 'name',\n header: tr('admin.users.table.user', 'User'),\n render: (user: User) => (\n <div className=\"flex items-center gap-3\">\n <Avatar fallback={(user.firstName?.[0] ?? user.email[0]).toUpperCase()} size=\"sm\" />\n <div className=\"min-w-0\">\n <div className=\"truncate font-medium text-text-primary\">{user.name || user.email}</div>\n <div className=\"truncate text-xs text-text-secondary\">{user.email}</div>\n </div>\n </div>\n ),\n },\n {\n key: 'status',\n header: tr('admin.users.table.status', 'Status'),\n width: '120px',\n render: (user: User) => <UserStatusBadge status={user.status} />,\n },\n {\n key: 'role',\n header: tr('admin.users.table.role', 'Role'),\n width: '120px',\n },\n {\n key: 'tenantId',\n header: tr('admin.users.table.tenant', 'Tenant'),\n width: '150px',\n render: (user: User) => (\n <span className=\"text-xs text-text-secondary\">{user.tenantId || '—'}</span>\n ),\n },\n {\n key: 'createdAt',\n header: tr('admin.users.table.joined', 'Joined'),\n width: '120px',\n sortable: true,\n render: (user: User) => formatDate(user.createdAt),\n },\n {\n key: 'lastLoginAt',\n header: tr('admin.users.table.lastLogin', 'Last Login'),\n width: '130px',\n render: (user: User) =>\n user.lastLoginAt\n ? formatRelativeTime(user.lastLoginAt)\n : tr('admin.users.table.never', 'Never'),\n },\n {\n key: 'actions',\n header: '',\n width: '50px',\n align: 'right' as const,\n render: (user: User) => (\n <RowActions\n actions={getRowActions(user)}\n ariaLabel={tr('admin.users.table.actionsFor', 'Actions for {{email}}', {\n email: user.email,\n })}\n />\n ),\n },\n ];\n\n return (\n <PermissionGuard\n allowed={canViewUsers}\n isLoading={permissionsLoading}\n pageTitle={tr('admin.users.title', 'Users')}\n denyMessage={tr('admin.users.denyMessage', \"You don't have permission to view users.\")}\n >\n <PageLayout\n title={tr('admin.users.title', 'Users')}\n gradientWord={tr('admin.users.title', 'Users')}\n description={\n total\n ? tr(\n 'admin.users.descriptionWithTotal',\n 'Manage all users on the platform · {{total}} total',\n { total }\n )\n : tr('admin.users.description', 'Manage all users on the platform')\n }\n >\n <div className=\"space-y-4\">\n {/* Filters */}\n <div className=\"flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between\">\n <div className=\"relative max-w-sm flex-1\">\n <Search className=\"absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-secondary\" />\n <Input\n placeholder={tr('admin.users.searchPlaceholder', 'Search by email or name...')}\n value={userFilter.search}\n onChange={(e) => {\n setPage(1);\n updateUserFilter({ search: e.target.value });\n }}\n className=\"pl-9\"\n />\n </div>\n <div className=\"flex items-center gap-2\">\n <select\n value={userFilter.status}\n onChange={(e) => {\n setPage(1);\n updateUserFilter({ status: e.target.value as UserFilter['status'] });\n }}\n className=\"h-10 rounded-md border border-border-default bg-bg-surface px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-[var(--color-focus-ring)] focus:ring-offset-2\"\n >\n <option value=\"all\">{tr('admin.users.filters.status.all', 'All Status')}</option>\n <option value=\"active\">{tr('admin.users.filters.status.active', 'Active')}</option>\n <option value=\"inactive\">\n {tr('admin.users.filters.status.inactive', 'Inactive')}\n </option>\n <option value=\"blocked\">\n {tr('admin.users.filters.status.suspended', 'Suspended')}\n </option>\n </select>\n {(userFilter.search || userFilter.status !== 'all') && (\n <Button\n variant=\"ghost\"\n onClick={() => {\n setPage(1);\n updateUserFilter({ search: '', status: 'all' });\n }}\n >\n {tr('admin.users.filters.clear', 'Clear')}\n </Button>\n )}\n </div>\n </div>\n\n {/* Error */}\n {error && (\n <div className=\"rounded-md border border-status-error-border/50 bg-status-error-bg/10 p-4 text-sm text-status-error-text\">\n {tr('admin.users.error.loadFailed', 'Failed to load users')}: {error.message}\n </div>\n )}\n\n {/* Table */}\n <DataTable\n columns={columns}\n data={users}\n keyExtractor={(u) => u.id}\n onRowClick={(u) => navigate(joinPath(basePath, `users/${u.id}`))}\n sortBy={userFilter.sortBy}\n sortOrder={userFilter.sortOrder}\n onSort={(column, order) =>\n updateUserFilter({\n sortBy: column as UserFilter['sortBy'],\n sortOrder: order,\n })\n }\n loading={loading || isUpdating}\n emptyState={\n <EmptyState\n illustration=\"empty-people\"\n icon={<UsersIcon className=\"h-8 w-8\" />}\n title={tr('admin.users.empty.title', 'No users found')}\n description={tr(\n 'admin.users.empty.description',\n 'Try adjusting your search or filters'\n )}\n action={\n <Button\n variant=\"outline\"\n onClick={() => updateUserFilter({ search: '', status: 'all' })}\n >\n {tr('admin.users.empty.clearFilters', 'Clear filters')}\n </Button>\n }\n />\n }\n />\n\n {/* Pagination */}\n {total > PAGE_SIZE && (\n <Pagination\n page={page}\n totalPages={totalPages}\n totalItems={total}\n pageSize={PAGE_SIZE}\n onPageChange={setPage}\n />\n )}\n </div>\n </PageLayout>\n </PermissionGuard>\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;AA4BA,IAAM,IAAY;AAKlB,SAAgB,IAAgB;CAC9B,IAAM,EAAE,iBAAc,mBAAgB,WAAW,MAAuB,GAAqB,EAEvF,IAAW,GAAa,EACxB,EAAE,cAAW,OAAO,GAAU,EAC9B,EAAE,eAAY,wBAAqB,GAAe,EAClD,IAAQ,GAAU,EAClB,EAAE,SAAM,GAAS,EACjB,IAAK,GACR,GAAa,GAAkB,MAC9B,EAAc,GAAG,GAAK,GAAU,EAAO,EACzC,CAAC,EAAE,CACJ,EAEK,CAAC,GAAM,KAAW,EAAS,EAAE,EAG7B,IAAY,QAAc;EAC9B,IAAM,IAGF,EAAE;AACN,EAAI,EAAW,WAAQ,EAAE,SAAS,EAAW;EAC7C,IAAM,IAA2C;GAC/C,QAAQ,EAAc;GACtB,UAAU,EAAc;GACxB,SAAS,EAAc;GACxB;AAID,SAHI,EAAW,WAAW,SAAS,EAAU,EAAW,YACtD,EAAE,SAAS,EAAU,EAAW,UAE3B;IACN,CAAC,EAAW,QAAQ,EAAW,OAAO,CAAC,EAEpC,IAAc,KAAsB,CAAC,GAErC,EAAE,SAAM,YAAS,UAAO,eAAY,EAAkB;EAC1D,WAAW;GACT,QAAQ;GACR,YAAY;IACV,OAAO;IACP,SAAS,IAAO,KAAK;IACtB;GACF;EACD,aAAa;EACb,MAAM;EACP,CAAC,EAEI,CAAC,GAAY,EAAE,SAAS,OAAgB,EAAsB;EAClE,mBAAmB;AAKZ,GAJL,EAAM,QACJ,EAAG,kCAAkC,eAAe,EACpD,EAAG,wCAAwC,6CAA6C,CACzF,EACI,GAAS;;EAEhB,UAAU,MAAQ;AAChB,KAAM,MAAM,EAAG,uCAAuC,gBAAgB,EAAE,EAAI,QAAQ;;EAEvF,CAAC,EAEI,IAAqB,EACzB,OAAO,MAAe;EACpB,IAAM,IAAY,EAAK,WAAW,WAAW,EAAc,WAAW,EAAc;AACpF,QAAM,EAAW,EACf,WAAW;GACT,IAAI,EAAK;GACT,OAAO,EACL,QAAQ,GACT;GACF,EACF,CAAC;IAEJ,CAAC,EAAW,CACb,EAEK,IAAgB,SAAe,GAAM,OAAO,SAAS,EAAE,EAAE,IAAI,EAAY,EAAE,CAAC,EAAK,CAAC,EAElF,IAAQ,GAAM,OAAO,cAAc,GACnC,KAAa,KAAK,KAAK,IAAQ,EAAU,EAEzC,KAAgB,GACnB,MAAe;EACd,IAAM,IAAU,CACd;GACE,KAAK;GACL,OAAO,EAAG,mCAAmC,eAAe;GAC5D,eAAe,EAAS,EAAS,GAAU,SAAS,EAAK,KAAK,CAAC;GAChE,CACF;AAaD,SAXI,KACF,EAAQ,KAAK;GACX,KAAK;GACL,OACE,EAAK,WAAW,WACZ,EAAG,mCAAmC,eAAe,GACrD,EAAG,kCAAkC,cAAc;GACzD,eAAe,EAAmB,EAAK;GACxC,CAAC,EAGG;IAET;EAAC;EAAU;EAAU;EAAgB;EAAoB;EAAG,CAC7D,EAEK,KAAU;EACd;GACE,KAAK;GACL,QAAQ,EAAG,0BAA0B,OAAO;GAC5C,SAAS,MACP,kBAAC,OAAD;IAAK,WAAU;cAAf,CACE,kBAAC,GAAD;KAAQ,WAAW,EAAK,YAAY,MAAM,EAAK,MAAM,IAAI,aAAa;KAAE,MAAK;KAAO,CAAA,EACpF,kBAAC,OAAD;KAAK,WAAU;eAAf,CACE,kBAAC,OAAD;MAAK,WAAU;gBAA0C,EAAK,QAAQ,EAAK;MAAY,CAAA,EACvF,kBAAC,OAAD;MAAK,WAAU;gBAAwC,EAAK;MAAY,CAAA,CACpE;OACF;;GAET;EACD;GACE,KAAK;GACL,QAAQ,EAAG,4BAA4B,SAAS;GAChD,OAAO;GACP,SAAS,MAAe,kBAAC,GAAD,EAAiB,QAAQ,EAAK,QAAU,CAAA;GACjE;EACD;GACE,KAAK;GACL,QAAQ,EAAG,0BAA0B,OAAO;GAC5C,OAAO;GACR;EACD;GACE,KAAK;GACL,QAAQ,EAAG,4BAA4B,SAAS;GAChD,OAAO;GACP,SAAS,MACP,kBAAC,QAAD;IAAM,WAAU;cAA+B,EAAK,YAAY;IAAW,CAAA;GAE9E;EACD;GACE,KAAK;GACL,QAAQ,EAAG,4BAA4B,SAAS;GAChD,OAAO;GACP,UAAU;GACV,SAAS,MAAe,GAAW,EAAK,UAAU;GACnD;EACD;GACE,KAAK;GACL,QAAQ,EAAG,+BAA+B,aAAa;GACvD,OAAO;GACP,SAAS,MACP,EAAK,cACD,EAAmB,EAAK,YAAY,GACpC,EAAG,2BAA2B,QAAQ;GAC7C;EACD;GACE,KAAK;GACL,QAAQ;GACR,OAAO;GACP,OAAO;GACP,SAAS,MACP,kBAAC,GAAD;IACE,SAAS,GAAc,EAAK;IAC5B,WAAW,EAAG,gCAAgC,yBAAyB,EACrE,OAAO,EAAK,OACb,CAAC;IACF,CAAA;GAEL;EACF;AAED,QACE,kBAAC,GAAD;EACE,SAAS;EACT,WAAW;EACX,WAAW,EAAG,qBAAqB,QAAQ;EAC3C,aAAa,EAAG,2BAA2B,2CAA2C;YAEtF,kBAAC,GAAD;GACE,OAAO,EAAG,qBAAqB,QAAQ;GACvC,cAAc,EAAG,qBAAqB,QAAQ;GAC9C,aACE,IACI,EACE,oCACA,sDACA,EAAE,UAAO,CACV,GACD,EAAG,2BAA2B,mCAAmC;aAGvE,kBAAC,OAAD;IAAK,WAAU;cAAf;KAEE,kBAAC,OAAD;MAAK,WAAU;gBAAf,CACE,kBAAC,OAAD;OAAK,WAAU;iBAAf,CACE,kBAAC,GAAD,EAAQ,WAAU,wEAAyE,CAAA,EAC3F,kBAAC,GAAD;QACE,aAAa,EAAG,iCAAiC,6BAA6B;QAC9E,OAAO,EAAW;QAClB,WAAW,MAAM;AAEf,SADA,EAAQ,EAAE,EACV,EAAiB,EAAE,QAAQ,EAAE,OAAO,OAAO,CAAC;;QAE9C,WAAU;QACV,CAAA,CACE;UACN,kBAAC,OAAD;OAAK,WAAU;iBAAf,CACE,kBAAC,UAAD;QACE,OAAO,EAAW;QAClB,WAAW,MAAM;AAEf,SADA,EAAQ,EAAE,EACV,EAAiB,EAAE,QAAQ,EAAE,OAAO,OAA+B,CAAC;;QAEtE,WAAU;kBANZ;SAQE,kBAAC,UAAD;UAAQ,OAAM;oBAAO,EAAG,kCAAkC,aAAa;UAAU,CAAA;SACjF,kBAAC,UAAD;UAAQ,OAAM;oBAAU,EAAG,qCAAqC,SAAS;UAAU,CAAA;SACnF,kBAAC,UAAD;UAAQ,OAAM;oBACX,EAAG,uCAAuC,WAAW;UAC/C,CAAA;SACT,kBAAC,UAAD;UAAQ,OAAM;oBACX,EAAG,wCAAwC,YAAY;UACjD,CAAA;SACF;YACP,EAAW,UAAU,EAAW,WAAW,UAC3C,kBAAC,GAAD;QACE,SAAQ;QACR,eAAe;AAEb,SADA,EAAQ,EAAE,EACV,EAAiB;UAAE,QAAQ;UAAI,QAAQ;UAAO,CAAC;;kBAGhD,EAAG,6BAA6B,QAAQ;QAClC,CAAA,CAEP;SACF;;KAGL,KACC,kBAAC,OAAD;MAAK,WAAU;gBAAf;OACG,EAAG,gCAAgC,uBAAuB;OAAC;OAAG,EAAM;OACjE;;KAIR,kBAAC,GAAD;MACW;MACT,MAAM;MACN,eAAe,MAAM,EAAE;MACvB,aAAa,MAAM,EAAS,EAAS,GAAU,SAAS,EAAE,KAAK,CAAC;MAChE,QAAQ,EAAW;MACnB,WAAW,EAAW;MACtB,SAAS,GAAQ,MACf,EAAiB;OACf,QAAQ;OACR,WAAW;OACZ,CAAC;MAEJ,SAAS,KAAW;MACpB,YACE,kBAAC,GAAD;OACE,cAAa;OACb,MAAM,kBAAC,GAAD,EAAW,WAAU,WAAY,CAAA;OACvC,OAAO,EAAG,2BAA2B,iBAAiB;OACtD,aAAa,EACX,iCACA,uCACD;OACD,QACE,kBAAC,GAAD;QACE,SAAQ;QACR,eAAe,EAAiB;SAAE,QAAQ;SAAI,QAAQ;SAAO,CAAC;kBAE7D,EAAG,kCAAkC,gBAAgB;QAC/C,CAAA;OAEX,CAAA;MAEJ,CAAA;KAGD,IAAQ,KACP,kBAAC,GAAD;MACQ;MACM;MACZ,YAAY;MACZ,UAAU;MACV,cAAc;MACd,CAAA;KAEA;;GACK,CAAA;EACG,CAAA"}
|
|
@@ -166,6 +166,7 @@ function k() {
|
|
|
166
166
|
denyMessage: "You don't have permission to view the waitlist.",
|
|
167
167
|
children: /* @__PURE__ */ y(i, {
|
|
168
168
|
title: "Waitlist Management",
|
|
169
|
+
gradientWord: "Management",
|
|
169
170
|
description: `Product waitlist entries${X ? ` \u00b7 ${X} total` : ""}`,
|
|
170
171
|
children: /* @__PURE__ */ b("div", {
|
|
171
172
|
className: "space-y-4",
|
|
@@ -218,6 +219,7 @@ function k() {
|
|
|
218
219
|
keyExtractor: (e) => e.id,
|
|
219
220
|
loading: B,
|
|
220
221
|
emptyState: /* @__PURE__ */ y(n, {
|
|
222
|
+
illustration: "empty-search",
|
|
221
223
|
icon: /* @__PURE__ */ y(m, { className: "h-8 w-8" }),
|
|
222
224
|
title: "No waitlist entries",
|
|
223
225
|
description: "No entries match your current filters"
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"WaitlistPage.js","names":[],"sources":["../../../src/admin/pages/WaitlistPage.tsx"],"sourcesContent":["import { useState, useMemo, useCallback, useEffect } from 'react';\nimport { ClipboardList, Search, Check, X, Trash2 } from 'lucide-react';\nimport { useAdminPermissions } from '../hooks/useAdminPermissions';\nimport {\n PageLayout,\n Input,\n DataTable,\n Pagination,\n EmptyState,\n Badge,\n PermissionGuard,\n} from '../components';\nimport { formatDate } from '../utils/formatters';\nimport { useGatewayClient } from '@burdenoff/fe-libs/shared/graphql';\nimport { gql } from '@apollo/client';\n\nconst PAGE_SIZE = 20;\n\ntype WaitlistStatus = 'PENDING' | 'APPROVED' | 'REJECTED';\n\nconst STATUS_VARIANT: Record<WaitlistStatus, 'default' | 'secondary' | 'outline' | 'destructive'> =\n {\n PENDING: 'outline',\n APPROVED: 'default',\n REJECTED: 'destructive',\n };\n\nconst GET_WAITLIST = gql`\n query GetWaitlist($filter: WaitlistFilterInput) {\n waitlist(filter: $filter) {\n id\n email\n name\n company\n platformProductId\n status\n reason\n rejectionReason\n processedAt\n processedBy\n createdAt\n }\n }\n`;\n\nconst APPROVE_WAITLIST = gql`\n mutation ApproveWaitlist($input: ApproveWaitlistInput!) {\n approveWaitlist(input: $input) {\n id\n status\n }\n }\n`;\n\nconst REJECT_WAITLIST = gql`\n mutation RejectWaitlist($input: RejectWaitlistInput!) {\n rejectWaitlist(input: $input) {\n id\n status\n }\n }\n`;\n\nconst REMOVE_WAITLIST = gql`\n mutation RemoveFromWaitlist($id: ID!) {\n removeFromWaitlist(id: $id)\n }\n`;\n\ntype WaitlistItem = {\n id: string;\n email: string;\n name: string | null;\n company: string | null;\n platformProductId: string;\n status: WaitlistStatus;\n reason: string | null;\n rejectionReason: string | null;\n processedAt: string | null;\n processedBy: string | null;\n createdAt: string;\n};\n\nexport function WaitlistPage() {\n const {\n canViewWaitlist,\n canManageWaitlist,\n isLoading: permissionsLoading,\n } = useAdminPermissions();\n const globalClient = useGatewayClient('global');\n\n const [page, setPage] = useState(1);\n const [search, setSearch] = useState('');\n const [statusFilter, setStatusFilter] = useState<WaitlistStatus | ''>('');\n const [data, setData] = useState<{ waitlist?: WaitlistItem[] } | null>(null);\n const [loading, setLoading] = useState(false);\n const [error, setError] = useState<{ message: string } | null>(null);\n const [fetchCount, setFetchCount] = useState(0);\n\n const filter = useMemo(() => {\n const f: Record<string, unknown> = {};\n if (statusFilter) f.status = statusFilter;\n return Object.keys(f).length > 0 ? f : undefined;\n }, [statusFilter]);\n\n const refetch = useCallback(() => setFetchCount((c) => c + 1), []);\n\n useEffect(() => {\n if (permissionsLoading || !canViewWaitlist) return;\n setLoading(true);\n globalClient\n .query({ query: GET_WAITLIST, variables: { filter }, fetchPolicy: 'network-only' })\n .then((result) => {\n const d = result.data as { waitlist?: WaitlistItem[] } | undefined;\n setData(d ?? null);\n setError(null);\n })\n .catch((err: Error) => setError({ message: err.message }))\n .finally(() => setLoading(false));\n }, [globalClient, filter, permissionsLoading, canViewWaitlist, fetchCount]);\n\n const allEntries: WaitlistItem[] = useMemo(\n () => (data?.waitlist ?? []) as WaitlistItem[],\n [data]\n );\n\n // Client-side search filter (email, name, company)\n const filtered = useMemo(() => {\n if (!search) return allEntries;\n const q = search.toLowerCase();\n return allEntries.filter(\n (e) =>\n e.email.toLowerCase().includes(q) ||\n (e.name && e.name.toLowerCase().includes(q)) ||\n (e.company && e.company.toLowerCase().includes(q))\n );\n }, [allEntries, search]);\n\n const total = filtered.length;\n const totalPages = Math.ceil(total / PAGE_SIZE);\n const paged = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);\n\n const handleApprove = useCallback(\n async (id: string) => {\n await globalClient.mutate({ mutation: APPROVE_WAITLIST, variables: { input: { id } } });\n refetch();\n },\n [globalClient, refetch]\n );\n\n const handleReject = useCallback(\n async (id: string) => {\n const reason = window.prompt('Rejection reason (optional):');\n await globalClient.mutate({\n mutation: REJECT_WAITLIST,\n variables: { input: { id, reason } },\n });\n refetch();\n },\n [globalClient, refetch]\n );\n\n const handleRemove = useCallback(\n async (id: string) => {\n if (!window.confirm('Remove this waitlist entry?')) return;\n await globalClient.mutate({ mutation: REMOVE_WAITLIST, variables: { id } });\n refetch();\n },\n [globalClient, refetch]\n );\n\n const columns = [\n {\n key: 'email',\n header: 'Email',\n render: (e: WaitlistItem) => (\n <div className=\"min-w-0\">\n <div className=\"font-medium text-text-primary\">{e.email}</div>\n {e.name && <div className=\"text-xs text-text-secondary\">{e.name}</div>}\n </div>\n ),\n },\n {\n key: 'company',\n header: 'Company',\n width: '140px',\n render: (e: WaitlistItem) => (\n <span className=\"text-sm text-text-secondary\">{e.company || '-'}</span>\n ),\n },\n {\n key: 'status',\n header: 'Status',\n width: '100px',\n render: (e: WaitlistItem) => (\n <Badge variant={STATUS_VARIANT[e.status] ?? 'outline'} className=\"text-xs capitalize\">\n {e.status.toLowerCase()}\n </Badge>\n ),\n },\n {\n key: 'createdAt',\n header: 'Joined',\n width: '110px',\n render: (e: WaitlistItem) => formatDate(e.createdAt),\n },\n ...(canManageWaitlist\n ? [\n {\n key: 'actions',\n header: 'Actions',\n width: '140px',\n render: (e: WaitlistItem) => (\n <div className=\"flex items-center gap-1\">\n {e.status === 'PENDING' && (\n <>\n <button\n onClick={() => handleApprove(e.id)}\n className=\"rounded p-1 text-green-500 hover:bg-green-500/10\"\n title=\"Approve\"\n >\n <Check className=\"h-4 w-4\" />\n </button>\n <button\n onClick={() => handleReject(e.id)}\n className=\"rounded p-1 text-red-500 hover:bg-red-500/10\"\n title=\"Reject\"\n >\n <X className=\"h-4 w-4\" />\n </button>\n </>\n )}\n <button\n onClick={() => handleRemove(e.id)}\n className=\"rounded p-1 text-text-secondary hover:bg-bg-sunken\"\n title=\"Remove\"\n >\n <Trash2 className=\"h-4 w-4\" />\n </button>\n </div>\n ),\n },\n ]\n : []),\n ];\n\n return (\n <PermissionGuard\n allowed={canViewWaitlist}\n isLoading={permissionsLoading}\n pageTitle=\"Waitlist\"\n denyMessage=\"You don't have permission to view the waitlist.\"\n >\n <PageLayout\n title=\"Waitlist Management\"\n description={`Product waitlist entries${total ? ` \\u00b7 ${total} total` : ''}`}\n >\n <div className=\"space-y-4\">\n <div className=\"flex flex-wrap items-center gap-3\">\n <div className=\"relative max-w-sm flex-1\">\n <Search className=\"absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-secondary\" />\n <Input\n placeholder=\"Search by email, name, or company...\"\n value={search}\n onChange={(e) => {\n setPage(1);\n setSearch(e.target.value);\n }}\n className=\"pl-9\"\n />\n </div>\n <select\n value={statusFilter}\n onChange={(e) => {\n setPage(1);\n setStatusFilter(e.target.value as WaitlistStatus | '');\n }}\n className=\"rounded-md border border-border-default bg-bg-surface px-3 py-2 text-sm\"\n >\n <option value=\"\">All statuses</option>\n <option value=\"PENDING\">Pending</option>\n <option value=\"APPROVED\">Approved</option>\n <option value=\"REJECTED\">Rejected</option>\n </select>\n </div>\n\n {error && (\n <div className=\"rounded-md border border-status-error-border/50 bg-status-error-bg/10 p-4 text-sm text-status-error-text\">\n Failed to load waitlist: {error.message}\n </div>\n )}\n\n <DataTable\n columns={columns}\n data={paged}\n keyExtractor={(e) => e.id}\n loading={loading}\n emptyState={\n <EmptyState\n icon={<ClipboardList className=\"h-8 w-8\" />}\n title=\"No waitlist entries\"\n description=\"No entries match your current filters\"\n />\n }\n />\n\n {total > PAGE_SIZE && (\n <Pagination\n page={page}\n totalPages={totalPages}\n totalItems={total}\n pageSize={PAGE_SIZE}\n onPageChange={setPage}\n />\n )}\n </div>\n </PageLayout>\n </PermissionGuard>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"WaitlistPage.js","names":[],"sources":["../../../src/admin/pages/WaitlistPage.tsx"],"sourcesContent":["import { useState, useMemo, useCallback, useEffect } from 'react';\nimport { ClipboardList, Search, Check, X, Trash2 } from 'lucide-react';\nimport { useAdminPermissions } from '../hooks/useAdminPermissions';\nimport {\n PageLayout,\n Input,\n DataTable,\n Pagination,\n EmptyState,\n Badge,\n PermissionGuard,\n} from '../components';\nimport { formatDate } from '../utils/formatters';\nimport { useGatewayClient } from '@burdenoff/fe-libs/shared/graphql';\nimport { gql } from '@apollo/client';\n\nconst PAGE_SIZE = 20;\n\ntype WaitlistStatus = 'PENDING' | 'APPROVED' | 'REJECTED';\n\nconst STATUS_VARIANT: Record<WaitlistStatus, 'default' | 'secondary' | 'outline' | 'destructive'> =\n {\n PENDING: 'outline',\n APPROVED: 'default',\n REJECTED: 'destructive',\n };\n\nconst GET_WAITLIST = gql`\n query GetWaitlist($filter: WaitlistFilterInput) {\n waitlist(filter: $filter) {\n id\n email\n name\n company\n platformProductId\n status\n reason\n rejectionReason\n processedAt\n processedBy\n createdAt\n }\n }\n`;\n\nconst APPROVE_WAITLIST = gql`\n mutation ApproveWaitlist($input: ApproveWaitlistInput!) {\n approveWaitlist(input: $input) {\n id\n status\n }\n }\n`;\n\nconst REJECT_WAITLIST = gql`\n mutation RejectWaitlist($input: RejectWaitlistInput!) {\n rejectWaitlist(input: $input) {\n id\n status\n }\n }\n`;\n\nconst REMOVE_WAITLIST = gql`\n mutation RemoveFromWaitlist($id: ID!) {\n removeFromWaitlist(id: $id)\n }\n`;\n\ntype WaitlistItem = {\n id: string;\n email: string;\n name: string | null;\n company: string | null;\n platformProductId: string;\n status: WaitlistStatus;\n reason: string | null;\n rejectionReason: string | null;\n processedAt: string | null;\n processedBy: string | null;\n createdAt: string;\n};\n\nexport function WaitlistPage() {\n const {\n canViewWaitlist,\n canManageWaitlist,\n isLoading: permissionsLoading,\n } = useAdminPermissions();\n const globalClient = useGatewayClient('global');\n\n const [page, setPage] = useState(1);\n const [search, setSearch] = useState('');\n const [statusFilter, setStatusFilter] = useState<WaitlistStatus | ''>('');\n const [data, setData] = useState<{ waitlist?: WaitlistItem[] } | null>(null);\n const [loading, setLoading] = useState(false);\n const [error, setError] = useState<{ message: string } | null>(null);\n const [fetchCount, setFetchCount] = useState(0);\n\n const filter = useMemo(() => {\n const f: Record<string, unknown> = {};\n if (statusFilter) f.status = statusFilter;\n return Object.keys(f).length > 0 ? f : undefined;\n }, [statusFilter]);\n\n const refetch = useCallback(() => setFetchCount((c) => c + 1), []);\n\n useEffect(() => {\n if (permissionsLoading || !canViewWaitlist) return;\n setLoading(true);\n globalClient\n .query({ query: GET_WAITLIST, variables: { filter }, fetchPolicy: 'network-only' })\n .then((result) => {\n const d = result.data as { waitlist?: WaitlistItem[] } | undefined;\n setData(d ?? null);\n setError(null);\n })\n .catch((err: Error) => setError({ message: err.message }))\n .finally(() => setLoading(false));\n }, [globalClient, filter, permissionsLoading, canViewWaitlist, fetchCount]);\n\n const allEntries: WaitlistItem[] = useMemo(\n () => (data?.waitlist ?? []) as WaitlistItem[],\n [data]\n );\n\n // Client-side search filter (email, name, company)\n const filtered = useMemo(() => {\n if (!search) return allEntries;\n const q = search.toLowerCase();\n return allEntries.filter(\n (e) =>\n e.email.toLowerCase().includes(q) ||\n (e.name && e.name.toLowerCase().includes(q)) ||\n (e.company && e.company.toLowerCase().includes(q))\n );\n }, [allEntries, search]);\n\n const total = filtered.length;\n const totalPages = Math.ceil(total / PAGE_SIZE);\n const paged = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);\n\n const handleApprove = useCallback(\n async (id: string) => {\n await globalClient.mutate({ mutation: APPROVE_WAITLIST, variables: { input: { id } } });\n refetch();\n },\n [globalClient, refetch]\n );\n\n const handleReject = useCallback(\n async (id: string) => {\n const reason = window.prompt('Rejection reason (optional):');\n await globalClient.mutate({\n mutation: REJECT_WAITLIST,\n variables: { input: { id, reason } },\n });\n refetch();\n },\n [globalClient, refetch]\n );\n\n const handleRemove = useCallback(\n async (id: string) => {\n if (!window.confirm('Remove this waitlist entry?')) return;\n await globalClient.mutate({ mutation: REMOVE_WAITLIST, variables: { id } });\n refetch();\n },\n [globalClient, refetch]\n );\n\n const columns = [\n {\n key: 'email',\n header: 'Email',\n render: (e: WaitlistItem) => (\n <div className=\"min-w-0\">\n <div className=\"font-medium text-text-primary\">{e.email}</div>\n {e.name && <div className=\"text-xs text-text-secondary\">{e.name}</div>}\n </div>\n ),\n },\n {\n key: 'company',\n header: 'Company',\n width: '140px',\n render: (e: WaitlistItem) => (\n <span className=\"text-sm text-text-secondary\">{e.company || '-'}</span>\n ),\n },\n {\n key: 'status',\n header: 'Status',\n width: '100px',\n render: (e: WaitlistItem) => (\n <Badge variant={STATUS_VARIANT[e.status] ?? 'outline'} className=\"text-xs capitalize\">\n {e.status.toLowerCase()}\n </Badge>\n ),\n },\n {\n key: 'createdAt',\n header: 'Joined',\n width: '110px',\n render: (e: WaitlistItem) => formatDate(e.createdAt),\n },\n ...(canManageWaitlist\n ? [\n {\n key: 'actions',\n header: 'Actions',\n width: '140px',\n render: (e: WaitlistItem) => (\n <div className=\"flex items-center gap-1\">\n {e.status === 'PENDING' && (\n <>\n <button\n onClick={() => handleApprove(e.id)}\n className=\"rounded p-1 text-green-500 hover:bg-green-500/10\"\n title=\"Approve\"\n >\n <Check className=\"h-4 w-4\" />\n </button>\n <button\n onClick={() => handleReject(e.id)}\n className=\"rounded p-1 text-red-500 hover:bg-red-500/10\"\n title=\"Reject\"\n >\n <X className=\"h-4 w-4\" />\n </button>\n </>\n )}\n <button\n onClick={() => handleRemove(e.id)}\n className=\"rounded p-1 text-text-secondary hover:bg-bg-sunken\"\n title=\"Remove\"\n >\n <Trash2 className=\"h-4 w-4\" />\n </button>\n </div>\n ),\n },\n ]\n : []),\n ];\n\n return (\n <PermissionGuard\n allowed={canViewWaitlist}\n isLoading={permissionsLoading}\n pageTitle=\"Waitlist\"\n denyMessage=\"You don't have permission to view the waitlist.\"\n >\n <PageLayout\n title=\"Waitlist Management\"\n gradientWord=\"Management\"\n description={`Product waitlist entries${total ? ` \\u00b7 ${total} total` : ''}`}\n >\n <div className=\"space-y-4\">\n <div className=\"flex flex-wrap items-center gap-3\">\n <div className=\"relative max-w-sm flex-1\">\n <Search className=\"absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-secondary\" />\n <Input\n placeholder=\"Search by email, name, or company...\"\n value={search}\n onChange={(e) => {\n setPage(1);\n setSearch(e.target.value);\n }}\n className=\"pl-9\"\n />\n </div>\n <select\n value={statusFilter}\n onChange={(e) => {\n setPage(1);\n setStatusFilter(e.target.value as WaitlistStatus | '');\n }}\n className=\"rounded-md border border-border-default bg-bg-surface px-3 py-2 text-sm\"\n >\n <option value=\"\">All statuses</option>\n <option value=\"PENDING\">Pending</option>\n <option value=\"APPROVED\">Approved</option>\n <option value=\"REJECTED\">Rejected</option>\n </select>\n </div>\n\n {error && (\n <div className=\"rounded-md border border-status-error-border/50 bg-status-error-bg/10 p-4 text-sm text-status-error-text\">\n Failed to load waitlist: {error.message}\n </div>\n )}\n\n <DataTable\n columns={columns}\n data={paged}\n keyExtractor={(e) => e.id}\n loading={loading}\n emptyState={\n <EmptyState\n illustration=\"empty-search\"\n icon={<ClipboardList className=\"h-8 w-8\" />}\n title=\"No waitlist entries\"\n description=\"No entries match your current filters\"\n />\n }\n />\n\n {total > PAGE_SIZE && (\n <Pagination\n page={page}\n totalPages={totalPages}\n totalItems={total}\n pageSize={PAGE_SIZE}\n onPageChange={setPage}\n />\n )}\n </div>\n </PageLayout>\n </PermissionGuard>\n );\n}\n"],"mappings":"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"}
|
|
@@ -15,6 +15,7 @@ function b() {
|
|
|
15
15
|
} }), w = b?.systemOverview, T = S?.activeWorkflows || [];
|
|
16
16
|
return /* @__PURE__ */ v(i, {
|
|
17
17
|
title: "Workflows",
|
|
18
|
+
gradientWord: "Workflows",
|
|
18
19
|
description: "Manage and monitor platform workflows",
|
|
19
20
|
actions: /* @__PURE__ */ y(t, { children: [/* @__PURE__ */ v(g, { className: "mr-2 h-4 w-4" }), "Create Workflow"] }),
|
|
20
21
|
children: /* @__PURE__ */ y("div", {
|
|
@@ -22,6 +23,7 @@ function b() {
|
|
|
22
23
|
children: [/* @__PURE__ */ y(s, { children: [
|
|
23
24
|
/* @__PURE__ */ v(o, {
|
|
24
25
|
title: "Total Workflows",
|
|
26
|
+
hero: !0,
|
|
25
27
|
value: w?.totalWorkflows ?? 0,
|
|
26
28
|
loading: x,
|
|
27
29
|
icon: /* @__PURE__ */ v(m, { className: "h-4 w-4" })
|
|
@@ -111,6 +113,7 @@ function b() {
|
|
|
111
113
|
keyExtractor: (e) => e.runId,
|
|
112
114
|
loading: C,
|
|
113
115
|
emptyState: /* @__PURE__ */ v(n, {
|
|
116
|
+
illustration: "empty-data",
|
|
114
117
|
icon: /* @__PURE__ */ v(d, { className: "h-8 w-8" }),
|
|
115
118
|
title: "No active workflows",
|
|
116
119
|
description: "There are currently no workflows running in the system"
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"WorkflowsPage.js","names":[],"sources":["../../../src/admin/pages/WorkflowsPage.tsx"],"sourcesContent":["import { GitBranch, Search, Plus, Activity, AlertCircle, Clock, Play } from 'lucide-react';\nimport {\n PageLayout,\n Input,\n Button,\n EmptyState,\n MetricCard,\n MetricsGrid,\n DataTable,\n Badge,\n} from '../components';\nimport {\n useGetWorkflowSystemOverviewQuery,\n useListActiveWorkflowsQuery,\n} from '../../generated/wspace-operations';\nimport { formatDateTime } from '../utils/formatters';\n\nexport function WorkflowsPage() {\n const { data: overviewData, loading: overviewLoading } = useGetWorkflowSystemOverviewQuery();\n\n const { data: listData, loading: listLoading } = useListActiveWorkflowsQuery({\n variables: { limit: 10, offset: 0 },\n });\n\n const overview = overviewData?.systemOverview;\n const activeRuns = listData?.activeWorkflows || [];\n\n const columns = [\n {\n key: 'workflowName',\n header: 'Workflow',\n render: (run: any) => (\n <div className=\"flex flex-col\">\n <span className=\"font-medium text-text-primary\">{run.workflowName}</span>\n <span className=\"text-xs text-text-secondary\">v{run.workflowVersion}</span>\n </div>\n ),\n },\n {\n key: 'runId',\n header: 'Run ID',\n render: (run: any) => <code className=\"text-xs\">{run.runId.substring(0, 8)}...</code>,\n },\n {\n key: 'status',\n header: 'Status',\n render: (run: any) => {\n const variants: Record<\n string,\n 'default' | 'secondary' | 'success' | 'warning' | 'destructive' | 'outline'\n > = {\n RUNNING: 'success',\n PENDING: 'warning',\n FAILED: 'destructive',\n COMPLETED: 'default',\n };\n return <Badge variant={variants[run.status] || 'outline'}>{run.status}</Badge>;\n },\n },\n {\n key: 'currentNode',\n header: 'Current Node',\n },\n {\n key: 'createdAt',\n header: 'Started At',\n render: (run: any) => formatDateTime(run.createdAt),\n },\n ];\n\n return (\n <PageLayout\n title=\"Workflows\"\n description=\"Manage and monitor platform workflows\"\n actions={\n <Button>\n <Plus className=\"mr-2 h-4 w-4\" />\n Create Workflow\n </Button>\n }\n >\n <div className=\"space-y-8\">\n <MetricsGrid>\n <MetricCard\n title=\"Total Workflows\"\n value={overview?.totalWorkflows ?? 0}\n loading={overviewLoading}\n icon={<GitBranch className=\"h-4 w-4\" />}\n />\n <MetricCard\n title=\"Active Runs\"\n value={overview?.activeWorkflows ?? 0}\n loading={overviewLoading}\n icon={<Play className=\"h-4 w-4\" />}\n trend={overview?.activeWorkflows && overview.activeWorkflows > 0 ? 'up' : 'neutral'}\n />\n <MetricCard\n title=\"Failed (24h)\"\n value={overview?.failedWorkflows ?? 0}\n loading={overviewLoading}\n icon={<AlertCircle className=\"h-4 w-4\" />}\n priority={overview?.failedWorkflows && overview.failedWorkflows > 0 ? 'high' : 'normal'}\n />\n <MetricCard\n title=\"Queued Jobs\"\n value={overview?.queuedJobs ?? 0}\n loading={overviewLoading}\n icon={<Clock className=\"h-4 w-4\" />}\n />\n </MetricsGrid>\n\n <div className=\"space-y-4\">\n <div className=\"flex items-center justify-between\">\n <h2 className=\"text-lg font-semibold\">Active Workflow Runs</h2>\n <div className=\"relative max-w-sm flex-1 ml-4\">\n <Search className=\"absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-secondary\" />\n <Input placeholder=\"Search active runs...\" className=\"pl-9\" />\n </div>\n </div>\n\n <DataTable\n columns={columns}\n data={activeRuns as any[]}\n keyExtractor={(run) => run.runId}\n loading={listLoading}\n emptyState={\n <EmptyState\n icon={<Activity className=\"h-8 w-8\" />}\n title=\"No active workflows\"\n description=\"There are currently no workflows running in the system\"\n />\n }\n />\n </div>\n </div>\n </PageLayout>\n );\n}\n"],"mappings":";;;;;;;;;;AAiBA,SAAgB,IAAgB;CAC9B,IAAM,EAAE,MAAM,GAAc,SAAS,MAAoB,GAAmC,EAEtF,EAAE,MAAM,GAAU,SAAS,MAAgB,EAA4B,EAC3E,WAAW;EAAE,OAAO;EAAI,QAAQ;EAAG,EACpC,CAAC,EAEI,IAAW,GAAc,gBACzB,IAAa,GAAU,mBAAmB,EAAE;AA6ClD,QACE,kBAAC,GAAD;EACE,OAAM;EACN,aAAY;EACZ,SACE,kBAAC,GAAD,EAAA,UAAA,CACE,kBAAC,GAAD,EAAM,WAAU,gBAAiB,CAAA,EAAA,kBAE1B,EAAA,CAAA;YAGX,kBAAC,OAAD;GAAK,WAAU;aAAf,CACE,kBAAC,GAAD,EAAA,UAAA;IACE,kBAAC,GAAD;KACE,OAAM;KACN,OAAO,GAAU,kBAAkB;KACnC,SAAS;KACT,MAAM,kBAAC,GAAD,EAAW,WAAU,WAAY,CAAA;KACvC,CAAA;IACF,kBAAC,GAAD;KACE,OAAM;KACN,OAAO,GAAU,mBAAmB;KACpC,SAAS;KACT,MAAM,kBAAC,GAAD,EAAM,WAAU,WAAY,CAAA;KAClC,OAAO,GAAU,mBAAmB,EAAS,kBAAkB,IAAI,OAAO;KAC1E,CAAA;IACF,kBAAC,GAAD;KACE,OAAM;KACN,OAAO,GAAU,mBAAmB;KACpC,SAAS;KACT,MAAM,kBAAC,GAAD,EAAa,WAAU,WAAY,CAAA;KACzC,UAAU,GAAU,mBAAmB,EAAS,kBAAkB,IAAI,SAAS;KAC/E,CAAA;IACF,kBAAC,GAAD;KACE,OAAM;KACN,OAAO,GAAU,cAAc;KAC/B,SAAS;KACT,MAAM,kBAAC,GAAD,EAAO,WAAU,WAAY,CAAA;KACnC,CAAA;IACU,EAAA,CAAA,EAEd,kBAAC,OAAD;IAAK,WAAU;cAAf,CACE,kBAAC,OAAD;KAAK,WAAU;eAAf,CACE,kBAAC,MAAD;MAAI,WAAU;gBAAwB;MAAyB,CAAA,EAC/D,kBAAC,OAAD;MAAK,WAAU;gBAAf,CACE,kBAAC,GAAD,EAAQ,WAAU,wEAAyE,CAAA,EAC3F,kBAAC,GAAD;OAAO,aAAY;OAAwB,WAAU;OAAS,CAAA,CAC1D;QACF;QAEN,kBAAC,GAAD;KACW,
|
|
1
|
+
{"version":3,"file":"WorkflowsPage.js","names":[],"sources":["../../../src/admin/pages/WorkflowsPage.tsx"],"sourcesContent":["import { GitBranch, Search, Plus, Activity, AlertCircle, Clock, Play } from 'lucide-react';\nimport {\n PageLayout,\n Input,\n Button,\n EmptyState,\n MetricCard,\n MetricsGrid,\n DataTable,\n Badge,\n} from '../components';\nimport {\n useGetWorkflowSystemOverviewQuery,\n useListActiveWorkflowsQuery,\n} from '../../generated/wspace-operations';\nimport { formatDateTime } from '../utils/formatters';\n\nexport function WorkflowsPage() {\n const { data: overviewData, loading: overviewLoading } = useGetWorkflowSystemOverviewQuery();\n\n const { data: listData, loading: listLoading } = useListActiveWorkflowsQuery({\n variables: { limit: 10, offset: 0 },\n });\n\n const overview = overviewData?.systemOverview;\n const activeRuns = listData?.activeWorkflows || [];\n\n const columns = [\n {\n key: 'workflowName',\n header: 'Workflow',\n render: (run: any) => (\n <div className=\"flex flex-col\">\n <span className=\"font-medium text-text-primary\">{run.workflowName}</span>\n <span className=\"text-xs text-text-secondary\">v{run.workflowVersion}</span>\n </div>\n ),\n },\n {\n key: 'runId',\n header: 'Run ID',\n render: (run: any) => <code className=\"text-xs\">{run.runId.substring(0, 8)}...</code>,\n },\n {\n key: 'status',\n header: 'Status',\n render: (run: any) => {\n const variants: Record<\n string,\n 'default' | 'secondary' | 'success' | 'warning' | 'destructive' | 'outline'\n > = {\n RUNNING: 'success',\n PENDING: 'warning',\n FAILED: 'destructive',\n COMPLETED: 'default',\n };\n return <Badge variant={variants[run.status] || 'outline'}>{run.status}</Badge>;\n },\n },\n {\n key: 'currentNode',\n header: 'Current Node',\n },\n {\n key: 'createdAt',\n header: 'Started At',\n render: (run: any) => formatDateTime(run.createdAt),\n },\n ];\n\n return (\n <PageLayout\n title=\"Workflows\"\n gradientWord=\"Workflows\"\n description=\"Manage and monitor platform workflows\"\n actions={\n <Button>\n <Plus className=\"mr-2 h-4 w-4\" />\n Create Workflow\n </Button>\n }\n >\n <div className=\"space-y-8\">\n <MetricsGrid>\n <MetricCard\n title=\"Total Workflows\"\n hero\n value={overview?.totalWorkflows ?? 0}\n loading={overviewLoading}\n icon={<GitBranch className=\"h-4 w-4\" />}\n />\n <MetricCard\n title=\"Active Runs\"\n value={overview?.activeWorkflows ?? 0}\n loading={overviewLoading}\n icon={<Play className=\"h-4 w-4\" />}\n trend={overview?.activeWorkflows && overview.activeWorkflows > 0 ? 'up' : 'neutral'}\n />\n <MetricCard\n title=\"Failed (24h)\"\n value={overview?.failedWorkflows ?? 0}\n loading={overviewLoading}\n icon={<AlertCircle className=\"h-4 w-4\" />}\n priority={overview?.failedWorkflows && overview.failedWorkflows > 0 ? 'high' : 'normal'}\n />\n <MetricCard\n title=\"Queued Jobs\"\n value={overview?.queuedJobs ?? 0}\n loading={overviewLoading}\n icon={<Clock className=\"h-4 w-4\" />}\n />\n </MetricsGrid>\n\n <div className=\"space-y-4\">\n <div className=\"flex items-center justify-between\">\n <h2 className=\"text-lg font-semibold\">Active Workflow Runs</h2>\n <div className=\"relative max-w-sm flex-1 ml-4\">\n <Search className=\"absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-secondary\" />\n <Input placeholder=\"Search active runs...\" className=\"pl-9\" />\n </div>\n </div>\n\n <DataTable\n columns={columns}\n data={activeRuns as any[]}\n keyExtractor={(run) => run.runId}\n loading={listLoading}\n emptyState={\n <EmptyState\n illustration=\"empty-data\"\n icon={<Activity className=\"h-8 w-8\" />}\n title=\"No active workflows\"\n description=\"There are currently no workflows running in the system\"\n />\n }\n />\n </div>\n </div>\n </PageLayout>\n );\n}\n"],"mappings":";;;;;;;;;;AAiBA,SAAgB,IAAgB;CAC9B,IAAM,EAAE,MAAM,GAAc,SAAS,MAAoB,GAAmC,EAEtF,EAAE,MAAM,GAAU,SAAS,MAAgB,EAA4B,EAC3E,WAAW;EAAE,OAAO;EAAI,QAAQ;EAAG,EACpC,CAAC,EAEI,IAAW,GAAc,gBACzB,IAAa,GAAU,mBAAmB,EAAE;AA6ClD,QACE,kBAAC,GAAD;EACE,OAAM;EACN,cAAa;EACb,aAAY;EACZ,SACE,kBAAC,GAAD,EAAA,UAAA,CACE,kBAAC,GAAD,EAAM,WAAU,gBAAiB,CAAA,EAAA,kBAE1B,EAAA,CAAA;YAGX,kBAAC,OAAD;GAAK,WAAU;aAAf,CACE,kBAAC,GAAD,EAAA,UAAA;IACE,kBAAC,GAAD;KACE,OAAM;KACN,MAAA;KACA,OAAO,GAAU,kBAAkB;KACnC,SAAS;KACT,MAAM,kBAAC,GAAD,EAAW,WAAU,WAAY,CAAA;KACvC,CAAA;IACF,kBAAC,GAAD;KACE,OAAM;KACN,OAAO,GAAU,mBAAmB;KACpC,SAAS;KACT,MAAM,kBAAC,GAAD,EAAM,WAAU,WAAY,CAAA;KAClC,OAAO,GAAU,mBAAmB,EAAS,kBAAkB,IAAI,OAAO;KAC1E,CAAA;IACF,kBAAC,GAAD;KACE,OAAM;KACN,OAAO,GAAU,mBAAmB;KACpC,SAAS;KACT,MAAM,kBAAC,GAAD,EAAa,WAAU,WAAY,CAAA;KACzC,UAAU,GAAU,mBAAmB,EAAS,kBAAkB,IAAI,SAAS;KAC/E,CAAA;IACF,kBAAC,GAAD;KACE,OAAM;KACN,OAAO,GAAU,cAAc;KAC/B,SAAS;KACT,MAAM,kBAAC,GAAD,EAAO,WAAU,WAAY,CAAA;KACnC,CAAA;IACU,EAAA,CAAA,EAEd,kBAAC,OAAD;IAAK,WAAU;cAAf,CACE,kBAAC,OAAD;KAAK,WAAU;eAAf,CACE,kBAAC,MAAD;MAAI,WAAU;gBAAwB;MAAyB,CAAA,EAC/D,kBAAC,OAAD;MAAK,WAAU;gBAAf,CACE,kBAAC,GAAD,EAAQ,WAAU,wEAAyE,CAAA,EAC3F,kBAAC,GAAD;OAAO,aAAY;OAAwB,WAAU;OAAS,CAAA,CAC1D;QACF;QAEN,kBAAC,GAAD;KACW,SAhGH;MACd;OACE,KAAK;OACL,QAAQ;OACR,SAAS,MACP,kBAAC,OAAD;QAAK,WAAU;kBAAf,CACE,kBAAC,QAAD;SAAM,WAAU;mBAAiC,EAAI;SAAoB,CAAA,EACzE,kBAAC,QAAD;SAAM,WAAU;mBAAhB,CAA8C,KAAE,EAAI,gBAAuB;WACvE;;OAET;MACD;OACE,KAAK;OACL,QAAQ;OACR,SAAS,MAAa,kBAAC,QAAD;QAAM,WAAU;kBAAhB,CAA2B,EAAI,MAAM,UAAU,GAAG,EAAE,EAAC,MAAU;;OACtF;MACD;OACE,KAAK;OACL,QAAQ;OACR,SAAS,MAUA,kBAAC,GAAD;QAAO,SANV;SACF,SAAS;SACT,SAAS;SACT,QAAQ;SACR,WAAW;SACZ,CAC+B,EAAI,WAAW;kBAAY,EAAI;QAAe,CAAA;OAEjF;MACD;OACE,KAAK;OACL,QAAQ;OACT;MACD;OACE,KAAK;OACL,QAAQ;OACR,SAAS,MAAa,EAAe,EAAI,UAAU;OACpD;MACF;KAwDS,MAAM;KACN,eAAe,MAAQ,EAAI;KAC3B,SAAS;KACT,YACE,kBAAC,GAAD;MACE,cAAa;MACb,MAAM,kBAAC,GAAD,EAAU,WAAU,WAAY,CAAA;MACtC,OAAM;MACN,aAAY;MACZ,CAAA;KAEJ,CAAA,CACE;MACF;;EACK,CAAA"}
|
|
@@ -22,6 +22,7 @@ function g() {
|
|
|
22
22
|
} }), b = _?.allWorkspaces.items ?? [], x = _?.allWorkspaces.total ?? 0, S = Math.ceil(x / 20);
|
|
23
23
|
return /* @__PURE__ */ p(i, {
|
|
24
24
|
title: "Workspaces",
|
|
25
|
+
gradientWord: "Workspaces",
|
|
25
26
|
description: "Manage and monitor platform workspaces",
|
|
26
27
|
actions: /* @__PURE__ */ m(n, { children: [/* @__PURE__ */ p(d, { className: "mr-2 h-4 w-4" }), "Create Workspace"] }),
|
|
27
28
|
children: /* @__PURE__ */ m("div", {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"WorkspacesListPage.js","names":[],"sources":["../../../src/admin/pages/WorkspacesListPage.tsx"],"sourcesContent":["import React, { useState } from 'react';\nimport { Layout, Search, Plus } from 'lucide-react';\nimport { PageLayout, Input, Button, DataTable, Pagination } from '../components';\nimport { useListWorkspacesQuery } from '../../generated/global-operations';\nimport { WorkspaceStatus } from '../../generated/global-types';\nimport { Badge } from '../components/ui';\nimport { cn } from '../utils/cn';\n\nconst STATUS_STYLING: Record<WorkspaceStatus, string> = {\n [WorkspaceStatus.Active]: 'bg-success/10 text-success border-success/20',\n [WorkspaceStatus.Suspended]: 'bg-status-error-bg/10 text-status-error-text border-status-error-border/20',\n [WorkspaceStatus.Archived]: 'bg-bg-sunken text-text-secondary border-muted',\n [WorkspaceStatus.Deleted]: 'bg-status-error-bg/10 text-status-error-text border-status-error-border/20',\n};\n\nexport function WorkspacesListPage() {\n const [page, setPage] = useState(1);\n const pageSize = 20;\n\n const { data, loading, error } = useListWorkspacesQuery({\n variables: {\n limit: pageSize,\n offset: (page - 1) * pageSize,\n },\n });\n\n const workspaces = data?.allWorkspaces.items ?? [];\n const total = data?.allWorkspaces.total ?? 0;\n const totalPages = Math.ceil(total / pageSize);\n\n const columns = [\n {\n key: 'name',\n header: 'Workspace Name',\n render: (w: any) => <div className=\"font-medium text-text-primary\">{w.name}</div>,\n },\n {\n key: 'id',\n header: 'ID',\n render: (w: any) => <code className=\"text-xs text-text-secondary\">{w.id}</code>,\n },\n {\n key: 'status',\n header: 'Status',\n render: (w: any) => (\n <Badge className={cn(STATUS_STYLING[w.status as WorkspaceStatus])} variant=\"outline\">\n {w.status}\n </Badge>\n ),\n },\n ];\n\n return (\n <PageLayout\n title=\"Workspaces\"\n description=\"Manage and monitor platform workspaces\"\n actions={\n <Button>\n <Plus className=\"mr-2 h-4 w-4\" />\n Create Workspace\n </Button>\n }\n >\n <div className=\"space-y-4\">\n <div className=\"flex items-center justify-between\">\n <div className=\"relative max-w-sm flex-1\">\n <Search className=\"absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-secondary\" />\n <Input placeholder=\"Search workspaces...\" className=\"pl-9\" />\n </div>\n </div>\n\n <DataTable\n columns={columns}\n data={workspaces}\n keyExtractor={(w) => w.id}\n loading={loading}\n emptyState={\n <div className=\"flex flex-col items-center justify-center py-12 text-center\">\n <Layout className=\"h-12 w-12 text-text-secondary/50 mb-4\" />\n <h3 className=\"text-lg font-medium\">No workspaces found</h3>\n <p className=\"text-text-secondary\">\n Workspaces will appear here when they are created.\n </p>\n </div>\n }\n />\n\n {total > pageSize && (\n <Pagination\n page={page}\n totalPages={totalPages}\n totalItems={total}\n pageSize={pageSize}\n onPageChange={setPage}\n />\n )}\n </div>\n </PageLayout>\n );\n}\n"],"mappings":";;;;;;;;;;;AAQA,IAAM,IAAkD;EACrD,EAAgB,SAAS;EACzB,EAAgB,YAAY;EAC5B,EAAgB,WAAW;EAC3B,EAAgB,UAAU;CAC5B;AAED,SAAgB,IAAqB;CACnC,IAAM,CAAC,GAAM,KAAW,EAAS,EAAE,EAG7B,EAAE,SAAM,YAAS,aAAU,EAAuB,EACtD,WAAW;EACT,OAAO;EACP,SAAS,IAAO,KAAK;EACtB,EACF,CAAC,EAEI,IAAa,GAAM,cAAc,SAAS,EAAE,EAC5C,IAAQ,GAAM,cAAc,SAAS,GACrC,IAAa,KAAK,KAAK,IAAQ,GAAS;AAwB9C,QACE,kBAAC,GAAD;EACE,OAAM;EACN,aAAY;EACZ,SACE,kBAAC,GAAD,EAAA,UAAA,CACE,kBAAC,GAAD,EAAM,WAAU,gBAAiB,CAAA,EAAA,mBAE1B,EAAA,CAAA;YAGX,kBAAC,OAAD;GAAK,WAAU;aAAf;IACE,kBAAC,OAAD;KAAK,WAAU;eACb,kBAAC,OAAD;MAAK,WAAU;gBAAf,CACE,kBAAC,GAAD,EAAQ,WAAU,wEAAyE,CAAA,EAC3F,kBAAC,GAAD;OAAO,aAAY;OAAuB,WAAU;OAAS,CAAA,CACzD;;KACF,CAAA;IAEN,kBAAC,GAAD;KACW,
|
|
1
|
+
{"version":3,"file":"WorkspacesListPage.js","names":[],"sources":["../../../src/admin/pages/WorkspacesListPage.tsx"],"sourcesContent":["import React, { useState } from 'react';\nimport { Layout, Search, Plus } from 'lucide-react';\nimport { PageLayout, Input, Button, DataTable, Pagination } from '../components';\nimport { useListWorkspacesQuery } from '../../generated/global-operations';\nimport { WorkspaceStatus } from '../../generated/global-types';\nimport { Badge } from '../components/ui';\nimport { cn } from '../utils/cn';\n\nconst STATUS_STYLING: Record<WorkspaceStatus, string> = {\n [WorkspaceStatus.Active]: 'bg-success/10 text-success border-success/20',\n [WorkspaceStatus.Suspended]: 'bg-status-error-bg/10 text-status-error-text border-status-error-border/20',\n [WorkspaceStatus.Archived]: 'bg-bg-sunken text-text-secondary border-muted',\n [WorkspaceStatus.Deleted]: 'bg-status-error-bg/10 text-status-error-text border-status-error-border/20',\n};\n\nexport function WorkspacesListPage() {\n const [page, setPage] = useState(1);\n const pageSize = 20;\n\n const { data, loading, error } = useListWorkspacesQuery({\n variables: {\n limit: pageSize,\n offset: (page - 1) * pageSize,\n },\n });\n\n const workspaces = data?.allWorkspaces.items ?? [];\n const total = data?.allWorkspaces.total ?? 0;\n const totalPages = Math.ceil(total / pageSize);\n\n const columns = [\n {\n key: 'name',\n header: 'Workspace Name',\n render: (w: any) => <div className=\"font-medium text-text-primary\">{w.name}</div>,\n },\n {\n key: 'id',\n header: 'ID',\n render: (w: any) => <code className=\"text-xs text-text-secondary\">{w.id}</code>,\n },\n {\n key: 'status',\n header: 'Status',\n render: (w: any) => (\n <Badge className={cn(STATUS_STYLING[w.status as WorkspaceStatus])} variant=\"outline\">\n {w.status}\n </Badge>\n ),\n },\n ];\n\n return (\n <PageLayout\n title=\"Workspaces\"\n gradientWord=\"Workspaces\"\n description=\"Manage and monitor platform workspaces\"\n actions={\n <Button>\n <Plus className=\"mr-2 h-4 w-4\" />\n Create Workspace\n </Button>\n }\n >\n <div className=\"space-y-4\">\n <div className=\"flex items-center justify-between\">\n <div className=\"relative max-w-sm flex-1\">\n <Search className=\"absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-secondary\" />\n <Input placeholder=\"Search workspaces...\" className=\"pl-9\" />\n </div>\n </div>\n\n <DataTable\n columns={columns}\n data={workspaces}\n keyExtractor={(w) => w.id}\n loading={loading}\n emptyState={\n <div className=\"flex flex-col items-center justify-center py-12 text-center\">\n <Layout className=\"h-12 w-12 text-text-secondary/50 mb-4\" />\n <h3 className=\"text-lg font-medium\">No workspaces found</h3>\n <p className=\"text-text-secondary\">\n Workspaces will appear here when they are created.\n </p>\n </div>\n }\n />\n\n {total > pageSize && (\n <Pagination\n page={page}\n totalPages={totalPages}\n totalItems={total}\n pageSize={pageSize}\n onPageChange={setPage}\n />\n )}\n </div>\n </PageLayout>\n );\n}\n"],"mappings":";;;;;;;;;;;AAQA,IAAM,IAAkD;EACrD,EAAgB,SAAS;EACzB,EAAgB,YAAY;EAC5B,EAAgB,WAAW;EAC3B,EAAgB,UAAU;CAC5B;AAED,SAAgB,IAAqB;CACnC,IAAM,CAAC,GAAM,KAAW,EAAS,EAAE,EAG7B,EAAE,SAAM,YAAS,aAAU,EAAuB,EACtD,WAAW;EACT,OAAO;EACP,SAAS,IAAO,KAAK;EACtB,EACF,CAAC,EAEI,IAAa,GAAM,cAAc,SAAS,EAAE,EAC5C,IAAQ,GAAM,cAAc,SAAS,GACrC,IAAa,KAAK,KAAK,IAAQ,GAAS;AAwB9C,QACE,kBAAC,GAAD;EACE,OAAM;EACN,cAAa;EACb,aAAY;EACZ,SACE,kBAAC,GAAD,EAAA,UAAA,CACE,kBAAC,GAAD,EAAM,WAAU,gBAAiB,CAAA,EAAA,mBAE1B,EAAA,CAAA;YAGX,kBAAC,OAAD;GAAK,WAAU;aAAf;IACE,kBAAC,OAAD;KAAK,WAAU;eACb,kBAAC,OAAD;MAAK,WAAU;gBAAf,CACE,kBAAC,GAAD,EAAQ,WAAU,wEAAyE,CAAA,EAC3F,kBAAC,GAAD;OAAO,aAAY;OAAuB,WAAU;OAAS,CAAA,CACzD;;KACF,CAAA;IAEN,kBAAC,GAAD;KACW,SA3CD;MACd;OACE,KAAK;OACL,QAAQ;OACR,SAAS,MAAW,kBAAC,OAAD;QAAK,WAAU;kBAAiC,EAAE;QAAW,CAAA;OAClF;MACD;OACE,KAAK;OACL,QAAQ;OACR,SAAS,MAAW,kBAAC,QAAD;QAAM,WAAU;kBAA+B,EAAE;QAAU,CAAA;OAChF;MACD;OACE,KAAK;OACL,QAAQ;OACR,SAAS,MACP,kBAAC,GAAD;QAAO,WAAW,EAAG,EAAe,EAAE,QAA2B;QAAE,SAAQ;kBACxE,EAAE;QACG,CAAA;OAEX;MACF;KAwBO,MAAM;KACN,eAAe,MAAM,EAAE;KACd;KACT,YACE,kBAAC,OAAD;MAAK,WAAU;gBAAf;OACE,kBAAC,GAAD,EAAQ,WAAU,yCAA0C,CAAA;OAC5D,kBAAC,MAAD;QAAI,WAAU;kBAAsB;QAAwB,CAAA;OAC5D,kBAAC,KAAD;QAAG,WAAU;kBAAsB;QAE/B,CAAA;OACA;;KAER,CAAA;IAED,IAAQ,MACP,kBAAC,GAAD;KACQ;KACM;KACZ,YAAY;KACF;KACV,cAAc;KACd,CAAA;IAEA;;EACK,CAAA"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@burdenoff/microfe-admin",
|
|
3
|
-
"version": "2026.
|
|
3
|
+
"version": "2026.625.1",
|
|
4
4
|
"description": "Burdenoff Admin Console Microfrontend - Platform administration and management interface",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@apollo/client": "^4.0.11",
|
|
45
|
-
"@burdenoff/fe-libs": "2026.
|
|
45
|
+
"@burdenoff/fe-libs": "2026.625.3",
|
|
46
46
|
"@hookform/resolvers": "^5.1.1",
|
|
47
47
|
"@tanstack/react-query": "^5.90.16",
|
|
48
48
|
"clsx": "^2.1.1",
|