Files
jorgecuadros-platform/apps/web/src/lib/labels.ts
T
rmancinasandClaude Opus 4.8 f1ef1c70b3 wip: ops admin panel + migration sync + crud/rbac phase-5 snapshot
Working-tree checkpoint of in-progress work carried across prior
sessions on the feat/crud-rbac branch, committed so it lands on the
remote alongside the CI changes.

- Operaciones admin panel: apps/api/src/ops (ingest upload, backup /
  restore / re-import jobs) wired into app.module + RBAC abilities, and
  the apps/web/src/app/operaciones page. docker-compose gets INGEST_DIR
  / BACKUP_DIR volumes; .gitignore excludes migration/ingest + backups.
- migration/sync.py plus transform_*.py / run_all / config / dbenv /
  blob_extract adjustments for the additive sync path.
- crud/rbac phase-5 web bits: AppShell, api/labels/types libs, globals.
- schema.prisma + PLAN/RESUME doc updates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 19:01:36 -07:00

380 lines
11 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",
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: "◐",
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;
}