Major additions accumulated over 9 days — single commit per request. Flow editor (new): - Generic visual editor for step trees, usable by project wizard + agent flows - PROJECT_KINDS / AGENT_KINDS catalogs decouple UI from domain - Drag-and-drop reorder via vuedraggable with scope isolation per peer group - Chain-aware depends_on rewrite on reorder (sequential only — DAGs preserved) - Variable picker with per-applies_to catalog (Customer / Quotation / Service Contract / Issue / Subscription), insert + copy-clipboard modes - trigger_condition helper with domain-specific JSONLogic examples - Global FlowEditorDialog mounted once in MainLayout, Odoo inline pattern - Server: targo-hub flow-runtime.js, flow-api.js, flow-templates.js - ERPNext: Flow Template/Run doctypes, scheduler, 5 seeded system templates - depends_on chips resolve to step labels instead of opaque "s4" ids QR/OCR scanner (field app): - Camera capture → Gemini Vision via targo-hub with 8s timeout - IndexedDB offline queue retries photos when signal returns - Watcher merges late-arriving scan results into the live UI Dispatch: - Planning mode (draft → publish) with offer pool for unassigned jobs - Shared presets, recurrence selector, suggested-slots dialog - PublishScheduleModal, unassign confirmation Ops app: - ClientDetailPage composables extraction (useClientData, useDeviceStatus, useWifiDiagnostic, useModemDiagnostic) - Project wizard: shared detail sections, wizard catalog/publish composables - Address pricing composable + pricing-mock data - Settings redesign hosting flow templates Targo-hub: - Contract acceptance (JWT residential + DocuSeal commercial tracks) - Referral system - Modem-bridge diagnostic normalizer - Device extractors consolidated Migration scripts: - Invoice/quote print format setup, Jinja rendering - Additional import + fix scripts (reversals, dates, customers, payments) Docs: - Consolidated: old scattered MDs → HANDOFF, ARCHITECTURE, DATA_AND_FLOWS, FLOW_EDITOR_ARCHITECTURE, BILLING_AND_PAYMENTS, CPE_MANAGEMENT, APP_DESIGN_GUIDELINES - Archived legacy wizard PHP for reference - STATUS snapshots for 2026-04-18/19 Cleanup: - Removed ~40 generated PDFs/HTMLs (invoice_preview*, rendered_jinja*) - .gitignore now covers invoice preview output + nested .DS_Store Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
397 lines
14 KiB
Vue
397 lines
14 KiB
Vue
<!--
|
|
FlowEditor.vue — Visual editor for a flow_definition tree.
|
|
|
|
A domain-agnostic editor for step trees. Host components pass a kind catalog
|
|
(PROJECT_KINDS, AGENT_KINDS, …) and the editor renders the tree, opens the
|
|
step editor modal, and emits updates.
|
|
|
|
Props:
|
|
- modelValue: the full flow_definition object (v-model). Shape:
|
|
{ version, trigger, variables, steps: [Step, ...] }
|
|
- kindCatalog: which kinds are allowed (controls the "add step" menu)
|
|
- readonly: disable all editing (useful for preview mode)
|
|
- appliesTo: domain context for the variable picker
|
|
('Customer' | 'Quotation' | 'Service Contract' | 'Issue' | 'Subscription')
|
|
|
|
Events:
|
|
- update:modelValue(newDef): emitted whenever the tree changes (add/edit/delete)
|
|
- step-click(step): for hosts that want to intercept clicks
|
|
|
|
Reordering
|
|
==========
|
|
Supports two equivalent ways to change step order:
|
|
1. Up/down arrows on each FlowNode (touch-friendly, discrete)
|
|
2. Drag-and-drop via vuedraggable (mouse-friendly, continuous)
|
|
|
|
Both paths converge on `reorderPeers(scopePred, oldIdx, newIdx)` which does
|
|
the heavy lifting:
|
|
- Rebuilds the flat `steps` array with the peer subset in the new order,
|
|
keeping non-peers and their flat positions untouched.
|
|
- Detects a "clean sequential chain" (each peer_i depends only on peer_{i-1})
|
|
and regenerates `depends_on` in the new order so that visual order =
|
|
execution order. If the chain is non-sequential (parallel fork, explicit
|
|
multi-deps), `depends_on` is left intact — we don't silently rewrite a
|
|
hand-authored DAG.
|
|
|
|
DnD scope isolation
|
|
-------------------
|
|
Each peer group (same parent_id + same branch) renders inside its own
|
|
<draggable> with a unique `group` name, so Sortable.js can't move a step
|
|
across scopes. Cross-scope reordering is intentionally unsupported because
|
|
it would require recomputing parent_id / branch, which the arrows UI can't
|
|
express either — keeps the mental model consistent.
|
|
|
|
Performance notes
|
|
-----------------
|
|
- Uses shallow filter + computed for root-level steps (O(n) per render,
|
|
n is small for typical flows < 50 steps).
|
|
- Reorder is O(n) flat-array rebuild + O(peers) chain detection, both cheap.
|
|
- Emits a complete def on each change — host should debounce save to API.
|
|
-->
|
|
<template>
|
|
<div class="flow-editor">
|
|
<div v-if="!steps.length" class="flow-editor-empty">
|
|
<q-icon name="account_tree" size="32px" color="grey-5" />
|
|
<div class="text-caption text-grey-7 q-mt-sm">Flow vide</div>
|
|
<q-btn v-if="!readonly" color="indigo-6" label="Ajouter une étape" no-caps size="sm"
|
|
icon="add" @click="openNew()" class="q-mt-sm" />
|
|
</div>
|
|
|
|
<template v-else>
|
|
<!-- Root-level draggable list: one sortable per scope. -->
|
|
<draggable
|
|
:list="rootPeersMirror"
|
|
item-key="id"
|
|
:group="{ name: 'flow-root', pull: false, put: false }"
|
|
handle=".flow-drag-handle"
|
|
:animation="150"
|
|
ghost-class="flow-drag-ghost"
|
|
chosen-class="flow-drag-chosen"
|
|
drag-class="flow-drag-dragging"
|
|
:disabled="readonly"
|
|
@end="onRootDragEnd"
|
|
>
|
|
<template #item="{ element }">
|
|
<FlowNode
|
|
:step="element" :all-steps="steps" :kind-catalog="kindCatalog" :depth="0"
|
|
:readonly="readonly"
|
|
@edit="onEdit"
|
|
@delete="onDelete"
|
|
@add="onAddChild"
|
|
@move="onMove"
|
|
@reorder="onReorderScope"
|
|
/>
|
|
</template>
|
|
</draggable>
|
|
|
|
<button v-if="!readonly" class="flow-add-root" @click="openNew()">
|
|
<q-icon name="add" size="16px" /> Ajouter une étape
|
|
</button>
|
|
</template>
|
|
|
|
<StepEditorModal v-model="modalOpen"
|
|
:step="editingStep"
|
|
:kind-catalog="kindCatalog"
|
|
:all-step-ids="allStepIds"
|
|
:all-steps="steps"
|
|
:applies-to="appliesTo"
|
|
@save="onSave" />
|
|
|
|
<!-- Kind picker popover for "add new" -->
|
|
<q-dialog v-model="kindPickerOpen">
|
|
<q-card style="min-width: 320px; max-width: 95vw">
|
|
<q-card-section>
|
|
<div class="text-subtitle2 text-weight-bold">Quel type d'étape ?</div>
|
|
</q-card-section>
|
|
<q-list separator>
|
|
<q-item v-for="(def, key) in kindCatalog" :key="key" clickable
|
|
@click="addStepOfKind(key)">
|
|
<q-item-section avatar>
|
|
<q-icon :name="def.icon" :style="{ color: def.color }" />
|
|
</q-item-section>
|
|
<q-item-section>
|
|
<q-item-label>{{ def.label }}</q-item-label>
|
|
<q-item-label v-if="def.help" caption>{{ def.help }}</q-item-label>
|
|
</q-item-section>
|
|
</q-item>
|
|
</q-list>
|
|
</q-card>
|
|
</q-dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, watch } from 'vue'
|
|
import draggable from 'vuedraggable'
|
|
import FlowNode from './FlowNode.vue'
|
|
import StepEditorModal from './StepEditorModal.vue'
|
|
import { buildEmptyStep } from './kind-catalogs'
|
|
|
|
const props = defineProps({
|
|
modelValue: { type: Object, required: true },
|
|
kindCatalog: { type: Object, required: true },
|
|
readonly: { type: Boolean, default: false },
|
|
appliesTo: { type: String, default: null },
|
|
})
|
|
|
|
const emit = defineEmits(['update:modelValue', 'step-click'])
|
|
|
|
/** All steps in the tree. Defaults to [] if flow def is empty. */
|
|
const steps = computed(() => props.modelValue?.steps || [])
|
|
|
|
/** Top-level steps (no parent). */
|
|
const rootSteps = computed(() => steps.value.filter(s => !s.parent_id))
|
|
|
|
/**
|
|
* Local mirror of the root peer group for vuedraggable.
|
|
*
|
|
* vuedraggable mutates the array it's bound to (Sortable.js moves the DOM first,
|
|
* then the component splices the underlying list to match). We can't feed it a
|
|
* computed ref directly because that's read-only. So we maintain a local ref
|
|
* and sync it from the authoritative `rootSteps` whenever the source changes.
|
|
*
|
|
* The watcher uses the stringified ID-order as the dependency so we only
|
|
* re-mirror when the set/order actually changes — not on every step edit.
|
|
*/
|
|
const rootPeersMirror = ref([])
|
|
watch(
|
|
() => rootSteps.value.map(s => s.id).join('|'),
|
|
() => { rootPeersMirror.value = [...rootSteps.value] },
|
|
{ immediate: true },
|
|
)
|
|
|
|
/** All step IDs — used by the editor modal for depends_on selection. */
|
|
const allStepIds = computed(() => steps.value.map(s => s.id))
|
|
|
|
// --- Editor modal state -----------------------------------------------------
|
|
|
|
const modalOpen = ref(false)
|
|
const editingStep = ref(null)
|
|
|
|
// --- Kind picker popover state ---------------------------------------------
|
|
|
|
const kindPickerOpen = ref(false)
|
|
/** Pending add context: when we open the picker, remember where to attach. */
|
|
const pendingParent = ref(null)
|
|
const pendingBranch = ref(null)
|
|
|
|
/** Emit a full def update with the steps array replaced. */
|
|
function emitSteps (newSteps) {
|
|
emit('update:modelValue', { ...props.modelValue, steps: newSteps })
|
|
}
|
|
|
|
/** Open the step editor for an existing step. */
|
|
function onEdit (step) {
|
|
if (props.readonly) return emit('step-click', step)
|
|
editingStep.value = step
|
|
modalOpen.value = true
|
|
}
|
|
|
|
/** Save an edited step back into the tree (in place by id). */
|
|
function onSave (edited) {
|
|
const newSteps = steps.value.map(s => s.id === edited.id ? edited : s)
|
|
emitSteps(newSteps)
|
|
}
|
|
|
|
// ─── Reordering ────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Core reorder primitive — used by both the arrow buttons and the drag-and-drop
|
|
* path. Moves a peer from `oldPeerIdx` to `newPeerIdx` within the scope defined
|
|
* by `scopePred`, rewrites `depends_on` if the chain was sequential, and emits.
|
|
*
|
|
* @param {(step) => boolean} scopePred identifies the peer group
|
|
* @param {number} oldPeerIdx 0-based index within the peer group
|
|
* @param {number} newPeerIdx 0-based target index within the peer group
|
|
*/
|
|
function reorderPeers (scopePred, oldPeerIdx, newPeerIdx) {
|
|
if (props.readonly) return
|
|
if (oldPeerIdx === newPeerIdx) return
|
|
|
|
const arr = steps.value.map(s => ({ ...s }))
|
|
|
|
// Record flat-array positions of peers so we can reinsert them in place.
|
|
const peerFlatSlots = []
|
|
arr.forEach((s, i) => { if (scopePred(s)) peerFlatSlots.push(i) })
|
|
if (oldPeerIdx < 0 || oldPeerIdx >= peerFlatSlots.length) return
|
|
if (newPeerIdx < 0 || newPeerIdx >= peerFlatSlots.length) return
|
|
|
|
const peersBefore = peerFlatSlots.map(i => arr[i])
|
|
const peerIdSet = new Set(peersBefore.map(s => s.id))
|
|
|
|
// Detect a clean sequential chain: each peer_i depends on exactly peer_{i-1}
|
|
// (and nothing else within the group). External deps don't count.
|
|
const isSequential = peersBefore.every((p, i) => {
|
|
const inGroup = (p.depends_on || []).filter(d => peerIdSet.has(d))
|
|
if (i === 0) return inGroup.length === 0
|
|
return inGroup.length === 1 && inGroup[0] === peersBefore[i - 1].id
|
|
})
|
|
|
|
// Build the new peer order.
|
|
const peersAfter = [...peersBefore]
|
|
const [moved] = peersAfter.splice(oldPeerIdx, 1)
|
|
peersAfter.splice(newPeerIdx, 0, moved)
|
|
|
|
// Regenerate sequential chain if that's what we detected.
|
|
if (isSequential) {
|
|
peersAfter.forEach((p, i) => {
|
|
// Keep dependencies on steps outside the peer group (cross-scope refs).
|
|
// Also filter out any self-ref that might have slipped in — this can't
|
|
// happen from a clean reorder, but guards against stale data.
|
|
const external = (p.depends_on || [])
|
|
.filter(d => !peerIdSet.has(d) && d !== p.id)
|
|
const prevId = i === 0 ? null : peersAfter[i - 1].id
|
|
const chained = prevId && prevId !== p.id ? [...external, prevId] : external
|
|
// Dedupe in case external already contained prevId for some reason.
|
|
p.depends_on = [...new Set(chained)]
|
|
})
|
|
}
|
|
|
|
// Reinsert peers at their original flat positions, now in the new order.
|
|
peerFlatSlots.forEach((flatIdx, peerIdx) => {
|
|
arr[flatIdx] = peersAfter[peerIdx]
|
|
})
|
|
|
|
emitSteps(arr)
|
|
}
|
|
|
|
/**
|
|
* ± 1 arrow-button reorder.
|
|
* Translates to a reorderPeers call on the peer group of the given step.
|
|
*/
|
|
function onMove (id, dir) {
|
|
const self = steps.value.find(s => s.id === id)
|
|
if (!self) return
|
|
const scopePred = (x) => x.parent_id === self.parent_id && x.branch === self.branch
|
|
const peers = steps.value.filter(scopePred)
|
|
const peerIdx = peers.findIndex(s => s.id === id)
|
|
const target = dir === 'up' ? peerIdx - 1 : peerIdx + 1
|
|
if (target < 0 || target >= peers.length) return
|
|
reorderPeers(scopePred, peerIdx, target)
|
|
}
|
|
|
|
/**
|
|
* DnD reorder event — bubbled up from any FlowNode at any depth.
|
|
* Arguments describe the scope that was reordered + the new indices.
|
|
*/
|
|
function onReorderScope (parentId, branch, oldIdx, newIdx) {
|
|
const scopePred = (x) =>
|
|
(x.parent_id || null) === (parentId || null) &&
|
|
(x.branch || null) === (branch || null)
|
|
reorderPeers(scopePred, oldIdx, newIdx)
|
|
}
|
|
|
|
/** DnD on the root scope — call Sortable's oldIndex/newIndex. */
|
|
function onRootDragEnd (evt) {
|
|
if (typeof evt.oldIndex !== 'number' || typeof evt.newIndex !== 'number') return
|
|
// Sortable has already visually moved the DOM inside rootPeersMirror. That's
|
|
// fine: after emitSteps + Vue re-render, the watcher resyncs the mirror from
|
|
// the authoritative order, so there's no double-apply.
|
|
reorderPeers(s => !s.parent_id, evt.oldIndex, evt.newIndex)
|
|
}
|
|
|
|
// ─── Delete ────────────────────────────────────────────────────────────────
|
|
|
|
/** Delete a step and all its descendants (recursive cleanup). */
|
|
function onDelete (stepId) {
|
|
const toRemove = new Set([stepId])
|
|
// Find transitive descendants by parent_id
|
|
let changed = true
|
|
while (changed) {
|
|
changed = false
|
|
for (const s of steps.value) {
|
|
if (s.parent_id && toRemove.has(s.parent_id) && !toRemove.has(s.id)) {
|
|
toRemove.add(s.id)
|
|
changed = true
|
|
}
|
|
}
|
|
}
|
|
const newSteps = steps.value
|
|
.filter(s => !toRemove.has(s.id))
|
|
// Also strip depends_on references to deleted steps
|
|
.map(s => ({
|
|
...s,
|
|
depends_on: (s.depends_on || []).filter(d => !toRemove.has(d)),
|
|
}))
|
|
emitSteps(newSteps)
|
|
}
|
|
|
|
// ─── Add step ──────────────────────────────────────────────────────────────
|
|
|
|
/** Open the kind picker for a new root-level step. */
|
|
function openNew () {
|
|
pendingParent.value = null
|
|
pendingBranch.value = null
|
|
kindPickerOpen.value = true
|
|
}
|
|
|
|
/** Open the kind picker for a child step (under a condition/switch branch). */
|
|
function onAddChild (parentId, branch) {
|
|
pendingParent.value = parentId
|
|
pendingBranch.value = branch
|
|
kindPickerOpen.value = true
|
|
}
|
|
|
|
/** After user picks a kind, create the step and open the editor. */
|
|
function addStepOfKind (kindName) {
|
|
const step = buildEmptyStep(kindName, props.kindCatalog)
|
|
if (pendingParent.value) {
|
|
step.parent_id = pendingParent.value
|
|
step.branch = pendingBranch.value
|
|
}
|
|
const newSteps = [...steps.value, step]
|
|
emitSteps(newSteps)
|
|
kindPickerOpen.value = false
|
|
// Open editor for the new step (next tick so the prop updates)
|
|
setTimeout(() => {
|
|
editingStep.value = step
|
|
modalOpen.value = true
|
|
}, 50)
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.flow-editor { display: flex; flex-direction: column; gap: 4px; }
|
|
.flow-editor-empty {
|
|
text-align: center;
|
|
padding: 32px 16px;
|
|
background: #f8fafc;
|
|
border: 1px dashed #cbd5e1;
|
|
border-radius: 6px;
|
|
}
|
|
.flow-add-root {
|
|
margin-top: 8px;
|
|
padding: 6px 12px;
|
|
background: #f8fafc;
|
|
border: 1px dashed #cbd5e1;
|
|
border-radius: 6px;
|
|
color: #64748b;
|
|
font-size: 0.8rem;
|
|
cursor: pointer;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
transition: all 0.15s;
|
|
}
|
|
.flow-add-root:hover { background: #eef2ff; color: #4338ca; border-color: #6366f1; }
|
|
</style>
|
|
|
|
<!--
|
|
Global drag-state styles — deliberately unscoped because Sortable.js assigns
|
|
these classes to nodes that live inside recursive FlowNode children, and
|
|
scoped <style> wouldn't reach them.
|
|
-->
|
|
<style>
|
|
.flow-drag-ghost {
|
|
opacity: 0.35;
|
|
background: #e0e7ff !important;
|
|
border: 2px dashed #6366f1 !important;
|
|
}
|
|
.flow-drag-chosen { cursor: grabbing; }
|
|
.flow-drag-dragging {
|
|
transform: rotate(0.5deg);
|
|
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.25) !important;
|
|
border-color: #6366f1 !important;
|
|
}
|
|
</style>
|