feat: update privilege keys and module configurations for enhanced access control
- Refactored privilege keys in `api.md` to use a more structured naming convention, aligning with the new `Group.Parent.Module` format. - Updated various module configurations across the application to reflect the new privilege key structure, ensuring consistent access control. - Removed deprecated keys and streamlined the privilege management process, enhancing clarity and maintainability. - Added new tests for privilege key parsing and grouping functionalities to ensure reliability and correctness. These changes significantly improve the application's privilege management system, providing a clearer structure for access control and enhancing overall security.
This commit is contained in:
@@ -167,9 +167,6 @@ export default function LoginPage() {
|
||||
label: { color: '#64748b', fontWeight: 500 },
|
||||
}}
|
||||
/>
|
||||
<a href="#" className="text-[13px] text-brand-600 font-semibold hover:underline">
|
||||
{t('forgot_password')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@@ -182,43 +179,6 @@ export default function LoginPage() {
|
||||
>
|
||||
{t('login_button')}
|
||||
</Button>
|
||||
|
||||
<Divider
|
||||
label={t('or_login_with')}
|
||||
labelPosition="center"
|
||||
my="lg"
|
||||
styles={{ label: { color: '#94a3b8', fontSize: '12px' } }}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="md"
|
||||
className="border-gray-200 hover:bg-gray-50 transition-colors"
|
||||
styles={{ root: { borderRadius: '10px', height: '44px' } }}
|
||||
>
|
||||
<GoogleIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="md"
|
||||
className="border-gray-200 hover:bg-gray-50 transition-colors"
|
||||
styles={{ root: { borderRadius: '10px', height: '44px' } }}
|
||||
>
|
||||
<Apple className="w-5.5 h-5.5 text-black" fill="black" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="md"
|
||||
className="border-gray-200 hover:bg-gray-50 transition-colors"
|
||||
styles={{ root: { borderRadius: '10px', height: '44px' } }}
|
||||
>
|
||||
<MicrosoftIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,14 +50,14 @@ export const MENU_ITEMS: MenuItemType[] = [
|
||||
label: 'nav:configuration-employees',
|
||||
icon: Users,
|
||||
path: '/app/sales/employees/index',
|
||||
moduleKey: 'CONFIGURATION.EMPLOYEE',
|
||||
moduleKey: 'ADMIN.SALES.DATA.EMPLOYEE',
|
||||
},
|
||||
{
|
||||
key: 'sales-cycles',
|
||||
label: 'nav:sales-cycles',
|
||||
icon: Repeat,
|
||||
path: '/app/sales/cycles/index',
|
||||
moduleKey: 'SALES.CYCLE',
|
||||
moduleKey: 'ADMIN.SALES.DATA.CYCLE',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -72,35 +72,35 @@ export const MENU_ITEMS: MenuItemType[] = [
|
||||
label: 'nav:sales-requests',
|
||||
icon: ClipboardList,
|
||||
path: '/app/sales/requests/index',
|
||||
moduleKey: 'SALES.REQUEST',
|
||||
moduleKey: 'ADMIN.SALES.ACTIVITIES.REQUEST',
|
||||
},
|
||||
{
|
||||
key: 'sales-orders',
|
||||
label: 'nav:sales-orders',
|
||||
icon: Box,
|
||||
path: '/app/sales/orders/index',
|
||||
moduleKey: 'SALES.ORDER',
|
||||
moduleKey: 'ADMIN.SALES.ACTIVITIES.ORDER',
|
||||
},
|
||||
{
|
||||
key: 'sales-invoices',
|
||||
label: 'nav:sales-invoices',
|
||||
icon: Receipt,
|
||||
path: '/app/sales/invoices/index',
|
||||
moduleKey: 'SALES.INVOICE',
|
||||
moduleKey: 'ADMIN.SALES.ACTIVITIES.INVOICE',
|
||||
},
|
||||
{
|
||||
key: 'sales-payments',
|
||||
label: 'nav:sales-payments',
|
||||
icon: CreditCard,
|
||||
path: '/app/sales/payments/index',
|
||||
moduleKey: 'SALES.PAYMENT',
|
||||
moduleKey: 'ADMIN.SALES.ACTIVITIES.PAYMENT',
|
||||
},
|
||||
{
|
||||
key: 'sales-plans',
|
||||
label: 'nav:sales-plans',
|
||||
icon: Calendar,
|
||||
path: '/app/sales/plans/index',
|
||||
moduleKey: 'SALES.PLAN',
|
||||
moduleKey: 'ADMIN.SALES.ACTIVITIES.PLAN',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -109,7 +109,7 @@ export const MENU_ITEMS: MenuItemType[] = [
|
||||
label: 'nav:sales-reports',
|
||||
icon: FileText,
|
||||
path: '/app/sales/reports/index',
|
||||
moduleKey: 'SALES.REPORT',
|
||||
moduleKey: 'ADMIN.SALES.REPORT',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -130,14 +130,14 @@ export const MENU_ITEMS: MenuItemType[] = [
|
||||
label: 'nav:configuration-employees',
|
||||
icon: Users,
|
||||
path: '/app/logistics/employees/index',
|
||||
moduleKey: 'CONFIGURATION.EMPLOYEE',
|
||||
moduleKey: 'ADMIN.SALES.DATA.EMPLOYEE',
|
||||
},
|
||||
{
|
||||
key: 'logistics-cycles',
|
||||
label: 'nav:logistics-cycles',
|
||||
icon: Repeat,
|
||||
path: '/app/logistics/cycles/index',
|
||||
moduleKey: 'LOGISTICS.CYCLE',
|
||||
moduleKey: 'ADMIN.LOGISTICS.DATA.CYCLE',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -152,14 +152,14 @@ export const MENU_ITEMS: MenuItemType[] = [
|
||||
label: 'nav:logistics-packing-slips',
|
||||
icon: Package,
|
||||
path: '/app/logistics/packing-slips/index',
|
||||
moduleKey: 'SALES.PACKING_SLIP',
|
||||
moduleKey: 'ADMIN.LOGISTICS.ACTIVITIES.PACKING_SLIP',
|
||||
},
|
||||
{
|
||||
key: 'logistics-plans',
|
||||
label: 'nav:logistics-plans',
|
||||
icon: Calendar,
|
||||
path: '/app/logistics/plans/index',
|
||||
moduleKey: 'LOGISTICS.PLAN',
|
||||
moduleKey: 'ADMIN.LOGISTICS.ACTIVITIES.PLAN',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -168,7 +168,7 @@ export const MENU_ITEMS: MenuItemType[] = [
|
||||
label: 'nav:logistics-reports',
|
||||
icon: FileText,
|
||||
path: '/app/logistics/reports/index',
|
||||
moduleKey: 'LOGISTICS.REPORT',
|
||||
moduleKey: 'ADMIN.LOGISTICS.REPORT',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -189,28 +189,28 @@ export const MENU_ITEMS: MenuItemType[] = [
|
||||
label: 'nav:configuration-branches',
|
||||
icon: MapPin,
|
||||
path: '/app/configuration/branches/index',
|
||||
moduleKey: 'CONFIGURATION.BRANCH',
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.BRANCH',
|
||||
},
|
||||
{
|
||||
key: 'configuration-divisions',
|
||||
label: 'nav:configuration-divisions',
|
||||
icon: Layers,
|
||||
path: '/app/configuration/divisions/index',
|
||||
moduleKey: 'CONFIGURATION.DIVISION',
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.DIVISION',
|
||||
},
|
||||
{
|
||||
key: 'configuration-customers',
|
||||
label: 'nav:configuration-customers',
|
||||
icon: Users,
|
||||
path: '/app/configuration/customers/index',
|
||||
moduleKey: 'CONFIGURATION.CUSTOMER',
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.CUSTOMER',
|
||||
},
|
||||
{
|
||||
key: 'configuration-products',
|
||||
label: 'nav:configuration-products',
|
||||
icon: Package,
|
||||
path: '/app/configuration/products/index',
|
||||
moduleKey: 'CONFIGURATION.PRODUCT',
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.PRODUCT',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -225,14 +225,14 @@ export const MENU_ITEMS: MenuItemType[] = [
|
||||
label: 'nav:system-users',
|
||||
icon: Users,
|
||||
path: '/app/system/users/index',
|
||||
moduleKey: 'USERS',
|
||||
moduleKey: 'ADMIN.SETTINGS.USER.USERS',
|
||||
},
|
||||
{
|
||||
key: 'system-privileges',
|
||||
label: 'nav:system-privileges',
|
||||
icon: Shield,
|
||||
path: '/app/system/privileges/index',
|
||||
moduleKey: 'PRIVILEGES',
|
||||
moduleKey: 'ADMIN.SETTINGS.USER.PRIVILEGES',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ describe('BranchesRemoteDataServices', () => {
|
||||
httpClient = createMockHttpClient();
|
||||
service = new BranchesRemoteDataServices(httpClient, {
|
||||
apiUrl: '/branches',
|
||||
moduleKey: 'CONFIGURATION.BRANCH',
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.BRANCH',
|
||||
transformer: new BranchesRemoteDataTransformer(),
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
|
||||
import type { BranchEntity } from '../entities';
|
||||
|
||||
export const branchesModuleConfig: ModuleConfigEntity<BranchEntity> = {
|
||||
moduleKey: 'CONFIGURATION.BRANCH',
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.BRANCH',
|
||||
translationNamespace: 'BRANCHES',
|
||||
apiUrl: '/branches',
|
||||
webUrl: '/app/configuration/branches',
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ describe('CustomersRemoteDataServices', () => {
|
||||
httpClient = createMockHttpClient();
|
||||
service = new CustomersRemoteDataServices(httpClient, {
|
||||
apiUrl: '/customers',
|
||||
moduleKey: 'CONFIGURATION.CUSTOMER',
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.CUSTOMER',
|
||||
transformer: new CustomersRemoteDataTransformer(),
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
|
||||
import type { CustomerEntity } from '../entities';
|
||||
|
||||
export const customersModuleConfig: ModuleConfigEntity<CustomerEntity> = {
|
||||
moduleKey: 'CONFIGURATION.CUSTOMER',
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.CUSTOMER',
|
||||
translationNamespace: 'CUSTOMERS',
|
||||
apiUrl: '/customers',
|
||||
webUrl: '/app/configuration/customers',
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ describe('DivisionsRemoteDataServices', () => {
|
||||
httpClient = createMockHttpClient();
|
||||
service = new DivisionsRemoteDataServices(httpClient, {
|
||||
apiUrl: '/divisions',
|
||||
moduleKey: 'CONFIGURATION.DIVISION',
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.DIVISION',
|
||||
transformer: new DivisionsRemoteDataTransformer(),
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
|
||||
import type { DivisionEntity } from '../entities';
|
||||
|
||||
export const divisionsModuleConfig: ModuleConfigEntity<DivisionEntity> = {
|
||||
moduleKey: 'CONFIGURATION.DIVISION',
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.DIVISION',
|
||||
translationNamespace: 'DIVISIONS',
|
||||
apiUrl: '/divisions',
|
||||
webUrl: '/app/configuration/divisions',
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ describe('EmployeesRemoteDataServices', () => {
|
||||
httpClient = createMockHttpClient();
|
||||
service = new EmployeesRemoteDataServices(httpClient, {
|
||||
apiUrl: '/employees',
|
||||
moduleKey: 'CONFIGURATION.EMPLOYEE',
|
||||
moduleKey: 'ADMIN.SALES.DATA.EMPLOYEE',
|
||||
transformer: new EmployeesRemoteDataTransformer(),
|
||||
});
|
||||
});
|
||||
|
||||
+2
-2
@@ -14,7 +14,7 @@ describe('employee purpose helpers', () => {
|
||||
});
|
||||
|
||||
it('keeps a shared employee privilege key', () => {
|
||||
expect(createEmployeeModuleConfig('sales').moduleKey).toBe('CONFIGURATION.EMPLOYEE');
|
||||
expect(createEmployeeModuleConfig('logistics').moduleKey).toBe('CONFIGURATION.EMPLOYEE');
|
||||
expect(createEmployeeModuleConfig('sales').moduleKey).toBe('ADMIN.SALES.DATA.EMPLOYEE');
|
||||
expect(createEmployeeModuleConfig('logistics').moduleKey).toBe('ADMIN.SALES.DATA.EMPLOYEE');
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import type { EmployeeEntity } from '../entities';
|
||||
|
||||
export function createEmployeeModuleConfig(purpose: FieldPurpose): ModuleConfigEntity<EmployeeEntity> {
|
||||
return {
|
||||
moduleKey: 'CONFIGURATION.EMPLOYEE',
|
||||
moduleKey: 'ADMIN.SALES.DATA.EMPLOYEE',
|
||||
translationNamespace: 'EMPLOYEES',
|
||||
apiUrl: '/employees',
|
||||
webUrl: `/app/${purpose}/employees`,
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ describe('ProductsRemoteDataServices', () => {
|
||||
httpClient = createMockHttpClient();
|
||||
service = new ProductsRemoteDataServices(httpClient, {
|
||||
apiUrl: '/products',
|
||||
moduleKey: 'CONFIGURATION.PRODUCT',
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.PRODUCT',
|
||||
transformer: new ProductsRemoteDataTransformer(),
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
|
||||
import type { ProductEntity } from '../entities';
|
||||
|
||||
export const productsModuleConfig: ModuleConfigEntity<ProductEntity> = {
|
||||
moduleKey: 'CONFIGURATION.PRODUCT',
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.PRODUCT',
|
||||
translationNamespace: 'PRODUCTS',
|
||||
apiUrl: '/products',
|
||||
webUrl: '/app/configuration/products',
|
||||
|
||||
@@ -33,7 +33,7 @@ describe('CyclesRemoteDataServices', () => {
|
||||
httpClient = createMockHttpClient();
|
||||
service = new CyclesRemoteDataServices(httpClient, {
|
||||
apiUrl: '/cycles',
|
||||
moduleKey: 'SALES.CYCLE',
|
||||
moduleKey: 'ADMIN.SALES.DATA.CYCLE',
|
||||
transformer: new CyclesRemoteDataTransformer('sales'),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { CycleEntity, CycleWeekdayRow } from '../entities';
|
||||
|
||||
export function createCycleModuleConfig(purpose: FieldPurpose): ModuleConfigEntity<CycleEntity> {
|
||||
return {
|
||||
moduleKey: purpose === 'sales' ? 'SALES.CYCLE' : 'LOGISTICS.CYCLE',
|
||||
moduleKey: purpose === 'sales' ? 'ADMIN.SALES.DATA.CYCLE' : 'ADMIN.LOGISTICS.DATA.CYCLE',
|
||||
translationNamespace: 'CYCLES',
|
||||
apiUrl: '/cycles',
|
||||
webUrl: `/app/${purpose}/cycles`,
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import type { ModuleConfigEntity } from '@repo/ui/foundations';
|
||||
export type ReportShellEntity = BaseEntity & { id: string };
|
||||
|
||||
export const logisticsReportsModuleConfig: ModuleConfigEntity<ReportShellEntity> = {
|
||||
moduleKey: 'LOGISTICS.REPORT',
|
||||
moduleKey: 'ADMIN.LOGISTICS.REPORT',
|
||||
translationNamespace: 'LOGISTICS_REPORTS',
|
||||
apiUrl: '/reports',
|
||||
webUrl: '/app/logistics/reports',
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
|
||||
import type { PackingSlipEntity } from '../entities';
|
||||
|
||||
export const packingSlipsModuleConfig: ModuleConfigEntity<PackingSlipEntity> = {
|
||||
moduleKey: 'SALES.PACKING_SLIP',
|
||||
moduleKey: 'ADMIN.LOGISTICS.ACTIVITIES.PACKING_SLIP',
|
||||
translationNamespace: 'PACKING_SLIPS',
|
||||
apiUrl: '/packing-slips',
|
||||
webUrl: '/app/logistics/packing-slips',
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('PlansRemoteDataServices', () => {
|
||||
httpClient,
|
||||
{
|
||||
apiUrl: '/plans',
|
||||
moduleKey: 'SALES.PLAN',
|
||||
moduleKey: 'ADMIN.SALES.ACTIVITIES.PLAN',
|
||||
transformer: new PlansRemoteDataTransformer('sales'),
|
||||
},
|
||||
'sales',
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { PlanEntity } from '../entities';
|
||||
|
||||
export function createPlanModuleConfig(purpose: FieldPurpose): ModuleConfigEntity<PlanEntity> {
|
||||
return {
|
||||
moduleKey: purpose === 'sales' ? 'SALES.PLAN' : 'LOGISTICS.PLAN',
|
||||
moduleKey: purpose === 'sales' ? 'ADMIN.SALES.ACTIVITIES.PLAN' : 'ADMIN.LOGISTICS.ACTIVITIES.PLAN',
|
||||
translationNamespace: 'PLANS',
|
||||
apiUrl: '/plans',
|
||||
webUrl: `/app/${purpose}/plans`,
|
||||
|
||||
@@ -5,12 +5,12 @@ import type { LookupEntity } from './lookup.entity';
|
||||
|
||||
export const salesInvoicesDataService = new LookupRemoteDataServices(apiClient, {
|
||||
apiUrl: '/sales-invoices',
|
||||
moduleKey: 'SALES.INVOICE',
|
||||
moduleKey: 'ADMIN.SALES.ACTIVITIES.INVOICE',
|
||||
});
|
||||
|
||||
export const packingSlipsDataService = new LookupRemoteDataServices(apiClient, {
|
||||
apiUrl: '/packing-slips',
|
||||
moduleKey: 'SALES.PACKING_SLIP',
|
||||
moduleKey: 'ADMIN.LOGISTICS.ACTIVITIES.PACKING_SLIP',
|
||||
});
|
||||
|
||||
export const loadSalesInvoiceOptions = createOptionLoader<LookupEntity>((config) =>
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ describe('SalesInvoicesRemoteDataServices', () => {
|
||||
httpClient = createMockHttpClient();
|
||||
service = new SalesInvoicesRemoteDataServices(httpClient, {
|
||||
apiUrl: '/sales-invoices',
|
||||
moduleKey: 'SALES.INVOICE',
|
||||
moduleKey: 'ADMIN.SALES.ACTIVITIES.INVOICE',
|
||||
transformer: new SalesInvoicesRemoteDataTransformer(),
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
|
||||
import type { SalesInvoiceEntity } from '../entities';
|
||||
|
||||
export const salesInvoicesModuleConfig: ModuleConfigEntity<SalesInvoiceEntity> = {
|
||||
moduleKey: 'SALES.INVOICE',
|
||||
moduleKey: 'ADMIN.SALES.ACTIVITIES.INVOICE',
|
||||
translationNamespace: 'SALES_INVOICES',
|
||||
apiUrl: '/sales-invoices',
|
||||
webUrl: '/app/sales/invoices',
|
||||
|
||||
@@ -33,7 +33,7 @@ describe('SalesOrdersRemoteDataServices', () => {
|
||||
httpClient = createMockHttpClient();
|
||||
service = new SalesOrdersRemoteDataServices(httpClient, {
|
||||
apiUrl: '/sales-orders',
|
||||
moduleKey: 'SALES.ORDER',
|
||||
moduleKey: 'ADMIN.SALES.ACTIVITIES.ORDER',
|
||||
transformer: new SalesOrdersRemoteDataTransformer(),
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
|
||||
import type { SalesOrderEntity } from '../entities';
|
||||
|
||||
export const salesOrdersModuleConfig: ModuleConfigEntity<SalesOrderEntity> = {
|
||||
moduleKey: 'SALES.ORDER',
|
||||
moduleKey: 'ADMIN.SALES.ACTIVITIES.ORDER',
|
||||
translationNamespace: 'SALES_ORDERS',
|
||||
apiUrl: '/sales-orders',
|
||||
webUrl: '/app/sales/orders',
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ describe('SalesPaymentsRemoteDataServices', () => {
|
||||
httpClient = createMockHttpClient();
|
||||
service = new SalesPaymentsRemoteDataServices(httpClient, {
|
||||
apiUrl: '/sales-payments',
|
||||
moduleKey: 'SALES.PAYMENT',
|
||||
moduleKey: 'ADMIN.SALES.ACTIVITIES.PAYMENT',
|
||||
transformer: new SalesPaymentsRemoteDataTransformer(),
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
|
||||
import type { SalesPaymentEntity } from '../entities';
|
||||
|
||||
export const salesPaymentsModuleConfig: ModuleConfigEntity<SalesPaymentEntity> = {
|
||||
moduleKey: 'SALES.PAYMENT',
|
||||
moduleKey: 'ADMIN.SALES.ACTIVITIES.PAYMENT',
|
||||
translationNamespace: 'SALES_PAYMENTS',
|
||||
apiUrl: '/sales-payments',
|
||||
webUrl: '/app/sales/payments',
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ModuleConfigEntity } from '@repo/ui/foundations';
|
||||
export type ReportShellEntity = BaseEntity & { id: string };
|
||||
|
||||
export const salesReportsModuleConfig: ModuleConfigEntity<ReportShellEntity> = {
|
||||
moduleKey: 'SALES.REPORT',
|
||||
moduleKey: 'ADMIN.SALES.REPORT',
|
||||
translationNamespace: 'SALES_REPORTS',
|
||||
apiUrl: '/reports',
|
||||
webUrl: '/app/sales/reports',
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ describe('SalesRequestsRemoteDataServices', () => {
|
||||
httpClient = createMockHttpClient();
|
||||
service = new SalesRequestsRemoteDataServices(httpClient, {
|
||||
apiUrl: '/sales-requests',
|
||||
moduleKey: 'SALES.REQUEST',
|
||||
moduleKey: 'ADMIN.SALES.ACTIVITIES.REQUEST',
|
||||
transformer: new SalesRequestsRemoteDataTransformer(),
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
|
||||
import type { SalesRequestEntity } from '../entities';
|
||||
|
||||
export const salesRequestsModuleConfig: ModuleConfigEntity<SalesRequestEntity> = {
|
||||
moduleKey: 'SALES.REQUEST',
|
||||
moduleKey: 'ADMIN.SALES.ACTIVITIES.REQUEST',
|
||||
translationNamespace: 'SALES_REQUESTS',
|
||||
apiUrl: '/sales-requests',
|
||||
webUrl: '/app/sales/requests',
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('SalesDocumentRemoteDataServices', () => {
|
||||
httpClient = createMockHttpClient();
|
||||
service = new SalesDocumentRemoteDataServices(httpClient, {
|
||||
apiUrl: '/sales-requests',
|
||||
moduleKey: 'SALES.REQUEST',
|
||||
moduleKey: 'ADMIN.SALES.ACTIVITIES.REQUEST',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+3
-3
@@ -45,7 +45,7 @@ describe('PrivilegesRemoteDataServices', () => {
|
||||
httpClient = createMockHttpClient();
|
||||
service = new PrivilegesRemoteDataServices(httpClient, {
|
||||
apiUrl: '/privileges',
|
||||
moduleKey: 'PRIVILEGES',
|
||||
moduleKey: 'ADMIN.SETTINGS.USER.PRIVILEGES',
|
||||
transformer: new PrivilegesRemoteDataTransformer(),
|
||||
});
|
||||
});
|
||||
@@ -125,7 +125,7 @@ describe('PrivilegesRemoteDataServices', () => {
|
||||
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 }],
|
||||
data: [{ id: 'key-1', code: 'ADMIN.SALES.ACTIVITIES.INVOICE', label: 'Sales Invoice', sortOrder: 1 }],
|
||||
meta: { currentPage: 1, itemsPerPage: 200, totalItems: 1, totalPages: 1 },
|
||||
},
|
||||
status: 200,
|
||||
@@ -142,7 +142,7 @@ describe('PrivilegesRemoteDataServices', () => {
|
||||
);
|
||||
expect(result.data[0]).toEqual({
|
||||
id: 'key-1',
|
||||
code: 'SALES.INVOICE',
|
||||
code: 'ADMIN.SALES.ACTIVITIES.INVOICE',
|
||||
label: 'Sales Invoice',
|
||||
sortOrder: 1,
|
||||
});
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
|
||||
import type { PrivilegeEntity } from '../entities';
|
||||
|
||||
export const privilegesModuleConfig: ModuleConfigEntity<PrivilegeEntity> = {
|
||||
moduleKey: 'PRIVILEGES',
|
||||
moduleKey: 'ADMIN.SETTINGS.USER.PRIVILEGES',
|
||||
translationNamespace: 'PRIVILEGES',
|
||||
apiUrl: '/privileges',
|
||||
webUrl: '/app/system/privileges',
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { EMPTY_MATRIX_CELL } from './entities';
|
||||
import type { PrivilegeKeyEntity } from './entities';
|
||||
import { groupPrivilegeKeys, flattenParentSectionRows, parsePrivilegeKey } from './privilege-key-hierarchy';
|
||||
|
||||
const keys: PrivilegeKeyEntity[] = [
|
||||
{ id: 'k1', code: 'ADMIN.SALES.DATA.CYCLE', label: 'Sales cycles', sortOrder: 1 },
|
||||
{ id: 'k2', code: 'ADMIN.SALES.ACTIVITIES.PLAN', label: 'Sales plans', sortOrder: 2 },
|
||||
{ id: 'k3', code: 'MOBILE.SALES.PLAN', label: 'Sales plans (mobile)', sortOrder: 3 },
|
||||
{ id: 'k4', code: 'MOBILE.SALES.PLAN.ATTENDANCE', label: 'Branch attendance', sortOrder: 4 },
|
||||
];
|
||||
|
||||
function row(key: PrivilegeKeyEntity, view = false) {
|
||||
return {
|
||||
keyId: key.id,
|
||||
code: key.code,
|
||||
label: key.label,
|
||||
sortOrder: key.sortOrder,
|
||||
cell: { ...EMPTY_MATRIX_CELL, view },
|
||||
};
|
||||
}
|
||||
|
||||
describe('parsePrivilegeKey', () => {
|
||||
it('parses 3-part keys', () => {
|
||||
expect(parsePrivilegeKey('MOBILE.SALES.PLAN')).toEqual({
|
||||
group: 'MOBILE',
|
||||
parent: 'SALES',
|
||||
module: 'PLAN',
|
||||
submodule: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses 4-part keys', () => {
|
||||
expect(parsePrivilegeKey('MOBILE.SALES.PLAN.ATTENDANCE')).toEqual({
|
||||
group: 'MOBILE',
|
||||
parent: 'SALES',
|
||||
module: 'PLAN',
|
||||
submodule: 'ATTENDANCE',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for legacy keys', () => {
|
||||
expect(parsePrivilegeKey('PRIVILEGES')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupPrivilegeKeys', () => {
|
||||
it('groups keys into tabs, parents, modules, and submodule rows', () => {
|
||||
const rowsByKeyId = Object.fromEntries(keys.map((key) => [key.id, row(key)]));
|
||||
|
||||
const grouped = groupPrivilegeKeys(keys, rowsByKeyId);
|
||||
|
||||
expect(grouped.map((tab) => tab.group)).toEqual(['ADMIN', 'MOBILE']);
|
||||
expect(grouped[0]?.parents[0]?.parent).toBe('SALES');
|
||||
expect(grouped[0]?.parents[0]?.modules.map((m) => m.module)).toEqual([
|
||||
'ACTIVITIES',
|
||||
'DATA',
|
||||
]);
|
||||
expect(grouped[1]?.parents[0]?.modules[0]?.directRow?.code).toBe('MOBILE.SALES.PLAN');
|
||||
expect(grouped[1]?.parents[0]?.modules[0]?.submodules[0]?.code).toBe(
|
||||
'MOBILE.SALES.PLAN.ATTENDANCE',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flattenParentSectionRows', () => {
|
||||
it('flattens modules and submodules into sortable table rows', () => {
|
||||
const rowsByKeyId = Object.fromEntries(keys.map((key) => [key.id, row(key)]));
|
||||
const grouped = groupPrivilegeKeys(keys, rowsByKeyId);
|
||||
const salesParent = grouped[0]?.parents[0];
|
||||
|
||||
expect(salesParent).toBeDefined();
|
||||
const tableRows = flattenParentSectionRows(salesParent!);
|
||||
|
||||
expect(tableRows.map((r) => r.code)).toEqual([
|
||||
'ADMIN.SALES.DATA.CYCLE',
|
||||
'ADMIN.SALES.ACTIVITIES.PLAN',
|
||||
]);
|
||||
expect(tableRows[0]).toMatchObject({ module: 'DATA', submodule: 'CYCLE' });
|
||||
expect(tableRows[1]).toMatchObject({ module: 'ACTIVITIES', submodule: 'PLAN' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { PrivilegeKeyEntity, PrivilegeMatrixCell } from './entities';
|
||||
|
||||
export type ParsedPrivilegeKey = {
|
||||
group: string;
|
||||
parent: string;
|
||||
module: string;
|
||||
submodule: string | null;
|
||||
};
|
||||
|
||||
export type PrivilegeMatrixRow = {
|
||||
keyId: string;
|
||||
code: string;
|
||||
label: string;
|
||||
sortOrder: number;
|
||||
cell: PrivilegeMatrixCell;
|
||||
};
|
||||
|
||||
export type PrivilegeModuleGroup = {
|
||||
module: string;
|
||||
directRow?: PrivilegeMatrixRow;
|
||||
submodules: PrivilegeMatrixRow[];
|
||||
};
|
||||
|
||||
export type PrivilegeParentSection = {
|
||||
parent: string;
|
||||
modules: PrivilegeModuleGroup[];
|
||||
};
|
||||
|
||||
export type PrivilegeGroupTab = {
|
||||
group: string;
|
||||
parents: PrivilegeParentSection[];
|
||||
};
|
||||
|
||||
export function parsePrivilegeKey(code: string): ParsedPrivilegeKey | null {
|
||||
const parts = code.split('.');
|
||||
if (parts.length === 3) {
|
||||
const [group, parent, module] = parts;
|
||||
return { group, parent, module, submodule: null };
|
||||
}
|
||||
if (parts.length === 4) {
|
||||
const [group, parent, module, submodule] = parts;
|
||||
return { group, parent, module, submodule };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function groupPrivilegeKeys(
|
||||
keys: PrivilegeKeyEntity[],
|
||||
rowsByKeyId: Record<string, PrivilegeMatrixRow>,
|
||||
): PrivilegeGroupTab[] {
|
||||
const groupMap = new Map<string, Map<string, Map<string, PrivilegeModuleGroup>>>();
|
||||
|
||||
const sortedKeys = [...keys].sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code));
|
||||
|
||||
for (const key of sortedKeys) {
|
||||
const parsed = parsePrivilegeKey(key.code);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
const row = rowsByKeyId[key.id];
|
||||
if (!row) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!groupMap.has(parsed.group)) {
|
||||
groupMap.set(parsed.group, new Map());
|
||||
}
|
||||
const parentMap = groupMap.get(parsed.group)!;
|
||||
if (!parentMap.has(parsed.parent)) {
|
||||
parentMap.set(parsed.parent, new Map());
|
||||
}
|
||||
const moduleMap = parentMap.get(parsed.parent)!;
|
||||
if (!moduleMap.has(parsed.module)) {
|
||||
moduleMap.set(parsed.module, { module: parsed.module, submodules: [] });
|
||||
}
|
||||
const moduleGroup = moduleMap.get(parsed.module)!;
|
||||
|
||||
if (parsed.submodule === null) {
|
||||
moduleGroup.directRow = row;
|
||||
} else {
|
||||
moduleGroup.submodules.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
return [...groupMap.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([group, parentMap]) => ({
|
||||
group,
|
||||
parents: [...parentMap.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([parent, moduleMap]) => ({
|
||||
parent,
|
||||
modules: [...moduleMap.values()]
|
||||
.map((moduleGroup) => ({
|
||||
...moduleGroup,
|
||||
submodules: [...moduleGroup.submodules].sort(
|
||||
(a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code),
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => a.module.localeCompare(b.module)),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
export type PrivilegeTableRow = PrivilegeMatrixRow & {
|
||||
module: string;
|
||||
submodule: string | null;
|
||||
};
|
||||
|
||||
export function flattenParentSectionRows(parent: PrivilegeParentSection): PrivilegeTableRow[] {
|
||||
const rows: PrivilegeTableRow[] = [];
|
||||
|
||||
for (const moduleGroup of parent.modules) {
|
||||
if (moduleGroup.directRow) {
|
||||
rows.push({
|
||||
...moduleGroup.directRow,
|
||||
module: moduleGroup.module,
|
||||
submodule: null,
|
||||
});
|
||||
}
|
||||
|
||||
for (const subRow of moduleGroup.submodules) {
|
||||
const parsed = parsePrivilegeKey(subRow.code);
|
||||
rows.push({
|
||||
...subRow,
|
||||
module: moduleGroup.module,
|
||||
submodule: parsed?.submodule ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return rows.sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code));
|
||||
}
|
||||
|
||||
export function formatPrivilegeSegment(segment: string): string {
|
||||
return segment
|
||||
.split('_')
|
||||
.map((part) => part.charAt(0) + part.slice(1).toLowerCase())
|
||||
.join(' ');
|
||||
}
|
||||
+1
-1
@@ -17,7 +17,7 @@ describe('buildPrivilegeMatrixRows', () => {
|
||||
details: [
|
||||
{
|
||||
privilegeKeyId: 'key-2',
|
||||
keyCode: 'USERS',
|
||||
keyCode: 'ADMIN.SETTINGS.USER.USERS',
|
||||
keyLabel: 'Users',
|
||||
sortOrder: 2,
|
||||
action: 'view',
|
||||
|
||||
+3
-3
@@ -17,7 +17,7 @@ const detailDto: PrivilegeDetailResponseDto = {
|
||||
{
|
||||
id: 'd1',
|
||||
privilegeKeyId: 'key-1',
|
||||
keyCode: 'SALES.INVOICE',
|
||||
keyCode: 'ADMIN.SALES.ACTIVITIES.INVOICE',
|
||||
keyLabel: 'Sales Invoice',
|
||||
sortOrder: 1,
|
||||
action: 'view',
|
||||
@@ -26,7 +26,7 @@ const detailDto: PrivilegeDetailResponseDto = {
|
||||
{
|
||||
id: 'd2',
|
||||
privilegeKeyId: 'key-1',
|
||||
keyCode: 'SALES.INVOICE',
|
||||
keyCode: 'ADMIN.SALES.ACTIVITIES.INVOICE',
|
||||
keyLabel: 'Sales Invoice',
|
||||
sortOrder: 1,
|
||||
action: 'create',
|
||||
@@ -48,7 +48,7 @@ describe('PrivilegesRemoteDataTransformer', () => {
|
||||
});
|
||||
expect(entity.details?.[0]).toMatchObject({
|
||||
privilegeKeyId: 'key-1',
|
||||
keyCode: 'SALES.INVOICE',
|
||||
keyCode: 'ADMIN.SALES.ACTIVITIES.INVOICE',
|
||||
keyLabel: 'Sales Invoice',
|
||||
sortOrder: 1,
|
||||
});
|
||||
|
||||
+22
-35
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Box, Paper, Table, Text } from '@repo/ui/components';
|
||||
import { FieldCheckbox } from '@repo/ui/form';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Paper, Text } from '@repo/ui/components';
|
||||
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
|
||||
import { privilegesDataService } from '../../../domain/factories';
|
||||
import { EMPTY_MATRIX_CELL, PRIVILEGE_ACTIONS, type PrivilegeKeyEntity } from '../../../domain/entities';
|
||||
import { EMPTY_MATRIX_CELL, type PrivilegeKeyEntity } from '../../../domain/entities';
|
||||
import { groupPrivilegeKeys } from '../../../domain/privilege-key-hierarchy';
|
||||
import { PrivilegeMatrixLayout } from '../privilege-matrix-layout';
|
||||
|
||||
export function FormPermissions() {
|
||||
const { formControl } = useFormPageContext();
|
||||
@@ -28,42 +29,28 @@ export function FormPermissions() {
|
||||
};
|
||||
}, [formControl]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const rowsByKeyId = Object.fromEntries(
|
||||
keys.map((key) => [
|
||||
key.id,
|
||||
{
|
||||
keyId: key.id,
|
||||
code: key.code,
|
||||
label: key.label,
|
||||
sortOrder: key.sortOrder,
|
||||
cell: formControl.getValues(`matrix.${key.id}`) ?? { ...EMPTY_MATRIX_CELL },
|
||||
},
|
||||
]),
|
||||
);
|
||||
return groupPrivilegeKeys(keys, rowsByKeyId);
|
||||
}, [keys, formControl]);
|
||||
|
||||
return (
|
||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||
<Text fw={600} mb="md">
|
||||
{t('section_permissions')}
|
||||
</Text>
|
||||
<Box style={{ overflowX: 'auto' }}>
|
||||
<Table striped highlightOnHover withTableBorder withColumnBorders>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('matrix_module')}</Table.Th>
|
||||
{PRIVILEGE_ACTIONS.map((action) => (
|
||||
<Table.Th key={action} ta="center">
|
||||
{t(`matrix_${action}`)}
|
||||
</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{keys.map((key) => (
|
||||
<Table.Tr key={key.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm">{key.label || key.code}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{key.code}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
{PRIVILEGE_ACTIONS.map((action) => (
|
||||
<Table.Td key={action} ta="center">
|
||||
<FieldCheckbox control={formControl.control} name={`matrix.${key.id}.${action}`} label="" />
|
||||
</Table.Td>
|
||||
))}
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
<PrivilegeMatrixLayout groups={grouped} t={t} mode="edit" formControl={formControl} />
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
import { Box, Stack, Table, Tabs, Text } from '@repo/ui/components';
|
||||
import { FieldCheckbox } from '@repo/ui/form';
|
||||
import type { Control } from 'react-hook-form';
|
||||
import { Check, Minus } from 'lucide-react';
|
||||
import { PRIVILEGE_ACTIONS } from '../../domain/entities';
|
||||
import {
|
||||
flattenParentSectionRows,
|
||||
formatPrivilegeSegment,
|
||||
type PrivilegeGroupTab,
|
||||
type PrivilegeMatrixRow,
|
||||
type PrivilegeTableRow,
|
||||
} from '../../domain/privilege-key-hierarchy';
|
||||
|
||||
const MODULE_COL_WIDTH = '22%';
|
||||
const SUBMODULE_COL_WIDTH = '30%';
|
||||
const ACTION_COL_WIDTH = 88;
|
||||
|
||||
type PrivilegeMatrixLayoutProps = {
|
||||
groups: PrivilegeGroupTab[];
|
||||
t: (key: string) => string;
|
||||
mode: 'edit' | 'read';
|
||||
formControl?: { control: Control<Record<string, unknown>> };
|
||||
};
|
||||
|
||||
function segmentLabel(t: (key: string) => string, kind: string, value: string) {
|
||||
const key = `hierarchy_${kind}_${value.toLowerCase()}`;
|
||||
const translated = t(key);
|
||||
return translated === key ? formatPrivilegeSegment(value) : translated;
|
||||
}
|
||||
|
||||
function submoduleCellLabel(
|
||||
t: (key: string) => string,
|
||||
row: PrivilegeTableRow,
|
||||
) {
|
||||
if (row.submodule) {
|
||||
return row.label || segmentLabel(t, 'submodule', row.submodule);
|
||||
}
|
||||
return row.label || segmentLabel(t, 'module', row.module);
|
||||
}
|
||||
|
||||
function MatrixActionCells({
|
||||
row,
|
||||
mode,
|
||||
formControl,
|
||||
}: {
|
||||
row: PrivilegeMatrixRow;
|
||||
mode: 'edit' | 'read';
|
||||
formControl?: { control: Control<Record<string, unknown>> };
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{PRIVILEGE_ACTIONS.map((action) => (
|
||||
<Table.Td key={action} ta="center" w={ACTION_COL_WIDTH} miw={ACTION_COL_WIDTH} maw={ACTION_COL_WIDTH}>
|
||||
<Box display="flex" style={{ justifyContent: 'center', alignItems: 'center' }}>
|
||||
{mode === 'edit' && formControl ? (
|
||||
<FieldCheckbox
|
||||
control={formControl.control}
|
||||
name={`matrix.${row.keyId}.${action}`}
|
||||
label=""
|
||||
/>
|
||||
) : row.cell[action] ? (
|
||||
<Check size={16} />
|
||||
) : (
|
||||
<Minus size={16} />
|
||||
)}
|
||||
</Box>
|
||||
</Table.Td>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ParentMatrixTable({
|
||||
rows,
|
||||
mode,
|
||||
formControl,
|
||||
t,
|
||||
}: {
|
||||
rows: PrivilegeTableRow[];
|
||||
mode: 'edit' | 'read';
|
||||
formControl?: { control: Control<Record<string, unknown>> };
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
if (rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box style={{ overflowX: 'auto' }}>
|
||||
<Table
|
||||
striped
|
||||
highlightOnHover
|
||||
withTableBorder
|
||||
withColumnBorders
|
||||
layout="fixed"
|
||||
w="100%"
|
||||
style={{ tableLayout: 'fixed' }}
|
||||
>
|
||||
<colgroup>
|
||||
<col style={{ width: MODULE_COL_WIDTH }} />
|
||||
<col style={{ width: SUBMODULE_COL_WIDTH }} />
|
||||
{PRIVILEGE_ACTIONS.map((action) => (
|
||||
<col key={action} style={{ width: ACTION_COL_WIDTH }} />
|
||||
))}
|
||||
</colgroup>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('matrix_module')}</Table.Th>
|
||||
<Table.Th>{t('matrix_submodule')}</Table.Th>
|
||||
{PRIVILEGE_ACTIONS.map((action) => (
|
||||
<Table.Th key={action} ta="center" w={ACTION_COL_WIDTH}>
|
||||
{t(`matrix_${action}`)}
|
||||
</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((row) => (
|
||||
<Table.Tr key={row.keyId}>
|
||||
<Table.Td valign="middle">
|
||||
<Text size="sm" fw={500}>
|
||||
{segmentLabel(t, 'module', row.module)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td valign="middle">
|
||||
<Text size="sm">{submoduleCellLabel(t, row)}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{row.code}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<MatrixActionCells row={row} mode={mode} formControl={formControl} />
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function PrivilegeMatrixLayout({
|
||||
groups,
|
||||
t,
|
||||
mode,
|
||||
formControl,
|
||||
}: PrivilegeMatrixLayoutProps) {
|
||||
if (groups.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs defaultValue={groups[0]?.group} variant="outline" radius="md">
|
||||
<Tabs.List mb="md">
|
||||
{groups.map((groupTab) => (
|
||||
<Tabs.Tab key={groupTab.group} value={groupTab.group}>
|
||||
{segmentLabel(t, 'group', groupTab.group)}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
|
||||
{groups.map((groupTab) => (
|
||||
<Tabs.Panel key={groupTab.group} value={groupTab.group} pt="md">
|
||||
<Stack gap="lg">
|
||||
{groupTab.parents.map((parentSection) => (
|
||||
<Stack key={parentSection.parent} gap="sm">
|
||||
<Text fw={600}>{segmentLabel(t, 'parent', parentSection.parent)}</Text>
|
||||
<ParentMatrixTable
|
||||
rows={flattenParentSectionRows(parentSection)}
|
||||
mode={mode}
|
||||
formControl={formControl}
|
||||
t={t}
|
||||
/>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
+18
-34
@@ -1,7 +1,9 @@
|
||||
import { Box, Paper, Table, Text } from '@repo/ui/components';
|
||||
import { Check, Minus } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { Paper, Text } from '@repo/ui/components';
|
||||
import { PRIVILEGE_ACTIONS, type PrivilegeEntity } from '../../domain/entities';
|
||||
import { buildPrivilegeMatrixRows } from '../../domain/privilege.matrix-rows';
|
||||
import { groupPrivilegeKeys } from '../../domain/privilege-key-hierarchy';
|
||||
import { PrivilegeMatrixLayout } from './privilege-matrix-layout';
|
||||
|
||||
export function PrivilegePermissionsTable({
|
||||
privilege,
|
||||
@@ -10,44 +12,26 @@ export function PrivilegePermissionsTable({
|
||||
privilege: Pick<PrivilegeEntity, 'matrix' | 'details'> | null | undefined;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
const rows = buildPrivilegeMatrixRows(privilege);
|
||||
const grouped = useMemo(() => {
|
||||
const rows = buildPrivilegeMatrixRows(privilege);
|
||||
const keys = rows.map((row) => ({
|
||||
id: row.keyId,
|
||||
code: row.code,
|
||||
label: row.label,
|
||||
sortOrder: row.sortOrder,
|
||||
}));
|
||||
const rowsByKeyId = Object.fromEntries(rows.map((row) => [row.keyId, row]));
|
||||
return groupPrivilegeKeys(keys, rowsByKeyId);
|
||||
}, [privilege]);
|
||||
|
||||
void PRIVILEGE_ACTIONS;
|
||||
|
||||
return (
|
||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||
<Text fw={600} mb="md">
|
||||
{t('section_permissions')}
|
||||
</Text>
|
||||
<Box style={{ overflowX: 'auto' }}>
|
||||
<Table striped highlightOnHover withTableBorder withColumnBorders>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('matrix_module')}</Table.Th>
|
||||
{PRIVILEGE_ACTIONS.map((action) => (
|
||||
<Table.Th key={action} ta="center">
|
||||
{t(`matrix_${action}`)}
|
||||
</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((row) => (
|
||||
<Table.Tr key={row.keyId}>
|
||||
<Table.Td>
|
||||
<Text size="sm">{row.label}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{row.code}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
{PRIVILEGE_ACTIONS.map((action) => (
|
||||
<Table.Td key={action} ta="center">
|
||||
{row.cell[action] ? <Check size={16} /> : <Minus size={16} />}
|
||||
</Table.Td>
|
||||
))}
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
<PrivilegeMatrixLayout groups={grouped} t={t} mode="read" />
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
+11
@@ -12,11 +12,22 @@
|
||||
"section_general": "General",
|
||||
"section_permissions": "Permissions",
|
||||
"matrix_module": "Module",
|
||||
"matrix_submodule": "Submodule",
|
||||
"matrix_view": "View",
|
||||
"matrix_create": "Create",
|
||||
"matrix_update": "Update",
|
||||
"matrix_delete": "Delete",
|
||||
"matrix_import": "Import",
|
||||
"hierarchy_group_admin": "Admin",
|
||||
"hierarchy_group_mobile": "Mobile",
|
||||
"hierarchy_parent_sales": "Sales",
|
||||
"hierarchy_parent_logistics": "Logistics",
|
||||
"hierarchy_parent_settings": "Settings",
|
||||
"hierarchy_module_data": "Data",
|
||||
"hierarchy_module_activities": "Activities",
|
||||
"hierarchy_module_report": "Report",
|
||||
"hierarchy_module_user": "User",
|
||||
"hierarchy_module_plan": "Plan",
|
||||
"status_draft": "Draft",
|
||||
"status_active": "Active",
|
||||
"status_archived": "Archived"
|
||||
|
||||
+11
@@ -12,11 +12,22 @@
|
||||
"section_general": "Umum",
|
||||
"section_permissions": "Izin",
|
||||
"matrix_module": "Modul",
|
||||
"matrix_submodule": "Submodul",
|
||||
"matrix_view": "Lihat",
|
||||
"matrix_create": "Buat",
|
||||
"matrix_update": "Ubah",
|
||||
"matrix_delete": "Hapus",
|
||||
"matrix_import": "Impor",
|
||||
"hierarchy_group_admin": "Admin",
|
||||
"hierarchy_group_mobile": "Mobile",
|
||||
"hierarchy_parent_sales": "Penjualan",
|
||||
"hierarchy_parent_logistics": "Logistik",
|
||||
"hierarchy_parent_settings": "Pengaturan",
|
||||
"hierarchy_module_data": "Data",
|
||||
"hierarchy_module_activities": "Aktivitas",
|
||||
"hierarchy_module_report": "Laporan",
|
||||
"hierarchy_module_user": "Pengguna",
|
||||
"hierarchy_module_plan": "Rencana",
|
||||
"status_draft": "Draft",
|
||||
"status_active": "Aktif",
|
||||
"status_archived": "Diarsipkan"
|
||||
|
||||
@@ -33,7 +33,7 @@ describe('UsersRemoteDataServices', () => {
|
||||
httpClient = createMockHttpClient();
|
||||
service = new UsersRemoteDataServices(httpClient, {
|
||||
apiUrl: '/users',
|
||||
moduleKey: 'USERS',
|
||||
moduleKey: 'ADMIN.SETTINGS.USER.USERS',
|
||||
transformer: new UsersRemoteDataTransformer(),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
|
||||
import type { UserEntity } from '../entities';
|
||||
|
||||
export const usersModuleConfig: ModuleConfigEntity<UserEntity> = {
|
||||
moduleKey: 'USERS',
|
||||
moduleKey: 'ADMIN.SETTINGS.USER.USERS',
|
||||
translationNamespace: 'USERS',
|
||||
apiUrl: '/users',
|
||||
webUrl: '/app/system/users',
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
export const MODULE_KEY = {
|
||||
PRIVILEGES: 'PRIVILEGES',
|
||||
USERS: 'USERS',
|
||||
CONFIGURATION_DIVISION: 'CONFIGURATION.DIVISION',
|
||||
CONFIGURATION_BRANCH: 'CONFIGURATION.BRANCH',
|
||||
CONFIGURATION_CUSTOMER: 'CONFIGURATION.CUSTOMER',
|
||||
CONFIGURATION_EMPLOYEE: 'CONFIGURATION.EMPLOYEE',
|
||||
SALES_CYCLE: 'SALES.CYCLE',
|
||||
SALES_PLAN: 'SALES.PLAN',
|
||||
LOGISTICS_CYCLE: 'LOGISTICS.CYCLE',
|
||||
LOGISTICS_PLAN: 'LOGISTICS.PLAN',
|
||||
PRIVILEGES: 'ADMIN.SETTINGS.USER.PRIVILEGES',
|
||||
USERS: 'ADMIN.SETTINGS.USER.USERS',
|
||||
CONFIGURATION_DIVISION: 'ADMIN.SETTINGS.DATA.DIVISION',
|
||||
CONFIGURATION_BRANCH: 'ADMIN.SETTINGS.DATA.BRANCH',
|
||||
CONFIGURATION_CUSTOMER: 'ADMIN.SETTINGS.DATA.CUSTOMER',
|
||||
CONFIGURATION_PRODUCT: 'ADMIN.SETTINGS.DATA.PRODUCT',
|
||||
CONFIGURATION_EMPLOYEE: 'ADMIN.SALES.DATA.EMPLOYEE',
|
||||
SALES_CYCLE: 'ADMIN.SALES.DATA.CYCLE',
|
||||
SALES_REQUEST: 'ADMIN.SALES.ACTIVITIES.REQUEST',
|
||||
SALES_ORDER: 'ADMIN.SALES.ACTIVITIES.ORDER',
|
||||
SALES_INVOICE: 'ADMIN.SALES.ACTIVITIES.INVOICE',
|
||||
SALES_PAYMENT: 'ADMIN.SALES.ACTIVITIES.PAYMENT',
|
||||
SALES_PLAN: 'ADMIN.SALES.ACTIVITIES.PLAN',
|
||||
SALES_REPORT: 'ADMIN.SALES.REPORT',
|
||||
PACKING_SLIP: 'ADMIN.LOGISTICS.ACTIVITIES.PACKING_SLIP',
|
||||
LOGISTICS_CYCLE: 'ADMIN.LOGISTICS.DATA.CYCLE',
|
||||
LOGISTICS_PLAN: 'ADMIN.LOGISTICS.ACTIVITIES.PLAN',
|
||||
LOGISTICS_REPORT: 'ADMIN.LOGISTICS.REPORT',
|
||||
} as const;
|
||||
|
||||
@@ -38,7 +38,7 @@ const me = {
|
||||
isSuperadmin: false,
|
||||
privilege: { id: 'priv-1', name: 'Admin', code: 'ADMIN' },
|
||||
permissions: {
|
||||
PRIVILEGES: { view: true, create: false, update: false, delete: false },
|
||||
'ADMIN.SETTINGS.USER.PRIVILEGES': { view: true, create: false, update: false, delete: false },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -83,7 +83,7 @@ describe('auth.helper', () => {
|
||||
expect(appDatabase.setItem).toHaveBeenCalledWith(
|
||||
'user_privilege',
|
||||
expect.objectContaining({
|
||||
PRIVILEGES: expect.objectContaining({ ALLOW_VIEW: true, ALLOW_CREATE: false }),
|
||||
'ADMIN.SETTINGS.USER.PRIVILEGES': expect.objectContaining({ ALLOW_VIEW: true, ALLOW_CREATE: false }),
|
||||
}),
|
||||
);
|
||||
expect(replace).toHaveBeenCalledWith('/app');
|
||||
|
||||
@@ -27,7 +27,7 @@ describe('enterpriseStorageAdapter', () => {
|
||||
return null;
|
||||
});
|
||||
|
||||
await expect(enterpriseStorageAdapter.getPrivileges('PRIVILEGES')).resolves.toEqual(defaultPrivileges);
|
||||
await expect(enterpriseStorageAdapter.getPrivileges('ADMIN.SETTINGS.USER.PRIVILEGES')).resolves.toEqual(defaultPrivileges);
|
||||
await expect(enterpriseStorageAdapter.getPrivileges('UNKNOWN')).resolves.toEqual(defaultPrivileges);
|
||||
});
|
||||
|
||||
@@ -35,12 +35,12 @@ describe('enterpriseStorageAdapter', () => {
|
||||
vi.mocked(appDatabase.getItem).mockImplementation(async (key) => {
|
||||
if (key === 'user_profile') return { isSuperadmin: false };
|
||||
if (key === 'user_privilege') {
|
||||
return { PRIVILEGES: { ...noPrivileges, ALLOW_VIEW: true } };
|
||||
return { 'ADMIN.SETTINGS.USER.PRIVILEGES': { ...noPrivileges, ALLOW_VIEW: true } };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
await expect(enterpriseStorageAdapter.getPrivileges('PRIVILEGES')).resolves.toEqual({
|
||||
await expect(enterpriseStorageAdapter.getPrivileges('ADMIN.SETTINGS.USER.PRIVILEGES')).resolves.toEqual({
|
||||
...noPrivileges,
|
||||
ALLOW_VIEW: true,
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ const items: TestMenuItem[] = [
|
||||
{
|
||||
key: 'system',
|
||||
children: [
|
||||
{ key: 'privileges', moduleKey: 'PRIVILEGES', path: '/app/system/privileges/index' },
|
||||
{ key: 'privileges', moduleKey: 'ADMIN.SETTINGS.USER.PRIVILEGES', path: '/app/system/privileges/index' },
|
||||
{ key: 'placeholder', path: '/app/system/other' },
|
||||
],
|
||||
},
|
||||
@@ -34,7 +34,7 @@ describe('filterMenuByViewPrivilege', () => {
|
||||
const filtered = filterMenuByViewPrivilege(
|
||||
items,
|
||||
{
|
||||
PRIVILEGES: { ...noPrivileges, ALLOW_VIEW: false },
|
||||
'ADMIN.SETTINGS.USER.PRIVILEGES': { ...noPrivileges, ALLOW_VIEW: false },
|
||||
EXAMPLE_FULL_PAGE: { ...noPrivileges, ALLOW_VIEW: true },
|
||||
},
|
||||
false,
|
||||
@@ -50,7 +50,7 @@ describe('filterMenuByViewPrivilege', () => {
|
||||
const filtered = filterMenuByViewPrivilege(
|
||||
items,
|
||||
{
|
||||
PRIVILEGES: { ...noPrivileges },
|
||||
'ADMIN.SETTINGS.USER.PRIVILEGES': { ...noPrivileges },
|
||||
EXAMPLE_FULL_PAGE: { ...noPrivileges },
|
||||
},
|
||||
false,
|
||||
@@ -72,13 +72,13 @@ describe('filterMenuByViewPrivilege', () => {
|
||||
{
|
||||
key: 'sales',
|
||||
children: [
|
||||
{ key: 'orders', moduleKey: 'SALES.ORDER', path: '/app/sales/orders/index' },
|
||||
{ key: 'orders', moduleKey: 'ADMIN.SALES.ACTIVITIES.ORDER', path: '/app/sales/orders/index' },
|
||||
{ key: 'reports', path: '/app/sales/reports', isPlaceholder: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const filtered = filterMenuByViewPrivilege(menu, { 'SALES.ORDER': { ...noPrivileges } }, false);
|
||||
const filtered = filterMenuByViewPrivilege(menu, { 'ADMIN.SALES.ACTIVITIES.ORDER': { ...noPrivileges } }, false);
|
||||
|
||||
expect(filtered.find((item) => item.key === 'sales')).toBeUndefined();
|
||||
});
|
||||
@@ -88,13 +88,13 @@ describe('filterMenuByViewPrivilege', () => {
|
||||
{
|
||||
key: 'sales',
|
||||
children: [
|
||||
{ key: 'orders', moduleKey: 'SALES.ORDER', path: '/app/sales/orders/index' },
|
||||
{ key: 'orders', moduleKey: 'ADMIN.SALES.ACTIVITIES.ORDER', path: '/app/sales/orders/index' },
|
||||
{ key: 'reports', path: '/app/sales/reports', isPlaceholder: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const filtered = filterMenuByViewPrivilege(menu, { 'SALES.ORDER': { ...noPrivileges, ALLOW_VIEW: true } }, false);
|
||||
const filtered = filterMenuByViewPrivilege(menu, { 'ADMIN.SALES.ACTIVITIES.ORDER': { ...noPrivileges, ALLOW_VIEW: true } }, false);
|
||||
|
||||
expect(filtered.find((item) => item.key === 'sales')?.children?.map((child) => child.key)).toEqual([
|
||||
'orders',
|
||||
|
||||
@@ -6,7 +6,7 @@ describe('mapUserPrivileges', () => {
|
||||
it('maps view/create/update/delete/import flags', () => {
|
||||
const result = mapUserPrivileges(
|
||||
{
|
||||
PRIVILEGES: {
|
||||
'ADMIN.SETTINGS.USER.PRIVILEGES': {
|
||||
view: true,
|
||||
create: true,
|
||||
update: false,
|
||||
@@ -17,7 +17,7 @@ describe('mapUserPrivileges', () => {
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.PRIVILEGES).toEqual({
|
||||
expect(result['ADMIN.SETTINGS.USER.PRIVILEGES']).toEqual({
|
||||
...noPrivileges,
|
||||
ALLOW_VIEW: true,
|
||||
ALLOW_CREATE: true,
|
||||
@@ -28,9 +28,9 @@ describe('mapUserPrivileges', () => {
|
||||
});
|
||||
|
||||
it('defaults missing ALLOW flags to false', () => {
|
||||
const result = mapUserPrivileges({ CUSTOMERS: { view: true } }, false);
|
||||
const result = mapUserPrivileges({ 'ADMIN.SETTINGS.DATA.CUSTOMER': { view: true } }, false);
|
||||
|
||||
expect(result.CUSTOMERS).toEqual({
|
||||
expect(result['ADMIN.SETTINGS.DATA.CUSTOMER']).toEqual({
|
||||
...noPrivileges,
|
||||
ALLOW_VIEW: true,
|
||||
ALLOW_CREATE: false,
|
||||
@@ -42,7 +42,7 @@ describe('mapUserPrivileges', () => {
|
||||
it('returns an empty map for superadmin (adapter grants full privileges)', () => {
|
||||
const result = mapUserPrivileges(
|
||||
{
|
||||
PRIVILEGES: { view: true, create: true, update: true, delete: true },
|
||||
'ADMIN.SETTINGS.USER.PRIVILEGES': { view: true, create: true, update: true, delete: true },
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('TrackGoRemoteDataServices', () => {
|
||||
httpClient = createMockHttpClient();
|
||||
service = new SampleRemoteDataServices(httpClient, {
|
||||
apiUrl: '/divisions',
|
||||
moduleKey: 'CONFIGURATION.DIVISION',
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.DIVISION',
|
||||
transformer: new SampleTransformer(),
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user