Renewal avisos left behind only a `RenewalNotice` row, whose sole job is gating: a row with `sentAt` drops the policy off the pending list. It cannot represent a failed send or a customer with no address, so the Pólizas tab had no "Registro de envíos" to show and a sent notice simply vanished from the list. Renewals now write `email_notification_log` — the same table the four bulk jobs write — as `RENEWAL_NOTICE` / `POLICIES`, with rows for failures and no-email skips too. `RenewalNotice` keeps its gating role unchanged; the two are complementary, not redundant. - extend `EmailNotificationType` (+RENEWAL_NOTICE) and `EmailNotificationServicio` (+POLICIES); `level` now carries the aviso generation on renewal rows, so every reader must branch on the type first (`notificationLevelLabel()` is the one place that lives) - backfill emailed notices (`channel = 'EMAIL'`) into the log; MAIL-channel rows are legacy printed letters and are deliberately left out - extract `NotificationLogService`/`NotificationLogModule` as the single writer, so a feature that sends mail records it without pulling the bulk-job pipelines into its module - `GET /notifications/log` and `/stats` take a comma-separated `servicio` list; each tab reads its own slice. This also fixes the "Omitidos" view, which mapped to no filter at all and showed every row - share one `NotificationLogPanel` between both tabs - pass SES_* / NOTIFICATION_ADMIN_EMAILS through the galactus compose, which was missing them entirely — mail is runtime config, not a CI secret, and the prod image sets NODE_ENV=production so a blank config fails loudly instead of falling back to stdout Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
438 lines
13 KiB
TypeScript
438 lines
13 KiB
TypeScript
// Spanish label maps + formatting helpers. Single source of truth for i18n.
|
||
|
||
import type {
|
||
BankDirection,
|
||
LedgerDirection,
|
||
PolicyStatus,
|
||
Role,
|
||
ServiceKind,
|
||
TransactionDomain,
|
||
TrustStatus,
|
||
} from "./types";
|
||
|
||
/** Access tiers, high → low. VIEWER is read-only; STAFF+ can write. */
|
||
export const ROLE_LABEL: Record<Role, string> = {
|
||
ADMIN: "Administrador",
|
||
MANAGER: "Gerente",
|
||
STAFF: "Personal",
|
||
VIEWER: "Solo lectura",
|
||
};
|
||
|
||
/** Roles in descending rank — for populating role <select>s. */
|
||
export const ROLES_DESC: Role[] = ["ADMIN", "MANAGER", "STAFF", "VIEWER"];
|
||
|
||
/**
|
||
* Placeholder the migration writes when a legacy record had no name and none
|
||
* could be recovered from a secondary table (migration/transform_customers.py).
|
||
*/
|
||
export const SIN_NOMBRE = "(SIN NOMBRE)";
|
||
|
||
export const DOMAIN_LABELS: Record<string, string> = {
|
||
UTILITY: "Servicios",
|
||
INSURANCE: "Seguros",
|
||
TRUST: "Fideicomiso",
|
||
};
|
||
|
||
export function domainLabel(domain: TransactionDomain): string {
|
||
return DOMAIN_LABELS[domain] ?? domain;
|
||
}
|
||
|
||
export const SERVICE_KIND_LABELS: Record<string, string> = {
|
||
WATER: "Agua",
|
||
ELECTRIC: "Electricidad",
|
||
GAS: "Gas",
|
||
CABLE: "Cable/TV",
|
||
PROPERTY_TAX: "Predial",
|
||
FEDERAL_ZONE: "Zona Federal",
|
||
ALARM: "Alarma",
|
||
TELEPHONE: "Teléfono",
|
||
OTHER: "Otro",
|
||
};
|
||
|
||
export function serviceKindLabel(kind: ServiceKind): string {
|
||
return SERVICE_KIND_LABELS[kind] ?? kind;
|
||
}
|
||
|
||
// A short glyph per service kind, drawn with unicode so no icon dependency.
|
||
export const SERVICE_KIND_GLYPH: Record<string, string> = {
|
||
WATER: "≈",
|
||
ELECTRIC: "⚡",
|
||
GAS: "◐",
|
||
TELEPHONE: "☎",
|
||
CABLE: "▤",
|
||
PROPERTY_TAX: "⌂",
|
||
FEDERAL_ZONE: "⇲",
|
||
ALARM: "◈",
|
||
OTHER: "•",
|
||
};
|
||
|
||
export function serviceKindGlyph(kind: ServiceKind): string {
|
||
return SERVICE_KIND_GLYPH[kind] ?? "•";
|
||
}
|
||
|
||
/**
|
||
* Free-text detail the migration parked in `PropertyService.notes`, which
|
||
* means something different per service kind: the municipality that bills the
|
||
* predial / zona federal, the CFE billing cycle (PAR/IMPAR), and the gas
|
||
* supply type. Used to label the note instead of dumping a bare string.
|
||
*/
|
||
export const SERVICE_NOTE_LABELS: Record<string, string> = {
|
||
PROPERTY_TAX: "Municipio",
|
||
FEDERAL_ZONE: "Municipio",
|
||
ELECTRIC: "Ciclo",
|
||
GAS: "Suministro",
|
||
CABLE: "Proveedor",
|
||
};
|
||
|
||
export function serviceNoteLabel(kind: ServiceKind): string | null {
|
||
return SERVICE_NOTE_LABELS[kind] ?? null;
|
||
}
|
||
|
||
// ----- fideicomisos (trusts) -----
|
||
|
||
export const TRUST_STATUS_LABELS: Record<TrustStatus, string> = {
|
||
active: "Vigente",
|
||
expiring: "Por vencer",
|
||
expired: "Vencido",
|
||
undated: "Sin fecha",
|
||
};
|
||
|
||
export function trustStatusLabel(status: TrustStatus): string {
|
||
return TRUST_STATUS_LABELS[status] ?? status;
|
||
}
|
||
|
||
// ----- policies -----
|
||
|
||
export const POLICY_STATUS_LABELS: Record<PolicyStatus, string> = {
|
||
active: "Vigente",
|
||
expiring: "Por vencer",
|
||
expired: "Vencida",
|
||
undated: "Sin vigencia",
|
||
};
|
||
|
||
export function policyStatusLabel(status: PolicyStatus): string {
|
||
return POLICY_STATUS_LABELS[status] ?? status;
|
||
}
|
||
|
||
/**
|
||
* Headline premium for a policy: always `netPremium`.
|
||
*
|
||
* The legacy `total` column did not survive the migration as a usable figure —
|
||
* of 2378 policies only 2 carry a non-zero total (1585 are literally 0, 791
|
||
* null), and one of those two is *lower* than its own net premium. `netPremium`
|
||
* is populated on 2377 of 2378. `total` is still shown verbatim in the policy
|
||
* detail's condiciones grid, where it reads as source data rather than as the
|
||
* amount the customer owes.
|
||
*/
|
||
export function premiumHeadline(p: {
|
||
netPremium?: string | null;
|
||
}): { value: string | null; label: string } {
|
||
return { value: p.netPremium ?? null, label: "Prima neta" };
|
||
}
|
||
|
||
/** "vence en 12 días" / "venció hace 3 días" — null when the policy is undated. */
|
||
export function expiryPhrase(days: number | null): string | null {
|
||
if (days === null) return null;
|
||
if (days === 0) return "vence hoy";
|
||
if (days > 0) return `vence en ${days} ${days === 1 ? "día" : "días"}`;
|
||
const past = Math.abs(days);
|
||
return `venció hace ${past} ${past === 1 ? "día" : "días"}`;
|
||
}
|
||
|
||
// ----- ledger / estado de cuenta -----
|
||
|
||
/**
|
||
* A charge is negative and a credit positive (see `billing.service.ts`), so the
|
||
* balance is the plain sum. These are the two words the office uses.
|
||
*/
|
||
export const DIRECTION_LABELS: Record<LedgerDirection, string> = {
|
||
charge: "Cargo",
|
||
credit: "Abono",
|
||
};
|
||
|
||
export function directionLabel(d: LedgerDirection): string {
|
||
return DIRECTION_LABELS[d] ?? d;
|
||
}
|
||
|
||
/**
|
||
* Spanish names for the legacy `TYPE OF TRX` lookup.
|
||
*
|
||
* The lookup ships an `ESPAÑOL` column, but it is **empty in the source** — all
|
||
* 79 rows are null — so the API can only return the English name. This map
|
||
* covers the entries that are real service/payment categories; the rest of the
|
||
* 79 are payee names (LORETO GONZALEZ, ALBERCAS VALLARTA…) that shouldn't be
|
||
* translated anyway, and fall through to the raw value.
|
||
*/
|
||
export const TX_TYPE_LABELS: Record<string, string> = {
|
||
WATER: "Agua",
|
||
ELECTRIC: "Electricidad",
|
||
TELEPHONE: "Teléfono",
|
||
"PROPERTY TAXES": "Predial",
|
||
"FEDERAL ZONE": "Zona federal",
|
||
"GAS BUTANO": "Gas butano",
|
||
"GAS REFILL": "Recarga de gas",
|
||
"TRUST FEE": "Cuota de fideicomiso",
|
||
"HOA DUES": "Cuota de asociación",
|
||
"ALARM SYSTEM": "Sistema de alarma",
|
||
"HOUSE INSURANCE": "Seguro de casa",
|
||
"AUTO INSURANCE": "Seguro de auto",
|
||
"CHECK DEPOSIT": "Depósito con cheque",
|
||
"CASH DEPOSIT": "Depósito en efectivo",
|
||
PAYPAL: "PayPal",
|
||
"RETURNED CHECK": "Cheque devuelto",
|
||
"ACCOUNT CANCELED": "Cuenta cancelada",
|
||
"BANK FEE": "Comisión bancaria",
|
||
"BANK INTEREST": "Interés bancario",
|
||
SECURITY: "Vigilancia",
|
||
BALANCE: "Saldo",
|
||
ACCOUNTANT: "Contador",
|
||
"RENEWAL CONCESSION": "Renovación de concesión",
|
||
};
|
||
|
||
export function txTypeLabel(
|
||
type: { nameEs?: string | null; nameEn?: string | null } | null | undefined,
|
||
): string {
|
||
const raw = type?.nameEs || type?.nameEn;
|
||
if (!raw) return "Sin clasificar";
|
||
return TX_TYPE_LABELS[raw.toUpperCase()] ?? raw;
|
||
}
|
||
|
||
/**
|
||
* Legacy table a movement came from. Shown so a staff member checking a
|
||
* surprising figure can trace it back to the Access table it was migrated from.
|
||
*/
|
||
export const LEDGER_SOURCE_LABELS: Record<string, string> = {
|
||
datos2: "Facturación 2025–26",
|
||
"FEE ANUAL": "Cuota anual 2018",
|
||
fee15: "Cuota anual 2017",
|
||
"IVA 2015": "IVA 2015",
|
||
EFECTIVO: "Recibos de caja",
|
||
EFECTIVO_BACKUP: "Recibos de caja (respaldo)",
|
||
"EFECTIVO FM3": "Trámites FM3",
|
||
"CHEQUE FM3": "Trámites FM3 (cheque)",
|
||
};
|
||
|
||
export function ledgerSourceLabel(source: string | null | undefined): string {
|
||
if (!source) return "—";
|
||
return LEDGER_SOURCE_LABELS[source] ?? source;
|
||
}
|
||
|
||
/**
|
||
* Balance wording. Negative = the customer owes the office; positive = the
|
||
* customer is in credit (they have money on account).
|
||
*/
|
||
export function balancePhrase(balance: string | number): string {
|
||
const n = typeof balance === "string" ? Number(balance) : balance;
|
||
if (!Number.isFinite(n) || Math.abs(n) < 0.005) return "Sin saldo";
|
||
return n < 0 ? "Adeudo" : "A favor";
|
||
}
|
||
|
||
/** CSS-class suffix matching `balancePhrase`, for colouring a figure. */
|
||
export function balanceTone(balance: string | number): "owing" | "credit" | "flat" {
|
||
const n = typeof balance === "string" ? Number(balance) : balance;
|
||
if (!Number.isFinite(n) || Math.abs(n) < 0.005) return "flat";
|
||
return n < 0 ? "owing" : "credit";
|
||
}
|
||
|
||
// ----- chequera / bank register -----
|
||
|
||
/**
|
||
* The office's own account, so the words are the bank's, not the ledger's:
|
||
* an ingreso is money arriving, an egreso money leaving, and a zero-amount row
|
||
* is a cheque that was voided.
|
||
*/
|
||
export const BANK_DIRECTION_LABELS: Record<BankDirection, string> = {
|
||
income: "Ingreso",
|
||
expense: "Egreso",
|
||
void: "Cancelado",
|
||
};
|
||
|
||
export function bankDirectionLabel(d: BankDirection): string {
|
||
return BANK_DIRECTION_LABELS[d] ?? d;
|
||
}
|
||
|
||
/** CSS-class suffix for colouring a bank figure, matching `.tx-amount`. */
|
||
export function bankTone(d: BankDirection): "pos" | "neg" | "" {
|
||
if (d === "income") return "pos";
|
||
return d === "expense" ? "neg" : "";
|
||
}
|
||
|
||
/** Legacy SCOTHIA table a register row came from. */
|
||
export const BANK_SOURCE_LABELS: Record<string, string> = {
|
||
"DATOS I": "Ingresos",
|
||
"DATOS E": "Egresos",
|
||
};
|
||
|
||
export function bankSourceLabel(source: string | null | undefined): string {
|
||
if (!source) return "—";
|
||
return BANK_SOURCE_LABELS[source] ?? source;
|
||
}
|
||
|
||
// ----- DB operations (admin) -----
|
||
|
||
export const OPS_KIND_LABELS: Record<string, string> = {
|
||
BACKUP: "Respaldo",
|
||
RESTORE: "Restauración",
|
||
REIMPORT: "Reimportación",
|
||
SYNC: "Sincronización",
|
||
};
|
||
|
||
export const OPS_STATUS_LABELS: Record<string, string> = {
|
||
RUNNING: "En curso",
|
||
SUCCESS: "Completado",
|
||
FAILED: "Con error",
|
||
};
|
||
|
||
/** Bytes → human size (KB/MB/GB), es-MX formatting. */
|
||
export function formatBytes(bytes: number | null | undefined): string {
|
||
if (bytes === null || bytes === undefined) return "—";
|
||
if (bytes < 1024) return `${bytes} B`;
|
||
const units = ["KB", "MB", "GB"];
|
||
let n = bytes / 1024;
|
||
let i = 0;
|
||
while (n >= 1024 && i < units.length - 1) {
|
||
n /= 1024;
|
||
i++;
|
||
}
|
||
return `${n.toLocaleString("es-MX", { maximumFractionDigits: 1 })} ${units[i]}`;
|
||
}
|
||
|
||
export function formatDateTime(iso: string | null | undefined): string {
|
||
if (!iso) return "—";
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime())) return "—";
|
||
return d.toLocaleString("es-MX", {
|
||
day: "2-digit",
|
||
month: "2-digit",
|
||
year: "numeric",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
});
|
||
}
|
||
|
||
export const MONTH_NAMES = [
|
||
"Enero",
|
||
"Febrero",
|
||
"Marzo",
|
||
"Abril",
|
||
"Mayo",
|
||
"Junio",
|
||
"Julio",
|
||
"Agosto",
|
||
"Septiembre",
|
||
"Octubre",
|
||
"Noviembre",
|
||
"Diciembre",
|
||
];
|
||
|
||
export function monthName(month: number): string {
|
||
return MONTH_NAMES[month - 1] ?? String(month);
|
||
}
|
||
|
||
// ----- formatting -----
|
||
|
||
export function formatMoney(
|
||
value: string | number | null | undefined,
|
||
currency: string | null | undefined,
|
||
): string {
|
||
if (value === null || value === undefined || value === "") return "—";
|
||
const num = typeof value === "string" ? Number(value) : value;
|
||
if (Number.isNaN(num)) return String(value);
|
||
const cur = (currency ?? "USD").toUpperCase();
|
||
try {
|
||
return new Intl.NumberFormat("es-MX", {
|
||
style: "currency",
|
||
currency: cur,
|
||
minimumFractionDigits: 2,
|
||
maximumFractionDigits: 2,
|
||
}).format(num);
|
||
} catch {
|
||
// Unknown currency code — fall back to plain number + suffix.
|
||
return `${num.toLocaleString("es-MX", {
|
||
minimumFractionDigits: 2,
|
||
maximumFractionDigits: 2,
|
||
})} ${cur}`;
|
||
}
|
||
}
|
||
|
||
export function formatDate(
|
||
iso: string | null | undefined,
|
||
): string {
|
||
if (!iso) return "—";
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime())) return "—";
|
||
const dd = String(d.getUTCDate()).padStart(2, "0");
|
||
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||
const yyyy = d.getUTCFullYear();
|
||
return `${dd}/${mm}/${yyyy}`;
|
||
}
|
||
|
||
export function formatNumber(n: number): string {
|
||
return n.toLocaleString("es-MX");
|
||
}
|
||
|
||
// "sourceSystem" from legacyRefs → display label.
|
||
export function sourceSystemLabel(source: string): string {
|
||
const map: Record<string, string> = {
|
||
utilities: "Servicios",
|
||
insurance: "Seguros",
|
||
};
|
||
return map[source] ?? source;
|
||
}
|
||
|
||
// ----- Mass email notifications -----
|
||
|
||
import type {
|
||
NotificationStatus,
|
||
NotificationType,
|
||
NotificationServicio,
|
||
} from "./api";
|
||
|
||
export const NOTIFICATION_TYPE_LABELS: Record<NotificationType, string> = {
|
||
OUTSTANDING_PAYMENT: "Pagos pendientes",
|
||
PAYMENT_CONFIRMATION: "Confirmación de pago",
|
||
ACCOUNT_STATUS: "Estado de cuenta",
|
||
TRUST_PAYMENT_CONFIRMATION: "Confirmación fideicomiso",
|
||
RENEWAL_NOTICE: "Aviso de renovación",
|
||
};
|
||
|
||
export const NOTIFICATION_SERVICIO_LABELS: Record<NotificationServicio, string> = {
|
||
CUSTOMERS: "Clientes",
|
||
TRUST: "Fideicomiso",
|
||
POLICIES: "Pólizas",
|
||
};
|
||
|
||
/**
|
||
* The `level` column means something different per notification type, so it
|
||
* can only be read alongside one. ACCOUNT_STATUS uses it for the alert colour;
|
||
* RENEWAL_NOTICE for the aviso generation. Everything else leaves it null.
|
||
*/
|
||
export function notificationLevelLabel(
|
||
type: NotificationType,
|
||
level: number | null,
|
||
): string {
|
||
if (level === null) return "";
|
||
if (type === "ACCOUNT_STATUS") return level === 0 ? " (amarilla)" : " (roja)";
|
||
if (type === "RENEWAL_NOTICE") {
|
||
if (level === 1) return " (1.º, 30 días antes)";
|
||
if (level === 2) return " (2.º, 15 días antes)";
|
||
if (level === 3) return " (3.º, 7 días después)";
|
||
return ` (aviso ${level})`;
|
||
}
|
||
return "";
|
||
}
|
||
|
||
export const NOTIFICATION_STATUS_LABELS: Record<NotificationStatus, string> = {
|
||
SENT: "Enviado",
|
||
FAILED: "Falló",
|
||
SKIPPED_NO_EMAIL: "Sin email",
|
||
SKIPPED_GATE: "Fuera de día",
|
||
};
|
||
|
||
export const NOTIFICATION_STATUS_COLORS: Record<NotificationStatus, string> = {
|
||
SENT: "var(--positive)",
|
||
FAILED: "var(--negative)",
|
||
SKIPPED_NO_EMAIL: "var(--muted)",
|
||
SKIPPED_GATE: "var(--muted-2)",
|
||
};
|