feat(console): add admin CRUD screens for parts, manufacturers, crossref, import
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
302
console/screens/admin_crossref.py
Normal file
302
console/screens/admin_crossref.py
Normal file
@@ -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
|
||||||
277
console/screens/admin_fabricantes.py
Normal file
277
console/screens/admin_fabricantes.py
Normal file
@@ -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
|
||||||
325
console/screens/admin_import.py
Normal file
325
console/screens/admin_import.py
Normal file
@@ -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 []
|
||||||
321
console/screens/admin_partes.py
Normal file
321
console/screens/admin_partes.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user