feat(shopfloor): tablet Shipping panel on the Job Workspace
Carrier/service/weight inputs + Generate Label + Mark Shipped, shown when the
job is awaiting_ship and gated read-only ("Waiting on: WO-xxxx") until every
job on the order is ready. Reuses workspace card tokens; dark-mode accent
override included.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Shop Floor',
|
||||
'version': '19.0.36.1.0',
|
||||
'version': '19.0.36.1.1',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer.',
|
||||
'description': """
|
||||
|
||||
@@ -50,6 +50,9 @@ export class FpJobWorkspace extends Component {
|
||||
// the active-step timer re-renders without an RPC. The template
|
||||
// reads tickNow and re-runs formatActiveStepElapsed each second.
|
||||
tickNow: Date.now(),
|
||||
// Shipping panel input buffer (carrier/service/weight). Seeded
|
||||
// lazily from the payload defaults inside the handlers below.
|
||||
shipForm: {},
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -611,6 +614,85 @@ export class FpJobWorkspace extends Component {
|
||||
this.notification.add(err.message, { type: "danger" });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Shipping handlers (tablet receiving+shipping 2026-05-29) ----------
|
||||
// All coercion is JS-side (CLAUDE.md Rule 20 — templates only expose Math).
|
||||
onShipInput(field, ev) {
|
||||
const raw = ev.target.value;
|
||||
this.state.shipForm[field] =
|
||||
field === "weight" ? (parseFloat(raw) || 0) : raw;
|
||||
}
|
||||
|
||||
async onGenerateLabel() {
|
||||
const sh = (this.state.data && this.state.data.shipping) || {};
|
||||
const f = this.state.shipForm || {};
|
||||
const weight = f.weight != null ? f.weight : (sh.weight || 0);
|
||||
const serviceType = f.service_type != null ? f.service_type : (sh.service_type || "");
|
||||
const carrierRaw = f.carrier_id != null ? f.carrier_id : (sh.carrier_id || false);
|
||||
const carrierId = carrierRaw ? parseInt(carrierRaw, 10) : false;
|
||||
if (!weight || weight <= 0) {
|
||||
this.notification.add("Enter a non-zero weight before generating the label.", { type: "danger" });
|
||||
return;
|
||||
}
|
||||
if (!carrierId) {
|
||||
this.notification.add("Pick an outbound carrier first.", { type: "danger" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await rpc("/fp/workspace/generate_label", {
|
||||
job_id: this.state.jobId,
|
||||
weight: weight,
|
||||
service_type: serviceType,
|
||||
carrier_id: carrierId,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
this.notification.add(
|
||||
"Label generated — tracking " + (res.tracking_number || "n/a"),
|
||||
{ type: "success" },
|
||||
);
|
||||
await this.refresh();
|
||||
} else {
|
||||
this.notification.add((res && res.error) || "Label generation failed", { type: "danger" });
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(err.message || String(err), { type: "danger" });
|
||||
}
|
||||
}
|
||||
|
||||
async onViewLabel() {
|
||||
const sh = (this.state.data && this.state.data.shipping) || {};
|
||||
if (!sh.label_attachment_id) return;
|
||||
try {
|
||||
// Route through fusion_pdf_preview (CLAUDE.md PDF-preview rule).
|
||||
const action = await rpc("/web/dataset/call_kw", {
|
||||
model: "ir.attachment",
|
||||
method: "action_fusion_preview",
|
||||
args: [[sh.label_attachment_id]],
|
||||
kwargs: { title: "Shipping Label" },
|
||||
});
|
||||
if (action) await this.action.doAction(action);
|
||||
} catch (err) {
|
||||
// Fallback: plain content open if the preview helper is absent.
|
||||
window.open("/web/content/" + sh.label_attachment_id, "_blank");
|
||||
}
|
||||
}
|
||||
|
||||
async onMarkShipped() {
|
||||
try {
|
||||
const res = await rpc("/fp/workspace/mark_shipped", { job_id: this.state.jobId });
|
||||
if (res && res.ok) {
|
||||
this.notification.add(
|
||||
"Marked shipped: " + ((res.shipped || []).join(", ") || "done"),
|
||||
{ type: "success" },
|
||||
);
|
||||
await this.refresh();
|
||||
} else {
|
||||
this.notification.add((res && res.error) || "Mark shipped failed", { type: "danger" });
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(err.message || String(err), { type: "danger" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("fp_job_workspace", FpJobWorkspace);
|
||||
|
||||
@@ -385,6 +385,69 @@ $_ws-text-hex: #1d1d1f;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SHIPPING PANEL (tablet receiving+shipping 2026-05-29)
|
||||
// =============================================================================
|
||||
// Reuses the workspace card tokens ($_ws-card-hex/$_ws-border-hex are
|
||||
// already dark-aware via the @if branch near the top of this file), so the
|
||||
// surface adapts to dark mode automatically. Only the blue left-accent is a
|
||||
// raw hex, and it gets its own dark override below (CLAUDE.md dark-mode rule).
|
||||
|
||||
.o_fp_ws_ship {
|
||||
background: $_ws-card-hex;
|
||||
border: 1px solid $_ws-border-hex;
|
||||
border-left: 4px solid #0071e3;
|
||||
border-radius: 8px;
|
||||
padding: 0.7rem 0.85rem;
|
||||
margin-bottom: 0.7rem;
|
||||
|
||||
.o_fp_ws_ship_head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.o_fp_ws_ship_icon { font-size: 1.2rem; }
|
||||
.o_fp_ws_ship_title { font-size: 1rem; }
|
||||
|
||||
.o_fp_ws_ship_waiting {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary, #777);
|
||||
}
|
||||
|
||||
.o_fp_ws_ship_fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.6rem;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
min-width: 150px;
|
||||
flex: 1;
|
||||
}
|
||||
.form-select, .form-control { margin-top: 0.2rem; }
|
||||
}
|
||||
|
||||
.o_fp_ws_ship_actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
.o_fp_ws_ship { border-left-color: #6cb6ff; }
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PRE-RECIPE RECEIVING CARD (Spec C1+C2 2026-05-24)
|
||||
// =============================================================================
|
||||
|
||||
@@ -213,6 +213,83 @@
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- SHIPPING PANEL (tablet receiving+shipping 2026-05-29)
|
||||
Shown when the job is awaiting_ship. Actions are
|
||||
enabled only when ALL of the order's jobs are
|
||||
awaiting_ship (spec D4 ship-together). -->
|
||||
<div t-if="state.data.shipping" class="o_fp_ws_ship">
|
||||
<div class="o_fp_ws_ship_head">
|
||||
<span class="o_fp_ws_ship_icon">🚚</span>
|
||||
<span class="o_fp_ws_ship_title">Ship Order</span>
|
||||
<span t-if="state.data.shipping.has_label"
|
||||
class="o_fp_chip o_fp_chip_info">
|
||||
Label ready · <t t-esc="state.data.shipping.tracking_number or 'no tracking'"/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Not-ready: read-only, list the blocking jobs -->
|
||||
<div t-if="!state.data.shipping.ready"
|
||||
class="o_fp_ws_ship_waiting">
|
||||
<i class="fa fa-hourglass-half"/> Waiting on:
|
||||
<t t-foreach="state.data.shipping.not_ready"
|
||||
t-as="nr" t-key="nr.wo_name">
|
||||
<span class="o_fp_chip o_fp_chip_warning">
|
||||
<t t-esc="nr.wo_name"/> — <t t-esc="nr.state_label"/>
|
||||
</span>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Ready: inputs + actions -->
|
||||
<t t-if="state.data.shipping.ready">
|
||||
<div class="o_fp_ws_ship_fields">
|
||||
<label>Carrier
|
||||
<select class="form-select"
|
||||
t-on-change="(ev) => this.onShipInput('carrier_id', ev)">
|
||||
<option value="">— pick carrier —</option>
|
||||
<t t-foreach="state.data.shipping.carrier_options"
|
||||
t-as="c" t-key="c.id">
|
||||
<option t-att-value="c.id"
|
||||
t-att-selected="c.id === state.data.shipping.carrier_id">
|
||||
<t t-esc="c.name"/>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
</label>
|
||||
<label>Service
|
||||
<select class="form-select"
|
||||
t-on-change="(ev) => this.onShipInput('service_type', ev)">
|
||||
<option value="">Carrier default</option>
|
||||
<t t-foreach="state.data.shipping.service_options"
|
||||
t-as="s" t-key="s[0]">
|
||||
<option t-att-value="s[0]"
|
||||
t-att-selected="s[0] === state.data.shipping.service_type">
|
||||
<t t-esc="s[1]"/>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
</label>
|
||||
<label>Weight
|
||||
<input type="number" step="0.001" inputmode="decimal"
|
||||
class="form-control"
|
||||
t-att-value="state.data.shipping.weight || ''"
|
||||
t-on-blur="(ev) => this.onShipInput('weight', ev)"/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="o_fp_ws_ship_actions">
|
||||
<button class="btn btn-primary me-2" t-on-click="onGenerateLabel">
|
||||
<i class="fa fa-tag"/> Generate Label
|
||||
</button>
|
||||
<button t-if="state.data.shipping.has_label"
|
||||
class="btn btn-light me-2" t-on-click="onViewLabel">
|
||||
<i class="fa fa-print"/> View / Print Label
|
||||
</button>
|
||||
<button class="btn btn-success" t-on-click="onMarkShipped">
|
||||
<i class="fa fa-check-circle"/> Mark Shipped
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<div t-if="!state.data.steps.length" class="o_fp_ws_empty">
|
||||
<i class="fa fa-exclamation-circle fa-2x"/>
|
||||
<div>Recipe not generated for this WO.</div>
|
||||
|
||||
Reference in New Issue
Block a user