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:
@@ -5,6 +5,7 @@ import { ToastProvider, Button } from './components/ui';
|
|||||||
import ErrorBoundary from './components/ErrorBoundary';
|
import ErrorBoundary from './components/ErrorBoundary';
|
||||||
import { PwaInstallPrompt, OfflineBanner } from './components/PwaNotices';
|
import { PwaInstallPrompt, OfflineBanner } from './components/PwaNotices';
|
||||||
import { AuthProvider, useAuth } from './lib/auth';
|
import { AuthProvider, useAuth } from './lib/auth';
|
||||||
|
import { useI18n } from './lib/i18n';
|
||||||
import { GlosarioProvider } from './lib/glosario';
|
import { GlosarioProvider } from './lib/glosario';
|
||||||
import { SucursalesProvider } from './lib/sucursales';
|
import { SucursalesProvider } from './lib/sucursales';
|
||||||
import { ServiceCategoriesProvider } from './lib/service-categories';
|
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):
|
// Pantalla cuando el servidor no responde al arrancar (red lenta o caída):
|
||||||
// en lugar de quedarse cargando para siempre, ofrece reintentar.
|
// 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="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">
|
<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>
|
<h1 className="font-heading text-xl text-theme-heading">{t('app.bootTitle')}</h1>
|
||||||
<p className="text-sm text-theme-muted">
|
<p className="text-sm text-theme-muted">{t('app.bootBody')}</p>
|
||||||
El servidor no responde en este momento. Revisa tu conexión a internet e intenta de nuevo.
|
<Button onClick={onRetry}>{t('app.retry')}</Button>
|
||||||
</p>
|
|
||||||
<Button onClick={onRetry}>Reintentar</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const RequireAuth: FC<{ children: ReactNode; href: string }> = ({ children, href }) => {
|
const RequireAuth: FC<{ children: ReactNode; href: string }> = ({ children, href }) => {
|
||||||
const { user, loading, bootError, retryBoot, canSee } = useAuth();
|
const { user, loading, bootError, retryBoot, canSee } = useAuth();
|
||||||
@@ -144,6 +146,7 @@ const LiquidacionesRoute: FC = () => {
|
|||||||
|
|
||||||
const HomeRoute: FC = () => {
|
const HomeRoute: FC = () => {
|
||||||
const { user, canSee } = useAuth();
|
const { user, canSee } = useAuth();
|
||||||
|
const { t } = useI18n();
|
||||||
if (user?.role === 'medico') return <Navigate to="/agenda" replace />;
|
if (user?.role === 'medico') return <Navigate to="/agenda" replace />;
|
||||||
if (canSee('/')) return <Dashboard />;
|
if (canSee('/')) return <Dashboard />;
|
||||||
for (const href of LANDING_CANDIDATOS) {
|
for (const href of LANDING_CANDIDATOS) {
|
||||||
@@ -151,8 +154,8 @@ const HomeRoute: FC = () => {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col items-center justify-center gap-3">
|
<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-theme-heading font-semibold">{t('app.noAccessTitle')}</p>
|
||||||
<p className="text-sm text-theme-muted">Tu usuario no tiene secciones habilitadas. Contacta al administrador.</p>
|
<p className="text-sm text-theme-muted">{t('app.noAccessBody')}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
|
|||||||
import GlobalSearch from './GlobalSearch';
|
import GlobalSearch from './GlobalSearch';
|
||||||
import ThemePicker from './ThemePicker';
|
import ThemePicker from './ThemePicker';
|
||||||
import NotificationBell from './NotificationBell';
|
import NotificationBell from './NotificationBell';
|
||||||
|
import { LanguageToggle } from './LanguageSelect';
|
||||||
import { useAuth, ROLE_LABELS } from '../lib/auth';
|
import { useAuth, ROLE_LABELS } from '../lib/auth';
|
||||||
|
import { useI18n, useClasicoLabel } from '../lib/i18n';
|
||||||
import { useClinicSettings } from '../lib/clinic-settings';
|
import { useClinicSettings } from '../lib/clinic-settings';
|
||||||
import { useChatUnread } from '../lib/chat';
|
import { useChatUnread } from '../lib/chat';
|
||||||
import { useQuickLinks } from '../lib/quicklinks';
|
import { useQuickLinks } from '../lib/quicklinks';
|
||||||
@@ -59,6 +61,8 @@ interface MenuChild {
|
|||||||
|
|
||||||
interface MenuEntry {
|
interface MenuEntry {
|
||||||
label: string;
|
label: string;
|
||||||
|
/** Etiqueta canónica (español) para estado/keys; `label` es la visible. */
|
||||||
|
id?: string;
|
||||||
icon: FC<{ size?: number | string; className?: string }>;
|
icon: FC<{ size?: number | string; className?: string }>;
|
||||||
href?: string;
|
href?: string;
|
||||||
children?: MenuChild[];
|
children?: MenuChild[];
|
||||||
@@ -146,6 +150,8 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, canSee, hasRole, logout, brand } = useAuth();
|
const { user, canSee, hasRole, logout, brand } = useAuth();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const cl = useClasicoLabel();
|
||||||
const { membresias: membresiasCfg, compras: comprasCfg, comisiones: comisionesCfg } = useClinicSettings();
|
const { membresias: membresiasCfg, compras: comprasCfg, comisiones: comisionesCfg } = useClinicSettings();
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const chatUnread = useChatUnread();
|
const chatUnread = useChatUnread();
|
||||||
@@ -164,8 +170,13 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
|
|||||||
if (comisionesCfg.activo) {
|
if (comisionesCfg.activo) {
|
||||||
tree = [...tree, { label: 'Liquidaciones', icon: Receipt, href: '/comisiones/liquidaciones' }];
|
tree = [...tree, { label: 'Liquidaciones', icon: Receipt, href: '/comisiones/liquidaciones' }];
|
||||||
}
|
}
|
||||||
return tree;
|
return tree.map((e) => ({
|
||||||
}, [membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo]);
|
...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(
|
const menuLeavesT = useMemo(
|
||||||
() => menuTreeT.flatMap((e) =>
|
() => menuTreeT.flatMap((e) =>
|
||||||
e.children
|
e.children
|
||||||
@@ -275,7 +286,7 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
|
|||||||
const current = `${location.pathname}${location.search}`;
|
const current = `${location.pathname}${location.search}`;
|
||||||
return menuTreeT
|
return menuTreeT
|
||||||
.filter((e) => e.children?.some((c) => current.startsWith(c.href.split('?')[0]) && (!c.href.includes('?') || current.includes(c.href.split('?')[1]))))
|
.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) =>
|
const toggleGroup = (label: string) =>
|
||||||
@@ -296,7 +307,7 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
|
|||||||
const base = c.href.split('?')[0];
|
const base = c.href.split('?')[0];
|
||||||
return current === c.href || (current.startsWith(base) && (!c.href.includes('?') || current.includes(c.href.split('?')[1])));
|
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;
|
if (!abiertos.length) return;
|
||||||
setOpenGroups((prev) => {
|
setOpenGroups((prev) => {
|
||||||
const faltan = abiertos.filter((l) => !prev.includes(l));
|
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' && (
|
{quickItems.length > 0 && quick.mode === 'menu' && (
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<div className="flex items-center justify-between px-4 py-2" style={{ backgroundColor: 'var(--sidebar-group-bg)' }}>
|
<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">
|
<span className="flex items-center gap-1">
|
||||||
<QuickLinksModePicker direction="down" align="right" />
|
<QuickLinksModePicker direction="down" align="right" />
|
||||||
<button
|
<button
|
||||||
@@ -333,7 +344,7 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
|
|||||||
onClick={() => quick.setEditing(!quick.editing)}
|
onClick={() => quick.setEditing(!quick.editing)}
|
||||||
className="hover:text-white transition-colors"
|
className="hover:text-white transition-colors"
|
||||||
style={{ color: 'var(--sidebar-group-text)' }}
|
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} />}
|
{quick.editing ? <X size={13} /> : <Pencil size={12} />}
|
||||||
</button>
|
</button>
|
||||||
@@ -363,15 +374,15 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
|
|||||||
{quick.editing && (
|
{quick.editing && (
|
||||||
<span className="flex items-center gap-0.5 shrink-0 pr-2">
|
<span className="flex items-center gap-0.5 shrink-0 pr-2">
|
||||||
<button type="button" onClick={() => quick.move(item.href, -1)} disabled={idx === 0}
|
<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} />
|
<ChevronUp size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => quick.move(item.href, 1)} disabled={idx === quickItems.length - 1}
|
<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} />
|
<ChevronDown size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => quick.remove(item.href)}
|
<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} />
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
@@ -396,7 +407,7 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
|
|||||||
const active = isActive(entry.href as string);
|
const active = isActive(entry.href as string);
|
||||||
const pinned = quick.isPinned(entry.href as string);
|
const pinned = quick.isPinned(entry.href as string);
|
||||||
return (
|
return (
|
||||||
<li key={entry.label} className="group flex items-center">
|
<li key={entry.id} className="group flex items-center">
|
||||||
<Link
|
<Link
|
||||||
to={entry.href as string}
|
to={entry.href as string}
|
||||||
onClick={() => setSidebarOpen(false)}
|
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'
|
pinned ? 'text-amber-400' : 'opacity-0 group-hover:opacity-100 hover:text-amber-400'
|
||||||
}`}
|
}`}
|
||||||
style={{ color: pinned ? undefined : 'var(--sidebar-text)' }}
|
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'} />
|
<Star size={14} fill={pinned ? 'currentColor' : 'none'} />
|
||||||
</button>
|
</button>
|
||||||
@@ -429,13 +440,13 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Entry con submenú (treeview AdminLTE)
|
// 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));
|
const childActive = leaves.some((c) => isActive(c.href));
|
||||||
return (
|
return (
|
||||||
<li key={entry.label}>
|
<li key={entry.id}>
|
||||||
<button
|
<button
|
||||||
type="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"
|
className="w-full flex items-center space-x-3 px-4 py-2.5 text-sm transition-colors"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: childActive ? 'var(--sidebar-active-bg)' : 'transparent',
|
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'
|
pinned ? 'text-amber-400' : 'opacity-0 group-hover:opacity-100 hover:text-amber-400'
|
||||||
}`}
|
}`}
|
||||||
style={{ color: pinned ? undefined : 'var(--sidebar-text)' }}
|
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'} />
|
<Star size={13} fill={pinned ? 'currentColor' : 'none'} />
|
||||||
</button>
|
</button>
|
||||||
@@ -513,18 +524,22 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-white truncate">{user?.name || 'Usuario'}</p>
|
<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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="p-2 transition-colors hover:text-white"
|
className="p-2 transition-colors hover:text-white"
|
||||||
style={{ color: 'var(--sidebar-text)' }}
|
style={{ color: 'var(--sidebar-text)' }}
|
||||||
title="Cerrar sesión"
|
title={t('user.logout')}
|
||||||
aria-label="Cerrar sesión"
|
aria-label={t('user.logout')}
|
||||||
>
|
>
|
||||||
<LogOut size={17} />
|
<LogOut size={17} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -548,7 +563,7 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="lg:hidden text-white"
|
className="lg:hidden text-white"
|
||||||
onClick={() => setSidebarOpen(true)}
|
onClick={() => setSidebarOpen(true)}
|
||||||
aria-label="Abrir menú"
|
aria-label={t('user.openMenu')}
|
||||||
>
|
>
|
||||||
<Menu size={20} />
|
<Menu size={20} />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { Search, Users, Calendar, Briefcase, Loader2, X } from 'lucide-react';
|
import { Search, Users, Calendar, Briefcase, Loader2, X } from 'lucide-react';
|
||||||
import { odooApi } from '../services/odoo';
|
import { odooApi } from '../services/odoo';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
|
import { useI18n } from '../lib/i18n';
|
||||||
|
|
||||||
interface Results {
|
interface Results {
|
||||||
patients: { id: number; name: string; phone: string }[];
|
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
|
* Búsqueda global del header: UN solo campo con resultados en vivo
|
||||||
* (debounce 250 ms) navegables con flechas + Enter.
|
* (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 navigate = useNavigate();
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [results, setResults] = useState<Results>(EMPTY);
|
const [results, setResults] = useState<Results>(EMPTY);
|
||||||
@@ -155,10 +158,10 @@ export const GlobalSearch: FC<GlobalSearchProps> = ({ placeholder = 'Buscar paci
|
|||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={showDropdown}
|
aria-expanded={showDropdown}
|
||||||
aria-autocomplete="list"
|
aria-autocomplete="list"
|
||||||
aria-label="Búsqueda global"
|
aria-label={t('search.aria')}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
value={query}
|
value={query}
|
||||||
placeholder={placeholder}
|
placeholder={ph}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
onFocus={() => { if (options.length > 0) setOpen(true); }}
|
onFocus={() => { if (options.length > 0) setOpen(true); }}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
@@ -169,8 +172,8 @@ export const GlobalSearch: FC<GlobalSearchProps> = ({ placeholder = 'Buscar paci
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setQuery(''); setResults(EMPTY); setOpen(false); inputRef.current?.focus(); }}
|
onClick={() => { setQuery(''); setResults(EMPTY); setOpen(false); inputRef.current?.focus(); }}
|
||||||
title="Limpiar búsqueda"
|
title={t('search.clear')}
|
||||||
aria-label="Limpiar búsqueda"
|
aria-label={t('search.clear')}
|
||||||
className="ml-1 shrink-0 rounded-full p-0.5 text-theme-muted hover:text-theme-heading"
|
className="ml-1 shrink-0 rounded-full p-0.5 text-theme-muted hover:text-theme-heading"
|
||||||
>
|
>
|
||||||
<X size={13} />
|
<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"
|
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 && (
|
{!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) => {
|
{options.map((opt, idx) => {
|
||||||
@@ -193,7 +196,7 @@ export const GlobalSearch: FC<GlobalSearchProps> = ({ placeholder = 'Buscar paci
|
|||||||
return (
|
return (
|
||||||
<div key={opt.key} className={firstOfGroup ? (idx === 0 ? 'p-1.5' : 'p-1.5 border-t border-theme-border') : ''}>
|
<div key={opt.key} className={firstOfGroup ? (idx === 0 ? 'p-1.5' : 'p-1.5 border-t border-theme-border') : ''}>
|
||||||
{firstOfGroup && (
|
{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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -216,7 +219,7 @@ export const GlobalSearch: FC<GlobalSearchProps> = ({ placeholder = 'Buscar paci
|
|||||||
|
|
||||||
{options.length > 0 && (
|
{options.length > 0 && (
|
||||||
<p className="px-4 py-2 border-t border-theme-border text-[10px] text-theme-muted">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -44,8 +44,10 @@ import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
|
|||||||
import GlobalSearch from './GlobalSearch';
|
import GlobalSearch from './GlobalSearch';
|
||||||
import ThemePicker from './ThemePicker';
|
import ThemePicker from './ThemePicker';
|
||||||
import NotificationBell from './NotificationBell';
|
import NotificationBell from './NotificationBell';
|
||||||
|
import { LanguageToggle } from './LanguageSelect';
|
||||||
import { useAuth } from '../lib/auth';
|
import { useAuth } from '../lib/auth';
|
||||||
import { useGlosario, traducirMenu, traducirEtiqueta } from '../lib/glosario';
|
import { useGlosario, traducirMenu, traducirEtiqueta } from '../lib/glosario';
|
||||||
|
import { useI18n, useNavLabel } from '../lib/i18n';
|
||||||
import { useClinicSettings } from '../lib/clinic-settings';
|
import { useClinicSettings } from '../lib/clinic-settings';
|
||||||
import { useChatUnread } from '../lib/chat';
|
import { useChatUnread } from '../lib/chat';
|
||||||
import { useQuickLinks } from '../lib/quicklinks';
|
import { useQuickLinks } from '../lib/quicklinks';
|
||||||
@@ -122,10 +124,12 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, canSee, hasRole, logout, brand } = useAuth();
|
const { user, canSee, hasRole, logout, brand } = useAuth();
|
||||||
const { term } = useGlosario();
|
const { term } = useGlosario();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const navLabel = useNavLabel();
|
||||||
const { membresias: membresiasCfg, compras: comprasCfg, comisiones: comisionesCfg, licencia: licenciaCfg } = useClinicSettings();
|
const { membresias: membresiasCfg, compras: comprasCfg, comisiones: comisionesCfg, licencia: licenciaCfg } = useClinicSettings();
|
||||||
const topNavItems = useMemo(
|
const topNavItems = useMemo(
|
||||||
() => TOP_NAV_ITEMS.map((it) => ({ ...it, label: traducirEtiqueta(it.label, term) })),
|
() => TOP_NAV_ITEMS.map((it) => ({ ...it, label: navLabel(traducirEtiqueta(it.label, term)) })),
|
||||||
[term]
|
[term, navLabel]
|
||||||
);
|
);
|
||||||
const menuSections = useMemo(() => {
|
const menuSections = useMemo(() => {
|
||||||
let base = MENU_SECTIONS;
|
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') }))
|
.map((s) => ({ ...s, items: s.items.filter((i) => i.href !== '/chat') }))
|
||||||
.filter((s) => s.items.length > 0);
|
.filter((s) => s.items.length > 0);
|
||||||
}
|
}
|
||||||
return traducirMenu(base, term);
|
return traducirMenu(base, term).map((s, idx) => ({
|
||||||
}, [term, membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo, licenciaCfg]);
|
...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 [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [expanded, setExpanded] = useState<string[]>(['Clínica']);
|
const [expanded, setExpanded] = useState<string[]>(['Clínica']);
|
||||||
const chatUnread = useChatUnread();
|
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="px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex items-center justify-between h-16">
|
<div className="flex items-center justify-between h-16">
|
||||||
<div className="flex items-center gap-4">
|
<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} />
|
<Menu size={22} />
|
||||||
</Button>
|
</Button>
|
||||||
<Link to="/" className="flex items-center gap-2">
|
<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)';
|
e.currentTarget.style.color = 'var(--text-muted)';
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Más <ChevronDown size={14} />
|
{t('user.more')} <ChevronDown size={14} />
|
||||||
</button>
|
</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="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">
|
<div className="p-2">
|
||||||
@@ -356,6 +367,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
|||||||
<GlobalSearch className="hidden md:block" />
|
<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" />
|
<NotificationBell buttonClassName="p-2 rounded-full relative transition text-theme-muted hover:bg-theme-accent-bg hover:text-theme-heading" />
|
||||||
<ThemePicker />
|
<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="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)' }}>
|
<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'}
|
{initials || 'US'}
|
||||||
@@ -369,7 +381,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
|||||||
e.currentTarget.style.backgroundColor = 'transparent';
|
e.currentTarget.style.backgroundColor = 'transparent';
|
||||||
e.currentTarget.style.color = 'var(--text-muted)';
|
e.currentTarget.style.color = 'var(--text-muted)';
|
||||||
}}
|
}}
|
||||||
title="Cerrar sesión"
|
title={t('user.logout')}
|
||||||
>
|
>
|
||||||
<LogOut size={16} />
|
<LogOut size={16} />
|
||||||
</button>
|
</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="rounded-2xl p-4 border" style={{ backgroundColor: 'var(--bg)', borderColor: 'var(--border)' }}>
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<Sparkles size={16} style={{ color: 'var(--accent)' }} />
|
<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>
|
</div>
|
||||||
{goal && goal.goal > 0 ? (
|
{goal && goal.goal > 0 ? (
|
||||||
<button onClick={hasRole('admin') ? openGoalModal : undefined} className={`w-full text-left ${hasRole('admin') ? 'cursor-pointer' : 'cursor-default'}`}>
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="outline" size="sm" className="w-full" onClick={openGoalModal}>
|
<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>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<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)' }}>
|
<span className="text-sm font-medium flex items-center gap-2" style={{ color: 'var(--text-heading)' }}>
|
||||||
<Star size={14} style={{ color: 'var(--accent)' }} />
|
<Star size={14} style={{ color: 'var(--accent)' }} />
|
||||||
Accesos rápidos
|
{t('nav.quickLinks')}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<QuickLinksModePicker direction="down" align="right" />
|
<QuickLinksModePicker direction="down" align="right" />
|
||||||
@@ -418,7 +430,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => quick.setEditing(!quick.editing)}
|
onClick={() => quick.setEditing(!quick.editing)}
|
||||||
style={{ color: 'var(--text-muted)' }}
|
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} />}
|
{quick.editing ? <X size={13} /> : <Pencil size={12} />}
|
||||||
</button>
|
</button>
|
||||||
@@ -456,15 +468,15 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
|||||||
{quick.editing && (
|
{quick.editing && (
|
||||||
<span className="flex items-center gap-0.5 shrink-0">
|
<span className="flex items-center gap-0.5 shrink-0">
|
||||||
<button type="button" onClick={() => quick.move(item.href, -1)} disabled={idx === 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} />
|
<ChevronUp size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => quick.move(item.href, 1)} disabled={idx === quickItems.length - 1}
|
<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} />
|
<ChevronDown size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => quick.remove(item.href)}
|
<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} />
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
@@ -476,16 +488,16 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{visibleSections.map((section) => (
|
{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
|
<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"
|
className="w-full flex items-center justify-between px-4 py-3 text-sm font-medium"
|
||||||
style={{ color: 'var(--text-heading)' }}
|
style={{ color: 'var(--text-heading)' }}
|
||||||
>
|
>
|
||||||
{section.label}
|
{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>
|
</button>
|
||||||
{expanded.includes(section.label) && (
|
{expanded.includes(section.id) && (
|
||||||
<ul className="px-2 pb-2 space-y-1">
|
<ul className="px-2 pb-2 space-y-1">
|
||||||
{section.items.map((item) => {
|
{section.items.map((item) => {
|
||||||
const Icon = item.icon;
|
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)' }}>
|
<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)' }}>
|
<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" />
|
<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} />
|
<X size={22} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -562,7 +574,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
|||||||
{quickItems.length > 0 && (
|
{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)' }}>
|
<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>
|
</p>
|
||||||
{quickItems.map((item) => {
|
{quickItems.map((item) => {
|
||||||
const Icon = item.icon;
|
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)'}
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--accent-bg)'}
|
||||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||||
>
|
>
|
||||||
<LogOut size={18} /> Cerrar sesión
|
<LogOut size={18} /> {t('user.logout')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -642,18 +654,18 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
|||||||
<Modal
|
<Modal
|
||||||
isOpen={goalModalOpen}
|
isOpen={goalModalOpen}
|
||||||
onClose={() => setGoalModalOpen(false)}
|
onClose={() => setGoalModalOpen(false)}
|
||||||
title="Meta de ventas del mes"
|
title={t('user.goalTitle')}
|
||||||
maxWidth="sm"
|
maxWidth="sm"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" onClick={() => setGoalModalOpen(false)}>Cancelar</Button>
|
<Button variant="outline" onClick={() => setGoalModalOpen(false)}>{t('common.cancel')}</Button>
|
||||||
<Button onClick={saveGoal} loading={goalSaving}>Guardar meta</Button>
|
<Button onClick={saveGoal} loading={goalSaving}>{t('user.goalSave')}</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-3">
|
<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>
|
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>{t('user.goalBody')}</p>
|
||||||
<Input label="Meta mensual (MXN)" type="number" min={0} step={1000} value={goalInput} onChange={(e) => setGoalInput(e.target.value)} placeholder="ej. 500000" />
|
<Input label={t('user.goalField')} type="number" min={0} step={1000} value={goalInput} onChange={(e) => setGoalInput(e.target.value)} placeholder="ej. 500000" />
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
63
frontend/src/components/LanguageSelect.tsx
Normal file
63
frontend/src/components/LanguageSelect.tsx
Normal 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;
|
||||||
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState, useCallback } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Bell, Boxes, CalendarX, Clock, Target, MessageSquare } from 'lucide-react';
|
import { Bell, Boxes, CalendarX, Clock, Target, MessageSquare } from 'lucide-react';
|
||||||
import { odooApi, type AppNotification } from '../services/odoo';
|
import { odooApi, type AppNotification } from '../services/odoo';
|
||||||
|
import { useI18n } from '../lib/i18n';
|
||||||
|
|
||||||
const TIPO_ICON: Record<string, typeof Boxes> = {
|
const TIPO_ICON: Record<string, typeof Boxes> = {
|
||||||
stock_bajo: Boxes,
|
stock_bajo: Boxes,
|
||||||
@@ -24,6 +25,7 @@ interface NotificationBellProps {
|
|||||||
|
|
||||||
export const NotificationBell: FC<NotificationBellProps> = ({ buttonClassName }) => {
|
export const NotificationBell: FC<NotificationBellProps> = ({ buttonClassName }) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { t } = useI18n();
|
||||||
const [notifications, setNotifications] = useState<AppNotification[]>([]);
|
const [notifications, setNotifications] = useState<AppNotification[]>([]);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -68,8 +70,8 @@ export const NotificationBell: FC<NotificationBellProps> = ({ buttonClassName })
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
title="Notificaciones"
|
title={t('notif.title')}
|
||||||
aria-label="Notificaciones"
|
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'}
|
className={buttonClassName || 'p-2 rounded-full relative transition text-theme-muted hover:bg-theme-accent-bg hover:text-theme-heading'}
|
||||||
>
|
>
|
||||||
<Bell size={18} />
|
<Bell size={18} />
|
||||||
@@ -83,12 +85,12 @@ export const NotificationBell: FC<NotificationBellProps> = ({ buttonClassName })
|
|||||||
{open && (
|
{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">
|
<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">
|
<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>
|
</p>
|
||||||
{loading && notifications.length === 0 ? (
|
{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 ? (
|
) : 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">
|
<ul className="max-h-80 overflow-y-auto">
|
||||||
{notifications.map((n) => {
|
{notifications.map((n) => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { FC } from 'react';
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Download, Share, WifiOff, X } from 'lucide-react';
|
import { Download, Share, WifiOff, X } from 'lucide-react';
|
||||||
import { Button } from './ui';
|
import { Button } from './ui';
|
||||||
|
import { useI18n } from '../lib/i18n';
|
||||||
|
|
||||||
// Avisos de PWA: invitación a instalar (Android/desktop vía
|
// Avisos de PWA: invitación a instalar (Android/desktop vía
|
||||||
// `beforeinstallprompt`, iOS con guía manual) y banner de sin conexión.
|
// `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);
|
typeof navigator !== 'undefined' && /iphone|ipad|ipod/i.test(navigator.userAgent);
|
||||||
|
|
||||||
export const PwaInstallPrompt: FC = () => {
|
export const PwaInstallPrompt: FC = () => {
|
||||||
|
const { t } = useI18n();
|
||||||
const [deferred, setDeferred] = useState<BeforeInstallPromptEvent | null>(null);
|
const [deferred, setDeferred] = useState<BeforeInstallPromptEvent | null>(null);
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
@@ -89,26 +91,25 @@ export const PwaInstallPrompt: FC = () => {
|
|||||||
<Download size={18} />
|
<Download size={18} />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<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 ? (
|
{deferred ? (
|
||||||
<p className="mt-0.5 text-xs text-theme-muted">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<p className="mt-0.5 text-xs text-theme-muted inline-flex flex-wrap items-center gap-1">
|
<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
|
{t('pwa.iosStepPrefix')} <Share size={12} className="inline" /> {t('pwa.iosStepSuffix')}
|
||||||
«Añadir a pantalla de inicio».
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{deferred && (
|
{deferred && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<Button size="sm" onClick={instalar}>Instalar</Button>
|
<Button size="sm" onClick={instalar}>{t('pwa.install')}</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Descartar"
|
aria-label={t('pwa.dismiss')}
|
||||||
onClick={descartar}
|
onClick={descartar}
|
||||||
className="rounded-lg p-1 text-theme-muted transition-colors hover:bg-theme-accent-bg hover:text-theme-heading"
|
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 = () => {
|
export const OfflineBanner: FC = () => {
|
||||||
|
const { t } = useI18n();
|
||||||
const [online, setOnline] = useState(() => (typeof navigator === 'undefined' ? true : navigator.onLine));
|
const [online, setOnline] = useState(() => (typeof navigator === 'undefined' ? true : navigator.onLine));
|
||||||
|
|
||||||
useEffect(() => {
|
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="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">
|
<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" />
|
<WifiOff size={14} className="text-theme-accent" />
|
||||||
Sin conexión — los cambios se guardarán al recuperar la red
|
{t('pwa.offline')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import type { FC } from 'react';
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { List, PanelRight, PanelBottom, Settings2 } from 'lucide-react';
|
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 {
|
interface DockItem {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -27,6 +28,7 @@ export const QuickLinksModePicker: FC<{ direction?: 'up' | 'down'; align?: 'left
|
|||||||
align = 'right',
|
align = 'right',
|
||||||
}) => {
|
}) => {
|
||||||
const quick = useQuickLinks();
|
const quick = useQuickLinks();
|
||||||
|
const { t } = useI18n();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -45,7 +47,7 @@ export const QuickLinksModePicker: FC<{ direction?: 'up' | 'down'; align?: 'left
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(!open)}
|
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"
|
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} />
|
<Settings2 size={15} />
|
||||||
</button>
|
</button>
|
||||||
@@ -56,7 +58,7 @@ export const QuickLinksModePicker: FC<{ direction?: 'up' | 'down'; align?: 'left
|
|||||||
} ${align === 'right' ? 'right-0' : 'left-0'}`}
|
} ${align === 'right' ? 'right-0' : 'left-0'}`}
|
||||||
>
|
>
|
||||||
<p className="px-2.5 py-1.5 text-[10px] uppercase tracking-wider text-theme-muted font-medium">
|
<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>
|
</p>
|
||||||
{(Object.keys(MODE_ICONS) as QuickLinksMode[]).map((m) => {
|
{(Object.keys(MODE_ICONS) as QuickLinksMode[]).map((m) => {
|
||||||
const ModeIcon = MODE_ICONS[m];
|
const ModeIcon = MODE_ICONS[m];
|
||||||
@@ -71,7 +73,7 @@ export const QuickLinksModePicker: FC<{ direction?: 'up' | 'down'; align?: 'left
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ModeIcon size={15} />
|
<ModeIcon size={15} />
|
||||||
{QUICK_LINKS_MODE_LABELS[m]}
|
{t(`dock.mode.${m}`)}
|
||||||
{selected && <span className="ml-auto text-brand-coral">●</span>}
|
{selected && <span className="ml-auto text-brand-coral">●</span>}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -90,6 +92,7 @@ export const QuickLinksModePicker: FC<{ direction?: 'up' | 'down'; align?: 'left
|
|||||||
export const QuickLinksDock: FC<QuickLinksDockProps> = ({ items }) => {
|
export const QuickLinksDock: FC<QuickLinksDockProps> = ({ items }) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const quick = useQuickLinks();
|
const quick = useQuickLinks();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
if (quick.mode === 'menu') return null;
|
if (quick.mode === 'menu') return null;
|
||||||
|
|
||||||
@@ -104,7 +107,7 @@ export const QuickLinksDock: FC<QuickLinksDockProps> = ({ items }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
aria-label="Accesos rápidos"
|
aria-label={t('dock.aria')}
|
||||||
className={
|
className={
|
||||||
isRight
|
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'
|
? '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 && (
|
{dockItems.length === 0 && (
|
||||||
<span className={`text-[11px] text-theme-muted px-2 ${isRight ? 'py-2 [writing-mode:vertical-rl]' : ''}`}>
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
{dockItems.map((item) => {
|
{dockItems.map((item) => {
|
||||||
|
|||||||
@@ -46,8 +46,10 @@ import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
|
|||||||
import GlobalSearch from './GlobalSearch';
|
import GlobalSearch from './GlobalSearch';
|
||||||
import ThemePicker from './ThemePicker';
|
import ThemePicker from './ThemePicker';
|
||||||
import NotificationBell from './NotificationBell';
|
import NotificationBell from './NotificationBell';
|
||||||
|
import { LanguageToggle } from './LanguageSelect';
|
||||||
import { useAuth, ROLE_LABELS } from '../lib/auth';
|
import { useAuth, ROLE_LABELS } from '../lib/auth';
|
||||||
import { useGlosario, traducirMenu } from '../lib/glosario';
|
import { useGlosario, traducirMenu } from '../lib/glosario';
|
||||||
|
import { useI18n, useNavLabel } from '../lib/i18n';
|
||||||
import { useClinicSettings } from '../lib/clinic-settings';
|
import { useClinicSettings } from '../lib/clinic-settings';
|
||||||
import { useChatUnread } from '../lib/chat';
|
import { useChatUnread } from '../lib/chat';
|
||||||
import { useQuickLinks } from '../lib/quicklinks';
|
import { useQuickLinks } from '../lib/quicklinks';
|
||||||
@@ -135,6 +137,8 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, canSee, hasRole, logout, brand } = useAuth();
|
const { user, canSee, hasRole, logout, brand } = useAuth();
|
||||||
const { term } = useGlosario();
|
const { term } = useGlosario();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const navLabel = useNavLabel();
|
||||||
const { membresias: membresiasCfg, compras: comprasCfg, comisiones: comisionesCfg, licencia: licenciaCfg } = useClinicSettings();
|
const { membresias: membresiasCfg, compras: comprasCfg, comisiones: comisionesCfg, licencia: licenciaCfg } = useClinicSettings();
|
||||||
const menuGroupsT = useMemo(() => {
|
const menuGroupsT = useMemo(() => {
|
||||||
let base = menuGroups;
|
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') }))
|
.map((g) => ({ ...g, items: g.items.filter((i) => i.href !== '/chat') }))
|
||||||
.filter((g) => g.items.length > 0);
|
.filter((g) => g.items.length > 0);
|
||||||
}
|
}
|
||||||
return traducirMenu(base, term);
|
return traducirMenu(base, term).map((g) => ({
|
||||||
}, [term, membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo, licenciaCfg]);
|
...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 [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const chatUnread = useChatUnread();
|
const chatUnread = useChatUnread();
|
||||||
// Sidebar colapsable (modo iconos). Preferencia persistente; en pantallas
|
// Sidebar colapsable (modo iconos). Preferencia persistente; en pantallas
|
||||||
@@ -310,8 +318,8 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleCollapsed}
|
onClick={toggleCollapsed}
|
||||||
title={collapsed ? 'Expandir menú' : 'Colapsar menú'}
|
title={collapsed ? t('user.expandMenu') : t('user.collapseMenu')}
|
||||||
aria-label={collapsed ? 'Expandir menú' : 'Colapsar menú'}
|
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'}`}
|
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} />}
|
{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">
|
<nav ref={navRef} className="flex-1 overflow-y-auto py-5 px-3">
|
||||||
{quickItems.length > 0 && quick.mode === 'menu' && collapsed && (
|
{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" />
|
<div className="mx-3 mb-2 border-t border-white/10" />
|
||||||
<ul className="space-y-1 flex flex-col items-center">
|
<ul className="space-y-1 flex flex-col items-center">
|
||||||
{quickItems.map((item) => {
|
{quickItems.map((item) => {
|
||||||
@@ -349,7 +357,7 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
|||||||
{quickItems.length > 0 && quick.mode === 'menu' && !collapsed && (
|
{quickItems.length > 0 && quick.mode === 'menu' && !collapsed && (
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex items-center justify-between px-3 mb-2">
|
<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">
|
<span className="flex items-center gap-1">
|
||||||
<QuickLinksModePicker direction="down" align="right" />
|
<QuickLinksModePicker direction="down" align="right" />
|
||||||
<button
|
<button
|
||||||
@@ -448,7 +456,7 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
|||||||
className={`shrink-0 transition-all ${
|
className={`shrink-0 transition-all ${
|
||||||
pinned ? 'text-amber-400' : 'text-white/30 opacity-0 group-hover:opacity-100 hover:text-amber-400'
|
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'} />
|
<Star size={15} fill={pinned ? 'currentColor' : 'none'} />
|
||||||
</button>
|
</button>
|
||||||
@@ -469,18 +477,24 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
|||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-white truncate">{user?.name || 'Usuario'}</p>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="p-2 rounded-lg text-white/50 hover:text-white hover:bg-theme-surface/10"
|
className="p-2 rounded-lg text-white/50 hover:text-white hover:bg-theme-surface/10"
|
||||||
title="Cerrar sesión"
|
title={t('user.logout')}
|
||||||
aria-label="Cerrar sesión"
|
aria-label={t('user.logout')}
|
||||||
>
|
>
|
||||||
<LogOut size={18} />
|
<LogOut size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</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">
|
<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" />
|
<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} />
|
<X size={22} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -524,7 +538,7 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
|||||||
className="lg:hidden"
|
className="lg:hidden"
|
||||||
style={{ color: 'var(--text-heading)' }}
|
style={{ color: 'var(--text-heading)' }}
|
||||||
onClick={() => setSidebarOpen(true)}
|
onClick={() => setSidebarOpen(true)}
|
||||||
aria-label="Abrir menú"
|
aria-label={t('user.openMenu')}
|
||||||
>
|
>
|
||||||
<Menu size={22} />
|
<Menu size={22} />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -540,10 +554,10 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
|||||||
onClick={hasRole('admin') ? openGoalModal : undefined}
|
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'}`}
|
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)' }}
|
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)' }}>
|
<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>
|
<span className="font-bold" style={{ color: 'var(--text-heading)' }}>{Math.round(goal.pct)}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full h-1.5 rounded-full overflow-hidden" style={{ backgroundColor: 'var(--bg)' }}>
|
<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>
|
||||||
<div className="flex items-center justify-between text-[11px] mt-1">
|
<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 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>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{goal && goal.goal <= 0 && hasRole('admin') && (
|
{goal && goal.goal <= 0 && hasRole('admin') && (
|
||||||
<Button variant="outline" size="sm" className="hidden md:inline-flex" onClick={openGoalModal}>
|
<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>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<GlobalSearch className="hidden md:block" />
|
<GlobalSearch className="hidden md:block" />
|
||||||
@@ -578,18 +592,18 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
|||||||
<Modal
|
<Modal
|
||||||
isOpen={goalModalOpen}
|
isOpen={goalModalOpen}
|
||||||
onClose={() => setGoalModalOpen(false)}
|
onClose={() => setGoalModalOpen(false)}
|
||||||
title="Meta de ventas del mes"
|
title={t('user.goalTitle')}
|
||||||
maxWidth="sm"
|
maxWidth="sm"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" onClick={() => setGoalModalOpen(false)}>Cancelar</Button>
|
<Button variant="outline" onClick={() => setGoalModalOpen(false)}>{t('common.cancel')}</Button>
|
||||||
<Button onClick={saveGoal} loading={goalSaving}>Guardar meta</Button>
|
<Button onClick={saveGoal} loading={goalSaving}>{t('user.goalSave')}</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-3">
|
<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>
|
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>{t('user.goalBody')}</p>
|
||||||
<Input label="Meta mensual (MXN)" type="number" min={0} step={1000} value={goalInput} onChange={(e) => setGoalInput(e.target.value)} placeholder="ej. 500000" />
|
<Input label={t('user.goalField')} type="number" min={0} step={1000} value={goalInput} onChange={(e) => setGoalInput(e.target.value)} placeholder="ej. 500000" />
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
1445
frontend/src/lib/i18n.tsx
Normal file
1445
frontend/src/lib/i18n.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'
|
|||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
import { ThemeProvider } from './lib/theme.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)
|
// 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.
|
// 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(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
|
<I18nProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</I18nProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
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 { 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 { useAuth } from '../lib/auth';
|
||||||
import { useGlosario } from '../lib/glosario';
|
import { useI18n, useBusinessTerm } from '../lib/i18n';
|
||||||
import { useClinicSettings } from '../lib/clinic-settings';
|
import { useClinicSettings } from '../lib/clinic-settings';
|
||||||
import { useSucursales } from '../lib/sucursales';
|
import { useSucursales } from '../lib/sucursales';
|
||||||
import { useRecursos } from '../lib/recursos';
|
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). */
|
/** Etiqueta de estado de cita traducida ('En cabina' sigue el término de sala). */
|
||||||
export const aptStateLabel = (state: string, term: (key: GlossaryKey) => string): string =>
|
export const aptStateLabel = (
|
||||||
state === 'arrived' ? `En ${term('room_singular').toLowerCase()}` : (APT_STATE_LABELS[state] || state);
|
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 = [
|
const stateOptions = [
|
||||||
{ value: '', label: 'Todos los estados' },
|
{ value: '', label: 'Todos los estados' },
|
||||||
@@ -161,7 +172,8 @@ const Agenda: FC = () => {
|
|||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, canAgenda, can } = useAuth();
|
const { user, canAgenda, can } = useAuth();
|
||||||
const { term } = useGlosario();
|
const term = useBusinessTerm();
|
||||||
|
const { t } = useI18n();
|
||||||
const { sucursales, defaultCode } = useSucursales();
|
const { sucursales, defaultCode } = useSucursales();
|
||||||
const { recursos } = useRecursos();
|
const { recursos } = useRecursos();
|
||||||
const clinicSettings = useClinicSettings();
|
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) => {
|
const handlePhoneConfirm = async (apt: Appointment, confirmed: boolean) => {
|
||||||
try {
|
try {
|
||||||
@@ -1885,12 +1897,12 @@ const Agenda: FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout title="Agenda" subtitle="Gestión de citas">
|
<Layout title={t('nav.agenda')} subtitle={t('agenda.subtitle')}>
|
||||||
<PageHeader title="Agenda" subtitle="Filtra por fecha, estado o médico">
|
<PageHeader title={t('nav.agenda')} subtitle={t('agenda.pageSubtitle')}>
|
||||||
<AutoRefreshBadge state={autoRefresh} />
|
<AutoRefreshBadge state={autoRefresh} />
|
||||||
<Button variant="outline" onClick={() => setListaModalOpen(true)}>
|
<Button variant="outline" onClick={() => setListaModalOpen(true)}>
|
||||||
<Users size={16} className="mr-2" />
|
<Users size={16} className="mr-2" />
|
||||||
Lista de espera
|
{t('agenda.waitlist')}
|
||||||
{countEnEspera > 0 && (
|
{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">
|
<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}
|
{countEnEspera}
|
||||||
@@ -1899,16 +1911,16 @@ const Agenda: FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={openBloqueos}>
|
<Button variant="outline" onClick={openBloqueos}>
|
||||||
<Ban size={16} className="mr-2" />
|
<Ban size={16} className="mr-2" />
|
||||||
Cerrar agendas
|
{t('agenda.closeAgendas')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={() => setExpressOpen(true)}>
|
<Button variant="outline" onClick={() => setExpressOpen(true)}>
|
||||||
<Sparkles size={16} className="mr-2" />
|
<Sparkles size={16} className="mr-2" />
|
||||||
Valoración express
|
{t('agenda.express')}
|
||||||
</Button>
|
</Button>
|
||||||
{puedeAgendar && (
|
{puedeAgendar && (
|
||||||
<Button onClick={openCreate}>
|
<Button onClick={openCreate}>
|
||||||
<Plus size={16} className="mr-2" />
|
<Plus size={16} className="mr-2" />
|
||||||
Nueva {term('appointment_singular').toLowerCase()}
|
{t('agenda.newAppointmentWord')} {term('appointment_singular').toLowerCase()}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
@@ -1966,7 +1978,7 @@ const Agenda: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<Select
|
<Select
|
||||||
label="Estado"
|
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}
|
value={stateFilter}
|
||||||
onChange={(e) => setStateFilter(e.target.value)}
|
onChange={(e) => setStateFilter(e.target.value)}
|
||||||
className="sm:max-w-[180px]"
|
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'
|
: 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Espacios libres
|
{t('agenda.freeSlots')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{([
|
{([
|
||||||
{ key: 'primera', label: 'Primera vez', count: countPrimera },
|
{ key: 'primera', label: t('agenda.filterFirstTime'), count: countPrimera },
|
||||||
{ key: 'checkin', label: 'Check-in', count: countCheckin },
|
{ key: 'checkin', label: t('agenda.filterCheckin'), count: countCheckin },
|
||||||
{ key: 'noshow', label: 'No show', count: countNoShow },
|
{ key: 'noshow', label: t('agenda.filterNoShow'), count: countNoShow },
|
||||||
{ key: 'porconfirmar', label: 'Por confirmar', count: countPorConfirmar },
|
{ key: 'porconfirmar', label: t('agenda.filterToConfirm'), count: countPorConfirmar },
|
||||||
{ key: 'cancelled', label: 'Canceló', count: countCanceladas },
|
{ key: 'cancelled', label: t('agenda.filterCancelled'), count: countCanceladas },
|
||||||
{ key: 'waitlist', label: 'En lista', count: countWaitlist },
|
{ key: 'waitlist', label: t('agenda.filterWaitlist'), count: countWaitlist },
|
||||||
] as const).map((chip) => (
|
] as const).map((chip) => (
|
||||||
<button
|
<button
|
||||||
key={chip.key}
|
key={chip.key}
|
||||||
@@ -2311,8 +2323,8 @@ const Agenda: FC = () => {
|
|||||||
) : viewMode === 'doctores' ? (
|
) : viewMode === 'doctores' ? (
|
||||||
gridColumns.length === 0 ? (
|
gridColumns.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="Sin médicos"
|
title={t('agenda.noDoctorsTitle')}
|
||||||
subtitle="No hay médicos dados de alta para mostrar en la vista por médico."
|
subtitle={t('agenda.noDoctorsSub')}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
@@ -2452,8 +2464,8 @@ const Agenda: FC = () => {
|
|||||||
) : viewMode === 'horas' ? (
|
) : viewMode === 'horas' ? (
|
||||||
gridColumns.length === 0 ? (
|
gridColumns.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="Sin médicos"
|
title={t('agenda.noDoctorsTitle')}
|
||||||
subtitle="No hay médicos dados de alta para mostrar en la vista por hora."
|
subtitle={t('agenda.noDoctorsSubHour')}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
@@ -2580,8 +2592,8 @@ const Agenda: FC = () => {
|
|||||||
) : viewMode === 'semana' ? (
|
) : viewMode === 'semana' ? (
|
||||||
gridColumns.length === 0 ? (
|
gridColumns.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="Sin médicos"
|
title={t('agenda.noDoctorsTitle')}
|
||||||
subtitle="No hay médicos dados de alta para mostrar en la vista semanal."
|
subtitle={t('agenda.noDoctorsSubWeek')}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
@@ -2688,8 +2700,8 @@ const Agenda: FC = () => {
|
|||||||
) : viewMode === 'recursos' ? (
|
) : viewMode === 'recursos' ? (
|
||||||
gridRecursos.length === 0 ? (
|
gridRecursos.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="Sin recursos"
|
title={t('agenda.noResourcesTitle')}
|
||||||
subtitle="Agrega recursos en Configuración › Recursos para ver esta vista."
|
subtitle={t('agenda.noResourcesSub')}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
@@ -2870,7 +2882,7 @@ const Agenda: FC = () => {
|
|||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">{apt.doctor || '-'}</td>
|
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">{apt.doctor || '-'}</td>
|
||||||
<td className="p-3">
|
<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>
|
||||||
<td className="p-3 text-center">
|
<td className="p-3 text-center">
|
||||||
<button
|
<button
|
||||||
@@ -2903,7 +2915,7 @@ const Agenda: FC = () => {
|
|||||||
rows={[
|
rows={[
|
||||||
{ label: 'Hora', value: apt.time },
|
{ label: 'Hora', value: apt.time },
|
||||||
{ label: 'Doctor', value: apt.doctor || '-' },
|
{ 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: 'Confirmación', value: <span title={confirmTitle(apt)}>{confirmIndicator(apt, 14)}</span> },
|
||||||
{ label: 'Pago', value: apt.payment_state },
|
{ label: 'Pago', value: apt.payment_state },
|
||||||
]}
|
]}
|
||||||
@@ -2989,13 +3001,13 @@ const Agenda: FC = () => {
|
|||||||
<Modal
|
<Modal
|
||||||
isOpen={createOpen}
|
isOpen={createOpen}
|
||||||
onClose={() => cerrarModalCita()}
|
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"
|
maxWidth="lg"
|
||||||
footer={
|
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}>
|
<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>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -3008,16 +3020,16 @@ const Agenda: FC = () => {
|
|||||||
onChange={(e) => setQuickMode(e.target.checked)}
|
onChange={(e) => setQuickMode(e.target.checked)}
|
||||||
className="rounded border-theme-border-strong"
|
className="rounded border-theme-border-strong"
|
||||||
/>
|
/>
|
||||||
Cita rápida (solo nombre y teléfono)
|
{t('agenda.quickMode')}
|
||||||
</label>
|
</label>
|
||||||
{quickMode ? (
|
{quickMode ? (
|
||||||
<>
|
<>
|
||||||
<p className="text-xs text-theme-muted">
|
<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>
|
</p>
|
||||||
<Input
|
<Input
|
||||||
label="Nombre *"
|
label={t('agenda.nameField')}
|
||||||
placeholder="Nombre del paciente"
|
placeholder={t('agenda.namePlaceholder')}
|
||||||
value={quickForm.name}
|
value={quickForm.name}
|
||||||
onChange={(e) => setQuickForm({ ...quickForm, name: e.target.value })}
|
onChange={(e) => setQuickForm({ ...quickForm, name: e.target.value })}
|
||||||
/>
|
/>
|
||||||
@@ -3252,7 +3264,7 @@ const Agenda: FC = () => {
|
|||||||
<Modal
|
<Modal
|
||||||
isOpen={expressOpen}
|
isOpen={expressOpen}
|
||||||
onClose={() => setExpressOpen(false)}
|
onClose={() => setExpressOpen(false)}
|
||||||
title="Valoración express"
|
title={t('agenda.expressTitle')}
|
||||||
maxWidth="md"
|
maxWidth="md"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
@@ -3333,7 +3345,7 @@ const Agenda: FC = () => {
|
|||||||
<Modal
|
<Modal
|
||||||
isOpen={!!detailApt}
|
isOpen={!!detailApt}
|
||||||
onClose={() => setDetailApt(null)}
|
onClose={() => setDetailApt(null)}
|
||||||
title={editMode ? 'Reagendar cita' : 'Detalle de la cita'}
|
title={editMode ? t('agenda.rescheduleTitle') : t('agenda.detailTitle')}
|
||||||
maxWidth="md"
|
maxWidth="md"
|
||||||
footer={
|
footer={
|
||||||
editMode ? (
|
editMode ? (
|
||||||
@@ -3613,7 +3625,7 @@ const Agenda: FC = () => {
|
|||||||
<Modal
|
<Modal
|
||||||
isOpen={!!conflict}
|
isOpen={!!conflict}
|
||||||
onClose={() => setConflict(null)}
|
onClose={() => setConflict(null)}
|
||||||
title="Conflicto de horario"
|
title={t('agenda.conflictTitle')}
|
||||||
maxWidth="sm"
|
maxWidth="sm"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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 { 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 { useAuth } from '../lib/auth';
|
||||||
import { publicarClinicSettings } from '../lib/clinic-settings';
|
import { publicarClinicSettings } from '../lib/clinic-settings';
|
||||||
|
import { useI18n } from '../lib/i18n';
|
||||||
|
import { LanguageSelect } from '../components/LanguageSelect';
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ key: 'clinica', label: 'Clínica' },
|
{ key: 'clinica', label: 'Clínica' },
|
||||||
@@ -67,6 +69,9 @@ const LEGACY_STORAGE_KEY = 'skeen_clinic_settings';
|
|||||||
|
|
||||||
const Configuracion: FC = () => {
|
const Configuracion: FC = () => {
|
||||||
const { user, brand, setBrand, can } = useAuth();
|
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
|
// 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.
|
// de contacto, tipo de cambio y comisiones) queda para administración del sistema.
|
||||||
const esAdmin = user?.role === 'admin';
|
const esAdmin = user?.role === 'admin';
|
||||||
@@ -321,21 +326,21 @@ const Configuracion: FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout title="Configuración" subtitle="Ajustes de la clínica">
|
<Layout title={t('config.title')} subtitle={t('config.subtitle')}>
|
||||||
<PageHeader title="Configuración" subtitle="Información y ajustes de la clínica" />
|
<PageHeader title={t('config.title')} subtitle={t('config.pageSubtitle')} />
|
||||||
|
|
||||||
{/* Tabs de secciones */}
|
{/* Tabs de secciones */}
|
||||||
<div className="flex flex-wrap gap-1.5 mb-6">
|
<div className="flex flex-wrap gap-1.5 mb-6">
|
||||||
{tabsVisibles.map((t) => (
|
{tabsVisibles.map((tabItem) => (
|
||||||
<button
|
<button
|
||||||
key={t.key}
|
key={tabItem.key}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTab(t.key)}
|
onClick={() => setTab(tabItem.key)}
|
||||||
className={`px-4 py-2 text-sm font-medium rounded-full transition ${
|
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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -343,7 +348,7 @@ const Configuracion: FC = () => {
|
|||||||
{tabsVisibles.length === 0 ? (
|
{tabsVisibles.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<Card.Body>
|
<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.Body>
|
||||||
</Card>
|
</Card>
|
||||||
) : tab === 'usuarios' ? (
|
) : 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'
|
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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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.Body>
|
||||||
</Card>
|
</Card>
|
||||||
) : tab === 'sucursales' ? (
|
) : tab === 'sucursales' ? (
|
||||||
@@ -461,10 +466,20 @@ const Configuracion: FC = () => {
|
|||||||
) : tab === 'webhooks' ? (
|
) : tab === 'webhooks' ? (
|
||||||
<WebhooksPanel />
|
<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">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
|
||||||
<Card>
|
<Card>
|
||||||
<Card.Body>
|
<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">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<a
|
<a
|
||||||
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(settings.direccion)}`}
|
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(settings.direccion)}`}
|
||||||
@@ -529,7 +544,7 @@ const Configuracion: FC = () => {
|
|||||||
<Card.Body>
|
<Card.Body>
|
||||||
<h3 className="font-heading text-xl text-theme-heading mb-4 flex items-center">
|
<h3 className="font-heading text-xl text-theme-heading mb-4 flex items-center">
|
||||||
<Building2 size={20} className="mr-2" />
|
<Building2 size={20} className="mr-2" />
|
||||||
Ajustes
|
{t('config.settings')}
|
||||||
</h3>
|
</h3>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-sm text-theme-muted">Cargando...</p>
|
<p className="text-sm text-theme-muted">Cargando...</p>
|
||||||
@@ -693,7 +708,7 @@ const Configuracion: FC = () => {
|
|||||||
{esAdmin && (
|
{esAdmin && (
|
||||||
<Card>
|
<Card>
|
||||||
<Card.Body>
|
<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">
|
<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
|
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.
|
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"
|
placeholder="Vacío = color del tema"
|
||||||
/>
|
/>
|
||||||
{brandForm[key] && (
|
{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
|
Limpiar
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -797,7 +812,7 @@ const Configuracion: FC = () => {
|
|||||||
<Card.Body>
|
<Card.Body>
|
||||||
<h3 className="font-heading text-xl text-theme-heading mb-1 flex items-center">
|
<h3 className="font-heading text-xl text-theme-heading mb-1 flex items-center">
|
||||||
<ArrowRightLeft size={20} className="mr-2" />
|
<ArrowRightLeft size={20} className="mr-2" />
|
||||||
Tipo de cambio USD / MXN
|
{t('config.exchangeRate')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-theme-muted mb-4">
|
<p className="text-sm text-theme-muted mb-4">
|
||||||
Se usa para convertir ventas en dólares. Vigente:
|
Se usa para convertir ventas en dólares. Vigente:
|
||||||
@@ -821,7 +836,7 @@ const Configuracion: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<Button onClick={saveTc} disabled={tcSaving}>
|
<Button onClick={saveTc} disabled={tcSaving}>
|
||||||
<Save size={16} className="mr-2" />
|
<Save size={16} className="mr-2" />
|
||||||
{tcSaving ? 'Guardando...' : 'Actualizar TC'}
|
{tcSaving ? t('common.saving') : t('config.updateRate')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
@@ -831,7 +846,7 @@ const Configuracion: FC = () => {
|
|||||||
<Card.Body>
|
<Card.Body>
|
||||||
<h3 className="font-heading text-xl text-theme-heading mb-1 flex items-center">
|
<h3 className="font-heading text-xl text-theme-heading mb-1 flex items-center">
|
||||||
<Percent size={20} className="mr-2" />
|
<Percent size={20} className="mr-2" />
|
||||||
Comisiones
|
{t('config.commissions')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-theme-muted mb-4">
|
<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").
|
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>
|
</div>
|
||||||
<Button onClick={saveCommissionDefault} disabled={commissionDefaultSaving}>
|
<Button onClick={saveCommissionDefault} disabled={commissionDefaultSaving}>
|
||||||
<Save size={16} className="mr-2" />
|
<Save size={16} className="mr-2" />
|
||||||
{commissionDefaultSaving ? 'Guardando...' : 'Guardar'}
|
{commissionDefaultSaving ? t('common.saving') : t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@@ -910,7 +925,7 @@ const Configuracion: FC = () => {
|
|||||||
onClick={() => saveDoctorCommission(d.id)}
|
onClick={() => saveDoctorCommission(d.id)}
|
||||||
disabled={commissionSavingId === d.id}
|
disabled={commissionSavingId === d.id}
|
||||||
>
|
>
|
||||||
{commissionSavingId === d.id ? 'Guardando...' : 'Guardar'}
|
{commissionSavingId === d.id ? t('common.saving') : t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
@@ -927,6 +942,7 @@ const Configuracion: FC = () => {
|
|||||||
</Card.Body>
|
</Card.Body>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ImpactoModal
|
<ImpactoModal
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ import { odooApi, type DashboardStats, type Appointment, type Service, type Char
|
|||||||
import { usePatientDrawer } from '../components/PatientDrawer';
|
import { usePatientDrawer } from '../components/PatientDrawer';
|
||||||
import { useTheme } from '../lib/theme';
|
import { useTheme } from '../lib/theme';
|
||||||
import { useAuth } from '../lib/auth';
|
import { useAuth } from '../lib/auth';
|
||||||
import { useGlosario } from '../lib/glosario';
|
import { useI18n, useBusinessTerm } from '../lib/i18n';
|
||||||
import { waLink } from '../lib/whatsapp';
|
import { waLink } from '../lib/whatsapp';
|
||||||
import { aptStateLabel } from './Agenda';
|
import { aptStateLabel } from './Agenda';
|
||||||
|
|
||||||
@@ -162,7 +162,8 @@ const TodayTile: FC<TodayTileProps> = ({ label, icon, onClick, children, classNa
|
|||||||
const Dashboard: FC = () => {
|
const Dashboard: FC = () => {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const { user, brand } = useAuth();
|
const { user, brand } = useAuth();
|
||||||
const { term } = useGlosario();
|
const term = useBusinessTerm();
|
||||||
|
const { t, locale } = useI18n();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isHomeNest = theme === 'homenest';
|
const isHomeNest = theme === 'homenest';
|
||||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||||
@@ -322,49 +323,49 @@ const Dashboard: FC = () => {
|
|||||||
const statsConfig = useMemo(
|
const statsConfig = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
title: 'Citas Hoy',
|
title: t('dashboard.statAppointments'),
|
||||||
value: stats?.appointments_today ?? 0,
|
value: stats?.appointments_today ?? 0,
|
||||||
icon: <Calendar size={20} />,
|
icon: <Calendar size={20} />,
|
||||||
trend: stats ? `${stats.appointments_month} este mes` : undefined,
|
trend: stats ? t('dashboard.thisMonth', { n: stats.appointments_month }) : undefined,
|
||||||
to: '/agenda',
|
to: '/agenda',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Pacientes',
|
title: t('dashboard.statPatients'),
|
||||||
value: stats?.total_patients ?? 0,
|
value: stats?.total_patients ?? 0,
|
||||||
icon: <Users size={20} />,
|
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',
|
to: '/pacientes',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Ingresos Mes',
|
title: t('dashboard.statRevenue'),
|
||||||
value: stats ? formatCurrency(stats.revenue_month) : '$0',
|
value: stats ? formatCurrency(stats.revenue_month) : '$0',
|
||||||
icon: <DollarSign size={20} />,
|
icon: <DollarSign size={20} />,
|
||||||
to: '/reportes?tab=ingresos',
|
to: '/reportes?tab=ingresos',
|
||||||
delta: buildDelta(stats?.revenue_month, stats?.prev_revenue_month),
|
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,
|
value: stats?.sales_today ?? 0,
|
||||||
icon: <ShoppingCart size={20} />,
|
icon: <ShoppingCart size={20} />,
|
||||||
to: '/ventas',
|
to: '/ventas',
|
||||||
delta: buildDelta(stats?.sales_today, stats?.prev_sales_today),
|
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,
|
value: stats?.pending_payments ?? 0,
|
||||||
icon: <Clock size={20} />,
|
icon: <Clock size={20} />,
|
||||||
to: '/reportes?tab=adeudos',
|
to: '/reportes?tab=adeudos',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Puntos Monedero',
|
title: t('dashboard.statWallet'),
|
||||||
value: stats?.total_wallet_points ?? 0,
|
value: stats?.total_wallet_points ?? 0,
|
||||||
icon: <Wallet size={20} />,
|
icon: <Wallet size={20} />,
|
||||||
to: '/monedero',
|
to: '/monedero',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[stats]
|
[stats, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Próximas citas de hoy (hora >= ahora); si ya no queda ninguna, las
|
// 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);
|
return (upcoming.length > 0 ? upcoming : sorted).slice(0, 3);
|
||||||
}, [appointments]);
|
}, [appointments]);
|
||||||
|
|
||||||
const todayLabel = new Date().toLocaleDateString('es-MX', {
|
const todayLabel = new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'es-MX', {
|
||||||
weekday: 'long',
|
weekday: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
});
|
});
|
||||||
|
|
||||||
const cajaStatus = useMemo(() => {
|
const cajaStatus = useMemo(() => {
|
||||||
if (!todayInfo) return { label: '—', ref: 'Consultando…', tone: 'text-theme-muted' };
|
if (!todayInfo) return { label: '—', ref: t('dashboard.checking'), tone: 'text-theme-muted' };
|
||||||
if (!todayInfo.caja) return { label: 'Sin abrir', ref: 'Sin corte de caja hoy', 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')
|
if (todayInfo.caja.state === 'open')
|
||||||
return { label: 'Abierta', ref: todayInfo.caja.name, tone: 'text-emerald-600' };
|
return { label: t('dashboard.cashOpen'), ref: todayInfo.caja.name, tone: 'text-emerald-600' };
|
||||||
return { label: 'Cerrada', ref: todayInfo.caja.name, tone: 'text-theme-heading' };
|
return { label: t('dashboard.cashClosed'), ref: todayInfo.caja.name, tone: 'text-theme-heading' };
|
||||||
}, [todayInfo]);
|
}, [todayInfo, t]);
|
||||||
|
|
||||||
const activityItems = useMemo(() => {
|
const activityItems = useMemo(() => {
|
||||||
return appointments.slice(0, 4).map((apt) => ({
|
return appointments.slice(0, 4).map((apt) => ({
|
||||||
@@ -409,13 +410,13 @@ const Dashboard: FC = () => {
|
|||||||
const s = aptReport.by_state || {};
|
const s = aptReport.by_state || {};
|
||||||
const pick = (...keys: string[]) => keys.reduce((a, k) => a + (s[k] || 0), 0);
|
const pick = (...keys: string[]) => keys.reduce((a, k) => a + (s[k] || 0), 0);
|
||||||
return [
|
return [
|
||||||
{ name: 'Completadas', value: pick('done'), color: '#CAE2C1' },
|
{ name: t('dashboard.stateDone'), value: pick('done'), color: '#CAE2C1' },
|
||||||
{ name: 'Confirmadas', value: pick('confirmed', 'arrived', 'in_progress'), color: '#A4CCE2' },
|
{ name: t('dashboard.stateConfirmed'), value: pick('confirmed', 'arrived', 'in_progress'), color: '#A4CCE2' },
|
||||||
{ name: 'Pendientes', value: pick('pending'), color: '#EDA588' },
|
{ name: t('dashboard.statPending'), value: pick('pending'), color: '#EDA588' },
|
||||||
{ name: 'No show', value: pick('no_show'), color: '#E57373' },
|
{ 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);
|
].filter((d) => d.value > 0);
|
||||||
}, [aptReport]);
|
}, [aptReport, t]);
|
||||||
|
|
||||||
const noShowRate = useMemo(() => {
|
const noShowRate = useMemo(() => {
|
||||||
if (!aptReport || !aptReport.total) return 0;
|
if (!aptReport || !aptReport.total) return 0;
|
||||||
@@ -423,25 +424,25 @@ const Dashboard: FC = () => {
|
|||||||
}, [aptReport]);
|
}, [aptReport]);
|
||||||
|
|
||||||
return (
|
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 ? (
|
{isHomeNest ? (
|
||||||
<div className="rounded-3xl p-6 sm:p-10 mb-8 border shadow-soft" style={{ backgroundColor: 'var(--surface)', borderColor: 'var(--border)' }}>
|
<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="flex flex-col lg:flex-row lg:items-center justify-between gap-6">
|
||||||
<div className="max-w-2xl">
|
<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)' }}>
|
<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>
|
</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>
|
<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)' }}>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-3 shrink-0">
|
<div className="flex flex-wrap gap-3 shrink-0">
|
||||||
<Button variant="primary" size="md" onClick={() => navigate('/agenda')}>
|
<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>
|
||||||
<Button variant="outline" size="md" onClick={() => navigate('/reportes')}>
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -457,10 +458,10 @@ const Dashboard: FC = () => {
|
|||||||
</div>
|
</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">
|
<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} />
|
<Sparkles size={16} />
|
||||||
Bienvenido{user?.name ? `, ${user.name.split(' ')[0]}` : ''}
|
{user?.name ? t('dashboard.welcomeUser', { name: user.name.split(' ')[0] }) : t('dashboard.welcome')}
|
||||||
</span>
|
</span>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
@@ -469,13 +470,13 @@ const Dashboard: FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="font-heading text-2xl sm:text-3xl mb-2">{brand?.nombre || 'Panel Clínico'}</h1>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0">
|
<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')}>
|
<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" />
|
<TrendingUp size={16} className="mr-2" />
|
||||||
Ver reportes
|
{t('dashboard.viewReports')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -487,21 +488,21 @@ const Dashboard: FC = () => {
|
|||||||
<Card className="mb-6 sm:mb-8">
|
<Card className="mb-6 sm:mb-8">
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<div>
|
<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>
|
<p className="text-xs text-theme-muted capitalize">{todayLabel}</p>
|
||||||
</div>
|
</div>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Body>
|
<Card.Body>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-5 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-5 gap-3">
|
||||||
<TodayTile
|
<TodayTile
|
||||||
label="Próximas citas"
|
label={t('dashboard.upcoming')}
|
||||||
icon={<CalendarClock size={14} />}
|
icon={<CalendarClock size={14} />}
|
||||||
onClick={() => navigate('/agenda')}
|
onClick={() => navigate('/agenda')}
|
||||||
ariaLabel="Ver próximas citas en la agenda"
|
ariaLabel={t('dashboard.ariaUpcoming')}
|
||||||
className="md:col-span-2"
|
className="md:col-span-2"
|
||||||
>
|
>
|
||||||
{upcomingToday.length === 0 ? (
|
{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">
|
<div className="space-y-1.5">
|
||||||
{upcomingToday.map((apt) => (
|
{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="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-theme-heading truncate flex-1">{apt.patient}</span>
|
||||||
<span className="text-xs text-theme-muted truncate hidden sm:inline max-w-[120px]">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -518,28 +519,26 @@ const Dashboard: FC = () => {
|
|||||||
</TodayTile>
|
</TodayTile>
|
||||||
|
|
||||||
<TodayTile
|
<TodayTile
|
||||||
label="Sala de espera"
|
label={t('dashboard.waitingRoom')}
|
||||||
icon={<Armchair size={14} />}
|
icon={<Armchair size={14} />}
|
||||||
onClick={() => navigate('/agenda')}
|
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">
|
<p className="text-2xl font-heading font-semibold text-theme-heading">
|
||||||
{todayInfo ? (todayInfo.waiting ?? '—') : '—'}
|
{todayInfo ? (todayInfo.waiting ?? '—') : '—'}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-theme-muted">
|
<p className="text-xs text-theme-muted">
|
||||||
{todayInfo?.waiting != null
|
{todayInfo?.waiting != null
|
||||||
? todayInfo.waiting === 1
|
? (todayInfo.waiting === 1 ? t('dashboard.waiting_one') : t('dashboard.waiting_other'))
|
||||||
? 'paciente en espera'
|
: t('dashboard.notAvailable')}
|
||||||
: 'pacientes en espera'
|
|
||||||
: 'no disponible'}
|
|
||||||
</p>
|
</p>
|
||||||
</TodayTile>
|
</TodayTile>
|
||||||
|
|
||||||
<TodayTile
|
<TodayTile
|
||||||
label="Caja"
|
label={t('dashboard.cash')}
|
||||||
icon={<Vault size={14} />}
|
icon={<Vault size={14} />}
|
||||||
onClick={() => navigate('/cortes')}
|
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-xl font-heading font-semibold ${cajaStatus.tone}`}>{cajaStatus.label}</p>
|
||||||
<p className="text-xs text-theme-muted truncate" title={cajaStatus.ref}>
|
<p className="text-xs text-theme-muted truncate" title={cajaStatus.ref}>
|
||||||
@@ -548,15 +547,15 @@ const Dashboard: FC = () => {
|
|||||||
</TodayTile>
|
</TodayTile>
|
||||||
|
|
||||||
<TodayTile
|
<TodayTile
|
||||||
label="Cobros del día"
|
label={t('dashboard.dayCharges')}
|
||||||
icon={<Banknote size={14} />}
|
icon={<Banknote size={14} />}
|
||||||
onClick={() => navigate('/ventas')}
|
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">
|
<p className="text-2xl font-heading font-semibold text-theme-heading">
|
||||||
{todayInfo ? (todayInfo.cobros != null ? formatCurrency(todayInfo.cobros) : '—') : '—'}
|
{todayInfo ? (todayInfo.cobros != null ? formatCurrency(todayInfo.cobros) : '—') : '—'}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-theme-muted">pagos completados hoy</p>
|
<p className="text-xs text-theme-muted">{t('dashboard.completedPayments')}</p>
|
||||||
</TodayTile>
|
</TodayTile>
|
||||||
</div>
|
</div>
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
@@ -584,12 +583,12 @@ const Dashboard: FC = () => {
|
|||||||
<Card.Header>
|
<Card.Header>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-heading text-lg text-theme-heading flex items-center gap-2">
|
<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>
|
</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>
|
</div>
|
||||||
<Button variant="ghost" size="sm" onClick={() => navigate('/cumpleanos')}>
|
<Button variant="ghost" size="sm" onClick={() => navigate('/cumpleanos')}>
|
||||||
Ver todos
|
{t('common.viewAll')}
|
||||||
<ArrowRight size={14} className="ml-1" />
|
<ArrowRight size={14} className="ml-1" />
|
||||||
</Button>
|
</Button>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
@@ -597,7 +596,7 @@ const Dashboard: FC = () => {
|
|||||||
{loadingBirthdays ? (
|
{loadingBirthdays ? (
|
||||||
<Skeleton count={2} className="h-10 w-full" />
|
<Skeleton count={2} className="h-10 w-full" />
|
||||||
) : birthdays.length === 0 ? (
|
) : 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">
|
<div className="flex flex-wrap gap-2">
|
||||||
{birthdays.map((b) => (
|
{birthdays.map((b) => (
|
||||||
@@ -609,12 +608,12 @@ const Dashboard: FC = () => {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => openPatient(b.id)}
|
onClick={() => openPatient(b.id)}
|
||||||
className="text-sm font-medium text-theme-heading hover:underline hover:text-theme-accent text-left"
|
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}
|
{b.name}
|
||||||
</button>
|
</button>
|
||||||
<span className="text-xs text-theme-muted">
|
<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>
|
</span>
|
||||||
{(b.whatsapp || b.phone) && (
|
{(b.whatsapp || b.phone) && (
|
||||||
<a
|
<a
|
||||||
@@ -639,15 +638,15 @@ const Dashboard: FC = () => {
|
|||||||
<Card>
|
<Card>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-heading text-lg text-theme-heading">Actividad semanal</h3>
|
<h3 className="font-heading text-lg text-theme-heading">{t('dashboard.chartWeeklyTitle')}</h3>
|
||||||
<p className="text-xs text-theme-muted">Citas e ingresos de los últimos 7 días</p>
|
<p className="text-xs text-theme-muted">{t('dashboard.chartWeeklySub')}</p>
|
||||||
</div>
|
</div>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Body>
|
<Card.Body>
|
||||||
{loadingChart ? (
|
{loadingChart ? (
|
||||||
<Skeleton count={8} className="h-6 w-full" />
|
<Skeleton count={8} className="h-6 w-full" />
|
||||||
) : chartData.length === 0 ? (
|
) : 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">
|
<div className="h-[300px] w-full">
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
@@ -680,8 +679,8 @@ const Dashboard: FC = () => {
|
|||||||
<Card className="flex flex-col">
|
<Card className="flex flex-col">
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-heading text-lg text-theme-heading">Próximas citas</h3>
|
<h3 className="font-heading text-lg text-theme-heading">{t('dashboard.upcoming')}</h3>
|
||||||
<p className="text-xs text-theme-muted">Citas programadas para hoy</p>
|
<p className="text-xs text-theme-muted">{t('dashboard.upcomingTodaySub')}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" className="hidden sm:inline-flex">
|
<Button variant="ghost" size="sm" className="hidden sm:inline-flex">
|
||||||
Ver agenda
|
Ver agenda
|
||||||
@@ -696,7 +695,7 @@ const Dashboard: FC = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : appointments.length === 0 ? (
|
) : 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">
|
<div className="space-y-3">
|
||||||
{appointments.map((apt) => (
|
{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-sm font-medium text-theme-heading truncate">{apt.patient}</p>
|
||||||
<p className="text-xs text-theme-muted truncate">{apt.service} • {apt.time}</p>
|
<p className="text-xs text-theme-muted truncate">{apt.service} • {apt.time}</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={badgeForAppointmentState(apt.state)}>{aptStateLabel(apt.state, term)}</Badge>
|
<Badge variant={badgeForAppointmentState(apt.state)}>{aptStateLabel(apt.state, term, t)}</Badge>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -722,15 +721,15 @@ const Dashboard: FC = () => {
|
|||||||
<Card>
|
<Card>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-heading text-lg text-theme-heading">Top servicios del mes</h3>
|
<h3 className="font-heading text-lg text-theme-heading">{t('dashboard.topServicesTitle')}</h3>
|
||||||
<p className="text-xs text-theme-muted">Por ingresos generados</p>
|
<p className="text-xs text-theme-muted">{t('dashboard.topServicesSub')}</p>
|
||||||
</div>
|
</div>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Body>
|
<Card.Body>
|
||||||
{loadingExtras ? (
|
{loadingExtras ? (
|
||||||
<Skeleton count={6} className="h-6 w-full" />
|
<Skeleton count={6} className="h-6 w-full" />
|
||||||
) : topServices.length === 0 ? (
|
) : 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">
|
<div className="h-[280px] w-full">
|
||||||
<ResponsiveContainer width="100%" height={280}>
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
@@ -761,7 +760,7 @@ const Dashboard: FC = () => {
|
|||||||
<Card>
|
<Card>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<div>
|
<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">
|
<p className="text-xs text-theme-muted">
|
||||||
{aptReport ? `${aptReport.total} citas · tasa no-show ${noShowRate}%` : 'Distribución por estado'}
|
{aptReport ? `${aptReport.total} citas · tasa no-show ${noShowRate}%` : 'Distribución por estado'}
|
||||||
</p>
|
</p>
|
||||||
@@ -771,7 +770,7 @@ const Dashboard: FC = () => {
|
|||||||
{loadingExtras ? (
|
{loadingExtras ? (
|
||||||
<Skeleton count={6} className="h-6 w-full" />
|
<Skeleton count={6} className="h-6 w-full" />
|
||||||
) : attendanceData.length === 0 ? (
|
) : 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="flex flex-col sm:flex-row items-center gap-4">
|
||||||
<div className="h-[240px] w-full sm:w-1/2">
|
<div className="h-[240px] w-full sm:w-1/2">
|
||||||
@@ -808,8 +807,8 @@ const Dashboard: FC = () => {
|
|||||||
<Card>
|
<Card>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-heading text-lg text-theme-heading">Servicios destacados</h3>
|
<h3 className="font-heading text-lg text-theme-heading">{t('dashboard.featuredTitle')}</h3>
|
||||||
<p className="text-xs text-theme-muted">Catálogo activo de tratamientos</p>
|
<p className="text-xs text-theme-muted">{t('dashboard.featuredSub')}</p>
|
||||||
</div>
|
</div>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Body>
|
<Card.Body>
|
||||||
@@ -820,7 +819,7 @@ const Dashboard: FC = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : services.length === 0 ? (
|
) : 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">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
{services.map((s) => (
|
{services.map((s) => (
|
||||||
@@ -846,8 +845,8 @@ const Dashboard: FC = () => {
|
|||||||
<Card>
|
<Card>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-heading text-lg text-theme-heading">Actividad reciente</h3>
|
<h3 className="font-heading text-lg text-theme-heading">{t('dashboard.recentTitle')}</h3>
|
||||||
<p className="text-xs text-theme-muted">Últimos movimientos en la clínica</p>
|
<p className="text-xs text-theme-muted">{t('dashboard.recentSub')}</p>
|
||||||
</div>
|
</div>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Body>
|
<Card.Body>
|
||||||
@@ -858,7 +857,7 @@ const Dashboard: FC = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : activityItems.length === 0 ? (
|
) : 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">
|
<div className="space-y-3">
|
||||||
{activityItems.map((item) => (
|
{activityItems.map((item) => (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { AlertTriangle, CheckCircle2, Loader2, Send } from 'lucide-react';
|
|||||||
import { odooApi, type FormularioPublicoConfig } from '../services/odoo';
|
import { odooApi, type FormularioPublicoConfig } from '../services/odoo';
|
||||||
import FormularioCampos, { type FotoConCampo } from '../components/formulario/FormularioCampos';
|
import FormularioCampos, { type FotoConCampo } from '../components/formulario/FormularioCampos';
|
||||||
import { necesitaFirma, necesitaFoto, validarLocal } from '../components/formulario/tipos';
|
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,
|
// 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
|
// 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 FormularioPublico: FC = () => {
|
||||||
const { token = '' } = useParams();
|
const { token = '' } = useParams();
|
||||||
|
const { t, locale } = useI18n();
|
||||||
const [config, setConfig] = useState<FormularioPublicoConfig | null>(null);
|
const [config, setConfig] = useState<FormularioPublicoConfig | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [noDisponible, setNoDisponible] = useState<string | null>(null);
|
const [noDisponible, setNoDisponible] = useState<string | null>(null);
|
||||||
@@ -55,12 +57,14 @@ const FormularioPublico: FC = () => {
|
|||||||
const pideFirma = necesitaFirma(campos, config?.plantilla.requiere_firma);
|
const pideFirma = necesitaFirma(campos, config?.plantilla.requiere_firma);
|
||||||
const pideFoto = necesitaFoto(campos, config?.plantilla.requiere_foto);
|
const pideFoto = necesitaFoto(campos, config?.plantilla.requiere_foto);
|
||||||
const term = (k: string, fallback: string) =>
|
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 () => {
|
const enviarLlenado = async () => {
|
||||||
if (!config) return;
|
if (!config) return;
|
||||||
const errs = validarLocal(campos, valores, firma, fotos, config.plantilla.requiere_firma, config.plantilla.requiere_foto);
|
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);
|
setErrores(errs);
|
||||||
if (Object.keys(errs).length) {
|
if (Object.keys(errs).length) {
|
||||||
const primero = document.querySelector('[data-error="1"]');
|
const primero = document.querySelector('[data-error="1"]');
|
||||||
@@ -91,11 +95,11 @@ const FormularioPublico: FC = () => {
|
|||||||
hp,
|
hp,
|
||||||
});
|
});
|
||||||
if (res.status === 'success') setEnviado(true);
|
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) {
|
} catch (err) {
|
||||||
const r = err as { response?: { status?: number; data?: { message?: string } } };
|
const r = err as { response?: { status?: number; data?: { message?: string } } };
|
||||||
if (r?.response?.status === 409) { setYaLlenado(true); return; }
|
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 {
|
} finally {
|
||||||
setEnviando(false);
|
setEnviando(false);
|
||||||
}
|
}
|
||||||
@@ -114,9 +118,9 @@ const FormularioPublico: FC = () => {
|
|||||||
<Centro>
|
<Centro>
|
||||||
<div className="w-full max-w-sm rounded-3xl border border-theme-border bg-theme-surface p-8 text-center shadow-soft">
|
<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" />
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Centro>
|
</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">
|
<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" />
|
<CheckCircle2 className="mx-auto mb-3 h-10 w-10 text-emerald-500" />
|
||||||
<h1 className="font-heading text-lg text-theme-heading">
|
<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>
|
</h1>
|
||||||
<p className="mt-2 text-sm text-theme-muted">
|
<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>
|
||||||
<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>
|
</div>
|
||||||
</Centro>
|
</Centro>
|
||||||
);
|
);
|
||||||
@@ -155,7 +159,7 @@ const FormularioPublico: FC = () => {
|
|||||||
)}
|
)}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-sm font-semibold text-theme-heading">{brand.nombre}</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -174,7 +178,7 @@ const FormularioPublico: FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{config.plantilla.duracion_estimada_min > 0 && (
|
{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">
|
<div className="mt-4">
|
||||||
@@ -211,8 +215,7 @@ const FormularioPublico: FC = () => {
|
|||||||
)}
|
)}
|
||||||
{pideFoto && pideFirma && (
|
{pideFoto && pideFirma && (
|
||||||
<p className="mt-4 text-xs text-theme-muted">
|
<p className="mt-4 text-xs text-theme-muted">
|
||||||
Al enviar aceptas que tus datos y tu firma se guarden en el{' '}
|
{t('public.form.legalRecord', { record: term('record_singular', 'Expediente').toLowerCase() })}
|
||||||
{term('record_singular', 'Expediente').toLowerCase()} de la clínica.
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</main>
|
</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"
|
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 ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
|
||||||
{enviando ? 'Enviando…' : 'Enviar formulario'}
|
{enviando ? t('public.form.sending') : t('public.form.submit')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import { Button, Input } from '../components/ui';
|
|||||||
import { odooApi } from '../services/odoo';
|
import { odooApi } from '../services/odoo';
|
||||||
import { useAuth } from '../lib/auth';
|
import { useAuth } from '../lib/auth';
|
||||||
import { useTheme } from '../lib/theme';
|
import { useTheme } from '../lib/theme';
|
||||||
|
import { useI18n } from '../lib/i18n';
|
||||||
import { useBookingConfig } from '../lib/publicBooking';
|
import { useBookingConfig } from '../lib/publicBooking';
|
||||||
|
|
||||||
const Login: FC = () => {
|
const Login: FC = () => {
|
||||||
const { login, brand, setBrand } = useAuth();
|
const { login, brand, setBrand } = useAuth();
|
||||||
|
const { t } = useI18n();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
// Enlace público de reservas: solo se muestra si el portal está activo
|
// Enlace público de reservas: solo se muestra si el portal está activo
|
||||||
@@ -38,7 +40,7 @@ const Login: FC = () => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
if (!username.trim() || !password) {
|
if (!username.trim() || !password) {
|
||||||
setError('Escribe usuario y contraseña');
|
setError(t('login.errRequired'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -49,9 +51,9 @@ const Login: FC = () => {
|
|||||||
// Mensajes amigables: nunca mostrar errores técnicos crudos.
|
// Mensajes amigables: nunca mostrar errores técnicos crudos.
|
||||||
const msg = err instanceof Error ? err.message : '';
|
const msg = err instanceof Error ? err.message : '';
|
||||||
if (msg.includes('Credenciales inválidas') || msg.includes('401')) {
|
if (msg.includes('Credenciales inválidas') || msg.includes('401')) {
|
||||||
setError('Usuario o contraseña incorrectos');
|
setError(t('login.errInvalid'));
|
||||||
} else {
|
} else {
|
||||||
setError('No se pudo iniciar sesión. Intenta de nuevo.');
|
setError(t('login.errGeneric'));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
@@ -84,8 +86,8 @@ const Login: FC = () => {
|
|||||||
alt={brand.nombre}
|
alt={brand.nombre}
|
||||||
className="h-10 w-auto object-contain mx-auto mb-6"
|
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>
|
<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)' }}>Inicia sesión para continuar</p>
|
<p className="text-sm" style={{ color: isHomeNest ? 'var(--text-muted)' : 'rgba(255,255,255,0.6)' }}>{t('login.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
@@ -98,7 +100,7 @@ const Login: FC = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
label="Usuario"
|
label={t('login.username')}
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
@@ -111,7 +113,7 @@ const Login: FC = () => {
|
|||||||
/>
|
/>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
label="Contraseña"
|
label={t('login.password')}
|
||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
@@ -125,8 +127,8 @@ const Login: FC = () => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPassword((v) => !v)}
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
aria-label={showPassword ? 'Ocultar contraseña' : 'Mostrar contraseña'}
|
aria-label={showPassword ? t('login.hidePassword') : t('login.showPassword')}
|
||||||
title={showPassword ? 'Ocultar contraseña' : 'Mostrar contraseña'}
|
title={showPassword ? t('login.hidePassword') : t('login.showPassword')}
|
||||||
className="absolute right-3 bottom-2.5 p-1 hover:opacity-80"
|
className="absolute right-3 bottom-2.5 p-1 hover:opacity-80"
|
||||||
style={{ color: isHomeNest ? 'var(--text-muted)' : 'rgba(255,255,255,0.5)' }}
|
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>}
|
{error && <p className="text-sm" style={{ color: 'var(--color-coral)' }}>{error}</p>}
|
||||||
<Button type="submit" className="w-full" loading={submitting}>
|
<Button type="submit" className="w-full" loading={submitting}>
|
||||||
<LogIn size={16} className="mr-2" />
|
<LogIn size={16} className="mr-2" />
|
||||||
Entrar
|
{t('login.submit')}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -147,7 +149,7 @@ const Login: FC = () => {
|
|||||||
className="inline-flex items-center gap-1 underline underline-offset-2 hover:opacity-80"
|
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)' }}
|
style={{ color: isHomeNest ? 'var(--text-muted)' : 'rgba(255,255,255,0.6)' }}
|
||||||
>
|
>
|
||||||
Reservar en línea
|
{t('login.bookOnline')}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 { 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 { exportToExcel } from '../lib/exporter';
|
||||||
import { useAuth } from '../lib/auth';
|
import { useAuth } from '../lib/auth';
|
||||||
import { useGlosario } from '../lib/glosario';
|
import { useI18n, useBusinessTerm } from '../lib/i18n';
|
||||||
import { useSucursales } from '../lib/sucursales';
|
import { useSucursales } from '../lib/sucursales';
|
||||||
|
|
||||||
const fmtMoney = (n: number) =>
|
const fmtMoney = (n: number) =>
|
||||||
@@ -126,7 +126,8 @@ const CRITICAL_ALERTS: { key: keyof Patient; label: string }[] = [
|
|||||||
|
|
||||||
const Pacientes: FC = () => {
|
const Pacientes: FC = () => {
|
||||||
const { brand, can } = useAuth();
|
const { brand, can } = useAuth();
|
||||||
const { term } = useGlosario();
|
const term = useBusinessTerm();
|
||||||
|
const { t } = useI18n();
|
||||||
const { labelFor: branchLabel } = useSucursales();
|
const { labelFor: branchLabel } = useSucursales();
|
||||||
const [patients, setPatients] = useState<Patient[]>([]);
|
const [patients, setPatients] = useState<Patient[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -432,14 +433,14 @@ const Pacientes: FC = () => {
|
|||||||
const TableHeader = () => (
|
const TableHeader = () => (
|
||||||
<thead className="bg-theme-bg">
|
<thead className="bg-theme-bg">
|
||||||
<tr>
|
<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">{t('patients.colName')}</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">{t('patients.colRecord')}</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 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">Edad</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">Última visita</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">{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">{t('patients.colPoints')}</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.colActions')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
);
|
);
|
||||||
@@ -528,16 +529,16 @@ const Pacientes: FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout title={term('patient_plural')} subtitle="Directorio de pacientes">
|
<Layout title={term('patient_plural')} subtitle={t('patients.subtitle')}>
|
||||||
<PageHeader title={term('patient_plural')} subtitle="Busca, crea y gestiona pacientes">
|
<PageHeader title={term('patient_plural')} subtitle={t('patients.pageSubtitle')}>
|
||||||
<Button variant="outline" onClick={exportPatients} loading={exporting} disabled={patients.length === 0 || !can('pacientes.exportar')}
|
<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" />
|
<Download size={16} className="mr-2" />
|
||||||
Exportar CSV
|
{t('patients.exportCsv')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={openCreate}>
|
<Button onClick={openCreate}>
|
||||||
<Plus size={16} className="mr-2" />
|
<Plus size={16} className="mr-2" />
|
||||||
Nuevo {term('patient_singular').toLowerCase()}
|
{t('patients.newWord')} {term('patient_singular').toLowerCase()}
|
||||||
</Button>
|
</Button>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
@@ -547,7 +548,7 @@ const Pacientes: FC = () => {
|
|||||||
<div className="relative w-full sm:max-w-sm">
|
<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" />
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted pointer-events-none" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Buscar por nombre, apellidos o teléfono..."
|
placeholder={t('patients.searchPlaceholder')}
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') setSearch(searchInput.trim()); if (e.key === 'Escape') { setSearchInput(''); setSearch(''); } }}
|
onKeyDown={(e) => { if (e.key === 'Enter') setSearch(searchInput.trim()); if (e.key === 'Escape') { setSearchInput(''); setSearch(''); } }}
|
||||||
@@ -558,8 +559,8 @@ const Pacientes: FC = () => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setSearchInput(''); setSearch(''); }}
|
onClick={() => { setSearchInput(''); setSearch(''); }}
|
||||||
title="Limpiar búsqueda"
|
title={t('patients.clearSearch')}
|
||||||
aria-label="Limpiar búsqueda"
|
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"
|
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} />
|
<X size={14} />
|
||||||
@@ -568,7 +569,7 @@ const Pacientes: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<Select
|
<Select
|
||||||
options={[
|
options={[
|
||||||
{ value: '', label: 'Todos los médicos' },
|
{ value: '', label: t('patients.allDoctors') },
|
||||||
...doctors.map((d) => ({
|
...doctors.map((d) => ({
|
||||||
value: String(d.id),
|
value: String(d.id),
|
||||||
label: d.patient_count !== undefined ? `${d.name} (${d.patient_count})` : d.name,
|
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">
|
<div className="flex flex-wrap items-center gap-2 mb-4 sm:mb-5">
|
||||||
{([
|
{([
|
||||||
{ key: 'vip', label: 'VIP', active: onlyVip, set: setOnlyVip },
|
{ 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: 'recent', label: t('patients.filterRecent'), 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: 'stale', label: t('patients.filterStale'), active: onlyStale, set: (v: boolean) => { setOnlyStale(v); if (v) setOnlyRecent(false); } },
|
||||||
{ key: 'incomplete', label: 'Expediente incompleto', active: onlyIncomplete, set: setOnlyIncomplete },
|
{ key: 'incomplete', label: t('patients.filterIncomplete'), active: onlyIncomplete, set: setOnlyIncomplete },
|
||||||
] as const).map((chip) => (
|
] as const).map((chip) => (
|
||||||
<button
|
<button
|
||||||
key={chip.key}
|
key={chip.key}
|
||||||
@@ -607,7 +608,7 @@ const Pacientes: FC = () => {
|
|||||||
onClick={() => { setOnlyVip(false); setOnlyRecent(false); setOnlyStale(false); setOnlyIncomplete(false); setDoctorFilter(''); }}
|
onClick={() => { setOnlyVip(false); setOnlyRecent(false); setOnlyStale(false); setOnlyIncomplete(false); setDoctorFilter(''); }}
|
||||||
className="text-xs text-theme-muted underline underline-offset-2 hover:text-theme-heading"
|
className="text-xs text-theme-muted underline underline-offset-2 hover:text-theme-heading"
|
||||||
>
|
>
|
||||||
Quitar filtros
|
{t('patients.clearFilters')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -618,9 +619,9 @@ const Pacientes: FC = () => {
|
|||||||
<Skeleton count={6} className="h-12 w-full" />
|
<Skeleton count={6} className="h-12 w-full" />
|
||||||
) : patients.length === 0 ? (
|
) : patients.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="Sin pacientes"
|
title={t('patients.emptyTitle')}
|
||||||
subtitle="No se encontraron pacientes."
|
subtitle={t('patients.emptySub')}
|
||||||
actionLabel={`Nuevo ${term('patient_singular').toLowerCase()}`}
|
actionLabel={`${t('patients.newWord')} ${term('patient_singular').toLowerCase()}`}
|
||||||
onAction={openCreate}
|
onAction={openCreate}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -639,7 +640,7 @@ const Pacientes: FC = () => {
|
|||||||
<td className="p-3 text-sm">
|
<td className="p-3 text-sm">
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${completionBadgeClasses(p.expediente_completion)}`}
|
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}%
|
{p.expediente_completion ?? 0}%
|
||||||
</span>
|
</span>
|
||||||
@@ -1003,11 +1004,11 @@ const Pacientes: FC = () => {
|
|||||||
|
|
||||||
<div className="flex gap-2 border-b border-theme-border pb-2 overflow-x-auto">
|
<div className="flex gap-2 border-b border-theme-border pb-2 overflow-x-auto">
|
||||||
{([
|
{([
|
||||||
{ key: 'info', label: 'Información general' },
|
{ key: 'info', label: t('patients.tabInfo') },
|
||||||
{ key: 'clinical', label: 'Historia clínica' },
|
{ key: 'clinical', label: t('patients.tabClinical') },
|
||||||
{ key: 'timeline', label: 'Historial' },
|
{ key: 'timeline', label: t('patients.tabTimeline') },
|
||||||
{ key: 'docs', label: 'Documentos' },
|
{ key: 'docs', label: t('patients.tabDocs') },
|
||||||
{ key: 'formularios', label: 'Formularios' },
|
{ key: 'formularios', label: t('patients.tabForms') },
|
||||||
] as const).map(({ key, label }) => (
|
] as const).map(({ key, label }) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
@@ -1106,7 +1107,7 @@ const Pacientes: FC = () => {
|
|||||||
<InfoRow label="Contacto de emergencia" value={selected?.emergency_contact} icon={AlertCircle} />
|
<InfoRow label="Contacto de emergencia" value={selected?.emergency_contact} icon={AlertCircle} />
|
||||||
<InfoRow label="Tel. emergencia" value={selected?.emergency_phone} icon={Phone} />
|
<InfoRow label="Tel. emergencia" value={selected?.emergency_phone} icon={Phone} />
|
||||||
<InfoRow label="Recomendado por" value={selected?.referred_by} icon={Users} />
|
<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} />
|
<InfoRow label="Puntos" value={`${selected?.wallet_points ?? 0} pts`} icon={Heart} />
|
||||||
</div>
|
</div>
|
||||||
{selected?.address_notes && (
|
{selected?.address_notes && (
|
||||||
@@ -1191,7 +1192,7 @@ const Pacientes: FC = () => {
|
|||||||
<>
|
<>
|
||||||
<section>
|
<section>
|
||||||
<h4 className="text-sm font-semibold text-theme-heading mb-3 flex items-center">
|
<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>
|
</h4>
|
||||||
<PatientAdjuntos patientId={selected.id} kind="expediente" />
|
<PatientAdjuntos patientId={selected.id} kind="expediente" />
|
||||||
</section>
|
</section>
|
||||||
@@ -1348,10 +1349,10 @@ const Pacientes: FC = () => {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-theme-bg">
|
<thead className="bg-theme-bg">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="text-left p-2.5 text-xs font-medium text-theme-muted">Servicio</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">Cant.</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">Precio</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">Subtotal</th>
|
<th className="text-right p-2.5 text-xs font-medium text-theme-muted">{t('common.subtotal')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-theme-border">
|
<tbody className="divide-y divide-theme-border">
|
||||||
|
|||||||
@@ -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 { 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 type { PatientPackage } from '../services/odoo';
|
||||||
import { useAuth } from '../lib/auth';
|
import { useAuth } from '../lib/auth';
|
||||||
|
import { useI18n } from '../lib/i18n';
|
||||||
import { useClinicSettings } from '../lib/clinic-settings';
|
import { useClinicSettings } from '../lib/clinic-settings';
|
||||||
|
|
||||||
const fmtMoney = (n: number) =>
|
const fmtMoney = (n: number) =>
|
||||||
@@ -29,16 +30,6 @@ const fmtMoney = (n: number) =>
|
|||||||
const fmtUsd = (n: number) =>
|
const fmtUsd = (n: number) =>
|
||||||
`$${(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} USD`;
|
`$${(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 {
|
interface TicketLine {
|
||||||
key: string;
|
key: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -57,6 +48,16 @@ interface TicketLine {
|
|||||||
const Pos: FC = () => {
|
const Pos: FC = () => {
|
||||||
const { openPatient } = usePatientDrawer();
|
const { openPatient } = usePatientDrawer();
|
||||||
const { can } = useAuth();
|
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 impuestos = useClinicSettings().impuestos;
|
||||||
const membresiasCfg = useClinicSettings().membresias;
|
const membresiasCfg = useClinicSettings().membresias;
|
||||||
const puedeImpuestos = can('ventas.impuestos');
|
const puedeImpuestos = can('ventas.impuestos');
|
||||||
@@ -397,7 +398,7 @@ const Pos: FC = () => {
|
|||||||
|
|
||||||
const limpiarTicket = () => {
|
const limpiarTicket = () => {
|
||||||
if (lines.length === 0) return;
|
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
|
// Desglose del ticket. Con los impuestos apagados: bruto === base, impuesto 0
|
||||||
@@ -802,10 +803,10 @@ const Pos: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : altaRapida ? (
|
) : altaRapida ? (
|
||||||
<div className="p-2.5 bg-theme-bg rounded-xl space-y-2">
|
<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={t('pos.nameRequired')} 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.phoneRequired')} value={altaForm.phone} onChange={(e) => setAltaForm({ ...altaForm, phone: e.target.value })} />
|
||||||
<div className="flex gap-2">
|
<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>
|
<Button size="sm" onClick={crearPacienteRapido}>Crear paciente</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -813,7 +814,7 @@ const Pos: FC = () => {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Buscar paciente por nombre o teléfono..."
|
placeholder={t('pos.searchPatient')}
|
||||||
value={pacienteQuery}
|
value={pacienteQuery}
|
||||||
onChange={(e) => setPacienteQuery(e.target.value)}
|
onChange={(e) => setPacienteQuery(e.target.value)}
|
||||||
className="pl-10"
|
className="pl-10"
|
||||||
@@ -934,10 +935,10 @@ const Pos: FC = () => {
|
|||||||
const totalsBlock = (
|
const totalsBlock = (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between text-sm">
|
<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">
|
<span className="text-theme-heading">
|
||||||
{fmtMoney(subtotal)}
|
{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)!)}` : '—'}
|
{toUsd(subtotal) !== null ? `≈ ${fmtUsd(toUsd(subtotal)!)}` : '—'}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -945,7 +946,7 @@ const Pos: FC = () => {
|
|||||||
{impuestos.activo && (
|
{impuestos.activo && (
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-theme-muted">
|
<span className="text-theme-muted">
|
||||||
{impuestos.nombre}{impuestos.precios_incluyen_impuesto ? ' (incluido)' : ''}
|
{impuestos.nombre}{impuestos.precios_incluyen_impuesto ? t('pos.taxIncluded') : ''}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-theme-heading">
|
<span className="text-theme-heading">
|
||||||
{fmtMoney(impuesto)}
|
{fmtMoney(impuesto)}
|
||||||
@@ -956,7 +957,7 @@ const Pos: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center justify-between text-sm gap-2">
|
<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">
|
<span className="flex items-center gap-1.5">
|
||||||
{discountNum > 0 && toUsd(discountNum) !== null && (
|
{discountNum > 0 && toUsd(discountNum) !== null && (
|
||||||
<span className="text-xs text-theme-muted">≈ {fmtUsd(toUsd(discountNum)!)}</span>
|
<span className="text-xs text-theme-muted">≈ {fmtUsd(toUsd(discountNum)!)}</span>
|
||||||
@@ -969,18 +970,18 @@ const Pos: FC = () => {
|
|||||||
onChange={(e) => setDiscount(e.target.value)}
|
onChange={(e) => setDiscount(e.target.value)}
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
disabled={!can('ventas.descuentos')}
|
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"
|
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<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-right">
|
||||||
<span className="text-xl font-heading font-bold text-theme-heading">{fmtMoney(total)}</span>
|
<span className="text-xl font-heading font-bold text-theme-heading">{fmtMoney(total)}</span>
|
||||||
<span
|
<span
|
||||||
className="block text-xs text-theme-muted"
|
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)!)}` : '—'}
|
{toUsd(total) !== null ? `≈ ${fmtUsd(toUsd(total)!)}` : '—'}
|
||||||
</span>
|
</span>
|
||||||
@@ -993,27 +994,27 @@ const Pos: FC = () => {
|
|||||||
className="w-full mt-1 !py-3 text-base"
|
className="w-full mt-1 !py-3 text-base"
|
||||||
onClick={abrirCobro}
|
onClick={abrirCobro}
|
||||||
disabled={lines.length === 0 || !paciente || !can('ventas.vender')}
|
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" />
|
<ShoppingBag size={18} className="mr-2" />
|
||||||
Cobrar {fmtMoney(total)}
|
{t('pos.chargeAmount', { amount: fmtMoney(total) })}
|
||||||
</Button>
|
</Button>
|
||||||
<p className="flex items-center justify-center gap-1.5 text-[11px] text-theme-muted">
|
<p className="flex items-center justify-center gap-1.5 text-[11px] text-theme-muted">
|
||||||
<Keyboard size={11} aria-hidden="true" />
|
<Keyboard size={11} aria-hidden="true" />
|
||||||
F2 buscar · F9 cobrar · +/− cantidad · Esc cerrar
|
{t('pos.shortcutsHint')}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShortcutsOpen(true)}
|
onClick={() => setShortcutsOpen(true)}
|
||||||
className="underline hover:text-theme-heading"
|
className="underline hover:text-theme-heading"
|
||||||
>
|
>
|
||||||
Ver atajos
|
{t('pos.viewShortcuts')}
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
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 ? (
|
{!cajaAbierta ? (
|
||||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
<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">
|
<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}>
|
<div className="relative mb-3" ref={searchWrapRef}>
|
||||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
|
||||||
<Input
|
<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}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
className="pl-10"
|
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-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 font-semibold text-theme-heading">{fmtMoney(sv.price)}</td>
|
||||||
<td className="px-3 py-2 text-right">
|
<td className="px-3 py-2 text-right">
|
||||||
<Button size="sm" onClick={() => addLine(sv)} title="Agregar al ticket">
|
<Button size="sm" onClick={() => addLine(sv)} title={t('pos.addToTicket')}>
|
||||||
<Plus size={13} className="mr-1" /> Agregar
|
<Plus size={13} className="mr-1" /> {t('pos.add')}
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>}
|
{(it.price ?? 0) > 0 ? fmtMoney(it.price!) : <span className="text-xs font-normal text-theme-muted">sin precio</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right">
|
<td className="px-3 py-2 text-right">
|
||||||
<Button size="sm" onClick={() => addItemLine(it)} title="Agregar al ticket">
|
<Button size="sm" onClick={() => addItemLine(it)} title={t('pos.addToTicket')}>
|
||||||
<Plus size={13} className="mr-1" /> Agregar
|
<Plus size={13} className="mr-1" /> {t('pos.add')}
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1346,7 +1347,7 @@ const Pos: FC = () => {
|
|||||||
<div className="flex items-center justify-between mb-2.5">
|
<div className="flex items-center justify-between mb-2.5">
|
||||||
<h3 className="font-heading text-lg text-theme-heading">Ticket</h3>
|
<h3 className="font-heading text-lg text-theme-heading">Ticket</h3>
|
||||||
{lines.length > 0 && (
|
{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" />
|
<Trash2 size={15} className="text-theme-muted" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -1419,7 +1420,7 @@ const Pos: FC = () => {
|
|||||||
maxWidth="sm"
|
maxWidth="sm"
|
||||||
footer={
|
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>
|
<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>.
|
se hace en <Link to="/cortes" className="underline font-medium text-theme-heading">Cortes de caja</Link>.
|
||||||
</p>
|
</p>
|
||||||
<Input
|
<Input
|
||||||
label="Efectivo inicial (fondo de caja)"
|
label={t('pos.initialCash')}
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
step={0.01}
|
step={0.01}
|
||||||
@@ -1444,11 +1445,11 @@ const Pos: FC = () => {
|
|||||||
<Modal
|
<Modal
|
||||||
isOpen={cobroOpen}
|
isOpen={cobroOpen}
|
||||||
onClose={() => setCobroOpen(false)}
|
onClose={() => setCobroOpen(false)}
|
||||||
title={`Cobrar ${fmtMoney(total)}`}
|
title={t('pos.chargeAmount', { amount: fmtMoney(total) })}
|
||||||
maxWidth="md"
|
maxWidth="md"
|
||||||
footer={
|
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>
|
<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.
|
Se asigna la venta completa a este doctor para sus comisiones.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 && (
|
{metodo === 'cash' && !conPuntos && (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
@@ -1492,7 +1493,7 @@ const Pos: FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
label={moneda === 'USD' ? 'Recibido (USD)' : 'Recibido'}
|
label={moneda === 'USD' ? t('pos.receivedUsd') : t('pos.received')}
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
step={10}
|
step={10}
|
||||||
@@ -1505,7 +1506,7 @@ const Pos: FC = () => {
|
|||||||
)}
|
)}
|
||||||
{recibido && (
|
{recibido && (
|
||||||
<p className="text-sm">
|
<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'}`}>
|
<span className={`font-semibold ${cambio > 0 ? 'text-theme-heading' : 'text-theme-muted'}`}>
|
||||||
{fmtMoney(cambio)}
|
{fmtMoney(cambio)}
|
||||||
{moneda === 'USD' && cambio > 0 && (
|
{moneda === 'USD' && cambio > 0 && (
|
||||||
@@ -1558,7 +1559,7 @@ const Pos: FC = () => {
|
|||||||
tax: resultado.sale.tax_total ?? resultado.impuesto ?? 0,
|
tax: resultado.sale.tax_total ?? resultado.impuesto ?? 0,
|
||||||
tax_name: impuestos.nombre,
|
tax_name: impuestos.nombre,
|
||||||
total: resultado.sale.total,
|
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_usados: resultado.puntos_usados,
|
||||||
puntos_ganados: resultado.puntos_ganados,
|
puntos_ganados: resultado.puntos_ganados,
|
||||||
wallet_points: resultado.wallet_points,
|
wallet_points: resultado.wallet_points,
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import {
|
|||||||
useReportFavorites,
|
useReportFavorites,
|
||||||
} from '../lib/reportRange';
|
} from '../lib/reportRange';
|
||||||
import { useClinicSettings } from '../lib/clinic-settings';
|
import { useClinicSettings } from '../lib/clinic-settings';
|
||||||
|
import { useI18n } from '../lib/i18n';
|
||||||
|
|
||||||
const COLORS = ['#57534e', '#a8a29e', '#d6d3d1', '#78716c', '#f59e0b', '#10b981'];
|
const COLORS = ['#57534e', '#a8a29e', '#d6d3d1', '#78716c', '#f59e0b', '#10b981'];
|
||||||
|
|
||||||
@@ -155,6 +156,7 @@ const ReportesContent: FC = () => {
|
|||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { hasRole, canSee, can, brand } = useAuth();
|
const { hasRole, canSee, can, brand } = useAuth();
|
||||||
const { membresias: membresiasCfg, recursos: recursosCfg, compras: comprasCfg } = useClinicSettings();
|
const { membresias: membresiasCfg, recursos: recursosCfg, compras: comprasCfg } = useClinicSettings();
|
||||||
|
const { t } = useI18n();
|
||||||
const esAdmin = hasRole('admin');
|
const esAdmin = hasRole('admin');
|
||||||
// PuedeVer una pestana = canSee('/reportes?tab=x'): la matriz guarda claves
|
// PuedeVer una pestana = canSee('/reportes?tab=x'): la matriz guarda claves
|
||||||
// por pestana y, cuando no existe la del tab, canSee cae al prefijo
|
// por pestana y, cuando no existe la del tab, canSee cae al prefijo
|
||||||
@@ -303,22 +305,22 @@ const ReportesContent: FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout title="Reportes" subtitle="Análisis del mes y día">
|
<Layout title={t('reports.title')} subtitle={t('reports.subtitle')}>
|
||||||
<PageHeader title="Reportes" subtitle={tabInfo?.desc || 'Análisis de la operación'}>
|
<PageHeader title={t('reports.title')} subtitle={tabInfo ? t(`reports.tab.${tabInfo.key}.desc`) : t('reports.opSubtitle')}>
|
||||||
{tab === 'general' && can('reportes.exportar') && (
|
{tab === 'general' && can('reportes.exportar') && (
|
||||||
<Button variant="outline" onClick={exportExcel} disabled={loading || !salesReport} loading={exporting}>
|
<Button variant="outline" onClick={exportExcel} disabled={loading || !salesReport} loading={exporting}>
|
||||||
<Download size={16} className="mr-2" />
|
<Download size={16} className="mr-2" />
|
||||||
Exportar
|
{t('reports.export')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{puedeTab('exportar') && can('reportes.exportar') && (
|
{puedeTab('exportar') && can('reportes.exportar') && (
|
||||||
<Button
|
<Button
|
||||||
variant={tab === 'exportar' ? 'primary' : 'outline'}
|
variant={tab === 'exportar' ? 'primary' : 'outline'}
|
||||||
onClick={() => setTab('exportar')}
|
onClick={() => setTab('exportar')}
|
||||||
title="Exportar listas de pacientes (correos, VIP, etiquetas)"
|
title={t('reports.exportDataTitle')}
|
||||||
>
|
>
|
||||||
<Share2 size={16} className="mr-2" />
|
<Share2 size={16} className="mr-2" />
|
||||||
Exportar datos
|
{t('reports.exportData')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
@@ -327,7 +329,7 @@ const ReportesContent: FC = () => {
|
|||||||
<Card className="mb-4">
|
<Card className="mb-4">
|
||||||
<Card.Body className="flex flex-col gap-3">
|
<Card.Body className="flex flex-col gap-3">
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<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) => (
|
{REPORT_PRESETS.map((p) => (
|
||||||
<button
|
<button
|
||||||
key={p.value}
|
key={p.value}
|
||||||
@@ -339,20 +341,20 @@ const ReportesContent: FC = () => {
|
|||||||
: 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong'
|
: 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{p.label}
|
{t(`reports.preset.${p.value}`)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-end gap-3">
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
<Input
|
<Input
|
||||||
label="Inicio"
|
label={t('reports.start')}
|
||||||
type="date"
|
type="date"
|
||||||
value={start}
|
value={start}
|
||||||
onChange={(e) => setRange('custom', e.target.value, end)}
|
onChange={(e) => setRange('custom', e.target.value, end)}
|
||||||
className="sm:max-w-[170px]"
|
className="sm:max-w-[170px]"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Fin"
|
label={t('reports.end')}
|
||||||
type="date"
|
type="date"
|
||||||
value={end}
|
value={end}
|
||||||
onChange={(e) => setRange('custom', start, e.target.value)}
|
onChange={(e) => setRange('custom', start, e.target.value)}
|
||||||
@@ -412,7 +414,7 @@ const ReportesContent: FC = () => {
|
|||||||
key={f.key}
|
key={f.key}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTab(f.tabs[0].key)}
|
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 ${
|
className={`inline-flex items-center gap-1.5 px-4 py-2 text-sm font-semibold rounded-xl transition ${
|
||||||
activa
|
activa
|
||||||
? 'bg-theme-accent text-theme-inverse shadow-card'
|
? 'bg-theme-accent text-theme-inverse shadow-card'
|
||||||
@@ -420,7 +422,7 @@ const ReportesContent: FC = () => {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Icono size={15} />
|
<Icono size={15} />
|
||||||
{f.label}
|
{t(`reports.family.${f.key}`)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -429,30 +431,30 @@ const ReportesContent: FC = () => {
|
|||||||
{/* Pestañas de la familia activa */}
|
{/* Pestañas de la familia activa */}
|
||||||
<div className="mb-6 pb-3 border-b border-theme-border">
|
<div className="mb-6 pb-3 border-b border-theme-border">
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{tabsFamilia.map((t) => {
|
{tabsFamilia.map((tabItem) => {
|
||||||
const activo = tab === t.key;
|
const activo = tab === tabItem.key;
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={t.key}
|
key={tabItem.key}
|
||||||
className={`inline-flex items-center rounded-full border overflow-hidden text-sm font-medium transition ${
|
className={`inline-flex items-center rounded-full border overflow-hidden text-sm font-medium transition ${
|
||||||
activo
|
activo
|
||||||
? 'bg-theme-accent text-theme-inverse border-theme-accent'
|
? 'bg-theme-accent text-theme-inverse border-theme-accent'
|
||||||
: 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong'
|
: '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">
|
<button type="button" onClick={() => setTab(tabItem.key)} title={t(`reports.tab.${tabItem.key}.desc`)} className="px-4 py-2">
|
||||||
{t.label}
|
{t(`reports.tab.${tabItem.key}`)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggle(t.key)}
|
onClick={() => toggle(tabItem.key)}
|
||||||
title={isFav(t.key) ? 'Quitar de favoritos' : 'Fijar en favoritos'}
|
title={isFav(tabItem.key) ? t('reports.favRemove') : t('reports.favAdd')}
|
||||||
aria-label={isFav(t.key) ? 'Quitar de favoritos' : 'Fijar en favoritos'}
|
aria-label={isFav(tabItem.key) ? t('reports.favRemove') : t('reports.favAdd')}
|
||||||
className={`px-2.5 py-2 border-l ${
|
className={`px-2.5 py-2 border-l ${
|
||||||
activo ? 'border-theme-inverse/30' : 'border-theme-border'
|
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>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -510,7 +512,7 @@ const ReportesContent: FC = () => {
|
|||||||
<Card.Body>
|
<Card.Body>
|
||||||
<div className="flex flex-col sm:flex-row sm:items-end gap-3 sm:gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-end gap-3 sm:gap-4">
|
||||||
<Input
|
<Input
|
||||||
label="Día efectivo"
|
label={t('reports.effectiveDay')}
|
||||||
type="date"
|
type="date"
|
||||||
value={today}
|
value={today}
|
||||||
onChange={(e) => setToday(e.target.value)}
|
onChange={(e) => setToday(e.target.value)}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
useBookingConfig, proximosDias, fechaISO, etiquetaDiaCorto, fechaLargaISO, construirIcs,
|
useBookingConfig, proximosDias, fechaISO, etiquetaDiaCorto, fechaLargaISO, construirIcs,
|
||||||
descargarIcs, normalizarTelefono, telefonoValido,
|
descargarIcs, normalizarTelefono, telefonoValido,
|
||||||
} from '../lib/publicBooking';
|
} 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
|
// 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.
|
// 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 Reservar: FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { config, loading } = useBookingConfig();
|
const { config, loading } = useBookingConfig();
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
|
||||||
const [paso, setPaso] = useState(0);
|
const [paso, setPaso] = useState(0);
|
||||||
const [servicioId, setServicioId] = useState<number | null>(null);
|
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); }
|
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));
|
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;
|
return out;
|
||||||
}, [config]);
|
}, [config, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!config || pasoActual !== 'fecha' || !servicioId) return;
|
if (!config || pasoActual !== 'fecha' || !servicioId) return;
|
||||||
@@ -81,8 +83,8 @@ const Reservar: FC = () => {
|
|||||||
|
|
||||||
const enviar = async () => {
|
const enviar = async () => {
|
||||||
if (!servicioId || !hora) return;
|
if (!servicioId || !hora) return;
|
||||||
if (nombre.trim().length < 2) { setError('Escribe tu nombre'); return; }
|
if (nombre.trim().length < 2) { setError(t('public.errName')); return; }
|
||||||
if (!telefonoValido(telefono)) { setError('Escribe un teléfono válido (10 dígitos)'); return; }
|
if (!telefonoValido(telefono)) { setError(t('public.errPhone')); return; }
|
||||||
setEnviando(true);
|
setEnviando(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -102,13 +104,16 @@ const Reservar: FC = () => {
|
|||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
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 {
|
} finally {
|
||||||
setEnviando(false);
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -123,9 +128,9 @@ const Reservar: FC = () => {
|
|||||||
<Centro>
|
<Centro>
|
||||||
<div className="w-full max-w-sm rounded-3xl border border-theme-border bg-theme-surface p-8 text-center shadow-soft">
|
<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" />
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Centro>
|
</Centro>
|
||||||
@@ -157,9 +162,9 @@ const Reservar: FC = () => {
|
|||||||
<Check className="h-6 w-6 text-emerald-600" />
|
<Check className="h-6 w-6 text-emerald-600" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="font-heading text-xl text-theme-heading">
|
<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>
|
</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">
|
<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={<CalendarPlus className="h-4 w-4" />} texto={`${fechaLargaISO(resultado.fecha)} · ${resultado.hora}`} />
|
||||||
<Linea icon={<Check className="h-4 w-4" />} texto={resultado.servicio} />
|
<Linea icon={<Check className="h-4 w-4" />} texto={resultado.servicio} />
|
||||||
@@ -171,21 +176,21 @@ const Reservar: FC = () => {
|
|||||||
)}
|
)}
|
||||||
{resultado.requiere_confirmacion && !resultado.mensaje_confirmacion && (
|
{resultado.requiere_confirmacion && !resultado.mensaje_confirmacion && (
|
||||||
<p className="mt-4 text-sm text-theme-muted">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="mt-6 flex flex-col gap-2">
|
<div className="mt-6 flex flex-col gap-2">
|
||||||
<Button onClick={() => descargarIcs(`cita-${resultado.folio}`, icalUrl)}>
|
<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>
|
||||||
<Button variant="ghost" onClick={() => navigate(`/reservar/cita/${resultado.token_de_gestion}`)}>
|
<Button variant="ghost" onClick={() => navigate(`/reservar/cita/${resultado.token_de_gestion}`)}>
|
||||||
Ver o cancelar mi reserva
|
{t('public.manage')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl border border-theme-border bg-theme-surface p-4">
|
<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">
|
<div className="mt-2 flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
readOnly
|
readOnly
|
||||||
@@ -198,7 +203,7 @@ const Reservar: FC = () => {
|
|||||||
try { await navigator.clipboard.writeText(enlaceGestion); setCopiado(true); } catch { /* ignore */ }
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -209,11 +214,11 @@ const Reservar: FC = () => {
|
|||||||
|
|
||||||
const tituloPaso = (): string => {
|
const tituloPaso = (): string => {
|
||||||
switch (pasoActual) {
|
switch (pasoActual) {
|
||||||
case 'servicio': return `Elige tu ${term('service_singular', 'servicio').toLowerCase()}`;
|
case 'servicio': return t('public.stepService', { term: term('service_singular', 'servicio').toLowerCase() });
|
||||||
case 'sucursal': return `Elige ${term('branch_singular', 'sucursal').toLowerCase()}`;
|
case 'sucursal': return t('public.stepBranch', { term: term('branch_singular', 'sucursal').toLowerCase() });
|
||||||
case 'especialista': return `Elige ${term('doctor_singular', 'especialista').toLowerCase()}`;
|
case 'especialista': return t('public.stepDoctor', { term: term('doctor_singular', 'especialista').toLowerCase() });
|
||||||
case 'fecha': return 'Elige fecha y hora';
|
case 'fecha': return t('public.stepDateTime');
|
||||||
case 'datos': return 'Tus datos';
|
case 'datos': return t('public.stepData');
|
||||||
default: return '';
|
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="rounded-3xl border border-theme-border bg-theme-surface p-5 shadow-soft">
|
||||||
<div className="mb-4 flex items-center gap-2">
|
<div className="mb-4 flex items-center gap-2">
|
||||||
{paso > 0 && (
|
{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">
|
className="flex h-8 w-8 items-center justify-center rounded-full text-theme-muted hover:bg-theme-bg">
|
||||||
<ArrowLeft size={16} />
|
<ArrowLeft size={16} />
|
||||||
</button>
|
</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'
|
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" />
|
<ChevronRight size={18} className="shrink-0 text-theme-muted" />
|
||||||
</button>
|
</button>
|
||||||
{config.medicos.map((m) => (
|
{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>
|
<div className="flex justify-center py-6"><Loader2 className="h-5 w-5 animate-spin text-theme-muted" /></div>
|
||||||
) : slots.length === 0 ? (
|
) : slots.length === 0 ? (
|
||||||
<p className="py-4 text-center text-sm text-theme-muted">
|
<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>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
|
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
|
||||||
@@ -381,7 +386,7 @@ const Reservar: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button className="w-full" disabled={!hora} onClick={siguiente}>Continuar</Button>
|
<Button className="w-full" disabled={!hora} onClick={siguiente}>{t('public.continue')}</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -392,13 +397,13 @@ const Reservar: FC = () => {
|
|||||||
{servicioSel?.nombre} · {fechaLargaISO(fecha)} · {hora}
|
{servicioSel?.nombre} · {fechaLargaISO(fecha)} · {hora}
|
||||||
{medicoSel ? ` · ${medicoSel.nombre}` : ''}
|
{medicoSel ? ` · ${medicoSel.nombre}` : ''}
|
||||||
</div>
|
</div>
|
||||||
<Input label="Nombre completo" value={nombre} onChange={(e) => setNombre(e.target.value)} autoComplete="name" />
|
<Input label={t('public.fullName')} 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={t('public.phoneWhatsapp')} 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" />
|
<Input label={t('public.emailOptional')} 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} />
|
<TextArea label={t('public.notesOptional')} value={notas} onChange={(e) => setNotas(e.target.value)} rows={3} />
|
||||||
{/* Honeypot: invisible para personas; los bots suelen rellenarlo. */}
|
{/* Honeypot: invisible para personas; los bots suelen rellenarlo. */}
|
||||||
<div aria-hidden="true" style={{ position: 'absolute', left: '-9999px', width: 1, height: 1, overflow: 'hidden' }}>
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="empresa"
|
name="empresa"
|
||||||
@@ -411,11 +416,11 @@ const Reservar: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
{error && <p className="text-sm text-rose-600">{error}</p>}
|
{error && <p className="text-sm text-rose-600">{error}</p>}
|
||||||
<Button className="w-full" loading={enviando} onClick={enviar}>
|
<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>
|
</Button>
|
||||||
{config.config.telefono_contacto && (
|
{config.config.telefono_contacto && (
|
||||||
<p className="flex items-center justify-center gap-1 text-center text-xs text-theme-muted">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { AlertTriangle, ArrowLeft, Check, Clock, Loader2, MapPin, User } from 'l
|
|||||||
import { Button, Input } from '../components/ui';
|
import { Button, Input } from '../components/ui';
|
||||||
import { odooApi, type BookingCita } from '../services/odoo';
|
import { odooApi, type BookingCita } from '../services/odoo';
|
||||||
import { fechaLargaISO } from '../lib/publicBooking';
|
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
|
// 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.
|
// 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 ReservarCita: FC = () => {
|
||||||
const { token = '' } = useParams();
|
const { token = '' } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { t } = useI18n();
|
||||||
const [cita, setCita] = useState<BookingCita | null>(null);
|
const [cita, setCita] = useState<BookingCita | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -22,12 +24,12 @@ const ReservarCita: FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
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)
|
odooApi.getBookingCita(token)
|
||||||
.then((res) => { if (alive) setCita(res); })
|
.then((res) => { if (alive) setCita(res); })
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
const msg = (err as { response?: { data?: { message?: string }; status?: number } });
|
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); });
|
.finally(() => { if (alive) setLoading(false); });
|
||||||
return () => { alive = false; };
|
return () => { alive = false; };
|
||||||
@@ -39,10 +41,10 @@ const ReservarCita: FC = () => {
|
|||||||
try {
|
try {
|
||||||
await odooApi.cancelBooking(token, motivo.trim());
|
await odooApi.cancelBooking(token, motivo.trim());
|
||||||
setCancelada(true);
|
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) {
|
} catch (err) {
|
||||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
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 {
|
} finally {
|
||||||
setCancelando(false);
|
setCancelando(false);
|
||||||
}
|
}
|
||||||
@@ -61,9 +63,9 @@ const ReservarCita: FC = () => {
|
|||||||
<Centro>
|
<Centro>
|
||||||
<div className="w-full max-w-sm rounded-3xl border border-theme-border bg-theme-surface p-8 text-center shadow-soft">
|
<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" />
|
<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>
|
<h1 className="font-heading text-lg text-theme-heading">{t('public.manage.notFoundTitle')}</h1>
|
||||||
<p className="mt-2 text-sm text-theme-muted">{error || 'El enlace no es válido o ya expiró.'}</p>
|
<p className="mt-2 text-sm text-theme-muted">{error || t('public.manage.expired')}</p>
|
||||||
<Button className="mt-6 w-full" onClick={() => navigate('/reservar')}>Hacer una reserva</Button>
|
<Button className="mt-6 w-full" onClick={() => navigate('/reservar')}>{t('public.manage.makeBooking')}</Button>
|
||||||
</div>
|
</div>
|
||||||
</Centro>
|
</Centro>
|
||||||
);
|
);
|
||||||
@@ -74,19 +76,19 @@ const ReservarCita: FC = () => {
|
|||||||
<div className="w-full max-w-lg space-y-4">
|
<div className="w-full max-w-lg space-y-4">
|
||||||
<button type="button" onClick={() => navigate('/reservar')}
|
<button type="button" onClick={() => navigate('/reservar')}
|
||||||
className="flex items-center gap-1 text-sm text-theme-muted hover:text-theme-heading">
|
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>
|
</button>
|
||||||
|
|
||||||
<div className="rounded-3xl border border-theme-border bg-theme-surface p-6 shadow-soft">
|
<div className="rounded-3xl border border-theme-border bg-theme-surface p-6 shadow-soft">
|
||||||
<div className="flex items-center justify-between">
|
<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 ${
|
<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 === 'cancelled' ? 'bg-rose-100 text-rose-700' : 'bg-emerald-100 text-emerald-700'
|
||||||
}`}>
|
}`}>
|
||||||
{cita.estado_label}
|
{cita.estado_label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<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}`} />
|
<Linea icon={<Clock className="h-4 w-4" />} texto={`${fechaLargaISO(cita.fecha)} · ${cita.hora}`} />
|
||||||
@@ -97,7 +99,7 @@ const ReservarCita: FC = () => {
|
|||||||
|
|
||||||
{cancelada && (
|
{cancelada && (
|
||||||
<p className="mt-4 rounded-xl bg-emerald-50 p-3 text-sm text-emerald-700">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -105,20 +107,20 @@ const ReservarCita: FC = () => {
|
|||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
{!confirmar ? (
|
{!confirmar ? (
|
||||||
<Button variant="outline" className="w-full" onClick={() => setConfirmar(true)}>
|
<Button variant="outline" className="w-full" onClick={() => setConfirmar(true)}>
|
||||||
Cancelar mi reserva
|
{t('public.manage.cancelMine')}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-sm text-theme-muted">
|
<p className="text-sm text-theme-muted">
|
||||||
¿Seguro que quieres cancelar? Puedes dejarnos un motivo (opcional).
|
{t('public.manage.confirmQuestion')}
|
||||||
</p>
|
</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>}
|
{error && <p className="text-sm text-rose-600">{error}</p>}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="danger" className="flex-1" loading={cancelando} onClick={cancelar}>
|
<Button variant="danger" className="flex-1" loading={cancelando} onClick={cancelar}>
|
||||||
Sí, cancelar
|
{t('public.manage.yesCancel')}
|
||||||
</Button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -127,9 +129,9 @@ const ReservarCita: FC = () => {
|
|||||||
|
|
||||||
{!cancelada && !cita.puede_cancelar && cita.estado !== 'cancelled' && (
|
{!cancelada && !cita.puede_cancelar && cita.estado !== 'cancelled' && (
|
||||||
<p className="mt-5 text-sm text-theme-muted">
|
<p className="mt-5 text-sm text-theme-muted">
|
||||||
Esta reserva ya no se puede cancelar en línea
|
{t('public.manage.cannotCancel')}
|
||||||
{cita.permitir_cancelar ? ` (con menos de ${cita.cancelar_hasta_horas} h de anticipación)` : ''}.
|
{cita.permitir_cancelar ? t('public.manage.leadTime', { h: cita.cancelar_hasta_horas }) : ''}.
|
||||||
{cita.telefono_contacto ? ` Comunícate al ${cita.telefono_contacto}.` : ' Comunícate con la clínica.'}
|
{cita.telefono_contacto ? t('public.manage.contactPhone', { phone: cita.telefono_contacto }) : t('public.manage.contactClinic')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1069,6 +1069,8 @@ export interface ClinicSettings {
|
|||||||
url_publica: string;
|
url_publica: string;
|
||||||
moneda_base: string;
|
moneda_base: string;
|
||||||
nota_ticket: string;
|
nota_ticket: string;
|
||||||
|
/** Idioma por defecto de la clínica ('es' | 'en'). Ver lib/i18n.tsx. */
|
||||||
|
idioma_default: string;
|
||||||
impuestos: ImpuestosSettings;
|
impuestos: ImpuestosSettings;
|
||||||
reservas_online: ReservasOnlineSettings;
|
reservas_online: ReservasOnlineSettings;
|
||||||
notificaciones: NotificacionesSettings;
|
notificaciones: NotificacionesSettings;
|
||||||
@@ -2207,6 +2209,7 @@ export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
|
|||||||
url_publica: '',
|
url_publica: '',
|
||||||
moneda_base: 'MXN',
|
moneda_base: 'MXN',
|
||||||
nota_ticket: '',
|
nota_ticket: '',
|
||||||
|
idioma_default: 'es',
|
||||||
impuestos: IMPUESTOS_DEFAULT,
|
impuestos: IMPUESTOS_DEFAULT,
|
||||||
reservas_online: RESERVAS_ONLINE_DEFAULT,
|
reservas_online: RESERVAS_ONLINE_DEFAULT,
|
||||||
notificaciones: NOTIFICACIONES_DEFAULT,
|
notificaciones: NOTIFICACIONES_DEFAULT,
|
||||||
|
|||||||
Reference in New Issue
Block a user