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
157 lines
8.1 KiB
TypeScript
157 lines
8.1 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { Button, Input, Label, Card, CardContent, Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@horux/shared-ui';
|
|
import { useContribuyentes, useCreateContribuyente, useUpdateContribuyente, useDeactivateContribuyente } from '@/lib/hooks/use-contribuyentes';
|
|
import type { CreateContribuyenteData } from '@/lib/api/contribuyentes';
|
|
import { useAuthStore } from '@/stores/auth-store';
|
|
import { apiClient } from '@/lib/api/client';
|
|
import { Plus, Pencil, Trash2, Building2, Sparkles } from 'lucide-react';
|
|
import { AddonsDialog } from './addons-dialog';
|
|
|
|
const TRIAL_LIMIT_TOOLTIP = 'Límite de contribuyentes para la prueba gratuita, para continuar agregando contribuyentes, selecciona un plan.';
|
|
|
|
export default function ContribuyentesPage() {
|
|
const { user } = useAuthStore();
|
|
const { data: contribuyentes, isLoading } = useContribuyentes();
|
|
const createMut = useCreateContribuyente();
|
|
const updateMut = useUpdateContribuyente();
|
|
const deactivateMut = useDeactivateContribuyente();
|
|
|
|
const [showDialog, setShowDialog] = useState(false);
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
const [form, setForm] = useState<CreateContribuyenteData>({ rfc: '', razonSocial: '' });
|
|
const [assignSelf, setAssignSelf] = useState(true);
|
|
const [addonsTarget, setAddonsTarget] = useState<{ id: string; nombre: string } | null>(null);
|
|
|
|
// Trial gate: durante el periodo de prueba el despacho no puede agregar más
|
|
// de 5 contribuyentes activos. El backend valida también; aquí solo se
|
|
// deshabilita el botón con tooltip explicativo.
|
|
const { data: planInfo } = useQuery({
|
|
queryKey: ['my-plan-info'],
|
|
queryFn: () => apiClient.get<{ isTrialActive: boolean }>('/despachos/me/plan').then(r => r.data),
|
|
});
|
|
const activeCount = (contribuyentes ?? []).filter((c: any) => c.active !== false).length;
|
|
const trialAtLimit = (planInfo?.isTrialActive ?? false) && activeCount >= 5;
|
|
|
|
const resetForm = () => { setForm({ rfc: '', razonSocial: '' }); setAssignSelf(true); setShowDialog(false); setEditingId(null); };
|
|
|
|
const handleSave = async () => {
|
|
try {
|
|
if (editingId) {
|
|
await updateMut.mutateAsync({ id: editingId, data: form });
|
|
} else {
|
|
const created = await createMut.mutateAsync({
|
|
...form,
|
|
supervisorUserId: assignSelf ? user?.id : undefined,
|
|
});
|
|
// Overage Business Cloud: si el 4º+ RFC disparó un nuevo addon, abre
|
|
// MercadoPago para autorizar el cobro recurrente mensual de $45/RFC.
|
|
if (created.overage?.action === 'created' && created.overage.paymentUrl) {
|
|
alert(`Se agregó un cobro mensual de $45 por contribuyente adicional (${created.overage.overageCount} extra${created.overage.overageCount === 1 ? '' : 's'}). Autoriza el pago en MercadoPago.`);
|
|
window.open(created.overage.paymentUrl, '_blank');
|
|
} else if (created.overage?.action === 'updated') {
|
|
alert(`Se actualizó el cobro mensual de contribuyentes adicionales a ${created.overage.overageCount} extra${created.overage.overageCount === 1 ? '' : 's'} ($${created.overage.overageCount * 45}/mes).`);
|
|
}
|
|
}
|
|
resetForm();
|
|
} catch (err: any) { alert(err.response?.data?.message || 'Error'); }
|
|
};
|
|
|
|
const handleDeactivate = async (id: string, rfc: string) => {
|
|
if (!confirm(`¿Desactivar contribuyente ${rfc}?`)) return;
|
|
try {
|
|
const result = await deactivateMut.mutateAsync(id);
|
|
if (result.overage?.action === 'cancelled') {
|
|
alert('Se canceló el cobro mensual de contribuyente adicional (volviste a los 3 incluidos).');
|
|
} else if (result.overage?.action === 'updated') {
|
|
alert(`El cobro de contribuyentes adicionales se actualizó a ${result.overage.overageCount} extra${result.overage.overageCount === 1 ? '' : 's'} ($${result.overage.overageCount * 45}/mes).`);
|
|
}
|
|
} catch (err: any) { alert(err.response?.data?.message || 'Error'); }
|
|
};
|
|
|
|
const openEdit = (c: any) => {
|
|
setForm({ rfc: c.rfc, razonSocial: c.nombre });
|
|
setEditingId(c.id);
|
|
setShowDialog(true);
|
|
};
|
|
|
|
return (
|
|
<div className="p-6 space-y-6 max-w-7xl mx-auto">
|
|
<div className="flex items-center justify-between">
|
|
<div><h1 className="text-2xl font-bold">Contribuyentes</h1><p className="text-sm text-muted-foreground">RFCs que gestiona tu despacho</p></div>
|
|
<Button
|
|
onClick={() => { resetForm(); setShowDialog(true); }}
|
|
disabled={trialAtLimit}
|
|
title={trialAtLimit ? TRIAL_LIMIT_TOOLTIP : undefined}
|
|
className="flex items-center gap-2"
|
|
>
|
|
<Plus className="h-4 w-4" /> Agregar RFC
|
|
</Button>
|
|
</div>
|
|
|
|
{isLoading ? <p className="text-muted-foreground">Cargando...</p> : !contribuyentes || contribuyentes.length === 0 ? (
|
|
<Card><CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
|
<Building2 className="h-12 w-12 text-muted-foreground mb-4" />
|
|
<h3 className="text-lg font-semibold">Sin contribuyentes</h3>
|
|
<p className="text-sm text-muted-foreground mt-1 mb-4">Agrega el primer RFC para empezar.</p>
|
|
<Button
|
|
onClick={() => { resetForm(); setShowDialog(true); }}
|
|
disabled={trialAtLimit}
|
|
title={trialAtLimit ? TRIAL_LIMIT_TOOLTIP : undefined}
|
|
>
|
|
Agregar primer RFC
|
|
</Button>
|
|
</CardContent></Card>
|
|
) : (
|
|
<div className="grid gap-3 lg:grid-cols-2 3xl:grid-cols-3 4xl:grid-cols-4">{contribuyentes.map((c) => (
|
|
<Card key={c.id}><CardContent className="flex items-center justify-between py-4 px-6">
|
|
<div>
|
|
<p className="font-semibold">{c.nombre}</p>
|
|
<p className="text-sm text-muted-foreground font-mono">{c.rfc}</p>
|
|
{c.regimenFiscal && <p className="text-xs text-muted-foreground mt-1">Régimen: {c.regimenFiscal}</p>}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="ghost" size="sm" onClick={() => setAddonsTarget({ id: c.id, nombre: c.nombre })} title="Add-ons"><Sparkles className="h-4 w-4" /></Button>
|
|
<Button variant="ghost" size="sm" onClick={() => openEdit(c)}><Pencil className="h-4 w-4" /></Button>
|
|
<Button variant="ghost" size="sm" onClick={() => handleDeactivate(c.id, c.rfc)} className="text-destructive hover:text-destructive"><Trash2 className="h-4 w-4" /></Button>
|
|
</div>
|
|
</CardContent></Card>
|
|
))}</div>
|
|
)}
|
|
|
|
<AddonsDialog target={addonsTarget} onClose={() => setAddonsTarget(null)} />
|
|
|
|
<Dialog open={showDialog} onOpenChange={() => resetForm()}>
|
|
<DialogContent>
|
|
<DialogHeader><DialogTitle>{editingId ? 'Editar contribuyente' : 'Agregar contribuyente'}</DialogTitle></DialogHeader>
|
|
<div className="space-y-4 py-4">
|
|
<div><Label>RFC</Label><Input value={form.rfc} onChange={(e) => setForm((p) => ({ ...p, rfc: e.target.value }))} placeholder="ABC010203XY1" maxLength={13} disabled={!!editingId} /></div>
|
|
<div><Label>Razón social</Label><Input value={form.razonSocial} onChange={(e) => setForm((p) => ({ ...p, razonSocial: e.target.value }))} placeholder="Empresa SA de CV" /></div>
|
|
{!editingId && (
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
id="assignSelf"
|
|
checked={assignSelf}
|
|
onChange={(e) => setAssignSelf(e.target.checked)}
|
|
className="h-4 w-4"
|
|
/>
|
|
<label htmlFor="assignSelf" className="text-sm text-muted-foreground">
|
|
Asignarme como supervisor de este RFC
|
|
</label>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={resetForm}>Cancelar</Button>
|
|
<Button onClick={handleSave} disabled={createMut.isPending || updateMut.isPending}>{editingId ? 'Guardar' : 'Agregar'}</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|
|
|