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

243 lines
9.1 KiB
Vue

<!--
StepEditorModal.vue Modal form for editing a single step.
Renders kind-specific fields dynamically from the kind catalog, so adding a
new step kind = adding an entry in kind-catalogs.js (no edit here needed).
Props:
- modelValue: open/closed boolean (v-model)
- step: the step being edited (cloned internally to avoid mutation)
- kindCatalog: PROJECT_KINDS | AGENT_KINDS
- allStepIds: array of step IDs available as depends_on targets
- appliesTo: domain context (e.g. 'Customer'), used by the variable picker
Events:
- update:modelValue(boolean)
- save(step): user clicked save, receives the edited step
Performance:
- Deep-clones the step on open (O(payload size)) to isolate edits.
- Field rendering driven by a computed descriptor list one render cycle
per kind change.
-->
<template>
<q-dialog :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)"
persistent maximized-bp="sm">
<q-card class="step-editor-card" style="width: 560px; max-width: 95vw">
<q-card-section class="step-editor-hdr">
<div class="row items-center">
<q-icon :name="kindDef.icon" size="20px" :style="{ color: kindDef.color }" class="q-mr-sm" />
<div class="col">
<div class="text-subtitle1 text-weight-bold">Modifier l'étape</div>
<div class="text-caption text-grey-7">{{ kindDef.label }}</div>
</div>
<!-- Click a variable → `{{path}}` is copied to the clipboard so the
user can paste it into any field below (SMS body, email subject,
webhook URL, …). No direct-inject because we'd need a ref on
every FieldInput + cursor tracking, which isn't worth it. -->
<VariablePicker :applies-to="appliesTo" mode="copy" label="Variables" />
<q-btn flat round dense icon="close" @click="close" class="q-ml-xs" />
</div>
</q-card-section>
<q-card-section class="step-editor-body" style="max-height: 70vh; overflow-y: auto">
<!-- Core fields — always present -->
<div class="row q-col-gutter-sm q-mb-md">
<div class="col-12">
<q-input v-model="local.label" dense outlined label="Nom de l'étape" stack-label />
</div>
</div>
<div class="row q-col-gutter-sm q-mb-md">
<div class="col-6">
<q-select v-model="local.kind" dense outlined emit-value map-options
:options="kindOptions" label="Type d'étape" stack-label
@update:model-value="onKindChange" />
</div>
<div class="col-6">
<q-select v-model="triggerType" dense outlined emit-value map-options
:options="triggerOptions" label="Déclencheur" stack-label
@update:model-value="onTriggerChange" />
</div>
</div>
<!-- Trigger-specific fields -->
<div v-if="triggerDef.fields.length" class="row q-col-gutter-sm q-mb-md">
<div v-for="f in triggerDef.fields" :key="f.name" class="col-6">
<FieldInput :field="f" :model-value="local.trigger[f.name]"
@update:model-value="val => (local.trigger[f.name] = val)" />
</div>
</div>
<!-- Dependencies — options are {label, value} so chips show the step
name (e.g. "Retrait ancien site") while the stored value stays
the stable id ("s2"). Prevents the "← s4" confusion where the
user couldn't tell which step was referenced. -->
<div class="q-mb-md">
<q-select v-model="local.depends_on" dense outlined multiple use-chips
emit-value map-options :options="dependsOnOptions"
label="Dépend de" stack-label
hint="Cette étape attend que les étapes listées soient complétées" />
</div>
<!-- Kind-specific payload fields -->
<div v-if="kindDef.fields.length" class="step-editor-section">
<div class="text-caption text-weight-bold text-grey-8 q-mb-sm">
Paramètres — {{ kindDef.label }}
</div>
<div class="row q-col-gutter-sm">
<div v-for="f in kindDef.fields" :key="f.name" :class="fieldWidth(f)">
<FieldInput :field="f" :model-value="local.payload[f.name]"
@update:model-value="val => (local.payload[f.name] = val)" />
</div>
</div>
</div>
<!-- Parent branch (shown if the step has a parent condition) -->
<div v-if="local.parent_id" class="q-mt-md">
<q-input v-model="local.branch" dense outlined label="Branche parent"
stack-label hint="yes / no / custom" />
</div>
</q-card-section>
<q-card-actions class="step-editor-ftr">
<q-btn flat color="grey-7" label="Annuler" @click="close" no-caps />
<q-space />
<q-btn unelevated color="indigo-6" label="Enregistrer" @click="save" no-caps
icon="check" />
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { getKind, getTrigger, TRIGGER_TYPES } from './kind-catalogs'
import FieldInput from './FieldInput.vue'
import VariablePicker from './VariablePicker.vue'
const props = defineProps({
modelValue: { type: Boolean, default: false },
step: { type: Object, default: null },
kindCatalog: { type: Object, required: true },
allStepIds: { type: Array, default: () => [] },
// Full step list (id + label) so we can render depends_on chips with
// human-readable labels instead of opaque "s4" IDs. Fallback:
// allStepIds is still honored when allSteps isn't passed.
allSteps: { type: Array, default: () => [] },
appliesTo: { type: String, default: null },
})
const emit = defineEmits(['update:modelValue', 'save'])
/** Local deep clone — mutations don't affect parent until user hits save. */
const local = ref(cloneStep(props.step))
const triggerType = ref(local.value.trigger?.type || 'on_prev_complete')
// Reset local state when the modal reopens on a different step
watch(() => [props.modelValue, props.step?.id], () => {
if (props.modelValue && props.step) {
local.value = cloneStep(props.step)
triggerType.value = local.value.trigger?.type || 'on_prev_complete'
}
})
/** Deep-clone a step so edits are isolated until save. */
function cloneStep (step) {
if (!step) return { id: '', kind: 'notify', label: '', payload: {}, trigger: { type: 'on_prev_complete' }, depends_on: [] }
return JSON.parse(JSON.stringify(step))
}
/** Kind descriptor from the catalog. */
const kindDef = computed(() => getKind(props.kindCatalog, local.value.kind))
/** Trigger descriptor from the shared trigger map. */
const triggerDef = computed(() => getTrigger(triggerType.value))
/** Options for the "type d'étape" select (derived once from the catalog). */
const kindOptions = computed(() =>
Object.entries(props.kindCatalog).map(([key, def]) => ({
label: def.label, value: key,
}))
)
/** Options for the "trigger" select. */
const triggerOptions = computed(() =>
Object.entries(TRIGGER_TYPES).map(([key, def]) => ({
label: def.label, value: key,
}))
)
/** depends_on options — exclude self to prevent circular refs. */
const otherStepIds = computed(() =>
props.allStepIds.filter(id => id !== local.value.id)
)
/**
* depends_on dropdown options with human-readable labels.
*
* Shape: `{ label: "Retrait ancien site", value: "s2" }[]`.
*
* Falls back to `allStepIds` when the caller didn't pass `allSteps` — that
* way the control never breaks, it just degrades to raw ids like before.
*/
const dependsOnOptions = computed(() => {
if (props.allSteps?.length) {
return props.allSteps
.filter(s => s.id !== local.value.id)
.map(s => ({ label: s.label || s.id, value: s.id }))
}
return otherStepIds.value.map(id => ({ label: id, value: id }))
})
/** Handle kind change: reset payload defaults, preserve id/label. */
function onKindChange (newKind) {
const def = getKind(props.kindCatalog, newKind)
const payload = {}
for (const f of def.fields || []) {
if (f.default !== undefined) payload[f.name] = f.default
}
local.value.payload = payload
}
/** Handle trigger change: reset trigger-specific fields. */
function onTriggerChange (newType) {
local.value.trigger = { type: newType }
}
/** Heuristic: textarea fields span full width, others are half. */
function fieldWidth (field) {
if (field.type === 'textarea' || field.type === 'json') return 'col-12'
return 'col-12 col-md-6'
}
function save () {
// Normalize empty arrays / nulls
if (!Array.isArray(local.value.depends_on)) local.value.depends_on = []
emit('save', JSON.parse(JSON.stringify(local.value)))
emit('update:modelValue', false)
}
function close () {
emit('update:modelValue', false)
}
</script>
<style scoped>
.step-editor-card { border-radius: 8px; }
.step-editor-hdr {
border-bottom: 1px solid #e2e8f0;
padding: 12px 16px;
}
.step-editor-body { padding: 16px; }
.step-editor-section {
border-top: 1px solid #f1f5f9;
padding-top: 12px;
margin-top: 8px;
}
.step-editor-ftr {
border-top: 1px solid #e2e8f0;
padding: 10px 16px;
}
</style>