From 8194167c5193dca325e7acfe760a8ee9b608c878 Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Sun, 15 Feb 2026 01:53:36 +0000 Subject: [PATCH] feat(console): add admin CRUD screens for parts, manufacturers, crossref, import Co-Authored-By: Claude Opus 4.6 --- console/screens/admin_crossref.py | 302 +++++++++++++++++++++++++ console/screens/admin_fabricantes.py | 277 +++++++++++++++++++++++ console/screens/admin_import.py | 325 +++++++++++++++++++++++++++ console/screens/admin_partes.py | 321 ++++++++++++++++++++++++++ 4 files changed, 1225 insertions(+) create mode 100644 console/screens/admin_crossref.py create mode 100644 console/screens/admin_fabricantes.py create mode 100644 console/screens/admin_import.py create mode 100644 console/screens/admin_partes.py diff --git a/console/screens/admin_crossref.py b/console/screens/admin_crossref.py new file mode 100644 index 0000000..f3e801e --- /dev/null +++ b/console/screens/admin_crossref.py @@ -0,0 +1,302 @@ +""" +Admin CRUD screen for Cross-References in the AUTOPARTES console application. + +Provides a paginated list view with create (F3), edit (ENTER), and +delete (F8/Del) operations for the part_cross_references table. +""" + +from console.core.screens import Screen +from console.core.keybindings import Key +from console.config import APP_NAME, VERSION +from console.utils.formatting import truncate + + +# Form field definitions for create/edit +_FIELDS = [ + {'label': 'Part ID', 'key': 'part_id', 'width': 8, 'hint': 'F1=Buscar parte'}, + {'label': 'Numero cruzado', 'key': 'cross_reference_number', 'width': 25}, + {'label': 'Tipo', 'key': 'reference_type', 'width': 15, 'hint': 'supersession/interchange/competitor'}, + {'label': 'Fuente', 'key': 'source', 'width': 20}, + {'label': 'Notas', 'key': 'notes', 'width': 40}, +] + +# Footer labels per mode +_FOOTER_LIST = [ + ("F3", "Nuevo"), + ("ENTER", "Editar"), + ("F8", "Eliminar"), + ("PgUp/Dn", "Paginar"), + ("ESC", "Atras"), +] + +_FOOTER_FORM = [ + ("TAB/Down", "Siguiente"), + ("Up", "Anterior"), + ("F9", "Guardar"), + ("ESC", "Cancelar"), +] + + +class AdminCrossrefScreen(Screen): + """Admin CRUD screen for the part_cross_references table.""" + + def __init__(self): + super().__init__(name="admin_crossref", title="Cross-References") + self._mode = 'list' # 'list' or 'form' + self._page = 1 + self._per_page = 15 + self._selected = 0 + self._crossrefs = [] + self._editing_id = None # None = creating, int = editing + self._focused_field = 0 + self._form_data = {} + self._dirty = False + + # ------------------------------------------------------------------ + # Data loading + # ------------------------------------------------------------------ + + def _load_crossrefs(self, db): + """Load the current page of cross-references.""" + self._crossrefs = db.get_crossrefs_paginated( + page=self._page, per_page=self._per_page + ) + + def _init_form(self, xref=None): + """Initialise form_data from an existing cross-reference or blank.""" + self._form_data = {} + if xref: + for f in _FIELDS: + val = xref.get(f['key'], '') + self._form_data[f['key']] = str(val) if val is not None else '' + else: + for f in _FIELDS: + self._form_data[f['key']] = '' + self._focused_field = 0 + self._dirty = False + + # ------------------------------------------------------------------ + # Render + # ------------------------------------------------------------------ + + def render(self, context, db, renderer): + renderer.draw_header( + f" {APP_NAME} v{VERSION}", + " CROSS-REFERENCES ", + ) + + if self._mode == 'list': + self._render_list(db, renderer) + else: + self._render_form(renderer) + + def _render_list(self, db, renderer): + """Render the paginated cross-references list.""" + self._load_crossrefs(db) + + headers = ["PARTE OEM", "NUMERO CRUZADO", "TIPO", "FUENTE"] + widths = [18, 22, 14, 16] + rows = [] + for x in self._crossrefs: + rows.append(( + truncate(x.get("oem_part_number", ""), 18), + truncate(x.get("cross_reference_number", ""), 22), + truncate(x.get("reference_type", "") or "", 14), + truncate(x.get("source", "") or "", 16), + )) + + renderer.draw_table( + headers, + rows, + widths, + page_info={ + "page": self._page, + "total_pages": self._page, + "total_rows": len(rows), + }, + selected_row=self._selected, + ) + renderer.draw_footer(_FOOTER_LIST) + + def _render_form(self, renderer): + """Render the create/edit form.""" + title = "EDITAR CROSS-REFERENCE" if self._editing_id else "NUEVA CROSS-REFERENCE" + fields = [] + for f in _FIELDS: + fields.append({ + 'label': f['label'], + 'value': self._form_data.get(f['key'], ''), + 'width': f['width'], + 'hint': f.get('hint', ''), + }) + renderer.draw_form(fields, focused_index=self._focused_field, title=title) + renderer.draw_footer(_FOOTER_FORM) + + # ------------------------------------------------------------------ + # Key handling + # ------------------------------------------------------------------ + + def on_key(self, key, context, db, renderer, nav): + if self._mode == 'list': + return self._handle_list_key(key, db, renderer) + else: + return self._handle_form_key(key, db, renderer) + + def _handle_list_key(self, key, db, renderer): + """Handle keys in list mode.""" + # ESC: go back + if key == Key.ESCAPE: + return "back" + + # Arrow navigation + if key == Key.UP: + if self._selected > 0: + self._selected -= 1 + return None + + if key == Key.DOWN: + if self._crossrefs and self._selected < len(self._crossrefs) - 1: + self._selected += 1 + return None + + # PgDn: next page + if key == Key.PGDN: + if len(self._crossrefs) == self._per_page: + self._page += 1 + self._selected = 0 + return None + + # PgUp: previous page + if key == Key.PGUP: + if self._page > 1: + self._page -= 1 + self._selected = 0 + return None + + # F3: create new cross-reference + if key == Key.F3: + self._mode = 'form' + self._editing_id = None + self._init_form() + return None + + # ENTER: edit selected cross-reference + if key == Key.ENTER: + if self._crossrefs and 0 <= self._selected < len(self._crossrefs): + xref = self._crossrefs[self._selected] + self._editing_id = xref["id"] + self._mode = 'form' + self._init_form(xref) + return None + + # Number keys 1-9: edit cross-reference at that row index + if 49 <= key <= 57: + idx = key - 49 + if 0 <= idx < len(self._crossrefs): + xref = self._crossrefs[idx] + self._editing_id = xref["id"] + self._mode = 'form' + self._init_form(xref) + return None + + # F8 or DEL: delete selected cross-reference + if key in (Key.F8, 330): # 330 = KEY_DC (Delete) + if self._crossrefs and 0 <= self._selected < len(self._crossrefs): + xref = self._crossrefs[self._selected] + oem = xref.get("oem_part_number", "") + xnum = xref.get("cross_reference_number", "") + confirmed = renderer.show_message( + f"Eliminar cross-reference?\n{oem} -> {xnum}", + "confirm", + ) + if confirmed: + try: + db.delete_crossref(xref["id"]) + except Exception as exc: + renderer.show_message(f"Error:\n{exc}", "error") + return None + if self._selected >= len(self._crossrefs) - 1: + self._selected = max(0, self._selected - 1) + return None + + return None + + def _handle_form_key(self, key, db, renderer): + """Handle keys in form mode.""" + # ESC: cancel form (with dirty check) + if key == Key.ESCAPE: + if self._dirty: + confirmed = renderer.show_message( + "Descartar cambios?", "confirm" + ) + if not confirmed: + return None + self._mode = 'list' + return None + + # TAB / Down: next field + if key in (Key.TAB, Key.DOWN): + if self._focused_field < len(_FIELDS) - 1: + self._focused_field += 1 + return None + + # Up: previous field + if key == Key.UP: + if self._focused_field > 0: + self._focused_field -= 1 + return None + + # F9: save + if key == Key.F9: + return self._save(db, renderer) + + # Backspace: delete last char from current field value + if key in (Key.BACKSPACE, 8): + field_key = _FIELDS[self._focused_field]['key'] + val = self._form_data.get(field_key, '') + if val: + self._form_data[field_key] = val[:-1] + self._dirty = True + return None + + # Printable characters: append to current field + if 32 <= key <= 126: + field_def = _FIELDS[self._focused_field] + field_key = field_def['key'] + val = self._form_data.get(field_key, '') + if len(val) < field_def['width']: + self._form_data[field_key] = val + chr(key) + self._dirty = True + return None + + return None + + def _save(self, db, renderer): + """Validate and save the form data.""" + data = dict(self._form_data) + + # Validate required fields + pid = data.get('part_id', '').strip() + if not pid or not pid.isdigit(): + renderer.show_message("Part ID debe ser un numero valido", "error") + return None + data['part_id'] = int(pid) + + if not data.get('cross_reference_number', '').strip(): + renderer.show_message("Numero cruzado es requerido", "error") + return None + + try: + if self._editing_id: + db.update_crossref(self._editing_id, data) + renderer.show_message("Cross-reference actualizada correctamente", "info") + else: + db.create_crossref(data) + renderer.show_message("Cross-reference creada correctamente", "info") + except Exception as exc: + renderer.show_message(f"Error al guardar:\n{exc}", "error") + return None + + self._mode = 'list' + self._dirty = False + return None diff --git a/console/screens/admin_fabricantes.py b/console/screens/admin_fabricantes.py new file mode 100644 index 0000000..2c3167d --- /dev/null +++ b/console/screens/admin_fabricantes.py @@ -0,0 +1,277 @@ +""" +Admin CRUD screen for Manufacturers in the AUTOPARTES console application. + +Provides a list view with create (F3), edit (ENTER), and delete (F8/Del) +operations for the manufacturers table. +""" + +from console.core.screens import Screen +from console.core.keybindings import Key +from console.config import APP_NAME, VERSION +from console.utils.formatting import truncate + + +# Form field definitions for create/edit +_FIELDS = [ + {'label': 'Nombre', 'key': 'name', 'width': 30}, + {'label': 'Tipo', 'key': 'type', 'width': 15, 'hint': 'oem/aftermarket/remanufactured'}, + {'label': 'Calidad', 'key': 'quality_tier', 'width': 10, 'hint': 'premium/standard/economy'}, + {'label': 'Pais', 'key': 'country', 'width': 20}, + {'label': 'Website', 'key': 'website', 'width': 40}, +] + +# Footer labels per mode +_FOOTER_LIST = [ + ("F3", "Nuevo"), + ("ENTER", "Editar"), + ("F8", "Eliminar"), + ("ESC", "Atras"), +] + +_FOOTER_FORM = [ + ("TAB/Down", "Siguiente"), + ("Up", "Anterior"), + ("F9", "Guardar"), + ("ESC", "Cancelar"), +] + + +class AdminFabricantesScreen(Screen): + """Admin CRUD screen for the manufacturers table.""" + + def __init__(self): + super().__init__(name="admin_fabricantes", title="Administracion de Fabricantes") + self._mode = 'list' # 'list' or 'form' + self._selected = 0 + self._manufacturers = [] + self._editing_id = None # None = creating, int = editing + self._focused_field = 0 + self._form_data = {} + self._dirty = False + + # ------------------------------------------------------------------ + # Data loading + # ------------------------------------------------------------------ + + def _load_manufacturers(self, db): + """Load all manufacturers.""" + self._manufacturers = db.get_manufacturers() + + def _init_form(self, mfr=None): + """Initialise form_data from an existing manufacturer or blank.""" + self._form_data = {} + if mfr: + for f in _FIELDS: + val = mfr.get(f['key'], '') + self._form_data[f['key']] = str(val) if val is not None else '' + else: + for f in _FIELDS: + self._form_data[f['key']] = '' + self._focused_field = 0 + self._dirty = False + + # ------------------------------------------------------------------ + # Render + # ------------------------------------------------------------------ + + def render(self, context, db, renderer): + renderer.draw_header( + f" {APP_NAME} v{VERSION}", + " ADMINISTRACION DE FABRICANTES ", + ) + + if self._mode == 'list': + self._render_list(db, renderer) + else: + self._render_form(renderer) + + def _render_list(self, db, renderer): + """Render the manufacturers list.""" + self._load_manufacturers(db) + + headers = ["NOMBRE", "TIPO", "CALIDAD", "PAIS", "WEBSITE"] + widths = [20, 14, 10, 14, 20] + rows = [] + for m in self._manufacturers: + rows.append(( + truncate(m.get("name", ""), 20), + truncate(m.get("type", "") or "", 14), + truncate(m.get("quality_tier", "") or "", 10), + truncate(m.get("country", "") or "", 14), + truncate(m.get("website", "") or "", 20), + )) + + renderer.draw_table( + headers, + rows, + widths, + page_info={ + "page": 1, + "total_pages": 1, + "total_rows": len(rows), + }, + selected_row=self._selected, + ) + renderer.draw_footer(_FOOTER_LIST) + + def _render_form(self, renderer): + """Render the create/edit form.""" + title = "EDITAR FABRICANTE" if self._editing_id else "NUEVO FABRICANTE" + fields = [] + for f in _FIELDS: + fields.append({ + 'label': f['label'], + 'value': self._form_data.get(f['key'], ''), + 'width': f['width'], + 'hint': f.get('hint', ''), + }) + renderer.draw_form(fields, focused_index=self._focused_field, title=title) + renderer.draw_footer(_FOOTER_FORM) + + # ------------------------------------------------------------------ + # Key handling + # ------------------------------------------------------------------ + + def on_key(self, key, context, db, renderer, nav): + if self._mode == 'list': + return self._handle_list_key(key, db, renderer) + else: + return self._handle_form_key(key, db, renderer) + + def _handle_list_key(self, key, db, renderer): + """Handle keys in list mode.""" + # ESC: go back + if key == Key.ESCAPE: + return "back" + + # Arrow navigation + if key == Key.UP: + if self._selected > 0: + self._selected -= 1 + return None + + if key == Key.DOWN: + if self._manufacturers and self._selected < len(self._manufacturers) - 1: + self._selected += 1 + return None + + # F3: create new manufacturer + if key == Key.F3: + self._mode = 'form' + self._editing_id = None + self._init_form() + return None + + # ENTER: edit selected manufacturer + if key == Key.ENTER: + if self._manufacturers and 0 <= self._selected < len(self._manufacturers): + mfr = self._manufacturers[self._selected] + self._editing_id = mfr["id"] + self._mode = 'form' + self._init_form(mfr) + return None + + # Number keys 1-9: edit manufacturer at that row index + if 49 <= key <= 57: + idx = key - 49 + if 0 <= idx < len(self._manufacturers): + mfr = self._manufacturers[idx] + self._editing_id = mfr["id"] + self._mode = 'form' + self._init_form(mfr) + return None + + # F8 or DEL: delete selected manufacturer + if key in (Key.F8, 330): # 330 = KEY_DC (Delete) + if self._manufacturers and 0 <= self._selected < len(self._manufacturers): + mfr = self._manufacturers[self._selected] + name = mfr.get("name", "") + confirmed = renderer.show_message( + f"Eliminar fabricante?\n{name}", + "confirm", + ) + if confirmed: + try: + db.delete_manufacturer(mfr["id"]) + except Exception as exc: + renderer.show_message(f"Error:\n{exc}", "error") + return None + if self._selected >= len(self._manufacturers) - 1: + self._selected = max(0, self._selected - 1) + return None + + return None + + def _handle_form_key(self, key, db, renderer): + """Handle keys in form mode.""" + # ESC: cancel form (with dirty check) + if key == Key.ESCAPE: + if self._dirty: + confirmed = renderer.show_message( + "Descartar cambios?", "confirm" + ) + if not confirmed: + return None + self._mode = 'list' + return None + + # TAB / Down: next field + if key in (Key.TAB, Key.DOWN): + if self._focused_field < len(_FIELDS) - 1: + self._focused_field += 1 + return None + + # Up: previous field + if key == Key.UP: + if self._focused_field > 0: + self._focused_field -= 1 + return None + + # F9: save + if key == Key.F9: + return self._save(db, renderer) + + # Backspace: delete last char from current field value + if key in (Key.BACKSPACE, 8): + field_key = _FIELDS[self._focused_field]['key'] + val = self._form_data.get(field_key, '') + if val: + self._form_data[field_key] = val[:-1] + self._dirty = True + return None + + # Printable characters: append to current field + if 32 <= key <= 126: + field_def = _FIELDS[self._focused_field] + field_key = field_def['key'] + val = self._form_data.get(field_key, '') + if len(val) < field_def['width']: + self._form_data[field_key] = val + chr(key) + self._dirty = True + return None + + return None + + def _save(self, db, renderer): + """Validate and save the form data.""" + data = dict(self._form_data) + + # Validate required fields + if not data.get('name', '').strip(): + renderer.show_message("Nombre es requerido", "error") + return None + + try: + if self._editing_id: + db.update_manufacturer(self._editing_id, data) + renderer.show_message("Fabricante actualizado correctamente", "info") + else: + db.create_manufacturer(data) + renderer.show_message("Fabricante creado correctamente", "info") + except Exception as exc: + renderer.show_message(f"Error al guardar:\n{exc}", "error") + return None + + self._mode = 'list' + self._dirty = False + return None diff --git a/console/screens/admin_import.py b/console/screens/admin_import.py new file mode 100644 index 0000000..4326613 --- /dev/null +++ b/console/screens/admin_import.py @@ -0,0 +1,325 @@ +""" +Import/Export screen for the AUTOPARTES console application. + +Provides a simple menu flow to import CSV files into the database or +export data to JSON files. Uses the renderer's show_input and +show_message dialogs for all user interaction. +""" + +import csv +import json +import os + +from console.core.screens import Screen +from console.core.keybindings import Key +from console.config import APP_NAME, VERSION + + +# Import type mapping: menu choice -> (label, table hint) +_IMPORT_TYPES = { + '1': ('Categorias', 'categories'), + '2': ('Grupos', 'groups'), + '3': ('Partes', 'parts'), + '4': ('Fabricantes', 'manufacturers'), + '5': ('Aftermarket', 'aftermarket'), + '6': ('CrossRef', 'crossref'), + '7': ('Fitment', 'fitment'), +} + +# Export type mapping +_EXPORT_TYPES = { + '1': ('Categorias', 'categories'), + '2': ('Grupos', 'groups'), + '3': ('Partes', 'parts'), + '4': ('Fabricantes', 'manufacturers'), + '5': ('Cross-References', 'crossref'), +} + +# Footer for the main menu +_FOOTER_MENU = [ + ("1-3", "Seleccionar"), + ("ESC", "Atras"), +] + + +class AdminImportScreen(Screen): + """Import/Export data screen with simple menu flow.""" + + def __init__(self): + super().__init__(name="admin_import", title="Importar / Exportar Datos") + self._selected = 0 + + # ------------------------------------------------------------------ + # Render + # ------------------------------------------------------------------ + + def render(self, context, db, renderer): + renderer.draw_header( + f" {APP_NAME} v{VERSION}", + " IMPORTAR / EXPORTAR DATOS ", + ) + + menu_items = [ + ("1", "Importar CSV"), + ("2", "Exportar datos a JSON"), + ("3", "Volver"), + ] + + renderer.draw_menu( + menu_items, + selected_index=self._selected, + title="IMPORTAR / EXPORTAR DATOS", + ) + renderer.draw_footer(_FOOTER_MENU) + + # ------------------------------------------------------------------ + # Key handling + # ------------------------------------------------------------------ + + def on_key(self, key, context, db, renderer, nav): + # ESC or '3': go back + if key == Key.ESCAPE or key == ord('3'): + return "back" + + # Arrow navigation + if key == Key.UP: + if self._selected > 0: + self._selected -= 1 + return None + + if key == Key.DOWN: + if self._selected < 2: + self._selected += 1 + return None + + # ENTER: activate selected + if key == Key.ENTER: + if self._selected == 0: + self._do_import(db, renderer) + elif self._selected == 1: + self._do_export(db, renderer) + else: + return "back" + return None + + # Direct number keys + if key == ord('1'): + self._do_import(db, renderer) + return None + + if key == ord('2'): + self._do_export(db, renderer) + return None + + return None + + # ------------------------------------------------------------------ + # Import flow + # ------------------------------------------------------------------ + + def _do_import(self, db, renderer): + """Run the CSV import flow using dialogs.""" + # Ask for import type + type_prompt = ( + "Tipo de datos:\n" + "1=Categorias 2=Grupos 3=Partes\n" + "4=Fabricantes 5=Aftermarket\n" + "6=CrossRef 7=Fitment" + ) + renderer.show_message(type_prompt, "info") + type_choice = renderer.show_input("Tipo (1-7)", max_len=1) + if type_choice is None or type_choice not in _IMPORT_TYPES: + renderer.show_message("Tipo no valido o cancelado", "error") + return + + type_label, type_key = _IMPORT_TYPES[type_choice] + + # Ask for file path + file_path = renderer.show_input("Ruta del archivo CSV", max_len=60) + if file_path is None or not file_path.strip(): + renderer.show_message("Importacion cancelada", "info") + return + + file_path = file_path.strip() + if not os.path.isfile(file_path): + renderer.show_message(f"Archivo no encontrado:\n{file_path}", "error") + return + + # Confirm + confirmed = renderer.show_message( + f"Importar {type_label} desde:\n{file_path}", + "confirm", + ) + if not confirmed: + return + + # Process the CSV + try: + count = self._process_csv(db, type_key, file_path) + renderer.show_message( + f"Importacion completada\n{count} registros procesados", + "info", + ) + except Exception as exc: + renderer.show_message(f"Error en importacion:\n{exc}", "error") + + def _process_csv(self, db, type_key, file_path): + """Read a CSV file and insert records into the database. + + Returns the number of records processed. + """ + count = 0 + with open(file_path, newline='', encoding='utf-8') as fh: + reader = csv.DictReader(fh) + for row in reader: + self._insert_row(db, type_key, row) + count += 1 + return count + + def _insert_row(self, db, type_key, row): + """Insert a single CSV row into the appropriate table.""" + if type_key == 'categories': + db._execute( + "INSERT INTO part_categories (name, name_es, slug, icon_name, display_order) " + "VALUES (?, ?, ?, ?, ?)", + ( + row.get('name', ''), + row.get('name_es', ''), + row.get('slug', ''), + row.get('icon_name', ''), + int(row.get('display_order', 0) or 0), + ), + ) + elif type_key == 'groups': + db._execute( + "INSERT INTO part_groups (category_id, name, name_es, slug, display_order) " + "VALUES (?, ?, ?, ?, ?)", + ( + int(row.get('category_id', 0) or 0), + row.get('name', ''), + row.get('name_es', ''), + row.get('slug', ''), + int(row.get('display_order', 0) or 0), + ), + ) + elif type_key == 'parts': + db.create_part({ + 'oem_part_number': row.get('oem_part_number', ''), + 'name': row.get('name', ''), + 'name_es': row.get('name_es', ''), + 'group_id': int(row['group_id']) if row.get('group_id') else None, + 'description': row.get('description', ''), + 'description_es': row.get('description_es', ''), + 'weight_kg': float(row['weight_kg']) if row.get('weight_kg') else None, + 'material': row.get('material', ''), + }) + elif type_key == 'manufacturers': + db.create_manufacturer({ + 'name': row.get('name', ''), + 'type': row.get('type', ''), + 'quality_tier': row.get('quality_tier', ''), + 'country': row.get('country', ''), + 'logo_url': row.get('logo_url', ''), + 'website': row.get('website', ''), + }) + elif type_key == 'aftermarket': + db._execute( + "INSERT INTO aftermarket_parts " + "(oem_part_id, manufacturer_id, part_number, name, name_es, " + " quality_tier, price_usd, warranty_months, in_stock) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + int(row.get('oem_part_id', 0) or 0), + int(row.get('manufacturer_id', 0) or 0), + row.get('part_number', ''), + row.get('name', ''), + row.get('name_es', ''), + row.get('quality_tier', ''), + float(row['price_usd']) if row.get('price_usd') else None, + int(row['warranty_months']) if row.get('warranty_months') else None, + int(row.get('in_stock', 1) or 1), + ), + ) + elif type_key == 'crossref': + db.create_crossref({ + 'part_id': int(row.get('part_id', 0) or 0), + 'cross_reference_number': row.get('cross_reference_number', ''), + 'reference_type': row.get('reference_type', ''), + 'source': row.get('source', ''), + 'notes': row.get('notes', ''), + }) + elif type_key == 'fitment': + db._execute( + "INSERT INTO vehicle_parts " + "(part_id, model_year_engine_id, quantity_required, position, fitment_notes) " + "VALUES (?, ?, ?, ?, ?)", + ( + int(row.get('part_id', 0) or 0), + int(row.get('model_year_engine_id', 0) or 0), + int(row.get('quantity_required', 1) or 1), + row.get('position', ''), + row.get('fitment_notes', ''), + ), + ) + + # ------------------------------------------------------------------ + # Export flow + # ------------------------------------------------------------------ + + def _do_export(self, db, renderer): + """Run the JSON export flow using dialogs.""" + # Ask for export type + type_prompt = ( + "Tipo de datos:\n" + "1=Categorias 2=Grupos 3=Partes\n" + "4=Fabricantes 5=CrossRef" + ) + renderer.show_message(type_prompt, "info") + type_choice = renderer.show_input("Tipo (1-5)", max_len=1) + if type_choice is None or type_choice not in _EXPORT_TYPES: + renderer.show_message("Tipo no valido o cancelado", "error") + return + + type_label, type_key = _EXPORT_TYPES[type_choice] + + # Ask for output path + default_name = f"{type_key}_export.json" + out_path = renderer.show_input( + f"Archivo de salida [{default_name}]", max_len=60 + ) + if out_path is None: + renderer.show_message("Exportacion cancelada", "info") + return + if not out_path.strip(): + out_path = default_name + + # Fetch data and write + try: + data = self._fetch_export_data(db, type_key) + with open(out_path.strip(), 'w', encoding='utf-8') as fh: + json.dump(data, fh, ensure_ascii=False, indent=2) + renderer.show_message( + f"Exportacion completada\n{len(data)} registros -> {out_path.strip()}", + "info", + ) + except Exception as exc: + renderer.show_message(f"Error en exportacion:\n{exc}", "error") + + def _fetch_export_data(self, db, type_key): + """Fetch the data to export based on the type key.""" + if type_key == 'categories': + return db.get_categories() + elif type_key == 'groups': + # Export all groups across all categories + categories = db.get_categories() + groups = [] + for cat in categories: + groups.extend(db.get_groups(cat['id'])) + return groups + elif type_key == 'parts': + return db.get_parts(page=1, per_page=100) + elif type_key == 'manufacturers': + return db.get_manufacturers() + elif type_key == 'crossref': + return db.get_crossrefs_paginated(page=1, per_page=100) + return [] diff --git a/console/screens/admin_partes.py b/console/screens/admin_partes.py new file mode 100644 index 0000000..512fdaf --- /dev/null +++ b/console/screens/admin_partes.py @@ -0,0 +1,321 @@ +""" +Admin CRUD screen for Parts in the AUTOPARTES console application. + +Provides a paginated list view with create (F3), edit (ENTER), and +delete (F8/Del) operations. Form editing is handled inline with +field-by-field navigation. +""" + +from console.core.screens import Screen +from console.core.keybindings import Key +from console.config import APP_NAME, VERSION +from console.utils.formatting import truncate, pad_right + + +# Form field definitions for create/edit +_FIELDS = [ + {'label': 'Numero OEM', 'key': 'oem_part_number', 'width': 20}, + {'label': 'Nombre', 'key': 'name', 'width': 40}, + {'label': 'Nombre (ES)', 'key': 'name_es', 'width': 40}, + {'label': 'Grupo ID', 'key': 'group_id', 'width': 5, 'hint': 'F1=Lista'}, + {'label': 'Descripcion', 'key': 'description', 'width': 50}, + {'label': 'Descripcion ES', 'key': 'description_es', 'width': 50}, + {'label': 'Material', 'key': 'material', 'width': 20}, + {'label': 'Peso (kg)', 'key': 'weight_kg', 'width': 8}, + {'label': 'Descontinuada', 'key': 'is_discontinued', 'width': 1, 'hint': 'S/N'}, +] + +# Footer labels per mode +_FOOTER_LIST = [ + ("F3", "Nuevo"), + ("ENTER", "Editar"), + ("F8", "Eliminar"), + ("PgUp/Dn", "Paginar"), + ("ESC", "Atras"), +] + +_FOOTER_FORM = [ + ("TAB/Down", "Siguiente"), + ("Up", "Anterior"), + ("F9", "Guardar"), + ("ESC", "Cancelar"), +] + + +class AdminPartesScreen(Screen): + """Admin CRUD screen for the parts table.""" + + def __init__(self): + super().__init__(name="admin_partes", title="Administracion de Partes") + self._mode = 'list' # 'list' or 'form' + self._page = 1 + self._per_page = 15 + self._selected = 0 + self._parts = [] + self._editing_id = None # None = creating, int = editing + self._focused_field = 0 + self._form_data = {} + self._dirty = False + + # ------------------------------------------------------------------ + # Data loading + # ------------------------------------------------------------------ + + def _load_parts(self, db): + """Load the current page of parts.""" + self._parts = db.get_parts(page=self._page, per_page=self._per_page) + + def _init_form(self, part=None): + """Initialise form_data from an existing part or blank.""" + self._form_data = {} + if part: + for f in _FIELDS: + key = f['key'] + val = part.get(key, '') + if key == 'is_discontinued': + val = 'S' if val else 'N' + self._form_data[key] = str(val) if val is not None else '' + else: + for f in _FIELDS: + self._form_data[f['key']] = '' + self._focused_field = 0 + self._dirty = False + + # ------------------------------------------------------------------ + # Render + # ------------------------------------------------------------------ + + def render(self, context, db, renderer): + renderer.draw_header( + f" {APP_NAME} v{VERSION}", + " ADMINISTRACION DE PARTES ", + ) + + if self._mode == 'list': + self._render_list(db, renderer) + else: + self._render_form(renderer) + + def _render_list(self, db, renderer): + """Render the paginated parts list.""" + self._load_parts(db) + + headers = ["NUMERO OEM", "NOMBRE", "GRUPO", "MATERIAL", "DISCONT"] + widths = [18, 25, 15, 12, 7] + rows = [] + for p in self._parts: + disc = "Si" if p.get("is_discontinued") else "" + rows.append(( + truncate(p.get("oem_part_number", ""), 18), + truncate(p.get("name_es") or p.get("name", ""), 25), + truncate(p.get("group_name", ""), 15), + truncate(p.get("material", "") or "", 12), + disc, + )) + + renderer.draw_table( + headers, + rows, + widths, + page_info={ + "page": self._page, + "total_pages": self._page, + "total_rows": len(rows), + }, + selected_row=self._selected, + ) + renderer.draw_footer(_FOOTER_LIST) + + def _render_form(self, renderer): + """Render the create/edit form.""" + title = "EDITAR PARTE" if self._editing_id else "NUEVA PARTE" + fields = [] + for f in _FIELDS: + fields.append({ + 'label': f['label'], + 'value': self._form_data.get(f['key'], ''), + 'width': f['width'], + 'hint': f.get('hint', ''), + }) + renderer.draw_form(fields, focused_index=self._focused_field, title=title) + renderer.draw_footer(_FOOTER_FORM) + + # ------------------------------------------------------------------ + # Key handling + # ------------------------------------------------------------------ + + def on_key(self, key, context, db, renderer, nav): + if self._mode == 'list': + return self._handle_list_key(key, context, db, renderer) + else: + return self._handle_form_key(key, db, renderer) + + def _handle_list_key(self, key, context, db, renderer): + """Handle keys in list mode.""" + # ESC: go back + if key == Key.ESCAPE: + return "back" + + # Arrow navigation + if key == Key.UP: + if self._selected > 0: + self._selected -= 1 + return None + + if key == Key.DOWN: + if self._parts and self._selected < len(self._parts) - 1: + self._selected += 1 + return None + + # PgDn: next page + if key == Key.PGDN: + if len(self._parts) == self._per_page: + self._page += 1 + self._selected = 0 + return None + + # PgUp: previous page + if key == Key.PGUP: + if self._page > 1: + self._page -= 1 + self._selected = 0 + return None + + # F3: create new part + if key == Key.F3: + self._mode = 'form' + self._editing_id = None + self._init_form() + return None + + # ENTER: edit selected part + if key == Key.ENTER: + if self._parts and 0 <= self._selected < len(self._parts): + part_row = self._parts[self._selected] + part = db.get_part(part_row["id"]) + if part: + self._editing_id = part["id"] + self._mode = 'form' + self._init_form(part) + return None + + # Number keys 1-9: edit part at that row index + if 49 <= key <= 57: + idx = key - 49 + if 0 <= idx < len(self._parts): + part_row = self._parts[idx] + part = db.get_part(part_row["id"]) + if part: + self._editing_id = part["id"] + self._mode = 'form' + self._init_form(part) + return None + + # F8 or DEL: delete selected part + if key in (Key.F8, 330): # 330 = KEY_DC (Delete) + if self._parts and 0 <= self._selected < len(self._parts): + part = self._parts[self._selected] + name = part.get("name_es") or part.get("name", "") + oem = part.get("oem_part_number", "") + confirmed = renderer.show_message( + f"Eliminar parte?\n{oem} - {name}", + "confirm", + ) + if confirmed: + db.delete_part(part["id"]) + # Adjust selection + if self._selected >= len(self._parts) - 1: + self._selected = max(0, self._selected - 1) + return None + + return None + + def _handle_form_key(self, key, db, renderer): + """Handle keys in form mode.""" + # ESC: cancel form (with dirty check) + if key == Key.ESCAPE: + if self._dirty: + confirmed = renderer.show_message( + "Descartar cambios?", "confirm" + ) + if not confirmed: + return None + self._mode = 'list' + return None + + # TAB / Down: next field + if key in (Key.TAB, Key.DOWN): + if self._focused_field < len(_FIELDS) - 1: + self._focused_field += 1 + return None + + # Up: previous field + if key == Key.UP: + if self._focused_field > 0: + self._focused_field -= 1 + return None + + # F9: save + if key == Key.F9: + return self._save(db, renderer) + + # Backspace: delete last char from current field value + if key in (Key.BACKSPACE, 8): + field_key = _FIELDS[self._focused_field]['key'] + val = self._form_data.get(field_key, '') + if val: + self._form_data[field_key] = val[:-1] + self._dirty = True + return None + + # Printable characters: append to current field + if 32 <= key <= 126: + field_def = _FIELDS[self._focused_field] + field_key = field_def['key'] + val = self._form_data.get(field_key, '') + if len(val) < field_def['width']: + self._form_data[field_key] = val + chr(key) + self._dirty = True + return None + + return None + + def _save(self, db, renderer): + """Validate and save the form data.""" + data = dict(self._form_data) + + # Validate required fields + if not data.get('oem_part_number', '').strip(): + renderer.show_message("Numero OEM es requerido", "error") + return None + if not data.get('name', '').strip(): + renderer.show_message("Nombre es requerido", "error") + return None + + # Convert types + gid = data.get('group_id', '').strip() + data['group_id'] = int(gid) if gid.isdigit() else None + + wkg = data.get('weight_kg', '').strip() + try: + data['weight_kg'] = float(wkg) if wkg else None + except ValueError: + data['weight_kg'] = None + + disc = data.get('is_discontinued', '').strip().upper() + data['is_discontinued'] = 1 if disc == 'S' else 0 + + try: + if self._editing_id: + db.update_part(self._editing_id, data) + renderer.show_message("Parte actualizada correctamente", "info") + else: + db.create_part(data) + renderer.show_message("Parte creada correctamente", "info") + except Exception as exc: + renderer.show_message(f"Error al guardar:\n{exc}", "error") + return None + + self._mode = 'list' + self._dirty = False + return None