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>
106 lines
6.2 KiB
Vue
106 lines
6.2 KiB
Vue
<template>
|
|
<!-- Graphe de DÉPENDANCES (Vue Flow) — lecture OU constructeur (editable) : ajouter des boîtes, les relier (depends_on),
|
|
cliquer pour lier une ACTION (kind), supprimer, glisser pour ranger. Mappe le Flow Template (steps). -->
|
|
<div class="tdg">
|
|
<div class="tdg-bar">
|
|
<div class="tdg-legend">
|
|
<span class="tdg-lg"><span class="tdg-dot" style="background:#ea580c"></span>🚚 Déplacement (terrain)</span>
|
|
<span class="tdg-lg"><span class="tdg-dot" style="background:#2563eb"></span>🏢 À distance (bureau)</span>
|
|
<span class="tdg-lg"><span class="tdg-arrow">→</span> dépend de</span>
|
|
</div>
|
|
<q-space />
|
|
<q-btn v-if="editable" dense unelevated no-caps color="primary" icon="add" label="Ajouter une étape" size="sm" @click="emit('add')" />
|
|
</div>
|
|
<div class="tdg-canvas">
|
|
<VueFlow :nodes="nodes" :edges="edges" :min-zoom="0.3" :max-zoom="2" fit-view-on-init :nodes-draggable="true"
|
|
:nodes-connectable="editable" @connect="onConnect" @node-click="onNodeClick" @node-drag-stop="onDragStop">
|
|
<Background pattern-color="#d6deea" :gap="18" />
|
|
<Controls />
|
|
<template #node-task="{ id, data }">
|
|
<Handle type="target" :position="Position.Left" />
|
|
<div class="tdg-node" :class="{ dispatch: data.dispatch, editable }">
|
|
<button v-if="editable" class="tdg-del" title="Supprimer" @click.stop="emit('delete', id)">✕</button>
|
|
<div class="tdg-node-top">
|
|
<span class="tdg-ic">{{ data.dispatch ? '🚚' : '🏢' }}</span>
|
|
<span class="tdg-st" :style="{ background: data.statusColor }">{{ data.status }}</span>
|
|
</div>
|
|
<div class="tdg-node-title">{{ data.subject }}</div>
|
|
<div class="tdg-node-meta">{{ data.assignee || '—' }}<span v-if="data.when"> · {{ data.when }}</span></div>
|
|
</div>
|
|
<Handle type="source" :position="Position.Right" />
|
|
</template>
|
|
</VueFlow>
|
|
</div>
|
|
<div v-if="editable" class="tdg-hint">Tirez d'un point à l'autre pour relier · clic = éditer l'action · ✕ = supprimer · glissez pour ranger</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'
|
|
|
|
// tasks: [{ id, subject, dependsOn:[ids], requiresDispatch, assignee, status, when, pos? }]
|
|
const props = defineProps({ tasks: { type: Array, default: () => [] }, editable: { type: Boolean, default: false } })
|
|
const emit = defineEmits(['add', 'connect', 'edit', 'delete', 'move'])
|
|
|
|
const STATUS_COLOR = { Open: '#64748b', Working: '#2563eb', 'Pending Review': '#d97706', Completed: '#16a34a', Cancelled: '#dc2626', Overdue: '#dc2626' }
|
|
|
|
const nodes = ref([])
|
|
const edges = ref([])
|
|
|
|
function rebuild () {
|
|
const tasks = props.tasks || []
|
|
const byId = {}; for (const t of tasks) byId[t.id] = t
|
|
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]?.dependsOn || []).filter(d => byId[d])
|
|
const d = deps.length ? 1 + Math.max(...deps.map(x => depth(x, seen))) : 0
|
|
memo[id] = d; return d
|
|
}
|
|
const perDepth = {}; const n = []
|
|
for (const t of tasks) {
|
|
const d = depth(t.id); perDepth[d] = perDepth[d] || 0
|
|
const pos = (t.pos && typeof t.pos.x === 'number') ? { x: t.pos.x, y: t.pos.y } : { x: d * 280, y: perDepth[d] * 130 }
|
|
n.push({ id: String(t.id), type: 'task', position: pos, data: { subject: t.subject, dispatch: !!t.requiresDispatch, assignee: t.assignee, status: t.status || 'Open', statusColor: STATUS_COLOR[t.status] || '#64748b', when: t.when } })
|
|
perDepth[d]++
|
|
}
|
|
const e = []
|
|
for (const t of tasks) for (const dep of (t.dependsOn || [])) { if (byId[dep]) e.push({ id: `e-${dep}-${t.id}`, source: String(dep), target: String(t.id), animated: !!t.requiresDispatch, style: { stroke: t.requiresDispatch ? '#ea580c' : '#94a3b8', strokeWidth: 2 } }) }
|
|
nodes.value = n; edges.value = e
|
|
}
|
|
watch(() => props.tasks, rebuild, { immediate: true, deep: true })
|
|
|
|
function onNodeClick (e) { if (props.editable && 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>
|
|
.tdg { display: flex; flex-direction: column; gap: 8px; }
|
|
.tdg-bar { display: flex; align-items: center; }
|
|
.tdg-legend { display: flex; flex-wrap: wrap; gap: 16px; font-size: 12px; color: #475569; padding: 0 4px; }
|
|
.tdg-lg { display: inline-flex; align-items: center; gap: 5px; }
|
|
.tdg-dot { width: 11px; height: 11px; border-radius: 3px; display: inline-block; }
|
|
.tdg-arrow { color: #94a3b8; font-weight: 700; }
|
|
.tdg-canvas { height: 560px; border: 1px solid #e2e8f0; border-radius: 10px; background: #fafbfc; overflow: hidden; }
|
|
.tdg-node { position: relative; background: #fff; border: 2px solid #2563eb; border-radius: 10px; padding: 8px 11px; min-width: 180px; max-width: 240px; box-shadow: 0 1px 5px rgba(15,23,42,.08); }
|
|
.tdg-node.dispatch { border-color: #ea580c; background: #fff7ed; }
|
|
.tdg-node.editable { cursor: pointer; }
|
|
.tdg-node-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 3px; }
|
|
.tdg-ic { font-size: 15px; }
|
|
.tdg-st { font-size: 9px; font-weight: 700; color: #fff; padding: 1px 6px; border-radius: 8px; text-transform: uppercase; }
|
|
.tdg-node-title { font-size: 13px; font-weight: 600; color: #1e293b; line-height: 1.25; }
|
|
.tdg-node-meta { font-size: 11px; color: #64748b; margin-top: 3px; }
|
|
.tdg-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; }
|
|
.tdg-node:hover .tdg-del { display: block; }
|
|
.tdg-hint { font-size: 11px; color: #94a3b8; padding: 0 4px; }
|
|
</style>
|