gigafibre-fsm/apps/ops/src/components/flow-editor/FlowCanvas.vue
louispaulb 512c4a5f1b feat(ops): dispatch auto complet + perf Boîte/rapports + fix session
Planificateur « Suggérer » : 4 stratégies (smart / meilleurs d'abord / équilibré /
juste ce qu'il faut), compétences+niveaux par tech (édition inline), niveau requis
par compétence + par job, carte des tournées (1 couleur/tech, domicile→arrêts,
sélecteur de jour), fenêtre de dispatch auj.+demain (dates sélectionnées), règle
week-end + placeholder « en attente du quart », clustering + lasso + filtre-date
sur la carte, accès rapide « À assigner » (badge).

Boîte : liste /conversations allégée (45 Mo → ~1 Mo, 17×) + messages chargés à
l'ouverture. Rapports : cache SWR sur revenue-explorer (22×). Session : keep-alive
+ timeout fetch global + authFetch durci → fin des rechargements manuels.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 08:49:35 -04:00

83 lines
5.3 KiB
Vue

<template>
<!-- Éditeur de flows VISUEL node-based (Vue Flow, port Vue de React Flow esthétique n8n). Lit/édite le MÊME modèle de steps (id/parent/branch/type). -->
<div class="flow-canvas">
<div v-if="editable" class="fc-toolbar">
<q-btn dense unelevated no-caps color="primary" icon="add" label="Ajouter une étape" size="sm" @click="emit('add')" />
<span class="fc-hint">Tirez d'un point à l'autre pour relier · clic = éditer · ✕ = supprimer · glissez pour ranger</span>
</div>
<VueFlow :nodes="nodes" :edges="edges" :min-zoom="0.2" :max-zoom="2" fit-view-on-init
:default-edge-options="{ type: 'smoothstep' }" :nodes-connectable="editable" :nodes-draggable="true"
@connect="onConnect" @node-click="onNodeClick" @node-drag-stop="onDragStop">
<Background pattern-color="#d6deea" :gap="18" />
<Controls />
<template #node-flowstep="{ id, data }">
<Handle type="target" :position="Position.Left" />
<div class="fc-node" :style="{ borderColor: data.color }">
<button v-if="editable" class="fc-del" title="Supprimer" @click.stop="emit('delete', id)">✕</button>
<div class="fc-node-type" :style="{ color: data.color }">{{ data.icon }} {{ data.typeLabel }}</div>
<div class="fc-node-label">{{ data.label }}</div>
</div>
<Handle type="source" :position="Position.Right" />
</template>
</VueFlow>
<div v-if="!nodes.length" class="fc-empty">Aucune étape « Ajouter une étape » pour commencer.</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { VueFlow, Handle, Position } from '@vue-flow/core'
import { Background } from '@vue-flow/background'
import { Controls } from '@vue-flow/controls'
import '@vue-flow/core/dist/style.css'
import '@vue-flow/core/dist/theme-default.css'
import '@vue-flow/controls/dist/style.css'
const props = defineProps({ steps: { type: Array, default: () => [] }, editable: { type: Boolean, default: true } })
const emit = defineEmits(['edit', 'delete', 'add', 'connect', 'move'])
const TYPE_META = {
tool: { c: '#0ea5e9', i: '🔧', l: 'Outil' }, condition: { c: '#f59e0b', i: '❓', l: 'Condition' },
switch: { c: '#f59e0b', i: '🔀', l: 'Switch' }, respond: { c: '#10b981', i: '💬', l: 'Réponse' },
action: { c: '#6366f1', i: '⚙️', l: 'Action' }, goto: { c: '#64748b', i: '↪️', l: 'Aller à' },
}
const nodes = ref([])
const edges = ref([])
// Layout : position manuelle (step._pos, persistée) sinon arbre auto (profondeur→X, ordre→Y).
function rebuild () {
const steps = props.steps || []
const byId = {}; for (const s of steps) byId[s.id] = s
const depthOf = (s) => { let d = 0, cur = s; const seen = new Set(); while (cur && cur.parent != null && byId[cur.parent] && !seen.has(cur.id)) { seen.add(cur.id); cur = byId[cur.parent]; d++ } return d }
const perDepth = {}; const n = []
for (const s of steps) {
const d = depthOf(s); perDepth[d] = perDepth[d] || 0
const m = TYPE_META[s.type] || TYPE_META.action
const pos = (s._pos && typeof s._pos.x === 'number') ? { x: s._pos.x, y: s._pos.y } : { x: d * 300, y: perDepth[d] * 130 }
n.push({ id: String(s.id), type: 'flowstep', position: pos, data: { label: s.label || s.id, typeLabel: m.l, icon: m.i, color: m.c } })
perDepth[d]++
}
const e = []
for (const s of steps) { if (s.parent != null && byId[s.parent]) e.push({ id: `e-${s.parent}-${s.id}`, source: String(s.parent), target: String(s.id), label: s.branch || '', labelBgPadding: [4, 2], style: { stroke: '#94a3b8' }, labelStyle: { fontSize: '11px', fill: '#64748b' } }) }
nodes.value = n; edges.value = e
}
watch(() => props.steps, rebuild, { immediate: true, deep: true })
function onNodeClick (e) { if (e && e.node) emit('edit', e.node.id) }
function onConnect (params) { if (params && params.source && params.target) emit('connect', { source: params.source, target: params.target }) }
function onDragStop (e) { const node = (e && e.node) || (e && e.nodes && e.nodes[0]); if (node) emit('move', { id: node.id, position: { x: node.position.x, y: node.position.y } }) }
</script>
<style scoped>
.flow-canvas { position: relative; height: 580px; border: 1px solid #e2e8f0; border-radius: 10px; background: #fafbfc; overflow: hidden; }
.fc-toolbar { position: absolute; top: 8px; left: 8px; z-index: 5; display: flex; align-items: center; gap: 10px; }
.fc-hint { font-size: 11px; color: #94a3b8; background: rgba(255,255,255,.7); padding: 2px 6px; border-radius: 6px; }
.fc-node { position: relative; background: #fff; border: 2px solid #6366f1; border-radius: 10px; padding: 8px 12px; min-width: 160px; max-width: 230px; box-shadow: 0 1px 5px rgba(15,23,42,.08); }
.fc-node-type { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; margin-bottom: 2px; }
.fc-node-label { font-size: 13px; color: #1e293b; line-height: 1.25; }
.fc-del { position: absolute; top: -8px; right: -8px; width: 18px; height: 18px; border-radius: 50%; border: none; background: #ef4444; color: #fff; font-size: 11px; line-height: 1; cursor: pointer; display: none; }
.fc-node:hover .fc-del { display: block; }
.fc-empty { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: #94a3b8; font-size: 0.9rem; pointer-events: none; }
</style>