fase 3.6: i18n espanol/ingles (530 claves, provider con fallback, interpolacion y pluralizacion, selector de idioma en menu de usuario y configuracion, convivencia con el glosario de negocio, superficies principales y paginas publicas traducidas sin alterar el espanol)

This commit is contained in:
2026-10-11 21:19:43 +00:00
parent 3ed0d327d2
commit e749a31d10
22 changed files with 2022 additions and 411 deletions

View File

@@ -5,6 +5,7 @@ import { ToastProvider, Button } from './components/ui';
import ErrorBoundary from './components/ErrorBoundary';
import { PwaInstallPrompt, OfflineBanner } from './components/PwaNotices';
import { AuthProvider, useAuth } from './lib/auth';
import { useI18n } from './lib/i18n';
import { GlosarioProvider } from './lib/glosario';
import { SucursalesProvider } from './lib/sucursales';
import { ServiceCategoriesProvider } from './lib/service-categories';
@@ -52,17 +53,18 @@ const FullScreenLoader: FC = () => (
// Pantalla cuando el servidor no responde al arrancar (red lenta o caída):
// en lugar de quedarse cargando para siempre, ofrece reintentar.
const BootErrorScreen: FC<{ onRetry: () => void }> = ({ onRetry }) => (
const BootErrorScreen: FC<{ onRetry: () => void }> = ({ onRetry }) => {
const { t } = useI18n();
return (
<div className="min-h-screen flex items-center justify-center p-6 bg-theme-bg">
<div className="max-w-md w-full space-y-4 rounded-2xl border border-theme-border bg-theme-surface p-8 shadow-soft text-center">
<h1 className="font-heading text-xl text-theme-heading">No se pudo conectar</h1>
<p className="text-sm text-theme-muted">
El servidor no responde en este momento. Revisa tu conexión a internet e intenta de nuevo.
</p>
<Button onClick={onRetry}>Reintentar</Button>
<h1 className="font-heading text-xl text-theme-heading">{t('app.bootTitle')}</h1>
<p className="text-sm text-theme-muted">{t('app.bootBody')}</p>
<Button onClick={onRetry}>{t('app.retry')}</Button>
</div>
</div>
);
};
const RequireAuth: FC<{ children: ReactNode; href: string }> = ({ children, href }) => {
const { user, loading, bootError, retryBoot, canSee } = useAuth();
@@ -144,6 +146,7 @@ const LiquidacionesRoute: FC = () => {
const HomeRoute: FC = () => {
const { user, canSee } = useAuth();
const { t } = useI18n();
if (user?.role === 'medico') return <Navigate to="/agenda" replace />;
if (canSee('/')) return <Dashboard />;
for (const href of LANDING_CANDIDATOS) {
@@ -151,8 +154,8 @@ const HomeRoute: FC = () => {
}
return (
<div className="min-h-screen flex flex-col items-center justify-center gap-3">
<p className="text-theme-heading font-semibold">Sin acceso a módulos</p>
<p className="text-sm text-theme-muted">Tu usuario no tiene secciones habilitadas. Contacta al administrador.</p>
<p className="text-theme-heading font-semibold">{t('app.noAccessTitle')}</p>
<p className="text-sm text-theme-muted">{t('app.noAccessBody')}</p>
</div>
);
};

View File

@@ -37,7 +37,9 @@ import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
import GlobalSearch from './GlobalSearch';
import ThemePicker from './ThemePicker';
import NotificationBell from './NotificationBell';
import { LanguageToggle } from './LanguageSelect';
import { useAuth, ROLE_LABELS } from '../lib/auth';
import { useI18n, useClasicoLabel } from '../lib/i18n';
import { useClinicSettings } from '../lib/clinic-settings';
import { useChatUnread } from '../lib/chat';
import { useQuickLinks } from '../lib/quicklinks';
@@ -59,6 +61,8 @@ interface MenuChild {
interface MenuEntry {
label: string;
/** Etiqueta canónica (español) para estado/keys; `label` es la visible. */
id?: string;
icon: FC<{ size?: number | string; className?: string }>;
href?: string;
children?: MenuChild[];
@@ -146,6 +150,8 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
const location = useLocation();
const navigate = useNavigate();
const { user, canSee, hasRole, logout, brand } = useAuth();
const { t } = useI18n();
const cl = useClasicoLabel();
const { membresias: membresiasCfg, compras: comprasCfg, comisiones: comisionesCfg } = useClinicSettings();
const [sidebarOpen, setSidebarOpen] = useState(false);
const chatUnread = useChatUnread();
@@ -164,8 +170,13 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
if (comisionesCfg.activo) {
tree = [...tree, { label: 'Liquidaciones', icon: Receipt, href: '/comisiones/liquidaciones' }];
}
return tree;
}, [membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo]);
return tree.map((e) => ({
...e,
id: e.label,
label: cl(e.label),
children: e.children?.map((c) => ({ ...c, label: cl(c.label) })),
}));
}, [membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo, cl]);
const menuLeavesT = useMemo(
() => menuTreeT.flatMap((e) =>
e.children
@@ -275,7 +286,7 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
const current = `${location.pathname}${location.search}`;
return menuTreeT
.filter((e) => e.children?.some((c) => current.startsWith(c.href.split('?')[0]) && (!c.href.includes('?') || current.includes(c.href.split('?')[1]))))
.map((e) => e.label);
.map((e) => e.id as string);
});
const toggleGroup = (label: string) =>
@@ -296,7 +307,7 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
const base = c.href.split('?')[0];
return current === c.href || (current.startsWith(base) && (!c.href.includes('?') || current.includes(c.href.split('?')[1])));
}))
.map((e) => e.label);
.map((e) => e.id as string);
if (!abiertos.length) return;
setOpenGroups((prev) => {
const faltan = abiertos.filter((l) => !prev.includes(l));
@@ -325,7 +336,7 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
{quickItems.length > 0 && quick.mode === 'menu' && (
<div className="mb-2">
<div className="flex items-center justify-between px-4 py-2" style={{ backgroundColor: 'var(--sidebar-group-bg)' }}>
<p className="text-[10px] uppercase tracking-wider font-medium" style={{ color: 'var(--sidebar-group-text)' }}>Accesos rápidos</p>
<p className="text-[10px] uppercase tracking-wider font-medium" style={{ color: 'var(--sidebar-group-text)' }}>{t('nav.quickLinks')}</p>
<span className="flex items-center gap-1">
<QuickLinksModePicker direction="down" align="right" />
<button
@@ -333,7 +344,7 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
onClick={() => quick.setEditing(!quick.editing)}
className="hover:text-white transition-colors"
style={{ color: 'var(--sidebar-group-text)' }}
title={quick.editing ? 'Terminar edición' : 'Editar accesos rápidos'}
title={quick.editing ? t('dock.finishEditing') : t('dock.editQuick')}
>
{quick.editing ? <X size={13} /> : <Pencil size={12} />}
</button>
@@ -363,15 +374,15 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
{quick.editing && (
<span className="flex items-center gap-0.5 shrink-0 pr-2">
<button type="button" onClick={() => quick.move(item.href, -1)} disabled={idx === 0}
className="hover:text-white disabled:opacity-30 transition-colors" style={{ color: 'var(--sidebar-text)' }} title="Subir">
className="hover:text-white disabled:opacity-30 transition-colors" style={{ color: 'var(--sidebar-text)' }} title={t('dock.up')}>
<ChevronUp size={14} />
</button>
<button type="button" onClick={() => quick.move(item.href, 1)} disabled={idx === quickItems.length - 1}
className="hover:text-white disabled:opacity-30 transition-colors" style={{ color: 'var(--sidebar-text)' }} title="Bajar">
className="hover:text-white disabled:opacity-30 transition-colors" style={{ color: 'var(--sidebar-text)' }} title={t('dock.down')}>
<ChevronDown size={14} />
</button>
<button type="button" onClick={() => quick.remove(item.href)}
className="hover:text-rose-400 transition-colors" style={{ color: 'var(--sidebar-text)' }} title="Quitar">
className="hover:text-rose-400 transition-colors" style={{ color: 'var(--sidebar-text)' }} title={t('dock.remove')}>
<X size={14} />
</button>
</span>
@@ -396,7 +407,7 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
const active = isActive(entry.href as string);
const pinned = quick.isPinned(entry.href as string);
return (
<li key={entry.label} className="group flex items-center">
<li key={entry.id} className="group flex items-center">
<Link
to={entry.href as string}
onClick={() => setSidebarOpen(false)}
@@ -420,7 +431,7 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
pinned ? 'text-amber-400' : 'opacity-0 group-hover:opacity-100 hover:text-amber-400'
}`}
style={{ color: pinned ? undefined : 'var(--sidebar-text)' }}
title={pinned ? 'Quitar de accesos rápidos' : 'Fijar en accesos rápidos'}
title={pinned ? t('user.unpinQuick') : t('user.pinQuick')}
>
<Star size={14} fill={pinned ? 'currentColor' : 'none'} />
</button>
@@ -429,13 +440,13 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
}
// Entry con submenú (treeview AdminLTE)
const open = openGroups.includes(entry.label);
const open = openGroups.includes(entry.id as string);
const childActive = leaves.some((c) => isActive(c.href));
return (
<li key={entry.label}>
<li key={entry.id}>
<button
type="button"
onClick={() => toggleGroup(entry.label)}
onClick={() => toggleGroup(entry.id as string)}
className="w-full flex items-center space-x-3 px-4 py-2.5 text-sm transition-colors"
style={{
backgroundColor: childActive ? 'var(--sidebar-active-bg)' : 'transparent',
@@ -488,7 +499,7 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
pinned ? 'text-amber-400' : 'opacity-0 group-hover:opacity-100 hover:text-amber-400'
}`}
style={{ color: pinned ? undefined : 'var(--sidebar-text)' }}
title={pinned ? 'Quitar de accesos rápidos' : 'Fijar en accesos rápidos'}
title={pinned ? t('user.unpinQuick') : t('user.pinQuick')}
>
<Star size={13} fill={pinned ? 'currentColor' : 'none'} />
</button>
@@ -513,18 +524,22 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">{user?.name || 'Usuario'}</p>
<p className="text-xs truncate" style={{ color: 'var(--sidebar-text)' }}>{user ? ROLE_LABELS[user.role] : ''}</p>
<p className="text-xs truncate" style={{ color: 'var(--sidebar-text)' }}>{user ? (ROLE_LABELS[user.role] ? t(`role.${user.role}`) : (user.role_name || user.role)) : ''}</p>
</div>
<button
onClick={handleLogout}
className="p-2 transition-colors hover:text-white"
style={{ color: 'var(--sidebar-text)' }}
title="Cerrar sesión"
aria-label="Cerrar sesión"
title={t('user.logout')}
aria-label={t('user.logout')}
>
<LogOut size={17} />
</button>
</div>
<div className="mt-3 flex items-center justify-between">
<span className="text-[11px]" style={{ color: 'var(--sidebar-text)' }}>{t('user.language')}</span>
<LanguageToggle color="var(--sidebar-text)" />
</div>
</div>
</>
);
@@ -548,7 +563,7 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
size="sm"
className="lg:hidden text-white"
onClick={() => setSidebarOpen(true)}
aria-label="Abrir menú"
aria-label={t('user.openMenu')}
>
<Menu size={20} />
</Button>

View File

@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom';
import { Search, Users, Calendar, Briefcase, Loader2, X } from 'lucide-react';
import { odooApi } from '../services/odoo';
import { cn } from '../lib/utils';
import { useI18n } from '../lib/i18n';
interface Results {
patients: { id: number; name: string; phone: string }[];
@@ -24,7 +25,9 @@ interface GlobalSearchProps {
* Búsqueda global del header: UN solo campo con resultados en vivo
* (debounce 250 ms) navegables con flechas + Enter.
*/
export const GlobalSearch: FC<GlobalSearchProps> = ({ placeholder = 'Buscar paciente, cita, servicio...', className = '' }) => {
export const GlobalSearch: FC<GlobalSearchProps> = ({ placeholder, className = '' }) => {
const { t } = useI18n();
const ph = placeholder ?? t('search.placeholder');
const navigate = useNavigate();
const [query, setQuery] = useState('');
const [results, setResults] = useState<Results>(EMPTY);
@@ -155,10 +158,10 @@ export const GlobalSearch: FC<GlobalSearchProps> = ({ placeholder = 'Buscar paci
role="combobox"
aria-expanded={showDropdown}
aria-autocomplete="list"
aria-label="Búsqueda global"
aria-label={t('search.aria')}
autoComplete="off"
value={query}
placeholder={placeholder}
placeholder={ph}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => { if (options.length > 0) setOpen(true); }}
onKeyDown={onKeyDown}
@@ -169,8 +172,8 @@ export const GlobalSearch: FC<GlobalSearchProps> = ({ placeholder = 'Buscar paci
<button
type="button"
onClick={() => { setQuery(''); setResults(EMPTY); setOpen(false); inputRef.current?.focus(); }}
title="Limpiar búsqueda"
aria-label="Limpiar búsqueda"
title={t('search.clear')}
aria-label={t('search.clear')}
className="ml-1 shrink-0 rounded-full p-0.5 text-theme-muted hover:text-theme-heading"
>
<X size={13} />
@@ -184,7 +187,7 @@ export const GlobalSearch: FC<GlobalSearchProps> = ({ placeholder = 'Buscar paci
className="absolute right-0 top-full mt-2 w-80 sm:w-96 max-h-[70vh] overflow-y-auto rounded-2xl border border-theme-border bg-theme-surface shadow-card z-50"
>
{!searching && options.length === 0 && (
<p className="px-4 py-3 text-xs text-theme-muted">Sin resultados para "{query.trim()}". Prueba con otro nombre o teléfono.</p>
<p className="px-4 py-3 text-xs text-theme-muted">{t('search.noResults', { q: query.trim() })}</p>
)}
{options.map((opt, idx) => {
@@ -193,7 +196,7 @@ export const GlobalSearch: FC<GlobalSearchProps> = ({ placeholder = 'Buscar paci
return (
<div key={opt.key} className={firstOfGroup ? (idx === 0 ? 'p-1.5' : 'p-1.5 border-t border-theme-border') : ''}>
{firstOfGroup && (
<p className="px-2.5 py-1 text-[10px] uppercase tracking-wider text-theme-muted font-medium">{opt.group}</p>
<p className="px-2.5 py-1 text-[10px] uppercase tracking-wider text-theme-muted font-medium">{t(`search.group.${opt.group}`)}</p>
)}
<button
type="button"
@@ -216,7 +219,7 @@ export const GlobalSearch: FC<GlobalSearchProps> = ({ placeholder = 'Buscar paci
{options.length > 0 && (
<p className="px-4 py-2 border-t border-theme-border text-[10px] text-theme-muted">
Usa ↑ ↓ para moverte y Enter para abrir
{t('search.hintKeys')}
</p>
)}
</div>

View File

@@ -44,8 +44,10 @@ import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
import GlobalSearch from './GlobalSearch';
import ThemePicker from './ThemePicker';
import NotificationBell from './NotificationBell';
import { LanguageToggle } from './LanguageSelect';
import { useAuth } from '../lib/auth';
import { useGlosario, traducirMenu, traducirEtiqueta } from '../lib/glosario';
import { useI18n, useNavLabel } from '../lib/i18n';
import { useClinicSettings } from '../lib/clinic-settings';
import { useChatUnread } from '../lib/chat';
import { useQuickLinks } from '../lib/quicklinks';
@@ -122,10 +124,12 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
const navigate = useNavigate();
const { user, canSee, hasRole, logout, brand } = useAuth();
const { term } = useGlosario();
const { t } = useI18n();
const navLabel = useNavLabel();
const { membresias: membresiasCfg, compras: comprasCfg, comisiones: comisionesCfg, licencia: licenciaCfg } = useClinicSettings();
const topNavItems = useMemo(
() => TOP_NAV_ITEMS.map((it) => ({ ...it, label: traducirEtiqueta(it.label, term) })),
[term]
() => TOP_NAV_ITEMS.map((it) => ({ ...it, label: navLabel(traducirEtiqueta(it.label, term)) })),
[term, navLabel]
);
const menuSections = useMemo(() => {
let base = MENU_SECTIONS;
@@ -151,8 +155,15 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
.map((s) => ({ ...s, items: s.items.filter((i) => i.href !== '/chat') }))
.filter((s) => s.items.length > 0);
}
return traducirMenu(base, term);
}, [term, membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo, licenciaCfg]);
return traducirMenu(base, term).map((s, idx) => ({
...s,
// `id` conserva la etiqueta canónica en español para el estado de
// expansión y las claves de React; `label` es la etiqueta visible.
id: base[idx].label,
label: navLabel(s.label),
items: s.items.map((it) => ({ ...it, label: navLabel(it.label) })),
}));
}, [term, navLabel, membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo, licenciaCfg]);
const [menuOpen, setMenuOpen] = useState(false);
const [expanded, setExpanded] = useState<string[]>(['Clínica']);
const chatUnread = useChatUnread();
@@ -273,7 +284,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
<div className="px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" className="lg:hidden" style={{ color: 'var(--text-heading)' }} onClick={() => setMenuOpen(true)} aria-label="Abrir menú">
<Button variant="ghost" size="sm" className="lg:hidden" style={{ color: 'var(--text-heading)' }} onClick={() => setMenuOpen(true)} aria-label={t('user.openMenu')}>
<Menu size={22} />
</Button>
<Link to="/" className="flex items-center gap-2">
@@ -323,7 +334,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
e.currentTarget.style.color = 'var(--text-muted)';
}}
>
Más <ChevronDown size={14} />
{t('user.more')} <ChevronDown size={14} />
</button>
<div className="absolute top-full left-0 mt-2 w-56 rounded-2xl shadow-card border opacity-0 invisible group-hover:opacity-100 group-hover:visible transition z-40" style={{ backgroundColor: 'var(--surface)', borderColor: 'var(--border)' }}>
<div className="p-2">
@@ -356,6 +367,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
<GlobalSearch className="hidden md:block" />
<NotificationBell buttonClassName="p-2 rounded-full relative transition text-theme-muted hover:bg-theme-accent-bg hover:text-theme-heading" />
<ThemePicker />
<LanguageToggle color="var(--text-muted)" />
<div className="hidden sm:flex items-center gap-2 pl-2 border-l" style={{ borderColor: 'var(--border)' }}>
<div className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold" style={{ backgroundColor: 'var(--success)', color: 'var(--success-text)' }}>
{initials || 'US'}
@@ -369,7 +381,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--text-muted)';
}}
title="Cerrar sesión"
title={t('user.logout')}
>
<LogOut size={16} />
</button>
@@ -385,7 +397,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
<div className="rounded-2xl p-4 border" style={{ backgroundColor: 'var(--bg)', borderColor: 'var(--border)' }}>
<div className="flex items-center gap-2 mb-3">
<Sparkles size={16} style={{ color: 'var(--accent)' }} />
<span className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>Meta del mes</span>
<span className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>{t('user.goalMonth')}</span>
</div>
{goal && goal.goal > 0 ? (
<button onClick={hasRole('admin') ? openGoalModal : undefined} className={`w-full text-left ${hasRole('admin') ? 'cursor-pointer' : 'cursor-default'}`}>
@@ -399,7 +411,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
</button>
) : (
<Button variant="outline" size="sm" className="w-full" onClick={openGoalModal}>
<Target size={14} className="mr-1.5" /> Definir meta
<Target size={14} className="mr-1.5" /> {t('user.goalDefine')}
</Button>
)}
</div>
@@ -410,7 +422,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
<div className="w-full flex items-center justify-between px-4 py-3">
<span className="text-sm font-medium flex items-center gap-2" style={{ color: 'var(--text-heading)' }}>
<Star size={14} style={{ color: 'var(--accent)' }} />
Accesos rápidos
{t('nav.quickLinks')}
</span>
<span className="flex items-center gap-1">
<QuickLinksModePicker direction="down" align="right" />
@@ -418,7 +430,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
type="button"
onClick={() => quick.setEditing(!quick.editing)}
style={{ color: 'var(--text-muted)' }}
title={quick.editing ? 'Terminar edición' : 'Editar accesos rápidos'}
title={quick.editing ? t('dock.finishEditing') : t('dock.editQuick')}
>
{quick.editing ? <X size={13} /> : <Pencil size={12} />}
</button>
@@ -456,15 +468,15 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
{quick.editing && (
<span className="flex items-center gap-0.5 shrink-0">
<button type="button" onClick={() => quick.move(item.href, -1)} disabled={idx === 0}
className="disabled:opacity-30" style={{ color: 'var(--text-muted)' }} title="Subir">
className="disabled:opacity-30" style={{ color: 'var(--text-muted)' }} title={t('dock.up')}>
<ChevronUp size={14} />
</button>
<button type="button" onClick={() => quick.move(item.href, 1)} disabled={idx === quickItems.length - 1}
className="disabled:opacity-30" style={{ color: 'var(--text-muted)' }} title="Bajar">
className="disabled:opacity-30" style={{ color: 'var(--text-muted)' }} title={t('dock.down')}>
<ChevronDown size={14} />
</button>
<button type="button" onClick={() => quick.remove(item.href)}
style={{ color: 'var(--text-muted)' }} title="Quitar">
style={{ color: 'var(--text-muted)' }} title={t('dock.remove')}>
<X size={14} />
</button>
</span>
@@ -476,16 +488,16 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
</div>
)}
{visibleSections.map((section) => (
<div key={section.label} className="rounded-xl border overflow-hidden" style={{ backgroundColor: 'var(--bg)', borderColor: 'var(--border)' }}>
<div key={section.id} className="rounded-xl border overflow-hidden" style={{ backgroundColor: 'var(--bg)', borderColor: 'var(--border)' }}>
<button
onClick={() => toggleSection(section.label)}
onClick={() => toggleSection(section.id)}
className="w-full flex items-center justify-between px-4 py-3 text-sm font-medium"
style={{ color: 'var(--text-heading)' }}
>
{section.label}
<ChevronDown size={14} className={`transition-transform ${expanded.includes(section.label) ? 'rotate-180' : ''}`} style={{ color: 'var(--text-muted)' }} />
<ChevronDown size={14} className={`transition-transform ${expanded.includes(section.id) ? 'rotate-180' : ''}`} style={{ color: 'var(--text-muted)' }} />
</button>
{expanded.includes(section.label) && (
{expanded.includes(section.id) && (
<ul className="px-2 pb-2 space-y-1">
{section.items.map((item) => {
const Icon = item.icon;
@@ -554,7 +566,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
<aside className="fixed inset-y-0 left-0 w-80 z-50 flex flex-col overflow-y-auto lg:hidden" style={{ backgroundColor: 'var(--surface)' }}>
<div className="flex items-center justify-between px-5 h-16 border-b" style={{ borderColor: 'var(--border)' }}>
<img src={brand.logo_oscuro} alt={brand.nombre} className="h-8 w-auto object-contain" />
<Button variant="ghost" size="sm" onClick={() => setMenuOpen(false)} aria-label="Cerrar menú">
<Button variant="ghost" size="sm" onClick={() => setMenuOpen(false)} aria-label={t('user.closeMenu')}>
<X size={22} />
</Button>
</div>
@@ -562,7 +574,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
{quickItems.length > 0 && (
<>
<p className="px-2 pt-1 text-[10px] uppercase tracking-wider font-medium flex items-center gap-1.5" style={{ color: 'var(--text-muted)' }}>
<Star size={11} style={{ color: 'var(--accent)' }} /> Accesos rápidos
<Star size={11} style={{ color: 'var(--accent)' }} /> {t('nav.quickLinks')}
</p>
{quickItems.map((item) => {
const Icon = item.icon;
@@ -632,7 +644,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--accent-bg)'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<LogOut size={18} /> Cerrar sesión
<LogOut size={18} /> {t('user.logout')}
</button>
</div>
</aside>
@@ -642,18 +654,18 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
<Modal
isOpen={goalModalOpen}
onClose={() => setGoalModalOpen(false)}
title="Meta de ventas del mes"
title={t('user.goalTitle')}
maxWidth="sm"
footer={
<>
<Button variant="outline" onClick={() => setGoalModalOpen(false)}>Cancelar</Button>
<Button onClick={saveGoal} loading={goalSaving}>Guardar meta</Button>
<Button variant="outline" onClick={() => setGoalModalOpen(false)}>{t('common.cancel')}</Button>
<Button onClick={saveGoal} loading={goalSaving}>{t('user.goalSave')}</Button>
</>
}
>
<div className="space-y-3">
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>Define el objetivo de ventas (MXN) para el mes en curso.</p>
<Input label="Meta mensual (MXN)" type="number" min={0} step={1000} value={goalInput} onChange={(e) => setGoalInput(e.target.value)} placeholder="ej. 500000" />
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>{t('user.goalBody')}</p>
<Input label={t('user.goalField')} type="number" min={0} step={1000} value={goalInput} onChange={(e) => setGoalInput(e.target.value)} placeholder="ej. 500000" />
</div>
</Modal>
</div>

View File

@@ -0,0 +1,63 @@
import type { FC } from 'react';
import { Languages } from 'lucide-react';
import { useI18n, IDIOMAS, type Locale } from '../lib/i18n';
// Selector de idioma. Dos variantes:
// - LanguageToggle: control segmentado ES | EN, para el pie del menú lateral.
// - LanguageSelect: desplegable, para Configuración.
// El idioma elegido persiste en localStorage (skeen_idioma) por navegador.
export const LanguageToggle: FC<{ className?: string; color?: string }> = ({ className = '', color }) => {
const { locale, setLocale, t } = useI18n();
return (
<div
className={`inline-flex items-center rounded-lg overflow-hidden border ${className}`}
style={{ borderColor: color || 'rgba(255,255,255,0.15)' }}
role="group"
aria-label={t('user.language')}
title={t('user.language')}
>
<span className="px-1.5 py-1 opacity-60" style={{ color }} aria-hidden="true">
<Languages size={14} />
</span>
{IDIOMAS.map((i) => {
const active = locale === i.code;
return (
<button
key={i.code}
type="button"
onClick={() => setLocale(i.code)}
aria-pressed={active}
className={`px-2 py-1 text-[11px] font-semibold transition-colors ${
active ? 'bg-theme-accent text-white' : 'hover:bg-white/10'
}`}
style={active ? undefined : { color: color || undefined }}
>
{i.label}
</button>
);
})}
</div>
);
};
export const LanguageSelect: FC<{ className?: string }> = ({ className = '' }) => {
const { locale, setLocale, t } = useI18n();
return (
<label className={`flex flex-col gap-1 ${className}`}>
<span className="text-xs font-medium text-theme-muted">{t('user.language')}</span>
<select
value={locale}
onChange={(e) => setLocale(e.target.value as Locale)}
aria-label={t('user.language')}
className="rounded-xl border border-theme-border bg-theme-surface px-3 py-2 text-sm text-theme-heading"
>
{IDIOMAS.map((i) => (
<option key={i.code} value={i.code}>{i.nombre}</option>
))}
</select>
</label>
);
};
export default LanguageToggle;

View File

@@ -3,6 +3,7 @@ import { useEffect, useRef, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Bell, Boxes, CalendarX, Clock, Target, MessageSquare } from 'lucide-react';
import { odooApi, type AppNotification } from '../services/odoo';
import { useI18n } from '../lib/i18n';
const TIPO_ICON: Record<string, typeof Boxes> = {
stock_bajo: Boxes,
@@ -24,6 +25,7 @@ interface NotificationBellProps {
export const NotificationBell: FC<NotificationBellProps> = ({ buttonClassName }) => {
const navigate = useNavigate();
const { t } = useI18n();
const [notifications, setNotifications] = useState<AppNotification[]>([]);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(true);
@@ -68,8 +70,8 @@ export const NotificationBell: FC<NotificationBellProps> = ({ buttonClassName })
<button
type="button"
onClick={() => setOpen((v) => !v)}
title="Notificaciones"
aria-label="Notificaciones"
title={t('notif.title')}
aria-label={t('notif.title')}
className={buttonClassName || 'p-2 rounded-full relative transition text-theme-muted hover:bg-theme-accent-bg hover:text-theme-heading'}
>
<Bell size={18} />
@@ -83,12 +85,12 @@ export const NotificationBell: FC<NotificationBellProps> = ({ buttonClassName })
{open && (
<div className="absolute right-0 top-full mt-2 z-50 w-80 rounded-2xl border border-theme-border bg-theme-surface shadow-card overflow-hidden">
<p className="px-4 py-2.5 text-xs font-medium text-theme-muted uppercase border-b border-theme-border">
Notificaciones
{t('notif.title')}
</p>
{loading && notifications.length === 0 ? (
<p className="text-sm text-theme-muted text-center py-6">Cargando...</p>
<p className="text-sm text-theme-muted text-center py-6">{t('notif.loading')}</p>
) : notifications.length === 0 ? (
<p className="text-sm text-theme-muted text-center py-6">Sin notificaciones</p>
<p className="text-sm text-theme-muted text-center py-6">{t('notif.empty')}</p>
) : (
<ul className="max-h-80 overflow-y-auto">
{notifications.map((n) => {

View File

@@ -2,6 +2,7 @@ import type { FC } from 'react';
import { useEffect, useState } from 'react';
import { Download, Share, WifiOff, X } from 'lucide-react';
import { Button } from './ui';
import { useI18n } from '../lib/i18n';
// Avisos de PWA: invitación a instalar (Android/desktop vía
// `beforeinstallprompt`, iOS con guía manual) y banner de sin conexión.
@@ -25,6 +26,7 @@ const esIOS = (): boolean =>
typeof navigator !== 'undefined' && /iphone|ipad|ipod/i.test(navigator.userAgent);
export const PwaInstallPrompt: FC = () => {
const { t } = useI18n();
const [deferred, setDeferred] = useState<BeforeInstallPromptEvent | null>(null);
const [visible, setVisible] = useState(false);
@@ -89,26 +91,25 @@ export const PwaInstallPrompt: FC = () => {
<Download size={18} />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-theme-heading">Instalar la app</p>
<p className="text-sm font-medium text-theme-heading">{t('pwa.installTitle')}</p>
{deferred ? (
<p className="mt-0.5 text-xs text-theme-muted">
Accede más rápido desde tu pantalla de inicio.
{t('pwa.installHint')}
</p>
) : (
<p className="mt-0.5 text-xs text-theme-muted inline-flex flex-wrap items-center gap-1">
Toca <Share size={12} className="inline" /> Compartir y luego
«Añadir a pantalla de inicio».
{t('pwa.iosStepPrefix')} <Share size={12} className="inline" /> {t('pwa.iosStepSuffix')}
</p>
)}
{deferred && (
<div className="mt-2">
<Button size="sm" onClick={instalar}>Instalar</Button>
<Button size="sm" onClick={instalar}>{t('pwa.install')}</Button>
</div>
)}
</div>
<button
type="button"
aria-label="Descartar"
aria-label={t('pwa.dismiss')}
onClick={descartar}
className="rounded-lg p-1 text-theme-muted transition-colors hover:bg-theme-accent-bg hover:text-theme-heading"
>
@@ -120,6 +121,7 @@ export const PwaInstallPrompt: FC = () => {
};
export const OfflineBanner: FC = () => {
const { t } = useI18n();
const [online, setOnline] = useState(() => (typeof navigator === 'undefined' ? true : navigator.onLine));
useEffect(() => {
@@ -139,7 +141,7 @@ export const OfflineBanner: FC = () => {
<div className="fixed inset-x-0 top-0 z-50 flex justify-center px-4 pt-3 pointer-events-none">
<div className="pointer-events-auto inline-flex items-center gap-2 rounded-full border border-theme-border bg-theme-surface px-3 py-1.5 text-xs text-theme-muted shadow-soft">
<WifiOff size={14} className="text-theme-accent" />
Sin conexión — los cambios se guardarán al recuperar la red
{t('pwa.offline')}
</div>
</div>
);

View File

@@ -2,7 +2,8 @@ import type { FC } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { List, PanelRight, PanelBottom, Settings2 } from 'lucide-react';
import { useQuickLinks, QUICK_LINKS_MODE_LABELS, type QuickLinksMode } from '../lib/quicklinks';
import { useQuickLinks, type QuickLinksMode } from '../lib/quicklinks';
import { useI18n } from '../lib/i18n';
interface DockItem {
label: string;
@@ -27,6 +28,7 @@ export const QuickLinksModePicker: FC<{ direction?: 'up' | 'down'; align?: 'left
align = 'right',
}) => {
const quick = useQuickLinks();
const { t } = useI18n();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
@@ -45,7 +47,7 @@ export const QuickLinksModePicker: FC<{ direction?: 'up' | 'down'; align?: 'left
type="button"
onClick={() => setOpen(!open)}
className="flex items-center justify-center w-8 h-8 rounded-xl text-theme-muted hover:bg-theme-accent-bg hover:text-theme-heading transition-colors"
title="Cambiar presentación de accesos rápidos"
title={t('dock.changeMode')}
>
<Settings2 size={15} />
</button>
@@ -56,7 +58,7 @@ export const QuickLinksModePicker: FC<{ direction?: 'up' | 'down'; align?: 'left
} ${align === 'right' ? 'right-0' : 'left-0'}`}
>
<p className="px-2.5 py-1.5 text-[10px] uppercase tracking-wider text-theme-muted font-medium">
Mostrar accesos rápidos
{t('dock.showModes')}
</p>
{(Object.keys(MODE_ICONS) as QuickLinksMode[]).map((m) => {
const ModeIcon = MODE_ICONS[m];
@@ -71,7 +73,7 @@ export const QuickLinksModePicker: FC<{ direction?: 'up' | 'down'; align?: 'left
}`}
>
<ModeIcon size={15} />
{QUICK_LINKS_MODE_LABELS[m]}
{t(`dock.mode.${m}`)}
{selected && <span className="ml-auto text-brand-coral">●</span>}
</button>
);
@@ -90,6 +92,7 @@ export const QuickLinksModePicker: FC<{ direction?: 'up' | 'down'; align?: 'left
export const QuickLinksDock: FC<QuickLinksDockProps> = ({ items }) => {
const location = useLocation();
const quick = useQuickLinks();
const { t } = useI18n();
if (quick.mode === 'menu') return null;
@@ -104,7 +107,7 @@ export const QuickLinksDock: FC<QuickLinksDockProps> = ({ items }) => {
return (
<nav
aria-label="Accesos rápidos"
aria-label={t('dock.aria')}
className={
isRight
? 'fixed right-0 top-1/2 -translate-y-1/2 z-40 hidden lg:flex flex-col items-center gap-1 py-2 px-1.5 mr-2 rounded-2xl border border-theme-border bg-theme-surface shadow-card'
@@ -113,7 +116,7 @@ export const QuickLinksDock: FC<QuickLinksDockProps> = ({ items }) => {
>
{dockItems.length === 0 && (
<span className={`text-[11px] text-theme-muted px-2 ${isRight ? 'py-2 [writing-mode:vertical-rl]' : ''}`}>
Fija accesos con la ★ del menú
{t('dock.hint')}
</span>
)}
{dockItems.map((item) => {

View File

@@ -46,8 +46,10 @@ import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
import GlobalSearch from './GlobalSearch';
import ThemePicker from './ThemePicker';
import NotificationBell from './NotificationBell';
import { LanguageToggle } from './LanguageSelect';
import { useAuth, ROLE_LABELS } from '../lib/auth';
import { useGlosario, traducirMenu } from '../lib/glosario';
import { useI18n, useNavLabel } from '../lib/i18n';
import { useClinicSettings } from '../lib/clinic-settings';
import { useChatUnread } from '../lib/chat';
import { useQuickLinks } from '../lib/quicklinks';
@@ -135,6 +137,8 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
const navigate = useNavigate();
const { user, canSee, hasRole, logout, brand } = useAuth();
const { term } = useGlosario();
const { t } = useI18n();
const navLabel = useNavLabel();
const { membresias: membresiasCfg, compras: comprasCfg, comisiones: comisionesCfg, licencia: licenciaCfg } = useClinicSettings();
const menuGroupsT = useMemo(() => {
let base = menuGroups;
@@ -160,8 +164,12 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
.map((g) => ({ ...g, items: g.items.filter((i) => i.href !== '/chat') }))
.filter((g) => g.items.length > 0);
}
return traducirMenu(base, term);
}, [term, membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo, licenciaCfg]);
return traducirMenu(base, term).map((g) => ({
...g,
label: navLabel(g.label),
items: g.items.map((it) => ({ ...it, label: navLabel(it.label) })),
}));
}, [term, navLabel, membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo, licenciaCfg]);
const [sidebarOpen, setSidebarOpen] = useState(false);
const chatUnread = useChatUnread();
// Sidebar colapsable (modo iconos). Preferencia persistente; en pantallas
@@ -310,8 +318,8 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
<button
type="button"
onClick={toggleCollapsed}
title={collapsed ? 'Expandir menú' : 'Colapsar menú'}
aria-label={collapsed ? 'Expandir menú' : 'Colapsar menú'}
title={collapsed ? t('user.expandMenu') : t('user.collapseMenu')}
aria-label={collapsed ? t('user.expandMenu') : t('user.collapseMenu')}
className={`p-1.5 rounded-lg text-white/40 hover:text-white hover:bg-theme-surface/10 transition-colors ${collapsed ? '' : 'ml-auto'}`}
>
{collapsed ? <PanelLeftOpen size={16} /> : <PanelLeftClose size={16} />}
@@ -321,7 +329,7 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
<nav ref={navRef} className="flex-1 overflow-y-auto py-5 px-3">
{quickItems.length > 0 && quick.mode === 'menu' && collapsed && (
<div className="mb-5" title="Accesos rápidos">
<div className="mb-5" title={t('nav.quickLinks')}>
<div className="mx-3 mb-2 border-t border-white/10" />
<ul className="space-y-1 flex flex-col items-center">
{quickItems.map((item) => {
@@ -349,7 +357,7 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
{quickItems.length > 0 && quick.mode === 'menu' && !collapsed && (
<div className="mb-6">
<div className="flex items-center justify-between px-3 mb-2">
<p className="text-[10px] uppercase tracking-wider text-white/40 font-heading">Accesos rápidos</p>
<p className="text-[10px] uppercase tracking-wider text-white/40 font-heading">{t('nav.quickLinks')}</p>
<span className="flex items-center gap-1">
<QuickLinksModePicker direction="down" align="right" />
<button
@@ -448,7 +456,7 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
className={`shrink-0 transition-all ${
pinned ? 'text-amber-400' : 'text-white/30 opacity-0 group-hover:opacity-100 hover:text-amber-400'
}`}
title={pinned ? 'Quitar de accesos rápidos' : 'Fijar en accesos rápidos'}
title={pinned ? t('user.unpinQuick') : t('user.pinQuick')}
>
<Star size={15} fill={pinned ? 'currentColor' : 'none'} />
</button>
@@ -469,18 +477,24 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
{!collapsed && (
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">{user?.name || 'Usuario'}</p>
<p className="text-xs text-white/50 truncate">{user ? ROLE_LABELS[user.role] : ''}</p>
<p className="text-xs text-white/50 truncate">{user ? (ROLE_LABELS[user.role] ? t(`role.${user.role}`) : (user.role_name || user.role)) : ''}</p>
</div>
)}
<button
onClick={handleLogout}
className="p-2 rounded-lg text-white/50 hover:text-white hover:bg-theme-surface/10"
title="Cerrar sesión"
aria-label="Cerrar sesión"
title={t('user.logout')}
aria-label={t('user.logout')}
>
<LogOut size={18} />
</button>
</div>
{!collapsed && (
<div className="mt-3 flex items-center justify-between">
<span className="text-[11px] text-white/40">{t('user.language')}</span>
<LanguageToggle />
</div>
)}
</div>
</>
);
@@ -506,7 +520,7 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
>
<div className="h-20 flex items-center justify-between px-5 border-b border-white/10 shrink-0">
<img src={brand.logo_claro} alt={brand.nombre} className="h-7 w-auto object-contain" />
<Button variant="ghost" size="sm" onClick={() => setSidebarOpen(false)} aria-label="Cerrar menú">
<Button variant="ghost" size="sm" onClick={() => setSidebarOpen(false)} aria-label={t('user.closeMenu')}>
<X size={22} />
</Button>
</div>
@@ -524,7 +538,7 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
className="lg:hidden"
style={{ color: 'var(--text-heading)' }}
onClick={() => setSidebarOpen(true)}
aria-label="Abrir menú"
aria-label={t('user.openMenu')}
>
<Menu size={22} />
</Button>
@@ -540,10 +554,10 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
onClick={hasRole('admin') ? openGoalModal : undefined}
className={`hidden md:flex flex-col w-44 xl:w-56 text-left rounded-xl border px-3 py-1.5 shadow-card ${hasRole('admin') ? 'cursor-pointer' : 'cursor-default'}`}
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
title={hasRole('admin') ? 'Click para editar la meta' : 'Meta de ventas del mes'}
title={hasRole('admin') ? t('user.goalEditHint') : t('user.goalMonthHint')}
>
<div className="flex items-center justify-between text-[11px] mb-1" style={{ color: 'var(--text-muted)' }}>
<span className="flex items-center gap-1"><Target size={12} /> Meta del mes</span>
<span className="flex items-center gap-1"><Target size={12} /> {t('user.goalMonth')}</span>
<span className="font-bold" style={{ color: 'var(--text-heading)' }}>{Math.round(goal.pct)}%</span>
</div>
<div className="w-full h-1.5 rounded-full overflow-hidden" style={{ backgroundColor: 'var(--bg)' }}>
@@ -551,13 +565,13 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
</div>
<div className="flex items-center justify-between text-[11px] mt-1">
<span className="font-semibold" style={{ color: 'var(--text-heading)' }}>{fmtMoney(goal.current)}</span>
<span style={{ color: 'var(--text-muted)' }}>de {fmtMoney(goal.goal)}</span>
<span style={{ color: 'var(--text-muted)' }}>{t('user.goalOf')} {fmtMoney(goal.goal)}</span>
</div>
</button>
)}
{goal && goal.goal <= 0 && hasRole('admin') && (
<Button variant="outline" size="sm" className="hidden md:inline-flex" onClick={openGoalModal}>
<Target size={14} className="mr-1.5" /> Definir meta
<Target size={14} className="mr-1.5" /> {t('user.goalDefine')}
</Button>
)}
<GlobalSearch className="hidden md:block" />
@@ -578,18 +592,18 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
<Modal
isOpen={goalModalOpen}
onClose={() => setGoalModalOpen(false)}
title="Meta de ventas del mes"
title={t('user.goalTitle')}
maxWidth="sm"
footer={
<>
<Button variant="outline" onClick={() => setGoalModalOpen(false)}>Cancelar</Button>
<Button onClick={saveGoal} loading={goalSaving}>Guardar meta</Button>
<Button variant="outline" onClick={() => setGoalModalOpen(false)}>{t('common.cancel')}</Button>
<Button onClick={saveGoal} loading={goalSaving}>{t('user.goalSave')}</Button>
</>
}
>
<div className="space-y-3">
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>Define el objetivo de ventas (MXN) para el mes en curso. La barra del header muestra el avance en tiempo real.</p>
<Input label="Meta mensual (MXN)" type="number" min={0} step={1000} value={goalInput} onChange={(e) => setGoalInput(e.target.value)} placeholder="ej. 500000" />
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>{t('user.goalBody')}</p>
<Input label={t('user.goalField')} type="number" min={0} step={1000} value={goalInput} onChange={(e) => setGoalInput(e.target.value)} placeholder="ej. 500000" />
</div>
</Modal>
</div>

1445
frontend/src/lib/i18n.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { ThemeProvider } from './lib/theme.tsx'
import { I18nProvider } from './lib/i18n.tsx'
// PWA: registra el service worker solo en producción (en dev rompería el HMR)
// y nunca deja que un fallo del SW rompa el arranque de la app.
@@ -19,7 +20,9 @@ if (import.meta.env.PROD && 'serviceWorker' in navigator) {
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ThemeProvider>
<I18nProvider>
<App />
</I18nProvider>
</ThemeProvider>
</StrictMode>,
)

View File

@@ -29,7 +29,7 @@ import {
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, type GlossaryKey, type Notificacion, AGENDA_PERM_ITEMS } from '../services/odoo';
import { useAuth } from '../lib/auth';
import { useGlosario } from '../lib/glosario';
import { useI18n, useBusinessTerm } from '../lib/i18n';
import { useClinicSettings } from '../lib/clinic-settings';
import { useSucursales } from '../lib/sucursales';
import { useRecursos } from '../lib/recursos';
@@ -72,8 +72,19 @@ export const APT_STATE_LABELS: Record<string, string> = {
};
/** 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);
export const aptStateLabel = (
state: string,
term: (key: GlossaryKey) => string,
t?: (key: string, vars?: Record<string, string | number>) => string,
): string => {
const room = term('room_singular').toLowerCase();
const key =
state === 'no_show' ? 'agenda.state.noShow'
: state === 'in_progress' ? 'agenda.state.inProgress'
: `agenda.state.${state}`;
if (t) return state === 'arrived' ? t('agenda.state.arrived', { room }) : t(key);
return state === 'arrived' ? `En ${room}` : (APT_STATE_LABELS[state] || state);
};
const stateOptions = [
{ value: '', label: 'Todos los estados' },
@@ -161,7 +172,8 @@ const Agenda: FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const { user, canAgenda, can } = useAuth();
const { term } = useGlosario();
const term = useBusinessTerm();
const { t } = useI18n();
const { sucursales, defaultCode } = useSucursales();
const { recursos } = useRecursos();
const clinicSettings = useClinicSettings();
@@ -1365,7 +1377,7 @@ const Agenda: FC = () => {
}
};
const aptStateLabels = (state: string) => aptStateLabel(state, term);
const aptStateLabels = (state: string) => aptStateLabel(state, term, t);
const handlePhoneConfirm = async (apt: Appointment, confirmed: boolean) => {
try {
@@ -1885,12 +1897,12 @@ const Agenda: FC = () => {
);
return (
<Layout title="Agenda" subtitle="Gestión de citas">
<PageHeader title="Agenda" subtitle="Filtra por fecha, estado o médico">
<Layout title={t('nav.agenda')} subtitle={t('agenda.subtitle')}>
<PageHeader title={t('nav.agenda')} subtitle={t('agenda.pageSubtitle')}>
<AutoRefreshBadge state={autoRefresh} />
<Button variant="outline" onClick={() => setListaModalOpen(true)}>
<Users size={16} className="mr-2" />
Lista de espera
{t('agenda.waitlist')}
{countEnEspera > 0 && (
<span className="ml-1.5 inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-brand-lilac text-[10px] font-bold text-theme-heading">
{countEnEspera}
@@ -1899,16 +1911,16 @@ const Agenda: FC = () => {
</Button>
<Button variant="outline" onClick={openBloqueos}>
<Ban size={16} className="mr-2" />
Cerrar agendas
{t('agenda.closeAgendas')}
</Button>
<Button variant="outline" onClick={() => setExpressOpen(true)}>
<Sparkles size={16} className="mr-2" />
Valoración express
{t('agenda.express')}
</Button>
{puedeAgendar && (
<Button onClick={openCreate}>
<Plus size={16} className="mr-2" />
Nueva {term('appointment_singular').toLowerCase()}
{t('agenda.newAppointmentWord')} {term('appointment_singular').toLowerCase()}
</Button>
)}
</PageHeader>
@@ -1966,7 +1978,7 @@ const Agenda: FC = () => {
</div>
<Select
label="Estado"
options={stateOptions.map((o) => (o.value === 'arrived' ? { ...o, label: `En ${term('room_singular').toLowerCase()}` } : o))}
options={stateOptions.map((o) => ({ ...o, label: o.value === '' ? t('agenda.stateAll') : aptStateLabel(o.value, term, t) }))}
value={stateFilter}
onChange={(e) => setStateFilter(e.target.value)}
className="sm:max-w-[180px]"
@@ -2159,16 +2171,16 @@ const Agenda: FC = () => {
: 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong'
}`}
>
Espacios libres
{t('agenda.freeSlots')}
</button>
)}
{([
{ key: 'primera', label: 'Primera vez', count: countPrimera },
{ key: 'checkin', label: 'Check-in', count: countCheckin },
{ key: 'noshow', label: 'No show', count: countNoShow },
{ key: 'porconfirmar', label: 'Por confirmar', count: countPorConfirmar },
{ key: 'cancelled', label: 'Canceló', count: countCanceladas },
{ key: 'waitlist', label: 'En lista', count: countWaitlist },
{ key: 'primera', label: t('agenda.filterFirstTime'), count: countPrimera },
{ key: 'checkin', label: t('agenda.filterCheckin'), count: countCheckin },
{ key: 'noshow', label: t('agenda.filterNoShow'), count: countNoShow },
{ key: 'porconfirmar', label: t('agenda.filterToConfirm'), count: countPorConfirmar },
{ key: 'cancelled', label: t('agenda.filterCancelled'), count: countCanceladas },
{ key: 'waitlist', label: t('agenda.filterWaitlist'), count: countWaitlist },
] as const).map((chip) => (
<button
key={chip.key}
@@ -2311,8 +2323,8 @@ const Agenda: FC = () => {
) : viewMode === 'doctores' ? (
gridColumns.length === 0 ? (
<EmptyState
title="Sin médicos"
subtitle="No hay médicos dados de alta para mostrar en la vista por médico."
title={t('agenda.noDoctorsTitle')}
subtitle={t('agenda.noDoctorsSub')}
/>
) : (
<div>
@@ -2452,8 +2464,8 @@ const Agenda: FC = () => {
) : viewMode === 'horas' ? (
gridColumns.length === 0 ? (
<EmptyState
title="Sin médicos"
subtitle="No hay médicos dados de alta para mostrar en la vista por hora."
title={t('agenda.noDoctorsTitle')}
subtitle={t('agenda.noDoctorsSubHour')}
/>
) : (
<div>
@@ -2580,8 +2592,8 @@ const Agenda: FC = () => {
) : viewMode === 'semana' ? (
gridColumns.length === 0 ? (
<EmptyState
title="Sin médicos"
subtitle="No hay médicos dados de alta para mostrar en la vista semanal."
title={t('agenda.noDoctorsTitle')}
subtitle={t('agenda.noDoctorsSubWeek')}
/>
) : (
<div>
@@ -2688,8 +2700,8 @@ const Agenda: FC = () => {
) : viewMode === 'recursos' ? (
gridRecursos.length === 0 ? (
<EmptyState
title="Sin recursos"
subtitle="Agrega recursos en Configuración › Recursos para ver esta vista."
title={t('agenda.noResourcesTitle')}
subtitle={t('agenda.noResourcesSub')}
/>
) : (
<div>
@@ -2870,7 +2882,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)}>{aptStateLabel(apt.state, term)}</Badge>
<Badge variant={badgeForAppointmentState(apt.state)}>{aptStateLabel(apt.state, term, t)}</Badge>
</td>
<td className="p-3 text-center">
<button
@@ -2903,7 +2915,7 @@ const Agenda: FC = () => {
rows={[
{ label: 'Hora', value: apt.time },
{ label: 'Doctor', value: apt.doctor || '-' },
{ label: 'Estado', value: <Badge variant={badgeForAppointmentState(apt.state)}>{aptStateLabel(apt.state, term)}</Badge> },
{ label: 'Estado', value: <Badge variant={badgeForAppointmentState(apt.state)}>{aptStateLabel(apt.state, term, t)}</Badge> },
{ label: 'Confirmación', value: <span title={confirmTitle(apt)}>{confirmIndicator(apt, 14)}</span> },
{ label: 'Pago', value: apt.payment_state },
]}
@@ -2989,13 +3001,13 @@ const Agenda: FC = () => {
<Modal
isOpen={createOpen}
onClose={() => cerrarModalCita()}
title={editingId ? `Editar ${term('appointment_singular').toLowerCase()}` : `Nueva ${term('appointment_singular').toLowerCase()}`}
title={editingId ? t('agenda.editAppointment', { term: term('appointment_singular').toLowerCase() }) : t('agenda.newAppointment', { term: term('appointment_singular').toLowerCase() })}
maxWidth="lg"
footer={
<>
<Button variant="outline" onClick={() => cerrarModalCita()}>Cancelar</Button>
<Button variant="outline" onClick={() => cerrarModalCita()}>{t('common.cancel')}</Button>
<Button onClick={editingId ? saveEditCita : quickMode ? submitQuick : createAppointment} loading={submitting}>
{editingId ? 'Guardar cambios' : quickMode ? 'Guardar cita rápida' : 'Guardar cita'}
{editingId ? t('agenda.saveChanges') : quickMode ? t('agenda.saveQuick') : t('agenda.saveAppointment')}
</Button>
</>
}
@@ -3008,16 +3020,16 @@ const Agenda: FC = () => {
onChange={(e) => setQuickMode(e.target.checked)}
className="rounded border-theme-border-strong"
/>
Cita rápida (solo nombre y teléfono)
{t('agenda.quickMode')}
</label>
{quickMode ? (
<>
<p className="text-xs text-theme-muted">
Si el teléfono ya existe, se reutiliza ese paciente; si no, se crea con datos mínimos.
{t('agenda.quickModeHint')}
</p>
<Input
label="Nombre *"
placeholder="Nombre del paciente"
label={t('agenda.nameField')}
placeholder={t('agenda.namePlaceholder')}
value={quickForm.name}
onChange={(e) => setQuickForm({ ...quickForm, name: e.target.value })}
/>
@@ -3252,7 +3264,7 @@ const Agenda: FC = () => {
<Modal
isOpen={expressOpen}
onClose={() => setExpressOpen(false)}
title="Valoración express"
title={t('agenda.expressTitle')}
maxWidth="md"
footer={
<>
@@ -3333,7 +3345,7 @@ const Agenda: FC = () => {
<Modal
isOpen={!!detailApt}
onClose={() => setDetailApt(null)}
title={editMode ? 'Reagendar cita' : 'Detalle de la cita'}
title={editMode ? t('agenda.rescheduleTitle') : t('agenda.detailTitle')}
maxWidth="md"
footer={
editMode ? (
@@ -3613,7 +3625,7 @@ const Agenda: FC = () => {
<Modal
isOpen={!!conflict}
onClose={() => setConflict(null)}
title="Conflicto de horario"
title={t('agenda.conflictTitle')}
maxWidth="sm"
footer={
<>

View File

@@ -28,6 +28,8 @@ import { Card, Button, Input, PageHeader, toast } from '../components/ui';
import { odooApi, type Doctor, type ClinicSettings, type ImpuestosSettings, BRAND_DEFAULT, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, RESERVAS_ONLINE_DEFAULT, NOTIFICACIONES_DEFAULT } from '../services/odoo';
import { useAuth } from '../lib/auth';
import { publicarClinicSettings } from '../lib/clinic-settings';
import { useI18n } from '../lib/i18n';
import { LanguageSelect } from '../components/LanguageSelect';
const TABS = [
{ key: 'clinica', label: 'Clínica' },
@@ -67,6 +69,9 @@ const LEGACY_STORAGE_KEY = 'skeen_clinic_settings';
const Configuracion: FC = () => {
const { user, brand, setBrand, can } = useAuth();
const { t } = useI18n();
// Clave i18n de una pestaña: 'notif_enviadas' -> 'config.tabs.notifEnviadas'.
const tabKey = (k: string) => 'config.tabs.' + k.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
// 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';
@@ -321,21 +326,21 @@ const Configuracion: FC = () => {
};
return (
<Layout title="Configuración" subtitle="Ajustes de la clínica">
<PageHeader title="Configuración" subtitle="Información y ajustes de la clínica" />
<Layout title={t('config.title')} subtitle={t('config.subtitle')}>
<PageHeader title={t('config.title')} subtitle={t('config.pageSubtitle')} />
{/* Tabs de secciones */}
<div className="flex flex-wrap gap-1.5 mb-6">
{tabsVisibles.map((t) => (
{tabsVisibles.map((tabItem) => (
<button
key={t.key}
key={tabItem.key}
type="button"
onClick={() => setTab(t.key)}
onClick={() => setTab(tabItem.key)}
className={`px-4 py-2 text-sm font-medium rounded-full transition ${
tab === t.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
tab === tabItem.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
}`}
>
{t.label}
{t(tabKey(tabItem.key))}
</button>
))}
</div>
@@ -343,7 +348,7 @@ const Configuracion: FC = () => {
{tabsVisibles.length === 0 ? (
<Card>
<Card.Body>
<p className="text-sm text-theme-muted">No tienes permiso para ninguna sección de Configuración.</p>
<p className="text-sm text-theme-muted">{t('config.noPermission')}</p>
</Card.Body>
</Card>
) : tab === 'usuarios' ? (
@@ -381,11 +386,11 @@ const Configuracion: FC = () => {
subcatalogo === s.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
}`}
>
{s.label}
{t(`config.tabs.${s.key}`)}
</button>
))}
</div>
<CatalogoPanel kind={subcatalogo as 'diagnosticos' | 'procedimientos'} titulo={subcatalogo === 'diagnosticos' ? 'Diagnósticos' : 'Procedimientos'} />
<CatalogoPanel kind={subcatalogo as 'diagnosticos' | 'procedimientos'} titulo={t(`config.tabs.${subcatalogo}`)} />
</Card.Body>
</Card>
) : tab === 'sucursales' ? (
@@ -461,10 +466,20 @@ const Configuracion: FC = () => {
) : tab === 'webhooks' ? (
<WebhooksPanel />
) : (
<>
<Card className="mb-4 sm:mb-6">
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-1">{t('config.idiomaTitle')}</h3>
<p className="text-sm text-theme-muted mb-3">{t('config.idiomaBody')}</p>
<div className="max-w-xs">
<LanguageSelect />
</div>
</Card.Body>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
<Card>
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-4">Información de la clínica</h3>
<h3 className="font-heading text-xl text-theme-heading mb-4">{t('config.clinicInfo')}</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.direccion)}`}
@@ -529,7 +544,7 @@ const Configuracion: FC = () => {
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-4 flex items-center">
<Building2 size={20} className="mr-2" />
Ajustes
{t('config.settings')}
</h3>
{loading ? (
<p className="text-sm text-theme-muted">Cargando...</p>
@@ -693,7 +708,7 @@ const Configuracion: FC = () => {
{esAdmin && (
<Card>
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-1">Identidad de la clínica</h3>
<h3 className="font-heading text-xl text-theme-heading mb-1">{t('config.clinicIdentity')}</h3>
<p className="text-sm text-theme-muted mb-4">
Nombre, pie de página y logos que aparecen en el inicio de sesión, el menú y los
documentos impresos. Dejar un campo vacío vuelve al valor por omisión.
@@ -760,7 +775,7 @@ const Configuracion: FC = () => {
placeholder="Vacío = color del tema"
/>
{brandForm[key] && (
<Button variant="ghost" size="sm" onClick={() => setBrandForm((f) => ({ ...f, [key]: '' }))} title="Usar el color del tema">
<Button variant="ghost" size="sm" onClick={() => setBrandForm((f) => ({ ...f, [key]: '' }))} title={t('config.useThemeColor')}>
Limpiar
</Button>
)}
@@ -797,7 +812,7 @@ const Configuracion: FC = () => {
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-1 flex items-center">
<ArrowRightLeft size={20} className="mr-2" />
Tipo de cambio USD / MXN
{t('config.exchangeRate')}
</h3>
<p className="text-sm text-theme-muted mb-4">
Se usa para convertir ventas en dólares. Vigente:
@@ -821,7 +836,7 @@ const Configuracion: FC = () => {
</div>
<Button onClick={saveTc} disabled={tcSaving}>
<Save size={16} className="mr-2" />
{tcSaving ? 'Guardando...' : 'Actualizar TC'}
{tcSaving ? t('common.saving') : t('config.updateRate')}
</Button>
</div>
</Card.Body>
@@ -831,7 +846,7 @@ const Configuracion: FC = () => {
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-1 flex items-center">
<Percent size={20} className="mr-2" />
Comisiones
{t('config.commissions')}
</h3>
<p className="text-sm text-theme-muted mb-4">
Porcentaje de comisión sobre las ventas atribuidas a cada médico (visible en su página "Mis ventas").
@@ -864,7 +879,7 @@ const Configuracion: FC = () => {
</div>
<Button onClick={saveCommissionDefault} disabled={commissionDefaultSaving}>
<Save size={16} className="mr-2" />
{commissionDefaultSaving ? 'Guardando...' : 'Guardar'}
{commissionDefaultSaving ? t('common.saving') : t('common.save')}
</Button>
</div>
<div className="overflow-x-auto">
@@ -910,7 +925,7 @@ const Configuracion: FC = () => {
onClick={() => saveDoctorCommission(d.id)}
disabled={commissionSavingId === d.id}
>
{commissionSavingId === d.id ? 'Guardando...' : 'Guardar'}
{commissionSavingId === d.id ? t('common.saving') : t('common.save')}
</Button>
)}
</td>
@@ -927,6 +942,7 @@ const Configuracion: FC = () => {
</Card.Body>
</Card>
</div>
</>
)}
<ImpactoModal

View File

@@ -51,7 +51,7 @@ 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 { useI18n, useBusinessTerm } from '../lib/i18n';
import { waLink } from '../lib/whatsapp';
import { aptStateLabel } from './Agenda';
@@ -162,7 +162,8 @@ const TodayTile: FC<TodayTileProps> = ({ label, icon, onClick, children, classNa
const Dashboard: FC = () => {
const { theme } = useTheme();
const { user, brand } = useAuth();
const { term } = useGlosario();
const term = useBusinessTerm();
const { t, locale } = useI18n();
const navigate = useNavigate();
const isHomeNest = theme === 'homenest';
const [stats, setStats] = useState<DashboardStats | null>(null);
@@ -322,49 +323,49 @@ const Dashboard: FC = () => {
const statsConfig = useMemo(
() => [
{
title: 'Citas Hoy',
title: t('dashboard.statAppointments'),
value: stats?.appointments_today ?? 0,
icon: <Calendar size={20} />,
trend: stats ? `${stats.appointments_month} este mes` : undefined,
trend: stats ? t('dashboard.thisMonth', { n: stats.appointments_month }) : undefined,
to: '/agenda',
},
{
title: 'Pacientes',
title: t('dashboard.statPatients'),
value: stats?.total_patients ?? 0,
icon: <Users size={20} />,
trend: stats ? `+${stats.new_patients_month} nuevos` : undefined,
trend: stats ? t('dashboard.newPatients', { n: stats.new_patients_month }) : undefined,
to: '/pacientes',
},
{
title: 'Ingresos Mes',
title: t('dashboard.statRevenue'),
value: stats ? formatCurrency(stats.revenue_month) : '$0',
icon: <DollarSign size={20} />,
to: '/reportes?tab=ingresos',
delta: buildDelta(stats?.revenue_month, stats?.prev_revenue_month),
deltaLabel: 'vs mes anterior',
deltaLabel: t('dashboard.vsPrevMonth'),
},
{
title: 'Ventas Hoy',
title: t('dashboard.statSales'),
value: stats?.sales_today ?? 0,
icon: <ShoppingCart size={20} />,
to: '/ventas',
delta: buildDelta(stats?.sales_today, stats?.prev_sales_today),
deltaLabel: 'vs ayer',
deltaLabel: t('dashboard.vsYesterday'),
},
{
title: 'Pendientes',
title: t('dashboard.statPending'),
value: stats?.pending_payments ?? 0,
icon: <Clock size={20} />,
to: '/reportes?tab=adeudos',
},
{
title: 'Puntos Monedero',
title: t('dashboard.statWallet'),
value: stats?.total_wallet_points ?? 0,
icon: <Wallet size={20} />,
to: '/monedero',
},
],
[stats]
[stats, t]
);
// Próximas citas de hoy (hora >= ahora); si ya no queda ninguna, las
@@ -381,19 +382,19 @@ const Dashboard: FC = () => {
return (upcoming.length > 0 ? upcoming : sorted).slice(0, 3);
}, [appointments]);
const todayLabel = new Date().toLocaleDateString('es-MX', {
const todayLabel = new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'es-MX', {
weekday: 'long',
day: 'numeric',
month: 'long',
});
const cajaStatus = useMemo(() => {
if (!todayInfo) return { label: '—', ref: 'Consultando…', tone: 'text-theme-muted' };
if (!todayInfo.caja) return { label: 'Sin abrir', ref: 'Sin corte de caja hoy', tone: 'text-theme-muted' };
if (!todayInfo) return { label: '—', ref: t('dashboard.checking'), tone: 'text-theme-muted' };
if (!todayInfo.caja) return { label: t('dashboard.cashNone'), ref: t('dashboard.noCashCut'), tone: 'text-theme-muted' };
if (todayInfo.caja.state === 'open')
return { label: 'Abierta', ref: todayInfo.caja.name, tone: 'text-emerald-600' };
return { label: 'Cerrada', ref: todayInfo.caja.name, tone: 'text-theme-heading' };
}, [todayInfo]);
return { label: t('dashboard.cashOpen'), ref: todayInfo.caja.name, tone: 'text-emerald-600' };
return { label: t('dashboard.cashClosed'), ref: todayInfo.caja.name, tone: 'text-theme-heading' };
}, [todayInfo, t]);
const activityItems = useMemo(() => {
return appointments.slice(0, 4).map((apt) => ({
@@ -409,13 +410,13 @@ const Dashboard: FC = () => {
const s = aptReport.by_state || {};
const pick = (...keys: string[]) => keys.reduce((a, k) => a + (s[k] || 0), 0);
return [
{ name: 'Completadas', value: pick('done'), color: '#CAE2C1' },
{ name: 'Confirmadas', value: pick('confirmed', 'arrived', 'in_progress'), color: '#A4CCE2' },
{ name: 'Pendientes', value: pick('pending'), color: '#EDA588' },
{ name: t('dashboard.stateDone'), value: pick('done'), color: '#CAE2C1' },
{ name: t('dashboard.stateConfirmed'), value: pick('confirmed', 'arrived', 'in_progress'), color: '#A4CCE2' },
{ name: t('dashboard.statPending'), value: pick('pending'), color: '#EDA588' },
{ name: 'No show', value: pick('no_show'), color: '#E57373' },
{ name: 'Canceladas', value: pick('cancelled'), color: '#E5E0DC' },
{ name: t('dashboard.stateCancelled'), value: pick('cancelled'), color: '#E5E0DC' },
].filter((d) => d.value > 0);
}, [aptReport]);
}, [aptReport, t]);
const noShowRate = useMemo(() => {
if (!aptReport || !aptReport.total) return 0;
@@ -423,25 +424,25 @@ const Dashboard: FC = () => {
}, [aptReport]);
return (
<Layout title="Dashboard" subtitle={`Panel de control de ${brand?.nombre || 'la clínica'}`}>
<Layout title={t('dashboard.title')} subtitle={t('dashboard.subtitleBrand', { brand: brand?.nombre || t('dashboard.theClinic') })}>
{isHomeNest ? (
<div className="rounded-3xl p-6 sm:p-10 mb-8 border shadow-soft" style={{ backgroundColor: 'var(--surface)', borderColor: 'var(--border)' }}>
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-6">
<div className="max-w-2xl">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full text-xs font-medium mb-4" style={{ backgroundColor: 'var(--success)', color: 'var(--success-text)' }}>
<Sparkles size={14} /> Bienvenido de vuelta
<Sparkles size={14} /> {t('dashboard.welcomeBack')}
</div>
<h1 className="font-display text-3xl sm:text-4xl lg:text-5xl mb-3" style={{ color: 'var(--text-heading)' }}>{brand?.nombre || 'Panel Clínico'}</h1>
<p className="text-base sm:text-lg leading-relaxed" style={{ color: 'var(--text-muted)' }}>
Ciencia, estética y cuidado personalizado en cada tratamiento. Gestiona tu clínica con calidez y confianza.
{t('dashboard.heroTextNest')}
</p>
</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 {term('appointment_singular').toLowerCase()}
<Calendar size={16} className="mr-2" /> {t('dashboard.newAppointmentWord')} {term('appointment_singular').toLowerCase()}
</Button>
<Button variant="outline" size="md" onClick={() => navigate('/reportes')}>
<TrendingUp size={16} className="mr-2" /> Ver reportes
<TrendingUp size={16} className="mr-2" /> {t('dashboard.viewReports')}
</Button>
</div>
</div>
@@ -457,10 +458,10 @@ const Dashboard: FC = () => {
</div>
) : (
<>
<PageHeader title="Dashboard" subtitle="Resumen general de la clínica">
<PageHeader title={t('dashboard.title')} subtitle={t('dashboard.subtitle')}>
<span className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium bg-theme-accent-bg text-theme-heading">
<Sparkles size={16} />
Bienvenido{user?.name ? `, ${user.name.split(' ')[0]}` : ''}
{user?.name ? t('dashboard.welcomeUser', { name: user.name.split(' ')[0] }) : t('dashboard.welcome')}
</span>
</PageHeader>
@@ -469,13 +470,13 @@ const Dashboard: FC = () => {
<div>
<h1 className="font-heading text-2xl sm:text-3xl mb-2">{brand?.nombre || 'Panel Clínico'}</h1>
<p className="text-white/90 text-sm sm:text-base max-w-xl">
Ciencia, estética y cuidado personalizado en cada tratamiento. Gestiona tu clínica con confianza.
{t('dashboard.heroText')}
</p>
</div>
<div className="shrink-0">
<Button variant="secondary" size="sm" className="bg-theme-surface/20 text-white border-white/30 hover:bg-theme-surface/30" onClick={() => navigate('/reportes')}>
<TrendingUp size={16} className="mr-2" />
Ver reportes
{t('dashboard.viewReports')}
</Button>
</div>
</div>
@@ -487,21 +488,21 @@ const Dashboard: FC = () => {
<Card className="mb-6 sm:mb-8">
<Card.Header>
<div>
<h3 className="font-heading text-lg text-theme-heading">Hoy en la clínica</h3>
<h3 className="font-heading text-lg text-theme-heading">{t('dashboard.todayTitle')}</h3>
<p className="text-xs text-theme-muted capitalize">{todayLabel}</p>
</div>
</Card.Header>
<Card.Body>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-5 gap-3">
<TodayTile
label="Próximas citas"
label={t('dashboard.upcoming')}
icon={<CalendarClock size={14} />}
onClick={() => navigate('/agenda')}
ariaLabel="Ver próximas citas en la agenda"
ariaLabel={t('dashboard.ariaUpcoming')}
className="md:col-span-2"
>
{upcomingToday.length === 0 ? (
<p className="text-sm text-theme-muted">Sin citas activas para hoy.</p>
<p className="text-sm text-theme-muted">{t('dashboard.noUpcoming')}</p>
) : (
<div className="space-y-1.5">
{upcomingToday.map((apt) => (
@@ -509,7 +510,7 @@ const Dashboard: FC = () => {
<span className="font-medium text-theme-heading tabular-nums shrink-0">{apt.time}</span>
<span className="text-theme-heading truncate flex-1">{apt.patient}</span>
<span className="text-xs text-theme-muted truncate hidden sm:inline max-w-[120px]">
{apt.doctor || 'Sin médico'}
{apt.doctor || t('dashboard.noDoctor')}
</span>
</div>
))}
@@ -518,28 +519,26 @@ const Dashboard: FC = () => {
</TodayTile>
<TodayTile
label="Sala de espera"
label={t('dashboard.waitingRoom')}
icon={<Armchair size={14} />}
onClick={() => navigate('/agenda')}
ariaLabel="Ver pacientes en sala de espera"
ariaLabel={t('dashboard.ariaWaiting')}
>
<p className="text-2xl font-heading font-semibold text-theme-heading">
{todayInfo ? (todayInfo.waiting ?? '—') : '—'}
</p>
<p className="text-xs text-theme-muted">
{todayInfo?.waiting != null
? todayInfo.waiting === 1
? 'paciente en espera'
: 'pacientes en espera'
: 'no disponible'}
? (todayInfo.waiting === 1 ? t('dashboard.waiting_one') : t('dashboard.waiting_other'))
: t('dashboard.notAvailable')}
</p>
</TodayTile>
<TodayTile
label="Caja"
label={t('dashboard.cash')}
icon={<Vault size={14} />}
onClick={() => navigate('/cortes')}
ariaLabel="Ir a cortes de caja"
ariaLabel={t('dashboard.ariaCash')}
>
<p className={`text-xl font-heading font-semibold ${cajaStatus.tone}`}>{cajaStatus.label}</p>
<p className="text-xs text-theme-muted truncate" title={cajaStatus.ref}>
@@ -548,15 +547,15 @@ const Dashboard: FC = () => {
</TodayTile>
<TodayTile
label="Cobros del día"
label={t('dashboard.dayCharges')}
icon={<Banknote size={14} />}
onClick={() => navigate('/ventas')}
ariaLabel="Ver cobros del día en ventas"
ariaLabel={t('dashboard.ariaCharges')}
>
<p className="text-2xl font-heading font-semibold text-theme-heading">
{todayInfo ? (todayInfo.cobros != null ? formatCurrency(todayInfo.cobros) : '—') : '—'}
</p>
<p className="text-xs text-theme-muted">pagos completados hoy</p>
<p className="text-xs text-theme-muted">{t('dashboard.completedPayments')}</p>
</TodayTile>
</div>
</Card.Body>
@@ -584,12 +583,12 @@ const Dashboard: FC = () => {
<Card.Header>
<div>
<h3 className="font-heading text-lg text-theme-heading flex items-center gap-2">
<Cake size={18} className="text-pink-500" /> Cumpleaños de hoy
<Cake size={18} className="text-pink-500" /> {t('dashboard.birthdaysTitle')}
</h3>
<p className="text-xs text-theme-muted">Pacientes que cumplen años hoy — contáctalos</p>
<p className="text-xs text-theme-muted">{t('dashboard.birthdaysSub')}</p>
</div>
<Button variant="ghost" size="sm" onClick={() => navigate('/cumpleanos')}>
Ver todos
{t('common.viewAll')}
<ArrowRight size={14} className="ml-1" />
</Button>
</Card.Header>
@@ -597,7 +596,7 @@ const Dashboard: FC = () => {
{loadingBirthdays ? (
<Skeleton count={2} className="h-10 w-full" />
) : birthdays.length === 0 ? (
<p className="text-sm text-theme-muted">Ningún paciente cumple años hoy.</p>
<p className="text-sm text-theme-muted">{t('dashboard.noBirthdays')}</p>
) : (
<div className="flex flex-wrap gap-2">
{birthdays.map((b) => (
@@ -609,12 +608,12 @@ const Dashboard: FC = () => {
type="button"
onClick={() => openPatient(b.id)}
className="text-sm font-medium text-theme-heading hover:underline hover:text-theme-accent text-left"
title="Abrir ficha del paciente"
title={t('dashboard.openPatient')}
>
{b.name}
</button>
<span className="text-xs text-theme-muted">
cumple {b.turning_age}{b.is_vip ? ' · VIP' : ''}
{t('dashboard.turning', { age: b.turning_age })}{b.is_vip ? ` · ${t('dashboard.vip')}` : ''}
</span>
{(b.whatsapp || b.phone) && (
<a
@@ -639,15 +638,15 @@ const Dashboard: FC = () => {
<Card>
<Card.Header>
<div>
<h3 className="font-heading text-lg text-theme-heading">Actividad semanal</h3>
<p className="text-xs text-theme-muted">Citas e ingresos de los últimos 7 días</p>
<h3 className="font-heading text-lg text-theme-heading">{t('dashboard.chartWeeklyTitle')}</h3>
<p className="text-xs text-theme-muted">{t('dashboard.chartWeeklySub')}</p>
</div>
</Card.Header>
<Card.Body>
{loadingChart ? (
<Skeleton count={8} className="h-6 w-full" />
) : chartData.length === 0 ? (
<EmptyState title="Sin datos" subtitle="No hay información para la gráfica semanal." icon={<TrendingUp size={28} />} />
<EmptyState title={t('dashboard.noDataTitle')} subtitle={t('dashboard.noWeeklySub')} icon={<TrendingUp size={28} />} />
) : (
<div className="h-[300px] w-full">
<ResponsiveContainer width="100%" height={300}>
@@ -680,8 +679,8 @@ const Dashboard: FC = () => {
<Card className="flex flex-col">
<Card.Header>
<div>
<h3 className="font-heading text-lg text-theme-heading">Próximas citas</h3>
<p className="text-xs text-theme-muted">Citas programadas para hoy</p>
<h3 className="font-heading text-lg text-theme-heading">{t('dashboard.upcoming')}</h3>
<p className="text-xs text-theme-muted">{t('dashboard.upcomingTodaySub')}</p>
</div>
<Button variant="ghost" size="sm" className="hidden sm:inline-flex">
Ver agenda
@@ -696,7 +695,7 @@ const Dashboard: FC = () => {
))}
</div>
) : appointments.length === 0 ? (
<EmptyState title="Sin citas" subtitle="No hay citas programadas para hoy." icon={<Calendar size={28} />} />
<EmptyState title={t('dashboard.noAppointmentsTitle')} subtitle={t('dashboard.noAppointmentsSub')} icon={<Calendar size={28} />} />
) : (
<div className="space-y-3">
{appointments.map((apt) => (
@@ -708,7 +707,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)}>{aptStateLabel(apt.state, term)}</Badge>
<Badge variant={badgeForAppointmentState(apt.state)}>{aptStateLabel(apt.state, term, t)}</Badge>
</div>
))}
</div>
@@ -722,15 +721,15 @@ const Dashboard: FC = () => {
<Card>
<Card.Header>
<div>
<h3 className="font-heading text-lg text-theme-heading">Top servicios del mes</h3>
<p className="text-xs text-theme-muted">Por ingresos generados</p>
<h3 className="font-heading text-lg text-theme-heading">{t('dashboard.topServicesTitle')}</h3>
<p className="text-xs text-theme-muted">{t('dashboard.topServicesSub')}</p>
</div>
</Card.Header>
<Card.Body>
{loadingExtras ? (
<Skeleton count={6} className="h-6 w-full" />
) : topServices.length === 0 ? (
<EmptyState title="Sin datos" subtitle="No hay ventas en el mes." icon={<TrendingUp size={28} />} />
<EmptyState title={t('dashboard.noDataTitle')} subtitle={t('dashboard.noMonthSalesSub')} icon={<TrendingUp size={28} />} />
) : (
<div className="h-[280px] w-full">
<ResponsiveContainer width="100%" height={280}>
@@ -761,7 +760,7 @@ const Dashboard: FC = () => {
<Card>
<Card.Header>
<div>
<h3 className="font-heading text-lg text-theme-heading">Asistencia del mes</h3>
<h3 className="font-heading text-lg text-theme-heading">{t('dashboard.attendanceTitle')}</h3>
<p className="text-xs text-theme-muted">
{aptReport ? `${aptReport.total} citas · tasa no-show ${noShowRate}%` : 'Distribución por estado'}
</p>
@@ -771,7 +770,7 @@ const Dashboard: FC = () => {
{loadingExtras ? (
<Skeleton count={6} className="h-6 w-full" />
) : attendanceData.length === 0 ? (
<EmptyState title="Sin datos" subtitle="No hay citas en el mes." icon={<Calendar size={28} />} />
<EmptyState title={t('dashboard.noDataTitle')} subtitle={t('dashboard.noMonthAppointmentsSub')} icon={<Calendar size={28} />} />
) : (
<div className="flex flex-col sm:flex-row items-center gap-4">
<div className="h-[240px] w-full sm:w-1/2">
@@ -808,8 +807,8 @@ const Dashboard: FC = () => {
<Card>
<Card.Header>
<div>
<h3 className="font-heading text-lg text-theme-heading">Servicios destacados</h3>
<p className="text-xs text-theme-muted">Catálogo activo de tratamientos</p>
<h3 className="font-heading text-lg text-theme-heading">{t('dashboard.featuredTitle')}</h3>
<p className="text-xs text-theme-muted">{t('dashboard.featuredSub')}</p>
</div>
</Card.Header>
<Card.Body>
@@ -820,7 +819,7 @@ const Dashboard: FC = () => {
))}
</div>
) : services.length === 0 ? (
<EmptyState title="Sin servicios" subtitle="No hay servicios registrados." icon={<Sparkles size={28} />} />
<EmptyState title={t('dashboard.noServicesTitle')} subtitle={t('dashboard.noServicesSub')} icon={<Sparkles size={28} />} />
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{services.map((s) => (
@@ -846,8 +845,8 @@ const Dashboard: FC = () => {
<Card>
<Card.Header>
<div>
<h3 className="font-heading text-lg text-theme-heading">Actividad reciente</h3>
<p className="text-xs text-theme-muted">Últimos movimientos en la clínica</p>
<h3 className="font-heading text-lg text-theme-heading">{t('dashboard.recentTitle')}</h3>
<p className="text-xs text-theme-muted">{t('dashboard.recentSub')}</p>
</div>
</Card.Header>
<Card.Body>
@@ -858,7 +857,7 @@ const Dashboard: FC = () => {
))}
</div>
) : activityItems.length === 0 ? (
<EmptyState title="Sin actividad" subtitle="No hay movimientos recientes." icon={<Activity size={28} />} />
<EmptyState title={t('dashboard.noActivityTitle')} subtitle={t('dashboard.noActivitySub')} icon={<Activity size={28} />} />
) : (
<div className="space-y-3">
{activityItems.map((item) => (

View File

@@ -5,6 +5,7 @@ import { AlertTriangle, CheckCircle2, Loader2, Send } from 'lucide-react';
import { odooApi, type FormularioPublicoConfig } from '../services/odoo';
import FormularioCampos, { type FotoConCampo } from '../components/formulario/FormularioCampos';
import { necesitaFirma, necesitaFoto, validarLocal } from '../components/formulario/tipos';
import { useI18n, GLOSARIO_EN } from '../lib/i18n';
// Portal público para que el paciente llene un formulario desde su celular,
// con firma y fotos. Fuera del gate de autenticación: el token HMAC del enlace
@@ -12,6 +13,7 @@ import { necesitaFirma, necesitaFoto, validarLocal } from '../components/formula
const FormularioPublico: FC = () => {
const { token = '' } = useParams();
const { t, locale } = useI18n();
const [config, setConfig] = useState<FormularioPublicoConfig | null>(null);
const [loading, setLoading] = useState(true);
const [noDisponible, setNoDisponible] = useState<string | null>(null);
@@ -55,12 +57,14 @@ const FormularioPublico: FC = () => {
const pideFirma = necesitaFirma(campos, config?.plantilla.requiere_firma);
const pideFoto = necesitaFoto(campos, config?.plantilla.requiere_foto);
const term = (k: string, fallback: string) =>
(config?.terms as unknown as Record<string, string>)?.[k] || fallback;
locale === 'en'
? (GLOSARIO_EN[k as keyof typeof GLOSARIO_EN] || fallback)
: ((config?.terms as unknown as Record<string, string>)?.[k] || fallback);
const enviarLlenado = async () => {
if (!config) return;
const errs = validarLocal(campos, valores, firma, fotos, config.plantilla.requiere_firma, config.plantilla.requiere_foto);
if (pideFirma && !firmaNombre.trim()) errs.firma_nombre = 'Escribe el nombre de quien firma';
if (pideFirma && !firmaNombre.trim()) errs.firma_nombre = t('public.form.errSignature');
setErrores(errs);
if (Object.keys(errs).length) {
const primero = document.querySelector('[data-error="1"]');
@@ -91,11 +95,11 @@ const FormularioPublico: FC = () => {
hp,
});
if (res.status === 'success') setEnviado(true);
else setErrorEnvio(res.message || 'No se pudo enviar. Intenta de nuevo.');
else setErrorEnvio(res.message || t('public.form.errSend'));
} catch (err) {
const r = err as { response?: { status?: number; data?: { message?: string } } };
if (r?.response?.status === 409) { setYaLlenado(true); return; }
setErrorEnvio(r?.response?.data?.message || 'No se pudo enviar. Revisa tu conexión e intenta de nuevo.');
setErrorEnvio(r?.response?.data?.message || t('public.form.errSendNet'));
} finally {
setEnviando(false);
}
@@ -114,9 +118,9 @@ const FormularioPublico: FC = () => {
<Centro>
<div className="w-full max-w-sm rounded-3xl border border-theme-border bg-theme-surface p-8 text-center shadow-soft">
<AlertTriangle className="mx-auto mb-3 h-8 w-8 text-amber-500" />
<h1 className="font-heading text-lg text-theme-heading">Formulario no disponible</h1>
<h1 className="font-heading text-lg text-theme-heading">{t('public.form.unavailableTitle')}</h1>
<p className="mt-2 text-sm text-theme-muted">
El enlace no es válido o ya expiró. Pide uno nuevo a la clínica.
{t('public.form.unavailableBody')}
</p>
</div>
</Centro>
@@ -129,12 +133,12 @@ const FormularioPublico: FC = () => {
<div className="w-full max-w-sm rounded-3xl border border-theme-border bg-theme-surface p-8 text-center shadow-soft">
<CheckCircle2 className="mx-auto mb-3 h-10 w-10 text-emerald-500" />
<h1 className="font-heading text-lg text-theme-heading">
¡Gracias{config.paciente?.nombre ? `, ${config.paciente.nombre}` : ''}!
{config.paciente?.nombre ? t('public.form.thanksName', { name: config.paciente.nombre }) : t('public.form.thanks')}
</h1>
<p className="mt-2 text-sm text-theme-muted">
{enviado ? 'Tu formulario quedó registrado.' : 'Este formulario ya había sido llenado.'}
{enviado ? t('public.form.registered') : t('public.form.alreadyFilled')}
</p>
<p className="mt-4 text-xs text-theme-muted">Ya puedes cerrar esta página.</p>
<p className="mt-4 text-xs text-theme-muted">{t('public.form.canClose')}</p>
</div>
</Centro>
);
@@ -155,7 +159,7 @@ const FormularioPublico: FC = () => {
)}
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-theme-heading">{brand.nombre}</p>
<p className="truncate text-xs text-theme-muted">{config.plantilla.tipo_label || 'Formulario'}</p>
<p className="truncate text-xs text-theme-muted">{config.plantilla.tipo_label || t('public.form.form')}</p>
</div>
</div>
</header>
@@ -174,7 +178,7 @@ const FormularioPublico: FC = () => {
</p>
)}
{config.plantilla.duracion_estimada_min > 0 && (
<p className="mt-1 text-xs text-theme-muted">Tarda unos {config.plantilla.duracion_estimada_min} minutos.</p>
<p className="mt-1 text-xs text-theme-muted">{t('public.form.takesMinutes', { n: config.plantilla.duracion_estimada_min })}</p>
)}
<div className="mt-4">
@@ -211,8 +215,7 @@ const FormularioPublico: FC = () => {
)}
{pideFoto && pideFirma && (
<p className="mt-4 text-xs text-theme-muted">
Al enviar aceptas que tus datos y tu firma se guarden en el{' '}
{term('record_singular', 'Expediente').toLowerCase()} de la clínica.
{t('public.form.legalRecord', { record: term('record_singular', 'Expediente').toLowerCase() })}
</p>
)}
</main>
@@ -226,7 +229,7 @@ const FormularioPublico: FC = () => {
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-theme-accent px-4 py-4 text-base font-semibold text-theme-inverse disabled:opacity-60"
>
{enviando ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
{enviando ? 'Enviando…' : 'Enviar formulario'}
{enviando ? t('public.form.sending') : t('public.form.submit')}
</button>
</div>
</div>

View File

@@ -6,10 +6,12 @@ import { Button, Input } from '../components/ui';
import { odooApi } from '../services/odoo';
import { useAuth } from '../lib/auth';
import { useTheme } from '../lib/theme';
import { useI18n } from '../lib/i18n';
import { useBookingConfig } from '../lib/publicBooking';
const Login: FC = () => {
const { login, brand, setBrand } = useAuth();
const { t } = useI18n();
const { theme } = useTheme();
const navigate = useNavigate();
// Enlace público de reservas: solo se muestra si el portal está activo
@@ -38,7 +40,7 @@ const Login: FC = () => {
e.preventDefault();
setError(null);
if (!username.trim() || !password) {
setError('Escribe usuario y contraseña');
setError(t('login.errRequired'));
return;
}
try {
@@ -49,9 +51,9 @@ const Login: FC = () => {
// Mensajes amigables: nunca mostrar errores técnicos crudos.
const msg = err instanceof Error ? err.message : '';
if (msg.includes('Credenciales inválidas') || msg.includes('401')) {
setError('Usuario o contraseña incorrectos');
setError(t('login.errInvalid'));
} else {
setError('No se pudo iniciar sesión. Intenta de nuevo.');
setError(t('login.errGeneric'));
}
} finally {
setSubmitting(false);
@@ -84,8 +86,8 @@ const Login: FC = () => {
alt={brand.nombre}
className="h-10 w-auto object-contain mx-auto mb-6"
/>
<h1 className="font-display text-3xl mb-2 tracking-wide" style={{ color: isHomeNest ? 'var(--text-heading)' : '#fff' }}>Bienvenido</h1>
<p className="text-sm" style={{ color: isHomeNest ? 'var(--text-muted)' : 'rgba(255,255,255,0.6)' }}>Inicia sesión para continuar</p>
<h1 className="font-display text-3xl mb-2 tracking-wide" style={{ color: isHomeNest ? 'var(--text-heading)' : '#fff' }}>{t('login.welcome')}</h1>
<p className="text-sm" style={{ color: isHomeNest ? 'var(--text-muted)' : 'rgba(255,255,255,0.6)' }}>{t('login.subtitle')}</p>
</div>
<form
@@ -98,7 +100,7 @@ const Login: FC = () => {
}}
>
<Input
label="Usuario"
label={t('login.username')}
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
@@ -111,7 +113,7 @@ const Login: FC = () => {
/>
<div className="relative">
<Input
label="Contraseña"
label={t('login.password')}
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
@@ -125,8 +127,8 @@ const Login: FC = () => {
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? 'Ocultar contraseña' : 'Mostrar contraseña'}
title={showPassword ? 'Ocultar contraseña' : 'Mostrar contraseña'}
aria-label={showPassword ? t('login.hidePassword') : t('login.showPassword')}
title={showPassword ? t('login.hidePassword') : t('login.showPassword')}
className="absolute right-3 bottom-2.5 p-1 hover:opacity-80"
style={{ color: isHomeNest ? 'var(--text-muted)' : 'rgba(255,255,255,0.5)' }}
>
@@ -136,7 +138,7 @@ const Login: FC = () => {
{error && <p className="text-sm" style={{ color: 'var(--color-coral)' }}>{error}</p>}
<Button type="submit" className="w-full" loading={submitting}>
<LogIn size={16} className="mr-2" />
Entrar
{t('login.submit')}
</Button>
</form>
@@ -147,7 +149,7 @@ const Login: FC = () => {
className="inline-flex items-center gap-1 underline underline-offset-2 hover:opacity-80"
style={{ color: isHomeNest ? 'var(--text-muted)' : 'rgba(255,255,255,0.6)' }}
>
Reservar en línea
{t('login.bookOnline')}
</a>
</p>
)}

View File

@@ -35,7 +35,7 @@ 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 { useI18n, useBusinessTerm } from '../lib/i18n';
import { useSucursales } from '../lib/sucursales';
const fmtMoney = (n: number) =>
@@ -126,7 +126,8 @@ const CRITICAL_ALERTS: { key: keyof Patient; label: string }[] = [
const Pacientes: FC = () => {
const { brand, can } = useAuth();
const { term } = useGlosario();
const term = useBusinessTerm();
const { t } = useI18n();
const { labelFor: branchLabel } = useSucursales();
const [patients, setPatients] = useState<Patient[]>([]);
const [loading, setLoading] = useState(true);
@@ -432,14 +433,14 @@ const Pacientes: FC = () => {
const TableHeader = () => (
<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">Expediente</th>
<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">{t('patients.colName')}</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">{t('patients.colRecord')}</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden sm:table-cell">{t('patients.colPhone')}</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">{t('patients.colAge')}</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">{t('patients.colLastVisit')}</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>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">{t('patients.colPoints')}</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">{t('patients.colActions')}</th>
</tr>
</thead>
);
@@ -528,16 +529,16 @@ const Pacientes: FC = () => {
};
return (
<Layout title={term('patient_plural')} subtitle="Directorio de pacientes">
<PageHeader title={term('patient_plural')} subtitle="Busca, crea y gestiona pacientes">
<Layout title={term('patient_plural')} subtitle={t('patients.subtitle')}>
<PageHeader title={term('patient_plural')} subtitle={t('patients.pageSubtitle')}>
<Button variant="outline" onClick={exportPatients} loading={exporting} disabled={patients.length === 0 || !can('pacientes.exportar')}
title={can('pacientes.exportar') ? undefined : 'Sin permiso para exportar'}>
title={can('pacientes.exportar') ? undefined : t('patients.noExportPerm')}>
<Download size={16} className="mr-2" />
Exportar CSV
{t('patients.exportCsv')}
</Button>
<Button onClick={openCreate}>
<Plus size={16} className="mr-2" />
Nuevo {term('patient_singular').toLowerCase()}
{t('patients.newWord')} {term('patient_singular').toLowerCase()}
</Button>
</PageHeader>
@@ -547,7 +548,7 @@ const Pacientes: FC = () => {
<div className="relative w-full sm:max-w-sm">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted pointer-events-none" />
<Input
placeholder="Buscar por nombre, apellidos o teléfono..."
placeholder={t('patients.searchPlaceholder')}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') setSearch(searchInput.trim()); if (e.key === 'Escape') { setSearchInput(''); setSearch(''); } }}
@@ -558,8 +559,8 @@ const Pacientes: FC = () => {
<button
type="button"
onClick={() => { setSearchInput(''); setSearch(''); }}
title="Limpiar búsqueda"
aria-label="Limpiar búsqueda"
title={t('patients.clearSearch')}
aria-label={t('patients.clearSearch')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-1 rounded-lg text-theme-muted hover:text-theme-heading hover:bg-theme-bg"
>
<X size={14} />
@@ -568,7 +569,7 @@ const Pacientes: FC = () => {
</div>
<Select
options={[
{ value: '', label: 'Todos los médicos' },
{ value: '', label: t('patients.allDoctors') },
...doctors.map((d) => ({
value: String(d.id),
label: d.patient_count !== undefined ? `${d.name} (${d.patient_count})` : d.name,
@@ -584,9 +585,9 @@ const Pacientes: FC = () => {
<div className="flex flex-wrap items-center gap-2 mb-4 sm:mb-5">
{([
{ key: 'vip', label: 'VIP', active: onlyVip, set: setOnlyVip },
{ key: 'recent', label: 'Visitas últimos 6 meses', active: onlyRecent, set: (v: boolean) => { setOnlyRecent(v); if (v) setOnlyStale(false); } },
{ key: 'stale', label: 'Sin visita +6 meses', active: onlyStale, set: (v: boolean) => { setOnlyStale(v); if (v) setOnlyRecent(false); } },
{ key: 'incomplete', label: 'Expediente incompleto', active: onlyIncomplete, set: setOnlyIncomplete },
{ key: 'recent', label: t('patients.filterRecent'), active: onlyRecent, set: (v: boolean) => { setOnlyRecent(v); if (v) setOnlyStale(false); } },
{ key: 'stale', label: t('patients.filterStale'), active: onlyStale, set: (v: boolean) => { setOnlyStale(v); if (v) setOnlyRecent(false); } },
{ key: 'incomplete', label: t('patients.filterIncomplete'), active: onlyIncomplete, set: setOnlyIncomplete },
] as const).map((chip) => (
<button
key={chip.key}
@@ -607,7 +608,7 @@ const Pacientes: FC = () => {
onClick={() => { setOnlyVip(false); setOnlyRecent(false); setOnlyStale(false); setOnlyIncomplete(false); setDoctorFilter(''); }}
className="text-xs text-theme-muted underline underline-offset-2 hover:text-theme-heading"
>
Quitar filtros
{t('patients.clearFilters')}
</button>
)}
</div>
@@ -618,9 +619,9 @@ const Pacientes: FC = () => {
<Skeleton count={6} className="h-12 w-full" />
) : patients.length === 0 ? (
<EmptyState
title="Sin pacientes"
subtitle="No se encontraron pacientes."
actionLabel={`Nuevo ${term('patient_singular').toLowerCase()}`}
title={t('patients.emptyTitle')}
subtitle={t('patients.emptySub')}
actionLabel={`${t('patients.newWord')} ${term('patient_singular').toLowerCase()}`}
onAction={openCreate}
/>
) : (
@@ -639,7 +640,7 @@ const Pacientes: FC = () => {
<td className="p-3 text-sm">
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${completionBadgeClasses(p.expediente_completion)}`}
title={p.expediente_missing && p.expediente_missing.length > 0 ? `Falta: ${p.expediente_missing.join(', ')}` : 'Expediente completo'}
title={p.expediente_missing && p.expediente_missing.length > 0 ? t('patients.missingRecord', { list: p.expediente_missing.join(', ') }) : t('patients.complete')}
>
{p.expediente_completion ?? 0}%
</span>
@@ -1003,11 +1004,11 @@ const Pacientes: FC = () => {
<div className="flex gap-2 border-b border-theme-border pb-2 overflow-x-auto">
{([
{ key: 'info', label: 'Información general' },
{ key: 'clinical', label: 'Historia clínica' },
{ key: 'timeline', label: 'Historial' },
{ key: 'docs', label: 'Documentos' },
{ key: 'formularios', label: 'Formularios' },
{ key: 'info', label: t('patients.tabInfo') },
{ key: 'clinical', label: t('patients.tabClinical') },
{ key: 'timeline', label: t('patients.tabTimeline') },
{ key: 'docs', label: t('patients.tabDocs') },
{ key: 'formularios', label: t('patients.tabForms') },
] as const).map(({ key, label }) => (
<button
key={key}
@@ -1106,7 +1107,7 @@ const Pacientes: FC = () => {
<InfoRow label="Contacto de emergencia" value={selected?.emergency_contact} icon={AlertCircle} />
<InfoRow label="Tel. emergencia" value={selected?.emergency_phone} icon={Phone} />
<InfoRow label="Recomendado por" value={selected?.referred_by} icon={Users} />
<InfoRow label="Médico asignado" value={selected?.primary_doctor || 'Sin asignar'} icon={Stethoscope} />
<InfoRow label="Médico asignado" value={selected?.primary_doctor || t('patients.unassigned')} icon={Stethoscope} />
<InfoRow label="Puntos" value={`${selected?.wallet_points ?? 0} pts`} icon={Heart} />
</div>
{selected?.address_notes && (
@@ -1191,7 +1192,7 @@ const Pacientes: FC = () => {
<>
<section>
<h4 className="text-sm font-semibold text-theme-heading mb-3 flex items-center">
<FileText size={16} className="mr-2 text-theme-muted" /> Expediente escaneado
<FileText size={16} className="mr-2 text-theme-muted" /> {t('patients.scannedRecord')}
</h4>
<PatientAdjuntos patientId={selected.id} kind="expediente" />
</section>
@@ -1348,10 +1349,10 @@ const Pacientes: FC = () => {
<table className="w-full text-sm">
<thead className="bg-theme-bg">
<tr>
<th className="text-left p-2.5 text-xs font-medium text-theme-muted">Servicio</th>
<th className="text-right p-2.5 text-xs font-medium text-theme-muted">Cant.</th>
<th className="text-right p-2.5 text-xs font-medium text-theme-muted">Precio</th>
<th className="text-right p-2.5 text-xs font-medium text-theme-muted">Subtotal</th>
<th className="text-left p-2.5 text-xs font-medium text-theme-muted">{t('patients.colService')}</th>
<th className="text-right p-2.5 text-xs font-medium text-theme-muted">{t('patients.colQty')}</th>
<th className="text-right p-2.5 text-xs font-medium text-theme-muted">{t('patients.colPrice')}</th>
<th className="text-right p-2.5 text-xs font-medium text-theme-muted">{t('common.subtotal')}</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">

View File

@@ -21,6 +21,7 @@ import {
import { odooApi, type Service, type Patient, type InventoryItem, type PosCheckoutResult, type Doctor, type Paquete, type Membresia } from '../services/odoo';
import type { PatientPackage } from '../services/odoo';
import { useAuth } from '../lib/auth';
import { useI18n } from '../lib/i18n';
import { useClinicSettings } from '../lib/clinic-settings';
const fmtMoney = (n: number) =>
@@ -29,16 +30,6 @@ const fmtMoney = (n: number) =>
const fmtUsd = (n: number) =>
`$${(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} USD`;
const METODO_OPTIONS = [
{ value: 'cash', label: 'Efectivo' },
{ value: 'card', label: 'Tarjeta' },
{ value: 'transfer', label: 'Transferencia' },
{ value: 'stripe', label: 'Stripe' },
{ value: 'mercadopago', label: 'MercadoPago' },
{ value: 'saldo_favor', label: 'Saldo a favor' },
];
const metodoLabel = (m: string) => METODO_OPTIONS.find((o) => o.value === m)?.label || m;
interface TicketLine {
key: string;
name: string;
@@ -57,6 +48,16 @@ interface TicketLine {
const Pos: FC = () => {
const { openPatient } = usePatientDrawer();
const { can } = useAuth();
const { t } = useI18n();
const metodoOpts = useMemo(() => [
{ value: 'cash', label: t('pos.method.cash') },
{ value: 'card', label: t('pos.method.card') },
{ value: 'transfer', label: t('pos.method.transfer') },
{ value: 'stripe', label: 'Stripe' },
{ value: 'mercadopago', label: 'MercadoPago' },
{ value: 'saldo_favor', label: t('pos.method.credit') },
], [t]);
const metodoLabelL = (m: string) => metodoOpts.find((o) => o.value === m)?.label || m;
const impuestos = useClinicSettings().impuestos;
const membresiasCfg = useClinicSettings().membresias;
const puedeImpuestos = can('ventas.impuestos');
@@ -397,7 +398,7 @@ const Pos: FC = () => {
const limpiarTicket = () => {
if (lines.length === 0) return;
if (window.confirm('¿Vaciar el ticket?')) setLines([]);
if (window.confirm(t('pos.confirmEmptyTicket'))) setLines([]);
};
// Desglose del ticket. Con los impuestos apagados: bruto === base, impuesto 0
@@ -802,10 +803,10 @@ const Pos: FC = () => {
</div>
) : altaRapida ? (
<div className="p-2.5 bg-theme-bg rounded-xl space-y-2">
<Input placeholder="Nombre *" value={altaForm.name} onChange={(e) => setAltaForm({ ...altaForm, name: e.target.value })} />
<Input placeholder="Teléfono *" value={altaForm.phone} onChange={(e) => setAltaForm({ ...altaForm, phone: e.target.value })} />
<Input placeholder={t('pos.nameRequired')} value={altaForm.name} onChange={(e) => setAltaForm({ ...altaForm, name: e.target.value })} />
<Input placeholder={t('pos.phoneRequired')} value={altaForm.phone} onChange={(e) => setAltaForm({ ...altaForm, phone: e.target.value })} />
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setAltaRapida(false)}>Cancelar</Button>
<Button variant="outline" size="sm" onClick={() => setAltaRapida(false)}>{t('common.cancel')}</Button>
<Button size="sm" onClick={crearPacienteRapido}>Crear paciente</Button>
</div>
</div>
@@ -813,7 +814,7 @@ const Pos: FC = () => {
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
<Input
placeholder="Buscar paciente por nombre o teléfono..."
placeholder={t('pos.searchPatient')}
value={pacienteQuery}
onChange={(e) => setPacienteQuery(e.target.value)}
className="pl-10"
@@ -934,10 +935,10 @@ const Pos: FC = () => {
const totalsBlock = (
<>
<div className="flex items-center justify-between text-sm">
<span className="text-theme-muted">Subtotal</span>
<span className="text-theme-muted">{t('common.subtotal')}</span>
<span className="text-theme-heading">
{fmtMoney(subtotal)}
<span className="text-xs text-theme-muted ml-1.5" title={toUsd(subtotal) === null ? 'Configura el tipo de cambio en Configuración' : undefined}>
<span className="text-xs text-theme-muted ml-1.5" title={toUsd(subtotal) === null ? t('pos.setRate') : undefined}>
{toUsd(subtotal) !== null ? `≈ ${fmtUsd(toUsd(subtotal)!)}` : '—'}
</span>
</span>
@@ -945,7 +946,7 @@ const Pos: FC = () => {
{impuestos.activo && (
<div className="flex items-center justify-between text-sm">
<span className="text-theme-muted">
{impuestos.nombre}{impuestos.precios_incluyen_impuesto ? ' (incluido)' : ''}
{impuestos.nombre}{impuestos.precios_incluyen_impuesto ? t('pos.taxIncluded') : ''}
</span>
<span className="text-theme-heading">
{fmtMoney(impuesto)}
@@ -956,7 +957,7 @@ const Pos: FC = () => {
</div>
)}
<div className="flex items-center justify-between text-sm gap-2">
<span className="text-theme-muted">Descuento</span>
<span className="text-theme-muted">{t('pos.discount')}</span>
<span className="flex items-center gap-1.5">
{discountNum > 0 && toUsd(discountNum) !== null && (
<span className="text-xs text-theme-muted">≈ {fmtUsd(toUsd(discountNum)!)}</span>
@@ -969,18 +970,18 @@ const Pos: FC = () => {
onChange={(e) => setDiscount(e.target.value)}
placeholder="0"
disabled={!can('ventas.descuentos')}
title={can('ventas.descuentos') ? undefined : 'Sin permiso para aplicar descuentos'}
title={can('ventas.descuentos') ? undefined : t('pos.noDiscountPerm')}
className="w-28 border border-theme-border-strong rounded-lg px-2 py-1 text-sm text-right disabled:opacity-50 disabled:cursor-not-allowed"
/>
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-base font-semibold text-theme-heading">Total</span>
<span className="text-base font-semibold text-theme-heading">{t('common.total')}</span>
<span className="text-right">
<span className="text-xl font-heading font-bold text-theme-heading">{fmtMoney(total)}</span>
<span
className="block text-xs text-theme-muted"
title={toUsd(total) === null ? 'Configura el tipo de cambio en Configuración' : undefined}
title={toUsd(total) === null ? t('pos.setRate') : undefined}
>
{toUsd(total) !== null ? `≈ ${fmtUsd(toUsd(total)!)}` : '—'}
</span>
@@ -993,27 +994,27 @@ const Pos: FC = () => {
className="w-full mt-1 !py-3 text-base"
onClick={abrirCobro}
disabled={lines.length === 0 || !paciente || !can('ventas.vender')}
title={can('ventas.vender') ? 'Cobrar (F9)' : 'Sin permiso para vender'}
title={can('ventas.vender') ? t('pos.chargeTitle') : t('pos.noSellPerm')}
>
<ShoppingBag size={18} className="mr-2" />
Cobrar {fmtMoney(total)}
{t('pos.chargeAmount', { amount: fmtMoney(total) })}
</Button>
<p className="flex items-center justify-center gap-1.5 text-[11px] text-theme-muted">
<Keyboard size={11} aria-hidden="true" />
F2 buscar · F9 cobrar · +/− cantidad · Esc cerrar
{t('pos.shortcutsHint')}
<button
type="button"
onClick={() => setShortcutsOpen(true)}
className="underline hover:text-theme-heading"
>
Ver atajos
{t('pos.viewShortcuts')}
</button>
</p>
</>
);
return (
<Layout title="Punto de Venta" subtitle="Arma el ticket y cobra en un solo paso">
<Layout title={t('pos.title')} subtitle={t('pos.subtitle')}>
{!cajaAbierta ? (
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-theme-warning text-theme-warning-text text-xs">
@@ -1042,7 +1043,7 @@ const Pos: FC = () => {
<div className="relative mb-3" ref={searchWrapRef}>
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
<Input
placeholder={catMode === 'servicios' ? 'Buscar servicio... (F2)' : catMode === 'paquetes' ? 'Buscar paquete... (F2)' : 'Buscar artículo... (F2)'}
placeholder={catMode === 'servicios' ? t('pos.searchService') : catMode === 'paquetes' ? t('pos.searchPackage') : t('pos.searchItem')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
@@ -1252,8 +1253,8 @@ const Pos: FC = () => {
<td className="px-3 py-2 text-theme-muted hidden md:table-cell">{sv.duration_min} min</td>
<td className="px-3 py-2 text-right font-semibold text-theme-heading">{fmtMoney(sv.price)}</td>
<td className="px-3 py-2 text-right">
<Button size="sm" onClick={() => addLine(sv)} title="Agregar al ticket">
<Plus size={13} className="mr-1" /> Agregar
<Button size="sm" onClick={() => addLine(sv)} title={t('pos.addToTicket')}>
<Plus size={13} className="mr-1" /> {t('pos.add')}
</Button>
</td>
</tr>
@@ -1274,8 +1275,8 @@ const Pos: FC = () => {
{(it.price ?? 0) > 0 ? fmtMoney(it.price!) : <span className="text-xs font-normal text-theme-muted">sin precio</span>}
</td>
<td className="px-3 py-2 text-right">
<Button size="sm" onClick={() => addItemLine(it)} title="Agregar al ticket">
<Plus size={13} className="mr-1" /> Agregar
<Button size="sm" onClick={() => addItemLine(it)} title={t('pos.addToTicket')}>
<Plus size={13} className="mr-1" /> {t('pos.add')}
</Button>
</td>
</tr>
@@ -1346,7 +1347,7 @@ const Pos: FC = () => {
<div className="flex items-center justify-between mb-2.5">
<h3 className="font-heading text-lg text-theme-heading">Ticket</h3>
{lines.length > 0 && (
<Button variant="ghost" size="sm" onClick={limpiarTicket} title="Vaciar ticket">
<Button variant="ghost" size="sm" onClick={limpiarTicket} title={t('pos.emptyTicket')}>
<Trash2 size={15} className="text-theme-muted" />
</Button>
)}
@@ -1419,7 +1420,7 @@ const Pos: FC = () => {
maxWidth="sm"
footer={
<>
<Button variant="outline" onClick={() => setCajaModalOpen(false)}>Cancelar</Button>
<Button variant="outline" onClick={() => setCajaModalOpen(false)}>{t('common.cancel')}</Button>
<Button onClick={abrirCajaDesdePos} loading={cajaAbriendo}>Abrir caja</Button>
</>
}
@@ -1430,7 +1431,7 @@ const Pos: FC = () => {
se hace en <Link to="/cortes" className="underline font-medium text-theme-heading">Cortes de caja</Link>.
</p>
<Input
label="Efectivo inicial (fondo de caja)"
label={t('pos.initialCash')}
type="number"
min={0}
step={0.01}
@@ -1444,11 +1445,11 @@ const Pos: FC = () => {
<Modal
isOpen={cobroOpen}
onClose={() => setCobroOpen(false)}
title={`Cobrar ${fmtMoney(total)}`}
title={t('pos.chargeAmount', { amount: fmtMoney(total) })}
maxWidth="md"
footer={
<>
<Button variant="outline" onClick={() => setCobroOpen(false)}>Cancelar</Button>
<Button variant="outline" onClick={() => setCobroOpen(false)}>{t('common.cancel')}</Button>
<Button onClick={cobrar} loading={cobrando}>Confirmar cobro</Button>
</>
}
@@ -1460,7 +1461,7 @@ const Pos: FC = () => {
Se asigna la venta completa a este doctor para sus comisiones.
</p>
</div>
<Select label="Método de pago" options={METODO_OPTIONS} value={metodo} onChange={(e) => setMetodo(e.target.value)} />
<Select label={t('pos.methodLabel')} options={metodoOpts} value={metodo} onChange={(e) => setMetodo(e.target.value)} />
{metodo === 'cash' && !conPuntos && (
<>
<div>
@@ -1492,7 +1493,7 @@ const Pos: FC = () => {
)}
</div>
<Input
label={moneda === 'USD' ? 'Recibido (USD)' : 'Recibido'}
label={moneda === 'USD' ? t('pos.receivedUsd') : t('pos.received')}
type="number"
min={0}
step={10}
@@ -1505,7 +1506,7 @@ const Pos: FC = () => {
)}
{recibido && (
<p className="text-sm">
<span className="text-theme-muted">Cambio: </span>
<span className="text-theme-muted">{t('pos.change')}</span>
<span className={`font-semibold ${cambio > 0 ? 'text-theme-heading' : 'text-theme-muted'}`}>
{fmtMoney(cambio)}
{moneda === 'USD' && cambio > 0 && (
@@ -1558,7 +1559,7 @@ const Pos: FC = () => {
tax: resultado.sale.tax_total ?? resultado.impuesto ?? 0,
tax_name: impuestos.nombre,
total: resultado.sale.total,
metodo: resultado.payment ? metodoLabel(resultado.payment.payment_method) : 'Puntos',
metodo: resultado.payment ? metodoLabelL(resultado.payment.payment_method) : t('pos.points'),
puntos_usados: resultado.puntos_usados,
puntos_ganados: resultado.puntos_ganados,
wallet_points: resultado.wallet_points,

View File

@@ -50,6 +50,7 @@ import {
useReportFavorites,
} from '../lib/reportRange';
import { useClinicSettings } from '../lib/clinic-settings';
import { useI18n } from '../lib/i18n';
const COLORS = ['#57534e', '#a8a29e', '#d6d3d1', '#78716c', '#f59e0b', '#10b981'];
@@ -155,6 +156,7 @@ const ReportesContent: FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const { hasRole, canSee, can, brand } = useAuth();
const { membresias: membresiasCfg, recursos: recursosCfg, compras: comprasCfg } = useClinicSettings();
const { t } = useI18n();
const esAdmin = hasRole('admin');
// PuedeVer una pestana = canSee('/reportes?tab=x'): la matriz guarda claves
// por pestana y, cuando no existe la del tab, canSee cae al prefijo
@@ -303,22 +305,22 @@ const ReportesContent: FC = () => {
};
return (
<Layout title="Reportes" subtitle="Análisis del mes y día">
<PageHeader title="Reportes" subtitle={tabInfo?.desc || 'Análisis de la operación'}>
<Layout title={t('reports.title')} subtitle={t('reports.subtitle')}>
<PageHeader title={t('reports.title')} subtitle={tabInfo ? t(`reports.tab.${tabInfo.key}.desc`) : t('reports.opSubtitle')}>
{tab === 'general' && can('reportes.exportar') && (
<Button variant="outline" onClick={exportExcel} disabled={loading || !salesReport} loading={exporting}>
<Download size={16} className="mr-2" />
Exportar
{t('reports.export')}
</Button>
)}
{puedeTab('exportar') && can('reportes.exportar') && (
<Button
variant={tab === 'exportar' ? 'primary' : 'outline'}
onClick={() => setTab('exportar')}
title="Exportar listas de pacientes (correos, VIP, etiquetas)"
title={t('reports.exportDataTitle')}
>
<Share2 size={16} className="mr-2" />
Exportar datos
{t('reports.exportData')}
</Button>
)}
</PageHeader>
@@ -327,7 +329,7 @@ const ReportesContent: FC = () => {
<Card className="mb-4">
<Card.Body className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs font-medium text-theme-muted uppercase mr-1">Periodo</span>
<span className="text-xs font-medium text-theme-muted uppercase mr-1">{t('reports.period')}</span>
{REPORT_PRESETS.map((p) => (
<button
key={p.value}
@@ -339,20 +341,20 @@ const ReportesContent: FC = () => {
: 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong'
}`}
>
{p.label}
{t(`reports.preset.${p.value}`)}
</button>
))}
</div>
<div className="flex flex-wrap items-end gap-3">
<Input
label="Inicio"
label={t('reports.start')}
type="date"
value={start}
onChange={(e) => setRange('custom', e.target.value, end)}
className="sm:max-w-[170px]"
/>
<Input
label="Fin"
label={t('reports.end')}
type="date"
value={end}
onChange={(e) => setRange('custom', start, e.target.value)}
@@ -412,7 +414,7 @@ const ReportesContent: FC = () => {
key={f.key}
type="button"
onClick={() => setTab(f.tabs[0].key)}
title={f.hint}
title={t(`reports.family.${f.key}.hint`)}
className={`inline-flex items-center gap-1.5 px-4 py-2 text-sm font-semibold rounded-xl transition ${
activa
? 'bg-theme-accent text-theme-inverse shadow-card'
@@ -420,7 +422,7 @@ const ReportesContent: FC = () => {
}`}
>
<Icono size={15} />
{f.label}
{t(`reports.family.${f.key}`)}
</button>
);
})}
@@ -429,30 +431,30 @@ const ReportesContent: FC = () => {
{/* Pestañas de la familia activa */}
<div className="mb-6 pb-3 border-b border-theme-border">
<div className="flex flex-wrap gap-1.5">
{tabsFamilia.map((t) => {
const activo = tab === t.key;
{tabsFamilia.map((tabItem) => {
const activo = tab === tabItem.key;
return (
<span
key={t.key}
key={tabItem.key}
className={`inline-flex items-center rounded-full border overflow-hidden text-sm font-medium transition ${
activo
? 'bg-theme-accent text-theme-inverse border-theme-accent'
: 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong'
}`}
>
<button type="button" onClick={() => setTab(t.key)} title={t.desc} className="px-4 py-2">
{t.label}
<button type="button" onClick={() => setTab(tabItem.key)} title={t(`reports.tab.${tabItem.key}.desc`)} className="px-4 py-2">
{t(`reports.tab.${tabItem.key}`)}
</button>
<button
type="button"
onClick={() => toggle(t.key)}
title={isFav(t.key) ? 'Quitar de favoritos' : 'Fijar en favoritos'}
aria-label={isFav(t.key) ? 'Quitar de favoritos' : 'Fijar en favoritos'}
onClick={() => toggle(tabItem.key)}
title={isFav(tabItem.key) ? t('reports.favRemove') : t('reports.favAdd')}
aria-label={isFav(tabItem.key) ? t('reports.favRemove') : t('reports.favAdd')}
className={`px-2.5 py-2 border-l ${
activo ? 'border-theme-inverse/30' : 'border-theme-border'
} ${isFav(t.key) ? 'text-amber-500' : activo ? 'text-theme-inverse/60 hover:text-theme-inverse' : 'text-theme-muted/60 hover:text-theme-heading'}`}
} ${isFav(tabItem.key) ? 'text-amber-500' : activo ? 'text-theme-inverse/60 hover:text-theme-inverse' : 'text-theme-muted/60 hover:text-theme-heading'}`}
>
<Star size={13} fill={isFav(t.key) ? 'currentColor' : 'none'} />
<Star size={13} fill={isFav(tabItem.key) ? 'currentColor' : 'none'} />
</button>
</span>
);
@@ -510,7 +512,7 @@ const ReportesContent: FC = () => {
<Card.Body>
<div className="flex flex-col sm:flex-row sm:items-end gap-3 sm:gap-4">
<Input
label="Día efectivo"
label={t('reports.effectiveDay')}
type="date"
value={today}
onChange={(e) => setToday(e.target.value)}

View File

@@ -10,6 +10,7 @@ import {
useBookingConfig, proximosDias, fechaISO, etiquetaDiaCorto, fechaLargaISO, construirIcs,
descargarIcs, normalizarTelefono, telefonoValido,
} from '../lib/publicBooking';
import { useI18n, GLOSARIO_EN } from '../lib/i18n';
// Portal público de reservas en línea (sin sesión). Mobile-first: la mayoría
// de las clientas reservan del celular. Respeta brand y glosario del backend.
@@ -20,6 +21,7 @@ type GrupoServicios = { code: string; nombre: string; servicios: ServicioPortal[
const Reservar: FC = () => {
const navigate = useNavigate();
const { config, loading } = useBookingConfig();
const { t, locale } = useI18n();
const [paso, setPaso] = useState(0);
const [servicioId, setServicioId] = useState<number | null>(null);
@@ -60,9 +62,9 @@ const Reservar: FC = () => {
if (svcs.length) { out.push({ code: c.code, nombre: c.name, servicios: svcs }); usados.add(c.code); }
}
const resto = config.servicios.filter((s) => !usados.has(s.categoria));
if (resto.length) out.push({ code: 'otros', nombre: 'Otros', servicios: resto });
if (resto.length) out.push({ code: 'otros', nombre: t('public.others'), servicios: resto });
return out;
}, [config]);
}, [config, t]);
useEffect(() => {
if (!config || pasoActual !== 'fecha' || !servicioId) return;
@@ -81,8 +83,8 @@ const Reservar: FC = () => {
const enviar = async () => {
if (!servicioId || !hora) return;
if (nombre.trim().length < 2) { setError('Escribe tu nombre'); return; }
if (!telefonoValido(telefono)) { setError('Escribe un teléfono válido (10 dígitos)'); return; }
if (nombre.trim().length < 2) { setError(t('public.errName')); return; }
if (!telefonoValido(telefono)) { setError(t('public.errPhone')); return; }
setEnviando(true);
setError(null);
try {
@@ -102,13 +104,16 @@ const Reservar: FC = () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
setError(msg || 'No se pudo completar la reserva. Intenta de nuevo.');
setError(msg || t('public.errGeneric'));
} finally {
setEnviando(false);
}
};
const term = (k: string, fallback: string) => config?.terms?.[k as keyof typeof config.terms] || fallback;
const term = (k: string, fallback: string) =>
locale === 'en'
? (GLOSARIO_EN[k as keyof typeof GLOSARIO_EN] || fallback)
: (config?.terms?.[k as keyof typeof config.terms] || fallback);
if (loading) {
return (
@@ -123,9 +128,9 @@ const Reservar: FC = () => {
<Centro>
<div className="w-full max-w-sm rounded-3xl border border-theme-border bg-theme-surface p-8 text-center shadow-soft">
<Clock className="mx-auto mb-3 h-8 w-8 text-theme-muted" />
<h1 className="font-heading text-lg text-theme-heading">Reservas en línea no disponibles</h1>
<h1 className="font-heading text-lg text-theme-heading">{t('public.unavailableTitle')}</h1>
<p className="mt-2 text-sm text-theme-muted">
Por el momento no puedes agendar desde aquí. Comunícate directamente con la clínica.
{t('public.unavailableBody')}
</p>
</div>
</Centro>
@@ -157,9 +162,9 @@ const Reservar: FC = () => {
<Check className="h-6 w-6 text-emerald-600" />
</div>
<h1 className="font-heading text-xl text-theme-heading">
{resultado.requiere_confirmacion ? 'Solicitud recibida' : '¡Reserva confirmada!'}
{resultado.requiere_confirmacion ? t('public.requestReceived') : t('public.confirmed')}
</h1>
<p className="mt-1 text-sm text-theme-muted">Folio <span className="font-semibold text-theme-heading">{resultado.folio}</span></p>
<p className="mt-1 text-sm text-theme-muted">{t('public.folioWord')} <span className="font-semibold text-theme-heading">{resultado.folio}</span></p>
<div className="mt-5 space-y-2 rounded-2xl bg-theme-bg p-4 text-left text-sm">
<Linea icon={<CalendarPlus className="h-4 w-4" />} texto={`${fechaLargaISO(resultado.fecha)} · ${resultado.hora}`} />
<Linea icon={<Check className="h-4 w-4" />} texto={resultado.servicio} />
@@ -171,21 +176,21 @@ const Reservar: FC = () => {
)}
{resultado.requiere_confirmacion && !resultado.mensaje_confirmacion && (
<p className="mt-4 text-sm text-theme-muted">
Tu reserva quedó registrada y la clínica la confirmará en breve.
{t('public.willConfirm')}
</p>
)}
<div className="mt-6 flex flex-col gap-2">
<Button onClick={() => descargarIcs(`cita-${resultado.folio}`, icalUrl)}>
<CalendarPlus size={16} className="mr-2" /> Agregar al calendario
<CalendarPlus size={16} className="mr-2" /> {t('public.addCalendar')}
</Button>
<Button variant="ghost" onClick={() => navigate(`/reservar/cita/${resultado.token_de_gestion}`)}>
Ver o cancelar mi reserva
{t('public.manage')}
</Button>
</div>
</div>
<div className="rounded-2xl border border-theme-border bg-theme-surface p-4">
<p className="text-xs font-medium text-theme-muted">Guarda este enlace para gestionar tu reserva</p>
<p className="text-xs font-medium text-theme-muted">{t('public.saveLink')}</p>
<div className="mt-2 flex items-center gap-2">
<input
readOnly
@@ -198,7 +203,7 @@ const Reservar: FC = () => {
try { await navigator.clipboard.writeText(enlaceGestion); setCopiado(true); } catch { /* ignore */ }
}}
>
<Copy size={14} className="mr-1" /> {copiado ? 'Copiado' : 'Copiar'}
<Copy size={14} className="mr-1" /> {copiado ? t('public.copied') : t('public.copy')}
</Button>
</div>
</div>
@@ -209,11 +214,11 @@ const Reservar: FC = () => {
const tituloPaso = (): string => {
switch (pasoActual) {
case 'servicio': return `Elige tu ${term('service_singular', 'servicio').toLowerCase()}`;
case 'sucursal': return `Elige ${term('branch_singular', 'sucursal').toLowerCase()}`;
case 'especialista': return `Elige ${term('doctor_singular', 'especialista').toLowerCase()}`;
case 'fecha': return 'Elige fecha y hora';
case 'datos': return 'Tus datos';
case 'servicio': return t('public.stepService', { term: term('service_singular', 'servicio').toLowerCase() });
case 'sucursal': return t('public.stepBranch', { term: term('branch_singular', 'sucursal').toLowerCase() });
case 'especialista': return t('public.stepDoctor', { term: term('doctor_singular', 'especialista').toLowerCase() });
case 'fecha': return t('public.stepDateTime');
case 'datos': return t('public.stepData');
default: return '';
}
};
@@ -242,7 +247,7 @@ const Reservar: FC = () => {
<div className="rounded-3xl border border-theme-border bg-theme-surface p-5 shadow-soft">
<div className="mb-4 flex items-center gap-2">
{paso > 0 && (
<button type="button" onClick={atras} aria-label="Atrás"
<button type="button" onClick={atras} aria-label={t('common.back')}
className="flex h-8 w-8 items-center justify-center rounded-full text-theme-muted hover:bg-theme-bg">
<ArrowLeft size={16} />
</button>
@@ -315,7 +320,7 @@ const Reservar: FC = () => {
medicoId === null ? 'border-theme-accent bg-theme-accent-bg' : 'border-theme-border hover:border-theme-border-strong'
}`}
>
<span className="text-sm font-medium text-theme-heading">Cualquiera disponible</span>
<span className="text-sm font-medium text-theme-heading">{t('public.anyAvailable')}</span>
<ChevronRight size={18} className="shrink-0 text-theme-muted" />
</button>
{config.medicos.map((m) => (
@@ -362,7 +367,7 @@ const Reservar: FC = () => {
<div className="flex justify-center py-6"><Loader2 className="h-5 w-5 animate-spin text-theme-muted" /></div>
) : slots.length === 0 ? (
<p className="py-4 text-center text-sm text-theme-muted">
No hay horarios disponibles ese día. Prueba con otra fecha.
{t('public.noSlots')}
</p>
) : (
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
@@ -381,7 +386,7 @@ const Reservar: FC = () => {
</div>
)}
<Button className="w-full" disabled={!hora} onClick={siguiente}>Continuar</Button>
<Button className="w-full" disabled={!hora} onClick={siguiente}>{t('public.continue')}</Button>
</div>
)}
@@ -392,13 +397,13 @@ const Reservar: FC = () => {
{servicioSel?.nombre} · {fechaLargaISO(fecha)} · {hora}
{medicoSel ? ` · ${medicoSel.nombre}` : ''}
</div>
<Input label="Nombre completo" value={nombre} onChange={(e) => setNombre(e.target.value)} autoComplete="name" />
<Input label="Teléfono (WhatsApp)" value={telefono} onChange={(e) => setTelefono(e.target.value)} inputMode="tel" autoComplete="tel" />
<Input label="Email (opcional)" value={email} onChange={(e) => setEmail(e.target.value)} inputMode="email" autoComplete="email" />
<TextArea label="Notas (opcional)" value={notas} onChange={(e) => setNotas(e.target.value)} rows={3} />
<Input label={t('public.fullName')} value={nombre} onChange={(e) => setNombre(e.target.value)} autoComplete="name" />
<Input label={t('public.phoneWhatsapp')} value={telefono} onChange={(e) => setTelefono(e.target.value)} inputMode="tel" autoComplete="tel" />
<Input label={t('public.emailOptional')} value={email} onChange={(e) => setEmail(e.target.value)} inputMode="email" autoComplete="email" />
<TextArea label={t('public.notesOptional')} value={notas} onChange={(e) => setNotas(e.target.value)} rows={3} />
{/* Honeypot: invisible para personas; los bots suelen rellenarlo. */}
<div aria-hidden="true" style={{ position: 'absolute', left: '-9999px', width: 1, height: 1, overflow: 'hidden' }}>
<label>No llenar este campo
<label>{t('public.honeypot')}
<input
type="text"
name="empresa"
@@ -411,11 +416,11 @@ const Reservar: FC = () => {
</div>
{error && <p className="text-sm text-rose-600">{error}</p>}
<Button className="w-full" loading={enviando} onClick={enviar}>
{config.config.requiere_confirmacion ? 'Solicitar reserva' : 'Confirmar reserva'}
{config.config.requiere_confirmacion ? t('public.requestBooking') : t('public.confirmBooking')}
</Button>
{config.config.telefono_contacto && (
<p className="flex items-center justify-center gap-1 text-center text-xs text-theme-muted">
<MessageCircle size={12} /> ¿Dudas? {config.config.telefono_contacto}
<MessageCircle size={12} /> {t('public.doubts')} {config.config.telefono_contacto}
</p>
)}
</div>

View File

@@ -5,6 +5,7 @@ import { AlertTriangle, ArrowLeft, Check, Clock, Loader2, MapPin, User } from 'l
import { Button, Input } from '../components/ui';
import { odooApi, type BookingCita } from '../services/odoo';
import { fechaLargaISO } from '../lib/publicBooking';
import { useI18n } from '../lib/i18n';
// Página de gestión de una reserva hecha en el portal (ver / cancelar) usando
// el token HMAC del enlace. Anónima: el token es la única credencial.
@@ -12,6 +13,7 @@ import { fechaLargaISO } from '../lib/publicBooking';
const ReservarCita: FC = () => {
const { token = '' } = useParams();
const navigate = useNavigate();
const { t } = useI18n();
const [cita, setCita] = useState<BookingCita | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -22,12 +24,12 @@ const ReservarCita: FC = () => {
useEffect(() => {
let alive = true;
if (!token) { setLoading(false); setError('Enlace no válido'); return; }
if (!token) { setLoading(false); setError(t('public.manage.invalidLink')); return; }
odooApi.getBookingCita(token)
.then((res) => { if (alive) setCita(res); })
.catch((err) => {
const msg = (err as { response?: { data?: { message?: string }; status?: number } });
if (alive) setError(msg?.response?.data?.message || 'No encontramos esta reserva.');
if (alive) setError(msg?.response?.data?.message || t('public.manage.notFound'));
})
.finally(() => { if (alive) setLoading(false); });
return () => { alive = false; };
@@ -39,10 +41,10 @@ const ReservarCita: FC = () => {
try {
await odooApi.cancelBooking(token, motivo.trim());
setCancelada(true);
setCita((prev) => (prev ? { ...prev, estado: 'cancelled', estado_label: 'Canceló', puede_cancelar: false } : prev));
setCita((prev) => (prev ? { ...prev, estado: 'cancelled', estado_label: t('agenda.state.cancelled'), puede_cancelar: false } : prev));
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
setError(msg || 'No se pudo cancelar la reserva.');
setError(msg || t('public.manage.cancelError'));
} finally {
setCancelando(false);
}
@@ -61,9 +63,9 @@ const ReservarCita: FC = () => {
<Centro>
<div className="w-full max-w-sm rounded-3xl border border-theme-border bg-theme-surface p-8 text-center shadow-soft">
<AlertTriangle className="mx-auto mb-3 h-8 w-8 text-amber-500" />
<h1 className="font-heading text-lg text-theme-heading">No encontramos tu reserva</h1>
<p className="mt-2 text-sm text-theme-muted">{error || 'El enlace no es válido o ya expiró.'}</p>
<Button className="mt-6 w-full" onClick={() => navigate('/reservar')}>Hacer una reserva</Button>
<h1 className="font-heading text-lg text-theme-heading">{t('public.manage.notFoundTitle')}</h1>
<p className="mt-2 text-sm text-theme-muted">{error || t('public.manage.expired')}</p>
<Button className="mt-6 w-full" onClick={() => navigate('/reservar')}>{t('public.manage.makeBooking')}</Button>
</div>
</Centro>
);
@@ -74,19 +76,19 @@ const ReservarCita: FC = () => {
<div className="w-full max-w-lg space-y-4">
<button type="button" onClick={() => navigate('/reservar')}
className="flex items-center gap-1 text-sm text-theme-muted hover:text-theme-heading">
<ArrowLeft size={16} /> Reservar otra cita
<ArrowLeft size={16} /> {t('public.manage.bookAnother')}
</button>
<div className="rounded-3xl border border-theme-border bg-theme-surface p-6 shadow-soft">
<div className="flex items-center justify-between">
<h1 className="font-heading text-lg text-theme-heading">Tu reserva</h1>
<h1 className="font-heading text-lg text-theme-heading">{t('public.manage.title')}</h1>
<span className={`rounded-full px-3 py-1 text-xs font-medium ${
cita.estado === 'cancelled' ? 'bg-rose-100 text-rose-700' : 'bg-emerald-100 text-emerald-700'
}`}>
{cita.estado_label}
</span>
</div>
<p className="mt-1 text-sm text-theme-muted">Folio <span className="font-semibold text-theme-heading">{cita.folio}</span></p>
<p className="mt-1 text-sm text-theme-muted">{t('public.folioWord')} <span className="font-semibold text-theme-heading">{cita.folio}</span></p>
<div className="mt-5 space-y-2 rounded-2xl bg-theme-bg p-4 text-sm">
<Linea icon={<Clock className="h-4 w-4" />} texto={`${fechaLargaISO(cita.fecha)} · ${cita.hora}`} />
@@ -97,7 +99,7 @@ const ReservarCita: FC = () => {
{cancelada && (
<p className="mt-4 rounded-xl bg-emerald-50 p-3 text-sm text-emerald-700">
Tu reserva quedó cancelada. Si fue un error, puedes agendar de nuevo.
{t('public.manage.cancelledOk')}
</p>
)}
@@ -105,20 +107,20 @@ const ReservarCita: FC = () => {
<div className="mt-5">
{!confirmar ? (
<Button variant="outline" className="w-full" onClick={() => setConfirmar(true)}>
Cancelar mi reserva
{t('public.manage.cancelMine')}
</Button>
) : (
<div className="space-y-3">
<p className="text-sm text-theme-muted">
¿Seguro que quieres cancelar? Puedes dejarnos un motivo (opcional).
{t('public.manage.confirmQuestion')}
</p>
<Input label="Motivo (opcional)" value={motivo} onChange={(e) => setMotivo(e.target.value)} />
<Input label={t('public.manage.reason')} value={motivo} onChange={(e) => setMotivo(e.target.value)} />
{error && <p className="text-sm text-rose-600">{error}</p>}
<div className="flex gap-2">
<Button variant="danger" className="flex-1" loading={cancelando} onClick={cancelar}>
Sí, cancelar
{t('public.manage.yesCancel')}
</Button>
<Button variant="ghost" className="flex-1" onClick={() => setConfirmar(false)}>No</Button>
<Button variant="ghost" className="flex-1" onClick={() => setConfirmar(false)}>{t('common.no')}</Button>
</div>
</div>
)}
@@ -127,9 +129,9 @@ const ReservarCita: FC = () => {
{!cancelada && !cita.puede_cancelar && cita.estado !== 'cancelled' && (
<p className="mt-5 text-sm text-theme-muted">
Esta reserva ya no se puede cancelar en línea
{cita.permitir_cancelar ? ` (con menos de ${cita.cancelar_hasta_horas} h de anticipación)` : ''}.
{cita.telefono_contacto ? ` Comunícate al ${cita.telefono_contacto}.` : ' Comunícate con la clínica.'}
{t('public.manage.cannotCancel')}
{cita.permitir_cancelar ? t('public.manage.leadTime', { h: cita.cancelar_hasta_horas }) : ''}.
{cita.telefono_contacto ? t('public.manage.contactPhone', { phone: cita.telefono_contacto }) : t('public.manage.contactClinic')}
</p>
)}
</div>

View File

@@ -1069,6 +1069,8 @@ export interface ClinicSettings {
url_publica: string;
moneda_base: string;
nota_ticket: string;
/** Idioma por defecto de la clínica ('es' | 'en'). Ver lib/i18n.tsx. */
idioma_default: string;
impuestos: ImpuestosSettings;
reservas_online: ReservasOnlineSettings;
notificaciones: NotificacionesSettings;
@@ -2207,6 +2209,7 @@ export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
url_publica: '',
moneda_base: 'MXN',
nota_ticket: '',
idioma_default: 'es',
impuestos: IMPUESTOS_DEFAULT,
reservas_online: RESERVAS_ONLINE_DEFAULT,
notificaciones: NOTIFICACIONES_DEFAULT,