gigafibre-fsm/apps/ops/src/pages/TaskGraphPage.vue
louispaulb 4a641ac802 perf(ops): lazy-load heavy libs & shell components — chunks route/shell bien plus légers
Batch 1 de l'audit d'optimisation. Les libs lourdes étaient statiquement importées
donc livrées même inutilisées → passées en import dynamique / defineAsyncComponent :
- MainLayout : ConversationPanel/PhoneModal/FlowEditor/NewTicket/Orchestrator/
  ServiceStatus/OutboxPanel → async (chunk shell 312 KB → 56 KB, chargé sur CHAQUE page)
- TaskGraphPage : TaskGantt (hy-vue-gantt) → async (2.6 MB → 20 KB ; le Gantt ne charge
  que dans la vue Gantt)
- NetworkPage : cytoscape → import() dans loadNetworkMap (484 KB → 52 KB)
- ReportAR/Revenu/Taxes : chart.js/auto → import() dans renderChart (async) (~150→~10 KB/page)

Total dist inchangé (~8 M) — le code est DÉFÉRÉ en chunks à la demande, pas supprimé.
Gain réel = ce que l'utilisateur télécharge pour VOIR une page. Build OK, leak-check propre,
shell + route rapport vérifiés (aucune erreur d'import).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 20:35:30 -04:00

259 lines
15 KiB
Vue

<template>
<q-page class="q-pa-md">
<div class="q-mb-md">
<div class="row items-center no-wrap q-mb-sm">
<q-icon name="account_tree" size="24px" color="primary" class="q-mr-sm" />
<div class="col">
<div class="text-h6 text-weight-bold">Orchestration<span class="gt-xs text-subtitle2 text-grey-6 q-ml-xs"> constructeur &amp; dépendances</span></div>
<div class="text-caption text-grey-6 gt-xs"><b>Modèles</b> = construire le flow (boîtes + liens + actions) · <b>Instances</b> = un flow lancé (statut réel). 🚚 terrain · 🏢 bureau.</div>
</div>
</div>
<div class="row items-center q-gutter-sm tg-controls">
<q-btn-toggle v-model="source" no-caps dense unelevated toggle-color="primary" color="grey-3" text-color="grey-8"
:options="[{ value: 'template', label: 'Modèles' }, { value: 'run', label: 'Instances' }]" @update:model-value="onSourceChange" />
<q-btn-toggle v-model="view" no-caps dense unelevated toggle-color="primary" color="grey-3" text-color="grey-8"
:options="[{ value: 'dag', icon: 'account_tree', label: 'Dépendances' }, { value: 'gantt', icon: 'view_timeline', label: 'Planning' }]" />
<q-select v-if="source === 'template'" v-model="selectedName" :options="templateOptions" emit-value map-options dense outlined options-dense
class="tg-select" label="Orchestration" @update:model-value="loadTemplate" />
<q-select v-else v-model="selectedRun" :options="runOptions" emit-value map-options dense outlined options-dense
class="tg-select" label="Instance (client)" @update:model-value="loadRun" />
<q-space class="gt-xs" />
<q-btn v-if="source === 'template'" dense unelevated no-caps color="green-7" icon="save" :label="dirty ? 'Enregistrer' : 'Enregistré'" :disable="!dirty || saving" :loading="saving" @click="save" />
<q-btn v-if="source === 'template'" dense unelevated no-caps color="deep-purple-6" icon="play_arrow" label="Lancer" @click="openLaunch"><q-tooltip>Lancer cette orchestration sur un client</q-tooltip></q-btn>
</div>
</div>
<div v-if="loading" class="flex flex-center q-pa-xl"><q-spinner color="primary" size="32px" /></div>
<template v-else>
<TaskDependencyGraph v-if="view === 'dag'" :tasks="tasks" :editable="source === 'template'"
@add="onAdd" @connect="onConnect" @edit="onEdit" @delete="onDelete" @move="onMove" />
<TaskGantt v-else :tasks="tasks" />
</template>
<div class="text-caption text-grey-5 q-mt-sm">
Données réelles (Flow Template / Flow Run). En <b>Modèles</b> : tirez d'une boîte à l'autre pour créer une dépendance, cliquez une boîte pour lier son <b>action</b>, puis <b>Enregistrer</b>.
En <b>Instances</b> : couleur = statut LIVE (vert=fait, bleu=en cours, rouge=échec, gris=en attente).<span v-if="source === 'run' && runStatus"> Instance <b>{{ runStatus }}</b><span v-if="runCustomer"> · {{ runCustomer }}</span>.</span>
</div>
<!-- Éditeur d'étape : libellé + ACTION branchable (kind exécuté par le runtime) -->
<q-dialog v-model="stepDialog">
<q-card style="min-width:min(380px, 90vw)">
<q-card-section class="row items-center q-pb-none"><q-icon name="bolt" color="primary" class="q-mr-sm" /><span class="text-subtitle1 text-weight-bold">Étape</span></q-card-section>
<q-card-section class="q-gutter-sm">
<q-input v-model="editStep.label" dense outlined autofocus label="Libellé de l'étape" />
<q-select v-model="editStep.kind" :options="KIND_OPTIONS" emit-value map-options dense outlined label="Action (exécutée par le flow)">
<template #option="scope"><q-item v-bind="scope.itemProps"><q-item-section avatar>{{ scope.opt.icon }}</q-item-section><q-item-section>{{ scope.opt.label }}</q-item-section></q-item></template>
</q-select>
<div class="text-caption text-grey-6">🚚 « Intervention terrain » = déplacement d'un technicien. Les autres actions sont à distance / système.</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat no-caps color="negative" label="Supprimer" @click="onDelete(editStep.id); stepDialog = false" />
<q-space />
<q-btn flat no-caps label="Fermer" v-close-popup />
<q-btn unelevated no-caps color="primary" label="OK" @click="applyStepEdit" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- Lancer une instance de l'orchestration sur un client réel -->
<q-dialog v-model="launchDialog">
<q-card style="min-width:min(380px, 90vw)">
<q-card-section class="row items-center q-pb-none"><q-icon name="play_circle" color="deep-purple-6" class="q-mr-sm" /><span class="text-subtitle1 text-weight-bold">Lancer sur un client</span></q-card-section>
<q-card-section>
<q-select v-model="custPicked" :options="custOptions" use-input input-debounce="300" @filter="searchCust" emit-value map-options dense outlined options-dense label="Client" hint="Tapez un nom de client" />
<div class="text-caption text-grey-6 q-mt-sm">Crée une instance de « {{ selectedLabel }} » et exécute les premières étapes. ⚠ Selon la config du modèle, des étapes peuvent échouer (visible dans Instances).</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat no-caps label="Annuler" v-close-popup />
<q-btn unelevated no-caps color="deep-purple-6" label="Lancer" :disable="!custPicked || launching" :loading="launching" @click="doLaunch" />
</q-card-actions>
</q-card>
</q-dialog>
</q-page>
</template>
<script setup>
import { ref, reactive, computed, onMounted, defineAsyncComponent } from 'vue'
import { useQuasar } from 'quasar'
import { listFlowTemplates, getFlowTemplate, updateFlowTemplate } from 'src/api/flow-templates'
import { HUB_URL } from 'src/config/hub'
import TaskDependencyGraph from 'src/components/flow-editor/TaskDependencyGraph.vue'
// Vue Gantt (hy-vue-gantt, lourd) rendu seulement dans la vue « gantt » → chargé à la demande.
const TaskGantt = defineAsyncComponent(() => import('src/components/flow-editor/TaskGantt.vue'))
const $q = useQuasar()
const view = ref('dag')
const source = ref('template')
const templateOptions = ref([])
const selectedName = ref(null)
const runOptions = ref([])
const selectedRun = ref(null)
const runStatus = ref('')
const runCustomer = ref('')
const tasks = ref([])
const loading = ref(true)
const saving = ref(false)
const dirty = ref(false)
// État de modèle en cours d'édition (steps bruts + le reste du flow_definition).
const templateSteps = ref([])
const loadedDef = ref({ version: 1, trigger: { event: 'manual' }, variables: {}, steps: [] })
const stepDialog = ref(false)
const editStep = reactive({ id: '', label: '', kind: 'notify' })
// Actions branchables = les `kind` que le runtime exécute.
const KIND = {
dispatch_job: { label: 'Intervention terrain', icon: '🚚', dispatch: true },
notify: { label: 'Notification (SMS/courriel)', icon: '📨' },
issue: { label: 'Ticket', icon: '🎫' },
erp_update: { label: 'Mise à jour ERP', icon: '🗄️' },
subscription_activate: { label: 'Activer l\'abonnement', icon: '⚡' },
wait: { label: 'Attente', icon: '⏳' },
condition: { label: 'Condition', icon: '❓' },
webhook: { label: 'Webhook', icon: '🔗' },
}
const KIND_OPTIONS = Object.entries(KIND).map(([value, m]) => ({ value, label: m.label, icon: m.icon }))
const RUN_STATUS = { done: 'Completed', running: 'Working', failed: 'Cancelled', scheduled: 'Pending Review', pending: 'Open', skipped: 'Open' }
function datesFor (steps) {
const byId = {}; for (const s of steps) byId[s.id] = s
const memo = {}
const depth = (id, seen = new Set()) => { if (memo[id] != null) return memo[id]; if (seen.has(id)) return 0; seen.add(id); const deps = (byId[id]?.depends_on || []).filter(d => byId[d]); const d = deps.length ? 1 + Math.max(...deps.map(x => depth(x, seen))) : 0; memo[id] = d; return d }
const base = new Date('2026-06-22T00:00:00')
const iso = (n) => { const d = new Date(base); d.setDate(d.getDate() + n); return d.toISOString().slice(0, 10) }
return (s) => { const dp = depth(s.id); return { start: iso(dp), end: iso(dp + 1) } }
}
function mapSteps (steps, stepState) {
const dates = datesFor(steps)
return steps.map(s => {
const k = KIND[s.kind] || { label: s.kind }
const live = stepState && stepState[s.id]
const status = live ? (RUN_STATUS[live.status] || 'Open') : 'Open'
const { start, end } = dates(s)
return { id: s.id, subject: s.label || s.id, dependsOn: s.depends_on || [], requiresDispatch: !!k.dispatch, assignee: live && live.error ? '⚠ ' + k.label : k.label, status, start, end, pos: s._pos }
})
}
function remap () { tasks.value = mapSteps(templateSteps.value) }
async function loadTemplate (name) {
if (!name) { tasks.value = []; loading.value = false; return }
loading.value = true; dirty.value = false
try {
const tpl = await getFlowTemplate(name)
loadedDef.value = tpl?.flow_definition || { version: 1, trigger: { event: 'manual' }, variables: {}, steps: [] }
templateSteps.value = JSON.parse(JSON.stringify(loadedDef.value.steps || []))
remap()
} catch (e) { tasks.value = [] } finally { loading.value = false }
}
// ── Builder : ajouter / relier / éditer / supprimer / déplacer (mute templateSteps + remap + dirty) ──
function findStep (id) { return templateSteps.value.find(s => String(s.id) === String(id)) }
function dependsTransitively (a, b, seen = new Set()) { // a dépend-il (transitivement) de b ?
if (a === b) return true
if (seen.has(a)) return false
seen.add(a)
const s = findStep(a); return (s?.depends_on || []).some(d => dependsTransitively(d, b, seen))
}
function onAdd () {
const id = 's' + Math.random().toString(36).slice(2, 7)
templateSteps.value.push({ id, kind: 'notify', label: 'Nouvelle étape', depends_on: [] })
dirty.value = true; remap()
editStep.id = id; editStep.label = 'Nouvelle étape'; editStep.kind = 'notify'; stepDialog.value = true
}
function onConnect ({ source: src, target }) {
if (src === target) return
const t = findStep(target); if (!t) return
t.depends_on = t.depends_on || []
if (t.depends_on.includes(src)) return
if (dependsTransitively(src, target)) { $q.notify({ type: 'warning', message: 'Lien refusé : créerait une boucle.' }); return }
t.depends_on.push(src); dirty.value = true; remap()
}
function onEdit (id) { const s = findStep(id); if (!s) return; editStep.id = id; editStep.label = s.label || ''; editStep.kind = s.kind || 'notify'; stepDialog.value = true }
function applyStepEdit () { const s = findStep(editStep.id); if (s) { s.label = editStep.label; s.kind = editStep.kind; dirty.value = true; remap() } stepDialog.value = false }
function onDelete (id) {
templateSteps.value = templateSteps.value.filter(s => String(s.id) !== String(id))
for (const s of templateSteps.value) if (s.depends_on) s.depends_on = s.depends_on.filter(d => String(d) !== String(id))
dirty.value = true; remap()
}
function onMove ({ id, position }) { const s = findStep(id); if (s) { s._pos = { x: position.x, y: position.y }; dirty.value = true } }
async function save () {
if (!selectedName.value) return
saving.value = true
try {
await updateFlowTemplate(selectedName.value, { flow_definition: { ...loadedDef.value, steps: templateSteps.value } })
dirty.value = false
$q.notify({ type: 'positive', message: 'Orchestration enregistrée', timeout: 1500 })
} catch (e) { $q.notify({ type: 'negative', message: 'Échec : ' + (e.message || e) }) } finally { saving.value = false }
}
// ── Lancer une instance sur un client (wrapper proxy-authed /flow/instantiate) ──
const launchDialog = ref(false)
const custOptions = ref([])
const custPicked = ref(null)
const launching = ref(false)
const selectedLabel = computed(() => (templateOptions.value.find(o => o.value === selectedName.value) || {}).label || '')
function openLaunch () { custPicked.value = null; custOptions.value = []; launchDialog.value = true }
async function searchCust (query, done) {
const q = String(query || '').trim()
if (q.length < 2) { done(() => {}); return }
try {
const r = await fetch(`${HUB_URL}/collab/customer-search?q=${encodeURIComponent(q)}`).then(x => x.json())
const opts = (r.matches || []).map(m => ({ label: (m.customer_name || m.name) + (m.territory ? ' · ' + m.territory : ''), value: m.name }))
done(() => { custOptions.value = opts })
} catch { done(() => {}) }
}
async function doLaunch () {
if (!custPicked.value || !selectedName.value) return
launching.value = true
try {
const r = await fetch(`${HUB_URL}/flow/instantiate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ template: selectedName.value, customer: custPicked.value }) }).then(x => x.json())
launchDialog.value = false
$q.notify({ type: 'positive', message: 'Instance lancée' + (r.name ? ' (' + r.name + ')' : ''), timeout: 2500 })
source.value = 'run'; await loadRuns()
} catch (e) { $q.notify({ type: 'negative', message: 'Échec du lancement : ' + (e.message || e) }) } finally { launching.value = false }
}
// ── Instances (lecture seule, statut live) ──
async function loadRun (name) {
if (!name) { tasks.value = []; loading.value = false; return }
loading.value = true
try {
const r = await fetch(`${HUB_URL}/flow/runs/${encodeURIComponent(name)}`).then(x => x.json())
const run = r?.run || r
runStatus.value = run?.status || ''; runCustomer.value = run?.customer || ''
let ss = run?.step_state; if (typeof ss === 'string') { try { ss = JSON.parse(ss) } catch { ss = {} } }
const tpl = await getFlowTemplate(run?.flow_template)
tasks.value = mapSteps(tpl?.flow_definition?.steps || [], ss || {})
} catch (e) { tasks.value = [] } finally { loading.value = false }
}
async function loadRuns () {
loading.value = true
try {
const r = await fetch(`${HUB_URL}/flow/runs`).then(x => x.json())
const runs = r.runs || []
runOptions.value = runs.map(x => ({ label: x.name + ' · ' + (x.customer || '?') + ' · ' + (x.status || ''), value: x.name }))
if (runOptions.value.length) { selectedRun.value = runOptions.value[0].value; await loadRun(selectedRun.value) } else { tasks.value = []; loading.value = false }
} catch (e) { loading.value = false }
}
function onSourceChange (v) { if (v === 'run') loadRuns(); else loadTemplate(selectedName.value) }
onMounted(async () => {
try {
const tpls = await listFlowTemplates()
templateOptions.value = tpls.map(t => ({ label: t.template_name || t.name, value: t.name }))
if (templateOptions.value.length) { selectedName.value = templateOptions.value[0].value; await loadTemplate(selectedName.value) } else loading.value = false
} catch (e) { loading.value = false }
})
</script>
<style scoped>
.tg-select { min-width: 210px; }
/* Mobile (xs) : les sélecteurs prennent toute la largeur et les contrôles s'empilent proprement. */
@media (max-width: 599px) {
.tg-select { flex: 1 1 100%; min-width: 0; }
.tg-controls .q-btn-toggle { flex: 1 1 auto; }
}
</style>