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>
313 lines
11 KiB
Vue
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>
|