From 4f70d576466dfa026c13428a6ab646ba36fdb3ec Mon Sep 17 00:00:00 2001 From: "Joseph T. French" Date: Wed, 1 Apr 2026 21:05:28 -0500 Subject: [PATCH 1/2] Add Publish Lists feature and update reports navigation --- src/app/(app)/reports/[id]/content.tsx | 173 +++---- src/app/(app)/reports/content.tsx | 20 +- .../(app)/reports/publish-lists/content.tsx | 490 ++++++++++++++++++ src/app/(app)/reports/publish-lists/page.tsx | 15 + src/app/(app)/sidebar-config.tsx | 1 + 5 files changed, 606 insertions(+), 93 deletions(-) create mode 100644 src/app/(app)/reports/publish-lists/content.tsx create mode 100644 src/app/(app)/reports/publish-lists/page.tsx diff --git a/src/app/(app)/reports/[id]/content.tsx b/src/app/(app)/reports/[id]/content.tsx index fe49d67..260d080 100644 --- a/src/app/(app)/reports/[id]/content.tsx +++ b/src/app/(app)/reports/[id]/content.tsx @@ -1,14 +1,9 @@ 'use client' import { PageHeader } from '@/components/PageHeader' -import { - customTheme, - extensions, - GraphFilters, - PageLayout, - useGraphContext, -} from '@/lib/core' +import { customTheme, extensions, PageLayout } from '@/lib/core' import type { + PublishList, Report, StatementData, StatementRow, @@ -18,7 +13,6 @@ import { Badge, Button, Card, - Checkbox, Label, Modal, ModalBody, @@ -35,7 +29,7 @@ import { import Link from 'next/link' import { useParams, useSearchParams } from 'next/navigation' import type { FC } from 'react' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { HiCheckCircle, HiChevronLeft, @@ -70,11 +64,20 @@ const formatDate = (dateString: string | null): string => { }) } -const StatementTable: FC<{ data: StatementData }> = ({ data }) => { +const StatementTable: FC<{ + data: StatementData + entityName?: string | null +}> = ({ data, entityName }) => { const hasComparative = data.rows.some((r) => r.priorValue !== null) - return (
+ {entityName && ( +
+

+ {entityName} +

+
+ )} {data.structureName} @@ -147,7 +150,6 @@ const StatementTable: FC<{ data: StatementData }> = ({ data }) => { const ReportViewerContent: FC = function () { const params = useParams() const searchParams = useSearchParams() - const { state: graphState } = useGraphContext() const reportId = params.id as string const graphId = searchParams.get('graph') @@ -160,33 +162,27 @@ const ReportViewerContent: FC = function () { // Share modal state const [showShareModal, setShowShareModal] = useState(false) - const [selectedTargets, setSelectedTargets] = useState>(new Set()) - const [externalGraphId, setExternalGraphId] = useState('') + const [publishLists, setPublishLists] = useState([]) + const [selectedListId, setSelectedListId] = useState(null) + const [isLoadingLists, setIsLoadingLists] = useState(false) const [isSharing, setIsSharing] = useState(false) const [shareResult, setShareResult] = useState(null) - // Other roboledger graphs to share to (exclude current graph) - const shareableGraphs = useMemo(() => { - return graphState.graphs - .filter(GraphFilters.roboledger) - .filter((g) => g.graphId !== graphId) - }, [graphState.graphs, graphId]) - - // All target graph IDs (own graphs + external) - const allTargetIds = useMemo(() => { - const ids = new Set(selectedTargets) - const trimmed = externalGraphId.trim() - if (trimmed) { - // Support comma-separated IDs - trimmed.split(/[,\s]+/).forEach((id) => { - if (id) ids.add(id) - }) + const loadPublishLists = useCallback(async () => { + if (!graphId) return + try { + setIsLoadingLists(true) + const lists = await extensions.reports.listPublishLists(graphId) + setPublishLists(lists) + } catch (err) { + console.error('Failed to load publish lists:', err) + } finally { + setIsLoadingLists(false) } - return ids - }, [selectedTargets, externalGraphId]) + }, [graphId]) const handleShare = useCallback(async () => { - if (!graphId || !reportId || allTargetIds.size === 0) return + if (!graphId || !reportId || !selectedListId) return try { setIsSharing(true) @@ -194,39 +190,26 @@ const ReportViewerContent: FC = function () { const result = await extensions.reports.share( graphId, reportId, - Array.from(allTargetIds) + selectedListId ) const succeeded = result.results.filter( (r) => r.status === 'shared' ).length const failed = result.results.filter((r) => r.status === 'error') - let msg = `Shared to ${succeeded} graph${succeeded !== 1 ? 's' : ''} successfully.` + let msg = `Shared to ${succeeded} recipient${succeeded !== 1 ? 's' : ''} successfully.` if (failed.length > 0) { msg += ` ${failed.length} failed: ${failed.map((f) => f.error || f.targetGraphId).join(', ')}` } setShareResult(msg) - setSelectedTargets(new Set()) - setExternalGraphId('') + setSelectedListId(null) } catch (err) { console.error('Share failed:', err) setShareResult('Failed to share report.') } finally { setIsSharing(false) } - }, [graphId, reportId, allTargetIds]) - - const toggleTarget = useCallback((gid: string) => { - setSelectedTargets((prev) => { - const next = new Set(prev) - if (next.has(gid)) { - next.delete(gid) - } else { - next.add(gid) - } - return next - }) - }, []) + }, [graphId, reportId, selectedListId]) // Load report metadata useEffect(() => { @@ -323,7 +306,7 @@ const ReportViewerContent: FC = function () { @@ -334,6 +317,8 @@ const ReportViewerContent: FC = function () { color="purple" onClick={() => { setShareResult(null) + setSelectedListId(null) + loadPublishLists() setShowShareModal(true) }} > @@ -357,7 +342,8 @@ const ReportViewerContent: FC = function () {
- Shared report — received{' '} + Shared report + {report.entityName ? ` from ${report.entityName}` : ''} — received{' '} {report.sharedAt ? formatDate(report.sharedAt.split('T')[0]) : ''}
@@ -387,7 +373,7 @@ const ReportViewerContent: FC = function () { ) : statement && statement.rows.length > 0 ? ( <> - + {/* Validation */} {statement.validation && ( @@ -465,45 +451,59 @@ const ReportViewerContent: FC = function () {

- Share a snapshot copy of this report. Recipients get a read-only - copy that won't change if your books are updated. + Share a snapshot copy of this report to a publish list. Recipients + get a read-only copy that won't change if your books are + updated.

- {/* Own graphs */} - {shareableGraphs.length > 0 && ( + {isLoadingLists ? ( +
+ +
+ ) : publishLists.length === 0 ? ( +
+

+ No publish lists yet. +

+ + + +
+ ) : (
- {shareableGraphs.map((g) => ( -
- toggleTarget(g.graphId)} - /> - -
+ {publishLists.map((list) => ( + ))}
)} - - {/* External graph IDs */} -
- - setExternalGraphId(e.target.value)} - placeholder="e.g. kg1abc123, kg2def456" - className="w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-purple-500 focus:ring-purple-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400" - /> -

- Comma-separated graph IDs from any account -

-
@@ -511,11 +511,10 @@ const ReportViewerContent: FC = function () { theme={customTheme.button} color="purple" onClick={handleShare} - disabled={isSharing || allTargetIds.size === 0} + disabled={isSharing || !selectedListId} > {isSharing ? : null} - Share to {allTargetIds.size} graph - {allTargetIds.size !== 1 ? 's' : ''} + Share Report - +
+ + + + + + +
} /> diff --git a/src/app/(app)/reports/publish-lists/content.tsx b/src/app/(app)/reports/publish-lists/content.tsx new file mode 100644 index 0000000..e137e68 --- /dev/null +++ b/src/app/(app)/reports/publish-lists/content.tsx @@ -0,0 +1,490 @@ +'use client' + +import { PageHeader } from '@/components/PageHeader' +import { + customTheme, + extensions, + GraphFilters, + PageLayout, + useGraphContext, +} from '@/lib/core' +import type { + PublishList, + PublishListDetail, +} from '@robosystems/client/extensions' +import { + Alert, + Badge, + Button, + Card, + Label, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + Spinner, + Table, + TableBody, + TableCell, + TableHead, + TableHeadCell, + TableRow, + TextInput, +} from 'flowbite-react' +import Link from 'next/link' +import type { FC } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { + HiArrowLeft, + HiOutlinePlusCircle, + HiOutlineTrash, + HiShare, + HiUserGroup, +} from 'react-icons/hi' + +const PublishListsContent: FC = function () { + const { state: graphState } = useGraphContext() + + const [lists, setLists] = useState([]) + const [selectedList, setSelectedList] = useState( + null + ) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + // Create list modal + const [showCreateModal, setShowCreateModal] = useState(false) + const [newListName, setNewListName] = useState('') + const [newListDescription, setNewListDescription] = useState('') + const [isCreating, setIsCreating] = useState(false) + + // Add member modal + const [showAddMemberModal, setShowAddMemberModal] = useState(false) + const [newMemberGraphId, setNewMemberGraphId] = useState('') + const [isAddingMember, setIsAddingMember] = useState(false) + const [addMemberError, setAddMemberError] = useState(null) + + const currentGraph = useMemo(() => { + const roboledgerGraphs = graphState.graphs.filter(GraphFilters.roboledger) + return ( + roboledgerGraphs.find((g) => g.graphId === graphState.currentGraphId) ?? + roboledgerGraphs[0] + ) + }, [graphState.graphs, graphState.currentGraphId]) + + const graphId = currentGraph?.graphId + + const loadLists = useCallback(async () => { + if (!graphId) return + try { + setIsLoading(true) + setError(null) + const result = await extensions.reports.listPublishLists(graphId) + setLists(result) + } catch (err) { + console.error('Failed to load publish lists:', err) + setError('Failed to load publish lists.') + } finally { + setIsLoading(false) + } + }, [graphId]) + + const loadListDetail = useCallback( + async (listId: string) => { + if (!graphId) return + try { + const detail = await extensions.reports.getPublishList(graphId, listId) + setSelectedList(detail) + } catch (err) { + console.error('Failed to load list detail:', err) + setError('Failed to load list details.') + } + }, + [graphId] + ) + + useEffect(() => { + loadLists() + }, [loadLists]) + + const handleCreateList = useCallback(async () => { + if (!graphId || !newListName.trim()) return + try { + setIsCreating(true) + await extensions.reports.createPublishList( + graphId, + newListName.trim(), + newListDescription.trim() || undefined + ) + setShowCreateModal(false) + setNewListName('') + setNewListDescription('') + await loadLists() + } catch (err) { + console.error('Failed to create list:', err) + setError('Failed to create publish list.') + } finally { + setIsCreating(false) + } + }, [graphId, newListName, newListDescription, loadLists]) + + const handleDeleteList = useCallback( + async (listId: string) => { + if (!graphId) return + try { + await extensions.reports.deletePublishList(graphId, listId) + if (selectedList?.id === listId) setSelectedList(null) + await loadLists() + } catch (err) { + console.error('Failed to delete list:', err) + setError('Failed to delete publish list.') + } + }, + [graphId, selectedList, loadLists] + ) + + const handleAddMember = useCallback(async () => { + if (!graphId || !selectedList || !newMemberGraphId.trim()) return + try { + setIsAddingMember(true) + setAddMemberError(null) + await extensions.reports.addMembers(graphId, selectedList.id, [ + newMemberGraphId.trim(), + ]) + setNewMemberGraphId('') + setShowAddMemberModal(false) + await loadListDetail(selectedList.id) + await loadLists() + } catch (err: any) { + console.error('Failed to add member:', err) + const msg = err?.message || 'Failed to add member.' + setAddMemberError(msg) + } finally { + setIsAddingMember(false) + } + }, [graphId, selectedList, newMemberGraphId, loadListDetail, loadLists]) + + const handleRemoveMember = useCallback( + async (memberId: string) => { + if (!graphId || !selectedList) return + try { + await extensions.reports.removeMember( + graphId, + selectedList.id, + memberId + ) + await loadListDetail(selectedList.id) + await loadLists() + } catch (err) { + console.error('Failed to remove member:', err) + setError('Failed to remove member.') + } + }, + [graphId, selectedList, loadListDetail, loadLists] + ) + + if (!currentGraph) { + return ( + + +

+ No graph selected. Select a graph to manage publish lists. +

+
+
+ ) + } + + return ( + + + + + + + + } + /> + + {error && ( + setError(null)} + > + {error} + + )} + +
+ {/* List panel */} + +

Your Lists

+ {isLoading ? ( +
+ +
+ ) : lists.length === 0 ? ( +
+ +

+ No publish lists yet. Create one to start sharing reports. +

+
+ ) : ( +
+ {lists.map((list) => ( + + ))} +
+ )} +
+ + {/* Detail panel */} + + {selectedList ? ( + <> +
+
+

+ {selectedList.name} +

+ {selectedList.description && ( +

+ {selectedList.description} +

+ )} +
+
+ + +
+
+ + {selectedList.members.length === 0 ? ( +
+

+ No recipients yet. Add graph IDs to this list. +

+
+ ) : ( +
+ + Graph ID + Organization + Added + + + + {selectedList.members.map((member) => ( + + +
+ + {member.targetGraphName || member.targetGraphId} + + {member.targetGraphName && ( + + {member.targetGraphId} + + )} +
+
+ + {member.targetOrgName || ( + - + )} + + + {new Date(member.addedAt).toLocaleDateString()} + + + + +
+ ))} +
+
+ )} + + ) : ( +
+ +

+ Select a list to view and manage its recipients. +

+
+ )} + +
+ + {/* Create list modal */} + setShowCreateModal(false)} + size="md" + > + Create Publish List + +
+
+ + setNewListName(e.target.value)} + placeholder="e.g. Series A Investors" + /> +
+
+ + setNewListDescription(e.target.value)} + placeholder="Who receives reports via this list" + /> +
+
+
+ + + + +
+ + {/* Add member modal */} + { + setShowAddMemberModal(false) + setAddMemberError(null) + }} + size="md" + > + Add Recipient + +
+ {addMemberError && ( + + {addMemberError} + + )} +

+ Enter the graph ID of the investor or entity you want to share + reports with. They will receive a copy of any report you share to + this list. +

+
+ + setNewMemberGraphId(e.target.value)} + placeholder="e.g. kg01abc123def456" + className="font-mono" + /> +
+
+
+ + + + +
+ + ) +} + +export default PublishListsContent diff --git a/src/app/(app)/reports/publish-lists/page.tsx b/src/app/(app)/reports/publish-lists/page.tsx new file mode 100644 index 0000000..9c41ff3 --- /dev/null +++ b/src/app/(app)/reports/publish-lists/page.tsx @@ -0,0 +1,15 @@ +'use client' + +import { useUser } from '@/lib/core' +import { Spinner } from '@/lib/core/ui-components' +import PublishListsContent from './content' + +export default function PublishListsPage() { + const { user, isLoading } = useUser() + + if (isLoading || !user) { + return + } + + return +} diff --git a/src/app/(app)/sidebar-config.tsx b/src/app/(app)/sidebar-config.tsx index a702a8b..2c3f146 100644 --- a/src/app/(app)/sidebar-config.tsx +++ b/src/app/(app)/sidebar-config.tsx @@ -51,6 +51,7 @@ export const getNavigationItems = ( items: [ { href: '/reports', label: 'View Reports' }, { href: '/reports/new', label: 'Create Report' }, + { href: '/reports/publish-lists', label: 'Publish Lists' }, ], }, { From 22db10faa2cd273465f84d319f1c2a2226f0a769 Mon Sep 17 00:00:00 2001 From: "Joseph T. French" Date: Wed, 1 Apr 2026 21:18:40 -0500 Subject: [PATCH 2/2] Update @robosystems/client to version 0.2.42 in package.json and package-lock.json --- package-lock.json | 34 ++++++++++++++++++++++++++++++---- package.json | 2 +- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 62f744c..3ddf207 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "Apache-2.0", "dependencies": { "@aws-sdk/client-sns": "^3.1021.0", - "@robosystems/client": "0.2.40", + "@robosystems/client": "0.2.42", "flowbite": "^3.1", "flowbite-react": "^0.12.5", "intuit-oauth": "^4.1.0", @@ -3219,9 +3219,9 @@ } }, "node_modules/@robosystems/client": { - "version": "0.2.40", - "resolved": "https://registry.npmjs.org/@robosystems/client/-/client-0.2.40.tgz", - "integrity": "sha512-P6fzWEZFK5JRvOJJByWQx6s2OgeagB03ScqFBYO0iAUL2l2BdNPIR8bRIZxy1z/mDedRvaXNWRY35yZ/ZabRuw==", + "version": "0.2.42", + "resolved": "https://registry.npmjs.org/@robosystems/client/-/client-0.2.42.tgz", + "integrity": "sha512-2gUBXvX9/Pp67BzSdkYLu6YmxWLSAOAISYakaCmyiqNX/Xrp6EuwbuthMaIA+FBugD9A/j+LH/CFK8i/AXOUGA==", "license": "MIT", "bin": { "create-feature": "bin/create-feature.sh" @@ -3296,6 +3296,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3309,6 +3310,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3322,6 +3324,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3335,6 +3338,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3348,6 +3352,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3361,6 +3366,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3374,6 +3380,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3387,6 +3394,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3400,6 +3408,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3413,6 +3422,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3426,6 +3436,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3439,6 +3450,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3452,6 +3464,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3465,6 +3478,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3478,6 +3492,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3491,6 +3506,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3504,6 +3520,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3517,6 +3534,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3530,6 +3548,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3543,6 +3562,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3556,6 +3576,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3569,6 +3590,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3582,6 +3604,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3595,6 +3618,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3608,6 +3632,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8070,6 +8095,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/package.json b/package.json index f5e8057..02926d3 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ }, "dependencies": { "@aws-sdk/client-sns": "^3.1021.0", - "@robosystems/client": "0.2.40", + "@robosystems/client": "0.2.42", "flowbite": "^3.1", "flowbite-react": "^0.12.5", "intuit-oauth": "^4.1.0",