g360-cli 1.13.0 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/package.json +1 -1
  2. package/src/assets/config/g360-skills.json +39 -0
  3. package/src/assets/config/project-types.json +15 -4
  4. package/src/assets/templates/python-flet/README.md +6 -1
  5. package/src/assets/templates/python-flet/pyproject.toml +1 -2
  6. package/src/assets/templates/python-flet-polished/README.md +170 -0
  7. package/src/assets/templates/python-flet-polished/assets/data/sample_data.json +12 -0
  8. package/src/assets/templates/python-flet-polished/assets/fonts/Inter-Variable.ttf +0 -0
  9. package/src/assets/templates/python-flet-polished/assets/fonts/JetBrainsMono-Variable.ttf +0 -0
  10. package/src/assets/templates/python-flet-polished/assets/images/app.ico +0 -0
  11. package/src/assets/templates/python-flet-polished/assets/images/logo.svg +9 -0
  12. package/src/assets/templates/python-flet-polished/build-portable.bat +42 -0
  13. package/src/assets/templates/python-flet-polished/create_shortcut.vbs +15 -0
  14. package/src/assets/templates/python-flet-polished/g360_flet/__init__.py +0 -0
  15. package/src/assets/templates/python-flet-polished/g360_flet/g360_signature.py +210 -0
  16. package/src/assets/templates/python-flet-polished/launch.vbs +3 -0
  17. package/src/assets/templates/python-flet-polished/launch_minimized.bat +3 -0
  18. package/src/assets/templates/python-flet-polished/main.py +47 -0
  19. package/src/assets/templates/python-flet-polished/pyproject.toml +18 -0
  20. package/src/assets/templates/python-flet-polished/requirements.txt +3 -0
  21. package/src/assets/templates/python-flet-polished/run.bat +189 -0
  22. package/src/assets/templates/python-flet-polished/skill.json +28 -0
  23. package/src/assets/templates/python-flet-polished/src/__init__.py +0 -0
  24. package/src/assets/templates/python-flet-polished/src/app.py +343 -0
  25. package/src/assets/templates/python-flet-polished/src/config/__init__.py +0 -0
  26. package/src/assets/templates/python-flet-polished/src/config/theme.py +122 -0
  27. package/src/assets/templates/python-flet-polished/src/core/__init__.py +0 -0
  28. package/src/assets/templates/python-flet-polished/src/core/constants.py +49 -0
  29. package/src/assets/templates/python-flet-polished/src/core/processor.py +128 -0
  30. package/src/assets/templates/python-flet-polished/src/core/skill.json +41 -0
  31. package/src/assets/templates/python-flet-polished/src/ui/__init__.py +0 -0
  32. package/src/assets/templates/python-flet-polished/src/ui/dashboard.py +308 -0
  33. package/src/assets/templates/python-flet-polished/src/ui/kpi_card.py +69 -0
  34. package/src/assets/templates/python-flet-polished/src/ui/search_overlay.py +143 -0
  35. package/src/assets/templates/python-flet-polished/sync_portable.py +71 -0
  36. package/src/cli.js +4 -4
  37. package/src/commands/init.js +33 -2
  38. package/py/src/g360_core/__pycache__/__init__.cpython-312.pyc +0 -0
  39. package/py/src/g360_core/__pycache__/__init__.cpython-314.pyc +0 -0
  40. package/py/src/g360_core/__pycache__/batch_processor.cpython-312.pyc +0 -0
  41. package/py/src/g360_core/__pycache__/batch_processor.cpython-314.pyc +0 -0
  42. package/py/src/g360_core/__pycache__/commercial_engine.cpython-314.pyc +0 -0
  43. package/py/src/g360_core/__pycache__/logger.cpython-312.pyc +0 -0
  44. package/py/src/g360_core/__pycache__/logger.cpython-314.pyc +0 -0
  45. package/py/src/g360_core/__pycache__/pipeline.cpython-312.pyc +0 -0
  46. package/py/src/g360_core/__pycache__/pipeline.cpython-314.pyc +0 -0
  47. package/py/src/g360_core/__pycache__/processor.cpython-312.pyc +0 -0
  48. package/py/src/g360_core/__pycache__/processor.cpython-314.pyc +0 -0
  49. package/py/src/g360_core/__pycache__/processor_segmentacion.cpython-312.pyc +0 -0
  50. package/py/src/g360_core/__pycache__/processor_segmentacion.cpython-314.pyc +0 -0
  51. package/py/src/g360_core/__pycache__/processor_sku.cpython-312.pyc +0 -0
  52. package/py/src/g360_core/__pycache__/processor_sku.cpython-314.pyc +0 -0
  53. package/py/src/g360_core/__pycache__/scanner.cpython-312.pyc +0 -0
  54. package/py/src/g360_core/__pycache__/scanner.cpython-314.pyc +0 -0
  55. package/py/src/g360_core/__pycache__/utils.cpython-312.pyc +0 -0
  56. package/py/src/g360_core/__pycache__/utils.cpython-314.pyc +0 -0
  57. package/src/assets/ingestion/core/__pycache__/ingestion.cpython-312.pyc +0 -0
  58. package/src/assets/signature/g360_flet/__pycache__/__init__.cpython-312.pyc +0 -0
  59. package/src/assets/signature/g360_flet/__pycache__/__init__.cpython-314.pyc +0 -0
  60. package/src/assets/signature/g360_flet/__pycache__/g360_signature.cpython-312.pyc +0 -0
  61. package/src/assets/signature/g360_flet/__pycache__/g360_signature.cpython-314.pyc +0 -0
@@ -0,0 +1,122 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ ACCENT = "#10B981"
5
+ ACCENT_DARK = "#047857"
6
+
7
+ DARK_COLORS = {
8
+ "accent": "#10B981",
9
+ "accent_dark": "#047857",
10
+ "success": "#34D399",
11
+ "warning": "#F59E0B",
12
+ "error": "#EF4444",
13
+ "info": "#3B82F6",
14
+ "violet": "#8B5CF6",
15
+ "pink": "#EC4899",
16
+ "cyan": "#06B6D4",
17
+ "orange": "#F97316",
18
+ "kpis": {
19
+ "primary": "#06B6D4",
20
+ "secondary": "#8B5CF6",
21
+ "tertiary": "#0EA5E9",
22
+ "quaternary": "#6366F1",
23
+ "quinary": "#EC4899",
24
+ "senary": "#F59E0B",
25
+ "septenary": "#EF4444",
26
+ "octonary": "#F97316",
27
+ },
28
+ "surface": "#141D33",
29
+ "surface_variant": "#1B2740",
30
+ "surface_sunken": "#0E1627",
31
+ "background": "#0A0F1E",
32
+ "border": "#FFFFFF17",
33
+ "text_muted": "#8FA0BA",
34
+ "text_primary": "#F1F5FB",
35
+ "text_secondary": "#C9D4E6",
36
+ }
37
+
38
+ LIGHT_COLORS = {
39
+ "accent": "#047857",
40
+ "accent_dark": "#065F46",
41
+ "success": "#15803D",
42
+ "warning": "#B45309",
43
+ "error": "#DC2626",
44
+ "info": "#2563EB",
45
+ "violet": "#7C3AED",
46
+ "pink": "#DB2777",
47
+ "cyan": "#0891B2",
48
+ "orange": "#EA580C",
49
+ "kpis": {
50
+ "primary": "#0891B2",
51
+ "secondary": "#7C3AED",
52
+ "tertiary": "#0284C7",
53
+ "quaternary": "#4F46E5",
54
+ "quinary": "#DB2777",
55
+ "senary": "#B45309",
56
+ "septenary": "#DC2626",
57
+ "octonary": "#EA580C",
58
+ },
59
+ "surface": "#FFFFFF",
60
+ "surface_variant": "#F7F9FC",
61
+ "surface_sunken": "#EEF1F6",
62
+ "background": "#F3F5F9",
63
+ "border": "#E3E8F0",
64
+ "text_muted": "#64748B",
65
+ "text_primary": "#0F172A",
66
+ "text_secondary": "#334155",
67
+ }
68
+
69
+
70
+ def get_colors(mode: str) -> dict:
71
+ return dict(DARK_COLORS if mode == "dark" else LIGHT_COLORS)
72
+
73
+
74
+ def rgba(color: str, opacity: float) -> str:
75
+ if color.startswith("#") and len(color) == 9:
76
+ return color
77
+ if color.startswith("#") and len(color) == 7:
78
+ hex_color = color[1:]
79
+ alpha = int(max(0.0, min(1.0, opacity)) * 255)
80
+ return f"#{alpha:02x}{hex_color}"
81
+ return color
82
+
83
+
84
+ def get_theme_file() -> Path:
85
+ return Path.home() / ".g360" / f"{_get_app_name()}_config.json"
86
+
87
+
88
+ def _get_app_name() -> str:
89
+ try:
90
+ from pathlib import Path as _P
91
+ _root = _P(__file__).resolve().parent.parent.parent.parent
92
+ _skill = _root / "skill.json"
93
+ if _skill.exists():
94
+ with open(_skill, encoding="utf-8") as _f:
95
+ _data = json.load(_f)
96
+ return _data.get("name", "g360-app").replace("-", "_")
97
+ except Exception:
98
+ pass
99
+ return "g360_app"
100
+
101
+
102
+ def load_theme_preference() -> str:
103
+ try:
104
+ config_file = get_theme_file()
105
+ if config_file.exists():
106
+ with open(config_file, encoding="utf-8") as f:
107
+ config = json.load(f)
108
+ return config.get("theme_mode", "dark")
109
+ except Exception:
110
+ pass
111
+ return "dark"
112
+
113
+
114
+ def save_theme_preference(mode: str):
115
+ try:
116
+ config_file = get_theme_file()
117
+ config_file.parent.mkdir(parents=True, exist_ok=True)
118
+ config = {"theme_mode": mode}
119
+ with open(config_file, "w", encoding="utf-8") as f:
120
+ json.dump(config, f)
121
+ except Exception:
122
+ pass
@@ -0,0 +1,49 @@
1
+ from pathlib import Path
2
+ import os
3
+
4
+ try:
5
+ import tomllib
6
+ except ImportError:
7
+ import tomli as tomllib
8
+
9
+ BASE_DIR = Path(__file__).resolve().parent.parent.parent.parent
10
+ ASSETS_DIR = BASE_DIR / "assets"
11
+ DATA_DIR = ASSETS_DIR / "data"
12
+
13
+ WINDOW_WIDTH = 1200
14
+ WINDOW_HEIGHT = 800
15
+ WINDOW_MIN_WIDTH = 900
16
+ WINDOW_MIN_HEIGHT = 600
17
+
18
+ AUTO_REFRESH_INTERVAL = 900
19
+
20
+ CACHE_FILE = DATA_DIR / ".last_raw.json"
21
+ VERSION_CACHE_FILE = DATA_DIR / ".version_check.json"
22
+ VERSION_CHECK_INTERVAL = 86400
23
+
24
+ LOG_PATH = BASE_DIR / "run_log.txt"
25
+
26
+
27
+ def get_local_version() -> str:
28
+ pyproject = BASE_DIR / "pyproject.toml"
29
+ if not pyproject.exists():
30
+ return "0.0.0"
31
+ try:
32
+ with open(pyproject, "rb") as f:
33
+ data = tomllib.load(f)
34
+ return str(data.get("project", {}).get("version", "0.0.0"))
35
+ except Exception:
36
+ return "0.0.0"
37
+
38
+
39
+ def get_app_name() -> str:
40
+ skill_path = BASE_DIR / "skill.json"
41
+ if skill_path.exists():
42
+ try:
43
+ with open(skill_path, encoding="utf-8") as f:
44
+ import json
45
+ data = json.load(f)
46
+ return data.get("name", "g360-app")
47
+ except Exception:
48
+ pass
49
+ return BASE_DIR.name
@@ -0,0 +1,128 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ G360 Processor - Motor de logica de negocio.
5
+
6
+ Este modulo debe ser heredado y extendido por cada app G360.
7
+ Contiene los patrones base para:
8
+ - Calcular KPIs agregados
9
+ - Generar metricas por linea/categoria
10
+ - Sugiere transferencias entre almacenes
11
+ - Exporta reportes a Excel con formato profesional
12
+
13
+ Heredar y sobrescribir los metodos segun el dominio de cada app.
14
+ """
15
+
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+
19
+
20
+ APP_AUTHOR = "g360-app-polished"
21
+ APP_NAME = "G360"
22
+
23
+
24
+ def _make_report_name(title: str) -> str:
25
+ """Genera nombre de archivo con timestamp: G360_{slug}_{YYYYMMDD}_{HHMMSS}.xlsx"""
26
+ now = datetime.now()
27
+ ts = now.strftime("%Y%m%d_%H%M%S")
28
+ import re
29
+ slug = re.sub(r"[^a-zA-Z0-9]+", "_", title).strip("_").upper()
30
+ if len(slug) > 40:
31
+ slug = slug[:40]
32
+ if not slug:
33
+ slug = "REPORTE"
34
+ return f"{APP_NAME}_{slug}_{ts}"
35
+
36
+
37
+ class BaseProcessor:
38
+ """
39
+ Procesador base que debe heredarse.
40
+
41
+ Ejemplo de uso:
42
+ class MiProcessor(BaseProcessor):
43
+ def calcular_kpis(self, raw_data):
44
+ # Implementar logica especifica
45
+ return kpis_dict
46
+ """
47
+
48
+ def __init__(self):
49
+ self._last_kpis = None
50
+ self._last_hash = None
51
+
52
+ def calcular_kpis(self, raw_data: dict) -> dict:
53
+ """
54
+ Calcula KPIs a partir de datos crudos.
55
+ Debe ser sobrescrito por subclases.
56
+
57
+ Args:
58
+ raw_data: Datos crudos del dominio (dict por almacén/entidad)
59
+
60
+ Returns:
61
+ dict con KPIs calculados
62
+ """
63
+ raise NotImplementedError("Subclase debe implementar calcular_kpis()")
64
+
65
+ def obtener_metricas(self, kpis: dict) -> tuple[list[dict], list[dict]]:
66
+ """
67
+ Genera metricas agrupadas (por linea, categoria, etc).
68
+ Debe ser sobrescrito por subclases.
69
+
70
+ Returns:
71
+ (metricas_principales, metricas_secundarias)
72
+ """
73
+ raise NotImplementedError("Subclase debe implementar obtener_metricas()")
74
+
75
+ def sugerir_acciones(self, raw_data: dict, kpis: dict) -> list[dict]:
76
+ """
77
+ Sugiere acciones (transferencias, alertas, etc).
78
+ Debe ser sobrescrito por subclases.
79
+
80
+ Returns:
81
+ lista de dicts con sugerencias
82
+ """
83
+ return []
84
+
85
+ def export_to_excel(self, data: list, file_path: str, title: str = "Reporte"):
86
+ """
87
+ Exporta datos a Excel con formato profesional.
88
+ Metodo base reutilizable.
89
+
90
+ Args:
91
+ data: Lista de filas [(col1, col2, ...), ...]
92
+ file_path: Ruta del archivo de salida
93
+ title: Titulo del reporte
94
+ """
95
+ from openpyxl import Workbook
96
+ from openpyxl.styles import PatternFill, Font, Alignment
97
+
98
+ wb = Workbook()
99
+ wb.properties.creator = APP_AUTHOR
100
+ wb.properties.description = f"Reporte generado por {APP_NAME} — {datetime.now().strftime('%Y-%m-%d %H:%M')}"
101
+
102
+ ws = wb.active
103
+ ws.title = title[:31]
104
+
105
+ # Headers
106
+ if data:
107
+ headers = [f"Col {i+1}" for i in range(len(data[0]))]
108
+ ws.append(headers)
109
+ for cell in ws[1]:
110
+ cell.fill = PatternFill(start_color="10B981", end_color="10B981", fill_type="solid")
111
+ cell.font = Font(color="FFFFFF", bold=True)
112
+ cell.alignment = Alignment(horizontal="center")
113
+
114
+ for row in data:
115
+ ws.append([str(v) if v is not None else "" for v in row])
116
+
117
+ ws.column_dimensions['A'].width = 35
118
+ for col in "BCD":
119
+ ws.column_dimensions[col].width = 16
120
+
121
+ wb.save(file_path)
122
+
123
+ def hash_data(self, data: dict) -> str:
124
+ """Hash SHA-256 para deteccion de cambios."""
125
+ import hashlib
126
+ import json
127
+ serialized = json.dumps(data, sort_keys=True, ensure_ascii=False).encode()
128
+ return hashlib.sha256(serialized).hexdigest()[:16]
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "flet-desktop",
3
+ "description": "G360 Flet Desktop Application",
4
+ "framework": "flet",
5
+ "version": "1.2.0",
6
+ "python": ">=3.11,<4.0",
7
+ "colors": {
8
+ "bg": "#0b1220",
9
+ "surface": "#1a2332",
10
+ "accent": "#00d084",
11
+ "text": "#f0f4f8",
12
+ "muted": "#94a3b8",
13
+ "success": "#22c55e",
14
+ "warning": "#f59e0b",
15
+ "error": "#ef4444"
16
+ },
17
+ "effects": {
18
+ "glassmorphism": true,
19
+ "blur": "12px",
20
+ "rounded": "12px"
21
+ },
22
+ "signature": {
23
+ "mode": "own",
24
+ "text": "G360 Desktop"
25
+ },
26
+ "build": {
27
+ "tool": "flet",
28
+ "target": "windows",
29
+ "name": "G360-App"
30
+ },
31
+ "dependencies": {
32
+ "flet": ">=0.25.0"
33
+ },
34
+ "guidelines": {
35
+ "architecture": "src/core (logica), src/ui (componentes), src/export (reportes)",
36
+ "naming": "PascalCase para clases UI, camelCase para metodos y variables",
37
+ "state_management": "Usar atributos de clase para estado, evitar globales",
38
+ "threading": "Usar threading.Thread para operaciones bloqueantes",
39
+ "window_config": "width=1350, height=900, resizable=True, theme_mode=DARK"
40
+ }
41
+ }
@@ -0,0 +1,308 @@
1
+ """
2
+ G360 Dashboard - Template base con patrones pulidos.
3
+
4
+ Patrones disponibles:
5
+ - Sidebar colapsable con chips de filtro
6
+ - KPIs con glow backlight por color
7
+ - Buscador flotante con debounce
8
+ - Tablas paginadas con sort en headers
9
+ - Export a Excel con openpyxl
10
+ - Health filter por umbrales configurables
11
+ - Stale data detection
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import threading
16
+ from datetime import datetime
17
+
18
+ import flet as ft
19
+ from src.config.theme import get_colors, rgba
20
+ from src.ui.kpi_card import KPICard
21
+ from src.ui.search_overlay import SearchOverlay
22
+
23
+
24
+ class Dashboard:
25
+ def __init__(self, page: ft.Page, theme_mode: str = "dark"):
26
+ self.page = page
27
+ self._theme_mode = theme_mode
28
+ self.c = get_colors(self._theme_mode)
29
+ self._search_timer = None
30
+ self._theme_button: ft.IconButton | None = None
31
+
32
+ # State
33
+ self._raw_data: dict | None = None
34
+ self._kpis: dict | None = None
35
+ self._cache_timestamp: str | None = None
36
+ self._stale_data: bool = False
37
+
38
+ # UI refs
39
+ self._kpi_row: ft.Row | None = None
40
+ self._main_content: ft.Container | None = None
41
+ self._sidebar: ft.Container | None = None
42
+ self._status_text = ft.Text(
43
+ "Cargue datos para comenzar",
44
+ size=13, color=self.c["text_muted"], weight=ft.FontWeight.W_500,
45
+ )
46
+ self._ts_text = ft.Text("", size=11, color=self.c["text_muted"], weight=ft.FontWeight.W_500)
47
+ self._stale_badge = ft.Container(
48
+ content=ft.Row([
49
+ ft.Icon(ft.Icons.WARNING_AMBER, size=14, color=self.c["warning"]),
50
+ ft.Text("Datos en caché", size=11, color=self.c["warning"], weight=ft.FontWeight.W_500),
51
+ ], spacing=4, vertical_alignment=ft.CrossAxisAlignment.CENTER),
52
+ visible=False,
53
+ padding=ft.Padding(left=10, right=10, top=5, bottom=5),
54
+ bgcolor=rgba(self.c["warning"], 0.07),
55
+ border_radius=8,
56
+ )
57
+ self._empty_state = ft.Container(
58
+ visible=False,
59
+ content=ft.Column([
60
+ ft.Icon(ft.Icons.CLOUD_OFF, size=48, color=rgba(self.c["text_muted"], 0.35)),
61
+ ft.Text("Sin datos disponibles", size=16, color=self.c["text_muted"], weight=ft.FontWeight.W_600),
62
+ ft.Text("Verificando conexión...", size=13, color=rgba(self.c["text_muted"], 0.65)),
63
+ ], horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=12),
64
+ )
65
+ self._loading_overlay = ft.Container(
66
+ visible=False,
67
+ content=ft.Column([
68
+ ft.ProgressRing(width=40, height=40, color=self.c["accent"]),
69
+ ft.Container(height=12),
70
+ ft.Text("Cargando...", size=14, color=self.c["text_primary"]),
71
+ ], horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=0),
72
+ bgcolor=rgba(self.c["background"], 0.85),
73
+ expand=True,
74
+ alignment=ft.alignment.center,
75
+ )
76
+
77
+ # Callbacks
78
+ self._on_theme_toggle = None
79
+ self._on_refresh = None
80
+ self._file_picker = ft.FilePicker()
81
+
82
+ def set_on_theme_toggle(self, cb):
83
+ self._on_theme_toggle = cb
84
+
85
+ def set_on_refresh(self, cb):
86
+ self._on_refresh = cb
87
+
88
+ def register_overlay(self):
89
+ self.page.overlay.append(self._file_picker)
90
+
91
+ def build(self) -> ft.Container:
92
+ self._sidebar = self._build_sidebar()
93
+ self._main_content = self._build_main_content()
94
+
95
+ return ft.Row(
96
+ controls=[
97
+ self._sidebar,
98
+ ft.VerticalDivider(width=1, color=self.c["border"]),
99
+ self._main_content,
100
+ ],
101
+ expand=True,
102
+ )
103
+
104
+ def _build_header(self) -> ft.Container:
105
+ theme_icon = ft.Icons.DARK_MODE if self._theme_mode == "dark" else ft.Icons.LIGHT_MODE
106
+ self._theme_button = ft.IconButton(
107
+ icon=theme_icon,
108
+ icon_size=18,
109
+ tooltip="Cambiar tema",
110
+ on_click=lambda _: self._on_theme_toggle() if self._on_theme_toggle else None,
111
+ )
112
+
113
+ return ft.Container(
114
+ content=ft.Row([
115
+ ft.Text("G360 Dashboard", size=16, weight=ft.FontWeight.W_700, color=self.c["text_primary"]),
116
+ ft.Container(expand=True),
117
+ self._ts_text,
118
+ ft.Container(width=16),
119
+ self._stale_badge,
120
+ ft.Container(width=16),
121
+ self._status_text,
122
+ ft.Container(width=16),
123
+ self._theme_button,
124
+ ], vertical_alignment=ft.CrossAxisAlignment.CENTER),
125
+ padding=ft.Padding(left=20, right=20, top=12, bottom=12),
126
+ bgcolor=self.c["surface"],
127
+ border_bottom=ft.BorderSide(1, self.c["border"]),
128
+ )
129
+
130
+ def _build_kpi_row(self, kpis: dict) -> ft.Row:
131
+ kpi_keys = ["almacenes", "skus", "disponible", "predespacho", "sin_catalogo", "alertas", "criticos", "alto_stock"]
132
+ kpi_colors = self.c.get("kpis", {})
133
+
134
+ controls = []
135
+ for i, key in enumerate(kpi_keys):
136
+ data = kpis.get(key, {})
137
+ card = KPICard(
138
+ label=key.replace("_", " ").title(),
139
+ value=str(data.get("value", "-")),
140
+ color=kpi_colors.get(key, list(kpi_colors.values())[0]),
141
+ subtext=data.get("subtext", ""),
142
+ )
143
+ controls.append(card)
144
+ if i < len(kpi_keys) - 1:
145
+ controls.append(ft.Container(width=8))
146
+
147
+ return ft.Row(
148
+ controls=controls,
149
+ wrap=True,
150
+ spacing=0,
151
+ run_spacing=8,
152
+ )
153
+
154
+ def _build_sidebar(self) -> ft.Container:
155
+ search_field = ft.TextField(
156
+ hint_text="Buscar SKU, descripción...",
157
+ border_radius=8,
158
+ height=36,
159
+ text_size=12,
160
+ dense=True,
161
+ border=ft.InputBorder.OUTLINE,
162
+ border_color=self.c["border"],
163
+ focused_border_color=self.c["accent"],
164
+ cursor_color=self.c["accent"],
165
+ hint_style=ft.TextStyle(size=11, color=self.c["text_muted"]),
166
+ suffix_icon=ft.Icon(ft.Icons.KEYBOARD_RETURN, size=14, color=rgba(self.c["text_muted"], 0.5)),
167
+ )
168
+
169
+ return ft.Container(
170
+ content=ft.Column([
171
+ ft.Container(height=16),
172
+ ft.Text("Filtros", size=12, weight=ft.FontWeight.W_600, color=self.c["text_muted"]),
173
+ ft.Container(height=8),
174
+ search_field,
175
+ ft.Container(height=16),
176
+ ft.Divider(color=self.c["border"]),
177
+ ft.Container(height=8),
178
+ ft.Text("Almacenes", size=12, weight=ft.FontWeight.W_600, color=self.c["text_muted"]),
179
+ ft.Container(height=8),
180
+ ft.Column([], scroll=ft.ScrollMode.AUTO, expand=True, id="sidebar_alms"),
181
+ ], spacing=0, expand=True),
182
+ width=220,
183
+ bgcolor=self.c["surface"],
184
+ padding=ft.Padding(left=12, right=12, top=16, bottom=12),
185
+ )
186
+
187
+ def _build_main_content(self) -> ft.Container:
188
+ return ft.Container(
189
+ content=ft.Column([
190
+ self._build_header(),
191
+ ft.Container(content=self._kpi_row if self._kpi_row else self._build_kpi_row({}), expand=False),
192
+ ft.Container(height=12),
193
+ ft.Container(
194
+ content=ft.Column([], scroll=ft.ScrollMode.AUTO, expand=True, id="main_cards"),
195
+ expand=True,
196
+ ),
197
+ self._empty_state,
198
+ self._loading_overlay,
199
+ ], spacing=0, expand=True),
200
+ expand=True,
201
+ bgcolor=self.c["background"],
202
+ padding=ft.Padding(left=16, right=16, top=16, bottom=16),
203
+ )
204
+
205
+ # --- Update methods ---
206
+
207
+ def update_data(self, raw_data: dict, cache_timestamp: str | None = None, api_timestamp: str | None = None, stale: bool = False):
208
+ self._raw_data = raw_data
209
+ self._cache_timestamp = cache_timestamp
210
+ self._stale_data = stale
211
+ # Subclases implementan la logica de renderizado
212
+ self._render()
213
+
214
+ def _render(self):
215
+ # Implementar en subclase segun el dominio
216
+ pass
217
+
218
+ def update_theme(self, mode: str):
219
+ self._theme_mode = mode
220
+ self.c = get_colors(mode)
221
+ # Reconstruir UI con nuevos colores
222
+ self.page.bgcolor = self.c["background"]
223
+ self._rebuild_ui()
224
+
225
+ def _rebuild_ui(self):
226
+ if self._main_content:
227
+ self._main_content.content = self._build_main_content().content
228
+ self.page.update()
229
+
230
+ def set_loading(self, loading: bool, message: str = "Cargando..."):
231
+ self._loading_overlay.visible = loading
232
+ if loading:
233
+ self._loading_overlay.content.controls[2].value = message
234
+ self.page.update()
235
+
236
+ def set_offline(self, offline: bool):
237
+ if offline:
238
+ self._status_text.value = "Sin conexión"
239
+ self._status_text.color = self.c["error"]
240
+ else:
241
+ self._status_text.value = "Conectado"
242
+ self._status_text.color = self.c["success"]
243
+ self.page.update()
244
+
245
+ def _show_snack(self, msg: str, is_error: bool = False):
246
+ self.page.snack_bar = ft.SnackBar(
247
+ content=ft.Text(msg, size=13),
248
+ bgcolor=self.c["error"] if is_error else self.c["accent"],
249
+ duration=3000,
250
+ )
251
+ self.page.open(self.page.snack_bar)
252
+
253
+ def _show_empty_state(self, title: str, subtitle: str = ""):
254
+ self._empty_state.content.controls[1].value = title
255
+ if subtitle:
256
+ if len(self._empty_state.content.controls) > 2:
257
+ self._empty_state.content.controls[2].value = subtitle
258
+ else:
259
+ self._empty_state.content.controls.append(
260
+ ft.Text(subtitle, size=13, color=rgba(self.c["text_muted"], 0.65))
261
+ )
262
+ self._empty_state.visible = True
263
+ self.page.update()
264
+
265
+ def _hide_empty_state(self):
266
+ self._empty_state.visible = False
267
+ self.page.update()
268
+
269
+ def _set_empty_state_status(self, msg: str, color: str):
270
+ for ctrl in self._empty_state.content.controls:
271
+ if isinstance(ctrl, ft.Text) and ctrl.size == 13:
272
+ ctrl.value = msg
273
+ ctrl.color = color
274
+ break
275
+ self.page.update()
276
+
277
+ def _update_refresh_status(self, cache_ts: str | None):
278
+ if cache_ts:
279
+ try:
280
+ dt = datetime.fromisoformat(cache_ts)
281
+ age_min = (datetime.now() - dt).total_seconds() / 60
282
+ self._ts_text.value = f"Hace {int(age_min)} min"
283
+ except Exception:
284
+ self._ts_text.value = ""
285
+ else:
286
+ self._ts_text.value = ""
287
+
288
+ def format_cache_timestamp(self, ts: str | None) -> str:
289
+ if not ts:
290
+ return ""
291
+ try:
292
+ return datetime.fromisoformat(ts).strftime("%H:%M:%S")
293
+ except Exception:
294
+ return ts[:19]
295
+
296
+ def _show_stale_warning(self):
297
+ self._stale_badge.visible = True
298
+ self.page.update()
299
+
300
+ def _hide_stale_warning(self):
301
+ self._stale_badge.visible = False
302
+ self.page.update()
303
+
304
+ def show_search_overlay(self):
305
+ """Abre el buscador flotante."""
306
+ overlay = SearchOverlay(self.page, self.c)
307
+ self.page.overlay.append(overlay)
308
+ self.page.open(overlay)