Initial commit: SKEEN Derma Experts - Sistema Integral de Gestión Clínica

- Frontend React (SKEEN Brand) con Vite, TypeScript, Tailwind
- Frontend Homenest (versión alternativa)
- Módulos Odoo 17 custom (citas, pacientes, monedero, pagos, ventas, inventario, whatsapp)
- WACRM fork (Next.js 16 + Supabase)
- Hermes + Bridge + Skills (Qwen3.6 via Nan Builders)
- Scripts de migración y operación
- Documentación extensiva en docs/
This commit is contained in:
2026-07-20 07:44:23 +00:00
commit a718592291
699 changed files with 324602 additions and 0 deletions

View File

@@ -0,0 +1,572 @@
import type { FC, ReactNode } from 'react';
import { useEffect, useState, useMemo } from 'react';
import {
Calendar,
Users,
DollarSign,
ShoppingCart,
Clock,
Wallet,
Sparkles,
Activity,
TrendingUp,
ArrowRight,
} from 'lucide-react';
import {
AreaChart,
Area,
BarChart,
Bar,
PieChart,
Pie,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import Layout from '../components/Layout';
import {
Card,
Button,
Badge,
EmptyState,
PageHeader,
SkeletonStat,
Skeleton,
toast,
badgeForAppointmentState,
} from '../components/ui';
import { odooApi, type DashboardStats, type Appointment, type Service, type ChartPoint } from '../services/odoo';
import { useTheme } from '../lib/theme';
const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
interface StatCardProps {
title: string;
value: string | number;
icon: ReactNode;
trend?: string;
loading?: boolean;
}
const StatCard: FC<StatCardProps> = ({ title, value, icon, trend, loading }) => {
if (loading) return <SkeletonStat />;
return (
<Card className="hover:shadow-md transition">
<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>
<p className="text-sm text-theme-muted mb-1">{title}</p>
<p className="text-2xl font-heading font-semibold text-theme-heading">{value}</p>
</Card.Body>
</Card>
);
};
const Dashboard: FC = () => {
const { theme } = useTheme();
const isHomeNest = theme === 'homenest';
const [stats, setStats] = useState<DashboardStats | null>(null);
const [chartData, setChartData] = useState<ChartPoint[]>([]);
const [appointments, setAppointments] = useState<Appointment[]>([]);
const [services, setServices] = useState<Service[]>([]);
const [loadingStats, setLoadingStats] = useState(true);
const [loadingChart, setLoadingChart] = useState(true);
const [loadingAppointments, setLoadingAppointments] = useState(true);
const [loadingServices, setLoadingServices] = useState(true);
const [topServices, setTopServices] = useState<{ service: string; qty: number; revenue: number }[]>([]);
const [aptReport, setAptReport] = useState<{ total: number; by_state: Record<string, number> } | null>(null);
const [loadingExtras, setLoadingExtras] = useState(true);
useEffect(() => {
const loadStats = async () => {
try {
setLoadingStats(true);
const res = await odooApi.getDashboard();
if (res.status === 'success' && res.stats) {
setStats(res.stats);
}
} catch (err) {
toast.error('Error al cargar estadísticas del dashboard');
console.error(err);
} finally {
setLoadingStats(false);
}
};
const loadChart = async () => {
try {
setLoadingChart(true);
const res = await odooApi.getWeeklyChart();
if (res.status === 'success' && res.data) {
setChartData(res.data);
}
} catch (err) {
toast.error('Error al cargar gráfica semanal');
console.error(err);
} finally {
setLoadingChart(false);
}
};
const loadAppointments = async () => {
try {
setLoadingAppointments(true);
const today = new Date().toISOString().split('T')[0];
const res = await odooApi.getAppointments({ date: today, limit: '5' });
if (res.status === 'success' && res.appointments) {
setAppointments(res.appointments.slice(0, 5));
}
} catch (err) {
toast.error('Error al cargar próximas citas');
console.error(err);
} finally {
setLoadingAppointments(false);
}
};
const loadServices = async () => {
try {
setLoadingServices(true);
const res = await odooApi.getServices();
if (res.status === 'success' && res.services) {
setServices(res.services.slice(0, 6));
}
} catch (err) {
toast.error('Error al cargar servicios');
console.error(err);
} finally {
setLoadingServices(false);
}
};
const loadExtras = async () => {
try {
setLoadingExtras(true);
const start = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().split('T')[0];
const end = new Date().toISOString().split('T')[0];
const [topRes, aptRes] = await Promise.all([
odooApi.getTopServices(start, end, 6),
odooApi.getAppointmentsReport(start, end),
]);
if (topRes.status === 'success') setTopServices(topRes.services || []);
if (aptRes.status === 'success') setAptReport({ total: aptRes.total, by_state: aptRes.by_state });
} catch (err) {
console.error(err);
} finally {
setLoadingExtras(false);
}
};
loadStats();
loadChart();
loadAppointments();
loadServices();
loadExtras();
}, []);
const statsConfig = useMemo(
() => [
{
title: 'Citas Hoy',
value: stats?.appointments_today ?? 0,
icon: <Calendar size={20} />,
trend: stats ? `${stats.appointments_month} este mes` : undefined,
},
{
title: 'Pacientes',
value: stats?.total_patients ?? 0,
icon: <Users size={20} />,
trend: stats ? `+${stats.new_patients_month} nuevos` : undefined,
},
{
title: 'Ingresos Mes',
value: stats ? formatCurrency(stats.revenue_month) : '$0',
icon: <DollarSign size={20} />,
},
{
title: 'Ventas Hoy',
value: stats?.sales_today ?? 0,
icon: <ShoppingCart size={20} />,
},
{
title: 'Pendientes',
value: stats?.pending_payments ?? 0,
icon: <Clock size={20} />,
},
{
title: 'Puntos Monedero',
value: stats?.total_wallet_points ?? 0,
icon: <Wallet size={20} />,
},
],
[stats]
);
const activityItems = useMemo(() => {
return appointments.slice(0, 4).map((apt) => ({
id: apt.id,
title: `${apt.patient} — ${apt.service}`,
subtitle: `${apt.date} ${apt.time}`,
state: apt.state,
}));
}, [appointments]);
const attendanceData = useMemo(() => {
if (!aptReport) return [] as { name: string; value: number; color: string }[];
const s = aptReport.by_state || {};
const pick = (...keys: string[]) => keys.reduce((a, k) => a + (s[k] || 0), 0);
return [
{ name: 'Completadas', value: pick('done'), color: '#CAE2C1' },
{ name: 'Confirmadas', value: pick('confirmed', 'arrived', 'in_progress'), color: '#A4CCE2' },
{ name: 'Pendientes', value: pick('pending'), color: '#EDA588' },
{ name: 'No show', value: pick('no_show'), color: '#E57373' },
{ name: 'Canceladas', value: pick('cancelled'), color: '#E5E0DC' },
].filter((d) => d.value > 0);
}, [aptReport]);
const noShowRate = useMemo(() => {
if (!aptReport || !aptReport.total) return 0;
return Math.round(((aptReport.by_state?.no_show || 0) / aptReport.total) * 100);
}, [aptReport]);
return (
<Layout title="Dashboard" subtitle="Panel de control de SKEEN Derma Experts">
{isHomeNest ? (
<div className="rounded-3xl p-6 sm:p-10 mb-8 border shadow-soft" style={{ backgroundColor: 'var(--surface)', borderColor: 'var(--border)' }}>
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-6">
<div className="max-w-2xl">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full text-xs font-medium mb-4" style={{ backgroundColor: 'var(--success)', color: 'var(--success-text)' }}>
<Sparkles size={14} /> Bienvenido de vuelta
</div>
<h1 className="font-display text-3xl sm:text-4xl lg:text-5xl mb-3" style={{ color: 'var(--text-heading)' }}>SKEEN Derma Experts</h1>
<p className="text-base sm:text-lg leading-relaxed" style={{ color: 'var(--text-muted)' }}>
Ciencia, estética y cuidado personalizado en cada tratamiento. Gestiona tu clínica con calidez y confianza.
</p>
</div>
<div className="flex flex-wrap gap-3 shrink-0">
<Button variant="primary" size="md">
<Calendar size={16} className="mr-2" /> Nueva cita
</Button>
<Button variant="outline" size="md">
<TrendingUp size={16} className="mr-2" /> Ver reportes
</Button>
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-8 pt-8" style={{ borderTop: '1px solid var(--border)' }}>
{statsConfig.slice(0, 4).map((s) => (
<div key={s.title} className="text-center sm:text-left">
<p className="text-xs mb-1" style={{ color: 'var(--text-muted)' }}>{s.title}</p>
<p className="text-xl sm:text-2xl font-heading" style={{ color: 'var(--text-heading)' }}>{loadingStats ? '-' : s.value}</p>
</div>
))}
</div>
</div>
) : (
<>
<PageHeader title="Dashboard" subtitle="Resumen general de la clínica">
<Button variant="outline" size="sm">
<Sparkles size={16} className="mr-2" />
Bienvenido
</Button>
</PageHeader>
<div className="bg-gradient-to-r from-[#EDA588] to-[#C7BCDD] rounded-2xl p-6 sm:p-8 text-white mb-6 sm:mb-8 shadow-sm">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="font-heading text-2xl sm:text-3xl mb-2">SKEEN Derma Experts</h1>
<p className="text-white/90 text-sm sm:text-base max-w-xl">
Ciencia, estética y cuidado personalizado en cada tratamiento. Gestiona tu clínica con confianza.
</p>
</div>
<div className="shrink-0">
<Button variant="secondary" size="sm" className="bg-theme-surface/20 text-white border-white/30 hover:bg-theme-surface/30">
<TrendingUp size={16} className="mr-2" />
Ver reportes
</Button>
</div>
</div>
</div>
</>
)}
{/* Stats grid */}
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-4 mb-6 sm:mb-8">
{statsConfig.map((s) => (
<StatCard
key={s.title}
title={s.title}
value={s.value}
icon={s.icon}
trend={s.trend}
loading={loadingStats}
/>
))}
</div>
{/* Chart + appointments */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6 mb-6 sm:mb-8">
<Card>
<Card.Header>
<div>
<h3 className="font-heading text-lg text-theme-heading">Actividad semanal</h3>
<p className="text-xs text-theme-muted">Citas e ingresos de los últimos 7 días</p>
</div>
</Card.Header>
<Card.Body>
{loadingChart ? (
<Skeleton count={8} className="h-6 w-full" />
) : chartData.length === 0 ? (
<EmptyState title="Sin datos" subtitle="No hay información para la gráfica semanal." icon={<TrendingUp size={28} />} />
) : (
<div className="h-[300px] w-full">
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={chartData} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>
<defs>
<linearGradient id="colorAppointments" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#EDA588" stopOpacity={0.3} />
<stop offset="95%" stopColor="#EDA588" stopOpacity={0} />
</linearGradient>
<linearGradient id="colorRevenue" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#CAE2C1" stopOpacity={0.3} />
<stop offset="95%" stopColor="#CAE2C1" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#E5E0DC" />
<XAxis dataKey="name" tick={{ fontSize: 12, fill: '#6B6B6B' }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 12, fill: '#6B6B6B' }} axisLine={false} tickLine={false} />
<Tooltip
contentStyle={{ borderRadius: 12, border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }}
/>
<Area type="monotone" dataKey="appointments" stroke="#EDA588" fillOpacity={1} fill="url(#colorAppointments)" strokeWidth={2} />
<Area type="monotone" dataKey="revenue" stroke="#CAE2C1" fillOpacity={1} fill="url(#colorRevenue)" strokeWidth={2} />
</AreaChart>
</ResponsiveContainer>
</div>
)}
</Card.Body>
</Card>
<Card className="flex flex-col">
<Card.Header>
<div>
<h3 className="font-heading text-lg text-theme-heading">Próximas citas</h3>
<p className="text-xs text-theme-muted">Citas programadas para hoy</p>
</div>
<Button variant="ghost" size="sm" className="hidden sm:inline-flex">
Ver agenda
<ArrowRight size={14} className="ml-1" />
</Button>
</Card.Header>
<Card.Body className="flex-1">
{loadingAppointments ? (
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : appointments.length === 0 ? (
<EmptyState title="Sin citas" subtitle="No hay citas programadas para hoy." icon={<Calendar size={28} />} />
) : (
<div className="space-y-3">
{appointments.map((apt) => (
<div
key={apt.id}
className="flex items-center justify-between p-3 border border-theme-border-strong rounded-xl hover:bg-theme-bg transition"
>
<div className="min-w-0">
<p className="text-sm font-medium text-theme-heading truncate">{apt.patient}</p>
<p className="text-xs text-theme-muted truncate">{apt.service} • {apt.time}</p>
</div>
<Badge variant={badgeForAppointmentState(apt.state)}>{apt.state}</Badge>
</div>
))}
</div>
)}
</Card.Body>
</Card>
</div>
{/* Top servicios + Asistencia del mes */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6 mb-6 sm:mb-8">
<Card>
<Card.Header>
<div>
<h3 className="font-heading text-lg text-theme-heading">Top servicios del mes</h3>
<p className="text-xs text-theme-muted">Por ingresos generados</p>
</div>
</Card.Header>
<Card.Body>
{loadingExtras ? (
<Skeleton count={6} className="h-6 w-full" />
) : topServices.length === 0 ? (
<EmptyState title="Sin datos" subtitle="No hay ventas en el mes." icon={<TrendingUp size={28} />} />
) : (
<div className="h-[280px] w-full">
<ResponsiveContainer width="100%" height={280}>
<BarChart data={topServices} layout="vertical" margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="#E5E0DC" />
<XAxis type="number" tick={{ fontSize: 11, fill: '#6B6B6B' }} axisLine={false} tickLine={false} tickFormatter={(v: number) => formatCurrency(v)} />
<YAxis
type="category"
dataKey="service"
width={150}
tick={{ fontSize: 11, fill: '#1A1A1A' }}
axisLine={false}
tickLine={false}
tickFormatter={(v: string) => (v.length > 22 ? `${v.slice(0, 22)}…` : v)}
/>
<Tooltip
formatter={(v) => formatCurrency(Number(v))}
contentStyle={{ borderRadius: 12, border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }}
/>
<Bar dataKey="revenue" fill="#EDA588" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
)}
</Card.Body>
</Card>
<Card>
<Card.Header>
<div>
<h3 className="font-heading text-lg text-theme-heading">Asistencia del mes</h3>
<p className="text-xs text-theme-muted">
{aptReport ? `${aptReport.total} citas · tasa no-show ${noShowRate}%` : 'Distribución por estado'}
</p>
</div>
</Card.Header>
<Card.Body>
{loadingExtras ? (
<Skeleton count={6} className="h-6 w-full" />
) : attendanceData.length === 0 ? (
<EmptyState title="Sin datos" subtitle="No hay citas en el mes." icon={<Calendar size={28} />} />
) : (
<div className="flex flex-col sm:flex-row items-center gap-4">
<div className="h-[240px] w-full sm:w-1/2">
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie data={attendanceData} dataKey="value" nameKey="name" cx="50%" cy="50%" innerRadius={60} outerRadius={95} paddingAngle={2}>
{attendanceData.map((d, i) => (
<Cell key={`cell-${i}`} fill={d.color} />
))}
</Pie>
<Tooltip contentStyle={{ borderRadius: 12, border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }} />
</PieChart>
</ResponsiveContainer>
</div>
<div className="flex-1 w-full space-y-2">
{attendanceData.map((d) => (
<div key={d.name} className="flex items-center justify-between text-sm">
<span className="flex items-center gap-2 text-theme-muted">
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: d.color }} />
{d.name}
</span>
<span className="font-medium text-theme-heading">{d.value}</span>
</div>
))}
</div>
</div>
)}
</Card.Body>
</Card>
</div>
{/* Services + activity */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
<Card>
<Card.Header>
<div>
<h3 className="font-heading text-lg text-theme-heading">Servicios destacados</h3>
<p className="text-xs text-theme-muted">Catálogo activo de tratamientos</p>
</div>
</Card.Header>
<Card.Body>
{loadingServices ? (
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
) : services.length === 0 ? (
<EmptyState title="Sin servicios" subtitle="No hay servicios registrados." icon={<Sparkles size={28} />} />
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{services.map((s) => (
<div
key={s.id}
className="flex items-center gap-3 p-3 border border-theme-border-strong rounded-xl hover:bg-theme-bg transition"
>
<div
className="w-10 h-10 rounded-lg shrink-0"
style={{ backgroundColor: s.color || '#78716c' }}
/>
<div className="min-w-0">
<p className="text-sm font-medium text-theme-heading truncate">{s.name}</p>
<p className="text-xs text-theme-muted">${s.price} • {s.duration_min} min</p>
</div>
</div>
))}
</div>
)}
</Card.Body>
</Card>
<Card>
<Card.Header>
<div>
<h3 className="font-heading text-lg text-theme-heading">Actividad reciente</h3>
<p className="text-xs text-theme-muted">Últimos movimientos en la clínica</p>
</div>
</Card.Header>
<Card.Body>
{loadingAppointments ? (
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
) : activityItems.length === 0 ? (
<EmptyState title="Sin actividad" subtitle="No hay movimientos recientes." icon={<Activity size={28} />} />
) : (
<div className="space-y-3">
{activityItems.map((item) => (
<div
key={item.id}
className="flex items-start gap-3 p-3 border border-theme-border-strong rounded-xl hover:bg-theme-bg transition"
>
<div className="w-9 h-9 rounded-full bg-theme-border flex items-center justify-center text-theme-muted shrink-0 mt-0.5">
<Activity size={16} />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-theme-heading truncate">{item.title}</p>
<p className="text-xs text-theme-muted">{item.subtitle}</p>
</div>
<Badge variant={badgeForAppointmentState(item.state)} className="shrink-0">
{item.state}
</Badge>
</div>
))}
</div>
)}
</Card.Body>
</Card>
</div>
</Layout>
);
};
export default Dashboard;