diff --git a/console/screens/comparador.py b/console/screens/comparador.py new file mode 100644 index 0000000..7a004fb --- /dev/null +++ b/console/screens/comparador.py @@ -0,0 +1,282 @@ +""" +Part comparator screen for the AUTOPARTES console application. + +Displays a side-by-side comparison of an OEM part against its aftermarket +alternatives. The first column is always the OEM part; subsequent columns +are aftermarket options. Below the comparison table, cross-reference +numbers are shown grouped by type. +""" + +from console.core.screens import Screen +from console.core.keybindings import Key +from console.config import APP_NAME, VERSION +from console.utils.formatting import format_currency, quality_bar + + +# Footer labels +_FOOTER = [ + ("\u2190\u2192", "Scroll"), + ("#", "Ver detalle"), + ("F3", "Otra parte"), + ("ESC", "Atras"), +] + + +class ComparadorScreen(Screen): + """Side-by-side OEM vs aftermarket comparison.""" + + def __init__(self): + super().__init__(name="comparador", title="Comparador") + self._part = None + self._alternatives = [] + self._cross_refs = [] + self._manufacturers = {} # id -> dict + self._col_offset = 0 # horizontal scroll offset + self._selected_alt = 0 # currently highlighted alternative + + # ------------------------------------------------------------------ + # Data loading + # ------------------------------------------------------------------ + + def _load(self, context, db): + """Load OEM part, alternatives, cross-refs, and manufacturer info.""" + part_id = context.get("part_id") + if part_id is None: + self._part = None + self._alternatives = [] + self._cross_refs = [] + return + + self._part = db.get_part(part_id) + self._alternatives = db.get_alternatives(part_id) if self._part else [] + self._cross_refs = db.get_cross_references(part_id) if self._part else [] + + # Build manufacturer lookup for country info + try: + mfrs = db.get_manufacturers() + self._manufacturers = {m["id"]: m for m in mfrs} + except Exception: + self._manufacturers = {} + + # Set initial column offset to show the selected alternative + selected = context.get("selected_alt_index", 0) + if 0 <= selected < len(self._alternatives): + self._selected_alt = selected + else: + self._selected_alt = 0 + self._col_offset = 0 + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + @staticmethod + def _format_warranty(months): + """Format warranty months as 'X meses' or '──' if missing.""" + if months is None: + return "\u2500\u2500" + return f"{months} meses" + + @staticmethod + def _calc_savings(oem_price, alt_price): + """Calculate percentage savings of alt vs OEM. + + Returns a formatted string like '-28%' or '──' when prices are + unavailable. + """ + if oem_price is None or alt_price is None or oem_price == 0: + return "\u2500\u2500" + pct = ((oem_price - alt_price) / oem_price) * 100 + if pct > 0: + return f"-{pct:.0f}%" + elif pct < 0: + return f"+{abs(pct):.0f}%" + return "0%" + + @staticmethod + def _format_stock(in_stock): + """Format boolean in_stock as Si/No.""" + if in_stock is None: + return "\u2500\u2500" + return "Si" if in_stock else "No" + + def _build_columns(self): + """Build the column list for draw_comparison. + + First column is the OEM part, followed by each aftermarket + alternative. Returns a list of dicts with 'header' and 'rows'. + """ + if self._part is None: + return [] + + p = self._part + oem_price = None # OEM parts don't have price_usd in our schema + + # ── OEM column ── + oem_col = { + "header": "OEM", + "rows": [ + ("Numero", p.get("oem_part_number", "")), + ("Calidad", quality_bar("oem")), + ("Tier", "OEM"), + ("Precio USD", "\u2500\u2500"), + ("Ahorro", "\u2500\u2500"), + ("Garantia", "\u2500\u2500"), + ("En stock", "\u2500\u2500"), + ("Fabricante", p.get("category_name", "")), + ], + } + + columns = [oem_col] + + # ── Aftermarket columns ── + for alt in self._alternatives: + tier = alt.get("quality_tier", "") or "" + price = alt.get("price_usd") + mfr_id = alt.get("manufacturer_id") + mfr_name = alt.get("manufacturer_name", "") + mfr_country = "" + if mfr_id and mfr_id in self._manufacturers: + mfr_country = self._manufacturers[mfr_id].get("country", "") or "" + + alt_col = { + "header": mfr_name, + "rows": [ + ("Numero", alt.get("part_number", "")), + ("Calidad", quality_bar(tier.lower()) if tier else "\u2500\u2500"), + ("Tier", tier.capitalize()), + ("Precio USD", format_currency(price)), + ("Ahorro", self._calc_savings(oem_price, price)), + ("Garantia", self._format_warranty(alt.get("warranty_months"))), + ("En stock", self._format_stock(alt.get("in_stock"))), + ("Fabricante", mfr_country if mfr_country else mfr_name), + ], + } + columns.append(alt_col) + + return columns + + # ------------------------------------------------------------------ + # Render + # ------------------------------------------------------------------ + + def render(self, context, db, renderer): + self._load(context, db) + + # Header + renderer.draw_header( + f" {APP_NAME} v{VERSION}", + " COMPARADOR OEM vs AFTERMARKET ", + ) + + if self._part is None: + renderer.draw_text(5, 4, "Parte no encontrada", "error") + renderer.draw_footer([("ESC", "Atras")]) + return + + # Build comparison columns + all_columns = self._build_columns() + + # Apply horizontal scroll: always show OEM (col 0) + offset slice + if len(all_columns) <= 1: + visible_columns = all_columns + else: + # Determine how many alt columns we can show. + # The renderer will auto-size, but let's allow scrolling + # through alternatives. + h, w = renderer.get_size() + # Rough estimate: label_w ~12, each col ~15-20 chars + # We keep it simple: show OEM + up to 3 alternatives at a time + max_visible_alts = max((w - 20) // 18, 1) + alt_cols = all_columns[1:] # all aftermarket columns + end = min(self._col_offset + max_visible_alts, len(alt_cols)) + visible_columns = [all_columns[0]] + alt_cols[self._col_offset:end] + + part_name = self._part.get("name_es") or self._part.get("name", "") + oem_number = self._part.get("oem_part_number", "") + title = f"COMPARACION: {oem_number} - {part_name}" + + renderer.draw_comparison(visible_columns, title=title) + + # ── Cross-references below the comparison ── + h, w = renderer.get_size() + # Estimate row where comparison ends: + # title(3) + header(1) + sep(1) + 8 data rows + 1 gap = 14 + xref_row = 3 + 3 + 1 + 8 + 2 + + if self._cross_refs and xref_row < h - 4: + section_title = ( + "\u2500\u2500 CROSS-REFERENCES " + + "\u2500" * max(w - 24, 4) + ) + renderer.draw_text(xref_row, 2, section_title, "title") + xref_row += 1 + + # Group by reference type + by_type = {} + for xr in self._cross_refs: + rtype = xr.get("reference_type", "other") or "other" + by_type.setdefault(rtype, []).append( + xr.get("cross_reference_number", "") + ) + for rtype, numbers in by_type.items(): + if xref_row >= h - 3: + break + line = f"{rtype.capitalize()}: {', '.join(numbers)}" + renderer.draw_text(xref_row, 4, line, "normal") + xref_row += 1 + + # Scroll indicator + if len(all_columns) > 1: + alt_count = len(all_columns) - 1 + indicator = ( + f" Mostrando alternativas " + f"{self._col_offset + 1}-" + f"{min(self._col_offset + len(visible_columns) - 1, alt_count)}" + f" de {alt_count}" + ) + indicator_row = min(xref_row + 1, h - 4) + if indicator_row > 0: + renderer.draw_text(indicator_row, 2, indicator, "info") + + renderer.draw_footer(_FOOTER) + + # ------------------------------------------------------------------ + # Key handling + # ------------------------------------------------------------------ + + def on_key(self, key, context, db, renderer, nav): + # ESC: back to part detail + if key == Key.ESCAPE: + return "back" + + # Left arrow: scroll columns left + if key == Key.LEFT: + if self._col_offset > 0: + self._col_offset -= 1 + return None + + # Right arrow: scroll columns right + if key == Key.RIGHT: + max_offset = max(len(self._alternatives) - 1, 0) + if self._col_offset < max_offset: + self._col_offset += 1 + return None + + # Number keys (1-9): view alternative detail + if 49 <= key <= 57: # '1'..'9' + idx = key - 49 # 0-based + if self._alternatives and 0 <= idx < len(self._alternatives): + part_id = context.get("part_id") + return ( + "comparador", + {"part_id": part_id, "selected_alt_index": idx}, + "Comparador", + ) + return None + + # F3: search for another part (go to buscar_parte) + if key == Key.F3: + return ("buscar_parte", {}, "Buscar Parte") + + return None diff --git a/console/screens/parte_detalle.py b/console/screens/parte_detalle.py new file mode 100644 index 0000000..3f84802 --- /dev/null +++ b/console/screens/parte_detalle.py @@ -0,0 +1,242 @@ +""" +Part detail screen for the AUTOPARTES console application. + +Shows full part information (OEM number, name, group, category, etc.) +with a table of aftermarket alternatives. Number keys navigate to +the comparator screen; F4 shows cross-references; F6 lists compatible +vehicles. +""" + +from console.core.screens import Screen +from console.core.keybindings import Key +from console.config import APP_NAME, VERSION +from console.utils.formatting import format_currency, truncate, quality_bar + + +# Footer labels +_FOOTER = [ + ("#", "Comparar"), + ("F4", "Cross-Ref"), + ("F6", "Vehiculos"), + ("ESC", "Atras"), +] + + +class ParteDetalleScreen(Screen): + """Detail view for a single OEM part with aftermarket alternatives.""" + + def __init__(self): + super().__init__(name="parte_detalle", title="Detalle de Parte") + self._part = None + self._alternatives = [] + self._selected_alt = 0 + + # ------------------------------------------------------------------ + # Data loading + # ------------------------------------------------------------------ + + def _load(self, context, db): + """Load part and alternatives from context['part_id'].""" + part_id = context.get("part_id") + if part_id is None: + self._part = None + self._alternatives = [] + return + self._part = db.get_part(part_id) + self._alternatives = db.get_alternatives(part_id) if self._part else [] + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + @staticmethod + def _format_warranty(months): + """Format warranty months as 'X meses' or '──' if missing.""" + if months is None: + return "──" + return f"{months} meses" + + @staticmethod + def _format_weight(kg): + """Format weight in kilograms or '──' if missing.""" + if kg is None: + return "──" + return f"{kg} kg" + + @staticmethod + def _format_discontinued(flag): + """Format the is_discontinued flag as Si/No.""" + if flag: + return "Si" + return "No" + + # ------------------------------------------------------------------ + # Render + # ------------------------------------------------------------------ + + def render(self, context, db, renderer): + self._load(context, db) + + # Header + renderer.draw_header( + f" {APP_NAME} v{VERSION}", + " DETALLE DE PARTE ", + ) + + if self._part is None: + renderer.draw_text(5, 4, "Parte no encontrada", "error") + renderer.draw_footer([("ESC", "Atras")]) + return + + p = self._part + + # ── Top section: part detail fields ── + fields = [ + ("Numero OEM", p.get("oem_part_number", "")), + ("Nombre", p.get("name", "")), + ("Nombre (ES)", p.get("name_es", "") or ""), + ("Grupo", p.get("group_name_es") or p.get("group_name", "")), + ("Categoria", p.get("category_name_es") or p.get("category_name", "")), + ("Descripcion", p.get("description_es") or p.get("description", "") or ""), + ("Material", p.get("material", "") or "──"), + ("Peso", self._format_weight(p.get("weight_kg"))), + ("Descontinuada", self._format_discontinued(p.get("is_discontinued"))), + ] + renderer.draw_detail(fields, title="INFORMACION DE LA PARTE") + + # ── Bottom section: alternatives table ── + h, w = renderer.get_size() + # Calculate where the detail section ends (title=3 rows + fields + 1 gap) + table_start_row = 3 + 3 + len(fields) + 1 + + if self._alternatives: + # Draw section title + section_title = "\u2500\u2500 ALTERNATIVAS AFTERMARKET " + "\u2500" * max(w - 32, 4) + renderer.draw_text(table_start_row, 2, section_title, "title") + table_start_row += 1 + + headers = ["FABRICANTE", "NUMERO", "CALIDAD", "PRECIO", "GARANTIA"] + widths = [14, 16, 10, 10, 10] + rows = [] + for alt in self._alternatives: + rows.append(( + truncate(alt.get("manufacturer_name", ""), 14), + truncate(alt.get("part_number", ""), 16), + (alt.get("quality_tier", "") or "").capitalize(), + format_currency(alt.get("price_usd")), + self._format_warranty(alt.get("warranty_months")), + )) + + renderer.draw_table( + headers, + rows, + widths, + selected_row=self._selected_alt, + ) + else: + renderer.draw_text( + table_start_row, 4, + "No hay alternativas aftermarket registradas", + "info", + ) + + renderer.draw_footer(_FOOTER) + + # ------------------------------------------------------------------ + # Key handling + # ------------------------------------------------------------------ + + def on_key(self, key, context, db, renderer, nav): + # ESC: go back + if key == Key.ESCAPE: + return "back" + + # Arrow navigation for alternatives + if key == Key.UP: + if self._selected_alt > 0: + self._selected_alt -= 1 + return None + + if key == Key.DOWN: + if self._alternatives and self._selected_alt < len(self._alternatives) - 1: + self._selected_alt += 1 + return None + + # Number keys (1-9): navigate to comparador for the selected alternative + if 49 <= key <= 57: # '1'..'9' + idx = key - 49 # 0-based + if self._alternatives and 0 <= idx < len(self._alternatives): + part_id = context.get("part_id") + return ( + "comparador", + {"part_id": part_id, "selected_alt_index": idx}, + "Comparador", + ) + return None + + # ENTER: navigate to comparador for the currently highlighted alternative + if key == Key.ENTER: + if self._alternatives and 0 <= self._selected_alt < len(self._alternatives): + part_id = context.get("part_id") + return ( + "comparador", + {"part_id": part_id, "selected_alt_index": self._selected_alt}, + "Comparador", + ) + return None + + # F4: show cross-references + if key == Key.F4: + part_id = context.get("part_id") + if part_id is None: + return None + xrefs = db.get_cross_references(part_id) + if not xrefs: + renderer.show_message("No hay cross-references para esta parte", "info") + return None + # Build message text grouped by reference type + lines = [] + by_type = {} + for xr in xrefs: + rtype = xr.get("reference_type", "other") or "other" + by_type.setdefault(rtype, []).append( + xr.get("cross_reference_number", "") + ) + for rtype, numbers in by_type.items(): + lines.append(f"{rtype.capitalize()}: {', '.join(numbers)}") + msg = "CROSS-REFERENCES\n" + "\n".join(lines) + renderer.show_message(msg, "info") + return None + + # F6: show vehicles that use this part + if key == Key.F6: + part_id = context.get("part_id") + if part_id is None: + return None + vehicles = db.get_vehicles_for_part(part_id) + if not vehicles: + renderer.show_message( + "No hay vehiculos registrados para esta parte", "info" + ) + return None + # Build message with vehicle list (limit to avoid overflow) + lines = [] + for v in vehicles[:10]: + brand = v.get("brand", "") + model = v.get("model", "") + year = v.get("year", "") + engine = v.get("engine", "") + line = f"{brand} {model} {year}" + if engine: + line += f" ({engine})" + position = v.get("position", "") + if position: + line += f" - {position}" + lines.append(line) + if len(vehicles) > 10: + lines.append(f"... y {len(vehicles) - 10} mas") + msg = "VEHICULOS COMPATIBLES\n" + "\n".join(lines) + renderer.show_message(msg, "info") + return None + + return None