UX Dashboard: fila Hoy en la clinica, KPIs clickeables, deltas; Cumpleanos wa.me E.164; Mis ventas con grafica diaria
This commit is contained in:
20
frontend/src/lib/whatsapp.ts
Normal file
20
frontend/src/lib/whatsapp.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// WhatsApp: enlace wa.me con número en formato E.164 (México = 52 + número,
|
||||||
|
// sin "+", sin espacios). Devuelve null si no hay número utilizable.
|
||||||
|
export const waNumber = (raw?: string | null): string | null => {
|
||||||
|
if (!raw) return null;
|
||||||
|
const digits = raw.replace(/\D/g, '');
|
||||||
|
if (!digits) return null;
|
||||||
|
// Ya trae lada del país (52155..., 52661..., etc.)
|
||||||
|
if (digits.startsWith('52')) return digits;
|
||||||
|
// Número local de 10 dígitos: anteponer lada de México
|
||||||
|
if (digits.length === 10) return `52${digits}`;
|
||||||
|
// Otros formatos: quitar ceros a la izquierda (ej. "01" de larga distancia)
|
||||||
|
return digits.replace(/^0+/, '') || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const waLink = (raw?: string | null, text?: string): string | null => {
|
||||||
|
const number = waNumber(raw);
|
||||||
|
if (!number) return null;
|
||||||
|
const q = text ? `?text=${encodeURIComponent(text)}` : '';
|
||||||
|
return `https://wa.me/${number}${q}`;
|
||||||
|
};
|
||||||
@@ -5,6 +5,7 @@ import Layout from '../components/Layout';
|
|||||||
import { Card, PageHeader, Button, toast } from '../components/ui';
|
import { Card, PageHeader, Button, toast } from '../components/ui';
|
||||||
import { odooApi } from '../services/odoo';
|
import { odooApi } from '../services/odoo';
|
||||||
import type { Birthday } from '../services/odoo';
|
import type { Birthday } from '../services/odoo';
|
||||||
|
import { waLink } from '../lib/whatsapp';
|
||||||
import { exportToExcel } from '../lib/exporter';
|
import { exportToExcel } from '../lib/exporter';
|
||||||
|
|
||||||
type Period = 'today' | 'week' | 'month';
|
type Period = 'today' | 'week' | 'month';
|
||||||
@@ -24,6 +25,9 @@ const fmtDate = (s: string | null) => {
|
|||||||
return d.toLocaleDateString('es-MX', { day: '2-digit', month: 'short' });
|
return d.toLocaleDateString('es-MX', { day: '2-digit', month: 'short' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const waBirthdayText = (name: string) =>
|
||||||
|
`¡Feliz cumpleaños ${(name || '').split(' ')[0]}! Le saluda el equipo de SKEEN Derma Experts.`;
|
||||||
|
|
||||||
const Cumpleanos: FC = () => {
|
const Cumpleanos: FC = () => {
|
||||||
const [period, setPeriod] = useState<Period>('month');
|
const [period, setPeriod] = useState<Period>('month');
|
||||||
const [items, setItems] = useState<Birthday[]>([]);
|
const [items, setItems] = useState<Birthday[]>([]);
|
||||||
@@ -156,17 +160,24 @@ const Cumpleanos: FC = () => {
|
|||||||
{fmtMoney(b.amount_due)}
|
{fmtMoney(b.amount_due)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
{b.phone && (
|
{waLink(b.whatsapp || b.phone, waBirthdayText(b.name)) ? (
|
||||||
<a
|
<a
|
||||||
href={`https://wa.me/${b.phone.replace(/\D/g, '')}?text=${encodeURIComponent(
|
href={waLink(b.whatsapp || b.phone, waBirthdayText(b.name)) || '#'}
|
||||||
`¡Feliz cumpleaños ${b.name.split(' ')[0]}! 🎉 Te esperamos en SKEEN Derma Experts.`
|
|
||||||
)}`}
|
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="inline-flex items-center gap-1 text-theme-heading hover:text-theme-heading text-xs font-medium"
|
className="inline-flex items-center gap-1 text-theme-heading hover:text-theme-accent text-xs font-medium"
|
||||||
|
title={`Abrir WhatsApp: ${b.whatsapp || b.phone}`}
|
||||||
>
|
>
|
||||||
<MessageCircle size={14} /> WhatsApp
|
<MessageCircle size={14} /> WhatsApp
|
||||||
</a>
|
</a>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-medium text-theme-muted opacity-50 cursor-not-allowed"
|
||||||
|
title="Paciente sin teléfono registrado"
|
||||||
|
aria-disabled="true"
|
||||||
|
>
|
||||||
|
<MessageCircle size={14} /> WhatsApp
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { FC, ReactNode } from 'react';
|
import type { FC, ReactNode, KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||||
import { useEffect, useState, useMemo } from 'react';
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
@@ -12,6 +12,12 @@ import {
|
|||||||
Activity,
|
Activity,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
ArrowUpRight,
|
||||||
|
ArrowDownRight,
|
||||||
|
CalendarClock,
|
||||||
|
Armchair,
|
||||||
|
Banknote,
|
||||||
|
Vault,
|
||||||
Cake,
|
Cake,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -45,40 +51,112 @@ import { odooApi, type DashboardStats, type Appointment, type Service, type Char
|
|||||||
import { usePatientDrawer } from '../components/PatientDrawer';
|
import { usePatientDrawer } from '../components/PatientDrawer';
|
||||||
import { useTheme } from '../lib/theme';
|
import { useTheme } from '../lib/theme';
|
||||||
import { useAuth } from '../lib/auth';
|
import { useAuth } from '../lib/auth';
|
||||||
|
import { waLink } from '../lib/whatsapp';
|
||||||
|
|
||||||
const formatCurrency = (value: number) =>
|
const formatCurrency = (value: number) =>
|
||||||
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
|
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
|
||||||
|
|
||||||
|
type StatDelta = { pct: number; direction: 'up' | 'down' | 'flat' } | null;
|
||||||
|
|
||||||
interface StatCardProps {
|
interface StatCardProps {
|
||||||
title: string;
|
title: string;
|
||||||
value: string | number;
|
value: string | number;
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
trend?: string;
|
trend?: string;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
onOpen?: () => void;
|
||||||
|
delta?: StatDelta;
|
||||||
|
deltaLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const StatCard: FC<StatCardProps> = ({ title, value, icon, trend, loading }) => {
|
const StatCard: FC<StatCardProps> = ({ title, value, icon, trend, loading, onOpen, delta, deltaLabel }) => {
|
||||||
if (loading) return <SkeletonStat />;
|
if (loading) return <SkeletonStat />;
|
||||||
|
const clickable = typeof onOpen === 'function';
|
||||||
|
const deltaColor =
|
||||||
|
delta?.direction === 'up'
|
||||||
|
? 'text-emerald-600'
|
||||||
|
: delta?.direction === 'down'
|
||||||
|
? 'text-rose-600'
|
||||||
|
: 'text-theme-muted';
|
||||||
return (
|
return (
|
||||||
<Card className="hover:shadow-md transition">
|
<div
|
||||||
<Card.Body>
|
{...(clickable
|
||||||
<div className="flex items-center justify-between mb-3">
|
? {
|
||||||
<div className="w-10 h-10 rounded-xl bg-theme-success/50 flex items-center justify-center text-theme-heading">
|
onClick: onOpen,
|
||||||
{icon}
|
role: 'button',
|
||||||
|
tabIndex: 0,
|
||||||
|
'aria-label': `${title}. Ir al módulo`,
|
||||||
|
onKeyDown: (e: ReactKeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onOpen?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
className: 'cursor-pointer group focus:outline-none focus-visible:ring-2 focus-visible:ring-theme-accent rounded-2xl',
|
||||||
|
}
|
||||||
|
: {})}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
className={[
|
||||||
|
'h-full transition',
|
||||||
|
clickable
|
||||||
|
? 'group-hover:shadow-md group-hover:-translate-y-0.5 group-hover:border-theme-accent/40'
|
||||||
|
: 'hover:shadow-md',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<Card.Body>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-theme-success/50 flex items-center justify-center text-theme-heading">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
{trend && (
|
||||||
|
<span className="text-xs font-medium text-theme-heading bg-[#CAE2C1] px-2 py-0.5 rounded-full">
|
||||||
|
{trend}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{trend && (
|
<p className="text-sm text-theme-muted mb-1">{title}</p>
|
||||||
<span className="text-xs font-medium text-theme-heading bg-[#CAE2C1] px-2 py-0.5 rounded-full">
|
<p className="text-2xl font-heading font-semibold text-theme-heading">{value}</p>
|
||||||
{trend}
|
{delta && (
|
||||||
</span>
|
<p className="mt-1.5 flex items-center gap-1 text-xs">
|
||||||
|
<span className={`inline-flex items-center gap-0.5 font-medium ${deltaColor}`}>
|
||||||
|
{delta.direction === 'up' && <ArrowUpRight size={12} />}
|
||||||
|
{delta.direction === 'down' && <ArrowDownRight size={12} />}
|
||||||
|
{Math.abs(delta.pct)}%
|
||||||
|
</span>
|
||||||
|
<span className="text-theme-muted">{deltaLabel || 'vs período anterior'}</span>
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Card.Body>
|
||||||
<p className="text-sm text-theme-muted mb-1">{title}</p>
|
</Card>
|
||||||
<p className="text-2xl font-heading font-semibold text-theme-heading">{value}</p>
|
</div>
|
||||||
</Card.Body>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface TodayTileProps {
|
||||||
|
label: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
onClick: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
ariaLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TodayTile: FC<TodayTileProps> = ({ label, icon, onClick, children, className, ariaLabel }) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className={`text-left rounded-xl border border-theme-border-strong bg-theme-surface p-3.5 cursor-pointer hover:bg-theme-bg hover:shadow-sm transition focus:outline-none focus-visible:ring-2 focus-visible:ring-theme-accent ${className || ''}`}
|
||||||
|
>
|
||||||
|
<p className="flex items-center gap-1.5 mb-2 text-[11px] font-medium uppercase tracking-wide text-theme-muted">
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
const Dashboard: FC = () => {
|
const Dashboard: FC = () => {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@@ -99,6 +177,12 @@ const Dashboard: FC = () => {
|
|||||||
// Cumpleaños del día (endpoint /reports/birthdays?period=today)
|
// Cumpleaños del día (endpoint /reports/birthdays?period=today)
|
||||||
const [birthdays, setBirthdays] = useState<Birthday[]>([]);
|
const [birthdays, setBirthdays] = useState<Birthday[]>([]);
|
||||||
const [loadingBirthdays, setLoadingBirthdays] = useState(true);
|
const [loadingBirthdays, setLoadingBirthdays] = useState(true);
|
||||||
|
// Datos de "Hoy en la clínica": sala de espera, cobros del día y estado de caja
|
||||||
|
const [todayInfo, setTodayInfo] = useState<{
|
||||||
|
waiting: number | null;
|
||||||
|
cobros: number | null;
|
||||||
|
caja: { state: string; name: string } | null;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadStats = async () => {
|
const loadStats = async () => {
|
||||||
@@ -192,14 +276,46 @@ const Dashboard: FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadToday = async () => {
|
||||||
|
// Los endpoints de reportes/caja no están disponibles para todos los
|
||||||
|
// roles (p. ej. médico): ante error el tile muestra "—" sin molestar.
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
const [dailyRes, cashRes] = await Promise.allSettled([
|
||||||
|
odooApi.getDailyReport(today),
|
||||||
|
odooApi.getCashClosings(today),
|
||||||
|
]);
|
||||||
|
const next: { waiting: number | null; cobros: number | null; caja: { state: string; name: string } | null } = {
|
||||||
|
waiting: null,
|
||||||
|
cobros: null,
|
||||||
|
caja: null,
|
||||||
|
};
|
||||||
|
if (dailyRes.status === 'fulfilled' && dailyRes.value.status === 'success') {
|
||||||
|
next.waiting = dailyRes.value.citas?.by_state?.arrived ?? 0;
|
||||||
|
next.cobros = dailyRes.value.pagos?.total ?? 0;
|
||||||
|
}
|
||||||
|
if (cashRes.status === 'fulfilled' && cashRes.value.status === 'success') {
|
||||||
|
const closing = (cashRes.value.cash_closings || [])[0];
|
||||||
|
if (closing) next.caja = { state: closing.state, name: closing.name };
|
||||||
|
}
|
||||||
|
setTodayInfo(next);
|
||||||
|
};
|
||||||
|
|
||||||
loadStats();
|
loadStats();
|
||||||
loadChart();
|
loadChart();
|
||||||
loadAppointments();
|
loadAppointments();
|
||||||
loadServices();
|
loadServices();
|
||||||
loadExtras();
|
loadExtras();
|
||||||
loadBirthdays();
|
loadBirthdays();
|
||||||
|
loadToday();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const buildDelta = (current?: number, prev?: number): StatDelta => {
|
||||||
|
if (current == null || prev == null || prev <= 0) return null;
|
||||||
|
const pct = Math.round(((current - prev) / prev) * 100);
|
||||||
|
if (pct === 0) return { pct: 0, direction: 'flat' };
|
||||||
|
return { pct: Math.abs(pct), direction: pct > 0 ? 'up' : 'down' };
|
||||||
|
};
|
||||||
|
|
||||||
const statsConfig = useMemo(
|
const statsConfig = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -207,37 +323,75 @@ const Dashboard: FC = () => {
|
|||||||
value: stats?.appointments_today ?? 0,
|
value: stats?.appointments_today ?? 0,
|
||||||
icon: <Calendar size={20} />,
|
icon: <Calendar size={20} />,
|
||||||
trend: stats ? `${stats.appointments_month} este mes` : undefined,
|
trend: stats ? `${stats.appointments_month} este mes` : undefined,
|
||||||
|
to: '/agenda',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Pacientes',
|
title: 'Pacientes',
|
||||||
value: stats?.total_patients ?? 0,
|
value: stats?.total_patients ?? 0,
|
||||||
icon: <Users size={20} />,
|
icon: <Users size={20} />,
|
||||||
trend: stats ? `+${stats.new_patients_month} nuevos` : undefined,
|
trend: stats ? `+${stats.new_patients_month} nuevos` : undefined,
|
||||||
|
to: '/pacientes',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Ingresos Mes',
|
title: 'Ingresos Mes',
|
||||||
value: stats ? formatCurrency(stats.revenue_month) : '$0',
|
value: stats ? formatCurrency(stats.revenue_month) : '$0',
|
||||||
icon: <DollarSign size={20} />,
|
icon: <DollarSign size={20} />,
|
||||||
|
to: '/reportes?tab=ingresos',
|
||||||
|
delta: buildDelta(stats?.revenue_month, stats?.prev_revenue_month),
|
||||||
|
deltaLabel: 'vs mes anterior',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Ventas Hoy',
|
title: 'Ventas Hoy',
|
||||||
value: stats?.sales_today ?? 0,
|
value: stats?.sales_today ?? 0,
|
||||||
icon: <ShoppingCart size={20} />,
|
icon: <ShoppingCart size={20} />,
|
||||||
|
to: '/ventas',
|
||||||
|
delta: buildDelta(stats?.sales_today, stats?.prev_sales_today),
|
||||||
|
deltaLabel: 'vs ayer',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Pendientes',
|
title: 'Pendientes',
|
||||||
value: stats?.pending_payments ?? 0,
|
value: stats?.pending_payments ?? 0,
|
||||||
icon: <Clock size={20} />,
|
icon: <Clock size={20} />,
|
||||||
|
to: '/reportes?tab=adeudos',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Puntos Monedero',
|
title: 'Puntos Monedero',
|
||||||
value: stats?.total_wallet_points ?? 0,
|
value: stats?.total_wallet_points ?? 0,
|
||||||
icon: <Wallet size={20} />,
|
icon: <Wallet size={20} />,
|
||||||
|
to: '/monedero',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[stats]
|
[stats]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Próximas citas de hoy (hora >= ahora); si ya no queda ninguna, las
|
||||||
|
// primeras del día ordenadas por hora.
|
||||||
|
const upcomingToday = useMemo(() => {
|
||||||
|
const nowMin = new Date().getHours() * 60 + new Date().getMinutes();
|
||||||
|
const toMinutes = (t: string) => {
|
||||||
|
const [h, m] = String(t || '0:0').split(':').map(Number);
|
||||||
|
return (h || 0) * 60 + (m || 0);
|
||||||
|
};
|
||||||
|
const active = appointments.filter((a) => !['cancelled', 'done', 'no_show'].includes(a.state));
|
||||||
|
const sorted = [...active].sort((a, b) => toMinutes(a.time) - toMinutes(b.time));
|
||||||
|
const upcoming = sorted.filter((a) => toMinutes(a.time) >= nowMin);
|
||||||
|
return (upcoming.length > 0 ? upcoming : sorted).slice(0, 3);
|
||||||
|
}, [appointments]);
|
||||||
|
|
||||||
|
const todayLabel = new Date().toLocaleDateString('es-MX', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
});
|
||||||
|
|
||||||
|
const cajaStatus = useMemo(() => {
|
||||||
|
if (!todayInfo) return { label: '—', ref: 'Consultando…', tone: 'text-theme-muted' };
|
||||||
|
if (!todayInfo.caja) return { label: 'Sin abrir', ref: 'Sin corte de caja hoy', tone: 'text-theme-muted' };
|
||||||
|
if (todayInfo.caja.state === 'open')
|
||||||
|
return { label: 'Abierta', ref: todayInfo.caja.name, tone: 'text-emerald-600' };
|
||||||
|
return { label: 'Cerrada', ref: todayInfo.caja.name, tone: 'text-theme-heading' };
|
||||||
|
}, [todayInfo]);
|
||||||
|
|
||||||
const activityItems = useMemo(() => {
|
const activityItems = useMemo(() => {
|
||||||
return appointments.slice(0, 4).map((apt) => ({
|
return appointments.slice(0, 4).map((apt) => ({
|
||||||
id: apt.id,
|
id: apt.id,
|
||||||
@@ -326,6 +480,85 @@ const Dashboard: FC = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Hoy en la clínica: acciones rápidas del día */}
|
||||||
|
<Card className="mb-6 sm:mb-8">
|
||||||
|
<Card.Header>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-heading text-lg text-theme-heading">Hoy en la clínica</h3>
|
||||||
|
<p className="text-xs text-theme-muted capitalize">{todayLabel}</p>
|
||||||
|
</div>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Body>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-5 gap-3">
|
||||||
|
<TodayTile
|
||||||
|
label="Próximas citas"
|
||||||
|
icon={<CalendarClock size={14} />}
|
||||||
|
onClick={() => navigate('/agenda')}
|
||||||
|
ariaLabel="Ver próximas citas en la agenda"
|
||||||
|
className="md:col-span-2"
|
||||||
|
>
|
||||||
|
{upcomingToday.length === 0 ? (
|
||||||
|
<p className="text-sm text-theme-muted">Sin citas activas para hoy.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{upcomingToday.map((apt) => (
|
||||||
|
<div key={apt.id} className="flex items-center gap-2 text-sm min-w-0">
|
||||||
|
<span className="font-medium text-theme-heading tabular-nums shrink-0">{apt.time}</span>
|
||||||
|
<span className="text-theme-heading truncate flex-1">{apt.patient}</span>
|
||||||
|
<span className="text-xs text-theme-muted truncate hidden sm:inline max-w-[120px]">
|
||||||
|
{apt.doctor || 'Sin médico'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TodayTile>
|
||||||
|
|
||||||
|
<TodayTile
|
||||||
|
label="Sala de espera"
|
||||||
|
icon={<Armchair size={14} />}
|
||||||
|
onClick={() => navigate('/agenda')}
|
||||||
|
ariaLabel="Ver pacientes en sala de espera"
|
||||||
|
>
|
||||||
|
<p className="text-2xl font-heading font-semibold text-theme-heading">
|
||||||
|
{todayInfo ? (todayInfo.waiting ?? '—') : '—'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-theme-muted">
|
||||||
|
{todayInfo?.waiting != null
|
||||||
|
? todayInfo.waiting === 1
|
||||||
|
? 'paciente en espera'
|
||||||
|
: 'pacientes en espera'
|
||||||
|
: 'no disponible'}
|
||||||
|
</p>
|
||||||
|
</TodayTile>
|
||||||
|
|
||||||
|
<TodayTile
|
||||||
|
label="Caja"
|
||||||
|
icon={<Vault size={14} />}
|
||||||
|
onClick={() => navigate('/cortes')}
|
||||||
|
ariaLabel="Ir a cortes de caja"
|
||||||
|
>
|
||||||
|
<p className={`text-xl font-heading font-semibold ${cajaStatus.tone}`}>{cajaStatus.label}</p>
|
||||||
|
<p className="text-xs text-theme-muted truncate" title={cajaStatus.ref}>
|
||||||
|
{cajaStatus.ref}
|
||||||
|
</p>
|
||||||
|
</TodayTile>
|
||||||
|
|
||||||
|
<TodayTile
|
||||||
|
label="Cobros del día"
|
||||||
|
icon={<Banknote size={14} />}
|
||||||
|
onClick={() => navigate('/ventas')}
|
||||||
|
ariaLabel="Ver cobros del día en ventas"
|
||||||
|
>
|
||||||
|
<p className="text-2xl font-heading font-semibold text-theme-heading">
|
||||||
|
{todayInfo ? (todayInfo.cobros != null ? formatCurrency(todayInfo.cobros) : '—') : '—'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-theme-muted">pagos completados hoy</p>
|
||||||
|
</TodayTile>
|
||||||
|
</div>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Stats grid */}
|
{/* Stats grid */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-4 mb-6 sm:mb-8">
|
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-4 mb-6 sm:mb-8">
|
||||||
{statsConfig.map((s) => (
|
{statsConfig.map((s) => (
|
||||||
@@ -336,6 +569,9 @@ const Dashboard: FC = () => {
|
|||||||
icon={s.icon}
|
icon={s.icon}
|
||||||
trend={s.trend}
|
trend={s.trend}
|
||||||
loading={loadingStats}
|
loading={loadingStats}
|
||||||
|
onOpen={s.to ? () => navigate(s.to!) : undefined}
|
||||||
|
delta={'delta' in s ? (s.delta as StatDelta) : undefined}
|
||||||
|
deltaLabel={'deltaLabel' in s ? (s.deltaLabel as string) : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -379,7 +615,7 @@ const Dashboard: FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
{(b.whatsapp || b.phone) && (
|
{(b.whatsapp || b.phone) && (
|
||||||
<a
|
<a
|
||||||
href={`https://wa.me/${(b.whatsapp || b.phone).replace(/\D/g, '')}`}
|
href={waLink(b.whatsapp || b.phone) || '#'}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="inline-flex items-center gap-1 text-xs text-theme-heading underline underline-offset-2"
|
className="inline-flex items-center gap-1 text-xs text-theme-heading underline underline-offset-2"
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
toast,
|
toast,
|
||||||
} from '../components/ui';
|
} from '../components/ui';
|
||||||
import { odooApi, type MySalesResponse } from '../services/odoo';
|
import { odooApi, type MySalesResponse } from '../services/odoo';
|
||||||
|
|
||||||
const rangeOptions = [
|
const rangeOptions = [
|
||||||
{ value: '30d', label: 'Últimos 30 días' },
|
{ value: '30d', label: 'Últimos 30 días' },
|
||||||
{ value: 'month', label: 'Este mes' },
|
{ value: 'month', label: 'Este mes' },
|
||||||
@@ -21,6 +20,41 @@ const rangeOptions = [
|
|||||||
const mxn = (n: number) =>
|
const mxn = (n: number) =>
|
||||||
`$${n.toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
`$${n.toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||||
|
|
||||||
|
// Gráfica de barras diaria sin librerías: columnas div con altura
|
||||||
|
// proporcional y tooltip con fecha y monto.
|
||||||
|
const DailyBars: FC<{ series: { date: string; total: number }[] }> = ({ series }) => {
|
||||||
|
const max = series.reduce((m, d) => Math.max(m, d.total), 0);
|
||||||
|
if (series.length === 0 || max <= 0) {
|
||||||
|
return <EmptyState title="Sin ventas en el período" subtitle="Cuando registres ventas verás aquí la gráfica diaria." />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-end gap-[3px] h-40">
|
||||||
|
{series.map((d) => (
|
||||||
|
<div key={d.date} className="relative flex-1 h-full flex items-end justify-center group min-w-0">
|
||||||
|
<div
|
||||||
|
className={`w-full rounded-t transition-colors ${d.total > 0 ? 'bg-theme-accent/60 group-hover:bg-theme-accent' : 'bg-theme-border'}`}
|
||||||
|
style={{ height: `${Math.max((d.total / max) * 100, d.total > 0 ? 2 : 1)}%` }}
|
||||||
|
/>
|
||||||
|
{d.total > 0 && (
|
||||||
|
<div className="pointer-events-none absolute bottom-full mb-1 left-1/2 -translate-x-1/2 hidden group-hover:block z-10 whitespace-nowrap rounded-lg bg-theme-heading px-2 py-1 text-xs text-theme-inverse shadow-md">
|
||||||
|
{d.date} · {mxn(d.total)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 flex gap-[3px] text-[10px] text-theme-muted">
|
||||||
|
{series.map((d, i) => (
|
||||||
|
<span key={d.date} className="flex-1 text-center tabular-nums">
|
||||||
|
{i % 5 === 0 || i === series.length - 1 ? d.date.slice(8) : ''}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const MisVentas: FC = () => {
|
const MisVentas: FC = () => {
|
||||||
const [range, setRange] = useState<'30d' | 'month' | 'last_month'>('30d');
|
const [range, setRange] = useState<'30d' | 'month' | 'last_month'>('30d');
|
||||||
const [data, setData] = useState<MySalesResponse | null>(null);
|
const [data, setData] = useState<MySalesResponse | null>(null);
|
||||||
@@ -87,9 +121,17 @@ const MisVentas: FC = () => {
|
|||||||
<Card>
|
<Card>
|
||||||
<Card.Body className="flex items-center gap-3">
|
<Card.Body className="flex items-center gap-3">
|
||||||
<CircleDollarSign size={20} className="text-theme-muted shrink-0" />
|
<CircleDollarSign size={20} className="text-theme-muted shrink-0" />
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<p className="text-xs text-theme-muted">Total MXN</p>
|
<p className="text-xs text-theme-muted">Total MXN</p>
|
||||||
<p className="text-lg font-semibold text-theme-heading">{mxn(kpis?.total_mxn ?? 0)}</p>
|
<p className="text-lg font-semibold text-theme-heading">{mxn(kpis?.total_mxn ?? 0)}</p>
|
||||||
|
<p className="mt-1.5 inline-flex items-center gap-1 rounded-lg bg-theme-success/60 px-2 py-1 text-xs font-semibold text-theme-heading">
|
||||||
|
<Percent size={12} />
|
||||||
|
Comisión estimada: {mxn(data.commission_estimate)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[11px] text-theme-muted">
|
||||||
|
{data.commission_pct}% {data.commission_source === 'empleado' ? '(tu porcentaje asignado)' : '(porcentaje global)'}
|
||||||
|
{data.date_from && data.date_to ? ` · ${data.date_from} a ${data.date_to}` : ''}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -115,20 +157,18 @@ const MisVentas: FC = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Comisión estimada */}
|
{/* Gráfica de ventas por día */}
|
||||||
<Card className="mb-4">
|
<Card className="mb-4">
|
||||||
<Card.Body className="flex flex-wrap items-center gap-3">
|
<Card.Header>
|
||||||
<Percent size={20} className="text-theme-muted shrink-0" />
|
<div>
|
||||||
<div className="flex-1 min-w-[200px]">
|
<h3 className="font-heading text-lg text-theme-heading">Ventas por día</h3>
|
||||||
<p className="text-sm font-semibold text-theme-heading">
|
|
||||||
Comisión estimada: {mxn(data.commission_estimate)}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-theme-muted">
|
<p className="text-xs text-theme-muted">
|
||||||
{data.commission_pct}% {data.commission_source === 'empleado' ? '(tu porcentaje asignado)' : '(porcentaje global)'}
|
{data.date_from && data.date_to ? `${data.date_from} a ${data.date_to}` : 'Rango seleccionado'}
|
||||||
{' '}sobre {mxn(kpis?.total_mxn ?? 0)} del período
|
|
||||||
{data.date_from && data.date_to ? ` · ${data.date_from} a ${data.date_to}` : ''}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Body>
|
||||||
|
<DailyBars series={data.daily_breakdown ?? []} />
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ export interface DashboardStats {
|
|||||||
pending_payments: number;
|
pending_payments: number;
|
||||||
total_wallet_points: number;
|
total_wallet_points: number;
|
||||||
sales_today: number;
|
sales_today: number;
|
||||||
|
// Comparativas del período anterior equivalente (deltas)
|
||||||
|
prev_revenue_month?: number;
|
||||||
|
prev_sales_today?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Appointment {
|
export interface Appointment {
|
||||||
@@ -407,6 +410,7 @@ export interface MySalesResponse {
|
|||||||
date_to?: string;
|
date_to?: string;
|
||||||
kpis: { ventas: number; total_mxn: number; total_usd: number | null; total_card: number };
|
kpis: { ventas: number; total_mxn: number; total_usd: number | null; total_card: number };
|
||||||
sales: MySaleItem[];
|
sales: MySaleItem[];
|
||||||
|
daily_breakdown?: { date: string; total: number }[];
|
||||||
commission_pct: number;
|
commission_pct: number;
|
||||||
commission_source?: string;
|
commission_source?: string;
|
||||||
commission_estimate: number;
|
commission_estimate: number;
|
||||||
|
|||||||
@@ -268,6 +268,22 @@ class SkeenFrontendController(http.Controller):
|
|||||||
puntos_totales = sum(m.points for m in Monederos.search([]))
|
puntos_totales = sum(m.points for m in Monederos.search([]))
|
||||||
ventas_hoy = Ventas.search_count([('date', '=', today), ('state', 'in', ('paid', 'partial'))])
|
ventas_hoy = Ventas.search_count([('date', '=', today), ('state', 'in', ('paid', 'partial'))])
|
||||||
|
|
||||||
|
# Comparativas contra el período anterior equivalente (deltas):
|
||||||
|
# - Ingresos Mes: mes anterior recortado al mismo día de corte (MTD)
|
||||||
|
# - Ventas Hoy: mismo conteo del día de ayer
|
||||||
|
prev_month_last = first_day - timedelta(days=1)
|
||||||
|
prev_month_first = prev_month_last.replace(day=1)
|
||||||
|
prev_month_end = prev_month_first.replace(day=min(today.day, prev_month_last.day))
|
||||||
|
prev_revenue_month = sum(v.total for v in Ventas.search([
|
||||||
|
('date', '>=', prev_month_first),
|
||||||
|
('date', '<=', prev_month_end),
|
||||||
|
('state', 'in', ('paid', 'partial')),
|
||||||
|
]))
|
||||||
|
prev_sales_today = Ventas.search_count([
|
||||||
|
('date', '=', today - timedelta(days=1)),
|
||||||
|
('state', 'in', ('paid', 'partial')),
|
||||||
|
])
|
||||||
|
|
||||||
return json_response({
|
return json_response({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'stats': {
|
'stats': {
|
||||||
@@ -279,6 +295,8 @@ class SkeenFrontendController(http.Controller):
|
|||||||
'pending_payments': pendientes_pago,
|
'pending_payments': pendientes_pago,
|
||||||
'total_wallet_points': puntos_totales,
|
'total_wallet_points': puntos_totales,
|
||||||
'sales_today': ventas_hoy,
|
'sales_today': ventas_hoy,
|
||||||
|
'prev_revenue_month': prev_revenue_month,
|
||||||
|
'prev_sales_today': prev_sales_today,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -2111,7 +2129,8 @@ class SkeenFrontendController(http.Controller):
|
|||||||
emp = self._linked_employee(user)
|
emp = self._linked_employee(user)
|
||||||
if not emp:
|
if not emp:
|
||||||
return json_response({'status': 'success', 'medico_sin_employee': True,
|
return json_response({'status': 'success', 'medico_sin_employee': True,
|
||||||
'sales': [], 'kpis': {'ventas': 0, 'total_mxn': 0.0, 'total_usd': None, 'total_card': 0.0},
|
'sales': [], 'daily_breakdown': [],
|
||||||
|
'kpis': {'ventas': 0, 'total_mxn': 0.0, 'total_usd': None, 'total_card': 0.0},
|
||||||
'commission_pct': 0.0, 'commission_estimate': 0.0})
|
'commission_pct': 0.0, 'commission_estimate': 0.0})
|
||||||
|
|
||||||
today = date.today()
|
today = date.today()
|
||||||
@@ -2158,6 +2177,22 @@ class SkeenFrontendController(http.Controller):
|
|||||||
total_mxn += r['subtotal'] or 0.0
|
total_mxn += r['subtotal'] or 0.0
|
||||||
lines_by_venta.setdefault(vid, []).append(r)
|
lines_by_venta.setdefault(vid, []).append(r)
|
||||||
|
|
||||||
|
# Serie diaria del rango (para la gráfica de Mis ventas): suma de
|
||||||
|
# líneas atribuidas por fecha de venta, con días sin ventas en 0.
|
||||||
|
date_by_venta = {v.id: v.date for v in ventas}
|
||||||
|
totals_by_date = {}
|
||||||
|
for r in rows:
|
||||||
|
vdate = date_by_venta.get(r['venta_id'][0])
|
||||||
|
if vdate:
|
||||||
|
key = vdate.strftime('%Y-%m-%d')
|
||||||
|
totals_by_date[key] = totals_by_date.get(key, 0.0) + (r['subtotal'] or 0.0)
|
||||||
|
daily_breakdown = []
|
||||||
|
day = date_from
|
||||||
|
while day <= date_to:
|
||||||
|
key = day.strftime('%Y-%m-%d')
|
||||||
|
daily_breakdown.append({'date': key, 'total': round(totals_by_date.get(key, 0.0), 2)})
|
||||||
|
day += timedelta(days=1)
|
||||||
|
|
||||||
def metodo_pago(v):
|
def metodo_pago(v):
|
||||||
paid = v.amount_paid or 0.0
|
paid = v.amount_paid or 0.0
|
||||||
card = card_by_venta.get(v.id, 0.0)
|
card = card_by_venta.get(v.id, 0.0)
|
||||||
@@ -2205,6 +2240,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
'total_card': round(sum(card_by_venta.values()), 2),
|
'total_card': round(sum(card_by_venta.values()), 2),
|
||||||
},
|
},
|
||||||
'sales': sales,
|
'sales': sales,
|
||||||
|
'daily_breakdown': daily_breakdown,
|
||||||
'commission_pct': pct,
|
'commission_pct': pct,
|
||||||
'commission_source': pct_source,
|
'commission_source': pct_source,
|
||||||
'commission_estimate': round(total_mxn * pct / 100.0, 2),
|
'commission_estimate': round(total_mxn * pct / 100.0, 2),
|
||||||
|
|||||||
Reference in New Issue
Block a user