Files
jorgecuadros-platform/apps/web/src/lib/labels.ts
T
rmancinasandClaude Opus 5 33833c3af9
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m30s
Build and Push Images / Build jorgecuadros-api (push) Failing after 3h13m42s
feat(notificaciones): one send log across servicios and pólizas
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>
2026-08-02 03:01:03 -07:00

438 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 202526",
"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)",
};