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>
448 lines
16 KiB
Vue
448 lines
16 KiB
Vue
<!--
|
|
FlowNode.vue — Recursive node component for the FlowEditor tree.
|
|
|
|
Renders a single step as a clickable card + its children (branches and
|
|
nested nodes). The component is fully generic: behaviour depends on the
|
|
`kindCatalog` prop, never on the kind name directly.
|
|
|
|
Props:
|
|
- step: the step object to render (has id, kind, label, payload, ...)
|
|
- allSteps: full steps array (for looking up children)
|
|
- kindCatalog: PROJECT_KINDS or AGENT_KINDS (see kind-catalogs.js)
|
|
- depth: nesting depth (for indentation)
|
|
- readonly: disable reorder + delete controls
|
|
|
|
Events:
|
|
- edit(step): user clicked a node to edit
|
|
- delete(id): user wants to delete step
|
|
- add(parentId, branch): user wants to add a child under this step
|
|
- move(id, dir): reorder within the same (parent_id, branch) scope;
|
|
dir is 'up' or 'down'
|
|
- reorder(parentId, branch, oldIdx, newIdx): DnD reorder, bubbled up
|
|
through the recursive tree so FlowEditor can handle it.
|
|
|
|
Drag-and-drop
|
|
-------------
|
|
Each peer group gets its own <draggable> with a unique `group` name derived
|
|
from `step.id + branch`. This prevents Sortable.js from moving items across
|
|
scopes (which would require recomputing parent_id/branch semantics).
|
|
|
|
Each draggable binds to a LOCAL MIRROR ref, kept in sync via watchers on the
|
|
authoritative `allSteps` prop. Sortable mutates those mirrors during the
|
|
drag; on drag end we emit `reorder` and let FlowEditor rebuild the flat
|
|
steps array, which triggers a re-render that resyncs the mirrors.
|
|
|
|
Performance:
|
|
- Children are computed once via a cached filter (Vue memoizes computed).
|
|
- Mirror watchers run only when the ID-order of a scope actually changes.
|
|
- Recursion bounded by tree depth (linear in number of steps).
|
|
-->
|
|
<template>
|
|
<div class="flow-node-wrap" :style="{ marginLeft: depth ? '16px' : '0' }">
|
|
<div class="flow-node" :class="`flow-node-${step.kind}`"
|
|
:style="{ borderLeftColor: kindDef.color }"
|
|
@click.stop="$emit('edit', step)">
|
|
<div class="flow-node-head">
|
|
<!-- Drag handle — Sortable.js only starts a drag when the user grabs
|
|
this exact element (see handle=".flow-drag-handle" on the
|
|
<draggable> wrapper in FlowEditor / the recursive branches below). -->
|
|
<q-icon v-if="!readonly" name="drag_indicator"
|
|
size="16px" color="grey-5" class="flow-drag-handle"
|
|
@click.stop>
|
|
<q-tooltip>Glisser pour réordonner</q-tooltip>
|
|
</q-icon>
|
|
<q-icon :name="kindDef.icon" size="16px" :style="{ color: kindDef.color }" class="flow-node-icon" />
|
|
<span class="flow-node-label">{{ step.label || step.id }}</span>
|
|
<span class="flow-node-type">{{ kindDef.label }}</span>
|
|
<span v-if="triggerBadge" class="flow-node-badge">{{ triggerBadge }}</span>
|
|
<div v-if="!readonly" class="flow-node-actions">
|
|
<q-btn flat round dense size="xs" icon="arrow_upward" color="grey-6"
|
|
:disable="!canMoveUp"
|
|
@click.stop="$emit('move', step.id, 'up')">
|
|
<q-tooltip>Déplacer vers le haut</q-tooltip>
|
|
</q-btn>
|
|
<q-btn flat round dense size="xs" icon="arrow_downward" color="grey-6"
|
|
:disable="!canMoveDown"
|
|
@click.stop="$emit('move', step.id, 'down')">
|
|
<q-tooltip>Déplacer vers le bas</q-tooltip>
|
|
</q-btn>
|
|
<q-btn flat round dense size="xs" icon="close" color="grey-5"
|
|
@click.stop="$emit('delete', step.id)">
|
|
<q-tooltip>Supprimer</q-tooltip>
|
|
</q-btn>
|
|
</div>
|
|
</div>
|
|
<div v-if="summaryLines.length" class="flow-node-body">
|
|
<div v-for="(line, i) in summaryLines" :key="i" class="flow-node-detail">{{ line }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Branches (for condition/switch-like kinds) -->
|
|
<div v-if="hasBranches" class="flow-node-branches">
|
|
<div v-for="branch in branchNames" :key="branch" class="flow-branch">
|
|
<div class="flow-branch-label" :class="`flow-branch-${branch}`">
|
|
{{ branchLabel(branch) }}
|
|
</div>
|
|
<draggable
|
|
:list="branchMirrors[branch] || []"
|
|
item-key="id"
|
|
:group="{ name: `flow-${step.id}-${branch}`, 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="(evt) => onBranchDragEnd(branch, evt)"
|
|
>
|
|
<template #item="{ element: child }">
|
|
<FlowNode
|
|
:step="child" :all-steps="allSteps" :kind-catalog="kindCatalog" :depth="depth + 1"
|
|
:readonly="readonly"
|
|
@edit="(s) => $emit('edit', s)"
|
|
@delete="(id) => $emit('delete', id)"
|
|
@add="(p, b) => $emit('add', p, b)"
|
|
@move="(id, d) => $emit('move', id, d)"
|
|
@reorder="(p, b, o, n) => $emit('reorder', p, b, o, n)"
|
|
/>
|
|
</template>
|
|
</draggable>
|
|
<button v-if="!readonly" class="flow-add-child"
|
|
@click.stop="$emit('add', step.id, branch)">+</button>
|
|
</div>
|
|
<button v-if="dynamicBranches && !readonly" class="flow-add-branch"
|
|
@click.stop="onAddBranch">+ Branche</button>
|
|
</div>
|
|
|
|
<!-- Non-branching children (flat children without a branch name) -->
|
|
<div v-else-if="flatChildren.length" class="flow-node-children">
|
|
<draggable
|
|
:list="flatMirror"
|
|
item-key="id"
|
|
:group="{ name: `flow-${step.id}-flat`, 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="onFlatDragEnd"
|
|
>
|
|
<template #item="{ element: child }">
|
|
<FlowNode
|
|
:step="child" :all-steps="allSteps" :kind-catalog="kindCatalog" :depth="depth + 1"
|
|
:readonly="readonly"
|
|
@edit="(s) => $emit('edit', s)"
|
|
@delete="(id) => $emit('delete', id)"
|
|
@add="(p, b) => $emit('add', p, b)"
|
|
@move="(id, d) => $emit('move', id, d)"
|
|
@reorder="(p, b, o, n) => $emit('reorder', p, b, o, n)"
|
|
/>
|
|
</template>
|
|
</draggable>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, ref, watch } from 'vue'
|
|
import draggable from 'vuedraggable'
|
|
import { getKind, getTrigger } from './kind-catalogs'
|
|
|
|
const props = defineProps({
|
|
step: { type: Object, required: true },
|
|
allSteps: { type: Array, default: () => [] },
|
|
kindCatalog: { type: Object, required: true },
|
|
depth: { type: Number, default: 0 },
|
|
readonly: { type: Boolean, default: false },
|
|
})
|
|
|
|
const emit = defineEmits(['edit', 'delete', 'add', 'move', 'reorder'])
|
|
|
|
/**
|
|
* A step can only move within its (parent_id, branch) scope — that keeps
|
|
* branch semantics intact. We compute the peers ordered by their index in
|
|
* the global `allSteps` array (which is the source of truth for ordering).
|
|
*/
|
|
const peers = computed(() =>
|
|
props.allSteps.filter(
|
|
s => s.parent_id === props.step.parent_id && s.branch === props.step.branch,
|
|
),
|
|
)
|
|
const peerIndex = computed(() => peers.value.findIndex(s => s.id === props.step.id))
|
|
const canMoveUp = computed(() => peerIndex.value > 0)
|
|
const canMoveDown = computed(() => peerIndex.value >= 0 && peerIndex.value < peers.value.length - 1)
|
|
|
|
/** Resolved kind descriptor from the catalog (icon, color, branchLabels, …). */
|
|
const kindDef = computed(() => getKind(props.kindCatalog, props.step.kind))
|
|
|
|
/** True when this kind supports branches (yes/no for condition, dynamic for switch). */
|
|
const hasBranches = computed(() => !!kindDef.value.hasBranches)
|
|
|
|
/** True when the kind supports adding arbitrary branch names (e.g. switch). */
|
|
const dynamicBranches = computed(() => kindDef.value.hasBranches === 'dynamic')
|
|
|
|
/** Branch names: either fixed (yes/no) or all distinct branches of children. */
|
|
const branchNames = computed(() => {
|
|
if (kindDef.value.branchLabels) return Object.keys(kindDef.value.branchLabels)
|
|
const set = new Set()
|
|
for (const s of props.allSteps) {
|
|
if (s.parent_id === props.step.id && s.branch) set.add(s.branch)
|
|
}
|
|
return [...set]
|
|
})
|
|
|
|
/** Human label for a branch (from catalog definitions or raw key). */
|
|
function branchLabel (branch) {
|
|
return kindDef.value.branchLabels?.[branch] || branch
|
|
}
|
|
|
|
/** Children in a specific branch (e.g. 'yes' branch of a condition). */
|
|
function childrenOfBranch (branch) {
|
|
return props.allSteps.filter(s => s.parent_id === props.step.id && s.branch === branch)
|
|
}
|
|
|
|
/** Children without a branch (flat nesting, rarely used). */
|
|
const flatChildren = computed(() =>
|
|
props.allSteps.filter(s => s.parent_id === props.step.id && !s.branch),
|
|
)
|
|
|
|
// ── Local mirrors for vuedraggable ─────────────────────────────────────────
|
|
//
|
|
// vuedraggable mutates the list it's bound to. We can't give it a computed
|
|
// ref (read-only), so we keep local mirrors and resync them whenever the
|
|
// authoritative source changes. The watchers key off stringified ID-order so
|
|
// they don't fire on unrelated edits (e.g. a peer's payload change).
|
|
|
|
/** Per-branch mirrors: { yes: [step,...], no: [step,...], ... } */
|
|
const branchMirrors = ref({})
|
|
/** Flat-children mirror. */
|
|
const flatMirror = ref([])
|
|
|
|
watch(
|
|
() => branchNames.value.map(b => `${b}:${childrenOfBranch(b).map(s => s.id).join(',')}`).join('|'),
|
|
() => {
|
|
const next = {}
|
|
for (const b of branchNames.value) next[b] = [...childrenOfBranch(b)]
|
|
branchMirrors.value = next
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
watch(
|
|
() => flatChildren.value.map(s => s.id).join('|'),
|
|
() => { flatMirror.value = [...flatChildren.value] },
|
|
{ immediate: true },
|
|
)
|
|
|
|
/** DnD ended inside a branch — bubble the reorder up with scope info. */
|
|
function onBranchDragEnd (branch, evt) {
|
|
if (typeof evt.oldIndex !== 'number' || typeof evt.newIndex !== 'number') return
|
|
if (evt.oldIndex === evt.newIndex) return
|
|
emit('reorder', props.step.id, branch, evt.oldIndex, evt.newIndex)
|
|
}
|
|
|
|
/** DnD ended inside flatChildren — bubble up (branch = null). */
|
|
function onFlatDragEnd (evt) {
|
|
if (typeof evt.oldIndex !== 'number' || typeof evt.newIndex !== 'number') return
|
|
if (evt.oldIndex === evt.newIndex) return
|
|
emit('reorder', props.step.id, null, evt.oldIndex, evt.newIndex)
|
|
}
|
|
|
|
/** Prompt user for a new branch name (used for switch dynamic branches). */
|
|
function onAddBranch () {
|
|
// eslint-disable-next-line no-alert
|
|
const branch = prompt('Nom de la branche (ex: dying_gasp)')
|
|
if (branch) emit('add', props.step.id, branch)
|
|
}
|
|
|
|
/** Short badge text describing the trigger (shown on the node card). */
|
|
const triggerBadge = computed(() => {
|
|
const t = props.step.trigger?.type
|
|
if (!t || t === 'on_prev_complete') return null
|
|
const td = getTrigger(t)
|
|
if (t === 'after_delay') {
|
|
const h = props.step.trigger.delay_hours
|
|
const d = props.step.trigger.delay_days
|
|
if (d) return `+ ${d}j`
|
|
if (h) return `+ ${h}h`
|
|
return td.label
|
|
}
|
|
return td.label
|
|
})
|
|
|
|
/**
|
|
* 1-2 lines of payload summary shown under the label (compact UX).
|
|
* Skip empty payloads to keep the node card tight.
|
|
*/
|
|
const summaryLines = computed(() => {
|
|
const lines = []
|
|
const p = props.step.payload || {}
|
|
switch (props.step.kind) {
|
|
case 'dispatch_job':
|
|
if (p.subject) lines.push(p.subject)
|
|
if (p.assigned_group) lines.push(`${p.job_type || 'Job'} · ${p.assigned_group} · ${p.duration_h || 1}h`)
|
|
break
|
|
case 'issue':
|
|
if (p.subject) lines.push(p.subject)
|
|
break
|
|
case 'notify':
|
|
if (p.channel && p.template_id) lines.push(`${p.channel.toUpperCase()} · ${p.template_id}`)
|
|
else if (p.channel) lines.push(p.channel.toUpperCase())
|
|
break
|
|
case 'webhook':
|
|
if (p.url) lines.push(`${p.method || 'POST'} ${p.url.slice(0, 50)}${p.url.length > 50 ? '…' : ''}`)
|
|
break
|
|
case 'erp_update':
|
|
if (p.doctype) lines.push(`${p.doctype}${p.docname_ref ? ` · ${p.docname_ref}` : ''}`)
|
|
break
|
|
case 'condition':
|
|
if (p.field) lines.push(`${p.field} ${p.op || '=='} ${p.value ?? ''}`)
|
|
break
|
|
case 'subscription_activate':
|
|
if (p.subscription_ref) lines.push(p.subscription_ref)
|
|
break
|
|
case 'respond':
|
|
if (p.message) lines.push(p.message.length > 70 ? p.message.slice(0, 70) + '…' : p.message)
|
|
break
|
|
case 'action':
|
|
if (p.action) lines.push(p.action)
|
|
break
|
|
case 'tool':
|
|
if (p.tool) lines.push(p.tool)
|
|
break
|
|
}
|
|
if (props.step.depends_on?.length) {
|
|
// Resolve dep IDs → human labels so the user sees step NAMES, not cryptic
|
|
// "s4" tokens that no longer match position after reorder. If a referenced
|
|
// step is missing (orphan ref), we keep the raw id and mark it with a
|
|
// question mark so it's obvious something's stale.
|
|
const labels = props.step.depends_on.map(id => {
|
|
const dep = props.allSteps.find(s => s.id === id)
|
|
return dep?.label || dep?.id || `${id} ?`
|
|
})
|
|
lines.push(`← ${labels.join(', ')}`)
|
|
}
|
|
return lines
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.flow-node-wrap { margin-bottom: 4px; }
|
|
|
|
.flow-node {
|
|
background: #fff;
|
|
border: 1px solid #e2e8f0;
|
|
border-left: 3px solid #94a3b8;
|
|
border-radius: 6px;
|
|
padding: 8px 10px;
|
|
cursor: pointer;
|
|
transition: border-color 0.15s, box-shadow 0.15s;
|
|
}
|
|
.flow-node:hover {
|
|
border-color: #6366f1;
|
|
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.12);
|
|
}
|
|
|
|
.flow-node-head {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
.flow-node-icon { flex-shrink: 0; }
|
|
|
|
/* Drag handle: cursor changes on hover; visually muted until the card is hovered. */
|
|
.flow-drag-handle {
|
|
cursor: grab;
|
|
opacity: 0;
|
|
transition: opacity 0.15s;
|
|
flex-shrink: 0;
|
|
}
|
|
.flow-node:hover .flow-drag-handle { opacity: 0.8; }
|
|
.flow-drag-handle:active { cursor: grabbing; }
|
|
|
|
.flow-node-label {
|
|
flex: 1;
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
color: #1e293b;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.flow-node-type {
|
|
font-size: 0.65rem;
|
|
padding: 1px 6px;
|
|
border-radius: 3px;
|
|
background: #f1f5f9;
|
|
color: #64748b;
|
|
flex-shrink: 0;
|
|
}
|
|
.flow-node-badge {
|
|
font-size: 0.65rem;
|
|
padding: 1px 6px;
|
|
border-radius: 3px;
|
|
background: #fef3c7;
|
|
color: #a16207;
|
|
flex-shrink: 0;
|
|
}
|
|
.flow-node-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 2px;
|
|
opacity: 0;
|
|
transition: opacity 0.15s;
|
|
flex-shrink: 0;
|
|
}
|
|
.flow-node:hover .flow-node-actions { opacity: 1; }
|
|
/* Disabled arrows keep the nav's layout stable but stay dim. */
|
|
.flow-node-actions .q-btn--disable { opacity: 0.35 !important; }
|
|
|
|
.flow-node-body {
|
|
margin-top: 4px;
|
|
padding-left: 22px;
|
|
}
|
|
.flow-node-detail {
|
|
font-size: 0.7rem;
|
|
color: #64748b;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.flow-node-children { margin-top: 4px; padding-left: 20px; border-left: 1px dashed #cbd5e1; }
|
|
|
|
.flow-node-branches {
|
|
margin-top: 6px;
|
|
padding-left: 12px;
|
|
border-left: 2px dashed #eab308;
|
|
}
|
|
.flow-branch { margin-bottom: 8px; }
|
|
.flow-branch-label {
|
|
font-size: 0.65rem;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
padding: 2px 8px;
|
|
border-radius: 3px;
|
|
display: inline-block;
|
|
margin-bottom: 4px;
|
|
}
|
|
.flow-branch-yes { background: #dcfce7; color: #166534; }
|
|
.flow-branch-no { background: #fee2e2; color: #991b1b; }
|
|
.flow-branch-label:not(.flow-branch-yes):not(.flow-branch-no) {
|
|
background: #e0e7ff; color: #3730a3;
|
|
}
|
|
|
|
.flow-add-child, .flow-add-branch {
|
|
background: #f8fafc;
|
|
border: 1px dashed #cbd5e1;
|
|
border-radius: 4px;
|
|
padding: 3px 10px;
|
|
font-size: 0.7rem;
|
|
color: #64748b;
|
|
cursor: pointer;
|
|
transition: background 0.15s;
|
|
}
|
|
.flow-add-child:hover, .flow-add-branch:hover { background: #eef2ff; color: #4338ca; }
|
|
</style>
|