feat: add privileges management module with CRUD functionality
- Introduced a new Privileges module, including routes for creating, editing, and viewing privileges. - Implemented a detailed permissions matrix for managing actions (view, create, update, delete, import) associated with each privilege. - Added validation schema for privilege creation and editing. - Developed UI components for displaying and managing privilege details and permissions. - Integrated language support for English and Indonesian in the privileges module. - Created unit tests for the privileges remote data service and transformer to ensure functionality. This commit enhances the application by providing a comprehensive privileges management system, improving user role management and permissions handling.
This commit is contained in:
@@ -7,6 +7,7 @@ const ExampleModule = lazy(() => import('./modules/example'));
|
|||||||
const SystemSetting = lazy(() => import('./modules/system/setting'));
|
const SystemSetting = lazy(() => import('./modules/system/setting'));
|
||||||
const SystemInformation = lazy(() => import('./modules/system/information'));
|
const SystemInformation = lazy(() => import('./modules/system/information'));
|
||||||
const SystemNotification = lazy(() => import('./modules/system/notification'));
|
const SystemNotification = lazy(() => import('./modules/system/notification'));
|
||||||
|
const PrivilegesModule = lazy(() => import('./modules/system/privileges/presentation/factory'));
|
||||||
|
|
||||||
export default function AppModule() {
|
export default function AppModule() {
|
||||||
return (
|
return (
|
||||||
@@ -17,6 +18,7 @@ export default function AppModule() {
|
|||||||
<Route path="/system/setting" element={<SystemSetting />} />
|
<Route path="/system/setting" element={<SystemSetting />} />
|
||||||
<Route path="/system/information" element={<SystemInformation />} />
|
<Route path="/system/information" element={<SystemInformation />} />
|
||||||
<Route path="/system/notifications" element={<SystemNotification />} />
|
<Route path="/system/notifications" element={<SystemNotification />} />
|
||||||
|
<Route path="/system/privileges/*" element={<PrivilegesModule />} />
|
||||||
<Route path="/" element={<Navigate to="/app/example/full-page" replace={true} />} />
|
<Route path="/" element={<Navigate to="/app/example/full-page" replace={true} />} />
|
||||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -235,6 +235,21 @@ export const MENU_ITEMS: MenuItemType[] = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'system-group',
|
||||||
|
label: 'nav:system',
|
||||||
|
icon: Shield,
|
||||||
|
path: '/app/system',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: 'system-privileges',
|
||||||
|
label: 'nav:system-privileges',
|
||||||
|
icon: Shield,
|
||||||
|
path: '/app/system/privileges/index',
|
||||||
|
moduleKey: 'PRIVILEGES',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'example-module',
|
key: 'example-module',
|
||||||
label: 'nav:example-module',
|
label: 'nav:example-module',
|
||||||
@@ -246,6 +261,7 @@ export const MENU_ITEMS: MenuItemType[] = [
|
|||||||
label: 'nav:example-full-page',
|
label: 'nav:example-full-page',
|
||||||
icon: LayoutDashboard,
|
icon: LayoutDashboard,
|
||||||
path: '/app/example/full-page/index',
|
path: '/app/example/full-page/index',
|
||||||
|
moduleKey: 'EXAMPLE_FULL_PAGE',
|
||||||
},
|
},
|
||||||
// {
|
// {
|
||||||
// key: 'example-single-page',
|
// key: 'example-single-page',
|
||||||
|
|||||||
@@ -33,5 +33,6 @@
|
|||||||
"example-module": "Example Module",
|
"example-module": "Example Module",
|
||||||
"example-full-page": "Example Full Page",
|
"example-full-page": "Example Full Page",
|
||||||
"example-single-page": "Example Single Page",
|
"example-single-page": "Example Single Page",
|
||||||
"system": "System"
|
"system": "System",
|
||||||
|
"system-privileges": "Privileges"
|
||||||
}
|
}
|
||||||
@@ -33,5 +33,6 @@
|
|||||||
"example-module": "Modul Contoh",
|
"example-module": "Modul Contoh",
|
||||||
"example-full-page": "Contoh Halaman Penuh",
|
"example-full-page": "Contoh Halaman Penuh",
|
||||||
"example-single-page": "Contoh Halaman Tunggal",
|
"example-single-page": "Contoh Halaman Tunggal",
|
||||||
"system": "Sistem"
|
"system": "Sistem",
|
||||||
|
"system-privileges": "Hak Akses"
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,7 @@ import { HistoryDrawer } from './components/history';
|
|||||||
import { BookmarkDrawer } from './components/bookmark';
|
import { BookmarkDrawer } from './components/bookmark';
|
||||||
import { useHistoryTracker } from './hooks/useHistoryTracker';
|
import { useHistoryTracker } from './hooks/useHistoryTracker';
|
||||||
import { MENU_ITEMS } from './data/menu.data';
|
import { MENU_ITEMS } from './data/menu.data';
|
||||||
|
import { useFilteredMenuItems } from '../../../core/lib/use-filtered-menu-items';
|
||||||
import { enterpriseStorageAdapter } from '../../../core/lib/enterprise-storage-adapter';
|
import { enterpriseStorageAdapter } from '../../../core/lib/enterprise-storage-adapter';
|
||||||
|
|
||||||
import navEn from './languages/en/nav.json';
|
import navEn from './languages/en/nav.json';
|
||||||
@@ -27,6 +28,7 @@ registerModuleNamespace('bookmark', { en: bookmarkEn, id: bookmarkId });
|
|||||||
export default function ModuleLayout({ children }: { children: React.ReactNode }) {
|
export default function ModuleLayout({ children }: { children: React.ReactNode }) {
|
||||||
// FIXME: To disable the history tracker, simply comment out or remove the following line:
|
// FIXME: To disable the history tracker, simply comment out or remove the following line:
|
||||||
useHistoryTracker();
|
useHistoryTracker();
|
||||||
|
const menuItems = useFilteredMenuItems(MENU_ITEMS);
|
||||||
|
|
||||||
const configAppShell: CoreAppShellConfig = {
|
const configAppShell: CoreAppShellConfig = {
|
||||||
variant: 'header-first',
|
variant: 'header-first',
|
||||||
@@ -50,7 +52,7 @@ export default function ModuleLayout({ children }: { children: React.ReactNode }
|
|||||||
header: <HeaderLayout />,
|
header: <HeaderLayout />,
|
||||||
sidebar: (
|
sidebar: (
|
||||||
<SidebarMenu
|
<SidebarMenu
|
||||||
items={MENU_ITEMS}
|
items={menuItems}
|
||||||
withToggle
|
withToggle
|
||||||
// FIXME: To hide the history and bookmark buttons from the sidebar, set these to false:
|
// FIXME: To hide the history and bookmark buttons from the sidebar, set these to false:
|
||||||
// showHistory={false}
|
// showHistory={false}
|
||||||
@@ -59,7 +61,7 @@ export default function ModuleLayout({ children }: { children: React.ReactNode }
|
|||||||
),
|
),
|
||||||
sidebarMobile: (
|
sidebarMobile: (
|
||||||
<SidebarMenu
|
<SidebarMenu
|
||||||
items={MENU_ITEMS}
|
items={menuItems}
|
||||||
variantOverride="expanded"
|
variantOverride="expanded"
|
||||||
// FIXME: To hide the history and bookmark buttons from the sidebar, set these to false:
|
// FIXME: To hide the history and bookmark buttons from the sidebar, set these to false:
|
||||||
// showHistory={false}
|
// showHistory={false}
|
||||||
|
|||||||
@@ -14,4 +14,6 @@ export interface MenuItemType {
|
|||||||
path: string;
|
path: string;
|
||||||
/** Optional nested child menu items */
|
/** Optional nested child menu items */
|
||||||
children?: MenuItemType[];
|
children?: MenuItemType[];
|
||||||
|
/** Privilege catalog key used to hide the item when ALLOW_VIEW is false */
|
||||||
|
moduleKey?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
+150
@@ -0,0 +1,150 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { AxiosInstance } from '@repo/core-api/http-client';
|
||||||
|
import { PrivilegesRemoteDataServices } from './privilege.remote.service';
|
||||||
|
import { PrivilegesRemoteDataTransformer } from '../domain/transformers/privilege.remote.transformer';
|
||||||
|
|
||||||
|
function createMockHttpClient(): AxiosInstance {
|
||||||
|
return {
|
||||||
|
request: vi.fn().mockResolvedValue({ data: {}, status: 200 }),
|
||||||
|
defaults: {} as AxiosInstance['defaults'],
|
||||||
|
interceptors: {
|
||||||
|
request: { use: vi.fn(), eject: vi.fn(), clear: vi.fn() },
|
||||||
|
response: { use: vi.fn(), eject: vi.fn(), clear: vi.fn() },
|
||||||
|
},
|
||||||
|
getUri: vi.fn(),
|
||||||
|
get: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
head: vi.fn(),
|
||||||
|
options: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
|
put: vi.fn(),
|
||||||
|
patch: vi.fn(),
|
||||||
|
postForm: vi.fn(),
|
||||||
|
putForm: vi.fn(),
|
||||||
|
patchForm: vi.fn(),
|
||||||
|
} as unknown as AxiosInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unwrappedDetail = {
|
||||||
|
id: 'priv-1',
|
||||||
|
name: 'Sales Staff',
|
||||||
|
code: 'SALES_STAFF',
|
||||||
|
status: 'active',
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 2,
|
||||||
|
createdBy: 'u1',
|
||||||
|
updatedBy: 'u2',
|
||||||
|
details: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('PrivilegesRemoteDataServices', () => {
|
||||||
|
let httpClient: AxiosInstance;
|
||||||
|
let service: PrivilegesRemoteDataServices;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
httpClient = createMockHttpClient();
|
||||||
|
service = new PrivilegesRemoteDataServices(httpClient, {
|
||||||
|
apiUrl: '/privileges',
|
||||||
|
moduleKey: 'PRIVILEGES',
|
||||||
|
transformer: new PrivilegesRemoteDataTransformer(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps an unwrapped getOne body as { data: entity }', async () => {
|
||||||
|
vi.mocked(httpClient.request).mockResolvedValueOnce({ data: unwrappedDetail, status: 200 });
|
||||||
|
|
||||||
|
const result = await service.getOne('priv-1');
|
||||||
|
|
||||||
|
expect(result.data).toEqual({
|
||||||
|
data: expect.objectContaining({ id: 'priv-1', code: 'SALES_STAFF', matrix: {} }),
|
||||||
|
});
|
||||||
|
expect(httpClient.request).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ url: '/privileges/priv-1', method: 'GET' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses PATCH when editing a privilege', async () => {
|
||||||
|
await service.edit('priv-1', { name: 'Sales Staff', code: 'SALES_STAFF' });
|
||||||
|
|
||||||
|
expect(httpClient.request).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
url: '/privileges/priv-1',
|
||||||
|
method: 'PATCH',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('activates via PATCH /privileges/:id/status with status active', async () => {
|
||||||
|
await service.activate('priv-1');
|
||||||
|
|
||||||
|
expect(httpClient.request).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
url: '/privileges/priv-1/status',
|
||||||
|
method: 'PATCH',
|
||||||
|
data: { status: 'active' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deactivates via PATCH /privileges/:id/status with status archived', async () => {
|
||||||
|
await service.deactivate('priv-1');
|
||||||
|
|
||||||
|
expect(httpClient.request).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
url: '/privileges/priv-1/status',
|
||||||
|
method: 'PATCH',
|
||||||
|
data: { status: 'archived' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bulk-deletes via POST /privileges/bulk-delete', async () => {
|
||||||
|
await service.batchDelete(['priv-1', 'priv-2']);
|
||||||
|
|
||||||
|
expect(httpClient.request).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
url: '/privileges/bulk-delete',
|
||||||
|
method: 'POST',
|
||||||
|
data: expect.objectContaining({ ids: ['priv-1', 'priv-2'] }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bulk-activates via POST /privileges/bulk-status', async () => {
|
||||||
|
await service.batchActivate(['priv-1']);
|
||||||
|
|
||||||
|
expect(httpClient.request).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
url: '/privileges/bulk-status',
|
||||||
|
method: 'POST',
|
||||||
|
data: { ids: ['priv-1'], status: 'active' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists privilege keys from /privilege-keys', async () => {
|
||||||
|
vi.mocked(httpClient.request).mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
data: [{ id: 'key-1', code: 'SALES.INVOICE', label: 'Sales Invoice', sortOrder: 1 }],
|
||||||
|
meta: { currentPage: 1, itemsPerPage: 200, totalItems: 1, totalPages: 1 },
|
||||||
|
},
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.listPrivilegeKeys();
|
||||||
|
|
||||||
|
expect(httpClient.request).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
url: '/privilege-keys',
|
||||||
|
method: 'GET',
|
||||||
|
params: { limit: 200 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.data[0]).toEqual({
|
||||||
|
id: 'key-1',
|
||||||
|
code: 'SALES.INVOICE',
|
||||||
|
label: 'Sales Invoice',
|
||||||
|
sortOrder: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import type { AxiosInstance, AxiosRequestConfig } from '@repo/core-api/http-client';
|
||||||
|
import { BaseRemoteDataServices, DESCRIPTORS, type DataServicesConfig } from '@repo/core-api/data-services';
|
||||||
|
import type { ApiResponse } from '@repo/core-api/http-client';
|
||||||
|
import { mapPrivilegeKey } from '../domain/transformers/privilege.remote.transformer';
|
||||||
|
import type { PrivilegeDto, PrivilegeEntity, PrivilegeKeyDto, PrivilegeKeyEntity } from '../domain/entities';
|
||||||
|
|
||||||
|
function unwrapPrivilegeDetail(raw: unknown): PrivilegeDto {
|
||||||
|
if (raw && typeof raw === 'object' && 'id' in raw && 'code' in raw && 'name' in raw) {
|
||||||
|
return raw as PrivilegeDto;
|
||||||
|
}
|
||||||
|
if (raw && typeof raw === 'object' && 'data' in raw) {
|
||||||
|
const nested = (raw as { data: unknown }).data;
|
||||||
|
if (nested && typeof nested === 'object' && 'id' in nested) {
|
||||||
|
return nested as PrivilegeDto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('Unexpected privilege detail response');
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PrivilegesRemoteDataServices extends BaseRemoteDataServices<PrivilegeEntity> {
|
||||||
|
constructor(httpClient: AxiosInstance, config: DataServicesConfig<PrivilegeEntity>) {
|
||||||
|
const apiUrl = config.apiUrl ?? '/privileges';
|
||||||
|
super(httpClient, {
|
||||||
|
...config,
|
||||||
|
urls: {
|
||||||
|
batchDeleteUrl: `${apiUrl}/bulk-delete`,
|
||||||
|
activateUrl: `${apiUrl}/:id/status`,
|
||||||
|
deactivateUrl: `${apiUrl}/:id/status`,
|
||||||
|
batchActivateUrl: `${apiUrl}/bulk-status`,
|
||||||
|
batchDeactivateUrl: `${apiUrl}/bulk-status`,
|
||||||
|
...config.urls,
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
editMethod: 'PATCH',
|
||||||
|
batchDeleteMethod: 'POST',
|
||||||
|
batchActivateMethod: 'POST',
|
||||||
|
batchDeactivateMethod: 'POST',
|
||||||
|
...config.methods,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOne<T = { data: PrivilegeEntity }>(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||||
|
const result = await this.execute<PrivilegeDto | { data: PrivilegeDto }>(DESCRIPTORS.getOne, {
|
||||||
|
variableURL: { id },
|
||||||
|
config,
|
||||||
|
});
|
||||||
|
const dto = unwrapPrivilegeDetail(result.data);
|
||||||
|
const entity = this.transformer
|
||||||
|
? this.transformer.transformGetOneResponse
|
||||||
|
? this.transformer.transformGetOneResponse(dto as unknown as PrivilegeEntity)
|
||||||
|
: this.transformer.transformToEntity(dto as unknown as PrivilegeEntity)
|
||||||
|
: (dto as unknown as PrivilegeEntity);
|
||||||
|
return { ...result, data: { data: entity } as T };
|
||||||
|
}
|
||||||
|
|
||||||
|
activate(id: string, _meta?: Record<string, unknown>, config?: AxiosRequestConfig) {
|
||||||
|
return this.execute<void>(DESCRIPTORS.activate, {
|
||||||
|
variableURL: { id },
|
||||||
|
config: { ...config, data: { status: 'active' } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deactivate(id: string, _meta?: Record<string, unknown>, config?: AxiosRequestConfig) {
|
||||||
|
return this.execute<void>(DESCRIPTORS.deactivate, {
|
||||||
|
variableURL: { id },
|
||||||
|
config: { ...config, data: { status: 'archived' } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
batchActivate(ids: Array<string | number>, _meta?: Record<string, unknown>, config?: AxiosRequestConfig) {
|
||||||
|
return this.execute<void>(DESCRIPTORS.batchActivate, {
|
||||||
|
config: { ...config, data: { ids, status: 'active' } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
batchDeactivate(ids: Array<string | number>, _meta?: Record<string, unknown>, config?: AxiosRequestConfig) {
|
||||||
|
return this.execute<void>(DESCRIPTORS.batchDeactivate, {
|
||||||
|
config: { ...config, data: { ids, status: 'archived' } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPrivilegeKeys(): Promise<ApiResponse<PrivilegeKeyEntity[]>> {
|
||||||
|
const result = await this.customRequest<{ data?: PrivilegeKeyDto[] } | PrivilegeKeyDto[]>({
|
||||||
|
url: '/privilege-keys',
|
||||||
|
method: 'GET',
|
||||||
|
params: { limit: 200 },
|
||||||
|
});
|
||||||
|
const rows = Array.isArray(result.data) ? result.data : (result.data?.data ?? []);
|
||||||
|
return { ...result, data: rows.map(mapPrivilegeKey) };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './privilege.constants';
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
import { ModuleConfigEntity } from '@repo/ui/foundations';
|
||||||
|
import type { PrivilegeEntity } from '../entities';
|
||||||
|
|
||||||
|
export const privilegesModuleConfig: ModuleConfigEntity<PrivilegeEntity> = {
|
||||||
|
moduleKey: 'PRIVILEGES',
|
||||||
|
translationNamespace: 'PRIVILEGES',
|
||||||
|
apiUrl: '/privileges',
|
||||||
|
webUrl: '/app/system/privileges',
|
||||||
|
moduleCategory: 'FULL_PAGE',
|
||||||
|
moduleType: 'MASTER_DATA',
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './privilege.entity';
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { BaseEntity } from '@repo/core-api/data-services';
|
||||||
|
|
||||||
|
export type PrivilegeStatus = 'draft' | 'active' | 'archived';
|
||||||
|
export type PrivilegeAction = 'view' | 'create' | 'update' | 'delete' | 'import';
|
||||||
|
|
||||||
|
export interface PrivilegeMatrixCell {
|
||||||
|
view: boolean;
|
||||||
|
create: boolean;
|
||||||
|
update: boolean;
|
||||||
|
delete: boolean;
|
||||||
|
import: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrivilegeDetailEntity {
|
||||||
|
id?: string;
|
||||||
|
privilegeKeyId: string;
|
||||||
|
keyCode: string;
|
||||||
|
keyLabel: string;
|
||||||
|
sortOrder: number;
|
||||||
|
action: PrivilegeAction;
|
||||||
|
value: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrivilegeKeyEntity {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrivilegeEntity extends BaseEntity {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
status?: PrivilegeStatus;
|
||||||
|
createdAt?: number;
|
||||||
|
updatedAt?: number;
|
||||||
|
createdBy?: string;
|
||||||
|
updatedBy?: string;
|
||||||
|
details?: PrivilegeDetailEntity[];
|
||||||
|
matrix?: Record<string, PrivilegeMatrixCell>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrivilegeDto {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
status?: PrivilegeStatus;
|
||||||
|
createdAt?: number;
|
||||||
|
updatedAt?: number;
|
||||||
|
createdBy?: string;
|
||||||
|
updatedBy?: string;
|
||||||
|
details?: PrivilegeDetailDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrivilegeDetailDto {
|
||||||
|
id?: string;
|
||||||
|
privilegeKeyId: string;
|
||||||
|
keyCode?: string;
|
||||||
|
keyLabel?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
action: PrivilegeAction;
|
||||||
|
value: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrivilegeDetailInputDto {
|
||||||
|
privilegeKeyId: string;
|
||||||
|
action: PrivilegeAction;
|
||||||
|
value: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrivilegeDetailResponseDto extends PrivilegeDto {
|
||||||
|
details: PrivilegeDetailDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrivilegeKeyDto {
|
||||||
|
id?: string;
|
||||||
|
code?: string;
|
||||||
|
keyCode?: string;
|
||||||
|
label?: string;
|
||||||
|
keyLabel?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PRIVILEGE_ACTIONS: PrivilegeAction[] = ['view', 'create', 'update', 'delete', 'import'];
|
||||||
|
|
||||||
|
export const EMPTY_MATRIX_CELL: PrivilegeMatrixCell = {
|
||||||
|
view: false,
|
||||||
|
create: false,
|
||||||
|
update: false,
|
||||||
|
delete: false,
|
||||||
|
import: false,
|
||||||
|
};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { apiClient } from '../../../../../../../core/lib/api-client';
|
||||||
|
import { PrivilegesRemoteDataServices } from '../../data/privilege.remote.service';
|
||||||
|
import { privilegesModuleConfig } from '../constants/privilege.constants';
|
||||||
|
import { PrivilegesRemoteDataTransformer } from '../transformers/privilege.remote.transformer';
|
||||||
|
|
||||||
|
export const privilegesDataTransformer = new PrivilegesRemoteDataTransformer();
|
||||||
|
|
||||||
|
export const privilegesDataService = new PrivilegesRemoteDataServices(apiClient, {
|
||||||
|
apiUrl: privilegesModuleConfig.apiUrl,
|
||||||
|
moduleKey: privilegesModuleConfig.moduleKey,
|
||||||
|
transformer: privilegesDataTransformer,
|
||||||
|
});
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { PrivilegesRemoteDataTransformer } from './privilege.remote.transformer';
|
||||||
|
import type { PrivilegeDetailResponseDto, PrivilegeEntity } from '../entities';
|
||||||
|
|
||||||
|
const transformer = new PrivilegesRemoteDataTransformer();
|
||||||
|
|
||||||
|
const detailDto: PrivilegeDetailResponseDto = {
|
||||||
|
id: 'priv-1',
|
||||||
|
name: 'Sales Staff',
|
||||||
|
code: 'SALES_STAFF',
|
||||||
|
status: 'active',
|
||||||
|
createdAt: 1700000000000,
|
||||||
|
updatedAt: 1700000001000,
|
||||||
|
createdBy: 'user-1',
|
||||||
|
updatedBy: 'user-2',
|
||||||
|
details: [
|
||||||
|
{
|
||||||
|
id: 'd1',
|
||||||
|
privilegeKeyId: 'key-1',
|
||||||
|
keyCode: 'SALES.INVOICE',
|
||||||
|
keyLabel: 'Sales Invoice',
|
||||||
|
sortOrder: 1,
|
||||||
|
action: 'view',
|
||||||
|
value: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'd2',
|
||||||
|
privilegeKeyId: 'key-1',
|
||||||
|
keyCode: 'SALES.INVOICE',
|
||||||
|
keyLabel: 'Sales Invoice',
|
||||||
|
sortOrder: 1,
|
||||||
|
action: 'create',
|
||||||
|
value: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('PrivilegesRemoteDataTransformer', () => {
|
||||||
|
it('maps details into a matrix and defaults missing actions to false', () => {
|
||||||
|
const entity = transformer.transformToEntity(detailDto);
|
||||||
|
|
||||||
|
expect(entity.matrix?.['key-1']).toEqual({
|
||||||
|
view: true,
|
||||||
|
create: false,
|
||||||
|
update: false,
|
||||||
|
delete: false,
|
||||||
|
import: false,
|
||||||
|
});
|
||||||
|
expect(entity.details?.[0]).toMatchObject({
|
||||||
|
privilegeKeyId: 'key-1',
|
||||||
|
keyCode: 'SALES.INVOICE',
|
||||||
|
keyLabel: 'Sales Invoice',
|
||||||
|
sortOrder: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds create payload with name, code, and flattened details only', () => {
|
||||||
|
const entity: PrivilegeEntity = {
|
||||||
|
name: 'Sales Staff',
|
||||||
|
code: 'SALES_STAFF',
|
||||||
|
status: 'draft',
|
||||||
|
matrix: {
|
||||||
|
'key-1': { view: true, create: true, update: false, delete: false, import: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const payload = transformer.transformCreatePayload(entity);
|
||||||
|
|
||||||
|
expect(payload).toEqual({
|
||||||
|
name: 'Sales Staff',
|
||||||
|
code: 'SALES_STAFF',
|
||||||
|
details: [
|
||||||
|
{ privilegeKeyId: 'key-1', action: 'view', value: true },
|
||||||
|
{ privilegeKeyId: 'key-1', action: 'create', value: true },
|
||||||
|
{ privilegeKeyId: 'key-1', action: 'update', value: false },
|
||||||
|
{ privilegeKeyId: 'key-1', action: 'delete', value: false },
|
||||||
|
{ privilegeKeyId: 'key-1', action: 'import', value: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(payload).not.toHaveProperty('status');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds edit payload without status', () => {
|
||||||
|
const entity: PrivilegeEntity = {
|
||||||
|
id: 'priv-1',
|
||||||
|
name: 'Sales Staff',
|
||||||
|
code: 'SALES_STAFF',
|
||||||
|
status: 'active',
|
||||||
|
matrix: {
|
||||||
|
'key-1': { view: true, create: false, update: false, delete: false, import: false },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const payload = transformer.transformEditPayload(entity);
|
||||||
|
|
||||||
|
expect(payload).not.toHaveProperty('status');
|
||||||
|
expect(payload).not.toHaveProperty('id');
|
||||||
|
expect(payload.details).toEqual(
|
||||||
|
expect.arrayContaining([{ privilegeKeyId: 'key-1', action: 'view', value: true }]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps an empty details list to an empty matrix', () => {
|
||||||
|
const entity = transformer.transformToEntity({ ...detailDto, details: [] });
|
||||||
|
expect(entity.matrix).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
import { BaseDataTransformer } from '@repo/core-api/data-services';
|
||||||
|
import {
|
||||||
|
EMPTY_MATRIX_CELL,
|
||||||
|
PRIVILEGE_ACTIONS,
|
||||||
|
PrivilegeDetailDto,
|
||||||
|
PrivilegeDetailInputDto,
|
||||||
|
PrivilegeDto,
|
||||||
|
PrivilegeEntity,
|
||||||
|
PrivilegeKeyDto,
|
||||||
|
PrivilegeKeyEntity,
|
||||||
|
PrivilegeMatrixCell,
|
||||||
|
} from '../entities';
|
||||||
|
|
||||||
|
function emptyCell(): PrivilegeMatrixCell {
|
||||||
|
return { ...EMPTY_MATRIX_CELL };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detailsToMatrix(details: PrivilegeDetailDto[] | undefined): Record<string, PrivilegeMatrixCell> {
|
||||||
|
if (!details?.length) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return details.reduce<Record<string, PrivilegeMatrixCell>>((matrix, detail) => {
|
||||||
|
const keyId = detail.privilegeKeyId;
|
||||||
|
const current = { ...(matrix[keyId] ?? emptyCell()) };
|
||||||
|
if (PRIVILEGE_ACTIONS.includes(detail.action)) {
|
||||||
|
current[detail.action] = Boolean(detail.value);
|
||||||
|
}
|
||||||
|
return { ...matrix, [keyId]: current };
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matrixToDetails(matrix: Record<string, PrivilegeMatrixCell> | undefined): PrivilegeDetailInputDto[] {
|
||||||
|
if (!matrix) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(matrix).flatMap(([privilegeKeyId, cell]) =>
|
||||||
|
PRIVILEGE_ACTIONS.map((action) => ({
|
||||||
|
privilegeKeyId,
|
||||||
|
action,
|
||||||
|
value: Boolean(cell?.[action]),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapPrivilegeKey(dto: PrivilegeKeyDto): PrivilegeKeyEntity {
|
||||||
|
return {
|
||||||
|
id: String(dto.id ?? ''),
|
||||||
|
code: String(dto.code ?? dto.keyCode ?? ''),
|
||||||
|
label: String(dto.label ?? dto.keyLabel ?? dto.code ?? dto.keyCode ?? ''),
|
||||||
|
sortOrder: Number(dto.sortOrder ?? 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PrivilegesRemoteDataTransformer extends BaseDataTransformer<PrivilegeEntity> {
|
||||||
|
transformToEntity(dto: PrivilegeDto | PrivilegeEntity): PrivilegeEntity {
|
||||||
|
return {
|
||||||
|
id: dto.id,
|
||||||
|
name: dto.name,
|
||||||
|
code: dto.code,
|
||||||
|
status: dto.status,
|
||||||
|
createdAt: dto.createdAt,
|
||||||
|
updatedAt: dto.updatedAt,
|
||||||
|
createdBy: dto.createdBy,
|
||||||
|
updatedBy: dto.updatedBy,
|
||||||
|
details: (dto.details ?? []).map((detail) => ({
|
||||||
|
id: detail.id,
|
||||||
|
privilegeKeyId: detail.privilegeKeyId,
|
||||||
|
keyCode: detail.keyCode ?? '',
|
||||||
|
keyLabel: detail.keyLabel ?? '',
|
||||||
|
sortOrder: detail.sortOrder ?? 0,
|
||||||
|
action: detail.action,
|
||||||
|
value: Boolean(detail.value),
|
||||||
|
})),
|
||||||
|
matrix: detailsToMatrix((dto as PrivilegeDto).details),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
transformToDTO(entity: PrivilegeEntity): PrivilegeEntity {
|
||||||
|
return {
|
||||||
|
...entity,
|
||||||
|
details: matrixToDetails(entity.matrix) as PrivilegeEntity['details'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
transformCreatePayload(entity: Partial<PrivilegeEntity>): Partial<PrivilegeEntity> {
|
||||||
|
return {
|
||||||
|
name: entity.name,
|
||||||
|
code: entity.code,
|
||||||
|
details: matrixToDetails(entity.matrix) as PrivilegeEntity['details'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
transformEditPayload(entity: Partial<PrivilegeEntity>): Partial<PrivilegeEntity> {
|
||||||
|
return {
|
||||||
|
name: entity.name,
|
||||||
|
code: entity.code,
|
||||||
|
details: matrixToDetails(entity.matrix) as PrivilegeEntity['details'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { createPrivilegeSchema } from './privilege.validator';
|
||||||
|
|
||||||
|
describe('createPrivilegeSchema', () => {
|
||||||
|
const t = (key: string) => key;
|
||||||
|
const schema = createPrivilegeSchema(t);
|
||||||
|
|
||||||
|
it('rejects empty name and code', () => {
|
||||||
|
const result = schema.safeParse({ name: '', code: '' });
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a name longer than 120 characters', () => {
|
||||||
|
const result = schema.safeParse({ name: 'A'.repeat(121), code: 'SALES_STAFF' });
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a code longer than 64 characters', () => {
|
||||||
|
const result = schema.safeParse({ name: 'Sales Staff', code: 'A'.repeat(65) });
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a valid name and code', () => {
|
||||||
|
const result = schema.safeParse({ name: 'Sales Staff', code: 'SALES_STAFF' });
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an optional matrix', () => {
|
||||||
|
const result = schema.safeParse({
|
||||||
|
name: 'Sales Staff',
|
||||||
|
code: 'SALES_STAFF',
|
||||||
|
matrix: { 'key-1': { view: true, create: false, update: false, delete: false, import: false } },
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { compose, required, maxLength } from '@repo/ui/validators';
|
||||||
|
|
||||||
|
export const createPrivilegeSchema = (t: (key: string) => string) => {
|
||||||
|
return z.object({
|
||||||
|
name: compose(z.string(), required(t('common:fields.name')), maxLength(120, t('common:fields.name'))),
|
||||||
|
code: compose(z.string(), required(t('common:fields.code')), maxLength(64, t('common:fields.code'))),
|
||||||
|
matrix: z
|
||||||
|
.record(
|
||||||
|
z.object({
|
||||||
|
view: z.boolean().optional(),
|
||||||
|
create: z.boolean().optional(),
|
||||||
|
update: z.boolean().optional(),
|
||||||
|
delete: z.boolean().optional(),
|
||||||
|
import: z.boolean().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
};
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
import { Box, Paper, SimpleGrid, FieldValue, RenderDate, Text, StatusBadge } from '@repo/ui/components';
|
||||||
|
import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||||
|
import type { PrivilegeEntity } from '../../../domain/entities';
|
||||||
|
|
||||||
|
export function DetailGeneral() {
|
||||||
|
const { detailData } = useDetailPageContext<PrivilegeEntity>();
|
||||||
|
const { t } = useEnterpriseModuleTranslationContext();
|
||||||
|
const data = detailData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||||
|
<Text fw={600} mb="md">
|
||||||
|
{t('section_general')}
|
||||||
|
</Text>
|
||||||
|
<Box>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md" verticalSpacing="xl">
|
||||||
|
<FieldValue label={t('common:fields.code')} value={data?.code} />
|
||||||
|
<FieldValue label={t('common:fields.name')} value={data?.name} />
|
||||||
|
<FieldValue
|
||||||
|
label={t('common:fields.status')}
|
||||||
|
value={data?.status}
|
||||||
|
render={(val) => <StatusBadge status={String(val ?? '')} />}
|
||||||
|
/>
|
||||||
|
<FieldValue
|
||||||
|
label={t('common:fields.createdAt')}
|
||||||
|
value={data?.createdAt}
|
||||||
|
render={(val) => <RenderDate value={val as any} />}
|
||||||
|
/>
|
||||||
|
<FieldValue
|
||||||
|
label={t('common:fields.updatedAt')}
|
||||||
|
value={data?.updatedAt}
|
||||||
|
render={(val) => <RenderDate value={val as any} />}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
import { Box, Paper, Table, Text } from '@repo/ui/components';
|
||||||
|
import { Check, Minus } from 'lucide-react';
|
||||||
|
import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||||
|
import { EMPTY_MATRIX_CELL, PRIVILEGE_ACTIONS, type PrivilegeEntity } from '../../../domain/entities';
|
||||||
|
|
||||||
|
export function DetailPermissions() {
|
||||||
|
const { detailData } = useDetailPageContext<PrivilegeEntity>();
|
||||||
|
const { t } = useEnterpriseModuleTranslationContext();
|
||||||
|
const matrix = detailData?.matrix ?? {};
|
||||||
|
const details = detailData?.details ?? [];
|
||||||
|
|
||||||
|
const rows = Object.entries(matrix)
|
||||||
|
.map(([keyId, cell]) => {
|
||||||
|
const sample = details.find((detail) => detail.privilegeKeyId === keyId);
|
||||||
|
return {
|
||||||
|
keyId,
|
||||||
|
code: sample?.keyCode ?? keyId,
|
||||||
|
label: sample?.keyLabel ?? keyId,
|
||||||
|
sortOrder: sample?.sortOrder ?? 0,
|
||||||
|
cell: cell ?? EMPTY_MATRIX_CELL,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||||
|
<Text fw={600} mb="md">
|
||||||
|
{t('section_permissions')}
|
||||||
|
</Text>
|
||||||
|
<Box style={{ overflowX: 'auto' }}>
|
||||||
|
<Table striped highlightOnHover withTableBorder withColumnBorders>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>{t('matrix_module')}</Table.Th>
|
||||||
|
{PRIVILEGE_ACTIONS.map((action) => (
|
||||||
|
<Table.Th key={action} ta="center">
|
||||||
|
{t(`matrix_${action}`)}
|
||||||
|
</Table.Th>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<Table.Tr key={row.keyId}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm">{row.label}</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{row.code}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
{PRIVILEGE_ACTIONS.map((action) => (
|
||||||
|
<Table.Td key={action} ta="center">
|
||||||
|
{row.cell[action] ? <Check size={16} /> : <Minus size={16} />}
|
||||||
|
</Table.Td>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
import { Box, FieldTextInput, Paper, SimpleGrid, Text } from '@repo/ui/components';
|
||||||
|
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
|
||||||
|
|
||||||
|
export function FormGeneral() {
|
||||||
|
const { formControl } = useFormPageContext();
|
||||||
|
const { t } = useEnterpriseModuleTranslationContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||||
|
<Text fw={600} mb="md">
|
||||||
|
{t('section_general')}
|
||||||
|
</Text>
|
||||||
|
<Box>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||||
|
<FieldTextInput
|
||||||
|
control={formControl.control}
|
||||||
|
name="name"
|
||||||
|
label={t('common:fields.name')}
|
||||||
|
placeholder="e.g. Sales Staff"
|
||||||
|
required
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
<FieldTextInput
|
||||||
|
name="code"
|
||||||
|
control={formControl.control}
|
||||||
|
label={t('common:fields.code')}
|
||||||
|
placeholder="e.g. SALES_STAFF"
|
||||||
|
required
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Box, Paper, Table, Text } from '@repo/ui/components';
|
||||||
|
import { FieldCheckbox } from '@repo/ui/form';
|
||||||
|
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
|
||||||
|
import { privilegesDataService } from '../../../domain/factories';
|
||||||
|
import { EMPTY_MATRIX_CELL, PRIVILEGE_ACTIONS, type PrivilegeKeyEntity } from '../../../domain/entities';
|
||||||
|
|
||||||
|
export function FormPermissions() {
|
||||||
|
const { formControl } = useFormPageContext();
|
||||||
|
const { t } = useEnterpriseModuleTranslationContext();
|
||||||
|
const [keys, setKeys] = useState<PrivilegeKeyEntity[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
privilegesDataService.listPrivilegeKeys().then((response) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const rows = [...(response.data ?? [])].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||||
|
setKeys(rows);
|
||||||
|
rows.forEach((key) => {
|
||||||
|
const current = formControl.getValues(`matrix.${key.id}`);
|
||||||
|
if (!current) {
|
||||||
|
formControl.setValue(`matrix.${key.id}`, { ...EMPTY_MATRIX_CELL });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [formControl]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||||
|
<Text fw={600} mb="md">
|
||||||
|
{t('section_permissions')}
|
||||||
|
</Text>
|
||||||
|
<Box style={{ overflowX: 'auto' }}>
|
||||||
|
<Table striped highlightOnHover withTableBorder withColumnBorders>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>{t('matrix_module')}</Table.Th>
|
||||||
|
{PRIVILEGE_ACTIONS.map((action) => (
|
||||||
|
<Table.Th key={action} ta="center">
|
||||||
|
{t(`matrix_${action}`)}
|
||||||
|
</Table.Th>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{keys.map((key) => (
|
||||||
|
<Table.Tr key={key.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm">{key.label || key.code}</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{key.code}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
{PRIVILEGE_ACTIONS.map((action) => (
|
||||||
|
<Table.Td key={action} ta="center">
|
||||||
|
<FieldCheckbox
|
||||||
|
control={formControl.control}
|
||||||
|
name={`matrix.${key.id}.${action}`}
|
||||||
|
label=""
|
||||||
|
/>
|
||||||
|
</Table.Td>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
import { SimpleGrid } from '@repo/ui/components';
|
||||||
|
import { FieldTextInput, FieldSelect } from '@repo/ui/form';
|
||||||
|
import { UseFormReturn } from 'react-hook-form';
|
||||||
|
|
||||||
|
export const FilterFormContent = ({ form, t }: { form: UseFormReturn<any>; t: (key: string) => string }) => {
|
||||||
|
return (
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||||
|
<FieldTextInput
|
||||||
|
control={form.control}
|
||||||
|
name="code"
|
||||||
|
label={t('common:fields.code')}
|
||||||
|
placeholder={`Enter ${t('common:fields.code')}`}
|
||||||
|
/>
|
||||||
|
<FieldTextInput
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
label={t('common:fields.name')}
|
||||||
|
placeholder={`Enter ${t('common:fields.name')}`}
|
||||||
|
/>
|
||||||
|
<FieldSelect
|
||||||
|
control={form.control}
|
||||||
|
name="status"
|
||||||
|
label={t('common:fields.status')}
|
||||||
|
clearable
|
||||||
|
data={[
|
||||||
|
{ value: 'draft', label: t('status_draft') },
|
||||||
|
{ value: 'active', label: t('status_active') },
|
||||||
|
{ value: 'archived', label: t('status_archived') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { lazy } from 'react';
|
||||||
|
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||||
|
import { EnterpriseModuleProvider } from '@repo/ui/foundations';
|
||||||
|
import { registerModuleNamespace } from '@repo/core-i18n';
|
||||||
|
import { privilegesModuleConfig } from '../../domain/constants';
|
||||||
|
import { privilegesDataService } from '../../domain/factories';
|
||||||
|
import { PrivilegeEntity } from '../../domain/entities';
|
||||||
|
import { privilegesStore } from '../store';
|
||||||
|
|
||||||
|
import privilegesId from '../languages/id/privileges.json';
|
||||||
|
import privilegesEn from '../languages/en/privileges.json';
|
||||||
|
|
||||||
|
const IndexPage = lazy(() => import('../pages/privilege.page.index'));
|
||||||
|
const FormPage = lazy(() => import('../pages/privilege.page.form'));
|
||||||
|
const DetailPage = lazy(() => import('../pages/privilege.page.detail'));
|
||||||
|
|
||||||
|
registerModuleNamespace(privilegesModuleConfig.translationNamespace, {
|
||||||
|
id: privilegesId,
|
||||||
|
en: privilegesEn,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function PrivilegesModule() {
|
||||||
|
return (
|
||||||
|
<EnterpriseModuleProvider<PrivilegeEntity>
|
||||||
|
config={privilegesModuleConfig}
|
||||||
|
dataServices={privilegesDataService}
|
||||||
|
store={privilegesStore}
|
||||||
|
>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/index" element={<IndexPage />} />
|
||||||
|
<Route path="/detail/:dataId" element={<DetailPage />} />
|
||||||
|
<Route path="/edit/:dataId" element={<FormPage formPageType="EDIT" />} />
|
||||||
|
<Route path="/duplicate/:dataId" element={<FormPage formPageType="DUPLICATE" />} />
|
||||||
|
<Route path="/create" element={<FormPage formPageType="CREATE" />} />
|
||||||
|
<Route path="/" element={<Navigate to={`${privilegesModuleConfig.webUrl}/index`} replace={true} />} />
|
||||||
|
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||||
|
</Routes>
|
||||||
|
</EnterpriseModuleProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"title": "Privileges",
|
||||||
|
"detail_page_title": "Privilege Detail",
|
||||||
|
"create_page_title": "New Privilege",
|
||||||
|
"edit_page_title": "Edit Privilege",
|
||||||
|
"duplicate_page_title": "Duplicate Privilege",
|
||||||
|
"description": "Manage role templates and the <1>permission matrix</1> for each module.",
|
||||||
|
"detail_page_description": "Review the privilege profile and its module permissions.",
|
||||||
|
"create_page_description": "Create a privilege and choose which actions each module can perform.",
|
||||||
|
"edit_page_description": "Update the privilege name, code, and permission matrix.",
|
||||||
|
"duplicate_page_description": "Copy an existing privilege to create a new role template.",
|
||||||
|
"section_general": "General",
|
||||||
|
"section_permissions": "Permissions",
|
||||||
|
"matrix_module": "Module",
|
||||||
|
"matrix_view": "View",
|
||||||
|
"matrix_create": "Create",
|
||||||
|
"matrix_update": "Update",
|
||||||
|
"matrix_delete": "Delete",
|
||||||
|
"matrix_import": "Import",
|
||||||
|
"status_draft": "Draft",
|
||||||
|
"status_active": "Active",
|
||||||
|
"status_archived": "Archived"
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"title": "Hak Akses",
|
||||||
|
"detail_page_title": "Detail Hak Akses",
|
||||||
|
"create_page_title": "Hak Akses Baru",
|
||||||
|
"edit_page_title": "Ubah Hak Akses",
|
||||||
|
"duplicate_page_title": "Duplikat Hak Akses",
|
||||||
|
"description": "Kelola templat peran dan <1>matriks izin</1> untuk setiap modul.",
|
||||||
|
"detail_page_description": "Tinjau profil hak akses dan izin modulnya.",
|
||||||
|
"create_page_description": "Buat hak akses dan pilih aksi yang boleh dilakukan setiap modul.",
|
||||||
|
"edit_page_description": "Perbarui nama, kode, dan matriks izin hak akses.",
|
||||||
|
"duplicate_page_description": "Salin hak akses yang ada untuk membuat templat peran baru.",
|
||||||
|
"section_general": "Umum",
|
||||||
|
"section_permissions": "Izin",
|
||||||
|
"matrix_module": "Modul",
|
||||||
|
"matrix_view": "Lihat",
|
||||||
|
"matrix_create": "Buat",
|
||||||
|
"matrix_update": "Ubah",
|
||||||
|
"matrix_delete": "Hapus",
|
||||||
|
"matrix_import": "Impor",
|
||||||
|
"status_draft": "Draft",
|
||||||
|
"status_active": "Aktif",
|
||||||
|
"status_archived": "Diarsipkan"
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
import { Stack } from '@repo/ui/components';
|
||||||
|
import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||||
|
import { privilegesModuleConfig } from '../../domain/constants';
|
||||||
|
import { DetailGeneral } from '../components/detail-component/detail-general';
|
||||||
|
import { DetailPermissions } from '../components/detail-component/detail-permissions';
|
||||||
|
|
||||||
|
export default function PrivilegePageDetail() {
|
||||||
|
const { t } = useEnterpriseModuleTranslationContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EnterpriseDetailPageProvider
|
||||||
|
highlightDataKey="code"
|
||||||
|
pageHeaderProps={{
|
||||||
|
title: t('detail_page_title'),
|
||||||
|
description: t('detail_page_description'),
|
||||||
|
breadcrumbs: [
|
||||||
|
{ label: t('nav:system'), type: 'text' },
|
||||||
|
{ label: t('nav:system-privileges'), type: 'link', href: `${privilegesModuleConfig.webUrl}/index` },
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<DetailGeneral />
|
||||||
|
<DetailPermissions />
|
||||||
|
</Stack>
|
||||||
|
</EnterpriseDetailPageProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Stack } from '@repo/ui/components';
|
||||||
|
import { useEnterpriseModuleTranslationContext, EnterpriseFormPageProvider, FormPageType } from '@repo/ui/foundations';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { privilegesModuleConfig } from '../../domain/constants';
|
||||||
|
import { createPrivilegeSchema } from '../../domain/validators/privilege.validator';
|
||||||
|
import { FormGeneral } from '../components/form-component/form-general';
|
||||||
|
import { FormPermissions } from '../components/form-component/form-permissions';
|
||||||
|
|
||||||
|
export default function PrivilegePageForm({ formPageType }: { formPageType: FormPageType }) {
|
||||||
|
const { t } = useEnterpriseModuleTranslationContext();
|
||||||
|
|
||||||
|
const title = useMemo(() => {
|
||||||
|
if (formPageType === 'CREATE') {
|
||||||
|
return { title: t('create_page_title'), description: t('create_page_description') };
|
||||||
|
}
|
||||||
|
if (formPageType === 'EDIT') {
|
||||||
|
return { title: t('edit_page_title'), description: t('edit_page_description') };
|
||||||
|
}
|
||||||
|
if (formPageType === 'DUPLICATE') {
|
||||||
|
return { title: t('duplicate_page_title'), description: t('duplicate_page_description') };
|
||||||
|
}
|
||||||
|
return { title: '', description: '' };
|
||||||
|
}, [formPageType, t]);
|
||||||
|
|
||||||
|
const validator = useMemo(() => createPrivilegeSchema(t), [t]);
|
||||||
|
const formControl = useForm({ resolver: zodResolver(validator) });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EnterpriseFormPageProvider
|
||||||
|
formControl={formControl}
|
||||||
|
formPageType={formPageType}
|
||||||
|
ignoreKeyDuplicate={['code']}
|
||||||
|
ignoreKeyUpdate={['status', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy', 'details', 'id']}
|
||||||
|
highlightDataKey="code"
|
||||||
|
pageHeaderProps={{
|
||||||
|
title: title?.title,
|
||||||
|
description: title?.description,
|
||||||
|
breadcrumbs: [
|
||||||
|
{ label: t('nav:system'), type: 'text' },
|
||||||
|
{ label: t('nav:system-privileges'), type: 'link', href: `${privilegesModuleConfig.webUrl}/index` },
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<FormGeneral />
|
||||||
|
<FormPermissions />
|
||||||
|
</Stack>
|
||||||
|
</EnterpriseFormPageProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
EnterpriseIndexPageProvider,
|
||||||
|
useEnterpriseModuleTranslationContext,
|
||||||
|
EnterpriseDataTable,
|
||||||
|
} from '@repo/ui/foundations';
|
||||||
|
import { ColDef, Text } from '@repo/ui/components';
|
||||||
|
import { Trans } from '@repo/core-i18n';
|
||||||
|
import { Shield } from 'lucide-react';
|
||||||
|
import { FilterFormContent } from '../components/index-component/filter-content';
|
||||||
|
import type { PrivilegeEntity } from '../../domain/entities';
|
||||||
|
|
||||||
|
export default function PrivilegePageIndex() {
|
||||||
|
const { t } = useEnterpriseModuleTranslationContext();
|
||||||
|
|
||||||
|
const columnDefs: ColDef<PrivilegeEntity>[] = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{ field: 'code', headerName: t('common:fields.code'), minWidth: 160 },
|
||||||
|
{ field: 'name', headerName: t('common:fields.name'), minWidth: 180 },
|
||||||
|
];
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const filterConfig = useMemo(() => {
|
||||||
|
return {
|
||||||
|
renderBody: (form: any) => {
|
||||||
|
if (!form) return null;
|
||||||
|
return <FilterFormContent form={form} t={t} />;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EnterpriseIndexPageProvider
|
||||||
|
pageHeaderProps={{
|
||||||
|
title: t('title'),
|
||||||
|
description: (
|
||||||
|
<Trans t={t} i18nKey="description" components={{ 1: <Text span fw={500} c="var(--mantine-color-text)" /> }} />
|
||||||
|
),
|
||||||
|
icon: Shield,
|
||||||
|
breadcrumbs: [
|
||||||
|
{ label: t('nav:system'), type: 'text' },
|
||||||
|
{ label: t('nav:system-privileges'), type: 'text' },
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EnterpriseDataTable columnDefs={columnDefs} filterConfig={filterConfig} />
|
||||||
|
</EnterpriseIndexPageProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { EnterpriseModuleState } from '@repo/ui/foundations';
|
||||||
|
import { PrivilegeEntity } from '../../domain/entities';
|
||||||
|
|
||||||
|
export interface PrivilegesStoreState extends EnterpriseModuleState<PrivilegeEntity> {}
|
||||||
|
|
||||||
|
export const privilegesStore = create<PrivilegesStoreState>((set) => ({
|
||||||
|
metaData: { limit: 15 },
|
||||||
|
setMetaData: (data) => set({ metaData: data }),
|
||||||
|
|
||||||
|
filterData: {},
|
||||||
|
setFilterData: (data) => set({ filterData: data }),
|
||||||
|
|
||||||
|
selectedRows: [],
|
||||||
|
setSelectedRows: (rows) => set({ selectedRows: rows }),
|
||||||
|
|
||||||
|
privileges: [],
|
||||||
|
setPrivileges: (privileges) => set({ privileges }),
|
||||||
|
|
||||||
|
tableConfig: null,
|
||||||
|
setTableConfig: (config) => set({ tableConfig: config }),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { noPrivileges } from '@repo/ui/foundations';
|
||||||
|
import { filterMenuByViewPrivilege } from './filter-menu-by-view-privilege';
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
key: 'system',
|
||||||
|
children: [
|
||||||
|
{ key: 'privileges', moduleKey: 'PRIVILEGES', path: '/app/system/privileges/index' },
|
||||||
|
{ key: 'placeholder', path: '/app/system/other' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'example',
|
||||||
|
children: [{ key: 'full-page', moduleKey: 'EXAMPLE_FULL_PAGE', path: '/app/example/full-page/index' }],
|
||||||
|
},
|
||||||
|
{ key: 'dashboard', path: '/app/dashboard' },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('filterMenuByViewPrivilege', () => {
|
||||||
|
it('returns the tree unchanged for a superadmin', () => {
|
||||||
|
expect(filterMenuByViewPrivilege(items, {}, true)).toEqual(items);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides a leaf when moduleKey is present and ALLOW_VIEW is false', () => {
|
||||||
|
const filtered = filterMenuByViewPrivilege(
|
||||||
|
items,
|
||||||
|
{
|
||||||
|
PRIVILEGES: { ...noPrivileges, ALLOW_VIEW: false },
|
||||||
|
EXAMPLE_FULL_PAGE: { ...noPrivileges, ALLOW_VIEW: true },
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(filtered.find((item) => item.key === 'system')?.children).toEqual([
|
||||||
|
{ key: 'placeholder', path: '/app/system/other' },
|
||||||
|
]);
|
||||||
|
expect(filtered.find((item) => item.key === 'example')?.children).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a parent when every child is removed', () => {
|
||||||
|
const filtered = filterMenuByViewPrivilege(
|
||||||
|
items,
|
||||||
|
{
|
||||||
|
PRIVILEGES: { ...noPrivileges },
|
||||||
|
EXAMPLE_FULL_PAGE: { ...noPrivileges },
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(filtered.find((item) => item.key === 'example')).toBeUndefined();
|
||||||
|
expect(filtered.find((item) => item.key === 'system')?.children).toEqual([
|
||||||
|
{ key: 'placeholder', path: '/app/system/other' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a leaf without moduleKey', () => {
|
||||||
|
const filtered = filterMenuByViewPrivilege(items, {}, false);
|
||||||
|
expect(filtered.find((item) => item.key === 'dashboard')).toEqual({ key: 'dashboard', path: '/app/dashboard' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { PrivilegeEntity } from '@repo/ui/foundations';
|
||||||
|
|
||||||
|
export function filterMenuByViewPrivilege<T extends { moduleKey?: string; children?: T[] }>(
|
||||||
|
items: T[],
|
||||||
|
privileges: Record<string, PrivilegeEntity>,
|
||||||
|
isSuperadmin: boolean,
|
||||||
|
): T[] {
|
||||||
|
if (isSuperadmin) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.flatMap((item) => {
|
||||||
|
const children = item.children
|
||||||
|
? filterMenuByViewPrivilege(item.children, privileges, false)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (item.moduleKey && privileges[item.moduleKey]?.ALLOW_VIEW !== true) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (children) {
|
||||||
|
if (children.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [{ ...item, children }];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [item];
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { noPrivileges } from '@repo/ui/foundations';
|
|||||||
import { mapUserPrivileges } from './map-user-privileges';
|
import { mapUserPrivileges } from './map-user-privileges';
|
||||||
|
|
||||||
describe('mapUserPrivileges', () => {
|
describe('mapUserPrivileges', () => {
|
||||||
it('maps view/create/update/delete and ignores extra API flags', () => {
|
it('maps view/create/update/delete/import flags', () => {
|
||||||
const result = mapUserPrivileges(
|
const result = mapUserPrivileges(
|
||||||
{
|
{
|
||||||
PRIVILEGES: {
|
PRIVILEGES: {
|
||||||
@@ -23,6 +23,7 @@ describe('mapUserPrivileges', () => {
|
|||||||
ALLOW_CREATE: true,
|
ALLOW_CREATE: true,
|
||||||
ALLOW_EDIT: false,
|
ALLOW_EDIT: false,
|
||||||
ALLOW_DELETE: false,
|
ALLOW_DELETE: false,
|
||||||
|
ALLOW_IMPORT: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -21,5 +21,8 @@ function mapPermissionFlags(flags: AuthPermissionFlags = {}): PrivilegeEntity {
|
|||||||
ALLOW_CREATE: flags.create ?? false,
|
ALLOW_CREATE: flags.create ?? false,
|
||||||
ALLOW_EDIT: flags.update ?? false,
|
ALLOW_EDIT: flags.update ?? false,
|
||||||
ALLOW_DELETE: flags.delete ?? false,
|
ALLOW_DELETE: flags.delete ?? false,
|
||||||
|
ALLOW_IMPORT: flags.import ?? false,
|
||||||
|
ALLOW_ACTIVATE: flags.update ?? false,
|
||||||
|
ALLOW_DEACTIVATE: flags.update ?? false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { PrivilegeEntity } from '@repo/ui/foundations';
|
||||||
|
import { appDatabase, AppDatabaseKey } from '../storage/local';
|
||||||
|
import { filterMenuByViewPrivilege } from './filter-menu-by-view-privilege';
|
||||||
|
|
||||||
|
export function useFilteredMenuItems<T extends { moduleKey?: string; children?: T[] }>(items: T[]): T[] {
|
||||||
|
const [filtered, setFiltered] = useState(items);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const profile = await appDatabase.getItem<{ isSuperadmin?: boolean }>(AppDatabaseKey.USER_PROFILE);
|
||||||
|
const privileges = await appDatabase.getItem<Record<string, PrivilegeEntity>>(AppDatabaseKey.USER_PRIVILEGE);
|
||||||
|
if (cancelled) return;
|
||||||
|
setFiltered(filterMenuByViewPrivilege(items, privileges ?? {}, Boolean(profile?.isSuperadmin)));
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ export enum STATUS_DATA {
|
|||||||
|
|
||||||
INACTIVE = 'inactive',
|
INACTIVE = 'inactive',
|
||||||
DEACTIVATED = 'deactivated',
|
DEACTIVATED = 'deactivated',
|
||||||
|
ARCHIVED = 'archived',
|
||||||
|
|
||||||
REQUEST = 'request',
|
REQUEST = 'request',
|
||||||
REQUESTING = 'requesting',
|
REQUESTING = 'requesting',
|
||||||
@@ -198,6 +199,7 @@ export const DEFAULT_STATUS_MAP: Record<string, BadgeProps> = {
|
|||||||
[STATUS_DATA.TODO]: { color: STATUS_COLOR.TODO, leftSection: getIcon(Clock) },
|
[STATUS_DATA.TODO]: { color: STATUS_COLOR.TODO, leftSection: getIcon(Clock) },
|
||||||
|
|
||||||
[STATUS_DATA.INACTIVE]: { color: STATUS_COLOR.INACTIVE, leftSection: getIcon(XCircle) },
|
[STATUS_DATA.INACTIVE]: { color: STATUS_COLOR.INACTIVE, leftSection: getIcon(XCircle) },
|
||||||
|
[STATUS_DATA.ARCHIVED]: { color: STATUS_COLOR.INACTIVE, leftSection: getIcon(XCircle) },
|
||||||
[STATUS_DATA.DEACTIVATED]: { color: STATUS_COLOR.DEACTIVATED, leftSection: getIcon(XCircle) },
|
[STATUS_DATA.DEACTIVATED]: { color: STATUS_COLOR.DEACTIVATED, leftSection: getIcon(XCircle) },
|
||||||
[STATUS_DATA.CLOSE]: { color: STATUS_COLOR.CLOSE, leftSection: getIcon(XCircle) },
|
[STATUS_DATA.CLOSE]: { color: STATUS_COLOR.CLOSE, leftSection: getIcon(XCircle) },
|
||||||
[STATUS_DATA.CLOSED]: { color: STATUS_COLOR.CLOSED, leftSection: getIcon(XCircle) },
|
[STATUS_DATA.CLOSED]: { color: STATUS_COLOR.CLOSED, leftSection: getIcon(XCircle) },
|
||||||
|
|||||||
+1
-1
@@ -63,7 +63,7 @@ export function BulkActionMenu({
|
|||||||
|
|
||||||
const hasActive = selectedRows.some((row) => row[statusKey]?.toLowerCase() === 'active');
|
const hasActive = selectedRows.some((row) => row[statusKey]?.toLowerCase() === 'active');
|
||||||
const hasInactive = selectedRows.some(
|
const hasInactive = selectedRows.some(
|
||||||
(row) => row[statusKey]?.toLowerCase() === 'inactive' || row[statusKey]?.toLowerCase() === 'draft',
|
(row) => row[statusKey]?.toLowerCase() === 'inactive' || row[statusKey]?.toLowerCase() === 'draft' || row[statusKey]?.toLowerCase() === 'archived',
|
||||||
);
|
);
|
||||||
|
|
||||||
const defaultActions: PageActionProps[] = [];
|
const defaultActions: PageActionProps[] = [];
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const defaultPrivileges: PrivilegeEntity = {
|
|||||||
ALLOW_CREATE: true,
|
ALLOW_CREATE: true,
|
||||||
ALLOW_EDIT: true,
|
ALLOW_EDIT: true,
|
||||||
ALLOW_DELETE: true,
|
ALLOW_DELETE: true,
|
||||||
|
ALLOW_IMPORT: true,
|
||||||
|
|
||||||
ALLOW_PRINT: true,
|
ALLOW_PRINT: true,
|
||||||
ALLOW_PRINT_COPY: true,
|
ALLOW_PRINT_COPY: true,
|
||||||
@@ -29,6 +30,7 @@ export const noPrivileges: PrivilegeEntity = {
|
|||||||
ALLOW_CREATE: false,
|
ALLOW_CREATE: false,
|
||||||
ALLOW_EDIT: false,
|
ALLOW_EDIT: false,
|
||||||
ALLOW_DELETE: false,
|
ALLOW_DELETE: false,
|
||||||
|
ALLOW_IMPORT: false,
|
||||||
|
|
||||||
ALLOW_PRINT: false,
|
ALLOW_PRINT: false,
|
||||||
ALLOW_PRINT_COPY: false,
|
ALLOW_PRINT_COPY: false,
|
||||||
|
|||||||
@@ -299,6 +299,7 @@ export interface PrivilegeEntity {
|
|||||||
ALLOW_CREATE: boolean;
|
ALLOW_CREATE: boolean;
|
||||||
ALLOW_EDIT: boolean;
|
ALLOW_EDIT: boolean;
|
||||||
ALLOW_DELETE: boolean;
|
ALLOW_DELETE: boolean;
|
||||||
|
ALLOW_IMPORT: boolean;
|
||||||
|
|
||||||
ALLOW_PRINT: boolean;
|
ALLOW_PRINT: boolean;
|
||||||
ALLOW_PRINT_COPY: boolean;
|
ALLOW_PRINT_COPY: boolean;
|
||||||
|
|||||||
@@ -504,7 +504,7 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
|||||||
const isMasterData = moduleType === 'MASTER_DATA';
|
const isMasterData = moduleType === 'MASTER_DATA';
|
||||||
|
|
||||||
const isDataActive = detailData && ['active'].includes(detailData[statusKey]?.toLowerCase());
|
const isDataActive = detailData && ['active'].includes(detailData[statusKey]?.toLowerCase());
|
||||||
const isDataInActive = detailData && ['inactive', 'draft'].includes(detailData[statusKey]?.toLowerCase());
|
const isDataInActive = detailData && ['inactive', 'draft', 'archived'].includes(detailData[statusKey]?.toLowerCase());
|
||||||
|
|
||||||
// 1. Declare action with Privilege & Module Type conditions directly
|
// 1. Declare action with Privilege & Module Type conditions directly
|
||||||
const rawActions = [
|
const rawActions = [
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
} from '../hooks/use-module.context';
|
} from '../hooks/use-module.context';
|
||||||
import { StatusPage } from '../../../components';
|
import { StatusPage } from '../../../components';
|
||||||
import { useEnterpriseStorage } from '../hooks/enterprise-storage.context';
|
import { useEnterpriseStorage } from '../hooks/enterprise-storage.context';
|
||||||
import { defaultPrivileges, noPrivileges } from '../constant';
|
import { noPrivileges } from '../constant';
|
||||||
|
|
||||||
export interface EnterpriseModuleProviderProps<
|
export interface EnterpriseModuleProviderProps<
|
||||||
E extends BaseEntity,
|
E extends BaseEntity,
|
||||||
@@ -47,7 +47,8 @@ export function EnterpriseModuleProvider<
|
|||||||
// Config Slice (Static)
|
// Config Slice (Static)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const storage = useEnterpriseStorage();
|
const storage = useEnterpriseStorage();
|
||||||
const [parsedPrivileges, setParsedPrivileges] = useState<PrivilegeEntity>(defaultPrivileges);
|
const [arePrivilegesReady, setArePrivilegesReady] = useState(false);
|
||||||
|
const [parsedPrivileges, setParsedPrivileges] = useState<PrivilegeEntity>(noPrivileges);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchPrivilegeData = async () => {
|
const fetchPrivilegeData = async () => {
|
||||||
@@ -63,6 +64,8 @@ export function EnterpriseModuleProvider<
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
setParsedPrivileges(noPrivileges);
|
setParsedPrivileges(noPrivileges);
|
||||||
console.error('Failed to retrieve privilege data:', error);
|
console.error('Failed to retrieve privilege data:', error);
|
||||||
|
} finally {
|
||||||
|
setArePrivilegesReady(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -176,6 +179,10 @@ export function EnterpriseModuleProvider<
|
|||||||
|
|
||||||
const { ALLOW_VIEW } = configSlice.privileges ?? {};
|
const { ALLOW_VIEW } = configSlice.privileges ?? {};
|
||||||
|
|
||||||
|
if (!arePrivilegesReady) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!ALLOW_VIEW) {
|
if (!ALLOW_VIEW) {
|
||||||
return (
|
return (
|
||||||
<StatusPage
|
<StatusPage
|
||||||
|
|||||||
Reference in New Issue
Block a user