gigafibre-fsm/apps/ops/src/components/shared/ProjectWizard.vue
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

2830 lines
130 KiB
Vue

<template>
<q-dialog :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" persistent>
<q-card style="width:650px;max-width:95vw" class="column no-wrap">
<q-card-section class="row items-center q-pb-sm" style="border-bottom:1px solid var(--ops-border, #e2e8f0)">
<div class="col">
<div class="text-subtitle1 text-weight-bold">
<q-icon name="account_tree" class="q-mr-xs" />
{{ issue?.name ? 'Projet' : 'Nouvelle soumission' }} &mdash; {{ STEP_LABELS[currentStep] }}
</div>
<div class="text-caption text-grey-6">
<template v-if="issue?.name">{{ issue.name }} &middot; {{ issue.subject }}</template>
<template v-else-if="customer?.name">{{ customer.customer_name || customer.name }}</template>
</div>
</div>
<q-btn flat round dense icon="close" @click="cancel" />
</q-card-section>
<div class="row q-px-md q-pt-sm" style="gap:4px">
<div v-for="(label, i) in STEP_LABELS" :key="i"
class="wizard-step-dot" :class="{ active: i === currentStep, done: i < currentStep, skipped: isQuickSale && i === 1 }"
@click="handleStepDotClick(i)">
<div class="wizard-step-num">{{ isQuickSale && i === 1 ? '&#x2013;' : (i < currentStep ? '&#x2713;' : i + 1) }}</div>
<div class="wizard-step-label">{{ label }}</div>
</div>
</div>
<q-card-section v-if="currentStep === 0" class="col q-pt-md" style="overflow-y:auto;min-height:300px">
<div class="text-caption text-grey-6 q-mb-sm">Que voulez-vous vendre&nbsp;?</div>
<div class="quick-sale-card" :class="{ selected: isQuickSale }" @click="selectQuickSale">
<div class="quick-sale-icon"><q-icon name="sell" size="26px" color="white" /></div>
<div class="col" style="min-width:0">
<div class="quick-sale-title">Vente rapide &mdash; sans installation</div>
<div class="quick-sale-desc">Ajout d'équipement (ex&nbsp;: routeur, amplificateur WiFi) ou d'un service mensuel sur une adresse existante. Aucune étape d'installation.</div>
</div>
<q-icon name="arrow_forward" size="20px" color="indigo-6" />
</div>
<div class="row items-center q-mt-md q-mb-xs">
<div class="text-caption text-grey-7 text-weight-medium">Ou choisir un modèle d'installation&nbsp;:</div>
<q-space />
<FlowQuickButton flat dense size="sm" icon="tune" label="Gérer les modèles"
category="residential" applies-to="Service Contract"
tooltip="Ouvrir l'éditeur de modèles (créer/modifier)" />
</div>
<div class="template-grid">
<div v-for="tpl in templates" :key="tpl.id" class="template-card"
:class="{ selected: selectedTemplate?.id === tpl.id }" @click="selectTemplate(tpl)">
<q-icon :name="tpl.icon" size="28px" :color="selectedTemplate?.id === tpl.id ? 'indigo-6' : 'grey-6'" />
<div class="template-card-name">{{ tpl.name }}</div>
<div class="template-card-desc">{{ tpl.description }}</div>
<q-badge :label="tpl.steps.length + ' étapes'" color="grey-3" text-color="grey-8" class="q-mt-xs" />
</div>
<div class="template-card" :class="{ selected: selectedTemplate === null && customSteps.length }"
@click="selectCustom">
<q-icon name="add_circle_outline" size="28px" color="grey-6" />
<div class="template-card-name">Projet sur mesure</div>
<div class="template-card-desc">Créer les étapes manuellement</div>
</div>
</div>
</q-card-section>
<q-card-section v-if="currentStep === 1" class="col q-pt-md" style="overflow-y:auto;min-height:300px;max-height:60vh">
<div v-if="isQuickSale" class="quick-sale-notice">
<q-icon name="info" size="18px" color="indigo-6" class="q-mr-sm" />
<div class="col">
<div class="text-weight-bold text-indigo-8" style="font-size:0.85rem">Mode Vente rapide &mdash; aucune étape requise</div>
<div class="text-caption text-grey-7">Appuyez sur «&nbsp;Suivant&nbsp;» pour aller directement au catalogue.</div>
</div>
</div>
<div v-else-if="lastMergeCount > 0" class="merge-notice">
<q-icon name="auto_awesome" size="18px" color="green-7" class="q-mr-sm" />
<div class="col">
<div class="text-weight-bold text-green-8" style="font-size:0.85rem">
{{ lastMergeCount }} étape{{ lastMergeCount > 1 ? 's' : '' }} fusionnée{{ lastMergeCount > 1 ? 's' : '' }} &mdash; un seul déplacement
</div>
<div class="text-caption text-grey-7">Les services sélectionnés partagent la même visite d'installation.</div>
</div>
</div>
<div v-else class="text-caption text-grey-6 q-mb-sm">Modifiez les étapes, dates et assignations</div>
<div class="steps-list">
<div v-for="(step, i) in wizardSteps" :key="i" class="step-card">
<div class="row items-center q-mb-xs">
<div class="step-num">{{ i + 1 }}</div>
<q-input v-model="step.subject" dense outlined class="col q-ml-sm" placeholder="Sujet de l'étape"
:input-style="{ fontSize: '0.85rem', fontWeight: '600' }" />
<q-btn v-if="wizardSteps.length > 1" flat round dense icon="delete" size="sm" color="red-5" class="q-ml-xs"
@click="removeStep(i)" />
</div>
<div class="row q-col-gutter-sm q-mb-xs">
<div class="col-4">
<q-select v-model="step.job_type" dense outlined emit-value map-options label="Type"
:options="JOB_TYPE_OPTIONS" :input-style="{ fontSize: '0.8rem' }" />
</div>
<div class="col-4">
<q-select v-model="step.priority" dense outlined emit-value map-options label="Priorité"
:options="PRIORITY_OPTIONS" :input-style="{ fontSize: '0.8rem' }" />
</div>
<div class="col-4">
<q-input v-model.number="step.duration_h" dense outlined type="number" label="Durée (h)"
:input-style="{ fontSize: '0.8rem' }" />
</div>
</div>
<div class="row q-col-gutter-sm q-mb-xs">
<div class="col-4">
<q-select v-model="step.assigned_group" dense outlined emit-value map-options label="Groupe"
:options="groupOptions" clearable :input-style="{ fontSize: '0.8rem' }" />
</div>
<div class="col-4">
<q-input v-model="step.scheduled_date" dense outlined type="date" label="Date"
:input-style="{ fontSize: '0.8rem' }" />
</div>
<div class="col-4">
<q-select v-model="step.depends_on_step" dense outlined emit-value map-options label="Après"
:options="dependencyOptionsFor(i)" clearable :input-style="{ fontSize: '0.8rem' }" />
</div>
</div>
<q-expansion-item dense dense-toggle label="Webhooks n8n" icon="webhook"
header-class="text-caption text-grey-6" style="margin:-4px -4px 0">
<div class="row q-col-gutter-sm q-pa-sm">
<div class="col-6">
<q-input v-model="step.on_open_webhook" dense outlined label="À l'ouverture"
placeholder="https://n8n.../webhook/..." :input-style="{ fontSize: '0.75rem' }" />
</div>
<div class="col-6">
<q-input v-model="step.on_close_webhook" dense outlined label="À la fermeture"
placeholder="https://n8n.../webhook/..." :input-style="{ fontSize: '0.75rem' }" />
</div>
</div>
</q-expansion-item>
<div v-if="step.depends_on_step != null" class="step-dep-indicator">
<q-icon name="subdirectory_arrow_right" size="14px" color="orange-7" />
<span class="text-caption text-orange-8">Après: étape {{ step.depends_on_step + 1 }}</span>
</div>
</div>
</div>
<q-btn flat dense icon="add" label="Ajouter une étape" color="indigo-6" no-caps class="q-mt-sm"
@click="addStep" />
</q-card-section>
<q-card-section v-if="currentStep === 2" class="col q-pt-md cart-scroll-host" style="overflow-y:auto;min-height:300px;max-height:60vh">
<div class="row q-mb-sm q-gutter-sm items-center">
<q-btn-toggle v-model="orderMode" no-caps dense unelevated toggle-color="indigo-6" color="grey-3" text-color="grey-8"
:options="ORDER_MODE_OPTIONS" />
<q-space />
<q-btn unelevated icon="inventory_2" label="Catalogue" color="indigo-6" no-caps size="sm" class="catalog-open-btn"
@click="openCatalog()" />
</div>
<!-- Sticky cart pill — combined summary (count + totals + taxes).
Sits at the top of the scrollable area so the running tally is
always visible while the rep picks forfaits below. The full
line-item editor lives on the Sommaire step (click → navigate). -->
<div class="cart-pill cart-pill-sticky" :class="{ 'cart-pill-empty': !orderItems.length }"
@click="goToSommaire">
<div class="cart-pill-left">
<q-icon name="shopping_cart" size="22px"
:color="orderItems.length ? 'indigo-6' : 'grey-5'" />
<q-badge v-if="orderItems.length" floating color="deep-orange-6" text-color="white"
class="cart-pill-badge">{{ orderItems.length }}</q-badge>
</div>
<div class="cart-pill-body">
<div class="cart-pill-title">
<template v-if="orderItems.length">
Panier — {{ orderItems.length }} item{{ orderItems.length > 1 ? 's' : '' }}
</template>
<template v-else>Panier vide</template>
</div>
<div class="cart-pill-sub">
<template v-if="orderItems.length">
<span class="text-indigo-7">Voir le sommaire →</span>
<span class="text-grey-6"> modifier, réordonner, supprimer</span>
</template>
<template v-else>
<span class="text-grey-6">Choisissez un forfait Internet ci-dessous pour commencer</span>
</template>
</div>
</div>
<div v-if="orderItems.length" class="cart-pill-totals">
<div v-if="recurringTotal > 0" class="cart-pill-total-line cart-pill-total-recurring">
{{ recurringTotal.toFixed(2) }}$<span class="cart-pill-mo">/mois</span>
</div>
<div v-if="onetimeTotal > 0" class="cart-pill-total-line">
{{ onetimeTotal.toFixed(2) }}$<span class="cart-pill-mo">unique</span>
</div>
<div class="cart-pill-tax">
+{{ ((onetimeTotal + recurringTotal) * 0.14975).toFixed(2) }}$ tx
</div>
</div>
<q-icon v-if="orderItems.length" name="arrow_forward" size="18px" color="indigo-6" />
</div>
<!-- ──────────────────────────────────────────────────────────────
ÉTAPE 1 — Internet (obligatoire, accordion list)
Same accordion pattern as TV/Phone: clicking the header toggles
the tier list, selecting a tier auto-closes the panel. The
header summary becomes the anchor showing what's in the cart
once a selection lands. ───────────────────────────────────── -->
<section class="wizard-step-block wizard-step-block-required"
:class="{ 'wizard-step-block-expanded': heroExpanded }">
<header class="wizard-step-header wizard-step-header-clickable"
@click="toggleHeroGroup('internet')">
<div class="wizard-step-number">1</div>
<div class="wizard-step-headline">
<div class="wizard-step-title">Internet</div>
<div class="wizard-step-summary">
<template v-if="selectedHeroTier">
<q-icon name="check_circle" size="14px" color="green-6" class="q-mr-xs" />
<strong class="text-indigo-8">{{ selectedHeroTier.label }}</strong>
<span class="text-grey-7"> · {{ selectedHeroTier.price_effective.toFixed(2) }}$/mois</span>
<span v-if="internetContractMonths" class="text-grey-6"> · contrat {{ internetContractMonths }} mois</span>
<span v-if="selectedHeroTier.speed" class="text-grey-6"> · {{ selectedHeroTier.speed }}</span>
</template>
<template v-else>
<q-icon name="flash_on" size="14px" color="amber-8" class="q-mr-xs" />
<span class="text-amber-8">Obligatoire — à partir de 39.95$/mois · cliquez pour choisir un forfait</span>
</template>
</div>
</div>
<div class="wizard-step-action">
<q-icon name="expand_more" size="20px" color="grey-6"
class="preset-hero-chevron" :class="{ 'rotated': heroExpanded }" />
<q-icon :name="selectedHeroTier ? 'check_circle' : 'radio_button_unchecked'"
size="22px"
:color="selectedHeroTier ? 'green-6' : 'amber-5'" />
</div>
</header>
<!-- Internet tiers as a vertical list inside the expand panel.
Selection closes the accordion (see onInternetTierClick). -->
<div v-show="heroExpanded" class="tier-list">
<div v-for="t in internetHeroTiers" :key="t.id"
class="tier-row"
:class="{ 'tier-row-active': selectedHeroTier && selectedHeroTier.code === t.code }"
@click.stop="onInternetTierClick(t)">
<div class="tier-row-icon">
<q-icon :name="t.icon" size="24px"
:color="selectedHeroTier && selectedHeroTier.code === t.code ? 'green-6' : 'indigo-6'" />
</div>
<div class="tier-row-body">
<div class="tier-row-head">
<span class="tier-row-title">{{ t.label }}</span>
<q-badge v-if="t.badge" :color="t.id === 'tier_g1500' ? 'deep-orange-5' : 'blue-grey-5'"
text-color="white" class="tier-row-badge">
{{ t.badge }}
</q-badge>
<span class="tier-row-speed">· {{ t.speed }}</span>
</div>
<div class="tier-row-desc">{{ t.desc }}</div>
</div>
<div class="tier-row-price">
<div class="tier-row-price-big">
{{ t.price_effective.toFixed(2) }}<span class="tier-row-price-unit">$/mois</span>
</div>
<div v-if="t.price_base > t.price_effective" class="tier-row-price-strike">
{{ t.price_base.toFixed(2) }}$
</div>
</div>
<div class="tier-row-select">
<q-icon :name="selectedHeroTier && selectedHeroTier.code === t.code ? 'check_circle' : 'radio_button_unchecked'"
size="22px"
:color="selectedHeroTier && selectedHeroTier.code === t.code ? 'green-6' : 'grey-4'" />
</div>
</div>
</div>
</section>
<!-- ──────────────────────────────────────────────────────────────
ÉTAPE 2 — Services additionnels (optionnels)
Each sub-group is a click-to-expand accordion. The summary line
reflects what's currently in the cart, so the rep sees a
one-line recap above the detail cards. ──────────────────────── -->
<section class="wizard-step-block q-mt-md">
<header class="wizard-step-header">
<div class="wizard-step-number wizard-step-number-optional">2</div>
<div class="wizard-step-headline">
<div class="wizard-step-title">Services additionnels</div>
<div v-if="!step2Summary.length" class="wizard-step-summary">
<span class="text-grey-6">Optionnels — Télé dès 25$/mois · Téléphonie +10$/mois · WiFi booster, etc. · rabais combo auto dès 2 services</span>
</div>
</div>
</header>
<!-- TV hero: anchor tile + click-expand panel. Gated behind Internet
(can't provide TV without a same-address Internet subscription). -->
<div class="preset-hero-group q-mt-sm"
:class="{
'preset-hero-group-expanded': tvExpanded,
'preset-hero-group-disabled': !selectedHeroTier,
}">
<div class="preset-hero preset-hero-tv"
:class="{ 'preset-hero-added': !!selectedTvTier }"
@click="toggleHeroGroup('tv')">
<div class="preset-hero-icon">
<q-icon :name="tvAnchor.icon" size="28px"
:color="selectedTvTier ? 'green-6' : 'pink-6'" />
</div>
<div class="preset-hero-text">
<div class="preset-hero-label">
Télé <span class="preset-hero-label-tier preset-hero-label-tv">{{ tvAnchor.label }}</span>
<q-badge v-if="tvAnchor.badge" color="pink-5" class="q-ml-xs" text-color="white">
{{ tvAnchor.badge }}
</q-badge>
</div>
<div class="preset-hero-desc">{{ tvAnchor.desc }}</div>
</div>
<div class="preset-hero-price">
<div class="preset-hero-price-tag">à partir de</div>
<div class="preset-hero-price-big preset-hero-price-tv">
{{ (selectedTvTier || tvHeroTiers[0]).price_effective.toFixed(2) }}<span class="preset-hero-price-unit">$/mois</span>
</div>
</div>
<div class="preset-hero-action">
<q-icon name="expand_more" size="18px" color="grey-6"
class="preset-hero-chevron" :class="{ 'rotated': tvExpanded }" />
<q-icon :name="selectedTvTier ? 'check_circle' : 'add_circle'"
size="22px" :color="selectedTvTier ? 'green-6' : 'pink-4'" />
</div>
</div>
<div v-show="tvExpanded" class="tier-expand tier-expand-tv">
<div v-for="t in tvHeroTiers" :key="t.id" class="tier-card"
:class="{
'tier-card-active': selectedTvTier && selectedTvTier.id === t.id,
'tier-card-preview': !t.ready,
}"
@click.stop="onTvTierClick(t)">
<div class="tier-card-head">
<q-icon :name="t.icon" size="20px"
:color="selectedTvTier && selectedTvTier.id === t.id ? 'green-6' : 'pink-6'" />
<div class="tier-card-title">{{ t.label }}</div>
<q-badge v-if="t.badge" color="pink-5" text-color="white" class="tier-card-badge">
{{ t.badge }}
</q-badge>
<q-badge v-else-if="!t.ready" color="grey-5" text-color="white" class="tier-card-badge">
À venir
</q-badge>
</div>
<div class="tier-card-desc">{{ t.desc }}</div>
<div v-if="t.picks_allowed" class="tier-card-speed">
<q-icon name="check_circle_outline" size="13px" color="pink-5" class="q-mr-xs" />
{{ t.picks_allowed }} chaînes au choix
</div>
<div class="tier-card-price">
<template v-if="t.price_effective">
<span class="tier-card-price-big">{{ t.price_effective.toFixed(2) }}$</span>
<span class="tier-card-price-unit">/mois</span>
</template>
<span v-else class="tier-card-price-unit">selon les chaînes choisies</span>
</div>
<q-icon v-if="selectedTvTier && selectedTvTier.id === t.id"
name="check_circle" size="18px" color="green-6" class="tier-card-check" />
</div>
</div>
</div>
<!-- Téléphonie hero group: click-expand with two options.
Nouveau numéro = add the preset directly. Conservation = add
the preset + inject a port-in "collecter preuve" tech step. -->
<div class="preset-hero-group q-mt-sm"
:class="{ 'preset-hero-group-expanded': phoneExpanded }">
<div class="preset-hero preset-hero-phone"
:class="{ 'preset-hero-added': phoneApplied }"
@click="toggleHeroGroup('phone')">
<div class="preset-hero-icon">
<q-icon name="phone" size="28px"
:color="phoneApplied ? 'green-6' : 'teal-6'" />
</div>
<div class="preset-hero-text">
<div class="preset-hero-label">
Téléphonie
<span v-if="phoneApplied" class="preset-hero-label-tier preset-hero-label-phone">
· {{ phoneMode === 'keep' ? 'Conservation' : 'Nouveau numéro' }}
<template v-if="phoneMode === 'keep' && phoneKeepNumber">
· {{ phoneKeepNumber }}
</template>
</span>
</div>
<div class="preset-hero-desc">
<template v-if="phoneApplied && phoneMode === 'keep' && !phoneKeepNumber">
<q-icon name="warning" size="12px" color="amber-8" class="q-mr-xs" />
<span class="text-amber-8">Cliquez pour saisir le numéro à conserver</span>
</template>
<template v-else>Illimitée CA/US · bonification combo</template>
</div>
</div>
<div class="preset-hero-price">
<div class="preset-hero-price-tag">à partir de</div>
<div class="preset-hero-price-big preset-hero-price-phone">
+10.00<span class="preset-hero-price-unit">$/mois</span>
</div>
</div>
<div class="preset-hero-action">
<q-icon name="expand_more" size="18px" color="grey-6"
class="preset-hero-chevron" :class="{ 'rotated': phoneExpanded }" />
<q-icon :name="phoneApplied ? 'check_circle' : 'add_circle'"
size="22px" :color="phoneApplied ? 'green-6' : 'teal-4'" />
</div>
</div>
<div v-show="phoneExpanded" class="tier-expand tier-expand-phone">
<div class="tier-card"
:class="{ 'tier-card-active': phoneApplied && phoneMode === 'new' }"
@click.stop="applyPhonePreset('new')">
<div class="tier-card-head">
<q-icon name="dialpad" size="20px"
:color="phoneApplied && phoneMode === 'new' ? 'green-6' : 'teal-6'" />
<div class="tier-card-title">Nouveau numéro</div>
</div>
<div class="tier-card-desc">Attribution automatique à l'installation — indicatif local.</div>
<div class="tier-card-price">
<span class="tier-card-price-big">+10.00$</span>
<span class="tier-card-price-unit">/mois</span>
</div>
<q-icon v-if="phoneApplied && phoneMode === 'new'"
name="check_circle" size="18px" color="green-6" class="tier-card-check" />
</div>
<div class="tier-card"
:class="{ 'tier-card-active': phoneApplied && phoneMode === 'keep' }"
@click.stop="applyPhonePreset('keep')">
<div class="tier-card-head">
<q-icon name="swap_calls" size="20px"
:color="phoneApplied && phoneMode === 'keep' ? 'green-6' : 'teal-6'" />
<div class="tier-card-title">Conservation (port-in)</div>
<q-badge color="amber-7" text-color="white" class="tier-card-badge">
Preuve requise
</q-badge>
</div>
<div class="tier-card-desc">
Le client garde son numéro actuel. Le tech doit photographier la facture du fournisseur actuel comme preuve de propriété.
</div>
<div v-if="phoneApplied && phoneMode === 'keep'" class="tier-card-keep-input" @click.stop>
<q-input v-model="phoneKeepNumber" dense outlined
placeholder="Numéro à conserver (ex. 514-555-1234)"
:input-style="{ fontSize: '0.82rem' }"
@click.stop />
</div>
<div class="tier-card-price">
<span class="tier-card-price-big">+10.00$</span>
<span class="tier-card-price-unit">/mois</span>
</div>
<q-icon v-if="phoneApplied && phoneMode === 'keep'"
name="check_circle" size="18px" color="green-6" class="tier-card-check" />
</div>
</div>
</div>
<!-- Remaining upsells (WiFi booster etc.) -->
<div class="row q-gutter-sm q-mt-sm items-stretch">
<div v-for="p in otherUpsells" :key="p.id" class="preset-upsell"
:class="[`tier-${p.tier}`, { 'preset-added': usedServiceTypes.has(p.service_type) }]"
@click="applyPreset(p)">
<q-icon :name="p.icon" size="18px"
:color="usedServiceTypes.has(p.service_type) ? 'green-6' : 'indigo-6'" />
<div class="preset-upsell-body">
<div class="preset-upsell-delta">
+{{ p.price_delta.toFixed(2) }}<span class="preset-upsell-mo">$/mois</span>
</div>
<div class="preset-upsell-label">{{ p.label }}</div>
</div>
<q-icon :name="usedServiceTypes.has(p.service_type) ? 'check_circle' : 'add'"
size="16px" :color="usedServiceTypes.has(p.service_type) ? 'green-6' : 'grey-5'" />
</div>
</div>
</section>
<!-- Referral code — own block, not nested inside the two step blocks -->
<div class="referral-row q-mt-md">
<q-icon name="redeem" size="18px" color="deep-purple-5" class="q-mr-sm" />
<div class="col" style="min-width:0">
<div class="referral-title">Code de référence</div>
<div class="referral-desc">50$ de crédit pour le nouvel abonné · 50$ pour le parrain à l'installation</div>
</div>
<q-input v-model="referralCode" dense outlined placeholder="Ex. GIGA-ABC123"
:readonly="referralApplied" style="max-width:160px"
:input-style="{ fontSize: '0.82rem' }"
@keyup.enter="applyReferralCode" />
<q-btn v-if="!referralApplied" unelevated dense no-caps color="deep-purple-5"
text-color="white" size="sm" icon-right="check"
:loading="referralChecking" label="Appliquer"
:disable="!referralCode.trim()" @click="applyReferralCode" />
<q-btn v-else flat dense no-caps color="deep-purple-7" size="sm"
icon="check_circle" label="-50$ appliqué" @click="removeReferralCode"
title="Retirer le crédit" />
</div>
<div v-if="referralError" class="referral-error">
<q-icon name="error" size="13px" /> {{ referralError }}
</div>
<!-- "Autre item" + "Item manuel" shortcuts live at the bottom —
the sticky cart pill at the top handles navigation to Sommaire
which is where cart details and totals are edited/reviewed. -->
<div class="row q-gutter-sm q-mt-md q-mb-sm">
<q-btn flat dense icon="add" label="Autre item du catalogue" color="indigo-6" no-caps size="sm"
@click="openCatalog()" />
<q-btn flat dense icon="edit" label="Item manuel" color="grey-7" no-caps size="sm"
@click="addOrderItem" />
</div>
<q-expansion-item dense dense-toggle label="Conditions & contrat" icon="gavel"
header-class="text-caption text-grey-6 q-mt-sm" default-opened>
<div class="q-pa-sm">
<q-input v-model="contractNotes" dense outlined type="textarea" placeholder="Conditions spéciales, annexe au contrat maître..."
:input-style="{ fontSize: '0.8rem', minHeight: '48px' }" />
<q-checkbox v-model="requireAcceptance" dense label="Requiert acceptation client avant exécution"
class="q-mt-xs" style="font-size:0.78rem" />
<div v-if="requireAcceptance" class="q-mt-sm q-gutter-sm">
<q-btn-toggle v-model="acceptanceMethod" no-caps dense unelevated size="sm"
toggle-color="indigo-6" color="grey-3" text-color="grey-8" :options="ACCEPTANCE_METHOD_OPTIONS" />
<div class="row q-gutter-sm q-mt-xs">
<q-input v-model="clientPhone" dense outlined placeholder="+15145551234" label="Téléphone"
class="col" :input-style="{ fontSize: '0.8rem' }" />
<q-input v-model="clientEmail" dense outlined placeholder="client@example.com" label="Courriel"
class="col" :input-style="{ fontSize: '0.8rem' }" />
</div>
<div class="text-caption text-grey-6 q-mt-xs">
{{ acceptanceMethod === 'docuseal'
? 'Le client recevra un lien DocuSeal pour signer électroniquement le contrat + conditions.'
: 'Le client recevra un lien pour voir le devis et cliquer "J\'accepte" avec horodatage + IP.' }}
</div>
</div>
</div>
</q-expansion-item>
</q-card-section>
<!-- ──────────────────────────────────────────────────────────────
Step 3 — Sommaire (cart detail + inline editor + drag-drop)
Each row collapses by default to a compact summary; clicking the
row expands an inline editor (name, qty, rate, billing, contract
months, regular price, bundle steps). Drag the left handle to
reorder — item order drives the quote's line order downstream. ─── -->
<q-card-section v-if="currentStep === 3" class="col q-pt-md" style="overflow-y:auto;min-height:300px;max-height:60vh">
<div class="row items-center q-mb-sm">
<q-icon name="shopping_cart" size="18px" color="indigo-6" class="q-mr-xs" />
<div class="text-weight-bold text-indigo-8" style="font-size:0.9rem">
Sommaire &mdash; {{ orderItems.length }} item{{ orderItems.length > 1 ? 's' : '' }}
</div>
<q-space />
<q-btn flat dense no-caps size="sm" icon="arrow_back" label="Retour au catalogue"
color="grey-7" @click="currentStep = 2" />
</div>
<div v-if="!orderItems.length" class="sommaire-empty">
<q-icon name="shopping_cart_checkout" size="40px" color="grey-4" />
<div class="text-grey-6 q-mt-sm" style="font-size:0.85rem">Aucun item dans le panier</div>
<q-btn unelevated color="indigo-6" no-caps icon="add_shopping_cart" size="sm"
label="Retour au catalogue" class="q-mt-sm" @click="currentStep = 2" />
</div>
<div v-else class="sommaire-list">
<div v-for="(item, i) in orderItems" :key="item._uid || i"
class="sommaire-row"
:class="{
'sommaire-row-expanded': expandedSommaireIdx === i,
'sommaire-row-dragging': dragIndex === i,
'sommaire-row-drop-target': dragOverIndex === i && dragIndex !== i,
}"
draggable="true"
@dragstart="onSommaireDragStart(i, $event)"
@dragend="onSommaireDragEnd"
@dragover.prevent="onSommaireDragOver(i, $event)"
@dragleave="onSommaireDragLeave(i)"
@drop.prevent="onSommaireDrop(i)">
<!-- Compact header (always visible) -->
<div class="sommaire-head" @click="toggleSommaireExpand(i)">
<div class="sommaire-handle" title="Glisser pour réordonner"
@click.stop>
<q-icon name="drag_indicator" size="18px" color="grey-5" />
</div>
<div class="sommaire-badge">{{ i + 1 }}</div>
<q-icon :name="item.billing === 'recurring' ? 'autorenew' : 'shopping_cart'" size="16px"
:color="item.billing === 'recurring' ? 'orange-7' : 'indigo-6'" class="sommaire-type-icon" />
<div class="sommaire-main">
<div class="sommaire-name">{{ item.item_name || 'Item sans nom' }}</div>
<div class="sommaire-meta">
<span>Qté {{ item.qty }}</span>
<span class="sommaire-sep">·</span>
<span class="text-weight-bold">{{ (item.qty * item.rate).toFixed(2) }}$</span>
<span v-if="item.billing === 'recurring'" class="text-orange-7">/mois</span>
<template v-if="item.billing === 'recurring' && item.contract_months">
<span class="sommaire-sep">·</span>
<span class="text-grey-6">{{ item.contract_months }} mois</span>
</template>
<template v-if="item.billing === 'onetime' && item.regular_price > item.rate">
<span class="sommaire-sep">·</span>
<q-chip dense square color="orange-1" text-color="orange-9"
icon="celebration" size="sm" style="margin:0">
Promo {{ ((item.regular_price - item.rate) * item.qty).toFixed(2) }}$
</q-chip>
</template>
</div>
</div>
<q-btn flat round dense size="sm" icon="delete" color="red-5"
@click.stop="deleteSommaireItem(i)" title="Supprimer" />
<q-icon :name="expandedSommaireIdx === i ? 'expand_less' : 'expand_more'"
size="18px" color="grey-6" />
</div>
<!-- Expanded editor -->
<div v-if="expandedSommaireIdx === i" class="sommaire-body" @click.stop>
<div class="row q-col-gutter-sm items-center">
<div class="col-12">
<q-input v-model="item.item_name" dense outlined placeholder="Produit / Service"
:input-style="{ fontSize: '0.82rem' }"
:readonly="!!item.item_code && !item.manual_edit"
:class="{ 'catalog-bound': !!item.item_code && !item.manual_edit }"
@click="onItemFieldClick(i, $event)">
<template v-slot:prepend>
<q-icon :name="item.billing === 'recurring' ? 'autorenew' : 'shopping_cart'" size="16px"
:color="item.billing === 'recurring' ? 'orange-7' : 'indigo-6'" />
</template>
<template v-slot:append>
<q-btn v-if="item.item_code" flat dense round size="sm" icon="swap_horiz" color="indigo-6"
@click.stop="openCatalogForLine(i)" title="Changer du catalogue" />
<q-btn v-else flat dense round size="sm" icon="inventory_2" color="indigo-6"
@click.stop="openCatalogForLine(i)" title="Choisir du catalogue" />
<q-btn flat dense round size="xs" icon="edit" color="grey-6"
@click.stop="enableManualEdit(i)"
:title="item.manual_edit ? 'Édition manuelle active' : 'Modifier le nom manuellement'" />
</template>
</q-input>
</div>
</div>
<div class="row q-col-gutter-sm q-mt-xs items-center">
<div class="col-3">
<q-input v-model.number="item.qty" dense outlined type="number" min="1"
label="Qté" :input-style="{ fontSize: '0.82rem' }" />
</div>
<div class="col-5">
<q-input v-model.number="item.rate" dense outlined type="number" step="0.01"
label="Prix $" :input-style="{ fontSize: '0.82rem' }">
<template v-slot:append>
<button type="button" class="billing-pill" :class="billingPillClass(item)"
@click.stop="cycleBilling(item)"
:title="'Cliquer pour changer — prochain: ' + nextBillingLabel(item)">
{{ billingPillLabel(item) }}
<q-icon name="swap_horiz" size="11px" class="q-ml-xs" />
</button>
</template>
</q-input>
</div>
<div class="col-4">
<q-input v-if="item.billing === 'recurring'" v-model.number="item.contract_months"
dense outlined type="number" label="Mois" title="Durée du contrat en mois"
:input-style="{ fontSize: '0.78rem', textAlign: 'center' }" />
<q-input v-else v-model.number="item.regular_price" dense outlined type="number" step="0.01"
label="Prix régulier (si promo)" placeholder="0 = sans promo"
:input-style="{ fontSize: '0.75rem' }">
<template v-slot:append>
<q-icon name="local_offer" size="14px" color="orange-7" />
</template>
</q-input>
</div>
</div>
<!-- Bundle steps (installation pill) kept here so editing a
service still lets the rep tweak which install steps come
with it. -->
<div v-if="item.project_template_id" class="item-steps-wrap q-mt-xs">
<template v-if="!templateLoadedFor.has(item.project_template_id)">
<q-btn flat dense size="sm" icon="auto_fix_high" no-caps color="indigo-6"
label="Charger les étapes d'installation" @click="loadTemplateFromItem(item)" />
</template>
<template v-else>
<button type="button" class="item-steps-pill"
:class="{ expanded: expandedBundles.has(item.project_template_id) }"
@click="toggleBundleExpanded(item.project_template_id)">
<q-icon name="account_tree" size="12px" class="q-mr-xs" />
<span>{{ bundleStepCount(item.project_template_id) }} étape{{ bundleStepCount(item.project_template_id) > 1 ? 's' : '' }} d'installation</span>
<q-icon :name="expandedBundles.has(item.project_template_id) ? 'expand_less' : 'expand_more'"
size="14px" class="q-ml-xs" />
</button>
</template>
</div>
</div>
</div>
</div>
<!-- Running totals — mirrors Step 2 so rep can sanity-check before
publishing. Kept compact and right-aligned. -->
<div v-if="orderItems.length" class="totals-box q-mt-md">
<div class="row justify-between" style="font-size:0.82rem">
<span class="text-grey-7">Sous-total unique</span>
<span class="text-weight-bold">{{ onetimeTotal.toFixed(2) }} $</span>
</div>
<div v-if="stepsExtraTotal > 0" class="row justify-between q-pl-md" style="font-size:0.76rem">
<span class="text-grey-6"><q-icon name="add_circle_outline" size="12px" color="orange-6" /> dont extras sur étapes</span>
<span class="text-orange-7">{{ stepsExtraTotal.toFixed(2) }} $</span>
</div>
<div class="row justify-between" style="font-size:0.82rem">
<span class="text-grey-7">Récurrent mensuel</span>
<span class="text-weight-bold text-orange-8">{{ recurringTotal.toFixed(2) }} $/mois</span>
</div>
<div v-if="promoTotal > 0" class="row justify-between" style="font-size:0.82rem">
<span class="text-orange-8"><q-icon name="celebration" size="14px" /> Valeur des promotions étalées</span>
<span class="text-weight-bold text-orange-8">{{ promoTotal.toFixed(2) }} $</span>
</div>
<q-separator class="q-my-xs" />
<div class="row justify-between" style="font-size:0.82rem">
<span class="text-grey-7">Taxes (TPS+TVQ ~14.975%)</span>
<span>{{ ((onetimeTotal + recurringTotal) * 0.14975).toFixed(2) }} $</span>
</div>
</div>
</q-card-section>
<q-card-section v-if="currentStep === 4" class="col q-pt-md" style="overflow-y:auto;min-height:300px;max-height:60vh">
<div class="text-caption text-grey-6 q-mb-md">Vérifiez le projet avant publication</div>
<div v-if="orderItems.length" class="ops-card q-mb-md" style="padding:10px 12px;border-color:#c7d2fe">
<div class="text-weight-bold text-indigo-8" style="font-size:0.82rem;margin-bottom:6px;">
<q-icon name="receipt_long" size="16px" class="q-mr-xs" />
{{ orderMode === 'quotation' ? 'Devis' : orderMode === 'prepaid' ? 'Facture' : 'Bon de commande' }}
&mdash; {{ orderItems.length }} items
</div>
<div v-for="(item, i) in orderItems" :key="i" style="font-size:0.78rem;color:#475569;display:flex;gap:6px;align-items:center">
<q-icon :name="item.billing === 'recurring' ? 'autorenew' : 'shopping_cart'" size="14px"
:color="item.billing === 'recurring' ? 'orange-7' : 'indigo-6'" />
<span class="col">{{ item.qty }}x {{ item.item_name }}</span>
<span v-if="item.billing === 'onetime' && item.regular_price > item.rate"
class="text-caption text-orange-8" style="text-decoration:line-through;opacity:0.7">
{{ (item.qty * item.regular_price).toFixed(2) }}$
</span>
<span class="text-weight-bold">{{ (item.qty * item.rate).toFixed(2) }}${{ item.billing === 'recurring' ? '/mois' : '' }}</span>
</div>
<q-separator class="q-my-xs" />
<div style="font-size:0.78rem" class="row justify-between text-weight-bold">
<span>Total</span>
<span>{{ onetimeTotal.toFixed(2) }}$ + {{ recurringTotal.toFixed(2) }}$/mois</span>
</div>
<div v-if="promoTotal > 0" style="font-size:0.75rem" class="row justify-between text-orange-8 q-mt-xs">
<span><q-icon name="celebration" size="12px" />&nbsp;Valeur promotions étalées sur {{ maxContractMonths }} mois</span>
<span class="text-weight-bold">{{ promoTotal.toFixed(2) }}$</span>
</div>
</div>
<div v-if="willCreateContract" class="ops-card q-mb-md" style="padding:10px 12px;border-color:#fed7aa;background:#fff7ed">
<div class="text-weight-bold text-orange-9" style="font-size:0.82rem;margin-bottom:4px;">
<q-icon name="handshake" size="16px" class="q-mr-xs" />
Contrat de service &mdash; {{ acceptanceMethod === 'docuseal' ? 'Commercial' : 'Résidentiel' }}
</div>
<div style="font-size:0.78rem;color:#7c2d12">
{{ recurringTotal.toFixed(2) }}$/mois &middot; {{ maxContractMonths }} mois
<template v-if="promoTotal > 0"> &middot; {{ promoTotal.toFixed(2) }}$ en promotions étalées</template>
</div>
<div v-if="acceptanceMethod !== 'docuseal'" class="text-caption text-orange-7 q-mt-xs">
Récapitulatif envoyé au client &mdash; click-to-accept avec horodatage + IP. Pas de "pénalité"; changement avant {{ maxContractMonths }} mois = portion non étalée au prorata.
</div>
</div>
<div class="review-tree">
<div v-for="(step, i) in wizardSteps" :key="i" class="review-step">
<div v-if="i > 0" class="review-step-connector">
<q-icon name="arrow_downward" size="16px" color="grey-4" />
</div>
<div class="review-step-card" :class="{ 'has-dep': step.depends_on_step != null }">
<div class="row items-center no-wrap q-gutter-x-sm">
<div class="step-num small">{{ i + 1 }}</div>
<div class="col" style="min-width:0">
<div class="text-weight-bold" style="font-size:0.85rem">{{ step.subject }}</div>
<div class="text-caption text-grey-6 row items-center q-gutter-x-sm">
<span v-if="step.job_type">{{ step.job_type }}</span>
<span v-if="step.assigned_group"><q-icon name="group" size="12px" /> {{ step.assigned_group }}</span>
<span v-if="step.scheduled_date"><q-icon name="event" size="12px" /> {{ step.scheduled_date }}</span>
<span>{{ step.duration_h }}h</span>
<span v-if="step.on_open_webhook || step.on_close_webhook" class="text-blue-6">
<q-icon name="webhook" size="12px" /> n8n
</span>
</div>
</div>
<span class="ops-badge" :class="step.priority === 'high' ? 'open' : step.priority === 'low' ? 'closed' : 'draft'"
style="font-size:10px;padding:1px 6px">{{ step.priority }}</span>
</div>
</div>
</div>
</div>
<div class="q-mt-md ops-card" style="padding:10px 12px;background:#f0fdf4;border-color:#bbf7d0">
<div class="row items-center q-gutter-x-sm">
<q-icon name="check_circle" color="green-6" size="20px" />
<div>
<div class="text-weight-bold text-green-8" style="font-size:0.85rem">Prêt à publier</div>
<div class="text-caption text-green-7">
{{ wizardSteps.length }} tâches + {{ orderItems.length }} items
&mdash; {{ orderMode === 'quotation' ? 'Devis → Bon de commande' : orderMode === 'prepaid' ? 'Facture pré-payée' : 'Commande directe' }}
</div>
</div>
</div>
</div>
</q-card-section>
<q-card-section v-if="publishedDone" class="col q-pt-md" style="overflow-y:auto;min-height:250px;max-height:70vh;">
<div style="text-align:center">
<q-icon :name="pendingAcceptance ? 'hourglass_top' : 'check_circle'" :color="pendingAcceptance ? 'orange-6' : 'green-6'" size="56px" />
<div class="text-h6 text-weight-bold q-mt-sm" :class="pendingAcceptance ? 'text-orange-8' : 'text-green-8'">
{{ pendingAcceptance ? 'En attente d\'acceptation client' : 'Projet créé avec succès' }}
</div>
<div class="text-caption text-grey-6 q-mt-xs">{{ publishedDocType }} <b>{{ publishedDocName }}</b></div>
<div v-if="publishedContractName" class="text-caption text-orange-8 q-mt-xs">
<q-icon name="handshake" size="14px" class="q-mr-xs" />Contrat <b>{{ publishedContractName }}</b>
</div>
<div v-if="!pendingAcceptance && publishedJobCount" class="text-caption text-grey-6">{{ publishedJobCount }} tâches créées</div>
</div>
<div v-if="pendingAcceptance && !agentAccepted" class="ops-card q-mx-auto q-my-sm" style="max-width:440px;padding:12px 16px;background:#fff7ed;border-color:#fed7aa;text-align:left">
<div class="text-caption text-orange-9" style="line-height:1.5">
<q-icon name="info" size="14px" class="q-mr-xs" />
Les tâches seront créées automatiquement lorsque le client acceptera le devis.
</div>
<q-separator class="q-my-sm" />
<q-btn unelevated color="green-7" icon="verified" label="Accepter pour le client" no-caps dense class="full-width"
:loading="agentAccepting" @click="acceptForClient" />
<div class="text-caption text-grey-6 q-mt-xs" style="text-align:center">
À utiliser si le client accepte verbalement (téléphone, en personne)
</div>
</div>
<div v-if="agentAccepted" class="ops-card q-mx-auto q-my-sm" style="max-width:440px;padding:12px 16px;background:#f0fdf4;border-color:#bbf7d0;text-align:center">
<q-icon name="check_circle" color="green-6" size="24px" />
<div class="text-weight-bold text-green-8 q-mt-xs" style="font-size:0.85rem">Devis accepté — tâches créées</div>
</div>
<div v-if="publishedDocType === 'Quotation'" class="ops-card q-mx-auto q-my-sm" style="max-width:440px;padding:12px 16px;border-color:#c7d2fe;text-align:left">
<div class="text-weight-bold text-indigo-8 q-mb-sm" style="font-size:0.82rem">
<q-icon name="send" size="16px" class="q-mr-xs" /> Envoyer au client
</div>
<div class="row q-col-gutter-sm items-end q-mb-xs">
<div class="col">
<q-input v-model="sendTo" dense outlined
:placeholder="sendChannel === 'email' ? 'client@exemple.com' : '+15145551234'"
:label="sendChannel === 'email' ? 'Courriel' : 'Téléphone'" :input-style="{ fontSize: '0.82rem' }">
<template v-slot:prepend>
<q-icon :name="sendChannel === 'email' ? 'email' : 'sms'" size="18px" color="indigo-6" />
</template>
</q-input>
</div>
<div class="col-auto">
<q-btn-toggle v-model="sendChannel" dense unelevated toggle-color="indigo-6" color="grey-3" text-color="grey-8"
size="sm" no-caps style="margin-bottom:1px"
:options="[{ label: 'Courriel', value: 'email' }, { label: 'SMS', value: 'sms' }]" />
</div>
</div>
<q-btn unelevated :label="sending ? 'Envoi...' : sendChannel === 'email' ? 'Envoyer par courriel' : 'Envoyer par SMS'"
:icon="sendChannel === 'email' ? 'email' : 'sms'"
color="indigo-6" no-caps dense class="full-width q-mt-xs"
:disable="!sendTo || sending" :loading="sending" @click="sendToClient" />
<div v-if="sendResult" class="text-caption q-mt-xs" :class="sendResult.ok ? 'text-green-7' : 'text-red-6'">
{{ sendResult.message }}
</div>
</div>
<div class="row justify-center q-gutter-sm q-mt-sm">
<q-btn unelevated icon="picture_as_pdf" label="PDF" color="indigo-6" dense no-caps @click="downloadPdf" />
<q-btn outline icon="content_copy" label="Lien PDF" color="grey-7" dense no-caps @click="copyPdfLink" />
<q-btn v-if="acceptanceLinkUrl" outline icon="link" label="Lien acceptation" color="purple-6" dense no-caps
@click="copyAcceptanceLink" />
</div>
<div v-if="pdfCopied" class="text-caption text-green-7 q-mt-xs" style="text-align:center">Copié!</div>
</q-card-section>
<q-card-actions align="right" class="q-px-md q-pb-md" style="border-top:1px solid var(--ops-border, #e2e8f0)">
<q-btn v-if="publishedDone" flat label="Fermer" color="grey-7" @click="cancel" />
<template v-else>
<q-btn flat label="Annuler" color="grey-7" @click="cancel" />
<q-btn v-if="currentStep > 0" flat label="Précédent" color="grey-7" icon="arrow_back" @click="goBack" />
<q-btn v-if="currentStep < 4" unelevated label="Suivant" color="indigo-6" icon-right="arrow_forward"
:disable="!canProceed" @click="goNext" />
<q-btn v-if="currentStep === 4" unelevated :label="publishLabel" color="green-7" icon="rocket_launch"
:loading="publishing" @click="publish" />
</template>
</q-card-actions>
</q-card>
<q-dialog v-model="catalogOpen" :maximized="$q.screen.lt.sm" transition-show="slide-up" transition-hide="slide-down">
<q-card class="catalog-modal column no-wrap">
<div class="catalog-modal-header">
<div class="row items-center no-wrap q-mb-sm">
<q-icon name="inventory_2" size="22px" color="indigo-6" class="q-mr-sm" />
<div class="col">
<div class="text-weight-bold" style="font-size:1rem">Catalogue</div>
<div class="text-caption text-grey-6">Ajoutez des produits ou services au devis</div>
</div>
<q-btn flat round dense icon="close" @click="catalogOpen = false" />
</div>
<q-input v-model="catalogSearch" dense outlined clearable autofocus
placeholder="Rechercher un produit ou service..."
class="catalog-search">
<template v-slot:prepend><q-icon name="search" color="grey-6" /></template>
</q-input>
<div class="catalog-cat-row">
<div v-for="cat in catalogCategories" :key="cat"
class="catalog-cat-chip" :class="{ active: catalogFilter === cat }"
@click="catalogFilter = cat">
<q-icon :name="cat === 'Tous' ? 'apps' : catIcon(cat)" size="14px" class="q-mr-xs" />
{{ cat }}
</div>
</div>
</div>
<q-scroll-area class="catalog-modal-body col">
<div v-if="catalogLoading" class="catalog-loading">
<q-spinner size="28px" color="indigo-6" />
<div class="text-caption text-grey-6 q-mt-sm">Chargement du catalogue...</div>
</div>
<div v-else-if="!displayedCatalog.length" class="catalog-empty">
<q-icon name="search_off" size="40px" color="grey-4" />
<div class="text-caption text-grey-6 q-mt-sm">Aucun produit ne correspond à votre recherche</div>
</div>
<div v-else class="catalog-list">
<div v-for="p in displayedCatalog" :key="p.item_code" class="catalog-card"
:class="{ 'just-added': catalogRecentlyAdded.has(p.item_code) }"
@click="addFromCatalog(p)">
<div class="catalog-card-icon" :style="{ background: catBgColor(p.service_category) }">
<q-icon :name="catIcon(p.service_category)" size="22px" :color="catIconColor(p.service_category)" />
</div>
<div class="catalog-card-body">
<div class="catalog-card-name">{{ p.item_name }}</div>
<div class="catalog-card-meta">
<span class="catalog-card-code">{{ p.item_code }}</span>
<q-chip v-if="cartItemCodes.has(p.item_code)" dense square color="green-1" text-color="green-9"
icon="check_circle" size="11px" class="catalog-card-chip">Au panier</q-chip>
</div>
</div>
<div class="catalog-card-price">
<div class="catalog-card-rate">{{ p.rate.toFixed(2) }} $</div>
<div class="catalog-card-billing" :class="p.billing_type === 'Mensuel' ? 'recurring' : 'onetime'">
{{ p.billing_type === 'Mensuel' ? '/ mois' : p.billing_type === 'Annuel' ? '/ an' : 'unique' }}
</div>
</div>
<q-btn round unelevated color="indigo-6" icon="add" size="sm" class="catalog-card-add" />
</div>
</div>
</q-scroll-area>
<div class="catalog-modal-footer">
<div class="catalog-footer-counter">
<q-icon name="shopping_cart" size="16px" class="q-mr-xs" />
<span>{{ orderItems.length }} {{ orderItems.length > 1 ? 'items' : 'item' }} au devis</span>
<span v-if="catalogRecentlyAdded.size" class="catalog-footer-added">
&middot; <span class="text-green-7 text-weight-bold">+{{ catalogRecentlyAdded.size }}</span> ajouté{{ catalogRecentlyAdded.size > 1 ? 's' : '' }}
</span>
</div>
<q-btn unelevated color="indigo-6" label="Terminé" no-caps icon-right="check"
@click="catalogOpen = false" />
</div>
</q-card>
</q-dialog>
<q-dialog v-model="addStepDialog.open">
<q-card style="min-width: 340px; max-width: 90vw">
<q-card-section class="row items-center q-pb-none">
<q-icon name="add_task" size="22px" color="indigo-6" class="q-mr-sm" />
<div class="text-weight-bold">Ajouter une étape</div>
<q-space />
<q-btn flat round dense icon="close" @click="addStepDialog.open = false" />
</q-card-section>
<q-card-section class="q-gutter-sm">
<q-input v-model="addStepDialog.subject" label="Sujet de l'étape" dense outlined autofocus
:rules="[v => !!v && v.trim().length > 0 || 'Requis']" />
<div class="row q-col-gutter-sm">
<div class="col-6">
<q-select v-model="addStepDialog.job_type" :options="JOB_TYPE_OPTIONS" label="Type" dense outlined
emit-value map-options />
</div>
<div class="col-6">
<q-input v-model.number="addStepDialog.duration_h" type="number" step="0.25" min="0.25"
label="Durée (h)" dense outlined />
</div>
</div>
<q-select v-model="addStepDialog.assigned_group" :options="groupOptions" label="Groupe assigné"
dense outlined emit-value map-options clearable />
</q-card-section>
<q-card-actions align="right" class="q-pa-md">
<q-btn flat no-caps label="Annuler" @click="addStepDialog.open = false" />
<q-btn unelevated color="indigo-6" no-caps label="Ajouter" icon-right="check"
:disable="!(addStepDialog.subject && addStepDialog.subject.trim())"
@click="confirmAddStep()" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- TV channel picker: Mix 5 / Mix 10 channel selection. -->
<q-dialog v-model="channelPicker.open" :maximized="$q.screen.lt.sm"
transition-show="slide-up" transition-hide="slide-down">
<q-card class="channel-picker-card">
<q-card-section class="row items-center q-pb-sm channel-picker-head">
<q-icon name="playlist_add_check" size="22px" color="pink-6" class="q-mr-sm" />
<div class="col" style="min-width:0">
<div class="text-weight-bold">Configurateur chaînes</div>
<div class="channel-picker-sub">
{{ pickerPicksUsed }} / {{ pickerPicksAllowed }} choix utilisés
<span v-if="pickerOverage > 0" class="channel-picker-overage">
· +{{ pickerOverage }} à la carte (tarification à venir)
</span>
</div>
</div>
<q-btn flat round dense icon="close" @click="closeChannelPicker()" />
</q-card-section>
<!-- Tier segmented toggle: Mix 5 ↔ Mix 10 without losing the current
selection. Overage recomputes on the fly when switching down. -->
<q-card-section class="q-py-sm channel-picker-tiers">
<div v-for="t in mixTiers" :key="t.id"
class="channel-picker-tier"
:class="{ 'channel-picker-tier-active': channelPicker.tier?.id === t.id }"
@click="switchPickerTier(t)">
<div class="channel-picker-tier-label">{{ t.label }}</div>
<div class="channel-picker-tier-price">{{ t.price_effective.toFixed(2) }}$/mois</div>
<div class="channel-picker-tier-meta">
{{ t.picks_allowed }} choix · {{ t.pick_unit_cost?.toFixed(2) }}$/pick régulier
</div>
</div>
</q-card-section>
<q-card-section class="q-py-sm">
<q-input v-model="channelPicker.search" dense outlined
placeholder="Rechercher une chaîne (ex. sports, TVA, comédie)"
clearable>
<template #prepend>
<q-icon name="search" size="18px" />
</template>
</q-input>
</q-card-section>
<q-card-section v-if="channelPicker.selection.length" class="q-py-sm channel-picker-chips">
<q-chip v-for="ch in channelPicker.selection" :key="ch.name"
:color="ch.premium_group ? 'pink-1' : 'grey-3'"
text-color="grey-9" removable dense
class="channel-chip"
@remove="toggleChannel(ch)">
<span class="channel-chip-name">{{ ch.name }}</span>
<q-badge v-if="ch.premium_group" color="red-6" text-color="white"
class="channel-chip-pastille" rounded>
2
</q-badge>
</q-chip>
</q-card-section>
<q-card-section class="channel-picker-list">
<div v-for="ch in filteredChannels" :key="ch.name"
class="channel-row"
:class="{
'channel-row-selected': isChannelSelected(ch),
'channel-row-premium': !!ch.premium_group,
}"
@click="toggleChannel(ch)">
<q-icon :name="isChannelSelected(ch) ? 'check_circle' : 'radio_button_unchecked'"
size="18px" :color="isChannelSelected(ch) ? 'green-6' : 'grey-5'" />
<div class="col" style="min-width:0">
<div class="channel-row-name">
{{ ch.name }}
<q-badge v-if="ch.premium_group" color="red-6" text-color="white"
class="q-ml-xs" rounded>2 choix</q-badge>
</div>
<div v-if="ch.sub && ch.sub.length" class="channel-row-sub">
incl. {{ ch.sub.join(', ') }}
</div>
</div>
<div v-if="ch.premium_group" class="channel-row-surcharge">+3$</div>
</div>
<div v-if="!filteredChannels.length" class="channel-row-empty">
Aucune chaîne ne correspond.
</div>
</q-card-section>
<q-card-actions align="right" class="q-pa-md channel-picker-foot">
<q-btn flat no-caps label="Annuler" @click="closeChannelPicker()" />
<q-btn unelevated color="pink-6" no-caps icon-right="check"
:label="pickerOverage > 0
? `Appliquer (${channelPicker.selection.length} chaînes · +${pickerOverage} à la carte)`
: `Appliquer (${channelPicker.selection.length} chaînes)`"
:disable="channelPicker.selection.length === 0"
@click="applyChannelPicker()" />
</q-card-actions>
</q-card>
</q-dialog>
</q-dialog>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { Notify, useQuasar } from 'quasar'
import { PROJECT_TEMPLATES, ASSIGNED_GROUPS } from 'src/config/project-templates'
import { STEP_LABELS, JOB_TYPE_OPTIONS, PRIORITY_OPTIONS, ORDER_MODE_OPTIONS, ACCEPTANCE_METHOD_OPTIONS, HUB_URL, REFERRAL_ITEM_CODE, REFERRAL_CREDIT_AMOUNT, EXTRA_FEE_PRESETS, TV_CHANNELS, catIcon } from 'src/data/wizard-constants'
import { useWizardCatalog } from 'src/composables/useWizardCatalog'
import { useWizardPublish } from 'src/composables/useWizardPublish'
import FlowQuickButton from 'src/components/flow-editor/FlowQuickButton.vue'
const props = defineProps({
modelValue: Boolean,
issue: { type: Object, default: () => ({}) },
customer: { type: Object, default: () => null },
existingJobs: { type: Array, default: () => [] },
deliveryAddressId: { type: String, default: '' },
})
const emit = defineEmits(['update:modelValue', 'created'])
const $q = useQuasar()
const templates = PROJECT_TEMPLATES
const currentStep = ref(0)
const selectedTemplate = ref(null)
const wizardSteps = ref([])
const customSteps = ref([])
const publishing = ref(false)
const orderMode = ref('direct')
const orderItems = ref([])
const contractNotes = ref('')
const requireAcceptance = ref(false)
const acceptanceMethod = ref('jwt')
const clientPhone = ref('')
const clientEmail = ref('')
const publishedDocName = ref('')
const publishedDocType = ref('')
const publishedContractName = ref('')
const publishedDone = ref(false)
const pdfCopied = ref(false)
const pendingAcceptance = ref(false)
const acceptanceLinkSent = ref(false)
const acceptanceSentVia = ref('')
const acceptanceLinkUrl = ref('')
const publishedJobCount = ref(0)
const sendChannel = ref('email')
const sendTo = ref('')
const sending = ref(false)
const sendResult = ref(null)
const agentAccepting = ref(false)
const agentAccepted = ref(false)
const catalogOpen = ref(false)
const catalogSearch = ref('')
const catalogRecentlyAdded = ref(new Set())
const catalogTargetLineIdx = ref(null)
const expandedBundles = ref(new Set())
const installStepExpanded = ref(false)
function toggleBundleExpanded (id) {
const next = new Set(expandedBundles.value)
if (next.has(id)) next.delete(id)
else next.add(id)
expandedBundles.value = next
}
// ── Billing cycle pill: Mensuel → Annuel → Unique → loop ──
// The pill replaces the separate interval select + recurring/onetime toggle.
// A recurring service with a yearly rate sets billing_interval='Year'; onetime
// clears the interval entirely so totals & subscription code read it correctly.
const BILLING_CYCLE = [
{ billing: 'recurring', billing_interval: 'Month', label: '/mois', cls: 'recurring' },
{ billing: 'recurring', billing_interval: 'Year', label: '/an', cls: 'yearly' },
{ billing: 'onetime', billing_interval: null, label: 'unique', cls: 'onetime' },
]
function billingIndex (item) {
if (item.billing === 'onetime') return 2
return item.billing_interval === 'Year' ? 1 : 0
}
function billingPillLabel (item) { return BILLING_CYCLE[billingIndex(item)].label }
function billingPillClass (item) { return BILLING_CYCLE[billingIndex(item)].cls }
function nextBillingLabel (item) {
return BILLING_CYCLE[(billingIndex(item) + 1) % BILLING_CYCLE.length].label
}
function cycleBilling (item) {
const next = BILLING_CYCLE[(billingIndex(item) + 1) % BILLING_CYCLE.length]
item.billing = next.billing
item.billing_interval = next.billing_interval
if (next.billing === 'onetime' && (!item.regular_price || item.regular_price < item.rate)) {
item.regular_price = item.rate
}
}
// Lookup helpers for the inline bundle accordion under each cart line.
function bundleForTemplate (tplId) {
return loadedBundles.value.find(b => b.id === tplId) || null
}
function bundleStepCount (tplId) {
return bundleForTemplate(tplId)?.steps.length || 0
}
// Per-step extra fee — stored directly on the wizardSteps entry. At publish
// time, each step with a positive extra_fee becomes a FEE-EXTRA line in the
// order so the extra flows through to the customer invoice/quote. extra_label
// carries the preset label (Émondage, Creusage…) or the custom free-form name.
function setStepExtraFee (idx, v) {
const step = wizardSteps.value[idx]
if (!step) return
const n = Number(v)
step.extra_fee = Number.isFinite(n) && n > 0 ? n : 0
}
function applyExtraPreset (idx, preset) {
const step = wizardSteps.value[idx]
if (!step) return
step.extra_fee = Number(preset.amount) || 0
step.extra_label = preset.label
closeExtraMenu(idx)
}
function clearExtra (idx) {
const step = wizardSteps.value[idx]
if (!step) return
step.extra_fee = 0
step.extra_label = ''
closeExtraMenu(idx)
}
// Per-step custom extra draft (label + amount) — keyed by step idx so each
// row edits independently. Flushed into step.extra_{fee,label} on confirm.
const customExtraDraft = ref({})
function getCustomDraft (idx) {
if (!customExtraDraft.value[idx]) {
const step = wizardSteps.value[idx]
customExtraDraft.value[idx] = {
label: step?.extra_label || '',
amount: step?.extra_fee || null,
}
}
return customExtraDraft.value[idx]
}
function updateCustomDraft (idx, field, value) {
const current = getCustomDraft(idx)
customExtraDraft.value = {
...customExtraDraft.value,
[idx]: { ...current, [field]: value },
}
}
function applyCustomExtra (idx) {
const draft = customExtraDraft.value[idx]
if (!draft) return
const amount = Number(draft.amount)
const label = (draft.label || '').trim()
if (!Number.isFinite(amount) || amount <= 0) return
const step = wizardSteps.value[idx]
if (!step) return
step.extra_fee = amount
step.extra_label = label || 'Frais extra'
closeExtraMenu(idx)
}
const extraMenuOpen = ref({})
function toggleExtraMenu (idx) {
extraMenuOpen.value = { ...extraMenuOpen.value, [idx]: !extraMenuOpen.value[idx] }
if (extraMenuOpen.value[idx]) {
// Seed the draft from the current step value so the custom inputs prefill.
const step = wizardSteps.value[idx]
customExtraDraft.value[idx] = {
label: step?.extra_label || '',
amount: step?.extra_fee || null,
}
}
}
function closeExtraMenu (idx) {
extraMenuOpen.value = { ...extraMenuOpen.value, [idx]: false }
}
const addStepDialog = ref({
open: false,
templateId: '',
subject: '',
job_type: 'Autre',
duration_h: 0.5,
assigned_group: '',
})
function openAddStepDialog (templateId) {
addStepDialog.value = {
open: true,
templateId,
subject: '',
job_type: 'Autre',
duration_h: 0.5,
assigned_group: '',
}
}
function confirmAddStep () {
const d = addStepDialog.value
if (!d.subject || !d.subject.trim()) return
addStepToBundle(d.templateId, {
subject: d.subject.trim(),
job_type: d.job_type,
duration_h: d.duration_h,
assigned_group: d.assigned_group,
})
addStepDialog.value.open = false
}
const templateLoadedFor = ref(new Set())
const { catalogProducts, catalogLoading, catalogFilter, catalogCategories, residentialPresets, internetHeroTiers, tvHeroTiers, selectedHeroTier, selectHeroTier, selectedTvTier, selectTvTier, filteredCatalog, loadCatalog, addCatalogItem, applyPreset, loadTemplateFromItem, toggleTemplate, removeStepFromBundle, addStepToBundle, loadedBundles, lastMergeCount, mergedTemplateLabels } =
useWizardCatalog({ orderItems, wizardSteps, templates, templateLoadedFor })
// Internet accordion defaults to open — Internet is required so we want
// the tier list visible on first landing. Clicking a tier closes it.
const heroExpanded = ref(true)
const tvExpanded = ref(false)
// Anchor tiles show the currently-selected tier, or fall back to the
// "default" tier (Megafibre 80 / Télé Base) when nothing from that family
// is in the cart yet. This keeps the marketing "à partir de X$" framing up front.
const heroAnchor = computed(() =>
selectedHeroTier.value || internetHeroTiers.find(t => t.default) || internetHeroTiers[0])
const tvAnchor = computed(() =>
selectedTvTier.value || tvHeroTiers.find(t => t.default) || tvHeroTiers[0])
// Internet tier select: keep the group open while the rep is comparing
// tiers, but collapse it once a selection lands — clearer signal that the
// anchor now reflects what's in the cart.
function onInternetTierClick (tier) {
selectHeroTier(tier)
heroExpanded.value = false
}
function onTvTierClick (tier) {
if (!selectedHeroTier.value) {
$q.notify({
type: 'warning',
message: 'Ajoutez un service Internet d\'abord',
caption: 'La télé nécessite un abonnement Internet à la même adresse',
timeout: 3000,
position: 'top',
})
return
}
// Tiers with picks_allowed open the channel picker. Base tier and
// non-ready tiers (à la carte for now) fall straight through.
if (tier.picks_allowed && tier.ready) {
openChannelPicker(tier)
tvExpanded.value = false
return
}
if (!tier.ready) {
$q.notify({
type: 'info',
message: `${tier.label} — configurateur de chaînes à venir`,
caption: 'Pour l\'instant, la ligne est ajoutée sans sélection détaillée',
timeout: 3000,
position: 'top',
})
}
selectTvTier(tier)
tvExpanded.value = false
}
// Click-to-expand for hero groups. Only one group can be open at a time
// (accordion behavior) but toggling requires a click, never hover. TV is
// gated behind Internet — attempting to expand without an Internet tier
// shows a toast instead of opening.
function toggleHeroGroup (which) {
if (which === 'tv' && !selectedHeroTier.value) {
$q.notify({
type: 'warning',
message: 'Ajoutez un service Internet d\'abord',
caption: 'La télé nécessite un abonnement Internet à la même adresse',
timeout: 3000,
position: 'top',
})
return
}
if (which === 'internet') {
heroExpanded.value = !heroExpanded.value
if (heroExpanded.value) { tvExpanded.value = false; phoneExpanded.value = false }
} else if (which === 'tv') {
tvExpanded.value = !tvExpanded.value
if (tvExpanded.value) { heroExpanded.value = false; phoneExpanded.value = false }
} else if (which === 'phone') {
phoneExpanded.value = !phoneExpanded.value
if (phoneExpanded.value) { heroExpanded.value = false; tvExpanded.value = false }
}
}
// Contract months for the currently-selected Internet tier, read from the
// actual cart line (authoritative) so manual edits are reflected.
const internetContractMonths = computed(() => {
const tier = selectedHeroTier.value
if (!tier) return null
const line = orderItems.value.find(i => i.item_code === tier.code)
return line?.contract_months || null
})
// ── Step 2 summary (services additionnels) ───────────────────────────────
// Compact chip row under the step-2 header — shows what's currently added
// (TV tier + chaînes, téléphonie mode, upsells) so the rep sees the running
// tally above the detail accordions.
const step2Summary = computed(() => {
const chips = []
if (selectedTvTier.value) {
const tier = selectedTvTier.value
const line = orderItems.value.find(i => i.item_code && i.item_code.startsWith('TV-MIX'))
const channelCount = Array.isArray(line?.tv_channels) ? line.tv_channels.length : 0
let label = tier.label
if (channelCount) label += ` · ${channelCount} chaîne${channelCount > 1 ? 's' : ''}`
chips.push({ key: 'tv', icon: 'live_tv', cls: 'wizard-step-chip-tv', label })
}
if (phoneApplied.value) {
const mode = phoneMode.value === 'keep' ? 'Conservation' : 'Nouveau numéro'
const num = phoneMode.value === 'keep' && phoneKeepNumber.value ? ` · ${phoneKeepNumber.value}` : ''
chips.push({ key: 'phone', icon: 'phone', cls: 'wizard-step-chip-phone', label: `Téléphonie · ${mode}${num}` })
}
// Other upsells (WiFi booster, etc.) that are currently applied
for (const p of otherUpsells.value) {
const codes = new Set((p.items || []).map(i => i.item_code).filter(Boolean))
if (orderItems.value.some(i => codes.has(i.item_code))) {
chips.push({ key: p.id, icon: p.icon || 'add_circle', cls: 'wizard-step-chip-upsell', label: p.label })
}
}
return chips
})
// ── Sommaire step (cart detail with edit/delete/qty/drag-drop) ───────────
// Only one row expands at a time (keeps the page short and focused).
// Drag the handle at the left to reorder — the array order drives quote
// line order on export. We use HTML5 native drag API rather than Sortable.js
// to keep the bundle lean (the list is always short).
const expandedSommaireIdx = ref(null)
const dragIndex = ref(null)
const dragOverIndex = ref(null)
// Clicking the cart pill on Step 2 jumps to the Sommaire step. No-op when
// empty — the pill already shows a muted "empty" state in that case.
function goToSommaire () {
if (!orderItems.value.length) return
currentStep.value = 3
}
function toggleSommaireExpand (idx) {
expandedSommaireIdx.value = expandedSommaireIdx.value === idx ? null : idx
}
function deleteSommaireItem (idx) {
orderItems.value.splice(idx, 1)
if (expandedSommaireIdx.value === idx) {
expandedSommaireIdx.value = null
} else if (expandedSommaireIdx.value != null && idx < expandedSommaireIdx.value) {
// Keep pointer at the same item after deleting an earlier row
expandedSommaireIdx.value--
}
}
function onSommaireDragStart (idx, e) {
dragIndex.value = idx
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move'
// Some browsers require data for drag events to fire reliably
e.dataTransfer.setData('text/plain', String(idx))
}
}
function onSommaireDragEnd () {
dragIndex.value = null
dragOverIndex.value = null
}
function onSommaireDragOver (idx, e) {
if (dragIndex.value == null || dragIndex.value === idx) return
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
dragOverIndex.value = idx
}
function onSommaireDragLeave (idx) {
if (dragOverIndex.value === idx) dragOverIndex.value = null
}
function onSommaireDrop (idx) {
const from = dragIndex.value
if (from == null || from === idx) {
dragIndex.value = null
dragOverIndex.value = null
return
}
const [moved] = orderItems.value.splice(from, 1)
orderItems.value.splice(idx, 0, moved)
// Track the expanded row across the reorder so UX feels stable
if (expandedSommaireIdx.value === from) {
expandedSommaireIdx.value = idx
} else if (expandedSommaireIdx.value != null) {
if (from < expandedSommaireIdx.value && idx >= expandedSommaireIdx.value) {
expandedSommaireIdx.value--
} else if (from > expandedSommaireIdx.value && idx <= expandedSommaireIdx.value) {
expandedSommaireIdx.value++
}
}
dragIndex.value = null
dragOverIndex.value = null
}
// ── TV channel picker ────────────────────────────────────────────────────
// Opens when the rep clicks a Mix 5 / Mix 10 tier. Tracks the tier being
// configured (mix-5 or mix-10), the working selection (array of channel
// objects), and the fuzzy search query. Applying writes back through
// selectTvTier(tier, channelSelection) which handles surcharges + overage.
const channelPicker = ref({
open: false,
tier: null,
selection: [],
search: '',
})
function openChannelPicker (tier) {
// Preload any existing selection from the current cart so reopening a Mix
// tier surfaces what's already been chosen (channels stored on TV-MIX line).
const existing = orderItems.value.find(i => i.item_code && i.item_code.startsWith('TV-MIX') && Array.isArray(i.tv_channels))
const names = existing?.tv_channels || []
const selection = names
.map(n => TV_CHANNELS.find(c => c.name === n))
.filter(Boolean)
channelPicker.value = {
open: true,
tier,
selection,
search: '',
}
}
function closeChannelPicker () {
channelPicker.value.open = false
}
function isChannelSelected (ch) {
return channelPicker.value.selection.some(s => s.name === ch.name)
}
function toggleChannel (ch) {
const list = channelPicker.value.selection
const idx = list.findIndex(s => s.name === ch.name)
if (idx >= 0) {
list.splice(idx, 1)
} else {
list.push(ch)
}
}
// Pick cost: premium channels = 2, regular = 1.
function channelPickCost (ch) {
return ch.premium_group ? 2 : 1
}
const pickerPicksUsed = computed(() =>
channelPicker.value.selection.reduce((n, c) => n + channelPickCost(c), 0),
)
const pickerPicksAllowed = computed(() => channelPicker.value.tier?.picks_allowed || 0)
const pickerOverage = computed(() => Math.max(0, pickerPicksUsed.value - pickerPicksAllowed.value))
// Fuzzy-ish match against name + sub[] aliases. Space-separated tokens all
// have to appear somewhere. Case-insensitive. No external lib — plenty fast
// for ~65 entries and a couple of tokens.
const filteredChannels = computed(() => {
const q = (channelPicker.value.search || '').trim().toLowerCase()
if (!q) return TV_CHANNELS
const tokens = q.split(/\s+/).filter(Boolean)
return TV_CHANNELS.filter(ch => {
const hay = [ch.name, ...(ch.sub || [])].join(' ').toLowerCase()
return tokens.every(t => hay.includes(t))
})
})
function applyChannelPicker () {
const { tier, selection } = channelPicker.value
if (!tier) return
selectTvTier(tier, selection.map(c => ({
name: c.name,
premium_group: c.premium_group || null,
pick_cost: channelPickCost(c),
})))
channelPicker.value.open = false
}
// Mix tiers available as segmented toggle inside the picker. Only tiers with
// picks_allowed qualify — that's Mix 5 and Mix 10 today.
const mixTiers = computed(() => tvHeroTiers.filter(t => t.picks_allowed && t.ready))
// Switch the picker tier without losing the current channel selection.
// Picks used stay the same; the allowance (and therefore overage) simply
// recomputes against the new picks_allowed.
function switchPickerTier (tier) {
if (!tier || channelPicker.value.tier?.id === tier.id) return
channelPicker.value.tier = tier
}
const displayedCatalog = computed(() => {
const q = (catalogSearch.value || '').trim().toLowerCase()
if (!q) return filteredCatalog.value
return filteredCatalog.value.filter(p =>
(p.item_name || '').toLowerCase().includes(q)
|| (p.item_code || '').toLowerCase().includes(q)
|| (p.service_category || '').toLowerCase().includes(q),
)
})
const cartItemCodes = computed(() => new Set(orderItems.value.map(i => i.item_code).filter(Boolean)))
const CAT_BG = { Internet: '#e0f2fe', Téléphonie: '#fef3c7', Bundle: '#ede9fe', Équipement: '#dcfce7', Frais: '#fee2e2' }
const CAT_COLOR = { Internet: 'blue-7', Téléphonie: 'amber-8', Bundle: 'purple-6', Équipement: 'green-7', Frais: 'red-6' }
function catBgColor (cat) { return CAT_BG[cat] || '#f1f5f9' }
function catIconColor (cat) { return CAT_COLOR[cat] || 'grey-7' }
function openCatalog () {
catalogTargetLineIdx.value = null
catalogOpen.value = true
catalogSearch.value = ''
catalogRecentlyAdded.value = new Set()
if (!catalogProducts.value.length) loadCatalog()
}
// Open catalog targeted at a specific empty row — when the user picks
// something, we replace that row in-place instead of appending a new one.
function openCatalogForLine (idx) {
catalogTargetLineIdx.value = idx
catalogOpen.value = true
catalogSearch.value = ''
catalogRecentlyAdded.value = new Set()
if (!catalogProducts.value.length) loadCatalog()
}
function onItemFieldClick (idx, ev) {
const line = orderItems.value[idx]
if (!line) return
// Manual-edit mode: let the user type freely.
if (line.manual_edit) return
if (ev?.target?.blur) ev.target.blur()
// TV Mix supplement line → reopen channel picker (edit-in-place for the
// current tier, preserving selected channels).
if (line.item_code === 'TV-MIX5' || line.item_code === 'TV-MIX10') {
const tier = tvHeroTiers.find(t => t.code === line.item_code)
if (tier) {
openChannelPicker(tier)
return
}
}
openCatalogForLine(idx)
}
function enableManualEdit (idx) {
const line = orderItems.value[idx]
if (!line) return
line.manual_edit = true
}
function addFromCatalog (p) {
const targetIdx = catalogTargetLineIdx.value
// In-place swap when targeting a line that already has a product — preserve
// qty + manual_edit flag so the agent keeps quantity overrides across swaps.
if (targetIdx != null && orderItems.value[targetIdx]) {
const existing = orderItems.value[targetIdx]
const keptQty = existing.qty && existing.qty > 0 ? existing.qty : 1
orderItems.value.splice(targetIdx, 1, {
item_code: p.item_code,
item_name: p.item_name,
qty: keptQty,
rate: p.rate,
regular_price: 0,
billing: p.billing_type === 'Mensuel' || p.billing_type === 'Annuel' ? 'recurring' : 'onetime',
billing_interval: p.billing_type === 'Annuel' ? 'Year' : 'Month',
contract_months: existing.contract_months || 12,
project_template_id: p.project_template_id || '',
requires_visit: p.requires_visit || false,
manual_edit: false,
})
if (p.project_template_id && !templateLoadedFor.value.has(p.project_template_id)) {
loadTemplateFromItem({ project_template_id: p.project_template_id })
}
catalogTargetLineIdx.value = null
} else {
addCatalogItem(p)
}
catalogRecentlyAdded.value = new Set([...catalogRecentlyAdded.value, p.item_code])
setTimeout(() => {
const next = new Set(catalogRecentlyAdded.value)
next.delete(p.item_code)
catalogRecentlyAdded.value = next
}, 900)
}
const { publish } = useWizardPublish({
props, emit,
state: {
publishing, wizardSteps, orderItems, orderMode,
contractNotes, requireAcceptance, acceptanceMethod,
clientPhone, clientEmail,
publishedDocName, publishedDocType, publishedDone,
pendingAcceptance, acceptanceLinkUrl, acceptanceLinkSent,
acceptanceSentVia, publishedJobCount,
sendTo, sendChannel,
publishedContractName,
cancel () { cancel() },
},
})
function getPdfUrl () {
return `${HUB_URL}/accept/doc-pdf/${encodeURIComponent(publishedDocType.value)}/${encodeURIComponent(publishedDocName.value)}`
}
function downloadPdf () { window.open(getPdfUrl(), '_blank') }
async function copyToClipboard (text) {
try { await navigator.clipboard.writeText(text) } catch {
const ta = document.createElement('textarea')
ta.value = text; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta)
}
pdfCopied.value = true
setTimeout(() => { pdfCopied.value = false }, 2500)
}
async function copyPdfLink () { await copyToClipboard(getPdfUrl()) }
async function copyAcceptanceLink () { if (acceptanceLinkUrl.value) await copyToClipboard(acceptanceLinkUrl.value) }
async function sendToClient () {
if (!sendTo.value || sending.value) return
sending.value = true
sendResult.value = null
try {
const res = await fetch(`${HUB_URL}/accept/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
quotation: publishedDocName.value,
customer: props.issue?.customer || '',
channel: sendChannel.value,
to: sendTo.value,
}),
})
const data = await res.json()
if (data.ok && data.sent) {
sendResult.value = {
ok: true,
message: sendChannel.value === 'email'
? `Courriel envoyé à ${sendTo.value} avec le PDF en pièce jointe`
: `SMS envoyé à ${sendTo.value}`,
}
acceptanceLinkUrl.value = data.link || acceptanceLinkUrl.value
} else {
sendResult.value = {
ok: false,
message: data.error || `Échec de l'envoi ${sendChannel.value === 'email' ? 'du courriel' : 'du SMS'}`,
}
}
} catch (e) {
sendResult.value = { ok: false, message: `Erreur: ${e.message}` }
} finally {
sending.value = false
}
}
async function acceptForClient () {
if (agentAccepting.value || !publishedDocName.value) return
agentAccepting.value = true
try {
const res = await fetch(`${HUB_URL}/api/accept-for-client`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ quotation: publishedDocName.value, agent_name: 'Ops Agent' }),
})
const data = await res.json()
if (data.ok) {
agentAccepted.value = true
pendingAcceptance.value = false
Notify.create({ type: 'positive', message: 'Devis accepté — tâches créées automatiquement', timeout: 5000 })
} else {
Notify.create({ type: 'negative', message: data.error || 'Erreur lors de l\'acceptation' })
}
} catch (e) {
Notify.create({ type: 'negative', message: `Erreur: ${e.message}` })
} finally {
agentAccepting.value = false
}
}
function addOrderItem () {
orderItems.value.push({ item_name: '', item_code: '', qty: 1, rate: 0, regular_price: 0, billing: 'onetime', billing_interval: 'Month', contract_months: 12 })
}
// Step extras are recorded on each wizardSteps entry (step.extra_fee). They
// roll up into onetimeTotal live; at publish time each extra becomes a
// FEE-EXTRA line so the customer invoice reflects the charge.
const stepsExtraTotal = computed(() =>
wizardSteps.value.reduce((s, step) => s + (Number(step.extra_fee) || 0), 0))
const onetimeTotal = computed(() =>
orderItems.value.filter(i => i.billing === 'onetime').reduce((s, i) => s + (i.qty * i.rate), 0)
+ stepsExtraTotal.value)
const recurringTotal = computed(() => orderItems.value.filter(i => i.billing === 'recurring').reduce((s, i) => s + (i.qty * i.rate), 0))
const promoTotal = computed(() => orderItems.value
.filter(i => i.billing === 'onetime' && (i.regular_price || 0) > i.rate)
.reduce((s, i) => s + ((i.regular_price || 0) - i.rate) * i.qty, 0))
const maxContractMonths = computed(() => {
const months = orderItems.value.filter(i => i.billing === 'recurring').map(i => i.contract_months || 0)
return months.length ? Math.max(...months) : 0
})
const willCreateContract = computed(() => orderMode.value === 'quotation'
&& orderItems.value.some(i => i.billing === 'recurring' && (i.contract_months || 0) > 0))
const usedServiceTypes = computed(() => {
const s = new Set()
for (const i of orderItems.value) {
if (i.service_type) s.add(i.service_type)
}
return s
})
// Upsell chips — all residential presets are upsells now (the Internet hero
// ships as a separate INTERNET_HERO_TIERS list with swap-on-click semantics).
const upsellPresets = computed(() => residentialPresets.filter(p => p.tier !== 'hero'))
// Split out the telephony preset — it now has a dedicated hero group with
// Nouveau/Conservation sub-options. Everything else (WiFi booster, …) stays
// in the plain chip row.
const phonePreset = computed(() => residentialPresets.find(p => p.service_type === 'phone'))
const otherUpsells = computed(() => upsellPresets.value.filter(p => p.service_type !== 'phone'))
const phoneExpanded = ref(false)
const phoneMode = ref('new') // 'new' | 'keep'
const phoneKeepNumber = ref('')
const phoneApplied = computed(() => {
const preset = phonePreset.value
if (!preset) return false
const codes = new Set(preset.items.map(i => i.item_code))
return orderItems.value.some(i => codes.has(i.item_code))
})
// Port-in proof step: one-off tech step injected when the customer keeps
// their existing number. Bundled under the Internet install template so it
// rides the same truck roll. merge_key keeps it idempotent across re-apply.
const PORT_IN_MERGE_KEY = 'port_in_proof'
function ensurePortInStep () {
const exists = wizardSteps.value.some(s => s.merge_key === PORT_IN_MERGE_KEY)
if (exists) return
wizardSteps.value.push({
merge_key: PORT_IN_MERGE_KEY,
subject: 'Collecter preuve de numéro (port-in téléphonie)',
job_type: 'Autre',
priority: 'high',
duration_h: 0.25,
assigned_group: 'Installation',
depends_on_step: null,
scheduled_date: '',
on_open_webhook: '',
on_close_webhook: '',
source_templates: ['phone_port_in'],
requires_photo: true,
notify_group: 'Telephony Provisioning',
})
}
function removePortInStep () {
wizardSteps.value = wizardSteps.value.filter(s => s.merge_key !== PORT_IN_MERGE_KEY)
}
function applyPhonePreset (mode) {
const preset = phonePreset.value
if (!preset) return
if (!phoneApplied.value) applyPreset(preset)
phoneMode.value = mode
if (mode === 'keep') {
ensurePortInStep()
} else {
removePortInStep()
phoneKeepNumber.value = ''
}
// Stamp mode + number on the TEL-ILL line so publish/print pick them up.
const telLine = orderItems.value.find(i => i.item_code === 'TEL-ILL')
if (telLine) {
telLine.phone_mode = mode
telLine.phone_keep_number = mode === 'keep' ? phoneKeepNumber.value : ''
}
// Auto-close the accordion on both modes — "new" is done; "keep" still
// needs the phone number but the compact anchor now shows a warning +
// re-clicking the anchor reopens to edit. This matches the "click to
// select, accordion closes" pattern requested across all services.
phoneExpanded.value = false
}
// Keep the TEL-ILL line in sync when the number field changes.
watch(phoneKeepNumber, (v) => {
const telLine = orderItems.value.find(i => i.item_code === 'TEL-ILL')
if (telLine && phoneMode.value === 'keep') telLine.phone_keep_number = v
})
// Referral: one-time -50$ line appended when a valid code is entered. The hub
// endpoint validates + reserves the code; if the endpoint isn't reachable we
// accept the code locally and server-side validation runs at submit time so
// the UI works in dev/offline without blocking the agent.
const referralCode = ref('')
const referralApplied = ref(false)
const referralChecking = ref(false)
const referralError = ref('')
async function applyReferralCode () {
const code = referralCode.value.trim().toUpperCase()
if (!code) return
referralChecking.value = true
referralError.value = ''
let accept = false
try {
const res = await fetch(`${HUB_URL}/api/referral/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
})
if (res.ok) {
const data = await res.json()
if (data.ok) accept = true
else referralError.value = data.error || 'Code invalide'
} else if (res.status === 404) {
accept = true // endpoint not yet deployed — accept, validate at submit
} else {
referralError.value = 'Erreur de validation (' + res.status + ')'
}
} catch (e) {
accept = true // offline / endpoint missing — accept, validate at submit
}
if (accept) {
referralCode.value = code
orderItems.value.push({
item_code: REFERRAL_ITEM_CODE,
item_name: 'Crédit parrainage · ' + code,
qty: 1,
rate: -Math.abs(REFERRAL_CREDIT_AMOUNT),
regular_price: 0,
billing: 'onetime',
billing_interval: null,
contract_months: 0,
is_rebate: true,
referral_code: code,
})
referralApplied.value = true
}
referralChecking.value = false
}
function removeReferralCode () {
orderItems.value = orderItems.value.filter(i => i.item_code !== REFERRAL_ITEM_CODE)
referralApplied.value = false
referralError.value = ''
referralCode.value = ''
}
const publishLabel = computed(() => {
if (orderMode.value === 'quotation') return 'Créer le devis & projet'
if (orderMode.value === 'prepaid') return 'Créer la facture & projet'
return 'Créer la commande & projet'
})
const groupOptions = ASSIGNED_GROUPS.map(g => ({ label: g, value: g }))
function dependencyOptionsFor (stepIndex) {
return wizardSteps.value
.map((s, i) => ({ label: `${i + 1}. ${s.subject?.substring(0, 35) || 'Étape'}`, value: i }))
.filter((_, i) => i !== stepIndex)
}
// Quick-sale = no installation steps in the cart. Derived from wizardSteps
// length, so loading a template automatically exits quick-sale and clearing
// the steps re-enters it. Matches the rule "wizard is forced only when a
// product needs dependency-of-tasks".
const isQuickSale = computed(() => wizardSteps.value.length === 0)
const canProceed = computed(() => {
if (currentStep.value === 0) return true
if (currentStep.value === 1) return wizardSteps.value.every(s => s.subject?.trim())
// Step 2 (Items/Devis) → Step 3 (Sommaire): block if no Internet tier picked.
// Internet is mandatory; skipping Sommaire on an empty cart wastes a click.
if (currentStep.value === 2) return orderItems.value.length > 0
// Step 3 (Sommaire) → Step 4 (Publier): always OK once there's at least one item.
if (currentStep.value === 3) return orderItems.value.length > 0
return true
})
function goNext () {
if (currentStep.value === 0 && isQuickSale.value) { currentStep.value = 2; return }
currentStep.value++
}
function goBack () {
if (currentStep.value === 2 && isQuickSale.value) { currentStep.value = 0; return }
currentStep.value--
}
function handleStepDotClick (i) {
if (i >= currentStep.value) return
if (isQuickSale.value && i === 1) { currentStep.value = 0; return }
currentStep.value = i
}
function selectQuickSale () {
selectedTemplate.value = null
wizardSteps.value = []
customSteps.value = []
currentStep.value = 2
setTimeout(() => openCatalog(), 180)
}
function selectTemplate (tpl) {
selectedTemplate.value = tpl
wizardSteps.value = tpl.steps.map(s => ({
subject: s.subject, job_type: s.job_type, priority: s.priority, duration_h: s.duration_h,
assigned_group: s.assigned_group || '', depends_on_step: s.depends_on_step, scheduled_date: '',
on_open_webhook: s.on_open_webhook || '', on_close_webhook: s.on_close_webhook || '',
}))
}
function selectCustom () {
selectedTemplate.value = null
if (!customSteps.value.length) customSteps.value = [makeEmptyStep()]
wizardSteps.value = customSteps.value
}
function makeEmptyStep () {
return { subject: '', job_type: 'Autre', priority: 'medium', duration_h: 1, assigned_group: '', depends_on_step: null, scheduled_date: '', on_open_webhook: '', on_close_webhook: '' }
}
function addStep () { wizardSteps.value.push(makeEmptyStep()) }
function removeStep (i) {
wizardSteps.value.forEach(s => {
if (s.depends_on_step === i) s.depends_on_step = null
else if (s.depends_on_step != null && s.depends_on_step > i) s.depends_on_step--
})
wizardSteps.value.splice(i, 1)
}
function cancel () {
currentStep.value = 0
selectedTemplate.value = null
wizardSteps.value = []
customSteps.value = []
orderItems.value = []
orderMode.value = 'direct'
contractNotes.value = ''
requireAcceptance.value = false
acceptanceMethod.value = 'jwt'
clientPhone.value = ''
clientEmail.value = ''
publishedDone.value = false
publishedDocName.value = ''
publishedDocType.value = ''
publishedContractName.value = ''
pdfCopied.value = false
pendingAcceptance.value = false
acceptanceLinkSent.value = false
acceptanceSentVia.value = ''
acceptanceLinkUrl.value = ''
publishedJobCount.value = 0
sendChannel.value = 'email'
sendTo.value = ''
sending.value = false
sendResult.value = null
emit('update:modelValue', false)
}
watch(() => props.modelValue, (v) => {
if (v) {
selectedTemplate.value = null
wizardSteps.value = []
customSteps.value = []
templateLoadedFor.value = new Set()
lastMergeCount.value = 0
mergedTemplateLabels.value = []
orderItems.value = []
expandedBundles.value = new Set()
installStepExpanded.value = false
// Residential commitment is the default expected path when launching
// from a customer page without a pre-existing Issue.
const fromCustomer = !!(props.customer && !props.issue?.name)
orderMode.value = fromCustomer ? 'quotation' : 'direct'
contractNotes.value = ''
requireAcceptance.value = fromCustomer
acceptanceMethod.value = 'jwt'
clientPhone.value = props.customer?.cell_phone || props.customer?.tel_home || ''
clientEmail.value = props.customer?.email_billing || props.customer?.email_id || ''
// Launched from a customer → skip straight to the Items step (no template
// pre-selection). Installation steps appear only if a product requires one.
// Launched from an existing Issue → start at step 0 so the agent can pick
// a project template aligned with the ticket.
currentStep.value = fromCustomer ? 2 : 0
}
})
</script>
<style scoped>
.wizard-step-dot {
flex: 1; display: flex; flex-direction: column; align-items: center;
padding: 8px 4px; cursor: default; opacity: 0.4; transition: opacity 0.2s;
}
.wizard-step-dot.active, .wizard-step-dot.done { opacity: 1; }
.wizard-step-dot.done { cursor: pointer; }
.wizard-step-num {
width: 24px; height: 24px; border-radius: 50%; background: #e2e8f0; color: #475569;
display: flex; align-items: center; justify-content: center; font-size: 0.75rem; font-weight: 700;
}
.wizard-step-dot.active .wizard-step-num { background: #6366f1; color: #fff; }
.wizard-step-dot.done .wizard-step-num { background: #10b981; color: #fff; }
.wizard-step-dot.skipped { opacity: 0.3; }
.wizard-step-dot.skipped .wizard-step-num { background: #f1f5f9; color: #94a3b8; }
.wizard-step-label { font-size: 0.7rem; color: #6b7280; margin-top: 2px; }
.quick-sale-card {
display: flex; align-items: center; gap: 12px;
padding: 14px 16px; border: 2px solid #c7d2fe; border-radius: 12px;
background: linear-gradient(135deg, #eef2ff 0%, #ffffff 100%);
cursor: pointer; transition: all 0.15s;
}
.quick-sale-card:hover { border-color: #6366f1; box-shadow: 0 4px 12px rgba(99,102,241,0.18); transform: translateY(-1px); }
.quick-sale-card.selected { border-color: #6366f1; background: #eef2ff; }
.quick-sale-icon {
width: 46px; height: 46px; border-radius: 12px;
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
box-shadow: 0 4px 10px rgba(99,102,241,0.3);
}
.quick-sale-title { font-weight: 700; font-size: 0.92rem; color: #1e293b; }
.quick-sale-desc { font-size: 0.76rem; color: #64748b; margin-top: 2px; line-height: 1.35; }
.quick-sale-notice {
display: flex; align-items: flex-start; padding: 12px 14px;
background: #eef2ff; border: 1px solid #c7d2fe; border-radius: 10px;
margin-bottom: 8px;
}
.merge-notice {
display: flex; align-items: flex-start; padding: 12px 14px;
background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 10px;
margin-bottom: 8px;
}
.bundles-summary {
border: 1px solid #c7d2fe; background: #eef2ff; border-radius: 10px;
padding: 10px 12px; margin-bottom: 10px;
}
.bundles-summary-header {
display: flex; align-items: center; gap: 8px; margin-bottom: 8px;
}
.bundles-summary-title {
font-weight: 700; font-size: 0.85rem; color: #3730a3;
display: flex; align-items: center; gap: 8px;
}
.bundles-summary-count {
background: #c7d2fe; color: #3730a3; font-weight: 600;
padding: 2px 8px; border-radius: 10px; font-size: 0.72rem;
}
.bundles-summary-list { display: flex; flex-direction: column; gap: 6px; }
.bundle-item {
background: #ffffff; border: 1px solid #e2e8f0; border-radius: 8px;
overflow: hidden;
}
.bundle-item-header {
display: flex; align-items: center; gap: 10px; padding: 8px 10px;
cursor: pointer; transition: background 0.15s;
}
.bundle-item-header:hover { background: #f8fafc; }
.bundle-item-name { flex: 1; min-width: 0; }
.bundle-item-steps {
border-top: 1px solid #e2e8f0; padding: 6px 10px 8px 34px;
background: #fafbfc; display: flex; flex-direction: column; gap: 4px;
}
.bundle-step-row {
display: flex; align-items: center; gap: 8px;
font-size: 0.78rem; color: #334155;
}
.bundle-step-subject { flex: 1; }
.bundle-add-step {
display: flex; align-items: center; gap: 6px; cursor: pointer;
padding: 6px 8px; margin-top: 2px; border-radius: 6px;
color: #4f46e5; font-size: 0.76rem; font-weight: 600;
border: 1px dashed #c7d2fe; background: #ffffff;
transition: background 0.15s;
}
.bundle-add-step:hover { background: #eef2ff; }
.pricing-compact {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
background: #f0fdfa; border: 1px solid #99f6e4; border-radius: 8px;
padding: 6px 10px; margin-bottom: 8px;
}
.pricing-compact-label { font-weight: 600; color: #115e59; font-size: 0.78rem; }
.pricing-area-badge {
background: #ccfbf1; color: #115e59; font-weight: 600;
padding: 2px 8px; border-radius: 10px; font-size: 0.7rem;
}
.pricing-area-default { background: #fef3c7; color: #92400e; }
.bundle-step-install { background: #f0fdfa; border-radius: 6px; padding: 4px 6px; }
.install-step-panel {
background: #f0fdfa; border: 1px solid #99f6e4; border-radius: 8px;
margin: 4px 0 4px 26px; padding: 10px 12px;
display: flex; flex-direction: column; gap: 8px;
}
.install-step-area-header {
display: flex; align-items: center; gap: 6px;
padding-bottom: 6px; border-bottom: 1px solid #ccfbf1;
}
.install-step-area-name { font-weight: 700; color: #115e59; font-size: 0.8rem; }
.install-step-area-meta { font-size: 0.72rem; color: #475569; }
.install-step-row { display: flex; align-items: center; gap: 10px; }
.install-step-row-label { font-size: 0.8rem; color: #0f172a; font-weight: 600; flex: 1; }
.install-step-extras { background: #ffffff; border: 1px solid #ccfbf1; border-radius: 6px; padding: 6px 8px; }
.install-step-extras-head { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.install-step-extras-empty { font-size: 0.74rem; color: #64748b; font-style: italic; padding: 3px 0; }
.install-step-extra-row {
display: flex; align-items: center; gap: 8px; padding: 5px 0;
border-top: 1px solid #f1f5f9;
}
.install-step-extra-row:first-of-type { border-top: 0; }
.install-step-extra-text { flex: 1; min-width: 0; }
.install-step-extra-amount { font-weight: 700; color: #0f766e; font-size: 0.8rem; }
.install-step-total {
display: flex; align-items: center; gap: 8px;
background: #ccfbf1; border-radius: 6px; padding: 5px 8px;
}
.template-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 10px; }
.template-card {
border: 2px solid #e2e8f0; border-radius: 10px; padding: 14px;
cursor: pointer; transition: all 0.15s; text-align: center;
}
.template-card:hover { border-color: #6366f1; background: #f5f3ff; }
.template-card.selected { border-color: #6366f1; background: #eef2ff; }
.template-card-name { font-weight: 700; font-size: 0.85rem; margin-top: 6px; color: #1e293b; }
.template-card-desc { font-size: 0.75rem; color: #6b7280; margin-top: 2px; }
.steps-list { display: flex; flex-direction: column; gap: 10px; }
.step-card {
border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 12px;
background: #fff; transition: border-color 0.15s;
}
.step-card:hover { border-color: #6366f1; }
.step-num {
width: 26px; height: 26px; border-radius: 50%; background: #6366f1; color: #fff;
font-weight: 700; font-size: 0.8rem; display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.step-num.small { width: 22px; height: 22px; font-size: 0.7rem; }
.step-dep-indicator {
display: flex; align-items: center; gap: 4px;
margin-top: 4px; padding: 2px 6px; background: #fff7ed; border-radius: 4px;
}
.review-tree { display: flex; flex-direction: column; }
.review-step-connector { text-align: center; padding: 0; line-height: 1; }
.review-step-card { border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 12px; background: #fff; }
.review-step-card.has-dep { border-left: 3px solid #f59e0b; }
.catalog-picker { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 10px 12px; }
.catalog-open-btn { border-radius: 8px; padding: 4px 14px; font-weight: 600; letter-spacing: 0.2px; }
.catalog-modal {
width: 560px; max-width: 95vw; height: 80vh; max-height: 720px;
display: flex; flex-direction: column; border-radius: 14px; overflow: hidden;
}
.catalog-modal-header {
padding: 14px 16px 10px; border-bottom: 1px solid #e2e8f0;
background: linear-gradient(180deg, #ffffff, #f8fafc);
}
.catalog-search :deep(.q-field__control) { border-radius: 10px; }
.catalog-cat-row {
display: flex; gap: 6px; overflow-x: auto; margin-top: 10px;
padding-bottom: 4px; scrollbar-width: none;
}
.catalog-cat-row::-webkit-scrollbar { display: none; }
.catalog-cat-chip {
display: inline-flex; align-items: center; flex-shrink: 0;
padding: 5px 12px; border-radius: 999px; background: #f1f5f9; color: #475569;
font-size: 0.78rem; font-weight: 500; cursor: pointer; transition: all 0.15s;
white-space: nowrap;
}
.catalog-cat-chip:hover { background: #e2e8f0; }
.catalog-cat-chip.active { background: #6366f1; color: #fff; box-shadow: 0 2px 6px rgba(99,102,241,0.3); }
.catalog-modal-body { padding: 10px 12px; background: #fafbfc; }
.catalog-list { display: flex; flex-direction: column; gap: 8px; padding-bottom: 8px; }
.catalog-card {
display: flex; align-items: center; gap: 12px;
padding: 10px 12px; background: #fff; border: 1px solid #e2e8f0;
border-radius: 12px; cursor: pointer; transition: all 0.15s;
position: relative;
}
.catalog-card:hover { border-color: #6366f1; box-shadow: 0 2px 8px rgba(99,102,241,0.12); transform: translateY(-1px); }
.catalog-card:active { transform: translateY(0); }
.catalog-card.just-added {
animation: catalogPulse 0.9s ease-out;
}
@keyframes catalogPulse {
0% { background: #fff; }
30% { background: #dcfce7; border-color: #10b981; box-shadow: 0 0 0 4px rgba(16,185,129,0.2); }
100% { background: #fff; }
}
.catalog-card-icon {
width: 42px; height: 42px; border-radius: 10px;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.catalog-card-body { flex: 1; min-width: 0; }
.catalog-card-name {
font-size: 0.88rem; font-weight: 600; color: #1e293b;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.catalog-card-meta { display: flex; align-items: center; gap: 6px; margin-top: 2px; }
.catalog-card-code { font-size: 0.7rem; color: #94a3b8; font-family: ui-monospace, monospace; }
.catalog-card-chip { height: 18px; font-size: 0.65rem !important; }
.catalog-card-price { text-align: right; flex-shrink: 0; }
.catalog-card-rate { font-size: 0.95rem; font-weight: 700; color: #1e293b; }
.catalog-card-billing { font-size: 0.68rem; font-weight: 500; margin-top: -2px; }
.catalog-card-billing.recurring { color: #ea580c; }
.catalog-card-billing.onetime { color: #64748b; }
.catalog-card-add { flex-shrink: 0; }
.catalog-modal-footer {
padding: 10px 14px; border-top: 1px solid #e2e8f0; background: #fff;
display: flex; align-items: center; justify-content: space-between; gap: 10px;
}
.catalog-footer-counter { font-size: 0.8rem; color: #475569; display: flex; align-items: center; }
.catalog-footer-added { margin-left: 4px; }
.catalog-loading, .catalog-empty {
display: flex; flex-direction: column; align-items: center; justify-content: center;
padding: 60px 20px; text-align: center;
}
@media (max-width: 599px) {
.catalog-modal { border-radius: 0; height: 100vh; max-height: 100vh; width: 100vw; max-width: 100vw; }
}
.presets-row { background: #fefce8; border: 1px solid #fde68a; border-radius: 10px; padding: 8px 10px; }
.preset-hero {
display: flex; align-items: center; gap: 12px;
padding: 12px 14px; border: 1.5px solid #c7d2fe; border-radius: 10px;
background: linear-gradient(135deg, #ffffff 0%, #eef2ff 100%);
cursor: pointer; transition: all 0.15s;
}
.preset-hero:hover {
border-color: #6366f1; box-shadow: 0 2px 10px rgba(99, 102, 241, 0.18);
transform: translateY(-1px);
}
.preset-hero.preset-hero-added {
border-color: #10b981;
background: linear-gradient(135deg, #ffffff 0%, #ecfdf5 100%);
}
.preset-hero-icon {
width: 44px; height: 44px; border-radius: 10px;
background: #fff; border: 1px solid #e2e8f0;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.preset-hero-text { flex: 1; min-width: 0; }
.preset-hero-label { font-size: 0.95rem; font-weight: 700; color: #1e293b; }
.preset-hero-desc { font-size: 0.75rem; color: #64748b; margin-top: 2px; }
.preset-hero-price { text-align: right; line-height: 1.1; }
.preset-hero-price-tag { font-size: 0.66rem; color: #64748b; text-transform: uppercase; letter-spacing: 0.4px; }
.preset-hero-price-big { font-size: 1.35rem; font-weight: 800; color: #3730a3; }
.preset-hero-price-unit { font-size: 0.7rem; font-weight: 600; color: #64748b; margin-left: 2px; }
.preset-hero-price-strike {
font-size: 0.7rem; color: #94a3b8; text-decoration: line-through; margin-top: 1px;
}
.preset-hero-group { position: relative; }
.preset-hero-label-tier { color: #3730a3; }
.preset-hero-action { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.preset-hero-chevron { transition: transform 0.18s; }
.preset-hero-chevron.rotated { transform: rotate(180deg); }
.tier-expand {
display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 8px; margin-top: 8px;
padding: 10px; border: 1px solid #e0e7ff; border-radius: 10px;
background: #fafbff;
animation: tier-fade-in 0.18s ease-out;
}
@keyframes tier-fade-in {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.tier-card {
position: relative; padding: 10px 12px; border: 1.5px solid #e2e8f0;
border-radius: 8px; background: #fff; cursor: pointer; transition: all 0.15s;
display: flex; flex-direction: column; gap: 4px;
}
.tier-card:hover {
border-color: #6366f1; box-shadow: 0 2px 8px rgba(99, 102, 241, 0.18);
transform: translateY(-1px);
}
.tier-card.tier-card-active {
border-color: #10b981; background: linear-gradient(135deg, #ffffff 0%, #ecfdf5 100%);
}
.tier-card-head { display: flex; align-items: center; gap: 6px; }
.tier-card-title { font-size: 0.88rem; font-weight: 700; color: #1e293b; flex: 1; }
.tier-card-badge { font-size: 0.6rem; padding: 2px 6px; }
.tier-card-speed { font-size: 0.72rem; color: #475569; font-weight: 600; }
.tier-card-desc { font-size: 0.7rem; color: #64748b; line-height: 1.25; }
.tier-card-price { display: flex; align-items: baseline; gap: 4px; margin-top: 4px; }
.tier-card-price-big { font-size: 1.05rem; font-weight: 800; color: #3730a3; }
.tier-card-price-unit { font-size: 0.65rem; color: #64748b; font-weight: 600; }
.tier-card-price-strike {
font-size: 0.65rem; color: #94a3b8; text-decoration: line-through; margin-left: 4px;
}
.tier-card-check { position: absolute; top: 8px; right: 8px; }
.preset-upsell {
display: flex; align-items: center; gap: 8px;
padding: 6px 10px; border: 1px solid #e2e8f0; border-radius: 8px;
background: #fff; cursor: pointer; transition: all 0.15s;
flex: 1; min-width: 150px;
}
.preset-upsell:hover { border-color: #6366f1; background: #eef2ff; box-shadow: 0 1px 4px rgba(99, 102, 241, 0.15); }
.preset-upsell.preset-added { border-color: #10b981; background: #f0fdf4; }
.preset-upsell.tier-addon { border-style: dashed; }
.preset-upsell-body { min-width: 0; flex: 1; line-height: 1.15; }
.preset-upsell-delta { font-size: 0.82rem; font-weight: 700; color: #3730a3; }
.preset-upsell-mo { font-size: 0.65rem; font-weight: 600; color: #64748b; margin-left: 2px; }
.preset-upsell-label { font-size: 0.72rem; color: #475569; }
.preset-upsell.preset-added .preset-upsell-delta { color: #047857; }
.referral-row {
display: flex; align-items: center; gap: 8px;
padding: 8px 10px; border: 1px dashed #c4b5fd; border-radius: 8px;
background: #faf5ff;
}
.referral-title { font-size: 0.8rem; font-weight: 600; color: #4c1d95; }
.referral-desc { font-size: 0.7rem; color: #6b7280; }
.referral-error { font-size: 0.72rem; color: #b91c1c; margin-top: 4px; padding-left: 4px; }
.catalog-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 6px; max-height: 220px; overflow-y: auto;
}
.catalog-item {
border: 1px solid #e2e8f0; border-radius: 8px; padding: 8px 10px;
background: #fff; cursor: pointer; transition: all 0.15s;
}
.catalog-item:hover { border-color: #6366f1; background: #f5f3ff; box-shadow: 0 1px 4px rgba(99, 102, 241, 0.12); }
.item-line { border: 1px solid #e2e8f0; border-radius: 8px; padding: 8px 10px; margin-bottom: 6px; background: #fafbfc; }
.billing-pill {
display: inline-flex; align-items: center;
border: 1px solid #e2e8f0; border-radius: 999px;
background: #fff; padding: 2px 8px; font-size: 0.7rem; font-weight: 700;
color: #475569; cursor: pointer; transition: all 0.15s;
font-family: inherit; line-height: 1.2;
}
.billing-pill:hover { border-color: #6366f1; background: #eef2ff; }
.billing-pill.recurring { background: #fff7ed; border-color: #fed7aa; color: #c2410c; }
.billing-pill.recurring:hover { background: #ffedd5; border-color: #fb923c; }
.billing-pill.yearly { background: #ecfdf5; border-color: #a7f3d0; color: #047857; }
.billing-pill.yearly:hover { background: #d1fae5; border-color: #34d399; }
.billing-pill.onetime { background: #eef2ff; border-color: #c7d2fe; color: #3730a3; }
.billing-pill.onetime:hover { background: #e0e7ff; border-color: #818cf8; }
.item-steps-wrap { display: flex; flex-direction: column; gap: 4px; }
.item-steps-pill {
align-self: flex-start;
display: inline-flex; align-items: center;
padding: 3px 10px; border-radius: 999px;
background: #eef2ff; border: 1px solid #c7d2fe;
color: #3730a3; font-size: 0.72rem; font-weight: 600;
cursor: pointer; transition: all 0.15s; font-family: inherit;
}
.item-steps-pill:hover { background: #e0e7ff; border-color: #6366f1; }
.item-steps-pill.expanded { background: #6366f1; border-color: #4f46e5; color: #fff; }
.item-steps-panel {
border: 1px solid #e2e8f0; border-radius: 8px;
background: #ffffff; padding: 6px 10px;
display: flex; flex-direction: column; gap: 4px;
margin-top: 2px;
}
.item-steps-panel .bundle-step-row {
display: flex; align-items: center; gap: 8px;
font-size: 0.78rem; color: #334155; padding: 2px 0;
}
.item-steps-panel .bundle-step-subject { flex: 1; min-width: 0; }
.step-extra-chip {
display: inline-flex; align-items: center; gap: 4px;
padding: 3px 8px; border-radius: 999px;
background: #fff7ed; border: 1px dashed #fed7aa;
color: #9a3412; font-size: 0.72rem; font-weight: 600;
cursor: pointer; font-family: inherit;
transition: background 0.15s, border-color 0.15s;
max-width: 240px;
}
.step-extra-chip:hover { background: #ffedd5; border-color: #fb923c; }
.step-extra-chip-set {
background: #ffedd5; border-style: solid; border-color: #fb923c;
}
.step-extra-chip-label { max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.step-extra-chip-amount { font-weight: 700; color: #c2410c; margin-left: 2px; }
.step-extra-chip-placeholder { color: #b45309; }
.step-extra-menu { min-width: 300px; padding: 8px 10px; }
.step-extra-menu-head {
font-size: 0.72rem; font-weight: 700; color: #9a3412;
text-transform: uppercase; letter-spacing: 0.05em; padding: 2px 4px 6px;
}
.step-extra-menu-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 4px;
}
.step-extra-preset {
display: flex; align-items: center; justify-content: space-between;
padding: 5px 8px; border-radius: 6px; cursor: pointer; font-family: inherit;
background: #fff7ed; border: 1px solid #fed7aa; color: #7c2d12;
font-size: 0.75rem; text-align: left; gap: 6px;
transition: background 0.15s, border-color 0.15s;
}
.step-extra-preset:hover { background: #ffedd5; border-color: #fb923c; }
.step-extra-preset.active {
background: #fb923c; border-color: #ea580c; color: #fff;
}
.step-extra-preset.active .step-extra-preset-amount { color: #fff; }
.step-extra-preset-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.step-extra-preset-amount { font-weight: 700; color: #c2410c; font-size: 0.72rem; }
.step-extra-custom { padding: 4px; }
.step-extra-custom-title {
font-size: 0.72rem; font-weight: 700; color: #64748b;
text-transform: uppercase; letter-spacing: 0.05em;
}
.step-extra-remove {
display: flex; align-items: center; gap: 4px; width: 100%;
padding: 5px 8px; border-radius: 6px; cursor: pointer; font-family: inherit;
background: transparent; border: none; color: #dc2626;
font-size: 0.75rem; font-weight: 600;
}
.step-extra-remove:hover { background: #fee2e2; }
.item-steps-panel .bundle-add-step {
display: flex; align-items: center; gap: 6px; cursor: pointer;
padding: 4px 8px; margin-top: 2px; border-radius: 6px;
color: #4f46e5; font-size: 0.74rem; font-weight: 600;
border: 1px dashed #c7d2fe; background: #ffffff;
transition: background 0.15s; font-family: inherit;
}
.item-steps-panel .bundle-add-step:hover { background: #eef2ff; }
.catalog-bound :deep(.q-field__control) { cursor: pointer; }
.catalog-bound :deep(.q-field__control):hover { border-color: #6366f1; background: #f5f3ff; }
.catalog-bound :deep(input) { cursor: pointer !important; color: #1e293b; font-weight: 600; }
.totals-box { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 12px; }
/* TV channel picker dialog */
.channel-picker-card {
width: 560px; max-width: 96vw;
display: flex; flex-direction: column; max-height: 90vh;
}
.channel-picker-head { border-bottom: 1px solid #f1f5f9; }
.channel-picker-sub {
font-size: 0.78rem; color: #64748b; margin-top: 2px;
}
.channel-picker-overage {
color: #ea580c; font-weight: 600; margin-left: 4px;
}
.channel-picker-chips {
display: flex; flex-wrap: wrap; gap: 6px;
border-bottom: 1px solid #f1f5f9; padding-top: 6px; padding-bottom: 10px;
}
.channel-chip {
position: relative; padding-right: 32px !important;
font-size: 0.78rem; font-weight: 600;
}
.channel-chip-pastille {
position: absolute; top: -4px; right: 4px;
min-width: 16px; height: 16px; font-size: 0.62rem;
padding: 0 4px; font-weight: 700;
}
.channel-chip-name { max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.channel-picker-list {
flex: 1; overflow-y: auto; padding: 4px 0;
display: flex; flex-direction: column; gap: 2px;
}
.channel-row {
display: flex; align-items: center; gap: 10px;
padding: 8px 16px; border-radius: 0; cursor: pointer;
transition: background 0.12s;
}
.channel-row:hover { background: #f8fafc; }
.channel-row-selected { background: #ecfdf5; }
.channel-row-selected:hover { background: #d1fae5; }
.channel-row-premium .channel-row-name { color: #9d174d; }
.channel-row-name {
font-size: 0.85rem; font-weight: 600; color: #1e293b;
display: flex; align-items: center; gap: 4px;
}
.channel-row-sub {
font-size: 0.72rem; color: #94a3b8; margin-top: 1px;
}
.channel-row-surcharge {
font-size: 0.78rem; font-weight: 700; color: #be185d;
}
.channel-row-empty {
padding: 24px; text-align: center; color: #94a3b8; font-size: 0.85rem;
}
.channel-picker-foot { border-top: 1px solid #f1f5f9; }
/* Tier segmented toggle inside picker */
.channel-picker-tiers {
display: flex; gap: 8px;
border-bottom: 1px solid #f1f5f9;
}
.channel-picker-tier {
flex: 1; padding: 8px 10px; border-radius: 8px;
border: 1px solid #e5e7eb; background: #fafafa;
cursor: pointer; transition: all 0.15s;
display: flex; flex-direction: column; gap: 2px;
}
.channel-picker-tier:hover { border-color: #f472b6; background: #fdf2f8; }
.channel-picker-tier-active {
border-color: #ec4899; background: #fdf2f8;
box-shadow: 0 0 0 2px #fbcfe8;
}
.channel-picker-tier-label { font-size: 0.84rem; font-weight: 700; color: #1e293b; }
.channel-picker-tier-price { font-size: 0.82rem; font-weight: 700; color: #be185d; }
.channel-picker-tier-meta { font-size: 0.7rem; color: #64748b; }
/* TV group gated behind Internet */
.preset-hero-group-disabled .preset-hero {
background: #f8fafc; border-color: #e5e7eb; opacity: 0.65;
}
.preset-hero-group-disabled .preset-hero:hover { background: #f1f5f9; }
.preset-hero-group-disabled .preset-hero-label::after {
content: ' — Internet requis'; color: #9ca3af; font-weight: 400; font-size: 0.78rem;
}
/* Telephony hero group + port-in input */
.preset-hero-phone { border-color: #14b8a6; }
.preset-hero-phone:hover { background: #f0fdfa; }
.preset-hero-label-phone { color: #0f766e; }
.preset-hero-price-phone { color: #0f766e; }
.tier-expand-phone .tier-card:hover { border-color: #14b8a6; background: #f0fdfa; }
.tier-card-keep-input {
margin-top: 8px; padding-top: 8px;
border-top: 1px dashed #e5e7eb;
}
/* ── Wizard step blocks (Étape 1 Internet, Étape 2 Services additionnels) ── */
.wizard-step-block {
border: 1px solid #e2e8f0; border-radius: 10px;
background: #fefefe; padding: 10px 12px;
}
.wizard-step-block-required {
background: linear-gradient(180deg, #fefce8 0%, #ffffff 50%);
border-color: #fde68a;
}
.wizard-step-header {
display: flex; align-items: flex-start; gap: 10px; margin-bottom: 8px;
}
.wizard-step-header-clickable {
cursor: pointer;
padding: 2px;
border-radius: 8px;
margin: -2px -2px 6px -2px;
transition: background 0.15s;
}
.wizard-step-header-clickable:hover {
background: rgba(99, 102, 241, 0.05);
}
.wizard-step-action {
display: flex; align-items: center; gap: 6px;
flex-shrink: 0; padding-top: 2px;
}
.wizard-step-block-expanded { box-shadow: 0 1px 4px rgba(99, 102, 241, 0.08); }
.wizard-step-number {
flex-shrink: 0;
width: 26px; height: 26px; border-radius: 50%;
background: #6366f1; color: #fff;
display: flex; align-items: center; justify-content: center;
font-size: 0.85rem; font-weight: 800;
box-shadow: 0 1px 3px rgba(99, 102, 241, 0.35);
}
.wizard-step-number-optional {
background: #94a3b8;
box-shadow: 0 1px 3px rgba(148, 163, 184, 0.35);
}
.wizard-step-headline { flex: 1; min-width: 0; }
.wizard-step-title {
font-size: 0.92rem; font-weight: 800; color: #1e293b; line-height: 1.3;
}
.wizard-step-title-note {
font-weight: 500; color: #64748b; font-size: 0.8rem;
}
.wizard-step-summary {
font-size: 0.76rem; color: #475569; margin-top: 2px;
line-height: 1.35;
}
.wizard-step-summary-chips { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
.wizard-step-chip {
display: inline-flex; align-items: center;
padding: 2px 8px; border-radius: 999px;
border: 1px solid transparent;
font-size: 0.72rem; font-weight: 600;
background: #eef2ff; color: #3730a3; border-color: #c7d2fe;
}
.wizard-step-chip-tv { background: #fdf2f8; color: #9d174d; border-color: #fbcfe8; }
.wizard-step-chip-phone { background: #f0fdfa; color: #0f766e; border-color: #99f6e4; }
.wizard-step-chip-upsell { background: #ecfeff; color: #155e75; border-color: #a5f3fc; }
/* ── Internet tier list (accordion content) ── */
.tier-list {
display: flex; flex-direction: column; gap: 6px;
animation: tier-fade-in 0.18s ease-out;
}
.tier-row {
display: flex; align-items: center; gap: 12px;
padding: 10px 12px;
border: 1.5px solid #e2e8f0; border-radius: 10px;
background: #fff; cursor: pointer; transition: all 0.15s;
}
.tier-row:hover {
border-color: #6366f1; background: #f5f3ff;
box-shadow: 0 1px 4px rgba(99, 102, 241, 0.18);
}
.tier-row-active {
border-color: #10b981;
background: linear-gradient(90deg, #ecfdf5 0%, #ffffff 60%);
box-shadow: 0 1px 4px rgba(16, 185, 129, 0.15);
}
.tier-row-icon {
flex-shrink: 0;
width: 40px; height: 40px; border-radius: 8px;
background: #fff; border: 1px solid #e2e8f0;
display: flex; align-items: center; justify-content: center;
}
.tier-row-active .tier-row-icon { border-color: #86efac; background: #ecfdf5; }
.tier-row-body { flex: 1; min-width: 0; }
.tier-row-head {
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
}
.tier-row-title { font-size: 0.92rem; font-weight: 700; color: #1e293b; }
.tier-row-badge { font-size: 0.6rem; padding: 2px 6px; }
.tier-row-speed { font-size: 0.78rem; color: #475569; font-weight: 600; }
.tier-row-desc { font-size: 0.72rem; color: #64748b; line-height: 1.3; margin-top: 2px; }
.tier-row-price { text-align: right; flex-shrink: 0; line-height: 1.1; }
.tier-row-price-big { font-size: 1.05rem; font-weight: 800; color: #3730a3; }
.tier-row-active .tier-row-price-big { color: #047857; }
.tier-row-price-unit { font-size: 0.68rem; font-weight: 600; color: #64748b; margin-left: 2px; }
.tier-row-price-strike {
font-size: 0.68rem; color: #94a3b8; text-decoration: line-through; margin-top: 1px;
}
.tier-row-select { flex-shrink: 0; }
@media (max-width: 480px) {
.tier-row { padding: 8px 10px; gap: 8px; }
.tier-row-icon { width: 32px; height: 32px; }
.tier-row-title { font-size: 0.86rem; }
.tier-row-desc { display: none; }
.tier-row-price-big { font-size: 0.95rem; }
}
/* ── Cart pill (Step 2 → Sommaire handoff) ─────────────────────────────
Sticky summary at the top of the Items/Devis scroll area. Combines
item count + running totals + taxes into a single strip; details live
on the Sommaire step (click → navigate). */
.cart-pill {
display: flex; align-items: center; gap: 12px;
padding: 10px 14px;
border: 1.5px solid #c7d2fe; border-radius: 12px;
background: linear-gradient(90deg, #eef2ff 0%, #ffffff 70%);
cursor: pointer; transition: all 0.15s;
position: relative;
}
.cart-pill:hover { border-color: #6366f1; box-shadow: 0 1px 6px rgba(99, 102, 241, 0.25); }
.cart-pill-empty {
border-color: #e2e8f0; background: #f8fafc; cursor: default;
}
.cart-pill-empty:hover { border-color: #e2e8f0; box-shadow: none; }
/* Sticky variant — stays pinned at the top of the card section so the
running tally is always visible while scrolling through pickers. */
.cart-pill-sticky {
position: sticky; top: 0; z-index: 5;
/* Slightly stronger shadow to float above pickers below. */
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08);
background: linear-gradient(90deg, #eef2ff 0%, #f5f3ff 100%);
backdrop-filter: saturate(1.1);
margin-bottom: 10px;
}
.cart-pill-sticky.cart-pill-empty {
background: #f8fafc;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
}
.cart-pill-left { position: relative; flex-shrink: 0; padding: 2px; }
.cart-pill-badge { font-size: 0.65rem; padding: 1px 5px; min-height: 16px; }
.cart-pill-body { flex: 1; min-width: 0; }
.cart-pill-title { font-size: 0.88rem; font-weight: 800; color: #1e293b; line-height: 1.2; }
.cart-pill-empty .cart-pill-title { color: #64748b; font-weight: 700; }
.cart-pill-sub { font-size: 0.74rem; line-height: 1.3; margin-top: 2px; }
.cart-pill-totals {
text-align: right; line-height: 1.15; flex-shrink: 0;
border-left: 1px solid #e0e7ff; padding-left: 10px;
}
.cart-pill-total-line { font-size: 0.88rem; font-weight: 800; color: #3730a3; }
.cart-pill-total-recurring { color: #c2410c; }
.cart-pill-mo { font-size: 0.62rem; font-weight: 600; margin-left: 1px; }
.cart-pill-tax { font-size: 0.66rem; color: #64748b; margin-top: 1px; }
/* ── Sommaire step (cart detail) ───────────────────────────────────────
Each row collapses to a compact summary; clicking expands an inline
editor. Drag handle on the left reorders the list. */
.sommaire-empty {
text-align: center; padding: 30px 16px;
border: 2px dashed #e2e8f0; border-radius: 10px; background: #f8fafc;
}
.sommaire-list { display: flex; flex-direction: column; gap: 6px; }
.sommaire-row {
border: 1.5px solid #e2e8f0; border-radius: 10px;
background: #fff; transition: all 0.15s;
overflow: hidden;
}
.sommaire-row:hover { border-color: #c7d2fe; }
.sommaire-row-expanded { border-color: #6366f1; box-shadow: 0 1px 6px rgba(99, 102, 241, 0.18); }
.sommaire-row-dragging { opacity: 0.4; }
.sommaire-row-drop-target {
border-color: #10b981; border-style: dashed;
background: linear-gradient(90deg, #ecfdf5 0%, #ffffff 60%);
}
.sommaire-head {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; cursor: pointer;
}
.sommaire-handle {
cursor: grab; padding: 2px; touch-action: none;
display: flex; align-items: center;
}
.sommaire-handle:active { cursor: grabbing; }
.sommaire-badge {
flex-shrink: 0; width: 22px; height: 22px; border-radius: 50%;
background: #6366f1; color: #fff; font-size: 0.72rem; font-weight: 800;
display: flex; align-items: center; justify-content: center;
}
.sommaire-type-icon { flex-shrink: 0; }
.sommaire-main { flex: 1; min-width: 0; }
.sommaire-name {
font-size: 0.88rem; font-weight: 700; color: #1e293b;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.sommaire-meta {
display: flex; align-items: center; gap: 4px; flex-wrap: wrap;
font-size: 0.74rem; color: #475569; margin-top: 1px;
}
.sommaire-sep { color: #cbd5e1; }
.sommaire-body {
padding: 8px 12px 10px 12px;
border-top: 1px dashed #e2e8f0;
background: #f8fafc;
}
@media (max-width: 480px) {
.cart-pill { padding: 8px 10px; gap: 8px; }
.cart-pill-title { font-size: 0.82rem; }
.cart-pill-sub { font-size: 0.7rem; }
.cart-pill-total-line { font-size: 0.8rem; }
.cart-pill-tax { font-size: 0.62rem; }
.cart-pill-totals { padding-left: 6px; }
.sommaire-head { padding: 6px 8px; gap: 8px; }
.sommaire-name { font-size: 0.82rem; }
.sommaire-meta { font-size: 0.7rem; }
.sommaire-badge { width: 20px; height: 20px; font-size: 0.66rem; }
}
</style>