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.
- package/package.json +1 -1
- package/src/assets/config/g360-skills.json +39 -0
- package/src/assets/config/project-types.json +15 -4
- package/src/assets/templates/python-flet/README.md +6 -1
- package/src/assets/templates/python-flet/pyproject.toml +1 -2
- package/src/assets/templates/python-flet-polished/README.md +170 -0
- package/src/assets/templates/python-flet-polished/assets/data/sample_data.json +12 -0
- package/src/assets/templates/python-flet-polished/assets/fonts/Inter-Variable.ttf +0 -0
- package/src/assets/templates/python-flet-polished/assets/fonts/JetBrainsMono-Variable.ttf +0 -0
- package/src/assets/templates/python-flet-polished/assets/images/app.ico +0 -0
- package/src/assets/templates/python-flet-polished/assets/images/logo.svg +9 -0
- package/src/assets/templates/python-flet-polished/build-portable.bat +42 -0
- package/src/assets/templates/python-flet-polished/create_shortcut.vbs +15 -0
- package/src/assets/templates/python-flet-polished/g360_flet/__init__.py +0 -0
- package/src/assets/templates/python-flet-polished/g360_flet/g360_signature.py +210 -0
- package/src/assets/templates/python-flet-polished/launch.vbs +3 -0
- package/src/assets/templates/python-flet-polished/launch_minimized.bat +3 -0
- package/src/assets/templates/python-flet-polished/main.py +47 -0
- package/src/assets/templates/python-flet-polished/pyproject.toml +18 -0
- package/src/assets/templates/python-flet-polished/requirements.txt +3 -0
- package/src/assets/templates/python-flet-polished/run.bat +189 -0
- package/src/assets/templates/python-flet-polished/skill.json +28 -0
- package/src/assets/templates/python-flet-polished/src/__init__.py +0 -0
- package/src/assets/templates/python-flet-polished/src/app.py +343 -0
- package/src/assets/templates/python-flet-polished/src/config/__init__.py +0 -0
- package/src/assets/templates/python-flet-polished/src/config/theme.py +122 -0
- package/src/assets/templates/python-flet-polished/src/core/__init__.py +0 -0
- package/src/assets/templates/python-flet-polished/src/core/constants.py +49 -0
- package/src/assets/templates/python-flet-polished/src/core/processor.py +128 -0
- package/src/assets/templates/python-flet-polished/src/core/skill.json +41 -0
- package/src/assets/templates/python-flet-polished/src/ui/__init__.py +0 -0
- package/src/assets/templates/python-flet-polished/src/ui/dashboard.py +308 -0
- package/src/assets/templates/python-flet-polished/src/ui/kpi_card.py +69 -0
- package/src/assets/templates/python-flet-polished/src/ui/search_overlay.py +143 -0
- package/src/assets/templates/python-flet-polished/sync_portable.py +71 -0
- package/src/cli.js +4 -4
- package/src/commands/init.js +33 -2
- package/py/src/g360_core/__pycache__/__init__.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/__init__.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/batch_processor.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/batch_processor.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/commercial_engine.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/logger.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/logger.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/pipeline.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/pipeline.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/processor.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/processor.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/processor_segmentacion.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/processor_segmentacion.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/processor_sku.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/processor_sku.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/scanner.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/scanner.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/utils.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/utils.cpython-314.pyc +0 -0
- package/src/assets/ingestion/core/__pycache__/ingestion.cpython-312.pyc +0 -0
- package/src/assets/signature/g360_flet/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/assets/signature/g360_flet/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/assets/signature/g360_flet/__pycache__/g360_signature.cpython-312.pyc +0 -0
- 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
|
|
File without changes
|
|
@@ -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
|
+
}
|
|
File without changes
|
|
@@ -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)
|