fix(ucepsa): simplify operator console tablet UI

This commit is contained in:
Victor Fraile Garcia 2026-05-27 14:22:16 +02:00
parent 94f6e9dc3f
commit d525443de9

View File

@ -22,10 +22,27 @@ PG_CONFIG = {
"connect_timeout": 5, "connect_timeout": 5,
} }
APP_TITLE = getenv("APP_TITLE", "MESAVAULT Edge-OEE - Consola de paros") APP_TITLE = getenv("APP_TITLE", "MESAVAULT Edge-OEE - Consola de paros UCEPSA")
DEFAULT_MACHINE_ID = getenv("DEFAULT_MACHINE_ID", "CORT-01") DEFAULT_MACHINE_ID = getenv("DEFAULT_MACHINE_ID", "CORT-01")
DEFAULT_ASSET = getenv("DEFAULT_ASSET", "revpi_oee_node_01") DEFAULT_ASSET = getenv("DEFAULT_ASSET", "revpi_oee_node_01")
ALLOWED_MACHINES = [
item.strip()
for item in getenv("ALLOWED_MACHINES", DEFAULT_MACHINE_ID).split(",")
if item.strip()
]
def resolve_machine_id(raw_value):
machine_id = (raw_value or DEFAULT_MACHINE_ID).strip()
if machine_id not in ALLOWED_MACHINES:
return DEFAULT_MACHINE_ID
return machine_id
app = Flask(__name__) app = Flask(__name__)
@ -33,7 +50,7 @@ def get_conn():
return psycopg2.connect(**PG_CONFIG) return psycopg2.connect(**PG_CONFIG)
def fetch_pending_stops(): def fetch_pending_stops(machine_id):
sql = """ sql = """
SELECT SELECT
id, id,
@ -54,13 +71,14 @@ def fetch_pending_stops():
cost_eur cost_eur
FROM mv_hot.edge_oee_stops_recent FROM mv_hot.edge_oee_stops_recent
WHERE classification_status = 'PENDING' WHERE classification_status = 'PENDING'
AND machine_id = %(machine_id)s
ORDER BY started_at DESC ORDER BY started_at DESC
LIMIT 50; LIMIT 50;
""" """
with get_conn() as conn: with get_conn() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(sql) cur.execute(sql, {"machine_id": machine_id})
return cur.fetchall() return cur.fetchall()
@ -81,7 +99,7 @@ def fetch_causes():
return cur.fetchall() return cur.fetchall()
def classify_stop(stop_id, cause_code, operator_note): def classify_stop(stop_id, machine_id, cause_code, operator_note):
sql = """ sql = """
UPDATE mv_hot.edge_oee_machine_stops UPDATE mv_hot.edge_oee_machine_stops
SET SET
@ -90,6 +108,7 @@ def classify_stop(stop_id, cause_code, operator_note):
classification_status = 'CLASSIFIED', classification_status = 'CLASSIFIED',
updated_at = now() updated_at = now()
WHERE id = %(stop_id)s WHERE id = %(stop_id)s
AND machine_id = %(machine_id)s
AND classification_status = 'PENDING'; AND classification_status = 'PENDING';
""" """
@ -99,221 +118,313 @@ def classify_stop(stop_id, cause_code, operator_note):
sql, sql,
{ {
"stop_id": stop_id, "stop_id": stop_id,
"machine_id": machine_id,
"cause_code": cause_code, "cause_code": cause_code,
"operator_note": operator_note, "operator_note": operator_note,
}, },
) )
return cur.rowcount
PAGE = """ def fmt_dt(value):
if not value:
return ""
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d %H:%M:%S")
return str(value)
def fmt_num(value, decimals=2):
if value is None:
return "0"
try:
return f"{float(value):.{decimals}f}"
except Exception:
return str(value)
HTML = """
<!doctype html> <!doctype html>
<html lang="es"> <html lang="es">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>{{ app_title }}</title> <title>{{ app_title }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="15">
<style> <style>
:root { :root {
--bg: #0f172a; --bg: #0f172a;
--card: #111827; --panel: #111827;
--card2: #1f2937; --panel2: #1f2937;
--text: #e5e7eb; --text: #e5e7eb;
--muted: #9ca3af; --muted: #94a3b8;
--line: #374151; --line: #334155;
--accent: #f97316; --blue: #2563eb;
--danger: #ef4444; --blue2: #60a5fa;
--ok: #22c55e; --green: #22c55e;
--button: #2563eb; --red: #ef4444;
--button-hover: #1d4ed8; --orange: #f97316;
--input: #020617;
}
* {
box-sizing: border-box;
} }
body { body {
margin: 0; margin: 0;
font-family: Arial, sans-serif; font-family: Arial, Helvetica, sans-serif;
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
} }
header { header {
padding: 18px 24px; padding: 18px 22px;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
background: #020617;
} }
h1 { h1 {
margin: 0; margin: 0 0 6px 0;
font-size: 24px; font-size: 28px;
line-height: 1.1;
} }
.subtitle { .subtitle {
color: var(--muted); color: var(--muted);
margin-top: 6px; font-size: 15px;
font-size: 14px;
} }
main { main {
padding: 24px; padding: 22px;
max-width: 1400px; max-width: 1800px;
margin: 0 auto; margin: 0 auto;
} }
.summary { .machine-tabs {
display: flex; display: flex;
gap: 12px; gap: 8px;
margin: 0 0 22px 0;
flex-wrap: wrap; flex-wrap: wrap;
margin-bottom: 18px;
} }
.summary-card { .machine-tab {
background: var(--card); padding: 9px 13px;
border: 1px solid var(--line);
border-radius: 10px; border-radius: 10px;
padding: 14px 18px; background: var(--panel2);
min-width: 180px; color: #dbeafe;
text-decoration: none;
border: 1px solid var(--line);
font-weight: 600;
font-size: 14px;
} }
.summary-card .label { .machine-tab.active {
background: var(--blue);
color: white;
border-color: var(--blue2);
}
.cards {
display: grid;
grid-template-columns: repeat(3, minmax(220px, 1fr));
gap: 14px;
margin-bottom: 22px;
}
.card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 12px;
padding: 18px;
min-height: 96px;
}
.card-title {
color: var(--muted); color: var(--muted);
font-size: 13px; font-size: 15px;
margin-bottom: 8px; margin-bottom: 10px;
} }
.summary-card .value { .card-value {
font-size: 26px; font-size: 34px;
font-weight: bold; line-height: 1;
font-weight: 700;
}
.card-subtitle {
margin-top: 8px;
color: var(--muted);
font-size: 14px;
}
.table-wrap {
overflow-x: auto;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 12px;
} }
table { table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
background: var(--card); min-width: 1300px;
border: 1px solid var(--line);
border-radius: 10px;
overflow: hidden;
}
th, td {
padding: 12px;
border-bottom: 1px solid var(--line);
vertical-align: top;
text-align: left;
font-size: 14px;
} }
th { th {
background: var(--card2); background: var(--panel2);
color: #f9fafb; color: white;
font-weight: bold; text-align: left;
font-size: 14px;
padding: 12px 14px;
border-bottom: 1px solid var(--line);
white-space: nowrap;
}
td {
padding: 12px 14px;
border-bottom: 1px solid #1e293b;
vertical-align: top;
font-size: 14px;
} }
tr:last-child td { tr:last-child td {
border-bottom: none; border-bottom: none;
} }
.machine-main {
font-weight: 700;
font-size: 16px;
}
.machine-sub {
color: var(--muted);
margin-top: 2px;
font-size: 13px;
}
.badge { .badge {
display: inline-block; display: inline-block;
padding: 4px 8px; padding: 4px 9px;
border-radius: 999px; border-radius: 999px;
font-size: 12px; font-size: 12px;
font-weight: bold; font-weight: 700;
border: 1px solid transparent;
margin-right: 4px;
} }
.badge-open { .badge-open {
background: rgba(239, 68, 68, 0.15); background: rgba(239, 68, 68, 0.14);
color: #fca5a5; color: #fecaca;
border: 1px solid rgba(239, 68, 68, 0.4); border-color: rgba(239, 68, 68, 0.45);
} }
.badge-closed { .badge-closed {
background: rgba(34, 197, 94, 0.15); background: rgba(34, 197, 94, 0.14);
color: #86efac; color: #bbf7d0;
border: 1px solid rgba(34, 197, 94, 0.4); border-color: rgba(34, 197, 94, 0.45);
} }
.badge-pending { .badge-pending {
background: rgba(249, 115, 22, 0.15); background: rgba(249, 115, 22, 0.14);
color: #fdba74; color: #fed7aa;
border: 1px solid rgba(249, 115, 22, 0.4); border-color: rgba(249, 115, 22, 0.45);
margin-top: 5px;
} }
select, input[type="text"] { select,
input[type="text"] {
width: 100%; width: 100%;
box-sizing: border-box; min-width: 210px;
background: #020617; padding: 10px 12px;
color: var(--text); border-radius: 8px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 6px; background: var(--input);
padding: 9px; color: var(--text);
font-size: 14px; font-size: 14px;
} }
button { button {
background: var(--button); padding: 10px 18px;
border-radius: 8px;
border: 0;
background: var(--blue);
color: white; color: white;
border: none; font-weight: 700;
border-radius: 6px;
padding: 10px 14px;
cursor: pointer; cursor: pointer;
font-weight: bold; font-size: 14px;
width: 100%;
} }
button:hover { button:hover {
background: var(--button-hover); background: #1d4ed8;
} }
.empty { .empty {
background: var(--card); padding: 40px;
border: 1px solid var(--line);
border-radius: 10px;
padding: 32px;
text-align: center; text-align: center;
color: var(--muted); color: var(--muted);
font-size: 18px;
} }
.small { .footer-note {
font-size: 12px; margin-top: 18px;
color: var(--muted); color: var(--muted);
font-size: 13px;
} }
.nowrap { @media (max-width: 900px) {
white-space: nowrap; main {
padding: 14px;
}
.cards {
grid-template-columns: 1fr;
}
h1 {
font-size: 22px;
}
} }
</style> </style>
</head> </head>
<body> <body>
<header> <header>
<h1>{{ app_title }}</h1> <h1>{{ app_title }}</h1>
<div class="subtitle"> <div class="subtitle">
Clasificación manual de paros detectados automáticamente por Edge-OEE. Clasificación manual de paros detectados por Edge-OEE ·
Última carga: {{ now }} Máquina seleccionada: <strong>{{ selected_machine }}</strong> ·
Última carga: {{ loaded_at }}
</div> </div>
</header> </header>
<main> <main>
<div class="summary"> <div class="machine-tabs">
<div class="summary-card"> {% for machine in allowed_machines %}
<div class="label">Paros pendientes</div> <a
<div class="value">{{ stops|length }}</div> class="machine-tab {% if machine == selected_machine %}active{% endif %}"
href="{{ url_for('index', machine_id=machine) }}"
>
{{ machine }}
</a>
{% endfor %}
</div> </div>
<div class="cards">
<div class="card">
<div class="card-title">Paros pendientes</div>
<div class="card-value">{{ pending_count }}</div>
</div>
<div class="summary-card"> <div class="card">
<div class="card-title">Máquina monitorizada</div> <div class="card-title">Máquina monitorizada</div>
<div class="card-value">{{ default_machine }}</div> <div class="card-value">{{ selected_machine }}</div>
<div class="card-subtitle">{{ default_asset }}</div> <div class="card-subtitle">{{ default_asset }}</div>
</div> </div>
</div> </div>
{% if stops|length == 0 %} <div class="table-wrap">
<div class="empty"> {% if stops %}
No hay paros pendientes de clasificar.
</div>
{% else %}
<table> <table>
<thead> <thead>
<tr> <tr>
@ -332,43 +443,40 @@ PAGE = """
<tbody> <tbody>
{% for stop in stops %} {% for stop in stops %}
<tr> <tr>
<form method="post" action="{{ url_for('classify') }}"> <td>#{{ stop.id }}</td>
<input type="hidden" name="stop_id" value="{{ stop.id }}">
<td class="nowrap">#{{ stop.id }}</td>
<td> <td>
<strong>{{ stop.machine_id or "-" }}</strong> <div class="machine-main">{{ stop.machine_id }}</div>
<div class="small">{{ stop.asset }}</div> <div class="machine-sub">{{ stop.asset }}</div>
</td> </td>
<td class="nowrap"> <td>{{ fmt_dt(stop.started_at) }}</td>
{{ stop.started_at }}
</td>
<td class="nowrap"> <td>
{{ stop.ended_at or "Abierto" }} {% if stop.ended_at %}
{{ fmt_dt(stop.ended_at) }}
{% else %}
Abierto
{% endif %}
</td> </td>
<td> <td>
{% if stop.status == 'OPEN' %} {% if stop.status == "OPEN" %}
<span class="badge badge-open">OPEN</span> <span class="badge badge-open">OPEN</span>
{% else %} {% else %}
<span class="badge badge-closed">CLOSED</span> <span class="badge badge-closed">{{ stop.status }}</span>
{% endif %} {% endif %}
<br> <br>
<span class="badge badge-pending">{{ stop.classification_status }}</span> <span class="badge badge-pending">{{ stop.classification_status }}</span>
</td> </td>
<td class="nowrap"> <td>{{ fmt_num(stop.duration_min, 2) }} min</td>
{{ "%.2f"|format(stop.duration_min or 0) }} min <td>{{ fmt_num(stop.cost_eur, 2) }} </td>
</td>
<td class="nowrap">
{{ "%.2f"|format(stop.cost_eur or 0) }}
</td>
<td> <td>
<form method="post" action="{{ url_for('classify', stop_id=stop.id) }}">
<input type="hidden" name="machine_id" value="{{ selected_machine }}">
<select name="cause_code" required> <select name="cause_code" required>
<option value="">Seleccionar causa...</option> <option value="">Seleccionar causa...</option>
{% for cause in causes %} {% for cause in causes %}
@ -380,18 +488,29 @@ PAGE = """
</td> </td>
<td> <td>
<input type="text" name="operator_note" placeholder="Nota opcional"> <input
type="text"
name="operator_note"
placeholder="Nota opcional"
value="{{ stop.operator_note or '' }}"
>
</td> </td>
<td> <td>
<button type="submit">Guardar</button> <button type="submit">Guardar</button>
</td>
</form> </form>
</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% else %}
<div class="empty">
No hay paros pendientes para {{ selected_machine }}.
</div>
{% endif %} {% endif %}
</div>
</main> </main>
</body> </body>
</html> </html>
@ -400,30 +519,45 @@ PAGE = """
@app.route("/", methods=["GET"]) @app.route("/", methods=["GET"])
def index(): def index():
stops = fetch_pending_stops() selected_machine = resolve_machine_id(request.args.get("machine_id"))
stops = fetch_pending_stops(selected_machine)
causes = fetch_causes() causes = fetch_causes()
return render_template_string( return render_template_string(
PAGE, HTML,
app_title=APP_TITLE, app_title=APP_TITLE,
stops=stops, stops=stops,
causes=causes, causes=causes,
now=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), pending_count=len(stops),
default_machine=DEFAULT_MACHINE_ID, selected_machine=selected_machine,
allowed_machines=ALLOWED_MACHINES,
default_machine=selected_machine,
default_asset=DEFAULT_ASSET, default_asset=DEFAULT_ASSET,
loaded_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
fmt_dt=fmt_dt,
fmt_num=fmt_num,
) )
@app.route("/classify", methods=["POST"]) @app.route("/classify/<int:stop_id>", methods=["POST"])
def classify(): def classify(stop_id):
stop_id = request.form.get("stop_id") selected_machine = resolve_machine_id(
cause_code = request.form.get("cause_code") request.form.get("machine_id") or request.args.get("machine_id")
operator_note = request.form.get("operator_note", "").strip() or None )
if stop_id and cause_code: cause_code = (request.form.get("cause_code") or "").strip()
classify_stop(stop_id, cause_code, operator_note) operator_note = (request.form.get("operator_note") or "").strip()
return redirect(url_for("index")) if cause_code:
classify_stop(
stop_id=stop_id,
machine_id=selected_machine,
cause_code=cause_code,
operator_note=operator_note,
)
return redirect(url_for("index", machine_id=selected_machine))
@app.route("/health", methods=["GET"]) @app.route("/health", methods=["GET"])
@ -432,6 +566,14 @@ def health():
with get_conn() as conn: with get_conn() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute("SELECT 1;") cur.execute("SELECT 1;")
cur.fetchone()
return {"status": "ok"}, 200 return {"status": "ok"}, 200
except Exception as exc: except Exception as exc:
return {"status": "error", "error": str(exc)}, 500 return {"status": "error", "error": str(exc)}, 500
if __name__ == "__main__":
port = int(getenv("APP_PORT", "8080"))
app.run(host="0.0.0.0", port=port)