feat: implement privilege permissions table and enhance user detail views
- Added a new PrivilegePermissionsTable component to display permissions associated with user privileges in a structured format. - Refactored DetailPermissions and UserPageDetail components to utilize the new PrivilegePermissionsTable for better organization of privilege information. - Introduced employeeId support in user management, allowing for enhanced user detail views and improved data handling. - Updated validation schemas and unit tests to accommodate new employee and privilege relationships. These changes improve the user management experience by providing clearer visibility into user privileges and associated employee details.
This commit is contained in:
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { EMPTY_MATRIX_CELL } from './entities';
|
||||||
|
import { buildPrivilegeMatrixRows } from './privilege.matrix-rows';
|
||||||
|
|
||||||
|
describe('buildPrivilegeMatrixRows', () => {
|
||||||
|
it('returns an empty list when privilege is missing', () => {
|
||||||
|
expect(buildPrivilegeMatrixRows(null)).toEqual([]);
|
||||||
|
expect(buildPrivilegeMatrixRows(undefined)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps matrix entries onto labeled rows sorted by sortOrder then code', () => {
|
||||||
|
const rows = buildPrivilegeMatrixRows({
|
||||||
|
matrix: {
|
||||||
|
'key-2': { ...EMPTY_MATRIX_CELL, view: true },
|
||||||
|
'key-1': { ...EMPTY_MATRIX_CELL, create: true },
|
||||||
|
},
|
||||||
|
details: [
|
||||||
|
{
|
||||||
|
privilegeKeyId: 'key-2',
|
||||||
|
keyCode: 'USERS',
|
||||||
|
keyLabel: 'Users',
|
||||||
|
sortOrder: 2,
|
||||||
|
action: 'view',
|
||||||
|
value: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
privilegeKeyId: 'key-1',
|
||||||
|
keyCode: 'BRANCHES',
|
||||||
|
keyLabel: 'Branches',
|
||||||
|
sortOrder: 1,
|
||||||
|
action: 'create',
|
||||||
|
value: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(rows.map((row) => row.keyId)).toEqual(['key-1', 'key-2']);
|
||||||
|
expect(rows[0]).toMatchObject({
|
||||||
|
code: 'BRANCHES',
|
||||||
|
label: 'Branches',
|
||||||
|
cell: { ...EMPTY_MATRIX_CELL, create: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the key id when details are missing', () => {
|
||||||
|
const rows = buildPrivilegeMatrixRows({
|
||||||
|
matrix: { 'key-9': { ...EMPTY_MATRIX_CELL, view: true } },
|
||||||
|
details: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(rows[0]).toMatchObject({
|
||||||
|
keyId: 'key-9',
|
||||||
|
code: 'key-9',
|
||||||
|
label: 'key-9',
|
||||||
|
sortOrder: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { EMPTY_MATRIX_CELL, type PrivilegeEntity, type PrivilegeMatrixCell } from './entities';
|
||||||
|
|
||||||
|
export type PrivilegeMatrixRow = {
|
||||||
|
keyId: string;
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
sortOrder: number;
|
||||||
|
cell: PrivilegeMatrixCell;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildPrivilegeMatrixRows(
|
||||||
|
privilege: Pick<PrivilegeEntity, 'matrix' | 'details'> | null | undefined,
|
||||||
|
): PrivilegeMatrixRow[] {
|
||||||
|
const matrix = privilege?.matrix ?? {};
|
||||||
|
const details = privilege?.details ?? [];
|
||||||
|
|
||||||
|
return 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));
|
||||||
|
}
|
||||||
+3
-56
@@ -1,63 +1,10 @@
|
|||||||
import { Box, Paper, Table, Text } from '@repo/ui/components';
|
|
||||||
import { Check, Minus } from 'lucide-react';
|
|
||||||
import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||||
import { EMPTY_MATRIX_CELL, PRIVILEGE_ACTIONS, type PrivilegeEntity } from '../../../domain/entities';
|
import type { PrivilegeEntity } from '../../../domain/entities';
|
||||||
|
import { PrivilegePermissionsTable } from '../privilege-permissions-table';
|
||||||
|
|
||||||
export function DetailPermissions() {
|
export function DetailPermissions() {
|
||||||
const { detailData } = useDetailPageContext<PrivilegeEntity>();
|
const { detailData } = useDetailPageContext<PrivilegeEntity>();
|
||||||
const { t } = useEnterpriseModuleTranslationContext();
|
const { t } = useEnterpriseModuleTranslationContext();
|
||||||
const matrix = detailData?.matrix ?? {};
|
|
||||||
const details = detailData?.details ?? [];
|
|
||||||
|
|
||||||
const rows = Object.entries(matrix)
|
return <PrivilegePermissionsTable privilege={detailData} t={t} />;
|
||||||
.map(([keyId, cell]) => {
|
|
||||||
const sample = details.find((detail) => detail.privilegeKeyId === keyId);
|
|
||||||
return {
|
|
||||||
keyId,
|
|
||||||
code: sample?.keyCode ?? keyId,
|
|
||||||
label: sample?.keyLabel ?? keyId,
|
|
||||||
sortOrder: sample?.sortOrder ?? 0,
|
|
||||||
cell: cell ?? EMPTY_MATRIX_CELL,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
|
||||||
<Text fw={600} mb="md">
|
|
||||||
{t('section_permissions')}
|
|
||||||
</Text>
|
|
||||||
<Box style={{ overflowX: 'auto' }}>
|
|
||||||
<Table striped highlightOnHover withTableBorder withColumnBorders>
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th>{t('matrix_module')}</Table.Th>
|
|
||||||
{PRIVILEGE_ACTIONS.map((action) => (
|
|
||||||
<Table.Th key={action} ta="center">
|
|
||||||
{t(`matrix_${action}`)}
|
|
||||||
</Table.Th>
|
|
||||||
))}
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
<Table.Tbody>
|
|
||||||
{rows.map((row) => (
|
|
||||||
<Table.Tr key={row.keyId}>
|
|
||||||
<Table.Td>
|
|
||||||
<Text size="sm">{row.label}</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{row.code}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
{PRIVILEGE_ACTIONS.map((action) => (
|
|
||||||
<Table.Td key={action} ta="center">
|
|
||||||
{row.cell[action] ? <Check size={16} /> : <Minus size={16} />}
|
|
||||||
</Table.Td>
|
|
||||||
))}
|
|
||||||
</Table.Tr>
|
|
||||||
))}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
import { Box, Paper, Table, Text } from '@repo/ui/components';
|
||||||
|
import { Check, Minus } from 'lucide-react';
|
||||||
|
import { PRIVILEGE_ACTIONS, type PrivilegeEntity } from '../../domain/entities';
|
||||||
|
import { buildPrivilegeMatrixRows } from '../../domain/privilege.matrix-rows';
|
||||||
|
|
||||||
|
export function PrivilegePermissionsTable({
|
||||||
|
privilege,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
privilege: Pick<PrivilegeEntity, 'matrix' | 'details'> | null | undefined;
|
||||||
|
t: (key: string) => string;
|
||||||
|
}) {
|
||||||
|
const rows = buildPrivilegeMatrixRows(privilege);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||||
|
<Text fw={600} mb="md">
|
||||||
|
{t('section_permissions')}
|
||||||
|
</Text>
|
||||||
|
<Box style={{ overflowX: 'auto' }}>
|
||||||
|
<Table striped highlightOnHover withTableBorder withColumnBorders>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>{t('matrix_module')}</Table.Th>
|
||||||
|
{PRIVILEGE_ACTIONS.map((action) => (
|
||||||
|
<Table.Th key={action} ta="center">
|
||||||
|
{t(`matrix_${action}`)}
|
||||||
|
</Table.Th>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<Table.Tr key={row.keyId}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm">{row.label}</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{row.code}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
{PRIVILEGE_ACTIONS.map((action) => (
|
||||||
|
<Table.Td key={action} ta="center">
|
||||||
|
{row.cell[action] ? <Check size={16} /> : <Minus size={16} />}
|
||||||
|
</Table.Td>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
+46
-1
@@ -43,7 +43,7 @@ describe('UsersRemoteDataTransformer', () => {
|
|||||||
expect(entity.updatedBy).toBe('u-99');
|
expect(entity.updatedBy).toBe('u-99');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('builds create payload with username, password, and privilegeId, without status', () => {
|
it('builds create payload with username, password, privilegeId, and employeeId, without status', () => {
|
||||||
const entity: UserEntity = {
|
const entity: UserEntity = {
|
||||||
id: dto.id,
|
id: dto.id,
|
||||||
username: dto.username,
|
username: dto.username,
|
||||||
@@ -59,6 +59,7 @@ describe('UsersRemoteDataTransformer', () => {
|
|||||||
username: 'alice',
|
username: 'alice',
|
||||||
password: 'password123',
|
password: 'password123',
|
||||||
privilegeId: 'priv-1',
|
privilegeId: 'priv-1',
|
||||||
|
employeeId: 'emp-1',
|
||||||
});
|
});
|
||||||
expect(payload).not.toHaveProperty('status');
|
expect(payload).not.toHaveProperty('status');
|
||||||
expect(payload).not.toHaveProperty('isSuperadmin');
|
expect(payload).not.toHaveProperty('isSuperadmin');
|
||||||
@@ -78,20 +79,36 @@ describe('UsersRemoteDataTransformer', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resolves employeeId from nested employee on create', () => {
|
||||||
|
const payload = transformer.transformCreatePayload({
|
||||||
|
username: 'alice',
|
||||||
|
password: 'password123',
|
||||||
|
employee: { id: 'emp-1', code: 'EMP_01', name: 'Ada Lovelace' },
|
||||||
|
});
|
||||||
|
expect(payload).toEqual({
|
||||||
|
username: 'alice',
|
||||||
|
password: 'password123',
|
||||||
|
employeeId: 'emp-1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('builds edit payload without status, empty password, or audit fields', () => {
|
it('builds edit payload without status, empty password, or audit fields', () => {
|
||||||
const payload = transformer.transformEditPayload({
|
const payload = transformer.transformEditPayload({
|
||||||
username: dto.username,
|
username: dto.username,
|
||||||
password: '',
|
password: '',
|
||||||
privilegeId: 'priv-1',
|
privilegeId: 'priv-1',
|
||||||
|
employee: dto.employee,
|
||||||
status: dto.status,
|
status: dto.status,
|
||||||
});
|
});
|
||||||
expect(payload).toEqual({
|
expect(payload).toEqual({
|
||||||
username: 'alice',
|
username: 'alice',
|
||||||
privilegeId: 'priv-1',
|
privilegeId: 'priv-1',
|
||||||
|
employeeId: 'emp-1',
|
||||||
});
|
});
|
||||||
expect(payload).not.toHaveProperty('password');
|
expect(payload).not.toHaveProperty('password');
|
||||||
expect(payload).not.toHaveProperty('status');
|
expect(payload).not.toHaveProperty('status');
|
||||||
expect(payload).not.toHaveProperty('id');
|
expect(payload).not.toHaveProperty('id');
|
||||||
|
expect(payload).not.toHaveProperty('employee');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('includes password on edit only when it is non-empty', () => {
|
it('includes password on edit only when it is non-empty', () => {
|
||||||
@@ -99,11 +116,13 @@ describe('UsersRemoteDataTransformer', () => {
|
|||||||
username: 'alice',
|
username: 'alice',
|
||||||
password: 'newpassword',
|
password: 'newpassword',
|
||||||
privilegeId: 'priv-1',
|
privilegeId: 'priv-1',
|
||||||
|
employeeId: 'emp-1',
|
||||||
});
|
});
|
||||||
expect(payload).toEqual({
|
expect(payload).toEqual({
|
||||||
username: 'alice',
|
username: 'alice',
|
||||||
password: 'newpassword',
|
password: 'newpassword',
|
||||||
privilegeId: 'priv-1',
|
privilegeId: 'priv-1',
|
||||||
|
employeeId: 'emp-1',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -116,6 +135,21 @@ describe('UsersRemoteDataTransformer', () => {
|
|||||||
expect(payload).toEqual({
|
expect(payload).toEqual({
|
||||||
username: 'alice',
|
username: 'alice',
|
||||||
privilegeId: null,
|
privilegeId: null,
|
||||||
|
employeeId: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends employeeId null when employee is cleared on edit', () => {
|
||||||
|
const payload = transformer.transformEditPayload({
|
||||||
|
username: 'alice',
|
||||||
|
privilegeId: 'priv-1',
|
||||||
|
employee: null,
|
||||||
|
employeeId: '',
|
||||||
|
});
|
||||||
|
expect(payload).toEqual({
|
||||||
|
username: 'alice',
|
||||||
|
privilegeId: 'priv-1',
|
||||||
|
employeeId: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -142,4 +176,15 @@ describe('UsersRemoteDataTransformer', () => {
|
|||||||
privilegeId: 'priv-1',
|
privilegeId: 'priv-1',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('maps employee object to employeeId on filter payload', () => {
|
||||||
|
const payload = transformer.transformPayloadFilter({
|
||||||
|
username: 'alice',
|
||||||
|
employee: { id: 'emp-1', code: 'EMP_01', name: 'Ada Lovelace' },
|
||||||
|
});
|
||||||
|
expect(payload).toEqual({
|
||||||
|
username: 'alice',
|
||||||
|
employeeId: 'emp-1',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+24
-13
@@ -2,12 +2,26 @@ import { BaseDataTransformer } from '@repo/core-api/data-services';
|
|||||||
import { emptyToNull, omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators';
|
import { emptyToNull, omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators';
|
||||||
import type { UserDto, UserEntity } from '../entities';
|
import type { UserDto, UserEntity } from '../entities';
|
||||||
|
|
||||||
function resolvePrivilegeId(entity: Partial<UserEntity>): string | null | undefined {
|
function resolveRelationId(
|
||||||
if (entity.privilege && typeof entity.privilege === 'object') {
|
relation: { id?: unknown } | null | undefined,
|
||||||
const id = entity.privilege.id;
|
fallbackId?: string | null,
|
||||||
|
): string | null | undefined {
|
||||||
|
if (relation && typeof relation === 'object') {
|
||||||
|
const id = relation.id;
|
||||||
return id == null ? null : String(id);
|
return id == null ? null : String(id);
|
||||||
}
|
}
|
||||||
return entity.privilegeId;
|
return fallbackId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenRelationFilter(filter: Record<string, any>, objectKey: string, idKey: string) {
|
||||||
|
const value = filter[objectKey];
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
filter[idKey] = value.id;
|
||||||
|
delete filter[objectKey];
|
||||||
|
} else if (typeof value === 'string') {
|
||||||
|
filter[idKey] = value;
|
||||||
|
delete filter[objectKey];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function flattenActor(value: unknown): string | undefined {
|
function flattenActor(value: unknown): string | undefined {
|
||||||
@@ -51,14 +65,16 @@ export class UsersRemoteDataTransformer extends BaseDataTransformer<UserEntity>
|
|||||||
return omitEmptyFields({
|
return omitEmptyFields({
|
||||||
username: entity.username,
|
username: entity.username,
|
||||||
password: entity.password,
|
password: entity.password,
|
||||||
privilegeId: resolvePrivilegeId(entity),
|
privilegeId: resolveRelationId(entity.privilege, entity.privilegeId),
|
||||||
|
employeeId: resolveRelationId(entity.employee, entity.employeeId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
transformEditPayload(entity: Partial<UserEntity>): Partial<UserEntity> {
|
transformEditPayload(entity: Partial<UserEntity>): Partial<UserEntity> {
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
username: entity.username,
|
username: entity.username,
|
||||||
privilegeId: emptyToNull(resolvePrivilegeId(entity)) as string | null,
|
privilegeId: emptyToNull(resolveRelationId(entity.privilege, entity.privilegeId)) as string | null,
|
||||||
|
employeeId: emptyToNull(resolveRelationId(entity.employee, entity.employeeId)) as string | null,
|
||||||
};
|
};
|
||||||
if (entity.password) {
|
if (entity.password) {
|
||||||
return { ...payload, password: entity.password };
|
return { ...payload, password: entity.password };
|
||||||
@@ -68,13 +84,8 @@ export class UsersRemoteDataTransformer extends BaseDataTransformer<UserEntity>
|
|||||||
|
|
||||||
transformPayloadFilter(filter: Record<string, any>): Record<string, any> {
|
transformPayloadFilter(filter: Record<string, any>): Record<string, any> {
|
||||||
const next = { ...filter };
|
const next = { ...filter };
|
||||||
if (next.privilege && typeof next.privilege === 'object') {
|
flattenRelationFilter(next, 'privilege', 'privilegeId');
|
||||||
next.privilegeId = next.privilege.id;
|
flattenRelationFilter(next, 'employee', 'employeeId');
|
||||||
delete next.privilege;
|
|
||||||
} else if (typeof next.privilege === 'string') {
|
|
||||||
next.privilegeId = next.privilege;
|
|
||||||
delete next.privilege;
|
|
||||||
}
|
|
||||||
return omitEmptyFields(next);
|
return omitEmptyFields(next);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,4 +47,19 @@ describe('createUserSchema', () => {
|
|||||||
const schema = createUserSchema(t, { requirePassword: false });
|
const schema = createUserSchema(t, { requirePassword: false });
|
||||||
expect(schema.safeParse({ username: 'alice', password: 'short' }).success).toBe(false);
|
expect(schema.safeParse({ username: 'alice', password: 'short' }).success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('accepts an optional employee relation on create', () => {
|
||||||
|
const schema = createUserSchema(t, { requirePassword: true });
|
||||||
|
expect(
|
||||||
|
schema.safeParse({
|
||||||
|
...valid,
|
||||||
|
employee: { id: 'emp-1', code: 'EMP_01', name: 'Ada Lovelace' },
|
||||||
|
}).success,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a null employee', () => {
|
||||||
|
const schema = createUserSchema(t, { requirePassword: true });
|
||||||
|
expect(schema.safeParse({ ...valid, employee: null }).success).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,5 +25,7 @@ export const createUserSchema = (t: (key: string) => string, options?: { require
|
|||||||
password,
|
password,
|
||||||
privilege: z.any().nullable().optional(),
|
privilege: z.any().nullable().optional(),
|
||||||
privilegeId: z.string().nullable().optional(),
|
privilegeId: z.string().nullable().optional(),
|
||||||
|
employee: z.any().nullable().optional(),
|
||||||
|
employeeId: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Box, Paper, SimpleGrid, FieldValue, Stack, Text, StatusBadge } from '@repo/ui/components';
|
||||||
|
import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||||
|
import { privilegesDataService } from '../../../../privileges/domain/factories';
|
||||||
|
import { PrivilegePermissionsTable } from '../../../../privileges/presentation/components/privilege-permissions-table';
|
||||||
|
import type { PrivilegeEntity } from '../../../../privileges/domain/entities';
|
||||||
|
import type { UserEntity } from '../../../domain/entities';
|
||||||
|
|
||||||
|
function resolvePrivilegeId(user: UserEntity | null | undefined): string | undefined {
|
||||||
|
const fromRelation = user?.privilege?.id;
|
||||||
|
const fromId = user?.privilegeId;
|
||||||
|
const value = fromRelation || fromId;
|
||||||
|
return value ? String(value) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DetailPrivilege() {
|
||||||
|
const { detailData } = useDetailPageContext<UserEntity>();
|
||||||
|
const { t } = useEnterpriseModuleTranslationContext();
|
||||||
|
const privilegeId = resolvePrivilegeId(detailData);
|
||||||
|
const [privilege, setPrivilege] = useState<PrivilegeEntity | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!privilegeId) {
|
||||||
|
setPrivilege(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
void privilegesDataService
|
||||||
|
.getOne(String(privilegeId))
|
||||||
|
.then((result) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const entity = (result.data as { data?: PrivilegeEntity } | undefined)?.data;
|
||||||
|
setPrivilege(entity ?? null);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setPrivilege(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [privilegeId]);
|
||||||
|
|
||||||
|
if (!privilegeId) {
|
||||||
|
return (
|
||||||
|
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||||
|
<Text fw={600} mb="md">
|
||||||
|
{t('section_privilege')}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t('privilege_empty')}
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||||
|
<Text fw={600} mb="md">
|
||||||
|
{t('section_privilege')}
|
||||||
|
</Text>
|
||||||
|
<Box>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md" verticalSpacing="xl">
|
||||||
|
<FieldValue label={t('common:fields.code')} value={privilege?.code ?? detailData?.privilege?.code} />
|
||||||
|
<FieldValue label={t('common:fields.name')} value={privilege?.name ?? detailData?.privilege?.name} />
|
||||||
|
<FieldValue
|
||||||
|
label={t('common:fields.status')}
|
||||||
|
value={privilege?.status}
|
||||||
|
render={(val) => (val ? <StatusBadge status={String(val)} /> : '-')}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
{privilege ? <PrivilegePermissionsTable privilege={privilege} t={t} /> : null}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
+46
-9
@@ -2,24 +2,48 @@ import { useEffect } from 'react';
|
|||||||
import { Box, FieldTextInput, FieldPasswordInput, FieldAsyncSelect, Paper, SimpleGrid, Text } from '@repo/ui/components';
|
import { Box, FieldTextInput, FieldPasswordInput, FieldAsyncSelect, Paper, SimpleGrid, Text } from '@repo/ui/components';
|
||||||
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
|
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
|
||||||
import { loadPrivilegeOptions } from '../../load-privilege-options';
|
import { loadPrivilegeOptions } from '../../load-privilege-options';
|
||||||
|
import { loadEmployeeOptions } from '../../load-employee-options';
|
||||||
import { privilegesDataService } from '../../../../privileges/domain/factories';
|
import { privilegesDataService } from '../../../../privileges/domain/factories';
|
||||||
|
import { employeesDataService } from '../../../../../configuration/employees/domain/factories';
|
||||||
|
import { relationLabel } from '../../../../../field/shared/relation-label';
|
||||||
import type { PrivilegeEntity } from '../../../../privileges/domain/entities';
|
import type { PrivilegeEntity } from '../../../../privileges/domain/entities';
|
||||||
|
import type { EmployeeEntity } from '../../../../../configuration/employees/domain/entities';
|
||||||
|
|
||||||
|
function hydrateRelation(
|
||||||
|
formControl: { getValues: (name: string) => unknown; setValue: (name: string, value: unknown) => void },
|
||||||
|
objectKey: 'privilege' | 'employee',
|
||||||
|
idKey: 'privilegeId' | 'employeeId',
|
||||||
|
current: { id?: string; code?: string; name?: string } | null | undefined,
|
||||||
|
getOne: (id: string) => Promise<{ data?: { data?: unknown } }>,
|
||||||
|
) {
|
||||||
|
const id = current?.id ?? formControl.getValues(idKey);
|
||||||
|
if (!id) return;
|
||||||
|
if (current?.code && current.name && current.name !== String(current.id)) return;
|
||||||
|
void getOne(String(id))
|
||||||
|
.then((result) => {
|
||||||
|
const entity = result.data?.data;
|
||||||
|
if (entity) formControl.setValue(objectKey, entity);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
export function FormGeneral({ requirePassword }: { requirePassword: boolean }) {
|
export function FormGeneral({ requirePassword }: { requirePassword: boolean }) {
|
||||||
const { formControl } = useFormPageContext();
|
const { formControl } = useFormPageContext();
|
||||||
const { t } = useEnterpriseModuleTranslationContext();
|
const { t } = useEnterpriseModuleTranslationContext();
|
||||||
const privilege = formControl.watch('privilege');
|
const privilege = formControl.watch('privilege');
|
||||||
|
const employee = formControl.watch('employee');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const current = formControl.getValues('privilege') as PrivilegeEntity | null | undefined;
|
hydrateRelation(formControl, 'privilege', 'privilegeId', privilege, (id) =>
|
||||||
const id = current?.id ?? formControl.getValues('privilegeId');
|
privilegesDataService.getOne(id),
|
||||||
if (!id) return;
|
);
|
||||||
if (current?.code && current.name && current.name !== String(current.id)) return;
|
}, [privilege, formControl]);
|
||||||
void privilegesDataService.getOne(String(id)).then((result) => {
|
|
||||||
const entity = (result.data as { data?: PrivilegeEntity } | undefined)?.data;
|
useEffect(() => {
|
||||||
if (entity) formControl.setValue('privilege', entity);
|
hydrateRelation(formControl, 'employee', 'employeeId', employee, (id) =>
|
||||||
});
|
employeesDataService.getOne(id),
|
||||||
}, [formControl]);
|
);
|
||||||
|
}, [employee, formControl]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||||
@@ -58,6 +82,19 @@ export function FormGeneral({ requirePassword }: { requirePassword: boolean }) {
|
|||||||
defaultOptions={privilege ? [privilege] : []}
|
defaultOptions={privilege ? [privilege] : []}
|
||||||
renderLabel={(item) => `${item.code} - ${item.name}`}
|
renderLabel={(item) => `${item.code} - ${item.name}`}
|
||||||
/>
|
/>
|
||||||
|
<FieldAsyncSelect<EmployeeEntity>
|
||||||
|
control={formControl.control}
|
||||||
|
name="employee"
|
||||||
|
label={t('common:fields.employee')}
|
||||||
|
placeholder={t('common:fields.employee')}
|
||||||
|
valueKey="id"
|
||||||
|
labelKey="name"
|
||||||
|
clearable
|
||||||
|
searchable
|
||||||
|
loadOptions={loadEmployeeOptions}
|
||||||
|
defaultOptions={employee ? [employee] : []}
|
||||||
|
renderLabel={relationLabel}
|
||||||
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
+14
@@ -3,8 +3,10 @@ import { FieldTextInput, FieldSelect, FieldAsyncSelect } from '@repo/ui/form';
|
|||||||
import { UseFormReturn } from 'react-hook-form';
|
import { UseFormReturn } from 'react-hook-form';
|
||||||
import { statusFilterOptions } from '../../../../../configuration/shared/status-filter-options';
|
import { statusFilterOptions } from '../../../../../configuration/shared/status-filter-options';
|
||||||
import { loadPrivilegeOptions } from '../../load-privilege-options';
|
import { loadPrivilegeOptions } from '../../load-privilege-options';
|
||||||
|
import { loadEmployeeOptions } from '../../load-employee-options';
|
||||||
import { relationLabel } from '../../../../../field/shared/relation-label';
|
import { relationLabel } from '../../../../../field/shared/relation-label';
|
||||||
import type { PrivilegeEntity } from '../../../../privileges/domain/entities';
|
import type { PrivilegeEntity } from '../../../../privileges/domain/entities';
|
||||||
|
import type { EmployeeEntity } from '../../../../../configuration/employees/domain/entities';
|
||||||
|
|
||||||
export const FilterFormContent = ({ form, t }: { form: UseFormReturn<any>; t: (key: string) => string }) => {
|
export const FilterFormContent = ({ form, t }: { form: UseFormReturn<any>; t: (key: string) => string }) => {
|
||||||
return (
|
return (
|
||||||
@@ -27,6 +29,18 @@ export const FilterFormContent = ({ form, t }: { form: UseFormReturn<any>; t: (k
|
|||||||
loadOptions={loadPrivilegeOptions}
|
loadOptions={loadPrivilegeOptions}
|
||||||
renderLabel={(item) => relationLabel(item)}
|
renderLabel={(item) => relationLabel(item)}
|
||||||
/>
|
/>
|
||||||
|
<FieldAsyncSelect<EmployeeEntity>
|
||||||
|
control={form.control}
|
||||||
|
name="employee"
|
||||||
|
label={t('common:fields.employee')}
|
||||||
|
placeholder={t('common:fields.employee')}
|
||||||
|
valueKey="id"
|
||||||
|
labelKey="name"
|
||||||
|
clearable
|
||||||
|
searchable
|
||||||
|
loadOptions={loadEmployeeOptions}
|
||||||
|
renderLabel={(item) => relationLabel(item)}
|
||||||
|
/>
|
||||||
<FieldSelect
|
<FieldSelect
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="status"
|
name="status"
|
||||||
|
|||||||
@@ -5,11 +5,20 @@
|
|||||||
"edit_page_title": "Edit User",
|
"edit_page_title": "Edit User",
|
||||||
"duplicate_page_title": "Duplicate User",
|
"duplicate_page_title": "Duplicate User",
|
||||||
"description": "Manage application <1>users</1>, passwords, and privilege assignment.",
|
"description": "Manage application <1>users</1>, passwords, and privilege assignment.",
|
||||||
"detail_page_description": "Review username, privilege, employee link, and account status.",
|
"detail_page_description": "Review username, assigned privilege details, employee link, and account status.",
|
||||||
"create_page_description": "Create a user with a username, password, and optional privilege.",
|
"create_page_description": "Create a user with a username, password, optional privilege, and optional employee.",
|
||||||
"edit_page_description": "Update username, password, and assigned privilege.",
|
"edit_page_description": "Update username, password, assigned privilege, and employee.",
|
||||||
"duplicate_page_description": "Copy an existing user to create a new account.",
|
"duplicate_page_description": "Copy an existing user to create a new account.",
|
||||||
"section_general": "General",
|
"section_general": "General",
|
||||||
|
"section_privilege": "Privilege",
|
||||||
|
"section_permissions": "Permissions",
|
||||||
|
"matrix_module": "Module",
|
||||||
|
"matrix_view": "View",
|
||||||
|
"matrix_create": "Create",
|
||||||
|
"matrix_update": "Update",
|
||||||
|
"matrix_delete": "Delete",
|
||||||
|
"matrix_import": "Import",
|
||||||
|
"privilege_empty": "No privilege assigned",
|
||||||
"status_draft": "Draft",
|
"status_draft": "Draft",
|
||||||
"status_active": "Active",
|
"status_active": "Active",
|
||||||
"status_archived": "Archived",
|
"status_archived": "Archived",
|
||||||
|
|||||||
@@ -5,11 +5,20 @@
|
|||||||
"edit_page_title": "Ubah Pengguna",
|
"edit_page_title": "Ubah Pengguna",
|
||||||
"duplicate_page_title": "Duplikat Pengguna",
|
"duplicate_page_title": "Duplikat Pengguna",
|
||||||
"description": "Kelola <1>pengguna</1> aplikasi, kata sandi, dan penetapan hak akses.",
|
"description": "Kelola <1>pengguna</1> aplikasi, kata sandi, dan penetapan hak akses.",
|
||||||
"detail_page_description": "Tinjau nama pengguna, hak akses, tautan karyawan, dan status akun.",
|
"detail_page_description": "Tinjau nama pengguna, detail hak akses, tautan karyawan, dan status akun.",
|
||||||
"create_page_description": "Buat pengguna dengan nama pengguna, kata sandi, dan hak akses opsional.",
|
"create_page_description": "Buat pengguna dengan nama pengguna, kata sandi, hak akses opsional, dan karyawan opsional.",
|
||||||
"edit_page_description": "Perbarui nama pengguna, kata sandi, dan hak akses yang ditetapkan.",
|
"edit_page_description": "Perbarui nama pengguna, kata sandi, hak akses, dan karyawan yang ditetapkan.",
|
||||||
"duplicate_page_description": "Salin pengguna yang ada untuk membuat akun baru.",
|
"duplicate_page_description": "Salin pengguna yang ada untuk membuat akun baru.",
|
||||||
"section_general": "Umum",
|
"section_general": "Umum",
|
||||||
|
"section_privilege": "Hak akses",
|
||||||
|
"section_permissions": "Izin",
|
||||||
|
"matrix_module": "Modul",
|
||||||
|
"matrix_view": "Lihat",
|
||||||
|
"matrix_create": "Buat",
|
||||||
|
"matrix_update": "Ubah",
|
||||||
|
"matrix_delete": "Hapus",
|
||||||
|
"matrix_import": "Impor",
|
||||||
|
"privilege_empty": "Tidak ada hak akses yang ditetapkan",
|
||||||
"status_draft": "Draft",
|
"status_draft": "Draft",
|
||||||
"status_active": "Aktif",
|
"status_active": "Aktif",
|
||||||
"status_archived": "Diarsipkan",
|
"status_archived": "Diarsipkan",
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { LoadOptionsFn } from '@repo/ui/form';
|
||||||
|
import { employeesDataService } from '../../../configuration/employees/domain/factories';
|
||||||
|
import type { EmployeeEntity } from '../../../configuration/employees/domain/entities';
|
||||||
|
|
||||||
|
export const loadEmployeeOptions: LoadOptionsFn<EmployeeEntity> = async (search, page) => {
|
||||||
|
const result = await employeesDataService.getMany({
|
||||||
|
params: { search, page, limit: 20, status: 'active' },
|
||||||
|
});
|
||||||
|
const rows = (result.data as { data?: EmployeeEntity[]; meta?: { totalPages?: number } })?.data ?? [];
|
||||||
|
const totalPages = (result.data as { meta?: { totalPages?: number } })?.meta?.totalPages ?? 1;
|
||||||
|
return { options: rows, hasMore: page < totalPages };
|
||||||
|
};
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { Stack } from '@repo/ui/components';
|
||||||
import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||||
import { usersModuleConfig } from '../../domain/constants';
|
import { usersModuleConfig } from '../../domain/constants';
|
||||||
import { DetailGeneral } from '../components/detail-component/detail-general';
|
import { DetailGeneral } from '../components/detail-component/detail-general';
|
||||||
|
import { DetailPrivilege } from '../components/detail-component/detail-privilege';
|
||||||
|
|
||||||
export default function UserPageDetail() {
|
export default function UserPageDetail() {
|
||||||
const { t } = useEnterpriseModuleTranslationContext();
|
const { t } = useEnterpriseModuleTranslationContext();
|
||||||
@@ -17,7 +19,10 @@ export default function UserPageDetail() {
|
|||||||
],
|
],
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DetailGeneral />
|
<Stack gap="md">
|
||||||
|
<DetailGeneral />
|
||||||
|
<DetailPrivilege />
|
||||||
|
</Stack>
|
||||||
</EnterpriseDetailPageProvider>
|
</EnterpriseDetailPageProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ export default function UserPageForm({ formPageType }: { formPageType: FormPageT
|
|||||||
<EnterpriseFormPageProvider
|
<EnterpriseFormPageProvider
|
||||||
formControl={formControl}
|
formControl={formControl}
|
||||||
formPageType={formPageType}
|
formPageType={formPageType}
|
||||||
ignoreKeyDuplicate={['username', 'password']}
|
ignoreKeyDuplicate={['username', 'password', 'employee', 'employeeId']}
|
||||||
ignoreKeyUpdate={['status', 'isSuperadmin', 'employee', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy', 'id']}
|
ignoreKeyUpdate={['status', 'isSuperadmin', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy', 'id']}
|
||||||
highlightDataKey="username"
|
highlightDataKey="username"
|
||||||
pageHeaderProps={{
|
pageHeaderProps={{
|
||||||
title: title?.title,
|
title: title?.title,
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export default function UserPageIndex() {
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
username: '',
|
username: '',
|
||||||
privilege: null,
|
privilege: null,
|
||||||
|
employee: null,
|
||||||
status: '',
|
status: '',
|
||||||
},
|
},
|
||||||
renderBody: (form: any) => {
|
renderBody: (form: any) => {
|
||||||
|
|||||||
@@ -680,7 +680,8 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
|
|||||||
// Define the default Status column
|
// Define the default Status column
|
||||||
const statusColumn: ColDef<E> = {
|
const statusColumn: ColDef<E> = {
|
||||||
colId: 'status',
|
colId: 'status',
|
||||||
maxWidth: 130,
|
minWidth: 100,
|
||||||
|
maxWidth: 100,
|
||||||
|
|
||||||
suppressHeaderMenuButton: true, // Suppress menu to keep the header clean
|
suppressHeaderMenuButton: true, // Suppress menu to keep the header clean
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user