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:
2026-07-20 07:44:23 +00:00
commit a718592291
699 changed files with 324602 additions and 0 deletions

View File

@@ -0,0 +1,241 @@
import type { FC } from 'react';
import { useEffect, useState } from 'react';
import { Phone, MapPin, Mail, Globe, Save, Building2, ArrowRightLeft } from 'lucide-react';
import Layout from '../components/Layout';
import { Card, Button, Input, PageHeader, toast } from '../components/ui';
import { odooApi } from '../services/odoo';
interface ClinicSettings {
name: string;
phone: string;
email: string;
address: string;
website: string;
}
const defaultSettings: ClinicSettings = {
name: 'SKEEN Derma Experts',
phone: '+52 (661) 100-2172',
email: 'hola@skeen.mx',
address: 'Playas de Rosarito, Baja California',
website: 'https://skeen.mx',
};
const STORAGE_KEY = 'skeen_clinic_settings';
const Configuracion: FC = () => {
const [settings, setSettings] = useState<ClinicSettings>(defaultSettings);
const [loading, setLoading] = useState(true);
useEffect(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) setSettings(JSON.parse(saved));
} catch {
// ignore
} finally {
setLoading(false);
}
}, []);
const save = () => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
toast.success('Configuración guardada');
} catch {
toast.error('Error al guardar configuración');
}
};
const update = (field: keyof ClinicSettings, value: string) => {
setSettings((prev) => ({ ...prev, [field]: value }));
};
// ---- Tipo de cambio USD/MXN ----
const [tcCurrent, setTcCurrent] = useState<{ rate: number; date: string; source: string } | null>(null);
const [tcInput, setTcInput] = useState('');
const [tcSaving, setTcSaving] = useState(false);
useEffect(() => {
odooApi.getExchangeRate()
.then((res) => {
if (res.current) {
setTcCurrent(res.current);
setTcInput(String(res.current.rate));
}
})
.catch(() => {/* odoo no disponible, ignorar */});
}, []);
const saveTc = async () => {
const rate = parseFloat(tcInput);
if (!rate || rate <= 0) {
toast.error('Ingresa un tipo de cambio válido');
return;
}
setTcSaving(true);
try {
const res = await odooApi.setExchangeRate(rate, 'manual');
setTcCurrent(res.exchange_rate);
toast.success(`Tipo de cambio actualizado: ${rate.toFixed(2)} MXN/USD`);
} catch {
toast.error('No se pudo guardar el tipo de cambio');
} finally {
setTcSaving(false);
}
};
return (
<Layout title="Configuración" subtitle="Ajustes de la clínica">
<PageHeader title="Configuración" subtitle="Información y ajustes de la clínica" />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
<Card>
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-4">Información de la clínica</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<a
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(settings.address)}`}
target="_blank"
rel="noreferrer"
className="p-4 bg-theme-bg rounded-xl flex items-start space-x-3 hover:bg-theme-bg transition"
>
<div className="w-10 h-10 rounded-full bg-theme-surface flex items-center justify-center text-theme-muted shadow-sm shrink-0">
<MapPin size={18} />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-theme-heading">Dirección</p>
<p className="text-sm text-theme-muted truncate">{settings.address}</p>
</div>
</a>
<a
href={`tel:${settings.phone}`}
className="p-4 bg-theme-bg rounded-xl flex items-start space-x-3 hover:bg-theme-bg transition"
>
<div className="w-10 h-10 rounded-full bg-theme-surface flex items-center justify-center text-theme-muted shadow-sm shrink-0">
<Phone size={18} />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-theme-heading">Teléfono</p>
<p className="text-sm text-theme-muted truncate">{settings.phone}</p>
</div>
</a>
<a
href={`mailto:${settings.email}`}
className="p-4 bg-theme-bg rounded-xl flex items-start space-x-3 hover:bg-theme-bg transition"
>
<div className="w-10 h-10 rounded-full bg-theme-surface flex items-center justify-center text-theme-muted shadow-sm shrink-0">
<Mail size={18} />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-theme-heading">Email</p>
<p className="text-sm text-theme-muted truncate">{settings.email}</p>
</div>
</a>
<a
href={settings.website}
target="_blank"
rel="noreferrer"
className="p-4 bg-theme-bg rounded-xl flex items-start space-x-3 hover:bg-theme-bg transition"
>
<div className="w-10 h-10 rounded-full bg-theme-surface flex items-center justify-center text-theme-muted shadow-sm shrink-0">
<Globe size={18} />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-theme-heading">Sitio web</p>
<p className="text-sm text-theme-muted truncate">{settings.website.replace(/^https?:\/\//, '')}</p>
</div>
</a>
</div>
</Card.Body>
</Card>
<Card>
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-4 flex items-center">
<Building2 size={20} className="mr-2" />
Ajustes
</h3>
{loading ? (
<p className="text-sm text-theme-muted">Cargando...</p>
) : (
<div className="space-y-4">
<Input
label="Nombre de la clínica"
value={settings.name}
onChange={(e) => update('name', e.target.value)}
/>
<Input
label="Teléfono"
value={settings.phone}
onChange={(e) => update('phone', e.target.value)}
/>
<Input
label="Email"
type="email"
value={settings.email}
onChange={(e) => update('email', e.target.value)}
/>
<Input
label="Dirección"
value={settings.address}
onChange={(e) => update('address', e.target.value)}
/>
<Input
label="Sitio web"
value={settings.website}
onChange={(e) => update('website', e.target.value)}
/>
<div className="pt-2">
<Button onClick={save}>
<Save size={16} className="mr-2" />
Guardar ajustes
</Button>
</div>
</div>
)}
</Card.Body>
</Card>
<Card className="lg:col-span-2">
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-1 flex items-center">
<ArrowRightLeft size={20} className="mr-2" />
Tipo de cambio USD / MXN
</h3>
<p className="text-sm text-theme-muted mb-4">
Se usa para convertir ventas en dólares. Vigente:
{tcCurrent ? (
<span className="font-semibold text-theme-heading"> {Number(tcCurrent.rate).toFixed(2)} MXN por 1 USD <span className="font-normal text-theme-muted">({tcCurrent.date})</span></span>
) : (
<span className="text-theme-muted"> sin definir</span>
)}
</p>
<div className="flex flex-col sm:flex-row items-start sm:items-end gap-3 max-w-md">
<div className="flex-1 w-full">
<Input
label="Nuevo tipo de cambio (MXN por 1 USD)"
type="number"
step="0.01"
min="0"
value={tcInput}
onChange={(e) => setTcInput(e.target.value)}
placeholder="17.25"
/>
</div>
<Button onClick={saveTc} disabled={tcSaving}>
<Save size={16} className="mr-2" />
{tcSaving ? 'Guardando...' : 'Actualizar TC'}
</Button>
</div>
</Card.Body>
</Card>
</div>
</Layout>
);
};
export default Configuracion;