feat: implement sysadmin subscription management and plan usage cycle recalculation tools
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user