From b22ce998400f27ba89821891972984a2478f9822 Mon Sep 17 00:00:00 2001 From: shancheas Date: Thu, 27 Aug 2026 10:35:50 +0700 Subject: [PATCH] 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. --- apps/web/src/apps/main/index.tsx | 2 + .../src/apps/main/layouts/data/menu.data.ts | 397 +++++++++--------- .../apps/main/layouts/languages/en/nav.json | 1 + .../apps/main/layouts/languages/id/nav.json | 1 + .../users/data/user.remote.service.test.ts | 65 +++ .../system/users/data/user.remote.service.ts | 13 + .../system/users/domain/constants/index.ts | 1 + .../users/domain/constants/user.constants.ts | 11 + .../system/users/domain/entities/index.ts | 1 + .../users/domain/entities/user.entity.ts | 31 ++ .../system/users/domain/factories/index.ts | 12 + .../user.remote.transformer.test.ts | 123 ++++++ .../transformers/user.remote.transformer.ts | 71 ++++ .../domain/validators/user.validator.test.ts | 50 +++ .../users/domain/validators/user.validator.ts | 29 ++ .../detail-component/detail-general.tsx | 41 ++ .../form-component/form-general.tsx | 65 +++ .../index-component/filter-content.tsx | 39 ++ .../users/presentation/factory/index.tsx | 40 ++ .../presentation/languages/en/users.json | 19 + .../presentation/languages/id/users.json | 19 + .../presentation/load-privilege-options.ts | 12 + .../presentation/pages/user.page.detail.tsx | 23 + .../presentation/pages/user.page.form.tsx | 48 +++ .../presentation/pages/user.page.index.tsx | 67 +++ .../system/users/presentation/store/index.ts | 22 + apps/web/src/core/constants/api-url.ts | 1 + apps/web/src/core/constants/module-key.ts | 1 + apps/web/src/core/constants/web-url.ts | 1 + .../core-i18n/src/languages/en/common.json | 4 + .../core-i18n/src/languages/id/common.json | 4 + 31 files changed, 1019 insertions(+), 195 deletions(-) create mode 100644 apps/web/src/apps/main/modules/system/users/data/user.remote.service.test.ts create mode 100644 apps/web/src/apps/main/modules/system/users/data/user.remote.service.ts create mode 100644 apps/web/src/apps/main/modules/system/users/domain/constants/index.ts create mode 100644 apps/web/src/apps/main/modules/system/users/domain/constants/user.constants.ts create mode 100644 apps/web/src/apps/main/modules/system/users/domain/entities/index.ts create mode 100644 apps/web/src/apps/main/modules/system/users/domain/entities/user.entity.ts create mode 100644 apps/web/src/apps/main/modules/system/users/domain/factories/index.ts create mode 100644 apps/web/src/apps/main/modules/system/users/domain/transformers/user.remote.transformer.test.ts create mode 100644 apps/web/src/apps/main/modules/system/users/domain/transformers/user.remote.transformer.ts create mode 100644 apps/web/src/apps/main/modules/system/users/domain/validators/user.validator.test.ts create mode 100644 apps/web/src/apps/main/modules/system/users/domain/validators/user.validator.ts create mode 100644 apps/web/src/apps/main/modules/system/users/presentation/components/detail-component/detail-general.tsx create mode 100644 apps/web/src/apps/main/modules/system/users/presentation/components/form-component/form-general.tsx create mode 100644 apps/web/src/apps/main/modules/system/users/presentation/components/index-component/filter-content.tsx create mode 100644 apps/web/src/apps/main/modules/system/users/presentation/factory/index.tsx create mode 100644 apps/web/src/apps/main/modules/system/users/presentation/languages/en/users.json create mode 100644 apps/web/src/apps/main/modules/system/users/presentation/languages/id/users.json create mode 100644 apps/web/src/apps/main/modules/system/users/presentation/load-privilege-options.ts create mode 100644 apps/web/src/apps/main/modules/system/users/presentation/pages/user.page.detail.tsx create mode 100644 apps/web/src/apps/main/modules/system/users/presentation/pages/user.page.form.tsx create mode 100644 apps/web/src/apps/main/modules/system/users/presentation/pages/user.page.index.tsx create mode 100644 apps/web/src/apps/main/modules/system/users/presentation/store/index.ts diff --git a/apps/web/src/apps/main/index.tsx b/apps/web/src/apps/main/index.tsx index 355df58..5bb896b 100644 --- a/apps/web/src/apps/main/index.tsx +++ b/apps/web/src/apps/main/index.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/web/src/apps/main/layouts/data/menu.data.ts b/apps/web/src/apps/main/layouts/data/menu.data.ts index 6162863..61b1e57 100644 --- a/apps/web/src/apps/main/layouts/data/menu.data.ts +++ b/apps/web/src/apps/main/layouts/data/menu.data.ts @@ -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', + // // }, + // ], + // }, ]; diff --git a/apps/web/src/apps/main/layouts/languages/en/nav.json b/apps/web/src/apps/main/layouts/languages/en/nav.json index 8d5e7ca..0a0047d 100644 --- a/apps/web/src/apps/main/layouts/languages/en/nav.json +++ b/apps/web/src/apps/main/layouts/languages/en/nav.json @@ -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", diff --git a/apps/web/src/apps/main/layouts/languages/id/nav.json b/apps/web/src/apps/main/layouts/languages/id/nav.json index 04247d9..af189d4 100644 --- a/apps/web/src/apps/main/layouts/languages/id/nav.json +++ b/apps/web/src/apps/main/layouts/languages/id/nav.json @@ -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", diff --git a/apps/web/src/apps/main/modules/system/users/data/user.remote.service.test.ts b/apps/web/src/apps/main/modules/system/users/data/user.remote.service.test.ts new file mode 100644 index 0000000..a95e9e5 --- /dev/null +++ b/apps/web/src/apps/main/modules/system/users/data/user.remote.service.test.ts @@ -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' }, + }), + ); + }); +}); diff --git a/apps/web/src/apps/main/modules/system/users/data/user.remote.service.ts b/apps/web/src/apps/main/modules/system/users/data/user.remote.service.ts new file mode 100644 index 0000000..ff62135 --- /dev/null +++ b/apps/web/src/apps/main/modules/system/users/data/user.remote.service.ts @@ -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 { + constructor(httpClient: AxiosInstance, config: DataServicesConfig) { + super(httpClient, { + ...config, + apiUrl: config.apiUrl ?? '/users', + }); + } +} diff --git a/apps/web/src/apps/main/modules/system/users/domain/constants/index.ts b/apps/web/src/apps/main/modules/system/users/domain/constants/index.ts new file mode 100644 index 0000000..1ea0d40 --- /dev/null +++ b/apps/web/src/apps/main/modules/system/users/domain/constants/index.ts @@ -0,0 +1 @@ +export * from './user.constants'; diff --git a/apps/web/src/apps/main/modules/system/users/domain/constants/user.constants.ts b/apps/web/src/apps/main/modules/system/users/domain/constants/user.constants.ts new file mode 100644 index 0000000..a187cb5 --- /dev/null +++ b/apps/web/src/apps/main/modules/system/users/domain/constants/user.constants.ts @@ -0,0 +1,11 @@ +import { ModuleConfigEntity } from '@repo/ui/foundations'; +import type { UserEntity } from '../entities'; + +export const usersModuleConfig: ModuleConfigEntity = { + moduleKey: 'USERS', + translationNamespace: 'USERS', + apiUrl: '/users', + webUrl: '/app/system/users', + moduleCategory: 'FULL_PAGE', + moduleType: 'MASTER_DATA', +} as const; diff --git a/apps/web/src/apps/main/modules/system/users/domain/entities/index.ts b/apps/web/src/apps/main/modules/system/users/domain/entities/index.ts new file mode 100644 index 0000000..e4aa507 --- /dev/null +++ b/apps/web/src/apps/main/modules/system/users/domain/entities/index.ts @@ -0,0 +1 @@ +export * from './user.entity'; diff --git a/apps/web/src/apps/main/modules/system/users/domain/entities/user.entity.ts b/apps/web/src/apps/main/modules/system/users/domain/entities/user.entity.ts new file mode 100644 index 0000000..cb898b3 --- /dev/null +++ b/apps/web/src/apps/main/modules/system/users/domain/entities/user.entity.ts @@ -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 }; +} diff --git a/apps/web/src/apps/main/modules/system/users/domain/factories/index.ts b/apps/web/src/apps/main/modules/system/users/domain/factories/index.ts new file mode 100644 index 0000000..8c2fa73 --- /dev/null +++ b/apps/web/src/apps/main/modules/system/users/domain/factories/index.ts @@ -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, +}); diff --git a/apps/web/src/apps/main/modules/system/users/domain/transformers/user.remote.transformer.test.ts b/apps/web/src/apps/main/modules/system/users/domain/transformers/user.remote.transformer.test.ts new file mode 100644 index 0000000..e8af1e1 --- /dev/null +++ b/apps/web/src/apps/main/modules/system/users/domain/transformers/user.remote.transformer.test.ts @@ -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', + }); + }); +}); diff --git a/apps/web/src/apps/main/modules/system/users/domain/transformers/user.remote.transformer.ts b/apps/web/src/apps/main/modules/system/users/domain/transformers/user.remote.transformer.ts new file mode 100644 index 0000000..44c137f --- /dev/null +++ b/apps/web/src/apps/main/modules/system/users/domain/transformers/user.remote.transformer.ts @@ -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): 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 { + 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): Partial { + return omitEmptyFields({ + username: entity.username, + password: entity.password, + privilegeId: resolvePrivilegeId(entity), + }); + } + + transformEditPayload(entity: Partial): Partial { + const payload: Record = { + username: entity.username, + privilegeId: emptyToNull(resolvePrivilegeId(entity)) as string | null, + }; + if (entity.password) { + return { ...payload, password: entity.password }; + } + return payload; + } + + transformPayloadFilter(filter: Record): Record { + const next = { ...filter }; + if (next.privilege && typeof next.privilege === 'object') { + next.privilegeId = next.privilege.id; + delete next.privilege; + } + return omitEmptyFields(next); + } +} diff --git a/apps/web/src/apps/main/modules/system/users/domain/validators/user.validator.test.ts b/apps/web/src/apps/main/modules/system/users/domain/validators/user.validator.test.ts new file mode 100644 index 0000000..d3c5584 --- /dev/null +++ b/apps/web/src/apps/main/modules/system/users/domain/validators/user.validator.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/apps/main/modules/system/users/domain/validators/user.validator.ts b/apps/web/src/apps/main/modules/system/users/domain/validators/user.validator.ts new file mode 100644 index 0000000..463a425 --- /dev/null +++ b/apps/web/src/apps/main/modules/system/users/domain/validators/user.validator.ts @@ -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(), + }); +}; diff --git a/apps/web/src/apps/main/modules/system/users/presentation/components/detail-component/detail-general.tsx b/apps/web/src/apps/main/modules/system/users/presentation/components/detail-component/detail-general.tsx new file mode 100644 index 0000000..ca1e216 --- /dev/null +++ b/apps/web/src/apps/main/modules/system/users/presentation/components/detail-component/detail-general.tsx @@ -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(); + const { t } = useEnterpriseModuleTranslationContext(); + const data = detailData; + + return ( + + + {t('section_general')} + + + + + + + + } + /> + } + /> + } + /> + + + + ); +} diff --git a/apps/web/src/apps/main/modules/system/users/presentation/components/form-component/form-general.tsx b/apps/web/src/apps/main/modules/system/users/presentation/components/form-component/form-general.tsx new file mode 100644 index 0000000..d9e87b7 --- /dev/null +++ b/apps/web/src/apps/main/modules/system/users/presentation/components/form-component/form-general.tsx @@ -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 ( + + + {t('section_general')} + + + + + + + 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}`} + /> + + + + ); +} diff --git a/apps/web/src/apps/main/modules/system/users/presentation/components/index-component/filter-content.tsx b/apps/web/src/apps/main/modules/system/users/presentation/components/index-component/filter-content.tsx new file mode 100644 index 0000000..67229ab --- /dev/null +++ b/apps/web/src/apps/main/modules/system/users/presentation/components/index-component/filter-content.tsx @@ -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; t: (key: string) => string }) => { + return ( + + + + 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)} + /> + + + ); +}; diff --git a/apps/web/src/apps/main/modules/system/users/presentation/factory/index.tsx b/apps/web/src/apps/main/modules/system/users/presentation/factory/index.tsx new file mode 100644 index 0000000..7e2013c --- /dev/null +++ b/apps/web/src/apps/main/modules/system/users/presentation/factory/index.tsx @@ -0,0 +1,40 @@ +import { lazy } from 'react'; +import { Navigate, Route, Routes } from 'react-router-dom'; +import { EnterpriseModuleProvider } from '@repo/ui/foundations'; +import { registerModuleNamespace } from '@repo/core-i18n'; +import { 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 ( + + config={usersModuleConfig} + dataServices={usersDataService} + store={usersStore} + > + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/apps/web/src/apps/main/modules/system/users/presentation/languages/en/users.json b/apps/web/src/apps/main/modules/system/users/presentation/languages/en/users.json new file mode 100644 index 0000000..8c50468 --- /dev/null +++ b/apps/web/src/apps/main/modules/system/users/presentation/languages/en/users.json @@ -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, 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" +} diff --git a/apps/web/src/apps/main/modules/system/users/presentation/languages/id/users.json b/apps/web/src/apps/main/modules/system/users/presentation/languages/id/users.json new file mode 100644 index 0000000..e62c898 --- /dev/null +++ b/apps/web/src/apps/main/modules/system/users/presentation/languages/id/users.json @@ -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 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" +} diff --git a/apps/web/src/apps/main/modules/system/users/presentation/load-privilege-options.ts b/apps/web/src/apps/main/modules/system/users/presentation/load-privilege-options.ts new file mode 100644 index 0000000..c53c4c4 --- /dev/null +++ b/apps/web/src/apps/main/modules/system/users/presentation/load-privilege-options.ts @@ -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 = 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 }; +}; diff --git a/apps/web/src/apps/main/modules/system/users/presentation/pages/user.page.detail.tsx b/apps/web/src/apps/main/modules/system/users/presentation/pages/user.page.detail.tsx new file mode 100644 index 0000000..75d64ee --- /dev/null +++ b/apps/web/src/apps/main/modules/system/users/presentation/pages/user.page.detail.tsx @@ -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 ( + + + + ); +} diff --git a/apps/web/src/apps/main/modules/system/users/presentation/pages/user.page.form.tsx b/apps/web/src/apps/main/modules/system/users/presentation/pages/user.page.form.tsx new file mode 100644 index 0000000..02199b6 --- /dev/null +++ b/apps/web/src/apps/main/modules/system/users/presentation/pages/user.page.form.tsx @@ -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 ( + + + + ); +} diff --git a/apps/web/src/apps/main/modules/system/users/presentation/pages/user.page.index.tsx b/apps/web/src/apps/main/modules/system/users/presentation/pages/user.page.index.tsx new file mode 100644 index 0000000..d17ba1a --- /dev/null +++ b/apps/web/src/apps/main/modules/system/users/presentation/pages/user.page.index.tsx @@ -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[] = 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 ; + }, + }; + }, [t]); + + return ( + }} /> + ), + icon: Users, + breadcrumbs: [ + { label: t('nav:system'), type: 'text' }, + { label: t('nav:system-users'), type: 'text' }, + ], + }} + > + + + ); +} diff --git a/apps/web/src/apps/main/modules/system/users/presentation/store/index.ts b/apps/web/src/apps/main/modules/system/users/presentation/store/index.ts new file mode 100644 index 0000000..e7972d0 --- /dev/null +++ b/apps/web/src/apps/main/modules/system/users/presentation/store/index.ts @@ -0,0 +1,22 @@ +import { create } from 'zustand'; +import { EnterpriseModuleState } from '@repo/ui/foundations'; +import { UserEntity } from '../../domain/entities'; + +export interface UsersStoreState extends EnterpriseModuleState {} + +export const usersStore = create((set) => ({ + metaData: { limit: 15 }, + setMetaData: (data) => set({ metaData: data }), + + filterData: {}, + setFilterData: (data) => set({ filterData: data }), + + selectedRows: [], + setSelectedRows: (rows) => set({ selectedRows: rows }), + + privileges: [], + setPrivileges: (privileges) => set({ privileges }), + + tableConfig: null, + setTableConfig: (config) => set({ tableConfig: config }), +})); diff --git a/apps/web/src/core/constants/api-url.ts b/apps/web/src/core/constants/api-url.ts index 06f3f23..b1f5307 100644 --- a/apps/web/src/core/constants/api-url.ts +++ b/apps/web/src/core/constants/api-url.ts @@ -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', diff --git a/apps/web/src/core/constants/module-key.ts b/apps/web/src/core/constants/module-key.ts index f091968..1cb9bdc 100644 --- a/apps/web/src/core/constants/module-key.ts +++ b/apps/web/src/core/constants/module-key.ts @@ -1,5 +1,6 @@ export const MODULE_KEY = { PRIVILEGES: 'PRIVILEGES', + USERS: 'USERS', CONFIGURATION_DIVISION: 'CONFIGURATION.DIVISION', CONFIGURATION_BRANCH: 'CONFIGURATION.BRANCH', CONFIGURATION_CUSTOMER: 'CONFIGURATION.CUSTOMER', diff --git a/apps/web/src/core/constants/web-url.ts b/apps/web/src/core/constants/web-url.ts index 099e6ae..0c0ac68 100644 --- a/apps/web/src/core/constants/web-url.ts +++ b/apps/web/src/core/constants/web-url.ts @@ -1,4 +1,5 @@ export const WEB_URL = { + USERS: '/app/system/users', DIVISIONS: '/app/configuration/divisions', BRANCHES: '/app/configuration/branches', CUSTOMERS: '/app/configuration/customers', diff --git a/packages/core-i18n/src/languages/en/common.json b/packages/core-i18n/src/languages/en/common.json index 815fd20..f8fa573 100644 --- a/packages/core-i18n/src/languages/en/common.json +++ b/packages/core-i18n/src/languages/en/common.json @@ -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", diff --git a/packages/core-i18n/src/languages/id/common.json b/packages/core-i18n/src/languages/id/common.json index 88fc443..172c555 100644 --- a/packages/core-i18n/src/languages/id/common.json +++ b/packages/core-i18n/src/languages/id/common.json @@ -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",