@burdenoff/microfe-admin 2026.625.1 → 2026.712.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/AdminRoot.js +2 -2
- package/dist/admin/AdminRoot.js.map +1 -1
- package/dist/admin/AdminRoutes.js +130 -78
- package/dist/admin/AdminRoutes.js.map +1 -1
- package/dist/admin/components/AccessDenied.js +2 -2
- package/dist/admin/components/AccessDenied.js.map +1 -1
- package/dist/admin/components/ActivityFeed.js +4 -4
- package/dist/admin/components/ActivityFeed.js.map +1 -1
- package/dist/admin/components/Charts.js.map +1 -1
- package/dist/admin/components/ConfirmDialog.js +1 -1
- package/dist/admin/components/ConfirmDialog.js.map +1 -1
- package/dist/admin/components/DataTable.js.map +1 -1
- package/dist/admin/components/FormSheet.js +1 -1
- package/dist/admin/components/FormSheet.js.map +1 -1
- package/dist/admin/components/TenantSsoPanel.js +1 -1
- package/dist/admin/components/TenantSsoPanel.js.map +1 -1
- package/dist/admin/components/Toast.js +11 -11
- package/dist/admin/components/Toast.js.map +1 -1
- package/dist/admin/components/forms/SubscriptionForm.js +2 -2
- package/dist/admin/components/forms/SubscriptionForm.js.map +1 -1
- package/dist/admin/components/forms/TenantForm.js +1 -1
- package/dist/admin/components/forms/UserForm.js +1 -1
- package/dist/admin/components/ui.js +3 -3
- package/dist/admin/components/ui.js.map +1 -1
- package/dist/admin/constants/featureFlags.js +12 -0
- package/dist/admin/constants/featureFlags.js.map +1 -0
- package/dist/admin/constants/index.js +33 -24
- package/dist/admin/constants/index.js.map +1 -1
- package/dist/admin/pages/AuditLogsPage.js +92 -77
- package/dist/admin/pages/AuditLogsPage.js.map +1 -1
- package/dist/admin/pages/BillingPage.js +95 -51
- package/dist/admin/pages/BillingPage.js.map +1 -1
- package/dist/admin/pages/CommunicationsPage.js +97 -92
- package/dist/admin/pages/CommunicationsPage.js.map +1 -1
- package/dist/admin/pages/ContentPagesPage.js +274 -259
- package/dist/admin/pages/ContentPagesPage.js.map +1 -1
- package/dist/admin/pages/DashboardPage.js +113 -84
- package/dist/admin/pages/DashboardPage.js.map +1 -1
- package/dist/admin/pages/FeatureFlagsPage.js +93 -75
- package/dist/admin/pages/FeatureFlagsPage.js.map +1 -1
- package/dist/admin/pages/OrganizationsListPage.js +105 -87
- package/dist/admin/pages/OrganizationsListPage.js.map +1 -1
- package/dist/admin/pages/ProductsPage.js +138 -130
- package/dist/admin/pages/ProductsPage.js.map +1 -1
- package/dist/admin/pages/ReferralsPage.js +70 -52
- package/dist/admin/pages/ReferralsPage.js.map +1 -1
- package/dist/admin/pages/RolesPermissionsPage.js +136 -118
- package/dist/admin/pages/RolesPermissionsPage.js.map +1 -1
- package/dist/admin/pages/SREPage.js +34 -32
- package/dist/admin/pages/SREPage.js.map +1 -1
- package/dist/admin/pages/SandboxSettingsPage.js +360 -322
- package/dist/admin/pages/SandboxSettingsPage.js.map +1 -1
- package/dist/admin/pages/SettingsPage.js +64 -61
- package/dist/admin/pages/SettingsPage.js.map +1 -1
- package/dist/admin/pages/SyncFailuresPage.js +120 -118
- package/dist/admin/pages/SyncFailuresPage.js.map +1 -1
- package/dist/admin/pages/TenantDetailPage.js +286 -231
- package/dist/admin/pages/TenantDetailPage.js.map +1 -1
- package/dist/admin/pages/TenantFormRoutePage.js +78 -73
- package/dist/admin/pages/TenantFormRoutePage.js.map +1 -1
- package/dist/admin/pages/TenantsListPage.js +182 -155
- package/dist/admin/pages/TenantsListPage.js.map +1 -1
- package/dist/admin/pages/UsersListPage.js +128 -113
- package/dist/admin/pages/UsersListPage.js.map +1 -1
- package/dist/admin/pages/WaitlistPage.js +78 -63
- package/dist/admin/pages/WaitlistPage.js.map +1 -1
- package/dist/admin/pages/WorkflowsPage.js +107 -102
- package/dist/admin/pages/WorkflowsPage.js.map +1 -1
- package/dist/admin/pages/WorkspacesListPage.js +61 -49
- package/dist/admin/pages/WorkspacesListPage.js.map +1 -1
- package/dist/node_modules/@hookform/resolvers/dist/resolvers.js +2 -2
- package/dist/node_modules/@hookform/resolvers/dist/resolvers.js.map +1 -1
- package/dist/node_modules/@hookform/resolvers/zod/dist/zod.js +35 -26
- package/dist/node_modules/@hookform/resolvers/zod/dist/zod.js.map +1 -1
- package/dist/node_modules/react-hook-form/dist/index.esm.js +609 -516
- package/dist/node_modules/react-hook-form/dist/index.esm.js.map +1 -1
- package/package.json +2 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ContentPagesPage.js","names":[],"sources":["../../../src/admin/pages/ContentPagesPage.tsx"],"sourcesContent":["import { useState, useCallback, useEffect, useRef } from 'react';\nimport { gql } from '@apollo/client';\nimport { useApolloClient } from '@apollo/client/react';\nimport { FileText, Search, Plus } from 'lucide-react';\nimport { useAdminPermissions } from '../hooks/useAdminPermissions';\nimport {\n PageLayout,\n Input,\n DataTable,\n Pagination,\n EmptyState,\n Badge,\n Button,\n PermissionGuard,\n FormSheet,\n FormSheetFooter,\n FormSection,\n FormRow,\n DeleteConfirmDialog,\n useToast,\n createRowActions,\n RowActions,\n} from '../components';\nimport { formatDate } from '../utils/formatters';\n\nconst PAGE_SIZE = 20;\n\n// Content \"type\" is stored on the ProductContentPage `tags` array (one tag per\n// type, matching the website SDK filters: BlogListPage → \"blog\",\n// CaseStudiesListPage → \"case-study\", ChangelogPage → \"changelog\").\nconst CONTENT_TYPES = [\n { value: 'blog', label: 'Blog', category: 'Blog' },\n { value: 'case-study', label: 'Case Study', category: 'Case Study' },\n { value: 'changelog', label: 'Changelog', category: 'Changelog' },\n] as const;\n\ntype ContentType = (typeof CONTENT_TYPES)[number]['value'];\n\nconst TYPE_VARIANT: Record<ContentType, 'default' | 'secondary' | 'outline'> = {\n blog: 'default',\n 'case-study': 'secondary',\n changelog: 'outline',\n};\n\n// ---------------------------------------------------------------------------\n// GraphQL\n// ---------------------------------------------------------------------------\n\nconst LIST_PRODUCTS = gql`\n query AdminContentListProducts($limit: Int, $offset: Int) {\n platformProducts(limit: $limit, offset: $offset) {\n items {\n id\n name\n slug\n }\n }\n }\n`;\n\nconst LIST_CONTENT_PAGES = gql`\n query AdminContentPages(\n $productId: String\n $filter: ContentPageFilterInput\n $limit: Int\n $offset: Int\n ) {\n contentPages(productId: $productId, filter: $filter, limit: $limit, offset: $offset) {\n items {\n id\n productId\n slug\n title\n content\n contentFormat\n tags\n category\n excerpt\n thumbnail\n author\n publishedAt\n readingTime\n featured\n isPublished\n visibility\n updatedAt\n }\n total\n }\n }\n`;\n\nconst CREATE_CONTENT_PAGE = gql`\n mutation AdminCreateContentPage($input: CreateContentPageInput!) {\n createContentPage(input: $input) {\n id\n slug\n }\n }\n`;\n\nconst UPDATE_CONTENT_PAGE = gql`\n mutation AdminUpdateContentPage($id: ID!, $input: UpdateContentPageInput!) {\n updateContentPage(id: $id, input: $input) {\n id\n slug\n }\n }\n`;\n\nconst DELETE_CONTENT_PAGE = gql`\n mutation AdminDeleteContentPage($id: ID!) {\n deleteContentPage(id: $id)\n }\n`;\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\ninterface ProductOption {\n id: string;\n name: string;\n slug: string;\n}\n\ninterface ContentPageItem {\n id: string;\n productId: string | null;\n slug: string;\n title: string;\n content: string;\n contentFormat: string | null;\n tags: string[];\n category: string | null;\n excerpt: string | null;\n thumbnail: string | null;\n author: string | null;\n publishedAt: string | null;\n readingTime: number | null;\n featured: boolean;\n isPublished: boolean;\n visibility: string;\n updatedAt: string;\n}\n\ninterface FormState {\n productId: string;\n type: ContentType;\n slug: string;\n title: string;\n excerpt: string;\n content: string;\n author: string;\n publishedAt: string;\n thumbnail: string;\n readingTime: string;\n featured: boolean;\n isPublished: boolean;\n}\n\nconst EMPTY_FORM: FormState = {\n productId: '',\n type: 'blog',\n slug: '',\n title: '',\n excerpt: '',\n content: '',\n author: '',\n publishedAt: '',\n thumbnail: '',\n readingTime: '',\n featured: false,\n isPublished: true,\n};\n\nfunction typeOf(tags: string[]): ContentType {\n if (tags.includes('changelog')) return 'changelog';\n if (tags.includes('case-study')) return 'case-study';\n return 'blog';\n}\n\nconst inputClass =\n 'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50';\n\n// ---------------------------------------------------------------------------\n// Page\n// ---------------------------------------------------------------------------\n\nexport function ContentPagesPage() {\n const {\n canViewProducts,\n canManageProducts,\n isLoading: permissionsLoading,\n } = useAdminPermissions();\n const apolloClient = useApolloClient();\n const toast = useToast();\n\n const [products, setProducts] = useState<ProductOption[]>([]);\n const [productId, setProductId] = useState('');\n const [typeFilter, setTypeFilter] = useState<'' | ContentType>('');\n const [searchInput, setSearchInput] = useState('');\n const [search, setSearch] = useState(''); // debounced value that actually drives the query\n const [page, setPage] = useState(1);\n\n // Monotonic request id so a slow earlier fetch can't overwrite a newer one.\n const fetchId = useRef(0);\n\n // Debounce the search box: only the settled value triggers a fetch, and a\n // new search resets to page 1.\n useEffect(() => {\n const t = setTimeout(() => {\n setSearch(searchInput);\n setPage(1);\n }, 300);\n return () => clearTimeout(t);\n }, [searchInput]);\n\n const [items, setItems] = useState<ContentPageItem[]>([]);\n const [total, setTotal] = useState(0);\n const [loading, setLoading] = useState(false);\n const [error, setError] = useState<string | null>(null);\n\n const [sheetOpen, setSheetOpen] = useState(false);\n const [editing, setEditing] = useState<ContentPageItem | null>(null);\n const [form, setForm] = useState<FormState>(EMPTY_FORM);\n const [submitting, setSubmitting] = useState(false);\n const [deleteTarget, setDeleteTarget] = useState<ContentPageItem | null>(null);\n const [deleting, setDeleting] = useState(false);\n\n const skip = permissionsLoading || !canViewProducts;\n\n // Load product list for the selector. Capped at 100 — the platform has well\n // under that many products, and this drives a simple filter dropdown, not an\n // exhaustive picker. Revisit with pagination/search if the catalogue grows.\n useEffect(() => {\n if (skip) return;\n // Guard against setting state after unmount if the user navigates away\n // before this resolves.\n let active = true;\n apolloClient\n .query<{ platformProducts: { items: ProductOption[] } }>({\n query: LIST_PRODUCTS,\n variables: { limit: 100, offset: 0 },\n fetchPolicy: 'network-only',\n })\n .then((res) => {\n if (!active) return;\n const list = res.data?.platformProducts?.items ?? [];\n setProducts(list);\n setProductId((prev) => prev || list[0]?.id || '');\n })\n .catch((e) => {\n if (!active) return;\n setError(e instanceof Error ? e.message : 'Failed to load products');\n });\n return () => {\n active = false;\n };\n }, [apolloClient, skip]);\n\n const fetchPages = useCallback(async () => {\n if (skip || !productId) return;\n const id = ++fetchId.current;\n setLoading(true);\n try {\n const res = await apolloClient.query<{\n contentPages: { items: ContentPageItem[]; total: number };\n }>({\n query: LIST_CONTENT_PAGES,\n variables: {\n productId,\n filter: {\n ...(search ? { search } : {}),\n ...(typeFilter ? { tag: typeFilter } : {}),\n },\n limit: PAGE_SIZE,\n offset: (page - 1) * PAGE_SIZE,\n },\n fetchPolicy: 'network-only',\n });\n if (id !== fetchId.current) return; // a newer fetch superseded this one\n setItems(res.data?.contentPages?.items ?? []);\n setTotal(res.data?.contentPages?.total ?? 0);\n setError(null);\n } catch (e) {\n if (id !== fetchId.current) return;\n setError(e instanceof Error ? e.message : 'Failed to load content pages');\n } finally {\n if (id === fetchId.current) setLoading(false);\n }\n }, [apolloClient, skip, productId, search, typeFilter, page]);\n\n useEffect(() => {\n fetchPages();\n }, [fetchPages]);\n\n const totalPages = Math.ceil(total / PAGE_SIZE);\n\n const openCreate = useCallback(() => {\n setEditing(null);\n setForm({ ...EMPTY_FORM, productId, type: typeFilter || 'blog' });\n setSheetOpen(true);\n }, [productId, typeFilter]);\n\n const openEdit = useCallback((item: ContentPageItem) => {\n setEditing(item);\n setForm({\n productId: item.productId ?? '',\n type: typeOf(item.tags),\n slug: item.slug,\n title: item.title,\n excerpt: item.excerpt ?? '',\n content: item.content,\n author: item.author ?? '',\n publishedAt: item.publishedAt ? item.publishedAt.slice(0, 10) : '',\n thumbnail: item.thumbnail ?? '',\n readingTime: item.readingTime != null ? String(item.readingTime) : '',\n featured: item.featured,\n isPublished: item.isPublished,\n });\n setSheetOpen(true);\n }, []);\n\n const handleSubmit = useCallback(async () => {\n const missing = [\n !form.title.trim() && 'Title',\n !form.slug.trim() && 'Slug',\n !form.content.trim() && 'Content',\n ].filter(Boolean);\n if (missing.length) {\n toast.error(`${missing.join(', ')} ${missing.length > 1 ? 'are' : 'is'} required`);\n return;\n }\n // The thumbnail is rendered as an <img src> on the public sites. Only accept\n // http(s) URLs, protocol-relative (`//…`) or site-absolute (`/…`) paths so a\n // `javascript:`/`data:` scheme can't be stored and later become an XSS vector.\n const thumb = form.thumbnail.trim();\n if (thumb && !/^(https?:\\/\\/|\\/\\/|\\/)/i.test(thumb)) {\n toast.error('Thumbnail must be an http(s) URL or an absolute path');\n return;\n }\n setSubmitting(true);\n const typeMeta = CONTENT_TYPES.find((t) => t.value === form.type)!;\n // On EDIT, an emptied optional field must send `null` so the backend clears\n // the previous value (an omitted/`undefined` field is dropped by JSON\n // serialization and leaves the old value untouched). On CREATE we omit.\n const empty = editing ? null : undefined;\n // tags/category are NOT in the shared (update) payload: the type selector is\n // locked on edit, so an edit must NOT overwrite the row's tags/category.\n // That preserves pages whose tags aren't one of blog/case-study/changelog\n // (e.g. legal/privacy) which can surface in the \"All types\" list — editing\n // their title/content must not silently retag them as a blog post.\n const shared = {\n title: form.title.trim(),\n content: form.content,\n contentFormat: 'markdown',\n excerpt: form.excerpt.trim() || empty,\n author: form.author.trim() || empty,\n publishedAt: form.publishedAt ? new Date(form.publishedAt).toISOString() : empty,\n thumbnail: form.thumbnail.trim() || empty,\n readingTime: form.readingTime !== '' ? Number(form.readingTime) : empty,\n featured: form.featured,\n isPublished: form.isPublished,\n // NOTE: `visibility` is intentionally NOT in the shared (update) payload.\n // The form has no visibility control, so an edit must not clobber a page\n // whose visibility was set to INTERNAL/AUTHENTICATED elsewhere back to\n // PUBLIC. It is only defaulted on CREATE below.\n };\n try {\n if (editing) {\n await apolloClient.mutate({\n mutation: UPDATE_CONTENT_PAGE,\n variables: { id: editing.id, input: shared },\n });\n toast.success('Content page updated');\n } else {\n await apolloClient.mutate({\n mutation: CREATE_CONTENT_PAGE,\n variables: {\n input: {\n ...shared,\n visibility: 'PUBLIC',\n tags: [form.type],\n category: typeMeta.category,\n productId: form.productId || undefined,\n // Collapse internal hyphen runs (a `--` can arise from adjacent\n // invalid chars) and trim leading/trailing hyphens — done once on\n // submit so it never fights the user mid-typing.\n slug: form.slug\n .trim()\n .replace(/-+/g, '-')\n .replace(/^-+|-+$/g, ''),\n },\n },\n });\n toast.success('Content page created');\n }\n setSheetOpen(false);\n await fetchPages();\n } catch (e) {\n toast.error(e instanceof Error ? e.message : 'Failed to save content page');\n } finally {\n setSubmitting(false);\n }\n }, [form, editing, apolloClient, toast, fetchPages]);\n\n const handleDelete = useCallback(async () => {\n // Guard against a double-click firing two DELETE mutations for the same row\n // (the second would NOT_FOUND once the first commits).\n if (!deleteTarget || deleting) return;\n setDeleting(true);\n try {\n await apolloClient.mutate({\n mutation: DELETE_CONTENT_PAGE,\n variables: { id: deleteTarget.id },\n });\n toast.success('Content page deleted');\n setDeleteTarget(null);\n // If we just removed the last row on a non-first page, step back a page —\n // the page-change effect refetches at the correct offset. Otherwise the\n // stale offset would land past the new total and strand an empty table.\n if (items.length === 1 && page > 1) {\n setPage((p) => p - 1);\n } else {\n await fetchPages();\n }\n } catch (e) {\n toast.error(e instanceof Error ? e.message : 'Failed to delete content page');\n } finally {\n setDeleting(false);\n }\n }, [deleteTarget, deleting, apolloClient, toast, fetchPages, items.length, page]);\n\n const columns = [\n {\n key: 'title',\n header: 'Title',\n render: (p: ContentPageItem) => (\n <div className=\"min-w-0\">\n <div className=\"font-medium text-foreground\">{p.title}</div>\n <div className=\"truncate text-xs text-muted-foreground\">{p.slug}</div>\n </div>\n ),\n },\n {\n key: 'type',\n header: 'Type',\n width: '120px',\n render: (p: ContentPageItem) => {\n // Don't fall back to \"Blog\" for tags outside our set (e.g. legal/privacy\n // pages that surface in the \"All types\" list) — label those \"Other\".\n const known = CONTENT_TYPES.find((x) => p.tags.includes(x.value));\n return (\n <Badge\n variant={known ? TYPE_VARIANT[known.value] : 'outline'}\n className=\"text-xs capitalize\"\n >\n {known?.label ?? 'Other'}\n </Badge>\n );\n },\n },\n {\n key: 'status',\n header: 'Status',\n width: '110px',\n render: (p: ContentPageItem) => (\n <div className=\"flex items-center gap-1\">\n <Badge variant={p.isPublished ? 'default' : 'outline'} className=\"text-xs\">\n {p.isPublished ? 'Published' : 'Draft'}\n </Badge>\n {p.featured && (\n <Badge variant=\"secondary\" className=\"text-xs\">\n Featured\n </Badge>\n )}\n </div>\n ),\n },\n {\n key: 'updatedAt',\n header: 'Updated',\n width: '110px',\n render: (p: ContentPageItem) => formatDate(p.updatedAt),\n },\n {\n key: 'actions',\n header: '',\n width: '60px',\n align: 'right' as const,\n render: (p: ContentPageItem) => (\n <RowActions\n actions={createRowActions({\n onEdit: canManageProducts ? () => openEdit(p) : undefined,\n onDelete: canManageProducts ? () => setDeleteTarget(p) : undefined,\n })}\n />\n ),\n },\n ];\n\n const formId = 'content-page-form';\n\n // True when editing a page whose tags don't map to a managed content type —\n // used to show a disabled \"Other\" in the (edit-locked) type selector.\n const editingUnmanagedType =\n !!editing && !CONTENT_TYPES.some((t) => editing.tags.includes(t.value));\n\n return (\n <PermissionGuard\n allowed={canViewProducts}\n isLoading={permissionsLoading}\n pageTitle=\"Content\"\n denyMessage=\"You don't have permission to manage content pages.\"\n >\n <PageLayout\n title=\"Content\"\n gradientWord=\"Content\"\n description={`Blog posts, case studies, and changelog entries${\n total ? ` · ${total} total` : ''\n }`}\n actions={\n <Button onClick={openCreate} disabled={!canManageProducts || !productId}>\n <Plus className=\"mr-2 h-4 w-4\" />\n New Content\n </Button>\n }\n >\n <div className=\"space-y-4\">\n {/* Filters */}\n <div className=\"flex flex-wrap items-center gap-3\">\n <select\n aria-label=\"Filter by product\"\n className={`${inputClass} max-w-xs`}\n value={productId}\n onChange={(e) => {\n setPage(1);\n setProductId(e.target.value);\n }}\n >\n {products.length === 0 && <option value=\"\">Loading products…</option>}\n {products.map((p) => (\n <option key={p.id} value={p.id}>\n {p.name}\n </option>\n ))}\n </select>\n\n <select\n aria-label=\"Filter by content type\"\n className={`${inputClass} max-w-[180px]`}\n value={typeFilter}\n onChange={(e) => {\n setPage(1);\n setTypeFilter(e.target.value as '' | ContentType);\n }}\n >\n <option value=\"\">All types</option>\n {CONTENT_TYPES.map((t) => (\n <option key={t.value} value={t.value}>\n {t.label}\n </option>\n ))}\n </select>\n\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-muted-foreground\" />\n <Input\n placeholder=\"Search content...\"\n value={searchInput}\n onChange={(e) => setSearchInput(e.target.value)}\n className=\"pl-9\"\n />\n </div>\n </div>\n\n {error && (\n <div className=\"rounded-md border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive\">\n {error}\n </div>\n )}\n\n <DataTable\n columns={columns}\n data={items}\n keyExtractor={(p) => p.id}\n loading={loading}\n emptyState={\n <EmptyState\n illustration=\"empty-files\"\n icon={<FileText className=\"h-8 w-8\" />}\n title=\"No content yet\"\n description=\"Create a blog post, case study, or changelog entry to get started.\"\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\n {/* Create / Edit sheet */}\n <FormSheet\n open={sheetOpen}\n onClose={() => setSheetOpen(false)}\n title={editing ? 'Edit content' : 'New content'}\n footer={\n <FormSheetFooter\n formId={formId}\n isSubmitting={submitting}\n submitLabel={editing ? 'Save changes' : 'Create'}\n onCancel={() => setSheetOpen(false)}\n />\n }\n >\n <form\n id={formId}\n onSubmit={(e) => {\n e.preventDefault();\n handleSubmit();\n }}\n className=\"space-y-6\"\n >\n <FormSection title=\"Basics\">\n <FormRow cols={2}>\n <label className=\"space-y-1.5\">\n <span className=\"text-sm font-medium\">Type</span>\n <select\n className={inputClass}\n // When editing a page whose tags fall outside the managed set\n // (e.g. legal/privacy), show a disabled \"Other\" rather than\n // mislabelling it as the typeOf() fallback \"Blog\". The control\n // is disabled on edit and tags are never written on update, so\n // this is display-only.\n value={editingUnmanagedType ? '__other' : form.type}\n disabled={!!editing}\n onChange={(e) => setForm((f) => ({ ...f, type: e.target.value as ContentType }))}\n >\n {editingUnmanagedType && <option value=\"__other\">Other</option>}\n {CONTENT_TYPES.map((t) => (\n <option key={t.value} value={t.value}>\n {t.label}\n </option>\n ))}\n </select>\n </label>\n <label className=\"space-y-1.5\">\n <span className=\"text-sm font-medium\">Product</span>\n <select\n className={inputClass}\n value={form.productId}\n disabled={!!editing}\n onChange={(e) => setForm((f) => ({ ...f, productId: e.target.value }))}\n >\n {products.map((p) => (\n <option key={p.id} value={p.id}>\n {p.name}\n </option>\n ))}\n </select>\n </label>\n </FormRow>\n <FormRow cols={2}>\n <label className=\"space-y-1.5\">\n <span className=\"text-sm font-medium\">Title</span>\n <Input\n value={form.title}\n onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}\n placeholder=\"A clear, descriptive title\"\n />\n </label>\n <label className=\"space-y-1.5\">\n <span className=\"text-sm font-medium\">Slug</span>\n <Input\n value={form.slug}\n disabled={!!editing}\n onChange={(e) =>\n setForm((f) => ({\n ...f,\n // Don't strip leading/trailing hyphens here — that breaks\n // typing \"my-slug\" (the hyphen would vanish mid-type).\n // Leading/trailing hyphens are trimmed on submit instead.\n slug: e.target.value.toLowerCase().replace(/[^a-z0-9-]+/g, '-'),\n }))\n }\n placeholder=\"url-friendly-slug\"\n />\n </label>\n </FormRow>\n <label className=\"block space-y-1.5\">\n <span className=\"text-sm font-medium\">Excerpt</span>\n <textarea\n className={`${inputClass} h-16 py-2`}\n value={form.excerpt}\n onChange={(e) => setForm((f) => ({ ...f, excerpt: e.target.value }))}\n placeholder=\"Short summary shown in cards and previews\"\n />\n </label>\n </FormSection>\n\n <FormSection\n title=\"Content\"\n description=\"Markdown supported (headings, lists, links, bold).\"\n >\n <textarea\n aria-label=\"Content\"\n className={`${inputClass} h-64 py-2 font-mono text-xs`}\n value={form.content}\n onChange={(e) => setForm((f) => ({ ...f, content: e.target.value }))}\n placeholder={'## Heading\\n\\nWrite your content in markdown...'}\n />\n </FormSection>\n\n <FormSection title=\"Meta\">\n <FormRow cols={3}>\n <label className=\"space-y-1.5\">\n <span className=\"text-sm font-medium\">Author</span>\n <Input\n value={form.author}\n onChange={(e) => setForm((f) => ({ ...f, author: e.target.value }))}\n placeholder=\"The Team\"\n />\n </label>\n <label className=\"space-y-1.5\">\n <span className=\"text-sm font-medium\">Published date</span>\n <Input\n type=\"date\"\n value={form.publishedAt}\n onChange={(e) => setForm((f) => ({ ...f, publishedAt: e.target.value }))}\n />\n </label>\n <label className=\"space-y-1.5\">\n <span className=\"text-sm font-medium\">Reading time (min)</span>\n <Input\n type=\"number\"\n min={0}\n value={form.readingTime}\n onChange={(e) => setForm((f) => ({ ...f, readingTime: e.target.value }))}\n />\n </label>\n </FormRow>\n <label className=\"block space-y-1.5\">\n <span className=\"text-sm font-medium\">Thumbnail URL</span>\n <Input\n value={form.thumbnail}\n onChange={(e) => setForm((f) => ({ ...f, thumbnail: e.target.value }))}\n placeholder=\"https://…/image.png\"\n />\n </label>\n <div className=\"flex items-center gap-6 pt-1\">\n <label className=\"flex items-center gap-2 text-sm\">\n <input\n type=\"checkbox\"\n checked={form.featured}\n onChange={(e) => setForm((f) => ({ ...f, featured: e.target.checked }))}\n />\n Featured\n </label>\n <label className=\"flex items-center gap-2 text-sm\">\n <input\n type=\"checkbox\"\n checked={form.isPublished}\n onChange={(e) => setForm((f) => ({ ...f, isPublished: e.target.checked }))}\n />\n Published\n </label>\n </div>\n </FormSection>\n </form>\n </FormSheet>\n\n <DeleteConfirmDialog\n open={!!deleteTarget}\n onClose={() => setDeleteTarget(null)}\n onConfirm={handleDelete}\n itemName={deleteTarget?.title ?? 'this content page'}\n itemType=\"content page\"\n loading={deleting}\n />\n </PermissionGuard>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"ContentPagesPage.js","names":[],"sources":["../../../src/admin/pages/ContentPagesPage.tsx"],"sourcesContent":["import { useState, useCallback, useEffect, useRef } from 'react';\nimport { gql } from '@apollo/client';\nimport { useApolloClient } from '@apollo/client/react';\nimport { FileText, Search, Plus } from 'lucide-react';\nimport { useAdminPermissions } from '../hooks/useAdminPermissions';\nimport {\n PageLayout,\n Input,\n DataTable,\n Pagination,\n EmptyState,\n Badge,\n Button,\n PermissionGuard,\n FormSheet,\n FormSheetFooter,\n FormSection,\n FormRow,\n DeleteConfirmDialog,\n useToast,\n createRowActions,\n RowActions,\n} from '../components';\nimport { formatDate } from '../utils/formatters';\nimport { PagePurpose, EmphasisPanel } from '@burdenoff/fe-libs/ui';\n\nconst PAGE_SIZE = 20;\n\n// Content \"type\" is stored on the ProductContentPage `tags` array (one tag per\n// type, matching the website SDK filters: BlogListPage → \"blog\",\n// CaseStudiesListPage → \"case-study\", ChangelogPage → \"changelog\").\nconst CONTENT_TYPES = [\n { value: 'blog', label: 'Blog', category: 'Blog' },\n { value: 'case-study', label: 'Case Study', category: 'Case Study' },\n { value: 'changelog', label: 'Changelog', category: 'Changelog' },\n] as const;\n\ntype ContentType = (typeof CONTENT_TYPES)[number]['value'];\n\nconst TYPE_VARIANT: Record<ContentType, 'default' | 'secondary' | 'outline'> = {\n blog: 'default',\n 'case-study': 'secondary',\n changelog: 'outline',\n};\n\n// ---------------------------------------------------------------------------\n// GraphQL\n// ---------------------------------------------------------------------------\n\nconst LIST_PRODUCTS = gql`\n query AdminContentListProducts($limit: Int, $offset: Int) {\n platformProducts(limit: $limit, offset: $offset) {\n items {\n id\n name\n slug\n }\n }\n }\n`;\n\nconst LIST_CONTENT_PAGES = gql`\n query AdminContentPages(\n $productId: String\n $filter: ContentPageFilterInput\n $limit: Int\n $offset: Int\n ) {\n contentPages(productId: $productId, filter: $filter, limit: $limit, offset: $offset) {\n items {\n id\n productId\n slug\n title\n content\n contentFormat\n tags\n category\n excerpt\n thumbnail\n author\n publishedAt\n readingTime\n featured\n isPublished\n visibility\n updatedAt\n }\n total\n }\n }\n`;\n\nconst CREATE_CONTENT_PAGE = gql`\n mutation AdminCreateContentPage($input: CreateContentPageInput!) {\n createContentPage(input: $input) {\n id\n slug\n }\n }\n`;\n\nconst UPDATE_CONTENT_PAGE = gql`\n mutation AdminUpdateContentPage($id: ID!, $input: UpdateContentPageInput!) {\n updateContentPage(id: $id, input: $input) {\n id\n slug\n }\n }\n`;\n\nconst DELETE_CONTENT_PAGE = gql`\n mutation AdminDeleteContentPage($id: ID!) {\n deleteContentPage(id: $id)\n }\n`;\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\ninterface ProductOption {\n id: string;\n name: string;\n slug: string;\n}\n\ninterface ContentPageItem {\n id: string;\n productId: string | null;\n slug: string;\n title: string;\n content: string;\n contentFormat: string | null;\n tags: string[];\n category: string | null;\n excerpt: string | null;\n thumbnail: string | null;\n author: string | null;\n publishedAt: string | null;\n readingTime: number | null;\n featured: boolean;\n isPublished: boolean;\n visibility: string;\n updatedAt: string;\n}\n\ninterface FormState {\n productId: string;\n type: ContentType;\n slug: string;\n title: string;\n excerpt: string;\n content: string;\n author: string;\n publishedAt: string;\n thumbnail: string;\n readingTime: string;\n featured: boolean;\n isPublished: boolean;\n}\n\nconst EMPTY_FORM: FormState = {\n productId: '',\n type: 'blog',\n slug: '',\n title: '',\n excerpt: '',\n content: '',\n author: '',\n publishedAt: '',\n thumbnail: '',\n readingTime: '',\n featured: false,\n isPublished: true,\n};\n\nfunction typeOf(tags: string[]): ContentType {\n if (tags.includes('changelog')) return 'changelog';\n if (tags.includes('case-study')) return 'case-study';\n return 'blog';\n}\n\nconst inputClass =\n 'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50';\n\n// ---------------------------------------------------------------------------\n// Page\n// ---------------------------------------------------------------------------\n\nexport function ContentPagesPage() {\n const {\n canViewProducts,\n canManageProducts,\n isLoading: permissionsLoading,\n } = useAdminPermissions();\n const apolloClient = useApolloClient();\n const toast = useToast();\n\n const [products, setProducts] = useState<ProductOption[]>([]);\n const [productId, setProductId] = useState('');\n const [typeFilter, setTypeFilter] = useState<'' | ContentType>('');\n const [searchInput, setSearchInput] = useState('');\n const [search, setSearch] = useState(''); // debounced value that actually drives the query\n const [page, setPage] = useState(1);\n\n // Monotonic request id so a slow earlier fetch can't overwrite a newer one.\n const fetchId = useRef(0);\n\n // Debounce the search box: only the settled value triggers a fetch, and a\n // new search resets to page 1.\n useEffect(() => {\n const t = setTimeout(() => {\n setSearch(searchInput);\n setPage(1);\n }, 300);\n return () => clearTimeout(t);\n }, [searchInput]);\n\n const [items, setItems] = useState<ContentPageItem[]>([]);\n const [total, setTotal] = useState(0);\n const [loading, setLoading] = useState(false);\n const [error, setError] = useState<string | null>(null);\n\n const [sheetOpen, setSheetOpen] = useState(false);\n const [editing, setEditing] = useState<ContentPageItem | null>(null);\n const [form, setForm] = useState<FormState>(EMPTY_FORM);\n const [submitting, setSubmitting] = useState(false);\n const [deleteTarget, setDeleteTarget] = useState<ContentPageItem | null>(null);\n const [deleting, setDeleting] = useState(false);\n\n const skip = permissionsLoading || !canViewProducts;\n\n // Load product list for the selector. Capped at 100 — the platform has well\n // under that many products, and this drives a simple filter dropdown, not an\n // exhaustive picker. Revisit with pagination/search if the catalogue grows.\n useEffect(() => {\n if (skip) return;\n // Guard against setting state after unmount if the user navigates away\n // before this resolves.\n let active = true;\n apolloClient\n .query<{ platformProducts: { items: ProductOption[] } }>({\n query: LIST_PRODUCTS,\n variables: { limit: 100, offset: 0 },\n fetchPolicy: 'network-only',\n })\n .then((res) => {\n if (!active) return;\n const list = res.data?.platformProducts?.items ?? [];\n setProducts(list);\n setProductId((prev) => prev || list[0]?.id || '');\n })\n .catch((e) => {\n if (!active) return;\n setError(e instanceof Error ? e.message : 'Failed to load products');\n });\n return () => {\n active = false;\n };\n }, [apolloClient, skip]);\n\n const fetchPages = useCallback(async () => {\n if (skip || !productId) return;\n const id = ++fetchId.current;\n setLoading(true);\n try {\n const res = await apolloClient.query<{\n contentPages: { items: ContentPageItem[]; total: number };\n }>({\n query: LIST_CONTENT_PAGES,\n variables: {\n productId,\n filter: {\n ...(search ? { search } : {}),\n ...(typeFilter ? { tag: typeFilter } : {}),\n },\n limit: PAGE_SIZE,\n offset: (page - 1) * PAGE_SIZE,\n },\n fetchPolicy: 'network-only',\n });\n if (id !== fetchId.current) return; // a newer fetch superseded this one\n setItems(res.data?.contentPages?.items ?? []);\n setTotal(res.data?.contentPages?.total ?? 0);\n setError(null);\n } catch (e) {\n if (id !== fetchId.current) return;\n setError(e instanceof Error ? e.message : 'Failed to load content pages');\n } finally {\n if (id === fetchId.current) setLoading(false);\n }\n }, [apolloClient, skip, productId, search, typeFilter, page]);\n\n useEffect(() => {\n fetchPages();\n }, [fetchPages]);\n\n const totalPages = Math.ceil(total / PAGE_SIZE);\n\n const openCreate = useCallback(() => {\n setEditing(null);\n setForm({ ...EMPTY_FORM, productId, type: typeFilter || 'blog' });\n setSheetOpen(true);\n }, [productId, typeFilter]);\n\n const openEdit = useCallback((item: ContentPageItem) => {\n setEditing(item);\n setForm({\n productId: item.productId ?? '',\n type: typeOf(item.tags),\n slug: item.slug,\n title: item.title,\n excerpt: item.excerpt ?? '',\n content: item.content,\n author: item.author ?? '',\n publishedAt: item.publishedAt ? item.publishedAt.slice(0, 10) : '',\n thumbnail: item.thumbnail ?? '',\n readingTime: item.readingTime != null ? String(item.readingTime) : '',\n featured: item.featured,\n isPublished: item.isPublished,\n });\n setSheetOpen(true);\n }, []);\n\n const handleSubmit = useCallback(async () => {\n const missing = [\n !form.title.trim() && 'Title',\n !form.slug.trim() && 'Slug',\n !form.content.trim() && 'Content',\n ].filter(Boolean);\n if (missing.length) {\n toast.error(`${missing.join(', ')} ${missing.length > 1 ? 'are' : 'is'} required`);\n return;\n }\n // The thumbnail is rendered as an <img src> on the public sites. Only accept\n // http(s) URLs, protocol-relative (`//…`) or site-absolute (`/…`) paths so a\n // `javascript:`/`data:` scheme can't be stored and later become an XSS vector.\n const thumb = form.thumbnail.trim();\n if (thumb && !/^(https?:\\/\\/|\\/\\/|\\/)/i.test(thumb)) {\n toast.error('Thumbnail must be an http(s) URL or an absolute path');\n return;\n }\n setSubmitting(true);\n const typeMeta = CONTENT_TYPES.find((t) => t.value === form.type)!;\n // On EDIT, an emptied optional field must send `null` so the backend clears\n // the previous value (an omitted/`undefined` field is dropped by JSON\n // serialization and leaves the old value untouched). On CREATE we omit.\n const empty = editing ? null : undefined;\n // tags/category are NOT in the shared (update) payload: the type selector is\n // locked on edit, so an edit must NOT overwrite the row's tags/category.\n // That preserves pages whose tags aren't one of blog/case-study/changelog\n // (e.g. legal/privacy) which can surface in the \"All types\" list — editing\n // their title/content must not silently retag them as a blog post.\n const shared = {\n title: form.title.trim(),\n content: form.content,\n contentFormat: 'markdown',\n excerpt: form.excerpt.trim() || empty,\n author: form.author.trim() || empty,\n publishedAt: form.publishedAt ? new Date(form.publishedAt).toISOString() : empty,\n thumbnail: form.thumbnail.trim() || empty,\n readingTime: form.readingTime !== '' ? Number(form.readingTime) : empty,\n featured: form.featured,\n isPublished: form.isPublished,\n // NOTE: `visibility` is intentionally NOT in the shared (update) payload.\n // The form has no visibility control, so an edit must not clobber a page\n // whose visibility was set to INTERNAL/AUTHENTICATED elsewhere back to\n // PUBLIC. It is only defaulted on CREATE below.\n };\n try {\n if (editing) {\n await apolloClient.mutate({\n mutation: UPDATE_CONTENT_PAGE,\n variables: { id: editing.id, input: shared },\n });\n toast.success('Content page updated');\n } else {\n await apolloClient.mutate({\n mutation: CREATE_CONTENT_PAGE,\n variables: {\n input: {\n ...shared,\n visibility: 'PUBLIC',\n tags: [form.type],\n category: typeMeta.category,\n productId: form.productId || undefined,\n // Collapse internal hyphen runs (a `--` can arise from adjacent\n // invalid chars) and trim leading/trailing hyphens — done once on\n // submit so it never fights the user mid-typing.\n slug: form.slug\n .trim()\n .replace(/-+/g, '-')\n .replace(/^-+|-+$/g, ''),\n },\n },\n });\n toast.success('Content page created');\n }\n setSheetOpen(false);\n await fetchPages();\n } catch (e) {\n toast.error(e instanceof Error ? e.message : 'Failed to save content page');\n } finally {\n setSubmitting(false);\n }\n }, [form, editing, apolloClient, toast, fetchPages]);\n\n const handleDelete = useCallback(async () => {\n // Guard against a double-click firing two DELETE mutations for the same row\n // (the second would NOT_FOUND once the first commits).\n if (!deleteTarget || deleting) return;\n setDeleting(true);\n try {\n await apolloClient.mutate({\n mutation: DELETE_CONTENT_PAGE,\n variables: { id: deleteTarget.id },\n });\n toast.success('Content page deleted');\n setDeleteTarget(null);\n // If we just removed the last row on a non-first page, step back a page —\n // the page-change effect refetches at the correct offset. Otherwise the\n // stale offset would land past the new total and strand an empty table.\n if (items.length === 1 && page > 1) {\n setPage((p) => p - 1);\n } else {\n await fetchPages();\n }\n } catch (e) {\n toast.error(e instanceof Error ? e.message : 'Failed to delete content page');\n } finally {\n setDeleting(false);\n }\n }, [deleteTarget, deleting, apolloClient, toast, fetchPages, items.length, page]);\n\n const columns = [\n {\n key: 'title',\n header: 'Title',\n render: (p: ContentPageItem) => (\n <div className=\"min-w-0\">\n <div className=\"font-medium text-foreground\">{p.title}</div>\n <div className=\"truncate text-xs text-muted-foreground\">{p.slug}</div>\n </div>\n ),\n },\n {\n key: 'type',\n header: 'Type',\n width: '120px',\n render: (p: ContentPageItem) => {\n // Don't fall back to \"Blog\" for tags outside our set (e.g. legal/privacy\n // pages that surface in the \"All types\" list) — label those \"Other\".\n const known = CONTENT_TYPES.find((x) => p.tags.includes(x.value));\n return (\n <Badge\n variant={known ? TYPE_VARIANT[known.value] : 'outline'}\n className=\"text-xs capitalize\"\n >\n {known?.label ?? 'Other'}\n </Badge>\n );\n },\n },\n {\n key: 'status',\n header: 'Status',\n width: '110px',\n render: (p: ContentPageItem) => (\n <div className=\"flex items-center gap-1\">\n <Badge variant={p.isPublished ? 'default' : 'outline'} className=\"text-xs\">\n {p.isPublished ? 'Published' : 'Draft'}\n </Badge>\n {p.featured && (\n <Badge variant=\"secondary\" className=\"text-xs\">\n Featured\n </Badge>\n )}\n </div>\n ),\n },\n {\n key: 'updatedAt',\n header: 'Updated',\n width: '110px',\n render: (p: ContentPageItem) => formatDate(p.updatedAt),\n },\n {\n key: 'actions',\n header: '',\n width: '60px',\n align: 'right' as const,\n render: (p: ContentPageItem) => (\n <RowActions\n actions={createRowActions({\n onEdit: canManageProducts ? () => openEdit(p) : undefined,\n onDelete: canManageProducts ? () => setDeleteTarget(p) : undefined,\n })}\n />\n ),\n },\n ];\n\n const formId = 'content-page-form';\n\n // True when editing a page whose tags don't map to a managed content type —\n // used to show a disabled \"Other\" in the (edit-locked) type selector.\n const editingUnmanagedType =\n !!editing && !CONTENT_TYPES.some((t) => editing.tags.includes(t.value));\n\n return (\n <PermissionGuard\n allowed={canViewProducts}\n isLoading={permissionsLoading}\n pageTitle=\"Content\"\n denyMessage=\"You don't have permission to manage content pages.\"\n >\n <PageLayout\n title=\"Content\"\n gradientWord=\"Content\"\n description={`Blog posts, case studies, and changelog entries${\n total ? ` · ${total} total` : ''\n }`}\n actions={\n <Button onClick={openCreate} disabled={!canManageProducts || !productId}>\n <Plus className=\"mr-2 h-4 w-4\" />\n New Content\n </Button>\n }\n >\n <div className=\"space-y-4\">\n <PagePurpose>\n Manage the marketing and product content that powers each product's public site —\n blog posts, case studies and changelog entries. Pick a product, then draft, edit and\n publish the stories that show up on its website.\n </PagePurpose>\n\n {/* Emphasis: how many content entries exist for the selected product. */}\n <EmphasisPanel className=\"flex items-center gap-4 p-5\">\n <div className=\"flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-action-primary-bg/15 text-action-primary-bg\">\n <FileText className=\"h-6 w-6\" />\n </div>\n <div>\n <div className=\"text-3xl font-bold tabular-nums text-text-primary\">{total}</div>\n <p className=\"text-sm text-text-secondary\">content entries for this product</p>\n </div>\n </EmphasisPanel>\n\n {/* Filters */}\n <div className=\"flex flex-wrap items-center gap-3\">\n <select\n aria-label=\"Filter by product\"\n className={`${inputClass} max-w-xs`}\n value={productId}\n onChange={(e) => {\n setPage(1);\n setProductId(e.target.value);\n }}\n >\n {products.length === 0 && <option value=\"\">Loading products…</option>}\n {products.map((p) => (\n <option key={p.id} value={p.id}>\n {p.name}\n </option>\n ))}\n </select>\n\n <select\n aria-label=\"Filter by content type\"\n className={`${inputClass} max-w-[180px]`}\n value={typeFilter}\n onChange={(e) => {\n setPage(1);\n setTypeFilter(e.target.value as '' | ContentType);\n }}\n >\n <option value=\"\">All types</option>\n {CONTENT_TYPES.map((t) => (\n <option key={t.value} value={t.value}>\n {t.label}\n </option>\n ))}\n </select>\n\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-muted-foreground\" />\n <Input\n placeholder=\"Search content...\"\n value={searchInput}\n onChange={(e) => setSearchInput(e.target.value)}\n className=\"pl-9\"\n />\n </div>\n </div>\n\n {error && (\n <div className=\"rounded-md border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive\">\n {error}\n </div>\n )}\n\n <DataTable\n columns={columns}\n data={items}\n keyExtractor={(p) => p.id}\n loading={loading}\n emptyState={\n <EmptyState\n illustration=\"empty-files\"\n icon={<FileText className=\"h-8 w-8\" />}\n title=\"No content yet\"\n description=\"Create a blog post, case study, or changelog entry to get started.\"\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\n {/* Create / Edit sheet */}\n <FormSheet\n open={sheetOpen}\n onClose={() => setSheetOpen(false)}\n title={editing ? 'Edit content' : 'New content'}\n footer={\n <FormSheetFooter\n formId={formId}\n isSubmitting={submitting}\n submitLabel={editing ? 'Save changes' : 'Create'}\n onCancel={() => setSheetOpen(false)}\n />\n }\n >\n <form\n id={formId}\n onSubmit={(e) => {\n e.preventDefault();\n handleSubmit();\n }}\n className=\"space-y-6\"\n >\n <FormSection title=\"Basics\">\n <FormRow cols={2}>\n <label className=\"space-y-1.5\">\n <span className=\"text-sm font-medium\">Type</span>\n <select\n className={inputClass}\n // When editing a page whose tags fall outside the managed set\n // (e.g. legal/privacy), show a disabled \"Other\" rather than\n // mislabelling it as the typeOf() fallback \"Blog\". The control\n // is disabled on edit and tags are never written on update, so\n // this is display-only.\n value={editingUnmanagedType ? '__other' : form.type}\n disabled={!!editing}\n onChange={(e) => setForm((f) => ({ ...f, type: e.target.value as ContentType }))}\n >\n {editingUnmanagedType && <option value=\"__other\">Other</option>}\n {CONTENT_TYPES.map((t) => (\n <option key={t.value} value={t.value}>\n {t.label}\n </option>\n ))}\n </select>\n </label>\n <label className=\"space-y-1.5\">\n <span className=\"text-sm font-medium\">Product</span>\n <select\n className={inputClass}\n value={form.productId}\n disabled={!!editing}\n onChange={(e) => setForm((f) => ({ ...f, productId: e.target.value }))}\n >\n {products.map((p) => (\n <option key={p.id} value={p.id}>\n {p.name}\n </option>\n ))}\n </select>\n </label>\n </FormRow>\n <FormRow cols={2}>\n <label className=\"space-y-1.5\">\n <span className=\"text-sm font-medium\">Title</span>\n <Input\n value={form.title}\n onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}\n placeholder=\"A clear, descriptive title\"\n />\n </label>\n <label className=\"space-y-1.5\">\n <span className=\"text-sm font-medium\">Slug</span>\n <Input\n value={form.slug}\n disabled={!!editing}\n onChange={(e) =>\n setForm((f) => ({\n ...f,\n // Don't strip leading/trailing hyphens here — that breaks\n // typing \"my-slug\" (the hyphen would vanish mid-type).\n // Leading/trailing hyphens are trimmed on submit instead.\n slug: e.target.value.toLowerCase().replace(/[^a-z0-9-]+/g, '-'),\n }))\n }\n placeholder=\"url-friendly-slug\"\n />\n </label>\n </FormRow>\n <label className=\"block space-y-1.5\">\n <span className=\"text-sm font-medium\">Excerpt</span>\n <textarea\n className={`${inputClass} h-16 py-2`}\n value={form.excerpt}\n onChange={(e) => setForm((f) => ({ ...f, excerpt: e.target.value }))}\n placeholder=\"Short summary shown in cards and previews\"\n />\n </label>\n </FormSection>\n\n <FormSection\n title=\"Content\"\n description=\"Markdown supported (headings, lists, links, bold).\"\n >\n <textarea\n aria-label=\"Content\"\n className={`${inputClass} h-64 py-2 font-mono text-xs`}\n value={form.content}\n onChange={(e) => setForm((f) => ({ ...f, content: e.target.value }))}\n placeholder={'## Heading\\n\\nWrite your content in markdown...'}\n />\n </FormSection>\n\n <FormSection title=\"Meta\">\n <FormRow cols={3}>\n <label className=\"space-y-1.5\">\n <span className=\"text-sm font-medium\">Author</span>\n <Input\n value={form.author}\n onChange={(e) => setForm((f) => ({ ...f, author: e.target.value }))}\n placeholder=\"The Team\"\n />\n </label>\n <label className=\"space-y-1.5\">\n <span className=\"text-sm font-medium\">Published date</span>\n <Input\n type=\"date\"\n value={form.publishedAt}\n onChange={(e) => setForm((f) => ({ ...f, publishedAt: e.target.value }))}\n />\n </label>\n <label className=\"space-y-1.5\">\n <span className=\"text-sm font-medium\">Reading time (min)</span>\n <Input\n type=\"number\"\n min={0}\n value={form.readingTime}\n onChange={(e) => setForm((f) => ({ ...f, readingTime: e.target.value }))}\n />\n </label>\n </FormRow>\n <label className=\"block space-y-1.5\">\n <span className=\"text-sm font-medium\">Thumbnail URL</span>\n <Input\n value={form.thumbnail}\n onChange={(e) => setForm((f) => ({ ...f, thumbnail: e.target.value }))}\n placeholder=\"https://…/image.png\"\n />\n </label>\n <div className=\"flex items-center gap-6 pt-1\">\n <label className=\"flex items-center gap-2 text-sm\">\n <input\n type=\"checkbox\"\n checked={form.featured}\n onChange={(e) => setForm((f) => ({ ...f, featured: e.target.checked }))}\n />\n Featured\n </label>\n <label className=\"flex items-center gap-2 text-sm\">\n <input\n type=\"checkbox\"\n checked={form.isPublished}\n onChange={(e) => setForm((f) => ({ ...f, isPublished: e.target.checked }))}\n />\n Published\n </label>\n </div>\n </FormSection>\n </form>\n </FormSheet>\n\n <DeleteConfirmDialog\n open={!!deleteTarget}\n onClose={() => setDeleteTarget(null)}\n onConfirm={handleDelete}\n itemName={deleteTarget?.title ?? 'this content page'}\n itemType=\"content page\"\n loading={deleting}\n />\n </PermissionGuard>\n );\n}\n"],"mappings":"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"}
|
|
@@ -12,113 +12,142 @@ import { mapActivityLogToUi as u } from "../utils/mappers.js";
|
|
|
12
12
|
import { useGetDashboardActiveWorkspaceCountQuery as d, useGetDashboardOrganizationCountQuery as f, useGetDashboardTenantCountQuery as p, useGetDashboardUserCountQuery as m, useGetRecentActivityLogsQuery as h } from "../../generated/global-operations.js";
|
|
13
13
|
import { useMemo as g } from "react";
|
|
14
14
|
import { Briefcase as _, Building2 as v, FileText as y, HelpCircle as b, Layout as x, Plus as S, UserPlus as C, Users as w } from "lucide-react";
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
15
|
+
import { useNavigate as T } from "react-router-dom";
|
|
16
|
+
import { jsx as E, jsxs as D } from "react/jsx-runtime";
|
|
17
|
+
import { useI18n as O } from "@burdenoff/fe-libs/shared/providers/shell/I18nProvider";
|
|
18
|
+
import { NextSteps as k, PagePurpose as A } from "@burdenoff/fe-libs/ui";
|
|
17
19
|
//#region src/admin/pages/DashboardPage.tsx
|
|
18
|
-
function
|
|
19
|
-
let { canViewDashboard:
|
|
20
|
+
function j() {
|
|
21
|
+
let { canViewDashboard: j, isLoading: M } = t(), { basePath: N = "" } = e(), P = T(), { t: F } = O(), I = (e, t, n) => s(F, e, t, n), L = M || !j, { data: R, loading: z } = p({
|
|
20
22
|
fetchPolicy: "cache-and-network",
|
|
21
|
-
skip:
|
|
22
|
-
}), { data:
|
|
23
|
+
skip: L
|
|
24
|
+
}), { data: B, loading: V } = f({
|
|
23
25
|
fetchPolicy: "cache-and-network",
|
|
24
|
-
skip:
|
|
25
|
-
}), { data:
|
|
26
|
+
skip: L
|
|
27
|
+
}), { data: H, loading: U } = d({
|
|
26
28
|
fetchPolicy: "cache-and-network",
|
|
27
|
-
skip:
|
|
28
|
-
}), { data:
|
|
29
|
+
skip: L
|
|
30
|
+
}), { data: W, loading: G } = m({
|
|
29
31
|
fetchPolicy: "cache-and-network",
|
|
30
|
-
skip:
|
|
31
|
-
}), { data:
|
|
32
|
+
skip: L
|
|
33
|
+
}), { data: K, loading: q } = h({
|
|
32
34
|
variables: { limit: 10 },
|
|
33
35
|
fetchPolicy: "cache-and-network",
|
|
34
|
-
skip:
|
|
35
|
-
}),
|
|
36
|
-
tenants:
|
|
37
|
-
organizations:
|
|
38
|
-
workspaces:
|
|
39
|
-
users:
|
|
40
|
-
},
|
|
36
|
+
skip: L
|
|
37
|
+
}), J = {
|
|
38
|
+
tenants: R?.tenants?.total ?? 0,
|
|
39
|
+
organizations: B?.allOrganizations?.total ?? 0,
|
|
40
|
+
workspaces: H?.allWorkspaces?.total ?? 0,
|
|
41
|
+
users: W?.userCount ?? 0
|
|
42
|
+
}, Y = g(() => (K?.activityLogs?.nodes ?? []).map(u), [K]), X = [
|
|
41
43
|
{
|
|
42
|
-
to: l(
|
|
43
|
-
label:
|
|
44
|
-
icon: /* @__PURE__ */
|
|
45
|
-
description:
|
|
44
|
+
to: l(N, "tenants"),
|
|
45
|
+
label: I("admin.dashboard.quickLinks.addTenant", "Add Tenant"),
|
|
46
|
+
icon: /* @__PURE__ */ E(S, { className: "h-4 w-4" }),
|
|
47
|
+
description: I("admin.dashboard.quickLinks.addTenantDescription", "Create a new tenant")
|
|
46
48
|
},
|
|
47
49
|
{
|
|
48
|
-
to: l(
|
|
49
|
-
label:
|
|
50
|
-
icon: /* @__PURE__ */
|
|
51
|
-
description:
|
|
50
|
+
to: l(N, "users"),
|
|
51
|
+
label: I("admin.dashboard.quickLinks.inviteUser", "Invite User"),
|
|
52
|
+
icon: /* @__PURE__ */ E(C, { className: "h-4 w-4" }),
|
|
53
|
+
description: I("admin.dashboard.quickLinks.inviteUserDescription", "Invite a new user")
|
|
52
54
|
},
|
|
53
55
|
{
|
|
54
|
-
to: l(
|
|
55
|
-
label:
|
|
56
|
-
icon: /* @__PURE__ */
|
|
57
|
-
description:
|
|
56
|
+
to: l(N, "audit"),
|
|
57
|
+
label: I("admin.dashboard.quickLinks.auditLogs", "Audit Logs"),
|
|
58
|
+
icon: /* @__PURE__ */ E(y, { className: "h-4 w-4" }),
|
|
59
|
+
description: I("admin.dashboard.quickLinks.auditLogsDescription", "View security audit trail")
|
|
58
60
|
},
|
|
59
61
|
{
|
|
60
|
-
to: l(
|
|
61
|
-
label:
|
|
62
|
-
icon: /* @__PURE__ */
|
|
63
|
-
description:
|
|
62
|
+
to: l(N, "support"),
|
|
63
|
+
label: I("admin.dashboard.quickLinks.support", "Support"),
|
|
64
|
+
icon: /* @__PURE__ */ E(b, { className: "h-4 w-4" }),
|
|
65
|
+
description: I("admin.dashboard.quickLinks.supportDescription", "Manage support tickets")
|
|
64
66
|
}
|
|
65
|
-
],
|
|
66
|
-
return /* @__PURE__ */
|
|
67
|
-
allowed:
|
|
68
|
-
isLoading:
|
|
69
|
-
pageTitle:
|
|
70
|
-
denyMessage:
|
|
71
|
-
children: /* @__PURE__ */
|
|
72
|
-
title:
|
|
73
|
-
description:
|
|
74
|
-
gradientWord:
|
|
67
|
+
], Z = z || V || U || G;
|
|
68
|
+
return /* @__PURE__ */ E(c, {
|
|
69
|
+
allowed: j,
|
|
70
|
+
isLoading: M,
|
|
71
|
+
pageTitle: I("admin.dashboard.title", "Dashboard"),
|
|
72
|
+
denyMessage: I("admin.dashboard.denyMessage", "You don't have permission to view the admin dashboard."),
|
|
73
|
+
children: /* @__PURE__ */ E(n, {
|
|
74
|
+
title: I("admin.dashboard.title", "Dashboard"),
|
|
75
|
+
description: I("admin.dashboard.description", "Platform overview and key metrics"),
|
|
76
|
+
gradientWord: I("admin.dashboard.title", "Dashboard"),
|
|
75
77
|
aurora: !0,
|
|
76
|
-
children: /* @__PURE__ */
|
|
78
|
+
children: /* @__PURE__ */ D("div", {
|
|
77
79
|
className: "space-y-6",
|
|
78
|
-
children: [
|
|
79
|
-
/* @__PURE__ */
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
80
|
+
children: [
|
|
81
|
+
/* @__PURE__ */ E(A, { children: I("admin.dashboard.purpose", "Your control tower for the whole platform. See live tenant, organization, workspace and user counts at a glance, watch the most recent administrative activity, and jump straight into the day-to-day jobs — onboarding a tenant, inviting a user, or reviewing the audit trail.") }),
|
|
82
|
+
/* @__PURE__ */ E(k, {
|
|
83
|
+
storageKey: "admin-dashboard",
|
|
84
|
+
steps: [
|
|
85
|
+
{
|
|
86
|
+
id: "add-tenant",
|
|
87
|
+
label: I("admin.dashboard.nextSteps.addTenant", "Onboard a tenant"),
|
|
88
|
+
description: I("admin.dashboard.nextSteps.addTenantDesc", "Provision a new customer tenant to start serving them."),
|
|
89
|
+
onClick: () => P(l(N, "tenants/new"))
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
id: "invite-user",
|
|
93
|
+
label: I("admin.dashboard.nextSteps.inviteUser", "Invite a teammate"),
|
|
94
|
+
description: I("admin.dashboard.nextSteps.inviteUserDesc", "Bring another operator into the admin console."),
|
|
95
|
+
onClick: () => P(l(N, "users"))
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
id: "review-audit",
|
|
99
|
+
label: I("admin.dashboard.nextSteps.reviewAudit", "Review the audit trail"),
|
|
100
|
+
description: I("admin.dashboard.nextSteps.reviewAuditDesc", "Check who changed what across the platform."),
|
|
101
|
+
onClick: () => P(l(N, "audit"))
|
|
102
|
+
}
|
|
103
|
+
]
|
|
85
104
|
}),
|
|
86
|
-
/* @__PURE__ */
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
105
|
+
/* @__PURE__ */ D(i, { children: [
|
|
106
|
+
/* @__PURE__ */ E(r, {
|
|
107
|
+
title: I("admin.dashboard.metrics.totalTenants", "Total Tenants"),
|
|
108
|
+
value: J.tenants,
|
|
109
|
+
loading: Z,
|
|
110
|
+
hero: !0,
|
|
111
|
+
icon: /* @__PURE__ */ E(v, { className: "h-5 w-5 text-action-primary-bg" })
|
|
112
|
+
}),
|
|
113
|
+
/* @__PURE__ */ E(r, {
|
|
114
|
+
title: I("admin.dashboard.metrics.organizations", "Organizations"),
|
|
115
|
+
value: J.organizations,
|
|
116
|
+
loading: Z,
|
|
117
|
+
icon: /* @__PURE__ */ E(_, { className: "h-5 w-5 text-text-secondary" })
|
|
118
|
+
}),
|
|
119
|
+
/* @__PURE__ */ E(r, {
|
|
120
|
+
title: I("admin.dashboard.metrics.activeWorkspaces", "Active Workspaces"),
|
|
121
|
+
value: J.workspaces,
|
|
122
|
+
loading: Z,
|
|
123
|
+
icon: /* @__PURE__ */ E(x, { className: "h-5 w-5 text-status-success-text" })
|
|
124
|
+
}),
|
|
125
|
+
/* @__PURE__ */ E(r, {
|
|
126
|
+
title: I("admin.dashboard.metrics.totalUsers", "Total Users"),
|
|
127
|
+
value: J.users,
|
|
128
|
+
loading: Z,
|
|
129
|
+
icon: /* @__PURE__ */ E(w, { className: "h-5 w-5 text-text-secondary" })
|
|
111
130
|
})
|
|
112
|
-
}),
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
131
|
+
] }),
|
|
132
|
+
/* @__PURE__ */ D("div", {
|
|
133
|
+
className: "grid gap-6 lg:grid-cols-3",
|
|
134
|
+
children: [/* @__PURE__ */ E("div", {
|
|
135
|
+
className: "lg:col-span-2",
|
|
136
|
+
children: /* @__PURE__ */ E(a, {
|
|
137
|
+
activities: Y,
|
|
138
|
+
loading: q
|
|
139
|
+
})
|
|
140
|
+
}), /* @__PURE__ */ E("div", {
|
|
141
|
+
className: "space-y-4",
|
|
142
|
+
children: /* @__PURE__ */ E(o, { links: X })
|
|
143
|
+
})]
|
|
144
|
+
})
|
|
145
|
+
]
|
|
117
146
|
})
|
|
118
147
|
})
|
|
119
148
|
});
|
|
120
149
|
}
|
|
121
150
|
//#endregion
|
|
122
|
-
export {
|
|
151
|
+
export { j as DashboardPage };
|
|
123
152
|
|
|
124
153
|
//# sourceMappingURL=DashboardPage.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"DashboardPage.js","names":[],"sources":["../../../src/admin/pages/DashboardPage.tsx"],"sourcesContent":["import { useMemo } from 'react';\nimport {\n Building2,\n Users,\n Layout,\n Briefcase,\n Plus,\n UserPlus,\n FileText,\n HelpCircle,\n} from 'lucide-react';\nimport { useAdmin } from '../providers/AdminProvider';\nimport { useAdminPermissions } from '../hooks/useAdminPermissions';\nimport {\n PageLayout,\n MetricCard,\n MetricsGrid,\n ActivityFeed,\n QuickLinks,\n PermissionGuard,\n} from '../components';\nimport { joinPath } from '../utils/navigation';\nimport { mapActivityLogToUi } from '../utils/mappers';\nimport type { Activity } from '../types';\nimport {\n useGetDashboardTenantCountQuery,\n useGetDashboardOrganizationCountQuery,\n useGetDashboardActiveWorkspaceCountQuery,\n useGetDashboardUserCountQuery,\n useGetRecentActivityLogsQuery,\n} from '../../generated/global-operations';\nimport { useI18n } from '@burdenoff/fe-libs/shared/providers/shell/I18nProvider';\nimport { tWithFallback } from '../utils/i18n';\n\n/**\n * Dashboard page — platform overview with real-time metrics\n */\nexport function DashboardPage() {\n const { canViewDashboard, isLoading: permissionsLoading } = useAdminPermissions();\n const { basePath = '' } = useAdmin();\n const { t } = useI18n();\n const tr = (key: string, fallback: string, params?: Record<string, string | number>): string =>\n tWithFallback(t, key, fallback, params);\n\n const skipQueries = permissionsLoading || !canViewDashboard;\n\n const { data: tenantData, loading: tenantLoading } = useGetDashboardTenantCountQuery({\n fetchPolicy: 'cache-and-network',\n skip: skipQueries,\n });\n const { data: orgData, loading: orgLoading } = useGetDashboardOrganizationCountQuery({\n fetchPolicy: 'cache-and-network',\n skip: skipQueries,\n });\n const { data: workspaceData, loading: workspaceLoading } =\n useGetDashboardActiveWorkspaceCountQuery({\n fetchPolicy: 'cache-and-network',\n skip: skipQueries,\n });\n const { data: userData, loading: userLoading } = useGetDashboardUserCountQuery({\n fetchPolicy: 'cache-and-network',\n skip: skipQueries,\n });\n const { data: activityData, loading: activityLoading } = useGetRecentActivityLogsQuery({\n variables: { limit: 10 },\n fetchPolicy: 'cache-and-network',\n skip: skipQueries,\n });\n\n const metrics = {\n tenants: tenantData?.tenants?.total ?? 0,\n organizations: orgData?.allOrganizations?.total ?? 0,\n workspaces: workspaceData?.allWorkspaces?.total ?? 0,\n users: userData?.userCount ?? 0,\n };\n\n const recentActivity: Activity[] = useMemo(\n () => (activityData?.activityLogs?.nodes ?? []).map(mapActivityLogToUi),\n [activityData]\n );\n\n const quickLinks = [\n {\n to: joinPath(basePath, 'tenants'),\n label: tr('admin.dashboard.quickLinks.addTenant', 'Add Tenant'),\n icon: <Plus className=\"h-4 w-4\" />,\n description: tr('admin.dashboard.quickLinks.addTenantDescription', 'Create a new tenant'),\n },\n {\n to: joinPath(basePath, 'users'),\n label: tr('admin.dashboard.quickLinks.inviteUser', 'Invite User'),\n icon: <UserPlus className=\"h-4 w-4\" />,\n description: tr('admin.dashboard.quickLinks.inviteUserDescription', 'Invite a new user'),\n },\n {\n to: joinPath(basePath, 'audit'),\n label: tr('admin.dashboard.quickLinks.auditLogs', 'Audit Logs'),\n icon: <FileText className=\"h-4 w-4\" />,\n description: tr(\n 'admin.dashboard.quickLinks.auditLogsDescription',\n 'View security audit trail'\n ),\n },\n {\n to: joinPath(basePath, 'support'),\n label: tr('admin.dashboard.quickLinks.support', 'Support'),\n icon: <HelpCircle className=\"h-4 w-4\" />,\n description: tr('admin.dashboard.quickLinks.supportDescription', 'Manage support tickets'),\n },\n ];\n\n const isLoading = tenantLoading || orgLoading || workspaceLoading || userLoading;\n\n return (\n <PermissionGuard\n allowed={canViewDashboard}\n isLoading={permissionsLoading}\n pageTitle={tr('admin.dashboard.title', 'Dashboard')}\n denyMessage={tr(\n 'admin.dashboard.denyMessage',\n \"You don't have permission to view the admin dashboard.\"\n )}\n >\n <PageLayout\n title={tr('admin.dashboard.title', 'Dashboard')}\n description={tr('admin.dashboard.description', 'Platform overview and key metrics')}\n gradientWord={tr('admin.dashboard.title', 'Dashboard')}\n aurora\n >\n <div className=\"space-y-6\">\n {/* Metrics Grid */}\n <MetricsGrid>\n <MetricCard\n title={tr('admin.dashboard.metrics.totalTenants', 'Total Tenants')}\n value={metrics.tenants}\n loading={isLoading}\n hero\n icon={<Building2 className=\"h-5 w-5 text-action-primary-bg\" />}\n />\n <MetricCard\n title={tr('admin.dashboard.metrics.organizations', 'Organizations')}\n value={metrics.organizations}\n loading={isLoading}\n icon={<Briefcase className=\"h-5 w-5 text-text-secondary\" />}\n />\n <MetricCard\n title={tr('admin.dashboard.metrics.activeWorkspaces', 'Active Workspaces')}\n value={metrics.workspaces}\n loading={isLoading}\n icon={<Layout className=\"h-5 w-5 text-status-success-text\" />}\n />\n <MetricCard\n title={tr('admin.dashboard.metrics.totalUsers', 'Total Users')}\n value={metrics.users}\n loading={isLoading}\n icon={<Users className=\"h-5 w-5 text-text-secondary\" />}\n />\n </MetricsGrid>\n\n {/* Content Area */}\n <div className=\"grid gap-6 lg:grid-cols-3\">\n <div className=\"lg:col-span-2\">\n <ActivityFeed activities={recentActivity} loading={activityLoading} />\n </div>\n <div className=\"space-y-4\">\n <QuickLinks links={quickLinks} />\n </div>\n </div>\n </div>\n </PageLayout>\n </PermissionGuard>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"DashboardPage.js","names":[],"sources":["../../../src/admin/pages/DashboardPage.tsx"],"sourcesContent":["import { useMemo } from 'react';\nimport {\n Building2,\n Users,\n Layout,\n Briefcase,\n Plus,\n UserPlus,\n FileText,\n HelpCircle,\n} from 'lucide-react';\nimport { useAdmin } from '../providers/AdminProvider';\nimport { useAdminPermissions } from '../hooks/useAdminPermissions';\nimport {\n PageLayout,\n MetricCard,\n MetricsGrid,\n ActivityFeed,\n QuickLinks,\n PermissionGuard,\n} from '../components';\nimport { useNavigate } from 'react-router-dom';\nimport { PagePurpose, NextSteps } from '@burdenoff/fe-libs/ui';\nimport { joinPath } from '../utils/navigation';\nimport { mapActivityLogToUi } from '../utils/mappers';\nimport type { Activity } from '../types';\nimport {\n useGetDashboardTenantCountQuery,\n useGetDashboardOrganizationCountQuery,\n useGetDashboardActiveWorkspaceCountQuery,\n useGetDashboardUserCountQuery,\n useGetRecentActivityLogsQuery,\n} from '../../generated/global-operations';\nimport { useI18n } from '@burdenoff/fe-libs/shared/providers/shell/I18nProvider';\nimport { tWithFallback } from '../utils/i18n';\n\n/**\n * Dashboard page — platform overview with real-time metrics\n */\nexport function DashboardPage() {\n const { canViewDashboard, isLoading: permissionsLoading } = useAdminPermissions();\n const { basePath = '' } = useAdmin();\n const navigate = useNavigate();\n const { t } = useI18n();\n const tr = (key: string, fallback: string, params?: Record<string, string | number>): string =>\n tWithFallback(t, key, fallback, params);\n\n const skipQueries = permissionsLoading || !canViewDashboard;\n\n const { data: tenantData, loading: tenantLoading } = useGetDashboardTenantCountQuery({\n fetchPolicy: 'cache-and-network',\n skip: skipQueries,\n });\n const { data: orgData, loading: orgLoading } = useGetDashboardOrganizationCountQuery({\n fetchPolicy: 'cache-and-network',\n skip: skipQueries,\n });\n const { data: workspaceData, loading: workspaceLoading } =\n useGetDashboardActiveWorkspaceCountQuery({\n fetchPolicy: 'cache-and-network',\n skip: skipQueries,\n });\n const { data: userData, loading: userLoading } = useGetDashboardUserCountQuery({\n fetchPolicy: 'cache-and-network',\n skip: skipQueries,\n });\n const { data: activityData, loading: activityLoading } = useGetRecentActivityLogsQuery({\n variables: { limit: 10 },\n fetchPolicy: 'cache-and-network',\n skip: skipQueries,\n });\n\n const metrics = {\n tenants: tenantData?.tenants?.total ?? 0,\n organizations: orgData?.allOrganizations?.total ?? 0,\n workspaces: workspaceData?.allWorkspaces?.total ?? 0,\n users: userData?.userCount ?? 0,\n };\n\n const recentActivity: Activity[] = useMemo(\n () => (activityData?.activityLogs?.nodes ?? []).map(mapActivityLogToUi),\n [activityData]\n );\n\n const quickLinks = [\n {\n to: joinPath(basePath, 'tenants'),\n label: tr('admin.dashboard.quickLinks.addTenant', 'Add Tenant'),\n icon: <Plus className=\"h-4 w-4\" />,\n description: tr('admin.dashboard.quickLinks.addTenantDescription', 'Create a new tenant'),\n },\n {\n to: joinPath(basePath, 'users'),\n label: tr('admin.dashboard.quickLinks.inviteUser', 'Invite User'),\n icon: <UserPlus className=\"h-4 w-4\" />,\n description: tr('admin.dashboard.quickLinks.inviteUserDescription', 'Invite a new user'),\n },\n {\n to: joinPath(basePath, 'audit'),\n label: tr('admin.dashboard.quickLinks.auditLogs', 'Audit Logs'),\n icon: <FileText className=\"h-4 w-4\" />,\n description: tr(\n 'admin.dashboard.quickLinks.auditLogsDescription',\n 'View security audit trail'\n ),\n },\n {\n to: joinPath(basePath, 'support'),\n label: tr('admin.dashboard.quickLinks.support', 'Support'),\n icon: <HelpCircle className=\"h-4 w-4\" />,\n description: tr('admin.dashboard.quickLinks.supportDescription', 'Manage support tickets'),\n },\n ];\n\n const isLoading = tenantLoading || orgLoading || workspaceLoading || userLoading;\n\n return (\n <PermissionGuard\n allowed={canViewDashboard}\n isLoading={permissionsLoading}\n pageTitle={tr('admin.dashboard.title', 'Dashboard')}\n denyMessage={tr(\n 'admin.dashboard.denyMessage',\n \"You don't have permission to view the admin dashboard.\"\n )}\n >\n <PageLayout\n title={tr('admin.dashboard.title', 'Dashboard')}\n description={tr('admin.dashboard.description', 'Platform overview and key metrics')}\n gradientWord={tr('admin.dashboard.title', 'Dashboard')}\n aurora\n >\n <div className=\"space-y-6\">\n <PagePurpose>\n {tr(\n 'admin.dashboard.purpose',\n 'Your control tower for the whole platform. See live tenant, organization, workspace and user counts at a glance, watch the most recent administrative activity, and jump straight into the day-to-day jobs — onboarding a tenant, inviting a user, or reviewing the audit trail.'\n )}\n </PagePurpose>\n\n <NextSteps\n storageKey=\"admin-dashboard\"\n steps={[\n {\n id: 'add-tenant',\n label: tr('admin.dashboard.nextSteps.addTenant', 'Onboard a tenant'),\n description: tr(\n 'admin.dashboard.nextSteps.addTenantDesc',\n 'Provision a new customer tenant to start serving them.'\n ),\n onClick: () => navigate(joinPath(basePath, 'tenants/new')),\n },\n {\n id: 'invite-user',\n label: tr('admin.dashboard.nextSteps.inviteUser', 'Invite a teammate'),\n description: tr(\n 'admin.dashboard.nextSteps.inviteUserDesc',\n 'Bring another operator into the admin console.'\n ),\n onClick: () => navigate(joinPath(basePath, 'users')),\n },\n {\n id: 'review-audit',\n label: tr('admin.dashboard.nextSteps.reviewAudit', 'Review the audit trail'),\n description: tr(\n 'admin.dashboard.nextSteps.reviewAuditDesc',\n 'Check who changed what across the platform.'\n ),\n onClick: () => navigate(joinPath(basePath, 'audit')),\n },\n ]}\n />\n\n {/* Metrics Grid */}\n <MetricsGrid>\n <MetricCard\n title={tr('admin.dashboard.metrics.totalTenants', 'Total Tenants')}\n value={metrics.tenants}\n loading={isLoading}\n hero\n icon={<Building2 className=\"h-5 w-5 text-action-primary-bg\" />}\n />\n <MetricCard\n title={tr('admin.dashboard.metrics.organizations', 'Organizations')}\n value={metrics.organizations}\n loading={isLoading}\n icon={<Briefcase className=\"h-5 w-5 text-text-secondary\" />}\n />\n <MetricCard\n title={tr('admin.dashboard.metrics.activeWorkspaces', 'Active Workspaces')}\n value={metrics.workspaces}\n loading={isLoading}\n icon={<Layout className=\"h-5 w-5 text-status-success-text\" />}\n />\n <MetricCard\n title={tr('admin.dashboard.metrics.totalUsers', 'Total Users')}\n value={metrics.users}\n loading={isLoading}\n icon={<Users className=\"h-5 w-5 text-text-secondary\" />}\n />\n </MetricsGrid>\n\n {/* Content Area */}\n <div className=\"grid gap-6 lg:grid-cols-3\">\n <div className=\"lg:col-span-2\">\n <ActivityFeed activities={recentActivity} loading={activityLoading} />\n </div>\n <div className=\"space-y-4\">\n <QuickLinks links={quickLinks} />\n </div>\n </div>\n </div>\n </PageLayout>\n </PermissionGuard>\n );\n}\n"],"mappings":"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"}
|