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>
433 lines
18 KiB
Vue
433 lines
18 KiB
Vue
<!--
|
|
FlowEditorDialog.vue — Global, full-screen editor dialog for Flow Templates.
|
|
|
|
Mounted once in MainLayout; any page opens it via the useFlowEditor
|
|
composable. This is the Odoo "inline create from anywhere" pattern.
|
|
|
|
What it does:
|
|
- Shows metadata header (template_name, category, applies_to, icon,
|
|
active/inactive toggle, version badge, system badge)
|
|
- Renders the FlowEditor visual tree bound to template.flow_definition
|
|
- Action bar: Save / Duplicate / Delete / Close
|
|
- Protects system templates from destructive edits (offers Duplicate)
|
|
|
|
Performance:
|
|
- Lazy: only renders the heavy FlowEditor when `isOpen` is true
|
|
- Uses `markDirty` on every field blur instead of watching the whole doc
|
|
(avoids O(n) deep-watch overhead on large flows)
|
|
-->
|
|
<template>
|
|
<q-dialog :model-value="isOpen" @update:model-value="onModelUpdate" persistent maximized
|
|
transition-show="slide-up" transition-hide="slide-down">
|
|
<q-card class="flow-editor-dialog column no-wrap">
|
|
<!-- Header -->
|
|
<q-card-section class="fe-header">
|
|
<div class="row items-center no-wrap">
|
|
<q-icon :name="template?.icon || 'account_tree'" size="24px" color="indigo-6" class="q-mr-sm" />
|
|
<div class="col">
|
|
<div class="text-subtitle1 text-weight-bold">
|
|
{{ mode === 'new' ? 'Nouveau template de flow' : (template?.template_name || templateName || '') }}
|
|
</div>
|
|
<div class="text-caption text-grey-7">
|
|
<span v-if="templateName">{{ templateName }}</span>
|
|
<span v-if="template?.version" class="q-ml-sm">v{{ template.version }}</span>
|
|
<q-badge v-if="template?.is_system" color="amber-2" text-color="amber-9" label="système"
|
|
class="q-ml-sm" />
|
|
<q-badge v-if="template && !template.is_active" color="grey-3" text-color="grey-7"
|
|
label="inactif" class="q-ml-sm" />
|
|
<q-badge v-if="dirty" color="orange-2" text-color="orange-9"
|
|
label="non sauvegardé" class="q-ml-sm" />
|
|
</div>
|
|
</div>
|
|
<q-btn flat round dense icon="close" @click="onClose" />
|
|
</div>
|
|
</q-card-section>
|
|
|
|
<q-separator />
|
|
|
|
<q-card-section v-if="loading" class="flex flex-center q-pa-xl">
|
|
<q-spinner size="40px" color="indigo-6" />
|
|
</q-card-section>
|
|
|
|
<q-card-section v-else-if="error" class="text-negative q-pa-md">
|
|
<q-icon name="error" size="20px" class="q-mr-sm" />{{ error }}
|
|
</q-card-section>
|
|
|
|
<!-- Body: meta grid + flow tree -->
|
|
<q-card-section v-else-if="template" class="col fe-body">
|
|
<div class="fe-body-grid">
|
|
<!-- Left: metadata form -->
|
|
<div class="fe-meta-col">
|
|
<div class="text-caption text-weight-bold text-grey-7 q-mb-sm">Informations</div>
|
|
|
|
<q-input v-model="template.template_name" dense outlined stack-label label="Nom du template *"
|
|
:disable="template.is_system" @update:model-value="markDirty" />
|
|
|
|
<div class="row q-col-gutter-sm q-mt-xs">
|
|
<div class="col-6">
|
|
<q-select v-model="template.category" dense outlined stack-label label="Catégorie"
|
|
emit-value map-options :options="CATEGORY_OPTIONS"
|
|
@update:model-value="markDirty" />
|
|
</div>
|
|
<div class="col-6">
|
|
<q-select v-model="template.applies_to" dense outlined stack-label label="Applique à"
|
|
emit-value map-options :options="APPLIES_TO_OPTIONS" clearable
|
|
@update:model-value="markDirty" />
|
|
</div>
|
|
</div>
|
|
|
|
<q-input v-model="template.icon" dense outlined stack-label label="Icône (material)"
|
|
placeholder="account_tree" class="q-mt-xs"
|
|
@update:model-value="markDirty">
|
|
<template #prepend><q-icon :name="template.icon || 'account_tree'" /></template>
|
|
</q-input>
|
|
|
|
<q-select v-model="template.trigger_event" dense outlined stack-label label="Événement déclencheur"
|
|
emit-value map-options :options="TRIGGER_EVENT_OPTIONS" clearable
|
|
hint="Quand ce flow doit-il démarrer automatiquement ?"
|
|
class="q-mt-xs" @update:model-value="markDirty" />
|
|
|
|
<!-- Trigger condition — JSONLogic (or simple expression). -->
|
|
<div class="q-mt-xs">
|
|
<div class="row items-center q-mb-xs">
|
|
<div class="text-caption text-weight-medium text-grey-8">Condition de démarrage</div>
|
|
<q-space />
|
|
<VariablePicker :applies-to="template.applies_to"
|
|
@insert="onInsertCondition" />
|
|
</div>
|
|
<q-input v-model="template.trigger_condition" dense outlined autogrow
|
|
:placeholder="conditionPlaceholder"
|
|
@update:model-value="markDirty" />
|
|
<div class="text-caption text-grey-6 q-mt-xs">
|
|
Facultatif. Filtre <b>au moment</b> où l'événement
|
|
« {{ triggerEventLabel }} » arrive : le flow démarre seulement si
|
|
la condition est vraie. <b>Ce n'est pas un scheduler</b> — pour
|
|
« X jours après / à une date », utilisez une étape
|
|
<code>wait</code> dans le flow.
|
|
</div>
|
|
|
|
<q-expansion-item dense expand-separator icon="lightbulb"
|
|
label="Exemples selon le contexte" class="q-mt-xs condition-help">
|
|
<q-card flat class="q-mt-xs">
|
|
<q-card-section class="q-pa-sm text-caption text-grey-8">
|
|
<div v-if="conditionExamples.length">
|
|
<div v-for="(ex, i) in conditionExamples" :key="i" class="q-mb-sm example-row">
|
|
<div class="text-weight-medium text-grey-9">{{ ex.title }}</div>
|
|
<div class="text-grey-7 q-mb-xs">{{ ex.desc }}</div>
|
|
<div class="row items-start no-wrap">
|
|
<div class="condition-code col">{{ ex.code }}</div>
|
|
<q-btn flat dense size="xs" icon="north_east" color="indigo-6"
|
|
class="q-ml-xs" @click="useExample(ex.code)">
|
|
<q-tooltip>Utiliser cet exemple</q-tooltip>
|
|
</q-btn>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-else class="text-grey-7">
|
|
Choisissez un « Applique à » ci-dessus pour voir des exemples adaptés.
|
|
</div>
|
|
|
|
<q-separator class="q-my-sm" />
|
|
|
|
<div class="text-caption text-grey-7">
|
|
<b>Format long (JSONLogic)</b> pour conditions complexes :
|
|
<div v-pre class="condition-code">{"and": [{"==": [{"var": "customer.customer_type"}, "Individual"]}, {">=": [{"var": "contract.monthly_price"}, 100]}]}</div>
|
|
<div class="q-mt-xs"><b>Format court</b> pour cas simples :
|
|
<div v-pre class="condition-code">customer.customer_type == "Individual"</div>
|
|
</div>
|
|
</div>
|
|
</q-card-section>
|
|
</q-card>
|
|
</q-expansion-item>
|
|
</div>
|
|
|
|
<q-input v-model="template.description" dense outlined stack-label type="textarea"
|
|
:rows="2" label="Description" class="q-mt-xs"
|
|
@update:model-value="markDirty" />
|
|
|
|
<q-input v-model="template.tags" dense outlined stack-label label="Tags (séparés par ,)"
|
|
class="q-mt-xs" @update:model-value="markDirty" />
|
|
|
|
<div class="q-mt-sm">
|
|
<q-toggle v-model="active" label="Actif" color="green-6"
|
|
@update:model-value="onToggleActive" />
|
|
</div>
|
|
|
|
<q-input v-model="template.notes" dense outlined stack-label type="textarea"
|
|
:rows="3" label="Notes internes" class="q-mt-md"
|
|
@update:model-value="markDirty" />
|
|
</div>
|
|
|
|
<!-- Right: flow tree -->
|
|
<div class="fe-tree-col">
|
|
<div class="row items-center q-mb-sm">
|
|
<div class="text-caption text-weight-bold text-grey-7">Étapes du flow</div>
|
|
<q-space />
|
|
<q-badge v-if="stepCount" color="indigo-1" text-color="indigo-8" :label="`${stepCount} étape${stepCount > 1 ? 's' : ''}`" />
|
|
</div>
|
|
<FlowEditor :model-value="template.flow_definition"
|
|
:kind-catalog="PROJECT_KINDS"
|
|
:applies-to="template.applies_to"
|
|
@update:model-value="onDefChange" />
|
|
</div>
|
|
</div>
|
|
</q-card-section>
|
|
|
|
<q-separator />
|
|
|
|
<!-- Footer actions -->
|
|
<q-card-actions class="fe-footer">
|
|
<q-btn v-if="mode === 'edit' && !template?.is_system"
|
|
flat color="red-7" icon="delete" label="Supprimer" no-caps
|
|
@click="onDelete" />
|
|
<q-btn v-if="mode === 'edit'"
|
|
flat color="indigo-6" icon="content_copy" label="Dupliquer" no-caps
|
|
@click="onDuplicate" />
|
|
<q-space />
|
|
<q-btn flat color="grey-7" label="Fermer" no-caps @click="onClose" />
|
|
<q-btn unelevated color="indigo-6" icon="save"
|
|
:label="mode === 'new' ? 'Créer' : 'Enregistrer'" no-caps
|
|
:loading="saving" :disable="!canSave"
|
|
@click="onSave" />
|
|
</q-card-actions>
|
|
</q-card>
|
|
</q-dialog>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, ref, watch } from 'vue'
|
|
import { Notify } from 'quasar'
|
|
import { useFlowEditor } from 'src/composables/useFlowEditor'
|
|
import FlowEditor from './FlowEditor.vue'
|
|
import VariablePicker from './VariablePicker.vue'
|
|
import { PROJECT_KINDS } from './kind-catalogs'
|
|
|
|
// ── Static option sets (defined once; never recomputed) ────────────────────
|
|
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: 'Devis (Quotation)', value: 'Quotation' },
|
|
{ label: 'Contrat (Service Contract)', value: 'Service Contract' },
|
|
{ label: 'Ticket (Issue)', value: 'Issue' },
|
|
{ label: 'Client (Customer)', value: 'Customer' },
|
|
{ label: 'Abonnement (Subscription)', value: 'Subscription' },
|
|
]
|
|
|
|
// Must match Flow Template DocType select options exactly — values are
|
|
// validated server-side. If you add a new one, also extend the DocType.
|
|
const TRIGGER_EVENT_OPTIONS = [
|
|
{ label: 'Contrat signé', value: 'on_contract_signed' },
|
|
{ label: 'Paiement reçu', value: 'on_payment_received' },
|
|
{ label: 'Abonnement actif', value: 'on_subscription_active' },
|
|
{ label: 'Devis créé', value: 'on_quotation_created' },
|
|
{ label: 'Devis accepté', value: 'on_quotation_accepted' },
|
|
{ label: 'Ticket ouvert', value: 'on_issue_opened' },
|
|
{ label: 'Client créé', value: 'on_customer_created' },
|
|
{ label: 'Intervention terminée', value: 'on_dispatch_completed' },
|
|
{ label: 'Manuel (bouton)', value: 'manual' },
|
|
]
|
|
|
|
// Domain-specific example conditions. Built per applies_to so the examples
|
|
// reference the right variable paths and real business cases.
|
|
const CONDITION_EXAMPLES = {
|
|
Customer: [
|
|
{ title: 'Particulier uniquement', desc: 'Filtre pour les clients résidentiels',
|
|
code: '{"==": [{"var": "customer.customer_type"}, "Individual"]}' },
|
|
{ title: 'Entreprise uniquement', desc: 'Filtre pour les clients commerciaux',
|
|
code: '{"==": [{"var": "customer.customer_type"}, "Company"]}' },
|
|
{ title: 'Clients francophones', desc: 'Routage par langue',
|
|
code: '{"==": [{"var": "customer.language"}, "fr"]}' },
|
|
{ title: 'Zone Montréal', desc: 'Routage géographique',
|
|
code: '{"==": [{"var": "customer.territory"}, "Montréal"]}' },
|
|
],
|
|
Quotation: [
|
|
{ title: 'Devis > 500 $', desc: 'Seulement pour les gros devis',
|
|
code: '{">": [{"var": "quotation.grand_total"}, 500]}' },
|
|
{ title: 'Devis résidentiel', desc: 'Filtre par type de client',
|
|
code: '{"==": [{"var": "customer.customer_type"}, "Individual"]}' },
|
|
],
|
|
'Service Contract': [
|
|
{ title: 'Contrats haut de gamme', desc: 'Onboarding VIP pour ≥ 100 $/mois',
|
|
code: '{">=": [{"var": "contract.monthly_price"}, 100]}' },
|
|
{ title: 'Plan Gigabit seulement', desc: 'Routage par plan',
|
|
code: '{"==": [{"var": "contract.plan"}, "GIGA-1000"]}' },
|
|
{ title: 'Contrats résidentiels', desc: 'Filtre par type de client',
|
|
code: '{"==": [{"var": "customer.customer_type"}, "Individual"]}' },
|
|
],
|
|
Issue: [
|
|
{ title: 'Tickets urgents', desc: 'Escalade immédiate',
|
|
code: '{"==": [{"var": "issue.priority"}, "Urgent"]}' },
|
|
{ title: 'Incidents techniques', desc: 'Filtre par type',
|
|
code: '{"==": [{"var": "issue.issue_type"}, "Incident"]}' },
|
|
{ title: 'Tickets haute priorité', desc: 'Urgent ou High',
|
|
code: '{"in": [{"var": "issue.priority"}, ["Urgent", "High"]]}' },
|
|
],
|
|
Subscription: [
|
|
{ title: 'Abonnements actifs', desc: 'Filtre sur le statut',
|
|
code: '{"==": [{"var": "subscription.status"}, "Active"]}' },
|
|
],
|
|
}
|
|
|
|
// ── Composable state ────────────────────────────────────────────────────────
|
|
const fe = useFlowEditor()
|
|
const {
|
|
isOpen, template, templateName, mode, loading, saving, error, dirty,
|
|
markDirty, close, save, duplicate, remove,
|
|
} = fe
|
|
|
|
// Local proxy for the active toggle (coerces 0/1 ↔ boolean)
|
|
const active = ref(true)
|
|
watch(() => template.value?.is_active, (v) => { active.value = v === undefined ? true : !!v })
|
|
|
|
function onToggleActive (v) {
|
|
if (template.value) {
|
|
template.value.is_active = v ? 1 : 0
|
|
markDirty()
|
|
}
|
|
}
|
|
|
|
/** Replace the flow_definition when the tree editor emits. */
|
|
function onDefChange (newDef) {
|
|
if (!template.value) return
|
|
template.value.flow_definition = newDef
|
|
markDirty()
|
|
}
|
|
|
|
/**
|
|
* Append a picked variable at the end of the trigger_condition field.
|
|
* We don't try to insert at the cursor position — the extra plumbing
|
|
* (textarea ref + selection range) isn't worth it for a field this small.
|
|
* Users can always reposition manually.
|
|
*/
|
|
function onInsertCondition (text) {
|
|
if (!template.value) return
|
|
const cur = template.value.trigger_condition || ''
|
|
template.value.trigger_condition = cur + text
|
|
markDirty()
|
|
}
|
|
|
|
/** Replace the whole condition with an example — clearer than appending. */
|
|
function useExample (code) {
|
|
if (!template.value) return
|
|
template.value.trigger_condition = code
|
|
markDirty()
|
|
}
|
|
|
|
/** Contextual examples based on applies_to (empty list falls back to a hint). */
|
|
const conditionExamples = computed(() => {
|
|
const a = template.value?.applies_to
|
|
return CONDITION_EXAMPLES[a] || []
|
|
})
|
|
|
|
/** Placeholder adapted to the chosen applies_to (first example of that domain). */
|
|
const conditionPlaceholder = computed(() => {
|
|
const ex = conditionExamples.value[0]
|
|
return ex?.code || '{"==": [{"var": "customer.customer_type"}, "Individual"]}'
|
|
})
|
|
|
|
/** Human label of the currently picked trigger_event for the hint text. */
|
|
const triggerEventLabel = computed(() => {
|
|
const e = template.value?.trigger_event
|
|
if (!e) return 'l\'événement choisi'
|
|
return TRIGGER_EVENT_OPTIONS.find(o => o.value === e)?.label || e
|
|
})
|
|
|
|
/** Number of steps in the current def (memoised by Vue). */
|
|
const stepCount = computed(() => template.value?.flow_definition?.steps?.length || 0)
|
|
|
|
/** Gate the Save button. */
|
|
const canSave = computed(() => {
|
|
if (saving.value) return false
|
|
if (!template.value?.template_name?.trim()) return false
|
|
if (mode.value === 'edit' && !dirty.value) return false
|
|
return true
|
|
})
|
|
|
|
// ── Actions ─────────────────────────────────────────────────────────────────
|
|
|
|
async function onSave () {
|
|
try {
|
|
await save()
|
|
Notify.create({ type: 'positive', message: 'Template sauvegardé', timeout: 1500 })
|
|
} catch (e) {
|
|
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
|
|
}
|
|
}
|
|
|
|
async function onDuplicate () {
|
|
try {
|
|
await duplicate()
|
|
Notify.create({ type: 'positive', message: 'Template dupliqué (copie inactive)', timeout: 2000 })
|
|
} catch (e) {
|
|
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
|
|
}
|
|
}
|
|
|
|
async function onDelete () {
|
|
if (!window.confirm(`Supprimer le template "${template.value?.template_name}" ?`)) return
|
|
try {
|
|
await remove()
|
|
Notify.create({ type: 'info', message: 'Template supprimé', timeout: 1500 })
|
|
} catch (e) {
|
|
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
|
|
}
|
|
}
|
|
|
|
function onClose () { close() }
|
|
|
|
function onModelUpdate (v) { if (!v) close() }
|
|
</script>
|
|
|
|
<style scoped>
|
|
.flow-editor-dialog {
|
|
width: 100vw;
|
|
height: 100vh;
|
|
max-width: 100vw;
|
|
max-height: 100vh;
|
|
background: #f8fafc;
|
|
}
|
|
.fe-header { padding: 10px 16px; background: #fff; }
|
|
.fe-body { overflow-y: auto; padding: 16px; }
|
|
.fe-footer { padding: 10px 16px; background: #fff; }
|
|
|
|
.fe-body-grid {
|
|
display: grid;
|
|
grid-template-columns: minmax(260px, 340px) 1fr;
|
|
gap: 20px;
|
|
align-items: start;
|
|
}
|
|
|
|
@media (max-width: 700px) {
|
|
.fe-body-grid { grid-template-columns: 1fr; }
|
|
}
|
|
|
|
.fe-meta-col, .fe-tree-col {
|
|
background: #fff;
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 8px;
|
|
padding: 12px;
|
|
}
|
|
|
|
/* Condition helper: compact code snippets so the examples don't wrap awkwardly. */
|
|
.condition-help :deep(.q-item) { min-height: 32px; padding: 6px 8px; }
|
|
.condition-code {
|
|
background: #f1f5f9;
|
|
color: #1e293b;
|
|
padding: 4px 8px;
|
|
border-radius: 4px;
|
|
font-family: ui-monospace, Menlo, Consolas, monospace;
|
|
font-size: 0.72rem;
|
|
margin-top: 3px;
|
|
word-break: break-all;
|
|
}
|
|
.example-row { padding: 6px 0; border-bottom: 1px solid #f1f5f9; }
|
|
.example-row:last-child { border-bottom: none; }
|
|
</style>
|