gigafibre-fsm/apps/ops/src/components/shared/ProjectWizard.vue
louispaulb 5ab14cac44 feat(fsm): platform build — comms UI, F→ERPNext sync/billing, roster, campaigns, network, reports
Accumulated work on the dispatch/legacy-writeback branch:
- Communications UI: CommunicationsPage, ConversationFullPage, DepartmentBoard,
  PipelineBoard, ReaderStack, Orchestrator/NewTicket/ServiceStatus/Outbox dialogs;
  hub gmail.js, ticket-collab.js, outbox.js, coupon-triage.js, client-diag.js.
- Billing/sync mirror (F→ERPNext): legacy-payments.js, legacy-sync.js,
  sync-orchestrator.js, supplier-invoices.js, municipality.js + incremental
  migration scripts; LegacySyncPage, SupplierInvoices + negative-billing /
  terminated-active reports.
- Roster/campaigns/network/voice: roster + roster-assistant, campaigns, giftbit,
  olt-snmp, traccar, twilio, vision, tech-absence-sms, ai/agent/config/helpers,
  legacy-dispatch-sync; ops PlanificationPage, RapportsPage, Settings, Tickets,
  ClientDetail updates.
- docs/ PLATFORM_GUIDE + UI_AND_OPTIMIZATION; .gitignore __pycache__.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 06:12:12 -04:00

2895 lines
134 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>
<!-- Installation financée — créance réelle créditée chaque mois (conforme CRTC, pas de clawback) -->
<div class="referral-row q-mt-md" style="border-color:#bde5cb;background:#f4fff6">
<q-icon name="construction" size="18px" color="green-7" class="q-mr-sm" />
<div class="col" style="min-width:0">
<div class="referral-title" style="color:#00733a">Installation</div>
<div class="referral-desc">Financée sur {{ maxContractMonths }} mois, créditée chaque mois — 0&nbsp;$ net pour le client tant qu'abonné</div>
</div>
<div class="row q-gutter-xs items-center no-wrap">
<q-btn v-for="p in INSTALL_PRESETS" :key="p.key" dense no-caps size="sm"
:outline="!isInstallPreset(p)" :unelevated="isInstallPreset(p)"
color="green-7" :text-color="isInstallPreset(p) ? 'white' : 'green-8'"
:label="p.label" @click="setInstallPreset(p)" />
</div>
</div>
<div v-if="installFee > 0 || installRegular > 0" class="row q-col-gutter-sm q-mt-xs items-center">
<div class="col-4">
<q-input v-model.number="installRegular" dense outlined type="number" step="0.01" min="0"
label="Prix original" :input-style="{ fontSize: '0.78rem' }"
title="Prix barré affiché au client (0 = aucun barré)">
<template v-slot:append><q-icon name="local_offer" size="13px" color="orange-7" /></template>
</q-input>
</div>
<div class="col-4">
<q-input v-model.number="installFee" dense outlined type="number" step="0.01" min="0"
label="Prix financé $" :input-style="{ fontSize: '0.78rem' }"
title="Montant réellement financé/dû (créance). 0 = installation gratuite" />
</div>
<div v-if="installFee > 0" class="col-4" style="font-size:0.76rem">
<div v-if="installRegular > installFee">
<s style="color:#9ca3af">{{ Number(installRegular).toFixed(2) }}$</s>
<b class="text-green-8 q-ml-xs">{{ Number(installFee).toFixed(2) }}$</b>
</div>
<div class="text-grey-7">+{{ (Number(installFee) / (maxContractMonths || 24)).toFixed(2) }}$/mois · crédité → <b class="text-green-8">0$</b></div>
</div>
</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.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) }}${{ item.billing === 'recurring' ? '/mois' : '' }}
</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="item.billing === 'recurring' ? 'col-2' : '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="item.billing === 'recurring' ? 'col-4' : 'col-5'">
<q-input v-model.number="item.rate" dense outlined type="number" step="0.01"
label="Prix marketing $" :input-style="{ fontSize: '0.82rem' }"
title="Prix réellement facturé / affiché au client">
<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 v-if="item.billing === 'recurring'" class="col-3">
<q-input 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' }" />
</div>
<div :class="item.billing === 'recurring' ? 'col-3' : 'col-4'">
<q-input v-model.number="item.regular_price" dense outlined type="number" step="0.01"
label="Prix original" placeholder="vide = aucun"
:input-style="{ fontSize: '0.75rem' }"
title="Prix barré affiché au client. Laisser vide (ou ≤ prix marketing) = aucun prix barré.">
<template v-slot:append>
<q-icon name="local_offer" size="14px" color="orange-7" />
</template>
</q-input>
</div>
</div>
<!-- Aperçu client WYSIWYG : le prix marketing barre le prix original -->
<div v-if="Number(item.regular_price) > Number(item.rate)" class="q-mt-xs"
style="font-size:0.78rem;display:flex;align-items:center;gap:6px;flex-wrap:wrap">
<span class="text-grey-6">Aperçu client :</span>
<span style="text-decoration:line-through;color:#9ca3af">{{ Number(item.regular_price).toFixed(2) }}$</span>
<span class="text-weight-bold text-green-8">{{ Number(item.rate).toFixed(2) }}${{ item.billing === 'recurring' ? '/mois' : '' }}</span>
<q-chip dense square color="orange-1" text-color="orange-9" size="sm" style="margin:0">
{{ ((Number(item.regular_price) - Number(item.rate)) * (item.qty || 1)).toFixed(2) }}${{ item.billing === 'recurring' ? '/mois' : '' }}
</q-chip>
</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.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) }}${{ item.billing === 'recurring' ? '/mois' : '' }}
</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)
}
// installRegular / installFee : déclarés AVANT useWizardPublish car son `state` les référence
// dès la création de l'objet — sinon ReferenceError (TDZ) au setup → le wizard ne monte plus.
const installRegular = ref(0) // prix de référence barré (marketing)
const installFee = ref(0) // montant réellement financé/dû (créance)
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,
installFee, installRegular,
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('')
// Installation financée (CRTC-compliant) — alimente install_fee / install_regular du contrat.
// NB: installRegular / installFee sont déclarés plus HAUT (avant useWizardPublish) — voir TDZ.
const INSTALL_PRESETS = [
{ key: 'standard', label: 'Standard', regular: 360, fee: 240 },
{ key: 'simple', label: 'Simple', regular: 180, fee: 120 },
{ key: 'none', label: 'Aucune', regular: 0, fee: 0 },
]
function setInstallPreset (p) { installRegular.value = p.regular; installFee.value = p.fee }
function isInstallPreset (p) { return Number(installFee.value) === p.fee && Number(installRegular.value) === p.regular }
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>