diff --git a/application/single_app/config.py b/application/single_app/config.py index a21fa6be3..7378b2436 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.277" +VERSION = "0.261.278" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/single_app/functions_activity_logging.py b/application/single_app/functions_activity_logging.py index 173ef3daa..636b4baf6 100644 --- a/application/single_app/functions_activity_logging.py +++ b/application/single_app/functions_activity_logging.py @@ -16,14 +16,21 @@ from config import cosmos_activity_logs_container +def build_activity_log_id(activity_type, user_id, idempotency_key): + """Build the stable activity record ID used for idempotent writes.""" + if not isinstance(idempotency_key, str) or not idempotency_key or len(idempotency_key) > 512: + raise ValueError("The activity idempotency key is invalid.") + return str(uuid.uuid5( + uuid.NAMESPACE_URL, + f"simplechat:{activity_type}:{user_id}:{idempotency_key}", + )) + + def _create_activity_record(record, idempotency_key=None): if idempotency_key is not None: - if not isinstance(idempotency_key, str) or not idempotency_key or len(idempotency_key) > 512: - raise ValueError("The activity idempotency key is invalid.") - record["id"] = str(uuid.uuid5( - uuid.NAMESPACE_URL, - f"simplechat:{record['activity_type']}:{record['user_id']}:{idempotency_key}", - )) + record["id"] = build_activity_log_id( + record['activity_type'], record['user_id'], idempotency_key + ) try: cosmos_activity_logs_container.create_item(body=record) except CosmosResourceExistsError: @@ -33,6 +40,37 @@ def _create_activity_record(record, idempotency_key=None): return record +def has_activity_log_for_resource(user_id, activity_type, resource_id, workspace_type=None): + """Check for an existing creation record within its user partition.""" + if not user_id or not resource_id: + return False + + if activity_type == 'conversation_creation': + resource_path = 'c.conversation.conversation_id' + elif activity_type == 'document_creation': + resource_path = 'c.document.document_id' + else: + raise ValueError("Unsupported activity type for resource lookup.") + + query = ( + "SELECT TOP 1 VALUE c.id FROM c " + f"WHERE c.activity_type = @activity_type AND {resource_path} = @resource_id" + ) + parameters = [ + {'name': '@activity_type', 'value': activity_type}, + {'name': '@resource_id', 'value': resource_id}, + ] + if workspace_type: + query += " AND c.workspace_type = @workspace_type" + parameters.append({'name': '@workspace_type', 'value': workspace_type}) + matches = cosmos_activity_logs_container.query_items( + query=query, + parameters=parameters, + partition_key=user_id, + ) + return next(iter(matches), None) is not None + + def coerce_activity_log_user_id(user_id: Any) -> str: """Extract a stable string user id from a scalar or session-style identity payload.""" if user_id is None: diff --git a/application/single_app/functions_authentication.py b/application/single_app/functions_authentication.py index 8a31cb179..8d65a9ee2 100644 --- a/application/single_app/functions_authentication.py +++ b/application/single_app/functions_authentication.py @@ -959,6 +959,47 @@ def decorated_function(*args, **kwargs): return "Forbidden", 403 return decorated_function +def get_control_center_capabilities(user=None, settings=None): + """Return the Control Center permissions implied by the user's roles and settings.""" + user = session.get('user', {}) if user is None else user + settings = get_settings() if settings is None else settings + require_admin_role = settings.get("require_member_of_control_center_admin", False) + require_dashboard_reader_role = settings.get( + "require_member_of_control_center_dashboard_reader", False + ) + + has_control_center_admin_role = ( + 'roles' in user and 'ControlCenterAdmin' in user['roles'] + if isinstance(user, dict) + else False + ) + has_dashboard_reader_role = ( + 'roles' in user and 'ControlCenterDashboardReader' in user['roles'] + if isinstance(user, dict) + else False + ) + has_regular_admin_role = ( + 'roles' in user and 'Admin' in user['roles'] + if isinstance(user, dict) + else False + ) + + has_full_access = ( + has_control_center_admin_role if require_admin_role else has_regular_admin_role + ) + can_view_dashboard = has_full_access or ( + require_dashboard_reader_role and has_dashboard_reader_role + ) + return { + 'can_view_dashboard': bool(can_view_dashboard), + 'can_manage_users': bool(has_full_access), + 'can_manage_groups': bool(has_full_access), + 'can_manage_workspaces': bool(has_full_access), + 'can_view_activity_logs': bool(has_full_access), + 'can_run_maintenance': bool(has_full_access), + } + + def control_center_required(access_level='admin'): """ Unified Control Center access control decorator. @@ -984,48 +1025,34 @@ def decorated_function(*args, **kwargs): user = session.get('user', {}) settings = get_settings() require_member_of_control_center_admin = settings.get("require_member_of_control_center_admin", False) - require_member_of_control_center_dashboard_reader = settings.get("require_member_of_control_center_dashboard_reader", False) - - has_control_center_admin_role = 'roles' in user and 'ControlCenterAdmin' in user['roles'] - has_dashboard_reader_role = 'roles' in user and 'ControlCenterDashboardReader' in user['roles'] - has_regular_admin_role = 'roles' in user and 'Admin' in user['roles'] - - # Check if ControlCenterAdmin role requirement is enforced - if require_member_of_control_center_admin: - # ControlCenterAdmin role is REQUIRED for access - # Only ControlCenterAdmin role grants full access - if has_control_center_admin_role: - return f(*args, **kwargs) - - # For dashboard access, check if DashboardReader role grants access - if access_level == 'dashboard': - if require_member_of_control_center_dashboard_reader and has_dashboard_reader_role: - return f(*args, **kwargs) - - # User doesn't have ControlCenterAdmin role, deny access - # Note: Regular Admin role does NOT grant access when this setting is enabled - is_api_request = (request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html) or request.path.startswith('/api/') - if is_api_request: - return jsonify({"error": "Forbidden", "message": "Insufficient permissions (ControlCenterAdmin role required)"}), 403 - else: - return "Forbidden: ControlCenterAdmin role required", 403 - - # ControlCenterAdmin requirement is NOT enforced (default behavior) - # Only regular Admin role grants access - ControlCenterAdmin role is IGNORED - if has_regular_admin_role: + capabilities = get_control_center_capabilities(user, settings) + permitted = ( + capabilities['can_view_dashboard'] + if access_level == 'dashboard' + else capabilities['can_manage_users'] + ) + if permitted: return f(*args, **kwargs) - # For dashboard-only access, check if DashboardReader role is enabled and user has it - if access_level == 'dashboard': - if require_member_of_control_center_dashboard_reader and has_dashboard_reader_role: - return f(*args, **kwargs) - # User is not an admin and doesn't have special roles - deny access is_api_request = (request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html) or request.path.startswith('/api/') if is_api_request: - return jsonify({"error": "Forbidden", "message": "Insufficient permissions (Admin role required)"}), 403 + required_role = ( + "ControlCenterAdmin" + if require_member_of_control_center_admin + else "Admin" + ) + return jsonify({ + "error": "Forbidden", + "message": f"Insufficient permissions ({required_role} role required)", + }), 403 else: - return "Forbidden: Admin role required", 403 + required_role = ( + "ControlCenterAdmin" + if require_member_of_control_center_admin + else "Admin" + ) + return f"Forbidden: {required_role} role required", 403 return decorated_function return decorator diff --git a/application/single_app/route_backend_control_center.py b/application/single_app/route_backend_control_center.py index 0b055148c..ebba74e75 100644 --- a/application/single_app/route_backend_control_center.py +++ b/application/single_app/route_backend_control_center.py @@ -6208,13 +6208,18 @@ def api_migrate_to_activity_logs(): results = { 'conversations_migrated': 0, 'conversations_failed': 0, + 'conversations_skipped_existing': 0, 'personal_documents_migrated': 0, 'personal_documents_failed': 0, + 'personal_documents_skipped_existing': 0, 'group_documents_migrated': 0, 'group_documents_failed': 0, + 'group_documents_skipped_existing': 0, 'public_documents_migrated': 0, 'public_documents_failed': 0, + 'public_documents_skipped_existing': 0, 'total_migrated': 0, + 'total_skipped_existing': 0, 'total_failed': 0, 'errors': [] } @@ -6236,9 +6241,21 @@ def api_migrate_to_activity_logs(): for conv in conversations: try: + if has_activity_log_for_resource( + conv.get('user_id'), 'conversation_creation', conv.get('id') + ): + conv['added_to_activity_log'] = True + cosmos_conversations_container.upsert_item(conv) + results['conversations_skipped_existing'] += 1 + continue + # Create activity log directly to preserve original timestamp activity_log = { - 'id': str(uuid.uuid4()), + 'id': build_activity_log_id( + 'conversation_creation', + conv.get('user_id'), + f"backfill:{conv.get('id')}", + ), 'activity_type': 'conversation_creation', 'user_id': conv.get('user_id'), 'timestamp': conv.get('created_at') or conv.get('last_updated') or datetime.utcnow().isoformat(), @@ -6288,9 +6305,21 @@ def api_migrate_to_activity_logs(): for doc in personal_docs: try: + if has_activity_log_for_resource( + doc.get('user_id'), 'document_creation', doc.get('id'), 'personal' + ): + doc['added_to_activity_log'] = True + cosmos_user_documents_container.upsert_item(doc) + results['personal_documents_skipped_existing'] += 1 + continue + # Create activity log directly to preserve original timestamp activity_log = { - 'id': str(uuid.uuid4()), + 'id': build_activity_log_id( + 'document_creation', + doc.get('user_id'), + f"backfill:personal:{doc.get('id')}", + ), 'user_id': doc.get('user_id'), 'activity_type': 'document_creation', 'workspace_type': 'personal', @@ -6354,9 +6383,21 @@ def api_migrate_to_activity_logs(): for doc in group_docs: try: + if has_activity_log_for_resource( + doc.get('user_id'), 'document_creation', doc.get('id'), 'group' + ): + doc['added_to_activity_log'] = True + cosmos_group_documents_container.upsert_item(doc) + results['group_documents_skipped_existing'] += 1 + continue + # Create activity log directly to preserve original timestamp activity_log = { - 'id': str(uuid.uuid4()), + 'id': build_activity_log_id( + 'document_creation', + doc.get('user_id'), + f"backfill:group:{doc.get('id')}", + ), 'user_id': doc.get('user_id'), 'activity_type': 'document_creation', 'workspace_type': 'group', @@ -6422,9 +6463,21 @@ def api_migrate_to_activity_logs(): for doc in public_docs: try: + if has_activity_log_for_resource( + doc.get('user_id'), 'document_creation', doc.get('id'), 'public' + ): + doc['added_to_activity_log'] = True + cosmos_public_documents_container.upsert_item(doc) + results['public_documents_skipped_existing'] += 1 + continue + # Create activity log directly to preserve original timestamp activity_log = { - 'id': str(uuid.uuid4()), + 'id': build_activity_log_id( + 'document_creation', + doc.get('user_id'), + f"backfill:public:{doc.get('id')}", + ), 'user_id': doc.get('user_id'), 'activity_type': 'document_creation', 'workspace_type': 'public', @@ -6482,7 +6535,14 @@ def api_migrate_to_activity_logs(): results['group_documents_migrated'] + results['public_documents_migrated'] ) - + + results['total_skipped_existing'] = ( + results['conversations_skipped_existing'] + + results['personal_documents_skipped_existing'] + + results['group_documents_skipped_existing'] + + results['public_documents_skipped_existing'] + ) + results['total_failed'] = ( results['conversations_failed'] + results['personal_documents_failed'] + diff --git a/application/single_app/route_backend_users.py b/application/single_app/route_backend_users.py index edd089156..00a3657c6 100644 --- a/application/single_app/route_backend_users.py +++ b/application/single_app/route_backend_users.py @@ -560,6 +560,8 @@ def user_settings(): # Whether the V2 Admin Settings categories rail is collapsed to icons. # Separate from the shell and workspace rails so each keeps its own state. 'v2AdminRailCollapsed', + # Whether the V2 Control Center section rail is collapsed to icons. + 'v2ControlCenterRailCollapsed', # V2 document explorer: how the workspace documents list is presented # (view mode, visible columns, page size, details pane) and the saved # filter combinations pinned in its navigation rail. diff --git a/application/single_app/route_backend_v2.py b/application/single_app/route_backend_v2.py index 306ab2d5c..b6468ae01 100644 --- a/application/single_app/route_backend_v2.py +++ b/application/single_app/route_backend_v2.py @@ -78,6 +78,7 @@ ) from functions_authentication import ( admin_required, + get_control_center_capabilities, get_current_user_id, get_current_user_info, login_required, @@ -872,6 +873,7 @@ def v2_bootstrap(): "navigation": _build_navigation(settings, current_user_roles), "features": _build_feature_flags(public_settings, per_user_overrides), "capabilities": _build_capabilities(settings), + "control_center": get_control_center_capabilities(session_user, settings), "orchestration": _build_orchestration(settings, public_settings), "catalogs": { "models": models, diff --git a/application/single_app/static/js/control-center.js b/application/single_app/static/js/control-center.js index 99c7a5c06..2c11536c3 100644 --- a/application/single_app/static/js/control-center.js +++ b/application/single_app/static/js/control-center.js @@ -4943,172 +4943,6 @@ function showAlert(message, type = 'info') { }, 5000); } -// Activity Log Migration Functions -async function checkMigrationStatus() { - // Only admins can see migration status - if (window.hasControlCenterAdmin !== true) { - return; - } - - try { - const response = await fetch('/api/admin/control-center/migrate/status'); - if (!response.ok) { - throw new Error('Failed to fetch migration status'); - } - - const data = await response.json(); - - if (data.migration_needed) { - // Update banner with counts - document.getElementById('migrationConversationCount').textContent = data.conversations_without_logs.toLocaleString(); - document.getElementById('migrationDocumentCount').textContent = data.total_documents_without_logs.toLocaleString(); - - // Show the banner - const banner = document.getElementById('migrationBanner'); - if (banner) { - banner.style.display = 'block'; - } - } else { - // Hide banner if no migration needed - const banner = document.getElementById('migrationBanner'); - if (banner) { - banner.style.display = 'none'; - } - } - - return data; - } catch (error) { - console.error('Error checking migration status:', error); - return null; - } -} - -function showMigrationProgress() { - const progressDiv = document.getElementById('migrationProgress'); - const migrateBtn = document.getElementById('migrateBannerBtn'); - - if (progressDiv) { - progressDiv.style.display = 'block'; - } - - if (migrateBtn) { - migrateBtn.disabled = true; - migrateBtn.innerHTML = ' Migrating...'; - } -} - -function hideMigrationProgress() { - const progressDiv = document.getElementById('migrationProgress'); - const migrateBtn = document.getElementById('migrateBannerBtn'); - - if (progressDiv) { - progressDiv.style.display = 'none'; - } - - if (migrateBtn) { - migrateBtn.disabled = false; - migrateBtn.innerHTML = ' Migrate Now'; - } -} - -function updateMigrationProgress(percent, statusText) { - const progressBar = document.getElementById('migrationProgressBar'); - const progressText = document.getElementById('migrationProgressText'); - const statusTextEl = document.getElementById('migrationStatusText'); - - if (progressBar) { - progressBar.style.width = percent + '%'; - progressBar.setAttribute('aria-valuenow', percent); - } - - if (progressText) { - progressText.textContent = percent + '%'; - } - - if (statusTextEl && statusText) { - statusTextEl.textContent = statusText; - } -} - -function hideMigrationBanner() { - const banner = document.getElementById('migrationBanner'); - if (banner) { - banner.style.display = 'none'; - } -} - -async function performMigration() { - // Show confirmation modal - const modal = new bootstrap.Modal(document.getElementById('migrationConfirmModal')); - modal.show(); -} - -async function executeMigration() { - // Close the confirmation modal - const modal = bootstrap.Modal.getInstance(document.getElementById('migrationConfirmModal')); - if (modal) { - modal.hide(); - } - - try { - showMigrationProgress(); - updateMigrationProgress(10, 'Starting migration...'); - - const response = await fetch('/api/admin/control-center/migrate/all', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - } - }); - - updateMigrationProgress(50, 'Processing records...'); - - if (!response.ok) { - throw new Error('Migration request failed'); - } - - const result = await response.json(); - - updateMigrationProgress(90, 'Finalizing...'); - - // Show results - setTimeout(() => { - updateMigrationProgress(100, 'Migration completed!'); - - setTimeout(() => { - hideMigrationProgress(); - hideMigrationBanner(); - - // Show detailed results - const totalMigrated = result.total_migrated || 0; - const totalFailed = result.total_failed || 0; - - let message = `Migration completed! Conversations: ${result.conversations_migrated || 0}, Personal docs: ${result.personal_documents_migrated || 0}, Group docs: ${result.group_documents_migrated || 0}, Public docs: ${result.public_documents_migrated || 0}. Total: ${totalMigrated} records migrated`; - - if (totalFailed > 0) { - message += `. Warning: ${totalFailed} records failed (check logs)`; - } - - showToast(message, 'success'); - - // Refresh activity trends to show new data - if (window.controlCenter) { - window.controlCenter.loadActivityTrends(); - } - }, 1500); - }, 500); - - } catch (error) { - console.error('Migration error:', error); - hideMigrationProgress(); - showToast(`Migration failed: ${error.message}. Check console and server logs for details.`, 'danger'); - } -} - -// Make migration functions globally accessible -window.checkMigrationStatus = checkMigrationStatus; -window.performMigration = performMigration; - // Make refresh function globally accessible for debugging window.refreshControlCenterData = refreshControlCenterData; window.loadRefreshStatus = loadRefreshStatus; @@ -5130,12 +4964,6 @@ document.addEventListener('DOMContentLoaded', function() { // Export GroupTableSorter to window for global access window.GroupTableSorter = GroupTableSorter; - // Wire up migration confirmation button - const confirmMigrationBtn = document.getElementById('confirmMigrationBtn'); - if (confirmMigrationBtn) { - confirmMigrationBtn.addEventListener('click', executeMigration); - } - // Debug: Log element availability console.log('Control Center Elements Check on DOM Ready:'); window.debugControlCenterElements(); @@ -5148,8 +4976,6 @@ document.addEventListener('DOMContentLoaded', function() { setTimeout(() => { loadRefreshStatus(); - // Check migration status - checkMigrationStatus(); }, 100); } }); \ No newline at end of file diff --git a/application/single_app/templates/control_center.html b/application/single_app/templates/control_center.html index 294e3e6a7..95959d0f2 100644 --- a/application/single_app/templates/control_center.html +++ b/application/single_app/templates/control_center.html @@ -666,48 +666,6 @@

Control Center

{% include "_semantic_search_health_warning.html" %} - - - {% set nav_layout = user_settings.get('settings', {}).get('navLayout') %} {% if not (nav_layout == 'sidebar' or (not nav_layout and app_settings.enable_left_nav_default)) %} @@ -3116,34 +3074,6 @@ - - diff --git a/application/v2_ui/src/App.tsx b/application/v2_ui/src/App.tsx index af8e156e4..f3ec3847a 100644 --- a/application/v2_ui/src/App.tsx +++ b/application/v2_ui/src/App.tsx @@ -29,6 +29,7 @@ import { PublicWorkspacePage } from './pages/PublicWorkspacePage'; import { PublicDirectoryPage } from './pages/PublicDirectoryPage'; import { clearWorkspaceEditorDrafts } from './lib/workspaceEditorDrafts'; import { ContentReviewPage } from './pages/ContentReviewPage'; +import { ControlCenterPage } from './pages/ControlCenterPage'; // Dev only: the workflow alert lab. Its one dynamic import is created only when // import.meta.env.DEV, which a production build replaces with false, so production never @@ -217,6 +218,8 @@ export function App() { } /> } /> } /> + } /> + } /> } /> { + id: string; + label: string; + render: (row: Row) => ReactNode; + sortable?: boolean; +} + +export function useCrossPageSelection() { + const [selection, setSelection] = useState<{ + allMatchingSelected: boolean; + includedIds: Set; + excludedIds: Set; + }>(() => ({ allMatchingSelected: false, includedIds: new Set(), excludedIds: new Set() })); + return { + selectedIds: selection.includedIds, + allMatchingSelected: selection.allMatchingSelected, + isSelected: (id: string) => selection.allMatchingSelected + ? !selection.excludedIds.has(id) + : selection.includedIds.has(id), + selectAllAcrossPages: () => setSelection({ + allMatchingSelected: true, includedIds: new Set(), excludedIds: new Set(), + }), + setSelected: (id: string, selected: boolean) => setSelection((current) => { + const includedIds = new Set(current.includedIds); + const excludedIds = new Set(current.excludedIds); + if (current.allMatchingSelected) { + if (selected) excludedIds.delete(id); + else excludedIds.add(id); + } else if (selected) includedIds.add(id); + else includedIds.delete(id); + return { ...current, includedIds, excludedIds }; + }), + setPageSelected: (ids: string[], selected: boolean) => setSelection((current) => { + const includedIds = new Set(current.includedIds); + const excludedIds = new Set(current.excludedIds); + ids.forEach((id) => { + if (current.allMatchingSelected) { + if (selected) excludedIds.delete(id); + else excludedIds.add(id); + } else if (selected) includedIds.add(id); + else includedIds.delete(id); + }); + return { ...current, includedIds, excludedIds }; + }), + clear: () => setSelection({ + allMatchingSelected: false, includedIds: new Set(), excludedIds: new Set(), + }), + }; +} + +export function DataTable({ + rows, + columns, + total, + page, + pageSize, + sort, + sortDirection, + loading = false, + error, + emptyMessage = 'No records found.', + selectable = false, + selectedIds = new Set(), + isRowSelected, + allMatchingSelected = false, + onSelect, + onSelectPage, + onSelectAllMatchingPages, + onSort, + onPageChange, +}: { + rows: Row[]; + columns: DataColumn[]; + total: number; + page: number; + pageSize: number; + sort?: string; + sortDirection?: 'asc' | 'desc'; + loading?: boolean; + error?: string | null; + emptyMessage?: string; + selectable?: boolean; + selectedIds?: ReadonlySet; + isRowSelected?: (id: string) => boolean; + allMatchingSelected?: boolean; + onSelect?: (id: string, selected: boolean) => void; + onSelectPage?: (ids: string[], selected: boolean) => void; + onSelectAllMatchingPages?: () => void; + onSort?: (columnId: string, direction: 'asc' | 'desc') => void; + onPageChange?: (page: number) => void; +}) { + const pageIds = rows.map((row) => row.id); + const rowIsSelected = (id: string) => isRowSelected?.(id) ?? selectedIds.has(id); + const allSelected = pageIds.length > 0 && pageIds.every(rowIsSelected); + const pageCount = Math.max(1, Math.ceil(total / pageSize)); + return ( +
+ {selectable && total > rows.length && allSelected && !allMatchingSelected && onSelectAllMatchingPages ? ( +
+ All {rows.length} records on this page are selected.{' '} + +
+ ) : null} +
+ + + + {selectable ? ( + + ) : null} + {columns.map((column) => ( + + ))} + + + + {loading ? ( + + ) : error ? ( + + ) : rows.length === 0 ? ( + + ) : rows.map((row) => ( + + {selectable ? ( + + ) : null} + {columns.map((column) => )} + + ))} + +
+ onSelectPage?.(pageIds, event.target.checked)} /> + + {column.sortable && onSort ? ( + + ) : column.label} +
Loading records…
{error}
{emptyMessage}
+ onSelect?.(row.id, event.target.checked)} /> + {column.render(row)}
+
+
+ {total.toLocaleString()} records · Page {page} of {pageCount} +
+ onPageChange?.(page - 1)}>Previous + = pageCount || loading} onClick={() => onPageChange?.(page + 1)}>Next +
+
+
+ ); +} + +export function FilterBar({ + search, + onSearch, + filters, + children, +}: { + search: string; + onSearch: (value: string) => void; + filters?: { id: string; label: string; active: boolean; onRemove: () => void }[]; + children?: ReactNode; +}) { + return ( +
+ + {filters?.filter((filter) => filter.active).map((filter) => ( + + ))} + {children} +
+ ); +} + +export function useUrlQueryParams() { + const [search, setSearch] = useState(() => window.location.search); + useEffect(() => { + const onPopState = () => setSearch(window.location.search); + window.addEventListener('popstate', onPopState); + return () => window.removeEventListener('popstate', onPopState); + }, []); + const params = useMemo(() => new URLSearchParams(search), [search]); + return { + params, + setParam: (key: string, value: string | null) => { + const next = new URLSearchParams(window.location.search); + if (value) next.set(key, value); + else next.delete(key); + const query = next.toString(); + window.history.replaceState(null, '', `${window.location.pathname}${query ? `?${query}` : ''}`); + setSearch(window.location.search); + }, + }; +} + +export function DetailDrawer({ title, onClose, children }: { title: string; onClose: () => void; children: ReactNode }) { + return ( +
{ + if (event.target === event.currentTarget) onClose(); + }}> + +
+ ); +} + +export function StatusBadge({ status }: { status: string }) { + const tone = /active|approved|complete|enabled/i.test(status) + ? 'bg-ok-soft text-ok' : /pending|review|processing/i.test(status) + ? 'bg-warn-soft text-warn' : /blocked|denied|failed|disabled/i.test(status) + ? 'bg-danger-soft text-danger' : 'bg-surface-2 text-text-2'; + return {status}; +} + +export function KpiCard({ label, value, detail }: { label: string; value: ReactNode; detail?: string }) { + return

{label}

{value}

{detail ?

{detail}

: null}
; +} + +export function ReasonConfirmDialog({ + title, description, reasonRequired = false, onConfirm, onClose, confirmLabel, +}: { + title: string; description: string; reasonRequired?: boolean; confirmLabel: string; + onConfirm: (reason: string) => void; onClose: () => void; +}) { + const [reason, setReason] = useState(''); + return ( + onConfirm(reason.trim())}> +