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:
365
frontend/src/pages/Servicios.tsx
Normal file
365
frontend/src/pages/Servicios.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Star, Edit2, Check, X, Plus, Clock, Tag, Package } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Input,
|
||||
TextArea,
|
||||
Modal,
|
||||
EmptyState,
|
||||
PageHeader,
|
||||
Skeleton,
|
||||
toast,
|
||||
} from '../components/ui';
|
||||
import { odooApi, type Service } from '../services/odoo';
|
||||
|
||||
const Servicios: FC = () => {
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState<Service | null>(null);
|
||||
const [editPrice, setEditPrice] = useState('');
|
||||
const [editPackagePrice, setEditPackagePrice] = useState('');
|
||||
const [editPackageSessions, setEditPackageSessions] = useState('');
|
||||
const [editPackageNotes, setEditPackageNotes] = useState('');
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
code: '',
|
||||
category: '',
|
||||
price: '',
|
||||
package_price: '',
|
||||
package_sessions: '',
|
||||
package_notes: '',
|
||||
duration_min: '',
|
||||
description: '',
|
||||
color: '#78716c',
|
||||
service_group: '',
|
||||
});
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await odooApi.getServices();
|
||||
if (res.status === 'success') setServices(res.services);
|
||||
} catch (err) {
|
||||
setError('Error al cargar servicios');
|
||||
toast.error('Error al cargar servicios');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const toggleFavorite = async (s: Service) => {
|
||||
try {
|
||||
await odooApi.updateService(s.id, { is_favorite: !s.is_favorite });
|
||||
toast.success('Favorito actualizado');
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error('Error al actualizar favorito');
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const startEdit = (s: Service) => {
|
||||
setEditing(s);
|
||||
setEditPrice(String(s.price));
|
||||
setEditPackagePrice(String(s.package_price ?? ''));
|
||||
setEditPackageSessions(String(s.package_sessions ?? ''));
|
||||
setEditPackageNotes(s.package_notes || '');
|
||||
};
|
||||
|
||||
const savePrice = async () => {
|
||||
if (!editing) return;
|
||||
const price = parseFloat(editPrice);
|
||||
const packagePrice = editPackagePrice === '' ? 0 : parseFloat(editPackagePrice);
|
||||
const packageSessions = editPackageSessions === '' ? 0 : parseInt(editPackageSessions, 10);
|
||||
if (Number.isNaN(price) || price < 0) {
|
||||
toast.error('Precio inválido');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await odooApi.updateService(editing.id, {
|
||||
price,
|
||||
package_price: Number.isNaN(packagePrice) ? 0 : packagePrice,
|
||||
package_sessions: Number.isNaN(packageSessions) ? 0 : packageSessions,
|
||||
package_notes: editPackageNotes,
|
||||
});
|
||||
toast.success('Servicio actualizado');
|
||||
setEditing(null);
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error('Error al actualizar servicio');
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const create = async () => {
|
||||
const price = parseFloat(form.price);
|
||||
const duration = parseInt(form.duration_min, 10);
|
||||
const packagePrice = form.package_price === '' ? 0 : parseFloat(form.package_price);
|
||||
const packageSessions = form.package_sessions === '' ? 0 : parseInt(form.package_sessions, 10);
|
||||
if (!form.name || Number.isNaN(price) || price < 0) {
|
||||
toast.error('Nombre y precio válidos son obligatorios');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await odooApi.createService({
|
||||
name: form.name,
|
||||
code: form.code,
|
||||
category: form.category,
|
||||
price,
|
||||
package_price: Number.isNaN(packagePrice) ? 0 : packagePrice,
|
||||
package_sessions: Number.isNaN(packageSessions) ? 0 : packageSessions,
|
||||
package_notes: form.package_notes,
|
||||
duration_min: Number.isNaN(duration) ? 0 : duration,
|
||||
description: form.description,
|
||||
color: form.color,
|
||||
service_group: form.service_group,
|
||||
});
|
||||
toast.success('Servicio creado');
|
||||
setCreateOpen(false);
|
||||
setForm({
|
||||
name: '',
|
||||
code: '',
|
||||
category: '',
|
||||
price: '',
|
||||
package_price: '',
|
||||
package_sessions: '',
|
||||
package_notes: '',
|
||||
duration_min: '',
|
||||
description: '',
|
||||
color: '#78716c',
|
||||
service_group: '',
|
||||
});
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error('Error al crear servicio');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatCurrency = (value: number) =>
|
||||
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
|
||||
|
||||
return (
|
||||
<Layout title="Servicios" subtitle="Catálogo de tratamientos">
|
||||
<PageHeader title="Servicios" subtitle="Gestiona precios, paquetes y favoritos">
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Nuevo servicio
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-48 w-full" />)}
|
||||
</div>
|
||||
) : services.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Sin servicios"
|
||||
subtitle="No hay servicios registrados."
|
||||
actionLabel="Nuevo servicio"
|
||||
onAction={() => setCreateOpen(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{services.map((s) => (
|
||||
<Card key={s.id} className="hover:shadow-md transition">
|
||||
<Card.Body>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full shrink-0"
|
||||
style={{ backgroundColor: s.color || '#78716c' }}
|
||||
/>
|
||||
<h4 className="font-medium text-theme-heading pr-2 truncate">{s.name}</h4>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleFavorite(s)}
|
||||
title={s.is_favorite ? 'Quitar favorito' : 'Marcar favorito'}
|
||||
>
|
||||
<Star
|
||||
size={18}
|
||||
className={s.is_favorite ? 'text-amber-500' : 'text-theme-muted'}
|
||||
fill={s.is_favorite ? 'currentColor' : 'none'}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-theme-muted mb-4">
|
||||
<span className="inline-flex items-center bg-theme-bg rounded-md px-2 py-0.5">
|
||||
<Tag size={12} className="mr-1" />
|
||||
{s.category || 'Sin categoría'}
|
||||
</span>
|
||||
<span className="inline-flex items-center bg-theme-bg rounded-md px-2 py-0.5">
|
||||
<Clock size={12} className="mr-1" />
|
||||
{s.duration_min} min
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{editing?.id === s.id ? (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
label="Precio"
|
||||
type="number"
|
||||
value={editPrice}
|
||||
onChange={(e) => setEditPrice(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Precio paquete"
|
||||
type="number"
|
||||
value={editPackagePrice}
|
||||
onChange={(e) => setEditPackagePrice(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
label="Sesiones"
|
||||
type="number"
|
||||
value={editPackageSessions}
|
||||
onChange={(e) => setEditPackageSessions(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Notas paquete"
|
||||
value={editPackageNotes}
|
||||
onChange={(e) => setEditPackageNotes(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={savePrice} title="Guardar">
|
||||
<Check size={16} className="text-theme-heading" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setEditing(null)} title="Cancelar">
|
||||
<X size={16} className="text-theme-muted" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-theme-muted">Precio sesión</p>
|
||||
<p className="text-lg font-heading font-semibold text-theme-heading">{formatCurrency(s.price)}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => startEdit(s)} title="Editar">
|
||||
<Edit2 size={16} className="text-theme-muted" />
|
||||
</Button>
|
||||
</div>
|
||||
{(s.package_price ?? 0) > 0 && (s.package_sessions ?? 0) > 0 && (
|
||||
<div className="flex items-start gap-2 rounded-xl p-2.5" style={{ backgroundColor: 'var(--success)' }}>
|
||||
<Package size={14} className="shrink-0 mt-0.5" style={{ color: 'var(--success-text)' }} />
|
||||
<div>
|
||||
<p className="text-xs font-medium" style={{ color: 'var(--success-text)' }}>
|
||||
Paquete {s.package_sessions} sesiones: {formatCurrency(s.package_price ?? 0)}
|
||||
</p>
|
||||
{s.package_notes && <p className="text-[10px]" style={{ color: 'var(--success-text)' }}>{s.package_notes}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
isOpen={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
title="Nuevo servicio"
|
||||
maxWidth="md"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={create} loading={submitting}>Crear servicio</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Nombre *"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Código"
|
||||
value={form.code}
|
||||
onChange={(e) => setForm({ ...form, code: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="Categoría"
|
||||
value={form.category}
|
||||
onChange={(e) => setForm({ ...form, category: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Precio sesión *"
|
||||
type="number"
|
||||
value={form.price}
|
||||
onChange={(e) => setForm({ ...form, price: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="Duración (min)"
|
||||
type="number"
|
||||
value={form.duration_min}
|
||||
onChange={(e) => setForm({ ...form, duration_min: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Precio paquete"
|
||||
type="number"
|
||||
value={form.package_price}
|
||||
onChange={(e) => setForm({ ...form, package_price: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="Sesiones en paquete"
|
||||
type="number"
|
||||
value={form.package_sessions}
|
||||
onChange={(e) => setForm({ ...form, package_sessions: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Notas del paquete"
|
||||
value={form.package_notes}
|
||||
onChange={(e) => setForm({ ...form, package_notes: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="Color"
|
||||
type="color"
|
||||
value={form.color}
|
||||
onChange={(e) => setForm({ ...form, color: e.target.value })}
|
||||
/>
|
||||
<TextArea
|
||||
label="Descripción"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Servicios;
|
||||
Reference in New Issue
Block a user