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>
243 lines
9.1 KiB
Vue
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>
|