370 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|