@object-ui/app-shell 4.2.1 → 4.4.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,130 @@
1
1
  # @object-ui/app-shell — Changelog
2
2
 
3
+ ## 4.4.0
4
+
5
+ ### Patch Changes
6
+
7
+ - 501ce20: fix(detail): hide system/tenant fields from auto-generated record detail
8
+
9
+ The auto-generated detail section (used when an object has no explicit form
10
+ sections) was leading every record page with `organization_id` (rendered as
11
+ "ORGANIZATION: Admin's Workspace") — pure tenancy metadata with no business
12
+ value. Extended the existing audit-field filter to also drop
13
+ `organization_id`, `tenant_id`, `is_deleted`, and `deleted_at`. Objects that
14
+ intentionally surface tenant info can still do so via explicit
15
+ `views.form.sections`.
16
+
17
+ - 63eb66d: fix(detail): expand lookup fields so subtitle + lookup cells show display names
18
+
19
+ The record-page fetch in `RecordDetailView` (the page-mode path) now
20
+ requests `$expand` for every lookup/master_detail field on the object,
21
+ mirroring the behaviour the legacy `DetailView` already had. Combined
22
+ with two small downstream fixes — `PageHeader` subtitle interpolation
23
+ now extracts `name/label` from expanded reference objects instead of
24
+ rendering `[object Object]`, and `LookupCellRenderer` now short-circuits
25
+ to `pickRecordDisplayName` when the value is already a nested record —
26
+ all `record:*` renderers and the page header subtitle (`Owned by
27
+ {account}`) now display the related record's name rather than the raw
28
+ foreign-key id.
29
+
30
+ - 2bd45af: feat(shell): main becomes the scroll container; record tabs are sticky
31
+ - `AppShell`'s SidebarProvider wrapper is now constrained to viewport
32
+ height (`h-svh overflow-hidden`) instead of expanding with content via
33
+ the default `min-h-svh`. This makes the inner `<main>` (which is
34
+ `overflow-auto`) the actual scroll container instead of the window.
35
+ - `RecordDetailView` page-mode container drops the redundant
36
+ `h-full overflow-auto` (avoids nested scrollers; main owns scroll now).
37
+ - `page:tabs` (horizontal) gets `sticky top-0 z-20` with a translucent
38
+ backdrop so the tab strip stays visible while users scroll through
39
+ long record pages — the Salesforce Lightning behaviour our schemas
40
+ were already implying.
41
+
42
+ - e33d575: Support dotted paths (e.g. `{account.name}`) in object `titleFormat`. When a
43
+ placeholder resolves to an expanded reference object, automatically extract
44
+ its `name`/`label`/`display_name`/`title` so detail page titles render the
45
+ related record's display name instead of falling through to the object label.
46
+ - Updated dependencies [63eb66d]
47
+ - Updated dependencies [67dabe1]
48
+ - Updated dependencies [ef0e30d]
49
+ - Updated dependencies [2bd45af]
50
+ - @object-ui/layout@4.4.0
51
+ - @object-ui/fields@4.4.0
52
+ - @object-ui/components@4.4.0
53
+ - @object-ui/types@4.4.0
54
+ - @object-ui/core@4.4.0
55
+ - @object-ui/i18n@4.4.0
56
+ - @object-ui/react@4.4.0
57
+ - @object-ui/data-objectstack@4.4.0
58
+ - @object-ui/auth@4.4.0
59
+ - @object-ui/permissions@4.4.0
60
+ - @object-ui/collaboration@4.4.0
61
+
62
+ ## 4.3.1
63
+
64
+ ### Patch Changes
65
+
66
+ - 9167935: fix
67
+ - 52af5cf: fix
68
+ - Updated dependencies [5f4ac6e]
69
+ - Updated dependencies [6b683c8]
70
+ - Updated dependencies [0d8eb98]
71
+ - @object-ui/i18n@4.3.1
72
+ - @object-ui/components@4.3.1
73
+ - @object-ui/layout@4.3.1
74
+ - @object-ui/fields@4.3.1
75
+ - @object-ui/react@4.3.1
76
+ - @object-ui/types@4.3.1
77
+ - @object-ui/core@4.3.1
78
+ - @object-ui/data-objectstack@4.3.1
79
+ - @object-ui/auth@4.3.1
80
+ - @object-ui/permissions@4.3.1
81
+ - @object-ui/collaboration@4.3.1
82
+
83
+ ## 4.3.0
84
+
85
+ ### Patch Changes
86
+
87
+ - 079c3b2: feat(plugin-report): per-block field resolution for joined reports
88
+
89
+ Joined report blocks can override `objectName` to query a different
90
+ object than the container, but the editor was always offering the
91
+ container's fields — wrong field names, wrong types, broken granularity
92
+ and chart-axis filtering.
93
+
94
+ `ReportConfigPanel` now accepts an optional `getFieldsForObject`
95
+ resolver. `JoinedBlocksEditor` uses it to source fields for each
96
+ block based on `block.objectName ?? containerObjectName`, falling
97
+ back to the static `availableFields` when the resolver returns
98
+ `undefined` (unknown object).
99
+
100
+ `ReportView` wires the resolver against the app's loaded `objects`
101
+ list and reuses the same parsing path internally to derive its
102
+ top-level `availableFields`, removing the duplicated schema lookup.
103
+
104
+ 5 new RTL tests verify the resolver wiring, fallback behaviour,
105
+ add-block flow, and inline duplicate-name validation (111 plugin-report
106
+ tests green).
107
+
108
+ - 154a36c: fix
109
+ - fed4897: fix
110
+ - Updated dependencies [f196cf4]
111
+ - Updated dependencies [ee1cc96]
112
+ - Updated dependencies [0b032be]
113
+ - Updated dependencies [115d36a]
114
+ - Updated dependencies [4e7bc1b]
115
+ - Updated dependencies [8442c05]
116
+ - @object-ui/i18n@4.3.0
117
+ - @object-ui/components@4.3.0
118
+ - @object-ui/fields@4.3.0
119
+ - @object-ui/react@4.3.0
120
+ - @object-ui/layout@4.3.0
121
+ - @object-ui/types@4.3.0
122
+ - @object-ui/core@4.3.0
123
+ - @object-ui/data-objectstack@4.3.0
124
+ - @object-ui/auth@4.3.0
125
+ - @object-ui/permissions@4.3.0
126
+ - @object-ui/collaboration@4.3.0
127
+
3
128
  ## 4.2.1
4
129
 
5
130
  ### Patch Changes
@@ -0,0 +1,10 @@
1
+ export interface ManagedByBadgeProps {
2
+ /** The `managedBy` flag from the object schema. */
3
+ managedBy?: string;
4
+ /** Optional override for the human-readable system name shown in the tooltip. */
5
+ label?: string;
6
+ /** Optional extra classes. */
7
+ className?: string;
8
+ }
9
+ export declare function ManagedByBadge({ managedBy, label, className }: ManagedByBadgeProps): import("react/jsx-runtime").JSX.Element | null;
10
+ export default ManagedByBadge;
@@ -0,0 +1,44 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ShieldAlert, Settings2, Lock, Archive } from 'lucide-react';
3
+ import { Badge, Tooltip, TooltipTrigger, TooltipContent, TooltipProvider, cn, } from '@object-ui/components';
4
+ const VARIANTS = {
5
+ config: {
6
+ icon: Settings2,
7
+ short: 'Admin config',
8
+ title: 'Administrator configuration',
9
+ body: () => 'These rows define how the platform behaves at runtime. Author them here; the runtime data they produce lives in a separate table.',
10
+ tone: 'border-sky-300/60 bg-sky-50 text-sky-900 hover:bg-sky-100 dark:border-sky-500/40 dark:bg-sky-950/40 dark:text-sky-100',
11
+ },
12
+ system: {
13
+ icon: Lock,
14
+ short: 'System-managed',
15
+ title: 'Managed by the platform',
16
+ body: () => 'Rows here are created automatically when actions run on the source record. The list below is a read-only monitoring surface — row-level actions (Approve, Recall, Resend, …) live on each row.',
17
+ tone: 'border-slate-300/60 bg-slate-50 text-slate-900 hover:bg-slate-100 dark:border-slate-500/40 dark:bg-slate-950/40 dark:text-slate-100',
18
+ },
19
+ 'append-only': {
20
+ icon: Archive,
21
+ short: 'Read-only · Audit log',
22
+ title: 'Read-only historical record',
23
+ body: () => "Immutable audit log. Rows cannot be created, edited, or deleted from the UI — they're written by the platform when events occur. Use Export to download for compliance review.",
24
+ tone: 'border-zinc-300/60 bg-zinc-50 text-zinc-900 hover:bg-zinc-100 dark:border-zinc-500/40 dark:bg-zinc-950/40 dark:text-zinc-100',
25
+ },
26
+ 'better-auth': {
27
+ icon: ShieldAlert,
28
+ short: 'Identity',
29
+ title: 'Managed by the identity provider',
30
+ body: (display) => `This object's schema is owned by ${display}. Direct edits bypass password hashing, session validation, two-factor checks, and audit hooks. Use the dedicated identity workflows instead (Invite User, Reset Password, Revoke Session, Rotate Key, …).`,
31
+ tone: 'border-amber-300/60 bg-amber-50 text-amber-900 hover:bg-amber-100 dark:border-amber-500/40 dark:bg-amber-950/40 dark:text-amber-100',
32
+ },
33
+ };
34
+ export function ManagedByBadge({ managedBy, label, className }) {
35
+ if (!managedBy || managedBy === 'platform')
36
+ return null;
37
+ const variant = VARIANTS[managedBy];
38
+ if (!variant)
39
+ return null;
40
+ const Icon = variant.icon;
41
+ const display = label ?? 'better-auth';
42
+ return (_jsx(TooltipProvider, { delayDuration: 200, children: _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsxs(Badge, { variant: "outline", "data-testid": "managed-by-badge", "data-bucket": managedBy, className: cn('gap-1 font-normal text-[11px] leading-none py-0.5 px-1.5 cursor-help', variant.tone, className), children: [_jsx(Icon, { className: "h-3 w-3", "aria-hidden": "true" }), _jsx("span", { children: variant.short })] }) }), _jsxs(TooltipContent, { side: "bottom", align: "start", className: "max-w-xs text-xs leading-relaxed", children: [_jsx("p", { className: "font-semibold mb-1", children: variant.title }), _jsx("p", { className: "text-muted-foreground", children: variant.body(display) })] })] }) }));
43
+ }
44
+ export default ManagedByBadge;
@@ -297,8 +297,8 @@ export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
297
297
  showSubmit: true,
298
298
  showCancel: true,
299
299
  submitText: editingRecord
300
- ? t('form.update', { defaultValue: t('common.save') || '保存' })
301
- : t('form.create', { defaultValue: t('common.create') || '创建' }),
300
+ ? t('form.update', { defaultValue: t('common.save', { defaultValue: 'Save' }) })
301
+ : t('form.create', { defaultValue: t('common.create', { defaultValue: 'Create' }) }),
302
302
  cancelText: t('common.cancel'),
303
303
  }, dataSource: dataSource }, editingRecord?.id || 'new'))] })] }));
304
304
  }
@@ -21,7 +21,6 @@ import { useMetadata } from '../../providers/MetadataProvider';
21
21
  import { useRecentItems } from '../../hooks/useRecentItems';
22
22
  import { useFavorites } from '../../hooks/useFavorites';
23
23
  import { useObjectTranslation } from '@object-ui/i18n';
24
- import { QuickActions } from './QuickActions';
25
24
  import { AppCard } from './AppCard';
26
25
  import { RecentApps } from './RecentApps';
27
26
  import { StarredApps } from './StarredApps';
@@ -52,5 +51,5 @@ export function HomePage() {
52
51
  defaultValue: 'Get started by creating your first application or configure your system settings.',
53
52
  }) }), _jsxs("div", { className: "mt-6 flex flex-col sm:flex-row items-center gap-3", children: [_jsxs(Button, { onClick: () => navigate('/create-app'), "data-testid": "create-first-app-btn", children: [_jsx(Plus, { className: "mr-2 h-4 w-4" }), t('home.createFirstApp', { defaultValue: 'Create Your First App' })] }), _jsxs(Button, { variant: "outline", onClick: () => navigate('/apps/setup'), "data-testid": "go-to-settings-btn", children: [_jsx(Settings, { className: "mr-2 h-4 w-4" }), t('home.systemSettings', { defaultValue: 'System Settings' })] })] })] }) }));
54
53
  }
55
- return (_jsxs("div", { className: "bg-background", children: [_jsxs("div", { className: "px-4 sm:px-6 pt-6 pb-4", children: [_jsx("h1", { className: "text-2xl sm:text-3xl font-bold tracking-tight", children: t('home.title', { defaultValue: 'Home' }) }), _jsx("p", { className: "text-sm text-muted-foreground mt-1", children: t('home.subtitle', { defaultValue: 'Your workspace dashboard' }) })] }), _jsxs("div", { className: "px-4 sm:px-6 py-4 space-y-8", children: [_jsx(QuickActions, {}), starredApps.length > 0 && (_jsx(StarredApps, { items: starredApps })), recentApps.length > 0 && (_jsx(RecentApps, { items: recentApps })), _jsxs("section", { children: [_jsx("h2", { className: "text-2xl font-semibold tracking-tight mb-4", children: t('home.allApps', { defaultValue: 'All Applications' }) }), _jsx("div", { className: "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4", children: activeApps.map((app) => (_jsx(AppCard, { app: app, onClick: () => navigate(`/apps/${app.name}`), isFavorite: favorites.some(f => f.id === `app:${app.name}`) }, app.name))) })] })] })] }));
54
+ return (_jsxs("div", { className: "bg-background", children: [_jsxs("div", { className: "px-4 sm:px-6 pt-6 pb-4", children: [_jsx("h1", { className: "text-2xl sm:text-3xl font-bold tracking-tight", children: t('home.title', { defaultValue: 'Home' }) }), _jsx("p", { className: "text-sm text-muted-foreground mt-1", children: t('home.subtitle', { defaultValue: 'Your workspace dashboard' }) })] }), _jsxs("div", { className: "px-4 sm:px-6 py-4 space-y-8", children: [starredApps.length > 0 && (_jsx(StarredApps, { items: starredApps })), recentApps.length > 0 && (_jsx(RecentApps, { items: recentApps })), _jsxs("section", { children: [_jsx("h2", { className: "text-2xl font-semibold tracking-tight mb-4", children: t('home.allApps', { defaultValue: 'All Applications' }) }), _jsx("div", { className: "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4", children: activeApps.map((app) => (_jsx(AppCard, { app: app, onClick: () => navigate(`/apps/${app.name}`), isFavorite: favorites.some(f => f.id === `app:${app.name}`) }, app.name))) })] })] })] }));
56
55
  }
@@ -7,7 +7,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
7
7
  * organization and navigates to `/home`), or create a new organization.
8
8
  * @module
9
9
  */
10
- import { useMemo, useState } from 'react';
10
+ import { useEffect, useMemo, useRef, useState } from 'react';
11
11
  import { Avatar, AvatarImage, AvatarFallback, Button, Input, Empty, EmptyTitle, EmptyDescription, } from '@object-ui/components';
12
12
  import { Plus, Search, Loader2 } from 'lucide-react';
13
13
  import { useAuth } from '@object-ui/auth';
@@ -55,7 +55,33 @@ export function OrganizationsPage() {
55
55
  setIsCreateOpen(false);
56
56
  handleSelect(org);
57
57
  };
58
- if (isOrganizationsLoading) {
58
+ /**
59
+ * UX P0-1: auto-skip the picker when the user belongs to exactly one
60
+ * organization. The picker is friction for >90% of users (single-org
61
+ * tenants) and only useful when there's an actual choice. We still mount
62
+ * the page (so direct navigation to `/organizations` works) but redirect
63
+ * once orgs have finished loading.
64
+ *
65
+ * Guarded by a ref so we only auto-redirect once per mount — if the user
66
+ * later creates a second org and returns here, they'll see the picker.
67
+ */
68
+ const autoSelectedRef = useRef(false);
69
+ useEffect(() => {
70
+ if (autoSelectedRef.current)
71
+ return;
72
+ if (isOrganizationsLoading)
73
+ return;
74
+ if (orgList.length !== 1)
75
+ return;
76
+ autoSelectedRef.current = true;
77
+ void handleSelect(orgList[0]);
78
+ // eslint-disable-next-line react-hooks/exhaustive-deps
79
+ }, [isOrganizationsLoading, orgList.length]);
80
+ // Show a spinner while we're either still loading, or about to auto-redirect
81
+ // because there's only one org. This prevents the picker from briefly
82
+ // flashing on screen for single-org users.
83
+ const willAutoSelect = !isOrganizationsLoading && orgList.length === 1;
84
+ if (isOrganizationsLoading || willAutoSelect) {
59
85
  return (_jsx("div", { className: "flex flex-1 items-center justify-center py-20", children: _jsx(Loader2, { className: "h-5 w-5 animate-spin text-muted-foreground" }) }));
60
86
  }
61
87
  return (_jsxs("div", { className: "mx-auto w-full max-w-5xl px-4 sm:px-6 py-8 sm:py-12", children: [_jsxs("div", { className: "mb-8", children: [_jsx("h1", { className: "text-2xl sm:text-3xl font-bold tracking-tight", children: t('organizations.heading', { defaultValue: 'Your Organizations' }) }), _jsx("p", { className: "text-sm text-muted-foreground mt-1", children: t('organizations.subtitle', {
@@ -4,4 +4,5 @@ export { useNavPins } from './useNavPins';
4
4
  export { useNavigationSync, NavigationSyncEffect, generateNavId, addNavigationItem, removeNavigationItems, renameNavigationItems, navigationEqual, type UseNavigationSyncReturn, } from './useNavigationSync';
5
5
  export { useObjectActions } from './useObjectActions';
6
6
  export { useRecentItems, type RecentItem } from './useRecentItems';
7
+ export { useRecordApprovals, type ApprovalProcessLite, type ApprovalRequestLite } from './useRecordApprovals';
7
8
  export { useResponsiveSidebar } from './useResponsiveSidebar';
@@ -4,4 +4,5 @@ export { useNavPins } from './useNavPins';
4
4
  export { useNavigationSync, NavigationSyncEffect, generateNavId, addNavigationItem, removeNavigationItems, renameNavigationItems, navigationEqual, } from './useNavigationSync';
5
5
  export { useObjectActions } from './useObjectActions';
6
6
  export { useRecentItems } from './useRecentItems';
7
+ export { useRecordApprovals } from './useRecordApprovals';
7
8
  export { useResponsiveSidebar } from './useResponsiveSidebar';
@@ -0,0 +1,49 @@
1
+ /**
2
+ * useRecordApprovals
3
+ *
4
+ * Resolves the approval state for a single record so the detail-view header
5
+ * can surface "Submit for Approval" / "Recall" actions and a status badge.
6
+ *
7
+ * Talks directly to the framework REST endpoints under
8
+ * `/api/v1/approvals/*`. Fails open: if the approvals plugin is not
9
+ * installed (404) or the user has no identity, returns inert state so the
10
+ * detail view continues to render normally.
11
+ */
12
+ export interface ApprovalProcessLite {
13
+ id: string;
14
+ name: string;
15
+ label?: string;
16
+ object_name: string;
17
+ active?: boolean;
18
+ }
19
+ export interface ApprovalRequestLite {
20
+ id: string;
21
+ process_name: string;
22
+ object_name: string;
23
+ record_id: string;
24
+ status: 'pending' | 'approved' | 'rejected' | 'recalled' | string;
25
+ submitter_id?: string | null;
26
+ current_step?: string | null;
27
+ pending_approvers?: string[] | null;
28
+ submitted_at?: string;
29
+ completed_at?: string | null;
30
+ }
31
+ interface UseRecordApprovalsResult {
32
+ loading: boolean;
33
+ available: boolean;
34
+ processes: ApprovalProcessLite[];
35
+ pendingRequest: ApprovalRequestLite | null;
36
+ latestRequest: ApprovalRequestLite | null;
37
+ canSubmit: boolean;
38
+ canRecall: boolean;
39
+ submit: (input?: {
40
+ processName?: string;
41
+ comment?: string;
42
+ }) => Promise<ApprovalRequestLite>;
43
+ recall: (input?: {
44
+ comment?: string;
45
+ }) => Promise<ApprovalRequestLite>;
46
+ refresh: () => Promise<void>;
47
+ }
48
+ export declare function useRecordApprovals(objectName: string | undefined, recordId: string | undefined, currentUserId?: string | null): UseRecordApprovalsResult;
49
+ export {};
@@ -0,0 +1,132 @@
1
+ /**
2
+ * useRecordApprovals
3
+ *
4
+ * Resolves the approval state for a single record so the detail-view header
5
+ * can surface "Submit for Approval" / "Recall" actions and a status badge.
6
+ *
7
+ * Talks directly to the framework REST endpoints under
8
+ * `/api/v1/approvals/*`. Fails open: if the approvals plugin is not
9
+ * installed (404) or the user has no identity, returns inert state so the
10
+ * detail view continues to render normally.
11
+ */
12
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
13
+ function apiBase() {
14
+ const url = import.meta.env?.VITE_SERVER_URL || '';
15
+ return `${String(url).replace(/\/$/, '')}/api/v1`;
16
+ }
17
+ async function fetchJson(path, init) {
18
+ const res = await fetch(`${apiBase()}${path}`, {
19
+ credentials: 'include',
20
+ headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) },
21
+ ...init,
22
+ });
23
+ let payload = null;
24
+ try {
25
+ payload = await res.json();
26
+ }
27
+ catch { /* empty */ }
28
+ if (!res.ok) {
29
+ const err = new Error(payload?.error || `HTTP ${res.status}`);
30
+ err.code = payload?.code ?? `HTTP_${res.status}`;
31
+ err.status = res.status;
32
+ throw err;
33
+ }
34
+ return payload;
35
+ }
36
+ export function useRecordApprovals(objectName, recordId, currentUserId) {
37
+ const [loading, setLoading] = useState(false);
38
+ const [available, setAvailable] = useState(true);
39
+ const [processes, setProcesses] = useState([]);
40
+ const [requests, setRequests] = useState([]);
41
+ const unavailableRef = useRef(false);
42
+ const refresh = useCallback(async () => {
43
+ if (!objectName || !recordId)
44
+ return;
45
+ if (unavailableRef.current)
46
+ return;
47
+ setLoading(true);
48
+ try {
49
+ const [procResp, reqResp] = await Promise.all([
50
+ fetchJson(`/approvals/processes?object=${encodeURIComponent(objectName)}&activeOnly=true`),
51
+ fetchJson(`/approvals/requests?object=${encodeURIComponent(objectName)}&recordId=${encodeURIComponent(recordId)}`),
52
+ ]);
53
+ setProcesses(procResp?.data ?? []);
54
+ setRequests(reqResp?.data ?? []);
55
+ setAvailable(true);
56
+ }
57
+ catch (err) {
58
+ if (err?.status === 404) {
59
+ unavailableRef.current = true;
60
+ setAvailable(false);
61
+ }
62
+ // Other errors are transient — silently keep last state.
63
+ }
64
+ finally {
65
+ setLoading(false);
66
+ }
67
+ }, [objectName, recordId]);
68
+ useEffect(() => {
69
+ if (!objectName || !recordId) {
70
+ setProcesses([]);
71
+ setRequests([]);
72
+ return;
73
+ }
74
+ refresh();
75
+ }, [objectName, recordId, refresh]);
76
+ const pendingRequest = useMemo(() => requests.find((r) => r.status === 'pending') ?? null, [requests]);
77
+ const latestRequest = useMemo(() => {
78
+ if (requests.length === 0)
79
+ return null;
80
+ const sorted = [...requests].sort((a, b) => {
81
+ const at = a.submitted_at || a.completed_at || '';
82
+ const bt = b.submitted_at || b.completed_at || '';
83
+ return bt.localeCompare(at);
84
+ });
85
+ return sorted[0] ?? null;
86
+ }, [requests]);
87
+ const canSubmit = available && processes.length > 0 && !pendingRequest;
88
+ const canRecall = !!pendingRequest && !!currentUserId
89
+ && pendingRequest.submitter_id === currentUserId;
90
+ const submit = useCallback(async (input) => {
91
+ if (!objectName || !recordId)
92
+ throw new Error('Missing object or record');
93
+ const processName = input?.processName
94
+ ?? (processes.length === 1 ? processes[0].name : undefined);
95
+ const row = await fetchJson(`/approvals/requests`, {
96
+ method: 'POST',
97
+ body: JSON.stringify({
98
+ object: objectName,
99
+ recordId,
100
+ ...(processName ? { processName } : {}),
101
+ ...(input?.comment ? { comment: input.comment } : {}),
102
+ }),
103
+ });
104
+ await refresh();
105
+ return row;
106
+ }, [objectName, recordId, processes, refresh]);
107
+ const recall = useCallback(async (input) => {
108
+ if (!pendingRequest)
109
+ throw new Error('No pending request');
110
+ const out = await fetchJson(`/approvals/requests/${encodeURIComponent(pendingRequest.id)}/recall`, {
111
+ method: 'POST',
112
+ body: JSON.stringify({
113
+ ...(currentUserId ? { actorId: currentUserId } : {}),
114
+ ...(input?.comment ? { comment: input.comment } : {}),
115
+ }),
116
+ });
117
+ await refresh();
118
+ return out.request;
119
+ }, [pendingRequest, currentUserId, refresh]);
120
+ return {
121
+ loading,
122
+ available,
123
+ processes,
124
+ pendingRequest,
125
+ latestRequest,
126
+ canSubmit,
127
+ canRecall,
128
+ submit,
129
+ recall,
130
+ refresh,
131
+ };
132
+ }
@@ -9,7 +9,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
9
9
  */
10
10
  import { useState } from 'react';
11
11
  import { Button, Badge, Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, } from '@object-ui/components';
12
- import { Bell, Plus, Pencil, Trash2, MessageSquare, Filter } from 'lucide-react';
12
+ import { Activity, Plus, Pencil, Trash2, MessageSquare, Filter } from 'lucide-react';
13
13
  import { useObjectTranslation } from '@object-ui/i18n';
14
14
  const typeConfig = {
15
15
  create: { icon: Plus, color: 'text-green-500' },
@@ -57,11 +57,11 @@ export function ActivityFeed({ activities = [], className }) {
57
57
  delete: t('layout.activityFeed.typeDelete'),
58
58
  comment: t('layout.activityFeed.typeComment'),
59
59
  };
60
- return (_jsxs(Sheet, { open: open, onOpenChange: setOpen, children: [_jsx(SheetTrigger, { asChild: true, children: _jsxs(Button, { variant: "ghost", size: "icon", className: className ?? 'h-8 w-8', "aria-label": t('layout.activityFeed.ariaLabel'), children: [_jsx(Bell, { className: "h-4 w-4" }), activities.length > 0 && (_jsx("span", { className: "absolute -top-0.5 -right-0.5 h-3.5 w-3.5 rounded-full bg-primary text-[9px] font-bold text-primary-foreground flex items-center justify-center", children: activities.length > 9 ? '9+' : activities.length }))] }) }), _jsxs(SheetContent, { side: "right", className: "w-80 sm:w-96", children: [_jsx(SheetHeader, { children: _jsxs(SheetTitle, { className: "flex items-center justify-between", children: [t('layout.activityFeed.title'), _jsxs(Button, { variant: showFilters ? 'secondary' : 'ghost', size: "sm", className: "h-7 px-2", onClick: () => setShowFilters(!showFilters), children: [_jsx(Filter, { className: "h-3.5 w-3.5 mr-1" }), t('layout.activityFeed.filter')] })] }) }), showFilters && (_jsx("div", { className: "flex flex-wrap gap-1.5 mt-3 px-1", children: Object.keys(typeConfig).map(type => {
60
+ return (_jsxs(Sheet, { open: open, onOpenChange: setOpen, children: [_jsx(SheetTrigger, { asChild: true, children: _jsxs(Button, { variant: "ghost", size: "icon", className: className ?? 'h-8 w-8', "aria-label": t('layout.activityFeed.ariaLabel'), children: [_jsx(Activity, { className: "h-4 w-4" }), activities.length > 0 && (_jsx("span", { "aria-hidden": "true", className: "absolute top-1 right-1 h-1.5 w-1.5 rounded-full bg-sky-500 ring-2 ring-background" }))] }) }), _jsxs(SheetContent, { side: "right", className: "w-80 sm:w-96", children: [_jsx(SheetHeader, { children: _jsxs(SheetTitle, { className: "flex items-center justify-between", children: [t('layout.activityFeed.title'), _jsxs(Button, { variant: showFilters ? 'secondary' : 'ghost', size: "sm", className: "h-7 px-2", onClick: () => setShowFilters(!showFilters), children: [_jsx(Filter, { className: "h-3.5 w-3.5 mr-1" }), t('layout.activityFeed.filter')] })] }) }), showFilters && (_jsx("div", { className: "flex flex-wrap gap-1.5 mt-3 px-1", children: Object.keys(typeConfig).map(type => {
61
61
  const { icon: Icon, color } = typeConfig[type];
62
62
  const active = notificationPreferences[type];
63
63
  return (_jsxs(Badge, { variant: active ? 'default' : 'outline', className: "cursor-pointer select-none gap-1", onClick: () => togglePreference(type), children: [_jsx(Icon, { className: `h-3 w-3 ${active ? '' : color}` }), typeLabels[type]] }, type));
64
- }) })), filteredActivities.length === 0 ? (_jsxs("div", { className: "flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground", children: [_jsx(Bell, { className: "h-8 w-8 opacity-40" }), _jsx("p", { className: "text-sm", children: t('layout.activityFeed.empty') })] })) : (_jsx("ul", { className: "mt-4 space-y-1 overflow-y-auto max-h-[calc(100vh-8rem)]", children: filteredActivities.map((item) => {
64
+ }) })), filteredActivities.length === 0 ? (_jsxs("div", { className: "flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground", children: [_jsx(Activity, { className: "h-8 w-8 opacity-40" }), _jsx("p", { className: "text-sm", children: t('layout.activityFeed.empty') })] })) : (_jsx("ul", { className: "mt-4 space-y-1 overflow-y-auto max-h-[calc(100vh-8rem)]", children: filteredActivities.map((item) => {
65
65
  const { icon: Icon, color } = typeConfig[item.type];
66
66
  return (_jsxs("li", { className: "flex items-start gap-3 rounded-md px-2 py-2 hover:bg-muted/50 transition-colors", children: [_jsx("span", { className: `mt-0.5 shrink-0 ${color}`, children: _jsx(Icon, { className: "h-4 w-4" }) }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("p", { className: "text-sm leading-snug", children: item.description }), _jsxs("p", { className: "mt-0.5 text-xs text-muted-foreground", children: [item.user, " \u00B7 ", formatRelativeTime(item.timestamp, t)] })] })] }, item.id));
67
67
  }) }))] })] }));
@@ -17,7 +17,7 @@
17
17
  * @module
18
18
  */
19
19
  import { type PresenceUser } from '@object-ui/collaboration';
20
- import { type ActivityItem } from './ActivityFeed';
20
+ import type { ActivityItem } from './ActivityFeed';
21
21
  import type { ConnectionState } from '@object-ui/data-objectstack';
22
22
  export type AppHeaderVariant = 'app' | 'home' | 'orgs';
23
23
  export interface AppHeaderProps {
@@ -19,14 +19,14 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
19
19
  */
20
20
  import { useLocation, useParams, Link, useNavigate } from 'react-router-dom';
21
21
  import { SidebarTrigger, Button, DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuGroup, Avatar, AvatarImage, AvatarFallback, } from '@object-ui/components';
22
- import { Search, HelpCircle, ChevronDown, Settings, LogOut, User as UserIcon, Boxes, Bell, CheckSquare, } from 'lucide-react';
22
+ import { Search, HelpCircle, ChevronDown, Settings, LogOut, User as UserIcon, Boxes, } from 'lucide-react';
23
23
  import { useState, useEffect, useCallback, useRef } from 'react';
24
24
  import { useOffline } from '@object-ui/react';
25
25
  import { PresenceAvatars } from '@object-ui/collaboration';
26
26
  import { ModeToggle } from './ModeToggle';
27
27
  import { LocaleSwitcher } from './LocaleSwitcher';
28
28
  import { ConnectionStatus } from './ConnectionStatus';
29
- import { ActivityFeed } from './ActivityFeed';
29
+ import { InboxPopover } from './InboxPopover';
30
30
  import { AppSwitcher } from './AppSwitcher';
31
31
  import { useAdapter } from '../providers/AdapterProvider';
32
32
  import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
@@ -375,18 +375,5 @@ export function AppHeader({ variant, appName, objects, connectionState, presence
375
375
  return (_jsxs("div", { className: "flex items-center justify-between w-full h-full", children: [_jsxs("div", { className: "flex items-center min-w-0 flex-1", children: [_jsx(Link, { to: "/home", className: "flex items-center justify-center h-7 w-7 shrink-0 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors", title: "ObjectStack", children: _jsx(Boxes, { className: "h-4 w-4" }) }), resolvedVariant === 'home' && (_jsx("span", { className: "hidden sm:inline ml-2 text-sm font-semibold tracking-tight", children: "ObjectStack" })), resolvedVariant === 'orgs' && (_jsxs(_Fragment, { children: [_jsx(PathSep, {}), _jsx("span", { className: "text-sm font-medium text-foreground/80 px-1.5", children: t('organizations.title', { defaultValue: 'Organizations' }) })] })), isApp && (_jsxs(_Fragment, { children: [_jsx(SidebarTrigger, { className: "md:hidden shrink-0 ml-1", "aria-label": t('common.toggleSidebar') || 'Toggle sidebar' }), activeAppName && onAppChange ? (_jsxs(_Fragment, { children: [_jsx(PathSep, {}), _jsx(AppSwitcher, { activeAppName: activeAppName, onAppChange: onAppChange })] })) : appName ? (_jsxs(_Fragment, { children: [_jsx(PathSep, {}), _jsx("span", { className: "text-sm font-medium text-foreground/80 px-1.5", children: appName })] })) : null, extraSegments.map((seg, i) => {
376
376
  const isLast = i === extraSegments.length - 1;
377
377
  return (_jsxs("span", { className: "hidden sm:flex items-center min-w-0", children: [_jsx(PathSep, {}), seg.siblings && seg.siblings.length > 1 ? (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { className: `flex items-center gap-1 rounded-md px-1.5 py-1 text-sm font-medium transition-colors outline-none hover:bg-accent hover:text-foreground ${!isLast ? 'text-foreground/60' : 'text-foreground/80'}`, children: [seg.label, _jsx(ChevronDown, { className: "h-3.5 w-3.5 text-muted-foreground" })] }), _jsxs(DropdownMenuContent, { align: "start", sideOffset: 8, className: "w-56 max-h-72 overflow-y-auto", children: [_jsx(DropdownMenuLabel, { className: "text-xs text-muted-foreground font-normal", children: "Switch Object" }), _jsx(DropdownMenuSeparator, {}), seg.siblings.map((sibling) => (_jsx(DropdownMenuItem, { asChild: true, children: _jsx(Link, { to: sibling.href, className: "w-full", children: sibling.label }) }, sibling.href)))] })] })) : seg.href ? (_jsx(Link, { to: seg.href, className: `rounded-md px-1.5 py-1 text-sm font-medium transition-colors hover:bg-accent hover:text-foreground truncate max-w-[160px] ${isLast ? 'text-foreground/80' : 'text-foreground/60'}`, children: seg.label })) : (_jsx("span", { className: `px-1.5 py-1 text-sm font-medium truncate max-w-[160px] ${isLast ? 'text-foreground/80' : 'text-foreground/60'}`, children: seg.label }))] }, i));
378
- }), _jsx("span", { className: "text-sm font-medium sm:hidden truncate min-w-0 ml-1", children: lastSegmentLabel })] }))] }), _jsxs("div", { className: "flex items-center gap-0.5 sm:gap-1 shrink-0 [&>*+*[data-topbar-group]]:ml-1 [&>[data-topbar-group]+[data-topbar-group]]:border-l [&>[data-topbar-group]+[data-topbar-group]]:border-border/60 [&>[data-topbar-group]+[data-topbar-group]]:pl-1 sm:[&>[data-topbar-group]+[data-topbar-group]]:pl-2 sm:[&>[data-topbar-group]+[data-topbar-group]]:ml-2", children: [!isOnline && (_jsxs("div", { className: "flex items-center gap-1 px-2 py-1 rounded-full bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-200 text-xs font-medium", children: [_jsx("span", { className: "h-2 w-2 rounded-full bg-yellow-500 animate-pulse" }), "Offline"] })), isApp && connectionState && _jsx(ConnectionStatus, { state: connectionState }), isApp && activeUsers.length > 0 && (_jsx("div", { className: "hidden md:flex items-center shrink-0", title: "Users currently online", children: _jsx(PresenceAvatars, { users: activeUsers, size: "sm", maxVisible: 3, showStatus: true }) })), _jsxs("div", { "data-topbar-group": true, className: "flex items-center gap-0.5 sm:gap-1 shrink-0", children: [_jsxs("button", { onClick: () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true })), className: "hidden lg:flex relative items-center gap-2 w-48 xl:w-64 h-8 px-3 text-sm rounded-md border bg-muted/50 text-muted-foreground hover:bg-muted transition-colors", children: [_jsx(Search, { className: "h-3.5 w-3.5 shrink-0" }), _jsx("span", { className: "flex-1 text-left text-xs", children: t('console.search', { defaultValue: 'Search...' }) }), _jsxs("kbd", { className: "pointer-events-none inline-flex h-5 items-center gap-0.5 rounded border bg-background px-1.5 text-[10px] font-medium text-muted-foreground", children: [_jsx("span", { className: "text-xs", children: "\u2318" }), "K"] })] }), _jsx(Button, { variant: "ghost", size: "icon", className: "lg:hidden h-8 w-8 shrink-0", onClick: () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true })), "aria-label": t('console.search', { defaultValue: 'Search...' }), children: _jsx(Search, { className: "h-4 w-4" }) })] }), _jsxs("div", { "data-topbar-group": true, className: "flex items-center gap-0.5 shrink-0", children: [_jsx("div", { className: "hidden sm:flex shrink-0", children: _jsx(ActivityFeed, { activities: activeActivities }) }), pendingApprovalsCount >= 0 && (_jsxs(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 relative shrink-0", "aria-label": t('sidebar.approvals', { defaultValue: 'Approvals' }), title: t('sidebar.approvals', { defaultValue: 'Approvals' }), onClick: () => {
379
- const app = currentAppName ?? params.appName;
380
- navigate(app ? `/apps/${app}/system/approvals` : '/apps/setup/system/approvals');
381
- }, children: [_jsx(CheckSquare, { className: "h-4 w-4" }), pendingApprovalsCount > 0 && (_jsx("span", { className: "absolute -top-0.5 -right-0.5 h-4 min-w-[16px] rounded-full bg-amber-500 text-[10px] leading-4 text-white text-center px-1", children: pendingApprovalsCount > 9 ? '9+' : pendingApprovalsCount }))] })), _jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 relative shrink-0", "aria-label": t('sidebar.notifications', { defaultValue: 'Notifications' }), children: [_jsx(Bell, { className: "h-4 w-4" }), unreadCount > 0 && (_jsx("span", { className: "absolute -top-0.5 -right-0.5 h-4 min-w-[16px] rounded-full bg-red-500 text-[10px] leading-4 text-white text-center px-1", children: unreadCount > 9 ? '9+' : unreadCount }))] }) }), _jsxs(DropdownMenuContent, { align: "end", className: "w-80 max-h-96 overflow-auto", children: [_jsxs(DropdownMenuLabel, { className: "flex items-center justify-between", children: [_jsx("span", { children: t('sidebar.notifications', { defaultValue: 'Notifications' }) }), unreadCount > 0 && (_jsx("button", { type: "button", onClick: (e) => { e.preventDefault(); markAllRead(); }, className: "text-xs text-muted-foreground hover:text-foreground", children: t('notifications.markAllRead', { defaultValue: 'Mark all read' }) }))] }), _jsx(DropdownMenuSeparator, {}), notifications.length === 0 ? (_jsx("div", { className: "px-3 py-6 text-sm text-muted-foreground text-center", children: t('notifications.empty', { defaultValue: 'No notifications' }) })) : (notifications.map((n) => (_jsxs(DropdownMenuItem, { className: `flex flex-col items-start gap-1 ${n.is_read ? '' : 'bg-accent/40'}`, onSelect: () => {
382
- markNotificationRead(n.id);
383
- if (n.source_object && n.source_id) {
384
- // Navigate to the related record; relative to /apps/<currentApp> if any.
385
- const app = currentAppName ?? params.appName;
386
- const target = app
387
- ? `/apps/${app}/${n.source_object}/${n.source_id}`
388
- : `/objects/${n.source_object}/${n.source_id}`;
389
- navigate(target);
390
- }
391
- }, children: [_jsx("div", { className: "text-sm font-medium leading-tight", children: n.title }), n.body && (_jsx("div", { className: "text-xs text-muted-foreground line-clamp-2", children: n.body })), n.created_at && (_jsx("div", { className: "text-[10px] text-muted-foreground", children: new Date(n.created_at).toLocaleString() }))] }, n.id))))] })] }), _jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 hidden md:flex shrink-0", asChild: true, "aria-label": t('sidebar.helpTooltip', { defaultValue: 'Help & Documentation' }), children: _jsx("a", { href: "https://docs.objectstack.ai", target: "_blank", rel: "noopener noreferrer", children: _jsx(HelpCircle, { className: "h-4 w-4" }) }) })] }), _jsxs("div", { "data-topbar-group": true, className: "flex items-center gap-0.5 shrink-0", children: [_jsx("div", { className: "hidden sm:flex shrink-0", children: _jsx(ModeToggle, {}) }), _jsx("div", { className: "hidden sm:flex shrink-0", children: _jsx(LocaleSwitcher, {}) }), _jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 shrink-0 rounded-full", children: _jsxs(Avatar, { className: "h-7 w-7 rounded-full", children: [_jsx(AvatarImage, { src: user?.image, alt: user?.name ?? 'User' }), _jsx(AvatarFallback, { className: "rounded-full bg-primary text-primary-foreground text-xs", children: getUserInitials(user) })] }) }) }), _jsxs(DropdownMenuContent, { align: "end", className: "min-w-64 rounded-lg", sideOffset: 4, children: [_jsx(DropdownMenuLabel, { className: "p-0 font-normal", children: _jsxs("div", { className: "flex items-center gap-2 px-2 py-2", children: [_jsxs(Avatar, { className: "h-8 w-8 rounded-lg", children: [_jsx(AvatarImage, { src: user?.image, alt: user?.name ?? 'User' }), _jsx(AvatarFallback, { className: "rounded-lg bg-primary text-primary-foreground", children: getUserInitials(user) })] }), _jsxs("div", { className: "grid flex-1 text-left text-sm leading-tight", children: [_jsx("span", { className: "truncate font-semibold", children: user?.name ?? 'User' }), _jsx("span", { className: "truncate text-xs text-muted-foreground", children: user?.email ?? '' })] })] }) }), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuGroup, { children: [hasOrgSection && (_jsxs(DropdownMenuItem, { onClick: () => navigate('/organizations'), className: "cursor-pointer", children: [_jsx(Boxes, { className: "mr-2 h-4 w-4" }), t('organizations.mine', { defaultValue: 'My Organizations' })] })), _jsxs(DropdownMenuItem, { onClick: () => navigate('/apps/setup/system/profile'), children: [_jsx(UserIcon, { className: "mr-2 h-4 w-4" }), t('user.profile', { defaultValue: 'Profile' })] }), _jsxs(DropdownMenuItem, { onClick: () => navigate('/apps/setup'), children: [_jsx(Settings, { className: "mr-2 h-4 w-4" }), t('sidebar.settings', { defaultValue: 'Settings' })] })] }), isAuthEnabled && (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { className: "text-destructive focus:text-destructive", onClick: () => signOut(), children: [_jsx(LogOut, { className: "mr-2 h-4 w-4" }), t('user.logout', { defaultValue: 'Log out' })] })] }))] })] })] })] })] }));
378
+ }), _jsx("span", { className: "text-sm font-medium sm:hidden truncate min-w-0 ml-1", children: lastSegmentLabel })] }))] }), _jsxs("div", { className: "flex items-center gap-0.5 sm:gap-1 shrink-0 [&>*+*[data-topbar-group]]:ml-1 [&>[data-topbar-group]+[data-topbar-group]]:border-l [&>[data-topbar-group]+[data-topbar-group]]:border-border/60 [&>[data-topbar-group]+[data-topbar-group]]:pl-1 sm:[&>[data-topbar-group]+[data-topbar-group]]:pl-2 sm:[&>[data-topbar-group]+[data-topbar-group]]:ml-2", children: [!isOnline && (_jsxs("div", { className: "flex items-center gap-1 px-2 py-1 rounded-full bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-200 text-xs font-medium", children: [_jsx("span", { className: "h-2 w-2 rounded-full bg-yellow-500 animate-pulse" }), "Offline"] })), isApp && connectionState && _jsx(ConnectionStatus, { state: connectionState }), isApp && activeUsers.length > 0 && (_jsx("div", { className: "hidden md:flex items-center shrink-0", title: "Users currently online", children: _jsx(PresenceAvatars, { users: activeUsers, size: "sm", maxVisible: 3, showStatus: true }) })), _jsxs("div", { "data-topbar-group": true, className: "flex items-center gap-0.5 sm:gap-1 shrink-0", children: [_jsxs("button", { onClick: () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true })), className: "hidden lg:flex relative items-center gap-2 w-48 xl:w-64 h-8 px-3 text-sm rounded-md border bg-muted/50 text-muted-foreground hover:bg-muted transition-colors", children: [_jsx(Search, { className: "h-3.5 w-3.5 shrink-0" }), _jsx("span", { className: "flex-1 text-left text-xs", children: t('console.search', { defaultValue: 'Search...' }) }), _jsxs("kbd", { className: "pointer-events-none inline-flex h-5 items-center gap-0.5 rounded border bg-background px-1.5 text-[10px] font-medium text-muted-foreground", children: [_jsx("span", { className: "text-xs", children: "\u2318" }), "K"] })] }), _jsx(Button, { variant: "ghost", size: "icon", className: "lg:hidden h-8 w-8 shrink-0", onClick: () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true })), "aria-label": t('console.search', { defaultValue: 'Search...' }), children: _jsx(Search, { className: "h-4 w-4" }) })] }), _jsxs("div", { "data-topbar-group": true, className: "flex items-center gap-0.5 shrink-0", children: [_jsx(InboxPopover, { notifications: notifications, unreadCount: unreadCount, pendingApprovalsCount: pendingApprovalsCount, activities: activeActivities, onMarkAllRead: markAllRead, onMarkRead: markNotificationRead }), _jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 hidden md:flex shrink-0", asChild: true, "aria-label": t('sidebar.helpTooltip', { defaultValue: 'Help & Documentation' }), children: _jsx("a", { href: "https://docs.objectstack.ai", target: "_blank", rel: "noopener noreferrer", children: _jsx(HelpCircle, { className: "h-4 w-4" }) }) })] }), _jsxs("div", { "data-topbar-group": true, className: "flex items-center gap-0.5 shrink-0", children: [" ", _jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 shrink-0 rounded-full", children: _jsxs(Avatar, { className: "h-7 w-7 rounded-full", children: [_jsx(AvatarImage, { src: user?.image, alt: user?.name ?? 'User' }), _jsx(AvatarFallback, { className: "rounded-full bg-primary text-primary-foreground text-xs", children: getUserInitials(user) })] }) }) }), _jsxs(DropdownMenuContent, { align: "end", className: "min-w-64 rounded-lg", sideOffset: 4, children: [_jsx(DropdownMenuLabel, { className: "p-0 font-normal", children: _jsxs("div", { className: "flex items-center gap-2 px-2 py-2", children: [_jsxs(Avatar, { className: "h-8 w-8 rounded-lg", children: [_jsx(AvatarImage, { src: user?.image, alt: user?.name ?? 'User' }), _jsx(AvatarFallback, { className: "rounded-lg bg-primary text-primary-foreground", children: getUserInitials(user) })] }), _jsxs("div", { className: "grid flex-1 text-left text-sm leading-tight", children: [_jsx("span", { className: "truncate font-semibold", children: user?.name ?? 'User' }), _jsx("span", { className: "truncate text-xs text-muted-foreground", children: user?.email ?? '' })] })] }) }), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuGroup, { children: [hasOrgSection && (_jsxs(DropdownMenuItem, { onClick: () => navigate('/organizations'), className: "cursor-pointer", children: [_jsx(Boxes, { className: "mr-2 h-4 w-4" }), t('organizations.mine', { defaultValue: 'My Organizations' })] })), _jsxs(DropdownMenuItem, { onClick: () => navigate('/apps/setup/system/profile'), children: [_jsx(UserIcon, { className: "mr-2 h-4 w-4" }), t('user.profile', { defaultValue: 'Profile' })] }), _jsxs(DropdownMenuItem, { onClick: () => navigate('/apps/setup'), children: [_jsx(Settings, { className: "mr-2 h-4 w-4" }), t('sidebar.settings', { defaultValue: 'Settings' })] })] }), _jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuLabel, { className: "text-[11px] font-normal text-muted-foreground uppercase tracking-wide px-2", children: t('user.preferences', { defaultValue: 'Preferences' }) }), _jsxs("div", { className: "flex items-center justify-between px-2 py-1.5 text-sm", children: [_jsx("span", { className: "text-foreground/80", children: t('user.theme', { defaultValue: 'Theme' }) }), _jsx(ModeToggle, {})] }), _jsxs("div", { className: "flex items-center justify-between px-2 py-1.5 text-sm", children: [_jsx("span", { className: "text-foreground/80", children: t('user.language', { defaultValue: 'Language' }) }), _jsx(LocaleSwitcher, {})] }), isAuthEnabled && (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { className: "text-destructive focus:text-destructive", onClick: () => signOut(), children: [_jsx(LogOut, { className: "mr-2 h-4 w-4" }), t('user.logout', { defaultValue: 'Log out' })] })] }))] })] })] })] })] }));
392
379
  }
@@ -39,7 +39,7 @@ export function ConsoleLayout({ children, activeAppName, activeApp, onAppChange,
39
39
  setContext('app');
40
40
  setCurrentAppName(activeAppName);
41
41
  }, [setContext, setCurrentAppName, activeAppName]);
42
- return (_jsxs(AppShell, { sidebar: _jsx(UnifiedSidebar, { activeAppName: activeAppName, onAppChange: onAppChange }), navbar: _jsx(AppHeader, { variant: "app", appName: appLabel, objects: objects, connectionState: connectionState, activeAppName: activeAppName, onAppChange: onAppChange }), className: "!p-0 overflow-hidden bg-muted/5", branding: activeApp?.branding
42
+ return (_jsxs(AppShell, { sidebar: _jsx(UnifiedSidebar, { activeAppName: activeAppName, onAppChange: onAppChange }), navbar: _jsx(AppHeader, { variant: "app", appName: appLabel, objects: objects, connectionState: connectionState, activeAppName: activeAppName, onAppChange: onAppChange }), className: "!p-0 overflow-y-auto overflow-x-hidden bg-muted/5", branding: activeApp?.branding
43
43
  ? {
44
44
  primaryColor: activeApp.branding.primaryColor,
45
45
  accentColor: activeApp.branding.accentColor,
@@ -0,0 +1,36 @@
1
+ /**
2
+ * InboxPopover
3
+ *
4
+ * UX P0-2: a single "inbox" surface that bundles the three things that
5
+ * used to occupy separate top-bar buttons:
6
+ * - Notifications (mentions, assignments, system alerts)
7
+ * - Approvals (pending approval requests for the user)
8
+ * - Activity (recent activity feed across the org)
9
+ *
10
+ * Rendered as a single bell button + popover with a tabbed body. The badge
11
+ * shows the combined unread count (notifications + approvals) so users
12
+ * still see at-a-glance pressure.
13
+ *
14
+ * @module
15
+ */
16
+ import type { ActivityItem } from './ActivityFeed';
17
+ export interface InboxNotification {
18
+ id: string;
19
+ type: string;
20
+ title: string;
21
+ body?: string | null;
22
+ source_object?: string | null;
23
+ source_id?: string | null;
24
+ actor_name?: string | null;
25
+ is_read?: boolean;
26
+ created_at?: string;
27
+ }
28
+ export interface InboxPopoverProps {
29
+ notifications: InboxNotification[];
30
+ unreadCount: number;
31
+ pendingApprovalsCount: number;
32
+ activities: ActivityItem[];
33
+ onMarkAllRead: () => void;
34
+ onMarkRead: (id: string) => void;
35
+ }
36
+ export declare function InboxPopover({ notifications, unreadCount, pendingApprovalsCount, activities, onMarkAllRead, onMarkRead, }: InboxPopoverProps): import("react/jsx-runtime").JSX.Element;