rbac-manager 0.0.0-stage → 1.0.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/.github/workflows/publish.yml +29 -0
- package/README.md +317 -2
- package/package.json +45 -4
- package/scripts/postbuild.js +17 -0
- package/src/config/rbac.config.ts +206 -0
- package/src/constants/actions.constants.ts +21 -0
- package/src/constants/apiKeys.constants.ts +25 -0
- package/src/constants/auth.constants.ts +39 -0
- package/src/constants/endpoints.constants.ts +66 -0
- package/src/constants/i18n.constants.ts +82 -0
- package/src/constants/index.ts +70 -0
- package/src/constants/masters.constants.ts +68 -0
- package/src/constants/permissions/index.ts +44 -0
- package/src/constants/permissions/masterPermissions.constants.ts +44 -0
- package/src/constants/permissions/rolePermissions.constants.ts +18 -0
- package/src/constants/permissions.constants.ts +34 -0
- package/src/constants/roles.constants.ts +34 -0
- package/src/core/adapters/authAdapter.ts +89 -0
- package/src/core/adapters/index.ts +17 -0
- package/src/core/adapters/mastersAdapter.ts +132 -0
- package/src/core/adapters/permissionsAdapter.ts +90 -0
- package/src/core/adapters/rolesAdapter.ts +115 -0
- package/src/core/api/httpClient.ts +296 -0
- package/src/core/context/PermissionContext.tsx +232 -0
- package/src/core/context/RBACConfigContext.tsx +52 -0
- package/src/core/guards/Can.tsx +98 -0
- package/src/core/guards/RouteGuard.tsx +42 -0
- package/src/core/hooks/useMaster.ts +99 -0
- package/src/core/hooks/usePermissions.ts +114 -0
- package/src/core/hooks/useRoles.ts +120 -0
- package/src/core/types/adapter.types.ts +54 -0
- package/src/core/types/auth.types.ts +44 -0
- package/src/core/types/index.ts +7 -0
- package/src/core/types/masters.types.ts +63 -0
- package/src/core/types/pagination.types.ts +37 -0
- package/src/core/types/rbac.types.ts +69 -0
- package/src/core/types/router.types.ts +43 -0
- package/src/core/types/style.types.ts +53 -0
- package/src/core/utils/permissionResolver.ts +44 -0
- package/src/index.ts +44 -0
- package/src/modules/masters/DepartmentMasterView.tsx +126 -0
- package/src/modules/masters/DesignationMasterView.tsx +97 -0
- package/src/modules/masters/EmployeeMasterView.tsx +107 -0
- package/src/modules/masters/EntityMasterView.tsx +102 -0
- package/src/modules/masters/ModuleMasterView.tsx +104 -0
- package/src/modules/masters/SubModuleMasterView.tsx +106 -0
- package/src/modules/masters/components/MasterDataTable.tsx +684 -0
- package/src/modules/masters/index.ts +7 -0
- package/src/modules/roles-and-permissions/components/PermissionMatrix.tsx +289 -0
- package/src/modules/roles-and-permissions/components/PermissionRegistry.tsx +108 -0
- package/src/modules/roles-and-permissions/components/RoleListTable.tsx +568 -0
- package/src/modules/roles-and-permissions/components/RoleModal.tsx +213 -0
- package/src/modules/roles-and-permissions/index.ts +4 -0
- package/src/shared/components/Badge.tsx +24 -0
- package/src/shared/components/Button.tsx +75 -0
- package/src/shared/components/DynamicSidebar.tsx +181 -0
- package/src/shared/components/Input.tsx +48 -0
- package/src/shared/components/Modal.tsx +70 -0
- package/src/shared/components/Table.tsx +345 -0
- package/src/styles/standalone.css +51 -0
- package/src/theme/ThemeContext.tsx +74 -0
- package/src/theme/defaultTheme.ts +81 -0
- package/src/theme/index.ts +3 -0
- package/src/theme/theme.types.ts +106 -0
- package/tsconfig.cjs.json +10 -0
- package/tsconfig.esm.json +10 -0
- package/tsconfig.json +28 -0
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
|
2
|
+
import { Table, Column, TableProps, BatchAction } from '../../../shared/components/Table';
|
|
3
|
+
import { Button } from '../../../shared/components/Button';
|
|
4
|
+
import { Modal } from '../../../shared/components/Modal';
|
|
5
|
+
import { Input } from '../../../shared/components/Input';
|
|
6
|
+
import { Can } from '../../../core/guards/Can';
|
|
7
|
+
import { useRBACConfig } from '../../../core/context/RBACConfigContext';
|
|
8
|
+
import { isActionAllowed, FeatureToggle } from '../../../config/rbac.config';
|
|
9
|
+
import { MasterDataTableClassNames } from '../../../core/types/style.types';
|
|
10
|
+
import { RBAC_CONSTANTS } from '../../../constants';
|
|
11
|
+
import { extractFieldErrors } from '../../../core/api/httpClient';
|
|
12
|
+
|
|
13
|
+
export interface SelectOption {
|
|
14
|
+
label: string;
|
|
15
|
+
value: string | number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface FormField {
|
|
19
|
+
name: string;
|
|
20
|
+
label: string;
|
|
21
|
+
type?: 'text' | 'number' | 'email' | 'select' | 'checkbox' | 'textarea';
|
|
22
|
+
placeholder?: string;
|
|
23
|
+
required?: boolean;
|
|
24
|
+
defaultValue?: any;
|
|
25
|
+
// Cascading & Dynamic options support
|
|
26
|
+
options?: SelectOption[] | ((formData: Record<string, any>) => SelectOption[]);
|
|
27
|
+
dependsOn?: string[]; // e.g. ['entityId'] - re-evaluates when entityId changes
|
|
28
|
+
disabled?: boolean | ((formData: Record<string, any>) => boolean);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CustomAction<T> {
|
|
32
|
+
key: string;
|
|
33
|
+
label: string;
|
|
34
|
+
icon?: React.ReactNode;
|
|
35
|
+
variant?: 'primary' | 'secondary' | 'danger' | 'outline' | 'ghost';
|
|
36
|
+
permissionKey?: string;
|
|
37
|
+
onClick: (item: T) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface MasterDataTableProps<T extends { id: string | number; [key: string]: any }> {
|
|
41
|
+
title: string;
|
|
42
|
+
description?: string;
|
|
43
|
+
permissionPrefix: string; // e.g. "department_master", "entity_master"
|
|
44
|
+
featureKey?: string; // maps to config.features.masters[featureKey] (e.g. "department")
|
|
45
|
+
|
|
46
|
+
// Granular Action Permissions (Dynamic Toggle)
|
|
47
|
+
allowAdd?: boolean;
|
|
48
|
+
allowEdit?: boolean;
|
|
49
|
+
allowDelete?: boolean;
|
|
50
|
+
allowSearch?: boolean;
|
|
51
|
+
|
|
52
|
+
// Custom permission key overrides (e.g. if backend uses 'dept.create')
|
|
53
|
+
permissionKeys?: {
|
|
54
|
+
view?: string;
|
|
55
|
+
add?: string;
|
|
56
|
+
edit?: string;
|
|
57
|
+
delete?: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Dynamic Column Customization
|
|
61
|
+
columns?: Column<T>[]; // Overrides entire default columns
|
|
62
|
+
extendColumns?: Column<T>[]; // Appends additional columns to defaults
|
|
63
|
+
omitColumns?: string[]; // Hides specific columns by key
|
|
64
|
+
|
|
65
|
+
// Dynamic Form Customization
|
|
66
|
+
formFields?: FormField[]; // Overrides entire default form fields
|
|
67
|
+
extendFormFields?: FormField[]; // Appends additional form fields
|
|
68
|
+
omitFormFields?: string[]; // Removes specific form fields
|
|
69
|
+
|
|
70
|
+
// Custom Row Actions
|
|
71
|
+
customActions?: CustomAction<T>[];
|
|
72
|
+
|
|
73
|
+
// Sorting Customization (Opt-in)
|
|
74
|
+
defaultSortBy?: string;
|
|
75
|
+
defaultSortOrder?: 'asc' | 'desc';
|
|
76
|
+
|
|
77
|
+
// Bulk Selection & Batch Actions (Opt-in)
|
|
78
|
+
selectable?: boolean;
|
|
79
|
+
batchActions?: BatchAction<T>[];
|
|
80
|
+
|
|
81
|
+
// URL Query Sync (Deep linking - Opt-in)
|
|
82
|
+
syncWithUrl?: boolean;
|
|
83
|
+
|
|
84
|
+
// Complete Style & ClassName Overrides
|
|
85
|
+
classNames?: MasterDataTableClassNames;
|
|
86
|
+
unstyled?: boolean; // Strips all default styling if true
|
|
87
|
+
|
|
88
|
+
// Slot / Render Props Overrides (Optional custom UI replacement)
|
|
89
|
+
renderHeader?: (props: {
|
|
90
|
+
title: string;
|
|
91
|
+
description?: string;
|
|
92
|
+
canAdd: boolean;
|
|
93
|
+
onOpenCreate: () => void;
|
|
94
|
+
}) => React.ReactNode;
|
|
95
|
+
renderSearch?: (props: {
|
|
96
|
+
search: string;
|
|
97
|
+
onSearchChange: (value: string) => void;
|
|
98
|
+
placeholder: string;
|
|
99
|
+
}) => React.ReactNode;
|
|
100
|
+
renderTable?: (props: TableProps<T>) => React.ReactNode;
|
|
101
|
+
|
|
102
|
+
// Hook provider
|
|
103
|
+
useMasterHook: () => {
|
|
104
|
+
items: T[];
|
|
105
|
+
total: number;
|
|
106
|
+
page: number;
|
|
107
|
+
limit: number;
|
|
108
|
+
isLoading: boolean;
|
|
109
|
+
fetchItems: (params?: any) => Promise<any>;
|
|
110
|
+
createItem: (payload: any) => Promise<any>;
|
|
111
|
+
editItem: (id: string | number, payload: any) => Promise<any>;
|
|
112
|
+
deleteItem: (id: string | number) => Promise<any>;
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function MasterDataTable<T extends { id: string | number; [key: string]: any }>({
|
|
117
|
+
title,
|
|
118
|
+
description,
|
|
119
|
+
permissionPrefix,
|
|
120
|
+
featureKey,
|
|
121
|
+
allowAdd: propAllowAdd,
|
|
122
|
+
allowEdit: propAllowEdit,
|
|
123
|
+
allowDelete: propAllowDelete,
|
|
124
|
+
allowSearch = true,
|
|
125
|
+
permissionKeys,
|
|
126
|
+
columns: defaultOrCustomColumns,
|
|
127
|
+
extendColumns = [],
|
|
128
|
+
omitColumns = [],
|
|
129
|
+
formFields: defaultOrCustomFields,
|
|
130
|
+
extendFormFields = [],
|
|
131
|
+
omitFormFields = [],
|
|
132
|
+
customActions = [],
|
|
133
|
+
defaultSortBy,
|
|
134
|
+
defaultSortOrder,
|
|
135
|
+
selectable = false,
|
|
136
|
+
batchActions,
|
|
137
|
+
syncWithUrl = false,
|
|
138
|
+
classNames,
|
|
139
|
+
unstyled = false,
|
|
140
|
+
renderHeader,
|
|
141
|
+
renderSearch,
|
|
142
|
+
renderTable,
|
|
143
|
+
useMasterHook,
|
|
144
|
+
}: MasterDataTableProps<T>) {
|
|
145
|
+
const { config } = useRBACConfig();
|
|
146
|
+
const i18n = RBAC_CONSTANTS.i18n;
|
|
147
|
+
const { items, total, page, limit, isLoading, fetchItems, createItem, editItem, deleteItem } = useMasterHook();
|
|
148
|
+
|
|
149
|
+
// URL query sync initialization (if syncWithUrl is active)
|
|
150
|
+
const initialUrlParams = useMemo(() => {
|
|
151
|
+
if (!syncWithUrl || typeof window === 'undefined') return {};
|
|
152
|
+
const sp = new URLSearchParams(window.location.search);
|
|
153
|
+
return {
|
|
154
|
+
search: sp.get('search') || '',
|
|
155
|
+
sortBy: sp.get('sortBy') || defaultSortBy,
|
|
156
|
+
sortOrder: (sp.get('sortOrder') as 'asc' | 'desc') || defaultSortOrder,
|
|
157
|
+
page: sp.get('page') ? Number(sp.get('page')) : undefined,
|
|
158
|
+
};
|
|
159
|
+
}, [syncWithUrl, defaultSortBy, defaultSortOrder]);
|
|
160
|
+
|
|
161
|
+
const [search, setSearch] = useState<string>(initialUrlParams.search || '');
|
|
162
|
+
const [sortBy, setSortBy] = useState<string | undefined>(initialUrlParams.sortBy);
|
|
163
|
+
const [sortOrder, setSortOrder] = useState<'asc' | 'desc' | undefined>(initialUrlParams.sortOrder);
|
|
164
|
+
const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]);
|
|
165
|
+
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
|
166
|
+
const [editingItem, setEditingItem] = useState<T | null>(null);
|
|
167
|
+
const [deleteTarget, setDeleteTarget] = useState<T | null>(null);
|
|
168
|
+
const [formData, setFormData] = useState<Record<string, any>>({});
|
|
169
|
+
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
|
170
|
+
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
|
171
|
+
const [error, setError] = useState<string | null>(null);
|
|
172
|
+
|
|
173
|
+
// Synchronize state with URL query parameters when enabled
|
|
174
|
+
const updateUrlParams = useCallback(
|
|
175
|
+
(params: Record<string, string | number | undefined>) => {
|
|
176
|
+
if (!syncWithUrl || typeof window === 'undefined') return;
|
|
177
|
+
const url = new URL(window.location.href);
|
|
178
|
+
Object.entries(params).forEach(([k, v]) => {
|
|
179
|
+
if (v !== undefined && v !== '' && v !== null) {
|
|
180
|
+
url.searchParams.set(k, String(v));
|
|
181
|
+
} else {
|
|
182
|
+
url.searchParams.delete(k);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
window.history.replaceState({}, '', url.toString());
|
|
186
|
+
},
|
|
187
|
+
[syncWithUrl]
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
// Resolve whether Add, Edit, Delete are permitted based on props OR global config
|
|
191
|
+
const effectiveModuleKey = featureKey || permissionPrefix;
|
|
192
|
+
const masterConfig: FeatureToggle | undefined =
|
|
193
|
+
featureKey && config.features.masters[featureKey] !== undefined
|
|
194
|
+
? config.features.masters[featureKey]
|
|
195
|
+
: config.features.masters[permissionPrefix];
|
|
196
|
+
|
|
197
|
+
const canAdd = propAllowAdd !== undefined ? propAllowAdd : isActionAllowed(masterConfig, 'add');
|
|
198
|
+
const canEdit = propAllowEdit !== undefined ? propAllowEdit : isActionAllowed(masterConfig, 'edit');
|
|
199
|
+
const canDelete = propAllowDelete !== undefined ? propAllowDelete : isActionAllowed(masterConfig, 'delete');
|
|
200
|
+
|
|
201
|
+
// Compute active columns
|
|
202
|
+
const computedColumns = useMemo<Column<T>[]>(() => {
|
|
203
|
+
let baseCols = defaultOrCustomColumns || [];
|
|
204
|
+
if (omitColumns.length > 0) {
|
|
205
|
+
baseCols = baseCols.filter((col) => !omitColumns.includes(col.key));
|
|
206
|
+
}
|
|
207
|
+
const combined = [...baseCols, ...extendColumns];
|
|
208
|
+
|
|
209
|
+
// Only append Actions column if at least one action is enabled
|
|
210
|
+
const hasActions = canEdit || canDelete || customActions.length > 0;
|
|
211
|
+
if (!hasActions) return combined;
|
|
212
|
+
|
|
213
|
+
return [
|
|
214
|
+
...combined,
|
|
215
|
+
{
|
|
216
|
+
key: '_actions',
|
|
217
|
+
header: i18n.actions || 'Actions',
|
|
218
|
+
align: 'right',
|
|
219
|
+
render: (item: T) => (
|
|
220
|
+
<div className="flex items-center justify-end gap-1">
|
|
221
|
+
{customActions.map((action) => {
|
|
222
|
+
const btn = (
|
|
223
|
+
<Button
|
|
224
|
+
key={action.key}
|
|
225
|
+
variant={action.variant || 'ghost'}
|
|
226
|
+
size="sm"
|
|
227
|
+
onClick={() => action.onClick(item)}
|
|
228
|
+
>
|
|
229
|
+
{action.icon} {action.label}
|
|
230
|
+
</Button>
|
|
231
|
+
);
|
|
232
|
+
return action.permissionKey ? (
|
|
233
|
+
<Can key={action.key} do={action.permissionKey}>
|
|
234
|
+
{btn}
|
|
235
|
+
</Can>
|
|
236
|
+
) : (
|
|
237
|
+
btn
|
|
238
|
+
);
|
|
239
|
+
})}
|
|
240
|
+
|
|
241
|
+
{canEdit && (
|
|
242
|
+
<Can
|
|
243
|
+
do={permissionKeys?.edit}
|
|
244
|
+
module={!permissionKeys?.edit ? effectiveModuleKey : undefined}
|
|
245
|
+
action={!permissionKeys?.edit ? 'edit' : undefined}
|
|
246
|
+
>
|
|
247
|
+
<Button
|
|
248
|
+
variant="ghost"
|
|
249
|
+
size="sm"
|
|
250
|
+
onClick={() => {
|
|
251
|
+
setEditingItem(item);
|
|
252
|
+
setIsModalOpen(true);
|
|
253
|
+
}}
|
|
254
|
+
>
|
|
255
|
+
{i18n.edit || 'Edit'}
|
|
256
|
+
</Button>
|
|
257
|
+
</Can>
|
|
258
|
+
)}
|
|
259
|
+
|
|
260
|
+
{canDelete && (
|
|
261
|
+
<Can
|
|
262
|
+
do={permissionKeys?.delete}
|
|
263
|
+
module={!permissionKeys?.delete ? effectiveModuleKey : undefined}
|
|
264
|
+
action={!permissionKeys?.delete ? 'delete' : undefined}
|
|
265
|
+
>
|
|
266
|
+
<Button
|
|
267
|
+
variant="ghost"
|
|
268
|
+
size="sm"
|
|
269
|
+
className="text-red-600 hover:bg-red-50"
|
|
270
|
+
onClick={() => setDeleteTarget(item)}
|
|
271
|
+
>
|
|
272
|
+
{i18n.delete || 'Delete'}
|
|
273
|
+
</Button>
|
|
274
|
+
</Can>
|
|
275
|
+
)}
|
|
276
|
+
</div>
|
|
277
|
+
),
|
|
278
|
+
},
|
|
279
|
+
];
|
|
280
|
+
}, [
|
|
281
|
+
defaultOrCustomColumns,
|
|
282
|
+
omitColumns,
|
|
283
|
+
extendColumns,
|
|
284
|
+
canEdit,
|
|
285
|
+
canDelete,
|
|
286
|
+
customActions,
|
|
287
|
+
permissionKeys,
|
|
288
|
+
effectiveModuleKey,
|
|
289
|
+
i18n,
|
|
290
|
+
]);
|
|
291
|
+
|
|
292
|
+
// Compute active form fields
|
|
293
|
+
const computedFormFields = useMemo<FormField[]>(() => {
|
|
294
|
+
let baseFields = defaultOrCustomFields || [];
|
|
295
|
+
if (omitFormFields.length > 0) {
|
|
296
|
+
baseFields = baseFields.filter((f) => !omitFormFields.includes(f.name));
|
|
297
|
+
}
|
|
298
|
+
return [...baseFields, ...extendFormFields];
|
|
299
|
+
}, [defaultOrCustomFields, omitFormFields, extendFormFields]);
|
|
300
|
+
|
|
301
|
+
// Fetch trigger on search or sort change
|
|
302
|
+
useEffect(() => {
|
|
303
|
+
const fetchParams: Record<string, any> = {
|
|
304
|
+
page: initialUrlParams.page || 1,
|
|
305
|
+
limit: 10,
|
|
306
|
+
search,
|
|
307
|
+
};
|
|
308
|
+
if (sortBy) fetchParams.sortBy = sortBy;
|
|
309
|
+
if (sortOrder) fetchParams.sortOrder = sortOrder;
|
|
310
|
+
|
|
311
|
+
fetchItems(fetchParams);
|
|
312
|
+
updateUrlParams({
|
|
313
|
+
search,
|
|
314
|
+
sortBy,
|
|
315
|
+
sortOrder,
|
|
316
|
+
page: initialUrlParams.page || 1,
|
|
317
|
+
});
|
|
318
|
+
}, [fetchItems, search, sortBy, sortOrder, initialUrlParams.page, updateUrlParams]);
|
|
319
|
+
|
|
320
|
+
// Reset form data & field errors when modal opens or closes
|
|
321
|
+
useEffect(() => {
|
|
322
|
+
if (editingItem) {
|
|
323
|
+
const initial: Record<string, any> = {};
|
|
324
|
+
computedFormFields.forEach((f) => {
|
|
325
|
+
initial[f.name] = editingItem[f.name] ?? f.defaultValue ?? '';
|
|
326
|
+
});
|
|
327
|
+
setFormData(initial);
|
|
328
|
+
} else {
|
|
329
|
+
const initial: Record<string, any> = {};
|
|
330
|
+
computedFormFields.forEach((f) => {
|
|
331
|
+
initial[f.name] = f.defaultValue ?? (f.type === 'checkbox' ? true : '');
|
|
332
|
+
});
|
|
333
|
+
setFormData(initial);
|
|
334
|
+
}
|
|
335
|
+
setError(null);
|
|
336
|
+
setFieldErrors({});
|
|
337
|
+
}, [editingItem, computedFormFields, isModalOpen]);
|
|
338
|
+
|
|
339
|
+
const handleFormFieldChange = (fieldName: string, val: any) => {
|
|
340
|
+
setFormData((prev) => ({ ...prev, [fieldName]: val }));
|
|
341
|
+
// Clear field-level error on edit
|
|
342
|
+
if (fieldErrors[fieldName]) {
|
|
343
|
+
setFieldErrors((prev) => {
|
|
344
|
+
const next = { ...prev };
|
|
345
|
+
delete next[fieldName];
|
|
346
|
+
return next;
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
352
|
+
e.preventDefault();
|
|
353
|
+
setIsSubmitting(true);
|
|
354
|
+
setError(null);
|
|
355
|
+
setFieldErrors({});
|
|
356
|
+
try {
|
|
357
|
+
if (editingItem) {
|
|
358
|
+
await editItem(editingItem.id, formData);
|
|
359
|
+
if (config.onNotification) {
|
|
360
|
+
config.onNotification({
|
|
361
|
+
type: 'success',
|
|
362
|
+
title: i18n.success || 'Success',
|
|
363
|
+
message: `${title} updated successfully`,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
} else {
|
|
367
|
+
await createItem(formData);
|
|
368
|
+
if (config.onNotification) {
|
|
369
|
+
config.onNotification({
|
|
370
|
+
type: 'success',
|
|
371
|
+
title: i18n.success || 'Success',
|
|
372
|
+
message: `${title} created successfully`,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
setIsModalOpen(false);
|
|
377
|
+
} catch (err: any) {
|
|
378
|
+
const extracted = err.fieldErrors || extractFieldErrors(err.data);
|
|
379
|
+
if (extracted) {
|
|
380
|
+
setFieldErrors(extracted);
|
|
381
|
+
}
|
|
382
|
+
const msg = err.message || 'Operation failed';
|
|
383
|
+
setError(msg);
|
|
384
|
+
if (config.onNotification) {
|
|
385
|
+
config.onNotification({
|
|
386
|
+
type: 'error',
|
|
387
|
+
title: i18n.error || 'Error',
|
|
388
|
+
message: msg,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
} finally {
|
|
392
|
+
setIsSubmitting(false);
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
const handleDelete = async () => {
|
|
397
|
+
if (!deleteTarget) return;
|
|
398
|
+
try {
|
|
399
|
+
await deleteItem(deleteTarget.id);
|
|
400
|
+
if (config.onNotification) {
|
|
401
|
+
config.onNotification({
|
|
402
|
+
type: 'success',
|
|
403
|
+
title: i18n.success || 'Success',
|
|
404
|
+
message: `${title} deleted successfully`,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
setDeleteTarget(null);
|
|
408
|
+
// Remove from selectedIds if selected
|
|
409
|
+
setSelectedIds((prev) => prev.filter((id) => id !== deleteTarget.id));
|
|
410
|
+
} catch (err: any) {
|
|
411
|
+
const msg = err.message || 'Delete failed';
|
|
412
|
+
if (config.onNotification) {
|
|
413
|
+
config.onNotification({
|
|
414
|
+
type: 'error',
|
|
415
|
+
title: i18n.error || 'Error',
|
|
416
|
+
message: msg,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
const handleBatchDelete = async (ids: (string | number)[]) => {
|
|
423
|
+
if (!window.confirm(`Are you sure you want to delete ${ids.length} items?`)) return;
|
|
424
|
+
try {
|
|
425
|
+
await Promise.all(ids.map((id) => deleteItem(id)));
|
|
426
|
+
if (config.onNotification) {
|
|
427
|
+
config.onNotification({
|
|
428
|
+
type: 'success',
|
|
429
|
+
title: i18n.success || 'Success',
|
|
430
|
+
message: `${ids.length} items deleted successfully`,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
setSelectedIds([]);
|
|
434
|
+
} catch (err: any) {
|
|
435
|
+
if (config.onNotification) {
|
|
436
|
+
config.onNotification({
|
|
437
|
+
type: 'error',
|
|
438
|
+
title: i18n.error || 'Error',
|
|
439
|
+
message: err.message || 'Batch delete failed',
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
const effectiveBatchActions: BatchAction<T>[] = useMemo(() => {
|
|
446
|
+
if (batchActions) return batchActions;
|
|
447
|
+
if (canDelete) {
|
|
448
|
+
return [
|
|
449
|
+
{
|
|
450
|
+
key: 'batch-delete',
|
|
451
|
+
label: i18n.delete || 'Delete',
|
|
452
|
+
variant: 'danger',
|
|
453
|
+
onClick: handleBatchDelete,
|
|
454
|
+
},
|
|
455
|
+
];
|
|
456
|
+
}
|
|
457
|
+
return [];
|
|
458
|
+
}, [batchActions, canDelete, i18n.delete]);
|
|
459
|
+
|
|
460
|
+
const tableProps: TableProps<T> = {
|
|
461
|
+
columns: computedColumns,
|
|
462
|
+
data: items,
|
|
463
|
+
isLoading,
|
|
464
|
+
classNames: classNames?.table,
|
|
465
|
+
unstyled,
|
|
466
|
+
sortBy,
|
|
467
|
+
sortOrder,
|
|
468
|
+
onSortChange: (newSortBy, newSortOrder) => {
|
|
469
|
+
setSortBy(newSortBy);
|
|
470
|
+
setSortOrder(newSortOrder);
|
|
471
|
+
},
|
|
472
|
+
selectable,
|
|
473
|
+
selectedIds,
|
|
474
|
+
onSelectRow: (id) => {
|
|
475
|
+
setSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
|
476
|
+
},
|
|
477
|
+
onSelectAll: (checked) => {
|
|
478
|
+
setSelectedIds(checked ? items.map((i) => i.id) : []);
|
|
479
|
+
},
|
|
480
|
+
batchActions: effectiveBatchActions,
|
|
481
|
+
pagination: {
|
|
482
|
+
page,
|
|
483
|
+
limit,
|
|
484
|
+
total,
|
|
485
|
+
onPageChange: (newPage) => {
|
|
486
|
+
fetchItems({ page: newPage, limit, search, sortBy, sortOrder });
|
|
487
|
+
updateUrlParams({ page: newPage });
|
|
488
|
+
},
|
|
489
|
+
},
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
return (
|
|
493
|
+
<div className={classNames?.root || (unstyled ? '' : 'space-y-4')}>
|
|
494
|
+
{/* Top Header */}
|
|
495
|
+
{renderHeader ? (
|
|
496
|
+
renderHeader({
|
|
497
|
+
title,
|
|
498
|
+
description,
|
|
499
|
+
canAdd,
|
|
500
|
+
onOpenCreate: () => {
|
|
501
|
+
setEditingItem(null);
|
|
502
|
+
setIsModalOpen(true);
|
|
503
|
+
},
|
|
504
|
+
})
|
|
505
|
+
) : (
|
|
506
|
+
<div
|
|
507
|
+
className={
|
|
508
|
+
classNames?.header?.container ||
|
|
509
|
+
(unstyled ? '' : 'flex flex-col sm:flex-row sm:items-center justify-between gap-4')
|
|
510
|
+
}
|
|
511
|
+
>
|
|
512
|
+
<div>
|
|
513
|
+
<h2 className={classNames?.header?.title || (unstyled ? '' : 'text-xl font-bold text-gray-900')}>
|
|
514
|
+
{title}
|
|
515
|
+
</h2>
|
|
516
|
+
{description && (
|
|
517
|
+
<p className={classNames?.header?.description || (unstyled ? '' : 'text-xs text-gray-500 mt-1')}>
|
|
518
|
+
{description}
|
|
519
|
+
</p>
|
|
520
|
+
)}
|
|
521
|
+
</div>
|
|
522
|
+
|
|
523
|
+
{canAdd && (
|
|
524
|
+
<Can
|
|
525
|
+
do={permissionKeys?.add}
|
|
526
|
+
module={!permissionKeys?.add ? effectiveModuleKey : undefined}
|
|
527
|
+
action={!permissionKeys?.add ? 'add' : undefined}
|
|
528
|
+
>
|
|
529
|
+
<Button
|
|
530
|
+
variant="primary"
|
|
531
|
+
className={classNames?.header?.addButton}
|
|
532
|
+
onClick={() => {
|
|
533
|
+
setEditingItem(null);
|
|
534
|
+
setIsModalOpen(true);
|
|
535
|
+
}}
|
|
536
|
+
>
|
|
537
|
+
+ {i18n.create || 'Add New'}
|
|
538
|
+
</Button>
|
|
539
|
+
</Can>
|
|
540
|
+
)}
|
|
541
|
+
</div>
|
|
542
|
+
)}
|
|
543
|
+
|
|
544
|
+
{/* Search Input */}
|
|
545
|
+
{allowSearch &&
|
|
546
|
+
(renderSearch ? (
|
|
547
|
+
renderSearch({
|
|
548
|
+
search,
|
|
549
|
+
onSearchChange: setSearch,
|
|
550
|
+
placeholder: `${i18n.search || 'Search'} ${title.toLowerCase()}...`,
|
|
551
|
+
})
|
|
552
|
+
) : (
|
|
553
|
+
<div className={classNames?.search?.container || (unstyled ? '' : 'flex items-center gap-3')}>
|
|
554
|
+
<input
|
|
555
|
+
type="text"
|
|
556
|
+
placeholder={`${i18n.search || 'Search'} ${title.toLowerCase()}...`}
|
|
557
|
+
value={search}
|
|
558
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
559
|
+
className={
|
|
560
|
+
classNames?.search?.input ||
|
|
561
|
+
(unstyled
|
|
562
|
+
? ''
|
|
563
|
+
: 'w-full sm:w-72 px-3.5 py-2 border border-gray-300 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-[#28518F]')
|
|
564
|
+
}
|
|
565
|
+
/>
|
|
566
|
+
</div>
|
|
567
|
+
))}
|
|
568
|
+
|
|
569
|
+
{/* Table: Slot or Default Table */}
|
|
570
|
+
{renderTable ? renderTable(tableProps) : <Table {...tableProps} />}
|
|
571
|
+
|
|
572
|
+
{/* Form Modal */}
|
|
573
|
+
<Modal
|
|
574
|
+
isOpen={isModalOpen}
|
|
575
|
+
onClose={() => setIsModalOpen(false)}
|
|
576
|
+
title={editingItem ? `${i18n.edit || 'Edit'} ${title}` : `${i18n.create || 'Create'} ${title}`}
|
|
577
|
+
>
|
|
578
|
+
<form onSubmit={handleSubmit} className="space-y-3.5">
|
|
579
|
+
{error && <div className="p-3 bg-red-50 border border-red-200 text-red-600 rounded-lg text-xs">{error}</div>}
|
|
580
|
+
|
|
581
|
+
{computedFormFields.map((field) => {
|
|
582
|
+
const isFieldDisabled =
|
|
583
|
+
typeof field.disabled === 'function' ? field.disabled(formData) : Boolean(field.disabled);
|
|
584
|
+
const fieldError = fieldErrors[field.name];
|
|
585
|
+
|
|
586
|
+
if (field.type === 'select') {
|
|
587
|
+
// Resolve static or dynamic cascading options
|
|
588
|
+
const resolvedOptions =
|
|
589
|
+
typeof field.options === 'function' ? field.options(formData) : field.options || [];
|
|
590
|
+
|
|
591
|
+
return (
|
|
592
|
+
<div key={field.name}>
|
|
593
|
+
<label className="block text-xs font-medium text-gray-700 mb-1">
|
|
594
|
+
{field.label} {field.required && '*'}
|
|
595
|
+
</label>
|
|
596
|
+
<select
|
|
597
|
+
value={formData[field.name] || ''}
|
|
598
|
+
disabled={isFieldDisabled}
|
|
599
|
+
onChange={(e) => handleFormFieldChange(field.name, e.target.value)}
|
|
600
|
+
className={`w-full px-3 py-2 border rounded-lg text-sm bg-white focus:outline-none focus:ring-2 disabled:opacity-50 ${
|
|
601
|
+
fieldError ? 'border-red-500 focus:ring-red-500' : 'border-gray-300 focus:ring-[#28518F]'
|
|
602
|
+
}`}
|
|
603
|
+
required={field.required}
|
|
604
|
+
>
|
|
605
|
+
<option value="">Select {field.label}</option>
|
|
606
|
+
{resolvedOptions.map((opt) => (
|
|
607
|
+
<option key={opt.value} value={opt.value}>
|
|
608
|
+
{opt.label}
|
|
609
|
+
</option>
|
|
610
|
+
))}
|
|
611
|
+
</select>
|
|
612
|
+
{fieldError && <p className="mt-1 text-xs text-red-600">{fieldError}</p>}
|
|
613
|
+
</div>
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (field.type === 'checkbox') {
|
|
618
|
+
return (
|
|
619
|
+
<div key={field.name} className="flex items-center gap-2 pt-1">
|
|
620
|
+
<input
|
|
621
|
+
type="checkbox"
|
|
622
|
+
id={`field-${field.name}`}
|
|
623
|
+
disabled={isFieldDisabled}
|
|
624
|
+
checked={Boolean(formData[field.name])}
|
|
625
|
+
onChange={(e) => handleFormFieldChange(field.name, e.target.checked)}
|
|
626
|
+
className="h-4 w-4 rounded border-gray-300 text-[#28518F] focus:ring-[#28518F] disabled:opacity-50 cursor-pointer"
|
|
627
|
+
/>
|
|
628
|
+
<label htmlFor={`field-${field.name}`} className="text-sm text-gray-700 cursor-pointer">
|
|
629
|
+
{field.label}
|
|
630
|
+
</label>
|
|
631
|
+
</div>
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
return (
|
|
636
|
+
<Input
|
|
637
|
+
key={field.name}
|
|
638
|
+
label={`${field.label} ${field.required ? '*' : ''}`}
|
|
639
|
+
type={field.type || 'text'}
|
|
640
|
+
placeholder={field.placeholder}
|
|
641
|
+
disabled={isFieldDisabled}
|
|
642
|
+
value={formData[field.name] || ''}
|
|
643
|
+
error={fieldError}
|
|
644
|
+
onChange={(e) => handleFormFieldChange(field.name, e.target.value)}
|
|
645
|
+
required={field.required}
|
|
646
|
+
/>
|
|
647
|
+
);
|
|
648
|
+
})}
|
|
649
|
+
|
|
650
|
+
<div className="flex justify-end gap-2 pt-3 border-t border-gray-100">
|
|
651
|
+
<Button type="button" variant="outline" onClick={() => setIsModalOpen(false)}>
|
|
652
|
+
{i18n.cancel || 'Cancel'}
|
|
653
|
+
</Button>
|
|
654
|
+
<Button type="submit" variant="primary" isLoading={isSubmitting}>
|
|
655
|
+
{editingItem ? i18n.saveChanges || 'Save Changes' : i18n.create || 'Create'}
|
|
656
|
+
</Button>
|
|
657
|
+
</div>
|
|
658
|
+
</form>
|
|
659
|
+
</Modal>
|
|
660
|
+
|
|
661
|
+
{/* Delete Confirmation Modal */}
|
|
662
|
+
<Modal
|
|
663
|
+
isOpen={Boolean(deleteTarget)}
|
|
664
|
+
onClose={() => setDeleteTarget(null)}
|
|
665
|
+
title={`${i18n.delete || 'Delete'} ${title}`}
|
|
666
|
+
maxWidth="sm"
|
|
667
|
+
>
|
|
668
|
+
<div className="space-y-3">
|
|
669
|
+
<p className="text-sm text-gray-600">
|
|
670
|
+
{i18n.confirmDelete || 'Are you sure you want to delete this record? This action cannot be undone.'}
|
|
671
|
+
</p>
|
|
672
|
+
<div className="flex justify-end gap-2 pt-2">
|
|
673
|
+
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
|
|
674
|
+
{i18n.cancel || 'Cancel'}
|
|
675
|
+
</Button>
|
|
676
|
+
<Button variant="danger" onClick={handleDelete}>
|
|
677
|
+
{i18n.confirm || 'Confirm Delete'}
|
|
678
|
+
</Button>
|
|
679
|
+
</div>
|
|
680
|
+
</div>
|
|
681
|
+
</Modal>
|
|
682
|
+
</div>
|
|
683
|
+
);
|
|
684
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export * from './components/MasterDataTable';
|
|
2
|
+
export * from './EntityMasterView';
|
|
3
|
+
export * from './DepartmentMasterView';
|
|
4
|
+
export * from './DesignationMasterView';
|
|
5
|
+
export * from './EmployeeMasterView';
|
|
6
|
+
export * from './ModuleMasterView';
|
|
7
|
+
export * from './SubModuleMasterView';
|