fase 1 productizacion: capa de configuracion (glosario multi-giro, sucursales y categorias configurables, ajustes de clinica por instancia, colores de marca, horarios, URLs de pago dinamicas) + script de provisioning de instancia nueva con datos demo + frontend consumiendo la config
This commit is contained in:
@@ -4,6 +4,9 @@ import { MENU_ITEMS } from './lib/menu-items';
|
||||
import { ToastProvider, Button } from './components/ui';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import { AuthProvider, useAuth } from './lib/auth';
|
||||
import { GlosarioProvider } from './lib/glosario';
|
||||
import { SucursalesProvider } from './lib/sucursales';
|
||||
import { ServiceCategoriesProvider } from './lib/service-categories';
|
||||
import { QuickLinksProvider } from './lib/quicklinks';
|
||||
import { PatientDrawerProvider } from './components/PatientDrawer';
|
||||
import ChatNotifications from './lib/chat-notifications';
|
||||
@@ -122,6 +125,9 @@ const App: FC = () => {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<AuthProvider>
|
||||
<GlosarioProvider>
|
||||
<SucursalesProvider>
|
||||
<ServiceCategoriesProvider>
|
||||
<QuickLinksProvider>
|
||||
<Router>
|
||||
<ToastProvider />
|
||||
@@ -160,6 +166,9 @@ const App: FC = () => {
|
||||
</PatientDrawerProvider>
|
||||
</Router>
|
||||
</QuickLinksProvider>
|
||||
</ServiceCategoriesProvider>
|
||||
</SucursalesProvider>
|
||||
</GlosarioProvider>
|
||||
</AuthProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
221
frontend/src/components/CategoriasPanel.tsx
Normal file
221
frontend/src/components/CategoriasPanel.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import type { FC } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Plus, Pencil, Power, Lock, Tag } from 'lucide-react';
|
||||
import { Button, Input, Modal, EmptyState, Skeleton, toast } from './ui';
|
||||
import { odooApi, type ServiceCategory } from '../services/odoo';
|
||||
import { useServiceCategories } from '../lib/service-categories';
|
||||
|
||||
interface FormState { name: string; code: string; sequence: string; }
|
||||
|
||||
const emptyForm: FormState = { name: '', code: '', sequence: '10' };
|
||||
|
||||
/** Configuración › Categorías de servicios: CRUD de categorías del catálogo. */
|
||||
const CategoriasPanel: FC = () => {
|
||||
const { refresh } = useServiceCategories();
|
||||
const [categories, setCategories] = useState<ServiceCategory[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<ServiceCategory | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await odooApi.getServiceCategories({ includeInactive: true });
|
||||
if (res.status === 'success') setCategories(res.categories);
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar categorías');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setForm(emptyForm);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (c: ServiceCategory) => {
|
||||
setEditing(c);
|
||||
setForm({ name: c.name, code: c.code, sequence: String(c.sequence) });
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.name.trim()) {
|
||||
toast.error('El nombre es obligatorio');
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
code: form.code.trim() || undefined,
|
||||
sequence: parseInt(form.sequence, 10) || 10,
|
||||
};
|
||||
try {
|
||||
setSubmitting(true);
|
||||
if (editing) {
|
||||
await odooApi.updateServiceCategory(editing.id, payload);
|
||||
toast.success('Categoría actualizada');
|
||||
} else {
|
||||
await odooApi.createServiceCategory(payload);
|
||||
toast.success('Categoría creada');
|
||||
}
|
||||
setModalOpen(false);
|
||||
await load();
|
||||
refresh();
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'No se pudo guardar la categoría');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleActiva = async (c: ServiceCategory) => {
|
||||
if (c.active) {
|
||||
if (c.is_system) {
|
||||
toast.error('Las categorías del sistema no se pueden eliminar, solo renombrar.');
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`¿Desactivar la categoría "${c.name}"?`)) return;
|
||||
try {
|
||||
const res = await odooApi.deleteServiceCategory(c.id);
|
||||
toast.success(res.message || 'Categoría desactivada');
|
||||
await load();
|
||||
refresh();
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'No se pudo desactivar la categoría');
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await odooApi.updateServiceCategory(c.id, { active: true });
|
||||
toast.success('Categoría activada');
|
||||
await load();
|
||||
refresh();
|
||||
} catch {
|
||||
toast.error('No se pudo activar la categoría');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4">
|
||||
<div>
|
||||
<h3 className="font-heading text-xl text-theme-heading">Categorías de servicios</h3>
|
||||
<p className="text-sm text-theme-muted">
|
||||
Ordenan y filtran el catálogo de servicios. Las del sistema se pueden renombrar, pero no eliminar.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={openCreate} className="sm:ml-auto">
|
||||
<Plus size={16} className="mr-2" />
|
||||
Nueva categoría
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Skeleton count={4} className="h-12 w-full" />
|
||||
) : categories.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Sin categorías"
|
||||
subtitle="Agrega la primera categoría de servicios."
|
||||
actionLabel="Nueva categoría"
|
||||
onAction={openCreate}
|
||||
icon={<Tag size={28} />}
|
||||
/>
|
||||
) : (
|
||||
<div className="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">Nombre</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Código</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden sm:table-cell">Orden</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 w-32">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{categories.map((c) => (
|
||||
<tr key={c.id} className="hover:bg-theme-bg">
|
||||
<td className="p-3 text-sm font-medium text-theme-heading">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{c.name}
|
||||
{c.is_system && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-theme-muted" title="Categoría del sistema">
|
||||
<Lock size={11} /> Sistema
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-3 text-sm text-theme-muted">{c.code}</td>
|
||||
<td className="p-3 text-sm text-theme-muted hidden sm:table-cell">{c.sequence}</td>
|
||||
<td className="p-3 text-sm">
|
||||
<span className={c.active ? 'text-emerald-600 font-medium' : 'text-theme-muted'}>
|
||||
{c.active ? 'Activa' : 'Inactiva'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(c)} title="Editar">
|
||||
<Pencil size={16} className="text-theme-muted" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleActiva(c)}
|
||||
title={c.active ? (c.is_system ? 'Del sistema: no se elimina' : 'Desactivar') : 'Activar'}
|
||||
>
|
||||
<Power size={16} className={c.active ? (c.is_system ? 'text-theme-muted opacity-40' : 'text-rose-500') : 'text-emerald-600'} />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
title={editing ? 'Editar categoría' : 'Nueva categoría'}
|
||||
maxWidth="md"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={submit} loading={submitting}>{editing ? 'Guardar cambios' : 'Crear categoría'}</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 })}
|
||||
placeholder="Se genera del nombre si se deja vacío"
|
||||
/>
|
||||
<Input
|
||||
label="Orden"
|
||||
type="number"
|
||||
value={form.sequence}
|
||||
onChange={(e) => setForm({ ...form, sequence: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoriasPanel;
|
||||
@@ -148,7 +148,7 @@ const menuLeaves = menuTree.flatMap((e) =>
|
||||
export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtitle: _subtitle }) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { user, canSee, hasRole, logout } = useAuth();
|
||||
const { user, canSee, hasRole, logout, brand } = useAuth();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const chatUnread = useChatUnread();
|
||||
|
||||
@@ -515,8 +515,8 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
|
||||
style={{ backgroundColor: 'var(--header-bg)' }}
|
||||
>
|
||||
<div className="hidden lg:flex w-[230px] shrink-0 items-center px-5">
|
||||
<span className="text-[26px] font-bold tracking-tight" style={{ color: '#333333' }}>
|
||||
SKEEN<sup className="text-xs font-semibold">®</sup>
|
||||
<span className="text-[26px] font-bold tracking-tight truncate" style={{ color: '#333333' }}>
|
||||
{brand.nombre}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-between px-3 sm:px-4 min-w-0">
|
||||
@@ -530,7 +530,7 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
|
||||
>
|
||||
<Menu size={20} />
|
||||
</Button>
|
||||
<span className="lg:hidden text-lg font-bold truncate" style={{ color: '#333333' }}>SKEEN<sup className="text-[10px]">®</sup></span>
|
||||
<span className="lg:hidden text-lg font-bold truncate" style={{ color: '#333333' }}>{brand.nombre}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 sm:space-x-3 shrink-0">
|
||||
|
||||
@@ -43,6 +43,7 @@ import GlobalSearch from './GlobalSearch';
|
||||
import ThemePicker from './ThemePicker';
|
||||
import NotificationBell from './NotificationBell';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { useGlosario, traducirMenu, traducirEtiqueta } from '../lib/glosario';
|
||||
import { useChatUnread } from '../lib/chat';
|
||||
import { useQuickLinks } from '../lib/quicklinks';
|
||||
import { odooApi } from '../services/odoo';
|
||||
@@ -53,14 +54,14 @@ interface LayoutProps {
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
const topNavItems = [
|
||||
const TOP_NAV_ITEMS = [
|
||||
{ label: 'Dashboard', icon: LayoutDashboard, href: '/' },
|
||||
{ label: 'Agenda', icon: Calendar, href: '/agenda' },
|
||||
{ label: 'Pacientes', icon: Users, href: '/pacientes' },
|
||||
{ label: 'Ventas', icon: ShoppingCart, href: '/ventas' },
|
||||
];
|
||||
|
||||
const menuSections = [
|
||||
const MENU_SECTIONS = [
|
||||
{
|
||||
label: 'Clínica',
|
||||
items: [
|
||||
@@ -113,6 +114,12 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { user, canSee, hasRole, logout, brand } = useAuth();
|
||||
const { term } = useGlosario();
|
||||
const topNavItems = useMemo(
|
||||
() => TOP_NAV_ITEMS.map((it) => ({ ...it, label: traducirEtiqueta(it.label, term) })),
|
||||
[term]
|
||||
);
|
||||
const menuSections = useMemo(() => traducirMenu(MENU_SECTIONS, term), [term]);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [expanded, setExpanded] = useState<string[]>(['Clínica']);
|
||||
const chatUnread = useChatUnread();
|
||||
@@ -122,7 +129,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
||||
menuSections
|
||||
.map((s) => ({ ...s, items: s.items.filter((it) => canSee(it.href)) }))
|
||||
.filter((s) => s.items.length > 0),
|
||||
[canSee, user]
|
||||
[canSee, user, menuSections]
|
||||
);
|
||||
|
||||
// Accesos rápidos personalizables (localStorage por usuario)
|
||||
@@ -135,7 +142,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
||||
seen.add(it.href);
|
||||
return true;
|
||||
});
|
||||
}, [canSee]);
|
||||
}, [canSee, topNavItems, menuSections]);
|
||||
const quickItems = useMemo(
|
||||
() =>
|
||||
quick.links
|
||||
@@ -148,7 +155,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
||||
return null;
|
||||
})
|
||||
.filter((it): it is (typeof menuSections)[number]['items'][number] => it !== null),
|
||||
[quick.links, canSee]
|
||||
[quick.links, canSee, menuSections]
|
||||
);
|
||||
|
||||
const initials = (user?.name || 'US')
|
||||
|
||||
@@ -4,13 +4,15 @@ import { Save, Info, Lock } from 'lucide-react';
|
||||
import { Button, Skeleton, toast } from './ui';
|
||||
import { odooApi, type FrontendRole } from '../services/odoo';
|
||||
import { ROLE_LABELS } from '../lib/auth';
|
||||
import { MENU_ITEMS } from '../lib/menu-items';
|
||||
import { MENU_ITEMS, menuItemLabel } from '../lib/menu-items';
|
||||
import { useGlosario } from '../lib/glosario';
|
||||
|
||||
const ROLES = Object.keys(ROLE_LABELS) as FrontendRole[];
|
||||
|
||||
type Matrix = Record<string, FrontendRole[]>;
|
||||
|
||||
const RolesPanel: FC = () => {
|
||||
const { term } = useGlosario();
|
||||
const [matrix, setMatrix] = useState<Matrix | null>(null);
|
||||
const [saved, setSaved] = useState<Matrix | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -101,7 +103,7 @@ const RolesPanel: FC = () => {
|
||||
{MENU_ITEMS.map((item) => (
|
||||
<tr key={item.href} className="hover:bg-theme-bg">
|
||||
<td className="px-3 py-2 font-medium text-theme-heading whitespace-nowrap sticky left-0 bg-theme-surface">
|
||||
{item.label}
|
||||
{menuItemLabel(item, term)}
|
||||
</td>
|
||||
{ROLES.map((role) => {
|
||||
const locked = item.href === '/configuracion' && role === 'admin';
|
||||
|
||||
@@ -45,6 +45,7 @@ import GlobalSearch from './GlobalSearch';
|
||||
import ThemePicker from './ThemePicker';
|
||||
import NotificationBell from './NotificationBell';
|
||||
import { useAuth, ROLE_LABELS } from '../lib/auth';
|
||||
import { useGlosario, traducirMenu } from '../lib/glosario';
|
||||
import { useChatUnread } from '../lib/chat';
|
||||
import { useQuickLinks } from '../lib/quicklinks';
|
||||
import { odooApi } from '../services/odoo';
|
||||
@@ -121,6 +122,8 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { user, canSee, hasRole, logout, brand } = useAuth();
|
||||
const { term } = useGlosario();
|
||||
const menuGroupsT = useMemo(() => traducirMenu(menuGroups, term), [term]);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const chatUnread = useChatUnread();
|
||||
// Sidebar colapsable (modo iconos). Preferencia persistente; en pantallas
|
||||
@@ -139,31 +142,31 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
||||
|
||||
const visibleGroups = useMemo(
|
||||
() =>
|
||||
menuGroups
|
||||
menuGroupsT
|
||||
.map((g) => ({ ...g, items: g.items.filter((it) => canSee(it.href)) }))
|
||||
.filter((g) => g.items.length > 0),
|
||||
[canSee, user]
|
||||
[canSee, user, menuGroupsT]
|
||||
);
|
||||
|
||||
// Accesos rápidos personalizables (localStorage por usuario)
|
||||
const quick = useQuickLinks();
|
||||
const dockItems = useMemo(
|
||||
() => menuGroups.flatMap((g) => g.items).filter((it) => canSee(it.href)),
|
||||
[canSee]
|
||||
() => menuGroupsT.flatMap((g) => g.items).filter((it) => canSee(it.href)),
|
||||
[canSee, menuGroupsT]
|
||||
);
|
||||
const quickItems = useMemo(
|
||||
() =>
|
||||
quick.links
|
||||
.filter((href) => canSee(href))
|
||||
.map((href) => {
|
||||
for (const g of menuGroups) {
|
||||
for (const g of menuGroupsT) {
|
||||
const found = g.items.find((it) => it.href === href);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((it): it is (typeof menuGroups)[number]['items'][number] => it !== null),
|
||||
[quick.links, canSee]
|
||||
[quick.links, canSee, menuGroupsT]
|
||||
);
|
||||
|
||||
const initials = (user?.name || 'US')
|
||||
|
||||
251
frontend/src/components/SucursalesPanel.tsx
Normal file
251
frontend/src/components/SucursalesPanel.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import type { FC } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Plus, Pencil, Power, Star, MapPin } from 'lucide-react';
|
||||
import { Button, Input, Modal, TextArea, EmptyState, Skeleton, toast } from './ui';
|
||||
import { odooApi, type Sucursal } from '../services/odoo';
|
||||
import { useSucursales } from '../lib/sucursales';
|
||||
|
||||
interface FormState {
|
||||
name: string;
|
||||
code: string;
|
||||
sequence: string;
|
||||
address: string;
|
||||
phone: string;
|
||||
notes: string;
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
const emptyForm: FormState = { name: '', code: '', sequence: '10', address: '', phone: '', notes: '', is_default: false };
|
||||
|
||||
/** Configuración › Sucursales: alta, edición y activación de sucursales. */
|
||||
const SucursalesPanel: FC = () => {
|
||||
const { refresh } = useSucursales();
|
||||
const [sucursales, setSucursales] = useState<Sucursal[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Sucursal | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await odooApi.getSucursales({ includeInactive: true });
|
||||
if (res.status === 'success') setSucursales(res.sucursales);
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar sucursales');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setForm(emptyForm);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (s: Sucursal) => {
|
||||
setEditing(s);
|
||||
setForm({
|
||||
name: s.name,
|
||||
code: s.code,
|
||||
sequence: String(s.sequence),
|
||||
address: s.address || '',
|
||||
phone: s.phone || '',
|
||||
notes: s.notes || '',
|
||||
is_default: s.is_default,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.name.trim()) {
|
||||
toast.error('El nombre es obligatorio');
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
code: form.code.trim() || undefined,
|
||||
sequence: parseInt(form.sequence, 10) || 10,
|
||||
address: form.address,
|
||||
phone: form.phone,
|
||||
notes: form.notes,
|
||||
is_default: form.is_default,
|
||||
};
|
||||
try {
|
||||
setSubmitting(true);
|
||||
if (editing) {
|
||||
await odooApi.updateSucursal(editing.id, payload);
|
||||
toast.success('Sucursal actualizada');
|
||||
} else {
|
||||
await odooApi.createSucursal(payload);
|
||||
toast.success('Sucursal creada');
|
||||
}
|
||||
setModalOpen(false);
|
||||
await load();
|
||||
refresh();
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'No se pudo guardar la sucursal');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleActiva = async (s: Sucursal) => {
|
||||
if (s.active) {
|
||||
if (!window.confirm(`¿Desactivar la sucursal "${s.name}"? Las citas ya registradas no se pierden.`)) return;
|
||||
try {
|
||||
const res = await odooApi.deleteSucursal(s.id);
|
||||
toast.success(res.message || 'Sucursal desactivada');
|
||||
await load();
|
||||
refresh();
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'No se pudo desactivar la sucursal');
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await odooApi.updateSucursal(s.id, { active: true });
|
||||
toast.success('Sucursal activada');
|
||||
await load();
|
||||
refresh();
|
||||
} catch {
|
||||
toast.error('No se pudo activar la sucursal');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4">
|
||||
<div>
|
||||
<h3 className="font-heading text-xl text-theme-heading">Sucursales</h3>
|
||||
<p className="text-sm text-theme-muted">
|
||||
Se usan al agendar citas y para etiquetar el lugar de atención. Una sucursal se desactiva, nunca se borra.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={openCreate} className="sm:ml-auto">
|
||||
<Plus size={16} className="mr-2" />
|
||||
Nueva sucursal
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Skeleton count={3} className="h-12 w-full" />
|
||||
) : sucursales.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Sin sucursales"
|
||||
subtitle="Agrega la primera sucursal de la clínica."
|
||||
actionLabel="Nueva sucursal"
|
||||
onAction={openCreate}
|
||||
icon={<MapPin size={28} />}
|
||||
/>
|
||||
) : (
|
||||
<div className="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">Nombre</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Código</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden sm:table-cell">Orden</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 w-32">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{sucursales.map((s) => (
|
||||
<tr key={s.id} className="hover:bg-theme-bg">
|
||||
<td className="p-3 text-sm font-medium text-theme-heading">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{s.name}
|
||||
{s.is_default && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-amber-600" title="Sucursal predeterminada">
|
||||
<Star size={11} fill="currentColor" /> Predeterminada
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-3 text-sm text-theme-muted">{s.code}</td>
|
||||
<td className="p-3 text-sm text-theme-muted hidden sm:table-cell">{s.sequence}</td>
|
||||
<td className="p-3 text-sm">
|
||||
<span className={s.active ? 'text-emerald-600 font-medium' : 'text-theme-muted'}>
|
||||
{s.active ? 'Activa' : 'Inactiva'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(s)} title="Editar">
|
||||
<Pencil size={16} className="text-theme-muted" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleActiva(s)}
|
||||
title={s.active ? 'Desactivar' : 'Activar'}
|
||||
>
|
||||
<Power size={16} className={s.active ? 'text-rose-500' : 'text-emerald-600'} />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
title={editing ? 'Editar sucursal' : 'Nueva sucursal'}
|
||||
maxWidth="lg"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={submit} loading={submitting}>{editing ? 'Guardar cambios' : 'Crear sucursal'}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input label="Nombre *" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||
<Input
|
||||
label="Código"
|
||||
value={form.code}
|
||||
onChange={(e) => setForm({ ...form, code: e.target.value })}
|
||||
placeholder="Se genera del nombre si se deja vacío"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Orden"
|
||||
type="number"
|
||||
value={form.sequence}
|
||||
onChange={(e) => setForm({ ...form, sequence: e.target.value })}
|
||||
/>
|
||||
<Input label="Teléfono" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
|
||||
</div>
|
||||
<Input label="Dirección" value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
|
||||
<TextArea label="Notas" value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
|
||||
<label className="inline-flex items-center gap-2 text-sm text-theme-heading cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.is_default}
|
||||
onChange={(e) => setForm({ ...form, is_default: e.target.checked })}
|
||||
className="rounded border-theme-border-strong"
|
||||
/>
|
||||
Sucursal predeterminada
|
||||
</label>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SucursalesPanel;
|
||||
235
frontend/src/components/TerminologiaPanel.tsx
Normal file
235
frontend/src/components/TerminologiaPanel.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Save, Languages, RotateCcw } from 'lucide-react';
|
||||
import { Button, Input, Select, Skeleton, toast } from './ui';
|
||||
import { odooApi, GLOSSARY_KEYS, type GlossaryKey, type GlossaryPack, type GlossaryTerms } from '../services/odoo';
|
||||
import { GLOSARIO_CLINICA, useGlosario } from '../lib/glosario';
|
||||
|
||||
// Términos base de cada pack. Espejo de skeen_citas/glosario.py: el backend no
|
||||
// expone un endpoint para "ver un pack sin guardarlo", así que el selector usa
|
||||
// esta copia para precargar el formulario antes de guardar.
|
||||
const PACK_TERMS: Record<string, GlossaryTerms> = {
|
||||
clinica: GLOSARIO_CLINICA,
|
||||
spa: {
|
||||
patient_singular: 'Cliente', patient_plural: 'Clientes',
|
||||
appointment_singular: 'Reserva', appointment_plural: 'Reservas',
|
||||
visit_singular: 'Sesión', visit_plural: 'Sesiones',
|
||||
record_singular: 'Ficha', record_plural: 'Fichas',
|
||||
doctor_singular: 'Terapeuta', doctor_plural: 'Terapeutas',
|
||||
staff_singular: 'Personal', staff_plural: 'Personal',
|
||||
branch_singular: 'Sucursal', branch_plural: 'Sucursales',
|
||||
room_singular: 'Sala', room_plural: 'Salas',
|
||||
service_singular: 'Servicio', service_plural: 'Servicios',
|
||||
package_singular: 'Paquete', package_plural: 'Paquetes',
|
||||
prescription_singular: 'Recomendación', prescription_plural: 'Recomendaciones',
|
||||
treatment_singular: 'Tratamiento', treatment_plural: 'Tratamientos',
|
||||
},
|
||||
estetica: {
|
||||
patient_singular: 'Cliente', patient_plural: 'Clientes',
|
||||
appointment_singular: 'Cita', appointment_plural: 'Citas',
|
||||
visit_singular: 'Sesión', visit_plural: 'Sesiones',
|
||||
record_singular: 'Ficha', record_plural: 'Fichas',
|
||||
doctor_singular: 'Especialista', doctor_plural: 'Especialistas',
|
||||
staff_singular: 'Personal', staff_plural: 'Personal',
|
||||
branch_singular: 'Sucursal', branch_plural: 'Sucursales',
|
||||
room_singular: 'Cabina', room_plural: 'Cabinas',
|
||||
service_singular: 'Servicio', service_plural: 'Servicios',
|
||||
package_singular: 'Paquete', package_plural: 'Paquetes',
|
||||
prescription_singular: 'Receta', prescription_plural: 'Recetas',
|
||||
treatment_singular: 'Tratamiento', treatment_plural: 'Tratamientos',
|
||||
},
|
||||
dental: {
|
||||
patient_singular: 'Paciente', patient_plural: 'Pacientes',
|
||||
appointment_singular: 'Cita', appointment_plural: 'Citas',
|
||||
visit_singular: 'Consulta', visit_plural: 'Consultas',
|
||||
record_singular: 'Expediente', record_plural: 'Expedientes',
|
||||
doctor_singular: 'Odontólogo', doctor_plural: 'Odontólogos',
|
||||
staff_singular: 'Personal', staff_plural: 'Personal',
|
||||
branch_singular: 'Sucursal', branch_plural: 'Sucursales',
|
||||
room_singular: 'Consultorio', room_plural: 'Consultorios',
|
||||
service_singular: 'Tratamiento', service_plural: 'Tratamientos',
|
||||
package_singular: 'Paquete', package_plural: 'Paquetes',
|
||||
prescription_singular: 'Receta', prescription_plural: 'Recetas',
|
||||
treatment_singular: 'Procedimiento', treatment_plural: 'Procedimientos',
|
||||
},
|
||||
};
|
||||
PACK_TERMS.custom = GLOSARIO_CLINICA;
|
||||
|
||||
const KEY_LABELS: Record<GlossaryKey, string> = {
|
||||
patient_singular: 'Paciente (singular)',
|
||||
patient_plural: 'Paciente (plural)',
|
||||
appointment_singular: 'Cita (singular)',
|
||||
appointment_plural: 'Cita (plural)',
|
||||
visit_singular: 'Visita (singular)',
|
||||
visit_plural: 'Visita (plural)',
|
||||
record_singular: 'Expediente (singular)',
|
||||
record_plural: 'Expediente (plural)',
|
||||
doctor_singular: 'Médico (singular)',
|
||||
doctor_plural: 'Médico (plural)',
|
||||
staff_singular: 'Personal (singular)',
|
||||
staff_plural: 'Personal (plural)',
|
||||
branch_singular: 'Sucursal (singular)',
|
||||
branch_plural: 'Sucursal (plural)',
|
||||
room_singular: 'Sala (singular)',
|
||||
room_plural: 'Sala (plural)',
|
||||
service_singular: 'Servicio (singular)',
|
||||
service_plural: 'Servicio (plural)',
|
||||
package_singular: 'Paquete (singular)',
|
||||
package_plural: 'Paquete (plural)',
|
||||
prescription_singular: 'Receta (singular)',
|
||||
prescription_plural: 'Receta (plural)',
|
||||
treatment_singular: 'Tratamiento (singular)',
|
||||
treatment_plural: 'Tratamiento (plural)',
|
||||
};
|
||||
|
||||
const PACK_FALLBACK: GlossaryPack[] = [
|
||||
{ code: 'clinica', name: 'Clínica' },
|
||||
{ code: 'spa', name: 'Spa / Wellness' },
|
||||
{ code: 'estetica', name: 'Estética' },
|
||||
{ code: 'dental', name: 'Dental' },
|
||||
{ code: 'custom', name: 'Custom' },
|
||||
];
|
||||
|
||||
/** Configuración › Terminología: pack + overrides de los 24 términos. */
|
||||
const TerminologiaPanel: FC = () => {
|
||||
const glosario = useGlosario();
|
||||
const [pack, setPack] = useState(glosario.pack || 'clinica');
|
||||
const [packs, setPacks] = useState<GlossaryPack[]>(glosario.packs.length ? glosario.packs : PACK_FALLBACK);
|
||||
const [terms, setTerms] = useState<GlossaryTerms>({ ...GLOSARIO_CLINICA });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const base = useMemo(() => PACK_TERMS[pack] || GLOSARIO_CLINICA, [pack]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const [g, p] = await Promise.all([odooApi.getGlosario(), odooApi.getGlosarioPacks()]);
|
||||
if (!alive) return;
|
||||
if (g.status === 'success') {
|
||||
setPack(g.pack || 'clinica');
|
||||
const next: GlossaryTerms = { ...GLOSARIO_CLINICA };
|
||||
for (const k of GLOSSARY_KEYS) if (g.terms?.[k]) next[k] = g.terms[k];
|
||||
setTerms(next);
|
||||
}
|
||||
if (p.status === 'success' && p.packs?.length) setPacks(p.packs);
|
||||
} catch {
|
||||
toast.error('No se pudo cargar la terminología vigente');
|
||||
} finally {
|
||||
if (alive) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
// Al cambiar de pack se precargan sus términos base para que el usuario vea
|
||||
// qué va a guardar (los overrides del pack anterior se descartan).
|
||||
const cambiarPack = (code: string) => {
|
||||
setPack(code);
|
||||
setTerms({ ...(PACK_TERMS[code] || GLOSARIO_CLINICA) });
|
||||
};
|
||||
|
||||
const overrides = useMemo(() => {
|
||||
const out: Partial<GlossaryTerms> = {};
|
||||
for (const k of GLOSSARY_KEYS) {
|
||||
const v = (terms[k] || '').trim();
|
||||
if (v && v !== base[k]) out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}, [terms, base]);
|
||||
|
||||
const efectivos = useMemo(() => {
|
||||
const out: GlossaryTerms = { ...base };
|
||||
for (const k of GLOSSARY_KEYS) {
|
||||
const v = (terms[k] || '').trim();
|
||||
if (v) out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}, [terms, base]);
|
||||
|
||||
const guardar = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
const res = await odooApi.putGlosario({ pack, overrides, motivo: 'Terminología desde Configuración' });
|
||||
if (res.status === 'success') {
|
||||
glosario.setTerms(res.pack, res.terms);
|
||||
const next: GlossaryTerms = { ...GLOSARIO_CLINICA };
|
||||
for (const k of GLOSSARY_KEYS) if (res.terms?.[k]) next[k] = res.terms[k];
|
||||
setTerms(next);
|
||||
toast.success('Terminología actualizada');
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'No se pudo guardar la terminología');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const packLabel = packs.find((p) => p.code === pack)?.name || pack;
|
||||
|
||||
if (loading) return <Skeleton count={8} className="h-12 w-full" />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col sm:flex-row sm:items-start gap-3 mb-4">
|
||||
<div>
|
||||
<h3 className="font-heading text-xl text-theme-heading flex items-center gap-2">
|
||||
<Languages size={20} /> Terminología de la clínica
|
||||
</h3>
|
||||
<p className="text-sm text-theme-muted">
|
||||
Cambia las palabras que ve el personal (Paciente, Cita, Sucursal, Cabina...). Pack activo:
|
||||
<span className="font-semibold text-theme-heading"> {packLabel}</span>.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={guardar} loading={saving} className="sm:ml-auto">
|
||||
<Save size={16} className="mr-2" /> Guardar terminología
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
|
||||
<Select
|
||||
label="Pack de terminología"
|
||||
options={packs.map((p) => ({ value: p.code, label: p.name }))}
|
||||
value={pack}
|
||||
onChange={(e) => cambiarPack(e.target.value)}
|
||||
/>
|
||||
<div className="flex items-end">
|
||||
<Button variant="outline" onClick={() => cambiarPack(pack)} title="Restaurar los términos base del pack">
|
||||
<RotateCcw size={15} className="mr-2" /> Restaurar pack
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-4 gap-y-3">
|
||||
{GLOSSARY_KEYS.map((k) => (
|
||||
<Input
|
||||
key={k}
|
||||
label={KEY_LABELS[k]}
|
||||
value={terms[k] || ''}
|
||||
placeholder={base[k]}
|
||||
onChange={(e) => setTerms((prev) => ({ ...prev, [k]: e.target.value }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-medium text-theme-heading mb-2">Términos efectivos (vista previa)</h4>
|
||||
<div className="rounded-xl border border-theme-border bg-theme-bg p-3 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-x-4 gap-y-1.5 text-xs">
|
||||
{GLOSSARY_KEYS.map((k) => (
|
||||
<div key={k} className="flex items-center justify-between gap-2">
|
||||
<span className="text-theme-muted truncate" title={KEY_LABELS[k]}>{KEY_LABELS[k]}</span>
|
||||
<span className="font-medium text-theme-heading">{efectivos[k]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-theme-muted mt-2">
|
||||
Los campos vacíos toman el valor del pack. Al guardar, solo se envían como ajuste los términos que cambian respecto al pack.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TerminologiaPanel;
|
||||
@@ -4,7 +4,7 @@ import { Palette, Check } from 'lucide-react';
|
||||
import { useTheme, type Theme } from '../lib/theme';
|
||||
|
||||
const THEME_LABELS: Record<Theme, string> = {
|
||||
skeen: 'SKEEN',
|
||||
skeen: 'Original',
|
||||
homenest: 'HomeNest',
|
||||
clasico: 'Clásico',
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '../lib/ticketConfig';
|
||||
import { odooApi } from '../services/odoo';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { useSucursales } from '../lib/sucursales';
|
||||
|
||||
// Editor del diseño del ticket (Configuración › Tickets).
|
||||
//
|
||||
@@ -136,6 +137,9 @@ const CLINICA_CAMPOS: { key: string; label: string; placeholder?: string }[] = [
|
||||
export const TicketDesigner: FC = () => {
|
||||
const config = useTicketConfig();
|
||||
const { user } = useAuth();
|
||||
const { sucursales } = useSucursales();
|
||||
// Nombre de la sucursal predeterminada para la vista previa (antes fijo).
|
||||
const sucursalMuestra = (sucursales.find((s) => s.is_default) || sucursales[0])?.name || 'Rosarito Centro';
|
||||
// Restaurar el formato original queda solo para administración del sistema;
|
||||
// gerencia puede editar y guardar su propio diseño.
|
||||
const esAdmin = user?.role === 'admin';
|
||||
@@ -504,7 +508,7 @@ export const TicketDesigner: FC = () => {
|
||||
<div className="xl:sticky xl:top-4">
|
||||
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Vista previa</p>
|
||||
<div className="rounded-2xl bg-theme-bg border border-theme-border p-4 overflow-auto max-h-[calc(100vh-8rem)]">
|
||||
<TicketDoc data={MUESTRA} config={previewConfig} preview />
|
||||
<TicketDoc data={{ ...MUESTRA, sucursal: sucursalMuestra }} config={previewConfig} preview />
|
||||
</div>
|
||||
<p className="text-[11px] text-theme-muted mt-2">
|
||||
La vista previa usa una venta de ejemplo con todos los rubros. Los datos reales se toman
|
||||
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
} from './ui';
|
||||
import { odooApi, type FrontendUser, type FrontendRole, type AgendaPerm, AGENDA_PERM_ITEMS } from '../services/odoo';
|
||||
import { ROLE_LABELS, useAuth } from '../lib/auth';
|
||||
import { MENU_ITEMS } from '../lib/menu-items';
|
||||
import { MENU_ITEMS, menuItemLabel } from '../lib/menu-items';
|
||||
import { useGlosario } from '../lib/glosario';
|
||||
|
||||
const roleOptions: { value: FrontendRole; label: string }[] = (
|
||||
Object.keys(ROLE_LABELS) as FrontendRole[]
|
||||
@@ -35,6 +36,7 @@ const roleBadge = (role: FrontendRole) => {
|
||||
|
||||
const UsuariosPanel: FC = () => {
|
||||
const { user: currentUser } = useAuth();
|
||||
const { term } = useGlosario();
|
||||
const [users, setUsers] = useState<FrontendUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
@@ -382,7 +384,7 @@ const UsuariosPanel: FC = () => {
|
||||
onChange={() => togglePerm(item.href)}
|
||||
className="rounded border-theme-border-strong"
|
||||
/>
|
||||
{item.label}
|
||||
{menuItemLabel(item, term)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { FC } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Check, ChevronLeft, ChevronRight, History, UserCheck, UserX, XCircle } from 'lucide-react';
|
||||
import type { Appointment, AppointmentStateLog } from '../../services/odoo';
|
||||
import { useGlosario } from '../../lib/glosario';
|
||||
|
||||
/** Pasos del checklist de atención (espejo de STATE_FLOW en skeen.cita) */
|
||||
export const CHECKLIST_STEPS = [
|
||||
@@ -37,17 +38,22 @@ interface Props {
|
||||
* siguen sincronizadas con phone_confirmed / arrived_at desde el backend.
|
||||
*/
|
||||
export const EstadoChecklist: FC<Props> = ({ apt, updating, history, onChange, compact, puedeCancelar = true, puedeNoShow = true }) => {
|
||||
const { term } = useGlosario();
|
||||
const [confirmarSalida, setConfirmarSalida] = useState<'cancel' | 'no_show' | null>(null);
|
||||
const idx = stepIndex(apt.state);
|
||||
const fueraDelFlujo = idx < 0;
|
||||
|
||||
// El paso "En cabina" usa el término de sala configurable (Cabina/Sala/Consultorio).
|
||||
const etiquetaPaso = (step: typeof CHECKLIST_STEPS[number]) =>
|
||||
step.state === 'arrived' ? `En ${term('room_singular').toLowerCase()}` : step.label;
|
||||
|
||||
useEffect(() => { setConfirmarSalida(null); }, [apt.id, apt.state]);
|
||||
|
||||
const irA = (state: string) => {
|
||||
if (state === apt.state) return;
|
||||
const destino = stepIndex(state);
|
||||
if (destino >= 0 && idx >= 0 && destino < idx) {
|
||||
if (!window.confirm(`¿Regresar la cita de "${CHECKLIST_STEPS[idx].label}" a "${CHECKLIST_STEPS[destino].label}"?`)) return;
|
||||
if (!window.confirm(`¿Regresar la cita de "${etiquetaPaso(CHECKLIST_STEPS[idx])}" a "${etiquetaPaso(CHECKLIST_STEPS[destino])}"?`)) return;
|
||||
}
|
||||
onChange('set_state', state);
|
||||
};
|
||||
@@ -90,7 +96,7 @@ export const EstadoChecklist: FC<Props> = ({ apt, updating, history, onChange, c
|
||||
}`}
|
||||
>
|
||||
{hecho ? <Check size={12} /> : <span className="text-[10px]">{i + 1}</span>}
|
||||
{step.label}
|
||||
{etiquetaPaso(step)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
@@ -116,7 +122,7 @@ export const EstadoChecklist: FC<Props> = ({ apt, updating, history, onChange, c
|
||||
className="inline-flex items-center gap-1 rounded-xl bg-theme-accent px-3 py-1.5 text-xs font-medium text-theme-inverse disabled:opacity-40"
|
||||
title="Avanzar un paso"
|
||||
>
|
||||
{idx >= 0 && idx < CHECKLIST_STEPS.length - 1 ? `Marcar ${CHECKLIST_STEPS[idx + 1].label}` : 'Siguiente'}
|
||||
{idx >= 0 && idx < CHECKLIST_STEPS.length - 1 ? `Marcar ${etiquetaPaso(CHECKLIST_STEPS[idx + 1])}` : 'Siguiente'}
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
<span className="ml-auto flex items-center gap-2">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { CalendarPlus, Clock3, MessageCircle, Plus, Repeat, Trash2, Users } from
|
||||
import { Badge, Button, Input, Modal, Select, TextArea, toast } from '../ui';
|
||||
import PatientCombobox from '../PatientCombobox';
|
||||
import { odooApi, type Doctor, type ListaEspera, type Patient, type Service } from '../../services/odoo';
|
||||
import { useGlosario } from '../../lib/glosario';
|
||||
|
||||
const PRIORIDADES = [
|
||||
{ value: 'alta', label: 'Alta' },
|
||||
@@ -50,6 +51,9 @@ export const ListaEsperaPanel: FC<PanelProps> = ({
|
||||
entries, loading, loadingAgendaId, onAgendar, onQuitar, onAdd, onRefresh, puedeGestionar = true, onChangeSala,
|
||||
}) => {
|
||||
const [abierto, setAbierto] = useState(true);
|
||||
const { term } = useGlosario();
|
||||
// Etiqueta de la sala "cabina" toma el término configurable (Cabina/Sala/Consultorio).
|
||||
const salas = SALAS.map((s) => (s.key === 'cabina' ? { ...s, label: term('room_singular') } : s));
|
||||
// Filtro por sala: el toggle de arriba decide qué pacientes se muestran.
|
||||
const [salaFiltro, setSalaFiltro] = useState<'todas' | 'consultoria' | 'cabina'>('todas');
|
||||
const visibles = salaFiltro === 'todas' ? entries : entries.filter((e) => (e.sala || '') === salaFiltro);
|
||||
@@ -72,7 +76,7 @@ export const ListaEsperaPanel: FC<PanelProps> = ({
|
||||
</button>
|
||||
{/* Toggle de sala: Consultoría / Cabina (filtra y muestra la etiqueta) */}
|
||||
<div className="flex items-center gap-1 rounded-full border border-violet-300 bg-theme-surface p-0.5">
|
||||
{([{ key: 'todas', label: 'Todas' }, ...SALAS] as const).map((s) => (
|
||||
{[{ key: 'todas', label: 'Todas' }, ...salas].map((s) => (
|
||||
<button
|
||||
key={s.key}
|
||||
type="button"
|
||||
@@ -110,7 +114,7 @@ export const ListaEsperaPanel: FC<PanelProps> = ({
|
||||
? 'Cargando...'
|
||||
: salaFiltro === 'todas'
|
||||
? 'Sin pacientes en espera. Cuando no haya hueco, agrégalos aquí para avisarles al liberarse una cita.'
|
||||
: `Sin pacientes esperando en ${SALAS.find((s) => s.key === salaFiltro)?.label}.`}
|
||||
: `Sin pacientes esperando en ${salas.find((s) => s.key === salaFiltro)?.label}.`}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -119,7 +123,7 @@ export const ListaEsperaPanel: FC<PanelProps> = ({
|
||||
e.phone,
|
||||
`Hola ${e.patient}, se liberó un espacio en la clínica${e.doctor ? ` con ${e.doctor}` : ''}. ¿Te gustaría tomarlo?`,
|
||||
);
|
||||
const salaActual = SALAS.find((s) => s.key === (e.sala || ''));
|
||||
const salaActual = salas.find((s) => s.key === (e.sala || ''));
|
||||
return (
|
||||
<div
|
||||
key={e.id}
|
||||
@@ -145,7 +149,7 @@ export const ListaEsperaPanel: FC<PanelProps> = ({
|
||||
salaActual ? salaActual.chip : 'bg-theme-bg text-theme-muted border-theme-border'
|
||||
} ${puedeGestionar ? 'hover:brightness-95 cursor-pointer' : 'cursor-default'}`}
|
||||
title={puedeGestionar
|
||||
? `Sala: ${salaActual?.label || 'Sin asignar'} — click para pasar a ${SALAS.find((s) => s.key === salaSiguiente(e.sala || ''))?.label}`
|
||||
? `Sala: ${salaActual?.label || 'Sin asignar'} — click para pasar a ${salas.find((s) => s.key === salaSiguiente(e.sala || ''))?.label}`
|
||||
: `Sala: ${salaActual?.label || 'Sin asignar'}`}
|
||||
>
|
||||
{salaActual?.label || 'Sin sala'}
|
||||
@@ -224,6 +228,7 @@ interface ModalProps {
|
||||
export const ListaEsperaModal: FC<ModalProps> = ({
|
||||
isOpen, onClose, doctors, services, patient, defaultDoctorId, defaultServiceId, isMedico, onCreated,
|
||||
}) => {
|
||||
const { term } = useGlosario();
|
||||
const [patientId, setPatientId] = useState('');
|
||||
const [patientName, setPatientName] = useState('');
|
||||
const [doctorId, setDoctorId] = useState(defaultDoctorId || '');
|
||||
@@ -350,7 +355,7 @@ export const ListaEsperaModal: FC<ModalProps> = ({
|
||||
options={[
|
||||
{ value: '', label: 'Sin asignar' },
|
||||
{ value: 'consultoria', label: 'Consultoría' },
|
||||
{ value: 'cabina', label: 'Cabina' },
|
||||
{ value: 'cabina', label: term('room_singular') },
|
||||
]}
|
||||
value={sala}
|
||||
onChange={(e) => setSala(e.target.value)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { odooApi, type FrontendUser, type FrontendRole, type AgendaPerm, type ClinicBrand, BRAND_DEFAULT } from '../services/odoo';
|
||||
import { applyBrandColors } from './brand-colors';
|
||||
|
||||
const TOKEN_KEY = 'skeen_token';
|
||||
const USER_KEY = 'skeen_user';
|
||||
@@ -120,6 +121,11 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Colores de marca en runtime: solo pisan el tema si la clínica los configuró.
|
||||
useEffect(() => {
|
||||
applyBrandColors(brand);
|
||||
}, [brand]);
|
||||
|
||||
const refreshBrand = useCallback(async () => {
|
||||
try {
|
||||
const res = await odooApi.getBrand();
|
||||
|
||||
67
frontend/src/lib/brand-colors.ts
Normal file
67
frontend/src/lib/brand-colors.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { ClinicBrand } from '../services/odoo';
|
||||
|
||||
// Colores de marca en runtime.
|
||||
//
|
||||
// Los temas (index.css) definen sus colores en variables CSS sobre el elemento
|
||||
// raíz. Si la clínica configuró colores de marca, se pisan como estilo en línea
|
||||
// de <html>, que tiene más prioridad que el selector [data-theme]. Si están
|
||||
// vacíos se quitan, de modo que la instancia sin configurar se ve exactamente
|
||||
// igual que siempre en los tres temas.
|
||||
|
||||
const HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
||||
|
||||
// Variables que controla el color primario de la marca.
|
||||
const PROPS_PRIMARIO = ['--color-coral', '--color-coral-hover', '--accent', '--accent-hover', '--accent-bg'];
|
||||
const PROPS_ACENTO = ['--color-lilac'];
|
||||
|
||||
const normalizeHex = (hex: string): string => {
|
||||
let h = hex.trim();
|
||||
if (h.length === 4) {
|
||||
h = `#${h[1]}${h[1]}${h[2]}${h[2]}${h[3]}${h[3]}`;
|
||||
}
|
||||
return h.toLowerCase();
|
||||
};
|
||||
|
||||
const hexToRgb = (hex: string): [number, number, number] => {
|
||||
const h = normalizeHex(hex);
|
||||
return [
|
||||
parseInt(h.slice(1, 3), 16),
|
||||
parseInt(h.slice(3, 5), 16),
|
||||
parseInt(h.slice(5, 7), 16),
|
||||
];
|
||||
};
|
||||
|
||||
/** Oscurece un color hex un porcentaje (0-1) para derivar el estado hover. */
|
||||
const darken = (hex: string, amount: number): string => {
|
||||
const [r, g, b] = hexToRgb(hex);
|
||||
const f = (v: number) => Math.max(0, Math.round(v * (1 - amount)));
|
||||
return `#${[f(r), f(g), f(b)].map((v) => v.toString(16).padStart(2, '0')).join('')}`;
|
||||
};
|
||||
|
||||
const rgba = (hex: string, alpha: number): string => {
|
||||
const [r, g, b] = hexToRgb(hex);
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
};
|
||||
|
||||
/** Aplica (o limpia) los colores de marca sobre el tema activo. */
|
||||
export const applyBrandColors = (brand: ClinicBrand): void => {
|
||||
if (typeof document === 'undefined') return;
|
||||
const root = document.documentElement;
|
||||
for (const p of [...PROPS_PRIMARIO, ...PROPS_ACENTO]) root.style.removeProperty(p);
|
||||
|
||||
const colores = brand.colores || {};
|
||||
const primario = colores.primario && HEX_RE.test(colores.primario) ? normalizeHex(colores.primario) : '';
|
||||
const acento = colores.acento && HEX_RE.test(colores.acento) ? normalizeHex(colores.acento) : '';
|
||||
|
||||
if (primario) {
|
||||
const hover = darken(primario, 0.12);
|
||||
root.style.setProperty('--color-coral', primario);
|
||||
root.style.setProperty('--color-coral-hover', hover);
|
||||
root.style.setProperty('--accent', primario);
|
||||
root.style.setProperty('--accent-hover', hover);
|
||||
root.style.setProperty('--accent-bg', rgba(primario, 0.12));
|
||||
}
|
||||
if (acento) {
|
||||
root.style.setProperty('--color-lilac', acento);
|
||||
}
|
||||
};
|
||||
174
frontend/src/lib/glosario.tsx
Normal file
174
frontend/src/lib/glosario.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { odooApi, GLOSSARY_KEYS, type GlossaryKey, type GlossaryPack, type GlossaryTerms } from '../services/odoo';
|
||||
import { useAuth } from './auth';
|
||||
|
||||
// Terminología por defecto = pack 'clinica' del backend, que coincide con los
|
||||
// literales que usa hoy producción. Es también el respaldo si el endpoint falla,
|
||||
// para que la interfaz nunca se rompa por un glosario no disponible.
|
||||
export const GLOSARIO_CLINICA: GlossaryTerms = {
|
||||
patient_singular: 'Paciente',
|
||||
patient_plural: 'Pacientes',
|
||||
appointment_singular: 'Cita',
|
||||
appointment_plural: 'Citas',
|
||||
visit_singular: 'Visita',
|
||||
visit_plural: 'Visitas',
|
||||
record_singular: 'Expediente',
|
||||
record_plural: 'Expedientes',
|
||||
doctor_singular: 'Médico',
|
||||
doctor_plural: 'Médicos',
|
||||
staff_singular: 'Personal',
|
||||
staff_plural: 'Personal',
|
||||
branch_singular: 'Sucursal',
|
||||
branch_plural: 'Sucursales',
|
||||
room_singular: 'Cabina',
|
||||
room_plural: 'Cabinas',
|
||||
service_singular: 'Servicio',
|
||||
service_plural: 'Servicios',
|
||||
package_singular: 'Paquete',
|
||||
package_plural: 'Paquetes',
|
||||
prescription_singular: 'Receta',
|
||||
prescription_plural: 'Recetas',
|
||||
treatment_singular: 'Tratamiento',
|
||||
treatment_plural: 'Tratamientos',
|
||||
};
|
||||
|
||||
const GLOSARIO_CACHE_KEY = 'skeen_glosario';
|
||||
|
||||
interface GlosarioContextValue {
|
||||
terms: GlossaryTerms;
|
||||
pack: string;
|
||||
packs: GlossaryPack[];
|
||||
loading: boolean;
|
||||
/** Traduce una clave del glosario; cae al valor del pack clínica y luego a la clave. */
|
||||
term: (key: GlossaryKey) => string;
|
||||
/** Reemplaza los términos en memoria tras un PUT en Configuración. */
|
||||
setTerms: (pack: string, terms: GlossaryTerms) => void;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
const GlosarioContext = createContext<GlosarioContextValue | undefined>(undefined);
|
||||
|
||||
const leerCache = (): { pack: string; terms: GlossaryTerms } => {
|
||||
try {
|
||||
const raw = localStorage.getItem(GLOSARIO_CACHE_KEY);
|
||||
if (!raw) return { pack: 'clinica', terms: GLOSARIO_CLINICA };
|
||||
const parsed = JSON.parse(raw) as { pack?: string; terms?: Partial<GlossaryTerms> };
|
||||
const terms: GlossaryTerms = { ...GLOSARIO_CLINICA };
|
||||
for (const k of GLOSSARY_KEYS) {
|
||||
const v = parsed.terms?.[k];
|
||||
if (v) terms[k] = v;
|
||||
}
|
||||
return { pack: parsed.pack || 'clinica', terms };
|
||||
} catch {
|
||||
return { pack: 'clinica', terms: GLOSARIO_CLINICA };
|
||||
}
|
||||
};
|
||||
|
||||
export const GlosarioProvider: FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const { token } = useAuth();
|
||||
const cached = useMemo(leerCache, []);
|
||||
const [terms, setTermsState] = useState<GlossaryTerms>(cached.terms);
|
||||
const [pack, setPack] = useState<string>(cached.pack);
|
||||
const [packs, setPacks] = useState<GlossaryPack[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
const refresh = useCallback(() => setAttempt((n) => n + 1), []);
|
||||
|
||||
const setTerms = useCallback((nextPack: string, nextTerms: GlossaryTerms) => {
|
||||
setPack(nextPack);
|
||||
setTermsState(nextTerms);
|
||||
try {
|
||||
localStorage.setItem(GLOSARIO_CACHE_KEY, JSON.stringify({ pack: nextPack, terms: nextTerms }));
|
||||
} catch {
|
||||
// localStorage bloqueado: seguimos en memoria
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Carga el glosario vigente al iniciar sesión y refresca al reintentar.
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
odooApi.getGlosario()
|
||||
.then((res) => {
|
||||
if (!alive || res.status !== 'success') return;
|
||||
const next: GlossaryTerms = { ...GLOSARIO_CLINICA };
|
||||
for (const k of GLOSSARY_KEYS) {
|
||||
if (res.terms?.[k]) next[k] = res.terms[k];
|
||||
}
|
||||
setTerms(res.pack || 'clinica', next);
|
||||
})
|
||||
.catch(() => {
|
||||
// fallback: caché o pack clínica
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [token, attempt, setTerms]);
|
||||
|
||||
// Packs disponibles solo hacen falta en Configuración; se piden con la sesión.
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
let alive = true;
|
||||
odooApi.getGlosarioPacks()
|
||||
.then((res) => {
|
||||
if (alive && res.status === 'success' && res.packs) setPacks(res.packs);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
const term = useCallback(
|
||||
(key: GlossaryKey): string => terms[key] || GLOSARIO_CLINICA[key] || key,
|
||||
[terms],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ terms, pack, packs, loading, term, setTerms, refresh }),
|
||||
[terms, pack, packs, loading, term, setTerms, refresh],
|
||||
);
|
||||
|
||||
return <GlosarioContext.Provider value={value}>{children}</GlosarioContext.Provider>;
|
||||
};
|
||||
|
||||
export const useGlosario = (): GlosarioContextValue => {
|
||||
const ctx = useContext(GlosarioContext);
|
||||
if (!ctx) throw new Error('useGlosario debe usarse dentro de GlosarioProvider');
|
||||
return ctx;
|
||||
};
|
||||
|
||||
// Etiquetas del menú lateral que se traducen al glosario. Solo se listan las que
|
||||
// hoy coinciden con un término clínico (Pacientes, Visitas, Médicos...); así con
|
||||
// la configuración por defecto el menú se ve igual que siempre. Los nombres de
|
||||
// módulo (Agenda, Dashboard, Punto de Venta...) no se tocan.
|
||||
export const MENU_LABEL_TERMS: Record<string, GlossaryKey> = {
|
||||
Pacientes: 'patient_plural',
|
||||
Visitas: 'visit_plural',
|
||||
Expedientes: 'record_plural',
|
||||
Médicos: 'doctor_plural',
|
||||
Servicios: 'service_plural',
|
||||
Paquetes: 'package_plural',
|
||||
};
|
||||
|
||||
/** Devuelve la etiqueta del menú traducida según el glosario vigente. */
|
||||
export const traducirEtiqueta = (label: string, term: (key: GlossaryKey) => string): string => {
|
||||
const key = MENU_LABEL_TERMS[label];
|
||||
return key ? term(key) : label;
|
||||
};
|
||||
|
||||
/** Copia superficial del menú (grupos con items) aplicando el glosario a las etiquetas. */
|
||||
export const traducirMenu = <T extends { label: string; items: { label: string }[] }>(
|
||||
groups: T[],
|
||||
term: (key: GlossaryKey) => string,
|
||||
): T[] =>
|
||||
groups.map((g) => ({
|
||||
...g,
|
||||
items: g.items.map((it) => ({ ...it, label: traducirEtiqueta(it.label, term) })),
|
||||
}));
|
||||
@@ -1,11 +1,23 @@
|
||||
// Lista canónica de secciones del sistema (para permisos de menú por usuario)
|
||||
export const MENU_ITEMS: { label: string; href: string }[] = [
|
||||
import type { GlossaryKey } from '../services/odoo';
|
||||
|
||||
// Lista canónica de secciones del sistema (para permisos de menú por usuario).
|
||||
// `termKey` marca las secciones cuyo nombre es un término configurable del
|
||||
// glosario (Pacientes, Visitas, Médicos...): con el pack por defecto se ven
|
||||
// exactamente como siempre. Los nombres de módulo (Agenda, Dashboard, Punto de
|
||||
// Venta...) no son términos y no se traducen.
|
||||
export interface MenuItem {
|
||||
label: string;
|
||||
href: string;
|
||||
termKey?: GlossaryKey;
|
||||
}
|
||||
|
||||
export const MENU_ITEMS: MenuItem[] = [
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Agenda', href: '/agenda' },
|
||||
{ label: 'Pacientes', href: '/pacientes' },
|
||||
{ label: 'Visitas', href: '/visitas' },
|
||||
{ label: 'Médicos', href: '/medicos' },
|
||||
{ label: 'Servicios', href: '/servicios' },
|
||||
{ label: 'Pacientes', href: '/pacientes', termKey: 'patient_plural' },
|
||||
{ label: 'Visitas', href: '/visitas', termKey: 'visit_plural' },
|
||||
{ label: 'Médicos', href: '/medicos', termKey: 'doctor_plural' },
|
||||
{ label: 'Servicios', href: '/servicios', termKey: 'service_plural' },
|
||||
{ label: 'Punto de Venta', href: '/pos' },
|
||||
{ label: 'Ventas', href: '/ventas' },
|
||||
{ label: 'Pagos', href: '/pagos' },
|
||||
@@ -16,7 +28,7 @@ export const MENU_ITEMS: { label: string; href: string }[] = [
|
||||
{ label: 'Comisiones', href: '/reportes?tab=comisiones' },
|
||||
{ label: 'Adeudos', href: '/reportes?tab=adeudos' },
|
||||
{ label: 'Horas agenda', href: '/reportes?tab=horas-agenda' },
|
||||
{ label: 'Paquetes', href: '/reportes?tab=paquetes' },
|
||||
{ label: 'Paquetes', href: '/reportes?tab=paquetes', termKey: 'package_plural' },
|
||||
{ label: 'Vendedores', href: '/reportes?tab=vendedores' },
|
||||
{ label: 'Concentrado', href: '/reportes?tab=concentrado' },
|
||||
{ label: 'Recomendaciones', href: '/reportes?tab=recomendaciones' },
|
||||
@@ -28,3 +40,7 @@ export const MENU_ITEMS: { label: string; href: string }[] = [
|
||||
{ label: 'Mensajes WACRM', href: '/wacrm/messages' },
|
||||
{ label: 'Leads WACRM', href: '/wacrm/leads' },
|
||||
];
|
||||
|
||||
/** Etiqueta de la sección traducida al glosario vigente. */
|
||||
export const menuItemLabel = (item: MenuItem, term: (key: GlossaryKey) => string): string =>
|
||||
item.termKey ? term(item.termKey) : item.label;
|
||||
|
||||
87
frontend/src/lib/service-categories.tsx
Normal file
87
frontend/src/lib/service-categories.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { odooApi, type ServiceCategory } from '../services/odoo';
|
||||
import { useAuth } from './auth';
|
||||
|
||||
// Respaldo histórico: las 4 categorías hardcodeadas antes del endpoint, en el
|
||||
// mismo orden. Con la configuración por defecto el catálogo se ve igual.
|
||||
export const CATEGORIAS_FALLBACK: ServiceCategory[] = [
|
||||
{ id: -1, code: 'consulta', name: 'Consulta', sequence: 10, active: true, is_system: true },
|
||||
{ id: -2, code: 'tratamiento', name: 'Tratamiento', sequence: 20, active: true, is_system: true },
|
||||
{ id: -3, code: 'procedimiento', name: 'Procedimiento', sequence: 30, active: true, is_system: true },
|
||||
{ id: -4, code: 'paquete', name: 'Paquete', sequence: 40, active: true, is_system: true },
|
||||
];
|
||||
|
||||
const NOMBRES_HISTORICOS: Record<string, string> = {
|
||||
consulta: 'Consulta',
|
||||
tratamiento: 'Tratamiento',
|
||||
procedimiento: 'Procedimiento',
|
||||
paquete: 'Paquete',
|
||||
};
|
||||
|
||||
interface ServiceCategoriesContextValue {
|
||||
/** Categorías activas, ordenadas por sequence. */
|
||||
categories: ServiceCategory[];
|
||||
loading: boolean;
|
||||
/** Nombre de la categoría por código; cae al histórico y luego al código. */
|
||||
labelFor: (code?: string | null) => string;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
const ServiceCategoriesContext = createContext<ServiceCategoriesContextValue | undefined>(undefined);
|
||||
|
||||
export const ServiceCategoriesProvider: FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const { token } = useAuth();
|
||||
const [categories, setCategories] = useState<ServiceCategory[]>(CATEGORIAS_FALLBACK);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
const refresh = useCallback(() => setAttempt((n) => n + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
odooApi.getServiceCategories()
|
||||
.then((res) => {
|
||||
if (!alive || res.status !== 'success' || !res.categories?.length) return;
|
||||
setCategories(res.categories);
|
||||
})
|
||||
.catch(() => {
|
||||
// fallback: CATEGORIAS_FALLBACK
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [token, attempt]);
|
||||
|
||||
const porCodigo = useMemo(() => {
|
||||
const m = new Map<string, ServiceCategory>();
|
||||
for (const c of categories) m.set(c.code, c);
|
||||
return m;
|
||||
}, [categories]);
|
||||
|
||||
const labelFor = useCallback(
|
||||
(code?: string | null) => {
|
||||
if (!code) return '';
|
||||
return porCodigo.get(code)?.name || NOMBRES_HISTORICOS[code] || code;
|
||||
},
|
||||
[porCodigo],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ categories, loading, labelFor, refresh }),
|
||||
[categories, loading, labelFor, refresh],
|
||||
);
|
||||
|
||||
return <ServiceCategoriesContext.Provider value={value}>{children}</ServiceCategoriesContext.Provider>;
|
||||
};
|
||||
|
||||
export const useServiceCategories = (): ServiceCategoriesContextValue => {
|
||||
const ctx = useContext(ServiceCategoriesContext);
|
||||
if (!ctx) throw new Error('useServiceCategories debe usarse dentro de ServiceCategoriesProvider');
|
||||
return ctx;
|
||||
};
|
||||
86
frontend/src/lib/sucursales.tsx
Normal file
86
frontend/src/lib/sucursales.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { odooApi, type Sucursal } from '../services/odoo';
|
||||
import { useAuth } from './auth';
|
||||
|
||||
// Respaldo histórico: las dos sucursales que existían hardcodeadas antes de que
|
||||
// el endpoint existiera. Si /sucursales falla, el frontend se sigue viendo igual.
|
||||
export const SUCURSALES_FALLBACK: Sucursal[] = [
|
||||
{ id: -1, code: 'rosarito', name: 'Rosarito', sequence: 10, active: true, is_default: true, address: '', phone: '', notes: '' },
|
||||
{ id: -2, code: 'tijuana', name: 'Tijuana', sequence: 20, active: true, is_default: false, address: '', phone: '', notes: '' },
|
||||
];
|
||||
|
||||
const NOMBRES_HISTORICOS: Record<string, string> = { rosarito: 'Rosarito', tijuana: 'Tijuana' };
|
||||
|
||||
interface SucursalesContextValue {
|
||||
/** Sucursales activas, ordenadas por sequence. */
|
||||
sucursales: Sucursal[];
|
||||
loading: boolean;
|
||||
defaultCode: string;
|
||||
/** Nombre de la sucursal por código; cae al histórico y luego al propio código. */
|
||||
labelFor: (code?: string | null) => string;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
const SucursalesContext = createContext<SucursalesContextValue | undefined>(undefined);
|
||||
|
||||
export const SucursalesProvider: FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const { token } = useAuth();
|
||||
const [sucursales, setSucursales] = useState<Sucursal[]>(SUCURSALES_FALLBACK);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
const refresh = useCallback(() => setAttempt((n) => n + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
odooApi.getSucursales()
|
||||
.then((res) => {
|
||||
if (!alive || res.status !== 'success' || !res.sucursales?.length) return;
|
||||
setSucursales(res.sucursales);
|
||||
})
|
||||
.catch(() => {
|
||||
// fallback: SUCURSALES_FALLBACK
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [token, attempt]);
|
||||
|
||||
const porCodigo = useMemo(() => {
|
||||
const m = new Map<string, Sucursal>();
|
||||
for (const s of sucursales) m.set(s.code, s);
|
||||
return m;
|
||||
}, [sucursales]);
|
||||
|
||||
const defaultCode = useMemo(
|
||||
() => (sucursales.find((s) => s.is_default) || sucursales[0])?.code || '',
|
||||
[sucursales],
|
||||
);
|
||||
|
||||
const labelFor = useCallback(
|
||||
(code?: string | null) => {
|
||||
if (!code) return '';
|
||||
return porCodigo.get(code)?.name || NOMBRES_HISTORICOS[code] || code;
|
||||
},
|
||||
[porCodigo],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ sucursales, loading, defaultCode, labelFor, refresh }),
|
||||
[sucursales, loading, defaultCode, labelFor, refresh],
|
||||
);
|
||||
|
||||
return <SucursalesContext.Provider value={value}>{children}</SucursalesContext.Provider>;
|
||||
};
|
||||
|
||||
export const useSucursales = (): SucursalesContextValue => {
|
||||
const ctx = useContext(SucursalesContext);
|
||||
if (!ctx) throw new Error('useSucursales debe usarse dentro de SucursalesProvider');
|
||||
return ctx;
|
||||
};
|
||||
@@ -26,8 +26,10 @@ import {
|
||||
badgeForAppointmentState,
|
||||
} from '../components/ui';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { odooApi, type Appointment, type AppointmentStateLog, type Patient, type Service, type Doctor, type Bloqueo, type ListaEspera, type PatientWallet, type ReceiptData, type AgendaPerm, AGENDA_PERM_ITEMS } from '../services/odoo';
|
||||
import { odooApi, type Appointment, type AppointmentStateLog, type Patient, type Service, type Doctor, type Bloqueo, type ListaEspera, type PatientWallet, type ReceiptData, type AgendaPerm, type GlossaryKey, AGENDA_PERM_ITEMS } from '../services/odoo';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { useGlosario } from '../lib/glosario';
|
||||
import { useSucursales } from '../lib/sucursales';
|
||||
import { todayLocal } from '../lib/utils';
|
||||
import { useAutoRefresh, AutoRefreshBadge } from '../lib/autoRefresh';
|
||||
|
||||
@@ -55,6 +57,10 @@ export const APT_STATE_LABELS: Record<string, string> = {
|
||||
waitlist: 'Lista de espera',
|
||||
};
|
||||
|
||||
/** Etiqueta de estado de cita traducida ('En cabina' sigue el término de sala). */
|
||||
export const aptStateLabel = (state: string, term: (key: GlossaryKey) => string): string =>
|
||||
state === 'arrived' ? `En ${term('room_singular').toLowerCase()}` : (APT_STATE_LABELS[state] || state);
|
||||
|
||||
const stateOptions = [
|
||||
{ value: '', label: 'Todos los estados' },
|
||||
{ value: 'pending', label: 'Agendada' },
|
||||
@@ -140,6 +146,8 @@ const Agenda: FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { user, canAgenda } = useAuth();
|
||||
const { term } = useGlosario();
|
||||
const { sucursales, defaultCode } = useSucursales();
|
||||
const { openPatient } = usePatientDrawer();
|
||||
// Rol médico: agenda restringida a su propio hr.employee (filtro también en servidor).
|
||||
// Cosmetología ve la agenda completa (es personal clínico, sin la privacidad
|
||||
@@ -253,6 +261,7 @@ const Agenda: FC = () => {
|
||||
notes: '',
|
||||
sessions: 1,
|
||||
duration: '',
|
||||
branch: '',
|
||||
});
|
||||
const quickToggleClass = editingId
|
||||
? 'hidden'
|
||||
@@ -272,6 +281,7 @@ const Agenda: FC = () => {
|
||||
time: '',
|
||||
notes: '',
|
||||
duration: '',
|
||||
branch: '',
|
||||
});
|
||||
const [pkgUpdating, setPkgUpdating] = useState<number | null>(null);
|
||||
const [expressOpen, setExpressOpen] = useState(false);
|
||||
@@ -943,7 +953,7 @@ const Agenda: FC = () => {
|
||||
const cambiarSala = async (entry: ListaEspera, sala: string) => {
|
||||
try {
|
||||
await odooApi.updateListaEspera(entry.id, { sala });
|
||||
const etiqueta = sala === 'consultoria' ? 'Consultoría' : sala === 'cabina' ? 'Cabina' : 'Sin sala';
|
||||
const etiqueta = sala === 'consultoria' ? 'Consultoría' : sala === 'cabina' ? term('room_singular') : 'Sin sala';
|
||||
toast.success(`${entry.patient}: ${etiqueta}`);
|
||||
await recargarListaEspera();
|
||||
} catch (err) {
|
||||
@@ -1249,7 +1259,7 @@ const Agenda: FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const aptStateLabels = APT_STATE_LABELS;
|
||||
const aptStateLabels = (state: string) => aptStateLabel(state, term);
|
||||
|
||||
const handlePhoneConfirm = async (apt: Appointment, confirmed: boolean) => {
|
||||
try {
|
||||
@@ -1413,6 +1423,7 @@ const Agenda: FC = () => {
|
||||
notes: form.notes,
|
||||
sessions,
|
||||
duration: form.duration ? parseInt(form.duration, 10) : 0,
|
||||
branch: form.branch || defaultCode || undefined,
|
||||
} as never);
|
||||
toast.success(sessions > 1 ? `Paquete creado: ${sessions} citas` : 'Cita creada');
|
||||
// Si la cita salió de la lista de espera, se marca como agendada
|
||||
@@ -1427,6 +1438,7 @@ const Agenda: FC = () => {
|
||||
notes: '',
|
||||
sessions: 1,
|
||||
duration: '',
|
||||
branch: '',
|
||||
});
|
||||
await load();
|
||||
} catch (err) {
|
||||
@@ -1453,6 +1465,7 @@ const Agenda: FC = () => {
|
||||
notes: apt.notes || '',
|
||||
sessions: 1,
|
||||
duration: String(apt.duration || apt.service_duration || DEFAULT_DURATION),
|
||||
branch: apt.branch || '',
|
||||
});
|
||||
setCreateOpen(true);
|
||||
};
|
||||
@@ -1473,6 +1486,7 @@ const Agenda: FC = () => {
|
||||
time: parseTimeToFloat(form.time) ?? 0,
|
||||
notes: form.notes,
|
||||
duration: form.duration ? parseInt(form.duration, 10) : 0,
|
||||
branch: form.branch || defaultCode || undefined,
|
||||
} as never);
|
||||
toast.success('Cita actualizada');
|
||||
setCreateOpen(false);
|
||||
@@ -1492,7 +1506,7 @@ const Agenda: FC = () => {
|
||||
setQuickMode(false);
|
||||
setEditingId(null);
|
||||
setPickedPatient(null);
|
||||
setForm((f) => ({ ...f, patient_id: '', duration: '', ...(medicoEmployeeId ? { doctor_id: String(medicoEmployeeId) } : {}) }));
|
||||
setForm((f) => ({ ...f, patient_id: '', duration: '', branch: '', ...(medicoEmployeeId ? { doctor_id: String(medicoEmployeeId) } : {}) }));
|
||||
setQuickForm({
|
||||
name: '',
|
||||
phone: '',
|
||||
@@ -1501,6 +1515,7 @@ const Agenda: FC = () => {
|
||||
time: '',
|
||||
notes: '',
|
||||
duration: '',
|
||||
branch: '',
|
||||
});
|
||||
setCreateOpen(true);
|
||||
};
|
||||
@@ -1519,7 +1534,7 @@ const Agenda: FC = () => {
|
||||
? String(medicoEmployeeId)
|
||||
: (opts?.doctorId != null ? String(opts.doctorId) : doctorFilter);
|
||||
const t = opts?.time ?? '';
|
||||
setForm((f) => ({ ...f, patient_id: '', date: d, time: t, doctor_id: docId }));
|
||||
setForm((f) => ({ ...f, patient_id: '', date: d, time: t, doctor_id: docId, branch: '' }));
|
||||
setQuickForm({
|
||||
name: '',
|
||||
phone: '',
|
||||
@@ -1528,6 +1543,7 @@ const Agenda: FC = () => {
|
||||
time: t,
|
||||
notes: '',
|
||||
duration: '',
|
||||
branch: '',
|
||||
});
|
||||
setCreateOpen(true);
|
||||
};
|
||||
@@ -1564,6 +1580,7 @@ const Agenda: FC = () => {
|
||||
doctor_id: quickForm.doctor_id ? parseInt(quickForm.doctor_id, 10) : undefined,
|
||||
notes: quickForm.notes || undefined,
|
||||
duration: quickForm.duration ? parseInt(quickForm.duration, 10) : undefined,
|
||||
branch: quickForm.branch || defaultCode || undefined,
|
||||
});
|
||||
if (res.patient_created) {
|
||||
toast.success('Paciente y cita rápida creados');
|
||||
@@ -1764,7 +1781,7 @@ const Agenda: FC = () => {
|
||||
{puedeAgendar && (
|
||||
<Button onClick={openCreate}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Nueva cita
|
||||
Nueva {term('appointment_singular').toLowerCase()}
|
||||
</Button>
|
||||
)}
|
||||
</PageHeader>
|
||||
@@ -1822,7 +1839,7 @@ const Agenda: FC = () => {
|
||||
</div>
|
||||
<Select
|
||||
label="Estado"
|
||||
options={stateOptions}
|
||||
options={stateOptions.map((o) => (o.value === 'arrived' ? { ...o, label: `En ${term('room_singular').toLowerCase()}` } : o))}
|
||||
value={stateFilter}
|
||||
onChange={(e) => setStateFilter(e.target.value)}
|
||||
className="sm:max-w-[180px]"
|
||||
@@ -2485,7 +2502,7 @@ const Agenda: FC = () => {
|
||||
<EmptyState
|
||||
title="Sin citas"
|
||||
subtitle="No hay citas para los filtros seleccionados."
|
||||
actionLabel="Nueva cita"
|
||||
actionLabel={`Nueva ${term('appointment_singular').toLowerCase()}`}
|
||||
onAction={openCreate}
|
||||
/>
|
||||
) : (
|
||||
@@ -2553,7 +2570,7 @@ const Agenda: FC = () => {
|
||||
</td>
|
||||
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">{apt.doctor || '-'}</td>
|
||||
<td className="p-3">
|
||||
<Badge variant={badgeForAppointmentState(apt.state)}>{APT_STATE_LABELS[apt.state] || apt.state}</Badge>
|
||||
<Badge variant={badgeForAppointmentState(apt.state)}>{aptStateLabel(apt.state, term)}</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-center">
|
||||
<button
|
||||
@@ -2586,7 +2603,7 @@ const Agenda: FC = () => {
|
||||
rows={[
|
||||
{ label: 'Hora', value: apt.time },
|
||||
{ label: 'Doctor', value: apt.doctor || '-' },
|
||||
{ label: 'Estado', value: <Badge variant={badgeForAppointmentState(apt.state)}>{APT_STATE_LABELS[apt.state] || apt.state}</Badge> },
|
||||
{ label: 'Estado', value: <Badge variant={badgeForAppointmentState(apt.state)}>{aptStateLabel(apt.state, term)}</Badge> },
|
||||
{ label: 'Confirmación', value: <span title={confirmTitle(apt)}>{confirmIndicator(apt, 14)}</span> },
|
||||
{ label: 'Pago', value: apt.payment_state },
|
||||
]}
|
||||
@@ -2672,7 +2689,7 @@ const Agenda: FC = () => {
|
||||
<Modal
|
||||
isOpen={createOpen}
|
||||
onClose={() => cerrarModalCita()}
|
||||
title={editingId ? 'Editar cita' : 'Nueva cita'}
|
||||
title={editingId ? `Editar ${term('appointment_singular').toLowerCase()}` : `Nueva ${term('appointment_singular').toLowerCase()}`}
|
||||
maxWidth="lg"
|
||||
footer={
|
||||
<>
|
||||
@@ -2717,6 +2734,12 @@ const Agenda: FC = () => {
|
||||
onChange={(e) => setQuickForm({ ...quickForm, doctor_id: e.target.value })}
|
||||
disabled={isMedico}
|
||||
/>
|
||||
<Select
|
||||
label={term('branch_singular')}
|
||||
options={sucursales.map((s) => ({ value: s.code, label: s.name }))}
|
||||
value={quickForm.branch || defaultCode}
|
||||
onChange={(e) => setQuickForm({ ...quickForm, branch: e.target.value })}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Fecha *"
|
||||
@@ -2816,6 +2839,12 @@ const Agenda: FC = () => {
|
||||
onChange={(e) => setForm({ ...form, doctor_id: e.target.value })}
|
||||
disabled={isMedico}
|
||||
/>
|
||||
<Select
|
||||
label={term('branch_singular')}
|
||||
options={sucursales.map((s) => ({ value: s.code, label: s.name }))}
|
||||
value={form.branch || defaultCode}
|
||||
onChange={(e) => setForm({ ...form, branch: e.target.value })}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Fecha *"
|
||||
@@ -2977,7 +3006,7 @@ const Agenda: FC = () => {
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={badgeForAppointmentState(detailApt.state)}>
|
||||
{aptStateLabels[detailApt.state] || detailApt.state}
|
||||
{aptStateLabels(detailApt.state)}
|
||||
</Badge>
|
||||
{detailApt.is_first_visit && (
|
||||
<Badge variant="primary">Primera vez</Badge>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Phone, MapPin, Mail, Globe, Save, Building2, ArrowRightLeft, Percent, Check, Calculator } from 'lucide-react';
|
||||
import { Phone, MapPin, Mail, Globe, Save, Building2, ArrowRightLeft, Percent, Check, Calculator, Palette } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import UsuariosPanel from '../components/UsuariosPanel';
|
||||
import RolesPanel from '../components/RolesPanel';
|
||||
@@ -10,12 +10,18 @@ import CatalogoPanel from '../components/CatalogoPanel';
|
||||
import AuditoriaPanel from '../components/AuditoriaPanel';
|
||||
import TicketDesigner from '../components/TicketDesigner';
|
||||
import ImpactoModal from '../components/ImpactoModal';
|
||||
import SucursalesPanel from '../components/SucursalesPanel';
|
||||
import CategoriasPanel from '../components/CategoriasPanel';
|
||||
import TerminologiaPanel from '../components/TerminologiaPanel';
|
||||
import { Card, Button, Input, PageHeader, toast } from '../components/ui';
|
||||
import { odooApi, type Doctor, BRAND_DEFAULT } from '../services/odoo';
|
||||
import { odooApi, type Doctor, type ClinicSettings, BRAND_DEFAULT, CLINIC_SETTINGS_DEFAULT } from '../services/odoo';
|
||||
import { useAuth } from '../lib/auth';
|
||||
|
||||
const TABS = [
|
||||
{ key: 'clinica', label: 'Clínica' },
|
||||
{ key: 'sucursales', label: 'Sucursales' },
|
||||
{ key: 'categorias', label: 'Categorías' },
|
||||
{ key: 'terminologia', label: 'Terminología' },
|
||||
{ key: 'usuarios', label: 'Usuarios' },
|
||||
{ key: 'roles', label: 'Roles' },
|
||||
{ key: 'recetas', label: 'Recetas' },
|
||||
@@ -24,38 +30,25 @@ const TABS = [
|
||||
{ key: 'auditoria', label: 'Auditoría' },
|
||||
] as const;
|
||||
|
||||
// Secciones que ve gerencia (admin del sistema ve todas).
|
||||
const TABS_GERENCIA = ['sucursales', 'categorias', 'terminologia', 'usuarios', 'roles', 'tickets'];
|
||||
|
||||
const SUBCATALOGOS = [
|
||||
{ key: 'diagnosticos', label: 'Diagnósticos' },
|
||||
{ key: 'procedimientos', label: 'Procedimientos' },
|
||||
] as const;
|
||||
|
||||
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';
|
||||
// Clave histórica del navegador: antes los ajustes de la clínica vivían solo en
|
||||
// localStorage. Se migran al backend una vez y deja de usarse.
|
||||
const LEGACY_STORAGE_KEY = 'skeen_clinic_settings';
|
||||
|
||||
const Configuracion: FC = () => {
|
||||
const { user, brand, setBrand } = useAuth();
|
||||
// Gerencia gestiona usuarios y roles; la sección "Clínica" (tipo de cambio y
|
||||
// comisiones) queda para administración del sistema.
|
||||
// Gerencia gestiona catálogos, usuarios y roles; la sección "Clínica" (datos
|
||||
// de contacto, tipo de cambio y comisiones) queda para administración del sistema.
|
||||
const esAdmin = user?.role === 'admin';
|
||||
const tabsVisibles = useMemo(
|
||||
() => (esAdmin
|
||||
? TABS
|
||||
: TABS.filter((t) => t.key === 'usuarios' || t.key === 'roles' || t.key === 'tickets')),
|
||||
() => (esAdmin ? TABS : TABS.filter((t) => TABS_GERENCIA.includes(t.key))),
|
||||
[esAdmin],
|
||||
);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -66,31 +59,60 @@ const Configuracion: FC = () => {
|
||||
const sub = searchParams.get('sub');
|
||||
return sub === 'procedimientos' ? 'procedimientos' : 'diagnosticos';
|
||||
});
|
||||
const [settings, setSettings] = useState<ClinicSettings>(() => ({
|
||||
...defaultSettings,
|
||||
// El nombre default sigue al brand (white-label); teléfono/dirección
|
||||
// quedan en localStorage o los valores históricos de la clínica.
|
||||
name: brand.nombre,
|
||||
}));
|
||||
const [settings, setSettings] = useState<ClinicSettings>(CLINIC_SETTINGS_DEFAULT);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Ajustes de la clínica desde el backend. Migración de una sola vez: si este
|
||||
// navegador tiene los datos viejos en localStorage y el backend sigue con los
|
||||
// defaults (contacto vacío), se precargan y se suben; después se deja de usar
|
||||
// localStorage como fuente de verdad.
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) setSettings(JSON.parse(saved));
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
let alive = true;
|
||||
odooApi.getClinicSettings()
|
||||
.then(async (res) => {
|
||||
if (!alive || res.status !== 'success') return;
|
||||
let next = res.settings;
|
||||
try {
|
||||
const raw = localStorage.getItem(LEGACY_STORAGE_KEY);
|
||||
if (raw) {
|
||||
const legacy = JSON.parse(raw) as Partial<{ name: string; phone: string; email: string; address: string; website: string }>;
|
||||
const backendVacio = !next.telefono && !next.email && !next.direccion && !next.web;
|
||||
const legacyTieneDatos = Boolean(legacy?.phone || legacy?.email || legacy?.address || legacy?.website);
|
||||
if (backendVacio && legacyTieneDatos) {
|
||||
next = {
|
||||
...next,
|
||||
telefono: legacy?.phone || next.telefono,
|
||||
email: legacy?.email || next.email,
|
||||
direccion: legacy?.address || next.direccion,
|
||||
web: legacy?.website || next.web,
|
||||
};
|
||||
const up = await odooApi.putClinicSettings({ ...next, motivo: 'Migración desde localStorage' });
|
||||
if (up.status === 'success') next = up.settings;
|
||||
}
|
||||
localStorage.removeItem(LEGACY_STORAGE_KEY);
|
||||
}
|
||||
} catch {
|
||||
localStorage.removeItem(LEGACY_STORAGE_KEY);
|
||||
}
|
||||
if (alive) setSettings(next);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('No se pudieron cargar los ajustes de la clínica');
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setLoading(false);
|
||||
});
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
const save = () => {
|
||||
const save = async () => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||
const res = await odooApi.putClinicSettings({ ...settings, motivo: 'Ajustes desde Configuración' });
|
||||
if (res.status === 'success') setSettings(res.settings);
|
||||
toast.success('Configuración guardada');
|
||||
} catch {
|
||||
toast.error('Error al guardar configuración');
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'Error al guardar configuración');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -104,7 +126,7 @@ const Configuracion: FC = () => {
|
||||
const [tcSaving, setTcSaving] = useState(false);
|
||||
// ---- Identidad de la clínica (brand white-label) ----
|
||||
const [brandForm, setBrandForm] = useState({
|
||||
nombre: '', pie: '', logo_claro: '', logo_oscuro: '',
|
||||
nombre: '', pie: '', logo_claro: '', logo_oscuro: '', url_publica: '', primario: '', acento: '',
|
||||
});
|
||||
const [brandSaving, setBrandSaving] = useState(false);
|
||||
const brandCargadoRef = useRef(false);
|
||||
@@ -116,6 +138,9 @@ const Configuracion: FC = () => {
|
||||
pie: brand.pie === BRAND_DEFAULT.pie ? '' : brand.pie,
|
||||
logo_claro: brand.logo_claro === BRAND_DEFAULT.logo_claro ? '' : brand.logo_claro,
|
||||
logo_oscuro: brand.logo_oscuro === BRAND_DEFAULT.logo_oscuro ? '' : brand.logo_oscuro,
|
||||
url_publica: brand.url_publica || '',
|
||||
primario: brand.colores?.primario || '',
|
||||
acento: brand.colores?.acento || '',
|
||||
});
|
||||
}, [brand]);
|
||||
|
||||
@@ -128,13 +153,16 @@ const Configuracion: FC = () => {
|
||||
pie: brandForm.pie.trim() || BRAND_DEFAULT.pie,
|
||||
logo_claro: brandForm.logo_claro.trim() || BRAND_DEFAULT.logo_claro,
|
||||
logo_oscuro: brandForm.logo_oscuro.trim() || BRAND_DEFAULT.logo_oscuro,
|
||||
url_publica: brandForm.url_publica.trim(),
|
||||
colores: { primario: brandForm.primario.trim(), acento: brandForm.acento.trim() },
|
||||
},
|
||||
motivo: 'Identidad desde Configuración',
|
||||
});
|
||||
setBrand(res.brand);
|
||||
toast.success('Identidad de la clínica actualizada (se ve al recargar en los demás equipos)');
|
||||
} catch {
|
||||
toast.error('No se pudo guardar la identidad de la clínica');
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'No se pudo guardar la identidad de la clínica');
|
||||
} finally {
|
||||
setBrandSaving(false);
|
||||
}
|
||||
@@ -298,6 +326,24 @@ const Configuracion: FC = () => {
|
||||
<CatalogoPanel kind={subcatalogo as 'diagnosticos' | 'procedimientos'} titulo={subcatalogo === 'diagnosticos' ? 'Diagnósticos' : 'Procedimientos'} />
|
||||
</Card.Body>
|
||||
</Card>
|
||||
) : tab === 'sucursales' ? (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<SucursalesPanel />
|
||||
</Card.Body>
|
||||
</Card>
|
||||
) : tab === 'categorias' ? (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<CategoriasPanel />
|
||||
</Card.Body>
|
||||
</Card>
|
||||
) : tab === 'terminologia' ? (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<TerminologiaPanel />
|
||||
</Card.Body>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
|
||||
<Card>
|
||||
@@ -305,7 +351,7 @@ const Configuracion: FC = () => {
|
||||
<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)}`}
|
||||
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(settings.direccion)}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="p-4 bg-theme-bg rounded-xl flex items-start space-x-3 hover:bg-theme-bg transition"
|
||||
@@ -315,12 +361,12 @@ const Configuracion: FC = () => {
|
||||
</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>
|
||||
<p className="text-sm text-theme-muted truncate">{settings.direccion}</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={`tel:${settings.phone}`}
|
||||
href={`tel:${settings.telefono}`}
|
||||
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">
|
||||
@@ -328,7 +374,7 @@ const Configuracion: FC = () => {
|
||||
</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>
|
||||
<p className="text-sm text-theme-muted truncate">{settings.telefono}</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -346,7 +392,7 @@ const Configuracion: FC = () => {
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={settings.website}
|
||||
href={settings.web}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="p-4 bg-theme-bg rounded-xl flex items-start space-x-3 hover:bg-theme-bg transition"
|
||||
@@ -356,7 +402,7 @@ const Configuracion: FC = () => {
|
||||
</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>
|
||||
<p className="text-sm text-theme-muted truncate">{(settings.web || '').replace(/^https?:\/\//, '')}</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
@@ -375,30 +421,78 @@ const Configuracion: FC = () => {
|
||||
<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)}
|
||||
value={settings.nombre}
|
||||
onChange={(e) => update('nombre', e.target.value)}
|
||||
/>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Teléfono"
|
||||
value={settings.telefono}
|
||||
onChange={(e) => update('telefono', e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="RFC"
|
||||
value={settings.rfc}
|
||||
onChange={(e) => update('rfc', e.target.value)}
|
||||
placeholder="Opcional"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={settings.email}
|
||||
onChange={(e) => update('email', e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Sitio web"
|
||||
value={settings.web}
|
||||
onChange={(e) => update('web', e.target.value)}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Dirección"
|
||||
value={settings.address}
|
||||
onChange={(e) => update('address', e.target.value)}
|
||||
value={settings.direccion}
|
||||
onChange={(e) => update('direccion', e.target.value)}
|
||||
/>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Horario de apertura"
|
||||
type="time"
|
||||
value={settings.horario_apertura}
|
||||
onChange={(e) => update('horario_apertura', e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Horario de cierre"
|
||||
type="time"
|
||||
value={settings.horario_cierre}
|
||||
onChange={(e) => update('horario_cierre', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Zona horaria"
|
||||
value={settings.zona_horaria}
|
||||
onChange={(e) => update('zona_horaria', e.target.value)}
|
||||
placeholder="America/Tijuana"
|
||||
/>
|
||||
<Input
|
||||
label="URL pública"
|
||||
value={settings.url_publica}
|
||||
onChange={(e) => update('url_publica', e.target.value)}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Sitio web"
|
||||
value={settings.website}
|
||||
onChange={(e) => update('website', e.target.value)}
|
||||
label="Nota del ticket"
|
||||
value={settings.nota_ticket}
|
||||
onChange={(e) => update('nota_ticket', e.target.value)}
|
||||
placeholder="Se imprime al pie del ticket"
|
||||
/>
|
||||
<p className="text-xs text-theme-muted">
|
||||
Moneda base: <span className="font-medium text-theme-heading">{settings.moneda_base || 'MXN'}</span>
|
||||
</p>
|
||||
<div className="pt-2">
|
||||
<Button onClick={save}>
|
||||
<Save size={16} className="mr-2" />
|
||||
@@ -445,7 +539,53 @@ const Configuracion: FC = () => {
|
||||
onChange={(e) => setBrandForm((f) => ({ ...f, logo_oscuro: e.target.value }))}
|
||||
placeholder={BRAND_DEFAULT.logo_oscuro}
|
||||
/>
|
||||
<Input
|
||||
label="URL pública de la instancia"
|
||||
value={brandForm.url_publica}
|
||||
onChange={(e) => setBrandForm((f) => ({ ...f, url_publica: e.target.value }))}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Colores de marca: vacíos = se conserva el color del tema activo. */}
|
||||
<div className="mt-5">
|
||||
<p className="text-sm font-medium text-theme-heading mb-1 flex items-center">
|
||||
<Palette size={16} className="mr-2" /> Colores de marca
|
||||
</p>
|
||||
<p className="text-xs text-theme-muted mb-3">
|
||||
Si se definen, pisan el color del tema en los tres temas. Dejarlos vacíos conserva el color original.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{([
|
||||
{ key: 'primario' as const, label: 'Color primario' },
|
||||
{ key: 'acento' as const, label: 'Color de acento' },
|
||||
]).map(({ key, label }) => (
|
||||
<div key={key}>
|
||||
<label className="block text-xs font-medium text-theme-muted mb-1.5">{label}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={brandForm[key] || (key === 'primario' ? '#eda588' : '#c7bcdd')}
|
||||
onChange={(e) => setBrandForm((f) => ({ ...f, [key]: e.target.value }))}
|
||||
className="h-10 w-12 shrink-0 rounded-lg border border-theme-border-strong bg-theme-surface cursor-pointer"
|
||||
aria-label={label}
|
||||
/>
|
||||
<Input
|
||||
value={brandForm[key]}
|
||||
onChange={(e) => setBrandForm((f) => ({ ...f, [key]: e.target.value }))}
|
||||
placeholder="Vacío = color del tema"
|
||||
/>
|
||||
{brandForm[key] && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setBrandForm((f) => ({ ...f, [key]: '' }))} title="Usar el color del tema">
|
||||
Limpiar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mt-4">
|
||||
{(brandForm.logo_claro || brandForm.logo_oscuro) && (
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -51,8 +51,9 @@ import { odooApi, type DashboardStats, type Appointment, type Service, type Char
|
||||
import { usePatientDrawer } from '../components/PatientDrawer';
|
||||
import { useTheme } from '../lib/theme';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { useGlosario } from '../lib/glosario';
|
||||
import { waLink } from '../lib/whatsapp';
|
||||
import { APT_STATE_LABELS } from './Agenda';
|
||||
import { aptStateLabel } from './Agenda';
|
||||
|
||||
const formatCurrency = (value: number) =>
|
||||
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
|
||||
@@ -161,6 +162,7 @@ const TodayTile: FC<TodayTileProps> = ({ label, icon, onClick, children, classNa
|
||||
const Dashboard: FC = () => {
|
||||
const { theme } = useTheme();
|
||||
const { user } = useAuth();
|
||||
const { term } = useGlosario();
|
||||
const navigate = useNavigate();
|
||||
const isHomeNest = theme === 'homenest';
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
@@ -436,7 +438,7 @@ const Dashboard: FC = () => {
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3 shrink-0">
|
||||
<Button variant="primary" size="md" onClick={() => navigate('/agenda')}>
|
||||
<Calendar size={16} className="mr-2" /> Nueva cita
|
||||
<Calendar size={16} className="mr-2" /> Nueva {term('appointment_singular').toLowerCase()}
|
||||
</Button>
|
||||
<Button variant="outline" size="md" onClick={() => navigate('/reportes')}>
|
||||
<TrendingUp size={16} className="mr-2" /> Ver reportes
|
||||
@@ -706,7 +708,7 @@ const Dashboard: FC = () => {
|
||||
<p className="text-sm font-medium text-theme-heading truncate">{apt.patient}</p>
|
||||
<p className="text-xs text-theme-muted truncate">{apt.service} • {apt.time}</p>
|
||||
</div>
|
||||
<Badge variant={badgeForAppointmentState(apt.state)}>{APT_STATE_LABELS[apt.state] || apt.state}</Badge>
|
||||
<Badge variant={badgeForAppointmentState(apt.state)}>{aptStateLabel(apt.state, term)}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Mail, Phone, Stethoscope, Briefcase, Save, Percent, Eye, CalendarDays,
|
||||
import Layout from '../components/Layout';
|
||||
import { Card, Badge, EmptyState, PageHeader, Skeleton, MobileCard, Input, Button, Modal, Select, toast } from '../components/ui';
|
||||
import { odooApi, type Doctor, type DoctorHistory, type HorarioDia } from '../services/odoo';
|
||||
import { useGlosario } from '../lib/glosario';
|
||||
|
||||
const DIAS = ['Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo'];
|
||||
|
||||
@@ -130,6 +131,7 @@ const StatBadges: FC<{ d: Doctor }> = ({ d }) => (
|
||||
);
|
||||
|
||||
const Medicos: FC = () => {
|
||||
const { term } = useGlosario();
|
||||
const [employees, setEmployees] = useState<Doctor[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -326,8 +328,8 @@ const Medicos: FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout title="Médicos" subtitle="Equipo médico">
|
||||
<PageHeader title="Médicos" subtitle="Especialistas de la clínica">
|
||||
<Layout title={term('doctor_plural')} subtitle="Equipo médico">
|
||||
<PageHeader title={term('doctor_plural')} subtitle="Especialistas de la clínica">
|
||||
{countBaja > 0 && (
|
||||
<label className="inline-flex items-center gap-2 text-sm text-theme-heading cursor-pointer">
|
||||
<input
|
||||
|
||||
@@ -32,6 +32,8 @@ import { useSearchParams } from 'react-router-dom';
|
||||
import { odooApi, type Patient, type Appointment, type Sale, type Visita, type Doctor, type EstadoCuenta, type PatientWallet, type ReceiptData } from '../services/odoo';
|
||||
import { exportToExcel } from '../lib/exporter';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { useGlosario } from '../lib/glosario';
|
||||
import { useSucursales } from '../lib/sucursales';
|
||||
|
||||
const fmtMoney = (n: number) =>
|
||||
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 });
|
||||
@@ -100,7 +102,6 @@ const visitaStateLabels: Record<string, string> = {
|
||||
const paymentStateLabels: Record<string, string> = {
|
||||
not_paid: 'No pagado', partial: 'Pago parcial', paid: 'Pagado',
|
||||
};
|
||||
const branchLabels: Record<string, string> = { rosarito: 'Rosarito', tijuana: 'Tijuana' };
|
||||
const mediumLabels: Record<string, string> = { onsite: 'Presencial', videocall: 'Videollamada' };
|
||||
|
||||
const completionBadgeClasses = (pct?: number) => {
|
||||
@@ -122,6 +123,8 @@ const CRITICAL_ALERTS: { key: keyof Patient; label: string }[] = [
|
||||
|
||||
const Pacientes: FC = () => {
|
||||
const { brand } = useAuth();
|
||||
const { term } = useGlosario();
|
||||
const { labelFor: branchLabel } = useSucursales();
|
||||
const [patients, setPatients] = useState<Patient[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -429,7 +432,7 @@ const Pacientes: FC = () => {
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden sm:table-cell">Teléfono</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Edad</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Última visita</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Visitas</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">{term('visit_plural')}</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Puntos</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Acciones</th>
|
||||
</tr>
|
||||
@@ -520,15 +523,15 @@ const Pacientes: FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout title="Pacientes" subtitle="Directorio de pacientes">
|
||||
<PageHeader title="Pacientes" subtitle="Busca, crea y gestiona pacientes">
|
||||
<Layout title={term('patient_plural')} subtitle="Directorio de pacientes">
|
||||
<PageHeader title={term('patient_plural')} subtitle="Busca, crea y gestiona pacientes">
|
||||
<Button variant="outline" onClick={exportPatients} loading={exporting} disabled={patients.length === 0}>
|
||||
<Download size={16} className="mr-2" />
|
||||
Exportar CSV
|
||||
</Button>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Nuevo paciente
|
||||
Nuevo {term('patient_singular').toLowerCase()}
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
@@ -611,7 +614,7 @@ const Pacientes: FC = () => {
|
||||
<EmptyState
|
||||
title="Sin pacientes"
|
||||
subtitle="No se encontraron pacientes."
|
||||
actionLabel="Nuevo paciente"
|
||||
actionLabel={`Nuevo ${term('patient_singular').toLowerCase()}`}
|
||||
onAction={openCreate}
|
||||
/>
|
||||
) : (
|
||||
@@ -717,7 +720,7 @@ const Pacientes: FC = () => {
|
||||
<Modal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
title={editing ? 'Editar paciente' : 'Nuevo paciente'}
|
||||
title={editing ? `Editar ${term('patient_singular').toLowerCase()}` : `Nuevo ${term('patient_singular').toLowerCase()}`}
|
||||
maxWidth="2xl"
|
||||
footer={
|
||||
<>
|
||||
@@ -1263,12 +1266,12 @@ const Pacientes: FC = () => {
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<InfoRow label="Referencia" value={entryDetail.item.reference} icon={FileText} />
|
||||
<InfoRow label="Fecha y hora" value={`${entryDetail.item.date} · ${entryDetail.item.time}`} icon={Calendar} />
|
||||
<InfoRow label="Servicio" value={entryDetail.item.service} icon={Stethoscope} />
|
||||
<InfoRow label="Médico" value={entryDetail.item.doctor || 'Sin asignar'} icon={User} />
|
||||
<InfoRow label={term('service_singular')} value={entryDetail.item.service} icon={Stethoscope} />
|
||||
<InfoRow label={term('doctor_singular')} value={entryDetail.item.doctor || 'Sin asignar'} icon={User} />
|
||||
<InfoRow label="Duración" value={entryDetail.item.duration ? `${entryDetail.item.duration} min` : '-'} />
|
||||
<InfoRow label="Precio" value={`$${entryDetail.item.price}`} />
|
||||
<InfoRow label="Monto pagado" value={`$${entryDetail.item.amount_paid ?? 0}`} />
|
||||
<InfoRow label="Sucursal" value={branchLabels[entryDetail.item.branch || ''] || entryDetail.item.branch || '-'} icon={MapPin} />
|
||||
<InfoRow label="Sucursal" value={branchLabel(entryDetail.item.branch) || '-'} icon={MapPin} />
|
||||
<InfoRow label="Medio" value={mediumLabels[entryDetail.item.medium || ''] || entryDetail.item.medium || '-'} />
|
||||
{entryDetail.item.package_finished && (
|
||||
<InfoRow label="Paquete terminado" value={entryDetail.item.package_finished_date || 'Sí'} />
|
||||
@@ -1351,8 +1354,8 @@ const Pacientes: FC = () => {
|
||||
<InfoRow label="Folio" value={entryDetail.item.name} icon={FileText} />
|
||||
<InfoRow label="Inicio" value={entryDetail.item.date_start || '-'} icon={Calendar} />
|
||||
<InfoRow label="Fin" value={entryDetail.item.date_end || '-'} icon={Calendar} />
|
||||
<InfoRow label="Servicio" value={entryDetail.item.servicio || '-'} icon={Stethoscope} />
|
||||
<InfoRow label="Médico" value={entryDetail.item.doctor || 'Sin asignar'} icon={User} />
|
||||
<InfoRow label={term('service_singular')} value={entryDetail.item.servicio || '-'} icon={Stethoscope} />
|
||||
<InfoRow label={term('doctor_singular')} value={entryDetail.item.doctor || 'Sin asignar'} icon={User} />
|
||||
<InfoRow label="Cosmetóloga" value={entryDetail.item.cosmetologa || 'Sin asignar'} icon={User} />
|
||||
</div>
|
||||
{[
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { odooApi } from '../services/odoo';
|
||||
import type { Paquete, PaqueteCompra, PaqueteConsumo, Service } from '../services/odoo';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { useGlosario } from '../lib/glosario';
|
||||
|
||||
const fmtMoney = (n: number) =>
|
||||
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 });
|
||||
@@ -51,6 +52,7 @@ const emptyForm: FormPaquete = {
|
||||
};
|
||||
|
||||
const Paquetes: FC = () => {
|
||||
const { term } = useGlosario();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const tab = searchParams.get('tab') === 'saldos' ? 'saldos' : 'catalogo';
|
||||
const setTab = (key: string) =>
|
||||
@@ -327,9 +329,9 @@ const Paquetes: FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout title="Paquetes" subtitle="Paquetes de sesiones">
|
||||
<Layout title={term('package_plural')} subtitle="Paquetes de sesiones">
|
||||
<PageHeader
|
||||
title="Paquetes de sesiones"
|
||||
title={`${term('package_plural')} de sesiones`}
|
||||
subtitle="Combos de sesiones a precio cerrado y el saldo que lleva cada paciente"
|
||||
>
|
||||
<Button variant="outline" onClick={() => (tab === 'catalogo' ? loadCatalogo() : loadSaldos())}>
|
||||
@@ -337,7 +339,7 @@ const Paquetes: FC = () => {
|
||||
</Button>
|
||||
{tab === 'catalogo' && esAdmin && (
|
||||
<Button onClick={abrirNuevo}>
|
||||
<Plus size={16} className="mr-2" /> Nuevo paquete
|
||||
<Plus size={16} className="mr-2" /> Nuevo {term('package_singular').toLowerCase()}
|
||||
</Button>
|
||||
)}
|
||||
</PageHeader>
|
||||
@@ -607,7 +609,7 @@ const Paquetes: FC = () => {
|
||||
<Modal
|
||||
isOpen={formOpen}
|
||||
onClose={() => setFormOpen(false)}
|
||||
title={form.id ? 'Editar paquete' : 'Nuevo paquete de sesiones'}
|
||||
title={form.id ? `Editar ${term('package_singular').toLowerCase()}` : `Nuevo ${term('package_singular').toLowerCase()} de sesiones`}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { Star, Wallet, Edit2, Plus, Clock, Tag, Package, Search, Copy, ChevronUp, ChevronDown, ArrowUpToLine } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import {
|
||||
@@ -16,23 +16,8 @@ import {
|
||||
} from '../components/ui';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { odooApi, type Service } from '../services/odoo';
|
||||
|
||||
const categoryOptions = [
|
||||
{ value: '', label: 'Todas las categorías' },
|
||||
{ value: 'consulta', label: 'Consulta' },
|
||||
{ value: 'tratamiento', label: 'Tratamiento' },
|
||||
{ value: 'procedimiento', label: 'Procedimiento' },
|
||||
{ value: 'paquete', label: 'Paquete' },
|
||||
];
|
||||
|
||||
const categoryFormOptions = categoryOptions.filter((o) => o.value !== '');
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
consulta: 'Consulta',
|
||||
tratamiento: 'Tratamiento',
|
||||
procedimiento: 'Procedimiento',
|
||||
paquete: 'Paquete',
|
||||
};
|
||||
import { useGlosario } from '../lib/glosario';
|
||||
import { useServiceCategories } from '../lib/service-categories';
|
||||
|
||||
interface ServiceForm {
|
||||
name: string;
|
||||
@@ -63,6 +48,16 @@ const emptyForm: ServiceForm = {
|
||||
};
|
||||
|
||||
const Servicios: FC = () => {
|
||||
const { term } = useGlosario();
|
||||
const { categories, labelFor } = useServiceCategories();
|
||||
const categoryOptions = useMemo(
|
||||
() => [{ value: '', label: 'Todas las categorías' }, ...categories.map((c) => ({ value: c.code, label: c.name }))],
|
||||
[categories],
|
||||
);
|
||||
const categoryFormOptions = useMemo(
|
||||
() => categories.map((c) => ({ value: c.code, label: c.name })),
|
||||
[categories],
|
||||
);
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -289,11 +284,11 @@ const Servicios: FC = () => {
|
||||
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">
|
||||
<Layout title={term('service_plural')} subtitle="Catálogo de tratamientos">
|
||||
<PageHeader title={term('service_plural')} subtitle="Gestiona precios, paquetes y favoritos">
|
||||
<Button onClick={openCreate}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Nuevo servicio
|
||||
Nuevo {term('service_singular').toLowerCase()}
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
@@ -325,7 +320,7 @@ const Servicios: FC = () => {
|
||||
<EmptyState
|
||||
title="Sin servicios"
|
||||
subtitle={search || categoryFilter ? 'No hay servicios que coincidan con los filtros.' : 'No hay servicios registrados.'}
|
||||
actionLabel="Nuevo servicio"
|
||||
actionLabel={`Nuevo ${term('service_singular').toLowerCase()}`}
|
||||
onAction={openCreate}
|
||||
/>
|
||||
) : (
|
||||
@@ -371,7 +366,7 @@ const Servicios: FC = () => {
|
||||
<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" />
|
||||
{categoryLabels[s.category] || s.category || 'Sin categoría'}
|
||||
{labelFor(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" />
|
||||
@@ -468,7 +463,7 @@ const Servicios: FC = () => {
|
||||
<Modal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => { setModalOpen(false); setEditing(null); setDuplicando(false); }}
|
||||
title={editing ? `Editar: ${editing.name}` : duplicando ? 'Duplicar servicio' : 'Nuevo servicio'}
|
||||
title={editing ? `Editar: ${editing.name}` : duplicando ? `Duplicar ${term('service_singular').toLowerCase()}` : `Nuevo ${term('service_singular').toLowerCase()}`}
|
||||
maxWidth="lg"
|
||||
footer={
|
||||
<>
|
||||
@@ -484,7 +479,7 @@ const Servicios: FC = () => {
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => { setModalOpen(false); setEditing(null); setDuplicando(false); }}>Cancelar</Button>
|
||||
<Button onClick={save} loading={submitting}>{editing ? 'Guardar cambios' : duplicando ? 'Crear copia' : 'Crear servicio'}</Button>
|
||||
<Button onClick={save} loading={submitting}>{editing ? 'Guardar cambios' : duplicando ? 'Crear copia' : `Crear ${term('service_singular').toLowerCase()}`}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { odooApi, type Visita, type VisitaAdjunto, type Doctor, type InventoryItem } from '../services/odoo';
|
||||
import type { BadgeVariant } from '../components/ui/Badge';
|
||||
import { todayLocal } from '../lib/utils';
|
||||
import { useGlosario } from '../lib/glosario';
|
||||
|
||||
const visitaStateLabels: Record<string, string> = {
|
||||
en_curso: 'En curso',
|
||||
@@ -48,6 +49,7 @@ const MAX_ADJUNTO_BYTES = 10 * 1024 * 1024;
|
||||
const hoy = () => todayLocal();
|
||||
|
||||
const Visitas: FC = () => {
|
||||
const { term } = useGlosario();
|
||||
const [visitas, setVisitas] = useState<Visita[]>([]);
|
||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -294,8 +296,8 @@ const Visitas: FC = () => {
|
||||
const hora = (v: Visita) => (v.date_start ? v.date_start.split(' ')[1] || '' : '-');
|
||||
|
||||
return (
|
||||
<Layout title="Visitas" subtitle="Registro de visitas clínicas">
|
||||
<PageHeader title="Visitas" subtitle="Consulta y completa las visitas clínicas del día" />
|
||||
<Layout title={term('visit_plural')} subtitle="Registro de visitas clínicas">
|
||||
<PageHeader title={term('visit_plural')} subtitle="Consulta y completa las visitas clínicas del día" />
|
||||
|
||||
<Card>
|
||||
<Card.Body>
|
||||
@@ -325,9 +327,9 @@ const Visitas: FC = () => {
|
||||
<thead className="bg-theme-bg">
|
||||
<tr>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Hora</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Paciente</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Servicio</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Médico</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">{term('patient_singular')}</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">{term('service_singular')}</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">{term('doctor_singular')}</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden lg:table-cell">Motivo</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Estado</th>
|
||||
</tr>
|
||||
|
||||
@@ -905,6 +905,9 @@ export interface ClinicBrand {
|
||||
logo_claro: string; // fondos oscuros (login SKEEN, sidebar)
|
||||
logo_oscuro: string; // fondos claros (tema HomeNest)
|
||||
pie: string; // línea del pie (login / documentos)
|
||||
url_publica?: string; // URL pública de la instancia (opcional)
|
||||
// Colores de marca (opcional). Vacíos = se usa el color del tema activo.
|
||||
colores?: { primario?: string; acento?: string };
|
||||
}
|
||||
|
||||
export const BRAND_DEFAULT: ClinicBrand = {
|
||||
@@ -914,6 +917,80 @@ export const BRAND_DEFAULT: ClinicBrand = {
|
||||
pie: 'SKEEN Derma Experts · Playas de Rosarito, B.C.',
|
||||
};
|
||||
|
||||
// ---- Glosario / terminología white-label ----
|
||||
export const GLOSSARY_KEYS = [
|
||||
'patient_singular', 'patient_plural',
|
||||
'appointment_singular', 'appointment_plural',
|
||||
'visit_singular', 'visit_plural',
|
||||
'record_singular', 'record_plural',
|
||||
'doctor_singular', 'doctor_plural',
|
||||
'staff_singular', 'staff_plural',
|
||||
'branch_singular', 'branch_plural',
|
||||
'room_singular', 'room_plural',
|
||||
'service_singular', 'service_plural',
|
||||
'package_singular', 'package_plural',
|
||||
'prescription_singular', 'prescription_plural',
|
||||
'treatment_singular', 'treatment_plural',
|
||||
] as const;
|
||||
|
||||
export type GlossaryKey = typeof GLOSSARY_KEYS[number];
|
||||
export type GlossaryTerms = Record<GlossaryKey, string>;
|
||||
export interface GlossaryPack { code: string; name: string; }
|
||||
|
||||
// ---- Sucursales ----
|
||||
export interface Sucursal {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
sequence: number;
|
||||
active: boolean;
|
||||
is_default: boolean;
|
||||
address: string;
|
||||
phone: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
// ---- Categorías de servicio ----
|
||||
export interface ServiceCategory {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
sequence: number;
|
||||
active: boolean;
|
||||
is_system: boolean;
|
||||
}
|
||||
|
||||
// ---- Ajustes de la clínica ----
|
||||
export interface ClinicSettings {
|
||||
nombre: string;
|
||||
telefono: string;
|
||||
email: string;
|
||||
direccion: string;
|
||||
web: string;
|
||||
rfc: string;
|
||||
horario_apertura: string;
|
||||
horario_cierre: string;
|
||||
zona_horaria: string;
|
||||
url_publica: string;
|
||||
moneda_base: string;
|
||||
nota_ticket: string;
|
||||
}
|
||||
|
||||
export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
|
||||
nombre: BRAND_DEFAULT.nombre,
|
||||
telefono: '',
|
||||
email: '',
|
||||
direccion: '',
|
||||
web: '',
|
||||
rfc: '',
|
||||
horario_apertura: '08:00',
|
||||
horario_cierre: '19:00',
|
||||
zona_horaria: 'America/Tijuana',
|
||||
url_publica: '',
|
||||
moneda_base: 'MXN',
|
||||
nota_ticket: '',
|
||||
};
|
||||
|
||||
export interface FrontendUser {
|
||||
id: number;
|
||||
login: string;
|
||||
@@ -1397,6 +1474,79 @@ export const odooApi = {
|
||||
return data;
|
||||
},
|
||||
|
||||
// ---- Glosario / terminología ----
|
||||
async getGlosario(): Promise<{ status: string; pack: string; terms: GlossaryTerms; message?: string }> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/glosario`);
|
||||
return data;
|
||||
},
|
||||
|
||||
async getGlosarioPacks(): Promise<{ status: string; packs: GlossaryPack[]; message?: string }> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/glosario/packs`);
|
||||
return data;
|
||||
},
|
||||
|
||||
async putGlosario(payload: { pack?: string; overrides?: Partial<GlossaryTerms>; motivo?: string }): Promise<{ status: string; pack: string; terms: GlossaryTerms; message?: string }> {
|
||||
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/glosario`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
// ---- Sucursales ----
|
||||
async getSucursales(opts?: { includeInactive?: boolean }): Promise<{ status: string; sucursales: Sucursal[]; total: number; message?: string }> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/sucursales`, {
|
||||
params: opts?.includeInactive ? { include_inactive: 1 } : undefined,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
async createSucursal(payload: Partial<Sucursal>): Promise<{ status: string; sucursal: Sucursal; message?: string }> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/sucursales`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async updateSucursal(id: number, payload: Partial<Sucursal>): Promise<{ status: string; sucursal: Sucursal; message?: string }> {
|
||||
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/sucursales/${id}`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async deleteSucursal(id: number): Promise<{ status: string; sucursal: Sucursal; en_uso: number; message: string }> {
|
||||
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/sucursales/${id}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
// ---- Categorías de servicio ----
|
||||
async getServiceCategories(opts?: { includeInactive?: boolean }): Promise<{ status: string; categories: ServiceCategory[]; total: number; message?: string }> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/service-categories`, {
|
||||
params: opts?.includeInactive ? { include_inactive: 1 } : undefined,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
async createServiceCategory(payload: { name: string; code?: string; sequence?: number; active?: boolean }): Promise<{ status: string; category: ServiceCategory; message?: string }> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/service-categories`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async updateServiceCategory(id: number, payload: { name?: string; code?: string; sequence?: number; active?: boolean }): Promise<{ status: string; category: ServiceCategory; message?: string }> {
|
||||
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/service-categories/${id}`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async deleteServiceCategory(id: number): Promise<{ status: string; category?: ServiceCategory; en_uso?: number; message?: string }> {
|
||||
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/service-categories/${id}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
// ---- Ajustes de la clínica ----
|
||||
async getClinicSettings(): Promise<{ status: string; settings: ClinicSettings; message?: string }> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/clinic-settings`);
|
||||
return data;
|
||||
},
|
||||
|
||||
async putClinicSettings(settings: Partial<ClinicSettings> & { motivo?: string }): Promise<{ status: string; settings: ClinicSettings; message?: string }> {
|
||||
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/clinic-settings`, settings);
|
||||
return data;
|
||||
},
|
||||
|
||||
async updateRolePermissions(matrix: Record<string, FrontendRole[]>): Promise<{ status: string; matrix: Record<string, FrontendRole[]>; message?: string }> {
|
||||
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/roles/permissions`, { matrix });
|
||||
return data;
|
||||
@@ -1430,7 +1580,7 @@ export const odooApi = {
|
||||
return data;
|
||||
},
|
||||
|
||||
async createQuickAppointment(payload: { name: string; phone: string; date: string; time: number; doctor_id?: number; notes?: string; duration?: number }): Promise<{ status: string; appointment: Appointment; patient: Patient; patient_created: boolean; message?: string }> {
|
||||
async createQuickAppointment(payload: { name: string; phone: string; date: string; time: number; doctor_id?: number; notes?: string; duration?: number; branch?: string }): Promise<{ status: string; appointment: Appointment; patient: Patient; patient_created: boolean; message?: string }> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/appointments/quick`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user