bloque 4: campanas por segmentos con medicion (modulo skeen_campanas: segmentos reutilizables con conteo verificado, campanas por WhatsApp y correo con programacion, prueba previa, limite por minuto, pausa por errores, consentimiento por canal y atribucion de citas) + encuesta al salir con NPS (campo propio, envio automatico y recordatorio, reporte con tendencia y desglose); apagado por defecto
This commit is contained in:
@@ -13,7 +13,7 @@ import { RecursosProvider } from './lib/recursos';
|
||||
import { QuickLinksProvider } from './lib/quicklinks';
|
||||
import { PatientDrawerProvider } from './components/PatientDrawer';
|
||||
import ChatNotifications from './lib/chat-notifications';
|
||||
import { useClinicSettings } from './lib/clinic-settings';
|
||||
import { useClinicSettings, useClinicSettingsCargado } from './lib/clinic-settings';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Agenda from './pages/Agenda';
|
||||
import Pacientes from './pages/Pacientes';
|
||||
@@ -32,6 +32,8 @@ import Cortes from './pages/Cortes';
|
||||
import Reportes from './pages/Reportes';
|
||||
import Configuracion from './pages/Configuracion';
|
||||
import Cumpleanos from './pages/Cumpleanos';
|
||||
import Campanas from './pages/Campanas';
|
||||
import Satisfaccion from './pages/Satisfaccion';
|
||||
import Inventario from './pages/Inventario';
|
||||
import Compras from './pages/Compras';
|
||||
import Liquidaciones from './pages/Liquidaciones';
|
||||
@@ -146,6 +148,24 @@ const LiquidacionesRoute: FC = () => {
|
||||
return <Liquidaciones />;
|
||||
};
|
||||
|
||||
// Campañas vive detrás de `campanas.activo`. Apagado, la ruta rebota a "/".
|
||||
const CampanasRoute: FC = () => {
|
||||
const { campanas } = useClinicSettings();
|
||||
const listo = useClinicSettingsCargado();
|
||||
if (!listo) return <FullScreenLoader />;
|
||||
if (!campanas.activo) return <Navigate to="/" replace />;
|
||||
return <Campanas />;
|
||||
};
|
||||
|
||||
// Satisfacción (encuestas de salida) vive detrás de `encuestas.activo`.
|
||||
const SatisfaccionRoute: FC = () => {
|
||||
const { encuestas } = useClinicSettings();
|
||||
const listo = useClinicSettingsCargado();
|
||||
if (!listo) return <FullScreenLoader />;
|
||||
if (!encuestas.activo) return <Navigate to="/" replace />;
|
||||
return <Satisfaccion />;
|
||||
};
|
||||
|
||||
const HomeRoute: FC = () => {
|
||||
const { user, canSee } = useAuth();
|
||||
const { t } = useI18n();
|
||||
@@ -214,6 +234,9 @@ const App: FC = () => {
|
||||
<Route path="/configuracion" element={<RequireAuth href="/configuracion"><Configuracion /></RequireAuth>} />
|
||||
<Route path="/usuarios" element={<Navigate to="/configuracion?tab=usuarios" replace />} />
|
||||
<Route path="/cumpleanos" element={<RequireAuth href="/cumpleanos"><Cumpleanos /></RequireAuth>} />
|
||||
{/* Campañas y Satisfacción: solo con campanas.activo / encuestas.activo. */}
|
||||
<Route path="/campanas" element={<RequireAuth href="/campanas"><CampanasRoute /></RequireAuth>} />
|
||||
<Route path="/satisfaccion" element={<RequireAuth href="/satisfaccion"><SatisfaccionRoute /></RequireAuth>} />
|
||||
<Route path="/inventario" element={<RequireAuth href="/inventario"><Inventario /></RequireAuth>} />
|
||||
{/* Compras solo cuando la clínica la activa (compras.activo). */}
|
||||
<Route path="/compras" element={<RequireAuth href="/compras"><ComprasRoute /></RequireAuth>} />
|
||||
|
||||
101
frontend/src/components/CampanasPanel.tsx
Normal file
101
frontend/src/components/CampanasPanel.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Save, Megaphone } from 'lucide-react';
|
||||
import { Button, Input, toast } from './ui';
|
||||
import {
|
||||
odooApi, CAMPANAS_DEFAULT, type ClinicSettings, type CampanasSettings,
|
||||
} from '../services/odoo';
|
||||
|
||||
interface Props {
|
||||
settings: ClinicSettings;
|
||||
onSaved: (settings: ClinicSettings) => void;
|
||||
}
|
||||
|
||||
const Toggle: FC<{ label: string; checked: boolean; onChange: (v: boolean) => void; ayuda?: string }> = ({ label, checked, onChange, ayuda }) => (
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`mt-0.5 relative h-6 w-11 shrink-0 rounded-full transition-colors ${checked ? 'bg-theme-accent' : 'bg-theme-border-strong'}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition-transform ${checked ? 'translate-x-5' : 'translate-x-0.5'}`} />
|
||||
</button>
|
||||
<span>
|
||||
<span className="block text-sm font-medium text-theme-heading">{label}</span>
|
||||
{ayuda && <span className="block text-xs text-theme-muted">{ayuda}</span>}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
|
||||
// Configuración → Campañas: bloque `campanas`. Apagado (default) no aparece la
|
||||
// sección, no corre el cron y no se envía nada.
|
||||
const CampanasPanel: FC<Props> = ({ settings, onSaved }) => {
|
||||
const [form, setForm] = useState<CampanasSettings>(settings.campanas || CAMPANAS_DEFAULT);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => setForm(settings.campanas || CAMPANAS_DEFAULT), [settings.campanas]);
|
||||
|
||||
const set = <K extends keyof CampanasSettings>(k: K, v: CampanasSettings[K]) =>
|
||||
setForm((prev) => ({ ...prev, [k]: v }));
|
||||
|
||||
const guardar = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await odooApi.putClinicSettings({
|
||||
campanas: { ...form }, motivo: 'Configuración de campañas',
|
||||
});
|
||||
if (res.status === 'success') {
|
||||
onSaved(res.settings);
|
||||
toast.success('Configuración de campañas guardada');
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'No se pudo guardar la configuración de campañas');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-heading text-lg text-theme-heading flex items-center gap-2">
|
||||
<Megaphone size={18} /> Campañas por segmentos
|
||||
</h3>
|
||||
<p className="text-sm text-theme-muted mt-1">
|
||||
Apagado (default) no existe la sección Campañas, no corre ningún proceso y no se envía
|
||||
nada. Encendido, aparecen las pantallas de Campañas y Segmentos.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={guardar} loading={saving}><Save size={16} className="mr-1.5" />Guardar</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Toggle label="Activar campañas" checked={form.activo} onChange={(v) => set('activo', v)}
|
||||
ayuda="Muestra la sección Campañas y habilita el proceso automático cada 5 minutos." />
|
||||
<Toggle label="Exigir consentimiento de marketing" checked={form.exigir_consentimiento}
|
||||
onChange={(v) => set('exigir_consentimiento', v)}
|
||||
ayuda="Un paciente sin consentimiento nunca recibe una campaña (los avisos de cita sí siguen)." />
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<Input type="number" min={1} max={600} label="Envíos por minuto"
|
||||
value={form.envios_por_minuto}
|
||||
onChange={(e) => set('envios_por_minuto', Number(e.target.value) || 1)} />
|
||||
<Input type="number" min={1} max={100} label="Errores seguidos para pausar"
|
||||
value={form.pausar_tras_errores}
|
||||
onChange={(e) => set('pausar_tras_errores', Number(e.target.value) || 1)} />
|
||||
<Input type="number" min={1} max={90} label="Ventana de atribución (días)"
|
||||
value={form.ventana_atribucion_dias}
|
||||
onChange={(e) => set('ventana_atribucion_dias', Number(e.target.value) || 1)} />
|
||||
<Input type="time" label="Hora de envío (recurrentes)" value={form.hora_envio}
|
||||
onChange={(e) => set('hora_envio', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CampanasPanel;
|
||||
131
frontend/src/components/EncuestasPanel.tsx
Normal file
131
frontend/src/components/EncuestasPanel.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Save, Smile } from 'lucide-react';
|
||||
import { Button, Input, TextArea, toast } from './ui';
|
||||
import {
|
||||
odooApi, ENCUESTAS_DEFAULT, type ClinicSettings, type EncuestasSettings,
|
||||
} from '../services/odoo';
|
||||
|
||||
interface Props {
|
||||
settings: ClinicSettings;
|
||||
onSaved: (settings: ClinicSettings) => void;
|
||||
}
|
||||
|
||||
const Toggle: FC<{ label: string; checked: boolean; onChange: (v: boolean) => void; ayuda?: string }> = ({ label, checked, onChange, ayuda }) => (
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`mt-0.5 relative h-6 w-11 shrink-0 rounded-full transition-colors ${checked ? 'bg-theme-accent' : 'bg-theme-border-strong'}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition-transform ${checked ? 'translate-x-5' : 'translate-x-0.5'}`} />
|
||||
</button>
|
||||
<span>
|
||||
<span className="block text-sm font-medium text-theme-heading">{label}</span>
|
||||
{ayuda && <span className="block text-xs text-theme-muted">{ayuda}</span>}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
|
||||
// Configuración → Encuestas: bloque `encuestas`. Apagado (default) no se envía
|
||||
// ninguna encuesta tras completar una cita.
|
||||
const EncuestasPanel: FC<Props> = ({ settings, onSaved }) => {
|
||||
const [form, setForm] = useState<EncuestasSettings>(settings.encuestas || ENCUESTAS_DEFAULT);
|
||||
const [plantillas, setPlantillas] = useState<{ id: number; name: string; activo: boolean; tiene_nps: boolean }[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => setForm(settings.encuestas || ENCUESTAS_DEFAULT), [settings.encuestas]);
|
||||
useEffect(() => {
|
||||
odooApi.getEncuestasConfig()
|
||||
.then((res) => { if (res.status === 'success') setPlantillas(res.plantillas || []); })
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const set = <K extends keyof EncuestasSettings>(k: K, v: EncuestasSettings[K]) =>
|
||||
setForm((prev) => ({ ...prev, [k]: v }));
|
||||
|
||||
const guardar = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await odooApi.putClinicSettings({
|
||||
encuestas: { ...form }, motivo: 'Configuración de encuestas',
|
||||
});
|
||||
if (res.status === 'success') {
|
||||
onSaved(res.settings);
|
||||
toast.success('Configuración de encuestas guardada');
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'No se pudo guardar la configuración de encuestas');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-heading text-lg text-theme-heading flex items-center gap-2">
|
||||
<Smile size={18} /> Encuestas de salida (NPS)
|
||||
</h3>
|
||||
<p className="text-sm text-theme-muted mt-1">
|
||||
Apagado (default) no se envía ninguna encuesta al completar una cita. Crea la plantilla
|
||||
(tipo Encuesta) con una pregunta de recomendación (0-10) en Formularios.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={guardar} loading={saving}><Save size={16} className="mr-1.5" />Guardar</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Toggle label="Activar encuestas de salida" checked={form.activo} onChange={(v) => set('activo', v)}
|
||||
ayuda="Envía la encuesta tras completarse una cita y habilita el reporte de Satisfacción." />
|
||||
<Toggle label="Enviar solo a la primera visita" checked={form.solo_primera_visita}
|
||||
onChange={(v) => set('solo_primera_visita', v)}
|
||||
ayuda="Solo se envía a quien tiene una única visita completada." />
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme-heading mb-1.5">Plantilla de encuesta</label>
|
||||
<select
|
||||
value={form.plantilla_id == null ? '' : String(form.plantilla_id)}
|
||||
onChange={(e) => set('plantilla_id', e.target.value ? Number(e.target.value) : null)}
|
||||
className="w-full rounded-lg border border-theme-border-strong bg-theme-surface px-3 py-2 text-sm text-theme-heading"
|
||||
>
|
||||
<option value="">— Ninguna —</option>
|
||||
{plantillas.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}{p.tiene_nps ? ' · NPS' : ''}{p.activo ? '' : ' (inactiva)'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme-heading mb-1.5">Canal</label>
|
||||
<select value={form.canal} onChange={(e) => set('canal', e.target.value as EncuestasSettings['canal'])}
|
||||
className="w-full rounded-lg border border-theme-border-strong bg-theme-surface px-3 py-2 text-sm text-theme-heading">
|
||||
<option value="whatsapp">WhatsApp</option>
|
||||
<option value="email">Correo</option>
|
||||
<option value="ambos">Ambos</option>
|
||||
</select>
|
||||
</div>
|
||||
<Input type="number" min={0} max={720} label="Enviar tras (horas de completada la cita)"
|
||||
value={form.enviar_tras_horas}
|
||||
onChange={(e) => set('enviar_tras_horas', Number(e.target.value) || 0)} />
|
||||
<Input type="number" min={0} max={720} label="Recordatorio si no responde (horas, 0 = sin recordatorio)"
|
||||
value={form.recordatorio_horas}
|
||||
onChange={(e) => set('recordatorio_horas', Number(e.target.value) || 0)} />
|
||||
</div>
|
||||
|
||||
<TextArea rows={3} label="Agradecimiento (se muestra al terminar la encuesta)"
|
||||
value={form.agradecimiento}
|
||||
onChange={(e) => set('agradecimiento', e.target.value)}
|
||||
placeholder="¡Gracias por tu opinión!" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EncuestasPanel;
|
||||
@@ -38,6 +38,8 @@ import {
|
||||
Download,
|
||||
Repeat,
|
||||
Truck,
|
||||
Megaphone,
|
||||
Smile,
|
||||
} from 'lucide-react';
|
||||
import { Button, Modal, Input } from './ui';
|
||||
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
|
||||
@@ -118,6 +120,8 @@ const MENU_SECTIONS = [
|
||||
const HOME_MEMBRESIA_ITEM = { label: 'Membresías', icon: Repeat, href: '/membresias' };
|
||||
const HOME_COMPRAS_ITEM = { label: 'Compras', icon: Truck, href: '/compras' };
|
||||
const HOME_COMISIONES_ITEM = { label: 'Liquidaciones', icon: Percent, href: '/comisiones/liquidaciones' };
|
||||
const HOME_CAMPANAS_ITEM = { label: 'Campañas', icon: Megaphone, href: '/campanas' };
|
||||
const HOME_SATISFACCION_ITEM = { label: 'Satisfacción', icon: Smile, href: '/satisfaccion' };
|
||||
|
||||
export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subtitle: _subtitle }) => {
|
||||
const location = useLocation();
|
||||
@@ -126,7 +130,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
||||
const { term } = useGlosario();
|
||||
const { t } = useI18n();
|
||||
const navLabel = useNavLabel();
|
||||
const { membresias: membresiasCfg, compras: comprasCfg, comisiones: comisionesCfg, licencia: licenciaCfg } = useClinicSettings();
|
||||
const { membresias: membresiasCfg, compras: comprasCfg, comisiones: comisionesCfg, licencia: licenciaCfg, campanas: campanasCfg, encuestas: encuestasCfg } = useClinicSettings();
|
||||
const topNavItems = useMemo(
|
||||
() => TOP_NAV_ITEMS.map((it) => ({ ...it, label: navLabel(traducirEtiqueta(it.label, term)) })),
|
||||
[term, navLabel]
|
||||
@@ -148,6 +152,16 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
||||
? { ...s, items: [...s.items, HOME_COMISIONES_ITEM] }
|
||||
: s));
|
||||
}
|
||||
if (campanasCfg.activo) {
|
||||
base = base.map((s) => (s.label === 'Analítica y más'
|
||||
? { ...s, items: [...s.items, HOME_CAMPANAS_ITEM] }
|
||||
: s));
|
||||
}
|
||||
if (encuestasCfg.activo) {
|
||||
base = base.map((s) => (s.label === 'Analítica y más'
|
||||
? { ...s, items: [...s.items, HOME_SATISFACCION_ITEM] }
|
||||
: s));
|
||||
}
|
||||
// Licencia en modo bloqueo sin WhatsApp: se ocultan el chat y la sección WACRM.
|
||||
if (!licenciaModuloIncluido(licenciaCfg, 'whatsapp')) {
|
||||
base = base
|
||||
@@ -163,7 +177,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
||||
label: navLabel(s.label),
|
||||
items: s.items.map((it) => ({ ...it, label: navLabel(it.label) })),
|
||||
}));
|
||||
}, [term, navLabel, membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo, licenciaCfg]);
|
||||
}, [term, navLabel, membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo, campanasCfg.activo, encuestasCfg.activo, licenciaCfg]);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [expanded, setExpanded] = useState<string[]>(['Clínica']);
|
||||
const chatUnread = useChatUnread();
|
||||
|
||||
@@ -40,6 +40,8 @@ import {
|
||||
Download,
|
||||
Repeat,
|
||||
Truck,
|
||||
Megaphone,
|
||||
Smile,
|
||||
} from 'lucide-react';
|
||||
import { Button, Modal, Input } from './ui';
|
||||
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
|
||||
@@ -131,6 +133,9 @@ const MEMBRESIA_ITEM = { label: 'Membresías', icon: Repeat, href: '/membresias'
|
||||
const COMPRAS_ITEM = { label: 'Compras', icon: Truck, href: '/compras' };
|
||||
// La sección Liquidaciones solo existe con `comisiones.activo=true`.
|
||||
const COMISIONES_ITEM = { label: 'Liquidaciones', icon: Percent, href: '/comisiones/liquidaciones' };
|
||||
// Campañas y Satisfacción solo existen con `campanas.activo` / `encuestas.activo`.
|
||||
const CAMPANAS_ITEM = { label: 'Campañas', icon: Megaphone, href: '/campanas' };
|
||||
const SATISFACCION_ITEM = { label: 'Satisfacción', icon: Smile, href: '/satisfaccion' };
|
||||
|
||||
export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
||||
const location = useLocation();
|
||||
@@ -139,7 +144,7 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
||||
const { term } = useGlosario();
|
||||
const { t } = useI18n();
|
||||
const navLabel = useNavLabel();
|
||||
const { membresias: membresiasCfg, compras: comprasCfg, comisiones: comisionesCfg, licencia: licenciaCfg } = useClinicSettings();
|
||||
const { membresias: membresiasCfg, compras: comprasCfg, comisiones: comisionesCfg, licencia: licenciaCfg, campanas: campanasCfg, encuestas: encuestasCfg } = useClinicSettings();
|
||||
const menuGroupsT = useMemo(() => {
|
||||
let base = menuGroups;
|
||||
if (membresiasCfg.activo) {
|
||||
@@ -157,6 +162,16 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
||||
? { ...g, items: [...g.items, COMISIONES_ITEM] }
|
||||
: g));
|
||||
}
|
||||
if (campanasCfg.activo) {
|
||||
base = base.map((g) => (g.label === 'Comunicación'
|
||||
? { ...g, items: [...g.items, CAMPANAS_ITEM] }
|
||||
: g));
|
||||
}
|
||||
if (encuestasCfg.activo) {
|
||||
base = base.map((g) => (g.label === 'Analítica'
|
||||
? { ...g, items: [...g.items, SATISFACCION_ITEM] }
|
||||
: g));
|
||||
}
|
||||
// Licencia en modo bloqueo sin WhatsApp: se ocultan el chat y la sección WACRM.
|
||||
if (!licenciaModuloIncluido(licenciaCfg, 'whatsapp')) {
|
||||
base = base
|
||||
@@ -169,7 +184,7 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
||||
label: navLabel(g.label),
|
||||
items: g.items.map((it) => ({ ...it, label: navLabel(it.label) })),
|
||||
}));
|
||||
}, [term, navLabel, membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo, licenciaCfg]);
|
||||
}, [term, navLabel, membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo, campanasCfg.activo, encuestasCfg.activo, licenciaCfg]);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const chatUnread = useChatUnread();
|
||||
// Sidebar colapsable (modo iconos). Preferencia persistente; en pantallas
|
||||
|
||||
@@ -241,6 +241,33 @@ const CampoFormulario: FC<Props> = ({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case 'nps': {
|
||||
const n = Number(valor);
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
{Array.from({ length: 11 }, (_, i) => i).map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
data-value={v}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(v)}
|
||||
className={`rounded-xl border py-3 text-base font-medium ${
|
||||
n === v ? 'border-theme-border-strong bg-theme-accent text-theme-inverse' : 'border-theme-border bg-theme-surface text-theme-heading'
|
||||
}`}
|
||||
>
|
||||
{v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-1 flex justify-between text-[11px] text-theme-muted">
|
||||
<span>0</span>
|
||||
<span>10</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case 'firma':
|
||||
return (
|
||||
<FirmaCanvas value={firma ?? null} onChange={(v) => onFirma?.(v)} disabled={disabled} />
|
||||
|
||||
@@ -12,6 +12,7 @@ export const CAMPO_TIPOS: { tipo: FormularioCampoTipo; label: string; descripcio
|
||||
{ tipo: 'multi_seleccion', label: 'Varias opciones', descripcion: 'Elegir varias' },
|
||||
{ tipo: 'checkbox', label: 'Casilla', descripcion: 'Sí / no' },
|
||||
{ tipo: 'escala', label: 'Escala 1-10', descripcion: 'Nivel o intensidad' },
|
||||
{ tipo: 'nps', label: 'Recomendación (0-10)', descripcion: 'Pregunta NPS de la encuesta de satisfacción' },
|
||||
{ tipo: 'firma', label: 'Firma', descripcion: 'Firma con el dedo' },
|
||||
{ tipo: 'foto', label: 'Foto', descripcion: 'Cámara o galería' },
|
||||
{ tipo: 'archivo', label: 'Archivo', descripcion: 'Documento o estudio' },
|
||||
@@ -97,6 +98,12 @@ export function validarLocal(
|
||||
if (!(n >= 1 && n <= 10)) errores[campo.clave] = 'Debe ser del 1 al 10';
|
||||
break;
|
||||
}
|
||||
case 'nps': {
|
||||
if (vacio) { if (campo.obligatorio) errores[campo.clave] = 'Elige un valor del 0 al 10'; break; }
|
||||
const n = Number(v);
|
||||
if (!(n >= 0 && n <= 10)) errores[campo.clave] = 'Debe ser del 0 al 10';
|
||||
break;
|
||||
}
|
||||
case 'numero':
|
||||
if (vacio) { if (campo.obligatorio) errores[campo.clave] = 'Falta este dato'; break; }
|
||||
if (Number.isNaN(Number(v))) errores[campo.clave] = 'Escribe un número';
|
||||
@@ -118,5 +125,6 @@ export function valorLegible(campo: FormularioCampo, valor: unknown): string {
|
||||
if (Array.isArray(valor)) return valor.length ? valor.join(', ') : '—';
|
||||
if (typeof valor === 'boolean') return valor ? 'Sí' : 'No';
|
||||
if (campo.tipo === 'escala') return `${valor} / 10`;
|
||||
if (campo.tipo === 'nps') return `${valor} / 10 (NPS)`;
|
||||
return String(valor);
|
||||
}
|
||||
|
||||
@@ -66,6 +66,10 @@ const MENU_ROLES: Record<string, FrontendRole[]> = {
|
||||
'/reportes?tab=exportar': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
||||
'/configuracion': ['admin', 'gerencia'],
|
||||
'/cumpleanos': ['admin', 'recepcion', 'gerencia', 'marketing'],
|
||||
// Campañas y satisfacción (apagadas por defecto: solo se pintan con
|
||||
// campanas.activo / encuestas.activo). Se listan aquí para canSee.
|
||||
'/campanas': ['admin', 'recepcion', 'gerencia', 'marketing'],
|
||||
'/satisfaccion': ['admin', 'recepcion', 'gerencia', 'medico'],
|
||||
'/wacrm/messages': ['admin', 'recepcion', 'gerencia', 'marketing'],
|
||||
'/wacrm/leads': ['admin', 'recepcion', 'gerencia', 'marketing'],
|
||||
// El chat se lista explícitamente para dejar fuera a marketing (antes, al no
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_DEFAULT, COBRO_AUTOMATICO_DEFAULT, RESERVAS_ONLINE_DEFAULT, ANTICIPO_DEFAULT, RECURSOS_DEFAULT, COMPRAS_DEFAULT, COMISIONES_DEFAULT, LEALTAD_DEFAULT, PROPINAS_DEFAULT, LICENCIA_DEFAULT, LICENCIA_LIMITES_DEFAULT, PORTAL_CLIENTE_DEFAULT } from '../services/odoo';
|
||||
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_DEFAULT, COBRO_AUTOMATICO_DEFAULT, RESERVAS_ONLINE_DEFAULT, ANTICIPO_DEFAULT, RECURSOS_DEFAULT, COMPRAS_DEFAULT, COMISIONES_DEFAULT, LEALTAD_DEFAULT, PROPINAS_DEFAULT, CAMPANAS_DEFAULT, ENCUESTAS_DEFAULT, LICENCIA_DEFAULT, LICENCIA_LIMITES_DEFAULT, PORTAL_CLIENTE_DEFAULT } from '../services/odoo';
|
||||
|
||||
// Ajustes de la clínica compartidos por la sesión (nombre, moneda e impuestos).
|
||||
// Se leen una sola vez del servidor y se avisa a quien los use cuando cambian.
|
||||
@@ -25,6 +25,8 @@ const normalizar = (settings?: Partial<ClinicSettings> | null): ClinicSettings =
|
||||
comisiones: { ...COMISIONES_DEFAULT, ...(settings?.comisiones || {}) },
|
||||
lealtad: { ...LEALTAD_DEFAULT, ...(settings?.lealtad || {}) },
|
||||
propinas: { ...PROPINAS_DEFAULT, ...(settings?.propinas || {}) },
|
||||
campanas: { ...CAMPANAS_DEFAULT, ...(settings?.campanas || {}) },
|
||||
encuestas: { ...ENCUESTAS_DEFAULT, ...(settings?.encuestas || {}) },
|
||||
licencia: {
|
||||
...LICENCIA_DEFAULT,
|
||||
...(settings?.licencia || {}),
|
||||
@@ -37,6 +39,19 @@ let actual: ClinicSettings = normalizar(null);
|
||||
let peticion: Promise<ClinicSettings> | null = null;
|
||||
const oyentes = new Set<(s: ClinicSettings) => void>();
|
||||
|
||||
// Bandera de "ya se intentó cargar" para las rutas detrás de un interruptor de
|
||||
// módulo (`campanas.activo`, `encuestas.activo`): sin ella, una carga directa
|
||||
// por URL vería los defaults (apagados) y rebotaría a "/" antes de que los
|
||||
// ajustes llegaran del servidor.
|
||||
let cargado = false;
|
||||
const oyentesCargado = new Set<(v: boolean) => void>();
|
||||
|
||||
const marcarCargado = () => {
|
||||
if (cargado) return;
|
||||
cargado = true;
|
||||
oyentesCargado.forEach((f) => f(true));
|
||||
};
|
||||
|
||||
const publicar = (s: ClinicSettings) => {
|
||||
actual = s;
|
||||
oyentes.forEach((f) => f(s));
|
||||
@@ -47,6 +62,7 @@ export const cargarClinicSettings = (): Promise<ClinicSettings> => {
|
||||
// público (reservas, pago, formularios) no lo tiene. Se devuelven los
|
||||
// defaults para no ensuciar la consola con un 401.
|
||||
if (typeof window !== 'undefined' && !window.localStorage.getItem('skeen_token')) {
|
||||
marcarCargado();
|
||||
return Promise.resolve(actual);
|
||||
}
|
||||
if (peticion) return peticion;
|
||||
@@ -56,7 +72,7 @@ export const cargarClinicSettings = (): Promise<ClinicSettings> => {
|
||||
return actual;
|
||||
})
|
||||
.catch(() => actual)
|
||||
.finally(() => { peticion = null; });
|
||||
.finally(() => { peticion = null; marcarCargado(); });
|
||||
return peticion;
|
||||
};
|
||||
|
||||
@@ -80,3 +96,20 @@ export const useClinicSettings = (): ClinicSettings => {
|
||||
}, []);
|
||||
return s;
|
||||
};
|
||||
|
||||
/**
|
||||
* True cuando los ajustes ya se pidieron al servidor una vez (con éxito o no).
|
||||
* Las rutas que viven detrás de un interruptor de módulo lo usan para no
|
||||
* rebotar a "/" con los defaults mientras la petición está en vuelo.
|
||||
*/
|
||||
export const useClinicSettingsCargado = (): boolean => {
|
||||
const [v, setV] = useState(cargado);
|
||||
useEffect(() => {
|
||||
if (cargado) { setV(true); return () => {}; }
|
||||
const f = (x: boolean) => setV(x);
|
||||
oyentesCargado.add(f);
|
||||
void cargarClinicSettings();
|
||||
return () => { oyentesCargado.delete(f); };
|
||||
}, []);
|
||||
return v;
|
||||
};
|
||||
|
||||
@@ -327,6 +327,8 @@ export const es: Dict = {
|
||||
'config.tabs.comisiones': 'Comisiones',
|
||||
'config.tabs.lealtad': 'Lealtad',
|
||||
'config.tabs.propinas': 'Propinas',
|
||||
'config.tabs.campanas': 'Campañas',
|
||||
'config.tabs.encuestas': 'Encuestas',
|
||||
'config.tabs.terminologia': 'Terminología',
|
||||
'config.tabs.usuarios': 'Usuarios',
|
||||
'config.tabs.roles': 'Roles',
|
||||
@@ -848,6 +850,125 @@ export const es: Dict = {
|
||||
'portal.common.currency': 'Moneda',
|
||||
'portal.common.status': 'Estado',
|
||||
'portal.common.date': 'Fecha',
|
||||
// --- campañas por segmentos ---
|
||||
'camp.title': 'Campañas',
|
||||
'camp.subtitle': 'Segmentos de pacientes y campañas con medición',
|
||||
'camp.tab.campanas': 'Campañas',
|
||||
'camp.tab.segmentos': 'Segmentos',
|
||||
'camp.new': 'Nueva campaña',
|
||||
'camp.newSegmento': 'Nuevo segmento',
|
||||
'camp.editSegmento': 'Editar segmento',
|
||||
'camp.edit': 'Editar campaña',
|
||||
'camp.empty': 'Aún no hay campañas.',
|
||||
'camp.emptySegmentos': 'Aún no hay segmentos.',
|
||||
'camp.off': 'El módulo de campañas está apagado. Actívalo en Configuración.',
|
||||
'camp.field.name': 'Nombre',
|
||||
'camp.field.descripcion': 'Descripción',
|
||||
'camp.field.activo': 'Activa',
|
||||
'camp.field.segmento': 'Segmento',
|
||||
'camp.field.canal': 'Canal',
|
||||
'camp.field.plantilla': 'Mensaje',
|
||||
'camp.field.enlace': 'Enlace',
|
||||
'camp.field.programacion': 'Programación',
|
||||
'camp.field.fecha': 'Fecha programada',
|
||||
'camp.field.hora': 'Hora',
|
||||
'camp.field.estado': 'Estado',
|
||||
'camp.field.notas': 'Notas',
|
||||
'camp.field.criterios': 'Criterios',
|
||||
'camp.vars': 'Variables disponibles',
|
||||
'camp.consentNote': 'Un paciente sin consentimiento de marketing nunca recibe la campaña (los avisos de cita sí siguen).',
|
||||
'camp.preview': 'Vista previa',
|
||||
'camp.previewCount': '{n} pacientes',
|
||||
'camp.previewSample': 'Muestra',
|
||||
'camp.previewRecalc': 'Recalcular',
|
||||
'camp.previewEmpty': 'Ajusta criterios para ver la vista previa.',
|
||||
'camp.criterio.ultimaVisita': 'Última visita hace más de N días',
|
||||
'camp.criterio.sinVisita': 'Sin visita en N meses',
|
||||
'camp.criterio.cumpleMes': 'Cumpleaños este mes',
|
||||
'camp.criterio.cumpleProx': 'Cumpleaños en los próximos N días',
|
||||
'camp.criterio.nivel': 'Nivel de lealtad',
|
||||
'camp.criterio.gasto': 'Gasto acumulado ≥',
|
||||
'camp.criterio.sucursal': 'Sucursal',
|
||||
'camp.criterio.servicio': 'Servicio que SÍ ha tomado',
|
||||
'camp.criterio.servicioNunca': 'Servicio que NUNCA ha tomado',
|
||||
'camp.criterio.membresia': 'Con membresía activa',
|
||||
'camp.criterio.paquetes': 'Con paquetes con sesiones',
|
||||
'camp.criterio.saldo': 'Con saldo pendiente',
|
||||
'camp.criterio.consentimiento': 'Consentimiento de marketing',
|
||||
'camp.opt.cualquiera': 'Cualquiera',
|
||||
'camp.opt.con': 'Con',
|
||||
'camp.opt.sin': 'Sin',
|
||||
'camp.opt.ninguno': '—',
|
||||
'camp.action.lanzar': 'Lanzar',
|
||||
'camp.action.pausar': 'Pausar',
|
||||
'camp.action.reanudar': 'Reanudar',
|
||||
'camp.action.cancelar': 'Cancelar',
|
||||
'camp.action.procesar': 'Procesar cola',
|
||||
'camp.action.prueba': 'Envío de prueba',
|
||||
'camp.action.metricas': 'Medición',
|
||||
'camp.action.envios': 'Envíos',
|
||||
'camp.prueba.title': 'Envío de prueba',
|
||||
'camp.prueba.telefono': 'Número',
|
||||
'camp.prueba.correo': 'Correo',
|
||||
'camp.prueba.send': 'Enviar prueba',
|
||||
'camp.prueba.hint': 'Sin credenciales reales o en modo prueba el envío queda como simulado en la bitácora.',
|
||||
'camp.metric.alcanzados': 'Alcanzados',
|
||||
'camp.metric.enviados': 'Enviados',
|
||||
'camp.metric.entregados': 'Entregados',
|
||||
'camp.metric.respuestas': 'Respuestas',
|
||||
'camp.metric.citas': 'Citas atribuidas',
|
||||
'camp.metric.valor': 'Valor atribuido',
|
||||
'camp.metric.conversion': 'Conversión',
|
||||
'camp.metric.noDisponible': 'No disponible',
|
||||
'camp.metric.ventana': 'Ventana de atribución: {n} días',
|
||||
'camp.estado.borrador': 'Borrador',
|
||||
'camp.estado.programada': 'Programada',
|
||||
'camp.estado.en_ejecucion': 'En ejecución',
|
||||
'camp.estado.pausada': 'Pausada',
|
||||
'camp.estado.terminada': 'Terminada',
|
||||
'camp.estado.cancelada': 'Cancelada',
|
||||
'camp.saved': 'Campaña guardada.',
|
||||
'camp.segmentoSaved': 'Segmento guardado.',
|
||||
'camp.deleted': 'Eliminado.',
|
||||
'camp.processing': 'Cola procesada.',
|
||||
'camp.confirmDelete': '¿Eliminar definitivamente?',
|
||||
'camp.envios.title': 'Bitácora de envíos',
|
||||
'camp.envios.esPrueba': 'Prueba',
|
||||
'camp.export.metricas': 'Exportar medición',
|
||||
'camp.export.segmento': 'Exportar muestra',
|
||||
'nav.campanas': 'Campañas',
|
||||
'nav.satisfaccion': 'Satisfacción',
|
||||
// --- satisfacción (NPS) ---
|
||||
'nps.title': 'Satisfacción',
|
||||
'nps.subtitle': 'Encuestas de salida y recomendación (NPS)',
|
||||
'nps.off': 'Las encuestas de salida están apagadas. Actívalas en Configuración.',
|
||||
'nps.period': 'Periodo',
|
||||
'nps.general': 'NPS del periodo',
|
||||
'nps.promotores': 'Promotores (9-10)',
|
||||
'nps.pasivos': 'Pasivos (7-8)',
|
||||
'nps.detractores': 'Detractores (0-6)',
|
||||
'nps.respuestas': 'Respuestas',
|
||||
'nps.trend': 'Tendencia por mes',
|
||||
'nps.byBranch': 'Por sucursal',
|
||||
'nps.byDoctor': 'Por médico',
|
||||
'nps.byService': 'Por servicio',
|
||||
'nps.responses': 'Respuestas con comentario',
|
||||
'nps.comment': 'Comentario',
|
||||
'nps.reply': 'Responder por WhatsApp',
|
||||
'nps.replyPlaceholder': 'Escribe el mensaje al paciente…',
|
||||
'nps.replySend': 'Enviar',
|
||||
'nps.openRecord': 'Abrir ficha',
|
||||
'nps.noData': 'Sin respuestas en el periodo.',
|
||||
'nps.score': 'Puntaje',
|
||||
'nps.category.promotor': 'Promotor',
|
||||
'nps.category.pasivo': 'Pasivo',
|
||||
'nps.category.detractor': 'Detractor',
|
||||
'nps.export': 'Exportar reporte',
|
||||
'nps.exportRespuestas': 'Exportar respuestas',
|
||||
'nps.process': 'Procesar encuestas ahora',
|
||||
'nps.processed': 'Encuestas procesadas.',
|
||||
'nps.replySent': 'Respuesta enviada.',
|
||||
'nps.replySimulated': 'Modo prueba: la respuesta quedó registrada como simulada.',
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------
|
||||
@@ -1135,6 +1256,8 @@ export const en: Dict = {
|
||||
'config.tabs.comisiones': 'Commissions',
|
||||
'config.tabs.lealtad': 'Loyalty',
|
||||
'config.tabs.propinas': 'Tips',
|
||||
'config.tabs.campanas': 'Campaigns',
|
||||
'config.tabs.encuestas': 'Surveys',
|
||||
'config.tabs.terminologia': 'Terminology',
|
||||
'config.tabs.usuarios': 'Users',
|
||||
'config.tabs.roles': 'Roles',
|
||||
@@ -1651,6 +1774,125 @@ export const en: Dict = {
|
||||
'portal.common.currency': 'Currency',
|
||||
'portal.common.status': 'Status',
|
||||
'portal.common.date': 'Date',
|
||||
// --- campaigns by segment ---
|
||||
'camp.title': 'Campaigns',
|
||||
'camp.subtitle': 'Patient segments and campaigns with measurement',
|
||||
'camp.tab.campanas': 'Campaigns',
|
||||
'camp.tab.segmentos': 'Segments',
|
||||
'camp.new': 'New campaign',
|
||||
'camp.newSegmento': 'New segment',
|
||||
'camp.editSegmento': 'Edit segment',
|
||||
'camp.edit': 'Edit campaign',
|
||||
'camp.empty': 'No campaigns yet.',
|
||||
'camp.emptySegmentos': 'No segments yet.',
|
||||
'camp.off': 'The campaigns module is off. Enable it in Settings.',
|
||||
'camp.field.name': 'Name',
|
||||
'camp.field.descripcion': 'Description',
|
||||
'camp.field.activo': 'Active',
|
||||
'camp.field.segmento': 'Segment',
|
||||
'camp.field.canal': 'Channel',
|
||||
'camp.field.plantilla': 'Message',
|
||||
'camp.field.enlace': 'Link',
|
||||
'camp.field.programacion': 'Schedule',
|
||||
'camp.field.fecha': 'Scheduled date',
|
||||
'camp.field.hora': 'Time',
|
||||
'camp.field.estado': 'Status',
|
||||
'camp.field.notas': 'Notes',
|
||||
'camp.field.criterios': 'Criteria',
|
||||
'camp.vars': 'Available variables',
|
||||
'camp.consentNote': 'A patient without marketing consent never receives the campaign (appointment notices still do).',
|
||||
'camp.preview': 'Preview',
|
||||
'camp.previewCount': '{n} patients',
|
||||
'camp.previewSample': 'Sample',
|
||||
'camp.previewRecalc': 'Recalculate',
|
||||
'camp.previewEmpty': 'Adjust criteria to see the preview.',
|
||||
'camp.criterio.ultimaVisita': 'Last visit more than N days ago',
|
||||
'camp.criterio.sinVisita': 'No visit in N months',
|
||||
'camp.criterio.cumpleMes': 'Birthday this month',
|
||||
'camp.criterio.cumpleProx': 'Birthday in the next N days',
|
||||
'camp.criterio.nivel': 'Loyalty level',
|
||||
'camp.criterio.gasto': 'Accumulated spend ≥',
|
||||
'camp.criterio.sucursal': 'Branch',
|
||||
'camp.criterio.servicio': 'Service they HAVE had',
|
||||
'camp.criterio.servicioNunca': 'Service they NEVER had',
|
||||
'camp.criterio.membresia': 'With active membership',
|
||||
'camp.criterio.paquetes': 'With packages with sessions',
|
||||
'camp.criterio.saldo': 'With pending balance',
|
||||
'camp.criterio.consentimiento': 'Marketing consent',
|
||||
'camp.opt.cualquiera': 'Any',
|
||||
'camp.opt.con': 'With',
|
||||
'camp.opt.sin': 'Without',
|
||||
'camp.opt.ninguno': '—',
|
||||
'camp.action.lanzar': 'Launch',
|
||||
'camp.action.pausar': 'Pause',
|
||||
'camp.action.reanudar': 'Resume',
|
||||
'camp.action.cancelar': 'Cancel',
|
||||
'camp.action.procesar': 'Process queue',
|
||||
'camp.action.prueba': 'Test send',
|
||||
'camp.action.metricas': 'Measurement',
|
||||
'camp.action.envios': 'Sends',
|
||||
'camp.prueba.title': 'Test send',
|
||||
'camp.prueba.telefono': 'Number',
|
||||
'camp.prueba.correo': 'Email',
|
||||
'camp.prueba.send': 'Send test',
|
||||
'camp.prueba.hint': 'Without real credentials or in test mode the send is logged as simulated.',
|
||||
'camp.metric.alcanzados': 'Reached',
|
||||
'camp.metric.enviados': 'Sent',
|
||||
'camp.metric.entregados': 'Delivered',
|
||||
'camp.metric.respuestas': 'Replies',
|
||||
'camp.metric.citas': 'Attributed appointments',
|
||||
'camp.metric.valor': 'Attributed value',
|
||||
'camp.metric.conversion': 'Conversion',
|
||||
'camp.metric.noDisponible': 'Not available',
|
||||
'camp.metric.ventana': 'Attribution window: {n} days',
|
||||
'camp.estado.borrador': 'Draft',
|
||||
'camp.estado.programada': 'Scheduled',
|
||||
'camp.estado.en_ejecucion': 'Running',
|
||||
'camp.estado.pausada': 'Paused',
|
||||
'camp.estado.terminada': 'Finished',
|
||||
'camp.estado.cancelada': 'Cancelled',
|
||||
'camp.saved': 'Campaign saved.',
|
||||
'camp.segmentoSaved': 'Segment saved.',
|
||||
'camp.deleted': 'Deleted.',
|
||||
'camp.processing': 'Queue processed.',
|
||||
'camp.confirmDelete': 'Delete permanently?',
|
||||
'camp.envios.title': 'Send log',
|
||||
'camp.envios.esPrueba': 'Test',
|
||||
'camp.export.metricas': 'Export measurement',
|
||||
'camp.export.segmento': 'Export sample',
|
||||
'nav.campanas': 'Campaigns',
|
||||
'nav.satisfaccion': 'Satisfaction',
|
||||
// --- satisfaction (NPS) ---
|
||||
'nps.title': 'Satisfaction',
|
||||
'nps.subtitle': 'Exit surveys and recommendation (NPS)',
|
||||
'nps.off': 'Exit surveys are off. Enable them in Settings.',
|
||||
'nps.period': 'Period',
|
||||
'nps.general': 'NPS for the period',
|
||||
'nps.promotores': 'Promoters (9-10)',
|
||||
'nps.pasivos': 'Passives (7-8)',
|
||||
'nps.detractores': 'Detractors (0-6)',
|
||||
'nps.respuestas': 'Responses',
|
||||
'nps.trend': 'Monthly trend',
|
||||
'nps.byBranch': 'By branch',
|
||||
'nps.byDoctor': 'By doctor',
|
||||
'nps.byService': 'By service',
|
||||
'nps.responses': 'Responses with comment',
|
||||
'nps.comment': 'Comment',
|
||||
'nps.reply': 'Reply on WhatsApp',
|
||||
'nps.replyPlaceholder': 'Write the message to the patient…',
|
||||
'nps.replySend': 'Send',
|
||||
'nps.openRecord': 'Open record',
|
||||
'nps.noData': 'No responses in the period.',
|
||||
'nps.score': 'Score',
|
||||
'nps.category.promotor': 'Promoter',
|
||||
'nps.category.pasivo': 'Passive',
|
||||
'nps.category.detractor': 'Detractor',
|
||||
'nps.export': 'Export report',
|
||||
'nps.exportRespuestas': 'Export responses',
|
||||
'nps.process': 'Process surveys now',
|
||||
'nps.processed': 'Surveys processed.',
|
||||
'nps.replySent': 'Reply sent.',
|
||||
'nps.replySimulated': 'Test mode: the reply was logged as simulated.',
|
||||
};
|
||||
|
||||
export const DICT: Record<Locale, Dict> = { es, en };
|
||||
@@ -1725,6 +1967,8 @@ export const MENU_LABEL_KEYS: Record<string, string> = { // grupos
|
||||
'Leads': 'nav.leads',
|
||||
'Membresías': 'nav.memberships',
|
||||
'Liquidaciones': 'nav.liquidations',
|
||||
'Campañas': 'nav.campanas',
|
||||
'Satisfacción': 'nav.satisfaccion',
|
||||
};
|
||||
|
||||
// Etiquetas del tema "clásico" -> clave i18n (reutiliza nav.* cuando coincide).
|
||||
|
||||
@@ -40,6 +40,8 @@ export const MENU_ITEMS: MenuItem[] = [
|
||||
{ label: 'Recomendaciones', href: '/reportes?tab=recomendaciones' },
|
||||
{ label: 'Exportar', href: '/reportes?tab=exportar' },
|
||||
{ label: 'Cumpleañeros', href: '/cumpleanos' },
|
||||
{ label: 'Campañas', href: '/campanas' },
|
||||
{ label: 'Satisfacción', href: '/satisfaccion' },
|
||||
{ label: 'Mis ventas', href: '/mis-ventas' },
|
||||
{ label: 'Chat', href: '/chat' },
|
||||
{ label: 'Configuración', href: '/configuracion' },
|
||||
|
||||
@@ -19,6 +19,8 @@ export const PERM_KEYS: string[] = [
|
||||
'whatsapp.ver', 'whatsapp.responder', 'whatsapp.asignar',
|
||||
'notificaciones.ver', 'notificaciones.enviar', 'notificaciones.editar',
|
||||
'formularios.ver', 'formularios.crear', 'formularios.editar', 'formularios.enviar',
|
||||
'campanas.ver', 'campanas.gestionar', 'campanas.enviar', 'campanas.medir',
|
||||
'encuestas.ver', 'encuestas.gestionar', 'encuestas.medir',
|
||||
'membresias.ver', 'membresias.crear', 'membresias.cobrar', 'membresias.editar',
|
||||
'membresias.cancelar',
|
||||
'compras.ver', 'compras.crear', 'compras.recibir', 'compras.pagar',
|
||||
@@ -53,6 +55,8 @@ const READ_PERM_KEYS = new Set([
|
||||
'whatsapp.ver',
|
||||
'notificaciones.ver',
|
||||
'formularios.ver',
|
||||
'campanas.ver', 'campanas.medir',
|
||||
'encuestas.ver', 'encuestas.medir',
|
||||
'membresias.ver',
|
||||
'compras.ver',
|
||||
'proveedores.ver',
|
||||
@@ -84,6 +88,9 @@ const DEFAULT_PERM_ROLES: Record<string, string[]> = {
|
||||
'notificaciones.ver': ALL, 'notificaciones.enviar': W, 'notificaciones.editar': AG,
|
||||
'formularios.ver': ALL, 'formularios.crear': FORM_WRITE, 'formularios.editar': AG,
|
||||
'formularios.enviar': FORM_WRITE,
|
||||
'campanas.ver': ALL, 'campanas.gestionar': ['admin', 'gerencia', 'marketing'],
|
||||
'campanas.enviar': ['admin', 'gerencia', 'marketing'], 'campanas.medir': ALL,
|
||||
'encuestas.ver': ALL, 'encuestas.gestionar': AG, 'encuestas.medir': ALL,
|
||||
'membresias.ver': ALL, 'membresias.crear': ['admin', 'recepcion', 'gerencia'],
|
||||
'membresias.cobrar': ['admin', 'recepcion', 'gerencia'], 'membresias.editar': AG,
|
||||
'membresias.cancelar': AG,
|
||||
|
||||
730
frontend/src/pages/Campanas.tsx
Normal file
730
frontend/src/pages/Campanas.tsx
Normal file
@@ -0,0 +1,730 @@
|
||||
import type { FC } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Megaphone, Users, Pencil, Trash2, Plus, Eye, PlayCircle, PauseCircle,
|
||||
RotateCcw, XCircle, FlaskConical, BarChart3, Download,
|
||||
} from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import { Card, PageHeader, Button, Input, Select, TextArea, Modal, Badge, EmptyState, toast } from '../components/ui';
|
||||
import { odooApi } from '../services/odoo';
|
||||
import type {
|
||||
Campana, CampanaEstado, CampanaMetricas, CampanaEnvio, Segmento,
|
||||
SegmentoCriterios, SegmentoMuestraPaciente, Service,
|
||||
} from '../services/odoo';
|
||||
import { SEGMENTO_CRITERIOS_DEFAULT } from '../services/odoo';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { useI18n } from '../lib/i18n';
|
||||
import { useGlosario } from '../lib/glosario';
|
||||
import { useClinicSettings } from '../lib/clinic-settings';
|
||||
import { useSucursales } from '../lib/sucursales';
|
||||
import { exportToExcel } from '../lib/exporter';
|
||||
|
||||
type Tab = 'campanas' | 'segmentos';
|
||||
|
||||
const ESTADO_STYLE: Record<CampanaEstado, string> = {
|
||||
borrador: 'bg-theme-bg text-theme-muted',
|
||||
programada: 'bg-sky-100 text-sky-700',
|
||||
en_ejecucion: 'bg-amber-100 text-amber-700',
|
||||
pausada: 'bg-orange-100 text-orange-700',
|
||||
terminada: 'bg-emerald-100 text-emerald-700',
|
||||
cancelada: 'bg-rose-100 text-rose-700',
|
||||
};
|
||||
|
||||
const fmtMoney = (n: number) =>
|
||||
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 });
|
||||
|
||||
const fmtDt = (s?: string | null) => {
|
||||
if (!s) return '—';
|
||||
const d = new Date(s.replace(' ', 'T'));
|
||||
if (Number.isNaN(d.getTime())) return s;
|
||||
return d.toLocaleString('es-MX', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
const tri = (v: boolean | null): string => (v === null ? '' : v ? 'con' : 'sin');
|
||||
|
||||
const resumenCriterios = (c: SegmentoCriterios, term: (k: 'branch_singular' | 'service_singular') => string): string[] => {
|
||||
const out: string[] = [];
|
||||
if (c.ultima_visita_dias > 0) out.push(`Última visita > ${c.ultima_visita_dias} d`);
|
||||
if (c.sin_visita_meses > 0) out.push(`Sin visita ${c.sin_visita_meses} m`);
|
||||
if (c.cumple_mes) out.push('Cumpleaños del mes');
|
||||
if (c.cumple_proximos_dias > 0) out.push(`Cumple en ${c.cumple_proximos_dias} d`);
|
||||
if (c.nivel_codigo) out.push(`Nivel ${c.nivel_codigo}`);
|
||||
if (c.gasto_minimo > 0) out.push(`Gasto ≥ ${fmtMoney(c.gasto_minimo)}`);
|
||||
if (c.sucursal) out.push(`Sucursal ${c.sucursal}`);
|
||||
if (c.servicio_id) out.push(`${term('service_singular')} tomado`);
|
||||
if (c.servicio_nunca_id) out.push(`${term('service_singular')} nunca tomado`);
|
||||
if (c.con_membresia !== null) out.push(c.con_membresia ? 'Con membresía' : 'Sin membresía');
|
||||
if (c.con_paquetes !== null) out.push(c.con_paquetes ? 'Con paquetes' : 'Sin paquetes');
|
||||
if (c.con_saldo !== null) out.push(c.con_saldo ? 'Con saldo' : 'Sin saldo');
|
||||
if (c.consentimiento) out.push(c.consentimiento === 'con' ? 'Con consentimiento' : 'Sin consentimiento');
|
||||
return out;
|
||||
};
|
||||
|
||||
const Campanas: FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { term } = useGlosario();
|
||||
const { can } = useAuth();
|
||||
const { sucursales } = useSucursales();
|
||||
const { lealtad } = useClinicSettings();
|
||||
|
||||
const puedeGestionar = can('campanas.gestionar');
|
||||
const puedeEnviar = can('campanas.enviar');
|
||||
const puedeMedir = can('campanas.medir');
|
||||
|
||||
const [tab, setTab] = useState<Tab>('campanas');
|
||||
const [campanas, setCampanas] = useState<Campana[]>([]);
|
||||
const [segmentos, setSegmentos] = useState<Segmento[]>([]);
|
||||
const [servicios, setServicios] = useState<Service[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const [segModal, setSegModal] = useState(false);
|
||||
const [segEdit, setSegEdit] = useState<Segmento | null>(null);
|
||||
const [segName, setSegName] = useState('');
|
||||
const [segDesc, setSegDesc] = useState('');
|
||||
const [criterios, setCriterios] = useState<SegmentoCriterios>({ ...SEGMENTO_CRITERIOS_DEFAULT });
|
||||
const [preview, setPreview] = useState<{ total: number; muestra: SegmentoMuestraPaciente[] } | null>(null);
|
||||
const [previewing, setPreviewing] = useState(false);
|
||||
|
||||
const [campModal, setCampModal] = useState(false);
|
||||
const [campEdit, setCampEdit] = useState<Campana | null>(null);
|
||||
const [campForm, setCampForm] = useState({
|
||||
name: '', descripcion: '', segmento_id: 0, canal: 'whatsapp' as Campana['canal'],
|
||||
plantilla: '', enlace: '', programacion: 'una_vez' as Campana['programacion'],
|
||||
fecha_programada: '', hora: '10:00',
|
||||
});
|
||||
|
||||
const [testModal, setTestModal] = useState<Campana | null>(null);
|
||||
const [testTel, setTestTel] = useState('');
|
||||
const [testCorreo, setTestCorreo] = useState('');
|
||||
|
||||
const [metricasModal, setMetricasModal] = useState(false);
|
||||
const [metricas, setMetricas] = useState<CampanaMetricas[]>([]);
|
||||
const [envios, setEnvios] = useState<CampanaEnvio[]>([]);
|
||||
const [enviosCampana, setEnviosCampana] = useState<Campana | null>(null);
|
||||
|
||||
const niveles = useMemo(
|
||||
() => (lealtad?.niveles || []).map((n) => ({ value: n.codigo, label: n.nombre || n.codigo })),
|
||||
[lealtad],
|
||||
);
|
||||
|
||||
const cargar = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [c, s] = await Promise.all([odooApi.listCampanas(), odooApi.listSegmentos(true)]);
|
||||
if (c.status === 'success') setCampanas(c.campanas || []);
|
||||
if (s.status === 'success') setSegmentos(s.segmentos || []);
|
||||
} catch {
|
||||
toast.error(t('camp.title'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => { void cargar(); }, [cargar]);
|
||||
|
||||
useEffect(() => {
|
||||
odooApi.getServices({ page_size: 200 })
|
||||
.then((res) => { if (res.status === 'success') setServicios(res.services || []); })
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// --- Vista previa del segmento (con retardo) ---
|
||||
const previewTimer = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (!segModal) return;
|
||||
if (previewTimer.current) window.clearTimeout(previewTimer.current);
|
||||
previewTimer.current = window.setTimeout(() => {
|
||||
setPreviewing(true);
|
||||
odooApi.previewSegmento(criterios)
|
||||
.then((res) => { if (res.status === 'success') setPreview({ total: res.total, muestra: res.muestra }); })
|
||||
.catch(() => {})
|
||||
.finally(() => setPreviewing(false));
|
||||
}, 350);
|
||||
return () => { if (previewTimer.current) window.clearTimeout(previewTimer.current); };
|
||||
}, [criterios, segModal]);
|
||||
|
||||
const abrirNuevoSegmento = () => {
|
||||
setSegEdit(null);
|
||||
setSegName('');
|
||||
setSegDesc('');
|
||||
setCriterios({ ...SEGMENTO_CRITERIOS_DEFAULT });
|
||||
setPreview(null);
|
||||
setSegModal(true);
|
||||
};
|
||||
|
||||
const abrirEditarSegmento = (s: Segmento) => {
|
||||
setSegEdit(s);
|
||||
setSegName(s.name);
|
||||
setSegDesc(s.descripcion || '');
|
||||
setCriterios({ ...SEGMENTO_CRITERIOS_DEFAULT, ...(s.criterios || {}) });
|
||||
setPreview({ total: s.ultimo_conteo, muestra: s.muestra || [] });
|
||||
setSegModal(true);
|
||||
};
|
||||
|
||||
const guardarSegmento = async () => {
|
||||
if (!segName.trim()) { toast.error(t('camp.field.name')); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = { name: segName.trim(), descripcion: segDesc, criterios };
|
||||
const res = segEdit
|
||||
? await odooApi.actualizarSegmento(segEdit.id, payload)
|
||||
: await odooApi.crearSegmento(payload);
|
||||
if (res.status === 'success') {
|
||||
toast.success(t('camp.segmentoSaved'));
|
||||
setSegModal(false);
|
||||
await cargar();
|
||||
} else {
|
||||
toast.error(res.message || '');
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('camp.segmentoSaved'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const borrarSegmento = async (s: Segmento) => {
|
||||
if (!window.confirm(t('camp.confirmDelete'))) return;
|
||||
try {
|
||||
const res = await odooApi.borrarSegmento(s.id);
|
||||
toast.success(res.message || t('camp.deleted'));
|
||||
await cargar();
|
||||
} catch { toast.error(t('camp.deleted')); }
|
||||
};
|
||||
|
||||
const abrirNuevaCampana = () => {
|
||||
setCampEdit(null);
|
||||
setCampForm({
|
||||
name: '', descripcion: '', segmento_id: segmentos[0]?.id || 0, canal: 'whatsapp',
|
||||
plantilla: 'Hola {nombre}, te esperamos en {sucursal}. Agenda tu {servicio}: {enlace}',
|
||||
enlace: '', programacion: 'una_vez', fecha_programada: '', hora: '10:00',
|
||||
});
|
||||
setCampModal(true);
|
||||
};
|
||||
|
||||
const abrirEditarCampana = (c: Campana) => {
|
||||
setCampEdit(c);
|
||||
setCampForm({
|
||||
name: c.name, descripcion: c.descripcion || '', segmento_id: c.segmento_id || 0,
|
||||
canal: c.canal, plantilla: c.plantilla || '', enlace: c.enlace || '',
|
||||
programacion: c.programacion, fecha_programada: c.fecha_programada || '', hora: c.hora || '10:00',
|
||||
});
|
||||
setCampModal(true);
|
||||
};
|
||||
|
||||
const guardarCampana = async () => {
|
||||
if (!campForm.name.trim()) { toast.error(t('camp.field.name')); return; }
|
||||
setBusy(true);
|
||||
const payload = {
|
||||
name: campForm.name.trim(),
|
||||
descripcion: campForm.descripcion,
|
||||
segmento_id: campForm.segmento_id || null,
|
||||
canal: campForm.canal,
|
||||
plantilla: campForm.plantilla,
|
||||
enlace: campForm.enlace,
|
||||
programacion: campForm.programacion,
|
||||
fecha_programada: campForm.fecha_programada || null,
|
||||
hora: campForm.hora,
|
||||
};
|
||||
try {
|
||||
const res = campEdit
|
||||
? await odooApi.actualizarCampana(campEdit.id, payload)
|
||||
: await odooApi.crearCampana(payload);
|
||||
if (res.status === 'success') {
|
||||
toast.success(t('camp.saved'));
|
||||
setCampModal(false);
|
||||
await cargar();
|
||||
} else {
|
||||
toast.error(res.message || '');
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('camp.saved'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const accion = async (c: Campana, a: 'lanzar' | 'pausar' | 'reanudar' | 'cancelar') => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await odooApi.accionCampana(c.id, a);
|
||||
if (res.status === 'success') { toast.success(t('common.save')); await cargar(); }
|
||||
else toast.error(res.message || '');
|
||||
} catch { toast.error(t('common.save')); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const procesar = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await odooApi.procesarCampanas();
|
||||
toast.success(`${t('camp.processing')} (${res.encoladas}/${res.enviadas + res.simuladas})`);
|
||||
await cargar();
|
||||
} catch { toast.error(t('camp.processing')); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const enviarPrueba = async () => {
|
||||
if (!testModal) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await odooApi.pruebaCampana(testModal.id, {
|
||||
telefono: testTel.trim() || undefined,
|
||||
correo: testCorreo.trim() || undefined,
|
||||
});
|
||||
if (res.ok) toast.success(res.message);
|
||||
else toast.error(res.message);
|
||||
setTestModal(null);
|
||||
} catch { toast.error(t('camp.action.prueba')); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const abrirMetricas = async (c?: Campana) => {
|
||||
setMetricasModal(true);
|
||||
try {
|
||||
if (c) {
|
||||
const res = await odooApi.metricasCampana(c.id);
|
||||
if (res.status === 'success') setMetricas([res.metricas]);
|
||||
} else {
|
||||
const res = await odooApi.metricasCampanas();
|
||||
if (res.status === 'success') setMetricas(res.metricas || []);
|
||||
}
|
||||
} catch { toast.error(t('camp.action.metricas')); }
|
||||
};
|
||||
|
||||
const abrirEnvios = async (c: Campana) => {
|
||||
setEnviosCampana(c);
|
||||
try {
|
||||
const res = await odooApi.enviosCampana(c.id, true);
|
||||
if (res.status === 'success') setEnvios(res.envios || []);
|
||||
} catch { toast.error(''); }
|
||||
};
|
||||
|
||||
const exportarMetricas = async () => {
|
||||
if (!metricas.length) return;
|
||||
try {
|
||||
await exportToExcel({
|
||||
filename: `campanas-medicion-${new Date().toISOString().split('T')[0]}`,
|
||||
sheetName: 'Medición',
|
||||
title: `Medición de ${t('camp.title')}`,
|
||||
columns: [
|
||||
{ header: t('camp.field.name'), key: 'campana' },
|
||||
{ header: t('camp.field.estado'), key: 'estado' },
|
||||
{ header: t('camp.metric.alcanzados'), key: 'alcanzados', format: 'number' },
|
||||
{ header: t('camp.metric.enviados'), key: 'enviados', format: 'number' },
|
||||
{ header: t('camp.metric.entregados'), key: 'entregados_txt' },
|
||||
{ header: t('camp.metric.respuestas'), key: 'respuestas_txt' },
|
||||
{ header: t('camp.metric.citas'), key: 'citas_atribuidas', format: 'number' },
|
||||
{ header: t('camp.metric.valor'), key: 'valor_atribuido', format: 'currency' },
|
||||
{ header: t('camp.metric.conversion'), key: 'conversion_pct', format: 'number' },
|
||||
],
|
||||
rows: metricas.map((m) => ({
|
||||
...m,
|
||||
entregados_txt: m.entregados_disponible ? String(m.entregados) : t('camp.metric.noDisponible'),
|
||||
respuestas_txt: m.respuestas_disponible ? String(m.respuestas) : t('camp.metric.noDisponible'),
|
||||
})),
|
||||
});
|
||||
toast.success(t('common.export'));
|
||||
} catch { toast.error(t('common.export')); }
|
||||
};
|
||||
|
||||
const exportarMuestra = async (s: Segmento) => {
|
||||
try {
|
||||
await exportToExcel({
|
||||
filename: `segmento-${s.name}-${new Date().toISOString().split('T')[0]}`,
|
||||
sheetName: 'Segmento',
|
||||
title: s.name,
|
||||
subtitle: t('camp.previewCount', { n: s.ultimo_conteo }),
|
||||
columns: [
|
||||
{ header: t('common.name'), key: 'name' },
|
||||
{ header: t('common.phone'), key: 'phone' },
|
||||
{ header: t('common.email'), key: 'email' },
|
||||
{ header: 'Última visita', key: 'last_visit', format: 'date' },
|
||||
{ header: t('camp.criterio.nivel'), key: 'nivel' },
|
||||
],
|
||||
rows: (s.muestra || []) as unknown as Record<string, unknown>[],
|
||||
});
|
||||
toast.success(t('common.export'));
|
||||
} catch { toast.error(t('common.export')); }
|
||||
};
|
||||
|
||||
const segmentosOpts = segmentos.map((s) => ({ value: String(s.id), label: s.name }));
|
||||
const serviciosOpts = [{ value: '', label: t('camp.opt.ninguno') },
|
||||
...servicios.map((s) => ({ value: String(s.id), label: s.name }))];
|
||||
const sucursalesOpts = [{ value: '', label: t('camp.opt.ninguno') },
|
||||
...sucursales.map((s) => ({ value: s.code, label: s.name }))];
|
||||
|
||||
return (
|
||||
<Layout title={t('camp.title')} subtitle={t('camp.subtitle')}>
|
||||
<PageHeader title={t('camp.title')} subtitle={t('camp.subtitle')}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{puedeMedir && (
|
||||
<Button variant="outline" onClick={() => void abrirMetricas()}>
|
||||
<BarChart3 size={16} className="mr-2" />{t('camp.action.metricas')}
|
||||
</Button>
|
||||
)}
|
||||
{puedeEnviar && (
|
||||
<Button variant="outline" onClick={() => void procesar()} disabled={busy}>
|
||||
<PlayCircle size={16} className="mr-2" />{t('camp.action.procesar')}
|
||||
</Button>
|
||||
)}
|
||||
{puedeGestionar && (
|
||||
<Button onClick={() => (tab === 'campanas' ? abrirNuevaCampana() : abrirNuevoSegmento())}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
{tab === 'campanas' ? t('camp.new') : t('camp.newSegmento')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
<div className="mb-4 flex gap-2">
|
||||
{(['campanas', 'segmentos'] as Tab[]).map((x) => (
|
||||
<button
|
||||
key={x}
|
||||
onClick={() => setTab(x)}
|
||||
className={`rounded-full px-4 py-2 text-sm font-medium transition ${
|
||||
tab === x ? 'bg-theme-heading text-theme-inverse' : 'bg-theme-bg text-theme-muted hover:bg-theme-border'
|
||||
}`}
|
||||
>
|
||||
{x === 'campanas' ? t('camp.tab.campanas') : t('camp.tab.segmentos')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Card><Card.Body><p className="p-6 text-sm text-theme-muted">{t('common.loading')}</p></Card.Body></Card>
|
||||
) : tab === 'segmentos' ? (
|
||||
<div className="grid gap-3">
|
||||
{segmentos.length === 0 ? (
|
||||
<Card><Card.Body>
|
||||
<EmptyState icon={<Users size={28} />} title={t('camp.emptySegmentos')} />
|
||||
</Card.Body></Card>
|
||||
) : segmentos.map((s) => (
|
||||
<Card key={s.id}><Card.Body>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-heading text-base text-theme-heading">{s.name}</h3>
|
||||
{!s.activo && <Badge>{t('common.no')}</Badge>}
|
||||
<span className="text-xs text-theme-muted">{t('camp.previewCount', { n: s.ultimo_conteo })}</span>
|
||||
</div>
|
||||
{s.descripcion && <p className="mt-1 text-sm text-theme-muted">{s.descripcion}</p>}
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{resumenCriterios(s.criterios, term).map((r) => (
|
||||
<span key={r} className="rounded-full bg-theme-bg px-2 py-0.5 text-[11px] text-theme-muted">{r}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={() => void odooApi.recalcularSegmento(s.id).then(() => cargar())}>
|
||||
<Eye size={14} className="mr-1" />{t('camp.previewRecalc')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => void exportarMuestra(s)}>
|
||||
<Download size={14} className="mr-1" />{t('common.export')}
|
||||
</Button>
|
||||
{puedeGestionar && (
|
||||
<>
|
||||
<Button size="sm" variant="outline" onClick={() => abrirEditarSegmento(s)}>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => void borrarSegmento(s)}>
|
||||
<Trash2 size={14} className="text-rose-600" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card.Body></Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{campanas.length === 0 ? (
|
||||
<Card><Card.Body>
|
||||
<EmptyState icon={<Megaphone size={28} />} title={t('camp.empty')} />
|
||||
</Card.Body></Card>
|
||||
) : campanas.map((c) => (
|
||||
<Card key={c.id}><Card.Body>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="font-heading text-base text-theme-heading">{c.name}</h3>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${ESTADO_STYLE[c.estado]}`}>
|
||||
{t(`camp.estado.${c.estado}`)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-theme-muted">
|
||||
{c.segmento || '—'} · {c.canal} · {c.programacion}
|
||||
{c.proxima_ejecucion ? ` · ${fmtDt(c.proxima_ejecucion)}` : ''}
|
||||
</p>
|
||||
{c.motivo_pausa && <p className="mt-1 text-xs text-orange-600">{c.motivo_pausa}</p>}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
{puedeEnviar && c.estado === 'borrador' && (
|
||||
<Button size="sm" onClick={() => void accion(c, 'lanzar')}><PlayCircle size={14} className="mr-1" />{t('camp.action.lanzar')}</Button>
|
||||
)}
|
||||
{puedeEnviar && (c.estado === 'programada' || c.estado === 'en_ejecucion') && (
|
||||
<Button size="sm" variant="outline" onClick={() => void accion(c, 'pausar')}><PauseCircle size={14} className="mr-1" />{t('camp.action.pausar')}</Button>
|
||||
)}
|
||||
{puedeEnviar && c.estado === 'pausada' && (
|
||||
<Button size="sm" onClick={() => void accion(c, 'reanudar')}><RotateCcw size={14} className="mr-1" />{t('camp.action.reanudar')}</Button>
|
||||
)}
|
||||
{puedeEnviar && c.estado !== 'cancelada' && (
|
||||
<Button size="sm" variant="ghost" onClick={() => void accion(c, 'cancelar')}><XCircle size={14} className="mr-1" />{t('camp.action.cancelar')}</Button>
|
||||
)}
|
||||
{puedeEnviar && (
|
||||
<Button size="sm" variant="outline" onClick={() => { setTestModal(c); setTestTel(''); setTestCorreo(''); }}>
|
||||
<FlaskConical size={14} className="mr-1" />{t('camp.action.prueba')}
|
||||
</Button>
|
||||
)}
|
||||
{puedeMedir && (
|
||||
<Button size="sm" variant="ghost" onClick={() => void abrirMetricas(c)}><BarChart3 size={14} /></Button>
|
||||
)}
|
||||
{puedeMedir && (
|
||||
<Button size="sm" variant="ghost" onClick={() => void abrirEnvios(c)}>{t('camp.action.envios')}</Button>
|
||||
)}
|
||||
{puedeGestionar && (
|
||||
<>
|
||||
<Button size="sm" variant="outline" onClick={() => abrirEditarCampana(c)}><Pencil size={14} /></Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => void odooApi.borrarCampana(c.id).then(() => { toast.success(t('camp.deleted')); return cargar(); })}>
|
||||
<Trash2 size={14} className="text-rose-600" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card.Body></Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- Modal segmento ---- */}
|
||||
<Modal
|
||||
isOpen={segModal}
|
||||
onClose={() => setSegModal(false)}
|
||||
title={segEdit ? t('camp.editSegmento') : t('camp.newSegmento')}
|
||||
footer={
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setSegModal(false)}>{t('common.cancel')}</Button>
|
||||
<Button onClick={() => void guardarSegmento()} loading={busy}>{t('common.save')}</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Input label={t('camp.field.name')} value={segName} onChange={(e) => setSegName(e.target.value)} />
|
||||
<Input label={t('camp.field.descripcion')} value={segDesc} onChange={(e) => setSegDesc(e.target.value)} />
|
||||
|
||||
<div className="rounded-2xl border border-theme-border bg-theme-bg/50 p-3">
|
||||
<p className="mb-3 text-xs font-semibold uppercase text-theme-muted">{t('camp.field.criterios')}</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Input type="number" min={0} label={t('camp.criterio.ultimaVisita')}
|
||||
value={criterios.ultima_visita_dias || ''}
|
||||
onChange={(e) => setCriterios({ ...criterios, ultima_visita_dias: Number(e.target.value) || 0 })} />
|
||||
<Input type="number" min={0} label={t('camp.criterio.sinVisita')}
|
||||
value={criterios.sin_visita_meses || ''}
|
||||
onChange={(e) => setCriterios({ ...criterios, sin_visita_meses: Number(e.target.value) || 0 })} />
|
||||
<Input type="number" min={0} label={t('camp.criterio.cumpleProx')}
|
||||
value={criterios.cumple_proximos_dias || ''}
|
||||
onChange={(e) => setCriterios({ ...criterios, cumple_proximos_dias: Number(e.target.value) || 0 })} />
|
||||
<label className="flex items-center gap-2 pt-5 text-sm text-theme-heading">
|
||||
<input type="checkbox" className="h-4 w-4 accent-theme-accent"
|
||||
checked={criterios.cumple_mes}
|
||||
onChange={(e) => setCriterios({ ...criterios, cumple_mes: e.target.checked })} />
|
||||
{t('camp.criterio.cumpleMes')}
|
||||
</label>
|
||||
<Select label={t('camp.criterio.nivel')} options={[{ value: '', label: t('camp.opt.ninguno') }, ...niveles]}
|
||||
value={criterios.nivel_codigo}
|
||||
onChange={(e) => setCriterios({ ...criterios, nivel_codigo: e.target.value })} />
|
||||
<Input type="number" min={0} label={t('camp.criterio.gasto')}
|
||||
value={criterios.gasto_minimo || ''}
|
||||
onChange={(e) => setCriterios({ ...criterios, gasto_minimo: Number(e.target.value) || 0 })} />
|
||||
<Select label={t('camp.criterio.sucursal')} options={sucursalesOpts}
|
||||
value={criterios.sucursal}
|
||||
onChange={(e) => setCriterios({ ...criterios, sucursal: e.target.value })} />
|
||||
<Select label={t('camp.criterio.servicio')} options={serviciosOpts}
|
||||
value={String(criterios.servicio_id || '')}
|
||||
onChange={(e) => setCriterios({ ...criterios, servicio_id: Number(e.target.value) || 0 })} />
|
||||
<Select label={t('camp.criterio.servicioNunca')} options={serviciosOpts}
|
||||
value={String(criterios.servicio_nunca_id || '')}
|
||||
onChange={(e) => setCriterios({ ...criterios, servicio_nunca_id: Number(e.target.value) || 0 })} />
|
||||
<Select label={t('camp.criterio.membresia')}
|
||||
options={[{ value: '', label: t('camp.opt.cualquiera') }, { value: 'con', label: t('camp.opt.con') }, { value: 'sin', label: t('camp.opt.sin') }]}
|
||||
value={tri(criterios.con_membresia)}
|
||||
onChange={(e) => setCriterios({ ...criterios, con_membresia: e.target.value === '' ? null : e.target.value === 'con' })} />
|
||||
<Select label={t('camp.criterio.paquetes')}
|
||||
options={[{ value: '', label: t('camp.opt.cualquiera') }, { value: 'con', label: t('camp.opt.con') }, { value: 'sin', label: t('camp.opt.sin') }]}
|
||||
value={tri(criterios.con_paquetes)}
|
||||
onChange={(e) => setCriterios({ ...criterios, con_paquetes: e.target.value === '' ? null : e.target.value === 'con' })} />
|
||||
<Select label={t('camp.criterio.saldo')}
|
||||
options={[{ value: '', label: t('camp.opt.cualquiera') }, { value: 'con', label: t('camp.opt.con') }, { value: 'sin', label: t('camp.opt.sin') }]}
|
||||
value={tri(criterios.con_saldo)}
|
||||
onChange={(e) => setCriterios({ ...criterios, con_saldo: e.target.value === '' ? null : e.target.value === 'con' })} />
|
||||
<Select label={t('camp.criterio.consentimiento')}
|
||||
options={[{ value: '', label: t('camp.opt.cualquiera') }, { value: 'con', label: t('camp.opt.con') }, { value: 'sin', label: t('camp.opt.sin') }]}
|
||||
value={criterios.consentimiento}
|
||||
onChange={(e) => setCriterios({ ...criterios, consentimiento: e.target.value as SegmentoCriterios['consentimiento'] })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-theme-border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase text-theme-muted">{t('camp.preview')}</p>
|
||||
<span className="text-sm font-medium text-theme-heading">
|
||||
{previewing ? t('common.loading') : preview ? t('camp.previewCount', { n: preview.total }) : t('camp.previewEmpty')}
|
||||
</span>
|
||||
</div>
|
||||
{preview && preview.muestra.length > 0 && (
|
||||
<ul className="mt-2 divide-y divide-theme-border text-sm">
|
||||
{preview.muestra.map((p) => (
|
||||
<li key={p.id} className="flex items-center justify-between py-1.5">
|
||||
<span className="text-theme-heading">{p.name}</span>
|
||||
<span className="text-xs text-theme-muted">{p.phone || p.email || ''}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* ---- Modal campaña ---- */}
|
||||
<Modal
|
||||
isOpen={campModal}
|
||||
onClose={() => setCampModal(false)}
|
||||
title={campEdit ? t('camp.edit') : t('camp.new')}
|
||||
footer={
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setCampModal(false)}>{t('common.cancel')}</Button>
|
||||
<Button onClick={() => void guardarCampana()} loading={busy}>{t('common.save')}</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Input label={t('camp.field.name')} value={campForm.name}
|
||||
onChange={(e) => setCampForm({ ...campForm, name: e.target.value })} />
|
||||
<Select label={t('camp.field.segmento')} options={segmentosOpts}
|
||||
value={String(campForm.segmento_id || '')}
|
||||
onChange={(e) => setCampForm({ ...campForm, segmento_id: Number(e.target.value) || 0 })} />
|
||||
<Select label={t('camp.field.canal')}
|
||||
options={[{ value: 'whatsapp', label: 'WhatsApp' }, { value: 'email', label: 'Correo' }, { value: 'ambos', label: 'WhatsApp + Correo' }]}
|
||||
value={campForm.canal}
|
||||
onChange={(e) => setCampForm({ ...campForm, canal: e.target.value as Campana['canal'] })} />
|
||||
<div>
|
||||
<TextArea rows={4} label={t('camp.field.plantilla')} value={campForm.plantilla}
|
||||
onChange={(e) => setCampForm({ ...campForm, plantilla: e.target.value })} />
|
||||
<p className="mt-1 text-xs text-theme-muted">
|
||||
{t('camp.vars')}: {'{nombre} {servicio} {sucursal} {enlace} {doctor} {fecha} {hora} {telefono}'}
|
||||
</p>
|
||||
</div>
|
||||
<Input label={`${t('camp.field.enlace')} ({'{enlace}'})`} value={campForm.enlace}
|
||||
onChange={(e) => setCampForm({ ...campForm, enlace: e.target.value })} />
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Select label={t('camp.field.programacion')}
|
||||
options={[
|
||||
{ value: 'una_vez', label: 'Una sola vez' },
|
||||
{ value: 'mensual', label: 'Mensual' },
|
||||
{ value: 'semanal', label: 'Semanal' },
|
||||
{ value: 'segmento', label: 'Según el segmento' },
|
||||
]}
|
||||
value={campForm.programacion}
|
||||
onChange={(e) => setCampForm({ ...campForm, programacion: e.target.value as Campana['programacion'] })} />
|
||||
{campForm.programacion === 'una_vez' && (
|
||||
<Input type="datetime-local" label={t('camp.field.fecha')} value={campForm.fecha_programada}
|
||||
onChange={(e) => setCampForm({ ...campForm, fecha_programada: e.target.value })} />
|
||||
)}
|
||||
<Input type="time" label={t('camp.field.hora')} value={campForm.hora}
|
||||
onChange={(e) => setCampForm({ ...campForm, hora: e.target.value })} />
|
||||
</div>
|
||||
<p className="rounded-xl bg-theme-bg p-3 text-xs text-theme-muted">{t('camp.consentNote')}</p>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* ---- Modal envío de prueba ---- */}
|
||||
<Modal
|
||||
isOpen={!!testModal}
|
||||
onClose={() => setTestModal(null)}
|
||||
title={t('camp.prueba.title')}
|
||||
footer={
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setTestModal(null)}>{t('common.cancel')}</Button>
|
||||
<Button onClick={() => void enviarPrueba()} loading={busy}>{t('camp.prueba.send')}</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Input label={t('camp.prueba.telefono')} value={testTel} onChange={(e) => setTestTel(e.target.value)} placeholder="+52 664 000 0000" />
|
||||
<Input label={t('camp.prueba.correo')} value={testCorreo} onChange={(e) => setTestCorreo(e.target.value)} placeholder="correo@ejemplo.com" />
|
||||
<p className="text-xs text-theme-muted">{t('camp.prueba.hint')}</p>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* ---- Modal medición ---- */}
|
||||
<Modal isOpen={metricasModal} onClose={() => setMetricasModal(false)} title={t('camp.action.metricas')}
|
||||
footer={
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => void exportarMetricas()}><Download size={16} className="mr-2" />{t('common.export')}</Button>
|
||||
<Button variant="ghost" onClick={() => setMetricasModal(false)}>{t('common.close')}</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{metricas.length === 0 ? (
|
||||
<p className="text-sm text-theme-muted">{t('common.loading')}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{metricas.map((m) => (
|
||||
<div key={m.campana_id} className="rounded-2xl border border-theme-border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium text-theme-heading">{m.campana}</h4>
|
||||
<span className="text-xs text-theme-muted">{t(`camp.estado.${m.estado}`)}</span>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<Metric label={t('camp.metric.alcanzados')} value={String(m.alcanzados)} />
|
||||
<Metric label={t('camp.metric.enviados')} value={String(m.enviados)} />
|
||||
<Metric label={t('camp.metric.entregados')} value={m.entregados_disponible ? String(m.entregados) : t('camp.metric.noDisponible')} muted={!m.entregados_disponible} />
|
||||
<Metric label={t('camp.metric.respuestas')} value={m.respuestas_disponible ? String(m.respuestas) : t('camp.metric.noDisponible')} muted={!m.respuestas_disponible} />
|
||||
<Metric label={t('camp.metric.citas')} value={String(m.citas_atribuidas)} />
|
||||
<Metric label={t('camp.metric.valor')} value={fmtMoney(m.valor_atribuido)} />
|
||||
<Metric label={t('camp.metric.conversion')} value={`${m.conversion_pct}%`} />
|
||||
</div>
|
||||
<p className="mt-2 text-[11px] text-theme-muted">{t('camp.metric.ventana', { n: m.ventana_atribucion_dias })}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* ---- Modal bitácora de envíos ---- */}
|
||||
<Modal isOpen={!!enviosCampana} onClose={() => setEnviosCampana(null)} title={t('camp.envios.title')}
|
||||
footer={<Button variant="ghost" onClick={() => setEnviosCampana(null)}>{t('common.close')}</Button>}>
|
||||
<div className="max-h-[60vh] overflow-y-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs uppercase text-theme-muted">
|
||||
<tr><th className="text-left py-2">Paciente</th><th className="text-left py-2">Canal</th><th className="text-left py-2">{t('camp.field.estado')}</th><th className="text-right py-2">{t('common.date')}</th></tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{envios.map((e) => (
|
||||
<tr key={e.id}>
|
||||
<td className="py-1.5 text-theme-heading">
|
||||
{e.patient || e.destino || '—'}
|
||||
{e.es_prueba && <span className="ml-1 text-[10px] text-theme-muted">({t('camp.envios.esPrueba')})</span>}
|
||||
</td>
|
||||
<td className="py-1.5 text-theme-muted">{e.canal}</td>
|
||||
<td className="py-1.5 text-theme-muted">{e.estado}{e.error ? ` · ${e.error.slice(0, 40)}` : ''}</td>
|
||||
<td className="py-1.5 text-right text-theme-muted">{fmtDt(e.enviada_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Modal>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
const Metric: FC<{ label: string; value: string; muted?: boolean }> = ({ label, value, muted }) => (
|
||||
<div className="rounded-xl bg-theme-bg p-2">
|
||||
<p className="text-[10px] uppercase text-theme-muted">{label}</p>
|
||||
<p className={`text-sm font-medium ${muted ? 'text-theme-muted italic' : 'text-theme-heading'}`}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Importa el hook de sucursales con respaldo (evita dependencia circular dura).
|
||||
export default Campanas;
|
||||
@@ -24,6 +24,8 @@ import ComprasPanel from '../components/ComprasPanel';
|
||||
import ComisionesPanel from '../components/ComisionesPanel';
|
||||
import LealtadPanel from '../components/LealtadPanel';
|
||||
import PropinasPanel from '../components/PropinasPanel';
|
||||
import CampanasPanel from '../components/CampanasPanel';
|
||||
import EncuestasPanel from '../components/EncuestasPanel';
|
||||
import LicenciaPanel from '../components/LicenciaPanel';
|
||||
import ApiKeysPanel from '../components/ApiKeysPanel';
|
||||
import WebhooksPanel from '../components/WebhooksPanel';
|
||||
@@ -49,6 +51,8 @@ const TABS = [
|
||||
{ key: 'comisiones', label: 'Comisiones' },
|
||||
{ key: 'lealtad', label: 'Lealtad' },
|
||||
{ key: 'propinas', label: 'Propinas' },
|
||||
{ key: 'campanas', label: 'Campañas' },
|
||||
{ key: 'encuestas', label: 'Encuestas' },
|
||||
{ key: 'terminologia', label: 'Terminología' },
|
||||
{ key: 'usuarios', label: 'Usuarios' },
|
||||
{ key: 'roles', label: 'Roles' },
|
||||
@@ -62,7 +66,7 @@ const TABS = [
|
||||
] as const;
|
||||
|
||||
// Secciones que ve gerencia (admin del sistema ve todas).
|
||||
const TABS_GERENCIA = ['reservas', 'portal', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'recursos', 'membresias', 'compras', 'comisiones', 'lealtad', 'propinas', 'terminologia', 'usuarios', 'roles', 'tickets', 'licencia', 'webhooks'];
|
||||
const TABS_GERENCIA = ['reservas', 'portal', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'recursos', 'membresias', 'compras', 'comisiones', 'lealtad', 'propinas', 'campanas', 'encuestas', 'terminologia', 'usuarios', 'roles', 'tickets', 'licencia', 'webhooks'];
|
||||
|
||||
const SUBCATALOGOS = [
|
||||
{ key: 'diagnosticos', label: 'Diagnósticos' },
|
||||
@@ -94,6 +98,8 @@ const Configuracion: FC = () => {
|
||||
comisiones: 'comisiones.editar',
|
||||
lealtad: 'lealtad.configurar',
|
||||
propinas: 'propinas.configurar',
|
||||
campanas: 'campanas.gestionar',
|
||||
encuestas: 'encuestas.gestionar',
|
||||
recursos: 'recursos.editar',
|
||||
licencia: 'config.editar',
|
||||
api: 'api.claves',
|
||||
@@ -458,6 +464,18 @@ const Configuracion: FC = () => {
|
||||
<PropinasPanel settings={settings} onSaved={setSettings} />
|
||||
</Card.Body>
|
||||
</Card>
|
||||
) : tab === 'campanas' ? (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<CampanasPanel settings={settings} onSaved={setSettings} />
|
||||
</Card.Body>
|
||||
</Card>
|
||||
) : tab === 'encuestas' ? (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<EncuestasPanel settings={settings} onSaved={setSettings} />
|
||||
</Card.Body>
|
||||
</Card>
|
||||
) : tab === 'terminologia' ? (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
|
||||
@@ -138,6 +138,9 @@ const FormularioPublico: FC = () => {
|
||||
<p className="mt-2 text-sm text-theme-muted">
|
||||
{enviado ? t('public.form.registered') : t('public.form.alreadyFilled')}
|
||||
</p>
|
||||
{config.agradecimiento && (
|
||||
<p className="mt-3 whitespace-pre-wrap text-sm text-theme-heading">{config.agradecimiento}</p>
|
||||
)}
|
||||
<p className="mt-4 text-xs text-theme-muted">{t('public.form.canClose')}</p>
|
||||
</div>
|
||||
</Centro>
|
||||
|
||||
309
frontend/src/pages/Satisfaccion.tsx
Normal file
309
frontend/src/pages/Satisfaccion.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import type { FC } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Smile, Download, RefreshCw, MessageCircle, UserRound } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import { Card, PageHeader, Button, Modal, TextArea, EmptyState, toast } from '../components/ui';
|
||||
import { usePatientDrawer } from '../components/PatientDrawer';
|
||||
import { odooApi } from '../services/odoo';
|
||||
import type { EncuestaRespuesta, EncuestasReporte, NpsDesglose } from '../services/odoo';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { useI18n } from '../lib/i18n';
|
||||
import { useGlosario } from '../lib/glosario';
|
||||
import { exportToExcel } from '../lib/exporter';
|
||||
|
||||
const iso = (d: Date) => {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
};
|
||||
|
||||
const inicioMes = () => {
|
||||
const d = new Date();
|
||||
return iso(new Date(d.getFullYear(), d.getMonth(), 1));
|
||||
};
|
||||
|
||||
const npsColor = (n: number | null): string => {
|
||||
if (n === null) return 'text-theme-muted';
|
||||
if (n >= 50) return 'text-emerald-600';
|
||||
if (n >= 0) return 'text-amber-600';
|
||||
return 'text-rose-600';
|
||||
};
|
||||
|
||||
const CATEGORIA_STYLE: Record<string, string> = {
|
||||
promotor: 'bg-emerald-100 text-emerald-700',
|
||||
pasivo: 'bg-amber-100 text-amber-700',
|
||||
detractor: 'bg-rose-100 text-rose-700',
|
||||
};
|
||||
|
||||
const Satisfaccion: FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { term } = useGlosario();
|
||||
const { can } = useAuth();
|
||||
const { openPatient } = usePatientDrawer();
|
||||
|
||||
const [start, setStart] = useState(inicioMes());
|
||||
const [end, setEnd] = useState(iso(new Date()));
|
||||
const [reporte, setReporte] = useState<EncuestasReporte | null>(null);
|
||||
const [respuestas, setRespuestas] = useState<EncuestaRespuesta[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [reply, setReply] = useState<EncuestaRespuesta | null>(null);
|
||||
const [replyText, setReplyText] = useState('');
|
||||
|
||||
const cargar = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [rep, res] = await Promise.all([
|
||||
odooApi.getEncuestasReporte(start, end),
|
||||
odooApi.getEncuestasRespuestas(start, end),
|
||||
]);
|
||||
if (rep.status === 'success') setReporte(rep);
|
||||
if (res.status === 'success') setRespuestas(res.respuestas || []);
|
||||
} catch {
|
||||
toast.error(t('nps.title'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [start, end, t]);
|
||||
|
||||
useEffect(() => { void cargar(); }, [cargar]);
|
||||
|
||||
const procesar = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await odooApi.procesarEncuestas();
|
||||
if (res.status === 'success') { toast.success(t('nps.processed')); await cargar(); }
|
||||
else toast.error(res.message || '');
|
||||
} catch { toast.error(t('nps.processed')); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const enviarRespuesta = async () => {
|
||||
if (!reply || !replyText.trim()) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await odooApi.responderEncuestaWhatsapp(reply.id, replyText.trim());
|
||||
if (res.status === 'success') {
|
||||
toast.success(res.simulado ? t('nps.replySimulated') : t('nps.replySent'));
|
||||
setReply(null);
|
||||
setReplyText('');
|
||||
} else {
|
||||
toast.error(res.message || '');
|
||||
}
|
||||
} catch { toast.error(t('nps.reply')); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const exportar = async () => {
|
||||
if (!reporte) return;
|
||||
try {
|
||||
await exportToExcel({
|
||||
filename: `satisfaccion-${start}_${end}`,
|
||||
sheetName: 'Satisfacción',
|
||||
title: `Satisfacción (NPS) ${start} → ${end}`,
|
||||
subtitle: `NPS ${reporte.general.nps ?? '—'} · ${reporte.general.total} respuestas`,
|
||||
columns: [
|
||||
{ header: t('nps.period'), key: 'bloque' },
|
||||
{ header: t('common.name'), key: 'nombre' },
|
||||
{ header: t('nps.respuestas'), key: 'total', format: 'number' },
|
||||
{ header: t('nps.promotores'), key: 'promotores', format: 'number' },
|
||||
{ header: t('nps.pasivos'), key: 'pasivos', format: 'number' },
|
||||
{ header: t('nps.detractores'), key: 'detractores', format: 'number' },
|
||||
{ header: 'NPS', key: 'nps', format: 'number' },
|
||||
],
|
||||
rows: [
|
||||
{ bloque: t('nps.general'), nombre: '—', ...reporte.general },
|
||||
...reporte.por_sucursal.map((r) => ({ bloque: t('nps.byBranch'), ...r })),
|
||||
...reporte.por_doctor.map((r) => ({ bloque: t('nps.byDoctor'), ...r })),
|
||||
...reporte.por_servicio.map((r) => ({ bloque: t('nps.byService'), ...r })),
|
||||
],
|
||||
});
|
||||
toast.success(t('common.export'));
|
||||
} catch { toast.error(t('common.export')); }
|
||||
};
|
||||
|
||||
const exportarRespuestas = async () => {
|
||||
try {
|
||||
await exportToExcel({
|
||||
filename: `satisfaccion-respuestas-${start}_${end}`,
|
||||
sheetName: 'Respuestas',
|
||||
title: `Respuestas de satisfacción ${start} → ${end}`,
|
||||
columns: [
|
||||
{ header: term('patient_singular'), key: 'patient' },
|
||||
{ header: 'NPS', key: 'nps_puntaje', format: 'number' },
|
||||
{ header: t('common.status'), key: 'nps_categoria' },
|
||||
{ header: t('nps.comment'), key: 'comentario' },
|
||||
{ header: term('branch_singular'), key: 'sucursal' },
|
||||
{ header: term('doctor_singular'), key: 'doctor' },
|
||||
{ header: term('service_singular'), key: 'servicio' },
|
||||
{ header: t('common.date'), key: 'llenado_at', format: 'date' },
|
||||
],
|
||||
rows: respuestas as unknown as Record<string, unknown>[],
|
||||
});
|
||||
toast.success(t('common.export'));
|
||||
} catch { toast.error(t('common.export')); }
|
||||
};
|
||||
|
||||
const g = reporte?.general;
|
||||
|
||||
return (
|
||||
<Layout title={t('nps.title')} subtitle={t('nps.subtitle')}>
|
||||
<PageHeader title={t('nps.title')} subtitle={t('nps.subtitle')}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input type="date" value={start} onChange={(e) => setStart(e.target.value)}
|
||||
className="rounded-xl border border-theme-border-strong bg-theme-surface px-3 py-2 text-sm text-theme-heading" />
|
||||
<input type="date" value={end} onChange={(e) => setEnd(e.target.value)}
|
||||
className="rounded-xl border border-theme-border-strong bg-theme-surface px-3 py-2 text-sm text-theme-heading" />
|
||||
{can('encuestas.gestionar') && (
|
||||
<Button variant="outline" onClick={() => void procesar()} loading={busy}>
|
||||
<RefreshCw size={16} className="mr-2" />{t('nps.process')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => void exportar()} disabled={!reporte}>
|
||||
<Download size={16} className="mr-2" />{t('nps.export')}
|
||||
</Button>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
{loading ? (
|
||||
<Card><Card.Body><p className="p-6 text-sm text-theme-muted">{t('common.loading')}</p></Card.Body></Card>
|
||||
) : !g || g.total === 0 ? (
|
||||
<Card><Card.Body><EmptyState icon={<Smile size={28} />} title={t('nps.noData')} /></Card.Body></Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card><Card.Body>
|
||||
<p className="text-xs uppercase text-theme-muted">{t('nps.general')}</p>
|
||||
<p className={`mt-1 font-heading text-3xl ${npsColor(g.nps)}`}>{g.nps}</p>
|
||||
<p className="text-xs text-theme-muted">{t('nps.respuestas')}: {g.total}</p>
|
||||
</Card.Body></Card>
|
||||
<Card><Card.Body>
|
||||
<p className="text-xs uppercase text-theme-muted">{t('nps.promotores')}</p>
|
||||
<p className="mt-1 font-heading text-2xl text-emerald-600">{g.promotores}</p>
|
||||
<p className="text-xs text-theme-muted">{g.promotores_pct}%</p>
|
||||
</Card.Body></Card>
|
||||
<Card><Card.Body>
|
||||
<p className="text-xs uppercase text-theme-muted">{t('nps.pasivos')}</p>
|
||||
<p className="mt-1 font-heading text-2xl text-amber-600">{g.pasivos}</p>
|
||||
<p className="text-xs text-theme-muted">{g.pasivos_pct}%</p>
|
||||
</Card.Body></Card>
|
||||
<Card><Card.Body>
|
||||
<p className="text-xs uppercase text-theme-muted">{t('nps.detractores')}</p>
|
||||
<p className="mt-1 font-heading text-2xl text-rose-600">{g.detractores}</p>
|
||||
<p className="text-xs text-theme-muted">{g.detractores_pct}%</p>
|
||||
</Card.Body></Card>
|
||||
</div>
|
||||
|
||||
<Card><Card.Body>
|
||||
<h3 className="mb-3 font-heading text-base text-theme-heading">{t('nps.trend')}</h3>
|
||||
<div className="flex items-end gap-1 overflow-x-auto pb-2">
|
||||
{reporte?.tendencia.map((m) => {
|
||||
const alto = Math.max(4, Math.min(100, Math.abs(m.nps ?? 0)));
|
||||
return (
|
||||
<div key={m.mes} className="flex w-12 shrink-0 flex-col items-center gap-1">
|
||||
<span className={`text-[10px] ${npsColor(m.nps)}`}>{m.nps ?? '—'}</span>
|
||||
<div className="flex h-24 w-6 items-end rounded bg-theme-bg">
|
||||
<div className={`w-full rounded ${(m.nps ?? 0) >= 0 ? 'bg-emerald-400' : 'bg-rose-400'}`} style={{ height: `${alto}%` }} />
|
||||
</div>
|
||||
<span className="text-[10px] text-theme-muted">{m.mes.slice(5)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card.Body></Card>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<DesgloseCard title={t('nps.byBranch')} rows={reporte?.por_sucursal || []} />
|
||||
<DesgloseCard title={t('nps.byDoctor')} rows={reporte?.por_doctor || []} />
|
||||
<DesgloseCard title={t('nps.byService')} rows={reporte?.por_servicio || []} />
|
||||
</div>
|
||||
|
||||
<Card><Card.Body>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="font-heading text-base text-theme-heading">{t('nps.responses')}</h3>
|
||||
<Button size="sm" variant="outline" onClick={() => void exportarRespuestas()} disabled={!respuestas.length}>
|
||||
<Download size={14} className="mr-2" />{t('nps.exportRespuestas')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="divide-y divide-theme-border">
|
||||
{respuestas.map((r) => (
|
||||
<div key={r.id} className="flex flex-wrap items-start justify-between gap-3 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium text-theme-heading">{r.patient}</span>
|
||||
<span className="rounded-full bg-theme-bg px-2 py-0.5 text-[11px] font-medium text-theme-heading">
|
||||
{r.nps_puntaje}/10
|
||||
</span>
|
||||
{r.nps_categoria && (
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${CATEGORIA_STYLE[r.nps_categoria] || 'bg-theme-bg text-theme-muted'}`}>
|
||||
{t(`nps.category.${r.nps_categoria}`)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{r.comentario && <p className="mt-1 text-sm text-theme-muted">{r.comentario}</p>}
|
||||
<p className="mt-1 text-[11px] text-theme-muted">
|
||||
{[r.sucursal, r.doctor, r.servicio].filter(Boolean).join(' · ')} · {r.llenado_at || ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={() => openPatient(r.partner_id)}>
|
||||
<UserRound size={14} className="mr-1" />{t('nps.openRecord')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => { setReply(r); setReplyText(''); }}>
|
||||
<MessageCircle size={14} className="mr-1" />WhatsApp
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card.Body></Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal isOpen={!!reply} onClose={() => setReply(null)} title={t('nps.reply')}
|
||||
footer={
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setReply(null)}>{t('common.cancel')}</Button>
|
||||
<Button onClick={() => void enviarRespuesta()} loading={busy}>{t('nps.replySend')}</Button>
|
||||
</div>
|
||||
}>
|
||||
{reply && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-theme-muted">{reply.patient} · {reply.phone || '—'}</p>
|
||||
<TextArea rows={4} value={replyText} onChange={(e) => setReplyText(e.target.value)}
|
||||
placeholder={t('nps.replyPlaceholder')} />
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
const DesgloseCard: FC<{ title: string; rows: NpsDesglose[] }> = ({ title, rows }) => (
|
||||
<Card><Card.Body>
|
||||
<h3 className="mb-3 font-heading text-base text-theme-heading">{title}</h3>
|
||||
{rows.length === 0 ? (
|
||||
<p className="text-sm text-theme-muted">—</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs uppercase text-theme-muted">
|
||||
<tr>
|
||||
<th className="text-left py-1.5">{title}</th>
|
||||
<th className="text-right py-1.5">NPS</th>
|
||||
<th className="text-right py-1.5">n</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{rows.map((r) => (
|
||||
<tr key={r.nombre}>
|
||||
<td className="py-1.5 text-theme-heading">{r.nombre}</td>
|
||||
<td className={`py-1.5 text-right font-medium ${npsColor(r.nps)}`}>{r.nps ?? '—'}</td>
|
||||
<td className="py-1.5 text-right text-theme-muted">{r.total}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</Card.Body></Card>
|
||||
);
|
||||
|
||||
export default Satisfaccion;
|
||||
@@ -1097,6 +1097,8 @@ export interface ClinicSettings {
|
||||
propinas: PropinasSettings;
|
||||
licencia: LicenciaSettings;
|
||||
portal_cliente: PortalClienteSettings;
|
||||
campanas: CampanasSettings;
|
||||
encuestas: EncuestasSettings;
|
||||
}
|
||||
|
||||
// ---- Licencia por plan (bloque `licencia` de clinic-settings) ----
|
||||
@@ -1700,6 +1702,48 @@ export const PROPINAS_DEFAULT: PropinasSettings = {
|
||||
cuenta_propinas: '',
|
||||
};
|
||||
|
||||
// ---- Campañas por segmentos (bloque `campanas` de clinic-settings) ----
|
||||
export interface CampanasSettings {
|
||||
activo: boolean;
|
||||
envios_por_minuto: number;
|
||||
pausar_tras_errores: number;
|
||||
ventana_atribucion_dias: number;
|
||||
exigir_consentimiento: boolean;
|
||||
hora_envio: string;
|
||||
}
|
||||
|
||||
export const CAMPANAS_DEFAULT: CampanasSettings = {
|
||||
activo: false,
|
||||
envios_por_minuto: 20,
|
||||
pausar_tras_errores: 5,
|
||||
ventana_atribucion_dias: 7,
|
||||
exigir_consentimiento: true,
|
||||
hora_envio: '10:00',
|
||||
};
|
||||
|
||||
// ---- Encuestas de salida (bloque `encuestas` de clinic-settings) ----
|
||||
export type EncuestasCanal = 'whatsapp' | 'email' | 'ambos';
|
||||
|
||||
export interface EncuestasSettings {
|
||||
activo: boolean;
|
||||
enviar_tras_horas: number;
|
||||
plantilla_id: number | null;
|
||||
canal: EncuestasCanal;
|
||||
recordatorio_horas: number;
|
||||
solo_primera_visita: boolean;
|
||||
agradecimiento: string;
|
||||
}
|
||||
|
||||
export const ENCUESTAS_DEFAULT: EncuestasSettings = {
|
||||
activo: false,
|
||||
enviar_tras_horas: 2,
|
||||
plantilla_id: null,
|
||||
canal: 'whatsapp',
|
||||
recordatorio_horas: 48,
|
||||
solo_primera_visita: false,
|
||||
agradecimiento: '',
|
||||
};
|
||||
|
||||
export interface PropinaRepartoFila {
|
||||
employee_id: number | null;
|
||||
empleado: string;
|
||||
@@ -2456,7 +2500,7 @@ export interface Cobro {
|
||||
// ---- Formularios digitales y consentimientos ----
|
||||
export type FormularioCampoTipo =
|
||||
| 'texto' | 'texto_largo' | 'numero' | 'fecha' | 'seleccion' | 'multi_seleccion'
|
||||
| 'checkbox' | 'escala' | 'firma' | 'foto' | 'archivo' | 'consentimiento';
|
||||
| 'checkbox' | 'escala' | 'nps' | 'firma' | 'foto' | 'archivo' | 'consentimiento';
|
||||
export type FormularioPlantillaTipo = 'consentimiento' | 'expediente' | 'encuesta' | 'autorizacion';
|
||||
export type FormularioEstado = 'borrador' | 'enviado' | 'llenado' | 'cancelado';
|
||||
export type FormularioMomento = 'antes' | 'despues' | 'otro';
|
||||
@@ -2548,6 +2592,7 @@ export interface FormularioPublicoConfig {
|
||||
cita: { fecha: string | null; hora: string | null; servicio: string | null } | null;
|
||||
estado: FormularioEstado;
|
||||
ya_llenado: boolean;
|
||||
agradecimiento?: string;
|
||||
plantilla: {
|
||||
nombre: string;
|
||||
tipo: FormularioPlantillaTipo;
|
||||
@@ -2561,8 +2606,176 @@ export interface FormularioPublicoConfig {
|
||||
};
|
||||
}
|
||||
|
||||
export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
|
||||
nombre: BRAND_DEFAULT.nombre,
|
||||
// ---- Campañas y segmentos ----
|
||||
export interface SegmentoCriterios {
|
||||
ultima_visita_dias: number;
|
||||
sin_visita_meses: number;
|
||||
cumple_mes: boolean;
|
||||
cumple_proximos_dias: number;
|
||||
nivel_codigo: string;
|
||||
gasto_minimo: number;
|
||||
sucursal: string;
|
||||
servicio_id: number;
|
||||
servicio_nunca_id: number;
|
||||
con_membresia: boolean | null;
|
||||
con_paquetes: boolean | null;
|
||||
con_saldo: boolean | null;
|
||||
consentimiento: '' | 'con' | 'sin';
|
||||
}
|
||||
|
||||
export const SEGMENTO_CRITERIOS_DEFAULT: SegmentoCriterios = {
|
||||
ultima_visita_dias: 0,
|
||||
sin_visita_meses: 0,
|
||||
cumple_mes: false,
|
||||
cumple_proximos_dias: 0,
|
||||
nivel_codigo: '',
|
||||
gasto_minimo: 0,
|
||||
sucursal: '',
|
||||
servicio_id: 0,
|
||||
servicio_nunca_id: 0,
|
||||
con_membresia: null,
|
||||
con_paquetes: null,
|
||||
con_saldo: null,
|
||||
consentimiento: '',
|
||||
};
|
||||
|
||||
export interface SegmentoMuestraPaciente {
|
||||
id: number;
|
||||
name: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
last_visit: string | null;
|
||||
nivel: string;
|
||||
consentimiento: boolean;
|
||||
}
|
||||
|
||||
export interface Segmento {
|
||||
id: number;
|
||||
name: string;
|
||||
descripcion: string;
|
||||
activo: boolean;
|
||||
criterios: SegmentoCriterios;
|
||||
ultimo_conteo: number;
|
||||
muestra: SegmentoMuestraPaciente[];
|
||||
preview_at: string | null;
|
||||
creado_por: string;
|
||||
}
|
||||
|
||||
export type CampanaCanal = 'whatsapp' | 'email' | 'ambos';
|
||||
export type CampanaProgramacion = 'una_vez' | 'mensual' | 'semanal' | 'segmento';
|
||||
export type CampanaEstado = 'borrador' | 'programada' | 'en_ejecucion'
|
||||
| 'pausada' | 'terminada' | 'cancelada';
|
||||
|
||||
export interface Campana {
|
||||
id: number;
|
||||
name: string;
|
||||
descripcion: string;
|
||||
activo: boolean;
|
||||
segmento_id: number | null;
|
||||
segmento: string;
|
||||
canal: CampanaCanal;
|
||||
canales: string[];
|
||||
plantilla: string;
|
||||
enlace: string;
|
||||
programacion: CampanaProgramacion;
|
||||
fecha_programada: string | null;
|
||||
hora: string;
|
||||
estado: CampanaEstado;
|
||||
ultima_ejecucion: string | null;
|
||||
proxima_ejecucion: string | null;
|
||||
motivo_pausa: string;
|
||||
creado_por: string;
|
||||
notas: string;
|
||||
}
|
||||
|
||||
export interface CampanaMetricas {
|
||||
campana_id: number;
|
||||
campana: string;
|
||||
estado: CampanaEstado;
|
||||
alcanzados: number;
|
||||
enviados: number;
|
||||
enviados_reales: number;
|
||||
simulados: number;
|
||||
errores: number;
|
||||
cancelados: number;
|
||||
pendientes: number;
|
||||
entregados: number | null;
|
||||
entregados_disponible: boolean;
|
||||
respuestas: number | null;
|
||||
respuestas_disponible: boolean;
|
||||
citas_atribuidas: number;
|
||||
valor_atribuido: number;
|
||||
ventana_atribucion_dias: number;
|
||||
conversion_pct: number;
|
||||
}
|
||||
|
||||
export interface CampanaEnvio {
|
||||
id: number;
|
||||
partner_id: number | null;
|
||||
patient: string;
|
||||
canal: string;
|
||||
estado: string;
|
||||
es_prueba: boolean;
|
||||
destino: string;
|
||||
enviada_at: string | null;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface ConsentimientoPaciente {
|
||||
partner_id: number;
|
||||
name: string;
|
||||
general: boolean;
|
||||
whatsapp: boolean;
|
||||
email: boolean;
|
||||
at: string | null;
|
||||
origen: string;
|
||||
}
|
||||
|
||||
// ---- Satisfacción (NPS) ----
|
||||
export interface NpsResumen {
|
||||
total: number;
|
||||
promotores: number;
|
||||
pasivos: number;
|
||||
detractores: number;
|
||||
promotores_pct: number;
|
||||
detractores_pct: number;
|
||||
pasivos_pct: number;
|
||||
nps: number | null;
|
||||
}
|
||||
|
||||
export interface NpsDesglose extends NpsResumen {
|
||||
nombre: string;
|
||||
}
|
||||
|
||||
export interface EncuestaRespuesta {
|
||||
id: number;
|
||||
name: string;
|
||||
partner_id: number;
|
||||
patient: string;
|
||||
phone: string;
|
||||
nps_puntaje: number;
|
||||
nps_categoria: string;
|
||||
comentario: string;
|
||||
llenado_at: string | null;
|
||||
cita_id: number | null;
|
||||
sucursal: string;
|
||||
doctor: string;
|
||||
servicio: string;
|
||||
}
|
||||
|
||||
export interface EncuestasReporte {
|
||||
status: string;
|
||||
start: string;
|
||||
end: string;
|
||||
general: NpsResumen;
|
||||
tendencia: { mes: string; total: number; nps: number | null; promotores: number; detractores: number }[];
|
||||
por_sucursal: NpsDesglose[];
|
||||
por_doctor: NpsDesglose[];
|
||||
por_servicio: NpsDesglose[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = { nombre: BRAND_DEFAULT.nombre,
|
||||
telefono: '',
|
||||
email: '',
|
||||
direccion: '',
|
||||
@@ -2586,6 +2799,8 @@ export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
|
||||
propinas: PROPINAS_DEFAULT,
|
||||
licencia: LICENCIA_DEFAULT,
|
||||
portal_cliente: PORTAL_CLIENTE_DEFAULT,
|
||||
campanas: CAMPANAS_DEFAULT,
|
||||
encuestas: ENCUESTAS_DEFAULT,
|
||||
};
|
||||
|
||||
export interface FrontendUser {
|
||||
@@ -3297,6 +3512,127 @@ export const odooApi = {
|
||||
return data;
|
||||
},
|
||||
|
||||
// ---- Campañas y segmentos ----
|
||||
async listSegmentos(includeInactive = false): Promise<{ status: string; segmentos: Segmento[]; total: number }> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/campanas/segmentos`, {
|
||||
params: includeInactive ? { include_inactive: 1 } : {},
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
async crearSegmento(payload: { name: string; descripcion?: string; activo?: boolean; criterios: SegmentoCriterios }): Promise<{ status: string; segmento: Segmento; message?: string }> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/campanas/segmentos`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async actualizarSegmento(id: number, payload: Partial<{ name: string; descripcion: string; activo: boolean; criterios: SegmentoCriterios }>): Promise<{ status: string; segmento: Segmento; message?: string }> {
|
||||
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/campanas/segmentos/${id}`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async borrarSegmento(id: number): Promise<{ status: string; desactivado: boolean; message: string }> {
|
||||
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/campanas/segmentos/${id}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
async previewSegmento(criterios: SegmentoCriterios): Promise<{ status: string; total: number; muestra: SegmentoMuestraPaciente[]; message?: string }> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/campanas/segmentos/preview`, { criterios });
|
||||
return data;
|
||||
},
|
||||
|
||||
async recalcularSegmento(id: number): Promise<{ status: string; segmento: Segmento; message?: string }> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/campanas/segmentos/${id}/preview`, {});
|
||||
return data;
|
||||
},
|
||||
|
||||
async listCampanas(estado?: string): Promise<{ status: string; campanas: Campana[]; total: number }> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/campanas`, { params: estado ? { estado } : {} });
|
||||
return data;
|
||||
},
|
||||
|
||||
async crearCampana(payload: Partial<Campana>): Promise<{ status: string; campana: Campana; message?: string }> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/campanas`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async actualizarCampana(id: number, payload: Partial<Campana>): Promise<{ status: string; campana: Campana; message?: string }> {
|
||||
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/campanas/${id}`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async borrarCampana(id: number): Promise<{ status: string; message: string }> {
|
||||
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/campanas/${id}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
async accionCampana(id: number, accion: 'lanzar' | 'pausar' | 'reanudar' | 'cancelar' | 'borrador' | 'procesar'): Promise<{ status: string; campana?: Campana; encoladas?: number; enviadas?: number; simuladas?: number; fallidas?: number; pausadas?: number; limite_alcanzado?: boolean; message?: string }> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/campanas/${id}/${accion}`, {});
|
||||
return data;
|
||||
},
|
||||
|
||||
async procesarCampanas(): Promise<{ status: string; encoladas: number; enviadas: number; simuladas: number; fallidas: number; pausadas: number; message?: string }> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/campanas/procesar`, {});
|
||||
return data;
|
||||
},
|
||||
|
||||
async pruebaCampana(id: number, payload: { partner_id?: number; telefono?: string; correo?: string }): Promise<{ status: string; ok: boolean; simulado?: boolean; message: string }> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/campanas/${id}/prueba`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async metricasCampana(id: number, start?: string, end?: string): Promise<{ status: string; metricas: CampanaMetricas; message?: string }> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/campanas/${id}/metricas`, { params: { start, end } });
|
||||
return data;
|
||||
},
|
||||
|
||||
async metricasCampanas(start?: string, end?: string): Promise<{ status: string; metricas: CampanaMetricas[]; totales: { alcanzados: number; enviados: number; citas_atribuidas: number; valor_atribuido: number }; message?: string }> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/campanas/metricas`, { params: { start, end } });
|
||||
return data;
|
||||
},
|
||||
|
||||
async enviosCampana(id: number, incluirPruebas = false): Promise<{ status: string; envios: CampanaEnvio[]; total: number; message?: string }> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/campanas/${id}/envios`, {
|
||||
params: incluirPruebas ? { incluir_pruebas: 1 } : {},
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
async getConsentimiento(partnerId: number): Promise<{ status: string; consentimiento: ConsentimientoPaciente; message?: string }> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/campanas/consentimiento/${partnerId}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
async setConsentimiento(partnerId: number, payload: { general?: boolean; whatsapp?: boolean; email?: boolean }): Promise<{ status: string; consentimiento: ConsentimientoPaciente; message?: string }> {
|
||||
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/campanas/consentimiento/${partnerId}`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
// ---- Encuestas de salida (satisfacción) ----
|
||||
async getEncuestasConfig(): Promise<{ status: string; config: EncuestasSettings; plantilla: { id: number; name: string; activo: boolean } | null; plantillas: { id: number; name: string; activo: boolean; tiene_nps: boolean }[]; message?: string }> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/encuestas`);
|
||||
return data;
|
||||
},
|
||||
|
||||
async getEncuestasReporte(start?: string, end?: string): Promise<EncuestasReporte> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/encuestas/reporte`, { params: { start, end } });
|
||||
return data;
|
||||
},
|
||||
|
||||
async getEncuestasRespuestas(start?: string, end?: string): Promise<{ status: string; respuestas: EncuestaRespuesta[]; total: number; message?: string }> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/encuestas/respuestas`, { params: { start, end } });
|
||||
return data;
|
||||
},
|
||||
|
||||
async procesarEncuestas(): Promise<{ status: string; encoladas: number; recordatorios: number; enviadas: number; simuladas: number; message?: string }> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/encuestas/procesar`, {});
|
||||
return data;
|
||||
},
|
||||
|
||||
async responderEncuestaWhatsapp(id: number, mensaje: string): Promise<{ status: string; simulado: boolean; notificaciones: number; message?: string }> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/encuestas/respuestas/${id}/whatsapp`, { mensaje });
|
||||
return data;
|
||||
},
|
||||
|
||||
// ---- Lealtad: puntos y niveles ----
|
||||
async getPatientLealtad(patientId: number): Promise<LealtadPaciente> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/patients/${patientId}/lealtad`);
|
||||
|
||||
Reference in New Issue
Block a user