Initial commit: SKEEN Derma Experts - Sistema Integral de Gestión Clínica

- Frontend React (SKEEN Brand) con Vite, TypeScript, Tailwind
- Frontend Homenest (versión alternativa)
- Módulos Odoo 17 custom (citas, pacientes, monedero, pagos, ventas, inventario, whatsapp)
- WACRM fork (Next.js 16 + Supabase)
- Hermes + Bridge + Skills (Qwen3.6 via Nan Builders)
- Scripts de migración y operación
- Documentación extensiva en docs/
This commit is contained in:
2026-07-20 07:44:23 +00:00
commit a718592291
699 changed files with 324602 additions and 0 deletions

View File

@@ -0,0 +1,748 @@
import axios from 'axios';
const ODOO_BASE = import.meta.env.VITE_API_BASE_URL || '/api/odoo';
// ============================================================
// Auth: adjuntar token y manejar 401 globalmente
// ============================================================
axios.interceptors.request.use((config) => {
const token = localStorage.getItem('skeen_token');
if (token) {
config.headers = config.headers || {};
(config.headers as Record<string, string>).Authorization = `Bearer ${token}`;
}
return config;
});
axios.interceptors.response.use(
(res) => res,
(error) => {
if (error?.response?.status === 401) {
const onLogin = window.location.pathname === '/login';
const isLoginCall = String(error.config?.url || '').includes('/auth/login');
if (!onLogin && !isLoginCall) {
localStorage.removeItem('skeen_token');
localStorage.removeItem('skeen_user');
window.location.href = '/login';
}
}
return Promise.reject(error);
},
);
// ============================================================
// Tipos
// ============================================================
export interface DashboardStats {
appointments_today: number;
appointments_month: number;
new_patients_month: number;
total_patients: number;
revenue_month: number;
pending_payments: number;
total_wallet_points: number;
sales_today: number;
}
export interface Appointment {
id: number;
reference: string;
patient_id: number;
patient: string;
phone: string;
service_id: number;
service: string;
service_category?: string;
date: string;
time: string;
state: string;
payment_state: string;
price: number;
doctor_id?: number;
doctor?: string;
branch?: string;
medium?: string;
notes?: string;
package_finished?: boolean;
package_finished_date?: string | null;
}
export interface Patient {
id: number;
patient_id: string;
name: string;
phone: string;
email: string;
birth_date: string | null;
age?: number;
gender?: string | false;
blood_type?: string | false;
last_visit: string | null;
total_visits: number;
wallet_points: number;
total_spent: number;
source: string;
is_vip: boolean;
primary_doctor_id?: number;
primary_doctor?: string;
// Datos personales extendidos
birthplace?: string;
occupation?: string;
marital_status?: string;
emergency_contact?: string;
emergency_phone?: string;
home_phone?: string;
mobile?: string;
address_notes?: string;
referred_by?: string;
patient_comments?: string;
internal_notes?: string;
// Historia clínica
is_pregnant?: boolean;
is_breastfeeding?: boolean;
uses_contraceptives?: boolean;
children_count?: number;
kidney_problems?: boolean;
back_pain?: boolean;
heart_disease?: boolean;
respiratory_problems?: boolean;
blood_pressure?: boolean;
diabetes?: boolean;
thyroid?: boolean;
colitis?: boolean;
constipation?: boolean;
liver_problems?: boolean;
surgeries?: boolean;
surgeries_notes?: string;
varicose_veins?: boolean;
migraine?: boolean;
faints_with_needles?: boolean;
medical_notes?: string;
// Legacy medical fields
allergies?: string;
medical_history?: string;
current_medication?: string;
}
export interface Service {
id: number;
code: string;
name: string;
category: string;
price: number;
package_price?: number;
package_sessions?: number;
package_notes?: string;
duration_min: number;
description: string;
color: string;
service_group: string;
is_favorite: boolean;
}
export interface Doctor {
id: number;
name: string;
job_title: string;
work_phone: string;
work_email: string;
commission_pct: number;
}
export interface Product {
id: number;
name: string;
list_price: number;
default_code: string;
type: string;
}
export interface Wallet {
id: number;
patient_id: number;
patient: string;
phone: string;
points: number;
equivalent_mxn: number;
}
export interface Payment {
id: number;
name: string;
patient: string;
patient_id: number;
amount: number;
payment_method: string;
state: string;
payment_url?: string;
provider_reference?: string;
create_date?: string;
}
export interface SaleLine {
id?: number;
service_id: number;
service?: string;
description: string;
quantity: number;
price_unit: number;
subtotal: number;
is_prescribed?: boolean;
prescribed_by_id?: number | null;
prescribed_by?: string | null;
}
export interface CommissionRow {
doctor_id: number;
doctor: string;
job_title: string;
commission_pct: number;
base: number;
commission: number;
items: number;
sales: number;
}
export interface Sale {
id: number;
name: string;
patient_id: number;
patient: string;
date: string;
subtotal: number;
discount: number;
tax: number;
total: number;
amount_paid: number;
amount_due: number;
state: string;
lines: SaleLine[];
refunded?: boolean;
refund_amount?: number;
refund_reason?: string;
refunded_at?: string | null;
}
export interface CashClosing {
id: number;
name: string;
date: string;
user: string | false;
opening_cash: number;
total_cash: number;
total_card: number;
total_transfer: number;
total_other: number;
total_sales: number;
closing_cash: number;
difference: number;
state: string;
}
export interface ChartPoint {
name: string;
appointments: number;
revenue: number;
patients: number;
}
export interface Birthday {
id: number;
name: string;
phone: string;
birth_date: string;
occurs_on: string;
turning_age: number;
last_visit: string | null;
total_spent: number;
amount_due: number;
is_vip: boolean;
}
export type InventoryKind = 'producto' | 'consumible';
export type InventoryLevel = 'out' | 'critical' | 'low' | 'optimal';
export type InventoryMoveType = 'compra' | 'venta' | 'baja' | 'ajuste';
export interface InventoryItem {
id: number;
name: string;
kind: InventoryKind;
sku: string;
category: string;
unit: string;
qty: number;
qty_optimal: number;
qty_min: number;
cost: number;
inventory_value: number;
expiry_date: string | null;
last_count_date: string | null;
active: boolean;
notes: string;
stock_level: InventoryLevel;
}
export interface InventoryMove {
id: number;
item_id: number;
type: InventoryMoveType;
qty: number;
before_qty: number;
after_qty: number;
date: string | null;
reference: string;
notes: string;
}
export interface InventorySummary {
levels: Record<InventoryLevel, number>;
total_value: number;
count: number;
}
// WACRM
export interface WacrmConversation {
id: number;
external_id: string;
contact_id?: string;
contact_name: string;
contact_phone: string;
status: string;
assigned_agent: string;
assigned_agent_id: string;
last_message_text: string;
last_message_at: string | null;
unread_count: number;
created_at: string | null;
}
export interface WacrmMessage {
id: number;
external_id: string;
conversation_id: string;
contact_name: string;
contact_phone: string;
sender_type: 'customer' | 'agent' | 'bot';
content_type: string;
content_text: string;
media_url: string;
template_name: string;
status: string;
created_at: string | null;
}
export interface WacrmStage {
id: number;
external_id: string;
name: string;
position: number;
color: string;
}
export interface WacrmPipeline {
id: number;
external_id: string;
name: string;
stages: WacrmStage[];
}
export interface WacrmLead {
id: number;
external_id: string;
title: string;
contact_id?: string;
contact_name: string;
contact_phone: string;
pipeline: string;
pipeline_id?: number;
stage: string;
stage_id?: number;
stage_color: string;
value: number;
currency: string;
status: 'open' | 'won' | 'lost';
expected_close_date: string | null;
notes: string;
assigned_to: string;
created_at: string | null;
}
export type FrontendRole = 'admin' | 'recepcion' | 'medico' | 'lectura';
export interface FrontendUser {
id: number;
login: string;
name: string;
role: FrontendRole;
must_change_password?: boolean;
last_login?: string | null;
active?: boolean;
}
export interface ApiResponse<T> {
status: string;
message?: string;
data?: T;
}
export interface Paginated<T> {
status: string;
total: number;
page: number;
page_size: number;
total_pages: number;
items: T;
}
// ============================================================
// API
// ============================================================
export const odooApi = {
// Auth (frontend React)
async login(login: string, password: string): Promise<{ status: string; token: string; user: FrontendUser; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/auth/login`, { login, password });
return data;
},
async me(): Promise<{ status: string; user: FrontendUser }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/auth/me`);
return data;
},
async logout(): Promise<{ status: string }> {
try {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/auth/logout`);
return data;
} catch {
return { status: 'success' };
}
},
async listFrontendUsers(): Promise<{ status: string; users: FrontendUser[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/auth/users`);
return data;
},
async createFrontendUser(payload: { login: string; name: string; password: string; role: FrontendRole }): Promise<{ status: string; user: FrontendUser; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/auth/users`, payload);
return data;
},
async updateFrontendUser(id: number, payload: Partial<{ name: string; role: FrontendRole; active: boolean; password: string; must_change_password: boolean }>): Promise<{ status: string; user: FrontendUser; message?: string }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/auth/users/${id}`, payload);
return data;
},
// Health
async healthCheck(): Promise<unknown> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/health`);
return data;
},
// Dashboard
async getDashboard(): Promise<{ status: string; stats: DashboardStats }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/dashboard`);
return data;
},
async getWeeklyChart(): Promise<{ status: string; data: ChartPoint[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/weekly_chart`);
return data;
},
// Citas
async getAppointments(params?: Record<string, string | number>): Promise<{ status: string; appointments: Appointment[]; total?: number; page?: number; page_size?: number; total_pages?: number }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/appointments`, { params });
return data;
},
async createAppointment(appointment: Partial<Appointment> & { sessions?: number }): Promise<{ status: string; appointment: Appointment; appointments?: Appointment[]; sessions?: number }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/appointments`, appointment);
return data;
},
async updateAppointment(id: number, appointment: Partial<Appointment>): Promise<{ status: string; appointment: Appointment }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/appointments/${id}`, appointment);
return data;
},
async deleteAppointment(id: number): Promise<{ status: string; message: string }> {
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/appointments/${id}`);
return data;
},
async updateAppointmentStatus(id: number, action: string, reason?: string): Promise<{ status: string; appointment: Appointment }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/appointments/${id}/status`, { action, reason });
return data;
},
async packageFinished(id: number, finished = true): Promise<{ status: string; appointment: Appointment }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/appointments/${id}/package_finished`, { finished });
return data;
},
async getAvailableSlots(date: string, servicio_id: number): Promise<{ status: string; slots: { time: number; time_str: string }[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/appointments/slots`, { params: { date, servicio_id } });
return data;
},
// Pacientes
async getPatients(params?: string | { search?: string; page?: number; page_size?: number }): Promise<{ status: string; patients: Patient[]; total?: number; page?: number; page_size?: number; total_pages?: number }> {
const p = typeof params === 'string' ? { search: params } : params || {};
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/patients`, { params: p });
return data;
},
async createPatient(patient: Partial<Patient>): Promise<{ status: string; patient: Patient }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/patients`, patient);
return data;
},
async updatePatient(id: number, patient: Partial<Patient>): Promise<{ status: string; patient: Patient }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/patients/${id}`, patient);
return data;
},
async getPatientHistory(id: number): Promise<{ status: string; appointments: Appointment[]; sales: Sale[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/patients/${id}/history`);
return data;
},
// Servicios
async getServices(): Promise<{ status: string; services: Service[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/services`);
return data;
},
async createService(service: Partial<Service>): Promise<{ status: string; service: Service }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/services`, service);
return data;
},
async updateService(id: number, service: Partial<Service>): Promise<{ status: string; service: Service }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/services/${id}`, service);
return data;
},
// Médicos
async getDoctors(): Promise<{ status: string; doctors: Doctor[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/doctors`);
return data;
},
async updateDoctor(id: number, payload: { commission_pct: number }): Promise<{ status: string; doctor: Doctor }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/doctors/${id}`, payload);
return data;
},
// Comisiones
async getCommissions(start?: string, end?: string): Promise<{ status: string; start: string; end: string; total_base: number; total_commission: number; commissions: CommissionRow[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/commissions`, { params: { start, end } });
return data;
},
// Productos
async getProducts(): Promise<{ status: string; products: Product[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/products`);
return data;
},
// Monederos
async getWallets(params?: string | { phone?: string; search?: string; page?: number; page_size?: number }): Promise<{ status: string; wallets: Wallet[]; total?: number; page?: number; page_size?: number; total_pages?: number }> {
const p = typeof params === 'string' ? { phone: params } : params || {};
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/wallets`, { params: p });
return data;
},
async getWalletByPhone(phone: string): Promise<{ status: string; wallet: { points: number; equivalent_mxn: number; history: unknown[] } }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/wallets/${phone}`);
return data;
},
async walletTransaction(payload: { phone: string; type: 'accrual' | 'redemption'; points: number; description?: string }): Promise<{ status: string; wallet: Wallet }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/wallets/transaction`, payload);
return data;
},
// Pagos
async getPayments(state?: string): Promise<{ status: string; payments: Payment[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/payments`, { params: state ? { state } : {} });
return data;
},
async createPayment(payment: Partial<Payment>): Promise<{ status: string; payment: Payment }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/payments`, payment);
return data;
},
async confirmPayment(id: number): Promise<{ status: string; payment: Payment }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/payments/${id}/confirm`);
return data;
},
// Ventas
async getSales(params?: Record<string, string | number>): Promise<{ status: string; sales: Sale[]; total?: number; page?: number; page_size?: number; total_pages?: number }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/sales`, { params });
return data;
},
async createSale(sale: Omit<Partial<Sale>, 'lines'> & { lines: Omit<SaleLine, 'subtotal'>[] }): Promise<{ status: string; sale: Sale }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/sales`, sale);
return data;
},
async paySale(id: number, payload: { payment_method: string; amount?: number }): Promise<{ status: string; sale: Sale; payment: Payment }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/sales/${id}/pay`, payload);
return data;
},
async refundSale(id: number, payload: { amount?: number; reason?: string }): Promise<{ status: string; sale: Sale; result: { refund_amount: number; points_reversed: number; new_state: string; new_amount_paid: number } }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/sales/${id}/refund`, payload);
return data;
},
// Cortes de caja
async getCashClosings(date?: string): Promise<{ status: string; cash_closings: CashClosing[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/cash_closings`, { params: date ? { date } : {} });
return data;
},
async createCashClosing(payload: { date?: string; opening_cash: number; notes?: string }): Promise<{ status: string; cash_closing: CashClosing }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/cash_closings`, payload);
return data;
},
async closeCashClosing(id: number, closing_cash: number): Promise<{ status: string; cash_closing: CashClosing }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/cash_closings/${id}/close`, { closing_cash });
return data;
},
// Reportes
async getSalesReport(start?: string, end?: string): Promise<{ status: string; total_sales: number; total_paid: number; total_due: number; count: number }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/sales`, { params: { start, end } });
return data;
},
async getAppointmentsReport(start?: string, end?: string): Promise<{ status: string; total: number; by_state: Record<string, number> }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/appointments`, { params: { start, end } });
return data;
},
async getTopServices(start?: string, end?: string, limit = 8): Promise<{ status: string; start: string; end: string; services: { service_id: number; service: string; qty: number; revenue: number }[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/top_services`, { params: { start, end, limit } });
return data;
},
async getCashReport(date?: string): Promise<{ status: string; total: number; by_method: Record<string, number> }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/cash`, { params: { date } });
return data;
},
// Tipo de cambio
async getExchangeRate(): Promise<{ status: string; current: { rate: number; date: string; source: string } | null; history: { id: number; date: string; rate: number; source: string }[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/exchange_rate`);
return data;
},
async setExchangeRate(rate: number, source = 'manual'): Promise<{ status: string; exchange_rate: { id: number; date: string; rate: number; source: string } }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/exchange_rate`, { rate, source });
return data;
},
// Meta de ventas mensual
async getSalesGoal(): Promise<{ status: string; goal: number; current: number; pct: number; start: string; end: string; count: number }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/sales_goal`);
return data;
},
async setSalesGoal(goal: number): Promise<{ status: string; goal: number }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/sales_goal`, { goal });
return data;
},
// Cumpleañeros
async getBirthdays(period: 'today' | 'week' | 'month' = 'month'): Promise<{ status: string; period: string; start: string; end: string; total: number; birthdays: Birthday[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/birthdays`, { params: { period } });
return data;
},
// Inventario / Consumibles
async getInventory(params?: { kind?: InventoryKind; level?: InventoryLevel; search?: string }): Promise<{ status: string; items: InventoryItem[]; summary: InventorySummary }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/inventory/items`, { params });
return data;
},
async createInventoryItem(item: Partial<InventoryItem>): Promise<{ status: string; item: InventoryItem }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/inventory/items`, item);
return data;
},
async updateInventoryItem(id: number, item: Partial<InventoryItem>): Promise<{ status: string; item: InventoryItem }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/inventory/items/${id}`, item);
return data;
},
async getInventoryMoves(id: number): Promise<{ status: string; moves: InventoryMove[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/inventory/items/${id}/moves`);
return data;
},
async createInventoryMove(id: number, payload: { type: InventoryMoveType; qty: number; reference?: string; notes?: string }): Promise<{ status: string; move: InventoryMove; item: InventoryItem }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/inventory/items/${id}/moves`, payload);
return data;
},
// ============================================================
// WACRM Proxy
// ============================================================
async syncWacrm(): Promise<{ status: string; result: Record<string, number | string | null> }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/wacrm/sync`);
return data;
},
async getWacrmMembers(): Promise<{ status: string; members: { id: string; name: string; email?: string; avatar_url?: string; role: string }[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/wacrm/members`);
return data;
},
async assignWacrmConversation(external_id: string, assigned_agent_id: string | null): Promise<{ status: string; assigned_agent_id?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/wacrm/conversations/${external_id}/assign`, {
assigned_agent_id,
});
return data;
},
async getWacrmConversations(search?: string): Promise<{ status: string; conversations: WacrmConversation[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/wacrm/conversations`, { params: search ? { search } : {} });
return data;
},
async sendWacrmMessage(payload: { conversation_id: string; text: string; assigned_agent_id: string }): Promise<{ status: string; message: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/wacrm/messages`, payload);
return data;
},
async getWacrmMessages(search?: string, conversation_id?: string): Promise<{ status: string; messages: WacrmMessage[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/wacrm/messages`, {
params: { ...(search ? { search } : {}), ...(conversation_id ? { conversation_id } : {}) },
});
return data;
},
async getWacrmPipelines(): Promise<{ status: string; pipelines: WacrmPipeline[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/wacrm/pipelines`);
return data;
},
async getWacrmLeads(search?: string, status?: string, pipeline_id?: string): Promise<{ status: string; leads: WacrmLead[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/wacrm/leads`, {
params: { ...(search ? { search } : {}), ...(status ? { status } : {}), ...(pipeline_id ? { pipeline_id } : {}) },
});
return data;
},
async updateWacrmLeadStatus(id: number, payload: { status?: 'open' | 'won' | 'lost'; stage_id?: string }): Promise<{ status: string; lead: WacrmLead }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/wacrm/leads/${id}/status`, payload);
return data;
},
};