The "Flags del envío" panel lived inside the Servicios tab and only
governed the four bulk jobs. The pólizas half had no debug at all, so
there was no way to test a renewal notice without mailing a real
customer. The panel now lives in the /notificaciones shell above the
tabs and both halves read it.
`debug` on the renewal path diverts to the same override inbox as the
servicios jobs and deliberately does NOT write the `RenewalNotice` row
or advance the sweep's `lastSuccessfulAt` — the customer was not
notified, so nothing may gate the letter they are still owed.
`ignoreDayRestriction` and `useEmailLimit` stay estado-de-cuenta-only
and are labelled as such.
Both automatic sweeps are now operator-editable. The renewal cadence
was a `@Cron("0 6 * * *")` literal and servicios had no automatic run
at all; both now resolve through `NotificationScheduleService`, which
stores the cadence in `app_settings` and reinstalls the cron job on
save — no redeploy, no restart. Defaults preserve current behaviour:
pólizas 06:00 daily, servicios off. A scheduled run never inherits the
UI flags; it always sends for real.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
310 lines
11 KiB
TypeScript
310 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { useCan } from "@/lib/abilities";
|
|
import {
|
|
getNotificationSchedules,
|
|
setNotificationSchedule,
|
|
type NotificationSchedule,
|
|
type NotificationSchedules,
|
|
type ScheduleKind,
|
|
} from "@/lib/api";
|
|
import { formatDateTime } from "@/lib/labels";
|
|
|
|
/**
|
|
* When the two automatic envíos run.
|
|
*
|
|
* Both cadences used to be source code: pólizas barría a las 06:00 desde un
|
|
* `@Cron` en el servidor y servicios no corría solo en absoluto. Cambiar
|
|
* cualquiera de los dos era un redeploy. Ahora se guardan en `app_settings` y
|
|
* el servidor reinstala el job al guardar — sin reinicio.
|
|
*
|
|
* Los flags de la tarjeta de arriba NO se aplican aquí: una corrida
|
|
* automática siempre manda de verdad.
|
|
*/
|
|
|
|
const KIND_LABEL: Record<ScheduleKind, string> = {
|
|
servicios: "Servicios",
|
|
polizas: "Pólizas",
|
|
};
|
|
|
|
const KIND_HINT: Record<ScheduleKind, string> = {
|
|
servicios:
|
|
"Ejecuta los cuatro envíos en orden, igual que el botón «Ejecutar todos». El estado de cuenta sigue respetando sus gates de lunes/miércoles/viernes.",
|
|
polizas:
|
|
"Barrido de avisos de renovación: 30 y 15 días antes del vencimiento, y 7 días después.",
|
|
};
|
|
|
|
const DAYS = [
|
|
{ value: 0, label: "Dom" },
|
|
{ value: 1, label: "Lun" },
|
|
{ value: 2, label: "Mar" },
|
|
{ value: 3, label: "Mié" },
|
|
{ value: 4, label: "Jue" },
|
|
{ value: 5, label: "Vie" },
|
|
{ value: 6, label: "Sáb" },
|
|
];
|
|
|
|
function timeValue(s: NotificationSchedule): string {
|
|
return `${String(s.hour).padStart(2, "0")}:${String(s.minute).padStart(2, "0")}`;
|
|
}
|
|
|
|
function describe(s: NotificationSchedule): string {
|
|
if (!s.enabled) return "Desactivado — solo se envía manualmente.";
|
|
const days = s.weekdays.length
|
|
? s.weekdays
|
|
.map((d) => DAYS.find((x) => x.value === d)?.label ?? d)
|
|
.join(", ")
|
|
: "todos los días";
|
|
return `${days} a las ${timeValue(s)} (hora de Tijuana).`;
|
|
}
|
|
|
|
export function NotificationScheduleCard() {
|
|
const canEdit = useCan("setting:manage");
|
|
|
|
const [schedules, setSchedules] = useState<NotificationSchedules | null>(null);
|
|
const [drafts, setDrafts] = useState<Partial<Record<ScheduleKind, NotificationSchedule>>>({});
|
|
const [editing, setEditing] = useState<ScheduleKind | null>(null);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [saved, setSaved] = useState<ScheduleKind | null>(null);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
setSchedules(await getNotificationSchedules());
|
|
setError(null);
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e));
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
function startEdit(kind: ScheduleKind) {
|
|
if (!schedules) return;
|
|
setDrafts((d) => ({ ...d, [kind]: { ...schedules[kind].value } }));
|
|
setEditing(kind);
|
|
setSaved(null);
|
|
setError(null);
|
|
}
|
|
|
|
async function save(kind: ScheduleKind) {
|
|
const draft = drafts[kind];
|
|
if (!draft) return;
|
|
setSaving(true);
|
|
setError(null);
|
|
try {
|
|
const result = await setNotificationSchedule(kind, draft);
|
|
setSchedules((prev) => (prev ? { ...prev, [kind]: result } : prev));
|
|
setEditing(null);
|
|
setSaved(kind);
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
if (!schedules) {
|
|
return (
|
|
<section className="card" style={{ padding: 20 }}>
|
|
<h2 className="section-title">Programación de envíos</h2>
|
|
{error ? (
|
|
<div className="state-box state-error" style={{ marginTop: 12 }}>
|
|
{error}
|
|
</div>
|
|
) : (
|
|
<p className="muted small" style={{ marginTop: 8, marginBottom: 0 }}>
|
|
Cargando…
|
|
</p>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<section className="card" style={{ padding: 20 }}>
|
|
<h2 className="section-title">Programación de envíos</h2>
|
|
<p className="muted small" style={{ marginTop: 4, marginBottom: 0, maxWidth: 660 }}>
|
|
Cuándo corre solo cada envío. Los cambios aplican de inmediato, sin
|
|
reiniciar el servidor. Una corrida automática nunca usa los flags de
|
|
arriba: siempre manda a los clientes reales.
|
|
</p>
|
|
|
|
{error && (
|
|
<div className="state-box state-error" style={{ marginTop: 12 }}>
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ display: "grid", gap: 12, marginTop: 14 }}>
|
|
{(Object.keys(KIND_LABEL) as ScheduleKind[]).map((kind) => {
|
|
const current = schedules[kind];
|
|
const draft = drafts[kind];
|
|
const isEditing = editing === kind && draft;
|
|
|
|
return (
|
|
<article
|
|
key={kind}
|
|
style={{
|
|
border: "1px solid var(--line)",
|
|
borderRadius: "var(--radius-sm)",
|
|
padding: 14,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "flex-start",
|
|
gap: 12,
|
|
flexWrap: "wrap",
|
|
}}
|
|
>
|
|
<div>
|
|
<strong>{KIND_LABEL[kind]}</strong>
|
|
<p className="muted small" style={{ margin: "4px 0 0", maxWidth: 560 }}>
|
|
{KIND_HINT[kind]}
|
|
</p>
|
|
</div>
|
|
{canEdit && !isEditing && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-outline btn-sm"
|
|
onClick={() => startEdit(kind)}
|
|
>
|
|
Editar
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{isEditing ? (
|
|
<div style={{ marginTop: 12 }}>
|
|
<label
|
|
className="field"
|
|
style={{ display: "flex", gap: 8, alignItems: "center", marginBottom: 10 }}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={draft.enabled}
|
|
disabled={saving}
|
|
onChange={(e) =>
|
|
setDrafts((d) => ({
|
|
...d,
|
|
[kind]: { ...draft, enabled: e.target.checked },
|
|
}))
|
|
}
|
|
/>
|
|
<span className="small">
|
|
<strong>Corrida automática activada</strong>
|
|
</span>
|
|
</label>
|
|
|
|
<label className="field" style={{ maxWidth: 160, marginBottom: 10 }}>
|
|
<span className="field-label">Hora (Tijuana)</span>
|
|
<input
|
|
className="input"
|
|
type="time"
|
|
value={timeValue(draft)}
|
|
disabled={saving || !draft.enabled}
|
|
onChange={(e) => {
|
|
const [h, m] = e.target.value.split(":").map(Number);
|
|
setDrafts((d) => ({
|
|
...d,
|
|
[kind]: {
|
|
...draft,
|
|
hour: Number.isFinite(h) ? h : draft.hour,
|
|
minute: Number.isFinite(m) ? m : draft.minute,
|
|
},
|
|
}));
|
|
}}
|
|
/>
|
|
</label>
|
|
|
|
<div className="field" style={{ marginBottom: 10 }}>
|
|
<span className="field-label">
|
|
Días (ninguno seleccionado = todos los días)
|
|
</span>
|
|
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginTop: 4 }}>
|
|
{DAYS.map((d) => {
|
|
const on = draft.weekdays.includes(d.value);
|
|
return (
|
|
<button
|
|
key={d.value}
|
|
type="button"
|
|
className={`btn btn-sm ${on ? "btn-primary" : "btn-outline"}`}
|
|
disabled={saving || !draft.enabled}
|
|
onClick={() =>
|
|
setDrafts((prev) => ({
|
|
...prev,
|
|
[kind]: {
|
|
...draft,
|
|
weekdays: on
|
|
? draft.weekdays.filter((x) => x !== d.value)
|
|
: [...draft.weekdays, d.value].sort(),
|
|
},
|
|
}))
|
|
}
|
|
>
|
|
{d.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="row-actions">
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary btn-sm"
|
|
disabled={saving}
|
|
onClick={() => void save(kind)}
|
|
>
|
|
{saving ? "Guardando…" : "Guardar"}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-outline btn-sm"
|
|
disabled={saving}
|
|
onClick={() => {
|
|
setEditing(null);
|
|
setError(null);
|
|
}}
|
|
>
|
|
Cancelar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div style={{ marginTop: 10 }}>
|
|
<p className="small" style={{ margin: 0 }}>
|
|
{describe(current.value)}
|
|
</p>
|
|
<p className="section-note" style={{ marginTop: 6, marginBottom: 0 }}>
|
|
<code>{current.cron}</code>
|
|
{current.nextRun &&
|
|
` · próxima corrida: ${formatDateTime(current.nextRun)}`}
|
|
{current.source === "default" &&
|
|
" · valor por omisión, nadie lo ha cambiado"}
|
|
{current.updatedAt &&
|
|
` · última edición: ${formatDateTime(current.updatedAt)}`}
|
|
{saved === kind && " · guardado"}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</article>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{!canEdit && (
|
|
<p className="section-note" style={{ marginTop: 12, marginBottom: 0 }}>
|
|
Solo un ADMIN puede cambiar la programación.
|
|
</p>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|