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:
Horux Dev
2026-04-29 21:03:41 +00:00
parent 066ba7deda
commit e7dbae1ab7
18 changed files with 1076 additions and 111 deletions

View File

@@ -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>