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 matrix 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 izin 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 (