redesign(shopfloor): clean slate — depth by shadow, no card borders

User feedback: the previous gradient-heavy look felt cluttered, job
cards had confusing heavy borders, the hierarchy was noisy. Wiped all
three SCSS files and both OWL templates and rebuilt from scratch with
a clean minimalist design language.

Design philosophy — the single source of truth:

  * NO borders on cards — depth comes from elevation (shadow) + a
    tiny surface-tint difference between page and card
  * ONE accent colour (var(--o-action)); semantic red/amber/green only
    for status pills and state bars
  * Shadow-only cards: $fp-elev-1, $fp-elev-2, $fp-elev-3 built on
    color-mix of foreground so they adapt to dark mode automatically
  * Generous whitespace, 8pt spacing scale ($fp-space-1 through
    $fp-space-10)
  * Type-first hierarchy: 32px page titles, 44px KPI numbers, tabular
    numerics so refreshing counts don't jitter
  * Priority/state cues via narrow 4-6px coloured bars and small dots
    — never via loud backgrounds or gradient washes
  * All interactive elements at 48px touch minimum (shop-floor gloves)

New token file (_fp_shopfloor_tokens.scss) exports:
  - $fp-space-1..10, $fp-radius-sm..xl, $fp-radius-pill
  - $fp-page / $fp-card / $fp-card-soft surface tints
  - $fp-ink / $fp-ink-soft / $fp-ink-mute / $fp-ink-faint text tiers
  - $fp-elev-1..3 layered shadows
  - $fp-text-xs..3xl type scale
  - @mixin fp-pill, fp-focus-ring, fp-card, fp-hover-only
  - fp-wash() function for state-coloured soft backgrounds

Tablet Station (fusion_plating_shopfloor.scss + shopfloor_tablet.xml):
  - Clean hero: just the title, station chip, picker + scan button
  - KPI cards: no gradient overlay, just a 10px coloured dot and big
    44px number. Hover lifts with shadow
  - Active WO: soft green wash background, no border, pulsing dot
  - Panels contain queue/baths/bakes/gates/holds — all on the same
    card surface with big rounded corners, no internal borders
  - Queue rows: flat on a soft page-tinted background, hover slides
    right 2px (no lift, cleaner)
  - Bake/Gate/Hold rows: state-coloured inset shadow as a 4px stripe,
    no border
  - Empty states: centred with a 44px muted icon and friendly copy

Manager Desk (manager_dashboard.scss + manager_dashboard.xml):
  - Matching hero with live dot that calmly pulses green during a fetch
  - 4 KPI cards in the same language as the tablet
  - Three panels (Unassigned / In Progress / Team) with coloured dots
    next to their titles instead of top accent bars
  - MO cards NO borders, subtle page-tint background, 4px left stripe
    only for priority (red HOT, amber Urgent)
  - Team cards: avatar + name + live load pill, hover slides right
  - WO expanded rows use card-soft buttons/dropdowns for low contrast

Plant Overview (plant_overview.scss):
  - Columns are now shadow-lifted cards on the tinted page background
  - Kanban cards: no border, small shadow, lift on hover
  - Priority stripe is an inset box-shadow (not a border) so hover
    transform doesn't wobble

Backend contract preserved — OWL class names, prop signatures, RPC
endpoints, and stateBadge mapping all unchanged. Only visuals.

Verified:
  * Bundle compiled to /web/assets/.../web.assets_backend.min.css
    (1.45MB, id 1926)
  * All 6 new classes present in compiled CSS
  * Zero SCSS "forbidden import" warnings
  * Zero Odoo module upgrade errors

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-18 18:45:16 -04:00
parent 6d1efc6c43
commit 067d1f01c8
6 changed files with 980 additions and 1361 deletions

View File

@@ -1,115 +1,131 @@
// ============================================================================= // =============================================================================
// Fusion Plating — Shop-Floor Design Tokens // Fusion Plating — Shop Floor Design System (v2, 2026-04)
// Copyright 2026 Nexa Systems Inc. // Copyright 2026 Nexa Systems Inc. · License OPL-1
// License OPL-1 (Odoo Proprietary License v1.0)
// //
// Single source of truth for the look-and-feel of every shop-floor OWL page. // Design philosophy:
// All values use CSS custom properties (--bs-*, --o-*) so the tokens adapt // * NO card borders — depth comes from elevation (shadow) only
// automatically between Odoo's light and dark themes — no media queries or // * Generous whitespace, calm surfaces, one accent colour
// duplicate palettes. // * Semantic colours (success/warning/danger) reserved for STATUS — not
// // decoration
// Gradients use `color-mix(in srgb, var(--bs-foo) X%, transparent)` because // * Type-first hierarchy: big headings + big numbers + small helpers
// transparent mixes with the page background, which already obeys the theme. // * Every value resolves from Odoo CSS custom properties, so light
// and dark themes work without duplicate palettes
// ============================================================================= // =============================================================================
// ---------- Spacing scale (8-pt baseline) ------------------------------------
$fp-space-1 : 4px;
$fp-space-2 : 8px;
$fp-space-3 : 12px;
$fp-space-4 : 16px;
$fp-space-5 : 20px;
$fp-space-6 : 24px;
$fp-space-7 : 32px;
$fp-space-8 : 40px;
$fp-space-9 : 48px;
$fp-space-10 : 64px;
// ---------- Radii ------------------------------------------------------------ // ---------- Radius -----------------------------------------------------------
$fp-radius-sm : 8px; $fp-radius-sm : 10px;
$fp-radius-md : 12px; $fp-radius-md : 14px;
$fp-radius-lg : 16px; $fp-radius-lg : 20px;
$fp-radius-xl : 20px; $fp-radius-xl : 28px;
$fp-radius-pill : 999px; $fp-radius-pill: 999px;
// ---------- Surfaces — depth by TINT, not by border --------------------------
// The page gets a slightly tinted background; cards sit on a lighter
// surface. That tint difference replaces the need for card borders.
$fp-page : color-mix(in srgb, var(--bs-body-color) 2.5%,
var(--o-view-background-color, var(--bs-body-bg)));
$fp-card : var(--o-view-background-color, var(--bs-body-bg));
$fp-card-soft : color-mix(in srgb, var(--bs-body-color) 4%,
var(--o-view-background-color, var(--bs-body-bg)));
// ---------- Elevation (shadows that respect dark mode) ----------------------- // ---------- Text tiers -------------------------------------------------------
// Shadows are built on the page's foreground colour so they darken in light $fp-ink : var(--bs-body-color);
// mode and deepen (but never go pure black) in dark mode. $fp-ink-soft : color-mix(in srgb, var(--bs-body-color) 70%, transparent);
$fp-shadow-xs : 0 1px 2px color-mix(in srgb, var(--bs-body-color) 6%, transparent); $fp-ink-mute : color-mix(in srgb, var(--bs-body-color) 48%, transparent);
$fp-shadow-sm : 0 2px 6px color-mix(in srgb, var(--bs-body-color) 9%, transparent); $fp-ink-faint : color-mix(in srgb, var(--bs-body-color) 28%, transparent);
$fp-shadow-md : 0 4px 14px color-mix(in srgb, var(--bs-body-color) 10%, transparent),
0 1px 3px color-mix(in srgb, var(--bs-body-color) 6%, transparent);
$fp-shadow-lg : 0 10px 28px color-mix(in srgb, var(--bs-body-color) 14%, transparent),
0 2px 6px color-mix(in srgb, var(--bs-body-color) 6%, transparent);
$fp-shadow-glow-primary : 0 0 0 4px color-mix(in srgb, var(--o-action) 12%, transparent);
// ---------- Elevation — soft, layered, theme-safe ----------------------------
// Shadows built on the foreground colour so they darken appropriately in
// light mode and show a subtle halo in dark mode.
$fp-elev-1 : 0 1px 2px color-mix(in srgb, var(--bs-body-color) 5%, transparent),
0 1px 3px color-mix(in srgb, var(--bs-body-color) 7%, transparent);
$fp-elev-2 : 0 2px 4px color-mix(in srgb, var(--bs-body-color) 6%, transparent),
0 6px 14px color-mix(in srgb, var(--bs-body-color) 9%, transparent);
$fp-elev-3 : 0 4px 8px color-mix(in srgb, var(--bs-body-color) 8%, transparent),
0 12px 28px color-mix(in srgb, var(--bs-body-color) 12%, transparent);
$fp-elev-hover : 0 6px 12px color-mix(in srgb, var(--bs-body-color) 10%, transparent),
0 18px 36px color-mix(in srgb, var(--bs-body-color) 14%, transparent);
// ---------- Surface layering ------------------------------------------------- // ---------- Semantic colour helpers (NOT gradients) --------------------------
// A "raised" surface tint — sits slightly above the base background. Uses $fp-accent : var(--o-action); // the one action colour
// the foreground colour to tint so it deepens correctly in dark mode. $fp-ok : var(--bs-success);
$fp-surface : var(--o-view-background-color, var(--bs-body-bg)); $fp-warn : var(--bs-warning);
$fp-surface-raised : color-mix(in srgb, $fp-bad : var(--bs-danger);
var(--bs-body-color) 2%, $fp-info : var(--bs-info);
var(--o-view-background-color, var(--bs-body-bg)));
$fp-surface-sunken : color-mix(in srgb,
var(--bs-body-color) 4%,
var(--o-view-background-color, var(--bs-body-bg)));
// Softened backgrounds for status pills / banners
@function fp-wash($color-var, $strength: 12%) {
@return color-mix(in srgb, var(#{$color-var}) #{$strength}, transparent);
}
// ---------- Border-tints ----------------------------------------------------- // ---------- Type scale ------------------------------------------------------
$fp-border : var(--bs-border-color); // Shop-floor tablets are read from 18" — baseline bumped from Odoo default.
$fp-border-strong : color-mix(in srgb, var(--bs-body-color) 18%, transparent); $fp-text-xs : 0.75rem; // 12px small labels
$fp-border-accent : color-mix(in srgb, var(--o-action) 40%, var(--bs-border-color)); $fp-text-sm : 0.875rem; // 14px helper text
$fp-text-base : 1rem; // 16px body
$fp-text-md : 1.125rem; // 18px emphasis
$fp-text-lg : 1.25rem; // 20px sub-headings
$fp-text-xl : 1.5rem; // 24px section headings
$fp-text-2xl : 2rem; // 32px page title
$fp-text-3xl : 2.75rem; // 44px KPI number
$fp-text-4xl : clamp(2rem, 5vw, 3rem); // hero
$fp-weight-medium : 500;
$fp-weight-semibold : 600;
$fp-weight-bold : 700;
// ---------- Typography ------------------------------------------------------- $fp-font-stack : -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
// Shop-floor tablets and phones get read from 18" away — bumps over Odoo's "Inter", "Helvetica Neue", Arial, sans-serif;
// default body size. Numbers are tabular so KPIs don't jitter on refresh.
$fp-font-base : 1rem; // 16px
$fp-font-sm : 0.875rem; // 14px
$fp-font-xs : 0.78rem; // 12.5px
$fp-font-lg : 1.125rem; // 18px
$fp-font-xl : 1.375rem; // 22px
$fp-font-2xl : 1.75rem; // 28px
$fp-font-3xl : 2.25rem; // 36px
$fp-font-kpi : 2.5rem; // 40px — headline number
$fp-font-hero : clamp(1.5rem, 3.5vw, 2.25rem);
// ---------- Motion -----------------------------------------------------------
// ---------- Animation --------------------------------------------------------
$fp-ease : cubic-bezier(0.22, 1, 0.36, 1); $fp-ease : cubic-bezier(0.22, 1, 0.36, 1);
$fp-ease-snap : cubic-bezier(0.34, 1.56, 0.64, 1); $fp-ease-out : cubic-bezier(0.33, 1, 0.68, 1);
$fp-dur-fast : 140ms; $fp-dur-fast : 120ms;
$fp-dur : 220ms; $fp-dur : 200ms;
$fp-dur-slow : 360ms; $fp-dur-slow : 360ms;
// ---------- Touch ------------------------------------------------------------
// ---------- Semantic gradients (tone-based, theme-safe) ---------------------- $fp-touch-min : 48px; // larger than Apple's 44px minimum — shop floor
// Each tone mixes the named Bootstrap token with transparent so it lays
// naturally over whatever page background it's on — light OR dark.
@mixin fp-grad($color-var, $strong: 18%, $soft: 6%) {
background-image: linear-gradient(
135deg,
color-mix(in srgb, var(#{$color-var}) #{$strong}, transparent) 0%,
color-mix(in srgb, var(#{$color-var}) #{$soft}, transparent) 100%
);
}
@mixin fp-grad-solid($color-var, $from: 100%, $to: 75%) {
background-image: linear-gradient(
135deg,
color-mix(in srgb, var(#{$color-var}) #{$from}, transparent) 0%,
color-mix(in srgb, var(#{$color-var}) #{$to}, transparent) 100%
);
color: var(--o-we-text-on-action, #fff);
}
// ---------- Tonal helpers ---------------------------------------------------- // =============================================================================
// Border + tint combo used on banners / pills — reads as "slightly // Mixins
// saturated panel" in both themes. // =============================================================================
@mixin fp-tone($color-var, $strength: 14%) {
background-color: color-mix(in srgb, var(#{$color-var}) #{$strength}, transparent);
color: var(#{$color-var});
border: 1px solid color-mix(in srgb, var(#{$color-var}) 38%, transparent);
}
// Focus ring — used on all interactive inputs/buttons
// ---------- Focus ring ------------------------------------------------------- @mixin fp-focus-ring {
@mixin fp-focus-ring() {
outline: none; outline: none;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--o-action) 35%, transparent); box-shadow: 0 0 0 3px color-mix(in srgb, #{$fp-accent} 35%, transparent);
} }
// Card surface — shadow-based, no border
@mixin fp-card($elev: $fp-elev-1) {
background-color: $fp-card;
border-radius: $fp-radius-lg;
box-shadow: $elev;
}
// ---------- Touch target ----------------------------------------------------- // Status pill (soft tint + colored text)
$fp-touch-min : 44px; // Apple HIG minimum @mixin fp-pill($color-var) {
background-color: color-mix(in srgb, var(#{$color-var}) 14%, transparent);
color: var(#{$color-var});
}
// Hide hover styles on touch devices (stuck hover = bad UX on phones)
@mixin fp-hover-only {
@media (hover: hover) {
@content;
}
}

View File

@@ -1,383 +1,343 @@
// ============================================================================= // =============================================================================
// Fusion Plating — Manager Desk // Fusion Plating — Manager Desk
// Copyright 2026 Nexa Systems Inc. // Copyright 2026 Nexa Systems Inc. · License OPL-1
// License OPL-1 (Odoo Proprietary License v1.0)
// //
// Shares design tokens + panel / KPI / chip classes from the Tablet Station // Shared tokens from _fp_shopfloor_tokens.scss (loaded first in the bundle).
// SCSS. Only the manager-specific components live here: hero banner with // Shared components re-used from tablet: .o_fp_panel, .o_fp_empty, .o_fp_chip.
// live dot, 3-column workload grid, richer MO cards, gradient avatars. // This file owns only the manager-specific layout.
//
// Variables / mixins come from _fp_shopfloor_tokens.scss — loaded FIRST in
// the asset bundle (see __manifest__.py). No @import; Odoo 19 forbids it.
// ============================================================================= // =============================================================================
// Touch-device hover suppression // --- Hover suppression on touch -----------------------------------------------
@media (hover: none) { @media (hover: none) {
.o_fp_manager .o_fp_mgr_card:hover, .o_fp_manager [class*="o_fp_"]:hover {
.o_fp_manager .o_fp_team_card:hover {
background-color: inherit !important;
border-color: var(--bs-border-color) !important;
transform: none !important; transform: none !important;
box-shadow: inherit !important;
} }
} }
.o_fp_manager { .o_fp_manager {
background-color: $fp-surface-sunken; font-family: $fp-font-stack;
color: var(--bs-body-color); background-color: $fp-page;
color: $fp-ink;
min-height: 100%; min-height: 100%;
padding: 24px 28px; padding: $fp-space-6 $fp-space-7;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 18px; gap: $fp-space-6;
@media (max-width: 900px) { padding: 16px; gap: 14px; } @media (max-width: 900px) { padding: $fp-space-4; gap: $fp-space-4; }
@media (max-width: 600px) { padding: 10px 10px 16px; gap: 10px; } @media (max-width: 600px) { padding: $fp-space-3; gap: $fp-space-4; }
// ========================================================================= // -------------------------------------------------------------------------
// Hero banner — gradient wash, live dot, action cluster // Hero row
// ========================================================================= // -------------------------------------------------------------------------
.o_fp_manager_header { .o_fp_manager_header {
position: relative;
overflow: hidden;
border-radius: $fp-radius-lg;
padding: 20px 24px;
background-color: $fp-surface-raised;
border: 1px solid $fp-border;
box-shadow: $fp-shadow-sm;
&::before {
content: "";
position: absolute;
inset: 0;
background-image:
radial-gradient(circle at 0% 50%,
color-mix(in srgb, var(--o-action) 22%, transparent) 0%,
transparent 55%),
radial-gradient(circle at 100% 0%,
color-mix(in srgb, var(--bs-info) 18%, transparent) 0%,
transparent 55%);
pointer-events: none;
}
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
gap: 16px; gap: $fp-space-5;
align-items: center; align-items: end;
> * { position: relative; z-index: 1; }
@media (max-width: 600px) { @media (max-width: 600px) {
grid-template-columns: 1fr; grid-template-columns: 1fr; gap: $fp-space-3;
gap: 10px;
padding: 14px;
} }
} }
.o_fp_manager_title { .o_fp_manager_title {
font-size: $fp-font-hero; font-size: $fp-text-2xl;
font-weight: 700; font-weight: $fp-weight-bold;
letter-spacing: -0.01em; letter-spacing: -0.02em;
display: inline-flex; align-items: center; gap: 10px; line-height: 1.1;
margin: 0;
color: $fp-ink;
display: inline-flex; align-items: center; gap: $fp-space-3;
}
.o_fp_manager_subtitle {
margin-top: $fp-space-2;
font-size: $fp-text-sm;
color: $fp-ink-mute;
display: flex; flex-wrap: wrap; gap: $fp-space-3; align-items: center;
} }
// Small breathing dot — green at rest, brighter pulse while polling // Live indicator — calm dot that pulses during a fetch
.o_fp_live_dot { .o_fp_live_dot {
display: inline-block; width: 10px; height: 10px;
width: 11px; height: 11px; border-radius: 50%; border-radius: 50%;
background-color: color-mix(in srgb, var(--bs-success) 75%, transparent); background-color: color-mix(in srgb, #{$fp-ok} 70%, transparent);
transition: background-color $fp-dur $fp-ease; transition: background-color $fp-dur $fp-ease;
&[data-active="y"] { &[data-active="y"] {
background-color: var(--bs-success); background-color: $fp-ok;
animation: o_fp_live_pulse 1.1s ease-in-out infinite; animation: o_fp_live_pulse 1.1s ease-in-out infinite;
} }
} }
@keyframes o_fp_live_pulse { @keyframes o_fp_live_pulse {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--bs-success) 60%, transparent); } 0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, #{$fp-ok} 55%, transparent); }
70% { box-shadow: 0 0 0 10px color-mix(in srgb, var(--bs-success) 0%, transparent); } 50% { box-shadow: 0 0 0 8px color-mix(in srgb, #{$fp-ok} 0%, transparent); }
100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--bs-success) 0%, transparent); }
}
.o_fp_manager_subtitle {
font-size: $fp-font-sm;
color: var(--bs-secondary-color);
margin-top: 4px;
} }
.o_fp_manager_head_actions { .o_fp_manager_head_actions {
display: flex; gap: 8px; align-items: center; display: flex; gap: $fp-space-2;
.btn { .btn {
min-height: $fp-touch-min; min-height: $fp-touch-min;
padding: 8px 16px; padding: 0 $fp-space-4;
border-radius: $fp-radius-md; border-radius: $fp-radius-md;
font-weight: 600; font-weight: $fp-weight-semibold;
border: none;
background-color: $fp-card;
color: $fp-ink;
box-shadow: $fp-elev-1;
transition: transform $fp-dur-fast $fp-ease, box-shadow $fp-dur $fp-ease;
@include fp-hover-only { &:hover { box-shadow: $fp-elev-2; } }
&:active { transform: scale(0.97); }
&.btn-primary {
background-color: $fp-accent;
color: var(--o-we-text-on-action, #fff);
}
} }
@media (max-width: 600px) { @media (max-width: 600px) {
width: 100%; flex-wrap: wrap; width: 100%; > .btn { flex: 1; }
> .btn { flex: 1; }
} }
} }
// ========================================================================= // -------------------------------------------------------------------------
// KPI strip — same token system as the tablet // Flash message — reused styling
// ========================================================================= // -------------------------------------------------------------------------
.o_fp_kpi_strip { .o_fp_tablet_message {
display: grid; display: flex; align-items: center; gap: $fp-space-3;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); padding: $fp-space-3 $fp-space-4;
gap: 12px; border-radius: $fp-radius-md;
@media (max-width: 600px) { font-size: $fp-text-base;
grid-template-columns: repeat(2, 1fr); font-weight: $fp-weight-medium;
gap: 8px; background-color: $fp-card;
} box-shadow: $fp-elev-1;
} color: $fp-ink;
.o_fp_kpi {
position: relative; overflow: hidden;
padding: 18px 18px 16px;
border-radius: $fp-radius-lg;
background-color: $fp-surface-raised;
border: 1px solid $fp-border;
box-shadow: $fp-shadow-sm;
transition: transform $fp-dur $fp-ease, box-shadow $fp-dur $fp-ease;
display: flex; flex-direction: column; gap: 4px;
min-height: 104px;
&::before { &::before {
content: ""; content: "";
position: absolute; width: 6px; align-self: stretch;
top: 0; left: 0; right: 0; height: 3px; border-radius: 3px;
background-image: linear-gradient(90deg, transparent, currentColor, transparent); background-color: $fp-info;
opacity: 0.7; }
&.o_fp_msg_info { &::before { background-color: $fp-info; } }
&.o_fp_msg_success { &::before { background-color: $fp-ok; } }
&.o_fp_msg_warning { &::before { background-color: $fp-warn; } }
&.o_fp_msg_danger { &::before { background-color: $fp-bad; } }
}
// -------------------------------------------------------------------------
// KPI strip — same language as tablet
// -------------------------------------------------------------------------
.o_fp_kpi_strip {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: $fp-space-4;
@media (max-width: 600px) { grid-template-columns: repeat(2, 1fr); gap: $fp-space-3; }
}
.o_fp_kpi {
position: relative;
padding: $fp-space-5;
border-radius: $fp-radius-lg;
background-color: $fp-card;
box-shadow: $fp-elev-1;
display: flex; flex-direction: column; gap: $fp-space-2;
transition: transform $fp-dur $fp-ease, box-shadow $fp-dur $fp-ease;
@include fp-hover-only { &:hover { transform: translateY(-2px); box-shadow: $fp-elev-2; } }
> .fa { font-size: 1.1rem; color: $fp-ink-mute; }
.o_fp_kpi_value {
font-size: $fp-text-3xl;
font-weight: $fp-weight-bold;
line-height: 1;
letter-spacing: -0.03em;
font-variant-numeric: tabular-nums;
color: $fp-ink;
}
.o_fp_kpi_label {
font-size: $fp-text-sm; color: $fp-ink-mute;
font-weight: $fp-weight-medium;
} }
&::after { &::after {
content: ""; content: "";
position: absolute; inset: 0; opacity: 0.35; pointer-events: none;
}
> * { position: relative; z-index: 1; }
@media (hover: hover) {
&:hover { transform: translateY(-2px); box-shadow: $fp-shadow-md; }
}
> .fa {
position: absolute; position: absolute;
right: 14px; top: 14px; top: $fp-space-4; right: $fp-space-4;
font-size: 1.5rem; width: 10px; height: 10px;
opacity: 0.28; border-radius: 50%;
background-color: $fp-ink-faint;
} }
.o_fp_kpi_value { &.o_fp_kpi_info { &::after { background-color: $fp-info; } }
font-size: $fp-font-kpi; &.o_fp_kpi_success { &::after { background-color: $fp-ok; } }
font-weight: 800; &.o_fp_kpi_warning { &::after { background-color: $fp-warn; } }
line-height: 1;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
color: var(--bs-body-color);
}
.o_fp_kpi_label {
font-size: $fp-font-xs;
color: var(--bs-secondary-color);
text-transform: uppercase;
letter-spacing: 0.06em;
font-weight: 600;
}
&.o_fp_kpi_info { color: var(--bs-info); &::after { @include fp-grad(--bs-info); } }
&.o_fp_kpi_success { color: var(--bs-success); &::after { @include fp-grad(--bs-success); } }
&.o_fp_kpi_warning { color: var(--bs-warning); &::after { @include fp-grad(--bs-warning); } }
&.o_fp_kpi_danger { &.o_fp_kpi_danger {
color: var(--bs-danger); &::after { background-color: $fp-bad; }
border-color: color-mix(in srgb, var(--bs-danger) 45%, $fp-border); .o_fp_kpi_value { color: $fp-bad; }
&::after { @include fp-grad(--bs-danger, 22%, 8%); }
.o_fp_kpi_value { color: var(--bs-danger); }
} }
&.o_fp_kpi_muted { color: var(--bs-secondary-color); &::after { opacity: 0; } } &.o_fp_kpi_muted { &::after { display: none; } }
@media (max-width: 600px) { @media (max-width: 600px) {
min-height: 84px; padding: 12px 12px 10px; padding: $fp-space-4;
.o_fp_kpi_value { font-size: 1.6rem; } .o_fp_kpi_value { font-size: $fp-text-2xl; }
.o_fp_kpi_label { font-size: 0.68rem; } .o_fp_kpi_label { font-size: $fp-text-xs; }
> .fa { font-size: 1rem; top: 10px; right: 10px; }
} }
} }
// ========================================================================= // -------------------------------------------------------------------------
// Flash message (shares tablet styling) // Workload grid
// ========================================================================= // -------------------------------------------------------------------------
.o_fp_tablet_message {
padding: 12px 16px;
border-radius: $fp-radius-md;
font-size: $fp-font-base;
font-weight: 500;
display: flex; align-items: center; gap: 10px;
box-shadow: $fp-shadow-xs;
&.o_fp_msg_info { @include fp-tone(--bs-info); }
&.o_fp_msg_success { @include fp-tone(--bs-success); }
&.o_fp_msg_warning { @include fp-tone(--bs-warning); }
&.o_fp_msg_danger { @include fp-tone(--bs-danger); }
}
// =========================================================================
// 3-column workload grid
// =========================================================================
.o_fp_manager_grid { .o_fp_manager_grid {
display: grid; display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1.15fr) minmax(0, 0.85fr); grid-template-columns: minmax(0, 1.1fr) minmax(0, 1.1fr) minmax(0, 0.85fr);
gap: 16px; gap: $fp-space-5;
@media (max-width: 1280px) { @media (max-width: 1280px) {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
.o_fp_panel_team { grid-column: span 2; } .o_fp_panel_team { grid-column: span 2; }
} }
@media (max-width: 900px) { @media (max-width: 900px) {
grid-template-columns: 1fr; gap: 10px; grid-template-columns: 1fr;
.o_fp_panel_team { grid-column: auto; } .o_fp_panel_team { grid-column: auto; }
} }
} }
// ========================================================================= // -------------------------------------------------------------------------
// Panels with coloured top accent // Panels
// ========================================================================= // -------------------------------------------------------------------------
.o_fp_panel { .o_fp_panel {
position: relative; @include fp-card($fp-elev-1);
overflow: hidden; padding: $fp-space-5;
background-color: $fp-surface-raised; @media (max-width: 600px) { padding: $fp-space-4; }
border: 1px solid $fp-border;
border-radius: $fp-radius-lg;
padding: 18px 20px;
box-shadow: $fp-shadow-sm;
@media (max-width: 600px) { padding: 12px 14px; border-radius: $fp-radius-md; }
&::before {
content: "";
position: absolute; top: 0; left: 0; right: 0; height: 4px;
background-color: $fp-border-strong;
}
} }
.o_fp_panel_unassigned::before {
background-image: linear-gradient(90deg,
var(--bs-warning), color-mix(in srgb, var(--bs-warning) 50%, transparent));
}
.o_fp_panel_active::before {
background-image: linear-gradient(90deg,
var(--bs-success), color-mix(in srgb, var(--bs-success) 50%, transparent));
}
.o_fp_panel_team::before {
background-image: linear-gradient(90deg,
var(--bs-info), color-mix(in srgb, var(--bs-info) 50%, transparent));
}
.o_fp_panel_head { .o_fp_panel_head {
display: flex; justify-content: space-between; align-items: center; display: flex;
margin-bottom: 14px; padding-bottom: 12px; justify-content: space-between;
border-bottom: 1px solid $fp-border; align-items: baseline;
margin-bottom: $fp-space-4;
h3 { h3 {
font-size: $fp-font-lg; font-size: $fp-text-lg;
font-weight: 700; font-weight: $fp-weight-bold;
margin: 0;
display: inline-flex; align-items: center; gap: 10px;
letter-spacing: -0.01em; letter-spacing: -0.01em;
margin: 0;
display: inline-flex; align-items: center; gap: $fp-space-2;
color: $fp-ink;
} }
} }
.o_fp_panel_count { .o_fp_panel_count {
min-width: 34px; font-size: $fp-text-sm;
padding: 3px 12px; font-weight: $fp-weight-semibold;
color: $fp-ink-mute;
background-color: $fp-card-soft;
padding: 2px 12px;
border-radius: $fp-radius-pill; border-radius: $fp-radius-pill;
font-weight: 700;
font-size: $fp-font-sm;
font-variant-numeric: tabular-nums;
background-color: color-mix(in srgb, var(--bs-body-color) 8%, transparent);
color: var(--bs-body-color);
border: 1px solid $fp-border;
} }
// Panel accent by tone — a single coloured dot next to the title
.o_fp_panel_unassigned .o_fp_panel_head h3::before,
.o_fp_panel_active .o_fp_panel_head h3::before,
.o_fp_panel_team .o_fp_panel_head h3::before {
content: "";
width: 10px; height: 10px;
border-radius: 50%;
margin-right: $fp-space-1;
}
.o_fp_panel_unassigned .o_fp_panel_head h3::before { background-color: $fp-warn; }
.o_fp_panel_active .o_fp_panel_head h3::before { background-color: $fp-ok; }
.o_fp_panel_team .o_fp_panel_head h3::before { background-color: $fp-info; }
// -------------------------------------------------------------------------
// Empty state
// -------------------------------------------------------------------------
.o_fp_empty { .o_fp_empty {
padding: 32px 16px; padding: $fp-space-7 $fp-space-4;
text-align: center; text-align: center;
color: var(--bs-secondary-color); color: $fp-ink-mute;
i.fa { display: block; font-size: 2.25rem; opacity: 0.55; margin-bottom: 8px; } font-size: $fp-text-base;
i.fa {
display: block;
font-size: 2.75rem;
opacity: 0.5;
margin-bottom: $fp-space-3;
}
} }
// ========================================================================= // -------------------------------------------------------------------------
// MO card list (Unassigned + In Progress columns) // MO cards — NO borders, depth by shadow + surface tint
// ========================================================================= // -------------------------------------------------------------------------
.o_fp_mgr_card_list { .o_fp_mgr_card_list {
display: flex; flex-direction: column; gap: 10px; display: flex; flex-direction: column; gap: $fp-space-3;
} }
.o_fp_mgr_card { .o_fp_mgr_card {
position: relative; position: relative;
overflow: hidden; background-color: $fp-page;
border: 1px solid $fp-border;
border-radius: $fp-radius-md; border-radius: $fp-radius-md;
background-color: $fp-surface; overflow: hidden;
transition: border-color $fp-dur $fp-ease, transition: transform $fp-dur-fast $fp-ease, box-shadow $fp-dur $fp-ease;
box-shadow $fp-dur $fp-ease,
transform $fp-dur-fast $fp-ease;
@media (hover: hover) { @include fp-hover-only { &:hover { box-shadow: $fp-elev-1; transform: translateX(2px); } }
&:hover {
border-color: $fp-border-accent;
box-shadow: $fp-shadow-sm;
transform: translateY(-1px);
}
}
// Priority stripe (4px) on the left — only when priority is set
&[data-priority="2"] { &[data-priority="2"] {
border-color: color-mix(in srgb, var(--bs-danger) 50%, $fp-border); background-color: color-mix(in srgb, #{$fp-bad} 4%, $fp-page);
&::before { &::before {
content: ""; content: "";
position: absolute; top: 0; left: 0; bottom: 0; width: 4px; position: absolute; left: 0; top: 0; bottom: 0;
background: var(--bs-danger); width: 4px; background-color: $fp-bad;
} }
} }
&[data-priority="1"] { &[data-priority="1"]::before {
&::before { content: "";
content: ""; position: absolute; left: 0; top: 0; bottom: 0;
position: absolute; top: 0; left: 0; bottom: 0; width: 4px; width: 4px; background-color: $fp-warn;
background: var(--bs-warning);
}
} }
} }
.o_fp_mgr_card_head { .o_fp_mgr_card_head {
display: flex; justify-content: space-between; align-items: center; display: flex; justify-content: space-between; align-items: center;
padding: 12px 14px; cursor: pointer; gap: 10px; padding: $fp-space-3 $fp-space-4;
min-height: 60px; cursor: pointer;
@media (max-width: 600px) { flex-wrap: wrap; padding: 12px; } min-height: 64px;
gap: $fp-space-3;
@media (max-width: 600px) { flex-wrap: wrap; }
} }
.o_fp_mgr_card_title { .o_fp_mgr_card_title {
font-weight: 700; font-size: $fp-font-base; font-weight: $fp-weight-bold;
font-size: $fp-text-base;
color: $fp-ink;
letter-spacing: -0.01em; letter-spacing: -0.01em;
} }
.o_fp_mgr_card_sub { .o_fp_mgr_card_sub {
color: var(--bs-secondary-color); font-size: $fp-font-sm; font-size: $fp-text-sm; color: $fp-ink-mute; margin-top: 2px;
margin-top: 3px;
}
.o_fp_mgr_card_chips {
display: flex; gap: 6px; flex-wrap: wrap;
} }
.o_fp_mgr_card_chips { display: flex; gap: $fp-space-1; flex-wrap: wrap; }
.o_fp_mgr_card_body { .o_fp_mgr_card_body {
border-top: 1px dashed $fp-border; padding: $fp-space-3 $fp-space-4 $fp-space-4;
padding: 12px 14px; display: flex; flex-direction: column; gap: $fp-space-2;
display: flex; flex-direction: column; gap: 10px; background-color: color-mix(in srgb, var(--bs-body-color) 3%, transparent);
background-color: color-mix(in srgb, var(--bs-body-color) 2%, transparent);
} }
// Per-WO row inside the expanded card
// -------------------------------------------------------------------------
// WO row inside expanded card
// -------------------------------------------------------------------------
.o_fp_mgr_wo_row { .o_fp_mgr_wo_row {
display: grid; display: grid;
grid-template-columns: 1fr auto auto auto auto; grid-template-columns: 1fr auto auto auto auto;
gap: 8px; gap: $fp-space-2;
align-items: center; align-items: center;
padding: 8px 10px; padding: $fp-space-2 $fp-space-3;
background-color: $fp-surface; background-color: $fp-card;
border: 1px solid $fp-border;
border-radius: $fp-radius-sm; border-radius: $fp-radius-sm;
font-size: $fp-font-sm; font-size: $fp-text-sm;
@media (max-width: 1400px) { @media (max-width: 1400px) {
grid-template-columns: 1fr auto auto; grid-template-columns: 1fr auto auto;
@@ -386,7 +346,6 @@
} }
@media (max-width: 600px) { @media (max-width: 600px) {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 6px;
.o_fp_mgr_picker { max-width: 100% !important; width: 100%; } .o_fp_mgr_picker { max-width: 100% !important; width: 100%; }
.btn { min-height: $fp-touch-min; } .btn { min-height: $fp-touch-min; }
} }
@@ -394,91 +353,93 @@
.o_fp_mgr_wo_info { .o_fp_mgr_wo_info {
min-width: 0; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-weight: 600; font-weight: $fp-weight-semibold;
color: $fp-ink;
} }
.o_fp_mgr_picker { .o_fp_mgr_picker {
min-width: 130px; max-width: 200px; min-width: 140px; max-width: 220px;
min-height: 38px; min-height: 40px;
padding: 4px 10px; padding: 0 $fp-space-3;
border: none;
border-radius: $fp-radius-sm; border-radius: $fp-radius-sm;
background-color: $fp-surface; background-color: $fp-card-soft;
border: 1px solid $fp-border; color: $fp-ink;
color: var(--bs-body-color); font-size: $fp-text-sm;
font-size: $fp-font-sm; &:focus { @include fp-focus-ring; }
&:focus { @include fp-focus-ring; border-color: var(--o-action); } }
.o_fp_mgr_wo_row .btn {
min-height: 40px;
padding: 0 $fp-space-3;
border: none;
border-radius: $fp-radius-sm;
font-size: $fp-text-sm;
font-weight: $fp-weight-semibold;
background-color: $fp-card-soft;
color: $fp-ink;
transition: filter $fp-dur-fast $fp-ease;
&:hover { filter: brightness(0.95); }
} }
// ========================================================================= // -------------------------------------------------------------------------
// Status chips (shared styles in tablet scss, but redefined in case // Status chips (reused)
// manager is loaded standalone) // -------------------------------------------------------------------------
// =========================================================================
.o_fp_chip { .o_fp_chip {
display: inline-flex; display: inline-flex; align-items: center;
align-items: center; padding: 2px 10px;
padding: 3px 10px;
border-radius: $fp-radius-pill; border-radius: $fp-radius-pill;
font-size: 0.72rem; font-size: 0.7rem;
font-weight: 700; font-weight: $fp-weight-bold;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em; letter-spacing: 0.06em;
&.o_fp_chip_info { @include fp-tone(--bs-info); } &.o_fp_chip_info { @include fp-pill(--bs-info); }
&.o_fp_chip_success { @include fp-tone(--bs-success); } &.o_fp_chip_success { @include fp-pill(--bs-success); }
&.o_fp_chip_warning { @include fp-tone(--bs-warning); } &.o_fp_chip_warning { @include fp-pill(--bs-warning); }
&.o_fp_chip_danger { @include fp-tone(--bs-danger); } &.o_fp_chip_danger { @include fp-pill(--bs-danger); }
&.o_fp_chip_muted { &.o_fp_chip_muted { background-color: $fp-card-soft; color: $fp-ink-mute; }
background-color: color-mix(in srgb, var(--bs-body-color) 6%, transparent);
color: var(--bs-secondary-color);
border: 1px solid $fp-border;
}
} }
// ========================================================================= // -------------------------------------------------------------------------
// Team column — avatar grid // Team column — avatar + name + load
// ========================================================================= // -------------------------------------------------------------------------
.o_fp_team_grid { .o_fp_team_grid {
display: flex; flex-direction: column; gap: 10px; display: flex; flex-direction: column; gap: $fp-space-2;
} }
.o_fp_team_card { .o_fp_team_card {
position: relative;
display: grid; display: grid;
grid-template-columns: 56px 1fr; grid-template-columns: 48px 1fr;
align-items: center; align-items: center;
gap: 14px; gap: $fp-space-3;
padding: 12px 14px; padding: $fp-space-3 $fp-space-4;
border: 1px solid $fp-border;
border-radius: $fp-radius-md; border-radius: $fp-radius-md;
background-color: $fp-surface; background-color: $fp-page;
cursor: pointer; cursor: pointer;
min-height: 72px; min-height: 72px;
transition: border-color $fp-dur $fp-ease, transition: transform $fp-dur-fast $fp-ease, background-color $fp-dur $fp-ease;
box-shadow $fp-dur $fp-ease,
transform $fp-dur-fast $fp-ease;
@media (hover: hover) { @include fp-hover-only {
&:hover { &:hover {
border-color: $fp-border-accent; background-color: color-mix(in srgb, #{$fp-accent} 6%, $fp-page);
box-shadow: $fp-shadow-sm; transform: translateX(2px);
transform: translateY(-1px);
} }
} }
} }
.o_fp_team_avatar { .o_fp_team_avatar {
width: 48px; height: 48px; width: 44px; height: 44px;
border-radius: 50%; border-radius: 50%;
object-fit: cover; object-fit: cover;
border: 2px solid $fp-border; background-color: color-mix(in srgb, #{$fp-accent} 12%, $fp-card);
background-color: color-mix(in srgb, var(--o-action) 15%, $fp-surface);
} }
.o_fp_team_info { flex: 1; min-width: 0; } .o_fp_team_info { min-width: 0; }
.o_fp_team_name { .o_fp_team_name {
font-weight: 700; font-weight: $fp-weight-semibold;
font-size: $fp-font-base; font-size: $fp-text-base;
color: $fp-ink;
letter-spacing: -0.01em; letter-spacing: -0.01em;
} }
.o_fp_team_load { .o_fp_team_load {
display: flex; gap: 6px; margin-top: 6px; display: flex; gap: $fp-space-1; margin-top: 4px;
} }
} }

View File

@@ -1,482 +1,260 @@
// ============================================================================= // =============================================================================
// Fusion Plating — Plant Overview Dashboard // Fusion Plating — Plant Overview (Kanban)
// Copyright 2026 Nexa Systems Inc. // Copyright 2026 Nexa Systems Inc. · License OPL-1
// License OPL-1 (Odoo Proprietary License v1.0)
// //
// Modernised 2026-04: gradient column headers, card depth, theme-safe // Kanban of work orders grouped by work centre. Clean, shadow-based,
// using shared design tokens. Variables / mixins come from // no heavy chrome. Shared tokens from _fp_shopfloor_tokens.scss.
// _fp_shopfloor_tokens.scss — loaded FIRST in the asset bundle
// (see __manifest__.py). No @import; Odoo 19 forbids it.
// ============================================================================= // =============================================================================
@media (hover: none) {
.o_fp_plant_overview [class*="o_fp_"]:hover {
transform: none !important;
box-shadow: inherit !important;
}
}
.o_fp_plant_overview { .o_fp_plant_overview {
font-family: $fp-font-stack;
background-color: $fp-page;
color: $fp-ink;
min-height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%;
min-height: 0;
background: $fp-surface-sunken;
padding: 0;
} }
// ---- Header -----------------------------------------------------------------
// ---------- Header ----------------------------------------------------------
.o_fp_po_header { .o_fp_po_header {
position: relative;
overflow: hidden;
display: flex; display: flex;
align-items: center;
justify-content: space-between; justify-content: space-between;
align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: 12px; gap: $fp-space-3;
padding: 20px 24px; padding: $fp-space-5 $fp-space-6;
background-color: $fp-surface-raised; background-color: $fp-page;
border-bottom: 1px solid $fp-border;
box-shadow: $fp-shadow-xs;
&::before {
content: "";
position: absolute; inset: 0;
background-image:
radial-gradient(circle at 0% 50%,
color-mix(in srgb, var(--o-action) 18%, transparent) 0%,
transparent 55%);
pointer-events: none;
}
> * { position: relative; z-index: 1; }
.o_fp_po_header_left { display: flex; align-items: center; } .o_fp_po_header_left { display: flex; align-items: center; }
.o_fp_po_title { .o_fp_po_title {
font-size: $fp-text-xl;
font-weight: $fp-weight-bold;
letter-spacing: -0.02em;
margin: 0; margin: 0;
font-size: $fp-font-xl; color: $fp-ink;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--bs-body-color);
} }
.o_fp_po_refresh_ts { font-size: $fp-font-xs; } .o_fp_po_refresh_ts {
font-size: $fp-text-xs; color: $fp-ink-mute;
}
.o_fp_po_header_right { .o_fp_po_header_right {
display: flex; align-items: center; gap: 10px; display: flex; align-items: center; gap: $fp-space-2;
}
@media (max-width: 600px) {
padding: $fp-space-4;
flex-direction: column; align-items: stretch;
> * { width: 100%; }
} }
} }
// ---- Search -----------------------------------------------------------------
// ---------- Search ----------------------------------------------------------
.o_fp_po_search_box { .o_fp_po_search_box {
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
.o_fp_po_search_icon { .o_fp_po_search_icon {
position: absolute; position: absolute; left: 14px;
left: 10px; color: $fp-ink-mute;
color: var(--bs-secondary-color);
pointer-events: none; pointer-events: none;
} }
.o_fp_po_search_input { .o_fp_po_search_input {
padding: 6px 32px 6px 32px; padding: 0 $fp-space-4 0 $fp-space-7;
border: 1px solid var(--bs-border-color); min-height: $fp-touch-min;
border-radius: 6px;
font-size: 0.875rem;
width: 260px;
outline: none;
transition: border-color 0.15s;
background-color: var(--bs-body-bg);
color: var(--bs-body-color);
&:focus {
border-color: var(--o-action);
box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--o-action) 15%, transparent);
}
}
.o_fp_po_search_clear {
position: absolute;
right: 6px;
background: none;
border: none; border: none;
color: var(--bs-secondary-color); border-radius: $fp-radius-md;
cursor: pointer; background-color: $fp-card;
padding: 2px 6px; color: $fp-ink;
box-shadow: $fp-elev-1;
width: 260px;
font-size: $fp-text-base;
transition: box-shadow $fp-dur $fp-ease;
&:hover { &:focus { @include fp-focus-ring; }
color: var(--bs-body-color); @media (max-width: 600px) { width: 100%; }
} }
.o_fp_po_search_clear {
position: absolute; right: 6px;
background: none; border: none;
color: $fp-ink-mute; padding: $fp-space-1 $fp-space-2;
cursor: pointer;
&:hover { color: $fp-ink; }
} }
} }
.o_fp_po_refresh_btn { .o_fp_po_refresh_btn {
width: 36px; width: $fp-touch-min; height: $fp-touch-min;
height: 36px; display: flex; align-items: center; justify-content: center;
padding: 0; border: none;
display: flex; border-radius: $fp-radius-md;
align-items: center; background-color: $fp-card;
justify-content: center; color: $fp-ink;
border-radius: 6px; box-shadow: $fp-elev-1;
cursor: pointer;
transition: transform $fp-dur-fast $fp-ease;
&:active { transform: scale(0.95); }
} }
// ---- Columns container ------------------------------------------------------
// ---------- Columns container -----------------------------------------------
.o_fp_po_columns { .o_fp_po_columns {
display: flex; display: flex;
gap: 12px; gap: $fp-space-4;
padding: 16px 20px; padding: 0 $fp-space-6 $fp-space-6;
overflow-x: auto; overflow-x: auto;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
align-items: flex-start; align-items: flex-start;
@media (max-width: 900px) {
padding: 0 $fp-space-4 $fp-space-4;
}
@media (max-width: 600px) {
flex-direction: column;
padding: 0 $fp-space-3 $fp-space-3;
}
} }
// ---- Single column (work centre) --------------------------------------------
// ---------- Column (work centre lane) ---------------------------------------
.o_fp_po_column { .o_fp_po_column {
flex: 0 0 280px; flex: 0 0 300px;
min-width: 260px; min-width: 280px;
max-width: 320px; max-width: 340px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background-color: $fp-surface-raised; background-color: $fp-card;
border: 1px solid $fp-border;
border-radius: $fp-radius-lg; border-radius: $fp-radius-lg;
box-shadow: $fp-shadow-sm; box-shadow: $fp-elev-1;
max-height: calc(100vh - 160px); max-height: calc(100vh - 180px);
overflow: hidden; overflow: hidden;
@media (max-width: 600px) {
flex: 1 1 auto;
min-width: 100%; max-width: 100%;
max-height: none;
}
} }
.o_fp_po_col_header { .o_fp_po_col_header {
position: relative; display: flex; align-items: center; justify-content: space-between;
overflow: hidden; padding: $fp-space-4 $fp-space-4 $fp-space-3;
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid $fp-border;
background-color: $fp-surface-sunken;
// Subtle gradient stripe at the top — turns the header into a "tab"
&::before {
content: "";
position: absolute; top: 0; left: 0; right: 0; height: 3px;
background-image: linear-gradient(90deg,
var(--o-action),
color-mix(in srgb, var(--o-action) 30%, transparent));
}
> * { position: relative; z-index: 1; }
.o_fp_po_col_name { .o_fp_po_col_name {
font-weight: 700; font-weight: $fp-weight-bold;
font-size: $fp-font-sm; font-size: $fp-text-sm;
color: var(--bs-body-color); color: $fp-ink;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.08em;
} }
.o_fp_po_col_count { .o_fp_po_col_count {
@include fp-tone(--o-action, 14%); font-weight: $fp-weight-semibold;
font-weight: 700; font-size: 0.75rem;
font-size: 0.72rem;
min-width: 26px;
padding: 2px 10px; padding: 2px 10px;
border-radius: $fp-radius-pill; border-radius: $fp-radius-pill;
background-color: $fp-card-soft;
color: $fp-ink-mute;
} }
} }
.o_fp_po_col_body { .o_fp_po_col_body {
overflow-y: auto; overflow-y: auto;
padding: 8px; padding: $fp-space-2 $fp-space-3 $fp-space-3;
flex: 1; flex: 1;
transition: background-color 0.15s, border-color 0.15s; transition: background-color $fp-dur $fp-ease;
border: 2px solid transparent; border-radius: 0 0 $fp-radius-lg $fp-radius-lg;
border-radius: 0 0 10px 10px;
// Drop target highlight when dragging a card over this column
&.o_fp_drop_target { &.o_fp_drop_target {
background-color: color-mix(in srgb, var(--o-action) 8%, transparent); background-color: color-mix(in srgb, #{$fp-accent} 8%, transparent);
border-color: color-mix(in srgb, var(--o-action) 40%, transparent);
} }
} }
// ---- Card -------------------------------------------------------------------
// ---------- Card ------------------------------------------------------------
.o_fp_po_card { .o_fp_po_card {
background-color: $fp-surface; background-color: $fp-page;
border: 1px solid $fp-border;
border-radius: $fp-radius-md; border-radius: $fp-radius-md;
padding: 12px 14px; padding: $fp-space-3 $fp-space-4;
margin-bottom: 10px; margin-bottom: $fp-space-2;
cursor: grab; cursor: grab;
box-shadow: $fp-shadow-xs; transition: transform $fp-dur-fast $fp-ease,
transition: box-shadow $fp-dur $fp-ease, box-shadow $fp-dur $fp-ease,
transform $fp-dur-fast $fp-ease,
opacity $fp-dur $fp-ease, opacity $fp-dur $fp-ease,
border-color $fp-dur $fp-ease; background-color $fp-dur $fp-ease;
&:hover { @include fp-hover-only {
box-shadow: $fp-shadow-md; &:hover {
transform: translateY(-2px); background-color: $fp-card;
border-color: $fp-border-accent; box-shadow: $fp-elev-2;
border-color: darken($border-color, 10%); transform: translateY(-2px);
}
} }
&:active { &:active, &.o_fp_po_dragging {
cursor: grabbing; cursor: grabbing;
opacity: 0.6;
} }
&:last-child { // Priority left bar — only visible when a priority is set
margin-bottom: 0; position: relative; overflow: hidden;
&[data-priority="2"] {
background-color: color-mix(in srgb, #{$fp-bad} 6%, $fp-page);
box-shadow: inset 4px 0 0 0 $fp-bad;
padding-left: calc(#{$fp-space-4} + 4px);
} }
&[data-priority="1"] {
// Dragging ghost state box-shadow: inset 4px 0 0 0 $fp-warn;
&.o_fp_dragging { padding-left: calc(#{$fp-space-4} + 4px);
opacity: 0.4;
border-style: dashed;
box-shadow: none;
transform: none;
} }
// State variants
&.o_fp_card_progress {
border-left: 4px solid var(--bs-warning);
}
&.o_fp_card_ready {
border-left: 4px solid var(--bs-primary);
}
&.o_fp_card_done {
border-left: 4px solid var(--bs-success);
opacity: 0.75;
}
&.o_fp_card_pending {
border-left: 4px solid var(--bs-warning);
}
}
// ---- Card top row (image + title + step badge) --------------------------------
.o_fp_po_card_top {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.o_fp_po_card_img {
width: 32px;
height: 32px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
}
.o_fp_po_card_img_placeholder {
width: 32px;
height: 32px;
border-radius: 4px;
background: var(--bs-tertiary-bg);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--bs-secondary-color);
font-size: 14px;
} }
.o_fp_po_card_title { .o_fp_po_card_title {
flex: 1; font-weight: $fp-weight-semibold;
min-width: 0; font-size: $fp-text-base;
font-size: 0.9rem; color: $fp-ink;
color: var(--bs-body-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.o_fp_po_card_step_badge {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--bs-info);
color: #fff;
font-size: 0.7rem;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
// ---- Priority card borders ---------------------------------------------------
.o_fp_po_card_hot {
border-left: 4px solid var(--bs-danger) !important;
background: color-mix(in srgb, var(--bs-danger) 8%, var(--bs-body-bg));
}
.o_fp_po_card_urgent {
border-left: 4px solid var(--bs-warning) !important;
background: color-mix(in srgb, var(--bs-warning) 8%, var(--bs-body-bg));
}
// ---- Product name and step display -------------------------------------------
.o_fp_po_card_product {
margin-bottom: 4px;
}
.o_fp_po_card_step {
margin-bottom: 4px;
}
.o_fp_po_card_customer {
font-size: 0.9rem;
margin-bottom: 2px; margin-bottom: 2px;
color: var(--bs-body-color); letter-spacing: -0.01em;
} }
.o_fp_po_card_sub {
.o_fp_po_card_refs { font-size: $fp-text-sm;
font-size: 0.8rem; color: $fp-ink-mute;
color: var(--bs-secondary-color); margin-bottom: $fp-space-2;
margin-bottom: 6px;
} }
.o_fp_po_card_meta {
// ---- Parts progress bar ----------------------------------------------------- display: flex; gap: $fp-space-2; flex-wrap: wrap; align-items: center;
font-size: $fp-text-xs; color: $fp-ink-mute;
.o_fp_po_card_parts {
margin-bottom: 6px;
}
.o_fp_po_parts_bar {
height: 6px;
background: var(--bs-tertiary-bg);
border-radius: 3px;
overflow: hidden;
margin-bottom: 2px;
}
.o_fp_po_parts_fill {
height: 100%;
background: var(--bs-warning);
border-radius: 3px;
transition: width 0.3s ease;
}
.o_fp_po_parts_label {
font-size: 0.75rem;
color: var(--bs-secondary-color);
}
.o_fp_po_card_last {
font-size: 0.75rem;
margin-bottom: 6px;
}
// ---- Tags + date footer -----------------------------------------------------
.o_fp_po_card_footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
flex-wrap: wrap;
}
.o_fp_po_card_tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
} }
.o_fp_po_tag { .o_fp_po_tag {
display: inline-block; padding: 1px 8px;
font-size: 0.65rem; border-radius: $fp-radius-pill;
font-weight: 700; font-size: 0.68rem;
font-weight: $fp-weight-bold;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.4px; letter-spacing: 0.06em;
padding: 2px 6px; &[data-tone="hot"] { @include fp-pill(--bs-danger); }
border-radius: 4px; &[data-tone="priority"] { @include fp-pill(--bs-warning); }
line-height: 1.4;
&.o_fp_tag_hot {
background: var(--bs-danger);
color: #fff;
}
&.o_fp_tag_priority {
background: var(--bs-success);
color: #fff;
}
&.o_fp_tag_attention {
background: var(--bs-warning);
color: var(--bs-body-color);
}
&.o_fp_tag_default {
background: var(--bs-tertiary-bg);
color: var(--bs-secondary-color);
}
} }
.o_fp_po_card_date {
font-size: 0.75rem; // ---------- Empty state ------------------------------------------------------
font-weight: 600; .o_fp_po_empty {
color: var(--bs-secondary-color); padding: $fp-space-6 $fp-space-3;
background: var(--bs-tertiary-bg); text-align: center;
padding: 1px 6px; color: $fp-ink-mute;
border-radius: 4px; font-size: $fp-text-sm;
white-space: nowrap;
}
// ---- Empty / no-cards -------------------------------------------------------
.o_fp_po_no_cards {
font-size: 0.85rem;
}
// ---- Responsive -------------------------------------------------------------
@media (max-width: 768px) {
.o_fp_po_columns {
flex-direction: column;
align-items: stretch;
padding: 12px;
gap: 10px;
}
.o_fp_po_column {
flex: 1 1 auto;
min-width: 100%;
max-width: 100%;
max-height: none;
}
.o_fp_po_search_input {
width: 180px !important;
}
.o_fp_po_header {
padding: 12px;
flex-wrap: wrap;
gap: 8px;
}
}
// Phone — further tighten + touch-first cards
@media (max-width: 600px) {
.o_fp_po_columns { padding: 8px; gap: 8px; }
.o_fp_po_col_body { padding: 6px; }
.o_fp_po_card {
padding: 10px 12px;
min-height: 64px; // comfortable tap zone
}
.o_fp_po_search_input { width: 100% !important; }
.o_fp_po_header {
flex-direction: column;
align-items: stretch;
> * { width: 100%; }
}
}
// Touch devices: disable hover-only highlights (they stick on tap)
@media (hover: none) {
.o_fp_po_card:hover { background: inherit !important; }
} }

View File

@@ -1,60 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!-- <!--
Copyright 2026 Nexa Systems Inc. Copyright 2026 Nexa Systems Inc. · License OPL-1
License OPL-1 (Odoo Proprietary License v1.0) Fusion Plating — Manager Desk
Part of the Fusion Plating product family. Rebuilt 2026-04 with the shop-floor design system.
--> -->
<templates xml:space="preserve"> <templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.ManagerDashboard"> <t t-name="fusion_plating_shopfloor.ManagerDashboard">
<div class="o_fp_manager"> <div class="o_fp_manager">
<!-- ===== Header ===== --> <!-- ============ Hero ============ -->
<div class="o_fp_manager_header"> <header class="o_fp_manager_header">
<div> <div>
<div class="o_fp_manager_title"> <h1 class="o_fp_manager_title">
<i class="fa fa-user-md me-2"/>Manager Desk <i class="fa fa-user-md"/>Manager Desk
<span class="o_fp_live_dot ms-2" <span class="o_fp_live_dot"
t-att-data-active="state.isFetching ? 'y' : 'n'" t-att-data-active="state.isFetching ? 'y' : 'n'"
t-att-title="'Updated ' + lastUpdatedLabel"/> t-att-title="'Updated ' + lastUpdatedLabel"/>
</div> </h1>
<div class="o_fp_manager_subtitle" t-if="state.overview"> <div class="o_fp_manager_subtitle" t-if="state.overview">
<span t-esc="state.overview.user_name"/> <span t-esc="state.overview.user_name"/>
<span class="text-muted">· live · updated <t t-esc="lastUpdatedLabel"/></span> <span>· Live · updated <t t-esc="lastUpdatedLabel"/></span>
</div> </div>
</div> </div>
<div class="o_fp_manager_head_actions"> <div class="o_fp_manager_head_actions">
<button class="btn btn-outline-secondary" <button class="btn"
t-on-click="refresh" t-on-click="refresh"
t-att-disabled="state.isFetching"> t-att-disabled="state.isFetching">
<i t-att-class="'fa fa-refresh' + (state.isFetching ? ' fa-spin' : '')"/> <i t-att-class="'fa fa-refresh' + (state.isFetching ? ' fa-spin' : '')"/>
</button> </button>
<button t-att-class="'btn ' + (state.mode === 'quick' ? 'btn-primary' : 'btn-outline-primary')" <button t-att-class="'btn ' + (state.mode === 'quick' ? 'btn-primary' : '')"
t-on-click="toggleMode"> t-on-click="toggleMode">
<i t-att-class="state.mode === 'quick' ? 'fa fa-list' : 'fa fa-th'"/> <t t-if="state.mode === 'quick'">Quick View</t>
<t t-if="state.mode === 'quick'"> Quick View</t> <t t-else="">Detailed View</t>
<t t-else=""> Detailed View</t>
</button> </button>
</div> </div>
</div> </header>
<!-- Error banner — visible instead of a stuck spinner --> <!-- ============ Error banner ============ -->
<div t-if="state.loadError" <div t-if="state.loadError"
class="o_fp_tablet_message o_fp_msg_danger"> class="o_fp_tablet_message o_fp_msg_danger">
<i class="fa fa-exclamation-triangle me-2"/> <i class="fa fa-exclamation-triangle"/>
<span t-esc="state.loadError"/> <span t-esc="state.loadError"/>
<button class="btn btn-sm btn-outline-danger ms-3" t-on-click="refresh"> <button class="btn btn-sm btn-outline-danger ms-auto"
Retry t-on-click="refresh">Retry</button>
</button>
</div> </div>
<!-- ===== Flash message ===== --> <!-- ============ Flash ============ -->
<div t-if="state.message" <div t-if="state.message"
t-att-class="'o_fp_tablet_message o_fp_msg_' + state.messageType"> t-att-class="'o_fp_tablet_message o_fp_msg_' + state.messageType">
<span t-esc="state.message"/> <span t-esc="state.message"/>
</div> </div>
<!-- ===== KPI strip ===== --> <!-- ============ KPI strip ============ -->
<div class="o_fp_kpi_strip" t-if="state.overview"> <div class="o_fp_kpi_strip" t-if="state.overview">
<div class="o_fp_kpi o_fp_kpi_warning"> <div class="o_fp_kpi o_fp_kpi_warning">
<i class="fa fa-user-times"/> <i class="fa fa-user-times"/>
@@ -78,13 +76,13 @@
</div> </div>
</div> </div>
<!-- ===== 3-column layout ===== --> <!-- ============ Workload grid ============ -->
<div class="o_fp_manager_grid" t-if="state.overview"> <div class="o_fp_manager_grid" t-if="state.overview">
<!-- Column 1: Unassigned --> <!-- Unassigned -->
<section class="o_fp_panel o_fp_panel_unassigned"> <section class="o_fp_panel o_fp_panel_unassigned">
<div class="o_fp_panel_head"> <div class="o_fp_panel_head">
<h3><i class="fa fa-inbox me-2 text-warning"/>Needs a Worker</h3> <h3><i class="fa fa-inbox"/>Needs a Worker</h3>
<span class="o_fp_panel_count"><t t-esc="state.overview.unassigned.length"/></span> <span class="o_fp_panel_count"><t t-esc="state.overview.unassigned.length"/></span>
</div> </div>
<div t-if="!state.overview.unassigned.length" class="o_fp_empty"> <div t-if="!state.overview.unassigned.length" class="o_fp_empty">
@@ -94,46 +92,41 @@
<div class="o_fp_mgr_card_list" t-if="state.overview.unassigned.length"> <div class="o_fp_mgr_card_list" t-if="state.overview.unassigned.length">
<t t-foreach="state.overview.unassigned" t-as="card" t-key="card.mo_id"> <t t-foreach="state.overview.unassigned" t-as="card" t-key="card.mo_id">
<div class="o_fp_mgr_card" <div class="o_fp_mgr_card"
t-att-data-priority="card.priority_any" t-att-data-priority="card.priority_any">
t-att-data-expanded="state.expandedMoId === card.mo_id ? 'y' : 'n'"> <div class="o_fp_mgr_card_head"
<div class="o_fp_mgr_card_head" t-on-click="() => this.toggleCard(card.mo_id)"> t-on-click="() => this.toggleCard(card.mo_id)">
<div> <div>
<div class="o_fp_mgr_card_title"> <div class="o_fp_mgr_card_title">
<strong t-esc="card.mo_name"/> <t t-esc="card.mo_name"/>
<span class="text-muted ms-2">· <t t-esc="card.so_name"/></span> <span class="text-muted ms-2 small">· <t t-esc="card.so_name"/></span>
</div> </div>
<div class="o_fp_mgr_card_sub"> <div class="o_fp_mgr_card_sub">
<t t-esc="card.customer"/> <t t-esc="card.customer"/>
· <t t-esc="card.product"/> · <t t-esc="card.product"/>
· Qty <t t-esc="card.qty_total"/> · Qty <t t-esc="card.qty_total"/>
<t t-if="card.date_planned"> <t t-if="card.date_planned"> · <t t-esc="card.date_planned"/></t>
· <t t-esc="card.date_planned"/>
</t>
</div> </div>
</div> </div>
<div class="o_fp_mgr_card_chips"> <div class="o_fp_mgr_card_chips">
<span t-if="card.priority_any >= 2" <span t-if="card.priority_any >= 2" class="o_fp_chip o_fp_chip_danger">HOT</span>
class="o_fp_chip o_fp_chip_danger">HOT</span> <span t-if="card.priority_any == 1" class="o_fp_chip o_fp_chip_warning">Urgent</span>
<span t-if="card.priority_any == 1"
class="o_fp_chip o_fp_chip_warning">Urgent</span>
<span class="o_fp_chip o_fp_chip_muted"> <span class="o_fp_chip o_fp_chip_muted">
<t t-esc="card.wos.length"/> WO <t t-esc="card.wos.length"/> WO
</span> </span>
</div> </div>
</div> </div>
<!-- Expanded WO list -->
<div class="o_fp_mgr_card_body" <div class="o_fp_mgr_card_body"
t-if="state.expandedMoId === card.mo_id or state.mode === 'detailed'"> t-if="state.expandedMoId === card.mo_id or state.mode === 'detailed'">
<t t-foreach="card.wos" t-as="wo" t-key="wo.id"> <t t-foreach="card.wos" t-as="wo" t-key="wo.id">
<div class="o_fp_mgr_wo_row"> <div class="o_fp_mgr_wo_row">
<div class="o_fp_mgr_wo_info"> <div class="o_fp_mgr_wo_info">
<strong t-esc="wo.name"/> <t t-esc="wo.name"/>
<span class="text-muted ms-2"> <span class="text-muted ms-2">
<t t-esc="wo.workcenter"/> <t t-esc="wo.workcenter"/>
<t t-if="wo.bath"> · <t t-esc="wo.bath"/></t> <t t-if="wo.bath"> · <t t-esc="wo.bath"/></t>
</span> </span>
</div> </div>
<select class="form-select form-select-sm o_fp_mgr_picker" <select class="o_fp_mgr_picker"
t-on-change="(ev) => this.onAssignWorker(wo, ev.target.value)"> t-on-change="(ev) => this.onAssignWorker(wo, ev.target.value)">
<option value="">— Assign worker —</option> <option value="">— Assign worker —</option>
<t t-foreach="state.overview.operators" t-as="op" t-key="op.id"> <t t-foreach="state.overview.operators" t-as="op" t-key="op.id">
@@ -143,7 +136,7 @@
</option> </option>
</t> </t>
</select> </select>
<select class="form-select form-select-sm o_fp_mgr_picker" <select class="o_fp_mgr_picker"
t-on-change="(ev) => this.onAssignTank(wo, ev.target.value)"> t-on-change="(ev) => this.onAssignTank(wo, ev.target.value)">
<option value="">— Tank —</option> <option value="">— Tank —</option>
<t t-foreach="state.overview.tanks" t-as="tnk" t-key="tnk.id"> <t t-foreach="state.overview.tanks" t-as="tnk" t-key="tnk.id">
@@ -153,11 +146,11 @@
</option> </option>
</t> </t>
</select> </select>
<button class="btn btn-sm btn-outline-warning" <button class="btn"
t-on-click="() => this.onTakeOver(wo)"> t-on-click="() => this.onTakeOver(wo)">
<i class="fa fa-user"/> Take Over <i class="fa fa-user"/> Take Over
</button> </button>
<button class="btn btn-sm btn-outline-secondary" <button class="btn"
t-on-click="() => this.openRecord('mrp.workorder', wo.id)"> t-on-click="() => this.openRecord('mrp.workorder', wo.id)">
Open Open
</button> </button>
@@ -169,10 +162,10 @@
</div> </div>
</section> </section>
<!-- Column 2: In Progress --> <!-- In Progress -->
<section class="o_fp_panel o_fp_panel_active"> <section class="o_fp_panel o_fp_panel_active">
<div class="o_fp_panel_head"> <div class="o_fp_panel_head">
<h3><i class="fa fa-cogs me-2 text-success"/>In Progress</h3> <h3><i class="fa fa-cogs"/>In Progress</h3>
<span class="o_fp_panel_count"><t t-esc="state.overview.active.length"/></span> <span class="o_fp_panel_count"><t t-esc="state.overview.active.length"/></span>
</div> </div>
<div t-if="!state.overview.active.length" class="o_fp_empty"> <div t-if="!state.overview.active.length" class="o_fp_empty">
@@ -182,13 +175,13 @@
<div class="o_fp_mgr_card_list" t-if="state.overview.active.length"> <div class="o_fp_mgr_card_list" t-if="state.overview.active.length">
<t t-foreach="state.overview.active" t-as="card" t-key="card.mo_id"> <t t-foreach="state.overview.active" t-as="card" t-key="card.mo_id">
<div class="o_fp_mgr_card" <div class="o_fp_mgr_card"
t-att-data-priority="card.priority_any" t-att-data-priority="card.priority_any">
t-att-data-expanded="state.expandedMoId === card.mo_id ? 'y' : 'n'"> <div class="o_fp_mgr_card_head"
<div class="o_fp_mgr_card_head" t-on-click="() => this.toggleCard(card.mo_id)"> t-on-click="() => this.toggleCard(card.mo_id)">
<div> <div>
<div class="o_fp_mgr_card_title"> <div class="o_fp_mgr_card_title">
<strong t-esc="card.mo_name"/> <t t-esc="card.mo_name"/>
<span class="text-muted ms-2">· <t t-esc="card.so_name"/></span> <span class="text-muted ms-2 small">· <t t-esc="card.so_name"/></span>
</div> </div>
<div class="o_fp_mgr_card_sub"> <div class="o_fp_mgr_card_sub">
<t t-esc="card.customer"/> <t t-esc="card.customer"/>
@@ -196,8 +189,7 @@
</div> </div>
</div> </div>
<div class="o_fp_mgr_card_chips"> <div class="o_fp_mgr_card_chips">
<span t-if="card.priority_any >= 2" <span t-if="card.priority_any >= 2" class="o_fp_chip o_fp_chip_danger">HOT</span>
class="o_fp_chip o_fp_chip_danger">HOT</span>
<span class="o_fp_chip o_fp_chip_success"> <span class="o_fp_chip o_fp_chip_success">
<t t-esc="card.wos.length"/> WO <t t-esc="card.wos.length"/> WO
</span> </span>
@@ -208,7 +200,7 @@
<t t-foreach="card.wos" t-as="wo" t-key="wo.id"> <t t-foreach="card.wos" t-as="wo" t-key="wo.id">
<div class="o_fp_mgr_wo_row"> <div class="o_fp_mgr_wo_row">
<div class="o_fp_mgr_wo_info"> <div class="o_fp_mgr_wo_info">
<strong t-esc="wo.name"/> <t t-esc="wo.name"/>
<span class="text-muted ms-2"> <span class="text-muted ms-2">
<t t-esc="wo.workcenter"/> <t t-esc="wo.workcenter"/>
<t t-if="wo.assigned_user_name"> <t t-if="wo.assigned_user_name">
@@ -220,11 +212,11 @@
<span t-att-class="'o_fp_chip o_fp_chip_' + (wo.state === 'progress' ? 'success' : 'info')"> <span t-att-class="'o_fp_chip o_fp_chip_' + (wo.state === 'progress' ? 'success' : 'info')">
<t t-esc="wo.state"/> <t t-esc="wo.state"/>
</span> </span>
<button class="btn btn-sm btn-outline-warning" <button class="btn"
t-on-click="() => this.onTakeOver(wo)"> t-on-click="() => this.onTakeOver(wo)">
Take Over Take Over
</button> </button>
<button class="btn btn-sm btn-outline-secondary" <button class="btn"
t-on-click="() => this.openRecord('mrp.workorder', wo.id)"> t-on-click="() => this.openRecord('mrp.workorder', wo.id)">
Open Open
</button> </button>
@@ -236,10 +228,10 @@
</div> </div>
</section> </section>
<!-- Column 3: Team --> <!-- Team -->
<section class="o_fp_panel o_fp_panel_team"> <section class="o_fp_panel o_fp_panel_team">
<div class="o_fp_panel_head"> <div class="o_fp_panel_head">
<h3><i class="fa fa-users me-2 text-info"/>Team</h3> <h3><i class="fa fa-users"/>Team</h3>
<span class="o_fp_panel_count"><t t-esc="state.overview.team.length"/></span> <span class="o_fp_panel_count"><t t-esc="state.overview.team.length"/></span>
</div> </div>
<div t-if="!state.overview.team.length" class="o_fp_empty"> <div t-if="!state.overview.team.length" class="o_fp_empty">
@@ -268,8 +260,10 @@
</section> </section>
</div> </div>
<!-- ============ Loading ============ -->
<div t-if="!state.overview and !state.loadError" class="o_fp_empty"> <div t-if="!state.overview and !state.loadError" class="o_fp_empty">
<i class="fa fa-spinner fa-spin me-2"/>Loading manager data… <i class="fa fa-spinner fa-spin"/>
<div>Loading manager data…</div>
</div> </div>
</div> </div>
</t> </t>

View File

@@ -1,38 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!-- <!--
Copyright 2026 Nexa Systems Inc. Copyright 2026 Nexa Systems Inc. · License OPL-1
License OPL-1 (Odoo Proprietary License v1.0) Fusion Plating — Tablet Station (Worker view)
Part of the Fusion Plating product family. Rebuilt 2026-04 with the shop-floor design system.
Tablet Station dashboard — KPI strip, My Queue, Active WO,
Baths, Bake Windows, First-Piece Gates, Quality Holds.
--> -->
<templates xml:space="preserve"> <templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.ShopfloorTablet"> <t t-name="fusion_plating_shopfloor.ShopfloorTablet">
<div class="o_fp_tablet"> <div class="o_fp_tablet">
<!-- ===== Header — title, station picker, scan toggle ===== --> <!-- ============ Hero ============ -->
<div class="o_fp_tablet_header"> <header class="o_fp_tablet_header">
<div> <div>
<div class="o_fp_tablet_title"> <h1 class="o_fp_tablet_title">
<i class="fa fa-tablet me-2"/>Shop Floor Tablet <i class="fa fa-tablet"/>
</div> Tablet Station
</h1>
<div class="o_fp_tablet_subtitle" t-if="state.overview"> <div class="o_fp_tablet_subtitle" t-if="state.overview">
<span t-esc="state.overview.user_name"/> <span><t t-esc="state.overview.user_name"/></span>
<t t-if="state.overview.station"> <span t-if="state.overview.station" class="o_fp_tablet_chip">
<span class="o_fp_tablet_chip ms-2"> <i class="fa fa-desktop"/>
<i class="fa fa-desktop me-1"/> <span t-esc="state.overview.station.name"/>
<span t-esc="state.overview.station.name"/> <t t-if="state.overview.station.work_center">
<span t-if="state.overview.station.work_center" class="text-muted"> · <span t-esc="state.overview.station.work_center"/>
<span t-esc="state.overview.station.work_center"/> </t>
</span> </span>
</span>
</t>
</div> </div>
</div> </div>
<div class="o_fp_tablet_header_actions"> <div class="o_fp_tablet_header_actions">
<select class="form-select o_fp_station_picker" <select class="o_fp_station_picker"
t-on-change="onPickStation" t-on-change="onPickStation"
t-if="state.overview"> t-if="state.overview">
<option value="">— Pick station —</option> <option value="">— Pick station —</option>
@@ -44,18 +40,17 @@
</option> </option>
</t> </t>
</select> </select>
<button class="btn btn-outline-primary o_fp_scan_toggle" <button class="o_fp_scan_toggle" t-on-click="toggleScan">
t-on-click="toggleScan">
<i class="fa fa-qrcode me-1"/>Scan <i class="fa fa-qrcode me-1"/>Scan
</button> </button>
</div> </div>
</div> </header>
<!-- ===== Optional scan drawer ===== --> <!-- ============ Scan drawer ============ -->
<div t-if="state.showScan" class="o_fp_scan_drawer"> <div t-if="state.showScan" class="o_fp_scan_drawer">
<input type="text" <input type="text"
class="o_fp_scan_input" class="o_fp_scan_input"
placeholder="Scan QR code (FP-STATION:…, FP-TANK:…, FP-BATH:…, FP-WO:…)" placeholder="Scan QR code (FP-STATION:… FP-TANK:… FP-BATH:… FP-WO:…)"
t-ref="scanInput" t-ref="scanInput"
t-model="state.scannedCode" t-model="state.scannedCode"
t-on-keydown="onScanKey"/> t-on-keydown="onScanKey"/>
@@ -66,13 +61,13 @@
</button> </button>
</div> </div>
<!-- ===== Flash message ===== --> <!-- ============ Flash ============ -->
<div t-if="state.message" <div t-if="state.message"
t-att-class="'o_fp_tablet_message o_fp_msg_' + state.messageType"> t-att-class="'o_fp_tablet_message o_fp_msg_' + state.messageType">
<span t-esc="state.message"/> <span t-esc="state.message"/>
</div> </div>
<!-- ===== KPI strip ===== --> <!-- ============ KPI strip ============ -->
<div class="o_fp_kpi_strip" t-if="state.overview"> <div class="o_fp_kpi_strip" t-if="state.overview">
<t t-foreach="state.overview.kpis" t-as="k" t-key="k.label"> <t t-foreach="state.overview.kpis" t-as="k" t-key="k.label">
<div t-att-class="'o_fp_kpi o_fp_kpi_' + k.tone"> <div t-att-class="'o_fp_kpi o_fp_kpi_' + k.tone">
@@ -83,45 +78,39 @@
</t> </t>
</div> </div>
<!-- ===== Active WO banner (only when one is running) ===== --> <!-- ============ Active WO ============ -->
<div class="o_fp_active_wo" <div class="o_fp_active_wo"
t-if="state.overview and state.overview.active_wo"> t-if="state.overview and state.overview.active_wo">
<div class="o_fp_active_wo_left"> <div class="o_fp_active_wo_left">
<span class="o_fp_active_wo_pulse"/> <span class="o_fp_active_wo_pulse"/>
<div> <div>
<div class="o_fp_active_wo_title"> <div class="o_fp_active_wo_title">
<i class="fa fa-play-circle me-1"/> Active: <strong t-esc="state.overview.active_wo.name"/>
Active Work Order: <strong t-esc="state.overview.active_wo.name"/>
</div> </div>
<div class="o_fp_active_wo_meta"> <div class="o_fp_active_wo_meta">
MO <t t-esc="state.overview.active_wo.mo_name"/> MO <t t-esc="state.overview.active_wo.mo_name"/>
· <t t-esc="state.overview.active_wo.product_name"/> · <t t-esc="state.overview.active_wo.product_name"/>
· Qty <t t-esc="state.overview.active_wo.qty_done"/>/<t t-esc="state.overview.active_wo.qty_total"/> · Qty <t t-esc="state.overview.active_wo.qty_done"/>/<t t-esc="state.overview.active_wo.qty_total"/>
<span t-if="state.overview.active_wo.workcenter" class="ms-2"> <t t-if="state.overview.active_wo.workcenter"> @ <t t-esc="state.overview.active_wo.workcenter"/></t>
@ <t t-esc="state.overview.active_wo.workcenter"/>
</span>
</div> </div>
</div> </div>
</div> </div>
<button class="btn btn-sm btn-outline-primary" <button class="o_fp_big_button"
t-on-click="() => openRecord('mrp.workorder', state.overview.active_wo.id)"> t-on-click="() => openRecord('mrp.workorder', state.overview.active_wo.id)">
Open WO Open WO
</button> </button>
</div> </div>
<!-- ===== Main grid ===== --> <!-- ============ Dashboard ============ -->
<div class="o_fp_tablet_dashboard" t-if="state.overview"> <div class="o_fp_tablet_dashboard" t-if="state.overview">
<!-- === LEFT column: My Queue (wide) === --> <!-- LEFT: My Queue -->
<section class="o_fp_panel o_fp_panel_queue"> <section class="o_fp_panel o_fp_panel_queue">
<div class="o_fp_panel_head"> <div class="o_fp_panel_head">
<h3><i class="fa fa-list-ol me-2"/>My Queue</h3> <h3><i class="fa fa-list-ol"/>My Queue</h3>
<span class="o_fp_panel_count"> <span class="o_fp_panel_count"><t t-esc="state.overview.my_queue.length"/></span>
<t t-esc="state.overview.my_queue.length"/>
</span>
</div> </div>
<div t-if="!state.overview.my_queue.length" <div t-if="!state.overview.my_queue.length" class="o_fp_empty">
class="o_fp_empty">
<i class="fa fa-check-circle text-success"/> <i class="fa fa-check-circle text-success"/>
<div>All caught up — nothing waiting on you.</div> <div>All caught up — nothing waiting on you.</div>
</div> </div>
@@ -140,30 +129,27 @@
</div> </div>
<div class="o_fp_queue_actions"> <div class="o_fp_queue_actions">
<button t-if="row.can_start" <button t-if="row.can_start"
class="btn btn-sm btn-success" class="btn btn-success"
t-on-click="() => this.onStartWo(row.source_id)"> t-on-click="() => this.onStartWo(row.source_id)">
<i class="fa fa-play me-1"/> Start <i class="fa fa-play"/> Start
</button> </button>
<button t-if="row.can_finish" <button t-if="row.can_finish"
class="btn btn-sm btn-primary" class="btn btn-primary"
t-on-click="() => this.onFinishWo(row.source_id)"> t-on-click="() => this.onFinishWo(row.source_id)">
<i class="fa fa-check me-1"/> Finish <i class="fa fa-check"/> Finish
</button> </button>
<i class="fa fa-chevron-right text-muted"
t-if="!row.can_start and !row.can_finish"/>
</div> </div>
</li> </li>
</t> </t>
</ul> </ul>
</section> </section>
<!-- === RIGHT column: Baths + Bakes + Gates + Holds === --> <!-- RIGHT column -->
<div class="o_fp_right_col"> <div class="o_fp_right_col">
<!-- Baths -->
<section class="o_fp_panel"> <section class="o_fp_panel">
<div class="o_fp_panel_head"> <div class="o_fp_panel_head">
<h3><i class="fa fa-flask me-2"/>Baths</h3> <h3><i class="fa fa-flask"/>Baths</h3>
<span class="o_fp_panel_count"><t t-esc="state.overview.baths.length"/></span> <span class="o_fp_panel_count"><t t-esc="state.overview.baths.length"/></span>
</div> </div>
<div t-if="!state.overview.baths.length" class="o_fp_empty"> <div t-if="!state.overview.baths.length" class="o_fp_empty">
@@ -173,12 +159,9 @@
<div class="o_fp_tile_grid" t-if="state.overview.baths.length"> <div class="o_fp_tile_grid" t-if="state.overview.baths.length">
<t t-foreach="state.overview.baths" t-as="b" t-key="b.id"> <t t-foreach="state.overview.baths" t-as="b" t-key="b.id">
<div class="o_fp_tile" <div class="o_fp_tile"
t-att-data-tone="stateBadge(b.last_log_status || b.state)"
t-on-click="() => this.openRecord('fusion.plating.bath', b.id)"> t-on-click="() => this.openRecord('fusion.plating.bath', b.id)">
<div class="o_fp_tile_title"><t t-esc="b.name"/></div> <div class="o_fp_tile_title"><t t-esc="b.name"/></div>
<div class="o_fp_tile_meta"> <div class="o_fp_tile_meta">Tank <t t-esc="b.tank || '—'"/></div>
Tank <t t-esc="b.tank || '—'"/>
</div>
<div class="o_fp_tile_chips"> <div class="o_fp_tile_chips">
<span t-att-class="'o_fp_chip o_fp_chip_' + stateBadge(b.state)"> <span t-att-class="'o_fp_chip o_fp_chip_' + stateBadge(b.state)">
<t t-esc="b.state"/> <t t-esc="b.state"/>
@@ -187,19 +170,15 @@
t-att-class="'o_fp_chip o_fp_chip_' + stateBadge(b.last_log_status)"> t-att-class="'o_fp_chip o_fp_chip_' + stateBadge(b.last_log_status)">
log: <t t-esc="b.last_log_status"/> log: <t t-esc="b.last_log_status"/>
</span> </span>
<span t-if="b.mto" class="o_fp_chip o_fp_chip_muted">
MTO <t t-esc="b.mto"/>
</span>
</div> </div>
</div> </div>
</t> </t>
</div> </div>
</section> </section>
<!-- Bake Windows -->
<section class="o_fp_panel"> <section class="o_fp_panel">
<div class="o_fp_panel_head"> <div class="o_fp_panel_head">
<h3><i class="fa fa-fire me-2"/>Bake Windows</h3> <h3><i class="fa fa-fire"/>Bake Windows</h3>
<span class="o_fp_panel_count"><t t-esc="state.overview.bake_windows.length"/></span> <span class="o_fp_panel_count"><t t-esc="state.overview.bake_windows.length"/></span>
</div> </div>
<div t-if="!state.overview.bake_windows.length" class="o_fp_empty"> <div t-if="!state.overview.bake_windows.length" class="o_fp_empty">
@@ -211,10 +190,10 @@
<li class="o_fp_bake_row" t-att-data-state="bw.state"> <li class="o_fp_bake_row" t-att-data-state="bw.state">
<div class="o_fp_bake_main"> <div class="o_fp_bake_main">
<div class="o_fp_bake_name"> <div class="o_fp_bake_name">
<strong t-esc="bw.name"/> <t t-esc="bw.name"/>
<span class="text-muted ms-1"><t t-esc="bw.part_ref"/></span> <span class="text-muted ms-1"> <t t-esc="bw.part_ref"/></span>
</div> </div>
<div class="o_fp_bake_meta text-muted"> <div class="o_fp_bake_meta">
<t t-esc="bw.customer"/> <t t-esc="bw.customer"/>
· Qty <t t-esc="bw.quantity"/> · Qty <t t-esc="bw.quantity"/>
· Lot <t t-esc="bw.lot_ref"/> · Lot <t t-esc="bw.lot_ref"/>
@@ -227,16 +206,16 @@
</div> </div>
<div class="o_fp_bake_actions"> <div class="o_fp_bake_actions">
<button t-if="bw.state === 'awaiting_bake'" <button t-if="bw.state === 'awaiting_bake'"
class="btn btn-sm btn-warning" class="btn btn-warning"
t-on-click="() => this.onStartBake(bw.id)"> t-on-click="() => this.onStartBake(bw.id)">
Start Start
</button> </button>
<button t-if="bw.state === 'bake_in_progress'" <button t-if="bw.state === 'bake_in_progress'"
class="btn btn-sm btn-success" class="btn btn-success"
t-on-click="() => this.onEndBake(bw.id)"> t-on-click="() => this.onEndBake(bw.id)">
End End
</button> </button>
<button class="btn btn-sm btn-outline-secondary" <button class="btn btn-light"
t-on-click="() => this.openRecord('fusion.plating.bake.window', bw.id)"> t-on-click="() => this.openRecord('fusion.plating.bake.window', bw.id)">
Open Open
</button> </button>
@@ -246,10 +225,9 @@
</ul> </ul>
</section> </section>
<!-- First-Piece Gates -->
<section class="o_fp_panel"> <section class="o_fp_panel">
<div class="o_fp_panel_head"> <div class="o_fp_panel_head">
<h3><i class="fa fa-flag-checkered me-2"/>First-Piece Gates</h3> <h3><i class="fa fa-flag-checkered"/>First-Piece Gates</h3>
<span class="o_fp_panel_count"><t t-esc="state.overview.gates.length"/></span> <span class="o_fp_panel_count"><t t-esc="state.overview.gates.length"/></span>
</div> </div>
<div t-if="!state.overview.gates.length" class="o_fp_empty"> <div t-if="!state.overview.gates.length" class="o_fp_empty">
@@ -261,10 +239,10 @@
<li class="o_fp_bake_row" t-att-data-state="g.result"> <li class="o_fp_bake_row" t-att-data-state="g.result">
<div class="o_fp_bake_main"> <div class="o_fp_bake_main">
<div class="o_fp_bake_name"> <div class="o_fp_bake_name">
<strong t-esc="g.name"/> <t t-esc="g.name"/>
<span class="text-muted ms-1"><t t-esc="g.part_ref"/></span> <span class="text-muted ms-1"> <t t-esc="g.part_ref"/></span>
</div> </div>
<div class="o_fp_bake_meta text-muted"> <div class="o_fp_bake_meta">
<t t-esc="g.customer"/> <t t-esc="g.customer"/>
<t t-if="g.bath"> · Bath <t t-esc="g.bath"/></t> <t t-if="g.bath"> · Bath <t t-esc="g.bath"/></t>
</div> </div>
@@ -276,16 +254,16 @@
</div> </div>
<div class="o_fp_bake_actions"> <div class="o_fp_bake_actions">
<button t-if="g.result === 'pending'" <button t-if="g.result === 'pending'"
class="btn btn-sm btn-success" class="btn btn-success"
t-on-click="() => this.onGateResult(g.id, 'pass')"> t-on-click="() => this.onGateResult(g.id, 'pass')">
Pass Pass
</button> </button>
<button t-if="g.result === 'pending'" <button t-if="g.result === 'pending'"
class="btn btn-sm btn-danger" class="btn btn-danger"
t-on-click="() => this.onGateResult(g.id, 'fail')"> t-on-click="() => this.onGateResult(g.id, 'fail')">
Fail Fail
</button> </button>
<button class="btn btn-sm btn-outline-secondary" <button class="btn btn-light"
t-on-click="() => this.openRecord('fusion.plating.first.piece.gate', g.id)"> t-on-click="() => this.openRecord('fusion.plating.first.piece.gate', g.id)">
Open Open
</button> </button>
@@ -295,10 +273,9 @@
</ul> </ul>
</section> </section>
<!-- Quality Holds -->
<section class="o_fp_panel" t-if="state.overview.holds.length"> <section class="o_fp_panel" t-if="state.overview.holds.length">
<div class="o_fp_panel_head"> <div class="o_fp_panel_head">
<h3 class="text-danger"><i class="fa fa-pause-circle me-2"/>Quality Holds</h3> <h3><i class="fa fa-pause-circle text-danger"/>Quality Holds</h3>
<span class="o_fp_panel_count"><t t-esc="state.overview.holds.length"/></span> <span class="o_fp_panel_count"><t t-esc="state.overview.holds.length"/></span>
</div> </div>
<ul class="o_fp_bake_list"> <ul class="o_fp_bake_list">
@@ -306,17 +283,18 @@
<li class="o_fp_bake_row" t-att-data-state="h.state"> <li class="o_fp_bake_row" t-att-data-state="h.state">
<div class="o_fp_bake_main"> <div class="o_fp_bake_main">
<div class="o_fp_bake_name"> <div class="o_fp_bake_name">
<strong t-esc="h.name"/> <t t-esc="h.name"/>
<span class="text-muted ms-1"><t t-esc="h.part_ref"/></span> <span class="text-muted ms-1"> <t t-esc="h.part_ref"/></span>
</div> </div>
<div class="o_fp_bake_meta text-muted"> <div class="o_fp_bake_meta">
Qty <t t-esc="h.qty"/> Qty <t t-esc="h.qty"/>
· <t t-esc="h.reason"/> · <t t-esc="h.reason"/>
<t t-if="h.operator"> · <t t-esc="h.operator"/></t> <t t-if="h.operator"> · <t t-esc="h.operator"/></t>
</div> </div>
</div> </div>
<div/>
<div class="o_fp_bake_actions"> <div class="o_fp_bake_actions">
<button class="btn btn-sm btn-outline-danger" <button class="btn btn-outline-danger"
t-on-click="() => this.openRecord('fusion.plating.quality.hold', h.id)"> t-on-click="() => this.openRecord('fusion.plating.quality.hold', h.id)">
Review Review
</button> </button>
@@ -328,17 +306,15 @@
</div> </div>
</div> </div>
<!-- ===== Loading / initial state ===== --> <!-- ============ Loading ============ -->
<div t-if="!state.overview" class="o_fp_empty"> <div t-if="!state.overview" class="o_fp_empty">
<i class="fa fa-spinner fa-spin me-2"/>Loading shop-floor data… <i class="fa fa-spinner fa-spin"/>
<div>Loading shop-floor data…</div>
</div> </div>
<!-- ===== Footer — server time ===== --> <!-- ============ Footer ============ -->
<div class="o_fp_tablet_footer text-muted" t-if="state.overview"> <div class="o_fp_tablet_footer" t-if="state.overview">
<small> Auto-refreshed · Last sync <t t-esc="state.overview.server_time"/>
Auto-refresh every 30 s · Last sync
<t t-esc="state.overview.server_time"/>
</small>
</div> </div>
</div> </div>
</t> </t>