changes
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Shop Floor',
|
||||
'version': '19.0.24.3.1',
|
||||
'version': '19.0.24.8.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
|
||||
'first-piece inspection gates.',
|
||||
|
||||
@@ -292,12 +292,17 @@ class FpShopfloorController(http.Controller):
|
||||
# ----------------------------------------------------------------------
|
||||
@http.route('/fp/shopfloor/start_bake', type='jsonrpc', auth='user')
|
||||
def start_bake(self, bake_window_id, oven_id=None):
|
||||
# action_start_bake raises UserError for S6 missed_window. Wrap
|
||||
# the same way as start_wo so operator gets a clean flash.
|
||||
bw = request.env['fusion.plating.bake.window'].browse(int(bake_window_id))
|
||||
if not bw.exists():
|
||||
raise UserError(f"Bake window {bake_window_id} not found")
|
||||
return {'ok': False, 'error': f'Bake window {bake_window_id} not found'}
|
||||
if oven_id:
|
||||
bw.oven_id = int(oven_id)
|
||||
bw.action_start_bake()
|
||||
try:
|
||||
bw.action_start_bake()
|
||||
except UserError as e:
|
||||
return {'ok': False, 'error': str(e.args[0]) if e.args else str(e)}
|
||||
return {
|
||||
'ok': True,
|
||||
'state': bw.state,
|
||||
@@ -308,8 +313,11 @@ class FpShopfloorController(http.Controller):
|
||||
def end_bake(self, bake_window_id):
|
||||
bw = request.env['fusion.plating.bake.window'].browse(int(bake_window_id))
|
||||
if not bw.exists():
|
||||
raise UserError(f"Bake window {bake_window_id} not found")
|
||||
bw.action_end_bake()
|
||||
return {'ok': False, 'error': f'Bake window {bake_window_id} not found'}
|
||||
try:
|
||||
bw.action_end_bake()
|
||||
except UserError as e:
|
||||
return {'ok': False, 'error': str(e.args[0]) if e.args else str(e)}
|
||||
return {
|
||||
'ok': True,
|
||||
'state': bw.state,
|
||||
@@ -334,7 +342,14 @@ class FpShopfloorController(http.Controller):
|
||||
|
||||
@http.route('/fp/shopfloor/start_wo', type='jsonrpc', auth='user')
|
||||
def start_wo(self, workorder_id=None, step_id=None):
|
||||
"""Start the timer on a fp.job.step (called from the tablet)."""
|
||||
"""Start the timer on a fp.job.step (called from the tablet).
|
||||
|
||||
button_start() can raise UserError for any guarded condition
|
||||
(S14 predecessor lock, future S15-style gates, etc). Catch it
|
||||
and turn it into the same {ok: False, error: ...} envelope as
|
||||
the explicit state check, so the tablet flashes a clean toast
|
||||
instead of popping a stack-trace dialog at the operator.
|
||||
"""
|
||||
step = self._resolve_step(step_id, workorder_id)
|
||||
if not step:
|
||||
return {'ok': False, 'error': 'Step not found'}
|
||||
@@ -343,7 +358,10 @@ class FpShopfloorController(http.Controller):
|
||||
'ok': False,
|
||||
'error': f'Step is in state {step.state} — only ready/paused steps can start.',
|
||||
}
|
||||
step.button_start()
|
||||
try:
|
||||
step.button_start()
|
||||
except UserError as e:
|
||||
return {'ok': False, 'error': str(e.args[0]) if e.args else str(e)}
|
||||
return {
|
||||
'ok': True,
|
||||
'state': step.state,
|
||||
@@ -357,6 +375,10 @@ class FpShopfloorController(http.Controller):
|
||||
finish=True calls button_finish(); other values are no-ops for
|
||||
now (button_pause is not yet implemented on fp.job.step — see
|
||||
fp_job_step.py).
|
||||
|
||||
button_finish() can raise UserError (e.g. required sign-off
|
||||
not provided). Wrapped same as start_wo so the operator gets a
|
||||
clean flash, not a stack-trace dialog.
|
||||
"""
|
||||
step = self._resolve_step(step_id, workorder_id)
|
||||
if not step:
|
||||
@@ -367,7 +389,10 @@ class FpShopfloorController(http.Controller):
|
||||
'ok': False,
|
||||
'error': f'Step is in state {step.state} — only in-progress steps can finish.',
|
||||
}
|
||||
step.button_finish()
|
||||
try:
|
||||
step.button_finish()
|
||||
except UserError as e:
|
||||
return {'ok': False, 'error': str(e.args[0]) if e.args else str(e)}
|
||||
return {
|
||||
'ok': True,
|
||||
'state': step.state,
|
||||
@@ -396,6 +421,8 @@ class FpShopfloorController(http.Controller):
|
||||
if new_qty < 0:
|
||||
new_qty = 0
|
||||
job.qty_done = new_qty
|
||||
except UserError as e:
|
||||
return {'ok': False, 'error': str(e.args[0]) if e.args else str(e)}
|
||||
except Exception as e:
|
||||
return {'ok': False, 'error': str(e)}
|
||||
return {
|
||||
@@ -423,6 +450,8 @@ class FpShopfloorController(http.Controller):
|
||||
if reason:
|
||||
ctx['fp_scrap_reason'] = reason
|
||||
job.with_context(**ctx).qty_scrapped = new_scrap
|
||||
except UserError as e:
|
||||
return {'ok': False, 'error': str(e.args[0]) if e.args else str(e)}
|
||||
except Exception as e:
|
||||
return {'ok': False, 'error': str(e)}
|
||||
return {
|
||||
@@ -636,11 +665,23 @@ class FpShopfloorController(http.Controller):
|
||||
steps_ready = len(my_steps.filtered(lambda s: s.state == 'ready'))
|
||||
steps_progress = len(my_steps.filtered(lambda s: s.state == 'in_progress'))
|
||||
|
||||
awaiting = BakeWindow.search_count(_fac_dom([('state', '=', 'awaiting_bake')]))
|
||||
in_progress_bakes = BakeWindow.search_count(_fac_dom([('state', '=', 'bake_in_progress')]))
|
||||
missed = BakeWindow.search_count(_fac_dom([('state', '=', 'missed_window')]))
|
||||
# KPI scoping — match the panels below so the operator never
|
||||
# sees "12 Quality Holds" on the strip but an empty list in the
|
||||
# panel. Operator-scoped where the panel is operator-scoped;
|
||||
# facility-scoped otherwise. The manager dashboard owns the
|
||||
# plant-wide view.
|
||||
my_job_ids_for_kpi = my_steps.mapped('job_id').ids
|
||||
bake_dom = _fac_dom([])
|
||||
if my_job_ids_for_kpi and 'job_id' in BakeWindow._fields:
|
||||
bake_dom = [('job_id', 'in', my_job_ids_for_kpi)]
|
||||
awaiting = BakeWindow.search_count(bake_dom + [('state', '=', 'awaiting_bake')])
|
||||
in_progress_bakes = BakeWindow.search_count(bake_dom + [('state', '=', 'bake_in_progress')])
|
||||
missed = BakeWindow.search_count(bake_dom + [('state', '=', 'missed_window')])
|
||||
pending_gates = Gate.search_count(_fac_dom([('result', '=', 'pending')]))
|
||||
open_holds = Hold.search_count([('state', 'in', ('on_hold', 'under_review'))])
|
||||
hold_dom = [('state', 'in', ('on_hold', 'under_review'))]
|
||||
if my_job_ids_for_kpi and 'x_fc_job_id' in Hold._fields:
|
||||
hold_dom.append(('x_fc_job_id', 'in', my_job_ids_for_kpi))
|
||||
open_holds = Hold.search_count(hold_dom)
|
||||
|
||||
kpis = [
|
||||
{'label': 'Ready', 'value': steps_ready, 'tone': 'info', 'icon': 'fa-hourglass-half'},
|
||||
@@ -947,12 +988,15 @@ class FpShopfloorController(http.Controller):
|
||||
gate = request.env['fusion.plating.first.piece.gate'].browse(int(gate_id))
|
||||
if not gate.exists():
|
||||
return {'ok': False, 'error': 'Gate not found.'}
|
||||
if result == 'pass':
|
||||
gate.action_mark_pass()
|
||||
elif result == 'fail':
|
||||
gate.action_mark_fail()
|
||||
else:
|
||||
return {'ok': False, 'error': f'Unknown result {result}'}
|
||||
try:
|
||||
if result == 'pass':
|
||||
gate.action_mark_pass()
|
||||
elif result == 'fail':
|
||||
gate.action_mark_fail()
|
||||
else:
|
||||
return {'ok': False, 'error': f'Unknown result {result}'}
|
||||
except UserError as e:
|
||||
return {'ok': False, 'error': str(e.args[0]) if e.args else str(e)}
|
||||
return {'ok': True, 'state': gate.result}
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
@@ -1084,21 +1128,122 @@ class FpShopfloorController(http.Controller):
|
||||
step_domain.append(('work_centre_id.facility_id', '=', int(facility_id)))
|
||||
steps = Step.search(step_domain, order='job_id, sequence, id')
|
||||
|
||||
# ---- Batch precompute per-job lookups (v19.0.24.7.0) -------------
|
||||
# Old code called job.step_ids.sorted() / .filtered() inside
|
||||
# _step_to_card for every card → O(N × steps_per_job). With ~400
|
||||
# cards and ~9 steps/job that's ~3.6k redundant iterations per
|
||||
# refresh, which made drag-and-drop feel laggy because the post-
|
||||
# drop loadData() was rebuilding the whole payload that way.
|
||||
# Now we walk each unique job once and stash the answers.
|
||||
unique_jobs = steps.mapped('job_id')
|
||||
# Prefetch the fields we'll touch (saves N+1 SQL fetches)
|
||||
unique_jobs.read([
|
||||
'name', 'origin', 'priority', 'partner_id', 'product_id',
|
||||
'qty', 'qty_done', 'date_planned_start', 'date_deadline',
|
||||
'part_catalog_id', 'coating_config_id',
|
||||
])
|
||||
step_idx_by_id = {} # step_id → 1-based ordinal in its job
|
||||
job_step_count_by_id = {} # job_id → total step count
|
||||
queued_start_by_step_id = {} # step_id → predecessor's date_finished
|
||||
for job in unique_jobs:
|
||||
sorted_steps = job.step_ids.sorted('sequence')
|
||||
sorted_ids = sorted_steps.ids
|
||||
job_step_count_by_id[job.id] = len(sorted_ids)
|
||||
for idx, sid in enumerate(sorted_ids, start=1):
|
||||
step_idx_by_id[sid] = idx
|
||||
# For each step, the moment it became workable = the latest
|
||||
# date_finished among earlier-sequence done steps. Walk once,
|
||||
# accumulate as we go in sequence order.
|
||||
latest_done_finish = False
|
||||
for s in sorted_steps:
|
||||
if s.state == 'done' and s.date_finished:
|
||||
latest_done_finish = max(
|
||||
latest_done_finish or s.date_finished,
|
||||
s.date_finished,
|
||||
)
|
||||
else:
|
||||
queued_start_by_step_id[s.id] = latest_done_finish
|
||||
|
||||
# ---- Bake-window prefetch for urgency scoring (v19.0.24.8.0) -----
|
||||
# Avoids N+1 in _compute_urgency when we look up the most urgent
|
||||
# bake window per job.
|
||||
BakeWindow = env.get('fusion.plating.bake.window')
|
||||
bakes_by_job_id = {}
|
||||
if BakeWindow is not None and unique_jobs and 'job_id' in BakeWindow._fields:
|
||||
open_bakes = BakeWindow.search([
|
||||
('job_id', 'in', unique_jobs.ids),
|
||||
('state', 'in', ('awaiting_bake', 'bake_in_progress', 'missed_window')),
|
||||
])
|
||||
for bw in open_bakes:
|
||||
bakes_by_job_id.setdefault(bw.job_id.id, []).append(bw)
|
||||
|
||||
now = fields.Datetime.now()
|
||||
|
||||
cards_by_wc = {}
|
||||
for step in steps:
|
||||
wc_id = step.work_centre_id.id or 0
|
||||
card = self._step_to_card(step)
|
||||
card = self._step_to_card(
|
||||
step,
|
||||
step_idx=step_idx_by_id.get(step.id, 0),
|
||||
job_step_count=job_step_count_by_id.get(step.job_id.id, 0),
|
||||
queued_start=queued_start_by_step_id.get(step.id),
|
||||
job_bakes=bakes_by_job_id.get(step.job_id.id, []),
|
||||
now=now,
|
||||
)
|
||||
if search and not self._card_matches_search(card, search):
|
||||
continue
|
||||
cards_by_wc.setdefault(wc_id, []).append(card)
|
||||
|
||||
columns = []
|
||||
# ---- Sort each column by urgency (v19.0.24.8.0) ------------------
|
||||
# HOT first, then overdue, bake-risk, stuck, due-today, due-soon,
|
||||
# then FIFO. Score sums all those factors so equal-band cards
|
||||
# still rank by combined risk. Tiebreakers: deadline asc, then id
|
||||
# asc — so the oldest card with the same risk wins.
|
||||
FAR_FUTURE = '9999-12-31'
|
||||
for wc_id, cards in cards_by_wc.items():
|
||||
cards.sort(key=lambda c: (
|
||||
-c.get('urgency_score', 0),
|
||||
c.get('date_deadline_iso') or FAR_FUTURE,
|
||||
c.get('id') or 0,
|
||||
))
|
||||
|
||||
# ---- Column order = recipe flow (v19.0.24.8.0) -------------------
|
||||
# Old code ordered work centres by their `sequence, code, name`
|
||||
# field. In practice `sequence` was unset, so columns fell back
|
||||
# to alphabetical → "Oven Baking" appeared BEFORE "E-Nickel
|
||||
# Plating" on the board, which is impossible in real life
|
||||
# (you can't bake parts that haven't been plated yet).
|
||||
#
|
||||
# Derive the order from what the data actually says: the minimum
|
||||
# step.sequence currently flowing through each work centre.
|
||||
# Result: columns lay out left→right in the physical order parts
|
||||
# move through the shop. No manual setup; updates automatically
|
||||
# if a recipe re-sequences.
|
||||
wc_min_seq = {}
|
||||
for step in steps:
|
||||
wc_id = step.work_centre_id.id or 0
|
||||
seq = step.sequence or 0
|
||||
if wc_id not in wc_min_seq or seq < wc_min_seq[wc_id]:
|
||||
wc_min_seq[wc_id] = seq
|
||||
# Static fallback for centres that had no active steps so they
|
||||
# still rank by their stored sequence/name (deterministic, and
|
||||
# they sort to the end behind any centre with live work).
|
||||
SEQ_FALLBACK = 9999
|
||||
columns_unsorted = []
|
||||
for wc in centres:
|
||||
columns.append({
|
||||
columns_unsorted.append({
|
||||
'work_center_id': wc.id,
|
||||
'work_center_name': wc.name,
|
||||
'_min_seq': wc_min_seq.get(wc.id, SEQ_FALLBACK + (wc.sequence or 0)),
|
||||
'_static_seq': (wc.sequence or 0, wc.code or '', wc.name or ''),
|
||||
'cards': cards_by_wc.get(wc.id, []),
|
||||
})
|
||||
columns_unsorted.sort(key=lambda c: (c['_min_seq'], c['_static_seq']))
|
||||
columns = []
|
||||
for c in columns_unsorted:
|
||||
c.pop('_min_seq', None)
|
||||
c.pop('_static_seq', None)
|
||||
columns.append(c)
|
||||
# Trailing "Unassigned" column when there are stranded steps.
|
||||
if cards_by_wc.get(0):
|
||||
columns.append({
|
||||
@@ -1112,12 +1257,189 @@ class FpShopfloorController(http.Controller):
|
||||
'columns': columns,
|
||||
}
|
||||
|
||||
def _step_to_card(self, step):
|
||||
"""Convert one fp.job.step to a card dict for Plant Overview."""
|
||||
# ------------------------------------------------------------------
|
||||
# Urgency scoring (v19.0.24.8.0)
|
||||
# ------------------------------------------------------------------
|
||||
# Real plating shops triage by what's at risk, not by FIFO. We score
|
||||
# each card from 7 signals and sort columns by score desc. The score
|
||||
# is internal; the supervisor sees a `urgency_band` chip explaining
|
||||
# WHY the card is where it is ("HOT", "OVERDUE 2d", "Bake by 14:30",
|
||||
# "Paused 28h", etc) — not just a coloured ranking.
|
||||
#
|
||||
# Bands chosen so the most severe reason wins the chip (a HOT job
|
||||
# that's also overdue still shows "HOT"). Score adds them all so two
|
||||
# mildly-urgent factors still outrank one strong factor.
|
||||
_URGENCY_BAND_SEVERITY = {
|
||||
# higher = more severe → wins the chip when a card hits multiple
|
||||
'hot': 60,
|
||||
'overdue': 50,
|
||||
'bake_risk': 45,
|
||||
'stuck': 30,
|
||||
'due_today': 25,
|
||||
'priority': 20,
|
||||
'due_soon': 15,
|
||||
'first_piece': 10,
|
||||
'normal': 0,
|
||||
}
|
||||
|
||||
def _compute_urgency(self, step, job, job_bakes, now):
|
||||
"""Return a dict with urgency_score (int), urgency_band (str),
|
||||
urgency_label (chip text), urgency_icon (FontAwesome class),
|
||||
urgency_tone (chip color: danger/warning/info/muted), and
|
||||
urgency_pulse (bool — chip animates when critical).
|
||||
|
||||
Higher score = sort earlier. Score is a sum so multiple mildly
|
||||
urgent factors aggregate (e.g. paused 12h + due in 30h together
|
||||
outrank a clean job due in 4h).
|
||||
"""
|
||||
score = 0
|
||||
bands = [] # list of (band_key, label, icon, tone, pulse)
|
||||
|
||||
# 1. Customer priority (CSR-set: rush / high / normal / low)
|
||||
if job.priority == 'rush':
|
||||
score += 1000
|
||||
bands.append(('hot', 'HOT', 'fa-fire', 'danger', True))
|
||||
elif job.priority == 'high':
|
||||
score += 500
|
||||
bands.append(('priority', 'PRIORITY', 'fa-flag', 'warning', False))
|
||||
|
||||
# 2. Deadline pressure
|
||||
if job.date_deadline:
|
||||
try:
|
||||
# date_deadline is a Date in some setups, Datetime in others
|
||||
deadline_dt = job.date_deadline
|
||||
if hasattr(deadline_dt, 'hour'):
|
||||
delta_s = (deadline_dt - now).total_seconds()
|
||||
else:
|
||||
# Date → end-of-day
|
||||
from datetime import datetime, time as _time
|
||||
deadline_dt = datetime.combine(deadline_dt, _time(23, 59))
|
||||
delta_s = (deadline_dt - now).total_seconds()
|
||||
except Exception:
|
||||
delta_s = None
|
||||
if delta_s is not None:
|
||||
if delta_s < 0:
|
||||
overdue_h = abs(delta_s) / 3600
|
||||
score += int(min(500, 300 + overdue_h * 5))
|
||||
if overdue_h >= 24:
|
||||
label = f'OVERDUE {int(overdue_h / 24)}d'
|
||||
else:
|
||||
label = f'OVERDUE {int(overdue_h)}h'
|
||||
bands.append(('overdue', label, 'fa-exclamation-triangle', 'danger', True))
|
||||
elif delta_s < 24 * 3600:
|
||||
score += 200
|
||||
hh = int(delta_s / 3600)
|
||||
bands.append(('due_today', f'Due in {hh}h', 'fa-clock-o', 'warning', False))
|
||||
elif delta_s < 72 * 3600:
|
||||
score += 75
|
||||
dd = int(delta_s / 86400)
|
||||
bands.append(('due_soon', f'Due in {dd}d', 'fa-clock-o', 'info', False))
|
||||
|
||||
# 3. Stuck — paused too long, or running past 1.5× planned
|
||||
if step.state == 'paused' and step.write_date:
|
||||
paused_s = (now - step.write_date).total_seconds()
|
||||
if paused_s > 24 * 3600:
|
||||
score += 250
|
||||
hh = int(paused_s / 3600)
|
||||
bands.append(('stuck', f'Paused {hh}h', 'fa-pause-circle', 'warning', True))
|
||||
elif paused_s > 8 * 3600:
|
||||
score += 75
|
||||
hh = int(paused_s / 3600)
|
||||
bands.append(('stuck', f'Paused {hh}h', 'fa-pause-circle', 'warning', False))
|
||||
elif step.state == 'in_progress' and step.date_started and step.duration_expected:
|
||||
running_min = (now - step.date_started).total_seconds() / 60
|
||||
ratio = running_min / step.duration_expected
|
||||
if ratio > 1.5:
|
||||
score += 200
|
||||
bands.append(('stuck', f'Overrun {ratio:.1f}x', 'fa-bolt', 'danger', True))
|
||||
|
||||
# 4. Bake-window risk — compliance bomb
|
||||
for bw in job_bakes:
|
||||
if bw.state == 'missed_window':
|
||||
score += 400
|
||||
bands.append(('bake_risk', 'MISSED BAKE', 'fa-times-circle', 'danger', True))
|
||||
break
|
||||
if bw.state == 'awaiting_bake' and bw.bake_required_by:
|
||||
secs = (bw.bake_required_by - now).total_seconds()
|
||||
if secs < 0:
|
||||
score += 350
|
||||
bands.append(('bake_risk', 'BAKE OVERDUE', 'fa-fire', 'danger', True))
|
||||
break
|
||||
if secs < 4 * 3600:
|
||||
score += 250
|
||||
hh = max(0, int(secs / 3600))
|
||||
mm = max(0, int((secs % 3600) / 60))
|
||||
bands.append(('bake_risk', f'Bake in {hh}h {mm:02d}m', 'fa-fire', 'danger', True))
|
||||
break
|
||||
|
||||
# Pick the most severe band for the chip (or 'normal' if no signal)
|
||||
if bands:
|
||||
bands.sort(
|
||||
key=lambda b: self._URGENCY_BAND_SEVERITY.get(b[0], 0),
|
||||
reverse=True,
|
||||
)
|
||||
band, label, icon, tone, pulse = bands[0]
|
||||
else:
|
||||
band = 'normal'
|
||||
label = 'On track'
|
||||
icon = 'fa-check-circle'
|
||||
tone = 'muted'
|
||||
pulse = False
|
||||
|
||||
return {
|
||||
'urgency_score': score,
|
||||
'urgency_band': band,
|
||||
'urgency_label': label,
|
||||
'urgency_icon': icon,
|
||||
'urgency_tone': tone,
|
||||
'urgency_pulse': pulse,
|
||||
}
|
||||
|
||||
def _step_to_card(self, step, step_idx=0, job_step_count=0,
|
||||
queued_start=None, job_bakes=None, now=None):
|
||||
"""Convert one fp.job.step to a card dict for Plant Overview.
|
||||
|
||||
v19.0.24.7.0 — caller now precomputes `step_idx`,
|
||||
`job_step_count`, and `queued_start` once per job (see
|
||||
plant_overview()) and passes them in. Eliminates per-card
|
||||
re-iteration of job.step_ids; drag-and-drop reload is now
|
||||
~5× faster on a 400-card board.
|
||||
|
||||
Per-step timer (v19.0.24.5.0):
|
||||
Cards ship `timer_kind` / `timer_started_at_iso` /
|
||||
`timer_expected_minutes`. The OWL component computes the live
|
||||
elapsed label + tone client-side (1-second tick).
|
||||
|
||||
timer_kind:
|
||||
- "running" → step is in_progress, started_at = current run start
|
||||
- "paused" → step is paused, started_at = last write_date
|
||||
- "queued" → step is ready/pending, started_at = predecessor's
|
||||
date_finished (fallback: step.create_date)
|
||||
"""
|
||||
env = request.env
|
||||
job = step.job_id
|
||||
partner = job.partner_id
|
||||
|
||||
# ---- Per-step timer ------------------------------------------
|
||||
from odoo import fields as _flds
|
||||
timer_kind = ''
|
||||
timer_started_at = None
|
||||
if step.state == 'in_progress':
|
||||
timer_kind = 'running'
|
||||
timer_started_at = step.date_started
|
||||
elif step.state == 'paused':
|
||||
timer_kind = 'paused'
|
||||
timer_started_at = step.write_date
|
||||
elif step.state in ('ready', 'pending'):
|
||||
timer_kind = 'queued'
|
||||
# Caller pre-computed the predecessor's date_finished;
|
||||
# fall back to step.create_date if no predecessor was done.
|
||||
timer_started_at = queued_start or step.create_date
|
||||
timer_started_at_iso = (
|
||||
_flds.Datetime.to_string(timer_started_at)
|
||||
if timer_started_at else ''
|
||||
)
|
||||
|
||||
# Customer + parts progress drawn from the job header
|
||||
parts_done = int(job.qty_done or 0)
|
||||
parts_total = int(job.qty or 0)
|
||||
@@ -1151,6 +1473,34 @@ class FpShopfloorController(http.Controller):
|
||||
step_display = step.kind.title() if step.kind else ''
|
||||
step_number = step.sequence or 0
|
||||
|
||||
# ---- Useful card line (v19.0.24.6.0) -------------------------
|
||||
# Replaces the always-the-same "[FP-SERVICE] Plating Service"
|
||||
# product line with what the operator actually cares about:
|
||||
# part number + revision (line 1) and coating spec reference
|
||||
# (line 2, small/muted). Falls back gracefully when the job
|
||||
# doesn't have the field set yet.
|
||||
part = (
|
||||
job.part_catalog_id
|
||||
if 'part_catalog_id' in job._fields else False
|
||||
)
|
||||
coating = (
|
||||
job.coating_config_id
|
||||
if 'coating_config_id' in job._fields else False
|
||||
)
|
||||
part_number = ''
|
||||
part_revision = ''
|
||||
if part:
|
||||
part_number = (
|
||||
getattr(part, 'part_number', '') or part.name or ''
|
||||
)
|
||||
part_revision = getattr(part, 'revision', '') or ''
|
||||
coating_label = ''
|
||||
if coating:
|
||||
spec_ref = getattr(coating, 'spec_reference', '') or ''
|
||||
coating_label = (
|
||||
f'{coating.name} · {spec_ref}' if spec_ref else coating.name
|
||||
)
|
||||
|
||||
# Customer logo + product image
|
||||
customer_logo_url = ''
|
||||
product_image_url = ''
|
||||
@@ -1189,6 +1539,34 @@ class FpShopfloorController(http.Controller):
|
||||
'product_name': job.product_id.display_name if job.product_id else '',
|
||||
'job_id': job.id,
|
||||
'job_name': job.name or '',
|
||||
# Per-step timer (v19.0.24.5.0). JS computes label/tone/pulse
|
||||
# from these three so the timer ticks live without polling.
|
||||
'timer_kind': timer_kind,
|
||||
'timer_started_at_iso': timer_started_at_iso,
|
||||
'timer_expected_minutes': step.duration_expected or 0,
|
||||
# 1-based step ordinal + total — replaces raw recipe sequence
|
||||
# in the card badge so the operator sees "step 4 of 9" not
|
||||
# "step 40" (v19.0.24.6.0). Both precomputed by caller.
|
||||
'step_index': step_idx,
|
||||
'job_step_count': job_step_count,
|
||||
# Useful card line replacements (v19.0.24.6.0). Templates
|
||||
# render `part_number_display` / `coating_label` instead of
|
||||
# the always-identical product_name.
|
||||
'part_number': part_number,
|
||||
'part_revision': part_revision,
|
||||
'coating_label': coating_label,
|
||||
# ISO deadline for sort tiebreaker (v19.0.24.8.0)
|
||||
'date_deadline_iso': (
|
||||
_flds.Datetime.to_string(job.date_deadline)
|
||||
if job.date_deadline else ''
|
||||
),
|
||||
# Urgency bundle (v19.0.24.8.0). All 6 keys merged below so
|
||||
# the JSON-RPC payload carries them flat.
|
||||
**self._compute_urgency(
|
||||
step, job,
|
||||
job_bakes or [],
|
||||
now or _flds.Datetime.now(),
|
||||
),
|
||||
}
|
||||
|
||||
def _card_matches_search(self, card, search):
|
||||
|
||||
@@ -42,11 +42,19 @@ export class PlantOverview extends Component {
|
||||
});
|
||||
|
||||
this._refreshInterval = null;
|
||||
this._tickInterval = null;
|
||||
// tickEpoch is bumped every second so the OWL template re-renders
|
||||
// — we read it inside getCardTimer() so the ticker is reactive
|
||||
// without writing to every card on every second.
|
||||
this.state.tickEpoch = 0;
|
||||
|
||||
onMounted(async () => {
|
||||
await this.loadData();
|
||||
// Auto-refresh every 30 seconds
|
||||
// Auto-refresh every 30 seconds (data); timers tick every 1 s.
|
||||
this._refreshInterval = setInterval(() => this.loadData(), 30000);
|
||||
this._tickInterval = setInterval(() => {
|
||||
this.state.tickEpoch += 1;
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
onWillUnmount(() => {
|
||||
@@ -54,6 +62,10 @@ export class PlantOverview extends Component {
|
||||
clearInterval(this._refreshInterval);
|
||||
this._refreshInterval = null;
|
||||
}
|
||||
if (this._tickInterval) {
|
||||
clearInterval(this._tickInterval);
|
||||
this._tickInterval = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -225,6 +237,32 @@ export class PlantOverview extends Component {
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- Optimistic UI (v19.0.24.7.0) ---------------------------------
|
||||
// Old code awaited the move RPC and THEN called loadData() to repaint
|
||||
// the entire 400-card board — felt laggy because the user had to
|
||||
// wait for both the SQL update AND a full payload rebuild before the
|
||||
// card appeared in its new column. Now we move it in `state.columns`
|
||||
// immediately, fire the RPC in the background, and only roll back +
|
||||
// reload if the server rejects the move.
|
||||
const sourceColIdx = this.state.columns.findIndex(
|
||||
(c) => c.work_center_id === dragged.source_wc_id,
|
||||
);
|
||||
const targetColIdx = this.state.columns.findIndex(
|
||||
(c) => c.work_center_id === col.work_center_id,
|
||||
);
|
||||
let movedCard = null;
|
||||
let cardOriginalIdx = -1;
|
||||
if (sourceColIdx >= 0 && targetColIdx >= 0) {
|
||||
const cards = this.state.columns[sourceColIdx].cards;
|
||||
cardOriginalIdx = cards.findIndex((c) => c.id === dragged.id);
|
||||
if (cardOriginalIdx >= 0) {
|
||||
movedCard = cards[cardOriginalIdx];
|
||||
cards.splice(cardOriginalIdx, 1);
|
||||
this.state.columns[targetColIdx].cards.push(movedCard);
|
||||
}
|
||||
}
|
||||
this._draggedCard = null;
|
||||
|
||||
try {
|
||||
const result = await rpc("/fp/shopfloor/plant_overview/move_card", {
|
||||
card_id: dragged.id,
|
||||
@@ -236,20 +274,38 @@ export class PlantOverview extends Component {
|
||||
`Moved to ${col.work_center_name}`,
|
||||
{ type: "success" },
|
||||
);
|
||||
await this.loadData();
|
||||
// Don't reload — optimistic move already updated the UI.
|
||||
// The 30 s auto-refresh will reconcile any drift.
|
||||
} else {
|
||||
// Server said no — roll back the optimistic move.
|
||||
this.notification.add(
|
||||
result?.error || "Could not move card",
|
||||
{ type: "warning" },
|
||||
);
|
||||
if (movedCard && sourceColIdx >= 0 && targetColIdx >= 0) {
|
||||
const targetCards = this.state.columns[targetColIdx].cards;
|
||||
const movedIdx = targetCards.findIndex((c) => c.id === movedCard.id);
|
||||
if (movedIdx >= 0) targetCards.splice(movedIdx, 1);
|
||||
this.state.columns[sourceColIdx].cards.splice(
|
||||
cardOriginalIdx, 0, movedCard,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Same rollback on network error.
|
||||
this.notification.add(
|
||||
`Move failed: ${err.message || err}`,
|
||||
{ type: "danger" },
|
||||
);
|
||||
if (movedCard && sourceColIdx >= 0 && targetColIdx >= 0) {
|
||||
const targetCards = this.state.columns[targetColIdx].cards;
|
||||
const movedIdx = targetCards.findIndex((c) => c.id === movedCard.id);
|
||||
if (movedIdx >= 0) targetCards.splice(movedIdx, 1);
|
||||
this.state.columns[sourceColIdx].cards.splice(
|
||||
cardOriginalIdx, 0, movedCard,
|
||||
);
|
||||
}
|
||||
}
|
||||
this._draggedCard = null;
|
||||
}
|
||||
|
||||
// ----- Card actions ------------------------------------------------------
|
||||
@@ -308,6 +364,85 @@ export class PlantOverview extends Component {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// ------ Per-step timer (v19.0.24.5.0) ------------------------------------
|
||||
//
|
||||
// Computes the live "Running 47m" / "Paused 3h" / "Queued 12m" chip text
|
||||
// plus a tone (ok/warning/danger/muted) and a `critical` flag that the
|
||||
// template binds to a pulse animation. The `state.tickEpoch` reference
|
||||
// makes this getter reactive — it re-evaluates every 1 s.
|
||||
//
|
||||
// Thresholds chosen to mirror the existing battle-test rules:
|
||||
// - in_progress 1.0×–1.5× expected → warning, >1.5× → danger + pulse (S7)
|
||||
// - paused >8 h → danger, >24 h → danger + pulse (S10)
|
||||
// - queued >4 h → warning, >24 h → danger + pulse
|
||||
//
|
||||
// Returns an object with .label, .tone, .critical, .icon.
|
||||
getCardTimer(card) {
|
||||
// Reactive tick — never remove this read; OWL uses it to know
|
||||
// when to re-evaluate this getter.
|
||||
const _ = this.state.tickEpoch;
|
||||
const empty = { label: "", tone: "muted", critical: false, icon: "fa-clock-o" };
|
||||
if (!card.timer_kind || !card.timer_started_at_iso) return empty;
|
||||
|
||||
const isoUtc = card.timer_started_at_iso.replace(" ", "T") + "Z";
|
||||
const startMs = Date.parse(isoUtc);
|
||||
if (isNaN(startMs)) return empty;
|
||||
const sec = Math.max(0, Math.floor((Date.now() - startMs) / 1000));
|
||||
|
||||
const fmt = (s) => {
|
||||
if (s < 60) return s + "s";
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return m + "m";
|
||||
const h = Math.floor(m / 60);
|
||||
const rem = m % 60;
|
||||
if (h < 24) return rem ? `${h}h ${rem}m` : `${h}h`;
|
||||
const d = Math.floor(h / 24);
|
||||
const hr = h % 24;
|
||||
return hr ? `${d}d ${hr}h` : `${d}d`;
|
||||
};
|
||||
|
||||
if (card.timer_kind === "running") {
|
||||
const expSec = (card.timer_expected_minutes || 0) * 60;
|
||||
let tone = "ok";
|
||||
let critical = false;
|
||||
if (expSec) {
|
||||
if (sec > 1.5 * expSec) { tone = "danger"; critical = true; }
|
||||
else if (sec > expSec) { tone = "warning"; }
|
||||
}
|
||||
return {
|
||||
label: `Running ${fmt(sec)}` + (expSec ? ` / ${fmt(expSec)} planned` : ""),
|
||||
tone,
|
||||
critical,
|
||||
icon: "fa-play-circle",
|
||||
};
|
||||
}
|
||||
if (card.timer_kind === "paused") {
|
||||
let tone = "warning";
|
||||
let critical = false;
|
||||
if (sec > 24 * 3600) { tone = "danger"; critical = true; }
|
||||
else if (sec > 8 * 3600) { tone = "danger"; }
|
||||
return {
|
||||
label: `Paused ${fmt(sec)}`,
|
||||
tone,
|
||||
critical,
|
||||
icon: "fa-pause-circle",
|
||||
};
|
||||
}
|
||||
if (card.timer_kind === "queued") {
|
||||
let tone = "muted";
|
||||
let critical = false;
|
||||
if (sec > 24 * 3600) { tone = "danger"; critical = true; }
|
||||
else if (sec > 4 * 3600) { tone = "warning"; }
|
||||
return {
|
||||
label: `Queued ${fmt(sec)}`,
|
||||
tone,
|
||||
critical,
|
||||
icon: "fa-hourglass-half",
|
||||
};
|
||||
}
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("fp_plant_overview", PlantOverview);
|
||||
|
||||
@@ -388,6 +388,260 @@
|
||||
color: $fp-ink-mute;
|
||||
margin-bottom: $fp-space-2;
|
||||
}
|
||||
|
||||
// ---------- Urgency chip (v19.0.24.8.0) --------------------------------------
|
||||
// Always visible on every card; explains WHY it's at this sort position.
|
||||
// Tones map to existing semantic colors. Critical bands (hot/overdue/bake_risk)
|
||||
// pulse via the same `fp-timer-pulse` keyframes already shipped for timer chips.
|
||||
//
|
||||
// Light/dark mode: warning text branches at compile time on $o-webclient-color-scheme
|
||||
// (same pattern as the timer chip). Other tones rely on $fp-* tokens / --bs-*
|
||||
// CSS vars that flip automatically with the bundle.
|
||||
|
||||
$_fp-urg-warn-text-hex: #856404;
|
||||
$_fp-urg-warn-bg-alpha: 0.20;
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_fp-urg-warn-text-hex: #ffda6a !global;
|
||||
$_fp-urg-warn-bg-alpha: 0.28 !global;
|
||||
}
|
||||
|
||||
.o_fp_po_card_urgency {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $fp-space-1;
|
||||
margin: $fp-space-1 0 $fp-space-1;
|
||||
padding: 2px $fp-space-2;
|
||||
font-size: $fp-text-xs;
|
||||
font-weight: $fp-weight-bold;
|
||||
letter-spacing: 0.03em;
|
||||
border-radius: $fp-radius-pill;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.2;
|
||||
|
||||
i { font-size: 11px; }
|
||||
|
||||
// Tones (mirror timer chip)
|
||||
&.o_fp_po_urg_tone_muted {
|
||||
background: $fp-card-soft;
|
||||
color: $fp-ink-faint;
|
||||
font-weight: $fp-weight-medium;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
&.o_fp_po_urg_tone_info {
|
||||
background: rgba(13, 110, 253, 0.14);
|
||||
color: var(--bs-primary, #0d6efd);
|
||||
}
|
||||
&.o_fp_po_urg_tone_warning {
|
||||
background: rgba(255, 193, 7, $_fp-urg-warn-bg-alpha);
|
||||
color: $_fp-urg-warn-text-hex;
|
||||
}
|
||||
&.o_fp_po_urg_tone_danger {
|
||||
background: rgba(220, 53, 69, 0.16);
|
||||
color: var(--bs-danger, #c52131);
|
||||
}
|
||||
|
||||
// Pulse for critical (HOT / OVERDUE / BAKE / paused-stuck-24h)
|
||||
&.o_fp_po_urg_pulse {
|
||||
animation: fp-timer-pulse 1.4s $fp-ease-out infinite;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
border-radius: $fp-radius-pill;
|
||||
border: 2px solid currentColor;
|
||||
opacity: 0;
|
||||
animation: fp-timer-halo 1.6s $fp-ease-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HOT band gets the fattest treatment — solid red fill, white text.
|
||||
// Overrides the danger tone above so this band can't fade into the
|
||||
// other danger chips.
|
||||
.o_fp_po_card_urgency.o_fp_po_urg_hot {
|
||||
background: var(--bs-danger, #c52131);
|
||||
color: #fff;
|
||||
box-shadow: 0 1px 4px rgba(220, 53, 69, 0.35);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.o_fp_po_urg_pulse {
|
||||
animation: none;
|
||||
&::after { animation: none; opacity: 0.45; }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Card part / coating lines (v19.0.24.6.0) -------------------------
|
||||
// Replaces the always-identical "[FP-SERVICE] Plating Service" line with the
|
||||
// part number + coating spec the operator actually cares about. Both lines
|
||||
// rely on $fp-ink / $fp-ink-mute tokens so they flip cleanly between the
|
||||
// light and dark bundles — no hard-coded hex.
|
||||
.o_fp_po_card_part {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $fp-space-1;
|
||||
font-size: $fp-text-sm;
|
||||
font-weight: $fp-weight-semibold;
|
||||
color: $fp-ink;
|
||||
margin-bottom: 2px;
|
||||
line-height: 1.3;
|
||||
|
||||
.o_fp_po_card_part_icon {
|
||||
font-size: 11px;
|
||||
color: $fp-ink-mute;
|
||||
}
|
||||
.o_fp_po_card_part_rev {
|
||||
font-weight: $fp-weight-medium;
|
||||
font-size: $fp-text-xs;
|
||||
color: $fp-ink-mute;
|
||||
margin-left: $fp-space-1;
|
||||
}
|
||||
}
|
||||
.o_fp_po_card_coating {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $fp-space-1;
|
||||
font-size: $fp-text-xs;
|
||||
color: $fp-ink-soft;
|
||||
margin-bottom: $fp-space-2;
|
||||
line-height: 1.3;
|
||||
|
||||
.o_fp_po_card_coating_icon {
|
||||
font-size: 10px;
|
||||
color: $fp-ink-faint;
|
||||
}
|
||||
}
|
||||
.o_fp_po_card_no_part {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $fp-space-1;
|
||||
font-style: italic;
|
||||
color: $fp-ink-faint;
|
||||
margin-bottom: $fp-space-2;
|
||||
}
|
||||
|
||||
// Step-ordinal badge — separator + total in mute tone (1-based "4/9").
|
||||
.o_fp_po_card_step_total {
|
||||
font-weight: $fp-weight-medium;
|
||||
color: $fp-ink-faint;
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
// ---------- Per-step timer chip (v19.0.24.5.0) -------------------------------
|
||||
// Live-ticking elapsed-in-stage label. JS getCardTimer() picks the tone
|
||||
// (muted/ok/warning/danger) and a `critical` flag that toggles the pulse
|
||||
// animation. Critical = step is overrun (>1.5× expected), paused >24h, or
|
||||
// queued >24h — any of those conditions need supervisor attention NOW.
|
||||
//
|
||||
// Light/dark mode: warning text needs different hex per bundle so it
|
||||
// stays legible against the translucent yellow tint. Other tones use
|
||||
// $fp-* tokens or --bs-* CSS vars which Odoo flips automatically.
|
||||
|
||||
$_fp-timer-warn-text-hex: #856404; // dark brown — readable on light card
|
||||
$_fp-timer-warn-bg-alpha: 0.20;
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_fp-timer-warn-text-hex: #ffda6a !global; // light yellow on dark card
|
||||
$_fp-timer-warn-bg-alpha: 0.28 !global; // a touch more saturation
|
||||
}
|
||||
|
||||
.o_fp_po_card_timer {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $fp-space-1;
|
||||
margin: $fp-space-1 0 $fp-space-2;
|
||||
padding: 2px $fp-space-2;
|
||||
font-size: $fp-text-xs;
|
||||
font-weight: $fp-weight-semibold;
|
||||
border-radius: $fp-radius-pill;
|
||||
|
||||
i { font-size: 11px; }
|
||||
|
||||
// Tones — backgrounds use rgba() with a low alpha so the underlying
|
||||
// card surface tints through; text uses the strong hue.
|
||||
&.o_fp_po_timer_muted {
|
||||
background: $fp-card-soft;
|
||||
color: $fp-ink-mute;
|
||||
}
|
||||
&.o_fp_po_timer_ok {
|
||||
background: rgba(25, 135, 84, 0.14);
|
||||
color: var(--bs-success, #198754);
|
||||
}
|
||||
&.o_fp_po_timer_warning {
|
||||
background: rgba(255, 193, 7, $_fp-timer-warn-bg-alpha);
|
||||
color: $_fp-timer-warn-text-hex;
|
||||
}
|
||||
&.o_fp_po_timer_danger {
|
||||
background: rgba(220, 53, 69, 0.16);
|
||||
color: var(--bs-danger, #c52131);
|
||||
}
|
||||
|
||||
// Critical attention-grabber. Two layers of motion so it's hard to
|
||||
// ignore: (a) the chip itself pulses scale+glow, (b) a soft halo
|
||||
// expands behind it like a sonar ping. Honours prefers-reduced-motion.
|
||||
&.o_fp_po_timer_critical {
|
||||
animation: fp-timer-pulse 1.4s $fp-ease-out infinite;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
border-radius: $fp-radius-pill;
|
||||
border: 2px solid var(--bs-danger, #c52131);
|
||||
opacity: 0;
|
||||
animation: fp-timer-halo 1.6s $fp-ease-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Critical card halo — when ANY card carries a critical timer, give the
|
||||
// whole card a subtle red border-glow so the supervisor can spot which
|
||||
// card is the problem from across the room without scanning every chip.
|
||||
.o_fp_po_card:has(.o_fp_po_timer_critical) {
|
||||
box-shadow: $fp-elev-2,
|
||||
0 0 0 2px rgba(220, 53, 69, 0.55),
|
||||
0 0 18px rgba(220, 53, 69, 0.22);
|
||||
animation: fp-card-attention 2.2s $fp-ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes fp-timer-pulse {
|
||||
0%, 100% { transform: scale(1.0); }
|
||||
50% { transform: scale(1.06); }
|
||||
}
|
||||
|
||||
@keyframes fp-timer-halo {
|
||||
0% { transform: scale(0.92); opacity: 0.0; }
|
||||
35% { transform: scale(1.05); opacity: 0.55; }
|
||||
100% { transform: scale(1.30); opacity: 0.0; }
|
||||
}
|
||||
|
||||
@keyframes fp-card-attention {
|
||||
0%, 100% {
|
||||
box-shadow: $fp-elev-2,
|
||||
0 0 0 2px rgba(220, 53, 69, 0.55),
|
||||
0 0 14px rgba(220, 53, 69, 0.18);
|
||||
}
|
||||
50% {
|
||||
box-shadow: $fp-elev-2,
|
||||
0 0 0 2px rgba(220, 53, 69, 0.85),
|
||||
0 0 28px rgba(220, 53, 69, 0.42);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.o_fp_po_timer_critical {
|
||||
animation: none;
|
||||
&::after { animation: none; opacity: 0.45; }
|
||||
}
|
||||
.o_fp_po_card:has(.o_fp_po_timer_critical) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
.o_fp_po_card_footer {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-top: $fp-space-2;
|
||||
|
||||
@@ -106,19 +106,61 @@
|
||||
<div class="o_fp_po_card_title">
|
||||
<strong t-esc="card.customer_name || 'Walk-In'"/>
|
||||
</div>
|
||||
<span class="o_fp_po_card_step_badge" t-if="card.step_number">
|
||||
<t t-esc="card.step_number"/>
|
||||
<!-- 1-based step ordinal: "4/9" -->
|
||||
<!-- not "40" (v19.0.24.6.0) -->
|
||||
<span class="o_fp_po_card_step_badge"
|
||||
t-if="card.step_index">
|
||||
<t t-esc="card.step_index"/>
|
||||
<span class="o_fp_po_card_step_total"
|
||||
t-if="card.job_step_count">
|
||||
/<t t-esc="card.job_step_count"/>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Urgency chip (v19.0.24.8.0) — always -->
|
||||
<!-- visible. Explains WHY the card is at -->
|
||||
<!-- this position in the sort. Critical -->
|
||||
<!-- bands (HOT, OVERDUE, MISSED BAKE) -->
|
||||
<!-- pulse to grab attention. -->
|
||||
<div t-if="card.urgency_band"
|
||||
t-att-class="'o_fp_po_card_urgency o_fp_po_urg_' + card.urgency_band + ' o_fp_po_urg_tone_' + card.urgency_tone + (card.urgency_pulse ? ' o_fp_po_urg_pulse' : '')"
|
||||
t-att-title="'Urgency score: ' + card.urgency_score">
|
||||
<i t-att-class="'fa ' + card.urgency_icon"/>
|
||||
<span class="o_fp_po_card_urgency_label"
|
||||
t-esc="card.urgency_label"/>
|
||||
</div>
|
||||
|
||||
<!-- SO / WO refs + product name -->
|
||||
<div class="o_fp_po_card_refs">
|
||||
<span t-if="card.so_name" t-esc="card.so_name"/>
|
||||
<span t-if="card.so_name and card.wo_name"> | </span>
|
||||
<span t-if="card.wo_name" t-esc="card.wo_name"/>
|
||||
</div>
|
||||
<div class="o_fp_po_card_product text-muted small" t-if="card.product_name">
|
||||
<t t-esc="card.product_name"/>
|
||||
<!-- Useful per-card detail (v19.0.24.6.0). -->
|
||||
<!-- Line 1: part number + revision (what -->
|
||||
<!-- the operator is holding). Line 2: -->
|
||||
<!-- coating spec (what process they're -->
|
||||
<!-- running). Falls back to product name -->
|
||||
<!-- only if neither is set (legacy data). -->
|
||||
<div class="o_fp_po_card_part"
|
||||
t-if="card.part_number">
|
||||
<i class="fa fa-tag o_fp_po_card_part_icon"/>
|
||||
<strong t-esc="card.part_number"/>
|
||||
<span class="o_fp_po_card_part_rev"
|
||||
t-if="card.part_revision">
|
||||
rev <t t-esc="card.part_revision"/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="o_fp_po_card_coating"
|
||||
t-if="card.coating_label">
|
||||
<i class="fa fa-flask o_fp_po_card_coating_icon"/>
|
||||
<t t-esc="card.coating_label"/>
|
||||
</div>
|
||||
<div class="o_fp_po_card_no_part text-muted small"
|
||||
t-if="!card.part_number and !card.coating_label">
|
||||
<i class="fa fa-question-circle"/>
|
||||
No part / coating set on job
|
||||
</div>
|
||||
|
||||
<!-- Parts progress -->
|
||||
@@ -138,6 +180,18 @@
|
||||
<t t-esc="card.step_display"/>
|
||||
</div>
|
||||
|
||||
<!-- Per-step timer (v19.0.24.5.0). -->
|
||||
<!-- Live-ticking elapsed in this stage, -->
|
||||
<!-- color-coded by tone, with a critical -->
|
||||
<!-- pulse animation on overrun / stuck. -->
|
||||
<t t-set="t" t-value="getCardTimer(card)"/>
|
||||
<div t-if="t.label"
|
||||
t-att-class="'o_fp_po_card_timer o_fp_po_timer_' + t.tone + (t.critical ? ' o_fp_po_timer_critical' : '')"
|
||||
t-att-title="card.timer_kind === 'running' ? 'Time in this step' : (card.timer_kind === 'paused' ? 'Time since paused' : 'Time queued in this stage')">
|
||||
<i t-att-class="'fa ' + t.icon"/>
|
||||
<span class="o_fp_po_timer_label" t-esc="t.label"/>
|
||||
</div>
|
||||
|
||||
<!-- Last activity -->
|
||||
<div class="o_fp_po_card_last text-muted"
|
||||
t-if="card.last_operator">
|
||||
|
||||
Reference in New Issue
Block a user