@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.
- package/dist/api.d.ts +4 -0
- package/dist/auth7/client.d.ts +26 -0
- package/dist/auth7/delegated-proxy.d.ts +33 -0
- package/dist/auth7/hooks/useAuth.d.ts +10 -0
- package/dist/auth7/hooks/useSession.d.ts +12 -0
- package/dist/auth7/index.d.ts +12 -0
- package/dist/auth7/provider.d.ts +13 -0
- package/dist/auth7/scope-guard.d.ts +5 -0
- package/dist/auth7/service-account.d.ts +2 -0
- package/dist/auth7/token-exchange.d.ts +11 -0
- package/dist/auth7/types.d.ts +63 -0
- package/dist/backend.d.ts +20 -0
- package/dist/crud-components.d.ts +47 -0
- package/dist/crud-hooks.d.ts +15 -0
- package/dist/crud-types.d.ts +86 -0
- package/dist/data-table/cursor-adapter.d.ts +74 -0
- package/dist/event-bus/index.d.ts +20 -0
- package/dist/event-bus/useEventBus.d.ts +21 -0
- package/dist/event-bus.d.ts +1 -0
- package/dist/export.d.ts +7 -0
- package/dist/form-layout.d.ts +39 -0
- package/dist/form-numeric.d.ts +35 -0
- package/dist/form-renderer-utils.d.ts +10 -0
- package/dist/form-renderer.d.ts +18 -0
- package/dist/form-rules.d.ts +12 -0
- package/dist/form-types.d.ts +130 -0
- package/dist/html2canvas.esm-d2sM-0Wm.js +4870 -0
- package/dist/i18n.d.ts +35 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.es-Bylk5fh-.js +5649 -0
- package/dist/index.js +20779 -0
- package/dist/jspdf.es.min-Dzm5j8wC.js +9977 -0
- package/dist/jspdf.plugin.autotable-DcntYaes.js +1078 -0
- package/dist/modal-layout.d.ts +45 -0
- package/dist/nats/client.d.ts +54 -0
- package/dist/notif7.d.ts +21 -0
- package/dist/notification/routing.d.ts +4 -0
- package/dist/notifications-bff.d.ts +22 -0
- package/dist/observability/otel-init.d.ts +1 -0
- package/dist/page-layout.d.ts +24 -0
- package/dist/purify.es-CiEWEeUM.js +605 -0
- package/dist/shell/app-shell-layout.d.ts +70 -0
- package/dist/shell/branch.d.ts +6 -0
- package/dist/shell/interaction.d.ts +2 -0
- package/dist/shell/provider.d.ts +15 -0
- package/dist/style-contract.d.ts +72 -0
- package/dist/types/core.d.ts +39 -0
- package/dist/types/notification.d.ts +30 -0
- package/dist/workflow/api-client.d.ts +2 -0
- package/dist/workflow/backend.d.ts +9 -0
- package/dist/workflow/crud-client.d.ts +3 -0
- package/dist/workflow/notif7.d.ts +6 -0
- package/dist/workflow/starter.d.ts +44 -0
- package/dist/workflow/use-crud-form.d.ts +27 -0
- package/dist/workflow/use-workflow-tracker.d.ts +40 -0
- package/package.json +92 -0
- package/src/api.ts +40 -0
- package/src/auth7/client.ts +248 -0
- package/src/auth7/delegated-proxy.ts +80 -0
- package/src/auth7/hooks/useAuth.ts +21 -0
- package/src/auth7/hooks/useSession.ts +75 -0
- package/src/auth7/index.ts +14 -0
- package/src/auth7/provider.tsx +256 -0
- package/src/auth7/scope-guard.ts +54 -0
- package/src/auth7/service-account.ts +71 -0
- package/src/auth7/token-exchange.ts +125 -0
- package/src/auth7/types.ts +72 -0
- package/src/backend.ts +96 -0
- package/src/crud-components.tsx +580 -0
- package/src/crud-hooks.test.ts +32 -0
- package/src/crud-hooks.ts +31 -0
- package/src/crud-types.ts +64 -0
- package/src/data-table/cursor-adapter.ts +239 -0
- package/src/event-bus/index.ts +90 -0
- package/src/event-bus/useEventBus.ts +93 -0
- package/src/event-bus.ts +1 -0
- package/src/export.ts +62 -0
- package/src/form-contract.css +52 -0
- package/src/form-layout.tsx +90 -0
- package/src/form-numeric.ts +173 -0
- package/src/form-renderer-utils.ts +139 -0
- package/src/form-renderer.tsx +624 -0
- package/src/form-rules.ts +54 -0
- package/src/form-types.ts +126 -0
- package/src/i18n.tsx +92 -0
- package/src/index.ts +20 -0
- package/src/modal-layout.tsx +60 -0
- package/src/nats/client.ts +130 -0
- package/src/notif7.ts +76 -0
- package/src/notification/routing.ts +64 -0
- package/src/notifications-bff.ts +273 -0
- package/src/observability/otel-init.ts +41 -0
- package/src/page-layout.tsx +69 -0
- package/src/shell/app-shell-layout.tsx +494 -0
- package/src/shell/branch.ts +48 -0
- package/src/shell/interaction.ts +17 -0
- package/src/shell/provider.tsx +34 -0
- package/src/style-contract.test.ts +59 -0
- package/src/style-contract.ts +132 -0
- package/src/types/core.ts +46 -0
- package/src/types/notification.ts +35 -0
- package/src/workflow/api-client.ts +7 -0
- package/src/workflow/backend.ts +53 -0
- package/src/workflow/crud-client.ts +43 -0
- package/src/workflow/notif7.ts +28 -0
- package/src/workflow/starter.ts +153 -0
- package/src/workflow/use-crud-form.ts +176 -0
- 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
|
+
});
|