Files
HoruxDespachosNuevo/apps/web/app/(dashboard)/contribuyentes/page.tsx
Horux Dev 1d828adc27 feat(contribuyentes): mostrar contador de RFCs disponibles del plan
- Agrega contador 'X de Y RFCs' debajo del título de la página
- Usa DESPACHO_PLANS desde @horux/shared para obtener maxRfcs del plan actual
- Durante trial muestra 'X de 5 RFCs'
- Planes ilimitados muestran solo 'X RFCs'
2026-05-25 16:20:37 +00:00

171 lines
8.7 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';
import { DESPACHO_PLANS } from '@horux/shared';
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<{ plan: string; 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;
// Contador de RFCs disponibles en el plan
const planKey = planInfo?.plan as keyof typeof DESPACHO_PLANS | undefined;
const planMaxRfcs = planKey ? DESPACHO_PLANS[planKey]?.maxRfcs ?? undefined : undefined;
const rfcCounterText = (() => {
if (planInfo?.isTrialActive) return `${activeCount} de 5 RFCs`;
if (planMaxRfcs != null && planMaxRfcs < 0) return `${activeCount} RFCs`;
if (planMaxRfcs !== undefined) return `${activeCount} de ${planMaxRfcs} RFCs`;
return `${activeCount} RFCs`;
})();
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 · {rfcCounterText}</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>
);
}