import React, { useCallback, useEffect, useMemo, useState } from 'react'; import type { ReactNode } from 'react'; import DocumentSummarySection, { DocumentSummarySectionProps } from './DocumentSummarySection'; import { describeDocumentSummary, extractDocumentMetadataPayload, type DocumentSummaryRow } from '../logic/documentSummary'; import type { Tag, Correspondent } from '../../types/documents'; import type { TagId, Identifier } from '../../types/identifiers'; type PanelTab = { id: string; label: string; render: (context?: Record) => ReactNode }; type ContentState = | { status: 'idle'; data: null; error: null } | { status: 'loading'; data: null; error: null } | { status: 'loaded'; data: string; error: null } | { status: 'empty'; data: string; error: null } | { status: 'unavailable'; data: null; error: null } | { status: 'error'; data: null; error: unknown }; export interface DocumentInfoPanelProps { tagLookupById?: Map; correspondentLookupById?: Map; summaryProps?: Omit; metadataItems?: DocumentSummaryRow[]; metadataPayload?: Record; metadataTabLabel?: string; detailsTabLabel?: string; contentConfig?: { id?: string; label?: string; enabled?: boolean; forceDisplay?: boolean; loadContent?: (args: { signal: AbortSignal }) => Promise; onCancel?: () => void; loadingMessage?: string; emptyMessage?: string; unavailableMessage?: string; errorMessage?: string; renderContent?: (data: string) => ReactNode; } | null; activeTab?: string; onTabChange?: (tabId: string) => void; defaultTabId?: string; resetKey?: string | null; classNamePrefix?: string; hideTabNavWhenSingle?: boolean; summaryPlacement?: 'inline' | 'tabs'; summaryTabLabel?: string; summaryTabId?: string; leadingTabs?: PanelTab[]; trailingTabs?: PanelTab[]; tabsPlacement?: 'top' | 'bottom'; summaryLayout?: 'default' | 'compact'; } const DocumentInfoPanel: React.FC = ({ document, tagLookupById, correspondentLookupById, summaryProps = {}, metadataItems: metadataItemsProp, metadataPayload: metadataPayloadProp, metadataTabLabel = 'Metadata', detailsTabLabel = 'Details', contentConfig: contentConfigProp = null, activeTab: controlledActiveTab, onTabChange, defaultTabId = 'details', resetKey = null, classNamePrefix = 'document-info', hideTabNavWhenSingle = true, summaryPlacement = 'inline', summaryTabLabel = 'Summary', summaryTabId = 'summary', leadingTabs = [], trailingTabs = [], tabsPlacement = 'top', summaryLayout = 'default', }) => { const base = classNamePrefix; const metadataItems = useMemo(() => { if (Array.isArray(metadataItemsProp) && metadataItemsProp.length) { return metadataItemsProp; } return describeDocumentSummary(document, { tagLookupById, correspondentLookupById }); }, [metadataItemsProp, document, tagLookupById, correspondentLookupById]); const metadataPayload = useMemo(() => { if (metadataPayloadProp !== undefined) { return metadataPayloadProp; } return extractDocumentMetadataPayload(document); }, [metadataPayloadProp, document]); const contentConfig = contentConfigProp || null; const contentEnabled = Boolean(contentConfig && (contentConfig.enabled ?? true)); const loadContent = contentConfig?.loadContent ?? null; const showContentTab = Boolean(contentConfig && (contentConfig.forceDisplay ?? contentEnabled)); const [contentState, setContentState] = useState(() => { if (!contentConfig) { return null; } if (!contentEnabled || !loadContent) { return { status: contentEnabled ? 'idle' : 'unavailable', data: null, error: null }; } return { status: 'idle', data: null, error: null }; }); const renderSummarySection = useCallback(() => ( ), [document, summaryLayout, summaryProps, correspondentLookupById]); const renderDetailsSection = useCallback(() => (
{metadataItems.length ? (
{metadataItems.map(({ key, label, value }) => (
{label}
{value || '—'}
))}
) : (

No details available.

)}
), [base, metadataItems]); const summaryInline = summaryPlacement !== 'tabs'; const summaryTab = useMemo(() => { if (summaryPlacement !== 'tabs') { return null; } return { id: summaryTabId, label: summaryTabLabel, render: () => (
{renderSummarySection()}
), }; }, [summaryPlacement, summaryTabId, summaryTabLabel, base, renderSummarySection]); const normalizedLeadingTabs = useMemo( () => (Array.isArray(leadingTabs) ? leadingTabs.filter((tab): tab is PanelTab => Boolean(tab && tab.id && tab.label)) : []), [leadingTabs], ); const normalizedTrailingTabs = useMemo( () => (Array.isArray(trailingTabs) ? trailingTabs.filter((tab): tab is PanelTab => Boolean(tab && tab.id && tab.label)) : []), [trailingTabs], ); const summaryNode = summaryInline ? ( <> {renderSummarySection()} {renderDetailsSection()} ) : null; const visibleTabs = useMemo(() => { const tabsList: PanelTab[] = []; if (normalizedLeadingTabs.length) { tabsList.push(...normalizedLeadingTabs); } if (summaryTab) { tabsList.push(summaryTab); } if (summaryPlacement !== 'tabs') { tabsList.push({ id: 'details', label: detailsTabLabel, render: () => renderDetailsSection(), }); } if (showContentTab && contentConfig) { tabsList.push({ id: contentConfig.id || 'content', label: contentConfig.label || 'Content', render: () => { const messageClass = `${base}__message`; const errorClass = `${base}__message ${base}__message--error`; const objectClass = `${base}__object ${base}__object--text-content`; if (!contentEnabled || !contentConfig.loadContent) { return (
{contentConfig.unavailableMessage || 'Content not available.'}
); } if (!contentState) { return (
{contentConfig.emptyMessage || 'No content available.'}
); } switch (contentState.status) { case 'loading': return (
{contentConfig.loadingMessage || 'Loading content…'}
); case 'error': { const errorMessage = contentConfig.errorMessage || (contentState.error instanceof Error ? contentState.error.message : null) || 'Failed to load content.'; return
{errorMessage}
; } case 'empty': return (
{contentConfig.emptyMessage || 'No content available.'}
); case 'loaded': return (
{contentState.data}
); case 'unavailable': return (
{contentConfig.unavailableMessage || 'Content not available.'}
); default: return (
{contentConfig.emptyMessage || 'No content available.'}
); } }, }); } if (metadataPayload) { tabsList.push({ id: 'metadata', label: metadataTabLabel, render: () => (
              {JSON.stringify(metadataPayload, null, 2)}
            
), }); } if (normalizedTrailingTabs.length) { tabsList.push(...normalizedTrailingTabs); } return tabsList; }, [ base, detailsTabLabel, contentConfig, contentEnabled, contentState, metadataPayload, metadataTabLabel, showContentTab, summaryTab, normalizedLeadingTabs, normalizedTrailingTabs, summaryPlacement, renderDetailsSection, ]); const fallbackTabId = useMemo(() => { if (!visibleTabs.length) { return null; } if (defaultTabId && visibleTabs.some((tab) => tab.id === defaultTabId)) { return defaultTabId; } return visibleTabs[0].id; }, [visibleTabs, defaultTabId]); const renderTabContent = (tab?: PanelTab | null, context: Record = {}) => { if (!tab) { return null; } if (!tab.render) { return null; } return tab.render(context); }; const isControlled = controlledActiveTab !== undefined && controlledActiveTab !== null; const [uncontrolledTab, setUncontrolledTab] = useState( isControlled ? controlledActiveTab : fallbackTabId, ); useEffect(() => { if (!isControlled) { setUncontrolledTab(fallbackTabId); } }, [fallbackTabId, isControlled]); useEffect(() => { if (isControlled && controlledActiveTab && !visibleTabs.some((tab) => tab.id === controlledActiveTab)) { const nextTab = fallbackTabId; if (nextTab && nextTab !== controlledActiveTab) { onTabChange?.(nextTab); } } }, [isControlled, controlledActiveTab, visibleTabs, fallbackTabId, onTabChange]); const activeTabId = isControlled ? controlledActiveTab : uncontrolledTab; // Track previous ID/key to avoid unnecessary resets on prop reference changes const prevDocIdRef = React.useRef(document?.id); const prevResetKeyRef = React.useRef(resetKey); const activeControllerRef = React.useRef(null); // Reset content state when document changes or contentConfig becomes available useEffect(() => { const docIdChanged = prevDocIdRef.current !== document?.id; const resetKeyChanged = prevResetKeyRef.current !== resetKey; // Check if we need to initialize state (e.g. contentConfig was loaded asynchronously) const needsInit = contentConfig && !contentState; if (docIdChanged || resetKeyChanged || needsInit) { prevDocIdRef.current = document?.id; prevResetKeyRef.current = resetKey; if (!contentConfig || !showContentTab) { setContentState(null); return; } if (!contentEnabled || !loadContent) { setContentState({ status: contentEnabled ? 'idle' : 'unavailable', data: null, error: null }); return; } // Reset to idle so the loading effect can trigger if needed setContentState({ status: 'idle', data: null, error: null }); } }, [ contentConfig, showContentTab, contentEnabled, loadContent, document?.id, resetKey, contentState, ]); // Cleanup effect: aborts when inputs change or component unmounts useEffect(() => { return () => { activeControllerRef.current?.abort(); contentConfig?.onCancel?.(); }; }, [activeTabId, contentConfig, loadContent, contentEnabled, document?.id]); // Loading effect: triggers load when status is idle useEffect(() => { const contentTabId = contentConfig?.id || 'content'; const isActive = activeTabId === contentTabId; if (!isActive || !contentConfig || !loadContent || !contentEnabled) { return; } if (contentState?.status !== 'idle') { return; } const controller = new AbortController(); activeControllerRef.current = controller; setContentState({ status: 'loading', data: null, error: null }); Promise.resolve(loadContent({ signal: controller.signal })) .then((result) => { if (controller.signal.aborted) { return; } if (result && result.length) { setContentState({ status: 'loaded', data: result, error: null }); } else { setContentState({ status: 'empty', data: '', error: null }); } }) .catch((error) => { if (controller.signal.aborted || error?.name === 'AbortError') { return; } setContentState({ status: 'error', data: null, error, }); }); }, [ activeTabId, contentConfig, loadContent, contentEnabled, contentState?.status, document?.id, ]); const handleTabSelect = (tabId) => { if (!visibleTabs.some((tab) => tab.id === tabId)) { return; } if (!isControlled) { setUncontrolledTab(tabId); } if (tabId !== activeTabId) { onTabChange?.(tabId); } }; const singleTab = visibleTabs.length === 1 ? visibleTabs[0] : null; const shouldHideNav = hideTabNavWhenSingle && singleTab; const tabNav = (
{visibleTabs.map((tab) => ( ))}
); const tabPanels = (
{visibleTabs.map((tab) => ( tab.id === activeTabId ? (
{renderTabContent(tab, { document })}
) : null ))}
); const tabsWrapperClass = `${base}__tabs-wrapper${tabsPlacement === 'bottom' ? ` ${base}__tabs-wrapper--bottom` : ''}`; return ( <> {summaryNode} {shouldHideNav ? (
{renderTabContent(singleTab, { document })}
) : (
{tabsPlacement !== 'bottom' ? tabNav : null} {tabsPlacement === 'bottom' ? tabPanels : null} {tabsPlacement === 'bottom' ? tabNav : null} {tabsPlacement !== 'bottom' ? tabPanels : null}
)} ); }; export default DocumentInfoPanel;