diff --git a/apps/web/src/apps/main/index.tsx b/apps/web/src/apps/main/index.tsx
index dd55074..36149eb 100644
--- a/apps/web/src/apps/main/index.tsx
+++ b/apps/web/src/apps/main/index.tsx
@@ -7,6 +7,7 @@ const ExampleModule = lazy(() => import('./modules/example'));
const SystemSetting = lazy(() => import('./modules/system/setting'));
const SystemInformation = lazy(() => import('./modules/system/information'));
const SystemNotification = lazy(() => import('./modules/system/notification'));
+const PrivilegesModule = lazy(() => import('./modules/system/privileges/presentation/factory'));
export default function AppModule() {
return (
@@ -17,6 +18,7 @@ export default function AppModule() {
} />
} />
} />
+ } />
} />
} />
diff --git a/apps/web/src/apps/main/layouts/data/menu.data.ts b/apps/web/src/apps/main/layouts/data/menu.data.ts
index c9dd227..7ee05ae 100644
--- a/apps/web/src/apps/main/layouts/data/menu.data.ts
+++ b/apps/web/src/apps/main/layouts/data/menu.data.ts
@@ -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',
label: 'nav:example-module',
@@ -246,6 +261,7 @@ export const MENU_ITEMS: MenuItemType[] = [
label: 'nav:example-full-page',
icon: LayoutDashboard,
path: '/app/example/full-page/index',
+ moduleKey: 'EXAMPLE_FULL_PAGE',
},
// {
// key: 'example-single-page',
diff --git a/apps/web/src/apps/main/layouts/languages/en/nav.json b/apps/web/src/apps/main/layouts/languages/en/nav.json
index fe0e58b..10d9146 100644
--- a/apps/web/src/apps/main/layouts/languages/en/nav.json
+++ b/apps/web/src/apps/main/layouts/languages/en/nav.json
@@ -33,5 +33,6 @@
"example-module": "Example Module",
"example-full-page": "Example Full Page",
"example-single-page": "Example Single Page",
- "system": "System"
-}
\ No newline at end of file
+ "system": "System",
+ "system-privileges": "Privileges"
+}
diff --git a/apps/web/src/apps/main/layouts/languages/id/nav.json b/apps/web/src/apps/main/layouts/languages/id/nav.json
index 4ef55de..e82c9dc 100644
--- a/apps/web/src/apps/main/layouts/languages/id/nav.json
+++ b/apps/web/src/apps/main/layouts/languages/id/nav.json
@@ -33,5 +33,6 @@
"example-module": "Modul Contoh",
"example-full-page": "Contoh Halaman Penuh",
"example-single-page": "Contoh Halaman Tunggal",
- "system": "Sistem"
-}
\ No newline at end of file
+ "system": "Sistem",
+ "system-privileges": "Hak Akses"
+}
diff --git a/apps/web/src/apps/main/layouts/module.layout.tsx b/apps/web/src/apps/main/layouts/module.layout.tsx
index 666c814..f169694 100644
--- a/apps/web/src/apps/main/layouts/module.layout.tsx
+++ b/apps/web/src/apps/main/layouts/module.layout.tsx
@@ -7,6 +7,7 @@ import { HistoryDrawer } from './components/history';
import { BookmarkDrawer } from './components/bookmark';
import { useHistoryTracker } from './hooks/useHistoryTracker';
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 navEn from './languages/en/nav.json';
@@ -27,6 +28,7 @@ registerModuleNamespace('bookmark', { en: bookmarkEn, id: bookmarkId });
export default function ModuleLayout({ children }: { children: React.ReactNode }) {
// FIXME: To disable the history tracker, simply comment out or remove the following line:
useHistoryTracker();
+ const menuItems = useFilteredMenuItems(MENU_ITEMS);
const configAppShell: CoreAppShellConfig = {
variant: 'header-first',
@@ -50,7 +52,7 @@ export default function ModuleLayout({ children }: { children: React.ReactNode }
header: ,
sidebar: (
{
+ 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,
+ });
+ });
+});
diff --git a/apps/web/src/apps/main/modules/system/privileges/data/privilege.remote.service.ts b/apps/web/src/apps/main/modules/system/privileges/data/privilege.remote.service.ts
new file mode 100644
index 0000000..77c725d
--- /dev/null
+++ b/apps/web/src/apps/main/modules/system/privileges/data/privilege.remote.service.ts
@@ -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 {
+ constructor(httpClient: AxiosInstance, config: DataServicesConfig) {
+ 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(id: string, config?: AxiosRequestConfig): Promise> {
+ const result = await this.execute(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, config?: AxiosRequestConfig) {
+ return this.execute(DESCRIPTORS.activate, {
+ variableURL: { id },
+ config: { ...config, data: { status: 'active' } },
+ });
+ }
+
+ deactivate(id: string, _meta?: Record, config?: AxiosRequestConfig) {
+ return this.execute(DESCRIPTORS.deactivate, {
+ variableURL: { id },
+ config: { ...config, data: { status: 'archived' } },
+ });
+ }
+
+ batchActivate(ids: Array, _meta?: Record, config?: AxiosRequestConfig) {
+ return this.execute(DESCRIPTORS.batchActivate, {
+ config: { ...config, data: { ids, status: 'active' } },
+ });
+ }
+
+ batchDeactivate(ids: Array, _meta?: Record, config?: AxiosRequestConfig) {
+ return this.execute(DESCRIPTORS.batchDeactivate, {
+ config: { ...config, data: { ids, status: 'archived' } },
+ });
+ }
+
+ async listPrivilegeKeys(): Promise> {
+ 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) };
+ }
+}
diff --git a/apps/web/src/apps/main/modules/system/privileges/domain/constants/index.ts b/apps/web/src/apps/main/modules/system/privileges/domain/constants/index.ts
new file mode 100644
index 0000000..d199f1e
--- /dev/null
+++ b/apps/web/src/apps/main/modules/system/privileges/domain/constants/index.ts
@@ -0,0 +1 @@
+export * from './privilege.constants';
diff --git a/apps/web/src/apps/main/modules/system/privileges/domain/constants/privilege.constants.ts b/apps/web/src/apps/main/modules/system/privileges/domain/constants/privilege.constants.ts
new file mode 100644
index 0000000..426230a
--- /dev/null
+++ b/apps/web/src/apps/main/modules/system/privileges/domain/constants/privilege.constants.ts
@@ -0,0 +1,11 @@
+import { ModuleConfigEntity } from '@repo/ui/foundations';
+import type { PrivilegeEntity } from '../entities';
+
+export const privilegesModuleConfig: ModuleConfigEntity = {
+ moduleKey: 'PRIVILEGES',
+ translationNamespace: 'PRIVILEGES',
+ apiUrl: '/privileges',
+ webUrl: '/app/system/privileges',
+ moduleCategory: 'FULL_PAGE',
+ moduleType: 'MASTER_DATA',
+} as const;
diff --git a/apps/web/src/apps/main/modules/system/privileges/domain/entities/index.ts b/apps/web/src/apps/main/modules/system/privileges/domain/entities/index.ts
new file mode 100644
index 0000000..21ec4ae
--- /dev/null
+++ b/apps/web/src/apps/main/modules/system/privileges/domain/entities/index.ts
@@ -0,0 +1 @@
+export * from './privilege.entity';
diff --git a/apps/web/src/apps/main/modules/system/privileges/domain/entities/privilege.entity.ts b/apps/web/src/apps/main/modules/system/privileges/domain/entities/privilege.entity.ts
new file mode 100644
index 0000000..f25a603
--- /dev/null
+++ b/apps/web/src/apps/main/modules/system/privileges/domain/entities/privilege.entity.ts
@@ -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;
+}
+
+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,
+};
diff --git a/apps/web/src/apps/main/modules/system/privileges/domain/factories/index.ts b/apps/web/src/apps/main/modules/system/privileges/domain/factories/index.ts
new file mode 100644
index 0000000..bf64bca
--- /dev/null
+++ b/apps/web/src/apps/main/modules/system/privileges/domain/factories/index.ts
@@ -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,
+});
diff --git a/apps/web/src/apps/main/modules/system/privileges/domain/transformers/privilege.remote.transformer.test.ts b/apps/web/src/apps/main/modules/system/privileges/domain/transformers/privilege.remote.transformer.test.ts
new file mode 100644
index 0000000..665998b
--- /dev/null
+++ b/apps/web/src/apps/main/modules/system/privileges/domain/transformers/privilege.remote.transformer.test.ts
@@ -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({});
+ });
+});
diff --git a/apps/web/src/apps/main/modules/system/privileges/domain/transformers/privilege.remote.transformer.ts b/apps/web/src/apps/main/modules/system/privileges/domain/transformers/privilege.remote.transformer.ts
new file mode 100644
index 0000000..dce8ad5
--- /dev/null
+++ b/apps/web/src/apps/main/modules/system/privileges/domain/transformers/privilege.remote.transformer.ts
@@ -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 {
+ if (!details?.length) {
+ return {};
+ }
+
+ return details.reduce>((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 | 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 {
+ 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): Partial {
+ return {
+ name: entity.name,
+ code: entity.code,
+ details: matrixToDetails(entity.matrix) as PrivilegeEntity['details'],
+ };
+ }
+
+ transformEditPayload(entity: Partial): Partial {
+ return {
+ name: entity.name,
+ code: entity.code,
+ details: matrixToDetails(entity.matrix) as PrivilegeEntity['details'],
+ };
+ }
+}
diff --git a/apps/web/src/apps/main/modules/system/privileges/domain/validators/privilege.validator.test.ts b/apps/web/src/apps/main/modules/system/privileges/domain/validators/privilege.validator.test.ts
new file mode 100644
index 0000000..988621b
--- /dev/null
+++ b/apps/web/src/apps/main/modules/system/privileges/domain/validators/privilege.validator.test.ts
@@ -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);
+ });
+});
diff --git a/apps/web/src/apps/main/modules/system/privileges/domain/validators/privilege.validator.ts b/apps/web/src/apps/main/modules/system/privileges/domain/validators/privilege.validator.ts
new file mode 100644
index 0000000..2a688c6
--- /dev/null
+++ b/apps/web/src/apps/main/modules/system/privileges/domain/validators/privilege.validator.ts
@@ -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(),
+ });
+};
diff --git a/apps/web/src/apps/main/modules/system/privileges/presentation/components/detail-component/detail-general.tsx b/apps/web/src/apps/main/modules/system/privileges/presentation/components/detail-component/detail-general.tsx
new file mode 100644
index 0000000..3cf0bc3
--- /dev/null
+++ b/apps/web/src/apps/main/modules/system/privileges/presentation/components/detail-component/detail-general.tsx
@@ -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();
+ const { t } = useEnterpriseModuleTranslationContext();
+ const data = detailData;
+
+ return (
+
+
+ {t('section_general')}
+
+
+
+
+
+ }
+ />
+ }
+ />
+ }
+ />
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/system/privileges/presentation/components/detail-component/detail-permissions.tsx b/apps/web/src/apps/main/modules/system/privileges/presentation/components/detail-component/detail-permissions.tsx
new file mode 100644
index 0000000..0dc3294
--- /dev/null
+++ b/apps/web/src/apps/main/modules/system/privileges/presentation/components/detail-component/detail-permissions.tsx
@@ -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();
+ 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 (
+
+
+ {t('section_permissions')}
+
+
+
+
+
+ {t('matrix_module')}
+ {PRIVILEGE_ACTIONS.map((action) => (
+
+ {t(`matrix_${action}`)}
+
+ ))}
+
+
+
+ {rows.map((row) => (
+
+
+ {row.label}
+
+ {row.code}
+
+
+ {PRIVILEGE_ACTIONS.map((action) => (
+
+ {row.cell[action] ? : }
+
+ ))}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/system/privileges/presentation/components/form-component/form-general.tsx b/apps/web/src/apps/main/modules/system/privileges/presentation/components/form-component/form-general.tsx
new file mode 100644
index 0000000..bbf21c5
--- /dev/null
+++ b/apps/web/src/apps/main/modules/system/privileges/presentation/components/form-component/form-general.tsx
@@ -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 (
+
+
+ {t('section_general')}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/system/privileges/presentation/components/form-component/form-permissions.tsx b/apps/web/src/apps/main/modules/system/privileges/presentation/components/form-component/form-permissions.tsx
new file mode 100644
index 0000000..996425b
--- /dev/null
+++ b/apps/web/src/apps/main/modules/system/privileges/presentation/components/form-component/form-permissions.tsx
@@ -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([]);
+
+ 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 (
+
+
+ {t('section_permissions')}
+
+
+
+
+
+ {t('matrix_module')}
+ {PRIVILEGE_ACTIONS.map((action) => (
+
+ {t(`matrix_${action}`)}
+
+ ))}
+
+
+
+ {keys.map((key) => (
+
+
+ {key.label || key.code}
+
+ {key.code}
+
+
+ {PRIVILEGE_ACTIONS.map((action) => (
+
+
+
+ ))}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/system/privileges/presentation/components/index-component/filter-content.tsx b/apps/web/src/apps/main/modules/system/privileges/presentation/components/index-component/filter-content.tsx
new file mode 100644
index 0000000..906c8ed
--- /dev/null
+++ b/apps/web/src/apps/main/modules/system/privileges/presentation/components/index-component/filter-content.tsx
@@ -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; t: (key: string) => string }) => {
+ return (
+
+
+
+
+
+ );
+};
diff --git a/apps/web/src/apps/main/modules/system/privileges/presentation/factory/index.tsx b/apps/web/src/apps/main/modules/system/privileges/presentation/factory/index.tsx
new file mode 100644
index 0000000..e422bbf
--- /dev/null
+++ b/apps/web/src/apps/main/modules/system/privileges/presentation/factory/index.tsx
@@ -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 (
+
+ config={privilegesModuleConfig}
+ dataServices={privilegesDataService}
+ store={privilegesStore}
+ >
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/system/privileges/presentation/languages/en/privileges.json b/apps/web/src/apps/main/modules/system/privileges/presentation/languages/en/privileges.json
new file mode 100644
index 0000000..2a155b0
--- /dev/null
+++ b/apps/web/src/apps/main/modules/system/privileges/presentation/languages/en/privileges.json
@@ -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 matrix1> 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"
+}
diff --git a/apps/web/src/apps/main/modules/system/privileges/presentation/languages/id/privileges.json b/apps/web/src/apps/main/modules/system/privileges/presentation/languages/id/privileges.json
new file mode 100644
index 0000000..cfd80ab
--- /dev/null
+++ b/apps/web/src/apps/main/modules/system/privileges/presentation/languages/id/privileges.json
@@ -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 izin1> 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"
+}
diff --git a/apps/web/src/apps/main/modules/system/privileges/presentation/pages/privilege.page.detail.tsx b/apps/web/src/apps/main/modules/system/privileges/presentation/pages/privilege.page.detail.tsx
new file mode 100644
index 0000000..424ebe1
--- /dev/null
+++ b/apps/web/src/apps/main/modules/system/privileges/presentation/pages/privilege.page.detail.tsx
@@ -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 (
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/system/privileges/presentation/pages/privilege.page.form.tsx b/apps/web/src/apps/main/modules/system/privileges/presentation/pages/privilege.page.form.tsx
new file mode 100644
index 0000000..d992bce
--- /dev/null
+++ b/apps/web/src/apps/main/modules/system/privileges/presentation/pages/privilege.page.form.tsx
@@ -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 (
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/system/privileges/presentation/pages/privilege.page.index.tsx b/apps/web/src/apps/main/modules/system/privileges/presentation/pages/privilege.page.index.tsx
new file mode 100644
index 0000000..c1aee0b
--- /dev/null
+++ b/apps/web/src/apps/main/modules/system/privileges/presentation/pages/privilege.page.index.tsx
@@ -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[] = 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 ;
+ },
+ };
+ }, [t]);
+
+ return (
+ }} />
+ ),
+ icon: Shield,
+ breadcrumbs: [
+ { label: t('nav:system'), type: 'text' },
+ { label: t('nav:system-privileges'), type: 'text' },
+ ],
+ }}
+ >
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/system/privileges/presentation/store/index.ts b/apps/web/src/apps/main/modules/system/privileges/presentation/store/index.ts
new file mode 100644
index 0000000..7d6bf33
--- /dev/null
+++ b/apps/web/src/apps/main/modules/system/privileges/presentation/store/index.ts
@@ -0,0 +1,22 @@
+import { create } from 'zustand';
+import { EnterpriseModuleState } from '@repo/ui/foundations';
+import { PrivilegeEntity } from '../../domain/entities';
+
+export interface PrivilegesStoreState extends EnterpriseModuleState {}
+
+export const privilegesStore = create((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 }),
+}));
diff --git a/apps/web/src/core/lib/filter-menu-by-view-privilege.test.ts b/apps/web/src/core/lib/filter-menu-by-view-privilege.test.ts
new file mode 100644
index 0000000..fe4546a
--- /dev/null
+++ b/apps/web/src/core/lib/filter-menu-by-view-privilege.test.ts
@@ -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' });
+ });
+});
diff --git a/apps/web/src/core/lib/filter-menu-by-view-privilege.ts b/apps/web/src/core/lib/filter-menu-by-view-privilege.ts
new file mode 100644
index 0000000..7b837ea
--- /dev/null
+++ b/apps/web/src/core/lib/filter-menu-by-view-privilege.ts
@@ -0,0 +1,30 @@
+import type { PrivilegeEntity } from '@repo/ui/foundations';
+
+export function filterMenuByViewPrivilege(
+ items: T[],
+ privileges: Record,
+ 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];
+ });
+}
diff --git a/apps/web/src/core/lib/map-user-privileges.test.ts b/apps/web/src/core/lib/map-user-privileges.test.ts
index edace75..2061375 100644
--- a/apps/web/src/core/lib/map-user-privileges.test.ts
+++ b/apps/web/src/core/lib/map-user-privileges.test.ts
@@ -3,7 +3,7 @@ import { noPrivileges } from '@repo/ui/foundations';
import { mapUserPrivileges } from './map-user-privileges';
describe('mapUserPrivileges', () => {
- it('maps view/create/update/delete and ignores extra API flags', () => {
+ it('maps view/create/update/delete/import flags', () => {
const result = mapUserPrivileges(
{
PRIVILEGES: {
@@ -23,6 +23,7 @@ describe('mapUserPrivileges', () => {
ALLOW_CREATE: true,
ALLOW_EDIT: false,
ALLOW_DELETE: false,
+ ALLOW_IMPORT: true,
});
});
diff --git a/apps/web/src/core/lib/map-user-privileges.ts b/apps/web/src/core/lib/map-user-privileges.ts
index d9a90d2..f2a3914 100644
--- a/apps/web/src/core/lib/map-user-privileges.ts
+++ b/apps/web/src/core/lib/map-user-privileges.ts
@@ -21,5 +21,8 @@ function mapPermissionFlags(flags: AuthPermissionFlags = {}): PrivilegeEntity {
ALLOW_CREATE: flags.create ?? false,
ALLOW_EDIT: flags.update ?? false,
ALLOW_DELETE: flags.delete ?? false,
+ ALLOW_IMPORT: flags.import ?? false,
+ ALLOW_ACTIVATE: flags.update ?? false,
+ ALLOW_DEACTIVATE: flags.update ?? false,
};
}
diff --git a/apps/web/src/core/lib/use-filtered-menu-items.ts b/apps/web/src/core/lib/use-filtered-menu-items.ts
new file mode 100644
index 0000000..9680f6c
--- /dev/null
+++ b/apps/web/src/core/lib/use-filtered-menu-items.ts
@@ -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(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>(AppDatabaseKey.USER_PRIVILEGE);
+ if (cancelled) return;
+ setFiltered(filterMenuByViewPrivilege(items, privileges ?? {}, Boolean(profile?.isSuperadmin)));
+ }
+
+ load();
+ return () => {
+ cancelled = true;
+ };
+ }, [items]);
+
+ return filtered;
+}
diff --git a/packages/ui/src/components/status-badge/status-badge.tsx b/packages/ui/src/components/status-badge/status-badge.tsx
index 3ff0066..8f158ed 100644
--- a/packages/ui/src/components/status-badge/status-badge.tsx
+++ b/packages/ui/src/components/status-badge/status-badge.tsx
@@ -23,6 +23,7 @@ export enum STATUS_DATA {
INACTIVE = 'inactive',
DEACTIVATED = 'deactivated',
+ ARCHIVED = 'archived',
REQUEST = 'request',
REQUESTING = 'requesting',
@@ -198,6 +199,7 @@ export const DEFAULT_STATUS_MAP: Record = {
[STATUS_DATA.TODO]: { color: STATUS_COLOR.TODO, leftSection: getIcon(Clock) },
[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.CLOSE]: { color: STATUS_COLOR.CLOSE, leftSection: getIcon(XCircle) },
[STATUS_DATA.CLOSED]: { color: STATUS_COLOR.CLOSED, leftSection: getIcon(XCircle) },
diff --git a/packages/ui/src/foundations/enterprise-module/components/data-table/components/bulk-actions.tsx b/packages/ui/src/foundations/enterprise-module/components/data-table/components/bulk-actions.tsx
index 6619d33..b49981d 100644
--- a/packages/ui/src/foundations/enterprise-module/components/data-table/components/bulk-actions.tsx
+++ b/packages/ui/src/foundations/enterprise-module/components/data-table/components/bulk-actions.tsx
@@ -63,7 +63,7 @@ export function BulkActionMenu({
const hasActive = selectedRows.some((row) => row[statusKey]?.toLowerCase() === 'active');
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[] = [];
diff --git a/packages/ui/src/foundations/enterprise-module/constant/default-privilege.ts b/packages/ui/src/foundations/enterprise-module/constant/default-privilege.ts
index 2678d0f..1e255f3 100644
--- a/packages/ui/src/foundations/enterprise-module/constant/default-privilege.ts
+++ b/packages/ui/src/foundations/enterprise-module/constant/default-privilege.ts
@@ -6,6 +6,7 @@ export const defaultPrivileges: PrivilegeEntity = {
ALLOW_CREATE: true,
ALLOW_EDIT: true,
ALLOW_DELETE: true,
+ ALLOW_IMPORT: true,
ALLOW_PRINT: true,
ALLOW_PRINT_COPY: true,
@@ -29,6 +30,7 @@ export const noPrivileges: PrivilegeEntity = {
ALLOW_CREATE: false,
ALLOW_EDIT: false,
ALLOW_DELETE: false,
+ ALLOW_IMPORT: false,
ALLOW_PRINT: false,
ALLOW_PRINT_COPY: false,
diff --git a/packages/ui/src/foundations/enterprise-module/entities/entity.ts b/packages/ui/src/foundations/enterprise-module/entities/entity.ts
index 711b43a..25d55f2 100644
--- a/packages/ui/src/foundations/enterprise-module/entities/entity.ts
+++ b/packages/ui/src/foundations/enterprise-module/entities/entity.ts
@@ -299,6 +299,7 @@ export interface PrivilegeEntity {
ALLOW_CREATE: boolean;
ALLOW_EDIT: boolean;
ALLOW_DELETE: boolean;
+ ALLOW_IMPORT: boolean;
ALLOW_PRINT: boolean;
ALLOW_PRINT_COPY: boolean;
diff --git a/packages/ui/src/foundations/enterprise-module/providers/detail-page.provider.tsx b/packages/ui/src/foundations/enterprise-module/providers/detail-page.provider.tsx
index 07c8a4a..03b985c 100644
--- a/packages/ui/src/foundations/enterprise-module/providers/detail-page.provider.tsx
+++ b/packages/ui/src/foundations/enterprise-module/providers/detail-page.provider.tsx
@@ -504,7 +504,7 @@ export function EnterpriseDetailPageProvider(
const isMasterData = moduleType === 'MASTER_DATA';
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
const rawActions = [
diff --git a/packages/ui/src/foundations/enterprise-module/providers/module.provider.tsx b/packages/ui/src/foundations/enterprise-module/providers/module.provider.tsx
index 1ea0956..970111c 100644
--- a/packages/ui/src/foundations/enterprise-module/providers/module.provider.tsx
+++ b/packages/ui/src/foundations/enterprise-module/providers/module.provider.tsx
@@ -21,7 +21,7 @@ import {
} from '../hooks/use-module.context';
import { StatusPage } from '../../../components';
import { useEnterpriseStorage } from '../hooks/enterprise-storage.context';
-import { defaultPrivileges, noPrivileges } from '../constant';
+import { noPrivileges } from '../constant';
export interface EnterpriseModuleProviderProps<
E extends BaseEntity,
@@ -47,7 +47,8 @@ export function EnterpriseModuleProvider<
// Config Slice (Static)
// ---------------------------------------------------------------------------
const storage = useEnterpriseStorage();
- const [parsedPrivileges, setParsedPrivileges] = useState(defaultPrivileges);
+ const [arePrivilegesReady, setArePrivilegesReady] = useState(false);
+ const [parsedPrivileges, setParsedPrivileges] = useState(noPrivileges);
useEffect(() => {
const fetchPrivilegeData = async () => {
@@ -63,6 +64,8 @@ export function EnterpriseModuleProvider<
} catch (error) {
setParsedPrivileges(noPrivileges);
console.error('Failed to retrieve privilege data:', error);
+ } finally {
+ setArePrivilegesReady(true);
}
};
@@ -176,6 +179,10 @@ export function EnterpriseModuleProvider<
const { ALLOW_VIEW } = configSlice.privileges ?? {};
+ if (!arePrivilegesReady) {
+ return null;
+ }
+
if (!ALLOW_VIEW) {
return (