Files
HoruxDespachos/apps/web/components/impuestos/activos-fijos-tab.tsx
2026-04-27 22:09:36 -06:00

370 lines
16 KiB
TypeScript

'use client';
import { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Card, CardContent, CardHeader, CardTitle, Button, Input, Label,
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
KpiCard, cn,
} from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
import {
Wallet, Calendar, AlertTriangle, CheckCircle2, Trash2, RotateCcw,
Building2, TrendingUp, Clock, CircleSlash, Filter,
} from 'lucide-react';
import { formatCurrency } from '@/lib/utils';
interface ActivoFijoItem {
cfdiId: number;
uuid: string;
fechaEmision: string;
rfcEmisor: string;
nombreEmisor: string;
usoCfdi: string;
concepto: string;
porcentajeAnual: number;
porcentajeMensual: number;
total: number;
iva: number;
moi: number;
acumuladoHastaMesAnterior: number;
acreditableEsteMes: number;
saldoPendiente: number;
estado: 'activo' | 'agotado' | 'baja_venta' | 'baja_desecho' | 'baja_otro';
baja: { fechaBaja: string; motivo: string; comentario: string | null } | null;
}
interface Totales {
cantidad: number;
totalMoi: number;
totalAcumuladoPrevio: number;
totalEsteMes: number;
totalSaldoPendiente: number;
cantidadActivos: number;
cantidadAgotados: number;
cantidadDeBaja: number;
}
interface Response {
items: ActivoFijoItem[];
totales: Totales;
usosExcluidos: string[];
}
const USOS_DISPONIBLES: { clave: string; concepto: string }[] = [
{ clave: 'I01', concepto: 'Construcciones' },
{ clave: 'I02', concepto: 'Mobiliario y equipo de oficina' },
{ clave: 'I03', concepto: 'Equipo de transporte' },
{ clave: 'I04', concepto: 'Equipo de cómputo y accesorios' },
{ clave: 'I05', concepto: 'Dados, troqueles, moldes, matrices' },
{ clave: 'I06', concepto: 'Comunicaciones telefónicas' },
{ clave: 'I07', concepto: 'Comunicaciones satelitales' },
{ clave: 'I08', concepto: 'Otra maquinaria y equipo' },
];
const ESTADO_LABEL: Record<string, { label: string; color: string }> = {
activo: { label: 'Activo', color: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' },
agotado: { label: 'Agotado', color: 'bg-muted text-muted-foreground' },
baja_venta: { label: 'Vendido', color: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400' },
baja_desecho: { label: 'Desechado', color: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400' },
baja_otro: { label: 'Baja', color: 'bg-zinc-100 text-zinc-800 dark:bg-zinc-900/30 dark:text-zinc-400' },
};
export function ActivosFijosTab({ año, mes }: { año: number; mes: number }) {
const queryClient = useQueryClient();
const { selectedContribuyenteId } = useContribuyenteStore();
const [filtroEstado, setFiltroEstado] = useState<'todos' | 'activos' | 'baja' | 'agotados'>('todos');
const [bajaModal, setBajaModal] = useState<ActivoFijoItem | null>(null);
const [bajaForm, setBajaForm] = useState({
fechaBaja: new Date().toISOString().slice(0, 10),
motivo: 'venta' as 'venta' | 'desecho' | 'otro',
comentario: '',
});
const [conceptosModal, setConceptosModal] = useState(false);
const [conceptosDraft, setConceptosDraft] = useState<Set<string>>(new Set());
const { data, isLoading } = useQuery<Response>({
queryKey: ['activos-fijos', año, mes, selectedContribuyenteId, filtroEstado],
queryFn: async () => {
const p = new URLSearchParams({ año: String(año), mes: String(mes), estado: filtroEstado });
if (selectedContribuyenteId) p.set('contribuyenteId', selectedContribuyenteId);
const res = await apiClient.get<Response>(`/impuestos/activos-fijos?${p}`);
return res.data;
},
});
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['activos-fijos'] });
const bajaMutation = useMutation({
mutationFn: async () => {
if (!bajaModal) return;
await apiClient.post(`/impuestos/activos-fijos/${bajaModal.cfdiId}/baja`, {
fechaBaja: bajaForm.fechaBaja,
motivo: bajaForm.motivo,
comentario: bajaForm.comentario || null,
});
},
onSuccess: () => {
setBajaModal(null);
invalidate();
},
});
const revertirMutation = useMutation({
mutationFn: async (cfdiId: number) => apiClient.delete(`/impuestos/activos-fijos/${cfdiId}/baja`),
onSuccess: invalidate,
});
const conceptosMutation = useMutation({
mutationFn: async (excluidos: string[]) => {
if (!selectedContribuyenteId) return;
await apiClient.put('/impuestos/activos-fijos/usos-excluidos', {
contribuyenteId: selectedContribuyenteId,
usos: excluidos,
});
},
onSuccess: () => {
setConceptosModal(false);
invalidate();
},
});
const openConceptos = () => {
setConceptosDraft(new Set(data?.usosExcluidos ?? []));
setConceptosModal(true);
};
const toggleConcepto = (clave: string) => {
setConceptosDraft(prev => {
const next = new Set(prev);
if (next.has(clave)) next.delete(clave);
else next.add(clave);
return next;
});
};
const items = data?.items ?? [];
const t = data?.totales;
const openBaja = (a: ActivoFijoItem) => {
setBajaForm({
fechaBaja: a.baja?.fechaBaja ?? new Date().toISOString().slice(0, 10),
motivo: (a.baja?.motivo as 'venta' | 'desecho' | 'otro') ?? 'venta',
comentario: a.baja?.comentario ?? '',
});
setBajaModal(a);
};
return (
<div className="space-y-4">
{/* Disclaimer */}
<Card className="border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/20">
<CardContent className="py-3 text-xs text-amber-900 dark:text-amber-100 flex items-start gap-2">
<AlertTriangle className="h-4 w-4 mt-0.5 flex-shrink-0" />
<div>
<strong>Vista informativa.</strong> El sistema considera estos CFDIs como gasto del periodo
(igual que el SAT), por lo que ya están en tu Dashboard y en tu cálculo de ISR.
Esta vista te sirve para llevar el seguimiento de la deducción mensual proporcional
(% anual ÷ 12) y decidir manualmente si la aplicas en tu declaración anual.
</div>
</CardContent>
</Card>
{/* KPIs */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<KpiCard title="Monto Original (MOI)" value={t?.totalMoi ?? 0} icon={<Wallet className="h-4 w-4" />} subtitle={`${t?.cantidad ?? 0} CFDIs`} />
<KpiCard title="Acumulado al mes anterior" value={t?.totalAcumuladoPrevio ?? 0} icon={<Calendar className="h-4 w-4" />} subtitle="Ya deducible" />
<KpiCard title="Acreditable este mes" value={t?.totalEsteMes ?? 0} icon={<TrendingUp className="h-4 w-4" />} subtitle="A aplicar este mes" />
<KpiCard title="Saldo pendiente" value={t?.totalSaldoPendiente ?? 0} icon={<Clock className="h-4 w-4" />} subtitle="Por deducir en futuro" />
</div>
{/* Filtros */}
<div className="flex items-center gap-3">
<Label className="text-xs text-muted-foreground">Mostrar:</Label>
<Select value={filtroEstado} onValueChange={(v) => setFiltroEstado(v as typeof filtroEstado)}>
<SelectTrigger className="w-40 h-9 text-sm"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="todos">Todos</SelectItem>
<SelectItem value="activos">Activos</SelectItem>
<SelectItem value="agotados">Agotados</SelectItem>
<SelectItem value="baja">Dados de baja</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="sm" onClick={openConceptos} disabled={!selectedContribuyenteId}>
<Filter className="h-4 w-4 mr-1" />
Conceptos
{data && data.usosExcluidos.length > 0 && (
<span className="ml-1 text-[10px] bg-amber-100 dark:bg-amber-900/40 text-amber-800 dark:text-amber-200 rounded px-1.5 py-0.5">
{data.usosExcluidos.length} excluidos
</span>
)}
</Button>
<span className="text-xs text-muted-foreground ml-auto">
{t ? `${t.cantidadActivos} activos · ${t.cantidadAgotados} agotados · ${t.cantidadDeBaja} bajas` : ''}
</span>
</div>
{/* Tabla */}
<Card>
<CardContent className="p-0">
{isLoading ? (
<p className="p-6 text-sm text-muted-foreground">Cargando...</p>
) : items.length === 0 ? (
<p className="p-6 text-sm text-muted-foreground text-center">
No hay activos fijos en el periodo seleccionado.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b bg-muted/50">
<tr>
<th className="text-left px-3 py-2 font-medium">Fecha</th>
<th className="text-left px-3 py-2 font-medium">Emisor</th>
<th className="text-left px-3 py-2 font-medium">Concepto</th>
<th className="text-right px-3 py-2 font-medium">MOI</th>
<th className="text-right px-3 py-2 font-medium">% anual</th>
<th className="text-right px-3 py-2 font-medium">Acum. previo</th>
<th className="text-right px-3 py-2 font-medium">Este mes</th>
<th className="text-right px-3 py-2 font-medium">Saldo</th>
<th className="text-center px-3 py-2 font-medium">Estado</th>
<th className="px-3 py-2"></th>
</tr>
</thead>
<tbody>
{items.map(a => {
const estadoMeta = ESTADO_LABEL[a.estado] ?? ESTADO_LABEL.activo;
const esBaja = a.estado.startsWith('baja_');
return (
<tr key={a.cfdiId} className="border-b hover:bg-muted/30">
<td className="px-3 py-2 whitespace-nowrap">
{new Date(a.fechaEmision).toLocaleDateString('es-MX', { day: 'numeric', month: 'short', year: 'numeric' })}
</td>
<td className="px-3 py-2">
<div className="font-mono text-xs">{a.rfcEmisor}</div>
<div className="text-xs text-muted-foreground truncate max-w-[180px]">{a.nombreEmisor}</div>
</td>
<td className="px-3 py-2 text-xs">
<div className="font-mono">{a.usoCfdi}</div>
<div className="text-muted-foreground truncate max-w-[180px]">{a.concepto}</div>
</td>
<td className="px-3 py-2 text-right font-medium tabular-nums">{formatCurrency(a.moi)}</td>
<td className="px-3 py-2 text-right tabular-nums text-muted-foreground">{(a.porcentajeAnual * 100).toFixed(0)}%</td>
<td className="px-3 py-2 text-right tabular-nums text-muted-foreground">{formatCurrency(a.acumuladoHastaMesAnterior)}</td>
<td className="px-3 py-2 text-right tabular-nums font-medium text-success">{formatCurrency(a.acreditableEsteMes)}</td>
<td className="px-3 py-2 text-right tabular-nums">{formatCurrency(a.saldoPendiente)}</td>
<td className="px-3 py-2 text-center">
<span className={cn('inline-block px-2 py-0.5 rounded-full text-[10px] font-medium', estadoMeta.color)}>
{estadoMeta.label}
</span>
</td>
<td className="px-3 py-2 text-right">
{esBaja ? (
<Button
variant="ghost" size="icon" title="Revertir baja"
onClick={() => revertirMutation.mutate(a.cfdiId)}
>
<RotateCcw className="h-4 w-4" />
</Button>
) : a.estado === 'activo' ? (
<Button
variant="ghost" size="icon" title="Dar de baja"
onClick={() => openBaja(a)}
>
<CircleSlash className="h-4 w-4 text-destructive" />
</Button>
) : null}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{/* Modal conceptos: excluir usos CFDI que el contador no quiere ver */}
<Dialog open={conceptosModal} onOpenChange={(o) => { if (!o) setConceptosModal(false); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Conceptos a considerar como activos fijos</DialogTitle>
</DialogHeader>
<div className="space-y-2 py-2">
<p className="text-sm text-muted-foreground">
Desmarca los conceptos cuyos CFDIs en este contribuyente NO sean adquisiciones de activos fijos
(ej. servicio telefónico mensual con uso I06). Por default todos están considerados.
</p>
{USOS_DISPONIBLES.map(u => {
const excluido = conceptosDraft.has(u.clave);
return (
<label key={u.clave} className="flex items-start gap-2 cursor-pointer text-sm py-1">
<input
type="checkbox"
checked={!excluido}
onChange={() => toggleConcepto(u.clave)}
className="mt-0.5"
/>
<div className="flex-1">
<span className="font-mono text-xs mr-2">{u.clave}</span>
<span>{u.concepto}</span>
</div>
</label>
);
})}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setConceptosModal(false)}>Cancelar</Button>
<Button
onClick={() => conceptosMutation.mutate([...conceptosDraft])}
disabled={conceptosMutation.isPending}
>
{conceptosMutation.isPending ? 'Guardando...' : 'Guardar'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Modal baja */}
<Dialog open={!!bajaModal} onOpenChange={(o) => { if (!o) setBajaModal(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Dar de baja activo fijo</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
<strong>{bajaModal?.concepto}</strong> {bajaModal?.nombreEmisor}
</p>
<div>
<Label>Fecha de baja</Label>
<Input type="date" value={bajaForm.fechaBaja} onChange={e => setBajaForm(f => ({ ...f, fechaBaja: e.target.value }))} />
</div>
<div>
<Label>Motivo</Label>
<Select value={bajaForm.motivo} onValueChange={(v) => setBajaForm(f => ({ ...f, motivo: v as typeof bajaForm.motivo }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="venta">Venta</SelectItem>
<SelectItem value="desecho">Desecho</SelectItem>
<SelectItem value="otro">Otro</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Comentario (opcional)</Label>
<Input value={bajaForm.comentario} onChange={e => setBajaForm(f => ({ ...f, comentario: e.target.value }))} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setBajaModal(null)}>Cancelar</Button>
<Button onClick={() => bajaMutation.mutate()} disabled={bajaMutation.isPending}>
{bajaMutation.isPending ? 'Guardando...' : 'Dar de baja'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}