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:
167
frontend-homenest/src/pages/Cumpleanos.tsx
Normal file
167
frontend-homenest/src/pages/Cumpleanos.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Cake, MessageCircle, Star, Download } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import { Card, PageHeader, Button, toast } from '../components/ui';
|
||||
import { odooApi } from '../services/odoo';
|
||||
import type { Birthday } from '../services/odoo';
|
||||
import { downloadCsv } from '../lib/utils';
|
||||
|
||||
type Period = 'today' | 'week' | 'month';
|
||||
|
||||
const PERIODS: { value: Period; label: string }[] = [
|
||||
{ value: 'today', label: 'Hoy' },
|
||||
{ value: 'week', label: 'Próximos 7 días' },
|
||||
{ value: 'month', label: 'Este mes' },
|
||||
];
|
||||
|
||||
const fmtMoney = (n: number) =>
|
||||
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 });
|
||||
|
||||
const fmtDate = (s: string | null) => {
|
||||
if (!s) return '—';
|
||||
const d = new Date(`${s}T12:00:00`);
|
||||
return d.toLocaleDateString('es-MX', { day: '2-digit', month: 'short' });
|
||||
};
|
||||
|
||||
const Cumpleanos: FC = () => {
|
||||
const [period, setPeriod] = useState<Period>('month');
|
||||
const [items, setItems] = useState<Birthday[]>([]);
|
||||
const [range, setRange] = useState<{ start: string; end: string } | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
odooApi
|
||||
.getBirthdays(period)
|
||||
.then((res) => {
|
||||
if (!active) return;
|
||||
setItems(res.birthdays || []);
|
||||
setRange({ start: res.start, end: res.end });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!active) return;
|
||||
setItems([]);
|
||||
})
|
||||
.finally(() => active && setLoading(false));
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [period]);
|
||||
|
||||
const exportBirthdays = () => {
|
||||
const rows: (string | number | boolean | null | undefined)[][] = [
|
||||
['Nombre', 'Teléfono', 'Fecha nacimiento', 'Cumple el', 'Cumple años', 'Última visita', 'Total gastado', 'Adeudo', 'VIP'],
|
||||
];
|
||||
items.forEach((b) =>
|
||||
rows.push([
|
||||
b.name, b.phone, b.birth_date, b.occurs_on, b.turning_age, b.last_visit,
|
||||
b.total_spent, b.amount_due, b.is_vip ? 'Sí' : 'No',
|
||||
])
|
||||
);
|
||||
downloadCsv(`cumpleaneros-skeen-${period}-${new Date().toISOString().split('T')[0]}.csv`, rows);
|
||||
toast.success(`CSV descargado (${items.length} cumpleañeros)`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout title="Cumpleañeros" subtitle="Pacientes que cumplen años">
|
||||
<PageHeader title="Cumpleañeros" subtitle="Pacientes que cumplen años en el periodo">
|
||||
<Button variant="outline" onClick={exportBirthdays} disabled={items.length === 0}>
|
||||
<Download size={16} className="mr-2" />
|
||||
Exportar CSV
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<Card className="mb-4">
|
||||
<Card.Body>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{PERIODS.map((p) => (
|
||||
<button
|
||||
key={p.value}
|
||||
onClick={() => setPeriod(p.value)}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium transition ${
|
||||
period === p.value
|
||||
? 'bg-homenest-bark text-white'
|
||||
: 'bg-[#FEF3C7] text-[#7A5C44] hover:bg-[#F5EBD8]'
|
||||
}`}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
{range && (
|
||||
<span className="ml-auto text-xs text-[#A87B5D]">
|
||||
{range.start} → {range.end} · {items.length} pacientes
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card.Body noPadding>
|
||||
{loading ? (
|
||||
<p className="p-6 text-sm text-[#7A5C44]">Cargando...</p>
|
||||
) : items.length === 0 ? (
|
||||
<div className="p-10 text-center text-[#A87B5D]">
|
||||
<Cake className="mx-auto mb-2" size={28} />
|
||||
<p className="text-sm">No hay cumpleañeros en este periodo.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[#FEF3C7] text-[#7A5C44] text-xs uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">Fecha</th>
|
||||
<th className="text-left px-4 py-3">Paciente</th>
|
||||
<th className="text-left px-4 py-3">Cumple</th>
|
||||
<th className="text-left px-4 py-3">Última visita</th>
|
||||
<th className="text-right px-4 py-3">Total gastado</th>
|
||||
<th className="text-right px-4 py-3">Adeudo</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#F5EBD8]">
|
||||
{items.map((b) => (
|
||||
<tr key={b.id} className="hover:bg-[#FEF3C7]">
|
||||
<td className="px-4 py-3 font-medium text-homenest-bark">{fmtDate(b.occurs_on)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{b.is_vip && <Star size={13} className="text-amber-500 fill-amber-500" />}
|
||||
<span className="text-homenest-bark">{b.name}</span>
|
||||
</div>
|
||||
<span className="text-xs text-[#A87B5D]">{b.phone}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[#7A5C44]">{b.turning_age} años</td>
|
||||
<td className="px-4 py-3 text-[#7A5C44]">{fmtDate(b.last_visit)}</td>
|
||||
<td className="px-4 py-3 text-right text-homenest-bark">{fmtMoney(b.total_spent)}</td>
|
||||
<td className={`px-4 py-3 text-right ${b.amount_due > 0 ? 'text-rose-600' : 'text-[#A87B5D]'}`}>
|
||||
{fmtMoney(b.amount_due)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{b.phone && (
|
||||
<a
|
||||
href={`https://wa.me/${b.phone.replace(/\D/g, '')}?text=${encodeURIComponent(
|
||||
`¡Feliz cumpleaños ${b.name.split(' ')[0]}! 🎉 Te esperamos en SKEEN Derma Experts.`
|
||||
)}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-#3E2C1C hover:text-#3E2C1C text-xs font-medium"
|
||||
>
|
||||
<MessageCircle size={14} /> WhatsApp
|
||||
</a>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Cumpleanos;
|
||||
Reference in New Issue
Block a user