@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,132 @@
|
|
|
1
|
+
export type Ui7FormDensity = "compact" | "comfortable";
|
|
2
|
+
|
|
3
|
+
export type Ui7ThemeDensity = Ui7FormDensity | "normal";
|
|
4
|
+
|
|
5
|
+
export interface Ui7FormVisualTokens {
|
|
6
|
+
labelSpacing: "6px";
|
|
7
|
+
labelFontSize: "12px";
|
|
8
|
+
labelFontWeight: 500;
|
|
9
|
+
labelLineHeight: "16px";
|
|
10
|
+
inputFontSize: "14px";
|
|
11
|
+
inputFontWeight: 400;
|
|
12
|
+
inputLineHeight: "20px";
|
|
13
|
+
controlHeight: "40px";
|
|
14
|
+
textareaMinHeight: "88px";
|
|
15
|
+
rowGap: "16px";
|
|
16
|
+
sectionGap: "24px";
|
|
17
|
+
columnGap: "16px";
|
|
18
|
+
readonlyBackground: "#f4f4f4";
|
|
19
|
+
readonlyCursor: "default";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const UI7_FORM_VISUAL_TOKENS: Ui7FormVisualTokens = {
|
|
23
|
+
labelSpacing: "6px",
|
|
24
|
+
labelFontSize: "12px",
|
|
25
|
+
labelFontWeight: 500,
|
|
26
|
+
labelLineHeight: "16px",
|
|
27
|
+
inputFontSize: "14px",
|
|
28
|
+
inputFontWeight: 400,
|
|
29
|
+
inputLineHeight: "20px",
|
|
30
|
+
controlHeight: "40px",
|
|
31
|
+
textareaMinHeight: "88px",
|
|
32
|
+
rowGap: "16px",
|
|
33
|
+
sectionGap: "24px",
|
|
34
|
+
columnGap: "16px",
|
|
35
|
+
readonlyBackground: "#f4f4f4",
|
|
36
|
+
readonlyCursor: "default"
|
|
37
|
+
} as const;
|
|
38
|
+
|
|
39
|
+
export const UI7_FORM_VISUAL_CSS_VARS = {
|
|
40
|
+
labelSpacing: "--ui7-form-label-gap",
|
|
41
|
+
labelFontSize: "--ui7-form-label-font-size",
|
|
42
|
+
labelFontWeight: "--ui7-form-label-font-weight",
|
|
43
|
+
labelLineHeight: "--ui7-form-label-line-height",
|
|
44
|
+
inputFontSize: "--ui7-form-input-font-size",
|
|
45
|
+
inputFontWeight: "--ui7-form-input-font-weight",
|
|
46
|
+
inputLineHeight: "--ui7-form-input-line-height",
|
|
47
|
+
controlHeight: "--ui7-form-control-height",
|
|
48
|
+
textareaMinHeight: "--ui7-form-textarea-min-height",
|
|
49
|
+
rowGap: "--ui7-form-row-gap",
|
|
50
|
+
sectionGap: "--ui7-form-section-gap",
|
|
51
|
+
columnGap: "--ui7-form-column-gap",
|
|
52
|
+
density: "--ui7-form-density",
|
|
53
|
+
readonlyBackground: "--ui7-form-readonly-background",
|
|
54
|
+
readonlyCursor: "--ui7-form-readonly-cursor"
|
|
55
|
+
} as const;
|
|
56
|
+
|
|
57
|
+
export const UI7_FORM_VISUAL_CLASSNAMES = {
|
|
58
|
+
scope: "ui7-form-contract",
|
|
59
|
+
densityCompact: "ui7-form-contract--density-compact",
|
|
60
|
+
densityComfortable: "ui7-form-contract--density-comfortable",
|
|
61
|
+
readonly: "ui7-form-contract--readonly",
|
|
62
|
+
row: "ui7-form-row",
|
|
63
|
+
section: "ui7-form-section",
|
|
64
|
+
column: "ui7-form-column",
|
|
65
|
+
label: "ui7-form-label",
|
|
66
|
+
labelRequired: "ui7-form-label__required",
|
|
67
|
+
control: "ui7-form-control",
|
|
68
|
+
controlReadonly: "ui7-form-control--readonly",
|
|
69
|
+
helper: "ui7-form-helper",
|
|
70
|
+
readonlyValue: "ui7-form-readonly-value"
|
|
71
|
+
} as const;
|
|
72
|
+
|
|
73
|
+
// ── Density tokens ─────────────────────────────────────────────────────────────
|
|
74
|
+
// Applied as inline styles so no external CSS is required.
|
|
75
|
+
|
|
76
|
+
export const UI7_FORM_DENSITY_TOKENS = {
|
|
77
|
+
compact: {
|
|
78
|
+
controlHeight: "2rem",
|
|
79
|
+
rowGap: "0.75rem",
|
|
80
|
+
sectionGap: "1rem",
|
|
81
|
+
columnGap: "0.75rem",
|
|
82
|
+
},
|
|
83
|
+
comfortable: {
|
|
84
|
+
controlHeight: "2.5rem",
|
|
85
|
+
rowGap: "1rem",
|
|
86
|
+
sectionGap: "1.5rem",
|
|
87
|
+
columnGap: "1rem",
|
|
88
|
+
},
|
|
89
|
+
} as const;
|
|
90
|
+
|
|
91
|
+
export function normalizeUi7FormDensity(
|
|
92
|
+
density?: Ui7ThemeDensity | null,
|
|
93
|
+
fallback: Ui7FormDensity = "compact"
|
|
94
|
+
): Ui7FormDensity {
|
|
95
|
+
if (density === "compact") return "compact";
|
|
96
|
+
if (density === "comfortable") return "comfortable";
|
|
97
|
+
if (density === "normal") return "comfortable";
|
|
98
|
+
return fallback;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function getUi7FormDensityClassName(
|
|
102
|
+
density?: Ui7ThemeDensity | null,
|
|
103
|
+
fallback: Ui7FormDensity = "compact"
|
|
104
|
+
): string {
|
|
105
|
+
return normalizeUi7FormDensity(density, fallback) === "compact"
|
|
106
|
+
? UI7_FORM_VISUAL_CLASSNAMES.densityCompact
|
|
107
|
+
: UI7_FORM_VISUAL_CLASSNAMES.densityComfortable;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function getUi7FormContractClassName(
|
|
111
|
+
options?: {
|
|
112
|
+
density?: Ui7ThemeDensity | null;
|
|
113
|
+
readonly?: boolean;
|
|
114
|
+
className?: string | null;
|
|
115
|
+
}
|
|
116
|
+
): string {
|
|
117
|
+
const classes: string[] = [UI7_FORM_VISUAL_CLASSNAMES.scope];
|
|
118
|
+
|
|
119
|
+
if (options?.density !== undefined) {
|
|
120
|
+
classes.push(getUi7FormDensityClassName(options.density));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (options?.readonly) {
|
|
124
|
+
classes.push(UI7_FORM_VISUAL_CLASSNAMES.readonly);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (options?.className) {
|
|
128
|
+
classes.push(options.className);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return classes.join(" ");
|
|
132
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export interface QueryParams {
|
|
2
|
+
cursor?: string;
|
|
3
|
+
pageSize?: number;
|
|
4
|
+
sortBy?: string;
|
|
5
|
+
sortDir?: 'asc' | 'desc';
|
|
6
|
+
search?: string;
|
|
7
|
+
filters?: Record<string, unknown>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface QueryResult<T> {
|
|
11
|
+
data: T[];
|
|
12
|
+
nextCursor?: string;
|
|
13
|
+
prevCursor?: string;
|
|
14
|
+
total?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface OffsetPage<T> {
|
|
18
|
+
data: T[];
|
|
19
|
+
total: number;
|
|
20
|
+
page: number;
|
|
21
|
+
per_page: number;
|
|
22
|
+
total_pages: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface OffsetQueryParams {
|
|
26
|
+
page?: string;
|
|
27
|
+
per_page?: string;
|
|
28
|
+
sort_by?: string;
|
|
29
|
+
sort_dir?: 'asc' | 'desc';
|
|
30
|
+
search?: string;
|
|
31
|
+
[key: string]: string | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type ServiceAction = 'get' | 'simpan' | 'hapus';
|
|
35
|
+
|
|
36
|
+
export interface ServiceRequest<T = unknown> {
|
|
37
|
+
payload?: T;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ServiceResponse<T = unknown> {
|
|
41
|
+
ok: boolean;
|
|
42
|
+
data?: T;
|
|
43
|
+
error?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type { Notification, Notif7Raw, NotificationListResponse, UnreadCountResponse } from '../types/notification';
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Frontend-normalized notification (snake_case) — dipakai oleh page dan shell
|
|
2
|
+
export interface Notification {
|
|
3
|
+
id: string;
|
|
4
|
+
user_id: string;
|
|
5
|
+
event_type: string;
|
|
6
|
+
title: string;
|
|
7
|
+
message: string;
|
|
8
|
+
is_read: boolean;
|
|
9
|
+
action_url?: string;
|
|
10
|
+
created_at: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Raw shape dari notif7 API — Go struct tanpa json tag → PascalCase
|
|
14
|
+
export interface Notif7Raw {
|
|
15
|
+
ID: string;
|
|
16
|
+
UserID: string;
|
|
17
|
+
EventType: string;
|
|
18
|
+
Title: string;
|
|
19
|
+
Body: string;
|
|
20
|
+
IsRead: boolean;
|
|
21
|
+
RefURL?: string;
|
|
22
|
+
CreatedAt: string;
|
|
23
|
+
Source?: string;
|
|
24
|
+
Channel?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface NotificationListResponse {
|
|
28
|
+
data: Notification[];
|
|
29
|
+
next_cursor?: string;
|
|
30
|
+
total?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface UnreadCountResponse {
|
|
34
|
+
unread_count: number;
|
|
35
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Workflow-specific backend proxy helpers (Response-based contract).
|
|
2
|
+
|
|
3
|
+
const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:8081';
|
|
4
|
+
|
|
5
|
+
export interface WorkflowBackendRequestOptions {
|
|
6
|
+
query?: URLSearchParams;
|
|
7
|
+
body?: string;
|
|
8
|
+
contentType?: string;
|
|
9
|
+
accessToken?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function request(
|
|
13
|
+
method: string,
|
|
14
|
+
path: string,
|
|
15
|
+
options: WorkflowBackendRequestOptions = {}
|
|
16
|
+
): Promise<Response> {
|
|
17
|
+
const url = options.query
|
|
18
|
+
? `${BACKEND_URL}${path}?${options.query.toString()}`
|
|
19
|
+
: `${BACKEND_URL}${path}`;
|
|
20
|
+
|
|
21
|
+
return fetch(url, {
|
|
22
|
+
method,
|
|
23
|
+
headers: {
|
|
24
|
+
'Content-Type': options.contentType ?? 'application/json',
|
|
25
|
+
...(options.accessToken ? { Authorization: `Bearer ${options.accessToken}` } : {}),
|
|
26
|
+
},
|
|
27
|
+
...(options.body !== undefined ? { body: options.body } : {}),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export { request as backendRequest };
|
|
32
|
+
|
|
33
|
+
// safeProxyJson forwards a backend Response back to the caller as JSON.
|
|
34
|
+
// Uses plain web-standard Response (works in both Node and Edge runtimes,
|
|
35
|
+
// and avoids a runtime dynamic import of `next/server` which fails to resolve
|
|
36
|
+
// from this library's path in Next.js 16).
|
|
37
|
+
export async function safeProxyJson(res: Response): Promise<Response> {
|
|
38
|
+
const text = await res.text();
|
|
39
|
+
let bodyText: string;
|
|
40
|
+
let isError = false;
|
|
41
|
+
try {
|
|
42
|
+
JSON.parse(text);
|
|
43
|
+
bodyText = text;
|
|
44
|
+
} catch {
|
|
45
|
+
bodyText = JSON.stringify({ error: text || 'empty response' });
|
|
46
|
+
isError = true;
|
|
47
|
+
}
|
|
48
|
+
const status = isError && res.status < 400 ? 502 : res.status;
|
|
49
|
+
return new Response(bodyText, {
|
|
50
|
+
status,
|
|
51
|
+
headers: { 'Content-Type': 'application/json' },
|
|
52
|
+
});
|
|
53
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export async function fetchDetailData<T>(url: string): Promise<T> {
|
|
2
|
+
const res = await fetch(url);
|
|
3
|
+
const body = (await res.json().catch(() => ({}))) as { data?: T; error?: string };
|
|
4
|
+
|
|
5
|
+
if (!res.ok) {
|
|
6
|
+
throw new Error(body.error ?? `Gagal memuat data (HTTP ${res.status})`);
|
|
7
|
+
}
|
|
8
|
+
if (!body.data) {
|
|
9
|
+
throw new Error('Gagal memuat data');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return body.data;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function submitWorkflowForm<TPayload>(
|
|
16
|
+
url: string,
|
|
17
|
+
method: 'POST' | 'PUT' | 'PATCH',
|
|
18
|
+
payload: TPayload,
|
|
19
|
+
): Promise<string> {
|
|
20
|
+
const res = await fetch(url, {
|
|
21
|
+
method,
|
|
22
|
+
headers: { 'Content-Type': 'application/json' },
|
|
23
|
+
body: JSON.stringify(payload),
|
|
24
|
+
});
|
|
25
|
+
const body = (await res.json().catch(() => ({}))) as { instance_id?: string; error?: string };
|
|
26
|
+
|
|
27
|
+
if (res.status === 202 && body.instance_id) {
|
|
28
|
+
return body.instance_id;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
throw new Error(body.error ?? `Gagal (HTTP ${res.status})`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function submitWorkflowDelete(url: string): Promise<string> {
|
|
35
|
+
const res = await fetch(url, { method: 'DELETE' });
|
|
36
|
+
const body = (await res.json().catch(() => ({}))) as { instance_id?: string; error?: string };
|
|
37
|
+
|
|
38
|
+
if (res.status === 202 && body.instance_id) {
|
|
39
|
+
return body.instance_id;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
throw new Error(body.error ?? `Gagal (HTTP ${res.status})`);
|
|
43
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Workflow-specific notif7 proxy helpers (Response-based contract).
|
|
2
|
+
|
|
3
|
+
const NOTIF7_URL = process.env.NOTIF7_URL ?? '';
|
|
4
|
+
|
|
5
|
+
export interface WorkflowNotif7RequestOptions {
|
|
6
|
+
accessToken?: string;
|
|
7
|
+
query?: URLSearchParams;
|
|
8
|
+
body?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function notif7Request(
|
|
12
|
+
method: string,
|
|
13
|
+
path: string,
|
|
14
|
+
options: WorkflowNotif7RequestOptions = {}
|
|
15
|
+
): Promise<Response> {
|
|
16
|
+
const url = options.query
|
|
17
|
+
? `${NOTIF7_URL}${path}?${options.query.toString()}`
|
|
18
|
+
: `${NOTIF7_URL}${path}`;
|
|
19
|
+
|
|
20
|
+
return fetch(url, {
|
|
21
|
+
method,
|
|
22
|
+
headers: {
|
|
23
|
+
'Content-Type': 'application/json',
|
|
24
|
+
...(options.accessToken ? { Authorization: `Bearer ${options.accessToken}` } : {}),
|
|
25
|
+
},
|
|
26
|
+
...(options.body !== undefined ? { body: options.body } : {}),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// BFF helpers untuk memulai workflow7 instance dari Next.js API route handlers.
|
|
2
|
+
// Server-side only — jangan import dari client components.
|
|
3
|
+
//
|
|
4
|
+
// POST /wf/instances/start mengambil branchID dari user JWT (bukan body), jadi
|
|
5
|
+
// helper meneruskan user access_token cookie ke workflow7 sebagai Bearer
|
|
6
|
+
// (BUKAN service M2M token).
|
|
7
|
+
//
|
|
8
|
+
// initiator_context.user_id auto-derived dari JWT sub claim — wajib supaya
|
|
9
|
+
// workflow7 EventRecorder dispatch SSE event `instance.completed` balik ke
|
|
10
|
+
// initiator. Kalau initiatedBy kosong → no dispatch → notifikasi silent.
|
|
11
|
+
|
|
12
|
+
const WORKFLOW7_URL =
|
|
13
|
+
process.env.NEXT_PUBLIC_BOS7_WORKFLOW_URL ??
|
|
14
|
+
process.env.WORKFLOW7_URL ??
|
|
15
|
+
'http://localhost:8081';
|
|
16
|
+
|
|
17
|
+
type CookieReqLike = Request & {
|
|
18
|
+
cookies?: { get?: (name: string) => { value?: string } | undefined };
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function decodeJWTSub(token: string): string {
|
|
22
|
+
try {
|
|
23
|
+
const parts = token.split('.');
|
|
24
|
+
if (parts.length < 2) return '';
|
|
25
|
+
const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
26
|
+
const claims = JSON.parse(Buffer.from(payload, 'base64').toString('utf-8')) as { sub?: string };
|
|
27
|
+
return claims.sub ?? '';
|
|
28
|
+
} catch {
|
|
29
|
+
return '';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Read the user access_token cookie from a Next.js API route request. */
|
|
34
|
+
export function getUserAccessToken(req: CookieReqLike | Request): string | undefined {
|
|
35
|
+
const fromHelper = (req as CookieReqLike).cookies?.get?.('access_token')?.value;
|
|
36
|
+
if (fromHelper) return fromHelper;
|
|
37
|
+
const cookieHeader = req.headers.get('cookie') ?? '';
|
|
38
|
+
const match = cookieHeader
|
|
39
|
+
.split(';')
|
|
40
|
+
.find((c) => c.trim().startsWith('access_token='));
|
|
41
|
+
if (!match) return undefined;
|
|
42
|
+
return match.split('=').slice(1).join('=').trim() || undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** UUID idempotency key — used as external_ref and embedded in workflow payload. */
|
|
46
|
+
export function newWfIdempotencyKey(): string {
|
|
47
|
+
return crypto.randomUUID();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type StartWorkflowInput = {
|
|
51
|
+
flowId: string;
|
|
52
|
+
payload: Record<string, unknown>;
|
|
53
|
+
externalRef?: string;
|
|
54
|
+
initiatorContext?: { user_id?: string; branch_id?: string };
|
|
55
|
+
/** User access token dari auth7 — diforward ke workflow7 sebagai Bearer. */
|
|
56
|
+
userAccessToken: string;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type StartWorkflowResult = {
|
|
60
|
+
ok: boolean;
|
|
61
|
+
instanceId?: string;
|
|
62
|
+
status?: string;
|
|
63
|
+
error?: string;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export async function startWorkflow(input: StartWorkflowInput): Promise<StartWorkflowResult> {
|
|
67
|
+
const userID = decodeJWTSub(input.userAccessToken);
|
|
68
|
+
const initiatorContext: Record<string, unknown> = { ...(input.initiatorContext ?? {}) };
|
|
69
|
+
if (!initiatorContext.user_id && userID) {
|
|
70
|
+
initiatorContext.user_id = userID;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const body: Record<string, unknown> = {
|
|
74
|
+
flow_definition_code: input.flowId,
|
|
75
|
+
payload: input.payload,
|
|
76
|
+
};
|
|
77
|
+
if (input.externalRef) body.external_ref = input.externalRef;
|
|
78
|
+
if (Object.keys(initiatorContext).length > 0) {
|
|
79
|
+
body.initiator_context = initiatorContext;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let res: Response;
|
|
83
|
+
try {
|
|
84
|
+
res = await fetch(`${WORKFLOW7_URL}/wf/instances/start`, {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
headers: {
|
|
87
|
+
'Content-Type': 'application/json',
|
|
88
|
+
Authorization: `Bearer ${input.userAccessToken}`,
|
|
89
|
+
},
|
|
90
|
+
body: JSON.stringify(body),
|
|
91
|
+
});
|
|
92
|
+
} catch (e: unknown) {
|
|
93
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
94
|
+
return { ok: false, error: `workflow7 unreachable: ${message}` };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!res.ok) {
|
|
98
|
+
try {
|
|
99
|
+
const errBody = (await res.json()) as { error?: { message?: string; code?: string } };
|
|
100
|
+
const msg = errBody?.error?.message ?? errBody?.error?.code ?? `workflow7 start failed: ${res.status}`;
|
|
101
|
+
return { ok: false, error: msg };
|
|
102
|
+
} catch {
|
|
103
|
+
return { ok: false, error: `workflow7 start failed: ${res.status}` };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// workflow7's FlowInstance struct has no JSON tags → fields marshal as
|
|
108
|
+
// PascalCase (ID, Status). Lowercase variants kept for forward-compat.
|
|
109
|
+
const json = (await res.json()) as {
|
|
110
|
+
data?: { ID?: string; Status?: string; id?: string; status?: string };
|
|
111
|
+
};
|
|
112
|
+
const inst = json?.data;
|
|
113
|
+
return {
|
|
114
|
+
ok: true,
|
|
115
|
+
instanceId: inst?.ID ?? inst?.id,
|
|
116
|
+
status: inst?.Status ?? inst?.status,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type WfStartedBody = {
|
|
121
|
+
instance_id?: string;
|
|
122
|
+
wf_instance_id: string;
|
|
123
|
+
status: string;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Standard 202 envelope for "workflow started" responses.
|
|
128
|
+
* `instanceId` is workflow7's instance UUID (used by SSE RefID match);
|
|
129
|
+
* `idempotencyKey` is the external_ref/wf_instance_id (financing service uses it
|
|
130
|
+
* as upsert idempotency key).
|
|
131
|
+
*/
|
|
132
|
+
export function respondWfStarted(
|
|
133
|
+
result: StartWorkflowResult,
|
|
134
|
+
idempotencyKey: string,
|
|
135
|
+
): Response {
|
|
136
|
+
if (!result.ok) {
|
|
137
|
+
return Response.json(
|
|
138
|
+
{ error: result.error ?? 'workflow_start_failed' },
|
|
139
|
+
{ status: 502 },
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
const body: WfStartedBody = {
|
|
143
|
+
instance_id: result.instanceId,
|
|
144
|
+
wf_instance_id: idempotencyKey,
|
|
145
|
+
status: result.status ?? 'STARTED',
|
|
146
|
+
};
|
|
147
|
+
return Response.json(body, { status: 202 });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Standard 401 envelope when access_token cookie missing. */
|
|
151
|
+
export function respondUnauthorized(): Response {
|
|
152
|
+
return Response.json({ error: 'unauthorized' }, { status: 401 });
|
|
153
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useState } from "react";
|
|
4
|
+
import type { CrudForm, FormMode } from "../form-types";
|
|
5
|
+
import {
|
|
6
|
+
collectAllFields,
|
|
7
|
+
collectPageFields,
|
|
8
|
+
getLayoutPageCount,
|
|
9
|
+
resolveApiPath,
|
|
10
|
+
runFieldValidation,
|
|
11
|
+
} from "../form-renderer-utils";
|
|
12
|
+
import { fetchDetailData, submitWorkflowForm } from "./crud-client";
|
|
13
|
+
import { useBosSharedI18n } from "../i18n";
|
|
14
|
+
|
|
15
|
+
export type UseCrudFormOptions<TData extends Record<string, unknown>> = {
|
|
16
|
+
schema: CrudForm<TData>;
|
|
17
|
+
mode: FormMode;
|
|
18
|
+
/** Route params used for API path interpolation, e.g. { id: "123" }. Memoize to avoid re-loads. */
|
|
19
|
+
params?: Record<string, string>;
|
|
20
|
+
/** Called with workflow instance_id on successful submit. */
|
|
21
|
+
onSuccess?: (instanceId: string) => void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type UseCrudFormReturn<TData extends Record<string, unknown>> = {
|
|
25
|
+
form: TData;
|
|
26
|
+
setForm: (patch: Partial<TData>) => void;
|
|
27
|
+
loading: boolean;
|
|
28
|
+
saving: boolean;
|
|
29
|
+
error: string | null;
|
|
30
|
+
fieldErrors: Partial<Record<keyof TData, string>>;
|
|
31
|
+
isDirty: boolean;
|
|
32
|
+
page: number;
|
|
33
|
+
pageCount: number;
|
|
34
|
+
isLastPage: boolean;
|
|
35
|
+
canGoPrev: boolean;
|
|
36
|
+
goNext: () => Promise<void>;
|
|
37
|
+
goPrev: () => void;
|
|
38
|
+
handleSave: () => Promise<void>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function useCrudForm<TData extends Record<string, unknown>>({
|
|
42
|
+
schema,
|
|
43
|
+
mode,
|
|
44
|
+
params = {},
|
|
45
|
+
onSuccess,
|
|
46
|
+
}: UseCrudFormOptions<TData>): UseCrudFormReturn<TData> {
|
|
47
|
+
const labels = useBosSharedI18n();
|
|
48
|
+
const [form, setFormState] = useState<TData>(schema.emptyData);
|
|
49
|
+
const [initialForm, setInitialForm] = useState<TData>(schema.emptyData);
|
|
50
|
+
const [loading, setLoading] = useState(mode !== "create");
|
|
51
|
+
const [saving, setSaving] = useState(false);
|
|
52
|
+
const [serverError, setServerError] = useState<string | null>(null);
|
|
53
|
+
const [hasValidationError, setHasValidationError] = useState(false);
|
|
54
|
+
const [fieldErrors, setFieldErrors] = useState<Partial<Record<keyof TData, string>>>({});
|
|
55
|
+
const [page, setPage] = useState(0);
|
|
56
|
+
|
|
57
|
+
const pageCount = getLayoutPageCount(schema.layout);
|
|
58
|
+
const isLastPage = page === pageCount - 1;
|
|
59
|
+
|
|
60
|
+
const setForm = (patch: Partial<TData>) =>
|
|
61
|
+
setFormState((prev) => ({ ...prev, ...patch }));
|
|
62
|
+
|
|
63
|
+
// stable key derived from params so useEffect doesn't need params in deps
|
|
64
|
+
const paramsKey = JSON.stringify(params);
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (mode === "create") return;
|
|
68
|
+
if (!schema.loadApiPath) return;
|
|
69
|
+
setLoading(true);
|
|
70
|
+
const url = resolveApiPath(schema.loadApiPath, params);
|
|
71
|
+
fetchDetailData<Partial<TData>>(url)
|
|
72
|
+
.then((row) => {
|
|
73
|
+
const loaded = { ...schema.emptyData, ...row } as TData;
|
|
74
|
+
setFormState(loaded);
|
|
75
|
+
setInitialForm(loaded);
|
|
76
|
+
})
|
|
77
|
+
.catch((e: unknown) => setServerError(e instanceof Error ? e.message : String(e)))
|
|
78
|
+
.finally(() => setLoading(false));
|
|
79
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
80
|
+
}, [mode, paramsKey]);
|
|
81
|
+
|
|
82
|
+
const isDirty = useMemo(
|
|
83
|
+
() => JSON.stringify(form) !== JSON.stringify(initialForm),
|
|
84
|
+
[form, initialForm],
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const validatePage = (pageIndex: number) => {
|
|
88
|
+
return runFieldValidation(collectPageFields(schema.layout, pageIndex), form, mode, labels);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const validateAll = () => {
|
|
92
|
+
const errs = runFieldValidation(collectAllFields(schema.layout), form, mode, labels);
|
|
93
|
+
if (schema.validate) {
|
|
94
|
+
return { ...errs, ...schema.validate(form) };
|
|
95
|
+
}
|
|
96
|
+
return errs;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const goNext = async () => {
|
|
100
|
+
setServerError(null);
|
|
101
|
+
setHasValidationError(false);
|
|
102
|
+
const layout = schema.layout;
|
|
103
|
+
if (layout.type === "wizard" && layout.validateOnNext) {
|
|
104
|
+
const errs = validatePage(page);
|
|
105
|
+
setFieldErrors(errs);
|
|
106
|
+
if (Object.keys(errs).length > 0) return;
|
|
107
|
+
}
|
|
108
|
+
if (layout.type === "wizard") {
|
|
109
|
+
const stepPath = layout.pages[page]?.stepSaveApiPath;
|
|
110
|
+
if (stepPath) {
|
|
111
|
+
setSaving(true);
|
|
112
|
+
try {
|
|
113
|
+
const url = resolveApiPath(stepPath, params);
|
|
114
|
+
await submitWorkflowForm(url, "POST", form);
|
|
115
|
+
} catch (e: unknown) {
|
|
116
|
+
setServerError(e instanceof Error ? e.message : String(e));
|
|
117
|
+
setSaving(false);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
setSaving(false);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
setPage((p) => Math.min(p + 1, pageCount - 1));
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const goPrev = () => {
|
|
127
|
+
setServerError(null);
|
|
128
|
+
setHasValidationError(false);
|
|
129
|
+
setPage((p) => Math.max(p - 1, 0));
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const handleSave = async () => {
|
|
133
|
+
setServerError(null);
|
|
134
|
+
const errs = validateAll();
|
|
135
|
+
setFieldErrors(errs);
|
|
136
|
+
if (Object.keys(errs).length > 0) {
|
|
137
|
+
setHasValidationError(true);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
setHasValidationError(false);
|
|
141
|
+
setSaving(true);
|
|
142
|
+
try {
|
|
143
|
+
let instanceId: string;
|
|
144
|
+
if (mode === "create") {
|
|
145
|
+
const url = resolveApiPath(schema.createApiPath, params);
|
|
146
|
+
instanceId = await submitWorkflowForm(url, "POST", form);
|
|
147
|
+
} else {
|
|
148
|
+
if (!schema.updateApiPath) throw new Error("updateApiPath tidak dikonfigurasi");
|
|
149
|
+
const url = resolveApiPath(schema.updateApiPath, params);
|
|
150
|
+
instanceId = await submitWorkflowForm(url, "PUT", form);
|
|
151
|
+
}
|
|
152
|
+
onSuccess?.(instanceId);
|
|
153
|
+
} catch (e: unknown) {
|
|
154
|
+
setServerError(e instanceof Error ? e.message : String(e));
|
|
155
|
+
} finally {
|
|
156
|
+
setSaving(false);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
form,
|
|
162
|
+
setForm,
|
|
163
|
+
loading,
|
|
164
|
+
saving,
|
|
165
|
+
error: hasValidationError ? labels.validationError : serverError,
|
|
166
|
+
fieldErrors,
|
|
167
|
+
isDirty,
|
|
168
|
+
page,
|
|
169
|
+
pageCount,
|
|
170
|
+
isLastPage,
|
|
171
|
+
canGoPrev: page > 0,
|
|
172
|
+
goNext,
|
|
173
|
+
goPrev,
|
|
174
|
+
handleSave,
|
|
175
|
+
};
|
|
176
|
+
}
|