feat: introduce users management module with CRUD functionality
- Added a new Users module, including routes for creating, editing, and viewing user details. - Implemented UI components for user forms and detail views, with validation schemas for user data. - Integrated language support for English and Indonesian in the users module. - Developed unit tests for user remote data services and transformers to ensure functionality and reliability. These changes enhance the application by providing a structured approach to user management, improving user experience and data handling.
This commit is contained in:
@@ -8,6 +8,7 @@ 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'));
|
||||
const UsersModule = lazy(() => import('./modules/system/users/presentation/factory'));
|
||||
const ConfigurationModule = lazy(() => import('./modules/configuration'));
|
||||
const SalesModule = lazy(() => import('./modules/sales'));
|
||||
const LogisticsFieldModule = lazy(() => import('./modules/field/logistics'));
|
||||
@@ -22,6 +23,7 @@ export default function AppModule() {
|
||||
<Route path="/system/information" element={<SystemInformation />} />
|
||||
<Route path="/system/notifications" element={<SystemNotification />} />
|
||||
<Route path="/system/privileges/*" element={<PrivilegesModule />} />
|
||||
<Route path="/system/users/*" element={<UsersModule />} />
|
||||
<Route path="/configuration/*" element={<ConfigurationModule />} />
|
||||
<Route path="/sales/*" element={<SalesModule />} />
|
||||
<Route path="/logistics/*" element={<LogisticsFieldModule />} />
|
||||
|
||||
@@ -44,44 +44,44 @@ export const MENU_ITEMS: MenuItemType[] = [
|
||||
icon: LayoutDashboard,
|
||||
path: '/app/dashboard',
|
||||
},
|
||||
{
|
||||
key: 'crm',
|
||||
label: 'nav:crm',
|
||||
icon: Users,
|
||||
path: '/app/crm',
|
||||
children: [
|
||||
{
|
||||
key: 'crm-leads',
|
||||
label: 'nav:crm-leads',
|
||||
icon: Briefcase,
|
||||
path: '/app/crm/leads',
|
||||
},
|
||||
{
|
||||
key: 'crm-pipelines',
|
||||
label: 'nav:crm-pipelines',
|
||||
icon: Activity,
|
||||
path: '/app/crm/pipelines',
|
||||
},
|
||||
{
|
||||
key: 'crm-contacts',
|
||||
label: 'nav:crm-contacts',
|
||||
icon: Phone,
|
||||
path: '/app/crm/contacts',
|
||||
},
|
||||
],
|
||||
},
|
||||
// {
|
||||
// key: 'crm',
|
||||
// label: 'nav:crm',
|
||||
// icon: Users,
|
||||
// path: '/app/crm',
|
||||
// children: [
|
||||
// {
|
||||
// key: 'crm-leads',
|
||||
// label: 'nav:crm-leads',
|
||||
// icon: Briefcase,
|
||||
// path: '/app/crm/leads',
|
||||
// },
|
||||
// {
|
||||
// key: 'crm-pipelines',
|
||||
// label: 'nav:crm-pipelines',
|
||||
// icon: Activity,
|
||||
// path: '/app/crm/pipelines',
|
||||
// },
|
||||
// {
|
||||
// key: 'crm-contacts',
|
||||
// label: 'nav:crm-contacts',
|
||||
// icon: Phone,
|
||||
// path: '/app/crm/contacts',
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
{
|
||||
key: 'sales',
|
||||
label: 'nav:sales',
|
||||
icon: ShoppingCart,
|
||||
path: '/app/sales',
|
||||
children: [
|
||||
{
|
||||
key: 'sales-quotations',
|
||||
label: 'nav:sales-quotations',
|
||||
icon: FileText,
|
||||
path: '/app/sales/quotations',
|
||||
},
|
||||
// {
|
||||
// key: 'sales-quotations',
|
||||
// label: 'nav:sales-quotations',
|
||||
// icon: FileText,
|
||||
// path: '/app/sales/quotations',
|
||||
// },
|
||||
{
|
||||
key: 'sales-requests',
|
||||
label: 'nav:sales-requests',
|
||||
@@ -140,150 +140,150 @@ export const MENU_ITEMS: MenuItemType[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'supply-chain',
|
||||
label: 'nav:supply-chain',
|
||||
icon: Truck,
|
||||
path: '/app/supply-chain',
|
||||
children: [
|
||||
{
|
||||
key: 'sc-inventory',
|
||||
label: 'nav:sc-inventory',
|
||||
icon: Box,
|
||||
path: '/app/supply-chain/inventory',
|
||||
children: [
|
||||
{
|
||||
key: 'sc-inventory-products',
|
||||
label: 'nav:sc-inventory-products',
|
||||
icon: Layers,
|
||||
path: '/app/supply-chain/inventory/products',
|
||||
},
|
||||
{
|
||||
key: 'sc-inventory-categories',
|
||||
label: 'nav:sc-inventory-categories',
|
||||
icon: Globe,
|
||||
path: '/app/supply-chain/inventory/categories',
|
||||
},
|
||||
{
|
||||
key: 'sc-inventory-adjustments',
|
||||
label: 'nav:sc-inventory-adjustments',
|
||||
icon: FileSearch,
|
||||
path: '/app/supply-chain/inventory/adjustments',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'sc-warehouses',
|
||||
label: 'nav:sc-warehouses',
|
||||
icon: Warehouse,
|
||||
path: '/app/supply-chain/warehouses',
|
||||
},
|
||||
{
|
||||
key: 'sc-logistics',
|
||||
label: 'nav:sc-logistics',
|
||||
icon: Globe,
|
||||
path: '/app/supply-chain/logistics',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'manufacturing',
|
||||
label: 'nav:manufacturing',
|
||||
icon: Factory,
|
||||
path: '/app/manufacturing',
|
||||
children: [
|
||||
{
|
||||
key: 'mfg-bom',
|
||||
label: 'nav:mfg-bom',
|
||||
icon: Layers,
|
||||
path: '/app/manufacturing/bom',
|
||||
},
|
||||
{
|
||||
key: 'mfg-work-orders',
|
||||
label: 'nav:mfg-work-orders',
|
||||
icon: HardHat,
|
||||
path: '/app/manufacturing/work-orders',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'hris',
|
||||
label: 'nav:hris',
|
||||
icon: Briefcase,
|
||||
path: '/app/hris',
|
||||
children: [
|
||||
{
|
||||
key: 'hris-employees',
|
||||
label: 'nav:hris-employees',
|
||||
icon: Users,
|
||||
path: '/app/hris/employees',
|
||||
},
|
||||
{
|
||||
key: 'hris-attendance',
|
||||
label: 'nav:hris-attendance',
|
||||
icon: Clock,
|
||||
path: '/app/hris/attendance',
|
||||
},
|
||||
{
|
||||
key: 'hris-payroll',
|
||||
label: 'nav:hris-payroll',
|
||||
icon: CreditCard,
|
||||
path: '/app/hris/payroll',
|
||||
},
|
||||
{
|
||||
key: 'hris-calendar',
|
||||
label: 'nav:hris-calendar',
|
||||
icon: Calendar,
|
||||
path: '/app/hris/calendar',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'accounting',
|
||||
label: 'nav:accounting',
|
||||
icon: Calculator,
|
||||
path: '/app/accounting',
|
||||
children: [
|
||||
{
|
||||
key: 'acc-gl',
|
||||
label: 'nav:acc-gl',
|
||||
icon: Database,
|
||||
path: '/app/accounting/general-ledger',
|
||||
},
|
||||
{
|
||||
key: 'acc-taxes',
|
||||
label: 'nav:acc-taxes',
|
||||
icon: PiggyBank,
|
||||
path: '/app/accounting/taxes',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
label: 'nav:settings',
|
||||
icon: Settings,
|
||||
path: '/app/settings',
|
||||
children: [
|
||||
{
|
||||
key: 'settings-general',
|
||||
label: 'nav:settings-general',
|
||||
icon: Settings,
|
||||
path: '/app/settings/general',
|
||||
},
|
||||
{
|
||||
key: 'settings-security',
|
||||
label: 'nav:settings-security',
|
||||
icon: Shield,
|
||||
path: '/app/settings/security',
|
||||
},
|
||||
{
|
||||
key: 'long-text-2',
|
||||
label: 'nav:long-text-2',
|
||||
icon: FileText,
|
||||
path: '/app/settings/long-menu-test',
|
||||
},
|
||||
],
|
||||
},
|
||||
// {
|
||||
// key: 'supply-chain',
|
||||
// label: 'nav:supply-chain',
|
||||
// icon: Truck,
|
||||
// path: '/app/supply-chain',
|
||||
// children: [
|
||||
// {
|
||||
// key: 'sc-inventory',
|
||||
// label: 'nav:sc-inventory',
|
||||
// icon: Box,
|
||||
// path: '/app/supply-chain/inventory',
|
||||
// children: [
|
||||
// {
|
||||
// key: 'sc-inventory-products',
|
||||
// label: 'nav:sc-inventory-products',
|
||||
// icon: Layers,
|
||||
// path: '/app/supply-chain/inventory/products',
|
||||
// },
|
||||
// {
|
||||
// key: 'sc-inventory-categories',
|
||||
// label: 'nav:sc-inventory-categories',
|
||||
// icon: Globe,
|
||||
// path: '/app/supply-chain/inventory/categories',
|
||||
// },
|
||||
// {
|
||||
// key: 'sc-inventory-adjustments',
|
||||
// label: 'nav:sc-inventory-adjustments',
|
||||
// icon: FileSearch,
|
||||
// path: '/app/supply-chain/inventory/adjustments',
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
// {
|
||||
// key: 'sc-warehouses',
|
||||
// label: 'nav:sc-warehouses',
|
||||
// icon: Warehouse,
|
||||
// path: '/app/supply-chain/warehouses',
|
||||
// },
|
||||
// {
|
||||
// key: 'sc-logistics',
|
||||
// label: 'nav:sc-logistics',
|
||||
// icon: Globe,
|
||||
// path: '/app/supply-chain/logistics',
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
// {
|
||||
// key: 'manufacturing',
|
||||
// label: 'nav:manufacturing',
|
||||
// icon: Factory,
|
||||
// path: '/app/manufacturing',
|
||||
// children: [
|
||||
// {
|
||||
// key: 'mfg-bom',
|
||||
// label: 'nav:mfg-bom',
|
||||
// icon: Layers,
|
||||
// path: '/app/manufacturing/bom',
|
||||
// },
|
||||
// {
|
||||
// key: 'mfg-work-orders',
|
||||
// label: 'nav:mfg-work-orders',
|
||||
// icon: HardHat,
|
||||
// path: '/app/manufacturing/work-orders',
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
// {
|
||||
// key: 'hris',
|
||||
// label: 'nav:hris',
|
||||
// icon: Briefcase,
|
||||
// path: '/app/hris',
|
||||
// children: [
|
||||
// {
|
||||
// key: 'hris-employees',
|
||||
// label: 'nav:hris-employees',
|
||||
// icon: Users,
|
||||
// path: '/app/hris/employees',
|
||||
// },
|
||||
// {
|
||||
// key: 'hris-attendance',
|
||||
// label: 'nav:hris-attendance',
|
||||
// icon: Clock,
|
||||
// path: '/app/hris/attendance',
|
||||
// },
|
||||
// {
|
||||
// key: 'hris-payroll',
|
||||
// label: 'nav:hris-payroll',
|
||||
// icon: CreditCard,
|
||||
// path: '/app/hris/payroll',
|
||||
// },
|
||||
// {
|
||||
// key: 'hris-calendar',
|
||||
// label: 'nav:hris-calendar',
|
||||
// icon: Calendar,
|
||||
// path: '/app/hris/calendar',
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
// {
|
||||
// key: 'accounting',
|
||||
// label: 'nav:accounting',
|
||||
// icon: Calculator,
|
||||
// path: '/app/accounting',
|
||||
// children: [
|
||||
// {
|
||||
// key: 'acc-gl',
|
||||
// label: 'nav:acc-gl',
|
||||
// icon: Database,
|
||||
// path: '/app/accounting/general-ledger',
|
||||
// },
|
||||
// {
|
||||
// key: 'acc-taxes',
|
||||
// label: 'nav:acc-taxes',
|
||||
// icon: PiggyBank,
|
||||
// path: '/app/accounting/taxes',
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
// {
|
||||
// key: 'settings',
|
||||
// label: 'nav:settings',
|
||||
// icon: Settings,
|
||||
// path: '/app/settings',
|
||||
// children: [
|
||||
// {
|
||||
// key: 'settings-general',
|
||||
// label: 'nav:settings-general',
|
||||
// icon: Settings,
|
||||
// path: '/app/settings/general',
|
||||
// },
|
||||
// {
|
||||
// key: 'settings-security',
|
||||
// label: 'nav:settings-security',
|
||||
// icon: Shield,
|
||||
// path: '/app/settings/security',
|
||||
// },
|
||||
// {
|
||||
// key: 'long-text-2',
|
||||
// label: 'nav:long-text-2',
|
||||
// icon: FileText,
|
||||
// path: '/app/settings/long-menu-test',
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
{
|
||||
key: 'configuration',
|
||||
label: 'nav:configuration',
|
||||
@@ -340,27 +340,34 @@ export const MENU_ITEMS: MenuItemType[] = [
|
||||
path: '/app/system/privileges/index',
|
||||
moduleKey: 'PRIVILEGES',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'example-module',
|
||||
label: 'nav:example-module',
|
||||
icon: Database,
|
||||
path: '/app/example-module',
|
||||
children: [
|
||||
{
|
||||
key: 'example-full-page',
|
||||
label: 'nav:example-full-page',
|
||||
icon: LayoutDashboard,
|
||||
path: '/app/example/full-page/index',
|
||||
moduleKey: 'EXAMPLE_FULL_PAGE',
|
||||
key: 'system-users',
|
||||
label: 'nav:system-users',
|
||||
icon: Users,
|
||||
path: '/app/system/users/index',
|
||||
moduleKey: 'USERS',
|
||||
},
|
||||
// {
|
||||
// key: 'example-single-page',
|
||||
// label: 'nav:example-single-page',
|
||||
// icon: FileText,
|
||||
// path: '/app/example/single-page/index',
|
||||
// },
|
||||
],
|
||||
},
|
||||
// {
|
||||
// key: 'example-module',
|
||||
// label: 'nav:example-module',
|
||||
// icon: Database,
|
||||
// path: '/app/example-module',
|
||||
// children: [
|
||||
// {
|
||||
// key: 'example-full-page',
|
||||
// label: 'nav:example-full-page',
|
||||
// icon: LayoutDashboard,
|
||||
// path: '/app/example/full-page/index',
|
||||
// moduleKey: 'EXAMPLE_FULL_PAGE',
|
||||
// },
|
||||
// // {
|
||||
// // key: 'example-single-page',
|
||||
// // label: 'nav:example-single-page',
|
||||
// // icon: FileText,
|
||||
// // path: '/app/example/single-page/index',
|
||||
// // },
|
||||
// ],
|
||||
// },
|
||||
];
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"example-single-page": "Example Single Page",
|
||||
"system": "System",
|
||||
"system-privileges": "Privileges",
|
||||
"system-users": "Users",
|
||||
"configuration": "Configuration",
|
||||
"configuration-divisions": "Divisions",
|
||||
"configuration-branches": "Branches",
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"example-single-page": "Contoh Halaman Tunggal",
|
||||
"system": "Sistem",
|
||||
"system-privileges": "Hak Akses",
|
||||
"system-users": "Pengguna",
|
||||
"configuration": "Konfigurasi",
|
||||
"configuration-divisions": "Divisi",
|
||||
"configuration-branches": "Cabang",
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { AxiosInstance } from '@repo/core-api/http-client';
|
||||
import { UsersRemoteDataServices } from './user.remote.service';
|
||||
import { UsersRemoteDataTransformer } from '../domain/transformers/user.remote.transformer';
|
||||
|
||||
function createMockHttpClient(): AxiosInstance {
|
||||
return {
|
||||
request: vi.fn().mockResolvedValue({ data: {}, status: 200 }),
|
||||
defaults: {} as AxiosInstance['defaults'],
|
||||
interceptors: {
|
||||
request: { use: vi.fn(), eject: vi.fn(), clear: vi.fn() },
|
||||
response: { use: vi.fn(), eject: vi.fn(), clear: vi.fn() },
|
||||
},
|
||||
getUri: vi.fn(),
|
||||
get: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
head: vi.fn(),
|
||||
options: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
postForm: vi.fn(),
|
||||
putForm: vi.fn(),
|
||||
patchForm: vi.fn(),
|
||||
} as unknown as AxiosInstance;
|
||||
}
|
||||
|
||||
describe('UsersRemoteDataServices', () => {
|
||||
let httpClient: AxiosInstance;
|
||||
let service: UsersRemoteDataServices;
|
||||
|
||||
beforeEach(() => {
|
||||
httpClient = createMockHttpClient();
|
||||
service = new UsersRemoteDataServices(httpClient, {
|
||||
apiUrl: '/users',
|
||||
moduleKey: 'USERS',
|
||||
transformer: new UsersRemoteDataTransformer(),
|
||||
});
|
||||
});
|
||||
|
||||
it('uses PATCH when editing a user', async () => {
|
||||
await service.edit('user-1', { username: 'alice' } as any);
|
||||
expect(httpClient.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ url: '/users/user-1', method: 'PATCH' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('bulk-deletes via POST /users/bulk-delete', async () => {
|
||||
await service.batchDelete(['user-1']);
|
||||
expect(httpClient.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ url: '/users/bulk-delete', method: 'POST' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('activates via PATCH /users/:id/status with status active', async () => {
|
||||
await service.activate('user-1');
|
||||
expect(httpClient.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: '/users/user-1/status',
|
||||
method: 'PATCH',
|
||||
data: { status: 'active' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { AxiosInstance } from '@repo/core-api/http-client';
|
||||
import type { DataServicesConfig } from '@repo/core-api/data-services';
|
||||
import { TrackGoRemoteDataServices } from '../../../../../../core/lib/trackgo-remote-data-services';
|
||||
import type { UserEntity } from '../domain/entities';
|
||||
|
||||
export class UsersRemoteDataServices extends TrackGoRemoteDataServices<UserEntity> {
|
||||
constructor(httpClient: AxiosInstance, config: DataServicesConfig<UserEntity>) {
|
||||
super(httpClient, {
|
||||
...config,
|
||||
apiUrl: config.apiUrl ?? '/users',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './user.constants';
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ModuleConfigEntity } from '@repo/ui/foundations';
|
||||
import type { UserEntity } from '../entities';
|
||||
|
||||
export const usersModuleConfig: ModuleConfigEntity<UserEntity> = {
|
||||
moduleKey: 'USERS',
|
||||
translationNamespace: 'USERS',
|
||||
apiUrl: '/users',
|
||||
webUrl: '/app/system/users',
|
||||
moduleCategory: 'FULL_PAGE',
|
||||
moduleType: 'MASTER_DATA',
|
||||
} as const;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './user.entity';
|
||||
@@ -0,0 +1,31 @@
|
||||
import { BaseEntity } from '@repo/core-api/data-services';
|
||||
import type { ConfigurationStatus } from '../../../../configuration/divisions/domain/entities';
|
||||
import type { RelationRef } from '../../../../../../../core/domain/relation-ref';
|
||||
|
||||
export interface UserEntity extends BaseEntity {
|
||||
username: string;
|
||||
password?: string;
|
||||
isSuperadmin?: boolean;
|
||||
privilegeId?: string | null;
|
||||
privilege?: RelationRef | null;
|
||||
employee?: RelationRef | null;
|
||||
status?: ConfigurationStatus;
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
createdBy?: string;
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export interface UserDto {
|
||||
id?: string;
|
||||
username: string;
|
||||
isSuperadmin?: boolean;
|
||||
privilegeId?: string | null;
|
||||
privilege?: RelationRef | null;
|
||||
employee?: RelationRef | null;
|
||||
status?: ConfigurationStatus;
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
createdBy?: string | { id: string; username: string };
|
||||
updatedBy?: string | { id: string; username: string };
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { apiClient } from '../../../../../../../core/lib/api-client';
|
||||
import { UsersRemoteDataServices } from '../../data/user.remote.service';
|
||||
import { usersModuleConfig } from '../constants/user.constants';
|
||||
import { UsersRemoteDataTransformer } from '../transformers/user.remote.transformer';
|
||||
|
||||
export const usersDataTransformer = new UsersRemoteDataTransformer();
|
||||
|
||||
export const usersDataService = new UsersRemoteDataServices(apiClient, {
|
||||
apiUrl: usersModuleConfig.apiUrl,
|
||||
moduleKey: usersModuleConfig.moduleKey,
|
||||
transformer: usersDataTransformer,
|
||||
});
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { UsersRemoteDataTransformer } from './user.remote.transformer';
|
||||
import type { UserEntity } from '../entities';
|
||||
|
||||
const transformer = new UsersRemoteDataTransformer();
|
||||
|
||||
const dto = {
|
||||
id: 'user-1',
|
||||
username: 'alice',
|
||||
isSuperadmin: false,
|
||||
privilege: { id: 'priv-1', code: 'SALES_STAFF', name: 'Sales Staff' },
|
||||
employee: { id: 'emp-1', code: 'EMP_01', name: 'Ada Lovelace' },
|
||||
status: 'active' as const,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
createdBy: { id: 'u1', username: 'admin' },
|
||||
updatedBy: { id: 'u2', username: 'bob' },
|
||||
};
|
||||
|
||||
describe('UsersRemoteDataTransformer', () => {
|
||||
it('maps dto fields onto the entity and flattens privilegeId and audit usernames', () => {
|
||||
const entity = transformer.transformToEntity(dto);
|
||||
expect(entity).toMatchObject({
|
||||
id: 'user-1',
|
||||
username: 'alice',
|
||||
isSuperadmin: false,
|
||||
privilegeId: 'priv-1',
|
||||
privilege: { id: 'priv-1', code: 'SALES_STAFF', name: 'Sales Staff' },
|
||||
employee: { id: 'emp-1', code: 'EMP_01', name: 'Ada Lovelace' },
|
||||
createdBy: 'admin',
|
||||
updatedBy: 'bob',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds create payload with username, password, and privilegeId, without status', () => {
|
||||
const entity: UserEntity = {
|
||||
id: dto.id,
|
||||
username: dto.username,
|
||||
password: 'password123',
|
||||
isSuperadmin: dto.isSuperadmin,
|
||||
privilegeId: 'priv-1',
|
||||
privilege: dto.privilege,
|
||||
employee: dto.employee,
|
||||
status: 'draft',
|
||||
};
|
||||
const payload = transformer.transformCreatePayload(entity);
|
||||
expect(payload).toEqual({
|
||||
username: 'alice',
|
||||
password: 'password123',
|
||||
privilegeId: 'priv-1',
|
||||
});
|
||||
expect(payload).not.toHaveProperty('status');
|
||||
expect(payload).not.toHaveProperty('isSuperadmin');
|
||||
expect(payload).not.toHaveProperty('employee');
|
||||
});
|
||||
|
||||
it('resolves privilegeId from nested privilege on create', () => {
|
||||
const payload = transformer.transformCreatePayload({
|
||||
username: 'alice',
|
||||
password: 'password123',
|
||||
privilege: { id: 'priv-1', code: 'SALES_STAFF', name: 'Sales Staff' },
|
||||
});
|
||||
expect(payload).toEqual({
|
||||
username: 'alice',
|
||||
password: 'password123',
|
||||
privilegeId: 'priv-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds edit payload without status, empty password, or audit fields', () => {
|
||||
const payload = transformer.transformEditPayload({
|
||||
username: dto.username,
|
||||
password: '',
|
||||
privilegeId: 'priv-1',
|
||||
status: dto.status,
|
||||
});
|
||||
expect(payload).toEqual({
|
||||
username: 'alice',
|
||||
privilegeId: 'priv-1',
|
||||
});
|
||||
expect(payload).not.toHaveProperty('password');
|
||||
expect(payload).not.toHaveProperty('status');
|
||||
expect(payload).not.toHaveProperty('id');
|
||||
});
|
||||
|
||||
it('includes password on edit only when it is non-empty', () => {
|
||||
const payload = transformer.transformEditPayload({
|
||||
username: 'alice',
|
||||
password: 'newpassword',
|
||||
privilegeId: 'priv-1',
|
||||
});
|
||||
expect(payload).toEqual({
|
||||
username: 'alice',
|
||||
password: 'newpassword',
|
||||
privilegeId: 'priv-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('sends privilegeId null when privilege is cleared on edit', () => {
|
||||
const payload = transformer.transformEditPayload({
|
||||
username: 'alice',
|
||||
privilege: null,
|
||||
privilegeId: '',
|
||||
});
|
||||
expect(payload).toEqual({
|
||||
username: 'alice',
|
||||
privilegeId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps privilege object to privilegeId on filter payload', () => {
|
||||
const payload = transformer.transformPayloadFilter({
|
||||
username: 'alice',
|
||||
status: 'active',
|
||||
privilege: { id: 'priv-1', code: 'SALES_STAFF', name: 'Sales Staff' },
|
||||
});
|
||||
expect(payload).toEqual({
|
||||
username: 'alice',
|
||||
status: 'active',
|
||||
privilegeId: 'priv-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import { BaseDataTransformer } from '@repo/core-api/data-services';
|
||||
import { emptyToNull, omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators';
|
||||
import type { UserDto, UserEntity } from '../entities';
|
||||
|
||||
function resolvePrivilegeId(entity: Partial<UserEntity>): string | null | undefined {
|
||||
if (entity.privilege && typeof entity.privilege === 'object') {
|
||||
const id = entity.privilege.id;
|
||||
return id == null ? null : String(id);
|
||||
}
|
||||
return entity.privilegeId;
|
||||
}
|
||||
|
||||
function flattenActor(value: unknown): string | undefined {
|
||||
if (typeof value === 'string') return value;
|
||||
if (value && typeof value === 'object' && 'username' in value) {
|
||||
return String((value as { username: unknown }).username);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export class UsersRemoteDataTransformer extends BaseDataTransformer<UserEntity> {
|
||||
transformToEntity(dto: UserDto | UserEntity): UserEntity {
|
||||
const privilegeId = 'privilegeId' in dto ? dto.privilegeId : (dto as UserEntity).privilegeId;
|
||||
const privilege = (dto as UserEntity).privilege ?? null;
|
||||
return {
|
||||
id: dto.id,
|
||||
username: dto.username,
|
||||
isSuperadmin: dto.isSuperadmin,
|
||||
privilegeId: privilegeId ?? privilege?.id ?? null,
|
||||
privilege,
|
||||
employee: (dto as UserEntity).employee ?? null,
|
||||
status: dto.status,
|
||||
createdAt: dto.createdAt,
|
||||
updatedAt: dto.updatedAt,
|
||||
createdBy: flattenActor(dto.createdBy),
|
||||
updatedBy: flattenActor(dto.updatedBy),
|
||||
};
|
||||
}
|
||||
|
||||
transformToDTO(entity: UserEntity): UserEntity {
|
||||
return { ...entity };
|
||||
}
|
||||
|
||||
transformCreatePayload(entity: Partial<UserEntity>): Partial<UserEntity> {
|
||||
return omitEmptyFields({
|
||||
username: entity.username,
|
||||
password: entity.password,
|
||||
privilegeId: resolvePrivilegeId(entity),
|
||||
});
|
||||
}
|
||||
|
||||
transformEditPayload(entity: Partial<UserEntity>): Partial<UserEntity> {
|
||||
const payload: Record<string, unknown> = {
|
||||
username: entity.username,
|
||||
privilegeId: emptyToNull(resolvePrivilegeId(entity)) as string | null,
|
||||
};
|
||||
if (entity.password) {
|
||||
return { ...payload, password: entity.password };
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
transformPayloadFilter(filter: Record<string, any>): Record<string, any> {
|
||||
const next = { ...filter };
|
||||
if (next.privilege && typeof next.privilege === 'object') {
|
||||
next.privilegeId = next.privilege.id;
|
||||
delete next.privilege;
|
||||
}
|
||||
return omitEmptyFields(next);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createUserSchema } from './user.validator';
|
||||
|
||||
describe('createUserSchema', () => {
|
||||
const t = (key: string) => key;
|
||||
const valid = {
|
||||
username: 'alice',
|
||||
password: 'password123',
|
||||
};
|
||||
|
||||
it('accepts a complete create payload', () => {
|
||||
const schema = createUserSchema(t, { requirePassword: true });
|
||||
expect(schema.safeParse(valid).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a username shorter than 3 characters', () => {
|
||||
const schema = createUserSchema(t, { requirePassword: true });
|
||||
expect(schema.safeParse({ ...valid, username: 'ab' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a username longer than 32 characters', () => {
|
||||
const schema = createUserSchema(t, { requirePassword: true });
|
||||
expect(schema.safeParse({ ...valid, username: 'a'.repeat(33) }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a username with characters other than letters, digits, or underscore', () => {
|
||||
const schema = createUserSchema(t, { requirePassword: true });
|
||||
expect(schema.safeParse({ ...valid, username: 'alice-bob' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a password shorter than 8 characters when required', () => {
|
||||
const schema = createUserSchema(t, { requirePassword: true });
|
||||
expect(schema.safeParse({ ...valid, password: 'short' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an empty password when required', () => {
|
||||
const schema = createUserSchema(t, { requirePassword: true });
|
||||
expect(schema.safeParse({ ...valid, password: '' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('allows an empty password when not required', () => {
|
||||
const schema = createUserSchema(t, { requirePassword: false });
|
||||
expect(schema.safeParse({ username: 'alice', password: '' }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a short password when not required but provided', () => {
|
||||
const schema = createUserSchema(t, { requirePassword: false });
|
||||
expect(schema.safeParse({ username: 'alice', password: 'short' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { z } from 'zod';
|
||||
import { compose, required, rangeLength } from '@repo/ui/validators';
|
||||
|
||||
const USERNAME_PATTERN = /^[a-zA-Z0-9_]+$/;
|
||||
|
||||
export const createUserSchema = (t: (key: string) => string, options?: { requirePassword?: boolean }) => {
|
||||
const requirePassword = options?.requirePassword ?? true;
|
||||
const passwordLabel = t('common:fields.password');
|
||||
const usernameLabel = t('common:fields.username');
|
||||
|
||||
const password = requirePassword
|
||||
? compose(z.string(), required(passwordLabel), rangeLength(8, 72, passwordLabel))
|
||||
: z.union([z.literal(''), compose(z.string(), rangeLength(8, 72, passwordLabel))]).optional();
|
||||
|
||||
return z.object({
|
||||
username: compose(
|
||||
z.string(),
|
||||
required(usernameLabel),
|
||||
rangeLength(3, 32, usernameLabel),
|
||||
(schema: z.ZodString) =>
|
||||
schema.regex(USERNAME_PATTERN, {
|
||||
message: JSON.stringify({ key: 'validation:invalid_format', values: { field: usernameLabel } }),
|
||||
}),
|
||||
),
|
||||
password,
|
||||
privilege: z.any().nullable().optional(),
|
||||
privilegeId: z.string().nullable().optional(),
|
||||
});
|
||||
};
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import { Box, Paper, SimpleGrid, FieldValue, RenderDate, Text, StatusBadge } from '@repo/ui/components';
|
||||
import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||
import { relationLabel } from '../../../../../field/shared/relation-label';
|
||||
import type { UserEntity } from '../../../domain/entities';
|
||||
|
||||
export function DetailGeneral() {
|
||||
const { detailData } = useDetailPageContext<UserEntity>();
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const data = detailData;
|
||||
|
||||
return (
|
||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||
<Text fw={600} mb="md">
|
||||
{t('section_general')}
|
||||
</Text>
|
||||
<Box>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md" verticalSpacing="xl">
|
||||
<FieldValue label={t('common:fields.username')} value={data?.username} />
|
||||
<FieldValue label={t('common:fields.privilege')} value={relationLabel(data?.privilege)} />
|
||||
<FieldValue label={t('common:fields.employee')} value={relationLabel(data?.employee)} />
|
||||
<FieldValue label={t('common:fields.isSuperadmin')} value={data?.isSuperadmin ? t('yes') : t('no')} />
|
||||
<FieldValue
|
||||
label={t('common:fields.status')}
|
||||
value={data?.status}
|
||||
render={(val) => <StatusBadge status={String(val ?? '')} />}
|
||||
/>
|
||||
<FieldValue
|
||||
label={t('common:fields.createdAt')}
|
||||
value={data?.createdAt}
|
||||
render={(val) => <RenderDate value={val as any} />}
|
||||
/>
|
||||
<FieldValue
|
||||
label={t('common:fields.updatedAt')}
|
||||
value={data?.updatedAt}
|
||||
render={(val) => <RenderDate value={val as any} />}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Box, FieldTextInput, FieldPasswordInput, FieldAsyncSelect, Paper, SimpleGrid, Text } from '@repo/ui/components';
|
||||
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
|
||||
import { loadPrivilegeOptions } from '../../load-privilege-options';
|
||||
import { privilegesDataService } from '../../../../privileges/domain/factories';
|
||||
import type { PrivilegeEntity } from '../../../../privileges/domain/entities';
|
||||
|
||||
export function FormGeneral({ requirePassword }: { requirePassword: boolean }) {
|
||||
const { formControl } = useFormPageContext();
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const privilege = formControl.watch('privilege');
|
||||
|
||||
useEffect(() => {
|
||||
const current = formControl.getValues('privilege') as PrivilegeEntity | null | undefined;
|
||||
const id = current?.id ?? formControl.getValues('privilegeId');
|
||||
if (!id) return;
|
||||
if (current?.code && current.name && current.name !== String(current.id)) return;
|
||||
void privilegesDataService.getOne(String(id)).then((result) => {
|
||||
const entity = (result.data as { data?: PrivilegeEntity } | undefined)?.data;
|
||||
if (entity) formControl.setValue('privilege', entity);
|
||||
});
|
||||
}, [formControl]);
|
||||
|
||||
return (
|
||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||
<Text fw={600} mb="md">
|
||||
{t('section_general')}
|
||||
</Text>
|
||||
<Box>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<FieldTextInput
|
||||
control={formControl.control}
|
||||
name="username"
|
||||
label={t('common:fields.username')}
|
||||
placeholder="e.g. alice"
|
||||
required
|
||||
radius="md"
|
||||
/>
|
||||
<FieldPasswordInput
|
||||
control={formControl.control}
|
||||
name="password"
|
||||
label={t('common:fields.password')}
|
||||
placeholder="••••••••"
|
||||
required={requirePassword}
|
||||
description={requirePassword ? undefined : t('password_keep_hint')}
|
||||
radius="md"
|
||||
/>
|
||||
<FieldAsyncSelect<PrivilegeEntity>
|
||||
control={formControl.control}
|
||||
name="privilege"
|
||||
label={t('common:fields.privilege')}
|
||||
placeholder={t('common:fields.privilege')}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
clearable
|
||||
searchable
|
||||
loadOptions={loadPrivilegeOptions}
|
||||
defaultOptions={privilege ? [privilege] : []}
|
||||
renderLabel={(item) => `${item.code} - ${item.name}`}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { SimpleGrid } from '@repo/ui/components';
|
||||
import { FieldTextInput, FieldSelect, FieldAsyncSelect } from '@repo/ui/form';
|
||||
import { UseFormReturn } from 'react-hook-form';
|
||||
import { statusFilterOptions } from '../../../../../configuration/shared/status-filter-options';
|
||||
import { loadPrivilegeOptions } from '../../load-privilege-options';
|
||||
import { relationLabel } from '../../../../../field/shared/relation-label';
|
||||
import type { PrivilegeEntity } from '../../../../privileges/domain/entities';
|
||||
|
||||
export const FilterFormContent = ({ form, t }: { form: UseFormReturn<any>; t: (key: string) => string }) => {
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<FieldTextInput
|
||||
control={form.control}
|
||||
name="username"
|
||||
label={t('common:fields.username')}
|
||||
placeholder={`Enter ${t('common:fields.username')}`}
|
||||
/>
|
||||
<FieldAsyncSelect<PrivilegeEntity>
|
||||
control={form.control}
|
||||
name="privilege"
|
||||
label={t('common:fields.privilege')}
|
||||
placeholder={t('common:fields.privilege')}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
clearable
|
||||
searchable
|
||||
loadOptions={loadPrivilegeOptions}
|
||||
renderLabel={(item) => relationLabel(item)}
|
||||
/>
|
||||
<FieldSelect
|
||||
control={form.control}
|
||||
name="status"
|
||||
label={t('common:fields.status')}
|
||||
clearable
|
||||
data={statusFilterOptions(t)}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { lazy } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { EnterpriseModuleProvider } from '@repo/ui/foundations';
|
||||
import { registerModuleNamespace } from '@repo/core-i18n';
|
||||
import { usersModuleConfig } from '../../domain/constants';
|
||||
import { usersDataService } from '../../domain/factories';
|
||||
import { UserEntity } from '../../domain/entities';
|
||||
import { usersStore } from '../store';
|
||||
|
||||
import usersId from '../languages/id/users.json';
|
||||
import usersEn from '../languages/en/users.json';
|
||||
|
||||
const IndexPage = lazy(() => import('../pages/user.page.index'));
|
||||
const FormPage = lazy(() => import('../pages/user.page.form'));
|
||||
const DetailPage = lazy(() => import('../pages/user.page.detail'));
|
||||
|
||||
registerModuleNamespace(usersModuleConfig.translationNamespace, {
|
||||
id: usersId,
|
||||
en: usersEn,
|
||||
});
|
||||
|
||||
export default function UsersModule() {
|
||||
return (
|
||||
<EnterpriseModuleProvider<UserEntity>
|
||||
config={usersModuleConfig}
|
||||
dataServices={usersDataService}
|
||||
store={usersStore}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/index" element={<IndexPage />} />
|
||||
<Route path="/detail/:dataId" element={<DetailPage />} />
|
||||
<Route path="/edit/:dataId" element={<FormPage formPageType="EDIT" />} />
|
||||
<Route path="/duplicate/:dataId" element={<FormPage formPageType="DUPLICATE" />} />
|
||||
<Route path="/create" element={<FormPage formPageType="CREATE" />} />
|
||||
<Route path="/" element={<Navigate to={`${usersModuleConfig.webUrl}/index`} replace={true} />} />
|
||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||
</Routes>
|
||||
</EnterpriseModuleProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"title": "Users",
|
||||
"detail_page_title": "User Detail",
|
||||
"create_page_title": "New User",
|
||||
"edit_page_title": "Edit User",
|
||||
"duplicate_page_title": "Duplicate User",
|
||||
"description": "Manage application <1>users</1>, passwords, and privilege assignment.",
|
||||
"detail_page_description": "Review username, privilege, employee link, and account status.",
|
||||
"create_page_description": "Create a user with a username, password, and optional privilege.",
|
||||
"edit_page_description": "Update username, password, and assigned privilege.",
|
||||
"duplicate_page_description": "Copy an existing user to create a new account.",
|
||||
"section_general": "General",
|
||||
"status_draft": "Draft",
|
||||
"status_active": "Active",
|
||||
"status_archived": "Archived",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"password_keep_hint": "Leave blank to keep the current password"
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"title": "Pengguna",
|
||||
"detail_page_title": "Detail Pengguna",
|
||||
"create_page_title": "Pengguna Baru",
|
||||
"edit_page_title": "Ubah Pengguna",
|
||||
"duplicate_page_title": "Duplikat Pengguna",
|
||||
"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.",
|
||||
"create_page_description": "Buat pengguna dengan nama pengguna, kata sandi, dan hak akses opsional.",
|
||||
"edit_page_description": "Perbarui nama pengguna, kata sandi, dan hak akses yang ditetapkan.",
|
||||
"duplicate_page_description": "Salin pengguna yang ada untuk membuat akun baru.",
|
||||
"section_general": "Umum",
|
||||
"status_draft": "Draft",
|
||||
"status_active": "Aktif",
|
||||
"status_archived": "Diarsipkan",
|
||||
"yes": "Ya",
|
||||
"no": "Tidak",
|
||||
"password_keep_hint": "Kosongkan untuk tetap memakai kata sandi saat ini"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { LoadOptionsFn } from '@repo/ui/form';
|
||||
import { privilegesDataService } from '../../privileges/domain/factories';
|
||||
import type { PrivilegeEntity } from '../../privileges/domain/entities';
|
||||
|
||||
export const loadPrivilegeOptions: LoadOptionsFn<PrivilegeEntity> = async (search, page) => {
|
||||
const result = await privilegesDataService.getMany({
|
||||
params: { search, page, limit: 20, status: 'active' },
|
||||
});
|
||||
const rows = (result.data as { data?: PrivilegeEntity[]; meta?: { totalPages?: number } })?.data ?? [];
|
||||
const totalPages = (result.data as { meta?: { totalPages?: number } })?.meta?.totalPages ?? 1;
|
||||
return { options: rows, hasMore: page < totalPages };
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||
import { usersModuleConfig } from '../../domain/constants';
|
||||
import { DetailGeneral } from '../components/detail-component/detail-general';
|
||||
|
||||
export default function UserPageDetail() {
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
|
||||
return (
|
||||
<EnterpriseDetailPageProvider
|
||||
highlightDataKey="username"
|
||||
pageHeaderProps={{
|
||||
title: t('detail_page_title'),
|
||||
description: t('detail_page_description'),
|
||||
breadcrumbs: [
|
||||
{ label: t('nav:system'), type: 'text' },
|
||||
{ label: t('nav:system-users'), type: 'link', href: `${usersModuleConfig.webUrl}/index` },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<DetailGeneral />
|
||||
</EnterpriseDetailPageProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useEnterpriseModuleTranslationContext, EnterpriseFormPageProvider, FormPageType } from '@repo/ui/foundations';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { usersModuleConfig } from '../../domain/constants';
|
||||
import { createUserSchema } from '../../domain/validators/user.validator';
|
||||
import { FormGeneral } from '../components/form-component/form-general';
|
||||
|
||||
export default function UserPageForm({ formPageType }: { formPageType: FormPageType }) {
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const requirePassword = formPageType !== 'EDIT';
|
||||
|
||||
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(() => createUserSchema(t, { requirePassword }), [t, requirePassword]);
|
||||
const formControl = useForm({ resolver: zodResolver(validator) });
|
||||
|
||||
return (
|
||||
<EnterpriseFormPageProvider
|
||||
formControl={formControl}
|
||||
formPageType={formPageType}
|
||||
ignoreKeyDuplicate={['username', 'password']}
|
||||
ignoreKeyUpdate={['status', 'isSuperadmin', 'employee', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy', 'id']}
|
||||
highlightDataKey="username"
|
||||
pageHeaderProps={{
|
||||
title: title?.title,
|
||||
description: title?.description,
|
||||
breadcrumbs: [
|
||||
{ label: t('nav:system'), type: 'text' },
|
||||
{ label: t('nav:system-users'), type: 'link', href: `${usersModuleConfig.webUrl}/index` },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<FormGeneral requirePassword={requirePassword} />
|
||||
</EnterpriseFormPageProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
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 { Users } from 'lucide-react';
|
||||
import { FilterFormContent } from '../components/index-component/filter-content';
|
||||
import { relationLabel } from '../../../../field/shared/relation-label';
|
||||
import type { UserEntity } from '../../domain/entities';
|
||||
|
||||
export default function UserPageIndex() {
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
|
||||
const columnDefs: ColDef<UserEntity>[] = useMemo(() => {
|
||||
return [
|
||||
{ field: 'username', headerName: t('common:fields.username'), minWidth: 160 },
|
||||
{
|
||||
field: 'privilege',
|
||||
headerName: t('common:fields.privilege'),
|
||||
minWidth: 180,
|
||||
valueGetter: ({ data }) => relationLabel(data?.privilege),
|
||||
},
|
||||
{
|
||||
field: 'employee',
|
||||
headerName: t('common:fields.employee'),
|
||||
minWidth: 180,
|
||||
valueGetter: ({ data }) => relationLabel(data?.employee),
|
||||
},
|
||||
{
|
||||
field: 'isSuperadmin',
|
||||
headerName: t('common:fields.isSuperadmin'),
|
||||
minWidth: 140,
|
||||
valueFormatter: ({ value }) => t(value ? 'yes' : 'no'),
|
||||
},
|
||||
];
|
||||
}, [t]);
|
||||
|
||||
const filterConfig = useMemo(() => {
|
||||
return {
|
||||
renderBody: (form: any) => {
|
||||
if (!form) return null;
|
||||
return <FilterFormContent form={form} t={t} />;
|
||||
},
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<EnterpriseIndexPageProvider
|
||||
pageHeaderProps={{
|
||||
title: t('title'),
|
||||
description: (
|
||||
<Trans t={t} i18nKey="description" components={{ 1: <Text span fw={500} c="var(--mantine-color-text)" /> }} />
|
||||
),
|
||||
icon: Users,
|
||||
breadcrumbs: [
|
||||
{ label: t('nav:system'), type: 'text' },
|
||||
{ label: t('nav:system-users'), type: 'text' },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<EnterpriseDataTable columnDefs={columnDefs} filterConfig={filterConfig} />
|
||||
</EnterpriseIndexPageProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { create } from 'zustand';
|
||||
import { EnterpriseModuleState } from '@repo/ui/foundations';
|
||||
import { UserEntity } from '../../domain/entities';
|
||||
|
||||
export interface UsersStoreState extends EnterpriseModuleState<UserEntity> {}
|
||||
|
||||
export const usersStore = create<UsersStoreState>((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 }),
|
||||
}));
|
||||
@@ -4,6 +4,7 @@ export const API_URL = {
|
||||
AUTH_REFRESH: '/auth/refresh',
|
||||
AUTH_REVOKE: '/auth/revoke',
|
||||
AUTH_ME: '/auth/me',
|
||||
USERS: '/users',
|
||||
DIVISIONS: '/divisions',
|
||||
BRANCHES: '/branches',
|
||||
CUSTOMERS: '/customers',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const MODULE_KEY = {
|
||||
PRIVILEGES: 'PRIVILEGES',
|
||||
USERS: 'USERS',
|
||||
CONFIGURATION_DIVISION: 'CONFIGURATION.DIVISION',
|
||||
CONFIGURATION_BRANCH: 'CONFIGURATION.BRANCH',
|
||||
CONFIGURATION_CUSTOMER: 'CONFIGURATION.CUSTOMER',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const WEB_URL = {
|
||||
USERS: '/app/system/users',
|
||||
DIVISIONS: '/app/configuration/divisions',
|
||||
BRANCHES: '/app/configuration/branches',
|
||||
CUSTOMERS: '/app/configuration/customers',
|
||||
|
||||
Reference in New Issue
Block a user