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:
262
frontend/src/pages/Cortes.tsx
Normal file
262
frontend/src/pages/Cortes.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Plus, Lock, Receipt } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
Badge,
|
||||
EmptyState,
|
||||
PageHeader,
|
||||
Skeleton,
|
||||
MobileCard,
|
||||
toast,
|
||||
badgeForCashClosingState,
|
||||
} from '../components/ui';
|
||||
import { odooApi, type CashClosing } from '../services/odoo';
|
||||
|
||||
const Cortes: FC = () => {
|
||||
const [closings, setClosings] = useState<CashClosing[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [closeOpen, setCloseOpen] = useState<CashClosing | null>(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 () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await odooApi.getCashClosings();
|
||||
if (res.status === 'success') setClosings(res.cash_closings);
|
||||
} catch (err) {
|
||||
setError('Error al cargar cortes');
|
||||
toast.error('Error al cargar cortes');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const create = async () => {
|
||||
const value = parseFloat(openingCash);
|
||||
if (Number.isNaN(value) || value < 0) {
|
||||
toast.error('Ingresa un efectivo inicial válido');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await odooApi.createCashClosing({ date, opening_cash: value });
|
||||
toast.success('Caja abierta');
|
||||
setCreateOpen(false);
|
||||
setOpeningCash('');
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error('Error al crear corte');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await odooApi.closeCashClosing(closeOpen.id, value);
|
||||
toast.success('Caja cerrada');
|
||||
setCloseOpen(null);
|
||||
setClosingCash('');
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error('Error al cerrar caja');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderUser = (user: string | false) => {
|
||||
if (!user) return 'Usuario no disponible';
|
||||
return user;
|
||||
};
|
||||
|
||||
const differenceColor = (diff: number) => {
|
||||
if (diff > 0) return 'text-theme-heading';
|
||||
if (diff < 0) return 'text-rose-600';
|
||||
return 'text-theme-muted';
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout title="Cortes de Caja" subtitle="Arqueos y cierres">
|
||||
<PageHeader title="Cortes de Caja" subtitle="Abre y cierra turnos">
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Abrir caja
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<Card>
|
||||
<Card.Body>
|
||||
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
<Skeleton count={5} className="h-14 w-full" />
|
||||
) : closings.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Sin cortes"
|
||||
subtitle="No hay cortes de caja registrados."
|
||||
actionLabel="Abrir caja"
|
||||
onAction={() => setCreateOpen(true)}
|
||||
icon={<Receipt size={28} />}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="hidden sm:block overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-theme-bg">
|
||||
<tr>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Referencia</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Fecha</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Usuario</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Apertura</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden lg:table-cell">Ventas</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Diferencia</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Estado</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{closings.map((c) => (
|
||||
<tr key={c.id} className="hover:bg-theme-bg">
|
||||
<td className="p-3 text-sm text-theme-muted">{c.name}</td>
|
||||
<td className="p-3 text-sm text-theme-muted">{c.date}</td>
|
||||
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">{renderUser(c.user)}</td>
|
||||
<td className="p-3 text-sm text-theme-heading">${c.opening_cash}</td>
|
||||
<td className="p-3 text-sm text-theme-heading hidden lg:table-cell">${c.total_sales}</td>
|
||||
<td className={`p-3 text-sm font-semibold ${differenceColor(c.difference)}`}>
|
||||
{c.difference > 0 ? '+' : ''}${c.difference}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge variant={badgeForCashClosingState(c.state)}>{c.state}</Badge>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{c.state !== 'closed' && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setCloseOpen(c)} title="Cerrar caja">
|
||||
<Lock size={16} className="text-theme-heading" />
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="sm:hidden space-y-3">
|
||||
{closings.map((c) => (
|
||||
<MobileCard
|
||||
key={c.id}
|
||||
title={c.name}
|
||||
subtitle={<Badge variant={badgeForCashClosingState(c.state)}>{c.state}</Badge>}
|
||||
rows={[
|
||||
{ label: 'Fecha', value: c.date },
|
||||
{ label: 'Usuario', value: renderUser(c.user) },
|
||||
{ label: 'Apertura', value: `$${c.opening_cash}` },
|
||||
{ label: 'Diferencia', value: `${c.difference > 0 ? '+' : ''}$${c.difference}` },
|
||||
]}
|
||||
actions={
|
||||
c.state !== 'closed' && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setCloseOpen(c)}>
|
||||
<Lock size={16} className="text-theme-heading" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
isOpen={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
title="Abrir caja"
|
||||
maxWidth="sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={create} loading={submitting}>Abrir caja</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Fecha"
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Efectivo inicial"
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.01}
|
||||
value={openingCash}
|
||||
onChange={(e) => setOpeningCash(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={!!closeOpen}
|
||||
onClose={() => setCloseOpen(null)}
|
||||
title="Cerrar caja"
|
||||
maxWidth="sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setCloseOpen(null)}>Cancelar</Button>
|
||||
<Button onClick={close} loading={submitting}>Cerrar caja</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{closeOpen && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-theme-muted">
|
||||
Corte: <span className="font-medium text-theme-heading">{closeOpen.name}</span>
|
||||
</p>
|
||||
<p className="text-sm text-theme-muted">
|
||||
Ventas registradas: <span className="font-medium text-theme-heading">${closeOpen.total_sales}</span>
|
||||
</p>
|
||||
<Input
|
||||
label="Efectivo final"
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.01}
|
||||
value={closingCash}
|
||||
onChange={(e) => setClosingCash(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Cortes;
|
||||
Reference in New Issue
Block a user