@isi-ui7/bos7-shared 0.2.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.
Files changed (108) hide show
  1. package/dist/api.d.ts +4 -0
  2. package/dist/auth7/client.d.ts +26 -0
  3. package/dist/auth7/delegated-proxy.d.ts +33 -0
  4. package/dist/auth7/hooks/useAuth.d.ts +10 -0
  5. package/dist/auth7/hooks/useSession.d.ts +12 -0
  6. package/dist/auth7/index.d.ts +12 -0
  7. package/dist/auth7/provider.d.ts +13 -0
  8. package/dist/auth7/scope-guard.d.ts +5 -0
  9. package/dist/auth7/service-account.d.ts +2 -0
  10. package/dist/auth7/token-exchange.d.ts +11 -0
  11. package/dist/auth7/types.d.ts +63 -0
  12. package/dist/backend.d.ts +20 -0
  13. package/dist/crud-components.d.ts +47 -0
  14. package/dist/crud-hooks.d.ts +15 -0
  15. package/dist/crud-types.d.ts +86 -0
  16. package/dist/data-table/cursor-adapter.d.ts +74 -0
  17. package/dist/event-bus/index.d.ts +20 -0
  18. package/dist/event-bus/useEventBus.d.ts +21 -0
  19. package/dist/event-bus.d.ts +1 -0
  20. package/dist/export.d.ts +7 -0
  21. package/dist/form-layout.d.ts +39 -0
  22. package/dist/form-numeric.d.ts +35 -0
  23. package/dist/form-renderer-utils.d.ts +10 -0
  24. package/dist/form-renderer.d.ts +18 -0
  25. package/dist/form-rules.d.ts +12 -0
  26. package/dist/form-types.d.ts +130 -0
  27. package/dist/html2canvas.esm-d2sM-0Wm.js +4870 -0
  28. package/dist/i18n.d.ts +35 -0
  29. package/dist/index.d.ts +18 -0
  30. package/dist/index.es-Bylk5fh-.js +5649 -0
  31. package/dist/index.js +20779 -0
  32. package/dist/jspdf.es.min-Dzm5j8wC.js +9977 -0
  33. package/dist/jspdf.plugin.autotable-DcntYaes.js +1078 -0
  34. package/dist/modal-layout.d.ts +45 -0
  35. package/dist/nats/client.d.ts +54 -0
  36. package/dist/notif7.d.ts +21 -0
  37. package/dist/notification/routing.d.ts +4 -0
  38. package/dist/notifications-bff.d.ts +22 -0
  39. package/dist/observability/otel-init.d.ts +1 -0
  40. package/dist/page-layout.d.ts +24 -0
  41. package/dist/purify.es-CiEWEeUM.js +605 -0
  42. package/dist/shell/app-shell-layout.d.ts +70 -0
  43. package/dist/shell/branch.d.ts +6 -0
  44. package/dist/shell/interaction.d.ts +2 -0
  45. package/dist/shell/provider.d.ts +15 -0
  46. package/dist/style-contract.d.ts +72 -0
  47. package/dist/types/core.d.ts +39 -0
  48. package/dist/types/notification.d.ts +30 -0
  49. package/dist/workflow/api-client.d.ts +2 -0
  50. package/dist/workflow/backend.d.ts +9 -0
  51. package/dist/workflow/crud-client.d.ts +3 -0
  52. package/dist/workflow/notif7.d.ts +6 -0
  53. package/dist/workflow/starter.d.ts +44 -0
  54. package/dist/workflow/use-crud-form.d.ts +27 -0
  55. package/dist/workflow/use-workflow-tracker.d.ts +40 -0
  56. package/package.json +92 -0
  57. package/src/api.ts +40 -0
  58. package/src/auth7/client.ts +248 -0
  59. package/src/auth7/delegated-proxy.ts +80 -0
  60. package/src/auth7/hooks/useAuth.ts +21 -0
  61. package/src/auth7/hooks/useSession.ts +75 -0
  62. package/src/auth7/index.ts +14 -0
  63. package/src/auth7/provider.tsx +256 -0
  64. package/src/auth7/scope-guard.ts +54 -0
  65. package/src/auth7/service-account.ts +71 -0
  66. package/src/auth7/token-exchange.ts +125 -0
  67. package/src/auth7/types.ts +72 -0
  68. package/src/backend.ts +96 -0
  69. package/src/crud-components.tsx +580 -0
  70. package/src/crud-hooks.test.ts +32 -0
  71. package/src/crud-hooks.ts +31 -0
  72. package/src/crud-types.ts +64 -0
  73. package/src/data-table/cursor-adapter.ts +239 -0
  74. package/src/event-bus/index.ts +90 -0
  75. package/src/event-bus/useEventBus.ts +93 -0
  76. package/src/event-bus.ts +1 -0
  77. package/src/export.ts +62 -0
  78. package/src/form-contract.css +52 -0
  79. package/src/form-layout.tsx +90 -0
  80. package/src/form-numeric.ts +173 -0
  81. package/src/form-renderer-utils.ts +139 -0
  82. package/src/form-renderer.tsx +624 -0
  83. package/src/form-rules.ts +54 -0
  84. package/src/form-types.ts +126 -0
  85. package/src/i18n.tsx +92 -0
  86. package/src/index.ts +20 -0
  87. package/src/modal-layout.tsx +60 -0
  88. package/src/nats/client.ts +130 -0
  89. package/src/notif7.ts +76 -0
  90. package/src/notification/routing.ts +64 -0
  91. package/src/notifications-bff.ts +273 -0
  92. package/src/observability/otel-init.ts +41 -0
  93. package/src/page-layout.tsx +69 -0
  94. package/src/shell/app-shell-layout.tsx +494 -0
  95. package/src/shell/branch.ts +48 -0
  96. package/src/shell/interaction.ts +17 -0
  97. package/src/shell/provider.tsx +34 -0
  98. package/src/style-contract.test.ts +59 -0
  99. package/src/style-contract.ts +132 -0
  100. package/src/types/core.ts +46 -0
  101. package/src/types/notification.ts +35 -0
  102. package/src/workflow/api-client.ts +7 -0
  103. package/src/workflow/backend.ts +53 -0
  104. package/src/workflow/crud-client.ts +43 -0
  105. package/src/workflow/notif7.ts +28 -0
  106. package/src/workflow/starter.ts +153 -0
  107. package/src/workflow/use-crud-form.ts +176 -0
  108. package/src/workflow/use-workflow-tracker.ts +109 -0
@@ -0,0 +1,494 @@
1
+ "use client";
2
+
3
+ import type { ReactNode } from "react";
4
+ import { useCallback, useEffect, useMemo, useState } from "react";
5
+ import { useI18n, useUi7Labels, type Ui7Locale } from "@isi-ui7/i18n";
6
+ import { useAuth as defaultUseAuth } from "../auth7";
7
+ import { handleShellProfileAction } from "./interaction";
8
+ import { setupBranchVisibilitySync, switchBranchById } from "./branch";
9
+
10
+ export interface AppShellAuthState {
11
+ isAuthenticated: boolean;
12
+ isLoading: boolean;
13
+ user: {
14
+ name?: string | null;
15
+ email?: string | null;
16
+ roles?: string[] | null;
17
+ branch_id?: string | null;
18
+ } | null;
19
+ login: () => void;
20
+ logout: () => Promise<void> | void;
21
+ }
22
+
23
+ export type ShellNavItemLike = any;
24
+
25
+ export interface ShellNotificationLike {
26
+ id?: string;
27
+ read?: boolean;
28
+ [key: string]: unknown;
29
+ }
30
+
31
+ interface Branch {
32
+ id: string;
33
+ name: string;
34
+ code?: string;
35
+ is_primary?: boolean;
36
+ is_current?: boolean;
37
+ }
38
+
39
+ interface RouterLike {
40
+ push: (href: string) => void;
41
+ }
42
+
43
+ interface ThemeLike {
44
+ displayName?: string;
45
+ }
46
+
47
+ interface NotificationBellLike {
48
+ badgeCount: number;
49
+ markAllRead: () => void;
50
+ onItemClick: (n: ShellNotificationLike) => void;
51
+ fetchBadge: () => void;
52
+ }
53
+
54
+ interface NotificationBellOptionsLike {
55
+ apiBase?: string;
56
+ enabled?: boolean;
57
+ onNavigate?: (href: string) => void;
58
+ }
59
+
60
+ interface UiShellLikeProps {
61
+ productName: string;
62
+ headerPrefix: string;
63
+ headerThemeVariant: any;
64
+ navItems: any[];
65
+ sideNavItems: ShellNavItemLike[];
66
+ sideNavMode: string;
67
+ onSideNavSelect: (href: string) => void;
68
+ notifications: {
69
+ badgeCount: number;
70
+ /** Bumping this value forces ui-shell to re-fetch the panel list. */
71
+ fetchKey?: number;
72
+ onFetch: () => Promise<ShellNotificationLike[]>;
73
+ onMarkAllRead: () => void;
74
+ onItemClick: (n: ShellNotificationLike) => void;
75
+ viewAllHref: string;
76
+ viewAllTarget: string;
77
+ };
78
+ userProfile: {
79
+ name: string;
80
+ role?: string;
81
+ email: string;
82
+ };
83
+ profileActions: string[];
84
+ onProfileAction: (action: string) => void;
85
+ profileExtra?: ReactNode;
86
+ apps: {
87
+ onFetch: () => Promise<Array<{ href: string }>>;
88
+ onAppSelect: (app: { href: string }) => void;
89
+ };
90
+ children: ReactNode;
91
+ }
92
+
93
+ export type AppSwitchMode = "push" | "location";
94
+
95
+ export interface AppShellLayoutDeps {
96
+ usePathname: () => string;
97
+ useRouter: () => RouterLike;
98
+ useTheme: () => { theme: ThemeLike };
99
+ useNotificationBell: (opts: NotificationBellOptionsLike) => NotificationBellLike;
100
+ GlobalTheme: any;
101
+ ModalProvider: any;
102
+ UiShell: any;
103
+ useAuth?: () => AppShellAuthState;
104
+ }
105
+
106
+ export interface AppShellLayoutProps {
107
+ children: ReactNode;
108
+ deps: AppShellLayoutDeps;
109
+ navItems: ShellNavItemLike[];
110
+ markActive: (items: ShellNavItemLike[], pathname: string) => ShellNavItemLike[];
111
+ appNameFallback: string;
112
+ auth7UiUrlFallback?: string;
113
+ portalUrlFallback?: string;
114
+ roleFallback?: string;
115
+ appSwitchMode?: AppSwitchMode;
116
+ resolveNotificationHref?: (href: string, navigate: (href: string) => void) => void;
117
+ notificationsViewAllHref?: string;
118
+ notificationsViewAllTarget?: string;
119
+ hideMenuButton?: boolean;
120
+ disableAppsMenu?: boolean;
121
+ sideNavMode?: "drilldown" | "overlay";
122
+ sideNavItems?: ShellNavItemLike[];
123
+ }
124
+
125
+ function BranchDropdown({
126
+ branches,
127
+ currentBranch,
128
+ onSwitch,
129
+ }: {
130
+ branches: Branch[];
131
+ currentBranch: Branch | null;
132
+ onSwitch: (branch: Branch) => void;
133
+ }) {
134
+ const [open, setOpen] = useState(false);
135
+ const { t } = useI18n();
136
+
137
+ return (
138
+ <div data-testid="branch-selector" className="profile-branch-dropdown">
139
+ <button type="button" className="profile-branch-dropdown__trigger" onClick={() => setOpen((v) => !v)} aria-expanded={open}>
140
+ <span className="profile-branch-dropdown__current">
141
+ <span className="profile-branch-section__label" style={{ margin: 0 }}>{t("ui7.branchLabel", "Cabang")}</span>
142
+ <span className="profile-branch-item__code">{currentBranch?.code || currentBranch?.name || "—"}</span>
143
+ </span>
144
+ <span className="profile-branch-dropdown__chevron" aria-hidden="true">{open ? "▲" : "▼"}</span>
145
+ </button>
146
+ {open && (
147
+ <div className="profile-branch-dropdown__list">
148
+ {branches.length === 0 && (
149
+ <button type="button" className="profile-branch-item profile-branch-item--active" onClick={() => setOpen(false)}>
150
+ <span className="profile-branch-item__code">{currentBranch?.code || currentBranch?.name || "—"}</span>
151
+ </button>
152
+ )}
153
+ {branches.map((b) => (
154
+ <button
155
+ key={b.id}
156
+ type="button"
157
+ className={`profile-branch-item${b.id === currentBranch?.id ? " profile-branch-item--active" : ""}`}
158
+ onClick={() => {
159
+ setOpen(false);
160
+ if (b.id !== currentBranch?.id) onSwitch(b);
161
+ }}
162
+ >
163
+ <span className="profile-branch-item__code">{b.code || b.name}</span>
164
+ {b.code && b.name && b.code !== b.name && <span className="profile-branch-item__name">{b.name}</span>}
165
+ {b.id === currentBranch?.id && <span className="profile-branch-item__check" aria-hidden="true">✓</span>}
166
+ </button>
167
+ ))}
168
+ </div>
169
+ )}
170
+ </div>
171
+ );
172
+ }
173
+
174
+ function LanguageSelector() {
175
+ const { t, locale, setLocale } = useI18n();
176
+ const [open, setOpen] = useState(false);
177
+ const itemClass = (value: Ui7Locale) =>
178
+ `profile-branch-item${locale === value ? " profile-branch-item--active" : ""}`;
179
+ const currentLabel =
180
+ locale === "en" ? t("ui7.languageEn", "English") : t("ui7.languageId", "Bahasa Indonesia");
181
+ const titleLabel = locale === "en" ? "Language" : "Bahasa";
182
+ const setLocalePreference = useCallback((next: Ui7Locale) => {
183
+ setLocale(next);
184
+ void fetch("/api/auth/preferences", {
185
+ method: "POST",
186
+ headers: { "Content-Type": "application/json" },
187
+ body: JSON.stringify({ locale: next }),
188
+ }).catch(() => {});
189
+ }, [setLocale]);
190
+
191
+ return (
192
+ <div data-testid="language-selector" className="profile-branch-dropdown">
193
+ <button type="button" className="profile-branch-dropdown__trigger" onClick={() => setOpen((v) => !v)} aria-expanded={open}>
194
+ <span className="profile-branch-dropdown__current">
195
+ <span className="profile-branch-section__label" style={{ margin: 0 }}>
196
+ {titleLabel}
197
+ </span>
198
+ <span className="profile-branch-item__code">{currentLabel}</span>
199
+ </span>
200
+ <span className="profile-branch-dropdown__chevron" aria-hidden="true">{open ? "▲" : "▼"}</span>
201
+ </button>
202
+ {open && (
203
+ <div className="profile-branch-dropdown__list">
204
+ <button type="button" className={itemClass("id")} onClick={() => { setLocalePreference("id"); setOpen(false); }}>
205
+ <span className="profile-branch-item__code">{t("ui7.languageId", "Bahasa Indonesia")}</span>
206
+ {locale === "id" && <span className="profile-branch-item__check" aria-hidden="true">✓</span>}
207
+ </button>
208
+ <button type="button" className={itemClass("en")} onClick={() => { setLocalePreference("en"); setOpen(false); }}>
209
+ <span className="profile-branch-item__code">{t("ui7.languageEn", "English")}</span>
210
+ {locale === "en" && <span className="profile-branch-item__check" aria-hidden="true">✓</span>}
211
+ </button>
212
+ </div>
213
+ )}
214
+ </div>
215
+ );
216
+ }
217
+
218
+ export function AppShellLayout({
219
+ children,
220
+ deps,
221
+ navItems,
222
+ markActive,
223
+ appNameFallback,
224
+ auth7UiUrlFallback = "http://localhost:3001",
225
+ portalUrlFallback = "https://portal.bos7.local",
226
+ roleFallback = "",
227
+ appSwitchMode = "location",
228
+ resolveNotificationHref,
229
+ notificationsViewAllHref,
230
+ notificationsViewAllTarget = "bos7PortalNotificationsWindow",
231
+ hideMenuButton = false,
232
+ disableAppsMenu = false,
233
+ sideNavMode = "drilldown",
234
+ sideNavItems,
235
+ }: AppShellLayoutProps) {
236
+ const corporateId = process.env.NEXT_PUBLIC_CORPORATE_ID || "carbon-default";
237
+ const appName = process.env.NEXT_PUBLIC_APP_NAME || appNameFallback;
238
+ const auth7UiUrl = process.env.NEXT_PUBLIC_AUTH7_URL || auth7UiUrlFallback;
239
+ const portalUrl = process.env.NEXT_PUBLIC_PORTAL_URL || portalUrlFallback;
240
+
241
+ const pathname = deps.usePathname();
242
+ const router = deps.useRouter();
243
+ const useAuthHook = deps.useAuth ?? defaultUseAuth;
244
+ const { isAuthenticated, isLoading, user, login, logout } = useAuthHook();
245
+
246
+ useEffect(() => {
247
+ if (!isLoading && !isAuthenticated) login();
248
+ }, [isLoading, isAuthenticated, login]);
249
+
250
+ const { badgeCount, markAllRead, onItemClick, fetchBadge } = deps.useNotificationBell({
251
+ apiBase: "/api/notifications",
252
+ enabled: isAuthenticated,
253
+ });
254
+
255
+ useEffect(() => {
256
+ const handler = () => fetchBadge();
257
+ window.addEventListener("notif:badge-refresh", handler);
258
+ return () => window.removeEventListener("notif:badge-refresh", handler);
259
+ }, [fetchBadge]);
260
+
261
+ const handleNotifItemClick = useCallback((n: ShellNotificationLike) => {
262
+ if (!n.read && n.id) {
263
+ fetch(`/api/notifications/${n.id}/read`, { method: "PATCH" })
264
+ .then(() => fetchBadge())
265
+ .catch(() => {});
266
+ }
267
+ onItemClick({ ...n, actionHref: undefined });
268
+ const actionHref = typeof n.actionHref === "string" ? n.actionHref : "";
269
+ if (actionHref) {
270
+ if (resolveNotificationHref) {
271
+ resolveNotificationHref(actionHref, (href: string) => router.push(href));
272
+ } else {
273
+ router.push(actionHref);
274
+ }
275
+ }
276
+ }, [onItemClick, fetchBadge, resolveNotificationHref, router]);
277
+
278
+ const resolvedSideNavItems = useMemo(() => markActive(navItems, pathname), [markActive, navItems, pathname]);
279
+
280
+ // Pre-auth render: never expose the page tree directly — pages may call
281
+ // hooks (useModal, etc.) that require providers only mounted in the
282
+ // authenticated branch below. When another tab logs out, `isAuthenticated`
283
+ // flips to false synchronously and the redirect is async — without this
284
+ // guard the page re-renders bare and crashes with
285
+ // "useModal must be used within <ModalProvider>".
286
+ if (isLoading || !isAuthenticated) {
287
+ return (
288
+ <div style={{ padding: "2rem", color: "#525252" }}>
289
+ {isLoading ? "Loading…" : "Redirecting to sign-in…"}
290
+ </div>
291
+ );
292
+ }
293
+
294
+ return (
295
+ <ThemedShellLayout
296
+ deps={deps}
297
+ corporateId={corporateId}
298
+ appName={appName}
299
+ auth7UiUrl={auth7UiUrl}
300
+ portalUrl={portalUrl}
301
+ roleFallback={roleFallback}
302
+ appSwitchMode={appSwitchMode}
303
+ notificationsViewAllHref={notificationsViewAllHref ?? `${portalUrl}/notifications`}
304
+ notificationsViewAllTarget={notificationsViewAllTarget}
305
+ hideMenuButton={hideMenuButton}
306
+ disableAppsMenu={disableAppsMenu}
307
+ sideNavMode={sideNavMode}
308
+ sideNavItems={sideNavItems ?? resolvedSideNavItems}
309
+ resolvedSideNavItems={resolvedSideNavItems}
310
+ notifBadgeCount={badgeCount}
311
+ userName={user?.name}
312
+ userEmail={user?.email}
313
+ userRole={user?.roles?.[0]}
314
+ userBranchId={user?.branch_id}
315
+ onNavigate={(href: string) => router.push(href)}
316
+ onMarkAllRead={markAllRead}
317
+ onNotifItemClick={handleNotifItemClick}
318
+ onLogout={() => { void logout(); }}
319
+ >
320
+ {children}
321
+ </ThemedShellLayout>
322
+ );
323
+ }
324
+
325
+ function ThemedShellLayout({
326
+ deps,
327
+ corporateId,
328
+ children,
329
+ ...rest
330
+ }: {
331
+ deps: AppShellLayoutDeps;
332
+ corporateId: string;
333
+ children: ReactNode;
334
+ } & Omit<Parameters<typeof ShellInner>[0], "deps" | "children">) {
335
+ const GlobalTheme = deps.GlobalTheme;
336
+ const ProviderModal = deps.ModalProvider;
337
+ const labels = useUi7Labels();
338
+ return (
339
+ <GlobalTheme corporateId={corporateId} variant="white" labels={labels}>
340
+ <ProviderModal>
341
+ <ShellInner deps={deps} {...rest}>
342
+ {children}
343
+ </ShellInner>
344
+ </ProviderModal>
345
+ </GlobalTheme>
346
+ );
347
+ }
348
+
349
+ function ShellInner({
350
+ deps,
351
+ appName,
352
+ auth7UiUrl,
353
+ portalUrl,
354
+ roleFallback,
355
+ appSwitchMode,
356
+ notificationsViewAllHref,
357
+ notificationsViewAllTarget,
358
+ hideMenuButton,
359
+ disableAppsMenu,
360
+ sideNavMode,
361
+ sideNavItems,
362
+ resolvedSideNavItems,
363
+ notifBadgeCount,
364
+ userName,
365
+ userEmail,
366
+ userRole,
367
+ userBranchId,
368
+ onNavigate,
369
+ onMarkAllRead,
370
+ onNotifItemClick,
371
+ onLogout,
372
+ children,
373
+ }: {
374
+ deps: AppShellLayoutDeps;
375
+ appName: string;
376
+ auth7UiUrl: string;
377
+ portalUrl: string;
378
+ roleFallback: string;
379
+ appSwitchMode: AppSwitchMode;
380
+ notificationsViewAllHref: string;
381
+ notificationsViewAllTarget: string;
382
+ hideMenuButton: boolean;
383
+ disableAppsMenu: boolean;
384
+ sideNavMode: "drilldown" | "overlay";
385
+ sideNavItems: ShellNavItemLike[];
386
+ resolvedSideNavItems: ShellNavItemLike[];
387
+ notifBadgeCount: number;
388
+ userName?: string | null;
389
+ userEmail?: string | null;
390
+ userRole?: string | null;
391
+ userBranchId?: string | null;
392
+ onNavigate: (href: string) => void;
393
+ onMarkAllRead: () => void;
394
+ onNotifItemClick: (n: ShellNotificationLike) => void;
395
+ onLogout: () => void;
396
+ children: ReactNode;
397
+ }) {
398
+ const { theme } = deps.useTheme();
399
+ const [branches, setBranches] = useState<Branch[]>([]);
400
+ const [currentBranch, setCurrentBranch] = useState<Branch | null>(null);
401
+
402
+ useEffect(() => {
403
+ fetch("/api/auth/branches")
404
+ .then((r) => (r.ok ? r.json() : null))
405
+ .then((d: { branches?: Branch[] } | null) => {
406
+ if (!d?.branches?.length) {
407
+ if (userBranchId) {
408
+ const fallback = { id: userBranchId, name: userBranchId, code: userBranchId, is_current: true };
409
+ setBranches([fallback]);
410
+ setCurrentBranch(fallback);
411
+ }
412
+ return;
413
+ }
414
+ const normalized = d.branches.map((b: Branch) => ({ ...b, is_current: b.id === userBranchId }));
415
+ setBranches(normalized);
416
+ setCurrentBranch(normalized.find((b) => b.is_current) ?? normalized[0]);
417
+ })
418
+ .catch(() => {
419
+ if (userBranchId) {
420
+ const fallback = { id: userBranchId, name: userBranchId, code: userBranchId, is_current: true };
421
+ setBranches([fallback]);
422
+ setCurrentBranch(fallback);
423
+ }
424
+ });
425
+ }, [userBranchId]);
426
+
427
+ useEffect(() => setupBranchVisibilitySync(userBranchId ?? undefined), [userBranchId]);
428
+
429
+ const switchBranch = useCallback(async (branch: Branch) => {
430
+ const ok = await switchBranchById(branch.id);
431
+ if (ok) window.location.reload();
432
+ }, []);
433
+
434
+ const roleDisplay = userRole
435
+ ? `${userRole.charAt(0).toUpperCase()}${userRole.slice(1)}`
436
+ : roleFallback || undefined;
437
+
438
+ const profileExtra = (
439
+ <>
440
+ <BranchDropdown branches={branches} currentBranch={currentBranch} onSwitch={switchBranch} />
441
+ <LanguageSelector />
442
+ </>
443
+ );
444
+
445
+ const ShellUi = deps.UiShell;
446
+
447
+ return (
448
+ <ShellUi
449
+ productName={appName}
450
+ headerPrefix={theme.displayName ?? "Core7"}
451
+ headerThemeVariant="g90"
452
+ navItems={[]}
453
+ sideNavItems={sideNavItems}
454
+ sideNavMode={sideNavMode}
455
+ onSideNavSelect={onNavigate}
456
+ notifications={{
457
+ badgeCount: notifBadgeCount,
458
+ // fetchKey ties the panel list to the SSE-driven badge counter.
459
+ // ui-shell re-fetches whenever fetchKey changes, so a new notification
460
+ // (which bumps badgeCount via useNotificationBell) also refreshes the
461
+ // panel — including while it's currently open.
462
+ fetchKey: notifBadgeCount,
463
+ onFetch: () => fetch("/api/notifications").then((r) => r.json()).then((d) => d.notifications as ShellNotificationLike[]),
464
+ onMarkAllRead,
465
+ onItemClick: onNotifItemClick,
466
+ viewAllHref: notificationsViewAllHref,
467
+ viewAllTarget: notificationsViewAllTarget,
468
+ }}
469
+ userProfile={{
470
+ name: userName || "User",
471
+ role: roleDisplay,
472
+ email: userEmail || "",
473
+ }}
474
+ profileActions={["profile", "change-password", "logout"]}
475
+ onProfileAction={(action: string) => handleShellProfileAction(action, auth7UiUrl, onLogout)}
476
+ profileExtra={profileExtra}
477
+ apps={disableAppsMenu
478
+ ? undefined
479
+ : {
480
+ onFetch: () => fetch("/api/apps").then((r) => r.json()).then((d) => d.apps as Array<{ href: string }>),
481
+ onAppSelect: (app: { href: string }) => {
482
+ if (appSwitchMode === "push") {
483
+ onNavigate(app.href);
484
+ return;
485
+ }
486
+ window.location.href = app.href;
487
+ },
488
+ }}
489
+ hideMenuButton={hideMenuButton}
490
+ >
491
+ {children}
492
+ </ShellUi>
493
+ );
494
+ }
@@ -0,0 +1,48 @@
1
+ function readCookie(name: string): string | undefined {
2
+ return document.cookie
3
+ .split("; ")
4
+ .find((entry) => entry.startsWith(`${name}=`))
5
+ ?.split("=")[1];
6
+ }
7
+
8
+ export async function switchBranchById(
9
+ branchId: string,
10
+ endpoint = "/api/auth/switch-branch"
11
+ ): Promise<boolean> {
12
+ try {
13
+ const res = await fetch(endpoint, {
14
+ method: "POST",
15
+ headers: { "Content-Type": "application/json" },
16
+ body: JSON.stringify({ branch_id: branchId }),
17
+ });
18
+ return res.ok;
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ export function setupBranchVisibilitySync(
25
+ currentBranchId: string | null | undefined,
26
+ options?: {
27
+ cookieName?: string;
28
+ endpoint?: string;
29
+ onSynced?: () => void;
30
+ }
31
+ ): () => void {
32
+ const cookieName = options?.cookieName ?? "bos7_branch";
33
+ const endpoint = options?.endpoint ?? "/api/auth/switch-branch";
34
+ const onSynced = options?.onSynced ?? (() => window.location.reload());
35
+
36
+ const sync = () => {
37
+ if (document.visibilityState !== "visible") return;
38
+ const cookieBranch = readCookie(cookieName);
39
+ if (!cookieBranch || cookieBranch === currentBranchId) return;
40
+
41
+ void switchBranchById(cookieBranch, endpoint).then((ok) => {
42
+ if (ok) onSynced();
43
+ });
44
+ };
45
+
46
+ document.addEventListener("visibilitychange", sync);
47
+ return () => document.removeEventListener("visibilitychange", sync);
48
+ }
@@ -0,0 +1,17 @@
1
+ export type ShellProfileAction = "profile" | "change-password" | "logout" | string;
2
+
3
+ export function handleShellProfileAction(
4
+ action: ShellProfileAction,
5
+ auth7UiUrl: string,
6
+ onLogout: () => void
7
+ ): void {
8
+ if (action === "profile") {
9
+ window.open(`${auth7UiUrl}/profile`, "_blank");
10
+ return;
11
+ }
12
+ if (action === "change-password") {
13
+ window.open(`${auth7UiUrl}/security/change-password`, "_blank");
14
+ return;
15
+ }
16
+ onLogout();
17
+ }
@@ -0,0 +1,34 @@
1
+ "use client";
2
+
3
+ import type { ComponentType, ReactNode } from "react";
4
+ import { AuthProvider } from "../auth7/provider";
5
+ import { I18nProvider } from "@isi-ui7/i18n";
6
+
7
+ type ProviderLayoutProps = {
8
+ children: ReactNode;
9
+ };
10
+
11
+ type SharedShellProviderProps = {
12
+ children: ReactNode;
13
+ Layout: ComponentType<ProviderLayoutProps>;
14
+ };
15
+
16
+ type SharedShellProviderOptions = {
17
+ withAuth?: boolean;
18
+ dictionaries?: Partial<Record<"id" | "en", Record<string, string>>>;
19
+ };
20
+
21
+ export function createShellProvider(options?: SharedShellProviderOptions) {
22
+ const withAuth = options?.withAuth ?? true;
23
+ const dictionaries = options?.dictionaries;
24
+
25
+ return function SharedShellProvider({ children, Layout }: SharedShellProviderProps) {
26
+ const content = (
27
+ <I18nProvider dictionaries={dictionaries}>
28
+ <Layout>{children}</Layout>
29
+ </I18nProvider>
30
+ );
31
+ if (!withAuth) return content;
32
+ return <AuthProvider>{content}</AuthProvider>;
33
+ };
34
+ }
@@ -0,0 +1,59 @@
1
+ import {
2
+ getUi7FormContractClassName,
3
+ getUi7FormDensityClassName,
4
+ normalizeUi7FormDensity,
5
+ UI7_FORM_VISUAL_CLASSNAMES,
6
+ UI7_FORM_VISUAL_TOKENS
7
+ } from "./style-contract";
8
+
9
+ describe("ui7 form visual contract", () => {
10
+ it("exposes stable visual tokens", () => {
11
+ expect(UI7_FORM_VISUAL_TOKENS).toEqual({
12
+ labelSpacing: "6px",
13
+ labelFontSize: "12px",
14
+ labelFontWeight: 500,
15
+ labelLineHeight: "16px",
16
+ inputFontSize: "14px",
17
+ inputFontWeight: 400,
18
+ inputLineHeight: "20px",
19
+ controlHeight: "40px",
20
+ textareaMinHeight: "88px",
21
+ rowGap: "16px",
22
+ sectionGap: "24px",
23
+ columnGap: "16px",
24
+ readonlyBackground: "#f4f4f4",
25
+ readonlyCursor: "default"
26
+ });
27
+ });
28
+
29
+ it("normalizes density aliases without breaking compact defaults", () => {
30
+ expect(normalizeUi7FormDensity("compact")).toBe("compact");
31
+ expect(normalizeUi7FormDensity("comfortable")).toBe("comfortable");
32
+ expect(normalizeUi7FormDensity("normal")).toBe("comfortable");
33
+ expect(normalizeUi7FormDensity(undefined)).toBe("compact");
34
+ });
35
+
36
+ it("maps density to stable class names", () => {
37
+ expect(getUi7FormDensityClassName("compact")).toBe(UI7_FORM_VISUAL_CLASSNAMES.densityCompact);
38
+ expect(getUi7FormDensityClassName("normal")).toBe(
39
+ UI7_FORM_VISUAL_CLASSNAMES.densityComfortable
40
+ );
41
+ });
42
+
43
+ it("builds a scope class list for consumers", () => {
44
+ expect(
45
+ getUi7FormContractClassName({
46
+ density: "comfortable",
47
+ readonly: true,
48
+ className: "custom-form-scope"
49
+ })
50
+ ).toBe(
51
+ [
52
+ UI7_FORM_VISUAL_CLASSNAMES.scope,
53
+ UI7_FORM_VISUAL_CLASSNAMES.densityComfortable,
54
+ UI7_FORM_VISUAL_CLASSNAMES.readonly,
55
+ "custom-form-scope"
56
+ ].join(" ")
57
+ );
58
+ });
59
+ });