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:
shancheas
2026-08-27 10:35:50 +07:00
parent 4d9c83ee83
commit b22ce99840
31 changed files with 1019 additions and 195 deletions
+2
View File
@@ -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 />} />
+202 -195
View File
@@ -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,
});
@@ -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',
});
});
});
@@ -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(),
});
};
@@ -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>
);
}
@@ -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>
);
}
@@ -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 }),
}));
+1
View File
@@ -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
View File
@@ -1,4 +1,5 @@
export const WEB_URL = {
USERS: '/app/system/users',
DIVISIONS: '/app/configuration/divisions',
BRANCHES: '/app/configuration/branches',
CUSTOMERS: '/app/configuration/customers',
@@ -198,6 +198,10 @@
"jobTitle": "Job Title",
"mobilePhone": "Mobile Phone",
"user": "User",
"username": "Username",
"password": "Password",
"privilege": "Privilege",
"isSuperadmin": "Superadmin",
"role": "Role",
"notes": "Notes",
"isActive": "Active",
@@ -198,6 +198,10 @@
"jobTitle": "Jabatan",
"mobilePhone": "No. HP",
"user": "Pengguna",
"username": "Nama pengguna",
"password": "Kata sandi",
"privilege": "Hak akses",
"isSuperadmin": "Superadmin",
"role": "Peran",
"notes": "Catatan",
"isActive": "Aktif",