Backend: - Notificación email al admin cuando llega primer pago aprobado (sin factura auto) - Endpoints GET /pagos-sin-factura y POST /emitir-factura-pago para admin global - Fix vinculación org Facturapi Horux 360 (69f23a5a242e0af47a41fa0d) - Fix webhook MP: validación defensiva de x-signature header - Fix autocompleto RFCs: eliminado filtro por contribuyenteId - Fix autocompleto conceptos: eliminado filtro por contribuyenteId - SAT fixes: anti-bot CSF scraper, request reuse, date range fix, stale job thresholds - SAT sync request reuse across jobs para evitar agotar cuota diaria - Typo fix MP_ACCESS_TOKEN en .env - Trial invitations system backend Frontend: - Nueva página /admin/facturas-pendientes con tabla y emisión manual - Métrica 'Facturas pendientes' en /clientes (clickable) - Navegación onboarding FIEL/CSD corregida - Sidebar themes sincronizados - Fix SAT portal migration scraper (NetIQ) - Trial invitation acceptance pages
261 lines
9.3 KiB
TypeScript
261 lines
9.3 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { Button, Input, Label, Card, CardContent, CardHeader, CardTitle, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@horux/shared-ui';
|
|
import { getAllInvitations, createInvitation, cancelInvitation } from '@/lib/api/trial-invitations';
|
|
import { getTenants } from '@/lib/api/tenants';
|
|
import { Gift, X, Clock, CheckCircle2, AlertTriangle, Loader2 } from 'lucide-react';
|
|
|
|
interface TenantOption {
|
|
id: string;
|
|
nombre: string;
|
|
rfc: string;
|
|
}
|
|
|
|
interface Invitation {
|
|
id: string;
|
|
tenantId: string;
|
|
plan: string;
|
|
durationDays: number;
|
|
status: string;
|
|
token: string;
|
|
sentAt: string;
|
|
expiresAt: string;
|
|
acceptedAt: string | null;
|
|
tenant: {
|
|
nombre: string;
|
|
rfc: string;
|
|
} | null;
|
|
}
|
|
|
|
export default function InvitacionesTrialPage() {
|
|
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
|
const [invitations, setInvitations] = useState<Invitation[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [creating, setCreating] = useState(false);
|
|
const [selectedTenantId, setSelectedTenantId] = useState('');
|
|
const [durationDays, setDurationDays] = useState('30');
|
|
const [plan, setPlan] = useState('business_control');
|
|
const [message, setMessage] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, []);
|
|
|
|
async function loadData() {
|
|
setLoading(true);
|
|
try {
|
|
const [tenantsData, invitationsData] = await Promise.all([
|
|
getTenants(),
|
|
getAllInvitations(),
|
|
]);
|
|
setTenants(tenantsData);
|
|
setInvitations(invitationsData);
|
|
} catch (err: any) {
|
|
setMessage({ kind: 'err', text: err?.response?.data?.message || 'Error al cargar datos' });
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handleCreate() {
|
|
if (!selectedTenantId || !durationDays) {
|
|
setMessage({ kind: 'err', text: 'Selecciona un despacho y duración' });
|
|
return;
|
|
}
|
|
setCreating(true);
|
|
setMessage(null);
|
|
try {
|
|
await createInvitation({
|
|
tenantId: selectedTenantId,
|
|
plan,
|
|
durationDays: parseInt(durationDays, 10),
|
|
});
|
|
setMessage({ kind: 'ok', text: 'Invitación enviada correctamente' });
|
|
setSelectedTenantId('');
|
|
setDurationDays('30');
|
|
loadData();
|
|
} catch (err: any) {
|
|
setMessage({ kind: 'err', text: err?.response?.data?.message || 'Error al crear invitación' });
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
}
|
|
|
|
async function handleCancel(id: string) {
|
|
if (!confirm('¿Seguro que quieres cancelar esta invitación?')) return;
|
|
try {
|
|
await cancelInvitation(id);
|
|
setMessage({ kind: 'ok', text: 'Invitación cancelada' });
|
|
loadData();
|
|
} catch (err: any) {
|
|
setMessage({ kind: 'err', text: err?.response?.data?.message || 'Error al cancelar' });
|
|
}
|
|
}
|
|
|
|
function statusIcon(status: string) {
|
|
switch (status) {
|
|
case 'pending': return <Clock className="h-4 w-4 text-amber-500" />;
|
|
case 'accepted': return <CheckCircle2 className="h-4 w-4 text-green-500" />;
|
|
case 'expired': return <AlertTriangle className="h-4 w-4 text-red-500" />;
|
|
case 'cancelled': return <X className="h-4 w-4 text-gray-500" />;
|
|
default: return null;
|
|
}
|
|
}
|
|
|
|
function statusLabel(status: string) {
|
|
switch (status) {
|
|
case 'pending': return 'Pendiente';
|
|
case 'accepted': return 'Aceptada';
|
|
case 'expired': return 'Expirada';
|
|
case 'cancelled': return 'Cancelada';
|
|
default: return status;
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="p-6 max-w-6xl mx-auto space-y-8">
|
|
<div>
|
|
<h1 className="text-2xl font-bold flex items-center gap-2">
|
|
<Gift className="h-6 w-6" />
|
|
Invitaciones de Trial
|
|
</h1>
|
|
<p className="text-muted-foreground">Envía invitaciones de prueba a despachos específicos</p>
|
|
</div>
|
|
|
|
{/* Toast de resultado */}
|
|
{message && (
|
|
<div
|
|
className={`max-w-3xl rounded-lg px-4 py-3 text-sm ${
|
|
message.kind === 'ok'
|
|
? 'bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 text-green-800 dark:text-green-300'
|
|
: 'bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-300'
|
|
}`}
|
|
>
|
|
{message.text}
|
|
</div>
|
|
)}
|
|
|
|
{/* Formulario de creación */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">Nueva invitación</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="grid md:grid-cols-3 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>Despacho</Label>
|
|
<Select value={selectedTenantId} onValueChange={setSelectedTenantId}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Selecciona un despacho" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{tenants.map((t) => (
|
|
<SelectItem key={t.id} value={t.id}>
|
|
{t.nombre} ({t.rfc})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Plan</Label>
|
|
<Select value={plan} onValueChange={setPlan}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="business_control">Business Control</SelectItem>
|
|
<SelectItem value="business_cloud">Enterprise</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Duración (días)</Label>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
max={365}
|
|
value={durationDays}
|
|
onChange={(e) => setDurationDays(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<Button onClick={handleCreate} disabled={creating}>
|
|
{creating ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Gift className="h-4 w-4 mr-2" />}
|
|
Enviar invitación
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Tabla de invitaciones */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">Historial de invitaciones</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{loading ? (
|
|
<div className="flex justify-center py-8">
|
|
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
|
</div>
|
|
) : invitations.length === 0 ? (
|
|
<p className="text-muted-foreground text-center py-8">No hay invitaciones enviadas</p>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b">
|
|
<th className="text-left py-2 px-3">Despacho</th>
|
|
<th className="text-left py-2 px-3">Plan</th>
|
|
<th className="text-left py-2 px-3">Días</th>
|
|
<th className="text-left py-2 px-3">Estado</th>
|
|
<th className="text-left py-2 px-3">Enviado</th>
|
|
<th className="text-left py-2 px-3">Expira</th>
|
|
<th className="text-left py-2 px-3"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{invitations.map((inv) => (
|
|
<tr key={inv.id} className="border-b hover:bg-muted/50">
|
|
<td className="py-2 px-3">
|
|
<div className="font-medium">{inv.tenant?.nombre || '—'}</div>
|
|
<div className="text-xs text-muted-foreground">{inv.tenant?.rfc || '—'}</div>
|
|
</td>
|
|
<td className="py-2 px-3">
|
|
{inv.plan === 'business_control' ? 'Business Control' : inv.plan === 'business_cloud' ? 'Enterprise' : inv.plan}
|
|
</td>
|
|
<td className="py-2 px-3">{inv.durationDays}</td>
|
|
<td className="py-2 px-3">
|
|
<span className="flex items-center gap-1">
|
|
{statusIcon(inv.status)}
|
|
{statusLabel(inv.status)}
|
|
</span>
|
|
</td>
|
|
<td className="py-2 px-3">
|
|
{new Date(inv.sentAt).toLocaleDateString('es-MX')}
|
|
</td>
|
|
<td className="py-2 px-3">
|
|
{new Date(inv.expiresAt).toLocaleDateString('es-MX')}
|
|
</td>
|
|
<td className="py-2 px-3">
|
|
{inv.status === 'pending' && (
|
|
<button
|
|
onClick={() => handleCancel(inv.id)}
|
|
className="text-destructive hover:underline text-xs"
|
|
>
|
|
Cancelar
|
|
</button>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|