Initial commit - Horux Despachos NL
This commit is contained in:
142
apps/web/app/(dashboard)/contribuyentes/addons-dialog.tsx
Normal file
142
apps/web/app/(dashboard)/contribuyentes/addons-dialog.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
'use client';
|
||||
|
||||
import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@horux/shared-ui';
|
||||
import { Sparkles, Check, ExternalLink } from 'lucide-react';
|
||||
import { useMyAddons, useSubscribeAddon, useCancelAddon } from '@/lib/hooks/use-addons';
|
||||
|
||||
/**
|
||||
* Catálogo de add-ons disponibles **por contribuyente**. No se ofrecen aquí
|
||||
* los add-ons tenant-level (maxRfcs, timbres, etc.) — esos viven en la
|
||||
* pantalla de suscripción del despacho.
|
||||
*/
|
||||
const ADDONS_POR_CONTRIBUYENTE: Array<{
|
||||
codename: string;
|
||||
nombre: string;
|
||||
descripcion: string;
|
||||
precio: number;
|
||||
frecuencia: 'mensual';
|
||||
}> = [
|
||||
{
|
||||
codename: 'lolita_ia_contribuyente',
|
||||
nombre: 'Lolita IA',
|
||||
descripcion: 'Agente IA fiscal dedicado al RFC. Responde dudas, sugiere optimizaciones, prepara resúmenes.',
|
||||
precio: 250,
|
||||
frecuencia: 'mensual',
|
||||
},
|
||||
];
|
||||
|
||||
export function AddonsDialog({
|
||||
target,
|
||||
onClose,
|
||||
}: {
|
||||
target: { id: string; nombre: string } | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { data, isLoading } = useMyAddons(target?.id);
|
||||
const subscribeMut = useSubscribeAddon();
|
||||
const cancelMut = useCancelAddon();
|
||||
|
||||
const handleSubscribe = async (codename: string) => {
|
||||
if (!target) return;
|
||||
try {
|
||||
const result = await subscribeMut.mutateAsync({
|
||||
addonCodename: codename,
|
||||
contribuyenteId: target.id,
|
||||
});
|
||||
if (result.paymentUrl) {
|
||||
window.open(result.paymentUrl, '_blank');
|
||||
}
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.message || err.message || 'Error al contratar add-on');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async (addonId: string, nombre: string) => {
|
||||
if (!confirm(`¿Cancelar ${nombre}? Se deja de cobrar al final del período actual.`)) return;
|
||||
try {
|
||||
await cancelMut.mutateAsync(addonId);
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.message || err.message || 'Error al cancelar');
|
||||
}
|
||||
};
|
||||
|
||||
const fmtMoney = (n: number) => n.toLocaleString('es-MX', { style: 'currency', currency: 'MXN', minimumFractionDigits: 0 });
|
||||
const fmtDate = (iso: string | null) => iso ? new Date(iso).toLocaleDateString('es-MX', { day: '2-digit', month: 'short', year: 'numeric' }) : '—';
|
||||
|
||||
const activeAddons = data?.addons ?? [];
|
||||
|
||||
return (
|
||||
<Dialog open={!!target} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2"><Sparkles className="h-5 w-5 text-amber-500" /> Add-ons — {target?.nombre}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4 space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Servicios adicionales de cobro mensual que se contratan por contribuyente. El cobro va en un preapproval MercadoPago independiente — se puede cancelar sin afectar la licencia anual del despacho.
|
||||
</p>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Cargando add-ons activos...</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{ADDONS_POR_CONTRIBUYENTE.map((a) => {
|
||||
const active = activeAddons.find((x) => x.codename === a.codename);
|
||||
const isActive = active && (active.status === 'authorized' || active.status === 'pending');
|
||||
return (
|
||||
<div key={a.codename} className="border rounded-lg p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold">{a.nombre}</h3>
|
||||
{isActive && (
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${active.status === 'authorized' ? 'bg-green-100 text-green-700' : 'bg-amber-100 text-amber-700'}`}>
|
||||
{active.status === 'authorized' ? <><Check className="inline h-3 w-3 mr-1" />Activo</> : 'Pendiente de pago'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">{a.descripcion}</p>
|
||||
<p className="text-sm font-medium mt-2">{fmtMoney(a.precio)}<span className="text-muted-foreground font-normal"> / mes</span></p>
|
||||
{isActive && active.currentPeriodEnd && (
|
||||
<p className="text-xs text-muted-foreground mt-1">Próximo cobro: {fmtDate(active.currentPeriodEnd)}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{isActive ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleCancel(active.id, a.nombre)}
|
||||
disabled={cancelMut.isPending}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleSubscribe(a.codename)}
|
||||
disabled={subscribeMut.isPending}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
Contratar <ExternalLink className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground border-t pt-3">
|
||||
Al contratar abre una pestaña de MercadoPago para autorizar el cobro recurrente. Si no tienes una suscripción activa del despacho, el add-on no podrá crearse.
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>Cerrar</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
156
apps/web/app/(dashboard)/contribuyentes/page.tsx
Normal file
156
apps/web/app/(dashboard)/contribuyentes/page.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
'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">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user