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.
Files changed (67) hide show
  1. package/.github/workflows/publish.yml +29 -0
  2. package/README.md +317 -2
  3. package/package.json +45 -4
  4. package/scripts/postbuild.js +17 -0
  5. package/src/config/rbac.config.ts +206 -0
  6. package/src/constants/actions.constants.ts +21 -0
  7. package/src/constants/apiKeys.constants.ts +25 -0
  8. package/src/constants/auth.constants.ts +39 -0
  9. package/src/constants/endpoints.constants.ts +66 -0
  10. package/src/constants/i18n.constants.ts +82 -0
  11. package/src/constants/index.ts +70 -0
  12. package/src/constants/masters.constants.ts +68 -0
  13. package/src/constants/permissions/index.ts +44 -0
  14. package/src/constants/permissions/masterPermissions.constants.ts +44 -0
  15. package/src/constants/permissions/rolePermissions.constants.ts +18 -0
  16. package/src/constants/permissions.constants.ts +34 -0
  17. package/src/constants/roles.constants.ts +34 -0
  18. package/src/core/adapters/authAdapter.ts +89 -0
  19. package/src/core/adapters/index.ts +17 -0
  20. package/src/core/adapters/mastersAdapter.ts +132 -0
  21. package/src/core/adapters/permissionsAdapter.ts +90 -0
  22. package/src/core/adapters/rolesAdapter.ts +115 -0
  23. package/src/core/api/httpClient.ts +296 -0
  24. package/src/core/context/PermissionContext.tsx +232 -0
  25. package/src/core/context/RBACConfigContext.tsx +52 -0
  26. package/src/core/guards/Can.tsx +98 -0
  27. package/src/core/guards/RouteGuard.tsx +42 -0
  28. package/src/core/hooks/useMaster.ts +99 -0
  29. package/src/core/hooks/usePermissions.ts +114 -0
  30. package/src/core/hooks/useRoles.ts +120 -0
  31. package/src/core/types/adapter.types.ts +54 -0
  32. package/src/core/types/auth.types.ts +44 -0
  33. package/src/core/types/index.ts +7 -0
  34. package/src/core/types/masters.types.ts +63 -0
  35. package/src/core/types/pagination.types.ts +37 -0
  36. package/src/core/types/rbac.types.ts +69 -0
  37. package/src/core/types/router.types.ts +43 -0
  38. package/src/core/types/style.types.ts +53 -0
  39. package/src/core/utils/permissionResolver.ts +44 -0
  40. package/src/index.ts +44 -0
  41. package/src/modules/masters/DepartmentMasterView.tsx +126 -0
  42. package/src/modules/masters/DesignationMasterView.tsx +97 -0
  43. package/src/modules/masters/EmployeeMasterView.tsx +107 -0
  44. package/src/modules/masters/EntityMasterView.tsx +102 -0
  45. package/src/modules/masters/ModuleMasterView.tsx +104 -0
  46. package/src/modules/masters/SubModuleMasterView.tsx +106 -0
  47. package/src/modules/masters/components/MasterDataTable.tsx +684 -0
  48. package/src/modules/masters/index.ts +7 -0
  49. package/src/modules/roles-and-permissions/components/PermissionMatrix.tsx +289 -0
  50. package/src/modules/roles-and-permissions/components/PermissionRegistry.tsx +108 -0
  51. package/src/modules/roles-and-permissions/components/RoleListTable.tsx +568 -0
  52. package/src/modules/roles-and-permissions/components/RoleModal.tsx +213 -0
  53. package/src/modules/roles-and-permissions/index.ts +4 -0
  54. package/src/shared/components/Badge.tsx +24 -0
  55. package/src/shared/components/Button.tsx +75 -0
  56. package/src/shared/components/DynamicSidebar.tsx +181 -0
  57. package/src/shared/components/Input.tsx +48 -0
  58. package/src/shared/components/Modal.tsx +70 -0
  59. package/src/shared/components/Table.tsx +345 -0
  60. package/src/styles/standalone.css +51 -0
  61. package/src/theme/ThemeContext.tsx +74 -0
  62. package/src/theme/defaultTheme.ts +81 -0
  63. package/src/theme/index.ts +3 -0
  64. package/src/theme/theme.types.ts +106 -0
  65. package/tsconfig.cjs.json +10 -0
  66. package/tsconfig.esm.json +10 -0
  67. 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';