mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 01:48:12 +00:00
Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -1,30 +1,62 @@
|
||||
import { Logger } from "@nestjs/common";
|
||||
|
||||
import { EXCHANGE_DEFAULTS, ExchangeOptions } from "./exchange.options";
|
||||
import {
|
||||
EXCHANGE_DEFAULTS,
|
||||
ExchangeOptions,
|
||||
ResolvedExchangeOptions,
|
||||
} from "./exchange.options";
|
||||
import {
|
||||
CurrencyPair,
|
||||
ExchangeRateProvider,
|
||||
} from "./exchange.types";
|
||||
|
||||
/** Matches USD buying/selling embedded in ethio.forex CBET page HTML (after entity unescape). */
|
||||
const USD_RATE_REGEX =
|
||||
/currency_code":\[0,"USD"\],"currency_name":\[0,"US DOLLAR"\],"buying":\[0,([\d.]+)\],"selling":\[0,([\d.]+)\]/;
|
||||
/** One currency's rates within a daily record returned by the CBE endpoint. */
|
||||
interface CbeExchangeRateEntry {
|
||||
transactionalSelling?: number | string | null;
|
||||
transactionalBuying?: number | string | null;
|
||||
currency?: { CurrencyCode?: string | null } | null;
|
||||
}
|
||||
|
||||
/** A single day's record from the CBE `daily-exchange-rates` endpoint. */
|
||||
interface CbeDailyRecord {
|
||||
Date?: string | null;
|
||||
ExchangeRate?: CbeExchangeRateEntry[] | null;
|
||||
}
|
||||
|
||||
/** Where the most recently served rate came from. */
|
||||
export type CbeRateSource = "live" | "cache" | "stored" | "default";
|
||||
|
||||
/** Health of the CBE feed, for operator-facing status displays. */
|
||||
export interface CbeProviderStatus {
|
||||
/** The rate most recently served, whatever its source. */
|
||||
rate: number | null;
|
||||
/** Where that rate came from. `live` means the API answered. */
|
||||
source: CbeRateSource | null;
|
||||
/** Epoch ms of the last successful live fetch, or `null` if never. */
|
||||
lastSuccessAt: number | null;
|
||||
/** Message from the most recent failed fetch, cleared on success. */
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Central Bank of Ethiopia (CBE) rate provider.
|
||||
* Commercial Bank of Ethiopia (CBE) rate provider.
|
||||
*
|
||||
* Sources a single canonical direction — **USD→ETB** (selling rate) — by
|
||||
* scraping ethio.forex, caching the result, and falling back to a configured
|
||||
* rate when the scrape fails. The inverse (ETB→USD) is derived by
|
||||
* {@link ExchangeService}, so this provider only ever reports USD→ETB.
|
||||
* Sources a single canonical direction — **USD→ETB** (transactional selling
|
||||
* rate) — from CBE's public `daily-exchange-rates` JSON endpoint, caching the
|
||||
* result and falling back to a configured rate when the fetch fails. The
|
||||
* inverse (ETB→USD) is derived by {@link ExchangeService}, so this provider
|
||||
* only ever reports USD→ETB.
|
||||
*/
|
||||
export class CbeExchangeProvider implements ExchangeRateProvider {
|
||||
readonly name = "CBE";
|
||||
|
||||
private readonly logger = new Logger(CbeExchangeProvider.name);
|
||||
private readonly options: Required<ExchangeOptions>;
|
||||
private readonly options: ResolvedExchangeOptions;
|
||||
private cachedRate: number | null = null;
|
||||
private cacheExpiresAt = 0;
|
||||
private lastSuccessAt: number | null = null;
|
||||
private lastError: string | null = null;
|
||||
private lastSource: CbeRateSource | null = null;
|
||||
|
||||
constructor(options: ExchangeOptions) {
|
||||
this.options = { ...EXCHANGE_DEFAULTS, ...stripUndefined(options) };
|
||||
@@ -38,15 +70,29 @@ export class CbeExchangeProvider implements ExchangeRateProvider {
|
||||
return this.getUsdToEtbRate();
|
||||
}
|
||||
|
||||
/** Health of the CBE feed — what was served last, and whether it is failing. */
|
||||
getStatus(): CbeProviderStatus {
|
||||
return {
|
||||
rate: this.cachedRate,
|
||||
source: this.lastSource,
|
||||
lastSuccessAt: this.lastSuccessAt,
|
||||
lastError: this.lastError,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current CBE USD→ETB **selling** rate scraped from ethio.forex.
|
||||
* Cached for `cacheTtlMs`; on failure reuses the last cached rate, else
|
||||
* returns `fallbackRate`.
|
||||
* Returns the current CBE USD→ETB **transactional selling** rate.
|
||||
*
|
||||
* Cached for `cacheTtlMs`. On a successful fetch the rate is written back via
|
||||
* `saveFallbackRate`, so the stored fallback is never more than one good
|
||||
* fetch stale. On failure the chain is: cached rate → `loadFallbackRate()`
|
||||
* → static `fallbackRate`.
|
||||
*/
|
||||
private async getUsdToEtbRate(): Promise<number> {
|
||||
const now = Date.now();
|
||||
|
||||
if (this.cachedRate !== null && now < this.cacheExpiresAt) {
|
||||
this.lastSource = "cache";
|
||||
return this.cachedRate;
|
||||
}
|
||||
|
||||
@@ -56,68 +102,133 @@ export class CbeExchangeProvider implements ExchangeRateProvider {
|
||||
try {
|
||||
const response = await fetch(scrapeUrl, {
|
||||
signal: AbortSignal.timeout(requestTimeoutMs),
|
||||
headers: { "User-Agent": "Mozilla/5.0" },
|
||||
headers: { Accept: "application/json", "User-Agent": "Mozilla/5.0" },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`CBE scrape responded with status ${response.status}`);
|
||||
throw new Error(`CBE rates responded with status ${response.status}`);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const rates = this.parseScrapedRates(html);
|
||||
const payload = (await response.json()) as unknown;
|
||||
const day = this.latestRecord(payload);
|
||||
|
||||
if (!rates) {
|
||||
throw new Error("USD rate not found in ethio.forex page HTML");
|
||||
if (!day) {
|
||||
throw new Error("CBE rates payload contained no daily record");
|
||||
}
|
||||
|
||||
const rate = rates.selling;
|
||||
if (!Number.isFinite(rate) || rate <= 0) {
|
||||
throw new Error(`Invalid selling rate parsed: ${rate}`);
|
||||
const rate = this.parseUsdRate(day);
|
||||
|
||||
if (rate === null) {
|
||||
throw new Error(
|
||||
`USD transactionalSelling not found in CBE record for ${day.Date ?? "unknown date"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const previous = this.cachedRate;
|
||||
this.cachedRate = rate;
|
||||
this.cacheExpiresAt = now + cacheTtlMs;
|
||||
this.lastSuccessAt = now;
|
||||
this.lastError = null;
|
||||
this.lastSource = "live";
|
||||
this.logger.log(
|
||||
`CBE USD→ETB rate refreshed from ethio.forex — buying=${rates.buying} selling=${rate}`,
|
||||
);
|
||||
return rate;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to scrape CBE exchange rate — using fallback ${fallbackRate} ETB/USD. Error: ${(err as Error).message}`,
|
||||
`CBE USD→ETB rate refreshed — transactionalSelling=${rate} (date=${day.Date ?? "unknown"})`,
|
||||
);
|
||||
|
||||
// Persist as the new fallback so a later outage reuses the last good
|
||||
// rate. Skipped when unchanged, to avoid pointless writes and audit noise.
|
||||
if (rate !== previous) {
|
||||
await this.persistFallback(rate);
|
||||
}
|
||||
|
||||
return rate;
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
this.lastError = message;
|
||||
this.logger.error(`Failed to fetch CBE exchange rate. Error: ${message}`);
|
||||
|
||||
if (this.cachedRate !== null) {
|
||||
this.lastSource = "cache";
|
||||
this.logger.warn(
|
||||
`Using previously cached CBE rate: ${this.cachedRate}`,
|
||||
);
|
||||
return this.cachedRate;
|
||||
}
|
||||
|
||||
const stored = await this.loadStoredFallback();
|
||||
if (stored !== null) {
|
||||
this.lastSource = "stored";
|
||||
this.logger.warn(`Using stored fallback CBE rate: ${stored}`);
|
||||
return stored;
|
||||
}
|
||||
|
||||
this.lastSource = "default";
|
||||
this.logger.warn(`Using default fallback CBE rate: ${fallbackRate}`);
|
||||
return fallbackRate;
|
||||
}
|
||||
}
|
||||
|
||||
private parseScrapedRates(
|
||||
html: string,
|
||||
): { buying: number; selling: number } | null {
|
||||
const decoded = this.unescapeHtml(html);
|
||||
const match = USD_RATE_REGEX.exec(decoded);
|
||||
if (!match) return null;
|
||||
/**
|
||||
* Writes a freshly fetched rate back as the stored fallback. Failures are
|
||||
* logged and swallowed: persisting the fallback is housekeeping, and must
|
||||
* never fail the pricing call that triggered it.
|
||||
*/
|
||||
private async persistFallback(rate: number): Promise<void> {
|
||||
const { saveFallbackRate } = this.options;
|
||||
if (!saveFallbackRate) return;
|
||||
|
||||
const buying = Number(match[1]);
|
||||
const selling = Number(match[2]);
|
||||
if (!Number.isFinite(buying) || !Number.isFinite(selling)) return null;
|
||||
|
||||
return { buying, selling };
|
||||
try {
|
||||
await saveFallbackRate(rate);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Failed to persist CBE fallback rate ${rate}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private unescapeHtml(html: string): string {
|
||||
return html
|
||||
.replace(/"/g, '"')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
/**
|
||||
* Reads the persisted fallback. Returns `null` — falling through to the
|
||||
* static default — when unconfigured, unusable, or itself failing.
|
||||
*/
|
||||
private async loadStoredFallback(): Promise<number | null> {
|
||||
const { loadFallbackRate } = this.options;
|
||||
if (!loadFallbackRate) return null;
|
||||
|
||||
try {
|
||||
const stored = await loadFallbackRate();
|
||||
const rate = Number(stored);
|
||||
return Number.isFinite(rate) && rate > 0 ? rate : null;
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Failed to load stored CBE fallback rate: ${(err as Error).message}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The endpoint returns an array of daily records (one when `_limit=1`), but
|
||||
* tolerate a bare object in case the shape changes.
|
||||
*/
|
||||
private latestRecord(payload: unknown): CbeDailyRecord | null {
|
||||
const record = Array.isArray(payload) ? payload[0] : payload;
|
||||
return record && typeof record === "object"
|
||||
? (record as CbeDailyRecord)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls USD `transactionalSelling` out of a daily record. Returns `null` when
|
||||
* the entry is missing or the value isn't a usable positive number — CBE
|
||||
* publishes `0`/`null` for currencies it isn't quoting that day.
|
||||
*/
|
||||
private parseUsdRate(day: CbeDailyRecord): number | null {
|
||||
const usd = day.ExchangeRate?.find(
|
||||
(entry) => entry?.currency?.CurrencyCode === "USD",
|
||||
);
|
||||
if (!usd) return null;
|
||||
|
||||
const rate = Number(usd.transactionalSelling);
|
||||
return Number.isFinite(rate) && rate > 0 ? rate : null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,18 +4,39 @@ export const EXCHANGE_OPTIONS = Symbol("EXCHANGE_OPTIONS");
|
||||
/** Configuration for the {@link ExchangeService} and its CBE provider. */
|
||||
export interface ExchangeOptions {
|
||||
/**
|
||||
* ethio.forex CBET page scraped for USD buying/selling rates.
|
||||
* @default 'https://ethio.forex/bank/CBET'
|
||||
* CBE daily-exchange-rates JSON endpoint. Returns an array of daily records;
|
||||
* `_limit=1&_sort=Date%3ADESC` narrows it to the most recent day.
|
||||
* @default 'https://combanketh.et/cbeapi/daily-exchange-rates/?_limit=1&_sort=Date%3ADESC'
|
||||
*/
|
||||
scrapeUrl?: string;
|
||||
|
||||
/**
|
||||
* Base USD→ETB rate used when scraping fails and no previously cached rate
|
||||
* exists. The ETB→USD direction is derived as its inverse.
|
||||
* @default 130
|
||||
* Last-resort USD→ETB rate, used only when the fetch fails, no cached rate
|
||||
* exists, and {@link loadFallbackRate} supplies nothing. The ETB→USD
|
||||
* direction is derived as its inverse.
|
||||
* @default 162
|
||||
*/
|
||||
fallbackRate?: number;
|
||||
|
||||
/**
|
||||
* Reads the persisted fallback rate — the last known good CBE rate, or one
|
||||
* set by an operator. Consulted only when the live fetch fails and no cached
|
||||
* rate is available; a `null` result falls through to {@link fallbackRate}.
|
||||
*
|
||||
* Optional: omit it and the provider uses the static `fallbackRate` alone.
|
||||
*/
|
||||
loadFallbackRate?: () => Promise<number | null>;
|
||||
|
||||
/**
|
||||
* Persists a freshly fetched live rate as the new fallback, so the stored
|
||||
* value is never more than one successful fetch stale. Called after every
|
||||
* successful fetch that produced a changed rate.
|
||||
*
|
||||
* Failures here are logged and swallowed — persisting the fallback must
|
||||
* never break the pricing call that triggered it.
|
||||
*/
|
||||
saveFallbackRate?: (rate: number) => Promise<void>;
|
||||
|
||||
/**
|
||||
* How long a successfully fetched rate is cached, in milliseconds.
|
||||
* @default 3_600_000 (1 hour)
|
||||
@@ -23,16 +44,23 @@ export interface ExchangeOptions {
|
||||
cacheTtlMs?: number;
|
||||
|
||||
/**
|
||||
* Timeout for the scrape HTTP request, in milliseconds.
|
||||
* Timeout for the rate HTTP request, in milliseconds.
|
||||
* @default 8_000
|
||||
*/
|
||||
requestTimeoutMs?: number;
|
||||
}
|
||||
|
||||
/** Defaults applied to any unset {@link ExchangeOptions} field. */
|
||||
export const EXCHANGE_DEFAULTS: Required<ExchangeOptions> = {
|
||||
scrapeUrl: "https://ethio.forex/bank/CBET",
|
||||
fallbackRate: 130,
|
||||
/** The scalar options, all resolved — the callbacks stay genuinely optional. */
|
||||
export type ResolvedExchangeOptions = Required<
|
||||
Omit<ExchangeOptions, "loadFallbackRate" | "saveFallbackRate">
|
||||
> &
|
||||
Pick<ExchangeOptions, "loadFallbackRate" | "saveFallbackRate">;
|
||||
|
||||
/** Defaults applied to any unset scalar {@link ExchangeOptions} field. */
|
||||
export const EXCHANGE_DEFAULTS: ResolvedExchangeOptions = {
|
||||
scrapeUrl:
|
||||
"https://combanketh.et/cbeapi/daily-exchange-rates/?_limit=1&_sort=Date%3ADESC",
|
||||
fallbackRate: 162,
|
||||
cacheTtlMs: 3_600_000,
|
||||
requestTimeoutMs: 8_000,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
|
||||
import { CbeExchangeProvider } from "./cbe.provider";
|
||||
import { CbeExchangeProvider, CbeProviderStatus } from "./cbe.provider";
|
||||
import { EXCHANGE_OPTIONS, ExchangeOptions } from "./exchange.options";
|
||||
import { CurrencyCode } from "./exchange.types";
|
||||
|
||||
@@ -47,6 +47,14 @@ export class ExchangeService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Health of the underlying rate feed — what was served last and whether it
|
||||
* is currently failing. For operator-facing status displays.
|
||||
*/
|
||||
getProviderStatus(): CbeProviderStatus {
|
||||
return this.provider.getStatus();
|
||||
}
|
||||
|
||||
/** Converts `amount` from one currency to another using {@link getRate}. */
|
||||
async convert(
|
||||
amount: number,
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
export { ExchangeService } from "./exchange.service";
|
||||
export { ExchangeModule } from "./exchange.module";
|
||||
export { CbeExchangeProvider } from "./cbe.provider";
|
||||
export type { CbeProviderStatus, CbeRateSource } from "./cbe.provider";
|
||||
export { EXCHANGE_OPTIONS, EXCHANGE_DEFAULTS } from "./exchange.options";
|
||||
export type { ExchangeOptions, ExchangeAsyncOptions } from "./exchange.options";
|
||||
export type {
|
||||
ExchangeOptions,
|
||||
ExchangeAsyncOptions,
|
||||
ResolvedExchangeOptions,
|
||||
} from "./exchange.options";
|
||||
export type {
|
||||
CurrencyCode,
|
||||
CurrencyPair,
|
||||
|
||||
@@ -113,6 +113,7 @@ export enum PaymentService {
|
||||
export enum PaymentReferenceType {
|
||||
BOOKING = "BOOKING",
|
||||
SHIPMENT = "SHIPMENT",
|
||||
EXCESS_BAGGAGE = "EXCESS_BAGGAGE",
|
||||
SUPPLEMENTARY_CHARGE = "SUPPLEMENTARY_CHARGE",
|
||||
}
|
||||
|
||||
|
||||
@@ -161,6 +161,8 @@ export enum InvoiceStatus {
|
||||
/** Issued and awaiting payment (alias of PENDING for fee invoices). */
|
||||
Issued = "ISSUED",
|
||||
Pending = "PENDING",
|
||||
/** Customer completed provider checkout (success redirect); awaiting webhook confirmation. */
|
||||
PaymentProcessing = "PAYMENT_PROCESSING",
|
||||
/** Some, but not all, of the balance has been settled. */
|
||||
PartiallyPaid = "PARTIALLY_PAID",
|
||||
Paid = "PAID",
|
||||
@@ -231,6 +233,20 @@ export enum LoadingStatus {
|
||||
Unloaded = "UNLOADED",
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-approval last-mile confirmation lifecycle, fired when a train departs
|
||||
* Djibouti: the customer confirms which containers go via EDR last-mile, then
|
||||
* the Truck & Machinery chief approves (truck available) or rejects (reason).
|
||||
* Approval creates/reuses the execution `LastMile` record — this status set is
|
||||
* intentionally separate from `LAST_MILE_STATUSES`.
|
||||
*/
|
||||
export enum LastMileRequestStatus {
|
||||
AwaitingConfirmation = "AWAITING_CONFIRMATION",
|
||||
Submitted = "SUBMITTED",
|
||||
Approved = "APPROVED",
|
||||
Rejected = "REJECTED",
|
||||
}
|
||||
|
||||
export enum WagonStatus {
|
||||
Available = "AVAILABLE",
|
||||
Assigned = "ASSIGNED",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export type OverviewRange = '7d' | '30d' | '90d';
|
||||
|
||||
export interface IOverviewBookingKpis {
|
||||
/** All bookings ever recorded (excluding deleted / GENERAL umbrella rows). */
|
||||
total: number;
|
||||
totalActive: number;
|
||||
needsAction: number;
|
||||
urgent: number;
|
||||
@@ -13,6 +15,8 @@ export interface IOverviewOperationsKpis {
|
||||
wagonsAvailable: number;
|
||||
containersInTransit: number;
|
||||
cargoesLoaded: number;
|
||||
schedulesUpcoming: number;
|
||||
dispatchedToday: number;
|
||||
}
|
||||
|
||||
export interface IOverviewCustomerKpis {
|
||||
@@ -33,6 +37,8 @@ export interface IOverviewStaffKpis {
|
||||
}
|
||||
|
||||
export interface IOverviewContractKpis {
|
||||
/** All contracts ever recorded (excluding deleted). */
|
||||
total: number;
|
||||
totalActive: number;
|
||||
needsAction: number;
|
||||
inApproval: number;
|
||||
@@ -151,8 +157,27 @@ export interface IOverviewBillingTab {
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
/** Scheduled train departures per day, split by trade direction. */
|
||||
export interface IOverviewDirectionTrendPoint {
|
||||
date: string;
|
||||
importCount: number;
|
||||
exportCount: number;
|
||||
domesticCount: number;
|
||||
}
|
||||
|
||||
export interface IOverviewTonnagePoint {
|
||||
label: string;
|
||||
tons: number;
|
||||
}
|
||||
|
||||
export interface IOverviewOperationsTab {
|
||||
kpis: IOverviewOperationsKpis;
|
||||
departureTrend: IOverviewDirectionTrendPoint[];
|
||||
scheduleStatusBreakdown: IOverviewStatusCount[];
|
||||
wagonsByType: IOverviewLabelCount[];
|
||||
wagonsByYard: IOverviewLabelCount[];
|
||||
containersBySize: IOverviewLabelCount[];
|
||||
cargoTonnageByType: IOverviewTonnagePoint[];
|
||||
trainStatusBreakdown: IOverviewStatusCount[];
|
||||
wagonStatusBreakdown: IOverviewStatusCount[];
|
||||
containerStatusBreakdown: IOverviewStatusCount[];
|
||||
@@ -181,5 +206,6 @@ export type OverviewTabKey =
|
||||
| 'contracts'
|
||||
| 'billing'
|
||||
| 'operations'
|
||||
| 'fleet'
|
||||
| 'customers'
|
||||
| 'staff';
|
||||
|
||||
@@ -91,6 +91,8 @@ export interface BlockedSeatLossSchedule {
|
||||
/** `soldSeats / sellableSeats`, as a percentage rounded to one decimal. */
|
||||
loadFactorPercent: number;
|
||||
blockedSeatCount: number;
|
||||
/** Distinct blockers behind this schedule's blocked seats, in no particular order. */
|
||||
blockedByNames: string[];
|
||||
/** Loss at full occupancy — the sum of the fares these seats would have sold for. */
|
||||
estimatedLossMinor: number;
|
||||
/** `estimatedLossMinor × loadFactor` — what the train's actual demand supports. */
|
||||
@@ -160,7 +162,8 @@ export interface BlockedSeatRevenueLossReport {
|
||||
|
||||
/** Compact roll-up embedded in `GET /dashboard/backoffice-stats`. */
|
||||
export interface BlockedSeatRevenueLossStat {
|
||||
periodDays: number;
|
||||
/** `null` means the roll-up covers full history — the earliest schedule to the latest. */
|
||||
periodDays: number | null;
|
||||
lossByCurrency: BlockedSeatLossByCurrency[];
|
||||
schedulesAffected: number;
|
||||
blockedSeatCount: number;
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { createRequire } from 'module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
// Optional PostCSS configuration for applications that need it
|
||||
export const postcssConfig = {
|
||||
plugins: {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Box, Text } from "@mantine/core";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
export interface CurrencySelectorProps {
|
||||
/** Selected currency code, or "" when none picked yet. */
|
||||
value: string;
|
||||
onChange: (currency: "USD" | "ETB") => void;
|
||||
disabled?: boolean;
|
||||
/** Validation error shown under the cards. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const OPTIONS = [
|
||||
{
|
||||
code: "USD",
|
||||
symbol: "$",
|
||||
name: "US Dollar",
|
||||
hint: "As quoted on the contract",
|
||||
},
|
||||
{
|
||||
code: "ETB",
|
||||
symbol: "Br",
|
||||
name: "Ethiopian Birr",
|
||||
hint: "Converted from the USD total",
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Card-style USD/ETB billing-currency picker. Renders unselected when `value`
|
||||
* is "" so a required choice never looks pre-made.
|
||||
*/
|
||||
export function CurrencySelector({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
error,
|
||||
}: CurrencySelectorProps) {
|
||||
return (
|
||||
<Box>
|
||||
<Box
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
{OPTIONS.map((o) => {
|
||||
const selected = value === o.code;
|
||||
return (
|
||||
<button
|
||||
key={o.code}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-pressed={selected}
|
||||
onClick={() => onChange(o.code)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
textAlign: "left",
|
||||
padding: "12px 14px",
|
||||
borderRadius: 12,
|
||||
border: selected
|
||||
? "1.5px solid #12B981"
|
||||
: error
|
||||
? "1px solid #FCA5A5"
|
||||
: "1px solid #E6ECF2",
|
||||
background: selected ? "#F6FBF8" : "#fff",
|
||||
cursor: disabled ? "default" : "pointer",
|
||||
opacity: disabled && !selected ? 0.55 : 1,
|
||||
transition: "border-color 120ms ease, background 120ms ease",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
flexShrink: 0,
|
||||
borderRadius: 11,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 15,
|
||||
fontWeight: 800,
|
||||
background: selected ? "#ECF6F1" : "#F1F4F7",
|
||||
color: selected ? "#0A6F4D" : "#6B7C8E",
|
||||
}}
|
||||
>
|
||||
{o.symbol}
|
||||
</Box>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fz={14} fw={700} c="#10202F" lh={1.25}>
|
||||
{o.code}
|
||||
<Text component="span" fz={12.5} fw={500} c="dimmed">
|
||||
{" "}
|
||||
· {o.name}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text fz={11.5} c="dimmed" lh={1.35}>
|
||||
{o.hint}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box
|
||||
style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
flexShrink: 0,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
border: selected ? "none" : "1.5px solid #D4DDE5",
|
||||
background: selected ? "#12B981" : "transparent",
|
||||
}}
|
||||
>
|
||||
{selected && <Check size={12} color="#fff" strokeWidth={3.5} />}
|
||||
</Box>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
{error && (
|
||||
<Text fz={12.5} c="red.6" mt={6}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default CurrencySelector;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { CurrencySelector, default } from "./CurrencySelector";
|
||||
export type { CurrencySelectorProps } from "./CurrencySelector";
|
||||
@@ -18,6 +18,8 @@ export interface OperationDatePickerProps {
|
||||
onChange: (date: string) => void;
|
||||
/** Stretch to the full width of the parent container. */
|
||||
fullWidth?: boolean;
|
||||
/** Text shown under the grid when no days are available. */
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
/** `yyyy-MM-dd` for a local date. */
|
||||
@@ -58,6 +60,7 @@ export function OperationDatePicker({
|
||||
value,
|
||||
onChange,
|
||||
fullWidth = false,
|
||||
emptyMessage = "No scheduled departures found for this route yet.",
|
||||
}: OperationDatePickerProps) {
|
||||
const [month, setMonth] = useState(() => {
|
||||
const now = new Date();
|
||||
@@ -270,7 +273,7 @@ export function OperationDatePicker({
|
||||
)}
|
||||
{departureDays.size === 0 && (
|
||||
<Text fz="12px" c="orange.7" mt="sm" ta="center">
|
||||
No scheduled departures found for this route yet.
|
||||
{emptyMessage}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -26,6 +26,8 @@ export { OperationDatePicker } from "./components/OperationDatePicker";
|
||||
export type { OperationDatePickerProps } from "./components/OperationDatePicker";
|
||||
export { ExportTrainPicker } from "./components/ExportTrainPicker";
|
||||
export type { ExportTrainPickerProps } from "./components/ExportTrainPicker";
|
||||
export { CurrencySelector } from "./components/CurrencySelector";
|
||||
export type { CurrencySelectorProps } from "./components/CurrencySelector";
|
||||
|
||||
export { CountdownTimer } from "./components/CountdownTimer";
|
||||
export type { CountdownTimerProps } from "./components/CountdownTimer";
|
||||
|
||||
Reference in New Issue
Block a user