mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 09:42:53 +00:00
fix: 194 and 183 plane takss
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
validate,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
} from 'class-validator';
|
||||
import { IsTin, normalizeTin } from './is-tin.validator';
|
||||
|
||||
class Required {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsTin({ message: 'TIN must be exactly 10 digits' })
|
||||
tin!: string;
|
||||
}
|
||||
|
||||
class Optional {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsTin({ message: 'TIN must be exactly 10 digits' })
|
||||
tin?: string;
|
||||
}
|
||||
|
||||
async function errs(cls: any, tin: any) {
|
||||
const o = new cls();
|
||||
o.tin = tin;
|
||||
return (await validate(o)).length;
|
||||
}
|
||||
|
||||
describe('IsTin', () => {
|
||||
it('accepts a real 10-digit TIN', async () => {
|
||||
expect(await errs(Required, '0012345678')).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['letters', 'ABCDEFGHIJ'],
|
||||
['symbols', '!!!!!!!!!!'],
|
||||
['too short', '123'],
|
||||
['too long', '12345678901'],
|
||||
['draft TIN', 'D123456789'],
|
||||
['spaced', '012 345678'],
|
||||
])('rejects %s', async (_label, value) => {
|
||||
expect(await errs(Required, value)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects empty on the required DTO but allows omission on the optional one', async () => {
|
||||
expect(await errs(Required, '')).toBeGreaterThan(0);
|
||||
expect(await errs(Optional, undefined)).toBe(0);
|
||||
});
|
||||
|
||||
it('normalizes messy input', () => {
|
||||
expect(normalizeTin(' 001-234-5678 ')).toBe('0012345678');
|
||||
expect(normalizeTin('')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
registerDecorator,
|
||||
ValidationArguments,
|
||||
ValidationOptions,
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
} from 'class-validator';
|
||||
|
||||
/** An Ethiopian TIN is exactly 10 digits. */
|
||||
export const TIN_REGEX = /^\d{10}$/;
|
||||
|
||||
/**
|
||||
* Draft companies carry a placeholder TIN ("D" + 9 digits) minted server-side by
|
||||
* CompaniesService.generateDraftTin(), because the column is NOT NULL + unique.
|
||||
* Those never travel through a DTO, so this constraint deliberately rejects them
|
||||
* — a "D…" value arriving on a request body is client-supplied and invalid.
|
||||
*/
|
||||
@ValidatorConstraint({ name: 'IsTin', async: false })
|
||||
export class IsTinConstraint implements ValidatorConstraintInterface {
|
||||
validate(value: unknown): boolean {
|
||||
// Empty is allowed here; pair with @IsOptional / @IsNotEmpty as needed.
|
||||
if (value === undefined || value === null || value === '') return true;
|
||||
if (typeof value !== 'string') return false;
|
||||
return TIN_REGEX.test(value);
|
||||
}
|
||||
|
||||
defaultMessage(args: ValidationArguments): string {
|
||||
return `${args.property} must be exactly 10 digits`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Class-validator decorator enforcing the 10-digit TIN format. */
|
||||
export function IsTin(validationOptions?: ValidationOptions) {
|
||||
return function (object: object, propertyName: string) {
|
||||
registerDecorator({
|
||||
target: object.constructor,
|
||||
propertyName,
|
||||
options: validationOptions,
|
||||
constraints: [],
|
||||
validator: IsTinConstraint,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip everything that isn't a digit and cap at 10 characters. Tolerant —
|
||||
* never throws; returns the value unchanged when empty/nullish.
|
||||
*/
|
||||
export function normalizeTin(
|
||||
value: string | null | undefined,
|
||||
): string | null | undefined {
|
||||
if (value === undefined || value === null || value === '') return value;
|
||||
return value.replace(/\D/g, '').slice(0, 10);
|
||||
}
|
||||
@@ -67,6 +67,8 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
kind,
|
||||
status,
|
||||
onboardingCompleted,
|
||||
sortBy = 'name',
|
||||
sortOrder = 'ASC',
|
||||
} = query;
|
||||
|
||||
const qb = this.repository
|
||||
@@ -113,8 +115,12 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
);
|
||||
}
|
||||
|
||||
// sortBy is whitelisted by @IsIn on the DTO, so it is safe to interpolate.
|
||||
const [items, total] = await qb
|
||||
.orderBy('company.name', 'ASC')
|
||||
.orderBy(`company.${sortBy}`, sortOrder)
|
||||
// Names are not unique and createdAt can tie on bulk imports; the id
|
||||
// tiebreaker keeps paging stable instead of dropping/repeating rows.
|
||||
.addOrderBy('company.id', 'ASC')
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize)
|
||||
.getManyAndCount();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Type } from 'class-transformer';
|
||||
import { CompanyType } from '../entities/company.entity';
|
||||
import { ProfileType } from '../entities/company-profile.entity';
|
||||
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
||||
import { IsTin } from '../../../common/validators/is-tin.validator';
|
||||
|
||||
export class CompanyProfileInputDto {
|
||||
@IsEnum(ProfileType)
|
||||
@@ -45,7 +46,7 @@ export class CreateCompanyWithProfileDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10)
|
||||
@IsTin({ message: 'TIN must be exactly 10 digits' })
|
||||
tin?: string;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, IsEmail } from 'class-validator';
|
||||
import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, IsEmail } from 'class-validator';
|
||||
import { CompanyType, CompanyStatus } from '../entities/company.entity';
|
||||
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
||||
import { IsTin } from '../../../common/validators/is-tin.validator';
|
||||
|
||||
export class CreateCompanyDto {
|
||||
@IsString()
|
||||
@@ -17,7 +18,7 @@ export class CreateCompanyDto {
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Length(10, 10, { message: 'TIN must be exactly 10 digits' })
|
||||
@IsTin({ message: 'TIN must be exactly 10 digits' })
|
||||
tin!: string;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { IsString, IsNotEmpty, Length } from "class-validator";
|
||||
import { IsString, IsNotEmpty } from "class-validator";
|
||||
import { IsTin } from "../../../common/validators/is-tin.validator";
|
||||
|
||||
export class FetchETradeDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Length(10, 10, { message: "TIN must be exactly 10 digits" })
|
||||
@IsTin({ message: "TIN must be exactly 10 digits" })
|
||||
tin!: string;
|
||||
}
|
||||
|
||||
@@ -47,4 +47,19 @@ export class ListCompaniesQueryDto {
|
||||
@Transform(({ value }: { value: unknown }) => value === "true" || value === true)
|
||||
@IsBoolean()
|
||||
onboardingCompleted?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: ["name", "createdAt", "updatedAt"],
|
||||
default: "name",
|
||||
description: "Column to order by. Defaults to name for backwards compatibility.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(["name", "createdAt", "updatedAt"])
|
||||
sortBy?: "name" | "createdAt" | "updatedAt";
|
||||
|
||||
@ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "ASC" })
|
||||
@IsOptional()
|
||||
@Transform(({ value }: { value: unknown }) => String(value).toUpperCase())
|
||||
@IsIn(["ASC", "DESC"])
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IsString, IsOptional, IsEmail, MaxLength, Length, IsEnum } from 'class-validator';
|
||||
import { IsString, IsOptional, IsEmail, MaxLength, IsEnum } from 'class-validator';
|
||||
import { CompanyNationality } from '../entities/company.entity';
|
||||
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
||||
import { IsTin } from '../../../common/validators/is-tin.validator';
|
||||
|
||||
export class UpdateProfileDto {
|
||||
@IsOptional()
|
||||
@@ -34,7 +35,7 @@ export class UpdateProfileDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(10, 10, { message: 'TIN must be exactly 10 digits' })
|
||||
@IsTin({ message: 'TIN must be exactly 10 digits' })
|
||||
tin?: string;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Card,
|
||||
Group,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
@@ -63,22 +64,35 @@ const VIEW_FILTERS: Record<
|
||||
active: { status: "active" },
|
||||
};
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ value: "createdAt:DESC", label: "Newest first" },
|
||||
{ value: "createdAt:ASC", label: "Oldest first" },
|
||||
{ value: "name:ASC", label: "Name (A–Z)" },
|
||||
{ value: "name:DESC", label: "Name (Z–A)" },
|
||||
] as const;
|
||||
|
||||
export default function CustomersPage() {
|
||||
const navigate = useNavigate();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [query, setQuery] = useState("");
|
||||
const [debouncedQuery] = useDebouncedValue(query, 300);
|
||||
const [view, setView] = useState<CustomerView>("all");
|
||||
const [sort, setSort] = useState<string>("createdAt:DESC");
|
||||
|
||||
const filter = useMemo(
|
||||
() => ({
|
||||
const filter = useMemo(() => {
|
||||
const [sortBy, sortOrder] = sort.split(":") as [
|
||||
"name" | "createdAt" | "updatedAt",
|
||||
"ASC" | "DESC",
|
||||
];
|
||||
return {
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
search: debouncedQuery,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
...VIEW_FILTERS[view],
|
||||
}),
|
||||
[pagination.pageIndex, pagination.pageSize, debouncedQuery, view],
|
||||
);
|
||||
};
|
||||
}, [pagination.pageIndex, pagination.pageSize, debouncedQuery, view, sort]);
|
||||
|
||||
const { data: stats } = useQuery(api.customers.stats.queryOptions({ input: {} }));
|
||||
|
||||
@@ -291,6 +305,20 @@ export default function CustomersPage() {
|
||||
{ label: "Active", value: "active" },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
radius="md"
|
||||
w={160}
|
||||
allowDeselect={false}
|
||||
aria-label="Sort customers"
|
||||
value={sort}
|
||||
onChange={(v) => {
|
||||
if (!v) return;
|
||||
setSort(v);
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
}}
|
||||
data={SORT_OPTIONS.map((o) => ({ ...o }))}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
|
||||
@@ -188,6 +188,8 @@ export interface CompanyListFilter {
|
||||
status?: CompanyStatus;
|
||||
/** `true` = submitted applications only; `false` = drafts only; omit for both. */
|
||||
onboardingCompleted?: boolean;
|
||||
sortBy?: "name" | "createdAt" | "updatedAt";
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
}
|
||||
|
||||
/** Standard paginated list envelope (matches the bookings service shape). */
|
||||
|
||||
@@ -21,7 +21,7 @@ export const onboardingSchema = z.object({
|
||||
// Derived from the eTrade address parts (kebele/woreda/zone/region); no
|
||||
// standalone input — the granular fields live in the registration section.
|
||||
companyAddress: z.string().optional(),
|
||||
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
|
||||
tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"),
|
||||
vatNumber: z
|
||||
.string()
|
||||
.min(1, "VAT number is required")
|
||||
|
||||
@@ -36,7 +36,7 @@ const schema = z.object({
|
||||
|
||||
// TRANSPORT
|
||||
fanNumber: z.string().min(1),
|
||||
tinNumber: z.string().min(1),
|
||||
tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"),
|
||||
|
||||
truckType: z.enum([
|
||||
"Casoni",
|
||||
@@ -185,7 +185,7 @@ export default function TransporterOnboarding() {
|
||||
|
||||
<Field data-invalid={!!errors.tinNumber}>
|
||||
<FieldLabel>TIN Number</FieldLabel>
|
||||
<Input {...register("tinNumber")} />
|
||||
<Input maxLength={10} inputMode="numeric" {...register("tinNumber")} />
|
||||
<FieldError errors={[errors.tinNumber]} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,7 @@ export const COMPANY_PROFILE_SCHEMA = z.object({
|
||||
.refine(isValidPhone, "Enter a valid phone number"),
|
||||
companyLocation: z.string().min(1, "Location is required"),
|
||||
companyAddress: z.string().min(1, "Address is required"),
|
||||
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
|
||||
tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"),
|
||||
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
|
||||
vatNumber: z
|
||||
.string()
|
||||
|
||||
Reference in New Issue
Block a user