From d6cdae30ec7632102f1a2081d356acb021970909 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Mon, 27 Apr 2026 20:42:06 -0400 Subject: [PATCH] feat(sub12a): OWL Simple Recipe Editor client action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JS: FpSimpleRecipeEditor component reads recipe_id from props.action.context (matches the existing tree editor's contract). HTML5 native drag-drop between Library (right) and Selected (left) panels — uses two distinct dataTransfer types (application/x-fp-step vs application/x-fp-library) so the drop handler knows whether to reorder or snapshot-copy. XML: 2-column grid layout. Selected has per-row × remove (hover reveal), drag handle, position number, icon, name, station-count badge. Library has search input, scrollable item list with empty- state, drag-handle items. SCSS: tokens follow the fp_shopfloor pattern with dark-mode SCSS @if branch (CLAUDE.md rule). 2-fr grid that collapses to single column under 900px for tablet/mobile. Tag: fp_simple_recipe_editor — registered via registry.category('actions'). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../static/src/js/simple_recipe_editor.js | 211 ++++++++++++++++++ .../static/src/scss/simple_recipe_editor.scss | 203 +++++++++++++++++ .../static/src/xml/simple_recipe_editor.xml | 106 +++++++++ 3 files changed, 520 insertions(+) create mode 100644 fusion_plating/fusion_plating/static/src/js/simple_recipe_editor.js create mode 100644 fusion_plating/fusion_plating/static/src/scss/simple_recipe_editor.scss create mode 100644 fusion_plating/fusion_plating/static/src/xml/simple_recipe_editor.xml diff --git a/fusion_plating/fusion_plating/static/src/js/simple_recipe_editor.js b/fusion_plating/fusion_plating/static/src/js/simple_recipe_editor.js new file mode 100644 index 00000000..0eb2e43d --- /dev/null +++ b/fusion_plating/fusion_plating/static/src/js/simple_recipe_editor.js @@ -0,0 +1,211 @@ +/** @odoo-module */ +/* + * Sub 12a — Simple Recipe Editor (OWL client action). + * + * Flat drag-drop alternative to the tree editor. Library on the right, + * Selected (ordered steps) on the left. Drag from library → snapshot- + * copy via /fp/simple_recipe/step/insert. Drag-reorder within Selected + * → /fp/simple_recipe/step/reorder. Same recipe data either editor. + */ + +import { Component, onMounted, useState } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { rpc } from "@web/core/network/rpc"; +import { useService } from "@web/core/utils/hooks"; +import { _t } from "@web/core/l10n/translation"; + + +export class FpSimpleRecipeEditor extends Component { + static template = "fusion_plating.FpSimpleRecipeEditor"; + static props = ["*"]; + + setup() { + this.action = useService("action"); + this.notification = useService("notification"); + this.dialog = useService("dialog"); + + this.state = useState({ + loading: true, + recipe: null, + steps: [], + library: [], + librarySearch: "", + templateOptions: [], + selectedTemplate: "", + dragOverIndex: null, + }); + + this._recipeId = null; + + onMounted(async () => { + const ctx = this.props.action?.context || {}; + this._recipeId = ctx.recipe_id || null; + if (this._recipeId) { + await this.loadAll(); + } else { + this.state.loading = false; + this.notification.add( + _t("No recipe context provided. Open this editor from a recipe form."), + { type: "warning" } + ); + } + }); + } + + async loadAll() { + this.state.loading = true; + const [recipeData, libraryData, templateData] = await Promise.all([ + rpc("/fp/simple_recipe/load", { recipe_id: this._recipeId }), + rpc("/fp/simple_recipe/library/list", { query: "" }), + rpc("/fp/simple_recipe/template/list", {}), + ]); + this.state.recipe = recipeData.recipe; + this.state.steps = recipeData.steps; + this.state.library = libraryData.templates; + this.state.templateOptions = templateData.templates; + this.state.loading = false; + } + + async onSearchLibrary(ev) { + const q = ev.target.value; + this.state.librarySearch = q; + const data = await rpc("/fp/simple_recipe/library/list", { query: q }); + this.state.library = data.templates; + } + + async insertFromLibrary(templateId, position) { + await rpc("/fp/simple_recipe/step/insert", { + recipe_id: this._recipeId, + template_id: templateId, + position: position, + }); + await this.loadAll(); + this.notification.add(_t("Step added"), { type: "success" }); + } + + async reorderStep(stepId, newIndex) { + const ids = this.state.steps.map((s) => s.id); + const oldIndex = ids.indexOf(stepId); + if (oldIndex < 0 || oldIndex === newIndex) { + return; + } + ids.splice(oldIndex, 1); + ids.splice(Math.min(newIndex, ids.length), 0, stepId); + await rpc("/fp/simple_recipe/step/reorder", { node_ids: ids }); + await this.loadAll(); + } + + async onRemoveStep(stepId) { + const proceed = await this._confirm( + _t("Remove this step from the recipe?") + ); + if (!proceed) { + return; + } + await rpc("/fp/simple_recipe/step/remove", { node_id: stepId }); + await this.loadAll(); + } + + async onAddInlineStep() { + await rpc("/fp/simple_recipe/step/insert", { + recipe_id: this._recipeId, + template_id: false, + position: 99, + vals: { name: "New Step" }, + }); + await this.loadAll(); + } + + async onImportTemplate() { + if (!this.state.selectedTemplate) { + return; + } + let proceed = true; + if (this.state.steps.length > 0) { + proceed = await this._confirm( + _t("This recipe already has steps. Import will append. Continue?") + ); + } + if (!proceed) { + return; + } + const result = await rpc("/fp/simple_recipe/template/import", { + source_recipe_id: parseInt(this.state.selectedTemplate, 10), + target_recipe_id: this._recipeId, + }); + this.notification.add( + _t("Imported %s steps", result.imported_count), + { type: "success" } + ); + await this.loadAll(); + } + + openInTreeEditor() { + this.action.doAction({ + type: "ir.actions.client", + tag: "fp_recipe_tree_editor", + name: this.state.recipe?.name || _t("Recipe"), + context: { recipe_id: this._recipeId }, + }); + } + + // --------------------------------------------------------- drag & drop + + onSelectedDragStart(stepId, ev) { + ev.dataTransfer.effectAllowed = "move"; + ev.dataTransfer.setData("application/x-fp-step", String(stepId)); + ev.dataTransfer.setData("text/plain", String(stepId)); + } + + onLibraryDragStart(templateId, ev) { + ev.dataTransfer.effectAllowed = "copy"; + ev.dataTransfer.setData("application/x-fp-library", String(templateId)); + ev.dataTransfer.setData("text/plain", "library"); + } + + onDragOver(index, ev) { + ev.preventDefault(); + ev.dataTransfer.dropEffect = + ev.dataTransfer.types.includes("application/x-fp-library") + ? "copy" + : "move"; + this.state.dragOverIndex = index; + } + + async onDrop(targetIndex, ev) { + ev.preventDefault(); + const fromLibrary = ev.dataTransfer.getData("application/x-fp-library"); + if (fromLibrary) { + await this.insertFromLibrary(parseInt(fromLibrary, 10), targetIndex); + } else { + const fromStep = ev.dataTransfer.getData("application/x-fp-step"); + const draggedId = parseInt(fromStep, 10); + if (draggedId) { + await this.reorderStep(draggedId, targetIndex); + } + } + this.state.dragOverIndex = null; + } + + onDragLeave() { + this.state.dragOverIndex = null; + } + + // --------------------------------------------------------------- helpers + + async _confirm(message) { + return await new Promise((resolve) => { + this.dialog.add( + "web.ConfirmationDialog", + { + body: message, + confirm: () => resolve(true), + cancel: () => resolve(false), + }, + { onClose: () => resolve(false) } + ); + }); + } +} + +registry.category("actions").add("fp_simple_recipe_editor", FpSimpleRecipeEditor); diff --git a/fusion_plating/fusion_plating/static/src/scss/simple_recipe_editor.scss b/fusion_plating/fusion_plating/static/src/scss/simple_recipe_editor.scss new file mode 100644 index 00000000..ca6fdef5 --- /dev/null +++ b/fusion_plating/fusion_plating/static/src/scss/simple_recipe_editor.scss @@ -0,0 +1,203 @@ +// Sub 12a — Simple Recipe Editor styling. +// +// Tokens follow the existing fp_shopfloor pattern (CSS custom props +// with hex fallbacks; dark-mode aware via $o-webclient-color-scheme +// SCSS @if branch — see fusion_plating CLAUDE.md for the rule). + +$o-webclient-color-scheme: bright !default; + +$_fp_se_page_hex: #f3f4f6; +$_fp_se_card_hex: #ffffff; +$_fp_se_border_hex: #d8dadd; +$_fp_se_accent_hex: #2e7d6b; +$_fp_se_muted_hex: #6b7280; +$_fp_se_drop_hex: #e8f5f0; + +@if $o-webclient-color-scheme == dark { + $_fp_se_page_hex: #1a1d21 !global; + $_fp_se_card_hex: #22262d !global; + $_fp_se_border_hex: #3a3f47 !global; + $_fp_se_drop_hex: #1f3a33 !global; +} + +$fp-se-page: var(--fp-page-bg, #{$_fp_se_page_hex}); +$fp-se-card: var(--fp-card-bg, #{$_fp_se_card_hex}); +$fp-se-border: var(--fp-border-color, #{$_fp_se_border_hex}); +$fp-se-accent: var(--fp-accent, #{$_fp_se_accent_hex}); +$fp-se-muted: var(--fp-muted, #{$_fp_se_muted_hex}); +$fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex}); + +.o_fp_simple_editor { + background: $fp-se-page; + height: 100%; + overflow: auto; + padding: 1rem; + + .o_fp_simple_editor_header { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; + + h2 { + margin: 0; + flex: 1; + color: $fp-se-accent; + } + + .o_fp_simple_editor_actions { + display: flex; + gap: .5rem; + } + } + + .o_fp_simple_editor_meta { + background: $fp-se-card; + border: 1px solid $fp-se-border; + border-radius: 4px; + padding: 1rem; + margin-bottom: 1rem; + + .o_fp_import_row { + display: flex; + align-items: center; + gap: .75rem; + + label { font-weight: 500; margin: 0; min-width: 14rem; } + select { flex: 1; max-width: 30rem; } + } + } + + .o_fp_simple_editor_body { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 1rem; + + @media (max-width: 900px) { + grid-template-columns: 1fr; + } + } +} + +.o_fp_selected_panel, +.o_fp_library_panel { + background: $fp-se-card; + border: 1px solid $fp-se-border; + border-radius: 4px; + padding: 1rem; + + h3 { + margin: 0 0 .75rem 0; + font-size: 1rem; + color: $fp-se-accent; + } +} + +.o_fp_step_row { + display: flex; + align-items: center; + gap: .5rem; + padding: .5rem; + border: 1px solid $fp-se-border; + border-radius: 4px; + margin-bottom: .25rem; + background: $fp-se-card; + cursor: grab; + + &.o_fp_drag_over { + background: $fp-se-drop; + border-color: $fp-se-accent; + } + + .o_fp_drag_handle { + color: $fp-se-muted; + cursor: grab; + user-select: none; + } + .o_fp_step_position { + font-weight: 600; + min-width: 1.5rem; + } + .o_fp_step_name { flex: 1; } + .o_fp_station_badge { + font-size: .75rem; + color: $fp-se-muted; + background: $fp-se-page; + padding: .125rem .5rem; + border-radius: 999px; + } + .o_fp_step_remove { + background: none; + border: none; + color: $fp-se-muted; + font-size: 1.25rem; + cursor: pointer; + opacity: 0; + transition: opacity .1s; + padding: 0 .25rem; + } + &:hover .o_fp_step_remove { + opacity: 1; + } +} + +.o_fp_step_dropzone { + border: 2px dashed $fp-se-border; + border-radius: 4px; + padding: 1rem; + text-align: center; + color: $fp-se-muted; + margin-top: .5rem; + + &.o_fp_drag_over, + &:hover { + border-color: $fp-se-accent; + background: $fp-se-drop; + } +} + +.o_fp_inline_add { + margin-top: .75rem; +} + +.o_fp_library_list { + margin-top: .5rem; + max-height: 65vh; + overflow: auto; +} + +.o_fp_library_item { + display: flex; + align-items: center; + gap: .5rem; + padding: .5rem; + border: 1px solid $fp-se-border; + border-radius: 4px; + margin-bottom: .25rem; + background: $fp-se-card; + cursor: grab; + user-select: none; + + .o_fp_library_name { flex: 1; } + .o_fp_library_meta { + font-size: .75rem; + color: $fp-se-muted; + } + + &:hover { + border-color: $fp-se-accent; + } +} + +.o_fp_library_empty { + color: $fp-se-muted; + font-style: italic; + padding: 1rem; + text-align: center; +} + +.o_fp_loading { + padding: 2rem; + text-align: center; + color: $fp-se-muted; +} diff --git a/fusion_plating/fusion_plating/static/src/xml/simple_recipe_editor.xml b/fusion_plating/fusion_plating/static/src/xml/simple_recipe_editor.xml new file mode 100644 index 00000000..fa217bda --- /dev/null +++ b/fusion_plating/fusion_plating/static/src/xml/simple_recipe_editor.xml @@ -0,0 +1,106 @@ + + + + +
+
+

+ Recipe: +

+
+ +
+
+ +
+
+ + + +
+
+ +
+
+

Selected (drag to reorder)

+
+ +
+ + . + + + + stations + + +
+
+
+ Drop here to add at end +
+
+ +
+ +
+

Step Library

+ +
+ +
+ + + + st. + +
+
+
+ No library entries match your search. +
+
+
+
+ +
+ Loading… +
+
+
+ +