feat: conceptos tab, filters, backfill, facturapi live keys, fixes
- Add Conceptos tab in CFDI page with column filters, sorting, pagination - Add GET /cfdi/conceptos endpoint with filters and orderBy - Backfill cfdi_conceptos from legacy XMLs (824k concepts inserted) - Fix CFDI delete button (bypass subscription check, add alerts) - Fix export to Excel (fetch all filtered results, limit 10k) - Fix facturacion page concepto delete bug (immutable updates, unique ids) - Add Facturapi live key auto-generation and caching - Fix SAT fechaPagoP parsing - Add metrics cache support for current year - Increase DB pool max to 15
This commit is contained in:
@@ -49,6 +49,7 @@ const RELACION_OPTIONS: Record<string, { value: string; label: string }[]> = {
|
||||
};
|
||||
|
||||
interface ConceptoForm {
|
||||
id: string;
|
||||
description: string;
|
||||
productKey: string;
|
||||
productKeyLabel: string;
|
||||
@@ -65,7 +66,7 @@ const defaultTaxes: TaxLine[] = [
|
||||
];
|
||||
|
||||
const emptyConcepto: ConceptoForm = {
|
||||
description: '', productKey: '', productKeyLabel: '',
|
||||
id: '', description: '', productKey: '', productKeyLabel: '',
|
||||
unitKey: 'E48', quantity: 1, price: 0, discount: 0, objetoImp: '02',
|
||||
taxes: [...defaultTaxes],
|
||||
};
|
||||
@@ -327,7 +328,7 @@ export default function FacturacionPage() {
|
||||
const [condiciones, setCondiciones] = useState('');
|
||||
|
||||
// Conceptos
|
||||
const [conceptos, setConceptos] = useState<ConceptoForm[]>([{ ...emptyConcepto }]);
|
||||
const [conceptos, setConceptos] = useState<ConceptoForm[]>([{ ...emptyConcepto, id: crypto.randomUUID() }]);
|
||||
|
||||
// CFDIs relacionados (Ingreso / Egreso)
|
||||
const [relatedDocs, setRelatedDocs] = useState<RelatedDocForm[]>([]);
|
||||
@@ -365,7 +366,7 @@ export default function FacturacionPage() {
|
||||
setSerie('');
|
||||
setFolio('');
|
||||
setCondiciones('');
|
||||
setConceptos([{ ...emptyConcepto, unitKey: defaultUnit }]);
|
||||
setConceptos([{ ...emptyConcepto, unitKey: defaultUnit, id: crypto.randomUUID() }]);
|
||||
setRelatedDocs([]);
|
||||
setPagoUuid('');
|
||||
setPagoMonto(0);
|
||||
@@ -514,7 +515,7 @@ export default function FacturacionPage() {
|
||||
setMetodoPago(c.defaultMetodoPago);
|
||||
// Resetear conceptos con unidad default según tipo
|
||||
const defaultUnit = tipo === 'T' ? 'H87' : 'E48';
|
||||
setConceptos([{ ...emptyConcepto, unitKey: defaultUnit }]);
|
||||
setConceptos([{ ...emptyConcepto, unitKey: defaultUnit, id: crypto.randomUUID() }]);
|
||||
};
|
||||
|
||||
// Unidades de servicio que no aplican para Traslado
|
||||
@@ -532,30 +533,28 @@ export default function FacturacionPage() {
|
||||
};
|
||||
|
||||
const selectProduct = (idx: number, clave: string, descripcion: string) => {
|
||||
const updated = [...conceptos];
|
||||
updated[idx].productKey = clave;
|
||||
updated[idx].productKeyLabel = `${clave} - ${descripcion}`;
|
||||
setConceptos(updated);
|
||||
setConceptos(prev => prev.map((c, i) => i === idx ? { ...c, productKey: clave, productKeyLabel: `${clave} - ${descripcion}` } : c));
|
||||
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(prev => prev.map((c, i) => {
|
||||
if (i !== idx) return c;
|
||||
const updated = { ...c, [field]: value } as ConceptoForm;
|
||||
// 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.taxes = recommended;
|
||||
} else {
|
||||
updated.taxes = [...defaultTaxes];
|
||||
}
|
||||
}
|
||||
}
|
||||
setConceptos(updated);
|
||||
return updated;
|
||||
}));
|
||||
};
|
||||
|
||||
// Aplica recomendación de impuestos a un concepto si corresponde (solo tipo I)
|
||||
@@ -571,12 +570,14 @@ export default function FacturacionPage() {
|
||||
};
|
||||
|
||||
const addConcepto = () => {
|
||||
const newConcepto = applyTaxRecommendation({ ...emptyConcepto });
|
||||
setConceptos([...conceptos, newConcepto]);
|
||||
const newConcepto = applyTaxRecommendation({ ...emptyConcepto, id: crypto.randomUUID() });
|
||||
setConceptos(prev => [...prev, newConcepto]);
|
||||
};
|
||||
const removeConcepto = (idx: number) => {
|
||||
if (conceptos.length === 1) return;
|
||||
setConceptos(conceptos.filter((_, i) => i !== idx));
|
||||
setConceptos(prev => {
|
||||
if (prev.length === 1) return prev;
|
||||
return prev.filter((_, i) => i !== idx);
|
||||
});
|
||||
};
|
||||
|
||||
// Cálculos
|
||||
@@ -708,7 +709,7 @@ export default function FacturacionPage() {
|
||||
<p className="text-muted-foreground mt-4">Total</p>
|
||||
<p className="text-2xl font-bold">${result.total.toLocaleString('es-MX', { minimumFractionDigits: 2 })}</p>
|
||||
</div>
|
||||
<Button onClick={() => { setResult(null); setConceptos([{ ...emptyConcepto }]); }}>
|
||||
<Button onClick={() => { setResult(null); setConceptos([{ ...emptyConcepto, id: crypto.randomUUID() }]); }}>
|
||||
Emitir otra factura
|
||||
</Button>
|
||||
</CardContent>
|
||||
@@ -1258,7 +1259,7 @@ export default function FacturacionPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{conceptos.map((c, idx) => (
|
||||
<div key={idx} className="p-4 border rounded-lg space-y-3">
|
||||
<div key={c.id} 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">
|
||||
@@ -1350,9 +1351,7 @@ export default function FacturacionPage() {
|
||||
<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);
|
||||
setConceptos(prev => prev.map((c, i) => i === idx ? { ...c, taxes: [...c.taxes, { category: 'traslado', type: opt.type, rate: opt.rate, factor: opt.factor }] } : c));
|
||||
}}>
|
||||
<SelectTrigger className="h-7 text-xs w-auto gap-1">
|
||||
<Plus className="h-3 w-3" />
|
||||
@@ -1367,9 +1366,7 @@ export default function FacturacionPage() {
|
||||
<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);
|
||||
setConceptos(prev => prev.map((c, i) => i === idx ? { ...c, taxes: [...c.taxes, { category: 'retencion', type: opt.type, rate: opt.rate, factor: opt.factor }] } : c));
|
||||
}}>
|
||||
<SelectTrigger className="h-7 text-xs w-auto gap-1">
|
||||
<Plus className="h-3 w-3" />
|
||||
@@ -1406,9 +1403,7 @@ export default function FacturacionPage() {
|
||||
{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);
|
||||
setConceptos(prev => prev.map((c, i) => i === idx ? { ...c, taxes: c.taxes.filter((_, ti) => ti !== tIdx) } : c));
|
||||
}} className="text-muted-foreground hover:text-destructive">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user