feat: add TypeScript definitions and utility functions for date formatting

- Created a global TypeScript declaration for the txadmin interface on the Window object.
- Implemented date formatting utilities using date-fns with Spanish locale support.
- Added TypeScript configuration files for app, Electron, and Node environments.
- Set up Vite configuration for React application.
This commit is contained in:
2026-08-14 19:13:32 -03:00
parent 9efb1792c7
commit 28295e346c
44 changed files with 10274 additions and 0 deletions
+209
View File
@@ -0,0 +1,209 @@
import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { Search, Edit2, ShieldAlert, ShieldCheck, ChevronUp, ChevronDown, RotateCcw, Box, Eye } from 'lucide-react';
import { fetchCompanies, setCompanyBanned } from '../api/sysadmin';
import { EditCompanyModal } from '../components/ui/EditCompanyModal';
import { CompanyServicesModal } from '../components/ui/CompanyServicesModal';
import { OrganizationInsightsModal } from '../components/ui/OrganizationInsightsModal';
export const CompaniesPage = () => {
const [companies, setCompanies] = useState<any[]>([]);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [loading, setLoading] = useState(false);
// Filters
const [filters, setFilters] = useState({
name: '',
banned: 'all', // all | true | false
sortBy: 'name',
sortOrder: 'asc'
});
// Modals state
const [editingCompany, setEditingCompany] = useState<any>(null);
const [servicesCompany, setServicesCompany] = useState<any>(null);
const [insightsCompany, setInsightsCompany] = useState<any>(null);
const loadCompanies = async () => {
setLoading(true);
try {
const payload: any = { page, limit: 10, sortBy: filters.sortBy, sortOrder: filters.sortOrder };
if (filters.name) payload.name = filters.name;
if (filters.banned !== 'all') payload.banned = filters.banned === 'true';
const res = await fetchCompanies(payload);
setCompanies(res.data);
setTotalPages(res.pages);
} catch (e) {
console.error(e);
alert('Error cargando organizaciones');
}
setLoading(false);
};
useEffect(() => {
loadCompanies();
}, [page, filters.sortBy, filters.sortOrder, filters.banned]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setPage(1);
loadCompanies();
};
const handleResetFilters = () => {
setFilters({
name: '',
banned: 'all',
sortBy: 'name',
sortOrder: 'asc'
});
setPage(1);
};
useEffect(() => {
if (filters.name === '' && filters.banned === 'all' && filters.sortBy === 'name' && filters.sortOrder === 'asc') {
loadCompanies();
}
}, [filters]);
const toggleSort = (field: string) => {
setFilters(prev => {
if (prev.sortBy === field) {
return { ...prev, sortOrder: prev.sortOrder === 'asc' ? 'desc' : 'asc' };
}
return { ...prev, sortBy: field, sortOrder: 'asc' };
});
};
const renderSortIcon = (field: string) => {
if (filters.sortBy !== field) return null;
return filters.sortOrder === 'asc' ? <ChevronUp size={16} /> : <ChevronDown size={16} />;
};
const handleToggleBanned = async (company: any) => {
if (window.confirm(`¿Deseas ${company.banned ? 'quitar el ban' : 'banear'} a la organización ${company.name}?`)) {
try {
await setCompanyBanned(company.id || company._id, !company.banned);
loadCompanies();
} catch (e: any) {
alert('Error al actualizar estado de la organización');
}
}
};
return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="animate-fade-in companies-page-container" style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem', flexShrink: 0 }}>
<h1 style={{ fontSize: '2rem', fontWeight: '700' }}>Gestión de Organizaciones</h1>
</div>
{/* Filters Bar */}
<div className="glass-panel filters-bar" style={{ padding: '1.5rem', marginBottom: '1rem', flexShrink: 0 }}>
<form onSubmit={handleSearch} style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div style={{ flex: '1', minWidth: '200px' }}>
<label style={{ display: 'block', fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>Nombre</label>
<input
type="text"
className="input-glass"
placeholder="Buscar por nombre..."
value={filters.name}
onChange={(e) => setFilters({...filters, name: e.target.value})}
/>
</div>
<div style={{ width: '150px' }}>
<label style={{ display: 'block', fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>Baneado</label>
<select
className="input-glass"
value={filters.banned}
onChange={(e) => setFilters({...filters, banned: e.target.value})}
>
<option value="all">Todos</option>
<option value="true"></option>
<option value="false">No</option>
</select>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button type="submit" className="btn-primary" style={{ display: 'flex', alignItems: 'center', gap: '8px', height: '100%' }}>
<Search size={18} /> Buscar
</button>
<button type="button" onClick={handleResetFilters} className="btn-secondary" style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '0.75rem', height: '100%' }} title="Limpiar Filtros">
<RotateCcw size={18} />
</button>
</div>
</form>
</div>
{/* Table */}
<div className="glass-panel" style={{ overflow: 'hidden', display: 'flex', flexDirection: 'column', flex: 1 }}>
<div style={{ overflow: 'auto', flex: 1 }} className="table-scroll-container">
<table style={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left', minWidth: '700px' }}>
<thead>
<tr>
<th className="sticky-header sortable-header" onClick={() => toggleSort('name')} style={{ padding: '1rem', color: 'var(--text-muted)', fontWeight: '600', cursor: 'pointer', userSelect: 'none' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>Organización {renderSortIcon('name')}</div>
</th>
<th className="sticky-header sortable-header" onClick={() => toggleSort('categoryId')} style={{ padding: '1rem', color: 'var(--text-muted)', fontWeight: '600', cursor: 'pointer', userSelect: 'none' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>Categoría {renderSortIcon('categoryId')}</div>
</th>
<th className="sticky-header" style={{ padding: '1rem', color: 'var(--text-muted)', fontWeight: '600' }}>Estado</th>
<th className="sticky-header" style={{ padding: '1rem', color: 'var(--text-muted)', fontWeight: '600', textAlign: 'right' }}>Acciones</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={4} style={{ padding: '2rem', textAlign: 'center' }}>Cargando...</td></tr>
) : companies.length === 0 ? (
<tr><td colSpan={4} style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-muted)' }}>No se encontraron organizaciones</td></tr>
) : (
companies.map(c => (
<tr key={c.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)', transition: 'background 0.2s' }} className="table-row-hover">
<td style={{ padding: '1rem' }}>
<div style={{ fontWeight: '600', color: 'var(--text-main)' }}>{c.name}</div>
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>{c.description || '-'}</div>
</td>
<td style={{ padding: '1rem', fontSize: '0.9rem', color: 'var(--text-muted)' }}>
{c.categoryId}
</td>
<td style={{ padding: '1rem' }}>
<button
onClick={() => handleToggleBanned(c)}
style={{ background: 'transparent', display: 'flex', alignItems: 'center', gap: '5px', color: c.banned ? 'var(--danger)' : 'var(--success)', cursor: 'pointer' }}
title={c.banned ? 'Click para desbanear' : 'Click para banear'}
>
{c.banned ? <ShieldAlert size={18} /> : <ShieldCheck size={18} />}
{c.banned ? 'Baneada' : 'Activa'}
</button>
</td>
<td style={{ padding: '1rem', textAlign: 'right' }}>
<div style={{ display: 'flex', gap: '10px', justifyContent: 'flex-end' }}>
<button className="icon-btn" title="Ver detalle" onClick={() => setInsightsCompany(c)}><Eye size={18} /></button>
<button className="icon-btn" title="Ver Servicios" onClick={() => setServicesCompany(c)}><Box size={18} /></button>
<button className="icon-btn" title="Editar Detalles" onClick={() => setEditingCompany(c)}><Edit2 size={18} /></button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="pagination-bar" style={{ padding: '1rem', borderTop: '1px solid var(--glass-border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexShrink: 0, background: 'rgba(0,0,0,0.2)' }}>
<span style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>Página {page} de {totalPages || 1}</span>
<div style={{ display: 'flex', gap: '10px' }}>
<button className="btn-secondary" disabled={page === 1} onClick={() => setPage(p => p - 1)}>Anterior</button>
<button className="btn-secondary" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>Siguiente</button>
</div>
</div>
</div>
{/* Modals */}
{editingCompany && <EditCompanyModal company={editingCompany} onClose={() => setEditingCompany(null)} onSaved={loadCompanies} />}
{servicesCompany && <CompanyServicesModal company={servicesCompany} onClose={() => setServicesCompany(null)} />}
{insightsCompany && <OrganizationInsightsModal company={insightsCompany} onClose={() => setInsightsCompany(null)} />}
</motion.div>
);
};
+121
View File
@@ -0,0 +1,121 @@
import { motion } from 'framer-motion';
import { Building2, CreditCard, RefreshCw, Server, Shield, Tags, Users } from 'lucide-react';
import { Link } from 'react-router-dom';
export const DashboardPage = () => {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
style={{ padding: '2rem 0' }}
>
<header style={{ marginBottom: '3rem' }}>
<h1 style={{ fontSize: '2.5rem', fontWeight: '800', background: 'linear-gradient(to right, var(--text-main), var(--text-muted))', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>
Bienvenido al Panel SysAdmin
</h1>
<p style={{ color: 'var(--text-muted)', marginTop: '0.5rem', fontSize: '1.1rem' }}>
Gestión centralizada del puente TurnosXpress.
</p>
</header>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: '2rem' }}>
{/* Card Users */}
<Link to="/users" style={{ display: 'block' }}>
<motion.div
whileHover={{ y: -5, scale: 1.02 }}
className="glass-panel"
style={{ padding: '2rem', display: 'flex', flexDirection: 'column', gap: '1rem', cursor: 'pointer', height: '100%' }}
>
<div style={{ width: '60px', height: '60px', borderRadius: '16px', background: 'rgba(59, 130, 246, 0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--primary)', border: '1px solid rgba(59, 130, 246, 0.2)' }}>
<Users size={30} />
</div>
<div>
<h3 style={{ fontSize: '1.3rem', fontWeight: '600', marginBottom: '0.5rem', color: 'var(--text-main)' }}>Gestión de Usuarios</h3>
<p style={{ color: 'var(--text-muted)', lineHeight: '1.5' }}>Administra perfiles, verifica cuentas, consulta organizaciones, suscripciones, pagos y herramientas de recálculo.</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginTop: '1rem' }}>
<span className="dashboard-tag"><CreditCard size={14} /> Suscripciones</span>
<span className="dashboard-tag"><RefreshCw size={14} /> Recálculos</span>
</div>
</div>
</motion.div>
</Link>
{/* Card Companies */}
<Link to="/companies" style={{ display: 'block' }}>
<motion.div
whileHover={{ y: -5, scale: 1.02 }}
className="glass-panel"
style={{ padding: '2rem', display: 'flex', flexDirection: 'column', gap: '1rem', cursor: 'pointer', height: '100%' }}
>
<div style={{ width: '60px', height: '60px', borderRadius: '16px', background: 'rgba(16, 185, 129, 0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--success)', border: '1px solid rgba(16, 185, 129, 0.2)' }}>
<Building2 size={30} />
</div>
<div>
<h3 style={{ fontSize: '1.3rem', fontWeight: '600', marginBottom: '0.5rem', color: 'var(--text-main)' }}>Gestión de Organizaciones</h3>
<p style={{ color: 'var(--text-muted)', lineHeight: '1.5' }}>Consulta organizaciones, revisa estado operativo y administra bloqueos desde el panel central.</p>
</div>
</motion.div>
</Link>
{/* Card Plans */}
<Link to="/plans" style={{ display: 'block' }}>
<motion.div
whileHover={{ y: -5, scale: 1.02 }}
className="glass-panel"
style={{ padding: '2rem', display: 'flex', flexDirection: 'column', gap: '1rem', cursor: 'pointer', height: '100%' }}
>
<div style={{ width: '60px', height: '60px', borderRadius: '16px', background: 'rgba(245, 158, 11, 0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--warning)', border: '1px solid rgba(245, 158, 11, 0.2)' }}>
<Tags size={30} />
</div>
<div>
<h3 style={{ fontSize: '1.3rem', fontWeight: '600', marginBottom: '0.5rem', color: 'var(--text-main)' }}>Gestión de Planes</h3>
<p style={{ color: 'var(--text-muted)', lineHeight: '1.5' }}>Visualiza y edita precios, límites, descuentos, funciones y disponibilidad de los planes contratables.</p>
</div>
</motion.div>
</Link>
{/* Card Wap Servers */}
<Link to="/wapservers" style={{ display: 'block' }}>
<motion.div whileHover={{ y: -5, scale: 1.02 }} className="glass-panel" style={{ padding: '2rem', display: 'flex', flexDirection: 'column', gap: '1rem', cursor: 'pointer', height: '100%' }}>
<div style={{ width: '60px', height: '60px', borderRadius: '16px', background: 'rgba(139, 92, 246, 0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--accent)', border: '1px solid rgba(139, 92, 246, 0.2)' }}>
<Server size={30} />
</div>
<div>
<h3 style={{ fontSize: '1.3rem', fontWeight: '600', marginBottom: '0.5rem', color: 'var(--text-main)' }}>Servidores WAP</h3>
<p style={{ color: 'var(--text-muted)', lineHeight: '1.5' }}>Consulta servidores de WhatsApp puente y su estado operativo.</p>
</div>
</motion.div>
</Link>
{/* Card Config */}
<div style={{ opacity: 0.6 }}>
<div className="glass-panel" style={{ padding: '2rem', display: 'flex', flexDirection: 'column', gap: '1rem', height: '100%' }}>
<div style={{ width: '60px', height: '60px', borderRadius: '16px', background: 'rgba(16, 185, 129, 0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--success)', border: '1px solid rgba(16, 185, 129, 0.2)' }}>
<Shield size={30} />
</div>
<div>
<h3 style={{ fontSize: '1.3rem', fontWeight: '600', marginBottom: '0.5rem' }}>Seguridad</h3>
<p style={{ color: 'var(--text-muted)', lineHeight: '1.5' }}>[Próximamente] Gestión de llaves, logs de auditoría y configuración del middleware.</p>
</div>
</div>
</div>
</div>
<style dangerouslySetInnerHTML={{__html: `
.dashboard-tag {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.25rem 0.55rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
color: var(--text-muted);
font-size: 0.78rem;
font-weight: 700;
border: 1px solid rgba(255, 255, 255, 0.08);
}
`}} />
</motion.div>
);
};
+181
View File
@@ -0,0 +1,181 @@
import { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import { CheckCircle, Edit2, RefreshCw, Star, XCircle } from 'lucide-react';
import { fetchPlans } from '../api/sysadmin';
import type { Plan } from '../api/sysadmin';
import { EditPlanModal } from '../components/ui/EditPlanModal';
const formatMoney = (value: number) => new Intl.NumberFormat('es-AR', { style: 'currency', currency: 'ARS' }).format(value || 0);
const getFeatureTags = (plan: Plan) => [
{ label: 'Mail', enabled: plan.mailNotifications },
{ label: 'SMS', enabled: plan.smsNotifications },
{ label: 'WAP', enabled: plan.wapNotifications },
{ label: 'Pagos', enabled: plan.payments },
{ label: 'Bot', enabled: plan.bot },
{ label: 'Fecha límite', enabled: plan.dateLimit },
];
const getLimitTags = (plan: Plan) => [
{ label: 'Org', value: plan.limitOrganizations },
{ label: 'Emp', value: plan.limitEmployees },
{ label: 'Serv', value: plan.limitServices },
{ label: 'Turnos', value: plan.limitAppointments },
{ label: 'Clientes', value: plan.limitClients },
{ label: 'Rep', value: plan.limitRepeats },
];
const formatLimit = (value: number) => value === -1 ? '∞' : value;
export const PlansPage = () => {
const [plans, setPlans] = useState<Plan[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [editingPlan, setEditingPlan] = useState<Plan | null>(null);
const [planIdFilter, setPlanIdFilter] = useState('');
const filteredPlans = plans.filter((plan) => {
const normalizedFilter = planIdFilter.trim().toLowerCase();
if (!normalizedFilter) return true;
return [plan.id, plan._id].some((planId) => planId?.toLowerCase().includes(normalizedFilter));
});
const loadPlans = async () => {
setLoading(true);
setError('');
try {
const data = await fetchPlans();
setPlans(data);
} catch (err: any) {
setError(err.response?.data?.message || err.message || 'Error cargando planes');
}
setLoading(false);
};
useEffect(() => {
loadPlans();
}, []);
return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="animate-fade-in plans-page-container" style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem', flexShrink: 0 }}>
<div>
<h1 style={{ fontSize: '2rem', fontWeight: '700' }}>Gestión de Planes</h1>
<p style={{ color: 'var(--text-muted)', marginTop: '0.35rem' }}>Visualizá y editá precios, límites y funciones contratables.</p>
</div>
<button className="btn-secondary" onClick={loadPlans} disabled={loading} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<RefreshCw size={18} /> Actualizar
</button>
</div>
<div className="glass-panel" style={{ padding: '1rem', marginBottom: '1rem', flexShrink: 0 }}>
<label style={{ display: 'block', color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: '0.5rem' }}>
Filtrar por ID de plan
</label>
<input
className="input-glass"
type="text"
placeholder="Pegá el id del plan"
value={planIdFilter}
onChange={(event) => setPlanIdFilter(event.target.value)}
/>
</div>
{error && (
<div className="glass-panel" style={{ padding: '1rem', marginBottom: '1rem', color: 'var(--danger)', borderColor: 'rgba(239,68,68,0.35)' }}>
{error}
</div>
)}
<div className="glass-panel" style={{ overflow: 'hidden', display: 'flex', flexDirection: 'column', flex: 1 }}>
<div style={{ overflow: 'auto', flex: 1 }} className="table-scroll-container">
<table style={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left', minWidth: '920px' }}>
<thead>
<tr>
<th className="sticky-header">Plan</th>
<th className="sticky-header">Precio</th>
<th className="sticky-header">Límites</th>
<th className="sticky-header">Funciones</th>
<th className="sticky-header">Estado</th>
<th className="sticky-header" style={{ textAlign: 'right' }}>Acciones</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={6} style={{ padding: '2rem', textAlign: 'center' }}>Cargando planes...</td></tr>
) : filteredPlans.length === 0 ? (
<tr><td colSpan={6} style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-muted)' }}>No se encontraron planes</td></tr>
) : (
filteredPlans.map(plan => (
<tr key={plan._id || plan.id || plan.code} className="table-row-hover" style={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<td style={{ padding: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontWeight: 700 }}>
{plan.name} {plan.featured && <Star size={16} color="var(--warning)" fill="var(--warning)" />}
</div>
<div style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>{plan.code}</div>
<div style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginTop: '0.35rem', maxWidth: '280px' }}>{plan.description}</div>
</td>
<td style={{ padding: '1rem' }}>
<div>{formatMoney(plan.price)} / mes</div>
<div style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>{formatMoney(plan.annualPrice)} / año</div>
</td>
<td style={{ padding: '1rem' }}>
<div className="limit-tags">
{getLimitTags(plan).map((limit) => (
<span key={limit.label} className={`limit-tag ${limit.value === -1 ? 'unlimited' : ''}`}>
<small>{limit.label}</small>
<strong>{formatLimit(limit.value)}</strong>
</span>
))}
</div>
</td>
<td style={{ padding: '1rem' }}>
<div className="feature-tags">
{getFeatureTags(plan).map((feature) => (
<span key={feature.label} className={`feature-tag ${feature.enabled ? 'enabled' : 'disabled'}`}>
{feature.label}
</span>
))}
</div>
</td>
<td style={{ padding: '1rem' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.35rem', color: plan.active ? 'var(--success)' : 'var(--warning)' }}>
{plan.active ? <CheckCircle size={18} /> : <XCircle size={18} />}
{plan.active ? 'Activo' : 'Inactivo'}
</span>
</td>
<td style={{ padding: '1rem', textAlign: 'right' }}>
<button className="icon-btn" title="Editar plan" aria-label={`Editar ${plan.name}`} onClick={() => setEditingPlan(plan)}>
<Edit2 size={18} />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
<style dangerouslySetInnerHTML={{__html: `
.sticky-header { position: sticky; top: 0; z-index: 10; background: rgba(10, 10, 15, 0.95); backdrop-filter: blur(10px); border-bottom: 1px solid var(--glass-border); padding: 1rem; color: var(--text-muted); font-weight: 600; }
.table-row-hover:hover { background: rgba(255, 255, 255, 0.02); }
.limit-tags { display: flex; flex-wrap: wrap; gap: 0.4rem; max-width: 300px; }
.limit-tag { display: inline-flex; align-items: center; gap: 0.3rem; border-radius: 999px; padding: 0.25rem 0.55rem; background: rgba(59, 130, 246, 0.12); border: 1px solid rgba(59, 130, 246, 0.24); color: #bfdbfe; }
.limit-tag small { color: #93c5fd; font-weight: 800; font-size: 0.7rem; text-transform: uppercase; }
.limit-tag strong { color: #f8fafc; font-size: 0.82rem; }
.limit-tag.unlimited { background: rgba(168, 85, 247, 0.16); border-color: rgba(168, 85, 247, 0.32); }
.limit-tag.unlimited small { color: #d8b4fe; }
.limit-tag.unlimited strong { color: #f5d0fe; font-size: 0.95rem; }
.feature-tags { display: flex; flex-wrap: wrap; gap: 0.4rem; max-width: 260px; }
.feature-tag { display: inline-flex; align-items: center; border-radius: 999px; padding: 0.25rem 0.55rem; font-size: 0.75rem; font-weight: 700; border: 1px solid transparent; }
.feature-tag.enabled { background: rgba(34, 197, 94, 0.14); color: #86efac; border-color: rgba(34, 197, 94, 0.28); }
.feature-tag.disabled { background: rgba(148, 163, 184, 0.1); color: #94a3b8; border-color: rgba(148, 163, 184, 0.18); text-decoration: line-through; opacity: 0.75; }
@media (max-width: 768px) { .plans-page-container { height: auto !important; overflow: visible !important; } .table-scroll-container { overflow-x: auto; } }
`}} />
{editingPlan && <EditPlanModal plan={editingPlan} onClose={() => setEditingPlan(null)} onSaved={loadPlans} />}
</motion.div>
);
};
+404
View File
@@ -0,0 +1,404 @@
import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { Search, Edit2, Trash2, CheckCircle, XCircle, Building2, ChevronUp, ChevronDown, RotateCcw, RefreshCw, MoreVertical, CreditCard } from 'lucide-react';
import { fetchUsers, deleteUser, setVerifiedStatus } from '../api/sysadmin';
import { EditUserModal } from '../components/ui/EditUserModal';
import { OrganizationsModal } from '../components/ui/OrganizationsModal';
import { RecalculationToolsModal } from '../components/ui/RecalculationToolsModal';
import { SubscriptionDetailsModal } from '../components/ui/SubscriptionDetailsModal';
import { formatDate } from '../utils/formatters';
export const UsersPage = () => {
const [users, setUsers] = useState<any[]>([]);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [loading, setLoading] = useState(false);
const [recalculationUserId, setRecalculationUserId] = useState('');
const [openActionsUserId, setOpenActionsUserId] = useState<string | null>(null);
// Filters
const [filters, setFilters] = useState({
name: '',
email: '',
isVerified: 'all', // all | true | false
sortBy: 'createdAt',
sortOrder: 'desc'
});
// Modals state
const [editingUser, setEditingUser] = useState<any>(null);
const [orgUser, setOrgUser] = useState<any>(null);
const [subscriptionUser, setSubscriptionUser] = useState<any>(null);
const [recalculationToolsOpen, setRecalculationToolsOpen] = useState(false);
const loadUsers = async () => {
setLoading(true);
try {
const payload: any = { page, limit: 10, sortBy: filters.sortBy, sortOrder: filters.sortOrder };
if (filters.name) payload.name = filters.name;
if (filters.email) payload.email = filters.email;
if (filters.isVerified !== 'all') payload.isVerified = filters.isVerified === 'true';
const res = await fetchUsers(payload);
setUsers(res.data);
setTotalPages(res.pages);
} catch (e) {
console.error(e);
alert('Error cargando usuarios');
}
setLoading(false);
};
useEffect(() => {
loadUsers();
}, [page, filters.sortBy, filters.sortOrder, filters.isVerified]);
useEffect(() => {
if (!openActionsUserId) return;
const closeActionsMenu = () => setOpenActionsUserId(null);
document.addEventListener('click', closeActionsMenu);
return () => document.removeEventListener('click', closeActionsMenu);
}, [openActionsUserId]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setPage(1);
loadUsers();
};
const handleResetFilters = () => {
setFilters({
name: '',
email: '',
isVerified: 'all',
sortBy: 'createdAt',
sortOrder: 'desc'
});
setPage(1);
};
// Efecto para recargar si se limpian los filtros manualmente
useEffect(() => {
if (filters.name === '' && filters.email === '' && filters.isVerified === 'all' && filters.sortBy === 'createdAt' && filters.sortOrder === 'desc') {
loadUsers();
}
}, [filters]);
const toggleSort = (field: string) => {
setFilters(prev => {
if (prev.sortBy === field) {
return { ...prev, sortOrder: prev.sortOrder === 'asc' ? 'desc' : 'asc' };
}
return { ...prev, sortBy: field, sortOrder: 'asc' };
});
};
const renderSortIcon = (field: string) => {
if (filters.sortBy !== field) return null;
return filters.sortOrder === 'asc' ? <ChevronUp size={16} /> : <ChevronDown size={16} />;
};
const handleDelete = async (user: any) => {
if (window.confirm(`¿Estás seguro de que deseas eliminar permanentemente al usuario ${user.email}? Esto fallará si el usuario es dueño de organizaciones.`)) {
try {
await deleteUser(user._id);
alert('Usuario eliminado correctamente');
loadUsers();
} catch (e: any) {
alert(e.response?.data?.message || e.message || 'Error al eliminar');
}
}
};
const handleToggleVerified = async (user: any) => {
if (window.confirm(`¿Deseas cambiar el estado de verificación de ${user.email}?`)) {
try {
await setVerifiedStatus(user._id, !user.verificated);
loadUsers();
} catch (e: any) {
alert('Error al actualizar estado');
}
}
};
const openRecalculationTools = (userId = '') => {
setRecalculationUserId(userId);
setRecalculationToolsOpen(true);
};
const runRowAction = (action: () => void) => {
setOpenActionsUserId(null);
action();
};
return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="animate-fade-in users-page-container" style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem', flexShrink: 0 }}>
<h1 style={{ fontSize: '2rem', fontWeight: '700' }}>Gestión de Usuarios</h1>
<button className="btn-secondary" onClick={() => openRecalculationTools()} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<RefreshCw size={18} /> Recalcular
</button>
</div>
{/* Filters Bar */}
<div className="glass-panel filters-bar" style={{ padding: '1.5rem', marginBottom: '1rem', flexShrink: 0 }}>
<form onSubmit={handleSearch} style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div style={{ flex: '1', minWidth: '200px' }}>
<label style={{ display: 'block', fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>Nombre / Apellido</label>
<input
type="text"
className="input-glass"
placeholder="Buscar por nombre..."
value={filters.name}
onChange={(e) => setFilters({...filters, name: e.target.value})}
/>
</div>
<div style={{ flex: '1', minWidth: '200px' }}>
<label style={{ display: 'block', fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>Email</label>
<input
type="text"
className="input-glass"
placeholder="Buscar por email..."
value={filters.email}
onChange={(e) => setFilters({...filters, email: e.target.value})}
/>
</div>
<div style={{ width: '150px' }}>
<label style={{ display: 'block', fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>Verificado</label>
<select
className="input-glass"
value={filters.isVerified}
onChange={(e) => setFilters({...filters, isVerified: e.target.value})}
>
<option value="all">Todos</option>
<option value="true"></option>
<option value="false">No</option>
</select>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button type="submit" className="btn-primary" style={{ display: 'flex', alignItems: 'center', gap: '8px', height: '100%' }}>
<Search size={18} /> Buscar
</button>
<button type="button" onClick={handleResetFilters} className="btn-secondary" style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '0.75rem', height: '100%' }} title="Limpiar Filtros">
<RotateCcw size={18} />
</button>
</div>
</form>
</div>
{/* Table */}
<div className="glass-panel" style={{ overflow: 'hidden', display: 'flex', flexDirection: 'column', flex: 1 }}>
<div style={{ overflow: 'auto', flex: 1 }} className="table-scroll-container">
<table style={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left', minWidth: '700px' }}>
<thead>
<tr>
<th className="sticky-header sortable-header" onClick={() => toggleSort('name')} style={{ padding: '1rem', color: 'var(--text-muted)', fontWeight: '600', cursor: 'pointer', userSelect: 'none' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>Usuario {renderSortIcon('name')}</div>
</th>
<th className="sticky-header" style={{ padding: '1rem', color: 'var(--text-muted)', fontWeight: '600' }}>Contacto</th>
<th className="sticky-header" style={{ padding: '1rem', color: 'var(--text-muted)', fontWeight: '600' }}>Verificado</th>
<th className="sticky-header" style={{ padding: '1rem', color: 'var(--text-muted)', fontWeight: '600' }}>Cód. Verif.</th>
<th className="sticky-header sortable-header" onClick={() => toggleSort('createdAt')} style={{ padding: '1rem', color: 'var(--text-muted)', fontWeight: '600', cursor: 'pointer', userSelect: 'none' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>Registro {renderSortIcon('createdAt')}</div>
</th>
<th className="sticky-header" style={{ padding: '1rem', color: 'var(--text-muted)', fontWeight: '600', textAlign: 'right' }}>Acciones</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={6} style={{ padding: '2rem', textAlign: 'center' }}>Cargando...</td></tr>
) : users.length === 0 ? (
<tr><td colSpan={6} style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-muted)' }}>No se encontraron usuarios</td></tr>
) : (
users.map(u => (
<tr key={u._id} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)', transition: 'background 0.2s' }} className="table-row-hover">
<td style={{ padding: '1rem' }}>
<div style={{ fontWeight: '600', color: 'var(--text-main)' }}>{u.firstName} {u.lastName}</div>
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>{u.email}</div>
</td>
<td style={{ padding: '1rem', fontSize: '0.9rem', color: 'var(--text-muted)' }}>
{u.phoneCountryCode && `+${u.phoneCountryCode}`} {u.phoneAreaCode} {u.phoneNumber}
</td>
<td style={{ padding: '1rem' }}>
<button
onClick={() => handleToggleVerified(u)}
style={{ background: 'transparent', display: 'flex', alignItems: 'center', gap: '5px', color: u.verificated ? 'var(--success)' : 'var(--warning)', cursor: 'pointer' }}
>
{u.verificated ? <CheckCircle size={18} /> : <XCircle size={18} />}
{u.verificated ? 'Sí' : 'No'}
</button>
</td>
<td style={{ padding: '1rem', fontSize: '0.9rem', color: 'var(--text-muted)' }}>
{u.verificationCode || '-'}
</td>
<td style={{ padding: '1rem', fontSize: '0.9rem', color: 'var(--text-muted)' }}>
{formatDate(u.creationDate)}
</td>
<td style={{ padding: '1rem', textAlign: 'right' }}>
<div className="actions-menu-wrapper" onClick={(e) => e.stopPropagation()}>
<button
className="icon-btn"
title="Acciones"
aria-label={`Acciones para ${u.email}`}
aria-haspopup="menu"
aria-expanded={openActionsUserId === u._id}
onClick={() => setOpenActionsUserId(openActionsUserId === u._id ? null : u._id)}
>
<MoreVertical size={18} />
</button>
{openActionsUserId === u._id && (
<div className="actions-menu" role="menu">
<button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => setOrgUser(u))}>
<Building2 size={16} /> Ver organizaciones
</button>
<button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => setSubscriptionUser(u))}>
<CreditCard size={16} /> Ver suscripción
</button>
<button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => openRecalculationTools(u._id))}>
<RefreshCw size={16} /> Recalcular uso del plan
</button>
<button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => handleToggleVerified(u))}>
{u.verificated ? <XCircle size={16} /> : <CheckCircle size={16} />}
{u.verificated ? 'Marcar como no verificado' : 'Marcar como verificado'}
</button>
<button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => setEditingUser(u))}>
<Edit2 size={16} /> Editar perfil
</button>
<button type="button" className="actions-menu-item danger" role="menuitem" onClick={() => runRowAction(() => handleDelete(u))}>
<Trash2 size={16} /> Eliminar
</button>
</div>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="pagination-bar" style={{ padding: '1rem', borderTop: '1px solid var(--glass-border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexShrink: 0, background: 'rgba(0,0,0,0.2)' }}>
<span style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>Página {page} de {totalPages || 1}</span>
<div style={{ display: 'flex', gap: '10px' }}>
<button className="btn-secondary" disabled={page === 1} onClick={() => setPage(p => p - 1)}>Anterior</button>
<button className="btn-secondary" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>Siguiente</button>
</div>
</div>
</div>
<style dangerouslySetInnerHTML={{__html: `
.sticky-header {
position: sticky;
top: 0;
z-index: 10;
background: rgba(10, 10, 15, 0.95);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--glass-border);
transition: background 0.2s;
}
.sortable-header:hover {
background: rgba(255, 255, 255, 0.05);
}
.table-row-hover:hover {
background: rgba(255, 255, 255, 0.02);
}
.icon-btn {
background: rgba(255,255,255,0.05);
color: var(--text-muted);
padding: 8px;
border-radius: 8px;
transition: all 0.2s;
}
.icon-btn:hover {
background: var(--primary);
color: white;
}
.icon-btn.danger:hover {
background: var(--danger);
}
.actions-menu-wrapper {
position: relative;
display: inline-flex;
justify-content: flex-end;
}
.actions-menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 30;
min-width: 230px;
padding: 0.4rem;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 12px;
background: #111827;
box-shadow: 0 18px 45px rgba(0, 0, 0, 0.45);
backdrop-filter: blur(12px);
}
.actions-menu-item {
width: 100%;
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.65rem 0.75rem;
border: 0;
border-radius: 8px;
background: transparent;
color: #f8fafc;
font-size: 0.9rem;
font-weight: 600;
text-align: left;
cursor: pointer;
transition: all 0.2s;
}
.actions-menu-item:hover {
background: rgba(255, 255, 255, 0.12);
color: #ffffff;
}
.actions-menu-item.danger {
color: #fca5a5;
}
.actions-menu-item.danger:hover {
background: rgba(239, 68, 68, 0.18);
color: #fecaca;
}
.btn-secondary {
background: rgba(255,255,255,0.05);
color: var(--text-main);
border: 1px solid var(--glass-border);
padding: 0.5rem 1rem;
border-radius: 8px;
transition: all 0.2s;
}
.btn-secondary:hover:not(:disabled) {
background: rgba(255,255,255,0.1);
}
.btn-secondary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media (max-width: 768px) {
.users-page-container {
height: auto !important;
overflow: visible !important;
}
.filters-bar form > div {
flex: 1 1 100% !important;
}
.table-scroll-container {
overflow-x: auto;
}
}
`}} />
{/* Modals */}
{editingUser && <EditUserModal user={editingUser} onClose={() => setEditingUser(null)} onSaved={loadUsers} />}
{orgUser && <OrganizationsModal user={orgUser} onClose={() => setOrgUser(null)} />}
{subscriptionUser && <SubscriptionDetailsModal user={subscriptionUser} onClose={() => setSubscriptionUser(null)} />}
{recalculationToolsOpen && <RecalculationToolsModal initialUserId={recalculationUserId} onClose={() => setRecalculationToolsOpen(false)} />}
</motion.div>
);
};
File diff suppressed because it is too large Load Diff