feat: implement sysadmin subscription management and plan usage cycle recalculation tools
This commit is contained in:
@@ -4,6 +4,7 @@ import { DashboardPage } from './pages/DashboardPage';
|
||||
import { UsersPage } from './pages/UsersPage';
|
||||
import { WapServersPage } from './pages/WapServersPage';
|
||||
import { CompaniesPage } from './pages/CompaniesPage';
|
||||
import { PlansPage } from './pages/PlansPage';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -13,6 +14,7 @@ function App() {
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="companies" element={<CompaniesPage />} />
|
||||
<Route path="plans" element={<PlansPage />} />
|
||||
<Route path="wapservers" element={<WapServersPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -33,6 +33,119 @@ export const getOrganizationsStatus = async (userId: string) => {
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export type RecalculatePlanUsageCycleResult = {
|
||||
userId: string;
|
||||
subscriptionId: string;
|
||||
planId: string;
|
||||
cycleStart: string;
|
||||
cycleEnd: string;
|
||||
appointmentsCount: number;
|
||||
};
|
||||
|
||||
export const recalculatePlanUsageCycle = async (userId: string): Promise<RecalculatePlanUsageCycleResult> => {
|
||||
const res = await sysadminApi.post('/users/recalculate-plan-usage-cycle', { userId });
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export type UserSubscriptionDetails = {
|
||||
userId: string;
|
||||
currentSubscription: {
|
||||
id: string;
|
||||
plan: {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
price: number;
|
||||
} | null;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
isActive: boolean;
|
||||
mpStatus?: string;
|
||||
billingMonths?: number;
|
||||
pendingPaymentType?: 'extension' | 'upgrade';
|
||||
pendingPaymentPreferenceId?: string;
|
||||
lastPaymentStatus?: string;
|
||||
} | null;
|
||||
payments: {
|
||||
id: string;
|
||||
subscriptionId: string;
|
||||
amount: number;
|
||||
paymentDate: string;
|
||||
paymentMethod?: string;
|
||||
status: string;
|
||||
transactionId?: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export const getUserSubscriptionDetails = async (userId: string): Promise<UserSubscriptionDetails> => {
|
||||
const res = await sysadminApi.post('/users/subscription-details', { userId });
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export type ExtendUserSubscriptionPayload = {
|
||||
userId: string;
|
||||
mode: 'add_months' | 'set_end_date';
|
||||
months?: 1 | 2;
|
||||
endDate?: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type ExtendUserSubscriptionResult = {
|
||||
userId: string;
|
||||
subscriptionId: string;
|
||||
previousEndDate: string;
|
||||
newEndDate: string;
|
||||
paymentId: string;
|
||||
transactionId?: string;
|
||||
};
|
||||
|
||||
export const extendUserSubscription = async (data: ExtendUserSubscriptionPayload): Promise<ExtendUserSubscriptionResult> => {
|
||||
const res = await sysadminApi.post('/users/extend-subscription', data);
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export type Plan = {
|
||||
id?: string;
|
||||
_id?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
features: string[];
|
||||
code: string;
|
||||
price: number;
|
||||
annualPrice: number;
|
||||
limitOrganizations: number;
|
||||
limitEmployees: number;
|
||||
limitServices: number;
|
||||
limitAppointments: number;
|
||||
limitClients: number;
|
||||
limitRepeats: number;
|
||||
mailNotifications: boolean;
|
||||
smsNotifications: boolean;
|
||||
wapNotifications: boolean;
|
||||
payments: boolean;
|
||||
bot: boolean;
|
||||
active: boolean;
|
||||
dateLimit: boolean;
|
||||
discount3Months?: number;
|
||||
discount6Months?: number;
|
||||
discount12Months?: number;
|
||||
featured?: boolean;
|
||||
};
|
||||
|
||||
export type UpdatePlanPayload = Partial<Omit<Plan, 'id' | '_id'>> & {
|
||||
planId: string;
|
||||
};
|
||||
|
||||
export const fetchPlans = async (): Promise<Plan[]> => {
|
||||
const res = await sysadminApi.post('/plans/list', {});
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const updatePlan = async (data: UpdatePlanPayload) => {
|
||||
const res = await sysadminApi.post('/plans/update', data);
|
||||
return res.data;
|
||||
};
|
||||
|
||||
// Wap Servers API Calls
|
||||
export const fetchWapServers = async (filters: any) => {
|
||||
const res = await sysadminApi.post('/wap/paginate', filters);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Outlet, NavLink } from 'react-router-dom';
|
||||
import { Users, LayoutDashboard, LogOut, Server, Building2 } from 'lucide-react';
|
||||
import { Users, LayoutDashboard, LogOut, Server, Building2, BadgeDollarSign } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export const DashboardLayout = () => {
|
||||
@@ -30,6 +30,10 @@ export const DashboardLayout = () => {
|
||||
<Building2 size={20} />
|
||||
Organizaciones
|
||||
</NavLink>
|
||||
<NavLink to="/plans" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
|
||||
<BadgeDollarSign size={20} />
|
||||
Planes
|
||||
</NavLink>
|
||||
<NavLink to="/wapservers" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
|
||||
<Server size={20} />
|
||||
Servidores WAP
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { X } from 'lucide-react';
|
||||
import { updatePlan } from '../../api/sysadmin';
|
||||
import type { Plan } from '../../api/sysadmin';
|
||||
|
||||
type EditPlanModalProps = {
|
||||
plan: Plan;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
};
|
||||
|
||||
const numberFields: { key: keyof Plan; label: string }[] = [
|
||||
{ key: 'price', label: 'Precio mensual' },
|
||||
{ key: 'annualPrice', label: 'Precio anual' },
|
||||
{ key: 'limitOrganizations', label: 'Límite de organizaciones' },
|
||||
{ key: 'limitEmployees', label: 'Límite de empleados' },
|
||||
{ key: 'limitServices', label: 'Límite de servicios' },
|
||||
{ key: 'limitAppointments', label: 'Límite de turnos' },
|
||||
{ key: 'limitClients', label: 'Límite de clientes' },
|
||||
{ key: 'limitRepeats', label: 'Límite de repeticiones' },
|
||||
{ key: 'discount3Months', label: 'Descuento 3 meses (%)' },
|
||||
{ key: 'discount6Months', label: 'Descuento 6 meses (%)' },
|
||||
{ key: 'discount12Months', label: 'Descuento 12 meses (%)' },
|
||||
];
|
||||
|
||||
const limitFields = new Set<keyof Plan>([
|
||||
'limitOrganizations',
|
||||
'limitEmployees',
|
||||
'limitServices',
|
||||
'limitAppointments',
|
||||
'limitClients',
|
||||
'limitRepeats',
|
||||
]);
|
||||
|
||||
const booleanFields: { key: keyof Plan; label: string }[] = [
|
||||
{ key: 'active', label: 'Activo para contratación' },
|
||||
{ key: 'featured', label: 'Destacado' },
|
||||
{ key: 'mailNotifications', label: 'Notificaciones por email' },
|
||||
{ key: 'smsNotifications', label: 'Notificaciones SMS' },
|
||||
{ key: 'wapNotifications', label: 'Notificaciones WhatsApp' },
|
||||
{ key: 'payments', label: 'Pagos habilitados' },
|
||||
{ key: 'bot', label: 'Bot habilitado' },
|
||||
{ key: 'dateLimit', label: 'Tiene límite de fecha' },
|
||||
];
|
||||
|
||||
export const EditPlanModal = ({ plan, onClose, onSaved }: EditPlanModalProps) => {
|
||||
const [formData, setFormData] = useState<Plan>({
|
||||
...plan,
|
||||
features: Array.isArray(plan.features) ? plan.features : [],
|
||||
});
|
||||
const [featuresText, setFeaturesText] = useState(formData.features.join('\n'));
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const planId = plan._id || plan.id || '';
|
||||
|
||||
const setField = (key: keyof Plan, value: string | number | boolean | string[]) => {
|
||||
setFormData({ ...formData, [key]: value });
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
await updatePlan({
|
||||
planId,
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
code: formData.code,
|
||||
price: Number(formData.price) || 0,
|
||||
annualPrice: Number(formData.annualPrice) || 0,
|
||||
limitOrganizations: Number(formData.limitOrganizations) || 0,
|
||||
limitEmployees: Number(formData.limitEmployees) || 0,
|
||||
limitServices: Number(formData.limitServices) || 0,
|
||||
limitAppointments: Number(formData.limitAppointments) || 0,
|
||||
limitClients: Number(formData.limitClients) || 0,
|
||||
limitRepeats: Number(formData.limitRepeats) || 0,
|
||||
discount3Months: Number(formData.discount3Months) || 0,
|
||||
discount6Months: Number(formData.discount6Months) || 0,
|
||||
discount12Months: Number(formData.discount12Months) || 0,
|
||||
active: Boolean(formData.active),
|
||||
featured: Boolean(formData.featured),
|
||||
mailNotifications: Boolean(formData.mailNotifications),
|
||||
smsNotifications: Boolean(formData.smsNotifications),
|
||||
wapNotifications: Boolean(formData.wapNotifications),
|
||||
payments: Boolean(formData.payments),
|
||||
bot: Boolean(formData.bot),
|
||||
dateLimit: Boolean(formData.dateLimit),
|
||||
features: featuresText.split('\n').map(feature => feature.trim()).filter(Boolean),
|
||||
});
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.message || err.message || 'Error guardando el plan');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 50, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)' }}>
|
||||
<motion.div initial={{ scale: 0.9, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} className="glass-panel" style={{ width: '94%', maxWidth: '860px', maxHeight: '90vh', overflowY: 'auto', padding: '2rem', position: 'relative' }}>
|
||||
<button onClick={onClose} style={{ position: 'absolute', top: '1.5rem', right: '1.5rem', background: 'transparent', color: 'var(--text-muted)' }}>
|
||||
<X size={24} />
|
||||
</button>
|
||||
|
||||
<h2 style={{ fontSize: '1.5rem', marginBottom: '1.5rem' }}>Editar plan: {plan.name}</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '1rem' }}>
|
||||
<div>
|
||||
<label className="modal-label">Nombre</label>
|
||||
<input className="input-glass" value={formData.name} onChange={e => setField('name', e.target.value)} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="modal-label">Código</label>
|
||||
<input className="input-glass" value={formData.code} onChange={e => setField('code', e.target.value)} required />
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label className="modal-label">Descripción</label>
|
||||
<textarea className="input-glass" rows={3} value={formData.description} onChange={e => setField('description', e.target.value)} required />
|
||||
</div>
|
||||
|
||||
<h3 className="plan-modal-section">Precios, límites y descuentos</h3>
|
||||
{numberFields.map(field => (
|
||||
<div key={field.key}>
|
||||
<label className="modal-label">{field.label}</label>
|
||||
<input className="input-glass" type="number" min={limitFields.has(field.key) ? undefined : '0'} step="0.01" value={Number(formData[field.key] || 0)} onChange={e => setField(field.key, Number(e.target.value))} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
<h3 className="plan-modal-section">Funciones y estado</h3>
|
||||
{booleanFields.map(field => (
|
||||
<label key={field.key} className="plan-toggle">
|
||||
<input type="checkbox" checked={Boolean(formData[field.key])} onChange={e => setField(field.key, e.target.checked)} />
|
||||
{field.label}
|
||||
</label>
|
||||
))}
|
||||
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label className="modal-label">Features visibles (una por línea)</label>
|
||||
<textarea className="input-glass" rows={5} value={featuresText} onChange={e => setFeaturesText(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div style={{ gridColumn: '1 / -1', marginTop: '1rem', display: 'flex', justifyContent: 'flex-end', gap: '1rem' }}>
|
||||
<button type="button" className="btn-secondary" onClick={onClose}>Cancelar</button>
|
||||
<button type="submit" className="btn-primary" disabled={loading || !planId}>{loading ? 'Guardando...' : 'Guardar cambios'}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style dangerouslySetInnerHTML={{__html: `
|
||||
.modal-label { display: block; font-size: 0.85rem; color: var(--text-muted); margin-bottom: 0.5rem; }
|
||||
.plan-modal-section { grid-column: 1 / -1; margin: 1rem 0 0.25rem; color: var(--primary); font-size: 1.05rem; }
|
||||
.plan-toggle { display: flex; align-items: center; gap: 0.6rem; color: var(--text-main); background: rgba(255,255,255,0.04); border: 1px solid var(--glass-border); border-radius: 10px; padding: 0.75rem; }
|
||||
.plan-toggle input { width: 16px; height: 16px; }
|
||||
@media (max-width: 768px) { form { grid-template-columns: 1fr !important; } }
|
||||
`}} />
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { RefreshCw, X } from 'lucide-react';
|
||||
import { recalculatePlanUsageCycle, type RecalculatePlanUsageCycleResult } from '../../api/sysadmin';
|
||||
import { formatDateTime } from '../../utils/formatters';
|
||||
|
||||
export const RecalculationToolsModal = ({ initialUserId = '', onClose }: { initialUserId?: string, onClose: () => void }) => {
|
||||
const [usageUserId, setUsageUserId] = useState(initialUserId);
|
||||
const [usageResult, setUsageResult] = useState<RecalculatePlanUsageCycleResult | null>(null);
|
||||
const [usageLoading, setUsageLoading] = useState(false);
|
||||
const [usageError, setUsageError] = useState('');
|
||||
|
||||
const handleRecalculatePlanUsage = async () => {
|
||||
const trimmedUserId = usageUserId.trim();
|
||||
if (!trimmedUserId) {
|
||||
setUsageError('Ingresá un userId para recalcular el uso del plan.');
|
||||
return;
|
||||
}
|
||||
|
||||
setUsageUserId(trimmedUserId);
|
||||
setUsageLoading(true);
|
||||
setUsageError('');
|
||||
setUsageResult(null);
|
||||
|
||||
try {
|
||||
const result = await recalculatePlanUsageCycle(trimmedUserId);
|
||||
setUsageResult(result);
|
||||
} catch (e: any) {
|
||||
setUsageError(e.response?.data?.message || e.message || 'Error al recalcular el uso del plan');
|
||||
} finally {
|
||||
setUsageLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 50, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)', padding: '1rem' }}>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="glass-panel"
|
||||
style={{ width: '90%', maxWidth: '760px', maxHeight: '90vh', overflowY: 'auto', padding: '2rem', position: 'relative' }}
|
||||
>
|
||||
<button onClick={onClose} style={{ position: 'absolute', top: '1.5rem', right: '1.5rem', background: 'transparent', color: 'var(--text-muted)' }}>
|
||||
<X size={24} />
|
||||
</button>
|
||||
|
||||
<h2 style={{ fontSize: '1.5rem', marginBottom: '0.5rem', display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<RefreshCw size={24} color="var(--primary)" />
|
||||
Herramientas de recálculo
|
||||
</h2>
|
||||
<p style={{ color: 'var(--text-muted)', marginBottom: '1.5rem' }}>Funciones exclusivas de sysadmin para reparar o reconstruir contadores.</p>
|
||||
|
||||
<div style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid var(--glass-border)', borderRadius: '12px', padding: '1.25rem' }}>
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<h3 style={{ fontSize: '1.1rem', fontWeight: '700', marginBottom: '0.35rem' }}>Recalcular uso del plan</h3>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>Recuenta las reservas del ciclo actual para el userId seleccionado.</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleRecalculatePlanUsage(); }} style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||
<div style={{ flex: '1 1 300px' }}>
|
||||
<label className="modal-label">User ID</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-glass"
|
||||
placeholder="Pegá o seleccioná un userId"
|
||||
value={usageUserId}
|
||||
onChange={(e) => setUsageUserId(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn-primary" disabled={usageLoading} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<RefreshCw size={18} /> {usageLoading ? 'Recalculando...' : 'Recalcular'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{usageError && <div style={{ marginTop: '1rem', color: 'var(--danger)', fontSize: '0.9rem' }}>{usageError}</div>}
|
||||
|
||||
{usageResult && (
|
||||
<div style={{ marginTop: '1rem', display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<div><strong>User ID</strong><br /><span className="long-id-value">{usageResult.userId}</span></div>
|
||||
<div><strong>Subscription ID</strong><br /><span className="long-id-value">{usageResult.subscriptionId}</span></div>
|
||||
<div><strong>Plan ID</strong><br /><span className="long-id-value">{usageResult.planId}</span></div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '0.75rem' }}>
|
||||
<div><strong>Cycle Start</strong><br /><span style={{ color: 'var(--text-muted)' }}>{formatDateTime(usageResult.cycleStart)}</span></div>
|
||||
<div><strong>Cycle End</strong><br /><span style={{ color: 'var(--text-muted)' }}>{formatDateTime(usageResult.cycleEnd)}</span></div>
|
||||
<div><strong>Appointments</strong><br /><span style={{ color: 'var(--success)', fontWeight: 700 }}>{usageResult.appointmentsCount}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style dangerouslySetInnerHTML={{__html: `
|
||||
.modal-label {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.long-id-value {
|
||||
display: block;
|
||||
color: var(--text-muted);
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
line-height: 1.45;
|
||||
}
|
||||
`}} />
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { CreditCard, X } from 'lucide-react';
|
||||
import { extendUserSubscription, getUserSubscriptionDetails, type UserSubscriptionDetails } from '../../api/sysadmin';
|
||||
import { formatDateTime } from '../../utils/formatters';
|
||||
|
||||
type Props = {
|
||||
user: any;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const SubscriptionDetailsModal = ({ user, onClose }: Props) => {
|
||||
const [details, setDetails] = useState<UserSubscriptionDetails | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [extensionDate, setExtensionDate] = useState('');
|
||||
const [extensionLoading, setExtensionLoading] = useState(false);
|
||||
const [extensionMessage, setExtensionMessage] = useState('');
|
||||
const [extensionError, setExtensionError] = useState('');
|
||||
|
||||
const formatAmount = (value?: number) => {
|
||||
if (typeof value !== 'number') return '-';
|
||||
return value.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' });
|
||||
};
|
||||
|
||||
const loadDetails = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await getUserSubscriptionDetails(user._id);
|
||||
setDetails(result);
|
||||
} catch (e: any) {
|
||||
setError(e.response?.data?.message || e.message || 'Error cargando suscripción');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadDetails();
|
||||
}, [user._id]);
|
||||
|
||||
const handleExtend = async (payload: { mode: 'add_months' | 'set_end_date'; months?: 1 | 2; endDate?: string }) => {
|
||||
if (!details?.currentSubscription) return;
|
||||
|
||||
setExtensionLoading(true);
|
||||
setExtensionError('');
|
||||
setExtensionMessage('');
|
||||
try {
|
||||
const result = await extendUserSubscription({
|
||||
userId: user._id,
|
||||
reason: 'Ajuste manual excepcional desde sysadmin-cli',
|
||||
...payload,
|
||||
});
|
||||
setExtensionMessage(`Vencimiento actualizado hasta ${formatDateTime(result.newEndDate)}. Se registró una bonificación $0.`);
|
||||
await loadDetails();
|
||||
} catch (e: any) {
|
||||
setExtensionError(e.response?.data?.message || e.message || 'Error extendiendo suscripción');
|
||||
} finally {
|
||||
setExtensionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const subscription = details?.currentSubscription;
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 50, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)', padding: '1rem' }}>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="glass-panel"
|
||||
style={{ width: '90%', maxWidth: '860px', maxHeight: '90vh', overflowY: 'auto', padding: '2rem', position: 'relative' }}
|
||||
>
|
||||
<button onClick={onClose} style={{ position: 'absolute', top: '1.5rem', right: '1.5rem', background: 'transparent', color: 'var(--text-muted)' }}>
|
||||
<X size={24} />
|
||||
</button>
|
||||
|
||||
<h2 style={{ fontSize: '1.5rem', marginBottom: '0.5rem', display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<CreditCard size={24} color="var(--primary)" />
|
||||
Suscripción del usuario
|
||||
</h2>
|
||||
<p style={{ color: 'var(--text-muted)', marginBottom: '1.5rem' }}>
|
||||
{user.firstName} {user.lastName} · {user.email}
|
||||
</p>
|
||||
|
||||
{loading && <div style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-muted)' }}>Cargando suscripción...</div>}
|
||||
{error && <div style={{ padding: '1rem', color: 'var(--danger)' }}>{error}</div>}
|
||||
|
||||
{!loading && !error && details && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<section className="subscription-card">
|
||||
<h3 className="subscription-section-title">Suscripción actual</h3>
|
||||
{!subscription ? (
|
||||
<p style={{ color: 'var(--text-muted)' }}>No hay suscripción registrada para este usuario.</p>
|
||||
) : (
|
||||
<div className="subscription-grid">
|
||||
<Info label="Subscription ID" value={subscription.id} long />
|
||||
<Info label="User ID" value={details.userId} long />
|
||||
<Info label="Plan" value={subscription.plan ? `${subscription.plan.name} (${subscription.plan.code})` : 'Plan no encontrado'} />
|
||||
<Info label="Plan ID" value={subscription.plan?.id || '-'} long />
|
||||
<Info label="Precio" value={formatAmount(subscription.plan?.price)} />
|
||||
<Info label="Inicio" value={formatDateTime(subscription.startDate)} />
|
||||
<Info label="Válida hasta" value={formatDateTime(subscription.endDate)} />
|
||||
<Info label="Activa" value={subscription.isActive ? 'Sí' : 'No'} />
|
||||
<Info label="MP status" value={subscription.mpStatus || '-'} />
|
||||
<Info label="Meses facturados" value={subscription.billingMonths?.toString() || '-'} />
|
||||
<Info label="Pago pendiente" value={subscription.pendingPaymentType || '-'} />
|
||||
<Info label="Preference pendiente" value={subscription.pendingPaymentPreferenceId || '-'} long />
|
||||
<Info label="Último pago" value={subscription.lastPaymentStatus || '-'} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{subscription && (
|
||||
<section className="subscription-card">
|
||||
<h3 className="subscription-section-title">Ajuste manual excepcional</h3>
|
||||
<p style={{ color: 'var(--text-muted)', marginBottom: '1rem' }}>
|
||||
Extiende la vigencia sin pago y registra un pago de bonificación por $0 asociado a esta suscripción.
|
||||
</p>
|
||||
<div className="extension-actions">
|
||||
<button className="btn btn-secondary" disabled={extensionLoading} onClick={() => handleExtend({ mode: 'add_months', months: 1 })}>
|
||||
Extender 1 mes
|
||||
</button>
|
||||
<button className="btn btn-secondary" disabled={extensionLoading} onClick={() => handleExtend({ mode: 'add_months', months: 2 })}>
|
||||
Extender 2 meses
|
||||
</button>
|
||||
<div className="extension-date-action">
|
||||
<input
|
||||
type="date"
|
||||
value={extensionDate}
|
||||
onChange={(event) => setExtensionDate(event.target.value)}
|
||||
disabled={extensionLoading}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={extensionLoading || !extensionDate}
|
||||
onClick={() => handleExtend({ mode: 'set_end_date', endDate: extensionDate })}
|
||||
>
|
||||
Establecer vencimiento
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{extensionLoading && <p style={{ color: 'var(--text-muted)', marginTop: '0.75rem' }}>Aplicando ajuste...</p>}
|
||||
{extensionMessage && <p style={{ color: 'var(--success)', marginTop: '0.75rem' }}>{extensionMessage}</p>}
|
||||
{extensionError && <p style={{ color: 'var(--danger)', marginTop: '0.75rem' }}>{extensionError}</p>}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="subscription-card">
|
||||
<h3 className="subscription-section-title">Pagos</h3>
|
||||
{details.payments.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-muted)' }}>No hay pagos registrados para este usuario.</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{details.payments.map((payment) => (
|
||||
<div key={payment.id} className="payment-row">
|
||||
<Info label="Payment ID" value={payment.id} long />
|
||||
<Info label="Subscription ID" value={payment.subscriptionId} long />
|
||||
<Info label="Monto" value={formatAmount(payment.amount)} />
|
||||
<Info label="Fecha" value={formatDateTime(payment.paymentDate)} />
|
||||
<Info label="Método" value={payment.paymentMethod || '-'} />
|
||||
<Info label="Estado" value={payment.status} />
|
||||
<Info label="Transaction ID" value={payment.transactionId || '-'} long />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style dangerouslySetInnerHTML={{__html: `
|
||||
.subscription-card {
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.subscription-section-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.subscription-grid,
|
||||
.payment-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
.payment-row {
|
||||
border-top: 1px solid var(--glass-border);
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
.subscription-label {
|
||||
display: block;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.subscription-value {
|
||||
display: block;
|
||||
color: var(--text-main);
|
||||
font-weight: 600;
|
||||
}
|
||||
.subscription-value.long {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.extension-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
.extension-date-action {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
.extension-date-action input {
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 8px;
|
||||
color: var(--text-main);
|
||||
padding: 0.65rem 0.75rem;
|
||||
}
|
||||
`}} />
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Info = ({ label, value, long = false }: { label: string; value: string; long?: boolean }) => (
|
||||
<div>
|
||||
<span className="subscription-label">{label}</span>
|
||||
<span className={`subscription-value${long ? ' long' : ''}`}>{value}</span>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +1,20 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Search, Edit2, Trash2, CheckCircle, XCircle, Building2, ChevronUp, ChevronDown, RotateCcw } from 'lucide-react';
|
||||
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({
|
||||
@@ -23,6 +28,8 @@ export const UsersPage = () => {
|
||||
// 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);
|
||||
@@ -46,6 +53,15 @@ export const UsersPage = () => {
|
||||
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);
|
||||
@@ -107,10 +123,23 @@ export const UsersPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
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 */}
|
||||
@@ -179,9 +208,9 @@ export const UsersPage = () => {
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={5} style={{ padding: '2rem', textAlign: 'center' }}>Cargando...</td></tr>
|
||||
<tr><td colSpan={6} style={{ padding: '2rem', textAlign: 'center' }}>Cargando...</td></tr>
|
||||
) : users.length === 0 ? (
|
||||
<tr><td colSpan={5} style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-muted)' }}>No se encontraron usuarios</td></tr>
|
||||
<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">
|
||||
@@ -205,13 +234,43 @@ export const UsersPage = () => {
|
||||
{u.verificationCode || '-'}
|
||||
</td>
|
||||
<td style={{ padding: '1rem', fontSize: '0.9rem', color: 'var(--text-muted)' }}>
|
||||
{new Date(u.creationDate).toLocaleDateString()}
|
||||
{formatDate(u.creationDate)}
|
||||
</td>
|
||||
<td style={{ padding: '1rem', textAlign: 'right' }}>
|
||||
<div style={{ display: 'flex', gap: '10px', justifyContent: 'flex-end' }}>
|
||||
<button className="icon-btn" title="Ver Organizaciones" onClick={() => setOrgUser(u)}><Building2 size={18} /></button>
|
||||
<button className="icon-btn" title="Editar Perfil" onClick={() => setEditingUser(u)}><Edit2 size={18} /></button>
|
||||
<button className="icon-btn danger" title="Eliminar" onClick={() => handleDelete(u)}><Trash2 size={18} /></button>
|
||||
<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>
|
||||
@@ -261,6 +320,51 @@ export const UsersPage = () => {
|
||||
.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);
|
||||
@@ -293,6 +397,8 @@ export const UsersPage = () => {
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { format, isValid, parseISO } from 'date-fns';
|
||||
import { es } from 'date-fns/locale';
|
||||
|
||||
const parseDate = (value?: string) => {
|
||||
if (!value) return null;
|
||||
const parsedDate = parseISO(value);
|
||||
return isValid(parsedDate) ? parsedDate : null;
|
||||
};
|
||||
|
||||
export const formatDate = (value?: string) => {
|
||||
const parsedDate = parseDate(value);
|
||||
return parsedDate ? format(parsedDate, 'dd MMM yyyy', { locale: es }) : value || '-';
|
||||
};
|
||||
|
||||
export const formatDateTime = (value?: string) => {
|
||||
const parsedDate = parseDate(value);
|
||||
return parsedDate ? format(parsedDate, "dd MMM yyyy, HH:mm 'hs'", { locale: es }) : value || '-';
|
||||
};
|
||||
Reference in New Issue
Block a user