- Add Sheet primitive component for mobile drawers - Add MobileNav with hamburger menu for dashboard layout - Hide desktop sidebars on mobile; show mobile header - Make dashboard header responsive with stacked layout on small screens - Hide selector text on mobile, show icons only - Convert fixed-width filters to responsive widths (CFDI, Clientes, Admin, Documentos, Alertas) - Cap dialog widths to 95vw on mobile (CFDI viewer, Documentos, Reportes, Contribuyentes, Facturación) - Make calendar grid smaller and use single-letter weekdays on mobile - Update viewport to include viewport-fit=cover for Samsung safe areas
143 lines
6.0 KiB
TypeScript
143 lines
6.0 KiB
TypeScript
'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-[95vw] md: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>
|
|
);
|
|
}
|