refactor: improve code formatting and organization across multiple components

- Enhanced readability by restructuring code formatting in various components, including API documentation, user management, and sales modules.
- Standardized table and object definitions for better clarity in API documentation.
- Improved layout and indentation in React components for better maintainability.
- Updated unit tests to reflect changes in formatting and ensure consistency.

These changes enhance the overall code quality and maintainability of the project, making it easier for developers to navigate and understand the codebase.
This commit is contained in:
shancheas
2026-08-27 13:09:57 +07:00
parent 105cf3030a
commit 6b012a6aae
44 changed files with 371 additions and 260 deletions
+6 -2
View File
@@ -45,7 +45,11 @@ Do **not** redeclare:
// GOOD
const columnDefs = [
{ field: 'username', headerName: t('common:fields.username'), minWidth: 160 },
{ field: 'privilege', headerName: t('common:fields.privilege'), valueGetter: ({ data }) => relationLabel(data?.privilege) },
{
field: 'privilege',
headerName: t('common:fields.privilege'),
valueGetter: ({ data }) => relationLabel(data?.privilege),
},
];
// BAD — duplicates shared chrome
@@ -60,7 +64,7 @@ const columnDefs = [
API and transformers map:
| Entity field | Meaning |
|---|---|
| ------------ | ------------------------------------- |
| `createdBy` | actor string, `{ username }`, or uuid |
| `createdAt` | unix ms |
| `updatedBy` | actor string, `{ username }`, or uuid |
+25 -38
View File
@@ -26,7 +26,7 @@ Base URL: `http://localhost:{PORT}` (default **3000**). There is **no** global p
### Tokens
| Token | Type | Default lifetime | Transport |
| ----- | ---- | ---------------- | --------- |
| ------- | --------------------- | -------------------------------------- | ------------------------- |
| Access | JWT HS256 | `15m` (`JWT_ACCESS_EXPIRES_IN`) | `Authorization: Bearer …` |
| Refresh | Opaque 64-char string | 7 days (`REFRESH_TOKEN_EXPIRES_IN_MS`) | JSON body `refreshToken` |
@@ -112,7 +112,7 @@ CSV import uses `multipart/form-data` with field name **`file`** (max 1 MiB).
Query (all optional):
| Param | Rules | Default |
| ----- | ----- | ------- |
| -------- | ----------- | ------- |
| `page` | integer ≥ 1 | `1` |
| `limit` | 1**200** | `10` |
| `offset` | integer ≥ 0 | — |
@@ -123,7 +123,9 @@ Public response:
```json
{
"data": [ /* items */ ],
"data": [
/* items */
],
"meta": {
"currentPage": 1,
"itemCount": 10,
@@ -141,7 +143,7 @@ Public response:
Unwrapped resource object, or:
| Operation | Status | Body |
| --------- | ------ | ---- |
| --------------- | ------ | ------------------------------------------ |
| Create | `201` | resource DTO (detail shape) |
| Update / status | `200` | resource DTO |
| Delete | `204` | empty |
@@ -163,7 +165,7 @@ Unwrapped resource object, or:
`message` is a string or an array of validation strings.
| Status | Typical cause |
| ------ | ------------- |
| ------ | --------------------------------------------------------------------------- |
| `400` | Validation, extra fields, invalid VO (phone/date/status), `status` on PATCH |
| `401` | Missing/expired/revoked JWT, bad credentials, invalid refresh |
| `403` | `Insufficient privilege` |
@@ -190,7 +192,7 @@ Core (configuration, privileges, cycles, plans, settings): `draft` | `active` |
Sales statuses (use **only** these on that resource):
| Resource | Allowed |
| -------- | ------- |
| -------------------------- | --------------------------------------------------------- |
| Sales request | `draft`, `pending`, `approved`, `rejected` |
| Sales order / packing slip | `draft`, `processed`, `completed`, `cancelled` |
| Sales invoice | `draft`, `processed`, `partial`, `completed`, `cancelled` |
@@ -231,7 +233,7 @@ Actions: `view` | `create` | `update` | `delete` | `import`.
HTTP mapping:
| Handler | Action |
| ------- | ------ |
| --------------------------------------------------------------------------------------------------- | -------- |
| `GET` list / detail | `view` |
| `POST /` create, `POST /plans/generate` | `create` |
| `PATCH /:id`, `PATCH /:id/status`, `POST /bulk-status`, nested customer contacts, plan destinations | `update` |
@@ -241,7 +243,7 @@ HTTP mapping:
Catalog (`GET /privilege-keys`, needs `PRIVILEGES` `view`):
| code | label |
| ---- | ----- |
| ------------------------ | ---------------- |
| `PRIVILEGES` | Privileges |
| `USERS` | Users |
| `CONFIGURATION.DIVISION` | Divisions |
@@ -265,7 +267,7 @@ Catalog (`GET /privilege-keys`, needs `PRIVILEGES` `view`):
Cycles and plans do **not** use a single key. Privilege is resolved from `purpose`:
| purpose | cycle key | plan key |
| ------- | --------- | -------- |
| ----------- | ----------------- | ---------------- |
| `sales` | `SALES.CYCLE` | `SALES.PLAN` |
| `logistics` | `LOGISTICS.CYCLE` | `LOGISTICS.PLAN` |
@@ -280,7 +282,7 @@ Sales plans may attach `invoiceIds` only. Logistics plans may attach `packingSli
Unless a section says otherwise, each resource below implements:
| Method | Path | Status | Notes |
| ------ | ---- | ------ | ----- |
| -------- | ------------------------- | ------ | --------------------------------------------- |
| `GET` | `/{resource}` | `200` | Paginated `{ data, meta }` |
| `GET` | `/{resource}/:id` | `200` | Detail (may include nested arrays list omits) |
| `POST` | `/{resource}` | `201` | Create |
@@ -352,9 +354,7 @@ List filters: `name`, `code`, `status`, `search` (name/code).
"name": "Sales Staff",
"code": "SALES_STAFF",
"status": "draft",
"details": [
{ "privilegeKeyId": "uuid", "action": "view", "value": true }
]
"details": [{ "privilegeKeyId": "uuid", "action": "view", "value": true }]
}
```
@@ -468,7 +468,7 @@ Contact create: `name` required; `jobTitle?`, `phone?`, `mobilePhone?`, `notes?`
### Nested contacts (privilege = customer **update**)
| Method | Path | Status | Body | Response |
| ------ | ---- | ------ | ---- | -------- |
| -------- | ------------------------------------ | ------ | --------------- | ------------------ |
| `POST` | `/customers/:id/contacts` | `200` | create contact | full `CustomerDto` |
| `PATCH` | `/customers/:id/contacts/:contactId` | `200` | partial contact | full `CustomerDto` |
| `DELETE` | `/customers/:id/contacts/:contactId` | `204` | — | empty |
@@ -574,12 +574,8 @@ Detail adds:
```json
{
"products": [
{ "id": "uuid", "productId": "uuid", "quantity": "2.0000", "price": "12500.0000" }
],
"images": [
{ "id": "uuid", "url": "https://…", "description": null }
]
"products": [{ "id": "uuid", "productId": "uuid", "quantity": "2.0000", "price": "12500.0000" }],
"images": [{ "id": "uuid", "url": "https://…", "description": null }]
}
```
@@ -660,7 +656,7 @@ CSV required: `date` (allocations empty on import).
Key: `CONFIGURATION.SETTING`. Singleton — no list/CRUD.
| Method | Path | Action | Notes |
| ------ | ---- | ------ | ----- |
| ------- | ----------- | ------ | ----------------------------------------------------------------- |
| `GET` | `/settings` | view | `404` `{ "message": "Settings not configured" }` if never patched |
| `PATCH` | `/settings` | update | upserts |
@@ -730,11 +726,12 @@ No hard delete: `DELETE` / `bulk-delete` archive. **Has CSV import.** Standard l
"endBranchId": "uuid",
"routeGeometry": {
"type": "LineString",
"coordinates": [[106.8456, -6.2088], [107.0, -6.3]]
},
"destinations": [
{ "id": "uuid", "customerId": "uuid", "sortOrder": 0 }
"coordinates": [
[106.8456, -6.2088],
[107.0, -6.3]
]
},
"destinations": [{ "id": "uuid", "customerId": "uuid", "sortOrder": 0 }]
}
],
"status": "draft",
@@ -760,7 +757,7 @@ Privilege: `RequireFieldPrivilege('plan', action)` → `SALES.PLAN` or `LOGISTIC
**No CSV import.** Delete archives. Extra routes: generate, add/remove destinations.
| Method | Path | Action | Status | Body / response |
| ------ | ---- | ------ | ------ | --------------- |
| -------- | ---------------------------------------- | ------ | ------- | ------------------------------------------------------------ |
| `GET` | `/plans` | view | 200 | paginated |
| `GET` | `/plans/:id` | view | 200 | `PlanDto` |
| `POST` | `/plans/generate` | create | 200 | `{ employeeId, purpose, from, to }``{ created, skipped }` |
@@ -898,14 +895,7 @@ type UnixMs = number;
type DecimalString = string;
type CoreStatus = 'draft' | 'active' | 'archived';
type FieldPurpose = 'sales' | 'logistics';
type Weekday =
| 'monday'
| 'tuesday'
| 'wednesday'
| 'thursday'
| 'friday'
| 'saturday'
| 'sunday';
type Weekday = 'monday' | 'tuesday' | 'wednesday' | 'thursday' | 'friday' | 'saturday' | 'sunday';
type PrivilegeAction = 'view' | 'create' | 'update' | 'delete' | 'import';
type PaginationMeta = {
@@ -932,10 +922,7 @@ type Me = {
username: string;
isSuperadmin: boolean;
privilege: { id: Uuid; name: string; code: string } | null;
permissions: Record<
string,
Record<PrivilegeAction, boolean>
>;
permissions: Record<string, Record<PrivilegeAction, boolean>>;
};
type RouteGeometry = {
@@ -29,9 +29,7 @@ describe('CustomersRemoteDataTransformer', () => {
it('includes named contacts on create and omits status', () => {
const payload = transformer.transformCreatePayload(dto);
expect(payload.contacts).toEqual([
{ name: 'Andi Pratama', jobTitle: 'Manager', phone: '+6281111111111' },
]);
expect(payload.contacts).toEqual([{ name: 'Andi Pratama', jobTitle: 'Manager', phone: '+6281111111111' }]);
expect(payload).not.toHaveProperty('status');
expect(payload).not.toHaveProperty('id');
});
@@ -13,7 +13,11 @@ import {
Text,
notifications,
} from '@repo/ui/components';
import { useDetailPageContext, useEnterpriseModuleConfigContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import {
useDetailPageContext,
useEnterpriseModuleConfigContext,
useEnterpriseModuleTranslationContext,
} from '@repo/ui/foundations';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Pencil, Plus, Trash2 } from 'lucide-react';
@@ -131,7 +135,12 @@ export function DetailContacts() {
<ActionIcon variant="subtle" onClick={() => openEdit(contact)} aria-label={t('edit_contact')}>
<Pencil size={16} />
</ActionIcon>
<ActionIcon variant="subtle" color="red" onClick={() => handleDelete(contact)} aria-label={t('delete_contact')}>
<ActionIcon
variant="subtle"
color="red"
onClick={() => handleDelete(contact)}
aria-label={t('delete_contact')}
>
<Trash2 size={16} />
</ActionIcon>
</Group>
@@ -1,4 +1,15 @@
import { ActionIcon, Box, Button, FieldTextInput, FieldTextarea, Group, Paper, SimpleGrid, Stack, Text } from '@repo/ui/components';
import {
ActionIcon,
Box,
Button,
FieldTextInput,
FieldTextarea,
Group,
Paper,
SimpleGrid,
Stack,
Text,
} from '@repo/ui/components';
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
import { useFieldArray } from '@repo/ui/form';
import { Plus, Trash2 } from 'lucide-react';
@@ -28,14 +28,8 @@ export const createProductSchema = (t: (key: string) => string) => {
return z.object({
code: configCodeSchema(t, PRODUCT_CODE_MAX),
name: productNameSchema(t),
unit: z.preprocess(
emptyToUndefined,
compose(z.string(), maxLength(16, t('common:fields.unit'))).optional(),
),
unit: z.preprocess(emptyToUndefined, compose(z.string(), maxLength(16, t('common:fields.unit'))).optional()),
price: optionalDecimalStringSchema(t, 'common:fields.price'),
brand: z.preprocess(
emptyToUndefined,
compose(z.string(), maxLength(64, t('common:fields.brand'))).optional(),
),
brand: z.preprocess(emptyToUndefined, compose(z.string(), maxLength(64, t('common:fields.brand'))).optional()),
});
};
@@ -40,9 +40,7 @@ describe('CyclesRemoteDataServices', () => {
it('uses PATCH when editing a cycle', async () => {
await service.edit('cyc-1', { cycleNumber: 2 } as any);
expect(httpClient.request).toHaveBeenCalledWith(
expect.objectContaining({ url: '/cycles/cyc-1', method: 'PATCH' }),
);
expect(httpClient.request).toHaveBeenCalledWith(expect.objectContaining({ url: '/cycles/cyc-1', method: 'PATCH' }));
});
it('bulk-deletes via POST /cycles/bulk-delete', async () => {
@@ -51,7 +51,9 @@ export interface CycleDto {
employeeId: string;
purpose: FieldPurpose;
cycleNumber: number;
weekdays?: CycleWeekdayEntity[] | Record<string, { startBranchId: string; endBranchId: string; customerIds: string[] }>;
weekdays?:
| CycleWeekdayEntity[]
| Record<string, { startBranchId: string; endBranchId: string; customerIds: string[] }>;
status?: ConfigurationStatus;
createdAt?: number;
updatedAt?: number;
@@ -2,12 +2,7 @@ import { BaseDataTransformer } from '@repo/core-api/data-services';
import { omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators';
import { WEEKDAYS } from '../../../../../../../core/domain/configuration-field-validators';
import type { FieldPurpose } from '../../../../../../../core/domain/field-purpose';
import type {
CycleEntity,
CycleWeekdayEntity,
CycleWeekdayRow,
CycleWeekdayWrite,
} from '../entities';
import type { CycleEntity, CycleWeekdayEntity, CycleWeekdayRow, CycleWeekdayWrite } from '../entities';
function relationId(value: unknown): string | undefined {
if (value && typeof value === 'object' && 'id' in value) {
@@ -44,7 +44,18 @@ export default function CyclePageForm({ formPageType }: { formPageType: FormPage
formControl={formControl}
formPageType={formPageType}
ignoreKeyDuplicate={['id']}
ignoreKeyUpdate={['status', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy', 'id', 'purpose', 'employeeId', 'weekdays', 'routeGeometry']}
ignoreKeyUpdate={[
'status',
'createdAt',
'updatedAt',
'createdBy',
'updatedBy',
'id',
'purpose',
'employeeId',
'weekdays',
'routeGeometry',
]}
highlightDataKey="cycleNumber"
pageHeaderProps={{
title: title?.title,
@@ -29,7 +29,8 @@ export default function CyclePageIndex() {
field: 'weekdayRows',
headerName: t('section_weekdays'),
minWidth: 140,
valueGetter: ({ data }) => data?.weekdayRows?.filter((row) => row.enabled).length ?? data?.weekdays?.length ?? 0,
valueGetter: ({ data }) =>
data?.weekdayRows?.filter((row) => row.enabled).length ?? data?.weekdays?.length ?? 0,
},
];
}, [t]);
@@ -65,7 +65,9 @@ export class PlansRemoteDataTransformer extends BaseDataTransformer<PlanEntity>
if (this.purpose === 'sales') {
payload.invoiceIds = relationIds(entity.invoices).length ? relationIds(entity.invoices) : entity.invoiceIds;
} else {
payload.packingSlipIds = relationIds(entity.packingSlips).length ? relationIds(entity.packingSlips) : entity.packingSlipIds;
payload.packingSlipIds = relationIds(entity.packingSlips).length
? relationIds(entity.packingSlips)
: entity.packingSlipIds;
}
return payload;
}
@@ -1,6 +1,24 @@
import { ActionIcon, Box, Button, FieldAsyncSelect, FieldValue, Group, Paper, RenderDate, SimpleGrid, Stack, StatusBadge, Text, notifications } from '@repo/ui/components';
import {
ActionIcon,
Box,
Button,
FieldAsyncSelect,
FieldValue,
Group,
Paper,
RenderDate,
SimpleGrid,
Stack,
StatusBadge,
Text,
notifications,
} from '@repo/ui/components';
import { RouteMap } from '@repo/ui/map';
import { useDetailPageContext, useEnterpriseModuleTranslationContext, useEnterpriseModuleDataServiceContext } from '@repo/ui/foundations';
import {
useDetailPageContext,
useEnterpriseModuleTranslationContext,
useEnterpriseModuleDataServiceContext,
} from '@repo/ui/foundations';
import { useForm } from 'react-hook-form';
import { Plus, Trash2 } from 'lucide-react';
import { relationLabel } from '../../../../shared/relation-label';
@@ -39,10 +57,24 @@ export function DetailGeneral() {
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md" verticalSpacing="xl">
<FieldValue label={t('common:fields.employee')} value={relationLabel(data?.employee) || data?.employeeId} />
<FieldValue label={t('common:fields.date')} value={data?.date} render={(val) => <RenderDate value={val as any} />} />
<FieldValue label={t('common:fields.startBranch')} value={relationLabel(data?.startBranch) || data?.startBranchId} />
<FieldValue label={t('common:fields.endBranch')} value={relationLabel(data?.endBranch) || data?.endBranchId} />
<FieldValue label={t('common:fields.status')} value={data?.status} render={(val) => <StatusBadge status={String(val ?? '')} />} />
<FieldValue
label={t('common:fields.date')}
value={data?.date}
render={(val) => <RenderDate value={val as any} />}
/>
<FieldValue
label={t('common:fields.startBranch')}
value={relationLabel(data?.startBranch) || data?.startBranchId}
/>
<FieldValue
label={t('common:fields.endBranch')}
value={relationLabel(data?.endBranch) || data?.endBranchId}
/>
<FieldValue
label={t('common:fields.status')}
value={data?.status}
render={(val) => <StatusBadge status={String(val ?? '')} />}
/>
</SimpleGrid>
</Paper>
@@ -1,5 +1,9 @@
import { Box, FieldAsyncSelect, FieldDatePicker, Paper, SimpleGrid, Text } from '@repo/ui/components';
import { useEnterpriseModuleTranslationContext, useFormPageContext, useEnterpriseModuleConfigContext } from '@repo/ui/foundations';
import {
useEnterpriseModuleTranslationContext,
useFormPageContext,
useEnterpriseModuleConfigContext,
} from '@repo/ui/foundations';
import { purposeFromModuleKey } from '../../../../../../../../core/domain/field-purpose';
import { loadEmployeeOptions } from '../../../../shared/load-employee-options';
import { loadBranchOptions } from '../../../../shared/load-branch-options';
@@ -39,7 +39,20 @@ export default function PlanPageForm({ formPageType }: { formPageType: FormPageT
formControl={formControl}
formPageType={formPageType}
ignoreKeyDuplicate={['id']}
ignoreKeyUpdate={['status', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy', 'id', 'purpose', 'employeeId', 'destinations', 'routeGeometry', 'invoiceIds', 'packingSlipIds']}
ignoreKeyUpdate={[
'status',
'createdAt',
'updatedAt',
'createdBy',
'updatedBy',
'id',
'purpose',
'employeeId',
'destinations',
'routeGeometry',
'invoiceIds',
'packingSlipIds',
]}
highlightDataKey="date"
pageHeaderProps={{
title: title?.title,
@@ -1,6 +1,4 @@
export function relationLabel(
item: { code?: string | null; name?: string; id?: string | number } | null | undefined,
) {
export function relationLabel(item: { code?: string | null; name?: string; id?: string | number } | null | undefined) {
if (!item) return '';
if (item.code && item.name) return `${item.code} - ${item.name}`;
return item.name || item.code || (item.id == null ? '' : String(item.id));
@@ -24,7 +24,9 @@ export default function SalesOrderPageDetail() {
{ label: t('nav:sales-orders'), type: 'link', href: `${salesOrdersModuleConfig.webUrl}/index` },
],
}}
customPageActions={(data, pageActions) => actions.detailStatusActions(data as SalesOrderEntity, pageActions ?? [])}
customPageActions={(data, pageActions) =>
actions.detailStatusActions(data as SalesOrderEntity, pageActions ?? [])
}
>
<Stack gap="md">
<DetailGeneral salesRequestHref={(id) => `${salesRequestsModuleConfig.webUrl}/detail/${id}`} />
@@ -62,7 +62,16 @@ export default function SalesOrderPageForm({ formPageType }: { formPageType: For
formControl={formControl}
formPageType={formPageType}
ignoreKeyDuplicate={['code']}
ignoreKeyUpdate={['status', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy', 'id', 'salesRequestId', 'salesRequest']}
ignoreKeyUpdate={[
'status',
'createdAt',
'updatedAt',
'createdBy',
'updatedBy',
'id',
'salesRequestId',
'salesRequest',
]}
highlightDataKey="code"
pageHeaderProps={{
title: title?.title,
@@ -8,7 +8,9 @@ export function DetailGeneral({
}: {
salesRequestHref?: (id: string) => string;
} = {}) {
const { detailData } = useDetailPageContext<SalesDocumentEntity & { salesRequestId?: string | null; salesRequest?: { id?: string; code?: string } | null }>();
const { detailData } = useDetailPageContext<
SalesDocumentEntity & { salesRequestId?: string | null; salesRequest?: { id?: string; code?: string } | null }
>();
const { t } = useEnterpriseModuleTranslationContext();
const data = detailData;
const salesRequestId = data?.salesRequestId ?? data?.salesRequest?.id;
@@ -26,7 +28,10 @@ export function DetailGeneral({
value={data?.date}
render={(val) => <RenderDate value={val as any} />}
/>
<FieldValue label={t('common:fields.salesPerson')} value={relationLabel(data?.salesPerson) || data?.salesPersonId} />
<FieldValue
label={t('common:fields.salesPerson')}
value={relationLabel(data?.salesPerson) || data?.salesPersonId}
/>
<FieldValue label={t('common:fields.branch')} value={relationLabel(data?.branch) || data?.branchId} />
<FieldValue label={t('common:fields.division')} value={relationLabel(data?.division) || data?.divisionId} />
<FieldValue label={t('common:fields.customer')} value={relationLabel(data?.customer) || data?.customerId} />
@@ -36,9 +41,7 @@ export function DetailGeneral({
value={salesRequestId}
render={() =>
salesRequestId ? (
<Anchor href={salesRequestHref(salesRequestId)}>
{data?.salesRequest?.code || salesRequestId}
</Anchor>
<Anchor href={salesRequestHref(salesRequestId)}>{data?.salesRequest?.code || salesRequestId}</Anchor>
) : (
'-'
)
@@ -14,11 +14,7 @@ export function DetailLocation() {
{t('section_location')}
</Text>
<Stack gap="md">
<LocationMap
latitude={data?.latitude}
longitude={data?.longitude}
emptyLabel={t('common:map.noLocation')}
/>
<LocationMap latitude={data?.latitude} longitude={data?.longitude} emptyLabel={t('common:map.noLocation')} />
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md" verticalSpacing="xl">
<FieldValue label={t('common:fields.address')} value={data?.address} />
<FieldValue label={t('common:fields.latitude')} value={data?.latitude} />
@@ -32,7 +32,13 @@ export function FormGeneral() {
placeholder="e.g. SR-20260826-0001"
radius="md"
/>
<FieldDatePicker control={formControl.control} name="date" label={t('common:fields.date')} required radius="md" />
<FieldDatePicker
control={formControl.control}
name="date"
label={t('common:fields.date')}
required
radius="md"
/>
<FieldAsyncSelect<EmployeeEntity>
control={formControl.control}
name="salesPerson"
@@ -15,7 +15,12 @@ export function FormImages() {
<Paper withBorder shadow="sm" radius="md" p="xl">
<Group justify="space-between" mb="md">
<Text fw={600}>{t('section_images')}</Text>
<Button variant="light" size="xs" leftSection={<Plus size={14} />} onClick={() => append({ url: '', description: '' })}>
<Button
variant="light"
size="xs"
leftSection={<Plus size={14} />}
onClick={() => append({ url: '', description: '' })}
>
{t('add_image')}
</Button>
</Group>
@@ -1,4 +1,14 @@
import { ActionIcon, Box, Button, FieldAsyncSelect, FieldTextInput, Group, Paper, Table, Text } from '@repo/ui/components';
import {
ActionIcon,
Box,
Button,
FieldAsyncSelect,
FieldTextInput,
Group,
Paper,
Table,
Text,
} from '@repo/ui/components';
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
import { useFieldArray, useWatch } from '@repo/ui/form';
import { Plus, Trash2 } from 'lucide-react';
@@ -70,7 +80,12 @@ export function FormProducts() {
</Table.Td>
<Table.Td ta="right">{currency.format(lineTotal(line?.quantity, line?.price))}</Table.Td>
<Table.Td>
<ActionIcon variant="subtle" color="red" onClick={() => remove(index)} aria-label={t('remove_line')}>
<ActionIcon
variant="subtle"
color="red"
onClick={() => remove(index)}
aria-label={t('remove_line')}
>
<Trash2 size={16} />
</ActionIcon>
</Table.Td>
@@ -95,7 +95,9 @@ export function toSalesWritePayload(
};
if (options?.includeSalesRequestId) {
payload.salesRequestId = relationId((entity as { salesRequest?: unknown }).salesRequest) ?? (entity as { salesRequestId?: string }).salesRequestId;
payload.salesRequestId =
relationId((entity as { salesRequest?: unknown }).salesRequest) ??
(entity as { salesRequestId?: string }).salesRequestId;
}
return payload;
@@ -1,6 +1,10 @@
import { z } from 'zod';
import { compose, maxLength, required } from '@repo/ui/validators';
import { configAddressSchema, optionalLatitudeSchema, optionalLongitudeSchema } from '../../../../../core/domain/configuration-field-validators';
import {
configAddressSchema,
optionalLatitudeSchema,
optionalLongitudeSchema,
} from '../../../../../core/domain/configuration-field-validators';
import { decimalStringSchema } from '../../../../../core/domain/decimal-string.schema';
const NOTES_MAX = 1024;
@@ -82,7 +82,10 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
icon: <Icon size={16} />,
variant: 'light' as const,
onClick: () => {
void applyStatus(selectedRows.map((row) => String(row.id)), action.target);
void applyStatus(
selectedRows.map((row) => String(row.id)),
action.target,
);
},
};
});
@@ -95,9 +95,7 @@ describe('PrivilegesRemoteDataTransformer', () => {
expect(payload).not.toHaveProperty('status');
expect(payload).not.toHaveProperty('id');
expect(payload.details).toEqual(
expect.arrayContaining([{ privilegeKeyId: 'key-1', action: 'view', value: true }]),
);
expect(payload.details).toEqual(expect.arrayContaining([{ privilegeKeyId: 'key-1', action: 'view', value: true }]));
});
it('maps an empty details list to an empty matrix', () => {
@@ -56,11 +56,7 @@ export function FormPermissions() {
</Table.Td>
{PRIVILEGE_ACTIONS.map((action) => (
<Table.Td key={action} ta="center">
<FieldCheckbox
control={formControl.control}
name={`matrix.${key.id}.${action}`}
label=""
/>
<FieldCheckbox control={formControl.control} name={`matrix.${key.id}.${action}`} label="" />
</Table.Td>
))}
</Table.Tr>
@@ -40,9 +40,7 @@ describe('UsersRemoteDataServices', () => {
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' }),
);
expect(httpClient.request).toHaveBeenCalledWith(expect.objectContaining({ url: '/users/user-1', method: 'PATCH' }));
});
it('bulk-deletes via POST /users/bulk-delete', async () => {
@@ -13,11 +13,7 @@ export const createUserSchema = (t: (key: string) => string, options?: { require
: 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) =>
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 } }),
}),
@@ -1,5 +1,13 @@
import { useEffect } from 'react';
import { Box, FieldTextInput, FieldPasswordInput, FieldAsyncSelect, Paper, SimpleGrid, Text } from '@repo/ui/components';
import {
Box,
FieldTextInput,
FieldPasswordInput,
FieldAsyncSelect,
Paper,
SimpleGrid,
Text,
} from '@repo/ui/components';
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
import { loadPrivilegeOptions } from '../../load-privilege-options';
import { loadEmployeeOptions } from '../../load-employee-options';
@@ -34,15 +42,11 @@ export function FormGeneral({ requirePassword }: { requirePassword: boolean }) {
const employee = formControl.watch('employee');
useEffect(() => {
hydrateRelation(formControl, 'privilege', 'privilegeId', privilege, (id) =>
privilegesDataService.getOne(id),
);
hydrateRelation(formControl, 'privilege', 'privilegeId', privilege, (id) => privilegesDataService.getOne(id));
}, [privilege, formControl]);
useEffect(() => {
hydrateRelation(formControl, 'employee', 'employeeId', employee, (id) =>
employeesDataService.getOne(id),
);
hydrateRelation(formControl, 'employee', 'employeeId', employee, (id) => employeesDataService.getOne(id));
}, [employee, formControl]);
return (
@@ -21,11 +21,7 @@ registerModuleNamespace(usersModuleConfig.translationNamespace, {
export default function UsersModule() {
return (
<EnterpriseModuleProvider<UserEntity>
config={usersModuleConfig}
dataServices={usersDataService}
store={usersStore}
>
<EnterpriseModuleProvider<UserEntity> config={usersModuleConfig} dataServices={usersDataService} store={usersStore}>
<Routes>
<Route path="/index" element={<IndexPage />} />
<Route path="/detail/:dataId" element={<DetailPage />} />
@@ -33,13 +33,12 @@ function emptyToUndefinedNumber(value: unknown) {
}
export function configCodeSchema(t: (key: string) => string, max = CONFIG_CODE_MAX) {
return compose(
z.string(),
required(t('common:fields.code')),
maxLength(max, t('common:fields.code')),
).regex(CODE_PATTERN, {
return compose(z.string(), required(t('common:fields.code')), maxLength(max, t('common:fields.code'))).regex(
CODE_PATTERN,
{
message: JSON.stringify({ key: 'validation:invalid_format', values: { field: t('common:fields.code') } }),
});
},
);
}
export function configNameSchema(t: (key: string) => string) {
@@ -69,13 +68,20 @@ export function optionalPhoneSchema() {
}
export function configAddressSchema(t: (key: string) => string) {
return compose(z.string(), required(t('common:fields.address')), maxLength(CONFIG_ADDRESS_MAX, t('common:fields.address')));
return compose(
z.string(),
required(t('common:fields.address')),
maxLength(CONFIG_ADDRESS_MAX, t('common:fields.address')),
);
}
export function weekdaySchema(t: (key: string) => string) {
return z.enum(WEEKDAYS, {
required_error: JSON.stringify({ key: 'validation:required', values: { field: t('common:fields.weekday') } }),
invalid_type_error: JSON.stringify({ key: 'validation:invalid_format', values: { field: t('common:fields.weekday') } }),
invalid_type_error: JSON.stringify({
key: 'validation:invalid_format',
values: { field: t('common:fields.weekday') },
}),
});
}
@@ -86,14 +92,19 @@ export function workingHoursSchema(t: (key: string) => string) {
}
export function optionalNfcIdSchema(t: (key: string) => string) {
return z.preprocess(emptyToUndefined, compose(z.string(), maxLength(CONFIG_NFC_ID_MAX, t('common:fields.nfcId'))).optional());
return z.preprocess(
emptyToUndefined,
compose(z.string(), maxLength(CONFIG_NFC_ID_MAX, t('common:fields.nfcId'))).optional(),
);
}
export function optionalLatitudeSchema() {
return z.preprocess(
emptyToUndefinedNumber,
compose(
z.number({ invalid_type_error: JSON.stringify({ key: 'validation:invalid_format', values: { field: 'latitude' } }) }),
z.number({
invalid_type_error: JSON.stringify({ key: 'validation:invalid_format', values: { field: 'latitude' } }),
}),
minValue(-90, 'latitude'),
maxValue(90, 'latitude'),
).optional(),
@@ -104,7 +115,9 @@ export function optionalLongitudeSchema() {
return z.preprocess(
emptyToUndefinedNumber,
compose(
z.number({ invalid_type_error: JSON.stringify({ key: 'validation:invalid_format', values: { field: 'longitude' } }) }),
z.number({
invalid_type_error: JSON.stringify({ key: 'validation:invalid_format', values: { field: 'longitude' } }),
}),
minValue(-180, 'longitude'),
maxValue(180, 'longitude'),
).optional(),
@@ -10,9 +10,7 @@ export function filterMenuByViewPrivilege<T extends { moduleKey?: string; childr
}
return items.flatMap((item) => {
const children = item.children
? filterMenuByViewPrivilege(item.children, privileges, false)
: undefined;
const children = item.children ? filterMenuByViewPrivilege(item.children, privileges, false) : undefined;
if (item.moduleKey && privileges[item.moduleKey]?.ALLOW_VIEW !== true) {
return [];
@@ -17,7 +17,9 @@ const { setView, panTo, invalidateSize } = vi.hoisted(() => ({
vi.mock('react-leaflet', () => ({
MapContainer: ({ children }: { children: React.ReactNode }) => <div data-testid="osm-map">{children}</div>,
TileLayer: () => <div data-testid="osm-tiles" />,
CircleMarker: ({ center }: { center: [number, number] }) => <div data-testid="location-marker">{`${center[0]},${center[1]}`}</div>,
CircleMarker: ({ center }: { center: [number, number] }) => (
<div data-testid="location-marker">{`${center[0]},${center[1]}`}</div>
),
useMap: () => ({ setView, panTo, invalidateSize }),
useMapEvents: (handlers: { click?: (event: LeafletClick) => void }) => {
mapClick = handlers.click;
@@ -123,10 +123,19 @@ export function LocationMap({
className="tg-map-viewport"
style={{ cursor: interactive ? 'crosshair' : undefined }}
>
<MapContainer center={center} zoom={zoom} style={{ height: '100%', width: '100%' }} scrollWheelZoom={interactive}>
<MapContainer
center={center}
zoom={zoom}
style={{ height: '100%', width: '100%' }}
scrollWheelZoom={interactive}
>
<TileLayer attribution={attribution} url={tileUrl} />
{position ? (
<CircleMarker center={position} radius={10} pathOptions={{ color: 'var(--mantine-color-blue-8)', fillOpacity: 0.9 }} />
<CircleMarker
center={position}
radius={10}
pathOptions={{ color: 'var(--mantine-color-blue-8)', fillOpacity: 0.9 }}
/>
) : null}
<SyncMapView latitude={latitude} longitude={longitude} lastPickedRef={lastPickedRef} />
{onChange ? <MapClickPicker onPick={handlePick} /> : null}
@@ -43,7 +43,7 @@ export function clampLatitude(value: number): number {
// which the form schemas reject. Keep 180 and -180 as authored instead of collapsing them.
export function wrapLongitude(value: number): number {
if (value >= -180 && value <= 180) return value;
const wrapped = ((value + 180) % 360 + 360) % 360 - 180;
const wrapped = ((((value + 180) % 360) + 360) % 360) - 180;
return wrapped;
}
@@ -54,10 +54,7 @@ export function locationFromLatLng(lat: number, lng: number): { latitude: number
};
}
export function mapViewForLocation(
latitude: unknown,
longitude: unknown,
): { center: [number, number]; zoom: number } {
export function mapViewForLocation(latitude: unknown, longitude: unknown): { center: [number, number]; zoom: number } {
const point = toLocationLatLng(latitude, longitude);
if (!point) {
return { center: DEFAULT_MAP_CENTER, zoom: DEFAULT_MAP_ZOOM };
+2 -2
View File
@@ -1,3 +1,3 @@
export const OSM_TILE_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
export const OSM_ATTRIBUTION = '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
export const OSM_ATTRIBUTION =
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
@@ -7,7 +7,5 @@ export function toLeafletLatLngs(geometry?: RouteGeometry | null): Array<[number
if (!geometry?.coordinates?.length) {
return [];
}
return geometry.coordinates
.filter((pair) => Array.isArray(pair) && pair.length >= 2)
.map(([lng, lat]) => [lat, lng]);
return geometry.coordinates.filter((pair) => Array.isArray(pair) && pair.length >= 2).map(([lng, lat]) => [lat, lng]);
}
+6 -1
View File
@@ -44,7 +44,12 @@ export function RouteMap({ geometry, height = 280 }: RouteMapProps) {
<TileLayer attribution={OSM_ATTRIBUTION} url={OSM_TILE_URL} />
<Polyline positions={positions} pathOptions={{ color: 'var(--mantine-color-blue-6)', weight: 4 }} />
{positions.map((position, index) => (
<CircleMarker key={`${position[0]}-${position[1]}-${index}`} center={position} radius={8} pathOptions={{ color: 'var(--mantine-color-blue-8)' }}>
<CircleMarker
key={`${position[0]}-${position[1]}-${index}`}
center={position}
radius={8}
pathOptions={{ color: 'var(--mantine-color-blue-8)' }}
>
<Tooltip permanent>{index + 1}</Tooltip>
</CircleMarker>
))}
@@ -1,24 +1,17 @@
import { describe, expect, it } from 'vitest';
import { DateUtils } from '@repo/utils';
import {
EMPTY_AUDIT_DISPLAY,
formatAuditActor,
formatAuditTimestamp,
resolveAuditValue,
} from './audit-column.utils';
import { EMPTY_AUDIT_DISPLAY, formatAuditActor, formatAuditTimestamp, resolveAuditValue } from './audit-column.utils';
describe('resolveAuditValue', () => {
it('prefers camelCase over snake_case', () => {
expect(
resolveAuditValue({ createdBy: 'alice', creator_name: 'legacy' }, 'createdBy', 'creator_name'),
).toBe('alice');
expect(resolveAuditValue({ createdBy: 'alice', creator_name: 'legacy' }, 'createdBy', 'creator_name')).toBe(
'alice',
);
});
it('falls back to snake_case when camelCase is missing', () => {
expect(resolveAuditValue({ creator_name: 'legacy' }, 'createdBy', 'creator_name')).toBe('legacy');
expect(resolveAuditValue({ created_at: 1_700_000_000_000 }, 'createdAt', 'created_at')).toBe(
1_700_000_000_000,
);
expect(resolveAuditValue({ created_at: 1_700_000_000_000 }, 'createdAt', 'created_at')).toBe(1_700_000_000_000);
});
it('returns undefined when neither field is present', () => {
@@ -63,7 +63,10 @@ export function BulkActionMenu({
const hasActive = selectedRows.some((row) => row[statusKey]?.toLowerCase() === 'active');
const hasInactive = selectedRows.some(
(row) => row[statusKey]?.toLowerCase() === 'inactive' || row[statusKey]?.toLowerCase() === 'draft' || row[statusKey]?.toLowerCase() === 'archived',
(row) =>
row[statusKey]?.toLowerCase() === 'inactive' ||
row[statusKey]?.toLowerCase() === 'draft' ||
row[statusKey]?.toLowerCase() === 'archived',
);
const defaultActions: PageActionProps[] = [];
@@ -282,7 +282,7 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
const filterKeys = filterConfig?.defaultValues
? Object.keys(filterConfig.defaultValues)
: Object.keys(filterData).filter(
(key) => key !== searchKey && !['page', 'limit', 'order_by', 'order_type'].includes(key),
(key) => key !== searchKey && !['page', 'limit', 'orderBy', 'orderType'].includes(key),
);
return filterKeys.filter((key) => {
@@ -773,8 +773,8 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
const requestParams: Record<string, any> = {
page,
limit,
order_by: orderBy,
order_type: orderType,
orderBy: orderBy,
orderType: orderType,
...filterRef.current,
};
@@ -504,7 +504,8 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
const isMasterData = moduleType === 'MASTER_DATA';
const isDataActive = detailData && ['active'].includes(detailData[statusKey]?.toLowerCase());
const isDataInActive = detailData && ['inactive', 'draft', 'archived'].includes(detailData[statusKey]?.toLowerCase());
const isDataInActive =
detailData && ['inactive', 'draft', 'archived'].includes(detailData[statusKey]?.toLowerCase());
// 1. Declare action with Privilege & Module Type conditions directly
const rawActions = [