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:
gsinghpal
2026-05-29 09:19:51 -04:00
parent f1273798cd
commit f75e082e67
4 changed files with 223 additions and 1 deletions

View File

@@ -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': """

View File

@@ -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);

View File

@@ -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)
// =============================================================================

View File

@@ -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>