- 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
1683 lines
81 KiB
TypeScript
1683 lines
81 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useRef } from 'react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { Header } from '@/components/layouts/header';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@horux/shared-ui';
|
|
import { useAuthStore } from '@/stores/auth-store';
|
|
import { useTenantViewStore } from '@/stores/tenant-view-store';
|
|
import { useContribuyenteStore } from '@/stores/contribuyente-store';
|
|
import {
|
|
useFormasPago, useMetodosPago, useUsosCfdi, useMonedas,
|
|
useClavesUnidad, useEmitirFactura, useTimbres,
|
|
} from '@/lib/hooks/use-facturacion';
|
|
import { searchClaveProdServ } from '@/lib/api/catalogos';
|
|
import { apiClient } from '@/lib/api/client';
|
|
import type { InvoiceData, InvoiceLineItem, RfcSearchResult, CfdiPpdPendiente, ConceptoPrevio, CfdiRelacionable } from '@/lib/api/facturacion';
|
|
import { searchRfcs, getCfdisPpd, searchConceptos, getCfdisRelacionables, downloadPdf, downloadXml } from '@/lib/api/facturacion';
|
|
import { Plus, Trash2, Send, Receipt, Search, Check, X, FileSearch, AlertTriangle } from 'lucide-react';
|
|
import { cn } from '@horux/shared-ui';
|
|
import { toCfdiDate } from '@/lib/utils';
|
|
|
|
interface TaxLine {
|
|
category: 'traslado' | 'retencion';
|
|
type: string; // IVA, ISR, IEPS
|
|
rate: number;
|
|
factor: string; // Tasa, Cuota, Exento
|
|
}
|
|
|
|
interface ConceptoForm {
|
|
description: string;
|
|
productKey: string;
|
|
productKeyLabel: string;
|
|
unitKey: string;
|
|
quantity: number;
|
|
price: number;
|
|
discount: number;
|
|
objetoImp: string;
|
|
taxes: TaxLine[];
|
|
}
|
|
|
|
const defaultTaxes: TaxLine[] = [
|
|
{ category: 'traslado', type: 'IVA', rate: 0.16, factor: 'Tasa' },
|
|
];
|
|
|
|
const emptyConcepto: ConceptoForm = {
|
|
description: '', productKey: '', productKeyLabel: '',
|
|
unitKey: 'E48', quantity: 1, price: 0, discount: 0, objetoImp: '02',
|
|
taxes: [...defaultTaxes],
|
|
};
|
|
|
|
const TAX_OPTIONS = {
|
|
traslado: [
|
|
{ type: 'IVA', label: 'IVA 16%', rate: 0.16, factor: 'Tasa' },
|
|
{ type: 'IVA', label: 'IVA 8% (Frontera)', rate: 0.08, factor: 'Tasa' },
|
|
{ type: 'IVA', label: 'IVA 0%', rate: 0, factor: 'Tasa' },
|
|
{ type: 'IVA', label: 'IVA Exento', rate: 0, factor: 'Exento' },
|
|
{ type: 'IEPS', label: 'IEPS 8%', rate: 0.08, factor: 'Tasa' },
|
|
{ type: 'IEPS', label: 'IEPS 26.5%', rate: 0.265, factor: 'Tasa' },
|
|
{ type: 'IEPS', label: 'IEPS 30%', rate: 0.30, factor: 'Tasa' },
|
|
{ type: 'IEPS', label: 'IEPS 53%', rate: 0.53, factor: 'Tasa' },
|
|
{ type: 'IEPS', label: 'IEPS 160%', rate: 1.60, factor: 'Tasa' },
|
|
{ type: 'ISH', label: 'ISH (Hospedaje) 3%', rate: 0.03, factor: 'Tasa' },
|
|
{ type: 'ISH', label: 'ISH (Hospedaje) 4%', rate: 0.04, factor: 'Tasa' },
|
|
{ type: 'ISH', label: 'ISH (Hospedaje) 5%', rate: 0.05, factor: 'Tasa' },
|
|
{ type: 'ImpLocal', label: 'Imp. Local Traslado 2%', rate: 0.02, factor: 'Tasa' },
|
|
{ type: 'ImpLocal', label: 'Imp. Local Traslado 3%', rate: 0.03, factor: 'Tasa' },
|
|
{ type: 'ImpLocal', label: 'Imp. Local Traslado 5%', rate: 0.05, factor: 'Tasa' },
|
|
],
|
|
retencion: [
|
|
{ type: 'ISR', label: 'ISR 10%', rate: 0.10, factor: 'Tasa' },
|
|
{ type: 'ISR', label: 'ISR 1.25%', rate: 0.0125, factor: 'Tasa' },
|
|
{ type: 'ISR', label: 'ISR 20%', rate: 0.20, factor: 'Tasa' },
|
|
{ type: 'ISR', label: 'ISR 25%', rate: 0.25, factor: 'Tasa' },
|
|
{ type: 'IVA', label: 'IVA Retenido 10.6667%', rate: 0.106667, factor: 'Tasa' },
|
|
{ type: 'IVA', label: 'IVA Retenido 4%', rate: 0.04, factor: 'Tasa' },
|
|
{ type: 'IVA', label: 'IVA Retenido 16%', rate: 0.16, factor: 'Tasa' },
|
|
{ type: 'IEPS', label: 'IEPS Retenido', rate: 0, factor: 'Tasa' },
|
|
{ type: 'ImpLocal', label: 'Imp. Local Retenido 2%', rate: 0.02, factor: 'Tasa' },
|
|
{ type: 'ImpLocal', label: 'Imp. Local Retenido 2.5%', rate: 0.025, factor: 'Tasa' },
|
|
{ type: 'ImpLocal', label: 'Imp. Local Retenido 3%', rate: 0.03, factor: 'Tasa' },
|
|
{ type: 'ImpLocal', label: 'Imp. Local Retenido 5%', rate: 0.05, factor: 'Tasa' },
|
|
],
|
|
};
|
|
|
|
// Regímenes PF que requieren retenciones al facturar a PM
|
|
const REGIMENES_PF_RETENCION = ['612', '626', '606'];
|
|
// Unidades consideradas "de servicio"
|
|
const UNIDADES_SERVICIO = ['E48', 'ACT'];
|
|
|
|
/**
|
|
* Recomienda impuestos para tipo I según régimen emisor, receptor y unidad.
|
|
* - Emisor PF (13 chars RFC) con régimen 612, 626 o 606
|
|
* - Receptor PM (no es 612, 626, 606)
|
|
* - Unidad de servicio
|
|
* Retorna taxes recomendados o null si no aplica
|
|
*/
|
|
function getRecommendedTaxes(
|
|
emisorRfc: string,
|
|
emisorRegimen: string | undefined,
|
|
receptorTaxId: string,
|
|
receptorTaxSystem: string,
|
|
unitKey: string,
|
|
): TaxLine[] | null {
|
|
// Solo para tipo I
|
|
// Emisor debe ser PF (13 chars) con régimen 612, 626 o 606
|
|
if (emisorRfc.length !== 13) return null;
|
|
if (!emisorRegimen || !REGIMENES_PF_RETENCION.includes(emisorRegimen)) return null;
|
|
|
|
// Receptor debe ser PM (12 chars) y NO tener régimen 612, 626, 606
|
|
if (receptorTaxId.length !== 12) return null;
|
|
if (REGIMENES_PF_RETENCION.includes(receptorTaxSystem)) return null;
|
|
|
|
// Solo para unidades de servicio
|
|
if (!UNIDADES_SERVICIO.includes(unitKey)) return null;
|
|
|
|
const taxes: TaxLine[] = [
|
|
{ category: 'traslado', type: 'IVA', rate: 0.16, factor: 'Tasa' },
|
|
{ category: 'retencion', type: 'IVA', rate: 0.106667, factor: 'Tasa' }, // 2/3 del IVA
|
|
];
|
|
|
|
if (emisorRegimen === '626') {
|
|
taxes.push({ category: 'retencion', type: 'ISR', rate: 0.0125, factor: 'Tasa' }); // 1.25%
|
|
} else {
|
|
taxes.push({ category: 'retencion', type: 'ISR', rate: 0.10, factor: 'Tasa' }); // 10%
|
|
}
|
|
|
|
return taxes;
|
|
}
|
|
|
|
const PAISES = [
|
|
{ code: 'USA', name: 'Estados Unidos' },
|
|
{ code: 'CAN', name: 'Canadá' },
|
|
{ code: 'GBR', name: 'Reino Unido' },
|
|
{ code: 'DEU', name: 'Alemania' },
|
|
{ code: 'FRA', name: 'Francia' },
|
|
{ code: 'ESP', name: 'España' },
|
|
{ code: 'ITA', name: 'Italia' },
|
|
{ code: 'BRA', name: 'Brasil' },
|
|
{ code: 'ARG', name: 'Argentina' },
|
|
{ code: 'COL', name: 'Colombia' },
|
|
{ code: 'CHL', name: 'Chile' },
|
|
{ code: 'PER', name: 'Perú' },
|
|
{ code: 'CHN', name: 'China' },
|
|
{ code: 'JPN', name: 'Japón' },
|
|
{ code: 'KOR', name: 'Corea del Sur' },
|
|
{ code: 'IND', name: 'India' },
|
|
{ code: 'AUS', name: 'Australia' },
|
|
{ code: 'NZL', name: 'Nueva Zelanda' },
|
|
{ code: 'CHE', name: 'Suiza' },
|
|
{ code: 'NLD', name: 'Países Bajos' },
|
|
{ code: 'SWE', name: 'Suecia' },
|
|
{ code: 'NOR', name: 'Noruega' },
|
|
{ code: 'DNK', name: 'Dinamarca' },
|
|
{ code: 'PRT', name: 'Portugal' },
|
|
{ code: 'BEL', name: 'Bélgica' },
|
|
{ code: 'AUT', name: 'Austria' },
|
|
{ code: 'IRL', name: 'Irlanda' },
|
|
{ code: 'POL', name: 'Polonia' },
|
|
{ code: 'CRI', name: 'Costa Rica' },
|
|
{ code: 'PAN', name: 'Panamá' },
|
|
{ code: 'DOM', name: 'República Dominicana' },
|
|
{ code: 'GTM', name: 'Guatemala' },
|
|
{ code: 'HND', name: 'Honduras' },
|
|
{ code: 'SLV', name: 'El Salvador' },
|
|
{ code: 'ECU', name: 'Ecuador' },
|
|
{ code: 'VEN', name: 'Venezuela' },
|
|
{ code: 'URY', name: 'Uruguay' },
|
|
{ code: 'PRY', name: 'Paraguay' },
|
|
{ code: 'BOL', name: 'Bolivia' },
|
|
{ code: 'CUB', name: 'Cuba' },
|
|
{ code: 'ISR', name: 'Israel' },
|
|
{ code: 'ARE', name: 'Emiratos Árabes Unidos' },
|
|
{ code: 'SGP', name: 'Singapur' },
|
|
{ code: 'HKG', name: 'Hong Kong' },
|
|
{ code: 'TWN', name: 'Taiwán' },
|
|
{ code: 'THA', name: 'Tailandia' },
|
|
{ code: 'RUS', name: 'Rusia' },
|
|
{ code: 'ZAF', name: 'Sudáfrica' },
|
|
{ code: 'NGA', name: 'Nigeria' },
|
|
{ code: 'EGY', name: 'Egipto' },
|
|
];
|
|
|
|
const TIPO_CONFIG: Record<string, {
|
|
label: string;
|
|
needsConceptos: boolean;
|
|
needsPaymentComplement: boolean;
|
|
needsRelated: boolean;
|
|
defaultUso: string;
|
|
defaultFormaPago: string;
|
|
defaultMetodoPago: string;
|
|
}> = {
|
|
I: { label: 'Ingreso', needsConceptos: true, needsPaymentComplement: false, needsRelated: true, defaultUso: 'G03', defaultFormaPago: '99', defaultMetodoPago: 'PUE' },
|
|
E: { label: 'Egreso (Nota de Crédito)', needsConceptos: true, needsPaymentComplement: false, needsRelated: true, defaultUso: 'G02', defaultFormaPago: '99', defaultMetodoPago: 'PUE' },
|
|
P: { label: 'Pago', needsConceptos: false, needsPaymentComplement: true, needsRelated: false, defaultUso: 'CP01', defaultFormaPago: '99', defaultMetodoPago: 'PPD' },
|
|
T: { label: 'Traslado', needsConceptos: true, needsPaymentComplement: false, needsRelated: false, defaultUso: 'S01', defaultFormaPago: '99', defaultMetodoPago: 'PUE' },
|
|
};
|
|
|
|
export default function FacturacionPage() {
|
|
const { user } = useAuthStore();
|
|
const { viewingTenantRfc } = useTenantViewStore();
|
|
const { selectedContribuyenteRfc } = useContribuyenteStore();
|
|
const emisorRfc = selectedContribuyenteRfc || viewingTenantRfc || user?.tenantRfc || '';
|
|
const { data: formasPago } = useFormasPago();
|
|
const { data: metodosPago } = useMetodosPago();
|
|
const { data: usosCfdi } = useUsosCfdi();
|
|
const { data: monedas } = useMonedas();
|
|
const { data: clavesUnidad } = useClavesUnidad();
|
|
const { data: timbres } = useTimbres();
|
|
const emitir = useEmitirFactura();
|
|
|
|
// Régimen del emisor (para recomendaciones de retenciones)
|
|
const { selectedContribuyenteId } = useContribuyenteStore();
|
|
|
|
// Banner "CSD recién tramitado": si el SAT rechazó una emisión en las
|
|
// últimas 24h con el patrón LCO, mostrar advertencia. Poll discreto
|
|
// (15min) por si el banner debe desaparecer tras pasar las 24h.
|
|
const lcoStatusQuery = useQuery({
|
|
queryKey: ['lco-status', selectedContribuyenteId],
|
|
queryFn: async () => {
|
|
const params = new URLSearchParams();
|
|
if (selectedContribuyenteId) params.set('contribuyenteId', selectedContribuyenteId);
|
|
const res = await apiClient.get<{ hasRecentLcoRejection: boolean; rejectedAt: string | null }>(
|
|
`/facturacion/lco-status?${params}`,
|
|
);
|
|
return res.data;
|
|
},
|
|
enabled: !!selectedContribuyenteId,
|
|
refetchInterval: 15 * 60 * 1000,
|
|
});
|
|
const [emisorRegimenes, setEmisorRegimenes] = useState<{ clave: string; descripcion: string }[]>([]);
|
|
const [emisorRegimen, setEmisorRegimen] = useState<string | undefined>();
|
|
useEffect(() => {
|
|
if (emisorRfc.length >= 12) {
|
|
import('@/lib/api/dashboard').then(({ getRegimenesDelPeriodo }) => {
|
|
const now = new Date();
|
|
const fi = `${now.getFullYear()}-01-01`;
|
|
const ff = `${now.getFullYear()}-12-31`;
|
|
getRegimenesDelPeriodo(fi, ff, undefined, selectedContribuyenteId).then(regs => {
|
|
setEmisorRegimenes(regs);
|
|
setEmisorRegimen(regs.length > 0 ? regs[0].clave : undefined);
|
|
}).catch(() => {
|
|
setEmisorRegimenes([]);
|
|
setEmisorRegimen(undefined);
|
|
});
|
|
});
|
|
} else {
|
|
setEmisorRegimenes([]);
|
|
setEmisorRegimen(undefined);
|
|
}
|
|
}, [emisorRfc, selectedContribuyenteId]);
|
|
|
|
// Al cambiar el régimen del emisor, recalcular recomendaciones de retenciones
|
|
const handleEmisorRegimenChange = (clave: string) => {
|
|
setEmisorRegimen(clave);
|
|
if (tipoComprobante === 'I') {
|
|
setConceptos(prev => prev.map(c => {
|
|
const recommended = getRecommendedTaxes(
|
|
emisorRfc, clave, receptor.taxId, receptor.taxSystem, c.unitKey
|
|
);
|
|
return recommended ? { ...c, taxes: recommended } : { ...c, taxes: [...defaultTaxes] };
|
|
}));
|
|
}
|
|
};
|
|
|
|
// Tipo de comprobante
|
|
const [tipoComprobante, setTipoComprobante] = useState('I');
|
|
const config = TIPO_CONFIG[tipoComprobante];
|
|
|
|
// Receptor
|
|
const [receptor, setReceptor] = useState({
|
|
legalName: '', taxId: '', taxSystem: '601', email: '', zip: '',
|
|
});
|
|
const isExtranjero = receptor.taxId === 'XEXX010101000';
|
|
const [extranjeroTaxId, setExtranjeroTaxId] = useState('');
|
|
const [extranjeroCountry, setExtranjeroCountry] = useState('USA');
|
|
|
|
// Auto-llenar datos cuando se detecta RFC extranjero
|
|
useEffect(() => {
|
|
if (isExtranjero) {
|
|
setReceptor(prev => ({ ...prev, taxSystem: '616' }));
|
|
// Obtener CP del tenant
|
|
apiClient.get('/facturacion/datos-fiscales').then(r => {
|
|
if (r.data?.codigoPostal) setReceptor(prev => ({ ...prev, zip: r.data.codigoPostal }));
|
|
}).catch(() => {});
|
|
}
|
|
}, [isExtranjero]);
|
|
|
|
// Factura global
|
|
const [isGlobal, setIsGlobal] = useState(false);
|
|
const PERIODICIDAD_LABELS: Record<string, string> = {
|
|
day: 'Diario', week: 'Semanal', fortnight: 'Quincenal', month: 'Mensual', two_months: 'Bimestral',
|
|
};
|
|
const [globalPeriodicidad, setGlobalPeriodicidad] = useState('month');
|
|
const [globalMes, setGlobalMes] = useState(String(new Date().getMonth() + 1).padStart(2, '0'));
|
|
const [globalAnio, setGlobalAnio] = useState(new Date().getFullYear());
|
|
|
|
// Campos CFDI
|
|
const [usoCfdi, setUsoCfdi] = useState('G03');
|
|
const [formaPago, setFormaPago] = useState('99');
|
|
const [metodoPago, setMetodoPago] = useState('PUE');
|
|
const [moneda, setMoneda] = useState('MXN');
|
|
const [exportacion, setExportacion] = useState('01');
|
|
const [serie, setSerie] = useState('');
|
|
const [folio, setFolio] = useState('');
|
|
const [condiciones, setCondiciones] = useState('');
|
|
const [cuentaPredial, setCuentaPredial] = useState('');
|
|
const [fechaEmision, setFechaEmision] = useState(() => {
|
|
const d = new Date();
|
|
d.setHours(12, 0, 0, 0);
|
|
return d.toISOString().slice(0, 16);
|
|
});
|
|
|
|
// Conceptos
|
|
const [conceptos, setConceptos] = useState<ConceptoForm[]>([{ ...emptyConcepto }]);
|
|
|
|
// Documento relacionado (Egreso)
|
|
const [relatedUuid, setRelatedUuid] = useState('');
|
|
const [relatedRelationship, setRelatedRelationship] = useState('01');
|
|
|
|
// Complemento de pago
|
|
const [pagoFecha, setPagoFecha] = useState(new Date().toISOString().slice(0, 10));
|
|
const [pagoUuid, setPagoUuid] = useState('');
|
|
const [pagoMonto, setPagoMonto] = useState(0);
|
|
const [pagoParcialidad, setPagoParcialidad] = useState(1);
|
|
const [pagoSaldoAnterior, setPagoSaldoAnterior] = useState(0);
|
|
const [pagoFormaPago, setPagoFormaPago] = useState('03');
|
|
const [pagoIvaBase, setPagoIvaBase] = useState(0);
|
|
const [pagoIvaTasa, setPagoIvaTasa] = useState(0.16);
|
|
|
|
// Reset del formulario al cambiar de contribuyente — evita que receptor/conceptos/retenciones
|
|
// de un contribuyente anterior se mezclen con los del nuevo (ej: retenciones de PF→PM que ya
|
|
// no aplican si el nuevo emisor es PM). No dispara en el primer render.
|
|
const firstRenderRef = useRef(true);
|
|
useEffect(() => {
|
|
if (firstRenderRef.current) {
|
|
firstRenderRef.current = false;
|
|
return;
|
|
}
|
|
const cfg = TIPO_CONFIG[tipoComprobante];
|
|
const defaultUnit = tipoComprobante === 'T' ? 'H87' : 'E48';
|
|
setReceptor({ legalName: '', taxId: '', taxSystem: '601', email: '', zip: '' });
|
|
setIsGlobal(false);
|
|
setExtranjeroTaxId('');
|
|
setExtranjeroCountry('USA');
|
|
setUsoCfdi(cfg.defaultUso);
|
|
setFormaPago(cfg.defaultFormaPago);
|
|
setMetodoPago(cfg.defaultMetodoPago);
|
|
setMoneda('MXN');
|
|
setExportacion('01');
|
|
setSerie('');
|
|
setFolio('');
|
|
setCondiciones('');
|
|
setCuentaPredial('');
|
|
setConceptos([{ ...emptyConcepto, unitKey: defaultUnit }]);
|
|
setRelatedUuid('');
|
|
setRelatedRelationship('01');
|
|
setPagoUuid('');
|
|
setPagoMonto(0);
|
|
setPagoParcialidad(1);
|
|
setPagoSaldoAnterior(0);
|
|
setPagoFormaPago('03');
|
|
setPagoIvaBase(0);
|
|
setPagoIvaTasa(0.16);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [selectedContribuyenteId]);
|
|
|
|
// Búsqueda RFC receptor
|
|
const [rfcResults, setRfcResults] = useState<RfcSearchResult[]>([]);
|
|
const [rfcSearchField, setRfcSearchField] = useState<'rfc' | 'name' | null>(null);
|
|
const rfcDropdownRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
if (rfcDropdownRef.current && !rfcDropdownRef.current.contains(e.target as Node)) {
|
|
setRfcSearchField(null);
|
|
setRfcResults([]);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
const handleRfcSearch = async (value: string, field: 'rfc' | 'name') => {
|
|
if (field === 'rfc') setReceptor(r => ({ ...r, taxId: value.toUpperCase() }));
|
|
else setReceptor(r => ({ ...r, legalName: value }));
|
|
setRfcSearchField(field);
|
|
if (value.length < 3) { setRfcResults([]); return; }
|
|
const results = await searchRfcs(value, selectedContribuyenteId || undefined);
|
|
setRfcResults(results);
|
|
};
|
|
|
|
// CFDIs PPD pendientes (para tipo P)
|
|
const [cfdisPpd, setCfdisPpd] = useState<CfdiPpdPendiente[]>([]);
|
|
const [showPpdDropdown, setShowPpdDropdown] = useState(false);
|
|
|
|
// CFDIs relacionables (para sección "CFDIs Relacionados" tipo I y E).
|
|
// Se carga al seleccionar receptor: lista CFDIs emitidos por el contribuyente
|
|
// activo cuyo rfc_receptor coincide con el seleccionado.
|
|
const [cfdisRelacionables, setCfdisRelacionables] = useState<CfdiRelacionable[]>([]);
|
|
const [showRelDropdown, setShowRelDropdown] = useState(false);
|
|
|
|
const selectRfc = async (item: RfcSearchResult) => {
|
|
setReceptor({
|
|
...receptor,
|
|
taxId: item.rfc,
|
|
legalName: item.razonSocial || '',
|
|
taxSystem: item.regimenFiscal || receptor.taxSystem,
|
|
zip: item.codigoPostal || receptor.zip,
|
|
});
|
|
setRfcResults([]);
|
|
setRfcSearchField(null);
|
|
|
|
// Recalcular recomendaciones de impuestos en conceptos existentes (tipo I)
|
|
if (tipoComprobante === 'I') {
|
|
const newTaxSystem = item.regimenFiscal || receptor.taxSystem;
|
|
setConceptos(prev => prev.map(c => {
|
|
const recommended = getRecommendedTaxes(
|
|
emisorRfc, emisorRegimen, item.rfc, newTaxSystem, c.unitKey
|
|
);
|
|
return recommended ? { ...c, taxes: recommended } : { ...c, taxes: [...defaultTaxes] };
|
|
}));
|
|
}
|
|
|
|
// Si es tipo P, cargar CFDIs PPD pendientes para este RFC
|
|
if (tipoComprobante === 'P' && item.rfc) {
|
|
try {
|
|
const ppd = await getCfdisPpd(item.rfc, selectedContribuyenteId || undefined);
|
|
setCfdisPpd(ppd);
|
|
} catch { setCfdisPpd([]); }
|
|
}
|
|
|
|
// Si es tipo I o E (necesita CFDIs relacionados) y hay contribuyente
|
|
// activo, precargar la lista de CFDIs relacionables filtrados.
|
|
if ((tipoComprobante === 'I' || tipoComprobante === 'E') && item.rfc && selectedContribuyenteId) {
|
|
try {
|
|
const rel = await getCfdisRelacionables(item.rfc, selectedContribuyenteId);
|
|
setCfdisRelacionables(rel);
|
|
} catch { setCfdisRelacionables([]); }
|
|
}
|
|
};
|
|
|
|
const selectCfdiRelacionable = (cfdi: CfdiRelacionable) => {
|
|
setRelatedUuid(cfdi.uuid);
|
|
setShowRelDropdown(false);
|
|
};
|
|
|
|
// Auto-carga de PPDs / relacionables cuando el RFC del receptor cambia y
|
|
// tiene longitud válida — cubre el caso donde el user escribe el RFC a mano
|
|
// (sin pasar por `selectRfc` del dropdown). También dispara al cambiar el
|
|
// tipoComprobante (ej: cambiar de I a P con receptor ya elegido).
|
|
useEffect(() => {
|
|
const rfc = receptor.taxId.toUpperCase().trim();
|
|
if (rfc.length < 12) {
|
|
setCfdisPpd([]);
|
|
setCfdisRelacionables([]);
|
|
return;
|
|
}
|
|
if (tipoComprobante === 'P') {
|
|
getCfdisPpd(rfc, selectedContribuyenteId || undefined).then(setCfdisPpd).catch(() => setCfdisPpd([]));
|
|
} else if ((tipoComprobante === 'I' || tipoComprobante === 'E') && selectedContribuyenteId) {
|
|
getCfdisRelacionables(rfc, selectedContribuyenteId)
|
|
.then(setCfdisRelacionables)
|
|
.catch(() => setCfdisRelacionables([]));
|
|
}
|
|
}, [receptor.taxId, tipoComprobante, selectedContribuyenteId]);
|
|
|
|
const selectCfdiPpd = (cfdi: CfdiPpdPendiente) => {
|
|
setPagoUuid(cfdi.uuid);
|
|
setPagoSaldoAnterior(Number(cfdi.saldoPendiente));
|
|
setPagoMonto(Number(cfdi.saldoPendiente));
|
|
setPagoIvaBase(Number(cfdi.saldoPendiente) / 1.16);
|
|
setShowPpdDropdown(false);
|
|
};
|
|
|
|
// Búsqueda producto SAT
|
|
const [prodSearch, setProdSearch] = useState('');
|
|
const [prodResults, setProdResults] = useState<Array<{ clave: string; descripcion: string }>>([]);
|
|
const [searchingIdx, setSearchingIdx] = useState<number | null>(null);
|
|
|
|
// Modal búsqueda de conceptos previos
|
|
const [showConceptoSearch, setShowConceptoSearch] = useState(false);
|
|
const [conceptoSearchIdx, setConceptoSearchIdx] = useState(0);
|
|
const [conceptoSearchQuery, setConceptoSearchQuery] = useState('');
|
|
const [conceptoSearchTipo, setConceptoSearchTipo] = useState('todos');
|
|
const [conceptoSearchResults, setConceptoSearchResults] = useState<ConceptoPrevio[]>([]);
|
|
const [conceptoSearching, setConceptoSearching] = useState(false);
|
|
|
|
const openConceptoSearch = (idx: number) => {
|
|
setConceptoSearchIdx(idx);
|
|
setConceptoSearchQuery('');
|
|
setConceptoSearchResults([]);
|
|
setShowConceptoSearch(true);
|
|
};
|
|
|
|
const handleConceptoSearch = async () => {
|
|
setConceptoSearching(true);
|
|
try {
|
|
const results = await searchConceptos(conceptoSearchQuery, conceptoSearchTipo, selectedContribuyenteId);
|
|
setConceptoSearchResults(results);
|
|
} catch { setConceptoSearchResults([]); }
|
|
finally { setConceptoSearching(false); }
|
|
};
|
|
|
|
const selectConceptoPrevio = (cp: ConceptoPrevio) => {
|
|
const updated = [...conceptos];
|
|
const precio = Number(cp.valorUnitario) || 0;
|
|
const iva = Number(cp.ivaTraslado) || 0;
|
|
const ivaRate = precio > 0 ? iva / precio : 0.16;
|
|
const taxes: TaxLine[] = [
|
|
{ category: 'traslado', type: 'IVA', rate: Math.round(ivaRate * 100) / 100 || 0.16, factor: 'Tasa' },
|
|
];
|
|
if (Number(cp.ivaRetencion) > 0 && precio > 0) {
|
|
taxes.push({ category: 'retencion', type: 'IVA', rate: Math.round((Number(cp.ivaRetencion) / precio) * 1000000) / 1000000, factor: 'Tasa' });
|
|
}
|
|
if (Number(cp.isrRetencion) > 0 && precio > 0) {
|
|
taxes.push({ category: 'retencion', type: 'ISR', rate: Math.round((Number(cp.isrRetencion) / precio) * 100) / 100, factor: 'Tasa' });
|
|
}
|
|
|
|
updated[conceptoSearchIdx] = {
|
|
...updated[conceptoSearchIdx],
|
|
description: cp.descripcion || '',
|
|
productKey: cp.claveProdServ || '',
|
|
productKeyLabel: cp.claveProdServ ? `${cp.claveProdServ} - ${cp.descripcion}` : '',
|
|
unitKey: cp.claveUnidad || 'E48',
|
|
price: precio + iva,
|
|
taxes,
|
|
objetoImp: '02',
|
|
};
|
|
setConceptos(updated);
|
|
setShowConceptoSearch(false);
|
|
};
|
|
|
|
// Resultado
|
|
const [result, setResult] = useState<{ id: string; uuid: string; total: number } | null>(null);
|
|
|
|
const handleTipoChange = (tipo: string) => {
|
|
setTipoComprobante(tipo);
|
|
const c = TIPO_CONFIG[tipo];
|
|
setUsoCfdi(c.defaultUso);
|
|
setFormaPago(c.defaultFormaPago);
|
|
setMetodoPago(c.defaultMetodoPago);
|
|
// Resetear conceptos con unidad default según tipo
|
|
const defaultUnit = tipo === 'T' ? 'H87' : 'E48';
|
|
setConceptos([{ ...emptyConcepto, unitKey: defaultUnit }]);
|
|
// Resetear fecha de emisión al día actual (12:00)
|
|
const d = new Date();
|
|
d.setHours(12, 0, 0, 0);
|
|
setFechaEmision(d.toISOString().slice(0, 16));
|
|
};
|
|
|
|
// Unidades de servicio que no aplican para Traslado
|
|
const SERVICE_UNITS = ['E48', 'ACT'];
|
|
const filteredUnidades = tipoComprobante === 'T'
|
|
? clavesUnidad?.filter(u => !SERVICE_UNITS.includes(u.clave))
|
|
: clavesUnidad;
|
|
|
|
const handleSearchProduct = async (q: string, idx: number) => {
|
|
setProdSearch(q);
|
|
setSearchingIdx(idx);
|
|
if (q.length < 2) { setProdResults([]); return; }
|
|
const results = await searchClaveProdServ(q);
|
|
setProdResults(results);
|
|
};
|
|
|
|
const selectProduct = (idx: number, clave: string, descripcion: string) => {
|
|
const updated = [...conceptos];
|
|
updated[idx].productKey = clave;
|
|
updated[idx].productKeyLabel = `${clave} - ${descripcion}`;
|
|
setConceptos(updated);
|
|
setProdResults([]);
|
|
setSearchingIdx(null);
|
|
};
|
|
|
|
const updateConcepto = (idx: number, field: keyof ConceptoForm, value: any) => {
|
|
const updated = [...conceptos];
|
|
(updated[idx] as any)[field] = value;
|
|
// Si cambió la unidad, re-evaluar recomendación de impuestos
|
|
if (field === 'unitKey' && tipoComprobante === 'I') {
|
|
const recommended = getRecommendedTaxes(
|
|
emisorRfc, emisorRegimen, receptor.taxId, receptor.taxSystem, value
|
|
);
|
|
if (recommended) {
|
|
updated[idx].taxes = recommended;
|
|
} else {
|
|
// Si ya no aplica retención, dejar solo IVA 16%
|
|
updated[idx].taxes = [...defaultTaxes];
|
|
}
|
|
}
|
|
setConceptos(updated);
|
|
};
|
|
|
|
// Aplica recomendación de impuestos a un concepto si corresponde (solo tipo I)
|
|
const applyTaxRecommendation = (concepto: ConceptoForm): ConceptoForm => {
|
|
if (tipoComprobante !== 'I') return concepto;
|
|
const recommended = getRecommendedTaxes(
|
|
emisorRfc, emisorRegimen, receptor.taxId, receptor.taxSystem, concepto.unitKey
|
|
);
|
|
if (recommended) {
|
|
return { ...concepto, taxes: recommended };
|
|
}
|
|
return concepto;
|
|
};
|
|
|
|
const addConcepto = () => {
|
|
const newConcepto = applyTaxRecommendation({ ...emptyConcepto });
|
|
setConceptos([...conceptos, newConcepto]);
|
|
};
|
|
const removeConcepto = (idx: number) => {
|
|
if (conceptos.length === 1) return;
|
|
setConceptos(conceptos.filter((_, i) => i !== idx));
|
|
};
|
|
|
|
// Cálculos
|
|
function calcConcepto(c: ConceptoForm) {
|
|
const trasladoRates = c.taxes.filter(t => t.category === 'traslado' && t.factor === 'Tasa').reduce((s, t) => s + t.rate, 0);
|
|
const base = c.price * c.quantity - c.discount;
|
|
const traslados = c.taxes.filter(t => t.category === 'traslado' && t.factor === 'Tasa').reduce((s, t) => s + base * t.rate, 0);
|
|
const retenciones = c.taxes.filter(t => t.category === 'retencion').reduce((s, t) => s + base * t.rate, 0);
|
|
return { base, traslados, retenciones };
|
|
}
|
|
const subtotal = config.needsConceptos ? conceptos.reduce((s, c) => s + calcConcepto(c).base, 0) : 0;
|
|
const totalTraslados = config.needsConceptos ? conceptos.reduce((s, c) => s + calcConcepto(c).traslados, 0) : 0;
|
|
const totalRetenciones = config.needsConceptos ? conceptos.reduce((s, c) => s + calcConcepto(c).retenciones, 0) : 0;
|
|
const totalDescuento = config.needsConceptos ? conceptos.reduce((s, c) => s + c.discount, 0) : 0;
|
|
const total = config.needsPaymentComplement ? pagoMonto : subtotal + totalTraslados - totalRetenciones;
|
|
const pagoIvaMonto = pagoIvaBase * pagoIvaTasa;
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!isGlobal && (!receptor.taxId || !receptor.legalName || !receptor.zip)) {
|
|
alert('Completa los datos del receptor'); return;
|
|
}
|
|
|
|
const data: any = {
|
|
customer: {
|
|
legalName: receptor.legalName,
|
|
taxId: isExtranjero ? extranjeroTaxId : receptor.taxId.toUpperCase(),
|
|
taxSystem: isExtranjero ? undefined : receptor.taxSystem,
|
|
email: receptor.email || undefined,
|
|
zip: receptor.zip,
|
|
...(isExtranjero ? { country: extranjeroCountry } : {}),
|
|
} as any,
|
|
use: usoCfdi,
|
|
paymentForm: formaPago,
|
|
paymentMethod: metodoPago,
|
|
currency: moneda,
|
|
type: tipoComprobante,
|
|
export: exportacion,
|
|
...(emisorRegimen ? { issuerTaxSystem: emisorRegimen } : {}),
|
|
...(selectedContribuyenteId ? { contribuyenteId: selectedContribuyenteId } : {}),
|
|
};
|
|
|
|
if (isGlobal && tipoComprobante === 'I') {
|
|
data.global = {
|
|
periodicity: globalPeriodicidad,
|
|
months: globalMes,
|
|
year: globalAnio,
|
|
};
|
|
}
|
|
if (serie) data.series = serie;
|
|
if (folio) data.folioNumber = parseInt(folio) || undefined;
|
|
if (condiciones) data.conditions = condiciones;
|
|
if (cuentaPredial) data.cuentaPredial = cuentaPredial;
|
|
|
|
// Validar fecha de emisión para I, E, T
|
|
if (tipoComprobante !== 'P' && fechaEmision) {
|
|
const now = new Date();
|
|
const selected = new Date(fechaEmision);
|
|
const minDate = new Date(now.getTime() - 72 * 60 * 60 * 1000);
|
|
if (selected > now) {
|
|
alert('La fecha de emisión no puede ser a futuro'); return;
|
|
}
|
|
if (selected < minDate) {
|
|
alert('La fecha de emisión no puede ser mayor a 72 horas en el pasado'); return;
|
|
}
|
|
data.fechaEmision = selected.toISOString();
|
|
}
|
|
|
|
if (config.needsConceptos) {
|
|
if (conceptos.some(c => !c.description || !c.productKey)) {
|
|
alert('Completa todos los conceptos'); return;
|
|
}
|
|
if (tipoComprobante !== 'T' && conceptos.some(c => c.price <= 0)) {
|
|
alert('El precio debe ser mayor a 0'); return;
|
|
}
|
|
data.items = conceptos.map(c => ({
|
|
description: c.description,
|
|
productKey: c.productKey,
|
|
unitKey: c.unitKey,
|
|
quantity: c.quantity,
|
|
price: tipoComprobante === 'T' ? 0 : c.price,
|
|
discount: c.discount || 0,
|
|
taxIncluded: false,
|
|
objetoImp: c.objetoImp,
|
|
taxes: tipoComprobante === 'T' || c.objetoImp === '01' ? [] : c.taxes.map(t => ({
|
|
type: t.type,
|
|
rate: t.rate,
|
|
factor: t.factor,
|
|
withholding: t.category === 'retencion',
|
|
})),
|
|
}));
|
|
}
|
|
|
|
if (config.needsRelated && relatedUuid) {
|
|
data.relatedDocuments = [{ relationship: relatedRelationship, uuids: [relatedUuid] }];
|
|
}
|
|
|
|
if (config.needsPaymentComplement) {
|
|
if (!pagoUuid) { alert('UUID del documento a pagar es requerido'); return; }
|
|
if (!pagoFecha) { alert('Fecha de pago es requerida'); return; }
|
|
data.complements = [{
|
|
type: 'pago',
|
|
data: [{
|
|
// Facturapi acepta fecha en formato ISO 8601 (YYYY-MM-DDTHH:mm:ss).
|
|
// El input <type=date> devuelve YYYY-MM-DD; agregamos T12:00:00
|
|
// (mediodía local) para evitar shifts de TZ que puedan tirar la
|
|
// fecha al día anterior según huso horario del SAT.
|
|
date: `${pagoFecha}T12:00:00`,
|
|
payment_form: pagoFormaPago,
|
|
related_documents: [{
|
|
uuid: pagoUuid,
|
|
amount: pagoMonto,
|
|
installment: pagoParcialidad,
|
|
last_balance: pagoSaldoAnterior,
|
|
taxes: [{
|
|
base: pagoIvaBase,
|
|
type: 'IVA',
|
|
rate: pagoIvaTasa,
|
|
}],
|
|
}],
|
|
}],
|
|
}];
|
|
}
|
|
|
|
try {
|
|
const res = await emitir.mutateAsync(data);
|
|
setResult({ id: res.id, uuid: res.uuid, total: res.total });
|
|
} catch (err: any) {
|
|
alert(err.response?.data?.message || err.message || 'Error al emitir factura');
|
|
}
|
|
};
|
|
|
|
if (result) {
|
|
return (
|
|
<>
|
|
<Header title="Facturación" />
|
|
<main className="p-6">
|
|
<Card className="max-w-lg mx-auto">
|
|
<CardContent className="pt-6 text-center space-y-4">
|
|
<div className="h-16 w-16 rounded-full bg-green-100 dark:bg-green-900 flex items-center justify-center mx-auto">
|
|
<Check className="h-8 w-8 text-green-600" />
|
|
</div>
|
|
<h2 className="text-xl font-bold">Factura Emitida</h2>
|
|
<div className="space-y-2 text-sm">
|
|
<p className="text-muted-foreground">UUID</p>
|
|
<p className="font-mono text-xs break-all">{result.uuid}</p>
|
|
<p className="text-muted-foreground mt-4">Total</p>
|
|
<p className="text-2xl font-bold">${result.total.toLocaleString('es-MX', { minimumFractionDigits: 2 })}</p>
|
|
</div>
|
|
<div className="flex gap-2 justify-center">
|
|
<Button
|
|
variant="outline"
|
|
onClick={async () => {
|
|
try {
|
|
const blob = await downloadPdf(result.id);
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `factura-${result.uuid}.pdf`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
} catch (err: any) {
|
|
alert(err.response?.data?.message || err.message || 'Error al descargar PDF');
|
|
}
|
|
}}
|
|
>
|
|
Descargar PDF
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
onClick={async () => {
|
|
try {
|
|
const blob = await downloadXml(result.id);
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `factura-${result.uuid}.xml`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
} catch (err: any) {
|
|
alert(err.response?.data?.message || err.message || 'Error al descargar XML');
|
|
}
|
|
}}
|
|
>
|
|
Descargar XML
|
|
</Button>
|
|
</div>
|
|
<Button onClick={() => { setResult(null); setConceptos([{ ...emptyConcepto }]); }}>
|
|
Emitir otra factura
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
</main>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Header title="Facturación" />
|
|
|
|
{/* Modal de búsqueda de conceptos previos */}
|
|
{showConceptoSearch && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
|
<div className="bg-white dark:bg-zinc-900 rounded-xl shadow-2xl w-full max-w-[95vw] md:max-w-3xl max-h-[80vh] flex flex-col m-4">
|
|
<div className="flex items-center justify-between p-4 border-b">
|
|
<h3 className="text-lg font-semibold">Buscar Concepto en Facturas</h3>
|
|
<Button variant="ghost" size="icon" onClick={() => setShowConceptoSearch(false)}>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
<div className="p-4 border-b space-y-3">
|
|
<div className="flex gap-2">
|
|
<Input
|
|
value={conceptoSearchQuery}
|
|
onChange={e => setConceptoSearchQuery(e.target.value)}
|
|
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), handleConceptoSearch())}
|
|
placeholder="Buscar por descripción o clave SAT..."
|
|
className="flex-1"
|
|
autoFocus
|
|
/>
|
|
<Select value={conceptoSearchTipo} onValueChange={setConceptoSearchTipo}>
|
|
<SelectTrigger className="w-36"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="todos">Todos</SelectItem>
|
|
<SelectItem value="emitidos">Emitidos</SelectItem>
|
|
<SelectItem value="recibidos">Recibidos (G01)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Button onClick={handleConceptoSearch} disabled={conceptoSearching}>
|
|
<Search className="h-4 w-4 mr-1" />
|
|
{conceptoSearching ? 'Buscando...' : 'Buscar'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto p-2">
|
|
{conceptoSearchResults.length === 0 && !conceptoSearching && (
|
|
<p className="text-center text-muted-foreground py-8 text-sm">
|
|
{conceptoSearchQuery ? 'Sin resultados. Intenta con otro término.' : 'Escribe para buscar en tus facturas emitidas y recibidas.'}
|
|
</p>
|
|
)}
|
|
{conceptoSearchResults.map((cp, i) => (
|
|
<button
|
|
key={i}
|
|
type="button"
|
|
className="w-full text-left p-3 rounded-lg hover:bg-accent transition-colors border mb-1"
|
|
onClick={() => selectConceptoPrevio(cp)}
|
|
>
|
|
<div className="flex justify-between items-start">
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium text-sm truncate">{cp.descripcion}</p>
|
|
<div className="flex gap-3 text-xs text-muted-foreground mt-1">
|
|
<span className="font-mono">{cp.claveProdServ}</span>
|
|
{cp.claveUnidad && <span>{cp.claveUnidad}</span>}
|
|
<span className={cp.tipoCfdi === 'EMITIDO' ? 'text-blue-600' : 'text-green-600'}>
|
|
{cp.tipoCfdi === 'EMITIDO' ? 'Emitido' : 'Recibido'}
|
|
</span>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
{cp.tipoCfdi === 'EMITIDO' ? cp.nombreReceptor : cp.nombreEmisor}
|
|
{' · '}
|
|
{toCfdiDate(cp.fechaEmision).toLocaleDateString('es-MX')}
|
|
</p>
|
|
</div>
|
|
<div className="text-right flex-shrink-0 ml-3">
|
|
<p className="font-bold text-sm">${Number(cp.valorUnitario).toLocaleString('es-MX', { minimumFractionDigits: 2 })}</p>
|
|
{Number(cp.ivaTraslado) > 0 && (
|
|
<p className="text-xs text-muted-foreground">+IVA ${Number(cp.ivaTraslado).toLocaleString('es-MX', { minimumFractionDigits: 2 })}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<main className="p-6">
|
|
{lcoStatusQuery.data?.hasRecentLcoRejection && (
|
|
<div className="mb-4 rounded-md border border-amber-500/50 bg-amber-50 dark:bg-amber-950/30 px-4 py-3 flex items-start gap-3">
|
|
<AlertTriangle className="h-5 w-5 text-amber-600 flex-shrink-0 mt-0.5" />
|
|
<div className="text-sm text-amber-900 dark:text-amber-100">
|
|
<strong>CSD aún en proceso de validación</strong> — espera 24 horas antes de emitir tu factura.
|
|
</div>
|
|
</div>
|
|
)}
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
{/* Timbres */}
|
|
{timbres && (
|
|
<div className="flex items-center gap-2 text-sm flex-wrap">
|
|
<Receipt className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-muted-foreground">Timbres:</span>
|
|
<span className={cn('font-medium', (timbres.totalDisponibles ?? timbres.disponibles ?? 0) <= 5 ? 'text-red-600' : 'text-green-600')}>
|
|
{timbres.configured
|
|
? `${timbres.disponibles ?? 0} del plan${(timbres.adicionales?.disponibles ?? 0) > 0 ? ` + ${timbres.adicionales!.disponibles} adicionales` : ''}`
|
|
: 'No configurado'}
|
|
</span>
|
|
<a href="/facturacion/timbres" className="text-xs text-primary hover:underline ml-2">
|
|
Comprar más →
|
|
</a>
|
|
</div>
|
|
)}
|
|
|
|
{/* Datos del Comprobante */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Datos del Comprobante</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid gap-4 md:grid-cols-3">
|
|
<div className="space-y-2">
|
|
<Label>Tipo de Comprobante</Label>
|
|
<Select value={tipoComprobante} onValueChange={handleTipoChange}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="I">I - Ingreso</SelectItem>
|
|
<SelectItem value="E">E - Egreso (Nota de Crédito)</SelectItem>
|
|
<SelectItem value="P">P - Pago</SelectItem>
|
|
<SelectItem value="T">T - Traslado</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
{emisorRegimenes.length >= 2 && (
|
|
<div className="space-y-2">
|
|
<Label>Régimen del Emisor</Label>
|
|
<Select value={emisorRegimen} onValueChange={handleEmisorRegimenChange}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
{emisorRegimenes.map(r => (
|
|
<SelectItem key={r.clave} value={r.clave}>{r.clave} - {r.descripcion}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
{tipoComprobante === 'I' && (
|
|
<div className="space-y-2">
|
|
<Label>Factura Global</Label>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const newVal = !isGlobal;
|
|
setIsGlobal(newVal);
|
|
if (newVal) {
|
|
// Obtener CP del tenant para factura global
|
|
apiClient.get('/facturacion/datos-fiscales').then(r => {
|
|
setReceptor(prev => ({ ...prev, zip: r.data?.codigoPostal || '' }));
|
|
}).catch(() => {});
|
|
setReceptor({
|
|
legalName: 'PUBLICO EN GENERAL',
|
|
taxId: 'XAXX010101000',
|
|
taxSystem: '616',
|
|
email: '',
|
|
zip: '',
|
|
});
|
|
setUsoCfdi('S01');
|
|
} else {
|
|
setReceptor({ legalName: '', taxId: '', taxSystem: '601', email: '', zip: '' });
|
|
setUsoCfdi('G03');
|
|
}
|
|
}}
|
|
className={cn(
|
|
'w-full h-10 rounded-md border px-3 text-sm text-left transition-colors',
|
|
isGlobal
|
|
? 'border-primary bg-primary/10 text-primary font-medium'
|
|
: 'border-input hover:bg-accent'
|
|
)}
|
|
>
|
|
{isGlobal ? '✓ Factura Global activada' : 'No (factura individual)'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
{isGlobal && tipoComprobante === 'I' && (
|
|
<>
|
|
<div className="space-y-2">
|
|
<Label>Periodicidad</Label>
|
|
<Select value={globalPeriodicidad} onValueChange={setGlobalPeriodicidad}>
|
|
<SelectTrigger>{PERIODICIDAD_LABELS[globalPeriodicidad]}</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="day">Diario</SelectItem>
|
|
<SelectItem value="week">Semanal</SelectItem>
|
|
<SelectItem value="fortnight">Quincenal</SelectItem>
|
|
<SelectItem value="month">Mensual</SelectItem>
|
|
<SelectItem value="two_months">Bimestral</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Mes</Label>
|
|
<Select value={globalMes} onValueChange={setGlobalMes}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="01">01 - Enero</SelectItem>
|
|
<SelectItem value="02">02 - Febrero</SelectItem>
|
|
<SelectItem value="03">03 - Marzo</SelectItem>
|
|
<SelectItem value="04">04 - Abril</SelectItem>
|
|
<SelectItem value="05">05 - Mayo</SelectItem>
|
|
<SelectItem value="06">06 - Junio</SelectItem>
|
|
<SelectItem value="07">07 - Julio</SelectItem>
|
|
<SelectItem value="08">08 - Agosto</SelectItem>
|
|
<SelectItem value="09">09 - Septiembre</SelectItem>
|
|
<SelectItem value="10">10 - Octubre</SelectItem>
|
|
<SelectItem value="11">11 - Noviembre</SelectItem>
|
|
<SelectItem value="12">12 - Diciembre</SelectItem>
|
|
<SelectItem value="13">13 - Ene-Feb</SelectItem>
|
|
<SelectItem value="14">14 - Mar-Abr</SelectItem>
|
|
<SelectItem value="15">15 - May-Jun</SelectItem>
|
|
<SelectItem value="16">16 - Jul-Ago</SelectItem>
|
|
<SelectItem value="17">17 - Sep-Oct</SelectItem>
|
|
<SelectItem value="18">18 - Nov-Dic</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Año</Label>
|
|
<Input type="number" value={globalAnio} onChange={e => setGlobalAnio(parseInt(e.target.value) || new Date().getFullYear())} min={2020} max={2030} />
|
|
</div>
|
|
</>
|
|
)}
|
|
{(tipoComprobante === 'I' || tipoComprobante === 'E') && (
|
|
<>
|
|
<div className="space-y-2">
|
|
<Label>Uso CFDI</Label>
|
|
<Select value={usoCfdi} onValueChange={setUsoCfdi}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
{usosCfdi?.map(u => (
|
|
<SelectItem key={u.clave} value={u.clave}>{u.clave} - {u.descripcion}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Exportación</Label>
|
|
<Select value={exportacion} onValueChange={setExportacion}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="01">01 - No aplica</SelectItem>
|
|
<SelectItem value="02">02 - Definitiva</SelectItem>
|
|
<SelectItem value="03">03 - Temporal</SelectItem>
|
|
<SelectItem value="04">04 - Definitiva (clave distinta A1)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Forma de Pago</Label>
|
|
<Select value={formaPago} onValueChange={setFormaPago}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
{formasPago?.map(f => (
|
|
<SelectItem key={f.clave} value={f.clave}>{f.clave} - {f.descripcion}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Método de Pago</Label>
|
|
<Select value={metodoPago} onValueChange={setMetodoPago}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
{metodosPago?.map(m => (
|
|
<SelectItem key={m.clave} value={m.clave}>{m.clave} - {m.descripcion}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Moneda</Label>
|
|
<Select value={moneda} onValueChange={setMoneda}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
{monedas?.map(m => (
|
|
<SelectItem key={m.clave} value={m.clave}>{m.clave} - {m.descripcion}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</>
|
|
)}
|
|
{tipoComprobante === 'P' && (
|
|
<div className="space-y-2">
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Uso CFDI: CP01 · Moneda: XXX · Forma/Método de pago se definen en el complemento
|
|
</p>
|
|
</div>
|
|
)}
|
|
{tipoComprobante === 'T' && (
|
|
<div className="space-y-2">
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Uso CFDI: S01 · Moneda: XXX · Sin forma ni método de pago
|
|
</p>
|
|
</div>
|
|
)}
|
|
{tipoComprobante !== 'P' && (
|
|
<div className="space-y-2">
|
|
<Label>Fecha de Emisión</Label>
|
|
<Input
|
|
type="datetime-local"
|
|
value={fechaEmision}
|
|
onChange={e => setFechaEmision(e.target.value)}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">Máximo 72 horas en el pasado. No se permiten fechas a futuro.</p>
|
|
</div>
|
|
)}
|
|
<div className="space-y-2">
|
|
<Label>Serie (opcional)</Label>
|
|
<Input value={serie} onChange={e => setSerie(e.target.value.toUpperCase())} placeholder="P" maxLength={10} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Folio (opcional)</Label>
|
|
<Input value={folio} onChange={e => setFolio(e.target.value.replace(/\D/g, ''))} placeholder="1001" maxLength={10} />
|
|
</div>
|
|
{tipoComprobante !== 'P' && metodoPago === 'PPD' && (
|
|
<div className="space-y-2">
|
|
<Label>Condiciones de pago</Label>
|
|
<Input value={condiciones} onChange={e => setCondiciones(e.target.value)} placeholder="Net 30 días" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Receptor */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Receptor</CardTitle>
|
|
<CardDescription>{isGlobal ? 'Público en general (factura global)' : 'Datos fiscales del cliente'}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{isGlobal && (
|
|
<div className="p-3 rounded-lg bg-muted text-sm mb-4">
|
|
<p><strong>RFC:</strong> XAXX010101000 · <strong>Nombre:</strong> PUBLICO EN GENERAL · <strong>Régimen:</strong> 616</p>
|
|
</div>
|
|
)}
|
|
{!isGlobal && (<>
|
|
<div className="grid gap-4 md:grid-cols-2" ref={rfcDropdownRef}>
|
|
<div className="space-y-2 relative">
|
|
<Label>RFC</Label>
|
|
<Input
|
|
value={receptor.taxId}
|
|
onChange={e => handleRfcSearch(e.target.value, 'rfc')}
|
|
onFocus={() => { if (receptor.taxId.length >= 3) handleRfcSearch(receptor.taxId, 'rfc'); }}
|
|
placeholder="XAXX010101000"
|
|
maxLength={14}
|
|
required
|
|
/>
|
|
{rfcSearchField === 'rfc' && rfcResults.length > 0 && (
|
|
<div className="absolute z-50 w-full mt-1 bg-white dark:bg-zinc-900 border rounded-lg shadow-lg max-h-48 overflow-y-auto">
|
|
{rfcResults.map(r => (
|
|
<button key={r.id} type="button" className="w-full text-left px-3 py-2 text-sm hover:bg-accent"
|
|
onClick={() => selectRfc(r)}>
|
|
<span className="font-mono font-bold">{r.rfc}</span>
|
|
<span className="text-muted-foreground ml-2">{r.razonSocial}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="space-y-2 relative">
|
|
<Label>Razón Social</Label>
|
|
<Input
|
|
value={receptor.legalName}
|
|
onChange={e => handleRfcSearch(e.target.value, 'name')}
|
|
onFocus={() => { if (receptor.legalName.length >= 3) handleRfcSearch(receptor.legalName, 'name'); }}
|
|
placeholder="Empresa SA de CV"
|
|
required
|
|
/>
|
|
{rfcSearchField === 'name' && rfcResults.length > 0 && (
|
|
<div className="absolute z-50 w-full mt-1 bg-white dark:bg-zinc-900 border rounded-lg shadow-lg max-h-48 overflow-y-auto">
|
|
{rfcResults.map(r => (
|
|
<button key={r.id} type="button" className="w-full text-left px-3 py-2 text-sm hover:bg-accent"
|
|
onClick={() => selectRfc(r)}>
|
|
<span className="font-medium">{r.razonSocial}</span>
|
|
<span className="text-muted-foreground ml-2 font-mono text-xs">{r.rfc}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Régimen Fiscal</Label>
|
|
<Select value={receptor.taxSystem} onValueChange={v => setReceptor({ ...receptor, taxSystem: v })}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="601">601 - General de Ley PM</SelectItem>
|
|
<SelectItem value="603">603 - PM Fines no Lucrativos</SelectItem>
|
|
<SelectItem value="605">605 - Sueldos y Salarios</SelectItem>
|
|
<SelectItem value="606">606 - Arrendamiento</SelectItem>
|
|
<SelectItem value="608">608 - Demás ingresos</SelectItem>
|
|
<SelectItem value="612">612 - PF Actividades Empresariales</SelectItem>
|
|
<SelectItem value="616">616 - Sin obligaciones fiscales</SelectItem>
|
|
<SelectItem value="621">621 - Incorporación Fiscal</SelectItem>
|
|
<SelectItem value="625">625 - Plataformas Tecnológicas</SelectItem>
|
|
<SelectItem value="626">626 - RESICO</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Código Postal</Label>
|
|
<Input value={receptor.zip} onChange={e => setReceptor({ ...receptor, zip: e.target.value.replace(/\D/g, '').slice(0, 5) })} placeholder="06600" maxLength={5} required />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Email (opcional)</Label>
|
|
<Input type="email" value={receptor.email} onChange={e => setReceptor({ ...receptor, email: e.target.value })} placeholder="cliente@empresa.com" />
|
|
</div>
|
|
</div>
|
|
{isExtranjero && (
|
|
<div className="grid gap-4 md:grid-cols-2 mt-4 p-3 rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950">
|
|
<p className="text-sm font-medium text-blue-700 dark:text-blue-300 md:col-span-2">Cliente Extranjero</p>
|
|
<div className="space-y-2">
|
|
<Label>Tax ID / Num. Registro Fiscal</Label>
|
|
<Input value={extranjeroTaxId} onChange={e => setExtranjeroTaxId(e.target.value)} placeholder="198912171234" required />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>País</Label>
|
|
<Select value={extranjeroCountry} onValueChange={setExtranjeroCountry}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
{PAISES.map(p => (
|
|
<SelectItem key={p.code} value={p.code}>{p.name} ({p.code})</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Complemento de Pago */}
|
|
{config.needsPaymentComplement && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Complemento de Pago</CardTitle>
|
|
<CardDescription>Datos del pago recibido contra un CFDI con método PPD</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<div className="space-y-2 md:col-span-2 relative">
|
|
<Label>UUID del CFDI a pagar</Label>
|
|
<Input
|
|
value={pagoUuid}
|
|
onChange={e => setPagoUuid(e.target.value.toUpperCase())}
|
|
onFocus={() => { if (cfdisPpd.length > 0) setShowPpdDropdown(true); }}
|
|
placeholder={cfdisPpd.length > 0 ? 'Selecciona una factura PPD...' : 'Ingresa el RFC del receptor primero'}
|
|
required
|
|
/>
|
|
{showPpdDropdown && cfdisPpd.length > 0 && (
|
|
<div className="absolute z-50 w-full mt-1 bg-white dark:bg-zinc-900 border rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
|
{cfdisPpd.map(c => (
|
|
<button
|
|
key={c.uuid}
|
|
type="button"
|
|
className="w-full text-left px-3 py-2.5 text-sm hover:bg-accent border-b last:border-b-0"
|
|
onClick={() => selectCfdiPpd(c)}
|
|
>
|
|
<div className="flex justify-between items-center">
|
|
<div>
|
|
<span className="font-mono text-xs">{c.uuid.substring(0, 8)}...</span>
|
|
{c.serie && c.folio && <span className="ml-2 text-muted-foreground">{c.serie}-{c.folio}</span>}
|
|
</div>
|
|
<span className="font-bold">${Number(c.saldoPendiente).toLocaleString('es-MX', { minimumFractionDigits: 2 })}</span>
|
|
</div>
|
|
<div className="text-xs text-muted-foreground mt-0.5">
|
|
{toCfdiDate(c.fechaEmision).toLocaleDateString('es-MX')} · Total: ${Number(c.totalMxn).toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
{cfdisPpd.length === 0 && receptor.taxId.length >= 3 && tipoComprobante === 'P' && (
|
|
<p className="text-xs text-muted-foreground mt-1">No se encontraron facturas PPD pendientes para este RFC</p>
|
|
)}
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Fecha de pago</Label>
|
|
<Input
|
|
type="date"
|
|
value={pagoFecha}
|
|
onChange={e => setPagoFecha(e.target.value)}
|
|
max={new Date().toISOString().slice(0, 10)}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Forma de Pago</Label>
|
|
<Select value={pagoFormaPago} onValueChange={setPagoFormaPago}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
{formasPago?.filter(f => f.clave !== '99').map(f => (
|
|
<SelectItem key={f.clave} value={f.clave}>{f.clave} - {f.descripcion}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Monto del pago</Label>
|
|
<Input type="number" min="0" step="0.01" value={pagoMonto || ''} onChange={e => setPagoMonto(parseFloat(e.target.value) || 0)} placeholder="0.00" required />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Número de parcialidad</Label>
|
|
<Input type="number" min="1" value={pagoParcialidad} onChange={e => setPagoParcialidad(parseInt(e.target.value) || 1)} required />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Saldo anterior</Label>
|
|
<Input type="number" min="0" step="0.01" value={pagoSaldoAnterior || ''} onChange={e => setPagoSaldoAnterior(parseFloat(e.target.value) || 0)} placeholder="0.00" required />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Desglose de impuestos del pago */}
|
|
<div className="border-t pt-4">
|
|
<p className="text-sm font-medium text-muted-foreground mb-3">Desglose de Impuestos del Pago</p>
|
|
<div className="grid gap-4 md:grid-cols-3">
|
|
<div className="space-y-2">
|
|
<Label>Base IVA</Label>
|
|
<Input type="number" min="0" step="0.01" value={pagoIvaBase || ''} onChange={e => setPagoIvaBase(parseFloat(e.target.value) || 0)} placeholder="0.00" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Tasa IVA</Label>
|
|
<Select value={String(pagoIvaTasa)} onValueChange={v => setPagoIvaTasa(parseFloat(v))}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="0.16">16%</SelectItem>
|
|
<SelectItem value="0.08">8% (Frontera)</SelectItem>
|
|
<SelectItem value="0">0% (Exento)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Monto IVA</Label>
|
|
<Input type="number" value={pagoIvaMonto.toFixed(2)} disabled className="bg-muted" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Cuenta Predial — solo régimen 606 (Arrendamiento) */}
|
|
{emisorRegimen === '606' && config.needsConceptos && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Datos del Inmueble</CardTitle>
|
|
<CardDescription>Obligatorio para arrendamiento (SAT)</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-2">
|
|
<Label>No. Cuenta Predial</Label>
|
|
<Input
|
|
value={cuentaPredial}
|
|
onChange={e => setCuentaPredial(e.target.value.replace(/[^0-9a-zA-Z]/g, '').toUpperCase())}
|
|
placeholder="Ej. 15956011002"
|
|
maxLength={150}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Número de cuenta predial del inmueble arrendado. Si contiene letras o guiones, sustitúyalos por "0".
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Conceptos (Ingreso, Egreso, Traslado) */}
|
|
{config.needsConceptos && (
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<CardTitle className="text-base">Conceptos</CardTitle>
|
|
<CardDescription>
|
|
{tipoComprobante === 'T' ? 'Mercancías a trasladar (precio en 0)' : 'Productos o servicios a facturar'}
|
|
</CardDescription>
|
|
</div>
|
|
<Button type="button" variant="outline" size="sm" onClick={addConcepto}>
|
|
<Plus className="h-4 w-4 mr-1" /> Concepto
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{conceptos.map((c, idx) => (
|
|
<div key={idx} className="p-4 border rounded-lg space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium text-muted-foreground">Concepto {idx + 1}</span>
|
|
<div className="flex gap-1">
|
|
<Button type="button" variant="ghost" size="sm" className="h-7 text-xs gap-1" onClick={() => openConceptoSearch(idx)}>
|
|
<FileSearch className="h-3.5 w-3.5" />
|
|
Buscar en facturas
|
|
</Button>
|
|
{conceptos.length > 1 && (
|
|
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => removeConcepto(idx)}>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|
<div className="space-y-2 md:col-span-2">
|
|
<Label>Descripción</Label>
|
|
<Input value={c.description} onChange={e => updateConcepto(idx, 'description', e.target.value)} placeholder="Servicio de consultoría" required />
|
|
</div>
|
|
<div className="space-y-2 relative">
|
|
<Label>Clave Producto SAT</Label>
|
|
<div className="relative">
|
|
<Input
|
|
value={searchingIdx === idx ? prodSearch : c.productKeyLabel || c.productKey}
|
|
onChange={e => handleSearchProduct(e.target.value, idx)}
|
|
onFocus={() => { setSearchingIdx(idx); setProdSearch(c.productKey); }}
|
|
placeholder="Buscar clave SAT..."
|
|
required
|
|
/>
|
|
<Search className="absolute right-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
</div>
|
|
{searchingIdx === idx && prodResults.length > 0 && (
|
|
<div className="absolute z-50 w-full mt-1 bg-white dark:bg-zinc-900 border rounded-lg shadow-lg max-h-48 overflow-y-auto">
|
|
{prodResults.map(p => (
|
|
<button key={p.clave} type="button" className="w-full text-left px-3 py-2 text-sm hover:bg-accent" onClick={() => selectProduct(idx, p.clave, p.descripcion)}>
|
|
<span className="font-mono font-bold">{p.clave}</span>
|
|
<span className="text-muted-foreground ml-2">{p.descripcion}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Unidad</Label>
|
|
<Select value={c.unitKey} onValueChange={v => updateConcepto(idx, 'unitKey', v)}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
{filteredUnidades?.map(u => (
|
|
<SelectItem key={u.clave} value={u.clave}>{u.clave} - {u.descripcion}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Cantidad</Label>
|
|
<Input type="number" min="1" step="0.01" value={c.quantity} onChange={e => updateConcepto(idx, 'quantity', parseFloat(e.target.value) || 1)} required />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Objeto de Impuesto</Label>
|
|
<Select value={c.objetoImp} onValueChange={v => updateConcepto(idx, 'objetoImp', v)}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="01">01 - No objeto de impuesto</SelectItem>
|
|
<SelectItem value="02">02 - Sí objeto de impuesto</SelectItem>
|
|
<SelectItem value="03">03 - Sí objeto, no obligado al desglose</SelectItem>
|
|
<SelectItem value="04">04 - Sí objeto, no causa impuesto</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
{tipoComprobante !== 'T' && (
|
|
<>
|
|
<div className="space-y-2">
|
|
<Label>Precio Unitario (sin IVA)</Label>
|
|
<Input type="number" min="0" step="0.01" value={c.price || ''} onChange={e => updateConcepto(idx, 'price', parseFloat(e.target.value) || 0)} placeholder="0.00" required />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Descuento</Label>
|
|
<Input type="number" min="0" step="0.01" value={c.discount || ''} onChange={e => updateConcepto(idx, 'discount', parseFloat(e.target.value) || 0)} placeholder="0.00" />
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
{/* Impuestos del concepto */}
|
|
{tipoComprobante !== 'T' && c.objetoImp === '02' && (
|
|
<div className="border-t pt-3 mt-1">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-xs font-medium text-muted-foreground">Impuestos</span>
|
|
<div className="flex gap-1">
|
|
<Select onValueChange={v => {
|
|
const opt = TAX_OPTIONS.traslado.find(o => `${o.type}-${o.rate}-${o.factor}` === v);
|
|
if (!opt) return;
|
|
const updated = [...conceptos];
|
|
updated[idx].taxes = [...updated[idx].taxes, { category: 'traslado', type: opt.type, rate: opt.rate, factor: opt.factor }];
|
|
setConceptos(updated);
|
|
}}>
|
|
<SelectTrigger className="h-7 text-xs w-auto gap-1">
|
|
<Plus className="h-3 w-3" />
|
|
<span>Traslado</span>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{TAX_OPTIONS.traslado.map(o => (
|
|
<SelectItem key={`${o.type}-${o.rate}-${o.factor}`} value={`${o.type}-${o.rate}-${o.factor}`}>{o.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Select onValueChange={v => {
|
|
const opt = TAX_OPTIONS.retencion.find(o => `${o.type}-${o.rate}` === v);
|
|
if (!opt) return;
|
|
const updated = [...conceptos];
|
|
updated[idx].taxes = [...updated[idx].taxes, { category: 'retencion', type: opt.type, rate: opt.rate, factor: opt.factor }];
|
|
setConceptos(updated);
|
|
}}>
|
|
<SelectTrigger className="h-7 text-xs w-auto gap-1">
|
|
<Plus className="h-3 w-3" />
|
|
<span>Retención</span>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{TAX_OPTIONS.retencion.map(o => (
|
|
<SelectItem key={`${o.type}-${o.rate}`} value={`${o.type}-${o.rate}`}>{o.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
{c.taxes.length === 0 && (
|
|
<p className="text-xs text-muted-foreground italic">Sin impuestos. Agrega traslados o retenciones.</p>
|
|
)}
|
|
<div className="space-y-1">
|
|
{c.taxes.map((tax, tIdx) => {
|
|
const base = calcConcepto(c).base;
|
|
const amount = base * tax.rate;
|
|
return (
|
|
<div key={tIdx} className="flex items-center justify-between text-xs py-1 px-2 rounded bg-muted/50">
|
|
<div className="flex items-center gap-2">
|
|
<span className={cn(
|
|
'px-1.5 py-0.5 rounded font-medium',
|
|
tax.category === 'traslado' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300' : 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300'
|
|
)}>
|
|
{tax.category === 'traslado' ? 'T' : 'R'}
|
|
</span>
|
|
<span>{tax.type} {tax.factor === 'Exento' ? 'Exento' : `${(tax.rate * 100).toFixed(4).replace(/\.?0+$/, '')}%`}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className={tax.category === 'retencion' ? 'text-red-600' : ''}>
|
|
{tax.category === 'retencion' ? '-' : ''}${amount.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
|
</span>
|
|
<button type="button" onClick={() => {
|
|
const updated = [...conceptos];
|
|
updated[idx].taxes = updated[idx].taxes.filter((_, i) => i !== tIdx);
|
|
setConceptos(updated);
|
|
}} className="text-muted-foreground hover:text-destructive">
|
|
<Trash2 className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{tipoComprobante !== 'T' && (
|
|
<div className="text-right text-sm text-muted-foreground">
|
|
Importe: ${((c.price * c.quantity) - c.discount).toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* CFDIs Relacionados (Ingreso y Egreso) */}
|
|
{config.needsRelated && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">CFDIs Relacionados</CardTitle>
|
|
<CardDescription>
|
|
Selecciona el CFDI emitido al mismo receptor que se relaciona con esta factura.
|
|
{cfdisRelacionables.length > 0
|
|
? ` ${cfdisRelacionables.length} CFDI(s) disponibles para este receptor.`
|
|
: receptor.taxId.length >= 12
|
|
? ' Sin CFDIs previos a este receptor — puedes ingresar el UUID manualmente.'
|
|
: ' Selecciona primero un receptor.'}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<div className="space-y-2 relative">
|
|
<Label>UUID del CFDI relacionado</Label>
|
|
<Input
|
|
value={relatedUuid}
|
|
onChange={e => setRelatedUuid(e.target.value.toUpperCase())}
|
|
onFocus={() => { if (cfdisRelacionables.length > 0) setShowRelDropdown(true); }}
|
|
placeholder={cfdisRelacionables.length > 0 ? 'Selecciona un CFDI o pega un UUID...' : 'XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX'}
|
|
/>
|
|
{showRelDropdown && cfdisRelacionables.length > 0 && (
|
|
<div className="absolute z-50 w-full mt-1 bg-white dark:bg-zinc-900 border rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
|
{cfdisRelacionables.map(c => (
|
|
<button
|
|
key={c.uuid}
|
|
type="button"
|
|
onClick={() => selectCfdiRelacionable(c)}
|
|
className="w-full text-left px-3 py-2 hover:bg-muted/60 border-b last:border-0 text-sm"
|
|
>
|
|
<div className="font-mono text-xs">{c.uuid}</div>
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<span className="px-1.5 py-0.5 rounded bg-muted">{c.tipoComprobante}</span>
|
|
{c.serie || c.folio ? <span>{c.serie || ''}{c.folio ? `-${c.folio}` : ''}</span> : null}
|
|
<span>${Number(c.totalMxn).toLocaleString('es-MX', { minimumFractionDigits: 2 })}</span>
|
|
<span>{toCfdiDate(c.fechaEmision).toLocaleDateString('es-MX')}</span>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Tipo de Relación</Label>
|
|
<Select value={relatedRelationship} onValueChange={setRelatedRelationship}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="01">01 - Nota de crédito</SelectItem>
|
|
<SelectItem value="02">02 - Nota de débito</SelectItem>
|
|
<SelectItem value="03">03 - Devolución de mercancía</SelectItem>
|
|
<SelectItem value="04">04 - Sustitución de CFDI previo</SelectItem>
|
|
<SelectItem value="05">05 - Traslados de mercancía</SelectItem>
|
|
<SelectItem value="06">06 - Factura por traslados previos</SelectItem>
|
|
<SelectItem value="07">07 - Aplicación de anticipo</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Resumen y Emitir */}
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-1">
|
|
{config.needsConceptos && tipoComprobante !== 'T' && (
|
|
<div className="flex gap-6 text-sm flex-wrap">
|
|
<div>
|
|
<span className="text-muted-foreground">Subtotal: </span>
|
|
<span className="font-medium">${subtotal.toLocaleString('es-MX', { minimumFractionDigits: 2 })}</span>
|
|
</div>
|
|
{totalDescuento > 0 && (
|
|
<div>
|
|
<span className="text-muted-foreground">Descuento: </span>
|
|
<span className="font-medium text-red-600">-${totalDescuento.toLocaleString('es-MX', { minimumFractionDigits: 2 })}</span>
|
|
</div>
|
|
)}
|
|
{totalTraslados > 0 && (
|
|
<div>
|
|
<span className="text-muted-foreground">Traslados: </span>
|
|
<span className="font-medium">${totalTraslados.toLocaleString('es-MX', { minimumFractionDigits: 2 })}</span>
|
|
</div>
|
|
)}
|
|
{totalRetenciones > 0 && (
|
|
<div>
|
|
<span className="text-muted-foreground">Retenciones: </span>
|
|
<span className="font-medium text-red-600">-${totalRetenciones.toLocaleString('es-MX', { minimumFractionDigits: 2 })}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{config.needsPaymentComplement && pagoIvaBase > 0 && (
|
|
<div className="text-sm text-muted-foreground">
|
|
IVA del pago: ${pagoIvaMonto.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
|
</div>
|
|
)}
|
|
<div className="text-xl font-bold">
|
|
Total: ${total.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
|
</div>
|
|
</div>
|
|
<Button type="submit" size="lg" disabled={emitir.isPending} className="gap-2">
|
|
<Send className="h-4 w-4" />
|
|
{emitir.isPending ? 'Timbrando...' : `Emitir ${config.label}`}
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</form>
|
|
</main>
|
|
</>
|
|
);
|
|
}
|