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

313 lines
11 KiB
Vue

<!--
FlowTemplatesSection.vue Library view of Flow Templates for SettingsPage.
Shows all templates in a filterable table, with quick-actions per row:
- Click opens the global FlowEditorDialog in edit mode
- Duplicate button creates an inactive copy
- Activate/Deactivate toggle inline PUT is_active
- "Nouveau" CTA opens the dialog in create mode
Protects is_system templates from delete (enforced server-side too).
Perf notes:
- Single list call on mount, no pagination (expected fleet 200)
- Client-side filter on cached list (O(n) per keystroke, debounced)
- Debounced `q` filter input (250 ms)
- Activate/Deactivate toggles the cached row optimistically to avoid
a full reload on success
-->
<template>
<div class="ft-section">
<!-- Toolbar -->
<div class="row items-center q-mb-md q-gutter-sm">
<q-input v-model="q" dense outlined debounce="250" placeholder="Rechercher un template…"
style="flex:1;min-width:200px" clearable>
<template #prepend><q-icon name="search" color="grey-6" /></template>
</q-input>
<q-select v-model="categoryFilter" dense outlined emit-value map-options clearable
:options="CATEGORY_OPTIONS" label="Catégorie" style="min-width:160px" />
<q-select v-model="appliesToFilter" dense outlined emit-value map-options clearable
:options="APPLIES_TO_OPTIONS" label="Applique à" style="min-width:200px" />
<q-toggle v-model="showInactive" label="Inclure inactifs" color="indigo-6" dense />
<q-space />
<q-btn unelevated color="indigo-6" icon="add" label="Nouveau" no-caps dense
@click="onCreate" />
<q-btn flat dense icon="refresh" @click="reload" :loading="loading">
<q-tooltip>Rafraîchir</q-tooltip>
</q-btn>
</div>
<!-- Loading -->
<div v-if="loading && !rows.length" class="flex flex-center q-pa-xl">
<q-spinner size="32px" color="indigo-6" />
</div>
<!-- Error -->
<q-banner v-else-if="error" class="bg-red-1 text-red-9 q-mb-md">
<template #avatar><q-icon name="error" color="red-7" /></template>
{{ error }}
</q-banner>
<!-- Empty -->
<div v-else-if="!filtered.length" class="text-center text-grey-6 q-py-lg">
<q-icon name="account_tree" size="32px" color="grey-4" />
<div class="text-caption q-mt-sm">Aucun template trouvé</div>
<q-btn flat dense color="indigo-6" label="Créer le premier template" no-caps
class="q-mt-sm" @click="onCreate" />
</div>
<!-- Table -->
<div v-else class="ft-table-wrap">
<table class="ft-table">
<thead>
<tr>
<th class="text-left" style="width:40px"></th>
<th class="text-left">Nom</th>
<th class="text-left" style="width:110px">Catégorie</th>
<th class="text-left" style="width:150px">Applique à</th>
<th class="text-left" style="width:170px">Trigger</th>
<th class="text-center" style="width:80px">Étapes</th>
<th class="text-center" style="width:70px">Actif</th>
<th class="text-center" style="width:110px">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="r in filtered" :key="r.name" class="ft-row"
@click="onEdit(r)">
<td><q-icon :name="r.icon || 'account_tree'" :color="r.is_system ? 'amber-8' : 'indigo-6'" /></td>
<td>
<div class="text-weight-medium">{{ r.template_name }}</div>
<div class="text-caption text-grey-6">
{{ r.name }}
<q-badge v-if="r.is_system" color="amber-2" text-color="amber-9" label="système" class="q-ml-xs" />
<span v-if="r.version" class="q-ml-xs">v{{ r.version }}</span>
</div>
</td>
<td><q-badge :color="categoryColor(r.category)" :label="categoryLabel(r.category)" /></td>
<td class="text-grey-7 text-caption">{{ r.applies_to || '—' }}</td>
<td class="text-caption text-grey-7">{{ triggerLabel(r.trigger_event) || '—' }}</td>
<td class="text-center text-caption">{{ r.step_count || 0 }}</td>
<td class="text-center" @click.stop>
<q-toggle :model-value="!!r.is_active" color="green-6" dense
@update:model-value="v => onToggleActive(r, v)" />
</td>
<td class="text-center" @click.stop>
<q-btn flat round dense size="sm" icon="content_copy" color="indigo-6"
@click="onDuplicate(r)">
<q-tooltip>Dupliquer</q-tooltip>
</q-btn>
<q-btn v-if="!r.is_system" flat round dense size="sm" icon="delete" color="red-5"
@click="onDelete(r)">
<q-tooltip>Supprimer</q-tooltip>
</q-btn>
</td>
</tr>
</tbody>
</table>
</div>
<div class="text-caption text-grey-6 q-mt-sm">
{{ filtered.length }} template{{ filtered.length > 1 ? 's' : '' }}
<span v-if="filtered.length !== rows.length"> · filtrés sur {{ rows.length }}</span>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { Notify } from 'quasar'
import { useFlowEditor } from 'src/composables/useFlowEditor'
import {
listFlowTemplates,
updateFlowTemplate,
duplicateFlowTemplate,
deleteFlowTemplate,
} from 'src/api/flow-templates'
// ── Static option sets ──────────────────────────────────────────────────────
const CATEGORY_OPTIONS = [
{ label: 'Résidentiel', value: 'residential' },
{ label: 'Commercial', value: 'commercial' },
{ label: 'Dépannage', value: 'incident' },
{ label: 'Administratif', value: 'admin' },
{ label: 'Agent AI', value: 'agent' },
{ label: 'Autre', value: 'other' },
]
const APPLIES_TO_OPTIONS = [
{ label: 'Quotation', value: 'Quotation' },
{ label: 'Service Contract', value: 'Service Contract' },
{ label: 'Issue', value: 'Issue' },
{ label: 'Customer', value: 'Customer' },
{ label: 'Subscription', value: 'Subscription' },
]
const TRIGGER_EVENT_LABELS = {
on_contract_signed: 'Contrat signé',
on_payment_received: 'Paiement reçu',
on_subscription_active: 'Abonnement actif',
on_quotation_created: 'Devis créé',
on_quotation_accepted: 'Devis accepté',
on_issue_opened: 'Ticket ouvert',
on_customer_created: 'Client créé',
on_dispatch_completed: 'Intervention terminée',
manual: 'Manuel',
}
const CATEGORY_COLORS = {
residential: 'blue-1',
commercial: 'teal-1',
incident: 'red-1',
admin: 'grey-3',
agent: 'purple-1',
other: 'grey-2',
}
const CATEGORY_LABELS = Object.fromEntries(CATEGORY_OPTIONS.map(o => [o.value, o.label]))
function categoryLabel (c) { return CATEGORY_LABELS[c] || c }
function categoryColor (c) { return CATEGORY_COLORS[c] || 'grey-2' }
function triggerLabel (t) { return TRIGGER_EVENT_LABELS[t] || t }
// ── Reactive state ──────────────────────────────────────────────────────────
const rows = ref([])
const loading = ref(false)
const error = ref(null)
const q = ref('')
const categoryFilter = ref(null)
const appliesToFilter = ref(null)
const showInactive = ref(true)
const fe = useFlowEditor()
// ── Data loading ────────────────────────────────────────────────────────────
/**
* Load templates from the Hub.
* Called on mount + after each mutation (or via the refresh button).
*/
async function reload () {
loading.value = true
error.value = null
try {
rows.value = await listFlowTemplates()
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
onMounted(reload)
// ── Client-side filtering (single pass, fast for < 500 rows) ────────────────
const filtered = computed(() => {
const ql = q.value?.trim().toLowerCase()
const cat = categoryFilter.value
const ap = appliesToFilter.value
return rows.value.filter(r => {
if (!showInactive.value && !r.is_active) return false
if (cat && r.category !== cat) return false
if (ap && r.applies_to !== ap) return false
if (ql) {
const hay = `${r.template_name} ${r.name} ${r.tags || ''} ${r.description || ''}`.toLowerCase()
if (!hay.includes(ql)) return false
}
return true
})
})
// ── Row actions ─────────────────────────────────────────────────────────────
function onCreate () {
fe.openNew({
category: categoryFilter.value || 'other',
applies_to: appliesToFilter.value || null,
onSaved: () => reload(),
})
}
function onEdit (row) {
fe.openTemplate(row.name, { onSaved: () => reload() })
}
async function onDuplicate (row) {
try {
const dup = await duplicateFlowTemplate(row.name)
Notify.create({ type: 'positive', message: `Copie créée (${dup.name})`, timeout: 1500 })
await reload()
fe.openTemplate(dup.name, { onSaved: () => reload() })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
}
}
async function onDelete (row) {
if (row.is_system) {
Notify.create({ type: 'warning', message: 'Les templates système ne peuvent pas être supprimés. Utilisez "Dupliquer".', timeout: 3000 })
return
}
if (!window.confirm(`Supprimer "${row.template_name}" ?`)) return
try {
await deleteFlowTemplate(row.name)
rows.value = rows.value.filter(r => r.name !== row.name)
Notify.create({ type: 'info', message: 'Supprimé', timeout: 1500 })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
}
}
/** Optimistic toggle — revert on API failure. */
async function onToggleActive (row, v) {
const prev = row.is_active
row.is_active = v ? 1 : 0
try {
await updateFlowTemplate(row.name, { is_active: v ? 1 : 0 })
} catch (e) {
row.is_active = prev
Notify.create({ type: 'negative', message: 'Échec: ' + e.message, timeout: 2500 })
}
}
</script>
<style scoped>
.ft-section { padding: 4px; }
.ft-table-wrap {
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow-x: auto;
background: #fff;
}
.ft-table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
}
.ft-table thead th {
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
padding: 8px 12px;
font-weight: 600;
color: #475569;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.ft-row td {
padding: 10px 12px;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
}
.ft-row { cursor: pointer; transition: background 0.12s; }
.ft-row:hover { background: #f8fafc; }
.ft-row:last-child td { border-bottom: none; }
</style>