gigafibre-fsm/apps/ops/src/composables/useWizardCatalog.js
louispaulb 41d9b5f316 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

462 lines
19 KiB
JavaScript

import { ref, computed } from 'vue'
import { HUB_URL, CATALOG_CATEGORIES, RESIDENTIAL_PRESETS, INTERNET_HERO_TIERS, INTERNET_HERO_CODES, TV_HERO_TIERS, TV_HERO_CODES, PREMIUM_SPORTS_CHANNELS, TV_PREMIUM_SURCHARGE_CODE, TV_ALC_OVERAGE_CODE } from 'src/data/wizard-constants'
// Premium channel lookup — keyed by premium_group id → catalog entry. Used to
// compute surcharges and pick-costs without re-walking PREMIUM_SPORTS_CHANNELS.
const PREMIUM_BY_GROUP = new Map(PREMIUM_SPORTS_CHANNELS.map(p => [p.id, p]))
// Shared "one truck roll" collapse — when multiple templates include the same
// logical task (e.g. fiber install visit), we keep a single step and rewire
// dependencies so the merged step's successors point at the right index.
// merge_key is the stable identifier; we fall back to subject for legacy
// templates that don't yet declare one.
const FALLBACK_CATALOG = [
{ item_code: 'INT-100', item_name: 'Internet 100 Mbps', rate: 49.99, billing_type: 'Mensuel', service_category: 'Internet', requires_visit: true, project_template_id: 'fiber_install' },
{ item_code: 'INT-300', item_name: 'Internet 300 Mbps', rate: 69.99, billing_type: 'Mensuel', service_category: 'Internet', requires_visit: true, project_template_id: 'fiber_install' },
{ item_code: 'INT-500', item_name: 'Internet 500 Mbps', rate: 89.99, billing_type: 'Mensuel', service_category: 'Internet', requires_visit: true, project_template_id: 'fiber_install' },
{ item_code: 'INT-1000', item_name: 'Internet 1 Gbps', rate: 109.99, billing_type: 'Mensuel', service_category: 'Internet', requires_visit: true, project_template_id: 'fiber_install' },
{ item_code: 'TEL-RES', item_name: 'Téléphonie résidentielle', rate: 19.99, billing_type: 'Mensuel', service_category: 'Téléphonie', requires_visit: true, project_template_id: 'phone_service' },
{ item_code: 'TEL-ILL', item_name: 'Téléphonie illimitée CA/US', rate: 29.99, billing_type: 'Mensuel', service_category: 'Téléphonie', requires_visit: true, project_template_id: 'phone_service' },
{ item_code: 'BDL-DUO-300', item_name: 'Duo Internet 300 + Téléphonie', rate: 79.99, billing_type: 'Mensuel', service_category: 'Bundle', requires_visit: true, project_template_id: 'fiber_install', is_bundle: true },
{ item_code: 'BDL-DUO-500', item_name: 'Duo Internet 500 + Téléphonie', rate: 99.99, billing_type: 'Mensuel', service_category: 'Bundle', requires_visit: true, project_template_id: 'fiber_install', is_bundle: true },
{ item_code: 'BDL-TRIO', item_name: 'Trio Internet 500 + Tél + IPTV', rate: 119.99, billing_type: 'Mensuel', service_category: 'Bundle', requires_visit: true, project_template_id: 'fiber_install', is_bundle: true },
{ item_code: 'EQP-ROUTER', item_name: 'Routeur WiFi 6', rate: 149.99, billing_type: 'Unique', service_category: 'Équipement' },
{ item_code: 'FEE-INSTALL', item_name: 'Frais d\'installation', rate: 75.00, billing_type: 'Unique', service_category: 'Frais' },
{ item_code: 'FEE-ACTIV', item_name: 'Frais d\'activation', rate: 25.00, billing_type: 'Unique', service_category: 'Frais' },
]
export function useWizardCatalog ({ orderItems, wizardSteps, templates, templateLoadedFor }) {
const catalogProducts = ref([])
const catalogLoading = ref(false)
const catalogFilter = ref('Tous')
const lastMergeCount = ref(0)
const mergedTemplateLabels = ref([])
const filteredCatalog = computed(() => {
if (catalogFilter.value === 'Tous') return catalogProducts.value
return catalogProducts.value.filter(p => p.service_category === catalogFilter.value)
})
async function loadCatalog () {
if (catalogProducts.value.length) return
catalogLoading.value = true
try {
const res = await fetch(`${HUB_URL}/api/catalog`)
const data = await res.json()
if (data.ok && data.items) {
catalogProducts.value = data.items
}
} catch (e) {
console.warn('[Wizard] Catalog load failed:', e.message)
catalogProducts.value = [...FALLBACK_CATALOG]
} finally {
catalogLoading.value = false
}
}
function addCatalogItem (product) {
orderItems.value.push({
item_code: product.item_code,
item_name: product.item_name,
qty: 1,
rate: product.rate,
regular_price: 0,
billing: product.billing_type === 'Mensuel' || product.billing_type === 'Annuel' ? 'recurring' : 'onetime',
billing_interval: product.billing_type === 'Annuel' ? 'Year' : 'Month',
contract_months: 12,
project_template_id: product.project_template_id || '',
requires_visit: product.requires_visit || false,
})
if (product.project_template_id && !templateLoadedFor.value.has(product.project_template_id)) {
loadTemplateFromItem({ project_template_id: product.project_template_id })
}
}
// Single-select hero tier swap — generic over a set of codes. Clicking a
// tier REPLACES whatever tier from the same family is in the cart (doesn't
// stack). Re-clicking the active tier toggles it off. `service_type` and
// `family_codes` scope the swap so Internet and TV switchers don't stomp
// each other. Auto-cleans shared templates (fiber_install) when no item
// from that family still needs it.
// When `channelSelection` is supplied (TV Mix tiers), we also emit premium
// surcharges (one per premium_group) and an à-la-carte overage line when
// the selection exceeds tier.picks_allowed.
function selectHeroTierGeneric ({ tier, familyCodes, serviceType, tiers, channelSelection }) {
const currentHeroCode = orderItems.value.find(i => familyCodes.has(i.item_code) && !i.is_rebate)?.item_code
const isToggleOff = currentHeroCode === tier.code && !channelSelection
const extraCodesToClear = serviceType === 'tv'
? [TV_PREMIUM_SURCHARGE_CODE, TV_ALC_OVERAGE_CODE]
: []
orderItems.value = orderItems.value.filter(i =>
!familyCodes.has(i.item_code) && !extraCodesToClear.includes(i.item_code),
)
// Share the fiber_install template across Internet tiers — only unload it
// when no remaining order item references it.
const sharedTpl = tier.items.find(i => i.project_template_id)?.project_template_id
if (sharedTpl) {
const stillNeeded = orderItems.value.some(i => i.project_template_id === sharedTpl)
if (!stillNeeded && templateLoadedFor.value.has(sharedTpl)) removeTemplate(sharedTpl)
}
if (isToggleOff) {
return
}
const supplementCode = serviceType === 'tv'
? tier.items.find(i => i.item_code && i.item_code.startsWith('TV-MIX'))?.item_code
: null
for (const i of tier.items) {
const isSupplement = supplementCode && i.item_code === supplementCode
orderItems.value.push({
item_code: i.item_code,
item_name: i.item_name,
qty: 1,
rate: i.rate,
regular_price: i.regular_price || 0,
billing: i.billing,
billing_interval: i.billing_interval || 'Month',
contract_months: i.contract_months || 24,
project_template_id: i.project_template_id || '',
service_type: serviceType,
combo_eligible: true,
is_rebate: !!i.is_rebate,
applies_to_item: i.applies_to_item || '',
tv_tier_id: serviceType === 'tv' ? tier.id : undefined,
tv_channels: isSupplement && channelSelection ? channelSelection.map(c => c.name) : undefined,
})
}
// Emit premium surcharges + overage for TV Mix tiers.
if (serviceType === 'tv' && channelSelection && channelSelection.length) {
const seenGroups = new Set()
for (const ch of channelSelection) {
if (!ch.premium_group || seenGroups.has(ch.premium_group)) continue
seenGroups.add(ch.premium_group)
const premium = PREMIUM_BY_GROUP.get(ch.premium_group)
if (!premium) continue
orderItems.value.push({
item_code: TV_PREMIUM_SURCHARGE_CODE,
item_name: `Premium ${premium.name}`,
qty: 1,
rate: Number(premium.surcharge) || 0,
regular_price: 0,
billing: 'recurring',
billing_interval: 'Month',
contract_months: 24,
service_type: 'tv',
combo_eligible: false,
premium_group: premium.id,
applies_to_item: supplementCode || '',
})
}
// Overage: one aggregated à-la-carte line when the selection exceeds picks.
const picksUsed = channelSelection.reduce((n, c) => n + (c.pick_cost || 1), 0)
const picksAllowed = tier.picks_allowed || 0
const overage = Math.max(0, picksUsed - picksAllowed)
if (overage > 0) {
orderItems.value.push({
item_code: TV_ALC_OVERAGE_CODE,
item_name: `Chaînes additionnelles (à la carte)`,
qty: overage,
rate: 0,
regular_price: 0,
billing: 'recurring',
billing_interval: 'Month',
contract_months: 24,
service_type: 'tv',
combo_eligible: false,
applies_to_item: supplementCode || '',
pricing_pending: true,
})
}
}
const tplId = tier.items.find(i => i.project_template_id)?.project_template_id
if (tplId && !templateLoadedFor.value.has(tplId)) {
loadTemplateFromItem({ project_template_id: tplId })
}
}
const selectHeroTier = (tier) => selectHeroTierGeneric({
tier, familyCodes: INTERNET_HERO_CODES, serviceType: 'internet', tiers: INTERNET_HERO_TIERS,
})
const selectTvTier = (tier, channelSelection) => selectHeroTierGeneric({
tier, familyCodes: TV_HERO_CODES, serviceType: 'tv', tiers: TV_HERO_TIERS, channelSelection,
})
const selectedHeroTier = computed(() => {
const code = orderItems.value.find(i => INTERNET_HERO_CODES.has(i.item_code) && !i.is_rebate)?.item_code
if (!code) return null
return INTERNET_HERO_TIERS.find(t => t.code === code) || null
})
// TV current selection — detect by tv_tier_id stamped on the first TV line.
// Falls back to code-matching in case of older persisted data.
const selectedTvTier = computed(() => {
const first = orderItems.value.find(i => TV_HERO_CODES.has(i.item_code))
if (!first) return null
if (first.tv_tier_id) return TV_HERO_TIERS.find(t => t.id === first.tv_tier_id) || null
return TV_HERO_TIERS.find(t => t.code === first.item_code) || null
})
// Apply/unapply a service preset — chip-style toggle.
// Clicking a preset whose items are already in the cart REMOVES those items
// and unloads the associated template. Otherwise adds missing items.
// This fixes the earlier UX where an "added" preset had no click-off path,
// forcing operators to delete lines one by one.
function applyPreset (preset) {
const presetCodes = new Set(preset.items.map(i => i.item_code).filter(Boolean))
const alreadyApplied = orderItems.value.some(i => presetCodes.has(i.item_code))
if (alreadyApplied) {
orderItems.value = orderItems.value.filter(i => !presetCodes.has(i.item_code))
const tplId = preset.items.find(i => i.project_template_id)?.project_template_id
if (tplId && templateLoadedFor.value.has(tplId)) {
removeTemplate(tplId)
}
return
}
const existingCodes = new Set(orderItems.value.map(i => i.item_code).filter(Boolean))
for (const i of preset.items) {
if (i.item_code && existingCodes.has(i.item_code)) continue
orderItems.value.push({
item_code: i.item_code,
item_name: i.item_name,
qty: 1,
rate: i.rate,
regular_price: i.regular_price || 0,
billing: i.billing,
billing_interval: i.billing_interval || 'Month',
contract_months: i.contract_months || 12,
project_template_id: i.project_template_id || '',
service_type: preset.service_type || '',
combo_eligible: !!preset.combo_eligible,
// Forward rebate metadata so the invoice Jinja can net the line into
// its parent, and so per-row overrides persist across save/reload.
is_rebate: !!i.is_rebate,
applies_to_item: i.applies_to_item || '',
})
}
const tplId = preset.items.find(i => i.project_template_id)?.project_template_id
if (tplId && !templateLoadedFor.value.has(tplId)) {
loadTemplateFromItem({ project_template_id: tplId })
}
}
// NOTE: auto-combo rebate was removed. Combo rebates (legacy RAB2X/RAB3X/
// RAB4X, -5/-10/-15$) are now added MANUALLY by the sales rep from the
// catalog — the wizard no longer inserts them behind the scenes.
function stepKey (step) { return step.merge_key || step.subject }
// Merge a template's steps into wizardSteps, dedup'ing by merge_key and
// remapping depends_on_step indexes so dependencies still point at the
// correct (possibly merged) predecessor. Returns the count of steps that
// collapsed into pre-existing ones.
function mergeTemplateSteps (tpl) {
const keyToIdx = new Map()
wizardSteps.value.forEach((s, i) => { keyToIdx.set(stepKey(s), i) })
// First pass: decide for each template step whether it matches an
// existing step or will be appended, so depends_on_step can be remapped
// correctly before we actually mutate wizardSteps.
const finalIdx = []
const toAppend = []
let nextIdx = wizardSteps.value.length
let mergedCount = 0
for (let i = 0; i < tpl.steps.length; i++) {
const step = tpl.steps[i]
const key = stepKey(step)
if (keyToIdx.has(key)) {
const existingIdx = keyToIdx.get(key)
const existing = wizardSteps.value[existingIdx]
const sources = new Set(existing.source_templates || [])
sources.add(tpl.id)
existing.source_templates = Array.from(sources)
finalIdx.push(existingIdx)
mergedCount++
} else {
finalIdx.push(nextIdx)
toAppend.push({ step, at: nextIdx })
keyToIdx.set(key, nextIdx)
nextIdx++
}
}
// Second pass: push new steps with remapped dependencies.
for (const { step } of toAppend) {
const dep = step.depends_on_step != null ? finalIdx[step.depends_on_step] : null
wizardSteps.value.push({
merge_key: stepKey(step),
subject: step.subject,
job_type: step.job_type,
priority: step.priority,
duration_h: step.duration_h,
assigned_group: step.assigned_group || '',
depends_on_step: dep,
scheduled_date: '',
on_open_webhook: step.on_open_webhook || '',
on_close_webhook: step.on_close_webhook || '',
source_templates: [tpl.id],
})
}
return mergedCount
}
// Remove all steps that originated from `templateId`. Steps shared with
// another active bundle keep their other sources; only orphans are deleted.
// depends_on_step indexes are remapped to survive the compaction.
function removeTemplate (templateId) {
if (!templateLoadedFor.value.has(templateId)) return
const oldToNew = new Map()
const filtered = []
wizardSteps.value.forEach((step, oldIdx) => {
const sources = (step.source_templates || []).filter(id => id !== templateId)
if (sources.length === 0) return
oldToNew.set(oldIdx, filtered.length)
filtered.push({ ...step, source_templates: sources })
})
for (const step of filtered) {
if (step.depends_on_step != null) {
const newDep = oldToNew.get(step.depends_on_step)
step.depends_on_step = newDep != null ? newDep : null
}
}
wizardSteps.value = filtered
const next = new Set(templateLoadedFor.value)
next.delete(templateId)
templateLoadedFor.value = next
}
function toggleTemplate (templateId, enable) {
if (enable) {
if (!templateLoadedFor.value.has(templateId)) {
loadTemplateFromItem({ project_template_id: templateId })
}
} else {
removeTemplate(templateId)
}
}
// Per-step uncheck inside a bundle. Detaches the bundle from the step's
// sources; if no other bundle claims it, the step is deleted outright.
// Remaps depends_on_step so survivors still line up.
function removeStepFromBundle (templateId, mergeKey) {
const oldToNew = new Map()
const filtered = []
wizardSteps.value.forEach((step, oldIdx) => {
const key = step.merge_key || step.subject
if (key === mergeKey) {
const sources = (step.source_templates || []).filter(id => id !== templateId)
if (sources.length === 0) return
oldToNew.set(oldIdx, filtered.length)
filtered.push({ ...step, source_templates: sources })
} else {
oldToNew.set(oldIdx, filtered.length)
filtered.push(step)
}
})
for (const step of filtered) {
if (step.depends_on_step != null) {
const newDep = oldToNew.get(step.depends_on_step)
step.depends_on_step = newDep != null ? newDep : null
}
}
wizardSteps.value = filtered
}
// Ad-hoc step injection — tagged with the owning bundle so it follows the
// bundle toggle lifecycle (off → removed, on → stays if it wasn't re-added
// elsewhere). Uses a synthesised merge_key so it can't collide with a
// catalog template step.
function addStepToBundle (templateId, stepData) {
wizardSteps.value.push({
merge_key: stepData.merge_key || `custom_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
subject: stepData.subject,
job_type: stepData.job_type || 'Autre',
priority: stepData.priority || 'medium',
duration_h: Number(stepData.duration_h) || 0.5,
assigned_group: stepData.assigned_group || '',
depends_on_step: null,
scheduled_date: '',
on_open_webhook: '',
on_close_webhook: '',
source_templates: [templateId],
})
}
// Grouping used by the "Étapes chargées" summary card on step 2.
// A step with more than one source template is flagged `shared` so the UI
// can badge it (removing bundle A won't drop it if bundle B still claims it).
const loadedBundles = computed(() => {
const bundles = []
for (const tplId of templateLoadedFor.value) {
const tpl = templates.find(t => t.id === tplId)
if (!tpl) continue
const steps = wizardSteps.value
.map((s, idx) => ({ s, idx }))
.filter(({ s }) => (s.source_templates || []).includes(tplId))
.map(({ s, idx }) => ({
idx,
subject: s.subject,
merge_key: s.merge_key,
shared: (s.source_templates || []).length > 1,
}))
bundles.push({
id: tpl.id,
name: tpl.name,
icon: tpl.icon,
steps,
})
}
return bundles
})
function loadTemplateFromItem (item) {
if (!item.project_template_id) return
const tpl = templates.find(t => t.id === item.project_template_id)
if (!tpl || templateLoadedFor.value.has(item.project_template_id)) return
const merged = mergeTemplateSteps(tpl)
templateLoadedFor.value = new Set([...templateLoadedFor.value, item.project_template_id])
if (merged > 0) {
lastMergeCount.value = merged
mergedTemplateLabels.value = [...mergedTemplateLabels.value, tpl.name]
}
}
return {
catalogProducts,
catalogLoading,
catalogFilter,
catalogCategories: CATALOG_CATEGORIES,
residentialPresets: RESIDENTIAL_PRESETS,
internetHeroTiers: INTERNET_HERO_TIERS,
tvHeroTiers: TV_HERO_TIERS,
selectedHeroTier,
selectHeroTier,
selectedTvTier,
selectTvTier,
filteredCatalog,
loadCatalog,
addCatalogItem,
applyPreset,
loadTemplateFromItem,
removeTemplate,
toggleTemplate,
removeStepFromBundle,
addStepToBundle,
loadedBundles,
lastMergeCount,
mergedTemplateLabels,
}
}