Compare commits
12 Commits
phase6_1-p
...
phase6_3-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27e12dd544 | ||
|
|
5f03080374 | ||
|
|
efaf16dffb | ||
|
|
e4000374ca | ||
|
|
fee4219703 | ||
|
|
6ca9a58a8c | ||
|
|
d86c120969 | ||
|
|
85609f99cd | ||
|
|
29821bd541 | ||
|
|
1fdafd34d1 | ||
|
|
9584953467 | ||
|
|
52097ca59b |
@@ -166,6 +166,19 @@ These modules have **source code in this repo** but are **intentionally NOT inst
|
||||
| `fusion_plating_culture` | `state=uninstalled`, dir removed from entech disk | Soft people-ops feature (peer kudos / "Fundamental of the Week"); zero data entered; not a client priority. Top-level "Culture" menu confused operators. | Ask the client whether they want it before reinstalling. If yes: re-sync folder + `-i fusion_plating_culture` + seed a value set. |
|
||||
| `fusion_plating_sensors` | deleted entirely (not in repo anymore) | Duplicated `fusion_plating_iot`'s scope but with no working alerting logic. Its valuables (sensor_type taxonomy, dashboard, location flexibility) were ported into `fusion_iot/fusion_plating_iot/`. | N/A — gone. Any new sensor work goes in `fusion_iot/fusion_plating_iot/`. |
|
||||
|
||||
## Shop-floor action endpoints — credit the correct tech via `tablet_tech_id`
|
||||
The tablet sits on a long-lived "shopfloor service" Odoo session shared by many techs. The actual tech-of-record is established via the PIN unlock (Phase 6); their id lives in the OWL `fp_shopfloor_tech_store` service and is sent as `tablet_tech_id` on every action RPC.
|
||||
|
||||
When writing a NEW shop-floor controller endpoint that **writes** (creates a record, calls a `button_*` method, posts to chatter):
|
||||
1. Add `tablet_tech_id=None` as a kwarg on the route handler.
|
||||
2. At the top, call: `env = env_for_tablet_tech(request.env, tablet_tech_id)` (from `fusion_plating_shopfloor/controllers/_tablet_audit.py`).
|
||||
3. Use `env` (not `request.env`) for all subsequent writes. `env.with_user(...)` is applied internally so `create_uid` / `write_uid` / chatter authorship carry the right uid.
|
||||
4. Read-only endpoints (load / kanban / funnel / overview) don't need this — leave them as `request.env`.
|
||||
|
||||
On the client side: use `fpRpc()` from `services/fp_rpc.js` (drop-in for `rpc()`) for action calls. It auto-injects `tablet_tech_id`. Read calls can keep using plain `rpc()`.
|
||||
|
||||
If `tablet_tech_id` is missing or invalid, `env_for_tablet_tech` falls back to the session uid — old callers and pre-Phase-6.3 endpoints continue working.
|
||||
|
||||
## Removing menus/records — Odoo does NOT auto-delete orphans
|
||||
Deleting a `<menuitem>` (or any `<record>`) from a data XML file does NOT remove the corresponding database row. The XML loader only updates records it sees; orphans persist in `ir.ui.menu` / `ir.model.data` until you delete them explicitly. Symptom: the menu still appears in the UI after `-u`. Fix — add a `<delete>` directive in a data file with `noupdate="0"`:
|
||||
```xml
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Shop Floor',
|
||||
'version': '19.0.30.0.0',
|
||||
'version': '19.0.30.2.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
|
||||
'first-piece inspection gates.',
|
||||
@@ -82,6 +82,24 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
'fusion_plating_shopfloor/static/src/scss/components/_kanban_card.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/components/kanban_card.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/components/kanban_card.js',
|
||||
# ---- Phase 6.2 tablet PIN gate ----
|
||||
'fusion_plating_shopfloor/static/src/js/services/tech_store.js',
|
||||
'fusion_plating_shopfloor/static/src/js/services/activity_tracker.js',
|
||||
# Phase 6.3 — fpRpc wrapper. MUST load before any consumer
|
||||
# (job_workspace, shopfloor_landing, manager_dashboard,
|
||||
# hold_composer) so `import { fpRpc }` resolves.
|
||||
'fusion_plating_shopfloor/static/src/js/services/fp_rpc.js',
|
||||
'fusion_plating_shopfloor/static/src/scss/components/_pin_pad.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/components/pin_pad.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/components/pin_pad.js',
|
||||
'fusion_plating_shopfloor/static/src/scss/components/_idle_warning.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/components/idle_warning.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/components/idle_warning.js',
|
||||
'fusion_plating_shopfloor/static/src/scss/tablet_lock.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/tablet_lock.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/tablet_lock.js',
|
||||
'fusion_plating_shopfloor/static/src/xml/components/pin_setup.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/components/pin_setup.js',
|
||||
# ---- Job Workspace (Phase 1 — tablet redesign) ----
|
||||
'fusion_plating_shopfloor/static/src/scss/job_workspace.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/job_workspace.xml',
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Helper for audit-credit propagation (Phase 6.3 tablet redesign).
|
||||
|
||||
Controllers that accept an optional `tablet_tech_id` kwarg use this
|
||||
helper to switch their `env` to the tech-of-record before performing
|
||||
writes. The result: chatter posts + create_uid/write_uid carry the
|
||||
unlocked tech's identity, not the tablet's persistent session user.
|
||||
"""
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def env_for_tablet_tech(env, tablet_tech_id):
|
||||
"""Return an env scoped to `tablet_tech_id` if it's a valid user;
|
||||
otherwise return the original env unchanged.
|
||||
|
||||
Validation: the user must exist and be active. We deliberately do
|
||||
NOT cross-check that they actually unlocked recently — the OWL
|
||||
component is the source of truth for "who's at the tablet right
|
||||
now", and the only path that produces a tablet_tech_id is a
|
||||
successful /fp/tablet/unlock followed by an active session in the
|
||||
OWL tech_store.
|
||||
"""
|
||||
if not tablet_tech_id:
|
||||
return env
|
||||
try:
|
||||
tech_id = int(tablet_tech_id)
|
||||
except (TypeError, ValueError):
|
||||
return env
|
||||
User = env['res.users'].sudo()
|
||||
tech = User.browse(tech_id)
|
||||
if not tech.exists() or not tech.active:
|
||||
_logger.warning(
|
||||
"tablet_tech_id %s invalid (not found or inactive); "
|
||||
"falling back to session uid %s",
|
||||
tablet_tech_id, env.uid,
|
||||
)
|
||||
return env
|
||||
return env(user=tech_id)
|
||||
@@ -25,6 +25,8 @@ from odoo import fields, http
|
||||
from odoo.addons.fusion_plating.models.fp_tz import fp_format
|
||||
from odoo.http import request
|
||||
|
||||
from ._tablet_audit import env_for_tablet_tech
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -387,7 +389,8 @@ class FpManagerDashboardController(http.Controller):
|
||||
# Assign a worker to a step
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/fp/manager/assign_worker', type='jsonrpc', auth='user')
|
||||
def assign_worker(self, step_id=None, user_id=None, workorder_id=None, **kwargs):
|
||||
def assign_worker(self, step_id=None, user_id=None, workorder_id=None,
|
||||
tablet_tech_id=None, **kwargs):
|
||||
"""Assign an operator to a step. ``step_id`` is the canonical
|
||||
kwarg; ``workorder_id`` is accepted as a deprecated alias for
|
||||
one release so any caller we missed doesn't break.
|
||||
@@ -400,7 +403,8 @@ class FpManagerDashboardController(http.Controller):
|
||||
step_id = workorder_id
|
||||
if not step_id:
|
||||
return {'ok': False, 'error': 'step_id required'}
|
||||
step = request.env['fp.job.step'].browse(int(step_id))
|
||||
env = env_for_tablet_tech(request.env, tablet_tech_id)
|
||||
step = env['fp.job.step'].browse(int(step_id))
|
||||
if not step.exists():
|
||||
return {'ok': False, 'error': 'Step not found.'}
|
||||
step.assigned_user_id = int(user_id) if user_id else False
|
||||
@@ -415,7 +419,8 @@ class FpManagerDashboardController(http.Controller):
|
||||
# Reassign or swap tank on a step
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/fp/manager/assign_tank', type='jsonrpc', auth='user')
|
||||
def assign_tank(self, step_id=None, tank_id=None, workorder_id=None, **kwargs):
|
||||
def assign_tank(self, step_id=None, tank_id=None, workorder_id=None,
|
||||
tablet_tech_id=None, **kwargs):
|
||||
"""Swap the tank on a step. ``step_id`` is the canonical kwarg;
|
||||
``workorder_id`` is accepted as a deprecated alias.
|
||||
"""
|
||||
@@ -427,7 +432,8 @@ class FpManagerDashboardController(http.Controller):
|
||||
step_id = workorder_id
|
||||
if not step_id:
|
||||
return {'ok': False, 'error': 'step_id required'}
|
||||
step = request.env['fp.job.step'].browse(int(step_id))
|
||||
env = env_for_tablet_tech(request.env, tablet_tech_id)
|
||||
step = env['fp.job.step'].browse(int(step_id))
|
||||
if not step.exists():
|
||||
return {'ok': False, 'error': 'Step not found.'}
|
||||
step.tank_id = int(tank_id) if tank_id else False
|
||||
@@ -442,7 +448,8 @@ class FpManagerDashboardController(http.Controller):
|
||||
# Manager takes over a step (no-show coverage)
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/fp/manager/take_over', type='jsonrpc', auth='user')
|
||||
def take_over(self, step_id=None, workorder_id=None, **kwargs):
|
||||
def take_over(self, step_id=None, workorder_id=None,
|
||||
tablet_tech_id=None, **kwargs):
|
||||
"""Manager takes over a step. ``step_id`` is the canonical kwarg;
|
||||
``workorder_id`` is accepted as a deprecated alias.
|
||||
"""
|
||||
@@ -454,10 +461,11 @@ class FpManagerDashboardController(http.Controller):
|
||||
step_id = workorder_id
|
||||
if not step_id:
|
||||
return {'ok': False, 'error': 'step_id required'}
|
||||
step = request.env['fp.job.step'].browse(int(step_id))
|
||||
env = env_for_tablet_tech(request.env, tablet_tech_id)
|
||||
step = env['fp.job.step'].browse(int(step_id))
|
||||
if not step.exists():
|
||||
return {'ok': False, 'error': 'Step not found.'}
|
||||
user = request.env.user
|
||||
user = env.user
|
||||
previous = step.assigned_user_id.name or '—'
|
||||
step.assigned_user_id = user.id
|
||||
step.message_post(
|
||||
|
||||
@@ -20,6 +20,8 @@ from odoo.addons.fusion_plating.models.fp_tz import (
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.http import request
|
||||
|
||||
from ._tablet_audit import env_for_tablet_tech
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -255,11 +257,13 @@ class FpShopfloorController(http.Controller):
|
||||
# Quick chemistry log from the tablet
|
||||
# ----------------------------------------------------------------------
|
||||
@http.route('/fp/shopfloor/log_chemistry', type='jsonrpc', auth='user')
|
||||
def log_chemistry(self, bath_id, readings, shift=None, notes=None):
|
||||
def log_chemistry(self, bath_id, readings, shift=None, notes=None,
|
||||
tablet_tech_id=None):
|
||||
"""Create a fusion.plating.bath.log with one line per reading."""
|
||||
env = env_for_tablet_tech(request.env, tablet_tech_id)
|
||||
if not bath_id:
|
||||
raise UserError("bath_id required")
|
||||
bath = request.env['fusion.plating.bath'].browse(int(bath_id))
|
||||
bath = env['fusion.plating.bath'].browse(int(bath_id))
|
||||
if not bath.exists():
|
||||
raise UserError(f"Bath {bath_id} not found")
|
||||
|
||||
@@ -274,7 +278,7 @@ class FpShopfloorController(http.Controller):
|
||||
'value': float(value) if value not in (None, '') else 0.0,
|
||||
}))
|
||||
|
||||
log = request.env['fusion.plating.bath.log'].create({
|
||||
log = env['fusion.plating.bath.log'].create({
|
||||
'bath_id': bath.id,
|
||||
'shift': shift or False,
|
||||
'notes': notes or False,
|
||||
@@ -291,10 +295,11 @@ class FpShopfloorController(http.Controller):
|
||||
# Bake window controls
|
||||
# ----------------------------------------------------------------------
|
||||
@http.route('/fp/shopfloor/start_bake', type='jsonrpc', auth='user')
|
||||
def start_bake(self, bake_window_id, oven_id=None):
|
||||
def start_bake(self, bake_window_id, oven_id=None, tablet_tech_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))
|
||||
env = env_for_tablet_tech(request.env, tablet_tech_id)
|
||||
bw = env['fusion.plating.bake.window'].browse(int(bake_window_id))
|
||||
if not bw.exists():
|
||||
return {'ok': False, 'error': f'Bake window {bake_window_id} not found'}
|
||||
if oven_id:
|
||||
@@ -306,12 +311,13 @@ class FpShopfloorController(http.Controller):
|
||||
return {
|
||||
'ok': True,
|
||||
'state': bw.state,
|
||||
'bake_start_time': fp_format(request.env, bw.bake_start_time),
|
||||
'bake_start_time': fp_format(env, bw.bake_start_time),
|
||||
}
|
||||
|
||||
@http.route('/fp/shopfloor/end_bake', type='jsonrpc', auth='user')
|
||||
def end_bake(self, bake_window_id):
|
||||
bw = request.env['fusion.plating.bake.window'].browse(int(bake_window_id))
|
||||
def end_bake(self, bake_window_id, tablet_tech_id=None):
|
||||
env = env_for_tablet_tech(request.env, tablet_tech_id)
|
||||
bw = env['fusion.plating.bake.window'].browse(int(bake_window_id))
|
||||
if not bw.exists():
|
||||
return {'ok': False, 'error': f'Bake window {bake_window_id} not found'}
|
||||
try:
|
||||
@@ -321,7 +327,7 @@ class FpShopfloorController(http.Controller):
|
||||
return {
|
||||
'ok': True,
|
||||
'state': bw.state,
|
||||
'bake_end_time': fp_format(request.env, bw.bake_end_time),
|
||||
'bake_end_time': fp_format(env, bw.bake_end_time),
|
||||
'bake_duration_hours': bw.bake_duration_hours,
|
||||
}
|
||||
|
||||
@@ -340,8 +346,15 @@ class FpShopfloorController(http.Controller):
|
||||
step = request.env['fp.job.step'].browse(int(sid))
|
||||
return step if step.exists() else False
|
||||
|
||||
def _resolve_step_in_env(self, env, step_id=None, workorder_id=None):
|
||||
sid = step_id if step_id else workorder_id
|
||||
if not sid:
|
||||
return False
|
||||
step = env['fp.job.step'].browse(int(sid))
|
||||
return step if step.exists() else False
|
||||
|
||||
@http.route('/fp/shopfloor/start_wo', type='jsonrpc', auth='user')
|
||||
def start_wo(self, workorder_id=None, step_id=None):
|
||||
def start_wo(self, workorder_id=None, step_id=None, tablet_tech_id=None):
|
||||
"""Start the timer on a fp.job.step (called from the tablet).
|
||||
|
||||
button_start() can raise UserError for any guarded condition
|
||||
@@ -350,7 +363,8 @@ class FpShopfloorController(http.Controller):
|
||||
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)
|
||||
env = env_for_tablet_tech(request.env, tablet_tech_id)
|
||||
step = self._resolve_step_in_env(env, step_id, workorder_id)
|
||||
if not step:
|
||||
return {'ok': False, 'error': 'Step not found'}
|
||||
if not _step_can_start(step):
|
||||
@@ -369,7 +383,8 @@ class FpShopfloorController(http.Controller):
|
||||
}
|
||||
|
||||
@http.route('/fp/shopfloor/stop_wo', type='jsonrpc', auth='user')
|
||||
def stop_wo(self, workorder_id=None, step_id=None, finish=False):
|
||||
def stop_wo(self, workorder_id=None, step_id=None, finish=False,
|
||||
tablet_tech_id=None):
|
||||
"""Finish the timer on a fp.job.step.
|
||||
|
||||
finish=True calls button_finish(); other values are no-ops for
|
||||
@@ -380,7 +395,8 @@ class FpShopfloorController(http.Controller):
|
||||
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)
|
||||
env = env_for_tablet_tech(request.env, tablet_tech_id)
|
||||
step = self._resolve_step_in_env(env, step_id, workorder_id)
|
||||
if not step:
|
||||
return {'ok': False, 'error': 'Step not found'}
|
||||
if finish:
|
||||
@@ -409,11 +425,12 @@ class FpShopfloorController(http.Controller):
|
||||
# both with a single tap. Scrap auto-spawns a hold via fp.job.write
|
||||
# (S17 hook, no extra wiring needed here).
|
||||
@http.route('/fp/shopfloor/bump_qty_done', type='jsonrpc', auth='user')
|
||||
def bump_qty_done(self, job_id, delta=1):
|
||||
def bump_qty_done(self, job_id, delta=1, tablet_tech_id=None):
|
||||
"""Increment job.qty_done by `delta` (defaults to +1).
|
||||
Returns the new totals so the tablet can update without a full refresh.
|
||||
"""
|
||||
job = request.env['fp.job'].browse(int(job_id))
|
||||
env = env_for_tablet_tech(request.env, tablet_tech_id)
|
||||
job = env['fp.job'].browse(int(job_id))
|
||||
if not job.exists():
|
||||
return {'ok': False, 'error': 'Job not found'}
|
||||
try:
|
||||
@@ -433,13 +450,15 @@ class FpShopfloorController(http.Controller):
|
||||
}
|
||||
|
||||
@http.route('/fp/shopfloor/bump_qty_scrapped', type='jsonrpc', auth='user')
|
||||
def bump_qty_scrapped(self, job_id, delta=1, reason=None):
|
||||
def bump_qty_scrapped(self, job_id, delta=1, reason=None,
|
||||
tablet_tech_id=None):
|
||||
"""Increment job.qty_scrapped by `delta`. The S17 write-hook on
|
||||
fp.job auto-spawns a fusion.plating.quality.hold for the delta;
|
||||
the operator can edit the description on that hold later.
|
||||
`reason` is optional — passed through to the hold's description.
|
||||
"""
|
||||
job = request.env['fp.job'].browse(int(job_id))
|
||||
env = env_for_tablet_tech(request.env, tablet_tech_id)
|
||||
job = env['fp.job'].browse(int(job_id))
|
||||
if not job.exists():
|
||||
return {'ok': False, 'error': 'Job not found'}
|
||||
try:
|
||||
@@ -470,20 +489,22 @@ class FpShopfloorController(http.Controller):
|
||||
position_label=None, reading_number=None,
|
||||
equipment_model=None, calibration_std_ref=None,
|
||||
microscope_image=None,
|
||||
microscope_image_filename=None):
|
||||
microscope_image_filename=None,
|
||||
tablet_tech_id=None):
|
||||
"""Record a single Fischerscope reading against a job.
|
||||
|
||||
`job_id` is the canonical kwarg; `production_id` is accepted as an
|
||||
alias for older clients. The reading auto-links to an existing
|
||||
CoC certificate for the job when one exists.
|
||||
"""
|
||||
Reading = request.env.get('fp.thickness.reading')
|
||||
env = env_for_tablet_tech(request.env, tablet_tech_id)
|
||||
Reading = env.get('fp.thickness.reading')
|
||||
if Reading is None:
|
||||
return {'ok': False, 'error': 'Certificates module not installed'}
|
||||
target_id = job_id or production_id
|
||||
if not target_id:
|
||||
return {'ok': False, 'error': 'job_id required'}
|
||||
job = request.env['fp.job'].browse(int(target_id))
|
||||
job = env['fp.job'].browse(int(target_id))
|
||||
if not job.exists():
|
||||
return {'ok': False, 'error': f'Job {target_id} not found'}
|
||||
|
||||
@@ -508,7 +529,7 @@ class FpShopfloorController(http.Controller):
|
||||
'ni_percent': float(ni_percent or 0.0),
|
||||
'p_percent': float(p_percent or 0.0),
|
||||
'position_label': position_label or '',
|
||||
'operator_id': request.env.user.id,
|
||||
'operator_id': env.user.id,
|
||||
}
|
||||
|
||||
if equipment_model:
|
||||
@@ -516,7 +537,7 @@ class FpShopfloorController(http.Controller):
|
||||
if calibration_std_ref:
|
||||
vals['calibration_std_ref'] = calibration_std_ref
|
||||
if microscope_image:
|
||||
att = request.env['ir.attachment'].create({
|
||||
att = env['ir.attachment'].create({
|
||||
'name': microscope_image_filename or f'thickness_{reading_number}.jpg',
|
||||
'datas': microscope_image,
|
||||
'res_model': 'fp.thickness.reading',
|
||||
@@ -525,7 +546,7 @@ class FpShopfloorController(http.Controller):
|
||||
vals['microscope_image_id'] = att.id
|
||||
|
||||
# Auto-link to an existing CoC if there is one for this job.
|
||||
Cert = request.env.get('fp.certificate')
|
||||
Cert = env.get('fp.certificate')
|
||||
if Cert is not None:
|
||||
if 'x_fc_job_id' in Cert._fields:
|
||||
cert_field = 'x_fc_job_id'
|
||||
@@ -557,7 +578,8 @@ class FpShopfloorController(http.Controller):
|
||||
part_ref=None, qty_on_hold=0, qty_original=0,
|
||||
hold_reason='other', description=None,
|
||||
mark_for_scrap=False, facility_id=None,
|
||||
work_center_id=None, current_process_node=None):
|
||||
work_center_id=None, current_process_node=None,
|
||||
tablet_tech_id=None):
|
||||
"""Create a quality hold record, splitting qty from the original lot.
|
||||
|
||||
The hold is linked to the fp.job and (when provided) the
|
||||
@@ -566,7 +588,8 @@ class FpShopfloorController(http.Controller):
|
||||
if not qty_on_hold or int(qty_on_hold) <= 0:
|
||||
raise UserError("qty_on_hold must be a positive integer.")
|
||||
|
||||
Hold = request.env['fusion.plating.quality.hold']
|
||||
env = env_for_tablet_tech(request.env, tablet_tech_id)
|
||||
Hold = env['fusion.plating.quality.hold']
|
||||
|
||||
vals = {
|
||||
'part_ref': part_ref or '',
|
||||
@@ -583,7 +606,7 @@ class FpShopfloorController(http.Controller):
|
||||
if work_center_id:
|
||||
vals['work_center_id'] = int(work_center_id)
|
||||
if portal_job_id:
|
||||
pj = request.env['fusion.plating.portal.job'].browse(
|
||||
pj = env['fusion.plating.portal.job'].browse(
|
||||
int(portal_job_id),
|
||||
)
|
||||
if pj.exists():
|
||||
@@ -594,7 +617,7 @@ class FpShopfloorController(http.Controller):
|
||||
# via fusion_plating_jobs (Phase 3) as `x_fc_job_id` / `x_fc_step_id`.
|
||||
step_target_id = step_id or workorder_id
|
||||
if step_target_id:
|
||||
step = request.env['fp.job.step'].browse(int(step_target_id))
|
||||
step = env['fp.job.step'].browse(int(step_target_id))
|
||||
if step.exists():
|
||||
if 'x_fc_step_id' in Hold._fields:
|
||||
vals['x_fc_step_id'] = step.id
|
||||
@@ -605,7 +628,7 @@ class FpShopfloorController(http.Controller):
|
||||
# set it through the step.
|
||||
if (job_id and 'x_fc_job_id' in Hold._fields
|
||||
and not vals.get('x_fc_job_id')):
|
||||
j = request.env['fp.job'].browse(int(job_id))
|
||||
j = env['fp.job'].browse(int(job_id))
|
||||
if j.exists():
|
||||
vals['x_fc_job_id'] = j.id
|
||||
|
||||
@@ -995,8 +1018,9 @@ class FpShopfloorController(http.Controller):
|
||||
# Mark a first-piece gate result from the tablet
|
||||
# ----------------------------------------------------------------------
|
||||
@http.route('/fp/shopfloor/mark_gate', type='jsonrpc', auth='user')
|
||||
def mark_gate(self, gate_id, result):
|
||||
gate = request.env['fusion.plating.first.piece.gate'].browse(int(gate_id))
|
||||
def mark_gate(self, gate_id, result, tablet_tech_id=None):
|
||||
env = env_for_tablet_tech(request.env, tablet_tech_id)
|
||||
gate = env['fusion.plating.first.piece.gate'].browse(int(gate_id))
|
||||
if not gate.exists():
|
||||
return {'ok': False, 'error': 'Gate not found.'}
|
||||
try:
|
||||
@@ -1084,21 +1108,23 @@ class FpShopfloorController(http.Controller):
|
||||
@http.route('/fp/shopfloor/plant_overview/move_card',
|
||||
type='jsonrpc', auth='user')
|
||||
def plant_overview_move_card(self, card_id, source_model=None,
|
||||
target_workcenter_id=None):
|
||||
target_workcenter_id=None,
|
||||
tablet_tech_id=None):
|
||||
"""Move a step card to a different work centre (drag & drop).
|
||||
|
||||
`source_model` is accepted for backward compatibility but ignored —
|
||||
Plant Overview now only ever serves fp.job.step cards. A target
|
||||
of 0 / falsy clears the work centre.
|
||||
"""
|
||||
Step = request.env['fp.job.step']
|
||||
env = env_for_tablet_tech(request.env, tablet_tech_id)
|
||||
Step = env['fp.job.step']
|
||||
step = Step.browse(int(card_id))
|
||||
if not step.exists():
|
||||
return {'ok': False, 'error': f'Step {card_id} not found.'}
|
||||
|
||||
wc_id = int(target_workcenter_id) if target_workcenter_id else False
|
||||
if wc_id:
|
||||
wc = request.env['fp.work.centre'].browse(wc_id)
|
||||
wc = env['fp.work.centre'].browse(wc_id)
|
||||
if not wc.exists():
|
||||
return {'ok': False,
|
||||
'error': f'Work centre {target_workcenter_id} not found.'}
|
||||
@@ -1108,7 +1134,7 @@ class FpShopfloorController(http.Controller):
|
||||
_logger.info(
|
||||
'Plant Overview: moved step %s (%s) → WC %s by uid %s',
|
||||
step.id, step.name, wc_id or 'unassigned',
|
||||
request.env.uid,
|
||||
env.uid,
|
||||
)
|
||||
except Exception as exc:
|
||||
_logger.exception('Plant Overview move_card failed')
|
||||
|
||||
@@ -23,6 +23,8 @@ from odoo import fields, http
|
||||
from odoo.addons.fusion_plating.models.fp_tz import fp_format
|
||||
from odoo.http import request
|
||||
|
||||
from ._tablet_audit import env_for_tablet_tech
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -190,8 +192,8 @@ class FpWorkspaceController(http.Controller):
|
||||
@http.route('/fp/workspace/hold', type='jsonrpc', auth='user')
|
||||
def hold(self, job_id, reason='other', qty_on_hold=1, description='',
|
||||
part_ref='', step_id=None, mark_for_scrap=False,
|
||||
photo_data=None, photo_filename=None):
|
||||
env = request.env
|
||||
photo_data=None, photo_filename=None, tablet_tech_id=None):
|
||||
env = env_for_tablet_tech(request.env, tablet_tech_id)
|
||||
job = env['fp.job'].browse(int(job_id))
|
||||
if not job.exists():
|
||||
return {'ok': False, 'error': f'Job {job_id} not found'}
|
||||
@@ -253,8 +255,8 @@ class FpWorkspaceController(http.Controller):
|
||||
# /fp/workspace/sign_off — capture signature + finish step atomically
|
||||
# ======================================================================
|
||||
@http.route('/fp/workspace/sign_off', type='jsonrpc', auth='user')
|
||||
def sign_off(self, step_id, signature_data_uri):
|
||||
env = request.env
|
||||
def sign_off(self, step_id, signature_data_uri, tablet_tech_id=None):
|
||||
env = env_for_tablet_tech(request.env, tablet_tech_id)
|
||||
sig = (signature_data_uri or '').strip()
|
||||
if not sig:
|
||||
_logger.warning("workspace/sign_off: empty signature for step %s", step_id)
|
||||
@@ -302,8 +304,8 @@ class FpWorkspaceController(http.Controller):
|
||||
# /fp/workspace/advance_milestone — fire next_milestone_action
|
||||
# ======================================================================
|
||||
@http.route('/fp/workspace/advance_milestone', type='jsonrpc', auth='user')
|
||||
def advance_milestone(self, job_id):
|
||||
env = request.env
|
||||
def advance_milestone(self, job_id, tablet_tech_id=None):
|
||||
env = env_for_tablet_tech(request.env, tablet_tech_id)
|
||||
job = env['fp.job'].browse(int(job_id))
|
||||
if not job.exists():
|
||||
return {'ok': False, 'error': f'Job {job_id} not found'}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
import { Component, useState } from "@odoo/owl";
|
||||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { fpRpc } from "../services/fp_rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
// Hold reasons kept here so the picker doesn't need a server roundtrip.
|
||||
@@ -75,7 +75,7 @@ export class FpHoldComposer extends Component {
|
||||
}
|
||||
this.state.submitting = true;
|
||||
try {
|
||||
const res = await rpc("/fp/workspace/hold", {
|
||||
const res = await fpRpc("/fp/workspace/hold", {
|
||||
job_id: this.props.jobId,
|
||||
step_id: this.props.stepId || null,
|
||||
part_ref: this.props.partRef || "",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — FpIdleWarning (shared OWL service)
|
||||
//
|
||||
// Yellow-border overlay + countdown toast shown during the last
|
||||
// (default 30) seconds before auto-lock. Any pointer/touch event on
|
||||
// the document elsewhere resets the activity tracker, which causes
|
||||
// this component's parent (FpTabletLock) to hide the warning.
|
||||
// =============================================================================
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
export class FpIdleWarning extends Component {
|
||||
static template = "fusion_plating_shopfloor.IdleWarning";
|
||||
static props = {
|
||||
secondsRemaining: { type: Number },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — FpPinPad (shared OWL service)
|
||||
//
|
||||
// Numeric 4-digit PIN pad. Auto-submits on the 4th digit via onSubmit
|
||||
// callback. Used by FpTabletLock unlock flow AND FpPinSetup change flow.
|
||||
//
|
||||
// Props:
|
||||
// onSubmit : (pin: string) => Promise<{ok: boolean, error?: string}>
|
||||
// title : optional header text
|
||||
// subtitle : optional smaller text
|
||||
// onCancel : optional cancel callback (e.g. close modal)
|
||||
// =============================================================================
|
||||
|
||||
import { Component, useState } from "@odoo/owl";
|
||||
|
||||
export class FpPinPad extends Component {
|
||||
static template = "fusion_plating_shopfloor.PinPad";
|
||||
static props = {
|
||||
onSubmit: { type: Function },
|
||||
title: { type: String, optional: true },
|
||||
subtitle: { type: String, optional: true },
|
||||
onCancel: { type: Function, optional: true },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.state = useState({
|
||||
pin: "",
|
||||
submitting: false,
|
||||
error: "",
|
||||
shake: false,
|
||||
});
|
||||
}
|
||||
|
||||
async _press(digit) {
|
||||
if (this.state.submitting) return;
|
||||
if (this.state.pin.length >= 4) return;
|
||||
this.state.pin = this.state.pin + digit;
|
||||
this.state.error = "";
|
||||
if (this.state.pin.length === 4) {
|
||||
await this._submit();
|
||||
}
|
||||
}
|
||||
|
||||
_clear() {
|
||||
this.state.pin = "";
|
||||
this.state.error = "";
|
||||
}
|
||||
|
||||
async _submit() {
|
||||
this.state.submitting = true;
|
||||
try {
|
||||
const result = await this.props.onSubmit(this.state.pin);
|
||||
if (result && !result.ok) {
|
||||
this.state.error = result.error || "Incorrect PIN";
|
||||
this.state.shake = true;
|
||||
setTimeout(() => { this.state.shake = false; }, 400);
|
||||
this.state.pin = "";
|
||||
}
|
||||
} catch (err) {
|
||||
this.state.error = err.message || String(err);
|
||||
this.state.pin = "";
|
||||
} finally {
|
||||
this.state.submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
get dots() {
|
||||
// Render 4 dot slots: filled if typed, empty otherwise
|
||||
return [0, 1, 2, 3].map((i) => this.state.pin.length > i);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — FpPinSetup (client action `fp_tablet_pin_setup`)
|
||||
//
|
||||
// Modal flow for setting OR changing the user's tablet PIN. Triggered
|
||||
// from res.users preferences via action_open_tablet_pin_setup. Three
|
||||
// stages: (1) old PIN (only if has_pin), (2) new PIN, (3) confirm new.
|
||||
// =============================================================================
|
||||
|
||||
import { Component, useState, onMounted } from "@odoo/owl";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { user } from "@web/core/user";
|
||||
import { FpPinPad } from "./pin_pad";
|
||||
|
||||
export class FpPinSetup extends Component {
|
||||
static template = "fusion_plating_shopfloor.PinSetup";
|
||||
static components = { FpPinPad };
|
||||
static props = ["*"];
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.action = useService("action");
|
||||
this.state = useState({
|
||||
stage: "loading", // 'loading' | 'old' | 'new' | 'confirm' | 'done'
|
||||
newPin: "",
|
||||
hasExistingPin: false,
|
||||
});
|
||||
onMounted(() => this._init());
|
||||
}
|
||||
|
||||
async _init() {
|
||||
// Cheap probe: search_count on the user's own record filtered
|
||||
// by pin_set_date. Non-manager users can read their own set_date
|
||||
// (not the hash). If the count is 1, they have a PIN; 0 = no PIN.
|
||||
try {
|
||||
const has = await rpc("/web/dataset/call_kw", {
|
||||
model: "res.users",
|
||||
method: "search_count",
|
||||
args: [[
|
||||
["id", "=", user.userId],
|
||||
["x_fc_tablet_pin_set_date", "!=", false],
|
||||
]],
|
||||
kwargs: {},
|
||||
});
|
||||
this.state.hasExistingPin = has > 0;
|
||||
} catch (e) {
|
||||
this.state.hasExistingPin = false;
|
||||
}
|
||||
this.state.stage = this.state.hasExistingPin ? "old" : "new";
|
||||
}
|
||||
|
||||
async onOldPinSubmit(pin) {
|
||||
// Stash for the final call; set_pin verifies it server-side
|
||||
this._oldPin = pin;
|
||||
this.state.stage = "new";
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async onNewPinSubmit(pin) {
|
||||
this.state.newPin = pin;
|
||||
this.state.stage = "confirm";
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async onConfirmPinSubmit(pin) {
|
||||
if (pin !== this.state.newPin) {
|
||||
return { ok: false, error: "PINs don't match. Try again." };
|
||||
}
|
||||
const params = { new_pin: this.state.newPin };
|
||||
if (this._oldPin) params.old_pin = this._oldPin;
|
||||
const res = await rpc("/fp/tablet/set_pin", params);
|
||||
if (res && res.ok) {
|
||||
this.notification.add("Tablet PIN updated.", { type: "success" });
|
||||
this.state.stage = "done";
|
||||
setTimeout(() => this._close(), 1500);
|
||||
return { ok: true };
|
||||
}
|
||||
// Reset back to start on hard error so user can retry cleanly
|
||||
this.notification.add((res && res.error) || "Failed to set PIN", { type: "danger" });
|
||||
this._oldPin = null;
|
||||
this.state.newPin = "";
|
||||
this.state.stage = this.state.hasExistingPin ? "old" : "new";
|
||||
return { ok: false, error: (res && res.error) || "Failed" };
|
||||
}
|
||||
|
||||
_close() {
|
||||
this.action.doAction({ type: "ir.actions.act_window_close" });
|
||||
}
|
||||
|
||||
onCancel() {
|
||||
this._close();
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("fp_tablet_pin_setup", FpPinSetup);
|
||||
@@ -20,21 +20,24 @@
|
||||
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { fpRpc } from "./services/fp_rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { WorkflowChip } from "./components/workflow_chip";
|
||||
import { GateViz } from "./components/gate_viz";
|
||||
import { FpSignaturePad } from "./components/signature_pad";
|
||||
import { FpHoldComposer } from "./components/hold_composer";
|
||||
import { FpTabletLock } from "./tablet_lock";
|
||||
|
||||
export class FpJobWorkspace extends Component {
|
||||
static template = "fusion_plating_shopfloor.JobWorkspace";
|
||||
static props = ["*"];
|
||||
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer };
|
||||
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock };
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.action = useService("action");
|
||||
this.dialog = useService("dialog");
|
||||
this.techStore = useService("fp_shopfloor_tech_store");
|
||||
|
||||
this.state = useState({
|
||||
data: null,
|
||||
@@ -76,6 +79,11 @@ export class FpJobWorkspace extends Component {
|
||||
this.action.doAction({ type: "ir.actions.act_window_close" });
|
||||
}
|
||||
|
||||
// ---- Hand-Off (Phase 6.2) ---------------------------------------------
|
||||
handOff() {
|
||||
this.techStore.lock();
|
||||
}
|
||||
|
||||
onJumpToBlocker({ model, id }) {
|
||||
// If the predecessor is in this same workspace, just scroll to it
|
||||
const inThisJob = (this.state.data.steps || []).find((s) => s.id === id);
|
||||
@@ -109,7 +117,7 @@ export class FpJobWorkspace extends Component {
|
||||
// ---- Step actions ------------------------------------------------------
|
||||
async onStartStep(stepId) {
|
||||
try {
|
||||
const res = await rpc("/fp/shopfloor/start_wo", { workorder_id: stepId });
|
||||
const res = await fpRpc("/fp/shopfloor/start_wo", { workorder_id: stepId });
|
||||
if (res && res.ok) {
|
||||
this.notification.add("Step started.", { type: "success" });
|
||||
await this.refresh();
|
||||
@@ -128,7 +136,7 @@ export class FpJobWorkspace extends Component {
|
||||
contextLabel: `${this.state.data.job.display_wo_name} · Step ${step.sequence_display}: ${step.name}`,
|
||||
onSubmit: async (dataUri) => {
|
||||
try {
|
||||
const res = await rpc("/fp/workspace/sign_off", {
|
||||
const res = await fpRpc("/fp/workspace/sign_off", {
|
||||
step_id: step.id,
|
||||
signature_data_uri: dataUri,
|
||||
});
|
||||
@@ -147,7 +155,7 @@ export class FpJobWorkspace extends Component {
|
||||
}
|
||||
// Plain finish — no signature required
|
||||
try {
|
||||
const res = await rpc("/fp/shopfloor/stop_wo", {
|
||||
const res = await fpRpc("/fp/shopfloor/stop_wo", {
|
||||
workorder_id: step.id, finish: true,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
@@ -194,7 +202,7 @@ export class FpJobWorkspace extends Component {
|
||||
|
||||
async onAdvanceMilestone() {
|
||||
try {
|
||||
const res = await rpc("/fp/workspace/advance_milestone", {
|
||||
const res = await fpRpc("/fp/workspace/advance_milestone", {
|
||||
job_id: this.state.jobId,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
|
||||
@@ -15,17 +15,20 @@
|
||||
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { fpRpc } from "./services/fp_rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { QrScanner } from "./qr_scanner";
|
||||
import { FpTabletLock } from "./tablet_lock";
|
||||
|
||||
export class ManagerDashboard extends Component {
|
||||
static template = "fusion_plating_shopfloor.ManagerDashboard";
|
||||
static props = ["*"];
|
||||
static components = { QrScanner };
|
||||
static components = { QrScanner, FpTabletLock };
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.action = useService("action");
|
||||
this.techStore = useService("fp_shopfloor_tech_store");
|
||||
|
||||
this.state = useState({
|
||||
overview: null,
|
||||
@@ -148,6 +151,11 @@ export class ManagerDashboard extends Component {
|
||||
this.state.mode = this.state.mode === "quick" ? "detailed" : "quick";
|
||||
}
|
||||
|
||||
// ---- Hand-Off (Phase 6.2) ---------------------------------------------
|
||||
handOff() {
|
||||
this.techStore.lock();
|
||||
}
|
||||
|
||||
toggleCard(jobId) {
|
||||
this.state.expandedJobId = this.state.expandedJobId === jobId ? null : jobId;
|
||||
}
|
||||
@@ -201,7 +209,7 @@ export class ManagerDashboard extends Component {
|
||||
async onAssignWorker(step, userIdRaw) {
|
||||
const userId = parseInt(userIdRaw) || null;
|
||||
try {
|
||||
const res = await rpc("/fp/manager/assign_worker", {
|
||||
const res = await fpRpc("/fp/manager/assign_worker", {
|
||||
step_id: step.id, user_id: userId,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
@@ -219,7 +227,7 @@ export class ManagerDashboard extends Component {
|
||||
async onAssignTank(step, tankIdRaw) {
|
||||
const tankId = parseInt(tankIdRaw) || null;
|
||||
try {
|
||||
const res = await rpc("/fp/manager/assign_tank", {
|
||||
const res = await fpRpc("/fp/manager/assign_tank", {
|
||||
step_id: step.id, tank_id: tankId,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
@@ -236,7 +244,7 @@ export class ManagerDashboard extends Component {
|
||||
|
||||
async onTakeOver(step) {
|
||||
try {
|
||||
const res = await rpc("/fp/manager/take_over", {
|
||||
const res = await fpRpc("/fp/manager/take_over", {
|
||||
step_id: step.id,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — Activity Tracker (shared OWL service)
|
||||
//
|
||||
// Watches the document for pointer/touch/keydown/visibility events and
|
||||
// tracks lastActiveAt. FpTabletLock reads getSecondsUntilLock() once per
|
||||
// second to drive the idle warning + auto-lock transitions.
|
||||
//
|
||||
// Threshold reads from ir.config_parameter at service start; refreshes
|
||||
// every 5 min in case the manager changed it.
|
||||
// =============================================================================
|
||||
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
const DEFAULT_IDLE_MIN = 5;
|
||||
const DEFAULT_WARN_SEC = 30;
|
||||
|
||||
export const fpShopfloorActivityTracker = {
|
||||
async start() {
|
||||
let lastActiveAt = Date.now();
|
||||
let idleThresholdMs = DEFAULT_IDLE_MIN * 60 * 1000;
|
||||
let warnThresholdSec = DEFAULT_WARN_SEC;
|
||||
|
||||
async function refreshThreshold() {
|
||||
try {
|
||||
const minutes = await rpc("/web/dataset/call_kw", {
|
||||
model: "ir.config_parameter",
|
||||
method: "get_param",
|
||||
args: ["fp.shopfloor.tablet_idle_lock_minutes", String(DEFAULT_IDLE_MIN)],
|
||||
kwargs: {},
|
||||
});
|
||||
idleThresholdMs = (parseInt(minutes, 10) || DEFAULT_IDLE_MIN) * 60 * 1000;
|
||||
const warn = await rpc("/web/dataset/call_kw", {
|
||||
model: "ir.config_parameter",
|
||||
method: "get_param",
|
||||
args: ["fp.shopfloor.tablet_warn_seconds_before_lock", String(DEFAULT_WARN_SEC)],
|
||||
kwargs: {},
|
||||
});
|
||||
warnThresholdSec = parseInt(warn, 10) || DEFAULT_WARN_SEC;
|
||||
} catch (e) {
|
||||
// keep defaults if RPC fails (e.g. no session yet)
|
||||
}
|
||||
}
|
||||
await refreshThreshold();
|
||||
setInterval(refreshThreshold, 5 * 60 * 1000);
|
||||
|
||||
// Activity = explicit user input. Mouse-move alone DOES NOT count
|
||||
// because something brushing the screen (a stray glove, a tool
|
||||
// resting on the tablet) could otherwise keep the session alive.
|
||||
const bump = () => { lastActiveAt = Date.now(); };
|
||||
document.addEventListener("pointerdown", bump, { capture: true });
|
||||
document.addEventListener("touchstart", bump, { capture: true, passive: true });
|
||||
document.addEventListener("keydown", bump, { capture: true });
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "visible") bump();
|
||||
});
|
||||
|
||||
return {
|
||||
bump,
|
||||
getSecondsUntilLock() {
|
||||
return Math.max(0, Math.floor((lastActiveAt + idleThresholdMs - Date.now()) / 1000));
|
||||
},
|
||||
getWarnThresholdSec() { return warnThresholdSec; },
|
||||
getIdleThresholdMs() { return idleThresholdMs; },
|
||||
getLastActiveAt() { return lastActiveAt; },
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
registry
|
||||
.category("services")
|
||||
.add("fp_shopfloor_activity", fpShopfloorActivityTracker);
|
||||
@@ -0,0 +1,42 @@
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — fpRpc() wrapper
|
||||
//
|
||||
// Drop-in replacement for the standard `rpc()` import. Automatically
|
||||
// injects the current tablet_tech_id from the tech_store into every
|
||||
// call, so server-side endpoints can attribute the action to the right
|
||||
// user via env.with_user() (see env_for_tablet_tech in
|
||||
// controllers/_tablet_audit.py).
|
||||
//
|
||||
// USE for any RPC that WRITES (start step, finish step, hold create,
|
||||
// sign-off, milestone advance). For read-only loads (kanban, workspace
|
||||
// load, manager funnel), plain rpc() is fine.
|
||||
//
|
||||
// Example:
|
||||
// import { fpRpc } from "../services/fp_rpc";
|
||||
// await fpRpc("/fp/shopfloor/start_wo", { workorder_id: stepId });
|
||||
//
|
||||
// =============================================================================
|
||||
|
||||
import { rpc as baseRpc } from "@web/core/network/rpc";
|
||||
|
||||
function _getTechStore() {
|
||||
// Lazy-resolve via the global debug API — avoids circular service init
|
||||
try {
|
||||
const env = odoo.__WOWL_DEBUG__?.root?.env;
|
||||
if (env && env.services && env.services.fp_shopfloor_tech_store) {
|
||||
return env.services.fp_shopfloor_tech_store;
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function fpRpc(url, params = {}) {
|
||||
const techStore = _getTechStore();
|
||||
if (techStore && techStore.currentTechId) {
|
||||
params = { ...params, tablet_tech_id: techStore.currentTechId };
|
||||
}
|
||||
return baseRpc(url, params);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — Tech Store (shared OWL service)
|
||||
//
|
||||
// Holds the "current tech of record" for the locked tablet. Set by
|
||||
// FpTabletLock on successful PIN unlock; cleared on auto-lock / Hand-Off.
|
||||
// Other components read currentTechId via useService("fp_shopfloor_tech_store")
|
||||
// and pass it through fpRpc() so server actions credit the right user.
|
||||
// =============================================================================
|
||||
|
||||
import { reactive } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
export const fpShopfloorTechStore = {
|
||||
start() {
|
||||
const state = reactive({
|
||||
currentTechId: null,
|
||||
currentTechName: "",
|
||||
lockedAt: null,
|
||||
});
|
||||
return {
|
||||
get currentTechId() { return state.currentTechId; },
|
||||
get currentTechName() { return state.currentTechName; },
|
||||
get isLocked() { return !state.currentTechId; },
|
||||
setTech(id, name) {
|
||||
state.currentTechId = id;
|
||||
state.currentTechName = name;
|
||||
state.lockedAt = null;
|
||||
},
|
||||
lock() {
|
||||
state.currentTechId = null;
|
||||
state.currentTechName = "";
|
||||
state.lockedAt = Date.now();
|
||||
},
|
||||
state, // exposed for OWL reactive subscriptions
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
registry
|
||||
.category("services")
|
||||
.add("fp_shopfloor_tech_store", fpShopfloorTechStore);
|
||||
@@ -20,9 +20,11 @@
|
||||
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { fpRpc } from "./services/fp_rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { QrScanner } from "./qr_scanner";
|
||||
import { FpKanbanCard } from "./components/kanban_card";
|
||||
import { FpTabletLock } from "./tablet_lock";
|
||||
|
||||
const LS_STATION_ID = "fp_landing_station_id";
|
||||
const LS_MODE = "fp_landing_mode";
|
||||
@@ -31,11 +33,12 @@ const REFRESH_MS = 15000;
|
||||
export class FpShopfloorLanding extends Component {
|
||||
static template = "fusion_plating_shopfloor.ShopfloorLanding";
|
||||
static props = ["*"];
|
||||
static components = { QrScanner, FpKanbanCard };
|
||||
static components = { QrScanner, FpKanbanCard, FpTabletLock };
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.action = useService("action");
|
||||
this.techStore = useService("fp_shopfloor_tech_store");
|
||||
|
||||
this.state = useState({
|
||||
mode: localStorage.getItem(LS_MODE) || "all_plant",
|
||||
@@ -120,6 +123,12 @@ export class FpShopfloorLanding extends Component {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
// ---- Hand-Off (Phase 6.2) ---------------------------------------------
|
||||
handOff() {
|
||||
// Tech walking away: lock the tablet so the next operator must PIN in
|
||||
this.techStore.lock();
|
||||
}
|
||||
|
||||
// ---- Search ------------------------------------------------------------
|
||||
onSearchInput(ev) {
|
||||
this.state.search = ev.target.value;
|
||||
@@ -246,7 +255,7 @@ export class FpShopfloorLanding extends Component {
|
||||
this._movesInFlight += 1;
|
||||
this._lastDropAt = Date.now();
|
||||
try {
|
||||
const res = await rpc("/fp/shopfloor/plant_overview/move_card", {
|
||||
const res = await fpRpc("/fp/shopfloor/plant_overview/move_card", {
|
||||
card_id: dragged.id,
|
||||
target_workcenter_id: col.work_center_id,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — FpTabletLock (top-level wrapper)
|
||||
//
|
||||
// Mounted by Landing / Workspace / Manager Dashboard as their outermost
|
||||
// element. Renders the lock screen (tile grid + PIN pad) when no tech
|
||||
// is signed in; renders <t t-slot="default"/> (the wrapped client
|
||||
// action) otherwise. Also drives the auto-lock countdown + idle warning.
|
||||
//
|
||||
// Usage in a parent template:
|
||||
//
|
||||
// <FpTabletLock>
|
||||
// <t t-set-slot="default">
|
||||
// <div class="o_fp_landing"> ...your existing tree... </div>
|
||||
// </t>
|
||||
// </FpTabletLock>
|
||||
//
|
||||
// =============================================================================
|
||||
|
||||
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { FpPinPad } from "./components/pin_pad";
|
||||
import { FpIdleWarning } from "./components/idle_warning";
|
||||
|
||||
export class FpTabletLock extends Component {
|
||||
static template = "fusion_plating_shopfloor.TabletLock";
|
||||
static components = { FpPinPad, FpIdleWarning };
|
||||
static props = {
|
||||
slots: { type: Object, optional: true },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.techStore = useService("fp_shopfloor_tech_store");
|
||||
this.activity = useService("fp_shopfloor_activity");
|
||||
this.notification = useService("notification");
|
||||
|
||||
this.state = useState({
|
||||
tiles: [],
|
||||
selectedTileUserId: null,
|
||||
idleSecondsRemaining: null,
|
||||
loadingTiles: false,
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await this._loadTiles();
|
||||
this._tick = setInterval(() => this._checkIdle(), 1000);
|
||||
// Heartbeat ping every 60s — for forensic visibility
|
||||
this._ping = setInterval(() => {
|
||||
if (this.techStore.currentTechId) {
|
||||
rpc("/fp/tablet/ping", { current_tech_id: this.techStore.currentTechId })
|
||||
.catch(() => {});
|
||||
}
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
onWillUnmount(() => {
|
||||
if (this._tick) clearInterval(this._tick);
|
||||
if (this._ping) clearInterval(this._ping);
|
||||
});
|
||||
}
|
||||
|
||||
get isLocked() {
|
||||
return this.techStore.isLocked;
|
||||
}
|
||||
|
||||
async _loadTiles() {
|
||||
this.state.loadingTiles = true;
|
||||
try {
|
||||
const stationId = parseInt(localStorage.getItem("fp_landing_station_id")) || null;
|
||||
const res = await rpc("/fp/tablet/tiles", { station_id: stationId });
|
||||
if (res && res.ok) {
|
||||
this.state.tiles = res.tiles;
|
||||
}
|
||||
} catch (err) {
|
||||
// Quiet fail — tile grid stays empty; user gets prompted
|
||||
} finally {
|
||||
this.state.loadingTiles = false;
|
||||
}
|
||||
}
|
||||
|
||||
_checkIdle() {
|
||||
if (!this.techStore.currentTechId) {
|
||||
this.state.idleSecondsRemaining = null;
|
||||
return;
|
||||
}
|
||||
const remaining = this.activity.getSecondsUntilLock();
|
||||
const warnThreshold = this.activity.getWarnThresholdSec();
|
||||
if (remaining <= 0) {
|
||||
this.handOff();
|
||||
} else if (remaining <= warnThreshold) {
|
||||
this.state.idleSecondsRemaining = remaining;
|
||||
} else if (this.state.idleSecondsRemaining !== null) {
|
||||
this.state.idleSecondsRemaining = null;
|
||||
}
|
||||
}
|
||||
|
||||
onTileClick(userId) {
|
||||
this.state.selectedTileUserId = userId;
|
||||
}
|
||||
|
||||
_selectedTileName() {
|
||||
const tile = this.state.tiles.find(t => t.user_id === this.state.selectedTileUserId);
|
||||
return tile ? tile.name : "";
|
||||
}
|
||||
|
||||
async unlock(pin) {
|
||||
try {
|
||||
const res = await rpc("/fp/tablet/unlock", {
|
||||
user_id: this.state.selectedTileUserId,
|
||||
pin,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
this.techStore.setTech(res.current_tech_id, res.current_tech_name);
|
||||
this.activity.bump();
|
||||
this.state.selectedTileUserId = null;
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: false, error: (res && res.error) || "Unlock failed" };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err.message || String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
onPinCancel() {
|
||||
this.state.selectedTileUserId = null;
|
||||
}
|
||||
|
||||
handOff() {
|
||||
this.techStore.lock();
|
||||
this.state.selectedTileUserId = null;
|
||||
this.state.idleSecondsRemaining = null;
|
||||
this._loadTiles();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// =============================================================================
|
||||
// FpIdleWarning — yellow-border countdown overlay before auto-lock
|
||||
// =============================================================================
|
||||
|
||||
.o_fp_idle_warning_overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 9998;
|
||||
box-shadow: inset 0 0 0 4px #ff9f0a;
|
||||
animation: o_fp_idle_pulse 1s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes o_fp_idle_pulse {
|
||||
from { box-shadow: inset 0 0 0 4px rgba(255, 159, 10, 0.6); }
|
||||
to { box-shadow: inset 0 0 0 4px rgba(255, 159, 10, 1); }
|
||||
}
|
||||
|
||||
.o_fp_idle_warning_toast {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #1d1d1f;
|
||||
color: #ffd585;
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||
|
||||
strong { color: #ffb84d; margin: 0 0.2rem; }
|
||||
> i { margin-right: 0.4rem; }
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// =============================================================================
|
||||
// FpPinPad — numeric keypad for tablet lock screen + PIN setup
|
||||
// Dark-mode aware via $o-webclient-color-scheme branch.
|
||||
// =============================================================================
|
||||
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
$_pin-bg-hex: #ffffff;
|
||||
$_pin-key-bg-hex: #f3f4f6;
|
||||
$_pin-key-hover-hex: #e5e7eb;
|
||||
$_pin-border-hex: #d8dadd;
|
||||
$_pin-dot-hex: #d8dadd;
|
||||
$_pin-dot-fill-hex: #1d1d1f;
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_pin-bg-hex: #22262d !global;
|
||||
$_pin-key-bg-hex: #2d3138 !global;
|
||||
$_pin-key-hover-hex: #3a3f48 !global;
|
||||
$_pin-border-hex: #424245 !global;
|
||||
$_pin-dot-fill-hex: #f5f5f7 !global;
|
||||
}
|
||||
|
||||
.o_fp_pin_pad {
|
||||
background: $_pin-bg-hex;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.o_fp_pin_title { font-size: 1.1rem; font-weight: 600; }
|
||||
.o_fp_pin_subtitle { font-size: 0.85rem; color: var(--text-secondary, #666); text-align: center; }
|
||||
|
||||
.o_fp_pin_dots {
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.o_fp_pin_dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: $_pin-dot-hex;
|
||||
transition: background 0.1s ease;
|
||||
&.filled { background: $_pin-dot-fill-hex; }
|
||||
}
|
||||
|
||||
.o_fp_pin_error {
|
||||
color: #ff3b30;
|
||||
font-size: 0.85rem;
|
||||
min-height: 1.2rem;
|
||||
}
|
||||
|
||||
.o_fp_pin_grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.o_fp_pin_key {
|
||||
background: $_pin-key-bg-hex;
|
||||
border: 1px solid $_pin-border-hex;
|
||||
border-radius: 10px;
|
||||
padding: 1rem 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s ease, transform 0.05s ease;
|
||||
|
||||
&:hover { background: $_pin-key-hover-hex; }
|
||||
&:active { transform: scale(0.97); }
|
||||
&:disabled { opacity: 0.5; cursor: wait; }
|
||||
}
|
||||
|
||||
.o_fp_pin_key_clear { font-size: 0.95rem; color: var(--text-secondary, #666); }
|
||||
.o_fp_pin_key_cancel { font-size: 0.95rem; color: var(--text-secondary, #666); }
|
||||
|
||||
@keyframes o_fp_pin_shake_kf {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-8px); }
|
||||
50% { transform: translateX(8px); }
|
||||
75% { transform: translateX(-4px); }
|
||||
}
|
||||
.o_fp_pin_shake { animation: o_fp_pin_shake_kf 0.4s ease; }
|
||||
@@ -0,0 +1,96 @@
|
||||
// =============================================================================
|
||||
// FpTabletLock — lock screen with tile grid + PIN pad overlay
|
||||
// =============================================================================
|
||||
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
$_lock-bg-hex: #f3f4f6;
|
||||
$_lock-card-hex: #ffffff;
|
||||
$_lock-border-hex: #d8dadd;
|
||||
$_lock-ink-hex: #1d1d1f;
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_lock-bg-hex: #1a1d21 !global;
|
||||
$_lock-card-hex: #22262d !global;
|
||||
$_lock-border-hex: #424245 !global;
|
||||
$_lock-ink-hex: #f5f5f7 !global;
|
||||
}
|
||||
|
||||
.o_fp_tablet_lock {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: $_lock-bg-hex;
|
||||
color: $_lock-ink-hex;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 2rem;
|
||||
z-index: 9000;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.o_fp_tablet_lock_header {
|
||||
h1 {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_tablet_lock_loading, .o_fp_tablet_lock_empty {
|
||||
margin: 2rem auto;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.o_fp_tablet_lock_tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 1rem;
|
||||
max-width: 900px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.o_fp_tablet_lock_tile {
|
||||
background: $_lock-card-hex;
|
||||
border: 2px solid $_lock-border-hex;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transition: border-color 0.1s ease, transform 0.05s ease;
|
||||
|
||||
&:hover { border-color: #0071e3; }
|
||||
&:active { transform: scale(0.98); }
|
||||
}
|
||||
|
||||
.o_fp_tablet_lock_tile_avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.o_fp_tablet_lock_tile_name {
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.o_fp_tablet_lock_tile_clocked {
|
||||
color: #34c759;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.o_fp_tablet_lock_tile_nopin {
|
||||
color: #ff9f0a;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.o_fp_tablet_lock_pinwrap {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.IdleWarning">
|
||||
<div class="o_fp_idle_warning_overlay">
|
||||
<div class="o_fp_idle_warning_toast">
|
||||
<i class="fa fa-clock-o"/>
|
||||
Locking in <strong t-esc="props.secondsRemaining"/>s · tap anywhere to stay
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.PinPad">
|
||||
<div t-att-class="'o_fp_pin_pad' + (state.shake ? ' o_fp_pin_shake' : '')">
|
||||
<div t-if="props.title" class="o_fp_pin_title" t-esc="props.title"/>
|
||||
<div t-if="props.subtitle" class="o_fp_pin_subtitle" t-esc="props.subtitle"/>
|
||||
|
||||
<div class="o_fp_pin_dots">
|
||||
<t t-foreach="dots" t-as="filled" t-key="filled_index">
|
||||
<span t-att-class="'o_fp_pin_dot' + (filled ? ' filled' : '')"/>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<div t-if="state.error" class="o_fp_pin_error" t-esc="state.error"/>
|
||||
|
||||
<div class="o_fp_pin_grid">
|
||||
<t t-foreach="[1,2,3,4,5,6,7,8,9]" t-as="d" t-key="d">
|
||||
<button class="o_fp_pin_key"
|
||||
t-on-click="() => this._press(String(d))"
|
||||
t-att-disabled="state.submitting">
|
||||
<t t-esc="d"/>
|
||||
</button>
|
||||
</t>
|
||||
<button class="o_fp_pin_key o_fp_pin_key_clear"
|
||||
t-on-click="_clear">Clear</button>
|
||||
<button class="o_fp_pin_key"
|
||||
t-on-click="() => this._press('0')"
|
||||
t-att-disabled="state.submitting">0</button>
|
||||
<button t-if="props.onCancel"
|
||||
class="o_fp_pin_key o_fp_pin_key_cancel"
|
||||
t-on-click="() => this.props.onCancel()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.PinSetup">
|
||||
<div class="o_fp_pin_setup">
|
||||
<div t-if="state.stage === 'loading'" class="o_fp_pin_setup_loading">
|
||||
<i class="fa fa-spinner fa-spin"/> Loading…
|
||||
</div>
|
||||
<FpPinPad t-if="state.stage === 'old'"
|
||||
onSubmit.bind="onOldPinSubmit"
|
||||
title="'Enter your current PIN'"
|
||||
onCancel.bind="onCancel"/>
|
||||
<FpPinPad t-if="state.stage === 'new'"
|
||||
onSubmit.bind="onNewPinSubmit"
|
||||
title="'Choose a new 4-digit PIN'"
|
||||
onCancel.bind="onCancel"/>
|
||||
<FpPinPad t-if="state.stage === 'confirm'"
|
||||
onSubmit.bind="onConfirmPinSubmit"
|
||||
title="'Confirm your new PIN'"
|
||||
subtitle="'Enter it again to confirm'"
|
||||
onCancel.bind="onCancel"/>
|
||||
<div t-if="state.stage === 'done'" class="o_fp_pin_setup_done">
|
||||
<i class="fa fa-check-circle text-success fa-3x"/>
|
||||
<h3>PIN updated</h3>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -2,6 +2,8 @@
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.JobWorkspace">
|
||||
<FpTabletLock>
|
||||
<t t-set-slot="default">
|
||||
<div class="o_fp_ws">
|
||||
|
||||
<!-- Loading state -->
|
||||
@@ -20,6 +22,12 @@
|
||||
<button class="btn btn-link o_fp_ws_back" t-on-click="onBack">
|
||||
<i class="fa fa-arrow-left"/> Back
|
||||
</button>
|
||||
<!-- Phase 6.2 — Hand-Off: lock the tablet -->
|
||||
<button class="btn btn-sm btn-warning ms-2"
|
||||
t-on-click="handOff"
|
||||
title="Lock the tablet for the next operator">
|
||||
<i class="fa fa-lock"/> Hand Off
|
||||
</button>
|
||||
<span class="o_fp_ws_wo"><t t-esc="state.data.job.display_wo_name"/></span>
|
||||
<span class="o_fp_ws_dot"> · </span>
|
||||
<span class="o_fp_ws_cust"><t t-esc="state.data.job.partner_name"/></span>
|
||||
@@ -225,6 +233,8 @@
|
||||
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</FpTabletLock>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.ManagerDashboard">
|
||||
<FpTabletLock>
|
||||
<t t-set-slot="default">
|
||||
<div class="o_fp_manager">
|
||||
|
||||
<!-- ============ Hero ============ -->
|
||||
@@ -45,6 +47,12 @@
|
||||
<i t-att-class="'fa fa-refresh' + (state.isFetching ? ' fa-spin' : '')"/>
|
||||
</button>
|
||||
<QrScanner cssClass="'btn'"/>
|
||||
<!-- Phase 6.2 — Hand-Off: lock the tablet -->
|
||||
<button class="btn btn-warning"
|
||||
t-on-click="handOff"
|
||||
title="Lock the tablet for the next operator">
|
||||
<i class="fa fa-lock"/> Hand Off
|
||||
</button>
|
||||
<button t-att-class="'btn ' + (state.mode === 'quick' ? 'btn-primary' : '')"
|
||||
t-on-click="toggleMode">
|
||||
<t t-if="state.mode === 'quick'">Quick View</t>
|
||||
@@ -583,6 +591,8 @@
|
||||
<div>Loading manager data…</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</FpTabletLock>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.ShopfloorLanding">
|
||||
<FpTabletLock>
|
||||
<t t-set-slot="default">
|
||||
<div class="o_fp_landing">
|
||||
|
||||
<!-- Loading state -->
|
||||
@@ -62,6 +64,13 @@
|
||||
</button>
|
||||
<QrScanner cssClass="'btn btn-sm btn-outline-secondary'" label="'Camera'"/>
|
||||
|
||||
<!-- Phase 6.2 — Hand-Off: lock the tablet for the next operator -->
|
||||
<button class="btn btn-sm btn-warning"
|
||||
t-on-click="handOff"
|
||||
title="Lock the tablet for the next operator">
|
||||
<i class="fa fa-lock"/> Hand Off
|
||||
</button>
|
||||
|
||||
<!-- Refresh indicator -->
|
||||
<span class="o_fp_landing_refresh text-muted">
|
||||
<i class="fa fa-clock-o"/> <t t-esc="state.lastRefresh"/>
|
||||
@@ -158,6 +167,8 @@
|
||||
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</FpTabletLock>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.TabletLock">
|
||||
<t t-if="isLocked">
|
||||
<div class="o_fp_tablet_lock">
|
||||
<div class="o_fp_tablet_lock_header">
|
||||
<h1><i class="fa fa-lock"/> Tap your name to unlock</h1>
|
||||
</div>
|
||||
<div t-if="state.loadingTiles" class="o_fp_tablet_lock_loading">
|
||||
<i class="fa fa-spinner fa-spin"/> Loading…
|
||||
</div>
|
||||
<div t-elif="!state.selectedTileUserId" class="o_fp_tablet_lock_tiles">
|
||||
<t t-if="!state.tiles.length">
|
||||
<div class="o_fp_tablet_lock_empty">
|
||||
No operators configured.
|
||||
</div>
|
||||
</t>
|
||||
<t t-foreach="state.tiles" t-as="tile" t-key="tile.user_id">
|
||||
<button class="o_fp_tablet_lock_tile"
|
||||
t-on-click="() => this.onTileClick(tile.user_id)">
|
||||
<img class="o_fp_tablet_lock_tile_avatar"
|
||||
t-att-src="tile.avatar_url"
|
||||
t-att-alt="tile.name"/>
|
||||
<div class="o_fp_tablet_lock_tile_name" t-esc="tile.name"/>
|
||||
<span t-if="tile.is_clocked_in" class="o_fp_tablet_lock_tile_clocked">
|
||||
● Clocked in
|
||||
</span>
|
||||
<span t-if="!tile.has_pin" class="o_fp_tablet_lock_tile_nopin">
|
||||
PIN required
|
||||
</span>
|
||||
</button>
|
||||
</t>
|
||||
</div>
|
||||
<div t-else="" class="o_fp_tablet_lock_pinwrap">
|
||||
<FpPinPad onSubmit.bind="unlock"
|
||||
title="_selectedTileName()"
|
||||
subtitle="'Enter your 4-digit PIN'"
|
||||
onCancel.bind="onPinCancel"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-slot="default"/>
|
||||
<FpIdleWarning t-if="state.idleSecondsRemaining !== null"
|
||||
secondsRemaining="state.idleSecondsRemaining"/>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
Reference in New Issue
Block a user