diff --git a/frontend/src/components/CortePrint.tsx b/frontend/src/components/CortePrint.tsx new file mode 100644 index 0000000..5ef17fd --- /dev/null +++ b/frontend/src/components/CortePrint.tsx @@ -0,0 +1,115 @@ +import type { FC } from 'react'; +import { Printer } from 'lucide-react'; +import { Button, Modal } from './ui'; +import type { CashClosing } from '../services/odoo'; + +// Corte de caja imprimible. Sigue el patrón de impresión existente +// (RecetaPrint / EstadoCuentaPrint / TicketPrint): contenido en un +// div.print-ticket + window.print(); el CSS de @media print en index.css +// oculta el resto de la página. + +interface ClinicSettings { + name: string; + phone: string; + address: string; +} + +const clinicSettings = (): ClinicSettings => { + try { + const saved = localStorage.getItem('skeen_clinic_settings'); + if (saved) return JSON.parse(saved); + } catch { + // ignore + } + return { name: 'SKEEN Derma Experts', phone: '', address: '' }; +}; + +const money = (v: number) => + '$' + (v ?? 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + +export const CortePrint: FC<{ corte: CashClosing | null; onClose: () => void }> = ({ corte, onClose }) => { + if (!corte) return null; + const clinic = clinicSettings(); + + const fila = (label: string, valor: number) => ( + + {label} + {money(valor)} + + ); + + const filaDiff = (label: string, esperado: number, contado: number, diff: number) => ( + + {label} + {money(esperado)} + {money(contado)} + 0 ? 'text-emerald-700' : '')}> + {diff > 0 ? '+' : ''}{money(diff)} + + + ); + + return ( + +
+
+

{clinic.name}

+ {clinic.address &&

{clinic.address}

} + {clinic.phone &&

{clinic.phone}

} +

CORTE DE CAJA {corte.name}

+

Fecha: {corte.date} · Cerró: {corte.user || '—'}

+
+ + + + + + + + + + + + {filaDiff('Efectivo', corte.opening_cash + corte.total_cash, corte.counted_cash, corte.diff_cash)} + {filaDiff('Tarjeta', corte.total_card, corte.counted_card, corte.diff_card)} + {filaDiff('Transferencia', corte.total_transfer, corte.counted_transfer, corte.diff_transfer)} + {filaDiff('Otros', corte.total_other, corte.counted_other, corte.diff_other)} + + + {fila('Ventas totales del día', corte.total_sales)} + {fila('Efectivo esperado (apertura + cobros)', corte.opening_cash + corte.total_cash)} + {fila('Efectivo de cierre registrado', corte.closing_cash)} + + + + + + + +
MétodoEsperadoContadoDiferencia
Diferencia total del corte 0 ? 'text-emerald-700' : '')}> + {corte.difference > 0 ? '+' : ''}{money(corte.difference)} +
+ + {corte.notes && ( +
+

Notas:

+

{corte.notes}

+
+ )} + +
+ _____________________
Entregó (cajero)
+ _____________________
Recibió (administración)
+
+

Impreso el {new Date().toLocaleString('es-MX')}

+
+ +
+ +
+
+ ); +}; diff --git a/frontend/src/components/PatientAdjuntos.tsx b/frontend/src/components/PatientAdjuntos.tsx index af4f805..e8ff29b 100644 --- a/frontend/src/components/PatientAdjuntos.tsx +++ b/frontend/src/components/PatientAdjuntos.tsx @@ -1,7 +1,7 @@ import type { FC } from 'react'; -import { useEffect, useState, useCallback, useRef } from 'react'; -import { FileText, X, Upload } from 'lucide-react'; -import { Button, Modal, Skeleton, toast } from './ui'; +import { useEffect, useState, useCallback, useRef, useMemo } from 'react'; +import { FileText, X, Upload, GitCompare, Tag } from 'lucide-react'; +import { Button, Modal, Select, Skeleton, toast } from './ui'; import { odooApi, type PatientAdjunto } from '../services/odoo'; interface PatientAdjuntosProps { @@ -13,12 +13,25 @@ interface PatientAdjuntosProps { const MAX_BYTES = 10 * 1024 * 1024; +const CLASIF_LABEL: Record = { + antes: 'Antes', + despues: 'Después', + sin_clasificar: '', +}; + export const PatientAdjuntos: FC = ({ patientId, kind, editable = true, onChange }) => { const [adjuntos, setAdjuntos] = useState([]); const [loading, setLoading] = useState(true); const [uploading, setUploading] = useState(false); const [visor, setVisor] = useState(null); + const [nuevaClasif, setNuevaClasif] = useState('sin_clasificar'); + const [clasificando, setClasificando] = useState(false); const fileRef = useRef(null); + // Comparación antes/después + const [compAbierta, setCompAbierta] = useState(false); + const [compServicio, setCompServicio] = useState(''); + const [compAntes, setCompAntes] = useState(''); + const [compDespues, setCompDespues] = useState(''); const load = useCallback(async () => { try { @@ -55,9 +68,11 @@ export const PatientAdjuntos: FC = ({ patientId, kind, edi name: file.name, file_b64, mimetype: file.type || 'application/octet-stream', + antes_despues: kind === 'imagen' ? nuevaClasif : undefined, }); if (res.status === 'success') { toast.success('Archivo subido'); + if (kind === 'imagen') setNuevaClasif('sin_clasificar'); await load(); onChange?.(); } @@ -84,13 +99,47 @@ export const PatientAdjuntos: FC = ({ patientId, kind, edi } }; + const clasificar = async (adjId: number, antes_despues: string) => { + try { + setClasificando(true); + await odooApi.updatePatientAdjunto(adjId, { antes_despues }); + if (visor) setVisor({ ...visor, antes_despues: antes_despues as PatientAdjunto['antes_despues'] }); + toast.success( + antes_despues === 'sin_clasificar' ? 'Clasificación quitada' + : antes_despues === 'antes' ? 'Marcada como Antes' : 'Marcada como Después', + ); + await load(); + } catch (err) { + toast.error('Error al clasificar la foto'); + console.error(err); + } finally { + setClasificando(false); + } + }; + const imagenes = adjuntos.filter((a) => a.mimetype.startsWith('image/')); const docs = adjuntos.filter((a) => !a.mimetype.startsWith('image/')); + // Comparación: opciones por servicio y fotos antes/después + const servicios = useMemo(() => { + const map = new Map(); + imagenes.forEach((a) => { if (a.servicio_id) map.set(a.servicio_id, a.servicio); }); + return Array.from(map.entries()); + }, [imagenes]); + + const fotosFiltradas = useMemo( + () => (compServicio ? imagenes.filter((a) => String(a.servicio_id) === compServicio) : imagenes), + [imagenes, compServicio], + ); + const fotosAntes = fotosFiltradas.filter((a) => a.antes_despues === 'antes'); + const fotosDespues = fotosFiltradas.filter((a) => a.antes_despues === 'despues'); + const fotoAntes = fotosAntes.find((a) => String(a.id) === compAntes) || fotosAntes[0]; + const fotoDespues = fotosDespues.find((a) => String(a.id) === compDespues) || fotosDespues[0]; + return (
{editable && ( -
+
= ({ patientId, kind, edi {kind === 'imagen' ? 'Subir foto' : 'Escanear / Subir expediente'} + {kind === 'imagen' && ( + { setCompServicio(e.target.value); setCompAntes(''); setCompDespues(''); }} + options={[ + { value: '', label: 'Todos los servicios' }, + ...servicios.map(([id, nombre]) => ({ value: String(id), label: nombre })), + ]} + /> + )} +
+ setCompDespues(e.target.value)} + options={ + fotosDespues.length === 0 + ? [{ value: '', label: 'Sin fotos marcadas como Después' }] + : fotosDespues.map((a) => ({ value: String(a.id), label: a.name || `Foto #${a.id}` })) + } + /> +
+
+
+

Antes

+ {fotoAntes ? ( + {fotoAntes.name} + ) : ( +
+ Sin foto Antes +
+ )} +
+
+

Después

+ {fotoDespues ? ( + {fotoDespues.name} + ) : ( +
+ Sin foto Después +
+ )} +
+
+
+
); }; diff --git a/frontend/src/pages/Cortes.tsx b/frontend/src/pages/Cortes.tsx index 4d29b65..ee25e66 100644 --- a/frontend/src/pages/Cortes.tsx +++ b/frontend/src/pages/Cortes.tsx @@ -1,6 +1,6 @@ import type { FC } from 'react'; import { useEffect, useState } from 'react'; -import { Plus, Lock, Receipt } from 'lucide-react'; +import { Plus, Lock, Receipt, Printer, ChevronLeft, ChevronRight } from 'lucide-react'; import Layout from '../components/Layout'; import { Card, @@ -12,20 +12,38 @@ import { PageHeader, Skeleton, MobileCard, + TextArea, toast, badgeForCashClosingState, } from '../components/ui'; +import { CortePrint } from '../components/CortePrint'; import { odooApi, type CashClosing } from '../services/odoo'; +const money = (v: number) => + '$' + (v ?? 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + +const PASOS = [ + { key: 'cash', titulo: 'Efectivo' }, + { key: 'card', titulo: 'Tarjeta' }, + { key: 'transfer', titulo: 'Transferencia' }, + { key: 'other', titulo: 'Otros' }, +] as const; + const Cortes: FC = () => { const [closings, setClosings] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [createOpen, setCreateOpen] = useState(false); - const [closeOpen, setCloseOpen] = useState(null); + const [wizard, setWizard] = useState(null); + const [step, setStep] = useState(0); // 0..3 métodos, 4 = resumen + const [countedCash, setCountedCash] = useState(''); + const [countedCard, setCountedCard] = useState(''); + const [countedTransfer, setCountedTransfer] = useState(''); + const [countedOther, setCountedOther] = useState(''); + const [notes, setNotes] = useState(''); + const [printCorte, setPrintCorte] = useState(null); const [date, setDate] = useState(() => new Date().toISOString().split('T')[0]); const [openingCash, setOpeningCash] = useState(''); - const [closingCash, setClosingCash] = useState(''); const [submitting, setSubmitting] = useState(false); const load = async () => { @@ -47,6 +65,33 @@ const Cortes: FC = () => { load(); }, []); + const abrirWizard = (c: CashClosing) => { + setWizard(c); + setStep(0); + setCountedCash(''); + setCountedCard(''); + setCountedTransfer(''); + setCountedOther(''); + setNotes(''); + }; + + const esperado = (c: CashClosing, key: string) => { + if (key === 'cash') return c.opening_cash + c.total_cash; + if (key === 'card') return c.total_card; + if (key === 'transfer') return c.total_transfer; + return c.total_other; + }; + + const valorContado = (key: string) => { + if (key === 'cash') return parseFloat(countedCash || '0') || 0; + if (key === 'card') return parseFloat(countedCard || '0') || 0; + if (key === 'transfer') return parseFloat(countedTransfer || '0') || 0; + return parseFloat(countedOther || '0') || 0; + }; + + const diffColor = (diff: number) => + diff < 0 ? 'text-rose-600' : diff > 0 ? 'text-emerald-600' : 'text-theme-muted'; + const create = async () => { const value = parseFloat(openingCash); if (Number.isNaN(value) || value < 0) { @@ -68,19 +113,19 @@ const Cortes: FC = () => { } }; - const close = async () => { - if (!closeOpen) return; - const value = parseFloat(closingCash); - if (Number.isNaN(value) || value < 0) { - toast.error('Ingresa un efectivo final válido'); - return; - } + const cerrarCaja = async () => { + if (!wizard) return; try { setSubmitting(true); - await odooApi.closeCashClosing(closeOpen.id, value); + await odooApi.closeCashClosing(wizard.id, { + counted_cash: valorContado('cash'), + counted_card: valorContado('card'), + counted_transfer: valorContado('transfer'), + counted_other: valorContado('other'), + notes, + }); toast.success('Caja cerrada'); - setCloseOpen(null); - setClosingCash(''); + setWizard(null); await load(); } catch (err) { toast.error('Error al cerrar caja'); @@ -96,11 +141,104 @@ const Cortes: FC = () => { }; const differenceColor = (diff: number) => { - if (diff > 0) return 'text-theme-heading'; + if (diff > 0) return 'text-emerald-600'; if (diff < 0) return 'text-rose-600'; return 'text-theme-muted'; }; + // --- Contenido de un paso del asistente --- + const renderPaso = () => { + if (!wizard) return null; + if (step < PASOS.length) { + const { key, titulo } = PASOS[step]; + const esp = esperado(wizard, key); + const cont = valorContado(key); + const diff = cont - esp; + const setters: Record void> = { + cash: setCountedCash, card: setCountedCard, + transfer: setCountedTransfer, other: setCountedOther, + }; + const values: Record = { + cash: countedCash, card: countedCard, + transfer: countedTransfer, other: countedOther, + }; + const informativo = key !== 'cash'; + return ( +
+
+
+

+ {key === 'cash' ? 'Efectivo esperado (apertura + cobros)' : `${titulo} registrado en el sistema`} +

+

{money(esp)}

+
+
+

{cont > 0 || values[key] ? 'Diferencia' : 'Aún sin contar'}

+

+ {values[key] ? `${diff > 0 ? 'Sobrante +' : diff < 0 ? 'Faltante ' : ''}${money(diff)}` : '—'} +

+
+
+ setters[key](e.target.value)} + autoFocus + /> + {key === 'cash' && countedCash && ( +

+ {diff === 0 && 'El efectivo cuadra.'} + {diff < 0 && `Faltan ${money(-diff)} en la caja.`} + {diff > 0 && `Sobran ${money(diff)} en la caja.`} +

+ )} +
+ ); + } + // Resumen + return ( +
+ + + + + + + + + + + {PASOS.map(({ key, titulo }) => { + const esp = esperado(wizard, key); + const cont = valorContado(key); + const diff = cont - esp; + return ( + + + + + + + ); + })} + +
MétodoEsperadoContadoDiferencia
{titulo}{money(esp)}{money(cont)} + {diff > 0 ? '+' : ''}{money(diff)} +
+