export type Role = "admin" | "owner" | "employee"; export type AppointmentStatus = "scheduled" | "completed" | "cancelled" | "no_show"; export interface Business { id: number; name: string; industry: string; currency: string; currency_symbol: string; phone: string | null; address: string | null; slug?: string | null; plan?: string; status?: string; template?: string | null; trial_ends_at?: string | null; booking_enabled?: number | boolean; cancel_window_hours?: number; cancel_penalty_pct?: number; require_deposit?: number | boolean; deposit_pct?: number; timezone?: string; auto_assign_specialist?: number | boolean; working_hours?: WorkingHoursMap | string | null; created_at?: string; } export type WorkingDay = { start: string; end: string }; export type WorkingHoursMap = Record; // 1=Lun … 7=Dom export interface User { id: number; business_id: number | null; email: string; name: string; role: Role; employee_id: number | null; avatar_color: string; } export interface Employee { id: number; business_id: number; name: string; email: string | null; phone: string | null; color: string; role: string; active: 0 | 1; rating: number; hire_date: string | null; service_ids?: number[]; specialties?: string[]; working_hours?: WorkingHoursMap | string | null; efficiency_score?: number; stats?: EmployeeStats; } export interface EmployeeStats { appointments_total: number; appointments_completed: number; revenue_total: number; avg_rating: number; share_pct: number; } export interface Service { id: number; business_id: number; name: string; description: string | null; category: string; duration_min: number; price: number; color: string; active: 0 | 1; employee_ids?: number[]; } export interface Client { id: number; business_id: number; name: string; email: string | null; phone: string | null; /** El teléfono normalizado a E.164. `null` = no se pudo normalizar. */ phone_e164?: string | null; /** Derivado: hay teléfono normalizado y por tanto se le puede avisar. */ contactable?: boolean; notes: string | null; tags: string | null; /** whatsapp | facebook | instagram | mostrador | referido */ source_channel?: string | null; created_at: string; stats?: ClientStats; /** Atribución traída del CRM. De solo lectura: allá es inmutable. */ crm_contact_id?: string | null; crm_synced_at?: string | null; crm_source?: string | null; crm_tags?: string | null; attr_session_source?: string | null; attr_medium?: string | null; attr_campaign?: string | null; attr_campaign_id?: string | null; attr_utm_source?: string | null; attr_utm_medium?: string | null; attr_utm_content?: string | null; attr_ad_id?: string | null; attr_referrer?: string | null; } export interface ClientStats { visits: number; total_spent: number; last_visit: string | null; avg_ticket: number; no_show_count: number; } export interface Appointment { id: number; business_id: number; service_id: number; employee_id: number; client_id: number; start_at: string; end_at: string; status: AppointmentStatus; /** * El CRM aplana "no vino" y "el spa canceló" en un solo estado; para el * negocio son cosas distintas, así que el matiz vive aquí. * Solo tiene valor cuando `status === "cancelled"`. */ cancelled_by?: "client" | "business" | null; cancel_reason?: string | null; price: number; notes: string | null; created_by_user_id: number | null; created_at: string; updated_at: string; // joined fields service?: Service; employee?: Pick; client?: Pick; } export interface Ticket { id: number; business_id: number; appointment_id: number | null; client_id: number; employee_id: number; service_id: number; amount: number; tip: number; payment_method: string; created_at: string; service?: Pick; employee?: Pick; client?: Pick; } export interface DashboardOverview { revenue_today: number; revenue_range: number; revenue_prev: number; revenue_change_pct: number; appts_today: number; appts_week: number; appts_upcoming: number; active_clients: number; avg_ticket: number; cancel_rate: number; no_show_rate: number; completion_pct: number; range: number; } export interface RankedEmployee { employee: Pick; appointments: number; revenue: number; avg_rating: number; share_pct: number; } export interface RankedService { service: Pick; count: number; revenue: number; } export interface RankedClient { client: Pick; visits: number; total_spent: number; last_visit: string | null; } export interface TopTicket { ticket: Ticket; } export interface RevenuePoint { date: string; revenue: number; appts: number; } export interface CategorySlice { category: string; revenue: number; count: number; } export interface BookResponse { appointment: { id: number; start_at: string; end_at?: string; price: number; service_name: string; employee_id: number; employee_name: string; reasons: string[]; }; business: { name: string; currency_symbol: string }; client_created: boolean; no_show_count?: number; risk_flag?: boolean; } export type PaymentMethod = "cash" | "card" | "transfer" | "other"; /** * El hecho consumado: la clienta vino. Solo existe si asistió — una cita es una * intención y una visita es un hecho con dinero; fusionarlas produce registros * que nadie cierra nunca. */ export interface Visit { id: number; business_id: number; appointment_id: number | null; client_id: number; employee_id: number; occurred_at: string; total_charged: number | null; payment_method: PaymentMethod | null; recorded_by_user_id: number | null; recorded_at: string; } export interface AttendanceResult { appointment: Appointment; visit: Visit | null; } /** Una cita del día pendiente de desenlace, con los nombres ya resueltos. */ export interface UnresolvedAppointment { id: number; client_id: number; employee_id: number; service_id: number; start_at: string; end_at: string; status: AppointmentStatus; price: number; client_name: string; employee_name: string; service_name: string; } export interface DayCloseSummary { date: string; closed_at: string | null; unresolved: UnresolvedAppointment[]; attended: number; no_show: number; cancelled: number; } export interface DayClosure { id: number; business_id: number; business_date: string; closed_by_user_id: number; closed_at: string; attended_count: number; no_show_count: number; cancelled_count: number; } // ─── Integración con Bucéfalo CRM ─────────────────────────────────────────── /** La atribución llega del CRM y es de solo lectura: allá es inmutable. */ export interface ClientAttribution { crm_source?: string | null; attr_session_source?: string | null; attr_medium?: string | null; attr_campaign?: string | null; attr_campaign_id?: string | null; attr_utm_source?: string | null; attr_utm_medium?: string | null; attr_utm_content?: string | null; attr_ad_id?: string | null; attr_referrer?: string | null; crm_tags?: string | null; crm_contact_id?: string | null; crm_synced_at?: string | null; } export interface CrmOutboxState { pendiente: number; enviando: number; confirmado: number; fallido: number; indeterminado: number; } export interface CrmSyncRun { id: number; kind: string; status: "corriendo" | "ok" | "error"; fetched: number; created: number; updated: number; started_at: string; finished_at: string | null; error: string | null; } export interface CrmStatus { connected: boolean; location_id?: string; pipeline_id?: string | null; /** Si es false, cada clienta tiene UNA oportunidad que se recicla por cita. */ allow_duplicate_opp?: boolean; last_sync_at?: string | null; last_sync_status?: string | null; stats?: { clientes: number; sincronizados: number; contactables: number; con_campana: number; }; last_run?: CrmSyncRun | null; outbox?: CrmOutboxState; } export interface CrmSyncResult { runId: number; fetched: number; created: number; updated: number; skipped: number; total_crm: number; status: "ok" | "error"; } export interface ConversationSummary { crm_conversation_id: string; crm_contact_id: string | null; contact_name: string; last_message_body: string | null; last_message_type: string | null; last_message_at: string | number | null; unread_count: number; client: { id: number; name: string; contactable: boolean } | null; } export interface ConversationMessage { id: string; body: string | null; direction: string | null; channel: string | null; status: string | null; sent_at: string | null; } export interface SendMessageResult { /** El CRM acusa ENCOLADO, no entrega. La interfaz no debe decir «entregado». */ queued: boolean; sent_to: string; redirigido: boolean; aviso: string | null; } /** Una cuenta de la plataforma, vista desde la consola de administración. */ export interface PlatformAccount { id: number; name: string; slug: string | null; timezone: string; status: string; created_at: string; clientes: number; usuarios: number; /** Vínculo con Bucéfalo CRM. `null` si la cuenta no está vinculada. */ location_id: string | null; crm_label: string | null; /** Los 6 últimos caracteres del token. El token nunca sale del servidor. */ token_fingerprint: string | null; token_updated_at: string | null; pipeline_id: string | null; calendar_id: string | null; allow_real_sends: boolean | null; test_email: string | null; last_sync_at: string | null; last_sync_status: string | null; } /** Las cinco entidades que se pueden sincronizar por su identificador. */ export type CrmEntidad = "contacto" | "conversacion" | "mensaje" | "cita" | "servicio"; export interface CrmSyncUnoResult { entidad: CrmEntidad; id: string; accion: string; detalle: Record; }