Initial commit: SKEEN Derma Experts - Sistema Integral de Gestión Clínica
- Frontend React (SKEEN Brand) con Vite, TypeScript, Tailwind - Frontend Homenest (versión alternativa) - Módulos Odoo 17 custom (citas, pacientes, monedero, pagos, ventas, inventario, whatsapp) - WACRM fork (Next.js 16 + Supabase) - Hermes + Bridge + Skills (Qwen3.6 via Nan Builders) - Scripts de migración y operación - Documentación extensiva en docs/
This commit is contained in:
135
frontend/src/lib/auth.tsx
Normal file
135
frontend/src/lib/auth.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { odooApi, type FrontendUser, type FrontendRole } from '../services/odoo';
|
||||
|
||||
const TOKEN_KEY = 'skeen_token';
|
||||
const USER_KEY = 'skeen_user';
|
||||
|
||||
const ROLE_RANK: Record<FrontendRole, number> = {
|
||||
lectura: 10,
|
||||
medico: 50,
|
||||
recepcion: 50,
|
||||
admin: 100,
|
||||
};
|
||||
|
||||
// Visibilidad del menú por ruta. Si una ruta no está listada, se muestra a todos los roles.
|
||||
const MENU_ROLES: Record<string, FrontendRole[]> = {
|
||||
'/': ['admin', 'recepcion', 'medico', 'lectura'],
|
||||
'/agenda': ['admin', 'recepcion', 'medico'],
|
||||
'/pacientes': ['admin', 'recepcion', 'medico', 'lectura'],
|
||||
'/medicos': ['admin', 'recepcion'],
|
||||
'/servicios': ['admin', 'recepcion', 'medico', 'lectura'],
|
||||
'/productos': ['admin', 'recepcion', 'lectura'],
|
||||
'/ventas': ['admin', 'recepcion'],
|
||||
'/pagos': ['admin', 'recepcion'],
|
||||
'/monedero': ['admin', 'recepcion', 'lectura'],
|
||||
'/inventario': ['admin', 'recepcion', 'lectura'],
|
||||
'/cortes': ['admin', 'recepcion'],
|
||||
'/reportes': ['admin', 'recepcion', 'lectura'],
|
||||
'/configuracion': ['admin'],
|
||||
'/usuarios': ['admin'],
|
||||
'/cumpleanos': ['admin', 'recepcion'],
|
||||
'/wacrm/messages': ['admin', 'recepcion'],
|
||||
'/wacrm/leads': ['admin', 'recepcion'],
|
||||
};
|
||||
|
||||
interface AuthContextValue {
|
||||
user: FrontendUser | null;
|
||||
token: string | null;
|
||||
loading: boolean;
|
||||
login: (login: string, password: string) => Promise<FrontendUser>;
|
||||
logout: () => Promise<void>;
|
||||
hasRole: (min: FrontendRole) => boolean;
|
||||
canSee: (href: string) => boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||
|
||||
export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [user, setUser] = useState<FrontendUser | null>(null);
|
||||
const [token, setToken] = useState<string | null>(() => localStorage.getItem(TOKEN_KEY));
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
// Validar token al arrancar
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const t = localStorage.getItem(TOKEN_KEY);
|
||||
if (!t) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
// Hidratar usuario cacheado para evitar parpadeo
|
||||
const cached = localStorage.getItem(USER_KEY);
|
||||
if (cached) {
|
||||
try { setUser(JSON.parse(cached)); } catch { /* ignore */ }
|
||||
}
|
||||
odooApi.me()
|
||||
.then((res) => {
|
||||
if (!alive) return;
|
||||
if (res.status === 'success') {
|
||||
setUser(res.user);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(res.user));
|
||||
} else {
|
||||
clear();
|
||||
}
|
||||
})
|
||||
.catch(() => { if (alive) clear(); })
|
||||
.finally(() => { if (alive) setLoading(false); });
|
||||
return () => { alive = false; };
|
||||
}, [clear]);
|
||||
|
||||
const login = async (login: string, password: string): Promise<FrontendUser> => {
|
||||
const res = await odooApi.login(login, password);
|
||||
if (res.status !== 'success' || !res.token) {
|
||||
throw new Error(res.message || 'Credenciales inválidas');
|
||||
}
|
||||
localStorage.setItem(TOKEN_KEY, res.token);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(res.user));
|
||||
setToken(res.token);
|
||||
setUser(res.user);
|
||||
return res.user;
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
try { await odooApi.logout(); } catch { /* ignore */ }
|
||||
clear();
|
||||
};
|
||||
|
||||
const hasRole = (min: FrontendRole): boolean => {
|
||||
if (!user) return false;
|
||||
return (ROLE_RANK[user.role] ?? 0) >= (ROLE_RANK[min] ?? 0);
|
||||
};
|
||||
|
||||
const canSee = (href: string): boolean => {
|
||||
if (!user) return false;
|
||||
const allowed = MENU_ROLES[href];
|
||||
if (!allowed) return true;
|
||||
return allowed.includes(user.role);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, token, loading, login, logout, hasRole, canSee }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = (): AuthContextValue => {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth debe usarse dentro de AuthProvider');
|
||||
return ctx;
|
||||
};
|
||||
|
||||
export const ROLE_LABELS: Record<FrontendRole, string> = {
|
||||
admin: 'Administrador',
|
||||
recepcion: 'Recepción',
|
||||
medico: 'Médico',
|
||||
lectura: 'Solo lectura',
|
||||
};
|
||||
50
frontend/src/lib/badges.ts
Normal file
50
frontend/src/lib/badges.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { BadgeVariant } from '../components/ui/Badge';
|
||||
|
||||
export const badgeForAppointmentState = (state: string): BadgeVariant => {
|
||||
switch (state) {
|
||||
case 'confirmed':
|
||||
return 'confirmed';
|
||||
case 'done':
|
||||
case 'completed':
|
||||
return 'done';
|
||||
case 'cancelled':
|
||||
return 'cancelled';
|
||||
default:
|
||||
return 'pending';
|
||||
}
|
||||
};
|
||||
|
||||
export const badgeForPaymentState = (state: string): BadgeVariant => {
|
||||
switch (state) {
|
||||
case 'confirmed':
|
||||
case 'posted':
|
||||
case 'paid':
|
||||
return 'paid';
|
||||
default:
|
||||
return 'unpaid';
|
||||
}
|
||||
};
|
||||
|
||||
export const badgeForSaleState = (state: string): BadgeVariant => {
|
||||
switch (state) {
|
||||
case 'sale':
|
||||
case 'done':
|
||||
case 'paid':
|
||||
return 'paid';
|
||||
case 'cancel':
|
||||
return 'cancelled';
|
||||
default:
|
||||
return 'pending';
|
||||
}
|
||||
};
|
||||
|
||||
export const badgeForCashClosingState = (state: string): BadgeVariant => {
|
||||
switch (state) {
|
||||
case 'closed':
|
||||
return 'done';
|
||||
case 'open':
|
||||
return 'pending';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
50
frontend/src/lib/theme.tsx
Normal file
50
frontend/src/lib/theme.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
|
||||
|
||||
type Theme = 'skeen' | 'homenest';
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
||||
|
||||
const STORAGE_KEY = 'skeen-theme';
|
||||
|
||||
function getInitialTheme(): Theme {
|
||||
if (typeof window === 'undefined') return 'skeen';
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY) as Theme | null;
|
||||
if (stored === 'skeen' || stored === 'homenest') return stored;
|
||||
return 'skeen';
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>(getInitialTheme);
|
||||
|
||||
const setTheme = (next: Theme) => {
|
||||
setThemeState(next);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(STORAGE_KEY, next);
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTheme = () => setTheme(theme === 'skeen' ? 'homenest' : 'skeen');
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme, toggleTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
|
||||
return ctx;
|
||||
}
|
||||
1
frontend/src/lib/toast.ts
Normal file
1
frontend/src/lib/toast.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { toast } from 'sonner';
|
||||
27
frontend/src/lib/utils.ts
Normal file
27
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
type CsvCell = string | number | boolean | null | undefined;
|
||||
|
||||
/**
|
||||
* Descarga un CSV (UTF-8 con BOM para que Excel respete acentos).
|
||||
* Cada fila es un arreglo de celdas; se escapan comillas automáticamente.
|
||||
*/
|
||||
export function downloadCsv(filename: string, rows: CsvCell[][]) {
|
||||
const csv = rows
|
||||
.map((row) => row.map((cell) => `"${String(cell ?? '').replace(/"/g, '""')}"`).join(','))
|
||||
.join('\r\n');
|
||||
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
Reference in New Issue
Block a user