@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,64 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ export type CrudPopupMenuItem = {
4
+ id: string;
5
+ itemType: "item" | "separator";
6
+ caption?: string;
7
+ };
8
+
9
+ export type CrudActionMode = "create" | "edit" | "view" | "delete";
10
+
11
+ export type CrudActionOverrideHooks<TData extends Record<string, unknown>> = {
12
+ beforeLoad?: (context: { mode: CrudActionMode; id?: string }) => Promise<void> | void;
13
+ afterLoad?: (data: TData, context: { mode: CrudActionMode; id?: string }) => Promise<TData> | TData;
14
+ beforeSubmit?: (data: TData, context: { mode: Extract<CrudActionMode, "create" | "edit">; id?: string }) => Promise<TData> | TData;
15
+ afterSubmit?: (result: unknown, context: { mode: Extract<CrudActionMode, "create" | "edit">; id?: string }) => Promise<void> | void;
16
+ mapRequest?: (data: TData, context: { mode: CrudActionMode; id?: string }) => unknown;
17
+ mapResponse?: (data: unknown, context: { mode: CrudActionMode; id?: string }) => TData;
18
+ fieldOverrides?: Partial<Record<string, { readonly?: boolean; required?: boolean; hidden?: boolean; helperText?: ReactNode }>>;
19
+ };
20
+
21
+ /**
22
+ * Level 2 custom action: schema-driven confirmation modal.
23
+ * Define in CrudListSchema.customActionSchemas keyed by popup menu item id.
24
+ * CrudSchemaListPage will handle the modal and API call automatically.
25
+ */
26
+ export type CrudCustomActionSchema = {
27
+ title: string;
28
+ description: string | ((row: Record<string, unknown>) => string);
29
+ confirmLabel?: string;
30
+ cancelLabel?: string;
31
+ /** API endpoint — string or function receiving row id. */
32
+ apiPath: string | ((id: string) => string);
33
+ method?: "POST" | "PATCH" | "PUT";
34
+ };
35
+
36
+ export type CrudListSchema = {
37
+ title: string;
38
+ subtitle?: string;
39
+ apiPath: string;
40
+ columns: Record<string, unknown>;
41
+ popupMenuItems: CrudPopupMenuItem[];
42
+ addButtonLabel: string;
43
+ /** Enable column sorting. Defaults to true. */
44
+ showSort?: boolean;
45
+ /** Enable search bar. Defaults to true. */
46
+ showSearch?: boolean;
47
+ /**
48
+ * Level 2: schema-driven confirmation modals for custom popup actions.
49
+ * Keyed by popup menu item id. Actions not listed here fall through to onCustomAction (Level 1).
50
+ */
51
+ customActionSchemas?: Record<string, CrudCustomActionSchema>;
52
+ };
53
+
54
+ export type CrudDeleteSchema<TData extends Record<string, unknown>> = {
55
+ /** Omit to use i18n default ("Konfirmasi Hapus"). */
56
+ title?: string;
57
+ /** Omit to use i18n default ("Data berikut akan dihapus permanen."). */
58
+ description?: string;
59
+ summary: Array<{ label: string; key: keyof TData | string; format?: (value: unknown, row: TData) => ReactNode }>;
60
+ /** Omit to use i18n default ("Hapus"). */
61
+ confirmLabel?: string;
62
+ /** Omit to use i18n default ("Batal"). */
63
+ cancelLabel?: string;
64
+ };
@@ -0,0 +1,239 @@
1
+ // BFF helper for `<ServerDataTable>` from `@isi-ui7/data-table`.
2
+ //
3
+ // Backends should expose cursor (keyset) pagination — performance stays flat
4
+ // at any depth and stays stable under concurrent inserts. Each row in the
5
+ // response carries an opaque `_cursor` field which the client echoes back
6
+ // via topData/bottomData to walk next/prev/last.
7
+ //
8
+ // Backend contract:
9
+ //
10
+ // GET /v1/<resource>
11
+ // ?limit=N
12
+ // &cursor=<opaque> (omit on first page)
13
+ // &dir=next|prev|last (defaults to next)
14
+ // &sort_by=col&sort_dir=asc|desc
15
+ // &q=<search>
16
+ // &<forwardFilters...>
17
+ //
18
+ // Response: { data: [{ ..., _cursor }], meta: { has_next, has_prev, limit } }
19
+ //
20
+ // Typical usage in a Next.js route handler:
21
+ //
22
+ // export async function POST(req: NextRequest) {
23
+ // return handleCursorTable(req, {
24
+ // backendUrl: process.env.BACKEND_URL ?? 'http://localhost:8091',
25
+ // resourcePath: '/v1/pejabat-cabang',
26
+ // forwardFilters: ['include_deleted', 'kode_cabang'],
27
+ // columnTypes: { kode_cabang: 'String', ... },
28
+ // altSortColumns: ['kode_cabang', 'nama_pejabat'],
29
+ // altSearchColumns: ['kode_cabang', 'nama_pejabat'],
30
+ // });
31
+ // }
32
+
33
+ import { NextResponse } from 'next/server';
34
+ import { exchangeUserToken } from '../auth7/token-exchange';
35
+
36
+ /** Minimal subset of NextRequest used by cursor-adapter. Accepting this interface
37
+ * instead of the concrete NextRequest prevents brand-marker conflicts when the
38
+ * consumer app resolves next/ from a different pnpm instance than this library. */
39
+ interface NextRequestLike {
40
+ cookies: { get(name: string): { value: string } | undefined };
41
+ nextUrl: { searchParams: URLSearchParams };
42
+ json(): Promise<unknown>;
43
+ }
44
+
45
+ export type CursorRequest = {
46
+ reqType?: 'next' | 'prev' | 'first' | 'last' | 'apply';
47
+ searchText?: string;
48
+ sortColumn?: { columnName: string; ascending?: boolean };
49
+ topData?: Record<string, unknown>;
50
+ bottomData?: Record<string, unknown>;
51
+ pageSize?: number;
52
+ };
53
+
54
+ /** Backend row shape. Carries an opaque `_cursor` used for next/prev anchors. */
55
+ export type BackendRow = Record<string, unknown> & {
56
+ id?: string;
57
+ _cursor?: string;
58
+ };
59
+
60
+ export interface CursorAdapterOptions {
61
+ /** Absolute base URL of the backend (e.g. `http://localhost:8091`). */
62
+ backendUrl: string;
63
+ /** Resource path on the backend, with leading slash (e.g. `/v1/pejabat-cabang`). */
64
+ resourcePath: string;
65
+ /**
66
+ * Query-string filter keys forwarded verbatim from the *route's URL* into
67
+ * the backend query. Used for page-level filters set via
68
+ * `apiPath="/api/x?status=ACTIVE"` so they survive the POST.
69
+ */
70
+ forwardFilters?: string[];
71
+ /** Column-name → ServerDataTable column type map. */
72
+ columnTypes: Record<string, string>;
73
+ /** Whitelist of columns the user may sort by. */
74
+ altSortColumns: string[];
75
+ /** Whitelist of columns the search input matches against. */
76
+ altSearchColumns: string[];
77
+ /** Cookie name carrying the user JWT. Defaults to `access_token`. */
78
+ accessTokenCookie?: string;
79
+ /**
80
+ * If set, exchange the user token for a delegated token for this audience before
81
+ * calling the backend. Required when the backend mounts RequireDelegatedAuth().
82
+ */
83
+ audience?: string;
84
+ }
85
+
86
+ function getAccessToken(req: NextRequestLike, cookieName: string): string | null {
87
+ return req.cookies.get(cookieName)?.value ?? null;
88
+ }
89
+
90
+ interface BackendCursorResponse {
91
+ data: BackendRow[];
92
+ meta?: {
93
+ has_next?: boolean;
94
+ has_prev?: boolean;
95
+ limit?: number;
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Translate a `<ServerDataTable>` POST cursor body into a backend cursor GET.
101
+ * Reads `bottomData._cursor` / `topData._cursor` to anchor next/prev requests,
102
+ * forwards sort + search + page-level filters, returns the table envelope.
103
+ */
104
+ export async function handleCursorTable(
105
+ req: NextRequestLike,
106
+ opts: CursorAdapterOptions,
107
+ ): Promise<NextResponse> {
108
+ const cookieName = opts.accessTokenCookie ?? 'access_token';
109
+ const accessToken = getAccessToken(req, cookieName);
110
+ if (!accessToken) {
111
+ return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
112
+ }
113
+
114
+ // P6 rollout: exchange for delegated token if audience is set.
115
+ let bearerToken = accessToken;
116
+ if (opts.audience) {
117
+ try {
118
+ const exchanged = await exchangeUserToken(accessToken, opts.audience, undefined, { failClosed: true });
119
+ bearerToken = exchanged.accessToken;
120
+ } catch {
121
+ return NextResponse.json({ error: 'token_exchange_failed', status: 502 }, { status: 502 });
122
+ }
123
+ }
124
+
125
+ let body: CursorRequest;
126
+ try {
127
+ body = (await req.json()) as CursorRequest;
128
+ } catch {
129
+ return NextResponse.json({ error: 'invalid_json' }, { status: 400 });
130
+ }
131
+
132
+ const pageSize = body.pageSize ?? 20;
133
+
134
+ // Resolve cursor + direction from the cursor-row already present in the
135
+ // table. ServerDataTable echoes whichever row was at the visible top/bottom.
136
+ let cursor = '';
137
+ let dir: 'next' | 'prev' | 'last' = 'next';
138
+ if (body.reqType === 'next' && body.bottomData?._cursor) {
139
+ cursor = String(body.bottomData._cursor);
140
+ dir = 'next';
141
+ } else if (body.reqType === 'prev' && body.topData?._cursor) {
142
+ cursor = String(body.topData._cursor);
143
+ dir = 'prev';
144
+ } else if (body.reqType === 'last') {
145
+ dir = 'last'; // backend returns the tail; no anchor needed
146
+ }
147
+ // reqType 'first' / 'apply' / undefined → no cursor, dir=next (= "from start")
148
+
149
+ const externalQS = req.nextUrl.searchParams;
150
+
151
+ const query = new URLSearchParams();
152
+ query.set('limit', String(pageSize));
153
+ if (cursor) query.set('cursor', cursor);
154
+ query.set('dir', dir);
155
+ if (body.searchText) query.set('q', body.searchText);
156
+ if (body.sortColumn?.columnName) {
157
+ query.set('sort_by', body.sortColumn.columnName);
158
+ query.set('sort_dir', body.sortColumn.ascending !== false ? 'asc' : 'desc');
159
+ }
160
+ for (const k of opts.forwardFilters ?? []) {
161
+ const v = externalQS.get(k);
162
+ if (v) query.set(k, v);
163
+ }
164
+
165
+ let backendRes: Response;
166
+ try {
167
+ backendRes = await fetch(`${opts.backendUrl}${opts.resourcePath}?${query.toString()}`, {
168
+ headers: { Authorization: `Bearer ${bearerToken}` },
169
+ });
170
+ } catch (e) {
171
+ return NextResponse.json({ error: String(e) }, { status: 502 });
172
+ }
173
+ if (!backendRes.ok) {
174
+ const text = await backendRes.text();
175
+ return NextResponse.json({ error: text }, { status: backendRes.status });
176
+ }
177
+
178
+ const json = (await backendRes.json()) as BackendCursorResponse;
179
+ const data = json.data ?? [];
180
+
181
+ return NextResponse.json({
182
+ data,
183
+ columnTypes: opts.columnTypes,
184
+ sortColumnName: body.sortColumn?.columnName,
185
+ sortAscending: body.sortColumn?.ascending ?? true,
186
+ altSortColumns: opts.altSortColumns,
187
+ altSearchColumns: opts.altSearchColumns,
188
+ allowNext: Boolean(json.meta?.has_next),
189
+ allowPrev: Boolean(json.meta?.has_prev),
190
+ });
191
+ }
192
+
193
+ /**
194
+ * Forward a backend GET verbatim. Useful for modal lookups and debug calls
195
+ * that don't need cursor translation.
196
+ */
197
+ export async function proxyBackendGet(
198
+ req: NextRequestLike,
199
+ opts: {
200
+ backendUrl: string;
201
+ resourcePath: string;
202
+ accessTokenCookie?: string;
203
+ audience?: string;
204
+ },
205
+ ): Promise<NextResponse> {
206
+ const cookieName = opts.accessTokenCookie ?? 'access_token';
207
+ const accessToken = getAccessToken(req, cookieName);
208
+ if (!accessToken) {
209
+ return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
210
+ }
211
+
212
+ // P6 rollout: exchange for delegated token if audience is set.
213
+ let bearerToken = accessToken;
214
+ if (opts.audience) {
215
+ try {
216
+ const exchanged = await exchangeUserToken(accessToken, opts.audience, undefined, { failClosed: true });
217
+ bearerToken = exchanged.accessToken;
218
+ } catch {
219
+ return NextResponse.json({ error: 'token_exchange_failed', status: 502 }, { status: 502 });
220
+ }
221
+ }
222
+
223
+ const qs = req.nextUrl.searchParams.toString();
224
+ const url = `${opts.backendUrl}${opts.resourcePath}${qs ? '?' + qs : ''}`;
225
+
226
+ try {
227
+ const res = await fetch(url, { headers: { Authorization: `Bearer ${bearerToken}` } });
228
+ const body = await res.text();
229
+ return new NextResponse(body, {
230
+ status: res.status,
231
+ headers: { 'Content-Type': res.headers.get('content-type') ?? 'application/json' },
232
+ });
233
+ } catch (e: unknown) {
234
+ return NextResponse.json(
235
+ { error: 'backend_unreachable', message: String(e) },
236
+ { status: 502 },
237
+ );
238
+ }
239
+ }
@@ -0,0 +1,90 @@
1
+ // lib/event-bus/index.ts — Event bus layer for bos7-template
2
+
3
+ import { createClient, NatsClient, Message, MessageHandler } from '../nats/client';
4
+
5
+ export interface EventBusConfig {
6
+ url: string;
7
+ name?: string;
8
+ }
9
+
10
+ export interface DomainEvent {
11
+ eventType: string;
12
+ subject: string;
13
+ occurredAt: Date;
14
+ id: string;
15
+ payload: Record<string, unknown>;
16
+ }
17
+
18
+ export type EventHandler = (event: DomainEvent) => void | Promise<void>;
19
+
20
+ export interface EventBus {
21
+ publish(event: DomainEvent): Promise<void>;
22
+ subscribe(eventType: string, handler: EventHandler): Promise<void>;
23
+ unsubscribe(eventType: string): void;
24
+ }
25
+
26
+ class NatsEventBus implements EventBus {
27
+ private client: NatsClient;
28
+ private handlers: Map<string, MessageHandler> = new Map();
29
+ private subjects: Map<string, () => void> = new Map();
30
+
31
+ constructor(client: NatsClient) {
32
+ this.client = client;
33
+ }
34
+
35
+ async publish(event: DomainEvent): Promise<void> {
36
+ const subject = `notifications.${event.subject}`;
37
+ const data = JSON.stringify(event);
38
+
39
+ await this.client.publish(subject, data);
40
+ console.log('Event published:', event.eventType, 'to', subject);
41
+ }
42
+
43
+ async subscribe(eventType: string, handler: EventHandler): Promise<void> {
44
+ const subject = `notifications.*.${eventType}`;
45
+
46
+ const msgHandler: MessageHandler = (msg: Message) => {
47
+ if (msg.data) {
48
+ const eventData = typeof msg.data === 'string'
49
+ ? JSON.parse(msg.data)
50
+ : JSON.parse(new TextDecoder().decode(msg.data));
51
+ handler(eventData as DomainEvent);
52
+ }
53
+ };
54
+
55
+ const sub = await this.client.subscribe(subject, msgHandler);
56
+ this.handlers.set(eventType, msgHandler);
57
+ this.subjects.set(eventType, sub.unsubscribe);
58
+
59
+ console.log('Subscribed to:', subject);
60
+ }
61
+
62
+ unsubscribe(eventType: string): void {
63
+ const unsub = this.subjects.get(eventType);
64
+ if (unsub) {
65
+ unsub();
66
+ this.handlers.delete(eventType);
67
+ this.subjects.delete(eventType);
68
+ }
69
+ }
70
+ }
71
+
72
+ let globalBus: EventBus | null = null;
73
+
74
+ export async function createEventBus(config: EventBusConfig): Promise<EventBus> {
75
+ const client = await createClient({
76
+ url: config.url,
77
+ name: config.name ?? 'bos7-event-bus',
78
+ });
79
+
80
+ globalBus = new NatsEventBus(client);
81
+ return globalBus;
82
+ }
83
+
84
+ export function getEventBus(): EventBus | null {
85
+ return globalBus;
86
+ }
87
+
88
+ export function useEventBus(): EventBus | null {
89
+ return globalBus;
90
+ }
@@ -0,0 +1,93 @@
1
+ // lib/event-bus/useEventBus.ts — React hook for event bus
2
+
3
+ 'use client';
4
+
5
+ import { useEffect, useState, useCallback } from 'react';
6
+ import { getEventBus, type EventBus, type DomainEvent } from './index';
7
+
8
+ export interface UseEventBusOptions {
9
+ autoConnect?: boolean;
10
+ url?: string;
11
+ }
12
+
13
+ export function useEventBus() {
14
+ const [bus, setBus] = useState<EventBus | null>(null);
15
+ const [isConnected, setIsConnected] = useState(false);
16
+ const [error, setError] = useState<Error | null>(null);
17
+
18
+ useEffect(() => {
19
+ const eventBus = getEventBus();
20
+ if (eventBus) {
21
+ setBus(eventBus);
22
+ setIsConnected(true);
23
+ }
24
+ }, []);
25
+
26
+ const subscribe = useCallback(async (eventType: string, handler: (event: DomainEvent) => void) => {
27
+ if (!bus) {
28
+ console.warn('Event bus not initialized');
29
+ return;
30
+ }
31
+
32
+ try {
33
+ await bus.subscribe(eventType, handler);
34
+ } catch (err) {
35
+ setError(err as Error);
36
+ }
37
+ }, [bus]);
38
+
39
+ const unsubscribe = useCallback((eventType: string) => {
40
+ if (!bus) return;
41
+ bus.unsubscribe(eventType);
42
+ }, [bus]);
43
+
44
+ const publish = useCallback(async (event: DomainEvent) => {
45
+ if (!bus) {
46
+ console.warn('Event bus not initialized');
47
+ return;
48
+ }
49
+
50
+ try {
51
+ await bus.publish(event);
52
+ } catch (err) {
53
+ setError(err as Error);
54
+ }
55
+ }, [bus]);
56
+
57
+ return {
58
+ isConnected,
59
+ error,
60
+ subscribe,
61
+ unsubscribe,
62
+ publish,
63
+ };
64
+ }
65
+
66
+ export interface UseNotificationEventsOptions {
67
+ onNotification?: (event: DomainEvent) => void;
68
+ }
69
+
70
+ export function useNotificationEvents(options: UseNotificationEventsOptions = {}) {
71
+ const { onNotification } = options;
72
+ const [notifications, setNotifications] = useState<DomainEvent[]>([]);
73
+
74
+ const handleNotification = useCallback((event: DomainEvent) => {
75
+ setNotifications((prev) => [event, ...prev]);
76
+ onNotification?.(event);
77
+ }, [onNotification]);
78
+
79
+ const { isConnected, subscribe, unsubscribe } = useEventBus();
80
+
81
+ useEffect(() => {
82
+ if (isConnected) {
83
+ subscribe('notification', handleNotification);
84
+ return () => unsubscribe('notification');
85
+ }
86
+ }, [isConnected, subscribe, unsubscribe, handleNotification]);
87
+
88
+ return {
89
+ notifications,
90
+ isConnected,
91
+ clearNotifications: () => setNotifications([]),
92
+ };
93
+ }
@@ -0,0 +1 @@
1
+ export * from './event-bus/index';
package/src/export.ts ADDED
@@ -0,0 +1,62 @@
1
+ // lib/export.ts — utility export Excel dan PDF untuk halaman laporan
2
+
3
+ import * as XLSX from 'xlsx';
4
+
5
+ export interface ExportColumn {
6
+ field: string;
7
+ title: string;
8
+ displayFormat?: string;
9
+ }
10
+
11
+ function cellValue(val: unknown, format?: string): unknown {
12
+ if (val === null || val === undefined) return '';
13
+ if (format === 'currency') return typeof val === 'number' ? val : Number(val) || 0;
14
+ if (format === 'date') return typeof val === 'string' ? val.slice(0, 10) : val;
15
+ return val;
16
+ }
17
+
18
+ export function exportToExcel<T extends Record<string, unknown>>(
19
+ data: T[],
20
+ columns: ExportColumn[],
21
+ sheetName: string
22
+ ): void {
23
+ const headers = columns.map((c) => c.title);
24
+ const rows = data.map((row) =>
25
+ columns.map((c) => cellValue(row[c.field], c.displayFormat))
26
+ );
27
+
28
+ const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]);
29
+ const wb = XLSX.utils.book_new();
30
+ XLSX.utils.book_append_sheet(wb, ws, sheetName.slice(0, 31));
31
+ XLSX.writeFile(wb, `${sheetName}.xlsx`);
32
+ }
33
+
34
+ export async function exportToPDF<T extends Record<string, unknown>>(
35
+ data: T[],
36
+ columns: ExportColumn[],
37
+ title: string
38
+ ): Promise<void> {
39
+ // Dynamic import — jsPDF is large, only load on demand
40
+ const { default: jsPDF } = await import('jspdf');
41
+ const { default: autoTable } = await import('jspdf-autotable');
42
+
43
+ const doc = new jsPDF({ orientation: 'landscape' });
44
+ doc.setFontSize(14);
45
+ doc.text(title, 14, 15);
46
+ doc.setFontSize(10);
47
+
48
+ autoTable(doc, {
49
+ head: [columns.map((c) => c.title)],
50
+ body: data.map((row) =>
51
+ columns.map((c) => {
52
+ const val = cellValue(row[c.field], c.displayFormat);
53
+ return String(val);
54
+ })
55
+ ),
56
+ startY: 22,
57
+ styles: { fontSize: 8 },
58
+ headStyles: { fillColor: [31, 97, 254] }, // Carbon blue
59
+ });
60
+
61
+ doc.save(`${title}.pdf`);
62
+ }
@@ -0,0 +1,52 @@
1
+ /* Form contract — Carbon overrides scoped to .ui7-form-contract
2
+ Token source of truth: UI7_FORM_VISUAL_TOKENS in style-contract.ts */
3
+
4
+ /* Label: 12px / 500 / 16px */
5
+ .ui7-form-contract .cds--label {
6
+ margin-block-end: 0;
7
+ font-family: inherit;
8
+ font-size: 0.75rem;
9
+ font-weight: 500;
10
+ line-height: 1rem;
11
+ }
12
+
13
+ /* Input body: 14px / 400 / 20px */
14
+ .ui7-form-contract .cds--text-input,
15
+ .ui7-form-contract .cds--text-area,
16
+ .ui7-form-contract .cds--select-input,
17
+ .ui7-form-contract .cds--date-picker__input {
18
+ font-family: inherit;
19
+ font-size: 0.875rem;
20
+ font-weight: 400;
21
+ line-height: 1.25rem;
22
+ }
23
+
24
+ /* Full-width date picker */
25
+ .ui7-form-contract .cds--date-picker,
26
+ .ui7-form-contract .cds--date-picker-container {
27
+ width: 100%;
28
+ }
29
+
30
+ /* ── Mobile: collapse all form panels to single column ───────────────────── */
31
+ /* Desktop-first: multi-column span values set via inline style are overridden
32
+ below Carbon's md breakpoint (671px). !important required to beat inline style. */
33
+ @media (max-width: 671px) {
34
+ .ui7-form-contract .ui7-form-row > .ui7-form-column {
35
+ grid-column: 1 / -1 !important;
36
+ }
37
+ }
38
+
39
+ /* ── Page layout — mobile adjustments ────────────────────────────────────── */
40
+ /* page__header: stack title + actions vertically on small screens */
41
+ @media (max-width: 671px) {
42
+ .page__header {
43
+ flex-direction: column;
44
+ align-items: flex-start;
45
+ gap: 0.5rem;
46
+ }
47
+
48
+ .page__actions {
49
+ width: 100%;
50
+ justify-content: flex-end;
51
+ }
52
+ }
@@ -0,0 +1,90 @@
1
+ "use client";
2
+
3
+ import type { CSSProperties, ReactNode } from "react";
4
+
5
+ /**
6
+ * Horizontal label + input row layout for forms. Carbon DS ships only the
7
+ * vertical (stacked) `<Form>` pattern, so this is the shared "label-left,
8
+ * input-right" primitive every bos7-* app should use to keep modals compact
9
+ * and align with the existing master-data screens.
10
+ *
11
+ * Pattern:
12
+ * <HorizontalField label="Kode Cabang" required>
13
+ * <TextInput id="..." labelText="" ... />
14
+ * </HorizontalField>
15
+ *
16
+ * The child input should have its own `labelText` set to an empty string
17
+ * (Carbon inputs render a top label by default; here the label lives in the
18
+ * row's left column instead).
19
+ *
20
+ * Width tuning: pass `labelWidth` to override the default. For tightly
21
+ * grouped forms (e.g. modal `size="sm"`), 8rem usually fits; for descriptive
22
+ * labels in `size="lg"` modals 10–12rem reads better.
23
+ */
24
+ export interface HorizontalFieldProps {
25
+ label: string;
26
+ /** Marks the row as required and renders a red asterisk next to the label. */
27
+ required?: boolean;
28
+ /** Helper text shown below the input (small, muted). */
29
+ helperText?: string;
30
+ /** Width of the label column. Defaults to 9.5rem. */
31
+ labelWidth?: string;
32
+ /** Horizontal gap between label and input. Defaults to 1rem. */
33
+ gap?: string;
34
+ /** Vertical spacing below the row. Defaults to 0.5rem. */
35
+ marginBlockEnd?: string;
36
+ /** Vertical alignment of the label relative to the input. Defaults to center. */
37
+ align?: "center" | "top";
38
+ className?: string;
39
+ children: ReactNode;
40
+ }
41
+
42
+ export function HorizontalField({
43
+ label,
44
+ required,
45
+ helperText,
46
+ labelWidth = "9.5rem",
47
+ gap = "1rem",
48
+ marginBlockEnd = "0.5rem",
49
+ align = "center",
50
+ className,
51
+ children,
52
+ }: HorizontalFieldProps) {
53
+ const rowStyle: CSSProperties = {
54
+ display: "grid",
55
+ gridTemplateColumns: `${labelWidth} 1fr`,
56
+ columnGap: gap,
57
+ alignItems: align === "top" ? "start" : "center",
58
+ marginBlockEnd,
59
+ };
60
+
61
+ const labelStyle: CSSProperties = {
62
+ fontSize: "0.875rem",
63
+ lineHeight: "1.25rem",
64
+ color: "var(--cds-text-secondary, #525252)",
65
+ paddingBlockStart: align === "top" ? "0.4375rem" : 0,
66
+ };
67
+
68
+ return (
69
+ <div className={className} style={rowStyle}>
70
+ <div style={labelStyle}>
71
+ {label}
72
+ {required ? <span style={{ color: "var(--cds-support-error, #da1e28)" }}> *</span> : null}
73
+ </div>
74
+ <div>
75
+ {children}
76
+ {helperText ? (
77
+ <div
78
+ style={{
79
+ fontSize: "0.75rem",
80
+ color: "var(--cds-text-helper, #6f6f6f)",
81
+ marginBlockStart: "0.25rem",
82
+ }}
83
+ >
84
+ {helperText}
85
+ </div>
86
+ ) : null}
87
+ </div>
88
+ </div>
89
+ );
90
+ }