feature/partidas-clarion-csv-validaciones-expo
This commit is contained in:
@@ -232,6 +232,13 @@ def _do_scan_file(job_id: str, model_target: str, config: Optional[str] = None,
|
||||
inv_type_value = "TEM"
|
||||
if model_target == "invoice_series" and inv_type_value in ("DEF", "MATDE", "EXDEF"):
|
||||
template_id = "imp_def_series"
|
||||
if meta.get("operation_type") == "exp" and model_target == "invoice_details":
|
||||
template_id = "exp_def_partidas"
|
||||
|
||||
logger.info(
|
||||
"Scan job %s template_id=%s model_target=%s job_type_override=%s",
|
||||
job_id, template_id, model_target, job_type_override,
|
||||
)
|
||||
|
||||
# --- Series de Importación Definitiva: flujo específico (Clarion VALIDA_TODA_SERIES_IMPO_DEF / VALIDA_PARCIAL) ---
|
||||
DEF_SERIES_TEMPLATE_OR_TYPE = (
|
||||
@@ -1206,6 +1213,7 @@ def _do_scan_file(job_id: str, model_target: str, config: Optional[str] = None,
|
||||
|
||||
# --- Partidas Exportación Definitiva: Clarion VALIDA_TODA_PAR_EXPO / VALIDA_PARCIAL_PAR_EXPO / VALIDACIONES_PAR_EXPO ---
|
||||
if model_target == "invoice_details" and template_id == "exp_def_partidas":
|
||||
logger.info("Partidas expo scan: running validation for job %s", job_id)
|
||||
try:
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader
|
||||
from api.v1.modules.a76.items.models import LineItem
|
||||
@@ -1425,8 +1433,6 @@ def _do_scan_file(job_id: str, model_target: str, config: Optional[str] = None,
|
||||
|
||||
with open(error_path, "w", encoding="utf-8") as f_err:
|
||||
for i, row in enumerate(rows_list, start=1):
|
||||
if i % 1000 == 0:
|
||||
self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count})
|
||||
row_norm = row_from_template(row, "exp_def_partidas", normalize_header)
|
||||
err = validate_row_partidas_expo(
|
||||
row_norm,
|
||||
@@ -1457,12 +1463,20 @@ def _do_scan_file(job_id: str, model_target: str, config: Optional[str] = None,
|
||||
error_count += 1
|
||||
error_lines_list.append(err["line"])
|
||||
f_err.write(json.dumps({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")}) + "\n")
|
||||
logger.info(
|
||||
"Partidas expo scan rechazo línea %s (col %s): %s",
|
||||
err["line"],
|
||||
err.get("col", ""),
|
||||
err.get("msg", ""),
|
||||
)
|
||||
if len(errors_detail) < 500:
|
||||
errors_detail.append({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")})
|
||||
processed_rows += 1
|
||||
|
||||
common_storage.store_error_lines(effective_job_type, job_id, error_lines_list)
|
||||
return common_responses.scan_result(job_id, processed_rows, error_count, errors_detail)
|
||||
return common_responses.scan_result(
|
||||
job_id, processed_rows, error_count, errors_detail, total_rows_in_file=total_rows
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Partidas exportación definitiva scan failed: %s", e)
|
||||
return {"status": "failed", "error": str(e)}
|
||||
@@ -3573,24 +3587,6 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
|
||||
error_path = common_storage.error_path_for_job(effective_job_type, job_id)
|
||||
error_lines = common_storage.get_error_lines(effective_job_type, job_id, error_path)
|
||||
|
||||
# Partidas exportación definitiva: inserción pendiente (Phase 2); solo cleanup y respuesta
|
||||
if model_target == "invoice_details" and meta.get("template_id") == "exp_def_partidas":
|
||||
common_storage.cleanup_import_job(
|
||||
effective_job_type, job_id,
|
||||
file_path=file_path,
|
||||
error_path=error_path,
|
||||
meta_path=meta_path,
|
||||
)
|
||||
return {
|
||||
"status": "finished",
|
||||
"inserted": 0,
|
||||
"skipped_invalid": len(error_lines),
|
||||
"skipped_missing_fk": 0,
|
||||
"skipped_duplicate": 0,
|
||||
"skipped_details": [],
|
||||
"message": "Validación de partidas de exportación completada. Inserción en BD pendiente de implementación (Phase 2).",
|
||||
}
|
||||
|
||||
# Si el upload fue de series (template_id imp_temp_series o imp_def_series), usar flujo series aunque model_target venga mal
|
||||
use_series_flow = (
|
||||
model_target == "invoice_series"
|
||||
@@ -4160,6 +4156,8 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
|
||||
_template_id_insert = meta.get("template_id") or (
|
||||
"imp_temp_header" if model_target == "invoice_header" else "imp_temp_details"
|
||||
)
|
||||
if job_type_override == "exp" and model_target == "invoice_details":
|
||||
_template_id_insert = "exp_def_partidas"
|
||||
if model_target == "invoice_header" and _template_id_insert == "imp_def_header":
|
||||
inv_type_value = "DEF"
|
||||
if model_target == "invoice_header" and _template_id_insert == "cmex_header":
|
||||
@@ -4179,6 +4177,11 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
|
||||
inv_type_value = "DEF"
|
||||
if model_target == "invoice_details" and _template_id_insert == "cmex_details":
|
||||
inv_type_value = "MEX"
|
||||
if model_target == "invoice_details" and _template_id_insert == "exp_def_partidas":
|
||||
op_type_value = OperationType("exp")
|
||||
inv_type_value = normalize_public_code(
|
||||
meta.get("tipo_factura") or footer_config.get("tipo_factura") or "AFIJO"
|
||||
) or "AFIJO"
|
||||
if _template_id_insert == "cmex_series":
|
||||
inv_type_value = "MEX"
|
||||
|
||||
@@ -4224,6 +4227,23 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
|
||||
|
||||
validator = ForeignKeyValidator(session, tenant_id, company_id)
|
||||
|
||||
error_msg_by_line: Dict[int, str] = {}
|
||||
if error_path and os.path.exists(error_path):
|
||||
try:
|
||||
with open(error_path, "r", encoding="utf-8") as f_err:
|
||||
for line in f_err:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rec = json.loads(line)
|
||||
if "line" in rec and "msg" in rec:
|
||||
error_msg_by_line[int(rec["line"])] = str(rec["msg"]).strip()
|
||||
except (json.JSONDecodeError, ValueError, TypeError):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
with open(file_path, 'r', encoding='utf-8-sig') as f:
|
||||
# Detect Delimiter
|
||||
sample = f.read(2048)
|
||||
@@ -4235,19 +4255,23 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
|
||||
|
||||
reader = csv.DictReader(f, dialect=dialect)
|
||||
|
||||
template_id = meta.get("template_id") or (
|
||||
"imp_temp_header" if model_target == "invoice_header" else "imp_temp_details"
|
||||
)
|
||||
template_id = _template_id_insert
|
||||
|
||||
for i, row in enumerate(reader, start=1):
|
||||
row_norm = row_from_template(row, template_id, normalize_header)
|
||||
if i in error_lines:
|
||||
skipped_invalid += 1
|
||||
inv_for_detail = (row_norm.get('NUMERO FACTURA') or row_norm.get('NUM FACTURA') or row_norm.get('FACTURA') or '').strip() if model_target == 'invoice_header' else ""
|
||||
if model_target == 'invoice_header':
|
||||
inv_for_detail = (row_norm.get('NUMERO FACTURA') or row_norm.get('NUM FACTURA') or row_norm.get('FACTURA') or '').strip()
|
||||
elif _template_id_insert == "exp_def_partidas":
|
||||
inv_for_detail = (row_norm.get('NUMERO FACTURA EXPO') or row_norm.get('NUMERO FACTURA EXPO.') or row_norm.get('FACTURA EXPO') or '').strip()
|
||||
else:
|
||||
inv_for_detail = (row_norm.get('NUMERO FACTURA') or row_norm.get('NUM FACTURA') or row_norm.get('FACTURA') or '').strip()
|
||||
reason = error_msg_by_line.get(i, "Línea marcada con error en el escaneo previo (revisar reporte de validación).")
|
||||
skipped_fk_details.append({
|
||||
"line": i,
|
||||
"invoice": inv_for_detail or "(vacío)",
|
||||
"reason": "Línea marcada con error en el escaneo previo (revisar reporte de validación).",
|
||||
"reason": reason,
|
||||
})
|
||||
continue
|
||||
|
||||
@@ -4664,26 +4688,50 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
|
||||
headers_to_insert.append(header)
|
||||
|
||||
elif model_target == 'invoice_details':
|
||||
invoice_number = (row_norm.get('NUMERO FACTURA') or row_norm.get('NUM FACTURA') or '').strip()
|
||||
if _template_id_insert == "exp_def_partidas":
|
||||
invoice_number = (
|
||||
row_norm.get('NUMERO FACTURA EXPO') or row_norm.get('NUMERO FACTURA EXPO.') or row_norm.get('FACTURA EXPO') or ''
|
||||
).strip()
|
||||
else:
|
||||
invoice_number = (row_norm.get('NUMERO FACTURA') or row_norm.get('NUM FACTURA') or '').strip()
|
||||
if not invoice_number:
|
||||
skipped_invalid += 1
|
||||
continue
|
||||
|
||||
cache_key = f"{invoice_number}|{inv_type_value}"
|
||||
if cache_key in invoice_id_cache:
|
||||
invoice_id = invoice_id_cache[cache_key]
|
||||
else:
|
||||
invoice_id = (
|
||||
session.query(InvoiceHeader.id)
|
||||
.filter(
|
||||
InvoiceHeader.tenant_id == tenant_id,
|
||||
InvoiceHeader.company_id == company_id,
|
||||
InvoiceHeader.invoice_number == invoice_number,
|
||||
InvoiceHeader.invoice_type == inv_type_value,
|
||||
if _template_id_insert == "exp_def_partidas":
|
||||
# Expo: lookup by invoice_number + operation_type only (no invoice_type filter, matching scan behavior)
|
||||
cache_key = f"{invoice_number}|exp"
|
||||
if cache_key in invoice_id_cache:
|
||||
invoice_id = invoice_id_cache[cache_key]
|
||||
else:
|
||||
invoice_id = (
|
||||
session.query(InvoiceHeader.id)
|
||||
.filter(
|
||||
InvoiceHeader.tenant_id == tenant_id,
|
||||
InvoiceHeader.company_id == company_id,
|
||||
InvoiceHeader.invoice_number == invoice_number,
|
||||
InvoiceHeader.operation_type == "exp",
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
invoice_id_cache[cache_key] = invoice_id
|
||||
invoice_id_cache[cache_key] = invoice_id
|
||||
else:
|
||||
cache_key = f"{invoice_number}|{inv_type_value}|{op_type_value.value}"
|
||||
if cache_key in invoice_id_cache:
|
||||
invoice_id = invoice_id_cache[cache_key]
|
||||
else:
|
||||
invoice_id = (
|
||||
session.query(InvoiceHeader.id)
|
||||
.filter(
|
||||
InvoiceHeader.tenant_id == tenant_id,
|
||||
InvoiceHeader.company_id == company_id,
|
||||
InvoiceHeader.invoice_number == invoice_number,
|
||||
InvoiceHeader.invoice_type == inv_type_value,
|
||||
InvoiceHeader.operation_type == op_type_value,
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
invoice_id_cache[cache_key] = invoice_id
|
||||
|
||||
if not invoice_id:
|
||||
logger.warning(
|
||||
@@ -4694,6 +4742,128 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
|
||||
skipped_missing_invoice += 1
|
||||
continue
|
||||
|
||||
# --- Partidas Exportación Definitiva: inserción real ---
|
||||
if _template_id_insert == "exp_def_partidas":
|
||||
part_num = (row_norm.get('NUM. PARTE') or row_norm.get('NUMPARTE') or row_norm.get('NUMERO PARTE') or row_norm.get('NUM PARTE') or '').strip()
|
||||
part_id = part_cache.get(part_num) if part_num else None
|
||||
if part_id is None and part_num:
|
||||
p = session.query(Part.id).filter(Part.part_number == part_num, Part.tenant_id == tenant_id, Part.company_id == company_id).first()
|
||||
if p:
|
||||
part_id = p.id
|
||||
part_cache[part_num] = part_id
|
||||
|
||||
line_num_val = (row_norm.get('LINEA EXPO') or row_norm.get('LINEA EXPO.') or row_norm.get('RENGLON EXPO'))
|
||||
line_num = parse_int(line_num_val) or (len(details_to_insert) + 1)
|
||||
|
||||
uom_code = (row_norm.get('UNIDAD DE MEDIDA') or row_norm.get('U.M.') or row_norm.get('UNIDAD MEDIDA') or '').strip().upper()
|
||||
uom_id = uom_id_by_code.get(uom_code) if uom_code else None
|
||||
bulk_key = (row_norm.get('CLAVE BULTOS') or row_norm.get('CLAVEBULTOS') or '').strip()
|
||||
package_id = package_id_by_key.get(bulk_key) if bulk_key else None
|
||||
|
||||
descarga_val = (row_norm.get('GENERA DESCARGA') or row_norm.get('GENERA DESCARGA?') or row_norm.get('DESCARGA') or 'SI').strip().upper()
|
||||
tipo_impo = (row_norm.get('TIPO DE IMPO') or row_norm.get('TIPO DE IMPO.') or row_norm.get('TIPO IMPO') or row_norm.get('PROCEDENCIA') or '').strip().upper()
|
||||
factura_impo = (row_norm.get('FACTURA IMPO') or row_norm.get('FACTURA IMPO.') or row_norm.get('FACTURA IMPORTACION') or '').strip()
|
||||
linea_impo_val = (row_norm.get('LINEA IMPO') or row_norm.get('LINEA IMPO.') or row_norm.get('LINEA IMPORTACION') or '').strip()
|
||||
|
||||
se_pago = (row_norm.get('SE PAGO IMPUESTO') or row_norm.get('SE PAGO IMPUESTO? (SI o NO)') or row_norm.get('SEPAGOIMPUESTO') or '').strip().upper()
|
||||
forma_pago = (row_norm.get('FORMA DE PAGO') or row_norm.get('FORMADEPAGO') or row_norm.get('FORMA PAGO') or '').strip() or None
|
||||
|
||||
es_sub_raw = (row_norm.get('ES PARTIDA/SUBPARTIDA') or row_norm.get('ESSUBPARTIDA') or row_norm.get('ES PARTIDA O SUBPARTIDA') or '').strip().upper()
|
||||
linea_principal_val = (row_norm.get('LINEA PRINCIPAL') or row_norm.get('LINEAPRINCIPAL') or row_norm.get('PARTIDA PRINCIPAL') or '').strip()
|
||||
is_subitem = (es_sub_raw == 'S')
|
||||
contains_subitems = (es_sub_raw == 'P')
|
||||
|
||||
# Clear existing line items once per invoice
|
||||
if invoice_id not in cleared_invoices:
|
||||
logger.info(f"Clearing existing details for Expo Invoice {invoice_number} (ID: {invoice_id})")
|
||||
session.query(LineItem).filter(LineItem.invoice_id == invoice_id).delete(synchronize_session=False)
|
||||
session.query(InvoiceSalesDetails).filter(InvoiceSalesDetails.invoice_id == invoice_id).delete(synchronize_session=False)
|
||||
cleared_invoices.add(invoice_id)
|
||||
|
||||
line = LineItem(
|
||||
invoice_id=invoice_id,
|
||||
line_number=line_num,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
part_number_id=part_id,
|
||||
unit_of_measure=uom_id,
|
||||
order=(row_norm.get('ORDEN DE COMPRA') or row_norm.get('ORDENCOMPRA') or row_norm.get('ORDEN DE VENTA') or None),
|
||||
tax_payment=(se_pago == 'SI'),
|
||||
payment_method=forma_pago,
|
||||
)
|
||||
session.add(line)
|
||||
session.flush()
|
||||
|
||||
# FaLineItem (a24 extension: subpartidas, descarga, factura impo ref)
|
||||
from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem
|
||||
fa_line = FaLineItem(
|
||||
id=line.id,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
search_invoice=factura_impo or None,
|
||||
search_line=parse_int(linea_impo_val),
|
||||
search_type=tipo_impo or None,
|
||||
download=(descarga_val == 'SI'),
|
||||
is_subitem=is_subitem,
|
||||
contains_subitems=contains_subitems,
|
||||
subitem_number=parse_int(linea_principal_val) if is_subitem else None,
|
||||
)
|
||||
session.add(fa_line)
|
||||
|
||||
price = parse_decimal(row_norm.get('COSTO UNITARIO') or row_norm.get('COSTOUNITARIO'))
|
||||
qty = parse_decimal(row_norm.get('CANTIDAD EXPORTADA/DESCARGAR') or row_norm.get('CANTIDAD EXPORTADA') or row_norm.get('CANTIDAD'))
|
||||
commercial_total = (price * qty) if price and qty else None
|
||||
|
||||
session.add(LineFinancial(
|
||||
item_line_id=line.id,
|
||||
unit_cost_capture=decimal_or_zero(price),
|
||||
total_commercial_value=decimal_or_zero(commercial_total),
|
||||
))
|
||||
|
||||
net_w = parse_decimal(row_norm.get('PESO NETO') or row_norm.get('PESONETO'))
|
||||
gross_w = parse_decimal(row_norm.get('PESO BRUTO') or row_norm.get('PESOBRUTO'))
|
||||
session.add(LineQuantity(
|
||||
item_line_id=line.id,
|
||||
quantity=decimal_or_zero(qty),
|
||||
net_weight=decimal_or_zero(net_w),
|
||||
gross_weight=decimal_or_zero(gross_w),
|
||||
package_quantity=int_or_zero(row_norm.get('CANTIDAD BULTOS') or row_norm.get('CANTIDADBULTOS')),
|
||||
package_id=package_id,
|
||||
))
|
||||
|
||||
origin = (row_norm.get('PAIS ORIGEN') or row_norm.get('PAISORIGEN') or row_norm.get('PAIS') or '').strip()
|
||||
fraction = (row_norm.get('FRACCION ARANCELARIA') or row_norm.get('FRACCIONARANCELARIA') or '').strip()
|
||||
american_fraction = (row_norm.get('FRACCION AMERICANA') or row_norm.get('FRACCIONAMERICANA') or '').strip()
|
||||
session.add(LineCustom(
|
||||
item_line_id=line.id,
|
||||
origin_country=origin or None,
|
||||
fraction=fraction or None,
|
||||
american_fraction=american_fraction or None,
|
||||
))
|
||||
|
||||
extra_desc = (row_norm.get('DESCRIPCION EXTRA') or row_norm.get('DESCRIPCIONEXTRA') or '').strip()
|
||||
additional_info = (row_norm.get('INFORMACION ADICIONAL') or row_norm.get('INFORMACIONADICIONAL') or '').strip()
|
||||
lot = (row_norm.get('LOTE') or '').strip()
|
||||
entry_number = (row_norm.get('NUMERO ENTRADA') or row_norm.get('NUM ENTRADA') or '').strip()
|
||||
session.add(LineDescription(
|
||||
item_line_id=line.id,
|
||||
extra_description=extra_desc or None,
|
||||
additional_info_spanish=additional_info or None,
|
||||
lot=lot or None,
|
||||
entry_number=entry_number or None,
|
||||
))
|
||||
|
||||
session.add(InvoiceSalesDetails(
|
||||
invoice_id=invoice_id,
|
||||
line_number=line_num,
|
||||
sales_order=(row_norm.get('ORDEN DE COMPRA') or row_norm.get('ORDENCOMPRA') or row_norm.get('ORDEN DE VENTA') or None),
|
||||
line_bundles=int_or_zero(row_norm.get('CANTIDAD BULTOS') or row_norm.get('CANTIDADBULTOS')),
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
))
|
||||
details_to_insert.append(line)
|
||||
continue
|
||||
|
||||
part_num = (row_norm.get('NUM. PARTE') or row_norm.get('NUMPARTE') or row_norm.get('NUMERO PARTE') or row_norm.get('NUM PARTE') or '').strip()
|
||||
if not part_num:
|
||||
skipped_invalid += 1
|
||||
|
||||
@@ -9,8 +9,8 @@ from typing import Dict, Any, Optional, Set, Tuple, List
|
||||
|
||||
from .partidas_impo_temp import _clip, _get
|
||||
|
||||
# Longitudes Clarion partidas expo
|
||||
MAX_LEN_FACTURA_EXPO = 15
|
||||
# Longitudes Clarion partidas expo (Factura EXPO alineado con invoice_number String(100) en InvoiceHeader)
|
||||
MAX_LEN_FACTURA_EXPO = 100
|
||||
MAX_LEN_LINEA_EXPO = 5
|
||||
MAX_LEN_TIPO_IMPO = 3
|
||||
MAX_LEN_ORDEN_COMPRA = 20
|
||||
@@ -262,7 +262,7 @@ def _validaciones_par_expo(
|
||||
line_num,
|
||||
"NUMERO FACTURA EXPO",
|
||||
f"Error: (Celda A{line_num}) La Factura de Exportación: {factura_expo} supera la longitud de caracteres. "
|
||||
"Capturar en la Celda A el campo Factura de Exportación con formato ###############.",
|
||||
f"Capturar en la Celda A el campo Factura de Exportación con un máximo de {MAX_LEN_FACTURA_EXPO} caracteres.",
|
||||
)
|
||||
# Longitud B
|
||||
linea_expo = _get(row, "LINEA EXPO", "LINEA EXPO.", "RENGLON EXPO")
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
FileText,
|
||||
UploadCloud
|
||||
} from 'lucide-svelte';
|
||||
import { tick } from 'svelte';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
@@ -53,6 +54,16 @@
|
||||
}
|
||||
open = newOpen;
|
||||
}
|
||||
|
||||
// When modal shows finished state with rejection details, scroll the detail table into view
|
||||
$effect(() => {
|
||||
if (open && isFinished && commitResults?.skipped_details?.length > 0) {
|
||||
tick().then(() => {
|
||||
const el = document.getElementById('detalle-errores-import');
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={handleOpenChange}>
|
||||
@@ -234,12 +245,17 @@
|
||||
>
|
||||
</div>
|
||||
<span class="text-3xl font-bold text-destructive">{totalSkipped}</span>
|
||||
{#if totalSkipped > 0 && commitResults.skipped_details && commitResults.skipped_details.length > 0}
|
||||
<p class="text-xs text-muted-foreground mt-2">
|
||||
Revisa el detalle por línea en la tabla inferior.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Details Table -->
|
||||
{#if commitResults.skipped_details && commitResults.skipped_details.length > 0}
|
||||
<div class="border rounded-lg overflow-hidden mt-2 shadow-sm">
|
||||
<div id="detalle-errores-import" class="border rounded-lg overflow-hidden mt-2 shadow-sm">
|
||||
<div class="bg-muted/50 px-4 py-2 border-b flex justify-between items-center">
|
||||
<h5 class="text-xs font-bold text-foreground uppercase tracking-wide">
|
||||
Detalle de Errores
|
||||
|
||||
Reference in New Issue
Block a user