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, } }