gigafibre-fsm/apps/ops/src/components/flow-editor/FlowNode.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

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>