gigafibre-fsm/apps/ops/src/components/flow-editor/FlowEditorDialog.vue
louispaulb cec5252944 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

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>