Update: nueva version Horux Despachos
This commit is contained in:
369
apps/web/components/impuestos/activos-fijos-tab.tsx
Normal file
369
apps/web/components/impuestos/activos-fijos-tab.tsx
Normal file
@@ -0,0 +1,369 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user