diff --git a/.gitignore b/.gitignore index 1a7381a..58e7572 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,4 @@ datos/ # Git .git/ +visitas_legacy_*.json diff --git a/frontend/index.html b/frontend/index.html index 9db0f48..8a04141 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,7 +5,7 @@ - +
| Nombre | +Categoría | +Descripción | +Acciones | +
|---|---|---|---|
| {item.name} | +{item.categoria || '-'} | ++ {item.descripcion} + | +
+
+
+
+
+ |
+
+ Rosarito +
+ + +{user?.name || 'Usuario'}
+{user ? ROLE_LABELS[user.role] : ''}
+Define el objetivo de ventas (MXN) para el mes en curso. La barra del header muestra el avance en tiempo real.
+ setGoalInput(e.target.value)} placeholder="ej. 500000" /> +Buscando...
+ )} + {!searching && !hasResults && ( +Sin resultados para "{query.trim()}"
+ )} + + {results.patients.length > 0 && ( +Pacientes
+ {results.patients.map((p) => ( +Citas próximas
+ {results.appointments.map((a) => ( +Servicios
+ {results.services.map((s) => ( +
+
+ Notificaciones +
+ {loading && notifications.length === 0 ? ( +Cargando...
+ ) : notifications.length === 0 ? ( +Sin notificaciones
+ ) : ( ++ {kind === 'imagen' ? 'Sin fotos en la galería.' : 'Sin expediente escaneado.'} +
+ ) : ( + <> + {imagenes.length > 0 && ( ++ Mostrar accesos rápidos +
+ {(Object.keys(MODE_ICONS) as QuickLinksMode[]).map((m) => { + const ModeIcon = MODE_ICONS[m]; + const selected = quick.mode === m; + return ( +{clinic.name}
+ {clinic.address &&{clinic.address}
} + {clinic.phone &&Tel: {clinic.phone}
} +{fecha}
++ Paciente: + {paciente} +
+ + {/* Cuerpo */} +℞
+{contenido}
+{doctor || 'Médico tratante'}
+{clinic.name}
+| Nombre | +Categoría | +Contenido | +Acciones | +
|---|---|---|---|
| {r.name} | +{r.categoria || '-'} | ++ {r.contenido} + | +
+
+
+ |
+
+ Tema +
+ {(Object.keys(THEME_LABELS) as Theme[]).map((t) => { + const selected = theme === t; + return ( +Gestiona quién puede entrar al sistema, su rol y sus secciones visibles.
+| Usuario | +Nombre | +Rol | +Secciones | +Último acceso | +Estado | +Acciones | +
|---|---|---|---|---|---|---|
| {u.login} | +{u.name} | ++ {u.allowed_menus && u.allowed_menus.length > 0 + ? `${u.allowed_menus.length} de ${MENU_ITEMS.length} secciones` + : 'Por rol'} + | +{u.last_login || '—'} | +
+ |
+
+
+
+ |
+
+ {permSelected.length} de {MENU_ITEMS.length} secciones visibles +
+ {permWarning && ( +Estás por quitarte a ti mismo el acceso a Configuración. No podrás volver a abrir esta pantalla.
+Artículos con fecha de caducidad registrada, ordenados por urgencia.
+ {loading ? ( +| Artículo | +Categoría | +Existencia | +Caduca | +Estado | +
|---|---|---|---|---|
| {c.name} | +{c.category || '-'} | +{c.qty} {c.unit} | +{c.expiry_date} | +
+ Artículos bajo su stock mínimo (o sin existencia). Sugerido = óptimo − existencia (o 2× mínimo). +
+ {loading ? ( +| Artículo | +Existencia | +Mínimo | +Óptimo | +Sugerido | +Costo est. | +
|---|---|---|---|---|---|
| {s.name} | +{s.qty} {s.unit} | +{s.qty_min || '-'} | +{s.qty_optimal || '-'} | ++{s.sugerido} {s.unit} | +{fmtMoney(s.costo_estimado)} | +
+ Total estimado: {fmtMoney(totalSugerencia.costo_total)} + {' '}({totalSugerencia.items} artículos) +
+| Fecha | +Artículo | +Cantidad | +Antes | +Después | +{esCompra ? 'Referencia' : 'Motivo / Referencia'} | +Notas | +
|---|---|---|---|---|---|---|
| {m.date || '-'} | +{m.item} | ++ {esCompra ? '+' : '−'}{m.qty} {m.unit} + | +{m.before_qty} | +{m.after_qty} | +{m.reference || '-'} | ++ {m.notes || '-'} + | +
{total} movimientos · página {page} de {totalPages || 1}
+Total cartera por cobrar
+0 ? 'text-rose-600' : 'text-theme-heading'}`}> + {formatCurrency(totalCartera)} +
+Pacientes con adeudo
+{totalPacientes}
+| Paciente | +Teléfono | +# Ventas | +Adeudo | +Última venta | +
|---|---|---|---|---|
| + {a.phone ? ( + + {a.name} + + ) : ( + a.name + )} + | +{a.phone || '-'} | +{a.num_ventas} | +{formatCurrency(a.total_adeudo)} | +{a.ultima_venta || '-'} | +
+ Vendido: {formatCurrency(totales.vendido)} + {' · '}Comisiones: {formatCurrency(totales.comision)} +
+| Médico | +Citas completadas | +Líneas | +Ventas | +Total vendido | +% Comisión | +Comisión estimada | +
|---|---|---|---|---|---|---|
|
+ {r.doctor} +{r.job_title || '—'} + |
+ {r.citas_done} | +{r.items} | +{r.sales} | +{formatCurrency(r.total_vendido)} | +{r.commission_pct}% | +{formatCurrency(r.comision)} | +
{label}
+{value}
+Sin citas.
} +Sin cobros.
+ ) : ( +Sin visitas.
} +| Fecha | +Usuario | +Apertura | +Efectivo | +Tarjeta | +Transfer. | +Otros | +Total ventas | +Diferencia | +Estado | +
|---|---|---|---|---|---|---|---|---|---|
| {c.date} | +{c.user || '-'} | +{formatCurrency(c.opening_cash)} | +{formatCurrency(c.total_cash)} | +{formatCurrency(c.total_card)} | +{formatCurrency(c.total_transfer)} | +{formatCurrency(c.total_other)} | +{formatCurrency(c.total_sales)} | ++ {c.state === 'closed' ? formatCurrency(c.difference) : '-'} + | +
+ |
+
Sin actividad por médico este día.
+ ) : ( +| Médico | +Citas | +Estados | +Ventas (recetado) | +Cobros | +
|---|---|---|---|---|
| {r.medico} | +{r.citas} | +
+
+ {Object.entries(r.by_state).map(([state, count]) => (
+
+ |
+ {formatCurrency(r.ventas)} | +{formatCurrency(r.cobros)} | +
Total: {report.citas.total}
+ {Object.keys(report.citas.by_state).length === 0 ? ( +Sin citas este día.
+ ) : ( +Cantidad
+{report.ventas.count}
+Total
+{formatCurrency(report.ventas.total)}
+Pagado
+{formatCurrency(report.ventas.total_paid)}
+Por cobrar
+0 ? 'text-rose-600' : 'text-theme-heading'}`}> + {formatCurrency(report.ventas.total_due)} +
++ {report.pagos.count} cobros · Total: {formatCurrency(report.pagos.total)} +
+ {Object.keys(report.pagos.by_method).length === 0 ? ( +Sin cobros este día.
+ ) : ( +Movimientos: {report.inventario.total}
+ {Object.keys(report.inventario.by_type).length === 0 ? ( +Sin movimientos este día.
+ ) : ( ++ Total devuelto: 0 ? 'text-rose-600' : 'text-theme-heading'}`}>{formatCurrency(totalDevuelto)} +
+| Folio | +Paciente | +Fecha venta | +Total venta | +Devuelto | +Motivo | +Fecha devolución | +
|---|---|---|---|---|---|---|
| {d.folio} | +{d.paciente} | +{d.fecha || '-'} | +{formatCurrency(d.total)} | +{formatCurrency(d.refund_amount)} | ++ {d.refund_reason || '-'} + | +{d.refunded_at || '-'} | +
{descripcion}
+{total} citas en el periodo
+| Hora | +Citas | +Estados | +
|---|---|---|
| {h.hora} | +{h.total} | +
+
+ {Object.entries(h.by_state).map(([state, count]) => (
+
+ |
+
Total vendido
+{formatCurrency(sales.total_sales)}
+Total cobrado
+{formatCurrency(sales.total_paid)}
+Por cobrar
+0 ? 'text-rose-600' : 'text-theme-heading'}`}> + {formatCurrency(sales.total_due)} +
+Ticket promedio
+{formatCurrency(ticketPromedio)}
+Sin cobros en el periodo.
+ ) : ( +Valor del inventario
+{formatCurrency(summary.total_value)}
+Artículos
+{summary.count}
+Sin existencias
+0 ? 'text-rose-600' : 'text-theme-heading'}`}>{summary.out}
+Bajo mínimo
+{summary.critical}
+| Artículo | +Categoría | +Unidad | +Existencia | +Mínimo | +Óptimo | +Costo | +Valor | +Nivel | +
|---|---|---|---|---|---|---|---|---|
| {i.name} | +{i.category || '-'} | +{i.unit} | +{i.qty} | +{i.qty_min || '-'} | +{i.qty_optimal || '-'} | +{formatCurrency(i.cost)} | +{formatCurrency(i.inventory_value)} | +
+ |
+
{label}
+{value}
++ {data.no_show.total_no_shows} no-shows de {data.no_show.total_citas} citas (canceladas no cuentan). +
+ {data.no_show.por_medico.length === 0 ? ( +Sin citas en el periodo.
+ ) : ( +| Médico | +Citas | +No-shows | +Tasa | +
|---|---|---|---|
| {m.medico} | +{m.citas} | +{m.no_shows} | +0.15 ? 'text-rose-600' : 'text-theme-heading'}`}> + {pct(m.tasa)} + | +
+ {data.ocupacion.dias_habiles} días hábiles × 8h (domingos no cuentan), menos bloqueos de agenda. +
+ {data.ocupacion.por_medico.length === 0 ? ( +Sin citas en el periodo.
+ ) : ( +| Médico | +Vendidas | +Disponibles | +Libres | +Ocupación | +
|---|---|---|---|---|
| {o.medico} | +{o.horas_vendidas}h | +{o.horas_disponibles}h | +{o.horas_libres}h | +{pct(o.ocupacion)} | +
Conversión de leads (deals creados en el periodo)
++ {data.leads.ganados} ganados de{' '} + {data.leads.total} leads ({pct(data.leads.tasa)}) + {data.leads.dias_promedio_ganar !== null && ( + · prom. {data.leads.dias_promedio_ganar} días a ganar + )} +
+Primera vez vs subsecuentes
++ {data.primera_vez.primera} primera vez ·{' '} + {data.primera_vez.subsecuentes} subsecuentes +
++ Total cobrado: {formatCurrency(totalCobrado)} +
+| Cliente | +# Pagos | +Total cobrado | +Último pago | +
|---|---|---|---|
| {c.name} | +{c.num_pagos} | +{formatCurrency(c.total_cobrado)} | +{c.ultimo_pago || '-'} | +
Total cobrado (pagos del periodo)
+{formatCurrency(totalCobrado)}
+Ventas liquidadas en el periodo
+{formatCurrency(totalPagadoCompleto)}
+Ventas totalmente pagadas del periodo, agrupadas por servicio.
+ {loading ? ( +| Servicio | +Cantidad | +Total | +
|---|---|---|
| {s.servicio} | +{s.cantidad} | +{formatCurrency(s.total)} | +
Sin cobros en el periodo.
+ ) : ( +Servicios vendidos como paquete: sesiones contratadas, citas agendadas y paquetes terminados.
+ {loading ? ( +| Servicio | +Sesiones | +Precio paquete | +Precio sesión | +Citas agendadas | +Paquetes terminados | +
|---|---|---|---|---|---|
| {p.name} | +{p.package_sessions} | +{formatCurrency(p.package_price)} | +{formatCurrency(p.price)} | +{p.citas} | +
+ |
+
{total} pacientes recomendados
+| # | +Recomendador | +Pacientes | +
|---|---|---|
| {i + 1} | +{r.recomendador} | +{r.pacientes} | +
| # | +Cliente | +# Ventas | +Total comprado | +Ticket promedio | +
|---|---|---|---|---|
| {i + 1} | +{c.name} | +{c.num_ventas} | +{formatCurrency(c.total)} | +{formatCurrency(c.ticket_promedio)} | +
Ventas agrupadas por el usuario que las registró en el sistema.
+ + {loading ? ( +| Usuario | +# Ventas | +Total vendido | +Cobrado | +
|---|---|---|---|
| {v.usuario} | +{v.num_ventas} | +{formatCurrency(v.total)} | +{formatCurrency(v.cobrado)} | +
{error}
} {loading ? (| + Hora + | + {gridColumns.map((col) => ( +
+
+
+ {initials(col.label)}
+
+
+
+
+ + {col.label} + ++ {col.subtitle ? `${col.subtitle} · ` : ''} + {gridDoctorCount(col.doctorId)} {gridDoctorCount(col.doctorId) === 1 ? 'cita' : 'citas'} + + |
+ ))}
+
|---|---|
| + {`${String(hour).padStart(2, '0')}:00`} + | + {gridColumns.map((col) => { + const cell = gridCellAppointments(col.doctorId, hour); + const bloqueo = bloqueoEnCelda(col.doctorId, hour); + const cellKey = `${col.key}-${hour}`; + return ( + { if (dragApt && !bloqueo) { e.preventDefault(); setDropTarget(cellKey); } }}
+ onDragLeave={() => setDropTarget((t) => (t === cellKey ? null : t))}
+ onDrop={(e) => { e.preventDefault(); handleDrop(col.doctorId, hour); }}
+ className={`p-1.5 align-top border-b border-l border-theme-border transition-colors ${dropTarget === cellKey ? 'bg-theme-accent-bg' : ''}`}
+ >
+ {bloqueo ? (
+
+
+ ) : cell.length === 0 ? (
+
+ Libre
+
+ ) : (
+ cell.map((apt) => (
+ openAptDetail(apt)}
+ {...chipDragProps(apt)}
+ className={`mb-1.5 rounded-xl border-l-4 px-2.5 py-1.5 text-xs shadow-card transition-shadow hover:shadow-soft ${
+ DRAGGABLE_STATES.includes(apt.state) ? 'cursor-grab active:cursor-grabbing' : 'cursor-pointer'
+ } ${gridStateClass[apt.state] || 'border-theme-border-strong bg-theme-bg text-theme-heading'} ${quickFilter === 'libres' ? 'opacity-40' : ''}`}
+ title={`${apt.time} · ${apt.patient} · ${apt.service} · ${apt.state}`}
+ >
+
+ ))
+ )}
+
+ {apt.time} · {apt.patient}
+
+ {apt.service}
+ |
+ );
+ })}
+
| + Médico + | + {gridHours.map((hour) => ( ++ {`${String(hour).padStart(2, '0')}:00`} + | + ))} +
|---|---|
|
+
+
+ {initials(col.label)}
+
+
+
+
+ + {col.label} + ++ {gridDoctorCount(col.doctorId)} {gridDoctorCount(col.doctorId) === 1 ? 'cita' : 'citas'} + + |
+ {gridHours.map((hour) => {
+ const cell = gridCellAppointments(col.doctorId, hour);
+ const bloqueo = bloqueoEnCelda(col.doctorId, hour);
+ const cellKey = `${col.key}-${hour}`;
+ return (
+ { if (dragApt && !bloqueo) { e.preventDefault(); setDropTarget(cellKey); } }}
+ onDragLeave={() => setDropTarget((t) => (t === cellKey ? null : t))}
+ onDrop={(e) => { e.preventDefault(); handleDrop(col.doctorId, hour); }}
+ className={`p-1 align-top border-b border-l border-theme-border transition-colors ${dropTarget === cellKey ? 'bg-theme-accent-bg' : ''}`}
+ >
+ {bloqueo ? (
+
+
+ ) : cell.length === 0 ? (
+
+ ) : (
+ cell.map((apt) => (
+ openAptDetail(apt)}
+ {...chipDragProps(apt)}
+ className={`mb-1 rounded-lg border-l-2 px-1.5 py-1 text-[11px] leading-tight shadow-card transition-shadow hover:shadow-soft ${
+ DRAGGABLE_STATES.includes(apt.state) ? 'cursor-grab active:cursor-grabbing' : 'cursor-pointer'
+ } ${gridStateClass[apt.state] || 'border-theme-border-strong bg-theme-bg text-theme-heading'} ${quickFilter === 'libres' ? 'opacity-40' : ''}`}
+ title={`${apt.time} · ${apt.patient} · ${apt.service} · ${apt.state}`}
+ >
+
+ ))
+ )}
+
+ {apt.time} · {firstName(apt.patient)}
+
+ |
+ );
+ })}
+
|
|
{apt.patient} | -{apt.service} | +
+ |
{apt.doctor || '-'} |
- {filteredAppointments.map((apt) => (
+ {visibleAppointments.map((apt) => (
+
+ {/* Modal detalle de cita: confirmar / no show / editar reserva */}
+
+ {/* Alta rápida */}
+
+
+
+
+ {/* Lista del día */}
+ Nuevo bloqueo +
+
+
+
+
+
+ Bloqueos de este día + {bloqueos.length === 0 ? ( +No hay bloqueos en la fecha seleccionada. + ) : ( +
+
+ )}
+ {detailApt && editMode && (
+
+
+
+
+ {detailApt.notes && (
+
+
+ Paciente +{detailApt.patient} +
+
+ Servicio +{detailApt.service} +
+
+ Fecha y hora +{detailApt.date} · {detailApt.time} +
+
+ Médico +{detailApt.doctor || 'Sin asignar'} +
+
+ Duración +{detailApt.duration ? `${detailApt.duration} min` : '-'} +
+
+ Precio +${detailApt.price} +
+
+ )}
+ Notas +{detailApt.notes} +
+ {detailApt.state === 'pending' && (
+
+
+
+ )}
+ + {detailApt.patient} · {detailApt.service} + +
+ setEditForm({ ...editForm, date: e.target.value })}
+ />
+ setEditForm({ ...editForm, time: e.target.value })}
+ />
+
+
+ {TABS.map((t) => (
+
+
+ {tab === 'usuarios' ? (
+
+ {SUBCATALOGOS.map((s) => (
+
+
+
+
+ {loading ? (
+
+
+
+ setFecha(e.target.value || 'todas')} />
+
+
+
+ )}
+
+ {/* Paciente */}
+
+
+
+
+ Paciente * + {pacienteSel ? ( +
+ {pacienteSel.name} · {pacienteSel.phone}
+
+ ) : (
+
+
+ )}
+
+ {pacienteResults.map((p) => (
+
+ )}
+
+
+ )}
+
+
+
-
@@ -277,10 +281,10 @@ const Dashboard: FC = () => {
) : (
<>
@@ -292,7 +296,7 @@ const Dashboard: FC = () => {
-
+ {TABS.map((t) => (
+
+
+ {tab === 'compras' ? (
+
+ {TABS.map((t) => (
+
+
+ {tab === 'reporte' ? (
+ <>
+
+ setRepStart(e.target.value)} className="sm:max-w-[160px]" />
+ setRepEnd(e.target.value)} className="sm:max-w-[160px]" />
+
+
+ {Array.from({ length: 3 }).map((_, i) =>
+ ) : reporte ? (
+ <>
+
+
+ Puntos activos +{reporte.puntos_activos} pts +Equivalente +{fmtMoney(reporte.equivalente_mxn)} +Cuentas con monedero +{reporte.cuentas} +Movimientos del periodo+1 punto = $1 MXN (conversión fija del sistema). + {Object.keys(reporte.by_type).length === 0 ? ( +Sin movimientos en el periodo. + ) : ( +
+ {Object.entries(reporte.by_type).map(([type, points]) => (
+
+ )}
+
+
+ ))}
+ {txTypeLabels[type] || type} ++ {points > 0 && type !== 'redemption' && type !== 'expiration' ? '+' : ''}{points} pts + +
@@ -258,6 +378,8 @@ const Monedero: FC = () => {
)}
+ >
+ )}
Nombre |
+ Expediente |
Teléfono |
Edad |
Última visita |
@@ -285,18 +370,47 @@ const Pacientes: FC = () => {
);
- const exportPatients = () => {
- const rows: (string | number | boolean | null | undefined)[][] = [
- ['Expediente', 'Nombre', 'Teléfono', 'Email', 'Fecha nacimiento', 'Género', 'Última visita', 'Visitas', 'Puntos monedero', 'Total gastado', 'Fuente', 'VIP'],
- ];
- patients.forEach((p) =>
- rows.push([
- p.patient_id, p.name, p.phone, p.email, p.birth_date, p.gender || '', p.last_visit,
- p.total_visits, p.wallet_points, p.total_spent, p.source, p.is_vip ? 'Sí' : 'No',
- ])
- );
- downloadCsv(`pacientes-skeen-${new Date().toISOString().split('T')[0]}.csv`, rows);
- toast.success(`CSV descargado (${patients.length} pacientes de la vista actual)`);
+ const timelineItems = history
+ ? [
+ ...history.appointments.map((a) => ({ kind: 'cita' as const, date: a.date || '', item: a })),
+ ...history.sales.map((s) => ({ kind: 'venta' as const, date: (s.date || '').slice(0, 10), item: s })),
+ ...history.visitas.map((v) => ({ kind: 'visita' as const, date: (v.date_start || '').slice(0, 10), item: v })),
+ ].sort((a, b) => b.date.localeCompare(a.date))
+ : [];
+
+ const exportPatients = async () => {
+ try {
+ await exportToExcel({
+ filename: `pacientes-skeen-${new Date().toISOString().split('T')[0]}`,
+ sheetName: 'Pacientes',
+ title: 'Pacientes SKEEN',
+ subtitle: `${patients.length} pacientes de la vista actual`,
+ columns: [
+ { header: 'Expediente', key: 'patient_id' },
+ { header: 'Nombre', key: 'name' },
+ { header: 'Teléfono', key: 'phone' },
+ { header: 'Email', key: 'email' },
+ { header: 'Fecha nacimiento', key: 'birth_date', format: 'date' },
+ { header: 'Género', key: 'gender' },
+ { header: 'Última visita', key: 'last_visit', format: 'date' },
+ { header: 'Visitas', key: 'total_visits', format: 'number' },
+ { header: 'Puntos monedero', key: 'wallet_points', format: 'number' },
+ { header: 'Total gastado', key: 'total_spent', format: 'currency' },
+ { header: 'Fuente', key: 'source' },
+ { header: 'VIP', key: 'vip' },
+ ],
+ rows: patients.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)`);
+ } catch (err) {
+ toast.error('Error al exportar');
+ console.error(err);
+ }
};
return (
@@ -324,6 +438,49 @@ const Pacientes: FC = () => {
className="pl-10"
/>
+
+ {([
+ { key: 'vip', label: 'VIP', active: onlyVip, set: setOnlyVip },
+ { key: 'recent', label: 'Visitas últimos 6 meses', active: onlyRecent, set: setOnlyRecent },
+ { key: 'incomplete', label: 'Expediente incompleto', active: onlyIncomplete, set: setOnlyIncomplete },
+ ] as const).map((chip) => (
+
{error && {error} } @@ -350,6 +507,14 @@ const Pacientes: FC = () => { {p.name} +
+ 0 ? `Falta: ${p.expediente_missing.join(', ')}` : 'Expediente completo'}
+ >
+ {p.expediente_completion ?? 0}%
+
+ |
{p.phone} |
{p.age ?? '-'} |
{p.last_visit || '-'} |
@@ -378,6 +543,7 @@ const Pacientes: FC = () => {
title={p.name}
subtitle={p.phone}
rows={[
+ { label: 'Expediente', value: `${p.expediente_completion ?? 0}%` },
{ label: 'Edad', value: p.age ?? '-' },
{ label: 'Última visita', value: p.last_visit || '-' },
{ label: 'Visitas', value: p.total_visits },
@@ -441,6 +607,26 @@ const Pacientes: FC = () => {
}
>
+ {/* Foto del paciente */}
+
@@ -570,14 +763,85 @@ const Pacientes: FC = () => {
onClose={() => setSelected(null)}
title={selected?.name || 'Detalle del paciente'}
maxWidth="2xl"
+ footer={
+
+ {photo ? (
+
+
+
+ )}
+
+
+
+ JPG o PNG, máx 5 MB. +
|
| Servicio | +Cant. | +Precio | +Subtotal | +
|---|---|---|---|
| + {line.service || line.description} + {line.prescribed_by && ( + Recetó: {line.prescribed_by} + )} + | +{line.quantity} | +${line.price_unit} | +${line.subtotal} | +
{label}
+{value}
+{visibles.length} cobros
+{error}
} {loading ? (Paciente
+{detalle.patient}
+Monto
+${detalle.amount}
+Método
+{metodoLabels[detalle.payment_method] || detalle.payment_method}
+Fecha de registro
+{detalle.create_date || '-'}
+Referencia proveedor
+{detalle.provider_reference || '-'}
+{paciente.name}
+
+ {paciente.phone}
+ {puntosPaciente > 0 && (
+
Toca un servicio para agregarlo al ticket.
+ ) : ( ++ {l.service.name} +
++ {s.name} +
++ Cambio: + 0 ? 'text-theme-heading' : 'text-theme-muted'}`}>{fmtMoney(cambio)} +
+ )} + > + )} + {puedePuntos && ( + + )} + {conPuntos && ( ++ Se canjearán {Math.ceil(total)} pts (1 pt = $1). El paciente acumula puntos solo por lo pagado en dinero. +
+ )} +SKEEN Derma Experts
+Playas de Rosarito, B.C.
+Paciente: {resultado.sale.patient}
+| {l.quantity} × {l.service || l.description} | +{fmtMoney(l.subtotal)} | +
¡Gracias por su visita!
+{error}
} - - {loading ? ( -| Referencia | -Nombre | -Tipo | -Precio | -
|---|---|---|---|
| {p.default_code || '-'} | -{p.name} | -
- |
- ${p.list_price} | -
{error}
} {loading ? ( @@ -173,93 +255,60 @@ const Servicios: FC = () => { ) : services.length === 0 ? (Precio sesión
{formatCurrency(s.price)}
+ {total} servicios · página {page} de {totalPages || 1} +
++ Si el nombre o código ya existen en el catálogo, el sistema lo rechazará para evitar duplicados. +
| Fecha de visita | +Cliente | +Edad | +Teléfono | +Médico asignado | +Exp. escaneado | +Galería | +Notas | +
|---|---|---|---|---|---|---|---|
| {p.last_visit || '-'} | +
+ {p.name} +{p.patient_id} + |
+ {p.age ?? '-'} | +{p.phone || '-'} | +{p.primary_doctor || '-'} | +
+ |
+
+ |
+
+ |
+
+ {total} pacientes · página {page} de {totalPages || 1} +
+| Usuario | -Nombre | -Rol | -Último acceso | -Estado | -Acciones | -
|---|---|---|---|---|---|
| {u.login} | -{u.name} | -{u.last_login || '—'} | -
- |
-
-
-
- |
-
| Hora | +Paciente | +Servicio | +Médico | +Motivo | +Estado | +
|---|---|---|---|---|---|
| {hora(v)} | +{v.patient} | +{v.servicio || '-'} | +{v.doctor || '-'} | +{v.motivo || '-'} | +
+ |
+
Insumos descargados del inventario.
+ )} + {selected.insumos.length === 0 ? ( +Sin insumos registrados.
+ ) : ( +Sin archivos adjuntos.
+ ) : ( + <> + {selected.adjuntos.filter((a) => a.kind !== 'documento').length > 0 && ( +@@ -422,6 +494,24 @@ const WacrmLeads: FC = () => { options={pipelineOptions} className="w-full lg:w-52" /> +
Mover a etapa
+Sin conversaciones con este filtro.
+ )} + {conversationsFiltradas.map((c) => ({c.last_message_at || c.created_at || ''}
{c.assigned_agent_id && ( - - Asignado + + {agentName(c.assigned_agent_id) || 'Asignado'} )}No hay mensajes en esta conversación
diff --git a/frontend/src/services/odoo.ts b/frontend/src/services/odoo.ts index 78b667a..a715f58 100644 --- a/frontend/src/services/odoo.ts +++ b/frontend/src/services/odoo.ts @@ -58,7 +58,10 @@ export interface Appointment { state: string; payment_state: string; price: number; - doctor_id?: number; + amount_paid?: number; + duration?: number; + is_first_visit?: boolean; + doctor_id?: number | null; doctor?: string; branch?: string; medium?: string; @@ -67,6 +70,64 @@ export interface Appointment { package_finished_date?: string | null; } +export interface Bloqueo { + id: number; + doctor_id: number; + doctor: string; + date: string; + all_day: boolean; + time_from: number; + time_to: number; + motivo: string; +} + +export type VisitaState = 'en_curso' | 'completada' | 'cancelada'; + +export interface VisitaInsumo { + id: number; + item_id: number; + item: string; + unit: string; + qty: number; + notes: string; +} + +export interface VisitaAdjunto { + id: number; + kind: 'antes' | 'despues' | 'documento'; + name: string; + mimetype: string; + notes: string; + create_date: string | null; + url: string; +} + +export interface Visita { + id: number; + name: string; + cita_id: number | null; + partner_id: number; + patient: string; + phone: string; + doctor_id: number | null; + doctor: string | null; + cosmetologa_id: number | null; + cosmetologa: string | null; + servicio_id: number | null; + servicio: string | null; + motivo: string; + diagnostico: string; + tratamiento: string; + notas: string; + receta_text?: string; + state: VisitaState; + date_start: string | null; + date_end: string | null; + insumos: VisitaInsumo[]; + insumos_descargados: boolean; + adjuntos: VisitaAdjunto[]; +} + export interface Patient { id: number; patient_id: string; @@ -83,8 +144,12 @@ export interface Patient { total_spent: number; source: string; is_vip: boolean; - primary_doctor_id?: number; + primary_doctor_id?: number | null; primary_doctor?: string; + whatsapp?: string; + has_photo?: boolean; + photo_url?: string; + photo?: string; // Datos personales extendidos birthplace?: string; occupation?: string; @@ -122,6 +187,42 @@ export interface Patient { allergies?: string; medical_history?: string; current_medication?: string; + // Completitud de expediente + expediente_completion?: number; + expediente_missing?: string[]; +} + +export interface EstadoCuenta { + puntos_monedero: number; + total_gastado: number; + adeudo: number; + visitas_total: number; +} + +export interface PatientAdjunto { + id: number; + kind: 'expediente' | 'imagen'; + name: string; + mimetype: string; + notes: string; + create_date: string | null; + url: string; +} + +export interface ExpedienteReciente { + id: number; + patient_id: string; + name: string; + phone: string; + age: number; + birth_date: string | null; + primary_doctor: string | null; + last_visit: string | null; + internal_notes: string; + expediente_count: number; + imagenes_count: number; + has_photo: boolean; + photo_url: string; } export interface Service { @@ -147,6 +248,7 @@ export interface Doctor { work_phone: string; work_email: string; commission_pct: number; + patient_count?: number; } export interface Product { @@ -364,6 +466,8 @@ export interface WacrmLead { expected_close_date: string | null; notes: string; assigned_to: string; + partner_id?: number | null; + conversation_id?: string; created_at: string | null; } @@ -377,6 +481,237 @@ export interface FrontendUser { must_change_password?: boolean; last_login?: string | null; active?: boolean; + allowed_menus?: string[] | null; +} + +export interface Receta { + id: number; + name: string; + categoria: string; + contenido: string; +} + +export interface Diagnostico { + id: number; + name: string; + categoria: string; + descripcion: string; +} + +export interface Procedimiento { + id: number; + name: string; + categoria: string; + descripcion: string; +} + +export interface AdeudoRow { + id: number; + name: string; + phone: string; + num_ventas: number; + total_adeudo: number; + ultima_venta: string | null; +} + +export interface ComisionRow { + doctor_id: number; + doctor: string; + job_title: string; + items: number; + sales: number; + citas_done: number; + total_vendido: number; + commission_pct: number; + comision: number; +} + +export interface MovimientoRow extends InventoryMove { + item: string; + unit: string; +} + +export interface CaducidadRow { + id: number; + name: string; + category: string; + qty: number; + unit: string; + expiry_date: string; + dias_restantes: number; + vencido: boolean; +} + +export interface SugerenciaRow { + id: number; + name: string; + category: string; + unit: string; + qty: number; + qty_min: number; + qty_optimal: number; + sugerido: number; + costo_estimado: number; +} + +export interface PagoServicioRow { + servicio: string; + cantidad: number; + total: number; +} + +export interface PagoClienteRow { + id: number; + name: string; + num_pagos: number; + total_cobrado: number; + ultimo_pago: string | null; +} + +export interface DevolucionRow { + id: number; + folio: string; + paciente: string; + fecha: string | null; + total: number; + refund_amount: number; + refund_reason: string; + refunded_at: string | null; +} + +export interface TopClienteRow { + id: number; + name: string; + num_ventas: number; + total: number; + ticket_promedio: number; +} + +export interface DailyPorMedico { + medico: string; + citas: number; + by_state: Record+ ¡Aún no hay visitas registradas! +
+