mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Correctness: - Resume AI now releases ANY assignment (not just the caller's own). A handoff-assigned thread previously stayed assigned after resume, so the eligibility gate silently kept the bot muted — Resume was a no-op. - GET /api/ai/usage: guard days >= 1 (Number(null)===0 is finite, so the 30-day default was unreachable and the window collapsed to 1 day). - GET /api/ai/usage: align the query cutoff to the start of the oldest charted local day so rows in the oldest partial day no longer count in totals while missing from the daily series (totals now match the bars). - ai-thread-banner: cache account AI-status per accountId and never cache a transient fetch failure — fixes cross-account leak and the banner staying hidden all session after one failed /api/ai/config call. - /api/ai/draft: construct the service-role client inside a guard and fire-and-forget the usage log, so a missing service-role key (or any log failure) can't 500 an already-generated, already-billed draft. Efficiency: - Don't await usage logging on the reply hot path (auto-reply + draft); logAiUsage swallows its own errors, so fire-and-forget is safe and removes a DB round-trip from the customer-facing send. Reuse / consistency: - usage route reuses date-utils (daysAgoStart/lastNDayKeys/localDayKey) and now buckets by LOCAL day like every other dashboard chart (was UTC). - extract formatCompactNumber (shared with formatCurrencyShort); drop the hand-rolled month table in favour of date-fns; extract fetchAccountMembers/memberLabel and use them in the AI settings picker. Resume still resets the per-conversation reply count (a deliberate, manual, rate-limited re-engagement — documented in the handler). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
107 lines
4.0 KiB
TypeScript
107 lines
4.0 KiB
TypeScript
/**
|
|
* Currency — single source of truth for deal-value formatting and
|
|
* the currency picker options.
|
|
*
|
|
* Before this module, ~6 components each defined their own
|
|
* `Intl.NumberFormat(..., { currency: "USD" })` helper with USD
|
|
* baked in. The default currency is now configurable per account
|
|
* (accounts.default_currency, migration 021), so every formatter
|
|
* takes a currency and falls back to DEFAULT_CURRENCY only when
|
|
* nothing is known.
|
|
*/
|
|
|
|
/** App-wide fallback when no account/deal currency is available. */
|
|
export const DEFAULT_CURRENCY = "USD";
|
|
|
|
export interface CurrencyOption {
|
|
/** ISO-4217 code, e.g. "USD". Stored verbatim in the DB. */
|
|
code: string;
|
|
/** Human label for the dropdown, e.g. "US Dollar". */
|
|
label: string;
|
|
/** Symbol for compact display, e.g. "$". */
|
|
symbol: string;
|
|
}
|
|
|
|
/**
|
|
* The currencies offered in pickers. Codes must be valid ISO-4217 so
|
|
* `Intl.NumberFormat` renders the right symbol/grouping. Extend this
|
|
* list to offer more — nothing else needs to change.
|
|
*/
|
|
export const CURRENCIES: CurrencyOption[] = [
|
|
{ code: "USD", label: "US Dollar", symbol: "$" },
|
|
{ code: "EUR", label: "Euro", symbol: "€" },
|
|
{ code: "GBP", label: "British Pound", symbol: "£" },
|
|
{ code: "INR", label: "Indian Rupee", symbol: "₹" },
|
|
{ code: "AUD", label: "Australian Dollar", symbol: "A$" },
|
|
{ code: "CAD", label: "Canadian Dollar", symbol: "C$" },
|
|
{ code: "BRL", label: "Brazilian Real", symbol: "R$" },
|
|
{ code: "JPY", label: "Japanese Yen", symbol: "¥" },
|
|
{ code: "CNY", label: "Chinese Yuan", symbol: "¥" },
|
|
{ code: "AED", label: "UAE Dirham", symbol: "د.إ" },
|
|
{ code: "ZAR", label: "South African Rand", symbol: "R" },
|
|
{ code: "NGN", label: "Nigerian Naira", symbol: "₦" },
|
|
{ code: "SGD", label: "Singapore Dollar", symbol: "S$" },
|
|
{ code: "MXN", label: "Mexican Peso", symbol: "$" },
|
|
];
|
|
|
|
/**
|
|
* Format a deal value as a currency string. Whole-number output
|
|
* (no minor units) — deal values are tracked to the dollar across
|
|
* the app. `currency` defaults to USD so callers with nothing better
|
|
* stay safe, but pass the account/deal currency wherever known.
|
|
*
|
|
* Total by design: `Intl.NumberFormat` throws a RangeError on a
|
|
* structurally invalid currency code, and `deals.currency` carries
|
|
* NO DB CHECK (only `accounts.default_currency` does), so legacy
|
|
* rows, imports, or hand-edited data can hold malformed values like
|
|
* "United States". We never let that crash a render — on a bad code
|
|
* we fall back to "CODE 1,234".
|
|
*/
|
|
export function formatCurrency(
|
|
value: number,
|
|
currency: string = DEFAULT_CURRENCY,
|
|
): string {
|
|
const code = (currency || DEFAULT_CURRENCY).trim();
|
|
const amount = Number(value) || 0;
|
|
try {
|
|
return new Intl.NumberFormat(undefined, {
|
|
style: "currency",
|
|
currency: code,
|
|
minimumFractionDigits: 0,
|
|
maximumFractionDigits: 0,
|
|
}).format(amount);
|
|
} catch {
|
|
// Invalid ISO code — show the raw code + grouped number so the
|
|
// value is still legible instead of throwing.
|
|
return `${code} ${new Intl.NumberFormat(undefined, {
|
|
maximumFractionDigits: 0,
|
|
}).format(amount)}`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compact currency for tight spaces (donut center, legend rows):
|
|
* "$1.2M" / "€34.5k" / "₹900". Uses the currency's symbol from
|
|
* CURRENCIES, falling back to the code when we don't carry a symbol.
|
|
*/
|
|
export function formatCurrencyShort(
|
|
value: number,
|
|
currency: string = DEFAULT_CURRENCY,
|
|
): string {
|
|
const code = currency || DEFAULT_CURRENCY;
|
|
const symbol = CURRENCIES.find((c) => c.code === code)?.symbol ?? `${code} `;
|
|
return `${symbol}${formatCompactNumber(value)}`;
|
|
}
|
|
|
|
/**
|
|
* Compact number for tight spaces (chart tiles, legends): 1_234 → "1.2k",
|
|
* 1_200_000 → "1.2M", 900 → "900". The unit-less core shared with
|
|
* {@link formatCurrencyShort}.
|
|
*/
|
|
export function formatCompactNumber(value: number): string {
|
|
const v = Number(value || 0);
|
|
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
|
|
if (v >= 1_000) return `${(v / 1_000).toFixed(1)}k`;
|
|
return v.toFixed(0);
|
|
}
|