feat: implement sysadmin subscription management and plan usage cycle recalculation tools

This commit is contained in:
2026-07-18 21:32:47 -03:00
parent 1c8c47cf76
commit 4f3b56784e
36 changed files with 2146 additions and 21 deletions
+2
View File
@@ -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>
+113
View File
@@ -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>
);
+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>
);
};
+114 -8
View File
@@ -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>
);
};
+18
View File
@@ -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 || '-';
};