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>
462 lines
19 KiB
JavaScript
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,
|
|
}
|
|
}
|