This reverts commit d3b326e.
The deployment caused reports of blank screens and 400 errors. Reverting to restore stable state while investigating root cause.
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-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>
|
|
</>
|
|
);
|
|
}
|