@object-ui/app-shell 4.0.11 → 4.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,91 @@
1
1
  # @object-ui/app-shell — Changelog
2
2
 
3
+ ## 4.2.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 47c27c7: fix
8
+ - @object-ui/types@4.2.1
9
+ - @object-ui/core@4.2.1
10
+ - @object-ui/i18n@4.2.1
11
+ - @object-ui/react@4.2.1
12
+ - @object-ui/components@4.2.1
13
+ - @object-ui/fields@4.2.1
14
+ - @object-ui/layout@4.2.1
15
+ - @object-ui/data-objectstack@4.2.1
16
+ - @object-ui/auth@4.2.1
17
+ - @object-ui/permissions@4.2.1
18
+ - @object-ui/collaboration@4.2.1
19
+
20
+ ## 4.2.0
21
+
22
+ ### Patch Changes
23
+
24
+ - 786de60: ReportView no longer caps the report content at `max-w-5xl` (1024px). Reports now use the full available content width, matching DashboardView behavior. Matrix and grid reports in particular benefit from the additional horizontal real estate.
25
+ - Updated dependencies [eb738bd]
26
+ - Updated dependencies [650392e]
27
+ - Updated dependencies [84b4bf1]
28
+ - @object-ui/i18n@4.2.0
29
+ - @object-ui/components@4.2.0
30
+ - @object-ui/fields@4.2.0
31
+ - @object-ui/react@4.2.0
32
+ - @object-ui/layout@4.2.0
33
+ - @object-ui/types@4.2.0
34
+ - @object-ui/core@4.2.0
35
+ - @object-ui/data-objectstack@4.2.0
36
+ - @object-ui/auth@4.2.0
37
+ - @object-ui/permissions@4.2.0
38
+ - @object-ui/collaboration@4.2.0
39
+
40
+ ## 4.1.0
41
+
42
+ ### Patch Changes
43
+
44
+ - b4ce9e2: Fix summary reports: render chart + KPIs, correct empty-table on server-aggregated data.
45
+ - `plugin-report`: `SpecReportGrid` now renders a KPI strip (per aggregating column) and a chart section above the grid for `summary` reports. KPI section auto-hides when no aggregating columns. New `buildChartData()` adapter buckets aggregated `ReportRow[]` to chart-ready data, auto-sorts pie/funnel descending, and falls back to row count when the chart `yAxis` points at a non-numeric column. When the data is server-aggregated, the grid switches columns to `[groupings, ${field}__${agg}]` so cells aren't empty against a raw-row column schema.
46
+ - `plugin-charts`: register `'column'` as an alias of `'bar'` in `ChartRenderer` / `AdvancedChartImpl` (Recharts only has `BarChart`).
47
+ - `app-shell`: `ReportView` now routes any object-backed report (matrix/joined/summary/tabular/columns/groupingsAcross) through the spec `ReportRenderer`; fully-legacy `fields`+`data` schemas still use `ReportViewer`.
48
+ - @object-ui/types@4.1.0
49
+ - @object-ui/core@4.1.0
50
+ - @object-ui/i18n@4.1.0
51
+ - @object-ui/react@4.1.0
52
+ - @object-ui/components@4.1.0
53
+ - @object-ui/fields@4.1.0
54
+ - @object-ui/layout@4.1.0
55
+ - @object-ui/data-objectstack@4.1.0
56
+ - @object-ui/auth@4.1.0
57
+ - @object-ui/permissions@4.1.0
58
+ - @object-ui/collaboration@4.1.0
59
+
60
+ ## 4.0.12
61
+
62
+ ### Patch Changes
63
+
64
+ - e468592: fix
65
+ - @object-ui/types@4.0.12
66
+ - @object-ui/core@4.0.12
67
+ - @object-ui/i18n@4.0.12
68
+ - @object-ui/react@4.0.12
69
+ - @object-ui/components@4.0.12
70
+ - @object-ui/fields@4.0.12
71
+ - @object-ui/layout@4.0.12
72
+ - @object-ui/data-objectstack@4.0.12
73
+ - @object-ui/auth@4.0.12
74
+ - @object-ui/permissions@4.0.12
75
+ - @object-ui/plugin-calendar@4.0.12
76
+ - @object-ui/plugin-charts@4.0.12
77
+ - @object-ui/plugin-chatbot@4.0.12
78
+ - @object-ui/plugin-dashboard@4.0.12
79
+ - @object-ui/plugin-designer@4.0.12
80
+ - @object-ui/plugin-detail@4.0.12
81
+ - @object-ui/plugin-form@4.0.12
82
+ - @object-ui/plugin-grid@4.0.12
83
+ - @object-ui/plugin-kanban@4.0.12
84
+ - @object-ui/plugin-list@4.0.12
85
+ - @object-ui/plugin-report@4.0.12
86
+ - @object-ui/plugin-view@4.0.12
87
+ - @object-ui/collaboration@4.0.12
88
+
3
89
  ## 4.0.11
4
90
 
5
91
  ### Patch Changes
@@ -0,0 +1,16 @@
1
+ export interface ManagedByBannerProps {
2
+ /** The `managedBy` flag from the object schema. */
3
+ managedBy?: string;
4
+ /** Optional override for the human-readable system name. */
5
+ label?: string;
6
+ /** Optional documentation link rendered as "Learn more →". */
7
+ docHref?: string;
8
+ /**
9
+ * Optional human-readable name for the source record / parent workflow
10
+ * referenced in `system`-bucket banners (e.g. "Opportunity"). When
11
+ * provided the banner reads "Use the Opportunity record's Submit for
12
+ * Approval action…" instead of the generic phrasing.
13
+ */
14
+ sourceRecordLabel?: string;
15
+ }
16
+ export declare function ManagedByBanner({ managedBy, label, docHref, sourceRecordLabel }: ManagedByBannerProps): import("react/jsx-runtime").JSX.Element | null;
@@ -0,0 +1,38 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { ShieldAlert, Info, Lock, Archive } from 'lucide-react';
3
+ const VARIANTS = {
4
+ config: {
5
+ icon: Info,
6
+ tone: 'border-sky-300/60 bg-sky-50 text-sky-900 dark:border-sky-500/40 dark:bg-sky-950/40 dark:text-sky-100',
7
+ title: 'Administrator configuration',
8
+ body: () => "These rows define how the platform behaves at runtime — they're authored here, but the runtime data they produce lives in a separate table. Changes take effect once a row is marked Active.",
9
+ },
10
+ system: {
11
+ icon: Lock,
12
+ tone: 'border-slate-300/60 bg-slate-50 text-slate-900 dark:border-slate-500/40 dark:bg-slate-950/40 dark:text-slate-100',
13
+ title: 'Managed by the platform',
14
+ body: (_display, src) => `Rows here are created and updated automatically by the platform engine. To start a new one, use the action button on the ${src ?? 'source record'} (e.g. "Submit for Approval", "Share", "Invite"). The list below is the audit / monitoring surface — actions like Approve, Recall, or Resend live on the row.`,
15
+ },
16
+ 'append-only': {
17
+ icon: Archive,
18
+ tone: 'border-zinc-300/60 bg-zinc-50 text-zinc-900 dark:border-zinc-500/40 dark:bg-zinc-950/40 dark:text-zinc-100',
19
+ title: 'Read-only historical record',
20
+ body: () => "This is an 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.",
21
+ },
22
+ 'better-auth': {
23
+ icon: ShieldAlert,
24
+ tone: 'border-amber-300/60 bg-amber-50 text-amber-900 dark:border-amber-500/40 dark:bg-amber-950/40 dark:text-amber-100',
25
+ title: 'Managed by better-auth',
26
+ body: (display) => `This object's schema is owned by ${display}. Direct edits here bypass password hashing, session validation, two-factor checks, and audit hooks — and may corrupt account state. Use the dedicated identity workflows instead (Invite User, Reset Password, Revoke Session, Rotate Key, …).`,
27
+ },
28
+ };
29
+ export function ManagedByBanner({ managedBy, label, docHref, sourceRecordLabel }) {
30
+ if (!managedBy || managedBy === 'platform')
31
+ return null;
32
+ const variant = VARIANTS[managedBy];
33
+ if (!variant)
34
+ return null;
35
+ const display = label ?? managedBy;
36
+ const Icon = variant.icon;
37
+ return (_jsxs("div", { role: "note", "data-testid": "managed-by-banner", "data-bucket": managedBy, className: `flex items-start gap-3 border-b px-4 py-2.5 text-sm ${variant.tone}`, children: [_jsx(Icon, { className: "mt-0.5 h-4 w-4 flex-none" }), _jsxs("div", { className: "flex-1", children: [_jsxs("strong", { className: "font-semibold", children: [variant.title, "."] }), ' ', variant.body(display, sourceRecordLabel), docHref && (_jsxs(_Fragment, { children: [' ', _jsx("a", { href: docHref, target: "_blank", rel: "noreferrer", className: "underline underline-offset-2 hover:opacity-80", children: "Learn more \u2192" })] }))] })] }));
38
+ }
@@ -26,5 +26,5 @@ export function HomeLayout({ children }) {
26
26
  useEffect(() => {
27
27
  setContext('home');
28
28
  }, [setContext]);
29
- return (_jsxs("div", { className: "flex min-h-svh w-full flex-col bg-background", "data-testid": "home-layout", children: [_jsx("header", { className: "sticky top-0 z-30 flex h-14 w-full shrink-0 items-center gap-2 border-b bg-background px-2 sm:px-4", children: _jsx(AppHeader, { variant: "home" }) }), _jsx("main", { className: "flex-1 min-w-0 overflow-auto", children: children }), showChatbot && (_jsx(Suspense, { fallback: null, children: _jsx(ConsoleFloatingChatbot, { appLabel: "Workspace", objects: [] }) }))] }));
29
+ return (_jsxs("div", { className: "flex min-h-svh w-full flex-col bg-background", "data-testid": "home-layout", children: [_jsx("header", { className: "sticky top-0 z-30 flex h-14 w-full shrink-0 items-center gap-2 border-b bg-background px-2 sm:px-4", children: _jsx(AppHeader, { variant: "home" }) }), _jsx("main", { className: "flex-1 min-w-0 overflow-auto pb-20 sm:pb-0", children: children }), showChatbot && (_jsx(Suspense, { fallback: null, children: _jsx(ConsoleFloatingChatbot, { appLabel: "Workspace", objects: [] }) }))] }));
30
30
  }
@@ -48,7 +48,9 @@ export function HomePage() {
48
48
  }
49
49
  // Empty state - no apps configured
50
50
  if (activeApps.length === 0) {
51
- return (_jsx("div", { className: "flex flex-1 items-center justify-center p-6", children: _jsxs(Empty, { children: [_jsx(EmptyTitle, { children: "Welcome to ObjectUI" }), _jsx(EmptyDescription, { children: "Get started by creating your first application or configure your system settings." }), _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" }), "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" }), "System Settings"] })] })] }) }));
51
+ return (_jsx("div", { className: "flex flex-1 items-center justify-center p-6", children: _jsxs(Empty, { children: [_jsx(EmptyTitle, { children: t('home.welcome', { defaultValue: 'Welcome to ObjectUI' }) }), _jsx(EmptyDescription, { children: t('home.welcomeDescription', {
52
+ defaultValue: 'Get started by creating your first application or configure your system settings.',
53
+ }) }), _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' })] })] })] }) }));
52
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
56
  }
@@ -19,7 +19,7 @@ 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, } from 'lucide-react';
22
+ import { Search, HelpCircle, ChevronDown, Settings, LogOut, User as UserIcon, Boxes, Bell, CheckSquare, } 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';
@@ -58,11 +58,17 @@ export function AppHeader({ variant, appName, objects, connectionState, presence
58
58
  const { currentAppName, recordTitle } = useNavigationContext();
59
59
  const [apiPresenceUsers, setApiPresenceUsers] = useState(null);
60
60
  const [apiActivities, setApiActivities] = useState(null);
61
+ /** M10.8: in-header notifications. Polled from sys_notification scoped to current user. */
62
+ const [notifications, setNotifications] = useState([]);
61
63
  // Once the server returns 404 for these collections we stop retrying for
62
64
  // the lifetime of the page — they're optional features and re-requesting
63
65
  // on every navigation creates console noise + wasted round trips.
64
66
  const presenceUnavailableRef = useRef(false);
65
67
  const activityUnavailableRef = useRef(false);
68
+ const notificationsUnavailableRef = useRef(false);
69
+ /** M11.C15: pending approvals count for the topbar shortcut. */
70
+ const [pendingApprovalsCount, setPendingApprovalsCount] = useState(0);
71
+ const approvalsUnavailableRef = useRef(false);
66
72
  const fetchPresenceAndActivities = useCallback(async () => {
67
73
  if (!dataSource || !isApp)
68
74
  return;
@@ -103,6 +109,172 @@ export function AppHeader({ variant, appName, objects, connectionState, presence
103
109
  catch { /* fallback below */ }
104
110
  }, [dataSource, isApp]);
105
111
  useEffect(() => { fetchPresenceAndActivities(); }, [fetchPresenceAndActivities]);
112
+ /**
113
+ * M10.8 + M11.B3: poll sys_notification for the signed-in user.
114
+ *
115
+ * - Limited to the 20 most-recent entries; unread count drives the bell badge.
116
+ * - Adaptive interval: 10s while the tab is foregrounded (was 30s) so the
117
+ * bell reflects mentions / assignments within seconds without requiring a
118
+ * server-push transport.
119
+ * - Immediate refetch on `visibilitychange` when the user returns to the
120
+ * tab, so a backgrounded tab catches up the moment it regains focus.
121
+ * - On transient errors, switches to exponential backoff (cap 2 min) and
122
+ * resets on the first successful fetch.
123
+ * - Tolerates 404 so deployments without sys_notification degrade silently.
124
+ *
125
+ * Full server-push (SSE / WebSocket) is tracked separately as a M12
126
+ * enhancement; this adaptive poll already reduces perceived latency from
127
+ * ~15s (average half of the old 30s window) to ~5s and is sufficient for
128
+ * pilots up to ~50 concurrent users.
129
+ */
130
+ useEffect(() => {
131
+ if (!dataSource || !isApp || !user?.id)
132
+ return;
133
+ if (notificationsUnavailableRef.current)
134
+ return;
135
+ let cancelled = false;
136
+ let timer = null;
137
+ const ACTIVE_INTERVAL_MS = 10000;
138
+ const HIDDEN_INTERVAL_MS = 60000;
139
+ const MAX_BACKOFF_MS = 120000;
140
+ let backoffMs = ACTIVE_INTERVAL_MS;
141
+ const isMissingResource = (err) => err?.httpStatus === 404 || err?.status === 404 || err?.code === 'object_not_found';
142
+ const fetchOnce = async () => {
143
+ try {
144
+ const res = await dataSource.find('sys_notification', {
145
+ $filter: { recipient_id: user.id },
146
+ $orderby: { created_at: 'desc' },
147
+ $top: 20,
148
+ });
149
+ if (cancelled)
150
+ return;
151
+ if (Array.isArray(res?.data))
152
+ setNotifications(res.data);
153
+ backoffMs = ACTIVE_INTERVAL_MS;
154
+ }
155
+ catch (err) {
156
+ if (isMissingResource(err)) {
157
+ notificationsUnavailableRef.current = true;
158
+ return;
159
+ }
160
+ backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
161
+ }
162
+ };
163
+ const scheduleNext = () => {
164
+ if (cancelled || notificationsUnavailableRef.current)
165
+ return;
166
+ const hidden = typeof document !== 'undefined' && document.hidden;
167
+ const delay = hidden ? HIDDEN_INTERVAL_MS : backoffMs;
168
+ timer = setTimeout(async () => {
169
+ await fetchOnce();
170
+ scheduleNext();
171
+ }, delay);
172
+ };
173
+ const onVisibilityChange = () => {
174
+ if (cancelled)
175
+ return;
176
+ if (typeof document === 'undefined' || document.hidden)
177
+ return;
178
+ if (timer) {
179
+ clearTimeout(timer);
180
+ timer = null;
181
+ }
182
+ backoffMs = ACTIVE_INTERVAL_MS;
183
+ fetchOnce().finally(scheduleNext);
184
+ };
185
+ fetchOnce().finally(scheduleNext);
186
+ if (typeof document !== 'undefined') {
187
+ document.addEventListener('visibilitychange', onVisibilityChange);
188
+ }
189
+ return () => {
190
+ cancelled = true;
191
+ if (timer)
192
+ clearTimeout(timer);
193
+ if (typeof document !== 'undefined') {
194
+ document.removeEventListener('visibilitychange', onVisibilityChange);
195
+ }
196
+ };
197
+ }, [dataSource, isApp, user?.id]);
198
+ /**
199
+ * M11.C15: poll pending-approvals count for the topbar shortcut badge.
200
+ * Hits the framework's `/api/v1/approvals/requests?status=pending`
201
+ * endpoint with the user's identities (id, email, role:<r>). Degrades
202
+ * silently to zero on 404 (approvals plugin not installed).
203
+ */
204
+ useEffect(() => {
205
+ if (!isApp || !user?.id)
206
+ return;
207
+ if (approvalsUnavailableRef.current)
208
+ return;
209
+ const serverUrl = (import.meta.env?.VITE_SERVER_URL || '').replace(/\/$/, '');
210
+ const base = `${serverUrl}/api/v1/approvals/requests`;
211
+ const identities = [];
212
+ if (user.id)
213
+ identities.push(user.id);
214
+ if (user.email)
215
+ identities.push(user.email);
216
+ for (const r of (user.roles || [])) {
217
+ if (r)
218
+ identities.push(`role:${r}`);
219
+ }
220
+ let cancelled = false;
221
+ let timer = null;
222
+ const POLL_MS = 30000;
223
+ const fetchOnce = async () => {
224
+ try {
225
+ const seen = new Set();
226
+ if (identities.length === 0)
227
+ return;
228
+ for (const id of identities) {
229
+ const qs = new URLSearchParams({ status: 'pending', approverId: id });
230
+ const res = await fetch(`${base}?${qs}`, { credentials: 'include' });
231
+ if (res.status === 404) {
232
+ approvalsUnavailableRef.current = true;
233
+ return;
234
+ }
235
+ if (!res.ok)
236
+ return;
237
+ const payload = await res.json().catch(() => null);
238
+ for (const row of (payload?.data || []))
239
+ seen.add(row.id);
240
+ }
241
+ if (!cancelled)
242
+ setPendingApprovalsCount(seen.size);
243
+ }
244
+ catch { /* transient — keep last value */ }
245
+ };
246
+ const schedule = () => {
247
+ if (cancelled || approvalsUnavailableRef.current)
248
+ return;
249
+ timer = setTimeout(async () => { await fetchOnce(); schedule(); }, POLL_MS);
250
+ };
251
+ fetchOnce().finally(schedule);
252
+ return () => { cancelled = true; if (timer)
253
+ clearTimeout(timer); };
254
+ }, [isApp, user?.id]);
255
+ const unreadCount = notifications.reduce((n, x) => n + (x.is_read ? 0 : 1), 0);
256
+ const markNotificationRead = useCallback(async (id) => {
257
+ setNotifications(prev => prev.map(n => n.id === id ? { ...n, is_read: true } : n));
258
+ if (!dataSource)
259
+ return;
260
+ try {
261
+ await dataSource.update('sys_notification', id, {
262
+ is_read: true,
263
+ read_at: new Date().toISOString(),
264
+ });
265
+ }
266
+ catch { /* best-effort */ }
267
+ }, [dataSource]);
268
+ const markAllRead = useCallback(async () => {
269
+ const unread = notifications.filter(n => !n.is_read);
270
+ if (!unread.length)
271
+ return;
272
+ setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
273
+ if (!dataSource)
274
+ return;
275
+ const now = new Date().toISOString();
276
+ await Promise.all(unread.map(n => dataSource.update('sys_notification', n.id, { is_read: true, read_at: now }).catch(() => { })));
277
+ }, [dataSource, notifications]);
106
278
  const activeUsers = presenceUsers ?? apiPresenceUsers ?? EMPTY_PRESENCE_USERS;
107
279
  const activeActivities = activities ?? apiActivities ?? [];
108
280
  const orgList = organizations ?? [];
@@ -203,5 +375,18 @@ export function AppHeader({ variant, appName, objects, connectionState, presence
203
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) => {
204
376
  const isLast = i === extraSegments.length - 1;
205
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));
206
- }), _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 }) }), _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("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' })] })] }))] })] })] })] })] }));
207
392
  }
@@ -202,19 +202,37 @@ export function AppSidebar({ activeAppName, onAppChange }) {
202
202
  }) }) })] })), _jsx(SidebarGroup, { className: "py-0", children: _jsxs(SidebarGroupContent, { className: "relative", children: [_jsx(Search, { className: "pointer-events-none absolute left-2 top-1/2 size-4 -translate-y-1/2 select-none opacity-70" }), _jsx(SidebarInput, { placeholder: "Search navigation...", value: navSearchQuery, onChange: (e) => setNavSearchQuery(e.target.value), className: "pl-8" })] }) }), _jsx(NavigationRenderer, { items: processedNavigation, basePath: basePath, evaluateVisibility: evalVis, checkPermission: checkPerm, checkCapability: checkCap, searchQuery: navSearchQuery, enablePinning: true, onPinToggle: togglePin, enableReorder: true, onReorder: handleReorder, resolveObjectLabel: (objectName, fallback) => resolveNavObjectLabel({ name: objectName, label: fallback }), t: t }), recentItems.length > 0 && (_jsxs(SidebarGroup, { children: [_jsxs(SidebarGroupLabel, { className: "flex items-center gap-1.5 cursor-pointer select-none", onClick: () => setRecentExpanded(prev => !prev), children: [_jsx(ChevronRight, { className: `h-3 w-3 transition-transform duration-150 ${recentExpanded ? 'rotate-90' : ''}` }), _jsx(Clock, { className: "h-3.5 w-3.5" }), "Recent"] }), recentExpanded && (_jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: recentItems.slice(0, 5).map(item => (_jsx(SidebarMenuItem, { children: _jsx(SidebarMenuButton, { asChild: true, tooltip: item.label, children: _jsxs(Link, { to: item.href, children: [_jsx("span", { className: "text-muted-foreground", children: item.type === 'dashboard' ? '📊' : item.type === 'report' ? '📈' : '📄' }), _jsx("span", { className: "truncate", children: item.label })] }) }) }, item.id))) }) }))] })), favorites.length > 0 && (_jsxs(SidebarGroup, { children: [_jsxs(SidebarGroupLabel, { className: "flex items-center gap-1.5", children: [_jsx(Star, { className: "h-3.5 w-3.5" }), "Favorites"] }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: favorites.slice(0, 8).map(item => (_jsxs(SidebarMenuItem, { children: [_jsx(SidebarMenuButton, { asChild: true, tooltip: item.label, children: _jsxs(Link, { to: item.href, children: [_jsx("span", { className: "text-muted-foreground", children: item.type === 'dashboard' ? '📊' : item.type === 'report' ? '📈' : item.type === 'page' ? '📄' : '📋' }), _jsx("span", { className: "truncate", children: item.label })] }) }), _jsx(SidebarMenuAction, { showOnHover: true, onClick: (e) => { e.stopPropagation(); removeFavorite(item.id); }, "aria-label": `Remove ${item.label} from favorites`, children: _jsx(StarOff, { className: "h-3 w-3" }) })] }, item.id))) }) })] }))] })) : (_jsxs(SidebarGroup, { "data-testid": "system-fallback-nav", children: [_jsxs(SidebarGroupLabel, { className: "flex items-center gap-1.5", children: [_jsx(Settings, { className: "h-3.5 w-3.5" }), "System"] }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: systemFallbackNavigation.map((item) => {
203
203
  const NavIcon = getIcon(item.icon);
204
204
  return (_jsx(SidebarMenuItem, { children: _jsx(SidebarMenuButton, { asChild: true, tooltip: item.label, children: _jsxs(Link, { to: item.url || '/system', children: [_jsx(NavIcon, { className: "h-4 w-4" }), _jsx("span", { children: item.label })] }) }) }, item.id));
205
- }) }) })] })) }), _jsx(SidebarFooter, { children: _jsx(SidebarMenu, { children: _jsx(SidebarMenuItem, { children: _jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(SidebarMenuButton, { size: "lg", className: "data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground", 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(ChevronsUpDown, { className: "ml-auto size-4" })] }) }), _jsxs(DropdownMenuContent, { className: "w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg", side: isMobile ? "bottom" : "right", align: "end", sideOffset: 4, children: [_jsx(DropdownMenuLabel, { className: "p-0 font-normal", children: _jsxs("div", { className: "flex items-center gap-2 px-1 py-1.5 text-left text-sm", 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, {}), _jsx(DropdownMenuGroup, { children: _jsxs(DropdownMenuItem, { onClick: () => navigate('/apps/setup'), children: [_jsx(Settings, { className: "mr-2 h-4 w-4" }), t('user.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' })] })] }))] })] }) }) }) })] }), isMobile && (_jsx("div", { className: "fixed bottom-0 left-0 right-0 z-50 flex items-center justify-around border-t bg-background/95 backdrop-blur-sm px-2 py-1 sm:hidden safe-area-bottom", children: (activeApp ? resolvedNavigation : systemFallbackNavigation).filter((n) => n.type !== 'group').slice(0, 5).map((item) => {
206
- const NavIcon = getIcon(item.icon);
207
- const baseUrl = activeApp ? `/apps/${activeAppName}` : '';
208
- let href = item.url || '#';
209
- if (item.type === 'object') {
210
- href = `${baseUrl}/${item.objectName}`;
211
- if (item.viewName)
212
- href += `/view/${item.viewName}`;
205
+ }) }) })] })) }), _jsx(SidebarFooter, { children: _jsx(SidebarMenu, { children: _jsx(SidebarMenuItem, { children: _jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(SidebarMenuButton, { size: "lg", className: "data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground", 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(ChevronsUpDown, { className: "ml-auto size-4" })] }) }), _jsxs(DropdownMenuContent, { className: "w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg", side: isMobile ? "bottom" : "right", align: "end", sideOffset: 4, children: [_jsx(DropdownMenuLabel, { className: "p-0 font-normal", children: _jsxs("div", { className: "flex items-center gap-2 px-1 py-1.5 text-left text-sm", 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, {}), _jsx(DropdownMenuGroup, { children: _jsxs(DropdownMenuItem, { onClick: () => navigate('/apps/setup'), children: [_jsx(Settings, { className: "mr-2 h-4 w-4" }), t('user.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' })] })] }))] })] }) }) }) })] }), isMobile && (_jsx("div", { className: "fixed bottom-0 left-0 right-0 z-50 flex items-center justify-around border-t bg-background/95 backdrop-blur-sm px-2 py-1 sm:hidden safe-area-bottom", children: (() => {
206
+ // Flatten group items so apps that organise navigation into groups
207
+ // (e.g. Setup Overview / Administration / …) still surface real
208
+ // leaf links in the mobile bottom nav instead of rendering nothing.
209
+ const source = activeApp ? resolvedNavigation : systemFallbackNavigation;
210
+ const leaves = [];
211
+ for (const item of source) {
212
+ if (item.type === 'group') {
213
+ for (const child of (item.children || item.items || [])) {
214
+ if (child && child.type !== 'group')
215
+ leaves.push(child);
216
+ }
217
+ }
218
+ else {
219
+ leaves.push(item);
220
+ }
213
221
  }
214
- else if (item.type === 'dashboard')
215
- href = item.dashboardName ? `${baseUrl}/dashboard/${item.dashboardName}` : '#';
216
- else if (item.type === 'page')
217
- href = item.pageName ? `${baseUrl}/page/${item.pageName}` : '#';
218
- return (_jsxs(Link, { to: href, className: "flex flex-col items-center gap-0.5 px-2 py-1.5 text-muted-foreground hover:text-foreground transition-colors min-w-[44px] min-h-[44px] justify-center", children: [_jsx(NavIcon, { className: "h-5 w-5" }), _jsx("span", { className: "text-[10px] truncate max-w-[60px]", children: resolveI18nLabel(item.label, t) })] }, item.id));
219
- }) }))] }));
222
+ return leaves.slice(0, 5).map((item) => {
223
+ const NavIcon = getIcon(item.icon);
224
+ const baseUrl = activeApp ? `/apps/${activeAppName}` : '';
225
+ let href = item.url || '#';
226
+ if (item.type === 'object') {
227
+ href = `${baseUrl}/${item.objectName}`;
228
+ if (item.viewName)
229
+ href += `/view/${item.viewName}`;
230
+ }
231
+ else if (item.type === 'dashboard')
232
+ href = item.dashboardName ? `${baseUrl}/dashboard/${item.dashboardName}` : '#';
233
+ else if (item.type === 'page')
234
+ href = item.pageName ? `${baseUrl}/page/${item.pageName}` : '#';
235
+ return (_jsxs(Link, { to: href, className: "flex flex-col items-center gap-0.5 px-2 py-1.5 text-muted-foreground hover:text-foreground transition-colors min-w-[44px] min-h-[44px] justify-center", children: [_jsx(NavIcon, { className: "h-5 w-5" }), _jsx("span", { className: "text-[10px] truncate max-w-[60px]", children: resolveI18nLabel(item.label, t) })] }, item.id));
236
+ });
237
+ })() }))] }));
220
238
  }
@@ -180,18 +180,35 @@ export function UnifiedSidebar({ activeAppName }) {
180
180
  const NavIcon = getIcon(item.icon);
181
181
  const isActive = location.pathname === item.url;
182
182
  return (_jsx(SidebarMenuItem, { children: _jsx(SidebarMenuButton, { asChild: true, tooltip: item.label, isActive: isActive, children: _jsxs(Link, { to: item.url || '/home', children: [_jsx(NavIcon, { className: "h-4 w-4" }), _jsx("span", { children: item.label })] }) }) }, item.id));
183
- }) }) }) }), favorites.filter(f => f.type === 'object' || f.type === 'dashboard' || f.type === 'page').length > 0 && (_jsxs(SidebarGroup, { children: [_jsxs(SidebarGroupLabel, { className: "flex items-center gap-1.5", children: [_jsx(Star, { className: "h-3.5 w-3.5" }), t('sidebar.starred', { defaultValue: 'Starred' })] }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: favorites.filter(f => f.type === 'object' || f.type === 'dashboard' || f.type === 'page').slice(0, 8).map(item => (_jsxs(SidebarMenuItem, { children: [_jsx(SidebarMenuButton, { asChild: true, tooltip: item.label, children: _jsxs(Link, { to: item.href, children: [_jsx("span", { className: "text-muted-foreground", children: item.type === 'dashboard' ? '📊' : item.type === 'page' ? '📄' : '📋' }), _jsx("span", { className: "truncate", children: item.label })] }) }), _jsx(SidebarMenuAction, { showOnHover: true, onClick: (e) => { e.stopPropagation(); removeFavorite(item.id); }, "aria-label": t('sidebar.removeFromFavorites', { defaultValue: 'Remove {{name}} from favorites', name: item.label }), children: _jsx(StarOff, { className: "h-3 w-3" }) })] }, item.id))) }) })] }))] })) }) }), _jsx(SidebarFooter, { className: "border-t p-1", children: _jsx(SidebarTrigger, { className: "w-full justify-start pl-2 group-data-[state=collapsed]:justify-center group-data-[state=collapsed]:pl-0" }) })] }), isMobile && context === 'app' && (_jsx("div", { className: "fixed bottom-0 left-0 right-0 z-50 flex items-center justify-around border-t bg-background/95 backdrop-blur-sm px-2 py-1 sm:hidden safe-area-bottom", children: processedNavigation.filter((n) => n.type !== 'group').slice(0, 5).map((item) => {
184
- const NavIcon = getIcon(item.icon);
185
- let href = item.url || '#';
186
- if (item.type === 'object') {
187
- href = `${basePath}/${item.objectName}`;
188
- if (item.viewName)
189
- href += `/view/${item.viewName}`;
183
+ }) }) }) }), favorites.filter(f => f.type === 'object' || f.type === 'dashboard' || f.type === 'page').length > 0 && (_jsxs(SidebarGroup, { children: [_jsxs(SidebarGroupLabel, { className: "flex items-center gap-1.5", children: [_jsx(Star, { className: "h-3.5 w-3.5" }), t('sidebar.starred', { defaultValue: 'Starred' })] }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: favorites.filter(f => f.type === 'object' || f.type === 'dashboard' || f.type === 'page').slice(0, 8).map(item => (_jsxs(SidebarMenuItem, { children: [_jsx(SidebarMenuButton, { asChild: true, tooltip: item.label, children: _jsxs(Link, { to: item.href, children: [_jsx("span", { className: "text-muted-foreground", children: item.type === 'dashboard' ? '📊' : item.type === 'page' ? '📄' : '📋' }), _jsx("span", { className: "truncate", children: item.label })] }) }), _jsx(SidebarMenuAction, { showOnHover: true, onClick: (e) => { e.stopPropagation(); removeFavorite(item.id); }, "aria-label": t('sidebar.removeFromFavorites', { defaultValue: 'Remove {{name}} from favorites', name: item.label }), children: _jsx(StarOff, { className: "h-3 w-3" }) })] }, item.id))) }) })] }))] })) }) }), _jsx(SidebarFooter, { className: "border-t p-1", children: _jsx(SidebarTrigger, { className: "w-full justify-start pl-2 group-data-[state=collapsed]:justify-center group-data-[state=collapsed]:pl-0" }) })] }), isMobile && context === 'app' && (_jsx("div", { className: "fixed bottom-0 left-0 right-0 z-50 flex items-center justify-around border-t bg-background/95 backdrop-blur-sm px-2 py-1 sm:hidden safe-area-bottom", children: (() => {
184
+ // Flatten group items so apps that organise navigation into groups
185
+ // (e.g. Setup Overview / Administration / …) still surface real
186
+ // leaf links in the mobile bottom nav instead of rendering nothing.
187
+ const leaves = [];
188
+ for (const item of processedNavigation) {
189
+ if (item.type === 'group') {
190
+ for (const child of (item.children || [])) {
191
+ if (child && child.type !== 'group')
192
+ leaves.push(child);
193
+ }
194
+ }
195
+ else {
196
+ leaves.push(item);
197
+ }
190
198
  }
191
- else if (item.type === 'dashboard')
192
- href = item.dashboardName ? `${basePath}/dashboard/${item.dashboardName}` : '#';
193
- else if (item.type === 'page')
194
- href = item.pageName ? `${basePath}/page/${item.pageName}` : '#';
195
- return (_jsxs(Link, { to: href, className: "flex flex-col items-center gap-0.5 px-2 py-1.5 text-muted-foreground hover:text-foreground transition-colors min-w-[44px] min-h-[44px] justify-center", children: [_jsx(NavIcon, { className: "h-5 w-5" }), _jsx("span", { className: "text-[10px] truncate max-w-[60px]", children: resolveI18nLabel(item.label, t) })] }, item.id));
196
- }) }))] }));
199
+ return leaves.slice(0, 5).map((item) => {
200
+ const NavIcon = getIcon(item.icon);
201
+ let href = item.url || '#';
202
+ if (item.type === 'object') {
203
+ href = `${basePath}/${item.objectName}`;
204
+ if (item.viewName)
205
+ href += `/view/${item.viewName}`;
206
+ }
207
+ else if (item.type === 'dashboard')
208
+ href = item.dashboardName ? `${basePath}/dashboard/${item.dashboardName}` : '#';
209
+ else if (item.type === 'page')
210
+ href = item.pageName ? `${basePath}/page/${item.pageName}` : '#';
211
+ return (_jsxs(Link, { to: href, className: "flex flex-col items-center gap-0.5 px-2 py-1.5 text-muted-foreground hover:text-foreground transition-colors min-w-[44px] min-h-[44px] justify-center", children: [_jsx(NavIcon, { className: "h-5 w-5" }), _jsx("span", { className: "text-[10px] truncate max-w-[60px]", children: resolveI18nLabel(item.label, t) })] }, item.id));
212
+ });
213
+ })() }))] }));
197
214
  }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * crudAffordances — UI-side mirror of the framework's
3
+ * `resolveCrudAffordances()` helper (see
4
+ * `@objectstack/spec/data/object.zod.ts`).
5
+ *
6
+ * The framework tags every object with a `managedBy` lifecycle bucket
7
+ * (`platform | config | system | append-only | better-auth`) and an
8
+ * optional `userActions` override block. UI components ask this helper
9
+ * "should I show the New / Import / Edit / Delete / Export buttons?" so
10
+ * the toolbar tracks the object's lifecycle automatically — no need for
11
+ * each view to special-case `sys_*` names.
12
+ *
13
+ * Keep this in lockstep with the framework helper. The bucket defaults
14
+ * mirror what Salesforce / ServiceNow / Workday / Notion do for the
15
+ * equivalent categories of system tables.
16
+ */
17
+ export type ManagedByBucket = 'platform' | 'config' | 'system' | 'append-only' | 'better-auth';
18
+ export interface CrudAffordances {
19
+ /** Generic "New" button for single-record creation. */
20
+ create: boolean;
21
+ /** CSV bulk-import wizard. */
22
+ import: boolean;
23
+ /** Inline + form editing of existing rows. */
24
+ edit: boolean;
25
+ /** Row-level + bulk delete. */
26
+ delete: boolean;
27
+ /** CSV / clipboard export. */
28
+ exportCsv: boolean;
29
+ }
30
+ export interface UserActionsOverride {
31
+ create?: boolean;
32
+ import?: boolean;
33
+ edit?: boolean;
34
+ delete?: boolean;
35
+ exportCsv?: boolean;
36
+ }
37
+ export interface SchemaLike {
38
+ managedBy?: string | null;
39
+ userActions?: UserActionsOverride | null;
40
+ }
41
+ export declare function resolveCrudAffordances(obj: SchemaLike | null | undefined): CrudAffordances;