feat(simple-editor): node_type bug fix + inline library authoring + back nav

Bucket 1 — Generation bug fix
- post-migrate.py for 19.0.18.8.0 promotes flat 'step' children of
  recipes to 'operation' so fp.job._generate_steps() picks them up.
  Filter is narrow: only direct children of node_type='recipe' get
  flipped, tree-editor sub-steps (parent.node_type='operation') are
  untouched. Idempotent. Posts an audit chatter note on each affected
  recipe.
- Simple Editor controller hardcodes node_type='operation' on insert
  + snapshot-import path so future recipes start correct.

Bucket 2 — Inline library authoring
- 6 new JSONRPC routes (/fp/simple_recipe/library/load + save +
  seed_defaults + input/{add,write,remove}, /fp/simple_recipe/tank/list).
- + New Step button in the right pane opens an inline form with name /
  kind / icon / instructions / stations / flags / prompts table.
- Pencil icon on each library row reopens the same form prefilled.
- Step Kind picker leads with 'Generic — no automatic behaviour'.
- 'Seed defaults from kind' calls action_seed_default_inputs server-side
  for kinds that have curated default prompts.

Bucket 3 — Back nav
- '← Recipes' button in the header (or '← Part' when opened from
  Process Composer) mirrors recipe_tree_editor.js, with
  clearBreadcrumbs:true to avoid stack pollution.

Verified on entech: LGPS1104's 19 'step' children now show as
'operation', migration chatter note posted on the recipe, asset cache
busted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-30 16:16:14 -04:00
parent b8d064b180
commit 4213c44e51
6 changed files with 954 additions and 23 deletions

View File

@@ -44,13 +44,25 @@ export class FpSimpleRecipeEditor extends Component {
editingStepId: null,
editName: "",
editInstructions: "",
// Inline library form — open when authoring or editing a
// library template directly from the right pane. null =
// closed; otherwise carries the template payload.
libraryEditor: null,
libraryEditorBusy: false,
tankSearchResults: [],
});
this._recipeId = null;
this._partId = null;
onMounted(async () => {
const ctx = this.props.action?.context || {};
this._recipeId = ctx.recipe_id || null;
this._partId = ctx.part_id || null;
// Mirror onto state so the OWL template can branch on it
// (template scope sees state directly, not arbitrary
// instance properties).
this.state.fromPart = !!this._partId;
if (this._recipeId) {
await this.loadAll();
} else {
@@ -173,6 +185,229 @@ export class FpSimpleRecipeEditor extends Component {
});
}
// ----------------------------------------------------- back navigation
/**
* Mirror of recipe_tree_editor.onBackToList. When the editor was
* opened from the part-scoped Process Composer, return to that part
* form; otherwise drop the user back on the Recipes list.
*
* `clearBreadcrumbs: true` is critical — without it, every part →
* composer → editor → back leaves intermediate pages on the
* breadcrumb stack so a second visit shows nonsense.
*/
onBackToList() {
if (this._partId) {
this.action.doAction(
{
type: "ir.actions.act_window",
res_model: "fp.part.catalog",
res_id: this._partId,
views: [[false, "form"]],
target: "current",
},
{ clearBreadcrumbs: true }
);
return;
}
this.action.doAction("fusion_plating.action_fp_process_recipe", {
clearBreadcrumbs: true,
});
}
// -------------------------------------------------- inline library form
/**
* Open the inline form for a NEW library template. Skeleton payload
* mirrors the shape returned by `/fp/simple_recipe/library/load` so
* the same template renders both create + edit.
*/
onOpenLibraryCreate() {
this.state.libraryEditor = {
id: null, // null = create
name: "",
code: "",
icon: "fa-cog",
default_kind: "",
description: "",
requires_signoff: false,
requires_predecessor_done: false,
requires_rack_assignment: false,
requires_transition_form: false,
tank_ids: [],
inputs: [],
};
this.state.tankSearchResults = [];
}
async onOpenLibraryEdit(templateId) {
this.state.libraryEditorBusy = true;
const data = await rpc("/fp/simple_recipe/library/load", {
template_id: templateId,
});
if (data.ok) {
// Defensive copy — OWL useState wraps top-level fields, but
// we want to be able to mutate this.state.libraryEditor.* in
// place without triggering library list re-renders.
this.state.libraryEditor = JSON.parse(JSON.stringify(data.template));
} else {
this.notification.add(
_t("Could not load library template — it may have been deleted."),
{ type: "warning" }
);
}
this.state.libraryEditorBusy = false;
}
onCancelLibraryEditor() {
this.state.libraryEditor = null;
this.state.tankSearchResults = [];
}
async onSaveLibraryEditor() {
const ed = this.state.libraryEditor;
if (!ed) return;
if (!(ed.name || "").trim()) {
this.notification.add(_t("Name is required."), { type: "warning" });
return;
}
this.state.libraryEditorBusy = true;
const vals = {
name: ed.name,
code: ed.code,
icon: ed.icon,
default_kind: ed.default_kind || false,
description: ed.description,
requires_signoff: !!ed.requires_signoff,
requires_predecessor_done: !!ed.requires_predecessor_done,
requires_rack_assignment: !!ed.requires_rack_assignment,
requires_transition_form: !!ed.requires_transition_form,
tank_ids: (ed.tank_ids || []).map((t) => t.id),
};
const result = await rpc("/fp/simple_recipe/library/save", {
template_id: ed.id || false,
vals: vals,
});
if (result.ok) {
// Refresh in place so the Inputs section reflects DB state
// (e.g. id assigned to a freshly-created template).
this.state.libraryEditor = JSON.parse(JSON.stringify(result.template));
// Refresh the library list in the right pane.
const libData = await rpc("/fp/simple_recipe/library/list", {
query: this.state.librarySearch || "",
});
this.state.library = libData.templates;
this.notification.add(
ed.id ? _t("Library step updated") : _t("Library step created"),
{ type: "success" }
);
} else {
this.notification.add(
result.error || _t("Save failed"),
{ type: "danger" }
);
}
this.state.libraryEditorBusy = false;
}
async onSeedLibraryDefaults() {
const ed = this.state.libraryEditor;
if (!ed || !ed.id) {
this.notification.add(
_t("Save the step first, then seed defaults."),
{ type: "warning" }
);
return;
}
if (!ed.default_kind) {
this.notification.add(
_t("Pick a Step Kind first to seed defaults."),
{ type: "warning" }
);
return;
}
const result = await rpc("/fp/simple_recipe/library/seed_defaults", {
template_id: ed.id,
});
if (result.ok) {
this.state.libraryEditor = JSON.parse(JSON.stringify(result.template));
this.notification.add(_t("Default prompts seeded"), { type: "success" });
} else {
this.notification.add(
result.message || _t("Seed failed"),
{ type: "warning" }
);
}
}
async onAddLibraryInput() {
const ed = this.state.libraryEditor;
if (!ed || !ed.id) {
this.notification.add(
_t("Save the step first, then add prompts."),
{ type: "warning" }
);
return;
}
const result = await rpc("/fp/simple_recipe/library/input/add", {
template_id: ed.id,
payload: { name: _t("New Prompt"), input_type: "text" },
});
if (result.ok) {
this.state.libraryEditor = JSON.parse(JSON.stringify(result.template));
}
}
async onLibraryInputBlur(inputId, field, ev) {
const value = ev.target.value;
if (field === "name" && !value.trim()) return;
await this._libraryInputWrite(inputId, field, value);
}
async onLibraryInputChange(inputId, field, value) {
await this._libraryInputWrite(inputId, field, value);
}
async _libraryInputWrite(inputId, field, value) {
const payload = {};
payload[field] = value;
const result = await rpc("/fp/simple_recipe/library/input/write", {
input_id: inputId,
payload: payload,
});
if (result.ok) {
this.state.libraryEditor = JSON.parse(JSON.stringify(result.template));
}
}
async onRemoveLibraryInput(inputId) {
const proceed = await this._confirm(_t("Remove this prompt?"));
if (!proceed) return;
const result = await rpc("/fp/simple_recipe/library/input/remove", {
input_id: inputId,
});
if (result.ok) {
this.state.libraryEditor = JSON.parse(JSON.stringify(result.template));
}
}
async onSearchTanks(ev) {
const q = ev.target.value;
const data = await rpc("/fp/simple_recipe/tank/list", { query: q });
this.state.tankSearchResults = data.tanks;
}
onAddTank(tank) {
const ed = this.state.libraryEditor;
if (!ed) return;
if (ed.tank_ids.some((t) => t.id === tank.id)) return;
ed.tank_ids.push(tank);
}
onRemoveTank(tankId) {
const ed = this.state.libraryEditor;
if (!ed) return;
ed.tank_ids = ed.tank_ids.filter((t) => t.id !== tankId);
}
// --------------------------------------------------------- drag & drop
onSelectedDragStart(stepId, ev) {

View File

@@ -321,3 +321,174 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex});
text-align: center;
color: $fp-se-muted;
}
// ---- Back button + library header (Bucket 3) ----
.o_fp_se_back {
color: $fp-se-accent;
margin-right: 1rem;
padding: .25rem .5rem;
font-weight: 500;
&:hover {
color: $fp-se-accent;
text-decoration: underline;
}
}
.o_fp_library_header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: .5rem;
h3 { margin: 0; }
}
.o_fp_library_item {
.o_fp_library_edit {
background: transparent;
border: 0;
color: $fp-se-muted;
padding: .25rem .5rem;
cursor: pointer;
opacity: 0;
transition: opacity .15s;
&:hover { color: $fp-se-accent; }
}
&:hover .o_fp_library_edit { opacity: 1; }
}
// ---- Inline library editor (Bucket 2) ----
.o_fp_library_editor {
background: $fp-se-card;
border: 1px solid $fp-se-border;
border-radius: 6px;
padding: 1rem;
max-height: 80vh;
overflow: auto;
&.o_fp_busy { opacity: .65; pointer-events: none; }
}
.o_fp_library_editor_header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
padding-bottom: .75rem;
border-bottom: 1px solid $fp-se-border;
h3 { margin: 0; font-size: 1.1rem; }
}
.o_fp_library_editor_body {
display: flex;
flex-direction: column;
gap: 1rem;
}
.o_fp_le_row {
display: flex;
gap: 1rem;
.o_fp_le_field { flex: 1; }
}
.o_fp_le_field {
display: flex;
flex-direction: column;
gap: .25rem;
label {
font-size: .85rem;
font-weight: 500;
color: $fp-se-muted;
}
}
.o_fp_le_tank_chips {
display: flex;
flex-wrap: wrap;
gap: .35rem;
margin-bottom: .25rem;
}
.o_fp_le_tank_chip {
display: inline-flex;
align-items: center;
gap: .35rem;
padding: .15rem .5rem;
background: rgba(0, 100, 200, .1);
border-radius: 12px;
font-size: .85rem;
.o_fp_le_tank_remove {
background: transparent;
border: 0;
cursor: pointer;
font-size: 1rem;
line-height: 1;
color: $fp-se-muted;
&:hover { color: red; }
}
}
.o_fp_le_tank_results {
border: 1px solid $fp-se-border;
border-radius: 4px;
margin-top: .25rem;
max-height: 12rem;
overflow: auto;
background: $fp-se-card;
}
.o_fp_le_tank_option {
padding: .35rem .5rem;
cursor: pointer;
border-bottom: 1px solid $fp-se-border;
&:last-child { border-bottom: 0; }
&:hover { background: rgba(0, 100, 200, .08); }
}
.o_fp_le_flags {
display: grid;
grid-template-columns: 1fr 1fr;
gap: .35rem .75rem;
label {
display: flex;
align-items: center;
gap: .35rem;
font-size: .9rem;
cursor: pointer;
}
}
.o_fp_le_prompts {
border-top: 1px solid $fp-se-border;
padding-top: 1rem;
}
.o_fp_le_prompts_header {
margin-bottom: .5rem;
}
.o_fp_le_prompt_actions {
display: flex;
gap: .5rem;
margin-top: .25rem;
}
.o_fp_library_editor_actions {
margin-top: 1rem;
padding-top: .75rem;
border-top: 1px solid $fp-se-border;
display: flex;
gap: .5rem;
justify-content: flex-end;
}

View File

@@ -4,6 +4,13 @@
<t t-name="fusion_plating.FpSimpleRecipeEditor">
<div class="o_fp_simple_editor">
<div class="o_fp_simple_editor_header">
<button class="btn btn-link o_fp_se_back"
t-on-click="onBackToList"
t-att-title="state.fromPart ? 'Back to part' : 'Back to recipes'">
<i class="fa fa-arrow-left me-2"/>
<t t-if="state.fromPart">Part</t>
<t t-else="">Recipes</t>
</button>
<h2 t-if="state.recipe">
Recipe: <span t-esc="state.recipe.name"/>
</h2>
@@ -256,27 +263,287 @@
</div>
<div class="o_fp_library_panel">
<h3>Step Library</h3>
<input type="text" class="form-control"
placeholder="Search…"
t-on-input="onSearchLibrary"
t-att-value="state.librarySearch"/>
<div class="o_fp_library_list">
<t t-foreach="state.library" t-as="tpl" t-key="tpl.id">
<div class="o_fp_library_item"
draggable="true"
t-on-dragstart="(ev) => this.onLibraryDragStart(tpl.id, ev)">
<i t-att-class="'fa ' + (tpl.icon || 'fa-cog')"/>
<span class="o_fp_library_name" t-esc="tpl.name"/>
<span class="o_fp_library_meta" t-if="tpl.station_count">
<t t-esc="tpl.station_count"/> st.
</span>
</div>
</t>
<div class="o_fp_library_empty" t-if="!state.library.length">
No library entries match your search.
<!-- ============== LIBRARY LIST (default view) ============== -->
<t t-if="!state.libraryEditor">
<div class="o_fp_library_header">
<h3>Step Library</h3>
<button class="btn btn-sm btn-primary o_fp_lib_new"
t-on-click="onOpenLibraryCreate"
title="Create a new library step (with prompts)">
<i class="fa fa-plus me-1"/>New Step
</button>
</div>
</div>
<input type="text" class="form-control"
placeholder="Search…"
t-on-input="onSearchLibrary"
t-att-value="state.librarySearch"/>
<div class="o_fp_library_list">
<t t-foreach="state.library" t-as="tpl" t-key="tpl.id">
<div class="o_fp_library_item"
draggable="true"
t-on-dragstart="(ev) => this.onLibraryDragStart(tpl.id, ev)">
<i t-att-class="'fa ' + (tpl.icon || 'fa-cog')"/>
<span class="o_fp_library_name" t-esc="tpl.name"/>
<span class="o_fp_library_meta" t-if="tpl.station_count">
<t t-esc="tpl.station_count"/> st.
</span>
<button class="o_fp_library_edit"
title="Edit this library step"
t-on-click.stop="() => this.onOpenLibraryEdit(tpl.id)">
<i class="fa fa-pencil"/>
</button>
</div>
</t>
<div class="o_fp_library_empty" t-if="!state.library.length">
No library entries match your search.
</div>
</div>
</t>
<!-- ============== INLINE LIBRARY EDITOR ============== -->
<t t-if="state.libraryEditor">
<div class="o_fp_library_editor"
t-att-class="state.libraryEditorBusy ? 'o_fp_busy' : ''">
<div class="o_fp_library_editor_header">
<h3 t-if="!state.libraryEditor.id">+ New Library Step</h3>
<h3 t-else="">Edit: <t t-esc="state.libraryEditor.name"/></h3>
<button class="btn btn-link btn-sm"
t-on-click="onCancelLibraryEditor"
title="Close without saving recent unsaved field">
<i class="fa fa-times"/>
</button>
</div>
<div class="o_fp_library_editor_body">
<div class="o_fp_le_row">
<div class="o_fp_le_field">
<label>Name *</label>
<input type="text" class="form-control"
t-model="state.libraryEditor.name"
placeholder="e.g. Surface Activation"/>
</div>
<div class="o_fp_le_field">
<label>Code</label>
<input type="text" class="form-control"
t-model="state.libraryEditor.code"
placeholder="e.g. SURF_ACT"/>
</div>
</div>
<div class="o_fp_le_row">
<div class="o_fp_le_field">
<label>Step Kind
<i class="fa fa-question-circle ms-1"
title="Picking a kind auto-seeds prompts and turns on workflow gates (Contract Review, Racking, Bake). Leave blank for plain generic steps."/>
</label>
<select class="form-select"
t-model="state.libraryEditor.default_kind">
<option value="">Generic — no automatic behaviour</option>
<option value="receiving">Receiving / Incoming Inspection</option>
<option value="contract_review">Contract Review (QA-005)</option>
<option value="racking">Racking</option>
<option value="mask">Masking</option>
<option value="cleaning">Cleaning</option>
<option value="electroclean">Electroclean</option>
<option value="etch">Etch / Activation</option>
<option value="rinse">Rinse</option>
<option value="strike">Strike (Wood's Nickel / Activation)</option>
<option value="plate">Plating</option>
<option value="replenishment">Tank Replenishment</option>
<option value="wbf_test">Water Break Free Test</option>
<option value="dry">Drying</option>
<option value="bake">Bake (HE Relief / Stress Relief)</option>
<option value="demask">De-Masking</option>
<option value="derack">De-Racking</option>
<option value="inspect">Inspection</option>
<option value="hardness_test">Hardness Test</option>
<option value="adhesion_test">Adhesion Test</option>
<option value="salt_spray">Salt Spray / Corrosion Test</option>
<option value="final_inspect">Final Inspection</option>
<option value="packaging">Packaging / Pre-Ship</option>
<option value="ship">Shipping</option>
<option value="gating">Gating</option>
</select>
</div>
<div class="o_fp_le_field">
<label>Icon</label>
<input type="text" class="form-control"
t-model="state.libraryEditor.icon"
placeholder="e.g. fa-flask"/>
</div>
</div>
<div class="o_fp_le_field">
<label>Default Operator Instructions</label>
<textarea class="form-control" rows="3"
t-model="state.libraryEditor.description"
placeholder="Standing instructions for this step. Snapshot-copied into recipes when authors drag it in."/>
</div>
<div class="o_fp_le_field">
<label>Allowed Stations</label>
<div class="o_fp_le_tank_chips">
<t t-foreach="state.libraryEditor.tank_ids" t-as="tnk" t-key="tnk.id">
<span class="o_fp_le_tank_chip">
<t t-esc="tnk.name"/>
<button class="o_fp_le_tank_remove"
t-on-click="() => this.onRemoveTank(tnk.id)">×</button>
</span>
</t>
</div>
<input type="text" class="form-control form-control-sm"
placeholder="Search tanks to add…"
t-on-input="onSearchTanks"/>
<div class="o_fp_le_tank_results"
t-if="state.tankSearchResults and state.tankSearchResults.length">
<t t-foreach="state.tankSearchResults" t-as="tnk" t-key="tnk.id">
<div class="o_fp_le_tank_option"
t-on-click="() => this.onAddTank(tnk)">
+ <t t-esc="tnk.name"/>
<small t-if="tnk.code" class="text-muted ms-1">(<t t-esc="tnk.code"/>)</small>
</div>
</t>
</div>
</div>
<div class="o_fp_le_flags">
<label>
<input type="checkbox"
t-model="state.libraryEditor.requires_signoff"/>
Require QA Sign-off
</label>
<label>
<input type="checkbox"
t-model="state.libraryEditor.requires_predecessor_done"/>
Require Predecessor Done
</label>
<label>
<input type="checkbox"
t-model="state.libraryEditor.requires_rack_assignment"/>
Requires Rack Assignment
</label>
<label>
<input type="checkbox"
t-model="state.libraryEditor.requires_transition_form"/>
Requires Transition Form
</label>
</div>
<!-- ============== PROMPTS ============== -->
<div class="o_fp_le_prompts">
<div class="o_fp_le_prompts_header">
<strong>Operation Measurements</strong>
<span class="text-muted ms-2 small">
What the operator records during this step. Snapshot-copied into recipes.
</span>
</div>
<table class="table table-sm o_fp_inputs_table"
t-if="state.libraryEditor.inputs and state.libraryEditor.inputs.length">
<thead>
<tr>
<th>Prompt</th>
<th style="width:160px;">Type</th>
<th style="width:90px;">Min</th>
<th style="width:90px;">Max</th>
<th style="width:80px;">Unit</th>
<th style="width:60px;">Req</th>
<th style="width:36px;"></th>
</tr>
</thead>
<tbody>
<tr t-foreach="state.libraryEditor.inputs" t-as="inp" t-key="inp.id">
<td>
<input type="text" class="form-control form-control-sm"
t-att-value="inp.name"
t-on-blur="(ev) => this.onLibraryInputBlur(inp.id, 'name', ev)"/>
</td>
<td>
<select class="form-select form-select-sm"
t-on-change="(ev) => this.onLibraryInputChange(inp.id, 'input_type', ev.target.value)">
<option value="text" t-att-selected="inp.input_type === 'text'">Text</option>
<option value="number" t-att-selected="inp.input_type === 'number'">Number</option>
<option value="boolean" t-att-selected="inp.input_type === 'boolean'">Yes/No</option>
<option value="selection" t-att-selected="inp.input_type === 'selection'">Selection</option>
<option value="date" t-att-selected="inp.input_type === 'date'">Date / Time</option>
<option value="signature" t-att-selected="inp.input_type === 'signature'">Signature</option>
<option value="time_hms" t-att-selected="inp.input_type === 'time_hms'">Time (HH:MM:SS)</option>
<option value="time_seconds" t-att-selected="inp.input_type === 'time_seconds'">Time (sec)</option>
<option value="temperature" t-att-selected="inp.input_type === 'temperature'">Temperature</option>
<option value="thickness" t-att-selected="inp.input_type === 'thickness'">Thickness</option>
<option value="pass_fail" t-att-selected="inp.input_type === 'pass_fail'">Pass / Fail</option>
<option value="photo" t-att-selected="inp.input_type === 'photo'">Photo</option>
<option value="multi_point_thickness" t-att-selected="inp.input_type === 'multi_point_thickness'">Multi-Point Thickness</option>
<option value="bath_chemistry_panel" t-att-selected="inp.input_type === 'bath_chemistry_panel'">Bath Chemistry Panel</option>
<option value="ph" t-att-selected="inp.input_type === 'ph'">pH</option>
</select>
</td>
<td>
<input type="number" step="any" class="form-control form-control-sm"
t-att-value="inp.target_min"
t-on-blur="(ev) => this.onLibraryInputBlur(inp.id, 'target_min', ev)"/>
</td>
<td>
<input type="number" step="any" class="form-control form-control-sm"
t-att-value="inp.target_max"
t-on-blur="(ev) => this.onLibraryInputBlur(inp.id, 'target_max', ev)"/>
</td>
<td>
<input type="text" class="form-control form-control-sm"
t-att-value="inp.target_unit"
placeholder=""
t-on-blur="(ev) => this.onLibraryInputBlur(inp.id, 'target_unit', ev)"/>
</td>
<td class="text-center">
<input type="checkbox"
t-att-checked="inp.required"
t-on-change="(ev) => this.onLibraryInputChange(inp.id, 'required', ev.target.checked)"/>
</td>
<td>
<button class="btn btn-link btn-sm text-danger p-0"
title="Remove prompt"
t-on-click="() => this.onRemoveLibraryInput(inp.id)">×</button>
</td>
</tr>
</tbody>
</table>
<p t-if="!state.libraryEditor.inputs or !state.libraryEditor.inputs.length"
class="text-muted small">
<t t-if="!state.libraryEditor.id">
Save the step first, then add prompts.
</t>
<t t-else="">
No prompts yet — operators will not be asked for measurements at runtime.
</t>
</p>
<div class="o_fp_le_prompt_actions"
t-if="state.libraryEditor.id">
<button class="btn btn-link btn-sm"
t-on-click="onAddLibraryInput">
<i class="fa fa-plus"/> Add prompt
</button>
<button class="btn btn-link btn-sm"
t-if="state.libraryEditor.default_kind"
t-on-click="onSeedLibraryDefaults"
title="Append the canonical prompts for this Step Kind. Idempotent — won't duplicate existing prompts.">
<i class="fa fa-magic"/> Seed defaults from kind
</button>
</div>
</div>
</div>
<div class="o_fp_library_editor_actions">
<button class="btn btn-primary"
t-on-click="onSaveLibraryEditor"
t-att-disabled="state.libraryEditorBusy">
<i class="fa fa-save me-1"/>Save
</button>
<button class="btn btn-secondary"
t-on-click="onCancelLibraryEditor">
Close
</button>
</div>
</div>
</t>
</div>
</div>