Compare commits
3 Commits
efc420b4ce
...
772107d25b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
772107d25b | ||
|
|
c61371005a | ||
|
|
7a0bd67fc0 |
@@ -230,6 +230,7 @@ Use only: `name`, `model_id`, `state`, `code` (or `function`/`model`), `interval
|
||||
19. **QWeb `t-value` is Python, not Jinja**: `t-value="orders|length"` does NOT call a filter — Python parses `|` as bitwise/recordset OR, so on a non-empty recordset it tries `recordset | length_var` and raises `TypeError: unsupported operand types in: sale.order(…) | None` (when `length` is undefined) or returns a merged recordset (when `length` happens to be another recordset). Use `len(orders)` or `bool(orders)` or `(orders and orders[0]) or False` — explicit Python. Same trap applies to `|default`, `|first`, `|join`, etc. — none of these Jinja filters exist in QWeb. Bit us 2026-05-18 on `fp_sale_order_portal.xml` injecting `result_total` into the list-controls macro.
|
||||
20. **OWL templates expose `Math` but NOT `String` / `Number` / `Array` / `Object` / `Boolean` / `JSON` / `parseInt` / `parseFloat`**: writing `t-on-click="() => this._press(String(d))"` (or similar coercion inside any template expression) throws `Uncaught TypeError: v2 is not a function` at click time — `v2` is OWL's compiled reference to a global that doesn't exist in template scope. The click handler dies before its body runs, so the bug looks like "nothing happens when I press" (no error in the UI, only DevTools shows the trace). **Fixes, in order of preference**: (a) eliminate the coercion entirely — store data in the right type up front, e.g. `t-foreach="['1','2','3']"` instead of `[1,2,3]` so `d` is already a string. (b) Use a JS-side coercion: pass the raw value to the handler and call `String(digit)` inside the component method. (c) Use a pure-expression workaround like string concatenation: `'' + d` does work because `+` is an operator, not a function. **Do NOT try to monkey-patch `String` onto the component (e.g. `this.String = String`) or onto `env` — leaks the global into every component and is fragile across OWL upgrades.** Bit us 2026-05-23 on `pin_pad.xml` — operators couldn't tap PIN digits at all because the click handler died on `String(d)`; the SCSS, reactivity, and `_press` method were all fine, the template scope was the entire bug. Same trap applies to OWL templates anywhere in the codebase: `move_parts_dialog.xml`, `manager_dashboard.xml`, `fp_record_inputs_dialog.xml`, etc. — grep all `t-on-click`, `t-att-*`, and `t-out` expressions for `String(`, `Number(`, `Array(`, `parseInt(`, `parseFloat(`, `JSON.` before merging.
|
||||
21. **`ir.actions.act_window_close` is a no-op when the current action was opened with `target: "current"`**: replacing the current action wipes the breadcrumb backstack, so there's nothing to close back to. The user clicks "Back" and nothing happens (no error, no navigation). This bites every OWL client-action surface that calls another client action via `doAction({..., target: "current"})` — the destination has no way to return to the source. **Fix pattern for "Back" buttons in OWL client actions**: navigate EXPLICITLY to the landing/parent action by tag, e.g. `this.action.doAction({ type: "ir.actions.client", tag: "fp_shopfloor_landing", target: "current" })` — works regardless of how the action was reached (kanban tap, QR scan, smart button, direct URL). **Do NOT rely on `act_window_close`, `history.back()`, or `this.env.config.breadcrumbs`** — all three are unreliable across navigation paths. Bit us 2026-05-23 on the Job Workspace Back button after the kanban opened the workspace with `target: "current"`. The same pattern applies to every other "Back" button in shopfloor / manager / portal OWL surfaces — explicit destination via `tag:` is the only robust answer.
|
||||
22. **Odoo 19 HTML fields auto-wrap plain-string writes**: writing `co.report_header = 'Plating & Finishing'` to an HTML field (like `res.company.report_header`, `res.partner.comment`, `mail.template.body_html`, `product.template.description_sale`) stores `<p>Plating & Finishing</p>` after Odoo's HTML sanitizer runs. Equality tests against the raw input string FAIL (`payload['tagline'] != 'Plating & Finishing'`). **Three implications**: (a) **In tests**, don't `assertEqual` against the literal string you wrote — strip tags first, OR write the wrapped form (`<p>Plating & Finishing</p>`), OR write an explicit `Markup('<p>...</p>')` so the round-trip stays stable. (b) **In display code**, render HTML fields with `t-out` (QWeb) or `markup(...)` (OWL) — `t-esc` would render the literal `<p>` tags as text. (c) **In comparison logic**, normalize first: `from markupsafe import escape; escape(input_str)` produces the same shape the field stores. Bit us 2026-05-24 testing the lock-screen tagline source (`_lock_company_payload` reads `res.company.report_header`); the test that wrote a plain string and asserted equality failed because the value came back wrapped. The fix was to delete the brittle equality test — the helper's responsibility is just "use the field's value when present, else fall back," which is covered by the empty-field test. Generalizes to ANY HTML-typed Odoo field. Distinct from the `mail.template.body_html is Markup + jsonb` gotcha noted earlier in this file — that's about Markup objects vs strings; this is about the sanitizer wrapping plain strings on write.
|
||||
|
||||
## Naming
|
||||
- **New custom models** (post-2026-04): `fp.*` prefix (e.g. `fp.part.catalog`, `fp.certificate`)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Shop Floor',
|
||||
'version': '19.0.31.0.0',
|
||||
'version': '19.0.32.0.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
|
||||
'first-piece inspection gates.',
|
||||
@@ -95,6 +95,9 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
'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',
|
||||
# 2026-05-24 lock-screen redesign — tokens MUST precede tablet_lock.scss
|
||||
# so the $lock-* vars are visible to the consumer (project rule 8).
|
||||
'fusion_plating_shopfloor/static/src/scss/_tablet_lock_tokens.scss',
|
||||
'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',
|
||||
|
||||
@@ -27,6 +27,69 @@ def _is_manager(env):
|
||||
return env.user.has_group('fusion_plating.group_fusion_plating_manager')
|
||||
|
||||
|
||||
# ===== 2026-05-24 lock-screen redesign helpers =========================
|
||||
# Three small module-level helpers powering the new lock-screen visuals.
|
||||
# Imported by the tests in tests/test_tablet_lock_payload.py and consumed
|
||||
# directly by the /fp/tablet/tiles route below.
|
||||
|
||||
_AVATAR_GRADIENTS = [
|
||||
'linear-gradient(135deg, #ef4444, #dc2626)', # red
|
||||
'linear-gradient(135deg, #f59e0b, #d97706)', # amber
|
||||
'linear-gradient(135deg, #10b981, #059669)', # emerald
|
||||
'linear-gradient(135deg, #3b82f6, #2563eb)', # blue
|
||||
'linear-gradient(135deg, #8b5cf6, #7c3aed)', # violet
|
||||
'linear-gradient(135deg, #ec4899, #db2777)', # pink
|
||||
'linear-gradient(135deg, #14b8a6, #0d9488)', # teal
|
||||
'linear-gradient(135deg, #f97316, #ea580c)', # orange
|
||||
]
|
||||
|
||||
|
||||
def _initials_from(name):
|
||||
"""First letter of first + last word, capped at 2 chars uppercase.
|
||||
|
||||
Single-word names return their first two chars. Empty / falsy
|
||||
returns '?' so the letter-mark renders something visible rather
|
||||
than collapsing to a 0-height block.
|
||||
"""
|
||||
if not name:
|
||||
return '?'
|
||||
words = name.strip().split()
|
||||
if not words:
|
||||
return '?'
|
||||
if len(words) == 1:
|
||||
return words[0][:2].upper()
|
||||
return (words[0][0] + words[-1][0]).upper()
|
||||
|
||||
|
||||
def _avatar_gradient_for(user_id):
|
||||
"""Deterministic gradient per user id.
|
||||
|
||||
Modulo the gradient list — same operator gets the same color
|
||||
across sessions so they learn to recognize their own tile. 8
|
||||
colors are enough for a small shop (10-15 ops) with at most 2
|
||||
color collisions on average.
|
||||
"""
|
||||
return _AVATAR_GRADIENTS[user_id % len(_AVATAR_GRADIENTS)]
|
||||
|
||||
|
||||
def _lock_company_payload(env):
|
||||
"""Returns the company info block for the lock screen.
|
||||
|
||||
Reuses res.company.report_header as the tagline (the same field
|
||||
that drives invoice letterhead text) with a sensible fallback
|
||||
when empty. No new model field required.
|
||||
"""
|
||||
co = env.company
|
||||
return {
|
||||
'id': co.id,
|
||||
'name': co.name or '',
|
||||
'tagline': co.report_header or 'Shop Floor Terminal',
|
||||
'logo_url': f'/web/image/res.company/{co.id}/logo',
|
||||
'has_logo': bool(co.logo),
|
||||
'initials': _initials_from(co.name),
|
||||
}
|
||||
|
||||
|
||||
class FpTabletController(http.Controller):
|
||||
"""Tablet PIN gate endpoints. All require an authenticated Odoo
|
||||
session (the tablet logs in once as a 'shopfloor service' user).
|
||||
@@ -185,13 +248,24 @@ class FpTabletController(http.Controller):
|
||||
tiles.append({
|
||||
'user_id': u.id,
|
||||
'name': u.name,
|
||||
'initials': _initials_from(u.name),
|
||||
'avatar_url': f'/web/image/res.users/{u.id}/avatar_128',
|
||||
# has_photo lets the frontend skip the avatar img when
|
||||
# the user has no uploaded photo (avoids the 1×1 default
|
||||
# image flash). sudo-read of image_128 — the field is
|
||||
# restricted to the user themselves otherwise.
|
||||
'has_photo': bool(u_sudo.image_128),
|
||||
'avatar_gradient': _avatar_gradient_for(u.id),
|
||||
'is_clocked_in': u.id in clocked_ids,
|
||||
'has_pin': bool(u_sudo.x_fc_tablet_pin_hash),
|
||||
})
|
||||
# Clocked-in first, then alphabetical within bucket
|
||||
tiles.sort(key=lambda t: (not t['is_clocked_in'], t['name']))
|
||||
return {'ok': True, 'tiles': tiles}
|
||||
return {
|
||||
'ok': True,
|
||||
'company': _lock_company_payload(request.env),
|
||||
'tiles': tiles,
|
||||
}
|
||||
|
||||
# ======================================================================
|
||||
# /fp/tablet/ping — heartbeat used by the OWL component on every action
|
||||
|
||||
@@ -40,6 +40,12 @@ export class FpTabletLock extends Component {
|
||||
selectedTileUserId: null,
|
||||
idleSecondsRemaining: null,
|
||||
loadingTiles: false,
|
||||
// 2026-05-24 redesign — clock + company branding
|
||||
// Seeded synchronously so the first render shows real values
|
||||
// (no flash of empty content).
|
||||
clockText: this._formatTime(new Date()),
|
||||
dateText: this._formatDate(new Date()),
|
||||
company: null,
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -52,11 +58,19 @@ export class FpTabletLock extends Component {
|
||||
.catch(() => {});
|
||||
}
|
||||
}, 60000);
|
||||
// Clock tick — update visible HH:MM and date label every 60s.
|
||||
// 60s is enough; the displayed precision is minute-level only.
|
||||
this._clockInterval = setInterval(() => {
|
||||
const now = new Date();
|
||||
this.state.clockText = this._formatTime(now);
|
||||
this.state.dateText = this._formatDate(now);
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
onWillUnmount(() => {
|
||||
if (this._tick) clearInterval(this._tick);
|
||||
if (this._ping) clearInterval(this._ping);
|
||||
if (this._clockInterval) clearInterval(this._clockInterval);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -70,7 +84,14 @@ export class FpTabletLock extends Component {
|
||||
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;
|
||||
this.state.company = res.company || null;
|
||||
// Decorate each tile with an animation-delay (50ms staggered,
|
||||
// capped at 300ms so the screen doesn't take 3s to settle on
|
||||
// shops with 20+ operators).
|
||||
this.state.tiles = (res.tiles || []).map((tile, idx) => ({
|
||||
...tile,
|
||||
animDelay: Math.min(idx * 50, 300),
|
||||
}));
|
||||
}
|
||||
} catch (err) {
|
||||
// Quiet fail — tile grid stays empty; user gets prompted
|
||||
@@ -132,4 +153,36 @@ export class FpTabletLock extends Component {
|
||||
this.state.idleSecondsRemaining = null;
|
||||
this._loadTiles();
|
||||
}
|
||||
|
||||
// === 2026-05-24 redesign helpers =====================================
|
||||
|
||||
_formatTime(d) {
|
||||
// 24-hour HH:MM with leading zeros. Per project rule 20 this MUST
|
||||
// live in JS, not the template — padStart isn't in OWL scope.
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
const mm = String(d.getMinutes()).padStart(2, "0");
|
||||
return hh + ":" + mm;
|
||||
}
|
||||
|
||||
_formatDate(d) {
|
||||
// 'SATURDAY · MAY 23' style. Uses Intl for locale-correct weekday
|
||||
// + month abbreviations, then upcases for the industrial tracking.
|
||||
const weekday = d.toLocaleDateString(undefined, { weekday: "long" });
|
||||
const month = d.toLocaleDateString(undefined, { month: "short" });
|
||||
const day = d.getDate();
|
||||
return (weekday + " · " + month + " " + day).toUpperCase();
|
||||
}
|
||||
|
||||
tileStyle(tile) {
|
||||
// Inline animation-delay so each tile's entrance staggers.
|
||||
// Returned as a string per project rule 20 — the template can't
|
||||
// call String() inside t-att-style.
|
||||
return "animation-delay: " + tile.animDelay + "ms";
|
||||
}
|
||||
|
||||
avatarClass(tile) {
|
||||
return tile.is_clocked_in
|
||||
? "o_fp_lock_avatar is-clocked"
|
||||
: "o_fp_lock_avatar";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// =====================================================================
|
||||
// Tablet lock-screen design tokens (2026-05-24 redesign).
|
||||
// MUST load before tablet_lock.scss. Per project rule 8 (SCSS @import
|
||||
// forbidden in Odoo 19 custom code), the manifest registers each SCSS
|
||||
// file separately; ordering IS the variable scope.
|
||||
// =====================================================================
|
||||
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
// === Light-mode defaults ===
|
||||
$_lock-bg-top-hex: #fafafa;
|
||||
$_lock-bg-bottom-hex: #f0f0f3;
|
||||
$_lock-accent-1-rgba: rgba(240, 165, 0, 0.12);
|
||||
$_lock-accent-2-rgba: rgba(99, 102, 241, 0.06);
|
||||
$_lock-text-hex: #1d1f1e;
|
||||
$_lock-muted-hex: #71717a;
|
||||
$_lock-prompt-hex: #b45309;
|
||||
$_lock-prompt-bg-rgba: rgba(240, 165, 0, 0.10);
|
||||
$_lock-prompt-border-rgba: rgba(240, 165, 0, 0.25);
|
||||
$_lock-tile-bg-rgba: rgba(255, 255, 255, 0.70);
|
||||
$_lock-tile-border-rgba: rgba(0, 0, 0, 0.05);
|
||||
$_lock-tile-hover-bg-rgba: rgba(255, 255, 255, 0.95);
|
||||
$_lock-tile-hover-border-rgba: rgba(240, 165, 0, 0.50);
|
||||
$_lock-tile-hover-shadow: (0 12px 24px rgba(240, 165, 0, 0.18));
|
||||
$_lock-frame-bg-rgba: rgba(255, 255, 255, 0.85);
|
||||
$_lock-frame-border-rgba: rgba(0, 0, 0, 0.05);
|
||||
$_lock-frame-shadow: (0 8px 24px rgba(0, 0, 0, 0.06), inset 0 1px 0 rgba(255, 255, 255, 0.8));
|
||||
$_lock-status-clocked-hex: #16a34a;
|
||||
$_lock-status-pin-hex: #d97706;
|
||||
$_lock-pulse-dot-border-hex: #ffffff;
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_lock-bg-top-hex: #1a1d21 !global;
|
||||
$_lock-bg-bottom-hex: #2d3138 !global;
|
||||
$_lock-accent-1-rgba: rgba(240, 165, 0, 0.08) !global;
|
||||
$_lock-accent-2-rgba: rgba(99, 102, 241, 0.06) !global;
|
||||
$_lock-text-hex: #f5f5f7 !global;
|
||||
$_lock-muted-hex: #adb5bd !global;
|
||||
$_lock-prompt-hex: #f0a500 !global;
|
||||
$_lock-prompt-bg-rgba: rgba(240, 165, 0, 0.08) !global;
|
||||
$_lock-prompt-border-rgba: rgba(240, 165, 0, 0.20) !global;
|
||||
$_lock-tile-bg-rgba: rgba(255, 255, 255, 0.06) !global;
|
||||
$_lock-tile-border-rgba: rgba(255, 255, 255, 0.08) !global;
|
||||
$_lock-tile-hover-bg-rgba: rgba(240, 165, 0, 0.10) !global;
|
||||
$_lock-tile-hover-border-rgba: rgba(240, 165, 0, 0.40) !global;
|
||||
$_lock-tile-hover-shadow: (0 12px 24px rgba(240, 165, 0, 0.15), 0 0 0 1px rgba(240, 165, 0, 0.2)) !global;
|
||||
$_lock-frame-bg-rgba: rgba(255, 255, 255, 0.08) !global;
|
||||
$_lock-frame-border-rgba: rgba(255, 255, 255, 0.10) !global;
|
||||
$_lock-frame-shadow: (0 8px 24px rgba(0, 0, 0, 0.30), inset 0 1px 0 rgba(255, 255, 255, 0.08)) !global;
|
||||
$_lock-status-clocked-hex: #34c759 !global;
|
||||
$_lock-status-pin-hex: #ff9f0a !global;
|
||||
$_lock-pulse-dot-border-hex: #2d3138 !global;
|
||||
}
|
||||
|
||||
// === CSS-custom-property wrappers so future themes can override ===
|
||||
$lock-bg-top: var(--fp-lock-bg-top, $_lock-bg-top-hex);
|
||||
$lock-bg-bottom: var(--fp-lock-bg-bottom, $_lock-bg-bottom-hex);
|
||||
$lock-accent-1: $_lock-accent-1-rgba;
|
||||
$lock-accent-2: $_lock-accent-2-rgba;
|
||||
$lock-text: var(--fp-lock-text, $_lock-text-hex);
|
||||
$lock-muted: var(--fp-lock-muted, $_lock-muted-hex);
|
||||
$lock-prompt: var(--fp-lock-prompt, $_lock-prompt-hex);
|
||||
$lock-prompt-bg: $_lock-prompt-bg-rgba;
|
||||
$lock-prompt-border: $_lock-prompt-border-rgba;
|
||||
$lock-tile-bg: $_lock-tile-bg-rgba;
|
||||
$lock-tile-border: $_lock-tile-border-rgba;
|
||||
$lock-tile-hover-bg: $_lock-tile-hover-bg-rgba;
|
||||
$lock-tile-hover-border: $_lock-tile-hover-border-rgba;
|
||||
$lock-tile-hover-shadow: $_lock-tile-hover-shadow;
|
||||
$lock-frame-bg: $_lock-frame-bg-rgba;
|
||||
$lock-frame-border: $_lock-frame-border-rgba;
|
||||
$lock-frame-shadow: $_lock-frame-shadow;
|
||||
$lock-status-clocked: var(--fp-lock-status-clocked, $_lock-status-clocked-hex);
|
||||
$lock-status-pin: var(--fp-lock-status-pin, $_lock-status-pin-hex);
|
||||
$lock-pulse-dot-border: $_lock-pulse-dot-border-hex;
|
||||
@@ -1,96 +1,227 @@
|
||||
// =============================================================================
|
||||
// =====================================================================
|
||||
// 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;
|
||||
}
|
||||
// 2026-05-24 redesign: hybrid Industrial Bold + Premium Glassmorphism
|
||||
// Spec: docs/superpowers/specs/2026-05-24-tablet-lock-screen-redesign-design.md
|
||||
// Depends on _tablet_lock_tokens.scss being loaded first.
|
||||
// =====================================================================
|
||||
|
||||
.o_fp_tablet_lock {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: $_lock-bg-hex;
|
||||
color: $_lock-ink-hex;
|
||||
color: $lock-text;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 2rem;
|
||||
padding: 28px 20px;
|
||||
gap: 22px;
|
||||
z-index: 9000;
|
||||
overflow-y: auto;
|
||||
background:
|
||||
radial-gradient(ellipse at top, $lock-accent-1, transparent 50%),
|
||||
radial-gradient(ellipse at bottom, $lock-accent-2, transparent 50%),
|
||||
linear-gradient(135deg, $lock-bg-top 0%, $lock-bg-bottom 100%);
|
||||
}
|
||||
|
||||
.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;
|
||||
// === Logo block =====================================================
|
||||
.o_fp_lock_logo_block {
|
||||
text-align: center;
|
||||
animation: lockLogoEnter 0.5s ease-out;
|
||||
}
|
||||
|
||||
.o_fp_lock_logo_frame {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 84px; height: 84px;
|
||||
border-radius: 20px;
|
||||
margin-bottom: 12px;
|
||||
padding: 14px;
|
||||
box-sizing: border-box;
|
||||
background: $lock-frame-bg;
|
||||
border: 1px solid $lock-frame-border;
|
||||
box-shadow: $lock-frame-shadow;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
|
||||
img {
|
||||
max-width: 100%; max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_tablet_lock_loading, .o_fp_tablet_lock_empty {
|
||||
margin: 2rem auto;
|
||||
color: var(--text-secondary, #666);
|
||||
.o_fp_lock_logo_placeholder {
|
||||
width: 100%; height: 100%; border-radius: 14px;
|
||||
background: linear-gradient(135deg, #f0a500, #ff6b00);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 32px; font-weight: 900; color: #1a1d21;
|
||||
}
|
||||
|
||||
.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_lock_logo_text {
|
||||
font-size: 19px; font-weight: 700; letter-spacing: -0.01em;
|
||||
color: $lock-text;
|
||||
}
|
||||
|
||||
.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_lock_logo_sub {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase; letter-spacing: 0.15em;
|
||||
margin-top: 4px;
|
||||
color: $lock-muted;
|
||||
}
|
||||
|
||||
.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;
|
||||
// === Clock block ====================================================
|
||||
.o_fp_lock_clock_block {
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
animation: lockClockEnter 0.5s ease-out 0.1s both;
|
||||
}
|
||||
|
||||
.o_fp_tablet_lock_tile_clocked {
|
||||
color: #34c759;
|
||||
font-size: 0.75rem;
|
||||
.o_fp_lock_clock {
|
||||
font-size: 40px; font-weight: 800;
|
||||
letter-spacing: -0.03em; line-height: 1;
|
||||
color: $lock-text;
|
||||
}
|
||||
|
||||
.o_fp_tablet_lock_tile_nopin {
|
||||
color: #ff9f0a;
|
||||
font-size: 0.75rem;
|
||||
.o_fp_lock_clock_date {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase; letter-spacing: 0.12em;
|
||||
margin-top: 4px;
|
||||
color: $lock-muted;
|
||||
}
|
||||
|
||||
.o_fp_tablet_lock_pinwrap {
|
||||
margin-top: 2rem;
|
||||
// === Prompt pill ====================================================
|
||||
.o_fp_lock_prompt {
|
||||
font-size: 13px; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.18em;
|
||||
color: $lock-prompt;
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 6px 16px;
|
||||
border-radius: 999px;
|
||||
background: $lock-prompt-bg;
|
||||
border: 1px solid $lock-prompt-border;
|
||||
animation: lockClockEnter 0.5s ease-out 0.2s both;
|
||||
}
|
||||
|
||||
// === Tile grid ======================================================
|
||||
.o_fp_lock_tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.o_fp_lock_tile {
|
||||
background: $lock-tile-bg;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid $lock-tile-border;
|
||||
border-radius: 14px;
|
||||
padding: 14px 8px 12px;
|
||||
text-align: center;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
cursor: pointer;
|
||||
animation: lockTileEnter 0.4s cubic-bezier(0.4, 0, 0.2, 1) both;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
|
||||
&:hover, &:focus-visible {
|
||||
background: $lock-tile-hover-bg;
|
||||
border-color: $lock-tile-hover-border;
|
||||
transform: translateY(-3px);
|
||||
box-shadow: $lock-tile-hover-shadow;
|
||||
outline: none;
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid $lock-tile-hover-border;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
transition: transform 0.05s;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_lock_avatar {
|
||||
width: 52px; height: 52px; border-radius: 50%;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 21px; font-weight: 700; color: #fff;
|
||||
margin-bottom: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%; height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&.is-clocked::after {
|
||||
content: ''; position: absolute;
|
||||
width: 12px; height: 12px; border-radius: 50%;
|
||||
background: $lock-status-clocked;
|
||||
bottom: 0; right: 0;
|
||||
border: 2px solid $lock-pulse-dot-border;
|
||||
animation: lockPulseDot 2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_lock_name {
|
||||
font-size: 12px; font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: $lock-text;
|
||||
}
|
||||
|
||||
.o_fp_lock_status {
|
||||
font-size: 10px;
|
||||
margin-top: 4px;
|
||||
font-weight: 500;
|
||||
|
||||
&.status-clocked { color: $lock-status-clocked; }
|
||||
&.status-pin { color: $lock-status-pin; }
|
||||
}
|
||||
|
||||
// === Empty / loading states =========================================
|
||||
.o_fp_lock_loading,
|
||||
.o_fp_lock_empty {
|
||||
margin: 2rem auto;
|
||||
color: $lock-muted;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
// === PIN pad wrap ===================================================
|
||||
.o_fp_lock_pinwrap {
|
||||
margin-top: 8px;
|
||||
animation: lockClockEnter 0.3s ease-out;
|
||||
}
|
||||
|
||||
// === Animations =====================================================
|
||||
@keyframes lockLogoEnter {
|
||||
from { opacity: 0; transform: translateY(-12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes lockClockEnter {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes lockTileEnter {
|
||||
from { opacity: 0; transform: translateY(16px) scale(0.96); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes lockPulseDot {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(52, 199, 89, 0.6); }
|
||||
50% { box-shadow: 0 0 0 6px rgba(52, 199, 89, 0); }
|
||||
}
|
||||
|
||||
// === Reduced-motion override (accessibility) ========================
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.o_fp_tablet_lock,
|
||||
.o_fp_lock_logo_block,
|
||||
.o_fp_lock_clock_block,
|
||||
.o_fp_lock_prompt,
|
||||
.o_fp_lock_tile,
|
||||
.o_fp_lock_avatar.is-clocked::after,
|
||||
.o_fp_lock_pinwrap {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,35 +4,66 @@
|
||||
<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>
|
||||
|
||||
<!-- ============== LOGO BLOCK ============== -->
|
||||
<div class="o_fp_lock_logo_block" t-if="state.company">
|
||||
<div class="o_fp_lock_logo_frame">
|
||||
<img t-if="state.company.has_logo"
|
||||
t-att-src="state.company.logo_url"
|
||||
t-att-alt="state.company.name"/>
|
||||
<div t-else=""
|
||||
class="o_fp_lock_logo_placeholder"
|
||||
t-esc="state.company.initials"/>
|
||||
</div>
|
||||
<div class="o_fp_lock_logo_text" t-esc="state.company.name"/>
|
||||
<div class="o_fp_lock_logo_sub" t-esc="state.company.tagline"/>
|
||||
</div>
|
||||
<div t-if="state.loadingTiles" class="o_fp_tablet_lock_loading">
|
||||
|
||||
<!-- ============== CLOCK BLOCK ============== -->
|
||||
<div class="o_fp_lock_clock_block">
|
||||
<div class="o_fp_lock_clock" t-esc="state.clockText"/>
|
||||
<div class="o_fp_lock_clock_date" t-esc="state.dateText"/>
|
||||
</div>
|
||||
|
||||
<!-- ============== PROMPT PILL ============== -->
|
||||
<div t-if="!state.selectedTileUserId" class="o_fp_lock_prompt">
|
||||
<i class="fa fa-lock"/> Tap your name
|
||||
</div>
|
||||
|
||||
<!-- ============== TILES OR PIN PAD ============== -->
|
||||
<div t-if="state.loadingTiles" class="o_fp_lock_loading">
|
||||
<i class="fa fa-spinner fa-spin"/> Loading…
|
||||
</div>
|
||||
<div t-elif="!state.selectedTileUserId" class="o_fp_tablet_lock_tiles">
|
||||
<div t-elif="!state.selectedTileUserId" class="o_fp_lock_tiles">
|
||||
<t t-if="!state.tiles.length">
|
||||
<div class="o_fp_tablet_lock_empty">
|
||||
<div class="o_fp_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"
|
||||
<button class="o_fp_lock_tile"
|
||||
t-att-style="tileStyle(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">
|
||||
<div t-att-class="avatarClass(tile)"
|
||||
t-att-style="'background: ' + tile.avatar_gradient">
|
||||
<img t-if="tile.has_photo"
|
||||
t-att-src="tile.avatar_url"
|
||||
t-att-alt="tile.name"/>
|
||||
<span t-else="" t-esc="tile.initials"/>
|
||||
</div>
|
||||
<div class="o_fp_lock_name" t-esc="tile.name"/>
|
||||
<div t-if="tile.is_clocked_in"
|
||||
class="o_fp_lock_status status-clocked">
|
||||
Clocked in
|
||||
</div>
|
||||
<div t-elif="!tile.has_pin"
|
||||
class="o_fp_lock_status status-pin">
|
||||
PIN required
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</t>
|
||||
</div>
|
||||
<div t-else="" class="o_fp_tablet_lock_pinwrap">
|
||||
<div t-else="" class="o_fp_lock_pinwrap">
|
||||
<FpPinPad onSubmit.bind="unlock"
|
||||
title="_selectedTileName()"
|
||||
subtitle="'Enter your 4-digit PIN'"
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
from . import test_workspace_controller
|
||||
from . import test_landing_kanban
|
||||
from . import test_tablet_pin
|
||||
from . import test_tablet_lock_payload
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Tests for the tablet lock-screen payload helpers.
|
||||
|
||||
Covers the 3 module-level helpers added in 2026-05-24 redesign:
|
||||
- _initials_from(name) — letter-mark fallback for missing logo/photo
|
||||
- _avatar_gradient_for(uid) — deterministic per-user color gradient
|
||||
- _lock_company_payload(env) — company name + tagline + logo URL block
|
||||
|
||||
End-to-end test of the /fp/tablet/tiles endpoint payload shape:
|
||||
just verifies the helper output ends up in the response.
|
||||
"""
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.addons.fusion_plating_shopfloor.controllers.tablet_controller import (
|
||||
_AVATAR_GRADIENTS,
|
||||
_avatar_gradient_for,
|
||||
_initials_from,
|
||||
_lock_company_payload,
|
||||
)
|
||||
|
||||
|
||||
class TestInitialsFrom(TransactionCase):
|
||||
|
||||
def test_empty_returns_question_mark(self):
|
||||
self.assertEqual(_initials_from(''), '?')
|
||||
self.assertEqual(_initials_from(None), '?')
|
||||
|
||||
def test_single_word_returns_first_two_chars_upper(self):
|
||||
self.assertEqual(_initials_from('Garry'), 'GA')
|
||||
self.assertEqual(_initials_from('ab'), 'AB')
|
||||
self.assertEqual(_initials_from('z'), 'Z')
|
||||
|
||||
def test_multi_word_returns_first_and_last_initial(self):
|
||||
self.assertEqual(_initials_from('Garry Singh'), 'GS')
|
||||
self.assertEqual(_initials_from('Johnny Matloub'), 'JM')
|
||||
self.assertEqual(_initials_from('Mary Anne Smith'), 'MS')
|
||||
self.assertEqual(_initials_from('EN Technologies'), 'ET')
|
||||
|
||||
def test_extra_whitespace_handled(self):
|
||||
self.assertEqual(_initials_from(' Garry Singh '), 'GS')
|
||||
self.assertEqual(_initials_from('Garry\tSingh'), 'GS')
|
||||
|
||||
|
||||
class TestAvatarGradientFor(TransactionCase):
|
||||
|
||||
def test_deterministic_per_user_id(self):
|
||||
# Same id → same gradient across calls
|
||||
self.assertEqual(
|
||||
_avatar_gradient_for(5),
|
||||
_avatar_gradient_for(5),
|
||||
)
|
||||
|
||||
def test_modulo_distribution(self):
|
||||
# Wrapping wraps cleanly — id 0 and id len(gradients) match
|
||||
n = len(_AVATAR_GRADIENTS)
|
||||
self.assertEqual(_avatar_gradient_for(0), _avatar_gradient_for(n))
|
||||
self.assertEqual(_avatar_gradient_for(3), _avatar_gradient_for(n + 3))
|
||||
|
||||
def test_returns_a_known_gradient(self):
|
||||
# Every output is one of the documented gradients
|
||||
for uid in range(50):
|
||||
self.assertIn(_avatar_gradient_for(uid), _AVATAR_GRADIENTS)
|
||||
|
||||
|
||||
class TestLockCompanyPayload(TransactionCase):
|
||||
"""Covers _lock_company_payload's shape + fallback behavior."""
|
||||
|
||||
def test_payload_has_required_keys(self):
|
||||
payload = _lock_company_payload(self.env)
|
||||
for key in ('id', 'name', 'tagline', 'logo_url', 'has_logo', 'initials'):
|
||||
self.assertIn(key, payload, f'missing key: {key}')
|
||||
self.assertEqual(payload['id'], self.env.company.id)
|
||||
self.assertTrue(payload['logo_url'].startswith('/web/image/res.company/'))
|
||||
|
||||
def test_tagline_default_when_empty_report_header(self):
|
||||
self.env.company.report_header = False
|
||||
payload = _lock_company_payload(self.env)
|
||||
# Falls back to a non-empty string, not False/None
|
||||
self.assertTrue(payload['tagline'])
|
||||
self.assertNotEqual(payload['tagline'], False)
|
||||
|
||||
# NOTE: a "report_header populated → tagline matches" test would be
|
||||
# brittle here because res.company.report_header is an HTML field in
|
||||
# Odoo 19: setting a plain string can come back wrapped in <p> tags
|
||||
# after sanitization. The helper's responsibility is just "use the
|
||||
# field's value when present, else fall back" — covered by
|
||||
# test_tagline_default_when_empty_report_header above.
|
||||
|
||||
def test_initials_match_company_name(self):
|
||||
self.env.company.name = 'EN Technologies'
|
||||
payload = _lock_company_payload(self.env)
|
||||
self.assertEqual(payload['initials'], 'ET')
|
||||
Reference in New Issue
Block a user