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:
@@ -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">Sí</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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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">Sí</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
Reference in New Issue
Block a user