Permisos: marketing (export completo, agenda/citas, cumpleanos, visitas, WACRM), gerencia gestiona usuarios y roles, recepcion edita expedientes, cosmetologas ven agenda, fix del sidebar que no dejaba seleccionar Leads

This commit is contained in:
2026-09-18 03:23:44 +00:00
parent c38783eb31
commit cebf8d663d
13 changed files with 354 additions and 47 deletions

View File

@@ -105,9 +105,14 @@ const Agenda: FC = () => {
const navigate = useNavigate();
const { user } = useAuth();
const { openPatient } = usePatientDrawer();
// Rol médico: agenda restringida a su propio hr.employee (filtro también en servidor)
// Rol médico: agenda restringida a su propio hr.employee (filtro también en servidor).
// Cosmetología ve la agenda completa (es personal clínico, sin la privacidad
// restringida del médico), pero arranca en su propia columna si tiene
// empleado vinculado.
const isMedico = user?.role === 'medico';
const isCosmetologa = user?.role === 'cosmetologia';
const medicoEmployeeId = isMedico && user?.employee_id ? user.employee_id : null;
const propiaColumnaId = isCosmetologa && user?.employee_id ? user.employee_id : null;
const [appointments, setAppointments] = useState<Appointment[]>([]);
const [patients, setPatients] = useState<Patient[]>([]);
const [services, setServices] = useState<Service[]>([]);
@@ -120,6 +125,7 @@ const Agenda: FC = () => {
const [stateFilter, setStateFilter] = useState('');
const [doctorFilter, setDoctorFilter] = useState(() => {
if (medicoEmployeeId) return String(medicoEmployeeId);
if (propiaColumnaId) return String(propiaColumnaId);
const saved = readAgendaPrefs().doctorFilter;
return typeof saved === 'string' ? saved : '';
});

View File

@@ -1,5 +1,5 @@
import type { FC } from 'react';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Phone, MapPin, Mail, Globe, Save, Building2, ArrowRightLeft, Percent, Check, Calculator } from 'lucide-react';
import Layout from '../components/Layout';
@@ -11,6 +11,7 @@ import AuditoriaPanel from '../components/AuditoriaPanel';
import ImpactoModal from '../components/ImpactoModal';
import { Card, Button, Input, PageHeader, toast } from '../components/ui';
import { odooApi, type Doctor } from '../services/odoo';
import { useAuth } from '../lib/auth';
const TABS = [
{ key: 'clinica', label: 'Clínica' },
@@ -45,8 +46,17 @@ const defaultSettings: ClinicSettings = {
const STORAGE_KEY = 'skeen_clinic_settings';
const Configuracion: FC = () => {
const { user } = useAuth();
// Gerencia gestiona usuarios y roles; la sección "Clínica" (tipo de cambio y
// comisiones) queda para administración del sistema.
const esAdmin = user?.role === 'admin';
const tabsVisibles = useMemo(
() => (esAdmin ? TABS : TABS.filter((t) => t.key === 'usuarios' || t.key === 'roles')),
[esAdmin],
);
const [searchParams, setSearchParams] = useSearchParams();
const tab = searchParams.get('tab') || 'clinica';
const tabPedida = searchParams.get('tab') || 'clinica';
const tab = tabsVisibles.some((t) => t.key === tabPedida) ? tabPedida : tabsVisibles[0].key;
const setTab = (t: string) => setSearchParams({ tab: t });
const [subcatalogo, setSubcatalogo] = useState<string>(() => {
const sub = searchParams.get('sub');
@@ -85,6 +95,7 @@ const Configuracion: FC = () => {
const [tcSaving, setTcSaving] = useState(false);
useEffect(() => {
if (!esAdmin) return;
odooApi.getExchangeRate()
.then((res) => {
if (res.current) {
@@ -123,6 +134,7 @@ const Configuracion: FC = () => {
const [impactoOpen, setImpactoOpen] = useState(false);
useEffect(() => {
if (!esAdmin) return;
odooApi.getCommissionDefault()
.then((res) => setCommissionDefault(String(res.commission_pct ?? 0)))
.catch(() => {/* odoo no disponible, ignorar */});
@@ -184,7 +196,7 @@ const Configuracion: FC = () => {
{/* Tabs de secciones */}
<div className="flex flex-wrap gap-1.5 mb-6">
{TABS.map((t) => (
{tabsVisibles.map((t) => (
<button
key={t.key}
type="button"

View File

@@ -133,6 +133,7 @@ const Pacientes: FC = () => {
const [entryDetail, setEntryDetail] = useState<
{ kind: 'cita'; item: Appointment } | { kind: 'venta'; item: Sale } | { kind: 'visita'; item: Visita } | null
>(null);
const [exporting, setExporting] = useState(false);
const [onlyIncomplete, setOnlyIncomplete] = useState(false);
const [onlyVip, setOnlyVip] = useState(false);
const [onlyRecent, setOnlyRecent] = useState(false);
@@ -415,13 +416,30 @@ const Pacientes: FC = () => {
].sort((a, b) => b.date.localeCompare(a.date))
: [];
// Exportación COMPLETA: pide al servidor todos los pacientes del filtro
// (no sólo la página visible). Hoy son ~7.2k filas.
const exportPatients = async () => {
try {
setExporting(true);
const res = await odooApi.exportPatients({
search: search || undefined,
incomplete: onlyIncomplete ? 1 : undefined,
vip: onlyVip ? 1 : undefined,
recent: onlyRecent ? 1 : (onlyStale ? 0 : undefined),
doctor_id: doctorFilter ? parseInt(doctorFilter, 10) : undefined,
});
if (res.status !== 'success') {
toast.error('Error al exportar');
return;
}
const todos = res.patients;
await exportToExcel({
filename: `pacientes-skeen-${new Date().toISOString().split('T')[0]}`,
sheetName: 'Pacientes',
title: 'Pacientes SKEEN',
subtitle: `${patients.length} pacientes de la vista actual`,
subtitle: res.truncado
? `${res.total} de ${res.total_disponible} pacientes (límite ${res.limite})`
: `${res.total} pacientes (exportación completa)`,
columns: [
{ header: 'Expediente', key: 'patient_id' },
{ header: 'Nombre', key: 'name' },
@@ -436,24 +454,26 @@ const Pacientes: FC = () => {
{ header: 'Fuente', key: 'source' },
{ header: 'VIP', key: 'vip' },
],
rows: patients.map((p) => ({
rows: todos.map((p) => ({
patient_id: p.patient_id, name: p.name, phone: p.phone, email: p.email,
birth_date: p.birth_date, gender: p.gender || '', last_visit: p.last_visit,
total_visits: p.total_visits, wallet_points: p.wallet_points,
total_spent: p.total_spent, source: p.source, vip: p.is_vip ? 'Sí' : 'No',
})),
});
toast.success(`Excel descargado (${patients.length} pacientes de la vista actual)`);
toast.success(`Excel descargado (${res.total} pacientes)`);
} catch (err) {
toast.error('Error al exportar');
console.error(err);
} finally {
setExporting(false);
}
};
return (
<Layout title="Pacientes" subtitle="Directorio de pacientes">
<PageHeader title="Pacientes" subtitle="Busca, crea y gestiona pacientes">
<Button variant="outline" onClick={exportPatients} disabled={patients.length === 0}>
<Button variant="outline" onClick={exportPatients} loading={exporting} disabled={patients.length === 0}>
<Download size={16} className="mr-2" />
Exportar CSV
</Button>