gigafibre-fsm/apps/ops/src/components/flow-editor/FlowEditor.vue
louispaulb 41d9b5f316 feat: flow editor, Gemini QR scanner with offline queue, dispatch planning v2
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>
2026-04-22 10:44:17 -04:00

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>